kxco-post-quantum 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Knightsbridge Group — KXCO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,200 @@
1
+ # @kxco/post-quantum
2
+
3
+ **Production-tested post-quantum cryptography patterns.** Deterministic key derivation, hybrid webhook signing, and kid fingerprinting — the integration patterns KXCO uses in production across KnightsVault, KXCO Bank, KnightsBot, The Exchequer, and Armature L1.
4
+
5
+ [![npm](https://img.shields.io/npm/v/@kxco/post-quantum?color=d4a017)](https://www.npmjs.com/package/@kxco/post-quantum)
6
+ [![license](https://img.shields.io/npm/l/@kxco/post-quantum)](./LICENSE)
7
+ [![FIPS 203](https://img.shields.io/badge/FIPS%20203-ML--KEM--768-1e3a8a)](https://csrc.nist.gov/pubs/fips/203/final)
8
+ [![FIPS 204](https://img.shields.io/badge/FIPS%20204-ML--DSA--65-1e3a8a)](https://csrc.nist.gov/pubs/fips/204/final)
9
+
10
+ ---
11
+
12
+ ## What this is
13
+
14
+ A higher-level package that wraps [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) — the audited, dependency-free TypeScript reference implementation of NIST's August 2024 post-quantum standards — with the integration patterns we run in production:
15
+
16
+ - **Deterministic key derivation** from a master secret via HKDF-SHA-512 with domain separation
17
+ - **Hybrid HMAC + ML-DSA-65 webhook signing** with non-repudiation
18
+ - **Kid fingerprints** for fast key identification in delivery headers
19
+ - **Replay protection** through enforced timestamp windows
20
+ - **Constant-time comparisons** where it matters
21
+
22
+ This package does NOT reimplement the NIST primitives. Cryptographic operations defer to `@noble/post-quantum`.
23
+
24
+ ## What it isn't
25
+
26
+ - Not a TLS library — use OpenSSL 3.5+ or BoringSSL for `X25519MLKEM768` at the edge
27
+ - Not a key management service — use AWS KMS, HashiCorp Vault, or an HSM for production secret storage
28
+ - Not FIPS 140-3 certified — the underlying algorithms are FIPS-standardised; the *module* is not validated
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ npm install @kxco/post-quantum
34
+ ```
35
+
36
+ Requires Node.js 18+. ESM-only.
37
+
38
+ ## Quick start
39
+
40
+ ### Sign a webhook
41
+
42
+ ```js
43
+ import { webhook, mlDsa, fingerprint, deriveSeed } from '@kxco/post-quantum'
44
+
45
+ // Derive a stable platform identity from your master secret
46
+ const KXCO_KEY_MASTER = Buffer.from(process.env.KXCO_KEY_MASTER, 'hex')
47
+ const { publicKey, secretKey } = mlDsa.keypairFromMaster(KXCO_KEY_MASTER, 'platform-v1')
48
+ const pqKid = fingerprint(publicKey)
49
+
50
+ // On every outbound webhook
51
+ const rawBody = JSON.stringify(payload)
52
+ const headers = webhook.signDelivery({
53
+ rawBody,
54
+ hmacSecret: endpointSecret,
55
+ pqSecretKey: secretKey,
56
+ pqKid,
57
+ event: 'payment.settled',
58
+ deliveryId: jobId,
59
+ })
60
+
61
+ await fetch(url, { method: 'POST', headers, body: rawBody })
62
+ ```
63
+
64
+ ### Verify a webhook (receiver side)
65
+
66
+ ```js
67
+ import { webhook } from '@kxco/post-quantum'
68
+
69
+ // Pin these from /.well-known/kxco-pq-pubkey on first integration
70
+ const PINNED_KID = '4a7c9e2f1b3d5680'
71
+ const PINNED_PUBKEY = Buffer.from('...3904 hex chars...', 'hex')
72
+ const HMAC_SECRET = process.env.KXCO_WEBHOOK_SECRET
73
+
74
+ const result = webhook.verifyDelivery({
75
+ headers: req.headers,
76
+ rawBody: req.rawBody, // the body bytes EXACTLY as received
77
+ hmacSecret: HMAC_SECRET,
78
+ pqPublicKey: PINNED_PUBKEY,
79
+ pinnedKid: PINNED_KID,
80
+ })
81
+
82
+ if (!result.hmacOk && !result.pqOk) {
83
+ return res.status(401).end()
84
+ }
85
+ ```
86
+
87
+ ### Encapsulate to a recipient
88
+
89
+ ```js
90
+ import { mlKem } from '@kxco/post-quantum'
91
+
92
+ // Sender side — encapsulate to the recipient's public key
93
+ const { ciphertext, sharedSecret } = mlKem.encapsulate(recipientPubKey)
94
+ // Use sharedSecret as an AES-256-GCM key; transmit ciphertext to the recipient
95
+
96
+ // Recipient side — recover the same shared secret
97
+ const recovered = mlKem.decapsulate(ciphertext, mySecretKey)
98
+ ```
99
+
100
+ ### Deterministic keypair from a master secret
101
+
102
+ ```js
103
+ import { mlDsa, mlKem, deriveSeed } from '@kxco/post-quantum'
104
+
105
+ const master = Buffer.from(process.env.KXCO_KEY_MASTER, 'hex')
106
+
107
+ // Two domain-separated keypairs from the same master
108
+ const signing = mlDsa.keypairFromMaster(master, 'platform-signing-v1')
109
+ const encryption = mlKem.keypairFromMaster(master, 'platform-encryption-v1')
110
+
111
+ // You can also derive raw seeds for other purposes
112
+ const customSeed = deriveSeed(master, 'audit-trail-anchor-v1', 32)
113
+ ```
114
+
115
+ ## The signed envelope
116
+
117
+ Both signatures cover **the exact same envelope**: `timestamp + "." + raw_body`.
118
+
119
+ ```
120
+ 4a7c9e2f1b3d5680.{"event":"payment.settled","amount":1000}
121
+ ^^^ Unix seconds ^^^ raw body, byte-for-byte
122
+ ```
123
+
124
+ This means receivers can verify either signature independently. Verifying both is defence-in-depth: HMAC blocks tampering by anyone without the shared secret, while ML-DSA-65 binds the message to the platform identity even if the HMAC secret leaks.
125
+
126
+ ## Why hybrid (HMAC + PQ) instead of PQ-only?
127
+
128
+ | Concern | HMAC-SHA-256 | ML-DSA-65 |
129
+ |---|---|---|
130
+ | Symmetric / asymmetric | Symmetric | Asymmetric |
131
+ | Post-quantum secure | ✓ | ✓ |
132
+ | Verify offline with shared secret | ✓ | — |
133
+ | Non-repudiation | ✗ — anyone with the secret can forge | ✓ — only the holder of the private key can sign |
134
+ | Library required to verify | none | a FIPS-204 library |
135
+ | Signature size | 32 bytes | 3309 bytes |
136
+
137
+ You get the cheap-and-easy verification path AND cryptographic identity binding. Receivers can adopt one signature first and the other later, or both from day one.
138
+
139
+ ## API
140
+
141
+ ### `mlDsa` — ML-DSA-65 (NIST FIPS 204, Dilithium3)
142
+
143
+ - `keypairFromMaster(master, info?)` → `{ publicKey, secretKey }`
144
+ - `sign(secretKey, message)` → hex string
145
+ - `verify(publicKey, message, sigHex)` → boolean
146
+ - `ml_dsa65` — the raw `@noble/post-quantum` primitive, re-exported
147
+
148
+ ### `mlKem` — ML-KEM-768 (NIST FIPS 203, Kyber768)
149
+
150
+ - `keypairFromMaster(master, info?)` → `{ publicKey, secretKey }`
151
+ - `encapsulate(publicKey)` → `{ ciphertext, sharedSecret }`
152
+ - `decapsulate(ciphertext, secretKey)` → `Buffer`
153
+ - `ml_kem768` — the raw `@noble/post-quantum` primitive, re-exported
154
+
155
+ ### `deriveSeed(master, info, length)` → `Buffer`
156
+
157
+ HKDF-SHA-512 derivation. Empty salt is fine when `master` has high entropy. Domain-separate via `info`.
158
+
159
+ ### `fingerprint(publicKey)` → 16-hex `kid`
160
+
161
+ First 16 hex characters of SHA-256 of the public key. Stable for the lifetime of the key.
162
+
163
+ ### `kidEquals(a, b)` → boolean
164
+
165
+ Constant-time string compare. Use this when comparing user-supplied kids.
166
+
167
+ ### `webhook` — hybrid signing utilities
168
+
169
+ - `envelope(timestamp, rawBody)` → `Buffer`
170
+ - `hmacHex(secret, timestamp, rawBody)` → hex string
171
+ - `verifyHmac(secret, timestamp, rawBody, sigHeader)` → boolean
172
+ - `pqSign(secretKey, timestamp, rawBody)` → `ml-dsa-65=<hex>` header value
173
+ - `verifyPq(publicKey, timestamp, rawBody, sigHeader)` → boolean
174
+ - `signDelivery({ rawBody, hmacSecret, pqSecretKey, pqKid, ... })` → header map
175
+ - `verifyDelivery({ headers, rawBody, hmacSecret?, pqPublicKey?, pinnedKid?, windowSeconds? })` → `{ hmacOk, pqOk, timestampOk, kidOk }`
176
+
177
+ ## Security notes
178
+
179
+ - **Keep your master secret in environment variables or a KMS / HSM.** Never commit it.
180
+ - **Use domain separation.** Two purposes = two distinct `info` strings.
181
+ - **Pin the kid.** Don't trust the public key on every request — fetch and pin it once.
182
+ - **Receive raw bodies byte-for-byte.** Re-stringifying JSON before verifying changes the signature input.
183
+ - **Enforce timestamp windows.** Defaults to 5 minutes. Set lower for higher-security paths.
184
+ - **Constant-time compare strings.** Use `kidEquals` and `verifyHmac`, not `===`.
185
+
186
+ ## References
187
+
188
+ - [NIST FIPS 204 — Module-Lattice-Based Digital Signature Standard](https://csrc.nist.gov/pubs/fips/204/final)
189
+ - [NIST FIPS 203 — Module-Lattice-Based Key-Encapsulation Mechanism Standard](https://csrc.nist.gov/pubs/fips/203/final)
190
+ - [NSA CNSA 2.0](https://media.defense.gov/2022/Sep/07/2003071834/-1/-1/0/CSA_CNSA_2.0_ALGORITHMS_.PDF)
191
+ - [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) — the underlying audited implementation
192
+ - [RFC 9106 — Argon2](https://datatracker.ietf.org/doc/html/rfc9106)
193
+
194
+ ## About KXCO
195
+
196
+ KXCO by Knightsbridge is the unification layer for global trade — settlement, issuance, compliance, custody, trading. Quantum-resistant by design. Visit [kxco.ai](https://kxco.ai).
197
+
198
+ ## License
199
+
200
+ MIT. See [LICENSE](./LICENSE).
package/SECURITY.md ADDED
@@ -0,0 +1,29 @@
1
+ # Security Policy
2
+
3
+ ## Reporting
4
+
5
+ Email `security@kxco.ai` with details. Do not open public issues for security reports.
6
+
7
+ We acknowledge within 48 hours. Critical findings are triaged within 5 business days.
8
+
9
+ ## Scope
10
+
11
+ This package wraps `@noble/post-quantum`. Vulnerabilities in the underlying NIST primitives or `@noble/post-quantum` should be reported to that project upstream. This package's scope is the integration patterns: derivation, envelope construction, kid generation, hybrid signing, verification.
12
+
13
+ ## Cryptographic posture
14
+
15
+ - **Algorithms:** NIST FIPS 203 (ML-KEM-768), NIST FIPS 204 (ML-DSA-65). Both Security Category 3.
16
+ - **FIPS module validation:** not held. Algorithms are FIPS-standardised; the module is not CMVP-certified.
17
+ - **Side channels:** the underlying `@noble/post-quantum` aims for constant-time execution. This package adds constant-time string compares for kid and HMAC headers. Higher-assurance deployments should run cryptographic operations inside a FIPS 140-3 HSM.
18
+ - **Randomness:** all operations use Node's `crypto` module CSPRNG.
19
+
20
+ ## Known limitations
21
+
22
+ - Public-edge TLS is hybrid X25519MLKEM768, not pure-PQ. This is correct posture for 2026; pure-PQ TLS will be appropriate once the IETF finalises the relevant drafts.
23
+ - Replay window default is 5 minutes. Reduce for tighter security.
24
+ - Master secret rotation is the caller's responsibility — this library does not manage rotation.
25
+
26
+ ## Audit status
27
+
28
+ - Underlying primitives (`@noble/post-quantum`) — audited by Cure53 (2024).
29
+ - This wrapper — internal review only at present. External audit planned.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "kxco-post-quantum",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "post-quantum",
7
+ "pqc",
8
+ "ml-dsa",
9
+ "ml-kem",
10
+ "dilithium",
11
+ "kyber",
12
+ "nist",
13
+ "fips-203",
14
+ "fips-204",
15
+ "webhook-signing",
16
+ "quantum-resistant",
17
+ "armature"
18
+ ],
19
+ "license": "MIT",
20
+ "author": "KXCO by Knightsbridge <hello@kxco.ai>",
21
+ "homepage": "https://kxco.ai",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/kxco/post-quantum.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/kxco/post-quantum/issues"
28
+ },
29
+ "type": "module",
30
+ "main": "./src/index.js",
31
+ "exports": {
32
+ ".": "./src/index.js",
33
+ "./ml-dsa": "./src/ml-dsa.js",
34
+ "./ml-kem": "./src/ml-kem.js",
35
+ "./derive": "./src/derive.js",
36
+ "./webhook": "./src/webhook.js",
37
+ "./kid": "./src/kid.js"
38
+ },
39
+ "files": [
40
+ "src/",
41
+ "README.md",
42
+ "LICENSE",
43
+ "SECURITY.md"
44
+ ],
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "dependencies": {
49
+ "@noble/post-quantum": "^0.2.1"
50
+ },
51
+ "scripts": {
52
+ "test": "node --test test/"
53
+ }
54
+ }
package/src/derive.js ADDED
@@ -0,0 +1,33 @@
1
+ // Deterministic key derivation via HKDF-SHA-512.
2
+ //
3
+ // Same seed + same info = same keypair, every time. This is how the KXCO
4
+ // platform reproduces its signing identity across replicas without storing
5
+ // the private key in a database: the master key is the env var, the rest is
6
+ // pure derivation.
7
+ //
8
+ // Domain separation through `info` is critical — using the same master key
9
+ // for different purposes (signing vs encryption) MUST use distinct info
10
+ // strings or you create a cross-protocol attack surface.
11
+
12
+ import { hkdfSync } from 'node:crypto'
13
+
14
+ /**
15
+ * Derive a deterministic seed from a master secret + an info string.
16
+ *
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}
21
+ */
22
+ export function deriveSeed(master, info, length) {
23
+ if (!master || master.length < 16) {
24
+ throw new Error('deriveSeed: master keying material must be at least 16 bytes')
25
+ }
26
+ if (!info || typeof info !== 'string') {
27
+ throw new Error('deriveSeed: info string is required for domain separation')
28
+ }
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
+ )
33
+ }
package/src/index.js ADDED
@@ -0,0 +1,16 @@
1
+ // @kxco/post-quantum
2
+ //
3
+ // Production-tested post-quantum cryptography patterns: deterministic key
4
+ // derivation, hybrid HMAC + ML-DSA webhook signing, kid fingerprinting.
5
+ // Built on @noble/post-quantum. Used in production at KXCO across
6
+ // KnightsVault, KXCO Bank, KnightsBot, The Exchequer, and Armature L1.
7
+ //
8
+ // This package does NOT reimplement the NIST primitives. It wraps the
9
+ // audited @noble/post-quantum reference implementation with the integration
10
+ // patterns we have proven in production.
11
+
12
+ export * as mlDsa from './ml-dsa.js'
13
+ export * as mlKem from './ml-kem.js'
14
+ export * from './derive.js'
15
+ export * from './kid.js'
16
+ export * as webhook from './webhook.js'
package/src/kid.js ADDED
@@ -0,0 +1,38 @@
1
+ // Public-key fingerprints (kid = "key identifier").
2
+ //
3
+ // Receivers pin a 16-hex fingerprint of the platform's public key, then
4
+ // compare against an X-KXCO-PQ-Kid header on every webhook. Fast rejection of
5
+ // stale or unknown keys without re-fetching the full 1952-byte public key on
6
+ // every request.
7
+
8
+ import { createHash } from 'node:crypto'
9
+
10
+ /**
11
+ * Compute a stable, 16-hex-character fingerprint of a public key.
12
+ *
13
+ * @param {Buffer|Uint8Array|string} publicKey — raw bytes or hex string
14
+ * @returns {string} 16 hex chars (8 bytes of SHA-256)
15
+ */
16
+ 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)
21
+ }
22
+
23
+ /**
24
+ * Constant-time comparison of two kid strings. Use this instead of `===`
25
+ * when comparing kids from user input.
26
+ *
27
+ * @param {string} a
28
+ * @param {string} b
29
+ * @returns {boolean}
30
+ */
31
+ export function kidEquals(a, b) {
32
+ if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) {
33
+ return false
34
+ }
35
+ let diff = 0
36
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
37
+ return diff === 0
38
+ }
package/src/ml-dsa.js ADDED
@@ -0,0 +1,58 @@
1
+ // ML-DSA-65 helpers (NIST FIPS 204, Dilithium3).
2
+ //
3
+ // Module-lattice signatures. Security Category 3 (≈ AES-192). Public key 1952
4
+ // bytes, signature 3309 bytes. Resistant to attacks by quantum computers.
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.
8
+
9
+ import { ml_dsa65 } from '@noble/post-quantum/ml-dsa'
10
+ import { deriveSeed } from './derive.js'
11
+
12
+ /**
13
+ * 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
+ *
16
+ * @returns {{ publicKey: Buffer, secretKey: Buffer }}
17
+ */
18
+ export function keypairFromMaster(master, info = 'ml-dsa-65-v1') {
19
+ const seed = deriveSeed(master, info, 32)
20
+ const k = ml_dsa65.keygen(seed)
21
+ return {
22
+ publicKey: Buffer.from(k.publicKey),
23
+ secretKey: Buffer.from(k.secretKey),
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Sign a message. Returns the signature as a hex string.
29
+ *
30
+ * @param {Buffer|Uint8Array} secretKey
31
+ * @param {Buffer|string} message
32
+ * @returns {string} hex-encoded signature (6618 chars)
33
+ */
34
+ 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')
38
+ }
39
+
40
+ /**
41
+ * Verify a hex-encoded signature.
42
+ *
43
+ * @param {Buffer|Uint8Array} publicKey
44
+ * @param {Buffer|string} message
45
+ * @param {string} sigHex
46
+ * @returns {boolean}
47
+ */
48
+ export function verify(publicKey, message, sigHex) {
49
+ const msg = Buffer.isBuffer(message) ? message : Buffer.from(message, 'utf8')
50
+ try {
51
+ return ml_dsa65.verify(publicKey, msg, Buffer.from(sigHex, 'hex'))
52
+ } catch {
53
+ return false
54
+ }
55
+ }
56
+
57
+ // Re-export the raw noble primitive for callers who want the lower-level API.
58
+ export { ml_dsa65 }
package/src/ml-kem.js ADDED
@@ -0,0 +1,59 @@
1
+ // ML-KEM-768 helpers (NIST FIPS 203, Kyber768).
2
+ //
3
+ // Module-lattice key encapsulation. Security Category 3 (≈ AES-192). Public
4
+ // key 1184 bytes, ciphertext 1088 bytes, shared secret 32 bytes. Resistant
5
+ // to attacks by quantum computers.
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.
10
+
11
+ import { ml_kem768 } from '@noble/post-quantum/ml-kem'
12
+ import { deriveSeed } from './derive.js'
13
+
14
+ /**
15
+ * Generate an ML-KEM-768 keypair from a master + domain-separation info.
16
+ * Deterministic — same inputs always produce the same keypair.
17
+ *
18
+ * @returns {{ publicKey: Buffer, secretKey: Buffer }}
19
+ */
20
+ export function keypairFromMaster(master, info = 'ml-kem-768-v1') {
21
+ const seed = deriveSeed(master, info, 64)
22
+ const k = ml_kem768.keygen(seed)
23
+ return {
24
+ publicKey: Buffer.from(k.publicKey),
25
+ secretKey: Buffer.from(k.secretKey),
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Encapsulate a shared secret to the recipient's public key.
31
+ *
32
+ * @param {Buffer|Uint8Array} publicKey
33
+ * @returns {{ ciphertext: Buffer, sharedSecret: Buffer }}
34
+ * — transmit ciphertext to the recipient; use sharedSecret as a symmetric key
35
+ */
36
+ export function encapsulate(publicKey) {
37
+ 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)
41
+ return {
42
+ ciphertext: ct,
43
+ cipherText: ct,
44
+ sharedSecret: Buffer.from(r.sharedSecret),
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Decapsulate: recover the shared secret from a ciphertext using the secret key.
50
+ *
51
+ * @param {Buffer|Uint8Array} ciphertext
52
+ * @param {Buffer|Uint8Array} secretKey
53
+ * @returns {Buffer} shared secret (32 bytes)
54
+ */
55
+ export function decapsulate(ciphertext, secretKey) {
56
+ return Buffer.from(ml_kem768.decapsulate(ciphertext, secretKey))
57
+ }
58
+
59
+ export { ml_kem768 }
package/src/webhook.js ADDED
@@ -0,0 +1,137 @@
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
+ // Receivers can verify either or both; verifying both is defence-in-depth.
13
+
14
+ import { createHmac, timingSafeEqual } from 'node:crypto'
15
+ import { sign as mlDsaSign, verify as mlDsaVerify } from './ml-dsa.js'
16
+
17
+ /**
18
+ * 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
+ */
24
+ 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])
27
+ }
28
+
29
+ /**
30
+ * Compute the hex HMAC-SHA-256 of the envelope using a shared secret.
31
+ *
32
+ * @returns {string} hex-encoded HMAC (no `sha256=` prefix)
33
+ */
34
+ export function hmacHex(secret, timestamp, rawBody) {
35
+ return createHmac('sha256', secret).update(envelope(timestamp, rawBody)).digest('hex')
36
+ }
37
+
38
+ /**
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}
43
+ */
44
+ export function verifyHmac(secret, timestamp, rawBody, sigHeader) {
45
+ const expected = 'sha256=' + hmacHex(secret, timestamp, rawBody)
46
+ 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)
51
+ }
52
+
53
+ /**
54
+ * Produce the X-KXCO-PQ-Signature header value: the hex ML-DSA-65 signature
55
+ * over the envelope, prefixed with `ml-dsa-65=`.
56
+ */
57
+ export function pqSign(secretKey, timestamp, rawBody) {
58
+ const sig = mlDsaSign(secretKey, envelope(timestamp, rawBody))
59
+ return `ml-dsa-65=${sig}`
60
+ }
61
+
62
+ /**
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}
67
+ */
68
+ export function verifyPq(publicKey, timestamp, rawBody, sigHeader) {
69
+ const hex = sigHeader.startsWith('ml-dsa-65=')
70
+ ? sigHeader.slice('ml-dsa-65='.length)
71
+ : sigHeader
72
+ return mlDsaVerify(publicKey, envelope(timestamp, rawBody), hex)
73
+ }
74
+
75
+ /**
76
+ * 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
88
+ */
89
+ export function signDelivery({ rawBody, hmacSecret, pqSecretKey, pqKid, event, deliveryId }) {
90
+ const ts = Math.floor(Date.now() / 1000).toString()
91
+ const headers = {
92
+ 'Content-Type': 'application/json',
93
+ 'X-KXCO-Timestamp': ts,
94
+ 'X-KXCO-Signature': 'sha256=' + hmacHex(hmacSecret, ts, rawBody),
95
+ 'X-KXCO-PQ-Signature': pqSign(pqSecretKey, ts, rawBody),
96
+ 'X-KXCO-PQ-Kid': pqKid,
97
+ }
98
+ if (event) headers['X-KXCO-Event'] = event
99
+ if (deliveryId) headers['X-KXCO-Delivery'] = deliveryId
100
+ return headers
101
+ }
102
+
103
+ /**
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 }}
116
+ */
117
+ 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']
122
+
123
+ const tsNum = parseInt(ts, 10)
124
+ const timestampOk = Number.isFinite(tsNum) &&
125
+ Math.abs(Date.now() / 1000 - tsNum) <= windowSeconds
126
+
127
+ const hmacOk = (hmacSecret && sigHmac && timestampOk)
128
+ ? verifyHmac(hmacSecret, ts, rawBody, sigHmac)
129
+ : false
130
+
131
+ const kidOk = pinnedKid ? kid === pinnedKid : true
132
+ const pqOk = (pqPublicKey && sigPq && timestampOk && kidOk)
133
+ ? verifyPq(pqPublicKey, ts, rawBody, sigPq)
134
+ : false
135
+
136
+ return { hmacOk, pqOk, timestampOk, kidOk }
137
+ }