refactor: ouvrir une inbox est un acte de registre, et deux invariants rendus explicites

`openDocumentInbox` passe du shim aux registres de branche. Ce qu'il fait est de
la comptabilité du verifier : vérifier la propriété, enregistrer la moitié
lecture sur la branche User, publier l'adresse. Seule la création du document
support relève du shim, et elle est appelée, pas hébergée. En amont l'acte
équivalent est générer une paire de clés et commiter `AddInboxCap`.

Deux risques de migration signalés par le contrat interne, transformés en
invariants vérifiés plutôt que supposés :

- **Le couple `(document, inbox)`** est un littéral RDF séparé par une espace là
  où l'amont a une structure typée (`AddInboxCapV0 { repo_id, overlay, priv_key }`).
  L'espace est sûr parce qu'un NURI n'en contient pas — alphabet base64url et
  segments `:` — mais c'était une propriété implicite. `encodeInboxCap` la
  vérifie désormais : un découpage erroné classerait une inbox sous un document
  tronqué et perdrait les dépôts sans erreur, la classe de panne que ce chemin a
  déjà payée une fois.
- **Le namespace réservé** garantit qu'aucun identifiant utilisateur ne peut s'y
  loger — sauf que `normalizeId` est injecté par le consommateur et que le
  défaut de la bibliothèque ne fait que trimmer. Une collision ne serait pas
  cosmétique : un utilisateur se retrouverait sur un compte d'infrastructure, à
  lire et écrire des documents qui ne sont pas les siens. Vérifié à la
  normalisation, avec un test qui simule un `normalizeId` hostile.

160 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
This commit is contained in:
Sylvain Duchesne
2026-08-04 15:02:29 +02:00
parent 107f9d1633
commit b62bfe1e63
5 changed files with 144 additions and 103 deletions
@@ -257,7 +257,20 @@ export interface RegistrySession {
}
function normalize(id: string): string {
return getStoreRegistryDeps().normalizeId(id);
const key = getStoreRegistryDeps().normalizeId(id);
// The reserved namespace's whole guarantee is that no user id can land in it, and
// that guarantee is NOT ours to make: `normalizeId` is injected by the consumer
// application, and the library's own default only trims — nothing stops a caller
// from passing an id that already starts with the sentinel. A collision here is not
// a cosmetic clash: a user would key onto an infrastructure account and read or
// write documents that are not theirs. So it is checked rather than assumed.
if (isReserved(key)) {
throw new Error(
"[ng-eventually] account-registry: `normalizeId` produced a key inside the " +
`reserved namespace, which no user id may occupy: ${JSON.stringify(key)}`,
);
}
return key;
}
export async function session(): Promise<RegistrySession> {
@@ -477,7 +490,7 @@ async function writePointer(doc: Nuri): Promise<void> {
}
/** Create one graph document in the shared wallet's private store (→ a NURI). */
async function createDoc(): Promise<Nuri> {
export async function createDoc(): Promise<Nuri> {
const s = await session();
// crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store",
// store_repo=undefined → shared wallet's private store.
@@ -988,103 +1001,6 @@ export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
* another account's unsynced docs. This is the helper a consumer application uses
* for its own my-entities path, instead of the all-accounts `listEntityDocs`.
*/
/**
* The inbox of a document this user owns — resolved, and created on first ask.
*
* Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`):
* an inbox is a keypair on the repo, whose PRIVATE half its owner holds. That half is
* recorded with `AddInboxCap { repo_id, overlay, priv_key }` — *"into the user branch,
* so that a user can share with all its device"* (`engine/repo/src/types.rs:1973`), the
* same branch that carries `AddLink`. So "which inboxes may I read" is answered by the
* User branch, and that is what this emulates.
*
* **The engine SUPPORTS this; nothing exercises it automatically.** Those are two
* different statements, and conflating them is what made an earlier version of this
* comment call the feature an "anticipation". It is not. `inbox: Option<PrivKey>` is a
* field of EVERY `Repo` (`engine/repo/src/repo.rs:126`), not of a store structure;
* `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`); and
* `update_inbox_cap_v0` applies it with `self.repos.get_mut(repo_id)` and **no
* `is_store` check of any kind** (`engine/verifier/src/verifier.rs:1920`). Generic by
* construction, and at any time (see the User-branch note above).
*
* What is true is narrower: no code path CREATES one for a document — `new_store_default`
* attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None`
* (`repo.rs:574`), and the only two `AddInboxCap` commits in the engine are for the
* public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So the
* capability exists and is simply unexposed above level 1: this function is aligned on
* the engine's model, it does not bet past it.
*
* Lazy on purpose, for the same reason: creating an inbox document for every entity up
* front would double every `createEntityDoc` for inboxes most documents never receive
* anything in. Upstream the keypair is cheap; here an inbox is a document, so it is
* minted when first asked for.
*
* *(Not covered: ROTATING an inbox key — the engine's "update" case with a new
* `priv_key`. This function is idempotent and returns the existing inbox instead. A
* known limit, not an oversight.)*
*
* Only for a document this user OWNS — see {@link ownsDocument}. Opening an inbox on
* someone else's document would be usurpation, not a courtesy: the opener keeps the
* reading half, so it would silently divert to itself the deposits meant for the
* owner. To deposit into someone else's document, resolve
* {@link documentInboxAddress} and `inbox.post` into it.
*/
export async function openDocumentInbox(doc: Nuri): Promise<Nuri> {
const holder = getCurrentUser();
if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set");
const known = (await readInboxCapsFor(doc)) ?? null;
if (known) return known;
// OWNERSHIP is the criterion, and holding a cap is NOT ownership — a cap can be
// received. Opening an inbox is what PUBLISHES this document's address, so a
// non-owner doing it would route the owner's deposits to itself, silently, on a
// document it merely reads.
//
// **This guard compensates OUR design, not an upstream constraint** — an earlier
// comment here claimed "upstream only the owner can commit `AddInboxCap`", which is
// false: that commit lands on the committer's OWN User branch, so anyone may write
// one naming anyone's repo. What protects upstream is that an inbox address is never
// PUBLISHED — it is TRANSMITTED (in a `ContactDetails` message, or a profile QR
// code), and `inboxes: PubKey → RepoId` is a per-verifier local table
// (`engine/verifier/src/verifier.rs:105`, rebuilt empty each session). A forged pair
// reaches nobody, because nobody was told about it.
//
// We publish instead of transmitting — the only way a third party can find the
// address at all here — which creates a vector upstream does not have: whoever can
// write the document can redirect its deposits. Hence this guard. It is a real
// divergence, deliberately taken; see `docs/briefs/2026-08-03-document-inbox-addressing.md`.
if (!(await ownsDocument(doc))) {
throw new Error(
"[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " +
`you own. Deposit into its published address instead (storeRegistry.documentInboxAddress ` +
`then inbox.post): ${JSON.stringify(doc)}`,
);
}
const inbox = await createDoc();
const s = await session();
const record = await ensureAccount(holder);
const store = record.docPrivate;
getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs
if (store) {
try {
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(doc + " " + inbox)}" }`,
store,
"openDocumentInbox",
);
} catch (error) {
console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error);
}
}
// …and the PUBLIC half, in the document itself, so a depositor can find it at all.
// Without this the inbox is reachable only by its owner — the opposite of what an
// inbox is for, and the bug this path shipped with.
await publishInboxAddress(doc, inbox);
return inbox;
}
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
const record = await ensureAccount(id);
const store = storeOf(record, scope);