Skip to content

add mTLS and TLS verification options - #55

Closed
binajmen wants to merge 15 commits into
gleam-lang:mainfrom
binajmen:main
Closed

binajmen wants to merge 15 commits into
gleam-lang:mainfrom
binajmen:main

Conversation

@binajmen

@binajmen binajmen commented Feb 25, 2026 •

Copy link
Copy Markdown

Fixes #54
Closes #36

Adds TLS verification options and mutual TLS (client certificate) support to Configuration.

API

verify_tls now takes a TlsVerification instead of a Bool, following the design proposed in #36:

  • NoVerification
  • VerifyWithSystemCerts (default)
  • VerifyWithCustomCa(path) — verify against a PEM CA file instead of the system store

Client certificates:

  • client_certificate(certfile:, keyfile:)
  • client_certificate_with_password(certfile:, keyfile:, password:)

Both build on the same ssl option list, and verification options are always passed explicitly so behaviour does not depend on the OTP default.

Errors

  • ConnectError.InvalidTlsOptions(detail) is returned when ssl rejects the options (missing cert/key/CA file, wrong key password) instead of crashing the caller.
  • HttpError.ConnectionClosed is returned when the server closes the connection before responding. With TLS 1.3 a server rejects a client certificate after the handshake, so the alert and the close race, which previously crashed about half the time.

Tests

A throwaway PKI is generated at test start with public_key:pkix_test_data/1 and served by two local ssl listeners, one requiring a client certificate. Covers custom CA verification, mTLS, encrypted keys, and misconfigured paths with no external services or committed key material.

Requirements

Erlang/OTP 25.1 or later (httpc:ssl_verify_host_options/1).

Comment thread src/gleam/httpc.gleam
///
pub fn verify_tls(config: Configuration, which: Bool) -> Configuration {
Builder(..config, verify_tls: which)
pub fn verify_tls(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems advantageous to do this without a breaking change if possible. Right now this method flags a single keyword to ssl, which is sort of orthongal to the rest of what is here.

@binajmen binajmen Feb 26, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is indeed a breaking change :/

i tried to get my head around what was discussed here: #36 (comment):

I don't think it would make sense to say "do not verify TLS" and "here's a cert to verify TLS with" at the same time, so instead we make a custom type with variants for not verifying, verifying with system certs, and verifying with custom certs. Do you think that would cover all the possibilities?

@lpil can we get your input on this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are many options in a full-featured TLS stack that are not included in gleam_httpc today. The reason you see stacks split them is that there is potentially interest in configuring with this CA, but only verifying the chain, not the hostname. The axes become "how much to verify in this connection" and "how to verify the connection." On further thought, there is more benefit to making it harder to misuse.

Comment thread src/gleam/httpc.gleam Outdated
ssl_verify_host_options(True)
|> list.map(fn(opt) {
case opt {
Cacerts(_) -> Cacertfile(charlist.from_string(path))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a footgun because, while {cacerts, CACerts} is in the default options, if for some reason it wasn't or that ever changed, this wouldn't get added. It should probably be two steps: first, get the default options; if {cacertfile, Path} is needed, the cacerts key is removed and replaced.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done. i transformed this part by first filtering the list, then prepending the option. still need to traverse the list, but i don't see another solution

@jtdowney jtdowney left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not affiliated with the Gleam project in any way; I am just a Gleam user with 15 years of reviewing TLS-related code. So feel free to ignore me.

The other thing I'd add is that there isn't much in the way of tests for the client certificate code, though it is difficult/tedious to test these things. You basically have to generate a mini-PKI and stand up a test server.

Comment thread src/gleam/httpc.gleam Outdated
let verify_opts = verification_options(config.tls_verification)

case config.tls_verification, cert_opts {
VerifyWithSystemCerts, [] -> option.None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems odd to resolve verify_opts outside of the case if it is only needed in one branch.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done.

@samof76

samof76 commented May 22, 2026

Copy link
Copy Markdown

@lpil Any updates on this wll be super helpful. TIA!

Client certificate and custom CA paths are the first user-supplied ssl
options, so a wrong path or password now surfaces as
InvalidTlsOptions rather than an unexpected_httpc_ip_error crash.

Remove two unreachable clauses: ssl never produces a bare
{tls_alert, Atom}, and httpc only emits the 3-tuple {ssl_error, Socket,
Reason} form. Document why that form is mapped to FailedToConnect
(TLS 1.3 rejects a missing client certificate after the handshake).
With TLS 1.3 a server rejects a missing or invalid client certificate
after the handshake completes, so the alert and the TCP close race in
httpc: sometimes the request fails with an ssl_error, sometimes with
socket_closed_remotely. The latter previously crashed the caller.
Relying on httpc's default when no client certificate is configured
meant VerifyWithSystemCerts only verified on OTP 26+, while the other
variants verified on any supported version. Building the option list
unconditionally makes the behaviour independent of the OTP default.
VerifyPeer and CustomizeHostnameCheck are never built or matched by
this code (they come from httpc:ssl_verify_host_options/1), and the
Erlang wrapper only forwarded to that function. Cacerts holds OTP
cert records, not binaries.
ssl_config:file_error/2 only wraps badmatch failures in {options, _};
other failures, such as reading an encrypted key without a password,
surface as a bare {keyfile, Reason} and previously crashed the caller.
A throwaway PKI is generated at test start and served by two ssl
listeners, one of which requires a client certificate. This covers
custom CA verification, mTLS, encrypted keys, and misconfigured paths
without external services or committed key material.
Deriving them from httpc:ssl_verify_host_options/1 and dropping cacerts
still called public_key:cacerts_get/0, which raises when the OS has no
CA store, so VerifyWithCustomCa crashed in exactly the environments a
custom CA is for. Assemble verify_peer, cacertfile, and the https
hostname check directly instead.
erlang:unique_integer/1 is only unique within one VM and repeats across
runs, so a second gleam test could fail on eexist before any test ran.
The custom CA options are now assembled by hand, so check that the
hostname is still verified. A TLS 1.2 only listener shows the rejection
of a missing client certificate as a plain handshake failure, in
contrast to the post-handshake behaviour of TLS 1.3.
The public_key cacerts_path application env is pointed at a missing
file for the duration of the request, which is how the OS store lookup
fails on every platform, and restored afterwards.
The public_key cacerts_path override only exists from OTP 27.2, so on
older supported versions the helper reports that and the test passes
without asserting. The probe now runs inside try/after so a failure
there also restores the environment.
@binajmen binajmen closed this Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add client certificate (mTLS) support to Configuration Add custom cacert support

3 participants