kxco-post-quantum 1.5.2 → 1.6.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kxco-post-quantum",
3
- "version": "1.5.2",
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.",
3
+ "version": "1.6.0",
4
+ "description": "ML-DSA-65, ML-KEM-768 and SLH-DSA-SHA2-192s with key fingerprinting. Runs in OpenSSL 3.5 on Node 24+, JavaScript elsewhere. 2,103 NIST ACVP vectors and 225 cross-implementation interop checks, 0 failed. Reproducible builds, SLSA provenance, published SBOM. The base layer for all kxco-pq-* packages.",
5
5
  "keywords": [
6
6
  "post-quantum",
7
7
  "pqc",
@@ -94,37 +94,55 @@
94
94
  "./kid": {
95
95
  "types": "./src/kid.d.ts",
96
96
  "import": "./src/kid.js"
97
+ },
98
+ "./seed": {
99
+ "types": "./src/seed.d.ts",
100
+ "import": "./src/seed.js"
101
+ },
102
+ "./jws": {
103
+ "types": "./src/jws.d.ts",
104
+ "import": "./src/jws.js"
105
+ },
106
+ "./backend": {
107
+ "types": "./src/backend.d.ts",
108
+ "import": "./src/backend.js"
97
109
  }
98
110
  },
99
111
  "files": [
100
- "src",
101
- "README.md",
102
- "LICENSE",
103
- "CONFORMANCE.md",
104
112
  "BENCHMARKS.md",
105
- "THREAT-MODEL.md",
113
+ "CHANGELOG.md",
114
+ "CONFORMANCE.md",
115
+ "DEPENDENCIES.md",
116
+ "LICENCE-PRODUCT.md",
117
+ "LICENSE",
106
118
  "MIGRATION.md",
119
+ "README.md",
107
120
  "SECURITY.md",
108
- "CHANGELOG.md"
121
+ "THREAT-MODEL.md",
122
+ "src"
109
123
  ],
110
124
  "engines": {
111
125
  "node": ">=20.19"
112
126
  },
113
127
  "dependencies": {
114
- "@noble/hashes": "2.3.0",
128
+ "@noble/hashes": "2.4.0",
115
129
  "@noble/post-quantum": "0.7.0"
116
130
  },
117
131
  "scripts": {
118
- "test": "node --test test/basic.test.js && node --test test/context.test.js && node --test test/category5.test.js && node --test test/edge-cases.test.js && node --test test/browser-smoke.test.js && node test/run-vectors.js",
132
+ "test": "node --test test/basic.test.js && node --test test/context.test.js && node --test test/category5.test.js && node --test test/edge-cases.test.js && node --test test/seed.test.js && node --test test/browser-smoke.test.js && node test/run-vectors.js",
119
133
  "test:vectors": "node test/run-vectors.js",
120
134
  "generate:vectors": "node test/generate-vectors.js > test/vectors.json",
121
135
  "bench": "node bench/bench.js",
122
136
  "conformance:fetch": "node conformance/fetch-vectors.mjs",
123
137
  "conformance:acvp": "node conformance/run-acvp.mjs --json conformance/results/acvp.json",
124
138
  "conformance:interop": "node conformance/interop/run-interop.mjs --json conformance/results/interop.json",
139
+ "conformance:protocol": "node conformance/protocol/run-protocol.mjs --json conformance/results/protocol.json",
140
+ "audit:deps": "node audit/run-audit.mjs --json audit/results/dependencies.json",
125
141
  "sbom": "npm sbom --sbom-format cyclonedx --sbom-type library",
126
142
  "bench:timing": "node --expose-gc bench/timing.mjs --iterations 20000 --json bench/results/timing.json",
127
- "bench:primitives": "node --expose-gc bench/primitives.mjs --iterations 100 --json bench/results/primitives.json"
143
+ "bench:primitives": "node --expose-gc bench/primitives.mjs --iterations 100 --json bench/results/primitives.json",
144
+ "evidence": "node scripts/build-evidence.mjs",
145
+ "evidence:full": "node scripts/build-evidence.mjs --full"
128
146
  },
129
147
  "publishConfig": {
130
148
  "provenance": true,
@@ -0,0 +1,16 @@
1
+ export interface BackendReport {
2
+ kind: 'openssl' | 'javascript'
3
+ library: string
4
+ /** OpenSSL version, on the native backend only. */
5
+ openssl?: string
6
+ /** Parameter sets the native backend can express. */
7
+ parameterSets?: string[]
8
+ /** Why the native backend is unavailable, on the JavaScript backend only. */
9
+ reason?: string
10
+ }
11
+
12
+ /** Describe the backend doing the maths in this process. Reports; never switches. */
13
+ export function backend(): BackendReport
14
+
15
+ /** Whether a parameter set runs natively here, e.g. isNative('ML-DSA-65'). */
16
+ export function isNative(alg: string): boolean
package/src/backend.js ADDED
@@ -0,0 +1,48 @@
1
+ // Which implementation is actually doing the maths, right now, on this box.
2
+ //
3
+ // The package picks its backend at import time by probing the runtime, not by
4
+ // reading a version number, so the only honest way to state which one a given
5
+ // deployment used is to ask it. This exists so that an evidence bundle, a
6
+ // support ticket or a customer's own conformance run can record the answer
7
+ // instead of inferring it from `process.version`.
8
+ //
9
+ // It reports; it does not switch. There is deliberately no way to force a
10
+ // backend from here: the two produce identical wire bytes, and a runtime flag
11
+ // that changed which one signed would be a flag that changes what a customer's
12
+ // evidence means.
13
+
14
+ import { native } from '#native'
15
+
16
+ /**
17
+ * Describe the active backend.
18
+ *
19
+ * @returns {{ kind: 'openssl'|'javascript', library: string, openssl?: string,
20
+ * parameterSets?: string[], reason?: string }}
21
+ */
22
+ export function backend() {
23
+ if (native === null) {
24
+ return {
25
+ kind: 'javascript',
26
+ library: '@noble/post-quantum',
27
+ reason: 'the runtime does not provide the FIPS 203/204/205 primitives',
28
+ }
29
+ }
30
+ return {
31
+ kind: 'openssl',
32
+ library: 'node:crypto',
33
+ openssl: native.openssl,
34
+ // Only the sets OpenSSL can express. Anything absent here still works, on
35
+ // the JavaScript backend, which is why this is a list rather than a flag.
36
+ parameterSets: native.algorithms(),
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Whether a given parameter set runs on the native backend in this process.
42
+ *
43
+ * @param {string} alg — e.g. 'ML-DSA-65'
44
+ * @returns {boolean}
45
+ */
46
+ export function isNative(alg) {
47
+ return native !== null && native.supports(alg)
48
+ }
package/src/index.d.ts CHANGED
@@ -11,3 +11,9 @@ export * as mlKem1024 from './ml-kem-1024.js'
11
11
  export * from './derive.js'
12
12
  export * from './kid.js'
13
13
  export * as webhook from './webhook.js'
14
+
15
+ /** Seed-form keys: RFC 9964 AKP JWKs and LAMPS seed-form PKCS#8. */
16
+ export * as seed from './seed.js'
17
+ /** Compact JWS using the RFC 9964 algorithm names. Format only, no network. */
18
+ export * as jws from './jws.js'
19
+ export { backend, isNative } from './backend.js'
package/src/index.js CHANGED
@@ -6,8 +6,13 @@
6
6
  // KnightsVault, KXCO Bank, KnightsBot, The Exchequer, and Armature L1.
7
7
  //
8
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.
9
+ // @noble/post-quantum reference implementation with the integration patterns we
10
+ // have proven in production, and on Node 24+ it prefers the OpenSSL 3.5 backend.
11
+ //
12
+ // The primitives are evidenced here rather than taken on trust: every parameter
13
+ // set is checked against NIST's own ACVP vectors and cross-checked against
14
+ // liboqs, Bouncy Castle and dilithium-py/kyber-py in both directions. See
15
+ // CONFORMANCE.md, and audit/ for the dependency review.
11
16
 
12
17
  export * as mlDsa from './ml-dsa.js'
13
18
  export * as mlKem from './ml-kem.js'
@@ -22,3 +27,14 @@ export * as mlKem1024 from './ml-kem-1024.js'
22
27
  export * from './derive.js'
23
28
  export * from './kid.js'
24
29
  export * as webhook from './webhook.js'
30
+
31
+ // Seed-form keys (RFC 9964 AKP JWKs, LAMPS seed-form PKCS#8) and compact JWS
32
+ // with the RFC 9964 algorithm names. Both are format and derivation only: they
33
+ // contact nothing, need no licence, and a token or key they produce stays
34
+ // verifiable offline for as long as the holder keeps the public key.
35
+ export * as seed from './seed.js'
36
+ export * as jws from './jws.js'
37
+
38
+ // Reports which backend is doing the maths in this process, for evidence
39
+ // bundles and support. It reports; it never switches.
40
+ export { backend, isNative } from './backend.js'
package/src/jws.d.ts ADDED
@@ -0,0 +1,74 @@
1
+ /// <reference types="node" />
2
+
3
+ /** JWS `alg` values this module signs and verifies (RFC 9964 names). */
4
+ export type JwsAlgorithm = 'ML-DSA-65' | 'ML-DSA-87'
5
+
6
+ export const JWS_ALGORITHMS: JwsAlgorithm[]
7
+
8
+ export interface JwsHeader {
9
+ alg: JwsAlgorithm
10
+ typ?: string
11
+ kid?: string
12
+ [key: string]: unknown
13
+ }
14
+
15
+ export interface SignJwsOptions {
16
+ /** Defaults to 'ML-DSA-65'. */
17
+ alg?: JwsAlgorithm
18
+ /** Key identifier, e.g. the 16-hex `fingerprint()` of the public key. */
19
+ kid?: string
20
+ typ?: string
21
+ /** Extra protected header members. May not restate alg, kid or typ. */
22
+ header?: Record<string, unknown>
23
+ }
24
+
25
+ export interface VerifyJwsSuccess {
26
+ valid: true
27
+ header: JwsHeader
28
+ /** Raw payload bytes. */
29
+ payload: Uint8Array
30
+ /** The payload decoded as UTF-8. JSON payloads are not parsed for you. */
31
+ text: string
32
+ }
33
+
34
+ export interface VerifyJwsFailure {
35
+ valid: false
36
+ error: string
37
+ }
38
+
39
+ export type VerifyJwsResult = VerifyJwsSuccess | VerifyJwsFailure
40
+
41
+ /**
42
+ * Sign a payload into a compact JWS. Objects are JSON-serialised.
43
+ *
44
+ * @throws {TypeError} on a bad options object or payload type
45
+ * @throws {Error} on an unsupported alg, or a header member that restates
46
+ * alg, kid or typ
47
+ */
48
+ export function signJws(
49
+ payload: object | string | Uint8Array,
50
+ secretKey: Buffer | Uint8Array,
51
+ opts?: SignJwsOptions,
52
+ ): string
53
+
54
+ /**
55
+ * Verify a compact JWS.
56
+ *
57
+ * The algorithm is resolved from this module's allowlist using the header's
58
+ * `alg`; a token cannot name its own verification routine. Pass `{ alg }` to
59
+ * require a specific algorithm and `{ kid }` to require a specific key.
60
+ *
61
+ * Returns `{ valid: false, error }` for every failure. Throws only on caller
62
+ * misuse of `opts`.
63
+ */
64
+ export function verifyJws(
65
+ token: string,
66
+ publicKey: Buffer | Uint8Array,
67
+ opts?: { alg?: JwsAlgorithm; kid?: string },
68
+ ): VerifyJwsResult
69
+
70
+ /**
71
+ * Read a token's header without verifying it — for choosing which key to fetch
72
+ * from a `kid`. The header is unauthenticated until `verifyJws` returns valid.
73
+ */
74
+ export function decodeJwsHeader(token: string): JwsHeader | null
package/src/jws.js ADDED
@@ -0,0 +1,249 @@
1
+ // Compact JWS with ML-DSA, using the RFC 9964 algorithm names.
2
+ //
3
+ // Format only. Nothing here contacts a network, reads a chain, or needs a
4
+ // licence: a token signed by this module verifies in any process that holds
5
+ // the public key, forever, offline. The paid behaviour that decides whether a
6
+ // key is still ALLOWED to sign lives in kxco-pq-network and above, never here.
7
+ //
8
+ // Why a JWS at all when this package already has its own envelopes. Because an
9
+ // institution's existing stack already parses JWS. Their gateway, their IdP,
10
+ // their audit tooling and their partner's verifier all speak it, and RFC 9964
11
+ // registered "ML-DSA-44", "ML-DSA-65" and "ML-DSA-87" as JWS algorithms
12
+ // precisely so a post-quantum signature can travel that path unchanged. Giving
13
+ // them one is the difference between a migration and a rewrite.
14
+ //
15
+ // The header is the protected header and the whole of it is signed. There is
16
+ // no unprotected header, no JSON serialisation and no detached payload in this
17
+ // release: each is a place where a verifier can be talked into checking
18
+ // something other than what it thinks it is checking, and none of them is
19
+ // needed to carry a signature between two services.
20
+ //
21
+ // SLH-DSA is not offered here. FIPS 205 signing takes on the order of a second
22
+ // and a half per signature, which is not something to put on a request path.
23
+
24
+ import * as mlDsa from './ml-dsa.js'
25
+ import * as mlDsa87 from './ml-dsa-87.js'
26
+
27
+ const HAS_BUFFER = typeof Buffer !== 'undefined'
28
+ const enc = new TextEncoder()
29
+ const dec = new TextDecoder()
30
+
31
+ // The `alg` allowlist. A verifier resolves the implementation from THIS table
32
+ // and nowhere else, so a token cannot name its own verification routine — the
33
+ // alg-confusion failure that has broken JWT libraries repeatedly.
34
+ const ALGORITHMS = {
35
+ 'ML-DSA-65': { mod: mlDsa, publicKeyBytes: 1952, signatureBytes: 3309 },
36
+ 'ML-DSA-87': { mod: mlDsa87, publicKeyBytes: 2592, signatureBytes: 4627 },
37
+ }
38
+
39
+ /** JWS `alg` values this module will sign or verify. */
40
+ export const JWS_ALGORITHMS = Object.keys(ALGORITHMS)
41
+
42
+ const DEFAULT_ALG = 'ML-DSA-65'
43
+
44
+ function b64url(bytes) {
45
+ if (HAS_BUFFER) return Buffer.from(bytes).toString('base64url')
46
+ let binary = ''
47
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
48
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
49
+ }
50
+
51
+ function fromB64url(str) {
52
+ if (HAS_BUFFER) return new Uint8Array(Buffer.from(str, 'base64url'))
53
+ const padded = str.replace(/-/g, '+').replace(/_/g, '/')
54
+ const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))
55
+ const out = new Uint8Array(binary.length)
56
+ for (let i = 0; i < out.length; i++) out[i] = binary.charCodeAt(i)
57
+ return out
58
+ }
59
+
60
+ // base64url is not a canonical encoding of arbitrary text: two different
61
+ // strings can decode to the same bytes if one carries padding or non-alphabet
62
+ // characters. A token whose segments re-encode differently from how they
63
+ // arrived is rejected, so a verifier and a downstream parser cannot be shown
64
+ // two different payloads for one signature.
65
+ function strictB64url(str, what) {
66
+ if (typeof str !== 'string' || str.length === 0 || !/^[A-Za-z0-9_-]+$/.test(str)) {
67
+ throw new Error(`malformed JWS: ${what} is not base64url`)
68
+ }
69
+ const bytes = fromB64url(str)
70
+ if (b64url(bytes) !== str) throw new Error(`malformed JWS: ${what} is not canonically encoded`)
71
+ return bytes
72
+ }
73
+
74
+ function bytesToHex(bytes) {
75
+ let s = ''
76
+ for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0')
77
+ return s
78
+ }
79
+
80
+ function hexToBytes(hex) {
81
+ const b = new Uint8Array(hex.length / 2)
82
+ for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
83
+ return b
84
+ }
85
+
86
+ function payloadBytes(payload) {
87
+ if (payload instanceof Uint8Array) return payload
88
+ if (typeof payload === 'string') return enc.encode(payload)
89
+ if (payload === null || payload === undefined) {
90
+ throw new TypeError('payload is required')
91
+ }
92
+ if (typeof payload === 'object') return enc.encode(JSON.stringify(payload))
93
+ throw new TypeError('payload must be an object, a string or a Uint8Array')
94
+ }
95
+
96
+ /**
97
+ * Sign a payload into a compact JWS.
98
+ *
99
+ * @param {object|string|Uint8Array} payload — objects are JSON-serialised
100
+ * @param {Buffer|Uint8Array} secretKey
101
+ * @param {{ alg?: string, kid?: string, typ?: string, header?: object }} [opts]
102
+ * @returns {string} compact JWS: header.payload.signature
103
+ */
104
+ export function signJws(payload, secretKey, opts = {}) {
105
+ if (opts === null || typeof opts !== 'object') {
106
+ throw new TypeError('expected an options object such as { kid, alg }')
107
+ }
108
+ const alg = opts.alg ?? DEFAULT_ALG
109
+ const spec = ALGORITHMS[alg]
110
+ if (!spec) {
111
+ throw new Error(`unsupported JWS alg '${alg}' — this module signs ${JWS_ALGORITHMS.join(' and ')}`)
112
+ }
113
+
114
+ // Extra header members are allowed but may not restate or override the ones
115
+ // this module is responsible for, which would let a caller sign under one
116
+ // alg while the header advertises another.
117
+ const extra = opts.header ?? {}
118
+ for (const reserved of ['alg', 'kid', 'typ']) {
119
+ if (Object.hasOwn(extra, reserved)) {
120
+ throw new Error(`header.${reserved} is set through opts.${reserved}, not opts.header`)
121
+ }
122
+ }
123
+
124
+ const header = {
125
+ alg,
126
+ ...(opts.typ !== undefined ? { typ: opts.typ } : {}),
127
+ ...(opts.kid !== undefined ? { kid: opts.kid } : {}),
128
+ ...extra,
129
+ }
130
+
131
+ const protectedB64 = b64url(enc.encode(JSON.stringify(header)))
132
+ const payloadB64 = b64url(payloadBytes(payload))
133
+ const signingInput = enc.encode(`${protectedB64}.${payloadB64}`)
134
+
135
+ // Goes through the module's own sign(), so the OpenSSL backend is used
136
+ // wherever the runtime has it and the JavaScript one everywhere else. The
137
+ // wire bytes are identical either way.
138
+ const sigHex = spec.mod.sign(secretKey, signingInput)
139
+ return `${protectedB64}.${payloadB64}.${b64url(hexToBytes(sigHex))}`
140
+ }
141
+
142
+ /**
143
+ * Verify a compact JWS.
144
+ *
145
+ * Returns `{ valid: false, error }` for anything that fails, and throws only on
146
+ * caller misuse, matching how `mlDsa.verify` already behaves.
147
+ *
148
+ * The algorithm is resolved from this module's allowlist using the header's
149
+ * `alg`, and the public key's length must match what that algorithm expects.
150
+ * Pass `{ alg }` to require a specific one, and `{ kid }` to require the header
151
+ * to name a specific key.
152
+ *
153
+ * @param {string} token
154
+ * @param {Buffer|Uint8Array} publicKey
155
+ * @param {{ alg?: string, kid?: string }} [opts]
156
+ * @returns {{ valid: true, header: object, payload: Uint8Array, text: string }
157
+ * | { valid: false, error: string }}
158
+ */
159
+ export function verifyJws(token, publicKey, opts = {}) {
160
+ if (opts === null || typeof opts !== 'object') {
161
+ throw new TypeError('expected an options object such as { alg, kid }')
162
+ }
163
+ if (typeof token !== 'string') return { valid: false, error: 'token must be a string' }
164
+
165
+ const parts = token.split('.')
166
+ if (parts.length !== 3) {
167
+ return { valid: false, error: 'malformed JWS: expected three dot-separated segments' }
168
+ }
169
+ const [protectedB64, payloadB64, sigB64] = parts
170
+
171
+ let header, payload, signature
172
+ try {
173
+ header = JSON.parse(dec.decode(strictB64url(protectedB64, 'header')))
174
+ payload = strictB64url(payloadB64, 'payload')
175
+ signature = strictB64url(sigB64, 'signature')
176
+ } catch (err) {
177
+ return { valid: false, error: err.message }
178
+ }
179
+
180
+ if (!header || typeof header !== 'object' || Array.isArray(header)) {
181
+ return { valid: false, error: 'malformed JWS: header is not an object' }
182
+ }
183
+
184
+ const spec = ALGORITHMS[header.alg]
185
+ if (!spec) {
186
+ return { valid: false, error: `unsupported JWS alg '${header.alg}'` }
187
+ }
188
+ if (opts.alg !== undefined && header.alg !== opts.alg) {
189
+ return { valid: false, error: `alg mismatch: expected '${opts.alg}', token declares '${header.alg}'` }
190
+ }
191
+ if (opts.kid !== undefined && header.kid !== opts.kid) {
192
+ return { valid: false, error: `kid mismatch: expected '${opts.kid}', token declares '${header.kid ?? '(none)'}'` }
193
+ }
194
+
195
+ // RFC 7515 section 4.1.11: a verifier that does not understand every member
196
+ // named in `crit` must reject the token. This module understands none of the
197
+ // extensions that would be named there, so any `crit` at all is a rejection.
198
+ if (header.crit !== undefined) {
199
+ return { valid: false, error: 'JWS declares crit header parameters this verifier does not implement' }
200
+ }
201
+ // RFC 7797 unencoded payloads change what the signature covers. Not accepted.
202
+ if (header.b64 !== undefined) {
203
+ return { valid: false, error: 'JWS declares b64, which this verifier does not implement' }
204
+ }
205
+
206
+ // The key must be the size the declared algorithm uses. Without this a token
207
+ // could name ML-DSA-87 while being checked against an ML-DSA-65 key, and the
208
+ // failure would look like a bad signature rather than a mixed-up key.
209
+ const keyBytes = publicKey instanceof Uint8Array ? publicKey : new Uint8Array(publicKey)
210
+ if (keyBytes.length !== spec.publicKeyBytes) {
211
+ return {
212
+ valid: false,
213
+ error: `key is ${keyBytes.length} bytes, but ${header.alg} public keys are ${spec.publicKeyBytes}`,
214
+ }
215
+ }
216
+ if (signature.length !== spec.signatureBytes) {
217
+ return {
218
+ valid: false,
219
+ error: `signature is ${signature.length} bytes, but ${header.alg} signatures are ${spec.signatureBytes}`,
220
+ }
221
+ }
222
+
223
+ const signingInput = enc.encode(`${protectedB64}.${payloadB64}`)
224
+ const ok = spec.mod.verify(keyBytes, signingInput, bytesToHex(signature))
225
+ if (!ok) return { valid: false, error: 'signature invalid' }
226
+
227
+ return { valid: true, header, payload, text: dec.decode(payload) }
228
+ }
229
+
230
+ /**
231
+ * Read a token's header without verifying anything.
232
+ *
233
+ * For dispatch only — picking which public key to fetch from a `kid`. The
234
+ * header is unauthenticated until `verifyJws` returns valid, and nothing in it
235
+ * should be acted on before that.
236
+ *
237
+ * @param {string} token
238
+ * @returns {object|null} null if the token is not parseable
239
+ */
240
+ export function decodeJwsHeader(token) {
241
+ if (typeof token !== 'string') return null
242
+ const first = token.split('.')[0]
243
+ try {
244
+ const header = JSON.parse(dec.decode(strictB64url(first, 'header')))
245
+ return header && typeof header === 'object' && !Array.isArray(header) ? header : null
246
+ } catch {
247
+ return null
248
+ }
249
+ }
@@ -5,6 +5,14 @@ export interface MlDsa87Keypair {
5
5
  publicKey: Buffer
6
6
  /** 4896-byte secret key */
7
7
  secretKey: Buffer
8
+ /**
9
+ * The 32-byte seed this keypair was expanded from.
10
+ *
11
+ * An expanded secret key does not contain its seed, so this is the only
12
+ * point at which it can be captured. Pass it to `exportJwk` or
13
+ * `exportSeedPkcs8` for RFC 9964 / LAMPS seed-form storage.
14
+ */
15
+ seed: Buffer
8
16
  }
9
17
 
10
18
  /**
package/src/ml-dsa-87.js CHANGED
@@ -82,6 +82,11 @@ export function keypairFromMaster(master, info = 'ml-dsa-87-v1') {
82
82
  return {
83
83
  publicKey: wrap(k.publicKey),
84
84
  secretKey: wrap(k.secretKey),
85
+ // The seed this key was expanded from. Additive: callers destructuring
86
+ // { publicKey, secretKey } are unaffected. It is here because an expanded
87
+ // key does not contain its seed, so this is the only moment it can be
88
+ // captured, and seed form is what RFC 9964 JWKs and KMS custody take.
89
+ seed: wrap(seedU8),
85
90
  }
86
91
  }
87
92
 
package/src/ml-dsa.d.ts CHANGED
@@ -5,6 +5,14 @@ export interface MlDsaKeypair {
5
5
  publicKey: Buffer
6
6
  /** 4032-byte secret key */
7
7
  secretKey: Buffer
8
+ /**
9
+ * The 32-byte seed this keypair was expanded from.
10
+ *
11
+ * An expanded secret key does not contain its seed, so this is the only
12
+ * point at which it can be captured. Pass it to `exportJwk` or
13
+ * `exportSeedPkcs8` for RFC 9964 / LAMPS seed-form storage.
14
+ */
15
+ seed: Buffer
8
16
  }
9
17
 
10
18
  /**
package/src/ml-dsa.js CHANGED
@@ -62,6 +62,11 @@ export function keypairFromMaster(master, info = 'ml-dsa-65-v1') {
62
62
  return {
63
63
  publicKey: wrap(k.publicKey),
64
64
  secretKey: wrap(k.secretKey),
65
+ // The seed this key was expanded from. Additive: callers destructuring
66
+ // { publicKey, secretKey } are unaffected. It is here because an expanded
67
+ // key does not contain its seed, so this is the only moment it can be
68
+ // captured, and seed form is what RFC 9964 JWKs and KMS custody take.
69
+ seed: wrap(seedU8),
65
70
  }
66
71
  }
67
72
 
@@ -5,6 +5,14 @@ export interface MlKem1024Keypair {
5
5
  publicKey: Buffer
6
6
  /** 3168-byte secret key */
7
7
  secretKey: Buffer
8
+ /**
9
+ * The 64-byte seed this keypair was expanded from.
10
+ *
11
+ * An expanded secret key does not contain its seed, so this is the only
12
+ * point at which it can be captured. Pass it to `exportJwk` or
13
+ * `exportSeedPkcs8` for RFC 9964 / LAMPS seed-form storage.
14
+ */
15
+ seed: Buffer
8
16
  }
9
17
 
10
18
  export interface MlKem1024Encapsulation {
@@ -63,6 +63,11 @@ export function keypairFromMaster(master, info = 'ml-kem-1024-v1') {
63
63
  return {
64
64
  publicKey: wrap(k.publicKey),
65
65
  secretKey: wrap(k.secretKey),
66
+ // The seed this key was expanded from. Additive: callers destructuring
67
+ // { publicKey, secretKey } are unaffected. It is here because an expanded
68
+ // key does not contain its seed, so this is the only moment it can be
69
+ // captured, and seed form is what RFC 9964 JWKs and KMS custody take.
70
+ seed: wrap(seedU8),
66
71
  }
67
72
  }
68
73
 
package/src/ml-kem.d.ts CHANGED
@@ -5,6 +5,14 @@ export interface MlKemKeypair {
5
5
  publicKey: Buffer
6
6
  /** 2400-byte secret key */
7
7
  secretKey: Buffer
8
+ /**
9
+ * The 64-byte seed this keypair was expanded from.
10
+ *
11
+ * An expanded secret key does not contain its seed, so this is the only
12
+ * point at which it can be captured. Pass it to `exportJwk` or
13
+ * `exportSeedPkcs8` for RFC 9964 / LAMPS seed-form storage.
14
+ */
15
+ seed: Buffer
8
16
  }
9
17
 
10
18
  export interface MlKemEncapsulation {
package/src/ml-kem.js CHANGED
@@ -38,6 +38,11 @@ export function keypairFromMaster(master, info = 'ml-kem-768-v1') {
38
38
  return {
39
39
  publicKey: wrap(k.publicKey),
40
40
  secretKey: wrap(k.secretKey),
41
+ // The seed this key was expanded from. Additive: callers destructuring
42
+ // { publicKey, secretKey } are unaffected. It is here because an expanded
43
+ // key does not contain its seed, so this is the only moment it can be
44
+ // captured, and seed form is what RFC 9964 JWKs and KMS custody take.
45
+ seed: wrap(seedU8),
41
46
  }
42
47
  }
43
48