kxco-post-quantum 1.7.9 → 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 CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## 1.7.9
4
14
 
5
15
  verifyDelivery reads each header only as a string. A header that arrives as an
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.7.9",
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-65
38
- * signature over the envelope, prefixed with `ml-dsa-65=`.
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-65 signature header.
48
- * Accepts the value with or without the `ml-dsa-65=` prefix.
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 sig = mlDsaSign(secretKey, envelope(timestamp, rawBody))
84
- return `ml-dsa-65=${sig}`
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-65 signature header.
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 hex = sigHeader.startsWith('ml-dsa-65=')
92
- ? sigHeader.slice('ml-dsa-65='.length)
93
- : sigHeader
94
- return mlDsaVerify(publicKey, envelope(timestamp, rawBody), hex)
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
  /**