@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/CHANGELOG.md +4 -0
- package/index.js +2 -0
- package/lib/acme.js +288 -0
- package/lib/asn1-der.js +68 -0
- package/lib/audit.js +1 -0
- package/lib/cert.js +763 -0
- package/lib/mail-agent.js +121 -0
- package/lib/mail-store.js +103 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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
|
+
};
|