kxco-post-quantum 1.7.9 → 1.9.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 CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.0 (2026-10-07)
4
+
5
+ `jws.signJws` takes its default from the key, and the default is ML-DSA-87.
6
+ Without `opts.alg`, a 4032-byte ML-DSA-65 secret key signs `ML-DSA-65`, as it
7
+ always has, and every other key signs `ML-DSA-87`. An ML-DSA-87 key no longer
8
+ needs `alg` named, where before it threw. The key is measured by its byte
9
+ length, so a key held as an ArrayBuffer is read as its own set. A token signed
10
+ by 1.8.0 verifies unchanged, which a fixture now tests.
11
+
12
+ To keep the old behaviour, name the set:
13
+ `signJws(payload, secretKey, { alg: 'ML-DSA-65' })`. An existing ML-DSA-65 key
14
+ needs no change, because its size already selects ML-DSA-65.
15
+
16
+ The README leads with ML-DSA-87. The quick start, the examples and the API
17
+ section use `mlDsa87`, and `mlDsa` is described as the ML-DSA-65 namespace kept
18
+ for keys that already exist. Its name and exports do not change. MIGRATION.md
19
+ and the source comments recommend ML-DSA-87 for every new signing key.
20
+
21
+ **Each release carries a CycloneDX 1.6 CBOM**, at
22
+ `releases/latest/download/cbom.cyclonedx.json` and inside the signed evidence
23
+ bundle as `05b-cbom.cyclonedx.json`. It lists every algorithm the package offers
24
+ (ML-DSA-65, ML-DSA-87, ML-KEM-768, ML-KEM-1024, SLH-DSA-SHA2-192s, HKDF-SHA-512,
25
+ HMAC-SHA-256, SHA-256), the four the native backend runs only to probe OpenSSL,
26
+ and the classical ECDSA of the npm registry and Sigstore signatures. Each carries
27
+ its OID, NIST category, purpose, protocol context, key and signature sizes in
28
+ bits, and the file and line where the source uses it.
29
+
30
+ `scripts/build-cbom.mjs` reconciles the CBOM against `src/`, and `npm test` and
31
+ the evidence build fail if the source uses an algorithm the CBOM does not
32
+ declare. PQCMM Level 4 criterion 1 moves to met; Level 4 as a whole stays not
33
+ met while zero-legacy capability is undetermined.
34
+
35
+ `PQCMM.md` and `CRYPTO-INVENTORY.md` said SLH-DSA was offered in ten parameter
36
+ sets. The API offers one, SLH-DSA-SHA2-192s, and the conformance suite checks the
37
+ underlying implementation for all twelve. Both documents now say so.
38
+
39
+ ## 1.8.0
40
+ The webhook helpers sign and verify with ML-DSA-87 keys. The key decides the
41
+ X-KXCO-PQ-Signature form: pqSign and signDelivery give `ml-dsa-87=<hex>` for
42
+ an ML-DSA-87 secret key and `ml-dsa-65=<hex>` for an ML-DSA-65 one, unchanged.
43
+ verifyPq and verifyDelivery verify under the set of the public key they are
44
+ given and accept only that set's prefix, so a header naming the other set
45
+ fails. An ML-DSA-65 key still accepts the bare hex value, and a delivery signed
46
+ by 1.7.9 verifies unchanged, which a fixture now tests. The webhook contract in
47
+ `spec/` describes the `ml-dsa-87=` form.
48
+
3
49
  ## 1.7.9
4
50
 
5
51
  verifyDelivery reads each header only as a string. A header that arrives as an
@@ -63,7 +63,7 @@ pretend to be a PKI.
63
63
  | Use | Algorithm | Quantum-safe |
64
64
  |---|---|---|
65
65
  | Signature and verification | ML-DSA-65, ML-DSA-87 (FIPS 204) | Yes |
66
- | Hash-based alternative | SLH-DSA, ten parameter sets (FIPS 205) | Yes |
66
+ | Hash-based alternative | SLH-DSA-SHA2-192s (FIPS 205) | Yes |
67
67
  | Key identifier | SHA-256 over the public key, truncated to 16 hex characters (`kid.js`) | Yes, second-preimage bound |
68
68
  | Key interchange | JWK `kty: AKP` per RFC 9964, and PKCS#8 seed export | Format, not an algorithm |
69
69
  | JWS signing | ML-DSA over the JWS signing input (`jws.js`) | Yes |
@@ -170,6 +170,7 @@ overclaim.
170
170
  | npm registry signature and provenance | npm's own, classical | **No** |
171
171
  | Reproducible build | Not a signature. The published tarball rebuilds bit-for-bit from its own tag, verified in CI on every run | Not applicable |
172
172
  | CycloneDX SBOM | Generated by `npm sbom` from the published tree; integrity carried by the ML-DSA signature over the release | Yes, via the release signature |
173
+ | CycloneDX CBOM | This inventory in machine-readable form, built by `scripts/build-cbom.mjs` and reconciled against `src/` on every build; integrity carried by the ML-DSA signature over the evidence bundle that contains it | Yes, via the release signature |
173
174
 
174
175
  The reproducible build is the part of this category that does not depend on any
175
176
  signature algorithm at all, and it is the reason the classical rows above are a
package/MIGRATION.md CHANGED
@@ -15,8 +15,8 @@ Two separate problems, in order:
15
15
 
16
16
  | You need | Use | Why |
17
17
  |---|---|---|
18
- | Signatures, general use | `mlDsa` (ML-DSA-65) | Category 3. Fast, 3309-byte signatures. The default. |
19
- | Signatures, Category 5 required | `mlDsa87` (ML-DSA-87) | When a counterparty specifies Category 5 or names ML-DSA-87. Signatures are 4627 bytes. |
18
+ | Signatures, new keys | `mlDsa87` (ML-DSA-87) | Category 5. 4627-byte signatures. The default, and what `jws.signJws` signs with unless the key is an ML-DSA-65 one. |
19
+ | Signatures, existing ML-DSA-65 keys | `mlDsa` (ML-DSA-65) | Category 3. 3309-byte signatures. Kept so the keys and signatures that already exist keep working. |
20
20
  | Signatures, no lattice assumption | `slhDsa` (SLH-DSA-SHA2-192s) | Hash-based, so it does not share ML-DSA's underlying assumption. Signatures are 16 KB and signing takes seconds. |
21
21
  | Key establishment | `mlKem` (ML-KEM-768) | Category 3, matching ML-DSA-65. |
22
22
  | Key establishment, Category 5 required | `mlKem1024` (ML-KEM-1024) | Public key and ciphertext are 1568 bytes each. The shared secret stays 32 bytes. |
@@ -24,9 +24,10 @@ Two separate problems, in order:
24
24
  **On CNSA 2.0.** It names ML-DSA-87 and ML-KEM-1024, and both are available
25
25
  here. Availability is not compliance: CNSA 2.0 compliance is a property of a
26
26
  deployment, and picking `mlDsa87` for one call path does not confer it on a
27
- system whose other signatures, identities and anchors are Category 3. Use the
28
- Category 5 sets when someone asks for the parameter set. Do not use their
29
- presence as the basis for a compliance statement.
27
+ system whose other signatures, identities and anchors are Category 3. Use
28
+ ML-DSA-87 for every new signing key, and ML-KEM-1024 when someone asks for the
29
+ parameter set. Do not use their presence as the basis for a compliance
30
+ statement.
30
31
 
31
32
  Two points that catch people out:
32
33
 
package/PQCMM.md CHANGED
@@ -36,6 +36,7 @@ request to us, no expiring artifact.
36
36
  | Evidence manifest | `releases/latest/download/manifest-node24.x.json` |
37
37
  | Evidence bundle | `releases/latest/download/evidence-node24.x.zip` |
38
38
  | CycloneDX SBOM | `releases/latest/download/sbom.cyclonedx.json` |
39
+ | CycloneDX CBOM | `releases/latest/download/cbom.cyclonedx.json` |
39
40
  | In-toto attestation | `releases/latest/download/evidence.intoto.jsonl` |
40
41
  | Release signing key | `releases/latest/download/release-signing-key.pub.hex` |
41
42
 
@@ -60,7 +61,9 @@ manifest rather than taken on our word.
60
61
  | 3 | Documented for evaluation | Yes | `README.md`, with a per-function API reference. |
61
62
 
62
63
  Release channel is stable and public, available to all consumers, with no
63
- programme enrolment. Ten SLH-DSA parameter sets are exposed, not one.
64
+ programme enrolment. One SLH-DSA parameter set is offered, SLH-DSA-SHA2-192s,
65
+ and the conformance suite checks the underlying implementation against NIST's
66
+ vectors for all twelve.
64
67
 
65
68
  ## Level 2, Foundational: MET
66
69
 
@@ -113,7 +116,7 @@ answer is the PKCS#11 path in `kxco-pq-hsm`, not an algorithm choice here.
113
116
 
114
117
  | # | Criterion | Answer | Evidence or gap |
115
118
  |---|---|---|---|
116
- | 1 | CBOM maintained | **No** | The product publishes an SBOM, not a CBOM. `CRYPTO-INVENTORY.md` now records each algorithm with its protocol context and usage purpose, which is the human-readable half of what a CBOM carries, but it is prose rather than a machine-readable CBOM and key sizes are stated by parameter set rather than per field. Closing this means emitting a CycloneDX CBOM as a release asset, which is a build change rather than a document. |
119
+ | 1 | CBOM maintained | Yes, from 1.9.0 | A CycloneDX 1.6 CBOM ships on every release from 1.9.0 at `releases/latest/download/cbom.cyclonedx.json` and inside the ML-DSA-signed evidence bundle. It carries each algorithm with its OID, NIST category, usage purpose and protocol context; key, signature, ciphertext and seed sizes per field, in bits; and the file and line where the source uses it. It includes what the package executes but does not offer (the native backend's capability probes) and the classical release layer (the npm registry and Sigstore signatures, both ECDSA). It is maintained by construction: `scripts/build-cbom.mjs` reconciles it against `src/` and the build fails if the source uses an algorithm it does not declare, or if a declared use has gone. |
117
120
  | 2 | Zero-legacy capability across every in-scope component | **Not determined** | Not assessed against the model's wording, which extends to boot, firmware update signing, hardware-bound operations and internal diagnostics. Claiming it without that determination would be an overclaim. |
118
121
  | 3 | Symmetric and hash strengths adequate beyond CRQC availability | Yes | SHA-256 for key identifiers and webhook HMAC, SHA-512 under HKDF for seed derivation, via `@noble/hashes` 2.4.0. Per-use detail in `CRYPTO-INVENTORY.md`. |
119
122
  | 4 | Hybrid and composite support documented, with contexts | Partial | Hybrid is documented: `webhook` performs HMAC plus ML-DSA-65 delivery signing, and `deriveSeed` exists to combine an ML-KEM shared secret with a classical secret through a KDF. Composite, in the algorithm-fused sense, is not supported and is not currently stated as unsupported. |
package/README.md CHANGED
@@ -11,12 +11,12 @@
11
11
  [![conformance](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/actions/workflows/conformance.yml/badge.svg)](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/actions/workflows/conformance.yml)
12
12
  [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
13
13
 
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.
14
+ - **All three NIST standards.** ML-DSA-87 and ML-DSA-65 (FIPS 204), ML-KEM-768 (FIPS 203) and SLH-DSA-SHA2-192s (FIPS 205). ML-DSA-87 is the signature set for new keys, and ML-DSA-65 stays for keys that already exist.
15
+ - **The CNSA 2.0 parameter sets ship.** ML-DSA-87 and ML-KEM-1024 at Category 5, with the same API as the Category 3 sets.
16
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
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
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.
19
+ - **Speaks the formats your stack already parses.** Compact JWS and AKP JWK under the `ML-DSA-87` and `ML-DSA-65` algorithm names, and PKCS#8 seed-form keys.
20
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.
21
21
 
22
22
  **The migration has dates.**
@@ -30,9 +30,9 @@
30
30
  | The requirement | What answers it |
31
31
  |---|---|
32
32
  | Post-quantum key establishment by 31 Dec 2030, EO 14412 s.4(b)(ii) | `mlKem.encapsulate` and `mlKem.decapsulate`, ML-KEM-768 |
33
- | Post-quantum signatures by 31 Dec 2031, EO 14412 s.4(b)(iii) | `mlDsa.sign` and `mlDsa.verify`, ML-DSA-65 |
33
+ | Post-quantum signatures by 31 Dec 2031, EO 14412 s.4(b)(iii) | `mlDsa87.sign` and `mlDsa87.verify`, ML-DSA-87 |
34
34
  | "PQC-agile libraries for all new applications", OMB M-26-15 | `mlDsa87` and `mlKem1024` behind the same API: Category 5 is a change of import |
35
- | "API gateways and application workloads must be configured to issue and validate PQC-signed tokens", OMB M-26-15 | `jws.signJws` and `jws.verifyJws`: compact JWS under ML-DSA-65 or ML-DSA-87, algorithm pinned at the verifier |
35
+ | "API gateways and application workloads must be configured to issue and validate PQC-signed tokens", OMB M-26-15 | `jws.signJws` and `jws.verifyJws`: compact JWS under ML-DSA-87 or ML-DSA-65, algorithm pinned at the verifier |
36
36
  | "re-encrypting long-lived sensitive data using keys protected by PQC mechanisms", OMB M-26-15 | [`kxco-pq-vault`](https://www.npmjs.com/package/kxco-pq-vault) |
37
37
  | Minimum elements for a cryptographic bill of materials, in CISA guidance due by 19 Mar 2027, EO 14412 s.5(d) | [`kxco-pq-scan`](https://www.npmjs.com/package/kxco-pq-scan) `--cbom`: a CycloneDX 1.6 CBOM today, ready to check against those elements when CISA publishes them |
38
38
 
@@ -57,14 +57,14 @@ Requires Node.js 20.19+. ESM-only.
57
57
  ## Quick start
58
58
 
59
59
  ```js
60
- import { mlDsa, mlKem, slhDsa, fingerprint, kidEquals } from 'kxco-post-quantum'
60
+ import { mlDsa87, mlKem, slhDsa, fingerprint, kidEquals } from 'kxco-post-quantum'
61
61
 
62
- // ML-DSA-65: sign and verify
63
- const { publicKey, secretKey } = mlDsa.keypairFromMaster(masterSecret, 'signing-65-v1')
64
- const sig = mlDsa.sign(secretKey, 'hello')
65
- const ok = mlDsa.verify(publicKey, 'hello', sig) // true
62
+ // ML-DSA-87: sign and verify
63
+ const { publicKey, secretKey } = mlDsa87.keypairFromMaster(masterSecret, 'signing-87-v1')
64
+ const sig = mlDsa87.sign(secretKey, 'hello')
65
+ const ok = mlDsa87.verify(publicKey, 'hello', sig) // true
66
66
 
67
- // SLH-DSA-SHA2-192s: hash-based signatures (same API shape as mlDsa)
67
+ // SLH-DSA-SHA2-192s: hash-based signatures (same API shape as mlDsa87)
68
68
  const slh = slhDsa.keypairFromMaster(masterSecret, 'signing-slh-v1')
69
69
  const slhSig = slhDsa.sign(slh.secretKey, 'hello')
70
70
  const slhOk = slhDsa.verify(slh.publicKey, 'hello', slhSig) // true
@@ -86,10 +86,11 @@ const recovered = mlKem.decapsulate(ciphertext, kemKeys.secretKey)
86
86
 
87
87
  ### Category 5 parameter sets
88
88
 
89
- `mlDsa87` (ML-DSA-87) and `mlKem1024` (ML-KEM-1024) have the same API as `mlDsa`
90
- and `mlKem`, one security category higher. Reach for them when a counterparty
91
- specifies Category 5 or names the parameter set. The KXCO default stays
92
- Category 3.
89
+ `mlDsa87` (ML-DSA-87) is the signature set for new keys. `mlDsa` (ML-DSA-65) has
90
+ the same API, one security category lower, and stays for the keys that already
91
+ exist, whose signatures keep verifying. `mlKem1024` (ML-KEM-1024) has the same
92
+ API as `mlKem`, one security category higher. Reach for it when a counterparty
93
+ specifies Category 5 or names the parameter set.
93
94
 
94
95
  ```js
95
96
  import { mlDsa87, mlKem1024 } from 'kxco-post-quantum'
@@ -99,10 +100,10 @@ const sig = mlDsa87.sign(secretKey, 'hello') // 4627 bytes, 9254 hex chars
99
100
  mlDsa87.verify(publicKey, 'hello', sig) // true
100
101
  ```
101
102
 
102
- | | Category 3 (default) | Category 5 |
103
+ | | Category 5 | Category 3 |
103
104
  |---|---|---|
104
- | Signatures | `mlDsa`: pk 1952, sig 3309 | `mlDsa87`: pk 2592, sig 4627 |
105
- | Key encapsulation | `mlKem`: pk 1184, ct 1088 | `mlKem1024`: pk 1568, ct 1568 |
105
+ | Signatures | `mlDsa87`: pk 2592, sig 4627, for new keys | `mlDsa`: pk 1952, sig 3309, for existing keys |
106
+ | Key encapsulation | `mlKem1024`: pk 1568, ct 1568 | `mlKem`: pk 1184, ct 1088 |
106
107
 
107
108
  The two sets do not mix, deliberately. Default derivation info differs, so one
108
109
  master yields unrelated keys for each, and so does a distinct label of your own;
@@ -120,16 +121,16 @@ signature made under a context does not verify without it, or under a different
120
121
  one.
121
122
 
122
123
  ```js
123
- const sig = mlDsa.sign(secretKey, 'hello', { context: 'kxco-nexus-v1' })
124
+ const sig = mlDsa87.sign(secretKey, 'hello', { context: 'kxco-nexus-v1' })
124
125
 
125
- mlDsa.verify(publicKey, 'hello', sig, { context: 'kxco-nexus-v1' }) // true
126
- mlDsa.verify(publicKey, 'hello', sig) // false
127
- mlDsa.verify(publicKey, 'hello', sig, { context: 'other-v1' }) // false
126
+ mlDsa87.verify(publicKey, 'hello', sig, { context: 'kxco-nexus-v1' }) // true
127
+ mlDsa87.verify(publicKey, 'hello', sig) // false
128
+ mlDsa87.verify(publicKey, 'hello', sig, { context: 'other-v1' }) // false
128
129
  ```
129
130
 
130
131
  The parameter is optional and defaults to no context, so every existing call
131
- site is unaffected. An empty context is identical to omitting it. `slhDsa` takes
132
- the same option.
132
+ site is unaffected. An empty context is identical to omitting it. `mlDsa` and
133
+ `slhDsa` take the same option.
133
134
 
134
135
  **Context separates at the signature level; `keypairFromMaster(master, info)`
135
136
  separates at the key level.** They are complementary. Use a context when one key
@@ -143,8 +144,8 @@ characters. Over-length or wrongly typed input throws (`RangeError` /
143
144
  a bad signature:
144
145
 
145
146
  ```js
146
- mlDsa.sign(secretKey, 'hello', 'kxco-nexus-v1') // throws TypeError
147
- // (needs { context: ... })
147
+ mlDsa87.sign(secretKey, 'hello', 'kxco-nexus-v1') // throws TypeError
148
+ // (needs { context: ... })
148
149
  ```
149
150
 
150
151
  That last case is worth guarding: without the throw it would silently sign with
@@ -176,8 +177,25 @@ between free and paid is set out in [LICENCE-PRODUCT.md](./LICENCE-PRODUCT.md).
176
177
 
177
178
  ## API
178
179
 
180
+ ### `mlDsa87`: ML-DSA-87 signatures (NIST FIPS 204)
181
+
182
+ The signature set for new keys. Security Category 5.
183
+
184
+ | Export | Signature | Description |
185
+ |---|---|---|
186
+ | `keypairFromMaster` | `(master, info?) → { publicKey, secretKey, seed }` | Deterministic keypair via HKDF-SHA-512. `info` defaults to `'ml-dsa-87-v1'`. `seed` is the 32 bytes the pair was expanded from. See [`seed`](#seed-seed-form-keys-rfc-9964-lamps). |
187
+ | `sign` | `(secretKey, message) → string` | Signs a message. Returns a hex-encoded signature (9254 chars). |
188
+ | `verify` | `(publicKey, message, sigHex) → boolean` | Verifies a hex-encoded signature. Returns `false` on any failure. |
189
+ | `ml_dsa87` | raw primitive | The underlying `@noble/post-quantum` primitive, re-exported. |
190
+
191
+ `publicKey` is 2592 bytes. `secretKey` is 4896 bytes. `message` accepts `Buffer`, `Uint8Array`, or `string`.
192
+
179
193
  ### `mlDsa`: ML-DSA-65 signatures (NIST FIPS 204)
180
194
 
195
+ The ML-DSA-65 namespace, kept for keys that already exist. Same API as
196
+ `mlDsa87`, at Security Category 3. Its name and exports are unchanged, and every
197
+ signature it has made keeps verifying.
198
+
181
199
  | Export | Signature | Description |
182
200
  |---|---|---|
183
201
  | `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). |
@@ -189,7 +207,7 @@ between free and paid is set out in [LICENCE-PRODUCT.md](./LICENCE-PRODUCT.md).
189
207
 
190
208
  ### `slhDsa`: SLH-DSA-SHA2-192s signatures (NIST FIPS 205)
191
209
 
192
- 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.
210
+ 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 `mlDsa87`. Signatures are 16,224 bytes against 4,627 for ML-DSA-87 and 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 ML-DSA stays the choice for high-volume signing.
193
211
 
194
212
  | Export | Signature | Description |
195
213
  |---|---|---|
@@ -251,24 +269,24 @@ OpenSSL writes, for every parameter set, and skips with a reason where there is
251
269
  no native backend to compare against.
252
270
 
253
271
  ```js
254
- import { mlDsa, seed } from 'kxco-post-quantum'
272
+ import { mlDsa87, seed } from 'kxco-post-quantum'
255
273
 
256
- const key = mlDsa.keypairFromMaster(process.env.KXCO_MASTER_KEY)
257
- const jwk = seed.exportJwk('ML-DSA-65', key, { kid: fingerprint(key.publicKey) })
258
- // { kty: 'AKP', alg: 'ML-DSA-65', pub: '...', priv: '<32-byte seed>', kid: '...' }
274
+ const key = mlDsa87.keypairFromMaster(process.env.KXCO_MASTER_KEY)
275
+ const jwk = seed.exportJwk('ML-DSA-87', key, { kid: fingerprint(key.publicKey) })
276
+ // { kty: 'AKP', alg: 'ML-DSA-87', pub: '...', priv: '<32-byte seed>', kid: '...' }
259
277
  ```
260
278
 
261
279
  ### `jws`: compact JWS with the RFC 9964 algorithm names
262
280
 
263
281
  Format only. A token signed here verifies in any process holding the public
264
282
  key, offline, with no configuration and no licence. RFC 9964 registered
265
- `ML-DSA-65` and `ML-DSA-87` as JWS algorithms so a post-quantum signature can
283
+ `ML-DSA-87` and `ML-DSA-65` as JWS algorithms so a post-quantum signature can
266
284
  travel the path an institution's gateway, IdP and partner verifier already
267
285
  parse.
268
286
 
269
287
  | Export | Signature | Description |
270
288
  |---|---|---|
271
- | `signJws` | `(payload, secretKey, opts?) → string` | Compact JWS. Objects are JSON-serialised. `opts.alg` defaults to `ML-DSA-65`. |
289
+ | `signJws` | `(payload, secretKey, opts?) → string` | Compact JWS. Objects are JSON-serialised. Without `opts.alg` the key decides: an ML-DSA-65 secret key signs `ML-DSA-65`, and every other key `ML-DSA-87`, the default. |
272
290
  | `verifyJws` | `(token, publicKey, opts?) → { valid, ... }` | Fails closed. `{ alg }` and `{ kid }` pin what the header may declare. |
273
291
  | `decodeJwsHeader` | `(token) → object \| null` | Unauthenticated read, for choosing which key to fetch. |
274
292
 
@@ -277,7 +295,7 @@ token, so a token cannot name its own verification routine. `crit` and `b64`
277
295
  headers are refused rather than ignored, and the public key's length must match
278
296
  the algorithm the header declares.
279
297
 
280
- The JWS algorithms are ML-DSA-65 and ML-DSA-87, the parameter sets sized for a
298
+ The JWS algorithms are ML-DSA-87 and ML-DSA-65, the parameter sets sized for a
281
299
  request path.
282
300
 
283
301
  ### `backend()` and `isNative(alg)`
@@ -287,9 +305,11 @@ with its version and parameter sets, or `javascript` with the reason the native
287
305
  backend is unavailable. For evidence bundles and support tickets. It reports,
288
306
  and the operator selects, as the next section shows.
289
307
 
290
- ### `webhook`: hybrid HMAC + ML-DSA-65 delivery signing
308
+ ### `webhook`: hybrid HMAC + ML-DSA delivery signing
309
+
310
+ 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 adds non-repudiation over the same `${timestamp}.${body}` envelope. The full identity/credential surface lives in `kxco-pq-sdk`.
291
311
 
292
- 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`.
312
+ The key decides the PQ header form. An ML-DSA-87 key signs `ml-dsa-87=<hex>`, and an ML-DSA-65 key signs `ml-dsa-65=<hex>` over the same envelope. `verifyPq` and `verifyDelivery` accept only the form that matches the public key they are given: a header whose prefix names the other set fails, and an ML-DSA-87 key takes no bare-hex form.
293
313
 
294
314
  ---
295
315
 
@@ -306,7 +326,7 @@ validated module, make the native backend a requirement:
306
326
  ```js
307
327
  import { requireNativeBackend } from 'kxco-post-quantum'
308
328
 
309
- requireNativeBackend(['ML-DSA-65', 'ML-KEM-768'])
329
+ requireNativeBackend(['ML-DSA-87', 'ML-KEM-768'])
310
330
  ```
311
331
 
312
332
  It throws `ERR_KXCO_PQ_BACKEND` if the JavaScript backend is live, or if the
@@ -344,7 +364,7 @@ KXCO_PQ_BACKEND=openssl prefer OpenSSL, which is the default anyway
344
364
  import { requireBackend } from 'kxco-post-quantum'
345
365
 
346
366
  requireBackend('javascript') // the JS certificate
347
- requireBackend('openssl', ['ML-DSA-65', 'ML-KEM-768']) // the native one
367
+ requireBackend('openssl', ['ML-DSA-87', 'ML-KEM-768']) // the native one
348
368
  ```
349
369
 
350
370
  A value that is neither throws at import, so a misspelled pin is caught before
@@ -428,24 +448,25 @@ npm view kxco-post-quantum license # Apache-2.0
428
448
  npm audit signatures --json # assert invalid:0 and missing:0
429
449
  ```
430
450
 
431
- Every release asset is signed with ML-DSA-65 by this package's own signing path,
451
+ Every release asset is signed with ML-DSA-87 by this package's own signing path,
432
452
  and carries a SLSA provenance file recording the workflow that built it.
433
453
 
434
454
  ```
435
455
  manifest-node24.x.json the bundle's manifest
436
456
  evidence-node24.x.zip the bundle
437
- evidence-node24.x.zip.sig ML-DSA-65 signature over the zip, hex
457
+ evidence-node24.x.zip.sig ML-DSA-87 signature over the zip, hex
438
458
  evidence.intoto.jsonl SLSA provenance
439
- release-signing-key.pub.hex the public key, also committed to this repository
459
+ release-signing-key-87.pub.hex the ML-DSA-87 public key, also committed to this repository
460
+ release-signing-key.pub.hex the ML-DSA-65 key that signed releases before 1.9.0
440
461
  ```
441
462
 
442
463
  ```js
443
464
  import { readFileSync } from 'node:fs'
444
- import { mlDsa } from 'kxco-post-quantum'
465
+ import { mlDsa87 } from 'kxco-post-quantum'
445
466
 
446
- const pub = Buffer.from(readFileSync('release-signing-key.pub.hex', 'utf8').trim(), 'hex')
467
+ const pub = Buffer.from(readFileSync('release-signing-key-87.pub.hex', 'utf8').trim(), 'hex')
447
468
  const sig = readFileSync('evidence-node24.x.zip.sig', 'utf8').trim()
448
- mlDsa.verify(pub, readFileSync('evidence-node24.x.zip'), sig) // true
469
+ mlDsa87.verify(pub, readFileSync('evidence-node24.x.zip'), sig) // true
449
470
  ```
450
471
 
451
472
  Compare the public key with the copy committed to this repository, so the key
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kxco-post-quantum",
3
- "version": "1.7.9",
3
+ "version": "1.9.0",
4
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",
@@ -159,7 +159,7 @@
159
159
  },
160
160
  "scripts": {
161
161
  "fuzz": "node fuzz/fuzz.mjs",
162
- "test": "node --test test/basic.test.js && node --test test/backend.test.js && node --test test/context.test.js && node --test test/category5.test.js && node --test test/edge-cases.test.js && node --test test/seed.test.js && node --test test/browser-smoke.test.js && node --test test/property.test.js && node test/run-vectors.js",
162
+ "test": "node --test test/basic.test.js && node --test test/webhook-ml-dsa-87.test.js && node --test test/jws-ml-dsa-87-default.test.js && node --test test/backend.test.js && node --test test/context.test.js && node --test test/category5.test.js && node --test test/edge-cases.test.js && node --test test/seed.test.js && node --test test/browser-smoke.test.js && node --test test/property.test.js && node --test test/cbom.test.js && node test/run-vectors.js",
163
163
  "test:vectors": "node test/run-vectors.js",
164
164
  "generate:vectors": "node test/generate-vectors.js > test/vectors.json",
165
165
  "bench": "node bench/bench.js",
@@ -169,6 +169,7 @@
169
169
  "conformance:protocol": "node conformance/protocol/run-protocol.mjs --json conformance/results/protocol.json",
170
170
  "audit:deps": "node audit/run-audit.mjs --json audit/results/dependencies.json",
171
171
  "sbom": "npm sbom --sbom-format cyclonedx --sbom-type library",
172
+ "cbom": "node scripts/build-cbom.mjs",
172
173
  "bench:timing": "node --expose-gc bench/timing.mjs --iterations 20000 --json bench/results/timing.json",
173
174
  "bench:primitives": "node --expose-gc bench/primitives.mjs --iterations 100 --json bench/results/primitives.json",
174
175
  "evidence": "node scripts/build-evidence.mjs",
@@ -179,6 +180,8 @@
179
180
  "access": "public"
180
181
  },
181
182
  "devDependencies": {
183
+ "ajv": "8.20.0",
184
+ "ajv-formats": "3.0.1",
182
185
  "fast-check": "4.10.2"
183
186
  }
184
187
  }
package/src/index.js CHANGED
@@ -18,9 +18,10 @@ export * as mlDsa from './ml-dsa.js'
18
18
  export * as mlKem from './ml-kem.js'
19
19
  export * as slhDsa from './slh-dsa.js'
20
20
 
21
- // Category 5 parameter sets, for callers who are given ML-DSA-87 or
22
- // ML-KEM-1024 as a requirement. The KXCO default stays Category 3
23
- // (mlDsa / mlKem). Supporting these sets is not a CNSA 2.0 compliance claim;
21
+ // Category 5 parameter sets. mlDsa87 is the signature set for new keys, and
22
+ // mlDsa stays for the ML-DSA-65 keys that already exist. mlKem1024 is for
23
+ // callers given ML-KEM-1024 as a requirement, and mlKem stays the default for
24
+ // key establishment. Supporting these sets is not a CNSA 2.0 compliance claim;
24
25
  // see the notes at the top of each module and CONFORMANCE.md.
25
26
  export * as mlDsa87 from './ml-dsa-87.js'
26
27
  export * as mlKem1024 from './ml-kem-1024.js'
package/src/jws.d.ts CHANGED
@@ -13,7 +13,10 @@ export interface JwsHeader {
13
13
  }
14
14
 
15
15
  export interface SignJwsOptions {
16
- /** Defaults to 'ML-DSA-65'. */
16
+ /**
17
+ * Without it the secret key decides: a 4032-byte ML-DSA-65 key signs
18
+ * 'ML-DSA-65', and every other key 'ML-DSA-87', the default.
19
+ */
17
20
  alg?: JwsAlgorithm
18
21
  /** Key identifier, e.g. the 16-hex `fingerprint()` of the public key. */
19
22
  kid?: string
package/src/jws.js CHANGED
@@ -32,8 +32,8 @@ const dec = new TextDecoder()
32
32
  // and nowhere else, so a token cannot name its own verification routine — the
33
33
  // alg-confusion failure that has broken JWT libraries repeatedly.
34
34
  const ALGORITHMS = {
35
- 'ML-DSA-65': { mod: mlDsa, publicKeyBytes: 1952, signatureBytes: 3309 },
36
- 'ML-DSA-87': { mod: mlDsa87, publicKeyBytes: 2592, signatureBytes: 4627 },
35
+ 'ML-DSA-65': { mod: mlDsa, publicKeyBytes: 1952, secretKeyBytes: 4032, signatureBytes: 3309 },
36
+ 'ML-DSA-87': { mod: mlDsa87, publicKeyBytes: 2592, secretKeyBytes: 4896, signatureBytes: 4627 },
37
37
  }
38
38
 
39
39
  /** JWS `alg` values this module will sign or verify. */
@@ -46,7 +46,16 @@ function algorithmFor(alg) {
46
46
  return typeof alg === 'string' && Object.hasOwn(ALGORITHMS, alg) ? ALGORITHMS[alg] : undefined
47
47
  }
48
48
 
49
- const DEFAULT_ALG = 'ML-DSA-65'
49
+ // Without opts.alg the secret key decides: its size names the set it belongs
50
+ // to, so an ML-DSA-65 key that already exists keeps signing ML-DSA-65 tokens.
51
+ // A key of neither size gets the default, ML-DSA-87. The size is the byte
52
+ // length, so a key held as an ArrayBuffer is measured too.
53
+ const DEFAULT_ALG = 'ML-DSA-87'
54
+
55
+ function algorithmForSecretKey(secretKey) {
56
+ const size = secretKey?.byteLength ?? secretKey?.length
57
+ return JWS_ALGORITHMS.find((alg) => ALGORITHMS[alg].secretKeyBytes === size)
58
+ }
50
59
 
51
60
  function b64url(bytes) {
52
61
  if (HAS_BUFFER) return Buffer.from(bytes).toString('base64url')
@@ -104,6 +113,9 @@ function payloadBytes(payload) {
104
113
  /**
105
114
  * Sign a payload into a compact JWS.
106
115
  *
116
+ * Without `opts.alg` the key decides: a 4032-byte ML-DSA-65 secret key signs
117
+ * ML-DSA-65, and every other key ML-DSA-87.
118
+ *
107
119
  * @param {object|string|Uint8Array} payload — objects are JSON-serialised
108
120
  * @param {Buffer|Uint8Array} secretKey
109
121
  * @param {{ alg?: string, kid?: string, typ?: string, header?: object }} [opts]
@@ -113,7 +125,7 @@ export function signJws(payload, secretKey, opts = {}) {
113
125
  if (opts === null || typeof opts !== 'object') {
114
126
  throw new TypeError('expected an options object such as { kid, alg }')
115
127
  }
116
- const alg = opts.alg ?? DEFAULT_ALG
128
+ const alg = opts.alg ?? algorithmForSecretKey(secretKey) ?? DEFAULT_ALG
117
129
  const spec = algorithmFor(alg)
118
130
  if (!spec) {
119
131
  throw new Error(`unsupported JWS alg '${alg}' — this module signs ${JWS_ALGORITHMS.join(' and ')}`)
package/src/ml-dsa-87.js CHANGED
@@ -4,9 +4,9 @@
4
4
  // bytes, secret key 4896 bytes, signature 4627 bytes. Resistant to attacks by
5
5
  // quantum computers.
6
6
  //
7
- // Same API as ./ml-dsa.js, one security category higher. Use this where a
8
- // counterparty specifies Category 5 or names ML-DSA-87. ML-DSA-65 remains the
9
- // default for the KXCO stack; see the note on parameter choice below.
7
+ // Same API as ./ml-dsa.js, one security category higher. This is the set for
8
+ // new keys and signatures. ML-DSA-65 (./ml-dsa.js) stays for keys that already
9
+ // exist; see the note on parameter choice below.
10
10
  //
11
11
  // Isomorphic: works in Node and modern browsers. Returns Buffer on Node
12
12
  // (backwards compatible), Uint8Array in browsers.
package/src/slh-dsa.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // hedge alongside ML-DSA-65.
8
8
  //
9
9
  // Tradeoff: signatures are ~5x larger than ML-DSA-65 (16224 vs 3309 bytes) and
10
- // signing is slower. Use ML-DSA-65 as the default; reach for SLH-DSA when you
10
+ // signing is slower. Use ML-DSA-87 as the default; reach for SLH-DSA when you
11
11
  // want a signature whose security does not depend on lattice hardness.
12
12
  //
13
13
  // Isomorphic: works in Node and modern browsers. Returns Buffer on Node
package/src/webhook.d.ts CHANGED
@@ -34,8 +34,10 @@ export function verifyHmac(
34
34
  ): boolean
35
35
 
36
36
  /**
37
- * Produce the X-KXCO-PQ-Signature header value: the hex ML-DSA-65
38
- * signature over the envelope, prefixed with `ml-dsa-65=`.
37
+ * Produce the X-KXCO-PQ-Signature header value: the hex ML-DSA signature
38
+ * over the envelope, prefixed with its parameter set. The key decides it: an
39
+ * ML-DSA-87 secret key (4896 bytes) gives `ml-dsa-87=<hex>`, and an
40
+ * ML-DSA-65 key gives `ml-dsa-65=<hex>` as it always has.
39
41
  */
40
42
  export function pqSign(
41
43
  secretKey: Buffer | Uint8Array,
@@ -44,8 +46,10 @@ export function pqSign(
44
46
  ): string
45
47
 
46
48
  /**
47
- * Verify a hex ML-DSA-65 signature header.
48
- * Accepts the value with or without the `ml-dsa-65=` prefix.
49
+ * Verify a hex ML-DSA signature header under the set `publicKey` belongs to.
50
+ * An ML-DSA-65 key accepts the value with or without the `ml-dsa-65=` prefix.
51
+ * An ML-DSA-87 key (2592 bytes) accepts only `ml-dsa-87=<hex>`. A prefix
52
+ * naming the other set returns false.
49
53
  */
50
54
  export function verifyPq(
51
55
  publicKey: Buffer | Uint8Array,
@@ -59,7 +63,7 @@ export interface SignDeliveryArgs {
59
63
  rawBody: string | Buffer
60
64
  /** Per-endpoint shared secret for HMAC */
61
65
  hmacSecret: string | Buffer
62
- /** Raw ML-DSA-65 secret key */
66
+ /** Raw ML-DSA-65 or ML-DSA-87 secret key. The key decides the header form. */
63
67
  pqSecretKey: Buffer | Uint8Array
64
68
  /** 16-hex kid fingerprint of the matching public key */
65
69
  pqKid: string
@@ -73,7 +77,7 @@ export interface SignDeliveryHeaders {
73
77
  'Content-Type': 'application/json'
74
78
  'X-KXCO-Timestamp': string
75
79
  'X-KXCO-Signature': string // sha256=<hex>
76
- 'X-KXCO-PQ-Signature': string // ml-dsa-65=<hex>
80
+ 'X-KXCO-PQ-Signature': string // ml-dsa-65=<hex>, or ml-dsa-87=<hex> for an ML-DSA-87 key
77
81
  'X-KXCO-PQ-Kid': string
78
82
  'X-KXCO-Event'?: string
79
83
  'X-KXCO-Delivery'?: string
package/src/webhook.js CHANGED
@@ -17,6 +17,19 @@
17
17
  import { hmac } from '@noble/hashes/hmac.js'
18
18
  import { sha256 } from '@noble/hashes/sha2.js'
19
19
  import { sign as mlDsaSign, verify as mlDsaVerify } from './ml-dsa.js'
20
+ import { sign as mlDsa87Sign, verify as mlDsa87Verify } from './ml-dsa-87.js'
21
+
22
+ // The PQ signature header names its parameter set: `ml-dsa-65=<hex>`, or
23
+ // `ml-dsa-87=<hex>` for an ML-DSA-87 key. The key decides which: an ML-DSA-87
24
+ // key (4896-byte secret, 2592-byte public) signs and verifies the -87 form,
25
+ // and every other key the -65 form, exactly as before -87 existed. A header
26
+ // whose prefix names the other set fails, so a key is never checked as the
27
+ // set it is not.
28
+ const ML_DSA_65 = { prefix: 'ml-dsa-65=', sign: mlDsaSign, verify: mlDsaVerify }
29
+ const ML_DSA_87 = { prefix: 'ml-dsa-87=', sign: mlDsa87Sign, verify: mlDsa87Verify }
30
+ const ML_DSA_87_SECRET_KEY_BYTES = 4896
31
+ const ML_DSA_87_PUBLIC_KEY_BYTES = 2592
32
+ const ML_DSA_65_PUBLIC_KEY_BYTES = 1952
20
33
 
21
34
  const HAS_BUFFER = typeof Buffer !== 'undefined'
22
35
  const enc = new TextEncoder()
@@ -77,21 +90,34 @@ export function verifyHmac(secret, timestamp, rawBody, sigHeader) {
77
90
  }
78
91
 
79
92
  /**
80
- * Produce the X-KXCO-PQ-Signature header value.
93
+ * Produce the X-KXCO-PQ-Signature header value: `ml-dsa-65=<hex>`, or
94
+ * `ml-dsa-87=<hex>` when `secretKey` is an ML-DSA-87 key.
81
95
  */
82
96
  export function pqSign(secretKey, timestamp, rawBody) {
83
- const sig = mlDsaSign(secretKey, envelope(timestamp, rawBody))
84
- return `ml-dsa-65=${sig}`
97
+ const set = secretKey?.length === ML_DSA_87_SECRET_KEY_BYTES ? ML_DSA_87 : ML_DSA_65
98
+ const sig = set.sign(secretKey, envelope(timestamp, rawBody))
99
+ return `${set.prefix}${sig}`
85
100
  }
86
101
 
87
102
  /**
88
- * Verify a hex ML-DSA-65 signature header.
103
+ * Verify a hex ML-DSA signature header under the set `publicKey` belongs to.
104
+ *
105
+ * An ML-DSA-65 key takes `ml-dsa-65=<hex>` or, as it always has, the bare hex.
106
+ * An ML-DSA-87 key takes only `ml-dsa-87=<hex>`. A prefix naming the other set
107
+ * is false.
89
108
  */
90
109
  export function verifyPq(publicKey, timestamp, rawBody, sigHeader) {
91
- const hex = sigHeader.startsWith('ml-dsa-65=')
92
- ? sigHeader.slice('ml-dsa-65='.length)
93
- : sigHeader
94
- return mlDsaVerify(publicKey, envelope(timestamp, rawBody), hex)
110
+ const is87 = publicKey?.length === ML_DSA_87_PUBLIC_KEY_BYTES
111
+ // A key of neither set's size is refused here, not left to the primitive.
112
+ if (!is87 && publicKey?.length !== ML_DSA_65_PUBLIC_KEY_BYTES) return false
113
+ const set = is87 ? ML_DSA_87 : ML_DSA_65
114
+ // Only the key's own prefix is stripped. A header naming the other set is
115
+ // then neither that prefix nor bare hex, and fails.
116
+ if (sigHeader.startsWith(set.prefix)) {
117
+ return set.verify(publicKey, envelope(timestamp, rawBody), sigHeader.slice(set.prefix.length))
118
+ }
119
+ if (is87) return false
120
+ return set.verify(publicKey, envelope(timestamp, rawBody), sigHeader)
95
121
  }
96
122
 
97
123
  /**