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 +46 -0
- package/CRYPTO-INVENTORY.md +2 -1
- package/MIGRATION.md +6 -5
- package/PQCMM.md +5 -2
- package/README.md +65 -44
- package/package.json +5 -2
- package/src/index.js +4 -3
- package/src/jws.d.ts +4 -1
- package/src/jws.js +16 -4
- package/src/ml-dsa-87.js +3 -3
- package/src/slh-dsa.js +1 -1
- package/src/webhook.d.ts +10 -6
- package/src/webhook.js +34 -8
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
|
package/CRYPTO-INVENTORY.md
CHANGED
|
@@ -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
|
|
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,
|
|
19
|
-
| Signatures,
|
|
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
|
|
28
|
-
|
|
29
|
-
presence as the basis for a compliance
|
|
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.
|
|
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 |
|
|
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
|
[](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/actions/workflows/conformance.yml)
|
|
12
12
|
[](./LICENSE)
|
|
13
13
|
|
|
14
|
-
- **All three NIST standards.** ML-
|
|
15
|
-
- **The CNSA 2.0 parameter sets ship.** ML-DSA-87 and ML-KEM-1024 at Category 5, with the same API as the
|
|
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-
|
|
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) | `
|
|
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-
|
|
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 {
|
|
60
|
+
import { mlDsa87, mlKem, slhDsa, fingerprint, kidEquals } from 'kxco-post-quantum'
|
|
61
61
|
|
|
62
|
-
// ML-DSA-
|
|
63
|
-
const { publicKey, secretKey } =
|
|
64
|
-
const sig =
|
|
65
|
-
const ok =
|
|
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
|
|
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)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
103
|
+
| | Category 5 | Category 3 |
|
|
103
104
|
|---|---|---|
|
|
104
|
-
| Signatures | `
|
|
105
|
-
| Key encapsulation | `
|
|
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 =
|
|
124
|
+
const sig = mlDsa87.sign(secretKey, 'hello', { context: 'kxco-nexus-v1' })
|
|
124
125
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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. `
|
|
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
|
-
|
|
147
|
-
|
|
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 `
|
|
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 {
|
|
272
|
+
import { mlDsa87, seed } from 'kxco-post-quantum'
|
|
255
273
|
|
|
256
|
-
const key =
|
|
257
|
-
const jwk = seed.exportJwk('ML-DSA-
|
|
258
|
-
// { kty: 'AKP', alg: 'ML-DSA-
|
|
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-
|
|
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`
|
|
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-
|
|
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
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
|
22
|
-
// ML-
|
|
23
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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-
|
|
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
|
|
38
|
-
*
|
|
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
|
|
48
|
-
*
|
|
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
|
|
84
|
-
|
|
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
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
/**
|