@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/hybrid.ts CHANGED
@@ -80,6 +80,8 @@ import { type ECDSA } from '@noble/curves/abstract/weierstrass.js';
80
80
  import { x25519 } from '@noble/curves/ed25519.js';
81
81
  import { p256, p384 } from '@noble/curves/nist.js';
82
82
  import {
83
+ abool,
84
+ afunction,
83
85
  asciiToBytes,
84
86
  bytesToNumberBE,
85
87
  bytesToNumberLE,
@@ -89,9 +91,11 @@ import {
89
91
  import { expand, extract } from '@noble/hashes/hkdf.js';
90
92
  import { sha256 } from '@noble/hashes/sha2.js';
91
93
  import { sha3_256, shake256 } from '@noble/hashes/sha3.js';
92
- 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';
93
95
  import { ml_kem1024, ml_kem768 } from './ml-kem.ts';
94
96
  import {
97
+ aobject,
98
+ astring,
95
99
  cleanBytes,
96
100
  copyBytes,
97
101
  randomBytes,
@@ -108,9 +112,32 @@ import {
108
112
  type CurveAll = ECDSA | EdDSA | MontgomeryECDH;
109
113
  type CurveECDH = ECDSA | MontgomeryECDH;
110
114
  type CurveSign = ECDSA | EdDSA;
115
+ const validateKEM = (kem: TArg<KEM>, title: string): TRet<KEM> => {
116
+ const k = aobject<KEM>(kem, title);
117
+ aobject(k.lengths, `${title}.lengths`);
118
+ afunction(k.keygen, `${title}.keygen`);
119
+ afunction(k.getPublicKey, `${title}.getPublicKey`);
120
+ afunction(k.encapsulate, `${title}.encapsulate`);
121
+ afunction(k.decapsulate, `${title}.decapsulate`);
122
+ return k as TRet<KEM>;
123
+ };
124
+ const validateSigner = (signer: TArg<Signer>, title: string): TRet<Signer> => {
125
+ const s = aobject<Signer>(signer, title);
126
+ aobject(s.lengths, `${title}.lengths`);
127
+ afunction(s.keygen, `${title}.keygen`);
128
+ afunction(s.getPublicKey, `${title}.getPublicKey`);
129
+ afunction(s.sign, `${title}.sign`);
130
+ afunction(s.verify, `${title}.verify`);
131
+ return s as TRet<Signer>;
132
+ };
111
133
 
112
134
  // Can re-use if decide to signatures support, on other hand getSecretKey is specific and ugly
113
135
  function ecKeygen(curve: CurveAll, allowZeroKey: boolean = false) {
136
+ const c = aobject<CurveAll>(curve, 'curve');
137
+ aobject(c.lengths, 'curve.lengths');
138
+ afunction(c.keygen, 'curve.keygen');
139
+ afunction(c.getPublicKey, 'curve.getPublicKey');
140
+ abool(allowZeroKey, 'allowZeroKey');
114
141
  const lengths = curve.lengths;
115
142
  let keygen = curve.keygen;
116
143
  if (allowZeroKey) {
@@ -154,6 +181,12 @@ function ecKeygen(curve: CurveAll, allowZeroKey: boolean = false) {
154
181
  /**
155
182
  * Wraps an ECDH-capable curve as a KEM.
156
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
+ *
157
190
  * On SEC 1 / Weierstrass curves, that means the compressed shared-point body without the
158
191
  * 1-byte `0x02` / `0x03` prefix.
159
192
  * The X25519 path also leaves RFC 7748's optional all-zero shared-secret check to callers.
@@ -169,14 +202,19 @@ function ecKeygen(curve: CurveAll, allowZeroKey: boolean = false) {
169
202
  * Wrap an ECDH-capable curve as a generic KEM.
170
203
  * ```ts
171
204
  * import { x25519 } from '@noble/curves/ed25519.js';
172
- * import { ecdhKem } from '@noble/post-quantum/hybrid.js';
173
- * const kem = ecdhKem(x25519);
205
+ * import { _ecdhKem } from '@noble/post-quantum/hybrid.js';
206
+ * const kem = _ecdhKem(x25519);
174
207
  * const publicKeyLen = kem.lengths.publicKey;
175
208
  * ```
176
209
  */
177
- export function ecdhKem(curve: CurveECDH, allowZeroKey: boolean = false): TRet<KEM> {
210
+ export function _ecdhKem(curve: CurveECDH, allowZeroKey: boolean = false): TRet<KEM> {
178
211
  const kg = ecKeygen(curve, allowZeroKey);
179
212
  if (!curve.getSharedSecret) throw new Error('wrong curve'); // ed25519 doesn't have one!
213
+ // Standalone (not `this.decapsulate`) so encapsulate works even when methods are destructured.
214
+ const decapsulate = (cipherText: TArg<Uint8Array>, secretKey: TArg<Uint8Array>) => {
215
+ const res = curve.getSharedSecret(secretKey, cipherText);
216
+ return (curve.lengths.publicKeyHasPrefix ? res.subarray(1) : res) as TRet<Uint8Array>;
217
+ };
180
218
  return {
181
219
  lengths: { ...kg.lengths, msg: kg.lengths.seed, cipherText: kg.lengths.publicKey },
182
220
  keygen: kg.keygen,
@@ -190,8 +228,8 @@ export function ecdhKem(curve: CurveECDH, allowZeroKey: boolean = false): TRet<K
190
228
  const seed = copyBytes(rand);
191
229
  let ek: Uint8Array | undefined = undefined;
192
230
  try {
193
- ek = this.keygen(seed).secretKey;
194
- const sharedSecret = this.decapsulate(publicKey, ek);
231
+ ek = kg.keygen(seed).secretKey;
232
+ const sharedSecret = decapsulate(publicKey, ek);
195
233
  const cipherText = curve.getPublicKey(ek) as TRet<Uint8Array>;
196
234
  return { sharedSecret, cipherText };
197
235
  } finally {
@@ -201,10 +239,7 @@ export function ecdhKem(curve: CurveECDH, allowZeroKey: boolean = false): TRet<K
201
239
  if (ek) cleanBytes(ek);
202
240
  }
203
241
  },
204
- decapsulate(cipherText: TArg<Uint8Array>, secretKey: TArg<Uint8Array>) {
205
- const res = curve.getSharedSecret(secretKey, cipherText);
206
- return (curve.lengths.publicKeyHasPrefix ? res.subarray(1) : res) as TRet<Uint8Array>;
207
- },
242
+ decapsulate,
208
243
  };
209
244
  }
210
245
 
@@ -237,7 +272,7 @@ export function ecSigner(curve: CurveSign, allowZeroKey: boolean = false): TRet<
237
272
  keygen: kg.keygen,
238
273
  getPublicKey: kg.getPublicKey,
239
274
  sign: (message, secretKey, opts = {}) => {
240
- validateSigOpts(opts);
275
+ opts = validateSigOpts(opts);
241
276
  // This generic wrapper intentionally keeps the Signer contract to message + key only.
242
277
  // Backend-specific knobs like ECDSA extraEntropy or Ed25519ctx context cannot be forwarded
243
278
  // uniformly through combineSigners(), so callers that need them must use the curve directly.
@@ -254,7 +289,7 @@ export function ecSigner(curve: CurveSign, allowZeroKey: boolean = false): TRet<
254
289
  * generic opts and lets wrapped-curve malformed-input errors escape unchanged.
255
290
  */
256
291
  verify: (signature, message, publicKey, opts = {}) => {
257
- validateVerOpts(opts);
292
+ opts = validateVerOpts(opts);
258
293
  if (opts.context !== undefined)
259
294
  throw new Error('ecSigner does not support context; use the underlying curve directly');
260
295
  return curve.verify(signature, message, publicKey);
@@ -262,18 +297,26 @@ export function ecSigner(curve: CurveSign, allowZeroKey: boolean = false): TRet<
262
297
  };
263
298
  }
264
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
+
265
306
  function splitLengths<K extends string, T extends { lengths: Partial<Record<K, number>> }>(
266
307
  lst: T[],
267
308
  name: K
268
309
  ) {
269
310
  // Preserve caller order exactly; raw numeric fields still decode as splitCoder() subarray views.
270
- return splitCoder(
311
+ const coder = splitCoder(
271
312
  name,
272
313
  ...lst.map((i) => {
273
314
  if (typeof i.lengths[name] !== 'number') throw new Error('wrong length: ' + name);
274
- return i.lengths[name];
315
+ return positiveLength(i.lengths[name], name);
275
316
  })
276
317
  );
318
+ positiveLength(coder.bytesLen, name);
319
+ return coder;
277
320
  }
278
321
 
279
322
  /** Seed-expansion callback used by the hybrid combiners. */
@@ -319,13 +362,15 @@ function combineKeys(
319
362
  const seedCoder = splitLengths(ck, 'seed');
320
363
  const pkCoder = splitLengths(ck, 'publicKey');
321
364
  // Allows to use identity functions for combiner/expandSeed
322
- if (realSeedLen === undefined) realSeedLen = seedCoder.bytesLen;
323
- anumber(realSeedLen);
365
+ const rootSeedLen = positiveLength(
366
+ realSeedLen === undefined ? seedCoder.bytesLen : realSeedLen,
367
+ 'realSeedLen'
368
+ );
324
369
  function expandDecapsulationKey(seed: TArg<Uint8Array>): TRet<{
325
370
  secretKey: Uint8Array[];
326
371
  publicKey: Uint8Array[];
327
372
  }> {
328
- abytes(seed, realSeedLen!);
373
+ abytes(seed, rootSeedLen);
329
374
  const expandedRaw = expandSeed(seed, seedCoder.bytesLen);
330
375
  // Identity/subarray expanders can hand back caller-owned seed storage. Detach those outputs so
331
376
  // later cleanup can wipe the expanded schedule without mutating the caller's root seed bytes.
@@ -359,39 +404,60 @@ function combineKeys(
359
404
  if (!ok) cleanBytes(secretKey);
360
405
  }
361
406
  }
362
- return {
363
- info: { lengths: { seed: realSeedLen, publicKey: pkCoder.bytesLen, secretKey: realSeedLen } },
364
- getPublicKey(secretKey: TArg<Uint8Array>) {
365
- // Composite secret keys are root seeds, so public-key derivation reruns key expansion from
366
- // that seed instead of decoding a packed child-secret-key structure.
367
- return this.keygen(secretKey).publicKey as TRet<Uint8Array>;
368
- },
369
- keygen(seed: TArg<Uint8Array> = randomBytes(realSeedLen)) {
370
- const { publicKey: pk, secretKey } = expandDecapsulationKey(seed);
407
+ // Standalone (not a method) so getPublicKey / destructured usage never depends on `this`.
408
+ const keygen = (seed?: TArg<Uint8Array>) => {
409
+ // Detach the root: the exported secretKey must not alias caller-owned seed bytes, so later
410
+ // caller mutation of the seed cannot silently change the secret key (and vice versa).
411
+ const root = seed === undefined ? randomBytes(rootSeedLen) : copyBytes(seed);
412
+ let res;
413
+ try {
414
+ const { publicKey: pk, secretKey } = expandDecapsulationKey(root);
371
415
  try {
372
- const publicKey = pkCoder.encode(pk) as TRet<Uint8Array>;
373
- return { secretKey: seed as TRet<Uint8Array>, publicKey };
416
+ res = {
417
+ secretKey: root as TRet<Uint8Array>,
418
+ publicKey: pkCoder.encode(pk) as TRet<Uint8Array>,
419
+ };
374
420
  } finally {
375
- cleanBytes(pk);
376
- // The exported secretKey is the caller/root seed itself; child secret keys are internal
421
+ // The exported secretKey is the (detached) root seed; child secret keys are internal
377
422
  // expansion outputs that are cleaned whether encoding succeeds or throws.
378
- cleanBytes(secretKey);
423
+ cleanBytes(pk, secretKey);
379
424
  }
425
+ return res;
426
+ } finally {
427
+ if (!res) cleanBytes(root);
428
+ }
429
+ };
430
+ return {
431
+ info: { lengths: { seed: rootSeedLen, publicKey: pkCoder.bytesLen, secretKey: rootSeedLen } },
432
+ // Composite secret keys are root seeds, so public-key derivation reruns key expansion from
433
+ // that seed instead of decoding a packed child-secret-key structure.
434
+ getPublicKey: (secretKey: TArg<Uint8Array>) => {
435
+ const keys = keygen(secretKey);
436
+ // keygen detaches its exported root; getPublicKey discards that half of the result.
437
+ cleanBytes(keys.secretKey);
438
+ return keys.publicKey as TRet<Uint8Array>;
380
439
  },
440
+ keygen,
381
441
  expandDecapsulationKey,
382
- realSeedLen,
442
+ realSeedLen: rootSeedLen,
383
443
  };
384
444
  }
385
445
 
386
446
  // This generic function that combines multiple KEMs into single one
387
447
  /**
388
448
  * Combines multiple KEMs into one composite KEM.
389
- * @param realSeedLen - Input seed length expected by `expandSeed`.
390
- * @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.
391
454
  * @param expandSeed - Seed expander used to derive per-KEM seeds.
392
455
  * @param combiner - Combines the per-KEM outputs into one shared secret.
393
- * @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.
394
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}
395
461
  * @example
396
462
  * Combine multiple KEMs into one composite KEM.
397
463
  * ```ts
@@ -416,20 +482,56 @@ export function combineKEMS(
416
482
  combiner: TArg<Combiner>,
417
483
  ...kems: TArg<KEM[]>
418
484
  ): TRet<KEM> {
485
+ if (realSeedLen !== undefined) positiveLength(realSeedLen, 'realSeedLen');
486
+ if (realMsgLen !== undefined) positiveLength(realMsgLen, 'realMsgLen');
487
+ if (typeof expandSeed !== 'function')
488
+ throw new TypeError('"expandSeed" expected function, got type=' + typeof expandSeed);
489
+ if (typeof combiner !== 'function')
490
+ throw new TypeError('"combiner" expected function, got type=' + typeof combiner);
419
491
  const rawCombiner = combiner as Combiner;
420
492
  const rawKems = kems as KEM[];
493
+ if (rawKems.length === 0) throw new RangeError('combineKEMS requires at least one KEM');
494
+ for (let i = 0; i < rawKems.length; i++) validateKEM(rawKems[i], `kems[${i}]`);
421
495
  const keys = combineKeys(realSeedLen, expandSeed, ...rawKems);
422
496
  const ctCoder = splitLengths(rawKems, 'cipherText');
423
497
  const pkCoder = splitLengths(rawKems, 'publicKey');
424
498
  const msgCoder = splitLengths(rawKems, 'msg');
425
- if (realMsgLen === undefined) realMsgLen = msgCoder.bytesLen;
426
- anumber(realMsgLen);
499
+ const sharedSecretLen = positiveLength(
500
+ realMsgLen === undefined ? msgCoder.bytesLen : realMsgLen,
501
+ 'realMsgLen'
502
+ );
427
503
  const lengths = Object.freeze({
428
504
  ...keys.info.lengths,
429
- msg: realMsgLen,
505
+ msg: sharedSecretLen,
430
506
  msgRand: msgCoder.bytesLen,
431
507
  cipherText: ctCoder.bytesLen,
432
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
+ };
433
535
  return Object.freeze({
434
536
  lengths,
435
537
  getPublicKey: keys.getPublicKey,
@@ -448,11 +550,14 @@ export function combineKEMS(
448
550
  sharedSecret.push(enc.sharedSecret);
449
551
  cipherText.push(enc.cipherText);
450
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>;
451
556
  return {
452
557
  // Detach the combiner result before cleanup: a caller-provided combiner may alias one of
453
558
  // the child sharedSecret buffers, and those child buffers are zeroized immediately below.
454
- sharedSecret: copyBytes(rawCombiner(pks, cipherText, sharedSecret)),
455
- cipherText: ctCoder.encode(cipherText) as TRet<Uint8Array>,
559
+ sharedSecret: combine(pks, cipherText, sharedSecret),
560
+ cipherText: encodedCipherText,
456
561
  };
457
562
  } finally {
458
563
  // Child encapsulation or combiner failures can happen after some components already
@@ -463,11 +568,16 @@ export function combineKEMS(
463
568
  decapsulate(ct: TArg<Uint8Array>, seed: TArg<Uint8Array>) {
464
569
  const cts = ctCoder.decode(ct);
465
570
  const { publicKey, secretKey } = keys.expandDecapsulationKey(seed);
466
- const sharedSecret = rawKems.map((i, j) => i.decapsulate(cts[j], secretKey[j]));
571
+ const sharedSecret: Uint8Array[] = [];
467
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]));
468
578
  // Detach the decapsulation result before cleanup: the combiner may hand back one of the
469
579
  // child shared-secret buffers, and those temporary buffers are zeroized below.
470
- return copyBytes(rawCombiner(publicKey, cts, sharedSecret));
580
+ return combine(publicKey, cts, sharedSecret);
471
581
  } finally {
472
582
  // Decapsulation only needs the expanded child secret keys and child shared secrets for this
473
583
  // call; keep the caller/root seed intact, but wipe all derived material even on errors.
@@ -480,10 +590,15 @@ export function combineKEMS(
480
590
  // realSeedLen: how much bytes expandSeed expects.
481
591
  /**
482
592
  * Combines multiple signers into one composite signer.
483
- * @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.
484
596
  * @param expandSeed - Seed expander used to derive per-signer seeds.
485
- * @param signers - Signers to combine.
597
+ * @param signers - At least one signer. A construction advertised as hybrid normally supplies two
598
+ * or more.
486
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}
487
602
  * @example
488
603
  * Combine multiple signers into one composite signer.
489
604
  * ```ts
@@ -491,7 +606,11 @@ export function combineKEMS(
491
606
  * import { combineSigners, expandSeedXof } from '@noble/post-quantum/hybrid.js';
492
607
  * import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js';
493
608
  * const hybrid = combineSigners(32, expandSeedXof(shake256), ml_dsa44, ml_dsa44);
494
- * const { publicKey } = hybrid.keygen();
609
+ * const seed = new Uint8Array(hybrid.lengths.seed!).fill(1);
610
+ * const { secretKey, publicKey } = hybrid.keygen(seed);
611
+ * const msg = new TextEncoder().encode('hello noble');
612
+ * const sig = hybrid.sign(msg, secretKey);
613
+ * const isValid = hybrid.verify(sig, msg, publicKey);
495
614
  * ```
496
615
  */
497
616
  export function combineSigners(
@@ -499,7 +618,12 @@ export function combineSigners(
499
618
  expandSeed: TArg<ExpandSeed>,
500
619
  ...signers: TArg<Signer[]>
501
620
  ): TRet<Signer> {
621
+ if (realSeedLen !== undefined) positiveLength(realSeedLen, 'realSeedLen');
622
+ if (typeof expandSeed !== 'function')
623
+ throw new TypeError('"expandSeed" expected function, got type=' + typeof expandSeed);
502
624
  const rawSigners = signers as Signer[];
625
+ if (rawSigners.length === 0) throw new RangeError('combineSigners requires at least one signer');
626
+ for (let i = 0; i < rawSigners.length; i++) validateSigner(rawSigners[i], `signers[${i}]`);
503
627
  const keys = combineKeys(realSeedLen, expandSeed, ...rawSigners);
504
628
  const sigCoder = splitLengths(rawSigners, 'signature');
505
629
  const pkCoder = splitLengths(rawSigners, 'publicKey');
@@ -508,7 +632,7 @@ export function combineSigners(
508
632
  getPublicKey: keys.getPublicKey,
509
633
  keygen: keys.keygen,
510
634
  sign(message, seed, opts = {}) {
511
- validateSigOpts(opts);
635
+ opts = validateSigOpts(opts);
512
636
  // This generic wrapper intentionally keeps the composite signer contract to message + root
513
637
  // seed only. Per-signer opts like context or extraEntropy cannot be preserved uniformly
514
638
  // across mixed backends, so callers that need them must use the underlying signer directly.
@@ -531,16 +655,22 @@ export function combineSigners(
531
655
  }
532
656
  },
533
657
  /** Verify one combined signature.
534
- * Returns `false` when the aggregate signature/publicKey decode succeeds but any child verify
535
- * check fails. Throws on unsupported generic opts or malformed aggregate encodings.
658
+ * Wrong-length aggregate signatures return `false` (matching ml-dsa / slh-dsa behavior), as
659
+ * does any failing child verify. Throws on unsupported generic opts or malformed publicKey.
536
660
  */
537
661
  verify: (signature, message, publicKey, opts = {}) => {
538
- validateVerOpts(opts);
662
+ opts = validateVerOpts(opts);
539
663
  if (opts.context !== undefined)
540
664
  throw new Error(
541
665
  'combineSigners does not support context; use the underlying signer directly'
542
666
  );
667
+ // Malformed signature *length* is a verification failure, not a thrown type error —
668
+ // consistent with ml-dsa / slh-dsa. Must run before sigCoder.decode, which throws.
669
+ // Preserve TypeError for non-byte API arguments before treating byte lengths as invalid.
670
+ abytes(signature, undefined, 'signature');
671
+ // A signature failure must not hide malformed aggregate public-key bytes.
543
672
  const pks = pkCoder.decode(publicKey);
673
+ if (signature.length !== sigCoder.bytesLen) return false;
544
674
  const sigs = sigCoder.decode(signature);
545
675
  for (let i = 0; i < rawSigners.length; i++) {
546
676
  if (!rawSigners[i].verify(sigs[i], message, pks[i])) return false;
@@ -563,14 +693,16 @@ export function combineSigners(
563
693
  * @param xof - XOF used for seed expansion.
564
694
  * @param kdf - Hash used for the final combiner.
565
695
  * @returns Hybrid KEM.
696
+ * @throws On wrong argument types. {@link TypeError}
697
+ * @throws On wrong argument ranges or values. {@link RangeError}
566
698
  * @example
567
699
  * Build a QSF hybrid KEM preset from a PQ KEM and an elliptic-curve KEM.
568
700
  * ```ts
569
701
  * import { p256 } from '@noble/curves/nist.js';
570
702
  * import { sha3_256, shake256 } from '@noble/hashes/sha3.js';
571
- * import { QSF, ecdhKem } from '@noble/post-quantum/hybrid.js';
703
+ * import { QSF, _ecdhKem } from '@noble/post-quantum/hybrid.js';
572
704
  * import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
573
- * 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);
574
706
  * const publicKeyLen = kem.lengths.publicKey;
575
707
  * ```
576
708
  */
@@ -581,7 +713,14 @@ export function QSF(
581
713
  xof: TArg<XOF>,
582
714
  kdf: CHash
583
715
  ): TRet<KEM> {
716
+ astring(label, 'label');
717
+ validateKEM(pqc, 'pqc');
718
+ validateKEM(curveKEM, 'curveKEM');
719
+ if (typeof xof !== 'function' || typeof (xof as any).create !== 'function')
720
+ throw new TypeError('"xof" expected hash function, got type=' + typeof xof);
584
721
  ahash(xof);
722
+ if (typeof kdf !== 'function' || typeof (kdf as any).create !== 'function')
723
+ throw new TypeError('"kdf" expected hash function, got type=' + typeof kdf);
585
724
  ahash(kdf);
586
725
  return combineKEMS(
587
726
  32,
@@ -599,7 +738,7 @@ export const QSF_ml_kem768_p256: TRet<KEM> = /* @__PURE__ */ (() =>
599
738
  QSF(
600
739
  'QSF-KEM(ML-KEM-768,P-256)-XOF(SHAKE256)-KDF(SHA3-256)',
601
740
  ml_kem768,
602
- ecdhKem(p256, true),
741
+ _ecdhKem(p256, true),
603
742
  shake256,
604
743
  sha3_256
605
744
  ))();
@@ -608,7 +747,7 @@ export const QSF_ml_kem1024_p384: TRet<KEM> = /* @__PURE__ */ (() =>
608
747
  QSF(
609
748
  'QSF-KEM(ML-KEM-1024,P-384)-XOF(SHAKE256)-KDF(SHA3-256)',
610
749
  ml_kem1024,
611
- ecdhKem(p384, true),
750
+ _ecdhKem(p384, true),
612
751
  shake256,
613
752
  sha3_256
614
753
  ))();
@@ -627,15 +766,17 @@ export const QSF_ml_kem1024_p384: TRet<KEM> = /* @__PURE__ */ (() =>
627
766
  * @param xof - XOF used for seed expansion.
628
767
  * @param hash - Hash used for HKDF extraction and expansion.
629
768
  * @returns Hybrid KEM.
769
+ * @throws On wrong argument types. {@link TypeError}
770
+ * @throws On wrong argument ranges or values. {@link RangeError}
630
771
  * @example
631
772
  * Build the "KitchenSink" hybrid KEM combiner.
632
773
  * ```ts
633
774
  * import { sha256 } from '@noble/hashes/sha2.js';
634
775
  * import { shake256 } from '@noble/hashes/sha3.js';
635
- * import { createKitchenSink, ecdhKem } from '@noble/post-quantum/hybrid.js';
776
+ * import { createKitchenSink, _ecdhKem } from '@noble/post-quantum/hybrid.js';
636
777
  * import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
637
778
  * import { x25519 } from '@noble/curves/ed25519.js';
638
- * const kem = createKitchenSink('example', ml_kem768, ecdhKem(x25519), shake256, sha256);
779
+ * const kem = createKitchenSink('example', ml_kem768, _ecdhKem(x25519), shake256, sha256);
639
780
  * const publicKeyLen = kem.lengths.publicKey;
640
781
  * ```
641
782
  */
@@ -646,7 +787,14 @@ export function createKitchenSink(
646
787
  xof: TArg<XOF>,
647
788
  hash: CHash
648
789
  ): TRet<KEM> {
790
+ astring(label, 'label');
791
+ validateKEM(pqc, 'pqc');
792
+ validateKEM(curveKEM, 'curveKEM');
793
+ if (typeof xof !== 'function' || typeof (xof as any).create !== 'function')
794
+ throw new TypeError('"xof" expected hash function, got type=' + typeof xof);
649
795
  ahash(xof);
796
+ if (typeof hash !== 'function' || typeof (hash as any).create !== 'function')
797
+ throw new TypeError('"hash" expected hash function, got type=' + typeof hash);
650
798
  ahash(hash);
651
799
  return combineKEMS(
652
800
  32,
@@ -671,9 +819,9 @@ export function createKitchenSink(
671
819
  );
672
820
  }
673
821
 
674
- // Internal alias only: this stays exactly `ecdhKem(x25519)`
822
+ // Internal alias only: this stays exactly `_ecdhKem(x25519)`
675
823
  // and inherits that wrapper's mutation/oracle behavior.
676
- const x25519kem = /* @__PURE__ */ ecdhKem(x25519);
824
+ const x25519kem = /* @__PURE__ */ _ecdhKem(x25519);
677
825
  /** KitchenSink preset combining ML-KEM-768 with X25519.
678
826
  * Caller randomness splits into 32 ML-KEM coins plus a 32-byte X25519 ephemeral-secret seed.
679
827
  */
@@ -734,6 +882,11 @@ function nistCurveKem(curve: ECDSA, scalarLen: number, elemLen: number, nseed: n
734
882
  }>;
735
883
  }
736
884
 
885
+ // Standalone (not `this.decapsulate`) so encapsulate works even when methods are destructured.
886
+ const decapsulate = (cipherText: TArg<Uint8Array>, secretKey: TArg<Uint8Array>) => {
887
+ const full = curve.getSharedSecret(secretKey, cipherText);
888
+ return full.subarray(1) as TRet<Uint8Array>;
889
+ };
737
890
  return {
738
891
  lengths: {
739
892
  secretKey: scalarLen,
@@ -754,7 +907,7 @@ function nistCurveKem(curve: ECDSA, scalarLen: number, elemLen: number, nseed: n
754
907
  let ek: Uint8Array | undefined = undefined;
755
908
  try {
756
909
  ek = rejectionSampling(rand).secretKey;
757
- const sharedSecret = this.decapsulate(publicKey, ek);
910
+ const sharedSecret = decapsulate(publicKey, ek);
758
911
  const cipherText = curve.getPublicKey(ek, false) as TRet<Uint8Array>;
759
912
  return { sharedSecret, cipherText };
760
913
  } finally {
@@ -763,10 +916,7 @@ function nistCurveKem(curve: ECDSA, scalarLen: number, elemLen: number, nseed: n
763
916
  if (ek) cleanBytes(ek);
764
917
  }
765
918
  },
766
- decapsulate(cipherText: TArg<Uint8Array>, secretKey: TArg<Uint8Array>) {
767
- const full = curve.getSharedSecret(secretKey, cipherText);
768
- return full.subarray(1) as TRet<Uint8Array>;
769
- },
919
+ decapsulate,
770
920
  };
771
921
  }
772
922
 
@@ -795,10 +945,11 @@ function concreteHybridKem(
795
945
  32,
796
946
  (seed: TArg<Uint8Array>): TRet<Uint8Array> => {
797
947
  abytes(seed, 32);
798
- const expanded = shake256(seed, { dkLen: totalSeedLen });
799
- const mlkemSeed = expanded.subarray(0, mlkemSeedLen);
800
- const curveSeed = expanded.subarray(mlkemSeedLen, totalSeedLen);
801
- return concatBytes(mlkemSeed, curveSeed) as TRet<Uint8Array>;
948
+ // One SHAKE256 stream split by the seed coder as mlkemSeed (64) || curveSeed (nseed).
949
+ // Returned directly: the previous concatBytes of two adjacent subarrays produced an
950
+ // identical copy while leaving this original buffer unwiped; expandDecapsulationKey
951
+ // wipes the returned buffer after the child seeds are copied out.
952
+ return shake256(seed, { dkLen: totalSeedLen }) as TRet<Uint8Array>;
802
953
  },
803
954
  (pk: TArg<Uint8Array[]>, ct: TArg<Uint8Array[]>, ss: TArg<Uint8Array[]>) =>
804
955
  sha3_256(concatBytes(ss[0], ss[1], ct[1], pk[1], asciiToBytes(label))),
@@ -814,20 +965,3 @@ export const ml_kem768_p256: TRet<KEM> = /* @__PURE__ */ (() =>
814
965
  /** P-384 + ML-KEM-1024 hybrid preset. */
815
966
  export const ml_kem1024_p384: TRet<KEM> = /* @__PURE__ */ (() =>
816
967
  concreteHybridKem('MLKEM1024-P384', ml_kem1024, p384, 48))();
817
-
818
- // Legacy aliases
819
- /** Legacy alias for `ml_kem768_x25519`. */
820
- export const XWing: TRet<KEM> = /* @__PURE__ */ (() => ml_kem768_x25519)();
821
- /** Legacy alias for `ml_kem768_x25519`. */
822
- export const MLKEM768X25519: TRet<KEM> = /* @__PURE__ */ (() => ml_kem768_x25519)();
823
- /** Legacy alias for `ml_kem768_p256`. */
824
- export const MLKEM768P256: TRet<KEM> = /* @__PURE__ */ (() => ml_kem768_p256)();
825
- /** Legacy alias for `ml_kem1024_p384`. */
826
- export const MLKEM1024P384: TRet<KEM> = /* @__PURE__ */ (() => ml_kem1024_p384)();
827
- /** Legacy alias for `QSF_ml_kem768_p256`. */
828
- export const QSFMLKEM768P256: TRet<KEM> = /* @__PURE__ */ (() => QSF_ml_kem768_p256)();
829
- /** Legacy alias for `QSF_ml_kem1024_p384`. */
830
- export const QSFMLKEM1024P384: TRet<KEM> = /* @__PURE__ */ (() => QSF_ml_kem1024_p384)();
831
- /** Legacy alias for `KitchenSink_ml_kem768_x25519`. */
832
- export const KitchenSinkMLKEM768X25519: TRet<KEM> = /* @__PURE__ */ (() =>
833
- KitchenSink_ml_kem768_x25519)();
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
  ```