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 +124 -0
- package/CHANGELOG.md +84 -0
- package/CONFORMANCE.md +61 -18
- package/README.md +5 -4
- package/SECURITY.md +22 -0
- package/THREAT-MODEL.md +28 -2
- package/package.json +10 -2
- package/src/_native.node.js +204 -0
- package/src/_native.stub.js +13 -0
- package/src/ml-dsa-87.js +16 -0
- package/src/ml-dsa.js +21 -0
- package/src/ml-kem-1024.js +16 -1
- package/src/ml-kem.js +16 -1
- package/src/slh-dsa.js +16 -0
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
|
|
100
|
-
implementation was available to pin. That is a
|
|
101
|
-
families and is stated rather than averaged
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
and
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
for
|
|
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)
|
|
15
|
-
- [
|
|
16
|
-
- [
|
|
17
|
-
- [
|
|
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:
|
|
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.
|
|
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 })
|
package/src/ml-kem-1024.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 })
|