@blamejs/pki 0.3.8 → 0.3.10

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/lib/key.js ADDED
@@ -0,0 +1,420 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.key
6
+ * @nav Signing
7
+ * @title Keys
8
+ * @intro The key-material lifecycle: export / import a private key as PKCS#8 (`OneAsymmetricKey`,
9
+ * RFC 5958) or a public key as SPKI (RFC 5280 sec. 4.1.2.7), encrypt / decrypt a private key under
10
+ * RFC 8018 PBES2 (`EncryptedPrivateKeyInfo`, PBKDF2 + AES-CBC-Pad), and `generate` /
11
+ * `publicFromPrivate` over every algorithm the WebCrypto engine drives -- RSA, EC, Ed25519/Ed448,
12
+ * X25519/X448, and the FIPS post-quantum ML-DSA / ML-KEM. Unencrypted export / import DELEGATES to the
13
+ * WebCrypto `exportKey` / `importKey` PKCS#8 / SPKI encoders (which already emit each algorithm's
14
+ * `AlgorithmIdentifier.parameters` correctly -- RSA NULL, EC namedCurve, Ed/X ABSENT), so the wrapper
15
+ * never re-encodes an AlgorithmIdentifier. PBES2 encrypt / decrypt composes the one shared `lib/pbes2.js`
16
+ * home (the same PBKDF2 + AES-CBC primitives `pki.cms` uses). Parsing lives at `pki.schema.pkcs8.parse`.
17
+ * @spec RFC 5958, RFC 8018, RFC 5280
18
+ * @card Export / import PKCS#8 and SPKI keys and encrypt a private key under RFC 8018 PBES2.
19
+ */
20
+ //
21
+ // PBES2 is composed from lib/pbes2.js (the ONE PBKDF2 + AES-CBC home shared with pki.cms), bound to the key
22
+ // namespace through the `_err` error FACTORY + the "key" domain prefix so every reject keeps a key/* code.
23
+ // A MAC-less PBES2-CBC decrypt is not a padding oracle (RFC 8018 sec. 8): every post-derivation failure --
24
+ // a bad PKCS#7 pad OR a valid pad that does not re-parse as a PrivateKeyInfo -- collapses into ONE uniform
25
+ // key/decrypt-failed; the structural pre-derivation faults (a non-PBKDF2 KDF, a non-AES-CBC scheme, an
26
+ // over-cap salt / iteration count, a wrong-length IV, a malformed parameter SEQUENCE) stay distinct and are
27
+ // thrown BEFORE any key derivation, so they leak nothing password-dependent. Unencrypted export / import
28
+ // and publicFromPrivate DELEGATE to the WebCrypto / node key engines rather than re-serialize an
29
+ // AlgorithmIdentifier -- a re-encode is exactly where a params-absent-vs-NULL divergence sneaks back in.
30
+
31
+ var nodeCrypto = require("crypto");
32
+ var asn1 = require("./asn1-der");
33
+ var oid = require("./oid");
34
+ var pbes2 = require("./pbes2");
35
+ var pkcs8 = require("./schema-pkcs8");
36
+ var pkix = require("./schema-pkix");
37
+ var webcrypto = require("./webcrypto");
38
+ var frameworkError = require("./framework-error");
39
+ var guard = require("./guard-all");
40
+
41
+ var b = asn1.build;
42
+ var subtle = webcrypto.webcrypto.subtle;
43
+ var CryptoKey = webcrypto.CryptoKey;
44
+ var KeyError = frameworkError.KeyError;
45
+ var PemError = frameworkError.PemError;
46
+ function O(n) { return oid.byName(n); }
47
+ // The guard / pbes2 error convention: a (code, msg, cause) FACTORY, never the KeyError class -- pbes2 and
48
+ // the guards invoke it as E(code, msg) with no `new` (a class there crashes on the error path).
49
+ function _err(code, msg, cause) { return new KeyError(code, msg, cause); }
50
+
51
+ // The operator-facing node cipher name <-> the AES-CBC registry name the PBES2 home speaks.
52
+ var CIPHER_NAME = { "aes-128-cbc": "aes128-CBC", "aes-192-cbc": "aes192-CBC", "aes-256-cbc": "aes256-CBC" };
53
+
54
+ // import inference: the algorithm OIDs that name exactly ONE WebCrypto algorithm and need no parameters --
55
+ // the Edwards/Montgomery curves and the FIPS post-quantum ML-DSA, ML-KEM, and SLH-DSA (all signing-only or
56
+ // agreement-only, so unambiguous). RSA (sign vs OAEP) and EC (ECDSA vs ECDH) are deliberately ABSENT -- they
57
+ // are ambiguous, so import fails closed and asks for opts.algorithm rather than guess a plausible use.
58
+ var INFER_ALG = {};
59
+ ["Ed25519", "Ed448", "X25519", "X448"].forEach(function (n) { INFER_ALG[O(n)] = { name: n }; });
60
+ [["id-ml-dsa-44", "ML-DSA-44"], ["id-ml-dsa-65", "ML-DSA-65"], ["id-ml-dsa-87", "ML-DSA-87"],
61
+ ["id-ml-kem-512", "ML-KEM-512"], ["id-ml-kem-768", "ML-KEM-768"], ["id-ml-kem-1024", "ML-KEM-1024"]
62
+ ].forEach(function (r) { INFER_ALG[O(r[0])] = { name: r[1] }; });
63
+ // SLH-DSA (FIPS 205) -- all twelve parameter sets; the WebCrypto name is the OID name minus "id-", uppercased.
64
+ ["sha2-128s", "sha2-128f", "sha2-192s", "sha2-192f", "sha2-256s", "sha2-256f",
65
+ "shake-128s", "shake-128f", "shake-192s", "shake-192f", "shake-256s", "shake-256f"
66
+ ].forEach(function (s) { INFER_ALG[O("id-slh-dsa-" + s)] = { name: ("SLH-DSA-" + s).toUpperCase() }; });
67
+
68
+ function _isCryptoKey(x) { return x instanceof CryptoKey; }
69
+ function _algName(a) { return typeof a === "string" ? a : (a && a.name); }
70
+
71
+ // A private-key input (CryptoKey | DER Buffer | 'PRIVATE KEY' PEM) -> the raw PKCS#8 PrivateKeyInfo DER.
72
+ async function _toPrivateKeyDer(input) {
73
+ if (_isCryptoKey(input)) {
74
+ if (input.type !== "private") throw _err("key/bad-input", "a private CryptoKey is required (got a " + input.type + " key)");
75
+ return Buffer.from(await subtle.exportKey("pkcs8", input));
76
+ }
77
+ return pkix.coerceToDer(input, { pemLabel: "PRIVATE KEY", PemError: PemError, ErrorClass: KeyError, prefix: "key" });
78
+ }
79
+
80
+ /**
81
+ * @primitive pki.key.encrypt
82
+ * @signature pki.key.encrypt(privateKey, password, opts?) -> Promise<Buffer|string>
83
+ * @since 0.3.10
84
+ * @status experimental
85
+ * @spec RFC 5958 sec. 3, RFC 8018
86
+ * @related pki.key.decrypt, pki.schema.pkcs8.parseEncrypted, pki.cms.encrypt
87
+ *
88
+ * Encrypt a PKCS#8 private key into an RFC 5958 `EncryptedPrivateKeyInfo` under RFC 8018 PBES2 (PBKDF2 +
89
+ * AES-CBC-Pad). `privateKey` is a DER `Buffer`, a `PRIVATE KEY` PEM string, or an extractable private
90
+ * `CryptoKey`; `password` is a string (UTF-8-encoded, byte-identical to OpenSSL), `Buffer`, or `Uint8Array`.
91
+ * The plaintext is the DER `PrivateKeyInfo`, validated as a well-formed PKCS#8 structure before encryption
92
+ * (never encrypt opaque bytes), and the produced `EncryptedPrivateKeyInfo` is re-parsed before return.
93
+ *
94
+ * The PBKDF2 `prf` equal to the default (`hmacWithSHA1`) is omitted from the parameters (X.690 sec. 11.5),
95
+ * `keyLength` is omitted (the AES cipher OID fixes the key size), and the salt / iteration count are bounded
96
+ * -- so the output is byte-exact with OpenSSL's `pkcs8 -topk8 -v2`. A bad input throws a typed `KeyError`.
97
+ *
98
+ * @opts
99
+ * - `cipher` (string) -- `aes-256-cbc` (default), `aes-192-cbc`, or `aes-128-cbc`.
100
+ * - `prf` (string) -- `hmacWithSHA256` (default), `hmacWithSHA384`, `hmacWithSHA512`, or `hmacWithSHA1`.
101
+ * - `iterations` (number) -- PBKDF2 iteration count, default 600000 (bounded by the decryptor's cap).
102
+ * - `salt` (Buffer) -- an explicit PBKDF2 salt (default 16 random octets).
103
+ * - `pem` (boolean) -- return an `ENCRYPTED PRIVATE KEY` PEM string instead of DER.
104
+ * @example
105
+ * var pair = await pki.key.generate("Ed25519");
106
+ * var der = await pki.key.export(pair.privateKey);
107
+ * var enc = await pki.key.encrypt(der, "s3cr3t", { pem: true });
108
+ * var back = await pki.key.decrypt(enc, "s3cr3t");
109
+ */
110
+ async function encrypt(privateKey, password, opts) {
111
+ opts = opts || {};
112
+ var der = await _toPrivateKeyDer(privateKey);
113
+ // RFC 5958 sec. 3: the plaintext is the DER PrivateKeyInfo -- validate it IS one (the strict parser
114
+ // rejects BER / non-canonical / trailing bytes) before encrypting, so we never wrap opaque input.
115
+ try { pkcs8.parse(der); } catch (e) { throw _err("key/bad-input", "the private key is not a well-formed PKCS#8 PrivateKeyInfo", e); }
116
+ var cipherName = CIPHER_NAME[opts.cipher || "aes-256-cbc"];
117
+ if (!cipherName) throw _err("key/bad-input", "unsupported cipher " + JSON.stringify(opts.cipher) + " (aes-128-cbc / aes-192-cbc / aes-256-cbc)");
118
+ var keyBits = pbes2.CONTENT_KEYBITS[O(cipherName)];
119
+ var prf = opts.prf || "hmacWithSHA256";
120
+ var prfNode = pbes2.prfNodeByName(prf, _err, "key");
121
+ var iterations = pbes2.assertIterations(opts.iterations == null ? 600000 : opts.iterations, _err, "key");
122
+ var salt = opts.salt != null ? pbes2.assertSalt(guard.bytes.view(opts.salt, KeyError, "key/bad-input", "salt"), _err, "key") : nodeCrypto.randomBytes(16);
123
+ var iv = nodeCrypto.randomBytes(16);
124
+ var dk = nodeCrypto.pbkdf2Sync(pbes2.passwordBytes(password, _err, "key"), salt, iterations, keyBits / 8, prfNode);
125
+ var ciphertext = pbes2.cbcEncrypt(dk, iv, der, keyBits);
126
+ var epki = b.sequence([pbes2.pbes2AlgId(salt, iterations, prf, cipherName, iv), b.octetString(ciphertext)]);
127
+ pkcs8.parseEncrypted(epki); // self-check: the produced EncryptedPrivateKeyInfo re-parses
128
+ return opts.pem ? pkcs8.pemEncode(epki, "ENCRYPTED PRIVATE KEY") : epki;
129
+ }
130
+
131
+ /**
132
+ * @primitive pki.key.decrypt
133
+ * @signature pki.key.decrypt(encrypted, password, opts?) -> Promise<Buffer|string>
134
+ * @since 0.3.10
135
+ * @status experimental
136
+ * @spec RFC 5958 sec. 3, RFC 8018 sec. 6.2, RFC 8018 sec. 8
137
+ * @defends pbes2-padding-oracle (CWE-208), pbkdf2-work-dos (CWE-400)
138
+ * @related pki.key.encrypt, pki.schema.pkcs8.parse
139
+ *
140
+ * Decrypt an RFC 5958 `EncryptedPrivateKeyInfo` (DER `Buffer` or `ENCRYPTED PRIVATE KEY` PEM) under RFC 8018
141
+ * PBES2, returning the inner `PrivateKeyInfo` (re-validated via `pki.schema.pkcs8.parse`). Only PBES2 with a
142
+ * PBKDF2 key-derivation function and an AES-CBC encryption scheme is accepted; PBES1, PBMAC1, scrypt, and any
143
+ * other `encryptionAlgorithm` fail closed with `key/unsupported-algorithm`.
144
+ *
145
+ * The salt and iteration count are attacker-controlled work: both caps are enforced BEFORE any derivation
146
+ * (`opts.maxIterations` may lower the cap, never raise it), and a wrong-length IV or malformed parameter set
147
+ * is a typed `key/bad-algorithm-parameters`. A MAC-less PBES2-CBC decrypt is not a padding oracle -- a wrong
148
+ * password and a valid-pad-but-not-a-PrivateKeyInfo both surface the single uniform `key/decrypt-failed`.
149
+ *
150
+ * @opts
151
+ * - `maxIterations` (number) -- lower the PBKDF2 iteration cap for this decrypt (downward-only).
152
+ * - `pem` (boolean) -- return a `PRIVATE KEY` PEM string instead of DER.
153
+ * @example
154
+ * var pair = await pki.key.generate("Ed25519");
155
+ * var enc = await pki.key.encrypt(await pki.key.export(pair.privateKey), "s3cr3t", { pem: true });
156
+ * var der = await pki.key.decrypt(enc, "s3cr3t");
157
+ */
158
+ async function decrypt(encrypted, password, opts) {
159
+ opts = opts || {};
160
+ // opts.maxIterations is a CALLER authoring bound (a downward-only cap): validate it config-time so an
161
+ // invalid value is a key/bad-input, never a NaN that silently disables the DoS cap and never mis-coded as
162
+ // a fault in the attacker-controlled input parameters.
163
+ if (opts.maxIterations != null && (typeof opts.maxIterations !== "number" || !isFinite(opts.maxIterations) || opts.maxIterations < 1 || Math.floor(opts.maxIterations) !== opts.maxIterations)) {
164
+ throw _err("key/bad-input", "maxIterations must be a positive integer");
165
+ }
166
+ var input = pkix.coerceToDer(encrypted, { pemLabel: "ENCRYPTED PRIVATE KEY", PemError: PemError, ErrorClass: KeyError, prefix: "key" });
167
+ var epki;
168
+ try { epki = pkcs8.parseEncrypted(input); }
169
+ catch (e) { throw _err("key/bad-input", "the input is not a well-formed EncryptedPrivateKeyInfo", e); }
170
+ var encAlg = epki.encryptionAlgorithm;
171
+ if (encAlg.oid !== O("pbes2")) throw _err("key/unsupported-algorithm", "unsupported key encryption algorithm " + (encAlg.name || encAlg.oid) + " (only RFC 8018 PBES2 is supported)");
172
+ var plaintext = _decryptPbes2(encAlg, epki.encryptedData, password, opts);
173
+ return opts.pem ? pkcs8.pemEncode(plaintext, "PRIVATE KEY") : plaintext;
174
+ }
175
+
176
+ function _decryptPbes2(encAlg, ciphertext, password, opts) {
177
+ var pb, keyBits, iv;
178
+ // Structural parse (typed, pre-derivation). These faults are not password-dependent, so a distinct code
179
+ // per fault leaks no oracle; a raw asn1 fault is normalized to key/bad-algorithm-parameters.
180
+ try {
181
+ var params = pbes2.seqChildren(encAlg.parameters, 2, "PBES2 parameters", _err, "key");
182
+ var kdf = pbes2.requireChildren(params[0], 2, "PBES2 keyDerivationFunc", _err, "key");
183
+ var encScheme = pbes2.requireChildren(params[1], 2, "PBES2 encryptionScheme", _err, "key");
184
+ if (asn1.read.oid(kdf[0]) !== O("pbkdf2")) throw _err("key/unsupported-algorithm", "the PBES2 keyDerivationFunc must be PBKDF2 (RFC 8018 sec. 6.2)");
185
+ pb = pbes2.parsePbkdf2Params(kdf[1].bytes, opts, _err, "key", true); // strictPrf: reject a non-canonical explicit default prf
186
+ var encOid = asn1.read.oid(encScheme[0]);
187
+ keyBits = pbes2.CONTENT_KEYBITS[encOid];
188
+ if (!keyBits || !/CBC/.test(oid.name(encOid) || "")) throw _err("key/unsupported-algorithm", "unsupported PBES2 encryptionScheme " + (oid.name(encOid) || encOid) + " (AES-CBC only)");
189
+ iv = asn1.read.octetString(encScheme[1]);
190
+ if (iv.length !== 16) throw _err("key/bad-algorithm-parameters", "the AES-CBC IV must be 16 octets");
191
+ } catch (e) {
192
+ // The shared PBES2 home rejects a malformed / truncated / over-cap parameter with the generic
193
+ // key/bad-input; in the PBES2 parameter-structure context that IS a bad-algorithm-parameters. The
194
+ // semantic verdicts it raises (unsupported-algorithm, iteration-limit) keep their precise codes.
195
+ if (e instanceof KeyError) {
196
+ if (e.code === "key/bad-input") throw _err("key/bad-algorithm-parameters", e.message, e.cause);
197
+ throw e;
198
+ }
199
+ throw _err("key/bad-algorithm-parameters", "malformed PBES2 parameters", e);
200
+ }
201
+ var dk = nodeCrypto.pbkdf2Sync(pbes2.passwordBytes(password, _err, "key"), pb.salt, pb.iterations, keyBits / 8, pb.prfNode);
202
+ // Post-derivation failures collapse to ONE uniform verdict (RFC 8018 sec. 8): a bad PKCS#7 pad and a
203
+ // valid pad whose plaintext is not a PrivateKeyInfo are indistinguishable -- the re-parse is the integrity
204
+ // check for MAC-less PBES2-CBC, and it MUST run (never skipped).
205
+ var plaintext;
206
+ try { plaintext = pbes2.cbcDecrypt(dk, iv, ciphertext, keyBits); }
207
+ catch (_e) { throw _err("key/decrypt-failed", "decryption failed"); }
208
+ try { pkcs8.parse(plaintext); }
209
+ catch (_e) { throw _err("key/decrypt-failed", "decryption failed"); }
210
+ return plaintext;
211
+ }
212
+
213
+ /**
214
+ * @primitive pki.key.export
215
+ * @signature pki.key.export(key, opts?) -> Promise<Buffer|string>
216
+ * @since 0.3.10
217
+ * @status experimental
218
+ * @spec RFC 5958, RFC 5280 sec. 4.1.2.7, RFC 8410 sec. 3
219
+ * @related pki.key.import, pki.key.publicFromPrivate, pki.schema.pkcs8.parse
220
+ *
221
+ * Export an extractable `CryptoKey` to DER (or PEM): a private key as PKCS#8 `OneAsymmetricKey`, a public
222
+ * key as SubjectPublicKeyInfo. The encoding is delegated to the WebCrypto `exportKey` PKCS#8 / SPKI encoder,
223
+ * so the algorithm-specific `AlgorithmIdentifier.parameters` are byte-correct -- RSA carries an explicit
224
+ * NULL, EC a namedCurve OID, and Ed25519 / Ed448 / X25519 / X448 omit parameters (RFC 8410 sec. 3); the
225
+ * wrapper never re-encodes the AlgorithmIdentifier.
226
+ *
227
+ * @opts
228
+ * - `format` (string) -- `der` (default) or `pem`.
229
+ * - `label` (string) -- the PEM label (defaults `PRIVATE KEY` / `PUBLIC KEY` by key type).
230
+ * @example
231
+ * var pair = await pki.key.generate("Ed25519");
232
+ * var spkiPem = await pki.key.export(pair.publicKey, { format: "pem" });
233
+ */
234
+ async function export_(key, opts) {
235
+ opts = opts || {};
236
+ if (!_isCryptoKey(key)) throw _err("key/bad-input", "export expects a WebCrypto CryptoKey");
237
+ var format, defaultLabel;
238
+ if (key.type === "private") { format = "pkcs8"; defaultLabel = "PRIVATE KEY"; }
239
+ else if (key.type === "public") { format = "spki"; defaultLabel = "PUBLIC KEY"; }
240
+ else throw _err("key/bad-input", "export supports asymmetric (private / public) CryptoKeys only");
241
+ var der = Buffer.from(await subtle.exportKey(format, key));
242
+ var fmt = opts.format || "der";
243
+ if (fmt === "der") return der;
244
+ if (fmt === "pem") return pkix.pemEncode(der, opts.label || defaultLabel, PemError);
245
+ throw _err("key/bad-input", "unsupported format " + JSON.stringify(opts.format) + " (der / pem)");
246
+ }
247
+
248
+ /**
249
+ * @primitive pki.key.import
250
+ * @signature pki.key.import(input, opts?) -> Promise<CryptoKey>
251
+ * @since 0.3.10
252
+ * @status experimental
253
+ * @spec RFC 5958, RFC 5280 sec. 4.1.2.7, RFC 8018
254
+ * @related pki.key.export, pki.key.decrypt
255
+ *
256
+ * Import a DER / PEM PKCS#8 private key, SPKI public key, or (with `opts.password`) an `ENCRYPTED PRIVATE
257
+ * KEY` -- auto-detecting the structure -- into a `CryptoKey`. The WebCrypto algorithm is inferred from the
258
+ * key's OID for the algorithms that name exactly one (Ed25519 / Ed448 / X25519 / X448 / ML-DSA / ML-KEM /
259
+ * SLH-DSA); RSA and EC are ambiguous between signing and key agreement, so `opts.algorithm` must be supplied
260
+ * for them (import fails closed rather than guess a use). Default key usages follow the algorithm and key type.
261
+ *
262
+ * @opts
263
+ * - `algorithm` (string | object) -- the WebCrypto algorithm (required for RSA / EC; overrides inference).
264
+ * - `usages` (string[]) -- key usages (default derived from the algorithm and public/private type).
265
+ * - `extractable` (boolean) -- default false.
266
+ * - `password` (string | Buffer) -- decrypt an `ENCRYPTED PRIVATE KEY` first.
267
+ * @example
268
+ * var pair = await pki.key.generate("Ed25519");
269
+ * var spki = await pki.key.export(pair.publicKey);
270
+ * var pub = await pki.key.import(spki); // Ed25519 -- algorithm inferred
271
+ */
272
+ async function import_(input, opts) {
273
+ opts = opts || {};
274
+ var detected = _detectKeyInput(input);
275
+ if (detected.format === "encrypted") {
276
+ if (opts.password == null) throw _err("key/bad-input", "an ENCRYPTED PRIVATE KEY requires opts.password to import");
277
+ detected = { format: "pkcs8", der: await decrypt(detected.der, opts.password) };
278
+ }
279
+ var algorithm = opts.algorithm != null ? opts.algorithm : _inferAlgorithm(detected);
280
+ var isPublic = detected.format === "spki";
281
+ var usages = opts.usages || _importUsages(_algName(algorithm), isPublic);
282
+ var extractable = opts.extractable != null ? opts.extractable : false;
283
+ try {
284
+ return await subtle.importKey(detected.format, detected.der, algorithm, extractable, usages);
285
+ } catch (e) {
286
+ if (e && e.isPkiError) throw e;
287
+ throw _err("key/bad-input", "importKey failed", e);
288
+ }
289
+ }
290
+
291
+ /**
292
+ * @primitive pki.key.generate
293
+ * @signature pki.key.generate(algorithm, opts?) -> Promise<{ privateKey, publicKey }>
294
+ * @since 0.3.10
295
+ * @status experimental
296
+ * @spec W3C WebCrypto, FIPS 203, FIPS 204
297
+ * @related pki.key.export, pki.key.publicFromPrivate
298
+ *
299
+ * Generate an asymmetric key pair over the WebCrypto engine: RSA, ECDSA / ECDH, Ed25519 / Ed448, X25519 /
300
+ * X448, and the FIPS post-quantum ML-DSA / ML-KEM. `algorithm` is a WebCrypto algorithm string or object;
301
+ * usages default to the algorithm's natural set (sign/verify, deriveBits/deriveKey, or encapsulate/
302
+ * decapsulate) and keys are extractable by default. Returns the `{ privateKey, publicKey }` `CryptoKey` pair.
303
+ *
304
+ * @opts
305
+ * - `extractable` (boolean) -- default true.
306
+ * - `usages` (string[]) -- override the default key usages.
307
+ * @example
308
+ * var pair = await pki.key.generate("Ed25519");
309
+ * var pkcs8 = await pki.key.export(pair.privateKey);
310
+ */
311
+ async function generate(algorithm, opts) {
312
+ opts = opts || {};
313
+ var extractable = opts.extractable != null ? opts.extractable : true;
314
+ var usages = opts.usages || _generateUsages(_algName(algorithm));
315
+ var pair;
316
+ try { pair = await subtle.generateKey(algorithm, extractable, usages); }
317
+ catch (e) { if (e && e.isPkiError) throw e; throw _err("key/bad-input", "generateKey failed", e); }
318
+ if (!pair || !pair.privateKey || !pair.publicKey) throw _err("key/bad-input", "the algorithm does not generate an asymmetric key pair");
319
+ return { privateKey: pair.privateKey, publicKey: pair.publicKey };
320
+ }
321
+
322
+ /**
323
+ * @primitive pki.key.publicFromPrivate
324
+ * @signature pki.key.publicFromPrivate(privateKey, opts?) -> Promise<Buffer|string>
325
+ * @since 0.3.10
326
+ * @status experimental
327
+ * @spec RFC 5280 sec. 4.1.2.7, RFC 8410 sec. 3
328
+ * @related pki.key.export, pki.key.import
329
+ *
330
+ * Derive the SubjectPublicKeyInfo (SPKI) public key from a PKCS#8 private key (DER `Buffer`, `PRIVATE KEY`
331
+ * PEM, or extractable private `CryptoKey`). The derivation is delegated to the node key engine, which infers
332
+ * the algorithm from the key structure, so no `AlgorithmIdentifier` is re-encoded -- Ed25519 stays
333
+ * parameters-absent, RSA keeps its NULL, EC keeps its namedCurve.
334
+ *
335
+ * @opts
336
+ * - `pem` (boolean) -- return a `PUBLIC KEY` PEM string instead of DER.
337
+ * @example
338
+ * var pair = await pki.key.generate("Ed25519");
339
+ * var spki = await pki.key.publicFromPrivate(await pki.key.export(pair.privateKey));
340
+ */
341
+ async function publicFromPrivate(privateKey, opts) {
342
+ opts = opts || {};
343
+ var der = await _toPrivateKeyDer(privateKey);
344
+ var spki;
345
+ try {
346
+ var priv = nodeCrypto.createPrivateKey({ key: der, format: "der", type: "pkcs8" });
347
+ spki = nodeCrypto.createPublicKey(priv).export({ format: "der", type: "spki" });
348
+ } catch (e) { throw _err("key/bad-input", "could not derive the public key from the private key", e); }
349
+ return opts.pem ? pkix.pemEncode(spki, "PUBLIC KEY", PemError) : spki;
350
+ }
351
+
352
+ // ---- import helpers --------------------------------------------------------
353
+
354
+ // Auto-detect a key input by structure (works for a raw DER Buffer or a label-agnostic PEM): a PrivateKeyInfo
355
+ // leads with an INTEGER version; an SPKI is SEQ{ algId, BIT STRING }; an EncryptedPrivateKeyInfo is
356
+ // SEQ{ algId, OCTET STRING }. Returns { format, der }.
357
+ function _detectKeyInput(input) {
358
+ var der = pkix.coerceToDer(input, { pemLabel: null, PemError: PemError, ErrorClass: KeyError, prefix: "key" });
359
+ var root;
360
+ try { root = asn1.decode(der); } catch (e) { throw _err("key/bad-input", "the input is not DER or a PEM key", e); }
361
+ var kids = root.children || [];
362
+ if (kids.length >= 1 && kids[0].tagClass === "universal" && kids[0].tagNumber === asn1.TAGS.INTEGER) return { format: "pkcs8", der: der };
363
+ if (kids.length === 2 && kids[1].tagClass === "universal" && kids[1].tagNumber === asn1.TAGS.BIT_STRING) return { format: "spki", der: der };
364
+ if (kids.length === 2 && kids[1].tagClass === "universal" && kids[1].tagNumber === asn1.TAGS.OCTET_STRING) return { format: "encrypted", der: der };
365
+ throw _err("key/bad-input", "the input is not a recognized PKCS#8, SPKI, or EncryptedPrivateKeyInfo");
366
+ }
367
+
368
+ // The AlgorithmIdentifier OID from a detected key: privateKeyAlgorithm (PKCS#8) or algorithm (SPKI).
369
+ function _readAlgOid(detected) {
370
+ try {
371
+ if (detected.format === "pkcs8") return pkcs8.parse(detected.der).privateKeyAlgorithm.oid;
372
+ // SPKI: SEQUENCE { algorithm AlgorithmIdentifier SEQUENCE { OID, ... }, subjectPublicKey BIT STRING }.
373
+ // Validate the algorithm SEQUENCE shape before dereferencing its OID -- a malformed SPKI must be a typed
374
+ // reject, never a raw children[] fault.
375
+ var alg = asn1.decode(detected.der).children[0];
376
+ if (!alg || alg.tagClass !== "universal" || alg.tagNumber !== asn1.TAGS.SEQUENCE || !alg.children || !alg.children.length) {
377
+ throw _err("key/bad-input", "malformed SubjectPublicKeyInfo algorithm identifier");
378
+ }
379
+ return asn1.read.oid(alg.children[0]);
380
+ } catch (e) {
381
+ if (e instanceof KeyError) throw e;
382
+ throw _err("key/bad-input", "the key algorithm could not be read for inference", e);
383
+ }
384
+ }
385
+
386
+ function _inferAlgorithm(detected) {
387
+ var algOid = _readAlgOid(detected);
388
+ var a = INFER_ALG[algOid];
389
+ if (a) return a;
390
+ throw _err("key/unsupported-algorithm", "cannot infer the WebCrypto algorithm for " + (oid.name(algOid) || algOid) +
391
+ " (RSA and EC are ambiguous between signing and key agreement -- pass opts.algorithm)");
392
+ }
393
+
394
+ // Default key usages by algorithm CLASS. The name is normalized to upper case first: WebCrypto matches
395
+ // algorithm names case-insensitively, so a caller passing { name: "x25519" } / "ml-kem-768" must land on the
396
+ // same usage class as the canonical spelling rather than fall through to the signing default.
397
+ function _importUsages(name, isPublic) {
398
+ var n = String(name == null ? "" : name).toUpperCase();
399
+ if (n === "X25519" || n === "X448" || n === "ECDH") return isPublic ? [] : ["deriveBits", "deriveKey"];
400
+ if (/^ML-KEM/.test(n)) return isPublic ? ["encapsulateBits"] : ["decapsulateBits"];
401
+ if (n === "RSA-OAEP") return isPublic ? ["encrypt"] : ["decrypt"];
402
+ return isPublic ? ["verify"] : ["sign"]; // signing default (Ed / ECDSA / RSA-* sign / ML-DSA / SLH-DSA)
403
+ }
404
+
405
+ function _generateUsages(name) {
406
+ var n = String(name == null ? "" : name).toUpperCase();
407
+ if (n === "X25519" || n === "X448" || n === "ECDH") return ["deriveBits", "deriveKey"];
408
+ if (/^ML-KEM/.test(n)) return ["encapsulateBits", "decapsulateBits"];
409
+ if (n === "RSA-OAEP") return ["encrypt", "decrypt"];
410
+ return ["sign", "verify"];
411
+ }
412
+
413
+ module.exports = {
414
+ encrypt: encrypt,
415
+ decrypt: decrypt,
416
+ export: export_,
417
+ import: import_,
418
+ generate: generate,
419
+ publicFromPrivate: publicFromPrivate,
420
+ };
@@ -39,6 +39,7 @@ var x509 = require("./schema-x509");
39
39
  var crl = require("./schema-crl");
40
40
  var ocsp = require("./schema-ocsp");
41
41
  var ocspVerify = require("./ocsp-verify");
42
+ var crlVerify = require("./crl-verify");
42
43
  var guard = require("./guard-all");
43
44
  var constants = require("./constants");
44
45
  var validator = require("./validator-all");
@@ -1762,7 +1763,7 @@ function crlChecker(crls) {
1762
1763
  // replayed old CRL must not read "good". Treat it as unusable.
1763
1764
  if (!theCrl.nextUpdate || theCrl.nextUpdate < time) continue; // stale / no bound
1764
1765
 
1765
- var sigOk = await verifyCrlSignature(theCrl, issuer);
1766
+ var sigOk = await crlVerify.verifyCrlSignature(theCrl, issuer.workingPublicKey);
1766
1767
  if (!sigOk) continue; // unverifiable -> not authoritative
1767
1768
 
1768
1769
  // The CRL is now authoritative + current + verified. An authoritative
@@ -1853,10 +1854,10 @@ function _verifyWithSpki(sigAlg, rawSig, spkiBytes, tbsBytes) {
1853
1854
  }).then(function (ok) { return ok === true; }, function () { return false; });
1854
1855
  }
1855
1856
 
1856
- function verifyCrlSignature(theCrl, issuer) {
1857
- if (!guard.crypto.isOctetAligned(theCrl.signatureValue)) return Promise.resolve(false); // non-octet-aligned signature
1858
- return _verifyWithSpki(theCrl.signatureAlgorithm, theCrl.signatureValue.bytes, issuer.workingPublicKey, theCrl.tbsBytes);
1859
- }
1857
+ // Inject this validator's signature engine into the shared internal crl-verify seam, so pki.crl.verify and
1858
+ // pki.path.crlChecker both route through this ONE engine (no second, weaker CRL verifier) without exposing
1859
+ // the seam on the public pki.path surface.
1860
+ crlVerify.setEngine(_verifyWithSpki);
1860
1861
 
1861
1862
  // ---- the OCSP revocation checker (RFC 6960) -------------------------------
1862
1863
 
package/lib/pbes2.js ADDED
@@ -0,0 +1,136 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- the RFC 8018 PBES2 / PBKDF2 primitives, the ONE home shared by pki.cms EncryptedData / PWRI
6
+ // password encryption and pki.key EncryptedPrivateKeyInfo (RFC 5958). Error-factory-parameterized like the
7
+ // guard family: every reject goes through the CALLER's `E(code, msg, cause)` full-code factory plus a domain
8
+ // `prefix`, so pki.cms keeps cms/* codes and pki.key keeps key/* codes off ONE implementation. Extracted
9
+ // behavior-preservingly from cms-encrypt.js + cms-decrypt.js -- the shipped CMS PBES2 tests are the guard.
10
+ // A shape here that CANNOT route through a guard stays with its caller; the reusable PBES2 shape lives here
11
+ // so a fourth copy (the drift a reviewer flags) cannot re-accrete.
12
+
13
+ var nodeCrypto = require("crypto");
14
+ var asn1 = require("./asn1-der");
15
+ var oid = require("./oid");
16
+ var guard = require("./guard-all");
17
+ var C = require("./constants");
18
+
19
+ var b = asn1.build;
20
+ function O(n) { return oid.byName(n); }
21
+
22
+ // PBKDF2 prf tables: the OID name -> node digest (encrypt names the prf) and the OID -> node digest (decrypt
23
+ // reads the OID). One source, both directions.
24
+ var PRF_NODE_BY_NAME = { hmacWithSHA1: "sha1", hmacWithSHA256: "sha256", hmacWithSHA384: "sha384", hmacWithSHA512: "sha512" };
25
+ var PRF_NODE_BY_OID = {}; Object.keys(PRF_NODE_BY_NAME).forEach(function (n) { PRF_NODE_BY_OID[O(n)] = PRF_NODE_BY_NAME[n]; });
26
+
27
+ // content-encryption OID -> AES key bits (CBC + GCM). The PBES2 encryptionScheme + CMS content cipher table.
28
+ var CONTENT_KEYBITS = {};
29
+ [["aes128-CBC", 128], ["aes192-CBC", 192], ["aes256-CBC", 256], ["aes128-GCM", 128], ["aes192-GCM", 192], ["aes256-GCM", 256]].forEach(function (r) { CONTENT_KEYBITS[O(r[0])] = r[1]; });
30
+
31
+ // A password is an octet string (RFC 8018 sec. 2): a string is UTF-8-encoded deterministically (correct for
32
+ // non-ASCII, and byte-identical to OpenSSL), a Buffer/Uint8Array used verbatim.
33
+ function passwordBytes(p, E, prefix) {
34
+ if (Buffer.isBuffer(p)) return p;
35
+ if (p instanceof Uint8Array) return Buffer.from(p);
36
+ if (typeof p === "string") return Buffer.from(p, "utf8");
37
+ throw E(prefix + "/bad-input", "a password must be a string, Buffer, or Uint8Array");
38
+ }
39
+
40
+ // iterationCount for a PRODUCED PBES2/PWRI structure: a positive integer at or below the cap the decryptor
41
+ // enforces, so a structure we emit is always one we can read back (config-time throw on a bad value).
42
+ function assertIterations(n, E, prefix) {
43
+ if (typeof n !== "number" || !isFinite(n) || n < 1 || Math.floor(n) !== n) throw E(prefix + "/bad-input", "iterations must be a positive integer");
44
+ if (n > C.LIMITS.PBKDF2_MAX_ITERATIONS) throw E(prefix + "/bad-input", "iterations exceeds the " + C.LIMITS.PBKDF2_MAX_ITERATIONS + " cap");
45
+ return n;
46
+ }
47
+
48
+ // Bound a PRODUCED salt to the same cap the decryptor enforces.
49
+ function assertSalt(salt, E, prefix) {
50
+ guard.limits.byteCap(salt, C.LIMITS.PBKDF2_MAX_SALT, E, prefix + "/bad-input", "salt");
51
+ return salt;
52
+ }
53
+
54
+ function prfNodeByName(prf, E, prefix) { if (!PRF_NODE_BY_NAME[prf]) throw E(prefix + "/bad-input", "unsupported prf " + JSON.stringify(prf)); return PRF_NODE_BY_NAME[prf]; }
55
+ function prfNodeByOid(oidStr, E, prefix) { if (!PRF_NODE_BY_OID[oidStr]) throw E(prefix + "/unsupported-algorithm", "unsupported PBKDF2 prf " + oidStr); return PRF_NODE_BY_OID[oidStr]; }
56
+
57
+ // PBKDF2-params { salt OCTET STRING, iterationCount INTEGER, prf DEFAULT hmacWithSHA1 }. prf equal to the
58
+ // DEFAULT (hmacWithSHA1) MUST be omitted on encode (X.690 sec. 11.5 / RFC 8018 App. A.2); every other prf is
59
+ // an hmacWithSHA_n AlgorithmIdentifier carrying NULL parameters.
60
+ function pbkdf2ParamsSeq(salt, iterations, prf) {
61
+ var kids = [b.octetString(salt), b.integer(BigInt(iterations))];
62
+ if (prf !== "hmacWithSHA1") kids.push(b.sequence([b.oid(O(prf)), b.nullValue()]));
63
+ return b.sequence(kids);
64
+ }
65
+
66
+ // The PBES2 AlgorithmIdentifier: SEQUENCE { id-PBES2, SEQUENCE { keyDerivationFunc PBKDF2, encryptionScheme } }.
67
+ // The ONE home for the CMS EncryptedData contentEncryptionAlgorithm AND the RFC 5958 EncryptedPrivateKeyInfo
68
+ // encryptionAlgorithm -- both wrap the same PBES2 structure around a PBKDF2 KDF plus an AES-CBC scheme whose
69
+ // IV rides in the encryptionScheme parameter. `cipherName` is a registry NAME (an AES-CBC OID name).
70
+ function pbes2AlgId(salt, iterations, prf, cipherName, iv) {
71
+ var kdf = b.sequence([b.oid(O("pbkdf2")), pbkdf2ParamsSeq(salt, iterations, prf)]);
72
+ var encScheme = b.sequence([b.oid(O(cipherName)), b.octetString(iv)]);
73
+ return b.sequence([b.oid(O("pbes2")), b.sequence([kdf, encScheme])]);
74
+ }
75
+
76
+ // SEQUENCE structural guards -- a malformed (primitive / too-short) attacker-controlled parameter SEQUENCE is
77
+ // a typed E(prefix + "/bad-input"), never a raw `.children` dereference fault (a PBES2 structure is public,
78
+ // not a decrypt oracle).
79
+ function requireChildren(node, minLen, what, E, prefix) {
80
+ if (!node || !node.children || node.children.length < minLen) throw E(prefix + "/bad-input", "malformed " + what);
81
+ return node.children;
82
+ }
83
+ function seqChildren(paramsDer, minLen, what, E, prefix) {
84
+ if (paramsDer == null) throw E(prefix + "/bad-input", "missing " + what);
85
+ return requireChildren(asn1.decode(paramsDer), minLen, what, E, prefix);
86
+ }
87
+
88
+ // Strict PBKDF2-params decode: salt (capped BEFORE derivation), iterationCount (positiveInt31 + cap, downward-
89
+ // overridable via a validated-positive-int opts.maxIterations so Math.min(NaN, cap) cannot silently disable
90
+ // the DoS cap), prf. Returns { salt, iterations, prfNode }.
91
+ function parsePbkdf2Params(paramsDer, opts, E, prefix, strictPrf) {
92
+ var node = Buffer.isBuffer(paramsDer) ? asn1.decode(paramsDer) : paramsDer;
93
+ var kids = requireChildren(node, 2, "PBKDF2 parameters", E, prefix);
94
+ var salt = asn1.read.octetString(kids[0]);
95
+ if (salt.length > C.LIMITS.PBKDF2_MAX_SALT) throw E(prefix + "/bad-input", "PBKDF2 salt exceeds the " + C.LIMITS.PBKDF2_MAX_SALT + "-octet cap");
96
+ var iterations = guard.range.positiveInt31(asn1.read.integer(kids[1]), E, prefix + "/bad-input", "PBKDF2 iterationCount");
97
+ var cap = C.LIMITS.PBKDF2_MAX_ITERATIONS;
98
+ if (opts && opts.maxIterations != null) {
99
+ if (typeof opts.maxIterations !== "number" || !isFinite(opts.maxIterations) || opts.maxIterations < 1 || Math.floor(opts.maxIterations) !== opts.maxIterations) throw E(prefix + "/bad-input", "maxIterations must be a positive integer");
100
+ cap = Math.min(opts.maxIterations, cap);
101
+ }
102
+ if (iterations > cap) throw E(prefix + "/iteration-limit", "PBKDF2 iterationCount " + iterations + " exceeds the cap " + cap);
103
+ var prfNode = "sha1";
104
+ for (var i = 2; i < node.children.length; i++) {
105
+ var ch = node.children[i];
106
+ if (ch.tagClass === "universal" && ch.tagNumber === asn1.TAGS.SEQUENCE) {
107
+ var prfOid = asn1.read.oid(ch.children[0]);
108
+ // X.690 sec. 11.5 / RFC 8018 App. A.2: a prf equal to the DEFAULT (hmacWithSHA1 with NULL parameters)
109
+ // MUST be omitted -- an explicit encoding of it is non-canonical. A strictPrf caller rejects it.
110
+ if (strictPrf && prfOid === O("hmacWithSHA1") && ch.children.length > 1 && ch.children[1].tagClass === "universal" && ch.children[1].tagNumber === asn1.TAGS.NULL) {
111
+ throw E(prefix + "/bad-input", "a PBKDF2 prf equal to the default hmacWithSHA1 must be omitted (X.690 sec. 11.5)");
112
+ }
113
+ prfNode = prfNodeByOid(prfOid, E, prefix);
114
+ }
115
+ }
116
+ return { salt: salt, iterations: iterations, prfNode: prfNode };
117
+ }
118
+
119
+ // AES-CBC content encryption / decryption (PKCS#7 pad). decrypt's final() throws on bad pad -- the caller
120
+ // collapses that into its uniform decrypt-failed verdict.
121
+ function cbcEncrypt(key, iv, plaintext, keyBits) {
122
+ var c = nodeCrypto.createCipheriv("aes-" + keyBits + "-cbc", key, iv);
123
+ return Buffer.concat([c.update(plaintext), c.final()]);
124
+ }
125
+ function cbcDecrypt(key, iv, ct, keyBits) {
126
+ var d = nodeCrypto.createDecipheriv("aes-" + keyBits + "-cbc", key, iv);
127
+ return Buffer.concat([d.update(ct), d.final()]);
128
+ }
129
+
130
+ module.exports = {
131
+ passwordBytes: passwordBytes, assertIterations: assertIterations, assertSalt: assertSalt,
132
+ prfNodeByName: prfNodeByName, prfNodeByOid: prfNodeByOid,
133
+ pbkdf2ParamsSeq: pbkdf2ParamsSeq, pbes2AlgId: pbes2AlgId, parsePbkdf2Params: parsePbkdf2Params,
134
+ requireChildren: requireChildren, seqChildren: seqChildren,
135
+ cbcEncrypt: cbcEncrypt, cbcDecrypt: cbcDecrypt, CONTENT_KEYBITS: CONTENT_KEYBITS,
136
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -72,7 +72,7 @@
72
72
  "check:vendor-currency": "node scripts/check-vendor-currency.js"
73
73
  },
74
74
  "devDependencies": {
75
- "c8": "11.0.0",
75
+ "c8": "12.0.0",
76
76
  "esbuild": "0.28.1",
77
77
  "eslint": "10.7.0"
78
78
  }
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:7df7ec92-2c30-46ba-acd2-036a52d58ca2",
5
+ "serialNumber": "urn:uuid:0bc607cc-142c-4a49-a525-90a6f0216e38",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-18T08:43:20.402Z",
8
+ "timestamp": "2026-07-23T18:57:16.468Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/pki@0.3.8",
22
+ "bom-ref": "@blamejs/pki@0.3.10",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.8",
25
+ "version": "0.3.10",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
29
- "purl": "pkg:npm/%40blamejs/pki@0.3.8",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.3.10",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/pki@0.3.8",
57
+ "ref": "@blamejs/pki@0.3.10",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]