@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/src/ml-dsa.ts CHANGED
@@ -27,9 +27,9 @@ import {
27
27
  splitCoder,
28
28
  type TArg,
29
29
  type TRet,
30
- validateOpts,
31
30
  validateSigOpts,
32
31
  validateVerOpts,
32
+ checkOptKeys,
33
33
  vecCoder,
34
34
  type VerOpts,
35
35
  } from './utils.ts';
@@ -43,9 +43,28 @@ export type DSAInternalOpts = {
43
43
  */
44
44
  externalMu?: boolean;
45
45
  };
46
- function validateInternalOpts(opts: TArg<DSAInternalOpts>) {
47
- validateOpts(opts);
48
- if (opts.externalMu !== undefined) abool(opts.externalMu, 'opts.externalMu');
46
+ /**
47
+ * Keys each internal surface accepts.
48
+ *
49
+ * `context` is deliberately absent from both. The internal functions never read it: the
50
+ * public wrappers consume it when they format `M'` and must not pass it down, because a
51
+ * key that is accepted and then not acted on is the same silent downgrade this validation
52
+ * exists to prevent. `externalMu` is the mirror case, existing here and rejected above.
53
+ * `extraEntropy` is signing-only, so verification does not take it either.
54
+ */
55
+ const INTERNAL_SIG_OPT_KEYS = /* @__PURE__ */ Object.freeze([
56
+ 'extraEntropy',
57
+ 'externalMu',
58
+ ] as const);
59
+ const INTERNAL_VER_OPT_KEYS = /* @__PURE__ */ Object.freeze(['externalMu'] as const);
60
+
61
+ function validateInternalOpts<T extends TArg<DSAInternalOpts>>(
62
+ opts: T,
63
+ allowed: readonly string[]
64
+ ): T {
65
+ const normalized = checkOptKeys(opts, allowed);
66
+ if (normalized.externalMu !== undefined) abool(normalized.externalMu, 'opts.externalMu');
67
+ return normalized;
49
68
  }
50
69
 
51
70
  /** ML-DSA signer surface with access to the internal message formatting mode. */
@@ -54,17 +73,26 @@ export type DSAInternal = CryptoKeys & {
54
73
  sign: (
55
74
  msg: TArg<Uint8Array>,
56
75
  secretKey: TArg<Uint8Array>,
57
- opts?: TArg<SigOpts & DSAInternalOpts>
76
+ opts?: TArg<Omit<SigOpts, 'context'> & DSAInternalOpts>
58
77
  ) => TRet<Uint8Array>;
59
78
  verify: (
60
79
  sig: TArg<Uint8Array>,
61
80
  msg: TArg<Uint8Array>,
62
81
  pubKey: TArg<Uint8Array>,
63
- opts?: TArg<VerOpts & DSAInternalOpts>
82
+ opts?: TArg<DSAInternalOpts>
64
83
  ) => boolean;
65
84
  };
66
85
  /** Public ML-DSA signer surface. */
67
- export type DSA = Signer & { internal: TRet<DSAInternal> };
86
+ export type DSA = Signer & {
87
+ internal: TRet<DSAInternal>;
88
+ securityLevel: number;
89
+ /**
90
+ * HashML-DSA (FIPS 204 §5.4) variant which signs a pre-hashed message.
91
+ * @param hash - Approved hash, checked against the parameter set security level.
92
+ * @returns Signer which pre-hashes `msg` before formatting `M'`.
93
+ */
94
+ prehash: (hash: TArg<CHash>) => TRet<Signer>;
95
+ };
68
96
 
69
97
  // Constants
70
98
  // FIPS 204 fixes ML-DSA over R = Z[X]/(X^256 + 1), so every polynomial has 256 coefficients.
@@ -154,6 +182,8 @@ const polyCoder = (d: number, compress: IdNum = id, verify: IdNum = id) =>
154
182
  });
155
183
 
156
184
  // Mutates `a` in place; callers must pass same-length polynomials.
185
+ // NOTE: conditional-reduction variants (as in ml-kem) were measured performance-neutral here —
186
+ // int32 `%` with 23-bit Q is already cheap — so the simpler mod() form is kept for audit.
157
187
  const polyAdd = (a_: TArg<Poly>, b_: TArg<Poly>): TRet<Poly> => {
158
188
  const a = a_ as Poly;
159
189
  const b = b_ as Poly;
@@ -201,7 +231,7 @@ function RejNTTPoly(xof_: TArg<XofGet>): TRet<Poly> {
201
231
  // Samples a polynomial ∈ Tq. xof() must return byte lengths divisible by 3.
202
232
  const r = newPoly(N);
203
233
  // NOTE: we can represent 3xu24 as 4xu32, but it doesn't improve perf :(
204
- for (let j = 0; j < N; ) {
234
+ for (let j = 0; j < N;) {
205
235
  const b = xof();
206
236
  if (b.length % 3) throw new Error('RejNTTPoly: unaligned block');
207
237
  for (let i = 0; j < N && i <= b.length - 3; i += 3) {
@@ -275,13 +305,16 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
275
305
  return res0;
276
306
  };
277
307
 
308
+ // m = (q-1)/(2γ2): 44 for ML-DSA-44, 16 for 65/87. Hoisted out of UseHint, which runs
309
+ // per coefficient during verification.
310
+ const HINT_M = Math.floor((Q - 1) / (2 * GAMMA2));
278
311
  const UseHint = (h: number, r: number) => {
279
312
  // Returns the high bits of r adjusted according to hint h
280
- const m = Math.floor((Q - 1) / (2 * GAMMA2));
281
313
  const { r1, r0 } = decompose(r);
282
314
  // 3: if h = 1 and r0 > 0 return (r1 + 1) mod m
283
315
  // 4: if h = 1 and r0 ≤ 0 return (r1 − 1) mod m
284
- if (h === 1) return r0 > 0 ? crystals.mod(r1 + 1, m) | 0 : crystals.mod(r1 - 1, m) | 0;
316
+ if (h === 1)
317
+ return r0 > 0 ? crystals.mod(r1 + 1, HINT_M) | 0 : crystals.mod(r1 - 1, HINT_M) | 0;
285
318
  return r1 | 0;
286
319
  };
287
320
  const Power2Round = (r: number) => {
@@ -360,7 +393,7 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
360
393
  const xof = xof_ as XofGet;
361
394
  // Samples an element a ∈ Rq with coeffcients in [−η, η] computed via rejection sampling from ρ.
362
395
  const r: Poly = newPoly(N);
363
- for (let j = 0; j < N; ) {
396
+ for (let j = 0; j < N;) {
364
397
  const b = xof();
365
398
  for (let i = 0; j < N && i < b.length; i += 1) {
366
399
  // half byte. Should be superfast with vector instructions. But very slow with js :(
@@ -384,7 +417,7 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
384
417
  const masks = buf.slice(0, 8);
385
418
  for (let i = N - TAU, pos = 8, maskPos = 0, maskBit = 0; i < N; i++) {
386
419
  let b = i + 1;
387
- for (; b > i; ) {
420
+ for (; b > i;) {
388
421
  b = buf[pos++];
389
422
  if (pos < shake256.blockLen) continue;
390
423
  s.xofInto(buf);
@@ -525,13 +558,34 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
525
558
  secretKey: TArg<Uint8Array>,
526
559
  opts: TArg<SigOpts & DSAInternalOpts> = {}
527
560
  ): TRet<Uint8Array> => {
528
- validateSigOpts(opts);
529
- validateInternalOpts(opts);
530
- let { extraEntropy: random, externalMu = false } = opts;
561
+ opts = validateSigOpts(opts, INTERNAL_SIG_OPT_KEYS);
562
+ opts = validateInternalOpts(opts, INTERNAL_SIG_OPT_KEYS);
563
+ const { extraEntropy: random, externalMu = false } = opts;
564
+ // FIPS 204 external-mu mode expects the 64-byte message representative µ = H(tr || M).
565
+ if (externalMu) abytes(msg, CRH_BYTES, 'mu');
566
+ // Prepare entropy before touching decoded secrets: randomBytes() may throw, and an RNG
567
+ // failure must not leave expanded secret-polynomial copies behind.
568
+ const ownRnd = random === false || random === undefined;
569
+ const rnd =
570
+ random === false
571
+ ? new Uint8Array(32)
572
+ : random === undefined
573
+ ? randomBytes(signRandBytes)
574
+ : (random as Uint8Array);
575
+ abytes(rnd, 32, 'extraEntropy');
531
576
  // This part can be pre-cached per secretKey, but there is only minor performance improvement,
532
577
  // since we re-use a lot of variables to computation.
533
578
  // (ρ, K,tr, s1, s2, t0) ← skDecode(sk)
534
- const [rho, _K, tr, s1, s2, t0] = secretCoder.decode(secretKey);
579
+ const decoded = (() => {
580
+ try {
581
+ return secretCoder.decode(secretKey);
582
+ } catch (error) {
583
+ // A malformed key must not strand entropy owned by the library.
584
+ if (ownRnd) cleanBytes(rnd);
585
+ throw error;
586
+ }
587
+ })();
588
+ const [rho, _K, tr, s1, s2, t0] = decoded;
535
589
  // Cache matrix to avoid re-compute later
536
590
  const A: Poly[][] = []; // A ← ExpandA(ρ)
537
591
  const xof = XOF128(rho);
@@ -553,25 +607,18 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
553
607
  // ▷ Compute message representative µ
554
608
  shake256.create({ dkLen: CRH_BYTES }).update(tr).update(msg).digest();
555
609
 
556
- // Compute private random seed
557
- const rnd =
558
- random === false
559
- ? new Uint8Array(32)
560
- : random === undefined
561
- ? randomBytes(signRandBytes)
562
- : random;
563
- abytes(rnd, 32, 'extraEntropy');
564
610
  const rhoprime = shake256
565
611
  .create({ dkLen: CRH_BYTES })
566
612
  .update(_K)
567
613
  .update(rnd)
568
614
  .update(mu)
569
615
  .digest(); // ρ′← H(K||rnd||µ, 512)
570
-
616
+ // Only wipe entropy we generated; caller-provided extraEntropy stays caller-owned.
617
+ if (ownRnd) cleanBytes(rnd);
571
618
  abytes(rhoprime, CRH_BYTES);
572
619
  const x256 = XOF256(rhoprime, ZCoder.bytesLen);
573
620
  // Rejection sampling loop
574
- main_loop: for (let kappa = 0; ; ) {
621
+ main_loop: for (let kappa = 0; ;) {
575
622
  const y = [];
576
623
  // y ← ExpandMask(ρ , κ)
577
624
  for (let i = 0; i < L; i++, kappa++)
@@ -599,7 +646,13 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
599
646
  const cs1 = s1.map((i) => MultiplyNTTs(i, cHat));
600
647
  for (let i = 0; i < L; i++) {
601
648
  polyAdd(crystals.NTT.decode(cs1[i]), y[i]); // z ← y + ⟨⟨cs1⟩⟩
602
- if (polyChknorm(cs1[i], GAMMA1 - BETA)) continue main_loop; // ||z||∞ ≥ γ1 − β
649
+ if (polyChknorm(cs1[i], GAMMA1 - BETA)) {
650
+ // Rejected. Wipe this iteration's secret-derived buffers before retrying; the
651
+ // accepted path wipes the same set, and only the persistent key material (s1, s2,
652
+ // t0, A, rhoprime) is kept for the next iteration and cleaned at the very end.
653
+ cleanBytes(cTilde, cs1, cHat, w1, w, z, y);
654
+ continue main_loop; // ||z||∞ ≥ γ1 − β
655
+ }
603
656
  }
604
657
  // cs1 is now z (▷ Signer’s response)
605
658
  let cnt = 0;
@@ -607,16 +660,25 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
607
660
  for (let i = 0; i < K; i++) {
608
661
  const cs2 = crystals.NTT.decode(MultiplyNTTs(s2[i], cHat)); // ⟨⟨cs2⟩⟩ ← NTT−1(cˆ◦ sˆ2)
609
662
  const r0 = polySub(w[i], cs2).map(LowBits); // r0 ← LowBits(w − ⟨⟨cs2⟩⟩)
610
- if (polyChknorm(r0, GAMMA2 - BETA)) continue main_loop; // ||r0||∞ ≥ γ2 − β
663
+ if (polyChknorm(r0, GAMMA2 - BETA)) {
664
+ cleanBytes(cTilde, cs1, cHat, w1, w, z, y, h, cs2, r0);
665
+ continue main_loop; // ||r0||∞ ≥ γ2 − β
666
+ }
611
667
  const ct0 = crystals.NTT.decode(MultiplyNTTs(t0[i], cHat)); // ⟨⟨ct0⟩⟩ ← NTT−1(cˆ◦ tˆ0)
612
- if (polyChknorm(ct0, GAMMA2)) continue main_loop;
668
+ if (polyChknorm(ct0, GAMMA2)) {
669
+ cleanBytes(cTilde, cs1, cHat, w1, w, z, y, h, cs2, r0, ct0);
670
+ continue main_loop;
671
+ }
613
672
  polyAdd(r0, ct0);
614
673
  // ▷ Signer’s hint
615
674
  const hint = polyMakeHint(r0, w1[i]); // h ← MakeHint(−⟨⟨ct0⟩⟩, w− ⟨⟨cs2⟩⟩ + ⟨⟨ct0⟩⟩)
616
675
  h.push(hint.v);
617
676
  cnt += hint.cnt;
618
677
  }
619
- if (cnt > OMEGA) continue; // the number of 1’s in h is greater than ω
678
+ if (cnt > OMEGA) {
679
+ cleanBytes(cTilde, cs1, cHat, w1, w, z, y, h);
680
+ continue; // the number of 1’s in h is greater than ω
681
+ }
620
682
  x256.clean();
621
683
  const res = sigCoder.encode([cTilde, cs1, h]); // σ ← sigEncode(c˜, z mod±q, h)
622
684
  // rho, _K, tr is subarray of secretKey, cannot clean.
@@ -636,8 +698,10 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
636
698
  publicKey: TArg<Uint8Array>,
637
699
  opts: TArg<DSAInternalOpts> = {}
638
700
  ) => {
639
- validateInternalOpts(opts);
701
+ opts = validateInternalOpts(opts, INTERNAL_VER_OPT_KEYS);
640
702
  const { externalMu = false } = opts;
703
+ // FIPS 204 external-mu mode expects the 64-byte message representative µ = H(tr || M).
704
+ if (externalMu) abytes(msg, CRH_BYTES, 'mu');
641
705
  // ML-DSA.Verify(pk, M, σ): Verifes a signature σ for a message M.
642
706
  const [rho, t1] = publicCoder.decode(publicKey); // (ρ, t1) ← pkDecode(pk)
643
707
  const tr = shake256(publicKey, { dkLen: TR_BYTES }); // 6: tr ← H(BytesToBits(pk), 512)
@@ -699,9 +763,14 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
699
763
  secretKey: TArg<Uint8Array>,
700
764
  opts: TArg<SigOpts> = {}
701
765
  ): TRet<Uint8Array> => {
702
- validateSigOpts(opts);
766
+ opts = validateSigOpts(opts);
703
767
  const M = getMessage(msg, opts.context);
704
- const res = internal.sign(M, secretKey, opts);
768
+ // `context` is consumed by getMessage() above; forwarding it would make the internal
769
+ // surface accept a key it never reads.
770
+ const res = internal.sign(M, secretKey, {
771
+ extraEntropy: opts.extraEntropy,
772
+ externalMu: false,
773
+ });
705
774
  cleanBytes(M);
706
775
  return res as TRet<Uint8Array>;
707
776
  },
@@ -711,11 +780,13 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
711
780
  publicKey: TArg<Uint8Array>,
712
781
  opts: TArg<VerOpts> = {}
713
782
  ) => {
714
- validateVerOpts(opts);
715
- return internal.verify(sig, getMessage(msg, opts.context), publicKey);
783
+ opts = validateVerOpts(opts);
784
+ abytes(sig, undefined, 'signature');
785
+ return internal.verify(sig, getMessage(msg, opts.context), publicKey, { externalMu: false });
716
786
  },
717
- prehash: (hash: CHash) => {
718
- checkHash(hash, securityLevel);
787
+ prehash: (hash: TArg<CHash>): TRet<Signer> => {
788
+ checkHash(hash as CHash, securityLevel);
789
+ const rawHash = hash as CHash;
719
790
  return Object.freeze({
720
791
  info: Object.freeze({ type: 'hashml-dsa' }),
721
792
  securityLevel: securityLevel,
@@ -727,9 +798,13 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
727
798
  secretKey: TArg<Uint8Array>,
728
799
  opts: TArg<SigOpts> = {}
729
800
  ): TRet<Uint8Array> => {
730
- validateSigOpts(opts);
731
- const M = getMessagePrehash(hash, msg, opts.context);
732
- const res = internal.sign(M, secretKey, opts);
801
+ opts = validateSigOpts(opts);
802
+ const M = getMessagePrehash(rawHash, msg, opts.context);
803
+ // As above: getMessagePrehash() consumes `context`, so it must not travel further.
804
+ const res = internal.sign(M, secretKey, {
805
+ extraEntropy: opts.extraEntropy,
806
+ externalMu: false,
807
+ });
733
808
  cleanBytes(M);
734
809
  return res as TRet<Uint8Array>;
735
810
  },
@@ -739,15 +814,37 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
739
814
  publicKey: TArg<Uint8Array>,
740
815
  opts: TArg<VerOpts> = {}
741
816
  ) => {
742
- validateVerOpts(opts);
743
- return internal.verify(sig, getMessagePrehash(hash, msg, opts.context), publicKey);
817
+ opts = validateVerOpts(opts);
818
+ abytes(sig, undefined, 'signature');
819
+ return internal.verify(sig, getMessagePrehash(rawHash, msg, opts.context), publicKey, {
820
+ externalMu: false,
821
+ });
744
822
  },
745
823
  });
746
824
  },
747
825
  });
748
826
  }
749
827
 
750
- /** ML-DSA-44 for 128-bit security level. Not recommended after 2030, as per ASD. */
828
+ /**
829
+ * ML-DSA-44 for 128-bit security level. Not recommended after 2030, as per ASD.
830
+ * @example
831
+ * Generate deterministic ML-DSA-44 keys, sign one message, and verify the signature.
832
+ * ```ts
833
+ * import { sha256 } from '@noble/hashes/sha2.js';
834
+ * import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js';
835
+ * const seed = new Uint8Array(ml_dsa44.lengths.seed!);
836
+ * const { secretKey, publicKey } = ml_dsa44.keygen(seed);
837
+ * const msg = new TextEncoder().encode('hello noble');
838
+ * const sig = ml_dsa44.sign(msg, secretKey);
839
+ * const isValid = ml_dsa44.verify(sig, msg, publicKey);
840
+ * const recovered = ml_dsa44.getPublicKey(secretKey);
841
+ * const context = new Uint8Array([1, 2, 3]);
842
+ * const prehash = ml_dsa44.prehash(sha256);
843
+ * const preSig = prehash.sign(msg, secretKey, { context });
844
+ * const preValid = prehash.verify(preSig, msg, publicKey, { context });
845
+ * const internalSig = ml_dsa44.internal.sign(msg, secretKey);
846
+ * ```
847
+ */
751
848
  export const ml_dsa44: TRet<DSA> = /* @__PURE__ */ (() =>
752
849
  getDilithium({
753
850
  ...PARAMS[2],