kxco-post-quantum 1.8.0 → 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,41 @@
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
+
3
39
  ## 1.8.0
4
40
  The webhook helpers sign and verify with ML-DSA-87 keys. The key decides the
5
41
  X-KXCO-PQ-Signature form: pqSign and signDelivery give `ml-dsa-87=<hex>` for
@@ -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,11 +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
291
309
 
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`.
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`.
293
311
 
294
- The key decides the PQ header form. An ML-DSA-65 key signs `ml-dsa-65=<hex>`, and an ML-DSA-87 key signs `ml-dsa-87=<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.
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.
295
313
 
296
314
  ---
297
315
 
@@ -308,7 +326,7 @@ validated module, make the native backend a requirement:
308
326
  ```js
309
327
  import { requireNativeBackend } from 'kxco-post-quantum'
310
328
 
311
- requireNativeBackend(['ML-DSA-65', 'ML-KEM-768'])
329
+ requireNativeBackend(['ML-DSA-87', 'ML-KEM-768'])
312
330
  ```
313
331
 
314
332
  It throws `ERR_KXCO_PQ_BACKEND` if the JavaScript backend is live, or if the
@@ -346,7 +364,7 @@ KXCO_PQ_BACKEND=openssl prefer OpenSSL, which is the default anyway
346
364
  import { requireBackend } from 'kxco-post-quantum'
347
365
 
348
366
  requireBackend('javascript') // the JS certificate
349
- requireBackend('openssl', ['ML-DSA-65', 'ML-KEM-768']) // the native one
367
+ requireBackend('openssl', ['ML-DSA-87', 'ML-KEM-768']) // the native one
350
368
  ```
351
369
 
352
370
  A value that is neither throws at import, so a misspelled pin is caught before
@@ -430,24 +448,25 @@ npm view kxco-post-quantum license # Apache-2.0
430
448
  npm audit signatures --json # assert invalid:0 and missing:0
431
449
  ```
432
450
 
433
- 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,
434
452
  and carries a SLSA provenance file recording the workflow that built it.
435
453
 
436
454
  ```
437
455
  manifest-node24.x.json the bundle's manifest
438
456
  evidence-node24.x.zip the bundle
439
- 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
440
458
  evidence.intoto.jsonl SLSA provenance
441
- 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
442
461
  ```
443
462
 
444
463
  ```js
445
464
  import { readFileSync } from 'node:fs'
446
- import { mlDsa } from 'kxco-post-quantum'
465
+ import { mlDsa87 } from 'kxco-post-quantum'
447
466
 
448
- 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')
449
468
  const sig = readFileSync('evidence-node24.x.zip.sig', 'utf8').trim()
450
- mlDsa.verify(pub, readFileSync('evidence-node24.x.zip'), sig) // true
469
+ mlDsa87.verify(pub, readFileSync('evidence-node24.x.zip'), sig) // true
451
470
  ```
452
471
 
453
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.8.0",
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/webhook-ml-dsa-87.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