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 +21 -0
- package/README.md +200 -0
- package/SECURITY.md +29 -0
- package/package.json +54 -0
- package/src/derive.js +33 -0
- package/src/index.js +16 -0
- package/src/kid.js +38 -0
- package/src/ml-dsa.js +58 -0
- package/src/ml-kem.js +59 -0
- package/src/webhook.js +137 -0
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
|
+
[](https://www.npmjs.com/package/@kxco/post-quantum)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](https://csrc.nist.gov/pubs/fips/203/final)
|
|
8
|
+
[](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
|
+
}
|