kxco-post-quantum 1.7.7 → 1.7.9

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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.9
4
+
5
+ verifyDelivery reads each header only as a string. A header that arrives as an
6
+ array, as some frameworks deliver a repeated header, counts as missing, so the
7
+ result reports the failed check where an array signature header used to throw.
8
+
9
+ ## 1.7.8
10
+
11
+ verifyDelivery accepts an X-KXCO-Timestamp header only as decimal digits.
12
+
13
+ Hex signatures and keys are accepted only as hex digits, across ML-DSA-65,
14
+ ML-DSA-87, SLH-DSA, fingerprint and the JWS helpers. verifyJws, signJws and the
15
+ seed helpers resolve algorithm names from their own tables only, and verifyJws
16
+ reports a header kid that is not a string without throwing.
17
+
18
+ slhDsa.sign falls back to the JavaScript backend on a Node build that generates
19
+ SLH-DSA keys but cannot import one as a JWK, such as 24.15.0, where it threw.
20
+ CI now tests 24.15.0.
21
+
22
+ The npm page gives each parameter set its own label in the examples, so no two
23
+ share seed bytes, and maps each EO 14412 and OMB M-26-15 requirement to the
24
+ export that answers it.
25
+
26
+ The dependency audit reviews the dev tree the way it reviews the production
27
+ tree, so the property tests' fast-check no longer fails the conformance workflow.
28
+
3
29
  ## 1.7.7
4
30
 
5
31
  Documentation. No source change.
package/README.md CHANGED
@@ -25,6 +25,19 @@
25
25
  - **United States:** [Executive Order 14412](https://www.federalregister.gov/documents/2026/06/25/2026-12909/securing-the-nation-against-advanced-cryptographic-attacks), signed on 22 June 2026, moves federal high-value and high-impact systems to post-quantum key establishment by 31 December 2030 and to post-quantum signatures by 31 December 2031. [OMB M-26-15](https://www.whitehouse.gov/wp-content/uploads/2026/06/M-26-15-Execution-of-the-Migration-to-Post-Quantum-Cryptography.pdf) requires PQC-agile libraries for all new applications.
26
26
  - **United Kingdom:** the [NCSC](https://www.ncsc.gov.uk/guidance/pqc-migration-timelines) sets 2028, 2031 and 2035 as its migration milestones.
27
27
 
28
+ **Each requirement has an export.**
29
+
30
+ | The requirement | What answers it |
31
+ |---|---|
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 |
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 |
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
+ | 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
+
39
+ **TLS has already moved, so this is the rest.** A stock Node.js client on 22.23.3, 24.21.0 and 26.1.0 negotiates X25519MLKEM768, the hybrid of X25519 and ML-KEM-768, with no options set (measured 30 September 2026). What TLS never reaches is what your application signs, issues and stores, and that is this package. The walkthrough, with every block run against this package: [The 2030 Post-Quantum Deadline in Code](https://www.livetradingnews.com/the-2030-post-quantum-deadline-in-code-6-changes-and-the-test-for-each).
40
+
28
41
  This is the primitive layer every other `kxco-pq-*` package builds on.
29
42
 
30
43
  [Conformance](./CONFORMANCE.md) · [Benchmarks](./BENCHMARKS.md) · [Migration](./MIGRATION.md) · [Threat model](./THREAT-MODEL.md) · [Changelog](./CHANGELOG.md) · [For institutions](#for-institutions) · [kxco.ai](https://kxco.ai)
@@ -47,12 +60,12 @@ Requires Node.js 20.19+. ESM-only.
47
60
  import { mlDsa, mlKem, slhDsa, fingerprint, kidEquals } from 'kxco-post-quantum'
48
61
 
49
62
  // ML-DSA-65: sign and verify
50
- const { publicKey, secretKey } = mlDsa.keypairFromMaster(masterSecret, 'signing-v1')
63
+ const { publicKey, secretKey } = mlDsa.keypairFromMaster(masterSecret, 'signing-65-v1')
51
64
  const sig = mlDsa.sign(secretKey, 'hello')
52
65
  const ok = mlDsa.verify(publicKey, 'hello', sig) // true
53
66
 
54
67
  // SLH-DSA-SHA2-192s: hash-based signatures (same API shape as mlDsa)
55
- const slh = slhDsa.keypairFromMaster(masterSecret, 'signing-v1')
68
+ const slh = slhDsa.keypairFromMaster(masterSecret, 'signing-slh-v1')
56
69
  const slhSig = slhDsa.sign(slh.secretKey, 'hello')
57
70
  const slhOk = slhDsa.verify(slh.publicKey, 'hello', slhSig) // true
58
71
 
@@ -69,6 +82,8 @@ const recovered = mlKem.decapsulate(ciphertext, kemKeys.secretKey)
69
82
 
70
83
  `masterSecret` is a Node Buffer or typed array (Uint8Array) with at least 16 bytes of entropy (typically 32–64 bytes from an env var or KMS).
71
84
 
85
+ **One label per key.** The label is the domain separation: one master under one label yields the same seed bytes, whichever parameter set reads them. Give every parameter set and every purpose its own label, as above, and version it (`-v1`, `-v2`) so a rotation is a new label.
86
+
72
87
  ### Category 5 parameter sets
73
88
 
74
89
  `mlDsa87` (ML-DSA-87) and `mlKem1024` (ML-KEM-1024) have the same API as `mlDsa`
@@ -79,7 +94,7 @@ Category 3.
79
94
  ```js
80
95
  import { mlDsa87, mlKem1024 } from 'kxco-post-quantum'
81
96
 
82
- const { publicKey, secretKey } = mlDsa87.keypairFromMaster(masterSecret, 'signing-v1')
97
+ const { publicKey, secretKey } = mlDsa87.keypairFromMaster(masterSecret, 'signing-87-v1')
83
98
  const sig = mlDsa87.sign(secretKey, 'hello') // 4627 bytes, 9254 hex chars
84
99
  mlDsa87.verify(publicKey, 'hello', sig) // true
85
100
  ```
@@ -90,8 +105,8 @@ mlDsa87.verify(publicKey, 'hello', sig) // true
90
105
  | Key encapsulation | `mlKem`: pk 1184, ct 1088 | `mlKem1024`: pk 1568, ct 1568 |
91
106
 
92
107
  The two sets do not mix, deliberately. Default derivation info differs, so one
93
- master yields unrelated keys for each; and a signature from one set does not
94
- verify under the other. Sizes are the migration cost, so check any fixed-width
108
+ master yields unrelated keys for each, and so does a distinct label of your own;
109
+ a signature from one set does not verify under the other. Sizes are the migration cost, so check any fixed-width
95
110
  signature or key field before mixing sets in one system.
96
111
 
97
112
  **CNSA 2.0 names ML-DSA-87 and ML-KEM-1024**, so moving a deployment to the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kxco-post-quantum",
3
- "version": "1.7.7",
3
+ "version": "1.7.9",
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/run-vectors.js",
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",
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",
@@ -177,5 +177,8 @@
177
177
  "publishConfig": {
178
178
  "provenance": true,
179
179
  "access": "public"
180
+ },
181
+ "devDependencies": {
182
+ "fast-check": "4.10.2"
180
183
  }
181
184
  }
@@ -113,8 +113,7 @@ function der(tag, payload) {
113
113
  // The AlgorithmIdentifier is read back off a key OpenSSL generates itself rather
114
114
  // than hard-coded from the OID registry, so a build that spells one differently
115
115
  // cannot produce a subtly wrong encoding here.
116
- function algorithmIdentifier(nodeName) {
117
- const { privateKey } = crypto.generateKeyPairSync(nodeName)
116
+ function algorithmIdentifier(privateKey) {
118
117
  const pkcs8 = privateKey.export({ format: 'der', type: 'pkcs8' })
119
118
  const headerLength = pkcs8[1] & 0x80 ? 2 + (pkcs8[1] & 0x7f) : 2
120
119
  const start = headerLength + 3 // skip the version INTEGER
@@ -155,13 +154,23 @@ const ALGORITHMS = {
155
154
 
156
155
  // Probed once, by actually generating a key. Asking the Node version would be a
157
156
  // guess about which build shipped which OpenSSL; generating a key is the fact.
157
+ //
158
+ // For the JWK form, generating is not the whole fact. Node 24.15.0 generates
159
+ // SLH-DSA keys but refuses one as a JWK, which is the only way sign() can load
160
+ // it, so every SLH-DSA signature threw there instead of falling back. The
161
+ // import sign() makes is probed too, in the exact shape it makes it.
158
162
  function probe() {
159
163
  const table = new Map()
160
164
  for (const [name, spec] of Object.entries(ALGORITHMS)) {
161
165
  try {
166
+ const { privateKey } = crypto.generateKeyPairSync(spec.nodeName)
167
+ if (spec.privateForm === 'jwk') {
168
+ const { pub, priv } = privateKey.export({ format: 'jwk' })
169
+ crypto.createPrivateKey({ key: { kty: 'AKP', alg: name, pub, priv }, format: 'jwk' })
170
+ }
162
171
  // jwkAlg is the FIPS name: SLH-DSA goes in as a JWK, which names the
163
172
  // algorithm in the payload rather than in an AlgorithmIdentifier.
164
- table.set(name, { ...spec, jwkAlg: name, algid: algorithmIdentifier(spec.nodeName) })
173
+ table.set(name, { ...spec, jwkAlg: name, algid: algorithmIdentifier(privateKey) })
165
174
  } catch {
166
175
  // Not in this build. The JavaScript backend covers it.
167
176
  }
package/src/jws.js CHANGED
@@ -39,6 +39,13 @@ const ALGORITHMS = {
39
39
  /** JWS `alg` values this module will sign or verify. */
40
40
  export const JWS_ALGORITHMS = Object.keys(ALGORITHMS)
41
41
 
42
+ // Own keys only, and only a string: a name the table inherits, such as
43
+ // `constructor`, is not an algorithm, and neither is an array that happens to
44
+ // stringify to one.
45
+ function algorithmFor(alg) {
46
+ return typeof alg === 'string' && Object.hasOwn(ALGORITHMS, alg) ? ALGORITHMS[alg] : undefined
47
+ }
48
+
42
49
  const DEFAULT_ALG = 'ML-DSA-65'
43
50
 
44
51
  function b64url(bytes) {
@@ -78,6 +85,7 @@ function bytesToHex(bytes) {
78
85
  }
79
86
 
80
87
  function hexToBytes(hex) {
88
+ if (typeof hex !== 'string' || hex.length % 2 || !/^[0-9a-fA-F]*$/.test(hex)) throw new Error('invalid hex')
81
89
  const b = new Uint8Array(hex.length / 2)
82
90
  for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
83
91
  return b
@@ -106,7 +114,7 @@ export function signJws(payload, secretKey, opts = {}) {
106
114
  throw new TypeError('expected an options object such as { kid, alg }')
107
115
  }
108
116
  const alg = opts.alg ?? DEFAULT_ALG
109
- const spec = ALGORITHMS[alg]
117
+ const spec = algorithmFor(alg)
110
118
  if (!spec) {
111
119
  throw new Error(`unsupported JWS alg '${alg}' — this module signs ${JWS_ALGORITHMS.join(' and ')}`)
112
120
  }
@@ -181,15 +189,21 @@ export function verifyJws(token, publicKey, opts = {}) {
181
189
  return { valid: false, error: 'malformed JWS: header is not an object' }
182
190
  }
183
191
 
184
- const spec = ALGORITHMS[header.alg]
192
+ const spec = algorithmFor(header.alg)
185
193
  if (!spec) {
186
- return { valid: false, error: `unsupported JWS alg '${header.alg}'` }
194
+ // A header alg that is not a string is named as JSON, which cannot throw
195
+ // on anything JSON.parse produced; interpolating it could.
196
+ const named = typeof header.alg === 'string' ? header.alg : JSON.stringify(header.alg)
197
+ return { valid: false, error: `unsupported JWS alg '${named}'` }
187
198
  }
188
199
  if (opts.alg !== undefined && header.alg !== opts.alg) {
189
200
  return { valid: false, error: `alg mismatch: expected '${opts.alg}', token declares '${header.alg}'` }
190
201
  }
191
202
  if (opts.kid !== undefined && header.kid !== opts.kid) {
192
- return { valid: false, error: `kid mismatch: expected '${opts.kid}', token declares '${header.kid ?? '(none)'}'` }
203
+ // Named as JSON when it is not a string, for the same reason as alg above.
204
+ const declared = header.kid === undefined ? '(none)'
205
+ : typeof header.kid === 'string' ? header.kid : JSON.stringify(header.kid)
206
+ return { valid: false, error: `kid mismatch: expected '${opts.kid}', token declares '${declared}'` }
193
207
  }
194
208
 
195
209
  // RFC 7515 section 4.1.11: a verifier that does not understand every member
package/src/kid.js CHANGED
@@ -12,6 +12,7 @@ import { sha256 } from '@noble/hashes/sha2.js'
12
12
 
13
13
  function hexToBytes(hex) {
14
14
  if (hex.length % 2) throw new Error('odd hex length')
15
+ if (!/^[0-9a-fA-F]*$/.test(hex)) throw new Error('invalid hex')
15
16
  const b = new Uint8Array(hex.length / 2)
16
17
  for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
17
18
  return b
package/src/ml-dsa-87.js CHANGED
@@ -44,7 +44,7 @@ function toBytes(input) {
44
44
  throw new Error('expected Uint8Array or string')
45
45
  }
46
46
  function hexToBytes(hex) {
47
- if (typeof hex !== 'string' || hex.length % 2) throw new Error('invalid hex')
47
+ if (typeof hex !== 'string' || hex.length % 2 || !/^[0-9a-fA-F]*$/.test(hex)) throw new Error('invalid hex')
48
48
  const b = new Uint8Array(hex.length / 2)
49
49
  for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
50
50
  return b
package/src/ml-dsa.js CHANGED
@@ -42,7 +42,7 @@ function toBytes(input) {
42
42
  throw new Error('expected Uint8Array or string')
43
43
  }
44
44
  function hexToBytes(hex) {
45
- if (typeof hex !== 'string' || hex.length % 2) throw new Error('invalid hex')
45
+ if (typeof hex !== 'string' || hex.length % 2 || !/^[0-9a-fA-F]*$/.test(hex)) throw new Error('invalid hex')
46
46
  const b = new Uint8Array(hex.length / 2)
47
47
  for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
48
48
  return b
package/src/seed.js CHANGED
@@ -71,7 +71,9 @@ const PARAMS = {
71
71
  export const SEED_ALGORITHMS = Object.keys(PARAMS)
72
72
 
73
73
  function paramsFor(alg) {
74
- const p = PARAMS[alg]
74
+ // Own keys only, so a name the table inherits, such as `constructor`, is an
75
+ // unsupported algorithm rather than a parameter set with no sizes.
76
+ const p = typeof alg === 'string' && Object.hasOwn(PARAMS, alg) ? PARAMS[alg] : undefined
75
77
  if (!p) {
76
78
  throw new Error(
77
79
  `unsupported algorithm '${alg}' — seed form is defined for ${SEED_ALGORITHMS.join(', ')}`,
@@ -166,8 +168,9 @@ export function seedFromMaster(alg, master, info) {
166
168
  * Export an AKP JWK (RFC 9964).
167
169
  *
168
170
  * With `seed`, the result is a private JWK carrying the seed in `priv` and is
169
- * accepted directly by `crypto.createPrivateKey({ format: 'jwk' })` on Node
170
- * 24+. Without it, the result is a public JWK.
171
+ * accepted directly by `crypto.createPrivateKey({ format: 'jwk' })` on a Node
172
+ * build that imports AKP JWKs for that set. Node 24.15.0 does for ML-DSA and
173
+ * not for ML-KEM. Without it, the result is a public JWK.
171
174
  *
172
175
  * An expanded secret key is NOT accepted in place of a seed: RFC 9964 has no
173
176
  * encoding for one, and silently deriving something else would produce a JWK
package/src/slh-dsa.js CHANGED
@@ -29,7 +29,7 @@ function toBytes(input) {
29
29
  throw new Error('expected Uint8Array or string')
30
30
  }
31
31
  function hexToBytes(hex) {
32
- if (typeof hex !== 'string' || hex.length % 2) throw new Error('invalid hex')
32
+ if (typeof hex !== 'string' || hex.length % 2 || !/^[0-9a-fA-F]*$/.test(hex)) throw new Error('invalid hex')
33
33
  const b = new Uint8Array(hex.length / 2)
34
34
  for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
35
35
  return b
package/src/webhook.d.ts CHANGED
@@ -89,8 +89,11 @@ export interface SignDeliveryHeaders {
89
89
  export function signDelivery(args: SignDeliveryArgs): SignDeliveryHeaders
90
90
 
91
91
  export interface VerifyDeliveryArgs {
92
- /** HTTP headers with LOWERCASE keys */
93
- headers: Record<string, string | undefined>
92
+ /**
93
+ * HTTP headers with LOWERCASE keys. Each KXCO header is read only as a
94
+ * string; a header that arrives as an array counts as missing.
95
+ */
96
+ headers: Record<string, string | string[] | undefined>
94
97
  /** The EXACT request body bytes as received */
95
98
  rawBody: string | Buffer
96
99
  /** Optional: enable HMAC verification by providing the shared secret */
package/src/webhook.js CHANGED
@@ -41,6 +41,9 @@ function constTimeEqualStrings(a, b) {
41
41
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
42
42
  return diff === 0
43
43
  }
44
+ function headerString(value) {
45
+ return typeof value === 'string' ? value : undefined
46
+ }
44
47
 
45
48
  /**
46
49
  * Build the canonical signed envelope: timestamp + "." + raw body string.
@@ -113,12 +116,19 @@ export function signDelivery({ rawBody, hmacSecret, pqSecretKey, pqKid, event, d
113
116
  * Verify a webhook delivery on the receiving side.
114
117
  */
115
118
  export function verifyDelivery({ headers, rawBody, hmacSecret, pqPublicKey, pinnedKid, windowSeconds = 300 }) {
116
- const ts = headers['x-kxco-timestamp']
117
- const sigHmac = headers['x-kxco-signature']
118
- const sigPq = headers['x-kxco-pq-signature']
119
- const kid = headers['x-kxco-pq-kid']
119
+ // Each header is read only as a string. Some frameworks hand a repeated
120
+ // header over as an array; that counts as missing rather than being coerced,
121
+ // so a header of any other type fails its check instead of throwing.
122
+ const ts = headerString(headers['x-kxco-timestamp'])
123
+ const sigHmac = headerString(headers['x-kxco-signature'])
124
+ const sigPq = headerString(headers['x-kxco-pq-signature'])
125
+ const kid = headerString(headers['x-kxco-pq-kid'])
120
126
 
121
- const tsNum = parseInt(ts, 10)
127
+ // Both signatures cover the header exactly as it arrives, so it is read
128
+ // only as the decimal digits it is specified to be. parseInt alone would
129
+ // take the leading digits of `1700000000.{"a":1` and leave the rest of the
130
+ // header to be signed, which lets the start of a body move into it.
131
+ const tsNum = /^[0-9]+$/.test(ts) ? parseInt(ts, 10) : NaN
122
132
  const timestampOk = Number.isFinite(tsNum) &&
123
133
  Math.abs(Date.now() / 1000 - tsNum) <= windowSeconds
124
134