60a9fd3ede
Deux adversaires à mandats disjoints (alignement NextGraph / économie
conceptuelle). Résultat : P1a fond de 8 notions nouvelles à 2, et un fait que
j'avais consigné comme VÉRIFIÉ était sur-lu.
CORRECTION DE FOND — le fetch keyless n'est PAS constructible. Le spike P0
concluait « Q1 OUI partiel, seul garde : l'overlay ». Il s'arrêtait au contrôle
d'accès sans regarder l'ADRESSAGE : aucune commande d'existence au niveau SDK ;
la seule sonde est interne au crate, exige des BlockId ET un repo chargé, et vise
l'overlay inner dérivé du secret de lecture. Une référence cap-less porte un
RepoId et l'overlay outer — ni BlockId, ni le bon overlay. L'adressage
présuppose le cap. Note corrigée sur place (pas empilée), avec la leçon
transposable : vérifier qu'une garde laisse passer ne prouve pas qu'une
opération est atteignable — encore faut-il pouvoir NOMMER ce qu'on demande.
P1a réécrite :
- UN seul type nouveau, `ReadCap`, le nom de l'amont. `Nuri` reste ce qu'il est
déjà (~90 usages) : la forme cap-less. Les types de marque disparaissent — le
SDK réel prend `nuri: String` et enforce au RUNTIME par la crypto ; une
garantie de compilation est un concept que NextGraph n'a pas, et un
consommateur qui typerait tout devrait dé-typer plus tard.
- `capFor(nuri) → ReadCap | undefined` : le trousseau. Comble un trou fatal du
premier jet — `doc_create` renvoie un NURI cap-less, donc l'invariant « on ne
va jamais d'une référence nue à un cap » empêchait le créateur d'obtenir le cap
de son propre document. Le trousseau existe déjà : la branche de store, où
chaque création commite AddRepo{read_cap}. En amont c'est le wallet.
- `shareCap(cap, toInbox)` : on partage UN DOCUMENT, à une ou plusieurs inboxes.
Pas le store — donner un cap de store livrerait tout son contenu présent et
futur. Les caps reçus arrivent comme dépôts d'inbox, consommés par le
inbox.watch existant (ce qui règle le point 7 de la revue adverse).
- Durabilité : ne PAS la promettre. Verbatim amont, les caps sont « not durable »
et qui ne reste pas abonné perd l'accès ; PermaCap est un TODO. La surface doit
exposer l'obligation d'abonnement, sinon le consommateur retient des caps morts.
- `PrincipalId` sort de la surface caps : n'existe pas en amont, et le brief le
supprimait de canRead en le qualifiant d'inversion ACL avant de le réintroduire
dans sealCapTo. On adresse des inboxes, comme inbox.post le fait déjà.
- Section 0 conservant les erreurs du premier jet : elles sont instructives.
- Exception publique actée : un lien de repo public n'a PAS de read_cap (il se
télécharge depuis l'outer overlay) — pour du public, « référence nue → contenu »
est bien la forme cible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
200 lines
11 KiB
Markdown
200 lines
11 KiB
Markdown
# 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<ReadCap>`
|
|
(« 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)
|
|
|
|
Le segment `:v:` est l'**overlay**, qui a sa propre section ci-dessous — c'est le
|
|
point le plus lourd de conséquences pour les modèles de présence anonyme.
|
|
|
|
## 4bis. L'overlay est l'espace réseau d'un STORE — jamais d'un document
|
|
|
|
**L'overlay est l'unité d'adressage réseau d'un store.** Chez le broker, les blocs
|
|
sont rangés sous une clé `(overlay, block_id)`, et les pairs se synchronisent
|
|
*dans* un overlay. Deux formes par store :
|
|
|
|
| | Dérivation | Qui peut le calculer |
|
|
|---|---|---|
|
|
| **outer** | `OverlayId::outer(store_id)` = BLAKE3 **public** | tout le monde (le store_id suffit) |
|
|
| **inner** | `OverlayId::inner(store_id, readcap_secret)` = BLAKE3 **keyed** | seulement qui détient la clé de lecture du store |
|
|
|
|
Cohérent avec le reste du modèle : pas de rôle ni de liste, seulement « détiens-tu
|
|
la clé qui permet de dériver cet identifiant ». `outer` = le nom public du store,
|
|
`inner` = son nom privé.
|
|
|
|
**Le `:v:` d'un NURI de DOCUMENT porte l'overlay de son STORE** (VÉRIFIÉ, chaîne
|
|
lue de bout en bout) : `NuriV0::repo_graph_name(repo_id, overlay_id)` formate
|
|
`o:{repo_id}:v:{overlay_id}` ; dans `doc_create` la valeur injectée est
|
|
`store.outer_overlay()` — le store **contenant**, jamais le `repo_id`. Un `Repo` ne
|
|
porte **aucun** champ overlay (seulement `store: Arc<Store>`) ; c'est `Store` qui
|
|
porte `overlay_id`. **Contre-preuve mécanique** : dans `Store`, `get`/`put`/`del`/`has`
|
|
passent tous `&self.overlay_id` au block storage — tous les documents d'un store
|
|
partagent le namespace de blocs, donc un overlay par-document est structurellement
|
|
impossible.
|
|
|
|
### La conséquence à connaître : le `:v:` est un pseudonyme stable
|
|
|
|
**Tous les documents d'une même personne dans son store protected portent le MÊME
|
|
`:v:`** = `outer(protected_store_id)`. Donc une référence cap-less — précisément
|
|
celle qu'on utilise pour « nommer sans donner à lire » — **expose l'appartenance
|
|
au store**, c'est-à-dire un **identifiant pseudonyme stable et permanent de la
|
|
personne**. Le store_id lui-même ne fuit pas (BLAKE3 non inversible), donc ça ne
|
|
dit pas *qui* ; mais c'est un **handle constant**, le même partout et pour
|
|
toujours, corrélable par quiconque collecte des références cap-less.
|
|
|
|
**Le couplage qui en résulte, et qui contraint tout modèle de présence anonyme** :
|
|
ce même `:v:` est *simultanément* (a) ce qui permet de **dédupliquer** des
|
|
références sans les lire — deux références de même `:v:` viennent de la même
|
|
personne — et (b) ce qui permet de **tracer** cette personne d'un contexte à
|
|
l'autre. **C'est le même bit d'information.** On ne peut pas obtenir la dédup sans
|
|
concéder le traçage, ni supprimer le traçage sans perdre la dédup — sauf à changer
|
|
le découpage en stores, ce qui déplace le curseur mais ne supprime pas l'arbitrage.
|
|
|
|
*Nuances.* Le `:v:` du NURI est l'overlay **outer**, alors que le trafic
|
|
client↔broker et le stockage local utilisent l'**inner** — valeur différente, mais
|
|
tirée du store elle aussi, donc la propriété tient dans les deux cas. Un store
|
|
`Dialog` renvoie un `Inner`, toujours store-scopé.
|
|
|
|
**CORRIGÉ le 2026-07-27 — cette hypothèse était FAUSSE.** On avait inféré, puis
|
|
cru vérifier, qu'un détenteur **sans clé** pouvait récupérer les blocs chiffrés et
|
|
donc prouver l'**existence** d'un document. Une revue adverse a montré que le
|
|
raisonnement s'arrêtait au *contrôle d'accès* sans regarder l'**adressage** :
|
|
|
|
- Il n'existe **aucune commande d'existence au niveau SDK**.
|
|
- La seule sonde (`BlocksExist`) est **interne au crate**, exige des `BlockId`
|
|
**et** un repo déjà **chargé**, et adresse l'overlay **inner** — lequel est
|
|
dérivé du **secret de lecture**.
|
|
- Une référence cap-less porte un RepoId et l'overlay **outer** : aucun `BlockId`
|
|
à sonder. Et l'outer n'est de toute façon jamais enregistré (`expose_outer`
|
|
codé en dur à `false`, sans paramètre SDK).
|
|
- Le seul primitif accessible à un non-membre (`ExtObjectGet`) exige les ObjectIds
|
|
**et leurs clés**.
|
|
|
|
> **L'adressage lui-même présuppose le cap.** Prouver l'existence d'un document
|
|
> sans détenir sa clé n'est pas constructible aujourd'hui, et rien n'indique que
|
|
> ce soit prévu.
|
|
|
|
Leçon transposable : vérifier qu'une garde d'accès **laisse passer** ne prouve pas
|
|
qu'une opération est atteignable — encore faut-il pouvoir **nommer** ce qu'on
|
|
demande.
|
|
|
|
## 5. Ce que le polyfill émule (caps.ts) — et où ça diverge
|
|
|
|
`packages/client/src/caps.ts` modélise `readers: Map<Nuri, Set<PrincipalId>>` +
|
|
`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`).
|