@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/README.md CHANGED
@@ -79,7 +79,7 @@ import {
79
79
  - [ML-DSA / Dilithium](#ml-dsa--dilithium-signatures)
80
80
  - [SLH-DSA / SPHINCS+](#slh-dsa--sphincs-signatures)
81
81
  - [Falcon](#falcon-signatures)
82
- - [hybrid: XWing, KitchenSink and others](#hybrid-xwing-kitchensink-and-others)
82
+ - [hybrid: X-Wing, KitchenSink and others](#hybrid-x-wing-kitchensink-and-others)
83
83
  - [What should I use?](#what-should-i-use)
84
84
  - [Security](#security)
85
85
  - [Contributing & testing](#contributing--testing)
@@ -123,6 +123,25 @@ Old, incompatible version (Kyber) is not provided. Open an issue if you need it.
123
123
  > `decapsulate` will simply return a different shared secret.
124
124
  > ML-KEM is also probabilistic and relies on quality of CSPRNG.
125
125
 
126
+ #### webcrypto: friendly wrapper
127
+
128
+ WebCrypto-backed ML-KEM and `ml_kem768_x25519` wrappers are also available. Their methods are async
129
+ and require a runtime that implements the corresponding experimental WebCrypto API.
130
+
131
+ ```ts
132
+ import { ml_kem768 } from '@noble/post-quantum/webcrypto.js';
133
+
134
+ if (await ml_kem768.isSupported()) {
135
+ const aliceKeys = await ml_kem768.keygen();
136
+ const { cipherText, sharedSecret: bobShared } = await ml_kem768.encapsulate(aliceKeys.publicKey);
137
+ const aliceShared = await ml_kem768.decapsulate(cipherText, aliceKeys.secretKey);
138
+ }
139
+ ```
140
+
141
+ The ML-KEM wrappers serialize private keys as 64-byte `raw-seed` values; the X25519 hybrid uses a
142
+ 32-byte seed. They can be passed to the corresponding synchronous implementation's `keygen(seed)`,
143
+ but are not expanded decapsulation keys.
144
+
126
145
  ### ML-DSA / Dilithium signatures
127
146
 
128
147
  ```ts
@@ -157,9 +176,17 @@ const isValidPre = hml.verify(sigPre, msg, keys.publicKey);
157
176
  - `context`: domain-separation byte string, up to 255 bytes; must match between `sign` and `verify`
158
177
  - `extraEntropy`: hedged-signing randomness. Default is 32 random bytes;
159
178
  `false` produces deterministic signatures; custom 32-byte value is also allowed
160
- - `externalMu`: treat `msg` as the precomputed 64-byte message representative µ
161
179
  - `prehash(hash)`: pre-hash variant (HashML-DSA) from FIPS-204
162
180
 
181
+ Unknown option keys are rejected rather than ignored, so a misspelling such as
182
+ `{ ctx }` fails loudly instead of silently signing with no domain separation.
183
+
184
+ `externalMu`, which treats `msg` as the precomputed 64-byte message representative
185
+ µ, is available on `ml_dsa*.internal.sign` / `internal.verify` only. The public
186
+ wrappers reject it: `sign` formats `M'` before the 64-byte check so it could never
187
+ accept a µ, and `verify` did not forward it, returning `false` for a valid
188
+ external-mu signature.
189
+
163
190
  ### SLH-DSA / SPHINCS+ signatures
164
191
 
165
192
  ```ts
@@ -218,7 +245,27 @@ Lattice-based digital signature algorithm, submitted to NIST PQC Round 3 ([websi
218
245
  - `falcon512padded`, `falcon1024padded`: fixed-length detached signatures
219
246
  - `attached.seal(...)` / `attached.open(...)`: attached-signature API for Round 3 vectors and interop
220
247
 
221
- ### hybrid: XWing, KitchenSink and others
248
+ > [!WARNING]
249
+ > Falcon signing is randomized by design. Leave signing options unset in production so every
250
+ > signature receives a fresh 40-byte public nonce and a fresh 48-byte sampler seed from the system
251
+ > CSPRNG. Falcon's `extraEntropy` option does not have the hedged semantics used by ML-DSA and
252
+ > SLH-DSA:
253
+ >
254
+ > - `extraEntropy: false` seeds an AES-CTR-DRBG with 48 zero bytes. It makes signatures
255
+ > deterministic for a fixed key and message, and reuses the same nonce and initial random stream
256
+ > across different messages. This is outside the Falcon Round 3 randomized-hash design.
257
+ > - A 48-byte `extraEntropy` value replaces system randomness; it is not mixed with fresh entropy.
258
+ > Reusing a value therefore reuses the signing stream.
259
+ > - The raw `random` callback overrides `extraEntropy` and supplies both the nonce and sampler seed.
260
+ > It exists for test-vector reproduction and should not be used as a production randomness hook.
261
+ >
262
+ > In particular, do not copy ML-DSA examples that use `extraEntropy: false` into Falcon code.
263
+
264
+ `attached.open(...)` throws when verification fails and returns a fresh copy of the embedded
265
+ message when it succeeds. The result does not alias the attached signature or public-key buffers.
266
+ Detached `verify(...)` returns `false` for an invalid signature.
267
+
268
+ ### hybrid: X-Wing, KitchenSink and others
222
269
 
223
270
  ```js
224
271
  import {
@@ -230,19 +277,32 @@ import {
230
277
 
231
278
  The hybrid submodule combines post-quantum algorithms with elliptic curve cryptography:
232
279
 
233
- - `ml_kem768_x25519`: ML-KEM-768 + X25519 (CG Framework, same as XWing)
234
- - `ml_kem768_p256`: ML-KEM-768 + P-256 (CG Framework)
235
- - `ml_kem1024_p384`: ML-KEM-1024 + P-384 (CG Framework)
280
+ - `ml_kem768_x25519`: ML-KEM-768 + X25519, implementing X-Wing under the descriptive
281
+ `ml_kem768_x25519` export name. There is no separate `XWing` alias.
282
+ - `ml_kem768_p256`: ML-KEM-768 + P-256 using the current CG framework construction
283
+ - `ml_kem1024_p384`: ML-KEM-1024 + P-384 using the current CG framework construction
236
284
  - `KitchenSink_ml_kem768_x25519`: ML-KEM-768 + X25519 with HKDF-SHA256 combiner
237
- - `QSF_ml_kem768_p256`: ML-KEM-768 + P-256 (QSF construction)
238
- - `QSF_ml_kem1024_p384`: ML-KEM-1024 + P-384 (QSF construction)
285
+ - `QSF_ml_kem768_p256`, `QSF_ml_kem1024_p384`: legacy compatibility presets for the older
286
+ QSF/C2PRI naming and labels. New code should use `ml_kem768_p256` and `ml_kem1024_p384`.
287
+
288
+ > **Security note:** `_ecdhKem(curve)` is an internal raw-ECDH component adapter, not a standalone
289
+ > IND-CCA-secure KEM. It has no KDF and does not bind the encapsulation or recipient public key, so
290
+ > different accepted point encodings can derive the same bytes. Use it only within a specified
291
+ > combiner that performs that binding, or use a standardized DHKEM. The built-in hybrid presets
292
+ > retain their specified combiners and test-vector-compatible behavior.
293
+
294
+ The current `ml_kem*` presets are tested against these work-in-progress specifications:
239
295
 
240
- The following spec drafts are matched:
296
+ - [irtf-cfrg-hybrid-kems-12](https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-hybrid-kems-12)
297
+ - [irtf-cfrg-concrete-hybrid-kems-03](https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-concrete-hybrid-kems-03)
298
+ - [connolly-cfrg-xwing-kem-10](https://datatracker.ietf.org/doc/html/draft-connolly-cfrg-xwing-kem-10)
241
299
 
242
- - [irtf-cfrg-hybrid-kems-07](https://datatracker.ietf.org/doc/draft-irtf-cfrg-hybrid-kems/)
243
- - [irtf-cfrg-concrete-hybrid-kems-02](https://datatracker.ietf.org/doc/draft-irtf-cfrg-concrete-hybrid-kems/)
244
- - [connolly-cfrg-xwing-kem-09](https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/)
245
- - [tls-westerbaan-xyber768d00-03](https://datatracker.ietf.org/doc/draft-tls-westerbaan-xyber768d00/)
300
+ `QSF(...)` is the legacy API name for the construction now called the C2PRI combiner. It derives
301
+ the final secret from `ssPQ || ssT || ctT || ekT || label`; omitting the PQ ciphertext and
302
+ encapsulation key is intentional and relies on the PQ KEM's C2PRI property. The `QSF_*` presets
303
+ retain older draft labels and vectors for compatibility, so they do not implement the current
304
+ concrete preset encodings. They are also unrelated to the separate universal-combiner example in
305
+ NIST SP 800-227.
246
306
 
247
307
  ### What should I use?
248
308
 
@@ -282,9 +342,23 @@ If you see anything unusual: investigate and report.
282
342
 
283
343
  ### Constant-timeness
284
344
 
285
- There is no protection against side-channel attacks.
286
- We actively research how to provide this property for post-quantum algorithms in JS.
287
- Keep in mind that even hardware versions ML-KEM [are vulnerable](https://eprint.iacr.org/2023/1084).
345
+ This pure JavaScript implementation does not claim constant-time execution. JavaScript engines,
346
+ JIT compilers, garbage collection, floating-point operations and `bigint` arithmetic do not offer
347
+ the execution guarantees needed for a formal constant-time claim.
348
+
349
+ - ML-DSA signing uses rejection loops, early-exit norm checks and conditional arithmetic whose
350
+ execution depends on secret-key and per-signature state. Fresh randomized signing is the default,
351
+ but it does not turn the implementation into a constant-time one.
352
+ - Falcon signing uses data-dependent Gaussian and rejection sampling, floating-point operations,
353
+ and `bigint` paths. Its timing and microarchitectural side-channel posture is materially weaker
354
+ than a hardened native implementation. Deterministic or repeated signing randomness can make
355
+ observations easier to correlate and should be avoided.
356
+ - These limitations matter most when an attacker can measure signing closely, such as hostile
357
+ co-tenancy, shared hardware, or a high-resolution local timing oracle. Use an isolated execution
358
+ environment or a reviewed native/constant-time backend when that is part of the threat model.
359
+
360
+ We actively research how to improve this property for post-quantum algorithms in JS. Even hardware
361
+ ML-KEM implementations require careful side-channel engineering and [have had practical attacks](https://eprint.iacr.org/2023/1084).
288
362
 
289
363
  ### Supply chain security
290
364
 
package/_crystals.js CHANGED
@@ -31,7 +31,7 @@ export const genCrystals = (opts) => {
31
31
  // Normalize JS `%` into the canonical Z_m representative `[0, modulo-1]` expected by
32
32
  // FIPS 203 §2.3 / FIPS 204 §2.3 before downstream mod-q arithmetic.
33
33
  const mod = (a, modulo = Q) => {
34
- const result = a % modulo | 0;
34
+ const result = (a % modulo) | 0;
35
35
  return (result >= 0 ? result | 0 : (modulo + result) | 0) | 0;
36
36
  };
37
37
  // FIPS 204 §7.4 uses the centered `mod ±` representative for low bits, keeping the
package/falcon.d.ts CHANGED
@@ -22,7 +22,7 @@ export type FalconAttached = CryptoKeys & {
22
22
  * @param sig Attached Falcon signature bytes.
23
23
  * @param publicKey Falcon public key bytes.
24
24
  * @param opts Optional verification options.
25
- * @returns Embedded message bytes when the signature is valid.
25
+ * @returns Fresh message bytes that do not alias either input when the signature is valid.
26
26
  */
27
27
  open(sig: Uint8Array, publicKey: Uint8Array, opts?: VerOpts): Uint8Array;
28
28
  };
package/falcon.js CHANGED
@@ -12,7 +12,7 @@ import { bytesToNumberLE, numberToHexUnpadded } from '@noble/curves/utils.js';
12
12
  import { shake256 } from '@noble/hashes/sha3.js';
13
13
  import { abytes, bytesToHex, createView, hexToBytes, randomBytes, swap32IfBE, u32, u8, } from '@noble/hashes/utils.js';
14
14
  import { genCrystals } from "./_crystals.js";
15
- import { baswap64If, cleanBytes, getMask, splitCoder, validateSigOpts, validateVerOpts, } from "./utils.js";
15
+ import { baswap64If, cleanBytes, copyBytes, getMask, splitCoder, validateSigOpts, validateVerOpts, SIG_OPT_KEYS, } from "./utils.js";
16
16
  /*
17
17
  FIPS-206 would likely improve the situation with spec.
18
18
 
@@ -230,8 +230,13 @@ const compCoder = (n) => {
230
230
  const sign = readBits(1);
231
231
  const low = readBits(7);
232
232
  let high = 0;
233
- for (; !readBits(1); high++)
234
- ;
233
+ // Reference comp_decode adds 128 for each unary zero and rejects immediately above 2047.
234
+ // Waiting for the terminating one first lets an invalid coefficient scan the entire input.
235
+ while (!readBits(1)) {
236
+ high++;
237
+ if (high > LIMIT >>> 7)
238
+ throw new Error(`limit: ${low | (high << 7)} > ${LIMIT}`);
239
+ }
235
240
  const v = low | (high << 7);
236
241
  if (sign && v === 0)
237
242
  throw new Error('negative zero encoding');
@@ -510,6 +515,9 @@ const SIGMA_MIN = /* @__PURE__ */ Object.freeze([
510
515
  f64b(BigInt('4608433670533905013')),
511
516
  f64b(BigInt('4608525754002622308')),
512
517
  ]);
518
+ // Upper end of the SamplerZ proof interval from Falcon section 3.9.1. The per-leaf sigma is
519
+ // derived from the reconstructed private basis, so imported keys must be checked against it.
520
+ const SIGMA_MAX = 1.8205;
513
521
  // Falcon Table 3.1 RCDT values for chi, split into 24-bit limbs; storage is [high, mid, low],
514
522
  // so gaussian0() intentionally compares them against v0, v1, v2 in reverse order. The final
515
523
  // RCDT[18] = 0 row is omitted because the algorithm iterates only over i = 0..17.
@@ -1111,6 +1119,51 @@ function getFloatPoly(logn) {
1111
1119
  },
1112
1120
  };
1113
1121
  }
1122
+ function ldlFFT(logn, g00t, g01t, g11t) {
1123
+ // Algorithm 8: LDL*(G)
1124
+ // (Page 37)
1125
+ // Require: A full-rank self-adjoint matrix G = (Gᵢⱼ) ∈ FFT(Q[x]/(φ))²ˣ²
1126
+ // Ensure: The LDL* decomposition G = LDL* over FFT(Q[x]/(φ))
1127
+ // Format: All polynomials are in FFT representation.
1128
+ // 1: D₀₀ ← G₀₀
1129
+ // 2: L₁₀ ← G₁₀/G₀₀
1130
+ // 3: D₁₁ ← G₁₁ - L₁₀ ⊙ L₁₀* ⊙ G₀₀
1131
+ // 4: L ← [ 1 0 ; L₁₀ 1 ], D ← [ D₀₀ 0 ; 0 D₁₁ ]
1132
+ // 5: return (L, D)
1133
+ // Algorithm 9: ffLDL*(G)
1134
+ // (Page 37)
1135
+ // Require: A full-rank Gram matrix G ∈ FFT(Q[x]/(xⁿ + 1))²ˣ²
1136
+ // Ensure: A binary tree T
1137
+ // Format: All polynomials are in FFT representation.
1138
+ // 1: (L, D) ← LDL*(G) ▷ L = [ 1 0 ; L₁₀ 1 ], D = [ D₀₀ 0 ; 0 D₁₁ ]
1139
+ // 2: T.value ← L₁₀
1140
+ // 3: if (n = 2) then
1141
+ // 4: T.leftchild ← D₀₀
1142
+ // 5: T.rightchild ← D₁₁
1143
+ // 6: return T
1144
+ // 7: else
1145
+ // 8: d₀₀, d₀₁ ← splitfft(D₀₀) ▷ dᵢⱼ ∈ FFT(Q[x]/(x^{n/2} + 1))
1146
+ // 9: d₁₀, d₁₁ ← splitfft(D₁₁)
1147
+ // 10: G₀ ← [ d₀₀ d₀₁ ; d₀₁* d₀₀ ], G₁ ← [ d₁₀ d₁₁ ; d₁₁* d₁₀ ]
1148
+ // ▷ Since D₀₀, D₁₁ are self-adjoint, (3.30) applies
1149
+ // 11: T.leftchild ← ffLDL*(G₀) ▷ Recursive calls
1150
+ // 12: T.rightchild ← ffLDL*(G₁)
1151
+ // 13: return T
1152
+ // Recursive calls may alias g00t and g11t, and the top-level arrays persist across signing
1153
+ // retries. LDL replaces array entries, so shallow copies keep both kinds of caller state intact.
1154
+ g00t = g00t.slice();
1155
+ g01t = g01t.slice();
1156
+ g11t = g11t.slice();
1157
+ const hn = 1 << (logn - 1);
1158
+ for (let i = 0; i < hn; i++) {
1159
+ const g01 = g01t[i];
1160
+ const g11 = g11t[i];
1161
+ const mu = fComplex.scale(g01, 1.0 / g00t[i].re);
1162
+ g11t[i] = { re: g11.re - (mu.re * g01.re + mu.im * g01.im), im: g11.im };
1163
+ g01t[i] = fComplex.conj(mu);
1164
+ }
1165
+ return { g00: g00t, g01: g01t, g11: g11t };
1166
+ }
1114
1167
  function ApproxExp(x, ccs) {
1115
1168
  // Algorithm 13: ApproxExp(x, ccs), (Page 42)
1116
1169
  // Require: Floating-point values x ∈ [0, ln(2)] and ccs ∈ [0, 1]
@@ -1633,19 +1686,24 @@ function genFalcon(opts) {
1633
1686
  };
1634
1687
  // [ 1B header ] [ 40B nonce ] [ compressed_sig ]
1635
1688
  const SignatureCoderDetached = (logn) => {
1636
- const sigLen = opts.padded ? opts.sigLen - 1 - NONCELEN : opts.detachedLen;
1637
- const getSigLen = (s2) => (opts.padded ? sigLen : s2.length);
1689
+ const paddedSigLen = opts.sigLen - 1 - NONCELEN;
1690
+ const getSigLen = (s2) => (opts.padded ? paddedSigLen : s2.length);
1638
1691
  return {
1639
1692
  encode({ nonce, s2 }) {
1640
- return headerCoder(0x30 + logn, splitCoder('falcon.detachedSignature', NONCELEN, getSigLen(s2))).encode([nonce, opts.padded ? pad(sigLen).encode(s2) : s2]);
1693
+ return headerCoder(0x30 + logn, splitCoder('falcon.detachedSignature', NONCELEN, getSigLen(s2))).encode([nonce, opts.padded ? pad(paddedSigLen).encode(s2) : s2]);
1641
1694
  },
1642
1695
  decode(data) {
1696
+ // Unpadded Round-3 signatures have parameter-set maxima (header + nonce + s2):
1697
+ // 752 bytes for Falcon-512 and 1462 for Falcon-1024. Reject before creating views or
1698
+ // entering the bit decoder so attacker-sized inputs cannot cause proportional work.
1699
+ if (!opts.padded && data.length > 1 + NONCELEN + opts.maxS2Len)
1700
+ throw new Error('detached signature too long');
1643
1701
  // Padded detached signatures are fixed-length (`lengths.signature`), so the payload width
1644
1702
  // must come from the parameter set, not from the input: deriving it would accept appended
1645
1703
  // zero bytes and truncated padding as extra valid encodings of the same signature.
1646
1704
  // Unpadded signatures are variable-length; decodeUnpaddedSig() enforces the exact canonical
1647
1705
  // bitlength of whatever remains.
1648
- const payloadLen = opts.padded ? sigLen : data.length - NONCELEN - 1;
1706
+ const payloadLen = opts.padded ? paddedSigLen : data.length - NONCELEN - 1;
1649
1707
  const [nonce, raw] = headerCoder(0x30 + logn, splitCoder('falcon.detachedSignature', NONCELEN, payloadLen)).decode(data);
1650
1708
  const s2 = decodeSig(raw);
1651
1709
  return { nonce, s2 };
@@ -1899,47 +1957,6 @@ function genFalcon(opts) {
1899
1957
  return s + z;
1900
1958
  }
1901
1959
  }
1902
- ldlFFT(logn, g00t, g01t, g11t) {
1903
- // Algorithm 8: LDL*(G)
1904
- // (Page 37)
1905
- // Require: A full-rank self-adjoint matrix G = (Gᵢⱼ) ∈ FFT(Q[x]/(φ))²ˣ²
1906
- // Ensure: The LDL* decomposition G = LDL* over FFT(Q[x]/(φ))
1907
- // Format: All polynomials are in FFT representation.
1908
- // 1: D₀₀ ← G₀₀
1909
- // 2: L₁₀ ← G₁₀/G₀₀
1910
- // 3: D₁₁ ← G₁₁ - L₁₀ ⊙ L₁₀* ⊙ G₀₀
1911
- // 4: L ← [ 1 0 ; L₁₀ 1 ], D ← [ D₀₀ 0 ; 0 D₁₁ ]
1912
- // 5: return (L, D)
1913
- // Algorithm 9: ffLDL*(G)
1914
- // (Page 37)
1915
- // Require: A full-rank Gram matrix G ∈ FFT(Q[x]/(xⁿ + 1))²ˣ²
1916
- // Ensure: A binary tree T
1917
- // Format: All polynomials are in FFT representation.
1918
- // 1: (L, D) ← LDL*(G) ▷ L = [ 1 0 ; L₁₀ 1 ], D = [ D₀₀ 0 ; 0 D₁₁ ]
1919
- // 2: T.value ← L₁₀
1920
- // 3: if (n = 2) then
1921
- // 4: T.leftchild ← D₀₀
1922
- // 5: T.rightchild ← D₁₁
1923
- // 6: return T
1924
- // 7: else
1925
- // 8: d₀₀, d₀₁ ← splitfft(D₀₀) ▷ dᵢⱼ ∈ FFT(Q[x]/(x^{n/2} + 1))
1926
- // 9: d₁₀, d₁₁ ← splitfft(D₁₁)
1927
- // 10: G₀ ← [ d₀₀ d₀₁ ; d₀₁* d₀₀ ], G₁ ← [ d₁₀ d₁₁ ; d₁₁* d₁₀ ]
1928
- // ▷ Since D₀₀, D₁₁ are self-adjoint, (3.30) applies
1929
- // 11: T.leftchild ← ffLDL*(G₀) ▷ Recursive calls
1930
- // 12: T.rightchild ← ffLDL*(G₁)
1931
- // 13: return T
1932
- g00t = g00t.slice(); // can be same as g11t and everything will break!
1933
- const hn = 1 << (logn - 1);
1934
- for (let i = 0; i < hn; i++) {
1935
- const g01 = g01t[i];
1936
- const g11 = g11t[i];
1937
- const mu = fComplex.scale(g01, 1.0 / g00t[i].re);
1938
- g11t[i] = { re: g11.re - (mu.re * g01.re + mu.im * g01.im), im: g11.im };
1939
- g01t[i] = fComplex.conj(mu);
1940
- }
1941
- return { g00: g00t, g01: g01t, g11: g11t };
1942
- }
1943
1960
  splitFFT(logn, f) {
1944
1961
  // Algorithm 1: splitfft(FFT(f))
1945
1962
  // (Page 29)
@@ -2045,7 +2062,13 @@ function genFalcon(opts) {
2045
2062
  // 13: z₀ ← mergefft(z'₀)
2046
2063
  // 14: return z = (z₀, z₁)
2047
2064
  if (logn === 0) {
2065
+ // The dynamic sampler stores 1/σ' instead of σ'. Keygen guarantees this interval,
2066
+ // but an imported compact key may reconstruct an invalid basis. Check the actual LDL*
2067
+ // leaf before it can drive SamplerZ; the negated comparison also rejects NaN/infinity.
2048
2068
  const leaf = Math.sqrt(g00i[0].re) * INV_SIGMA[this.logn];
2069
+ const sigmaPrime = 1 / leaf;
2070
+ if (!(sigmaPrime >= SIGMA_MIN[this.logn] && sigmaPrime <= SIGMA_MAX))
2071
+ throw new Error('invalid secretKey: sampler sigma out of range');
2049
2072
  // 3: z₀ ← SamplerZ(t₀, σ')
2050
2073
  // ▷ Since n=1, tᵢ = invFFT(tᵢ) ∈ Q and zᵢ = invFFT(zᵢ) ∈ Z
2051
2074
  const t0re = this.samplerZ(t0[0].re, leaf);
@@ -2053,7 +2076,7 @@ function genFalcon(opts) {
2053
2076
  return { t0: [{ re: t0re, im: 0.0 }], t1: [{ re: t1re, im: 0.0 }] };
2054
2077
  }
2055
2078
  // 6: (l, T₀, T₁) ← (T.value, T.leftchild, T.rightchild)
2056
- const { g00, g01, g11 } = this.ldlFFT(logn, g00i, g01i, g11i);
2079
+ const { g00, g01, g11 } = ldlFFT(logn, g00i, g01i, g11i);
2057
2080
  const { f0: g00f0, f1: g00f1 } = this.splitSelfAdjFFT(logn, g00);
2058
2081
  const { f0: g11f0, f1: g11f1 } = this.splitSelfAdjFFT(logn, g11);
2059
2082
  // 7: t'₁ ← splitfft(t₁)
@@ -2203,10 +2226,15 @@ function genFalcon(opts) {
2203
2226
  publicKey: publicKeyCoder.bytesLen,
2204
2227
  secretKey: secretKeyCoder.bytesLen,
2205
2228
  });
2229
+ // Falcon takes a sampler callback the other schemes do not, and rejects `context`
2230
+ // with its own message; both stay in the accepted set so the specific errors fire.
2231
+ const FALCON_SIG_OPT_KEYS = [...SIG_OPT_KEYS, 'random'];
2206
2232
  // Noble exposes a 48-byte sampler-seed hook,
2207
2233
  // but Falcon still samples/encodes a separate 40-byte nonce per signature.
2208
2234
  const getRnd = (opts = {}) => {
2209
- validateSigOpts(opts);
2235
+ // `context` stays in the accepted set so the specific "not supported" error below
2236
+ // still fires, rather than the generic unexpected-option one.
2237
+ opts = validateSigOpts(opts, FALCON_SIG_OPT_KEYS);
2210
2238
  if (opts.context !== undefined)
2211
2239
  throw new Error('context is not supported');
2212
2240
  if (opts.random !== undefined && typeof opts.random !== 'function')
@@ -2221,8 +2249,8 @@ function genFalcon(opts) {
2221
2249
  return (len = 0) => drbg.randomBytes(len);
2222
2250
  };
2223
2251
  const checkVerOpts = (opts = {}) => {
2224
- validateVerOpts(opts);
2225
- if (opts.context !== undefined)
2252
+ const normalized = validateVerOpts(opts);
2253
+ if (normalized.context !== undefined)
2226
2254
  throw new Error('context is not supported');
2227
2255
  };
2228
2256
  const tests = Object.freeze({
@@ -2296,12 +2324,43 @@ function genFalcon(opts) {
2296
2324
  },
2297
2325
  open(sig, pk, verOpts = {}) {
2298
2326
  checkVerOpts(verOpts);
2299
- const { s2, nonce, msg } = SignatureCoder.decode(sig);
2300
- // Zero-copy API: returned message aliases the caller-provided signature buffer.
2301
- // Copy it if ownership is needed.
2302
- if (verifyRaw(pk, s2, nonce, msg))
2303
- return msg;
2304
- throw new Error('invalid signature');
2327
+ // Wrong argument types are caller bugs and must stay TypeErrors; only what happens
2328
+ // after this is untrusted input. Detached verify() type-checks the public key the same
2329
+ // way, so open() does too: a wrong type is fatal, and a malformed (wrong-length or
2330
+ // non-canonical) key folds into the single rejection below rather than leaking a raw
2331
+ // codec error, exactly as detached verify() folds it into `false`.
2332
+ abytes(sig, undefined, 'signature');
2333
+ abytes(pk, undefined, 'publicKey');
2334
+ // Decode and verify owned snapshots. Apart from keeping the authenticated result stable
2335
+ // after open() returns, this ensures every verification step observes the same bytes when
2336
+ // an input is backed by SharedArrayBuffer or has subclass-overridden view methods.
2337
+ const ownedSig = copyBytes(sig);
2338
+ const ownedPk = copyBytes(pk);
2339
+ // Decode failures and malformed-key failures are rejected signatures, not internal
2340
+ // faults. Letting the codec's own errors out gave a caller handling untrusted input
2341
+ // several different messages for one corrupt byte, including "end of buffer: len=2
2342
+ // buf=0 lastByte=undefined", which reads as a library bug. Detached verify already
2343
+ // treats every such failure uniformly; open() collapses them into one Error (the
2344
+ // original preserved as `cause`). A well-formed signature that simply does not
2345
+ // validate falls through to the same message with no cause.
2346
+ try {
2347
+ let verifiedMsg;
2348
+ try {
2349
+ const { s2, nonce, msg } = SignatureCoder.decode(ownedSig);
2350
+ if (verifyRaw(ownedPk, s2, nonce, msg))
2351
+ verifiedMsg = msg;
2352
+ }
2353
+ catch (cause) {
2354
+ throw new Error('invalid signature', { cause });
2355
+ }
2356
+ if (verifiedMsg === undefined)
2357
+ throw new Error('invalid signature');
2358
+ // Do not retain or expose the full attached-signature allocation through `.buffer`.
2359
+ return copyBytes(verifiedMsg);
2360
+ }
2361
+ finally {
2362
+ cleanBytes(ownedSig, ownedPk);
2363
+ }
2305
2364
  },
2306
2365
  });
2307
2366
  const res = {
@@ -2318,14 +2377,14 @@ function genFalcon(opts) {
2318
2377
  }
2319
2378
  const falcon512opts = {
2320
2379
  N: 512,
2380
+ // Keep the mode an own property: omitted config fields must not inherit from Object.prototype.
2381
+ padded: false,
2321
2382
  // Table 3.3 fixed padded detached bytes, including the detached header byte and 40-byte nonce.
2322
2383
  sigLen: 666,
2323
2384
  fgBits: 6,
2324
2385
  FGBits: 8,
2325
2386
  // Compressed-s payload bytes only, excluding the detached header byte and 40-byte nonce.
2326
2387
  paddedLen: 625,
2327
- // Payload-only budget: genFalcon() adds the detached header byte and 40-byte nonce around it.
2328
- detachedLen: 690,
2329
2388
  };
2330
2389
  /**
2331
2390
  * Falcon-512 detached-signature API with the attached helper exposed as `.attached`.
@@ -2357,14 +2416,13 @@ export const falcon512padded = /* @__PURE__ */ (() => genFalcon({
2357
2416
  }))();
2358
2417
  const falcon1024opts = {
2359
2418
  N: 1024,
2419
+ padded: false,
2360
2420
  // Table 3.3 fixed padded detached bytes, including the detached header byte and 40-byte nonce.
2361
2421
  sigLen: 1280,
2362
2422
  fgBits: 5,
2363
2423
  FGBits: 8,
2364
2424
  // Compressed-s payload bytes only, excluding the detached header byte and 40-byte nonce.
2365
2425
  paddedLen: 1239,
2366
- // Payload-only budget: genFalcon() adds the detached header byte and 40-byte nonce around it.
2367
- detachedLen: 1280,
2368
2426
  };
2369
2427
  /**
2370
2428
  * Falcon-1024 detached-signature API with the attached helper exposed as `.attached`.
@@ -2405,6 +2463,7 @@ export const __tests = /* @__PURE__ */ (() => Object.freeze({
2405
2463
  INV_SIGMA,
2406
2464
  SIGMA_MIN,
2407
2465
  getFloatPoly,
2466
+ ldlFFT,
2408
2467
  cleanCPoly,
2409
2468
  falcon512: falcon512.__test,
2410
2469
  falcon512padded: falcon512padded.__test,
package/hybrid.d.ts CHANGED
@@ -84,6 +84,12 @@ type CurveSign = ECDSA | EdDSA;
84
84
  /**
85
85
  * Wraps an ECDH-capable curve as a KEM.
86
86
  * Shared secrets stay in the wrapped curve's raw ECDH byte format with no built-in KDF.
87
+ *
88
+ * SECURITY: this is a low-level component adapter, not a standalone IND-CCA-secure KEM. It does
89
+ * not bind the encapsulation or recipient public key into the secret, so distinct accepted point
90
+ * encodings can produce the same output. Use it only inside a construction whose specified
91
+ * combiner binds those values, or use a standardized DHKEM with labeled extract-and-expand.
92
+ *
87
93
  * On SEC 1 / Weierstrass curves, that means the compressed shared-point body without the
88
94
  * 1-byte `0x02` / `0x03` prefix.
89
95
  * The X25519 path also leaves RFC 7748's optional all-zero shared-secret check to callers.
@@ -99,12 +105,12 @@ type CurveSign = ECDSA | EdDSA;
99
105
  * Wrap an ECDH-capable curve as a generic KEM.
100
106
  * ```ts
101
107
  * import { x25519 } from '@noble/curves/ed25519.js';
102
- * import { ecdhKem } from '@noble/post-quantum/hybrid.js';
103
- * const kem = ecdhKem(x25519);
108
+ * import { _ecdhKem } from '@noble/post-quantum/hybrid.js';
109
+ * const kem = _ecdhKem(x25519);
104
110
  * const publicKeyLen = kem.lengths.publicKey;
105
111
  * ```
106
112
  */
107
- export declare function ecdhKem(curve: CurveECDH, allowZeroKey?: boolean): TRet<KEM>;
113
+ export declare function _ecdhKem(curve: CurveECDH, allowZeroKey?: boolean): TRet<KEM>;
108
114
  /**
109
115
  * Wraps a curve signer as a generic `Signer`.
110
116
  * Signatures stay in the wrapped curve's native byte encoding.
@@ -151,12 +157,18 @@ export declare function expandSeedXof(xof: TArg<XOF>): TRet<ExpandSeed>;
151
157
  export type Combiner = (publicKeys: TArg<Uint8Array[]>, cipherTexts: TArg<Uint8Array[]>, sharedSecrets: TArg<Uint8Array[]>) => TRet<Uint8Array>;
152
158
  /**
153
159
  * Combines multiple KEMs into one composite KEM.
154
- * @param realSeedLen - Input seed length expected by `expandSeed`.
155
- * @param realMsgLen - Shared-secret length returned by `combiner`.
160
+ * @param realSeedLen - Positive input seed length expected by `expandSeed`, or `undefined` to use
161
+ * the sum of component seed lengths. Callers remain responsible for choosing a security-appropriate
162
+ * size.
163
+ * @param realMsgLen - Positive shared-secret length returned by `combiner`, or `undefined` to use
164
+ * the sum of component message lengths.
156
165
  * @param expandSeed - Seed expander used to derive per-KEM seeds.
157
166
  * @param combiner - Combines the per-KEM outputs into one shared secret.
158
- * @param kems - KEM implementations to combine.
167
+ * @param kems - At least one KEM implementation. A construction advertised as hybrid normally
168
+ * supplies two or more.
159
169
  * @returns Composite KEM.
170
+ * @throws On wrong argument types. {@link TypeError}
171
+ * @throws If there are no components or any required length resolves to zero. {@link RangeError}
160
172
  * @example
161
173
  * Combine multiple KEMs into one composite KEM.
162
174
  * ```ts
@@ -179,10 +191,15 @@ realMsgLen: number | undefined, // how much bytes combiner returns
179
191
  expandSeed: TArg<ExpandSeed>, combiner: TArg<Combiner>, ...kems: TArg<KEM[]>): TRet<KEM>;
180
192
  /**
181
193
  * Combines multiple signers into one composite signer.
182
- * @param realSeedLen - Input seed length expected by `expandSeed`.
194
+ * @param realSeedLen - Positive input seed length expected by `expandSeed`, or `undefined` to use
195
+ * the sum of component seed lengths. Callers remain responsible for choosing a security-appropriate
196
+ * size.
183
197
  * @param expandSeed - Seed expander used to derive per-signer seeds.
184
- * @param signers - Signers to combine.
198
+ * @param signers - At least one signer. A construction advertised as hybrid normally supplies two
199
+ * or more.
185
200
  * @returns Composite signer.
201
+ * @throws On wrong argument types. {@link TypeError}
202
+ * @throws If there are no components or any required length resolves to zero. {@link RangeError}
186
203
  * @example
187
204
  * Combine multiple signers into one composite signer.
188
205
  * ```ts
@@ -211,14 +228,16 @@ export declare function combineSigners(realSeedLen: number | undefined, expandSe
211
228
  * @param xof - XOF used for seed expansion.
212
229
  * @param kdf - Hash used for the final combiner.
213
230
  * @returns Hybrid KEM.
231
+ * @throws On wrong argument types. {@link TypeError}
232
+ * @throws On wrong argument ranges or values. {@link RangeError}
214
233
  * @example
215
234
  * Build a QSF hybrid KEM preset from a PQ KEM and an elliptic-curve KEM.
216
235
  * ```ts
217
236
  * import { p256 } from '@noble/curves/nist.js';
218
237
  * import { sha3_256, shake256 } from '@noble/hashes/sha3.js';
219
- * import { QSF, ecdhKem } from '@noble/post-quantum/hybrid.js';
238
+ * import { QSF, _ecdhKem } from '@noble/post-quantum/hybrid.js';
220
239
  * import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
221
- * const kem = QSF('example', ml_kem768, ecdhKem(p256, true), shake256, sha3_256);
240
+ * const kem = QSF('example', ml_kem768, _ecdhKem(p256, true), shake256, sha3_256);
222
241
  * const publicKeyLen = kem.lengths.publicKey;
223
242
  * ```
224
243
  */
@@ -241,15 +260,17 @@ export declare const QSF_ml_kem1024_p384: TRet<KEM>;
241
260
  * @param xof - XOF used for seed expansion.
242
261
  * @param hash - Hash used for HKDF extraction and expansion.
243
262
  * @returns Hybrid KEM.
263
+ * @throws On wrong argument types. {@link TypeError}
264
+ * @throws On wrong argument ranges or values. {@link RangeError}
244
265
  * @example
245
266
  * Build the "KitchenSink" hybrid KEM combiner.
246
267
  * ```ts
247
268
  * import { sha256 } from '@noble/hashes/sha2.js';
248
269
  * import { shake256 } from '@noble/hashes/sha3.js';
249
- * import { createKitchenSink, ecdhKem } from '@noble/post-quantum/hybrid.js';
270
+ * import { createKitchenSink, _ecdhKem } from '@noble/post-quantum/hybrid.js';
250
271
  * import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
251
272
  * import { x25519 } from '@noble/curves/ed25519.js';
252
- * const kem = createKitchenSink('example', ml_kem768, ecdhKem(x25519), shake256, sha256);
273
+ * const kem = createKitchenSink('example', ml_kem768, _ecdhKem(x25519), shake256, sha256);
253
274
  * const publicKeyLen = kem.lengths.publicKey;
254
275
  * ```
255
276
  */