brainclaw 1.21.0 → 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.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-cloud.js +63 -0
- package/dist/cli.js +2 -3
- package/dist/commands/cloud.js +198 -0
- package/dist/commands/session-end.js +0 -102
- package/dist/commands/session-start.js +0 -23
- package/dist/core/claims.js +0 -18
- package/dist/core/coordination.js +1 -3
- package/dist/core/federation-attestation.js +96 -0
- package/dist/core/federation-canonical.js +95 -0
- package/dist/core/federation-hpke.js +213 -0
- package/dist/core/federation-inbound.js +187 -0
- package/dist/core/federation-keyring.js +241 -0
- package/dist/core/federation-message.js +5 -5
- package/dist/core/federation-outbox-v2.js +125 -0
- package/dist/core/federation-pairing.js +213 -0
- package/dist/core/federation-projection.js +336 -0
- package/dist/core/federation-relay.js +223 -0
- package/dist/core/federation-state.js +270 -0
- package/dist/core/ids.js +5 -0
- package/dist/core/schema.js +0 -17
- package/dist/facts.js +9 -9
- package/dist/facts.json +8 -8
- package/docs/cli.md +70 -39
- package/docs/concepts/federation-v2-rfc.md +275 -0
- package/docs/index.md +1 -0
- package/package.json +2 -2
- package/dist/cli/register-federation.js +0 -258
- package/dist/core/federation-cloud.js +0 -245
- package/dist/core/federation-outbox.js +0 -292
- package/dist/core/federation-signing.js +0 -115
|
@@ -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(['
|
|
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);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fédération v2 — outbox locale (pln#651 étape 3).
|
|
3
|
+
*
|
|
4
|
+
* Création propre : l'outbox v1 (`federation-outbox.ts`) a été SUPPRIMÉE en étape 2 avec
|
|
5
|
+
* ses 112 enveloppes en attente, dont 50 portaient un `worktree_path` absolu et 48 le nom
|
|
6
|
+
* d'hôte de la machine (dec#156-d). Rien n'est repris d'elle — ni format, ni contenu.
|
|
7
|
+
*
|
|
8
|
+
* ── CE QUE CETTE OUTBOX SAIT, ET CE QU'ELLE NE SAIT PAS ───────────────────────
|
|
9
|
+
* Elle stocke des enveloppes DÉJÀ SCELLÉES et suit leur état. Elle ne construit pas
|
|
10
|
+
* l'enveloppe, ne chiffre pas, ne classe pas les champs : c'est l'étape 5 (le projecteur
|
|
11
|
+
* et ses trois filets). La séparation est délibérée — une file d'attente qui saurait
|
|
12
|
+
* aussi fabriquer son contenu serait un second chemin de sérialisation, donc un second
|
|
13
|
+
* endroit où un champ non classé peut fuir.
|
|
14
|
+
*
|
|
15
|
+
* Conséquence testable : ce module n'accepte QUE la partie `sealed` opaque et des
|
|
16
|
+
* métadonnées de transport. Il n'a aucun accès au clair, donc il ne peut pas le divulguer.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { memoryDir, writeFileAtomic } from './io.js';
|
|
21
|
+
import { nowISO } from './ids.js';
|
|
22
|
+
import { logger } from './logger.js';
|
|
23
|
+
/** Les trois répertoires SONT les trois états — l'état est le chemin, pas un champ. */
|
|
24
|
+
const STATE_DIRS = {
|
|
25
|
+
pending: 'outbox',
|
|
26
|
+
synced: 'sent',
|
|
27
|
+
conflict: 'conflict',
|
|
28
|
+
};
|
|
29
|
+
export const OUTBOX_ENTRY_SCHEMA = 'brainclaw.federation-outbox-entry/v2';
|
|
30
|
+
function stateDir(state, cwd) {
|
|
31
|
+
return path.join(memoryDir(cwd), 'coordination', 'federation', STATE_DIRS[state]);
|
|
32
|
+
}
|
|
33
|
+
function entryPath(state, idempotencyKey, cwd) {
|
|
34
|
+
return path.join(stateDir(state, cwd), `${idempotencyKey}.json`);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Met une enveloppe scellée en file d'attente.
|
|
38
|
+
*
|
|
39
|
+
* IDEMPOTENT PAR CONSTRUCTION : la clé d'idempotence est le NOM DU FICHIER, donc un
|
|
40
|
+
* double enqueue de la même opération ne peut pas produire deux entrées. C'est ce que
|
|
41
|
+
* `materializeFederationSignal` ne faisait pas — il frappait un nouvel id et un nouveau
|
|
42
|
+
* created_at à chaque passage, rendant le dédoublonnage absent par construction (étape 6).
|
|
43
|
+
*
|
|
44
|
+
* Une entrée déjà `synced` n'est pas remise en attente : la retransmission d'une opération
|
|
45
|
+
* confirmée est exactement le rejeu contre lequel l'étape 6 protège à la réception ; il
|
|
46
|
+
* n'y a pas de raison de l'émettre depuis ici.
|
|
47
|
+
*/
|
|
48
|
+
export function enqueue(entry, cwd = process.cwd()) {
|
|
49
|
+
for (const state of ['synced', 'pending', 'conflict']) {
|
|
50
|
+
if (fs.existsSync(entryPath(state, entry.idempotency_key, cwd)))
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const now = nowISO();
|
|
54
|
+
const full = { schema: OUTBOX_ENTRY_SCHEMA, ...entry, attempts: 0, created_at: now, updated_at: now };
|
|
55
|
+
const filepath = entryPath('pending', entry.idempotency_key, cwd);
|
|
56
|
+
fs.mkdirSync(path.dirname(filepath), { recursive: true });
|
|
57
|
+
writeFileAtomic(filepath, `${JSON.stringify(full, null, 2)}\n`);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Déplace une entrée d'un état vers un autre.
|
|
62
|
+
*
|
|
63
|
+
* Le déplacement est un `rename` : une entrée ne peut pas exister dans deux états à la
|
|
64
|
+
* fois, même si le processus meurt au milieu. Une copie-puis-suppression laisserait une
|
|
65
|
+
* fenêtre où l'opération est comptée deux fois — et les compteurs de `status` mentiraient
|
|
66
|
+
* précisément pendant l'incident où on les consulte.
|
|
67
|
+
*/
|
|
68
|
+
export function transition(idempotencyKey, from, to, cwd = process.cwd(), mutate) {
|
|
69
|
+
const src = entryPath(from, idempotencyKey, cwd);
|
|
70
|
+
if (!fs.existsSync(src))
|
|
71
|
+
return false;
|
|
72
|
+
const dest = entryPath(to, idempotencyKey, cwd);
|
|
73
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
74
|
+
if (mutate) {
|
|
75
|
+
try {
|
|
76
|
+
const entry = JSON.parse(fs.readFileSync(src, 'utf-8'));
|
|
77
|
+
const next = mutate({ ...entry, updated_at: nowISO() });
|
|
78
|
+
writeFileAtomic(dest, `${JSON.stringify(next, null, 2)}\n`);
|
|
79
|
+
fs.rmSync(src, { force: true });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
logger.warn(`Entrée d'outbox illisible (${idempotencyKey}) : ${err instanceof Error ? err.message : String(err)}`);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
fs.renameSync(src, dest);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/** Entrées d'un état donné, triées par date de création. */
|
|
91
|
+
export function list(state, cwd = process.cwd()) {
|
|
92
|
+
const dir = stateDir(state, cwd);
|
|
93
|
+
if (!fs.existsSync(dir))
|
|
94
|
+
return [];
|
|
95
|
+
const entries = [];
|
|
96
|
+
for (const name of fs.readdirSync(dir)) {
|
|
97
|
+
if (!name.endsWith('.json'))
|
|
98
|
+
continue;
|
|
99
|
+
try {
|
|
100
|
+
entries.push(JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8')));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Une entrée corrompue est ignorée à la lecture mais reste sur disque : la
|
|
104
|
+
// supprimer ici effacerait la seule trace d'une opération peut-être jamais émise.
|
|
105
|
+
logger.warn(`Entrée d'outbox ignorée (illisible) : ${name}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return entries.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Compte les trois états EN LES LISANT SUR DISQUE.
|
|
112
|
+
*
|
|
113
|
+
* Les compteurs de `connection.json` sont un cache d'affichage ; ceci est la vérité. Le
|
|
114
|
+
* critère de sortie de l'étape 3 dit « observables par une commande » — observer un
|
|
115
|
+
* compteur qu'on a soi-même incrémenté n'observe rien, c'est se relire. En cas d'écart,
|
|
116
|
+
* c'est ce comptage qui gagne.
|
|
117
|
+
*/
|
|
118
|
+
export function counters(cwd = process.cwd()) {
|
|
119
|
+
return {
|
|
120
|
+
pending: list('pending', cwd).length,
|
|
121
|
+
synced: list('synced', cwd).length,
|
|
122
|
+
conflict: list('conflict', cwd).length,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=federation-outbox-v2.js.map
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cérémonie d'appairage — fédération v2 (pln#651 étape 4, RFC §5.2).
|
|
3
|
+
*
|
|
4
|
+
* C'est le chantier qui a motivé toute la recherche : l'enrôlement v1 était lourd et
|
|
5
|
+
* fastidieux, et surtout il n'était pas sûr — présenter un PEM suffisait à activer un
|
|
6
|
+
* agent. Ici, rejoindre un projet est une CÉRÉMONIE DE CLÉS.
|
|
7
|
+
*
|
|
8
|
+
* ── POURQUOI L'APPAIRAGE ET LA DISTRIBUTION DE CLÉS SONT UN SEUL CHANTIER ─────
|
|
9
|
+
* On pourrait croire qu'un « enrôlement simple » d'abord, le chiffrement ensuite, serait
|
|
10
|
+
* plus rapide. Ce serait faux deux fois. D'abord parce que la clé de chiffrement de
|
|
11
|
+
* l'appareil doit être ATTESTÉE par son identité Ed25519 — sans quoi le Cloud, qui
|
|
12
|
+
* orchestre l'appairage, peut insérer sa propre clé dans la liste d'enveloppement.
|
|
13
|
+
* Ensuite parce que livrer les deux séparément produirait DEUX flux d'enrôlement et
|
|
14
|
+
* imposerait de réenrôler tout le monde au moment de la bascule.
|
|
15
|
+
*
|
|
16
|
+
* ── INTERDIT DANS LE CHEMIN NOMINAL (dec#8) ──────────────────────────────────
|
|
17
|
+
* Aucune clé d'API, aucun PEM, aucun agent_id, aucune variable d'environnement à copier
|
|
18
|
+
* à la main. L'humain manipule UN code d'invitation et compare DEUX empreintes. Tout le
|
|
19
|
+
* reste est dérivé localement ou négocié par le protocole.
|
|
20
|
+
*
|
|
21
|
+
* La clé d'API manuelle reste un mécanisme de compatibilité documenté, HORS de ce
|
|
22
|
+
* parcours — et sa seule présence n'active plus rien (c'est le défaut v1 fermé en vague 1).
|
|
23
|
+
*/
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
import { nowISO } from './ids.js';
|
|
26
|
+
import { loadAgentSigningKey, ensureAgentSigningKey } from './agent-registry.js';
|
|
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';
|
|
30
|
+
export class PairingError extends Error {
|
|
31
|
+
stage;
|
|
32
|
+
status;
|
|
33
|
+
constructor(message, stage, status) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.stage = stage;
|
|
36
|
+
this.status = status;
|
|
37
|
+
this.name = 'PairingError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Phases 1 à 3 du parcours : réclamer l'invitation, prouver la possession de l'identité,
|
|
42
|
+
* attester la clé de chiffrement. Le tout SANS que l'humain ne copie autre chose que le
|
|
43
|
+
* code d'invitation.
|
|
44
|
+
*
|
|
45
|
+
* REPRENABLE PAR CONSTRUCTION. Chaque phase est un appel distinct dont le résultat est
|
|
46
|
+
* écrit avant la suivante, et la clé d'appareil n'est jamais régénérée si elle existe
|
|
47
|
+
* (`ensureDeviceKey`). Une interruption laisse un enrollment en attente côté cloud, qui
|
|
48
|
+
* expire proprement — jamais un orphelin non réclamable.
|
|
49
|
+
*/
|
|
50
|
+
export async function beginPairing(params) {
|
|
51
|
+
const cwd = params.cwd ?? process.cwd();
|
|
52
|
+
// (1) L'IDENTITÉ D'ABORD. `ensureAgentSigningKey` ne fait PAS tourner une clé
|
|
53
|
+
// existante : une rotation silencieuse invaliderait l'empreinte déjà approuvée côté
|
|
54
|
+
// cloud, et l'agent se retrouverait rejeté sans comprendre pourquoi.
|
|
55
|
+
ensureAgentSigningKey(params.agentId);
|
|
56
|
+
const identity = loadAgentSigningKey(params.agentId);
|
|
57
|
+
if (!identity) {
|
|
58
|
+
throw new PairingError(`Aucune clé d'identité Ed25519 pour l'agent '${params.agentId}'. La cérémonie ne peut pas commencer sans identité.`, 'identity');
|
|
59
|
+
}
|
|
60
|
+
// (2) RÉCLAMER L'INVITATION. Le code n'est jamais stocké — ni ici, ni côté cloud, qui
|
|
61
|
+
// n'en garde que le SHA-256.
|
|
62
|
+
const claimed = await params.transport.post('/api/v1/enrollments/claim', {
|
|
63
|
+
invite_code: params.inviteCode,
|
|
64
|
+
identity_public_key_pem: identity.publicKeyPem,
|
|
65
|
+
agent_id: params.agentId,
|
|
66
|
+
});
|
|
67
|
+
if (claimed.status !== 200 && claimed.status !== 201) {
|
|
68
|
+
throw new PairingError(describeError(claimed.body, "l'invitation n'a pas pu être réclamée"), 'claim', claimed.status);
|
|
69
|
+
}
|
|
70
|
+
const enrollmentId = asString(claimed.body['enrollment_id']);
|
|
71
|
+
const cloudProjectId = asString(claimed.body['project_id']);
|
|
72
|
+
const challenge = asString(claimed.body['pop_challenge']);
|
|
73
|
+
if (!enrollmentId || !cloudProjectId || !challenge) {
|
|
74
|
+
throw new PairingError('Réponse de claim incomplète (enrollment_id, project_id ou pop_challenge manquant).', 'claim');
|
|
75
|
+
}
|
|
76
|
+
// Le cloud renvoie l'empreinte qu'il a calculée. On la RECALCULE localement et on
|
|
77
|
+
// compare : un désaccord signifie que la clé enregistrée n'est pas celle qu'on croit
|
|
78
|
+
// avoir envoyée, ce qui invaliderait toute la chaîne d'attestation qui suit.
|
|
79
|
+
const remoteIdentityFp = asString(claimed.body['identity_key_fingerprint']);
|
|
80
|
+
const localIdentityFp = fingerprintPem(identity.publicKeyPem);
|
|
81
|
+
if (remoteIdentityFp && remoteIdentityFp !== localIdentityFp) {
|
|
82
|
+
throw new PairingError(`Empreinte d'identité divergente : le cloud a enregistré ${remoteIdentityFp}, cet appareil détient ${localIdentityFp}. ` +
|
|
83
|
+
`Interrompre — la clé enregistrée n'est pas celle de cet appareil.`, 'claim');
|
|
84
|
+
}
|
|
85
|
+
// (3) LA CLÉ DE CHIFFREMENT DE L'APPAREIL. Distincte de l'identité, jamais dérivée
|
|
86
|
+
// d'elle (RFC §5.1) — c'est ce qui rend « écrire sans lire » possible.
|
|
87
|
+
const deviceId = params.deviceId ?? newDeviceId();
|
|
88
|
+
const device = ensureDeviceKey(deviceId);
|
|
89
|
+
// (4) PREUVE DE POSSESSION + ATTESTATION, EN UN SEUL ACTE. Les deux signatures sont
|
|
90
|
+
// produites par la MÊME clé d'identité : c'est ce lien qui interdit à quiconque
|
|
91
|
+
// d'attester une clé de chiffrement sans détenir l'identité approuvée.
|
|
92
|
+
const challengeSignature = crypto
|
|
93
|
+
.sign(null, Buffer.from(new TextEncoder().encode(challenge)), crypto.createPrivateKey(identity.privateKeyPem))
|
|
94
|
+
.toString('base64');
|
|
95
|
+
const attestation = buildKeyAttestation({
|
|
96
|
+
enrollmentId,
|
|
97
|
+
projectId: cloudProjectId,
|
|
98
|
+
agentId: params.agentId,
|
|
99
|
+
encryptionPublicKeyPem: device.public_key_pem,
|
|
100
|
+
identityPrivateKeyPem: identity.privateKeyPem,
|
|
101
|
+
// L'horodatage vient d'ICI et voyage avec la signature. Le serveur ne le fabrique
|
|
102
|
+
// pas : il ne pourrait pas, l'appareil ayant signé avant qu'il ne l'apprenne.
|
|
103
|
+
createdAt: nowISO(),
|
|
104
|
+
});
|
|
105
|
+
const proved = await params.transport.post(`/api/v1/enrollments/${enrollmentId}/prove`, {
|
|
106
|
+
invite_code: params.inviteCode,
|
|
107
|
+
challenge_signature: challengeSignature,
|
|
108
|
+
encryption_public_key_pem: device.public_key_pem,
|
|
109
|
+
attestation_signature: attestation.signature,
|
|
110
|
+
attestation_created_at: attestation.created_at,
|
|
111
|
+
key_type: 'encryption',
|
|
112
|
+
key_purpose: 'envelope',
|
|
113
|
+
});
|
|
114
|
+
if (proved.status !== 200) {
|
|
115
|
+
throw new PairingError(describeError(proved.body, "la preuve de possession a été refusée"), 'prove', proved.status);
|
|
116
|
+
}
|
|
117
|
+
// (5) ÉTAT LOCAL en 'pending'. Créer l'état ne vaut pas approbation — le passage à
|
|
118
|
+
// 'active' appartient à la confirmation humaine, phase suivante.
|
|
119
|
+
const deviceRecord = {
|
|
120
|
+
device_id: deviceId,
|
|
121
|
+
x25519_fingerprint: device.fingerprint,
|
|
122
|
+
attested_by_ed25519: localIdentityFp,
|
|
123
|
+
enrolled_at: nowISO(),
|
|
124
|
+
// Le premier appareil est marqué récupération : sans cela, un projet mono-appareil
|
|
125
|
+
// n'atteindrait jamais le quorum de RFC §5.3 et ne pourrait rien émettre. Le second
|
|
126
|
+
// porteur reste exigé — `recoveryReadiness` continue de refuser tant qu'il manque.
|
|
127
|
+
recovery: true,
|
|
128
|
+
};
|
|
129
|
+
const state = createConnectionState({
|
|
130
|
+
cloudProjectId,
|
|
131
|
+
device: deviceRecord,
|
|
132
|
+
workspacePath: cwd,
|
|
133
|
+
enrollmentId,
|
|
134
|
+
});
|
|
135
|
+
saveConnectionState(state, cwd);
|
|
136
|
+
return {
|
|
137
|
+
enrollment_id: enrollmentId,
|
|
138
|
+
cloud_project_id: cloudProjectId,
|
|
139
|
+
device,
|
|
140
|
+
fingerprints: { identity: localIdentityFp, encryption: device.fingerprint },
|
|
141
|
+
state,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Interroge l'état d'un enrollment en attente d'approbation.
|
|
146
|
+
*
|
|
147
|
+
* NE MATÉRIALISE RIEN. Le premier pull réel est en lecture seule et non destructif
|
|
148
|
+
* (RFC §5.2 phase 4) ; cette fonction ne fait que lire un état de cérémonie.
|
|
149
|
+
*/
|
|
150
|
+
export async function checkPairingApproval(params) {
|
|
151
|
+
const res = await params.transport.get(`/api/v1/enrollments/${params.enrollmentId}`);
|
|
152
|
+
if (res.status !== 200) {
|
|
153
|
+
throw new PairingError(describeError(res.body, "l'état de l'enrôlement n'a pas pu être lu"), 'poll', res.status);
|
|
154
|
+
}
|
|
155
|
+
const enrollment = (res.body['enrollment'] ?? res.body);
|
|
156
|
+
const state = asString(enrollment['state']) ?? 'unknown';
|
|
157
|
+
return { state, role: asString(enrollment['invited_role']), approved: state === 'active' };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Bascule l'état local en 'active' une fois l'approbation obtenue.
|
|
161
|
+
*
|
|
162
|
+
* SÉPARÉ DU SONDAGE VOLONTAIREMENT : lire l'état distant et modifier l'état local sont
|
|
163
|
+
* deux actes. Les fusionner ferait qu'une lecture de statut mute le workspace — un effet
|
|
164
|
+
* de bord invisible dans une commande qu'on croit inoffensive.
|
|
165
|
+
*/
|
|
166
|
+
export function completePairing(params) {
|
|
167
|
+
const cwd = params.cwd ?? process.cwd();
|
|
168
|
+
const state = loadConnectionState(cwd);
|
|
169
|
+
if (!state) {
|
|
170
|
+
throw new PairingError("Aucun état d'appairage local — relancer `brainclaw cloud connect`.", 'complete');
|
|
171
|
+
}
|
|
172
|
+
const next = {
|
|
173
|
+
...state,
|
|
174
|
+
enrollment: { ...state.enrollment, stage: 'active', role: params.role ?? state.enrollment.role, updated_at: nowISO() },
|
|
175
|
+
};
|
|
176
|
+
saveConnectionState(next, cwd);
|
|
177
|
+
return next;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Ce qu'un `disconnect` fait — et surtout ce qu'il NE FAIT PAS.
|
|
181
|
+
*
|
|
182
|
+
* Il retire l'autorisation LOCALE et demande la révocation distante. Il ne prétend pas
|
|
183
|
+
* effacer les blobs déjà tirés ni les clés déjà lues par d'autres appareils (RFC §5.2).
|
|
184
|
+
* Le dire est le contrat ; le taire laisserait croire à un effacement rétroactif que la
|
|
185
|
+
* cryptographie ne permet pas.
|
|
186
|
+
*/
|
|
187
|
+
export async function requestRevocation(params) {
|
|
188
|
+
try {
|
|
189
|
+
const res = await params.transport.post(`/api/v1/enrollments/${params.enrollmentId}/revoke`, {
|
|
190
|
+
reason: params.reason ?? 'disconnect local',
|
|
191
|
+
});
|
|
192
|
+
return { revoked: res.status === 200, detail: asString(res.body['error']) };
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
// Un cloud injoignable ne doit PAS empêcher de se déconnecter localement : sinon un
|
|
196
|
+
// appareil perdu resterait autorisé faute de réseau. On rend l'échec, l'appelant
|
|
197
|
+
// efface quand même le local et le dit à l'humain.
|
|
198
|
+
return { revoked: false, detail: err instanceof Error ? err.message : String(err) };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// ── Aides ─────────────────────────────────────────────────────────────────────
|
|
202
|
+
function asString(v) {
|
|
203
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
204
|
+
}
|
|
205
|
+
/** Remonte le message du serveur quand il y en a un, plutôt qu'un libellé générique. */
|
|
206
|
+
function describeError(body, fallback) {
|
|
207
|
+
const err = asString(body['error']);
|
|
208
|
+
const code = asString(body['code']);
|
|
209
|
+
if (err)
|
|
210
|
+
return code ? `${err} (${code})` : err;
|
|
211
|
+
return fallback;
|
|
212
|
+
}
|
|
213
|
+
//# sourceMappingURL=federation-pairing.js.map
|