From c9dad4153737bd01efc0be35a7b17c39bff08d67 Mon Sep 17 00:00:00 2001 From: "Jose E. Cribeiro Aneiros" Date: Fri, 21 Aug 2026 10:30:31 +0200 Subject: [PATCH 1/2] feat: forward in-memory certs_keys in build_client_ssl_opts --- src/nhttp_sock.erl | 5 +- test/nhttp_sock_SUITE.erl | 114 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/nhttp_sock.erl b/src/nhttp_sock.erl index a3dd56d..f29b384 100644 --- a/src/nhttp_sock.erl +++ b/src/nhttp_sock.erl @@ -104,6 +104,7 @@ application protocol was negotiated (e.g., `<<"h2">>` or `<<"http/1.1">>`). buffer => pos_integer(), certfile => file:filename(), keyfile => file:filename(), + certs_keys => [map()], cacertfile => file:filename(), cacerts => [public_key:der_encoded()], alpn_advertised_protocols => [binary()], @@ -204,6 +205,7 @@ connect(Host, Port, Opts, Timeout) -> build_client_ssl_opts(Opts) -> Certfile = maps:get(certfile, Opts, undefined), Keyfile = maps:get(keyfile, Opts, undefined), + CertsKeys = maps:get(certs_keys, Opts, undefined), Cacertfile = maps:get(cacertfile, Opts, undefined), Cacerts = maps:get(cacerts, Opts, undefined), WildcardHostName = maps:get(wildcard_hostname, Opts, false), @@ -241,7 +243,8 @@ build_client_ssl_opts(Opts) -> WithHostnameCheck = maybe_add_opt(customize_hostname_check, HostnameCheck, WithCacert), WithCert = maybe_add_opt(certfile, Certfile, WithHostnameCheck), - maybe_add_opt(keyfile, Keyfile, WithCert). + WithKey = maybe_add_opt(keyfile, Keyfile, WithCert), + maybe_add_opt(certs_keys, CertsKeys, WithKey). -doc "Build SSL options from opts map. Accepts any map containing SSL-related keys.". -spec build_ssl_opts(map()) -> [ssl:tls_server_option()]. diff --git a/test/nhttp_sock_SUITE.erl b/test/nhttp_sock_SUITE.erl index 30ae5ae..830f478 100644 --- a/test/nhttp_sock_SUITE.erl +++ b/test/nhttp_sock_SUITE.erl @@ -40,8 +40,10 @@ ssl_setopts/1, ssl_controlling_process/1, ssl_peername_sockname/1, + ssl_connect_client_certs_keys/1, build_ssl_opts_variants/1, build_client_ssl_opts_variants/1, + build_client_ssl_opts_certs_keys/1, normalize_host_variants/1, error_timeout/1, error_closed/1, @@ -90,6 +92,7 @@ groups() -> ssl_setopts, ssl_controlling_process, ssl_peername_sockname, + ssl_connect_client_certs_keys, ssl_pre_handshake_ops, ssl_accept_error, ssl_connect_with_sni @@ -97,6 +100,7 @@ groups() -> {opts, [parallel], [ build_ssl_opts_variants, build_client_ssl_opts_variants, + build_client_ssl_opts_certs_keys, normalize_host_variants ]}, {errors, [parallel], [ @@ -1092,6 +1096,28 @@ build_client_ssl_opts_variants(_Config) -> ok. +build_client_ssl_opts_certs_keys(_Config) -> + CertsKeys = [#{cert => <<"cert-der">>, key => {'RSAPrivateKey', <<"key-der">>}}], + + Opts1 = nhttp_sock:build_client_ssl_opts(#{ + verify => verify_none, + certs_keys => CertsKeys + }), + ?assertEqual({certs_keys, CertsKeys}, lists:keyfind(certs_keys, 1, Opts1)), + + Opts2 = nhttp_sock:build_client_ssl_opts(#{ + verify => verify_peer, + cacerts => [<<"ca-der">>], + certs_keys => CertsKeys + }), + ?assertEqual({certs_keys, CertsKeys}, lists:keyfind(certs_keys, 1, Opts2)), + ?assertEqual({cacerts, [<<"ca-der">>]}, lists:keyfind(cacerts, 1, Opts2)), + + Opts3 = nhttp_sock:build_client_ssl_opts(#{verify => verify_none}), + ?assertNot(lists:keymember(certs_keys, 1, Opts3)), + + ok. + normalize_host_variants(_Config) -> {ok, ListenSock} = nhttp_sock:listen(#{port => 0, transport => tcp}), {ok, {_, Port}} = nhttp_sock:sockname(ListenSock), @@ -1123,6 +1149,94 @@ normalize_host_variants(_Config) -> nhttp_sock:close(ListenSock), ok. +ssl_connect_client_certs_keys(Config) -> + CertFile = ?config(certfile, Config), + KeyFile = ?config(keyfile, Config), + ConfDir = filename:dirname(CertFile), + CaFile = filename:join(ConfDir, "ca.pem"), + ClientCertFile = filename:join(ConfDir, "client.pem"), + ClientKeyFile = filename:join(ConfDir, "client.key"), + case filelib:is_file(ClientCertFile) andalso filelib:is_file(CaFile) of + false -> + {skip, "client/CA certificates not found"}; + true -> + {ok, ListenSock} = nhttp_sock:listen(#{ + port => 0, + transport => ssl, + certfile => CertFile, + keyfile => KeyFile + }), + {ok, {_, Port}} = nhttp_sock:sockname(ListenSock), + + ServerSslOpts = + nhttp_sock:build_ssl_opts(#{ + certfile => CertFile, + keyfile => KeyFile, + cacertfile => CaFile, + verify => verify_peer + }) ++ [{fail_if_no_peer_cert, true}], + + Self = self(), + spawn_link(fun() -> + case nhttp_sock:accept(ListenSock, 5000) of + {ok, PreSock} -> + case nhttp_sock:handshake(PreSock, 5000, ServerSslOpts) of + {ok, ServerSock} -> + {ok, Data} = nhttp_sock:recv(ServerSock, 0, 5000), + Self ! {server_recv, Data}, + nhttp_sock:close(ServerSock); + {error, Reason} -> + Self ! {server_error, Reason} + end; + {error, Reason} -> + Self ! {server_error, Reason} + end + end), + + CertsKeys = load_certs_keys(ClientCertFile, ClientKeyFile), + {ok, ClientSock} = nhttp_sock:connect("127.0.0.1", Port, #{ + transport => ssl, + verify => verify_none, + certs_keys => CertsKeys, + alpn_advertised_protocols => [] + }), + ?assertEqual(ssl, nhttp_sock:transport(ClientSock)), + + ok = nhttp_sock:send(ClientSock, <<"mtls ping">>), + receive + {server_recv, RecvData} -> + ?assertEqual(<<"mtls ping">>, RecvData); + {server_error, Err} -> + ct:fail("mTLS handshake failed: ~p", [Err]) + after 5000 -> + ct:fail("Server did not complete mTLS handshake") + end, + + nhttp_sock:close(ClientSock), + nhttp_sock:close(ListenSock), + ok + end. + +-doc "Load an in-memory certs_keys entry from PEM cert/key files.". +load_certs_keys(CertFile, KeyFile) -> + {ok, CertPem} = file:read_file(CertFile), + {ok, KeyPem} = file:read_file(KeyFile), + {'Certificate', CertDer, _} = + lists:keyfind('Certificate', 1, public_key:pem_decode(CertPem)), + {KeyType, KeyDer, _} = private_key_entry(public_key:pem_decode(KeyPem)), + [#{cert => CertDer, key => {KeyType, KeyDer}}]. + +private_key_entry(Entries) -> + [Key | _] = [ + Entry + || {Type, _Der, _} = Entry <- Entries, + Type =:= 'PrivateKeyInfo' orelse + Type =:= 'RSAPrivateKey' orelse + Type =:= 'ECPrivateKey' orelse + Type =:= 'DSAPrivateKey' + ], + Key. + %%%----------------------------------------------------------------------------- %%% ADDITIONAL COVERAGE TESTS %%%----------------------------------------------------------------------------- From 1638ba1e55d94f8a4f9674b05ed2051bfd2fcd98 Mon Sep 17 00:00:00 2001 From: "Jose E. Cribeiro Aneiros" Date: Fri, 21 Aug 2026 10:58:36 +0200 Subject: [PATCH 2/2] chore: changes version and updates changelog --- CHANGELOG.md | 8 ++++++++ src/nhttp_lib.app.src | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa1394e..4f5a62f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.5] - 2026-08-21 + +### Added + +- `certs_keys` client TLS option in `nhttp_sock:build_client_ssl_opts/1` and + `t:nhttp_sock:connect_opts/0`, forwarding in-memory client certificates for + mutual TLS (mTLS) without writing cert/key to disk + ## [1.0.4] - 2026-08-20 ### Added diff --git a/src/nhttp_lib.app.src b/src/nhttp_lib.app.src index e343be3..cb9d41c 100644 --- a/src/nhttp_lib.app.src +++ b/src/nhttp_lib.app.src @@ -1,6 +1,6 @@ {application, nhttp_lib, [ {description, "HTTP protocol primitives for Erlang/OTP 27+ (HTTP/1.1, HTTP/2, HTTP/3, QPACK)"}, - {vsn, "1.0.4"}, + {vsn, "1.0.5"}, {registered, []}, {applications, [ kernel,