diff --git a/docs/simulation.md b/docs/simulation.md index 3e6c287..0ab2e96 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -194,19 +194,33 @@ calls; the consumer never touches the registry internals: consumer creates it: `public` → world-readable; `protected`/`private` → owner reads, owner holds the write cap. `open` now also **remembers** `(scope, owner)` per document so a later connection-driven grant can find the protected ones. -- **`declareConnections(connections)` (`polyfill.ts`)** — the SDK-shaped - **protected sharing act**. The consumer hands its social graph (a `Connections`: - who-is-connected-to-whom) and the SDK issues, for every **protected** document, - that document's read cap to the owner's direct connections - (`CapRegistry.grantReadToConnections`). Public docs stay world-readable; private - docs stay owner-only. Re-callable whenever the graph changes; additive and - idempotent. The consumer passes only principals — no document NURI, no store id. +- **`declareConnections(peers, as?)` (`polyfill.ts`)** — the SDK-shaped + **protected sharing act**, now **AUTHENTICATED / BILATERAL** (`connections.ts`). + Each call declares the CURRENT identity's OWN peers (`as` defaults to + `getCurrentUser()`); the lib records that as a **directed assertion authored by + the current identity** — a session can only ever assert its own side. A protected + read cap is issued between two principals only when **both have asserted the + other** (a materialized two-sided link, `ConnectionRegistry.neighbors` → + `CapRegistry.grantReadToConnections`). Public docs stay world-readable; private + docs stay owner-only. Re-callable; additive + idempotent. The consumer passes + only principals — no document NURI, no store id. + + **Why bilateral (adversarial finding).** If a single directed assertion granted + access, any reader could read any owner's protected documents by unilaterally + self-declaring a connection. The two-sided requirement is the emulation of the + target's mutual capability exchange: only a reciprocated link grants the cap. A + unilateral / self-declared connection grants **nothing** (proven in + `test/connections.test.ts` and `test/isolation-active.test.ts` case (b)). The result is the target's discrimination reproduced end-to-end: **private** → -owner; **protected** → owner + connections; **public** → all. Proven in -`test/isolation-active.test.ts` (an unconnected principal is denied a protected -document, granted it after `declareConnections`, and reads the public document -throughout). +owner; **protected** → owner + BILATERAL connections; **public** → all. Proven in +`test/isolation-active.test.ts`: (a) an unconnected principal is denied a protected +document, granted it after a two-sided `declareConnections`, and reads the public +document throughout; (b) a unilateral/self-declared connection is denied. + +This discrimination is only observable because each entity is **its own document** +(the consumer creates per-entity docs via `createEntityDoc` and `open`s each) — in +a mono-store layout the per-document ReadCap is all-or-nothing. ### Write-guard coverage (honest scope) @@ -220,25 +234,24 @@ through the public proxy, but the consumer's real write paths bypass it and are (the write guard becomes effective only when the broker/verifier enforces caps natively at migration); the READ side is what makes isolation observably active. -### Emulated ReadCap ≠ application isolation — they COEXIST +### The per-document ReadCap is now THE isolation path (item-level filter retired) -`isolation.ts` is a **separate, deliberately non-merged** axis: +Isolation is enforced by the **per-document ReadCap** (`caps.ts` + `read-filter.ts`) +alone: the access unit is the DOCUMENT (`@graph` = repo), grants are explicit +(`open` / `grantRead` / `makePublic`) and, for `protected`, driven by the +**bilateral connection registry** (`connections.ts`). Because the consumer now +writes **one document per entity** (`createEntityDoc` + `open` per entity), the +per-document cap discriminates at entity granularity — the target's behaviour. -| | ReadCap (`caps.ts` + `read-filter.ts`) | isolation (`isolation.ts`) | -|---|---|---| -| Unit | the DOCUMENT (`@graph` = repo) | the ITEM / record | -| Question | does the principal HOLD this doc's read cap? | given WHO is connected to WHOM, may this principal see it? | -| Models | NextGraph's native capability delivery (broker-enforced) | an application social-visibility policy, above the doc layer | -| Grants | explicit, per-document (`grantRead` / `makePublic`) | implicit, from the connection graph + item scope | - -`isolation.ts` honors a visibility matrix (public = everyone; protected = owner + -direct connections; private = owner only) with **pure** functions — no NextGraph, -no React, zero domain. The consumer injects the connection graph (`Connections`) -and the `ownerOf`/`scopeOf` accessors. The connection-derived `protected` -visibility has no equivalent in the per-document cap model, so the two are not -redundant. Each is a removable scaffold that disappears against a different piece -of real infra (caps → native ReadCaps; isolation → real per-account social graph -+ per-account wallets). +The old **item-level application-visibility filter** (`isolation.ts` +`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is **retired** +from the consumer path: the app carries **no** access logic — it declares its +identity and its bilateral connections and trusts the SDK. `isolation.ts` survives +only as the home of the generic `Connections` interface (consumed by +`connections.ts` / `caps.grantReadToConnections`) plus its own unit tests; its +matrix functions are dead scaffolding kept for reference and removed at migration. +There is no longer a second, coexisting app-layer filter to reconcile — the single +axis is the per-document cap, exactly as in the target. ## Emulated inbox + curator (`inbox.ts`) @@ -254,12 +267,17 @@ fork the broker ([`fork-inbox-fallback.md`](./fork-inbox-fallback.md)), the lib emulated in-lib. - **`post(targetInbox, opts)`** appends a deposit `{ from, payload, ts }` as RDF into the inbox DOCUMENT (in the shared wallet) via `docs.sparqlUpdate`. Each - deposit is a unique RDF subject → concurrent deposits don't collide. `from` is - optional: pass `null` for an ANONYMOUS deposit; omit it to default to the - current polyfill user (`getCurrentUser`). This reproduces the protocol's - "identified if known, anonymous otherwise" — though the emulation stores - `from = null` as *absence of a triple*, it does not provide the target's + deposit is a unique RDF subject → concurrent deposits don't collide. **`from` is + BOUND to the current identity** (`getCurrentUser`) — it is authenticated, not + caller-supplied: omit it to stamp the current user, pass `null` to deposit + ANONYMOUSLY, and a `from` naming ANOTHER principal is **rejected as a spoof**. + This reproduces the protocol's "identified if known, anonymous otherwise" AND + the target's guarantee that a client cannot forge another's sender identity (in + the target the broker seals `from` from the wallet's own key; here the check + closes the spoof the shared wallet would otherwise allow). The emulation stores + `from = null` as *absence of a triple*, so it does not provide the target's **crypto** anonymity (`from = None` sealed), which only a native inbox would. + Proven in `test/inbox.test.ts` case (c). - **`read` / `materialize` (alias)** play the **emulated CURATOR**: they read the deposits back via `docs.sparqlQuery`, JSON-parse each payload, sort by `ts`. - **`watch(targetInbox, onDeposits, { intervalMs })`** is the emulated watcher: it @@ -297,9 +315,14 @@ into entries. Materialization is the natural **dedup / moderation point**. shared index. - **`submitToIndex(ref, opts?)`** — the SDK act "make this discoverable". Deposits `ref` into the index document's inbox via `inbox.post`. `from` follows - the inbox convention (anonymous when `null`). `ref` is **opaque** here — the - consumer serializes whatever locates the entity (e.g. an entity document NURI + - discovery metadata). + the inbox convention (bound to the current identity; anonymous when `null`). + `ref` is **opaque** here — the consumer serializes whatever locates the entity + (e.g. an entity document NURI + discovery metadata). **PUBLIC-ONLY guard:** when + `opts.doc` names the document being surfaced, a document under a non-public + (protected/private) read policy is **REFUSED** (`caps.governsRead(doc) && + !caps.canRead(doc, null)`) — the global index is world-readable, so admitting a + governed doc's NURI would leak it past its scope. Proven in + `test/discovery.test.ts` case (d). - **`readIndex()`** — the EMULATED CURATOR. Reads every submission, **dedups by serialized `ref`** (the moderation point: a duplicate submission surfaces once), returns entries sorted by `ts`. `watchIndex(onEntries, opts?)` is the diff --git a/packages/client/src/connections.ts b/packages/client/src/connections.ts new file mode 100644 index 0000000..c32b0ab --- /dev/null +++ b/packages/client/src/connections.ts @@ -0,0 +1,90 @@ +/** + * connections — a BILATERAL (two-sided, authenticated) connection registry. + * + * STOPGAP / polyfill-era. In the target, "connected" means the two wallets have + * *each* issued the other a capability (a mutual, cryptographically-authenticated + * link). A single side cannot manufacture the relationship. Here — one shared + * wallet, everything physically readable — the registry reproduces that property + * as data: a connection between `a` and `b` is materialized ONLY when BOTH sides + * have asserted it. + * + * ── Why bilateral (the adversarial finding this defends) ────────────────────── + * "protected = owner + connections" must not be bypassable by a reader who simply + * self-declares a connection to the owner. If a single directed assertion granted + * access, any principal could read any owner's protected documents by unilaterally + * claiming a link. So the registry keeps DIRECTED assertions and exposes as + * `neighbors(p)` only the principals `q` for which BOTH `assert(p → q)` AND + * `assert(q → p)` are present — the materialized two-sided link. + * + * ── Generic by construction ─────────────────────────────────────────────────── + * Knows no application domain. The consumer maps its own relationship (accepted + * friendships, follows-back, …) onto directed assertions: each side asserts the + * other. Only when both assertions exist is the link live and does it drive a + * protected read grant (via {@link CapRegistry.grantReadToConnections}). Removed + * at migration, where a real mutual capability replaces the materialized link. + */ + +import type { Connections } from "./isolation"; +import type { PrincipalId } from "./types"; + +/** + * One directed connection assertion: `from` asserts a connection to `to`. A link + * is live (and grants protected read) only when the reverse assertion also + * exists. The consumer's own social graph is fed as these directed assertions. + */ +export interface ConnectionAssertion { + from: PrincipalId; + to: PrincipalId; +} + +/** + * Accumulates directed assertions and exposes the BILATERAL neighbourhood. Both + * `assert(a → b)` and `assert(b → a)` must be present for `a`/`b` to be neighbours. + */ +export class ConnectionRegistry { + /** principal → the set of principals it has asserted a connection TO. */ + private asserted = new Map>(); + + /** Record that `from` asserts a connection to `to` (one direction only). */ + assert(from: PrincipalId, to: PrincipalId): void { + if (!from || !to || from === to) return; + let s = this.asserted.get(from); + if (!s) this.asserted.set(from, (s = new Set())); + s.add(to); + } + + /** Record a batch of directed assertions. */ + assertAll(assertions: Iterable): void { + for (const { from, to } of assertions) this.assert(from, to); + } + + /** Has `from` asserted a connection to `to` (one direction)? */ + hasAsserted(from: PrincipalId, to: PrincipalId): boolean { + return this.asserted.get(from)?.has(to) ?? false; + } + + /** + * The BILATERAL neighbours of `principal`: every `q` such that `principal` and + * `q` have each asserted the other. A unilateral (one-sided) assertion yields + * NO neighbour — the defence against self-declared connections. + */ + neighbors(principal: PrincipalId): Set { + const out = new Set(); + const outgoing = this.asserted.get(principal); + if (!outgoing) return out; + for (const to of outgoing) if (this.hasAsserted(to, principal)) out.add(to); + return out; + } + + clear(): void { + this.asserted.clear(); + } +} + +/** + * Adapt a {@link ConnectionRegistry} to the {@link Connections} interface consumed + * by {@link CapRegistry.grantReadToConnections}. Only bilateral links surface. + */ +export function bilateralConnections(registry: ConnectionRegistry): Connections { + return { neighbors: (principal) => registry.neighbors(principal) }; +} diff --git a/packages/client/src/discovery.ts b/packages/client/src/discovery.ts index 163d8ab..53d27f2 100644 --- a/packages/client/src/discovery.ts +++ b/packages/client/src/discovery.ts @@ -41,6 +41,7 @@ import * as inbox from "./inbox"; import { ensureAccount, reservedAccount } from "./store-registry"; +import { getCaps } from "./polyfill"; import type { Nuri, PrincipalId } from "./types"; /** @@ -66,11 +67,19 @@ export interface IndexEntry { /** Options for {@link submitToIndex}. */ export interface SubmitOptions { /** - * Who is submitting. Omit (or pass `null`) for an ANONYMOUS submission; pass a - * principal id to identify the submitter. Defaults to the current polyfill user - * when the property is entirely absent (mirrors {@link inbox.post}). + * Who is submitting. Omit for the current identity, or pass `null` for an + * ANONYMOUS submission. `from` is BOUND to the current identity by the inbox + * (naming another principal is rejected as a spoof — see {@link inbox.post}). */ from?: PrincipalId | null; + /** + * The NURI of the document being made discoverable. When given, the index + * enforces PUBLIC-ONLY: a document under a non-public (protected/private) read + * policy is REFUSED — the public index must never leak a governed document's + * NURI. Omit it only for a ref with no addressable document (rare); a governed + * doc always passes it so the guard can fire. + */ + doc?: Nuri; /** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it * keeps tests deterministic. */ ts?: number; @@ -100,8 +109,23 @@ async function indexInboxNuri(): Promise { * entry. GENERIC: `ref` is opaque here (the consumer serializes whatever a * client needs to later locate the entity — e.g. an entity document NURI plus * discovery metadata). `from` follows the inbox convention (anonymous if `null`). + * + * PUBLIC-ONLY: when `opts.doc` names the document being surfaced, a document under + * a non-public read policy (protected/private) is REFUSED — the global index is + * world-readable, so admitting a governed doc's NURI would leak it past its scope. */ export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise { + const doc = opts?.doc; + if (doc !== undefined) { + const caps = getCaps(); + // A governed doc is submittable ONLY if it is public (anonymous may read it). + if (caps.governsRead(doc) && !caps.canRead(doc, null)) { + throw new Error( + "[ng-eventually] submitToIndex: only PUBLIC documents may be submitted to " + + "the discovery index — a protected/private document must not be surfaced.", + ); + } + } const target = await indexInboxNuri(); await inbox.post(target, { payload: ref, diff --git a/packages/client/src/inbox.ts b/packages/client/src/inbox.ts index 2e7a35d..b715d55 100644 --- a/packages/client/src/inbox.ts +++ b/packages/client/src/inbox.ts @@ -90,11 +90,31 @@ function readBindings(result: unknown): Array> * * Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate` * (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox - * graph, so concurrent deposits don't collide. `from` is optional: pass `null` - * for an anonymous deposit; omit it entirely to default to the current user. + * graph, so concurrent deposits don't collide. + * + * `from` is BOUND TO THE CURRENT IDENTITY — it is authenticated, not + * caller-supplied. Omit it to stamp the current user; pass `null` to deposit + * ANONYMOUSLY (a legitimate choice — "identified if known, anonymous otherwise"). + * A `from` naming ANOTHER principal is a SPOOF and is REJECTED: in the target the + * broker seals the sender from the wallet's own key, so a client cannot forge + * another's identity. (At migration this check is redundant — the seal enforces + * it — but until then it closes the spoof the shared wallet would otherwise allow.) */ export async function post(targetInbox: Nuri, opts: PostOptions): Promise { - const from = opts.from === undefined ? getCurrentUser() : opts.from; + const current = getCurrentUser(); + let from: PrincipalId | null; + if (opts.from === undefined) { + from = current; // default: stamp the current identity + } else if (opts.from === null) { + from = null; // explicit anonymous deposit + } else if (opts.from === current) { + from = opts.from; // identifying as self — allowed + } else { + throw new Error( + "[ng-eventually] inbox.post: `from` must be the current identity or null " + + "(anonymous) — depositing as another principal is a spoof.", + ); + } const ts = opts.ts ?? Date.now(); const sid = await sessionId(); diff --git a/packages/client/src/polyfill.ts b/packages/client/src/polyfill.ts index d7b8f3d..c410495 100644 --- a/packages/client/src/polyfill.ts +++ b/packages/client/src/polyfill.ts @@ -11,7 +11,7 @@ import type { NgLike, UseShapeLike, PrincipalId } from "./types"; import type { RegistrySession } from "./store-registry"; import { CapRegistry } from "./caps"; -import type { Connections } from "./isolation"; +import { ConnectionRegistry, bilateralConnections } from "./connections"; /** * Consumer-injected dependencies of the storeRegistry (polyfill-era). The @@ -47,6 +47,9 @@ let registryDeps: Required | null = null; /** The emulated ReadCap/WriteCap registry. Empty until the app declares caps; * while it has no read policy the read filter passes through (no regression). */ let caps = new CapRegistry(); +/** The emulated BILATERAL connection registry. Accumulates directed assertions + * (each authored by the asserting identity); only two-sided links materialize. */ +let connectionRegistry = new ConnectionRegistry(); export function configure(c: EventuallyConfig): void { cfg = c; @@ -108,24 +111,44 @@ export function getCaps(): CapRegistry { } /** - * Declare the current session's CONNECTIONS to the SDK — the domain sharing act - * "a protected document is readable by its owner AND that owner's connections". - * The consumer knows who is connected to whom (its own social graph) and hands - * that graph to the SDK; the SDK issues the corresponding read access on every - * protected document it governs (public stays world-readable, private stays - * owner-only). Re-call whenever the connection graph changes. + * Declare the CURRENT identity's own connections to the SDK — the domain sharing + * act "a protected document is readable by its owner AND that owner's connections". * - * SDK-shaped: the consumer passes a {@link Connections} (who-is-connected-to-whom) - * and gets access enforcement — it never touches a document NURI, a store id, or - * the cap registry internals. + * AUTHENTICATED / BILATERAL. Each entry in `peers` is a principal the CURRENT user + * (`getCurrentUser()`, or an explicit `as`) asserts a connection to. The lib + * records that assertion as authored BY the current identity — a session can only + * ever assert its OWN side. A protected read is granted between two principals only + * when BOTH have asserted the other (a materialized two-sided link). So a reader + * who unilaterally self-declares a connection to an owner gets NOTHING: the owner + * never asserted them back. Public stays world-readable; private stays owner-only. + * Re-callable whenever the connection graph changes (additive + idempotent). + * + * SDK-shaped: the consumer passes principals only — never a document NURI, a store + * id, or the cap registry internals. `as` names the asserting identity explicitly + * (defaults to the current user); the consumer normally omits it. */ -export function declareConnections(connections: Connections): void { - caps.grantReadToConnections((owner) => connections.neighbors(owner)); +export function declareConnections(peers: Iterable, as?: PrincipalId): void { + const self = as ?? currentUser; + if (self) for (const peer of peers) connectionRegistry.assert(self, peer); + // Re-derive protected grants from the CURRENT bilateral view (only two-sided + // links surface as neighbours). Idempotent: grants only ever accumulate. + caps.grantReadToConnections((owner) => connectionRegistry.neighbors(owner)); +} + +/** @internal — the bilateral connection registry (mainly for tests / adapters). */ +export function getConnectionRegistry(): ConnectionRegistry { + return connectionRegistry; +} + +/** The current bilateral connection view (only two-sided links surface). */ +export function getConnections() { + return bilateralConnections(connectionRegistry); } /** Reset all emulated caps (mainly for tests / fresh sessions). */ export function resetCaps(): void { caps = new CapRegistry(); + connectionRegistry = new ConnectionRegistry(); } // Cap surface — polyfill-era (caps are emulated now; native at migration). diff --git a/packages/client/test/connections.test.ts b/packages/client/test/connections.test.ts new file mode 100644 index 0000000..f3d5089 --- /dev/null +++ b/packages/client/test/connections.test.ts @@ -0,0 +1,60 @@ +/** + * ConnectionRegistry — BILATERAL connection materialization (T03.h). + * + * A connection is live only when BOTH sides have asserted the other. A unilateral + * (self-declared) assertion yields no neighbour — the defence against a reader who + * fakes a connection to an owner to read that owner's protected documents. + */ +import { test, expect } from "bun:test"; +import { ConnectionRegistry, bilateralConnections } from "../src/connections"; + +test("a UNILATERAL assertion yields NO neighbour", () => { + const reg = new ConnectionRegistry(); + reg.assert("mallory", "alice"); // mallory self-declares; alice never asserts back + expect([...reg.neighbors("mallory")]).toEqual([]); + expect([...reg.neighbors("alice")]).toEqual([]); +}); + +test("a BILATERAL assertion (both sides) materializes the link", () => { + const reg = new ConnectionRegistry(); + reg.assert("alice", "bob"); + reg.assert("bob", "alice"); + expect([...reg.neighbors("alice")]).toEqual(["bob"]); + expect([...reg.neighbors("bob")]).toEqual(["alice"]); +}); + +test("mixed: only the reciprocated peers surface", () => { + const reg = new ConnectionRegistry(); + reg.assert("alice", "bob"); // reciprocated below + reg.assert("bob", "alice"); + reg.assert("alice", "carol"); // NOT reciprocated by carol + reg.assert("dave", "alice"); // dave asserts alice, alice never asserts dave + expect([...reg.neighbors("alice")].sort()).toEqual(["bob"]); +}); + +test("self-assertion and empty are ignored", () => { + const reg = new ConnectionRegistry(); + reg.assert("alice", "alice"); + reg.assert("", "bob"); + reg.assert("alice", ""); + expect([...reg.neighbors("alice")]).toEqual([]); +}); + +test("bilateralConnections adapts to the Connections interface", () => { + const reg = new ConnectionRegistry(); + reg.assertAll([ + { from: "alice", to: "bob" }, + { from: "bob", to: "alice" }, + ]); + const conns = bilateralConnections(reg); + expect([...conns.neighbors("alice")]).toEqual(["bob"]); + expect([...conns.neighbors("carol")]).toEqual([]); +}); + +test("clear() removes all assertions", () => { + const reg = new ConnectionRegistry(); + reg.assert("alice", "bob"); + reg.assert("bob", "alice"); + reg.clear(); + expect([...reg.neighbors("alice")]).toEqual([]); +}); diff --git a/packages/client/test/discovery.test.ts b/packages/client/test/discovery.test.ts index ddf1b06..79c0131 100644 --- a/packages/client/test/discovery.test.ts +++ b/packages/client/test/discovery.test.ts @@ -7,6 +7,8 @@ import { resetStoreRegistry, resetConfig, setCurrentUser, + getCaps, + resetCaps, } from "../src/polyfill"; import { resetRegistryCache, ensureAccount } from "../src/store-registry"; import type { RegistrySession } from "../src/store-registry"; @@ -20,6 +22,7 @@ afterAll(() => { resetConfig(); resetStoreRegistry(); setCurrentUser(null); + resetCaps(); }); test("throws a clear error when configureStoreRegistry() was not called", async () => { @@ -171,6 +174,7 @@ test("submitToIndex creates the @index special account on first sight (3 docs)", }); test("submit → read round-trips the reference as an index entry", async () => { + setCurrentUser("alice"); // `from` is bound to the current identity const ref = { nuri: "did:ng:o:event1", title: "Concert au parc" }; await submitToIndex(ref, { from: "alice", ts: 100 }); const entries = await readIndex(); @@ -197,8 +201,9 @@ test("a reference submitted by A is discovered by a NON-connected reader via the test("readIndex deduplicates identical references (materialization moderation point)", async () => { const ref = { nuri: "did:ng:o:dup", title: "Twice" }; - await submitToIndex(ref, { from: "alice", ts: 100 }); - await submitToIndex(ref, { from: "bob", ts: 200 }); // duplicate reference + // Anonymous submissions (dedup keys on the ref, not the submitter). + await submitToIndex(ref, { from: null, ts: 100 }); + await submitToIndex(ref, { from: null, ts: 200 }); // duplicate reference const entries = await readIndex(); expect(entries).toHaveLength(1); // surfaced once }); @@ -209,6 +214,30 @@ test("from: null makes an anonymous submission", async () => { expect(entries[0]!.from).toBeNull(); }); +// (d) PUBLIC-ONLY: a protected/private document must NOT be submittable to the +// world-readable discovery index; a public (or ungoverned) document is fine. +test("(d) submitToIndex refuses a PROTECTED/PRIVATE document (public-only)", async () => { + resetCaps(); + // A PROTECTED and a PRIVATE governed document, and a PUBLIC one. + getCaps().open("did:ng:o:prot", "protected", "alice"); + getCaps().open("did:ng:o:priv", "private", "alice"); + getCaps().open("did:ng:o:pub", "public", "alice"); + + // Submitting the protected doc's NURI is REJECTED. + await expect( + submitToIndex({ nuri: "did:ng:o:prot" }, { from: null, doc: "did:ng:o:prot" }), + ).rejects.toThrow(/PUBLIC|public-only|protected\/private/i); + // Private too. + await expect( + submitToIndex({ nuri: "did:ng:o:priv" }, { from: null, doc: "did:ng:o:priv" }), + ).rejects.toThrow(/PUBLIC|public-only|protected\/private/i); + // The PUBLIC document passes. + await submitToIndex({ nuri: "did:ng:o:pub" }, { from: null, doc: "did:ng:o:pub", ts: 1 }); + const entries = await readIndex(); + expect(entries.map((e) => (e.ref as { nuri: string }).nuri)).toEqual(["did:ng:o:pub"]); + resetCaps(); +}); + test("INDEX_ACCOUNT lives in the reserved namespace (no typed username can equal it)", () => { // The index account occupies a key no user input can produce: it is prefixed // with a NUL control char, which a user cannot type into a username field and diff --git a/packages/client/test/inbox.test.ts b/packages/client/test/inbox.test.ts index f10e7cf..4c96f41 100644 --- a/packages/client/test/inbox.test.ts +++ b/packages/client/test/inbox.test.ts @@ -134,6 +134,7 @@ beforeEach(() => { }); test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => { + setCurrentUser("alice"); // `from` is bound to the current identity await post(TARGET, { from: "alice", payload: { kind: "join" }, ts: 100 }); expect(fake.sparql_update).toHaveBeenCalledTimes(1); const call = fake.sparql_update.mock.calls[0]!; @@ -143,12 +144,29 @@ test("post writes via the real injected ng.sparql_update (not makeNg), scoped to }); test("post → read round-trips payload, from and ts", async () => { + setCurrentUser("alice"); // `from` is bound to the current identity await post(TARGET, { from: "alice", payload: { kind: "join", n: 3 }, ts: 100 }); const deposits = await read(TARGET); expect(deposits).toHaveLength(1); expect(deposits[0]).toEqual({ from: "alice", payload: { kind: "join", n: 3 }, ts: 100 }); }); +// (c) `from` is BOUND to the current identity — a spoof (naming another +// principal) is REJECTED; identifying as self or anonymous (null) is allowed. +test("(c) post rejects a spoofed `from` (naming another principal); self/null allowed", async () => { + setCurrentUser("alice"); + // SPOOF: alice tries to deposit AS bob → rejected. + await expect(post(TARGET, { from: "bob", payload: { x: 1 }, ts: 1 })).rejects.toThrow( + /spoof|current identity/i, + ); + // Identifying as self → allowed. + await post(TARGET, { from: "alice", payload: { x: 2 }, ts: 2 }); + // Explicit anonymous → allowed. + await post(TARGET, { from: null, payload: { x: 3 }, ts: 3 }); + const froms = (await read(TARGET)).map((d) => d.from); + expect(froms).toEqual(["alice", null]); +}); + test("from is optional — omitting it defaults to the current user", async () => { setCurrentUser("bob"); await post(TARGET, { payload: { hi: 1 }, ts: 200 }); diff --git a/packages/client/test/isolation-active.test.ts b/packages/client/test/isolation-active.test.ts index cddf8ce..f60b735 100644 --- a/packages/client/test/isolation-active.test.ts +++ b/packages/client/test/isolation-active.test.ts @@ -1,12 +1,15 @@ /** - * ReadCap ACTIVE — end-to-end proof that isolation is enforced by the emulated - * cap registry (not merely by the app's social isolation filter). + * ReadCap ACTIVE (T03.h) — end-to-end proof that the emulated SDK enforces + * per-DOCUMENT isolation, driven by per-entity documents + BILATERAL connections. * - * This mirrors exactly what the app's storeRegistry wrapper does: create an - * entity document through the REAL registry (`createEntityDoc`), then declare - * its cap policy via `getCaps().open(doc, scope, owner)`. The read filter then - * hides one owner's private document from another principal — the faithful - * per-DOCUMENT NextGraph behavior. + * Mirrors exactly what the app does: create an entity document through the REAL + * registry (`createEntityDoc`), declare its cap policy via + * `getCaps().open(doc, scope, owner)`, set the current identity, and declare + * connections as the CURRENT identity's own peers (authenticated, bilateral). The + * read filter then discriminates: + * (a) unconnected principal denied a PROTECTED doc; granted after a BILATERAL + * connection; PUBLIC readable throughout — via the ACTIVE ReadCap. + * (b) a UNILATERAL / self-declared connection grants NOTHING. */ import { test, expect, mock, afterAll } from "bun:test"; import { createEntityDoc, resetRegistryCache } from "../src/store-registry"; @@ -18,15 +21,16 @@ import { resetConfig, getCaps, resetCaps, + setCurrentUser, declareConnections, } from "../src/polyfill"; import { filterReadable } from "../src/read-filter"; -import { connectionsFromLinks } from "../src/isolation"; afterAll(() => { resetConfig(); resetStoreRegistry(); resetCaps(); + setCurrentUser(null); }); const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" }; @@ -42,75 +46,78 @@ function inject() { configureStoreRegistry({ getSession: async () => SESSION, normalizeUser: (u) => u.trim() }); resetRegistryCache(); resetCaps(); + setCurrentUser(null); return ng; } test("ReadCap active: a private entity doc created via the real registry is hidden from another principal", async () => { inject(); - // Alice creates a PRIVATE entity document via the REAL store-registry, then - // (as the app wrapper does) declares its cap policy: owner-only read. const aliceDoc = await createEntityDoc("alice", "private"); getCaps().open(aliceDoc, "private", "alice"); - // Bob creates a PUBLIC entity document (world-readable). const bobDoc = await createEntityDoc("bob", "public"); getCaps().open(bobDoc, "public", "bob"); - // The reactive set as the broker would deliver it (mono-store: items carry - // their @graph = the document they live in). const items = [ { "@graph": aliceDoc, "@id": "a1", label: "alice-private" }, { "@graph": bobDoc, "@id": "b1", label: "bob-public" }, ]; - // Bob cannot read alice's private doc; he CAN read the public one and, since - // the read filter is now under a policy, alice's private item is filtered out. - const bobView = filterReadable(items, getCaps(), "bob").map((i) => i["@id"]); - expect(bobView).toEqual(["b1"]); - - // Alice reads her own private doc AND the public one. - const aliceView = filterReadable(items, getCaps(), "alice").map((i) => i["@id"]); - expect(aliceView.sort()).toEqual(["a1", "b1"]); - - // Anonymous sees only the public doc. - const anonView = filterReadable(items, getCaps(), null).map((i) => i["@id"]); - expect(anonView).toEqual(["b1"]); - - // Sanity: this is the REGISTRY talking, not app-level isolation — the caps - // registry has an active read policy. + expect(filterReadable(items, getCaps(), "bob").map((i) => i["@id"])).toEqual(["b1"]); + expect(filterReadable(items, getCaps(), "alice").map((i) => i["@id"]).sort()).toEqual(["a1", "b1"]); + expect(filterReadable(items, getCaps(), null).map((i) => i["@id"])).toEqual(["b1"]); expect(getCaps().hasReadPolicy()).toBe(true); }); -test("ReadCap active: a PROTECTED entity doc is hidden from an unconnected principal, revealed after they connect, PUBLIC readable regardless", async () => { +// (a) protected hidden while unconnected → revealed after a BILATERAL connection; +// public readable regardless — all through the ACTIVE ReadCap. +test("(a) PROTECTED doc: hidden unconnected, revealed after BILATERAL connection, PUBLIC always readable", async () => { inject(); - // Alice creates a PROTECTED entity document + a PUBLIC one, declaring the caps - // exactly as the app wrapper does (createEntityDoc → getCaps().open). const aliceProtected = await createEntityDoc("alice", "protected"); getCaps().open(aliceProtected, "protected", "alice"); const alicePublic = await createEntityDoc("alice", "public"); getCaps().open(alicePublic, "public", "alice"); const items = [ - { "@graph": aliceProtected, "@id": "p1", label: "alice-protected" }, - { "@graph": alicePublic, "@id": "u1", label: "alice-public" }, + { "@graph": aliceProtected, "@id": "p1" }, + { "@graph": alicePublic, "@id": "u1" }, ]; + const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]).sort(); - const view = (user: string) => filterReadable(items, getCaps(), user).map((i) => i["@id"]).sort(); - - // BEFORE any connection: bob (unconnected) sees ONLY alice's public item, NOT - // her protected one. Alice sees both. + // BEFORE any connection: bob sees only the public item. expect(view("bob")).toEqual(["u1"]); expect(view("alice")).toEqual(["p1", "u1"]); - // The app declares the CONNECTIONS graph to the SDK (domain sharing act): now - // alice and bob are connected. The SDK issues the protected doc's read cap to - // bob (owner's connection). Public is unaffected. - declareConnections(connectionsFromLinks([{ a: "alice", b: "bob" }])); + // BILATERAL: alice asserts bob AND bob asserts alice → the link materializes and + // the SDK issues the protected doc's read cap to bob. + declareConnections(["bob"], "alice"); + declareConnections(["alice"], "bob"); - // AFTER connecting: bob reads alice's PROTECTED item too; PUBLIC still readable. expect(view("bob")).toEqual(["p1", "u1"]); - // A THIRD, still-unconnected principal (carol) sees only the public one. + // A third, unconnected principal still sees only the public one. expect(view("carol")).toEqual(["u1"]); }); + +// (b) A UNILATERAL / self-declared connection must NOT grant protected read. +test("(b) a UNILATERAL / self-declared connection grants NO protected read", async () => { + inject(); + + const aliceProtected = await createEntityDoc("alice", "protected"); + getCaps().open(aliceProtected, "protected", "alice"); + const items = [{ "@graph": aliceProtected, "@id": "p1" }]; + const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]); + + // The ATTACKER (mallory) self-declares a connection to alice — a UNILATERAL + // assertion authored by mallory. Alice NEVER asserts mallory back. + declareConnections(["alice"], "mallory"); + expect(view("mallory")).toEqual([]); // still denied — no bilateral link + + // Even if alice connects to bob (a different, legitimate bilateral link), + // mallory's one-sided assertion still grants nothing. + declareConnections(["bob"], "alice"); + declareConnections(["alice"], "bob"); + expect(view("mallory")).toEqual([]); + expect(view("bob")).toEqual(["p1"]); +});