← Lector

How Lector sync works, in detail. In short: no account; twelve words generated on your device derive every key; each Markdown file and its path are encrypted on the device (AES-256-GCM) before they leave it; the server keeps only encrypted blocks and cannot read them. The full specification below is in French.

Chaîne de sync de Lector : spécification

Lector 2.0 est gratuit et sans compte. La synchronisation entre appareils passe par une chaîne : un secret tiré sur un appareil de l'utilisateur, d'où sortent toutes les clés. Le serveur ne garde que des blocs chiffrés qu'il ne sait pas ouvrir.

Statut : implémenté (serveur back/, clients front/src/services/chaine/, front/desktop/, écrans front/src/components/ChaineSync.jsx) et testé (docs/CHAINE-TESTS.md). Mise en production : docs/BASCULE-PROD.md.

0. En bref

1. Vocabulaire

TermeSens
SSecret de chaîne, 16 octets aléatoires (128 bits). Écrit en 12 mots ou en code d'appairage (QR).
ChaîneLes appareils qui connaissent S.
CasierCe que le serveur garde pour une chaîne : une ligne Chain, une ligne ChainDoc par document, les blocs sur disque.
idIdentifiant public de la chaîne, dérivé de S (32 hex). Le serveur range le casier sous cet id.
Preuve PValeur dérivée de S, présentée à chaque requête. Le serveur n'en garde que hex(SHA-256(P)).
docIdIdentifiant opaque d'un document : HMAC de son chemin. Stable tant que le chemin ne change pas.
BlocContenu chiffré d'un document (contentEnc).
Pierre tombaleTrace d'un document supprimé, gardée 30 jours, avec la marque de suppression chiffrée tombEnc.
AppareilUne installation de Lector. Il tire au hasard un identifiant (UUID) qui sert seulement à reconnaître ses propres échos dans le flux temps réel ; le serveur ne le stocke pas.

Un seul secret

Ce que possède quelqu'unCe qu'il obtient
S (12 mots, QR, un appareil de la chaîne)Tout : lire, écrire, effacer le casier, renouveler la chaîne.
P seule (fuite d'en-têtes)Écrire, supprimer ou effacer des blocs ; jamais les lire (P ne donne ni S, ni les clés).
L'id seulRien : sans P, toutes les routes refusent (400 sans preuve, 403 avec une fausse) et ne disent même pas la taille du casier.
RienPas même l'existence du casier.
Le serveur (hébergeur, intrus sur la machine)Blocs chiffrés et métadonnées listées au § 2.

Pourquoi pas de compte

Rien à réinitialiser, rien à facturer, rien qui relie le casier à une personne. Le prix : perdre S partout (tous les appareils et la fiche des 12 mots) rend le casier illisible pour tout le monde, définitivement. Chaque appareil garde son vault en clair sur son disque et peut recréer une chaîne (§ 11).

2. Ce que le serveur voit, ce qu'il ne voit pas

Il voit :

Il ne voit pas : le contenu, les chemins, les titres, les noms de dossiers, l'arborescence, les extensions, S, les 12 mots, les clés. Les surlignages et fiches vivent dans le .md (bloc <!-- lector-generated -->), donc dans le bloc chiffré.

Il ne journalise jamais : la preuve, pathEnc, un corps de requête, une IP, un id de chaîne complet (8 premiers caractères au plus). Les tests le vérifient dans la sortie du serveur.

Rien d'autre ne passe par lui. L'assistant IA et la lecture à voix haute utilisent la clé API personnelle de l'utilisateur et appellent Anthropic ou ElevenLabs directement depuis l'appareil (§ 10). L'export EPUB se fait sur l'appareil.

3. Cryptographie

Tout est dans front/src/services/chaine/crypto.js : HKDF-SHA256, HMAC-SHA256, AES-256-GCM, BIP39. Un seul module ES, sans DOM, qui tourne dans le navigateur, la vue web Capacitor, le processus principal d'Electron (import dynamique) et Node 22. Il est vérifié contre docs/CHAINE-VECTEURS.json, produits par une implémentation indépendante (node:crypto, generer-vecteurs.mjs).

3.1 Secret

S = 16 octets de crypto.getRandomValues. Jamais envoyé au serveur, jamais journalisé, jamais placé dans une URL.

3.2 Dérivations

HKDF-SHA256, sel vide, S comme matériau d'entrée :

Nominfo HKDFTailleUsage
idlector-id16 octets, écrit en hex (32 caractères)Identifiant public du casier, en-tête X-Lector-Chain-Id.
K_enclector-enc32 octetsClé AES-256-GCM des chemins, contenus et marques de suppression.
K_maclector-mac32 octetsClé HMAC-SHA256 des docId.
Plector-proof32 octetsPreuve, envoyée en base64url (43 caractères) dans X-Lector-Chain-Proof. Le serveur stocke hex(SHA-256(P)). HKDF est à sens unique : P ne donne ni S, ni K_enc, ni K_mac.

Dans le code : preparer(S) renvoie { id, preuve, cleEnc, cleMac }, avec cleEnc et cleMac en CryptoKey non exportables.

3.3 Chemin normalisé et docId

Chemin canonique (normaliserChemin) : relatif à la racine du vault, séparateur /, Unicode NFC (macOS rend souvent du NFD : Été donne le même docId sous les deux formes), casse conservée, aucun segment vide, . ou .., pas de / initial ni final, pas de NUL, 1024 octets UTF-8 au plus. Convertir \ (Windows) en / est le rôle de la couche vault.

docId = hex(HMAC-SHA256(K_mac, UTF-8(chemin normalisé)))      64 caractères hex

Renommer un fichier revient à supprimer l'ancien docId et à créer le nouveau.

3.4 Enveloppe

Format unique pour les chemins, les contenus et les marques de suppression :

enveloppe = 0x01 (version) ‖ iv (12 octets) ‖ chiffré ‖ étiquette (16 octets)

3.5 pathEnc, contentEnc, tombEnc

ChampClairAADEncodage
contentEncoctets du fichierUTF-8(docId)binaire : corps du PUT, réponse du GET
pathEncUTF-8(chemin normalisé)UTF-8("chemin:" + docId)base64url sans remplissage : en-tête X-Lector-Path-Enc, champ du manifeste
tombEncUTF-8("supprime:" + docId + ":" + sha256 du bloc supprimé)UTF-8("tombe:" + docId)base64url : en-tête X-Lector-Tomb-Enc du DELETE, champ du manifeste

Les AAD différentes empêchent le serveur d'échanger le chemin et le contenu d'un document ; le docId dans l'AAD empêche d'attacher le bloc d'un document à un autre. dechiffrerCheminAvec recalcule en plus HMAC(K_mac, chemin) et refuse un résultat différent du docId.

3.6 12 mots et code d'appairage

3.7 Vecteurs

docs/CHAINE-VECTEURS.json, pour S = 000102030405060708090a0b0c0d0e0f :

ValeurRésultat
id21493c49c3cb28f9041197f59f7e704e
docId("Pro/Notes.md")99e94d4113b8b7e5983597f288e090da3c7e6e7861702a1d33c3bc8ecbebe3c0
12 motsabandon amount liar amount expire adjust cage candy arch gather drum buyer
code d'appairagelector-chaine:AAECAwQFBgcICQoLDA0ODw

Ils contiennent aussi K_enc, K_mac, P, l'empreinte stockée par le serveur, pathEnc (iv 0a0b0c0d0e0f101112131415) et contentEnc de "# Test\n" (iv 00112233445566778899aabb), un cas NFC/NFD, des chemins invalides, des mots à somme fausse et un second S.

npm run test:chaine                                   # vérifie crypto.js contre les vecteurs (et le reste)
node front/src/services/chaine/generer-vecteurs.mjs   # régénère le JSON avec node:crypto

4. Stockage serveur

4.1 Schéma Prisma (back/prisma/schema.prisma)

model Chain {
  id         String     @id                 // 32 hex, dérivé de S côté client
  proofHash  String                         // hex(SHA-256(P)), jamais la preuve
  bytesUsed  Int        @default(0)         // somme des size des blocs vivants
  docCount   Int        @default(0)         // blocs vivants
  createdAt  DateTime   @default(now())
  lastSeenAt DateTime   @default(now())     // écrit au plus une fois par heure
  docs       ChainDoc[]
}

model ChainDoc {
  id        String    @id @default(uuid())
  chainId   String
  chain     Chain     @relation(fields: [chainId], references: [id], onDelete: Cascade)
  docId     String    // 64 hex
  pathEnc   String    // enveloppe du chemin, base64url
  sha256    String    // SHA-256 du BLOC ('' pour une pierre tombale)
  size      Int       // taille du bloc (0 pour une pierre tombale)
  mtime     DateTime  // fournie par le client
  deletedAt DateTime?
  tombEnc   String?
  updatedAt DateTime  @updatedAt
  @@unique([chainId, docId])
  @@index([chainId, deletedAt])
}

Aucune table d'utilisateurs, d'appareils ni de licences. Le conteneur applique le schéma au démarrage (prisma db push) : sur une base qui contient encore les tables de la version avec compte, il refuse (perte de données) et redémarre en boucle. D'où la bascule sur une base neuve (docs/BASCULE-PROD.md).

4.2 Fichiers

4.3 Compteurs et quota

bytesUsed et docCount sont tenus à jour dans la ligne Chain. Un dépôt écrit d'abord le bloc dans un fichier temporaire, à côté de sa place. La réservation de place, une mise à jour conditionnelle (bytesUsed <= quota - delta, atomique dans SQLite : deux dépôts parallèles ne dépassent pas le quota ensemble), et la ligne ChainDoc passent ensuite dans une seule transaction, liée au casier lu à l'authentification (même createdAt). Le bloc n'est renommé à sa place qu'après la transaction. En cas d'échec, le temporaire est supprimé et rien ne change, ni sur le disque, ni en base. Un dépôt qui croise un effacement du casier reçoit 404 CHAIN_UNKNOWN. Un dépôt qui remplace un bloc ne compte que la différence ; une suppression libère la place du bloc. scripts/verifier-chaine.js recalcule ces compteurs à partir des lignes et du disque (§ 5.3).

5. Contrat des routes (/v1/chain)

5.1 En-têtes et authentification

En-têteValeur
X-Lector-Chain-Idid, 32 hex minuscules
X-Lector-Chain-ProofP en base64url, 43 caractères
X-Lector-Devicefacultatif : identifiant aléatoire de l'installation (1 à 64 caractères parmi [A-Za-z0-9_-]), sert seulement à marquer les événements du flux ; jamais stocké

Ordre des contrôles, sur toutes les routes (création comprise) :

  1. en-têtes absents ou mal formés : 400 CHAIN_ID_INVALID, puis CHAIN_PROOF_MISSING ;
  2. casier inconnu : 404 CHAIN_UNKNOWN (jamais créé, effacé, ou renouvelé ailleurs) ;
  3. preuve fausse : 403 CHAIN_PROOF_INVALID, puis 429 CHAIN_PROOF_LIMITED (§ 13) ; la bonne preuve passe toujours ;
  4. lastSeenAt mis à jour au plus une fois par heure.

La preuve n'est jamais dans une URL : toutes les requêtes, flux compris, la portent dans les en-têtes. Toutes les réponses portent Cache-Control: no-store. CORS expose ETag, X-Lector-Path-Enc et X-Lector-Mtime.

5.2 Routes

RouteRequêteRéponseRefus
POST /v1/chainaucun corps : l'id et la preuve viennent des en-têtes201 créé, 200 s'il existe déjà avec la même preuve (reprise après coupure) ; { chainId, bytesUsed, quotaBytes, maxDocBytes, maxDocs, docCount }403 CHAIN_PROOF_INVALID (existe avec une autre preuve) ; 429 CHAIN_CREATE_LIMITED ; 503 CHAIN_CAPACITY
GET /v1/chainmêmes champs404, 403, 429
DELETE /v1/chain204 : la ligne Chain d'abord, qui emporte ses documents (tout dépôt en vol échoue), puis le dossier ; les flux ouverts sont fermés, et un lecteur qui n'a pas tout lu est coupé net404, 403
GET /v1/chain/manifestmêmes champs + docs: [{ docId, pathEnc, sha256, size, mtime, deletedAt, tombEnc }], pierres tombales de moins de 30 jours comprises404, 403
PUT /v1/chain/docs/:docIdcorps binaire = bloc ; X-Lector-Path-Enc ; X-Lector-Mtime (date ISO entre les années 0000 et 9999, sinon 400 MTIME_INVALID ; absent : la date du serveur) ; If-Match: <sha256 du bloc attendu> (guillemets, W/ et * acceptés) ou If-None-Match: * pour une création, y compris par-dessus une pierre tombale ; sans les deux, écriture inconditionnelle200 { docId, sha256, size, bytesUsed } et ETag412 { code: CONFLICT, current } ; 413 DOC_TOO_LARGE { maxDocBytes }, QUOTA_EXCEEDED { bytesUsed, quotaBytes, size }, TOO_MANY_DOCS { docCount, maxDocs } ; 400 DOC_ID_INVALID, PATH_ENC_INVALID, MTIME_INVALID, ENVELOPE_INVALID
GET /v1/chain/docs/:docIdbloc brut ; ETag = SHA-256 du bloc (sans guillemets), X-Lector-Path-Enc, X-Lector-Mtime404 DOC_UNKNOWN ; 410 DOC_DELETED { deletedAt }
DELETE /v1/chain/docs/:docIdX-Lector-Tomb-Enc obligatoire ; If-Match facultatif (les clients l'envoient dès qu'ils connaissent l'empreinte du bloc)204, aussi quand le document est déjà supprimé ; bloc effacé du disque, place rendue. Au plus maxDocs pierres tombales par casier : les plus anciennes partent d'abord (un appareil qui l'a manquée renvoie le document, comme après 30 jours)400 TOMB_ENC_INVALID ; 404 DOC_UNKNOWN ; 412 CONFLICT
GET /v1/chain/verify{ chain (8 caractères), blocs, octets, blocsManquants, empreintesFausses, versionsInconnues, blocsSuspects, orphelins, compteurs, attendu, ok }404, 403
GET /v1/chain/streamlu par fetch (en-têtes), jamais par EventSourceSSE : : connected, puis event: vault-change avec { kind: 'put' | 'delete', docId, actorDevice }, et : ping toutes les 25 s503 STREAM_CAPACITY (20 flux par chaîne, 10 par IP, un /64 en IPv6, 500 au total) ; un flux qui laisse plus de 64 Kio non lus est coupé, et le client se reconnecte
GET /v1/health, /v1/releases/*inchangées

maxDocBytes est la taille maximale du clair d'un document : un bloc est accepté jusqu'à maxDocBytes + 29 octets. ENVELOPE_INVALID couvre un bloc de moins de 29 octets, une version différente de 1, et un bloc qui ressemble à du clair (partie chiffrée de 32 octets ou plus qui se lit en UTF-8 valide, ou entropie inférieure à 7,5 bits par octet sur un bloc d'au moins 1 Kio) : garde-fou contre un client bogué, pas une preuve.

Toutes les routes de l'ancienne version (/v1/auth/*, /v1/sync/*, /v1/licenses/*, /v1/webhooks/*, /v1/chat/*, /v1/tts/*, /v1/reader/*) répondent 404. Une ancienne app lit le manifeste en premier : elle s'arrête en erreur avant toute action, sans rien effacer.

5.3 Vérification

GET /v1/chain/verify (pour l'appareil, avec la preuve) et node scripts/verifier-chaine.js --chain <id> | --all [--recount] (sur le serveur, sortie JSON { chaines, problemes, fantomes, rapports }, code de sortie 0 si tout va bien, 1 s'il reste un problème, 2 pour une erreur d'usage ou une chaîne inconnue) contrôlent : chaque bloc vivant existe, a la bonne taille, la bonne empreinte, commence par 0x01, ne ressemble pas à du clair ; aucun fichier orphelin ; compteurs égaux à la somme des lignes ; avec --all, aucun dossier de casier sans ligne en base (fantôme). --recount recalcule les compteurs et supprime orphelins et fantômes ; à lancer API arrêtée.

6. Synchronisation côté client

6.1 Base locale

<vault>/.lector-sync/chaine.json :

{
  "version": 2,
  "chainId": "2149...",
  "docs": { "Pro/Notes.md": { "docId": "99e9...", "sha256Clair": "47dd...", "sha256Bloc": "ded3...", "size": 7, "mtime": "..." } }
}

Deux empreintes : celle du clair (comparée au disque) et celle du bloc (comparée au manifeste). Rien de dérivé du clair ne part au serveur. Champ facultatif horsDisque: true : document du casier gardé sans fichier sur ce disque, parce qu'un fichier local occupe déjà son nom à la casse près (§ 6.2) ; il n'est jamais supprimé depuis cet appareil. Une base d'une autre version (dont la version: 1 de la branche avec compte) est ignorée et reconstruite : la synchro suivante se comporte comme une adoption (§ 6.3), sans rien supprimer.

6.2 Trois voies (synchroniserChaine, front/src/services/chaine/synchro.js)

6.3 Adoption

Un appareil qui rejoint une chaîne avec un vault déjà rempli (ou après une nouvelle chaîne) n'a pas de base pour ce casier. Pour un document présent des deux côtés, il télécharge le bloc : même clair, la base est enregistrée ; seul le local a bougé depuis la base de la chaîne précédente, il part ; seul le serveur a bougé, il est pris ; sinon, copie de conflit. Les fichiers présents d'un seul côté sont envoyés ou téléchargés, avec la règle de casse du § 6.2. Rien n'est supprimé pendant l'adoption.

6.4 Ordinateur (Electron)

La synchro tourne dans le processus principal (front/desktop/sync-manager.js) : au démarrage, à chaque changement d'un fichier markdown du vault, toutes les 30 s, et dès qu'un autre appareil annonce une écriture dans le flux (GET /v1/chain/stream, lu par fetch avec les en-têtes ; ses propres échos sont filtrés par actorDevice). Une réponse CHAIN_UNKNOWN ou CHAIN_PROOF_INVALID arrête la boucle (chain-unknown, chain-proof) sans rien toucher au disque. Un cycle à relancer (412, fichier changé pendant le cycle) repart après 1 s, puis 2 s, 4 s... jusqu'à 30 s. stop() interrompt le cycle avant sa prochaine écriture et l'attend (5 s au plus) ; après stop(), rien ne repart, et un stop() pendant start() laisse la synchro arrêtée. getStatus() donne aussi notWritten, le nombre de documents que le disque refuse (§ 6.2). Lector écrit LECTOR-FORMATS.md à la racine du vault ; il voyage comme les autres documents.

6.5 Navigateur et mobile

Pas de vault local : fileSystem.listDirectoryFiles('cloud://') lit le manifeste et déchiffre les pathEnc pour construire l'arbre ; openFileByPath télécharge puis déchiffre ; writeFile chiffre puis dépose avec If-Match = empreinte du bloc lu par cette session (VaultWebChaine, une instance par session). Un document modifié ailleurs après sa lecture donne 412, jamais un écrasement. L'app iOS et Android (Capacitor) utilise le même module que le navigateur. Le web n'écoute pas le flux temps réel : il relit le manifeste chaque fois que l'arbre est rechargé.

7. Markdown seulement, limite par document et quota

Filtre client, dans les deux sens (estMarkdown) :

Le serveur ne peut pas faire ce contrôle : chemins et contenus sont chiffrés. Ses seules gardes sont la taille d'un bloc et le quota.

Limite par document : un fichier de plus de maxDocBytes octets (2 Mo par défaut) n'est pas envoyé et n'entraîne aucune requête refusée ; il est signalé (tooLarge) et reste sur l'appareil.

Quota : le client lit la place libre dans le manifeste et n'envoie pas ce qui serait refusé. Casier plein : les documents en attente sont comptés (quotaPending), un seul événement quota est émis à l'entrée dans cet état, la synchro continue pour le reste (téléchargements, suppressions). Si le serveur répond quand même 413 (un autre appareil a pris la place entre-temps), le cycle cesse d'envoyer et se termine normalement : ce n'est jamais une erreur fatale, rien n'est supprimé localement. Une suppression rend la place ; les documents en attente partent au cycle suivant.

À la création, l'écran montre avant l'envoi le nombre de fichiers markdown et la taille des blocs ; l'envoi est partiel si la place manque, la chaîne est créée quand même.

8. Écrans

Un seul écran, Synchronisation (ChaineSync.jsx), textes sync.* et codes cloud.chain.err.* en anglais et en français. Chaque vue a une explication courte, un « Pourquoi ? » et un lien « Détails techniques » vers ce document, publié par le site de Lector (https://lector.dockerbop.fr/sync/, page front/landing/sync/index.html produite par node tools/page-sync.mjs).

VueContenu
Proposer« Protéger et synchroniser » (créer) ou « Rejoindre ». Jamais bloquant.
12 motsGrille numérotée, avertissement de perte, formulaire du gestionnaire de mots de passe (§ 9), kit de secours à imprimer, case « Je les ai notés ». Sur le web : ce navigateur ne les montrera plus. Pas de bouton copier.
VérificationTrois mots demandés au hasard. « Vérifier plus tard » est possible : un rappel discret reste dans l'état tant que les mots ne sont pas confirmés.
Progression (ordinateur)Estimation (fichiers, taille, place du casier), puis barre d'envoi.
RejoindreOnglets « Scanner » (si BarcodeDetector et une caméra sont disponibles) et « 12 mots » (champ masqué autocomplete="current-password"), bouton « Depuis mon gestionnaire de mots de passe » quand le navigateur sait le faire. Erreurs distinctes : mots invalides (mot inconnu, somme fausse, nombre de mots) et « aucune chaîne ne correspond à ces mots » (404). Jamais de création depuis cet écran.
ÉtatPlace utilisée et nombre de documents, état de la synchro (documents trop gros, casier plein, blocs illisibles ou d'un autre type, documents que le disque refuse), rappel de sauvegarde. Ordinateur : dossier du vault, « Ajouter un appareil », « Nouvelle chaîne », « Vérifier ». Partout : « Quitter la chaîne sur cet appareil », « Tout effacer ».
Ajouter un appareil (ordinateur)Derrière le verrou (Touch ID, 5 min ; sans Touch ID, confirmation, 60 s) : QR du code et 12 mots, masqués après 60 s. L'explication dit qui peut scanner le QR (§ 3.6).
Opération coupéeCréation ou renouvellement interrompu : reprendre ou abandonner. Un renouvellement refusé par ROTATION_INCOMPLETE (§ 11.1) liste les documents en cause.
Chaîne inconnue« Cette chaîne n'existe plus : renouvelée ou effacée depuis un autre appareil. » Rejoindre avec les nouveaux mots ; sur ordinateur, créer une chaîne depuis ce vault.
Nouvelle chaîne§ 11, avec ce que ça garantit et ne garantit pas. Derrière le verrou : tirer les nouveaux mots et lancer le renouvellement.
Tout effacerIl faut taper le mot affiché (ERASE ou EFFACER) ; sur ordinateur, verrou ouvert. Sur le web et le mobile, l'explication dit que le casier est peut-être la seule copie des documents sur cet appareil.
Clés API (écran à part)§ 10.

Kit de secours (KitSecours.jsx, window.print, sans dépendance) : les 12 mots numérotés, le QR du code, et une page « Pour un proche » (où est la clé, quelle app, comment récupérer et lire les documents, adresse de ce document sur le site de Lector).

Médias : sur l'appareil qui a le fichier, les images relatives s'affichent ; ailleurs, un encart « Image non synchronisée : seul le markdown voyage entre les appareils ».

9. Gestionnaire de mots de passe

10. Stockage local du secret et des clés API

PlateformeSecret de chaîneClés API (Anthropic, ElevenLabs)
Ordinateur (Electron)S en hex dans userData/cloud-secrets.dat ({ vaultPath, serverUrl, deviceId, chainSecret, chainId, chainPendingSecret, chainSaved }), chiffré par safeStorage (Trousseau macOS, DPAPI, libsecret). Sans chiffrement système (Linux sans trousseau) : JSON en mode 0600 et avertissement dans l'écran. Le renderer ne reçoit jamais S : seulement hasChain, et les mots pour affichage, à la création ou verrou ouvert. Nouvelle chaîne (mots et lancement) et Tout effacer exigent aussi le verrou ouvert (LOCKED sinon) : un script qui tournerait dans le renderer ne peut ni faire passer la chaîne sur des mots qu'il connaît, ni effacer le casier, sans Touch ID là où il existe.userData/api-keys.dat, safeStorage. Sans trousseau : fichier 0600 en clair, et l'écran le dit au lieu d'annoncer un chiffrement (keys:get renvoie secure). Les appels partent du processus principal (net.fetch) : la clé ne va pas au renderer, qui ne voit que les 4 derniers caractères. Le Claude CLI local reste la voie par défaut quand il est installé.
Navigateur, iOS, AndroidIndexedDB lector-chaine : { version, chainId, preuve, cleEnc, cleMac }, clés CryptoKey non exportables ; S est effacé de la mémoire après preparer. Un script qui s'exécuterait sur la page pourrait utiliser les clés sur place, pas emporter S. La preuve est lisible (il faut l'envoyer) : elle permet d'écrire ou d'effacer, pas de lire.IndexedDB lector-keys, chiffrées en AES-GCM par une CryptoKey non exportable rangée dans la même base. Appels directs (anthropic-dangerous-direct-browser-access: true, xi-api-key). L'écran le dit : « Ta clé reste sur cet appareil. Sur le web, elle est lisible par tout script de la page : ne l'utilise que sur un appareil de confiance. »

Les anciennes données locales de la version avec compte (jeton, licence, identifiants d'appareil, entrée IndexedDB liée à un compte) sont effacées au premier démarrage de la 2.0. Le vault reste.

11. Nouvelle chaîne, tout effacer, quitter

11.1 Nouvelle chaîne (rotation côté client)

À faire quand un appareil est perdu ou volé, ou qu'on n'a plus confiance dans quelqu'un qui a vu les 12 mots. Disponible sur ordinateur (appareil qui a tout le vault), verrou ouvert. renouvelerChaine :

  1. tire un nouveau S' et affiche ses 12 mots ;
  2. synchronise avec l'ancien casier jusqu'à un cycle sans changement ;
  3. crée le nouveau casier (POST /v1/chain) et y envoie tout le vault, rechiffré : tous les docId changent ;
  4. relit le manifeste de l'ancien casier : si un autre appareil y a écrit entre-temps, récupère et renvoie, jusqu'à stabilité (5 passes au plus, sinon ROTATION_UNSTABLE) ;
  5. efface l'ancien casier (DELETE /v1/chain), seulement si le nouveau a tout reçu et que chaque document markdown du dernier manifeste de l'ancien est dans le nouveau casier ou sur le disque. Sinon 409 ROTATION_INCOMPLETE { missing: [chemins] } : rien n'est effacé, le secret reste en attente et l'écran liste ces documents (les corriger ou les retirer, puis reprendre, ou abandonner). Les blocs illisibles ou absents du serveur ne bloquent pas. Faute de place, le nouveau casier est retiré et l'ancien reste la chaîne.

Création et reprise (envoyerTout) : un bloc du casier inconnu de la base n'est ni supprimé ni écrasé. Même clair, il est gardé ; sinon la synchro suivante en fait une copie de conflit.

S' est gardé « en attente » (chainPendingSecret) pendant l'opération : une coupure se reprend, ou s'abandonne (le casier en cours est effacé ; si l'ancien n'existe déjà plus, le nouveau devient la chaîne). Les autres appareils reçoivent 404 CHAIN_UNKNOWN, s'arrêtent sans rien effacer, et rejoignent avec les nouveaux mots (adoption, § 6.3).

Garantit : les anciens mots n'ouvrent plus rien (l'ancien casier est effacé du serveur) ; les nouveaux docId ne se relient pas aux anciens. Ne garantit pas : ce qu'un ancien appareil a déjà sur son disque ; les sauvegardes du serveur antérieures, qui gardent les anciens blocs, lisibles avec l'ancienne clé, jusqu'à 30 jours sur le serveur et jusqu'à 6 mois dans les archives mensuelles ; l'effacement physique des blocs sur le disque du serveur ; un appareil espionné pendant qu'il était dans la chaîne.

11.2 Tout effacer

Depuis n'importe quel appareil qui a la chaîne (DELETE /v1/chain, sur ordinateur verrou ouvert) : la ligne du casier d'abord, qui emporte ses documents (tout dépôt en vol échoue), puis le dossier ; les flux ouverts sont fermés, un lecteur qui n'a pas tout lu est coupé net, les autres appareils reçoivent 404 et s'arrêtent. Cet appareil oublie S. Le vault local n'est pas touché, sur aucun ordinateur. Le web et le mobile n'ont pas de vault local (§ 6.5) : le casier y est peut-être la seule copie des documents, et l'écran le dit. Les sauvegardes du serveur gardent les blocs chiffrés jusqu'à 30 jours, et jusqu'à 6 mois dans les archives mensuelles. Recréer ensuite une chaîne, même avec le même S, est possible.

11.3 Quitter sur cet appareil

S (ou les clés, sur le web) est oublié et la synchro s'arrête. Le casier et les autres appareils ne changent pas ; la base locale est gardée pour une adoption plus tard.

11.4 S perdu partout

Si plus aucun appareil ne garde S et que la fiche est perdue, les blocs sont illisibles pour tout le monde, définitivement, et le casier ne peut plus être effacé par l'utilisateur (il faudrait P). La purge des casiers inactifs le supprime au bout de 12 mois (§ 14). Un appareil qui a encore le vault en clair crée une nouvelle chaîne.

12. Limites assumées

13. Anti-abus

Sans compte, les seules défenses hors quota sont en mémoire, par IP (back/services/limiteurs.js) :

Variables du serveur : DATABASE_URL, PORT, CHAIN_FILES_ROOT, CHAIN_QUOTA_BYTES (52428800), CHAIN_MAX_DOC_BYTES (2097152), CHAIN_MAX_DOCS (5000), CHAIN_MAX_CHAINS (10000), CHAIN_CREATE_PER_HOUR (5), CHAIN_RATE_LIMITS, RELEASE_NOTIFY_TOKEN. Exemple : back/.env.example.

14. Purge des orphelins

La purge tourne au démarrage de l'API puis toutes les 24 heures (back/services/casiers.js) :

C'est le sort des casiers dont tous les appareils et la fiche ont disparu (§ 11.4). Un appareil qui synchronise même une fois par an garde son casier. verifier-chaine.js --all signale aussi les fantômes et les fichiers sans ligne (orphelins, après un arrêt brutal entre l'écriture du bloc et celle de la ligne) ; --recount les supprime.

15. Modèle de menace en 10 lignes

  1. Le serveur, son hébergeur et un intrus sur la machine ne lisent ni contenus, ni chemins, ni titres : ils n'ont que des blocs AES-256-GCM.
  2. Ils voient des métadonnées : nombre et taille des blocs, dates, rythme, IP au passage (§ 2).
  3. Ils peuvent cacher, refuser ou resservir un ancien bloc ; pas fabriquer, modifier, déplacer, ni faire effacer un fichier local.
  4. Qui a les 12 mots ou le QR a tout : ils se gardent comme une clé de coffre, jamais en photo ni dans un message.
  5. Qui a seulement la preuve peut abîmer le casier, pas le lire ; les appareils gardent leurs fichiers.
  6. Qui connaît seulement l'id n'obtient rien, pas même la taille du casier ; ses fausses preuves ne bloquent pas le propriétaire.
  7. Un appareil perdu se coupe par « Nouvelle chaîne » ; ce qu'il a déjà sur son disque reste lisible par qui le tient.
  8. Les sauvegardes du serveur gardent les anciens blocs jusqu'à 6 mois, lisibles avec l'ancienne clé.
  9. Sur le web, un script hostile dans la page pourrait utiliser les clés sur place ; il ne peut pas emporter S. La politique de sécurité du contenu et l'assainissement des documents le rendent improbable (docs/SECURITE.md).
  10. Les mots perdus partout, le casier est perdu pour tout le monde, et purgé après 12 mois sans activité.

16. Tests

Suites, commandes et résultats : docs/CHAINE-TESTS.md.