kxco-post-quantum 1.4.1 → 1.5.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/BENCHMARKS.md CHANGED
@@ -15,7 +15,51 @@ node --expose-gc bench/primitives.mjs --iterations 100 --json bench/results/prim
15
15
  ```
16
16
 
17
17
  Every parameter set the package can reach is measured, not only the five it wraps
18
- in its own helpers.
18
+ in its own helpers. The OpenSSL rows appear only on a runtime that provides those
19
+ primitives, so run it on Node 24 or later to reproduce the comparison below and
20
+ on Node 22 for the JavaScript figures alone.
21
+
22
+ ## Two backends
23
+
24
+ Since 1.5.0 this package runs the FIPS primitives in OpenSSL 3.5 where the
25
+ runtime provides them (Node 24 and later) and in JavaScript everywhere else.
26
+ Those are different implementations, so one set of numbers cannot describe both.
27
+ The harness measures whichever are available and labels every row, and the
28
+ tables below the comparison are the JavaScript figures.
29
+
30
+ | Algorithm | Operation | OpenSSL p50 | JavaScript p50 | OpenSSL p99 | JavaScript p99 | faster by |
31
+ |---|---|---:|---:|---:|---:|---:|
32
+ | ML-DSA-44 | sign | 1.125 | 5.116 | 4.619 | 21.325 | 4.5x |
33
+ | ML-DSA-44 | verify | 0.241 | 1.373 | 0.725 | 2.451 | 5.7x |
34
+ | ML-DSA-65 | sign | 1.293 | 8.074 | 4.562 | 40.840 | 6.2x |
35
+ | ML-DSA-65 | verify | 0.288 | 2.249 | 0.652 | 3.803 | 7.8x |
36
+ | ML-DSA-87 | sign | 1.879 | 11.031 | 7.128 | 39.404 | 5.9x |
37
+ | ML-DSA-87 | verify | 0.613 | 3.363 | 1.831 | 5.843 | 5.5x |
38
+ | ML-KEM-1024 | encapsulate | 0.126 | 0.708 | 0.278 | 1.986 | 5.6x |
39
+ | ML-KEM-1024 | decapsulate | 0.230 | 0.919 | 0.339 | 1.630 | 4.0x |
40
+ | ML-KEM-512 | encapsulate | 0.079 | 0.378 | 0.212 | 1.008 | 4.8x |
41
+ | ML-KEM-512 | decapsulate | 0.236 | 0.461 | 0.380 | 1.498 | 2.0x |
42
+ | ML-KEM-768 | encapsulate | 0.148 | 0.558 | 0.256 | 1.288 | 3.8x |
43
+ | ML-KEM-768 | decapsulate | 0.183 | 0.874 | 0.523 | 5.378 | 4.8x |
44
+ | SLH-DSA-SHA2-128f | sign | 68.639 | 97.791 | 165.092 | 173.466 | 1.4x |
45
+ | SLH-DSA-SHA2-128f | verify | 3.459 | 6.082 | 4.303 | 27.278 | 1.8x |
46
+ | SLH-DSA-SHA2-192s | sign | 1710.954 | 4342.105 | 1938.773 | 4628.987 | 2.5x |
47
+ | SLH-DSA-SHA2-192s | verify | 1.263 | 7.457 | 2.405 | 40.920 | 5.9x |
48
+ | SLH-DSA-SHAKE-256f | sign | 161.358 | 2158.272 | 179.655 | 3510.503 | 13.4x |
49
+ | SLH-DSA-SHAKE-256f | verify | 5.599 | 66.177 | 6.774 | 98.654 | 11.8x |
50
+
51
+ The speedup column is the headline, but for a document about tail latency the
52
+ p99 columns matter more. ML-DSA-65 signing goes from a 40.8 ms p99 to 4.6 ms:
53
+ the rejection-sampling tail that the opening of this document warns about is
54
+ still there in the OpenSSL path, but it is roughly nine times tighter, so a
55
+ timeout sized off it can be far smaller.
56
+
57
+ SLH-DSA-SHAKE-256f is the largest single change, 2158 ms down to 161 ms for a
58
+ signature. SLH-DSA-SHA2-192s remains slow in absolute terms at 1.7 seconds and
59
+ no backend makes a hash-based signature cheap; it is 2.5x rather than fast.
60
+
61
+ Neither column is a side-channel claim. See
62
+ [THREAT-MODEL.md](THREAT-MODEL.md).
19
63
 
20
64
  ## Signatures
21
65
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,74 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.1
4
+
5
+ Dependency bump: `@noble/post-quantum` 0.7.0 to **0.7.1**, published 2026-08-27.
6
+ Exact pin as always, never a range.
7
+
8
+ Worth taking rather than waiting for the scheduled Dependabot run, because one of
9
+ its changes touches how this package calls it: 0.7.1 snapshots options on entry
10
+ so a caller's object cannot be mutated afterwards. Every FIPS 204 context string
11
+ this package passes goes in as an options object, so that hardening is directly
12
+ on our path.
13
+
14
+ It also adds a WebCrypto wrapper for ML-KEM upstream. Nothing here uses it yet,
15
+ but it is worth knowing that the backend is converging on the same thing this
16
+ package did in 1.5.0: use the platform's implementation where the platform has
17
+ one.
18
+
19
+ Verified before merging, on all three runtimes: 39 pinned vectors bit-for-bit and
20
+ 53 tests on Node 20 and 22 (the JavaScript path, where this bump actually
21
+ applies) and on Node 24+ (the OpenSSL path).
22
+
23
+ AUDIT.md carries the new pin and its integrity hash. The conservative bound is
24
+ unchanged and restated: the maintainer's self-audit covers 0.6.1, we ship 0.7.1,
25
+ so **the version we ship is covered by no audit at all**.
26
+
27
+ ## 1.5.0
28
+
29
+ **The FIPS primitives now run in OpenSSL where the runtime has them.** On Node 24
30
+ and later this package uses OpenSSL 3.5 for ML-KEM, ML-DSA and SLH-DSA. On Node
31
+ 20 and 22, in browsers, and on any runtime without them, the JavaScript backend
32
+ is used exactly as before.
33
+
34
+ Additive. No export changed, no signature changed, no key format changed, and
35
+ `@noble/post-quantum` is still a dependency and still the backend on every
36
+ runtime that lacks the native primitives. Nothing is removed.
37
+
38
+ **Both backends are checked against each other rather than assumed to agree.**
39
+ The interoperability matrix runs in full on both, against liboqs, Bouncy Castle
40
+ and dilithium-py / kyber-py, and both return **225 passed, 0 failed, 42 not
41
+ applicable across 38 rows**. CI runs both legs. Every generated report now
42
+ records which backend produced it under `wrapperBackend`, because a run that
43
+ does not say is not evidence about either one.
44
+
45
+ Keys and signatures are unchanged on the wire, which is what makes this safe to
46
+ do silently: a signature made by one backend verifies under the other, in both
47
+ directions, for all nine parameter sets. Existing keys keep working. Nothing
48
+ needs migrating.
49
+
50
+ Measured on the development machine:
51
+
52
+ | | OpenSSL | JavaScript | |
53
+ |---|---|---|---|
54
+ | ML-DSA-65 sign | 1.34 ms | 11.54 ms | 8.6x |
55
+ | ML-DSA-65 verify | 0.28 ms | 2.22 ms | 7.9x |
56
+ | SLH-DSA-SHA2-192s sign | 1595 ms | 4717 ms | 3.0x |
57
+
58
+ **A FIPS 204 context string keeps the JavaScript path.** Node's `sign` and
59
+ `verify` take no context argument, and signing without the caller's context
60
+ would produce a signature that verifies against nothing. That is a deliberate
61
+ fallback, not a gap.
62
+
63
+ **THREAT-MODEL.md is updated, and the change is narrower than it looks.** That
64
+ document argued the timing and cache attacker was out of scope *structurally*,
65
+ because the property cannot be established from inside JavaScript. On the
66
+ OpenSSL path that argument no longer applies. It does not follow that this
67
+ package is constant-time: we have published no timing measurements, and
68
+ OpenSSL's side-channel posture is theirs to state rather than ours to assert.
69
+ The honest position is that the attacker moves from structurally out of scope to
70
+ **unmeasured** on Node 24+, and stays out of scope everywhere else.
71
+
3
72
  ## 1.4.1
4
73
 
5
74
  Evidence and documentation only. **No `src/` module changed**, no export was
package/CONFORMANCE.md CHANGED
@@ -102,7 +102,19 @@ no maintained pure-Python implementation was available to pin. That is a
102
102
  narrower base than the other two families and is stated rather than averaged
103
103
  away.
104
104
 
105
- None of the three shares code with this package's backend. liboqs is the
105
+ ### Which backend the matrix exercised
106
+
107
+ From 1.5.0 this package has two backends: OpenSSL 3.5 where the runtime provides
108
+ the FIPS primitives (Node 24 and later) and JavaScript everywhere else. They are
109
+ different implementations, so a matrix run against one is not evidence about the
110
+ other. CI therefore runs the whole matrix on **both**, and every generated report
111
+ records which one it used under `wrapperBackend`. A report that does not say is
112
+ not evidence.
113
+
114
+ Both produce the same result: **225 passed, 0 failed, 42 not applicable**. That
115
+ is the point of running both.
116
+
117
+ None of the three peers shares code with either of this package's backends. liboqs is the
106
118
  reference C implementation the wider ecosystem tests against; Bouncy Castle is a
107
119
  widely deployed independent implementation; the Python pair are independent
108
120
  spec-derived implementations.
package/README.md CHANGED
@@ -11,7 +11,7 @@ ML-DSA-65 (FIPS 204) and SLH-DSA-SHA2-192s (FIPS 205) signatures, ML-KEM-768 (FI
11
11
 
12
12
  **Evidence, not adjectives:**
13
13
 
14
- - [CONFORMANCE.md](./CONFORMANCE.md): NIST ACVP vectors for FIPS 203/204/205 (2,103 tests, 0 failed), and a cross-implementation interop matrix against liboqs, Bouncy Castle and two pure-Python implementations (225 checks, 0 failed, both directions, with negative controls). Reproducible: `npm run conformance:acvp`, `npm run conformance:interop`.
14
+ - [CONFORMANCE.md](./CONFORMANCE.md): NIST ACVP vectors for FIPS 203/204/205 (2,103 tests, 0 failed), and a cross-implementation interop matrix against liboqs, Bouncy Castle and two pure-Python implementations (225 checks, 0 failed, both directions, with negative controls), run against both backends. Reproducible: `npm run conformance:acvp`, `npm run conformance:interop`.
15
15
  - [BENCHMARKS.md](./BENCHMARKS.md): per-algorithm latency at p95/p99, plus memory. Two figures worth designing around: ML-DSA signing has a 4.5x tail between median and p99, and SLH-DSA-SHA2-192s signs in about 6.8 seconds.
16
16
  - [THREAT-MODEL.md](./THREAT-MODEL.md): what this defends against and what it does not. Read the side-channel section before deciding where a signing key lives.
17
17
  - [MIGRATION.md](./MIGRATION.md): moving an RSA or ECDSA system across, and moving between versions of this package.
package/THREAT-MODEL.md CHANGED
@@ -70,8 +70,34 @@ layout, memory placement or cache behaviour; the JIT may specialise a hot path
70
70
  on the values flowing through it; the garbage collector may copy secret bytes to
71
71
  places the caller cannot reach and cannot clear. Constant-time execution cannot
72
72
  be established, let alone maintained across engine versions, from inside the
73
- language. The backend states this about itself in plain terms: *"There is no
74
- protection against side-channel attacks."*
73
+ language. The JavaScript backend states this about itself in plain terms:
74
+ *"There is no protection against side-channel attacks."*
75
+
76
+ **Since 1.5.0 that paragraph no longer describes every deployment, and the
77
+ difference should not be overstated.** On Node 24 and later this package runs
78
+ the FIPS 203/204/205 primitives in OpenSSL 3.5 rather than in JavaScript, so the
79
+ argument above stops applying to the primitive path: the code doing the
80
+ arithmetic is C, compiled ahead of time, outside the JIT and outside the
81
+ collector.
82
+
83
+ What that does and does not buy:
84
+
85
+ - It **does** remove the structural impossibility. The reasoning above was that
86
+ the property could not be established from inside the language at all. On the
87
+ OpenSSL path it can, in principle, be established.
88
+ - It **does not** amount to a constant-time claim from us. We have published no
89
+ timing measurements of either backend, and OpenSSL's post-quantum
90
+ implementations carry their own side-channel posture which is theirs to state,
91
+ not ours to assert on their behalf.
92
+ - It **does not** apply to Node 20 or 22, to browsers, or to any runtime without
93
+ those primitives. Those keep the JavaScript backend and everything above
94
+ applies to them unchanged.
95
+ - It **does not** cover power or electromagnetic analysis on any path.
96
+
97
+ So the honest position is narrower than "we fixed side channels": the timing and
98
+ cache attacker moves from *structurally out of scope* to *unmeasured* on Node
99
+ 24+, and stays out of scope everywhere else. Treat it as unmeasured until
100
+ measurements exist.
75
101
 
76
102
  Two consequences worth being blunt about:
77
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kxco-post-quantum",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "description": "ML-DSA-65, ML-KEM-768 and SLH-DSA-SHA2-192s primitives with key fingerprinting. The base layer for all kxco-pq-* packages.",
5
5
  "keywords": [
6
6
  "post-quantum",
@@ -52,6 +52,12 @@
52
52
  "sideEffects": false,
53
53
  "main": "./src/index.js",
54
54
  "types": "./src/index.d.ts",
55
+ "imports": {
56
+ "#native": {
57
+ "node": "./src/_native.node.js",
58
+ "default": "./src/_native.stub.js"
59
+ }
60
+ },
55
61
  "exports": {
56
62
  ".": {
57
63
  "types": "./src/index.d.ts",
@@ -106,7 +112,7 @@
106
112
  },
107
113
  "dependencies": {
108
114
  "@noble/hashes": "2.3.0",
109
- "@noble/post-quantum": "0.7.0"
115
+ "@noble/post-quantum": "0.7.1"
110
116
  },
111
117
  "scripts": {
112
118
  "test": "node --test test/basic.test.js && node --test test/context.test.js && node --test test/category5.test.js && node --test test/browser-smoke.test.js && node test/run-vectors.js",
@@ -0,0 +1,204 @@
1
+ // OpenSSL-backed primitives, used when the runtime provides them.
2
+ //
3
+ // Node 24 and later expose the FIPS 203/204/205 parameter sets through OpenSSL
4
+ // 3.5. Where that is available this module supplies the primitives and the
5
+ // JavaScript backend is not called; on Node 20 and 22, and on any runtime
6
+ // without them, `native` is null and nothing changes.
7
+ //
8
+ // Why prefer it. The C implementation is the one the wider ecosystem tests
9
+ // against, it carries a constant-time story no JavaScript implementation can
10
+ // make, and it removes the runtime dependency from the signing path entirely on
11
+ // Node 24+. It is also faster by a wide margin, measured on this machine:
12
+ //
13
+ // ML-DSA-65 sign 1.34 ms against 11.54 ms (8.6x)
14
+ // ML-DSA-65 verify 0.28 ms against 2.22 ms (7.9x)
15
+ // SLH-DSA-SHA2-192s sign 1595 ms against 4717 ms (3.0x)
16
+ //
17
+ // The two implementations are interchangeable on the wire, which is checked
18
+ // rather than assumed: the interoperability matrix runs every parameter set
19
+ // against both backends in both directions, and `npm run conformance:interop`
20
+ // reproduces it.
21
+ //
22
+ // Keys cross the boundary in the encodings this package already uses. Private
23
+ // keys go in as PKCS8 carrying the expanded key, or as a JWK for SLH-DSA, whose
24
+ // private key is not seed-derived in OpenSSL's representation. Public keys go in
25
+ // as SPKI wrapping the raw bytes. Every one of those was verified against the
26
+ // JavaScript backend before this module was written; none of it is inferred
27
+ // from the specification.
28
+
29
+ import crypto from 'node:crypto'
30
+
31
+ // FIPS 204 section 5.2 context strings are not reachable through Node's sign and
32
+ // verify, which take no context argument. A call that uses one falls back to the
33
+ // JavaScript backend rather than being signed without it: a signature made
34
+ // without the caller's context verifies against nothing and would look like a
35
+ // cross-implementation disagreement rather than a missing feature.
36
+
37
+ const DER_SEQUENCE = 0x30
38
+ const DER_OCTET_STRING = 0x04
39
+ const DER_BIT_STRING = 0x03
40
+
41
+ function derLength(length) {
42
+ if (length < 0x80) return Buffer.from([length])
43
+ if (length < 0x100) return Buffer.from([0x81, length])
44
+ return Buffer.from([0x82, length >> 8, length & 0xff])
45
+ }
46
+
47
+ function der(tag, payload) {
48
+ return Buffer.concat([Buffer.from([tag]), derLength(payload.length), payload])
49
+ }
50
+
51
+ // The AlgorithmIdentifier is read back off a key OpenSSL generates itself rather
52
+ // than hard-coded from the OID registry, so a build that spells one differently
53
+ // cannot produce a subtly wrong encoding here.
54
+ function algorithmIdentifier(nodeName) {
55
+ const { privateKey } = crypto.generateKeyPairSync(nodeName)
56
+ const pkcs8 = privateKey.export({ format: 'der', type: 'pkcs8' })
57
+ const headerLength = pkcs8[1] & 0x80 ? 2 + (pkcs8[1] & 0x7f) : 2
58
+ const start = headerLength + 3 // skip the version INTEGER
59
+ return pkcs8.subarray(start, start + 2 + pkcs8[start + 1])
60
+ }
61
+
62
+ function toPkcs8(algid, privateBytes) {
63
+ const body = Buffer.concat([
64
+ Buffer.from('020100', 'hex'),
65
+ algid,
66
+ der(DER_OCTET_STRING, der(DER_OCTET_STRING, Buffer.from(privateBytes))),
67
+ ])
68
+ return der(DER_SEQUENCE, body)
69
+ }
70
+
71
+ function toSpki(algid, publicBytes) {
72
+ const bits = der(DER_BIT_STRING, Buffer.concat([Buffer.from([0x00]), Buffer.from(publicBytes)]))
73
+ return der(DER_SEQUENCE, Buffer.concat([algid, bits]))
74
+ }
75
+
76
+ const base64url = (bytes) => Buffer.from(bytes).toString('base64url')
77
+
78
+ // Each entry records how this package's representation maps onto OpenSSL's.
79
+ // `privateForm` is the difference that matters: ML-DSA and ML-KEM accept the
80
+ // expanded private key inside PKCS8, while OpenSSL keys SLH-DSA by its full
81
+ // private key through a JWK.
82
+ const ALGORITHMS = {
83
+ 'ML-DSA-44': { nodeName: 'ml-dsa-44', kind: 'sig', privateForm: 'pkcs8' },
84
+ 'ML-DSA-65': { nodeName: 'ml-dsa-65', kind: 'sig', privateForm: 'pkcs8' },
85
+ 'ML-DSA-87': { nodeName: 'ml-dsa-87', kind: 'sig', privateForm: 'pkcs8' },
86
+ 'ML-KEM-512': { nodeName: 'ml-kem-512', kind: 'kem', privateForm: 'pkcs8' },
87
+ 'ML-KEM-768': { nodeName: 'ml-kem-768', kind: 'kem', privateForm: 'pkcs8' },
88
+ 'ML-KEM-1024': { nodeName: 'ml-kem-1024', kind: 'kem', privateForm: 'pkcs8' },
89
+ 'SLH-DSA-SHA2-128f': { nodeName: 'slh-dsa-sha2-128f', kind: 'sig', privateForm: 'jwk' },
90
+ 'SLH-DSA-SHA2-192s': { nodeName: 'slh-dsa-sha2-192s', kind: 'sig', privateForm: 'jwk' },
91
+ 'SLH-DSA-SHAKE-256f': { nodeName: 'slh-dsa-shake-256f', kind: 'sig', privateForm: 'jwk' },
92
+ }
93
+
94
+ // Probed once, by actually generating a key. Asking the Node version would be a
95
+ // guess about which build shipped which OpenSSL; generating a key is the fact.
96
+ function probe() {
97
+ const table = new Map()
98
+ for (const [name, spec] of Object.entries(ALGORITHMS)) {
99
+ try {
100
+ // jwkAlg is the FIPS name: SLH-DSA goes in as a JWK, which names the
101
+ // algorithm in the payload rather than in an AlgorithmIdentifier.
102
+ table.set(name, { ...spec, jwkAlg: name, algid: algorithmIdentifier(spec.nodeName) })
103
+ } catch {
104
+ // Not in this build. The JavaScript backend covers it.
105
+ }
106
+ }
107
+ return table
108
+ }
109
+
110
+ const SUPPORTED = probe()
111
+
112
+ function privateKeyObject(spec, secretKey, publicKey) {
113
+ if (spec.privateForm === 'jwk') {
114
+ // FIPS 205 lays the private key out as SK.seed || SK.prf || PK.seed ||
115
+ // PK.root, and the public key is PK.seed || PK.root, so the public half is
116
+ // the back half of the private key. Callers that already hold it pass it;
117
+ // this package's sign() does not, and recovering it here is exact rather
118
+ // than a reconstruction.
119
+ const pub = publicKey ?? secretKey.subarray(secretKey.length / 2)
120
+ return crypto.createPrivateKey({
121
+ key: {
122
+ kty: 'AKP',
123
+ alg: spec.jwkAlg,
124
+ pub: base64url(pub),
125
+ priv: base64url(secretKey),
126
+ },
127
+ format: 'jwk',
128
+ })
129
+ }
130
+ return crypto.createPrivateKey({
131
+ key: toPkcs8(spec.algid, secretKey),
132
+ format: 'der',
133
+ type: 'pkcs8',
134
+ })
135
+ }
136
+
137
+ function publicKeyObject(spec, publicKey) {
138
+ return crypto.createPublicKey({
139
+ key: toSpki(spec.algid, publicKey),
140
+ format: 'der',
141
+ type: 'spki',
142
+ })
143
+ }
144
+
145
+ export const native = SUPPORTED.size === 0 ? null : {
146
+ /** Parameter sets this build can do. Anything else falls through to JS.
147
+ *
148
+ * The Buffer check is not defensive padding. This package's browser-mode
149
+ * tests simulate a browser by removing Buffer from the global scope, and a
150
+ * real browser resolves `#native` to the stub and never reaches this module
151
+ * at all. Without this, those tests would exercise the OpenSSL path while
152
+ * claiming to cover the browser one, which is a false pass rather than a
153
+ * crash: the very thing the suite exists to catch.
154
+ */
155
+ supports(alg) {
156
+ return typeof Buffer !== 'undefined' && SUPPORTED.has(alg)
157
+ },
158
+
159
+ /** Names of the supported sets, for the conformance report to record. */
160
+ algorithms() {
161
+ return [...SUPPORTED.keys()].sort()
162
+ },
163
+
164
+ openssl: process.versions.openssl,
165
+
166
+ sign(alg, secretKey, message, publicKey) {
167
+ const spec = SUPPORTED.get(alg)
168
+ if (!spec) return null
169
+ return crypto.sign(null, Buffer.from(message), privateKeyObject(spec, secretKey, publicKey))
170
+ },
171
+
172
+ verify(alg, publicKey, message, signature) {
173
+ const spec = SUPPORTED.get(alg)
174
+ if (!spec) return null
175
+ try {
176
+ return crypto.verify(
177
+ null,
178
+ Buffer.from(message),
179
+ publicKeyObject(spec, publicKey),
180
+ Buffer.from(signature)
181
+ )
182
+ } catch {
183
+ // A malformed key or signature is a failed verification, not a crash,
184
+ // which matches what the JavaScript backend does with the same input.
185
+ return false
186
+ }
187
+ },
188
+
189
+ encapsulate(alg, publicKey) {
190
+ const spec = SUPPORTED.get(alg)
191
+ if (!spec || spec.kind !== 'kem') return null
192
+ // Node names these sharedKey and ciphertext; this package has always called
193
+ // them sharedSecret and cipherText, so the rename happens here rather than
194
+ // leaking a second vocabulary into the public API.
195
+ const { sharedKey, ciphertext } = crypto.encapsulate(publicKeyObject(spec, publicKey))
196
+ return { cipherText: ciphertext, sharedSecret: sharedKey }
197
+ },
198
+
199
+ decapsulate(alg, cipherText, secretKey) {
200
+ const spec = SUPPORTED.get(alg)
201
+ if (!spec || spec.kind !== 'kem') return null
202
+ return crypto.decapsulate(privateKeyObject(spec, secretKey), Buffer.from(cipherText))
203
+ },
204
+ }
@@ -0,0 +1,13 @@
1
+ // Non-Node backend selector.
2
+ //
3
+ // Resolved by the "#native" subpath import in package.json for every runtime
4
+ // that is not Node: browsers, Deno's browser-ish targets, bundlers. There is no
5
+ // OpenSSL to reach for there, so the wrapper uses its JavaScript backend and
6
+ // nothing about its behaviour changes.
7
+ //
8
+ // Keeping this as a separate file rather than a runtime check is deliberate: a
9
+ // bundler that saw `import('node:crypto')` anywhere in the graph would either
10
+ // fail to resolve it or ship a polyfill, and this package is documented as
11
+ // working unmodified in a browser.
12
+
13
+ export const native = null
package/src/ml-dsa-87.js CHANGED
@@ -31,6 +31,7 @@
31
31
  import { ml_dsa87 } from '@noble/post-quantum/ml-dsa.js'
32
32
  import { deriveSeed } from './derive.js'
33
33
  import { normalizeContext, MAX_CONTEXT_BYTES } from './_context.js'
34
+ import { native } from '#native'
34
35
 
35
36
  export { MAX_CONTEXT_BYTES }
36
37
 
@@ -57,6 +58,15 @@ function wrap(bytes) {
57
58
  return HAS_BUFFER ? Buffer.from(bytes) : bytes
58
59
  }
59
60
 
61
+ // Where the runtime provides the FIPS primitives through OpenSSL (Node 24 and
62
+ // later) they are used in place of the JavaScript backend. Everywhere else,
63
+ // including every browser, `native` is null and nothing about this module
64
+ // changes. The two backends are checked against each other for this parameter
65
+ // set in both directions by the interoperability matrix.
66
+ const NATIVE_ALG = 'ML-DSA-87'
67
+ const usesNative = (context) =>
68
+ context === undefined && native !== null && native.supports(NATIVE_ALG)
69
+
60
70
  /**
61
71
  * Generate an ML-DSA-87 keypair from a master + domain-separation info.
62
72
  *
@@ -88,6 +98,9 @@ export function keypairFromMaster(master, info = 'ml-dsa-87-v1') {
88
98
  */
89
99
  export function sign(secretKey, message, opts) {
90
100
  const context = normalizeContext(opts)
101
+ if (usesNative(context)) {
102
+ return bytesToHex(native.sign(NATIVE_ALG, secretKey, toBytes(message)))
103
+ }
91
104
  const sig = context === undefined
92
105
  ? ml_dsa87.sign(toBytes(message), secretKey)
93
106
  : ml_dsa87.sign(toBytes(message), secretKey, { context })
@@ -116,6 +129,9 @@ export function verify(publicKey, message, sigHex, opts) {
116
129
  // Outside the try: misuse must surface, not be swallowed as "invalid".
117
130
  const context = normalizeContext(opts)
118
131
  try {
132
+ if (usesNative(context)) {
133
+ return native.verify(NATIVE_ALG, publicKey, toBytes(message), hexToBytes(sigHex))
134
+ }
119
135
  return context === undefined
120
136
  ? ml_dsa87.verify(hexToBytes(sigHex), toBytes(message), publicKey)
121
137
  : ml_dsa87.verify(hexToBytes(sigHex), toBytes(message), publicKey, { context })
package/src/ml-dsa.js CHANGED
@@ -9,9 +9,24 @@
9
9
  import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js'
10
10
  import { deriveSeed } from './derive.js'
11
11
  import { normalizeContext, MAX_CONTEXT_BYTES } from './_context.js'
12
+ import { native } from '#native'
12
13
 
13
14
  export { MAX_CONTEXT_BYTES }
14
15
 
16
+ // Where the runtime provides the FIPS 204 primitives through OpenSSL (Node 24
17
+ // and later) they are used in place of the JavaScript backend. Everywhere else,
18
+ // including every browser, `native` is null and nothing about this module
19
+ // changes. The two backends are checked against each other for every parameter
20
+ // set in both directions by the interoperability matrix, so this is a swap
21
+ // between two implementations known to agree, not an assumption that they do.
22
+ //
23
+ // A context string forces the JavaScript path: Node's sign and verify take no
24
+ // context argument, and signing without the caller's context would produce a
25
+ // signature that verifies against nothing.
26
+ const NATIVE_ALG = 'ML-DSA-65'
27
+ const usesNative = (context) =>
28
+ context === undefined && native !== null && native.supports(NATIVE_ALG)
29
+
15
30
  const HAS_BUFFER = typeof Buffer !== 'undefined'
16
31
  const enc = new TextEncoder()
17
32
 
@@ -64,6 +79,9 @@ export function keypairFromMaster(master, info = 'ml-dsa-65-v1') {
64
79
  */
65
80
  export function sign(secretKey, message, opts) {
66
81
  const context = normalizeContext(opts)
82
+ if (usesNative(context)) {
83
+ return bytesToHex(native.sign(NATIVE_ALG, secretKey, toBytes(message)))
84
+ }
67
85
  const sig = context === undefined
68
86
  ? ml_dsa65.sign(toBytes(message), secretKey)
69
87
  : ml_dsa65.sign(toBytes(message), secretKey, { context })
@@ -91,6 +109,9 @@ export function verify(publicKey, message, sigHex, opts) {
91
109
  // Outside the try: misuse must surface, not be swallowed as "invalid".
92
110
  const context = normalizeContext(opts)
93
111
  try {
112
+ if (usesNative(context)) {
113
+ return native.verify(NATIVE_ALG, publicKey, toBytes(message), hexToBytes(sigHex))
114
+ }
94
115
  return context === undefined
95
116
  ? ml_dsa65.verify(hexToBytes(sigHex), toBytes(message), publicKey)
96
117
  : ml_dsa65.verify(hexToBytes(sigHex), toBytes(message), publicKey, { context })
@@ -31,6 +31,7 @@
31
31
 
32
32
  import { ml_kem1024 } from '@noble/post-quantum/ml-kem.js'
33
33
  import { deriveSeed } from './derive.js'
34
+ import { native } from '#native'
34
35
 
35
36
  const HAS_BUFFER = typeof Buffer !== 'undefined'
36
37
 
@@ -38,6 +39,15 @@ function wrap(bytes) {
38
39
  return HAS_BUFFER ? Buffer.from(bytes) : bytes
39
40
  }
40
41
 
42
+ // Where the runtime provides the FIPS primitives through OpenSSL (Node 24 and
43
+ // later) they are used in place of the JavaScript backend. Everywhere else,
44
+ // including every browser, `native` is null and nothing about this module
45
+ // changes. The two backends are checked against each other for this parameter
46
+ // set in both directions by the interoperability matrix.
47
+ const NATIVE_ALG = 'ML-KEM-1024'
48
+ const usesNative = () =>
49
+ native !== null && native.supports(NATIVE_ALG)
50
+
41
51
  /**
42
52
  * Generate an ML-KEM-1024 keypair from a master + domain-separation info.
43
53
  *
@@ -63,7 +73,9 @@ export function keypairFromMaster(master, info = 'ml-kem-1024-v1') {
63
73
  * @returns {{ ciphertext: Buffer|Uint8Array, cipherText: Buffer|Uint8Array, sharedSecret: Buffer|Uint8Array }}
64
74
  */
65
75
  export function encapsulate(publicKey) {
66
- const r = ml_kem1024.encapsulate(publicKey)
76
+ const r = usesNative()
77
+ ? native.encapsulate(NATIVE_ALG, publicKey)
78
+ : ml_kem1024.encapsulate(publicKey)
67
79
  const ct = wrap(r.cipherText ?? r.ciphertext)
68
80
  return {
69
81
  ciphertext: ct,
@@ -84,6 +96,9 @@ export function encapsulate(publicKey) {
84
96
  * @returns {Buffer|Uint8Array} shared secret (32 bytes)
85
97
  */
86
98
  export function decapsulate(ciphertext, secretKey) {
99
+ if (usesNative()) {
100
+ return wrap(native.decapsulate(NATIVE_ALG, ciphertext, secretKey))
101
+ }
87
102
  return wrap(ml_kem1024.decapsulate(ciphertext, secretKey))
88
103
  }
89
104
 
package/src/ml-kem.js CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { ml_kem768 } from '@noble/post-quantum/ml-kem.js'
11
11
  import { deriveSeed } from './derive.js'
12
+ import { native } from '#native'
12
13
 
13
14
  const HAS_BUFFER = typeof Buffer !== 'undefined'
14
15
 
@@ -16,6 +17,15 @@ function wrap(bytes) {
16
17
  return HAS_BUFFER ? Buffer.from(bytes) : bytes
17
18
  }
18
19
 
20
+ // Where the runtime provides the FIPS primitives through OpenSSL (Node 24 and
21
+ // later) they are used in place of the JavaScript backend. Everywhere else,
22
+ // including every browser, `native` is null and nothing about this module
23
+ // changes. The two backends are checked against each other for this parameter
24
+ // set in both directions by the interoperability matrix.
25
+ const NATIVE_ALG = 'ML-KEM-768'
26
+ const usesNative = () =>
27
+ native !== null && native.supports(NATIVE_ALG)
28
+
19
29
  /**
20
30
  * Generate an ML-KEM-768 keypair from a master + domain-separation info.
21
31
  *
@@ -38,7 +48,9 @@ export function keypairFromMaster(master, info = 'ml-kem-768-v1') {
38
48
  * @returns {{ ciphertext: Buffer|Uint8Array, cipherText: Buffer|Uint8Array, sharedSecret: Buffer|Uint8Array }}
39
49
  */
40
50
  export function encapsulate(publicKey) {
41
- const r = ml_kem768.encapsulate(publicKey)
51
+ const r = usesNative()
52
+ ? native.encapsulate(NATIVE_ALG, publicKey)
53
+ : ml_kem768.encapsulate(publicKey)
42
54
  const ct = wrap(r.cipherText ?? r.ciphertext)
43
55
  return {
44
56
  ciphertext: ct,
@@ -55,6 +67,9 @@ export function encapsulate(publicKey) {
55
67
  * @returns {Buffer|Uint8Array} shared secret (32 bytes)
56
68
  */
57
69
  export function decapsulate(ciphertext, secretKey) {
70
+ if (usesNative()) {
71
+ return wrap(native.decapsulate(NATIVE_ALG, ciphertext, secretKey))
72
+ }
58
73
  return wrap(ml_kem768.decapsulate(ciphertext, secretKey))
59
74
  }
60
75
 
package/src/slh-dsa.js CHANGED
@@ -16,6 +16,7 @@
16
16
  import { slh_dsa_sha2_192s } from '@noble/post-quantum/slh-dsa.js'
17
17
  import { deriveSeed } from './derive.js'
18
18
  import { normalizeContext, MAX_CONTEXT_BYTES } from './_context.js'
19
+ import { native } from '#native'
19
20
 
20
21
  export { MAX_CONTEXT_BYTES }
21
22
 
@@ -45,6 +46,15 @@ function wrap(bytes) {
45
46
  // Seed length for SLH-DSA-SHA2-192s keygen (FIPS 205: SK.seed || SK.prf || PK.seed).
46
47
  const SEED_BYTES = slh_dsa_sha2_192s.lengths.seed
47
48
 
49
+ // Where the runtime provides the FIPS primitives through OpenSSL (Node 24 and
50
+ // later) they are used in place of the JavaScript backend. Everywhere else,
51
+ // including every browser, `native` is null and nothing about this module
52
+ // changes. The two backends are checked against each other for this parameter
53
+ // set in both directions by the interoperability matrix.
54
+ const NATIVE_ALG = 'SLH-DSA-SHA2-192s'
55
+ const usesNative = (context) =>
56
+ context === undefined && native !== null && native.supports(NATIVE_ALG)
57
+
48
58
  /**
49
59
  * Generate an SLH-DSA-SHA2-192s keypair from a master + domain-separation info.
50
60
  *
@@ -74,6 +84,9 @@ export function keypairFromMaster(master, info = 'slh-dsa-sha2-192s-v1') {
74
84
  */
75
85
  export function sign(secretKey, message, opts) {
76
86
  const context = normalizeContext(opts)
87
+ if (usesNative(context)) {
88
+ return bytesToHex(native.sign(NATIVE_ALG, secretKey, toBytes(message)))
89
+ }
77
90
  const sig = context === undefined
78
91
  ? slh_dsa_sha2_192s.sign(toBytes(message), secretKey)
79
92
  : slh_dsa_sha2_192s.sign(toBytes(message), secretKey, { context })
@@ -95,6 +108,9 @@ export function sign(secretKey, message, opts) {
95
108
  export function verify(publicKey, message, sigHex, opts) {
96
109
  const context = normalizeContext(opts)
97
110
  try {
111
+ if (usesNative(context)) {
112
+ return native.verify(NATIVE_ALG, publicKey, toBytes(message), hexToBytes(sigHex))
113
+ }
98
114
  return context === undefined
99
115
  ? slh_dsa_sha2_192s.verify(hexToBytes(sigHex), toBytes(message), publicKey)
100
116
  : slh_dsa_sha2_192s.verify(hexToBytes(sigHex), toBytes(message), publicKey, { context })