@blamejs/core 0.6.37 → 0.6.58

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.
@@ -0,0 +1,413 @@
1
+ "use strict";
2
+ /**
3
+ * OTLP gRPC log sink — OpenTelemetry Protocol logs over gRPC.
4
+ *
5
+ * Companion to lib/log-stream-otlp.js (HTTP/JSON). The OTel collector
6
+ * accepts both transports; gRPC is the higher-throughput path with
7
+ * less per-batch overhead and is preferred for production deployments
8
+ * pushing >100K logs/s.
9
+ *
10
+ * Wire format:
11
+ * - HTTP/2 (node:http2) POST to
12
+ * /opentelemetry.proto.collector.logs.v1.LogsService/Export
13
+ * - content-type: application/grpc+proto
14
+ * - te: trailers
15
+ * - Body: 1-byte compression flag (0 = none) + 4-byte big-endian
16
+ * length + protobuf-encoded ExportLogsServiceRequest
17
+ * - Response status: HTTP/2 trailer `grpc-status` (0 = OK)
18
+ *
19
+ * The protobuf body is encoded by hand via lib/protobuf-encoder.js —
20
+ * no schema parser ships in the bundle, consistent with the framework's
21
+ * vendoring stance. The OTel logs schema is small and stable
22
+ * (https://github.com/open-telemetry/opentelemetry-proto/blob/main/
23
+ * opentelemetry/proto/logs/v1/logs.proto).
24
+ *
25
+ * Severity mapping follows the OTel spec: debug=5 / info=9 / warn=13 /
26
+ * error=17.
27
+ */
28
+ var http2 = require("node:http2");
29
+ var C = require("./constants");
30
+ var pb = require("./protobuf-encoder");
31
+ var safeUrl = require("./safe-url");
32
+ var { LogStreamError } = require("./framework-error");
33
+
34
+ var _err = LogStreamError.factory;
35
+
36
+ var DEFAULTS = {
37
+ scopeName: "blamejs",
38
+ batchSize: 100,
39
+ maxBatchAgeMs: C.TIME.seconds(5),
40
+ timeoutMs: C.TIME.seconds(30),
41
+ bufferLimit: 10000,
42
+ };
43
+
44
+ var SEVERITY = {
45
+ debug: { number: 5, text: "DEBUG" },
46
+ info: { number: 9, text: "INFO" },
47
+ warn: { number: 13, text: "WARN" },
48
+ error: { number: 17, text: "ERROR" },
49
+ };
50
+
51
+ // ---- Protobuf message shapes (OTel logs.proto) ----
52
+ //
53
+ // Field numbers match
54
+ // https://github.com/open-telemetry/opentelemetry-proto/blob/main/
55
+ // opentelemetry/proto/{common,resource,logs}/v1/*.proto
56
+ //
57
+ // AnyValue (common.proto) — proto3 oneof; we emit one of:
58
+ // string_value = 1, bool_value = 2, int_value = 3 (int64 varint),
59
+ // double_value = 4, bytes_value = 7
60
+ function _encodeAnyValue(v) {
61
+ if (v == null) return pb.string(1, ""); // string_value=""
62
+ if (typeof v === "string") return pb.string(1, v);
63
+ if (typeof v === "boolean")return pb.bool(2, v);
64
+ if (typeof v === "number") {
65
+ if (Number.isInteger(v) && v >= 0) return pb.uint64(3, v); // int_value (proto3 int64 varint)
66
+ return pb.double(4, v); // double_value
67
+ }
68
+ if (Buffer.isBuffer(v)) return pb.bytes(7, v);
69
+ // Fallback — JSON-stringify objects/arrays into string_value rather
70
+ // than implementing the full ArrayValue / KeyValueList branches.
71
+ // Operators wanting structured nested attributes shape their record
72
+ // up-front into top-level scalars or stringified JSON.
73
+ try { return pb.string(1, JSON.stringify(v)); }
74
+ catch (_e) { return pb.string(1, String(v)); }
75
+ }
76
+
77
+ // KeyValue (common.proto): key=1 (string), value=2 (AnyValue)
78
+ function _encodeKeyValue(key, value) {
79
+ return Buffer.concat([
80
+ pb.string(1, key),
81
+ pb.embeddedMessage(2, _encodeAnyValue(value)),
82
+ ]);
83
+ }
84
+
85
+ function _encodeAttributes(obj) {
86
+ if (!obj) return [];
87
+ var out = [];
88
+ var keys = Object.keys(obj);
89
+ for (var i = 0; i < keys.length; i++) {
90
+ var k = keys[i];
91
+ var v = obj[k];
92
+ if (v === undefined) continue;
93
+ out.push(_encodeKeyValue(k, v));
94
+ }
95
+ return out;
96
+ }
97
+
98
+ // Resource (resource.proto): attributes=1 (repeated KeyValue),
99
+ // dropped_attributes_count=2
100
+ function _encodeResource(attrs) {
101
+ var kvs = _encodeAttributes(attrs);
102
+ if (kvs.length === 0) return Buffer.alloc(0);
103
+ var pieces = kvs.map(function (kvBody) { return pb.embeddedMessage(1, kvBody); });
104
+ return Buffer.concat(pieces);
105
+ }
106
+
107
+ // InstrumentationScope (common.proto): name=1, version=2
108
+ function _encodeScope(name, version) {
109
+ return Buffer.concat([
110
+ pb.string(1, name || DEFAULTS.scopeName),
111
+ pb.string(2, version || ""),
112
+ ]);
113
+ }
114
+
115
+ // LogRecord (logs.proto):
116
+ // time_unix_nano = 1 (fixed64)
117
+ // severity_number = 2 (enum / varint)
118
+ // severity_text = 3 (string)
119
+ // body = 5 (AnyValue)
120
+ // attributes = 6 (repeated KeyValue)
121
+ // observed_time_unix_nano = 11 (fixed64)
122
+ function _encodeLogRecord(record) {
123
+ var sev = SEVERITY[record.level] || SEVERITY.info;
124
+ var tsMs = record.ts || Date.now();
125
+ // Convert ms to ns. Use BigInt to avoid 53-bit precision loss on
126
+ // operators emitting > year-2255 timestamps. For ms-resolution
127
+ // records the LSB nanos are 0; we still send fixed64.
128
+ var tsNs = BigInt(tsMs) * 1000000n;
129
+ var attrPieces = _encodeAttributes(record.meta).map(function (kvBody) {
130
+ return pb.embeddedMessage(6, kvBody);
131
+ });
132
+ var msg = (record.message != null ? String(record.message) : "");
133
+ return Buffer.concat([
134
+ pb.fixed64(1, tsNs),
135
+ pb.uint32(2, sev.number),
136
+ pb.string(3, sev.text),
137
+ pb.embeddedMessage(5, pb.string(1, msg)), // body.string_value
138
+ Buffer.concat(attrPieces),
139
+ pb.fixed64(11, tsNs),
140
+ ]);
141
+ }
142
+
143
+ // ScopeLogs (logs.proto): scope=1 (InstrumentationScope),
144
+ // log_records=2 (repeated LogRecord), schema_url=3
145
+ function _encodeScopeLogs(records, scopeName, scopeVersion) {
146
+ var recordPieces = records.map(function (rec) {
147
+ return pb.embeddedMessage(2, _encodeLogRecord(rec));
148
+ });
149
+ return Buffer.concat([
150
+ pb.embeddedMessage(1, _encodeScope(scopeName, scopeVersion)),
151
+ Buffer.concat(recordPieces),
152
+ ]);
153
+ }
154
+
155
+ // ResourceLogs (logs.proto): resource=1 (Resource),
156
+ // scope_logs=2 (repeated ScopeLogs), schema_url=3
157
+ function _encodeResourceLogs(records, cfg) {
158
+ var resourceBody = _encodeResource(_resourceAttrs(cfg));
159
+ var scopeLogsBody = _encodeScopeLogs(records, cfg.scopeName, cfg.scopeVersion);
160
+ return Buffer.concat([
161
+ pb.embeddedMessage(1, resourceBody),
162
+ pb.embeddedMessage(2, scopeLogsBody),
163
+ ]);
164
+ }
165
+
166
+ // ExportLogsServiceRequest (collector logs.proto):
167
+ // resource_logs = 1 (repeated ResourceLogs)
168
+ function _encodeExportRequest(records, cfg) {
169
+ return pb.embeddedMessage(1, _encodeResourceLogs(records, cfg));
170
+ }
171
+
172
+ function _resourceAttrs(cfg) {
173
+ var attrs = Object.assign({}, cfg.resourceAttributes || {});
174
+ if (cfg.serviceName) attrs["service.name"] = cfg.serviceName;
175
+ if (cfg.serviceVersion) attrs["service.version"] = cfg.serviceVersion;
176
+ return attrs;
177
+ }
178
+
179
+ // ---- gRPC framing ----
180
+
181
+ function _frame(messageBuf) {
182
+ // 1 byte (compression flag, 0 = uncompressed) + 4 bytes (length, BE)
183
+ // + message bytes.
184
+ var hdr = Buffer.alloc(5);
185
+ hdr[0] = 0;
186
+ hdr.writeUInt32BE(messageBuf.length, 1);
187
+ return Buffer.concat([hdr, messageBuf]);
188
+ }
189
+
190
+ // ---- HTTP/2 client ----
191
+ //
192
+ // One client per sink instance. The OTLP gRPC server keeps the
193
+ // connection alive across many Export calls; we re-create on
194
+ // disconnect.
195
+ function _makeClient(cfg) {
196
+ var url = new URL(cfg.url);
197
+ var authority = url.protocol + "//" + url.host;
198
+ var sessionOpts = {};
199
+ if (cfg.ca) sessionOpts.ca = cfg.ca;
200
+ if (cfg.servername) sessionOpts.servername = cfg.servername;
201
+ if (cfg.allowInsecure) sessionOpts.rejectUnauthorized = false;
202
+ var session = http2.connect(authority, sessionOpts);
203
+ session.on("error", function () { /* surfaced through request err */ });
204
+ if (typeof session.unref === "function") session.unref();
205
+ return session;
206
+ }
207
+
208
+ function _doExport(session, cfg, records) {
209
+ return new Promise(function (resolve, reject) {
210
+ var body = _encodeExportRequest(records, cfg);
211
+ var framed = _frame(body);
212
+
213
+ var headers = Object.assign({
214
+ ":method": "POST",
215
+ ":path": "/opentelemetry.proto.collector.logs.v1.LogsService/Export",
216
+ "content-type": "application/grpc+proto",
217
+ "te": "trailers",
218
+ "grpc-encoding": "identity",
219
+ "grpc-accept-encoding": "identity",
220
+ }, cfg.headers || {});
221
+
222
+ var req = session.request(headers);
223
+ var resStatus = null;
224
+ var trailers = null;
225
+ var errored = false;
226
+
227
+ var timer = setTimeout(function () {
228
+ errored = true;
229
+ try { req.close(http2.constants.NGHTTP2_CANCEL); } catch (_e) {}
230
+ reject(_err("ETIMEDOUT",
231
+ "otlp-grpc: request timed out after " + cfg.timeoutMs + "ms"));
232
+ }, cfg.timeoutMs);
233
+
234
+ req.on("response", function (h) {
235
+ resStatus = h[":status"];
236
+ });
237
+ req.on("trailers", function (t) { trailers = t; });
238
+ req.on("error", function (e) {
239
+ if (errored) return;
240
+ errored = true;
241
+ clearTimeout(timer);
242
+ reject(_err("HTTP2_ERROR", "otlp-grpc: " + (e && e.message || String(e))));
243
+ });
244
+ req.on("close", function () {
245
+ if (errored) return;
246
+ clearTimeout(timer);
247
+ if (resStatus !== 200) {
248
+ return reject(_err("HTTP_ERROR",
249
+ "otlp-grpc: HTTP/2 status " + resStatus));
250
+ }
251
+ // Trailers MUST carry grpc-status. The OTel collector returns
252
+ // grpc-status: 0 on success.
253
+ var grpcStatus = trailers && trailers["grpc-status"];
254
+ var grpcMessage = trailers && trailers["grpc-message"];
255
+ if (grpcStatus === undefined) {
256
+ return reject(_err("HTTP_ERROR",
257
+ "otlp-grpc: response missing grpc-status trailer"));
258
+ }
259
+ if (String(grpcStatus) !== "0") {
260
+ return reject(_err("HTTP_ERROR",
261
+ "otlp-grpc: grpc-status " + grpcStatus +
262
+ (grpcMessage ? " — " + grpcMessage : "")));
263
+ }
264
+ resolve();
265
+ });
266
+
267
+ req.end(framed);
268
+ });
269
+ }
270
+
271
+ // ---- Public sink factory ----
272
+
273
+ function create(config) {
274
+ if (!config || !config.url) {
275
+ throw _err("BAD_OPT", "log-stream otlp-grpc: { url } is required");
276
+ }
277
+ // Reject http:// without explicit allowInsecure — gRPC endpoints
278
+ // are virtually always TLS in real deployments.
279
+ var allowedProtocols = config.allowedProtocols || safeUrl.ALLOW_HTTP_TLS;
280
+ safeUrl.parse(config.url, {
281
+ allowedProtocols: allowedProtocols,
282
+ errorClass: LogStreamError,
283
+ });
284
+
285
+ var cfg = Object.assign({}, DEFAULTS, config);
286
+ cfg.batchSize = Number(cfg.batchSize) || DEFAULTS.batchSize;
287
+ cfg.maxBatchAgeMs = Number(cfg.maxBatchAgeMs) || DEFAULTS.maxBatchAgeMs;
288
+ cfg.timeoutMs = Number(cfg.timeoutMs) || DEFAULTS.timeoutMs;
289
+ cfg.bufferLimit = Number(cfg.bufferLimit) || DEFAULTS.bufferLimit;
290
+
291
+ var onDrop = typeof config.onDrop === "function" ? config.onDrop : null;
292
+ function _emitDrop(reason, batch, err) {
293
+ if (!onDrop) return;
294
+ try { onDrop({ reason: reason, batch: batch, error: err || null }); }
295
+ catch (_e) {}
296
+ }
297
+
298
+ var buffer = [];
299
+ var inFlight = false;
300
+ var closed = false;
301
+ var flushTimer = null;
302
+ var session = null;
303
+ var inflightPromise = null;
304
+
305
+ function _ensureSession() {
306
+ if (session && !session.destroyed) return session;
307
+ session = _makeClient(cfg);
308
+ return session;
309
+ }
310
+
311
+ function _scheduleFlush() {
312
+ if (flushTimer || closed) return;
313
+ flushTimer = setTimeout(function () {
314
+ flushTimer = null;
315
+ _flush().catch(function () { /* drained internally */ });
316
+ }, cfg.maxBatchAgeMs);
317
+ if (typeof flushTimer.unref === "function") flushTimer.unref();
318
+ }
319
+
320
+ async function _flush() {
321
+ if (inFlight || buffer.length === 0) return;
322
+ inFlight = true;
323
+ try {
324
+ while (buffer.length > 0 && !closed) {
325
+ var batch = buffer.splice(0, cfg.batchSize);
326
+ try {
327
+ var s = _ensureSession();
328
+ inflightPromise = _doExport(s, cfg, batch);
329
+ await inflightPromise;
330
+ } catch (e) {
331
+ _emitDrop("send-failed", batch, e);
332
+ } finally {
333
+ inflightPromise = null;
334
+ }
335
+ }
336
+ } finally {
337
+ inFlight = false;
338
+ }
339
+ }
340
+
341
+ function emit(record) {
342
+ if (closed) {
343
+ _emitDrop("sink-closed", [record], null);
344
+ return Promise.resolve({ accepted: false, reason: "closed" });
345
+ }
346
+ if (buffer.length >= cfg.bufferLimit) {
347
+ var dropped = buffer.shift();
348
+ _emitDrop("overflow", [dropped], null);
349
+ }
350
+ buffer.push(record);
351
+ if (buffer.length >= cfg.batchSize) {
352
+ // Drain immediately; don't await — emit is fire-and-forget on the
353
+ // hot path. Errors surface via onDrop.
354
+ _flush().catch(function () {});
355
+ } else {
356
+ _scheduleFlush();
357
+ }
358
+ return Promise.resolve({ accepted: true, queued: buffer.length });
359
+ }
360
+
361
+ async function close() {
362
+ closed = true;
363
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
364
+ // Drain buffered records before tearing down the HTTP/2 session.
365
+ if (inflightPromise) { try { await inflightPromise; } catch (_e) {} }
366
+ var pending = buffer.splice(0, buffer.length);
367
+ if (pending.length > 0) {
368
+ try {
369
+ var s = _ensureSession();
370
+ await _doExport(s, cfg, pending);
371
+ } catch (e) {
372
+ _emitDrop("send-failed", pending, e);
373
+ }
374
+ }
375
+ // Tear down the HTTP/2 session. `session.close()` is the *graceful*
376
+ // close — it waits for in-flight streams to complete on their own
377
+ // and won't free the underlying socket while any stream is still
378
+ // open. By this point we've already awaited `inflightPromise` and
379
+ // run a final `_doExport` for any buffered records, so there's
380
+ // nothing left to drain; `session.destroy()` is the structurally
381
+ // correct call. (Calling only close() left the test fixture's
382
+ // server.close() hanging indefinitely on Linux CI runners — the
383
+ // close() was returning while the underlying TCP socket stayed
384
+ // connected, blocking the server-side close from completing.)
385
+ try {
386
+ if (session) {
387
+ try { session.close(); } catch (_e1) { /* best-effort graceful */ }
388
+ try { session.destroy(); } catch (_e2) { /* socket teardown */ }
389
+ }
390
+ } catch (_e) {}
391
+ session = null;
392
+ }
393
+
394
+ return {
395
+ protocol: "otlp-grpc",
396
+ emit: emit,
397
+ close: close,
398
+ // Test hooks — encode without sending.
399
+ _encodeForTest: function (records) { return _encodeExportRequest(records, cfg); },
400
+ _frameForTest: _frame,
401
+ };
402
+ }
403
+
404
+ module.exports = {
405
+ create: create,
406
+ // Exposed for layer-0 tests that verify the wire encoding without
407
+ // standing up an HTTP/2 server.
408
+ _encodeAnyValue: _encodeAnyValue,
409
+ _encodeKeyValue: _encodeKeyValue,
410
+ _encodeLogRecord: _encodeLogRecord,
411
+ _encodeExportRequest: _encodeExportRequest,
412
+ _frame: _frame,
413
+ };
package/lib/log-stream.js CHANGED
@@ -14,6 +14,12 @@
14
14
  * Model. Operators with an OTel collector running
15
15
  * (k8s, cloud) get standard log forwarding without a
16
16
  * vendor-specific adapter.
17
+ * otlp-grpc — same OTel Logs Data Model but over gRPC (HTTP/2 +
18
+ * hand-encoded protobuf). Higher-throughput than the
19
+ * JSON variant; preferred for production deployments
20
+ * pushing >100K logs/s straight to a remote OTel
21
+ * collector. No protobuf parser ships in the bundle —
22
+ * the encoder is the framework's own (lib/protobuf-encoder.js).
17
23
  * cloudwatch — AWS CloudWatch Logs (PutLogEvents) over HTTPS with
18
24
  * SigV4. Pass { autoCreate: true } to have the framework
19
25
  * issue CreateLogGroup + CreateLogStream on first emit
@@ -49,6 +55,7 @@
49
55
  var localProto = require("./log-stream-local");
50
56
  var webhookProto = require("./log-stream-webhook");
51
57
  var otlpProto = require("./log-stream-otlp");
58
+ var otlpGrpcProto = require("./log-stream-otlp-grpc");
52
59
  var cloudwatchProto = require("./log-stream-cloudwatch");
53
60
  var syslogProto = require("./log-stream-syslog");
54
61
  var redactor = require("./redact");
@@ -63,6 +70,7 @@ var dispatcher = protocolDispatcher.create({
63
70
  "local": localProto,
64
71
  "webhook": webhookProto,
65
72
  "otlp": otlpProto,
73
+ "otlp-grpc": otlpGrpcProto,
66
74
  "cloudwatch": cloudwatchProto,
67
75
  "syslog": syslogProto,
68
76
  },
package/lib/mail.js CHANGED
@@ -454,8 +454,20 @@ function smtpTransport(opts) {
454
454
  if (opts.ecdhCurve) tlsOpts.ecdhCurve = opts.ecdhCurve;
455
455
  if (opts.ca) tlsOpts.ca = opts.ca;
456
456
 
457
+ // SNI is only legal for hostnames; IP literals must omit servername
458
+ // (Node's tls.connect throws "Setting the TLS ServerName to an IP
459
+ // address is not permitted" otherwise). Operators with private CAs
460
+ // and an IP-only target pass `opts.servername: "expected-cn.example"`
461
+ // explicitly. Same convention as lib/redis-client.js.
462
+ var host = opts.host;
463
+ var servername = opts.servername;
464
+ if (servername === undefined) {
465
+ servername = (/^\d+\.\d+\.\d+\.\d+$/.test(host) || (host && host.indexOf(":") !== -1))
466
+ ? undefined : host;
467
+ }
468
+
457
469
  var cfg = {
458
- host: opts.host,
470
+ host: host,
459
471
  port: port,
460
472
  user: opts.user,
461
473
  pass: opts.pass,
@@ -463,6 +475,7 @@ function smtpTransport(opts) {
463
475
  ehloName: ehloName,
464
476
  timeoutMs: timeoutMs,
465
477
  tlsOpts: tlsOpts,
478
+ servername: servername,
466
479
  dkimSigner: opts.dkimSigner || null,
467
480
  };
468
481
 
@@ -542,7 +555,8 @@ function _smtpSend(message, cfg) {
542
555
 
543
556
  function connect() {
544
557
  if (cfg.useImplicitTLS) {
545
- var tlsConnectOpts = Object.assign({ servername: cfg.host }, cfg.tlsOpts);
558
+ var tlsConnectOpts = Object.assign({}, cfg.tlsOpts);
559
+ if (cfg.servername) tlsConnectOpts.servername = cfg.servername;
546
560
  attachSocket(tls().connect(cfg.port, cfg.host, tlsConnectOpts));
547
561
  } else {
548
562
  attachSocket(net().createConnection(cfg.port, cfg.host));
@@ -562,7 +576,8 @@ function _smtpSend(message, cfg) {
562
576
  }
563
577
  else if (step === 10) {
564
578
  if (code !== 220) { fail("starttls-rejected (code " + code + ")"); return; }
565
- var tlsConnectOpts = Object.assign({ socket: socket, servername: cfg.host }, cfg.tlsOpts);
579
+ var tlsConnectOpts = Object.assign({ socket: socket }, cfg.tlsOpts);
580
+ if (cfg.servername) tlsConnectOpts.servername = cfg.servername;
566
581
  var tlsSocket = tls().connect(tlsConnectOpts, function () {
567
582
  upgradedToTLS = true;
568
583
  try { socket.removeAllListeners("data"); } catch (_e) { /* listeners migrate to upgraded socket */ }
package/lib/mtls-ca.js CHANGED
@@ -90,6 +90,14 @@ var DEFAULT_PATHS = {
90
90
  caKey: "ca.key",
91
91
  caKeySealed: "ca.key.sealed",
92
92
  caCert: "ca.crt",
93
+ // Revocation registry — JSON file under dataDir tracking revoked
94
+ // serial numbers. Operators export this as a CRL via
95
+ // ca.generateCrl() (engine.generateCrl signs the list with the CA
96
+ // key). Persisted as JSON rather than a stored CRL because the
97
+ // signed CRL is a derivative artifact — the registry survives CA
98
+ // rotation, the CRL doesn't.
99
+ revocations: "revocations.json",
100
+ crl: "ca.crl",
93
101
  };
94
102
 
95
103
  var VALID_SEAL_MODES = { auto: 1, required: 1, disabled: 1 };
@@ -100,6 +108,8 @@ function _resolvePaths(dataDir, paths) {
100
108
  caKey: path.join(dataDir, p.caKey),
101
109
  caKeySealed: path.join(dataDir, p.caKeySealed),
102
110
  caCert: path.join(dataDir, p.caCert),
111
+ revocations: path.join(dataDir, p.revocations),
112
+ crl: path.join(dataDir, p.crl),
103
113
  };
104
114
  }
105
115
 
@@ -318,6 +328,147 @@ function create(opts) {
318
328
  return result;
319
329
  }
320
330
 
331
+ // ---- Revocation registry + CRL ----
332
+
333
+ function _loadRevocations() {
334
+ if (!fs.existsSync(paths.revocations)) return { revocations: [] };
335
+ try {
336
+ var json = JSON.parse(fs.readFileSync(paths.revocations, "utf8"));
337
+ if (!json || !Array.isArray(json.revocations)) return { revocations: [] };
338
+ return json;
339
+ } catch (e) {
340
+ throw new MtlsCaError("mtls-ca/revocation-corrupt",
341
+ "could not parse " + paths.revocations + ": " +
342
+ ((e && e.message) || String(e)));
343
+ }
344
+ }
345
+
346
+ function _saveRevocations(state) {
347
+ var atomicFile = require("./atomic-file");
348
+ atomicFile.writeSync(paths.revocations,
349
+ JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
350
+ }
351
+
352
+ function _normalizeSerial(s) {
353
+ if (!s || typeof s !== "string") {
354
+ throw new MtlsCaError("mtls-ca/bad-serial",
355
+ "serial number must be a non-empty string");
356
+ }
357
+ // Strip the optional leading `0x` and any common separators
358
+ // (`:` or `-` or whitespace). What remains MUST be hex — otherwise
359
+ // we silently accept gibberish like "xyz-not-hex" (which previously
360
+ // normalised to a single "e" because the strip-non-hex regex left
361
+ // exactly one valid char). Operators pasting an openssl-printed
362
+ // serial use any of: "0xABC123", "AB:C1:23", "AB-C1-23", "abc 123";
363
+ // a typo or non-serial string fails fast instead of registering a
364
+ // phantom revocation row.
365
+ var stripped = s.replace(/^0x/i, "").replace(/[:\-\s]/g, "");
366
+ if (!/^[0-9a-fA-F]+$/.test(stripped)) {
367
+ throw new MtlsCaError("mtls-ca/bad-serial",
368
+ "serial number contains non-hex characters " +
369
+ "(allowed shapes: hex with optional 0x prefix, ':', '-', or whitespace " +
370
+ "as separators): " + JSON.stringify(s));
371
+ }
372
+ return stripped.toLowerCase();
373
+ }
374
+
375
+ // Map operator-friendly reason codes to RFC 5280 numeric codes used
376
+ // by X.509 CRLs. Default "unspecified" (0) when omitted.
377
+ var CRL_REASON_BY_NAME = {
378
+ "unspecified": 0,
379
+ "keyCompromise": 1,
380
+ "key-compromise": 1,
381
+ "caCompromise": 2,
382
+ "ca-compromise": 2,
383
+ "affiliationChanged": 3,
384
+ "superseded": 4,
385
+ "cessationOfOperation": 5,
386
+ "cessation-of-operation": 5,
387
+ "certificateHold": 6,
388
+ "removeFromCRL": 8,
389
+ "privilegeWithdrawn": 9,
390
+ "aACompromise": 10,
391
+ };
392
+
393
+ function revoke(serialNumber, opts3) {
394
+ var serial = _normalizeSerial(serialNumber);
395
+ opts3 = opts3 || {};
396
+ var reasonName = opts3.reason || "unspecified";
397
+ var reasonCode = CRL_REASON_BY_NAME[reasonName];
398
+ if (reasonCode === undefined) {
399
+ throw new MtlsCaError("mtls-ca/bad-reason",
400
+ "revoke: unknown reason '" + reasonName + "' (valid: " +
401
+ Object.keys(CRL_REASON_BY_NAME).join(", ") + ")");
402
+ }
403
+ var state = _loadRevocations();
404
+ var existing = state.revocations.find(function (r) {
405
+ return r.serialNumber === serial;
406
+ });
407
+ if (existing) {
408
+ // Idempotent — repeated revoke() of the same serial doesn't
409
+ // shift the revokedAt timestamp.
410
+ return existing;
411
+ }
412
+ var entry = {
413
+ serialNumber: serial,
414
+ reason: reasonName,
415
+ reasonCode: reasonCode,
416
+ revokedAt: Date.now(),
417
+ };
418
+ state.revocations.push(entry);
419
+ _saveRevocations(state);
420
+ return entry;
421
+ }
422
+
423
+ function isRevoked(serialNumber) {
424
+ var serial = _normalizeSerial(serialNumber);
425
+ var state = _loadRevocations();
426
+ return state.revocations.some(function (r) {
427
+ return r.serialNumber === serial;
428
+ });
429
+ }
430
+
431
+ function getRevocations() {
432
+ return _loadRevocations().revocations.slice();
433
+ }
434
+
435
+ // Generate a signed X.509 CRL covering every entry in the registry.
436
+ // RFC 5280 — issuer = CA subject, signed by the CA private key.
437
+ // Operators publish the resulting PEM at a CRL distribution point
438
+ // referenced from issued certs (cert extension support is on the
439
+ // engine roadmap; for now operators set up the URL externally).
440
+ async function generateCrl(opts3) {
441
+ opts3 = opts3 || {};
442
+ if (typeof engine.generateCrl !== "function") {
443
+ throw new MtlsCaError("mtls-ca/engine-no-crl",
444
+ "configured engine does not implement generateCrl(); the bundled " +
445
+ "engine ships in v0.6.45+");
446
+ }
447
+ var ca = await initCA();
448
+ var revocations = _loadRevocations().revocations;
449
+ var nowMs = Date.now();
450
+ var thisUpdate = opts3.thisUpdate || new Date(nowMs);
451
+ var nextUpdate = opts3.nextUpdate ||
452
+ new Date(nowMs + 7 * 24 * 60 * 60 * 1000); // 7d default
453
+ var crlPem = await engine.generateCrl({
454
+ caCertPem: ca.caCertPem,
455
+ caKeyPem: ca.caKeyPem,
456
+ revocations: revocations,
457
+ thisUpdate: thisUpdate,
458
+ nextUpdate: nextUpdate,
459
+ });
460
+ if (typeof crlPem !== "string" || crlPem.length === 0) {
461
+ throw new MtlsCaError("mtls-ca/bad-engine-output",
462
+ "engine.generateCrl must return a non-empty PEM string");
463
+ }
464
+ if (opts3.persist !== false) {
465
+ var atomicFile = require("./atomic-file");
466
+ atomicFile.writeSync(paths.crl, crlPem, { mode: 0o644 });
467
+ }
468
+ return { crlPem: crlPem, thisUpdate: thisUpdate, nextUpdate: nextUpdate,
469
+ entryCount: revocations.length, path: paths.crl };
470
+ }
471
+
321
472
  return {
322
473
  exists: exists,
323
474
  keyExists: keyExists,
@@ -328,6 +479,10 @@ function create(opts) {
328
479
  initCA: initCA,
329
480
  generateClientCert: generateClientCert,
330
481
  generateClientP12: generateClientP12,
482
+ revoke: revoke,
483
+ isRevoked: isRevoked,
484
+ getRevocations: getRevocations,
485
+ generateCrl: generateCrl,
331
486
  paths: paths,
332
487
  generation: generation,
333
488
  caKeySealedMode: caKeySealedMode,