MLLPS / TLS
@cosyte/mllp supports TLS-wrapped MLLP ("MLLPS") on both the client and the server, built on
Node's node:tls: no bundled TLS implementation, no extra dependency. This page covers enabling
TLS, mutual TLS (mTLS), the verification-on-by-default posture, bind safety, the TLS 1.2 floor, and
the typed failure modes.
Spec anchor: IHE ATNA, ITI-19 Authenticate Node (https://profiles.ihe.net/ITI/TF/Volume2/ITI-19.html), the "STX: TLS 1.2 Floor using BCP195" option, ITI TF-2 §3.19.6.2.3.
Enabling TLS
Client: pass tls: true for all-defaults (verification on), or a TlsOptions object to
customize:
import { createClient } from "@cosyte/mllp";
const client = createClient({
host: "mllp.example.com",
port: 2575,
tls: { ca: caPem }, // trust this CA; verification is on by default
});
await client.connect();
Server: ServerOptions.tls requires cert + key:
import { createServer } from "@cosyte/mllp";
const server = createServer({
tls: { cert: certPem, key: keyPem },
});
await server.listen(2575, "127.0.0.1");
When TLS is configured, the server consumes tls.Server's 'secureConnection' event (post-handshake
sockets) instead of net.Server's 'connection', and the client's onConnect transport hook maps
to 'secureConnect' (handshake complete) rather than the raw TCP 'connect'.
Mutual TLS (mTLS)
ServerTlsOptions.clientAuth selects the ATNA ITI-19 mutual-authentication mode:
clientAuth | Client certificate | Behavior |
|---|---|---|
'NONE' (default) | Not requested | Standard server-authenticated TLS only. |
'WANT' | Requested, not required | An absent or untrusted client cert does not reject the connection; the peer certificate (if any) is surfaced on the 'connection' event as peerCertificate. |
'MUST' | Requested and required | ATNA mutual node authentication. A missing or untrusted client certificate rejects the handshake; the server never accepts the connection. |
const server = createServer({
tls: { cert: certPem, key: keyPem, ca: clientCaPem, clientAuth: "MUST" },
});
server.on("connection", ({ peerCertificate }) => {
// { subjectCN, issuerCN, validTo, authorized } | null, content-free, never the full cert object
if (peerCertificate !== null) logger.info({ clientCN: peerCertificate.subjectCN });
});
const client = createClient({
host: "mllp.example.com",
port: 2575,
tls: { ca: serverCaPem, cert: clientCertPem, key: clientKeyPem },
});
The 'connection' event's peerCertificate includes an authorized flag: whether the chain was
verified against ServerTlsOptions.ca. ⚠️ Under 'WANT', a peer certificate can be present yet
unverified (the connection is accepted regardless): never make authorization decisions on
subjectCN unless authorized is true. Under 'MUST' an unverified certificate never reaches
the 'connection' event, so authorized is always true there.
A note on TLS 1.3 and client-certificate rejection (RFC 8446 §4.4.2). Under TLS 1.3, a client's
own handshake (and therefore connect() resolving) can complete before a clientAuth: 'MUST'
server finishes validating the client's certificate. connect() resolving does not guarantee the
server accepted your client certificate. There is no synchronous signal at 'secureConnect' time
that reveals this, and the server's rejection alert arrives one network round-trip later. No fixed
wait can close that gap on a real network. @cosyte/mllp handles it by classification, not
timing: the rejection surfaces moments later as a typed post-connect error (an 'error' event
whose MllpConnectionError.cause carries the ERR_SSL_*/alert detail), and TLS-protocol-shaped
errors are classified permanent (see isTlsProtocolError). An autoReconnect client will not
loop against a server that will always reject it. ACK correlation remains the delivery
guarantee: send() never resolves without its ACK, so a rejected session can never silently
"deliver" a message.
Verification is on by default
Certificate verification defaults to on for every client connection. tls: true does not
relax it. The only opt-out is the explicit, loud allowUnverified flag:
const client = createClient({
host: "127.0.0.1",
port: 2575,
tls: { allowUnverified: true }, // NEVER do this against an untrusted network
});
There is no raw rejectUnauthorized surface on TlsOptions. allowUnverified is the only door,
and it is loud by design. Every successful secureConnect on a connection configured this way
(the initial connect and every reconnect) both:
- emits a frozen
'securityWarning'event:{ code: 'MLLP_TLS_VERIFY_DISABLED', message, host, port, timestamp } - calls
process.emitWarning(message, { code: 'MLLP_TLS_VERIFY_DISABLED' })
so an insecure connection cannot go unnoticed in logs, monitoring, or --trace-warnings output.
Bind safety
Two independent hardening changes apply to every MllpServer, TLS or plaintext:
- The default bind host is
'127.0.0.1', not'0.0.0.0'.listen(port)with no host binds loopback only. - Binding a wildcard host requires an explicit opt-in, enforced against the OS-normalized
bound address. Literal wildcard spellings (
'0.0.0.0','::','','::0','0:0:0:0:0:0:0:0','::ffff:0.0.0.0') are rejected before binding. Spellings only the resolver can see ('0','0.0','0x0.0.0.0', a hostname resolving to the unspecified address, …) are caught by a post-bind check on the address the OS actually bound (server.address(), always canonical): the just-bound server is closed immediately andlisten()rejects with the same typedMllpConnectionError. No listening state is left behind, and no'listening'event is emitted. Whatever the spelling, a wildcard bind never survives withoutServerOptions.allowWildcardBind: true:
await server.listen(2575, "0.0.0.0");
// rejects: MllpConnectionError, "refusing to bind wildcard host '0.0.0.0'.
// Set ServerOptions.allowWildcardBind: true to bind all interfaces"
const server = createServer({ allowWildcardBind: true });
await server.listen(2575, "0.0.0.0"); // binds; also emits a securityWarning
When a wildcard host is actually bound, the server emits the same loud pair as the TLS
verification opt-out: a frozen 'securityWarning' (code: 'MLLP_BIND_ALL_INTERFACES') and
process.emitWarning, once, at listen() time.
listen() is also single-flight: a call while the server is already listening (or while
another listen() is still in flight) rejects with a typed MllpConnectionError rather than
racing the first call's post-bind safety checks. Call close() before re-listening; sequential
listen() → close() → listen() works. This is what makes the no-state/no-event invariant
above unconditional: no concurrent bind can ever record listening state for a socket the safety
check just closed.
TLS 1.2 floor and cipher suites
minVersion defaults to 'TLSv1.2' on both the client and the server: the IHE ATNA ITI-19 "STX:
TLS 1.2 Floor using BCP195" option (ITI TF-2 §3.19.6.2.3). TlsOptions/ServerTlsOptions only
accept 'TLSv1.2' | 'TLSv1.3' for minVersion/maxVersion. TLS 1.0/1.1 are not expressible
through this API; the floor cannot be lowered by configuration.
ITI TF-2 §3.19.6.2.3 mandates four TLS 1.2 cipher suites:
TLS_DHE_RSA_WITH_AES_128_GCM_SHA256TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256TLS_DHE_RSA_WITH_AES_256_GCM_SHA384TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
@cosyte/mllp does not bundle a cipher list. ciphers is unset by default, which means Node's
compiled-in defaults. Node's default TLS 1.2 cipher list already includes both mandated ECDHE
suites above. If your deployment requires the DHE suites specifically, or a stricter list, pass
ciphers (an OpenSSL cipher-list string) on either side.
Typed failure modes
On the initial connect() path, TLS failures reject with MllpConnectionError carrying an
additive connectionCause. (Failures on the auto-reconnect path surface as raw socket errors:
the permanence classification below still applies to them, but they do not carry a
connectionCause; see Known limitations.)
'tls-verify': a certificate-verification failure: untrusted chain, expired/not-yet-valid certificate, hostname/SAN mismatch, revocation, and related codes (seeisTlsVerificationErrorCode, also exported for callers who want the same classification). Classified permanent byisTransientConnectionError. AnautoReconnectclient will not loop into a misconfigured or MITM'd endpoint; the state machine goes straight toCLOSED.'tls-handshake': a TLS-protocol-shaped handshake failure observed before'secureConnect':ERR_SSL_*codes,EPROTO, or an OpenSSL alert-bearing error (protocol version mismatch, no shared cipher, a required mutual-TLS client certificate rejected by the server, …). The exact boundary isisTlsProtocolError, exported for callers who want the same classification. Like'tls-verify', TLS-protocol-shaped errors are classified permanent for the reconnect classifier.- No
connectionCause: pure TCP-level failures (ECONNREFUSED,ETIMEDOUT,EHOSTUNREACH, a plainECONNRESET, …) even on a TLS-configured connection. These carry the same shape as plaintext connect failures and stay transient. A network blip during a handshake still auto-heals.
try {
await client.connect();
} catch (err) {
if (err instanceof MllpConnectionError && err.connectionCause === "tls-verify") {
// Do not retry blindly. This is a configuration or MITM problem, not a network blip.
}
}
Server-side, a failed handshake (including a rejected client certificate under clientAuth: 'MUST') never crashes the server and never stops it from serving other connections. It emits a
frozen 'tlsClientError' event: { remoteAddress, remotePort, message, code, timestamp }. Only
the error's message and code are surfaced, never payload bytes, never a certificate dump.
Observability
client.getStats().tls and server.getStats().tls report whether TLS is configured.
server.getStats().tlsClientErrorsTotal counts 'tlsClientError' events since listen().
Known limitations
- No PKI or CA management.
@cosyte/mllpconsumes PEM material you provide; it does not issue, rotate, or manage certificates. - No certificate rotation/reload. Certificates are read once at
tls.connect/tls.createServerconstruction time. Restart the process to rotate. - No CRL/OCSP beyond Node's defaults. Revocation checking is whatever
node:tlsdoes by default; there is no additional revocation-checking layer. - Cipher list is Node's compiled-in default unless you pass
ciphersexplicitly. See "TLS 1.2 floor and cipher suites" above. - Reconnect-path errors do not yet carry
connectionCause. Thetls-verify/tls-handshakelabels are attached on the initialconnect()path only; auto-reconnect failures surface as raw socket errors (their transient/permanent classification still applies).