ex_ssl is an experimental TLS implementation for Elixir/OTP with two primary goals:
- provide a client API that is behaviorally compatible with Erlang/OTP
:sslwhere implemented; - provide programmable TLS ClientHello wire profiles for applications that need precise control over observable TLS handshake behavior.
The OTP application is :ex_ssl. The public compatibility module is SSL (Elixir.SSL), which does not conflict with Erlang's built-in :ssl module.
Status: experimental TLS 1.3 client runtime with authenticated connections, passive and active-once binary/raw traffic, application ownership transfer, ALPN lookup, bounded streaming writes, initial-handshake client authentication, and TCP-to-TLS upgrades. See the compatibility matrix for exact restrictions. OTP
:sslremains the default recommendation.
Version 0.4 also supports ordered TLS 1.3 algorithm options, explicit certificate-chain signature restrictions, one client identity, and a validated TCP option allowlist. These additions require ex_ssl 0.4 or newer; see compatibility and the implementation ledger for executed gates.
Add the ex_ssl package to your dependencies:
def deps do
[
{:ex_ssl, "~> 0.5.0"}
]
endThe Hex package name is ex_ssl.
OTP :ssl is the correct default TLS implementation for normal Erlang/Elixir applications. ex_ssl exists for cases where an application also needs deterministic or profile-driven control of the ClientHello wire representation, including characteristics such as:
- cipher-suite ordering;
- extension ordering;
- supported versions/groups;
- KeyShare shape and placement;
- signature algorithm ordering;
- ALPN ordering;
- GREASE placement/policy;
- legacy session-id/compatibility behavior;
- padding and selected record-shaping behavior.
The library treats these as a wire profile, not as a JA3 configuration. JA3 and JA4 are derived fingerprints of the emitted/parsed ClientHello.
Package / repository: ex_ssl
OTP application: :ex_ssl
Public API: SSL
Internal namespace: SSL.*
Migration is intended to look like:
# OTP
:ssl.connect(host, port, options)
# ex_ssl
SSL.connect(host, port, options)Custom behavior is additive and namespaced:
SSL.connect(host, port,
verify: :verify_peer,
server_name_indication: host,
ex_ssl: [
profile: :default
]
)The supported runtime baselines are Elixir 1.18 on Erlang/OTP 28, Elixir 1.19 on
Erlang/OTP 28, and Elixir 1.20 on Erlang/OTP 29. CI covers all three tuples. OTP 29
remains the behavioral reference target for the implemented :ssl-compatible client
feature subset.
The validation and security evidence map documents mandatory peer/runtime gates and the reproducible pinned downstream source-candidate/package check. Independent human security review remains incomplete.
The initial compatibility baseline is the Erlang/OTP 29 :ssl client API.
Implemented client functions are connect/2,3,4, send/2, recv/2,3,
close/1, setopts/2, controlling_process/2, and
negotiated_protocol/1, connection_information/1,2, peercert/1,
peername/1, and sockname/1. Full handshakes verify CertificateVerify and
Finished; resumed handshakes verify the ticket-bound Finished. Connect succeeds
after the client Finished is transmitted. The connection runs
as a temporary supervised :gen_statem; a failed session is never restarted.
Defaults are deliberately restricted to binary, passive, raw, verified TLS 1.3. They differ from OTP's defaults. Supported options and receive/upgrade ownership rules are documented in the compatibility matrix.
TLS 1.3 resumption is opt-in with session_tickets: :auto; the default is
:disabled. Auto requires TLS 1.3-only versions and no configured client identity.
Tickets remain in a bounded in-memory cache, partitioned by endpoint and loaded
trust/security policy. Each use revalidates the saved peer certificate chain and
performs fresh ECDHE. A server declining PSK continues normal full authentication
on that connection. No early data, automatic reconnect, or request replay occurs.
See the resumption policy and
compatibility details.
The wider roadmap (not implemented API) includes:
close/2
shutdown/2
getopts/2
getstat/1,2
update_keys/2
export_key_materials/4,5
format_error/1
versions/0
Compatibility means observable behavior, not only matching names and arities.
For implemented features, ex_ssl aims to match:
- success/error tuple forms;
- timeout semantics;
- active/passive socket semantics;
- controlling-process behavior;
- standard TLS/socket option behavior;
- STARTTLS-style upgrade of an existing TCP socket;
- active message forms.
The active message contract targets OTP forms:
{:ssl, socket, data}
{:ssl_closed, socket}
{:ssl_error, socket, reason}
{:ssl_passive, socket}The TCP protocol target is a TLS client, using RFC 9846 as the normative
TLS 1.3 specification. The separate SSL.QUIC engine also implements the
record-free TLS 1.3 server role; its acceptance status is documented separately.
Initial target features:
- TLS 1.3 ClientHello/ServerHello;
- HelloRetryRequest;
- EncryptedExtensions;
- server Certificate / CertificateVerify / Finished;
- client Finished;
- X.509 path and service-identity verification;
- ALPN;
- TLS alerts and close_notify;
- NewSessionTicket parsing;
- KeyUpdate;
- passive and OTP-style active receive modes;
- STARTTLS upgrade;
- programmable ClientHello WireProfile;
- GREASE (RFC 8701);
- JA3 and JA4 introspection.
Initial target cipher suites:
TLS_AES_128_GCM_SHA256
TLS_AES_256_GCM_SHA384
TLS_CHACHA20_POLY1305_SHA256
Initial target groups:
X25519
secp256r1
secp384r1
Runtime support depends on the crypto provider available to OTP. Implemented TLS 1.3 handshake signatures are P-256/P-384 ECDSA, Ed25519, and RSA-PSS-RSAE / RSA-PSS-PSS SHA-256/384/512, with strict key and parameter checks. See the compatibility matrix for the bounded subset; client certificates and explicit bounded TLS 1.2 are implemented in version 0.4.
SSL.QUIC provides an experimental record-free TLS 1.3 client/server handshake
API with caller-owned state and ordered traffic-secret actions. The TCP client
shares its ClientHello/HRR and authentication core. This adds neither QUIC
networking nor TCP server APIs. See the interface
and implementation ledger. The pinned
aioquic comparison exercises both roles and compares
traffic secrets without QUIC networking.
SSL.Fingerprint.client_hello(bytes, :tcp | :quic) computes JA3/JA4 from actual
naked ClientHello bytes; new/1 and feed/2 support bounded fragmented
observation. Unknown IDs and wire ordering remain available separately from the
analytical projections. See fingerprint API and references.
Application
│
▼
SSL OTP-compatible facade
│
▼
SSL.Connection :gen_statem per connection
│
├── SSL.Protocol.* records / handshake / transcript
├── SSL.Crypto.* HKDF / AEAD / key schedule / ECDHE
├── SSL.PKIX.* certificate + identity verification
├── SSL.ClientHello.* WireProfile / GREASE / serializer
├── SSL.Fingerprint.* JA3 / JA4 / wire inspection
└── SSL.Packet application packet modes
│
▼
:gen_tcp
The raw TCP socket is controlled by SSL.Connection, normally using internal active: :once. Application-visible active mode is implemented above TLS so handshake/record processing cannot be disabled by application socket mode.
The profile is the source of truth for what ex_ssl attempts to emit.
Conceptually:
WireProfile
│
▼
validate against TLS/runtime capabilities
│
▼
materialize per-connection randomness, GREASE and fresh KeyShare
│
▼
ordered ClientHello AST
│
├──► JA3
├──► JA4
└──► exact-wire inspection
│
▼
serialize
Profiles may describe KeyShare group/order/position but may not reuse actual ephemeral private/public key material between real connections.
A matching TLS ClientHello does not guarantee that a remote service will see a client as identical to a browser or mobile application. Other observable layers can include TCP/IP behavior, HTTP/2 SETTINGS and framing, header behavior, QUIC/HTTP/3 behavior, application protocol details, and timing.
ex_ssl focuses on the TLS layer.
Version 0.4 accepts explicit TLS1.2-only or mixed version lists while keeping TLS1.3 as the default. TLS1.2 requires ECDHE, AES-GCM, Extended Master Secret and secure-renegotiation indication; renegotiation is disabled. Peers without EMS, including the observed local OTP28 TLS1.2 server, fail explicitly. OpenSSL full/mTLS exchanges validate the bounded subset. See the compatibility matrix and progress ledger for evidence and remaining gates.
Elixir/Erlang immutable binaries and garbage collection do not provide deterministic memory zeroization. ex_ssl minimizes secret lifetime and scope but does not claim guaranteed erasure of every in-memory copy.
Fingerprint fidelity never overrides TLS authentication.
ex_ssl must fail closed for:
- invalid certificate chains;
- service identity mismatch;
- invalid CertificateVerify signatures;
- invalid Finished values;
- AEAD authentication failures;
- invalid protocol state;
- record/sequence limit violations.
The implementation uses OTP :crypto and :public_key for cryptographic primitives and X.509 functionality rather than reimplementing those primitives in Elixir.
The authenticated TLS 1.3 client, passive/active-once raw application traffic, STARTTLS, ownership transfer, public ALPN, KeyUpdate handling, and bounded multi-record writes are implemented. Development continues toward broader OTP API/options, active modes, packet modes, exporters, verified real-world profiles, performance work, and independent security review.
See:
The project uses four complementary test classes:
Cryptographic and transcript operations are checked against independent known results.
Parsers are tested across arbitrary TCP, record, and handshake fragmentation boundaries.
Integration tests connect ex_ssl to generated local OTP :ssl peers and
OpenSSL. The dedicated Caddy workflow validates fingerprint behavior.
Focused scenarios compare OTP :ssl and SSL behavior for implemented receive,
path-depth, ownership, timeout, and active-mode semantics.
A compatibility claim is not complete until covered by tests.
Primary references:
- TLS 1.3: RFC 9846 — https://www.rfc-editor.org/info/rfc9846/
- GREASE: RFC 8701 — https://www.rfc-editor.org/info/rfc8701/
- TLS service identity: RFC 9525 — https://www.rfc-editor.org/info/rfc9525/
- Erlang/OTP
:ssl: https://www.erlang.org/doc/apps/ssl/ssl.html - Erlang/OTP
:crypto: https://www.erlang.org/doc/apps/crypto/crypto.html - Erlang/OTP
:public_key: https://www.erlang.org/doc/apps/public_key/public_key.html - JA4 technical details: https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md
Licensed under the Apache License 2.0.