docs+fix: le modèle de sécurité local-first, et le retrait de linkTo qui le brisait
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.
This commit is contained in:
@@ -615,7 +615,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
|
|||||||
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, InboxScope, NG, NgLike, Nuri, NuriLike, PrincipalId, ReadCap, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape
|
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, InboxScope, NG, NgLike, Nuri, NuriLike, PrincipalId, ReadCap, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape
|
||||||
docs: depositInto, docCreate, sparqlQuery, sparqlUpdate
|
docs: depositInto, docCreate, sparqlQuery, sparqlUpdate
|
||||||
inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch
|
inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch
|
||||||
storeRegistry: createEntityDoc, linkTo, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph
|
storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph
|
||||||
```
|
```
|
||||||
|
|
||||||
### `@ng-eventually/client/polyfill` — `src/polyfill.ts`
|
### `@ng-eventually/client/polyfill` — `src/polyfill.ts`
|
||||||
|
|||||||
@@ -16,12 +16,47 @@ Purpose: to give the ground truth of NextGraph's access-rights model, in order t
|
|||||||
>
|
>
|
||||||
> - **A comment describing the CURRENT state is not the intent.** §3's DIRECTION block exists because `RepoLinkV0`'s comment was read as the target model. It is not.
|
> - **A comment describing the CURRENT state is not the intent.** §3's DIRECTION block exists because `RepoLinkV0`'s comment was read as the target model. It is not.
|
||||||
> - **A word you recognise probably does not mean what you think.** `branch` is not git's. `wallet` is only a keyring — what we call a virtual user is a **user** (a *site*). Check the type before using the word.
|
> - **A word you recognise probably does not mean what you think.** `branch` is not git's. `wallet` is only a keyring — what we call a virtual user is a **user** (a *site*). Check the type before using the word.
|
||||||
|
> - **The access rule is "whoever has the reference AND the key reads" — never "whoever has the reference reads".** An agent forgets this with remarkable consistency, because client-server reflexes supply a missing half: somewhere a server checks. Here nobody checks (§0 below). Getting it wrong does not fail — it silently grants. Every time you hand out a value, ask which of the two you just handed.
|
||||||
> - **NextGraph's naming ALWAYS wins over any rule of ours — including the one that follows.** Where upstream has a word for something, that is the word, full stop. `publisher` is a case in point: it appears 126 times in the engine (`as_publisher`, `publisher_advert`) for a **pub/sub role on a topic**, and it must be used, unchanged, whenever that is what is meant.
|
> - **NextGraph's naming ALWAYS wins over any rule of ours — including the one that follows.** Where upstream has a word for something, that is the word, full stop. `publisher` is a case in point: it appears 126 times in the engine (`as_publisher`, `publisher_advert`) for a **pub/sub role on a topic**, and it must be used, unchanged, whenever that is what is meant.
|
||||||
> - **What is banned is OUR loose "publish a document"**, which has no upstream referent and covers three different acts: *placing a document in a public store*, *making it findable*, and (in this emulation only) *handing out a key*. Every design discussion that used it drifted between them. NextGraph knows only the first — a document IS IN a public store, and brokers serve it accordingly. Say which act you mean. If you catch yourself writing "publish a document", you have not yet decided which one. This never licenses renaming something upstream calls `publisher`.
|
> - **What is banned is OUR loose "publish a document"**, which has no upstream referent and covers three different acts: *placing a document in a public store*, *making it findable*, and (in this emulation only) *handing out a key*. Every design discussion that used it drifted between them. NextGraph knows only the first — a document IS IN a public store, and brokers serve it accordingly. Say which act you mean. If you catch yourself writing "publish a document", you have not yet decided which one. This never licenses renaming something upstream calls `publisher`.
|
||||||
> - **"I looked and it is not there" is not a finding.** §4quinquies once stated that no register existed for received caps, after checking one code path. `AddLink` had been sitting next to `AddRepo` in the same file the whole time. Absence needs at least as much evidence as presence — and an implementation *cache* (like local user storage) is never the model: it is what the model fills.
|
> - **"I looked and it is not there" is not a finding.** §4quinquies once stated that no register existed for received caps, after checking one code path. `AddLink` had been sitting next to `AddRepo` in the same file the whole time. Absence needs at least as much evidence as presence — and an implementation *cache* (like local user storage) is never the model: it is what the model fills.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0. The security model, and why an agent keeps losing it
|
||||||
|
|
||||||
|
**This is the one thing to hold on to.** Every design mistake this file records — a cap minted where a reference sufficed, a link that granted what it was meant to merely name, an "is it allowed?" call that cannot exist — comes from letting go of it.
|
||||||
|
|
||||||
|
### The rule
|
||||||
|
|
||||||
|
> A document is readable by whoever holds **the reference AND the key**. Not by whoever holds the reference.
|
||||||
|
|
||||||
|
There is no third party in that sentence. No server, no ACL, no check at access time: the engine verifies a permission on WRITE only (`verify_permission` → `PermissionDenied` in `Commit::verify`), never on read. Content is encrypted; the key IS the right. That is what local-first means here — there is nobody to ask, so possession is the whole mechanism.
|
||||||
|
|
||||||
|
### Why this is easy to lose
|
||||||
|
|
||||||
|
Client-server reflexes supply the missing half without asking. In that world a link is an identifier, harmless on its own, and access is decided elsewhere — so handing out a URL feels safe, and "may Bob read this?" is a question with an answer. Both instincts are wrong here, and neither announces itself: code written on them **does not fail**, it silently grants. There is no error to notice, no test that goes red on its own.
|
||||||
|
|
||||||
|
### What the rule buys — composable confidentiality
|
||||||
|
|
||||||
|
Because a reference grants nothing, **a reference is not recursive**: a widely-circulated document may point at a restricted one. The reference says *it exists*; the key says *who reads it*.
|
||||||
|
|
||||||
|
That is what lets confidentiality be composed inside one document:
|
||||||
|
|
||||||
|
- a public summary pointing at restricted chapters,
|
||||||
|
- a public event pointing at its participant list,
|
||||||
|
- a profile pointing at data reserved for a circle.
|
||||||
|
|
||||||
|
The author circulates one document and still decides, per referenced document, who gets the key. **Remove non-recursiveness and this whole construction collapses**: everything a circulated document mentions becomes readable by all its readers, and no level of confidentiality can exist inside a document one shares.
|
||||||
|
|
||||||
|
`test/cross-user-access.test.ts` is this property's test, and it is the suite's centrepiece: Alice's document in a public store REFERENCES her protected one; Bob follows the reference, names the target, reads nothing of it. It writes the reference **bare** — which is exactly what an application must do.
|
||||||
|
|
||||||
|
### The failure mode to watch for
|
||||||
|
|
||||||
|
Any call that returns a key where a reference was asked for. It looks like a convenience ("give me the link to my document"), it type-checks, the tests stay green — and it converts "whoever has the reference and the key" into "whoever has the reference", for that document and every document it mentions. `linkTo` (added and removed 2026-08-06) was exactly this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. A ReadCap = possession of a key, NOT a per-identity ACL
|
## 1. A ReadCap = possession of a key, NOT a per-identity ACL
|
||||||
|
|
||||||
A **ReadCap is fundamentally a cryptographic key that one holds**, not an ACL entry tied to a wallet. "Whoever holds the key can read."
|
A **ReadCap is fundamentally a cryptographic key that one holds**, not an ACL entry tied to a wallet. "Whoever holds the key can read."
|
||||||
|
|||||||
@@ -837,7 +837,7 @@ const identity = new IdentityStore(
|
|||||||
];
|
];
|
||||||
setCurrentUser("owner-O");
|
setCurrentUser("owner-O");
|
||||||
getCaps().open("did:ng:o:protdoc", "protected");
|
getCaps().open("did:ng:o:protdoc", "protected");
|
||||||
const link = getCaps().publishRepoLink("did:ng:o:pubdoc");
|
const link = getCaps().recordInPublicStore("did:ng:o:pubdoc");
|
||||||
const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
||||||
// A stranger holds nothing — including the PUBLISHED document, until the repo
|
// A stranger holds nothing — including the PUBLISHED document, until the repo
|
||||||
// link reaches them (§5: whoever has the URL reads the content).
|
// link reaches them (§5: whoever has the URL reads the content).
|
||||||
@@ -871,7 +871,7 @@ const identity = new IdentityStore(
|
|||||||
setCurrentUser(ownerId);
|
setCurrentUser(ownerId);
|
||||||
const doc = await storeRegistry.createEntityDoc(ownerId, "public");
|
const doc = await storeRegistry.createEntityDoc(ownerId, "public");
|
||||||
const ownerInbox = await storeRegistry.openDocumentInbox(doc);
|
const ownerInbox = await storeRegistry.openDocumentInbox(doc);
|
||||||
const link = storeRegistry.linkTo(doc); // the repo link the owner circulates
|
const link = getCaps().capFor(doc)!; // out-of-band: the harness plays 'the owner sent it'
|
||||||
|
|
||||||
setCurrentUser(depositorId);
|
setCurrentUser(depositorId);
|
||||||
getCaps().learn(link);
|
getCaps().learn(link);
|
||||||
|
|||||||
@@ -40,36 +40,16 @@ export {
|
|||||||
/** WHERE to deposit for a document — readable by any holder of it. `undefined` if none. */
|
/** WHERE to deposit for a document — readable by any holder of it. `undefined` if none. */
|
||||||
} from "../shared-wallet/account-registry";
|
} from "../shared-wallet/account-registry";
|
||||||
|
|
||||||
import { getCaps } from "../shared-wallet/bootstrap";
|
|
||||||
import { toNuri } from "../model/nuri";
|
|
||||||
import type { NuriLike, ReadCap } from "../model/types";
|
|
||||||
export { openDocumentInbox } from "../emulated-verifier/branch-registers";
|
export { openDocumentInbox } from "../emulated-verifier/branch-registers";
|
||||||
|
|
||||||
/**
|
// No `linkTo` here, and its absence is deliberate (it existed 2026-08-06, one day).
|
||||||
* The shareable link of a document — what you circulate so someone can open it.
|
//
|
||||||
*
|
// It returned a document's KEY where a caller would ask for its reference, which turns
|
||||||
* Distinct from {@link share}, and both are needed: a link is what TRAVELS (a message,
|
// the access rule from "whoever has the reference AND the key reads" into "whoever has
|
||||||
* a QR code, a page), whereas sharing hands the access to one named person through
|
// the reference reads" — see `docs/readcap-and-nuri-model.md` § 0. That is not a leak of
|
||||||
* their inbox. Upstream the same split exists — a `RepoLinkV0 { read_cap }` is the
|
// hygiene, it is the rule changing: a document one circulates would grant everything it
|
||||||
* thing you pass around, and `ContactDetails.read_cap` is the directed delivery.
|
// MENTIONS, and confidentiality could no longer be composed inside a shared document.
|
||||||
*
|
//
|
||||||
* This is the one place an application legitimately holds a key, because a public
|
// An application names a document with the reference it already has (every call here
|
||||||
* document's link IS meant to be handled: you cannot circulate what you may not touch.
|
// returns bare ones), and grants access with `inbox.share(doc, toUser)`. What travels
|
||||||
* A protected document's key never comes out this way — it goes through `share`.
|
// with a key in it is a deliberate act, not the result of asking for a link.
|
||||||
*
|
|
||||||
* Typed `ReadCap`, since that is what it is — a reference with the key inside. A
|
|
||||||
* `ReadCap` is assignable wherever a `Nuri` is expected (a cap IS a NURI carrying the
|
|
||||||
* key, upstream's one `NuriV0`), so it hands straight to any call that takes a
|
|
||||||
* reference. Throws if you hold nothing: a link you cannot open is not a link.
|
|
||||||
*/
|
|
||||||
export function linkTo(doc: NuriLike): ReadCap {
|
|
||||||
const target = toNuri(doc, "linkTo");
|
|
||||||
const cap = getCaps().capFor(target);
|
|
||||||
if (!cap) {
|
|
||||||
throw new Error(
|
|
||||||
"[ng-eventually] linkTo: you hold no key for this document, so there is no link " +
|
|
||||||
`to hand out: ${JSON.stringify(target)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return cap;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
resetRegistryCache,
|
resetRegistryCache,
|
||||||
userInbox,
|
userInbox,
|
||||||
} from "../src/shared-wallet/account-registry";
|
} from "../src/shared-wallet/account-registry";
|
||||||
import { linkTo } from "../src/surface/placement";
|
|
||||||
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
|
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
|
||||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||||
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share,connectedUser} from "../src/polyfill";
|
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share,connectedUser} from "../src/polyfill";
|
||||||
@@ -220,7 +219,7 @@ async function aliceSetsUpHerDocuments() {
|
|||||||
// grants nothing. This is the whole point of the scenario.
|
// grants nothing. This is the whole point of the scenario.
|
||||||
await write(pubDoc, REFERS_TO, protDoc);
|
await write(pubDoc, REFERS_TO, protDoc);
|
||||||
|
|
||||||
const pubLink = linkTo(pubDoc); // the shareable repo link of the public doc
|
const pubLink = getCaps().capFor(pubDoc)!; // out-of-band: the test plays 'Alice sent Bob the link' // the shareable repo link of the public doc
|
||||||
return { protDoc, pubDoc, pubLink };
|
return { protDoc, pubDoc, pubLink };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +396,7 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn
|
|||||||
const doc = await createEntityDoc("alice", "public");
|
const doc = await createEntityDoc("alice", "public");
|
||||||
const aliceInbox = await openDocumentInbox(doc);
|
const aliceInbox = await openDocumentInbox(doc);
|
||||||
expect(aliceInbox).not.toBe(await userInbox("alice", "protected"));
|
expect(aliceInbox).not.toBe(await userInbox("alice", "protected"));
|
||||||
const link = linkTo(doc); // the repo link alice circulates — links DO travel
|
const link = getCaps().capFor(doc)!; // the repo link alice circulates — links DO travel
|
||||||
|
|
||||||
// Bob RESOLVES the address himself, from the document. The only thing he is handed
|
// Bob RESOLVES the address himself, from the document. The only thing he is handed
|
||||||
// is the link, which is the one thing the model says circulates. The address is not
|
// is the link, which is the one thing the model says circulates. The address is not
|
||||||
@@ -423,7 +422,7 @@ test("opening an inbox on someone else's document is refused, not silently forke
|
|||||||
const doc = await createEntityDoc("alice", "public");
|
const doc = await createEntityDoc("alice", "public");
|
||||||
const aliceInbox = await openDocumentInbox(doc);
|
const aliceInbox = await openDocumentInbox(doc);
|
||||||
|
|
||||||
const link = linkTo(doc);
|
const link = getCaps().capFor(doc)!;
|
||||||
|
|
||||||
// Bob holds the document — that is a READ right, and it is not ownership.
|
// Bob holds the document — that is a READ right, and it is not ownership.
|
||||||
setCurrentUser("bob");
|
setCurrentUser("bob");
|
||||||
@@ -437,7 +436,7 @@ test("a fresh document has NO inbox — one belongs to one document, and only it
|
|||||||
inject();
|
inject();
|
||||||
setCurrentUser("alice");
|
setCurrentUser("alice");
|
||||||
const doc = await createEntityDoc("alice", "public");
|
const doc = await createEntityDoc("alice", "public");
|
||||||
const link = linkTo(doc);
|
const link = getCaps().capFor(doc)!;
|
||||||
|
|
||||||
// Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo
|
// Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo
|
||||||
// (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
|
// (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
|
||||||
@@ -457,7 +456,7 @@ test("opening an inbox publishes ONE address, and re-opening does not accumulate
|
|||||||
const dedicated = await openDocumentInbox(doc);
|
const dedicated = await openDocumentInbox(doc);
|
||||||
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
|
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
|
||||||
|
|
||||||
const link = linkTo(doc);
|
const link = getCaps().capFor(doc)!;
|
||||||
setCurrentUser("bob");
|
setCurrentUser("bob");
|
||||||
getCaps().learn(link);
|
getCaps().learn(link);
|
||||||
expect(await documentInboxAddress(doc)).toBe(dedicated);
|
expect(await documentInboxAddress(doc)).toBe(dedicated);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
import { getCaps } from "../src/shared-wallet/bootstrap";
|
import { getCaps } from "../src/shared-wallet/bootstrap";
|
||||||
import { test, expect, mock, afterAll } from "bun:test";
|
import { test, expect, mock, afterAll } from "bun:test";
|
||||||
import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
|
import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
|
||||||
import { linkTo } from "../src/surface/placement";
|
|
||||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||||
import type { ReadCap } from "../src/model/types";
|
import type { ReadCap } from "../src/model/types";
|
||||||
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share} from "../src/polyfill";
|
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share} from "../src/polyfill";
|
||||||
@@ -255,7 +254,7 @@ test("(b) a bare reference reads nothing; the repo link of a published document
|
|||||||
const pub = await createEntityDoc("alice", "public");
|
const pub = await createEntityDoc("alice", "public");
|
||||||
const items = [item(pub, "u1")];
|
const items = [item(pub, "u1")];
|
||||||
expect(getCaps().isInPublicStore(pub)).toBe(true);
|
expect(getCaps().isInPublicStore(pub)).toBe(true);
|
||||||
const link = linkTo(pub);
|
const link = getCaps().capFor(pub)!;
|
||||||
|
|
||||||
// bob HAS the document's bare NURI (it is right there in `items`) and reads nothing.
|
// bob HAS the document's bare NURI (it is right there in `items`) and reads nothing.
|
||||||
setCurrentUser("bob");
|
setCurrentUser("bob");
|
||||||
|
|||||||
Reference in New Issue
Block a user