@noble/post-quantum 0.6.1 → 0.7.1

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/slh-dsa.js CHANGED
@@ -28,10 +28,17 @@
28
28
  */
29
29
  /*! noble-post-quantum - MIT License (c) 2024 Paul Miller (paulmillr.com) */
30
30
  import { hmac } from '@noble/hashes/hmac.js';
31
+ import { bytesToNumberBE, numberToBytesBE } from '@noble/curves/utils.js';
31
32
  import { sha256, sha512 } from '@noble/hashes/sha2.js';
32
33
  import { shake256 } from '@noble/hashes/sha3.js';
33
- import { bytesToHex, concatBytes, createView, hexToBytes, } from '@noble/hashes/utils.js';
34
+ import { concatBytes, createView } from '@noble/hashes/utils.js';
34
35
  import { abytes, checkHash, cleanBytes, copyBytes, equalBytes, getMask, getMessage, getMessagePrehash, randomBytes, splitCoder, validateSigOpts, validateVerOpts, vecCoder, } from "./utils.js";
36
+ // Keys the internal SLH-DSA surface accepts. `context` is deliberately absent: the public
37
+ // wrappers consume it when they format M' and must not forward it, because a key that is
38
+ // accepted and then never read is the same silent downgrade this validation exists to prevent.
39
+ // `extraEntropy` is signing-only, so verification (which takes no options of its own) has none.
40
+ const INTERNAL_SIG_OPT_KEYS = /* @__PURE__ */ Object.freeze(['extraEntropy']);
41
+ const INTERNAL_VER_OPT_KEYS = /* @__PURE__ */ Object.freeze([]);
35
42
  /** Winternitz signature params. */
36
43
  /**
37
44
  * Built-in SLH-DSA Table 2 subset keyed by strength/profile.
@@ -61,19 +68,6 @@ const AddressType = {
61
68
  WOTSPRF: 5,
62
69
  FORSPRF: 6,
63
70
  };
64
- function hexToNumber(hex) {
65
- if (typeof hex !== 'string')
66
- throw new Error('hex string expected, got ' + typeof hex);
67
- return BigInt(hex === '' ? '0' : '0x' + hex); // Big Endian
68
- }
69
- // BE: Big Endian, LE: Little Endian. This is the local FIPS 205 `toInt(...)` equivalent.
70
- function bytesToNumberBE(bytes) {
71
- return hexToNumber(bytesToHex(bytes));
72
- }
73
- // Local in-range FIPS 205 `toByte(x, n)` equivalent; callers must keep `n < 256^len`.
74
- function numberToBytesBE(n, len) {
75
- return hexToBytes(n.toString(16).padStart(len * 2, '0'));
76
- }
77
71
  // Local FIPS 205 Algorithm 4 `base_2^b(...)` implementation. Bits are consumed in big-endian
78
72
  // order within each input byte, and callers must provide at least `ceil(outLen * b / 8)` bytes;
79
73
  // short inputs are not rejected and would zero-extend implicitly.
@@ -92,8 +86,11 @@ const base2b = (outLen, b) => {
92
86
  return baseB;
93
87
  };
94
88
  };
89
+ const _1n = /* @__PURE__ */ BigInt(1);
90
+ const _8n = /* @__PURE__ */ BigInt(8);
91
+ const _0xffn = /* @__PURE__ */ BigInt(0xff);
95
92
  function getMaskBig(bits) {
96
- return (1n << BigInt(bits)) - 1n; // 4 -> 0b1111
93
+ return (_1n << BigInt(bits)) - _1n; // 4 -> 0b1111
97
94
  }
98
95
  /** One parameter/hash instantiation of the public SLH-DSA API.
99
96
  * `keygen(seed)` is a deterministic 3N-byte library hook around the internal keygen flow,
@@ -137,9 +134,18 @@ function gen(opts, hashOpts_) {
137
134
  // `height` / `chain` and `index` / `hash` share the same spec words, so callers must use the
138
135
  // address-type-specific combinations instead of mixing both meanings in one call.
139
136
  const setAddr = (opts, addr = new Uint8Array(ADDR_BYTES)) => {
140
- const { type, height, tree, layer, index, chain, hash, keypair } = opts;
141
- const { subtreeAddr, keypairAddr } = opts;
142
- const v = createView(addr);
137
+ // These objects are created in hot internal loops, so avoid cloning them. Read only own fields:
138
+ // absent address words must stay absent even if Object.prototype was polluted.
139
+ const type = Object.hasOwn(opts, 'type') ? opts.type : undefined;
140
+ const height = Object.hasOwn(opts, 'height') ? opts.height : undefined;
141
+ const tree = Object.hasOwn(opts, 'tree') ? opts.tree : undefined;
142
+ const layer = Object.hasOwn(opts, 'layer') ? opts.layer : undefined;
143
+ const index = Object.hasOwn(opts, 'index') ? opts.index : undefined;
144
+ const chain = Object.hasOwn(opts, 'chain') ? opts.chain : undefined;
145
+ const hash = Object.hasOwn(opts, 'hash') ? opts.hash : undefined;
146
+ const keypair = Object.hasOwn(opts, 'keypair') ? opts.keypair : undefined;
147
+ const subtreeAddr = Object.hasOwn(opts, 'subtreeAddr') ? opts.subtreeAddr : undefined;
148
+ const keypairAddr = Object.hasOwn(opts, 'keypairAddr') ? opts.keypairAddr : undefined;
143
149
  if (height !== undefined)
144
150
  addr[OFFSET_CHAIN_ADDR] = height;
145
151
  if (layer !== undefined)
@@ -150,12 +156,21 @@ function gen(opts, hashOpts_) {
150
156
  addr[OFFSET_CHAIN_ADDR] = chain;
151
157
  if (hash !== undefined)
152
158
  addr[OFFSET_HASH_ADDR] = hash;
153
- if (index !== undefined)
154
- v.setUint32(OFFSET_TREE_INDEX, index, false);
159
+ // Manual big-endian writes: setAddr runs in the innermost WOTS/tree loops, and creating a
160
+ // DataView per call was a measurable share of sign() time.
161
+ if (index !== undefined) {
162
+ addr[OFFSET_TREE_INDEX + 0] = index >>> 24;
163
+ addr[OFFSET_TREE_INDEX + 1] = index >>> 16;
164
+ addr[OFFSET_TREE_INDEX + 2] = index >>> 8;
165
+ addr[OFFSET_TREE_INDEX + 3] = index;
166
+ }
155
167
  if (subtreeAddr)
156
168
  addr.set(subtreeAddr.subarray(0, OFFSET_TREE + 8));
157
- if (tree !== undefined)
158
- v.setBigUint64(OFFSET_TREE, tree, false);
169
+ if (tree !== undefined) {
170
+ let t = tree;
171
+ for (let i = 7; i >= 0; i--, t >>= _8n)
172
+ addr[OFFSET_TREE + i] = Number(t & _0xffn);
173
+ }
159
174
  if (keypair !== undefined) {
160
175
  addr[OFFSET_KP_ADDR1] = keypair;
161
176
  if (TREE_HEIGHT > 8)
@@ -209,10 +224,12 @@ function gen(opts, hashOpts_) {
209
224
  const maxIdx = (1 << height) - 1;
210
225
  const stack = new Uint8Array(height * N);
211
226
  const authPath = new Uint8Array(height * N);
227
+ // One node buffer per treehash call (not per leaf): both halves are fully overwritten at
228
+ // each use, and the returned root aliases cur1, which is never reused after return.
229
+ const current = new Uint8Array(2 * N);
230
+ const cur0 = current.subarray(0, N);
231
+ const cur1 = current.subarray(N);
212
232
  for (let idx = 0;; idx++) {
213
- const current = new Uint8Array(2 * N);
214
- const cur0 = current.subarray(0, N);
215
- const cur1 = current.subarray(N);
216
233
  const addrOffset = idx + idxOffset;
217
234
  cur1.set(leafFn(leafIdx, addrOffset, rawContext, info));
218
235
  let h = 0;
@@ -360,7 +377,7 @@ function gen(opts, hashOpts_) {
360
377
  return Uint8Array.from(pk);
361
378
  },
362
379
  sign: (msg, sk, opts = {}) => {
363
- validateSigOpts(opts);
380
+ opts = validateSigOpts(opts, INTERNAL_SIG_OPT_KEYS);
364
381
  let { extraEntropy: random } = opts;
365
382
  const [skSeed, skPRF, pk] = secretCoder.decode(sk); // todo: fix
366
383
  const [pkSeed, _] = publicCoder.decode(pk);
@@ -394,7 +411,9 @@ function gen(opts, hashOpts_) {
394
411
  height: 0,
395
412
  index: indices[i] + idxOffset,
396
413
  }, forsTreeAddr);
397
- const prf = context.PRFaddr(forsTreeAddr);
414
+ // Copy: PRFaddr returns a per-context scratch view, and this value is retained in
415
+ // `fors` across the many PRFaddr calls inside forsTreehash below.
416
+ const prf = copyBytes(context.PRFaddr(forsTreeAddr));
398
417
  setAddr({ type: AddressType.FORSTREE }, forsTreeAddr);
399
418
  const { root, authPath } = forsTreehash(context, indices[i], idxOffset, forsTreeAddr, forsLeaf);
400
419
  roots.push(root);
@@ -404,7 +423,9 @@ function gen(opts, hashOpts_) {
404
423
  type: AddressType.FORSPK,
405
424
  keypairAddr: wotsAddr,
406
425
  });
407
- const root = context.thashN(K, concatBytes(...roots), forsPkAddr);
426
+ // Copy: thashN returns a per-context scratch view, and `root` lives across every hash
427
+ // call in the hypertree loop below (it is also mutated via root.set).
428
+ const root = copyBytes(context.thashN(K, concatBytes(...roots), forsPkAddr));
408
429
  // WOTS signatures
409
430
  const treeAddr = setAddr({ type: AddressType.HASHTREE });
410
431
  const wots = [];
@@ -422,12 +443,20 @@ function gen(opts, hashOpts_) {
422
443
  cleanBytes(R, random, treeAddr, wotsAddr, forsLeaf, forsTreeAddr, indices, roots);
423
444
  return SIG;
424
445
  },
425
- verify: (sig, msg, publicKey) => {
446
+ verify: (sig, msg, publicKey, opts = {}) => {
447
+ // The internal verify reads no options; reject any so a stray key (e.g. a caller
448
+ // mistaking this for the public verify and passing `context`) is reported rather than
449
+ // silently swallowed by this function's arity.
450
+ validateVerOpts(opts, INTERNAL_VER_OPT_KEYS);
426
451
  const [pkSeed, pubRoot] = publicCoder.decode(publicKey);
427
- const [random, forsVec, wotsVec] = sigCoder.decode(sig);
428
452
  const pk = publicKey;
453
+ // FIPS 205 Algorithm 20 step 1: wrong-length signatures return false instead of throwing
454
+ // (same as ml-dsa). Must run before sigCoder.decode, which throws on length mismatch.
455
+ // Preserve TypeError for non-byte API arguments before treating byte lengths as invalid.
456
+ abytes(sig, undefined, 'signature');
429
457
  if (sig.length !== sigCoder.bytesLen)
430
458
  return false;
459
+ const [random, forsVec, wotsVec] = sigCoder.decode(sig);
431
460
  const context = getContext(pkSeed);
432
461
  let { tree, leafIdx, md } = hashMessage(random, pk, msg, context);
433
462
  const wotsAddr = setAddr({
@@ -447,14 +476,16 @@ function gen(opts, hashOpts_) {
447
476
  const idxOffset = i << A;
448
477
  setAddr({ height: 0, index: indices[i] + idxOffset }, forsTreeAddr);
449
478
  const leaf = context.thash1(prf, forsTreeAddr);
450
- // Compute inplace, because we need all roots in same byte array
451
- roots.push(computeRoot(leaf, indices[i], idxOffset, authPath, A, context, forsTreeAddr));
479
+ // Copy: computeRoot returns a thashN scratch view, and roots are retained across the
480
+ // remaining FORS iterations (computeRoot itself copies `leaf` before hashing).
481
+ roots.push(copyBytes(computeRoot(leaf, indices[i], idxOffset, authPath, A, context, forsTreeAddr)));
452
482
  }
453
483
  const forsPkAddr = setAddr({
454
484
  type: AddressType.FORSPK,
455
485
  keypairAddr: wotsAddr,
456
486
  });
457
- let root = context.thashN(K, concatBytes(...roots), forsPkAddr); // root = thash()
487
+ // Copy: `root` must survive the thash1/thashN calls of the WOTS chain loop below.
488
+ let root = copyBytes(context.thashN(K, concatBytes(...roots), forsPkAddr)); // root = thash()
458
489
  // WOTS signature
459
490
  const treeAddr = setAddr({ type: AddressType.HASHTREE });
460
491
  const wotsPkAddr = setAddr({ type: AddressType.WOTSPK });
@@ -477,7 +508,8 @@ function gen(opts, hashOpts_) {
477
508
  }
478
509
  }
479
510
  const leaf = context.thashN(WOTS_LEN, wotsPk, wotsPkAddr);
480
- root = computeRoot(leaf, leafIdx, 0, sigAuth, TREE_HEIGHT, context, treeAddr);
511
+ // Copy: `root` is read by chainLengths / equalBytes after later hash calls.
512
+ root = copyBytes(computeRoot(leaf, leafIdx, 0, sigAuth, TREE_HEIGHT, context, treeAddr));
481
513
  leafIdx = Number(tree & getMaskBig(TREE_HEIGHT));
482
514
  }
483
515
  return equalBytes(root, pubRoot);
@@ -491,14 +523,16 @@ function gen(opts, hashOpts_) {
491
523
  keygen: internal.keygen,
492
524
  getPublicKey: internal.getPublicKey,
493
525
  sign: (msg, secretKey, opts = {}) => {
494
- validateSigOpts(opts);
526
+ opts = validateSigOpts(opts);
495
527
  const M = getMessage(msg, opts.context);
496
- const res = internal.sign(M, secretKey, opts);
528
+ // `context` is consumed by getMessage() above; forwarding it would make the internal
529
+ // surface accept a key it never reads.
530
+ const res = internal.sign(M, secretKey, { extraEntropy: opts.extraEntropy });
497
531
  cleanBytes(M);
498
532
  return res;
499
533
  },
500
534
  verify: (sig, msg, publicKey, opts = {}) => {
501
- validateVerOpts(opts);
535
+ opts = validateVerOpts(opts);
502
536
  return internal.verify(sig, getMessage(msg, opts.context), publicKey);
503
537
  },
504
538
  prehash: (hash) => {
@@ -510,14 +544,15 @@ function gen(opts, hashOpts_) {
510
544
  keygen: internal.keygen,
511
545
  getPublicKey: internal.getPublicKey,
512
546
  sign: (msg, secretKey, opts = {}) => {
513
- validateSigOpts(opts);
547
+ opts = validateSigOpts(opts);
514
548
  const M = getMessagePrehash(rawHash, msg, opts.context);
515
- const res = internal.sign(M, secretKey, opts);
549
+ // As above: getMessagePrehash() consumes `context`, so it must not travel further.
550
+ const res = internal.sign(M, secretKey, { extraEntropy: opts.extraEntropy });
516
551
  cleanBytes(M);
517
552
  return res;
518
553
  },
519
554
  verify: (sig, msg, publicKey, opts = {}) => {
520
- validateVerOpts(opts);
555
+ opts = validateVerOpts(opts);
521
556
  return internal.verify(sig, getMessagePrehash(rawHash, msg, opts.context), publicKey);
522
557
  },
523
558
  });
@@ -533,21 +568,26 @@ const genShake = () => (opts) => (pubSeed, skSeed) => {
533
568
  // for each address-bound call instead of reabsorbing the same seed every time.
534
569
  const h0 = shake256.create({}).update(pubSeed);
535
570
  const h0tmp = h0.clone();
571
+ // Per-context output scratch: thash1/thashN/PRFaddr return these buffers directly, so
572
+ // callers must consume or copy a result before the next call on the same lane.
573
+ const thashOut = new Uint8Array(N);
574
+ const prfOut = new Uint8Array(N);
536
575
  const thash = (blocks, input, addr) => {
537
576
  stats.thash++;
538
- return h0
539
- ._cloneInto(h0tmp)
577
+ const len = blocks * N;
578
+ h0._cloneInto(h0tmp)
540
579
  .update(addr)
541
- .update(input.subarray(0, blocks * N))
542
- .xof(N);
580
+ .update(input.length === len ? input : input.subarray(0, len))
581
+ .xofInto(thashOut);
582
+ return thashOut;
543
583
  };
544
584
  return {
545
585
  PRFaddr: (addr) => {
546
586
  if (!skSeed)
547
587
  throw new Error('no sk seed');
548
588
  stats.prf++;
549
- const res = h0._cloneInto(h0tmp).update(addr).update(skSeed).xof(N);
550
- return res;
589
+ h0._cloneInto(h0tmp).update(addr).update(skSeed).xofInto(prfOut);
590
+ return prfOut;
551
591
  },
552
592
  PRFmsg: (skPRF, random, msg) => {
553
593
  stats.gen_message_random++;
@@ -568,11 +608,12 @@ const genShake = () => (opts) => (pubSeed, skSeed) => {
568
608
  clean: () => {
569
609
  h0.destroy();
570
610
  h0tmp.destroy();
611
+ cleanBytes(thashOut, prfOut);
571
612
  //console.log(stats);
572
613
  },
573
614
  };
574
615
  };
575
- const SHAKE_SIMPLE = /* @__PURE__ */ (() => ({ getContext: genShake() }))();
616
+ const SHAKE_SIMPLE = /* @__PURE__ */ (() => ({ isCompressed: false, getContext: genShake() }))();
576
617
  /**
577
618
  * SLH-DSA-SHAKE-128f: Table 2 row `n=16, h=66, d=22, h'=3, a=6, k=33, lg w=4, m=34`;
578
619
  * lengths `publicKey=32`, `secretKey=64`, `signature=17088`, `seed=48`, `signRand=16`.
@@ -635,6 +676,16 @@ const genSha = (h0, h1) => (opts) => (pub_seed, sk_seed) => {
635
676
  .update(new Uint8Array(h1.blockLen - N));
636
677
  const h0tmp = h0ps.clone();
637
678
  const h1tmp = h1ps.clone();
679
+ // Per-context output scratch: thash1/thashN/PRFaddr return views into these buffers, so
680
+ // callers must consume or copy a result before the next call on the same lane (see Context
681
+ // docs). digestInto also skips digest()'s per-call destroy(): the tmp states are fully
682
+ // overwritten by the next _cloneInto and wiped in clean().
683
+ const h0out = new Uint8Array(h0.outputLen);
684
+ const h1out = new Uint8Array(h1.outputLen);
685
+ const prfOut = new Uint8Array(h0.outputLen);
686
+ const h0outN = h0out.subarray(0, N);
687
+ const h1outN = h1out.subarray(0, N);
688
+ const prfOutN = prfOut.subarray(0, N);
638
689
  // https://www.rfc-editor.org/rfc/rfc8017.html#appendix-B.2.1
639
690
  // This local helper is intentionally stricter than generic MGF1 reuse: current SLH-DSA callers
640
691
  // only request tiny `m`-byte outputs, but the guard below rejects `length > 2^32` instead of
@@ -653,27 +704,26 @@ const genSha = (h0, h1) => (opts) => (pub_seed, sk_seed) => {
653
704
  cleanBytes(out.subarray(length));
654
705
  return out.subarray(0, length);
655
706
  }
656
- const thash = (_, h, hTmp) => (blocks, input, addr) => {
707
+ const thash = (h, hTmp, out, outN) => (blocks, input, addr) => {
657
708
  stats.thash++;
658
- const d = h
659
- ._cloneInto(hTmp)
709
+ const len = blocks * N;
710
+ h._cloneInto(hTmp)
660
711
  .update(addr)
661
- .update(input.subarray(0, blocks * N))
662
- .digest();
663
- return d.subarray(0, N);
712
+ .update(input.length === len ? input : input.subarray(0, len))
713
+ .digestInto(out);
714
+ return outN;
664
715
  };
665
716
  return {
666
717
  PRFaddr: (addr) => {
667
718
  if (!sk_seed)
668
719
  throw new Error('No sk seed');
669
720
  stats.prf++;
670
- const res = h0ps
721
+ h0ps
671
722
  ._cloneInto(h0tmp)
672
723
  .update(addr)
673
724
  .update(sk_seed)
674
- .digest()
675
- .subarray(0, N);
676
- return res;
725
+ .digestInto(prfOut);
726
+ return prfOutN;
677
727
  },
678
728
  PRFmsg: (skPRF, random, msg) => {
679
729
  stats.gen_message_random++;
@@ -689,13 +739,14 @@ const genSha = (h0, h1) => (opts) => (pub_seed, sk_seed) => {
689
739
  const seed = concatBytes(R.subarray(0, N), pk.subarray(0, N), h1.create().update(R.subarray(0, N)).update(pk).update(m).digest());
690
740
  return mgf1(seed, outLen, h1);
691
741
  },
692
- thash1: thash(h0, h0ps, h0tmp).bind(null, 1),
693
- thashN: thash(h1, h1ps, h1tmp),
742
+ thash1: thash(h0ps, h0tmp, h0out, h0outN).bind(null, 1),
743
+ thashN: thash(h1ps, h1tmp, h1out, h1outN),
694
744
  clean: () => {
695
745
  h0ps.destroy();
696
746
  h1ps.destroy();
697
747
  h0tmp.destroy();
698
748
  h1tmp.destroy();
749
+ cleanBytes(h0out, h1out, prfOut);
699
750
  //console.log(stats);
700
751
  },
701
752
  };
@@ -712,6 +763,23 @@ const SHA512_SIMPLE = /* @__PURE__ */ (() => ({
712
763
  * SLH-DSA-SHA2-128f: Table 2 row `n=16, h=66, d=22, h'=3, a=6, k=33, lg w=4, m=34`;
713
764
  * lengths `publicKey=32`, `secretKey=64`, `signature=17088`, `seed=48`, `signRand=16`.
714
765
  * Also exposes `.prehash(...)`.
766
+ * @example
767
+ * Generate deterministic SLH-DSA keys, sign one message, and verify the signature.
768
+ * ```ts
769
+ * import { sha256 } from '@noble/hashes/sha2.js';
770
+ * import { slh_dsa_sha2_128f } from '@noble/post-quantum/slh-dsa.js';
771
+ * const seed = new Uint8Array(slh_dsa_sha2_128f.lengths.seed!);
772
+ * const { secretKey, publicKey } = slh_dsa_sha2_128f.keygen(seed);
773
+ * const msg = new TextEncoder().encode('hello noble');
774
+ * const sig = slh_dsa_sha2_128f.sign(msg, secretKey);
775
+ * const isValid = slh_dsa_sha2_128f.verify(sig, msg, publicKey);
776
+ * const recovered = slh_dsa_sha2_128f.getPublicKey(secretKey);
777
+ * const context = new Uint8Array([1, 2, 3]);
778
+ * const prehash = slh_dsa_sha2_128f.prehash(sha256);
779
+ * const preSig = prehash.sign(msg, secretKey, { context });
780
+ * const preValid = prehash.verify(preSig, msg, publicKey, { context });
781
+ * const internalSig = slh_dsa_sha2_128f.internal.sign(msg, secretKey);
782
+ * ```
715
783
  */
716
784
  export const slh_dsa_sha2_128f = /* @__PURE__ */ (() => gen(PARAMS['128f'], SHA256_SIMPLE))();
717
785
  /**
@@ -744,4 +812,3 @@ export const slh_dsa_sha2_256f = /* @__PURE__ */ (() => gen(PARAMS['256f'], SHA5
744
812
  * Also exposes `.prehash(...)`.
745
813
  */
746
814
  export const slh_dsa_sha2_256s = /* @__PURE__ */ (() => gen(PARAMS['256s'], SHA512_SIMPLE))();
747
- //# sourceMappingURL=slh-dsa.js.map
package/src/_crystals.ts CHANGED
@@ -73,9 +73,15 @@ type Crystals<T extends TypedArray> = {
73
73
  smod: (a: number, modulo?: number) => number;
74
74
  nttZetas: T;
75
75
  NTT: {
76
- /** Forward transform in place. Mutates and returns `r`. */
76
+ /**
77
+ * Forward transform in place. Mutates and returns `r`.
78
+ * Kyber-mode input coefficients must already use canonical representatives in `[0, Q)`.
79
+ */
77
80
  encode: (r: T) => T;
78
- /** Inverse transform in place. Mutates and returns `r`. */
81
+ /**
82
+ * Inverse transform in place. Mutates and returns `r`.
83
+ * Kyber-mode input coefficients must already use canonical representatives in `[0, Q)`.
84
+ */
79
85
  decode: (r: T) => T;
80
86
  };
81
87
  bitsCoder: (d: number, c: Coder<number, number>) => BytesCoderLen<T>;
@@ -106,7 +112,7 @@ export const genCrystals = <T extends TypedArray>(opts: CrystalOpts<T>): TRet<Cr
106
112
  // Normalize JS `%` into the canonical Z_m representative `[0, modulo-1]` expected by
107
113
  // FIPS 203 §2.3 / FIPS 204 §2.3 before downstream mod-q arithmetic.
108
114
  const mod = (a: number, modulo = Q): number => {
109
- const result = a % modulo | 0;
115
+ const result = (a % modulo) | 0;
110
116
  return (result >= 0 ? result | 0 : (modulo + result) | 0) | 0;
111
117
  };
112
118
  // FIPS 204 §7.4 uses the centered `mod ±` representative for low bits, keeping the
@@ -135,14 +141,34 @@ export const genCrystals = <T extends TypedArray>(opts: CrystalOpts<T>): TRet<Cr
135
141
  // Kyber has slightly different params, since there is no 512th primitive root of unity mod q,
136
142
  // only 256th primitive root of unity mod. Which also complicates MultiplyNTT.
137
143
 
138
- const field = {
139
- add: (a: number, b: number) => mod((a | 0) + (b | 0)) | 0,
140
- sub: (a: number, b: number) => mod((a | 0) - (b | 0)) | 0,
141
- mul: (a: number, b: number) => mod((a | 0) * (b | 0)) | 0,
142
- inv: (_a: number) => {
143
- throw new Error('not implemented');
144
- },
144
+ const inv = (_a: number) => {
145
+ throw new Error('not implemented');
145
146
  };
147
+ // ML-KEM (Kyber) polynomials always enter the transform reduced to [0, Q), so add/sub only
148
+ // need one conditional correction instead of `%`; measured ~20% faster NTT there.
149
+ // ML-DSA keeps the generic mod() path on purpose: its first forward stage sees centered
150
+ // (negative) coefficients, and `sub(a, t)` can drop below -Q (t is a mul output in [0, Q)),
151
+ // so a single correction is not enough. A guarded fast path with mod() fallback was measured
152
+ // slower than plain `%` for the 23-bit Q (V8 int32 modulo is one div; the branches lose).
153
+ const field = isKyber
154
+ ? {
155
+ add: (a: number, b: number) => {
156
+ const r = (a + b) | 0;
157
+ return r >= Q ? (r - Q) | 0 : r;
158
+ },
159
+ sub: (a: number, b: number) => {
160
+ const r = (a - b) | 0;
161
+ return r < 0 ? (r + Q) | 0 : r;
162
+ },
163
+ mul: (a: number, b: number) => mod((a | 0) * (b | 0)) | 0,
164
+ inv,
165
+ }
166
+ : {
167
+ add: (a: number, b: number) => mod((a | 0) + (b | 0)) | 0,
168
+ sub: (a: number, b: number) => mod((a | 0) - (b | 0)) | 0,
169
+ mul: (a: number, b: number) => mod((a | 0) * (b | 0)) | 0,
170
+ inv,
171
+ };
146
172
  const nttOpts = {
147
173
  N,
148
174
  roots: nttZetas as any,
@@ -168,6 +194,12 @@ export const genCrystals = <T extends TypedArray>(opts: CrystalOpts<T>): TRet<Cr
168
194
  // Pack one little-endian `d`-bit word per coefficient, matching FIPS 203 ByteEncode /
169
195
  // ByteDecode and the FIPS 204 BitsToBytes-based polynomial packing helpers.
170
196
  const bitsCoder = (d: number, c: Coder<number, number>): TRet<BytesCoderLen<T>> => {
197
+ // Validate the carry shape once: JS bitwise operations silently truncate wider accumulators.
198
+ for (let i = 0, bufLen = 0; i < N; i++) {
199
+ bufLen += d;
200
+ if (bufLen > 32) getMask(bufLen);
201
+ bufLen %= 8;
202
+ }
171
203
  const mask = getMask(d);
172
204
  const bytesLen = d * (N / 8);
173
205
  return {
@@ -178,7 +210,9 @@ export const genCrystals = <T extends TypedArray>(opts: CrystalOpts<T>): TRet<Cr
178
210
  for (let i = 0, buf = 0, bufLen = 0, pos = 0; i < poly.length; i++) {
179
211
  buf |= (c.encode(poly[i]) & mask) << bufLen;
180
212
  bufLen += d;
181
- for (; bufLen >= 8; bufLen -= 8, buf >>= 8) r[pos++] = buf & getMask(bufLen);
213
+ // Take the low byte directly: `& 0xff` matches the previous getMask(bufLen) result
214
+ // after Uint8Array truncation, without a validated function call per output byte.
215
+ for (; bufLen >= 8; bufLen -= 8, buf >>= 8) r[pos++] = buf & 0xff;
182
216
  }
183
217
  return r as TRet<Uint8Array>;
184
218
  },