@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.
- package/CHANGELOG.md +951 -908
- package/index.js +25 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +67 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-smtp-command.js +58 -3
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +64 -26
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +40 -30
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +13 -0
- package/lib/mail-server-submission.js +70 -24
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +80 -3
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +153 -33
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- 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
|
|
522
|
-
//
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
{
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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:
|
|
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
|
+
};
|