brainclaw 1.20.4 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-cloud.js +63 -0
  3. package/dist/cli.js +2 -3
  4. package/dist/commands/cloud.js +198 -0
  5. package/dist/commands/export.js +3 -3
  6. package/dist/commands/init.js +11 -0
  7. package/dist/commands/mcp-write-claims.js +162 -46
  8. package/dist/commands/mcp-write-entities.js +67 -0
  9. package/dist/commands/mcp.js +64 -1
  10. package/dist/commands/session-end.js +0 -102
  11. package/dist/commands/session-start.js +0 -23
  12. package/dist/commands/switch.js +41 -12
  13. package/dist/core/actions.js +25 -1
  14. package/dist/core/agent-files.js +19 -0
  15. package/dist/core/agentruns.js +68 -10
  16. package/dist/core/assignments.js +94 -19
  17. package/dist/core/claims.js +13 -24
  18. package/dist/core/config.js +58 -0
  19. package/dist/core/context-diff.js +28 -11
  20. package/dist/core/coordination.js +1 -3
  21. package/dist/core/entity-locator.js +404 -0
  22. package/dist/core/federation-attestation.js +96 -0
  23. package/dist/core/federation-canonical.js +95 -0
  24. package/dist/core/federation-hpke.js +213 -0
  25. package/dist/core/federation-inbound.js +187 -0
  26. package/dist/core/federation-keyring.js +241 -0
  27. package/dist/core/federation-message.js +5 -5
  28. package/dist/core/federation-outbox-v2.js +125 -0
  29. package/dist/core/federation-pairing.js +213 -0
  30. package/dist/core/federation-projection.js +336 -0
  31. package/dist/core/federation-relay.js +223 -0
  32. package/dist/core/federation-state.js +270 -0
  33. package/dist/core/identity.js +9 -1
  34. package/dist/core/ids.js +5 -0
  35. package/dist/core/io.js +39 -1
  36. package/dist/core/operations/relocate.js +40 -10
  37. package/dist/core/schema.js +24 -17
  38. package/dist/core/sequence.js +47 -6
  39. package/dist/core/store-resolution.js +99 -26
  40. package/dist/core/workspace-projects.js +23 -2
  41. package/dist/core/worktree.js +59 -1
  42. package/dist/facts.js +7 -7
  43. package/dist/facts.json +6 -6
  44. package/docs/cli.md +73 -40
  45. package/docs/concepts/federation-v2-rfc.md +275 -0
  46. package/docs/index.md +1 -0
  47. package/package.json +2 -2
  48. package/dist/cli/register-federation.js +0 -258
  49. package/dist/core/federation-cloud.js +0 -245
  50. package/dist/core/federation-outbox.js +0 -292
  51. package/dist/core/federation-signing.js +0 -115
@@ -0,0 +1,213 @@
1
+ /**
2
+ * HPKE mode base — DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / ChaCha20-Poly1305.
3
+ * Suite v1 du RFC fédération §3.1, conforme à la RFC 9180.
4
+ *
5
+ * ── POURQUOI CE CODE EXISTE PLUTÔT QU'UNE DÉPENDANCE ──────────────────────────
6
+ * Node n'expose pas HPKE. Le projet tient à zéro dépendance d'exécution hors
7
+ * commander/yaml/zod, et ajouter une bibliothèque de crypto pour un usage aussi restreint
8
+ * — mode base, une suite, chiffrement à un coup — élargirait la surface d'audit bien
9
+ * au-delà du besoin.
10
+ *
11
+ * CE QUI EST IMPLÉMENTÉ, ET CE QUI NE L'EST PAS. Uniquement le MODE BASE (`mode = 0x00`)
12
+ * en un seul coup. Ni PSK, ni authentification de l'expéditeur, ni API à secret exporté,
13
+ * ni chiffrements multiples sur un même contexte. L'authenticité de l'émetteur ne repose
14
+ * PAS sur HPKE ici : elle vient de la signature Ed25519 d'origine, portée dans
15
+ * l'enveloppe et vérifiée par tout lecteur (RFC §3.1). Confondre les deux serait une
16
+ * erreur d'architecture — HPKE en mode base ne dit rien de QUI a chiffré.
17
+ *
18
+ * TOUTES LES CONSTANTES SONT CELLES DE LA RFC 9180 et sont vérifiées contre le vecteur de
19
+ * test A.2 dans tests/unit/federation-hpke.test.ts. Un « ça a l'air juste » sur du code
20
+ * cryptographique ne vaut rien : soit le vecteur officiel passe, soit l'implémentation
21
+ * est fausse.
22
+ */
23
+ import crypto from 'node:crypto';
24
+ // Identifiants d'algorithmes (RFC 9180 §7). Encodés en 16 bits gros-boutiste dans suite_id.
25
+ const KEM_ID = 0x0020; // DHKEM(X25519, HKDF-SHA256)
26
+ const KDF_ID = 0x0001; // HKDF-SHA256
27
+ const AEAD_ID = 0x0003; // ChaCha20-Poly1305
28
+ const NH = 32; // taille de sortie de SHA-256
29
+ const NK = 32; // taille de clé ChaCha20-Poly1305
30
+ const NN = 12; // taille de nonce ChaCha20-Poly1305
31
+ const MODE_BASE = 0x00;
32
+ export const HPKE_SUITE = 'HPKE-v1/X25519-HKDF-SHA256-CHACHA20POLY1305';
33
+ function concat(...parts) {
34
+ const total = parts.reduce((n, p) => n + p.length, 0);
35
+ const out = new Uint8Array(total);
36
+ let off = 0;
37
+ for (const p of parts) {
38
+ out.set(p, off);
39
+ off += p.length;
40
+ }
41
+ return out;
42
+ }
43
+ function u16(n) {
44
+ return new Uint8Array([(n >> 8) & 0xff, n & 0xff]);
45
+ }
46
+ function ascii(s) {
47
+ return new TextEncoder().encode(s);
48
+ }
49
+ /** RFC 9180 §4 : suite_id du KEM, distinct de celui du contexte HPKE. */
50
+ function kemSuiteId() {
51
+ return concat(ascii('KEM'), u16(KEM_ID));
52
+ }
53
+ /**
54
+ * RFC 9180 §5.1 : suite_id du contexte, couvrant KEM, KDF et AEAD.
55
+ *
56
+ * `aeadId` est paramétrable UNIQUEMENT pour la validation par vecteurs. La RFC ne publie
57
+ * en clair dans son corps que l'appendice A.1 (AES-128-GCM) ; pouvoir instancier le
58
+ * schedule sous cet identifiant permet de vérifier toute la machinerie
59
+ * labeled_extract/labeled_expand contre des valeurs officielles, plutôt que de se
60
+ * contenter d'un aller-retour interne qui passerait tout aussi bien avec deux erreurs
61
+ * symétriques. La suite de production reste figée à ChaCha20-Poly1305.
62
+ */
63
+ function hpkeSuiteId(aeadId = AEAD_ID) {
64
+ return concat(ascii('HPKE'), u16(KEM_ID), u16(KDF_ID), u16(aeadId));
65
+ }
66
+ function labeledExtract(suiteId, salt, label, ikm) {
67
+ // labeled_ikm = "HPKE-v1" || suite_id || label || ikm (RFC 9180 §4)
68
+ const labeledIkm = concat(ascii('HPKE-v1'), suiteId, ascii(label), ikm);
69
+ return new Uint8Array(crypto.createHmac('sha256', Buffer.from(salt)).update(Buffer.from(labeledIkm)).digest());
70
+ }
71
+ function labeledExpand(suiteId, prk, label, info, length) {
72
+ const labeledInfo = concat(u16(length), ascii('HPKE-v1'), suiteId, ascii(label), info);
73
+ // HKDF-Expand (RFC 5869) : T(i) = HMAC(prk, T(i-1) || info || i)
74
+ const out = new Uint8Array(length);
75
+ let t = new Uint8Array(0);
76
+ let off = 0;
77
+ for (let i = 1; off < length; i++) {
78
+ t = new Uint8Array(crypto.createHmac('sha256', Buffer.from(prk))
79
+ .update(Buffer.from(concat(t, labeledInfo, new Uint8Array([i]))))
80
+ .digest());
81
+ const take = Math.min(t.length, length - off);
82
+ out.set(t.subarray(0, take), off);
83
+ off += take;
84
+ }
85
+ return out;
86
+ }
87
+ function extractAndExpand(dh, kemContext) {
88
+ const suiteId = kemSuiteId();
89
+ const eaePrk = labeledExtract(suiteId, new Uint8Array(0), 'eae_prk', dh);
90
+ return labeledExpand(suiteId, eaePrk, 'shared_secret', kemContext, NH);
91
+ }
92
+ // ── Conversions de clés X25519 ────────────────────────────────────────────────
93
+ /**
94
+ * Octets bruts (32) d'une clé publique X25519 depuis son PEM SPKI.
95
+ *
96
+ * L'en-tête SPKI d'une X25519 fait exactement 12 octets et est constant pour cet
97
+ * algorithme ; les 32 derniers octets sont la clé. On prend la FIN du DER plutôt qu'un
98
+ * décalage fixe depuis le début : le préfixe pourrait varier d'un encodeur à l'autre,
99
+ * la longueur de la clé, non.
100
+ */
101
+ export function rawPublicKey(pem) {
102
+ const der = crypto.createPublicKey(pem).export({ type: 'spki', format: 'der' });
103
+ return new Uint8Array(der.subarray(der.length - 32));
104
+ }
105
+ function publicKeyFromRaw(raw) {
106
+ // Préfixe SPKI de X25519 : SEQUENCE { SEQUENCE { OID 1.3.101.110 }, BIT STRING }
107
+ const prefix = Buffer.from('302a300506032b656e032100', 'hex');
108
+ return crypto.createPublicKey({
109
+ key: Buffer.concat([prefix, Buffer.from(raw)]),
110
+ format: 'der',
111
+ type: 'spki',
112
+ });
113
+ }
114
+ // ── KEM : encapsulation / décapsulation ───────────────────────────────────────
115
+ function encapsulate(recipientPublicPem) {
116
+ const ephemeral = crypto.generateKeyPairSync('x25519');
117
+ const recipientKey = crypto.createPublicKey(recipientPublicPem);
118
+ const dh = new Uint8Array(crypto.diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipientKey }));
119
+ const enc = new Uint8Array(ephemeral.publicKey.export({ type: 'spki', format: 'der' }).subarray(-32));
120
+ const pkRm = rawPublicKey(recipientPublicPem);
121
+ // kem_context = enc || pkRm — l'ordre est normatif ; l'inverser produit un secret
122
+ // différent des deux côtés et un échec de déchiffrement sans explication.
123
+ return { sharedSecret: extractAndExpand(dh, concat(enc, pkRm)), enc };
124
+ }
125
+ function decapsulate(enc, recipientPrivateKey) {
126
+ const ephemeralPublic = publicKeyFromRaw(enc);
127
+ const dh = new Uint8Array(crypto.diffieHellman({ privateKey: recipientPrivateKey, publicKey: ephemeralPublic }));
128
+ const pkRm = new Uint8Array(crypto.createPublicKey(recipientPrivateKey).export({ type: 'spki', format: 'der' }).subarray(-32));
129
+ return extractAndExpand(dh, concat(enc, pkRm));
130
+ }
131
+ // ── Contexte HPKE ─────────────────────────────────────────────────────────────
132
+ function keySchedule(sharedSecret, info, suite = { aeadId: AEAD_ID, nk: NK, nn: NN }) {
133
+ const suiteId = hpkeSuiteId(suite.aeadId);
134
+ // En mode base, psk et psk_id sont vides — mais leurs hachages entrent QUAND MÊME dans
135
+ // le contexte. Les omettre donnerait un contexte différent de toute autre
136
+ // implémentation conforme.
137
+ const pskIdHash = labeledExtract(suiteId, new Uint8Array(0), 'psk_id_hash', new Uint8Array(0));
138
+ const infoHash = labeledExtract(suiteId, new Uint8Array(0), 'info_hash', info);
139
+ const keyScheduleContext = concat(new Uint8Array([MODE_BASE]), pskIdHash, infoHash);
140
+ const secret = labeledExtract(suiteId, sharedSecret, 'secret', new Uint8Array(0));
141
+ return {
142
+ key: labeledExpand(suiteId, secret, 'key', keyScheduleContext, suite.nk),
143
+ baseNonce: labeledExpand(suiteId, secret, 'base_nonce', keyScheduleContext, suite.nn),
144
+ keyScheduleContext,
145
+ secret,
146
+ };
147
+ }
148
+ /**
149
+ * Scelle un texte clair pour un destinataire, lié à un AAD.
150
+ *
151
+ * L'AAD est passé en OCTETS DÉJÀ CANONIQUES, jamais en objet : la canonicalisation
152
+ * appartient à l'appelant, qui doit produire exactement les mêmes octets à la
153
+ * vérification. L'accepter en objet ici inviterait deux canonicalisations différentes.
154
+ *
155
+ * `seq` vaut 0 : un contexte n'est utilisé que pour UN chiffrement. Le RFC interdit toute
156
+ * répétition du couple de contexte de nonce, et la garantie la plus simple est qu'un
157
+ * contexte ne serve jamais deux fois — chaque appel génère une clé éphémère neuve.
158
+ */
159
+ export function seal(params) {
160
+ const { sharedSecret, enc } = encapsulate(params.recipientPublicKeyPem);
161
+ const { key, baseNonce } = keySchedule(sharedSecret, params.info ?? new Uint8Array(0));
162
+ const cipher = crypto.createCipheriv('chacha20-poly1305', Buffer.from(key), Buffer.from(baseNonce), {
163
+ authTagLength: 16,
164
+ });
165
+ cipher.setAAD(Buffer.from(params.aadCanonicalBytes), { plaintextLength: params.plaintext.length });
166
+ const body = Buffer.concat([cipher.update(Buffer.from(params.plaintext)), cipher.final()]);
167
+ const tag = cipher.getAuthTag();
168
+ return {
169
+ alg: HPKE_SUITE,
170
+ enc: Buffer.from(enc).toString('base64url'),
171
+ nonce: Buffer.from(baseNonce).toString('base64url'),
172
+ ciphertext: Buffer.concat([body, tag]).toString('base64url'),
173
+ };
174
+ }
175
+ /**
176
+ * Ouvre un blob scellé. Échoue FERMÉ au moindre octet d'AAD différent.
177
+ *
178
+ * Toute erreur est convertie en `undefined` plutôt qu'en exception détaillée : distinguer
179
+ * « mauvaise clé » de « AAD divergent » de « tag invalide » donnerait à un attaquant un
180
+ * oracle sur la cause de l'échec. L'appelant apprend seulement que ça n'ouvre pas.
181
+ */
182
+ export function open(params) {
183
+ try {
184
+ if (params.sealed.alg !== HPKE_SUITE)
185
+ return undefined;
186
+ const enc = new Uint8Array(Buffer.from(params.sealed.enc, 'base64url'));
187
+ const sharedSecret = decapsulate(enc, params.recipientPrivateKey);
188
+ const { key, baseNonce } = keySchedule(sharedSecret, params.info ?? new Uint8Array(0));
189
+ // Le nonce annoncé DOIT être celui dérivé du schedule. Accepter un nonce arbitraire
190
+ // laisserait un émetteur en réutiliser un — la faute la plus grave possible sur un
191
+ // AEAD à nonce, qui trahit le clair de deux messages d'un simple XOR.
192
+ const announced = Buffer.from(params.sealed.nonce, 'base64url');
193
+ if (!announced.equals(Buffer.from(baseNonce)))
194
+ return undefined;
195
+ const raw = Buffer.from(params.sealed.ciphertext, 'base64url');
196
+ if (raw.length < 16)
197
+ return undefined;
198
+ const body = raw.subarray(0, raw.length - 16);
199
+ const tag = raw.subarray(raw.length - 16);
200
+ const decipher = crypto.createDecipheriv('chacha20-poly1305', Buffer.from(key), Buffer.from(baseNonce), {
201
+ authTagLength: 16,
202
+ });
203
+ decipher.setAAD(Buffer.from(params.aadCanonicalBytes), { plaintextLength: body.length });
204
+ decipher.setAuthTag(tag);
205
+ return new Uint8Array(Buffer.concat([decipher.update(body), decipher.final()]));
206
+ }
207
+ catch {
208
+ return undefined;
209
+ }
210
+ }
211
+ /** Exposé pour les vecteurs de test RFC 9180 ; hors de ce cadre, utiliser `seal`/`open`. */
212
+ export const __testing = { labeledExtract, labeledExpand, keySchedule, kemSuiteId, hpkeSuiteId, extractAndExpand };
213
+ //# sourceMappingURL=federation-hpke.js.map
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Vérification à la réception — fédération v2 (pln#651 étape 6, RFC §6).
3
+ *
4
+ * ── LE TROU QUE CE MODULE FERME ───────────────────────────────────────────────
5
+ * Avant lui, `pullSignalsFromCloud` faisait un simple `as { messages: … }` sans AUCUNE
6
+ * vérification, puis `materializeFederationSignal` écrivait dans la mémoire locale via
7
+ * saveCandidate et saveRuntimeNote. La signature Ed25519 existante était TRANSPORT
8
+ * uniquement, edge→cloud, dans des en-têtes : elle n'était pas persistée dans l'enveloppe
9
+ * et ne disait rien de l'origine du contenu.
10
+ *
11
+ * Conséquence : un cloud malveillant ne lisait pas la roadmap — elle est chiffrée — mais
12
+ * pouvait INJECTER des candidates et des runtime_notes dans la mémoire de chaque agent
13
+ * enrôlé. Soit un canal d'injection de prompt vers toute la flotte. Trois critiques sur
14
+ * quatre l'ont classé bloquant, par trois angles distincts.
15
+ *
16
+ * Chiffrer les LECTURES en laissant les ÉCRITURES forgeables par l'opérateur qu'on
17
+ * prétend neutraliser est le défaut structurel fermé ici.
18
+ *
19
+ * ── CE QUE LE CLOUD PEUT ENCORE FAIRE, ET QU'IL FAUT DIRE ─────────────────────
20
+ * Il peut RETARDER ou OMETTRE une enveloppe. Aucune cryptographie ne l'en empêche : un
21
+ * relais qui ne relaie pas est indétectable de l'intérieur. Ce qu'il ne peut plus faire,
22
+ * c'est injecter, rejouer une ancienne révision comme courante, ou réordonner des
23
+ * métadonnées sans être vu.
24
+ */
25
+ import crypto from 'node:crypto';
26
+ import { canonicalJson } from './federation-canonical.js';
27
+ import { open as hpkeOpen } from './federation-hpke.js';
28
+ import { FederationEnvelopeSchema, originSigningInput, } from './federation-projection.js';
29
+ import { acceptsRevision, recordRevision } from './federation-state.js';
30
+ function reject(reason, detail) {
31
+ return { ok: false, reason, detail };
32
+ }
33
+ /**
34
+ * Vérifie la signature d'origine sur les octets canoniques complets.
35
+ *
36
+ * Toute exception — PEM illisible, signature mal encodée, mauvaise longueur — rend
37
+ * `false` sans distinction. Laisser remonter l'erreur, ou différencier les causes,
38
+ * donnerait un oracle sur la RAISON du refus ; du point de vue de l'appelant il n'y a
39
+ * qu'un seul fait utile : ces octets ne sont pas signés par cette clé.
40
+ */
41
+ function verifyOriginSignature(env, signerPem) {
42
+ try {
43
+ return crypto.verify(null, originSigningInput(env.meta, env.sealed, env.key_epoch), crypto.createPublicKey(signerPem), Buffer.from(env.origin_sig.value, 'base64url'));
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ /**
50
+ * Vérifie une enveloppe entrante. AUCUNE écriture locale n'a lieu ici — la fonction est
51
+ * pure vis-à-vis du disque et rend un verdict.
52
+ *
53
+ * L'ORDRE DES CONTRÔLES EST UN CONTRAT (RFC §6) : parse strict → résolution du signataire
54
+ * → signature → AAD → révocation → déchiffrement → cohérence du clair → anti-rejeu →
55
+ * dédoublonnage. Déchiffrer AVANT d'avoir vérifié la signature exposerait le déchiffreur
56
+ * à un ciphertext choisi par l'attaquant ; vérifier l'anti-rejeu avant la signature
57
+ * laisserait un cloud faire avancer la barrière avec des révisions forgées, condamnant
58
+ * les révisions légitimes qui suivent.
59
+ */
60
+ export function verifyInbound(params) {
61
+ // (1) Parse STRICT. Une clé inconnue est refusée, pas retirée en silence : c'est le
62
+ // pendant entrant du filet 2 du projecteur.
63
+ const parsed = FederationEnvelopeSchema.safeParse(params.raw);
64
+ if (!parsed.success) {
65
+ return reject('schema_invalid', parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '));
66
+ }
67
+ const env = parsed.data;
68
+ // (2) Résoudre le signataire DANS LE ROSTER ATTESTÉ.
69
+ const signerPem = params.roster.keys.get(env.origin_sig.key_id);
70
+ if (!signerPem) {
71
+ return reject('unknown_signer', `key_id '${env.origin_sig.key_id}' absent du roster attesté.`);
72
+ }
73
+ // Révoqué et inconnu sont distingués : un opérateur doit pouvoir voir qu'un membre
74
+ // révoqué continue d'émettre, ce qu'un « inconnu » générique masquerait.
75
+ if (params.roster.revoked?.has(env.origin_sig.key_id)) {
76
+ return reject('revoked_signer', `key_id '${env.origin_sig.key_id}' révoqué.`);
77
+ }
78
+ // (3) Signature sur les octets canoniques COMPLETS : meta, sealed (alg, enc, nonce,
79
+ // ciphertext) et key_epoch. Couvrir meta est ce qui rend le réordonnancement de
80
+ // priorité, de dépendances ou de statut détectable — le Cloud peut lire ces champs,
81
+ // il ne peut pas les changer.
82
+ if (!verifyOriginSignature(env, signerPem)) {
83
+ return reject('bad_signature', "la signature d'origine ne couvre pas ces octets.");
84
+ }
85
+ // (4) L'AAD doit DÉCRIRE l'objet annoncé. Sans ce contrôle, une enveloppe légitime pour
86
+ // l'objet A, correctement signée, pourrait être présentée comme concernant l'objet B :
87
+ // la signature resterait valide puisqu'elle couvre meta, mais meta lui-même mentirait
88
+ // sur sa cohérence interne.
89
+ const aad = env.meta.aad;
90
+ if (aad.object_id !== env.meta.id_opaque
91
+ || aad.base_rev !== env.meta.base_rev
92
+ || aad.object_type !== env.meta.kind
93
+ || aad.cloud_project_id !== params.state.cloud_project_id) {
94
+ return reject('aad_mismatch', "l'AAD ne décrit pas l'objet annoncé par meta.");
95
+ }
96
+ // (5) Déchiffrement. Échoue FERMÉ : `hpkeOpen` ne distingue pas les causes, pour ne pas
97
+ // offrir d'oracle sur la raison de l'échec.
98
+ if (!params.epochPrivateKey) {
99
+ return reject('undecryptable', `aucune clé détenue pour l'epoch ${env.key_epoch}.`);
100
+ }
101
+ const plaintext = hpkeOpen({
102
+ recipientPrivateKey: params.epochPrivateKey,
103
+ sealed: env.sealed,
104
+ aadCanonicalBytes: new TextEncoder().encode(canonicalJson(aad)),
105
+ });
106
+ if (!plaintext) {
107
+ return reject('undecryptable', 'AEAD refusé (clé, AAD ou tag).');
108
+ }
109
+ let content;
110
+ try {
111
+ content = JSON.parse(new TextDecoder().decode(plaintext));
112
+ }
113
+ catch {
114
+ return reject('payload_type_mismatch', 'le clair déchiffré n\'est pas du JSON.');
115
+ }
116
+ if (content === null || typeof content !== 'object') {
117
+ return reject('payload_type_mismatch', 'le clair déchiffré n\'est pas un objet.');
118
+ }
119
+ // (6) ANTI-REJEU. L'AEAD détecte l'ALTÉRATION, pas le REJEU d'un ciphertext valide et
120
+ // ancien. Sans high-water mark, un cloud peut resservir un état antérieur comme s'il
121
+ // était courant, et rien dans la cryptographie ne s'y oppose.
122
+ //
123
+ // Le dédoublonnage est vérifié AVANT le rejeu : une enveloppe DÉJÀ matérialisée a
124
+ // légitimement une révision égale au high-water mark. La confondre avec un rejeu
125
+ // rendrait tout retour d'un retry indistinguable d'une attaque.
126
+ const idempotencyKey = env.meta.transport.idempotency_key;
127
+ if (params.seenIdempotencyKeys?.has(idempotencyKey)) {
128
+ return reject('duplicate', `opération déjà matérialisée (${idempotencyKey}).`);
129
+ }
130
+ if (!acceptsRevision(params.state, env.meta.id_opaque, env.meta.base_rev)) {
131
+ const seen = params.state.sync.high_water[env.meta.id_opaque];
132
+ return reject('replay_or_rollback', `révision ${env.meta.base_rev} refusée pour ${env.meta.id_opaque} : high-water mark à ${seen}.`);
133
+ }
134
+ return {
135
+ ok: true,
136
+ envelope: env,
137
+ content,
138
+ kind: env.meta.kind,
139
+ idempotencyKey,
140
+ nextState: recordRevision(params.state, env.meta.id_opaque, env.meta.base_rev),
141
+ };
142
+ }
143
+ /**
144
+ * Vérifie un lot d'enveloppes et rend le verdict de chacune.
145
+ *
146
+ * L'ÉTAT AVANCE AU FIL DU LOT, et l'ordre compte : deux révisions du même objet dans un
147
+ * même lot doivent être appliquées dans l'ordre croissant, sinon la seconde est refusée
148
+ * comme rollback — ce qui est le comportement voulu. Un lot est traité comme une suite
149
+ * d'opérations, jamais comme un ensemble à réordonner selon ce que le Cloud propose.
150
+ *
151
+ * Le dédoublonnage accumule les clés vues DANS le lot en plus de celles déjà connues :
152
+ * un cloud qui livrerait deux fois la même opération dans un seul lot serait sinon
153
+ * accepté deux fois.
154
+ */
155
+ export function verifyInboundBatch(params) {
156
+ const seen = new Set(params.seenIdempotencyKeys ?? []);
157
+ let state = params.state;
158
+ const results = [];
159
+ let accepted = 0;
160
+ for (const raw of params.envelopes) {
161
+ // L'epoch est lu sur l'enveloppe AVANT vérification, uniquement pour choisir une clé.
162
+ // Ce n'est pas une décision de confiance : une valeur mensongère mène simplement à
163
+ // une clé qui n'ouvrira pas, donc à un refus.
164
+ const epoch = typeof raw?.key_epoch === 'number'
165
+ ? raw.key_epoch
166
+ : undefined;
167
+ const result = verifyInbound({
168
+ raw,
169
+ roster: params.roster,
170
+ state,
171
+ epochPrivateKey: epoch === undefined ? undefined : params.epochKeys.get(epoch),
172
+ seenIdempotencyKeys: seen,
173
+ });
174
+ results.push(result);
175
+ if (result.ok) {
176
+ // L'état n'avance QUE sur acceptation. Une enveloppe refusée ne doit laisser aucune
177
+ // trace : ni barrière avancée, ni clé d'idempotence enregistrée. Sinon un cloud
178
+ // hostile empoisonnerait l'état avec des enveloppes invalides et condamnerait les
179
+ // révisions légitimes qui suivent.
180
+ state = result.nextState;
181
+ seen.add(result.idempotencyKey);
182
+ accepted++;
183
+ }
184
+ }
185
+ return { results, nextState: state, accepted, rejected: results.length - accepted };
186
+ }
187
+ //# sourceMappingURL=federation-inbound.js.map
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Fédération v2 — identité d'APPAREIL et trousseau multi-epoch (pln#651 étape 3).
3
+ *
4
+ * Création propre, AUCUNE migration (dec#156) : ce module ne lit ni `cloud_sync` ni
5
+ * `BRAINCLAW_CLOUD_*` pour reconstruire un état. Le chemin v1 a été démoli en étape 2.
6
+ *
7
+ * ── POURQUOI DEUX PAIRES DE CLÉS ET NON UNE ────────────────────────────────────
8
+ * L'appareil porte DEUX clés distinctes, et aucune ne se dérive de l'autre :
9
+ *
10
+ * Ed25519 (~/.brainclaw/keys/<agentId>.ed25519.pem, agent-registry.ts)
11
+ * → QUI PARLE. Signature d'origine des enveloppes, preuve de possession
12
+ * pendant l'appairage. C'est l'identité que le Cloud a déjà enregistrée.
13
+ *
14
+ * X25519 (~/.brainclaw/keys/<deviceId>.x25519.pem, ce module)
15
+ * → QUI PEUT LIRE. Destinataire des enveloppements HPKE qui remettent les
16
+ * clés privées d'epoch.
17
+ *
18
+ * Les dériver l'une de l'autre est tentant (une seule clé à sauvegarder) et faux :
19
+ * cela lierait la capacité de LECTURE à la capacité de SIGNATURE, alors que le RFC
20
+ * §5.1 en fait une propriété d'architecture — « écrire sans lire ». Un émetteur qui
21
+ * ne doit pas lire reçoit la clé publique de projet et son accès de signature, rien
22
+ * d'autre. Avec des clés dérivées, révoquer la lecture révoquerait l'écriture, et
23
+ * la compromission de l'une livrerait l'autre.
24
+ *
25
+ * ── PLAFOND DE SÉCURITÉ, ÉCRIT ICI PARCE QUE C'EST ICI QU'ON LIT LA CLÉ ────────
26
+ * `~/.brainclaw/keys/` est un répertoire du système de fichiers, lisible par TOUT
27
+ * processus tournant sous le même UID. Sur Windows le mode 0600 de `fs.chmod` est
28
+ * largement ignoré. Donc : la sécurité du chiffrement de bout en bout côté Cloud
29
+ * NE DÉPASSE PAS celle du disque local. Un malware ayant l'UID de l'utilisateur lit
30
+ * les clés d'epoch et déchiffre tout ce que l'appareil pouvait déchiffrer.
31
+ *
32
+ * Ce n'est pas un défaut à corriger dans ce step : TPM, enclave sécurisée et HSM
33
+ * sont explicitement une v2 ultérieure (RFC §5.1). C'est un plafond à ÉNONCER, pour
34
+ * qu'on ne vende pas au-delà de ce que la construction tient.
35
+ */
36
+ import crypto from 'node:crypto';
37
+ import fs from 'node:fs';
38
+ import os from 'node:os';
39
+ import path from 'node:path';
40
+ import { MEMORY_DIR } from './io.js';
41
+ import { nowISO } from './ids.js';
42
+ import { logger } from './logger.js';
43
+ /** Empreinte canonique d'une clé publique PEM — même règle que Ed25519 (agent-registry.ts). */
44
+ export function fingerprintKeyPem(publicKeyPem) {
45
+ return crypto.createHash('sha256').update(publicKeyPem.replace(/\r/g, '').trim()).digest('hex');
46
+ }
47
+ // ── Emplacements ──────────────────────────────────────────────────────────────
48
+ /** Racine neutre des secrets : ~/.brainclaw/ — JAMAIS le store de workspace. */
49
+ function keysRoot(home = os.homedir()) {
50
+ return path.join(home, MEMORY_DIR, 'keys');
51
+ }
52
+ /**
53
+ * Clé privée X25519 de l'appareil.
54
+ *
55
+ * Voisine de la clé Ed25519 par CHOIX : un seul répertoire à protéger, à sauvegarder
56
+ * et à effacer. Le suffixe distingue les deux algorithmes de façon lisible sans avoir
57
+ * à ouvrir le fichier.
58
+ */
59
+ export function deviceKeyPath(deviceId, home = os.homedir()) {
60
+ return path.join(keysRoot(home), `${deviceId}.x25519.pem`);
61
+ }
62
+ /**
63
+ * Clés privées d'epoch, cloisonnées PAR PROJET CLOUD.
64
+ *
65
+ * Le cloisonnement n'est pas cosmétique : `disconnect` d'un projet doit pouvoir
66
+ * effacer ses clés sans toucher à celles d'un autre projet auquel la même machine
67
+ * est appairée. Un trousseau à plat rendrait cette suppression sélective fragile.
68
+ */
69
+ export function epochKeyPath(cloudProjectId, epoch, home = os.homedir()) {
70
+ return path.join(keysRoot(home), 'epochs', cloudProjectId, `epoch-${epoch}.x25519.pem`);
71
+ }
72
+ function ensureDir(dir) {
73
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
74
+ }
75
+ /**
76
+ * Écrit un secret sur disque en RESTREIGNANT les permissions AVANT d'écrire les
77
+ * octets, pas après.
78
+ *
79
+ * `writeFileSync(p, data)` puis `chmodSync(p, 0o600)` laisse une fenêtre où le
80
+ * fichier existe en 0644 avec la clé dedans. Le mode passé à l'ouverture ferme
81
+ * cette fenêtre. Sur Windows le mode est largement ignoré — d'où le plafond
82
+ * documenté en tête de module ; ce n'est pas une raison de l'omettre sur POSIX,
83
+ * où il est effectif.
84
+ */
85
+ function writeSecretFile(filepath, contents) {
86
+ ensureDir(path.dirname(filepath));
87
+ fs.writeFileSync(filepath, contents, { encoding: 'utf-8', mode: 0o600 });
88
+ }
89
+ /**
90
+ * Retourne la clé X25519 de l'appareil, en la créant au premier appel.
91
+ *
92
+ * NE FAIT JAMAIS TOURNER une clé existante — même contrat que `ensureAgentSigningKey`
93
+ * pour Ed25519, et pour la même raison en plus grave : une rotation silencieuse
94
+ * casserait l'attestation déjà approuvée par un humain côté Cloud, et rendrait
95
+ * ILLISIBLES toutes les enveloppes d'epoch déjà remises à l'ancienne clé. Une clé de
96
+ * signature perdue empêche d'écrire ; une clé de déchiffrement perdue perd des données.
97
+ */
98
+ export function ensureDeviceKey(deviceId, home = os.homedir()) {
99
+ const filepath = deviceKeyPath(deviceId, home);
100
+ if (fs.existsSync(filepath)) {
101
+ const privateKey = crypto.createPrivateKey(fs.readFileSync(filepath, 'utf-8'));
102
+ const publicKeyPem = crypto
103
+ // @types/node 26 a retiré la surcharge KeyObject de createPublicKey (régression :
104
+ // Node accepte une clé privée pour en dériver la publique, comme documenté).
105
+ // Même contournement que agent-registry.ts ; comportement d'exécution inchangé.
106
+ .createPublicKey(privateKey)
107
+ .export({ type: 'spki', format: 'pem' })
108
+ .toString();
109
+ return {
110
+ device_id: deviceId,
111
+ public_key_pem: publicKeyPem,
112
+ fingerprint: fingerprintKeyPem(publicKeyPem),
113
+ created_at: fs.statSync(filepath).birthtime.toISOString(),
114
+ };
115
+ }
116
+ const generated = crypto.generateKeyPairSync('x25519');
117
+ const privateKeyPem = generated.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
118
+ const publicKeyPem = generated.publicKey.export({ type: 'spki', format: 'pem' }).toString();
119
+ writeSecretFile(filepath, privateKeyPem);
120
+ return {
121
+ device_id: deviceId,
122
+ public_key_pem: publicKeyPem,
123
+ fingerprint: fingerprintKeyPem(publicKeyPem),
124
+ created_at: nowISO(),
125
+ };
126
+ }
127
+ /**
128
+ * Charge la clé privée X25519 de l'appareil, ou `undefined` si absente.
129
+ *
130
+ * Ne CRÉE rien : un appelant qui a besoin de déchiffrer et ne trouve pas la clé doit
131
+ * traiter cela comme un échec explicite, pas voir une clé fraîche se matérialiser et
132
+ * échouer plus tard, plus loin, sur un déchiffrement incompréhensible.
133
+ */
134
+ export function loadDevicePrivateKey(deviceId, home = os.homedir()) {
135
+ const filepath = deviceKeyPath(deviceId, home);
136
+ if (!fs.existsSync(filepath))
137
+ return undefined;
138
+ return crypto.createPrivateKey(fs.readFileSync(filepath, 'utf-8'));
139
+ }
140
+ /**
141
+ * Enregistre la clé privée d'un epoch, remise par une enveloppe HPKE après approbation.
142
+ *
143
+ * REFUSE D'ÉCRASER un epoch déjà détenu. Deux clés différentes pour un même numéro
144
+ * d'epoch signifie que quelque chose s'est mal passé en amont — le Cloud a resservi un
145
+ * autre roster, ou deux appairages se marchent dessus. Écraser rendrait silencieusement
146
+ * illisible tout ce qui a été scellé sous la première ; l'erreur est le bon comportement.
147
+ * Ré-enregistrer la MÊME clé est en revanche idempotent : une reprise d'appairage
148
+ * interrompu ne doit pas échouer (le step 4 exige une reprise sûre).
149
+ */
150
+ export function storeEpochPrivateKey(cloudProjectId, epoch, privateKeyPem, home = os.homedir()) {
151
+ const filepath = epochKeyPath(cloudProjectId, epoch, home);
152
+ if (fs.existsSync(filepath)) {
153
+ const existing = fs.readFileSync(filepath, 'utf-8').replace(/\r/g, '').trim();
154
+ if (existing === privateKeyPem.replace(/\r/g, '').trim())
155
+ return;
156
+ throw new Error(`Refus d'écraser la clé de l'epoch ${epoch} du projet ${cloudProjectId} : ` +
157
+ `une clé DIFFÉRENTE est déjà détenue. Écraser rendrait illisible tout ce qui a été ` +
158
+ `scellé sous la clé actuelle. Vérifier le roster côté cloud avant de forcer.`);
159
+ }
160
+ // Valider AVANT d'écrire : un PEM corrompu stocké se découvrirait au premier
161
+ // déchiffrement, longtemps après, sans lien évident avec l'appairage qui l'a produit.
162
+ const key = crypto.createPrivateKey(privateKeyPem);
163
+ if (key.asymmetricKeyType !== 'x25519') {
164
+ throw new Error(`Clé d'epoch invalide : attendu x25519, reçu ${key.asymmetricKeyType ?? 'inconnu'}`);
165
+ }
166
+ writeSecretFile(filepath, privateKeyPem);
167
+ }
168
+ /** Charge la clé privée d'un epoch, ou `undefined` si cet epoch n'est pas détenu. */
169
+ export function loadEpochPrivateKey(cloudProjectId, epoch, home = os.homedir()) {
170
+ const filepath = epochKeyPath(cloudProjectId, epoch, home);
171
+ if (!fs.existsSync(filepath))
172
+ return undefined;
173
+ try {
174
+ return crypto.createPrivateKey(fs.readFileSync(filepath, 'utf-8'));
175
+ }
176
+ catch (err) {
177
+ // Une clé illisible n'est PAS équivalente à une clé absente : la première est une
178
+ // corruption à signaler, la seconde un état normal. On journalise et on renvoie
179
+ // undefined pour que l'appelant refuse le déchiffrement — jamais un fallback muet.
180
+ logger.warn(`Clé d'epoch ${epoch} illisible pour ${cloudProjectId}: ${err instanceof Error ? err.message : String(err)}`);
181
+ return undefined;
182
+ }
183
+ }
184
+ /**
185
+ * Énumère les epochs dont la clé privée est détenue localement — le trousseau réel,
186
+ * lu SUR DISQUE et non déduit de l'état de connexion.
187
+ *
188
+ * La distinction compte : l'état de connexion dit ce que l'appareil CROIT détenir,
189
+ * le disque dit ce qu'il détient VRAIMENT. `federation-state.ts` réconcilie les deux
190
+ * plutôt que de faire confiance au JSON, parce qu'une restauration partielle de
191
+ * sauvegarde produit exactement ce désaccord.
192
+ */
193
+ export function heldEpochs(cloudProjectId, home = os.homedir()) {
194
+ const dir = path.dirname(epochKeyPath(cloudProjectId, 0, home));
195
+ if (!fs.existsSync(dir))
196
+ return [];
197
+ const epochs = [];
198
+ for (const entry of fs.readdirSync(dir)) {
199
+ const match = /^epoch-(\d+)\.x25519\.pem$/.exec(entry);
200
+ if (match)
201
+ epochs.push(Number(match[1]));
202
+ }
203
+ return epochs.sort((a, b) => a - b);
204
+ }
205
+ /**
206
+ * Efface les clés d'epoch d'un projet — appelé par `cloud disconnect`.
207
+ *
208
+ * CE QUE ÇA NE FAIT PAS, et que la commande appelante doit dire à l'humain : cela
209
+ * n'efface pas les blobs déjà tirés et déchiffrés localement, ni ce qu'un autre
210
+ * appareil détient. `disconnect` retire une autorisation locale ; il ne réécrit pas
211
+ * le passé (RFC §5.2). Retourne le nombre de clés supprimées.
212
+ */
213
+ export function forgetProjectEpochs(cloudProjectId, home = os.homedir()) {
214
+ const dir = path.dirname(epochKeyPath(cloudProjectId, 0, home));
215
+ if (!fs.existsSync(dir))
216
+ return 0;
217
+ let removed = 0;
218
+ for (const entry of fs.readdirSync(dir)) {
219
+ if (!/^epoch-\d+\.x25519\.pem$/.test(entry))
220
+ continue;
221
+ fs.rmSync(path.join(dir, entry), { force: true });
222
+ removed++;
223
+ }
224
+ try {
225
+ fs.rmdirSync(dir);
226
+ }
227
+ catch { /* non vide ou déjà parti : sans conséquence */ }
228
+ return removed;
229
+ }
230
+ /** Clé publique d'un epoch détenu, dérivée de la privée — utile pour afficher son empreinte. */
231
+ export function epochPublicKey(cloudProjectId, epoch, home = os.homedir()) {
232
+ const priv = loadEpochPrivateKey(cloudProjectId, epoch, home);
233
+ if (!priv)
234
+ return undefined;
235
+ const pem = crypto
236
+ .createPublicKey(priv)
237
+ .export({ type: 'spki', format: 'pem' })
238
+ .toString();
239
+ return { public_key_pem: pem, fingerprint: fingerprintKeyPem(pem) };
240
+ }
241
+ //# sourceMappingURL=federation-keyring.js.map
@@ -13,17 +13,17 @@ export const FederationMessageSchema = z.object({
13
13
  agent_name: z.string(),
14
14
  agent_id: z.string().optional(),
15
15
  host_id: z.string().optional(),
16
- }),
16
+ }).strict(),
17
17
  to: z.object({
18
18
  project_name: z.string(),
19
19
  project_path: z.string(),
20
20
  agent_name: z.string().optional(),
21
- }),
22
- type: z.enum(['signal', 'handoff', 'candidate', 'runtime_note', 'board_snapshot']),
23
- payload: z.unknown(),
21
+ }).strict(),
22
+ type: z.enum(['handoff', 'candidate', 'runtime_note']),
23
+ payload: z.record(z.string(), z.unknown()),
24
24
  created_at: z.string(),
25
25
  causal_parent: z.string().optional(),
26
- });
26
+ }).strict();
27
27
  function computeIdempotencyKey(msg) {
28
28
  const data = JSON.stringify({ from: msg.from, to: msg.to, type: msg.type, payload: msg.payload });
29
29
  return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);