kxco-post-quantum 1.2.1 → 1.4.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/src/index.js CHANGED
@@ -1,17 +1,24 @@
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 * as slhDsa from './slh-dsa.js'
15
- export * from './derive.js'
16
- export * from './kid.js'
17
- export * as webhook from './webhook.js'
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 * as slhDsa from './slh-dsa.js'
15
+
16
+ // Category 5 parameter sets, for callers who are given ML-DSA-87 or
17
+ // ML-KEM-1024 as a requirement. The KXCO default stays Category 3
18
+ // (mlDsa / mlKem). Supporting these sets is not a CNSA 2.0 compliance claim;
19
+ // see the notes at the top of each module and CONFORMANCE.md.
20
+ export * as mlDsa87 from './ml-dsa-87.js'
21
+ export * as mlKem1024 from './ml-kem-1024.js'
22
+ export * from './derive.js'
23
+ export * from './kid.js'
24
+ export * as webhook from './webhook.js'
package/src/kid.d.ts CHANGED
@@ -1,21 +1,21 @@
1
- /// <reference types="node" />
2
-
3
- /**
4
- * Compute a 16-hex-character fingerprint of a public key:
5
- * the first 8 bytes of `SHA-256(public_key)`, hex-encoded.
6
- *
7
- * Stable for the lifetime of the keypair. Used to identify which
8
- * platform key signed an outbound delivery without including the
9
- * full 1952-byte public key in every request.
10
- *
11
- * @param publicKey — raw bytes or hex string
12
- */
13
- export function fingerprint(publicKey: Buffer | Uint8Array | string): string
14
-
15
- /**
16
- * Constant-time comparison of two kid strings.
17
- *
18
- * Use this instead of `===` when comparing kids that may be
19
- * influenced by untrusted input — eg. an `X-KXCO-PQ-Kid` header.
20
- */
21
- export function kidEquals(a: string, b: string): boolean
1
+ /// <reference types="node" />
2
+
3
+ /**
4
+ * Compute a 16-hex-character fingerprint of a public key:
5
+ * the first 8 bytes of `SHA-256(public_key)`, hex-encoded.
6
+ *
7
+ * Stable for the lifetime of the keypair. Used to identify which
8
+ * platform key signed an outbound delivery without including the
9
+ * full 1952-byte public key in every request.
10
+ *
11
+ * @param publicKey — raw bytes or hex string
12
+ */
13
+ export function fingerprint(publicKey: Buffer | Uint8Array | string): string
14
+
15
+ /**
16
+ * Constant-time comparison of two kid strings.
17
+ *
18
+ * Use this instead of `===` when comparing kids that may be
19
+ * influenced by untrusted input — eg. an `X-KXCO-PQ-Kid` header.
20
+ */
21
+ export function kidEquals(a: string, b: string): boolean
package/src/kid.js CHANGED
@@ -1,53 +1,53 @@
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
- // Isomorphic: uses @noble/hashes/sha256 — runs identically in Node and
9
- // browsers.
10
-
11
- import { sha256 } from '@noble/hashes/sha2.js'
12
-
13
- function hexToBytes(hex) {
14
- if (hex.length % 2) throw new Error('odd hex length')
15
- const b = new Uint8Array(hex.length / 2)
16
- for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
17
- return b
18
- }
19
-
20
- function bytesToHex(bytes) {
21
- let s = ''
22
- for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0')
23
- return s
24
- }
25
-
26
- /**
27
- * Compute a stable 16-hex-character fingerprint of a public key.
28
- *
29
- * @param {Buffer|Uint8Array|string} publicKey — raw bytes or hex string
30
- * @returns {string} 16 hex chars (8 bytes of SHA-256)
31
- */
32
- export function fingerprint(publicKey) {
33
- const bytes = typeof publicKey === 'string'
34
- ? hexToBytes(publicKey)
35
- : (publicKey instanceof Uint8Array ? publicKey : new Uint8Array(publicKey))
36
- return bytesToHex(sha256(bytes)).slice(0, 16)
37
- }
38
-
39
- /**
40
- * Constant-time comparison of two kid strings.
41
- *
42
- * @param {string} a
43
- * @param {string} b
44
- * @returns {boolean}
45
- */
46
- export function kidEquals(a, b) {
47
- if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) {
48
- return false
49
- }
50
- let diff = 0
51
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
52
- return diff === 0
53
- }
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
+ // Isomorphic: uses @noble/hashes/sha256 — runs identically in Node and
9
+ // browsers.
10
+
11
+ import { sha256 } from '@noble/hashes/sha2.js'
12
+
13
+ function hexToBytes(hex) {
14
+ if (hex.length % 2) throw new Error('odd hex length')
15
+ const b = new Uint8Array(hex.length / 2)
16
+ for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
17
+ return b
18
+ }
19
+
20
+ function bytesToHex(bytes) {
21
+ let s = ''
22
+ for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0')
23
+ return s
24
+ }
25
+
26
+ /**
27
+ * Compute a stable 16-hex-character fingerprint of a public key.
28
+ *
29
+ * @param {Buffer|Uint8Array|string} publicKey — raw bytes or hex string
30
+ * @returns {string} 16 hex chars (8 bytes of SHA-256)
31
+ */
32
+ export function fingerprint(publicKey) {
33
+ const bytes = typeof publicKey === 'string'
34
+ ? hexToBytes(publicKey)
35
+ : (publicKey instanceof Uint8Array ? publicKey : new Uint8Array(publicKey))
36
+ return bytesToHex(sha256(bytes)).slice(0, 16)
37
+ }
38
+
39
+ /**
40
+ * Constant-time comparison of two kid strings.
41
+ *
42
+ * @param {string} a
43
+ * @param {string} b
44
+ * @returns {boolean}
45
+ */
46
+ export function kidEquals(a, b) {
47
+ if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) {
48
+ return false
49
+ }
50
+ let diff = 0
51
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
52
+ return diff === 0
53
+ }
@@ -0,0 +1,81 @@
1
+ /// <reference types="node" />
2
+
3
+ export interface MlDsa87Keypair {
4
+ /** 2592-byte public key */
5
+ publicKey: Buffer
6
+ /** 4896-byte secret key */
7
+ secretKey: Buffer
8
+ }
9
+
10
+ /**
11
+ * Generate an ML-DSA-87 (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
+ * Security Category 5. The default `info` differs from the ML-DSA-65 module's,
17
+ * so one master yields unrelated keys for the two parameter sets.
18
+ *
19
+ * CNSA 2.0 names ML-DSA-87. Support for the parameter set is not a CNSA 2.0
20
+ * compliance claim; see the note at the top of `ml-dsa-87.js`.
21
+ */
22
+ export function keypairFromMaster(
23
+ master: Buffer | Uint8Array,
24
+ info?: string,
25
+ ): MlDsa87Keypair
26
+
27
+ /** Maximum context length in bytes (FIPS 204 section 5.2). */
28
+ export const MAX_CONTEXT_BYTES: 255
29
+
30
+ export interface SignatureOptions {
31
+ /**
32
+ * Optional FIPS 204 section 5.2 context string, at most 255 bytes.
33
+ * Strings are encoded as UTF-8.
34
+ *
35
+ * Gives domain separation at the signature level: a signature made under a
36
+ * context does not verify without it, or under a different one. An empty
37
+ * context is identical to omitting it.
38
+ */
39
+ context?: Buffer | Uint8Array | string
40
+ }
41
+
42
+ /**
43
+ * Sign a message under an ML-DSA-87 secret key. Returns the signature
44
+ * as a hex string (4627 bytes = 9254 hex characters).
45
+ *
46
+ * @throws {TypeError} if `opts` is not an options object, or `context` is
47
+ * neither a string nor a Uint8Array
48
+ * @throws {RangeError} if `context` exceeds 255 bytes
49
+ */
50
+ export function sign(
51
+ secretKey: Buffer | Uint8Array,
52
+ message: Buffer | Uint8Array | string,
53
+ opts?: SignatureOptions,
54
+ ): string
55
+
56
+ /**
57
+ * Verify a hex-encoded ML-DSA-87 signature against a public key + message.
58
+ *
59
+ * Returns `false` on any cryptographic failure, including a signature or key
60
+ * from a different parameter set.
61
+ *
62
+ * Throws only on caller misuse of `opts`, which is a programming error rather
63
+ * than a failed verification and is not swallowed.
64
+ *
65
+ * @throws {TypeError} if `opts` is not an options object, or `context` is
66
+ * neither a string nor a Uint8Array
67
+ * @throws {RangeError} if `context` exceeds 255 bytes
68
+ */
69
+ export function verify(
70
+ publicKey: Buffer | Uint8Array,
71
+ message: Buffer | Uint8Array | string,
72
+ sigHex: string,
73
+ opts?: SignatureOptions,
74
+ ): boolean
75
+
76
+ /**
77
+ * Raw `@noble/post-quantum` ML-DSA-87 primitive, re-exported for callers
78
+ * who want the lower-level API. The wrapper functions above are
79
+ * recommended for production use.
80
+ */
81
+ export const ml_dsa87: typeof import('@noble/post-quantum/ml-dsa.js').ml_dsa87
@@ -0,0 +1,127 @@
1
+ // ML-DSA-87 helpers (NIST FIPS 204, Dilithium5).
2
+ //
3
+ // Module-lattice signatures. Security Category 5 (≈ AES-256). Public key 2592
4
+ // bytes, secret key 4896 bytes, signature 4627 bytes. Resistant to attacks by
5
+ // quantum computers.
6
+ //
7
+ // Same API as ./ml-dsa.js, one security category higher. Use this where a
8
+ // counterparty specifies Category 5 or names ML-DSA-87. ML-DSA-65 remains the
9
+ // default for the KXCO stack; see the note on parameter choice below.
10
+ //
11
+ // Isomorphic: works in Node and modern browsers. Returns Buffer on Node
12
+ // (backwards compatible), Uint8Array in browsers.
13
+ //
14
+ // ---------------------------------------------------------------------------
15
+ // Parameter choice, and what this module does NOT establish
16
+ //
17
+ // CNSA 2.0 names ML-DSA-87 for National Security Systems. Exporting this module
18
+ // makes that parameter set available to callers. It does not make any deployed
19
+ // system CNSA 2.0 compliant, and it must not be cited as a compliance badge.
20
+ // Compliance is a property of a deployment, not of an available function: the
21
+ // KXCO estate signs with ML-DSA-65 at Category 3, including Armature L1 from
22
+ // block 0 and every issued KXCO ID, none of which this module changes.
23
+ //
24
+ // The accurate sentence is "supports ML-DSA-87". See CONFORMANCE.md.
25
+ //
26
+ // Cost of moving up: a signature is 4627 bytes against 3309 for ML-DSA-65, and
27
+ // a public key is 2592 against 1952. Once both sets are in use, anything
28
+ // downstream with a fixed-width signature field will meet both sizes.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ import { ml_dsa87 } from '@noble/post-quantum/ml-dsa.js'
32
+ import { deriveSeed } from './derive.js'
33
+ import { normalizeContext, MAX_CONTEXT_BYTES } from './_context.js'
34
+
35
+ export { MAX_CONTEXT_BYTES }
36
+
37
+ const HAS_BUFFER = typeof Buffer !== 'undefined'
38
+ const enc = new TextEncoder()
39
+
40
+ function toBytes(input) {
41
+ if (input instanceof Uint8Array) return input
42
+ if (typeof input === 'string') return enc.encode(input)
43
+ throw new Error('expected Uint8Array or string')
44
+ }
45
+ function hexToBytes(hex) {
46
+ if (typeof hex !== 'string' || hex.length % 2) throw new Error('invalid hex')
47
+ const b = new Uint8Array(hex.length / 2)
48
+ for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
49
+ return b
50
+ }
51
+ function bytesToHex(bytes) {
52
+ let s = ''
53
+ for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0')
54
+ return s
55
+ }
56
+ function wrap(bytes) {
57
+ return HAS_BUFFER ? Buffer.from(bytes) : bytes
58
+ }
59
+
60
+ /**
61
+ * Generate an ML-DSA-87 keypair from a master + domain-separation info.
62
+ *
63
+ * The default info differs from the ML-DSA-65 default, so the same master
64
+ * yields unrelated keys for the two parameter sets rather than colliding.
65
+ *
66
+ * @returns {{ publicKey: Buffer|Uint8Array, secretKey: Buffer|Uint8Array }}
67
+ */
68
+ export function keypairFromMaster(master, info = 'ml-dsa-87-v1') {
69
+ const seed = deriveSeed(master, info, 32)
70
+ const seedU8 = seed instanceof Uint8Array ? seed : new Uint8Array(seed)
71
+ const k = ml_dsa87.keygen(seedU8)
72
+ return {
73
+ publicKey: wrap(k.publicKey),
74
+ secretKey: wrap(k.secretKey),
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Sign a message. Returns the signature as a hex string.
80
+ *
81
+ * An optional FIPS 204 section 5.2 context string gives domain separation: a
82
+ * signature made under a context does not verify without it.
83
+ *
84
+ * @param {Buffer|Uint8Array} secretKey
85
+ * @param {Buffer|Uint8Array|string} message
86
+ * @param {{ context?: Uint8Array|Buffer|string }} [opts] at most 255 context bytes
87
+ * @returns {string} hex-encoded signature (9254 chars)
88
+ */
89
+ export function sign(secretKey, message, opts) {
90
+ const context = normalizeContext(opts)
91
+ const sig = context === undefined
92
+ ? ml_dsa87.sign(toBytes(message), secretKey)
93
+ : ml_dsa87.sign(toBytes(message), secretKey, { context })
94
+ return bytesToHex(sig)
95
+ }
96
+
97
+ /**
98
+ * Verify a hex-encoded signature.
99
+ *
100
+ * Pass the same context the signer used. A signature made under a context
101
+ * returns false here if the context is omitted or differs, which is the point
102
+ * of it.
103
+ *
104
+ * Returns false for any cryptographic failure, including a signature produced
105
+ * under a different parameter set. Throws only on caller misuse of `opts`
106
+ * (wrong type, or a context over 255 bytes), because that is a bug rather than
107
+ * a failed verification and should not be silently swallowed.
108
+ *
109
+ * @param {Buffer|Uint8Array} publicKey
110
+ * @param {Buffer|Uint8Array|string} message
111
+ * @param {string} sigHex
112
+ * @param {{ context?: Uint8Array|Buffer|string }} [opts]
113
+ * @returns {boolean}
114
+ */
115
+ export function verify(publicKey, message, sigHex, opts) {
116
+ // Outside the try: misuse must surface, not be swallowed as "invalid".
117
+ const context = normalizeContext(opts)
118
+ try {
119
+ return context === undefined
120
+ ? ml_dsa87.verify(hexToBytes(sigHex), toBytes(message), publicKey)
121
+ : ml_dsa87.verify(hexToBytes(sigHex), toBytes(message), publicKey, { context })
122
+ } catch {
123
+ return false
124
+ }
125
+ }
126
+
127
+ export { ml_dsa87 }
package/src/ml-dsa.d.ts CHANGED
@@ -1,45 +1,78 @@
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.js').ml_dsa65
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
+ /** Maximum context length in bytes (FIPS 204 section 5.2). */
22
+ export const MAX_CONTEXT_BYTES: 255
23
+
24
+ export interface SignatureOptions {
25
+ /**
26
+ * Optional FIPS 204 section 5.2 context string, at most 255 bytes.
27
+ * Strings are encoded as UTF-8.
28
+ *
29
+ * Gives domain separation at the signature level: a signature made under a
30
+ * context does not verify without it, or under a different one. An empty
31
+ * context is identical to omitting it.
32
+ *
33
+ * Complements `keypairFromMaster(master, info)`, which separates domains at
34
+ * the key level.
35
+ */
36
+ context?: Buffer | Uint8Array | string
37
+ }
38
+
39
+ /**
40
+ * Sign a message under an ML-DSA-65 secret key. Returns the signature
41
+ * as a hex string (3309 bytes = 6618 hex characters).
42
+ *
43
+ * @throws {TypeError} if `opts` is not an options object, or `context` is
44
+ * neither a string nor a Uint8Array
45
+ * @throws {RangeError} if `context` exceeds 255 bytes
46
+ */
47
+ export function sign(
48
+ secretKey: Buffer | Uint8Array,
49
+ message: Buffer | Uint8Array | string,
50
+ opts?: SignatureOptions,
51
+ ): string
52
+
53
+ /**
54
+ * Verify a hex-encoded ML-DSA-65 signature against a public key + message.
55
+ *
56
+ * Returns `false` on any cryptographic failure (invalid hex, wrong length,
57
+ * mismatch, or a missing/incorrect context).
58
+ *
59
+ * Throws only on caller misuse of `opts`, which is a programming error rather
60
+ * than a failed verification and is not swallowed.
61
+ *
62
+ * @throws {TypeError} if `opts` is not an options object, or `context` is
63
+ * neither a string nor a Uint8Array
64
+ * @throws {RangeError} if `context` exceeds 255 bytes
65
+ */
66
+ export function verify(
67
+ publicKey: Buffer | Uint8Array,
68
+ message: Buffer | Uint8Array | string,
69
+ sigHex: string,
70
+ opts?: SignatureOptions,
71
+ ): boolean
72
+
73
+ /**
74
+ * Raw `@noble/post-quantum` ML-DSA-65 primitive, re-exported for callers
75
+ * who want the lower-level API. The wrapper functions above are
76
+ * recommended for production use.
77
+ */
78
+ export const ml_dsa65: typeof import('@noble/post-quantum/ml-dsa.js').ml_dsa65