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,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sérialisation canonique — fédération v2 (pln#651 étape 5, RFC §3.1).
|
|
3
|
+
*
|
|
4
|
+
* ── POURQUOI « CANONIQUE » ET PAS SIMPLEMENT `JSON.stringify` ─────────────────
|
|
5
|
+
* Ces octets sont hachés, chiffrés et signés par UN programme, puis vérifiés par UN
|
|
6
|
+
* AUTRE. Une différence d'un seul octet — un espace, un ordre de clés, un `1e3` au lieu
|
|
7
|
+
* de `1000` — fait échouer la vérification sans qu'aucun message ne dise laquelle des
|
|
8
|
+
* deux implémentations a tort.
|
|
9
|
+
*
|
|
10
|
+
* `JSON.stringify` sur un OBJET n'est déterministe que si l'ordre d'insertion l'est. Il
|
|
11
|
+
* ne l'est pas quand l'objet vient d'un `JSON.parse`, d'un spread ou d'un tri différent.
|
|
12
|
+
* D'où un sérialiseur explicite qui trie par point de code, comme l'exige le RFC.
|
|
13
|
+
*
|
|
14
|
+
* Le RFC dit aussi : « Core et Cloud partagent les vecteurs de test ; ils ne
|
|
15
|
+
* réimplémentent pas chacun une quasi-canonicalisation. » Les vecteurs vivent dans les
|
|
16
|
+
* tests des deux dépôts, sur les mêmes chaînes littérales.
|
|
17
|
+
*/
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
/**
|
|
20
|
+
* Trie par POINT DE CODE et non par `localeCompare`.
|
|
21
|
+
*
|
|
22
|
+
* `Array.prototype.sort()` sans comparateur trie déjà par unité de code UTF-16, ce qui
|
|
23
|
+
* diffère du point de code pour les caractères hors du plan multilingue de base. Un
|
|
24
|
+
* emoji dans un nom de clé suffirait à faire diverger deux implémentations qui croient
|
|
25
|
+
* toutes deux « trier les clés ». On compare donc explicitement les points de code.
|
|
26
|
+
*/
|
|
27
|
+
function compareCodePoints(a, b) {
|
|
28
|
+
const ai = Array.from(a);
|
|
29
|
+
const bi = Array.from(b);
|
|
30
|
+
const n = Math.min(ai.length, bi.length);
|
|
31
|
+
for (let i = 0; i < n; i++) {
|
|
32
|
+
const ca = ai[i].codePointAt(0);
|
|
33
|
+
const cb = bi[i].codePointAt(0);
|
|
34
|
+
if (ca !== cb)
|
|
35
|
+
return ca - cb;
|
|
36
|
+
}
|
|
37
|
+
return ai.length - bi.length;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* JSON canonique : clés triées, aucune espace, chaînes NFC, entiers finis sans notation
|
|
41
|
+
* exponentielle.
|
|
42
|
+
*
|
|
43
|
+
* REFUSE plutôt que d'inventer une représentation pour ce que JSON ne porte pas
|
|
44
|
+
* fidèlement : `undefined`, `NaN`, `Infinity`, fonctions, symboles, `BigInt`. Les
|
|
45
|
+
* sérialiser en `null` — ce que fait `JSON.stringify` pour certains — produirait deux
|
|
46
|
+
* objets différents avec les mêmes octets, donc une signature valide pour un contenu
|
|
47
|
+
* qu'on n'a pas signé.
|
|
48
|
+
*/
|
|
49
|
+
export function canonicalJson(value) {
|
|
50
|
+
if (value === null)
|
|
51
|
+
return 'null';
|
|
52
|
+
switch (typeof value) {
|
|
53
|
+
case 'boolean':
|
|
54
|
+
return value ? 'true' : 'false';
|
|
55
|
+
case 'number':
|
|
56
|
+
if (!Number.isFinite(value)) {
|
|
57
|
+
throw new Error(`Canonicalisation impossible : nombre non fini (${String(value)}).`);
|
|
58
|
+
}
|
|
59
|
+
// `String(1e21)` rend "1e+21". Le RFC interdit la notation exponentielle, et un
|
|
60
|
+
// vérificateur qui lirait "1e+21" produirait d'autres octets que celui qui écrit
|
|
61
|
+
// "1000000000000000000000". Refuser est plus sûr qu'une conversion approximative.
|
|
62
|
+
if (Number.isInteger(value) && Math.abs(value) >= 1e21) {
|
|
63
|
+
throw new Error(`Canonicalisation impossible : entier hors de la plage sérialisable sans exposant (${value}).`);
|
|
64
|
+
}
|
|
65
|
+
return JSON.stringify(value);
|
|
66
|
+
case 'string':
|
|
67
|
+
// NFC : « é » composé et « e + accent » combinés sont visuellement identiques et
|
|
68
|
+
// produisent des octets différents. Sans normalisation, un titre saisi sur macOS
|
|
69
|
+
// (NFD par défaut) et le même titre saisi sur Windows ne se vérifieraient pas.
|
|
70
|
+
return JSON.stringify(value.normalize('NFC'));
|
|
71
|
+
case 'object': {
|
|
72
|
+
if (Array.isArray(value)) {
|
|
73
|
+
return `[${value.map((v) => canonicalJson(v)).join(',')}]`;
|
|
74
|
+
}
|
|
75
|
+
const obj = value;
|
|
76
|
+
const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort(compareCodePoints);
|
|
77
|
+
return `{${keys.map((k) => `${JSON.stringify(k.normalize('NFC'))}:${canonicalJson(obj[k])}`).join(',')}}`;
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
throw new Error(`Canonicalisation impossible : type '${typeof value}' non sérialisable en JSON.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** base64url sans padding — la seule forme admise par le RFC pour les champs binaires. */
|
|
84
|
+
export function b64url(bytes) {
|
|
85
|
+
return Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
86
|
+
}
|
|
87
|
+
export function b64urlDecode(s) {
|
|
88
|
+
const padded = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
89
|
+
return new Uint8Array(Buffer.from(padded + '='.repeat((4 - (padded.length % 4)) % 4), 'base64'));
|
|
90
|
+
}
|
|
91
|
+
/** SHA-256 des octets canoniques d'une valeur, rendu en base64url (RFC §3.2). */
|
|
92
|
+
export function canonicalSha256(value) {
|
|
93
|
+
return b64url(new Uint8Array(crypto.createHash('sha256').update(canonicalJson(value), 'utf-8').digest()));
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=federation-canonical.js.map
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HPKE mode base — DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / ChaCha20-Poly1305.
|
|
3
|
+
* Suite v1 du RFC fédération §3.1, conforme à la RFC 9180.
|
|
4
|
+
*
|
|
5
|
+
* ── POURQUOI CE CODE EXISTE PLUTÔT QU'UNE DÉPENDANCE ──────────────────────────
|
|
6
|
+
* Node n'expose pas HPKE. Le projet tient à zéro dépendance d'exécution hors
|
|
7
|
+
* commander/yaml/zod, et ajouter une bibliothèque de crypto pour un usage aussi restreint
|
|
8
|
+
* — mode base, une suite, chiffrement à un coup — élargirait la surface d'audit bien
|
|
9
|
+
* au-delà du besoin.
|
|
10
|
+
*
|
|
11
|
+
* CE QUI EST IMPLÉMENTÉ, ET CE QUI NE L'EST PAS. Uniquement le MODE BASE (`mode = 0x00`)
|
|
12
|
+
* en un seul coup. Ni PSK, ni authentification de l'expéditeur, ni API à secret exporté,
|
|
13
|
+
* ni chiffrements multiples sur un même contexte. L'authenticité de l'émetteur ne repose
|
|
14
|
+
* PAS sur HPKE ici : elle vient de la signature Ed25519 d'origine, portée dans
|
|
15
|
+
* l'enveloppe et vérifiée par tout lecteur (RFC §3.1). Confondre les deux serait une
|
|
16
|
+
* erreur d'architecture — HPKE en mode base ne dit rien de QUI a chiffré.
|
|
17
|
+
*
|
|
18
|
+
* TOUTES LES CONSTANTES SONT CELLES DE LA RFC 9180 et sont vérifiées contre le vecteur de
|
|
19
|
+
* test A.2 dans tests/unit/federation-hpke.test.ts. Un « ça a l'air juste » sur du code
|
|
20
|
+
* cryptographique ne vaut rien : soit le vecteur officiel passe, soit l'implémentation
|
|
21
|
+
* est fausse.
|
|
22
|
+
*/
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
// Identifiants d'algorithmes (RFC 9180 §7). Encodés en 16 bits gros-boutiste dans suite_id.
|
|
25
|
+
const KEM_ID = 0x0020; // DHKEM(X25519, HKDF-SHA256)
|
|
26
|
+
const KDF_ID = 0x0001; // HKDF-SHA256
|
|
27
|
+
const AEAD_ID = 0x0003; // ChaCha20-Poly1305
|
|
28
|
+
const NH = 32; // taille de sortie de SHA-256
|
|
29
|
+
const NK = 32; // taille de clé ChaCha20-Poly1305
|
|
30
|
+
const NN = 12; // taille de nonce ChaCha20-Poly1305
|
|
31
|
+
const MODE_BASE = 0x00;
|
|
32
|
+
export const HPKE_SUITE = 'HPKE-v1/X25519-HKDF-SHA256-CHACHA20POLY1305';
|
|
33
|
+
function concat(...parts) {
|
|
34
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
35
|
+
const out = new Uint8Array(total);
|
|
36
|
+
let off = 0;
|
|
37
|
+
for (const p of parts) {
|
|
38
|
+
out.set(p, off);
|
|
39
|
+
off += p.length;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function u16(n) {
|
|
44
|
+
return new Uint8Array([(n >> 8) & 0xff, n & 0xff]);
|
|
45
|
+
}
|
|
46
|
+
function ascii(s) {
|
|
47
|
+
return new TextEncoder().encode(s);
|
|
48
|
+
}
|
|
49
|
+
/** RFC 9180 §4 : suite_id du KEM, distinct de celui du contexte HPKE. */
|
|
50
|
+
function kemSuiteId() {
|
|
51
|
+
return concat(ascii('KEM'), u16(KEM_ID));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* RFC 9180 §5.1 : suite_id du contexte, couvrant KEM, KDF et AEAD.
|
|
55
|
+
*
|
|
56
|
+
* `aeadId` est paramétrable UNIQUEMENT pour la validation par vecteurs. La RFC ne publie
|
|
57
|
+
* en clair dans son corps que l'appendice A.1 (AES-128-GCM) ; pouvoir instancier le
|
|
58
|
+
* schedule sous cet identifiant permet de vérifier toute la machinerie
|
|
59
|
+
* labeled_extract/labeled_expand contre des valeurs officielles, plutôt que de se
|
|
60
|
+
* contenter d'un aller-retour interne qui passerait tout aussi bien avec deux erreurs
|
|
61
|
+
* symétriques. La suite de production reste figée à ChaCha20-Poly1305.
|
|
62
|
+
*/
|
|
63
|
+
function hpkeSuiteId(aeadId = AEAD_ID) {
|
|
64
|
+
return concat(ascii('HPKE'), u16(KEM_ID), u16(KDF_ID), u16(aeadId));
|
|
65
|
+
}
|
|
66
|
+
function labeledExtract(suiteId, salt, label, ikm) {
|
|
67
|
+
// labeled_ikm = "HPKE-v1" || suite_id || label || ikm (RFC 9180 §4)
|
|
68
|
+
const labeledIkm = concat(ascii('HPKE-v1'), suiteId, ascii(label), ikm);
|
|
69
|
+
return new Uint8Array(crypto.createHmac('sha256', Buffer.from(salt)).update(Buffer.from(labeledIkm)).digest());
|
|
70
|
+
}
|
|
71
|
+
function labeledExpand(suiteId, prk, label, info, length) {
|
|
72
|
+
const labeledInfo = concat(u16(length), ascii('HPKE-v1'), suiteId, ascii(label), info);
|
|
73
|
+
// HKDF-Expand (RFC 5869) : T(i) = HMAC(prk, T(i-1) || info || i)
|
|
74
|
+
const out = new Uint8Array(length);
|
|
75
|
+
let t = new Uint8Array(0);
|
|
76
|
+
let off = 0;
|
|
77
|
+
for (let i = 1; off < length; i++) {
|
|
78
|
+
t = new Uint8Array(crypto.createHmac('sha256', Buffer.from(prk))
|
|
79
|
+
.update(Buffer.from(concat(t, labeledInfo, new Uint8Array([i]))))
|
|
80
|
+
.digest());
|
|
81
|
+
const take = Math.min(t.length, length - off);
|
|
82
|
+
out.set(t.subarray(0, take), off);
|
|
83
|
+
off += take;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function extractAndExpand(dh, kemContext) {
|
|
88
|
+
const suiteId = kemSuiteId();
|
|
89
|
+
const eaePrk = labeledExtract(suiteId, new Uint8Array(0), 'eae_prk', dh);
|
|
90
|
+
return labeledExpand(suiteId, eaePrk, 'shared_secret', kemContext, NH);
|
|
91
|
+
}
|
|
92
|
+
// ── Conversions de clés X25519 ────────────────────────────────────────────────
|
|
93
|
+
/**
|
|
94
|
+
* Octets bruts (32) d'une clé publique X25519 depuis son PEM SPKI.
|
|
95
|
+
*
|
|
96
|
+
* L'en-tête SPKI d'une X25519 fait exactement 12 octets et est constant pour cet
|
|
97
|
+
* algorithme ; les 32 derniers octets sont la clé. On prend la FIN du DER plutôt qu'un
|
|
98
|
+
* décalage fixe depuis le début : le préfixe pourrait varier d'un encodeur à l'autre,
|
|
99
|
+
* la longueur de la clé, non.
|
|
100
|
+
*/
|
|
101
|
+
export function rawPublicKey(pem) {
|
|
102
|
+
const der = crypto.createPublicKey(pem).export({ type: 'spki', format: 'der' });
|
|
103
|
+
return new Uint8Array(der.subarray(der.length - 32));
|
|
104
|
+
}
|
|
105
|
+
function publicKeyFromRaw(raw) {
|
|
106
|
+
// Préfixe SPKI de X25519 : SEQUENCE { SEQUENCE { OID 1.3.101.110 }, BIT STRING }
|
|
107
|
+
const prefix = Buffer.from('302a300506032b656e032100', 'hex');
|
|
108
|
+
return crypto.createPublicKey({
|
|
109
|
+
key: Buffer.concat([prefix, Buffer.from(raw)]),
|
|
110
|
+
format: 'der',
|
|
111
|
+
type: 'spki',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// ── KEM : encapsulation / décapsulation ───────────────────────────────────────
|
|
115
|
+
function encapsulate(recipientPublicPem) {
|
|
116
|
+
const ephemeral = crypto.generateKeyPairSync('x25519');
|
|
117
|
+
const recipientKey = crypto.createPublicKey(recipientPublicPem);
|
|
118
|
+
const dh = new Uint8Array(crypto.diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipientKey }));
|
|
119
|
+
const enc = new Uint8Array(ephemeral.publicKey.export({ type: 'spki', format: 'der' }).subarray(-32));
|
|
120
|
+
const pkRm = rawPublicKey(recipientPublicPem);
|
|
121
|
+
// kem_context = enc || pkRm — l'ordre est normatif ; l'inverser produit un secret
|
|
122
|
+
// différent des deux côtés et un échec de déchiffrement sans explication.
|
|
123
|
+
return { sharedSecret: extractAndExpand(dh, concat(enc, pkRm)), enc };
|
|
124
|
+
}
|
|
125
|
+
function decapsulate(enc, recipientPrivateKey) {
|
|
126
|
+
const ephemeralPublic = publicKeyFromRaw(enc);
|
|
127
|
+
const dh = new Uint8Array(crypto.diffieHellman({ privateKey: recipientPrivateKey, publicKey: ephemeralPublic }));
|
|
128
|
+
const pkRm = new Uint8Array(crypto.createPublicKey(recipientPrivateKey).export({ type: 'spki', format: 'der' }).subarray(-32));
|
|
129
|
+
return extractAndExpand(dh, concat(enc, pkRm));
|
|
130
|
+
}
|
|
131
|
+
// ── Contexte HPKE ─────────────────────────────────────────────────────────────
|
|
132
|
+
function keySchedule(sharedSecret, info, suite = { aeadId: AEAD_ID, nk: NK, nn: NN }) {
|
|
133
|
+
const suiteId = hpkeSuiteId(suite.aeadId);
|
|
134
|
+
// En mode base, psk et psk_id sont vides — mais leurs hachages entrent QUAND MÊME dans
|
|
135
|
+
// le contexte. Les omettre donnerait un contexte différent de toute autre
|
|
136
|
+
// implémentation conforme.
|
|
137
|
+
const pskIdHash = labeledExtract(suiteId, new Uint8Array(0), 'psk_id_hash', new Uint8Array(0));
|
|
138
|
+
const infoHash = labeledExtract(suiteId, new Uint8Array(0), 'info_hash', info);
|
|
139
|
+
const keyScheduleContext = concat(new Uint8Array([MODE_BASE]), pskIdHash, infoHash);
|
|
140
|
+
const secret = labeledExtract(suiteId, sharedSecret, 'secret', new Uint8Array(0));
|
|
141
|
+
return {
|
|
142
|
+
key: labeledExpand(suiteId, secret, 'key', keyScheduleContext, suite.nk),
|
|
143
|
+
baseNonce: labeledExpand(suiteId, secret, 'base_nonce', keyScheduleContext, suite.nn),
|
|
144
|
+
keyScheduleContext,
|
|
145
|
+
secret,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Scelle un texte clair pour un destinataire, lié à un AAD.
|
|
150
|
+
*
|
|
151
|
+
* L'AAD est passé en OCTETS DÉJÀ CANONIQUES, jamais en objet : la canonicalisation
|
|
152
|
+
* appartient à l'appelant, qui doit produire exactement les mêmes octets à la
|
|
153
|
+
* vérification. L'accepter en objet ici inviterait deux canonicalisations différentes.
|
|
154
|
+
*
|
|
155
|
+
* `seq` vaut 0 : un contexte n'est utilisé que pour UN chiffrement. Le RFC interdit toute
|
|
156
|
+
* répétition du couple de contexte de nonce, et la garantie la plus simple est qu'un
|
|
157
|
+
* contexte ne serve jamais deux fois — chaque appel génère une clé éphémère neuve.
|
|
158
|
+
*/
|
|
159
|
+
export function seal(params) {
|
|
160
|
+
const { sharedSecret, enc } = encapsulate(params.recipientPublicKeyPem);
|
|
161
|
+
const { key, baseNonce } = keySchedule(sharedSecret, params.info ?? new Uint8Array(0));
|
|
162
|
+
const cipher = crypto.createCipheriv('chacha20-poly1305', Buffer.from(key), Buffer.from(baseNonce), {
|
|
163
|
+
authTagLength: 16,
|
|
164
|
+
});
|
|
165
|
+
cipher.setAAD(Buffer.from(params.aadCanonicalBytes), { plaintextLength: params.plaintext.length });
|
|
166
|
+
const body = Buffer.concat([cipher.update(Buffer.from(params.plaintext)), cipher.final()]);
|
|
167
|
+
const tag = cipher.getAuthTag();
|
|
168
|
+
return {
|
|
169
|
+
alg: HPKE_SUITE,
|
|
170
|
+
enc: Buffer.from(enc).toString('base64url'),
|
|
171
|
+
nonce: Buffer.from(baseNonce).toString('base64url'),
|
|
172
|
+
ciphertext: Buffer.concat([body, tag]).toString('base64url'),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Ouvre un blob scellé. Échoue FERMÉ au moindre octet d'AAD différent.
|
|
177
|
+
*
|
|
178
|
+
* Toute erreur est convertie en `undefined` plutôt qu'en exception détaillée : distinguer
|
|
179
|
+
* « mauvaise clé » de « AAD divergent » de « tag invalide » donnerait à un attaquant un
|
|
180
|
+
* oracle sur la cause de l'échec. L'appelant apprend seulement que ça n'ouvre pas.
|
|
181
|
+
*/
|
|
182
|
+
export function open(params) {
|
|
183
|
+
try {
|
|
184
|
+
if (params.sealed.alg !== HPKE_SUITE)
|
|
185
|
+
return undefined;
|
|
186
|
+
const enc = new Uint8Array(Buffer.from(params.sealed.enc, 'base64url'));
|
|
187
|
+
const sharedSecret = decapsulate(enc, params.recipientPrivateKey);
|
|
188
|
+
const { key, baseNonce } = keySchedule(sharedSecret, params.info ?? new Uint8Array(0));
|
|
189
|
+
// Le nonce annoncé DOIT être celui dérivé du schedule. Accepter un nonce arbitraire
|
|
190
|
+
// laisserait un émetteur en réutiliser un — la faute la plus grave possible sur un
|
|
191
|
+
// AEAD à nonce, qui trahit le clair de deux messages d'un simple XOR.
|
|
192
|
+
const announced = Buffer.from(params.sealed.nonce, 'base64url');
|
|
193
|
+
if (!announced.equals(Buffer.from(baseNonce)))
|
|
194
|
+
return undefined;
|
|
195
|
+
const raw = Buffer.from(params.sealed.ciphertext, 'base64url');
|
|
196
|
+
if (raw.length < 16)
|
|
197
|
+
return undefined;
|
|
198
|
+
const body = raw.subarray(0, raw.length - 16);
|
|
199
|
+
const tag = raw.subarray(raw.length - 16);
|
|
200
|
+
const decipher = crypto.createDecipheriv('chacha20-poly1305', Buffer.from(key), Buffer.from(baseNonce), {
|
|
201
|
+
authTagLength: 16,
|
|
202
|
+
});
|
|
203
|
+
decipher.setAAD(Buffer.from(params.aadCanonicalBytes), { plaintextLength: body.length });
|
|
204
|
+
decipher.setAuthTag(tag);
|
|
205
|
+
return new Uint8Array(Buffer.concat([decipher.update(body), decipher.final()]));
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Exposé pour les vecteurs de test RFC 9180 ; hors de ce cadre, utiliser `seal`/`open`. */
|
|
212
|
+
export const __testing = { labeledExtract, labeledExpand, keySchedule, kemSuiteId, hpkeSuiteId, extractAndExpand };
|
|
213
|
+
//# sourceMappingURL=federation-hpke.js.map
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vérification à la réception — fédération v2 (pln#651 étape 6, RFC §6).
|
|
3
|
+
*
|
|
4
|
+
* ── LE TROU QUE CE MODULE FERME ───────────────────────────────────────────────
|
|
5
|
+
* Avant lui, `pullSignalsFromCloud` faisait un simple `as { messages: … }` sans AUCUNE
|
|
6
|
+
* vérification, puis `materializeFederationSignal` écrivait dans la mémoire locale via
|
|
7
|
+
* saveCandidate et saveRuntimeNote. La signature Ed25519 existante était TRANSPORT
|
|
8
|
+
* uniquement, edge→cloud, dans des en-têtes : elle n'était pas persistée dans l'enveloppe
|
|
9
|
+
* et ne disait rien de l'origine du contenu.
|
|
10
|
+
*
|
|
11
|
+
* Conséquence : un cloud malveillant ne lisait pas la roadmap — elle est chiffrée — mais
|
|
12
|
+
* pouvait INJECTER des candidates et des runtime_notes dans la mémoire de chaque agent
|
|
13
|
+
* enrôlé. Soit un canal d'injection de prompt vers toute la flotte. Trois critiques sur
|
|
14
|
+
* quatre l'ont classé bloquant, par trois angles distincts.
|
|
15
|
+
*
|
|
16
|
+
* Chiffrer les LECTURES en laissant les ÉCRITURES forgeables par l'opérateur qu'on
|
|
17
|
+
* prétend neutraliser est le défaut structurel fermé ici.
|
|
18
|
+
*
|
|
19
|
+
* ── CE QUE LE CLOUD PEUT ENCORE FAIRE, ET QU'IL FAUT DIRE ─────────────────────
|
|
20
|
+
* Il peut RETARDER ou OMETTRE une enveloppe. Aucune cryptographie ne l'en empêche : un
|
|
21
|
+
* relais qui ne relaie pas est indétectable de l'intérieur. Ce qu'il ne peut plus faire,
|
|
22
|
+
* c'est injecter, rejouer une ancienne révision comme courante, ou réordonner des
|
|
23
|
+
* métadonnées sans être vu.
|
|
24
|
+
*/
|
|
25
|
+
import crypto from 'node:crypto';
|
|
26
|
+
import { canonicalJson } from './federation-canonical.js';
|
|
27
|
+
import { open as hpkeOpen } from './federation-hpke.js';
|
|
28
|
+
import { FederationEnvelopeSchema, originSigningInput, } from './federation-projection.js';
|
|
29
|
+
import { acceptsRevision, recordRevision } from './federation-state.js';
|
|
30
|
+
function reject(reason, detail) {
|
|
31
|
+
return { ok: false, reason, detail };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Vérifie la signature d'origine sur les octets canoniques complets.
|
|
35
|
+
*
|
|
36
|
+
* Toute exception — PEM illisible, signature mal encodée, mauvaise longueur — rend
|
|
37
|
+
* `false` sans distinction. Laisser remonter l'erreur, ou différencier les causes,
|
|
38
|
+
* donnerait un oracle sur la RAISON du refus ; du point de vue de l'appelant il n'y a
|
|
39
|
+
* qu'un seul fait utile : ces octets ne sont pas signés par cette clé.
|
|
40
|
+
*/
|
|
41
|
+
function verifyOriginSignature(env, signerPem) {
|
|
42
|
+
try {
|
|
43
|
+
return crypto.verify(null, originSigningInput(env.meta, env.sealed, env.key_epoch), crypto.createPublicKey(signerPem), Buffer.from(env.origin_sig.value, 'base64url'));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Vérifie une enveloppe entrante. AUCUNE écriture locale n'a lieu ici — la fonction est
|
|
51
|
+
* pure vis-à-vis du disque et rend un verdict.
|
|
52
|
+
*
|
|
53
|
+
* L'ORDRE DES CONTRÔLES EST UN CONTRAT (RFC §6) : parse strict → résolution du signataire
|
|
54
|
+
* → signature → AAD → révocation → déchiffrement → cohérence du clair → anti-rejeu →
|
|
55
|
+
* dédoublonnage. Déchiffrer AVANT d'avoir vérifié la signature exposerait le déchiffreur
|
|
56
|
+
* à un ciphertext choisi par l'attaquant ; vérifier l'anti-rejeu avant la signature
|
|
57
|
+
* laisserait un cloud faire avancer la barrière avec des révisions forgées, condamnant
|
|
58
|
+
* les révisions légitimes qui suivent.
|
|
59
|
+
*/
|
|
60
|
+
export function verifyInbound(params) {
|
|
61
|
+
// (1) Parse STRICT. Une clé inconnue est refusée, pas retirée en silence : c'est le
|
|
62
|
+
// pendant entrant du filet 2 du projecteur.
|
|
63
|
+
const parsed = FederationEnvelopeSchema.safeParse(params.raw);
|
|
64
|
+
if (!parsed.success) {
|
|
65
|
+
return reject('schema_invalid', parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '));
|
|
66
|
+
}
|
|
67
|
+
const env = parsed.data;
|
|
68
|
+
// (2) Résoudre le signataire DANS LE ROSTER ATTESTÉ.
|
|
69
|
+
const signerPem = params.roster.keys.get(env.origin_sig.key_id);
|
|
70
|
+
if (!signerPem) {
|
|
71
|
+
return reject('unknown_signer', `key_id '${env.origin_sig.key_id}' absent du roster attesté.`);
|
|
72
|
+
}
|
|
73
|
+
// Révoqué et inconnu sont distingués : un opérateur doit pouvoir voir qu'un membre
|
|
74
|
+
// révoqué continue d'émettre, ce qu'un « inconnu » générique masquerait.
|
|
75
|
+
if (params.roster.revoked?.has(env.origin_sig.key_id)) {
|
|
76
|
+
return reject('revoked_signer', `key_id '${env.origin_sig.key_id}' révoqué.`);
|
|
77
|
+
}
|
|
78
|
+
// (3) Signature sur les octets canoniques COMPLETS : meta, sealed (alg, enc, nonce,
|
|
79
|
+
// ciphertext) et key_epoch. Couvrir meta est ce qui rend le réordonnancement de
|
|
80
|
+
// priorité, de dépendances ou de statut détectable — le Cloud peut lire ces champs,
|
|
81
|
+
// il ne peut pas les changer.
|
|
82
|
+
if (!verifyOriginSignature(env, signerPem)) {
|
|
83
|
+
return reject('bad_signature', "la signature d'origine ne couvre pas ces octets.");
|
|
84
|
+
}
|
|
85
|
+
// (4) L'AAD doit DÉCRIRE l'objet annoncé. Sans ce contrôle, une enveloppe légitime pour
|
|
86
|
+
// l'objet A, correctement signée, pourrait être présentée comme concernant l'objet B :
|
|
87
|
+
// la signature resterait valide puisqu'elle couvre meta, mais meta lui-même mentirait
|
|
88
|
+
// sur sa cohérence interne.
|
|
89
|
+
const aad = env.meta.aad;
|
|
90
|
+
if (aad.object_id !== env.meta.id_opaque
|
|
91
|
+
|| aad.base_rev !== env.meta.base_rev
|
|
92
|
+
|| aad.object_type !== env.meta.kind
|
|
93
|
+
|| aad.cloud_project_id !== params.state.cloud_project_id) {
|
|
94
|
+
return reject('aad_mismatch', "l'AAD ne décrit pas l'objet annoncé par meta.");
|
|
95
|
+
}
|
|
96
|
+
// (5) Déchiffrement. Échoue FERMÉ : `hpkeOpen` ne distingue pas les causes, pour ne pas
|
|
97
|
+
// offrir d'oracle sur la raison de l'échec.
|
|
98
|
+
if (!params.epochPrivateKey) {
|
|
99
|
+
return reject('undecryptable', `aucune clé détenue pour l'epoch ${env.key_epoch}.`);
|
|
100
|
+
}
|
|
101
|
+
const plaintext = hpkeOpen({
|
|
102
|
+
recipientPrivateKey: params.epochPrivateKey,
|
|
103
|
+
sealed: env.sealed,
|
|
104
|
+
aadCanonicalBytes: new TextEncoder().encode(canonicalJson(aad)),
|
|
105
|
+
});
|
|
106
|
+
if (!plaintext) {
|
|
107
|
+
return reject('undecryptable', 'AEAD refusé (clé, AAD ou tag).');
|
|
108
|
+
}
|
|
109
|
+
let content;
|
|
110
|
+
try {
|
|
111
|
+
content = JSON.parse(new TextDecoder().decode(plaintext));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return reject('payload_type_mismatch', 'le clair déchiffré n\'est pas du JSON.');
|
|
115
|
+
}
|
|
116
|
+
if (content === null || typeof content !== 'object') {
|
|
117
|
+
return reject('payload_type_mismatch', 'le clair déchiffré n\'est pas un objet.');
|
|
118
|
+
}
|
|
119
|
+
// (6) ANTI-REJEU. L'AEAD détecte l'ALTÉRATION, pas le REJEU d'un ciphertext valide et
|
|
120
|
+
// ancien. Sans high-water mark, un cloud peut resservir un état antérieur comme s'il
|
|
121
|
+
// était courant, et rien dans la cryptographie ne s'y oppose.
|
|
122
|
+
//
|
|
123
|
+
// Le dédoublonnage est vérifié AVANT le rejeu : une enveloppe DÉJÀ matérialisée a
|
|
124
|
+
// légitimement une révision égale au high-water mark. La confondre avec un rejeu
|
|
125
|
+
// rendrait tout retour d'un retry indistinguable d'une attaque.
|
|
126
|
+
const idempotencyKey = env.meta.transport.idempotency_key;
|
|
127
|
+
if (params.seenIdempotencyKeys?.has(idempotencyKey)) {
|
|
128
|
+
return reject('duplicate', `opération déjà matérialisée (${idempotencyKey}).`);
|
|
129
|
+
}
|
|
130
|
+
if (!acceptsRevision(params.state, env.meta.id_opaque, env.meta.base_rev)) {
|
|
131
|
+
const seen = params.state.sync.high_water[env.meta.id_opaque];
|
|
132
|
+
return reject('replay_or_rollback', `révision ${env.meta.base_rev} refusée pour ${env.meta.id_opaque} : high-water mark à ${seen}.`);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
envelope: env,
|
|
137
|
+
content,
|
|
138
|
+
kind: env.meta.kind,
|
|
139
|
+
idempotencyKey,
|
|
140
|
+
nextState: recordRevision(params.state, env.meta.id_opaque, env.meta.base_rev),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Vérifie un lot d'enveloppes et rend le verdict de chacune.
|
|
145
|
+
*
|
|
146
|
+
* L'ÉTAT AVANCE AU FIL DU LOT, et l'ordre compte : deux révisions du même objet dans un
|
|
147
|
+
* même lot doivent être appliquées dans l'ordre croissant, sinon la seconde est refusée
|
|
148
|
+
* comme rollback — ce qui est le comportement voulu. Un lot est traité comme une suite
|
|
149
|
+
* d'opérations, jamais comme un ensemble à réordonner selon ce que le Cloud propose.
|
|
150
|
+
*
|
|
151
|
+
* Le dédoublonnage accumule les clés vues DANS le lot en plus de celles déjà connues :
|
|
152
|
+
* un cloud qui livrerait deux fois la même opération dans un seul lot serait sinon
|
|
153
|
+
* accepté deux fois.
|
|
154
|
+
*/
|
|
155
|
+
export function verifyInboundBatch(params) {
|
|
156
|
+
const seen = new Set(params.seenIdempotencyKeys ?? []);
|
|
157
|
+
let state = params.state;
|
|
158
|
+
const results = [];
|
|
159
|
+
let accepted = 0;
|
|
160
|
+
for (const raw of params.envelopes) {
|
|
161
|
+
// L'epoch est lu sur l'enveloppe AVANT vérification, uniquement pour choisir une clé.
|
|
162
|
+
// Ce n'est pas une décision de confiance : une valeur mensongère mène simplement à
|
|
163
|
+
// une clé qui n'ouvrira pas, donc à un refus.
|
|
164
|
+
const epoch = typeof raw?.key_epoch === 'number'
|
|
165
|
+
? raw.key_epoch
|
|
166
|
+
: undefined;
|
|
167
|
+
const result = verifyInbound({
|
|
168
|
+
raw,
|
|
169
|
+
roster: params.roster,
|
|
170
|
+
state,
|
|
171
|
+
epochPrivateKey: epoch === undefined ? undefined : params.epochKeys.get(epoch),
|
|
172
|
+
seenIdempotencyKeys: seen,
|
|
173
|
+
});
|
|
174
|
+
results.push(result);
|
|
175
|
+
if (result.ok) {
|
|
176
|
+
// L'état n'avance QUE sur acceptation. Une enveloppe refusée ne doit laisser aucune
|
|
177
|
+
// trace : ni barrière avancée, ni clé d'idempotence enregistrée. Sinon un cloud
|
|
178
|
+
// hostile empoisonnerait l'état avec des enveloppes invalides et condamnerait les
|
|
179
|
+
// révisions légitimes qui suivent.
|
|
180
|
+
state = result.nextState;
|
|
181
|
+
seen.add(result.idempotencyKey);
|
|
182
|
+
accepted++;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { results, nextState: state, accepted, rejected: results.length - accepted };
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=federation-inbound.js.map
|