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
@@ -22,6 +22,7 @@ import { test, expect, mock, afterAll } from "bun:test";
import {
createEntityDoc,
resetRegistryCache,
resolveWriteGraph,
userInbox,
} from "../src/shared-wallet/account-registry";
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
@@ -416,6 +417,44 @@ test("connecting a user that does not exist provisions nothing", async () => {
expect(getCaps().isEnforcing()).toBe(false);
});
// REGRESSION (second adversarial pass). `inbox.post` is a published door that skips both
// guards by design — the deposit is the one write that legitimately crosses. It accepted
// ANY NURI, so it wrote into a document its caller could not even read. Upstream the
// confusion cannot arise: a deposit carries an inbox key, not a document reference.
test("a deposit is addressed to an inbox, never to a document", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
await write(protDoc, SECRET, "alice's own");
setCurrentUser("bob");
await expect(post(protDoc, { payload: { x: 1 }, ts: 1 })).rejects.toThrow(/not an inbox/i);
setCurrentUser("alice");
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
});
// REGRESSION (second adversarial pass). The write guard reads ownership from the store
// index — and the holder's own store document was marked "created by me", so it was
// writable through the PUBLISHED `docs.sparqlUpdate`. One insert into it and you were
// the owner of anything you cared to name.
test("a holder cannot write into their own store index and forge ownership", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
await write(protDoc, SECRET, "alice's own");
setCurrentUser("bob");
await createEntityDoc("bob", "protected"); // bob has his own stores
const bobStore = await resolveWriteGraph("bob", "protected");
await expect(
sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${SHIM}:index> <${SHIM}:contains> "${protDoc}" }`, bobStore, "forge"),
).rejects.toThrow(/WRITE cap/i);
// …and he is still refused the write itself — here by rule 1 (he cannot even reach
// alice's protected document), which fires before the ownership guard. Both say no.
await expect(write(protDoc, SECRET, "bob was here")).rejects.toThrow(/refused/i);
});
// WRITING IS OWNERSHIP — the two regressions that replaced the old write guard.
//
// It used to ask "was this cap served to me by a public store?", which was wrong in both