brainclaw 1.21.0 → 1.23.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-capture.js +15 -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/loops-handlers.js +0 -1
- package/dist/commands/mcp-catalog.js +24 -256
- package/dist/commands/mcp-read-handlers.js +5 -1
- package/dist/commands/mcp-schemas.generated.js +811 -1
- package/dist/commands/mcp-write-coordination.js +16 -7
- package/dist/commands/mcp.js +45 -1
- package/dist/commands/memory-confirm.js +83 -0
- package/dist/commands/session-end.js +0 -102
- package/dist/commands/session-start.js +0 -23
- package/dist/commands/switch.js +24 -2
- package/dist/core/assignment-request-schema.js +112 -0
- package/dist/core/capture-schema.js +62 -0
- package/dist/core/claim-request-schema.js +72 -0
- package/dist/core/claims.js +0 -18
- package/dist/core/code-map/aggregate.js +36 -1
- 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/core/sequence-request-schema.js +93 -0
- package/dist/core/session-request-schema.js +90 -0
- package/dist/core/step-request-schema.js +112 -0
- package/dist/core/store-resolution.js +34 -5
- package/dist/core/warnings.js +37 -0
- 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/docs/integrations/mcp.md +1 -1
- 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,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schémas zod des entrées de la famille CLAIM — `bclaw_claim` et `bclaw_release_claim`
|
|
3
|
+
* (pln#599 batch 2, deuxième famille composite).
|
|
4
|
+
*
|
|
5
|
+
* ── CONTRAINTE, INCHANGÉE DEPUIS LA FAMILLE CAPTURE ───────────────────────────
|
|
6
|
+
* Le JSON Schema produit doit être byte-identique à celui écrit à la main : le fingerprint
|
|
7
|
+
* de gouvernance (tests/unit/mcp-governance.test.ts) ne doit pas bouger. Une migration qui
|
|
8
|
+
* déplace le fingerprint n'est plus une migration, c'est un changement de surface publique.
|
|
9
|
+
*
|
|
10
|
+
* ── LES DEUX PIÈGES PAYÉS COMPTANT SUR #220/#221, REPRODUITS ICI EN GARDE ──────
|
|
11
|
+
* 1. zod émet `additionalProperties: false` d'office. Le laisser DURCIT le contrat : un
|
|
12
|
+
* client passant une clé inconnue était ACCEPTÉ (clé ignorée) et se ferait désormais
|
|
13
|
+
* rejeter. Le générateur le retire — À LA RACINE UNIQUEMENT, cf. OPEN_SCHEMAS.
|
|
14
|
+
* 2. Un champ REQUIS dans la version manuelle doit le rester. `rank` était passé optionnel
|
|
15
|
+
* par inadvertance sur la famille séquence : assouplissement du contrat, attrapé par le
|
|
16
|
+
* seul fingerprint. Ici les requis sont `scope`+`description` (claim) et `id` (release).
|
|
17
|
+
*
|
|
18
|
+
* Ni l'un ni l'autre n'avait été vu par mes propres vérifications, ni par le snapshot du
|
|
19
|
+
* registre CLI — qui mesure quelque chose de plus faible et donne une fausse assurance.
|
|
20
|
+
*
|
|
21
|
+
* ── CE QUI N'EST PAS RESSERRÉ, DÉLIBÉRÉMENT ───────────────────────────────────
|
|
22
|
+
* `store` et `planStatus` restent des chaînes libres et non des enums, bien que leurs
|
|
23
|
+
* valeurs utiles soient énumérées dans leur description. Les resserrer mérite sa propre
|
|
24
|
+
* décision : ce serait un rejet nouveau sur des appels aujourd'hui acceptés.
|
|
25
|
+
* `handoffMode` garde en revanche son enum, parce qu'il en avait DÉJÀ un.
|
|
26
|
+
*/
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
/** Identité de l'appelant — commune à la famille, comme pour capture et séquence. */
|
|
29
|
+
const CallerIdentity = {
|
|
30
|
+
agent: z.string().describe('Agent or person name.').optional(),
|
|
31
|
+
agentId: z.string().describe('Registered agent id.').optional(),
|
|
32
|
+
};
|
|
33
|
+
export const ClaimRequestSchema = z.object({
|
|
34
|
+
scope: z.string().describe('Scope being claimed.'),
|
|
35
|
+
description: z.string().describe('Description of the work.'),
|
|
36
|
+
...CallerIdentity,
|
|
37
|
+
planId: z.string().describe('Optional linked plan item ID.').optional(),
|
|
38
|
+
project: z
|
|
39
|
+
.string()
|
|
40
|
+
.describe('Project name or path. Use this when working on a project different from the MCP server workspace (e.g. CLI agents in a different directory).')
|
|
41
|
+
.optional(),
|
|
42
|
+
// Chaîne libre, PAS un enum : les trois niveaux sont documentés mais la valeur reste
|
|
43
|
+
// ouverte côté schéma publié.
|
|
44
|
+
store: z.string().describe('Target store level: local (default), repo, workspace.').optional(),
|
|
45
|
+
worktreeBranch: z
|
|
46
|
+
.string()
|
|
47
|
+
.describe('Branch name for the worktree. Defaults to feat/<scope-slug>.')
|
|
48
|
+
.optional(),
|
|
49
|
+
worktree: z
|
|
50
|
+
.boolean()
|
|
51
|
+
.describe('Whether to create an isolated git worktree (default true). Pass false for an advisory-only lock with no worktree (trp#431) — for in-place work in the main tree.')
|
|
52
|
+
.optional(),
|
|
53
|
+
advisory: z
|
|
54
|
+
.boolean()
|
|
55
|
+
.describe('Alias for worktree:false — advisory-only lock with no worktree (trp#431).')
|
|
56
|
+
.optional(),
|
|
57
|
+
// Enum CONSERVÉ : il existait déjà dans le schéma manuel. Le retirer serait
|
|
58
|
+
// l'assouplissement symétrique du durcissement qu'on évite ailleurs.
|
|
59
|
+
handoffMode: z
|
|
60
|
+
.enum(['self-commit', 'integrator'])
|
|
61
|
+
.describe('Handoff mode: "self-commit" (worker commits+merges) or "integrator" (another agent reviews+merges). Default: self-commit.')
|
|
62
|
+
.optional(),
|
|
63
|
+
});
|
|
64
|
+
export const ReleaseClaimRequestSchema = z.object({
|
|
65
|
+
id: z.string().describe('Claim ID to release.'),
|
|
66
|
+
planStatus: z.string().describe('Optional: update linked plan status.').optional(),
|
|
67
|
+
coordinator_override: z
|
|
68
|
+
.boolean()
|
|
69
|
+
.describe('Opt-in override for a trusted+ caller releasing a claim they do NOT own (cross-agent teardown, ghost-claim cleanup). Rejected for contributor-level callers; audited when used. trp#928.')
|
|
70
|
+
.optional(),
|
|
71
|
+
});
|
|
72
|
+
//# sourceMappingURL=claim-request-schema.js.map
|
package/dist/core/claims.js
CHANGED
|
@@ -16,7 +16,6 @@ import { loadState, persistState } from './state.js';
|
|
|
16
16
|
import { createRuntimeEvent } from './events.js';
|
|
17
17
|
import { latestActivityMs, readHeartbeat } from './runtime-signals.js';
|
|
18
18
|
import { emitRegistryPostImage, registryFaultPoint } from './events/registry-post-image.js';
|
|
19
|
-
import { maybeEnqueueClaimTransition, isFederationEnqueueActive } from './federation-outbox.js';
|
|
20
19
|
/** Parse duration string like '4h', '30m' to ms. */
|
|
21
20
|
function parseTtl(value) {
|
|
22
21
|
const match = /^(\d+)([mhd])$/i.exec(value.trim());
|
|
@@ -79,26 +78,9 @@ function saveClaimUnlocked(claim, cwd, options) {
|
|
|
79
78
|
// pln#568 (I2): journal the post-image BEFORE the projection write, so a
|
|
80
79
|
// crash can only leave the journal ahead of the projection, never behind.
|
|
81
80
|
const created = !store.exists(parsed.id);
|
|
82
|
-
// Federation (pln#101): capture the PREVIOUS status BEFORE the write so we can
|
|
83
|
-
// diff it after (create or active↔terminal transition ⇒ enqueue for cloud
|
|
84
|
-
// sync). Only pay the prev-load when federation is actually active; this whole
|
|
85
|
-
// block runs under the store mutation mutex, which serializes rev reservation.
|
|
86
|
-
const fedActive = isFederationEnqueueActive(cwd, options?.federation?.suppressEnqueue);
|
|
87
|
-
let fedPrevStatus;
|
|
88
|
-
if (fedActive) {
|
|
89
|
-
try {
|
|
90
|
-
fedPrevStatus = loadClaimFromAnyDir(parsed.id, cwd).status;
|
|
91
|
-
}
|
|
92
|
-
catch {
|
|
93
|
-
fedPrevStatus = undefined;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
81
|
emitRegistryPostImage('claim', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
|
|
97
82
|
registryFaultPoint('after_registry_journal');
|
|
98
83
|
store.save(parsed);
|
|
99
|
-
if (fedActive) {
|
|
100
|
-
maybeEnqueueClaimTransition(parsed, fedPrevStatus, fedPrevStatus === undefined, cwd, options?.federation?.suppressEnqueue);
|
|
101
|
-
}
|
|
102
84
|
const writeDir = claimsDir(cwd, 'write');
|
|
103
85
|
for (const dirPath of claimDirs(cwd)) {
|
|
104
86
|
if (dirPath === writeDir)
|
|
@@ -468,6 +468,41 @@ export function aggregateBrief(target, limit, resolved, currentHead, memoryReade
|
|
|
468
468
|
...(capped[i].cross_package ? { cross_package: true } : {}),
|
|
469
469
|
...(capped[i].local ? { local: true } : {}),
|
|
470
470
|
}));
|
|
471
|
-
return {
|
|
471
|
+
return {
|
|
472
|
+
target,
|
|
473
|
+
suggested_files_to_read: suggested,
|
|
474
|
+
related_memory: summarizeRelatedMemory(related),
|
|
475
|
+
freshness_badge: mergeBadges(perStore.map((p) => ({ ref: p.ref, badge: p.badge, hasIndex: p.r.hasIndex }))),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Resume la memoire liee servie par `code_brief` (pln#598 etape 3).
|
|
480
|
+
*
|
|
481
|
+
* POURQUOI. Un trap ou une decision de ce depot depasse regulierement 2 000 caracteres —
|
|
482
|
+
* plusieurs des textes ecrits pendant la refonte federation v2 en font le double. Un
|
|
483
|
+
* `code_brief` qui attache trois d'entre eux sert des milliers de caracteres avant meme
|
|
484
|
+
* que l'agent n'ait ouvert un fichier, pour un contenu qu'il ne lira peut-etre pas.
|
|
485
|
+
*
|
|
486
|
+
* LE TEXTE N'EST PAS PERDU, IL EST DIFFERE. Chaque entree raccourcie porte l'appel EXACT
|
|
487
|
+
* qui rend l'integralite. Un allegement qui supprime l'information au lieu de la deplacer
|
|
488
|
+
* force l'agent a deviner — et deviner sur un trap est precisement ce que les traps
|
|
489
|
+
* existent pour eviter.
|
|
490
|
+
*
|
|
491
|
+
* `id`, `kind`, `tags` et `related_paths` restent ENTIERS : ce sont eux qui permettent de
|
|
492
|
+
* decider s'il vaut la peine d'aller lire. Les tronquer ferait economiser des octets sur
|
|
493
|
+
* la seule partie qui sert a trier.
|
|
494
|
+
*/
|
|
495
|
+
const RELATED_MEMORY_TEXT_LIMIT = 300;
|
|
496
|
+
export function summarizeRelatedMemory(items) {
|
|
497
|
+
return items.map((item) => {
|
|
498
|
+
if (typeof item.text !== 'string' || item.text.length <= RELATED_MEMORY_TEXT_LIMIT)
|
|
499
|
+
return item;
|
|
500
|
+
return {
|
|
501
|
+
...item,
|
|
502
|
+
text: `${item.text.slice(0, RELATED_MEMORY_TEXT_LIMIT)}…`,
|
|
503
|
+
text_truncated: true,
|
|
504
|
+
full_text_via: { tool: 'bclaw_get', args: { entity: item.kind, id: item.id } },
|
|
505
|
+
};
|
|
506
|
+
});
|
|
472
507
|
}
|
|
473
508
|
//# sourceMappingURL=aggregate.js.map
|
|
@@ -300,9 +300,7 @@ function buildIncomingSignalsSummary(cwd) {
|
|
|
300
300
|
try {
|
|
301
301
|
const fedSignals = pullSignalsFromLinkedProjects(cwd);
|
|
302
302
|
for (const sig of fedSignals) {
|
|
303
|
-
const payloadPreview =
|
|
304
|
-
? sig.payload.slice(0, 120)
|
|
305
|
-
: JSON.stringify(sig.payload).slice(0, 120);
|
|
303
|
+
const payloadPreview = JSON.stringify(sig.payload).slice(0, 120);
|
|
306
304
|
incomingSignals.push({
|
|
307
305
|
id: sig.id,
|
|
308
306
|
entity_type: sig.type,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrat d'attestation de clé, côté CLIENT (pln#651 étape 4, RFC §5.2).
|
|
3
|
+
*
|
|
4
|
+
* ── CE FICHIER A UN JUMEAU, ET C'EST DÉLIBÉRÉ ─────────────────────────────────
|
|
5
|
+
* Le même contrat existe côté Cloud dans `brainclaw-cloud/src/lib/attestation.ts`.
|
|
6
|
+
* Les deux DOIVENT produire octet pour octet la même chaîne : le CLI signe, le Worker
|
|
7
|
+
* vérifie. Ils ne peuvent pas partager un module — deux dépôts, deux runtimes (Node ici,
|
|
8
|
+
* Workers là-bas) — donc la duplication est assumée et le gel de la forme est verrouillé
|
|
9
|
+
* DES DEUX CÔTÉS par un test sur le littéral exact.
|
|
10
|
+
*
|
|
11
|
+
* Ce n'est pas une précaution théorique. La première livraison côté Cloud reconstruisait
|
|
12
|
+
* `created_at` avec l'horloge du serveur au moment de l'approbation : la signature portait
|
|
13
|
+
* sur d'autres octets que ceux vérifiés, et AUCUN appairage ne pouvait aboutir. Le défaut
|
|
14
|
+
* a survécu à un typecheck vert parce que rien n'exerçait les deux côtés ensemble.
|
|
15
|
+
*
|
|
16
|
+
* RÈGLE QUI EN DÉCOULE, valable au-delà de ce fichier : tout champ couvert par une
|
|
17
|
+
* signature doit venir du signataire, ou d'une valeur qu'il connaît déjà. Un champ que le
|
|
18
|
+
* vérificateur fabrique lui-même ne peut pas être signé.
|
|
19
|
+
*/
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
/**
|
|
22
|
+
* Payload canonique de l'attestation.
|
|
23
|
+
*
|
|
24
|
+
* LA FORME EST GELÉE. Changer l'ordre des clés ou ajouter un champ invalide toutes les
|
|
25
|
+
* attestations déjà émises — ce qui est voulu, mais doit être un acte délibéré, jamais
|
|
26
|
+
* l'effet de bord d'une refactorisation. Le test de gel existe pour cela.
|
|
27
|
+
*/
|
|
28
|
+
export function attestationPayload(input) {
|
|
29
|
+
return JSON.stringify({
|
|
30
|
+
v: 1,
|
|
31
|
+
kind: 'brainclaw.federation.v2.key_attestation',
|
|
32
|
+
enrollment_id: input.enrollment_id,
|
|
33
|
+
project_id: input.project_id,
|
|
34
|
+
agent_id: input.agent_id,
|
|
35
|
+
key_type: input.key_type,
|
|
36
|
+
key_purpose: input.key_purpose,
|
|
37
|
+
key_fingerprint: input.key_fingerprint,
|
|
38
|
+
key_epoch: input.key_epoch,
|
|
39
|
+
created_at: input.created_at,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Empreinte canonique d'un PEM — MÊME RÈGLE que `fingerprintPublicKeyPem` d'agent-registry
|
|
44
|
+
* et que `fingerprintPem` côté Cloud.
|
|
45
|
+
*
|
|
46
|
+
* Le retrait des CR et le trim ne sont pas cosmétiques : le même PEM traversant un champ
|
|
47
|
+
* JSON ou un éditeur Windows ressort avec un CRLF ou un saut de ligne final. Sans
|
|
48
|
+
* canonicalisation, deux représentations de LA MÊME clé donnent deux empreintes
|
|
49
|
+
* différentes — et la comparaison locale↔distante, qui EST la preuve d'identité de la
|
|
50
|
+
* clé, échouerait sur une différence invisible à l'œil.
|
|
51
|
+
*/
|
|
52
|
+
export function fingerprintPem(pem) {
|
|
53
|
+
return crypto.createHash('sha256').update(pem.replace(/\r/g, '').trim()).digest('hex');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Signe des octets avec une clé privée Ed25519 au format PEM, en base64.
|
|
57
|
+
*
|
|
58
|
+
* Ed25519 ne prend pas d'algorithme de hachage séparé — d'où le `null` en premier
|
|
59
|
+
* argument, qui n'est pas un oubli : passer un digest ici lèverait.
|
|
60
|
+
*/
|
|
61
|
+
export function signEd25519(privateKeyPem, message) {
|
|
62
|
+
const key = crypto.createPrivateKey(privateKeyPem);
|
|
63
|
+
return crypto.sign(null, Buffer.from(message), key).toString('base64');
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Construit et signe l'attestation liant la clé de chiffrement X25519 de l'appareil à son
|
|
67
|
+
* identité Ed25519.
|
|
68
|
+
*
|
|
69
|
+
* C'EST LA PIÈCE QUI EMPÊCHE LE MEMBRE FANTÔME. Sans elle, le Cloud — qui orchestre
|
|
70
|
+
* l'appairage — pourrait insérer sa propre clé dans la liste d'enveloppement : un
|
|
71
|
+
* chiffrement de bout en bout dont l'échange de clés serait arbitré par la partie même
|
|
72
|
+
* qu'il prétend neutraliser.
|
|
73
|
+
*
|
|
74
|
+
* Retourne aussi l'horodatage, que l'appelant DOIT transmettre au serveur : sans lui, le
|
|
75
|
+
* vérificateur ne peut pas reconstruire les octets signés.
|
|
76
|
+
*/
|
|
77
|
+
export function buildKeyAttestation(params) {
|
|
78
|
+
const keyFingerprint = fingerprintPem(params.encryptionPublicKeyPem);
|
|
79
|
+
const payload = attestationPayload({
|
|
80
|
+
enrollment_id: params.enrollmentId,
|
|
81
|
+
project_id: params.projectId,
|
|
82
|
+
agent_id: params.agentId,
|
|
83
|
+
key_type: 'encryption',
|
|
84
|
+
key_purpose: 'envelope',
|
|
85
|
+
key_fingerprint: keyFingerprint,
|
|
86
|
+
key_epoch: params.keyEpoch ?? 1,
|
|
87
|
+
created_at: params.createdAt,
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
payload,
|
|
91
|
+
signature: signEd25519(params.identityPrivateKeyPem, new TextEncoder().encode(payload)),
|
|
92
|
+
created_at: params.createdAt,
|
|
93
|
+
key_fingerprint: keyFingerprint,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=federation-attestation.js.map
|
|
@@ -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
|