@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 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";
@@ -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;AAG7F,YAAY,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,wBAAwB,EAAE,0BAA0B,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AACpK,OAAO,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEtE,YAAY,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,sBAAsB,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACxI,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;AAC9B,YAAY,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAC3F,OAAO,EAAE,gBAAgB,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAC;AAG3F,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAC9F,YAAY,EAAE,gBAAgB,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;AAI7F,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"}
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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cello-protocol/crypto",
3
- "version": "0.0.58",
3
+ "version": "0.0.59",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {