@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.
- package/LICENSE +661 -0
- package/README.md +54 -0
- package/SECURITY.md +116 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/intro-crypto.d.ts +216 -0
- package/dist/intro-crypto.d.ts.map +1 -0
- package/dist/intro-crypto.js +378 -0
- package/dist/intro-safety-number.d.ts +42 -0
- package/dist/intro-safety-number.d.ts.map +1 -0
- package/dist/intro-safety-number.js +108 -0
- package/dist/intro-wire.d.ts +149 -0
- package/dist/intro-wire.d.ts.map +1 -0
- package/dist/intro-wire.js +160 -0
- package/package.json +72 -0
|
@@ -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
|