fix: second tour adverse — mes correctifs avaient quatre trous, dont un qui les annulait

J'ai relancé un adversaire sur les correctifs du commit précédent, comme la règle
l'exige. Il en a trouvé quatre. Le premier annulait la garde que je venais d'écrire.

**Le registre de propriété était écrivable depuis la surface publiée.** `assertMayWrite`
lit la propriété dans l'index de store de l'appelant — et `caps.open` marquait les
documents de STRUCTURE (les trois stores, les inbox) comme « créés par moi ». Donc un
porteur pouvait, par le `docs.sparqlUpdate` publié, insérer `contains "<n'importe quel
document>"` dans son propre index et s'en déclarer propriétaire. Démontré : Bob écrit dans
le document protégé d'Alice, et détourne l'inbox d'un de ses documents — exactement le
vecteur que le commit précédent prétendait fermer. `open` classe désormais sans marquer :
un document de structure n'est possédé par personne au sens de la paternité, donc les deux
moitiés de la garde répondent non, ce qui est correct.

**`inbox.post` acceptait n'importe quel NURI.** Déplacer `depositInto` hors de la surface
ne suffisait pas : `post` atteint la même porte, qui saute les deux gardes par
conception. Bob, ne détenant rien, écrivait quatre triplets dans le document d'Alice. En
amont la confusion est impossible — `InboxPost` scelle vers une CLÉ d'inbox et le broker
route par `inboxes: PubKey → RepoId` ; adresser un document n'est pas refusé, c'est
inexprimable. Le shim tient maintenant un index des inbox, l'équivalent émulé de ce que
le broker sait par construction, et `post` refuse ce qui n'en est pas une.

**Le filtre de lecture fuyait encore par les clés dunder.** `DeepSignalSet` expose la
collection brute sur `__raw__` / `__meta__` : `[...view]` rendait zéro élément pendant que
`view.__raw__` rendait le Set complet, tous utilisateurs confondus. Mon en-tête affirmait
qu'« une propriété simple ne porte aucun élément » — faux pour ce type.

**Et il cassait des membres légitimes** : ma liste blanche couvrait la moitié des
helpers d'itération, si bien que `toArray`, `reduce`, `first`, `take`, `drop`, `flatMap`
levaient sur les données du porteur lui-même. Tous filtrés désormais ; le refus ne vaut
que pour l'inconnu.

**Deux tests réparés à la source plutôt qu'en affaiblissant les gardes.** Le faux
`doc_create` de `inbox.test.ts` rendait une CONSTANTE — tous les documents créés étaient
le même NURI, donc la garde de propriété n'avait rien à distinguer et deux tests lisaient
l'inbox d'Alice sous l'identité de Bob sans que rien ne proteste. Et le harnais e2e
utilisait un document ordinaire comme inbox.

Enfin, mon propre cache d'inbox a reproduit la faute que la revue avait relevée ailleurs :
un mémo qui survit à sa session. Rattaché à `resetRegistryCache`.

189 tests unitaires (six régressions de plus), e2e 40/40 et applicatif 10/10 — après un
échec réseau non reproductible, relancé sans modification.
This commit is contained in:
Sylvain Duchesne
2026-08-07 14:26:32 +02:00
parent 0b936d2119
commit b5f05472d9
9 changed files with 227 additions and 7 deletions
@@ -57,6 +57,7 @@ import {
userInbox,
createDoc,
ensureAccount,
recordInbox,
type VirtualUserRecord,
} from "../shared-wallet/account-registry";
import type { InboxScope, Nuri, ReadCap, Scope } from "../model/types";
@@ -489,6 +490,10 @@ export async function openDocumentInbox(doc: Nuri): Promise<Nuri> {
const record = await ensureAccount(holder);
const store = record.docPrivate;
getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs
// …and the shim records that it IS an inbox, so a depositor can find that out without
// holding anything of it. See `recordInbox`: upstream a deposit cannot address a plain
// document at all, and this is what stands in for that impossibility.
await recordInbox(inbox);
if (store) {
try {
await registerUpdate(
+15 -1
View File
@@ -280,7 +280,21 @@ export class CapRegistry {
* arming their guard here would be enforcement this batch does not do.
*/
open(nuri: Nuri, scope: Scope): ReadCap {
const cap = this.mint(nuri);
// `file`, NOT `mint` — and the difference is a hole that was open for one commit.
//
// Every caller of this method files a STRUCTURAL document: one of the holder's three
// store documents, or an inbox. Those are not authored content, they are registers —
// written only through `emulated-verifier/register-write.ts`. Minting them marked
// them "created by me", which let the write guard through, which let a holder append
// `contains "<anyone's document>"` to their own store index through the PUBLISHED
// `docs.sparqlUpdate` and forge ownership of it. `ownsDocument` reads that very
// index, so the guard was fully bypassable from the surface.
//
// Found by re-running the adversary on the fix (2026-08-07). Filing without minting
// closes it at the source: a structural document is owned by nobody in the authorship
// sense, so both halves of `assertMayWrite` say no, which is correct.
const cap = mintCap(nuri);
this.file(cap);
if (scope === "public") this.markInPublicStore(nuri);
return cap;
}
@@ -111,11 +111,26 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
// at all, so "yes it is in there" would be an answer the target cannot give.
if (prop === "has") return (item: unknown) => keep(item) && (target as Set<unknown>).has(item);
// The reactive-set extras: they iterate, so they must iterate the filtered items.
// The list is `iteratorHelperKeys` from `@ng-org/alien-deepsignals` — an earlier
// pass whitelisted half of it and threw on the rest, so a holder's calls on their
// OWN data crashed (`toArray`, `reduce`, `first`…). Filtering is the answer for all
// of them; refusing is only for what is not on this list.
if (prop === "map") return (fn: (v: unknown, i: number) => unknown) => kept(target).map(fn);
if (prop === "filter") return (fn: (v: unknown, i: number) => boolean) => kept(target).filter(fn);
if (prop === "find") return (fn: (v: unknown, i: number) => boolean) => kept(target).find(fn);
if (prop === "some") return (fn: (v: unknown, i: number) => boolean) => kept(target).some(fn);
if (prop === "every") return (fn: (v: unknown, i: number) => boolean) => kept(target).every(fn);
if (prop === "toArray") return () => kept(target);
if (prop === "first") return () => kept(target)[0];
if (prop === "take") return (n: number) => kept(target).slice(0, n);
if (prop === "drop") return (n: number) => kept(target).slice(n);
if (prop === "flatMap") return (fn: (v: unknown, i: number) => unknown) => kept(target).flatMap(fn as never);
if (prop === "reduce") {
return (fn: (acc: unknown, v: unknown, i: number) => unknown, init?: unknown) =>
init === undefined
? kept(target).reduce(fn as never)
: kept(target).reduce(fn as never, init);
}
if (prop === "getById" || prop === "getBy") {
const inner = Reflect.get(target, prop, target) as ((...a: unknown[]) => unknown) | undefined;
if (typeof inner !== "function") return inner;
@@ -134,6 +149,19 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
return typeof fn === "function" ? fn.bind(target) : fn;
}
// RAW ESCAPE HATCHES. `DeepSignalSet` exposes the underlying collection on
// dunder keys (`__raw__`, `__meta__` — `RAW_KEY` in `@ng-org/alien-deepsignals`),
// and the header used to claim "a plain property carries no items". It does here:
// `view.__raw__` handed back the unfiltered Set, every identity's items in it.
// Found by re-running the adversary on the fix (2026-08-07). Any dunder key is
// refused, because that is the convention the escape hatches follow.
if (typeof prop === "string" && prop.startsWith("__")) {
throw new Error(
`[ng-eventually] read filter: \`${prop}\` reaches past the view to the raw ` +
"collection, which holds every identity's items. There is no filtered form of it.",
);
}
const v = Reflect.get(target, prop, target);
if (typeof v !== "function") return v;
// UNKNOWN function member: refuse rather than forward. See the header — forwarding
@@ -124,6 +124,7 @@ export const P = {
inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ
inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document
exposedReadCap: `${SHIM}:exposedReadCap`, // header branch → the cap a PUBLIC store serves to anyone
isInbox: `${SHIM}:isInbox`, // shim → this NURI IS an inbox (see `assertIsInbox`)
} as const;
// Fixed subject of the per-(account×scope) index document. The index doc plays
// the role of the future store-container: it lists the NURIs of the entity
@@ -311,6 +312,11 @@ export function resetRegistryCache(): void {
inboxInFlight.clear();
shimDocNuri = null;
shimDocInFlight = null;
// The inbox index is session state like the rest: a NURI confirmed as an inbox in one
// session must not be taken for one in the next. (Left out at first, and the full unit
// suite went red on a NURI collision between two files — a memo that outlives its
// session is exactly the fault this review found elsewhere in the tests.)
knownInboxes.clear();
}
// --- SPARQL result helpers ------------------------------------------------
@@ -711,6 +717,68 @@ export async function ensureAccount(id: string): Promise<VirtualUserRecord> {
// --- resolvers ------------------------------------------------------------
/** Fixed subject of the shim's inbox INDEX — see {@link recordInbox}. */
const INBOX_INDEX_SUBJECT = `${SHIM}:inboxes`;
/**
* Record that `nuri` IS an inbox, in the shim — the emulated counterpart of what the
* broker knows by construction upstream.
*
* Upstream you cannot address a repo with a deposit: `InboxPost` seals to an inbox
* PUBKEY, and the broker routes it through `inboxes: PubKey → RepoId`
* (`engine/verifier/src/verifier.rs:105,1677`). Depositing into a plain document is not
* refused there — it is unrepresentable. Here an inbox is a document like any other, so
* "is this an inbox?" has to be asked of something, and the shim is where the emulation
* keeps what the network would know.
*
* Written through the PHYSICAL door: which NURIs are inboxes is not one virtual user's
* business, exactly like the account records beside it.
*/
export async function recordInbox(nuri: Nuri): Promise<void> {
const s = await session();
const shimDoc = await resolveShimDoc();
try {
await physicalUpdate(
s.sessionId,
`INSERT DATA { <${INBOX_INDEX_SUBJECT}> <${P.isInbox}> "${escapeLiteral(nuri)}" }`,
shimDoc,
"recordInbox",
);
} catch (error) {
console.error(accessLogPrefix() + " recordInbox failed:", error);
}
knownInboxes.add(nuri);
}
/** Inboxes this session has already confirmed — the index only ever grows. */
const knownInboxes = new Set<Nuri>();
/**
* Is `nuri` an inbox? Asked of the shim, through the physical door — a depositor is not
* the inbox's owner and holds nothing of it, which is the whole point of an inbox.
*/
export async function isKnownInbox(nuri: Nuri): Promise<boolean> {
if (knownInboxes.has(nuri)) return true;
const s = await session();
const shimDoc = await resolveShimDoc();
try {
const res = await physicalQuery(
s.sessionId,
`SELECT ?i WHERE { <${INBOX_INDEX_SUBJECT}> <${P.isInbox}> ?i }`,
undefined,
shimDoc,
"isKnownInbox",
);
for (const row of readBindings(res)) {
const v = bindingValue(row, "i");
if (v && isNuri(v)) knownInboxes.add(v);
}
} catch (error) {
console.error(accessLogPrefix() + " isKnownInbox failed:", error);
}
return knownInboxes.has(nuri);
}
/** The index document NURI of an account for a scope (the store-container). */
export function storeOf(record: VirtualUserRecord, scope: Scope): Nuri {
return scope === "public"
@@ -877,6 +945,7 @@ export async function userInbox(id: string, scope: InboxScope): Promise<Nuri> {
const doc = await createDoc();
fileOwnInbox(id, doc);
await recordInbox(doc);
try {
await physicalUpdate(
s.sessionId,
+18 -1
View File
@@ -32,7 +32,7 @@ import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { addLink, documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers";
import { userInbox } from "../shared-wallet/account-registry";
import { userInbox, isKnownInbox } from "../shared-wallet/account-registry";
import { escapeLiteral } from "./sparql";
import { hasReadCap, toNuri } from "../model/nuri";
import {
@@ -179,6 +179,23 @@ export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promis
<${P.payload}> "${payloadLiteral}" ;
<${P.ts}> "${ts}"${fromTriple} .
}`;
// The target must BE an inbox, and this is the one check standing between a deposit
// and an arbitrary write into someone else's document.
//
// Upstream the question does not arise: `InboxPost` seals to an inbox PUBKEY and the
// broker routes it by `inboxes: PubKey → RepoId` — addressing a plain repo with a
// deposit is not refused, it is unrepresentable. Here an inbox is a document like any
// other, so without this `inbox.post(someoneElsesDocument, …)` wrote four triples into
// it, through a published door that skips both guards by design. Found by re-running
// the adversary on the fix that un-published `depositInto` (2026-08-07) — moving that
// function was not enough, because `post` reaches the same door.
if (!(await isKnownInbox(targetInbox))) {
throw new Error(
"[ng-eventually] inbox.post: refused — this is not an inbox. A deposit is addressed " +
"to an inbox, never to a document; upstream the two cannot even be confused, " +
`because a deposit carries an inbox key and not a document reference. ${JSON.stringify(targetInbox)}`,
);
}
// A deposit crosses the boundary on purpose — see `register-write.depositInto`.
await depositInto(sid, update, targetInbox, "deposit");
// Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log):