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), spelled here as the published text spells it. What this package supplies against that option, and what stays yours, is declared on the Conformance statement.
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 names four TLS 1.2 cipher suites an actor claiming the transport-security
option supports. They are listed here in both spellings that matter: the IANA spelling the standard
prints (and the one the 'tlsNegotiated' event below reports), and the OpenSSL spelling a
cipher-list string is written in.
| IANA name (ITI TF-2 §3.19.6.2.3) | OpenSSL name |
|---|---|
TLS_DHE_RSA_WITH_AES_128_GCM_SHA256 | DHE-RSA-AES128-GCM-SHA256 |
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 | ECDHE-RSA-AES128-GCM-SHA256 |
TLS_DHE_RSA_WITH_AES_256_GCM_SHA384 | DHE-RSA-AES256-GCM-SHA384 |
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 | ECDHE-RSA-AES256-GCM-SHA384 |
Selecting the option: atnaTransportSecurity
Off by default. With it off, @cosyte/mllp imposes no cipher list at all and the offered list
is the runtime's, which a Node distribution may configure at build time and an operator may replace
wholesale from outside the process (--tls-cipher-list, NODE_OPTIONS). What a link offers is then
a property of the deployment, not of this package.
Set atnaTransportSecurity: true on either side and the offered list becomes those four suites and
nothing else, from this package rather than from the runtime:
const server = createServer({
tls: { cert: certPem, key: keyPem, atnaTransportSecurity: true },
});
const client = createClient({
host: "mllp.example.com",
port: 2575,
tls: { ca: caPem, atnaTransportSecurity: true },
});
import { ATNA_CIPHER_SUITES, TLS13_DEFAULT_CIPHER_SUITES } from "@cosyte/mllp";
ATNA_CIPHER_SUITES.length; // => 4
TLS13_DEFAULT_CIPHER_SUITES.length; // => 3
Four things to know before you turn it on:
- It only ever narrows what is offered, never widens it. A peer that supports none of the four fails the handshake instead of negotiating something else. That is the point, and it is why the option is off by default: switching it on can stop a previously working link.
- TLS 1.3 is unaffected. A TLS 1.3 suite is enabled only by its full name in the cipher list, so
a list holding the four TLS 1.2 suites alone would silently turn TLS 1.3 off. The three TLS
1.3 suites the runtime enables by default (
TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_AES_128_GCM_SHA256) are therefore offered alongside the four. Two peers that reach TLS 1.3 without the option still reach TLS 1.3 with it. - The server also provides ephemeral Diffie-Hellman parameters. Two of the four suites are DHE,
and a server with no DH parameters cannot actually offer a DHE suite: it would advertise the list
and then fail every DHE handshake in it. The group is the one the TLS library selects for the
certificate in use, unless you name your own with
dhParameters(next section), which wins. - It is mutually exclusive with
ciphers. Both declare the offered list, so setting both rejectsconnect()/listen()with a typedMllpTlsConfigurationErrorrather than silently discarding one of them.
ciphers (an OpenSSL cipher-list string) remains available on either side for a list of your own
choosing. A list the runtime rejects is a loud, typed failure at connect or listen time; it never
falls back to the runtime default list. See "Typed failure modes" below.
Supplying your own group: dhParameters
Two of the four suites are DHE, and a server with no ephemeral Diffie-Hellman parameters cannot
offer a DHE suite at all: it advertises the suite and then fails every handshake in it.
ServerTlsOptions.dhParameters is how you hand it a group.
import { readFileSync } from "node:fs";
const server = createServer({
tls: {
cert: certPem,
key: keyPem,
ciphers: "DHE-RSA-AES128-GCM-SHA256",
dhParameters: readFileSync("dhparam.pem", "utf8"), // openssl dhparam -out dhparam.pem 3072
},
});
Two reasons to reach for it:
- Your site policy names a group. Turning on
atnaTransportSecuritygets you a group, but it is the one the TLS library selects for the certificate in use, not one you chose. - You restricted
ciphersyourself to a list containing a DHE suite. Without parameters that list is unofferable in its DHE half, and this is the only way to make it work.
What to know:
- The value is PEM content, never a filesystem path. Every credential on these types is content and this package performs no disk IO for any of it: read the file yourself, as above. A path string is not a Diffie-Hellman parameter block and is refused as such.
- It takes precedence over the group
atnaTransportSecurityselects. Setting both is not a conflict and is the intended combination for a deployment with its own policy: the option decides which suites are offered, and this decides which group answers the DHE half of them. UnlikeatnaTransportSecurityandciphers, which both declare the offered list and therefore refuse each other, these two declare different things. - There is no client-side counterpart, deliberately. The side that answers the key exchange
supplies the parameters, so this exists on
ServerTlsOptionsand not onTlsOptions. "auto"is not accepted here. That is the TLS library's own spelling for its automatic selection, andatnaTransportSecurityis this package's way of asking for it.- Parameters that cannot be used reject
listen(), withMLLP_TLS_DH_PARAMETERS_REJECTED(see the table below). Nothing binds, and nothing falls back to a server running without them. That check exists because the TLS library's own behaviour for parameters it cannot read is to discard them in silence, which leaves a listener that answers no DHE handshake and says nothing about why. - The armour has to survive the trip. The TLS library reads PEM a line at a time and wants each
-----BEGIN DH PARAMETERS-----/-----END DH PARAMETERS-----boundary alone on its own line, with exactly five dashes on each side. A value that crosses an environment variable, a single-line JSON field or any other whitespace-collapsing layer loses its line breaks and stops being a parameter block, so it is refused here rather than discarded quietly later. Everything the library itself tolerates is tolerated: CRLF endings, a missing final newline, text before or after the block, and a body wrapped at any width or not at all. - The body has to be the whole parameter structure, and only it. Behind armour the library
reads, what it then parses is a prime, a generator, and at most one optional private-value length
behind them. A block carrying anything else, an extra field of any kind or a private-value length
wider than the 32 bits the library reads it at, is discarded just as silently as damaged armour
is, so it is refused here too. A parameter file from a related standard has exactly that extra
field, and armouring one under this label is the wrong file rather than a typo: take
openssl dhparamoutput and the case never arises. - The two values have to be inside the bounds the library uses. A perfect structure can still carry values the library will not answer a key exchange with, and that failure is invisible from outside: it loads the parameters, refuses them when the handshake asks for a key, and the listener advertises its DHE suite and answers nothing, exactly as if it had discarded them. So the same bounds are applied before anything binds: the prime must be odd, and the generator must be at least 2 and no greater than the prime minus 2.
- What is checked, and what is not. The block is read before anything binds: it must be PEM,
must be a
DH PARAMETERSblock, must decode to the parameter structure the library reads and nothing besides, must carry values inside the bounds above, and the library itself must accept the group (it refuses one below 1024 bits, and one below its configured security level). The one thing not checked is that the prime is prime, because a primality test costs seconds on a 3072-bit group and minutes on a large one, andlisten()is not the place to spend it. A well-formed block whose modulus is composite is therefore accepted here, as it is by the library itself, and the link it carries is weaker than its size suggests. Generate parameters with a tool that produces valid ones (openssl dhparam) rather than relying on this check to find out.
With neither atnaTransportSecurity nor dhParameters set, the server supplies no Diffie-Hellman
parameters at all, exactly as before either option existed.
What this does not do. It is the cipher-suite half of the transport-security option. Mutual
node authentication is clientAuth plus a client cert/key, and the TLS 1.2 floor is
minVersion, which already defaults to it. Claiming an IHE option is your declaration to make about
your actor: this package can support it and evidence it, and cannot make it on anyone's behalf.
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.
Cipher-suite configuration errors
A cipher-suite list this package cannot honour is refused before a socket exists, so nothing is
left connected and nothing is left bound. connect() and listen() reject with
MllpTlsConfigurationError, identified by instanceof plus a stable code, never by matching on
the message text:
code | Meaning |
|---|---|
MLLP_TLS_CIPHER_LIST_REJECTED | The TLS library refused the list: no suite in it is available in this build. There is no fallback to the runtime default list. |
MLLP_TLS_CIPHER_OPTION_CONFLICT | atnaTransportSecurity and ciphers both declare the offered list. Set exactly one. |
MLLP_TLS_DH_PARAMETERS_REJECTED | ServerTlsOptions.dhParameters is not usable: not PEM, not a DH PARAMETERS block, or a group the TLS library refuses. Nothing is bound and there is no fallback to a server running without them. Server side only. |
try {
await server.listen(2575);
} catch (err) {
if (err instanceof MllpTlsConfigurationError && err.code === MLLP_TLS_CIPHER_LIST_REJECTED) {
// A configuration problem, not a network one. Nothing is listening.
}
}
The list is validated on its own, with no certificate, key or passphrase in scope, so the error
cannot carry credential material. Its message is a fixed registry entry per code. A server
constructed with a refused configuration stays refused: every listen() on it rejects the same way,
and it never serves on some other list.
Observability
client.getStats().tls and server.getStats().tls report whether TLS is configured.
server.getStats().tlsClientErrorsTotal counts 'tlsClientError' events since listen().
What each link actually negotiated
Both the client and the server emit a frozen 'tlsNegotiated' event once per completed TLS
handshake, carrying the protocol version and cipher suite that link agreed on. That is what turns a
conformance claim into something a deployment can evidence from its own logs rather than assert.
client.on("tlsNegotiated", ({ protocolVersion, cipherSuite, host, port }) => {
logger.info({ tls: protocolVersion, suite: cipherSuite, host, port });
// e.g. { tls: 'TLSv1.2', suite: 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256', ... }
});
server.on("tlsNegotiated", ({ protocolVersion, cipherSuite }) => {
metrics.increment(`mllp.tls.${protocolVersion}.${cipherSuite}`);
});
protocolVersion, as the TLS library reports it:'TLSv1.2'or'TLSv1.3'.cipherSuite, in its IANA spelling, the one ITI TF-2 §3.19.6.2.3 prints, so it compares against a conformance claim directly. The OpenSSL spelling is a different rendering of the same suite; see the table above.host/port, the same routing metadata a'securityWarning'carries: the target address for a client, the bound address for a server.timestamp, wall-clock time at emission.
It fires once per connection, on the initial connect and on every reconnect, so a log records every session rather than only the first. It is never emitted for a plaintext connection: there is nothing negotiated to report. It carries no HL7 payload content and no certificate or key material, and it is emitted at handshake-completion time, before any HL7 byte has crossed the link.
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. - Claiming an IHE option stays your declaration to make.
atnaTransportSecurityfixes what a link offers and'tlsNegotiated'evidences what it agreed on, but the option is claimed by an actor, not by a transport library, and this package cannot make that claim on anyone's behalf. - With the option off, the cipher list is the runtime's, and the runtime's is configurable at build time by a Node distribution and replaceable from outside the process. 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).