@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.
package/lib/mtls-ca.js CHANGED
@@ -14,9 +14,9 @@
14
14
  * tagging, and atomic commit. Cert issuance (CA generation, client
15
15
  * cert signing, PKCS#12 packaging) delegates to a pluggable engine
16
16
  * so the operator chooses the X.509 toolchain. The default pure-JS
17
- * engine lives in `lib/mtls-engine-default.js` (backed by the
18
- * vendored @peculiar/x509 + pkijs bundle); operators with custom
19
- * requirements pass their own via `opts.engine`.
17
+ * engine lives in `lib/mtls-engine-default.js` (backed by the vendored
18
+ * zero-dep @blamejs/pki toolkit); operators with custom requirements
19
+ * pass their own via `opts.engine`.
20
20
  *
21
21
  * Files relative to `dataDir`: `ca.crt` (PEM cert, plaintext),
22
22
  * `ca.key` (PEM key, plaintext — refused under `caKeySealedMode:
@@ -69,10 +69,10 @@ var safeJson = require("./safe-json");
69
69
  var validateOpts = require("./validate-opts");
70
70
  var { FrameworkError } = require("./framework-error");
71
71
 
72
- // The default engine carries a 600+ KB vendored bundle (peculiar/x509 +
73
- // pkijs + reflect-metadata). Lazy-require it so operators wiring a
74
- // custom engine never pay the cost. The lazyRequire wrapper keeps the
75
- // require at top-of-file declaration shape — no indented inline calls.
72
+ // The default engine carries a vendored X.509 toolkit (@blamejs/pki).
73
+ // Lazy-require it so operators wiring a custom engine never pay the cost.
74
+ // The lazyRequire wrapper keeps the require at top-of-file declaration
75
+ // shape — no indented inline calls.
76
76
  var mtlsEngineDefault = lazyRequire(function () { return require("./mtls-engine-default"); });
77
77
 
78
78
  var caLog = boot("mtls-ca");
@@ -148,6 +148,7 @@ function parseGeneration(certPem) {
148
148
  if (typeof certPem !== "string" && !Buffer.isBuffer(certPem)) return 0;
149
149
  try {
150
150
  var cert = new nodeCrypto.X509Certificate(certPem);
151
+ /* c8 ignore next -- defensive: a successfully-parsed X.509 certificate always exposes a subject DN */
151
152
  var subj = cert.subject || "";
152
153
  var m = /OU=CAv(\d+)/.exec(subj);
153
154
  return m ? parseInt(m[1], 10) : 1;
@@ -180,6 +181,7 @@ function parseGeneration(certPem) {
180
181
  * caKeySealedMode: string, // "required" (default) | "disabled"
181
182
  * generation: number, // current CA generation for OU=CAv{N}
182
183
  * engine: object, // pluggable X.509 engine; default lib/mtls-engine-default
184
+ * algorithm: string, // pin CA + leaf key algorithm; default ML-DSA-87. Pass "ECDSA-P384-SHA384" for a classical CA when a peer predates OpenSSL 3.5
183
185
  *
184
186
  * @example
185
187
  * var fs = require("fs");
@@ -194,11 +196,43 @@ function parseGeneration(certPem) {
194
196
  * typeof ca.initCA;
195
197
  * // → "function"
196
198
  */
199
+ // Map an algorithm-pin label to the node KeyObject.asymmetricKeyType a stored CA
200
+ // key of that algorithm reports, so a pin can be checked against an on-disk CA.
201
+ // Returns null for a label this file can't map (a custom engine's own naming) —
202
+ // the check is then skipped and the engine owns the semantics.
203
+ // The OpenSSL curve name node reports for the framework's sole classical pin
204
+ // (ECDSA-P384-SHA384). A stored EC CA must report this curve to satisfy that pin.
205
+ var CLASSICAL_CA_CURVE = "secp384r1";
206
+
207
+ function _expectedKeyTypeForPin(label) {
208
+ var l = String(label).toLowerCase();
209
+ if (l.indexOf("ecdsa") !== -1) return "ec";
210
+ var m = l.match(/ml-dsa-(\d+)/);
211
+ return m ? ("ml-dsa-" + m[1]) : null;
212
+ }
213
+
214
+ // The inverse of _expectedKeyTypeForPin: map a stored CA key's node
215
+ // asymmetricKeyType to the algorithm label leaves should be issued under, so an
216
+ // UNPINNED deployment (e.g. an upgrade that never set opts.algorithm) issues
217
+ // leaves under the CA's OWN algorithm — a coherent chain the CA's existing peers
218
+ // can verify — instead of the engine's newer process default. Returns undefined
219
+ // for a key node cannot parse or a type this file doesn't map (the engine then
220
+ // applies its own default).
221
+ function _labelForCaKeyType(caKeyPem) {
222
+ var type;
223
+ /* c8 ignore next -- the "" fallback is defensive: a parsed KeyObject always reports a non-empty asymmetricKeyType, so it is never reached */
224
+ try { type = String(nodeCrypto.createPrivateKey(caKeyPem).asymmetricKeyType || "").toLowerCase(); }
225
+ catch (_e) { return undefined; }
226
+ if (type === "ec") return "ECDSA-P384-SHA384";
227
+ if (/^ml-dsa-\d+$/.test(type)) return type.toUpperCase();
228
+ return undefined;
229
+ }
230
+
197
231
  function create(opts) {
198
232
  opts = opts || {};
199
233
  validateOpts(opts, [
200
234
  "dataDir", "paths", "vault",
201
- "caKeySealedMode", "generation", "engine", "revocationStore",
235
+ "caKeySealedMode", "generation", "engine", "revocationStore", "algorithm",
202
236
  ], "b.mtlsCa");
203
237
  validateOpts.requireNonEmptyString(opts.dataDir, "mtlsCa.create: opts.dataDir", MtlsCaError, "mtls-ca/no-datadir");
204
238
  // Auto-create the dataDir with restrictive perms (CA keys live here).
@@ -221,9 +255,29 @@ function create(opts) {
221
255
  var generation = typeof opts.generation === "number" && opts.generation >= 1
222
256
  ? Math.floor(opts.generation) : 1;
223
257
  // The default engine is lazy-loaded at top-of-file; resolve it only
224
- // when no custom engine was passed.
258
+ // when no custom engine was passed. Whether the bundled engine is in use
259
+ // gates the CA-following algorithm inference below: _labelForCaKeyType maps a
260
+ // key to the BUNDLED engine's label set, which is meaningless (or wrong) for a
261
+ // custom engine's own labels / key curves. A falsy engine (null / undefined)
262
+ // selects the bundled engine, so the flag must match the `opts.engine || ...`
263
+ // fallback exactly — an explicit engine: null is the bundled engine, not custom.
264
+ var usesDefaultEngine = !opts.engine;
225
265
  var engine = opts.engine || mtlsEngineDefault();
226
266
 
267
+ // Optional algorithm pin. When set, it is threaded into BOTH CA generation
268
+ // (initCA) and every leaf/PKCS#12 issuance so the whole chain shares one
269
+ // algorithm — the operator opt-in for a classical (ECDSA-P384-SHA384) CA when
270
+ // a peer predates the OpenSSL 3.5 that verifies the ML-DSA-87 default. The
271
+ // label set is the engine's to validate (a custom engine may define its own),
272
+ // so this is a config-time type guard only; an unknown label surfaces from the
273
+ // engine at issuance.
274
+ var caAlgorithm = opts.algorithm;
275
+ if (caAlgorithm !== undefined && (typeof caAlgorithm !== "string" || caAlgorithm.length === 0)) {
276
+ throw new MtlsCaError("mtls-ca/bad-algorithm",
277
+ "opts.algorithm must be a non-empty string label " +
278
+ "(e.g. \"ECDSA-P384-SHA384\") when set");
279
+ }
280
+
227
281
  function _requireVault(reason) {
228
282
  if (!vault || typeof vault.seal !== "function" || typeof vault.unseal !== "function") {
229
283
  throw new MtlsCaError("mtls-ca/no-vault",
@@ -332,8 +386,10 @@ function create(opts) {
332
386
  while (w < buf.length) {
333
387
  w += nodeFs.writeSync(fd, buf, w, buf.length - w, null);
334
388
  }
389
+ /* c8 ignore next -- best-effort: fsync on a freshly-opened, still-valid fd does not throw here */
335
390
  try { nodeFs.fsyncSync(fd); } catch (_fe) { /* fsync best-effort */ }
336
391
  } finally {
392
+ /* c8 ignore next -- best-effort: closeSync on the just-written fd does not throw here */
337
393
  try { nodeFs.closeSync(fd); } catch (_ce) { /* close best-effort */ }
338
394
  }
339
395
  }
@@ -353,8 +409,10 @@ function create(opts) {
353
409
  // so a genuinely-broken filesystem state surfaces in operator logs
354
410
  // rather than getting silently swallowed.
355
411
  try { if (nodeFs.existsSync(keyTmp)) nodeFs.unlinkSync(keyTmp); }
412
+ /* c8 ignore next -- best-effort cleanup: unlink of a tmp file we just created does not throw here */
356
413
  catch (cleanupErr) { caLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: keyTmp, error: cleanupErr.message }); }
357
414
  try { if (nodeFs.existsSync(certTmp)) nodeFs.unlinkSync(certTmp); }
415
+ /* c8 ignore next -- best-effort cleanup: unlink of a tmp file we just created does not throw here */
358
416
  catch (cleanupErr) { caLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: certTmp, error: cleanupErr.message }); }
359
417
  throw new MtlsCaError("mtls-ca/commit-failed",
360
418
  "atomic CA commit failed: " + ((e && e.message) || String(e)));
@@ -368,9 +426,54 @@ function create(opts) {
368
426
 
369
427
  async function initCA() {
370
428
  if (exists()) {
371
- return { caCertPem: loadCert().toString("utf8"), caKeyPem: loadKey().toString("utf8") };
429
+ var existingCertPem = loadCert().toString("utf8");
430
+ var existingKeyPem = loadKey().toString("utf8");
431
+ // A stored CA is returned as-is (initCA never silently rotates). But an
432
+ // algorithm pin that DISAGREES with the stored CA cannot be honored: the
433
+ // CA's own signature over every leaf is what a peer verifies, so issuing an
434
+ // ECDSA leaf pinned for a legacy peer under a stored ML-DSA CA still yields
435
+ // an ML-DSA-signed chain that peer cannot verify. Refuse the mismatch and
436
+ // tell the operator to rotate, rather than issue an unusable credential.
437
+ if (caAlgorithm !== undefined) {
438
+ var expectedType = _expectedKeyTypeForPin(caAlgorithm);
439
+ var actualType = null;
440
+ var actualCurve = null;
441
+ // A custom engine may store a key node cannot parse — skip the check then.
442
+ try {
443
+ var caKeyObj = nodeCrypto.createPrivateKey(existingKeyPem);
444
+ /* c8 ignore next -- the "" fallback is defensive: a parsed KeyObject always reports a non-empty asymmetricKeyType, so it is never reached */
445
+ actualType = String(caKeyObj.asymmetricKeyType || "").toLowerCase();
446
+ actualCurve = caKeyObj.asymmetricKeyDetails && caKeyObj.asymmetricKeyDetails.namedCurve
447
+ ? String(caKeyObj.asymmetricKeyDetails.namedCurve).toLowerCase() : null;
448
+ } catch (_e) { actualType = null; }
449
+ if (expectedType !== null && actualType && actualType !== expectedType) {
450
+ throw new MtlsCaError("mtls-ca/algorithm-mismatch",
451
+ "the CA at this dataDir was generated under " + actualType + ", but algorithm " +
452
+ JSON.stringify(caAlgorithm) + " (" + expectedType + ") was requested. A leaf issued " +
453
+ "under the pin would be signed by the mismatched CA and fail chain verification at a " +
454
+ "peer. Rotate to a new CA (a fresh dataDir, or a higher generation) to change algorithms.");
455
+ }
456
+ // Every ECDSA label maps to the generic "ec" type, so the type check alone
457
+ // would accept a P-256/P-521 stored CA under the ECDSA-P384 pin — leaving
458
+ // the operator believing they hold P-384 posture. The framework's sole
459
+ // classical pin is ECDSA-P384-SHA384 (secp384r1), so enforce the curve for
460
+ // it; a custom-engine label (unrecognized here) owns its own curve.
461
+ if (actualType === "ec" && /ecdsa-p384/i.test(String(caAlgorithm)) && actualCurve !== CLASSICAL_CA_CURVE) {
462
+ throw new MtlsCaError("mtls-ca/algorithm-mismatch",
463
+ "the CA at this dataDir uses EC curve " + actualCurve + ", but algorithm " +
464
+ JSON.stringify(caAlgorithm) + " requires P-384 (" + CLASSICAL_CA_CURVE + "). Rotate to a new " +
465
+ "CA (a fresh dataDir, or a higher generation) to change the curve.");
466
+ }
467
+ }
468
+ return { caCertPem: existingCertPem, caKeyPem: existingKeyPem };
372
469
  }
373
- var fresh = await engine.generateCa({ generation: generation });
470
+ // Build the args conditionally so an `algorithm` key is present ONLY when the
471
+ // operator pinned one — a strict custom engine that validates its generateCa
472
+ // option shape would reject an own `algorithm: undefined` key on an unpinned
473
+ // first-time init (matching the conditional custom leaf-engine handling).
474
+ var caGenArgs = { generation: generation };
475
+ if (caAlgorithm !== undefined) caGenArgs.algorithm = caAlgorithm;
476
+ var fresh = await engine.generateCa(caGenArgs);
374
477
  if (!fresh || typeof fresh.caCertPem !== "string" || typeof fresh.caKeyPem !== "string") {
375
478
  throw new MtlsCaError("mtls-ca/bad-engine-output",
376
479
  "engine.generateCa must return { caCertPem, caKeyPem }");
@@ -402,10 +505,51 @@ function create(opts) {
402
505
  };
403
506
  }
404
507
 
508
+ // Build the engine call args for a leaf/PKCS#12 issuance. The leaf follows the
509
+ // CA's algorithm: the pin when set (initCA already verified it matches the
510
+ // stored CA), otherwise — for the BUNDLED engine only — the stored CA's own
511
+ // algorithm, so an unpinned upgrade over an existing classical CA keeps issuing
512
+ // classical leaves instead of the engine's ML-DSA process default. A custom
513
+ // engine gets no inferred algorithm (its label set / key curve is its own to
514
+ // resolve; the bundled ECDSA-P384-SHA384 label would break it). An explicit
515
+ // opts2.algorithm always wins.
516
+ function _leafEngineArgs(ca, opts2) {
517
+ var leafAlg = caAlgorithm;
518
+ if (leafAlg === undefined) {
519
+ // The stored CA's own key type maps to the BUNDLED engine's label set, so
520
+ // the inference is USED only for that engine — a custom engine resolves its
521
+ // own algorithm from its own key (injecting the bundled ECDSA-P384-SHA384
522
+ // label would break a P-256/P-521 or custom-labeled engine that validates
523
+ // its option shape).
524
+ var caKeyLabel = _labelForCaKeyType(ca.caKeyPem);
525
+ if (usesDefaultEngine) leafAlg = caKeyLabel;
526
+ }
527
+ var args = Object.assign({}, opts2, { caCertPem: ca.caCertPem, caKeyPem: ca.caKeyPem });
528
+ if (leafAlg !== undefined) {
529
+ // The resolved CA algorithm (a pin verified against the stored CA, or the
530
+ // bundled engine's stored-CA inference) is AUTHORITATIVE and wins over a
531
+ // per-issuance opts.algorithm: silently honoring a conflicting one would let
532
+ // a classical ECDSA CA issue an ML-DSA leaf its legacy peers can't
533
+ // authenticate (and mis-select the P12 MAC tier). Refuse a conflict outright
534
+ // rather than issue a leaf that doesn't match the CA the operator pinned.
535
+ if (opts2.algorithm !== undefined && opts2.algorithm !== leafAlg) {
536
+ throw new MtlsCaError("mtls-ca/algorithm-conflict",
537
+ "generateClientCert/generateClientP12: opts.algorithm " + JSON.stringify(opts2.algorithm) +
538
+ " conflicts with the CA's algorithm " + JSON.stringify(leafAlg) +
539
+ " (the leaf must match the CA; rotate to a fresh CA to change algorithms)");
540
+ }
541
+ args.algorithm = leafAlg;
542
+ }
543
+ // When leafAlg is undefined (a custom engine), opts2.algorithm passes through
544
+ // for the engine to resolve; caCertPem/caKeyPem are forced last so opts2 can't
545
+ // shadow them.
546
+ return args;
547
+ }
548
+
405
549
  async function generateClientCert(opts2) {
406
550
  opts2 = opts2 || {};
407
551
  var ca = await initCA();
408
- var args = Object.assign({}, opts2, { caCertPem: ca.caCertPem, caKeyPem: ca.caKeyPem });
552
+ var args = _leafEngineArgs(ca, opts2);
409
553
  var result = await engine.signClientCert(args);
410
554
  if (!result || typeof result.cert !== "string" || typeof result.key !== "string") {
411
555
  throw new MtlsCaError("mtls-ca/bad-engine-output",
@@ -424,7 +568,10 @@ function create(opts) {
424
568
  "generateClientP12 requires opts.password (the PKCS#12 encryption password)");
425
569
  }
426
570
  var ca = await initCA();
427
- var args = Object.assign({}, opts2, { caCertPem: ca.caCertPem, caKeyPem: ca.caKeyPem });
571
+ // Leaf (and its P12 MAC tier) follows the CA's algorithm via the shared
572
+ // arg-builder — the pin when set, else the bundled engine's stored-CA
573
+ // inference, never a custom engine's inferred label or the process default.
574
+ var args = _leafEngineArgs(ca, opts2);
428
575
  var result = await engine.packageP12(args);
429
576
  if (!result || !Buffer.isBuffer(result.p12)) {
430
577
  throw new MtlsCaError("mtls-ca/bad-engine-output",
@@ -456,6 +603,7 @@ function create(opts) {
456
603
  { maxBytes: C.BYTES.mib(16) });
457
604
  return (json && Array.isArray(json.revocations)) ? json.revocations : [];
458
605
  } catch (e) {
606
+ /* c8 ignore next 2 -- defensive: safeJson.parse throws an Error with a message, so the String(e) fallback is unreachable */
459
607
  throw new MtlsCaError("mtls-ca/revocation-corrupt",
460
608
  "could not parse " + paths.revocations + ": " + ((e && e.message) || String(e)));
461
609
  }
@@ -512,10 +660,12 @@ function create(opts) {
512
660
  return stripped.toLowerCase();
513
661
  }
514
662
 
515
- // Map operator-friendly reason codes to RFC 5280 numeric codes used
516
- // by X.509 CRLs. Default "unspecified" (0) when omitted. removeFromCRL
517
- // uses hex 0x08 to express RFC 5280's reason code 8 the literal is a
518
- // protocol identifier, not a byte quantity.
663
+ // Map operator-friendly reason codes to RFC 5280 numeric codes used by X.509
664
+ // CRLs. Default "unspecified" (0) when omitted. removeFromCRL (code 8) is
665
+ // deliberately absent: it is a DELTA-CRL directive to UN-revoke a cert from the
666
+ // base CRL, not a revocation reason, and is invalid in a full CRL (all this CA
667
+ // issues) — the toolkit refuses it at sign time, so a persisted code-8 entry
668
+ // would poison every later generateCrl(). revoke() rejects it explicitly below.
519
669
  var CRL_REASON_BY_NAME = {
520
670
  "unspecified": 0,
521
671
  "keyCompromise": 1,
@@ -527,7 +677,6 @@ function create(opts) {
527
677
  "cessationOfOperation": 5,
528
678
  "cessation-of-operation": 5,
529
679
  "certificateHold": 6,
530
- "removeFromCRL": 0x08,
531
680
  "privilegeWithdrawn": 9,
532
681
  "aACompromise": 10,
533
682
  };
@@ -552,6 +701,12 @@ function create(opts) {
552
701
  "revoke requires a serial number or a fingerprint " +
553
702
  "(revoke(serial, opts) or revoke({ serial, fingerprint }))");
554
703
  }
704
+ if (reasonName === "removeFromCRL") {
705
+ throw new MtlsCaError("mtls-ca/bad-reason",
706
+ "revoke: 'removeFromCRL' (RFC 5280 code 8) is a delta-CRL un-revocation " +
707
+ "directive, not a revocation reason — this CA issues full CRLs only, and a " +
708
+ "persisted code-8 entry would make every generateCrl() fail");
709
+ }
555
710
  var reasonCode = CRL_REASON_BY_NAME[reasonName];
556
711
  if (reasonCode === undefined) {
557
712
  throw new MtlsCaError("mtls-ca/bad-reason",