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/src/seed.d.ts ADDED
@@ -0,0 +1,91 @@
1
+ /// <reference types="node" />
2
+
3
+ /** Parameter sets that have a seed form. SLH-DSA does not and is absent. */
4
+ export type SeedAlgorithm = 'ML-DSA-65' | 'ML-DSA-87' | 'ML-KEM-768' | 'ML-KEM-1024'
5
+
6
+ export const SEED_ALGORITHMS: SeedAlgorithm[]
7
+
8
+ export interface SeededKeypair {
9
+ publicKey: Buffer | Uint8Array
10
+ secretKey: Buffer | Uint8Array
11
+ /** 32 bytes for ML-DSA, 64 for ML-KEM. */
12
+ seed: Buffer | Uint8Array
13
+ }
14
+
15
+ /** RFC 9964 algorithm-key-pair JWK. `priv` carries the SEED, not the expanded key. */
16
+ export interface AkpJwk {
17
+ kty: 'AKP'
18
+ alg: SeedAlgorithm
19
+ /** base64url public key */
20
+ pub: string
21
+ /** base64url seed, present only on private JWKs */
22
+ priv?: string
23
+ kid?: string
24
+ use?: string
25
+ }
26
+
27
+ /**
28
+ * Expand a seed into a keypair. Deterministic, and byte-identical to what
29
+ * OpenSSL 3.5 derives from the same seed.
30
+ *
31
+ * @throws {RangeError} if the seed is not the parameter set's seed length
32
+ */
33
+ export function keypairFromSeed(
34
+ alg: SeedAlgorithm,
35
+ seed: Buffer | Uint8Array,
36
+ ): SeededKeypair
37
+
38
+ /**
39
+ * Derive a parameter set's seed from a master secret, using the same
40
+ * HKDF-SHA-512 derivation `keypairFromMaster` uses. With the default `info`
41
+ * this reproduces exactly the key `keypairFromMaster` produces.
42
+ */
43
+ export function seedFromMaster(
44
+ alg: SeedAlgorithm,
45
+ master: Buffer | Uint8Array | string,
46
+ info?: string,
47
+ ): Buffer | Uint8Array
48
+
49
+ /**
50
+ * Export an RFC 9964 AKP JWK. Supply `seed` for a private JWK.
51
+ *
52
+ * An expanded secret key is rejected: RFC 9964 has no encoding for one.
53
+ *
54
+ * @throws {RangeError} on a wrong-length public key or seed
55
+ */
56
+ export function exportJwk(
57
+ alg: SeedAlgorithm,
58
+ key: { publicKey: Buffer | Uint8Array; seed?: Buffer | Uint8Array },
59
+ opts?: { kid?: string; use?: string },
60
+ ): AkpJwk
61
+
62
+ /**
63
+ * Import an RFC 9964 AKP JWK, expanding a private one back to a full keypair.
64
+ *
65
+ * @throws {Error} if the key derived from `priv` disagrees with `pub`
66
+ */
67
+ export function importJwk(jwk: AkpJwk | object): {
68
+ alg: SeedAlgorithm
69
+ publicKey: Buffer | Uint8Array
70
+ secretKey?: Buffer | Uint8Array
71
+ seed?: Buffer | Uint8Array
72
+ }
73
+
74
+ /**
75
+ * Export a seed as PKCS#8 in LAMPS seed form — the `[0] IMPLICIT OCTET STRING`
76
+ * CHOICE. Loads directly into OpenSSL 3.5 and Node 24+.
77
+ */
78
+ export function exportSeedPkcs8(
79
+ alg: SeedAlgorithm,
80
+ seed: Buffer | Uint8Array,
81
+ ): Buffer | Uint8Array
82
+
83
+ /**
84
+ * Read a seed-form PKCS#8 key back.
85
+ *
86
+ * @throws {Error} on an expanded-form key, which contains no seed to return
87
+ */
88
+ export function importSeedPkcs8(der: Buffer | Uint8Array): {
89
+ alg: SeedAlgorithm
90
+ seed: Buffer | Uint8Array
91
+ }
package/src/seed.js ADDED
@@ -0,0 +1,394 @@
1
+ // Seed-form keys: the 32 bytes an ML-DSA key really is, and the 64 an ML-KEM
2
+ // key really is.
3
+ //
4
+ // FIPS 204 and FIPS 203 both generate a keypair by expanding a short seed. The
5
+ // expanded private key this package has always returned (4032 bytes for
6
+ // ML-DSA-65, 2400 for ML-KEM-768) is derived FROM that seed and does not
7
+ // contain it: rho, K, tr, s1, s2 and t0 are what expansion produces, not what
8
+ // it consumed. So a seed cannot be recovered from an expanded key, and any
9
+ // caller who wants seed-form storage has to hold the seed from the moment of
10
+ // derivation. `keypairFromMaster` therefore returns it alongside the keypair.
11
+ //
12
+ // Why seed form matters commercially: 32 bytes fits in a KMS secret, an HSM
13
+ // object, an env var and a QR code; 4032 bytes fits in none of them
14
+ // comfortably. It is also the only private form Node's AKP JWK accepts, and
15
+ // the form RFC 9964 and the LAMPS certificate drafts standardise on, so it is
16
+ // what an institution's existing key custody actually knows how to hold.
17
+ //
18
+ // Everything here is derivation and encoding, so it is pure JavaScript and
19
+ // runs unchanged in a browser. It does not import node:crypto.
20
+ //
21
+ // The encodings are not inferred from the specifications. Each one was read
22
+ // off a key OpenSSL 3.5 generated itself and checked byte-for-byte against
23
+ // what this module produces; test/seed.test.js reproduces that comparison on
24
+ // any runtime that has the native backend, so a build whose OpenSSL disagreed
25
+ // would fail rather than ship a subtly wrong encoding.
26
+
27
+ import { ml_dsa65, ml_dsa87 } from '@noble/post-quantum/ml-dsa.js'
28
+ import { ml_kem768, ml_kem1024 } from '@noble/post-quantum/ml-kem.js'
29
+ import { deriveSeed } from './derive.js'
30
+
31
+ const HAS_BUFFER = typeof Buffer !== 'undefined'
32
+
33
+ function wrap(bytes) {
34
+ return HAS_BUFFER ? Buffer.from(bytes) : bytes
35
+ }
36
+
37
+ // ── Parameter sets ──────────────────────────────────────────────────────────
38
+ //
39
+ // `oid` is the DER content of the algorithm OID under the NIST arc
40
+ // 2.16.840.1.101.3.4 (= 60 86 48 01 65 03 04). ML-DSA hangs off .3, ML-KEM off
41
+ // .4. These are registry values and do not vary by implementation; the test
42
+ // suite asserts each one against what this machine's OpenSSL emits rather than
43
+ // trusting the table.
44
+ //
45
+ // SLH-DSA is deliberately absent. OpenSSL keys it by its full private key
46
+ // rather than a seed, so there is no seed form to export, and FIPS 205 signing
47
+ // is far too slow for the hot path this module exists to serve.
48
+
49
+ const NIST_ARC = [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04]
50
+
51
+ const PARAMS = {
52
+ 'ML-DSA-65': {
53
+ kind: 'sig', seedBytes: 32, publicKeyBytes: 1952, secretKeyBytes: 4032,
54
+ oid: [...NIST_ARC, 0x03, 0x12], impl: ml_dsa65, info: 'ml-dsa-65-v1',
55
+ },
56
+ 'ML-DSA-87': {
57
+ kind: 'sig', seedBytes: 32, publicKeyBytes: 2592, secretKeyBytes: 4896,
58
+ oid: [...NIST_ARC, 0x03, 0x13], impl: ml_dsa87, info: 'ml-dsa-87-v1',
59
+ },
60
+ 'ML-KEM-768': {
61
+ kind: 'kem', seedBytes: 64, publicKeyBytes: 1184, secretKeyBytes: 2400,
62
+ oid: [...NIST_ARC, 0x04, 0x02], impl: ml_kem768, info: 'ml-kem-768-v1',
63
+ },
64
+ 'ML-KEM-1024': {
65
+ kind: 'kem', seedBytes: 64, publicKeyBytes: 1568, secretKeyBytes: 3168,
66
+ oid: [...NIST_ARC, 0x04, 0x03], impl: ml_kem1024, info: 'ml-kem-1024-v1',
67
+ },
68
+ }
69
+
70
+ /** Parameter sets this module can express in seed form. */
71
+ export const SEED_ALGORITHMS = Object.keys(PARAMS)
72
+
73
+ function paramsFor(alg) {
74
+ const p = PARAMS[alg]
75
+ if (!p) {
76
+ throw new Error(
77
+ `unsupported algorithm '${alg}' — seed form is defined for ${SEED_ALGORITHMS.join(', ')}`,
78
+ )
79
+ }
80
+ return p
81
+ }
82
+
83
+ function asBytes(value, what) {
84
+ if (value instanceof Uint8Array) return value
85
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return new Uint8Array(value)
86
+ throw new TypeError(`${what} must be a Uint8Array`)
87
+ }
88
+
89
+ // ── base64url ───────────────────────────────────────────────────────────────
90
+ //
91
+ // Buffer where it exists, btoa/atob where it does not. Neither path is a
92
+ // polyfill of the other: this is the same two-runtime split the rest of the
93
+ // package makes, kept local so nothing here reaches for a Node global in a
94
+ // browser.
95
+
96
+ function b64url(bytes) {
97
+ if (HAS_BUFFER) return Buffer.from(bytes).toString('base64url')
98
+ let binary = ''
99
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
100
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
101
+ }
102
+
103
+ function fromB64url(str) {
104
+ if (typeof str !== 'string') throw new TypeError('expected a base64url string')
105
+ if (HAS_BUFFER) return new Uint8Array(Buffer.from(str, 'base64url'))
106
+ const padded = str.replace(/-/g, '+').replace(/_/g, '/')
107
+ const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))
108
+ const out = new Uint8Array(binary.length)
109
+ for (let i = 0; i < out.length; i++) out[i] = binary.charCodeAt(i)
110
+ return out
111
+ }
112
+
113
+ // ── Keys from seeds ─────────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Expand a seed into a keypair.
117
+ *
118
+ * Deterministic, and identical to what OpenSSL 3.5 derives from the same seed:
119
+ * the interoperability test compares the public halves byte-for-byte.
120
+ *
121
+ * @param {string} alg — one of SEED_ALGORITHMS
122
+ * @param {Uint8Array} seed — 32 bytes for ML-DSA, 64 for ML-KEM
123
+ * @returns {{ publicKey: Buffer|Uint8Array, secretKey: Buffer|Uint8Array, seed: Buffer|Uint8Array }}
124
+ */
125
+ export function keypairFromSeed(alg, seed) {
126
+ const p = paramsFor(alg)
127
+ const bytes = asBytes(seed, 'seed')
128
+ if (bytes.length !== p.seedBytes) {
129
+ throw new RangeError(`${alg} seed must be ${p.seedBytes} bytes, got ${bytes.length}`)
130
+ }
131
+ const k = p.impl.keygen(bytes)
132
+ return {
133
+ publicKey: wrap(k.publicKey),
134
+ secretKey: wrap(k.secretKey),
135
+ seed: wrap(bytes),
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Derive the seed for a parameter set from a master secret, using the same
141
+ * HKDF-SHA-512 derivation `keypairFromMaster` has always used.
142
+ *
143
+ * Passing the default `info` for a set reproduces exactly the key that
144
+ * `mlDsa.keypairFromMaster(master)` or `mlKem.keypairFromMaster(master)`
145
+ * produces, which is what makes seed export possible for keys that already
146
+ * exist in production.
147
+ *
148
+ * @param {string} alg
149
+ * @param {Buffer|Uint8Array|string} master
150
+ * @param {string} [info] — defaults to the parameter set's own domain tag
151
+ * @returns {Buffer|Uint8Array}
152
+ */
153
+ export function seedFromMaster(alg, master, info) {
154
+ const p = paramsFor(alg)
155
+ return deriveSeed(master, info ?? p.info, p.seedBytes)
156
+ }
157
+
158
+ // ── JWK, RFC 9964 ───────────────────────────────────────────────────────────
159
+ //
160
+ // RFC 9964 gives the algorithm-key-pair key type: kty "AKP", the parameter set
161
+ // in "alg", the public key in "pub", and — for a private key — the SEED in
162
+ // "priv". Not the expanded key. Node rejects an expanded key here outright,
163
+ // which is the specification being enforced rather than a Node limitation.
164
+
165
+ /**
166
+ * Export an AKP JWK (RFC 9964).
167
+ *
168
+ * With `seed`, the result is a private JWK carrying the seed in `priv` and is
169
+ * accepted directly by `crypto.createPrivateKey({ format: 'jwk' })` on Node
170
+ * 24+. Without it, the result is a public JWK.
171
+ *
172
+ * An expanded secret key is NOT accepted in place of a seed: RFC 9964 has no
173
+ * encoding for one, and silently deriving something else would produce a JWK
174
+ * that names a key it does not contain.
175
+ *
176
+ * @param {string} alg
177
+ * @param {{ publicKey: Uint8Array, seed?: Uint8Array }} key
178
+ * @param {{ kid?: string, use?: string }} [opts]
179
+ * @returns {{ kty: 'AKP', alg: string, pub: string, priv?: string, kid?: string }}
180
+ */
181
+ export function exportJwk(alg, key, opts = {}) {
182
+ const p = paramsFor(alg)
183
+ if (!key || typeof key !== 'object') {
184
+ throw new TypeError('expected a key object such as { publicKey, seed }')
185
+ }
186
+ const publicKey = asBytes(key.publicKey, 'publicKey')
187
+ if (publicKey.length !== p.publicKeyBytes) {
188
+ throw new RangeError(`${alg} public key must be ${p.publicKeyBytes} bytes, got ${publicKey.length}`)
189
+ }
190
+
191
+ const jwk = { kty: 'AKP', alg, pub: b64url(publicKey) }
192
+
193
+ if (key.seed !== undefined && key.seed !== null) {
194
+ const seed = asBytes(key.seed, 'seed')
195
+ if (seed.length === p.secretKeyBytes) {
196
+ throw new RangeError(
197
+ `${alg} seed must be ${p.seedBytes} bytes; got ${seed.length}, which is the expanded secret key. ` +
198
+ 'RFC 9964 encodes the seed, and a seed cannot be recovered from an expanded key — ' +
199
+ 'hold the seed returned by keypairFromMaster or keypairFromSeed.',
200
+ )
201
+ }
202
+ if (seed.length !== p.seedBytes) {
203
+ throw new RangeError(`${alg} seed must be ${p.seedBytes} bytes, got ${seed.length}`)
204
+ }
205
+ jwk.priv = b64url(seed)
206
+ }
207
+
208
+ if (opts.kid) jwk.kid = opts.kid
209
+ if (opts.use) jwk.use = opts.use
210
+ return jwk
211
+ }
212
+
213
+ /**
214
+ * Import an AKP JWK (RFC 9964).
215
+ *
216
+ * A private JWK is expanded back to a full keypair, and the expansion is
217
+ * checked against the `pub` the JWK carried: a JWK whose seed and public key
218
+ * disagree is rejected rather than quietly preferring one of them.
219
+ *
220
+ * @param {object} jwk
221
+ * @returns {{ alg: string, publicKey: Buffer|Uint8Array, seed?: Buffer|Uint8Array, secretKey?: Buffer|Uint8Array }}
222
+ */
223
+ export function importJwk(jwk) {
224
+ if (!jwk || typeof jwk !== 'object') throw new TypeError('expected a JWK object')
225
+ if (jwk.kty !== 'AKP') {
226
+ throw new Error(`unsupported JWK kty '${jwk.kty}' — expected 'AKP' (RFC 9964)`)
227
+ }
228
+ const p = paramsFor(jwk.alg)
229
+
230
+ const publicKey = fromB64url(jwk.pub)
231
+ if (publicKey.length !== p.publicKeyBytes) {
232
+ throw new RangeError(`${jwk.alg} JWK 'pub' must decode to ${p.publicKeyBytes} bytes, got ${publicKey.length}`)
233
+ }
234
+
235
+ if (jwk.priv === undefined || jwk.priv === null) {
236
+ return { alg: jwk.alg, publicKey: wrap(publicKey) }
237
+ }
238
+
239
+ const seed = fromB64url(jwk.priv)
240
+ if (seed.length !== p.seedBytes) {
241
+ throw new RangeError(`${jwk.alg} JWK 'priv' must decode to ${p.seedBytes} bytes (the seed), got ${seed.length}`)
242
+ }
243
+
244
+ const expanded = p.impl.keygen(seed)
245
+ if (!bytesEqual(expanded.publicKey, publicKey)) {
246
+ throw new Error(
247
+ `${jwk.alg} JWK is inconsistent: the public key derived from 'priv' does not match 'pub'`,
248
+ )
249
+ }
250
+
251
+ return {
252
+ alg: jwk.alg,
253
+ publicKey: wrap(expanded.publicKey),
254
+ secretKey: wrap(expanded.secretKey),
255
+ seed: wrap(seed),
256
+ }
257
+ }
258
+
259
+ function bytesEqual(a, b) {
260
+ if (a.length !== b.length) return false
261
+ let diff = 0
262
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
263
+ return diff === 0
264
+ }
265
+
266
+ // ── PKCS#8, seed form ───────────────────────────────────────────────────────
267
+ //
268
+ // OneAsymmetricKey (RFC 5958) whose privateKey OCTET STRING wraps the seed
269
+ // CHOICE from the LAMPS certificate drafts:
270
+ //
271
+ // SEQUENCE {
272
+ // INTEGER 0
273
+ // SEQUENCE { OID <parameter set> }
274
+ // OCTET STRING { [0] IMPLICIT OCTET STRING <seed> }
275
+ // }
276
+ //
277
+ // The [0] tag (0x80) is what distinguishes seed form from the expanded form,
278
+ // which appears as a bare OCTET STRING in the same position. This package's
279
+ // existing expanded-key PKCS#8 export is untouched and both forms remain
280
+ // importable by OpenSSL 3.5.
281
+
282
+ const DER_SEQUENCE = 0x30
283
+ const DER_INTEGER = 0x02
284
+ const DER_OCTET_STRING = 0x04
285
+ const DER_OID = 0x06
286
+ const DER_SEED_CHOICE = 0x80 // [0] IMPLICIT OCTET STRING
287
+
288
+ function derLength(length) {
289
+ if (length < 0x80) return [length]
290
+ if (length < 0x100) return [0x81, length]
291
+ return [0x82, length >> 8, length & 0xff]
292
+ }
293
+
294
+ function der(tag, payload) {
295
+ return [tag, ...derLength(payload.length), ...payload]
296
+ }
297
+
298
+ /**
299
+ * Export a seed as a PKCS#8 private key in LAMPS seed form.
300
+ *
301
+ * The result loads directly into OpenSSL 3.5 and into
302
+ * `crypto.createPrivateKey({ format: 'der', type: 'pkcs8' })` on Node 24+.
303
+ *
304
+ * @param {string} alg
305
+ * @param {Uint8Array} seed
306
+ * @returns {Buffer|Uint8Array} DER
307
+ */
308
+ export function exportSeedPkcs8(alg, seed) {
309
+ const p = paramsFor(alg)
310
+ const bytes = asBytes(seed, 'seed')
311
+ if (bytes.length !== p.seedBytes) {
312
+ throw new RangeError(`${alg} seed must be ${p.seedBytes} bytes, got ${bytes.length}`)
313
+ }
314
+ const body = [
315
+ ...der(DER_INTEGER, [0x00]),
316
+ ...der(DER_SEQUENCE, der(DER_OID, p.oid)),
317
+ ...der(DER_OCTET_STRING, der(DER_SEED_CHOICE, [...bytes])),
318
+ ]
319
+ return wrap(Uint8Array.from(der(DER_SEQUENCE, body)))
320
+ }
321
+
322
+ /**
323
+ * Read a seed-form PKCS#8 private key back.
324
+ *
325
+ * Rejects the expanded form rather than guessing: an expanded key in this
326
+ * position is a valid PKCS#8 key but not a seed, and returning its first 32
327
+ * bytes as one would produce a completely different keypair.
328
+ *
329
+ * @param {Uint8Array} der8
330
+ * @returns {{ alg: string, seed: Buffer|Uint8Array }}
331
+ */
332
+ export function importSeedPkcs8(der8) {
333
+ const buf = asBytes(der8, 'pkcs8')
334
+ const r = { i: 0, buf }
335
+
336
+ expectTag(r, DER_SEQUENCE, 'PKCS#8')
337
+ readLength(r)
338
+
339
+ expectTag(r, DER_INTEGER, 'version')
340
+ const versionLen = readLength(r)
341
+ r.i += versionLen
342
+
343
+ expectTag(r, DER_SEQUENCE, 'AlgorithmIdentifier')
344
+ const algIdLen = readLength(r)
345
+ const algIdEnd = r.i + algIdLen
346
+ expectTag(r, DER_OID, 'algorithm OID')
347
+ const oidLen = readLength(r)
348
+ const oid = buf.subarray(r.i, r.i + oidLen)
349
+ r.i = algIdEnd
350
+
351
+ const alg = SEED_ALGORITHMS.find((name) => bytesEqual(Uint8Array.from(PARAMS[name].oid), oid))
352
+ if (!alg) {
353
+ throw new Error(
354
+ `unrecognised algorithm OID in PKCS#8 — seed form is defined for ${SEED_ALGORITHMS.join(', ')}`,
355
+ )
356
+ }
357
+
358
+ expectTag(r, DER_OCTET_STRING, 'privateKey')
359
+ readLength(r)
360
+
361
+ const inner = buf[r.i]
362
+ if (inner === DER_OCTET_STRING) {
363
+ throw new Error(
364
+ `this PKCS#8 key is in expanded form, not seed form. A seed cannot be recovered ` +
365
+ `from an expanded ${alg} key; re-export from the seed the key was derived from.`,
366
+ )
367
+ }
368
+ if (inner !== DER_SEED_CHOICE) {
369
+ throw new Error(`unexpected privateKey encoding: tag 0x${inner.toString(16)}`)
370
+ }
371
+ r.i += 1
372
+ const seedLen = readLength(r)
373
+ if (seedLen !== PARAMS[alg].seedBytes) {
374
+ throw new RangeError(`${alg} seed must be ${PARAMS[alg].seedBytes} bytes, got ${seedLen}`)
375
+ }
376
+ return { alg, seed: wrap(buf.subarray(r.i, r.i + seedLen)) }
377
+ }
378
+
379
+ function expectTag(r, tag, what) {
380
+ if (r.buf[r.i] !== tag) {
381
+ throw new Error(`malformed DER: expected ${what} tag 0x${tag.toString(16)} at offset ${r.i}`)
382
+ }
383
+ r.i += 1
384
+ }
385
+
386
+ function readLength(r) {
387
+ const first = r.buf[r.i++]
388
+ if (first < 0x80) return first
389
+ const count = first & 0x7f
390
+ if (count === 0 || count > 3) throw new Error('malformed DER: unsupported length encoding')
391
+ let length = 0
392
+ for (let n = 0; n < count; n++) length = (length << 8) | r.buf[r.i++]
393
+ return length
394
+ }