@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/ml-kem.ts CHANGED
@@ -222,7 +222,7 @@ function SampleNTT(xof_: TArg<XofGet>): TRet<Poly> {
222
222
  // The reader must already bind the Algorithm 7 seed||j||i bytes
223
223
  // and return block lengths divisible by 3.
224
224
  const r: Poly = new Uint16Array(N);
225
- for (let j = 0; j < N; ) {
225
+ for (let j = 0; j < N;) {
226
226
  const b = xof();
227
227
  if (b.length % 3) throw new Error('SampleNTT: unaligned block');
228
228
  for (let i = 0; j < N && i + 3 <= b.length; i += 3) {
@@ -399,8 +399,11 @@ const genKPKE = (opts_: TArg<KyberOpts>) => {
399
399
  // tmp += sk[i] * u[i]
400
400
  for (let i = 0; i < K; i++) polyAdd(tmp, MultiplyNTTs(sk[i], crystals.NTT.encode(u[i])));
401
401
  polySub(v, crystals.NTT.decode(tmp)); // w = v' - tmp
402
- cleanBytes(tmp, sk, u);
403
- return poly1.encode(v) as TRet<Uint8Array>;
402
+ // `v` now holds w, from which the plaintext is just a 1-bit threshold away, so wipe it too.
403
+ // encode() allocates its own buffer, so the returned bytes do not alias `v`.
404
+ const res = poly1.encode(v) as TRet<Uint8Array>;
405
+ cleanBytes(tmp, sk, u, v);
406
+ return res;
404
407
  },
405
408
  };
406
409
  };
@@ -409,6 +412,11 @@ const genKPKE = (opts_: TArg<KyberOpts>) => {
409
412
  * Public ML-KEM wrapper over the internal K-PKE subroutine.
410
413
  * `keygen(seed)` and `encapsulate(publicKey, msg)` are deterministic/test-oriented hooks that map
411
414
  * more directly to Algorithms 16-17 than to the pure no-input / random-internal Algorithms 19-20.
415
+ * `encapsulate`'s optional `msg` is the 32-byte message randomness `m` of Algorithm 17, the
416
+ * pre-image the shared secret is derived from, NOT a plaintext to encrypt: ML-KEM is a key
417
+ * encapsulation mechanism, not a cipher. Omit it to draw fresh randomness; pass it only to
418
+ * reproduce a known-answer vector, and only as 32 uniformly random bytes, since a low-entropy or
419
+ * reused value makes the shared secret predictable. The same holds for `keygen`'s optional `seed`.
412
420
  * decapsulate() tries to follow the Algorithms 18/21 implicit-reject structure as closely as
413
421
  * practical here by re-encrypting, comparing ciphertexts, returning `Khat` on match or `Kbar` on
414
422
  * mismatch, and zeroizing the non-returned shared-secret candidate; JS/JIT still provides no
@@ -443,34 +451,58 @@ function createKyber(opts: TArg<KyberOpts>): TRet<MLKEM> {
443
451
  return Object.freeze({
444
452
  info: Object.freeze({ type: 'ml-kem' }),
445
453
  lengths: kemLengths,
446
- keygen: (seed: TArg<Uint8Array> = randomBytes(seedLen)) => {
447
- abytes(seed, seedLen, 'seed');
448
- const { publicKey, secretKey: sk } = KPKE.keygen(seed.subarray(0, 32));
449
- const publicKeyHash = HASH256(publicKey);
450
- // (dkPKE||ek||H(ek)||z)
451
- const secretKey = secretCoder.encode([sk, publicKey, publicKeyHash, seed.subarray(32)]);
452
- cleanBytes(sk, publicKeyHash);
453
- return {
454
- publicKey: publicKey as TRet<Uint8Array>,
455
- secretKey: secretKey as TRet<Uint8Array>,
456
- };
454
+ keygen: (seed?: TArg<Uint8Array>) => {
455
+ // A generated seed carries z (the implicit-rejection secret) and must be wiped once the
456
+ // secret key holds a copy, matching ml-dsa / slh-dsa / falcon keygen. A caller-supplied
457
+ // seed is the caller's to manage (and the immutability test requires it stay untouched).
458
+ const ownSeed = seed === undefined;
459
+ const s = ownSeed ? randomBytes(seedLen) : (seed as TArg<Uint8Array>);
460
+ let sk: Uint8Array | undefined;
461
+ let publicKeyHash: Uint8Array | undefined;
462
+ try {
463
+ abytes(s, seedLen, 'seed');
464
+ const keys = KPKE.keygen(s.subarray(0, 32));
465
+ const publicKey = keys.publicKey;
466
+ sk = keys.secretKey as Uint8Array;
467
+ publicKeyHash = HASH256(publicKey);
468
+ // (dkPKE||ek||H(ek)||z)
469
+ const secretKey = secretCoder.encode([sk, publicKey, publicKeyHash, s.subarray(32)]);
470
+ return {
471
+ publicKey: publicKey as TRet<Uint8Array>,
472
+ secretKey: secretKey as TRet<Uint8Array>,
473
+ };
474
+ } finally {
475
+ if (sk !== undefined) cleanBytes(sk);
476
+ if (publicKeyHash !== undefined) cleanBytes(publicKeyHash);
477
+ if (ownSeed) cleanBytes(s);
478
+ }
457
479
  },
458
480
  getPublicKey: (secretKey: TArg<Uint8Array>): TRet<Uint8Array> => {
459
481
  const [_sk, publicKey, _publicKeyHash, _z] = secretCoder.decode(secretKey);
460
482
  return Uint8Array.from(publicKey) as TRet<Uint8Array>;
461
483
  },
462
- encapsulate: (publicKey: TArg<Uint8Array>, msg: TArg<Uint8Array> = randomBytes(msgLen)) => {
463
- abytes(publicKey, lengths.publicKey, 'publicKey');
464
- abytes(msg, msgLen, 'message');
465
- validateModulus(publicKey, 'encapsulate');
466
- // derive randomness
467
- const kr = HASH512.create().update(msg).update(HASH256(publicKey)).digest();
468
- const cipherText = KPKE.encrypt(publicKey, msg, kr.subarray(32, 64));
469
- cleanBytes(kr.subarray(32));
470
- return {
471
- cipherText: cipherText as TRet<Uint8Array>,
472
- sharedSecret: kr.subarray(0, 32) as TRet<Uint8Array>,
473
- };
484
+ encapsulate: (publicKey: TArg<Uint8Array>, msg?: TArg<Uint8Array>) => {
485
+ // A generated message is the preimage of the shared secret (K = G(m || H(ek))[0:32]) and
486
+ // must be wiped. A caller-supplied message is the deterministic-randomness hook and the
487
+ // caller's to manage (the immutability test requires it stay untouched).
488
+ const ownMsg = msg === undefined;
489
+ const m = ownMsg ? randomBytes(msgLen) : (msg as TArg<Uint8Array>);
490
+ let kr: Uint8Array | undefined;
491
+ try {
492
+ abytes(publicKey, lengths.publicKey, 'publicKey');
493
+ abytes(m, msgLen, 'message');
494
+ validateModulus(publicKey, 'encapsulate');
495
+ // derive randomness
496
+ kr = HASH512.create().update(m).update(HASH256(publicKey)).digest();
497
+ const cipherText = KPKE.encrypt(publicKey, m, kr.subarray(32, 64));
498
+ return {
499
+ cipherText: cipherText as TRet<Uint8Array>,
500
+ sharedSecret: kr.subarray(0, 32) as TRet<Uint8Array>,
501
+ };
502
+ } finally {
503
+ if (kr !== undefined) cleanBytes(kr.subarray(32));
504
+ if (ownMsg) cleanBytes(m);
505
+ }
474
506
  },
475
507
  decapsulate: (cipherText: TArg<Uint8Array>, secretKey: TArg<Uint8Array>): TRet<Uint8Array> => {
476
508
  abytes(secretKey, secretCoder.bytesLen, 'secretKey'); // 768*k + 96
@@ -509,15 +541,24 @@ function createKyber(opts: TArg<KyberOpts>): TRet<MLKEM> {
509
541
  const cached = KPKE.prepare(ek);
510
542
  return Object.freeze({
511
543
  publicKey: ek as TRet<Uint8Array>,
512
- encapsulate: (msg: TArg<Uint8Array> = randomBytes(msgLen)) => {
513
- abytes(msg, msgLen, 'message');
514
- const kr = HASH512.create().update(msg).update(publicKeyHash).digest();
515
- const cipherText = cached.encrypt(msg, kr.subarray(32, 64));
516
- cleanBytes(kr.subarray(32));
517
- return {
518
- cipherText: cipherText as TRet<Uint8Array>,
519
- sharedSecret: kr.subarray(0, 32) as TRet<Uint8Array>,
520
- };
544
+ encapsulate: (msg?: TArg<Uint8Array>) => {
545
+ // As in the non-prepared encapsulate: a generated message is the shared-secret
546
+ // preimage and is wiped; a caller-supplied one is left untouched.
547
+ const ownMsg = msg === undefined;
548
+ const m = ownMsg ? randomBytes(msgLen) : (msg as TArg<Uint8Array>);
549
+ let kr: Uint8Array | undefined;
550
+ try {
551
+ abytes(m, msgLen, 'message');
552
+ kr = HASH512.create().update(m).update(publicKeyHash).digest();
553
+ const cipherText = cached.encrypt(m, kr.subarray(32, 64));
554
+ return {
555
+ cipherText: cipherText as TRet<Uint8Array>,
556
+ sharedSecret: kr.subarray(0, 32) as TRet<Uint8Array>,
557
+ };
558
+ } finally {
559
+ if (kr !== undefined) cleanBytes(kr.subarray(32));
560
+ if (ownMsg) cleanBytes(m);
561
+ }
521
562
  },
522
563
  decapsulate: (
523
564
  cipherText: TArg<Uint8Array>,
package/src/slh-dsa.ts CHANGED
@@ -53,6 +53,13 @@ import {
53
53
  type VerOpts,
54
54
  } from './utils.ts';
55
55
 
56
+ // Keys the internal SLH-DSA surface accepts. `context` is deliberately absent: the public
57
+ // wrappers consume it when they format M' and must not forward it, because a key that is
58
+ // accepted and then never read is the same silent downgrade this validation exists to prevent.
59
+ // `extraEntropy` is signing-only, so verification (which takes no options of its own) has none.
60
+ const INTERNAL_SIG_OPT_KEYS = /* @__PURE__ */ Object.freeze(['extraEntropy'] as const);
61
+ const INTERNAL_VER_OPT_KEYS = /* @__PURE__ */ Object.freeze([] as const);
62
+
56
63
  /**
57
64
  * * N: Security parameter (in bytes). W: Winternitz parameter
58
65
  * * H: Hypertree height. D: Hypertree layers
@@ -83,6 +90,8 @@ export type SphincsHashOpts = {
83
90
  getContext: GetContext;
84
91
  };
85
92
 
93
+ type InternalSphincsHashOpts = SphincsHashOpts & { isCompressed: boolean };
94
+
86
95
  /** Winternitz signature params. */
87
96
  /**
88
97
  * Built-in SLH-DSA Table 2 subset keyed by strength/profile.
@@ -218,8 +227,8 @@ export type SphincsSigner = Signer & {
218
227
  * and `getPublicKey(secretKey)` only extracts the embedded public key
219
228
  * instead of recomputing `PK.root`.
220
229
  */
221
- function gen(opts: SphincsOpts, hashOpts_: TArg<SphincsHashOpts>): TRet<SphincsSigner> {
222
- const hashOpts = hashOpts_ as SphincsHashOpts;
230
+ function gen(opts: SphincsOpts, hashOpts_: TArg<InternalSphincsHashOpts>): TRet<SphincsSigner> {
231
+ const hashOpts = hashOpts_ as InternalSphincsHashOpts;
223
232
  const { N, W, H, D, K, A, securityLevel: securityLevel } = opts;
224
233
  const getContext = hashOpts.getContext(opts);
225
234
  if (W !== 16) throw new Error('Unsupported Winternitz parameter');
@@ -270,8 +279,18 @@ function gen(opts: SphincsOpts, hashOpts_: TArg<SphincsHashOpts>): TRet<SphincsS
270
279
  }>,
271
280
  addr: TArg<ADRS> = new Uint8Array(ADDR_BYTES)
272
281
  ) => {
273
- const { type, height, tree, layer, index, chain, hash, keypair } = opts;
274
- const { subtreeAddr, keypairAddr } = opts;
282
+ // These objects are created in hot internal loops, so avoid cloning them. Read only own fields:
283
+ // absent address words must stay absent even if Object.prototype was polluted.
284
+ const type = Object.hasOwn(opts, 'type') ? opts.type : undefined;
285
+ const height = Object.hasOwn(opts, 'height') ? opts.height : undefined;
286
+ const tree = Object.hasOwn(opts, 'tree') ? opts.tree : undefined;
287
+ const layer = Object.hasOwn(opts, 'layer') ? opts.layer : undefined;
288
+ const index = Object.hasOwn(opts, 'index') ? opts.index : undefined;
289
+ const chain = Object.hasOwn(opts, 'chain') ? opts.chain : undefined;
290
+ const hash = Object.hasOwn(opts, 'hash') ? opts.hash : undefined;
291
+ const keypair = Object.hasOwn(opts, 'keypair') ? opts.keypair : undefined;
292
+ const subtreeAddr = Object.hasOwn(opts, 'subtreeAddr') ? opts.subtreeAddr : undefined;
293
+ const keypairAddr = Object.hasOwn(opts, 'keypairAddr') ? opts.keypairAddr : undefined;
275
294
 
276
295
  if (height !== undefined) addr[OFFSET_CHAIN_ADDR] = height;
277
296
  if (layer !== undefined) addr[OFFSET_LAYER] = layer;
@@ -547,7 +566,7 @@ function gen(opts: SphincsOpts, hashOpts_: TArg<SphincsHashOpts>): TRet<SphincsS
547
566
  return Uint8Array.from(pk) as TRet<Uint8Array>;
548
567
  },
549
568
  sign: (msg: TArg<Uint8Array>, sk: TArg<Uint8Array>, opts: TArg<SigOpts> = {}) => {
550
- validateSigOpts(opts);
569
+ opts = validateSigOpts(opts, INTERNAL_SIG_OPT_KEYS);
551
570
  let { extraEntropy: random } = opts;
552
571
  const [skSeed, skPRF, pk] = secretCoder.decode(sk); // todo: fix
553
572
  const [pkSeed, _] = publicCoder.decode(pk);
@@ -623,7 +642,16 @@ function gen(opts: SphincsOpts, hashOpts_: TArg<SphincsHashOpts>): TRet<SphincsS
623
642
  cleanBytes(R, random, treeAddr, wotsAddr, forsLeaf, forsTreeAddr, indices, roots);
624
643
  return SIG as TRet<Uint8Array>;
625
644
  },
626
- verify: (sig: TArg<Uint8Array>, msg: TArg<Uint8Array>, publicKey: TArg<Uint8Array>) => {
645
+ verify: (
646
+ sig: TArg<Uint8Array>,
647
+ msg: TArg<Uint8Array>,
648
+ publicKey: TArg<Uint8Array>,
649
+ opts: TArg<VerOpts> = {}
650
+ ) => {
651
+ // The internal verify reads no options; reject any so a stray key (e.g. a caller
652
+ // mistaking this for the public verify and passing `context`) is reported rather than
653
+ // silently swallowed by this function's arity.
654
+ validateVerOpts(opts, INTERNAL_VER_OPT_KEYS);
627
655
  const [pkSeed, pubRoot] = publicCoder.decode(publicKey);
628
656
  const pk = publicKey;
629
657
  // FIPS 205 Algorithm 20 step 1: wrong-length signatures return false instead of throwing
@@ -700,9 +728,11 @@ function gen(opts: SphincsOpts, hashOpts_: TArg<SphincsHashOpts>): TRet<SphincsS
700
728
  keygen: internal.keygen,
701
729
  getPublicKey: internal.getPublicKey,
702
730
  sign: (msg: TArg<Uint8Array>, secretKey: TArg<Uint8Array>, opts: TArg<SigOpts> = {}) => {
703
- validateSigOpts(opts);
731
+ opts = validateSigOpts(opts);
704
732
  const M = getMessage(msg, opts.context);
705
- const res = internal.sign(M, secretKey, opts);
733
+ // `context` is consumed by getMessage() above; forwarding it would make the internal
734
+ // surface accept a key it never reads.
735
+ const res = internal.sign(M, secretKey, { extraEntropy: opts.extraEntropy });
706
736
  cleanBytes(M);
707
737
  return res as TRet<Uint8Array>;
708
738
  },
@@ -712,7 +742,7 @@ function gen(opts: SphincsOpts, hashOpts_: TArg<SphincsHashOpts>): TRet<SphincsS
712
742
  publicKey: TArg<Uint8Array>,
713
743
  opts: TArg<VerOpts> = {}
714
744
  ) => {
715
- validateVerOpts(opts);
745
+ opts = validateVerOpts(opts);
716
746
  return internal.verify(sig, getMessage(msg, opts.context), publicKey);
717
747
  },
718
748
  prehash: (hash: TArg<CHash>): TRet<Signer> => {
@@ -724,9 +754,10 @@ function gen(opts: SphincsOpts, hashOpts_: TArg<SphincsHashOpts>): TRet<SphincsS
724
754
  keygen: internal.keygen,
725
755
  getPublicKey: internal.getPublicKey,
726
756
  sign: (msg: TArg<Uint8Array>, secretKey: TArg<Uint8Array>, opts: TArg<SigOpts> = {}) => {
727
- validateSigOpts(opts);
757
+ opts = validateSigOpts(opts);
728
758
  const M = getMessagePrehash(rawHash, msg, opts.context);
729
- const res = internal.sign(M, secretKey, opts);
759
+ // As above: getMessagePrehash() consumes `context`, so it must not travel further.
760
+ const res = internal.sign(M, secretKey, { extraEntropy: opts.extraEntropy });
730
761
  cleanBytes(M);
731
762
  return res as TRet<Uint8Array>;
732
763
  },
@@ -736,7 +767,7 @@ function gen(opts: SphincsOpts, hashOpts_: TArg<SphincsHashOpts>): TRet<SphincsS
736
767
  publicKey: TArg<Uint8Array>,
737
768
  opts: TArg<VerOpts> = {}
738
769
  ) => {
739
- validateVerOpts(opts);
770
+ opts = validateVerOpts(opts);
740
771
  return internal.verify(sig, getMessagePrehash(rawHash, msg, opts.context), publicKey);
741
772
  },
742
773
  });
@@ -812,7 +843,7 @@ const genShake =
812
843
  } as TRet<Context>;
813
844
  };
814
845
 
815
- const SHAKE_SIMPLE = /* @__PURE__ */ (() => ({ getContext: genShake() }))();
846
+ const SHAKE_SIMPLE = /* @__PURE__ */ (() => ({ isCompressed: false, getContext: genShake() }))();
816
847
 
817
848
  /**
818
849
  * SLH-DSA-SHAKE-128f: Table 2 row `n=16, h=66, d=22, h'=3, a=6, k=33, lg w=4, m=34`;
package/src/utils.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  abytes as abytes_,
11
11
  ahash as ahash_,
12
12
  anumber,
13
+ bytesToHex,
13
14
  concatBytes,
14
15
  isBytes,
15
16
  isLE,
@@ -215,9 +216,10 @@ export function equalBytes(a: TArg<Uint8Array>, b: TArg<Uint8Array>): boolean {
215
216
  * ```
216
217
  */
217
218
  export function copyBytes(bytes: TArg<Uint8Array>): TRet<Uint8Array> {
218
- // `Uint8Array.from(...)` would also accept arrays / other typed arrays. Keep this helper strict
219
- // because callers use it at byte-validation boundaries before mutating the detached copy.
220
- return Uint8Array.from(abytes(bytes)) as TRet<Uint8Array>;
219
+ // The typed-array constructor copies a typed-array source through its internal byte storage.
220
+ // Unlike `Uint8Array.from`, it does not invoke a subclass-overridden iterator. Keep the explicit
221
+ // validation because the constructor itself would also accept arrays and other typed arrays.
222
+ return new Uint8Array(abytes(bytes)) as TRet<Uint8Array>;
221
223
  }
222
224
 
223
225
  /**
@@ -315,6 +317,66 @@ export function validateOpts(opts: object): void {
315
317
  // Arrays silently passed here before, but these call sites expect named option-bag fields.
316
318
  if (isBytes(opts)) throw new TypeError('"opts" expected object, got Uint8Array');
317
319
  aobject(opts, 'opts');
320
+ const proto = Object.getPrototypeOf(opts);
321
+ // Options are security parameters, not general class instances. Restricting the bag to own
322
+ // properties prevents values injected through Object.prototype (or a custom shared prototype)
323
+ // from silently changing signing behavior. Null-prototype records remain supported.
324
+ if (proto !== null && proto !== Object.prototype)
325
+ throw new TypeError('"opts" expected a plain object');
326
+ }
327
+
328
+ // Frozen because they are exported: an unfrozen array export lets anything in the
329
+ // process push a key onto the accepted set and silently re-open exactly the hole this
330
+ // validation closes.
331
+ /** Keys accepted by `verify`. */
332
+ export const VER_OPT_KEYS: readonly ['context'] = /* @__PURE__ */ Object.freeze([
333
+ 'context',
334
+ ] as const);
335
+ /** Keys accepted by `sign`. */
336
+ export const SIG_OPT_KEYS: readonly ['context', 'extraEntropy'] = /* @__PURE__ */ Object.freeze([
337
+ 'context',
338
+ 'extraEntropy',
339
+ ] as const);
340
+
341
+ /**
342
+ * Rejects option keys the caller did not mean to set.
343
+ *
344
+ * Validating the types of known keys while ignoring unknown ones makes a typo
345
+ * indistinguishable from an omission, and for these options an omission is a
346
+ * security downgrade rather than a no-op: `{ ctx }` instead of `{ context }` signs
347
+ * with no domain separation, succeeds, and verifies for anyone who also supplies
348
+ * none. Nothing at any layer reports it. TypeScript catches this through excess
349
+ * property checks, so the exposure is JavaScript callers specifically.
350
+ *
351
+ * @param opts - Options object to check.
352
+ * @param allowed - The keys this call site accepts.
353
+ * Returns a frozen null-prototype snapshot so later reads cannot fall through to a polluted
354
+ * prototype. Like `checkOpts()` in noble-hashes, only enumerable own properties are copied.
355
+ * @throws If any other copied key is present or the bag has a custom prototype. {@link TypeError}
356
+ * @returns Sanitized snapshot of the enumerable own options.
357
+ * @example
358
+ * Accept a known option key. A key the list does not name, such as `ctx`, throws instead.
359
+ * ```ts
360
+ * import { checkOptKeys } from '@noble/post-quantum/utils.js';
361
+ * checkOptKeys({ context: new Uint8Array() }, ['context']);
362
+ * ```
363
+ */
364
+ export function checkOptKeys<T extends object>(opts: T, allowed: readonly string[]): T {
365
+ validateOpts(opts);
366
+ // Snapshot once before validation: Object.assign follows the same own-enumerable option-bag
367
+ // semantics as noble-hashes, while the null prototype keeps omitted fields immune to pollution.
368
+ const normalized = Object.assign(Object.create(null), opts) as Record<string, unknown>;
369
+ for (const [k, v] of Object.entries(normalized)) {
370
+ // `undefined` means unset everywhere else in these validators, and building an options bag by
371
+ // spread is a normal way to reach these calls, so present-but-undefined stays equivalent to
372
+ // omission.
373
+ if (v === undefined) continue;
374
+ if (!allowed.includes(k))
375
+ throw new TypeError(
376
+ 'unexpected option "' + String(k) + '"; expected one of: ' + allowed.join(', ')
377
+ );
378
+ }
379
+ return Object.freeze(normalized) as T;
318
380
  }
319
381
 
320
382
  /**
@@ -322,16 +384,23 @@ export function validateOpts(opts: object): void {
322
384
  * `context` itself is validated with `abytes(...)`, and individual algorithms may narrow support
323
385
  * further after this shared plain-object gate.
324
386
  * @param opts - Verification options. See {@link VerOpts}.
387
+ * @param allowed - Keys this call site accepts. Defaults to {@link VER_OPT_KEYS}; surfaces that
388
+ * take extra keys, or take fewer, pass their own list.
325
389
  * @throws On wrong argument types. {@link TypeError}
390
+ * @returns Frozen null-prototype snapshot of the validated options.
326
391
  * @example
327
392
  * Validate common verification options.
328
393
  * ```ts
329
394
  * validateVerOpts({ context: new Uint8Array([1]) });
330
395
  * ```
331
396
  */
332
- export function validateVerOpts(opts: TArg<VerOpts>): void {
333
- validateOpts(opts);
334
- if (opts.context !== undefined) abytes(opts.context, undefined, 'opts.context');
397
+ export function validateVerOpts<T extends TArg<VerOpts>>(
398
+ opts: T,
399
+ allowed: readonly string[] = VER_OPT_KEYS
400
+ ): T {
401
+ const normalized = checkOptKeys(opts, allowed);
402
+ if (normalized.context !== undefined) abytes(normalized.context, undefined, 'opts.context');
403
+ return normalized;
335
404
  }
336
405
 
337
406
  /**
@@ -339,17 +408,25 @@ export function validateVerOpts(opts: TArg<VerOpts>): void {
339
408
  * `extraEntropy` is validated with `abytes(...)`; exact lengths and extra algorithm-specific
340
409
  * restrictions are enforced later by callers.
341
410
  * @param opts - Signing options. See {@link SigOpts}.
411
+ * @param allowed - Keys this call site accepts. Defaults to {@link SIG_OPT_KEYS}; surfaces that
412
+ * take extra keys, or take fewer, pass their own list.
342
413
  * @throws On wrong argument types. {@link TypeError}
414
+ * @returns Frozen null-prototype snapshot of the validated options.
343
415
  * @example
344
416
  * Validate common signing options.
345
417
  * ```ts
346
418
  * validateSigOpts({ extraEntropy: new Uint8Array([1]) });
347
419
  * ```
348
420
  */
349
- export function validateSigOpts(opts: TArg<SigOpts>): void {
350
- validateVerOpts(opts);
351
- if (opts.extraEntropy !== false && opts.extraEntropy !== undefined)
352
- abytes(opts.extraEntropy, undefined, 'opts.extraEntropy');
421
+ export function validateSigOpts<T extends TArg<SigOpts>>(
422
+ opts: T,
423
+ allowed: readonly string[] = SIG_OPT_KEYS
424
+ ): T {
425
+ const normalized = checkOptKeys(opts, allowed);
426
+ if (normalized.context !== undefined) abytes(normalized.context, undefined, 'opts.context');
427
+ if (normalized.extraEntropy !== false && normalized.extraEntropy !== undefined)
428
+ abytes(normalized.extraEntropy, undefined, 'opts.extraEntropy');
429
+ return normalized;
353
430
  }
354
431
 
355
432
  /** Generic signature interface with key generation, signing, and verification. */
@@ -617,6 +694,20 @@ export function getMessage(msg: TArg<Uint8Array>, ctx: TArg<Uint8Array> = EMPTY)
617
694
  // 06 09 60 86 48 01 65 03 04 02
618
695
  const oidNistP = /* @__PURE__ */ Uint8Array.from([6, 9, 0x60, 0x86, 0x48, 1, 0x65, 3, 4, 2]);
619
696
 
697
+ /**
698
+ * Output length, in bytes, that each XOF OID under this arc denotes.
699
+ *
700
+ * Unlike a fixed hash, an XOF's OID is a promise about the digest length: RFC 8702
701
+ * defines id-shake128 as SHAKE128 with 256-bit output and id-shake256 as SHAKE256 with
702
+ * 512-bit output, and FIPS 204 / FIPS 205 use exactly those pairings for pre-hash. Both
703
+ * bare noble-hashes defaults are half these values, so neither can be signed under its
704
+ * own OID.
705
+ */
706
+ const XOF_OID_OUTPUT_LEN: Record<string, number> = /* @__PURE__ */ (() => ({
707
+ '060960864801650304020b': 32, // id-shake128, SHAKE128(M, 256)
708
+ '060960864801650304020c': 64, // id-shake256, SHAKE256(M, 512)
709
+ }))();
710
+
620
711
  /**
621
712
  * Validates that a hash exposes a NIST hash OID and enough collision resistance.
622
713
  * Current accepted surface is broader than the FIPS algorithm tables: any hash/XOF under the NIST
@@ -646,6 +737,20 @@ export function checkHash(hash: CHash, requiredStrength: number = 0): void {
646
737
  // FIPS 204 / FIPS 205 require both collision and second-preimage strength; for approved NIST
647
738
  // hashes/XOFs under this OID subtree, the collision bound from the configured digest length is
648
739
  // the tighter runtime check, so enforce that lower bound here.
740
+ // XOFs under this arc are identified by an OID that fixes their output length:
741
+ // FIPS 204 §5.4.1 (SHAKE128) and FIPS 205 §10.2.2 (both SHAKEs), matching RFC 8702, pair
742
+ // id-shake128 with SHAKE128(M, 256) and id-shake256 with SHAKE256(M, 512). getMessagePrehash embeds
743
+ // hash.oid beside hash(msg), so a shorter digest signs an M' that claims a length
744
+ // it does not have: noble-hashes' bare shake256 defaults to 32 bytes and cleared
745
+ // the collision bound at the 128-bit level, producing signatures a conformant
746
+ // verifier rejects because it recomputes 512 bits. Check the length the OID
747
+ // denotes rather than the generic bound.
748
+ const xofLen = XOF_OID_OUTPUT_LEN[bytesToHex(oid as Uint8Array)];
749
+ if (xofLen !== undefined && hash.outputLen !== xofLen) {
750
+ throw new Error(
751
+ 'Pre-hash XOF output length must be ' + xofLen + ' bytes for this OID, got: ' + hash.outputLen
752
+ );
753
+ }
649
754
  const collisionResistance = (hash.outputLen * 8) / 2;
650
755
  if (requiredStrength > collisionResistance) {
651
756
  throw new Error(