kxco-post-quantum 1.7.8 → 1.8.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 +16 -0
- package/README.md +2 -0
- package/package.json +2 -2
- package/src/webhook.d.ts +15 -8
- package/src/webhook.js +44 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.0
|
|
4
|
+
The webhook helpers sign and verify with ML-DSA-87 keys. The key decides the
|
|
5
|
+
X-KXCO-PQ-Signature form: pqSign and signDelivery give `ml-dsa-87=<hex>` for
|
|
6
|
+
an ML-DSA-87 secret key and `ml-dsa-65=<hex>` for an ML-DSA-65 one, unchanged.
|
|
7
|
+
verifyPq and verifyDelivery verify under the set of the public key they are
|
|
8
|
+
given and accept only that set's prefix, so a header naming the other set
|
|
9
|
+
fails. An ML-DSA-65 key still accepts the bare hex value, and a delivery signed
|
|
10
|
+
by 1.7.9 verifies unchanged, which a fixture now tests. The webhook contract in
|
|
11
|
+
`spec/` describes the `ml-dsa-87=` form.
|
|
12
|
+
|
|
13
|
+
## 1.7.9
|
|
14
|
+
|
|
15
|
+
verifyDelivery reads each header only as a string. A header that arrives as an
|
|
16
|
+
array, as some frameworks deliver a repeated header, counts as missing, so the
|
|
17
|
+
result reports the failed check where an array signature header used to throw.
|
|
18
|
+
|
|
3
19
|
## 1.7.8
|
|
4
20
|
|
|
5
21
|
verifyDelivery accepts an X-KXCO-Timestamp header only as decimal digits.
|
package/README.md
CHANGED
|
@@ -291,6 +291,8 @@ and the operator selects, as the next section shows.
|
|
|
291
291
|
|
|
292
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`.
|
|
293
293
|
|
|
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.
|
|
295
|
+
|
|
294
296
|
---
|
|
295
297
|
|
|
296
298
|
## Requiring the native backend
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kxco-post-quantum",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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/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",
|
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
|
|
@@ -89,8 +93,11 @@ export interface SignDeliveryHeaders {
|
|
|
89
93
|
export function signDelivery(args: SignDeliveryArgs): SignDeliveryHeaders
|
|
90
94
|
|
|
91
95
|
export interface VerifyDeliveryArgs {
|
|
92
|
-
/**
|
|
93
|
-
|
|
96
|
+
/**
|
|
97
|
+
* HTTP headers with LOWERCASE keys. Each KXCO header is read only as a
|
|
98
|
+
* string; a header that arrives as an array counts as missing.
|
|
99
|
+
*/
|
|
100
|
+
headers: Record<string, string | string[] | undefined>
|
|
94
101
|
/** The EXACT request body bytes as received */
|
|
95
102
|
rawBody: string | Buffer
|
|
96
103
|
/** Optional: enable HMAC verification by providing the shared secret */
|
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()
|
|
@@ -41,6 +54,9 @@ function constTimeEqualStrings(a, b) {
|
|
|
41
54
|
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
42
55
|
return diff === 0
|
|
43
56
|
}
|
|
57
|
+
function headerString(value) {
|
|
58
|
+
return typeof value === 'string' ? value : undefined
|
|
59
|
+
}
|
|
44
60
|
|
|
45
61
|
/**
|
|
46
62
|
* Build the canonical signed envelope: timestamp + "." + raw body string.
|
|
@@ -74,21 +90,34 @@ export function verifyHmac(secret, timestamp, rawBody, sigHeader) {
|
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
/**
|
|
77
|
-
* 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.
|
|
78
95
|
*/
|
|
79
96
|
export function pqSign(secretKey, timestamp, rawBody) {
|
|
80
|
-
const
|
|
81
|
-
|
|
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}`
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
/**
|
|
85
|
-
* 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.
|
|
86
108
|
*/
|
|
87
109
|
export function verifyPq(publicKey, timestamp, rawBody, sigHeader) {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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)
|
|
92
121
|
}
|
|
93
122
|
|
|
94
123
|
/**
|
|
@@ -113,10 +142,13 @@ export function signDelivery({ rawBody, hmacSecret, pqSecretKey, pqKid, event, d
|
|
|
113
142
|
* Verify a webhook delivery on the receiving side.
|
|
114
143
|
*/
|
|
115
144
|
export function verifyDelivery({ headers, rawBody, hmacSecret, pqPublicKey, pinnedKid, windowSeconds = 300 }) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
145
|
+
// Each header is read only as a string. Some frameworks hand a repeated
|
|
146
|
+
// header over as an array; that counts as missing rather than being coerced,
|
|
147
|
+
// so a header of any other type fails its check instead of throwing.
|
|
148
|
+
const ts = headerString(headers['x-kxco-timestamp'])
|
|
149
|
+
const sigHmac = headerString(headers['x-kxco-signature'])
|
|
150
|
+
const sigPq = headerString(headers['x-kxco-pq-signature'])
|
|
151
|
+
const kid = headerString(headers['x-kxco-pq-kid'])
|
|
120
152
|
|
|
121
153
|
// Both signatures cover the header exactly as it arrives, so it is read
|
|
122
154
|
// only as the decimal digits it is specified to be. parseInt alone would
|