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 +26 -0
- package/README.md +20 -5
- package/package.json +5 -2
- package/src/_native.node.js +12 -3
- package/src/jws.js +18 -4
- package/src/kid.js +1 -0
- package/src/ml-dsa-87.js +1 -1
- package/src/ml-dsa.js +1 -1
- package/src/seed.js +6 -3
- package/src/slh-dsa.js +1 -1
- package/src/webhook.d.ts +5 -2
- package/src/webhook.js +15 -5
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
|
|
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.
|
|
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
|
}
|
package/src/_native.node.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
192
|
+
const spec = algorithmFor(header.alg)
|
|
185
193
|
if (!spec) {
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
93
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
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
|
-
|
|
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
|
|