kxco-post-quantum 1.3.0 → 1.4.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/BENCHMARKS.md +124 -0
- package/CHANGELOG.md +413 -292
- package/CONFORMANCE.md +211 -0
- package/LICENSE +202 -202
- package/MIGRATION.md +143 -0
- package/README.md +216 -178
- package/SECURITY.md +63 -41
- package/THREAT-MODEL.md +194 -0
- package/package.json +126 -106
- package/src/derive.d.ts +20 -20
- package/src/derive.js +47 -47
- package/src/index.d.ts +13 -8
- package/src/index.js +24 -17
- package/src/kid.d.ts +21 -21
- package/src/kid.js +53 -53
- package/src/ml-dsa-87.d.ts +81 -0
- package/src/ml-dsa-87.js +127 -0
- package/src/ml-dsa.d.ts +78 -78
- package/src/ml-dsa.js +102 -102
- package/src/ml-kem-1024.d.ts +59 -0
- package/src/ml-kem-1024.js +90 -0
- package/src/ml-kem.d.ts +49 -49
- package/src/ml-kem.js +61 -61
- package/src/slh-dsa.d.ts +72 -72
- package/src/slh-dsa.js +106 -106
- package/src/webhook.d.ts +119 -119
- package/src/webhook.js +135 -135
package/src/webhook.d.ts
CHANGED
|
@@ -1,119 +1,119 @@
|
|
|
1
|
-
/// <reference types="node" />
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Build the canonical signed envelope: `timestamp + "." + raw_body`.
|
|
5
|
-
*
|
|
6
|
-
* Receivers MUST construct the envelope from the timestamp header and the
|
|
7
|
-
* RAW request body bytes as received. Re-serialising a parsed JSON object
|
|
8
|
-
* will not produce the same bytes and signature verification will fail.
|
|
9
|
-
*/
|
|
10
|
-
export function envelope(
|
|
11
|
-
timestamp: string | number,
|
|
12
|
-
rawBody: string | Buffer,
|
|
13
|
-
): Buffer
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Compute the hex HMAC-SHA-256 of the envelope using the shared secret.
|
|
17
|
-
* Returns the hex value WITHOUT the `sha256=` prefix.
|
|
18
|
-
*/
|
|
19
|
-
export function hmacHex(
|
|
20
|
-
secret: string | Buffer,
|
|
21
|
-
timestamp: string | number,
|
|
22
|
-
rawBody: string | Buffer,
|
|
23
|
-
): string
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Constant-time verify of the X-KXCO-Signature header.
|
|
27
|
-
* Accepts the header value with or without the `sha256=` prefix.
|
|
28
|
-
*/
|
|
29
|
-
export function verifyHmac(
|
|
30
|
-
secret: string | Buffer,
|
|
31
|
-
timestamp: string | number,
|
|
32
|
-
rawBody: string | Buffer,
|
|
33
|
-
sigHeader: string,
|
|
34
|
-
): boolean
|
|
35
|
-
|
|
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=`.
|
|
39
|
-
*/
|
|
40
|
-
export function pqSign(
|
|
41
|
-
secretKey: Buffer | Uint8Array,
|
|
42
|
-
timestamp: string | number,
|
|
43
|
-
rawBody: string | Buffer,
|
|
44
|
-
): string
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Verify a hex ML-DSA-65 signature header.
|
|
48
|
-
* Accepts the value with or without the `ml-dsa-65=` prefix.
|
|
49
|
-
*/
|
|
50
|
-
export function verifyPq(
|
|
51
|
-
publicKey: Buffer | Uint8Array,
|
|
52
|
-
timestamp: string | number,
|
|
53
|
-
rawBody: string | Buffer,
|
|
54
|
-
sigHeader: string,
|
|
55
|
-
): boolean
|
|
56
|
-
|
|
57
|
-
export interface SignDeliveryArgs {
|
|
58
|
-
/** The exact body bytes that will be transmitted */
|
|
59
|
-
rawBody: string | Buffer
|
|
60
|
-
/** Per-endpoint shared secret for HMAC */
|
|
61
|
-
hmacSecret: string | Buffer
|
|
62
|
-
/** Raw ML-DSA-65 secret key */
|
|
63
|
-
pqSecretKey: Buffer | Uint8Array
|
|
64
|
-
/** 16-hex kid fingerprint of the matching public key */
|
|
65
|
-
pqKid: string
|
|
66
|
-
/** Optional event name (eg. "payment.settled") */
|
|
67
|
-
event?: string
|
|
68
|
-
/** Optional delivery / idempotency identifier */
|
|
69
|
-
deliveryId?: string
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface SignDeliveryHeaders {
|
|
73
|
-
'Content-Type': 'application/json'
|
|
74
|
-
'X-KXCO-Timestamp': string
|
|
75
|
-
'X-KXCO-Signature': string // sha256=<hex>
|
|
76
|
-
'X-KXCO-PQ-Signature': string // ml-dsa-65=<hex>
|
|
77
|
-
'X-KXCO-PQ-Kid': string
|
|
78
|
-
'X-KXCO-Event'?: string
|
|
79
|
-
'X-KXCO-Delivery'?: string
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Sign a webhook delivery. Returns the full set of headers a sender
|
|
84
|
-
* should attach to the HTTP request.
|
|
85
|
-
*
|
|
86
|
-
* The caller is responsible for sending `rawBody` byte-for-byte
|
|
87
|
-
* unchanged. The receiver verifies against the bytes as transmitted.
|
|
88
|
-
*/
|
|
89
|
-
export function signDelivery(args: SignDeliveryArgs): SignDeliveryHeaders
|
|
90
|
-
|
|
91
|
-
export interface VerifyDeliveryArgs {
|
|
92
|
-
/** HTTP headers with LOWERCASE keys */
|
|
93
|
-
headers: Record<string, string | undefined>
|
|
94
|
-
/** The EXACT request body bytes as received */
|
|
95
|
-
rawBody: string | Buffer
|
|
96
|
-
/** Optional: enable HMAC verification by providing the shared secret */
|
|
97
|
-
hmacSecret?: string | Buffer
|
|
98
|
-
/** Optional: enable PQ verification by providing the platform public key */
|
|
99
|
-
pqPublicKey?: Buffer | Uint8Array
|
|
100
|
-
/** Required when `pqPublicKey` is provided */
|
|
101
|
-
pinnedKid?: string
|
|
102
|
-
/** Replay-window in seconds. Default 300 (5 minutes). */
|
|
103
|
-
windowSeconds?: number
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export interface VerifyDeliveryResult {
|
|
107
|
-
hmacOk: boolean
|
|
108
|
-
pqOk: boolean
|
|
109
|
-
timestampOk: boolean
|
|
110
|
-
kidOk: boolean
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Verify a webhook delivery on the receiving side.
|
|
115
|
-
*
|
|
116
|
-
* Returns a breakdown of which predicates passed. A delivery is
|
|
117
|
-
* acceptable when `(hmacOk || pqOk) && timestampOk && kidOk`.
|
|
118
|
-
*/
|
|
119
|
-
export function verifyDelivery(args: VerifyDeliveryArgs): VerifyDeliveryResult
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build the canonical signed envelope: `timestamp + "." + raw_body`.
|
|
5
|
+
*
|
|
6
|
+
* Receivers MUST construct the envelope from the timestamp header and the
|
|
7
|
+
* RAW request body bytes as received. Re-serialising a parsed JSON object
|
|
8
|
+
* will not produce the same bytes and signature verification will fail.
|
|
9
|
+
*/
|
|
10
|
+
export function envelope(
|
|
11
|
+
timestamp: string | number,
|
|
12
|
+
rawBody: string | Buffer,
|
|
13
|
+
): Buffer
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compute the hex HMAC-SHA-256 of the envelope using the shared secret.
|
|
17
|
+
* Returns the hex value WITHOUT the `sha256=` prefix.
|
|
18
|
+
*/
|
|
19
|
+
export function hmacHex(
|
|
20
|
+
secret: string | Buffer,
|
|
21
|
+
timestamp: string | number,
|
|
22
|
+
rawBody: string | Buffer,
|
|
23
|
+
): string
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Constant-time verify of the X-KXCO-Signature header.
|
|
27
|
+
* Accepts the header value with or without the `sha256=` prefix.
|
|
28
|
+
*/
|
|
29
|
+
export function verifyHmac(
|
|
30
|
+
secret: string | Buffer,
|
|
31
|
+
timestamp: string | number,
|
|
32
|
+
rawBody: string | Buffer,
|
|
33
|
+
sigHeader: string,
|
|
34
|
+
): boolean
|
|
35
|
+
|
|
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=`.
|
|
39
|
+
*/
|
|
40
|
+
export function pqSign(
|
|
41
|
+
secretKey: Buffer | Uint8Array,
|
|
42
|
+
timestamp: string | number,
|
|
43
|
+
rawBody: string | Buffer,
|
|
44
|
+
): string
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Verify a hex ML-DSA-65 signature header.
|
|
48
|
+
* Accepts the value with or without the `ml-dsa-65=` prefix.
|
|
49
|
+
*/
|
|
50
|
+
export function verifyPq(
|
|
51
|
+
publicKey: Buffer | Uint8Array,
|
|
52
|
+
timestamp: string | number,
|
|
53
|
+
rawBody: string | Buffer,
|
|
54
|
+
sigHeader: string,
|
|
55
|
+
): boolean
|
|
56
|
+
|
|
57
|
+
export interface SignDeliveryArgs {
|
|
58
|
+
/** The exact body bytes that will be transmitted */
|
|
59
|
+
rawBody: string | Buffer
|
|
60
|
+
/** Per-endpoint shared secret for HMAC */
|
|
61
|
+
hmacSecret: string | Buffer
|
|
62
|
+
/** Raw ML-DSA-65 secret key */
|
|
63
|
+
pqSecretKey: Buffer | Uint8Array
|
|
64
|
+
/** 16-hex kid fingerprint of the matching public key */
|
|
65
|
+
pqKid: string
|
|
66
|
+
/** Optional event name (eg. "payment.settled") */
|
|
67
|
+
event?: string
|
|
68
|
+
/** Optional delivery / idempotency identifier */
|
|
69
|
+
deliveryId?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SignDeliveryHeaders {
|
|
73
|
+
'Content-Type': 'application/json'
|
|
74
|
+
'X-KXCO-Timestamp': string
|
|
75
|
+
'X-KXCO-Signature': string // sha256=<hex>
|
|
76
|
+
'X-KXCO-PQ-Signature': string // ml-dsa-65=<hex>
|
|
77
|
+
'X-KXCO-PQ-Kid': string
|
|
78
|
+
'X-KXCO-Event'?: string
|
|
79
|
+
'X-KXCO-Delivery'?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sign a webhook delivery. Returns the full set of headers a sender
|
|
84
|
+
* should attach to the HTTP request.
|
|
85
|
+
*
|
|
86
|
+
* The caller is responsible for sending `rawBody` byte-for-byte
|
|
87
|
+
* unchanged. The receiver verifies against the bytes as transmitted.
|
|
88
|
+
*/
|
|
89
|
+
export function signDelivery(args: SignDeliveryArgs): SignDeliveryHeaders
|
|
90
|
+
|
|
91
|
+
export interface VerifyDeliveryArgs {
|
|
92
|
+
/** HTTP headers with LOWERCASE keys */
|
|
93
|
+
headers: Record<string, string | undefined>
|
|
94
|
+
/** The EXACT request body bytes as received */
|
|
95
|
+
rawBody: string | Buffer
|
|
96
|
+
/** Optional: enable HMAC verification by providing the shared secret */
|
|
97
|
+
hmacSecret?: string | Buffer
|
|
98
|
+
/** Optional: enable PQ verification by providing the platform public key */
|
|
99
|
+
pqPublicKey?: Buffer | Uint8Array
|
|
100
|
+
/** Required when `pqPublicKey` is provided */
|
|
101
|
+
pinnedKid?: string
|
|
102
|
+
/** Replay-window in seconds. Default 300 (5 minutes). */
|
|
103
|
+
windowSeconds?: number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface VerifyDeliveryResult {
|
|
107
|
+
hmacOk: boolean
|
|
108
|
+
pqOk: boolean
|
|
109
|
+
timestampOk: boolean
|
|
110
|
+
kidOk: boolean
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Verify a webhook delivery on the receiving side.
|
|
115
|
+
*
|
|
116
|
+
* Returns a breakdown of which predicates passed. A delivery is
|
|
117
|
+
* acceptable when `(hmacOk || pqOk) && timestampOk && kidOk`.
|
|
118
|
+
*/
|
|
119
|
+
export function verifyDelivery(args: VerifyDeliveryArgs): VerifyDeliveryResult
|
package/src/webhook.js
CHANGED
|
@@ -1,135 +1,135 @@
|
|
|
1
|
-
// Hybrid HMAC + ML-DSA-65 webhook signing — the production pattern used by
|
|
2
|
-
// KXCO Bank, KnightsVault, and every product on the KXCO platform.
|
|
3
|
-
//
|
|
4
|
-
// Why hybrid?
|
|
5
|
-
// - HMAC-SHA-256 is symmetric and post-quantum secure as a MAC. Receivers
|
|
6
|
-
// who share the secret can verify offline with no library dependencies.
|
|
7
|
-
// - ML-DSA-65 adds NON-REPUDIATION: a receiver who only verifies the PQ
|
|
8
|
-
// signature can prove the message came from the holder of the platform
|
|
9
|
-
// private key — even if the HMAC secret has been leaked to a third party.
|
|
10
|
-
//
|
|
11
|
-
// Both signatures cover EXACTLY the same envelope: `${timestamp}.${rawBody}`.
|
|
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.
|
|
16
|
-
|
|
17
|
-
import { hmac } from '@noble/hashes/hmac.js'
|
|
18
|
-
import { sha256 } from '@noble/hashes/sha2.js'
|
|
19
|
-
import { sign as mlDsaSign, verify as mlDsaVerify } from './ml-dsa.js'
|
|
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
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Build the canonical signed envelope: timestamp + "." + raw body string.
|
|
47
|
-
*/
|
|
48
|
-
export function envelope(timestamp, rawBody) {
|
|
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)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Compute the hex HMAC-SHA-256 of the envelope using a shared secret.
|
|
59
|
-
*/
|
|
60
|
-
export function hmacHex(secret, timestamp, rawBody) {
|
|
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))
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Verify the HMAC signature in constant time.
|
|
69
|
-
*/
|
|
70
|
-
export function verifyHmac(secret, timestamp, rawBody, sigHeader) {
|
|
71
|
-
const expected = 'sha256=' + hmacHex(secret, timestamp, rawBody)
|
|
72
|
-
const given = sigHeader.startsWith('sha256=') ? sigHeader : `sha256=${sigHeader}`
|
|
73
|
-
return constTimeEqualStrings(expected, given)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Produce the X-KXCO-PQ-Signature header value.
|
|
78
|
-
*/
|
|
79
|
-
export function pqSign(secretKey, timestamp, rawBody) {
|
|
80
|
-
const sig = mlDsaSign(secretKey, envelope(timestamp, rawBody))
|
|
81
|
-
return `ml-dsa-65=${sig}`
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Verify a hex ML-DSA-65 signature header.
|
|
86
|
-
*/
|
|
87
|
-
export function verifyPq(publicKey, timestamp, rawBody, sigHeader) {
|
|
88
|
-
const hex = sigHeader.startsWith('ml-dsa-65=')
|
|
89
|
-
? sigHeader.slice('ml-dsa-65='.length)
|
|
90
|
-
: sigHeader
|
|
91
|
-
return mlDsaVerify(publicKey, envelope(timestamp, rawBody), hex)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Sign a webhook delivery. Returns the full set of headers a sender should
|
|
96
|
-
* attach to the HTTP request.
|
|
97
|
-
*/
|
|
98
|
-
export function signDelivery({ rawBody, hmacSecret, pqSecretKey, pqKid, event, deliveryId }) {
|
|
99
|
-
const ts = Math.floor(Date.now() / 1000).toString()
|
|
100
|
-
const headers = {
|
|
101
|
-
'Content-Type': 'application/json',
|
|
102
|
-
'X-KXCO-Timestamp': ts,
|
|
103
|
-
'X-KXCO-Signature': 'sha256=' + hmacHex(hmacSecret, ts, rawBody),
|
|
104
|
-
'X-KXCO-PQ-Signature': pqSign(pqSecretKey, ts, rawBody),
|
|
105
|
-
'X-KXCO-PQ-Kid': pqKid,
|
|
106
|
-
}
|
|
107
|
-
if (event) headers['X-KXCO-Event'] = event
|
|
108
|
-
if (deliveryId) headers['X-KXCO-Delivery'] = deliveryId
|
|
109
|
-
return headers
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Verify a webhook delivery on the receiving side.
|
|
114
|
-
*/
|
|
115
|
-
export function verifyDelivery({ headers, rawBody, hmacSecret, pqPublicKey, pinnedKid, windowSeconds = 300 }) {
|
|
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']
|
|
120
|
-
|
|
121
|
-
const tsNum = parseInt(ts, 10)
|
|
122
|
-
const timestampOk = Number.isFinite(tsNum) &&
|
|
123
|
-
Math.abs(Date.now() / 1000 - tsNum) <= windowSeconds
|
|
124
|
-
|
|
125
|
-
const hmacOk = (hmacSecret && sigHmac && timestampOk)
|
|
126
|
-
? verifyHmac(hmacSecret, ts, rawBody, sigHmac)
|
|
127
|
-
: false
|
|
128
|
-
|
|
129
|
-
const kidOk = pinnedKid ? kid === pinnedKid : true
|
|
130
|
-
const pqOk = (pqPublicKey && sigPq && timestampOk && kidOk)
|
|
131
|
-
? verifyPq(pqPublicKey, ts, rawBody, sigPq)
|
|
132
|
-
: false
|
|
133
|
-
|
|
134
|
-
return { hmacOk, pqOk, timestampOk, kidOk }
|
|
135
|
-
}
|
|
1
|
+
// Hybrid HMAC + ML-DSA-65 webhook signing — the production pattern used by
|
|
2
|
+
// KXCO Bank, KnightsVault, and every product on the KXCO platform.
|
|
3
|
+
//
|
|
4
|
+
// Why hybrid?
|
|
5
|
+
// - HMAC-SHA-256 is symmetric and post-quantum secure as a MAC. Receivers
|
|
6
|
+
// who share the secret can verify offline with no library dependencies.
|
|
7
|
+
// - ML-DSA-65 adds NON-REPUDIATION: a receiver who only verifies the PQ
|
|
8
|
+
// signature can prove the message came from the holder of the platform
|
|
9
|
+
// private key — even if the HMAC secret has been leaked to a third party.
|
|
10
|
+
//
|
|
11
|
+
// Both signatures cover EXACTLY the same envelope: `${timestamp}.${rawBody}`.
|
|
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.
|
|
16
|
+
|
|
17
|
+
import { hmac } from '@noble/hashes/hmac.js'
|
|
18
|
+
import { sha256 } from '@noble/hashes/sha2.js'
|
|
19
|
+
import { sign as mlDsaSign, verify as mlDsaVerify } from './ml-dsa.js'
|
|
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
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the canonical signed envelope: timestamp + "." + raw body string.
|
|
47
|
+
*/
|
|
48
|
+
export function envelope(timestamp, rawBody) {
|
|
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)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compute the hex HMAC-SHA-256 of the envelope using a shared secret.
|
|
59
|
+
*/
|
|
60
|
+
export function hmacHex(secret, timestamp, rawBody) {
|
|
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))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Verify the HMAC signature in constant time.
|
|
69
|
+
*/
|
|
70
|
+
export function verifyHmac(secret, timestamp, rawBody, sigHeader) {
|
|
71
|
+
const expected = 'sha256=' + hmacHex(secret, timestamp, rawBody)
|
|
72
|
+
const given = sigHeader.startsWith('sha256=') ? sigHeader : `sha256=${sigHeader}`
|
|
73
|
+
return constTimeEqualStrings(expected, given)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Produce the X-KXCO-PQ-Signature header value.
|
|
78
|
+
*/
|
|
79
|
+
export function pqSign(secretKey, timestamp, rawBody) {
|
|
80
|
+
const sig = mlDsaSign(secretKey, envelope(timestamp, rawBody))
|
|
81
|
+
return `ml-dsa-65=${sig}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Verify a hex ML-DSA-65 signature header.
|
|
86
|
+
*/
|
|
87
|
+
export function verifyPq(publicKey, timestamp, rawBody, sigHeader) {
|
|
88
|
+
const hex = sigHeader.startsWith('ml-dsa-65=')
|
|
89
|
+
? sigHeader.slice('ml-dsa-65='.length)
|
|
90
|
+
: sigHeader
|
|
91
|
+
return mlDsaVerify(publicKey, envelope(timestamp, rawBody), hex)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Sign a webhook delivery. Returns the full set of headers a sender should
|
|
96
|
+
* attach to the HTTP request.
|
|
97
|
+
*/
|
|
98
|
+
export function signDelivery({ rawBody, hmacSecret, pqSecretKey, pqKid, event, deliveryId }) {
|
|
99
|
+
const ts = Math.floor(Date.now() / 1000).toString()
|
|
100
|
+
const headers = {
|
|
101
|
+
'Content-Type': 'application/json',
|
|
102
|
+
'X-KXCO-Timestamp': ts,
|
|
103
|
+
'X-KXCO-Signature': 'sha256=' + hmacHex(hmacSecret, ts, rawBody),
|
|
104
|
+
'X-KXCO-PQ-Signature': pqSign(pqSecretKey, ts, rawBody),
|
|
105
|
+
'X-KXCO-PQ-Kid': pqKid,
|
|
106
|
+
}
|
|
107
|
+
if (event) headers['X-KXCO-Event'] = event
|
|
108
|
+
if (deliveryId) headers['X-KXCO-Delivery'] = deliveryId
|
|
109
|
+
return headers
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Verify a webhook delivery on the receiving side.
|
|
114
|
+
*/
|
|
115
|
+
export function verifyDelivery({ headers, rawBody, hmacSecret, pqPublicKey, pinnedKid, windowSeconds = 300 }) {
|
|
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']
|
|
120
|
+
|
|
121
|
+
const tsNum = parseInt(ts, 10)
|
|
122
|
+
const timestampOk = Number.isFinite(tsNum) &&
|
|
123
|
+
Math.abs(Date.now() / 1000 - tsNum) <= windowSeconds
|
|
124
|
+
|
|
125
|
+
const hmacOk = (hmacSecret && sigHmac && timestampOk)
|
|
126
|
+
? verifyHmac(hmacSecret, ts, rawBody, sigHmac)
|
|
127
|
+
: false
|
|
128
|
+
|
|
129
|
+
const kidOk = pinnedKid ? kid === pinnedKid : true
|
|
130
|
+
const pqOk = (pqPublicKey && sigPq && timestampOk && kidOk)
|
|
131
|
+
? verifyPq(pqPublicKey, ts, rawBody, sigPq)
|
|
132
|
+
: false
|
|
133
|
+
|
|
134
|
+
return { hmacOk, pqOk, timestampOk, kidOk }
|
|
135
|
+
}
|