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.
- 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/export.js +3 -3
- package/dist/commands/init.js +11 -0
- package/dist/commands/mcp-write-claims.js +162 -46
- package/dist/commands/mcp-write-entities.js +67 -0
- package/dist/commands/mcp.js +64 -1
- package/dist/commands/session-end.js +0 -102
- package/dist/commands/session-start.js +0 -23
- package/dist/commands/switch.js +41 -12
- package/dist/core/actions.js +25 -1
- package/dist/core/agent-files.js +19 -0
- package/dist/core/agentruns.js +68 -10
- package/dist/core/assignments.js +94 -19
- package/dist/core/claims.js +13 -24
- package/dist/core/config.js +58 -0
- package/dist/core/context-diff.js +28 -11
- package/dist/core/coordination.js +1 -3
- package/dist/core/entity-locator.js +404 -0
- 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/identity.js +9 -1
- package/dist/core/ids.js +5 -0
- package/dist/core/io.js +39 -1
- package/dist/core/operations/relocate.js +40 -10
- package/dist/core/schema.js +24 -17
- package/dist/core/sequence.js +47 -6
- package/dist/core/store-resolution.js +99 -26
- package/dist/core/workspace-projects.js +23 -2
- package/dist/core/worktree.js +59 -1
- package/dist/facts.js +7 -7
- package/dist/facts.json +6 -6
- package/docs/cli.md +73 -40
- 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,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
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Le projecteur et ses trois filets — fédération v2 (pln#651 étape 5, RFC §4).
|
|
3
|
+
*
|
|
4
|
+
* C'est ici que la table de classification devient EXÉCUTABLE. Trois classes, et
|
|
5
|
+
* « non classé » n'en est pas une quatrième : c'est un refus.
|
|
6
|
+
*
|
|
7
|
+
* ── POURQUOI TROIS FILETS ET NON UN SEUL ──────────────────────────────────────
|
|
8
|
+
* Établi par quatre critiques sur quatre pendant l'idéation, chacune par un angle
|
|
9
|
+
* différent :
|
|
10
|
+
* (i) Zod fait `.strip()` PAR DÉFAUT — un champ nouveau passe la validation en
|
|
11
|
+
* silence, sans jamais lever ;
|
|
12
|
+
* (ii) un test couvre la SORTIE, pas la CONSTRUCTION — N projecteurs, un seul testé,
|
|
13
|
+
* et les N-1 autres fuient (application littérale de trp#5a8fb7d9) ;
|
|
14
|
+
* (iii) `{...entity, ciphertext}` compile parfaitement et déverse tout.
|
|
15
|
+
*
|
|
16
|
+
* Et le typage seul ne borne RIEN. `pushToCloud(payload: { id: string })` suivi d'un
|
|
17
|
+
* `JSON.stringify` sérialise chaque clé présente à L'EXÉCUTION : le typage structurel de
|
|
18
|
+
* TypeScript est une BORNE INFÉRIEURE sur ce que l'objet contient, jamais une borne
|
|
19
|
+
* supérieure. C'est la raison pour laquelle le filet 2 existe malgré le filet 1.
|
|
20
|
+
*
|
|
21
|
+
* FILET 1 — un builder nominal, marque de type non fabricable ailleurs, sélection
|
|
22
|
+
* explicite champ par champ, JAMAIS de spread.
|
|
23
|
+
* FILET 2 — un parse `.strict()` à UN SEUL point de sortie que tout push traverse.
|
|
24
|
+
* FILET 3 — fixture golden byte-exacte + test de complétude sur l'inventaire.
|
|
25
|
+
*
|
|
26
|
+
* ── CE QUI RESTE VISIBLE, ÉCRIT ET NON MASQUÉ ─────────────────────────────────
|
|
27
|
+
* « Lane 3 » cache le libellé, pas le GRAPHE. Le nombre de lanes, la forme du graphe de
|
|
28
|
+
* dépendances et la cardinalité restent lisibles par le Cloud. C'est assumé : le board
|
|
29
|
+
* aveugle rend une structure, et une structure est une information. Le prétendre
|
|
30
|
+
* autrement serait mentir sur la garantie.
|
|
31
|
+
*/
|
|
32
|
+
import crypto from 'node:crypto';
|
|
33
|
+
import { z } from 'zod';
|
|
34
|
+
import { canonicalJson, canonicalSha256, b64url } from './federation-canonical.js';
|
|
35
|
+
import { seal, HPKE_SUITE } from './federation-hpke.js';
|
|
36
|
+
export const ENVELOPE_SCHEMA = 'brainclaw.federation-envelope/v1';
|
|
37
|
+
export const AAD_PROTOCOL = 'brainclaw/federation/v1';
|
|
38
|
+
/**
|
|
39
|
+
* Types d'entités PROJETABLES. Une famille absente de cette liste n'est pas « à faire
|
|
40
|
+
* plus tard » : son schéma entier est interdit de sortie (RFC §4.3). L'ajout d'une
|
|
41
|
+
* famille est un acte délibéré qui passe par la table de classification.
|
|
42
|
+
*/
|
|
43
|
+
export const FEDERATED_KINDS = [
|
|
44
|
+
'constraint', 'decision', 'trap', 'handoff', 'plan', 'plan_step', 'sequence',
|
|
45
|
+
'claim', 'candidate', 'runtime_note', 'inbox_message', 'assignment',
|
|
46
|
+
'agent_run', 'action_required', 'ai_task', 'runtime_event', 'lane_result',
|
|
47
|
+
];
|
|
48
|
+
// ── Champs INTERDITS DE SORTIR (RFC §4.2, classe 3) ──────────────────────────
|
|
49
|
+
/**
|
|
50
|
+
* Ces noms ne doivent apparaître NI dans meta NI dans sealed. Leur présence fait échouer
|
|
51
|
+
* la projection — un refus, pas une troncature.
|
|
52
|
+
*
|
|
53
|
+
* POURQUOI UNE LISTE DE NOMS ICI ALORS QUE J'AI ÉCRIT AILLEURS QU'UNE LISTE DE NOMS NE
|
|
54
|
+
* SUFFIT PAS : elle ne suffit effectivement pas SEULE, et n'est pas seule. Le filet 1
|
|
55
|
+
* empêche déjà tout champ non explicitement sélectionné d'entrer dans meta, et le filet 2
|
|
56
|
+
* refuse toute clé inconnue à la sortie. Cette liste est un TROISIÈME contrôle qui vise
|
|
57
|
+
* le SCELLÉ — la seule partie que les deux autres filets ne peuvent pas inspecter,
|
|
58
|
+
* puisqu'ils la traitent comme opaque. Sans elle, `worktree_path` chiffré partirait quand
|
|
59
|
+
* même : chiffré n'est pas « autorisé à sortir ».
|
|
60
|
+
*/
|
|
61
|
+
export const FORBIDDEN_LEAF_NAMES = new Set([
|
|
62
|
+
'host_id', 'session_id', 'worktree_path', 'project_path', 'storage_dir', 'related_paths',
|
|
63
|
+
'command', 'shell', 'pid', 'provider_run_id', 'base_sha', 'cwd',
|
|
64
|
+
'api_key', 'apiKey', 'secret', 'token', 'password', 'private_key', 'privateKey',
|
|
65
|
+
'env', 'environment',
|
|
66
|
+
]);
|
|
67
|
+
/** Motifs de VALEURS trahissant un chemin local, quel que soit le nom du champ. */
|
|
68
|
+
const LOCAL_PATH_PATTERNS = [
|
|
69
|
+
/^[A-Za-z]:[\\/]/, // C:\... ou C:/...
|
|
70
|
+
/^\/(?:home|Users|root)\//, // /home/x, /Users/x, /root/x
|
|
71
|
+
/\.brainclaw[\\/]worktrees/,
|
|
72
|
+
];
|
|
73
|
+
export class ProjectionRefused extends Error {
|
|
74
|
+
path;
|
|
75
|
+
constructor(message, path) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.path = path;
|
|
78
|
+
this.name = 'ProjectionRefused';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Parcours RÉCURSIF refusant tout champ interdit, par son nom ou par la forme de sa
|
|
83
|
+
* valeur.
|
|
84
|
+
*
|
|
85
|
+
* Appliqué au CLAIR AVANT scellement, donc y compris à ce qui partira chiffré. Le RFC est
|
|
86
|
+
* explicite : la classe interdite est « absente de meta ET de sealed ». Un chemin de
|
|
87
|
+
* worktree chiffré reste une donnée qui a quitté l'hôte, et le jour où la clé fuit, elle
|
|
88
|
+
* a fui aussi. Le chiffrement protège le contenu ; il ne rend pas licite de l'envoyer.
|
|
89
|
+
*/
|
|
90
|
+
export function assertNoForbiddenLeaf(value, path = '$') {
|
|
91
|
+
if (value === null || value === undefined)
|
|
92
|
+
return;
|
|
93
|
+
if (typeof value === 'string') {
|
|
94
|
+
for (const pattern of LOCAL_PATH_PATTERNS) {
|
|
95
|
+
if (pattern.test(value)) {
|
|
96
|
+
throw new ProjectionRefused(`Chemin local détecté en ${path} — interdit de sortir, même scellé (RFC §4.2).`, path);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
value.forEach((v, i) => assertNoForbiddenLeaf(v, `${path}[${i}]`));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (typeof value === 'object') {
|
|
106
|
+
for (const [key, v] of Object.entries(value)) {
|
|
107
|
+
if (FORBIDDEN_LEAF_NAMES.has(key)) {
|
|
108
|
+
throw new ProjectionRefused(`Champ interdit '${key}' en ${path} — absent de meta ET de sealed (RFC §4.2).`, `${path}.${key}`);
|
|
109
|
+
}
|
|
110
|
+
assertNoForbiddenLeaf(v, `${path}.${key}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// ── FILET 2 : le schéma strict du point de sortie unique ─────────────────────
|
|
115
|
+
/**
|
|
116
|
+
* `.strict()` PARTOUT, et c'est le point.
|
|
117
|
+
*
|
|
118
|
+
* Zod `.strip()` — le défaut — accepterait un objet porteur d'une clé inconnue et la
|
|
119
|
+
* retirerait SILENCIEUSEMENT du résultat. Le push passerait, et personne n'apprendrait
|
|
120
|
+
* qu'un champ non classé a été ajouté au projecteur. `.strict()` LÈVE, ce qui transforme
|
|
121
|
+
* l'oubli de classification en échec de CI plutôt qu'en fuite discrète.
|
|
122
|
+
*/
|
|
123
|
+
const CanonicalAadSchema = z.object({
|
|
124
|
+
protocol: z.literal(AAD_PROTOCOL),
|
|
125
|
+
cloud_project_id: z.string().min(1),
|
|
126
|
+
object_id: z.string().min(1),
|
|
127
|
+
base_rev: z.number().int().nonnegative(),
|
|
128
|
+
object_type: z.enum(FEDERATED_KINDS),
|
|
129
|
+
schema: z.literal(ENVELOPE_SCHEMA),
|
|
130
|
+
}).strict();
|
|
131
|
+
const PublicMetaSchema = z.object({
|
|
132
|
+
id_opaque: z.string().uuid(),
|
|
133
|
+
kind: z.enum(FEDERATED_KINDS),
|
|
134
|
+
status: z.object({
|
|
135
|
+
object: z.string().min(1),
|
|
136
|
+
sync: z.enum(['pending', 'synced', 'conflict']).optional(),
|
|
137
|
+
}).strict(),
|
|
138
|
+
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
|
139
|
+
rank: z.number().int().optional(),
|
|
140
|
+
deps: z.array(z.object({ from: z.string().uuid(), to: z.string().uuid() }).strict()),
|
|
141
|
+
// Jour UTC, JAMAIS l'heure. Une heure précise trahit le rythme de travail d'une
|
|
142
|
+
// personne ; le jour ne dit que la cadence, ce que le RFC assume explicitement.
|
|
143
|
+
timestamp_bucket_jour: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
144
|
+
base_rev: z.number().int().nonnegative(),
|
|
145
|
+
aad: CanonicalAadSchema,
|
|
146
|
+
// Ne contient NI nom NI empreinte de destinataire : la référence de paquet de clés
|
|
147
|
+
// aurait divulgué le roster, canal de fuite que l'idéation avait manqué.
|
|
148
|
+
wrap_hint: z.string().min(1),
|
|
149
|
+
transport: z.object({
|
|
150
|
+
operation_id: z.string().min(1),
|
|
151
|
+
content_hash: z.string().min(1),
|
|
152
|
+
idempotency_key: z.string().min(1),
|
|
153
|
+
}).strict(),
|
|
154
|
+
}).strict();
|
|
155
|
+
const SealedSchema = z.object({
|
|
156
|
+
alg: z.literal(HPKE_SUITE),
|
|
157
|
+
enc: z.string().min(1),
|
|
158
|
+
nonce: z.string().min(1),
|
|
159
|
+
ciphertext: z.string().min(1),
|
|
160
|
+
}).strict();
|
|
161
|
+
export const FederationEnvelopeSchema = z.object({
|
|
162
|
+
schema: z.literal(ENVELOPE_SCHEMA),
|
|
163
|
+
meta: PublicMetaSchema,
|
|
164
|
+
sealed: SealedSchema,
|
|
165
|
+
key_epoch: z.number().int().nonnegative(),
|
|
166
|
+
origin_sig: z.object({
|
|
167
|
+
alg: z.literal('Ed25519'),
|
|
168
|
+
key_id: z.string().min(1),
|
|
169
|
+
value: z.string().min(1),
|
|
170
|
+
}).strict(),
|
|
171
|
+
}).strict();
|
|
172
|
+
/** Jour UTC. La troncature est faite ICI pour qu'aucun appelant ne puisse l'oublier. */
|
|
173
|
+
function dayBucket(when) {
|
|
174
|
+
const d = typeof when === 'string' ? new Date(when) : when;
|
|
175
|
+
if (Number.isNaN(d.getTime()))
|
|
176
|
+
throw new ProjectionRefused('Date invalide pour le bucket jour.', '$.occurredAt');
|
|
177
|
+
return d.toISOString().slice(0, 10);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Construit la projection publique — SÉLECTION EXPLICITE, CHAMP PAR CHAMP.
|
|
181
|
+
*
|
|
182
|
+
* Aucun spread nulle part dans cette fonction, et c'est délibéré : `{...input}` suffirait
|
|
183
|
+
* à déverser tout ce que l'appelant a mis dans l'objet. Chaque champ de meta est nommé
|
|
184
|
+
* ici ou n'existe pas. Ajouter un champ à `PublicMetaSchema` sans l'ajouter ici produit
|
|
185
|
+
* une erreur de compilation, et l'inverse un échec de parse `.strict()`.
|
|
186
|
+
*
|
|
187
|
+
* `priority` et `rank` ne sont posés QUE s'ils existent : l'absence d'un champ optionnel
|
|
188
|
+
* est SIGNIFICATIVE (RFC §3). Inventer `priority: 'medium'` pour un objet qui n'en porte
|
|
189
|
+
* pas créerait une fuite par normalisation — le Cloud croirait à une priorité choisie.
|
|
190
|
+
*/
|
|
191
|
+
export function toPublicProjection(input) {
|
|
192
|
+
const aad = {
|
|
193
|
+
protocol: AAD_PROTOCOL,
|
|
194
|
+
cloud_project_id: input.cloudProjectId,
|
|
195
|
+
object_id: input.idOpaque,
|
|
196
|
+
base_rev: input.baseRev,
|
|
197
|
+
object_type: input.kind,
|
|
198
|
+
schema: ENVELOPE_SCHEMA,
|
|
199
|
+
};
|
|
200
|
+
const meta = {
|
|
201
|
+
id_opaque: input.idOpaque,
|
|
202
|
+
kind: input.kind,
|
|
203
|
+
status: input.syncState
|
|
204
|
+
? { object: input.statusObject, sync: input.syncState }
|
|
205
|
+
: { object: input.statusObject },
|
|
206
|
+
deps: (input.deps ?? []).map((d) => ({ from: d.from, to: d.to })),
|
|
207
|
+
timestamp_bucket_jour: dayBucket(input.occurredAt),
|
|
208
|
+
base_rev: input.baseRev,
|
|
209
|
+
aad,
|
|
210
|
+
wrap_hint: input.wrapHint,
|
|
211
|
+
transport: {
|
|
212
|
+
operation_id: input.operationId,
|
|
213
|
+
// Dérivés du CIPHERTEXT, jamais du clair (RFC §3.2). En v1, content_hash portait
|
|
214
|
+
// sur le corps sémantique en clair : le Cloud pouvait alors confirmer une
|
|
215
|
+
// devinette sur un contenu à faible entropie en comparant des hachages.
|
|
216
|
+
content_hash: canonicalSha256(input.sealed),
|
|
217
|
+
idempotency_key: '',
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
if (input.priority !== undefined)
|
|
221
|
+
meta.priority = input.priority;
|
|
222
|
+
if (input.rank !== undefined)
|
|
223
|
+
meta.rank = input.rank;
|
|
224
|
+
return meta;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* LE SEUL point par lequel une enveloppe peut être produite.
|
|
228
|
+
*
|
|
229
|
+
* L'ordre des opérations est un contrat, pas un détail :
|
|
230
|
+
* 1. refus des champs interdits sur le CLAIR (avant qu'ils ne deviennent illisibles) ;
|
|
231
|
+
* 2. AAD canonique, lié au projet, à l'objet, à la révision et au type ;
|
|
232
|
+
* 3. scellement HPKE avec cet AAD exact ;
|
|
233
|
+
* 4. projection publique par le filet 1 ;
|
|
234
|
+
* 5. dérivés de transport calculés sur le CIPHERTEXT ;
|
|
235
|
+
* 6. signature d'origine sur meta ‖ sealed ‖ key_epoch ;
|
|
236
|
+
* 7. parse `.strict()` — le filet 2 — qui refuse toute clé inconnue.
|
|
237
|
+
*
|
|
238
|
+
* Inverser 1 et 3 laisserait passer un champ interdit dans le blob chiffré.
|
|
239
|
+
*/
|
|
240
|
+
export function buildEnvelope(params) {
|
|
241
|
+
// (1) Le contrôle porte sur le clair, y compris ce qui sera scellé.
|
|
242
|
+
assertNoForbiddenLeaf(params.content, '$.content');
|
|
243
|
+
// (2) AAD comme STRUCTURE canonique et non concaténation ambiguë : « a|b » et « a|b »
|
|
244
|
+
// issus de découpages différents produisent la même chaîne, donc deux contextes
|
|
245
|
+
// distincts indiscernables.
|
|
246
|
+
const aad = {
|
|
247
|
+
protocol: AAD_PROTOCOL,
|
|
248
|
+
cloud_project_id: params.cloudProjectId,
|
|
249
|
+
object_id: params.idOpaque,
|
|
250
|
+
base_rev: params.baseRev,
|
|
251
|
+
object_type: params.kind,
|
|
252
|
+
schema: ENVELOPE_SCHEMA,
|
|
253
|
+
};
|
|
254
|
+
const aadBytes = new TextEncoder().encode(canonicalJson(aad));
|
|
255
|
+
// (3)
|
|
256
|
+
const sealed = seal({
|
|
257
|
+
recipientPublicKeyPem: params.recipientPublicKeyPem,
|
|
258
|
+
plaintext: new TextEncoder().encode(canonicalJson(params.content)),
|
|
259
|
+
aadCanonicalBytes: aadBytes,
|
|
260
|
+
});
|
|
261
|
+
// (4)
|
|
262
|
+
const meta = toPublicProjection({
|
|
263
|
+
kind: params.kind,
|
|
264
|
+
idOpaque: params.idOpaque,
|
|
265
|
+
cloudProjectId: params.cloudProjectId,
|
|
266
|
+
baseRev: params.baseRev,
|
|
267
|
+
statusObject: params.statusObject,
|
|
268
|
+
syncState: params.syncState,
|
|
269
|
+
priority: params.priority,
|
|
270
|
+
rank: params.rank,
|
|
271
|
+
deps: params.deps,
|
|
272
|
+
occurredAt: params.occurredAt,
|
|
273
|
+
wrapHint: params.wrapHint,
|
|
274
|
+
operationId: params.operationId,
|
|
275
|
+
sealed,
|
|
276
|
+
});
|
|
277
|
+
// (5) idempotency_key = SHA-256(canonical(sealed) || operation_id || origin_sig.key_id).
|
|
278
|
+
// CLEFÉE par l'identité du signataire : deux agents qui pousseraient le même contenu
|
|
279
|
+
// n'auraient pas la même clé, donc le Cloud ne peut pas déduire qu'ils poussent la
|
|
280
|
+
// même chose.
|
|
281
|
+
meta.transport.idempotency_key = b64url(new Uint8Array(crypto.createHash('sha256')
|
|
282
|
+
.update(canonicalJson(sealed), 'utf-8')
|
|
283
|
+
.update(params.operationId, 'utf-8')
|
|
284
|
+
.update(params.originKeyId, 'utf-8')
|
|
285
|
+
.digest()));
|
|
286
|
+
// (6) L'entrée couvre alg, enc, nonce ET ciphertext, plus key_epoch — pas seulement
|
|
287
|
+
// « meta || ciphertext ». Le raccourci ne couvrirait pas les paramètres permettant
|
|
288
|
+
// d'INTERPRÉTER le ciphertext, qu'un Cloud pourrait alors modifier sans casser la
|
|
289
|
+
// signature.
|
|
290
|
+
const signingInput = Buffer.concat([
|
|
291
|
+
Buffer.from('brainclaw/federation-envelope/v1\0', 'utf-8'),
|
|
292
|
+
Buffer.from(canonicalJson(meta), 'utf-8'),
|
|
293
|
+
Buffer.from(canonicalJson(sealed), 'utf-8'),
|
|
294
|
+
Buffer.from(canonicalJson(params.keyEpoch), 'utf-8'),
|
|
295
|
+
]);
|
|
296
|
+
const signature = crypto.sign(null, signingInput, crypto.createPrivateKey(params.originPrivateKeyPem));
|
|
297
|
+
const envelope = {
|
|
298
|
+
schema: ENVELOPE_SCHEMA,
|
|
299
|
+
meta,
|
|
300
|
+
sealed,
|
|
301
|
+
key_epoch: params.keyEpoch,
|
|
302
|
+
origin_sig: { alg: 'Ed25519', key_id: params.originKeyId, value: signature.toString('base64url') },
|
|
303
|
+
};
|
|
304
|
+
// (7) FILET 2 — fail-closed. Une clé inconnue lève ici plutôt que de partir sur le fil.
|
|
305
|
+
return FederationEnvelopeSchema.parse(envelope);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Octets exacts sur lesquels porte la signature d'origine.
|
|
309
|
+
*
|
|
310
|
+
* Exporté pour que le VÉRIFICATEUR les reconstruise avec la même fonction que
|
|
311
|
+
* l'émetteur. Deux reconstructions indépendantes est précisément le défaut qui a rendu
|
|
312
|
+
* l'attestation d'appairage insatisfiable côté Cloud : le vérificateur fabriquait un
|
|
313
|
+
* champ que le signataire ne pouvait pas connaître.
|
|
314
|
+
*/
|
|
315
|
+
export function originSigningInput(meta, sealed, keyEpoch) {
|
|
316
|
+
return Buffer.concat([
|
|
317
|
+
Buffer.from('brainclaw/federation-envelope/v1\0', 'utf-8'),
|
|
318
|
+
Buffer.from(canonicalJson(meta), 'utf-8'),
|
|
319
|
+
Buffer.from(canonicalJson(sealed), 'utf-8'),
|
|
320
|
+
Buffer.from(canonicalJson(keyEpoch), 'utf-8'),
|
|
321
|
+
]);
|
|
322
|
+
}
|
|
323
|
+
// ── Ids opaques ──────────────────────────────────────────────────────────────
|
|
324
|
+
/**
|
|
325
|
+
* Ids RE-ROULÉS en UUID v4, mapping local↔cloud gardé LOCAL.
|
|
326
|
+
*
|
|
327
|
+
* Pourquoi ne pas simplement hacher l'id local : un hachage est déterministe, donc le
|
|
328
|
+
* même objet exporté vers deux projets Cloud produirait le même identifiant, et un
|
|
329
|
+
* observateur corrélerait les deux projets. Un UUID aléatoire par projet coupe cette
|
|
330
|
+
* corrélation — c'est le sens de « stable dans un projet Cloud, pas cross-projet »
|
|
331
|
+
* (RFC §4.1).
|
|
332
|
+
*/
|
|
333
|
+
export function newOpaqueId() {
|
|
334
|
+
return crypto.randomUUID();
|
|
335
|
+
}
|
|
336
|
+
//# sourceMappingURL=federation-projection.js.map
|