@oxpulse/intro-protocol 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Briar-faithful crypto primitives for the L2 introduction protocol.
3
+ *
4
+ * All functions are pure — no side-effects beyond the returned values.
5
+ * Implements sub-spec §4.2 (labels, key derivation, MAC/sig, AEAD).
6
+ *
7
+ * Label encoding: UTF-8 at use-site, no NUL terminators (architect nit #2).
8
+ * MAC comparison: always constant-time via timingSafeEqual (W4).
9
+ *
10
+ * ADR-010: This module is part of the @oxpulse/intro-protocol bounded
11
+ * context (intro-crypto + intro-wire + intro-safety-number in ONE package).
12
+ *
13
+ * ADR-008: timingSafeEqual + timingSafePubkeyEqualB64u are imported from
14
+ * @oxpulse/crypto-primitives (the single public source of truth for
15
+ * constant-time comparison). concatBytes + utf8 stay local here — they are
16
+ * internal general-purpose helpers, not part of the crypto-primitives
17
+ * public surface.
18
+ */
19
+ import { ed25519 } from '@noble/curves/ed25519.js';
20
+ import { sha256 } from '@noble/hashes/sha2.js';
21
+ import { hmac } from '@noble/hashes/hmac.js';
22
+ import { hkdf, expand } from '@noble/hashes/hkdf.js';
23
+ import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
24
+ import { randomBytes } from '@noble/ciphers/utils.js';
25
+ import { encode as cborgEncode, rfc8949EncodeOptions } from 'cborg';
26
+ // Constant-time comparison — single source of truth (ADR-008).
27
+ // deriveSharedSecret — RFC 7748 §6.1 all-zero-shared-secret defense (low-order point guard).
28
+ // b64uEncodeBytes / b64uDecodeBytes — single canonical base64url home (ADR-013 / #218 nit #11).
29
+ import { timingSafeEqual, deriveSharedSecret, b64uEncodeBytes, b64uDecodeBytes, } from '@oxpulse/crypto-primitives';
30
+ // ---------------------------------------------------------------------------
31
+ // Label constants (sub-spec §4.2) — UTF-8 encoded at use-site.
32
+ // ---------------------------------------------------------------------------
33
+ export const LABEL_SESSION_ID = 'oxpulse-intro-v1/SESSION_ID';
34
+ export const LABEL_MASTER_KEY = 'oxpulse-intro-v1/MASTER_KEY';
35
+ export const LABEL_ALICE_MAC_KEY = 'oxpulse-intro-v1/ALICE_MAC_KEY';
36
+ export const LABEL_BOB_MAC_KEY = 'oxpulse-intro-v1/BOB_MAC_KEY';
37
+ export const LABEL_AUTH_MAC = 'oxpulse-intro-v1/AUTH_MAC';
38
+ export const LABEL_AUTH_NONCE = 'oxpulse-intro-v1/AUTH_NONCE';
39
+ export const LABEL_AUTH_SIGN = 'oxpulse-intro-v1/AUTH_SIGN';
40
+ export const LABEL_ACTIVATE_MAC = 'oxpulse-intro-v1/ACTIVATE_MAC';
41
+ export const PROTOCOL_VERSION = 0x01;
42
+ // ---------------------------------------------------------------------------
43
+ // Internal helpers (ADR-008: concatBytes + utf8 stay local/general-purpose;
44
+ // only timingSafeEqual + timingSafePubkeyEqualB64u are public exports from
45
+ // crypto-primitives).
46
+ // ---------------------------------------------------------------------------
47
+ const enc = new TextEncoder();
48
+ /** Encode a LABEL_* string to UTF-8 bytes. */
49
+ function utf8(label) {
50
+ return enc.encode(label);
51
+ }
52
+ /** Concatenate multiple Uint8Arrays into one. */
53
+ function concatBytes(...arrays) {
54
+ let total = 0;
55
+ for (const a of arrays)
56
+ total += a.length;
57
+ const out = new Uint8Array(total);
58
+ let offset = 0;
59
+ for (const a of arrays) {
60
+ out.set(a, offset);
61
+ offset += a.length;
62
+ }
63
+ return out;
64
+ }
65
+ /**
66
+ * Best-effort zeroization of secret key material (ADR-013 / #216).
67
+ *
68
+ * Overwrites the buffer with zeros. JS zeroization is best-effort — V8 may
69
+ * copy Uint8Array contents during GC compaction, so this is NOT a guarantee
70
+ * that no copy survives — but it is the recognized hardening baseline and
71
+ * the SECURITY.md threat model already promises these values are secret.
72
+ *
73
+ * Callers MUST wipe:
74
+ * - ephemeral private keys after `deriveMasterKey`
75
+ * - `masterKey` when done with all derived MAC keys + AEAD operations
76
+ * - MAC keys via {@link wipeMacKeys} when the handshake completes/aborts
77
+ *
78
+ * Returns the same Uint8Array reference (now zeroed) for convenience.
79
+ */
80
+ export function wipe(u) {
81
+ u.fill(0);
82
+ return u;
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // base64url helpers — imported from @oxpulse/crypto-primitives (ADR-013 / #218 nit #11).
86
+ // Local copies removed; b64uEncodeBytes / b64uDecodeBytes are the canonical home.
87
+ // ---------------------------------------------------------------------------
88
+ // ---------------------------------------------------------------------------
89
+ // Session ID
90
+ // ---------------------------------------------------------------------------
91
+ /**
92
+ * Derives a deterministic 16-byte (22-char base64url) session ID.
93
+ *
94
+ * input = utf8(LABEL_SESSION_ID) || introducerPub || firstPub || secondPub
95
+ * result = SHA-256(input)[0..16] encoded as base64url
96
+ *
97
+ * All pubkeys must be 32-byte Ed25519 pubkeys (raw bytes, not base64url).
98
+ * Callers holding b64u strings decode first: `b64uDecodeBytes(b64uStr)`
99
+ * from `@oxpulse/crypto-primitives`.
100
+ *
101
+ * alicePub + bobPub are canonical-ordered internally via {@link isAliceRole}
102
+ * (lex-smaller pubkey first), matching {@link buildAuthTranscript}. This
103
+ * guarantees both parties produce identical sessionIds regardless of which
104
+ * side calls deriveSessionId(introducer, own, peer) — see ADR-013 / #217.
105
+ *
106
+ * @param introducerPub - Introducer long-term Ed25519 pubkey (32 bytes)
107
+ * @param alicePub - Alice ephemeral pubkey (32 bytes) — auto role-ordered
108
+ * @param bobPub - Bob ephemeral pubkey (32 bytes) — auto role-ordered
109
+ */
110
+ export function deriveSessionId(introducerPub, alicePub, bobPub) {
111
+ if (introducerPub.length !== 32 || alicePub.length !== 32 || bobPub.length !== 32) {
112
+ throw new Error('deriveSessionId: all pubkeys must be 32-byte Ed25519 pubkeys');
113
+ }
114
+ // Canonical-order alice/bob via isAliceRole (lex-smaller first), matching
115
+ // buildAuthTranscript. Without this, a receiver re-deriving sessionId for
116
+ // verifySessionIdRedundancy who passes (ownEphPub, peerEphPub) in the wrong
117
+ // order would get a different sessionId than the introducer placed on the
118
+ // wire, causing a spurious forgery alarm + protocol abort (DoS).
119
+ const [firstPub, secondPub] = isAliceRole(alicePub, bobPub)
120
+ ? [alicePub, bobPub]
121
+ : [bobPub, alicePub];
122
+ const input = concatBytes(utf8(LABEL_SESSION_ID), introducerPub, firstPub, secondPub);
123
+ const digest = sha256(input);
124
+ return b64uEncodeBytes(digest.slice(0, 16));
125
+ }
126
+ // ---------------------------------------------------------------------------
127
+ // Role assignment
128
+ // ---------------------------------------------------------------------------
129
+ /**
130
+ * Constant-time lexicographic compare of two Ed25519/X25519 pubkeys.
131
+ * Returns true if localPubkey < peerPubkey (Alice role).
132
+ * Returns false on equal (tiebreaker: peer is Alice, local is Bob).
133
+ */
134
+ export function isAliceRole(localPubkey, peerPubkey) {
135
+ const len = Math.min(localPubkey.length, peerPubkey.length);
136
+ // Walk all bytes to avoid timing leak on early-exit comparison.
137
+ // Result: first differing byte where local < peer → Alice; local >= peer → Bob.
138
+ let result = 0; // 0 = equal so far
139
+ let decided = 0; // 1 once first difference found
140
+ for (let i = 0; i < len; i++) {
141
+ const diff = (localPubkey[i] - peerPubkey[i]) | 0;
142
+ // If not decided and diff != 0, set result and mark decided.
143
+ const notDecided = 1 - decided;
144
+ const hasDiff = diff !== 0 ? 1 : 0;
145
+ const shouldDecide = notDecided & hasDiff;
146
+ result = (result & (1 - shouldDecide)) | (((diff >> 31) & 1) & shouldDecide);
147
+ decided = decided | shouldDecide;
148
+ }
149
+ return result === 1;
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // Master key derivation (X25519 + HKDF-SHA256)
153
+ // ---------------------------------------------------------------------------
154
+ /**
155
+ * Derives the shared master key from an ephemeral X25519 DH + HKDF.
156
+ *
157
+ * ikm = X25519(ownEphPriv, peerEphPub) — wiped before return (#216)
158
+ * salt = utf8(LABEL_MASTER_KEY)
159
+ * info = [PROTOCOL_VERSION] || aliceEphPub || bobEphPub
160
+ * return HKDF-SHA256(salt, ikm, info, 32)
161
+ *
162
+ * aliceEphPub + bobEphPub are role-ordered so both parties use identical info.
163
+ * The caller is responsible for wiping the returned masterKey (via {@link wipe})
164
+ * once all MAC keys are derived and AEAD operations complete, and for wiping
165
+ * the ownEphPriv argument after this call returns.
166
+ */
167
+ export function deriveMasterKey(ownEphPriv, peerEphPub, aliceEphPub, bobEphPub) {
168
+ const ikm = deriveSharedSecret(ownEphPriv, peerEphPub);
169
+ const salt = utf8(LABEL_MASTER_KEY);
170
+ const info = concatBytes(new Uint8Array([PROTOCOL_VERSION]), aliceEphPub, bobEphPub);
171
+ const masterKey = hkdf(sha256, ikm, salt, info, 32);
172
+ // Best-effort zeroize the raw DH shared secret — no longer needed after HKDF.
173
+ wipe(ikm);
174
+ return masterKey;
175
+ }
176
+ // ---------------------------------------------------------------------------
177
+ // MAC key derivation (HKDF-Expand only, RFC 5869 §2.3)
178
+ // ---------------------------------------------------------------------------
179
+ /**
180
+ * Derives alice and bob MAC keys from masterKey via HKDF-Expand-only.
181
+ *
182
+ * expand(masterKey, utf8(LABEL_ALICE_MAC_KEY), 32)
183
+ * expand(masterKey, utf8(LABEL_BOB_MAC_KEY), 32)
184
+ *
185
+ * Uses `expand` (HKDF-Expand) directly — masterKey is already a PRK.
186
+ */
187
+ export function deriveMacKeys(masterKey) {
188
+ const alice = expand(sha256, masterKey, utf8(LABEL_ALICE_MAC_KEY), 32);
189
+ const bob = expand(sha256, masterKey, utf8(LABEL_BOB_MAC_KEY), 32);
190
+ return { alice, bob };
191
+ }
192
+ /**
193
+ * Best-effort zeroize both MAC keys returned by {@link deriveMacKeys} (#216).
194
+ *
195
+ * Call when the handshake completes or aborts and the MAC keys are no longer
196
+ * needed. After this call, `keys.alice` and `keys.bob` are zeroed — further
197
+ * use will produce wrong results / throw, which is the intended fail-closed
198
+ * behaviour.
199
+ */
200
+ export function wipeMacKeys(keys) {
201
+ wipe(keys.alice);
202
+ wipe(keys.bob);
203
+ }
204
+ /**
205
+ * Builds the canonical CBOR auth transcript.
206
+ *
207
+ * canonicalCBOR([
208
+ * introducer.longTermPub,
209
+ * [alice.longTermPub, alice.acceptedAt, alice.ephPub, alice.transportProps],
210
+ * [bob.longTermPub, bob.acceptedAt, bob.ephPub, bob.transportProps],
211
+ * ])
212
+ *
213
+ * Parties are canonically ordered: the party whose longTermPubkey is
214
+ * lexicographically smaller (isAliceRole) is always listed first.
215
+ * This ensures both sides produce identical CBOR bytes regardless of
216
+ * which side calls buildAuthTranscript(introducer, own, peer).
217
+ */
218
+ export function buildAuthTranscript(introducerLongTermPub, ownSide, peerSide) {
219
+ // Defensive guard: two parties with the same long-term pubkey is a
220
+ // degenerate case that should never occur in a real introduction (two
221
+ // different people). Without this, isAliceRole returns false on equal
222
+ // pubkeys (tiebreaker: peer is Alice), so each side places the PEER first
223
+ // → transcripts diverge → MAC verify fails silently. Throw loudly instead.
224
+ if (timingSafeEqual(ownSide.longTermPubkey, peerSide.longTermPubkey)) {
225
+ throw new Error('buildAuthTranscript: both parties must have distinct long-term pubkeys');
226
+ }
227
+ // Determine canonical ordering: alice (lex-smaller pubkey) is always first.
228
+ const ownIsAlice = isAliceRole(ownSide.longTermPubkey, peerSide.longTermPubkey);
229
+ const [firstSide, secondSide] = ownIsAlice
230
+ ? [ownSide, peerSide]
231
+ : [peerSide, ownSide];
232
+ const value = [
233
+ introducerLongTermPub,
234
+ [firstSide.longTermPubkey, firstSide.acceptedAt, firstSide.ephPub, firstSide.transportProps],
235
+ [secondSide.longTermPubkey, secondSide.acceptedAt, secondSide.ephPub, secondSide.transportProps],
236
+ ];
237
+ return cborgEncode(value, rfc8949EncodeOptions);
238
+ }
239
+ // ---------------------------------------------------------------------------
240
+ // AUTH MAC
241
+ // ---------------------------------------------------------------------------
242
+ /**
243
+ * Computes HMAC-SHA256(ownMacKey, utf8(LABEL_AUTH_MAC) || sessionId || transcript).
244
+ *
245
+ * sessionId is mixed in explicitly (ADR-013 / #218 nit #7) so the binding to
246
+ * the introducer's announced sessionId survives future transcript-shape
247
+ * changes. Previously the binding was only transitive (sessionId inputs are
248
+ * also transcript fields); explicit binding is more robust against refactor.
249
+ */
250
+ export function computeAuthMac(ownMacKey, sessionId, transcript) {
251
+ return hmac(sha256, ownMacKey, concatBytes(utf8(LABEL_AUTH_MAC), sessionId, transcript));
252
+ }
253
+ /**
254
+ * Verifies the peer's auth MAC.
255
+ *
256
+ * Recomputes HMAC-SHA256(peerMacKey, utf8(LABEL_AUTH_MAC) || sessionId ||
257
+ * canonicalTranscript) and compares using constant-time equality.
258
+ *
259
+ * With canonical-ordering in buildAuthTranscript, both sides produce identical
260
+ * transcript bytes — the verifier passes its own locally-built transcript, which
261
+ * equals the sender's transcript exactly (the "swap" is identity by design).
262
+ */
263
+ export function verifyAuthMac(peerMacKey, sessionId, canonicalTranscript, receivedMac) {
264
+ const expected = hmac(sha256, peerMacKey, concatBytes(utf8(LABEL_AUTH_MAC), sessionId, canonicalTranscript));
265
+ return timingSafeEqual(expected, receivedMac);
266
+ }
267
+ // ---------------------------------------------------------------------------
268
+ // AUTH SIG (Ed25519 over HMAC-derived nonce)
269
+ // ---------------------------------------------------------------------------
270
+ /**
271
+ * Computes the auth signature.
272
+ *
273
+ * nonce = HMAC-SHA256(ownMacKey, utf8(LABEL_AUTH_NONCE) || sessionId || transcript)
274
+ * return Ed25519-sign(ownLongTermPriv, utf8(LABEL_AUTH_SIGN) || nonce)
275
+ *
276
+ * sessionId is mixed into the nonce explicitly (ADR-013 / #218 nit #7) for
277
+ * the same reason as in {@link computeAuthMac} — explicit binding survives
278
+ * future transcript-shape changes.
279
+ */
280
+ export function computeAuthSig(ownLongTermPriv, ownMacKey, sessionId, transcript) {
281
+ const nonce = hmac(sha256, ownMacKey, concatBytes(utf8(LABEL_AUTH_NONCE), sessionId, transcript));
282
+ const message = concatBytes(utf8(LABEL_AUTH_SIGN), nonce);
283
+ return ed25519.sign(message, ownLongTermPriv);
284
+ }
285
+ /**
286
+ * Verifies the peer's auth signature.
287
+ *
288
+ * nonce = HMAC-SHA256(peerMacKey, utf8(LABEL_AUTH_NONCE) || sessionId ||
289
+ * canonicalTranscript)
290
+ * return Ed25519-verify(peerLongTermPub, utf8(LABEL_AUTH_SIGN) || nonce, receivedSig)
291
+ *
292
+ * With canonical-ordering in buildAuthTranscript, both sides produce identical
293
+ * transcript bytes — pass the locally-built canonical transcript here.
294
+ */
295
+ export function verifyAuthSig(peerLongTermPub, peerMacKey, sessionId, canonicalTranscript, receivedSig) {
296
+ try {
297
+ const nonce = hmac(sha256, peerMacKey, concatBytes(utf8(LABEL_AUTH_NONCE), sessionId, canonicalTranscript));
298
+ const message = concatBytes(utf8(LABEL_AUTH_SIGN), nonce);
299
+ return ed25519.verify(receivedSig, message, peerLongTermPub);
300
+ }
301
+ catch {
302
+ return false;
303
+ }
304
+ }
305
+ // ---------------------------------------------------------------------------
306
+ // ACTIVATE MAC
307
+ // ---------------------------------------------------------------------------
308
+ /**
309
+ * Computes HMAC-SHA256(ownMacKey, utf8(LABEL_ACTIVATE_MAC) || sessionId).
310
+ */
311
+ export function computeActivateMac(ownMacKey, sessionId) {
312
+ return hmac(sha256, ownMacKey, concatBytes(utf8(LABEL_ACTIVATE_MAC), sessionId));
313
+ }
314
+ /**
315
+ * Verifies activate MAC using constant-time comparison.
316
+ */
317
+ export function verifyActivateMac(peerMacKey, sessionId, receivedMac) {
318
+ const expected = hmac(sha256, peerMacKey, concatBytes(utf8(LABEL_ACTIVATE_MAC), sessionId));
319
+ return timingSafeEqual(expected, receivedMac);
320
+ }
321
+ /**
322
+ * Encrypts plaintext with XChaCha20-Poly1305.
323
+ *
324
+ * nonce = random(24B)
325
+ * AAD = utf8(label) || sessionId
326
+ * cipher = XChaCha20-Poly1305(masterKey, nonce, AAD)
327
+ * return { nonce, ciphertext: cipher.encrypt(plaintext) }
328
+ */
329
+ export function sealAead(masterKey, label, sessionId, plaintext) {
330
+ const nonce = randomBytes(24);
331
+ const aad = concatBytes(utf8(label), sessionId);
332
+ const cipher = xchacha20poly1305(masterKey, nonce, aad);
333
+ const ciphertext = cipher.encrypt(plaintext);
334
+ return { nonce, ciphertext };
335
+ }
336
+ /**
337
+ * Decrypts an AEAD envelope. Throws on authentication tag mismatch.
338
+ *
339
+ * AAD = utf8(label) || sessionId (must match seal-time values)
340
+ * cipher = XChaCha20-Poly1305(masterKey, env.nonce, AAD)
341
+ * return cipher.decrypt(env.ciphertext)
342
+ */
343
+ export function openAead(masterKey, label, sessionId, env) {
344
+ const aad = concatBytes(utf8(label), sessionId);
345
+ const cipher = xchacha20poly1305(masterKey, env.nonce, aad);
346
+ return cipher.decrypt(env.ciphertext);
347
+ }
348
+ // ---------------------------------------------------------------------------
349
+ // AeadEnvelope ↔ wire-format bridge (#218 nit #8)
350
+ // ---------------------------------------------------------------------------
351
+ //
352
+ // The wire format encodes AEAD ciphertext as a single base64url string of
353
+ // `nonce(24B) ‖ ciphertext ‖ tag(16B)` (see intro-wire IntroAuthV1Schema).
354
+ // sealAead/openAead operate on the structured {nonce, ciphertext} form.
355
+ // These bridge helpers eliminate a class of caller bugs (wrong concat order,
356
+ // wrong split offset, mismatched b64u encode/decode).
357
+ /**
358
+ * Serialize an AeadEnvelope to the wire-format base64url string
359
+ * `b64u(nonce || ciphertext)`.
360
+ */
361
+ export function envelopeToWireB64u(env) {
362
+ return b64uEncodeBytes(concatBytes(env.nonce, env.ciphertext));
363
+ }
364
+ /**
365
+ * Parse a wire-format base64url string `b64u(nonce || ciphertext)` into an
366
+ * AeadEnvelope. Throws if the input is shorter than the 24-byte nonce.
367
+ *
368
+ * Note: uses `b64uDecodeBytes` from `@oxpulse/crypto-primitives` — for
369
+ * attacker-influenced inputs callers should pre-validate via the wire Zod
370
+ * schema, which constrains the charset to base64url.
371
+ */
372
+ export function wireB64uToEnvelope(s) {
373
+ const bytes = b64uDecodeBytes(s);
374
+ if (bytes.length < 24) {
375
+ throw new Error('wireB64uToEnvelope: input too short — must be >= 24-byte nonce');
376
+ }
377
+ return { nonce: bytes.subarray(0, 24), ciphertext: bytes.subarray(24) };
378
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Signal-style safety number derivation for the L2 introduction protocol.
3
+ *
4
+ * Per sub-spec §7.3 (Q3 resolution):
5
+ *
6
+ * fp_input = canonicalCBOR([min(alice, bob), max(alice, bob), masterKey])
7
+ * fp_hash = SHA-512(fp_input)[0..30] // 30 bytes
8
+ * fp_dec = bytesToDecimalString(fp_hash) // 60 digits exactly
9
+ *
10
+ * Implementation: 30-byte SHA-512 slice → big-endian BigInt → decimal string,
11
+ * left-zero-padded to 72 digits, take leftmost 60, grouped as 12 × 5.
12
+ *
13
+ * 2^240 ≈ 1.77 × 10^72 (up to 73 decimal digits). padStart(72) is a no-op
14
+ * for 73-digit values; slice(0, 60) always yields exactly 60 digits.
15
+ *
16
+ * Symmetric property: alice/bob inputs are canonically ordered (lex-smaller
17
+ * first) before CBOR encoding, so swap(alice,bob) → identical output.
18
+ */
19
+ /**
20
+ * Derives a Signal-style safety number for an introduction session.
21
+ *
22
+ * @param alicePub - Alice's long-term pubkey (32 bytes)
23
+ * @param bobPub - Bob's long-term pubkey (32 bytes)
24
+ * @param masterKey - Shared master key from DH (32 bytes)
25
+ * @returns 60-digit decimal string in 12 groups of 5 separated by spaces
26
+ *
27
+ * @example
28
+ * deriveSafetyNumber(alice, bob, masterKey)
29
+ * // → "12345 67890 12345 67890 12345 67890 12345 67890 12345 67890 12345 67890"
30
+ *
31
+ * SECURITY_COST-8 (SAS human comparison): The returned safety number is a
32
+ * Short Authentication String (SAS) intended to be read aloud or compared
33
+ * visually by the two human parties over an authenticated channel (in-person,
34
+ * phone call). It is NOT compared in code as part of a security decision —
35
+ * the comparison is performed by humans, who are inherently non-constant-time.
36
+ * Therefore no constant-time comparison is required or meaningful for the SAS
37
+ * value itself. The underlying deriveSafetyNumber computation is
38
+ * deterministic and side-channel-free (pure hash/CBOR, no secret-dependent
39
+ * branches on attacker-controlled input beyond the public pubkey ordering).
40
+ */
41
+ export declare function deriveSafetyNumber(alicePub: Uint8Array, bobPub: Uint8Array, masterKey: Uint8Array): string;
42
+ //# sourceMappingURL=intro-safety-number.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"intro-safety-number.d.ts","sourceRoot":"","sources":["../src/intro-safety-number.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAG,UAAU,EACrB,MAAM,EAAK,UAAU,EACrB,SAAS,EAAE,UAAU,GACpB,MAAM,CA0BR"}
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Signal-style safety number derivation for the L2 introduction protocol.
3
+ *
4
+ * Per sub-spec §7.3 (Q3 resolution):
5
+ *
6
+ * fp_input = canonicalCBOR([min(alice, bob), max(alice, bob), masterKey])
7
+ * fp_hash = SHA-512(fp_input)[0..30] // 30 bytes
8
+ * fp_dec = bytesToDecimalString(fp_hash) // 60 digits exactly
9
+ *
10
+ * Implementation: 30-byte SHA-512 slice → big-endian BigInt → decimal string,
11
+ * left-zero-padded to 72 digits, take leftmost 60, grouped as 12 × 5.
12
+ *
13
+ * 2^240 ≈ 1.77 × 10^72 (up to 73 decimal digits). padStart(72) is a no-op
14
+ * for 73-digit values; slice(0, 60) always yields exactly 60 digits.
15
+ *
16
+ * Symmetric property: alice/bob inputs are canonically ordered (lex-smaller
17
+ * first) before CBOR encoding, so swap(alice,bob) → identical output.
18
+ */
19
+ import { sha512 } from '@noble/hashes/sha2.js';
20
+ import { encode as cborgEncode, rfc8949EncodeOptions } from 'cborg';
21
+ /**
22
+ * Derives a Signal-style safety number for an introduction session.
23
+ *
24
+ * @param alicePub - Alice's long-term pubkey (32 bytes)
25
+ * @param bobPub - Bob's long-term pubkey (32 bytes)
26
+ * @param masterKey - Shared master key from DH (32 bytes)
27
+ * @returns 60-digit decimal string in 12 groups of 5 separated by spaces
28
+ *
29
+ * @example
30
+ * deriveSafetyNumber(alice, bob, masterKey)
31
+ * // → "12345 67890 12345 67890 12345 67890 12345 67890 12345 67890 12345 67890"
32
+ *
33
+ * SECURITY_COST-8 (SAS human comparison): The returned safety number is a
34
+ * Short Authentication String (SAS) intended to be read aloud or compared
35
+ * visually by the two human parties over an authenticated channel (in-person,
36
+ * phone call). It is NOT compared in code as part of a security decision —
37
+ * the comparison is performed by humans, who are inherently non-constant-time.
38
+ * Therefore no constant-time comparison is required or meaningful for the SAS
39
+ * value itself. The underlying deriveSafetyNumber computation is
40
+ * deterministic and side-channel-free (pure hash/CBOR, no secret-dependent
41
+ * branches on attacker-controlled input beyond the public pubkey ordering).
42
+ */
43
+ export function deriveSafetyNumber(alicePub, bobPub, masterKey) {
44
+ // Canonical ordering: lex-smaller pubkey is always first.
45
+ // This ensures symmetry: swap(alice,bob) → same output.
46
+ //
47
+ // ADR-012: lexLess below is NOT constant-time. This is SAFE because the
48
+ // pubkeys being ordered are already PUBLIC — they are exchanged over the
49
+ // QR code / introduction channel in cleartext and are not secret material.
50
+ // The ordering only determines array position in the CBOR input; it does
51
+ // not gate any security decision on secret data. See ADR-012.
52
+ const [first, second] = lexLess(alicePub, bobPub)
53
+ ? [alicePub, bobPub]
54
+ : [bobPub, alicePub];
55
+ // Build CBOR input: [min(alice,bob), max(alice,bob), masterKey]
56
+ const input = cborgEncode([first, second, masterKey], rfc8949EncodeOptions);
57
+ // Hash and take first 30 bytes
58
+ const hash30 = sha512(input).subarray(0, 30);
59
+ // Convert 30 bytes to a decimal string, exactly 60 digits.
60
+ // 30 bytes = 240 bits. 2^240 ≈ 1.77 × 10^72.
61
+ // We take the BigInt, stringify, zero-pad to 72 digits, then take first 60.
62
+ const decimal = bigIntFrom(hash30).toString(10).padStart(72, '0').slice(0, 60);
63
+ // Group as 12 groups of 5
64
+ return groupsOfFive(decimal);
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // Internal helpers
68
+ // ---------------------------------------------------------------------------
69
+ /**
70
+ * Returns true if a is lexicographically less than b.
71
+ *
72
+ * ADR-012: This function is NOT constant-time — it returns early on the
73
+ * first differing byte. This is SAFE because the inputs are public Ed25519
74
+ * long-term pubkeys that are already transmitted in cleartext over the QR
75
+ * code / introduction channel. The byte values being compared are not
76
+ * secret, so leaking the first-differing-byte position via timing reveals
77
+ * nothing an attacker does not already know. The ordering result only
78
+ * determines canonical array position in the CBOR safety-number input; it
79
+ * does not gate any security decision on secret data. A constant-time
80
+ * version (isAliceRole in intro-crypto.ts) exists where the comparison
81
+ * itself IS security-relevant (role assignment). See ADR-012.
82
+ */
83
+ function lexLess(a, b) {
84
+ const len = Math.min(a.length, b.length);
85
+ for (let i = 0; i < len; i++) {
86
+ if (a[i] < b[i])
87
+ return true;
88
+ if (a[i] > b[i])
89
+ return false;
90
+ }
91
+ return a.length < b.length;
92
+ }
93
+ /** Interprets bytes as a big-endian unsigned integer. */
94
+ function bigIntFrom(bytes) {
95
+ let n = 0n;
96
+ for (const b of bytes) {
97
+ n = (n << 8n) | BigInt(b);
98
+ }
99
+ return n;
100
+ }
101
+ /** Splits a 60-character string into 12 groups of 5 separated by spaces. */
102
+ function groupsOfFive(s) {
103
+ const parts = [];
104
+ for (let i = 0; i < s.length; i += 5) {
105
+ parts.push(s.slice(i, i + 5));
106
+ }
107
+ return parts.join(' ');
108
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * intro-wire.ts — Wire format codec for L2 introduction protocol messages.
3
+ *
4
+ * Encodes/decodes all 6 message types per sub-spec §4.1. Outer envelope
5
+ * is JSON (matches chat-sdk inner-payload pattern at sdk-inbox-bridge
6
+ * dispatch). Zod discriminated union validates at the receive boundary.
7
+ *
8
+ * ADR-002: JSON + Zod wire format (NOT wire-codec CBOR/zstd — the intro
9
+ * protocol is its own bounded context per ADR-010).
10
+ *
11
+ * Plan: docs/superpowers/plans/2026-05-22-discovery-l2-plan.md (S3)
12
+ */
13
+ import { z } from 'zod';
14
+ export declare const IntroRequestV1Schema: z.ZodObject<{
15
+ kind: z.ZodLiteral<"intro_request_v1">;
16
+ sessionId: z.ZodString;
17
+ target: z.ZodObject<{
18
+ pubkey_b64u: z.ZodString;
19
+ author_b64u: z.ZodString;
20
+ profile_key_b64u: z.ZodString;
21
+ short_id: z.ZodOptional<z.ZodString>;
22
+ handle: z.ZodOptional<z.ZodString>;
23
+ transport_props: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBigInt, z.ZodBoolean, z.ZodNull, z.ZodArray<z.ZodUnknown>]>>>;
24
+ }, z.core.$strip>;
25
+ note: z.ZodOptional<z.ZodString>;
26
+ created_at: z.ZodNumber;
27
+ }, z.core.$strip>;
28
+ export declare const IntroAcceptV1Schema: z.ZodObject<{
29
+ kind: z.ZodLiteral<"intro_accept_v1">;
30
+ sessionId: z.ZodString;
31
+ eph_pub_b64u: z.ZodString;
32
+ accepted_at: z.ZodNumber;
33
+ transport_props: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBigInt, z.ZodBoolean, z.ZodNull, z.ZodArray<z.ZodUnknown>]>>>;
34
+ }, z.core.$strip>;
35
+ export declare const IntroDeclineV1Schema: z.ZodObject<{
36
+ kind: z.ZodLiteral<"intro_decline_v1">;
37
+ sessionId: z.ZodString;
38
+ reason: z.ZodOptional<z.ZodEnum<{
39
+ declined: "declined";
40
+ unknown_introducer: "unknown_introducer";
41
+ silent: "silent";
42
+ blocked: "blocked";
43
+ }>>;
44
+ }, z.core.$strip>;
45
+ export declare const IntroAuthV1Schema: z.ZodObject<{
46
+ kind: z.ZodLiteral<"intro_auth_v1">;
47
+ sessionId: z.ZodString;
48
+ aead_ciphertext: z.ZodString;
49
+ }, z.core.$strip>;
50
+ export declare const IntroActivateV1Schema: z.ZodObject<{
51
+ kind: z.ZodLiteral<"intro_activate_v1">;
52
+ sessionId: z.ZodString;
53
+ aead_ciphertext: z.ZodString;
54
+ }, z.core.$strip>;
55
+ export declare const IntroAbortV1Schema: z.ZodObject<{
56
+ kind: z.ZodLiteral<"intro_abort_v1">;
57
+ sessionId: z.ZodString;
58
+ reason: z.ZodEnum<{
59
+ timeout: "timeout";
60
+ invariant_violation: "invariant_violation";
61
+ user_cancel: "user_cancel";
62
+ peer_declined: "peer_declined";
63
+ cross_instance_unsupported: "cross_instance_unsupported";
64
+ }>;
65
+ }, z.core.$strip>;
66
+ export declare const IntroMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
67
+ kind: z.ZodLiteral<"intro_request_v1">;
68
+ sessionId: z.ZodString;
69
+ target: z.ZodObject<{
70
+ pubkey_b64u: z.ZodString;
71
+ author_b64u: z.ZodString;
72
+ profile_key_b64u: z.ZodString;
73
+ short_id: z.ZodOptional<z.ZodString>;
74
+ handle: z.ZodOptional<z.ZodString>;
75
+ transport_props: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBigInt, z.ZodBoolean, z.ZodNull, z.ZodArray<z.ZodUnknown>]>>>;
76
+ }, z.core.$strip>;
77
+ note: z.ZodOptional<z.ZodString>;
78
+ created_at: z.ZodNumber;
79
+ }, z.core.$strip>, z.ZodObject<{
80
+ kind: z.ZodLiteral<"intro_accept_v1">;
81
+ sessionId: z.ZodString;
82
+ eph_pub_b64u: z.ZodString;
83
+ accepted_at: z.ZodNumber;
84
+ transport_props: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBigInt, z.ZodBoolean, z.ZodNull, z.ZodArray<z.ZodUnknown>]>>>;
85
+ }, z.core.$strip>, z.ZodObject<{
86
+ kind: z.ZodLiteral<"intro_decline_v1">;
87
+ sessionId: z.ZodString;
88
+ reason: z.ZodOptional<z.ZodEnum<{
89
+ declined: "declined";
90
+ unknown_introducer: "unknown_introducer";
91
+ silent: "silent";
92
+ blocked: "blocked";
93
+ }>>;
94
+ }, z.core.$strip>, z.ZodObject<{
95
+ kind: z.ZodLiteral<"intro_auth_v1">;
96
+ sessionId: z.ZodString;
97
+ aead_ciphertext: z.ZodString;
98
+ }, z.core.$strip>, z.ZodObject<{
99
+ kind: z.ZodLiteral<"intro_activate_v1">;
100
+ sessionId: z.ZodString;
101
+ aead_ciphertext: z.ZodString;
102
+ }, z.core.$strip>, z.ZodObject<{
103
+ kind: z.ZodLiteral<"intro_abort_v1">;
104
+ sessionId: z.ZodString;
105
+ reason: z.ZodEnum<{
106
+ timeout: "timeout";
107
+ invariant_violation: "invariant_violation";
108
+ user_cancel: "user_cancel";
109
+ peer_declined: "peer_declined";
110
+ cross_instance_unsupported: "cross_instance_unsupported";
111
+ }>;
112
+ }, z.core.$strip>], "kind">;
113
+ export type IntroMessage = z.infer<typeof IntroMessageSchema>;
114
+ export type IntroKind = IntroMessage['kind'];
115
+ /**
116
+ * Encode an introduction protocol message to a JSON wire string.
117
+ *
118
+ * Validates before encoding (defensive — refuses to produce invalid wire
119
+ * payloads even when TypeScript types are satisfied at call site).
120
+ */
121
+ export declare function encodeIntroMessage(msg: IntroMessage): string;
122
+ /**
123
+ * Decode an introduction protocol message from an unknown payload.
124
+ *
125
+ * The payload is the JSON-parsed object received from sdk-inbox-bridge
126
+ * onSealedPayload callback. Throws a ZodError on validation failure —
127
+ * callers must catch and treat as a rejected/malformed message.
128
+ */
129
+ export declare function decodeIntroMessage(payload: unknown): IntroMessage;
130
+ /**
131
+ * Verify the sessionId redundancy check per sub-spec §4.1.
132
+ *
133
+ * The receiver re-derives the sessionId locally from the introducer's
134
+ * public key + their own key material, then asserts equality against
135
+ * the value carried on the wire. A mismatch indicates either an
136
+ * introducer forgery attempt or a deterministic-derivation drift bug.
137
+ *
138
+ * Returns true if the wire sessionId matches the locally-derived value.
139
+ *
140
+ * *** SECURITY (ADR-011, CWE-208) ***
141
+ * The comparison uses `timingSafePubkeyEqualB64u` (constant-time XOR-reduce
142
+ * over the decoded bytes) instead of plain `===`. A plain `===` on the
143
+ * crypto-derived b64url sessionId leaks the first-mismatch byte position
144
+ * via timing, enabling a session-id oracle (OWASP ASVS V11.3.1, CWE-208).
145
+ * The sessionId is attacker-influenced (an introducer forgery attempt
146
+ * controls the wire value), so it MUST be compared in constant time.
147
+ */
148
+ export declare function verifySessionIdRedundancy(msg: IntroMessage, derivedSessionId: string): boolean;
149
+ //# sourceMappingURL=intro-wire.d.ts.map