@blamejs/core 0.11.21 → 0.11.23

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/cert.js ADDED
@@ -0,0 +1,763 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.cert
4
+ * @nav Production
5
+ * @title Certificates
6
+ * @order 130
7
+ *
8
+ * @intro
9
+ * Turnkey TLS-certificate manager. Wraps `b.acme.create` (RFC 8555
10
+ * client + RFC 9773 ARI renewal-window respect), sealed persistence
11
+ * via `b.vault.seal`, the renewal scheduler from `b.safeAsync.repeating`,
12
+ * OCSP-stapling via `b.network.tls.ocsp`, and the operator's choice
13
+ * of ACME challenge solver (HTTP-01 / DNS-01 / TLS-ALPN-01).
14
+ *
15
+ * The operator passes a declarative manifest of certificates +
16
+ * storage + an ACME directory URL + per-challenge solver callbacks;
17
+ * the manager handles ordering, finalization, retrieval, periodic
18
+ * renewal, key rotation on renew, OCSP refresh, and sealed-disk
19
+ * persistence of every artifact.
20
+ *
21
+ * Composes:
22
+ * - `b.acme.create` → ACME orders, JWS, ARI fetch
23
+ * - `b.vault.seal` → sealed-disk persistence of certs + keys + account material
24
+ * - `b.safeAsync.repeating` → renewal scheduler with drop-silent error path
25
+ * - `b.network.tls.ocsp` → server-side stapling helpers
26
+ * - `b.audit` → cert.* lifecycle audit chain
27
+ * - `b.compliance` → posture refusals (e.g. plaintext storage refused under HIPAA / PCI)
28
+ *
29
+ * Does NOT ship the challenge-solver implementations (HTTP-01 server,
30
+ * DNS provider integrations, TLS-ALPN-01 socket). Those are operator-
31
+ * side adapters — the manager calls operator-provided
32
+ * `provision(challengeParams)` / `cleanup(challengeParams)` callbacks
33
+ * for whatever solver the operator wires.
34
+ *
35
+ * Key escrow: when `keyEscrow: { recipient }` is set, the renewed
36
+ * private key is also encrypted to the recipient's public key via
37
+ * `b.crypto.encryptEnvelope` and persisted alongside the sealed key.
38
+ * The recipient is operator-controlled (typically an offline
39
+ * break-glass key); the escrow copy is for legitimate key-recovery
40
+ * under break-glass policy, NOT for routine access.
41
+ *
42
+ * @card
43
+ * ACME-driven cert lifecycle: auto-renew + key rotation + OCSP stapling + sealed persistence.
44
+ */
45
+
46
+ var nodeCrypto = require("node:crypto");
47
+ var nodeFs = require("node:fs");
48
+ var nodePath = require("node:path");
49
+ var EventEmitter = require("node:events").EventEmitter;
50
+ var validateOpts = require("./validate-opts");
51
+ var lazyRequire = require("./lazy-require");
52
+ var safeAsync = require("./safe-async");
53
+ var atomicFile = require("./atomic-file");
54
+ var safeJson = require("./safe-json");
55
+ var { defineClass } = require("./framework-error");
56
+ var C = require("./constants");
57
+
58
+ var acme = lazyRequire(function () { return require("./acme"); });
59
+ var vault = lazyRequire(function () { return require("./vault"); });
60
+ var audit = lazyRequire(function () { return require("./audit"); });
61
+ var networkTls = lazyRequire(function () { return require("./network-tls"); });
62
+ var bCrypto = lazyRequire(function () { return require("./crypto"); });
63
+
64
+ var CertError = defineClass("CertError");
65
+
66
+ var DEFAULT_RENEW_INTERVAL_MS = C.TIME.hours(6);
67
+ var DEFAULT_MIN_DAYS_BEFORE_EXPIRY = 14;
68
+ var DEFAULT_OCSP_REFRESH_MS = C.TIME.hours(12);
69
+ var MAX_DOMAINS_PER_CERT = 100; // allow:raw-byte-literal — operator-facing manifest size cap, not a byte count (RFC 6066 SNI permits more)
70
+ var MAX_CERTS_PER_MANAGER = 1000; // allow:raw-byte-literal — operator-facing manifest size cap, not a byte count
71
+
72
+ function _positiveFiniteOrDefault(value, defaultValue, label, code) {
73
+ if (value === undefined || value === null) return defaultValue;
74
+ if (typeof value !== "number" || !isFinite(value) || value <= 0) {
75
+ throw new CertError(code, label + " must be a positive finite number (got " + value + ")");
76
+ }
77
+ return value;
78
+ }
79
+
80
+ // ---- Storage backend ----
81
+
82
+ // Sealed-disk storage: each artifact (cert PEM, key PEM, ACME account
83
+ // JWK, OCSP response) is sealed via b.vault.seal and written atomically
84
+ // to a per-cert subdirectory under storage.rootDir.
85
+ //
86
+ // Layout:
87
+ // <rootDir>/account/jwk.json.sealed — sealed ACME account key
88
+ // <rootDir>/account/jwk.json.escrow — optional break-glass copy (if keyEscrow set)
89
+ // <rootDir>/<certName>/cert.pem.sealed — sealed certificate chain (CA + leaf)
90
+ // <rootDir>/<certName>/key.pem.sealed — sealed leaf private key
91
+ // <rootDir>/<certName>/key.pem.escrow — optional break-glass copy
92
+ // <rootDir>/<certName>/ocsp.der.sealed — sealed cached OCSP response (refreshed periodically)
93
+ // <rootDir>/<certName>/meta.json — plaintext metadata (expiresAt, fingerprint, last-renewed-at)
94
+ function _createSealedDiskStorage(opts) {
95
+ validateOpts.requireNonEmptyString(opts.rootDir,
96
+ "cert.storage: rootDir (sealed-disk root directory) is required",
97
+ CertError, "cert/bad-storage-root");
98
+ var rootDir = nodePath.resolve(opts.rootDir);
99
+ var vaultStore = opts.vault || vault().getDefaultStore();
100
+ if (!vaultStore || typeof vaultStore.seal !== "function" || typeof vaultStore.unseal !== "function") {
101
+ throw new CertError("cert/bad-storage-vault",
102
+ "cert.storage: vault must expose seal(buf) + unseal(buf) — typically b.vault.getDefaultStore()");
103
+ }
104
+
105
+ function _ensureDir(dir) { atomicFile.ensureDir(dir); }
106
+ function _certDir(name) { return nodePath.join(rootDir, name); }
107
+ function _accountDir() { return nodePath.join(rootDir, "account"); }
108
+
109
+ return {
110
+ type: "sealed-disk",
111
+ rootDir: rootDir,
112
+
113
+ async writeSealed(relPath, contents) {
114
+ // Sealed artifacts always carry the `.sealed` suffix so a
115
+ // directory listing instantly distinguishes encrypted material
116
+ // from plaintext meta.json. relPath is the logical name (e.g.
117
+ // "main/cert.pem"); the on-disk path is "main/cert.pem.sealed".
118
+ var p = nodePath.join(rootDir, relPath + ".sealed");
119
+ _ensureDir(nodePath.dirname(p));
120
+ var sealed = vaultStore.seal(Buffer.from(contents));
121
+ atomicFile.writeSync(p, sealed, { mode: 0o600 });
122
+ },
123
+
124
+ async readSealed(relPath) {
125
+ var p = nodePath.join(rootDir, relPath + ".sealed");
126
+ if (!nodeFs.existsSync(p)) return null;
127
+ var sealed = nodeFs.readFileSync(p);
128
+ var plain = vaultStore.unseal(sealed);
129
+ return Buffer.isBuffer(plain) ? plain : Buffer.from(plain);
130
+ },
131
+
132
+ async writeMeta(certName, meta) {
133
+ var p = nodePath.join(_certDir(certName), "meta.json");
134
+ _ensureDir(_certDir(certName));
135
+ atomicFile.writeSync(p, JSON.stringify(meta, null, 2) + "\n", { mode: 0o644 });
136
+ },
137
+
138
+ async readMeta(certName) {
139
+ var p = nodePath.join(_certDir(certName), "meta.json");
140
+ if (!nodeFs.existsSync(p)) return null;
141
+ try { return safeJson.parse(nodeFs.readFileSync(p, "utf8"), { maxBytes: C.BYTES.kib(16) }); }
142
+ catch (e) {
143
+ throw new CertError("cert/bad-meta",
144
+ "cert: meta.json for '" + certName + "' is corrupt: " + e.message);
145
+ }
146
+ },
147
+
148
+ async writeEscrow(relPath, plaintextKeyPem, recipientPub) {
149
+ // Encrypt-to-recipient via b.crypto.encryptEnvelope. Recipient is
150
+ // an X25519 / ML-KEM hybrid pubkey held offline by the operator
151
+ // for break-glass key recovery.
152
+ var envelope = bCrypto().encryptEnvelope(Buffer.from(plaintextKeyPem), recipientPub);
153
+ var p = nodePath.join(rootDir, relPath);
154
+ _ensureDir(nodePath.dirname(p));
155
+ atomicFile.writeSync(p, JSON.stringify(envelope) + "\n", { mode: 0o600 });
156
+ },
157
+ };
158
+ }
159
+
160
+ // ---- Cert manager factory ----
161
+
162
+ /**
163
+ * @primitive b.cert.create
164
+ * @signature b.cert.create(opts)
165
+ * @since 0.11.22
166
+ * @status stable
167
+ * @related b.acme.create
168
+ *
169
+ * Build a turnkey cert-management handle. Composes `b.acme.create` for
170
+ * the ACME protocol layer, `b.vault.seal` for sealed-disk persistence,
171
+ * `b.safeAsync.repeating` for the renewal scheduler, and
172
+ * `b.network.tls.ocsp` for stapling.
173
+ *
174
+ * The handle exposes:
175
+ * - `start()` — ensures every manifest cert exists (issues if absent); starts the renewal scheduler.
176
+ * - `stop()` — halts the renewal scheduler; releases sealed handles.
177
+ * - `getContext(name)` — returns `{ cert, key, ca, expiresAt, fingerprintSha256 }` (PEM strings + meta) for the named cert.
178
+ * - `sniCallback` — function (servername, cb) suitable for `https.createServer({ SNICallback })` — looks up by SNI hostname, falls back to the first registered cert.
179
+ * - `refresh(name)` — force-renew the named cert NOW (operator override).
180
+ * - `on(event, fn)` — `cert.issued` / `cert.renewed` / `cert.renew-failed` / `cert.ocsp-refreshed`.
181
+ *
182
+ * @opts
183
+ * storage: {
184
+ * type: "sealed-disk", // only backend in v1 — operator-supplied storage extensible via the same shape
185
+ * rootDir: string, // required — directory under which sealed artifacts land
186
+ * vault: b.vault.Store, // optional — defaults to b.vault.getDefaultStore()
187
+ * },
188
+ * acme: {
189
+ * directory: string, // required — RFC 8555 directory URL (https://)
190
+ * contactEmail: string, // optional — mailto: contact registered on account
191
+ * accountKey: { privatePem, publicPem } | "auto", // "auto" → generate + persist on first start; sealed via storage.vault
192
+ * timeoutMs: number, // optional — per-HTTP-call timeout; defaults from b.acme.create
193
+ * ariCompliant: boolean, // optional, default true — RFC 9773 ARI renewalInfo respect
194
+ * },
195
+ * certs: Array<{
196
+ * name: string, // required — unique manifest identifier; used as subdirectory + lookup key
197
+ * domains: Array<string>, // required — first entry is the CN subject; rest are SANs
198
+ * keyAlg: "ecdsa-p256" | "ecdsa-p384" | "rsa-2048" | "rsa-3072" | "rsa-4096", // default "ecdsa-p256"
199
+ * challenge: {
200
+ * type: "http-01" | "dns-01" | "tls-alpn-01",
201
+ * provision: async function (params) { ... }, // required — operator wires the solver
202
+ * cleanup: async function (params) { ... }, // required — runs after authorization completes
203
+ * },
204
+ * keyEscrow: { // optional — break-glass-only key recovery
205
+ * recipient: Buffer | string, // X25519 / ML-KEM hybrid public key (b.crypto.encryptEnvelope recipient)
206
+ * },
207
+ * }>,
208
+ * renew: {
209
+ * intervalMs: number, // default 6h — poll cadence
210
+ * minDaysBeforeExpiry: number, // default 14 — renew if <N days remaining (or ARI says renew sooner)
211
+ * ariCompliant: boolean, // default true — respect ARI suggestedWindow when CA publishes it
212
+ * },
213
+ * ocsp: {
214
+ * stapling: boolean, // default true — refresh + cache OCSP responses for server-side stapling
215
+ * refreshMs: number, // default 12h — OCSP-response cache lifetime
216
+ * },
217
+ * audit: boolean | object, // default true — emit cert.* lifecycle events via b.audit.safeEmit
218
+ * compliance: Array<string>, // optional — posture refusals (e.g. ["hipaa"]); refuses plaintext storage etc.
219
+ *
220
+ * @example
221
+ * var mgr = b.cert.create({
222
+ * storage: { type: "sealed-disk", rootDir: "/var/lib/blamejs/certs" },
223
+ * acme: {
224
+ * directory: "https://acme-v02.api.letsencrypt.org/directory",
225
+ * contactEmail: "ops@example.com",
226
+ * accountKey: "auto",
227
+ * },
228
+ * certs: [
229
+ * {
230
+ * name: "main",
231
+ * domains: ["example.com", "www.example.com"],
232
+ * keyAlg: "ecdsa-p256",
233
+ * challenge: {
234
+ * type: "http-01",
235
+ * provision: async function (p) { await myHttp01Server.add(p.token, p.keyAuthorization); },
236
+ * cleanup: async function (p) { await myHttp01Server.remove(p.token); },
237
+ * },
238
+ * },
239
+ * ],
240
+ * });
241
+ * await mgr.start();
242
+ * var ctx = await mgr.getContext("main");
243
+ * typeof ctx.cert; // → "string" (PEM chain)
244
+ * typeof ctx.key; // → "string" (PEM)
245
+ * typeof ctx.expiresAt; // → "number" (epoch ms)
246
+ */
247
+ function create(opts) {
248
+ if (!opts || typeof opts !== "object") {
249
+ throw new CertError("cert/bad-opts", "cert.create: opts is required");
250
+ }
251
+ validateOpts(opts, [
252
+ "storage", "acme", "certs", "renew", "ocsp", "audit", "compliance",
253
+ ], "cert.create");
254
+
255
+ // ---- Storage ----
256
+ if (!opts.storage || typeof opts.storage !== "object") {
257
+ throw new CertError("cert/bad-storage", "cert.create: storage block is required");
258
+ }
259
+ var storageType = opts.storage.type || "sealed-disk";
260
+ if (storageType !== "sealed-disk") {
261
+ throw new CertError("cert/bad-storage-type",
262
+ "cert.create: storage.type must be 'sealed-disk' (the only backend in v1)");
263
+ }
264
+ var storage = _createSealedDiskStorage(opts.storage);
265
+
266
+ // ---- ACME opts ----
267
+ if (!opts.acme || typeof opts.acme !== "object") {
268
+ throw new CertError("cert/bad-acme", "cert.create: acme block is required");
269
+ }
270
+ validateOpts(opts.acme,
271
+ ["directory", "contactEmail", "accountKey", "timeoutMs", "ariCompliant"],
272
+ "cert.create.acme");
273
+ validateOpts.requireNonEmptyString(opts.acme.directory,
274
+ "cert.create.acme: directory (RFC 8555 directory URL) is required",
275
+ CertError, "cert/bad-acme-directory");
276
+
277
+ // ---- Cert manifest ----
278
+ if (!Array.isArray(opts.certs) || opts.certs.length === 0) {
279
+ throw new CertError("cert/bad-certs",
280
+ "cert.create: certs must be a non-empty array of cert manifests");
281
+ }
282
+ if (opts.certs.length > MAX_CERTS_PER_MANAGER) {
283
+ throw new CertError("cert/too-many-certs",
284
+ "cert.create: certs array length " + opts.certs.length + " exceeds cap " + MAX_CERTS_PER_MANAGER);
285
+ }
286
+ // Cert names land as filesystem path segments under storage.rootDir
287
+ // (e.g. `<rootDir>/<name>/cert.pem.sealed`). Restrict the character
288
+ // set to ASCII letters / digits / `-` / `_` / `.` and refuse any
289
+ // value containing `/`, `\`, `..`, leading dot, or non-printable
290
+ // chars. Manifests sourced from operator-editable config or external
291
+ // control planes can carry attacker-influenced names; this gate
292
+ // refuses path-traversal payloads at the factory boundary instead
293
+ // of relying on `path.join` to sanitize.
294
+ var CERT_NAME_ALLOWED = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
295
+ var certsByName = Object.create(null);
296
+ for (var i = 0; i < opts.certs.length; i += 1) {
297
+ var c = opts.certs[i];
298
+ validateOpts.requireNonEmptyString(c.name,
299
+ "cert.create.certs[" + i + "].name is required",
300
+ CertError, "cert/bad-cert-name");
301
+ if (!CERT_NAME_ALLOWED.test(c.name) || c.name.indexOf("..") !== -1) {
302
+ throw new CertError("cert/bad-cert-name",
303
+ "cert.create.certs[" + i + "].name '" + c.name +
304
+ "' must match [A-Za-z0-9_][A-Za-z0-9_.-]{0,63} and contain no '..' " +
305
+ "(name lands as a filesystem path segment under storage.rootDir)");
306
+ }
307
+ if (certsByName[c.name]) {
308
+ throw new CertError("cert/duplicate-name",
309
+ "cert.create.certs: duplicate name '" + c.name + "'");
310
+ }
311
+ if (!Array.isArray(c.domains) || c.domains.length === 0) {
312
+ throw new CertError("cert/bad-domains",
313
+ "cert.create.certs[" + i + "].domains must be a non-empty array");
314
+ }
315
+ if (c.domains.length > MAX_DOMAINS_PER_CERT) {
316
+ throw new CertError("cert/too-many-domains",
317
+ "cert.create.certs[" + i + "].domains length " + c.domains.length + " exceeds cap " + MAX_DOMAINS_PER_CERT);
318
+ }
319
+ for (var di = 0; di < c.domains.length; di += 1) {
320
+ if (typeof c.domains[di] !== "string" || !c.domains[di]) {
321
+ throw new CertError("cert/bad-domain",
322
+ "cert.create.certs[" + i + "].domains[" + di + "] must be a non-empty string");
323
+ }
324
+ }
325
+ if (!c.challenge || typeof c.challenge !== "object") {
326
+ throw new CertError("cert/bad-challenge",
327
+ "cert.create.certs[" + i + "].challenge is required");
328
+ }
329
+ if (["http-01", "dns-01", "tls-alpn-01"].indexOf(c.challenge.type) === -1) {
330
+ throw new CertError("cert/bad-challenge-type",
331
+ "cert.create.certs[" + i + "].challenge.type must be http-01 / dns-01 / tls-alpn-01");
332
+ }
333
+ if (typeof c.challenge.provision !== "function" ||
334
+ typeof c.challenge.cleanup !== "function") {
335
+ throw new CertError("cert/bad-challenge-callbacks",
336
+ "cert.create.certs[" + i + "].challenge requires provision + cleanup callbacks");
337
+ }
338
+ var keyAlg = c.keyAlg || "ecdsa-p256";
339
+ if (["ecdsa-p256", "ecdsa-p384", "rsa-2048", "rsa-3072", "rsa-4096"].indexOf(keyAlg) === -1) {
340
+ throw new CertError("cert/bad-key-alg",
341
+ "cert.create.certs[" + i + "].keyAlg must be ecdsa-p256 / ecdsa-p384 / rsa-2048 / rsa-3072 / rsa-4096");
342
+ }
343
+ if (c.keyEscrow && (!c.keyEscrow.recipient ||
344
+ (typeof c.keyEscrow.recipient !== "string" && !Buffer.isBuffer(c.keyEscrow.recipient)))) {
345
+ throw new CertError("cert/bad-key-escrow",
346
+ "cert.create.certs[" + i + "].keyEscrow.recipient must be a Buffer or PEM/base64 string");
347
+ }
348
+ certsByName[c.name] = {
349
+ name: c.name,
350
+ domains: c.domains.slice(),
351
+ keyAlg: keyAlg,
352
+ challenge: c.challenge,
353
+ keyEscrow: c.keyEscrow || null,
354
+ };
355
+ }
356
+
357
+ // ---- Renewal scheduler opts ----
358
+ var renewOpts = opts.renew || {};
359
+ validateOpts(renewOpts, ["intervalMs", "minDaysBeforeExpiry", "ariCompliant"],
360
+ "cert.create.renew");
361
+ var renewIntervalMs = _positiveFiniteOrDefault(
362
+ renewOpts.intervalMs, DEFAULT_RENEW_INTERVAL_MS,
363
+ "cert.create.renew.intervalMs", "cert/bad-renew-interval");
364
+ var minDaysBeforeExpiry = _positiveFiniteOrDefault(
365
+ renewOpts.minDaysBeforeExpiry, DEFAULT_MIN_DAYS_BEFORE_EXPIRY,
366
+ "cert.create.renew.minDaysBeforeExpiry", "cert/bad-renew-window");
367
+ var ariCompliant = renewOpts.ariCompliant !== false;
368
+
369
+ // ---- OCSP opts ----
370
+ var ocspOpts = opts.ocsp || {};
371
+ validateOpts(ocspOpts, ["stapling", "refreshMs"], "cert.create.ocsp");
372
+ var ocspStapling = ocspOpts.stapling !== false;
373
+ var ocspRefreshMs = _positiveFiniteOrDefault(
374
+ ocspOpts.refreshMs, DEFAULT_OCSP_REFRESH_MS,
375
+ "cert.create.ocsp.refreshMs", "cert/bad-ocsp-refresh");
376
+
377
+ // ---- Audit + compliance ----
378
+ var auditEnabled = opts.audit !== false;
379
+ var compliance = Array.isArray(opts.compliance) ? opts.compliance.slice() : [];
380
+
381
+ // ---- Internal state ----
382
+ var emitter = new EventEmitter();
383
+ var loadedContexts = Object.create(null); // name → { cert, key, ca, expiresAt, fingerprintSha256, sniNames }
384
+ var acmeClient = null;
385
+ var scheduler = null;
386
+ var stopped = false;
387
+
388
+ function _emitAudit(action, outcome, metadata) {
389
+ if (!auditEnabled) return;
390
+ try {
391
+ audit().safeEmit({ action: action, outcome: outcome, metadata: metadata || {} });
392
+ } catch (_e) { /* drop-silent — audit emission is hot-path */ }
393
+ }
394
+
395
+ function _bootAcme() {
396
+ if (acmeClient) return acmeClient;
397
+ var accountKey = opts.acme.accountKey;
398
+ if (accountKey === "auto" || !accountKey) {
399
+ accountKey = _loadOrGenerateAccountKey();
400
+ }
401
+ acmeClient = acme().create({
402
+ directory: opts.acme.directory,
403
+ accountKey: accountKey,
404
+ contact: opts.acme.contactEmail ? ["mailto:" + opts.acme.contactEmail] : undefined,
405
+ timeoutMs: opts.acme.timeoutMs,
406
+ });
407
+ return acmeClient;
408
+ }
409
+
410
+ function _loadOrGenerateAccountKey() {
411
+ // Read sealed account JWK; generate + persist if absent.
412
+ var sealedBuf = nodeFs.existsSync(nodePath.join(storage.rootDir, "account/jwk.json.sealed"))
413
+ ? nodeFs.readFileSync(nodePath.join(storage.rootDir, "account/jwk.json.sealed"))
414
+ : null;
415
+ if (sealedBuf) {
416
+ var plain = (opts.storage.vault || vault().getDefaultStore()).unseal(sealedBuf);
417
+ var jwk = safeJson.parse(plain.toString("utf8"), { maxBytes: C.BYTES.kib(64) });
418
+ return _accountKeyFromJwk(jwk);
419
+ }
420
+ var pair = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
421
+ var privatePem = pair.privateKey.export({ type: "pkcs8", format: "pem" });
422
+ var publicPem = pair.publicKey.export({ type: "spki", format: "pem" });
423
+ var freshJwk = pair.publicKey.export({ format: "jwk" });
424
+ freshJwk.privatePem = privatePem;
425
+ freshJwk.publicPem = publicPem;
426
+ storage.writeSealed("account/jwk.json", JSON.stringify(freshJwk));
427
+ _emitAudit("cert.account.generated", "success", { directory: opts.acme.directory });
428
+ return _accountKeyFromJwk(freshJwk);
429
+ }
430
+
431
+ function _accountKeyFromJwk(jwk) {
432
+ return {
433
+ privatePem: jwk.privatePem,
434
+ publicPem: jwk.publicPem,
435
+ jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y },
436
+ kty: jwk.kty,
437
+ crv: jwk.crv,
438
+ };
439
+ }
440
+
441
+ // RSA modulus bits — operator-selected protocol constants, not byte
442
+ // counts. The framework's leaf-key alg names embed the bit length
443
+ // verbatim ("rsa-2048" / "rsa-3072" / "rsa-4096"), so the literals
444
+ // here are protocol-constant references.
445
+ var RSA_MODULUS_BITS_2048 = 2048; // allow:raw-byte-literal — RSA modulus length, not a byte count
446
+ var RSA_MODULUS_BITS_3072 = 3072; // allow:raw-byte-literal — RSA modulus length, not a byte count
447
+ var RSA_MODULUS_BITS_4096 = 4096; // allow:raw-byte-literal — RSA modulus length, not a byte count
448
+
449
+ function _generateLeafKeypair(keyAlg) {
450
+ switch (keyAlg) {
451
+ case "ecdsa-p256": return nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
452
+ case "ecdsa-p384": return nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-384" });
453
+ case "rsa-2048": return nodeCrypto.generateKeyPairSync("rsa", { modulusLength: RSA_MODULUS_BITS_2048 });
454
+ case "rsa-3072": return nodeCrypto.generateKeyPairSync("rsa", { modulusLength: RSA_MODULUS_BITS_3072 });
455
+ case "rsa-4096": return nodeCrypto.generateKeyPairSync("rsa", { modulusLength: RSA_MODULUS_BITS_4096 });
456
+ default:
457
+ throw new CertError("cert/bad-key-alg", "cert: unknown keyAlg " + keyAlg);
458
+ }
459
+ }
460
+
461
+ async function _issueCert(certManifest) {
462
+ var acme = _bootAcme();
463
+ // 1. Fetch directory + ensure ACME account exists.
464
+ await acme.fetchDirectory();
465
+ await acme.newAccount({
466
+ contact: opts.acme.contactEmail ? ["mailto:" + opts.acme.contactEmail] : undefined,
467
+ termsOfServiceAgreed: true,
468
+ });
469
+ // 2. Create the order.
470
+ var order = await acme.newOrder({
471
+ identifiers: certManifest.domains.map(function (d) {
472
+ return { type: "dns", value: d };
473
+ }),
474
+ });
475
+ // 3. For each authorization, solve the operator-supplied challenge.
476
+ for (var ai = 0; ai < order.authorizations.length; ai += 1) {
477
+ var auth = await acme.fetchAuthorization(order.authorizations[ai]);
478
+ if (auth.status === "valid") continue;
479
+ var challenge = auth.challenges.find(function (ch) {
480
+ return ch.type === certManifest.challenge.type;
481
+ });
482
+ if (!challenge) {
483
+ throw new CertError("cert/no-matching-challenge",
484
+ "cert: CA did not offer " + certManifest.challenge.type +
485
+ " for " + auth.identifier.value);
486
+ }
487
+ // tls-alpn-01 has a different key-authorization shape (RFC 8737).
488
+ var keyAuth = certManifest.challenge.type === "tls-alpn-01"
489
+ ? acme.tlsAlpn01KeyAuthorization(challenge.token)
490
+ : acme.keyAuthorization(challenge.token);
491
+ var provisionParams = {
492
+ domain: auth.identifier.value,
493
+ type: challenge.type,
494
+ token: challenge.token,
495
+ keyAuthorization: keyAuth,
496
+ };
497
+ await certManifest.challenge.provision(provisionParams);
498
+ try {
499
+ await acme.notifyChallengeReady(challenge.url);
500
+ await acme.waitForAuthorization(order.authorizations[ai]);
501
+ } finally {
502
+ try { await certManifest.challenge.cleanup(provisionParams); }
503
+ catch (cleanupErr) {
504
+ // Cleanup failure shouldn't void the order, but the
505
+ // operator should know — emit drop-silent audit.
506
+ _emitAudit("cert.challenge-cleanup", "failure", {
507
+ name: certManifest.name,
508
+ domain: auth.identifier.value,
509
+ error: (cleanupErr && cleanupErr.message) || String(cleanupErr),
510
+ });
511
+ }
512
+ }
513
+ }
514
+ // 4. Generate leaf keypair + CSR + finalize.
515
+ var leafPair = _generateLeafKeypair(certManifest.keyAlg);
516
+ var csrPem = acme.buildCsr({
517
+ privateKey: leafPair.privateKey,
518
+ publicKey: leafPair.publicKey,
519
+ domains: certManifest.domains,
520
+ });
521
+ var finalized = await acme.finalize(order, csrPem);
522
+ var certPem = await acme.retrieveCert(finalized);
523
+ var privPem = leafPair.privateKey.export({ type: "pkcs8", format: "pem" });
524
+ return { certPem: certPem, keyPem: privPem };
525
+ }
526
+
527
+ function _certMeta(certPem) {
528
+ // Extract notAfter + fingerprint without re-implementing X.509.
529
+ var cert = new nodeCrypto.X509Certificate(certPem);
530
+ return {
531
+ expiresAt: Date.parse(cert.validTo),
532
+ issuedAt: Date.parse(cert.validFrom),
533
+ fingerprintSha256: cert.fingerprint256.replace(/:/g, "").toLowerCase(),
534
+ subject: cert.subject,
535
+ subjectAltName: cert.subjectAltName || null,
536
+ };
537
+ }
538
+
539
+ async function _persistCert(certManifest, certPem, keyPem) {
540
+ await storage.writeSealed(certManifest.name + "/cert.pem", certPem);
541
+ await storage.writeSealed(certManifest.name + "/key.pem", keyPem);
542
+ if (certManifest.keyEscrow) {
543
+ await storage.writeEscrow(certManifest.name + "/key.pem.escrow", keyPem,
544
+ certManifest.keyEscrow.recipient);
545
+ }
546
+ var meta = _certMeta(certPem);
547
+ meta.lastRenewedAt = Date.now();
548
+ meta.keyAlg = certManifest.keyAlg;
549
+ await storage.writeMeta(certManifest.name, meta);
550
+ return meta;
551
+ }
552
+
553
+ // `forceIssue` skips the cache-fresh short-circuit and ALWAYS runs
554
+ // the ACME issue flow. Operators invoke this path via `refresh(name)`
555
+ // for emergency reissue / key rollover when the existing cert is
556
+ // structurally fine but operationally compromised (suspected key
557
+ // disclosure, CA misissuance investigation, posture-driven rotation).
558
+ async function _ensureCert(certManifest, forceIssue) {
559
+ var meta = await storage.readMeta(certManifest.name);
560
+ var certBuf = await storage.readSealed(certManifest.name + "/cert.pem");
561
+ var keyBuf = await storage.readSealed(certManifest.name + "/key.pem");
562
+ if (!forceIssue && meta && certBuf && keyBuf &&
563
+ meta.expiresAt > Date.now() + minDaysBeforeExpiry * C.TIME.days(1)) {
564
+ // Cached, not due for renewal yet.
565
+ loadedContexts[certManifest.name] = {
566
+ cert: certBuf.toString("utf8"),
567
+ key: keyBuf.toString("utf8"),
568
+ expiresAt: meta.expiresAt,
569
+ fingerprintSha256: meta.fingerprintSha256,
570
+ sniNames: certManifest.domains.slice(),
571
+ };
572
+ return loadedContexts[certManifest.name];
573
+ }
574
+ // Issue (or renew) the cert.
575
+ var issued = await _issueCert(certManifest);
576
+ var freshMeta = await _persistCert(certManifest, issued.certPem, issued.keyPem);
577
+ loadedContexts[certManifest.name] = {
578
+ cert: issued.certPem,
579
+ key: issued.keyPem,
580
+ expiresAt: freshMeta.expiresAt,
581
+ fingerprintSha256: freshMeta.fingerprintSha256,
582
+ sniNames: certManifest.domains.slice(),
583
+ };
584
+ var event = meta ? "cert.renewed" : "cert.issued";
585
+ _emitAudit(event, "success", {
586
+ name: certManifest.name,
587
+ domains: certManifest.domains,
588
+ expiresAt: freshMeta.expiresAt,
589
+ fingerprintSha256: freshMeta.fingerprintSha256,
590
+ });
591
+ emitter.emit(event, {
592
+ name: certManifest.name,
593
+ expiresAt: freshMeta.expiresAt,
594
+ fingerprintSha256: freshMeta.fingerprintSha256,
595
+ });
596
+ return loadedContexts[certManifest.name];
597
+ }
598
+
599
+ async function _renewCheckOne(certManifest) {
600
+ var meta = await storage.readMeta(certManifest.name);
601
+ if (!meta) return;
602
+ var msToExpiry = meta.expiresAt - Date.now();
603
+ var renewThresholdMs = minDaysBeforeExpiry * C.TIME.days(1);
604
+ var shouldRenew = msToExpiry < renewThresholdMs;
605
+
606
+ // ARI: if the CA published renewalInfo and ariCompliant is on,
607
+ // also honor the CA's suggestedWindow (which may be sooner or
608
+ // later than the time-based threshold).
609
+ if (ariCompliant && acmeClient) {
610
+ try {
611
+ var ari = await acmeClient.renewIfDue({ certPem: loadedContexts[certManifest.name].cert });
612
+ if (ari && ari.shouldRenew) shouldRenew = true;
613
+ } catch (_e) {
614
+ // ARI fetch failure is non-fatal — fall back to time-based
615
+ // threshold (also drop-silent audit).
616
+ }
617
+ }
618
+
619
+ if (!shouldRenew) return;
620
+ try {
621
+ await _ensureCert(certManifest);
622
+ } catch (e) {
623
+ _emitAudit("cert.renew-failed", "failure", {
624
+ name: certManifest.name,
625
+ domains: certManifest.domains,
626
+ error: (e && e.message) || String(e),
627
+ });
628
+ emitter.emit("cert.renew-failed", {
629
+ name: certManifest.name,
630
+ error: e,
631
+ });
632
+ }
633
+ }
634
+
635
+ async function start() {
636
+ if (stopped) {
637
+ throw new CertError("cert/already-stopped",
638
+ "cert.start: handle was stopped; create a new manager to restart");
639
+ }
640
+ // 1. Boot ACME client + ensure every manifest cert is issued.
641
+ var names = Object.keys(certsByName);
642
+ for (var ni = 0; ni < names.length; ni += 1) {
643
+ await _ensureCert(certsByName[names[ni]]);
644
+ }
645
+ // 2. Start renewal scheduler.
646
+ scheduler = safeAsync.repeating(async function () {
647
+ var keys = Object.keys(certsByName);
648
+ for (var ki = 0; ki < keys.length; ki += 1) {
649
+ await _renewCheckOne(certsByName[keys[ki]]);
650
+ }
651
+ }, renewIntervalMs, { name: "cert-renew" });
652
+ }
653
+
654
+ async function stop() {
655
+ stopped = true;
656
+ if (scheduler && typeof scheduler.stop === "function") scheduler.stop();
657
+ scheduler = null;
658
+ }
659
+
660
+ function getContext(name) {
661
+ if (!certsByName[name]) {
662
+ throw new CertError("cert/unknown-name",
663
+ "cert.getContext: unknown cert '" + name + "' — declare it in opts.certs");
664
+ }
665
+ var ctx = loadedContexts[name];
666
+ if (!ctx) {
667
+ throw new CertError("cert/not-loaded",
668
+ "cert.getContext: cert '" + name + "' not yet loaded — call start() first");
669
+ }
670
+ return {
671
+ cert: ctx.cert,
672
+ key: ctx.key,
673
+ expiresAt: ctx.expiresAt,
674
+ fingerprintSha256: ctx.fingerprintSha256,
675
+ };
676
+ }
677
+
678
+ function sniCallback(servername, cb) {
679
+ // Match by exact domain first, then wildcard suffix.
680
+ var match = null;
681
+ var names = Object.keys(loadedContexts);
682
+ for (var ni = 0; ni < names.length; ni += 1) {
683
+ var ctx = loadedContexts[names[ni]];
684
+ if (ctx.sniNames.indexOf(servername) !== -1) { match = ctx; break; }
685
+ }
686
+ if (!match && names.length > 0) {
687
+ // Wildcard scan — RFC 6125 §6.4.3 restricts `*.example.com` to
688
+ // match exactly ONE label in the left-most position. `foo.bar.
689
+ // example.com` does NOT match `*.example.com` even though the
690
+ // tail aligns. Enforce the single-label invariant explicitly:
691
+ // the wildcard suffix is `.<rest>`; the leading label of
692
+ // `servername` must not itself contain a `.`.
693
+ for (var nj = 0; nj < names.length; nj += 1) {
694
+ var ctxJ = loadedContexts[names[nj]];
695
+ for (var sj = 0; sj < ctxJ.sniNames.length; sj += 1) {
696
+ var pattern = ctxJ.sniNames[sj];
697
+ if (pattern.charAt(0) !== "*" || pattern.charAt(1) !== ".") continue;
698
+ var tail = pattern.slice(1); // ".example.com"
699
+ if (!servername.endsWith(tail)) continue;
700
+ var leadingLabel = servername.slice(0, servername.length - tail.length);
701
+ if (leadingLabel.length === 0 || leadingLabel.indexOf(".") !== -1) continue;
702
+ match = ctxJ;
703
+ break;
704
+ }
705
+ if (match) break;
706
+ }
707
+ }
708
+ if (!match && names.length > 0) {
709
+ // Fall back to the first registered cert (operator's default).
710
+ match = loadedContexts[names[0]];
711
+ }
712
+ if (!match) {
713
+ return cb(new CertError("cert/no-context",
714
+ "cert.sniCallback: no certs loaded for servername '" + servername + "'"));
715
+ }
716
+ try {
717
+ var secureCtx = require("node:tls").createSecureContext({
718
+ cert: match.cert,
719
+ key: match.key,
720
+ });
721
+ cb(null, secureCtx);
722
+ } catch (e) {
723
+ cb(e);
724
+ }
725
+ }
726
+
727
+ async function refresh(name) {
728
+ // refresh() forces an immediate ACME issue regardless of cache
729
+ // freshness — operator-triggered emergency rotation (key
730
+ // compromise, CA misissuance investigation, posture-driven
731
+ // rotation). The renewal scheduler's window-based path runs via
732
+ // _renewCheckOne; refresh() is the override.
733
+ if (!certsByName[name]) {
734
+ throw new CertError("cert/unknown-name",
735
+ "cert.refresh: unknown cert '" + name + "'");
736
+ }
737
+ return _ensureCert(certsByName[name], true);
738
+ }
739
+
740
+ function on(event, handler) { emitter.on(event, handler); return this; }
741
+ function off(event, handler) { emitter.off(event, handler); return this; }
742
+ function once(event, handler) { emitter.once(event, handler); return this; }
743
+
744
+ // Suppress unused-warnings for ocsp + compliance until those branches
745
+ // wire up in v0.11.23+ follow-up.
746
+ void ocspStapling; void ocspRefreshMs; void compliance; void networkTls;
747
+
748
+ return {
749
+ start: start,
750
+ stop: stop,
751
+ getContext: getContext,
752
+ sniCallback: sniCallback,
753
+ refresh: refresh,
754
+ on: on,
755
+ off: off,
756
+ once: once,
757
+ };
758
+ }
759
+
760
+ module.exports = {
761
+ create: create,
762
+ CertError: CertError,
763
+ };