cb56f91b5e
La règle d'accès est « qui a la référence ET la clé lit » — jamais « qui a la référence lit ». Il n'y a pas de tiers dans cette phrase : le moteur ne vérifie une permission qu'à l'ÉCRITURE, jamais à la lecture. Le contenu est chiffré, la clé EST le droit. C'est ce que veut dire local-first ici : il n'y a personne à qui demander, donc la possession est tout le mécanisme. Ce que cette règle achète, et qui n'était consigné nulle part : une référence n'accordant rien, **elle n'est pas récursive**. Un document largement diffusé peut pointer vers un document restreint — la référence dit qu'il existe, la clé dit qui le lit. D'où la confidentialité COMPOSABLE : un sommaire diffusé qui renvoie à des chapitres restreints, un événement public qui renvoie à sa liste de participants. L'auteur diffuse un document et décide encore, document référencé par document référencé, qui reçoit la clé. `linkTo` détruisait cette construction. Il rendait la CLÉ là où un appelant demande une référence, transformant la règle en « qui a la référence lit » — pour ce document et pour tout ce qu'il MENTIONNE. Ajouté et retiré le même jour. Documenté en §0, avant tout le reste, avec la raison pour laquelle un agent perd ce point avec constance : les réflexes client-serveur fournissent la moitié manquante sans qu'on s'en aperçoive — quelque part un serveur vérifierait, un lien serait un identifiant inoffensif, « Bob a-t-il le droit ? » aurait une réponse. Aucun des trois n'est vrai ici, et du code écrit là-dessus n'échoue pas : il accorde en silence. Rien ne passe au rouge. Renommé au passage : `publishRepoLink`/`isPublished` → `recordInPublicStore`/ `isInPublicStore`. Ces méthodes n'ont pas de pendant amont et « publier » ne désigne rien de précis ici. 171 tests unitaires, e2e 42/42 en 3,6 min.
401 lines
16 KiB
TypeScript
401 lines
16 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) 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 { 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 { ReadCap } from "../src/model/types";
|
|
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,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";
|
|
|
|
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) 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().isInPublicStore(pub)).toBe(true);
|
|
const link = getCaps().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");
|
|
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
|
|
});
|