brainclaw 1.23.0 → 1.24.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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Remise attestée d'une clé privée d'epoch. Le Cloud relaie ce manifeste et
3
+ * son ciphertext sans pouvoir ouvrir l'un ou l'autre.
4
+ */
5
+ import crypto from 'node:crypto';
6
+ import { z } from 'zod';
7
+ import { canonicalJson } from './federation-canonical.js';
8
+ import { HPKE_SUITE, open as hpkeOpen, seal } from './federation-hpke.js';
9
+ import { epochPublicKey, fingerprintKeyPem, loadDevicePrivateKey, loadEpochPrivateKey, storeEpochPrivateKey } from './federation-keyring.js';
10
+ export const EPOCH_GRANT_SCHEMA = 'brainclaw.federation-epoch-grant/v1';
11
+ export const EPOCH_GRANT_KIND = 'epoch_grant';
12
+ export const EPOCH_GRANT_AAD_PROTOCOL = 'brainclaw/federation-epoch-grant/aad/v1';
13
+ const Aad = z.object({
14
+ protocol: z.literal(EPOCH_GRANT_AAD_PROTOCOL),
15
+ cloud_project_id: z.string().min(1),
16
+ epoch: z.number().int().positive(),
17
+ target_device_id: z.string().min(1),
18
+ target_x25519_fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
19
+ policy_revision: z.number().int().nonnegative(),
20
+ grant_id: z.string().min(1),
21
+ }).strict();
22
+ const Sealed = z.object({
23
+ alg: z.literal(HPKE_SUITE),
24
+ enc: z.string().min(1),
25
+ nonce: z.string().min(1),
26
+ ciphertext: z.string().min(1),
27
+ }).strict();
28
+ export const EpochGrantManifestSchema = z.object({
29
+ schema: z.literal(EPOCH_GRANT_SCHEMA),
30
+ kind: z.literal(EPOCH_GRANT_KIND),
31
+ grant_id: z.string().min(1),
32
+ cloud_project_id: z.string().min(1),
33
+ epoch: z.number().int().positive(),
34
+ policy_revision: z.number().int().nonnegative(),
35
+ target: z.object({
36
+ device_id: z.string().min(1),
37
+ x25519_fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
38
+ }).strict(),
39
+ /** Le lecteur recompose cet AAD et le compare avant de déchiffrer. */
40
+ aad: Aad,
41
+ epoch_public_key_pem: z.string().min(1),
42
+ epoch_public_key_fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
43
+ sealed: Sealed,
44
+ custodian_sig: z.object({
45
+ alg: z.literal('Ed25519'),
46
+ key_id: z.string().min(1),
47
+ value: z.string().min(1),
48
+ }).strict(),
49
+ }).strict();
50
+ export function epochGrantAad(params) {
51
+ return {
52
+ protocol: EPOCH_GRANT_AAD_PROTOCOL,
53
+ cloud_project_id: params.cloudProjectId,
54
+ epoch: params.epoch,
55
+ target_device_id: params.targetDeviceId,
56
+ target_x25519_fingerprint: params.targetX25519Fingerprint,
57
+ policy_revision: params.policyRevision,
58
+ grant_id: params.grantId,
59
+ };
60
+ }
61
+ export function epochGrantSigningInput(manifest) {
62
+ return Buffer.concat([
63
+ Buffer.from('brainclaw/federation-epoch-grant/v1\0', 'utf8'),
64
+ Buffer.from(canonicalJson(manifest), 'utf8'),
65
+ ]);
66
+ }
67
+ function unsigned(manifest) {
68
+ const { custodian_sig: _signature, ...value } = manifest;
69
+ return value;
70
+ }
71
+ function publicX25519(pem) {
72
+ let key;
73
+ try {
74
+ key = crypto.createPublicKey(pem);
75
+ }
76
+ catch {
77
+ throw new Error('Clé X25519 cible illisible.');
78
+ }
79
+ if (key.asymmetricKeyType !== 'x25519')
80
+ throw new Error('La cible doit fournir une clé X25519.');
81
+ }
82
+ function privateEd25519(pem) {
83
+ let key;
84
+ try {
85
+ key = crypto.createPrivateKey(pem);
86
+ }
87
+ catch {
88
+ throw new Error('Clé Ed25519 custodian illisible.');
89
+ }
90
+ if (key.asymmetricKeyType !== 'ed25519')
91
+ throw new Error('Le custodian doit signer en Ed25519.');
92
+ return key;
93
+ }
94
+ function normalizedPem(pem) { return pem.replace(/\r/g, '').trim(); }
95
+ export function buildEpochGrant(params) {
96
+ if (!params.custodian.active)
97
+ throw new Error('Custodian inactif.');
98
+ if (!params.target.active || !params.target.attested || !params.target.canRead)
99
+ throw new Error('Cible non autorisée.');
100
+ if (!params.target.authorizedEpochs.includes(params.epoch))
101
+ throw new Error('Epoch hors horizon autorisé.');
102
+ if (!Number.isInteger(params.epoch) || params.epoch <= 0 || !Number.isInteger(params.policyRevision) || params.policyRevision < 0) {
103
+ throw new Error('Epoch ou révision de politique invalide.');
104
+ }
105
+ if (!params.cloudProjectId || !params.grantId || !params.target.deviceId)
106
+ throw new Error('Projet, grant_id ou cible absent.');
107
+ publicX25519(params.target.x25519PublicKeyPem);
108
+ const targetFingerprint = fingerprintKeyPem(params.target.x25519PublicKeyPem);
109
+ if (targetFingerprint !== params.target.x25519Fingerprint)
110
+ throw new Error('Empreinte cible différente de la clé attestée.');
111
+ const epochPrivate = loadEpochPrivateKey(params.cloudProjectId, params.epoch, params.home);
112
+ const announced = epochPublicKey(params.cloudProjectId, params.epoch, params.home);
113
+ if (!epochPrivate || !announced)
114
+ throw new Error('Custodian non détenteur de cet epoch.');
115
+ const aad = epochGrantAad({
116
+ cloudProjectId: params.cloudProjectId,
117
+ epoch: params.epoch,
118
+ grantId: params.grantId,
119
+ policyRevision: params.policyRevision,
120
+ targetDeviceId: params.target.deviceId,
121
+ targetX25519Fingerprint: targetFingerprint,
122
+ });
123
+ const sealed = seal({
124
+ recipientPublicKeyPem: params.target.x25519PublicKeyPem,
125
+ plaintext: new TextEncoder().encode(epochPrivate.export({ type: 'pkcs8', format: 'pem' }).toString()),
126
+ aadCanonicalBytes: new TextEncoder().encode(canonicalJson(aad)),
127
+ });
128
+ const value = {
129
+ schema: EPOCH_GRANT_SCHEMA,
130
+ kind: EPOCH_GRANT_KIND,
131
+ grant_id: params.grantId,
132
+ cloud_project_id: params.cloudProjectId,
133
+ epoch: params.epoch,
134
+ policy_revision: params.policyRevision,
135
+ target: { device_id: params.target.deviceId, x25519_fingerprint: targetFingerprint },
136
+ aad,
137
+ epoch_public_key_pem: announced.public_key_pem,
138
+ epoch_public_key_fingerprint: announced.fingerprint,
139
+ sealed,
140
+ };
141
+ const signature = crypto.sign(null, epochGrantSigningInput(value), privateEd25519(params.custodian.privateKeyPem)).toString('base64url');
142
+ return EpochGrantManifestSchema.parse({
143
+ ...value,
144
+ custodian_sig: { alg: 'Ed25519', key_id: params.custodian.keyId, value: signature },
145
+ });
146
+ }
147
+ function reject(reason, detail) {
148
+ return { ok: false, reason, detail };
149
+ }
150
+ function signatureValid(manifest, signer) {
151
+ try {
152
+ const key = crypto.createPublicKey(signer);
153
+ return key.asymmetricKeyType === 'ed25519'
154
+ && crypto.verify(null, epochGrantSigningInput(unsigned(manifest)), key, Buffer.from(manifest.custodian_sig.value, 'base64url'));
155
+ }
156
+ catch {
157
+ return false;
158
+ }
159
+ }
160
+ /**
161
+ * La clé n'est écrite qu'après roster custodian, signature, AAD, cible, HPKE et
162
+ * comparaison de la clé publique redérivée avec l'annonce signée.
163
+ */
164
+ export function verifyAndStoreEpochGrant(params) {
165
+ const parsed = EpochGrantManifestSchema.safeParse(params.raw);
166
+ if (!parsed.success)
167
+ return reject('schema_invalid', parsed.error.issues.map((issue) => issue.message).join('; '));
168
+ const manifest = parsed.data;
169
+ const signer = params.activeCustodians.get(manifest.custodian_sig.key_id);
170
+ if (!signer)
171
+ return reject('non_custodian', 'Signataire absent des custodians actifs.');
172
+ if (!signatureValid(manifest, signer))
173
+ return reject('bad_signature', 'Signature Ed25519 invalide.');
174
+ const aad = epochGrantAad({
175
+ cloudProjectId: manifest.cloud_project_id,
176
+ epoch: manifest.epoch,
177
+ grantId: manifest.grant_id,
178
+ policyRevision: manifest.policy_revision,
179
+ targetDeviceId: manifest.target.device_id,
180
+ targetX25519Fingerprint: manifest.target.x25519_fingerprint,
181
+ });
182
+ if (canonicalJson(manifest.aad) !== canonicalJson(aad))
183
+ return reject('aad_mismatch', 'AAD différent des liaisons annoncées.');
184
+ if (manifest.target.device_id !== params.recipientDeviceId)
185
+ return reject('target_mismatch', 'Remise destinée à un autre appareil.');
186
+ const recipient = params.recipientPrivateKey ?? loadDevicePrivateKey(params.recipientDeviceId, params.home);
187
+ if (!recipient || recipient.asymmetricKeyType !== 'x25519')
188
+ return reject('recipient_key_unavailable', 'Clé X25519 destinataire absente.');
189
+ const recipientPublic = crypto.createPublicKey(recipient).export({ type: 'spki', format: 'pem' }).toString();
190
+ if (fingerprintKeyPem(recipientPublic) !== manifest.target.x25519_fingerprint)
191
+ return reject('target_mismatch', 'Clé locale différente de la cible attestée.');
192
+ const clear = hpkeOpen({
193
+ recipientPrivateKey: recipient,
194
+ sealed: manifest.sealed,
195
+ aadCanonicalBytes: new TextEncoder().encode(canonicalJson(aad)),
196
+ });
197
+ if (!clear)
198
+ return reject('undecryptable', 'HPKE a refusé la remise.');
199
+ let privatePem;
200
+ let derivedPublic;
201
+ try {
202
+ privatePem = new TextDecoder().decode(clear);
203
+ const epochPrivate = crypto.createPrivateKey(privatePem);
204
+ if (epochPrivate.asymmetricKeyType !== 'x25519')
205
+ throw new Error('non-X25519');
206
+ derivedPublic = crypto.createPublicKey(epochPrivate).export({ type: 'spki', format: 'pem' }).toString();
207
+ }
208
+ catch {
209
+ return reject('epoch_key_mismatch', 'Clair d’epoch invalide.');
210
+ }
211
+ if (normalizedPem(derivedPublic) !== normalizedPem(manifest.epoch_public_key_pem)
212
+ || fingerprintKeyPem(derivedPublic) !== manifest.epoch_public_key_fingerprint
213
+ || fingerprintKeyPem(manifest.epoch_public_key_pem) !== manifest.epoch_public_key_fingerprint)
214
+ return reject('epoch_key_mismatch', 'Clé publique redérivée différente de l’annonce.');
215
+ try {
216
+ storeEpochPrivateKey(manifest.cloud_project_id, manifest.epoch, privatePem, params.home);
217
+ }
218
+ catch (error) {
219
+ return reject('storage_refused', error instanceof Error ? error.message : String(error));
220
+ }
221
+ return { ok: true, manifest };
222
+ }
223
+ //# sourceMappingURL=federation-grant.js.map
@@ -238,4 +238,43 @@ export function epochPublicKey(cloudProjectId, epoch, home = os.homedir()) {
238
238
  .toString();
239
239
  return { public_key_pem: pem, fingerprint: fingerprintKeyPem(pem) };
240
240
  }
241
+ /**
242
+ * Fait naître la PREMIÈRE clé d'epoch d'un projet, si et seulement si personne n'en détient.
243
+ *
244
+ * ── POURQUOI CETTE FONCTION MANQUAIT, ET CE QUE SON ABSENCE PRODUISAIT ────────
245
+ * Mesuré le 2026-08-09 : `storeEpochPrivateKey` n'avait AUCUN appelant de production — seuls
246
+ * les tests en fabriquaient. Un projet fraîchement appairé restait donc à `current_epoch: 0`
247
+ * avec `known_epochs: []`, et toute tentative de sceller échouait sur « clé d'epoch
248
+ * introuvable ». La fédération ne pouvait rien émettre, non par refus mais par absence de
249
+ * clé — un état qu'aucun message n'expliquait.
250
+ *
251
+ * ── QUI A LE DROIT DE CRÉER, ET POURQUOI C'EST ÉTROIT ─────────────────────────
252
+ * Le PREMIER appareil d'un projet, et lui seul. Un appareil qui rejoint un projet existant
253
+ * ne doit RIEN créer : il doit RECEVOIR la clé par une remise attestée (dec#159). En
254
+ * fabriquer une localement produirait un second epoch portant le même numéro et une clé
255
+ * différente — donc des enveloppes que personne d'autre ne peut lire, sans qu'aucune erreur
256
+ * ne se déclenche à l'émission.
257
+ *
258
+ * C'est le cas dégénéré du modèle d'équipe, pas une branche parallèle : en solo, le premier
259
+ * appareil est aussi le seul custodian.
260
+ *
261
+ * NE RÉÉCRIT JAMAIS : si une clé existe déjà pour cet epoch, elle est renvoyée telle quelle.
262
+ * `storeEpochPrivateKey` refuse de son côté d'écraser une clé DIFFÉRENTE.
263
+ */
264
+ export function ensureFirstEpochKey(cloudProjectId, epoch, home = os.homedir()) {
265
+ const existing = epochPublicKey(cloudProjectId, epoch, home);
266
+ if (existing)
267
+ return { created: false, ...existing };
268
+ const { privateKey } = crypto.generateKeyPairSync('x25519');
269
+ const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
270
+ storeEpochPrivateKey(cloudProjectId, epoch, pem, home);
271
+ const materialized = epochPublicKey(cloudProjectId, epoch, home);
272
+ if (!materialized) {
273
+ // Vérifier APRÈS écriture plutôt que supposer : une clé qu'on croit détenir mais qui
274
+ // n'est pas relisible produirait des enveloppes illisibles, découvertes bien plus tard.
275
+ throw new Error(`Clé d'epoch ${epoch} écrite mais non relisible pour ${cloudProjectId} — ` +
276
+ 'ne pas émettre tant que la cause n\'est pas comprise.');
277
+ }
278
+ return { created: true, ...materialized };
279
+ }
241
280
  //# sourceMappingURL=federation-keyring.js.map
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Correspondance id LOCAL ↔ id OPAQUE — et elle ne quitte jamais la machine (RFC §7).
3
+ *
4
+ * ── POURQUOI UNE TABLE PLUTÔT QU'UN HACHAGE ──────────────────────────────────
5
+ * Un id opaque doit être STABLE : sans stabilité, chaque émission créerait un nouvel objet
6
+ * côté cloud et le board afficherait un doublon par mise à jour. La tentation est donc de
7
+ * dériver l'opaque du local par un hachage — c'est reproductible et sans état.
8
+ *
9
+ * Mais un hachage NON CLEFÉ est réversible par devinette : le cloud connaît la forme des
10
+ * ids locaux (`pln_`, `dec_`, `trp_` + hexadécimal court), il lui suffit d'énumérer pour
11
+ * confirmer qu'un opaque correspond à `pln_5047fdb1`. Il apprendrait alors le compteur
12
+ * local, l'ordre de création, et le lien entre deux projets partageant un objet.
13
+ *
14
+ * Une table locale n'a pas ce défaut : l'opaque est un UUID v4 sans relation calculable
15
+ * avec le local. Le prix est un état à conserver — assumé, parce que le RFC pose déjà que
16
+ * la correspondance reste locale, et parce que la PERDRE n'est pas une catastrophe : on
17
+ * réémet sous de nouveaux opaques, ce qui duplique l'affichage sans rien divulguer.
18
+ *
19
+ * ── CE FICHIER NE SORT JAMAIS ────────────────────────────────────────────────
20
+ * Il contient exactement ce que la projection existe pour cacher : la liaison entre un
21
+ * objet du cloud et son identité locale. Il vit sous `.brainclaw/coordination/federation/`
22
+ * et n'est référencé par aucune enveloppe.
23
+ */
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import crypto from 'node:crypto';
27
+ import { memoryDir, writeFileAtomic } from './io.js';
28
+ import { logger } from './logger.js';
29
+ const MAP_FILE = 'opaque-ids.json';
30
+ export const OPAQUE_MAP_SCHEMA = 'brainclaw.federation-opaque-map/v1';
31
+ function mapPath(cwd) {
32
+ return path.join(memoryDir(cwd), 'coordination', 'federation', MAP_FILE);
33
+ }
34
+ function loadMap(cwd) {
35
+ const file = mapPath(cwd);
36
+ if (!fs.existsSync(file))
37
+ return { schema: OPAQUE_MAP_SCHEMA, entries: {} };
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
40
+ if (parsed.schema !== OPAQUE_MAP_SCHEMA || typeof parsed.entries !== 'object' || !parsed.entries) {
41
+ // FAIL-CLOSED sur un fichier d'une autre forme : repartir d'une table vide
42
+ // RÉÉMETTRA sous de nouveaux opaques (doublons visibles), ce qui est bruyant mais
43
+ // sûr. Réutiliser une table mal comprise pourrait au contraire rattacher un objet à
44
+ // l'identité d'un autre.
45
+ logger.warn(`Table d'ids opaques d'une forme inconnue — ignorée : ${file}`);
46
+ return { schema: OPAQUE_MAP_SCHEMA, entries: {} };
47
+ }
48
+ return { schema: OPAQUE_MAP_SCHEMA, entries: parsed.entries };
49
+ }
50
+ catch (err) {
51
+ logger.warn(`Table d'ids opaques illisible (${err instanceof Error ? err.message : String(err)}) — ignorée.`);
52
+ return { schema: OPAQUE_MAP_SCHEMA, entries: {} };
53
+ }
54
+ }
55
+ /**
56
+ * Renvoie l'id opaque STABLE d'un objet local, en le créant à la première demande.
57
+ *
58
+ * La clé inclut le projet cloud : le même objet local projeté vers deux projets cloud
59
+ * distincts reçoit deux opaques différents. Sans cela, deux clouds pourraient recouper
60
+ * leurs tables et découvrir qu'ils regardent le même objet.
61
+ */
62
+ export function opaqueIdFor(cloudProjectId, localId, cwd = process.cwd()) {
63
+ const map = loadMap(cwd);
64
+ const key = `${cloudProjectId}/${localId}`;
65
+ const existing = map.entries[key];
66
+ if (existing)
67
+ return existing;
68
+ const fresh = crypto.randomUUID();
69
+ map.entries[key] = fresh;
70
+ const file = mapPath(cwd);
71
+ fs.mkdirSync(path.dirname(file), { recursive: true });
72
+ writeFileAtomic(file, `${JSON.stringify(map, null, 2)}\n`);
73
+ return fresh;
74
+ }
75
+ /** Résout l'identifiant local correspondant à un opaque reçu du cloud. */
76
+ export function localIdForOpaque(cloudProjectId, opaqueId, cwd = process.cwd()) {
77
+ const prefix = `${cloudProjectId}/`;
78
+ for (const [key, value] of Object.entries(loadMap(cwd).entries)) {
79
+ if (key.startsWith(prefix) && value === opaqueId)
80
+ return key.slice(prefix.length);
81
+ }
82
+ return undefined;
83
+ }
84
+ /**
85
+ * Enregistre le sens inverse du mapping après la création canonique de l'objet local.
86
+ * Refuse une incohérence au lieu de rattacher un opaque au mauvais objet.
87
+ */
88
+ export function rememberOpaqueId(cloudProjectId, localId, opaqueId, cwd = process.cwd()) {
89
+ const map = loadMap(cwd);
90
+ const key = `${cloudProjectId}/${localId}`;
91
+ const current = map.entries[key];
92
+ if (current && current !== opaqueId) {
93
+ throw new Error(`Correspondance opaque incohérente pour ${localId}.`);
94
+ }
95
+ const prefix = `${cloudProjectId}/`;
96
+ const inverse = Object.entries(map.entries).find(([entryKey, value]) => entryKey.startsWith(prefix) && value === opaqueId && entryKey !== key);
97
+ if (inverse)
98
+ throw new Error(`Opaque ${opaqueId} déjà rattaché à ${inverse[0].slice(prefix.length)}.`);
99
+ if (current === opaqueId)
100
+ return;
101
+ map.entries[key] = opaqueId;
102
+ const file = mapPath(cwd);
103
+ fs.mkdirSync(path.dirname(file), { recursive: true });
104
+ writeFileAtomic(file, `${JSON.stringify(map, null, 2)}\n`);
105
+ }
106
+ /** Nombre de correspondances connues — utile au diagnostic, jamais projeté. */
107
+ export function opaqueMapSize(cloudProjectId, cwd = process.cwd()) {
108
+ const prefix = `${cloudProjectId}/`;
109
+ return Object.keys(loadMap(cwd).entries).filter((k) => k.startsWith(prefix)).length;
110
+ }
111
+ //# sourceMappingURL=federation-opaque-ids.js.map
@@ -76,7 +76,17 @@ export function transition(idempotencyKey, from, to, cwd = process.cwd(), mutate
76
76
  const entry = JSON.parse(fs.readFileSync(src, 'utf-8'));
77
77
  const next = mutate({ ...entry, updated_at: nowISO() });
78
78
  writeFileAtomic(dest, `${JSON.stringify(next, null, 2)}\n`);
79
- fs.rmSync(src, { force: true });
79
+ // MISE À JOUR SUR PLACE — `from === to` est un cas LÉGITIME et non un no-op : c'est
80
+ // ainsi qu'un échec d'envoi incrémente `attempts` et enregistre `last_error` sans
81
+ // quitter la file d'attente.
82
+ //
83
+ // Sans cette garde, `src` et `dest` sont le MÊME chemin : on écrit le fichier puis on
84
+ // le SUPPRIME aussitôt. L'entrée disparaît exactement au moment où l'on voulait
85
+ // seulement noter que son envoi a échoué — donc l'opération jamais émise perd sa
86
+ // seule trace, et précisément lors de l'incident où elle compte.
87
+ // Trouvé par le test « un 500 laisse aussi en attente » (2026-08-09).
88
+ if (path.resolve(src) !== path.resolve(dest))
89
+ fs.rmSync(src, { force: true });
80
90
  return true;
81
91
  }
82
92
  catch (err) {
@@ -93,11 +103,31 @@ export function list(state, cwd = process.cwd()) {
93
103
  if (!fs.existsSync(dir))
94
104
  return [];
95
105
  const entries = [];
106
+ let legacy = 0;
96
107
  for (const name of fs.readdirSync(dir)) {
97
108
  if (!name.endsWith('.json'))
98
109
  continue;
99
110
  try {
100
- entries.push(JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8')));
111
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
112
+ // ── DÉBRIS v1 DANS LE MÊME RÉPERTOIRE ────────────────────────────────────
113
+ //
114
+ // dec#156 a abandonné le format v1 SANS migration — mais la v2 réutilise le même
115
+ // chemin sur disque, et les entrées v1 y sont restées. Elles n'ont ni `schema` ni
116
+ // `created_at` (leurs champs sont op/entity_type/enqueued_at/last_status).
117
+ //
118
+ // Sans ce filtre, le tri par `created_at` lève « Cannot read properties of
119
+ // undefined (reading 'localeCompare') » et `brainclaw cloud status` PLANTE —
120
+ // c'est-à-dire la toute première commande qu'on lance après un appairage réussi.
121
+ // Constaté sur un magasin réel portant 129 entrées v1 (2026-08-09).
122
+ //
123
+ // Elles sont IGNORÉES, pas supprimées : ce sont des opérations peut-être jamais
124
+ // émises, et les effacer ici retirerait leur seule trace. Le compte est journalisé
125
+ // pour que leur présence reste visible plutôt que devinée.
126
+ if (parsed.schema !== OUTBOX_ENTRY_SCHEMA || typeof parsed.created_at !== 'string') {
127
+ legacy += 1;
128
+ continue;
129
+ }
130
+ entries.push(parsed);
101
131
  }
102
132
  catch {
103
133
  // Une entrée corrompue est ignorée à la lecture mais reste sur disque : la
@@ -105,6 +135,10 @@ export function list(state, cwd = process.cwd()) {
105
135
  logger.warn(`Entrée d'outbox ignorée (illisible) : ${name}`);
106
136
  }
107
137
  }
138
+ if (legacy > 0) {
139
+ logger.warn(`${legacy} entrée(s) d'outbox au format v1 ignorée(s) dans « ${STATE_DIRS[state]} » — ` +
140
+ `abandonnées par dec#156, conservées sur disque, sans effet sur la fédération v2.`);
141
+ }
108
142
  return entries.sort((a, b) => a.created_at.localeCompare(b.created_at));
109
143
  }
110
144
  /**
@@ -25,8 +25,9 @@ import crypto from 'node:crypto';
25
25
  import { nowISO } from './ids.js';
26
26
  import { loadAgentSigningKey, ensureAgentSigningKey } from './agent-registry.js';
27
27
  import { buildKeyAttestation, fingerprintPem } from './federation-attestation.js';
28
- import { ensureDeviceKey, } from './federation-keyring.js';
29
- import { createConnectionState, loadConnectionState, saveConnectionState, newDeviceId, } from './federation-state.js';
28
+ import { ensureDeviceKey, ensureFirstEpochKey, } from './federation-keyring.js';
29
+ import { logger } from './logger.js';
30
+ import { createConnectionState, loadConnectionState, saveConnectionState, upsertPairing, hasActivePairing, newDeviceId, } from './federation-state.js';
30
31
  export class PairingError extends Error {
31
32
  stage;
32
33
  status;
@@ -84,7 +85,25 @@ export async function beginPairing(params) {
84
85
  }
85
86
  // (3) LA CLÉ DE CHIFFREMENT DE L'APPAREIL. Distincte de l'identité, jamais dérivée
86
87
  // d'elle (RFC §5.1) — c'est ce qui rend « écrire sans lire » possible.
87
- const deviceId = params.deviceId ?? newDeviceId();
88
+ //
89
+ // MULTI-AGENTS : si un appairage existe déjà pour CE projet cloud, on RÉUTILISE son
90
+ // appareil (dec#161) — les agents d'une même machine partagent une clé de chiffrement.
91
+ // Générer un nouveau device par agent multiplierait les clés de déchiffrement sans
92
+ // raison, et exigerait une remise d'epoch par device au lieu de par machine.
93
+ const existing = loadConnectionState(cwd);
94
+ const sameProject = existing && existing.cloud_project_id === cloudProjectId;
95
+ if (existing && !sameProject) {
96
+ throw new PairingError(`Ce workspace est déjà appairé au projet ${existing.cloud_project_id} ; un workspace ne ` +
97
+ 'peut pas être lié à deux projets cloud. Utilisez un autre répertoire.', 'claim');
98
+ }
99
+ // Ne refuser QUE si l'agent est déjà ACTIF — là il n'y a rien à faire. Un pairing
100
+ // `pending`/`attested` est une cérémonie interrompue : la relancer est une REPRISE
101
+ // légitime (nouvelle invitation, nouveau enrollment_id), et upsertPairing remplace
102
+ // proprement l'ancien enregistrement de cet agent.
103
+ if (sameProject && existing.pairings.some((p) => p.agent_id === params.agentId && p.stage === 'active')) {
104
+ throw new PairingError(`L'agent '${params.agentId}' est déjà appairé et actif sur ce workspace. Rien à faire.`, 'claim');
105
+ }
106
+ const deviceId = params.deviceId ?? (sameProject ? existing.device.device_id : newDeviceId());
88
107
  const device = ensureDeviceKey(deviceId);
89
108
  // (4) PREUVE DE POSSESSION + ATTESTATION, EN UN SEUL ACTE. Les deux signatures sont
90
109
  // produites par la MÊME clé d'identité : c'est ce lien qui interdit à quiconque
@@ -126,12 +145,18 @@ export async function beginPairing(params) {
126
145
  // porteur reste exigé — `recoveryReadiness` continue de refuser tant qu'il manque.
127
146
  recovery: true,
128
147
  };
129
- const state = createConnectionState({
130
- cloudProjectId,
131
- device: deviceRecord,
132
- workspacePath: cwd,
133
- enrollmentId,
134
- });
148
+ // AJOUT, pas écrasement (trp#1625). Sur un projet déjà appairé, on greffe le pairing du
149
+ // nouvel agent sur l'état existant — device et epochs conservés. Sinon, création.
150
+ const pairing = {
151
+ agent_id: params.agentId,
152
+ stage: 'pending',
153
+ enrollment_id: enrollmentId,
154
+ updated_at: nowISO(),
155
+ };
156
+ const base = sameProject
157
+ ? existing
158
+ : createConnectionState({ cloudProjectId, device: deviceRecord, workspacePath: cwd, enrollmentId, agentId: params.agentId });
159
+ const state = sameProject ? upsertPairing(existing, pairing) : base;
135
160
  saveConnectionState(state, cwd);
136
161
  return {
137
162
  enrollment_id: enrollmentId,
@@ -148,7 +173,12 @@ export async function beginPairing(params) {
148
173
  * (RFC §5.2 phase 4) ; cette fonction ne fait que lire un état de cérémonie.
149
174
  */
150
175
  export async function checkPairingApproval(params) {
151
- const res = await params.transport.get(`/api/v1/enrollments/${params.enrollmentId}`);
176
+ // `/status` et non la route complète : l'agent en cours d'appairage n'a NI JWT NI clé
177
+ // d'API — il n'en obtient une qu'une fois approuvé. Interroger `GET /enrollments/:id`
178
+ // (withUserAuth) échouait donc sur « Missing API key » juste après une preuve de
179
+ // possession réussie, laissant la cérémonie à un pas de la fin (constaté en production
180
+ // le 2026-08-10). `/status` est public et ne rend que l'état du cycle de vie.
181
+ const res = await params.transport.get(`/api/v1/enrollments/${params.enrollmentId}/status`);
152
182
  if (res.status !== 200) {
153
183
  throw new PairingError(describeError(res.body, "l'état de l'enrôlement n'a pas pu être lu"), 'poll', res.status);
154
184
  }
@@ -169,9 +199,54 @@ export function completePairing(params) {
169
199
  if (!state) {
170
200
  throw new PairingError("Aucun état d'appairage local — relancer `brainclaw cloud connect`.", 'complete');
171
201
  }
202
+ // Le pairing à activer : celui de l'enrôlement nommé, sinon le miroir courant (solo).
203
+ const targetEnrollmentId = params.enrollmentId ?? state.enrollment.enrollment_id;
204
+ const target = state.pairings.find((p) => p.enrollment_id === targetEnrollmentId);
205
+ if (!target) {
206
+ throw new PairingError(`Aucun pairing en attente pour l'enrôlement ${String(targetEnrollmentId)} — relancer \`cloud connect\`.`, 'complete');
207
+ }
208
+ // La genèse d'epoch ne joue que si AUCUN pairing n'était encore actif : c'est le PREMIER
209
+ // agent du premier appareil. Un second agent sur le même appareil hérite de l'epoch déjà
210
+ // détenu — regénérer produirait un epoch concurrent (dec#161). `ensureFirstEpochKey`
211
+ // reste idempotent en dernier recours, mais la condition dit l'intention.
212
+ const firstEverActivation = !hasActivePairing(state);
213
+ // ── GENÈSE DE LA PREMIÈRE CLÉ D'EPOCH ───────────────────────────────────────
214
+ //
215
+ // Sans elle, l'appairage s'achevait sur `current_epoch: 0` et `known_epochs: []` : le
216
+ // projet était « actif » et incapable de sceller quoi que ce soit, échouant sur « clé
217
+ // d'epoch introuvable » sans qu'aucun message n'explique pourquoi. Mesuré le 2026-08-09 —
218
+ // `storeEpochPrivateKey` n'avait aucun appelant de production.
219
+ //
220
+ // SEUL LE PREMIER APPAREIL crée. Un appareil qui rejoint un projet existant doit RECEVOIR
221
+ // la clé par une remise attestée (dec#159) : en fabriquer une localement produirait un
222
+ // second epoch au même numéro avec une clé différente, donc des enveloppes que personne
223
+ // d'autre ne peut lire — et aucune erreur ne se déclencherait à l'émission.
224
+ //
225
+ // `peer_devices` vide est le signal disponible ici. Il est FAIBLE : un cloud hostile peut
226
+ // prétendre qu'un projet peuplé est vide pour pousser ce client à forger un epoch
227
+ // concurrent. Le roster signé de dec#159 est ce qui fermera ce trou ; en attendant, la
228
+ // limite est nommée plutôt que tue.
229
+ const isFirstDevice = (state.peer_devices?.length ?? 0) === 0;
230
+ const doGenesis = firstEverActivation && isFirstDevice;
231
+ const epoch = state.keys.current_epoch > 0 ? state.keys.current_epoch : 1;
232
+ let knownEpochs = state.keys.known_epochs;
233
+ if (doGenesis) {
234
+ const key = ensureFirstEpochKey(state.cloud_project_id, epoch);
235
+ if (key.created) {
236
+ logger.info(`Epoch ${epoch} créé pour ce projet — empreinte ${key.fingerprint}`);
237
+ }
238
+ knownEpochs = knownEpochs.includes(epoch) ? knownEpochs : [...knownEpochs, epoch];
239
+ }
240
+ // Active le pairing ciblé via upsertPairing (qui maintient le miroir `enrollment`).
241
+ const activated = upsertPairing(state, {
242
+ ...target,
243
+ stage: 'active',
244
+ role: params.role ?? target.role,
245
+ updated_at: nowISO(),
246
+ });
172
247
  const next = {
173
- ...state,
174
- enrollment: { ...state.enrollment, stage: 'active', role: params.role ?? state.enrollment.role, updated_at: nowISO() },
248
+ ...activated,
249
+ keys: { current_epoch: doGenesis ? epoch : state.keys.current_epoch, known_epochs: knownEpochs },
175
250
  };
176
251
  saveConnectionState(next, cwd);
177
252
  return next;