kxco-post-quantum 1.0.3 → 1.1.1

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
@@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.1] — 2026-05-21
11
+
12
+ Operational hardening release. No source-code changes; this is the first
13
+ release published via **npm Trusted Publishing** rather than a long-lived
14
+ `NPM_TOKEN`.
15
+
16
+ ### Changed
17
+ - `.github/workflows/publish.yml` now publishes via npm Trusted Publishing
18
+ (OIDC). The `NODE_AUTH_TOKEN` / `NPM_OTP` env vars are removed; the
19
+ workflow's `id-token: write` permission is the entire credential.
20
+ Registered at https://www.npmjs.com/package/kxco-post-quantum/access
21
+ binding `org=JackKXCO`, `repo=kxco-post-quantum`, `workflow=publish.yml`.
22
+ - Repo-level `NPM_TOKEN` and `NPM_OTP` secrets removed — no long-lived
23
+ credentials remain in the publishing path.
24
+
25
+ ### Why this matters
26
+ - Every release tarball is now signed by GitHub Actions OIDC against the
27
+ exact commit being published, with no human-held secret in the loop.
28
+ - No more burning recovery codes per release. The publish workflow now
29
+ runs hands-free on every `v*` tag push.
30
+
31
+ ## [1.1.0] — 2026-05-21
32
+
33
+ Same API. Same byte-for-byte outputs (all 29 pinned vectors still match).
34
+ The package now runs in **browsers** as well as Node.
35
+
36
+ ### Added
37
+ - Isomorphic runtime — every module works identically in modern browsers
38
+ (Chromium, Firefox, Safari) and Node, served from CDNs like esm.sh
39
+ with zero polyfill burden
40
+ - `test/browser-smoke.test.js` runs the public API with `globalThis.Buffer`
41
+ removed, asserts plain `Uint8Array` outputs and a clean hybrid-signing
42
+ round trip — proves browser compatibility in CI
43
+
44
+ ### Changed
45
+ - HKDF-SHA-512 now sourced from `@noble/hashes/hkdf` (was `node:crypto`)
46
+ - HMAC-SHA-256 now sourced from `@noble/hashes/hmac` (was `node:crypto`)
47
+ - SHA-256 for kid fingerprints now sourced from `@noble/hashes/sha256`
48
+ (was `node:crypto`)
49
+ - Constant-time comparisons are portable byte loops (replaces
50
+ `node:crypto.timingSafeEqual`) — identical security property,
51
+ runs in browsers
52
+ - Functions return `Buffer` on Node (when `globalThis.Buffer` is defined)
53
+ and plain `Uint8Array` in browsers. **Backwards compatible** for Node
54
+ callers; `Buffer extends Uint8Array` so any code accepting `Uint8Array`
55
+ already works.
56
+ - `engines.node` bumped to `>=20.19` to match the underlying
57
+ `@noble/hashes@2` requirement (Node 18 is past EOL)
58
+
59
+ ### Dependencies
60
+ - Added `@noble/hashes ^2.2.0` (peer of `@noble/post-quantum`)
61
+ - `@noble/post-quantum ^0.2.1` unchanged
62
+
63
+ ### Verification
64
+ - 9 node tests pass
65
+ - 6 browser-smoke tests pass
66
+ - 29 pinned vectors still match — no cryptographic surface changes,
67
+ bit-for-bit identical to 1.0.3 in Node
68
+
10
69
  ## [1.0.3] — 2026-05-21
11
70
 
12
71
  First release ships with SLSA Level 2 provenance attestation tied to a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kxco-post-quantum",
3
- "version": "1.0.3",
3
+ "version": "1.1.1",
4
4
  "description": "Production-tested post-quantum cryptography patterns: deterministic key derivation, hybrid webhook signing, and kid fingerprinting. Built on @noble/post-quantum. Used in production at KXCO.",
5
5
  "keywords": [
6
6
  "post-quantum",
@@ -33,27 +33,27 @@
33
33
  "types": "./src/index.d.ts",
34
34
  "exports": {
35
35
  ".": {
36
- "types": "./src/index.d.ts",
36
+ "types": "./src/index.d.ts",
37
37
  "import": "./src/index.js"
38
38
  },
39
39
  "./ml-dsa": {
40
- "types": "./src/ml-dsa.d.ts",
40
+ "types": "./src/ml-dsa.d.ts",
41
41
  "import": "./src/ml-dsa.js"
42
42
  },
43
43
  "./ml-kem": {
44
- "types": "./src/ml-kem.d.ts",
44
+ "types": "./src/ml-kem.d.ts",
45
45
  "import": "./src/ml-kem.js"
46
46
  },
47
47
  "./derive": {
48
- "types": "./src/derive.d.ts",
48
+ "types": "./src/derive.d.ts",
49
49
  "import": "./src/derive.js"
50
50
  },
51
51
  "./webhook": {
52
- "types": "./src/webhook.d.ts",
52
+ "types": "./src/webhook.d.ts",
53
53
  "import": "./src/webhook.js"
54
54
  },
55
55
  "./kid": {
56
- "types": "./src/kid.d.ts",
56
+ "types": "./src/kid.d.ts",
57
57
  "import": "./src/kid.js"
58
58
  }
59
59
  },
@@ -65,15 +65,17 @@
65
65
  "CHANGELOG.md"
66
66
  ],
67
67
  "engines": {
68
- "node": ">=18"
68
+ "node": ">=20.19"
69
69
  },
70
70
  "dependencies": {
71
+ "@noble/hashes": "^2.2.0",
71
72
  "@noble/post-quantum": "^0.2.1"
72
73
  },
73
74
  "scripts": {
74
- "test": "node --test test/basic.test.js && node test/run-vectors.js",
75
- "test:vectors": "node test/run-vectors.js",
76
- "generate:vectors": "node test/generate-vectors.js > test/vectors.json"
75
+ "test": "node --test test/basic.test.js && node --test test/browser-smoke.test.js && node test/run-vectors.js",
76
+ "test:vectors": "node test/run-vectors.js",
77
+ "generate:vectors": "node test/generate-vectors.js > test/vectors.json",
78
+ "bench": "node bench/bench.js"
77
79
  },
78
80
  "publishConfig": {
79
81
  "provenance": true,
package/src/derive.js CHANGED
@@ -8,26 +8,40 @@
8
8
  // Domain separation through `info` is critical — using the same master key
9
9
  // for different purposes (signing vs encryption) MUST use distinct info
10
10
  // strings or you create a cross-protocol attack surface.
11
+ //
12
+ // Isomorphic: uses @noble/hashes/hkdf which runs identically in Node 18+ and
13
+ // modern browsers. Returns Buffer when running on Node (for backwards
14
+ // compatibility with existing callers), Uint8Array in browsers.
15
+
16
+ import { hkdf } from '@noble/hashes/hkdf.js'
17
+ import { sha512 } from '@noble/hashes/sha2.js'
11
18
 
12
- import { hkdfSync } from 'node:crypto'
19
+ const HAS_BUFFER = typeof Buffer !== 'undefined'
20
+ const enc = new TextEncoder()
21
+
22
+ function toBytes(input) {
23
+ if (input instanceof Uint8Array) return input
24
+ if (typeof input === 'string') return enc.encode(input)
25
+ throw new Error('expected Uint8Array or string')
26
+ }
13
27
 
14
28
  /**
15
29
  * Derive a deterministic seed from a master secret + an info string.
16
30
  *
17
- * @param {Buffer|Uint8Array} master — high-entropy input keying material
18
- * @param {string} info — domain separation tag (eg. 'kxco-platform-ml-dsa-65-v1')
19
- * @param {number} length — output seed length in bytes (32 for ML-DSA, 64 for ML-KEM)
20
- * @returns {Buffer}
31
+ * @param {Buffer|Uint8Array|string} master — high-entropy keying material (>= 16 bytes)
32
+ * @param {string} info — domain separation tag
33
+ * @param {number} length — output seed length in bytes
34
+ * @returns {Buffer|Uint8Array}
21
35
  */
22
36
  export function deriveSeed(master, info, length) {
23
- if (!master || master.length < 16) {
37
+ const ikm = toBytes(master)
38
+ if (!ikm || ikm.length < 16) {
24
39
  throw new Error('deriveSeed: master keying material must be at least 16 bytes')
25
40
  }
26
41
  if (!info || typeof info !== 'string') {
27
42
  throw new Error('deriveSeed: info string is required for domain separation')
28
43
  }
29
- // Empty salt with HKDF-SHA-512 is fine when master has high entropy.
30
- return Buffer.from(
31
- hkdfSync('sha512', master, Buffer.alloc(32), Buffer.from(info, 'utf8'), length)
32
- )
44
+ const salt = new Uint8Array(32) // 32 zero bytes — fine with high-entropy IKM
45
+ const out = hkdf(sha512, ikm, salt, enc.encode(info), length)
46
+ return HAS_BUFFER ? Buffer.from(out) : out
33
47
  }
package/src/kid.js CHANGED
@@ -4,25 +4,40 @@
4
4
  // compare against an X-KXCO-PQ-Kid header on every webhook. Fast rejection of
5
5
  // stale or unknown keys without re-fetching the full 1952-byte public key on
6
6
  // every request.
7
+ //
8
+ // Isomorphic: uses @noble/hashes/sha256 — runs identically in Node and
9
+ // browsers.
10
+
11
+ import { sha256 } from '@noble/hashes/sha2.js'
7
12
 
8
- import { createHash } from 'node:crypto'
13
+ function hexToBytes(hex) {
14
+ if (hex.length % 2) throw new Error('odd hex length')
15
+ const b = new Uint8Array(hex.length / 2)
16
+ for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
17
+ return b
18
+ }
19
+
20
+ function bytesToHex(bytes) {
21
+ let s = ''
22
+ for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0')
23
+ return s
24
+ }
9
25
 
10
26
  /**
11
- * Compute a stable, 16-hex-character fingerprint of a public key.
27
+ * Compute a stable 16-hex-character fingerprint of a public key.
12
28
  *
13
- * @param {Buffer|Uint8Array|string} publicKey — raw bytes or hex string
29
+ * @param {Buffer|Uint8Array|string} publicKey — raw bytes or hex string
14
30
  * @returns {string} 16 hex chars (8 bytes of SHA-256)
15
31
  */
16
32
  export function fingerprint(publicKey) {
17
- const buf = typeof publicKey === 'string'
18
- ? Buffer.from(publicKey, 'hex')
19
- : Buffer.from(publicKey)
20
- return createHash('sha256').update(buf).digest('hex').slice(0, 16)
33
+ const bytes = typeof publicKey === 'string'
34
+ ? hexToBytes(publicKey)
35
+ : (publicKey instanceof Uint8Array ? publicKey : new Uint8Array(publicKey))
36
+ return bytesToHex(sha256(bytes)).slice(0, 16)
21
37
  }
22
38
 
23
39
  /**
24
- * Constant-time comparison of two kid strings. Use this instead of `===`
25
- * when comparing kids from user input.
40
+ * Constant-time comparison of two kid strings.
26
41
  *
27
42
  * @param {string} a
28
43
  * @param {string} b
package/src/ml-dsa.js CHANGED
@@ -3,24 +3,47 @@
3
3
  // Module-lattice signatures. Security Category 3 (≈ AES-192). Public key 1952
4
4
  // bytes, signature 3309 bytes. Resistant to attacks by quantum computers.
5
5
  //
6
- // This module wraps @noble/post-quantum with deterministic keygen-from-seed
7
- // and ergonomic buffer-in/hex-out helpers used throughout KXCO production.
6
+ // Isomorphic: works in Node and modern browsers. Returns Buffer on Node
7
+ // (backwards compatible), Uint8Array in browsers.
8
8
 
9
9
  import { ml_dsa65 } from '@noble/post-quantum/ml-dsa'
10
10
  import { deriveSeed } from './derive.js'
11
11
 
12
+ const HAS_BUFFER = typeof Buffer !== 'undefined'
13
+ const enc = new TextEncoder()
14
+
15
+ function toBytes(input) {
16
+ if (input instanceof Uint8Array) return input
17
+ if (typeof input === 'string') return enc.encode(input)
18
+ throw new Error('expected Uint8Array or string')
19
+ }
20
+ function hexToBytes(hex) {
21
+ if (typeof hex !== 'string' || hex.length % 2) throw new Error('invalid hex')
22
+ const b = new Uint8Array(hex.length / 2)
23
+ for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
24
+ return b
25
+ }
26
+ function bytesToHex(bytes) {
27
+ let s = ''
28
+ for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0')
29
+ return s
30
+ }
31
+ function wrap(bytes) {
32
+ return HAS_BUFFER ? Buffer.from(bytes) : bytes
33
+ }
34
+
12
35
  /**
13
36
  * Generate an ML-DSA-65 keypair from a master + domain-separation info.
14
- * Same inputs always produce the same keypair — no state, no DB row.
15
37
  *
16
- * @returns {{ publicKey: Buffer, secretKey: Buffer }}
38
+ * @returns {{ publicKey: Buffer|Uint8Array, secretKey: Buffer|Uint8Array }}
17
39
  */
18
40
  export function keypairFromMaster(master, info = 'ml-dsa-65-v1') {
19
41
  const seed = deriveSeed(master, info, 32)
20
- const k = ml_dsa65.keygen(seed)
42
+ const seedU8 = seed instanceof Uint8Array ? seed : new Uint8Array(seed)
43
+ const k = ml_dsa65.keygen(seedU8)
21
44
  return {
22
- publicKey: Buffer.from(k.publicKey),
23
- secretKey: Buffer.from(k.secretKey),
45
+ publicKey: wrap(k.publicKey),
46
+ secretKey: wrap(k.secretKey),
24
47
  }
25
48
  }
26
49
 
@@ -28,31 +51,28 @@ export function keypairFromMaster(master, info = 'ml-dsa-65-v1') {
28
51
  * Sign a message. Returns the signature as a hex string.
29
52
  *
30
53
  * @param {Buffer|Uint8Array} secretKey
31
- * @param {Buffer|string} message
54
+ * @param {Buffer|Uint8Array|string} message
32
55
  * @returns {string} hex-encoded signature (6618 chars)
33
56
  */
34
57
  export function sign(secretKey, message) {
35
- const msg = Buffer.isBuffer(message) ? message : Buffer.from(message, 'utf8')
36
- const sig = ml_dsa65.sign(secretKey, msg)
37
- return Buffer.from(sig).toString('hex')
58
+ const sig = ml_dsa65.sign(secretKey, toBytes(message))
59
+ return bytesToHex(sig)
38
60
  }
39
61
 
40
62
  /**
41
63
  * Verify a hex-encoded signature.
42
64
  *
43
65
  * @param {Buffer|Uint8Array} publicKey
44
- * @param {Buffer|string} message
45
- * @param {string} sigHex
66
+ * @param {Buffer|Uint8Array|string} message
67
+ * @param {string} sigHex
46
68
  * @returns {boolean}
47
69
  */
48
70
  export function verify(publicKey, message, sigHex) {
49
- const msg = Buffer.isBuffer(message) ? message : Buffer.from(message, 'utf8')
50
71
  try {
51
- return ml_dsa65.verify(publicKey, msg, Buffer.from(sigHex, 'hex'))
72
+ return ml_dsa65.verify(publicKey, toBytes(message), hexToBytes(sigHex))
52
73
  } catch {
53
74
  return false
54
75
  }
55
76
  }
56
77
 
57
- // Re-export the raw noble primitive for callers who want the lower-level API.
58
78
  export { ml_dsa65 }
package/src/ml-kem.js CHANGED
@@ -4,25 +4,30 @@
4
4
  // key 1184 bytes, ciphertext 1088 bytes, shared secret 32 bytes. Resistant
5
5
  // to attacks by quantum computers.
6
6
  //
7
- // Encapsulate to a recipient's public key: returns a ciphertext to transmit
8
- // and a shared secret to use as a symmetric key. Decapsulate with the secret
9
- // key to recover the same shared secret.
7
+ // Isomorphic: works in Node and modern browsers. Returns Buffer on Node
8
+ // (backwards compatible), Uint8Array in browsers.
10
9
 
11
10
  import { ml_kem768 } from '@noble/post-quantum/ml-kem'
12
11
  import { deriveSeed } from './derive.js'
13
12
 
13
+ const HAS_BUFFER = typeof Buffer !== 'undefined'
14
+
15
+ function wrap(bytes) {
16
+ return HAS_BUFFER ? Buffer.from(bytes) : bytes
17
+ }
18
+
14
19
  /**
15
20
  * Generate an ML-KEM-768 keypair from a master + domain-separation info.
16
- * Deterministic — same inputs always produce the same keypair.
17
21
  *
18
- * @returns {{ publicKey: Buffer, secretKey: Buffer }}
22
+ * @returns {{ publicKey: Buffer|Uint8Array, secretKey: Buffer|Uint8Array }}
19
23
  */
20
24
  export function keypairFromMaster(master, info = 'ml-kem-768-v1') {
21
25
  const seed = deriveSeed(master, info, 64)
22
- const k = ml_kem768.keygen(seed)
26
+ const seedU8 = seed instanceof Uint8Array ? seed : new Uint8Array(seed)
27
+ const k = ml_kem768.keygen(seedU8)
23
28
  return {
24
- publicKey: Buffer.from(k.publicKey),
25
- secretKey: Buffer.from(k.secretKey),
29
+ publicKey: wrap(k.publicKey),
30
+ secretKey: wrap(k.secretKey),
26
31
  }
27
32
  }
28
33
 
@@ -30,18 +35,15 @@ export function keypairFromMaster(master, info = 'ml-kem-768-v1') {
30
35
  * Encapsulate a shared secret to the recipient's public key.
31
36
  *
32
37
  * @param {Buffer|Uint8Array} publicKey
33
- * @returns {{ ciphertext: Buffer, sharedSecret: Buffer }}
34
- * — transmit ciphertext to the recipient; use sharedSecret as a symmetric key
38
+ * @returns {{ ciphertext: Buffer|Uint8Array, cipherText: Buffer|Uint8Array, sharedSecret: Buffer|Uint8Array }}
35
39
  */
36
40
  export function encapsulate(publicKey) {
37
41
  const r = ml_kem768.encapsulate(publicKey)
38
- // @noble/post-quantum exposes `cipherText` (camelCase). Normalise to the
39
- // more standard `ciphertext` for callers; both fields are returned.
40
- const ct = Buffer.from(r.cipherText ?? r.ciphertext)
42
+ const ct = wrap(r.cipherText ?? r.ciphertext)
41
43
  return {
42
44
  ciphertext: ct,
43
45
  cipherText: ct,
44
- sharedSecret: Buffer.from(r.sharedSecret),
46
+ sharedSecret: wrap(r.sharedSecret),
45
47
  }
46
48
  }
47
49
 
@@ -50,10 +52,10 @@ export function encapsulate(publicKey) {
50
52
  *
51
53
  * @param {Buffer|Uint8Array} ciphertext
52
54
  * @param {Buffer|Uint8Array} secretKey
53
- * @returns {Buffer} shared secret (32 bytes)
55
+ * @returns {Buffer|Uint8Array} shared secret (32 bytes)
54
56
  */
55
57
  export function decapsulate(ciphertext, secretKey) {
56
- return Buffer.from(ml_kem768.decapsulate(ciphertext, secretKey))
58
+ return wrap(ml_kem768.decapsulate(ciphertext, secretKey))
57
59
  }
58
60
 
59
61
  export { ml_kem768 }
package/src/webhook.js CHANGED
@@ -9,50 +9,72 @@
9
9
  // private key — even if the HMAC secret has been leaked to a third party.
10
10
  //
11
11
  // Both signatures cover EXACTLY the same envelope: `${timestamp}.${rawBody}`.
12
- // Receivers can verify either or both; verifying both is defence-in-depth.
12
+ //
13
+ // Isomorphic: HMAC and SHA-256 come from @noble/hashes, constant-time
14
+ // compare is a portable byte loop. No node:crypto dependency. Runs in Node
15
+ // and modern browsers.
13
16
 
14
- import { createHmac, timingSafeEqual } from 'node:crypto'
17
+ import { hmac } from '@noble/hashes/hmac.js'
18
+ import { sha256 } from '@noble/hashes/sha2.js'
15
19
  import { sign as mlDsaSign, verify as mlDsaVerify } from './ml-dsa.js'
16
20
 
21
+ const HAS_BUFFER = typeof Buffer !== 'undefined'
22
+ const enc = new TextEncoder()
23
+
24
+ function toBytes(input) {
25
+ if (input instanceof Uint8Array) return input
26
+ if (typeof input === 'string') return enc.encode(input)
27
+ throw new Error('expected Uint8Array or string')
28
+ }
29
+ function bytesToHex(bytes) {
30
+ let s = ''
31
+ for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0')
32
+ return s
33
+ }
34
+ function wrap(bytes) {
35
+ return HAS_BUFFER ? Buffer.from(bytes) : bytes
36
+ }
37
+ // Portable constant-time string compare (Node + browser).
38
+ function constTimeEqualStrings(a, b) {
39
+ if (a.length !== b.length) return false
40
+ let diff = 0
41
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
42
+ return diff === 0
43
+ }
44
+
17
45
  /**
18
46
  * Build the canonical signed envelope: timestamp + "." + raw body string.
19
- *
20
- * @param {string|number} timestamp — Unix seconds (string or number)
21
- * @param {string|Buffer} rawBody — the EXACT request body as transmitted
22
- * @returns {Buffer}
23
47
  */
24
48
  export function envelope(timestamp, rawBody) {
25
- const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8')
26
- return Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), body])
49
+ const bodyBytes = toBytes(rawBody)
50
+ const prefixBytes = enc.encode(`${timestamp}.`)
51
+ const out = new Uint8Array(prefixBytes.length + bodyBytes.length)
52
+ out.set(prefixBytes, 0)
53
+ out.set(bodyBytes, prefixBytes.length)
54
+ return wrap(out)
27
55
  }
28
56
 
29
57
  /**
30
58
  * Compute the hex HMAC-SHA-256 of the envelope using a shared secret.
31
- *
32
- * @returns {string} hex-encoded HMAC (no `sha256=` prefix)
33
59
  */
34
60
  export function hmacHex(secret, timestamp, rawBody) {
35
- return createHmac('sha256', secret).update(envelope(timestamp, rawBody)).digest('hex')
61
+ const env = envelope(timestamp, rawBody)
62
+ const envBytes = env instanceof Uint8Array ? env : new Uint8Array(env)
63
+ const key = toBytes(secret)
64
+ return bytesToHex(hmac(sha256, key, envBytes))
36
65
  }
37
66
 
38
67
  /**
39
- * Verify the HMAC signature in constant time. The signature header may be
40
- * passed with or without the `sha256=` prefix.
41
- *
42
- * @returns {boolean}
68
+ * Verify the HMAC signature in constant time.
43
69
  */
44
70
  export function verifyHmac(secret, timestamp, rawBody, sigHeader) {
45
71
  const expected = 'sha256=' + hmacHex(secret, timestamp, rawBody)
46
72
  const given = sigHeader.startsWith('sha256=') ? sigHeader : `sha256=${sigHeader}`
47
- const a = Buffer.from(expected)
48
- const b = Buffer.from(given)
49
- if (a.length !== b.length) return false
50
- return timingSafeEqual(a, b)
73
+ return constTimeEqualStrings(expected, given)
51
74
  }
52
75
 
53
76
  /**
54
- * Produce the X-KXCO-PQ-Signature header value: the hex ML-DSA-65 signature
55
- * over the envelope, prefixed with `ml-dsa-65=`.
77
+ * Produce the X-KXCO-PQ-Signature header value.
56
78
  */
57
79
  export function pqSign(secretKey, timestamp, rawBody) {
58
80
  const sig = mlDsaSign(secretKey, envelope(timestamp, rawBody))
@@ -60,10 +82,7 @@ export function pqSign(secretKey, timestamp, rawBody) {
60
82
  }
61
83
 
62
84
  /**
63
- * Verify a hex ML-DSA-65 signature header. Accepts the value with or
64
- * without the `ml-dsa-65=` prefix.
65
- *
66
- * @returns {boolean}
85
+ * Verify a hex ML-DSA-65 signature header.
67
86
  */
68
87
  export function verifyPq(publicKey, timestamp, rawBody, sigHeader) {
69
88
  const hex = sigHeader.startsWith('ml-dsa-65=')
@@ -74,17 +93,7 @@ export function verifyPq(publicKey, timestamp, rawBody, sigHeader) {
74
93
 
75
94
  /**
76
95
  * Sign a webhook delivery. Returns the full set of headers a sender should
77
- * attach to the HTTP request. The caller is responsible for sending the
78
- * `rawBody` byte-for-byte unchanged (no re-stringification on the receiver).
79
- *
80
- * @param {object} args
81
- * @param {string|Buffer} args.rawBody
82
- * @param {string|Buffer} args.hmacSecret
83
- * @param {Buffer|Uint8Array} args.pqSecretKey
84
- * @param {string} args.pqKid — fingerprint of the matching public key
85
- * @param {string} [args.event] — optional event name
86
- * @param {string} [args.deliveryId] — optional idempotency / debugging ID
87
- * @returns {object} HTTP header map
96
+ * attach to the HTTP request.
88
97
  */
89
98
  export function signDelivery({ rawBody, hmacSecret, pqSecretKey, pqKid, event, deliveryId }) {
90
99
  const ts = Math.floor(Date.now() / 1000).toString()
@@ -101,24 +110,13 @@ export function signDelivery({ rawBody, hmacSecret, pqSecretKey, pqKid, event, d
101
110
  }
102
111
 
103
112
  /**
104
- * Verify a webhook delivery on the receiving side. Both signatures are
105
- * checked; the result tells you which (or both) passed. Also enforces a
106
- * timestamp window to prevent replays.
107
- *
108
- * @param {object} args
109
- * @param {object} args.headers — case-insensitive lookups OK if normalised
110
- * @param {string|Buffer} args.rawBody — the EXACT body byte-for-byte
111
- * @param {string|Buffer} [args.hmacSecret]
112
- * @param {Buffer|Uint8Array} [args.pqPublicKey]
113
- * @param {string} [args.pinnedKid] — required if pqPublicKey is given
114
- * @param {number} [args.windowSeconds] — default 300 (5 minutes)
115
- * @returns {{ hmacOk: boolean, pqOk: boolean, timestampOk: boolean, kidOk: boolean }}
113
+ * Verify a webhook delivery on the receiving side.
116
114
  */
117
115
  export function verifyDelivery({ headers, rawBody, hmacSecret, pqPublicKey, pinnedKid, windowSeconds = 300 }) {
118
- const ts = headers['x-kxco-timestamp']
119
- const sigHmac = headers['x-kxco-signature']
120
- const sigPq = headers['x-kxco-pq-signature']
121
- const kid = headers['x-kxco-pq-kid']
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']
122
120
 
123
121
  const tsNum = parseInt(ts, 10)
124
122
  const timestampOk = Number.isFinite(tsNum) &&