@blamejs/core 0.17.23 → 0.18.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.
@@ -5,42 +5,52 @@
5
5
  * mtls-engine-default — pure-JS X.509 engine wired into b.mtlsCa.
6
6
  *
7
7
  * Implements the engine contract documented at the top of lib/mtls-ca.js:
8
- * generateCa({ generation }) -> { caCertPem, caKeyPem }
9
- * signClientCert({ cn, validityDays,
10
- * caCertPem, caKeyPem }) -> { cert, key, ca, issuedAt, expiresAt }
11
- * packageP12({ cn, password, validityDays,
12
- * caCertPem, caKeyPem }) -> { p12, certPem, issuedAt, expiresAt }
8
+ * generateCa({ generation, algorithm }) -> { caCertPem, caKeyPem }
9
+ * signClientCert({ cn, validityDays, usage, sans, algorithm,
10
+ * caCertPem, caKeyPem }) -> { cert, key, ca, issuedAt, expiresAt }
11
+ * packageP12({ cn, password, validityDays, algorithm,
12
+ * caCertPem, caKeyPem }) -> { p12, certPem, issuedAt, expiresAt }
13
+ * generateCrl({ caCertPem, caKeyPem,
14
+ * revocations, thisUpdate,
15
+ * nextUpdate }) -> CRL PEM
13
16
  *
14
- * Backed by lib/vendor/pki.cjs (vendored @peculiar/x509 + pkijs +
15
- * reflect-metadata + ASN.1 schema chain). node:crypto.webcrypto is bound
16
- * inside the bundle entry; nothing here calls openssl CLI.
17
+ * Backed by lib/vendor/blamejs-pki.cjs (@blamejs/pki zero-dep pure-CJS
18
+ * X.509 / CRL / PKCS#12 toolkit with a built-in WebCrypto over node:crypto).
19
+ * No openssl CLI is invoked.
17
20
  *
18
21
  * Algorithm envelope:
19
- * CA + leaf signatures: ECDSA P-384 + SHA-384
20
- * PKCS#12 key bag : PBES2 + AES-256-CBC + PBKDF2-HMAC-SHA-512, 2,000,000 iter
21
- * PKCS#12 cert bag : same as key bag
22
- * PKCS#12 outer MAC : HMAC-SHA-512 + PBKDF2, 2,000,000 iter
23
- *
24
- * The X.509 ecosystem doesn't yet accept SLH-DSA / ML-DSA on shipping
25
- * client certs, so the cert sigs stay classical ECDSA-P384 — matching
26
- * the framework's hybrid KEM posture rather than its standalone PQ
27
- * signing posture. Swap atomically when browsers + OS cert stores can
28
- * verify a PQ algorithm; bump CA_GENERATION on the same release so
29
- * b.mtlsCa.status reports legacy correctly.
22
+ * CA + leaf signatures: ML-DSA-87 by default (FIPS 204). node:tls verifies
23
+ * ML-DSA certificate chains + CertificateVerify on the
24
+ * supported Node LTS (OpenSSL 3.5), so PQC-signed mTLS
25
+ * certificates complete a real mutual-auth handshake.
26
+ * Operators whose peers are not yet on OpenSSL 3.5 pass
27
+ * algorithm: "ECDSA-P384-SHA384" (b.mtlsCa.create({ algorithm })
28
+ * threads it into both CA generation and leaf issuance)
29
+ * for a universally-interoperable classical CA. A pin is
30
+ * per-call: it never mutates the process-wide default, so
31
+ * one classical CA cannot downgrade another CA's ML-DSA-87
32
+ * default. SLH-DSA is intentionally not offered here:
33
+ * OpenSSL rejects it in the TLS handshake ("unknown
34
+ * certificate type").
35
+ * PKCS#12 key + cert bags: PBES2 + AES-256-CBC + PBKDF2-HMAC-SHA-512, 2,000,000 iter
36
+ * PKCS#12 outer MAC : follows the cert tier — PBMAC1 + PBKDF2-HMAC-SHA-512
37
+ * @ 2,000,000 iter (RFC 9579) for the PQC default; the
38
+ * classical ECDSA-P384 bridge uses the legacy-importable
39
+ * RFC 7292 App. B HMAC-SHA-512 MacData so a pre-OpenSSL-3.5
40
+ * peer can verify it
30
41
  */
31
42
 
32
43
  var nodeCrypto = require("node:crypto");
33
-
34
- var pki = require("./vendor/pki.cjs");
44
+ var pki = require("./vendor/blamejs-pki.cjs");
35
45
 
36
46
  var C = require("./constants");
37
47
  var bCrypto = require("./crypto");
48
+ var ipUtils = require("./ip-utils");
38
49
  var numericBounds = require("./numeric-bounds");
50
+ var safeBuffer = require("./safe-buffer");
39
51
  var { FrameworkError } = require("./framework-error");
40
52
 
41
- var x509 = pki.x509;
42
- var pkijs = pki.pkijs;
43
- var webcrypto = pki.crypto;
53
+ var subtle = pki.webcrypto.subtle;
44
54
 
45
55
  class MtlsEngineError extends FrameworkError {
46
56
  constructor(code, message) {
@@ -53,164 +63,217 @@ class MtlsEngineError extends FrameworkError {
53
63
 
54
64
  var CA_KEY_USAGES = ["sign", "verify"];
55
65
 
56
- // Algorithm priority — each entry probed at first use; the first one
57
- // the vendored x509 library AND webcrypto can both honour wins.
58
- // Ordered highest-PQC-posture first so the engine self-upgrades the
59
- // moment the vendor bundle gains PQ-sig X.509 support.
66
+ // Algorithm priority — each entry probed at first use; the first one the
67
+ // vendored PKI toolkit AND webcrypto can both honour wins. Ordered
68
+ // highest-PQC-posture first so the engine issues post-quantum certificates
69
+ // by default. Every listed candidate has been confirmed to complete a real
70
+ // node:tls mutual-auth handshake on the supported Node LTS.
60
71
  //
61
- // keyAlg: passed to webcrypto.subtle.generateKey + import
62
- // sigAlg: passed to x509.X509CertificateGenerator.create
63
- // label : surfaced via b.mtlsCa.status() so operators can audit
64
- // which algorithm the in-flight CA generation is using
72
+ // keyAlg : passed to webcrypto.subtle.generateKey; the certificate signature
73
+ // algorithm is resolved from the signing key by pki.x509.sign.
74
+ // label : surfaced via b.mtlsCa.status() so operators can audit which
75
+ // algorithm the in-flight CA generation is using, and passed to
76
+ // generateCa({ algorithm }) to pin a specific one.
65
77
  var ALG_CANDIDATES = [
66
- // Pure-PQC stateless hash-based matches lib/audit-sign's posture.
67
- // FIPS 205 (SPHINCS+ family). Awaiting node:tls + browser cert-store
68
- // verification support; currently issuance-only on most stacks.
69
- {
70
- label: "SLH-DSA-SHAKE-256f",
71
- keyAlg: { name: "SLH-DSA-SHAKE-256f" },
72
- sigAlg: { name: "SLH-DSA-SHAKE-256f" },
73
- posture: "pqc-pure",
74
- },
75
- {
76
- label: "SLH-DSA-SHAKE-128f",
77
- keyAlg: { name: "SLH-DSA-SHAKE-128f" },
78
- sigAlg: { name: "SLH-DSA-SHAKE-128f" },
79
- posture: "pqc-pure",
80
- },
81
- // Pure-PQC lattice — FIPS 204 (Dilithium family). Smaller than SLH-DSA,
82
- // accepted by the same emerging cert-store deployments.
78
+ // Pure-PQC latticeFIPS 204 (ML-DSA / Dilithium family). Verified end to
79
+ // end in a node:tls mutual-auth handshake (chain + CertificateVerify).
83
80
  {
84
81
  label: "ML-DSA-87",
85
82
  keyAlg: { name: "ML-DSA-87" },
86
- sigAlg: { name: "ML-DSA-87" },
87
83
  posture: "pqc-pure",
88
84
  },
89
85
  {
90
86
  label: "ML-DSA-65",
91
87
  keyAlg: { name: "ML-DSA-65" },
92
- sigAlg: { name: "ML-DSA-65" },
93
88
  posture: "pqc-pure",
94
89
  },
95
- // Documented bridge — used until cert ecosystems verify the above.
96
- // The framework's hybrid KEM posture (X25519MLKEM768) covers handshake
97
- // KEX; these certs sign with ECDSA P-384 + SHA-384.
90
+ // Classical bridge — for peers not yet on OpenSSL 3.5. Opt in with
91
+ // generateCa({ algorithm: "ECDSA-P384-SHA384" }).
98
92
  {
99
93
  label: "ECDSA-P384-SHA384",
100
94
  keyAlg: { name: "ECDSA", namedCurve: "P-384" },
101
- sigAlg: { name: "ECDSA", hash: "SHA-384" },
102
95
  posture: "classical",
96
+ // The signature digest MUST be passed to pki.x509.sign / pki.crl.sign — the
97
+ // toolkit defaults an EC key to SHA-256, which would silently downgrade this
98
+ // bridge below its SHA-384 posture (and below the framework's no-SHA-256
99
+ // default rule). ML-DSA needs no digest (it is no-prehash: the toolkit forces
100
+ // SHA-512 and ignores an explicit digest), so only the classical entry sets it.
101
+ digest: "sha384",
103
102
  },
104
103
  ];
105
104
 
106
- // First-call probe cache. Re-runs after engine reload (test reset path).
105
+ // First-call probe cache for the process-wide DEFAULT (never written by a per-call
106
+ // pin, so a pin can't downgrade the default). Re-runs after engine reload.
107
107
  var _selectedAlg = null;
108
+ // The most-recent RESOLVED algorithm (pinned OR default) — reporting only, so
109
+ // algorithmEnvelope() can describe a pinned selection accurately without poisoning
110
+ // the default cache above.
111
+ var _lastSelectedAlg = null;
108
112
 
109
113
  async function _probeCandidate(c) {
110
114
  try {
111
- var pair = await webcrypto.subtle.generateKey(c.keyAlg, true, CA_KEY_USAGES);
115
+ var pair = await subtle.generateKey(c.keyAlg, true, CA_KEY_USAGES);
116
+ /* c8 ignore next -- defensive: webcrypto.generateKey always resolves a valid keypair here */
112
117
  if (!pair || !pair.publicKey) return false;
113
- // Also confirm the x509 generator accepts the sigAlg by issuing a
114
- // throwaway self-signed cert. Some keyAlgs work in webcrypto but
115
- // aren't yet wired through @peculiar/x509's encoder without this
116
- // round-trip we'd select an algorithm we can't actually mint certs
117
- // with and hit a confusing failure on first issuance.
118
- await x509.X509CertificateGenerator.create({
119
- serialNumber: "01",
120
- subject: "CN=probe",
121
- issuer: "CN=probe",
118
+ // Confirm the toolkit can also mint a certificate under this key — some
119
+ // key algorithms keygen in webcrypto but aren't wired through the X.509
120
+ // signer, and selecting one we can't issue with would surface a
121
+ // confusing failure on first real issuance.
122
+ var spki = Buffer.from(await subtle.exportKey("spki", pair.publicKey));
123
+ await pki.x509.sign({
124
+ subject: "probe",
125
+ subjectPublicKey: spki,
126
+ serialNumber: "0x01",
122
127
  notBefore: new Date(),
123
128
  notAfter: new Date(Date.now() + C.TIME.seconds(1)),
124
- signingAlgorithm: c.sigAlg,
125
- publicKey: pair.publicKey,
126
- signingKey: pair.privateKey,
127
- });
129
+ extensions: { basicConstraints: { cA: true } },
130
+ }, { key: pair.privateKey }, { pem: true });
128
131
  return true;
132
+ /* c8 ignore start -- probe never throws: every listed candidate algorithm is honoured by the runtime's OpenSSL 3.5+ */
129
133
  } catch (_e) {
130
134
  return false;
131
135
  }
136
+ /* c8 ignore stop */
137
+ }
138
+
139
+ function _emitAlgorithmSelected(c, candidatesProbed) {
140
+ setImmediate(function () {
141
+ try {
142
+ var auditMod = require("./audit"); // allow:inline-require — circular-load defense
143
+ auditMod.safeEmit({
144
+ action: "mtls.engine.algorithm_selected",
145
+ outcome: "success",
146
+ metadata: { label: c.label, posture: c.posture, candidatesProbed: candidatesProbed },
147
+ });
148
+ /* c8 ignore next -- belt-and-suspenders: audit.safeEmit is itself drop-silent, so this catch is unreachable */
149
+ } catch (_e) { /* drop-silent */ }
150
+ });
132
151
  }
133
152
 
134
- async function _selectAlgorithm() {
135
- if (_selectedAlg) return _selectedAlg;
153
+ // Resolve the signing algorithm. With no argument the highest-posture
154
+ // candidate that probes clean is cached and reused. `preferredLabel` pins a
155
+ // specific candidate (the generateCa({ algorithm }) opt-in) — it must exist
156
+ // and probe clean, else the call throws rather than silently downgrading.
157
+ async function _selectAlgorithm(preferredLabel) {
158
+ if (preferredLabel) {
159
+ var wanted = null;
160
+ for (var w = 0; w < ALG_CANDIDATES.length; w++) {
161
+ if (ALG_CANDIDATES[w].label === preferredLabel || ALG_CANDIDATES[w].keyAlg.name === preferredLabel) {
162
+ wanted = ALG_CANDIDATES[w];
163
+ break;
164
+ }
165
+ }
166
+ if (!wanted) {
167
+ throw new MtlsEngineError("mtls-engine/unknown-algorithm",
168
+ "unknown algorithm " + JSON.stringify(preferredLabel) + " — supported: " +
169
+ ALG_CANDIDATES.map(function (c) { return c.label; }).join(", "));
170
+ }
171
+ /* c8 ignore start -- unreachable: every pinnable candidate probes clean on the runtime's OpenSSL 3.5+ */
172
+ if (!(await _probeCandidate(wanted))) {
173
+ throw new MtlsEngineError("mtls-engine/algorithm-unavailable",
174
+ "algorithm " + JSON.stringify(preferredLabel) + " is not available in this runtime");
175
+ }
176
+ /* c8 ignore stop */
177
+ // A pinned algorithm is per-call and MUST NOT be cached as the process
178
+ // default. `_selectedAlg` is the shared fallback the no-argument path below
179
+ // returns; writing a classical pin here would make a single
180
+ // generateCa({ algorithm: "ECDSA-P384-SHA384" }) silently downgrade every
181
+ // later default CA/leaf off the ML-DSA-87 PQC-first default in the same
182
+ // process. Callers thread a pin explicitly through generateCa /
183
+ // signClientCert instead (b.mtlsCa.create({ algorithm })).
184
+ _lastSelectedAlg = wanted; // reporting only — does NOT touch the default cache
185
+ _emitAlgorithmSelected(wanted, 1);
186
+ return wanted;
187
+ }
188
+ if (_selectedAlg) { _lastSelectedAlg = _selectedAlg; return _selectedAlg; }
136
189
  for (var i = 0; i < ALG_CANDIDATES.length; i++) {
137
190
  var c = ALG_CANDIDATES[i];
138
- var ok = await _probeCandidate(c);
139
- if (ok) {
191
+ if (await _probeCandidate(c)) {
140
192
  _selectedAlg = c;
141
- // Emit an audit row at first probe so operators see which
142
- // algorithm landed without having to call b.mtlsCa.status().
143
- // Pre-PQC ecosystems land on the ECDSA-P384 bridge silently;
144
- // this puts the choice on the chain so compliance dashboards
145
- // alert when an operator's deployment hasn't yet picked up the
146
- // PQ-signed-cert capability the framework would otherwise
147
- // prefer.
148
- setImmediate(function () {
149
- try {
150
- var auditMod = require("./audit"); // allow:inline-require — circular-load defense
151
- auditMod.safeEmit({
152
- action: "mtls.engine.algorithm_selected",
153
- outcome: "success",
154
- metadata: { label: c.label, posture: c.posture, candidatesProbed: i + 1 },
155
- });
156
- } catch (_e) { /* drop-silent */ }
157
- });
193
+ _lastSelectedAlg = c;
194
+ _emitAlgorithmSelected(c, i + 1);
158
195
  return c;
159
196
  }
160
197
  }
161
- // Should never happen ECDSA-P384-SHA384 is universal.
198
+ /* c8 ignore start -- unreachable: ECDSA-P384-SHA384 is universal, so the candidate loop always returns first */
199
+ // Should never happen — ECDSA-P384 is universal.
162
200
  throw new MtlsEngineError("mtls-engine/no-algorithm",
163
201
  "no candidate algorithm passed the webcrypto + x509 probe");
202
+ /* c8 ignore stop */
164
203
  }
165
204
 
166
- // Backwards-compat shape for callers that read these directly. Resolved
167
- // lazily so the algorithm choice is the first-probe result.
168
- var CA_KEY_ALG = null;
169
- var CA_SIG_ALG = null;
170
-
171
- // AES-256 key length expressed in bits — webcrypto's contract for the
172
- // `length` field of AES-CBC. Hex form keeps the protocol identifier
173
- // out of the byte-shape detector (the value isn't a byte quantity).
174
- var P12_CONTENT_ENC = { name: "AES-CBC", length: 0x100 };
175
- var P12_KDF_HASH = "SHA-512";
176
- var P12_MAC_HASH = "SHA-512";
177
- // PKCS#12 PBKDF2 iteration count — protocol-fixed cost parameter, not
178
- // a byte quantity. Hex form per the same rationale as P12_CONTENT_ENC.length.
179
- var P12_ITER = 0x1E8480;
205
+ // PKCS#12 protection envelope. The cipher / PRF / MAC identifiers are
206
+ // protocol-fixed strings, and the iteration count is a cost parameter, not a
207
+ // byte quantity — hex form keeps it out of the byte-shape detector.
208
+ var P12_CIPHER = "aes-256-cbc";
209
+ var P12_PRF = "hmacWithSHA512";
210
+ var P12_MAC_HASH = "sha512";
211
+ var P12_ITER = 0x1E8480; // 2,000,000 (PBMAC1 outer MAC + PBES2 bag KDF)
212
+ // The classic App. B.2 MacData KDF (RFC 7292) is a synchronous per-iteration
213
+ // hash loop, so it is capped ~10x below PBMAC1's native PBKDF2 — 1,000,000 is
214
+ // the toolkit's classic-MAC ceiling (~1s wall clock). Used only for the
215
+ // classical ECDSA-P384 bridge's legacy-importable MacData.
216
+ var P12_CLASSIC_MAC_ITER = 0xF4240; // 1,000,000
180
217
 
181
218
  var CA_VALIDITY_DAYS = 10 * 365; // 10y CA lifetime
182
219
  var LEAF_DEFAULT_DAYS = 365;
183
220
  var DEFAULT_CA_NAME = "blamejs CA";
184
- var BAG_ID_KEY = "1.2.840.113549.1.12.10.1.2"; // pkcs-12-pkcs-8ShroudedKeyBag
185
- var BAG_ID_CERT = "1.2.840.113549.1.12.10.1.3"; // pkcs-12-certBag
186
- var EKU_CLIENT_AUTH_OID = "1.3.6.1.5.5.7.3.2";
187
- var EKU_SERVER_AUTH_OID = "1.3.6.1.5.5.7.3.1";
188
-
189
- function _pemBlock(label, der) {
190
- var b64 = Buffer.from(der).toString("base64");
191
- return "-----BEGIN " + label + "-----\n" + b64.match(/.{1,64}/g).join("\n") + "\n-----END " + label + "-----\n";
221
+ // RFC 5280 removeFromCRL CRLReason — a delta-CRL un-revocation directive invalid
222
+ // in a full CRL; filtered out of full-CRL entries (see generateCrl).
223
+ var CRL_REMOVE_FROM_CRL = 8;
224
+
225
+ function _serial() {
226
+ // pki wants a decimal or 0x-hex integer; generateToken yields hex octets.
227
+ return "0x" + bCrypto.generateToken(C.BYTES.bytes(16));
192
228
  }
193
229
 
194
- async function _exportKeyPairToPem(keyPair) {
195
- var pkcs8 = await webcrypto.subtle.exportKey("pkcs8", keyPair.privateKey);
196
- var spki = await webcrypto.subtle.exportKey("spki", keyPair.publicKey);
197
- return {
198
- privatePem: _pemBlock("PRIVATE KEY", pkcs8),
199
- publicPem: _pemBlock("PUBLIC KEY", spki),
200
- };
230
+ function _ekuNames(usage) {
231
+ var names = [];
232
+ if (usage === "client" || usage === "both") names.push("clientAuth");
233
+ if (usage === "server" || usage === "both") names.push("serverAuth");
234
+ return names;
201
235
  }
202
236
 
203
- // Import a PEM private key regardless of its on-disk encoding.
204
- // Existing keys may be SEC1, PKCS#1, or PKCS#8 Node's createPrivateKey
205
- // normalises all three; webcrypto.importKey then reads the PKCS#8 DER.
206
- async function _importPemPrivateKey(pem, alg, usages, extractable) {
207
- var keyObj = nodeCrypto.createPrivateKey(pem);
208
- var pkcs8 = keyObj.export({ format: "der", type: "pkcs8" });
209
- return webcrypto.subtle.importKey("pkcs8", pkcs8, alg, !!extractable, usages);
237
+ // Pack a textual IP into the DER octet form pki's iPAddress GeneralName
238
+ // expects (4 octets for IPv4, 16 for IPv6). Composes lib/ip-utils.
239
+ function _packIp(value) {
240
+ var s = String(value);
241
+ if (ipUtils.isIPv4(s)) {
242
+ var quads = s.split(".");
243
+ var buf4 = Buffer.alloc(4);
244
+ for (var i = 0; i < 4; i++) {
245
+ var n = parseInt(quads[i], 10);
246
+ /* c8 ignore next -- unreachable: the IPv4 arm is entered only after isIPv4() validated each octet 0-255 */
247
+ if (!(n >= 0 && n <= 255)) throw new MtlsEngineError("mtls-engine/bad-san", "invalid IPv4 SAN " + JSON.stringify(s));
248
+ buf4[i] = n;
249
+ }
250
+ return buf4;
251
+ }
252
+ var groups;
253
+ /* c8 ignore next -- unreachable: expandIpv6Groups returns null for malformed input rather than throwing */
254
+ try { groups = ipUtils.expandIpv6Groups(s); } catch (_e) { groups = null; }
255
+ if (Array.isArray(groups) && groups.length === 8) {
256
+ var buf16 = Buffer.alloc(16);
257
+ for (var g = 0; g < 8; g++) buf16.writeUInt16BE(groups[g] & 0xffff, g * 2);
258
+ return buf16;
259
+ }
260
+ throw new MtlsEngineError("mtls-engine/bad-san", "invalid IP SAN " + JSON.stringify(s));
210
261
  }
211
262
 
212
- function _parseCertPem(pem) {
213
- return new x509.X509Certificate(pem);
263
+ // Map operator SAN entries (strings, optionally "DNS:" / "IP:" prefixed) to
264
+ // pki GeneralName objects.
265
+ function _sanEntry(s) {
266
+ var str = String(s);
267
+ if (/^DNS:/i.test(str)) return { dNSName: str.slice(4) };
268
+ if (/^IP:/i.test(str)) return { iPAddress: _packIp(str.slice(3)) };
269
+ if (ipUtils.isIPv4(str)) return { iPAddress: _packIp(str) };
270
+ // A bare IPv6 literal is an IP SAN too — auto-detect it symmetrically with
271
+ // IPv4 (a colon-bearing literal can never be a valid DNS hostname, so it must
272
+ // not fall through to the dNSName default and become an unmatchable SAN).
273
+ var v6 = ipUtils.expandIpv6Groups(str);
274
+ if (Array.isArray(v6) && v6.length === 8) return { iPAddress: _packIp(str) };
275
+ // Bare non-IP entries default to DNS — matches operator expectation.
276
+ return { dNSName: str };
214
277
  }
215
278
 
216
279
  function _normaliseCn(cn) {
@@ -222,33 +285,44 @@ function _normaliseCn(cn) {
222
285
  return s;
223
286
  }
224
287
 
288
+ // The signature digest a CA private key should sign with. generateCa /
289
+ // signClientCert know their candidate's digest directly; generateCrl only has
290
+ // the CA key, so derive it: an EC (P-384) CA signs with SHA-384, matching the
291
+ // classical bridge's posture; ML-DSA is no-prehash (undefined -> the toolkit
292
+ // forces SHA-512), so return undefined for a non-EC or unparseable key.
293
+ function _digestForKey(caKeyPem) {
294
+ try {
295
+ return nodeCrypto.createPrivateKey(caKeyPem).asymmetricKeyType === "ec" ? "sha384" : undefined;
296
+ /* c8 ignore next -- defensive: the default engine only signs CRLs with the CA key it just generated (always a parseable EC/ML-DSA key), so the parse never throws here */
297
+ } catch (_e) { return undefined; }
298
+ }
299
+
225
300
  async function generateCa(opts) {
226
301
  opts = opts || {};
227
302
  var generation = (typeof opts.generation === "number" && opts.generation >= 1)
228
303
  ? Math.floor(opts.generation) : 1;
229
304
  var caName = opts.name || DEFAULT_CA_NAME;
230
305
 
231
- var alg = await _selectAlgorithm();
232
- CA_KEY_ALG = alg.keyAlg; CA_SIG_ALG = alg.sigAlg;
233
- var keys = await webcrypto.subtle.generateKey(CA_KEY_ALG, true, CA_KEY_USAGES);
306
+ var alg = await _selectAlgorithm(opts.algorithm);
307
+ var keys = await subtle.generateKey(alg.keyAlg, true, CA_KEY_USAGES);
308
+ var spki = Buffer.from(await subtle.exportKey("spki", keys.publicKey));
234
309
  var now = new Date();
235
- var ca = await x509.X509CertificateGenerator.createSelfSigned({
236
- serialNumber: bCrypto.generateToken(C.BYTES.bytes(16)),
237
- name: "CN=" + caName + ",OU=CAv" + generation,
238
- notBefore: now,
239
- notAfter: new Date(now.getTime() + C.TIME.days(CA_VALIDITY_DAYS)),
240
- signingAlgorithm: CA_SIG_ALG,
241
- keys: keys,
242
- extensions: [
243
- new x509.BasicConstraintsExtension(true, 0, true),
244
- new x509.KeyUsagesExtension(
245
- x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign,
246
- true
247
- ),
248
- ],
249
- });
250
- var pem = await _exportKeyPairToPem(keys);
251
- return { caCertPem: ca.toString("pem"), caKeyPem: pem.privatePem };
310
+
311
+ var caCertPem = await pki.x509.sign({
312
+ // Structured DN a bare string subject is taken by the toolkit as the CN
313
+ // VALUE (so "CN=name,OU=CAvN" would become a single CN literal, double-
314
+ // encoded as CN=CN=name\,OU=CAvN with no real OU). Pass RDN objects so the
315
+ // CN and the OU=CAv{N} generation tag are distinct attributes.
316
+ subject: [{ commonName: caName }, { organizationalUnitName: "CAv" + generation }],
317
+ subjectPublicKey: spki,
318
+ serialNumber: _serial(),
319
+ notBefore: now,
320
+ notAfter: new Date(now.getTime() + C.TIME.days(CA_VALIDITY_DAYS)),
321
+ extensions: { basicConstraints: { cA: true, pathLen: 0 }, keyUsage: ["keyCertSign", "cRLSign"] },
322
+ }, { key: keys.privateKey }, { pem: true, digestAlgorithm: alg.digest });
323
+
324
+ var caKeyPem = await pki.key.export(keys.privateKey, { format: "pem" });
325
+ return { caCertPem: caCertPem, caKeyPem: caKeyPem };
252
326
  }
253
327
 
254
328
  async function signClientCert(opts) {
@@ -264,72 +338,57 @@ async function signClientCert(opts) {
264
338
  var cn = _normaliseCn(opts.cn);
265
339
 
266
340
  // Extended Key Usage: defaults to clientAuth (the historical behaviour).
267
- // Operators issuing server certs for inbound mTLS reverse-proxy fronts
268
- // pass `usage: "server"` (sets serverAuth EKU), or `usage: "both"`
269
- // (clientAuth + serverAuth — dual-purpose certs for service-to-service
270
- // mTLS where the same workload is both initiator and acceptor).
341
+ // usage: "server" -> serverAuth; "both" -> clientAuth + serverAuth.
271
342
  var usage = opts.usage || "client";
272
- var ekuOids = [];
273
- if (usage === "client" || usage === "both") ekuOids.push(EKU_CLIENT_AUTH_OID);
274
- if (usage === "server" || usage === "both") ekuOids.push(EKU_SERVER_AUTH_OID);
275
- if (ekuOids.length === 0) {
343
+ var ekuNames = _ekuNames(usage);
344
+ if (ekuNames.length === 0) {
276
345
  throw new MtlsEngineError("mtls-engine/bad-usage",
277
346
  "signClientCert: opts.usage must be 'client' | 'server' | 'both', got " +
278
347
  JSON.stringify(opts.usage));
279
348
  }
280
349
 
281
- // Subject Alternative Names — required for serverAuth (modern TLS
282
- // clients only honor SANs, not CN). Accept opts.sans as an array of
283
- // strings (DNS names by default; "DNS:foo" / "IP:1.2.3.4" forms also).
284
- var sanExt = null;
350
+ // Subject Alternative Names — required for serverAuth (modern TLS clients
351
+ // only honor SANs, not CN). Accept opts.sans as an array of strings.
352
+ var sans = null;
285
353
  if (Array.isArray(opts.sans) && opts.sans.length > 0) {
286
- var sanEntries = opts.sans.map(function (s) {
287
- var str = String(s);
288
- if (/^DNS:/i.test(str)) return { type: "dns", value: str.slice(4) };
289
- if (/^IP:/i.test(str)) return { type: "ip", value: str.slice(3) };
290
- // Bare entries default to DNS — matches operator expectation
291
- return { type: "dns", value: str };
292
- });
293
- sanExt = new x509.SubjectAlternativeNameExtension(sanEntries);
354
+ sans = opts.sans.map(_sanEntry);
294
355
  } else if (usage === "server" || usage === "both") {
295
356
  // serverAuth without a SAN is unverifiable by modern TLS clients.
296
- // Auto-add the CN as a DNS SAN so the most common case "just works".
297
- sanExt = new x509.SubjectAlternativeNameExtension([{ type: "dns", value: cn }]);
357
+ sans = [{ dNSName: cn }];
298
358
  }
299
359
 
300
- var alg = await _selectAlgorithm();
301
- CA_KEY_ALG = alg.keyAlg; CA_SIG_ALG = alg.sigAlg;
302
- var caKey = await _importPemPrivateKey(opts.caKeyPem, CA_KEY_ALG, ["sign"]);
303
- var caCert = _parseCertPem(opts.caCertPem);
304
- var clientKeys = await webcrypto.subtle.generateKey(CA_KEY_ALG, true, CA_KEY_USAGES);
360
+ // Leaf key algorithm follows the CA's: an ML-DSA-87 default, or the classical
361
+ // bridge when the caller pinned one (b.mtlsCa threads its create({ algorithm })
362
+ // through here). Undefined selects the process default.
363
+ var alg = await _selectAlgorithm(opts.algorithm);
364
+ var clientKeys = await subtle.generateKey(alg.keyAlg, true, CA_KEY_USAGES);
365
+ var clientSpki = Buffer.from(await subtle.exportKey("spki", clientKeys.publicKey));
305
366
 
306
367
  var now = new Date();
307
368
  var notAfter = new Date(now.getTime() + C.TIME.days(validityDays));
308
- var extensions = [
309
- new x509.BasicConstraintsExtension(false, undefined, true),
310
- new x509.KeyUsagesExtension(
311
- x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment,
312
- true
313
- ),
314
- new x509.ExtendedKeyUsageExtension(ekuOids, true),
315
- ];
316
- if (sanExt) extensions.push(sanExt);
317
-
318
- var clientCert = await x509.X509CertificateGenerator.create({
319
- serialNumber: bCrypto.generateToken(C.BYTES.bytes(16)),
320
- subject: "CN=" + cn,
321
- issuer: caCert.subject,
322
- notBefore: now,
323
- notAfter: notAfter,
324
- signingAlgorithm: CA_SIG_ALG,
325
- publicKey: clientKeys.publicKey,
326
- signingKey: caKey,
327
- extensions: extensions,
328
- });
329
- var pem = await _exportKeyPairToPem(clientKeys);
369
+ var extensions = {
370
+ basicConstraints: { cA: false },
371
+ keyUsage: ["digitalSignature", "keyEncipherment"],
372
+ extendedKeyUsage: ekuNames,
373
+ extendedKeyUsageCritical: true,
374
+ };
375
+ if (sans) extensions.subjectAltName = sans;
376
+
377
+ var certPem = await pki.x509.sign({
378
+ // A bare string subject is the CN VALUE (the toolkit wraps it as CN=<value>);
379
+ // passing "CN=" + cn would double-encode to CN=CN=<cn>.
380
+ subject: cn,
381
+ subjectPublicKey: clientSpki,
382
+ serialNumber: _serial(),
383
+ notBefore: now,
384
+ notAfter: notAfter,
385
+ extensions: extensions,
386
+ }, { cert: opts.caCertPem, key: opts.caKeyPem }, { pem: true, digestAlgorithm: alg.digest });
387
+
388
+ var keyPem = await pki.key.export(clientKeys.privateKey, { format: "pem" });
330
389
  return {
331
- cert: clientCert.toString("pem"),
332
- key: pem.privatePem,
390
+ cert: certPem,
391
+ key: keyPem,
333
392
  ca: opts.caCertPem,
334
393
  issuedAt: now.toISOString(),
335
394
  expiresAt: notAfter.toISOString(),
@@ -343,86 +402,35 @@ async function packageP12(opts) {
343
402
  throw new MtlsEngineError("mtls-engine/no-password",
344
403
  "packageP12 requires opts.password (non-empty string)");
345
404
  }
405
+ // Resolve the leaf's algorithm tier so the outer MAC matches it. (signClientCert
406
+ // resolves the same alg from opts.algorithm; undefined selects the default.)
407
+ var alg = await _selectAlgorithm(opts.algorithm);
346
408
  var leaf = await signClientCert(opts);
347
409
 
348
- // Re-import the leaf key as extractable so we can re-export PKCS#8 DER
349
- // for the shrouded key bag.
350
- var leafKey = await _importPemPrivateKey(leaf.key, CA_KEY_ALG, ["sign"], true);
351
- var leafPkcs8 = await webcrypto.subtle.exportKey("pkcs8", leafKey);
352
- var privateKeyInfo = pkijs.PrivateKeyInfo.fromBER(leafPkcs8);
353
-
354
- var leafX509 = _parseCertPem(leaf.cert);
355
- var caX509 = _parseCertPem(leaf.ca);
356
- var leafPkijsCert = pkijs.Certificate.fromBER(leafX509.rawData);
357
- var caPkijsCert = pkijs.Certificate.fromBER(caX509.rawData);
358
-
359
- var passwordBuf = Buffer.from(opts.password, "utf8");
360
-
361
- var pfx = new pkijs.PFX({
362
- parsedValue: {
363
- integrityMode: 0, // PasswordMode (outer HMAC-PBKDF2)
364
- authenticatedSafe: new pkijs.AuthenticatedSafe({
365
- parsedValue: {
366
- safeContents: [
367
- {
368
- privacyMode: 1, // PasswordPrivacyMode (PBES2)
369
- value: new pkijs.SafeContents({
370
- safeBags: [
371
- new pkijs.SafeBag({
372
- bagId: BAG_ID_KEY,
373
- bagValue: new pkijs.PKCS8ShroudedKeyBag({ parsedValue: privateKeyInfo }),
374
- }),
375
- ],
376
- }),
377
- },
378
- {
379
- privacyMode: 1,
380
- value: new pkijs.SafeContents({
381
- safeBags: [
382
- new pkijs.SafeBag({
383
- bagId: BAG_ID_CERT,
384
- bagValue: new pkijs.CertBag({ parsedValue: leafPkijsCert }),
385
- }),
386
- new pkijs.SafeBag({
387
- bagId: BAG_ID_CERT,
388
- bagValue: new pkijs.CertBag({ parsedValue: caPkijsCert }),
389
- }),
390
- ],
391
- }),
392
- },
393
- ],
394
- },
395
- }),
396
- },
397
- });
398
-
399
- // Inner protection on the shrouded-key bag itself.
400
- await pfx.parsedValue.authenticatedSafe.parsedValue.safeContents[0]
401
- .value.safeBags[0].bagValue.makeInternalValues({
402
- password: passwordBuf,
403
- contentEncryptionAlgorithm: P12_CONTENT_ENC,
404
- hmacHashAlgorithm: P12_KDF_HASH,
405
- iterationCount: P12_ITER,
406
- });
407
-
408
- // Encrypt each SafeContents envelope.
409
- await pfx.parsedValue.authenticatedSafe.makeInternalValues({
410
+ var pbe = { password: opts.password, cipher: P12_CIPHER, iterations: P12_ITER, prf: P12_PRF };
411
+ // The integrity MAC follows the cert's interop tier. The PQC-pure default uses
412
+ // PBMAC1 (RFC 9579) — an ML-DSA peer already runs the OpenSSL 3.5 that supports
413
+ // it. The classical ECDSA-P384 bridge exists FOR peers predating OpenSSL 3.5,
414
+ // and PBMAC1 (OpenSSL 3.4+) would make the file unverifiable by exactly those
415
+ // consumers, so it uses the universally-importable RFC 7292 App. B HMAC MacData
416
+ // (at the classic KDF's lower iteration ceiling). PBES2 bag protection is
417
+ // legacy-readable in both tiers, so only the MAC differs.
418
+ var mac = alg.posture === "classical"
419
+ ? { algorithm: "hmac", hash: P12_MAC_HASH, iterations: P12_CLASSIC_MAC_ITER }
420
+ : { algorithm: "pbmac1", hash: P12_MAC_HASH, iterations: P12_ITER };
421
+ var p12 = await pki.pkcs12.build({
410
422
  safeContents: [
411
- { password: passwordBuf, contentEncryptionAlgorithm: P12_CONTENT_ENC, hmacHashAlgorithm: P12_KDF_HASH, iterationCount: P12_ITER },
412
- { password: passwordBuf, contentEncryptionAlgorithm: P12_CONTENT_ENC, hmacHashAlgorithm: P12_KDF_HASH, iterationCount: P12_ITER },
423
+ { bags: [{ type: "shroudedKey", key: leaf.key, encrypt: pbe }] },
424
+ { encrypt: pbe, bags: [
425
+ { type: "cert", cert: leaf.cert },
426
+ { type: "cert", cert: leaf.ca },
427
+ ] },
413
428
  ],
414
- });
415
-
416
- // Outer integrity MAC.
417
- await pfx.makeInternalValues({
418
- password: passwordBuf,
419
- iterations: P12_ITER,
420
- pbkdf2HashAlgorithm: P12_KDF_HASH,
421
- hmacHashAlgorithm: P12_MAC_HASH,
422
- });
429
+ }, { password: opts.password, mac: mac });
423
430
 
424
431
  return {
425
- p12: Buffer.from(pfx.toSchema().toBER(false)),
432
+ /* c8 ignore next -- defensive: pki.pkcs12.build always returns a Buffer, so the Buffer.from() arm is unreachable */
433
+ p12: Buffer.isBuffer(p12) ? p12 : Buffer.from(p12),
426
434
  certPem: leaf.cert,
427
435
  issuedAt: leaf.issuedAt,
428
436
  expiresAt: leaf.expiresAt,
@@ -432,34 +440,42 @@ async function packageP12(opts) {
432
440
  function algorithmEnvelope() {
433
441
  return {
434
442
  cert: {
435
- keyAlg: CA_KEY_ALG,
436
- sigAlg: CA_SIG_ALG,
437
- label: _selectedAlg && _selectedAlg.label,
438
- posture: _selectedAlg && _selectedAlg.posture,
439
- // Operators querying status() before any cert has been issued
440
- // get the candidate priority list — the engine probes lazily so
441
- // the chosen algorithm isn't known until first use.
443
+ // Report the most-recent resolved algorithm (a pinned selection or the
444
+ // default), so the envelope is accurate on the pinned issuance path too — not
445
+ // just _selectedAlg (the default cache a pin deliberately never writes).
446
+ keyAlg: _lastSelectedAlg && _lastSelectedAlg.keyAlg,
447
+ label: _lastSelectedAlg && _lastSelectedAlg.label,
448
+ posture: _lastSelectedAlg && _lastSelectedAlg.posture,
449
+ // Operators querying status() before any cert has been issued get the
450
+ // candidate priority list — the engine probes lazily so the chosen
451
+ // algorithm isn't known until first use.
442
452
  priority: ALG_CANDIDATES.map(function (c) {
443
453
  return { label: c.label, posture: c.posture };
444
454
  }),
445
455
  },
446
- p12: {
447
- contentEncryption: P12_CONTENT_ENC,
448
- kdfHash: P12_KDF_HASH,
449
- macHash: P12_MAC_HASH,
450
- iterationCount: P12_ITER,
456
+ p12: {
457
+ contentEncryption: P12_CIPHER, // PBES2 + AES-256-CBC bag protection (both tiers)
458
+ kdfPrf: P12_PRF, // PBKDF2-HMAC-SHA-512 (both tiers)
459
+ iterationCount: P12_ITER, // bag KDF iterations (both tiers)
460
+ // The outer integrity MAC is tier-dependent: packageP12 selects it from the
461
+ // cert algorithm's posture — PBMAC1 (RFC 9579) for the PQC default, the
462
+ // traditional RFC 7292 HMAC MacData for the classical bridge so a
463
+ // pre-OpenSSL-3.5 peer can verify it. Both are reported (keyed by posture)
464
+ // so a consumer describes the archive it actually builds under either pin.
465
+ mac: {
466
+ "pqc-pure": { algorithm: "pbmac1", hash: P12_MAC_HASH, iterations: P12_ITER },
467
+ classical: { algorithm: "hmac", hash: P12_MAC_HASH, iterations: P12_CLASSIC_MAC_ITER },
468
+ },
451
469
  },
452
470
  caValidityDays: CA_VALIDITY_DAYS,
453
471
  leafDefaultDays: LEAF_DEFAULT_DAYS,
454
472
  };
455
473
  }
456
474
 
457
- // Generate a signed X.509 CRL (RFC 5280) covering every revoked
458
- // serial number. The vendored peculiar/x509 library exposes
459
- // X509CrlGenerator.create which builds the TBSCertList, populates
460
- // the entries, and signs with the CA private key same signature
461
- // algorithm the CA itself was issued under (auto-detected via
462
- // _selectAlgorithm + cached on first issuance).
475
+ // Generate a signed X.509 CRL (RFC 5280) covering every revoked serial
476
+ // number. pki.crl.sign builds the TBSCertList, populates the entries, and
477
+ // signs under the CA private key — the signature algorithm is resolved from
478
+ // the CA key, matching the algorithm the CA itself was issued under.
463
479
  async function generateCrl(opts) {
464
480
  opts = opts || {};
465
481
  if (!opts.caCertPem || !opts.caKeyPem) {
@@ -467,30 +483,39 @@ async function generateCrl(opts) {
467
483
  "generateCrl requires { caCertPem, caKeyPem, revocations, thisUpdate, nextUpdate }");
468
484
  }
469
485
  var revocations = Array.isArray(opts.revocations) ? opts.revocations : [];
470
- var alg = await _selectAlgorithm();
471
- CA_KEY_ALG = alg.keyAlg; CA_SIG_ALG = alg.sigAlg;
472
-
473
- var caKey = await _importPemPrivateKey(opts.caKeyPem, CA_KEY_ALG, ["sign"]);
474
- var caCert = _parseCertPem(opts.caCertPem);
475
-
476
- // X509CrlEntry expects { serialNumber: hex, revocationDate, reason }.
477
- var entries = revocations.map(function (r) {
478
- return {
479
- serialNumber: r.serialNumber,
480
- revocationDate: new Date(r.revokedAt || Date.now()),
481
- reason: (typeof r.reasonCode === "number") ? r.reasonCode : 0,
486
+
487
+ var revoked = revocations.map(function (r) {
488
+ var entry = {
489
+ serialNumber: _normaliseRevokedSerial(r.serialNumber),
490
+ revocationDate: new Date(r.revokedAt || Date.now()),
482
491
  };
492
+ // A full CRL (the only kind this CA issues) cannot carry removeFromCRL
493
+ // (RFC 5280 code 8) — it is a delta-CRL un-revocation directive the toolkit
494
+ // rejects, failing the ENTIRE CRL. revoke() refuses it going forward, but a
495
+ // registry written by a pre-fix build (or hand-edited) may still hold one;
496
+ // keep the serial revoked (it IS in the store — fail-secure) and DROP only the
497
+ // invalid reason so one legacy entry can't block publishing every other
498
+ // revocation.
499
+ if (typeof r.reasonCode === "number" && r.reasonCode !== CRL_REMOVE_FROM_CRL) {
500
+ entry.reason = r.reasonCode;
501
+ }
502
+ return entry;
483
503
  });
484
504
 
485
- var crl = await x509.X509CrlGenerator.create({
486
- issuer: caCert.subject,
487
- thisUpdate: opts.thisUpdate || new Date(),
488
- nextUpdate: opts.nextUpdate,
489
- entries: entries,
490
- signingAlgorithm: CA_SIG_ALG,
491
- signingKey: caKey,
492
- });
493
- return crl.toString("pem");
505
+ return pki.crl.sign({
506
+ thisUpdate: opts.thisUpdate || new Date(),
507
+ nextUpdate: opts.nextUpdate,
508
+ revoked: revoked,
509
+ }, { cert: opts.caCertPem, key: opts.caKeyPem }, { pem: true, digestAlgorithm: _digestForKey(opts.caKeyPem) });
510
+ }
511
+
512
+ // A revoked serial arrives as the hex string the engine issued (no 0x
513
+ // prefix). pki wants a decimal or 0x-hex integer, so prefix bare hex.
514
+ function _normaliseRevokedSerial(serial) {
515
+ var s = String(serial == null ? "" : serial);
516
+ if (/^0x/i.test(s)) return s;
517
+ if (safeBuffer.isHex(s)) return "0x" + s;
518
+ return s;
494
519
  }
495
520
 
496
521
  module.exports = {