kxco-post-quantum 1.0.3 → 1.1.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 +38 -0
- package/package.json +13 -11
- package/src/derive.js +24 -10
- package/src/kid.js +24 -9
- package/src/ml-dsa.js +36 -16
- package/src/ml-kem.js +18 -16
- package/src/webhook.js +50 -52
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.1.0] — 2026-05-21
|
|
11
|
+
|
|
12
|
+
Same API. Same byte-for-byte outputs (all 29 pinned vectors still match).
|
|
13
|
+
The package now runs in **browsers** as well as Node.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Isomorphic runtime — every module works identically in modern browsers
|
|
17
|
+
(Chromium, Firefox, Safari) and Node, served from CDNs like esm.sh
|
|
18
|
+
with zero polyfill burden
|
|
19
|
+
- `test/browser-smoke.test.js` runs the public API with `globalThis.Buffer`
|
|
20
|
+
removed, asserts plain `Uint8Array` outputs and a clean hybrid-signing
|
|
21
|
+
round trip — proves browser compatibility in CI
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- HKDF-SHA-512 now sourced from `@noble/hashes/hkdf` (was `node:crypto`)
|
|
25
|
+
- HMAC-SHA-256 now sourced from `@noble/hashes/hmac` (was `node:crypto`)
|
|
26
|
+
- SHA-256 for kid fingerprints now sourced from `@noble/hashes/sha256`
|
|
27
|
+
(was `node:crypto`)
|
|
28
|
+
- Constant-time comparisons are portable byte loops (replaces
|
|
29
|
+
`node:crypto.timingSafeEqual`) — identical security property,
|
|
30
|
+
runs in browsers
|
|
31
|
+
- Functions return `Buffer` on Node (when `globalThis.Buffer` is defined)
|
|
32
|
+
and plain `Uint8Array` in browsers. **Backwards compatible** for Node
|
|
33
|
+
callers; `Buffer extends Uint8Array` so any code accepting `Uint8Array`
|
|
34
|
+
already works.
|
|
35
|
+
- `engines.node` bumped to `>=20.19` to match the underlying
|
|
36
|
+
`@noble/hashes@2` requirement (Node 18 is past EOL)
|
|
37
|
+
|
|
38
|
+
### Dependencies
|
|
39
|
+
- Added `@noble/hashes ^2.2.0` (peer of `@noble/post-quantum`)
|
|
40
|
+
- `@noble/post-quantum ^0.2.1` unchanged
|
|
41
|
+
|
|
42
|
+
### Verification
|
|
43
|
+
- 9 node tests pass
|
|
44
|
+
- 6 browser-smoke tests pass
|
|
45
|
+
- 29 pinned vectors still match — no cryptographic surface changes,
|
|
46
|
+
bit-for-bit identical to 1.0.3 in Node
|
|
47
|
+
|
|
10
48
|
## [1.0.3] — 2026-05-21
|
|
11
49
|
|
|
12
50
|
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
|
+
"version": "1.1.0",
|
|
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":
|
|
36
|
+
"types": "./src/index.d.ts",
|
|
37
37
|
"import": "./src/index.js"
|
|
38
38
|
},
|
|
39
39
|
"./ml-dsa": {
|
|
40
|
-
"types":
|
|
40
|
+
"types": "./src/ml-dsa.d.ts",
|
|
41
41
|
"import": "./src/ml-dsa.js"
|
|
42
42
|
},
|
|
43
43
|
"./ml-kem": {
|
|
44
|
-
"types":
|
|
44
|
+
"types": "./src/ml-kem.d.ts",
|
|
45
45
|
"import": "./src/ml-kem.js"
|
|
46
46
|
},
|
|
47
47
|
"./derive": {
|
|
48
|
-
"types":
|
|
48
|
+
"types": "./src/derive.d.ts",
|
|
49
49
|
"import": "./src/derive.js"
|
|
50
50
|
},
|
|
51
51
|
"./webhook": {
|
|
52
|
-
"types":
|
|
52
|
+
"types": "./src/webhook.d.ts",
|
|
53
53
|
"import": "./src/webhook.js"
|
|
54
54
|
},
|
|
55
55
|
"./kid": {
|
|
56
|
-
"types":
|
|
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": ">=
|
|
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":
|
|
75
|
-
"test:vectors":
|
|
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
|
-
|
|
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
|
|
18
|
-
* @param {string}
|
|
19
|
-
* @param {number}
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
27
|
+
* Compute a stable 16-hex-character fingerprint of a public key.
|
|
12
28
|
*
|
|
13
|
-
* @param {Buffer|Uint8Array|string} publicKey
|
|
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
|
|
18
|
-
?
|
|
19
|
-
:
|
|
20
|
-
return
|
|
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.
|
|
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
|
-
//
|
|
7
|
-
//
|
|
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
|
|
42
|
+
const seedU8 = seed instanceof Uint8Array ? seed : new Uint8Array(seed)
|
|
43
|
+
const k = ml_dsa65.keygen(seedU8)
|
|
21
44
|
return {
|
|
22
|
-
publicKey:
|
|
23
|
-
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}
|
|
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
|
|
36
|
-
|
|
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}
|
|
45
|
-
* @param {string}
|
|
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,
|
|
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
|
-
//
|
|
8
|
-
//
|
|
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
|
|
26
|
+
const seedU8 = seed instanceof Uint8Array ? seed : new Uint8Array(seed)
|
|
27
|
+
const k = ml_kem768.keygen(seedU8)
|
|
23
28
|
return {
|
|
24
|
-
publicKey:
|
|
25
|
-
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
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 {
|
|
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
|
|
26
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
119
|
-
const sigHmac
|
|
120
|
-
const sigPq
|
|
121
|
-
const 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) &&
|