@cello-protocol/crypto 0.0.58 → 0.0.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * DOD-M15-KEYAGREE-1 — CELLO's own per-session key agreement.
3
+ *
4
+ * ─── Why CELLO needs its own, when Noise already encrypts the link ─────────────────────────────
5
+ *
6
+ * Live content today is plaintext inside libp2p's Noise session. That confidentiality is real — but
7
+ * it is **libp2p's** key agreement over **libp2p's** ephemeral transport keys, so CELLO cannot
8
+ * upgrade its own guarantee: a post-quantum migration would happen on libp2p's timeline, with
9
+ * libp2p's algorithm choices, whenever libp2p chose to make it.
10
+ *
11
+ * The threat is harvest-now-decrypt-later, and it is why this is urgent rather than later: every
12
+ * cross-NAT conversation is relayed today, therefore recordable at fixed endpoints today, and adding
13
+ * this layer next year does not protect traffic already sent.
14
+ *
15
+ * ─── Construction (SPARC Phase P) ──────────────────────────────────────────────────────────────
16
+ *
17
+ * generateSessionEphemeral(): # per SESSION, never reused
18
+ * 1. sk = random X25519 secret # RFC 7748
19
+ * pk = X25519 base * sk
20
+ *
21
+ * deriveSessionSecrets(ownSk, peerPk, sessionId, extra?):
22
+ * 1. shared = X25519(ownSk, peerPk) # EPHEMERAL-ephemeral ECDH
23
+ * 2. REFUSE if shared is all-zero # RFC 7748 §6.1
24
+ * 3. ikm = shared || extra? # the PQ hook
25
+ * 4. bind = sort(ownPk, peerPk) # canonical, role-independent
26
+ * 5. key = HKDF-SHA256(ikm, salt=sessionId, info="cello/session/v1/content-key" || bind, 32)
27
+ *
28
+ * THERE IS NO SECOND OUTPUT. This block used to specify a `csalt` derived from the same secret;
29
+ * the salt is agreed INDEPENDENTLY in `session-salt.ts` (Decisions Carried #8). Re-deriving it
30
+ * here brings back everything #7 was retracted for — epochs, per-leaf attribution, lockstep
31
+ * switching.
32
+ *
33
+ * RFCs: X25519 — RFC 7748. HKDF — RFC 5869. (The AEAD that consumes the key is NIST SP 800-38D,
34
+ * in `content-seal.ts`, which is the in-tree pattern this extends.)
35
+ *
36
+ * ─── The three ways this could be WORSE than no layer at all ───────────────────────────────────
37
+ *
38
+ * Named before the code, and each has a test:
39
+ *
40
+ * **1. Static-static.** A key derived only from long-term identity keys is the same key forever, so
41
+ * anyone who ever obtains an identity key decrypts every conversation that agent ever had — strictly
42
+ * worse than the Noise session it replaces. Hence ephemeral-EPHEMERAL: both sides mint fresh, and
43
+ * the caller destroys the secret at close.
44
+ *
45
+ * **2. A degenerate agreement accepted silently.** X25519 against a small-order point yields an
46
+ * all-zero shared secret; both sides then derive the same key, encryption appears to work, and the
47
+ * attacker who supplied the point knows it too. Encryption that *looks* like it is working is worse
48
+ * than none, because nobody investigates it. This throws.
49
+ *
50
+ * **3. A PQ hook that exists only in prose.** The line is blunt — *"the derivation accepts an
51
+ * additional shared secret from day one… omitting the hook defeats the entire reason for the
52
+ * work."* A parameter that is accepted and ignored reads as done and is not, so a test proves a
53
+ * different extra secret produces a different key.
54
+ *
55
+ * ─── WHAT THIS DOES NOT DEFEND AGAINST, stated plainly (review F6) ─────────────────────────────
56
+ *
57
+ * The ephemerals reaching THIS FUNCTION are raw public keys — nothing in this API takes an identity
58
+ * key, so it cannot tell whose they are. On its own that is sufficient only against the threat the
59
+ * DoD line names: harvest-now-decrypt-later, i.e. a PASSIVE recorder, which is what a relay storing
60
+ * traffic is. It is NOT sufficient against an ACTIVE on-path relay, which substitutes both
61
+ * ephemerals and reads everything while both ends see a working conversation.
62
+ *
63
+ * ✅ THE SYSTEM DOES DEFEND AGAINST THAT NOW — `DOD-M15-EPHEMERAL-AUTH-1` shipped it. The ephemeral
64
+ * public is signed with the agent's Ed25519 identity, and `verifySessionEphemeral` checks the peer's
65
+ * against the counterparty identity the SESSION was opened with, BEFORE this function is called. A
66
+ * failure stops the session rather than continuing in the open.
67
+ *
68
+ * The distinction is kept rather than replaced with "MITM is covered", because it is a property of
69
+ * the CALL ORDER and not of this module: a future caller that derives before verifying has an
70
+ * agreement with whoever sent the bytes, and this file would not notice.
71
+ *
72
+ * ─── ONE OUTPUT. The salt used to live here, and that was the defect. ─────────────────────────
73
+ *
74
+ * This module produced a content-hash salt as a second HKDF output, and Andre corrected it before
75
+ * `SEALWIRE-1` encoded anything (Decisions Carried #8, superseding the "one agreement, two outputs"
76
+ * bullet). The two are unrelated goals that merely both need a shared secret:
77
+ *
78
+ * the **envelope key** stops the relay reading messages in flight and MUST be destroyed at close;
79
+ * the **session salt** stops anyone holding stored hashes from confirming a guessed message and
80
+ * MUST survive for the life of the session.
81
+ *
82
+ * **Deriving both from one secret tied "must be forgotten" to "must be kept forever."** Everything
83
+ * that followed — salt epochs, per-leaf epoch attribution, lockstep switching, and my own Decision
84
+ * #7 ruling all of that — was a symptom of the coupling, not a requirement. The salt now lives in
85
+ * `session-salt.ts`, agreed in the SAME exchange from both sides' random contributions, and none of
86
+ * those consequences exist.
87
+ *
88
+ * ─── The remaining output, and its lifetime ───────────────────────────────────────────────────
89
+ *
90
+ **The envelope key NEVER touches disk**, and `destroySessionEphemeral` is how a caller discards the
91
+ * secret behind it at session close. A revived session RE-KEYS (Decisions Carried #5) — and because
92
+ * the salt no longer rides on this secret, re-keying no longer disturbs the transcript's
93
+ * verifiability.
94
+ *
95
+ * ⚠️ ALL THREE ARE WIRED NOW, and the history is kept because this file has been wrong in BOTH
96
+ * directions. It first claimed *"that is the forward secrecy, and `destroySessionEphemeral` is what
97
+ * makes it real"* while nothing called it. The correction over-swung to *"nothing in the daemon
98
+ * mints an ephemeral, derives a content key, or destroys one"*, which `006-CRYPTO` made two thirds
99
+ * false the same day. Both were confident; neither was measured.
100
+ *
101
+ * What is true today, and it is checkable in one grep each:
102
+ *
103
+ * `generateSessionEphemeral` / `destroySessionEphemeral` — `session-node-manager.ts` mints one
104
+ * per session at activation and destroys it at every site that drops the session's entry, and at
105
+ * shutdown (`006-CRYPTO`).
106
+ * `deriveSessionSecrets` — called from the same file, on an inbound signed ephemeral, AFTER the
107
+ * peer's signature is verified against the counterparty identity the session was opened with
108
+ * (`007-CRYPTO`). Its output encrypts the message body in `session-content-seal.ts`.
109
+ *
110
+ * ─── WHAT THIS FILE ALONE STILL DOES NOT DEFEND AGAINST ───────────────────────────────────────
111
+ *
112
+ * The ephemerals ARRIVING here are unauthenticated — this module takes raw public keys and cannot
113
+ * tell whose they are. That check is real but it lives one layer up, in
114
+ * `session-ephemeral-auth.ts`, which the daemon runs BEFORE calling this. So the property holds for
115
+ * the system and not for this function: a caller that derives from an unverified peer key has an
116
+ * agreement with whoever sent it, and against an on-path relay that is a relay who can read
117
+ * everything.
118
+ *
119
+ * Said this precisely because `session-salt.ts` states its own reachability boundary and this file
120
+ * once stated none, so the two halves of the same exchange read as though both were live.
121
+ *
122
+ * ─── The key and the salt must never be EQUAL, and what actually keeps them apart ──────────────
123
+ *
124
+ * The salt travels wherever a content hash does and the relay sees it, so a salt that equalled the
125
+ * key would hand the key to everyone who can see a hash.
126
+ *
127
+ * An earlier version said *"domain separation by label is what keeps them independent."* That was
128
+ * true when the salt was a second HKDF output of this function and is not true now. The salt is
129
+ * computed in `session-salt.ts` from a DIFFERENT input — the two sides' random contributions, not
130
+ * this ECDH secret — under its own label, with no HKDF salt. Different inputs, different module,
131
+ * different function. Label separation is not the mechanism; it is not even reachable, because
132
+ * there is only one label here.
133
+ */
134
+ export declare const SESSION_KEY_BYTES = 32;
135
+ export interface SessionEphemeral {
136
+ /** X25519 secret. The caller MUST destroy this at session close — that is what forward secrecy is. */
137
+ secretKey: Uint8Array;
138
+ /** X25519 public, sent to the peer in the session handshake. */
139
+ publicKey: Uint8Array;
140
+ }
141
+ export interface SessionSecrets {
142
+ /**
143
+ * AEAD key for message content (consumed by the `content-seal.ts` AES-256-GCM pattern).
144
+ *
145
+ * The ONLY output. It never touches disk and is destroyed at session close — see
146
+ * `destroySessionEphemeral`. The content-hash salt is NOT here: it is agreed separately in
147
+ * `session-salt.ts`, because its lifetime is the opposite of this one's.
148
+ */
149
+ contentKey: Uint8Array;
150
+ }
151
+ /**
152
+ * Mint this side's per-SESSION ephemeral keypair.
153
+ *
154
+ * Fresh every session, deliberately. Reusing one across sessions would collapse to static-static and
155
+ * void the forward secrecy that `design-problems` already claims as structural.
156
+ */
157
+ export declare function generateSessionEphemeral(): SessionEphemeral;
158
+ /**
159
+ * DESTROY THIS SIDE'S EPHEMERAL SECRET — `DOD-M15-KEYAGREE-1`, review F4.
160
+ *
161
+ * The line's clause is *"destroys the ephemerals at close"*, and it existed only as a sentence in a
162
+ * docstring telling the caller to do it. Forward secrecy is not a property of generating a fresh
163
+ * key; it is a property of the old one being GONE. A comment asserting that is the failure mode this
164
+ * milestone has caught five times in seal and persistence code, so it is code now.
165
+ *
166
+ * HONEST LIMIT: JavaScript cannot guarantee no copy survives. The garbage collector may have moved
167
+ * the buffer, and a `Uint8Array` handed across a module boundary may have been copied. Zeroing the
168
+ * buffer we hold removes the value from the one place we control, which is strictly better than not
169
+ * doing it and strictly weaker than the guarantee a language with explicit memory would give.
170
+ */
171
+ export declare function destroySessionEphemeral(e: SessionEphemeral): void;
172
+ export declare function deriveSessionSecrets(opts: {
173
+ ownEphemeralSecret: Uint8Array;
174
+ peerEphemeralPublic: Uint8Array;
175
+ /** The session this agreement is for. Bound in as the HKDF salt. */
176
+ sessionId: Uint8Array;
177
+ /**
178
+ * THE PQ HOOK — additional agreed secret, mixed into the IKM.
179
+ *
180
+ * Present from day one, before there is a PQ contribution to put in it, because retrofitting it
181
+ * later is a wire change and a rewrite rather than an addition. Hybrid PQ becomes: run a KEM,
182
+ * pass its shared secret here. Any length — an ML-KEM secret is 32 bytes but a hybrid may
183
+ * concatenate more than one contribution, and fixing the length would force the rewrite this
184
+ * parameter exists to avoid.
185
+ */
186
+ extraSharedSecret?: Uint8Array;
187
+ /**
188
+ * THE PQ TRANSCRIPT — review F8, and it is added NOW precisely because it cannot be added later.
189
+ *
190
+ * `extraSharedSecret` alone is not a complete hybrid combiner. Concatenating shared secrets is the
191
+ * right shape and matches TLS's X25519MLKEM768 and NIST SP 800-56C Rev 2 §2 — that part was
192
+ * checked and is sound. What it lacks is the KEM's PUBLIC material: X-Wing's combiner hashes
193
+ * `ss_pq ‖ ss_x ‖ ct_x ‖ pk_x`, binding the ciphertext and public key, and the current analysis
194
+ * ("On the Necessity of Public Contexts in Hybrid KEMs", eprint 2026/140) is that this is
195
+ * NECESSARY rather than belt-and-braces.
196
+ *
197
+ * A caller doing the obvious thing — passing only the ML-KEM shared secret — would get a hybrid
198
+ * whose ciphertext and public key are unbound. This parameter is where `ct_pq ‖ pk_pq` goes.
199
+ *
200
+ * It is empty today and that is the point: the DoD line's whole justification for building the
201
+ * hook before there is anything to put in it is that hybrid PQ must be *"an addition, not a
202
+ * rewrite."* Added after a wire format exists, this is a wire change — the exact rewrite the hook
203
+ * was meant to avoid.
204
+ */
205
+ pqTranscript?: Uint8Array;
206
+ }): SessionSecrets;
207
+ //# sourceMappingURL=session-key-agreement.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-key-agreement.d.ts","sourceRoot":"","sources":["../src/session-key-agreement.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoIG;AAQH,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAepC,MAAM,WAAW,gBAAgB;IAC/B,sGAAsG;IACtG,SAAS,EAAE,UAAU,CAAC;IACtB,gEAAgE;IAChE,SAAS,EAAE,UAAU,CAAC;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;;OAMG;IACH,UAAU,EAAE,UAAU,CAAC;CACxB;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,IAAI,gBAAgB,CAG3D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAEjE;AAmBD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE;IACzC,kBAAkB,EAAE,UAAU,CAAC;IAC/B,mBAAmB,EAAE,UAAU,CAAC;IAChC,oEAAoE;IACpE,SAAS,EAAE,UAAU,CAAC;IACtB;;;;;;;;OAQG;IACH,iBAAiB,CAAC,EAAE,UAAU,CAAC;IAC/B;;;;;;;;;;;;;;;;;OAiBG;IACH,YAAY,CAAC,EAAE,UAAU,CAAC;CAC3B,GAAG,cAAc,CAgJjB"}
@@ -0,0 +1,320 @@
1
+ /**
2
+ * DOD-M15-KEYAGREE-1 — CELLO's own per-session key agreement.
3
+ *
4
+ * ─── Why CELLO needs its own, when Noise already encrypts the link ─────────────────────────────
5
+ *
6
+ * Live content today is plaintext inside libp2p's Noise session. That confidentiality is real — but
7
+ * it is **libp2p's** key agreement over **libp2p's** ephemeral transport keys, so CELLO cannot
8
+ * upgrade its own guarantee: a post-quantum migration would happen on libp2p's timeline, with
9
+ * libp2p's algorithm choices, whenever libp2p chose to make it.
10
+ *
11
+ * The threat is harvest-now-decrypt-later, and it is why this is urgent rather than later: every
12
+ * cross-NAT conversation is relayed today, therefore recordable at fixed endpoints today, and adding
13
+ * this layer next year does not protect traffic already sent.
14
+ *
15
+ * ─── Construction (SPARC Phase P) ──────────────────────────────────────────────────────────────
16
+ *
17
+ * generateSessionEphemeral(): # per SESSION, never reused
18
+ * 1. sk = random X25519 secret # RFC 7748
19
+ * pk = X25519 base * sk
20
+ *
21
+ * deriveSessionSecrets(ownSk, peerPk, sessionId, extra?):
22
+ * 1. shared = X25519(ownSk, peerPk) # EPHEMERAL-ephemeral ECDH
23
+ * 2. REFUSE if shared is all-zero # RFC 7748 §6.1
24
+ * 3. ikm = shared || extra? # the PQ hook
25
+ * 4. bind = sort(ownPk, peerPk) # canonical, role-independent
26
+ * 5. key = HKDF-SHA256(ikm, salt=sessionId, info="cello/session/v1/content-key" || bind, 32)
27
+ *
28
+ * THERE IS NO SECOND OUTPUT. This block used to specify a `csalt` derived from the same secret;
29
+ * the salt is agreed INDEPENDENTLY in `session-salt.ts` (Decisions Carried #8). Re-deriving it
30
+ * here brings back everything #7 was retracted for — epochs, per-leaf attribution, lockstep
31
+ * switching.
32
+ *
33
+ * RFCs: X25519 — RFC 7748. HKDF — RFC 5869. (The AEAD that consumes the key is NIST SP 800-38D,
34
+ * in `content-seal.ts`, which is the in-tree pattern this extends.)
35
+ *
36
+ * ─── The three ways this could be WORSE than no layer at all ───────────────────────────────────
37
+ *
38
+ * Named before the code, and each has a test:
39
+ *
40
+ * **1. Static-static.** A key derived only from long-term identity keys is the same key forever, so
41
+ * anyone who ever obtains an identity key decrypts every conversation that agent ever had — strictly
42
+ * worse than the Noise session it replaces. Hence ephemeral-EPHEMERAL: both sides mint fresh, and
43
+ * the caller destroys the secret at close.
44
+ *
45
+ * **2. A degenerate agreement accepted silently.** X25519 against a small-order point yields an
46
+ * all-zero shared secret; both sides then derive the same key, encryption appears to work, and the
47
+ * attacker who supplied the point knows it too. Encryption that *looks* like it is working is worse
48
+ * than none, because nobody investigates it. This throws.
49
+ *
50
+ * **3. A PQ hook that exists only in prose.** The line is blunt — *"the derivation accepts an
51
+ * additional shared secret from day one… omitting the hook defeats the entire reason for the
52
+ * work."* A parameter that is accepted and ignored reads as done and is not, so a test proves a
53
+ * different extra secret produces a different key.
54
+ *
55
+ * ─── WHAT THIS DOES NOT DEFEND AGAINST, stated plainly (review F6) ─────────────────────────────
56
+ *
57
+ * The ephemerals reaching THIS FUNCTION are raw public keys — nothing in this API takes an identity
58
+ * key, so it cannot tell whose they are. On its own that is sufficient only against the threat the
59
+ * DoD line names: harvest-now-decrypt-later, i.e. a PASSIVE recorder, which is what a relay storing
60
+ * traffic is. It is NOT sufficient against an ACTIVE on-path relay, which substitutes both
61
+ * ephemerals and reads everything while both ends see a working conversation.
62
+ *
63
+ * ✅ THE SYSTEM DOES DEFEND AGAINST THAT NOW — `DOD-M15-EPHEMERAL-AUTH-1` shipped it. The ephemeral
64
+ * public is signed with the agent's Ed25519 identity, and `verifySessionEphemeral` checks the peer's
65
+ * against the counterparty identity the SESSION was opened with, BEFORE this function is called. A
66
+ * failure stops the session rather than continuing in the open.
67
+ *
68
+ * The distinction is kept rather than replaced with "MITM is covered", because it is a property of
69
+ * the CALL ORDER and not of this module: a future caller that derives before verifying has an
70
+ * agreement with whoever sent the bytes, and this file would not notice.
71
+ *
72
+ * ─── ONE OUTPUT. The salt used to live here, and that was the defect. ─────────────────────────
73
+ *
74
+ * This module produced a content-hash salt as a second HKDF output, and Andre corrected it before
75
+ * `SEALWIRE-1` encoded anything (Decisions Carried #8, superseding the "one agreement, two outputs"
76
+ * bullet). The two are unrelated goals that merely both need a shared secret:
77
+ *
78
+ * the **envelope key** stops the relay reading messages in flight and MUST be destroyed at close;
79
+ * the **session salt** stops anyone holding stored hashes from confirming a guessed message and
80
+ * MUST survive for the life of the session.
81
+ *
82
+ * **Deriving both from one secret tied "must be forgotten" to "must be kept forever."** Everything
83
+ * that followed — salt epochs, per-leaf epoch attribution, lockstep switching, and my own Decision
84
+ * #7 ruling all of that — was a symptom of the coupling, not a requirement. The salt now lives in
85
+ * `session-salt.ts`, agreed in the SAME exchange from both sides' random contributions, and none of
86
+ * those consequences exist.
87
+ *
88
+ * ─── The remaining output, and its lifetime ───────────────────────────────────────────────────
89
+ *
90
+ **The envelope key NEVER touches disk**, and `destroySessionEphemeral` is how a caller discards the
91
+ * secret behind it at session close. A revived session RE-KEYS (Decisions Carried #5) — and because
92
+ * the salt no longer rides on this secret, re-keying no longer disturbs the transcript's
93
+ * verifiability.
94
+ *
95
+ * ⚠️ ALL THREE ARE WIRED NOW, and the history is kept because this file has been wrong in BOTH
96
+ * directions. It first claimed *"that is the forward secrecy, and `destroySessionEphemeral` is what
97
+ * makes it real"* while nothing called it. The correction over-swung to *"nothing in the daemon
98
+ * mints an ephemeral, derives a content key, or destroys one"*, which `006-CRYPTO` made two thirds
99
+ * false the same day. Both were confident; neither was measured.
100
+ *
101
+ * What is true today, and it is checkable in one grep each:
102
+ *
103
+ * `generateSessionEphemeral` / `destroySessionEphemeral` — `session-node-manager.ts` mints one
104
+ * per session at activation and destroys it at every site that drops the session's entry, and at
105
+ * shutdown (`006-CRYPTO`).
106
+ * `deriveSessionSecrets` — called from the same file, on an inbound signed ephemeral, AFTER the
107
+ * peer's signature is verified against the counterparty identity the session was opened with
108
+ * (`007-CRYPTO`). Its output encrypts the message body in `session-content-seal.ts`.
109
+ *
110
+ * ─── WHAT THIS FILE ALONE STILL DOES NOT DEFEND AGAINST ───────────────────────────────────────
111
+ *
112
+ * The ephemerals ARRIVING here are unauthenticated — this module takes raw public keys and cannot
113
+ * tell whose they are. That check is real but it lives one layer up, in
114
+ * `session-ephemeral-auth.ts`, which the daemon runs BEFORE calling this. So the property holds for
115
+ * the system and not for this function: a caller that derives from an unverified peer key has an
116
+ * agreement with whoever sent it, and against an on-path relay that is a relay who can read
117
+ * everything.
118
+ *
119
+ * Said this precisely because `session-salt.ts` states its own reachability boundary and this file
120
+ * once stated none, so the two halves of the same exchange read as though both were live.
121
+ *
122
+ * ─── The key and the salt must never be EQUAL, and what actually keeps them apart ──────────────
123
+ *
124
+ * The salt travels wherever a content hash does and the relay sees it, so a salt that equalled the
125
+ * key would hand the key to everyone who can see a hash.
126
+ *
127
+ * An earlier version said *"domain separation by label is what keeps them independent."* That was
128
+ * true when the salt was a second HKDF output of this function and is not true now. The salt is
129
+ * computed in `session-salt.ts` from a DIFFERENT input — the two sides' random contributions, not
130
+ * this ECDH secret — under its own label, with no HKDF salt. Different inputs, different module,
131
+ * different function. Label separation is not the mechanism; it is not even reachable, because
132
+ * there is only one label here.
133
+ */
134
+ import { x25519 } from "@noble/curves/ed25519.js";
135
+ import { hkdf } from "@noble/hashes/hkdf.js";
136
+ import { sha256 } from "@noble/hashes/sha2.js";
137
+ /** X25519 keys and the derived outputs are all 32 bytes. */
138
+ const X25519_KEY_BYTES = 32;
139
+ export const SESSION_KEY_BYTES = 32;
140
+ const ENC = new TextEncoder();
141
+ /**
142
+ * THE ONE label. Versioned so a future derivation change is a new label rather than a silent
143
+ * reinterpretation of the same bytes.
144
+ *
145
+ * An earlier version read *"distinct labels are the ONLY thing separating the two outputs — same
146
+ * IKM, same salt, same binding."* There are no longer two outputs to separate: the content-hash
147
+ * salt moved to `session-salt.ts` and is derived from different inputs entirely (Decisions Carried
148
+ * #8). The label still earns its place — it is bound into `info`, so it is what a future second
149
+ * output WOULD be separated by — but it is not currently holding two values apart.
150
+ */
151
+ const INFO_CONTENT_KEY = ENC.encode("cello/session/v1/content-key");
152
+ /**
153
+ * Mint this side's per-SESSION ephemeral keypair.
154
+ *
155
+ * Fresh every session, deliberately. Reusing one across sessions would collapse to static-static and
156
+ * void the forward secrecy that `design-problems` already claims as structural.
157
+ */
158
+ export function generateSessionEphemeral() {
159
+ const secretKey = x25519.utils.randomSecretKey();
160
+ return { secretKey, publicKey: x25519.getPublicKey(secretKey) };
161
+ }
162
+ /**
163
+ * DESTROY THIS SIDE'S EPHEMERAL SECRET — `DOD-M15-KEYAGREE-1`, review F4.
164
+ *
165
+ * The line's clause is *"destroys the ephemerals at close"*, and it existed only as a sentence in a
166
+ * docstring telling the caller to do it. Forward secrecy is not a property of generating a fresh
167
+ * key; it is a property of the old one being GONE. A comment asserting that is the failure mode this
168
+ * milestone has caught five times in seal and persistence code, so it is code now.
169
+ *
170
+ * HONEST LIMIT: JavaScript cannot guarantee no copy survives. The garbage collector may have moved
171
+ * the buffer, and a `Uint8Array` handed across a module boundary may have been copied. Zeroing the
172
+ * buffer we hold removes the value from the one place we control, which is strictly better than not
173
+ * doing it and strictly weaker than the guarantee a language with explicit memory would give.
174
+ */
175
+ export function destroySessionEphemeral(e) {
176
+ e.secretKey.fill(0);
177
+ }
178
+ /** Constant-time-ish all-zero check. Not secret-dependent branching — the input is already known bad. */
179
+ function isAllZero(b) {
180
+ let acc = 0;
181
+ for (const x of b)
182
+ acc |= x;
183
+ return acc === 0;
184
+ }
185
+ /** Lexicographic compare, so both sides order the two public keys identically. */
186
+ function lexLess(a, b) {
187
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
188
+ const x = a[i];
189
+ const y = b[i];
190
+ if (x !== y)
191
+ return x < y;
192
+ }
193
+ return a.length < b.length;
194
+ }
195
+ export function deriveSessionSecrets(opts) {
196
+ if (opts.peerEphemeralPublic.length !== X25519_KEY_BYTES) {
197
+ throw new Error(`KEYAGREE: peer ephemeral public key must be ${X25519_KEY_BYTES} bytes, got ${opts.peerEphemeralPublic.length}. ` +
198
+ "Refusing rather than padding — a short key silently zero-extended is an agreement with " +
199
+ "something that is not the peer. This check ALSO keeps the HKDF `info` unambiguous (review " +
200
+ "F9): with a fixed-length label, two exactly-32-byte public keys and a trailing transcript, " +
201
+ "no two different (keys, transcript) inputs can encode to the same info bytes. A " +
202
+ "variable-length public would break that, and two peers whose info collided would derive the " +
203
+ "same key from different material.");
204
+ }
205
+ if (opts.ownEphemeralSecret.length !== X25519_KEY_BYTES) {
206
+ // Review F11: symmetric with the peer check above. `@noble` catches it, but names its own
207
+ // parameter rather than CELLO's key — the same substitution as F7, one layer down.
208
+ throw new Error(`KEYAGREE: own ephemeral secret must be ${X25519_KEY_BYTES} bytes, got ${opts.ownEphemeralSecret.length}. ` +
209
+ "This is a local defect, not something the peer did.");
210
+ }
211
+ /**
212
+ * REFUSE A NON-CANONICAL PEER KEY — review F10, and it is a one-bit attack with no diagnosis.
213
+ *
214
+ * RFC 7748 §5 has X25519 MASK bit 255 of the u-coordinate, so `pk` and `pk | 0x80…` produce the
215
+ * SAME shared secret — but they are different BYTES, and the binding below uses the bytes as
216
+ * received, including in the sort comparison.
217
+ *
218
+ * So a relay that flips the top bit of one relayed ephemeral costs itself nothing: ECDH still
219
+ * agrees, but one side binds `pk` and the other binds `pk'`, possibly in a different sorted order.
220
+ * The two derive different keys, the session never decrypts, and nothing anywhere explains why —
221
+ * precisely the failure the sorted binding exists to prevent, achieved for one flipped bit.
222
+ *
223
+ * Refusing rather than masking, deliberately: masking would make the tamper invisible, and a peer
224
+ * sending a non-canonical encoding is either broken or probing. Say so.
225
+ */
226
+ if (opts.peerEphemeralPublic[31] & 0x80) {
227
+ throw new Error("KEYAGREE: the peer's ephemeral public key is non-canonical — bit 255 is set. X25519 masks " +
228
+ "that bit (RFC 7748 §5) so the agreement would still succeed, but the raw bytes are bound into " +
229
+ "the key derivation, so the two sides would derive DIFFERENT keys and the session would never " +
230
+ "decrypt with nothing explaining why. Refusing: a correct peer never sets it, and a flipped " +
231
+ "bit in transit is exactly what this catches.");
232
+ }
233
+ if (opts.sessionId.length === 0) {
234
+ throw new Error("KEYAGREE: sessionId must not be empty. It is bound in as the HKDF salt — the BACKSTOP against " +
235
+ "catastrophic ephemeral reuse. (Review F13: what ordinarily stops two sessions between the " +
236
+ "same peers sharing a key is the fresh ephemerals, not this. An earlier version of this " +
237
+ "message claimed the stronger thing.)");
238
+ }
239
+ /**
240
+ * WRAPPED — review F7. `@noble` rejects a small-order or otherwise invalid point here, and its
241
+ * message is *"invalid private or public key received"*: it names neither CELLO, nor which of the
242
+ * two keys, nor the session. That is a third-party exit-point label standing in for the cause, on
243
+ * the path that ACTUALLY fires — while the carefully-written message below sits on the branch
244
+ * documented as unreachable.
245
+ */
246
+ let shared;
247
+ try {
248
+ shared = x25519.getSharedSecret(opts.ownEphemeralSecret, opts.peerEphemeralPublic);
249
+ }
250
+ catch (err) {
251
+ throw new Error("KEYAGREE: the peer's ephemeral public key is unusable for X25519 — it is invalid or a " +
252
+ `small-order point (RFC 7748 §6.1), so no session key can be agreed. Peer key began ` +
253
+ `${Buffer.from(opts.peerEphemeralPublic.subarray(0, 8)).toString("hex")}…. This is the peer's ` +
254
+ "key, not yours; a correct client never sends one. Refusing rather than deriving: a degenerate " +
255
+ "agreement yields a key the sender of that point also holds, while every message appears to " +
256
+ "encrypt normally.", { cause: err });
257
+ }
258
+ /**
259
+ * FAIL CLOSED ON A DEGENERATE AGREEMENT — RFC 7748 §6.1.
260
+ *
261
+ * A small-order peer point drives the shared secret to all zeros. Both sides would then derive the
262
+ * same key, every message would encrypt and decrypt correctly, and whoever supplied the point
263
+ * would hold the key. There is no symptom to notice.
264
+ *
265
+ * ⚠️ THIS BRANCH IS UNREACHABLE TODAY, and saying so is the point. `@noble/curves` already refuses
266
+ * — `getSharedSecret` throws *"invalid private or public key received"* before control arrives
267
+ * here — which the revert test proved: deleting this check left every test green. So this is a
268
+ * BACKSTOP against that dependency behaviour changing, not the thing currently providing the
269
+ * property, and an earlier version of this comment claimed otherwise.
270
+ *
271
+ * It is kept rather than deleted because the cost is one branch and the failure it guards has no
272
+ * symptom. The test pins the PROPERTY (a degenerate agreement is refused) rather than which layer
273
+ * refuses, so it keeps its teeth either way — and would catch a `@noble` upgrade that stopped
274
+ * rejecting.
275
+ */
276
+ if (isAllZero(shared)) {
277
+ throw new Error("KEYAGREE: degenerate X25519 agreement — the shared secret is all zeros, which means the peer " +
278
+ "supplied a small-order point (RFC 7748 §6.1). Refusing: deriving from it would produce a key " +
279
+ "the attacker who sent that point also knows, while every message appeared to encrypt normally.");
280
+ }
281
+ const ownPublic = x25519.getPublicKey(opts.ownEphemeralSecret);
282
+ /**
283
+ * REFLECTION — the peer sent back our own ephemeral public. Standard hygiene: it is not a
284
+ * key-recovery attack against X25519, but it means the "peer" contributed nothing to the
285
+ * agreement, and both sorted halves would be identical. One line, refused.
286
+ */
287
+ if (Buffer.compare(Buffer.from(ownPublic), Buffer.from(opts.peerEphemeralPublic)) === 0) {
288
+ throw new Error("KEYAGREE: the peer's ephemeral public key is identical to our own — the peer contributed " +
289
+ "nothing to the agreement. Refusing: this is a reflection, not a handshake.");
290
+ }
291
+ /**
292
+ * Both public keys bound into `info`, in CANONICAL (sorted) order.
293
+ *
294
+ * Sorted rather than by role, because the two daemons reach this point from different code paths
295
+ * and a disagreement about who "initiated" would produce two different keys — a conversation that
296
+ * fails to decrypt with nothing anywhere explaining why. Binding both also ties the derivation to
297
+ * this exact pair of ephemerals, so a key from one handshake cannot be replayed into another.
298
+ */
299
+ const [first, second] = lexLess(ownPublic, opts.peerEphemeralPublic)
300
+ ? [ownPublic, opts.peerEphemeralPublic]
301
+ : [opts.peerEphemeralPublic, ownPublic];
302
+ const extra = opts.extraSharedSecret ?? new Uint8Array(0);
303
+ const ikm = new Uint8Array(shared.length + extra.length);
304
+ ikm.set(shared, 0);
305
+ ikm.set(extra, shared.length);
306
+ const transcript = opts.pqTranscript ?? new Uint8Array(0);
307
+ const info = (label) => {
308
+ const out = new Uint8Array(label.length + first.length + second.length + transcript.length);
309
+ out.set(label, 0);
310
+ out.set(first, label.length);
311
+ out.set(second, label.length + first.length);
312
+ // TRAILING, so the label remains recoverable as info[0 : len-64-|transcript|] for a caller that
313
+ // knows the transcript length. It is empty today; when a hybrid fills it, both sides supply the
314
+ // same bytes or they diverge — which is the safe direction.
315
+ out.set(transcript, label.length + first.length + second.length);
316
+ return out;
317
+ };
318
+ return { contentKey: hkdf(sha256, ikm, opts.sessionId, info(INFO_CONTENT_KEY), SESSION_KEY_BYTES) };
319
+ }
320
+ //# sourceMappingURL=session-key-agreement.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-key-agreement.js","sourceRoot":"","sources":["../src/session-key-agreement.ts"],"names":[],"mappings":"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"}