@noble/post-quantum 0.5.3 → 0.6.0

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
@@ -50,7 +50,8 @@
50
50
  *
51
51
  * - GPG:
52
52
  * • Concatenate keys.
53
- * • Combiner: SHA3-256(kemShare || ecdhShare || ciphertext || pubKey || algId || domSep || len(domSep))
53
+ * • Combiner:
54
+ * SHA3-256(kemShare || ecdhShare || ciphertext || pubKey || algId || domSep || len(domSep))
54
55
  *
55
56
  * - TLS:
56
57
  * • Transcript-based derivation (HKDF).
@@ -92,8 +93,11 @@ import { abytes, ahash, anumber, type CHash, type CHashXOF } from '@noble/hashes
92
93
  import { ml_kem1024, ml_kem768 } from './ml-kem.ts';
93
94
  import {
94
95
  cleanBytes,
96
+ copyBytes,
95
97
  randomBytes,
96
98
  splitCoder,
99
+ validateSigOpts,
100
+ validateVerOpts,
97
101
  type CryptoKeys,
98
102
  type KEM,
99
103
  type Signer,
@@ -108,14 +112,25 @@ function ecKeygen(curve: CurveAll, allowZeroKey: boolean = false) {
108
112
  const lengths = curve.lengths;
109
113
  let keygen = curve.keygen;
110
114
  if (allowZeroKey) {
115
+ // Only the ECDSA/Weierstrass branch uses raw scalar-byte secret keys here. Edwards seeds are
116
+ // hashed/pruned and Montgomery keys are clamped byte strings, so forcing Point.Fn semantics on
117
+ // those curves would change key construction instead of just relaxing scalar range handling.
118
+ if (!('getSharedSecret' in curve && 'sign' in curve && 'verify' in curve))
119
+ throw new Error('allowZeroKey requires a Weierstrass curve');
120
+ // This legacy flag is really "skip the +1 shift" for vector matching, not "accept scalar 0".
121
+ // It swaps seeded Weierstrass keygen from reduction into [1, ORDER) to direct reduction into
122
+ // [0, ORDER), which preserves exact reduced bytes but still leaves scalar 0 invalid.
111
123
  // This is ugly, but we need to return exact results here.
112
- const wCurve = curve as typeof p256;
124
+ const wCurve = curve as ECDSA;
113
125
  const Fn = wCurve.Point.Fn;
114
- if (!Fn) throw new Error('No Point.Fn');
126
+ // Unlike noble-curves' seeded Weierstrass keygen, this path removes the post-reduction +1.
127
+ // That is enough to match exact reduced-vector bytes, but an all-zero seed still reduces to
128
+ // scalar 0 here and getPublicKey(secretKey) throws instead of "allowing zero".
115
129
  keygen = (seed: Uint8Array = randomBytes(lengths.seed)) => {
116
130
  abytes(seed, lengths.seed!, 'seed');
117
131
  const seedScalar = Fn.isLE ? bytesToNumberLE(seed) : bytesToNumberBE(seed);
118
- const secretKey = Fn.toBytes(Fn.create(seedScalar)); // Fixes modulo bias, but not zero
132
+ // Reduce directly into [0, ORDER); scalar 0 still stays invalid.
133
+ const secretKey = Fn.toBytes(Fn.create(seedScalar));
119
134
  return { secretKey, publicKey: curve.getPublicKey(secretKey) };
120
135
  };
121
136
  }
@@ -126,6 +141,29 @@ function ecKeygen(curve: CurveAll, allowZeroKey: boolean = false) {
126
141
  };
127
142
  }
128
143
 
144
+ /**
145
+ * Wraps an ECDH-capable curve as a KEM.
146
+ * Shared secrets stay in the wrapped curve's raw ECDH byte format with no built-in KDF.
147
+ * On SEC 1 / Weierstrass curves, that means the compressed shared-point body without the
148
+ * 1-byte `0x02` / `0x03` prefix.
149
+ * The X25519 path also leaves RFC 7748's optional all-zero shared-secret check to callers.
150
+ * @param curve - Curve with `getSharedSecret`.
151
+ * @param allowZeroKey - Legacy vector-matching toggle for Weierstrass keygen.
152
+ * On Weierstrass curves this removes the usual post-reduction `+1` shift, changing seeded scalar
153
+ * reduction from `[1, ORDER)` to direct reduction into `[0, ORDER)`. It does not make scalar zero
154
+ * valid: an all-zero seed still derives scalar `0` and throws in `curve.getPublicKey(...)`.
155
+ * Only supported on Weierstrass/ECDSA curves.
156
+ * @returns KEM wrapper over the curve.
157
+ * @throws If the curve does not expose `getSharedSecret`. {@link Error}
158
+ * @example
159
+ * Wrap an ECDH-capable curve as a generic KEM.
160
+ * ```ts
161
+ * import { x25519 } from '@noble/curves/ed25519.js';
162
+ * import { ecdhKem } from '@noble/post-quantum/hybrid.js';
163
+ * const kem = ecdhKem(x25519);
164
+ * const publicKeyLen = kem.lengths.publicKey;
165
+ * ```
166
+ */
129
167
  export function ecdhKem(curve: CurveECDH, allowZeroKey: boolean = false): KEM {
130
168
  const kg = ecKeygen(curve, allowZeroKey);
131
169
  if (!curve.getSharedSecret) throw new Error('wrong curve'); // ed25519 doesn't have one!
@@ -133,12 +171,22 @@ export function ecdhKem(curve: CurveECDH, allowZeroKey: boolean = false): KEM {
133
171
  lengths: { ...kg.lengths, msg: kg.lengths.seed, cipherText: kg.lengths.publicKey },
134
172
  keygen: kg.keygen,
135
173
  getPublicKey: kg.getPublicKey,
136
- encapsulate(publicKey: Uint8Array, rand: Uint8Array = randomBytes(curve.lengths.secretKey)) {
137
- const ek = this.keygen(rand).secretKey;
138
- const sharedSecret = this.decapsulate(publicKey, ek);
139
- const cipherText = curve.getPublicKey(ek);
140
- cleanBytes(ek);
141
- return { sharedSecret, cipherText };
174
+ encapsulate(publicKey: Uint8Array, rand: Uint8Array = randomBytes(curve.lengths.seed)) {
175
+ // Some curve.keygen(seed) paths reuse the provided seed buffer as secretKey; detach caller
176
+ // randomness first so cleanBytes() only wipes wrapper-owned material.
177
+ const seed = copyBytes(rand);
178
+ let ek: Uint8Array | undefined = undefined;
179
+ try {
180
+ ek = this.keygen(seed).secretKey;
181
+ const sharedSecret = this.decapsulate(publicKey, ek);
182
+ const cipherText = curve.getPublicKey(ek);
183
+ return { sharedSecret, cipherText };
184
+ } finally {
185
+ // Invalid peer public keys can make decapsulation throw; wipe both the detached seed and
186
+ // derived ephemeral secret key even when encapsulation aborts before returning.
187
+ cleanBytes(seed);
188
+ if (ek) cleanBytes(ek);
189
+ }
142
190
  },
143
191
  decapsulate(cipherText: Uint8Array, secretKey: Uint8Array) {
144
192
  const res = curve.getSharedSecret(secretKey, cipherText);
@@ -147,6 +195,27 @@ export function ecdhKem(curve: CurveECDH, allowZeroKey: boolean = false): KEM {
147
195
  };
148
196
  }
149
197
 
198
+ /**
199
+ * Wraps a curve signer as a generic `Signer`.
200
+ * Signatures stay in the wrapped curve's native byte encoding.
201
+ * This wrapper does not normalize or document which per-curve signing options are meaningful.
202
+ * @param curve - Curve with `sign` and `verify`.
203
+ * @param allowZeroKey - Legacy vector-matching toggle for Weierstrass keygen.
204
+ * On Weierstrass curves this removes the usual post-reduction `+1` shift, changing seeded scalar
205
+ * reduction from `[1, ORDER)` to direct reduction into `[0, ORDER)`. It does not make scalar zero
206
+ * valid: an all-zero seed still derives scalar `0` and throws in `curve.getPublicKey(...)`.
207
+ * Only supported on Weierstrass/ECDSA curves.
208
+ * @returns Signer wrapper over the curve.
209
+ * @throws If the curve does not expose `sign` and `verify`. {@link Error}
210
+ * @example
211
+ * Wrap a curve signer as a generic signer.
212
+ * ```ts
213
+ * import { ed25519 } from '@noble/curves/ed25519.js';
214
+ * import { ecSigner } from '@noble/post-quantum/hybrid.js';
215
+ * const signer = ecSigner(ed25519);
216
+ * const sigLen = signer.lengths.signature;
217
+ * ```
218
+ */
150
219
  export function ecSigner(curve: CurveSign, allowZeroKey: boolean = false): Signer {
151
220
  const kg = ecKeygen(curve, allowZeroKey);
152
221
  if (!curve.sign || !curve.verify) throw new Error('wrong curve'); // ed25519 doesn't have one!
@@ -154,8 +223,29 @@ export function ecSigner(curve: CurveSign, allowZeroKey: boolean = false): Signe
154
223
  lengths: { ...kg.lengths, signature: curve.lengths.signature, signRand: 0 },
155
224
  keygen: kg.keygen,
156
225
  getPublicKey: kg.getPublicKey,
157
- sign: (message, secretKey) => curve.sign(message, secretKey),
158
- verify: (signature, message, publicKey) => curve.verify(signature, message, publicKey),
226
+ sign: (message, secretKey, opts = {}) => {
227
+ validateSigOpts(opts);
228
+ // This generic wrapper intentionally keeps the Signer contract to message + key only.
229
+ // Backend-specific knobs like ECDSA extraEntropy or Ed25519ctx context cannot be forwarded
230
+ // uniformly through combineSigners(), so callers that need them must use the curve directly.
231
+ if (opts.extraEntropy !== undefined)
232
+ throw new Error(
233
+ 'ecSigner does not support extraEntropy; use the underlying curve directly'
234
+ );
235
+ if (opts.context !== undefined)
236
+ throw new Error('ecSigner does not support context; use the underlying curve directly');
237
+ return curve.sign(message, secretKey);
238
+ },
239
+ /** Verify one wrapped curve signature.
240
+ * Returns the wrapped curve's `verify()` result for well-formed inputs. Throws on unsupported
241
+ * generic opts and lets wrapped-curve malformed-input errors escape unchanged.
242
+ */
243
+ verify: (signature, message, publicKey, opts = {}) => {
244
+ validateVerOpts(opts);
245
+ if (opts.context !== undefined)
246
+ throw new Error('ecSigner does not support context; use the underlying curve directly');
247
+ return curve.verify(signature, message, publicKey);
248
+ },
159
249
  };
160
250
  }
161
251
 
@@ -163,6 +253,7 @@ function splitLengths<K extends string, T extends { lengths: Partial<Record<K, n
163
253
  lst: T[],
164
254
  name: K
165
255
  ) {
256
+ // Preserve caller order exactly; raw numeric fields still decode as splitCoder() subarray views.
166
257
  return splitCoder(
167
258
  name,
168
259
  ...lst.map((i) => {
@@ -172,14 +263,32 @@ function splitLengths<K extends string, T extends { lengths: Partial<Record<K, n
172
263
  );
173
264
  }
174
265
 
266
+ /** Seed-expansion callback used by the hybrid combiners. */
175
267
  export type ExpandSeed = (seed: Uint8Array, len: number) => Uint8Array;
176
268
  type XOF = CHashXOF<any, { dkLen: number }>;
177
269
 
178
270
  // It is XOF for most cases, but can be more complex!
271
+ /**
272
+ * Adapts an XOF into an `ExpandSeed` callback.
273
+ * The returned callback interprets its second argument as an output byte length passed as `dkLen`.
274
+ * @param xof - Extendable-output hash function.
275
+ * @returns Seed expander using `dkLen`.
276
+ * @example
277
+ * Adapt an XOF into a seed expander.
278
+ * ```ts
279
+ * import { shake256 } from '@noble/hashes/sha3.js';
280
+ * import { expandSeedXof } from '@noble/post-quantum/hybrid.js';
281
+ * const expandSeed = expandSeedXof(shake256);
282
+ * const seed = expandSeed(new Uint8Array([1]), 4);
283
+ * ```
284
+ */
179
285
  export function expandSeedXof(xof: XOF): ExpandSeed {
286
+ // Forward the caller seed directly: XOFs are expected to treat inputs as read-only, and this
287
+ // adapter only translates the requested byte length into the hash API's `dkLen` option.
180
288
  return (seed: Uint8Array, seedLen: number) => xof(seed, { dkLen: seedLen });
181
289
  }
182
290
 
291
+ /** Combines public keys, ciphertexts, and shared secrets into one shared secret. */
183
292
  export type Combiner = (
184
293
  publicKeys: Uint8Array[],
185
294
  cipherTexts: Uint8Array[],
@@ -198,23 +307,54 @@ function combineKeys(
198
307
  anumber(realSeedLen);
199
308
  function expandDecapsulationKey(seed: Uint8Array) {
200
309
  abytes(seed, realSeedLen!);
201
- const expanded = seedCoder.decode(expandSeed(seed, seedCoder.bytesLen));
202
- const keys = ck.map((i, j) => i.keygen(expanded[j]));
203
- const secretKey = keys.map((i) => i.secretKey);
204
- const publicKey = keys.map((i) => i.publicKey);
205
- return { secretKey, publicKey };
310
+ const expandedRaw = expandSeed(seed, seedCoder.bytesLen);
311
+ // Identity/subarray expanders can hand back caller-owned seed storage. Detach those outputs so
312
+ // later cleanup can wipe the expanded schedule without mutating the caller's root seed bytes.
313
+ const expandedSeed = expandedRaw.buffer === seed.buffer ? copyBytes(expandedRaw) : expandedRaw;
314
+ const expanded: Uint8Array[] = [];
315
+ const keySecret: Uint8Array[] = [];
316
+ const secretKey: Uint8Array[] = [];
317
+ const publicKey: Uint8Array[] = [];
318
+ let ok = false;
319
+ try {
320
+ // seedCoder.decode() returns zero-copy slices into expandedSeed and can throw before child
321
+ // keygen() runs, so keep the raw expanded buffer separate and copy each child seed before any
322
+ // later cleanup wipes the shared backing bytes.
323
+ for (const part of seedCoder.decode(expandedSeed)) expanded.push(copyBytes(part));
324
+ for (let i = 0; i < ck.length; i++) {
325
+ const keys = ck[i].keygen(expanded[i]);
326
+ keySecret.push(keys.secretKey);
327
+ secretKey.push(copyBytes(keys.secretKey));
328
+ publicKey.push(keys.publicKey);
329
+ }
330
+ ok = true;
331
+ return { secretKey, publicKey };
332
+ } finally {
333
+ // Child keygen() can throw after deriving only a prefix of the composite key schedule. Keep
334
+ // the exported copies on success, but wipe all temporary and partially built secret material
335
+ // on either path so failures do not strand derived child seeds in memory.
336
+ cleanBytes(expandedSeed, expanded, keySecret);
337
+ if (!ok) cleanBytes(secretKey);
338
+ }
206
339
  }
207
340
  return {
208
341
  info: { lengths: { seed: realSeedLen, publicKey: pkCoder.bytesLen, secretKey: realSeedLen } },
209
342
  getPublicKey(secretKey: Uint8Array) {
343
+ // Composite secret keys are root seeds, so public-key derivation reruns key expansion from
344
+ // that seed instead of decoding a packed child-secret-key structure.
210
345
  return this.keygen(secretKey).publicKey;
211
346
  },
212
347
  keygen(seed: Uint8Array = randomBytes(realSeedLen)) {
213
348
  const { publicKey: pk, secretKey } = expandDecapsulationKey(seed);
214
- const publicKey = pkCoder.encode(pk);
215
- cleanBytes(pk);
216
- cleanBytes(secretKey);
217
- return { secretKey: seed, publicKey };
349
+ try {
350
+ const publicKey = pkCoder.encode(pk);
351
+ return { secretKey: seed, publicKey };
352
+ } finally {
353
+ cleanBytes(pk);
354
+ // The exported secretKey is the caller/root seed itself; child secret keys are internal
355
+ // expansion outputs that are cleaned whether encoding succeeds or throws.
356
+ cleanBytes(secretKey);
357
+ }
218
358
  },
219
359
  expandDecapsulationKey,
220
360
  realSeedLen,
@@ -222,6 +362,31 @@ function combineKeys(
222
362
  }
223
363
 
224
364
  // This generic function that combines multiple KEMs into single one
365
+ /**
366
+ * Combines multiple KEMs into one composite KEM.
367
+ * @param realSeedLen - Input seed length expected by `expandSeed`.
368
+ * @param realMsgLen - Shared-secret length returned by `combiner`.
369
+ * @param expandSeed - Seed expander used to derive per-KEM seeds.
370
+ * @param combiner - Combines the per-KEM outputs into one shared secret.
371
+ * @param kems - KEM implementations to combine.
372
+ * @returns Composite KEM.
373
+ * @example
374
+ * Combine multiple KEMs into one composite KEM.
375
+ * ```ts
376
+ * import { shake256 } from '@noble/hashes/sha3.js';
377
+ * import { combineKEMS, expandSeedXof } from '@noble/post-quantum/hybrid.js';
378
+ * import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
379
+ * const hybrid = combineKEMS(
380
+ * 32,
381
+ * 32,
382
+ * expandSeedXof(shake256),
383
+ * (_pk, _ct, sharedSecrets) => sharedSecrets[0],
384
+ * ml_kem768,
385
+ * ml_kem768
386
+ * );
387
+ * const { publicKey } = hybrid.keygen();
388
+ * ```
389
+ */
225
390
  export function combineKEMS(
226
391
  realSeedLen: number | undefined, // how much bytes expandSeed expects
227
392
  realMsgLen: number | undefined, // how much bytes combiner returns
@@ -247,26 +412,60 @@ export function combineKEMS(
247
412
  encapsulate(pk: Uint8Array, randomness: Uint8Array = randomBytes(msgCoder.bytesLen)) {
248
413
  const pks = pkCoder.decode(pk);
249
414
  const rand = msgCoder.decode(randomness);
250
- const enc = kems.map((i, j) => i.encapsulate(pks[j], rand[j]));
251
- const sharedSecret = enc.map((i) => i.sharedSecret);
252
- const cipherText = enc.map((i) => i.cipherText);
253
- const res = {
254
- sharedSecret: combiner(pks, cipherText, sharedSecret),
255
- cipherText: ctCoder.encode(cipherText),
256
- };
257
- cleanBytes(sharedSecret, cipherText);
258
- return res;
415
+ const sharedSecret: Uint8Array[] = [];
416
+ const cipherText: Uint8Array[] = [];
417
+ try {
418
+ for (let i = 0; i < kems.length; i++) {
419
+ const enc = kems[i].encapsulate(pks[i], rand[i]);
420
+ sharedSecret.push(enc.sharedSecret);
421
+ cipherText.push(enc.cipherText);
422
+ }
423
+ return {
424
+ // Detach the combiner result before cleanup: a caller-provided combiner may alias one of
425
+ // the child sharedSecret buffers, and those child buffers are zeroized immediately below.
426
+ sharedSecret: copyBytes(combiner(pks, cipherText, sharedSecret)),
427
+ cipherText: ctCoder.encode(cipherText),
428
+ };
429
+ } finally {
430
+ // Child encapsulation or combiner failures can happen after some components already
431
+ // returned secret material; zeroize whatever was produced before propagating the error.
432
+ cleanBytes(sharedSecret, cipherText);
433
+ }
259
434
  },
260
435
  decapsulate(ct: Uint8Array, seed: Uint8Array) {
261
436
  const cts = ctCoder.decode(ct);
262
437
  const { publicKey, secretKey } = keys.expandDecapsulationKey(seed);
263
438
  const sharedSecret = kems.map((i, j) => i.decapsulate(cts[j], secretKey[j]));
264
- return combiner(publicKey, cts, sharedSecret);
439
+ try {
440
+ // Detach the decapsulation result before cleanup: the combiner may hand back one of the
441
+ // child shared-secret buffers, and those temporary buffers are zeroized below.
442
+ return copyBytes(combiner(publicKey, cts, sharedSecret));
443
+ } finally {
444
+ // Decapsulation only needs the expanded child secret keys and child shared secrets for this
445
+ // call; keep the caller/root seed intact, but wipe all derived material even on errors.
446
+ cleanBytes(secretKey, sharedSecret);
447
+ }
265
448
  },
266
449
  };
267
450
  }
268
451
  // There is no specs for this, but can be useful
269
452
  // realSeedLen: how much bytes expandSeed expects.
453
+ /**
454
+ * Combines multiple signers into one composite signer.
455
+ * @param realSeedLen - Input seed length expected by `expandSeed`.
456
+ * @param expandSeed - Seed expander used to derive per-signer seeds.
457
+ * @param signers - Signers to combine.
458
+ * @returns Composite signer.
459
+ * @example
460
+ * Combine multiple signers into one composite signer.
461
+ * ```ts
462
+ * import { shake256 } from '@noble/hashes/sha3.js';
463
+ * import { combineSigners, expandSeedXof } from '@noble/post-quantum/hybrid.js';
464
+ * import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js';
465
+ * const hybrid = combineSigners(32, expandSeedXof(shake256), ml_dsa44, ml_dsa44);
466
+ * const { publicKey } = hybrid.keygen();
467
+ * ```
468
+ */
270
469
  export function combineSigners(
271
470
  realSeedLen: number | undefined,
272
471
  expandSeed: ExpandSeed,
@@ -279,14 +478,39 @@ export function combineSigners(
279
478
  lengths: { ...keys.info.lengths, signature: sigCoder.bytesLen, signRand: 0 },
280
479
  getPublicKey: keys.getPublicKey,
281
480
  keygen: keys.keygen,
282
- sign(message, seed) {
481
+ sign(message, seed, opts = {}) {
482
+ validateSigOpts(opts);
483
+ // This generic wrapper intentionally keeps the composite signer contract to message + root
484
+ // seed only. Per-signer opts like context or extraEntropy cannot be preserved uniformly
485
+ // across mixed backends, so callers that need them must use the underlying signer directly.
486
+ if (opts.extraEntropy !== undefined)
487
+ throw new Error(
488
+ 'combineSigners does not support extraEntropy; use the underlying signer directly'
489
+ );
490
+ if (opts.context !== undefined)
491
+ throw new Error(
492
+ 'combineSigners does not support context; use the underlying signer directly'
493
+ );
283
494
  const { secretKey } = keys.expandDecapsulationKey(seed);
284
- // NOTE: we probably can make different hashes for different algorithms
285
- // same way as we do for kem, but not sure if this a good idea.
286
- const sigs = signers.map((i, j) => i.sign(message, secretKey[j]));
287
- return sigCoder.encode(sigs);
495
+ try {
496
+ const sigs = signers.map((i, j) => i.sign(message, secretKey[j]));
497
+ return sigCoder.encode(sigs);
498
+ } finally {
499
+ // Composite secret keys are root seeds; the per-signer child secret keys are temporary
500
+ // expansion outputs and must not stay live after the combined signature is produced.
501
+ cleanBytes(secretKey);
502
+ }
288
503
  },
289
- verify: (signature, message, publicKey) => {
504
+ /** Verify one combined signature.
505
+ * Returns `false` when the aggregate signature/publicKey decode succeeds but any child verify
506
+ * check fails. Throws on unsupported generic opts or malformed aggregate encodings.
507
+ */
508
+ verify: (signature, message, publicKey, opts = {}) => {
509
+ validateVerOpts(opts);
510
+ if (opts.context !== undefined)
511
+ throw new Error(
512
+ 'combineSigners does not support context; use the underlying signer directly'
513
+ );
290
514
  const pks = pkCoder.decode(publicKey);
291
515
  const sigs = sigCoder.decode(signature);
292
516
  for (let i = 0; i < signers.length; i++) {
@@ -297,12 +521,36 @@ export function combineSigners(
297
521
  };
298
522
  }
299
523
 
524
+ /**
525
+ * Builds a QSF hybrid KEM preset from a PQ KEM and an elliptic-curve KEM.
526
+ * The combined shared-secret length follows `kdf.outputLen`; the built-in presets use 32-byte
527
+ * SHA3-256 output, while custom `kdf` choices inherit their own digest size.
528
+ * Its combiner hashes `ss0 || ss1 || ct1 || pk1 || label`, not the full
529
+ * `(c1, c2, ek1, ek2)` example input shape from SP 800-227 equation (15).
530
+ * Labels are encoded with `asciiToBytes()`, so non-ASCII labels are rejected.
531
+ * @param label - Domain-separation label.
532
+ * @param pqc - Post-quantum KEM.
533
+ * @param curveKEM - Classical curve KEM.
534
+ * @param xof - XOF used for seed expansion.
535
+ * @param kdf - Hash used for the final combiner.
536
+ * @returns Hybrid KEM.
537
+ * @example
538
+ * Build a QSF hybrid KEM preset from a PQ KEM and an elliptic-curve KEM.
539
+ * ```ts
540
+ * import { p256 } from '@noble/curves/nist.js';
541
+ * import { sha3_256, shake256 } from '@noble/hashes/sha3.js';
542
+ * import { QSF, ecdhKem } from '@noble/post-quantum/hybrid.js';
543
+ * import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
544
+ * const kem = QSF('example', ml_kem768, ecdhKem(p256, true), shake256, sha3_256);
545
+ * const publicKeyLen = kem.lengths.publicKey;
546
+ * ```
547
+ */
300
548
  export function QSF(label: string, pqc: KEM, curveKEM: KEM, xof: XOF, kdf: CHash): KEM {
301
549
  ahash(xof);
302
550
  ahash(kdf);
303
551
  return combineKEMS(
304
552
  32,
305
- 32,
553
+ kdf.outputLen,
306
554
  expandSeedXof(xof),
307
555
  (pk, ct, ss) => kdf(concatBytes(ss[0], ss[1], ct[1], pk[1], asciiToBytes(label))),
308
556
  pqc,
@@ -310,23 +558,58 @@ export function QSF(label: string, pqc: KEM, curveKEM: KEM, xof: XOF, kdf: CHash
310
558
  );
311
559
  }
312
560
 
313
- export const QSFMLKEM768P256: KEM = QSF(
314
- 'QSF-KEM(ML-KEM-768,P-256)-XOF(SHAKE256)-KDF(SHA3-256)',
315
- ml_kem768,
316
- ecdhKem(p256, true),
317
- shake256,
318
- sha3_256
319
- );
320
-
321
- export const QSFMLKEM1024P384: KEM = QSF(
322
- 'QSF-KEM(ML-KEM-1024,P-384)-XOF(SHAKE256)-KDF(SHA3-256)',
323
- ml_kem1024,
324
- ecdhKem(p384, true),
325
- shake256,
326
- sha3_256
327
- );
561
+ /** QSF preset combining ML-KEM-768 with P-256. */
562
+ export const QSF_ml_kem768_p256: KEM = /* @__PURE__ */ (() =>
563
+ QSF(
564
+ 'QSF-KEM(ML-KEM-768,P-256)-XOF(SHAKE256)-KDF(SHA3-256)',
565
+ ml_kem768,
566
+ ecdhKem(p256, true),
567
+ shake256,
568
+ sha3_256
569
+ ))();
570
+ /** QSF preset combining ML-KEM-1024 with P-384. */
571
+ export const QSF_ml_kem1024_p384: KEM = /* @__PURE__ */ (() =>
572
+ QSF(
573
+ 'QSF-KEM(ML-KEM-1024,P-384)-XOF(SHAKE256)-KDF(SHA3-256)',
574
+ ml_kem1024,
575
+ ecdhKem(p384, true),
576
+ shake256,
577
+ sha3_256
578
+ ))();
328
579
 
329
- export function KitchenSink(label: string, pqc: KEM, curveKEM: KEM, xof: XOF, hash: CHash): KEM {
580
+ /**
581
+ * Builds the "KitchenSink" hybrid KEM combiner.
582
+ * The current builder always derives a fixed 32-byte output,
583
+ * regardless of the hash's native output size.
584
+ * Its HKDF extract step uses implicit zero salt with IKM
585
+ * `hybrid_prk || ss0 || ss1 || ct0 || pk0 || ct1 || pk1 || label`.
586
+ * Its HKDF expand step fixes `info` to `len || 'shared_secret' || ''`.
587
+ * Labels are encoded with `asciiToBytes()`, so non-ASCII labels are rejected.
588
+ * @param label - Domain-separation label.
589
+ * @param pqc - Post-quantum KEM.
590
+ * @param curveKEM - Classical curve KEM.
591
+ * @param xof - XOF used for seed expansion.
592
+ * @param hash - Hash used for HKDF extraction and expansion.
593
+ * @returns Hybrid KEM.
594
+ * @example
595
+ * Build the "KitchenSink" hybrid KEM combiner.
596
+ * ```ts
597
+ * import { sha256 } from '@noble/hashes/sha2.js';
598
+ * import { shake256 } from '@noble/hashes/sha3.js';
599
+ * import { createKitchenSink, ecdhKem } from '@noble/post-quantum/hybrid.js';
600
+ * import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
601
+ * import { x25519 } from '@noble/curves/ed25519.js';
602
+ * const kem = createKitchenSink('example', ml_kem768, ecdhKem(x25519), shake256, sha256);
603
+ * const publicKeyLen = kem.lengths.publicKey;
604
+ * ```
605
+ */
606
+ export function createKitchenSink(
607
+ label: string,
608
+ pqc: KEM,
609
+ curveKEM: KEM,
610
+ xof: XOF,
611
+ hash: CHash
612
+ ): KEM {
330
613
  ahash(xof);
331
614
  ahash(hash);
332
615
  return combineKEMS(
@@ -352,17 +635,27 @@ export function KitchenSink(label: string, pqc: KEM, curveKEM: KEM, xof: XOF, ha
352
635
  );
353
636
  }
354
637
 
355
- const x25519kem = ecdhKem(x25519);
356
- export const KitchenSinkMLKEM768X25519: KEM = KitchenSink(
357
- 'KitchenSink-KEM(ML-KEM-768,X25519)-XOF(SHAKE256)-KDF(HKDF-SHA-256)',
358
- ml_kem768,
359
- x25519kem,
360
- shake256,
361
- sha256
362
- );
638
+ // Internal alias only: this stays exactly `ecdhKem(x25519)`
639
+ // and inherits that wrapper's mutation/oracle behavior.
640
+ const x25519kem = /* @__PURE__ */ ecdhKem(x25519);
641
+ /** KitchenSink preset combining ML-KEM-768 with X25519.
642
+ * Caller randomness splits into 32 ML-KEM coins plus a 32-byte X25519 ephemeral-secret seed.
643
+ */
644
+ export const KitchenSink_ml_kem768_x25519: KEM = /* @__PURE__ */ (() =>
645
+ createKitchenSink(
646
+ 'KitchenSink-KEM(ML-KEM-768,X25519)-XOF(SHAKE256)-KDF(HKDF-SHA-256)',
647
+ ml_kem768,
648
+ x25519kem,
649
+ shake256,
650
+ sha256
651
+ ))();
363
652
 
364
653
  // Always X25519 and ML-KEM - 768, no point to export
365
- export const XWing: KEM = /* @__PURE__ */ (() =>
654
+ /** X25519 + ML-KEM-768 hybrid preset.
655
+ * Uses the hard-coded domain-separation label `\\.//^\\` and hashes only `ct1 || pk1`
656
+ * from the X25519 side in addition to the two component shared secrets.
657
+ */
658
+ export const ml_kem768_x25519: KEM = /* @__PURE__ */ (() =>
366
659
  combineKEMS(
367
660
  32,
368
661
  32,
@@ -373,11 +666,19 @@ export const XWing: KEM = /* @__PURE__ */ (() =>
373
666
  x25519kem
374
667
  ))();
375
668
 
376
- export const MLKEM768X25519: KEM = XWing;
377
-
669
+ /**
670
+ * Internal SEC 1-style KEM wrapper for NIST curves.
671
+ * `nseed` is only the rejection-sampling byte budget for deriving one nonzero scalar:
672
+ * current presets use `128` bytes for P-256 and `48` bytes for P-384.
673
+ * `decapsulate()` returns the uncompressed shared point body `x || y` without the `0x04`
674
+ * prefix, not the SEC 1 `x_P`-only primitive output, because current hybrid combiners hash
675
+ * both coordinates.
676
+ */
378
677
  function nistCurveKem(curve: ECDSA, scalarLen: number, elemLen: number, nseed: number): KEM {
379
678
  const Fn = curve.Point.Fn;
380
679
  if (!Fn) throw new Error('no Point.Fn');
680
+ // Scan scalar-sized windows until one decodes to a nonzero scalar in `[1, n-1]`; if every
681
+ // window is zero or out of range, fail instead of silently reducing modulo `n`.
381
682
  function rejectionSampling(seed: Uint8Array): { secretKey: Uint8Array; publicKey: Uint8Array } {
382
683
  let sk: bigint;
383
684
  for (let start = 0, end = scalarLen; ; start = end, end += scalarLen) {
@@ -407,11 +708,17 @@ function nistCurveKem(curve: ECDSA, scalarLen: number, elemLen: number, nseed: n
407
708
  },
408
709
  encapsulate(publicKey: Uint8Array, rand: Uint8Array = randomBytes(nseed)) {
409
710
  abytes(rand, nseed, 'rand');
410
- const { secretKey: ek } = rejectionSampling(rand);
411
- const sharedSecret = this.decapsulate(publicKey, ek);
412
- const cipherText = curve.getPublicKey(ek, false);
413
- cleanBytes(ek);
414
- return { sharedSecret, cipherText };
711
+ let ek: Uint8Array | undefined = undefined;
712
+ try {
713
+ ek = rejectionSampling(rand).secretKey;
714
+ const sharedSecret = this.decapsulate(publicKey, ek);
715
+ const cipherText = curve.getPublicKey(ek, false);
716
+ return { sharedSecret, cipherText };
717
+ } finally {
718
+ // Rejection-sampled NIST-curve ephemeral secret keys are temporary encapsulation state and
719
+ // must be wiped even if peer-key validation or shared-secret derivation throws.
720
+ if (ek) cleanBytes(ek);
721
+ }
415
722
  },
416
723
  decapsulate(cipherText: Uint8Array, secretKey: Uint8Array) {
417
724
  const full = curve.getSharedSecret(secretKey, cipherText);
@@ -420,6 +727,14 @@ function nistCurveKem(curve: ECDSA, scalarLen: number, elemLen: number, nseed: n
420
727
  };
421
728
  }
422
729
 
730
+ /**
731
+ * Internal ML-KEM + NIST-curve combiner.
732
+ * `nseed` controls only the curve-side rejection-sampling budget; it is expanded from the
733
+ * 32-byte root seed and is not itself part of the exported secret-key length.
734
+ * The domain-separation `label` is used only in the final `sha3_256` combiner, not in
735
+ * `shake256(seed, { dkLen: 64 + nseed })`,
736
+ * and the combiner hashes `ss0 || ss1 || ct1 || pk1 || label`.
737
+ */
423
738
  function concreteHybridKem(label: string, mlkem: KEM, curve: ECDSA, nseed: number): KEM {
424
739
  const { secretKey: scalarLen, publicKeyUncompressed: elemLen } = curve.lengths;
425
740
  if (!scalarLen || !elemLen) throw new Error('wrong curve');
@@ -443,8 +758,27 @@ function concreteHybridKem(label: string, mlkem: KEM, curve: ECDSA, nseed: numbe
443
758
  );
444
759
  }
445
760
 
446
- export const MLKEM768P256: KEM = /* @__PURE__ */ (() =>
761
+ /** P-256 + ML-KEM-768 hybrid preset. */
762
+ export const ml_kem768_p256: KEM = /* @__PURE__ */ (() =>
447
763
  concreteHybridKem('MLKEM768-P256', ml_kem768, p256, 128))();
448
764
 
449
- export const MLKEM1024P384: KEM = /* @__PURE__ */ (() =>
765
+ /** P-384 + ML-KEM-1024 hybrid preset. */
766
+ export const ml_kem1024_p384: KEM = /* @__PURE__ */ (() =>
450
767
  concreteHybridKem('MLKEM1024-P384', ml_kem1024, p384, 48))();
768
+
769
+ // Legacy aliases
770
+ /** Legacy alias for `ml_kem768_x25519`. */
771
+ export const XWing: KEM = /* @__PURE__ */ (() => ml_kem768_x25519)();
772
+ /** Legacy alias for `ml_kem768_x25519`. */
773
+ export const MLKEM768X25519: KEM = /* @__PURE__ */ (() => ml_kem768_x25519)();
774
+ /** Legacy alias for `ml_kem768_p256`. */
775
+ export const MLKEM768P256: KEM = /* @__PURE__ */ (() => ml_kem768_p256)();
776
+ /** Legacy alias for `ml_kem1024_p384`. */
777
+ export const MLKEM1024P384: KEM = /* @__PURE__ */ (() => ml_kem1024_p384)();
778
+ /** Legacy alias for `QSF_ml_kem768_p256`. */
779
+ export const QSFMLKEM768P256: KEM = /* @__PURE__ */ (() => QSF_ml_kem768_p256)();
780
+ /** Legacy alias for `QSF_ml_kem1024_p384`. */
781
+ export const QSFMLKEM1024P384: KEM = /* @__PURE__ */ (() => QSF_ml_kem1024_p384)();
782
+ /** Legacy alias for `KitchenSink_ml_kem768_x25519`. */
783
+ export const KitchenSinkMLKEM768X25519: KEM = /* @__PURE__ */ (() =>
784
+ KitchenSink_ml_kem768_x25519)();