zeropoint-node 1.0.3 → 1.0.5

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,140 @@
1
+ /**
2
+ * ML-KEM-768 conformance suite.
3
+ *
4
+ * The previous module in this directory passed a round trip and was still not
5
+ * Kyber, so a round trip is the weakest check here, not the headline. What
6
+ * decides conformance is `ml-kem-768-acvp.json`: NIST's own ACVP vectors for
7
+ * FIPS 203, ML-KEM-768 group, covering key generation, encapsulation,
8
+ * decapsulation including the implicit-rejection path, and encapsulation-key
9
+ * validation. Matching them means agreeing with NIST byte for byte.
10
+ *
11
+ * `npm run kat:ml-kem` goes further — 10 000 reference cases from pq-crystals.
12
+ * It is not in the gate because it takes ~33s; this file takes well under one.
13
+ */
14
+
15
+ import { readFileSync } from 'node:fs'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { dirname, resolve } from 'node:path'
18
+ import {
19
+ ML_KEM_768,
20
+ keyGen,
21
+ keyGenDerand,
22
+ encaps,
23
+ encapsDerand,
24
+ decaps,
25
+ selfTest,
26
+ } from './ml-kem.ts'
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url))
29
+ const vectors = JSON.parse(readFileSync(resolve(here, 'ml-kem-768-acvp.json'), 'utf8'))
30
+
31
+ let failures = 0
32
+ function check(name: string, ok: boolean, detail = ''): void {
33
+ if (ok) {
34
+ console.log(' ✓ ' + name)
35
+ } else {
36
+ failures++
37
+ console.log(' ✗ ' + name + (detail ? ' — ' + detail : ''))
38
+ }
39
+ }
40
+
41
+ console.log('ML-KEM-768 (FIPS 203)\n')
42
+
43
+ // ---------------------------------------------------------------- structure
44
+ console.log('Structural identities (no vectors involved)')
45
+ const structural = selfTest()
46
+ check('NTT invertible, ring-homomorphic, compression within bound', structural.length === 0, structural.join('; '))
47
+
48
+ // ------------------------------------------------------------------- sizes
49
+ console.log('\nParameter sizes')
50
+ check('encapsulation key 1184 bytes', ML_KEM_768.encapsulationKeyBytes === 1184)
51
+ check('decapsulation key 2400 bytes', ML_KEM_768.decapsulationKeyBytes === 2400)
52
+ check('ciphertext 1088 bytes', ML_KEM_768.ciphertextBytes === 1088)
53
+
54
+ // ------------------------------------------------------- ACVP: key generation
55
+ console.log('\nNIST ACVP — key generation (' + vectors.keyGen.length + ' vectors)')
56
+ let bad = 0
57
+ for (const t of vectors.keyGen) {
58
+ const kp = keyGenDerand(Buffer.from(t.d, 'hex'), Buffer.from(t.z, 'hex'))
59
+ if (
60
+ kp.encapsulationKey.toString('hex') !== t.ek.toLowerCase() ||
61
+ kp.decapsulationKey.toString('hex') !== t.dk.toLowerCase()
62
+ ) bad++
63
+ }
64
+ check('every (d, z) reproduces NIST ek and dk', bad === 0, bad + ' mismatched')
65
+
66
+ // -------------------------------------------------------- ACVP: encapsulation
67
+ console.log('\nNIST ACVP — encapsulation (' + vectors.encapsulation.length + ' vectors)')
68
+ bad = 0
69
+ for (const t of vectors.encapsulation) {
70
+ const r = encapsDerand(Buffer.from(t.ek, 'hex'), Buffer.from(t.m, 'hex'))
71
+ if (r.ciphertext.toString('hex') !== t.c.toLowerCase() || r.sharedSecret.toString('hex') !== t.k.toLowerCase()) bad++
72
+ }
73
+ check('every (ek, m) reproduces NIST ciphertext and shared secret', bad === 0, bad + ' mismatched')
74
+
75
+ // -------------------------------------------------------- ACVP: decapsulation
76
+ console.log('\nNIST ACVP — decapsulation (' + vectors.decapsulation.length + ' vectors)')
77
+ const byReason = new Map<string, { pass: number; total: number }>()
78
+ for (const t of vectors.decapsulation) {
79
+ const got = decaps(Buffer.from(t.dk, 'hex'), Buffer.from(t.c, 'hex')).toString('hex')
80
+ const slot = byReason.get(t.reason) ?? { pass: 0, total: 0 }
81
+ slot.total++
82
+ if (got === t.k.toLowerCase()) slot.pass++
83
+ byReason.set(t.reason, slot)
84
+ }
85
+ for (const [reason, s] of byReason) {
86
+ check(`${reason} (${s.total})`, s.pass === s.total, `${s.total - s.pass} mismatched`)
87
+ }
88
+
89
+ // ------------------------------------------------ ACVP: encapsulation-key check
90
+ console.log('\nNIST ACVP — encapsulation key validation (' + vectors.encapsulationKeyCheck.length + ' vectors)')
91
+ bad = 0
92
+ for (const t of vectors.encapsulationKeyCheck) {
93
+ let accepted = true
94
+ try {
95
+ encapsDerand(Buffer.from(t.ek, 'hex'), Buffer.alloc(32))
96
+ } catch {
97
+ accepted = false
98
+ }
99
+ if (accepted !== t.testPassed) bad++
100
+ }
101
+ check('accepts valid keys, rejects unreduced coefficients', bad === 0, bad + ' misjudged')
102
+
103
+ // ------------------------------------------------------------ behavioural
104
+ console.log('\nBehaviour')
105
+ const kp = keyGen()
106
+ const e = encaps(kp.encapsulationKey)
107
+ check('shared secret is 32 bytes', e.sharedSecret.length === 32)
108
+ check('round trip recovers the shared secret', decaps(kp.decapsulationKey, e.ciphertext).equals(e.sharedSecret))
109
+
110
+ const other = keyGen()
111
+ check(
112
+ 'an unrelated decapsulation key yields a different secret',
113
+ !decaps(other.decapsulationKey, e.ciphertext).equals(e.sharedSecret)
114
+ )
115
+
116
+ // Implicit rejection: a corrupted ciphertext must yield a key, not an error,
117
+ // and that key must be wrong. Returning an error would leak plaintext validity.
118
+ const corrupt = Buffer.from(e.ciphertext)
119
+ corrupt[0] = corrupt[0]! ^ 1
120
+ const rejected = decaps(kp.decapsulationKey, corrupt)
121
+ check('corrupted ciphertext still returns 32 bytes (implicit rejection)', rejected.length === 32)
122
+ check('and that value differs from the true secret', !rejected.equals(e.sharedSecret))
123
+ check(
124
+ 'implicit rejection is deterministic for a given (dk, c)',
125
+ decaps(kp.decapsulationKey, corrupt).equals(rejected)
126
+ )
127
+
128
+ let threw = false
129
+ try { decaps(kp.decapsulationKey, Buffer.alloc(10)) } catch { threw = true }
130
+ check('wrong-length ciphertext is rejected', threw)
131
+ threw = false
132
+ try { encapsDerand(Buffer.alloc(10), Buffer.alloc(32)) } catch { threw = true }
133
+ check('wrong-length encapsulation key is rejected', threw)
134
+
135
+ console.log('')
136
+ if (failures > 0) {
137
+ console.error(`❌ ML-KEM-768: ${failures} check(s) failed`)
138
+ process.exit(1)
139
+ }
140
+ console.log('✅ ML-KEM-768 conformant to FIPS 203 against NIST ACVP vectors')
@@ -0,0 +1,554 @@
1
+ /**
2
+ * ML-KEM-768 — FIPS 203, the real thing.
3
+ *
4
+ * This replaces a module that called itself Kyber and was not: it sampled the
5
+ * matrix A from a centred binomial distribution, so its coefficients lived in
6
+ * {-1,0,1} instead of uniform over Z_q, and the module-LWE instance underneath
7
+ * was not the hard problem. A round trip that agrees proves only that a scheme
8
+ * is self-consistent, never that it is the scheme it claims to be.
9
+ *
10
+ * What makes this one real is not this comment. It is that
11
+ * `ml-kem.test.ts` runs the pq-crystals accumulated known-answer test: 10 000
12
+ * keygen/encaps/decaps triples driven by a deterministic SHAKE-128 stream,
13
+ * hashed to a single digest that must equal the value published by C2SP/CCTV
14
+ * for ML-KEM-768. That digest comes from the reference implementation, so
15
+ * matching it means agreeing with pq-crystals on every byte of 10 000 keys,
16
+ * ciphertexts and shared secrets — including the implicit-rejection path.
17
+ *
18
+ * Nothing here is hardcoded from the spec's tables: the NTT twiddle factors are
19
+ * computed from zeta = 17 and a bit-reversal, and `selfTest()` checks the ring
20
+ * identities they must satisfy.
21
+ *
22
+ * Structure follows FIPS 203 directly. K-PKE is the IND-CPA scheme; ML-KEM
23
+ * wraps it in the Fujisaki-Okamoto transform with implicit rejection.
24
+ *
25
+ * Not constant time. JavaScript cannot promise that — array indexing, JIT
26
+ * deoptimisation and GC all leak timing. Do not use this where an attacker can
27
+ * measure decapsulation.
28
+ */
29
+
30
+ import { createHash, randomBytes } from 'node:crypto'
31
+
32
+ // ============================================================================
33
+ // PARAMETERS (FIPS 203, Table 2 — ML-KEM-768)
34
+ // ============================================================================
35
+
36
+ const N = 256
37
+ const Q = 3329
38
+ const K = 3
39
+ const ETA1 = 2
40
+ const ETA2 = 2
41
+ const DU = 10
42
+ const DV = 4
43
+
44
+ export const ML_KEM_768 = {
45
+ encapsulationKeyBytes: 384 * K + 32, // 1184
46
+ decapsulationKeyBytes: 768 * K + 96, // 2400
47
+ ciphertextBytes: 32 * (DU * K + DV), // 1088
48
+ sharedSecretBytes: 32,
49
+ } as const
50
+
51
+ // ============================================================================
52
+ // SYMMETRIC PRIMITIVES (FIPS 203 §4.1)
53
+ // ============================================================================
54
+
55
+ /** G: SHA3-512, split into two 32-byte halves. */
56
+ function G(input: Buffer): [Buffer, Buffer] {
57
+ const h = createHash('sha3-512').update(input).digest()
58
+ return [h.subarray(0, 32), h.subarray(32, 64)]
59
+ }
60
+
61
+ /** H: SHA3-256. */
62
+ function H(input: Buffer): Buffer {
63
+ return createHash('sha3-256').update(input).digest()
64
+ }
65
+
66
+ /** J: SHAKE-256 to 32 bytes — the implicit-rejection secret. */
67
+ function J(input: Buffer): Buffer {
68
+ return createHash('shake256', { outputLength: 32 }).update(input).digest()
69
+ }
70
+
71
+ /** PRF_eta: SHAKE-256(s ‖ b) to 64·eta bytes. */
72
+ function PRF(eta: number, s: Buffer, b: number): Buffer {
73
+ return createHash('shake256', { outputLength: 64 * eta })
74
+ .update(Buffer.concat([s, Buffer.from([b])]))
75
+ .digest()
76
+ }
77
+
78
+ /**
79
+ * XOF: SHAKE-128(rho ‖ i ‖ j).
80
+ *
81
+ * 840 bytes is drawn up front rather than squeezed incrementally: Node's hash
82
+ * API has no incremental squeeze, and SampleNTT needs more than 575 bytes only
83
+ * with probability 2^-38. `sampleNTT` throws rather than silently truncating if
84
+ * that budget is ever exhausted, so the rare case fails loudly instead of
85
+ * producing a wrong polynomial.
86
+ */
87
+ const XOF_BYTES = 840
88
+ function XOF(rho: Buffer, i: number, j: number): Buffer {
89
+ return createHash('shake128', { outputLength: XOF_BYTES })
90
+ .update(Buffer.concat([rho, Buffer.from([i, j])]))
91
+ .digest()
92
+ }
93
+
94
+ // ============================================================================
95
+ // NTT TWIDDLE FACTORS — computed, not tabulated
96
+ // ============================================================================
97
+
98
+ /** Reverse the low 7 bits of i. */
99
+ function bitRev7(i: number): number {
100
+ let r = 0
101
+ for (let b = 0; b < 7; b++) r = (r << 1) | ((i >> b) & 1)
102
+ return r
103
+ }
104
+
105
+ /** zetas[i] = 17^BitRev7(i) mod q — FIPS 203 §4.3. */
106
+ const ZETAS: Int16Array = (() => {
107
+ const z = new Int16Array(128)
108
+ for (let i = 0; i < 128; i++) {
109
+ let acc = 1
110
+ const e = bitRev7(i)
111
+ for (let b = 0; b < e; b++) acc = (acc * 17) % Q
112
+ z[i] = acc
113
+ }
114
+ return z
115
+ })()
116
+
117
+ // ============================================================================
118
+ // POLYNOMIAL ARITHMETIC (coefficients held reduced in [0, q))
119
+ // ============================================================================
120
+
121
+ export type Poly = Int16Array // 256 coefficients
122
+ type PolyVec = Poly[] // K polynomials
123
+
124
+ function newPoly(): Poly {
125
+ return new Int16Array(N)
126
+ }
127
+
128
+ /** Reduce into [0, q). Inputs stay within +/- a few multiples of q. */
129
+ function mod(x: number): number {
130
+ const r = x % Q
131
+ return r < 0 ? r + Q : r
132
+ }
133
+
134
+ function polyAdd(a: Poly, b: Poly): Poly {
135
+ const r = newPoly()
136
+ for (let i = 0; i < N; i++) r[i] = mod(a[i]! + b[i]!)
137
+ return r
138
+ }
139
+
140
+ function polySub(a: Poly, b: Poly): Poly {
141
+ const r = newPoly()
142
+ for (let i = 0; i < N; i++) r[i] = mod(a[i]! - b[i]!)
143
+ return r
144
+ }
145
+
146
+ /** Forward NTT, in place on a copy — Cooley-Tukey, 7 layers. */
147
+ export function ntt(f: Poly): Poly {
148
+ const r = Int16Array.from(f)
149
+ let k = 1
150
+ for (let len = 128; len >= 2; len >>= 1) {
151
+ for (let start = 0; start < N; start += 2 * len) {
152
+ const zeta = ZETAS[k++]!
153
+ for (let j = start; j < start + len; j++) {
154
+ const t = mod(zeta * r[j + len]!)
155
+ r[j + len] = mod(r[j]! - t)
156
+ r[j] = mod(r[j]! + t)
157
+ }
158
+ }
159
+ }
160
+ return r
161
+ }
162
+
163
+ /** Inverse NTT — Gentleman-Sande, then scale by 128^-1 mod q. */
164
+ export function nttInverse(f: Poly): Poly {
165
+ const r = Int16Array.from(f)
166
+ let k = 127
167
+ for (let len = 2; len <= 128; len <<= 1) {
168
+ for (let start = 0; start < N; start += 2 * len) {
169
+ const zeta = ZETAS[k--]!
170
+ for (let j = start; j < start + len; j++) {
171
+ const t = r[j]!
172
+ r[j] = mod(t + r[j + len]!)
173
+ r[j + len] = mod(zeta * mod(r[j + len]! - t))
174
+ }
175
+ }
176
+ }
177
+ // 128^-1 mod 3329: 128·3303 = 422784 = 127·3329 + 1.
178
+ for (let i = 0; i < N; i++) r[i] = mod(r[i]! * 3303)
179
+ return r
180
+ }
181
+
182
+ /**
183
+ * Multiplication in the NTT domain — FIPS 203 Algorithm 12.
184
+ *
185
+ * The NTT does not fully split Z_q[X]/(X^256+1): it lands in 128 quadratic
186
+ * quotients, so each pair of coefficients multiplies as a degree-1 polynomial
187
+ * modulo X^2 - zeta^(2·BitRev7(i)+1).
188
+ */
189
+ export function multiplyNTTs(a: Poly, b: Poly): Poly {
190
+ const r = newPoly()
191
+ for (let i = 0; i < 64; i++) {
192
+ const z = ZETAS[64 + i]!
193
+ // pair 2i
194
+ let a0 = a[4 * i]!, a1 = a[4 * i + 1]!, b0 = b[4 * i]!, b1 = b[4 * i + 1]!
195
+ r[4 * i] = mod(mod(a1 * b1) * z + a0 * b0)
196
+ r[4 * i + 1] = mod(a0 * b1 + a1 * b0)
197
+ // pair 2i+1 uses -zeta
198
+ a0 = a[4 * i + 2]!; a1 = a[4 * i + 3]!; b0 = b[4 * i + 2]!; b1 = b[4 * i + 3]!
199
+ r[4 * i + 2] = mod(-mod(a1 * b1) * z + a0 * b0)
200
+ r[4 * i + 3] = mod(a0 * b1 + a1 * b0)
201
+ }
202
+ return r
203
+ }
204
+
205
+ /** Inner product of two NTT-domain vectors. */
206
+ function vecDot(a: PolyVec, b: PolyVec): Poly {
207
+ let acc = newPoly()
208
+ for (let i = 0; i < K; i++) acc = polyAdd(acc, multiplyNTTs(a[i]!, b[i]!))
209
+ return acc
210
+ }
211
+
212
+ // ============================================================================
213
+ // SAMPLING (FIPS 203 §4.2.2)
214
+ // ============================================================================
215
+
216
+ /** Algorithm 7 — rejection sampling of a uniform NTT-domain polynomial. */
217
+ export function sampleNTT(rho: Buffer, i: number, j: number): Poly {
218
+ const buf = XOF(rho, i, j)
219
+ const a = newPoly()
220
+ let ctr = 0
221
+ let pos = 0
222
+ while (ctr < N) {
223
+ if (pos + 3 > buf.length) {
224
+ throw new Error(`SampleNTT exhausted ${XOF_BYTES} XOF bytes at ${ctr}/256 coefficients`)
225
+ }
226
+ const d1 = (buf[pos]! | (buf[pos + 1]! << 8)) & 0xfff
227
+ const d2 = ((buf[pos + 1]! >> 4) | (buf[pos + 2]! << 4)) & 0xfff
228
+ pos += 3
229
+ if (d1 < Q) a[ctr++] = d1
230
+ if (ctr < N && d2 < Q) a[ctr++] = d2
231
+ }
232
+ return a
233
+ }
234
+
235
+ /** Algorithm 8 — centred binomial distribution with parameter eta. */
236
+ export function samplePolyCBD(bytes: Buffer, eta: number): Poly {
237
+ const f = newPoly()
238
+ // Bit i of the stream, little-endian within each byte.
239
+ const bit = (n: number): number => (bytes[n >> 3]! >> (n & 7)) & 1
240
+ for (let i = 0; i < N; i++) {
241
+ let x = 0
242
+ let y = 0
243
+ for (let k = 0; k < eta; k++) {
244
+ x += bit(2 * i * eta + k)
245
+ y += bit(2 * i * eta + eta + k)
246
+ }
247
+ f[i] = mod(x - y)
248
+ }
249
+ return f
250
+ }
251
+
252
+ // ============================================================================
253
+ // ENCODING AND COMPRESSION (FIPS 203 §4.2.1)
254
+ // ============================================================================
255
+
256
+ /** Algorithm 5 — pack 256 d-bit integers little-endian. */
257
+ export function byteEncode(f: Poly, d: number): Buffer {
258
+ const out = Buffer.alloc(32 * d)
259
+ let bit = 0
260
+ for (let i = 0; i < N; i++) {
261
+ const v = f[i]!
262
+ for (let b = 0; b < d; b++) {
263
+ if ((v >> b) & 1) out[bit >> 3] = out[bit >> 3]! | (1 << (bit & 7))
264
+ bit++
265
+ }
266
+ }
267
+ return out
268
+ }
269
+
270
+ /** Algorithm 6 — inverse of byteEncode. */
271
+ export function byteDecode(bytes: Buffer, d: number): Poly {
272
+ const f = newPoly()
273
+ let bit = 0
274
+ for (let i = 0; i < N; i++) {
275
+ let v = 0
276
+ for (let b = 0; b < d; b++) {
277
+ v |= ((bytes[bit >> 3]! >> (bit & 7)) & 1) << b
278
+ bit++
279
+ }
280
+ // d = 12 carries values mod q; the spec reduces on decode.
281
+ f[i] = d === 12 ? v % Q : v
282
+ }
283
+ return f
284
+ }
285
+
286
+ /** Compress_d: round(x·2^d / q) mod 2^d. */
287
+ function compress(f: Poly, d: number): Poly {
288
+ const r = newPoly()
289
+ const mask = (1 << d) - 1
290
+ for (let i = 0; i < N; i++) {
291
+ const t = (f[i]! << d) + (Q >> 1)
292
+ r[i] = ((t - (t % Q)) / Q) & mask
293
+ }
294
+ return r
295
+ }
296
+
297
+ /** Decompress_d: round(y·q / 2^d). */
298
+ function decompress(f: Poly, d: number): Poly {
299
+ const r = newPoly()
300
+ for (let i = 0; i < N; i++) {
301
+ const t = f[i]! * Q + (1 << (d - 1))
302
+ r[i] = t >> d
303
+ }
304
+ return r
305
+ }
306
+
307
+ // ============================================================================
308
+ // K-PKE — the IND-CPA scheme (FIPS 203 §5)
309
+ // ============================================================================
310
+
311
+ function encodeVec(v: PolyVec, d: number): Buffer {
312
+ return Buffer.concat(v.map((p) => byteEncode(p, d)))
313
+ }
314
+
315
+ function decodeVec(b: Buffer, d: number): PolyVec {
316
+ const out: PolyVec = []
317
+ for (let i = 0; i < K; i++) out.push(byteDecode(b.subarray(i * 32 * d, (i + 1) * 32 * d), d))
318
+ return out
319
+ }
320
+
321
+ /**
322
+ * Matrix A-hat. FIPS 203 keygen reads SampleNTT(rho ‖ j ‖ i) and encrypt reads
323
+ * SampleNTT(rho ‖ i ‖ j) — the transpose, expressed by swapping the two index
324
+ * bytes rather than by transposing the matrix afterwards.
325
+ */
326
+ function sampleMatrix(rho: Buffer, transposed: boolean): PolyVec[] {
327
+ const A: PolyVec[] = []
328
+ for (let i = 0; i < K; i++) {
329
+ const row: PolyVec = []
330
+ for (let j = 0; j < K; j++) {
331
+ row.push(transposed ? sampleNTT(rho, i, j) : sampleNTT(rho, j, i))
332
+ }
333
+ A.push(row)
334
+ }
335
+ return A
336
+ }
337
+
338
+ /** Algorithm 13 — K-PKE.KeyGen, from the expanded seeds. */
339
+ function pkeKeyGen(rho: Buffer, sigma: Buffer): { ekPKE: Buffer; dkPKE: Buffer } {
340
+ const A = sampleMatrix(rho, false)
341
+
342
+ let nonce = 0
343
+ const s: PolyVec = []
344
+ for (let i = 0; i < K; i++) s.push(samplePolyCBD(PRF(ETA1, sigma, nonce++), ETA1))
345
+ const e: PolyVec = []
346
+ for (let i = 0; i < K; i++) e.push(samplePolyCBD(PRF(ETA1, sigma, nonce++), ETA1))
347
+
348
+ const sHat = s.map(ntt)
349
+ const eHat = e.map(ntt)
350
+
351
+ const tHat: PolyVec = []
352
+ for (let i = 0; i < K; i++) tHat.push(polyAdd(vecDot(A[i]!, sHat), eHat[i]!))
353
+
354
+ return {
355
+ ekPKE: Buffer.concat([encodeVec(tHat, 12), rho]),
356
+ dkPKE: encodeVec(sHat, 12),
357
+ }
358
+ }
359
+
360
+ /** Algorithm 14 — K-PKE.Encrypt. */
361
+ function pkeEncrypt(ekPKE: Buffer, m: Buffer, coins: Buffer): Buffer {
362
+ const tHat = decodeVec(ekPKE.subarray(0, 384 * K), 12)
363
+ const rho = ekPKE.subarray(384 * K, 384 * K + 32)
364
+ const A = sampleMatrix(rho, true)
365
+
366
+ let nonce = 0
367
+ const r: PolyVec = []
368
+ for (let i = 0; i < K; i++) r.push(samplePolyCBD(PRF(ETA1, coins, nonce++), ETA1))
369
+ const e1: PolyVec = []
370
+ for (let i = 0; i < K; i++) e1.push(samplePolyCBD(PRF(ETA2, coins, nonce++), ETA2))
371
+ const e2 = samplePolyCBD(PRF(ETA2, coins, nonce++), ETA2)
372
+
373
+ const rHat = r.map(ntt)
374
+
375
+ const u: PolyVec = []
376
+ for (let i = 0; i < K; i++) u.push(polyAdd(nttInverse(vecDot(A[i]!, rHat)), e1[i]!))
377
+
378
+ const mu = decompress(byteDecode(m, 1), 1)
379
+ const v = polyAdd(polyAdd(nttInverse(vecDot(tHat, rHat)), e2), mu)
380
+
381
+ const c1 = Buffer.concat(u.map((p) => byteEncode(compress(p, DU), DU)))
382
+ const c2 = byteEncode(compress(v, DV), DV)
383
+ return Buffer.concat([c1, c2])
384
+ }
385
+
386
+ /** Algorithm 15 — K-PKE.Decrypt. */
387
+ function pkeDecrypt(dkPKE: Buffer, c: Buffer): Buffer {
388
+ const c1 = c.subarray(0, 32 * DU * K)
389
+ const c2 = c.subarray(32 * DU * K)
390
+
391
+ const u: PolyVec = []
392
+ for (let i = 0; i < K; i++) {
393
+ u.push(decompress(byteDecode(c1.subarray(i * 32 * DU, (i + 1) * 32 * DU), DU), DU))
394
+ }
395
+ const v = decompress(byteDecode(c2, DV), DV)
396
+ const sHat = decodeVec(dkPKE, 12)
397
+
398
+ const w = polySub(v, nttInverse(vecDot(sHat, u.map(ntt))))
399
+ return byteEncode(compress(w, 1), 1)
400
+ }
401
+
402
+ // ============================================================================
403
+ // ML-KEM — the IND-CCA2 KEM (FIPS 203 §6)
404
+ // ============================================================================
405
+
406
+ export interface KeyPair {
407
+ readonly encapsulationKey: Buffer
408
+ readonly decapsulationKey: Buffer
409
+ }
410
+
411
+ export interface Encapsulation {
412
+ readonly ciphertext: Buffer
413
+ readonly sharedSecret: Buffer
414
+ }
415
+
416
+ /**
417
+ * Key generation from already-expanded seeds.
418
+ *
419
+ * Split out because the ONLY difference between FIPS 203 final and the initial
420
+ * public draft is how (rho, sigma) come from d — final binds the parameter set
421
+ * in, ipd did not. Everything below this line is identical in both, which is
422
+ * what lets `scripts/ml-kem-accumulated-kat.mjs` drive the 10 000-case
423
+ * pq-crystals reference KAT (published for ipd) against this exact code without
424
+ * an ipd branch existing in the production path.
425
+ */
426
+ export function keyGenFromSeeds(rho: Buffer, sigma: Buffer, z: Buffer): KeyPair {
427
+ const { ekPKE, dkPKE } = pkeKeyGen(rho, sigma)
428
+ return {
429
+ encapsulationKey: ekPKE,
430
+ decapsulationKey: Buffer.concat([dkPKE, ekPKE, H(ekPKE), z]),
431
+ }
432
+ }
433
+
434
+ /** Algorithm 16 — ML-KEM.KeyGen_internal, deterministic in (d, z). */
435
+ export function keyGenDerand(d: Buffer, z: Buffer): KeyPair {
436
+ const [rho, sigma] = G(Buffer.concat([d, Buffer.from([K])]))
437
+ return keyGenFromSeeds(rho, sigma, z)
438
+ }
439
+
440
+ /** SHA3-512 seed expansion, exposed so the reference KAT can supply its own. */
441
+ export function expandSeed(d: Buffer): [Buffer, Buffer] {
442
+ return G(d)
443
+ }
444
+
445
+ /** Algorithm 17 — ML-KEM.Encaps_internal, deterministic in m. */
446
+ export function encapsDerand(ek: Buffer, m: Buffer): Encapsulation {
447
+ if (ek.length !== ML_KEM_768.encapsulationKeyBytes) {
448
+ throw new Error(`encapsulation key must be ${ML_KEM_768.encapsulationKeyBytes} bytes, got ${ek.length}`)
449
+ }
450
+ // Modulus check (FIPS 203 §7.2): the encoded coefficients must be reduced,
451
+ // i.e. re-encoding what we decoded must reproduce the input byte for byte.
452
+ const tHat = decodeVec(ek.subarray(0, 384 * K), 12)
453
+ if (!encodeVec(tHat, 12).equals(ek.subarray(0, 384 * K))) {
454
+ throw new Error('encapsulation key failed the modulus check')
455
+ }
456
+
457
+ const [sharedSecret, coins] = G(Buffer.concat([m, H(ek)]))
458
+ return { ciphertext: pkeEncrypt(ek, m, coins), sharedSecret }
459
+ }
460
+
461
+ /** Algorithm 18 — ML-KEM.Decaps_internal, with implicit rejection. */
462
+ export function decaps(dk: Buffer, c: Buffer): Buffer {
463
+ if (dk.length !== ML_KEM_768.decapsulationKeyBytes) {
464
+ throw new Error(`decapsulation key must be ${ML_KEM_768.decapsulationKeyBytes} bytes, got ${dk.length}`)
465
+ }
466
+ if (c.length !== ML_KEM_768.ciphertextBytes) {
467
+ throw new Error(`ciphertext must be ${ML_KEM_768.ciphertextBytes} bytes, got ${c.length}`)
468
+ }
469
+
470
+ const dkPKE = dk.subarray(0, 384 * K)
471
+ const ek = dk.subarray(384 * K, 768 * K + 32)
472
+ const h = dk.subarray(768 * K + 32, 768 * K + 64)
473
+ const z = dk.subarray(768 * K + 64, 768 * K + 96)
474
+
475
+ const mPrime = pkeDecrypt(dkPKE, c)
476
+ const [kPrime, coins] = G(Buffer.concat([mPrime, h]))
477
+ const kBar = J(Buffer.concat([z, c]))
478
+ const cPrime = pkeEncrypt(ek, mPrime, coins)
479
+
480
+ // Full-length comparison. A length-terminated compare (strcmp) would accept
481
+ // some wrong ciphertexts once a zero byte appears — CCTV ships vectors for it.
482
+ return cPrime.equals(c) ? kPrime : kBar
483
+ }
484
+
485
+ /** ML-KEM.KeyGen — draws its own randomness. */
486
+ export function keyGen(): KeyPair {
487
+ return keyGenDerand(randomBytes(32), randomBytes(32))
488
+ }
489
+
490
+ /** ML-KEM.Encaps — draws its own randomness. */
491
+ export function encaps(ek: Buffer): Encapsulation {
492
+ return encapsDerand(ek, randomBytes(32))
493
+ }
494
+
495
+ // ============================================================================
496
+ // SELF-CHECK — ring identities the twiddle factors must satisfy
497
+ // ============================================================================
498
+
499
+ /** Structural facts that hold for any correct NTT. Returns failures. */
500
+ export function selfTest(): string[] {
501
+ const fail: string[] = []
502
+
503
+ // zeta = 17 has order 256 in Z_q^*, so zeta^128 = -1.
504
+ let z128 = 1
505
+ for (let i = 0; i < 128; i++) z128 = (z128 * 17) % Q
506
+ if (z128 !== Q - 1) fail.push(`17^128 = ${z128}, expected ${Q - 1}`)
507
+
508
+ // NTT is invertible.
509
+ const f = newPoly()
510
+ for (let i = 0; i < N; i++) f[i] = (i * 7 + 3) % Q
511
+ const back = nttInverse(ntt(f))
512
+ for (let i = 0; i < N; i++) {
513
+ if (back[i] !== f[i]) { fail.push(`NTT round trip differs at ${i}: ${f[i]} -> ${back[i]}`); break }
514
+ }
515
+
516
+ // NTT is a ring homomorphism: it carries negacyclic convolution to pointwise
517
+ // multiplication. Checked against a schoolbook product in X^256 + 1.
518
+ const a = newPoly()
519
+ const b = newPoly()
520
+ for (let i = 0; i < N; i++) { a[i] = (i * 13 + 5) % Q; b[i] = (i * 29 + 11) % Q }
521
+ const school = newPoly()
522
+ for (let i = 0; i < N; i++) {
523
+ let acc = 0
524
+ for (let j = 0; j <= i; j++) acc = (acc + a[j]! * b[i - j]!) % Q
525
+ for (let j = i + 1; j < N; j++) acc = (acc - a[j]! * b[N + i - j]!) % Q
526
+ school[i] = mod(acc)
527
+ }
528
+ const viaNTT = nttInverse(multiplyNTTs(ntt(a), ntt(b)))
529
+ for (let i = 0; i < N; i++) {
530
+ if (viaNTT[i] !== school[i]) {
531
+ fail.push(`NTT product differs from schoolbook at ${i}: ${viaNTT[i]} vs ${school[i]}`)
532
+ break
533
+ }
534
+ }
535
+
536
+ // Compress/Decompress must stay inside the spec's error bound q/2^(d+1).
537
+ for (const d of [DU, DV, 1]) {
538
+ let worst = 0
539
+ const p = newPoly()
540
+ for (let i = 0; i < N; i++) p[i] = (i * 13) % Q
541
+ const back2 = decompress(compress(p, d), d)
542
+ for (let i = 0; i < N; i++) {
543
+ let e = back2[i]! - p[i]!
544
+ if (e > Q / 2) e -= Q
545
+ if (e < -Q / 2) e += Q
546
+ const ae = e < 0 ? -e : e
547
+ if (ae > worst) worst = ae
548
+ }
549
+ const bound = Q / (1 << (d + 1)) + 1
550
+ if (worst > bound) fail.push(`compress d=${d} error ${worst} exceeds bound ${bound}`)
551
+ }
552
+
553
+ return fail
554
+ }