/** * 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) a bare reference grants NOTHING (naming is not reading), while the repo * link of a published document opens it for whoever receives it; * (c) switching identity SWITCHES heldByHolder — it never wipes one. */ import { test, expect, mock, afterAll } from "bun:test"; import { createEntityDoc, resetRegistryCache, walletInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry"; import type { RegistrySession } from "../src/shared-wallet/account-registry"; import type { ReadCap } from "../src/model/types"; import { configure, configureStoreRegistry, resetStoreRegistry, resetConfig, capFor, getCaps, resetCaps, setCurrentUser, shareCap, } 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"; 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+/); const onlySubject = subjM ? subjM[1]! : null; const bySubject = new Map>(); 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>(); 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 = { 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 walletInbox("bob"); setCurrentUser("alice"); await shareCap(capFor(shared)!, bobInbox); // 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 walletInbox("carol")); 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 walletInbox("bob"); await shareCap(capFor(doc)!, bobInbox); setCurrentUser("bob"); const deposits = await readInbox(bobInbox); expect(deposits).toEqual([]); // infrastructure, not consumer data expect(capFor(doc)).toBeDefined(); // …but it landed in bob's held caps }); // (b) A bare reference grants nothing; the repo link of a published document does. test("(b) a bare reference reads nothing; the repo link of a published document opens it", async () => { inject(); setCurrentUser("alice"); const pub = await createEntityDoc("alice", "public"); const items = [item(pub, "u1")]; expect(getCaps().isPublished(pub)).toBe(true); const link = capFor(pub)!; // bob HAS the document's bare NURI (it is right there in `items`) and reads nothing. setCurrentUser("bob"); expect(view(items)).toEqual([]); // Receiving the repo link — what a discovery entry actually carries — opens it. getCaps().learn(link); 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"); const cap = capFor(doc); expect(cap).toBeDefined(); setCurrentUser("bob"); expect(capFor(doc)).toBeUndefined(); setCurrentUser("alice"); expect(capFor(doc)).toBe(cap!); // 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"); const cap = capFor(doc); expect(cap).toBeDefined(); // Same account, spelled differently — same shim account, so the same held caps. setCurrentUser("alice"); expect(capFor(doc)).toBe(cap!); setCurrentUser(" ALICE "); expect(capFor(doc)).toBe(cap!); // A genuinely different account still holds nothing. setCurrentUser("bob"); expect(capFor(doc)).toBeUndefined(); }); // 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 walletInbox("bob"); // Alice deposits into bob's inbox — allowed, and it grants her nothing back. await shareCap(capFor(secret)!, bobInbox); await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i); expect(capFor(secret)).toBeDefined(); // 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(capFor(secret)).toBeUndefined(); // 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(capFor(secret)).toBeDefined(); }); 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(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(capFor(doc)).toBe(stored.o as ReadCap); // same value, not two mints that agree by luck });