diff --git a/docs/briefs/2026-07-20-caps-emulation-alignment.md b/docs/briefs/2026-07-20-caps-emulation-alignment.md new file mode 100644 index 0000000..4b945fa --- /dev/null +++ b/docs/briefs/2026-07-20-caps-emulation-alignment.md @@ -0,0 +1,118 @@ +# Brief — aligner l'émulation des caps sur le vrai modèle NextGraph + +**Brief (incubation) — 2026-07-20.** Voir la référence `docs/readcap-and-nuri-model.md`. + +## Problème + +`caps.ts` émule les droits de lecture comme une **ACL** (`Map>`, +`grantRead(doc, grantee)`) — **l'inversion** du vrai modèle NextGraph (possession +de clé). Conséquences : pas de notion de **référence cap-less**, grant/révocation +**instantanés et totaux** (au lieu de scellage durable + re-key), et une API +(`declareConnections`) que les consommateurs doivent **re-déclarer à chaque +session**. Cet écart empêche de bâtir correctement des modèles qui reposent sur la +vraie sémantique — notamment la **présence anonyme** (nommer/compter sans lire). + +## Objectif : shape-fidelity, PAS sécurité + +Le polyfill **n'égale PAS** la sécurité de NextGraph fini, et n'essaie pas. Le +wallet partagé + l'absence de crypto rendent l'émulation **volontairement +non-sécurisée** (tout est en clair, tout marqueur est forgeable) — véhicule de +dev/staging, pas un but. **Seul objectif** : exposer la **BONNE FORME** des +primitives futures pour que les consommateurs (Festipod) soient codés contre le +**modèle mental correct** et **n'aient pas à être réécrits** quand NextGraph sera fini. + +Corollaire : **« pas de crypto » n'est pas un problème** ; ce qui compte est d'être +**dans la même logique, avec RIGUEUR**. Une critique « un attaquant lit le clair / +forge un marqueur » est **exacte mais hors-scope**. Ce qui est **inacceptable** = +exposer la **mauvaise forme** (ex. une ACL là où le réel est possession de clé) → +le consommateur code contre un modèle qui n'existera pas. **L'inversion ACL des +ReadCaps EST ce manque de rigueur** — le défaut central à corriger. + +## Mécanisme d'enforcement : simulation crypto LÉGÈRE (anti-ACL, anti-raccourci) + +Pour que la forme soit **réellement** possession-de-clé (et pas une ACL déguisée), +la donnée d'un doc est **stockée chiffrée** (chiffrement symétrique par-doc, même +léger) et le **ReadCap = la clé**. Invariant (cf. `docs/vision.md`) : + +> un **`did` nu (sans ReadCap)** ne permet **PAS** de lire ; un **NURI avec +> ReadCap** est **suffisant et requis**. + +Ça **empêche les raccourcis** que l'adversaire a relevés (#4/#6 : lire le clair, +`sparqlQuery`/`inbox.read` qui bypass le filtre) et **interdit** de retomber sur +une ACL — c'est le cœur de « même logique, avec rigueur ». + +**Cible — PAS l'état actuel** : toutes les surfaces de lecture (`read-model`, +`read-filter`, `sparqlQuery`, `inbox.read`) devront passer par le +déchiffrement-avec-clé. **Aujourd'hui c'est FAUX** : `inbox.read` (`inbox.ts:198`) +et `sparqlQuery` brut (`docs.ts:82`) **bypass** le filtre, et le seul garde +(`caps.canRead`, `caps.ts:95`) est une **ACL set-membership** — l'inversion même +que la vision interdit. **Fermer ces voies = le cœur de P1.** + +1. **Deux formes de référence distinctes** : cap-less (nomme/localise sans lire — + aligné sur le NURI sans `:k:`) vs cap-porteur (id + clé/token). Aujourd'hui + absent. +2. **Grant = livrer un cap-token à un destinataire** (émuler le scellage : + le destinataire *reçoit* le token dans son inbox ; c'est la **possession** du + token qui autorise la lecture — pas une ligne d'ACL vérifiée par principal). +3. **Enforcement par possession** : les lecteurs (`read-filter`, `use-shape`) ne + voient que ce dont ils **détiennent le token**, pas « ce dont ils sont dans le + set de readers ». +4. **Résoudre un cap-less** = nommer / prouver l'existence / compter, **sans** + exposer le contenu (support de la présence anonyme). +5. **Révocation = re-key** émulé : invalider l'ancien token, re-livrer un nouveau + aux autorisés restants ; **non-rétroactif**. + +## Questions ouvertes + +- **TRANCHÉ (directive PO, 2026-07-21)** : on **simule la crypto** (donnée chiffrée + par-doc, cap = clé). La « sémantique seule » (registre de tokens) est **écartée** — + elle redevient une ACL et laisse lire le clair. Reste à trancher le **niveau** de + simulation (chiffrement réel léger vs projection read-model masquée), **avant P1**. +- Représentation NURI cap-less vs cap-porteur dans l'émulation (calquer `:k:`). +- Faut-il permettre le **fetch keyless** (résoudre un cap-less en existence/compte + sans le contenu) — dépend de ce que le vrai broker autorise (INFÉRÉ, non tracé). +- Migration d'API : `declareConnections`/`grantRead` → `seal(cap, recipient)` + + `inbox → caps reçus`. Casse les consommateurs (`declareConnections` app disparaît). + +## P0 — Spike « keyless-resolve » (le verrou, AVANT tout P1) + +**Question porteuse** : un détenteur d'une **référence cap-less** (`did:ng:o:{id}:v:{overlay}`, sans `:k:`) peut-il, **sans jamais lire le contenu** : +- **Q1 — Existence / fetch** : prouver/récupérer la présence des blocs (chiffrés) auprès du broker ? Ou le broker exige-t-il un ReadCap/membership pour servir les blocs ? +- **Q2 — Suppression** : distinguer « existe » de « supprimé » ? *(Le point FRAGILE : NextGraph est append-only CRDT — une désinscription = un **commit tombstone** qu'il faudrait **lire** pour connaître → potentiellement **la clé est requise**. Or le **décrément au leave** en dépend.)* +- **Q3 — Confidentialité** : la clé (`:k:`) reste-t-elle **requise** pour déchiffrer (le keyless ne donne jamais le contenu) ? + +**Pourquoi c'est le verrou** : tout le **compteur anonyme** (compter/valider des réfs cap-less sans lire) ET le **décrément au leave** en dépendent. **Si NON** → « compteur anonyme par réf cap-less » est **inconstruisible en cible** → Festipod ne doit **pas** coder cette forme (réécriture garantie). **Si OUI** → P1 expose `resolveCapLess(nuri) → {exists|deleted}` (jamais de contenu), et l'émulation la simule fidèlement. + +**Méthode** (bon marché, décisif) : +1. **Tracer** dans `nextgraph-rs` le chemin d'**autorisation de fetch** du broker/verifier : qui sert les blocs (`BlocksGet`/`TopicSync`/`OverlaySync`) ? un cap/membership est-il vérifié, ou `id+overlay` suffit ? l'overlay *outer* est-il public ? une suppression est-elle observable sans clé ? +2. *(Optionnel)* **test e2e décisif** (façon `e2e/reactivity-doc-subscribe.ts`) : B détient la réf cap-less, tente fetch/existence **sans** la clé, vérifie qu'il **n'accède pas** au contenu. Preuve empirique > source. +3. *(Ou)* confirmer avec le dev NextGraph — le plus rapide. + +**Livrable** : YES/NO/PARTIAL par Q1/Q2/Q3 + le primitif exact (file:line) + la forme d'API à exposer (si OUI), ou le constat que le compteur change (si NON). +**Décision gatée** : OUI → P1 ; NON → le brief inscriptions revoit le compteur (pas anonyme, ou autre primitif). + +## Esquisse de phases + +- **P1** — distinction cap-less / cap-porteur dans les NURI + le read-model + (résoudre un cap-less = nommer/compter, pas lire). +- **P2** — remplacer l'ACL par un modèle de **possession de token** (grant = livrer + à un destinataire ; enforcement = possession). +- **P3** — révocation par re-key (invalidation + re-livraison, non-rétroactive). +- **P4** — adapter l'API consommateur + `migration-guide.md`. + +## Revue adverse (2026-07-20) — à intégrer + +Un adversaire a réfuté le brief (6 constats). **À lire au filtre de l'Objectif ci-dessus** (forme, pas sécurité). Les critiques purement **sécurité** — contenu en clair lisible (#4), marqueurs forgeables — sont **ACCEPTÉES / hors-scope** : le polyfill ne cherche pas à les empêcher. Restent les vrais défauts de **FORME / rigueur** (à corriger), et une question de **modèle futur** (#5) : + +1. **WriteCap oublié, et « possession » y est FAUX.** L'écriture est **membership/permissions** (`AddMember`) — une **liste d'autorisation**, pas de la possession de clé (réf §1) ; `ng-proxy.ts:28-48` garde chaque `sparql_update`. → garder une **piste WriteCap = membership** ; la **possession ne concerne QUE la lecture**. +2. **P2 « possession sans crypto » = l'ACL renommée.** Sans crypto, « qui détient quel token » = `Map>` = le `readers` actuel : **aucun delta observable**. Les vrais deltas sont **durabilité + cap-less + re-partage par le détenteur** — c'est ÇA le contenu de P2, pas « inverser l'ACL ». +3. **Révocation non-rétroactive INÉMULABLE** sans versioning : `read-model.ts:112-118` ne lit que l'état courant → « invalider l'ancien token » = retrait total = l'inverse du réel (l'ex-détenteur déchiffre les versions **antérieures**). → n'émuler que « plus de nouvelles lectures après re-key » + **documenter la non-rétroactivité comme non-émulable**. +4. **cap-less « sans exposer le contenu » ILLUSOIRE dans l'émulation** : contenu en **clair** dans le wallet partagé ; `sparqlQuery`/`inbox.read` **bypass** le filtre ; `read-filter.ts:30-35` est tout-ou-rien. → l'anonymat cap-less exige soit du **vrai crypto**, soit une **projection read-model masquée** (compter sans lire). « Remplacement pas refonte » est **surévalué**. +5. **Keyless-fetch = INFÉRÉ et load-bearing** : ajouter un **spike P0** qui le vérifie **avant** P1 (sinon le modèle — polyfill ET Festipod — est inconstruisible). +6. **Migration ≠ swap d'API.** `declareConnections` se re-joue chaque session parce que la map est éphémère ; des scellages durables déplacent le grant à l'**acceptation de connexion** + persistent « déjà scellé » — pas d'analogue de `protectedDocsOf` + la boucle de re-dérivation. **Re-architecture consommateur.** +7. *(Plausible)* livrer un cap par inbox async **ne re-déclenche pas** `watchShape` (souscrit aux docs de données, pas aux caps) → vues illisibles **périmées** jusqu'à un autre changement. → prévoir un signal de mutation de caps. + +**Conséquence** : ajouter en tête **P0 (spike keyless-fetch)** et une **piste WriteCap distincte** ; requalifier P2 (le vrai contenu = durabilité + cap-less + re-partage, pas « inverser l'ACL ») ; acter que **sans crypto, la privacy de lecture n'est pas applicable** (choisir : vrai crypto vs projection masquée). + +Liens : `readcap-and-nuri-model.md`, `packages/client/src/caps.ts`. Côté consommateur, +le brief Festipod « réaligner les inscriptions » dépend de ce chantier. diff --git a/docs/readcap-and-nuri-model.md b/docs/readcap-and-nuri-model.md new file mode 100644 index 0000000..b6316ca --- /dev/null +++ b/docs/readcap-and-nuri-model.md @@ -0,0 +1,133 @@ +# Modèle ReadCap & NURI de NextGraph — et l'émulation caps du polyfill + +**Établi 2026-07-20**, VÉRIFIÉ par lecture directe du cœur Rust `nextgraph-rs` +(sauf points marqués INFÉRÉ). Les `file:line` sont datés — les numéros de ligne +sont volatils, se repérer par symbole/regex. + +But : donner la vérité-terrain du modèle de droits d'accès NextGraph, pour +aligner l'émulation `caps.ts` du polyfill (aujourd'hui une ACL — l'inverse du +modèle réel). C'est la base de l'item « aligner ReadCap/WriteCap avec NextGraph ». + +--- + +## 1. Un ReadCap = possession d'une clé, PAS une ACL par-identité + +Un **ReadCap est fondamentalement une clé cryptographique que l'on détient**, pas +une entrée d'ACL liée à un wallet. « Qui détient la clé peut lire. » + +- Structure : `ReadCap = ObjectRef = BlockRef { id: BlockId, key: SymKey }` + (`engine/repo/src/types.rs:461, 463-471, 557, 565`). + - `id: BlockId` = digest **BLAKE3** (adresse de l'objet chiffré). + - `key: SymKey = ChaCha20Key([u8;32])` = la **clé de déchiffrement**. + Détenir le couple → le broker sert les blocs chiffrés par `id`, on déchiffre + **localement** avec `key`. +- Granularité : par commit/objet l'`ObjectRef` **est** le cap ; pour une branche + → commit de définition ; pour un repo → RootBranch ; pour un store → cap du + repo racine (`types.rs:559-565`). `ReadCapSecret` = la moitié clé (`:567-570`). +- **Il n'y a PAS de read-ACL.** L'appartenance/permissions d'un repo + (`RootBranch`, `AddMember`, `AddPermission`) gouvernent l'**écriture/admin**, + pas la lecture. La lecture n'est gardée que par la possession de la clé. + +## 2. Accorder la lecture = sceller la clé au destinataire + +« Grant » = livrer le cap **scellé** (`crypto_box seal`, chiffrement à clé +publique anonyme) à la **pubkey d'inbox** du destinataire — seul lui l'ouvre avec +sa clé privée. + +- Message d'inbox scellé : `InboxMsgBody.msg` = `crypto_box::seal(... to_inbox ...)`, + ouvert avec la clé secrète d'inbox (`engine/net/src/types.rs:4272, 4299, 4319`). +- Le payload peut porter un cap : `ContactDetails.read_cap: Option` + (« if user wants to share the content of profile ») (`net/types.rs:4232-4233`) + → **grant dirigé** (scellé à un destinataire). +- Variante **non-dirigée** : `RepoLinkV0.read_cap` = un lien partageable que + **quiconque le reçoit** peut ouvrir (`net/types.rs:5061-5078`). + +Le « ciblage wallet » vit donc dans **l'enveloppe de scellage**, pas dans le cap : +le cap reste `{id, clé}`, possession-based. + +## 3. Révocation = re-key (grossier, non-rétroactif) + +On ne « reprend » pas une clé livrée. Révoquer = **re-chiffrer** avec une nouvelle +clé et ne la re-sceller qu'aux autorisés restants. + +- « Capabilities are not durable: they can be refreshed by members and previously + shared Caps become obsolete/revoked… if [a member] doesn't subscribe, they lose + access after the refresh » (`net/types.rs:5055-5058`). +- Mécanisme : `RootCapRefresh` / `BranchCapRefresh` (`repo/src/commit.rs:616,630`; + perms `types.rs:1748-1749`). +- Conséquences : **grossier** (échelle repo/branche), **non-rétroactif** (ce qui a + été lu avant reste connu de l'ex-détenteur ; il ne déchiffre que les versions + **antérieures** au refresh). +- Livraison **durable** d'un cap = `PermaCap` — encore **TODO** (`repo/types.rs:578`). + +## 4. Grammaire NURI : cap-less vs cap-porteur (le segment `:k:`) + +Le discriminant est le segment **`:k:{clé}`** : présent = cap-porteur ; **absent = +cap-less** (nomme/localise **sans** donner le droit de lire). C'est de **première +classe** dans le type : `NuriV0.target` (des ids) et `access`/`objects` (le cap) +sont des **champs séparés** — un NURI d'id parse avec `access: vec![]` +(`engine/net/src/app_protocol.rs:53-62, 99-118, 181-195, 659-677`). + +**Cap-less** (id + overlay éventuel, pas de clé) — formatters `app_protocol.rs`, +regexes `net/types.rs` : +- `did:ng:o:{repo_id}` (`:315`, `RE_REPO_O` types.rs:52) +- `did:ng:o:{repo_id}:v:{overlay_id}` (`:263`, `RE_REPO` types.rs:55) +- `did:ng:o:{repo_id}:v:{overlay_id}:b:{branch_id}` (`RE_BRANCH` types.rs:58) +- `did:ng:o:{repo_id}:c:{commit_id}` (`:355`) +- `did:ng:b:{branch}` / `h:{topic}` / `v:{overlay}` / `d:{inbox}` (`:327,323,319,359`) + +**Cap-porteur** (embarque la clé) : +- `did:ng:j:{id}:k:{clé}` — read cap d'objet/fichier (`repo/types.rs:511`, + `RE_FILE_READ_CAP` types.rs:49) +- `did:ng:o:{repo}:c:{commit}:k:{clé}` (`RE_COMMIT` types.rs:73) +- liste `RE_OBJECTS` `…:[cj]:{id}:k:{clé}…:l:{locator}` (types.rs:64) + +L'`overlay` est **dérivé publiquement** de l'id du repo (`OverlayId::outer(store_id)`, +`:259`) → id+overlay ne fuitent **aucun secret**. + +INFÉRÉ : un détenteur **sans clé** peut vraisemblablement **récupérer les blocs +chiffrés** (avec l'overlay, toujours cap-less) → vérifier l'**existence** d'un doc +sans lire son **contenu**. Le chemin d'autorisation de fetch broker pour un +détenteur sans clé n'a **pas** été tracé — à confirmer avant de s'en servir. + +## 5. Ce que le polyfill émule (caps.ts) — et où ça diverge + +`packages/client/src/caps.ts` modélise `readers: Map>` + +`grantRead(doc, grantee)` (`:29-30, 41-42`) — **une ACL de principals par +document, soit l'INVERSION exacte du modèle réel** (clé). Divergences : + +| | Réel NextGraph | Émulation caps.ts | +|---|---|---| +| Nature | possession de **clé** | **ACL** (set de principals) | +| Grant | sceller la clé (crypto_box) à l'inbox | ajouter un principal au set | +| Durabilité | **durable** (clé livrée une fois) | **éphémère** (Map vide à chaque session → re-déclarée) | +| Révocation | **re-key** grossier, non-rétroactif | retrait du set : **instantané et total** | +| Granularité | repo / branche / commit / objet | **un cap par doc-NURI** | +| Réf. sans droit | **NURI cap-less** (sans `:k:`) | pas de notion (l'ACL dit qui peut) | + +**Face app** : `declareConnections` (côté consommateur) qui re-déclare « mes +connexions lisent mes entités protected » **à chaque session** est un **artefact +de cette ACL éphémère** — sans objet dans le modèle réel (les scellages y sont +durables ; on scelle par-doc au partage, pas par-session). + +## 6. Implications pour les consommateurs (ex. Festipod) + +- « **scope protected = mon réseau peut lire** » n'est **pas** une ACL vérifiée + par le broker : c'est « j'ai **scellé ma read key** à chacune de mes + connexions ». Le modèle mental « scope = ACL » est faux au niveau NextGraph. +- **Références anonymes possibles** : mettre un **NURI cap-less** dans une + collection tierce laisse le tiers **nommer/compter** sans **lire l'identité** ; + sceller le cap-porteur séparément aux seuls autorisés. (Base d'un modèle de + présence « participation auto-possédée + Set curé cap-less + cap scellé aux + connexions ».) +- **Alignement à faire** : quand les vraies opérations de cap seront disponibles, + remplacer l'ACL émulée par du scellage de clé durable par-doc, et + `declareConnections`-comme-ACL-ré-déclarée disparaît. + +## Réserves / lacunes + +- `file:line` datés (2026-07) — re-vérifier par symbole ; le core bouge. +- INFÉRÉ : fetch broker keyless (existence sans clé) — non tracé au runtime. +- Non tracé : exécution complète de `RootCapRefresh` côté verifier + (`verifier/src/commits/mod.rs:616`), stockage wallet de `private_store_read_cap` + (`repo/types.rs:945,976`). diff --git a/docs/vision.md b/docs/vision.md new file mode 100644 index 0000000..29570ad --- /dev/null +++ b/docs/vision.md @@ -0,0 +1,53 @@ +# Vision & principes du polyfill `@ng-eventually/client` + +## Raison d'être + +Un **stand-in fidèle en FORME** des primitives futures de NextGraph. Objectif +**unique** : que les consommateurs (Festipod) soient **codés contre le modèle +mental CORRECT** — celui de NextGraph fini — et **n'aient RIEN à réécrire** quand +NextGraph fournira les vraies primitives. + +## Ce que le polyfill n'est PAS + +Une couche de **sécurité**. Le **wallet partagé** (tout le monde partage les mêmes +clés) + l'absence de vraie crypto rendent l'émulation **infiniment moins +sécurisée** qu'un wallet-par-utilisateur — c'est un **véhicule de dev/staging**, +pas un but. **L'insécurité est ACCEPTÉE.** Un attaquant qui contourne l'émulation +n'est pas notre problème. + +## Le seul critère : shape-fidelity, avec RIGUEUR + +Les **surfaces exposées** doivent matcher **exactement la FORME** des primitives +futures, **même là où l'enforcement est simulé**. Le **mode d'échec à éviter** : +exposer la **mauvaise forme** → le consommateur code contre un modèle qui +n'existera pas → réécriture. L'inversion **ACL** des ReadCaps était exactement ce +défaut (une ACL là où le réel est **possession de clé**) — un manque de rigueur. + +## Simuler la crypto pour EMPÊCHER les raccourcis + +Sans un minimum de simulation crypto, des raccourcis préjudiciables sont pris (on +lit le clair, on retombe sur des ACLs). Le polyfill **simule** donc le mécanisme +final, assez pour tenir cet **invariant** : + +> **Un `did` (id nu, SANS ReadCap) et un NURI (AVEC ReadCap) sont traités +> VRAIMENT différemment : le premier ne permet PAS de lire la donnée ; le second +> est SUFFISANT et REQUIS.** + +Concrètement : la donnée d'un document est **stockée chiffrée** (chiffrement +symétrique par-doc, même léger) ; le **ReadCap = la clé** ; sans elle, **impossible +de déchiffrer/lire**. Pas d'ACL, pas de clair accessible « à côté ». Obtenir la +lecture = **détenir la clé**, exactement comme en cible. + +## Conséquences de forme (à respecter partout) + +- **Lecture = possession d'une clé** (ReadCap = `{id, clé}`). Un id nu ne lit pas. +- **Écriture = membership/permissions** (primitif **distinct** — PAS de la possession). +- **Partage d'un cap = le sceller à un destinataire** (livraison **durable**, au + moment du partage — PAS une ACL re-déclarée à chaque session). +- **Révocation = re-key** (nouvelle clé ; les anciens détenteurs gardent l'ancien + état). Non-rétroactif. +- **Référence cap-less** (nommer/pointer sans lire) **distincte** de la référence + cap-porteuse. + +Voir `readcap-and-nuri-model.md` (le vrai modèle, vérifié dans `nextgraph-rs`) et +`briefs/2026-07-20-caps-emulation-alignment.md` (le chantier d'alignement).