kxco-post-quantum 1.4.0 → 1.5.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/BENCHMARKS.md ADDED
@@ -0,0 +1,124 @@
1
+ # Performance
2
+
3
+ Per-algorithm latency and memory, reported as tail latency rather than a mean.
4
+
5
+ A mean hides the tail, and the tail is what a request budget has to absorb. For
6
+ ML-DSA that distinction is not cosmetic: signing uses rejection sampling, so it
7
+ loops until the candidate signature falls in range, and the slow tail is inherent
8
+ to the algorithm rather than measurement noise. Anyone sizing a timeout from a
9
+ mean will size it wrong.
10
+
11
+ Reproduce with:
12
+
13
+ ```
14
+ node --expose-gc bench/primitives.mjs --iterations 100 --json bench/results/primitives.json
15
+ ```
16
+
17
+ Every parameter set the package can reach is measured, not only the five it wraps
18
+ in its own helpers.
19
+
20
+ ## Signatures
21
+
22
+ Milliseconds. 100 iterations except where the `n` column says otherwise.
23
+
24
+ | Algorithm | Operation | n | median | p95 | p99 | ops/s |
25
+ |---|---|---:|---:|---:|---:|---:|
26
+ | ML-DSA-44 | keygen | 100 | 1.695 | 2.847 | 3.317 | 545 |
27
+ | ML-DSA-44 | sign | 100 | 8.880 | 24.104 | 30.193 | 91 |
28
+ | ML-DSA-44 | verify | 100 | 1.554 | 3.000 | 3.650 | 552 |
29
+ | ML-DSA-65 | keygen | 100 | 3.394 | 9.058 | 11.699 | 238 |
30
+ | ML-DSA-65 | sign | 100 | 11.308 | 36.752 | 51.084 | 64 |
31
+ | ML-DSA-65 | verify | 100 | 3.456 | 5.032 | 5.553 | 288 |
32
+ | ML-DSA-87 | keygen | 100 | 5.226 | 8.270 | 9.635 | 194 |
33
+ | ML-DSA-87 | sign | 100 | 13.148 | 33.932 | 42.381 | 62 |
34
+ | ML-DSA-87 | verify | 100 | 4.877 | 11.119 | 21.760 | 167 |
35
+ | SLH-DSA-SHA2-128f | sign | 20 | 151.319 | 194.915 | 205.881 | 7 |
36
+ | SLH-DSA-SHA2-128f | verify | 20 | 6.412 | 10.672 | 30.298 | 121 |
37
+ | SLH-DSA-SHA2-192s | sign | 3 | 6788.860 | 7229.209 | 7229.209 | 0 |
38
+ | SLH-DSA-SHA2-192s | verify | 10 | 4.997 | 62.287 | 62.287 | 89 |
39
+ | SLH-DSA-SHAKE-256f | sign | 5 | 3333.195 | 3786.271 | 3786.271 | 0 |
40
+ | SLH-DSA-SHAKE-256f | verify | 10 | 72.219 | 86.779 | 86.779 | 14 |
41
+
42
+ **Two numbers to design around.**
43
+
44
+ **ML-DSA signing has a long tail.** ML-DSA-65 signs in 11 ms at the median and
45
+ 51 ms at p99, a factor of 4.5. Rejection sampling means an unlucky signature does
46
+ several more rounds. Size request budgets from p99, not the median.
47
+
48
+ **SLH-DSA-SHA2-192s signs in about 6.8 seconds.** That is the set `slhDsa` wraps,
49
+ and it is not a per-request operation. It suits infrequent, high-value signatures
50
+ such as firmware or root attestations. Verification is cheap, around 5 ms, so an
51
+ SLH-DSA signature is expensive to make and cheap to check. The `f` variants trade
52
+ signature size for signing speed: SHA2-128f signs in 151 ms.
53
+
54
+ ## Key encapsulation
55
+
56
+ | Algorithm | Operation | n | median | p95 | p99 | ops/s |
57
+ |---|---|---:|---:|---:|---:|---:|
58
+ | ML-KEM-512 | keygen | 100 | 0.338 | 0.969 | 1.277 | 2222 |
59
+ | ML-KEM-512 | encapsulate | 100 | 0.512 | 1.043 | 1.617 | 1720 |
60
+ | ML-KEM-512 | decapsulate | 100 | 0.845 | 2.133 | 2.561 | 965 |
61
+ | ML-KEM-768 | keygen | 100 | 0.645 | 1.357 | 1.757 | 1344 |
62
+ | ML-KEM-768 | encapsulate | 100 | 0.822 | 2.050 | 5.067 | 971 |
63
+ | ML-KEM-768 | decapsulate | 100 | 1.292 | 2.750 | 4.048 | 674 |
64
+ | ML-KEM-1024 | keygen | 100 | 1.062 | 2.043 | 2.776 | 860 |
65
+ | ML-KEM-1024 | encapsulate | 100 | 1.066 | 1.902 | 10.844 | 641 |
66
+ | ML-KEM-1024 | decapsulate | 100 | 0.917 | 1.813 | 2.169 | 918 |
67
+
68
+ ML-KEM is sub-millisecond at the median across all three sets. Moving from
69
+ Category 3 to Category 5 costs well under a millisecond per operation, so the
70
+ migration cost of ML-KEM-1024 is its 1568-byte keys and ciphertexts, not its
71
+ speed. See [MIGRATION.md](MIGRATION.md).
72
+
73
+ ## Wrapper overhead
74
+
75
+ The package helpers derive keys from a master secret through HKDF, which the raw
76
+ primitives do not. That is the only overhead they add:
77
+
78
+ | Helper | median | raw keygen median | HKDF cost |
79
+ |---|---:|---:|---:|
80
+ | `mlDsa.keypairFromMaster` | 3.945 | 3.394 | ~0.55 ms |
81
+ | `mlKem.keypairFromMaster` | 1.129 | 0.645 | ~0.48 ms |
82
+
83
+ Signing and verification go straight through, so they carry no wrapper cost
84
+ beyond hex encoding.
85
+
86
+ ## Memory
87
+
88
+ Heap growth per operation, in bytes, from `process.memoryUsage().heapUsed` across
89
+ a batch:
90
+
91
+ | Algorithm | keygen | sign or encapsulate |
92
+ |---|---:|---:|
93
+ | ML-KEM-768 | ~11.7 kB | ~13.0 kB |
94
+ | ML-KEM-1024 | ~20.6 kB | ~6.1 kB |
95
+ | ML-DSA-65 | ~12.9 kB | ~21.0 kB |
96
+ | ML-DSA-87 | ~3.3 kB | ~11.4 kB |
97
+ | SLH-DSA-SHA2-128f | ~143 kB | |
98
+ | SLH-DSA-SHA2-192s | ~192 kB | |
99
+ | SLH-DSA-SHAKE-256f | ~864 kB | |
100
+
101
+ SLH-DSA allocates one to three orders of magnitude more than the lattice schemes,
102
+ which is consistent with building a hypertree per key. The lattice figures are
103
+ tens of kilobytes and are not a constraint at these rates.
104
+
105
+ **Read these as orders of magnitude, not exact allocations.** Garbage collection
106
+ can run mid-batch, so a figure smaller than a sibling's does not reliably mean
107
+ less allocation. ML-DSA-87 keygen reading lower than ML-DSA-65 is an artefact of
108
+ collection timing, not evidence that the larger parameter set allocates less.
109
+
110
+ ## What these figures are not
111
+
112
+ - **One machine, one run.** Node v26.1.0 on win32-x64. Absolute numbers move with
113
+ hardware and runtime; the ratios between operations are the portable part.
114
+ - **Not a cross-vendor comparison.** Nothing here was measured against another
115
+ implementation, so it says nothing about how this compares to a native or
116
+ hardware-backed stack. It will be slower than both.
117
+ - **Reduced samples where marked.** SLH-DSA slow variants take 3 to 20 samples
118
+ rather than 100, because 100 signatures at 6.8 seconds each is not a benchmark,
119
+ it is an afternoon. Where n is small, p95 and p99 collapse onto the maximum and
120
+ are reported that way rather than dressed up.
121
+ - **Not a side-channel measurement.** Timing here is throughput, gathered without
122
+ any attempt to detect secret-dependent variation, and it must not be read as
123
+ evidence about constant-time behaviour. See [THREAT-MODEL.md](THREAT-MODEL.md),
124
+ which states plainly that no such property is claimed.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,89 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0
4
+
5
+ **The FIPS primitives now run in OpenSSL where the runtime has them.** On Node 24
6
+ and later this package uses OpenSSL 3.5 for ML-KEM, ML-DSA and SLH-DSA. On Node
7
+ 20 and 22, in browsers, and on any runtime without them, the JavaScript backend
8
+ is used exactly as before.
9
+
10
+ Additive. No export changed, no signature changed, no key format changed, and
11
+ `@noble/post-quantum` is still a dependency and still the backend on every
12
+ runtime that lacks the native primitives. Nothing is removed.
13
+
14
+ **Both backends are checked against each other rather than assumed to agree.**
15
+ The interoperability matrix runs in full on both, against liboqs, Bouncy Castle
16
+ and dilithium-py / kyber-py, and both return **225 passed, 0 failed, 42 not
17
+ applicable across 38 rows**. CI runs both legs. Every generated report now
18
+ records which backend produced it under `wrapperBackend`, because a run that
19
+ does not say is not evidence about either one.
20
+
21
+ Keys and signatures are unchanged on the wire, which is what makes this safe to
22
+ do silently: a signature made by one backend verifies under the other, in both
23
+ directions, for all nine parameter sets. Existing keys keep working. Nothing
24
+ needs migrating.
25
+
26
+ Measured on the development machine:
27
+
28
+ | | OpenSSL | JavaScript | |
29
+ |---|---|---|---|
30
+ | ML-DSA-65 sign | 1.34 ms | 11.54 ms | 8.6x |
31
+ | ML-DSA-65 verify | 0.28 ms | 2.22 ms | 7.9x |
32
+ | SLH-DSA-SHA2-192s sign | 1595 ms | 4717 ms | 3.0x |
33
+
34
+ **A FIPS 204 context string keeps the JavaScript path.** Node's `sign` and
35
+ `verify` take no context argument, and signing without the caller's context
36
+ would produce a signature that verifies against nothing. That is a deliberate
37
+ fallback, not a gap.
38
+
39
+ **THREAT-MODEL.md is updated, and the change is narrower than it looks.** That
40
+ document argued the timing and cache attacker was out of scope *structurally*,
41
+ because the property cannot be established from inside JavaScript. On the
42
+ OpenSSL path that argument no longer applies. It does not follow that this
43
+ package is constant-time: we have published no timing measurements, and
44
+ OpenSSL's side-channel posture is theirs to state rather than ours to assert.
45
+ The honest position is that the attacker moves from structurally out of scope to
46
+ **unmeasured** on Node 24+, and stays out of scope everywhere else.
47
+
48
+ ## 1.4.1
49
+
50
+ Evidence and documentation only. **No `src/` module changed**, no export was
51
+ added or removed, and the dependency set is unchanged, so no call site can
52
+ behave differently than it did on 1.4.0.
53
+
54
+ **liboqs is now a third interop implementation.** The cross-implementation
55
+ matrix ran against two peers and now runs against three: liboqs 0.16.0 (C),
56
+ Bouncy Castle 1.85.2 (Java) and dilithium-py 1.4.0 / kyber-py 1.2.0 (Python).
57
+ **225 checks passed, 0 failed, 42 not applicable, across 38 rows**, up from
58
+ 156/0/10 across 24. The previous figures are reproduced exactly when the new
59
+ peer is excluded, so nothing about the existing evidence moved.
60
+
61
+ SLH-DSA had one peer and now has two. liboqs runs from a container built from
62
+ source at a tag pinned in `peers-lock.json`, and CI builds it, so the version
63
+ tested is the version named.
64
+
65
+ A peer that cannot do something now records as not-applicable rather than as a
66
+ disagreement. The 42 not-applicable are itemised in CONFORMANCE.md: ten from our
67
+ own hedged signing, thirty-two from two liboqs API limits.
68
+
69
+ **AUDIT.md corrections.** The reviewer checklist told anyone doing due diligence
70
+ to fetch an endpoint that returns 500. Verification now goes to Armature L1 over
71
+ public JSON-RPC, with the calldata layout documented and a named article page
72
+ for the other half of the join. Both documented commands were run verbatim
73
+ against production before publishing.
74
+
75
+ Two false claims in the same file are withdrawn. It said we pin
76
+ `@noble/post-quantum@0.6.1`, "the exact version covered by the maintainer's own
77
+ self-audit"; we ship `0.7.0`, the self-audit covers `0.6.1`, and **the version we
78
+ ship is covered by no audit at all**. It also described the dependency as
79
+ "itself audited", contradicting its own section 1.
80
+
81
+ **Supply chain.** Each release now publishes its CycloneDX SBOM as a GitHub
82
+ Release asset at `releases/download/<tag>/sbom.cyclonedx.json`, a permanent
83
+ unauthenticated URL. It was previously generated but retained only as an
84
+ expiring Actions artifact, which is not a published SBOM. The dependency policy
85
+ is now stated in SECURITY.md rather than living only in `dependabot.yml`.
86
+
3
87
  ## 1.4.0
4
88
 
5
89
  Adds the Security Category 5 parameter sets, and publishes conformance and
package/CONFORMANCE.md CHANGED
@@ -5,7 +5,7 @@ Two claims, each with a harness in this repository that anyone can run:
5
5
  1. **This package computes what FIPS 203, 204 and 205 say it should.** Evidenced
6
6
  against NIST's own ACVP test vectors.
7
7
  2. **Independent implementations can consume what it produces, and it can
8
- consume theirs.** Evidenced against Bouncy Castle (Java) and
8
+ consume theirs.** Evidenced against liboqs (C), Bouncy Castle (Java) and
9
9
  dilithium-py / kyber-py (Python), in both directions.
10
10
 
11
11
  The second claim is the one that matters in deployment and the one that vector
@@ -93,18 +93,38 @@ Peers, both pinned in
93
93
 
94
94
  | Peer | Implementation | Language | Covers |
95
95
  |---|---|---|---|
96
+ | `liboqs` | `liboqs` 0.16.0 with binding 0.16.0, built from source | C | ML-DSA, ML-KEM, SLH-DSA |
96
97
  | `bouncycastle` | `org.bouncycastle:bcprov-jdk18on:1.85.2`, SHA-256 pinned | Java | ML-DSA, ML-KEM, SLH-DSA |
97
98
  | `python` | `dilithium-py==1.4.0`, `kyber-py==1.2.0` | Python | ML-DSA, ML-KEM |
98
99
 
99
- SLH-DSA is covered by Bouncy Castle only, because no maintained pure-Python
100
- implementation was available to pin. That is a narrower base than the other two
101
- families and is stated rather than averaged away.
100
+ SLH-DSA is covered by liboqs and Bouncy Castle, not by the Python pair, because
101
+ no maintained pure-Python implementation was available to pin. That is a
102
+ narrower base than the other two families and is stated rather than averaged
103
+ away.
102
104
 
103
- Neither shares code with this package's backend. Bouncy Castle is a widely
104
- deployed independent implementation; the Python pair are independent
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
118
+ reference C implementation the wider ecosystem tests against; Bouncy Castle is a
119
+ widely deployed independent implementation; the Python pair are independent
105
120
  spec-derived implementations.
106
121
 
107
- **Result: 156 checks passed, 0 failed, 10 not applicable, across 24 rows.**
122
+ liboqs needs a C toolchain, so its peer runs in a container built from
123
+ [conformance/interop/peers/liboqs.Dockerfile](conformance/interop/peers/liboqs.Dockerfile)
124
+ rather than requiring every contributor to install one. If the image is absent
125
+ the peer reports unavailable, which is not a failure but is also not evidence.
126
+
127
+ **Result: 225 checks passed, 0 failed, 42 not applicable, across 38 rows.**
108
128
 
109
129
  Per row, in both directions:
110
130
 
@@ -130,17 +150,40 @@ Each parameter set this package publishes a helper for is run twice: once
130
150
  through that helper (`wrapper`) and once through the primitive (`backend`), so
131
151
  the evidence covers the published API and not only its dependency.
132
152
 
133
- ### The ten not-applicable checks
134
-
135
- `bytes` on the five `wrapper` rows, in each of two context modes. This package's
136
- `sign` is hedged: it draws fresh randomness per signature, which FIPS 204 permits
137
- and recommends, so its output is deliberately not reproducible. Byte equality is
138
- asserted on the `backend` rows for the same parameter sets, so determinism is
139
- still evidenced everywhere it is meaningful. Reasoning is in
140
- [THREAT-MODEL.md](THREAT-MODEL.md).
141
-
142
- The five wrapper rows are the five parameter sets this package publishes helpers
143
- for: ML-DSA-65, ML-DSA-87, ML-KEM-768, ML-KEM-1024 and SLH-DSA-SHA2-192s.
153
+ ### The forty-two not-applicable checks
154
+
155
+ A check is recorded as not applicable when a peer says it cannot do something,
156
+ never when a peer disagrees with us. A peer that cannot honour a request answers
157
+ `unsupported` and the matrix records N/A; any other error is a failure and is
158
+ counted as one.
159
+
160
+ **Ten from hedged signing.** This package's `sign` is hedged: it draws fresh
161
+ randomness per signature, which FIPS 204 permits and recommends, so its output is
162
+ deliberately not reproducible. Byte equality is asserted on the `backend` rows
163
+ for the same parameter sets, so determinism is still evidenced everywhere it is
164
+ meaningful. Reasoning is in [THREAT-MODEL.md](THREAT-MODEL.md).
165
+
166
+ They are the `bytes` check on each signature `wrapper` row, in each of two
167
+ context modes: three such rows against Bouncy Castle (ML-DSA-65, ML-DSA-87,
168
+ SLH-DSA-SHA2-192s) and two against the Python pair, which has no SLH-DSA. The
169
+ KEM wrapper rows carry no `bytes` check, because a KEM produces a fresh
170
+ ciphertext by design and byte equality is not defined for it.
171
+
172
+ **Thirty-two from two liboqs API limits**, both properties of that library
173
+ rather than of this one:
174
+
175
+ - `keys` on all fourteen liboqs rows. liboqs exposes no seed-derived keygen, so
176
+ it cannot rebuild a key from a FIPS 203 / FIPS 204 seed the way the other
177
+ peers do. It is addressed by encoded secret key instead, which still tests
178
+ something real: the private key encodings are themselves standardised, so a
179
+ key of ours that failed to load into liboqs and produce interoperable output
180
+ would be a genuine defect.
181
+ - `bytes` on all eighteen liboqs signature checks. liboqs signs hedged and
182
+ exposes no deterministic mode through its Python binding, so byte equality
183
+ against it is not defined. Bouncy Castle and the Python pair both cover it.
184
+
185
+ Everything else is exercised against liboqs, in both directions, including FIPS
186
+ 204 context strings and both negative controls.
144
187
 
145
188
  ---
146
189
 
package/README.md CHANGED
@@ -11,10 +11,11 @@ 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, and a cross-implementation interop matrix against Bouncy Castle and two pure-Python implementations. 134 interop checks, both directions, with negative controls. Reproducible: `npm run conformance:acvp`, `npm run conformance:interop`.
15
- - [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.
16
- - [MIGRATION.md](./MIGRATION.md) — moving an RSA or ECDSA system across, and moving between versions of this package.
17
- - [SECURITY.md](./SECURITY.md) — reporting, and release integrity.
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
+ - [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
+ - [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
+ - [MIGRATION.md](./MIGRATION.md): moving an RSA or ECDSA system across, and moving between versions of this package.
18
+ - [SECURITY.md](./SECURITY.md): reporting, and release integrity.
18
19
 
19
20
  ---
20
21
 
package/SECURITY.md CHANGED
@@ -36,6 +36,28 @@ Out of scope (report upstream to https://github.com/paulmillr/noble-post-quantum
36
36
  - HMAC-SHA-256
37
37
  - HKDF-SHA-512 (RFC 5869)
38
38
 
39
+ ## Dependency policy
40
+ Both runtime dependencies are pinned to an exact version, never a range:
41
+ `@noble/post-quantum` and `@noble/hashes`. A range would let the code that runs
42
+ the cryptography change without a release of this package, which is not a
43
+ property we are willing to give up for convenience. Every GitHub Action in our
44
+ workflows is pinned by 40-character commit SHA for the same reason.
45
+
46
+ Updates are proposed, never automatic. Dependabot opens pull requests weekly for
47
+ both npm and GitHub Actions, and Dependabot security updates are enabled at the
48
+ repository level so an advisory does not wait for the Monday run. The
49
+ configuration is in [.github/dependabot.yml](.github/dependabot.yml).
50
+
51
+ A dependency bump is not merged on the strength of a green test run alone. A
52
+ change to `@noble/post-quantum` is a change to the primitives themselves, so it
53
+ is gated on the full conformance evidence regenerating clean: the NIST ACVP
54
+ vectors and the cross-implementation interoperability matrix, both described in
55
+ [CONFORMANCE.md](CONFORMANCE.md).
56
+
57
+ Each release publishes a CycloneDX SBOM as a GitHub Release asset, at
58
+ `releases/download/<tag>/sbom.cyclonedx.json`, generated from the tree that was
59
+ actually installed for that build.
60
+
39
61
  ## Disclosure
40
62
  We follow coordinated disclosure with a 90-day default window.
41
63
  For actively-exploited issues we ship a patch release within 48 hours.
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.0",
3
+ "version": "1.5.0",
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",
@@ -95,6 +101,7 @@
95
101
  "README.md",
96
102
  "LICENSE",
97
103
  "CONFORMANCE.md",
104
+ "BENCHMARKS.md",
98
105
  "THREAT-MODEL.md",
99
106
  "MIGRATION.md",
100
107
  "SECURITY.md",
@@ -115,7 +122,8 @@
115
122
  "conformance:fetch": "node conformance/fetch-vectors.mjs",
116
123
  "conformance:acvp": "node conformance/run-acvp.mjs --json conformance/results/acvp.json",
117
124
  "conformance:interop": "node conformance/interop/run-interop.mjs --json conformance/results/interop.json",
118
- "sbom": "npm sbom --sbom-format cyclonedx --sbom-type library"
125
+ "sbom": "npm sbom --sbom-format cyclonedx --sbom-type library",
126
+ "bench:primitives": "node --expose-gc bench/primitives.mjs --iterations 100 --json bench/results/primitives.json"
119
127
  },
120
128
  "publishConfig": {
121
129
  "provenance": true,
@@ -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 })