@noble/post-quantum 0.7.0 → 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/hybrid.ts CHANGED
@@ -91,7 +91,7 @@ import {
91
91
  import { expand, extract } from '@noble/hashes/hkdf.js';
92
92
  import { sha256 } from '@noble/hashes/sha2.js';
93
93
  import { sha3_256, shake256 } from '@noble/hashes/sha3.js';
94
- import { abytes, ahash, anumber, type CHash, type CHashXOF } from '@noble/hashes/utils.js';
94
+ import { abytes, ahash, anumber, isBytes, type CHash, type CHashXOF } from '@noble/hashes/utils.js';
95
95
  import { ml_kem1024, ml_kem768 } from './ml-kem.ts';
96
96
  import {
97
97
  aobject,
@@ -181,6 +181,12 @@ function ecKeygen(curve: CurveAll, allowZeroKey: boolean = false) {
181
181
  /**
182
182
  * Wraps an ECDH-capable curve as a KEM.
183
183
  * Shared secrets stay in the wrapped curve's raw ECDH byte format with no built-in KDF.
184
+ *
185
+ * SECURITY: this is a low-level component adapter, not a standalone IND-CCA-secure KEM. It does
186
+ * not bind the encapsulation or recipient public key into the secret, so distinct accepted point
187
+ * encodings can produce the same output. Use it only inside a construction whose specified
188
+ * combiner binds those values, or use a standardized DHKEM with labeled extract-and-expand.
189
+ *
184
190
  * On SEC 1 / Weierstrass curves, that means the compressed shared-point body without the
185
191
  * 1-byte `0x02` / `0x03` prefix.
186
192
  * The X25519 path also leaves RFC 7748's optional all-zero shared-secret check to callers.
@@ -196,12 +202,12 @@ function ecKeygen(curve: CurveAll, allowZeroKey: boolean = false) {
196
202
  * Wrap an ECDH-capable curve as a generic KEM.
197
203
  * ```ts
198
204
  * import { x25519 } from '@noble/curves/ed25519.js';
199
- * import { ecdhKem } from '@noble/post-quantum/hybrid.js';
200
- * const kem = ecdhKem(x25519);
205
+ * import { _ecdhKem } from '@noble/post-quantum/hybrid.js';
206
+ * const kem = _ecdhKem(x25519);
201
207
  * const publicKeyLen = kem.lengths.publicKey;
202
208
  * ```
203
209
  */
204
- export function ecdhKem(curve: CurveECDH, allowZeroKey: boolean = false): TRet<KEM> {
210
+ export function _ecdhKem(curve: CurveECDH, allowZeroKey: boolean = false): TRet<KEM> {
205
211
  const kg = ecKeygen(curve, allowZeroKey);
206
212
  if (!curve.getSharedSecret) throw new Error('wrong curve'); // ed25519 doesn't have one!
207
213
  // Standalone (not `this.decapsulate`) so encapsulate works even when methods are destructured.
@@ -266,7 +272,7 @@ export function ecSigner(curve: CurveSign, allowZeroKey: boolean = false): TRet<
266
272
  keygen: kg.keygen,
267
273
  getPublicKey: kg.getPublicKey,
268
274
  sign: (message, secretKey, opts = {}) => {
269
- validateSigOpts(opts);
275
+ opts = validateSigOpts(opts);
270
276
  // This generic wrapper intentionally keeps the Signer contract to message + key only.
271
277
  // Backend-specific knobs like ECDSA extraEntropy or Ed25519ctx context cannot be forwarded
272
278
  // uniformly through combineSigners(), so callers that need them must use the curve directly.
@@ -283,7 +289,7 @@ export function ecSigner(curve: CurveSign, allowZeroKey: boolean = false): TRet<
283
289
  * generic opts and lets wrapped-curve malformed-input errors escape unchanged.
284
290
  */
285
291
  verify: (signature, message, publicKey, opts = {}) => {
286
- validateVerOpts(opts);
292
+ opts = validateVerOpts(opts);
287
293
  if (opts.context !== undefined)
288
294
  throw new Error('ecSigner does not support context; use the underlying curve directly');
289
295
  return curve.verify(signature, message, publicKey);
@@ -291,18 +297,26 @@ export function ecSigner(curve: CurveSign, allowZeroKey: boolean = false): TRet<
291
297
  };
292
298
  }
293
299
 
300
+ function positiveLength(value: number, title: string): number {
301
+ const length = anumber(value, title);
302
+ if (length === 0) throw new RangeError(`"${title}" expected integer greater than 0, got 0`);
303
+ return length;
304
+ }
305
+
294
306
  function splitLengths<K extends string, T extends { lengths: Partial<Record<K, number>> }>(
295
307
  lst: T[],
296
308
  name: K
297
309
  ) {
298
310
  // Preserve caller order exactly; raw numeric fields still decode as splitCoder() subarray views.
299
- return splitCoder(
311
+ const coder = splitCoder(
300
312
  name,
301
313
  ...lst.map((i) => {
302
314
  if (typeof i.lengths[name] !== 'number') throw new Error('wrong length: ' + name);
303
- return i.lengths[name];
315
+ return positiveLength(i.lengths[name], name);
304
316
  })
305
317
  );
318
+ positiveLength(coder.bytesLen, name);
319
+ return coder;
306
320
  }
307
321
 
308
322
  /** Seed-expansion callback used by the hybrid combiners. */
@@ -348,13 +362,15 @@ function combineKeys(
348
362
  const seedCoder = splitLengths(ck, 'seed');
349
363
  const pkCoder = splitLengths(ck, 'publicKey');
350
364
  // Allows to use identity functions for combiner/expandSeed
351
- if (realSeedLen === undefined) realSeedLen = seedCoder.bytesLen;
352
- anumber(realSeedLen);
365
+ const rootSeedLen = positiveLength(
366
+ realSeedLen === undefined ? seedCoder.bytesLen : realSeedLen,
367
+ 'realSeedLen'
368
+ );
353
369
  function expandDecapsulationKey(seed: TArg<Uint8Array>): TRet<{
354
370
  secretKey: Uint8Array[];
355
371
  publicKey: Uint8Array[];
356
372
  }> {
357
- abytes(seed, realSeedLen!);
373
+ abytes(seed, rootSeedLen);
358
374
  const expandedRaw = expandSeed(seed, seedCoder.bytesLen);
359
375
  // Identity/subarray expanders can hand back caller-owned seed storage. Detach those outputs so
360
376
  // later cleanup can wipe the expanded schedule without mutating the caller's root seed bytes.
@@ -392,7 +408,7 @@ function combineKeys(
392
408
  const keygen = (seed?: TArg<Uint8Array>) => {
393
409
  // Detach the root: the exported secretKey must not alias caller-owned seed bytes, so later
394
410
  // caller mutation of the seed cannot silently change the secret key (and vice versa).
395
- const root = seed === undefined ? randomBytes(realSeedLen!) : copyBytes(seed);
411
+ const root = seed === undefined ? randomBytes(rootSeedLen) : copyBytes(seed);
396
412
  let res;
397
413
  try {
398
414
  const { publicKey: pk, secretKey } = expandDecapsulationKey(root);
@@ -412,7 +428,7 @@ function combineKeys(
412
428
  }
413
429
  };
414
430
  return {
415
- info: { lengths: { seed: realSeedLen, publicKey: pkCoder.bytesLen, secretKey: realSeedLen } },
431
+ info: { lengths: { seed: rootSeedLen, publicKey: pkCoder.bytesLen, secretKey: rootSeedLen } },
416
432
  // Composite secret keys are root seeds, so public-key derivation reruns key expansion from
417
433
  // that seed instead of decoding a packed child-secret-key structure.
418
434
  getPublicKey: (secretKey: TArg<Uint8Array>) => {
@@ -423,19 +439,25 @@ function combineKeys(
423
439
  },
424
440
  keygen,
425
441
  expandDecapsulationKey,
426
- realSeedLen,
442
+ realSeedLen: rootSeedLen,
427
443
  };
428
444
  }
429
445
 
430
446
  // This generic function that combines multiple KEMs into single one
431
447
  /**
432
448
  * Combines multiple KEMs into one composite KEM.
433
- * @param realSeedLen - Input seed length expected by `expandSeed`.
434
- * @param realMsgLen - Shared-secret length returned by `combiner`.
449
+ * @param realSeedLen - Positive input seed length expected by `expandSeed`, or `undefined` to use
450
+ * the sum of component seed lengths. Callers remain responsible for choosing a security-appropriate
451
+ * size.
452
+ * @param realMsgLen - Positive shared-secret length returned by `combiner`, or `undefined` to use
453
+ * the sum of component message lengths.
435
454
  * @param expandSeed - Seed expander used to derive per-KEM seeds.
436
455
  * @param combiner - Combines the per-KEM outputs into one shared secret.
437
- * @param kems - KEM implementations to combine.
456
+ * @param kems - At least one KEM implementation. A construction advertised as hybrid normally
457
+ * supplies two or more.
438
458
  * @returns Composite KEM.
459
+ * @throws On wrong argument types. {@link TypeError}
460
+ * @throws If there are no components or any required length resolves to zero. {@link RangeError}
439
461
  * @example
440
462
  * Combine multiple KEMs into one composite KEM.
441
463
  * ```ts
@@ -460,27 +482,56 @@ export function combineKEMS(
460
482
  combiner: TArg<Combiner>,
461
483
  ...kems: TArg<KEM[]>
462
484
  ): TRet<KEM> {
463
- if (realSeedLen !== undefined) anumber(realSeedLen, 'realSeedLen');
464
- if (realMsgLen !== undefined) anumber(realMsgLen, 'realMsgLen');
485
+ if (realSeedLen !== undefined) positiveLength(realSeedLen, 'realSeedLen');
486
+ if (realMsgLen !== undefined) positiveLength(realMsgLen, 'realMsgLen');
465
487
  if (typeof expandSeed !== 'function')
466
488
  throw new TypeError('"expandSeed" expected function, got type=' + typeof expandSeed);
467
489
  if (typeof combiner !== 'function')
468
490
  throw new TypeError('"combiner" expected function, got type=' + typeof combiner);
469
491
  const rawCombiner = combiner as Combiner;
470
492
  const rawKems = kems as KEM[];
493
+ if (rawKems.length === 0) throw new RangeError('combineKEMS requires at least one KEM');
471
494
  for (let i = 0; i < rawKems.length; i++) validateKEM(rawKems[i], `kems[${i}]`);
472
495
  const keys = combineKeys(realSeedLen, expandSeed, ...rawKems);
473
496
  const ctCoder = splitLengths(rawKems, 'cipherText');
474
497
  const pkCoder = splitLengths(rawKems, 'publicKey');
475
498
  const msgCoder = splitLengths(rawKems, 'msg');
476
- if (realMsgLen === undefined) realMsgLen = msgCoder.bytesLen;
477
- anumber(realMsgLen, 'realMsgLen');
499
+ const sharedSecretLen = positiveLength(
500
+ realMsgLen === undefined ? msgCoder.bytesLen : realMsgLen,
501
+ 'realMsgLen'
502
+ );
478
503
  const lengths = Object.freeze({
479
504
  ...keys.info.lengths,
480
- msg: realMsgLen,
505
+ msg: sharedSecretLen,
481
506
  msgRand: msgCoder.bytesLen,
482
507
  cipherText: ctCoder.bytesLen,
483
508
  });
509
+ const combine = (
510
+ publicKeys: TArg<Uint8Array[]>,
511
+ cipherTexts: TArg<Uint8Array[]>,
512
+ sharedSecrets: TArg<Uint8Array[]>
513
+ ): TRet<Uint8Array> => {
514
+ const combined = rawCombiner(publicKeys, cipherTexts, sharedSecrets);
515
+ try {
516
+ return copyBytes(abytes(combined, sharedSecretLen, 'sharedSecret'));
517
+ } catch (error) {
518
+ if (isBytes(combined)) {
519
+ // A combiner may return any callback argument. Public keys during encapsulation and
520
+ // ciphertexts during decapsulation are views into caller-owned inputs, so wipe an invalid
521
+ // byte result only when its range does not overlap either public argument vector. Child
522
+ // shared-secret aliases are already wiped by the operation's outer finally block.
523
+ const overlaps = (value: TArg<Uint8Array>) =>
524
+ combined.buffer === value.buffer &&
525
+ combined.byteOffset < value.byteOffset + value.byteLength &&
526
+ value.byteOffset < combined.byteOffset + combined.byteLength;
527
+ const aliasesPublicInput =
528
+ (publicKeys as Uint8Array[]).some(overlaps) ||
529
+ (cipherTexts as Uint8Array[]).some(overlaps);
530
+ if (!aliasesPublicInput) cleanBytes(combined);
531
+ }
532
+ throw error;
533
+ }
534
+ };
484
535
  return Object.freeze({
485
536
  lengths,
486
537
  getPublicKey: keys.getPublicKey,
@@ -499,11 +550,14 @@ export function combineKEMS(
499
550
  sharedSecret.push(enc.sharedSecret);
500
551
  cipherText.push(enc.cipherText);
501
552
  }
553
+ // Validate and detach public ciphertexts before deriving a final secret from them. This
554
+ // also ensures a malformed child cannot make us allocate and then strand a combined key.
555
+ const encodedCipherText = ctCoder.encode(cipherText) as TRet<Uint8Array>;
502
556
  return {
503
557
  // Detach the combiner result before cleanup: a caller-provided combiner may alias one of
504
558
  // the child sharedSecret buffers, and those child buffers are zeroized immediately below.
505
- sharedSecret: copyBytes(rawCombiner(pks, cipherText, sharedSecret)),
506
- cipherText: ctCoder.encode(cipherText) as TRet<Uint8Array>,
559
+ sharedSecret: combine(pks, cipherText, sharedSecret),
560
+ cipherText: encodedCipherText,
507
561
  };
508
562
  } finally {
509
563
  // Child encapsulation or combiner failures can happen after some components already
@@ -514,11 +568,16 @@ export function combineKEMS(
514
568
  decapsulate(ct: TArg<Uint8Array>, seed: TArg<Uint8Array>) {
515
569
  const cts = ctCoder.decode(ct);
516
570
  const { publicKey, secretKey } = keys.expandDecapsulationKey(seed);
517
- const sharedSecret = rawKems.map((i, j) => i.decapsulate(cts[j], secretKey[j]));
571
+ const sharedSecret: Uint8Array[] = [];
518
572
  try {
573
+ // Child decapsulate() is inside the try: it can throw on an attacker-supplied ciphertext
574
+ // (e.g. a low-order X25519 point), and by then the expanded child secret keys — plus any
575
+ // child shared secrets already produced — are live and must still be wiped.
576
+ for (let i = 0; i < rawKems.length; i++)
577
+ sharedSecret.push(rawKems[i].decapsulate(cts[i], secretKey[i]));
519
578
  // Detach the decapsulation result before cleanup: the combiner may hand back one of the
520
579
  // child shared-secret buffers, and those temporary buffers are zeroized below.
521
- return copyBytes(rawCombiner(publicKey, cts, sharedSecret));
580
+ return combine(publicKey, cts, sharedSecret);
522
581
  } finally {
523
582
  // Decapsulation only needs the expanded child secret keys and child shared secrets for this
524
583
  // call; keep the caller/root seed intact, but wipe all derived material even on errors.
@@ -531,10 +590,15 @@ export function combineKEMS(
531
590
  // realSeedLen: how much bytes expandSeed expects.
532
591
  /**
533
592
  * Combines multiple signers into one composite signer.
534
- * @param realSeedLen - Input seed length expected by `expandSeed`.
593
+ * @param realSeedLen - Positive input seed length expected by `expandSeed`, or `undefined` to use
594
+ * the sum of component seed lengths. Callers remain responsible for choosing a security-appropriate
595
+ * size.
535
596
  * @param expandSeed - Seed expander used to derive per-signer seeds.
536
- * @param signers - Signers to combine.
597
+ * @param signers - At least one signer. A construction advertised as hybrid normally supplies two
598
+ * or more.
537
599
  * @returns Composite signer.
600
+ * @throws On wrong argument types. {@link TypeError}
601
+ * @throws If there are no components or any required length resolves to zero. {@link RangeError}
538
602
  * @example
539
603
  * Combine multiple signers into one composite signer.
540
604
  * ```ts
@@ -554,10 +618,11 @@ export function combineSigners(
554
618
  expandSeed: TArg<ExpandSeed>,
555
619
  ...signers: TArg<Signer[]>
556
620
  ): TRet<Signer> {
557
- if (realSeedLen !== undefined) anumber(realSeedLen, 'realSeedLen');
621
+ if (realSeedLen !== undefined) positiveLength(realSeedLen, 'realSeedLen');
558
622
  if (typeof expandSeed !== 'function')
559
623
  throw new TypeError('"expandSeed" expected function, got type=' + typeof expandSeed);
560
624
  const rawSigners = signers as Signer[];
625
+ if (rawSigners.length === 0) throw new RangeError('combineSigners requires at least one signer');
561
626
  for (let i = 0; i < rawSigners.length; i++) validateSigner(rawSigners[i], `signers[${i}]`);
562
627
  const keys = combineKeys(realSeedLen, expandSeed, ...rawSigners);
563
628
  const sigCoder = splitLengths(rawSigners, 'signature');
@@ -567,7 +632,7 @@ export function combineSigners(
567
632
  getPublicKey: keys.getPublicKey,
568
633
  keygen: keys.keygen,
569
634
  sign(message, seed, opts = {}) {
570
- validateSigOpts(opts);
635
+ opts = validateSigOpts(opts);
571
636
  // This generic wrapper intentionally keeps the composite signer contract to message + root
572
637
  // seed only. Per-signer opts like context or extraEntropy cannot be preserved uniformly
573
638
  // across mixed backends, so callers that need them must use the underlying signer directly.
@@ -594,7 +659,7 @@ export function combineSigners(
594
659
  * does any failing child verify. Throws on unsupported generic opts or malformed publicKey.
595
660
  */
596
661
  verify: (signature, message, publicKey, opts = {}) => {
597
- validateVerOpts(opts);
662
+ opts = validateVerOpts(opts);
598
663
  if (opts.context !== undefined)
599
664
  throw new Error(
600
665
  'combineSigners does not support context; use the underlying signer directly'
@@ -628,14 +693,16 @@ export function combineSigners(
628
693
  * @param xof - XOF used for seed expansion.
629
694
  * @param kdf - Hash used for the final combiner.
630
695
  * @returns Hybrid KEM.
696
+ * @throws On wrong argument types. {@link TypeError}
697
+ * @throws On wrong argument ranges or values. {@link RangeError}
631
698
  * @example
632
699
  * Build a QSF hybrid KEM preset from a PQ KEM and an elliptic-curve KEM.
633
700
  * ```ts
634
701
  * import { p256 } from '@noble/curves/nist.js';
635
702
  * import { sha3_256, shake256 } from '@noble/hashes/sha3.js';
636
- * import { QSF, ecdhKem } from '@noble/post-quantum/hybrid.js';
703
+ * import { QSF, _ecdhKem } from '@noble/post-quantum/hybrid.js';
637
704
  * import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
638
- * const kem = QSF('example', ml_kem768, ecdhKem(p256, true), shake256, sha3_256);
705
+ * const kem = QSF('example', ml_kem768, _ecdhKem(p256, true), shake256, sha3_256);
639
706
  * const publicKeyLen = kem.lengths.publicKey;
640
707
  * ```
641
708
  */
@@ -671,7 +738,7 @@ export const QSF_ml_kem768_p256: TRet<KEM> = /* @__PURE__ */ (() =>
671
738
  QSF(
672
739
  'QSF-KEM(ML-KEM-768,P-256)-XOF(SHAKE256)-KDF(SHA3-256)',
673
740
  ml_kem768,
674
- ecdhKem(p256, true),
741
+ _ecdhKem(p256, true),
675
742
  shake256,
676
743
  sha3_256
677
744
  ))();
@@ -680,7 +747,7 @@ export const QSF_ml_kem1024_p384: TRet<KEM> = /* @__PURE__ */ (() =>
680
747
  QSF(
681
748
  'QSF-KEM(ML-KEM-1024,P-384)-XOF(SHAKE256)-KDF(SHA3-256)',
682
749
  ml_kem1024,
683
- ecdhKem(p384, true),
750
+ _ecdhKem(p384, true),
684
751
  shake256,
685
752
  sha3_256
686
753
  ))();
@@ -699,15 +766,17 @@ export const QSF_ml_kem1024_p384: TRet<KEM> = /* @__PURE__ */ (() =>
699
766
  * @param xof - XOF used for seed expansion.
700
767
  * @param hash - Hash used for HKDF extraction and expansion.
701
768
  * @returns Hybrid KEM.
769
+ * @throws On wrong argument types. {@link TypeError}
770
+ * @throws On wrong argument ranges or values. {@link RangeError}
702
771
  * @example
703
772
  * Build the "KitchenSink" hybrid KEM combiner.
704
773
  * ```ts
705
774
  * import { sha256 } from '@noble/hashes/sha2.js';
706
775
  * import { shake256 } from '@noble/hashes/sha3.js';
707
- * import { createKitchenSink, ecdhKem } from '@noble/post-quantum/hybrid.js';
776
+ * import { createKitchenSink, _ecdhKem } from '@noble/post-quantum/hybrid.js';
708
777
  * import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
709
778
  * import { x25519 } from '@noble/curves/ed25519.js';
710
- * const kem = createKitchenSink('example', ml_kem768, ecdhKem(x25519), shake256, sha256);
779
+ * const kem = createKitchenSink('example', ml_kem768, _ecdhKem(x25519), shake256, sha256);
711
780
  * const publicKeyLen = kem.lengths.publicKey;
712
781
  * ```
713
782
  */
@@ -750,9 +819,9 @@ export function createKitchenSink(
750
819
  );
751
820
  }
752
821
 
753
- // Internal alias only: this stays exactly `ecdhKem(x25519)`
822
+ // Internal alias only: this stays exactly `_ecdhKem(x25519)`
754
823
  // and inherits that wrapper's mutation/oracle behavior.
755
- const x25519kem = /* @__PURE__ */ ecdhKem(x25519);
824
+ const x25519kem = /* @__PURE__ */ _ecdhKem(x25519);
756
825
  /** KitchenSink preset combining ML-KEM-768 with X25519.
757
826
  * Caller randomness splits into 32 ML-KEM coins plus a 32-byte X25519 ephemeral-secret seed.
758
827
  */
package/src/index.ts CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  } from '@noble/post-quantum/falcon.js';
20
20
  import {
21
21
  ml_kem768_x25519, ml_kem768_p256, ml_kem1024_p384,
22
- KitchenSink_ml_kem768_x25519, XWing,
22
+ KitchenSink_ml_kem768_x25519,
23
23
  QSF_ml_kem768_p256, QSF_ml_kem1024_p384,
24
24
  } from '@noble/post-quantum/hybrid.js';
25
25
  ```
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,13 +73,13 @@ 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. */
@@ -212,7 +231,7 @@ function RejNTTPoly(xof_: TArg<XofGet>): TRet<Poly> {
212
231
  // Samples a polynomial ∈ Tq. xof() must return byte lengths divisible by 3.
213
232
  const r = newPoly(N);
214
233
  // NOTE: we can represent 3xu24 as 4xu32, but it doesn't improve perf :(
215
- for (let j = 0; j < N; ) {
234
+ for (let j = 0; j < N;) {
216
235
  const b = xof();
217
236
  if (b.length % 3) throw new Error('RejNTTPoly: unaligned block');
218
237
  for (let i = 0; j < N && i <= b.length - 3; i += 3) {
@@ -374,7 +393,7 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
374
393
  const xof = xof_ as XofGet;
375
394
  // Samples an element a ∈ Rq with coeffcients in [−η, η] computed via rejection sampling from ρ.
376
395
  const r: Poly = newPoly(N);
377
- for (let j = 0; j < N; ) {
396
+ for (let j = 0; j < N;) {
378
397
  const b = xof();
379
398
  for (let i = 0; j < N && i < b.length; i += 1) {
380
399
  // half byte. Should be superfast with vector instructions. But very slow with js :(
@@ -398,7 +417,7 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
398
417
  const masks = buf.slice(0, 8);
399
418
  for (let i = N - TAU, pos = 8, maskPos = 0, maskBit = 0; i < N; i++) {
400
419
  let b = i + 1;
401
- for (; b > i; ) {
420
+ for (; b > i;) {
402
421
  b = buf[pos++];
403
422
  if (pos < shake256.blockLen) continue;
404
423
  s.xofInto(buf);
@@ -539,8 +558,8 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
539
558
  secretKey: TArg<Uint8Array>,
540
559
  opts: TArg<SigOpts & DSAInternalOpts> = {}
541
560
  ): TRet<Uint8Array> => {
542
- validateSigOpts(opts);
543
- validateInternalOpts(opts);
561
+ opts = validateSigOpts(opts, INTERNAL_SIG_OPT_KEYS);
562
+ opts = validateInternalOpts(opts, INTERNAL_SIG_OPT_KEYS);
544
563
  const { extraEntropy: random, externalMu = false } = opts;
545
564
  // FIPS 204 external-mu mode expects the 64-byte message representative µ = H(tr || M).
546
565
  if (externalMu) abytes(msg, CRH_BYTES, 'mu');
@@ -599,7 +618,7 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
599
618
  abytes(rhoprime, CRH_BYTES);
600
619
  const x256 = XOF256(rhoprime, ZCoder.bytesLen);
601
620
  // Rejection sampling loop
602
- main_loop: for (let kappa = 0; ; ) {
621
+ main_loop: for (let kappa = 0; ;) {
603
622
  const y = [];
604
623
  // y ← ExpandMask(ρ , κ)
605
624
  for (let i = 0; i < L; i++, kappa++)
@@ -627,7 +646,13 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
627
646
  const cs1 = s1.map((i) => MultiplyNTTs(i, cHat));
628
647
  for (let i = 0; i < L; i++) {
629
648
  polyAdd(crystals.NTT.decode(cs1[i]), y[i]); // z ← y + ⟨⟨cs1⟩⟩
630
- 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
+ }
631
656
  }
632
657
  // cs1 is now z (▷ Signer’s response)
633
658
  let cnt = 0;
@@ -635,16 +660,25 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
635
660
  for (let i = 0; i < K; i++) {
636
661
  const cs2 = crystals.NTT.decode(MultiplyNTTs(s2[i], cHat)); // ⟨⟨cs2⟩⟩ ← NTT−1(cˆ◦ sˆ2)
637
662
  const r0 = polySub(w[i], cs2).map(LowBits); // r0 ← LowBits(w − ⟨⟨cs2⟩⟩)
638
- 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
+ }
639
667
  const ct0 = crystals.NTT.decode(MultiplyNTTs(t0[i], cHat)); // ⟨⟨ct0⟩⟩ ← NTT−1(cˆ◦ tˆ0)
640
- 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
+ }
641
672
  polyAdd(r0, ct0);
642
673
  // ▷ Signer’s hint
643
674
  const hint = polyMakeHint(r0, w1[i]); // h ← MakeHint(−⟨⟨ct0⟩⟩, w− ⟨⟨cs2⟩⟩ + ⟨⟨ct0⟩⟩)
644
675
  h.push(hint.v);
645
676
  cnt += hint.cnt;
646
677
  }
647
- 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
+ }
648
682
  x256.clean();
649
683
  const res = sigCoder.encode([cTilde, cs1, h]); // σ ← sigEncode(c˜, z mod±q, h)
650
684
  // rho, _K, tr is subarray of secretKey, cannot clean.
@@ -664,7 +698,7 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
664
698
  publicKey: TArg<Uint8Array>,
665
699
  opts: TArg<DSAInternalOpts> = {}
666
700
  ) => {
667
- validateInternalOpts(opts);
701
+ opts = validateInternalOpts(opts, INTERNAL_VER_OPT_KEYS);
668
702
  const { externalMu = false } = opts;
669
703
  // FIPS 204 external-mu mode expects the 64-byte message representative µ = H(tr || M).
670
704
  if (externalMu) abytes(msg, CRH_BYTES, 'mu');
@@ -729,9 +763,14 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
729
763
  secretKey: TArg<Uint8Array>,
730
764
  opts: TArg<SigOpts> = {}
731
765
  ): TRet<Uint8Array> => {
732
- validateSigOpts(opts);
766
+ opts = validateSigOpts(opts);
733
767
  const M = getMessage(msg, opts.context);
734
- 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
+ });
735
774
  cleanBytes(M);
736
775
  return res as TRet<Uint8Array>;
737
776
  },
@@ -741,9 +780,9 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
741
780
  publicKey: TArg<Uint8Array>,
742
781
  opts: TArg<VerOpts> = {}
743
782
  ) => {
744
- validateVerOpts(opts);
783
+ opts = validateVerOpts(opts);
745
784
  abytes(sig, undefined, 'signature');
746
- return internal.verify(sig, getMessage(msg, opts.context), publicKey);
785
+ return internal.verify(sig, getMessage(msg, opts.context), publicKey, { externalMu: false });
747
786
  },
748
787
  prehash: (hash: TArg<CHash>): TRet<Signer> => {
749
788
  checkHash(hash as CHash, securityLevel);
@@ -759,9 +798,13 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
759
798
  secretKey: TArg<Uint8Array>,
760
799
  opts: TArg<SigOpts> = {}
761
800
  ): TRet<Uint8Array> => {
762
- validateSigOpts(opts);
801
+ opts = validateSigOpts(opts);
763
802
  const M = getMessagePrehash(rawHash, msg, opts.context);
764
- const res = internal.sign(M, secretKey, opts);
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
+ });
765
808
  cleanBytes(M);
766
809
  return res as TRet<Uint8Array>;
767
810
  },
@@ -771,9 +814,11 @@ function getDilithium(opts_: TArg<DilithiumOpts>): TRet<DSA> {
771
814
  publicKey: TArg<Uint8Array>,
772
815
  opts: TArg<VerOpts> = {}
773
816
  ) => {
774
- validateVerOpts(opts);
817
+ opts = validateVerOpts(opts);
775
818
  abytes(sig, undefined, 'signature');
776
- return internal.verify(sig, getMessagePrehash(rawHash, msg, opts.context), publicKey);
819
+ return internal.verify(sig, getMessagePrehash(rawHash, msg, opts.context), publicKey, {
820
+ externalMu: false,
821
+ });
777
822
  },
778
823
  });
779
824
  },