@cello-protocol/crypto 0.0.58 → 0.0.59
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/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/session-key-agreement.d.ts +166 -0
- package/dist/session-key-agreement.d.ts.map +1 -0
- package/dist/session-key-agreement.js +273 -0
- package/dist/session-key-agreement.js.map +1 -0
- package/dist/session-salt.d.ts +129 -0
- package/dist/session-salt.d.ts.map +1 -0
- package/dist/session-salt.js +205 -0
- package/dist/session-salt.js.map +1 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export { ed25519_FROST } from "@noble/curves/ed25519.js";
|
|
|
15
15
|
export { buildCheckpointTbs, computeCheckpointHash } from "./checkpoint.js";
|
|
16
16
|
export { buildRelayRegistrationTbs, verifyRelayRegistrationSignature } from "./relay-registration.js";
|
|
17
17
|
export { sealToRecipient, openSealed, CONTENT_SEAL_OVERHEAD_BYTES } from "./content-seal.js";
|
|
18
|
+
export { generateSaltContribution, deriveSessionSalt, saltFingerprint, saltedContentHash, SALT_CONTRIBUTION_BYTES, SALT_FINGERPRINT_BYTES, SESSION_SALT_BYTES, } from "./session-salt.js";
|
|
19
|
+
export { generateSessionEphemeral, destroySessionEphemeral, deriveSessionSecrets, SESSION_KEY_BYTES, type SessionEphemeral, type SessionSecrets, } from "./session-key-agreement.js";
|
|
18
20
|
export type { ManifestVerifyResult, ManifestVerifyDiagnostics, ManifestVerifySkipReason, ManifestVerifySkippedEntry, ConsortiumManifestInput } from "./manifest.js";
|
|
19
21
|
export { canonicalManifestBody, verifyManifest } from "./manifest.js";
|
|
20
22
|
export type { PreAuthCapability, PreAuthCapabilityBody, CapabilityVerifyResult, CapabilityVerifyReason } from "./preauth-capability.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,SAAS,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACzF,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,eAAe,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACpI,YAAY,EAAE,cAAc,EAAE,cAAc,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAClG,OAAO,EACL,wBAAwB,EACxB,oBAAoB,EACpB,WAAW,EACX,oBAAoB,EACpB,SAAS,EACT,WAAW,EACX,iBAAiB,GAClB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACxI,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC3F,YAAY,EACV,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,uBAAuB,EACvB,0BAA0B,EAC1B,YAAY,EACZ,eAAe,GAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,6BAA6B,EAC7B,YAAY,EACZ,uBAAuB,EACvB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAIzE,OAAO,EACL,2BAA2B,EAC3B,yBAAyB,EACzB,YAAY,GACb,MAAM,4BAA4B,CAAC;AACpC,YAAY,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,OAAO,EACL,wBAAwB,EACxB,sBAAsB,EACtB,iCAAiC,EACjC,wBAAwB,GACzB,MAAM,mCAAmC,CAAC;AAK3C,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAGzD,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAG5E,OAAO,EAAE,yBAAyB,EAAE,gCAAgC,EAAE,MAAM,yBAAyB,CAAC;AAGtG,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,2BAA2B,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,14 @@ export { buildCheckpointTbs, computeCheckpointHash } from "./checkpoint.js";
|
|
|
20
20
|
export { buildRelayRegistrationTbs, verifyRelayRegistrationSignature } from "./relay-registration.js";
|
|
21
21
|
// M7-MSG-001: recipient content sealed box (E2E encryption for parked content)
|
|
22
22
|
export { sealToRecipient, openSealed, CONTENT_SEAL_OVERHEAD_BYTES } from "./content-seal.js";
|
|
23
|
+
// Decisions Carried #8/#9/#10: the SESSION SALT — agreed in the same exchange as the envelope key
|
|
24
|
+
// but NOT derived from it, because the key must be destroyed at close and the salt must survive the
|
|
25
|
+
// session. Both sides contribute; HMAC for the salted content hash; a fingerprint for the open check.
|
|
26
|
+
export { generateSaltContribution, deriveSessionSalt, saltFingerprint, saltedContentHash, SALT_CONTRIBUTION_BYTES, SALT_FINGERPRINT_BYTES, SESSION_SALT_BYTES, } from "./session-salt.js";
|
|
27
|
+
// DOD-M15-KEYAGREE-1: CELLO's own per-session key agreement — ephemeral-ephemeral X25519 with the
|
|
28
|
+
// hybrid-PQ hook built in. ONE output, the envelope key: the salt is agreed separately above
|
|
29
|
+
// (Decisions Carried #8), because the key is destroyed at close and the salt outlives the session.
|
|
30
|
+
export { generateSessionEphemeral, destroySessionEphemeral, deriveSessionSecrets, SESSION_KEY_BYTES, } from "./session-key-agreement.js";
|
|
23
31
|
export { canonicalManifestBody, verifyManifest } from "./manifest.js";
|
|
24
32
|
export { canonicalCapabilityBody, signCapability, verifyCapability, encodeCapability, decodeCapability } from "./preauth-capability.js";
|
|
25
33
|
export { CONSORTIUM_ROOT_KEYS, CONSORTIUM_THRESHOLD, TEST_CONSORTIUM_ROOT_KEYS, TEST_CONSORTIUM_THRESHOLD, } from "./consortium-keys.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,eAAe,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAEpI,OAAO,EACL,wBAAwB,EACxB,oBAAoB,EACpB,WAAW,EACX,oBAAoB,EACpB,SAAS,EACT,WAAW,EACX,iBAAiB,GAClB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAExI,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAU3F,OAAO,EACL,6BAA6B,EAC7B,YAAY,EACZ,uBAAuB,EACvB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,kBAAkB,CAAC;AAE1B,iGAAiG;AACjG,OAAO,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAEzE,uGAAuG;AACvG,0FAA0F;AAC1F,OAAO,EACL,2BAA2B,EAC3B,yBAAyB,EACzB,YAAY,GACb,MAAM,4BAA4B,CAAC;AAEpC,8FAA8F;AAC9F,OAAO,EACL,wBAAwB,EACxB,sBAAsB,EACtB,iCAAiC,EACjC,wBAAwB,GACzB,MAAM,mCAAmC,CAAC;AAE3C,qGAAqG;AACrG,mGAAmG;AACnG,qEAAqE;AACrE,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,8EAA8E;AAC9E,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAE5E,oEAAoE;AACpE,OAAO,EAAE,yBAAyB,EAAE,gCAAgC,EAAE,MAAM,yBAAyB,CAAC;AAEtG,+EAA+E;AAC/E,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,2BAA2B,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,eAAe,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAEpI,OAAO,EACL,wBAAwB,EACxB,oBAAoB,EACpB,WAAW,EACX,oBAAoB,EACpB,SAAS,EACT,WAAW,EACX,iBAAiB,GAClB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAExI,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAU3F,OAAO,EACL,6BAA6B,EAC7B,YAAY,EACZ,uBAAuB,EACvB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,kBAAkB,CAAC;AAE1B,iGAAiG;AACjG,OAAO,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAEzE,uGAAuG;AACvG,0FAA0F;AAC1F,OAAO,EACL,2BAA2B,EAC3B,yBAAyB,EACzB,YAAY,GACb,MAAM,4BAA4B,CAAC;AAEpC,8FAA8F;AAC9F,OAAO,EACL,wBAAwB,EACxB,sBAAsB,EACtB,iCAAiC,EACjC,wBAAwB,GACzB,MAAM,mCAAmC,CAAC;AAE3C,qGAAqG;AACrG,mGAAmG;AACnG,qEAAqE;AACrE,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,8EAA8E;AAC9E,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAE5E,oEAAoE;AACpE,OAAO,EAAE,yBAAyB,EAAE,gCAAgC,EAAE,MAAM,yBAAyB,CAAC;AAEtG,+EAA+E;AAC/E,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,2BAA2B,EAAE,MAAM,mBAAmB,CAAC;AAC7F,kGAAkG;AAClG,oGAAoG;AACpG,sGAAsG;AACtG,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,eAAe,EACf,iBAAiB,EACjB,uBAAuB,EACvB,sBAAsB,EACtB,kBAAkB,GACnB,MAAM,mBAAmB,CAAC;AAE3B,kGAAkG;AAClG,6FAA6F;AAC7F,mGAAmG;AACnG,OAAO,EACL,wBAAwB,EACxB,uBAAuB,EACvB,oBAAoB,EACpB,iBAAiB,GAGlB,MAAM,4BAA4B,CAAC;AAIpC,OAAO,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAGtE,OAAO,EAAE,uBAAuB,EAAE,cAAc,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AACxI,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,yBAAyB,EACzB,yBAAyB,GAC1B,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,gBAAgB,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAC;AAE3F,wEAAwE;AACxE,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,166 @@
|
|
|
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 here are UNAUTHENTICATED. That is sufficient against the threat the DoD line names
|
|
58
|
+
* — harvest-now-decrypt-later, i.e. a PASSIVE recorder, which is what a relay storing traffic is.
|
|
59
|
+
*
|
|
60
|
+
* It is NOT sufficient against an ACTIVE on-path relay, which can substitute both ephemerals and
|
|
61
|
+
* read everything. Nothing in this API takes an identity key, so there is nowhere to bind the
|
|
62
|
+
* ephemeral to the peer. `DOD-M15-EPHEMERAL-AUTH-1` carries that: the ephemeral public must be
|
|
63
|
+
* signed with the agent's Ed25519 identity and the peer's verified before deriving.
|
|
64
|
+
*
|
|
65
|
+
* Said here because the file's own headline is "CELLO owns its own confidentiality guarantee", and a
|
|
66
|
+
* reader could otherwise conclude MITM is covered. It is not, yet.
|
|
67
|
+
*
|
|
68
|
+
* ─── ONE OUTPUT. The salt used to live here, and that was the defect. ─────────────────────────
|
|
69
|
+
*
|
|
70
|
+
* This module produced a content-hash salt as a second HKDF output, and Andre corrected it before
|
|
71
|
+
* `SEALWIRE-1` encoded anything (Decisions Carried #8, superseding the "one agreement, two outputs"
|
|
72
|
+
* bullet). The two are unrelated goals that merely both need a shared secret:
|
|
73
|
+
*
|
|
74
|
+
* the **envelope key** stops the relay reading messages in flight and MUST be destroyed at close;
|
|
75
|
+
* the **session salt** stops anyone holding stored hashes from confirming a guessed message and
|
|
76
|
+
* MUST survive for the life of the session.
|
|
77
|
+
*
|
|
78
|
+
* **Deriving both from one secret tied "must be forgotten" to "must be kept forever."** Everything
|
|
79
|
+
* that followed — salt epochs, per-leaf epoch attribution, lockstep switching, and my own Decision
|
|
80
|
+
* #7 ruling all of that — was a symptom of the coupling, not a requirement. The salt now lives in
|
|
81
|
+
* `session-salt.ts`, agreed in the SAME exchange from both sides' random contributions, and none of
|
|
82
|
+
* those consequences exist.
|
|
83
|
+
*
|
|
84
|
+
* ─── The remaining output, and its lifetime ───────────────────────────────────────────────────
|
|
85
|
+
*
|
|
86
|
+
**The envelope key NEVER touches disk** and is destroyed at session close. That is the forward
|
|
87
|
+
* secrecy, and `destroySessionEphemeral` is what makes it real. A revived session RE-KEYS (Decisions
|
|
88
|
+
* Carried #5) — and because the salt no longer rides on this secret, re-keying no longer disturbs
|
|
89
|
+
* the transcript's verifiability. They must never be EQUAL: the salt travels wherever a content hash
|
|
90
|
+
* does and the relay sees it, so a salt that equalled the key would hand the key to everyone who can
|
|
91
|
+
* see a hash. Domain separation by label is what keeps them independent.
|
|
92
|
+
*/
|
|
93
|
+
export declare const SESSION_KEY_BYTES = 32;
|
|
94
|
+
export interface SessionEphemeral {
|
|
95
|
+
/** X25519 secret. The caller MUST destroy this at session close — that is what forward secrecy is. */
|
|
96
|
+
secretKey: Uint8Array;
|
|
97
|
+
/** X25519 public, sent to the peer in the session handshake. */
|
|
98
|
+
publicKey: Uint8Array;
|
|
99
|
+
}
|
|
100
|
+
export interface SessionSecrets {
|
|
101
|
+
/**
|
|
102
|
+
* AEAD key for message content (consumed by the `content-seal.ts` AES-256-GCM pattern).
|
|
103
|
+
*
|
|
104
|
+
* The ONLY output. It never touches disk and is destroyed at session close — see
|
|
105
|
+
* `destroySessionEphemeral`. The content-hash salt is NOT here: it is agreed separately in
|
|
106
|
+
* `session-salt.ts`, because its lifetime is the opposite of this one's.
|
|
107
|
+
*/
|
|
108
|
+
contentKey: Uint8Array;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Mint this side's per-SESSION ephemeral keypair.
|
|
112
|
+
*
|
|
113
|
+
* Fresh every session, deliberately. Reusing one across sessions would collapse to static-static and
|
|
114
|
+
* void the forward secrecy that `design-problems` already claims as structural.
|
|
115
|
+
*/
|
|
116
|
+
export declare function generateSessionEphemeral(): SessionEphemeral;
|
|
117
|
+
/**
|
|
118
|
+
* DESTROY THIS SIDE'S EPHEMERAL SECRET — `DOD-M15-KEYAGREE-1`, review F4.
|
|
119
|
+
*
|
|
120
|
+
* The line's clause is *"destroys the ephemerals at close"*, and it existed only as a sentence in a
|
|
121
|
+
* docstring telling the caller to do it. Forward secrecy is not a property of generating a fresh
|
|
122
|
+
* key; it is a property of the old one being GONE. A comment asserting that is the failure mode this
|
|
123
|
+
* milestone has caught five times in seal and persistence code, so it is code now.
|
|
124
|
+
*
|
|
125
|
+
* HONEST LIMIT: JavaScript cannot guarantee no copy survives. The garbage collector may have moved
|
|
126
|
+
* the buffer, and a `Uint8Array` handed across a module boundary may have been copied. Zeroing the
|
|
127
|
+
* buffer we hold removes the value from the one place we control, which is strictly better than not
|
|
128
|
+
* doing it and strictly weaker than the guarantee a language with explicit memory would give.
|
|
129
|
+
*/
|
|
130
|
+
export declare function destroySessionEphemeral(e: SessionEphemeral): void;
|
|
131
|
+
export declare function deriveSessionSecrets(opts: {
|
|
132
|
+
ownEphemeralSecret: Uint8Array;
|
|
133
|
+
peerEphemeralPublic: Uint8Array;
|
|
134
|
+
/** The session this agreement is for. Bound in as the HKDF salt. */
|
|
135
|
+
sessionId: Uint8Array;
|
|
136
|
+
/**
|
|
137
|
+
* THE PQ HOOK — additional agreed secret, mixed into the IKM.
|
|
138
|
+
*
|
|
139
|
+
* Present from day one, before there is a PQ contribution to put in it, because retrofitting it
|
|
140
|
+
* later is a wire change and a rewrite rather than an addition. Hybrid PQ becomes: run a KEM,
|
|
141
|
+
* pass its shared secret here. Any length — an ML-KEM secret is 32 bytes but a hybrid may
|
|
142
|
+
* concatenate more than one contribution, and fixing the length would force the rewrite this
|
|
143
|
+
* parameter exists to avoid.
|
|
144
|
+
*/
|
|
145
|
+
extraSharedSecret?: Uint8Array;
|
|
146
|
+
/**
|
|
147
|
+
* THE PQ TRANSCRIPT — review F8, and it is added NOW precisely because it cannot be added later.
|
|
148
|
+
*
|
|
149
|
+
* `extraSharedSecret` alone is not a complete hybrid combiner. Concatenating shared secrets is the
|
|
150
|
+
* right shape and matches TLS's X25519MLKEM768 and NIST SP 800-56C Rev 2 §2 — that part was
|
|
151
|
+
* checked and is sound. What it lacks is the KEM's PUBLIC material: X-Wing's combiner hashes
|
|
152
|
+
* `ss_pq ‖ ss_x ‖ ct_x ‖ pk_x`, binding the ciphertext and public key, and the current analysis
|
|
153
|
+
* ("On the Necessity of Public Contexts in Hybrid KEMs", eprint 2026/140) is that this is
|
|
154
|
+
* NECESSARY rather than belt-and-braces.
|
|
155
|
+
*
|
|
156
|
+
* A caller doing the obvious thing — passing only the ML-KEM shared secret — would get a hybrid
|
|
157
|
+
* whose ciphertext and public key are unbound. This parameter is where `ct_pq ‖ pk_pq` goes.
|
|
158
|
+
*
|
|
159
|
+
* It is empty today and that is the point: the DoD line's whole justification for building the
|
|
160
|
+
* hook before there is anything to put in it is that hybrid PQ must be *"an addition, not a
|
|
161
|
+
* rewrite."* Added after a wire format exists, this is a wire change — the exact rewrite the hook
|
|
162
|
+
* was meant to avoid.
|
|
163
|
+
*/
|
|
164
|
+
pqTranscript?: Uint8Array;
|
|
165
|
+
}): SessionSecrets;
|
|
166
|
+
//# 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2FG;AAQH,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAUpC,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,CA+IjB"}
|
|
@@ -0,0 +1,273 @@
|
|
|
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 here are UNAUTHENTICATED. That is sufficient against the threat the DoD line names
|
|
58
|
+
* — harvest-now-decrypt-later, i.e. a PASSIVE recorder, which is what a relay storing traffic is.
|
|
59
|
+
*
|
|
60
|
+
* It is NOT sufficient against an ACTIVE on-path relay, which can substitute both ephemerals and
|
|
61
|
+
* read everything. Nothing in this API takes an identity key, so there is nowhere to bind the
|
|
62
|
+
* ephemeral to the peer. `DOD-M15-EPHEMERAL-AUTH-1` carries that: the ephemeral public must be
|
|
63
|
+
* signed with the agent's Ed25519 identity and the peer's verified before deriving.
|
|
64
|
+
*
|
|
65
|
+
* Said here because the file's own headline is "CELLO owns its own confidentiality guarantee", and a
|
|
66
|
+
* reader could otherwise conclude MITM is covered. It is not, yet.
|
|
67
|
+
*
|
|
68
|
+
* ─── ONE OUTPUT. The salt used to live here, and that was the defect. ─────────────────────────
|
|
69
|
+
*
|
|
70
|
+
* This module produced a content-hash salt as a second HKDF output, and Andre corrected it before
|
|
71
|
+
* `SEALWIRE-1` encoded anything (Decisions Carried #8, superseding the "one agreement, two outputs"
|
|
72
|
+
* bullet). The two are unrelated goals that merely both need a shared secret:
|
|
73
|
+
*
|
|
74
|
+
* the **envelope key** stops the relay reading messages in flight and MUST be destroyed at close;
|
|
75
|
+
* the **session salt** stops anyone holding stored hashes from confirming a guessed message and
|
|
76
|
+
* MUST survive for the life of the session.
|
|
77
|
+
*
|
|
78
|
+
* **Deriving both from one secret tied "must be forgotten" to "must be kept forever."** Everything
|
|
79
|
+
* that followed — salt epochs, per-leaf epoch attribution, lockstep switching, and my own Decision
|
|
80
|
+
* #7 ruling all of that — was a symptom of the coupling, not a requirement. The salt now lives in
|
|
81
|
+
* `session-salt.ts`, agreed in the SAME exchange from both sides' random contributions, and none of
|
|
82
|
+
* those consequences exist.
|
|
83
|
+
*
|
|
84
|
+
* ─── The remaining output, and its lifetime ───────────────────────────────────────────────────
|
|
85
|
+
*
|
|
86
|
+
**The envelope key NEVER touches disk** and is destroyed at session close. That is the forward
|
|
87
|
+
* secrecy, and `destroySessionEphemeral` is what makes it real. A revived session RE-KEYS (Decisions
|
|
88
|
+
* Carried #5) — and because the salt no longer rides on this secret, re-keying no longer disturbs
|
|
89
|
+
* the transcript's verifiability. They must never be EQUAL: the salt travels wherever a content hash
|
|
90
|
+
* does and the relay sees it, so a salt that equalled the key would hand the key to everyone who can
|
|
91
|
+
* see a hash. Domain separation by label is what keeps them independent.
|
|
92
|
+
*/
|
|
93
|
+
import { x25519 } from "@noble/curves/ed25519.js";
|
|
94
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
95
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
96
|
+
/** X25519 keys and the derived outputs are all 32 bytes. */
|
|
97
|
+
const X25519_KEY_BYTES = 32;
|
|
98
|
+
export const SESSION_KEY_BYTES = 32;
|
|
99
|
+
const ENC = new TextEncoder();
|
|
100
|
+
/**
|
|
101
|
+
* Distinct labels are the ONLY thing separating the two outputs — same IKM, same salt, same binding.
|
|
102
|
+
* Versioned so a future derivation change is a new label rather than a silent reinterpretation of
|
|
103
|
+
* the same bytes.
|
|
104
|
+
*/
|
|
105
|
+
const INFO_CONTENT_KEY = ENC.encode("cello/session/v1/content-key");
|
|
106
|
+
/**
|
|
107
|
+
* Mint this side's per-SESSION ephemeral keypair.
|
|
108
|
+
*
|
|
109
|
+
* Fresh every session, deliberately. Reusing one across sessions would collapse to static-static and
|
|
110
|
+
* void the forward secrecy that `design-problems` already claims as structural.
|
|
111
|
+
*/
|
|
112
|
+
export function generateSessionEphemeral() {
|
|
113
|
+
const secretKey = x25519.utils.randomSecretKey();
|
|
114
|
+
return { secretKey, publicKey: x25519.getPublicKey(secretKey) };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* DESTROY THIS SIDE'S EPHEMERAL SECRET — `DOD-M15-KEYAGREE-1`, review F4.
|
|
118
|
+
*
|
|
119
|
+
* The line's clause is *"destroys the ephemerals at close"*, and it existed only as a sentence in a
|
|
120
|
+
* docstring telling the caller to do it. Forward secrecy is not a property of generating a fresh
|
|
121
|
+
* key; it is a property of the old one being GONE. A comment asserting that is the failure mode this
|
|
122
|
+
* milestone has caught five times in seal and persistence code, so it is code now.
|
|
123
|
+
*
|
|
124
|
+
* HONEST LIMIT: JavaScript cannot guarantee no copy survives. The garbage collector may have moved
|
|
125
|
+
* the buffer, and a `Uint8Array` handed across a module boundary may have been copied. Zeroing the
|
|
126
|
+
* buffer we hold removes the value from the one place we control, which is strictly better than not
|
|
127
|
+
* doing it and strictly weaker than the guarantee a language with explicit memory would give.
|
|
128
|
+
*/
|
|
129
|
+
export function destroySessionEphemeral(e) {
|
|
130
|
+
e.secretKey.fill(0);
|
|
131
|
+
}
|
|
132
|
+
/** Constant-time-ish all-zero check. Not secret-dependent branching — the input is already known bad. */
|
|
133
|
+
function isAllZero(b) {
|
|
134
|
+
let acc = 0;
|
|
135
|
+
for (const x of b)
|
|
136
|
+
acc |= x;
|
|
137
|
+
return acc === 0;
|
|
138
|
+
}
|
|
139
|
+
/** Lexicographic compare, so both sides order the two public keys identically. */
|
|
140
|
+
function lexLess(a, b) {
|
|
141
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
|
142
|
+
const x = a[i];
|
|
143
|
+
const y = b[i];
|
|
144
|
+
if (x !== y)
|
|
145
|
+
return x < y;
|
|
146
|
+
}
|
|
147
|
+
return a.length < b.length;
|
|
148
|
+
}
|
|
149
|
+
export function deriveSessionSecrets(opts) {
|
|
150
|
+
if (opts.peerEphemeralPublic.length !== X25519_KEY_BYTES) {
|
|
151
|
+
throw new Error(`KEYAGREE: peer ephemeral public key must be ${X25519_KEY_BYTES} bytes, got ${opts.peerEphemeralPublic.length}. ` +
|
|
152
|
+
"Refusing rather than padding — a short key silently zero-extended is an agreement with " +
|
|
153
|
+
"something that is not the peer. This check ALSO does double duty as the canonicalisation " +
|
|
154
|
+
"guard for the HKDF `info` (review F9): the label is recoverable as info[0 : len-64-|transcript|] " +
|
|
155
|
+
"only because both public keys are exactly 32 bytes. A variable-length public would let a " +
|
|
156
|
+
"crafted key make a content-key info equal a content-salt info.");
|
|
157
|
+
}
|
|
158
|
+
if (opts.ownEphemeralSecret.length !== X25519_KEY_BYTES) {
|
|
159
|
+
// Review F11: symmetric with the peer check above. `@noble` catches it, but names its own
|
|
160
|
+
// parameter rather than CELLO's key — the same substitution as F7, one layer down.
|
|
161
|
+
throw new Error(`KEYAGREE: own ephemeral secret must be ${X25519_KEY_BYTES} bytes, got ${opts.ownEphemeralSecret.length}. ` +
|
|
162
|
+
"This is a local defect, not something the peer did.");
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* REFUSE A NON-CANONICAL PEER KEY — review F10, and it is a one-bit attack with no diagnosis.
|
|
166
|
+
*
|
|
167
|
+
* RFC 7748 §5 has X25519 MASK bit 255 of the u-coordinate, so `pk` and `pk | 0x80…` produce the
|
|
168
|
+
* SAME shared secret — but they are different BYTES, and the binding below uses the bytes as
|
|
169
|
+
* received, including in the sort comparison.
|
|
170
|
+
*
|
|
171
|
+
* So a relay that flips the top bit of one relayed ephemeral costs itself nothing: ECDH still
|
|
172
|
+
* agrees, but one side binds `pk` and the other binds `pk'`, possibly in a different sorted order.
|
|
173
|
+
* The two derive different keys, the session never decrypts, and nothing anywhere explains why —
|
|
174
|
+
* precisely the failure the sorted binding exists to prevent, achieved for one flipped bit.
|
|
175
|
+
*
|
|
176
|
+
* Refusing rather than masking, deliberately: masking would make the tamper invisible, and a peer
|
|
177
|
+
* sending a non-canonical encoding is either broken or probing. Say so.
|
|
178
|
+
*/
|
|
179
|
+
if (opts.peerEphemeralPublic[31] & 0x80) {
|
|
180
|
+
throw new Error("KEYAGREE: the peer's ephemeral public key is non-canonical — bit 255 is set. X25519 masks " +
|
|
181
|
+
"that bit (RFC 7748 §5) so the agreement would still succeed, but the raw bytes are bound into " +
|
|
182
|
+
"the key derivation, so the two sides would derive DIFFERENT keys and the session would never " +
|
|
183
|
+
"decrypt with nothing explaining why. Refusing: a correct peer never sets it, and a flipped " +
|
|
184
|
+
"bit in transit is exactly what this catches.");
|
|
185
|
+
}
|
|
186
|
+
if (opts.sessionId.length === 0) {
|
|
187
|
+
throw new Error("KEYAGREE: sessionId must not be empty. It is bound in as the HKDF salt — the BACKSTOP against " +
|
|
188
|
+
"catastrophic ephemeral reuse. (Review F13: what ordinarily stops two sessions between the " +
|
|
189
|
+
"same peers sharing a key is the fresh ephemerals, not this. An earlier version of this " +
|
|
190
|
+
"message claimed the stronger thing.)");
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* WRAPPED — review F7. `@noble` rejects a small-order or otherwise invalid point here, and its
|
|
194
|
+
* message is *"invalid private or public key received"*: it names neither CELLO, nor which of the
|
|
195
|
+
* two keys, nor the session. That is a third-party exit-point label standing in for the cause, on
|
|
196
|
+
* the path that ACTUALLY fires — while the carefully-written message below sits on the branch
|
|
197
|
+
* documented as unreachable.
|
|
198
|
+
*/
|
|
199
|
+
let shared;
|
|
200
|
+
try {
|
|
201
|
+
shared = x25519.getSharedSecret(opts.ownEphemeralSecret, opts.peerEphemeralPublic);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
throw new Error("KEYAGREE: the peer's ephemeral public key is unusable for X25519 — it is invalid or a " +
|
|
205
|
+
`small-order point (RFC 7748 §6.1), so no session key can be agreed. Peer key began ` +
|
|
206
|
+
`${Buffer.from(opts.peerEphemeralPublic.subarray(0, 8)).toString("hex")}…. This is the peer's ` +
|
|
207
|
+
"key, not yours; a correct client never sends one. Refusing rather than deriving: a degenerate " +
|
|
208
|
+
"agreement yields a key the sender of that point also holds, while every message appears to " +
|
|
209
|
+
"encrypt normally.", { cause: err });
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* FAIL CLOSED ON A DEGENERATE AGREEMENT — RFC 7748 §6.1.
|
|
213
|
+
*
|
|
214
|
+
* A small-order peer point drives the shared secret to all zeros. Both sides would then derive the
|
|
215
|
+
* same key, every message would encrypt and decrypt correctly, and whoever supplied the point
|
|
216
|
+
* would hold the key. There is no symptom to notice.
|
|
217
|
+
*
|
|
218
|
+
* ⚠️ THIS BRANCH IS UNREACHABLE TODAY, and saying so is the point. `@noble/curves` already refuses
|
|
219
|
+
* — `getSharedSecret` throws *"invalid private or public key received"* before control arrives
|
|
220
|
+
* here — which the revert test proved: deleting this check left every test green. So this is a
|
|
221
|
+
* BACKSTOP against that dependency behaviour changing, not the thing currently providing the
|
|
222
|
+
* property, and an earlier version of this comment claimed otherwise.
|
|
223
|
+
*
|
|
224
|
+
* It is kept rather than deleted because the cost is one branch and the failure it guards has no
|
|
225
|
+
* symptom. The test pins the PROPERTY (a degenerate agreement is refused) rather than which layer
|
|
226
|
+
* refuses, so it keeps its teeth either way — and would catch a `@noble` upgrade that stopped
|
|
227
|
+
* rejecting.
|
|
228
|
+
*/
|
|
229
|
+
if (isAllZero(shared)) {
|
|
230
|
+
throw new Error("KEYAGREE: degenerate X25519 agreement — the shared secret is all zeros, which means the peer " +
|
|
231
|
+
"supplied a small-order point (RFC 7748 §6.1). Refusing: deriving from it would produce a key " +
|
|
232
|
+
"the attacker who sent that point also knows, while every message appeared to encrypt normally.");
|
|
233
|
+
}
|
|
234
|
+
const ownPublic = x25519.getPublicKey(opts.ownEphemeralSecret);
|
|
235
|
+
/**
|
|
236
|
+
* REFLECTION — the peer sent back our own ephemeral public. Standard hygiene: it is not a
|
|
237
|
+
* key-recovery attack against X25519, but it means the "peer" contributed nothing to the
|
|
238
|
+
* agreement, and both sorted halves would be identical. One line, refused.
|
|
239
|
+
*/
|
|
240
|
+
if (Buffer.compare(Buffer.from(ownPublic), Buffer.from(opts.peerEphemeralPublic)) === 0) {
|
|
241
|
+
throw new Error("KEYAGREE: the peer's ephemeral public key is identical to our own — the peer contributed " +
|
|
242
|
+
"nothing to the agreement. Refusing: this is a reflection, not a handshake.");
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Both public keys bound into `info`, in CANONICAL (sorted) order.
|
|
246
|
+
*
|
|
247
|
+
* Sorted rather than by role, because the two daemons reach this point from different code paths
|
|
248
|
+
* and a disagreement about who "initiated" would produce two different keys — a conversation that
|
|
249
|
+
* fails to decrypt with nothing anywhere explaining why. Binding both also ties the derivation to
|
|
250
|
+
* this exact pair of ephemerals, so a key from one handshake cannot be replayed into another.
|
|
251
|
+
*/
|
|
252
|
+
const [first, second] = lexLess(ownPublic, opts.peerEphemeralPublic)
|
|
253
|
+
? [ownPublic, opts.peerEphemeralPublic]
|
|
254
|
+
: [opts.peerEphemeralPublic, ownPublic];
|
|
255
|
+
const extra = opts.extraSharedSecret ?? new Uint8Array(0);
|
|
256
|
+
const ikm = new Uint8Array(shared.length + extra.length);
|
|
257
|
+
ikm.set(shared, 0);
|
|
258
|
+
ikm.set(extra, shared.length);
|
|
259
|
+
const transcript = opts.pqTranscript ?? new Uint8Array(0);
|
|
260
|
+
const info = (label) => {
|
|
261
|
+
const out = new Uint8Array(label.length + first.length + second.length + transcript.length);
|
|
262
|
+
out.set(label, 0);
|
|
263
|
+
out.set(first, label.length);
|
|
264
|
+
out.set(second, label.length + first.length);
|
|
265
|
+
// TRAILING, so the label remains recoverable as info[0 : len-64-|transcript|] for a caller that
|
|
266
|
+
// knows the transcript length. It is empty today; when a hybrid fills it, both sides supply the
|
|
267
|
+
// same bytes or they diverge — which is the safe direction.
|
|
268
|
+
out.set(transcript, label.length + first.length + second.length);
|
|
269
|
+
return out;
|
|
270
|
+
};
|
|
271
|
+
return { contentKey: hkdf(sha256, ikm, opts.sessionId, info(INFO_CONTENT_KEY), SESSION_KEY_BYTES) };
|
|
272
|
+
}
|
|
273
|
+
//# 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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"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SESSION SALT — Decisions Carried #8, #9, #10 (Andre, 2026-08-23).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ AGREED, STORED, AND VERIFIABLE — BUT NO SENDER SALTS YET. Two revisions of this header have
|
|
5
|
+
* now been overtaken, so it states the boundary precisely rather than a slogan:
|
|
6
|
+
*
|
|
7
|
+
* `session-salt-agreement.ts` calls `deriveSessionSalt` and `saltFingerprint`; `content_salt` has
|
|
8
|
+
* a writer and a reader; and `wire-content-hash.ts` DOES import `saltedContentHash` — the RECEIVE
|
|
9
|
+
* path will verify a frame that names `hmac-sha256-salt-v1` (`DOD-M15-SEALWIRE-1` part B1).
|
|
10
|
+
*
|
|
11
|
+
* What no code in this build does is SEND one. Every send site still calls `wireContentHash`, so
|
|
12
|
+
* no message's hash depends on a salt yet, and part B2 is what flips that.
|
|
13
|
+
*
|
|
14
|
+
* The distinction matters to anyone auditing reachability: the salted path is live inbound and dead
|
|
15
|
+
* outbound. A reader who takes "nothing hashes with it" from an older revision of this header would
|
|
16
|
+
* wrongly conclude the salted branch cannot execute in production at all.
|
|
17
|
+
*
|
|
18
|
+
* ─── Why this is a separate file from the key agreement ────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* It used to be a second output of `deriveSessionSecrets`, and that was the defect. The envelope key
|
|
21
|
+
* and the salt are unrelated goals that merely both need a shared secret:
|
|
22
|
+
*
|
|
23
|
+
* the **envelope key** stops the relay reading messages in flight, and MUST be destroyed at close;
|
|
24
|
+
* the **session salt** stops anyone holding stored hashes from confirming a guessed message, and
|
|
25
|
+
* MUST survive for the life of the session.
|
|
26
|
+
*
|
|
27
|
+
* **Deriving both from one secret tied "must be forgotten" to "must be kept forever."** Every
|
|
28
|
+
* consequence that flowed from it — salt epochs, per-leaf epoch attribution, lockstep switching —
|
|
29
|
+
* was a symptom of that coupling, not a real requirement. Keeping the MOMENT (one exchange) and
|
|
30
|
+
* dropping the DERIVATION removes all of them.
|
|
31
|
+
*
|
|
32
|
+
* ─── What it defends against, and it is live today ─────────────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* `wireContentHash` is `SHA-256(0x00 ‖ content)` with nothing session-specific in it. So the same
|
|
35
|
+
* message text is the same 32 bytes in **every conversation, between every pair of agents, forever**
|
|
36
|
+
* — a relay can correlate one message across sessions and agent pairs, and can build a table of
|
|
37
|
+
* common short messages once and read it everywhere (`DOD-M15-HASHCORRELATE-1`).
|
|
38
|
+
*
|
|
39
|
+
* Chaining would not fix it: the adversary holds the previous hash, so they would compute
|
|
40
|
+
* `hash(previous ‖ guess)`. Chaining hides repeats; it does not hide content. A secret per-session
|
|
41
|
+
* salt does.
|
|
42
|
+
*
|
|
43
|
+
* ─── BOTH SIDES CONTRIBUTE, and why that is a requirement rather than a nicety ─────────────────
|
|
44
|
+
*
|
|
45
|
+
* Not initiator-minted. The client is open source and an operator can modify their own build, so a
|
|
46
|
+
* single minter could unilaterally destroy the property **for both parties** — always send the same
|
|
47
|
+
* salt, or a low-entropy one — and every conversation that client has becomes guessable by any relay
|
|
48
|
+
* holding the hashes. The honest peer cannot detect it and never consented to it.
|
|
49
|
+
*
|
|
50
|
+
* Both-contribute means **one honest participant is enough**: if either side's contribution is
|
|
51
|
+
* unpredictable, the salt is. Each side can also verify its own contribution was actually used.
|
|
52
|
+
* Same principle as the sovereign-node rule — no single party can unilaterally break a guarantee.
|
|
53
|
+
*
|
|
54
|
+
* (The envelope key already has this property structurally: X25519 ephemeral-ephemeral combines both
|
|
55
|
+
* secrets, and `session-key-agreement.ts` refuses the small-order point that is the one way a peer
|
|
56
|
+
* could force a degenerate result. Same guarantee, different mechanism.)
|
|
57
|
+
*
|
|
58
|
+
* ─── 🚨 THE SECRECY HERE IS THE CHANNEL'S, NOT THE CONSTRUCTION'S ──────────────────────────────
|
|
59
|
+
*
|
|
60
|
+
* The single most important thing about this module, and it does NOT resemble the envelope key.
|
|
61
|
+
*
|
|
62
|
+
* The key is a DH secret: a passive relay holding both public keys cannot compute it, and
|
|
63
|
+
* `dod-m15-keyagree-1.test.ts` pins exactly that. **The salt is a function of two values that are
|
|
64
|
+
* SENT.** Anyone who can read both contributions derives it with the same public HKDF label. There
|
|
65
|
+
* is no "a third party cannot derive it" test in this module, and there cannot be one.
|
|
66
|
+
*
|
|
67
|
+
* So "one honest participant is enough" carries a precondition that must never be dropped:
|
|
68
|
+
* unpredictable **to anyone who cannot read the exchange**.
|
|
69
|
+
*
|
|
70
|
+
* ─── THEREFORE, a rule for `DOD-M15-SEALWIRE-1`, which owns the wire ──────────────────────────
|
|
71
|
+
*
|
|
72
|
+
* The contributions MUST be exchanged on the peer-to-peer `/cello/content/1.0.0` stream. It rides
|
|
73
|
+
* circuit-relay-v2 carrying its own Noise session, so a relay forwarding it sees ciphertext.
|
|
74
|
+
*
|
|
75
|
+
* They MUST NEVER appear in `session_offer` / `session_offer_accept`, or anything else a
|
|
76
|
+
* DIRECTORY brokers. **That is the trap:** today the ONLY round trip at session open runs on the
|
|
77
|
+
* directory's signaling stream (`session-ceremony.ts`), so it is the natural, obvious place to put
|
|
78
|
+
* a contribution — one round trip, at open, before any leaf — and it is precisely the channel
|
|
79
|
+
* Decision #8 forbids. Nothing about this function protects the salt if that rule is broken, and
|
|
80
|
+
* a session that shipped that way cannot be repaired afterwards: the relay already holds the salt
|
|
81
|
+
* and the hashes.
|
|
82
|
+
*
|
|
83
|
+
* Residual, and it now covers TWO values rather than one: the contributions are unauthenticated,
|
|
84
|
+
* exactly like the ephemerals, so an ACTIVE on-path attacker who substitutes both sides defeats the
|
|
85
|
+
* salt as well as the key. That is `DOD-M15-EPHEMERAL-AUTH-1`.
|
|
86
|
+
*/
|
|
87
|
+
/** Contributions and the salt are all 32 bytes. */
|
|
88
|
+
export declare const SALT_CONTRIBUTION_BYTES = 32;
|
|
89
|
+
export declare const SESSION_SALT_BYTES = 32;
|
|
90
|
+
/** Enough to detect a disagreement; short enough to be obviously not the salt. */
|
|
91
|
+
export declare const SALT_FINGERPRINT_BYTES = 8;
|
|
92
|
+
/** This side's random contribution. Fresh per session; sent to the peer. */
|
|
93
|
+
export declare function generateSaltContribution(): Uint8Array;
|
|
94
|
+
/**
|
|
95
|
+
* Combine the two contributions into the session salt.
|
|
96
|
+
*
|
|
97
|
+
* CANONICAL ORDER (lexicographic), so both sides compute identical bytes without agreeing on who
|
|
98
|
+
* initiated. The two daemons reach this from different code paths; ordering by role would mean a
|
|
99
|
+
* disagreement about "who started it" produced two different salts — and a salt disagreement is the
|
|
100
|
+
* least debuggable failure in this system, because the send succeeds and the receiver discards.
|
|
101
|
+
*/
|
|
102
|
+
export declare function deriveSessionSalt(ourContribution: Uint8Array, peerContribution: Uint8Array): Uint8Array;
|
|
103
|
+
/**
|
|
104
|
+
* A short, one-way fingerprint of the salt, for the agreement check at session open — Decision #10.
|
|
105
|
+
*
|
|
106
|
+
* **The salt itself is never compared on the wire.** A fingerprint is derived under its own HKDF
|
|
107
|
+
* label, so it cannot be worked back to the salt, and it is deliberately short so nobody mistakes it
|
|
108
|
+
* for key material.
|
|
109
|
+
*
|
|
110
|
+
* Why compare at all: a salt disagreement makes every message fail the receive-path authenticity
|
|
111
|
+
* check, and `wire-content-hash.ts`'s own header calls that the least debuggable shape there is —
|
|
112
|
+
* the send succeeds, `parked: false`, the sender's log says the frame left, and the receiver discards
|
|
113
|
+
* before anything is logged about it. It cost two real daemons to find once. Refusing the session at
|
|
114
|
+
* open, with a named reason, is the difference between a diagnosis and a week.
|
|
115
|
+
*/
|
|
116
|
+
export declare function saltFingerprint(salt: Uint8Array): Uint8Array;
|
|
117
|
+
/**
|
|
118
|
+
* The salted content hash — Decision #9.
|
|
119
|
+
*
|
|
120
|
+
* **HMAC, not `SHA-256(salt ‖ content)`.** The naive concatenation is vulnerable to length
|
|
121
|
+
* extension: an attacker who holds `H(salt ‖ m)` and knows `|salt|` can compute `H(salt ‖ m ‖ pad ‖
|
|
122
|
+
* m')` without knowing the salt. HMAC is the standard construction for exactly this and costs
|
|
123
|
+
* nothing extra here.
|
|
124
|
+
*
|
|
125
|
+
* The `0x00` domain byte is retained inside the message so this stays domain-separated from any
|
|
126
|
+
* other HMAC over the same key, and so the unsalted and salted forms can never collide.
|
|
127
|
+
*/
|
|
128
|
+
export declare function saltedContentHash(salt: Uint8Array, content: Uint8Array): Uint8Array;
|
|
129
|
+
//# sourceMappingURL=session-salt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-salt.d.ts","sourceRoot":"","sources":["../src/session-salt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqFG;AAOH,mDAAmD;AACnD,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAC1C,eAAO,MAAM,kBAAkB,KAAK,CAAC;AACrC,kFAAkF;AAClF,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAMxC,4EAA4E;AAC5E,wBAAgB,wBAAwB,IAAI,UAAU,CAErD;AAQD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,eAAe,EAAE,UAAU,EAAE,gBAAgB,EAAE,UAAU,GAAG,UAAU,CAkEvG;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,UAAU,GAAG,UAAU,CAE5D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,UAAU,GAAG,UAAU,CAKnF"}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SESSION SALT — Decisions Carried #8, #9, #10 (Andre, 2026-08-23).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ AGREED, STORED, AND VERIFIABLE — BUT NO SENDER SALTS YET. Two revisions of this header have
|
|
5
|
+
* now been overtaken, so it states the boundary precisely rather than a slogan:
|
|
6
|
+
*
|
|
7
|
+
* `session-salt-agreement.ts` calls `deriveSessionSalt` and `saltFingerprint`; `content_salt` has
|
|
8
|
+
* a writer and a reader; and `wire-content-hash.ts` DOES import `saltedContentHash` — the RECEIVE
|
|
9
|
+
* path will verify a frame that names `hmac-sha256-salt-v1` (`DOD-M15-SEALWIRE-1` part B1).
|
|
10
|
+
*
|
|
11
|
+
* What no code in this build does is SEND one. Every send site still calls `wireContentHash`, so
|
|
12
|
+
* no message's hash depends on a salt yet, and part B2 is what flips that.
|
|
13
|
+
*
|
|
14
|
+
* The distinction matters to anyone auditing reachability: the salted path is live inbound and dead
|
|
15
|
+
* outbound. A reader who takes "nothing hashes with it" from an older revision of this header would
|
|
16
|
+
* wrongly conclude the salted branch cannot execute in production at all.
|
|
17
|
+
*
|
|
18
|
+
* ─── Why this is a separate file from the key agreement ────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* It used to be a second output of `deriveSessionSecrets`, and that was the defect. The envelope key
|
|
21
|
+
* and the salt are unrelated goals that merely both need a shared secret:
|
|
22
|
+
*
|
|
23
|
+
* the **envelope key** stops the relay reading messages in flight, and MUST be destroyed at close;
|
|
24
|
+
* the **session salt** stops anyone holding stored hashes from confirming a guessed message, and
|
|
25
|
+
* MUST survive for the life of the session.
|
|
26
|
+
*
|
|
27
|
+
* **Deriving both from one secret tied "must be forgotten" to "must be kept forever."** Every
|
|
28
|
+
* consequence that flowed from it — salt epochs, per-leaf epoch attribution, lockstep switching —
|
|
29
|
+
* was a symptom of that coupling, not a real requirement. Keeping the MOMENT (one exchange) and
|
|
30
|
+
* dropping the DERIVATION removes all of them.
|
|
31
|
+
*
|
|
32
|
+
* ─── What it defends against, and it is live today ─────────────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* `wireContentHash` is `SHA-256(0x00 ‖ content)` with nothing session-specific in it. So the same
|
|
35
|
+
* message text is the same 32 bytes in **every conversation, between every pair of agents, forever**
|
|
36
|
+
* — a relay can correlate one message across sessions and agent pairs, and can build a table of
|
|
37
|
+
* common short messages once and read it everywhere (`DOD-M15-HASHCORRELATE-1`).
|
|
38
|
+
*
|
|
39
|
+
* Chaining would not fix it: the adversary holds the previous hash, so they would compute
|
|
40
|
+
* `hash(previous ‖ guess)`. Chaining hides repeats; it does not hide content. A secret per-session
|
|
41
|
+
* salt does.
|
|
42
|
+
*
|
|
43
|
+
* ─── BOTH SIDES CONTRIBUTE, and why that is a requirement rather than a nicety ─────────────────
|
|
44
|
+
*
|
|
45
|
+
* Not initiator-minted. The client is open source and an operator can modify their own build, so a
|
|
46
|
+
* single minter could unilaterally destroy the property **for both parties** — always send the same
|
|
47
|
+
* salt, or a low-entropy one — and every conversation that client has becomes guessable by any relay
|
|
48
|
+
* holding the hashes. The honest peer cannot detect it and never consented to it.
|
|
49
|
+
*
|
|
50
|
+
* Both-contribute means **one honest participant is enough**: if either side's contribution is
|
|
51
|
+
* unpredictable, the salt is. Each side can also verify its own contribution was actually used.
|
|
52
|
+
* Same principle as the sovereign-node rule — no single party can unilaterally break a guarantee.
|
|
53
|
+
*
|
|
54
|
+
* (The envelope key already has this property structurally: X25519 ephemeral-ephemeral combines both
|
|
55
|
+
* secrets, and `session-key-agreement.ts` refuses the small-order point that is the one way a peer
|
|
56
|
+
* could force a degenerate result. Same guarantee, different mechanism.)
|
|
57
|
+
*
|
|
58
|
+
* ─── 🚨 THE SECRECY HERE IS THE CHANNEL'S, NOT THE CONSTRUCTION'S ──────────────────────────────
|
|
59
|
+
*
|
|
60
|
+
* The single most important thing about this module, and it does NOT resemble the envelope key.
|
|
61
|
+
*
|
|
62
|
+
* The key is a DH secret: a passive relay holding both public keys cannot compute it, and
|
|
63
|
+
* `dod-m15-keyagree-1.test.ts` pins exactly that. **The salt is a function of two values that are
|
|
64
|
+
* SENT.** Anyone who can read both contributions derives it with the same public HKDF label. There
|
|
65
|
+
* is no "a third party cannot derive it" test in this module, and there cannot be one.
|
|
66
|
+
*
|
|
67
|
+
* So "one honest participant is enough" carries a precondition that must never be dropped:
|
|
68
|
+
* unpredictable **to anyone who cannot read the exchange**.
|
|
69
|
+
*
|
|
70
|
+
* ─── THEREFORE, a rule for `DOD-M15-SEALWIRE-1`, which owns the wire ──────────────────────────
|
|
71
|
+
*
|
|
72
|
+
* The contributions MUST be exchanged on the peer-to-peer `/cello/content/1.0.0` stream. It rides
|
|
73
|
+
* circuit-relay-v2 carrying its own Noise session, so a relay forwarding it sees ciphertext.
|
|
74
|
+
*
|
|
75
|
+
* They MUST NEVER appear in `session_offer` / `session_offer_accept`, or anything else a
|
|
76
|
+
* DIRECTORY brokers. **That is the trap:** today the ONLY round trip at session open runs on the
|
|
77
|
+
* directory's signaling stream (`session-ceremony.ts`), so it is the natural, obvious place to put
|
|
78
|
+
* a contribution — one round trip, at open, before any leaf — and it is precisely the channel
|
|
79
|
+
* Decision #8 forbids. Nothing about this function protects the salt if that rule is broken, and
|
|
80
|
+
* a session that shipped that way cannot be repaired afterwards: the relay already holds the salt
|
|
81
|
+
* and the hashes.
|
|
82
|
+
*
|
|
83
|
+
* Residual, and it now covers TWO values rather than one: the contributions are unauthenticated,
|
|
84
|
+
* exactly like the ephemerals, so an ACTIVE on-path attacker who substitutes both sides defeats the
|
|
85
|
+
* salt as well as the key. That is `DOD-M15-EPHEMERAL-AUTH-1`.
|
|
86
|
+
*/
|
|
87
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
88
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
89
|
+
import { hmac } from "@noble/hashes/hmac.js";
|
|
90
|
+
import { randomBytes } from "node:crypto";
|
|
91
|
+
/** Contributions and the salt are all 32 bytes. */
|
|
92
|
+
export const SALT_CONTRIBUTION_BYTES = 32;
|
|
93
|
+
export const SESSION_SALT_BYTES = 32;
|
|
94
|
+
/** Enough to detect a disagreement; short enough to be obviously not the salt. */
|
|
95
|
+
export const SALT_FINGERPRINT_BYTES = 8;
|
|
96
|
+
const ENC = new TextEncoder();
|
|
97
|
+
const INFO_SESSION_SALT = ENC.encode("cello/session/v1/salt");
|
|
98
|
+
const INFO_SALT_FINGERPRINT = ENC.encode("cello/session/v1/salt-fingerprint");
|
|
99
|
+
/** This side's random contribution. Fresh per session; sent to the peer. */
|
|
100
|
+
export function generateSaltContribution() {
|
|
101
|
+
return new Uint8Array(randomBytes(SALT_CONTRIBUTION_BYTES));
|
|
102
|
+
}
|
|
103
|
+
function isAllZero(b) {
|
|
104
|
+
let acc = 0;
|
|
105
|
+
for (const x of b)
|
|
106
|
+
acc |= x;
|
|
107
|
+
return acc === 0;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Combine the two contributions into the session salt.
|
|
111
|
+
*
|
|
112
|
+
* CANONICAL ORDER (lexicographic), so both sides compute identical bytes without agreeing on who
|
|
113
|
+
* initiated. The two daemons reach this from different code paths; ordering by role would mean a
|
|
114
|
+
* disagreement about "who started it" produced two different salts — and a salt disagreement is the
|
|
115
|
+
* least debuggable failure in this system, because the send succeeds and the receiver discards.
|
|
116
|
+
*/
|
|
117
|
+
export function deriveSessionSalt(ourContribution, peerContribution) {
|
|
118
|
+
/**
|
|
119
|
+
* REFUSE A DEGENERATE PEER CONTRIBUTION — the same posture the key agreement takes toward a
|
|
120
|
+
* small-order point, and for the same reason: a peer that contributes nothing has unilaterally
|
|
121
|
+
* decided the salt, which is exactly the property both-contribute exists to prevent.
|
|
122
|
+
*/
|
|
123
|
+
if (peerContribution.length !== SALT_CONTRIBUTION_BYTES) {
|
|
124
|
+
throw new Error(`SESSION SALT: the peer's salt contribution must be ${SALT_CONTRIBUTION_BYTES} bytes, got ` +
|
|
125
|
+
`${peerContribution.length}. Refusing rather than padding — a short contribution silently ` +
|
|
126
|
+
"zero-extended is a salt this side did not really help choose.");
|
|
127
|
+
}
|
|
128
|
+
if (isAllZero(peerContribution)) {
|
|
129
|
+
throw new Error("SESSION SALT: the peer's salt contribution is all zeros, which means it contributed nothing " +
|
|
130
|
+
"and the salt would be decided by one side alone. That is the property both-contribute exists " +
|
|
131
|
+
"to prevent: a modified client could then make every one of its conversations guessable by any " +
|
|
132
|
+
"relay holding the hashes, without its peer being able to tell. Refusing.");
|
|
133
|
+
}
|
|
134
|
+
if (ourContribution.length !== SALT_CONTRIBUTION_BYTES) {
|
|
135
|
+
throw new Error(`SESSION SALT: our own salt contribution must be ${SALT_CONTRIBUTION_BYTES} bytes, got ` +
|
|
136
|
+
`${ourContribution.length}. This is a local defect, not something the peer did.`);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* OUR OWN all-zero contribution is refused too — review F6, and the asymmetry mattered.
|
|
140
|
+
*
|
|
141
|
+
* Without this, a daemon with a broken or patched RNG derives happily while the PEER refuses with
|
|
142
|
+
* *"the peer's salt contribution is all zeros"* — so the operator whose machine is actually broken
|
|
143
|
+
* reads a failure that blames their counterparty. `session-key-agreement.ts` already established
|
|
144
|
+
* the symmetric pattern for lengths ("this is a local defect, not something the peer did"); this
|
|
145
|
+
* file had it for length and not for zero.
|
|
146
|
+
*/
|
|
147
|
+
if (isAllZero(ourContribution)) {
|
|
148
|
+
throw new Error("SESSION SALT: our OWN salt contribution is all zeros, so this side contributed nothing. This " +
|
|
149
|
+
"is a LOCAL defect — a broken or patched random source — not something the peer did. Refusing " +
|
|
150
|
+
"rather than deriving a salt one side chose alone.");
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A REFLECTED contribution — the peer echoing ours back — is refused, matching
|
|
154
|
+
* `session-key-agreement.ts`'s reflection check. Confidentiality survives (our half is random), so
|
|
155
|
+
* this is hygiene rather than a break; but the module claims each side can verify its contribution
|
|
156
|
+
* was used, and nothing was verifying the PEER contributed at all.
|
|
157
|
+
*/
|
|
158
|
+
if (Buffer.compare(Buffer.from(ourContribution), Buffer.from(peerContribution)) === 0) {
|
|
159
|
+
throw new Error("SESSION SALT: the peer's salt contribution is identical to our own — the peer contributed " +
|
|
160
|
+
"nothing to the agreement. Refusing: this is a reflection, not an exchange.");
|
|
161
|
+
}
|
|
162
|
+
const [first, second] = Buffer.compare(Buffer.from(ourContribution), Buffer.from(peerContribution)) < 0
|
|
163
|
+
? [ourContribution, peerContribution]
|
|
164
|
+
: [peerContribution, ourContribution];
|
|
165
|
+
const ikm = new Uint8Array(first.length + second.length);
|
|
166
|
+
ikm.set(first, 0);
|
|
167
|
+
ikm.set(second, first.length);
|
|
168
|
+
// No salt argument to HKDF here: the session id is not needed to separate sessions, because the
|
|
169
|
+
// contributions are fresh random per session and already do it.
|
|
170
|
+
return hkdf(sha256, ikm, new Uint8Array(0), INFO_SESSION_SALT, SESSION_SALT_BYTES);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* A short, one-way fingerprint of the salt, for the agreement check at session open — Decision #10.
|
|
174
|
+
*
|
|
175
|
+
* **The salt itself is never compared on the wire.** A fingerprint is derived under its own HKDF
|
|
176
|
+
* label, so it cannot be worked back to the salt, and it is deliberately short so nobody mistakes it
|
|
177
|
+
* for key material.
|
|
178
|
+
*
|
|
179
|
+
* Why compare at all: a salt disagreement makes every message fail the receive-path authenticity
|
|
180
|
+
* check, and `wire-content-hash.ts`'s own header calls that the least debuggable shape there is —
|
|
181
|
+
* the send succeeds, `parked: false`, the sender's log says the frame left, and the receiver discards
|
|
182
|
+
* before anything is logged about it. It cost two real daemons to find once. Refusing the session at
|
|
183
|
+
* open, with a named reason, is the difference between a diagnosis and a week.
|
|
184
|
+
*/
|
|
185
|
+
export function saltFingerprint(salt) {
|
|
186
|
+
return hkdf(sha256, salt, new Uint8Array(0), INFO_SALT_FINGERPRINT, SALT_FINGERPRINT_BYTES);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The salted content hash — Decision #9.
|
|
190
|
+
*
|
|
191
|
+
* **HMAC, not `SHA-256(salt ‖ content)`.** The naive concatenation is vulnerable to length
|
|
192
|
+
* extension: an attacker who holds `H(salt ‖ m)` and knows `|salt|` can compute `H(salt ‖ m ‖ pad ‖
|
|
193
|
+
* m')` without knowing the salt. HMAC is the standard construction for exactly this and costs
|
|
194
|
+
* nothing extra here.
|
|
195
|
+
*
|
|
196
|
+
* The `0x00` domain byte is retained inside the message so this stays domain-separated from any
|
|
197
|
+
* other HMAC over the same key, and so the unsalted and salted forms can never collide.
|
|
198
|
+
*/
|
|
199
|
+
export function saltedContentHash(salt, content) {
|
|
200
|
+
const msg = new Uint8Array(1 + content.length);
|
|
201
|
+
msg[0] = 0x00;
|
|
202
|
+
msg.set(content, 1);
|
|
203
|
+
return hmac(sha256, salt, msg);
|
|
204
|
+
}
|
|
205
|
+
//# sourceMappingURL=session-salt.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-salt.js","sourceRoot":"","sources":["../src/session-salt.ts"],"names":[],"mappings":"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"}
|