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.
@@ -0,0 +1,194 @@
1
+ # Threat model
2
+
3
+ What this package defends against, what it does not, and where the boundary
4
+ falls. Read this before deciding where to run it.
5
+
6
+ The short version: this is a software library written in JavaScript. It gives
7
+ you correct, standards-conformant ML-KEM, ML-DSA and SLH-DSA. It does not give
8
+ you resistance to an attacker who can measure the machine while it runs. If your
9
+ threat model includes that attacker, the key needs to live somewhere else.
10
+
11
+ ---
12
+
13
+ ## What is being protected
14
+
15
+ | Asset | Where it lives | Consequence if lost |
16
+ |---|---|---|
17
+ | ML-DSA / SLH-DSA private key | Caller-owned bytes in process memory | Attacker can forge signatures indefinitely |
18
+ | ML-KEM decapsulation key | Caller-owned bytes in process memory | Attacker can recover past and future shared secrets |
19
+ | ML-KEM shared secret | Return value, caller-owned | Attacker can decrypt the session that used it |
20
+ | Master seed used with `deriveSeed` | Caller-owned | Attacker can regenerate every derived key |
21
+
22
+ The library holds no keys of its own, opens no sockets, reads no files and keeps
23
+ no state between calls. Everything above is caller-owned material passed in and
24
+ returned. That places most of the security boundary in the calling application,
25
+ not here.
26
+
27
+ ---
28
+
29
+ ## Attackers in scope
30
+
31
+ **A remote attacker who can choose messages, signatures, ciphertexts and
32
+ context strings.** They submit arbitrary and malformed input across the network
33
+ and observe accept/reject and returned bytes. This is the attacker the library
34
+ is built to withstand.
35
+
36
+ - Forgery is resisted by the parameter sets themselves. The signature and
37
+ verification paths follow FIPS 204 and FIPS 205 including the context-string
38
+ binding, so a signature made under one context does not verify under another.
39
+ Cross-implementation evidence is in [CONFORMANCE.md](CONFORMANCE.md).
40
+ - Malformed input is rejected rather than misinterpreted. Key and ciphertext
41
+ lengths are checked, ML-KEM performs the FIPS 203 §7.2/§7.3 input checks, and
42
+ a corrupted ciphertext yields an unrelated shared secret through implicit
43
+ rejection instead of an error that would distinguish the failure.
44
+ - Signature malleability is not a defence the caller has to add: verification is
45
+ over the exact encoded signature.
46
+
47
+ **An attacker who tampers with data at rest or in transit.** Detecting this is
48
+ the library's purpose and it does so as well as the underlying parameter sets.
49
+
50
+ **A quantum adversary running Shor's algorithm.** The three algorithm families
51
+ here rest on module-lattice and hash-based problems with no known efficient
52
+ quantum attack, which is the reason to use them. This is a statement about the
53
+ current state of cryptanalysis, not a proof, and it is the same assumption every
54
+ FIPS 203/204/205 deployment makes.
55
+
56
+ ---
57
+
58
+ ## Attackers out of scope
59
+
60
+ These are real attackers. They are excluded because this library genuinely does
61
+ not defend against them, and saying otherwise would be worse than saying nothing.
62
+
63
+ **An attacker who can measure execution on the same machine.** Timing, cache
64
+ occupancy, branch prediction, memory access patterns, power draw,
65
+ electromagnetic emission. Nothing here is hardened against any of it.
66
+
67
+ This is not an oversight that a future release closes. It follows from the
68
+ runtime. JavaScript exposes no control over instruction selection, branch
69
+ layout, memory placement or cache behaviour; the JIT may specialise a hot path
70
+ on the values flowing through it; the garbage collector may copy secret bytes to
71
+ places the caller cannot reach and cannot clear. Constant-time execution cannot
72
+ be established, let alone maintained across engine versions, from inside the
73
+ language. The backend states this about itself in plain terms: *"There is no
74
+ protection against side-channel attacks."*
75
+
76
+ Two consequences worth being blunt about:
77
+
78
+ - Do not run signing or decapsulation on hardware that also runs untrusted
79
+ code. Shared-tenancy compute where another tenant can co-schedule on your
80
+ physical core is exactly the setting this fails in.
81
+ - Do not run it where an adversary can attach measurement equipment. Smart
82
+ cards, payment terminals, anything physically in an attacker's hands.
83
+
84
+ **An attacker who can read process memory.** A core dump, a debugger, a heap
85
+ snapshot or an in-process code-execution bug exposes every key the process is
86
+ holding. Best-effort zeroization is documented below and it does not change this.
87
+
88
+ **An attacker who has already achieved code execution in your process.** They
89
+ can call the library themselves with the keys it was given.
90
+
91
+ **A weak or predictable random source.** Key generation and hedged signing draw
92
+ from the platform CSPRNG through the backend. On a host whose entropy source is
93
+ broken or replayed, for example a VM image cloned after boot, the keys are
94
+ predictable and no property here survives that.
95
+
96
+ **An attacker positioned in the supply chain.** Addressed separately, by
97
+ release provenance and pinning rather than by anything in the runtime. See
98
+ [SECURITY.md](SECURITY.md).
99
+
100
+ ---
101
+
102
+ ## Where the residual risk actually sits
103
+
104
+ If you accept the two boxes above, the risk that remains is concentrated in one
105
+ place: **a long-lived signing key held in the memory of a general-purpose
106
+ process on shared hardware.**
107
+
108
+ Mitigations in rough order of how much they buy:
109
+
110
+ 1. **Keep the key out of the process.** An HSM or KMS that performs ML-DSA
111
+ internally removes the whole out-of-scope column for that key, because the
112
+ key never enters a JavaScript heap. This library then handles verification
113
+ only, which touches no secrets and is therefore unaffected by every
114
+ side-channel concern above.
115
+ 2. **Isolate the signer.** A dedicated host or a VM with no untrusted
116
+ co-tenancy, running only the signing service, reachable through a narrow
117
+ authenticated interface. This does not make the code constant-time; it
118
+ removes the attacker who could exploit that.
119
+ 3. **Prefer short-lived keys where the design allows it.** A key rotated
120
+ frequently limits what a successful measurement attack yields.
121
+ 4. **Keep verification and signing on separate hosts.** Verification is the
122
+ operation exposed to hostile input, and it holds no secrets. Nothing is
123
+ gained by placing it next to the key.
124
+
125
+ ---
126
+
127
+ ## Choices this library makes, and why
128
+
129
+ **Hedged signing by default.** `mlDsa.sign` and `slhDsa.sign` do not pass
130
+ `extraEntropy`, so the backend draws fresh randomness for each signature. FIPS
131
+ 204 permits both, and hedged signing is the recommended default: it removes the
132
+ class of fault and differential attacks that recover a key from two signatures
133
+ produced over the same message with the same nonce. The cost is that signatures
134
+ are not reproducible, which the interop matrix reports rather than glosses over.
135
+ Callers who need reproducible output should use the backend primitives directly
136
+ and accept the trade.
137
+
138
+ **Pre-hash strength is enforced, and it is stricter than NIST's sample
139
+ vectors.** The backend refuses a HashML-DSA or HashSLH-DSA pre-hash whose
140
+ collision strength falls below the parameter set's security category, for
141
+ example SHA2-256 with ML-DSA-87. NIST's published vector files pair every
142
+ approved hash with every parameter set, so a run against them shows those
143
+ combinations as skipped. That is the intended behaviour, and the conformance
144
+ report counts them explicitly rather than hiding them in a pass total.
145
+
146
+ **Dependencies are exact-pinned, not range-pinned.** `@noble/post-quantum` and
147
+ `@noble/hashes` are pinned to single versions. A cryptographic backend that
148
+ floats within a semver range means the bytes you ship are not the bytes you
149
+ tested. The cost is manual review at each upgrade, which is the point.
150
+
151
+ **Context strings are supported and bounded.** FIPS 204 §5.2 folds a caller
152
+ context into the signed message, which is how a signature made for one purpose
153
+ is prevented from verifying for another. The 255-byte limit is enforced rather
154
+ than truncated, because silent truncation would merge two contexts a caller
155
+ meant to keep apart.
156
+
157
+ **Constant-time comparison is claimed; constant-time cryptography is not.**
158
+ `kidEquals` compares key fingerprints without an early return, so it does not
159
+ leak how many leading bytes matched. That is an achievable property in
160
+ JavaScript for a fixed-length byte comparison and it is asserted. It says
161
+ nothing about the algorithm implementations, where the property is not
162
+ achievable and is not claimed. Two different statements about two different
163
+ things; do not read the first as implying the second.
164
+
165
+ **Zeroization is best effort and is not a security control here.** Key bytes are
166
+ cleared where the library owns the buffer. In a garbage-collected runtime with
167
+ immutable strings and copying collection, no library can guarantee that no copy
168
+ survives. Treat memory disclosure as total key compromise regardless.
169
+
170
+ ---
171
+
172
+ ## What would change this assessment
173
+
174
+ Not a roadmap, and none of it is in the package today. Stated so the boundary is
175
+ falsifiable rather than permanent by assertion.
176
+
177
+ - A native or WebAssembly backend built from a constant-time implementation
178
+ would move the timing and cache attacker from out of scope to partly in
179
+ scope, with published measurements to show it. It would not cover power or
180
+ electromagnetic analysis.
181
+ - Published timing measurements under a statistical test such as dudect would
182
+ turn "no claim" into a measured bound. Absence of a detected leak is not
183
+ absence of a leak, and any such result would be reported that way.
184
+ - FIPS 140-3 validation of a module used underneath would change what can be
185
+ asserted about the boundary, and is not the same as the algorithm-level
186
+ conformance evidence in [CONFORMANCE.md](CONFORMANCE.md).
187
+
188
+ ---
189
+
190
+ ## Reporting
191
+
192
+ Security contact and disclosure timelines are in [SECURITY.md](SECURITY.md).
193
+ If you find that any statement in this document is wrong, that is a finding
194
+ worth reporting on its own.
package/package.json CHANGED
@@ -1,107 +1,124 @@
1
- {
2
- "name": "kxco-post-quantum",
3
- "version": "1.2.1",
4
- "description": "ML-DSA-65, ML-KEM-768 and SLH-DSA-SHA2-192s primitives with key fingerprinting. The base layer for all kxco-pq-* packages.",
5
- "keywords": [
6
- "post-quantum",
7
- "pqc",
8
- "ml-dsa",
9
- "ml-kem",
10
- "slh-dsa",
11
- "sphincs",
12
- "dilithium",
13
- "kyber",
14
- "nist",
15
- "fips-203",
16
- "fips-204",
17
- "fips-205",
18
- "webhook-signing",
19
- "quantum-resistant",
20
- "armature",
21
- "key-derivation",
22
- "hkdf",
23
- "deterministic-keys",
24
- "fingerprint",
25
- "replay-protection",
26
- "constant-time",
27
- "hybrid-signing",
28
- "non-repudiation"
29
- ],
30
- "license": "Apache-2.0",
31
- "author": "Shayne Heffernan and John Heffernan",
32
- "contributors": [
33
- {
34
- "name": "Shayne Heffernan"
35
- },
36
- {
37
- "name": "John Heffernan"
38
- }
39
- ],
40
- "homepage": "https://kxco.ai",
41
- "funding": "https://kxco.ai",
42
- "repository": {
43
- "type": "git",
44
- "url": "https://github.com/JackKXCO/kxco-post-quantum.git"
45
- },
46
- "bugs": {
47
- "url": "https://github.com/JackKXCO/kxco-post-quantum/issues"
48
- },
49
- "type": "module",
50
- "sideEffects": false,
51
- "main": "./src/index.js",
52
- "types": "./src/index.d.ts",
53
- "exports": {
54
- ".": {
55
- "types": "./src/index.d.ts",
56
- "import": "./src/index.js"
57
- },
58
- "./ml-dsa": {
59
- "types": "./src/ml-dsa.d.ts",
60
- "import": "./src/ml-dsa.js"
61
- },
62
- "./ml-kem": {
63
- "types": "./src/ml-kem.d.ts",
64
- "import": "./src/ml-kem.js"
65
- },
66
- "./slh-dsa": {
67
- "types": "./src/slh-dsa.d.ts",
68
- "import": "./src/slh-dsa.js"
69
- },
70
- "./derive": {
71
- "types": "./src/derive.d.ts",
72
- "import": "./src/derive.js"
73
- },
74
- "./webhook": {
75
- "types": "./src/webhook.d.ts",
76
- "import": "./src/webhook.js"
77
- },
78
- "./kid": {
79
- "types": "./src/kid.d.ts",
80
- "import": "./src/kid.js"
81
- }
82
- },
83
- "files": [
84
- "src",
85
- "README.md",
86
- "LICENSE",
87
- "SECURITY.md",
88
- "CHANGELOG.md"
89
- ],
90
- "engines": {
91
- "node": ">=20.19"
92
- },
93
- "dependencies": {
94
- "@noble/hashes": "^2.2.0",
95
- "@noble/post-quantum": "^0.6.1"
96
- },
97
- "scripts": {
98
- "test": "node --test test/basic.test.js && node --test test/browser-smoke.test.js && node test/run-vectors.js",
99
- "test:vectors": "node test/run-vectors.js",
100
- "generate:vectors": "node test/generate-vectors.js > test/vectors.json",
101
- "bench": "node bench/bench.js"
102
- },
103
- "publishConfig": {
104
- "provenance": true,
105
- "access": "public"
106
- }
107
- }
1
+ {
2
+ "name": "kxco-post-quantum",
3
+ "version": "1.4.0",
4
+ "description": "ML-DSA-65, ML-KEM-768 and SLH-DSA-SHA2-192s primitives with key fingerprinting. The base layer for all kxco-pq-* packages.",
5
+ "keywords": [
6
+ "post-quantum",
7
+ "pqc",
8
+ "ml-dsa",
9
+ "ml-kem",
10
+ "slh-dsa",
11
+ "sphincs",
12
+ "dilithium",
13
+ "kyber",
14
+ "nist",
15
+ "fips-203",
16
+ "fips-204",
17
+ "fips-205",
18
+ "webhook-signing",
19
+ "quantum-resistant",
20
+ "armature",
21
+ "key-derivation",
22
+ "hkdf",
23
+ "deterministic-keys",
24
+ "fingerprint",
25
+ "replay-protection",
26
+ "hybrid-signing",
27
+ "non-repudiation",
28
+ "ml-dsa-87",
29
+ "ml-kem-1024",
30
+ "category-5"
31
+ ],
32
+ "license": "Apache-2.0",
33
+ "author": "Shayne Heffernan and John Heffernan",
34
+ "contributors": [
35
+ {
36
+ "name": "Shayne Heffernan"
37
+ },
38
+ {
39
+ "name": "John Heffernan"
40
+ }
41
+ ],
42
+ "homepage": "https://kxco.ai",
43
+ "funding": "https://kxco.ai",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/KnightsbridgeAIQ/kxco-post-quantum.git"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/KnightsbridgeAIQ/kxco-post-quantum/issues"
50
+ },
51
+ "type": "module",
52
+ "sideEffects": false,
53
+ "main": "./src/index.js",
54
+ "types": "./src/index.d.ts",
55
+ "exports": {
56
+ ".": {
57
+ "types": "./src/index.d.ts",
58
+ "import": "./src/index.js"
59
+ },
60
+ "./ml-dsa": {
61
+ "types": "./src/ml-dsa.d.ts",
62
+ "import": "./src/ml-dsa.js"
63
+ },
64
+ "./ml-dsa-87": {
65
+ "types": "./src/ml-dsa-87.d.ts",
66
+ "import": "./src/ml-dsa-87.js"
67
+ },
68
+ "./ml-kem": {
69
+ "types": "./src/ml-kem.d.ts",
70
+ "import": "./src/ml-kem.js"
71
+ },
72
+ "./ml-kem-1024": {
73
+ "types": "./src/ml-kem-1024.d.ts",
74
+ "import": "./src/ml-kem-1024.js"
75
+ },
76
+ "./slh-dsa": {
77
+ "types": "./src/slh-dsa.d.ts",
78
+ "import": "./src/slh-dsa.js"
79
+ },
80
+ "./derive": {
81
+ "types": "./src/derive.d.ts",
82
+ "import": "./src/derive.js"
83
+ },
84
+ "./webhook": {
85
+ "types": "./src/webhook.d.ts",
86
+ "import": "./src/webhook.js"
87
+ },
88
+ "./kid": {
89
+ "types": "./src/kid.d.ts",
90
+ "import": "./src/kid.js"
91
+ }
92
+ },
93
+ "files": [
94
+ "src",
95
+ "README.md",
96
+ "LICENSE",
97
+ "CONFORMANCE.md",
98
+ "THREAT-MODEL.md",
99
+ "MIGRATION.md",
100
+ "SECURITY.md",
101
+ "CHANGELOG.md"
102
+ ],
103
+ "engines": {
104
+ "node": ">=20.19"
105
+ },
106
+ "dependencies": {
107
+ "@noble/hashes": "2.3.0",
108
+ "@noble/post-quantum": "0.7.0"
109
+ },
110
+ "scripts": {
111
+ "test": "node --test test/basic.test.js && node --test test/context.test.js && node --test test/category5.test.js && node --test test/browser-smoke.test.js && node test/run-vectors.js",
112
+ "test:vectors": "node test/run-vectors.js",
113
+ "generate:vectors": "node test/generate-vectors.js > test/vectors.json",
114
+ "bench": "node bench/bench.js",
115
+ "conformance:fetch": "node conformance/fetch-vectors.mjs",
116
+ "conformance:acvp": "node conformance/run-acvp.mjs --json conformance/results/acvp.json",
117
+ "conformance:interop": "node conformance/interop/run-interop.mjs --json conformance/results/interop.json",
118
+ "sbom": "npm sbom --sbom-format cyclonedx --sbom-type library"
119
+ },
120
+ "publishConfig": {
121
+ "provenance": true,
122
+ "access": "public"
123
+ }
124
+ }
@@ -0,0 +1,69 @@
1
+ // Internal: FIPS 204 / FIPS 205 signature context strings.
2
+ //
3
+ // Not part of the public exports map. Shared by ml-dsa.js and slh-dsa.js
4
+ // because a validator for security-relevant input should exist once, even
5
+ // though the small byte/hex helpers in those modules are duplicated.
6
+ //
7
+ // FIPS 204 section 5.2 (and FIPS 205 equivalently) allow an optional context
8
+ // string of at most 255 bytes, mixed into the message representative. It gives
9
+ // domain separation: a signature made under one context does not verify under
10
+ // another, or under no context at all.
11
+ //
12
+ // KXCO derives keys per domain via deriveSeed(master, info), which separates at
13
+ // the KEY level. Context separates at the SIGNATURE level, so one key can sign
14
+ // for several domains without a signature being replayable across them. The two
15
+ // are complementary, not alternatives.
16
+
17
+ const enc = new TextEncoder()
18
+
19
+ /** FIPS 204 section 5.2: |ctx| <= 255. */
20
+ export const MAX_CONTEXT_BYTES = 255
21
+
22
+ /**
23
+ * Normalise the options bag into a context byte string, or undefined.
24
+ *
25
+ * Returns undefined for "no context", which makes the caller take the exact
26
+ * code path it took before this parameter existed. An empty context is
27
+ * cryptographically identical to no context, so it also returns undefined
28
+ * rather than passing a zero-length array down.
29
+ *
30
+ * THROWS on caller misuse (wrong type, over-length). This is deliberate and it
31
+ * differs from verify()'s usual fail-closed behaviour: a malformed context is a
32
+ * programming error, not a bad signature, and silently returning false would
33
+ * hide the bug behind an outcome that looks like a normal verification failure.
34
+ * No existing call site passes this argument, so nothing can regress.
35
+ *
36
+ * @param {{ context?: Uint8Array|Buffer|string }} [opts]
37
+ * @returns {Uint8Array|undefined}
38
+ */
39
+ export function normalizeContext(opts) {
40
+ if (opts === undefined || opts === null) return undefined
41
+
42
+ if (typeof opts !== 'object' || Array.isArray(opts) || opts instanceof Uint8Array) {
43
+ throw new TypeError(
44
+ 'expected an options object such as { context }, not a bare value',
45
+ )
46
+ }
47
+
48
+ const { context } = opts
49
+ if (context === undefined || context === null) return undefined
50
+
51
+ let bytes
52
+ if (context instanceof Uint8Array) {
53
+ bytes = context
54
+ } else if (typeof context === 'string') {
55
+ bytes = enc.encode(context)
56
+ } else {
57
+ throw new TypeError('context must be a Uint8Array or a string')
58
+ }
59
+
60
+ if (bytes.length > MAX_CONTEXT_BYTES) {
61
+ throw new RangeError(
62
+ `context must be at most ${MAX_CONTEXT_BYTES} bytes (FIPS 204 section 5.2), got ${bytes.length}`,
63
+ )
64
+ }
65
+
66
+ // Empty context is identical to no context under FIPS 204, so collapse it
67
+ // and keep the legacy call path byte-for-byte.
68
+ return bytes.length === 0 ? undefined : bytes
69
+ }
package/src/derive.d.ts CHANGED
@@ -1,20 +1,20 @@
1
- /// <reference types="node" />
2
-
3
- /**
4
- * Derive a deterministic seed from a master secret + an info string,
5
- * using HKDF-SHA-512 with an empty salt.
6
- *
7
- * Same `master + info + length` always produces the same seed.
8
- *
9
- * @param master — high-entropy input keying material (≥16 bytes)
10
- * @param info — domain separation tag (eg. 'kxco-platform-ml-dsa-65-v1')
11
- * @param length — output seed length in bytes (32 for ML-DSA, 64 for ML-KEM)
12
- *
13
- * @throws {Error} if `master` is shorter than 16 bytes
14
- * @throws {Error} if `info` is empty or not a string
15
- */
16
- export function deriveSeed(
17
- master: Buffer | Uint8Array,
18
- info: string,
19
- length: number,
20
- ): Buffer
1
+ /// <reference types="node" />
2
+
3
+ /**
4
+ * Derive a deterministic seed from a master secret + an info string,
5
+ * using HKDF-SHA-512 with an empty salt.
6
+ *
7
+ * Same `master + info + length` always produces the same seed.
8
+ *
9
+ * @param master — high-entropy input keying material (≥16 bytes)
10
+ * @param info — domain separation tag (eg. 'kxco-platform-ml-dsa-65-v1')
11
+ * @param length — output seed length in bytes (32 for ML-DSA, 64 for ML-KEM)
12
+ *
13
+ * @throws {Error} if `master` is shorter than 16 bytes
14
+ * @throws {Error} if `info` is empty or not a string
15
+ */
16
+ export function deriveSeed(
17
+ master: Buffer | Uint8Array,
18
+ info: string,
19
+ length: number,
20
+ ): Buffer
package/src/derive.js CHANGED
@@ -1,47 +1,47 @@
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
- // Isomorphic: uses @noble/hashes/hkdf which runs identically in Node 18+ and
13
- // modern browsers. Returns Buffer when running on Node (for backwards
14
- // compatibility with existing callers), Uint8Array in browsers.
15
-
16
- import { hkdf } from '@noble/hashes/hkdf.js'
17
- import { sha512 } from '@noble/hashes/sha2.js'
18
-
19
- const HAS_BUFFER = typeof Buffer !== 'undefined'
20
- const enc = new TextEncoder()
21
-
22
- function toBytes(input) {
23
- if (input instanceof Uint8Array) return input
24
- if (typeof input === 'string') return enc.encode(input)
25
- throw new Error('expected Uint8Array or string')
26
- }
27
-
28
- /**
29
- * Derive a deterministic seed from a master secret + an info string.
30
- *
31
- * @param {Buffer|Uint8Array|string} master — high-entropy keying material (>= 16 bytes)
32
- * @param {string} info — domain separation tag
33
- * @param {number} length — output seed length in bytes
34
- * @returns {Buffer|Uint8Array}
35
- */
36
- export function deriveSeed(master, info, length) {
37
- const ikm = toBytes(master)
38
- if (!ikm || ikm.length < 16) {
39
- throw new Error('deriveSeed: master keying material must be at least 16 bytes')
40
- }
41
- if (!info || typeof info !== 'string') {
42
- throw new Error('deriveSeed: info string is required for domain separation')
43
- }
44
- const salt = new Uint8Array(32) // 32 zero bytes — fine with high-entropy IKM
45
- const out = hkdf(sha512, ikm, salt, enc.encode(info), length)
46
- return HAS_BUFFER ? Buffer.from(out) : out
47
- }
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
+ // Isomorphic: uses @noble/hashes/hkdf which runs identically in Node 18+ and
13
+ // modern browsers. Returns Buffer when running on Node (for backwards
14
+ // compatibility with existing callers), Uint8Array in browsers.
15
+
16
+ import { hkdf } from '@noble/hashes/hkdf.js'
17
+ import { sha512 } from '@noble/hashes/sha2.js'
18
+
19
+ const HAS_BUFFER = typeof Buffer !== 'undefined'
20
+ const enc = new TextEncoder()
21
+
22
+ function toBytes(input) {
23
+ if (input instanceof Uint8Array) return input
24
+ if (typeof input === 'string') return enc.encode(input)
25
+ throw new Error('expected Uint8Array or string')
26
+ }
27
+
28
+ /**
29
+ * Derive a deterministic seed from a master secret + an info string.
30
+ *
31
+ * @param {Buffer|Uint8Array|string} master — high-entropy keying material (>= 16 bytes)
32
+ * @param {string} info — domain separation tag
33
+ * @param {number} length — output seed length in bytes
34
+ * @returns {Buffer|Uint8Array}
35
+ */
36
+ export function deriveSeed(master, info, length) {
37
+ const ikm = toBytes(master)
38
+ if (!ikm || ikm.length < 16) {
39
+ throw new Error('deriveSeed: master keying material must be at least 16 bytes')
40
+ }
41
+ if (!info || typeof info !== 'string') {
42
+ throw new Error('deriveSeed: info string is required for domain separation')
43
+ }
44
+ const salt = new Uint8Array(32) // 32 zero bytes — fine with high-entropy IKM
45
+ const out = hkdf(sha512, ikm, salt, enc.encode(info), length)
46
+ return HAS_BUFFER ? Buffer.from(out) : out
47
+ }
package/src/index.d.ts CHANGED
@@ -1,8 +1,13 @@
1
- /// <reference types="node" />
2
-
3
- export * as mlDsa from './ml-dsa.js'
4
- export * as mlKem from './ml-kem.js'
5
- export * as slhDsa from './slh-dsa.js'
6
- export * from './derive.js'
7
- export * from './kid.js'
8
- export * as webhook from './webhook.js'
1
+ /// <reference types="node" />
2
+
3
+ export * as mlDsa from './ml-dsa.js'
4
+ export * as mlKem from './ml-kem.js'
5
+ export * as slhDsa from './slh-dsa.js'
6
+
7
+ /** ML-DSA-87 (Category 5). Not a CNSA 2.0 compliance claim; see CONFORMANCE.md. */
8
+ export * as mlDsa87 from './ml-dsa-87.js'
9
+ /** ML-KEM-1024 (Category 5). Not a CNSA 2.0 compliance claim; see CONFORMANCE.md. */
10
+ export * as mlKem1024 from './ml-kem-1024.js'
11
+ export * from './derive.js'
12
+ export * from './kid.js'
13
+ export * as webhook from './webhook.js'