kxco-post-quantum 1.0.2 → 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 +116 -63
- package/README.md +40 -5
- package/SECURITY.md +22 -89
- package/package.json +39 -15
- package/src/derive.d.ts +20 -0
- package/src/derive.js +24 -10
- package/src/index.d.ts +7 -0
- package/src/kid.d.ts +21 -0
- package/src/kid.js +24 -9
- package/src/ml-dsa.d.ts +45 -0
- package/src/ml-dsa.js +36 -16
- package/src/ml-kem.d.ts +49 -0
- package/src/ml-kem.js +18 -16
- package/src/webhook.d.ts +119 -0
- package/src/webhook.js +50 -52
- package/AUDIT.md +0 -110
- package/test/run-vectors.js +0 -155
- package/test/vectors.json +0 -137
package/src/ml-dsa.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
export interface MlDsaKeypair {
|
|
4
|
+
/** 1952-byte public key */
|
|
5
|
+
publicKey: Buffer
|
|
6
|
+
/** 4032-byte secret key */
|
|
7
|
+
secretKey: Buffer
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate an ML-DSA-65 (NIST FIPS 204) keypair deterministically
|
|
12
|
+
* from a master + domain-separation info string.
|
|
13
|
+
*
|
|
14
|
+
* Same inputs always produce the same keypair — no state, no DB row.
|
|
15
|
+
*/
|
|
16
|
+
export function keypairFromMaster(
|
|
17
|
+
master: Buffer | Uint8Array,
|
|
18
|
+
info?: string,
|
|
19
|
+
): MlDsaKeypair
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sign a message under an ML-DSA-65 secret key. Returns the signature
|
|
23
|
+
* as a hex string (3309 bytes = 6618 hex characters).
|
|
24
|
+
*/
|
|
25
|
+
export function sign(
|
|
26
|
+
secretKey: Buffer | Uint8Array,
|
|
27
|
+
message: Buffer | string,
|
|
28
|
+
): string
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Verify a hex-encoded ML-DSA-65 signature against a public key + message.
|
|
32
|
+
* Returns `false` on any error (invalid hex, wrong length, mismatch).
|
|
33
|
+
*/
|
|
34
|
+
export function verify(
|
|
35
|
+
publicKey: Buffer | Uint8Array,
|
|
36
|
+
message: Buffer | string,
|
|
37
|
+
sigHex: string,
|
|
38
|
+
): boolean
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Raw `@noble/post-quantum` ML-DSA-65 primitive, re-exported for callers
|
|
42
|
+
* who want the lower-level API. The wrapper functions above are
|
|
43
|
+
* recommended for production use.
|
|
44
|
+
*/
|
|
45
|
+
export const ml_dsa65: typeof import('@noble/post-quantum/ml-dsa').ml_dsa65
|
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.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
export interface MlKemKeypair {
|
|
4
|
+
/** 1184-byte public key */
|
|
5
|
+
publicKey: Buffer
|
|
6
|
+
/** 2400-byte secret key */
|
|
7
|
+
secretKey: Buffer
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MlKemEncapsulation {
|
|
11
|
+
/** 1088-byte KEM ciphertext to transmit to the recipient */
|
|
12
|
+
ciphertext: Buffer
|
|
13
|
+
/** Alias for `ciphertext` (mirrors @noble/post-quantum's camelCase) */
|
|
14
|
+
cipherText: Buffer
|
|
15
|
+
/** 32-byte shared secret to use as a symmetric AEAD key */
|
|
16
|
+
sharedSecret: Buffer
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate an ML-KEM-768 (NIST FIPS 203) keypair deterministically
|
|
21
|
+
* from a master + domain-separation info string.
|
|
22
|
+
*/
|
|
23
|
+
export function keypairFromMaster(
|
|
24
|
+
master: Buffer | Uint8Array,
|
|
25
|
+
info?: string,
|
|
26
|
+
): MlKemKeypair
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Encapsulate a shared secret to the recipient's ML-KEM-768 public key.
|
|
30
|
+
*
|
|
31
|
+
* The recipient calls `decapsulate(ciphertext, secretKey)` to recover
|
|
32
|
+
* the same shared secret. Use the shared secret as a symmetric AEAD
|
|
33
|
+
* key (eg. AES-256-GCM).
|
|
34
|
+
*/
|
|
35
|
+
export function encapsulate(publicKey: Buffer | Uint8Array): MlKemEncapsulation
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Decapsulate: recover the shared secret from a KEM ciphertext
|
|
39
|
+
* using the recipient's secret key.
|
|
40
|
+
*/
|
|
41
|
+
export function decapsulate(
|
|
42
|
+
ciphertext: Buffer | Uint8Array,
|
|
43
|
+
secretKey: Buffer | Uint8Array,
|
|
44
|
+
): Buffer
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Raw `@noble/post-quantum` ML-KEM-768 primitive, re-exported.
|
|
48
|
+
*/
|
|
49
|
+
export const ml_kem768: typeof import('@noble/post-quantum/ml-kem').ml_kem768
|
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.d.ts
ADDED
|
@@ -0,0 +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
|
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) &&
|
package/AUDIT.md
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
# Audit Posture
|
|
2
|
-
|
|
3
|
-
**Status as of v1.0.1 release (2026-05-21).** Self-attested. No third-party audit of this wrapper library has been performed yet. This document exists to make our posture **legible to reviewers** so the right questions get asked of the right party.
|
|
4
|
-
|
|
5
|
-
If you are doing institutional due diligence, read this end-to-end before the README.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## 1. What has been audited (upstream)
|
|
10
|
-
|
|
11
|
-
**`@noble/post-quantum@0.2.1`** — the underlying NIST primitives we wrap — has been independently audited.
|
|
12
|
-
|
|
13
|
-
| Auditor | Year | Scope | Report |
|
|
14
|
-
|---|---|---|---|
|
|
15
|
-
| **Cure53** | 2024 | `@noble/post-quantum` cryptographic primitives | https://github.com/paulmillr/noble-post-quantum#security |
|
|
16
|
-
|
|
17
|
-
This audit covers the actual cryptographic operations: ML-DSA-65 sign/verify, ML-KEM-768 keygen/encapsulate/decapsulate, the constant-time properties, the test vector compliance with NIST's reference outputs.
|
|
18
|
-
|
|
19
|
-
When you `npm install kxco-post-quantum`, the audited code is what runs the math. This wrapper does not reimplement the primitives.
|
|
20
|
-
|
|
21
|
-
The exact upstream we pin:
|
|
22
|
-
|
|
23
|
-
```
|
|
24
|
-
@noble/post-quantum@0.2.1
|
|
25
|
-
integrity: sha512-ImgfMp9notXSEocz464o1AefYfFWEkkszKMGO+ZiTn73yIBFeNyEHKQUMS+SheJwSNymldSts6YyVcQDjcnVVg==
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
## 2. What has NOT been audited (this wrapper)
|
|
29
|
-
|
|
30
|
-
The integration patterns in this package — `pqSigner` derivation, kid fingerprinting, webhook envelope construction, hybrid HMAC + ML-DSA signing, timestamp replay enforcement — have not been independently audited.
|
|
31
|
-
|
|
32
|
-
What we have done:
|
|
33
|
-
|
|
34
|
-
- **Internal review** by the KXCO engineering team and KXCO Cybersecurity (lead: Sean O'Coiligh, 30+ years cybersecurity, formerly led Offensive Cyber at the DTCC Cyber Threat Fusion Center)
|
|
35
|
-
- **Reproducible test vectors** in `test/vectors.json` covering every primitive — anyone running `npm test` gets the same outputs the maintainers see
|
|
36
|
-
- **Production deployment** across the KXCO platform (KnightsVault, KXCO Bank, KnightsBot, The Exchequer, Armature L1) since November 2025
|
|
37
|
-
- **Public verifiable proof** of the signing identity at https://chain.kxco.ai/wallet/api/.well-known/kxco-pq-pubkey — anyone can fetch the kid, install this library, and verify signatures from the production fleet
|
|
38
|
-
|
|
39
|
-
What we have **not** done:
|
|
40
|
-
|
|
41
|
-
- ❌ Engaged a third-party auditor for this wrapper
|
|
42
|
-
- ❌ Held a public security review window with bug bounty
|
|
43
|
-
- ❌ Obtained CMVP FIPS 140-3 module certification
|
|
44
|
-
- ❌ Submitted to ENISA / NCSC / BSI evaluation schemes
|
|
45
|
-
|
|
46
|
-
## 3. Audit roadmap
|
|
47
|
-
|
|
48
|
-
| Milestone | Target | Owner |
|
|
49
|
-
|---|---|---|
|
|
50
|
-
| Engage external auditor for wrapper integration patterns | Q3 2026 | KXCO Engineering |
|
|
51
|
-
| Public bug bounty programme | Q4 2026 | KXCO Security |
|
|
52
|
-
| Apply for FIPS 140-3 CMVP validation of a cryptographic module deployment using this library + an HSM | 2027 | KXCO Compliance |
|
|
53
|
-
| NIST PQC Workshop presentation (production lessons) | When workshop opens for 2026/27 | Shayne Heffernan + Sean O'Coiligh |
|
|
54
|
-
|
|
55
|
-
The exact dates depend on engineering and budget capacity. The order is committed.
|
|
56
|
-
|
|
57
|
-
## 4. Reproducibility checks (run these yourself)
|
|
58
|
-
|
|
59
|
-
You do not have to trust us. Run these to verify:
|
|
60
|
-
|
|
61
|
-
```bash
|
|
62
|
-
# Clone and install
|
|
63
|
-
git clone https://github.com/JackKXCO/kxco-post-quantum
|
|
64
|
-
npm install
|
|
65
|
-
|
|
66
|
-
# Run the full test suite — primitives + vectors
|
|
67
|
-
npm test
|
|
68
|
-
|
|
69
|
-
# Run vector verification only
|
|
70
|
-
npm run test:vectors
|
|
71
|
-
|
|
72
|
-
# Fetch the live production platform key and verify offline
|
|
73
|
-
curl https://chain.kxco.ai/wallet/api/.well-known/kxco-pq-pubkey
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Expected: `npm test` reports `✓ All 29 checks pass — library output matches pinned vectors bit-for-bit.`
|
|
77
|
-
|
|
78
|
-
## 5. Threat model summary
|
|
79
|
-
|
|
80
|
-
See [SECURITY.md](./SECURITY.md) for the full threat model. In short:
|
|
81
|
-
|
|
82
|
-
- **In scope:** quantum signature non-repudiation, quantum-safe KEM, webhook forgery resistance, replay rejection, body tamper detection, wrong-key rejection.
|
|
83
|
-
- **Out of scope:** master secret storage (use KMS/HSM), TLS termination (use OpenSSL 3.5+), receiving raw bodies byte-for-byte (use `express.raw` or equivalent), key rotation procedures (caller's responsibility).
|
|
84
|
-
|
|
85
|
-
## 6. Bug-finding signals
|
|
86
|
-
|
|
87
|
-
If you are evaluating this library, look at:
|
|
88
|
-
|
|
89
|
-
- **Test coverage:** 9 functional tests + 29 vector checks = 38 distinct assertions covering every export
|
|
90
|
-
- **Code size:** ~280 source lines across 6 modules — small enough to review end-to-end in an afternoon
|
|
91
|
-
- **Dependency surface:** one runtime dependency (`@noble/post-quantum`), itself audited
|
|
92
|
-
- **Determinism:** every output is reproducible from inputs — no hidden state, no globals beyond a lazy cache, no network
|
|
93
|
-
- **API stability:** v1.0 commits to the public surface listed in CHANGELOG.md
|
|
94
|
-
|
|
95
|
-
## 7. Reviewer checklist
|
|
96
|
-
|
|
97
|
-
For institutional reviewers, the smallest version of "did they actually do the work":
|
|
98
|
-
|
|
99
|
-
- [ ] `npm view kxco-post-quantum dist.signatures` returns a signed package
|
|
100
|
-
- [ ] `npm test` passes after fresh clone + install
|
|
101
|
-
- [ ] `npm run test:vectors` matches the pinned vectors
|
|
102
|
-
- [ ] `curl https://chain.kxco.ai/wallet/api/.well-known/kxco-pq-pubkey` returns a valid ML-DSA-65 public key
|
|
103
|
-
- [ ] The kid (`kid` field above) matches `fingerprint()` of the returned `publicKey` field
|
|
104
|
-
- [ ] An outbound webhook from `chain.kxco.ai` verifies with `webhook.verifyDelivery` against the pinned kid
|
|
105
|
-
|
|
106
|
-
All six are reproducible without any cooperation from KXCO. That's the standard we hold ourselves to.
|
|
107
|
-
|
|
108
|
-
---
|
|
109
|
-
|
|
110
|
-
**Contact:** security@kxco.ai for vulnerability reports. audit@kxco.ai for due-diligence and review requests.
|