kxco-post-quantum 1.7.3 → 1.7.6
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 +21 -0
- package/README.md +149 -87
- package/package.json +13 -4
- package/src/_native.node.js +110 -9
- package/src/_native.stub.js +4 -0
- package/src/backend.d.ts +25 -0
- package/src/backend.js +47 -12
- package/src/index.js +1 -1
- package/src/ml-dsa-87.js +8 -3
- package/src/ml-dsa.js +12 -6
- package/src/slh-dsa.js +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.6
|
|
4
|
+
|
|
5
|
+
Documentation. No source change.
|
|
6
|
+
|
|
7
|
+
The release-integrity lines now name the version that SLSA provenance and
|
|
8
|
+
the CycloneDX SBOM start from, and the keyword list drops `quantum-safe`,
|
|
9
|
+
which was removed on purpose in an earlier release.
|
|
10
|
+
|
|
11
|
+
## 1.7.5
|
|
12
|
+
|
|
13
|
+
Documentation. No source change.
|
|
14
|
+
|
|
15
|
+
**The npm page leads with what the package proves.** The first screen now carries the standards, the NIST ACVP and interoperability
|
|
16
|
+
results, the CNSA 2.0 parameter sets, the native OpenSSL 3.5 backend and the
|
|
17
|
+
supply-chain evidence, then the migration dates set by NIST, Executive Order
|
|
18
|
+
14412, OMB M-26-15 and the UK NCSC.
|
|
19
|
+
|
|
20
|
+
A family table maps every KXCO package to the job it does, and a new For
|
|
21
|
+
institutions section sets out the operated services and how to reach us. The
|
|
22
|
+
evidence documents are unchanged and linked from the page.
|
|
23
|
+
|
|
3
24
|
## 1.7.3
|
|
4
25
|
|
|
5
26
|
One resolution fix that consumers could hit, plus the ACVTS tooling and two
|
package/README.md
CHANGED
|
@@ -1,50 +1,33 @@
|
|
|
1
1
|
# kxco-post-quantum
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**NIST post-quantum signatures and key exchange for Node.js and the browser, proven against NIST's own test vectors.**
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/kxco-post-quantum)
|
|
5
|
+
[](https://www.npmjs.com/package/kxco-post-quantum)
|
|
6
|
+
[](https://www.npmjs.com/package/kxco-post-quantum)
|
|
7
|
+
[](./CONFORMANCE.md)
|
|
8
|
+
[](https://www.npmjs.com/package/kxco-post-quantum)
|
|
9
|
+
[](https://securityscorecards.dev/viewer/?uri=github.com/KnightsbridgeAIQ/kxco-post-quantum)
|
|
6
10
|
[](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/actions/workflows/ci.yml)
|
|
7
11
|
[](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/actions/workflows/conformance.yml)
|
|
8
12
|
[](./LICENSE)
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
- **All three NIST standards.** ML-KEM-768 (FIPS 203), ML-DSA-65 (FIPS 204) and SLH-DSA-SHA2-192s (FIPS 205), all at NIST Category 3.
|
|
15
|
+
- **The CNSA 2.0 parameter sets ship.** ML-DSA-87 and ML-KEM-1024 at Category 5, with the same API as the defaults.
|
|
16
|
+
- **1,793 NIST ACVP vectors passed, 0 failed.** The other 310 are pairings the library refuses as weaker than the parameter set. See [CONFORMANCE.md](./CONFORMANCE.md).
|
|
17
|
+
- **Interoperable by test.** 225 checks against liboqs, Bouncy Castle and the Python reference implementations, in both directions, 0 failed. See [CONFORMANCE.md](./CONFORMANCE.md).
|
|
18
|
+
- **Native speed on Node 24.** The maths runs in OpenSSL 3.5 on Node 24 and later, and in JavaScript on Node 20, Node 22 and in browsers, with identical bytes on the wire.
|
|
19
|
+
- **Speaks the formats your stack already parses.** Compact JWS and AKP JWK under the `ML-DSA-65` and `ML-DSA-87` algorithm names, and PKCS#8 seed-form keys.
|
|
20
|
+
- **A supply chain you can check.** Reproducible builds verified in CI, with SLSA provenance and a CycloneDX SBOM on every release since 1.4.1. Apache-2.0, with no licence check and nothing that phones home.
|
|
11
21
|
|
|
12
|
-
**
|
|
22
|
+
**The migration has dates.**
|
|
13
23
|
|
|
14
|
-
**
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
- **NIST** published [FIPS 203](https://csrc.nist.gov/pubs/fips/203/final), [FIPS 204](https://csrc.nist.gov/pubs/fips/204/final) and [FIPS 205](https://csrc.nist.gov/pubs/fips/205/final) in August 2024.
|
|
25
|
+
- **United States:** [Executive Order 14412](https://www.federalregister.gov/documents/2026/06/25/2026-12909/securing-the-nation-against-advanced-cryptographic-attacks), signed on 22 June 2026, moves federal high-value and high-impact systems to post-quantum key establishment by 31 December 2030 and to post-quantum signatures by 31 December 2031. [OMB M-26-15](https://www.whitehouse.gov/wp-content/uploads/2026/06/M-26-15-Execution-of-the-Migration-to-Post-Quantum-Cryptography.pdf) requires PQC-agile libraries for all new applications.
|
|
26
|
+
- **United Kingdom:** the [NCSC](https://www.ncsc.gov.uk/guidance/pqc-migration-timelines) sets 2028, 2031 and 2035 as its migration milestones.
|
|
17
27
|
|
|
18
|
-
|
|
19
|
-
# the full evidence bundle for the current release
|
|
20
|
-
curl -sLO https://github.com/KnightsbridgeAIQ/kxco-post-quantum/releases/latest/download/evidence-node24.x.zip
|
|
21
|
-
|
|
22
|
-
# or just the manifest: every file digest, and which backend produced the results
|
|
23
|
-
curl -sL https://github.com/KnightsbridgeAIQ/kxco-post-quantum/releases/latest/download/manifest-node24.x.json
|
|
24
|
-
|
|
25
|
-
# licence and provenance, straight from the registry
|
|
26
|
-
npm view kxco-post-quantum license # Apache-2.0
|
|
27
|
-
npm audit signatures --json # assert invalid:0 and missing:0
|
|
28
|
-
```
|
|
28
|
+
This is the primitive layer every other `kxco-pq-*` package builds on.
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
check for each: the licence is **Apache-2.0**, not commercial; the SLH-DSA
|
|
32
|
-
parameter set is **SLH-DSA-SHA2-192s**, a real FIPS 205 name, not
|
|
33
|
-
`SLH-DSA-128s`; ML-DSA-65 and ML-KEM-768 are **NIST Category 3**, and
|
|
34
|
-
ML-DSA-87 and ML-KEM-1024, also shipped, are Category 5; the implementation
|
|
35
|
-
languages are **JavaScript and C** (OpenSSL 3.5 on Node 24+).
|
|
36
|
-
|
|
37
|
-
**Evidence, not adjectives:**
|
|
38
|
-
|
|
39
|
-
- [CONFORMANCE.md](./CONFORMANCE.md): NIST ACVP vectors for FIPS 203/204/205: **2,103 vectors, 1,793 passed, 0 failed, 310 skipped**, where every skip is this library refusing a pre-hash weaker than the parameter set and is listed individually with its reason. CONFORMANCE.md says a skip is not a pass, so the headline says so too. Of those, **1,551 are in the downloadable evidence bundle**, measured from the published v1.6.3 assets: 855 in `02-conformance-acvp.json`, 624 in `02b-conformance-acvp-fips205.json` and a 72-vector sample of SLH-DSA signature generation in `02d-conformance-acvp-fips205-siggen.json`. Signature generation is sampled rather than shipped whole because it signs in seconds per operation; the full set is reproduced on demand with `node conformance/run-acvp.mjs --set SLH-DSA-sigGen-FIPS205`. The bundle names that gap rather than leaving it to be noticed. Plus 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`.
|
|
40
|
-
- [BENCHMARKS.md](./BENCHMARKS.md): per-algorithm latency at p95/p99 on both backends and on x86-64 and arm64, plus memory. Two figures worth designing around: ML-DSA signing keeps a rejection-sampling tail on either backend (5.1x median-to-p99 in JavaScript, 3.5x on OpenSSL), and SLH-DSA-SHA2-192s signs in seconds rather than milliseconds (4.3 s and 1.7 s).
|
|
41
|
-
- [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.
|
|
42
|
-
- [MIGRATION.md](./MIGRATION.md): moving an RSA or ECDSA system across, and moving between versions of this package.
|
|
43
|
-
- [SECURITY.md](./SECURITY.md): reporting, release integrity, and the dependency policy.
|
|
44
|
-
- [AGILITY.md](./AGILITY.md): what has to change when the algorithm changes. The replacement plan, the mechanisms that exist today, the transition peers can follow, and the four kinds of agility this package does not give you.
|
|
45
|
-
- [BOUNDARY.md](./BOUNDARY.md): which cryptography this package performs, which it depends on, and which it merely offers to a caller. Release signing is ML-DSA-65; the transport that delivers the release is classical TLS, and that is stated rather than folded into the claim.
|
|
46
|
-
- [LIFECYCLE.md](./LIFECYCLE.md): supported versions, the runtime ceiling, and the one blocking supplier dependency with its mitigations. Read the roadmap beside a maturity claim, not after it.
|
|
47
|
-
- **Every release is reproducible and attested.** The published tarball rebuilds bit-for-bit from its own tag, verified in CI on every run, and each release carries a SLSA provenance attestation plus a CycloneDX SBOM at a permanent unauthenticated URL. A provenance attestation says a build happened in CI; the reproducible build says the artefact is the source. They are different claims and both are checkable without asking us for anything.
|
|
30
|
+
[Conformance](./CONFORMANCE.md) · [Benchmarks](./BENCHMARKS.md) · [Migration](./MIGRATION.md) · [Threat model](./THREAT-MODEL.md) · [Changelog](./CHANGELOG.md) · [For institutions](#for-institutions) · [kxco.ai](https://kxco.ai)
|
|
48
31
|
|
|
49
32
|
---
|
|
50
33
|
|
|
@@ -63,12 +46,12 @@ Requires Node.js 20.19+. ESM-only.
|
|
|
63
46
|
```js
|
|
64
47
|
import { mlDsa, mlKem, slhDsa, fingerprint, kidEquals } from 'kxco-post-quantum'
|
|
65
48
|
|
|
66
|
-
// ML-DSA-65
|
|
49
|
+
// ML-DSA-65: sign and verify
|
|
67
50
|
const { publicKey, secretKey } = mlDsa.keypairFromMaster(masterSecret, 'signing-v1')
|
|
68
51
|
const sig = mlDsa.sign(secretKey, 'hello')
|
|
69
52
|
const ok = mlDsa.verify(publicKey, 'hello', sig) // true
|
|
70
53
|
|
|
71
|
-
// SLH-DSA-SHA2-192s
|
|
54
|
+
// SLH-DSA-SHA2-192s: hash-based signatures (same API shape as mlDsa)
|
|
72
55
|
const slh = slhDsa.keypairFromMaster(masterSecret, 'signing-v1')
|
|
73
56
|
const slhSig = slhDsa.sign(slh.secretKey, 'hello')
|
|
74
57
|
const slhOk = slhDsa.verify(slh.publicKey, 'hello', slhSig) // true
|
|
@@ -77,14 +60,14 @@ const slhOk = slhDsa.verify(slh.publicKey, 'hello', slhSig) // true
|
|
|
77
60
|
const kid = fingerprint(publicKey) // e.g. '4a7c9e2f1b3d5680'
|
|
78
61
|
kidEquals(kid, kid) // true (constant-time)
|
|
79
62
|
|
|
80
|
-
// ML-KEM-768
|
|
63
|
+
// ML-KEM-768: key encapsulation
|
|
81
64
|
const kemKeys = mlKem.keypairFromMaster(masterSecret, 'encryption-v1')
|
|
82
65
|
const { ciphertext, sharedSecret } = mlKem.encapsulate(kemKeys.publicKey)
|
|
83
66
|
const recovered = mlKem.decapsulate(ciphertext, kemKeys.secretKey)
|
|
84
67
|
// sharedSecret and recovered are the same 32 bytes
|
|
85
68
|
```
|
|
86
69
|
|
|
87
|
-
`masterSecret` is a
|
|
70
|
+
`masterSecret` is a Node Buffer or typed array (Uint8Array) with at least 16 bytes of entropy (typically 32–64 bytes from an env var or KMS).
|
|
88
71
|
|
|
89
72
|
### Category 5 parameter sets
|
|
90
73
|
|
|
@@ -103,17 +86,17 @@ mlDsa87.verify(publicKey, 'hello', sig) // true
|
|
|
103
86
|
|
|
104
87
|
| | Category 3 (default) | Category 5 |
|
|
105
88
|
|---|---|---|
|
|
106
|
-
| Signatures | `mlDsa
|
|
107
|
-
| Key encapsulation | `mlKem
|
|
89
|
+
| Signatures | `mlDsa`: pk 1952, sig 3309 | `mlDsa87`: pk 2592, sig 4627 |
|
|
90
|
+
| Key encapsulation | `mlKem`: pk 1184, ct 1088 | `mlKem1024`: pk 1568, ct 1568 |
|
|
108
91
|
|
|
109
92
|
The two sets do not mix, deliberately. Default derivation info differs, so one
|
|
110
93
|
master yields unrelated keys for each; and a signature from one set does not
|
|
111
94
|
verify under the other. Sizes are the migration cost, so check any fixed-width
|
|
112
95
|
signature or key field before mixing sets in one system.
|
|
113
96
|
|
|
114
|
-
**CNSA 2.0 names ML-DSA-87 and ML-KEM-1024
|
|
115
|
-
2.0
|
|
116
|
-
|
|
97
|
+
**CNSA 2.0 names ML-DSA-87 and ML-KEM-1024**, so moving a deployment to the
|
|
98
|
+
CNSA 2.0 parameter sets is a change of import. See
|
|
99
|
+
[CONFORMANCE.md](./CONFORMANCE.md).
|
|
117
100
|
|
|
118
101
|
### Context strings (FIPS 204 / FIPS 205)
|
|
119
102
|
|
|
@@ -142,7 +125,7 @@ should not share a key at all.
|
|
|
142
125
|
Strings are encoded as UTF-8, so the 255-byte limit is bytes and not
|
|
143
126
|
characters. Over-length or wrongly typed input throws (`RangeError` /
|
|
144
127
|
`TypeError`) rather than returning `false`, because that is a caller bug and not
|
|
145
|
-
a
|
|
128
|
+
a bad signature:
|
|
146
129
|
|
|
147
130
|
```js
|
|
148
131
|
mlDsa.sign(secretKey, 'hello', 'kxco-nexus-v1') // throws TypeError
|
|
@@ -155,22 +138,43 @@ separation.
|
|
|
155
138
|
|
|
156
139
|
---
|
|
157
140
|
|
|
141
|
+
## For institutions
|
|
142
|
+
|
|
143
|
+
The cryptography is free under Apache-2.0, works offline and needs nothing from
|
|
144
|
+
KXCO, now or in ten years. What KXCO sells is the part that has to be operated:
|
|
145
|
+
an answer about the present.
|
|
146
|
+
|
|
147
|
+
| Service | What you get |
|
|
148
|
+
|---|---|
|
|
149
|
+
| Hosted key registry | Whether a key is active, revoked or rotated, answered at verification time |
|
|
150
|
+
| Meta-transaction relay | KXCO validates your signed intent, pays the gas and submits it, so you never hold a token or run a node |
|
|
151
|
+
| On-chain anchoring | A timestamp on Armature L1 that the chain itself has verified |
|
|
152
|
+
| Live revocation | `anchored+live` verification, which confirms the signing key is still trusted now |
|
|
153
|
+
| Support and SLA | Availability commitments, an escalation path and a named contact |
|
|
154
|
+
|
|
155
|
+
Priced in USD, per seat, per year. No tokens, no nodes and no wallets. The line
|
|
156
|
+
between free and paid is set out in [LICENCE-PRODUCT.md](./LICENCE-PRODUCT.md).
|
|
157
|
+
|
|
158
|
+
**Talk to us: [admin@kxco.ai](mailto:admin@kxco.ai)** · [kxco.ai](https://kxco.ai)
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
158
162
|
## API
|
|
159
163
|
|
|
160
|
-
### `mlDsa
|
|
164
|
+
### `mlDsa`: ML-DSA-65 signatures (NIST FIPS 204)
|
|
161
165
|
|
|
162
166
|
| Export | Signature | Description |
|
|
163
167
|
|---|---|---|
|
|
164
|
-
| `keypairFromMaster` | `(master, info?) → { publicKey, secretKey, seed }` | Deterministic keypair via HKDF-SHA-512. `info` defaults to `'ml-dsa-65-v1'`. `seed` is the 32 bytes the pair was expanded from
|
|
168
|
+
| `keypairFromMaster` | `(master, info?) → { publicKey, secretKey, seed }` | Deterministic keypair via HKDF-SHA-512. `info` defaults to `'ml-dsa-65-v1'`. `seed` is the 32 bytes the pair was expanded from. See [`seed`](#seed-seed-form-keys-rfc-9964-lamps). |
|
|
165
169
|
| `sign` | `(secretKey, message) → string` | Signs a message. Returns a hex-encoded signature (6618 chars). |
|
|
166
170
|
| `verify` | `(publicKey, message, sigHex) → boolean` | Verifies a hex-encoded signature. Returns `false` on any failure. |
|
|
167
171
|
| `ml_dsa65` | raw primitive | The underlying `@noble/post-quantum` primitive, re-exported. |
|
|
168
172
|
|
|
169
173
|
`publicKey` is 1952 bytes. `secretKey` is 4032 bytes. `message` accepts `Buffer`, `Uint8Array`, or `string`.
|
|
170
174
|
|
|
171
|
-
### `slhDsa
|
|
175
|
+
### `slhDsa`: SLH-DSA-SHA2-192s signatures (NIST FIPS 205)
|
|
172
176
|
|
|
173
|
-
Hash-based, stateless signatures. Security Category 3 (matching ML-DSA-65),
|
|
177
|
+
Hash-based, stateless signatures. Security Category 3 (matching ML-DSA-65), with security resting only on the SHA-2 hash function: no lattice or number-theoretic assumptions. Use it as a conservative hedge alongside `mlDsa`. Signatures are 16,224 bytes against 3,309 for ML-DSA-65, per [FIPS 205](https://csrc.nist.gov/pubs/fips/205/final) and [FIPS 204](https://csrc.nist.gov/pubs/fips/204/final), so `mlDsa` stays the default for high-volume signing.
|
|
174
178
|
|
|
175
179
|
| Export | Signature | Description |
|
|
176
180
|
|---|---|---|
|
|
@@ -181,7 +185,7 @@ Hash-based, stateless signatures. Security Category 3 (matching ML-DSA-65), but
|
|
|
181
185
|
|
|
182
186
|
`publicKey` is 48 bytes. `secretKey` is 96 bytes. `message` accepts `Buffer`, `Uint8Array`, or `string`.
|
|
183
187
|
|
|
184
|
-
### `mlKem
|
|
188
|
+
### `mlKem`: ML-KEM-768 key encapsulation (NIST FIPS 203)
|
|
185
189
|
|
|
186
190
|
| Export | Signature | Description |
|
|
187
191
|
|---|---|---|
|
|
@@ -198,18 +202,18 @@ First 16 hex characters of SHA-256 of the public key. Stable for the lifetime of
|
|
|
198
202
|
|
|
199
203
|
### `kidEquals(a, b)` → `boolean`
|
|
200
204
|
|
|
201
|
-
Constant-time comparison of two kid strings. Use this when comparing user-supplied input
|
|
205
|
+
Constant-time comparison of two kid strings. Use this when comparing user-supplied input, in place of `===`.
|
|
202
206
|
|
|
203
207
|
### `deriveSeed(master, info, length)` → `Buffer`
|
|
204
208
|
|
|
205
209
|
HKDF-SHA-512 derivation. `master` must be at least 16 bytes. `info` is a required domain-separation string. Returns `length` bytes.
|
|
206
210
|
|
|
207
|
-
### `seed
|
|
211
|
+
### `seed`: seed-form keys (RFC 9964, LAMPS)
|
|
208
212
|
|
|
209
213
|
FIPS 203 and 204 expand a keypair from a short seed. The expanded private key
|
|
210
214
|
this package returns is derived from that seed and does not contain it, so a
|
|
211
215
|
seed cannot be recovered from an expanded key. `keypairFromMaster` therefore
|
|
212
|
-
returns the seed it derived alongside the pair
|
|
216
|
+
returns the seed it derived alongside the pair. That is additive, so callers that
|
|
213
217
|
destructure `{ publicKey, secretKey }` are unaffected.
|
|
214
218
|
|
|
215
219
|
Seed form is 32 bytes for ML-DSA and 64 for ML-KEM. It fits in a KMS secret, an
|
|
@@ -239,7 +243,7 @@ const jwk = seed.exportJwk('ML-DSA-65', key, { kid: fingerprint(key.publicKey) }
|
|
|
239
243
|
// { kty: 'AKP', alg: 'ML-DSA-65', pub: '...', priv: '<32-byte seed>', kid: '...' }
|
|
240
244
|
```
|
|
241
245
|
|
|
242
|
-
### `jws
|
|
246
|
+
### `jws`: compact JWS with the RFC 9964 algorithm names
|
|
243
247
|
|
|
244
248
|
Format only. A token signed here verifies in any process holding the public
|
|
245
249
|
key, offline, with no configuration and no licence. RFC 9964 registered
|
|
@@ -258,17 +262,17 @@ token, so a token cannot name its own verification routine. `crit` and `b64`
|
|
|
258
262
|
headers are refused rather than ignored, and the public key's length must match
|
|
259
263
|
the algorithm the header declares.
|
|
260
264
|
|
|
261
|
-
|
|
262
|
-
|
|
265
|
+
The JWS algorithms are ML-DSA-65 and ML-DSA-87, the parameter sets sized for a
|
|
266
|
+
request path.
|
|
263
267
|
|
|
264
268
|
### `backend()` and `isNative(alg)`
|
|
265
269
|
|
|
266
|
-
Reports which implementation is doing the maths in this process
|
|
270
|
+
Reports which implementation is doing the maths in this process: `openssl`
|
|
267
271
|
with its version and parameter sets, or `javascript` with the reason the native
|
|
268
|
-
backend is unavailable. For evidence bundles and support tickets. It reports
|
|
269
|
-
|
|
272
|
+
backend is unavailable. For evidence bundles and support tickets. It reports,
|
|
273
|
+
and the operator selects, as the next section shows.
|
|
270
274
|
|
|
271
|
-
### `webhook
|
|
275
|
+
### `webhook`: hybrid HMAC + ML-DSA-65 delivery signing
|
|
272
276
|
|
|
273
277
|
Low-level helpers for the KXCO hybrid webhook pattern: `envelope`, `hmacHex`, `verifyHmac`, `pqSign`, `verifyPq`, `signDelivery`, `verifyDelivery`. HMAC-SHA-256 gives symmetric verification with no library dependency; ML-DSA-65 adds non-repudiation over the same `${timestamp}.${body}` envelope. The full identity/credential surface lives in `kxco-pq-sdk`.
|
|
274
278
|
|
|
@@ -281,9 +285,8 @@ runtime provides the FIPS 203/204/205 primitives, the JavaScript implementation
|
|
|
281
285
|
otherwise. Both produce identical wire bytes, so falling back is the right
|
|
282
286
|
default and nothing about a signature changes.
|
|
283
287
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
means the control is not in force and nothing says so.
|
|
288
|
+
For a deployment under a control that says cryptography must execute inside a
|
|
289
|
+
validated module, make the native backend a requirement:
|
|
287
290
|
|
|
288
291
|
```js
|
|
289
292
|
import { requireNativeBackend } from 'kxco-post-quantum'
|
|
@@ -306,37 +309,82 @@ KXCO_PQ_REQUIRE_NATIVE=1
|
|
|
306
309
|
Set that and a process which has landed on the JavaScript backend fails at
|
|
307
310
|
import, before its first signature rather than after.
|
|
308
311
|
|
|
309
|
-
**
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
312
|
+
**It asserts that OpenSSL is doing the maths** and removes the silent fallback.
|
|
313
|
+
Pair it with the validated OpenSSL build your control names, and the control is
|
|
314
|
+
enforced at import.
|
|
315
|
+
|
|
316
|
+
### Pinning the implementation your certificate names
|
|
317
|
+
|
|
318
|
+
`requireNativeBackend()` only ever asserts OpenSSL. Both implementations are
|
|
319
|
+
being taken to algorithm validation, so a deployment under a control that names
|
|
320
|
+
a certificate has to be able to pin whichever one its certificate covers, and
|
|
321
|
+
for some that is the JavaScript implementation.
|
|
316
322
|
|
|
317
|
-
|
|
323
|
+
```
|
|
324
|
+
KXCO_PQ_BACKEND=javascript never use OpenSSL, even where it is present
|
|
325
|
+
KXCO_PQ_BACKEND=openssl prefer OpenSSL, which is the default anyway
|
|
326
|
+
```
|
|
318
327
|
|
|
319
|
-
|
|
320
|
-
|
|
328
|
+
```js
|
|
329
|
+
import { requireBackend } from 'kxco-post-quantum'
|
|
330
|
+
|
|
331
|
+
requireBackend('javascript') // the JS certificate
|
|
332
|
+
requireBackend('openssl', ['ML-DSA-65', 'ML-KEM-768']) // the native one
|
|
333
|
+
```
|
|
321
334
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
- [`kxco-pq-hsm`](https://www.npmjs.com/package/kxco-pq-hsm) to hold the key in hardware
|
|
335
|
+
A value that is neither throws at import, so a misspelled pin is caught before
|
|
336
|
+
the first signature.
|
|
325
337
|
|
|
326
|
-
|
|
338
|
+
The environment selects, the function asserts, and they are deliberately kept
|
|
339
|
+
apart: `requireBackend('javascript')` fails on an unpinned OpenSSL process
|
|
340
|
+
rather than switching it. Application code cannot quietly change which
|
|
341
|
+
implementation your evidence is about.
|
|
327
342
|
|
|
328
|
-
|
|
343
|
+
Nothing here changes what a signature looks like. The two produce identical
|
|
344
|
+
wire bytes, which the interoperability matrix proves for every parameter set in
|
|
345
|
+
both directions, and a signature made under the pin verifies on the other
|
|
346
|
+
backend. What changes is which one computed it, and `backend()` always reports
|
|
347
|
+
that truthfully, including when the answer is the result of a pin:
|
|
329
348
|
|
|
330
|
-
|
|
331
|
-
|
|
349
|
+
```js
|
|
350
|
+
backend()
|
|
351
|
+
// { kind: 'javascript', library: '@noble/post-quantum', pinned: 'javascript',
|
|
352
|
+
// reason: 'KXCO_PQ_BACKEND=javascript pins this process to the JavaScript backend' }
|
|
353
|
+
```
|
|
332
354
|
|
|
333
|
-
|
|
355
|
+
That `reason` keeps pinned and unavailable apart, so an evidence bundle records
|
|
356
|
+
exactly which one applied.
|
|
357
|
+
|
|
358
|
+
## The KXCO post-quantum family
|
|
359
|
+
|
|
360
|
+
This is the primitive layer: keys, signatures, encapsulation and fingerprints.
|
|
361
|
+
Install it directly when you need ML-DSA or ML-KEM on their own, or pick the
|
|
362
|
+
package that matches the job.
|
|
363
|
+
|
|
364
|
+
| You need to | Install |
|
|
365
|
+
|---|---|
|
|
366
|
+
| Put the whole stack in one install | [`kxco-pq`](https://www.npmjs.com/package/kxco-pq) |
|
|
367
|
+
| Use ML-DSA, ML-KEM and SLH-DSA directly | [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum) |
|
|
368
|
+
| Keep signing keys on the HSM you already run | [`kxco-pq-hsm`](https://www.npmjs.com/package/kxco-pq-hsm) |
|
|
369
|
+
| Sign a document or record anyone can verify offline | [`kxco-pq-attest`](https://www.npmjs.com/package/kxco-pq-attest) |
|
|
370
|
+
| Keep a tamper-evident audit trail | [`kxco-pq-audit`](https://www.npmjs.com/package/kxco-pq-audit) |
|
|
371
|
+
| Verify a signature in a browser, with no server | [`kxco-verify`](https://www.npmjs.com/package/kxco-verify) |
|
|
372
|
+
| Issue institution identity credentials | [`kxco-pq-sdk`](https://www.npmjs.com/package/kxco-pq-sdk) |
|
|
373
|
+
| Encrypt files and payloads to one or many recipients | [`kxco-pq-vault`](https://www.npmjs.com/package/kxco-pq-vault) |
|
|
374
|
+
| Encrypt Node streams and WebSockets | [`kxco-pq-tls`](https://www.npmjs.com/package/kxco-pq-tls) |
|
|
375
|
+
| Sign and verify webhooks | [`kxco-post-quantum-webhook`](https://www.npmjs.com/package/kxco-post-quantum-webhook) |
|
|
376
|
+
| Give an AI agent an identity a verified institution sponsors | [`kxco-pq-agent`](https://www.npmjs.com/package/kxco-pq-agent) |
|
|
377
|
+
| Have Armature L1 verify a signature in consensus | [`kxco-pq-chain`](https://www.npmjs.com/package/kxco-pq-chain) |
|
|
378
|
+
| Prove an envelope at three levels, offline to on-chain | [`kxco-pq-network`](https://www.npmjs.com/package/kxco-pq-network) |
|
|
379
|
+
| Generate and rotate keys from a terminal | [`kxco-pq-cli`](https://www.npmjs.com/package/kxco-pq-cli) |
|
|
380
|
+
| Find quantum-vulnerable cryptography in a dependency tree | [`kxco-pq-scan`](https://www.npmjs.com/package/kxco-pq-scan) |
|
|
381
|
+
| Fail the build when code reaches past the wrapper | [`eslint-plugin-kxco-pq`](https://www.npmjs.com/package/eslint-plugin-kxco-pq) |
|
|
334
382
|
|
|
335
383
|
---
|
|
336
384
|
|
|
337
385
|
## Security
|
|
338
386
|
|
|
339
|
-
|
|
387
|
+
The maths runs in OpenSSL 3.5 on Node 24 and later, and in [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) and [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) elsewhere. This package reimplements no NIST primitive. Every parameter set is held to NIST's own ACVP vectors and cross-checked against liboqs, Bouncy Castle and the Python reference implementations on both backends, per [CONFORMANCE.md](./CONFORMANCE.md). The audit history of every upstream library is recorded in [AUDIT.md](./AUDIT.md).
|
|
340
388
|
|
|
341
389
|
To report a vulnerability: [open a private security advisory](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/security/advisories/new) or email **john@knightsbridgelaw.com**. Acknowledgement within 2 business days, triage decision within 5. Full policy, including safe harbour for good-faith research: <https://kxco.ai/security>.
|
|
342
390
|
|
|
@@ -346,10 +394,25 @@ Apache-2.0. See [LICENSE](./LICENSE).
|
|
|
346
394
|
|
|
347
395
|
## Maintainers
|
|
348
396
|
|
|
349
|
-
Shayne Heffernan and John Heffernan
|
|
397
|
+
Shayne Heffernan and John Heffernan, [KXCO by Knightsbridge](https://kxco.ai)
|
|
350
398
|
|
|
351
399
|
## Verifying a release
|
|
352
400
|
|
|
401
|
+
Every claim on this page is checkable without asking us. The evidence bundle
|
|
402
|
+
for the current release sits at a permanent, unauthenticated URL:
|
|
403
|
+
|
|
404
|
+
```bash
|
|
405
|
+
# the full evidence bundle for the current release
|
|
406
|
+
curl -sLO https://github.com/KnightsbridgeAIQ/kxco-post-quantum/releases/latest/download/evidence-node24.x.zip
|
|
407
|
+
|
|
408
|
+
# or just the manifest: every file digest, and which backend produced the results
|
|
409
|
+
curl -sL https://github.com/KnightsbridgeAIQ/kxco-post-quantum/releases/latest/download/manifest-node24.x.json
|
|
410
|
+
|
|
411
|
+
# licence and provenance, straight from the registry
|
|
412
|
+
npm view kxco-post-quantum license # Apache-2.0
|
|
413
|
+
npm audit signatures --json # assert invalid:0 and missing:0
|
|
414
|
+
```
|
|
415
|
+
|
|
353
416
|
Every release asset is signed with ML-DSA-65 by this package's own signing path,
|
|
354
417
|
and carries a SLSA provenance file recording the workflow that built it.
|
|
355
418
|
|
|
@@ -370,6 +433,5 @@ const sig = readFileSync('evidence-node24.x.zip.sig', 'utf8').trim()
|
|
|
370
433
|
mlDsa.verify(pub, readFileSync('evidence-node24.x.zip'), sig) // true
|
|
371
434
|
```
|
|
372
435
|
|
|
373
|
-
Compare the public key
|
|
374
|
-
|
|
375
|
-
same party produced both.
|
|
436
|
+
Compare the public key with the copy committed to this repository, so the key
|
|
437
|
+
and the artefact are checked against two independent sources.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kxco-post-quantum",
|
|
3
|
-
"version": "1.7.
|
|
4
|
-
"description": "ML-DSA
|
|
3
|
+
"version": "1.7.6",
|
|
4
|
+
"description": "NIST post-quantum cryptography for Node.js and browsers: ML-DSA, ML-KEM and SLH-DSA (FIPS 203, 204, 205). 1,793 NIST ACVP vectors passed, 0 failed. OpenSSL 3.5 native on Node 24+, the CNSA 2.0 parameter sets, reproducible builds with provenance.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"post-quantum",
|
|
7
7
|
"pqc",
|
|
@@ -28,7 +28,16 @@
|
|
|
28
28
|
"non-repudiation",
|
|
29
29
|
"ml-dsa-87",
|
|
30
30
|
"ml-kem-1024",
|
|
31
|
-
"category-5"
|
|
31
|
+
"category-5",
|
|
32
|
+
"pqc-migration",
|
|
33
|
+
"cnsa-2.0",
|
|
34
|
+
"crypto-agility",
|
|
35
|
+
"harvest-now-decrypt-later",
|
|
36
|
+
"digital-signature",
|
|
37
|
+
"key-encapsulation",
|
|
38
|
+
"jws",
|
|
39
|
+
"jwk",
|
|
40
|
+
"openssl"
|
|
32
41
|
],
|
|
33
42
|
"license": "Apache-2.0",
|
|
34
43
|
"author": "Shayne Heffernan and John Heffernan",
|
|
@@ -44,7 +53,7 @@
|
|
|
44
53
|
"funding": "https://kxco.ai",
|
|
45
54
|
"repository": {
|
|
46
55
|
"type": "git",
|
|
47
|
-
"url": "https://github.com/KnightsbridgeAIQ/kxco-post-quantum.git"
|
|
56
|
+
"url": "git+https://github.com/KnightsbridgeAIQ/kxco-post-quantum.git"
|
|
48
57
|
},
|
|
49
58
|
"bugs": {
|
|
50
59
|
"url": "https://github.com/KnightsbridgeAIQ/kxco-post-quantum/issues"
|
package/src/_native.node.js
CHANGED
|
@@ -28,11 +28,73 @@
|
|
|
28
28
|
|
|
29
29
|
import crypto from 'node:crypto'
|
|
30
30
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
31
|
+
// Which implementation this process must use, chosen by the operator.
|
|
32
|
+
//
|
|
33
|
+
// Until now the only control was requireNativeBackend(), which asserts OpenSSL
|
|
34
|
+
// is present. That was enough while only one implementation was going to carry
|
|
35
|
+
// a certificate. Both are now being taken to validation, so a deployment under
|
|
36
|
+
// a validated-module control has to be able to pin EITHER, and the one it must
|
|
37
|
+
// pin is whichever its certificate names.
|
|
38
|
+
//
|
|
39
|
+
// Set from the environment rather than from application code, for the same
|
|
40
|
+
// reason the assertion is: the team under the control is usually not the team
|
|
41
|
+
// calling this library.
|
|
42
|
+
//
|
|
43
|
+
// KXCO_PQ_BACKEND=javascript never use OpenSSL, even where it is present
|
|
44
|
+
// KXCO_PQ_BACKEND=openssl prefer OpenSSL, which is the default anyway;
|
|
45
|
+
// pair with requireBackend to make it binding
|
|
46
|
+
//
|
|
47
|
+
// A value that is neither throws at import. A misspelled pin that silently did
|
|
48
|
+
// nothing would leave the operator believing a control was in force when it
|
|
49
|
+
// was not, which is the exact failure this exists to remove.
|
|
50
|
+
//
|
|
51
|
+
// Nothing here changes what a signature looks like. The two implementations
|
|
52
|
+
// produce identical wire bytes, proved for every parameter set in both
|
|
53
|
+
// directions by the interoperability matrix. What it changes is which one
|
|
54
|
+
// computed them, and backend() always reports that truthfully, including when
|
|
55
|
+
// the answer is the result of this pin.
|
|
56
|
+
const PIN = (() => {
|
|
57
|
+
try {
|
|
58
|
+
const v = globalThis.process?.env?.KXCO_PQ_BACKEND
|
|
59
|
+
if (v === undefined || v === null || v === '') return null
|
|
60
|
+
const k = String(v).trim().toLowerCase()
|
|
61
|
+
if (k !== 'openssl' && k !== 'javascript') {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`KXCO_PQ_BACKEND must be 'openssl' or 'javascript', got '${v}'`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
return k
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err instanceof Error && err.message.startsWith('KXCO_PQ_BACKEND')) throw err
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
})()
|
|
72
|
+
|
|
73
|
+
/** The operator's pin, or null. Reported by backend() so evidence records it. */
|
|
74
|
+
export const pinned = PIN
|
|
75
|
+
|
|
76
|
+
// FIPS 204 section 5.2 and FIPS 205 context strings. Node's sign and verify
|
|
77
|
+
// took no context argument when this module was written, so a call that used
|
|
78
|
+
// one fell back to the JavaScript backend: signing without the caller's context
|
|
79
|
+
// produces a signature that verifies against nothing, which would look like a
|
|
35
80
|
// cross-implementation disagreement rather than a missing feature.
|
|
81
|
+
//
|
|
82
|
+
// Newer Node builds do take one, and honour it. That is worth having rather
|
|
83
|
+
// than assuming either way, because the fallback quietly moved every
|
|
84
|
+
// context-using call onto the implementation the operator did not ask for, and
|
|
85
|
+
// requireNativeBackend() cannot see it happen: it asserts the backend is
|
|
86
|
+
// present, not that a particular call reached it.
|
|
87
|
+
//
|
|
88
|
+
// So the capability is probed rather than inferred from a version number, and
|
|
89
|
+
// the probe is the real thing: sign under one context, then require that the
|
|
90
|
+
// same context verifies and a different one does not. A build that accepted
|
|
91
|
+
// the argument and ignored it would pass the first check and fail the second,
|
|
92
|
+
// and ignoring it is the dangerous outcome, not rejecting it.
|
|
93
|
+
//
|
|
94
|
+
// Verified before this was enabled: with a context string, OpenSSL and
|
|
95
|
+
// @noble/post-quantum accept each other's signatures in both directions, and
|
|
96
|
+
// both reject a wrong context, across ML-DSA-44/65/87 and the three SLH-DSA
|
|
97
|
+
// sets this build exposes.
|
|
36
98
|
|
|
37
99
|
const DER_SEQUENCE = 0x30
|
|
38
100
|
const DER_OCTET_STRING = 0x04
|
|
@@ -109,6 +171,34 @@ function probe() {
|
|
|
109
171
|
|
|
110
172
|
const SUPPORTED = probe()
|
|
111
173
|
|
|
174
|
+
// Whether this runtime's sign and verify honour a context string. Probed once,
|
|
175
|
+
// on first use rather than at import, because most callers never pass one and
|
|
176
|
+
// the probe costs a keygen.
|
|
177
|
+
//
|
|
178
|
+
// `null` means not yet probed. Any throw is read as "no", so a build that
|
|
179
|
+
// rejects the argument outright falls back exactly as before.
|
|
180
|
+
let contextHonoured = null
|
|
181
|
+
function probeContext() {
|
|
182
|
+
if (contextHonoured !== null) return contextHonoured
|
|
183
|
+
contextHonoured = false
|
|
184
|
+
try {
|
|
185
|
+
// ML-DSA-44 is the cheapest set to key and sign. If the build has no
|
|
186
|
+
// ML-DSA at all there is nothing to probe with and the answer stays no.
|
|
187
|
+
const spec = SUPPORTED.get('ML-DSA-44')
|
|
188
|
+
if (!spec) return contextHonoured
|
|
189
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync(spec.nodeName)
|
|
190
|
+
const message = Buffer.from('kxco-pq context probe')
|
|
191
|
+
const a = Buffer.from('a'), b = Buffer.from('b')
|
|
192
|
+
const sig = crypto.sign(null, message, { key: privateKey, context: a })
|
|
193
|
+
contextHonoured =
|
|
194
|
+
crypto.verify(null, message, { key: publicKey, context: a }, sig) === true &&
|
|
195
|
+
crypto.verify(null, message, { key: publicKey, context: b }, sig) === false
|
|
196
|
+
} catch {
|
|
197
|
+
contextHonoured = false
|
|
198
|
+
}
|
|
199
|
+
return contextHonoured
|
|
200
|
+
}
|
|
201
|
+
|
|
112
202
|
function privateKeyObject(spec, secretKey, publicKey) {
|
|
113
203
|
if (spec.privateForm === 'jwk') {
|
|
114
204
|
// FIPS 205 lays the private key out as SK.seed || SK.prf || PK.seed ||
|
|
@@ -142,7 +232,7 @@ function publicKeyObject(spec, publicKey) {
|
|
|
142
232
|
})
|
|
143
233
|
}
|
|
144
234
|
|
|
145
|
-
export const native = SUPPORTED.size === 0 ? null : {
|
|
235
|
+
export const native = (PIN === 'javascript' || SUPPORTED.size === 0) ? null : {
|
|
146
236
|
/** Parameter sets this build can do. Anything else falls through to JS.
|
|
147
237
|
*
|
|
148
238
|
* The Buffer check is not defensive padding. This package's browser-mode
|
|
@@ -161,22 +251,33 @@ export const native = SUPPORTED.size === 0 ? null : {
|
|
|
161
251
|
return [...SUPPORTED.keys()].sort()
|
|
162
252
|
},
|
|
163
253
|
|
|
254
|
+
/** Whether a signature carrying a context string can stay on this backend. */
|
|
255
|
+
supportsContext() {
|
|
256
|
+
return probeContext()
|
|
257
|
+
},
|
|
258
|
+
|
|
164
259
|
openssl: process.versions.openssl,
|
|
165
260
|
|
|
166
|
-
sign(alg, secretKey, message, publicKey) {
|
|
261
|
+
sign(alg, secretKey, message, publicKey, context) {
|
|
167
262
|
const spec = SUPPORTED.get(alg)
|
|
168
263
|
if (!spec) return null
|
|
169
|
-
|
|
264
|
+
const key = privateKeyObject(spec, secretKey, publicKey)
|
|
265
|
+
return crypto.sign(
|
|
266
|
+
null,
|
|
267
|
+
Buffer.from(message),
|
|
268
|
+
context === undefined ? key : { key, context: Buffer.from(context) },
|
|
269
|
+
)
|
|
170
270
|
},
|
|
171
271
|
|
|
172
|
-
verify(alg, publicKey, message, signature) {
|
|
272
|
+
verify(alg, publicKey, message, signature, context) {
|
|
173
273
|
const spec = SUPPORTED.get(alg)
|
|
174
274
|
if (!spec) return null
|
|
175
275
|
try {
|
|
276
|
+
const key = publicKeyObject(spec, publicKey)
|
|
176
277
|
return crypto.verify(
|
|
177
278
|
null,
|
|
178
279
|
Buffer.from(message),
|
|
179
|
-
|
|
280
|
+
context === undefined ? key : { key, context: Buffer.from(context) },
|
|
180
281
|
Buffer.from(signature)
|
|
181
282
|
)
|
|
182
283
|
} catch {
|
package/src/_native.stub.js
CHANGED
package/src/backend.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface BackendReport {
|
|
|
7
7
|
parameterSets?: string[]
|
|
8
8
|
/** Why the native backend is unavailable, on the JavaScript backend only. */
|
|
9
9
|
reason?: string
|
|
10
|
+
/** The operator's KXCO_PQ_BACKEND pin, when one is set. */
|
|
11
|
+
pinned?: 'openssl' | 'javascript'
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/** Describe the backend doing the maths in this process. Reports; never switches. */
|
|
@@ -32,3 +34,26 @@ export function isNative(alg: string): boolean
|
|
|
32
34
|
* @throws Error with `code: 'ERR_KXCO_PQ_BACKEND'` when the requirement fails.
|
|
33
35
|
*/
|
|
34
36
|
export function requireNativeBackend(algorithms?: string[]): BackendReport
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Refuse to run unless the cryptography is executing in a named implementation.
|
|
40
|
+
*
|
|
41
|
+
* The general form of requireNativeBackend. Both implementations are being
|
|
42
|
+
* taken to algorithm validation, so a deployment under a control that names a
|
|
43
|
+
* certificate must be able to pin the one its certificate covers, and for some
|
|
44
|
+
* that is the JavaScript one.
|
|
45
|
+
*
|
|
46
|
+
* Asserting 'javascript' where OpenSSL is present fails unless the operator
|
|
47
|
+
* also set `KXCO_PQ_BACKEND=javascript`. This function reports; the
|
|
48
|
+
* environment decides. Keeping those apart is what stops application code
|
|
49
|
+
* quietly changing which implementation a customer's evidence is about.
|
|
50
|
+
*
|
|
51
|
+
* @param kind Which implementation must be running.
|
|
52
|
+
* @param algorithms Parameter sets that must run in it. Only meaningful for
|
|
53
|
+
* 'openssl'; the JavaScript backend covers every set.
|
|
54
|
+
* @throws Error with `code: 'ERR_KXCO_PQ_BACKEND'` when the requirement fails.
|
|
55
|
+
*/
|
|
56
|
+
export function requireBackend(
|
|
57
|
+
kind: 'openssl' | 'javascript',
|
|
58
|
+
algorithms?: string[],
|
|
59
|
+
): BackendReport
|
package/src/backend.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// wrong one, which is a different thing, and the thing a deployment under a
|
|
17
17
|
// validated-module control actually needs.
|
|
18
18
|
|
|
19
|
-
import { native } from '#native'
|
|
19
|
+
import { native, pinned } from '#native'
|
|
20
20
|
|
|
21
21
|
// An operator control. A deployment under a validated-module requirement is
|
|
22
22
|
// usually not the same team as the one calling this library, so the
|
|
@@ -46,13 +46,20 @@ export function backend() {
|
|
|
46
46
|
return {
|
|
47
47
|
kind: 'javascript',
|
|
48
48
|
library: '@noble/post-quantum',
|
|
49
|
-
|
|
49
|
+
// Two different facts wear the same result, and an evidence bundle that
|
|
50
|
+
// conflated them would be wrong. Absent means the runtime cannot do it.
|
|
51
|
+
// Pinned means it can and the operator said not to.
|
|
52
|
+
reason: pinned === 'javascript'
|
|
53
|
+
? 'KXCO_PQ_BACKEND=javascript pins this process to the JavaScript backend'
|
|
54
|
+
: 'the runtime does not provide the FIPS 203/204/205 primitives',
|
|
55
|
+
...(pinned ? { pinned } : {}),
|
|
50
56
|
}
|
|
51
57
|
}
|
|
52
58
|
return {
|
|
53
59
|
kind: 'openssl',
|
|
54
60
|
library: 'node:crypto',
|
|
55
61
|
openssl: native.openssl,
|
|
62
|
+
...(pinned ? { pinned } : {}),
|
|
56
63
|
// Only the sets OpenSSL can express. Anything absent here still works, on
|
|
57
64
|
// the JavaScript backend, which is why this is a list rather than a flag.
|
|
58
65
|
parameterSets: native.algorithms(),
|
|
@@ -93,20 +100,48 @@ export function isNative(alg) {
|
|
|
93
100
|
* @throws {Error} with `code: 'ERR_KXCO_PQ_BACKEND'` when the requirement fails.
|
|
94
101
|
*/
|
|
95
102
|
export function requireNativeBackend(algorithms) {
|
|
103
|
+
return requireBackend('openssl', algorithms)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Refuse to run unless the cryptography is executing in a named implementation.
|
|
108
|
+
*
|
|
109
|
+
* The general form of requireNativeBackend, and the reason it exists is that
|
|
110
|
+
* both implementations are being taken to algorithm validation. A deployment
|
|
111
|
+
* under a control that names a certificate has to be able to pin the one its
|
|
112
|
+
* certificate covers, and for some of them that is the JavaScript one.
|
|
113
|
+
*
|
|
114
|
+
* Asserting 'javascript' on a runtime that has OpenSSL will fail unless the
|
|
115
|
+
* operator also set KXCO_PQ_BACKEND=javascript, and that is deliberate. This
|
|
116
|
+
* function reports; the environment decides. Keeping the two apart is what
|
|
117
|
+
* stops application code quietly changing which implementation a customer's
|
|
118
|
+
* evidence is about.
|
|
119
|
+
*
|
|
120
|
+
* @param {'openssl'|'javascript'} kind
|
|
121
|
+
* @param {string[]} [algorithms] — parameter sets that must run in it. Only
|
|
122
|
+
* meaningful for 'openssl'; the JavaScript backend covers every set.
|
|
123
|
+
* @throws {Error} with `code: 'ERR_KXCO_PQ_BACKEND'` when the requirement fails.
|
|
124
|
+
*/
|
|
125
|
+
export function requireBackend(kind, algorithms) {
|
|
126
|
+
if (kind !== 'openssl' && kind !== 'javascript') {
|
|
127
|
+
throw backendError(`backend must be 'openssl' or 'javascript', got '${kind}'`, { required: kind })
|
|
128
|
+
}
|
|
96
129
|
const b = backend()
|
|
97
|
-
if (b.kind !==
|
|
130
|
+
if (b.kind !== kind) {
|
|
98
131
|
throw backendError(
|
|
99
|
-
`the
|
|
100
|
-
{ required:
|
|
132
|
+
`the ${kind} backend is required and is not the one running: ${b.reason ?? `this process is on ${b.kind}`}`,
|
|
133
|
+
{ required: kind, actual: b.kind, reason: b.reason, pinned: b.pinned },
|
|
101
134
|
)
|
|
102
135
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
`
|
|
108
|
-
|
|
109
|
-
|
|
136
|
+
if (kind === 'openssl') {
|
|
137
|
+
const missing = (algorithms ?? []).filter((a) => !isNative(a))
|
|
138
|
+
if (missing.length) {
|
|
139
|
+
throw backendError(
|
|
140
|
+
`the native backend is required for ${missing.join(', ')}, ` +
|
|
141
|
+
`and this OpenSSL does not provide ${missing.length > 1 ? 'them' : 'it'}`,
|
|
142
|
+
{ required: 'openssl', actual: b.kind, missing, available: b.parameterSets },
|
|
143
|
+
)
|
|
144
|
+
}
|
|
110
145
|
}
|
|
111
146
|
return b
|
|
112
147
|
}
|
package/src/index.js
CHANGED
|
@@ -37,4 +37,4 @@ export * as jws from './jws.js'
|
|
|
37
37
|
|
|
38
38
|
// Reports which backend is doing the maths in this process, for evidence
|
|
39
39
|
// bundles and support. It reports; it never switches.
|
|
40
|
-
export { backend, isNative, requireNativeBackend } from './backend.js'
|
|
40
|
+
export { backend, isNative, requireNativeBackend, requireBackend } from './backend.js'
|
package/src/ml-dsa-87.js
CHANGED
|
@@ -64,8 +64,13 @@ function wrap(bytes) {
|
|
|
64
64
|
// changes. The two backends are checked against each other for this parameter
|
|
65
65
|
// set in both directions by the interoperability matrix.
|
|
66
66
|
const NATIVE_ALG = 'ML-DSA-87'
|
|
67
|
+
// A context string stays on the native backend only where the runtime honours
|
|
68
|
+
// it; see the probe in _native.node.js. Where it does not, this falls back to
|
|
69
|
+
// the JavaScript backend exactly as it always did.
|
|
67
70
|
const usesNative = (context) =>
|
|
68
|
-
|
|
71
|
+
native !== null &&
|
|
72
|
+
native.supports(NATIVE_ALG) &&
|
|
73
|
+
(context === undefined || native.supportsContext())
|
|
69
74
|
|
|
70
75
|
/**
|
|
71
76
|
* Generate an ML-DSA-87 keypair from a master + domain-separation info.
|
|
@@ -104,7 +109,7 @@ export function keypairFromMaster(master, info = 'ml-dsa-87-v1') {
|
|
|
104
109
|
export function sign(secretKey, message, opts) {
|
|
105
110
|
const context = normalizeContext(opts)
|
|
106
111
|
if (usesNative(context)) {
|
|
107
|
-
return bytesToHex(native.sign(NATIVE_ALG, secretKey, toBytes(message)))
|
|
112
|
+
return bytesToHex(native.sign(NATIVE_ALG, secretKey, toBytes(message), undefined, context))
|
|
108
113
|
}
|
|
109
114
|
const sig = context === undefined
|
|
110
115
|
? ml_dsa87.sign(toBytes(message), secretKey)
|
|
@@ -135,7 +140,7 @@ export function verify(publicKey, message, sigHex, opts) {
|
|
|
135
140
|
const context = normalizeContext(opts)
|
|
136
141
|
try {
|
|
137
142
|
if (usesNative(context)) {
|
|
138
|
-
return native.verify(NATIVE_ALG, publicKey, toBytes(message), hexToBytes(sigHex))
|
|
143
|
+
return native.verify(NATIVE_ALG, publicKey, toBytes(message), hexToBytes(sigHex), context)
|
|
139
144
|
}
|
|
140
145
|
return context === undefined
|
|
141
146
|
? ml_dsa87.verify(hexToBytes(sigHex), toBytes(message), publicKey)
|
package/src/ml-dsa.js
CHANGED
|
@@ -20,12 +20,18 @@ export { MAX_CONTEXT_BYTES }
|
|
|
20
20
|
// set in both directions by the interoperability matrix, so this is a swap
|
|
21
21
|
// between two implementations known to agree, not an assumption that they do.
|
|
22
22
|
//
|
|
23
|
-
// A context string
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// A context string used to force the JavaScript path unconditionally, because
|
|
24
|
+
// Node's sign and verify took no context argument. Newer builds take one and
|
|
25
|
+
// honour it, so the capability is probed and the call stays native where it
|
|
26
|
+
// holds. The fallback is unchanged everywhere else.
|
|
26
27
|
const NATIVE_ALG = 'ML-DSA-65'
|
|
28
|
+
// A context string stays on the native backend only where the runtime honours
|
|
29
|
+
// it; see the probe in _native.node.js. Where it does not, this falls back to
|
|
30
|
+
// the JavaScript backend exactly as it always did.
|
|
27
31
|
const usesNative = (context) =>
|
|
28
|
-
|
|
32
|
+
native !== null &&
|
|
33
|
+
native.supports(NATIVE_ALG) &&
|
|
34
|
+
(context === undefined || native.supportsContext())
|
|
29
35
|
|
|
30
36
|
const HAS_BUFFER = typeof Buffer !== 'undefined'
|
|
31
37
|
const enc = new TextEncoder()
|
|
@@ -85,7 +91,7 @@ export function keypairFromMaster(master, info = 'ml-dsa-65-v1') {
|
|
|
85
91
|
export function sign(secretKey, message, opts) {
|
|
86
92
|
const context = normalizeContext(opts)
|
|
87
93
|
if (usesNative(context)) {
|
|
88
|
-
return bytesToHex(native.sign(NATIVE_ALG, secretKey, toBytes(message)))
|
|
94
|
+
return bytesToHex(native.sign(NATIVE_ALG, secretKey, toBytes(message), undefined, context))
|
|
89
95
|
}
|
|
90
96
|
const sig = context === undefined
|
|
91
97
|
? ml_dsa65.sign(toBytes(message), secretKey)
|
|
@@ -115,7 +121,7 @@ export function verify(publicKey, message, sigHex, opts) {
|
|
|
115
121
|
const context = normalizeContext(opts)
|
|
116
122
|
try {
|
|
117
123
|
if (usesNative(context)) {
|
|
118
|
-
return native.verify(NATIVE_ALG, publicKey, toBytes(message), hexToBytes(sigHex))
|
|
124
|
+
return native.verify(NATIVE_ALG, publicKey, toBytes(message), hexToBytes(sigHex), context)
|
|
119
125
|
}
|
|
120
126
|
return context === undefined
|
|
121
127
|
? ml_dsa65.verify(hexToBytes(sigHex), toBytes(message), publicKey)
|
package/src/slh-dsa.js
CHANGED
|
@@ -52,8 +52,13 @@ const SEED_BYTES = slh_dsa_sha2_192s.lengths.seed
|
|
|
52
52
|
// changes. The two backends are checked against each other for this parameter
|
|
53
53
|
// set in both directions by the interoperability matrix.
|
|
54
54
|
const NATIVE_ALG = 'SLH-DSA-SHA2-192s'
|
|
55
|
+
// A context string stays on the native backend only where the runtime honours
|
|
56
|
+
// it; see the probe in _native.node.js. Where it does not, this falls back to
|
|
57
|
+
// the JavaScript backend exactly as it always did.
|
|
55
58
|
const usesNative = (context) =>
|
|
56
|
-
|
|
59
|
+
native !== null &&
|
|
60
|
+
native.supports(NATIVE_ALG) &&
|
|
61
|
+
(context === undefined || native.supportsContext())
|
|
57
62
|
|
|
58
63
|
/**
|
|
59
64
|
* Generate an SLH-DSA-SHA2-192s keypair from a master + domain-separation info.
|
|
@@ -85,7 +90,7 @@ export function keypairFromMaster(master, info = 'slh-dsa-sha2-192s-v1') {
|
|
|
85
90
|
export function sign(secretKey, message, opts) {
|
|
86
91
|
const context = normalizeContext(opts)
|
|
87
92
|
if (usesNative(context)) {
|
|
88
|
-
return bytesToHex(native.sign(NATIVE_ALG, secretKey, toBytes(message)))
|
|
93
|
+
return bytesToHex(native.sign(NATIVE_ALG, secretKey, toBytes(message), undefined, context))
|
|
89
94
|
}
|
|
90
95
|
const sig = context === undefined
|
|
91
96
|
? slh_dsa_sha2_192s.sign(toBytes(message), secretKey)
|
|
@@ -109,7 +114,7 @@ export function verify(publicKey, message, sigHex, opts) {
|
|
|
109
114
|
const context = normalizeContext(opts)
|
|
110
115
|
try {
|
|
111
116
|
if (usesNative(context)) {
|
|
112
|
-
return native.verify(NATIVE_ALG, publicKey, toBytes(message), hexToBytes(sigHex))
|
|
117
|
+
return native.verify(NATIVE_ALG, publicKey, toBytes(message), hexToBytes(sigHex), context)
|
|
113
118
|
}
|
|
114
119
|
return context === undefined
|
|
115
120
|
? slh_dsa_sha2_192s.verify(hexToBytes(sigHex), toBytes(message), publicKey)
|