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/BENCHMARKS.md +18 -13
- package/CHANGELOG.md +122 -0
- package/CONFORMANCE.md +77 -5
- package/DEPENDENCIES.md +137 -0
- package/LICENCE-PRODUCT.md +90 -0
- package/README.md +72 -6
- package/package.json +29 -11
- package/src/backend.d.ts +16 -0
- package/src/backend.js +48 -0
- package/src/index.d.ts +6 -0
- package/src/index.js +18 -2
- package/src/jws.d.ts +74 -0
- package/src/jws.js +249 -0
- package/src/ml-dsa-87.d.ts +8 -0
- package/src/ml-dsa-87.js +5 -0
- package/src/ml-dsa.d.ts +8 -0
- package/src/ml-dsa.js +5 -0
- package/src/ml-kem-1024.d.ts +8 -0
- package/src/ml-kem-1024.js +5 -0
- package/src/ml-kem.d.ts +8 -0
- package/src/ml-kem.js +5 -0
- package/src/seed.d.ts +91 -0
- package/src/seed.js +394 -0
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
|
+
}
|