@bcts/spqr 1.0.0-alpha.21

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/src/error.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Copyright © 2025 Signal Messenger, LLC
3
+ * Copyright © 2026 Parity Technologies
4
+ *
5
+ * Error types for the SPQR protocol.
6
+ */
7
+
8
+ export class SpqrError extends Error {
9
+ constructor(
10
+ message: string,
11
+ public readonly code: SpqrErrorCode,
12
+ public readonly detail?: unknown,
13
+ ) {
14
+ super(message);
15
+ this.name = "SpqrError";
16
+ }
17
+ }
18
+
19
+ export enum SpqrErrorCode {
20
+ StateDecode = "STATE_DECODE",
21
+ NotImplemented = "NOT_IMPLEMENTED",
22
+ MsgDecode = "MSG_DECODE",
23
+ MacVerifyFailed = "MAC_VERIFY_FAILED",
24
+ EpochOutOfRange = "EPOCH_OUT_OF_RANGE",
25
+ EncodingDecoding = "ENCODING_DECODING",
26
+ Serialization = "SERIALIZATION",
27
+ VersionMismatch = "VERSION_MISMATCH",
28
+ MinimumVersion = "MINIMUM_VERSION",
29
+ KeyJump = "KEY_JUMP",
30
+ KeyTrimmed = "KEY_TRIMMED",
31
+ KeyAlreadyRequested = "KEY_ALREADY_REQUESTED",
32
+ ErroneousDataReceived = "ERRONEOUS_DATA_RECEIVED",
33
+ SendKeyEpochDecreased = "SEND_KEY_EPOCH_DECREASED",
34
+ InvalidParams = "INVALID_PARAMS",
35
+ ChainNotAvailable = "CHAIN_NOT_AVAILABLE",
36
+ }
37
+
38
+ export class EncodingError extends Error {
39
+ constructor(
40
+ message: string,
41
+ public readonly inner?: PolynomialError,
42
+ ) {
43
+ super(message);
44
+ this.name = "EncodingError";
45
+ }
46
+ }
47
+
48
+ export class PolynomialError extends Error {
49
+ constructor(
50
+ message: string,
51
+ public readonly code:
52
+ | "MESSAGE_LENGTH_EVEN"
53
+ | "MESSAGE_LENGTH_TOO_LONG"
54
+ | "SERIALIZATION_INVALID",
55
+ ) {
56
+ super(message);
57
+ this.name = "PolynomialError";
58
+ }
59
+ }
60
+
61
+ export class AuthenticatorError extends Error {
62
+ constructor(
63
+ message: string,
64
+ public readonly code:
65
+ | "INVALID_CT_MAC"
66
+ | "INVALID_HDR_MAC"
67
+ | "ROOT_KEY_PRESENT"
68
+ | "ROOT_KEY_MISSING"
69
+ | "MAC_KEY_PRESENT"
70
+ | "MAC_KEY_MISSING",
71
+ ) {
72
+ super(message);
73
+ this.name = "AuthenticatorError";
74
+ }
75
+ }
@@ -0,0 +1,546 @@
1
+ /**
2
+ * Copyright © 2025 Signal Messenger, LLC
3
+ * Copyright © 2026 Parity Technologies
4
+ *
5
+ * True incremental ML-KEM-768 implementation.
6
+ *
7
+ * Implements the libcrux-compatible incremental encapsulation split:
8
+ *
9
+ * - generate(): Splits the ML-KEM-768 public key into:
10
+ * hdr (pk1, 64 bytes) = rho(32) + H(ek)(32) -- where H = SHA3-256
11
+ * ek (pk2, 1152 bytes) = ByteEncode12(tHat) -- the NTT vector
12
+ *
13
+ * - encaps1(hdr, rng): Uses only rho and H(ek) from the header to produce
14
+ * a REAL ct1 (960 bytes), shared secret (32 bytes), and
15
+ * encapsulation state (2080 bytes) for encaps2.
16
+ *
17
+ * - encaps2(ek, es): Completes the encapsulation using the tHat from pk2
18
+ * and the stored NTT randomness. Returns ct2 (128 bytes).
19
+ *
20
+ * - decaps(dk, ct1, ct2): Standard ML-KEM-768 decapsulation.
21
+ *
22
+ * Wire-compatible with Signal's Rust libcrux incremental implementation.
23
+ */
24
+
25
+ import { sha3_256, sha3_512, shake256 } from "@noble/hashes/sha3.js";
26
+ import { u32 } from "@noble/hashes/utils.js";
27
+ import { genCrystals, XOF128 } from "@noble/post-quantum/_crystals.js";
28
+ import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
29
+ import type { RandomBytes } from "./types.js";
30
+
31
+ // ---- ML-KEM-768 constants ----
32
+
33
+ const N = 256;
34
+ const Q = 3329;
35
+ const F = 3303;
36
+ const ROOT_OF_UNITY = 17;
37
+ const K = 3;
38
+ const ETA1 = 2;
39
+ const ETA2 = 2;
40
+ const DU = 10;
41
+ const DV = 4;
42
+
43
+ // ---- Size constants ----
44
+
45
+ /** Size of the public key header: rho(32) + H(ek)(32) */
46
+ export const HEADER_SIZE = 64;
47
+
48
+ /** Size of the encapsulation key: ByteEncode12(tHat) = 3 * 384 = 1152 bytes */
49
+ export const EK_SIZE = 1152;
50
+
51
+ /** Size of the decapsulation (secret) key */
52
+ export const DK_SIZE = 2400;
53
+
54
+ /** Size of the first ciphertext fragment (ct[0..960]) */
55
+ export const CT1_SIZE = 960;
56
+
57
+ /** Size of the second ciphertext fragment (ct[960..1088]) */
58
+ export const CT2_SIZE = 128;
59
+
60
+ /** Size of the KEM shared secret */
61
+ export const SS_SIZE = 32;
62
+
63
+ /** Standard ML-KEM-768 full public key size */
64
+ export const FULL_PK_SIZE = 1184;
65
+
66
+ /** Standard ML-KEM-768 full ciphertext size */
67
+ export const FULL_CT_SIZE = 1088;
68
+
69
+ /** Size of the keygen seed */
70
+ export const KEYGEN_SEED_SIZE = 64;
71
+
72
+ /** Size of the encapsulation randomness (message m) */
73
+ export const ENCAPS_SEED_SIZE = 32;
74
+
75
+ /**
76
+ * Size of the encapsulation state:
77
+ * r_as_ntt: K(3) * N(256) * 2 = 1536 bytes
78
+ * error2: N(256) * 2 = 512 bytes
79
+ * randomness: 32 bytes
80
+ * Total: 2080 bytes
81
+ */
82
+ export const ES_SIZE = 2080;
83
+
84
+ // ---- Initialize crystals NTT machinery ----
85
+
86
+ const { mod, nttZetas, NTT, bitsCoder } = genCrystals({
87
+ N,
88
+ Q,
89
+ F,
90
+ ROOT_OF_UNITY,
91
+ newPoly: (n: number): Uint16Array => new Uint16Array(n),
92
+ brvBits: 7,
93
+ isKyber: true,
94
+ });
95
+
96
+ // ---- Polynomial operations (copied from noble ml-kem.ts, closure-scoped) ----
97
+
98
+ type Poly = Uint16Array;
99
+
100
+ function polyAdd(a: Poly, b: Poly): void {
101
+ for (let i = 0; i < N; i++) a[i] = mod(a[i] + b[i]);
102
+ }
103
+
104
+ function BaseCaseMultiply(
105
+ a0: number,
106
+ a1: number,
107
+ b0: number,
108
+ b1: number,
109
+ zeta: number,
110
+ ): { c0: number; c1: number } {
111
+ const c0 = mod(a1 * b1 * zeta + a0 * b0);
112
+ const c1 = mod(a0 * b1 + a1 * b0);
113
+ return { c0, c1 };
114
+ }
115
+
116
+ function MultiplyNTTs(f: Poly, g: Poly): Poly {
117
+ for (let i = 0; i < N / 2; i++) {
118
+ let z = nttZetas[64 + (i >> 1)];
119
+ if ((i & 1) !== 0) z = -z;
120
+ const { c0, c1 } = BaseCaseMultiply(f[2 * i + 0], f[2 * i + 1], g[2 * i + 0], g[2 * i + 1], z);
121
+ f[2 * i + 0] = c0;
122
+ f[2 * i + 1] = c1;
123
+ }
124
+ return f;
125
+ }
126
+
127
+ type XofGet = ReturnType<ReturnType<typeof XOF128>["get"]>;
128
+
129
+ function SampleNTT(xof: XofGet): Poly {
130
+ const r: Poly = new Uint16Array(N);
131
+ for (let j = 0; j < N; ) {
132
+ const b = xof();
133
+ if (b.length % 3 !== 0) throw new Error("SampleNTT: unaligned block");
134
+ for (let i = 0; j < N && i + 3 <= b.length; i += 3) {
135
+ const d1 = ((b[i + 0] >> 0) | (b[i + 1] << 8)) & 0xfff;
136
+ const d2 = ((b[i + 1] >> 4) | (b[i + 2] << 4)) & 0xfff;
137
+ if (d1 < Q) r[j++] = d1;
138
+ if (j < N && d2 < Q) r[j++] = d2;
139
+ }
140
+ }
141
+ return r;
142
+ }
143
+
144
+ function sampleCBD(seed: Uint8Array, nonce: number, eta: number): Poly {
145
+ const len = (eta * N) / 4;
146
+ const buf = shake256
147
+ .create({ dkLen: len })
148
+ .update(seed)
149
+ .update(new Uint8Array([nonce]))
150
+ .digest();
151
+ const r: Poly = new Uint16Array(N);
152
+ const b32 = u32(buf);
153
+ let bitLen = 0;
154
+ let p = 0;
155
+ let bb = 0;
156
+ let t0 = 0;
157
+ for (let b of b32) {
158
+ for (let j = 0; j < 32; j++) {
159
+ bb += b & 1;
160
+ b >>= 1;
161
+ bitLen += 1;
162
+ if (bitLen === eta) {
163
+ t0 = bb;
164
+ bb = 0;
165
+ } else if (bitLen === 2 * eta) {
166
+ r[p++] = mod(t0 - bb);
167
+ bb = 0;
168
+ bitLen = 0;
169
+ }
170
+ }
171
+ }
172
+ if (bitLen !== 0) throw new Error(`sampleCBD: leftover bits: ${bitLen}`);
173
+ return r;
174
+ }
175
+
176
+ // ---- Compress/decompress coders ----
177
+
178
+ const compress = (d: number): { encode: (i: number) => number; decode: (i: number) => number } => {
179
+ if (d >= 12) return { encode: (i: number) => i, decode: (i: number) => i };
180
+ const a = 2 ** (d - 1);
181
+ return {
182
+ encode: (i: number) => ((i << d) + Q / 2) / Q,
183
+ decode: (i: number) => (i * Q + a) >>> d,
184
+ };
185
+ };
186
+
187
+ const polyCoder = (d: number): ReturnType<typeof bitsCoder> => bitsCoder(d, compress(d));
188
+
189
+ // Coders for encoding/decoding polynomials
190
+ const poly12 = polyCoder(12); // for tHat encoding (ByteEncode12)
191
+ const polyDU = polyCoder(DU); // for u compression (du=10)
192
+ const polyDV = polyCoder(DV); // for v compression (dv=4)
193
+ const poly1 = polyCoder(1); // for message encoding (1-bit)
194
+
195
+ // ---- Key material ----
196
+
197
+ /** Generated ML-KEM-768 key material, split for incremental protocol */
198
+ export interface Keys {
199
+ /** Public key header: rho(32) + SHA3-256(full_pk)(32) */
200
+ hdr: Uint8Array;
201
+ /** Encapsulation key: ByteEncode12(tHat) = 1152 bytes */
202
+ ek: Uint8Array;
203
+ /** Decapsulation (secret) key: full 2400-byte ML-KEM-768 secret key */
204
+ dk: Uint8Array;
205
+ }
206
+
207
+ /** Result of encaps1: REAL ct1, shared secret, and encapsulation state */
208
+ export interface Encaps1Result {
209
+ /** REAL ct1 (960 bytes) */
210
+ ct1: Uint8Array;
211
+ /** Encapsulation state (2080 bytes): r_as_ntt(1536) + error2(512) + m(32) */
212
+ es: Uint8Array;
213
+ /** REAL shared secret (32 bytes) */
214
+ sharedSecret: Uint8Array;
215
+ }
216
+
217
+ // ---- Encapsulation state encoding ----
218
+
219
+ /**
220
+ * Encode the encapsulation state as 2080 bytes:
221
+ * r_as_ntt: K polys, each 256 coefficients as uint16 LE = 1536 bytes
222
+ * error2: 1 poly, 256 coefficients as uint16 LE = 512 bytes
223
+ * randomness: 32 bytes (the message m)
224
+ */
225
+ function encodeState(rHat: Poly[], e2: Poly, m: Uint8Array): Uint8Array {
226
+ const state = new Uint8Array(ES_SIZE);
227
+ let offset = 0;
228
+
229
+ // r_as_ntt: K polynomials
230
+ for (let k = 0; k < K; k++) {
231
+ const poly = rHat[k];
232
+ for (let i = 0; i < N; i++) {
233
+ const val = poly[i];
234
+ state[offset++] = val & 0xff;
235
+ state[offset++] = (val >> 8) & 0xff;
236
+ }
237
+ }
238
+
239
+ // error2: 1 polynomial
240
+ for (let i = 0; i < N; i++) {
241
+ const val = e2[i];
242
+ state[offset++] = val & 0xff;
243
+ state[offset++] = (val >> 8) & 0xff;
244
+ }
245
+
246
+ // randomness (message m)
247
+ state.set(m, offset);
248
+
249
+ return state;
250
+ }
251
+
252
+ /**
253
+ * Decode the encapsulation state from 2080 bytes.
254
+ * Includes the Issue 1275 endianness workaround.
255
+ */
256
+ function decodeState(state: Uint8Array): {
257
+ rHat: Poly[];
258
+ e2: Poly;
259
+ m: Uint8Array;
260
+ } {
261
+ // Apply Issue 1275 workaround
262
+ const fixedState = fixIssue1275(state);
263
+ const st = fixedState ?? state;
264
+
265
+ let offset = 0;
266
+
267
+ // r_as_ntt: K polynomials
268
+ const rHat: Poly[] = [];
269
+ for (let k = 0; k < K; k++) {
270
+ const poly = new Uint16Array(N);
271
+ for (let i = 0; i < N; i++) {
272
+ poly[i] = st[offset] | (st[offset + 1] << 8);
273
+ offset += 2;
274
+ }
275
+ rHat.push(poly);
276
+ }
277
+
278
+ // error2: 1 polynomial
279
+ const e2 = new Uint16Array(N);
280
+ for (let i = 0; i < N; i++) {
281
+ e2[i] = st[offset] | (st[offset + 1] << 8);
282
+ offset += 2;
283
+ }
284
+
285
+ // randomness (message m)
286
+ const m = st.slice(offset, offset + 32);
287
+
288
+ return { rHat, e2, m };
289
+ }
290
+
291
+ // ---- Issue 1275 Endianness Workaround ----
292
+
293
+ /**
294
+ * Port of Rust's potentially_fix_state_incorrectly_encoded_by_libcrux_issue_1275.
295
+ *
296
+ * Due to https://github.com/cryspen/libcrux/issues/1275, the encapsulation
297
+ * state may contain error2 coefficients with wrong endianness.
298
+ *
299
+ * Error2 values should be in [-2, 2] (ETA2=2 for ML-KEM-768).
300
+ * As uint16 LE, valid values are: 0x0000, 0x0001, 0x0002, 0xFFFF (-1), 0xFFFE (-2).
301
+ * Bad-endian equivalents: 0x0100, 0x0200, 0xFEFF.
302
+ *
303
+ * Returns a fixed copy if endianness is wrong, or null if state is OK.
304
+ */
305
+ function fixIssue1275(es: Uint8Array): Uint8Array | null {
306
+ // error2 is at bytes [1536..2048] (after r_as_ntt, before randomness)
307
+ const E2_START = K * N * 2; // 1536
308
+ const E2_END = E2_START + N * 2; // 2048
309
+
310
+ for (let i = E2_START; i < E2_END; i += 2) {
311
+ const lo = es[i];
312
+ const hi = es[i + 1];
313
+ const val = lo | (hi << 8); // interpret as i16 LE
314
+
315
+ // 0x0000 and 0xFFFF have same representation in both endiannesses
316
+ if (val === 0x0000 || val === 0xffff) continue;
317
+
318
+ // Good LE values: 0x0001, 0x0002, 0xFFFE
319
+ if (val === 0x0001 || val === 0x0002 || val === 0xfffe) {
320
+ return null; // Already correct
321
+ }
322
+
323
+ // Bad (big-endian) values: 0x0100, 0x0200, 0xFEFF
324
+ if (val === 0x0100 || val === 0x0200 || val === 0xfeff) {
325
+ return flipEndianness(es);
326
+ }
327
+
328
+ // Unknown value -- return null (use as-is)
329
+ return null;
330
+ }
331
+
332
+ return null;
333
+ }
334
+
335
+ /**
336
+ * Flip the endianness of all i16 values in the encapsulation state.
337
+ * The last 32 bytes (randomness) are NOT flipped.
338
+ */
339
+ function flipEndianness(es: Uint8Array): Uint8Array {
340
+ const fixed = new Uint8Array(es);
341
+ const coeffEnd = es.length - 32; // don't flip the last 32 bytes (randomness)
342
+ for (let i = 0; i < coeffEnd; i += 2) {
343
+ const tmp = fixed[i];
344
+ fixed[i] = fixed[i + 1];
345
+ fixed[i + 1] = tmp;
346
+ }
347
+ return fixed;
348
+ }
349
+
350
+ // ---- Key generation ----
351
+
352
+ /**
353
+ * Generate an ML-KEM-768 keypair and split the public key.
354
+ *
355
+ * The ML-KEM-768 public key (1184 bytes) is:
356
+ * tHat = pk[0..1152] (ByteEncode12 of NTT vector)
357
+ * rho = pk[1152..1184] (32-byte seed)
358
+ *
359
+ * The incremental split produces:
360
+ * hdr (pk1) = rho(32) + SHA3-256(pk)(32) = 64 bytes
361
+ * ek (pk2) = tHat(1152) = ByteEncode12(tHat) = 1152 bytes
362
+ *
363
+ * @param rng - Random byte generator; must provide at least 64 bytes
364
+ * @returns Split key material
365
+ */
366
+ export function generate(rng: RandomBytes): Keys {
367
+ const seed = rng(KEYGEN_SEED_SIZE);
368
+ const { publicKey, secretKey } = ml_kem768.keygen(seed);
369
+
370
+ // Standard pk layout: tHat(1152) || rho(32)
371
+ const tHat = publicKey.slice(0, EK_SIZE); // 1152 bytes
372
+ const rho = publicKey.slice(EK_SIZE); // 32 bytes
373
+ const hEk = sha3_256(publicKey); // H(ek) = SHA3-256(full pk)
374
+
375
+ // pk1 (header) = rho(32) || H(ek)(32)
376
+ const hdr = new Uint8Array(HEADER_SIZE);
377
+ hdr.set(rho, 0);
378
+ hdr.set(hEk, 32);
379
+
380
+ return {
381
+ hdr,
382
+ ek: tHat,
383
+ dk: secretKey,
384
+ };
385
+ }
386
+
387
+ // ---- Encapsulation Phase 1 ----
388
+
389
+ /**
390
+ * Phase 1 of true incremental encapsulation.
391
+ *
392
+ * Uses only rho and H(ek) from the 64-byte header to produce:
393
+ * - REAL ct1 (960 bytes): compress_du(u) where u = NTT^-1(A^T * rHat) + e1
394
+ * - REAL shared secret: K-hat from SHA3-512(m || H(ek))
395
+ * - Encapsulation state (2080 bytes): r_as_ntt + error2 + m
396
+ *
397
+ * @param hdr - The 64-byte header: rho(32) + H(ek)(32)
398
+ * @param rng - Random byte generator
399
+ * @returns Real ct1, encapsulation state, and real shared secret
400
+ */
401
+ export function encaps1(hdr: Uint8Array, rng: RandomBytes): Encaps1Result {
402
+ const rho = hdr.slice(0, 32);
403
+ const hEk = hdr.slice(32, 64);
404
+
405
+ // Step 1: m = random 32 bytes
406
+ const m = rng(ENCAPS_SEED_SIZE);
407
+
408
+ // Step 2: kr = SHA3-512(m || H(ek)) -> K-hat(32) || r(32)
409
+ const kr = sha3_512.create().update(m).update(hEk).digest();
410
+ const kHat = kr.slice(0, 32);
411
+ const r = kr.slice(32, 64);
412
+
413
+ // Step 3: Sample rHat[i] = NTT(CBD(r, i, ETA1))
414
+ const rHat: Poly[] = [];
415
+ for (let i = 0; i < K; i++) {
416
+ rHat.push(NTT.encode(sampleCBD(r, i, ETA1)));
417
+ }
418
+
419
+ // Step 4: Generate A from rho via XOF128, compute u
420
+ const x = XOF128(rho);
421
+ const u: Poly[] = [];
422
+ for (let i = 0; i < K; i++) {
423
+ const e1 = sampleCBD(r, K + i, ETA2);
424
+ const tmp = new Uint16Array(N);
425
+ for (let j = 0; j < K; j++) {
426
+ const aij = SampleNTT(x.get(i, j));
427
+ polyAdd(tmp, MultiplyNTTs(aij, rHat[j].slice() as Poly));
428
+ }
429
+ polyAdd(e1, NTT.decode(tmp));
430
+ u.push(e1);
431
+ }
432
+ x.clean();
433
+
434
+ // Step 5: ct1 = compress_du(u) = encode each u[i] with du=10
435
+ const ct1 = new Uint8Array(CT1_SIZE);
436
+ for (let i = 0; i < K; i++) {
437
+ const encoded = polyDU.encode(u[i]);
438
+ ct1.set(encoded, i * polyDU.bytesLen);
439
+ }
440
+
441
+ // Step 6: e2 = CBD(r, 2*K, ETA2)
442
+ const e2 = sampleCBD(r, 2 * K, ETA2);
443
+
444
+ // Step 7: Encode state
445
+ const es = encodeState(rHat, e2, m);
446
+
447
+ return {
448
+ ct1,
449
+ es,
450
+ sharedSecret: kHat,
451
+ };
452
+ }
453
+
454
+ // ---- Encapsulation Phase 2 ----
455
+
456
+ /**
457
+ * Phase 2 of true incremental encapsulation.
458
+ *
459
+ * Uses the tHat from pk2 (ek) and the stored NTT randomness to compute ct2.
460
+ *
461
+ * @param ek - The 1152-byte encapsulation key (ByteEncode12(tHat))
462
+ * @param es - The 2080-byte encapsulation state from encaps1
463
+ * @returns ct2 (128 bytes) ONLY
464
+ */
465
+ export function encaps2(ek: Uint8Array, es: Uint8Array): Uint8Array {
466
+ const { rHat, e2, m } = decodeState(es);
467
+
468
+ // Decode tHat from ek (ByteDecode12)
469
+ const tHat: Poly[] = [];
470
+ for (let i = 0; i < K; i++) {
471
+ const slice = ek.subarray(i * poly12.bytesLen, (i + 1) * poly12.bytesLen);
472
+ tHat.push(poly12.decode(slice));
473
+ }
474
+
475
+ // Compute v = NTT^-1(sum(tHat[i] * rHat[i])) + e2 + Decompress1(m)
476
+ const tmp = new Uint16Array(N);
477
+ for (let i = 0; i < K; i++) {
478
+ polyAdd(tmp, MultiplyNTTs(tHat[i].slice() as Poly, rHat[i].slice() as Poly));
479
+ }
480
+ const v = NTT.decode(tmp);
481
+ polyAdd(v, e2);
482
+
483
+ // Decompress1(m) = decode m as 1-bit polynomial
484
+ const mPoly = poly1.decode(m);
485
+ polyAdd(v, mPoly);
486
+
487
+ // ct2 = compress_dv(v)
488
+ return polyDV.encode(v);
489
+ }
490
+
491
+ // ---- Decapsulation ----
492
+
493
+ /**
494
+ * Decapsulate a split ciphertext using the decapsulation key.
495
+ *
496
+ * Concatenates ct1 (960 bytes) and ct2 (128 bytes) into a standard
497
+ * 1088-byte ML-KEM-768 ciphertext, then performs standard decapsulation.
498
+ *
499
+ * @param dk - The 2400-byte decapsulation (secret) key
500
+ * @param ct1 - First ciphertext fragment (960 bytes)
501
+ * @param ct2 - Second ciphertext fragment (128 bytes)
502
+ * @returns The 32-byte shared secret
503
+ */
504
+ export function decaps(dk: Uint8Array, ct1: Uint8Array, ct2: Uint8Array): Uint8Array {
505
+ const ct = new Uint8Array(FULL_CT_SIZE);
506
+ ct.set(ct1.subarray(0, CT1_SIZE), 0);
507
+ ct.set(ct2.subarray(0, CT2_SIZE), CT1_SIZE);
508
+ return ml_kem768.decapsulate(ct, dk);
509
+ }
510
+
511
+ // ---- Validation ----
512
+
513
+ /**
514
+ * Check whether an encapsulation key is consistent with a header.
515
+ *
516
+ * Reconstructs the full public key from pk2 (tHat) and pk1[0..32] (rho),
517
+ * computes SHA3-256 of the full pk, and compares with H(ek) in the header.
518
+ *
519
+ * @param ek - Encapsulation key (1152 bytes = ByteEncode12(tHat))
520
+ * @param hdr - Header (64 bytes = rho(32) + H(ek)(32))
521
+ * @returns true if ek is consistent with the header
522
+ */
523
+ export function ekMatchesHeader(ek: Uint8Array, hdr: Uint8Array): boolean {
524
+ if (hdr.length < HEADER_SIZE || ek.length < EK_SIZE) {
525
+ return false;
526
+ }
527
+
528
+ const rho = hdr.slice(0, 32);
529
+ const expectedHash = hdr.slice(32, 64);
530
+
531
+ // Reconstruct full pk: tHat(1152) || rho(32)
532
+ const pk = new Uint8Array(FULL_PK_SIZE);
533
+ pk.set(ek.subarray(0, EK_SIZE), 0);
534
+ pk.set(rho, EK_SIZE);
535
+
536
+ // Compute H(pk) and compare
537
+ const actualHash = sha3_256(pk);
538
+
539
+ // Constant-time comparison
540
+ if (actualHash.length !== expectedHash.length) return false;
541
+ let diff = 0;
542
+ for (let i = 0; i < actualHash.length; i++) {
543
+ diff |= actualHash[i] ^ expectedHash[i];
544
+ }
545
+ return diff === 0;
546
+ }