@blamejs/core 0.9.49 → 0.10.1

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +951 -908
  2. package/index.js +25 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +67 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/compliance.js +176 -8
  25. package/lib/crypto-field.js +114 -14
  26. package/lib/crypto.js +216 -20
  27. package/lib/db.js +1 -0
  28. package/lib/guard-jmap.js +321 -0
  29. package/lib/guard-managesieve-command.js +566 -0
  30. package/lib/guard-pop3-command.js +317 -0
  31. package/lib/guard-smtp-command.js +58 -3
  32. package/lib/mail-agent.js +20 -7
  33. package/lib/mail-arc-sign.js +12 -8
  34. package/lib/mail-auth.js +323 -34
  35. package/lib/mail-crypto-pgp.js +934 -0
  36. package/lib/mail-crypto-smime.js +340 -0
  37. package/lib/mail-crypto.js +108 -0
  38. package/lib/mail-dav.js +1224 -0
  39. package/lib/mail-deploy.js +492 -0
  40. package/lib/mail-dkim.js +431 -26
  41. package/lib/mail-journal.js +435 -0
  42. package/lib/mail-scan.js +502 -0
  43. package/lib/mail-server-imap.js +64 -26
  44. package/lib/mail-server-jmap.js +488 -0
  45. package/lib/mail-server-managesieve.js +853 -0
  46. package/lib/mail-server-mx.js +40 -30
  47. package/lib/mail-server-pop3.js +836 -0
  48. package/lib/mail-server-rate-limit.js +13 -0
  49. package/lib/mail-server-submission.js +70 -24
  50. package/lib/mail-server-tls.js +445 -0
  51. package/lib/mail-sieve.js +557 -0
  52. package/lib/mail-spam-score.js +284 -0
  53. package/lib/mail.js +99 -0
  54. package/lib/metrics.js +80 -3
  55. package/lib/middleware/dpop.js +58 -3
  56. package/lib/middleware/idempotency-key.js +255 -42
  57. package/lib/middleware/protected-resource-metadata.js +114 -2
  58. package/lib/network-dns-resolver.js +33 -0
  59. package/lib/network-tls.js +46 -0
  60. package/lib/outbox.js +62 -12
  61. package/lib/pqc-agent.js +13 -5
  62. package/lib/retry.js +23 -9
  63. package/lib/router.js +23 -1
  64. package/lib/safe-ical.js +634 -0
  65. package/lib/safe-icap.js +502 -0
  66. package/lib/safe-mime.js +15 -0
  67. package/lib/safe-sieve.js +684 -0
  68. package/lib/safe-smtp.js +57 -0
  69. package/lib/safe-url.js +37 -0
  70. package/lib/safe-vcard.js +473 -0
  71. package/lib/self-update-standalone-verifier.js +32 -3
  72. package/lib/self-update.js +153 -33
  73. package/lib/vendor/MANIFEST.json +161 -156
  74. package/lib/vendor-data.js +127 -9
  75. package/lib/vex.js +324 -59
  76. package/package.json +1 -1
  77. package/sbom.cdx.json +6 -6
@@ -210,6 +210,19 @@ function create(opts) {
210
210
  var concurrent = concurrentByIp.get(ip) || 0;
211
211
  if (concurrent <= 1) concurrentByIp.delete(ip);
212
212
  else concurrentByIp.set(ip, concurrent - 1);
213
+ // BUG-2 — CWE-400. authFailureTimes auto-deletes when its array
214
+ // empties in checkAuthAdmit; connectionTimes was the asymmetric
215
+ // case. Sweep this IP's rate-window now that it has released its
216
+ // last concurrent slot: if the per-minute window has fully
217
+ // expired AND there's no live connection, drop the entry so a
218
+ // botnet of unique IPs cannot grow the Map without bound.
219
+ if (!concurrentByIp.has(ip)) {
220
+ var arr = connectionTimes.get(ip);
221
+ if (arr) {
222
+ _pruneWindow(arr, CONNECTION_RATE_WINDOW_MS);
223
+ if (arr.length === 0) connectionTimes.delete(ip);
224
+ }
225
+ }
213
226
  }
214
227
 
215
228
  function checkAuthAdmit(ip) {
@@ -113,6 +113,7 @@ var validateOpts = require("./validate-opts");
113
113
  var guardSmtpCommand = require("./guard-smtp-command");
114
114
  var guardDomain = require("./guard-domain");
115
115
  var mailServerRateLimit = require("./mail-server-rate-limit");
116
+ var mailServerTls = require("./mail-server-tls");
116
117
  var { defineClass } = require("./framework-error");
117
118
 
118
119
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -518,31 +519,40 @@ function create(opts) {
518
519
  return;
519
520
  }
520
521
  _writeReply(socket, REPLY_220_READY, "2.0.0 Ready to start TLS");
521
- // CVE-2021-38371 / CVE-2021-33515 defense: clear pre-handshake
522
- // buffer at upgrade time.
522
+ // CVE-2021-38371 (Exim) / CVE-2021-33515 (Dovecot) STARTTLS-
523
+ // injection defense: clear the pre-handshake command buffer +
524
+ // body collector AND strip the plain-socket "data" listener
525
+ // before wrapping in TLSSocket so bytes the peer pipelined
526
+ // pre-handshake cannot reach the post-TLS state machine.
523
527
  lineBuffer = ""; bodyCollector = null; inDataBody = false;
524
- var tlsSocket = new nodeTls.TLSSocket(socket, {
525
- isServer: true, secureContext: opts.tlsContext,
526
- });
527
- tlsSocket.on("secure", function () {
528
- state.tls = true; state.stage = "ehlo"; state.helo = null;
529
- // Authenticated state SURVIVES STARTTLS upgrade credentials
530
- // verified pre-STARTTLS under permissive remain valid post-
531
- // STARTTLS. Operator opts down to permissive only with this
532
- // tradeoff acknowledged.
533
- });
534
- tlsSocket.on("error", function (err) {
535
- _emit("mail.server.submission.tls_handshake_failed",
536
- { connectionId: state.id, code: (err && err.code) || "unknown" }, "failure");
537
- _closeConnection(socket);
538
- });
539
- tlsSocket.on("data", function (chunk) {
540
- try { _ingestBytes(state, tlsSocket, chunk); }
541
- catch (err) {
542
- _emit("mail.server.submission.handler_threw",
543
- { connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
528
+ mailServerTls.upgradeSocket({
529
+ plainSocket: socket,
530
+ secureContext: opts.tlsContext,
531
+ idleTimeoutMs: idleTimeoutMs,
532
+ onSecure: function (_tlsSocket) {
533
+ state.tls = true; state.stage = "ehlo"; state.helo = null;
534
+ // Authenticated state SURVIVES STARTTLS upgrade credentials
535
+ // verified pre-STARTTLS under permissive remain valid post-
536
+ // STARTTLS. Operator opts down to permissive only with this
537
+ // tradeoff acknowledged.
538
+ },
539
+ onData: function (tlsSocket, chunk) {
540
+ try { _ingestBytes(state, tlsSocket, chunk); }
541
+ catch (err) {
542
+ _emit("mail.server.submission.handler_threw",
543
+ { connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
544
+ _closeConnection(tlsSocket);
545
+ }
546
+ },
547
+ onError: function (err) {
548
+ _emit("mail.server.submission.tls_handshake_failed",
549
+ { connectionId: state.id, code: (err && err.code) || "unknown" }, "failure");
550
+ _closeConnection(socket);
551
+ },
552
+ onTimeout: function (tlsSocket) {
553
+ _writeReply(tlsSocket, REPLY_421_SERVICE_NOT_AVAIL, "4.4.2 Idle timeout");
544
554
  _closeConnection(tlsSocket);
545
- }
555
+ },
546
556
  });
547
557
  }
548
558
 
@@ -559,6 +569,17 @@ function create(opts) {
559
569
  "5.7.11 Encryption required for AUTH (RFC 4954 §4)");
560
570
  return;
561
571
  }
572
+ if (!state.tls && profile === "permissive") {
573
+ // Permissive profile accepts cleartext AUTH for legacy
574
+ // operator-acknowledged downgrade per RFC 4954 §4 commentary,
575
+ // but the operator MUST see the event in the audit trail so
576
+ // a downgraded posture is visible without sniffing the wire.
577
+ // Emits before the verify call so a credential exposure on the
578
+ // cleartext channel is still attributed in the audit timeline.
579
+ _emit("mail.server.submission.auth_cleartext_accepted",
580
+ { connectionId: state.id, remoteAddress: state.remoteAddress,
581
+ profile: profile }, "warning");
582
+ }
562
583
  if (state.authenticated) {
563
584
  _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 Already authenticated");
564
585
  return;
@@ -625,12 +646,17 @@ function create(opts) {
625
646
  return;
626
647
  }
627
648
  if (result && result.ok === true && result.actor) {
649
+ // Capture the mechanism BEFORE nulling authPending — the
650
+ // audit event reports the mechanism that produced the
651
+ // successful verify, not whatever state.authPending happens
652
+ // to be at the post-null read (which is always null).
653
+ var successfulMechanism = state.authPending && state.authPending.mechanism;
628
654
  state.authenticated = true;
629
655
  state.actor = result.actor;
630
656
  state.authPending = null;
631
657
  _emit("mail.server.submission.auth_success", {
632
658
  connectionId: state.id,
633
- mechanism: state.authPending && state.authPending.mechanism,
659
+ mechanism: successfulMechanism,
634
660
  tenantId: result.actor.tenantId || null,
635
661
  scopes: Array.isArray(result.actor.scopes) ? result.actor.scopes : [],
636
662
  });
@@ -843,6 +869,26 @@ function create(opts) {
843
869
  _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 No valid recipients");
844
870
  return;
845
871
  }
872
+ // RFC 2920 PIPELINING race: a client may emit RCPT TO + DATA
873
+ // in the same TCP segment. The recipientPolicy callback is
874
+ // async; without this gate, `state.rcptsPending` > 0 means at
875
+ // least one recipient verdict has not yet returned, and DATA
876
+ // proceeding here would commit the message to a partially-
877
+ // resolved recipient set (refuse outcomes that arrive after
878
+ // the dot-terminator would be silently dropped because the
879
+ // transaction has already moved past the `rcpt` stage). 451
880
+ // 4.5.0 is transient — the sender retries; PIPELINING-aware
881
+ // clients receive the pipelined replies and reissue DATA
882
+ // cleanly.
883
+ if ((state.rcptsPending || 0) > 0) {
884
+ _emit("mail.server.submission.pipelining_data_race", {
885
+ connectionId: state.id, rcptsPending: state.rcptsPending,
886
+ rcptsCommitted: state.rcpts.length,
887
+ }, "denied");
888
+ _writeReply(socket, REPLY_451_LOCAL_ERROR,
889
+ "4.5.0 RCPT TO verdicts pending; reissue DATA after recipient replies");
890
+ return;
891
+ }
846
892
  _writeReply(socket, REPLY_354_START_INPUT, "End data with <CR><LF>.<CR><LF>");
847
893
  state.stage = "data-body";
848
894
  inDataBody = true;
@@ -0,0 +1,445 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mail.server.tls
4
+ * @nav Mail
5
+ * @title Mail Server TLS Bootstrap
6
+ * @order 538
7
+ *
8
+ * @intro
9
+ * Operator-UX helper for the `tlsContext` opt on `b.mail.server.mx`
10
+ * and `b.mail.server.submission`. Both listeners refuse to boot
11
+ * without a `tlsContext` by design (no implicit plaintext mode);
12
+ * pre-this-primitive operators had to wire `node:tls.createSecureContext`
13
+ * themselves plus solve cert renewal + sealed-storage-of-keys + in-
14
+ * process reload-on-rotation. This primitive owns the wiring.
15
+ *
16
+ * ```js
17
+ * var tlsCtx = b.mail.server.tls.context({
18
+ * certFile: "/etc/letsencrypt/live/mail.example.com/fullchain.pem",
19
+ * keyFile: "/var/lib/blamejs/mail.example.com.key.sealed",
20
+ * vault: b.vault, // for keyFile unseal
21
+ * watch: true, // auto-reload on rotation
22
+ * });
23
+ *
24
+ * var mx = b.mail.server.mx.create({
25
+ * tlsContext: tlsCtx.secureContext,
26
+ * ...
27
+ * });
28
+ * ```
29
+ *
30
+ * The helper handles the three things operators need but were
31
+ * reinventing per-deployment:
32
+ *
33
+ * 1. **Sealed-key unwrap** — operators who store the private key on
34
+ * disk via `b.vault.sealPemFile` (recommended posture per
35
+ * SECURITY.md) pass `vault: b.vault` here and the helper unseals
36
+ * at load-time, never holding the plaintext key longer than the
37
+ * `tls.createSecureContext` call.
38
+ *
39
+ * 2. **Cert-rotation in-process reload** — when `watch: true`, the
40
+ * helper polls `certFile` + `keyFile` for mtime changes (default
41
+ * 30s poll, matching the framework's vault-pem-file convention).
42
+ * On change, the helper builds a fresh `SecureContext` and emits
43
+ * a `mail.server.tls.context_reloaded` audit event. Operators
44
+ * who wire `tlsCtx.onReload(fn)` get a callback so the running
45
+ * listener's `SecureContext` reference can be swapped.
46
+ *
47
+ * 3. **Boot-fail surface** — missing/unreadable file, unsealable
48
+ * key, mismatched cert/key pair, expired cert — all surfaced at
49
+ * `context()` call with a typed `MailServerTlsError` so the
50
+ * operator's boot path fails fast at the right line, not 20
51
+ * stack frames deep inside the listener.
52
+ *
53
+ * ## ACME provisioning
54
+ *
55
+ * This primitive does NOT drive ACME issuance — that's `b.acme`'s
56
+ * job (RFC 8555 + RFC 9773 ARI). The operator's deployment script /
57
+ * sidecar / systemd-timer orchestrates `b.acme.renewIfDue` and
58
+ * writes the renewed cert + key to `certFile` / `keyFile`. The
59
+ * watch-loop here picks up the change and reloads. Composing this
60
+ * way keeps the TLS-context helper unaware of which ACME provider
61
+ * the operator picked (Let's Encrypt / ZeroSSL / Buypass / step-ca /
62
+ * internal PKI) and unaware of which challenge type (HTTP-01 /
63
+ * DNS-01 / TLS-ALPN-01) the deployment uses.
64
+ *
65
+ * For a turnkey ACME-and-then-load path operators wire the two
66
+ * primitives at deploy-time:
67
+ *
68
+ * ```js
69
+ * // Once per deploy (sidecar / systemd-timer / k8s CronJob):
70
+ * var acme = b.acme.create({ directoryUrl: "https://acme-v02.api.letsencrypt.org/directory", ... });
71
+ * // ... acme.newAccount + acme.newOrder + challenge-solve + acme.finalize ...
72
+ * // → write the issued cert.pem + key.pem to the watched paths
73
+ *
74
+ * // Once per process at boot:
75
+ * var tls = b.mail.server.tls.context({ certFile, keyFile, watch: true });
76
+ * var mx = b.mail.server.mx.create({ tlsContext: tls.secureContext, ... });
77
+ * tls.onReload(function (newCtx) { mx.replaceTlsContext(newCtx); });
78
+ * ```
79
+ *
80
+ * The cleartext-refused error message from `b.mail.server.mx` /
81
+ * `b.mail.server.submission` points at this primitive so the
82
+ * operator's boot dead-end becomes a one-line fix.
83
+ *
84
+ * @card
85
+ * Operator-UX helper for the TLS context required by b.mail.server.mx /
86
+ * .submission. Loads cert + key (with optional vault-sealed-key unwrap),
87
+ * watches for rotation, builds a node:tls SecureContext + emits an
88
+ * audit event on every reload. ACME provisioning stays in b.acme;
89
+ * this primitive just loads what's on disk and reloads when it changes.
90
+ */
91
+
92
+ var nodeFs = require("node:fs");
93
+ var nodeTls = require("node:tls");
94
+ var lazyRequire = require("./lazy-require");
95
+ var C = require("./constants");
96
+ var validateOpts = require("./validate-opts");
97
+ var { defineClass } = require("./framework-error");
98
+
99
+ var audit = lazyRequire(function () { return require("./audit"); });
100
+
101
+ var MailServerTlsError = defineClass("MailServerTlsError", { alwaysPermanent: true });
102
+
103
+ var DEFAULT_POLL_MS = C.TIME.seconds(30);
104
+
105
+ /**
106
+ * @primitive b.mail.server.tls.context
107
+ * @signature b.mail.server.tls.context(opts)
108
+ * @since 0.9.48
109
+ * @status stable
110
+ * @related b.mail.server.mx.create, b.mail.server.submission.create, b.vault.sealPemFile, b.acme.create
111
+ *
112
+ * Build a `node:tls` `SecureContext` from cert + key PEM file paths.
113
+ * Returns a handle exposing `secureContext`, `reload()`, `onReload(fn)`,
114
+ * and `stop()`. When `watch: true`, the helper polls both files for
115
+ * mtime changes (default every 30s) and rebuilds the context in-place
116
+ * on change — operators wire `onReload` to swap the running listener's
117
+ * context after cert rotation.
118
+ *
119
+ * @opts
120
+ * certFile: string, // required — PEM-encoded fullchain
121
+ * keyFile: string, // required — PEM-encoded private key (raw OR sealed)
122
+ * vault: object, // optional — b.vault; when supplied + keyFile
123
+ * // starts with the b.vault.sealPemFile magic
124
+ * // ("vault:"), unsealed before use
125
+ * watch: boolean, // default false — when true, poll for rotation
126
+ * pollMs: number, // default 30000; min 1000
127
+ *
128
+ * @example
129
+ * var tls = b.mail.server.tls.context({
130
+ * certFile: "/etc/letsencrypt/live/mail.example.com/fullchain.pem",
131
+ * keyFile: "/etc/letsencrypt/live/mail.example.com/privkey.pem",
132
+ * watch: true,
133
+ * });
134
+ * // Wire `tls.secureContext` into b.mail.server.mx.create / submission.create
135
+ * tls.onReload(function (newCtx) {
136
+ * // operator swaps the running listener's SecureContext via the
137
+ * // listener's reload hook (when the listener exposes one) or via
138
+ * // restart-on-rotation flow
139
+ * });
140
+ *
141
+ * // ... later, on shutdown:
142
+ * tls.stop(); // clears the poll timer
143
+ */
144
+ function context(opts) {
145
+ validateOpts.requireObject(opts, "b.mail.server.tls.context",
146
+ MailServerTlsError, "mail-server-tls/bad-opts");
147
+ validateOpts.requireNonEmptyString(opts.certFile,
148
+ "b.mail.server.tls.context: opts.certFile",
149
+ MailServerTlsError, "mail-server-tls/bad-cert-file");
150
+ validateOpts.requireNonEmptyString(opts.keyFile,
151
+ "b.mail.server.tls.context: opts.keyFile",
152
+ MailServerTlsError, "mail-server-tls/bad-key-file");
153
+ if (opts.vault !== undefined &&
154
+ (typeof opts.vault !== "object" || opts.vault === null ||
155
+ typeof opts.vault.unseal !== "function")) {
156
+ throw new MailServerTlsError("mail-server-tls/bad-vault",
157
+ "b.mail.server.tls.context: opts.vault must be a b.vault handle (.unseal fn)");
158
+ }
159
+ validateOpts.optionalBoolean(opts.watch,
160
+ "b.mail.server.tls.context: opts.watch",
161
+ MailServerTlsError, "mail-server-tls/bad-watch");
162
+ var pollMs = opts.pollMs === undefined ? DEFAULT_POLL_MS : opts.pollMs;
163
+ if (typeof pollMs !== "number" || !isFinite(pollMs) || pollMs < C.TIME.seconds(1)) {
164
+ throw new MailServerTlsError("mail-server-tls/bad-poll-ms",
165
+ "b.mail.server.tls.context: opts.pollMs must be a finite number >= 1000");
166
+ }
167
+
168
+ var certFile = opts.certFile;
169
+ var keyFile = opts.keyFile;
170
+ var vault = opts.vault || null;
171
+ var watch = opts.watch === true;
172
+ var reloadListeners = [];
173
+ var secureContext = null;
174
+ var lastCertMtime = 0;
175
+ var lastKeyMtime = 0;
176
+ var pollTimer = null;
177
+ var stopped = false;
178
+
179
+ function _readKey() {
180
+ var raw = nodeFs.readFileSync(keyFile, "utf8");
181
+ // b.vault.sealPemFile produces blobs that decrypt via vault.unseal.
182
+ // Detect by the sealed-cell prefix the framework's vault layer
183
+ // already documents (everything else passes through as plain PEM).
184
+ if (vault && raw.indexOf("vault:") === 0) {
185
+ try {
186
+ return vault.unseal(raw).toString("utf8");
187
+ } catch (e) {
188
+ throw new MailServerTlsError("mail-server-tls/unseal-failed",
189
+ "b.mail.server.tls.context: failed to unseal " + keyFile +
190
+ " via b.vault.unseal: " + (e && e.message ? e.message : String(e)));
191
+ }
192
+ }
193
+ return raw;
194
+ }
195
+
196
+ function _build() {
197
+ var certPem;
198
+ try {
199
+ certPem = nodeFs.readFileSync(certFile, "utf8");
200
+ } catch (e) {
201
+ throw new MailServerTlsError("mail-server-tls/cert-unreadable",
202
+ "b.mail.server.tls.context: cannot read certFile " + certFile + ": " +
203
+ (e && e.message ? e.message : String(e)));
204
+ }
205
+ var keyPem;
206
+ try {
207
+ keyPem = _readKey();
208
+ } catch (e) {
209
+ if (e && e.isFrameworkError) throw e;
210
+ throw new MailServerTlsError("mail-server-tls/key-unreadable",
211
+ "b.mail.server.tls.context: cannot read keyFile " + keyFile + ": " +
212
+ (e && e.message ? e.message : String(e)));
213
+ }
214
+ var ctx;
215
+ try {
216
+ ctx = nodeTls.createSecureContext({ cert: certPem, key: keyPem });
217
+ } catch (e) {
218
+ throw new MailServerTlsError("mail-server-tls/secure-context-failed",
219
+ "b.mail.server.tls.context: createSecureContext threw (mismatched cert/key? " +
220
+ "expired? bad PEM?): " + (e && e.message ? e.message : String(e)));
221
+ }
222
+ return ctx;
223
+ }
224
+
225
+ function _emit(action, metadata) {
226
+ try {
227
+ audit().safeEmit({
228
+ action: action,
229
+ outcome: "success",
230
+ metadata: metadata || {},
231
+ });
232
+ } catch (_e) { /* drop-silent — audit best-effort */ }
233
+ }
234
+
235
+ function reload() {
236
+ var fresh = _build();
237
+ secureContext = fresh;
238
+ try {
239
+ var cstat = nodeFs.statSync(certFile);
240
+ lastCertMtime = cstat.mtimeMs;
241
+ } catch (_e) { /* file disappeared between read + stat; tolerate */ }
242
+ try {
243
+ var kstat = nodeFs.statSync(keyFile);
244
+ lastKeyMtime = kstat.mtimeMs;
245
+ } catch (_e) { /* same */ }
246
+ _emit("mail.server.tls.context_reloaded",
247
+ { certFile: certFile, keyFile: keyFile });
248
+ for (var i = 0; i < reloadListeners.length; i++) {
249
+ try { reloadListeners[i](secureContext); }
250
+ catch (_e) { /* listener errors must not break the loop */ }
251
+ }
252
+ return secureContext;
253
+ }
254
+
255
+ function onReload(fn) {
256
+ if (typeof fn !== "function") {
257
+ throw new MailServerTlsError("mail-server-tls/bad-listener",
258
+ "b.mail.server.tls.context: onReload(fn) requires a function");
259
+ }
260
+ reloadListeners.push(fn);
261
+ }
262
+
263
+ function _poll() {
264
+ if (stopped) return;
265
+ var changed = false;
266
+ try {
267
+ var cs = nodeFs.statSync(certFile);
268
+ if (cs.mtimeMs !== lastCertMtime) changed = true;
269
+ } catch (_e) { /* file removed transiently mid-rotation; skip */ }
270
+ try {
271
+ var ks = nodeFs.statSync(keyFile);
272
+ if (ks.mtimeMs !== lastKeyMtime) changed = true;
273
+ } catch (_e) { /* same */ }
274
+ if (changed) {
275
+ try { reload(); }
276
+ catch (e) {
277
+ // Reload failed (likely mid-rotation, file half-written).
278
+ // Surface as audit but DON'T overwrite the live context —
279
+ // the listener keeps serving with the prior good cert until
280
+ // the next poll catches a clean snapshot.
281
+ try {
282
+ audit().safeEmit({
283
+ action: "mail.server.tls.reload_failed",
284
+ outcome: "failure",
285
+ metadata: { error: e && e.message ? e.message : String(e) },
286
+ });
287
+ } catch (_e) { /* drop-silent */ }
288
+ }
289
+ }
290
+ }
291
+
292
+ function stop() {
293
+ stopped = true;
294
+ if (pollTimer) {
295
+ clearInterval(pollTimer);
296
+ pollTimer = null;
297
+ }
298
+ }
299
+
300
+ // Initial build — propagates boot-fail typed errors to the caller.
301
+ secureContext = _build();
302
+ try {
303
+ lastCertMtime = nodeFs.statSync(certFile).mtimeMs;
304
+ lastKeyMtime = nodeFs.statSync(keyFile).mtimeMs;
305
+ } catch (_e) { /* file disappeared between read + stat; tolerate */ }
306
+
307
+ if (watch) {
308
+ pollTimer = setInterval(_poll, pollMs);
309
+ if (typeof pollTimer.unref === "function") pollTimer.unref();
310
+ }
311
+
312
+ return {
313
+ get secureContext() { return secureContext; },
314
+ reload: reload,
315
+ onReload: onReload,
316
+ stop: stop,
317
+ };
318
+ }
319
+
320
+ /**
321
+ * @primitive b.mail.server.tls.upgradeSocket
322
+ * @signature b.mail.server.tls.upgradeSocket(opts)
323
+ * @since 0.9.57
324
+ * @status stable
325
+ * @related b.mail.server.tls.context, b.mail.server.mx.create, b.mail.server.submission.create
326
+ *
327
+ * STARTTLS / STLS upgrade primitive shared by every mail-protocol
328
+ * listener (MX / submission / IMAP / POP3). Wraps the four-step dance
329
+ * every listener was inlining and that has been a recurring source
330
+ * of cleartext-injection bugs (CVE-2021-33515 Dovecot,
331
+ * CVE-2021-38371 Exim) when even one of the four steps is forgotten:
332
+ *
333
+ * 1. Remove ALL `"data"` listeners from the plain socket so any
334
+ * bytes the peer queued in the TCP receive buffer before the
335
+ * handshake do NOT reach the plaintext state machine after the
336
+ * socket has been re-typed as a TLSSocket. Without listener
337
+ * removal, plain-mode bytes pipelined ahead of the handshake
338
+ * reach the post-TLS dispatcher and execute under the
339
+ * authenticated TLS context.
340
+ * 2. Pause the plain socket so no further bytes flow through the
341
+ * old handler in the window before the TLSSocket attaches.
342
+ * 3. Re-arm the idle timeout on the new TLSSocket (the plain
343
+ * socket's `setTimeout` does not survive the upgrade — RFC 5321
344
+ * §4.5.3.2.7 idle timeouts must keep running post-handshake).
345
+ * 4. Wire `"secure"` / `"data"` / `"error"` handlers via callbacks
346
+ * so the caller's per-protocol state machine keeps owning the
347
+ * ingest logic.
348
+ *
349
+ * @opts
350
+ * plainSocket: net.Socket, // pre-upgrade socket
351
+ * secureContext: tls.SecureContext, // from b.mail.server.tls.context
352
+ * idleTimeoutMs: number, // re-armed post-handshake
353
+ * onSecure: function(tlsSocket), // called once "secure" fires
354
+ * onData: function(tlsSocket, chunk), // post-handshake ingest
355
+ * onError: function(err), // handshake / runtime error
356
+ * onTimeout: function(tlsSocket), // optional idle timeout cb
357
+ *
358
+ * @example
359
+ * b.mail.server.tls.upgradeSocket({
360
+ * plainSocket: socket,
361
+ * secureContext: opts.tlsContext,
362
+ * idleTimeoutMs: idleTimeoutMs,
363
+ * onSecure: function (tlsSocket) { state.tls = true; },
364
+ * onData: function (tlsSocket, chunk) { _ingest(state, tlsSocket, chunk); },
365
+ * onError: function (err) { _emit("tls.handshake_failed", { err: err.message }); },
366
+ * });
367
+ */
368
+ function upgradeSocket(opts) {
369
+ if (!opts || typeof opts !== "object") {
370
+ throw new MailServerTlsError("mail-server-tls/bad-upgrade-opts",
371
+ "upgradeSocket: opts required");
372
+ }
373
+ var plainSocket = opts.plainSocket;
374
+ if (!plainSocket || typeof plainSocket.removeAllListeners !== "function") {
375
+ throw new MailServerTlsError("mail-server-tls/bad-upgrade-socket",
376
+ "upgradeSocket: opts.plainSocket must be a net.Socket");
377
+ }
378
+ if (!opts.secureContext) {
379
+ throw new MailServerTlsError("mail-server-tls/bad-upgrade-context",
380
+ "upgradeSocket: opts.secureContext required");
381
+ }
382
+ if (typeof opts.onSecure !== "function") {
383
+ throw new MailServerTlsError("mail-server-tls/bad-upgrade-onsecure",
384
+ "upgradeSocket: opts.onSecure(tlsSocket) required");
385
+ }
386
+ if (typeof opts.onData !== "function") {
387
+ throw new MailServerTlsError("mail-server-tls/bad-upgrade-ondata",
388
+ "upgradeSocket: opts.onData(tlsSocket, chunk) required");
389
+ }
390
+ if (typeof opts.onError !== "function") {
391
+ throw new MailServerTlsError("mail-server-tls/bad-upgrade-onerror",
392
+ "upgradeSocket: opts.onError(err) required");
393
+ }
394
+ var idleTimeoutMs = opts.idleTimeoutMs;
395
+ if (idleTimeoutMs !== undefined &&
396
+ (typeof idleTimeoutMs !== "number" || !isFinite(idleTimeoutMs) || idleTimeoutMs < 0)) {
397
+ throw new MailServerTlsError("mail-server-tls/bad-upgrade-idle-timeout",
398
+ "upgradeSocket: opts.idleTimeoutMs must be a non-negative finite number");
399
+ }
400
+
401
+ // CVE-2021-33515 / CVE-2021-38371 defense: strip every "data"
402
+ // listener on the plain socket BEFORE the TLSSocket wraps it.
403
+ // Without this, plain-mode bytes the peer queued pre-handshake
404
+ // (RFC 2920 PIPELINING + an unsuspecting parser) reach the
405
+ // post-TLS dispatcher and execute as if they had been sent over
406
+ // the authenticated channel.
407
+ plainSocket.removeAllListeners("data");
408
+ // Pause so the kernel TCP buffer doesn't drain into the old
409
+ // handler in the window before TLSSocket attaches its own.
410
+ if (typeof plainSocket.pause === "function") {
411
+ try { plainSocket.pause(); } catch (_e) { /* tolerate already-closed */ }
412
+ }
413
+
414
+ var tlsSocket = new nodeTls.TLSSocket(plainSocket, {
415
+ isServer: true,
416
+ secureContext: opts.secureContext,
417
+ });
418
+
419
+ tlsSocket.on("secure", function () {
420
+ if (idleTimeoutMs !== undefined && typeof tlsSocket.setTimeout === "function") {
421
+ try { tlsSocket.setTimeout(idleTimeoutMs); }
422
+ catch (_e) { /* tolerate */ }
423
+ }
424
+ if (typeof opts.onTimeout === "function") {
425
+ tlsSocket.on("timeout", function () { opts.onTimeout(tlsSocket); });
426
+ }
427
+ try { opts.onSecure(tlsSocket); }
428
+ catch (e) { try { opts.onError(e); } catch (_e) { /* drop-silent */ } }
429
+ });
430
+ tlsSocket.on("data", function (chunk) {
431
+ try { opts.onData(tlsSocket, chunk); }
432
+ catch (e) { try { opts.onError(e); } catch (_e) { /* drop-silent */ } }
433
+ });
434
+ tlsSocket.on("error", function (err) {
435
+ try { opts.onError(err); } catch (_e) { /* drop-silent */ }
436
+ });
437
+
438
+ return tlsSocket;
439
+ }
440
+
441
+ module.exports = {
442
+ context: context,
443
+ upgradeSocket: upgradeSocket,
444
+ MailServerTlsError: MailServerTlsError,
445
+ };