Mail Server TLS Bootstrap

Operator-UX helper for the tlsContext opt on b.mail.server.mx and b.mail.server.submission. Both listeners refuse to boot without a tlsContext by design (no implicit plaintext mode); pre-this-primitive operators had to wire node:tls.createSecureContext themselves plus solve cert renewal + sealed-storage-of-keys + in- process reload-on-rotation. This primitive owns the wiring.

var tlsCtx = b.mail.server.tls.context({
  certFile:   "/etc/letsencrypt/live/mail.example.com/fullchain.pem",
  keyFile:    "/var/lib/blamejs/mail.example.com.key.sealed",
  vault:      b.vault,                  // for keyFile unseal
  watch:      true,                     // auto-reload on rotation
});

var mx = b.mail.server.mx.create({
  tlsContext: tlsCtx.secureContext,
  ...
});

The helper handles the three things operators need but were reinventing per-deployment:

1. **Sealed-key unwrap** — operators who store the private key on disk via b.vault.sealPemFile (recommended posture per SECURITY.md) pass vault: b.vault here and the helper unseals at load-time, never holding the plaintext key longer than the tls.createSecureContext call.

2. **Cert-rotation in-process reload** — when watch: true, the helper polls certFile + keyFile for mtime changes (default 30s poll, matching the framework's vault-pem-file convention). On change, the helper builds a fresh SecureContext and emits a mail.server.tls.context_reloaded audit event. Operators who wire tlsCtx.onReload(fn) get a callback so the running listener's SecureContext reference can be swapped.

3. **Boot-fail surface** — missing/unreadable file, unsealable key, mismatched cert/key pair, expired cert — all surfaced at context() call with a typed MailServerTlsError so the operator's boot path fails fast at the right line, not 20 stack frames deep inside the listener.

## ACME provisioning

This primitive does NOT drive ACME issuance — that's b.acme's job (RFC 8555 + RFC 9773 ARI). The operator's deployment script / sidecar / systemd-timer orchestrates b.acme.renewIfDue and writes the renewed cert + key to certFile / keyFile. The watch-loop here picks up the change and reloads. Composing this way keeps the TLS-context helper unaware of which ACME provider the operator picked (Let's Encrypt / ZeroSSL / Buypass / step-ca / internal PKI) and unaware of which challenge type (HTTP-01 / DNS-01 / TLS-ALPN-01) the deployment uses.

For a turnkey ACME-and-then-load path operators wire the two primitives at deploy-time:

// Once per deploy (sidecar / systemd-timer / k8s CronJob):
var acme = b.acme.create({ directoryUrl: "https://acme-v02.api.letsencrypt.org/directory", ... });
// ... acme.newAccount + acme.newOrder + challenge-solve + acme.finalize ...
//   → write the issued cert.pem + key.pem to the watched paths

// Once per process at boot:
var tls = b.mail.server.tls.context({ certFile, keyFile, watch: true });
var mx  = b.mail.server.mx.create({ tlsContext: tls.secureContext, ... });
tls.onReload(function (newCtx) { mx.replaceTlsContext(newCtx); });

The cleartext-refused error message from b.mail.server.mx / b.mail.server.submission points at this primitive so the operator's boot dead-end becomes a one-line fix.

b.mail.server.tls.context(opts) #

stable0.9.48
{
  certFile:   string,      // required — PEM-encoded fullchain
  keyFile:    string,      // required — PEM-encoded private key (raw OR sealed)
  vault:      object,      // optional — b.vault; when supplied + keyFile
                           //   starts with the b.vault.sealPemFile magic
                           //   ("vault:"), unsealed before use
  watch:      boolean,     // default false — when true, poll for rotation
  pollMs:     number,      // default 30000; min 1000
}

Build a node:tls SecureContext from cert + key PEM file paths. Returns a handle exposing secureContext, reload(), onReload(fn), and stop(). When watch: true, the helper polls both files for mtime changes (default every 30s) and rebuilds the context in-place on change — operators wire onReload to swap the running listener's context after cert rotation.

var tls = b.mail.server.tls.context({
  certFile: "/etc/letsencrypt/live/mail.example.com/fullchain.pem",
  keyFile:  "/etc/letsencrypt/live/mail.example.com/privkey.pem",
  watch:    true,
});
// Wire `tls.secureContext` into b.mail.server.mx.create / submission.create
tls.onReload(function (newCtx) {
  // operator swaps the running listener's SecureContext via the
  // listener's reload hook (when the listener exposes one) or via
  // restart-on-rotation flow
});

// ... later, on shutdown:
tls.stop();   // clears the poll timer

b.mail.server.tls.upgradeSocket(opts) #

stable0.9.57
{
  plainSocket:     net.Socket,                 // pre-upgrade socket
  secureContext:   tls.SecureContext,          // from b.mail.server.tls.context
  idleTimeoutMs:   number,                     // re-armed post-handshake
  onSecure:        function(tlsSocket),        // called once "secure" fires
  onData:          function(tlsSocket, chunk), // post-handshake ingest
  onError:         function(err),              // handshake / runtime error
  onTimeout:       function(tlsSocket),        // optional idle timeout cb
}

STARTTLS / STLS upgrade primitive shared by every mail-protocol listener (MX / submission / IMAP / POP3). Wraps the four-step dance every listener was inlining and that has been a recurring source of cleartext-injection bugs (CVE-2021-33515 Dovecot, CVE-2021-38371 Exim) when even one of the four steps is forgotten:

1. Remove ALL "data" listeners from the plain socket so any bytes the peer queued in the TCP receive buffer before the handshake do NOT reach the plaintext state machine after the socket has been re-typed as a TLSSocket. Without listener removal, plain-mode bytes pipelined ahead of the handshake reach the post-TLS dispatcher and execute under the authenticated TLS context. 2. Pause the plain socket so no further bytes flow through the old handler in the window before the TLSSocket attaches. 3. Re-arm the idle timeout on the new TLSSocket (the plain socket's setTimeout does not survive the upgrade — RFC 5321 §4.5.3.2.7 idle timeouts must keep running post-handshake). 4. Wire "secure" / "data" / "error" handlers via callbacks so the caller's per-protocol state machine keeps owning the ingest logic.

b.mail.server.tls.upgradeSocket({
  plainSocket:   socket,
  secureContext: opts.tlsContext,
  idleTimeoutMs: idleTimeoutMs,
  onSecure:      function (tlsSocket) { state.tls = true; },
  onData:        function (tlsSocket, chunk) { _ingest(state, tlsSocket, chunk); },
  onError:       function (err) { _emit("tls.handshake_failed", { err: err.message }); },
});

b.mail.server.tls.upgradeLineProtocol(opts) #

stable0.15.13
{
  state:         object,                       // connection state ({ lineBuffer, tls, … })
  socket:        net.Socket,                   // pre-upgrade plain socket
  secureContext: tls.SecureContext,            // from b.mail.server.tls.context
  idleTimeoutMs: number,                       // re-armed post-handshake
  clearFields:   Array,                // extra state fields to null pre-upgrade
  drain:         function(state, tlsSocket),   // the protocol line drainer
  onSecure:      function(tlsSocket),          // optional post-secure work
  onError:       function(err),                // handshake / runtime error
  onTimeout:     function(tlsSocket),          // optional idle-timeout handler
}

STARTTLS / STLS completion for the line-buffered store listeners (IMAP / POP3 / ManageSieve), layered over upgradeSocket. The caller has already validated protocol state and written its "begin TLS" response; this owns the steps that recur identically across the three:

1. Drop the pre-handshake state.lineBuffer (always) plus any protocol-specific half-parsed command / literal / auth fields (clearFields) so bytes the peer pipelined before the upgrade cannot survive into the post-TLS session (CVE-2021-33515 / CVE-2021-38371 STARTTLS-injection class). Centralizing the lineBuffer reset makes it impossible for a listener to forget. 2. Mark state.tls = true on the secure event, then run the caller's optional onSecure for protocol-specific work (e.g. ManageSieve re-emitting its capability banner per RFC 5804). 3. Feed every post-handshake chunk through the caller's drain via the standard state.lineBuffer append.

The transfer listeners (MX / submission) ingest via a serialized feed pump, not the line buffer, so they call upgradeSocket directly.

b.mail.server.tls.upgradeLineProtocol({
  state: state, socket: socket, secureContext: opts.tlsContext,
  idleTimeoutMs: idleTimeoutMs, clearFields: ["pendingLiteral"],
  drain: _drainBuffer,
  onError: function (err) { _emit("imap.tls_failed", { err: err.message }); _close(socket, state); },
});

Last updated 2026-08-08T16:39:49.652Z by seeder.