0832338201
Le modèle amont est explicite dans `PublicRepoLinkV0` : le lien ne porte AUCUN `read_cap`, et son commentaire dit pourquoi — *"The latest ReadCap of the branch will be downloaded from the outerOverlay, if the peer brokers listed below allow it […] the public site are served differently by brokers"* (engine/net/src/types.rs:5098). La clé n'est pas remise par un émetteur : elle est donnée par le réseau à qui la demande, parce que le broker a épinglé l'overlay externe (`expose_outer`). La bibliothèque refusait jusqu'ici la forme sans cap quel que soit le store. Sûr dans le bon sens, mais une application ne pouvait pas exprimer « fais circuler, la référence suffit » — le seul acte que le modèle rend gratuit — et son unique contournement était de distribuer la clé, ce qui détruit la confidentialité composable. `emulated-verifier/public-store.ts` émule le mécanisme SANS toucher à la garde. La possession reste l'unique critère : un document public est lisible non par exception mais parce que son cap est *obtenable*. Chaque porte de lecture demande d'abord (`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`), puis le chemin ordinaire s'applique. Lire n'est pas écrire. Ce que le store sert est un droit de LECTURE : `learnFromPublicStore` le classe à part et `assertMayWrite` refuse l'écriture dessus. Sans cela une référence nue achetait une écriture, ce qu'aucun store amont n'accorde. Autres conséquences : - `recordInPublicStore` (marquer + frapper) devient `markInPublicStore` (marquer). Frapper un second cap à côté de celui qu'on vient de télécharger donnerait deux clés différentes le jour où la constante devient un secret. - `hasCap` quitte la porte polyfill : il se lisait « ai-je le droit de lire ceci ? » et un document public y répondait `false` jusqu'à ce qu'on demande son cap. Aucun appelant hors des tests. - Les tests cross-user ne font plus traverser de cap par une variable JS : Bob n'obtient que la référence nue, comme une vraie application. Écarts documentés plutôt que masqués : le pari sur un modèle DÉCLARÉ (`expose_outer` est câblé à `false` côté client et `ExtTopicSyncReq` est `unimplemented!()`), la découverte limitée à ce qu'on sait déjà nommer, `useShape` qui n'a pas d'await à dépenser, et l'absence de `locator`. 179 tests unitaires, e2e 42/42 contre le broker en ligne.
417 lines
17 KiB
TypeScript
417 lines
17 KiB
TypeScript
/**
|
|
* ReadCap ACTIVE — end-to-end proof that the emulated SDK enforces per-DOCUMENT
|
|
* isolation, driven by per-entity documents + KEY POSSESSION.
|
|
*
|
|
* Mirrors what the app does: create an entity document through the REAL registry
|
|
* (`createEntityDoc`) — which files its cap in the creator's held caps, the emulated
|
|
* `AddRepo { read_cap }` — and, when the app decides two identities are related,
|
|
* SHARE that one document's cap to the other's inbox (`shareCap`). The recipient
|
|
* needs no dedicated operation: processing their inbox absorbs it.
|
|
*
|
|
* What the read filter then shows:
|
|
* (a) a document nobody shared is unreadable, and stays unreadable for a third
|
|
* party after a share to someone else — sharing is per-document, per-inbox;
|
|
* (b) the read-filtered VIEW decides on possession alone — it is synchronous, so it
|
|
* asks no store anything (a public store WOULD serve its cap; that is proven on
|
|
* the read paths, in `cross-user-access.test.ts`);
|
|
* (c) switching identity SWITCHES heldByHolder — it never wipes one.
|
|
*/
|
|
import { getCaps } from "../src/shared-wallet/bootstrap";
|
|
import { test, expect, mock, afterAll } from "bun:test";
|
|
import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
|
|
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
|
import type { Nuri, ReadCap } from "../src/model/types";
|
|
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,resetCaps,setCurrentUser,share} from "../src/polyfill";
|
|
import { read as readInbox } from "../src/surface/inbox";
|
|
import { filterReadable } from "../src/emulated-verifier/read-filter";
|
|
|
|
afterAll(() => {
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
});
|
|
|
|
const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
|
|
const SHIM = "urn:ng-eventually:shim";
|
|
const INBOX = "urn:ng-eventually:inbox";
|
|
|
|
/** Possession, asked of the internal registry — see `polyfill.ts` on why the door
|
|
* stopped publishing it. */
|
|
function hasCap(nuri: Nuri): boolean {
|
|
return getCaps().capFor(nuri) !== undefined;
|
|
}
|
|
|
|
interface Quad { g: string; s: string; p: string; o: string }
|
|
|
|
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
|
|
function unescapeLiteral(s: string): string {
|
|
let out = "";
|
|
for (let i = 0; i < s.length; i++) {
|
|
if (s[i] === "\\" && i + 1 < s.length) {
|
|
const next = s[++i];
|
|
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
|
|
} else out += s[i];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** A stateful fake `ng` serving BOTH the shim SPARQL and the inbox SPARQL. */
|
|
function makeFakeNg() {
|
|
const quads: Quad[] = [];
|
|
let docCounter = 0;
|
|
|
|
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
|
|
|
const sparql_update = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const anchor = a[2] as string | undefined;
|
|
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
|
let g: string;
|
|
let body: string;
|
|
if (gm) {
|
|
g = gm[1]!;
|
|
body = gm[2]!;
|
|
} else {
|
|
if (!anchor) return undefined;
|
|
g = anchor;
|
|
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
|
}
|
|
const sm = body.match(/<([^>]+)>/);
|
|
if (!sm) return undefined;
|
|
const s = sm[1]!;
|
|
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
|
let m: RegExpExecArray | null;
|
|
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
|
while ((m = pairRe.exec(after)) !== null) {
|
|
const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
|
|
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
|
quads.push({ g, s, p, o });
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const sparql_query = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const anchor = a[3] as string | undefined;
|
|
// Pointer SELECT (store-root → doc-shim).
|
|
if (query.includes(`<${SHIM}:shimDoc>`)) {
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`)
|
|
.map((q) => ({ shimDoc: { value: q.o } }));
|
|
return { results: { bindings } };
|
|
}
|
|
// Account SELECT.
|
|
if (query.includes(`<${SHIM}:id>`)) {
|
|
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
|
const onlySubject = subjM ? subjM[1]! : null;
|
|
const bySubject = new Map<string, Record<string, string>>();
|
|
for (const q of quads) {
|
|
if (q.g !== anchor) continue;
|
|
if (onlySubject !== null && q.s !== onlySubject) continue;
|
|
const rec = bySubject.get(q.s) ?? {};
|
|
if (q.p === `${SHIM}:id`) rec.id = q.o;
|
|
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
|
|
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
|
|
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
|
|
bySubject.set(q.s, rec);
|
|
}
|
|
const bindings = [...bySubject.values()]
|
|
.filter((r) => r.id)
|
|
.map((r) => ({
|
|
id: { value: r.id! },
|
|
docPublic: { value: r.docPublic ?? "" },
|
|
docProtected: { value: r.docProtected ?? "" },
|
|
docPrivate: { value: r.docPrivate ?? "" },
|
|
}));
|
|
return { results: { bindings } };
|
|
}
|
|
// Inbox deposit SELECT.
|
|
if (query.includes(`<${INBOX}:payload>`)) {
|
|
const bySubject = new Map<string, Record<string, string>>();
|
|
for (const q of quads) {
|
|
if (q.g !== anchor) continue;
|
|
const rec = bySubject.get(q.s) ?? {};
|
|
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
|
|
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
|
|
if (q.p === `${INBOX}:from`) rec.from = q.o;
|
|
bySubject.set(q.s, rec);
|
|
}
|
|
const bindings = [...bySubject.values()]
|
|
.filter((r) => r.payload !== undefined && r.ts !== undefined)
|
|
.map((r) => {
|
|
const row: Record<string, { value: string }> = {
|
|
payload: { value: r.payload! },
|
|
ts: { value: r.ts! },
|
|
};
|
|
if (r.from !== undefined) row.from = { value: r.from };
|
|
return row;
|
|
});
|
|
return { results: { bindings } };
|
|
}
|
|
// User-branch `link` SELECT (the emulated AddLink records).
|
|
// User-branch `inboxCap` SELECT (the emulated AddInboxCap records).
|
|
if (query.includes(`<${SHIM}:inboxCap>`)) {
|
|
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
|
|
}
|
|
// Store-branch `readCap` SELECT (the emulated AddRepo records).
|
|
if (query.includes(`<${SHIM}:readCap>`)) {
|
|
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
|
|
}
|
|
if (query.includes(`<${SHIM}:link>`)) {
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor && q.p === `${SHIM}:link`)
|
|
.map((q) => ({ c: { value: q.o } }));
|
|
return { results: { bindings } };
|
|
}
|
|
// Scope-index `contains` SELECT.
|
|
if (query.includes(`<${SHIM}:contains>`)) {
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`)
|
|
.map((q) => ({ e: { value: q.o } }));
|
|
return { results: { bindings } };
|
|
}
|
|
return { results: { bindings: [] } };
|
|
});
|
|
|
|
return { doc_create, sparql_update, sparql_query, _quads: quads };
|
|
}
|
|
|
|
function inject(normalizeId: (id: string) => string = (id) => id.trim()) {
|
|
const ng = makeFakeNg();
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({ getSession: async () => SESSION, normalizeId });
|
|
resetRegistryCache();
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
return ng;
|
|
}
|
|
|
|
/** The items an ORM set would carry, one per document. */
|
|
const item = (doc: string, id: string) => ({ "@graph": doc, "@id": id });
|
|
/** What the current holder reads out of `items`. */
|
|
const view = (items: Array<{ "@graph": string; "@id": string }>) =>
|
|
filterReadable(items, getCaps()).map((i) => i["@id"]).sort();
|
|
|
|
|
|
test("a created document is readable by its creator and by nobody else", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const aliceDoc = await createEntityDoc("alice", "private");
|
|
setCurrentUser("bob");
|
|
const bobDoc = await createEntityDoc("bob", "private");
|
|
|
|
const items = [item(aliceDoc, "a1"), item(bobDoc, "b1")];
|
|
|
|
setCurrentUser("alice");
|
|
expect(view(items)).toEqual(["a1"]);
|
|
setCurrentUser("bob");
|
|
expect(view(items)).toEqual(["b1"]);
|
|
setCurrentUser(null);
|
|
expect(view(items)).toEqual([]); // anonymous holds nothing
|
|
expect(getCaps().isEnforcing()).toBe(true);
|
|
});
|
|
|
|
// (a) Sharing is per-document AND per-recipient: a share to bob leaves carol out.
|
|
test("(a) sharing one document's cap to ONE inbox reveals it there, and only there", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const shared = await createEntityDoc("alice", "protected");
|
|
const kept = await createEntityDoc("alice", "protected");
|
|
const items = [item(shared, "s1"), item(kept, "k1")];
|
|
|
|
// BEFORE the share: bob reads nothing of alice's.
|
|
setCurrentUser("bob");
|
|
expect(view(items)).toEqual([]);
|
|
|
|
// The app decides alice↔bob are related: alice shares ONE document's cap into
|
|
// bob's OWN inbox — the only cross-wallet act there is.
|
|
const bobInbox = await userInbox("bob", "protected");
|
|
setCurrentUser("alice");
|
|
await share(shared, "bob");
|
|
|
|
// bob processes his inbox — no dedicated "receive" operation exists.
|
|
setCurrentUser("bob");
|
|
await readInbox(bobInbox);
|
|
expect(view(items)).toEqual(["s1"]); // the shared one only — not `kept`
|
|
|
|
// carol, who was not shared with, still reads nothing.
|
|
setCurrentUser("carol");
|
|
await readInbox(await userInbox("carol", "protected"));
|
|
expect(view(items)).toEqual([]);
|
|
});
|
|
|
|
test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "protected");
|
|
const bobInbox = await userInbox("bob", "protected");
|
|
await share(doc, "bob");
|
|
|
|
setCurrentUser("bob");
|
|
const deposits = await readInbox(bobInbox);
|
|
expect(deposits).toEqual([]); // infrastructure, not consumer data
|
|
expect(hasCap(doc)).toBe(true); // …but it landed in bob's held caps
|
|
});
|
|
|
|
// (b) The ORM read filter is PURE POSSESSION — it asks nothing of anyone.
|
|
//
|
|
// Note what this does NOT say: that a bare reference to a public document is
|
|
// unreadable. It is readable, through the read paths, because a public store serves
|
|
// its cap (`emulated-verifier/public-store.ts`, and `cross-user-access.test.ts` proves
|
|
// it). This filter sits below that: it is synchronous, it decides from what the holder
|
|
// holds AT THAT MOMENT, and a document whose cap was never obtained is filtered out
|
|
// whatever store it sits in. The library's own read paths ask first; the reactive ORM
|
|
// view has no door to ask through, and that limit is recorded in `read-filter.ts`.
|
|
test("(b) the read-filtered view decides on possession alone, with no lookup", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const pub = await createEntityDoc("alice", "public");
|
|
const items = [item(pub, "u1")];
|
|
expect(getCaps().isInPublicStore(pub)).toBe(true);
|
|
const cap = getCaps().capFor(pub)!;
|
|
|
|
// bob HAS the document's bare NURI (it is right there in `items`), holds no cap for
|
|
// it, and the view drops it — no question asked of any store.
|
|
setCurrentUser("bob");
|
|
expect(view(items)).toEqual([]);
|
|
|
|
// Once the cap IS among what he holds — however it got there — the same view yields it.
|
|
getCaps().learn(cap);
|
|
expect(view(items)).toEqual(["u1"]);
|
|
});
|
|
|
|
// (c) Identity change switches heldByHolder; it does not wipe them.
|
|
test("(c) switching identity switches heldByHolder — a returning identity keeps its caps", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "protected");
|
|
expect(hasCap(doc)).toBe(true);
|
|
|
|
setCurrentUser("bob");
|
|
expect(hasCap(doc)).toBe(false);
|
|
|
|
setCurrentUser("alice");
|
|
expect(hasCap(doc)).toBe(true); // durable across the switch — nothing re-declared
|
|
});
|
|
|
|
// A virtual user IS a shim account, and the shim keys accounts through the
|
|
// consumer's `normalizeId`. The held caps must key the SAME way: otherwise an app
|
|
// that spells its own identity differently between two calls ("@Alice" at login,
|
|
// "alice" later) gets a second held caps and stops reading its own documents.
|
|
test("one held caps per virtual WALLET, not per spelling of its id", async () => {
|
|
inject((id) => id.trim().replace(/^@+/, "").toLowerCase());
|
|
|
|
setCurrentUser("@Alice");
|
|
const doc = await createEntityDoc("@Alice", "protected");
|
|
expect(hasCap(doc)).toBe(true);
|
|
|
|
// Same account, spelled differently — same shim account, so the same held caps.
|
|
setCurrentUser("alice");
|
|
expect(hasCap(doc)).toBe(true);
|
|
setCurrentUser(" ALICE ");
|
|
expect(hasCap(doc)).toBe(true);
|
|
|
|
// A genuinely different account still holds nothing.
|
|
setCurrentUser("bob");
|
|
expect(hasCap(doc)).toBe(false);
|
|
});
|
|
|
|
// THE BREACH P1a OPENED. Caps travel as inbox deposits, so an unguarded inbox read
|
|
// let anyone who knew an inbox NURI collect the caps addressed to its owner —
|
|
// defeating directed sharing entirely. Depositing stays open (it is the only way a
|
|
// link crosses between wallets at all); reading does not.
|
|
test("an inbox may be DEPOSITED into by anyone, and READ only by its owner", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const secret = await createEntityDoc("alice", "protected");
|
|
const bobInbox = await userInbox("bob", "protected");
|
|
|
|
// Alice deposits into bob's inbox — allowed, and it grants her nothing back.
|
|
await share(secret, "bob");
|
|
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
|
|
expect(hasCap(secret)).toBe(true); // still hers, obviously
|
|
|
|
// Mallory knows the NURI of bob's inbox and tries to pocket what is in it.
|
|
setCurrentUser("mallory");
|
|
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
|
|
expect(hasCap(secret)).toBe(false); // nothing was absorbed
|
|
|
|
// Anonymous owns no inbox at all.
|
|
setCurrentUser(null);
|
|
await expect(readInbox(bobInbox)).rejects.toThrow(/no identity is set/i);
|
|
|
|
// Bob reads his own, and only then does the cap land.
|
|
setCurrentUser("bob");
|
|
await readInbox(bobInbox);
|
|
expect(hasCap(secret)).toBe(true);
|
|
});
|
|
|
|
test("a fresh session rebuilds the held caps from the scope index (the emulated AddRepo)", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "protected");
|
|
const items = [item(doc, "p1")];
|
|
|
|
// Simulate a new session over the same wallet: caps are in memory, so they go —
|
|
// the registry cache too. Only the persisted documents remain.
|
|
resetCaps();
|
|
resetRegistryCache();
|
|
expect(view(items)).toEqual([]);
|
|
|
|
// Listing my own documents refiles their caps: this is the store branch that
|
|
// carries `AddRepo { read_cap }` upstream.
|
|
const { listMyEntityDocs } = await import("../src/shared-wallet/account-registry");
|
|
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
|
|
expect(view(items)).toEqual(["p1"]);
|
|
});
|
|
|
|
// The Store branch exists so a cap is READ back, not recomputed. Without this test
|
|
// the two are indistinguishable: with a stand-in value, re-minting happens to give
|
|
// the same string. So corrupt the stored cap and check the corruption wins — proof
|
|
// the value comes from the store, and proof that P1b's real key will too.
|
|
test("a document's cap is READ from the Store branch, never recomputed", async () => {
|
|
const ng = inject();
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "protected");
|
|
|
|
// The store recorded `AddRepo { read_cap }` beside the `contains` listing.
|
|
const stored = ng._quads.filter((q) => q.p === "urn:ng-eventually:shim:readCap");
|
|
expect(stored.length).toBe(1);
|
|
expect(stored[0]!.o).toBe(`${doc}:r:OK`);
|
|
|
|
// Rewrite it to a DIFFERENT value, then start a fresh session.
|
|
stored[0]!.o = `${doc}:r:FROM-THE-STORE`;
|
|
resetCaps();
|
|
resetRegistryCache();
|
|
|
|
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
|
|
// Recomputing would have produced `:r:OK`; this is what was stored.
|
|
expect(getCaps().capFor(doc)).toBe(`${doc}:r:FROM-THE-STORE` as ReadCap);
|
|
});
|
|
|
|
// The listing and the keys are separate upstream (Main vs Store branch), and the
|
|
// separation has to survive here or a document could be listed without its cap.
|
|
test("the listing and the caps are two separate records", async () => {
|
|
const ng = inject();
|
|
setCurrentUser("alice");
|
|
await createEntityDoc("alice", "private");
|
|
|
|
const subjects = new Set(ng._quads.filter((q) => q.p.startsWith("urn:ng-eventually:shim:")).map((q) => q.s));
|
|
expect(subjects.has("urn:ng-eventually:shim:index")).toBe(true); // Main branch: contains
|
|
expect(subjects.has("urn:ng-eventually:shim:storeBranch")).toBe(true); // Store branch: readCap
|
|
});
|
|
|
|
// P1b will make the stand-in value a real, non-derivable key. The moment it does,
|
|
// any path that mints a SECOND cap instead of using the stored one breaks: the
|
|
// creator would hold a key that does not open its own document. This pins that the
|
|
// creation path mints exactly once.
|
|
test("creation mints the cap ONCE — the stored value is the one held", async () => {
|
|
const ng = inject();
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "protected");
|
|
|
|
const stored = ng._quads.find((q) => q.p === "urn:ng-eventually:shim:readCap")!;
|
|
expect(getCaps().capFor(doc)).toBe(stored.o as ReadCap); // same value, not two mints that agree by luck
|
|
});
|