kxco-post-quantum 1.4.1 → 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/CHANGELOG.md +45 -0
- package/CONFORMANCE.md +13 -1
- package/README.md +1 -1
- package/THREAT-MODEL.md +28 -2
- package/package.json +7 -1
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
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
|
+
|
|
3
48
|
## 1.4.1
|
|
4
49
|
|
|
5
50
|
Evidence and documentation only. **No `src/` module changed**, no export was
|
package/CONFORMANCE.md
CHANGED
|
@@ -102,7 +102,19 @@ no maintained pure-Python implementation was available to pin. That is a
|
|
|
102
102
|
narrower base than the other two families and is stated rather than averaged
|
|
103
103
|
away.
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
### Which backend the matrix exercised
|
|
106
|
+
|
|
107
|
+
From 1.5.0 this package has two backends: OpenSSL 3.5 where the runtime provides
|
|
108
|
+
the FIPS primitives (Node 24 and later) and JavaScript everywhere else. They are
|
|
109
|
+
different implementations, so a matrix run against one is not evidence about the
|
|
110
|
+
other. CI therefore runs the whole matrix on **both**, and every generated report
|
|
111
|
+
records which one it used under `wrapperBackend`. A report that does not say is
|
|
112
|
+
not evidence.
|
|
113
|
+
|
|
114
|
+
Both produce the same result: **225 passed, 0 failed, 42 not applicable**. That
|
|
115
|
+
is the point of running both.
|
|
116
|
+
|
|
117
|
+
None of the three peers shares code with either of this package's backends. liboqs is the
|
|
106
118
|
reference C implementation the wider ecosystem tests against; Bouncy Castle is a
|
|
107
119
|
widely deployed independent implementation; the Python pair are independent
|
|
108
120
|
spec-derived implementations.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ ML-DSA-65 (FIPS 204) and SLH-DSA-SHA2-192s (FIPS 205) signatures, ML-KEM-768 (FI
|
|
|
11
11
|
|
|
12
12
|
**Evidence, not adjectives:**
|
|
13
13
|
|
|
14
|
-
- [CONFORMANCE.md](./CONFORMANCE.md): NIST ACVP vectors for FIPS 203/204/205 (2,103 tests, 0 failed), and a cross-implementation interop matrix against liboqs, Bouncy Castle and two pure-Python implementations (225 checks, 0 failed, both directions, with negative controls). Reproducible: `npm run conformance:acvp`, `npm run conformance:interop`.
|
|
14
|
+
- [CONFORMANCE.md](./CONFORMANCE.md): NIST ACVP vectors for FIPS 203/204/205 (2,103 tests, 0 failed), and a cross-implementation interop matrix against liboqs, Bouncy Castle and two pure-Python implementations (225 checks, 0 failed, both directions, with negative controls), run against both backends. Reproducible: `npm run conformance:acvp`, `npm run conformance:interop`.
|
|
15
15
|
- [BENCHMARKS.md](./BENCHMARKS.md): per-algorithm latency at p95/p99, plus memory. Two figures worth designing around: ML-DSA signing has a 4.5x tail between median and p99, and SLH-DSA-SHA2-192s signs in about 6.8 seconds.
|
|
16
16
|
- [THREAT-MODEL.md](./THREAT-MODEL.md): what this defends against and what it does not. Read the side-channel section before deciding where a signing key lives.
|
|
17
17
|
- [MIGRATION.md](./MIGRATION.md): moving an RSA or ECDSA system across, and moving between versions of this package.
|
package/THREAT-MODEL.md
CHANGED
|
@@ -70,8 +70,34 @@ layout, memory placement or cache behaviour; the JIT may specialise a hot path
|
|
|
70
70
|
on the values flowing through it; the garbage collector may copy secret bytes to
|
|
71
71
|
places the caller cannot reach and cannot clear. Constant-time execution cannot
|
|
72
72
|
be established, let alone maintained across engine versions, from inside the
|
|
73
|
-
language. The backend states this about itself in plain terms:
|
|
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",
|
|
@@ -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 })
|