@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/ml-kem.js CHANGED
@@ -296,8 +296,11 @@ const genKPKE = (opts_) => {
296
296
  for (let i = 0; i < K; i++)
297
297
  polyAdd(tmp, MultiplyNTTs(sk[i], crystals.NTT.encode(u[i])));
298
298
  polySub(v, crystals.NTT.decode(tmp)); // w = v' - tmp
299
- cleanBytes(tmp, sk, u);
300
- return poly1.encode(v);
299
+ // `v` now holds w, from which the plaintext is just a 1-bit threshold away, so wipe it too.
300
+ // encode() allocates its own buffer, so the returned bytes do not alias `v`.
301
+ const res = poly1.encode(v);
302
+ cleanBytes(tmp, sk, u, v);
303
+ return res;
301
304
  },
302
305
  };
303
306
  };
@@ -305,6 +308,11 @@ const genKPKE = (opts_) => {
305
308
  * Public ML-KEM wrapper over the internal K-PKE subroutine.
306
309
  * `keygen(seed)` and `encapsulate(publicKey, msg)` are deterministic/test-oriented hooks that map
307
310
  * more directly to Algorithms 16-17 than to the pure no-input / random-internal Algorithms 19-20.
311
+ * `encapsulate`'s optional `msg` is the 32-byte message randomness `m` of Algorithm 17, the
312
+ * pre-image the shared secret is derived from, NOT a plaintext to encrypt: ML-KEM is a key
313
+ * encapsulation mechanism, not a cipher. Omit it to draw fresh randomness; pass it only to
314
+ * reproduce a known-answer vector, and only as 32 uniformly random bytes, since a low-entropy or
315
+ * reused value makes the shared secret predictable. The same holds for `keygen`'s optional `seed`.
308
316
  * decapsulate() tries to follow the Algorithms 18/21 implicit-reject structure as closely as
309
317
  * practical here by re-encrypting, comparing ciphertexts, returning `Khat` on match or `Kbar` on
310
318
  * mismatch, and zeroizing the non-returned shared-secret candidate; JS/JIT still provides no
@@ -340,34 +348,65 @@ function createKyber(opts) {
340
348
  return Object.freeze({
341
349
  info: Object.freeze({ type: 'ml-kem' }),
342
350
  lengths: kemLengths,
343
- keygen: (seed = randomBytes(seedLen)) => {
344
- abytes(seed, seedLen, 'seed');
345
- const { publicKey, secretKey: sk } = KPKE.keygen(seed.subarray(0, 32));
346
- const publicKeyHash = HASH256(publicKey);
347
- // (dkPKE||ek||H(ek)||z)
348
- const secretKey = secretCoder.encode([sk, publicKey, publicKeyHash, seed.subarray(32)]);
349
- cleanBytes(sk, publicKeyHash);
350
- return {
351
- publicKey: publicKey,
352
- secretKey: secretKey,
353
- };
351
+ keygen: (seed) => {
352
+ // A generated seed carries z (the implicit-rejection secret) and must be wiped once the
353
+ // secret key holds a copy, matching ml-dsa / slh-dsa / falcon keygen. A caller-supplied
354
+ // seed is the caller's to manage (and the immutability test requires it stay untouched).
355
+ const ownSeed = seed === undefined;
356
+ const s = ownSeed ? randomBytes(seedLen) : seed;
357
+ let sk;
358
+ let publicKeyHash;
359
+ try {
360
+ abytes(s, seedLen, 'seed');
361
+ const keys = KPKE.keygen(s.subarray(0, 32));
362
+ const publicKey = keys.publicKey;
363
+ sk = keys.secretKey;
364
+ publicKeyHash = HASH256(publicKey);
365
+ // (dkPKE||ek||H(ek)||z)
366
+ const secretKey = secretCoder.encode([sk, publicKey, publicKeyHash, s.subarray(32)]);
367
+ return {
368
+ publicKey: publicKey,
369
+ secretKey: secretKey,
370
+ };
371
+ }
372
+ finally {
373
+ if (sk !== undefined)
374
+ cleanBytes(sk);
375
+ if (publicKeyHash !== undefined)
376
+ cleanBytes(publicKeyHash);
377
+ if (ownSeed)
378
+ cleanBytes(s);
379
+ }
354
380
  },
355
381
  getPublicKey: (secretKey) => {
356
382
  const [_sk, publicKey, _publicKeyHash, _z] = secretCoder.decode(secretKey);
357
383
  return Uint8Array.from(publicKey);
358
384
  },
359
- encapsulate: (publicKey, msg = randomBytes(msgLen)) => {
360
- abytes(publicKey, lengths.publicKey, 'publicKey');
361
- abytes(msg, msgLen, 'message');
362
- validateModulus(publicKey, 'encapsulate');
363
- // derive randomness
364
- const kr = HASH512.create().update(msg).update(HASH256(publicKey)).digest();
365
- const cipherText = KPKE.encrypt(publicKey, msg, kr.subarray(32, 64));
366
- cleanBytes(kr.subarray(32));
367
- return {
368
- cipherText: cipherText,
369
- sharedSecret: kr.subarray(0, 32),
370
- };
385
+ encapsulate: (publicKey, msg) => {
386
+ // A generated message is the preimage of the shared secret (K = G(m || H(ek))[0:32]) and
387
+ // must be wiped. A caller-supplied message is the deterministic-randomness hook and the
388
+ // caller's to manage (the immutability test requires it stay untouched).
389
+ const ownMsg = msg === undefined;
390
+ const m = ownMsg ? randomBytes(msgLen) : msg;
391
+ let kr;
392
+ try {
393
+ abytes(publicKey, lengths.publicKey, 'publicKey');
394
+ abytes(m, msgLen, 'message');
395
+ validateModulus(publicKey, 'encapsulate');
396
+ // derive randomness
397
+ kr = HASH512.create().update(m).update(HASH256(publicKey)).digest();
398
+ const cipherText = KPKE.encrypt(publicKey, m, kr.subarray(32, 64));
399
+ return {
400
+ cipherText: cipherText,
401
+ sharedSecret: kr.subarray(0, 32),
402
+ };
403
+ }
404
+ finally {
405
+ if (kr !== undefined)
406
+ cleanBytes(kr.subarray(32));
407
+ if (ownMsg)
408
+ cleanBytes(m);
409
+ }
371
410
  },
372
411
  decapsulate: (cipherText, secretKey) => {
373
412
  abytes(secretKey, secretCoder.bytesLen, 'secretKey'); // 768*k + 96
@@ -406,15 +445,27 @@ function createKyber(opts) {
406
445
  const cached = KPKE.prepare(ek);
407
446
  return Object.freeze({
408
447
  publicKey: ek,
409
- encapsulate: (msg = randomBytes(msgLen)) => {
410
- abytes(msg, msgLen, 'message');
411
- const kr = HASH512.create().update(msg).update(publicKeyHash).digest();
412
- const cipherText = cached.encrypt(msg, kr.subarray(32, 64));
413
- cleanBytes(kr.subarray(32));
414
- return {
415
- cipherText: cipherText,
416
- sharedSecret: kr.subarray(0, 32),
417
- };
448
+ encapsulate: (msg) => {
449
+ // As in the non-prepared encapsulate: a generated message is the shared-secret
450
+ // preimage and is wiped; a caller-supplied one is left untouched.
451
+ const ownMsg = msg === undefined;
452
+ const m = ownMsg ? randomBytes(msgLen) : msg;
453
+ let kr;
454
+ try {
455
+ abytes(m, msgLen, 'message');
456
+ kr = HASH512.create().update(m).update(publicKeyHash).digest();
457
+ const cipherText = cached.encrypt(m, kr.subarray(32, 64));
458
+ return {
459
+ cipherText: cipherText,
460
+ sharedSecret: kr.subarray(0, 32),
461
+ };
462
+ }
463
+ finally {
464
+ if (kr !== undefined)
465
+ cleanBytes(kr.subarray(32));
466
+ if (ownMsg)
467
+ cleanBytes(m);
468
+ }
418
469
  },
419
470
  decapsulate: (cipherText, secretKey) => {
420
471
  abytes(secretKey, secretCoder.bytesLen, 'secretKey');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noble/post-quantum",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Auditable & minimal JS implementation of post-quantum cryptography: FIPS 203, 204, 205, Falcon",
5
5
  "files": [
6
6
  "*.js",
@@ -8,25 +8,28 @@
8
8
  "src"
9
9
  ],
10
10
  "dependencies": {
11
- "@noble/ciphers": "~2.3.0",
12
- "@noble/curves": "~2.3.0",
13
- "@noble/hashes": "~2.3.0"
11
+ "@noble/ciphers": "2.4.0",
12
+ "@noble/curves": "2.4.0",
13
+ "@noble/hashes": "2.4.0"
14
14
  },
15
15
  "devDependencies": {
16
- "@paulmillr/jsbt": "0.6.5",
17
- "@types/node": "25.3.0",
16
+ "@paulmillr/jsbt": "0.7.1",
17
+ "bismar": "0.1.8",
18
+ "@types/node": "26.2.0",
18
19
  "fast-check": "4.2.0",
19
- "prettier": "3.6.2",
20
- "typescript": "6.0.2"
20
+ "prettier": "3.9.6",
21
+ "typescript": "6.0.3"
21
22
  },
22
23
  "scripts": {
23
24
  "benchmark": "node benchmark/pq.ts",
24
- "benchmark:size": "npx bismar@0.1 -s",
25
+ "benchmark:size": "bismar -bsm",
25
26
  "build": "tsc",
26
27
  "check": "jsbt-check",
27
28
  "build:clean": "rm *.{js,d.ts} 2> /dev/null",
28
29
  "format": "prettier --write 'src/**/*.{js,ts}' 'test/**/*.{js,ts,mjs}'",
29
- "test": "node test/index.ts",
30
+ "test": "node --no-warnings test/index.ts",
31
+ "test:bun": "bun test/index.ts",
32
+ "test:deno": "deno --allow-env --allow-read test/index.ts",
30
33
  "test:slow": "SLOW_TESTS=1 node test/index.ts"
31
34
  },
32
35
  "exports": {
@@ -37,7 +40,8 @@
37
40
  "./ml-dsa.js": "./ml-dsa.js",
38
41
  "./ml-kem.js": "./ml-kem.js",
39
42
  "./slh-dsa.js": "./slh-dsa.js",
40
- "./utils.js": "./utils.js"
43
+ "./utils.js": "./utils.js",
44
+ "./webcrypto.js": "./webcrypto.js"
41
45
  },
42
46
  "engines": {
43
47
  "node": ">= 20.19.0"
package/slh-dsa.js CHANGED
@@ -33,6 +33,12 @@ import { sha256, sha512 } from '@noble/hashes/sha2.js';
33
33
  import { shake256 } from '@noble/hashes/sha3.js';
34
34
  import { concatBytes, createView } from '@noble/hashes/utils.js';
35
35
  import { abytes, checkHash, cleanBytes, copyBytes, equalBytes, getMask, getMessage, getMessagePrehash, randomBytes, splitCoder, validateSigOpts, validateVerOpts, vecCoder, } from "./utils.js";
36
+ // Keys the internal SLH-DSA surface accepts. `context` is deliberately absent: the public
37
+ // wrappers consume it when they format M' and must not forward it, because a key that is
38
+ // accepted and then never read is the same silent downgrade this validation exists to prevent.
39
+ // `extraEntropy` is signing-only, so verification (which takes no options of its own) has none.
40
+ const INTERNAL_SIG_OPT_KEYS = /* @__PURE__ */ Object.freeze(['extraEntropy']);
41
+ const INTERNAL_VER_OPT_KEYS = /* @__PURE__ */ Object.freeze([]);
36
42
  /** Winternitz signature params. */
37
43
  /**
38
44
  * Built-in SLH-DSA Table 2 subset keyed by strength/profile.
@@ -128,8 +134,18 @@ function gen(opts, hashOpts_) {
128
134
  // `height` / `chain` and `index` / `hash` share the same spec words, so callers must use the
129
135
  // address-type-specific combinations instead of mixing both meanings in one call.
130
136
  const setAddr = (opts, addr = new Uint8Array(ADDR_BYTES)) => {
131
- const { type, height, tree, layer, index, chain, hash, keypair } = opts;
132
- const { subtreeAddr, keypairAddr } = opts;
137
+ // These objects are created in hot internal loops, so avoid cloning them. Read only own fields:
138
+ // absent address words must stay absent even if Object.prototype was polluted.
139
+ const type = Object.hasOwn(opts, 'type') ? opts.type : undefined;
140
+ const height = Object.hasOwn(opts, 'height') ? opts.height : undefined;
141
+ const tree = Object.hasOwn(opts, 'tree') ? opts.tree : undefined;
142
+ const layer = Object.hasOwn(opts, 'layer') ? opts.layer : undefined;
143
+ const index = Object.hasOwn(opts, 'index') ? opts.index : undefined;
144
+ const chain = Object.hasOwn(opts, 'chain') ? opts.chain : undefined;
145
+ const hash = Object.hasOwn(opts, 'hash') ? opts.hash : undefined;
146
+ const keypair = Object.hasOwn(opts, 'keypair') ? opts.keypair : undefined;
147
+ const subtreeAddr = Object.hasOwn(opts, 'subtreeAddr') ? opts.subtreeAddr : undefined;
148
+ const keypairAddr = Object.hasOwn(opts, 'keypairAddr') ? opts.keypairAddr : undefined;
133
149
  if (height !== undefined)
134
150
  addr[OFFSET_CHAIN_ADDR] = height;
135
151
  if (layer !== undefined)
@@ -361,7 +377,7 @@ function gen(opts, hashOpts_) {
361
377
  return Uint8Array.from(pk);
362
378
  },
363
379
  sign: (msg, sk, opts = {}) => {
364
- validateSigOpts(opts);
380
+ opts = validateSigOpts(opts, INTERNAL_SIG_OPT_KEYS);
365
381
  let { extraEntropy: random } = opts;
366
382
  const [skSeed, skPRF, pk] = secretCoder.decode(sk); // todo: fix
367
383
  const [pkSeed, _] = publicCoder.decode(pk);
@@ -427,7 +443,11 @@ function gen(opts, hashOpts_) {
427
443
  cleanBytes(R, random, treeAddr, wotsAddr, forsLeaf, forsTreeAddr, indices, roots);
428
444
  return SIG;
429
445
  },
430
- verify: (sig, msg, publicKey) => {
446
+ verify: (sig, msg, publicKey, opts = {}) => {
447
+ // The internal verify reads no options; reject any so a stray key (e.g. a caller
448
+ // mistaking this for the public verify and passing `context`) is reported rather than
449
+ // silently swallowed by this function's arity.
450
+ validateVerOpts(opts, INTERNAL_VER_OPT_KEYS);
431
451
  const [pkSeed, pubRoot] = publicCoder.decode(publicKey);
432
452
  const pk = publicKey;
433
453
  // FIPS 205 Algorithm 20 step 1: wrong-length signatures return false instead of throwing
@@ -503,14 +523,16 @@ function gen(opts, hashOpts_) {
503
523
  keygen: internal.keygen,
504
524
  getPublicKey: internal.getPublicKey,
505
525
  sign: (msg, secretKey, opts = {}) => {
506
- validateSigOpts(opts);
526
+ opts = validateSigOpts(opts);
507
527
  const M = getMessage(msg, opts.context);
508
- const res = internal.sign(M, secretKey, opts);
528
+ // `context` is consumed by getMessage() above; forwarding it would make the internal
529
+ // surface accept a key it never reads.
530
+ const res = internal.sign(M, secretKey, { extraEntropy: opts.extraEntropy });
509
531
  cleanBytes(M);
510
532
  return res;
511
533
  },
512
534
  verify: (sig, msg, publicKey, opts = {}) => {
513
- validateVerOpts(opts);
535
+ opts = validateVerOpts(opts);
514
536
  return internal.verify(sig, getMessage(msg, opts.context), publicKey);
515
537
  },
516
538
  prehash: (hash) => {
@@ -522,14 +544,15 @@ function gen(opts, hashOpts_) {
522
544
  keygen: internal.keygen,
523
545
  getPublicKey: internal.getPublicKey,
524
546
  sign: (msg, secretKey, opts = {}) => {
525
- validateSigOpts(opts);
547
+ opts = validateSigOpts(opts);
526
548
  const M = getMessagePrehash(rawHash, msg, opts.context);
527
- const res = internal.sign(M, secretKey, opts);
549
+ // As above: getMessagePrehash() consumes `context`, so it must not travel further.
550
+ const res = internal.sign(M, secretKey, { extraEntropy: opts.extraEntropy });
528
551
  cleanBytes(M);
529
552
  return res;
530
553
  },
531
554
  verify: (sig, msg, publicKey, opts = {}) => {
532
- validateVerOpts(opts);
555
+ opts = validateVerOpts(opts);
533
556
  return internal.verify(sig, getMessagePrehash(rawHash, msg, opts.context), publicKey);
534
557
  },
535
558
  });
@@ -590,7 +613,7 @@ const genShake = () => (opts) => (pubSeed, skSeed) => {
590
613
  },
591
614
  };
592
615
  };
593
- const SHAKE_SIMPLE = /* @__PURE__ */ (() => ({ getContext: genShake() }))();
616
+ const SHAKE_SIMPLE = /* @__PURE__ */ (() => ({ isCompressed: false, getContext: genShake() }))();
594
617
  /**
595
618
  * SLH-DSA-SHAKE-128f: Table 2 row `n=16, h=66, d=22, h'=3, a=6, k=33, lg w=4, m=34`;
596
619
  * lengths `publicKey=32`, `secretKey=64`, `signature=17088`, `seed=48`, `signRand=16`.
package/src/_crystals.ts CHANGED
@@ -112,7 +112,7 @@ export const genCrystals = <T extends TypedArray>(opts: CrystalOpts<T>): TRet<Cr
112
112
  // Normalize JS `%` into the canonical Z_m representative `[0, modulo-1]` expected by
113
113
  // FIPS 203 §2.3 / FIPS 204 §2.3 before downstream mod-q arithmetic.
114
114
  const mod = (a: number, modulo = Q): number => {
115
- const result = a % modulo | 0;
115
+ const result = (a % modulo) | 0;
116
116
  return (result >= 0 ? result | 0 : (modulo + result) | 0) | 0;
117
117
  };
118
118
  // FIPS 204 §7.4 uses the centered `mod ±` representative for low bits, keeping the