Current section
Files
Jump to
Current section
Files
README.md
# ex_ssl
`ex_ssl` is an experimental TLS implementation for Elixir/OTP with two primary goals:
1. provide a client API that is behaviorally compatible with Erlang/OTP `:ssl` where implemented;
2. 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](docs/COMPATIBILITY.md) for exact restrictions. OTP
> `:ssl` remains 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](docs/COMPATIBILITY.md) and the
[implementation ledger](docs/EX_SSL_HTTP_FETCH_PROGRESS.md) for executed gates.
## Installation
Add the `ex_ssl` package to your dependencies:
```elixir
def deps do
[
{:ex_ssl, "~> 0.5.0"}
]
end
```
The Hex package name is [`ex_ssl`](https://hex.pm/packages/ex_ssl).
## Why 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.
## Naming
```text
Package / repository: ex_ssl
OTP application: :ex_ssl
Public API: SSL
Internal namespace: SSL.*
```
Migration is intended to look like:
```elixir
# OTP
:ssl.connect(host, port, options)
# ex_ssl
SSL.connect(host, port, options)
```
Custom behavior is additive and namespaced:
```elixir
SSL.connect(host, port,
verify: :verify_peer,
server_name_indication: host,
ex_ssl: [
profile: :default
]
)
```
## Runtime baseline
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](docs/SECURITY_REVIEW_EVIDENCE.md)
documents mandatory peer/runtime gates and the reproducible pinned downstream
source-candidate/package check. Independent human security review remains incomplete.
## Compatibility target
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](docs/COMPATIBILITY.md).
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](docs/ADR_TLS13_RESUMPTION.md) and
[compatibility details](docs/COMPATIBILITY.md).
The wider roadmap (not implemented API) includes:
```text
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:
```elixir
{:ssl, socket, data}
{:ssl_closed, socket}
{:ssl_error, socket, reason}
{:ssl_passive, socket}
```
## Protocol scope
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:
```text
TLS_AES_128_GCM_SHA256
TLS_AES_256_GCM_SHA384
TLS_CHACHA20_POLY1305_SHA256
```
Initial target groups:
```text
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](docs/COMPATIBILITY.md) for the bounded subset; client
certificates and explicit bounded TLS 1.2 are implemented in version 0.4.
## Architecture
`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](docs/QUIC_TLS_INTERFACE.md)
and [implementation ledger](docs/QUIC_TLS_IMPLEMENTATION.md). The pinned
[aioquic comparison](e2e/quic_tls/README.md) 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](docs/FINGERPRINTS.md).
```text
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.
## Wire profiles
The profile is the source of truth for what `ex_ssl` attempts to emit.
Conceptually:
```text
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.
## Important limitations
### Not a complete device impersonation layer
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.
### Explicit bounded TLS 1.2
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.
### BEAM secret zeroization
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.
## Security model
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.
## Development status
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:
- [ARCHITECTURE.md](docs/ARCHITECTURE.md)
- [DESIGN.md](docs/DESIGN.md)
- [PRD.md](docs/PRD.md)
- [IMPLEMENTATION_PLAN.md](docs/IMPLEMENTATION_PLAN.md)
- [AGENTS.md](AGENTS.md)
## Testing philosophy
The project uses four complementary test classes:
### Protocol/vector tests
Cryptographic and transcript operations are checked against independent known results.
### Property/fragmentation tests
Parsers are tested across arbitrary TCP, record, and handshake fragmentation boundaries.
### Interoperability tests
Integration tests connect ex_ssl to generated local OTP `:ssl` peers and
OpenSSL. The dedicated Caddy workflow validates fingerprint behavior.
### Differential compatibility tests
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.
## Standards and references
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
## License
Licensed under the [Apache License 2.0](LICENSE).