Current section

Files

Jump to
quic src quic_tls.erl
Raw

src/quic_tls.erl

%%% -*- erlang -*-
%%%
%%% QUIC TLS 1.3 Message Handling
%%% RFC 8446 - TLS 1.3
%%% RFC 9001 - Using TLS to Secure QUIC
%%%
%%% Copyright (c) 2024-2026 Benoit Chesneau
%%% Apache License 2.0
%%%
%%% @doc TLS 1.3 message generation and parsing for QUIC.
%%%
%%% This module handles TLS 1.3 handshake messages as they appear in
%%% QUIC CRYPTO frames. Messages are encoded without the TLS record layer.
%%%
%%% == TLS Messages in QUIC ==
%%%
%%% QUIC uses TLS 1.3 for the cryptographic handshake, but without the
%%% TLS record layer. TLS handshake messages are sent directly in
%%% CRYPTO frames.
%%%
-module(quic_tls).
-include("quic.hrl").
-include_lib("public_key/include/public_key.hrl").
-export([
%% ClientHello
build_client_hello/1,
%% Server message parsing (client-side)
parse_server_hello/1,
parse_encrypted_extensions/1,
parse_certificate/1,
parse_certificate_verify/1,
parse_finished/1,
%% Client Finished
build_finished/1,
verify_finished/3,
verify_finished/4,
%% Server-side message building
parse_client_hello/1,
build_server_hello/1,
build_hello_retry_request/3,
build_encrypted_extensions/1,
build_certificate/2,
build_certificate_verify/3,
%% Transport parameters
encode_transport_params/1,
decode_transport_params/1,
%% Preferred address (RFC 9000 Section 9.6)
encode_preferred_address/1,
decode_preferred_address/1,
%% TLS message framing
encode_handshake_message/2,
decode_handshake_message/1,
%% Client certificate support (mutual TLS)
build_certificate_request/1,
build_certificate_request/2,
parse_certificate_request/1,
build_certificate_verify_client/3,
verify_certificate_verify/4,
%% TLS 1.3 External PSK (RFC 8446 §4.2.11)
select_psk/4,
verify_resumption_binder/5,
parse_extensions_ordered/1
]).
%%====================================================================
%% ClientHello
%%====================================================================
%% @doc Build a TLS 1.3 ClientHello message for QUIC.
%% Options:
%% - server_name: SNI hostname (binary)
%% - alpn: List of ALPN protocols (list of binaries)
%% - transport_params: QUIC transport parameters (map)
%% - session_ticket: #session_ticket{} for resumption with PSK (optional)
%%
%% Returns: {ClientHelloMsg, PrivateKey, Random}
-spec build_client_hello(map()) -> {binary(), binary(), binary()}.
build_client_hello(Opts) ->
%% Key-share group: the head of `groups' (default x25519). The
%% remaining groups are advertised in supported_groups so the
%% server can request one via HelloRetryRequest.
Groups = maps:get(groups, Opts, default_groups()),
%% Group that gets the key_share entry: the head of `groups', or an
%% explicit override (used by a HelloRetryRequest retry where
%% supported_groups is unchanged but the share moves to the
%% server-selected group).
HeadGroup = maps:get(key_share_group, Opts, hd(Groups)),
{PubKey, PrivKey} = quic_crypto:generate_key_pair(HeadGroup),
%% On a HelloRetryRequest retry, CH2 MUST reuse CH1's random and
%% legacy_session_id (RFC 8446 §4.1.2).
Random =
case maps:get(retry_random, Opts, undefined) of
undefined -> crypto:strong_rand_bytes(32);
R when is_binary(R) -> R
end,
Opts1 = Opts#{
groups => Groups,
head_group => HeadGroup,
session_id => maps:get(retry_session_id, Opts, <<>>)
},
%% Resolve PSK offer (resumption ticket or external PSK). The
%% caller is responsible for ensuring at most one is supplied;
%% psk_offer_from_opts/1 raises {bad_opts, psk_conflict} otherwise.
case psk_offer_from_opts(Opts1) of
undefined ->
build_client_hello_standard(Random, PubKey, PrivKey, Opts1);
#psk_offer{} = Offer ->
build_client_hello_with_psk(Random, PubKey, PrivKey, Opts1, Offer)
end.
%% @private Default key-exchange groups (wire-compatible with the
%% pre-multigroup behaviour: x25519 only).
default_groups() -> [x25519].
%% @private Default advertised signature schemes, identical to the
%% historical hardcoded ClientHello list.
default_sig_algs() ->
[ecdsa_secp256r1_sha256, rsa_pss_rsae_sha256, rsa_pkcs1_sha256, ed25519].
%% @private Named-group atom to RFC 8446 §4.2.7 wire code.
group_to_code(x25519) -> ?GROUP_X25519;
group_to_code(secp256r1) -> ?GROUP_SECP256R1;
group_to_code(secp384r1) -> ?GROUP_SECP384R1.
%% @private Signature-scheme atom to RFC 8446 §4.2.3 wire code.
sig_alg_to_code(ecdsa_secp256r1_sha256) -> ?SIG_ECDSA_SECP256R1_SHA256;
sig_alg_to_code(ecdsa_secp384r1_sha384) -> ?SIG_ECDSA_SECP384R1_SHA384;
sig_alg_to_code(rsa_pss_rsae_sha256) -> ?SIG_RSA_PSS_RSAE_SHA256;
sig_alg_to_code(rsa_pss_rsae_sha384) -> ?SIG_RSA_PSS_RSAE_SHA384;
sig_alg_to_code(rsa_pss_rsae_sha512) -> ?SIG_RSA_PSS_RSAE_SHA512;
sig_alg_to_code(ed25519) -> ?SIG_ED25519;
sig_alg_to_code(rsa_pkcs1_sha256) -> ?SIG_RSA_PKCS1_SHA256.
%% @private Resolve external_psk/session_ticket into a #psk_offer{}.
psk_offer_from_opts(Opts) ->
Ticket = maps:get(session_ticket, Opts, undefined),
Ext = maps:get(external_psk, Opts, undefined),
case {Ticket, Ext} of
{undefined, undefined} -> undefined;
{_, undefined} -> psk_offer_from_ticket(Ticket);
{undefined, _} -> psk_offer_from_external(Ext);
{_, _} -> error({bad_opts, psk_conflict})
end.
psk_offer_from_ticket(#session_ticket{} = Ticket) ->
Secret = quic_ticket:derive_psk(Ticket#session_ticket.resumption_secret, Ticket),
Now = erlang:system_time(second),
TicketAge = (Now - Ticket#session_ticket.received_at) * 1000,
Age = (TicketAge + Ticket#session_ticket.age_add) band 16#FFFFFFFF,
#psk_offer{
type = resumption,
identity = Ticket#session_ticket.ticket,
age = Age,
secret = Secret,
cipher = Ticket#session_ticket.cipher,
modes = [psk_dhe_ke]
}.
psk_offer_from_external({Identity, Secret}) ->
psk_offer_from_external({Identity, Secret, [psk_dhe_ke]});
psk_offer_from_external({Identity, Secret, Modes}) when
is_binary(Identity),
byte_size(Identity) > 0,
is_binary(Secret),
byte_size(Secret) > 0,
is_list(Modes),
Modes =/= []
->
%% Currently only TLS_AES_128_GCM_SHA256 is supported; binder
%% length, key schedule hash and HKDF all key off this. When more
%% suites land, surface a `cipher' option here.
#psk_offer{
type = external,
identity = Identity,
age = 0,
secret = Secret,
cipher = aes_128_gcm,
modes = validate_psk_modes(Modes)
};
psk_offer_from_external(Other) ->
error({bad_opts, {external_psk, Other}}).
validate_psk_modes(Modes) ->
Allowed = [psk_dhe_ke, psk_ke],
case [M || M <- Modes, not lists:member(M, Allowed)] of
[] -> Modes;
Bad -> error({bad_opts, {unsupported_psk_modes, Bad}})
end.
%% Build standard ClientHello without PSK
build_client_hello_standard(Random, PubKey, PrivKey, Opts) ->
%% Build extensions
Extensions = build_client_hello_extensions(PubKey, Opts),
%% Legacy session ID (empty for TLS 1.3, or CH1's value on retry)
SessionId = maps:get(session_id, Opts, <<>>),
%% Cipher suites (TLS 1.3 only)
CipherSuites = <<?TLS_AES_128_GCM_SHA256:16>>,
%% Legacy compression methods (null only)
CompressionMethods = <<1, 0>>,
%% Build ClientHello body
ClientHello = <<
% legacy_version (always 0x0303)
?TLS_VERSION_1_2:16,
% random
Random:32/binary,
% legacy_session_id length
(byte_size(SessionId)):8,
% legacy_session_id
SessionId/binary,
% cipher_suites length
(byte_size(CipherSuites)):16,
% cipher_suites
CipherSuites/binary,
% legacy_compression_methods
CompressionMethods/binary,
% extensions length
(byte_size(Extensions)):16,
% extensions
Extensions/binary
>>,
%% Wrap in handshake message
Msg = encode_handshake_message(?TLS_CLIENT_HELLO, ClientHello),
{Msg, PrivKey, Random}.
%% Build ClientHello with `pre_shared_key' extension for either a
%% resumption ticket or an external PSK.
%% RFC 8446 §4.2.11: pre_shared_key MUST be the last extension; the
%% binder is computed over the truncated ClientHello (everything
%% before the binders section).
build_client_hello_with_psk(Random, PubKey, PrivKey, Opts, #psk_offer{} = Offer) ->
#psk_offer{
identity = Identity,
age = Age,
secret = Secret,
cipher = Cipher,
modes = Modes,
type = OfferType
} = Offer,
%% Base extensions advertise the configured PSK modes (drives the
%% server's `psk_key_exchange_modes' decision).
OptsWithModes = Opts#{psk_modes => Modes},
BaseExtensions0 = build_client_hello_extensions(PubKey, OptsWithModes),
%% RFC 8446 §4.2.10 / RFC 9001 §4.6.1: offer 0-RTT by carrying an empty
%% early_data extension in the ClientHello (before pre_shared_key, which
%% must remain the last extension). A resumption offer always derives
%% early keys here, so it always offers 0-RTT.
BaseExtensions =
case OfferType of
resumption ->
<<BaseExtensions0/binary, (encode_extension(?EXT_EARLY_DATA, <<>>))/binary>>;
_ ->
BaseExtensions0
end,
%% Binder length comes from the negotiated hash (32 for SHA-256, 48 for SHA-384).
Hash = quic_crypto:cipher_to_hash(Cipher),
BinderLen = quic_crypto:hash_len(Hash),
%% PskIdentity: identity<1..2^16-1>, obfuscated_ticket_age.
%% External PSK uses age = 0 per RFC 8446 §4.2.11.
IdentityLen = byte_size(Identity),
PskIdentity = <<IdentityLen:16, Identity/binary, Age:32>>,
IdentitiesLen = byte_size(PskIdentity),
Identities = <<IdentitiesLen:16, PskIdentity/binary>>,
BinderPlaceholder = <<0:BinderLen/unit:8>>,
BindersSection = <<(BinderLen + 1):16, BinderLen:8, BinderPlaceholder/binary>>,
PskExtBody = <<Identities/binary, BindersSection/binary>>,
PskExtLen = byte_size(PskExtBody),
PskExt = <<?EXT_PRE_SHARED_KEY:16, PskExtLen:16, PskExtBody/binary>>,
AllExtensions = <<BaseExtensions/binary, PskExt/binary>>,
ExtensionsLen = byte_size(AllExtensions),
SessionId = maps:get(session_id, Opts, <<>>),
CipherSuites = <<?TLS_AES_128_GCM_SHA256:16>>,
CompressionMethods = <<1, 0>>,
ClientHelloBody = <<
?TLS_VERSION_1_2:16,
Random:32/binary,
(byte_size(SessionId)):8,
SessionId/binary,
(byte_size(CipherSuites)):16,
CipherSuites/binary,
CompressionMethods/binary,
ExtensionsLen:16,
AllExtensions/binary
>>,
%% Truncate before the binders section to feed the binder HMAC.
BindersSectionLen = byte_size(BindersSection),
TruncatedLen = byte_size(ClientHelloBody) - BindersSectionLen,
<<TruncatedBody:TruncatedLen/binary, _/binary>> = ClientHelloBody,
%% RFC 8446 §4.2.11.2: the binder transcript keeps every length field
%% (including the handshake-message length) at its final post-binder
%% value; only the binder bytes are removed. So encode the full body and
%% strip the binders off the end, preserving the full-length header.
FullMsg = encode_handshake_message(?TLS_CLIENT_HELLO, ClientHelloBody),
TruncatedMsg = binary:part(FullMsg, 0, byte_size(FullMsg) - BindersSectionLen),
EarlySecret = quic_crypto:derive_early_secret(Cipher, Secret),
TruncatedHash = quic_crypto:transcript_hash(Cipher, TruncatedMsg),
Binder = quic_crypto:compute_psk_binder(Cipher, EarlySecret, TruncatedHash, OfferType),
FinalBindersSection = <<(BinderLen + 1):16, BinderLen:8, Binder/binary>>,
FinalClientHello = <<TruncatedBody/binary, FinalBindersSection/binary>>,
Msg = encode_handshake_message(?TLS_CLIENT_HELLO, FinalClientHello),
{Msg, PrivKey, Random}.
%% Build ClientHello extensions
build_client_hello_extensions(PubKey, Opts) ->
ServerName = maps:get(server_name, Opts, undefined),
Alpn = maps:get(alpn, Opts, []),
TransportParams = maps:get(transport_params, Opts, #{}),
%% Supported versions (TLS 1.3 only)
%% Length is in bytes (2 bytes per version), not version count
SupportedVersions = encode_extension(
?EXT_SUPPORTED_VERSIONS,
<<2, ?TLS_VERSION_1_3:16>>
),
%% Supported groups: every entry the client offers (head gets a
%% key_share below; the rest are HRR-eligible).
Groups = maps:get(groups, Opts, default_groups()),
GroupCodes = iolist_to_binary([<<(group_to_code(G)):16>> || G <- Groups]),
SupportedGroups = encode_extension(
?EXT_SUPPORTED_GROUPS,
<<(byte_size(GroupCodes)):16, GroupCodes/binary>>
),
%% Signature algorithms (caller-configurable; defaults to the
%% historical wire list).
SigAlgAtoms = maps:get(signature_algs, Opts, default_sig_algs()),
SigAlgCodes = iolist_to_binary([<<(sig_alg_to_code(A)):16>> || A <- SigAlgAtoms]),
SigAlgs = encode_extension(
?EXT_SIGNATURE_ALGORITHMS,
<<(byte_size(SigAlgCodes)):16, SigAlgCodes/binary>>
),
%% Key share: single entry for the head group.
HeadGroup = maps:get(head_group, Opts, hd(Groups)),
KeyShareEntry = <<(group_to_code(HeadGroup)):16, (byte_size(PubKey)):16, PubKey/binary>>,
KeyShare = encode_extension(
?EXT_KEY_SHARE,
<<(byte_size(KeyShareEntry)):16, KeyShareEntry/binary>>
),
%% Server Name Indication
SNI =
case ServerName of
undefined ->
<<>>;
Name when is_binary(Name) ->
NameLen = byte_size(Name),
NameList = <<0, NameLen:16, Name/binary>>,
encode_extension(
?EXT_SERVER_NAME,
<<(byte_size(NameList)):16, NameList/binary>>
)
end,
%% ALPN
AlpnExt =
case Alpn of
[] ->
<<>>;
Protocols ->
ProtocolList = encode_alpn_list(Protocols),
encode_extension(
?EXT_ALPN,
<<(byte_size(ProtocolList)):16, ProtocolList/binary>>
)
end,
%% QUIC Transport Parameters
TransportParamsData = encode_transport_params(TransportParams),
TransportParamsExt = encode_extension(
?EXT_QUIC_TRANSPORT_PARAMS,
TransportParamsData
),
%% PSK Key Exchange Modes — list of bytes per RFC 8446 §4.2.9.
%% Defaults to `psk_dhe_ke' only; callers offering external PSK
%% may pass `psk_modes => [psk_ke, psk_dhe_ke]` etc.
Modes = maps:get(psk_modes, Opts, [psk_dhe_ke]),
ModeBytes = iolist_to_binary([encode_psk_mode(M) || M <- Modes]),
PskModes = encode_extension(
?EXT_PSK_KEY_EXCHANGE_MODES,
<<(byte_size(ModeBytes)):8, ModeBytes/binary>>
),
iolist_to_binary([
SupportedVersions,
SupportedGroups,
SigAlgs,
KeyShare,
SNI,
AlpnExt,
TransportParamsExt,
PskModes
]).
encode_alpn_list(Protocols) ->
iolist_to_binary([<<(byte_size(P)):8, P/binary>> || P <- Protocols]).
%%====================================================================
%% Server Message Parsing
%%====================================================================
%% @doc Parse a ServerHello message.
%% Returns server's public key and selected cipher suite.
-spec parse_server_hello(binary()) ->
{ok, #{
public_key := binary() | undefined,
cipher := atom(),
random := binary(),
selected_psk_identity => non_neg_integer()
}}
| {hrr, #{cipher := atom(), selected_group := atom() | unknown, extensions := map()}}
| {error, term()}.
parse_server_hello(<<
% legacy_version
?TLS_VERSION_1_2:16,
Random:32/binary,
SessionIdLen:8,
SessionId:SessionIdLen/binary,
CipherSuite:16,
% legacy_compression_method
0,
ExtensionsLen:16,
Extensions:ExtensionsLen/binary,
_Rest/binary
>>) ->
case parse_extensions(Extensions) of
{ok, ExtMap} when Random =:= ?TLS_HRR_RANDOM ->
%% HelloRetryRequest (RFC 8446 §4.1.4): a ServerHello whose
%% random is the HRR sentinel. Its key_share extension
%% carries only the selected group (KeyShareHelloRetryRequest).
Cipher = cipher_from_suite(CipherSuite),
case maps:find(?EXT_KEY_SHARE, ExtMap) of
{ok, <<GroupCode:16>>} ->
{hrr, #{
cipher => Cipher,
selected_group => code_to_group(GroupCode),
extensions => ExtMap
}};
_ ->
{error, hrr_missing_key_share}
end;
{ok, ExtMap} ->
Cipher = cipher_from_suite(CipherSuite),
SelectedPsk =
case maps:find(?EXT_PRE_SHARED_KEY, ExtMap) of
{ok, <<Idx:16>>} -> Idx;
_ -> undefined
end,
KeyShareResult =
case maps:find(?EXT_KEY_SHARE, ExtMap) of
{ok, KeyShareData} ->
parse_server_key_share(KeyShareData);
error when SelectedPsk =/= undefined ->
%% psk_ke handshake: no DHE → public_key=undefined.
{ok, undefined};
error ->
{error, missing_key_share}
end,
case KeyShareResult of
{ok, PubKey} ->
Base = #{
public_key => PubKey,
cipher => Cipher,
random => Random,
session_id => SessionId,
extensions => ExtMap
},
Result =
case SelectedPsk of
undefined -> Base;
Idx2 -> Base#{selected_psk_identity => Idx2}
end,
{ok, Result};
Error ->
Error
end;
Error ->
Error
end;
parse_server_hello(_) ->
{error, invalid_server_hello}.
%% @doc Parse EncryptedExtensions message.
%% The `early_data' field signals whether the server accepted 0-RTT by
%% echoing an empty `early_data' extension (RFC 8446 §4.2.10, RFC 9001
%% §4.6.2). Presence is the entire signal; the extension body is empty
%% on the EE path.
-spec parse_encrypted_extensions(binary()) ->
{ok, #{alpn => binary(), transport_params => map(), early_data => boolean()}}
| {error, term()}.
parse_encrypted_extensions(<<ExtensionsLen:16, Extensions:ExtensionsLen/binary, _Rest/binary>>) ->
case parse_extensions(Extensions) of
{ok, ExtMap} ->
Alpn =
case maps:find(?EXT_ALPN, ExtMap) of
{ok, <<_ListLen:16, ProtoLen:8, Proto:ProtoLen/binary, _/binary>>} ->
Proto;
_ ->
undefined
end,
TransportParams =
case maps:find(?EXT_QUIC_TRANSPORT_PARAMS, ExtMap) of
{ok, TPData} ->
case decode_transport_params(TPData) of
{ok, TP} -> TP;
_ -> #{}
end;
_ ->
#{}
end,
EarlyData = maps:is_key(?EXT_EARLY_DATA, ExtMap),
{ok, #{
alpn => Alpn,
transport_params => TransportParams,
early_data => EarlyData
}};
Error ->
Error
end;
parse_encrypted_extensions(_) ->
{error, invalid_encrypted_extensions}.
%% @doc Parse Certificate message.
-spec parse_certificate(binary()) ->
{ok, #{context := binary(), certificates := [binary()]}}
| {error, term()}.
parse_certificate(
<<ContextLen:8, Context:ContextLen/binary, CertsLen:24, CertsData:CertsLen/binary,
_Rest/binary>>
) ->
Certs = parse_certificate_list(CertsData),
{ok, #{context => Context, certificates => Certs}};
parse_certificate(_) ->
{error, invalid_certificate}.
parse_certificate_list(<<>>) ->
[];
parse_certificate_list(
<<CertLen:24, Cert:CertLen/binary, ExtLen:16, _Ext:ExtLen/binary, Rest/binary>>
) ->
[Cert | parse_certificate_list(Rest)].
%% @doc Parse CertificateVerify message.
-spec parse_certificate_verify(binary()) ->
{ok, #{algorithm := non_neg_integer(), signature := binary()}}
| {error, term()}.
parse_certificate_verify(<<Algorithm:16, SigLen:16, Signature:SigLen/binary, _Rest/binary>>) ->
{ok, #{algorithm => Algorithm, signature => Signature}};
parse_certificate_verify(_) ->
{error, invalid_certificate_verify}.
%% @doc Parse Finished message.
-spec parse_finished(binary()) -> {ok, binary()} | {error, term()}.
parse_finished(VerifyData) when byte_size(VerifyData) >= 32 ->
%% SHA-256 produces 32 bytes
<<Data:32/binary, _/binary>> = VerifyData,
{ok, Data};
parse_finished(_) ->
{error, invalid_finished}.
%%====================================================================
%% Client Finished
%%====================================================================
%% @doc Build a Finished message.
%% VerifyData should be computed using quic_crypto:compute_finished_verify/2.
-spec build_finished(binary()) -> binary().
build_finished(VerifyData) ->
encode_handshake_message(?TLS_FINISHED, VerifyData).
%% @doc Verify a Finished message (default SHA-256).
%% TrafficSecret is the sender's traffic secret.
%% TranscriptHash is the hash of all messages up to (but not including) Finished.
-spec verify_finished(binary(), binary(), binary()) -> boolean().
verify_finished(ReceivedVerifyData, TrafficSecret, TranscriptHash) ->
FinishedKey = quic_crypto:derive_finished_key(TrafficSecret),
ExpectedVerifyData = quic_crypto:compute_finished_verify(FinishedKey, TranscriptHash),
crypto:hash_equals(ReceivedVerifyData, ExpectedVerifyData).
%% @doc Verify a Finished message with cipher-specific hash.
-spec verify_finished(binary(), binary(), binary(), atom()) -> boolean().
verify_finished(ReceivedVerifyData, TrafficSecret, TranscriptHash, Cipher) ->
FinishedKey = quic_crypto:derive_finished_key(Cipher, TrafficSecret),
ExpectedVerifyData = quic_crypto:compute_finished_verify(Cipher, FinishedKey, TranscriptHash),
crypto:hash_equals(ReceivedVerifyData, ExpectedVerifyData).
%%====================================================================
%% Transport Parameters
%%====================================================================
%% @doc Encode QUIC transport parameters.
%% Params is a map with keys like:
%% original_dcid, max_idle_timeout, max_udp_payload_size,
%% initial_max_data, initial_max_stream_data_bidi_local, etc.
-spec encode_transport_params(map()) -> binary().
encode_transport_params(Params) ->
Encoded = maps:fold(
fun(Key, Value, Acc) ->
case encode_transport_param(Key, Value) of
<<>> -> Acc;
Bin -> [Bin | Acc]
end
end,
[],
Params
),
iolist_to_binary(lists:reverse(Encoded)).
encode_transport_param(original_dcid, Value) ->
encode_tp(?TP_ORIGINAL_DCID, Value);
encode_transport_param(max_idle_timeout, Value) ->
encode_tp(?TP_MAX_IDLE_TIMEOUT, quic_varint:encode(Value));
encode_transport_param(max_udp_payload_size, Value) ->
encode_tp(?TP_MAX_UDP_PAYLOAD_SIZE, quic_varint:encode(Value));
encode_transport_param(initial_max_data, Value) ->
encode_tp(?TP_INITIAL_MAX_DATA, quic_varint:encode(Value));
encode_transport_param(initial_max_stream_data_bidi_local, Value) ->
encode_tp(?TP_INITIAL_MAX_STREAM_DATA_BIDI_LOCAL, quic_varint:encode(Value));
encode_transport_param(initial_max_stream_data_bidi_remote, Value) ->
encode_tp(?TP_INITIAL_MAX_STREAM_DATA_BIDI_REMOTE, quic_varint:encode(Value));
encode_transport_param(initial_max_stream_data_uni, Value) ->
encode_tp(?TP_INITIAL_MAX_STREAM_DATA_UNI, quic_varint:encode(Value));
encode_transport_param(initial_max_streams_bidi, Value) ->
encode_tp(?TP_INITIAL_MAX_STREAMS_BIDI, quic_varint:encode(Value));
encode_transport_param(initial_max_streams_uni, Value) ->
encode_tp(?TP_INITIAL_MAX_STREAMS_UNI, quic_varint:encode(Value));
encode_transport_param(ack_delay_exponent, Value) ->
encode_tp(?TP_ACK_DELAY_EXPONENT, quic_varint:encode(Value));
encode_transport_param(max_ack_delay, Value) ->
encode_tp(?TP_MAX_ACK_DELAY, quic_varint:encode(Value));
encode_transport_param(disable_active_migration, true) ->
encode_tp(?TP_DISABLE_ACTIVE_MIGRATION, <<>>);
encode_transport_param(active_connection_id_limit, Value) ->
encode_tp(?TP_ACTIVE_CONNECTION_ID_LIMIT, quic_varint:encode(Value));
encode_transport_param(initial_scid, Value) ->
encode_tp(?TP_INITIAL_SCID, Value);
encode_transport_param(retry_scid, Value) ->
encode_tp(?TP_RETRY_SCID, Value);
encode_transport_param(preferred_address, #preferred_address{} = PA) ->
encode_tp(?TP_PREFERRED_ADDRESS, encode_preferred_address(PA));
encode_transport_param(max_datagram_frame_size, Value) when Value > 0 ->
encode_tp(?TP_MAX_DATAGRAM_FRAME_SIZE, quic_varint:encode(Value));
%% draft-ietf-quic-reliable-stream-reset-07 - Reliable RESET_STREAM
encode_transport_param(reset_stream_at, true) ->
encode_tp(?TP_RESET_STREAM_AT, <<>>);
encode_transport_param(_, _) ->
<<>>.
encode_tp(Id, Value) ->
IdBin = quic_varint:encode(Id),
LenBin = quic_varint:encode(byte_size(Value)),
<<IdBin/binary, LenBin/binary, Value/binary>>.
%% @doc Decode QUIC transport parameters.
%% RFC 9000 Section 7.4: Validates against duplicate parameters and semantic constraints
-spec decode_transport_params(binary()) -> {ok, map()} | {error, term()}.
decode_transport_params(Data) ->
try
case decode_transport_params_loop(Data, #{}) of
{ok, Params} -> validate_transport_params(Params);
Error -> Error
end
catch
error:_ -> {error, invalid_transport_params}
end.
decode_transport_params_loop(<<>>, Acc) ->
{ok, Acc};
decode_transport_params_loop(Data, Acc) ->
{Id, Rest1} = quic_varint:decode(Data),
{Len, Rest2} = quic_varint:decode(Rest1),
<<Value:Len/binary, Rest3/binary>> = Rest2,
Key = tp_id_to_key(Id),
%% RFC 9000 Section 7.4: Duplicate transport parameters MUST be treated as error
case maps:is_key(Key, Acc) of
true ->
{error, {transport_parameter_error, duplicate_parameter, Key}};
false ->
DecodedValue = decode_tp_value(Id, Value),
decode_transport_params_loop(Rest3, maps:put(Key, DecodedValue, Acc))
end.
%% @doc Validate transport parameter semantic constraints
%% RFC 9000 Section 18.2
-spec validate_transport_params(map()) -> {ok, map()} | {error, term()}.
validate_transport_params(Params) ->
case validate_tp_constraints(Params) of
ok -> {ok, Params};
{error, _} = Error -> Error
end.
validate_tp_constraints(Params) ->
%% RFC 9000 Section 18.2: active_connection_id_limit MUST be >= 2
case maps:get(active_connection_id_limit, Params, 2) of
N when N < 2 ->
{error, {transport_parameter_error, active_connection_id_limit_too_small, N}};
_ ->
validate_ack_delay_exponent(Params)
end.
validate_ack_delay_exponent(Params) ->
%% RFC 9000 Section 18.2: ack_delay_exponent MUST be <= 20
case maps:get(ack_delay_exponent, Params, 3) of
N when N > 20 ->
{error, {transport_parameter_error, ack_delay_exponent_too_large, N}};
_ ->
validate_max_udp_payload_size(Params)
end.
validate_max_udp_payload_size(Params) ->
%% RFC 9000 Section 18.2: max_udp_payload_size MUST be >= 1200
case maps:get(max_udp_payload_size, Params, 65527) of
N when N < 1200 ->
{error, {transport_parameter_error, max_udp_payload_size_too_small, N}};
_ ->
validate_max_ack_delay(Params)
end.
validate_max_ack_delay(Params) ->
%% RFC 9000 Section 18.2: max_ack_delay MUST be < 2^14 (16384)
case maps:get(max_ack_delay, Params, 25) of
N when N >= 16384 ->
{error, {transport_parameter_error, max_ack_delay_too_large, N}};
_ ->
ok
end.
tp_id_to_key(?TP_ORIGINAL_DCID) -> original_dcid;
tp_id_to_key(?TP_MAX_IDLE_TIMEOUT) -> max_idle_timeout;
tp_id_to_key(?TP_STATELESS_RESET_TOKEN) -> stateless_reset_token;
tp_id_to_key(?TP_MAX_UDP_PAYLOAD_SIZE) -> max_udp_payload_size;
tp_id_to_key(?TP_INITIAL_MAX_DATA) -> initial_max_data;
tp_id_to_key(?TP_INITIAL_MAX_STREAM_DATA_BIDI_LOCAL) -> initial_max_stream_data_bidi_local;
tp_id_to_key(?TP_INITIAL_MAX_STREAM_DATA_BIDI_REMOTE) -> initial_max_stream_data_bidi_remote;
tp_id_to_key(?TP_INITIAL_MAX_STREAM_DATA_UNI) -> initial_max_stream_data_uni;
tp_id_to_key(?TP_INITIAL_MAX_STREAMS_BIDI) -> initial_max_streams_bidi;
tp_id_to_key(?TP_INITIAL_MAX_STREAMS_UNI) -> initial_max_streams_uni;
tp_id_to_key(?TP_ACK_DELAY_EXPONENT) -> ack_delay_exponent;
tp_id_to_key(?TP_MAX_ACK_DELAY) -> max_ack_delay;
tp_id_to_key(?TP_DISABLE_ACTIVE_MIGRATION) -> disable_active_migration;
tp_id_to_key(?TP_PREFERRED_ADDRESS) -> preferred_address;
tp_id_to_key(?TP_ACTIVE_CONNECTION_ID_LIMIT) -> active_connection_id_limit;
tp_id_to_key(?TP_INITIAL_SCID) -> initial_scid;
tp_id_to_key(?TP_RETRY_SCID) -> retry_scid;
tp_id_to_key(?TP_MAX_DATAGRAM_FRAME_SIZE) -> max_datagram_frame_size;
tp_id_to_key(?TP_RESET_STREAM_AT) -> reset_stream_at;
tp_id_to_key(Id) -> {unknown, Id}.
decode_tp_value(?TP_ORIGINAL_DCID, Value) ->
Value;
decode_tp_value(?TP_STATELESS_RESET_TOKEN, Value) ->
Value;
decode_tp_value(?TP_INITIAL_SCID, Value) ->
Value;
decode_tp_value(?TP_RETRY_SCID, Value) ->
Value;
decode_tp_value(?TP_DISABLE_ACTIVE_MIGRATION, <<>>) ->
true;
decode_tp_value(?TP_RESET_STREAM_AT, <<>>) ->
true;
decode_tp_value(?TP_RESET_STREAM_AT, _) ->
%% Non-empty value is a TRANSPORT_PARAMETER_ERROR per spec
error({transport_parameter_error, reset_stream_at_non_empty});
decode_tp_value(?TP_PREFERRED_ADDRESS, Value) ->
decode_preferred_address(Value);
%% Known integer parameters (varints)
decode_tp_value(Id, Value) when
Id =:= ?TP_MAX_IDLE_TIMEOUT;
Id =:= ?TP_MAX_UDP_PAYLOAD_SIZE;
Id =:= ?TP_INITIAL_MAX_DATA;
Id =:= ?TP_INITIAL_MAX_STREAM_DATA_BIDI_LOCAL;
Id =:= ?TP_INITIAL_MAX_STREAM_DATA_BIDI_REMOTE;
Id =:= ?TP_INITIAL_MAX_STREAM_DATA_UNI;
Id =:= ?TP_INITIAL_MAX_STREAMS_BIDI;
Id =:= ?TP_INITIAL_MAX_STREAMS_UNI;
Id =:= ?TP_ACK_DELAY_EXPONENT;
Id =:= ?TP_MAX_ACK_DELAY;
Id =:= ?TP_ACTIVE_CONNECTION_ID_LIMIT;
Id =:= ?TP_MAX_DATAGRAM_FRAME_SIZE
->
%% Known integer parameters are varints
{Int, _} = quic_varint:decode(Value),
Int;
decode_tp_value(_Id, Value) ->
%% RFC 9000 Section 18: Unknown transport parameters MUST be ignored.
%% Store raw value to avoid decode errors on unknown/extension parameters.
Value.
%% @doc Decode preferred_address transport parameter (RFC 9000 Section 18.2).
%% Format:
%% IPv4 address: 4 bytes
%% IPv4 port: 2 bytes
%% IPv6 address: 16 bytes
%% IPv6 port: 2 bytes
%% CID length: 1 byte
%% Connection ID: variable (0-20 bytes)
%% Stateless reset: 16 bytes
-spec decode_preferred_address(binary()) -> #preferred_address{}.
decode_preferred_address(<<
IPv4_A:8,
IPv4_B:8,
IPv4_C:8,
IPv4_D:8,
IPv4Port:16,
IPv6_1:16,
IPv6_2:16,
IPv6_3:16,
IPv6_4:16,
IPv6_5:16,
IPv6_6:16,
IPv6_7:16,
IPv6_8:16,
IPv6Port:16,
CIDLen:8,
CID:CIDLen/binary,
StatelessResetToken:16/binary,
_Rest/binary
>>) ->
%% Parse IPv4 - zero address means not present
{IPv4Addr, IPv4PortVal} =
case {IPv4_A, IPv4_B, IPv4_C, IPv4_D, IPv4Port} of
{0, 0, 0, 0, 0} -> {undefined, undefined};
_ -> {{IPv4_A, IPv4_B, IPv4_C, IPv4_D}, IPv4Port}
end,
%% Parse IPv6 - zero address means not present
{IPv6Addr, IPv6PortVal} =
case {IPv6_1, IPv6_2, IPv6_3, IPv6_4, IPv6_5, IPv6_6, IPv6_7, IPv6_8, IPv6Port} of
{0, 0, 0, 0, 0, 0, 0, 0, 0} -> {undefined, undefined};
_ -> {{IPv6_1, IPv6_2, IPv6_3, IPv6_4, IPv6_5, IPv6_6, IPv6_7, IPv6_8}, IPv6Port}
end,
#preferred_address{
ipv4_addr = IPv4Addr,
ipv4_port = IPv4PortVal,
ipv6_addr = IPv6Addr,
ipv6_port = IPv6PortVal,
cid = CID,
stateless_reset_token = StatelessResetToken
}.
%% @doc Encode preferred_address transport parameter (RFC 9000 Section 18.2).
-spec encode_preferred_address(#preferred_address{}) -> binary().
encode_preferred_address(#preferred_address{
ipv4_addr = IPv4Addr,
ipv4_port = IPv4Port,
ipv6_addr = IPv6Addr,
ipv6_port = IPv6Port,
cid = CID,
stateless_reset_token = Token
}) ->
%% Encode IPv4 (zeros if not present)
IPv4Bin =
case IPv4Addr of
{A, B, C, D} -> <<A, B, C, D, IPv4Port:16>>;
undefined -> <<0, 0, 0, 0, 0:16>>
end,
%% Encode IPv6 (zeros if not present)
IPv6Bin =
case IPv6Addr of
{V1, V2, V3, V4, V5, V6, V7, V8} ->
<<V1:16, V2:16, V3:16, V4:16, V5:16, V6:16, V7:16, V8:16, IPv6Port:16>>;
undefined ->
<<0:128, 0:16>>
end,
CIDLen = byte_size(CID),
<<IPv4Bin/binary, IPv6Bin/binary, CIDLen:8, CID/binary, Token/binary>>.
%%====================================================================
%% TLS Message Framing
%%====================================================================
%% @doc Encode a TLS handshake message with type and length.
-spec encode_handshake_message(non_neg_integer(), binary()) -> binary().
encode_handshake_message(Type, Body) ->
Length = byte_size(Body),
<<Type:8, Length:24, Body/binary>>.
%% @doc Decode a TLS handshake message.
%% Returns {Type, Body, Rest} or {error, Reason}.
-spec decode_handshake_message(binary()) ->
{ok, {non_neg_integer(), binary()}, binary()}
| {error, term()}.
decode_handshake_message(<<Type:8, Length:24, Body:Length/binary, Rest/binary>>) ->
{ok, {Type, Body}, Rest};
decode_handshake_message(<<_Type:8, Length:24, Data/binary>>) when byte_size(Data) < Length ->
{error, incomplete};
decode_handshake_message(_) ->
{error, invalid}.
%%====================================================================
%% Internal Functions
%%====================================================================
encode_extension(Type, Data) ->
<<Type:16, (byte_size(Data)):16, Data/binary>>.
parse_extensions(Data) ->
case parse_extensions_ordered(Data) of
{ok, Ordered} ->
{ok, maps:from_list([{T, D} || {T, D, _Off, _Sz} <- Ordered])};
Error ->
Error
end.
%% @doc Parse an extensions blob preserving order and byte offsets.
%% Returns [{Type, Data, ByteOffset, ByteSize}] where ByteOffset is the
%% position of `Type' within `Data'. Rejects duplicate extension types
%% per RFC 8446 §4.2 with {error, {duplicate_extension, Type}}; the
%% caller maps that to an illegal_parameter alert.
-spec parse_extensions_ordered(binary()) ->
{ok, [{non_neg_integer(), binary(), non_neg_integer(), non_neg_integer()}]}
| {error, term()}.
parse_extensions_ordered(Data) ->
parse_extensions_ordered(Data, 0, [], #{}).
parse_extensions_ordered(<<>>, _Off, Acc, _Seen) ->
{ok, lists:reverse(Acc)};
parse_extensions_ordered(<<Type:16, Len:16, ExtData:Len/binary, Rest/binary>>, Off, Acc, Seen) ->
case maps:is_key(Type, Seen) of
true ->
{error, {duplicate_extension, Type}};
false ->
Entry = {Type, ExtData, Off, 4 + Len},
parse_extensions_ordered(
Rest, Off + 4 + Len, [Entry | Acc], maps:put(Type, true, Seen)
)
end;
parse_extensions_ordered(<<Type:16, Len:16, Data/binary>>, _Off, _Acc, _Seen) when
byte_size(Data) < Len
->
{error, {extension_truncated, Type, Len, byte_size(Data)}};
parse_extensions_ordered(Data, _Off, _Acc, _Seen) when byte_size(Data) < 4 ->
{error, {extension_header_incomplete, byte_size(Data)}};
parse_extensions_ordered(_, _, _, _) ->
{error, invalid_extensions}.
parse_server_key_share(<<?GROUP_X25519:16, 32:16, PubKey:32/binary, _/binary>>) ->
{ok, PubKey};
parse_server_key_share(<<?GROUP_SECP256R1:16, Len:16, PubKey:Len/binary, _/binary>>) ->
{ok, PubKey};
parse_server_key_share(_) ->
{error, unsupported_key_share}.
cipher_from_suite(?TLS_AES_128_GCM_SHA256) -> aes_128_gcm;
cipher_from_suite(?TLS_AES_256_GCM_SHA384) -> aes_256_gcm;
cipher_from_suite(?TLS_CHACHA20_POLY1305_SHA256) -> chacha20_poly1305;
cipher_from_suite(_) -> aes_128_gcm.
%% @private Encode a single psk_key_exchange_mode byte (RFC 8446 §4.2.9).
encode_psk_mode(psk_ke) -> <<?PSK_KE_MODE_KE:8>>;
encode_psk_mode(psk_dhe_ke) -> <<?PSK_KE_MODE_DHE_KE:8>>.
%%====================================================================
%% Server-side TLS Functions
%%====================================================================
%% @doc Parse a ClientHello message.
%% Returns a map with:
%% - random: 32-byte client random
%% - session_id: Legacy session ID
%% - cipher_suites: List of cipher suites offered
%% - extensions: Map of extensions
%% - key_share: Client's public key for key exchange
%% - server_name: SNI hostname (if present)
%% - alpn_protocols: List of ALPN protocols (if present)
%% - transport_params: QUIC transport parameters (if present)
-spec parse_client_hello(binary()) ->
{ok, map()} | {error, term()}.
parse_client_hello(<<
% Legacy version
?TLS_VERSION_1_2:16,
Random:32/binary,
SessionIdLen:8,
SessionId:SessionIdLen/binary,
CipherSuitesLen:16,
CipherSuites:CipherSuitesLen/binary,
CompressionLen:8,
_Compression:CompressionLen/binary,
ExtLen:16,
Extensions:ExtLen/binary,
_Rest/binary
>>) ->
%% Parse cipher suites
Ciphers = parse_cipher_suites(CipherSuites),
%% Parse extensions preserving order so PSK validation can assert
%% pre_shared_key is the last extension and capture the binders
%% offset for binder verification.
case parse_extensions_ordered(Extensions) of
{ok, OrderedExts} ->
ExtMap = maps:from_list([{T, D} || {T, D, _Off, _Sz} <- OrderedExts]),
%% Validate pre_shared_key placement (must be last per RFC 8446 §4.2.11).
case validate_psk_extension_placement(OrderedExts) of
ok ->
finalise_client_hello(
Random, SessionId, Ciphers, OrderedExts, ExtMap, Extensions
);
{error, _} = Err ->
Err
end;
{error, Reason} ->
{error, Reason}
end;
parse_client_hello(Data) when is_binary(Data) ->
%% Provide detailed error for debugging ClientHello parsing failures
case Data of
<<Version:16, _/binary>> when Version =/= ?TLS_VERSION_1_2 ->
{error, {invalid_legacy_version, Version}};
<<_:16, _Random:32/binary, SessionIdLen:8, Rest1/binary>> when
byte_size(Rest1) < SessionIdLen
->
{error, {session_id_too_short, SessionIdLen, byte_size(Rest1)}};
<<_:16, _:32/binary, SessionIdLen:8, _:SessionIdLen/binary, CipherSuitesLen:16,
Rest2/binary>> when byte_size(Rest2) < CipherSuitesLen ->
{error, {cipher_suites_too_short, CipherSuitesLen, byte_size(Rest2)}};
% Minimum: 2 + 32 + 1 + 2 + 1 = 38 bytes
_ when byte_size(Data) < 38 ->
{error, {client_hello_too_short, byte_size(Data)}};
_ ->
{error, {invalid_client_hello, byte_size(Data)}}
end;
parse_client_hello(_) ->
{error, invalid_client_hello_not_binary}.
%% @doc Build a ServerHello message.
%% Options:
%% - random: Server random (32 bytes, generated if not provided)
%% - session_id: Legacy session ID to echo
%% - cipher_suite: Selected cipher suite
%% - key_share: Server's public key
-spec build_server_hello(map()) -> {binary(), binary()}.
build_server_hello(Opts) ->
%% Generate server random
Random = maps:get(random, Opts, crypto:strong_rand_bytes(32)),
%% Echo session ID from client
SessionId = maps:get(session_id, Opts, <<>>),
SessionIdLen = byte_size(SessionId),
%% Selected cipher suite
CipherSuite = maps:get(cipher_suite, Opts, ?TLS_AES_128_GCM_SHA256),
%% Generate ECDHE key pair if not provided
{PubKey, PrivKey} =
case maps:find(key_pair, Opts) of
{ok, {Pub, Priv}} -> {Pub, Priv};
error -> crypto:generate_key(ecdh, x25519)
end,
%% Build extensions
Extensions = build_server_hello_extensions(PubKey, Opts),
ExtLen = byte_size(Extensions),
%% Build ServerHello
ServerHello = <<
% Legacy version (TLS 1.2)
?TLS_VERSION_1_2:16,
Random:32/binary,
SessionIdLen:8,
SessionId/binary,
CipherSuite:16,
% Legacy compression method (null)
0:8,
ExtLen:16,
Extensions/binary
>>,
%% Wrap with handshake header
Message = encode_handshake_message(?TLS_SERVER_HELLO, ServerHello),
{Message, PrivKey}.
%% @doc Build a HelloRetryRequest (RFC 8446 §4.1.4). Wire-encoded as
%% a ServerHello with the HRR sentinel random; its key_share carries
%% only the selected group (no public key). Echoes the client's
%% legacy_session_id and the negotiated cipher suite.
-spec build_hello_retry_request(binary(), non_neg_integer(), atom()) -> binary().
build_hello_retry_request(SessionId, CipherSuite, SelectedGroup) ->
SessionIdLen = byte_size(SessionId),
SupportedVersions = encode_extension(
?EXT_SUPPORTED_VERSIONS,
<<?TLS_VERSION_1_3:16>>
),
KeyShare = encode_extension(
?EXT_KEY_SHARE,
<<(group_to_code(SelectedGroup)):16>>
),
Extensions = <<SupportedVersions/binary, KeyShare/binary>>,
Body = <<
?TLS_VERSION_1_2:16,
(?TLS_HRR_RANDOM)/binary,
SessionIdLen:8,
SessionId/binary,
CipherSuite:16,
0:8,
(byte_size(Extensions)):16,
Extensions/binary
>>,
encode_handshake_message(?TLS_SERVER_HELLO, Body).
%% @doc Build EncryptedExtensions message.
%% Options:
%% - alpn: Selected ALPN protocol
%% - transport_params: QUIC transport parameters
-spec build_encrypted_extensions(map()) -> binary().
build_encrypted_extensions(Opts) ->
Extensions = build_encrypted_extensions_list(Opts),
ExtLen = byte_size(Extensions),
Body = <<ExtLen:16, Extensions/binary>>,
encode_handshake_message(?TLS_ENCRYPTED_EXTENSIONS, Body).
%% @doc Build Certificate message.
%% Certs is a list of DER-encoded certificates (server cert first).
-spec build_certificate(binary(), [binary()]) -> binary().
build_certificate(Context, Certs) ->
CertList = build_cert_list(Certs),
CertListLen = byte_size(CertList),
ContextLen = byte_size(Context),
Body = <<ContextLen:8, Context/binary, CertListLen:24, CertList/binary>>,
encode_handshake_message(?TLS_CERTIFICATE, Body).
%% @doc Build CertificateVerify message.
%% PrivateKey is the server's private key.
%% TranscriptHash is the hash of all handshake messages up to (not including) CertificateVerify.
-spec build_certificate_verify(non_neg_integer(), crypto:key_id(), binary()) -> binary().
build_certificate_verify(SignatureAlgorithm, PrivateKey, TranscriptHash) ->
%% Build the content to sign
%% RFC 8446 Section 4.4.3:
%% Content = 64 spaces + "TLS 1.3, server CertificateVerify" + 0x00 + TranscriptHash
Spaces = binary:copy(<<32>>, 64),
ContextString = <<"TLS 1.3, server CertificateVerify">>,
Content = <<Spaces/binary, ContextString/binary, 0, TranscriptHash/binary>>,
%% Sign with the appropriate algorithm
{SigAlg, HashAlg, SignOpts} = get_signature_params(SignatureAlgorithm),
CryptoKey = convert_private_key(SigAlg, PrivateKey),
Signature = crypto:sign(SigAlg, HashAlg, Content, CryptoKey, SignOpts),
SigLen = byte_size(Signature),
Body = <<SignatureAlgorithm:16, SigLen:16, Signature/binary>>,
encode_handshake_message(?TLS_CERTIFICATE_VERIFY, Body).
%%====================================================================
%% Server-side Internal Functions
%%====================================================================
parse_cipher_suites(<<>>) ->
[];
parse_cipher_suites(<<Suite:16, Rest/binary>>) ->
[Suite | parse_cipher_suites(Rest)].
parse_client_key_shares(<<Len:16, Data:Len/binary, _/binary>>) ->
parse_key_share_entries(Data);
parse_client_key_shares(_) ->
undefined.
parse_key_share_entries(<<>>) ->
[];
parse_key_share_entries(<<Group:16, Len:16, KeyData:Len/binary, Rest/binary>>) ->
[{Group, KeyData} | parse_key_share_entries(Rest)];
parse_key_share_entries(_) ->
[].
parse_server_name_ext(<<Len:16, Data:Len/binary, _/binary>>) ->
parse_server_name_list(Data);
parse_server_name_ext(_) ->
undefined.
parse_server_name_list(<<0:8, NameLen:16, Name:NameLen/binary, _/binary>>) ->
% Type 0 = hostname
Name;
parse_server_name_list(_) ->
undefined.
parse_alpn_ext(<<Len:16, Data:Len/binary, _/binary>>) ->
parse_alpn_list(Data);
parse_alpn_ext(_) ->
[].
parse_alpn_list(<<>>) ->
[];
parse_alpn_list(<<Len:8, Proto:Len/binary, Rest/binary>>) ->
[Proto | parse_alpn_list(Rest)];
parse_alpn_list(_) ->
[].
%% Parse pre_shared_key extension from ClientHello
%% RFC 8446 Section 4.2.11
%% Returns: #{identities => [{Ticket, ObfuscatedAge}], binders => [Binder]}
parse_pre_shared_key_ext(
<<IdentitiesLen:16, IdentitiesData:IdentitiesLen/binary, BindersLen:16,
BindersData:BindersLen/binary>>
) ->
Identities = parse_psk_identities(IdentitiesData),
Binders = parse_psk_binders(BindersData),
#{identities => Identities, binders => Binders};
parse_pre_shared_key_ext(_) ->
undefined.
%% @private
%% Reject ClientHellos where pre_shared_key is not the final extension
%% (RFC 8446 §4.2.11). Mapping `{error, psk_not_last}` to an
%% illegal_parameter alert is the caller's responsibility.
validate_psk_extension_placement(OrderedExts) ->
case lists:reverse(OrderedExts) of
[] ->
ok;
[{Last, _, _, _} | RestRev] ->
case lists:keyfind(?EXT_PRE_SHARED_KEY, 1, RestRev) of
false when Last =:= ?EXT_PRE_SHARED_KEY -> ok;
false -> ok;
_Found -> {error, psk_not_last}
end
end.
%% @private
%% Continue building the ClientHello map once extension order has
%% been validated. Captures the binders-section offset *within the
%% extensions blob* so the caller can compute the truncated
%% ClientHello for binder verification.
finalise_client_hello(Random, SessionId, Ciphers, OrderedExts, ExtMap, ExtBlob) ->
KeyShare =
case maps:find(?EXT_KEY_SHARE, ExtMap) of
{ok, KsData} -> parse_client_key_shares(KsData);
error -> undefined
end,
ServerName =
case maps:find(?EXT_SERVER_NAME, ExtMap) of
{ok, SniData} -> parse_server_name_ext(SniData);
error -> undefined
end,
ALPNProtocols =
case maps:find(?EXT_ALPN, ExtMap) of
{ok, AlpnData} -> parse_alpn_ext(AlpnData);
error -> []
end,
TransportParams =
case maps:find(?EXT_QUIC_TRANSPORT_PARAMS, ExtMap) of
{ok, TpData} ->
case decode_transport_params(TpData) of
{ok, TP} -> TP;
{error, _} -> #{}
end;
error ->
#{}
end,
PSK =
case maps:find(?EXT_PRE_SHARED_KEY, ExtMap) of
{ok, PskData} -> parse_pre_shared_key_ext(PskData);
error -> undefined
end,
PskModes =
case maps:find(?EXT_PSK_KEY_EXCHANGE_MODES, ExtMap) of
{ok, ModesData} -> parse_psk_modes_ext(ModesData);
error -> []
end,
SupportedGroups =
case maps:find(?EXT_SUPPORTED_GROUPS, ExtMap) of
{ok, SgData} -> parse_supported_groups_ext(SgData);
error -> []
end,
SignatureAlgs =
case maps:find(?EXT_SIGNATURE_ALGORITHMS, ExtMap) of
{ok, SaData} -> parse_signature_algorithms_ext(SaData);
error -> []
end,
EarlyData = maps:is_key(?EXT_EARLY_DATA, ExtMap),
%% Binders-section offset within the extension data of the PSK
%% extension, plus the absolute offset within ExtBlob. The
%% caller combines these with the handshake-message header to
%% truncate the ClientHello for binder verification.
PskBinders = psk_binders_offset(OrderedExts, ExtBlob),
{ok, #{
random => Random,
session_id => SessionId,
cipher_suites => Ciphers,
extensions => ExtMap,
ordered_extensions => OrderedExts,
key_share => KeyShare,
server_name => ServerName,
alpn_protocols => ALPNProtocols,
transport_params => TransportParams,
pre_shared_key => PSK,
psk_key_exchange_modes => PskModes,
psk_binders => PskBinders,
supported_groups => SupportedGroups,
signature_algorithms => SignatureAlgs,
early_data => EarlyData
}}.
%% @private Parse supported_groups (RFC 8446 §4.2.7) into recognised
%% group atoms; unknown codes are dropped (they can't be selected).
parse_supported_groups_ext(<<ListLen:16, Codes:ListLen/binary, _/binary>>) ->
[code_to_group(C) || <<C:16>> <= Codes, code_to_group(C) =/= unknown];
parse_supported_groups_ext(_) ->
[].
%% @private Parse signature_algorithms (RFC 8446 §4.2.3) into the
%% list of wire codes (integers) in the order offered.
parse_signature_algorithms_ext(<<ListLen:16, Codes:ListLen/binary, _/binary>>) ->
[C || <<C:16>> <= Codes];
parse_signature_algorithms_ext(_) ->
[].
%% @private Named-group wire code to atom (unknown -> unknown).
code_to_group(?GROUP_X25519) -> x25519;
code_to_group(?GROUP_SECP256R1) -> secp256r1;
code_to_group(?GROUP_SECP384R1) -> secp384r1;
code_to_group(_) -> unknown.
%% @private
%% Parse the psk_key_exchange_modes extension into a list of atoms.
%% RFC 8446 §4.2.9: <<Len:8, Modes:Len/binary>>.
parse_psk_modes_ext(<<Len:8, Modes:Len/binary, _/binary>>) ->
[decode_psk_mode(M) || <<M:8>> <= Modes];
parse_psk_modes_ext(_) ->
[].
decode_psk_mode(?PSK_KE_MODE_KE) -> psk_ke;
decode_psk_mode(?PSK_KE_MODE_DHE_KE) -> psk_dhe_ke;
decode_psk_mode(Other) -> {unknown, Other}.
%% @private
%% Locate the pre_shared_key extension and compute the offset (within
%% the extensions blob) of the binders-length field. Returns a map
%% `#{ext_offset, binders_offset, binders_size}` or `undefined' if no
%% PSK was offered.
psk_binders_offset(OrderedExts, _ExtBlob) ->
case lists:keyfind(?EXT_PRE_SHARED_KEY, 1, OrderedExts) of
false ->
undefined;
{?EXT_PRE_SHARED_KEY, PskData, ExtOffset, _ExtSize} ->
%% PskData = <<IdentitiesLen:16, Identities..., BindersLen:16, Binders...>>
case PskData of
<<IdentitiesLen:16, _Identities:IdentitiesLen/binary, BindersLen:16,
_Binders:BindersLen/binary>> ->
%% Header of pre_shared_key extension is type(2)+len(2) = 4 bytes,
%% so the binders-length field sits at:
%% ext_offset (start of type:16) + 4 (skip header)
%% + 2 (skip IdentitiesLen:16)
%% + IdentitiesLen
BindersOff = ExtOffset + 4 + 2 + IdentitiesLen,
BindersTotal = 2 + BindersLen,
#{
ext_offset => ExtOffset,
binders_offset => BindersOff,
binders_size => BindersTotal
};
_ ->
undefined
end
end.
%% @doc Select a PSK from a ClientHello and verify the binder.
%%
%% `ClientHelloMap' is the result of `parse_client_hello/1'.
%% `FullHandshakeMsg' is the raw 4-byte-headered handshake bytes for
%% ClientHello (msg_type + length + body) — needed to compute the
%% truncated ClientHello hash for binder verification.
%% `PskConfig' is `#{psk_callback => Fn | undefined,
%% psks => Map | undefined}`.
%% `ServerModes' is the list of psk_key_exchange modes the server is
%% willing to negotiate (defaults to `[psk_dhe_ke]`).
%%
%% Returns:
%% `{ok, #{identity_idx => N, secret => Secret, mode => Mode}}` on
%% successful selection (identity found, binder verified, modes
%% compatible);
%% `none' when the client didn't offer pre_shared_key OR offered
%% identities don't match local config OR no compatible mode
%% (caller falls through to cert path or sends
%% unknown_psk_identity);
%% `{error, bad_binder}` when an identity matched but the binder
%% didn't verify — caller MUST send decrypt_error (no cert fallback).
-spec select_psk(map(), binary(), map(), [psk_dhe_ke | psk_ke]) ->
{ok, map()} | none | {error, bad_binder}.
select_psk(#{pre_shared_key := undefined}, _FullMsg, _PskConfig, _ServerModes) ->
none;
select_psk(#{pre_shared_key := #{identities := []}}, _FullMsg, _PskConfig, _ServerModes) ->
none;
select_psk(
#{
pre_shared_key := #{identities := Identities, binders := Binders},
psk_key_exchange_modes := ClientModes,
psk_binders := PskBindersInfo,
cipher_suites := CipherSuites
},
FullHandshakeMsg,
PskConfig,
ServerModes
) ->
case mode_intersection(ClientModes, ServerModes) of
[] ->
none;
[SelectedMode | _] ->
Cipher = select_cipher(CipherSuites),
TruncatedHash = truncated_client_hello_hash(
FullHandshakeMsg, PskBindersInfo, Cipher
),
try_psk_identities(
Identities,
Binders,
0,
PskConfig,
SelectedMode,
Cipher,
TruncatedHash
)
end;
select_psk(_, _, _, _) ->
none.
mode_intersection(Client, Server) ->
[M || M <- Client, lists:member(M, Server)].
select_cipher(CipherSuites) ->
case lists:member(?TLS_AES_128_GCM_SHA256, CipherSuites) of
true -> aes_128_gcm;
false -> aes_128_gcm
end.
%% @private
%% Slice the full handshake bytes down to the truncated ClientHello
%% and compute its transcript hash.
truncated_client_hello_hash(FullHandshakeMsg, #{binders_offset := BindersOffInExt}, Cipher) ->
%% Handshake header = 4 bytes (msg_type:8, length:24).
%% After that comes the body: legacy_version(2) + random(32) +
%% session_id(1+N) + cipher_suites(2+N) + compression(1+N) +
%% extensions(2+N). The binders offset is relative to the start
%% of the extensions blob, so we need the absolute offset within
%% FullHandshakeMsg.
<<_MsgType:8, _Len:24, Body/binary>> = FullHandshakeMsg,
ExtBlobOffsetInBody = extensions_offset_in_body(Body),
AbsoluteBindersOff = 4 + ExtBlobOffsetInBody + BindersOffInExt,
%% RFC 8446 §4.2.11.2: keep every length field (including the
%% handshake-message length) at its final post-binder value; only the
%% binder bytes are removed. FullHandshakeMsg already carries the
%% full-length header, so hash its prefix as-is without rewrapping.
<<TruncatedBytes:AbsoluteBindersOff/binary, _/binary>> = FullHandshakeMsg,
quic_crypto:transcript_hash(Cipher, TruncatedBytes).
%% @private — find where the extensions blob starts within a
%% ClientHello body.
extensions_offset_in_body(
<<_LegacyVersion:16, _Random:32/binary, SessionIdLen:8, _SessionId:SessionIdLen/binary,
CipherSuitesLen:16, _CipherSuites:CipherSuitesLen/binary, CompressionLen:8,
_Compression:CompressionLen/binary, _ExtLen:16, _/binary>>
) ->
2 + 32 + 1 + SessionIdLen + 2 + CipherSuitesLen + 1 + CompressionLen + 2.
try_psk_identities([], _Binders, _Idx, _PskConfig, _Mode, _Cipher, _TruncatedHash) ->
none;
try_psk_identities(
[{Identity, _Age} | RestIds],
[Binder | RestBinders],
Idx,
PskConfig,
Mode,
Cipher,
TruncatedHash
) ->
case lookup_psk_secret(Identity, PskConfig) of
{ok, Secret} ->
case verify_psk_binder(Secret, Cipher, TruncatedHash, Binder) of
true ->
{ok, #{
identity_idx => Idx,
identity => Identity,
secret => Secret,
mode => Mode
}};
false ->
%% Identity matched but binder failed: per plan rule
%% 3, this is fatal — no cert-path fallback.
{error, bad_binder}
end;
not_found ->
try_psk_identities(
RestIds,
RestBinders,
Idx + 1,
PskConfig,
Mode,
Cipher,
TruncatedHash
)
end;
try_psk_identities(_, _, _, _, _, _, _) ->
none.
lookup_psk_secret(Identity, #{psk_callback := Fn} = Config) when is_function(Fn, 1) ->
case safe_callback(Fn, Identity) of
{ok, Secret} when is_binary(Secret) ->
{ok, Secret};
_ ->
lookup_in_psk_map(Identity, Config)
end;
lookup_psk_secret(Identity, Config) ->
lookup_in_psk_map(Identity, Config).
lookup_in_psk_map(Identity, #{psks := Map}) when is_map(Map) ->
case maps:find(Identity, Map) of
{ok, Secret} when is_binary(Secret) -> {ok, Secret};
_ -> not_found
end;
lookup_in_psk_map(_Identity, _) ->
not_found.
safe_callback(Fn, Identity) ->
try Fn(Identity) of
Result -> Result
catch
Class:Reason:Stack ->
logger:warning(
#{
what => psk_callback_failed,
class => Class,
reason => Reason,
stack => Stack
},
#{domain => [erlang_quic, tls]}
),
not_found
end.
verify_psk_binder(Secret, Cipher, TruncatedHash, OfferedBinder) ->
EarlySecret = quic_crypto:derive_early_secret(Cipher, Secret),
Expected = quic_crypto:compute_psk_binder(
Cipher, EarlySecret, TruncatedHash, external
),
crypto:hash_equals(Expected, OfferedBinder).
%% @doc Verify a resumption PSK binder against a parsed ClientHello.
%% `Secret' is the resumption PSK, `PskBindersInfo' is the `psk_binders'
%% map from `parse_client_hello/1', and `OfferedBinder' is the binder the
%% client sent. Uses the "res binder" label (RFC 8446 §4.2.11.2).
-spec verify_resumption_binder(binary(), atom(), binary(), map(), binary()) -> boolean().
verify_resumption_binder(Secret, Cipher, FullClientHello, PskBindersInfo, OfferedBinder) when
is_map(PskBindersInfo)
->
TruncatedHash = truncated_client_hello_hash(FullClientHello, PskBindersInfo, Cipher),
EarlySecret = quic_crypto:derive_early_secret(Cipher, Secret),
Expected = quic_crypto:compute_psk_binder(
Cipher, EarlySecret, TruncatedHash, resumption
),
crypto:hash_equals(Expected, OfferedBinder);
verify_resumption_binder(_Secret, _Cipher, _FullClientHello, _PskBindersInfo, _OfferedBinder) ->
%% Missing binder offset info — cannot verify, fail closed.
false.
parse_psk_identities(<<>>) ->
[];
parse_psk_identities(
<<IdentityLen:16, Identity:IdentityLen/binary, ObfuscatedAge:32, Rest/binary>>
) ->
[{Identity, ObfuscatedAge} | parse_psk_identities(Rest)];
parse_psk_identities(_) ->
[].
parse_psk_binders(<<>>) ->
[];
parse_psk_binders(<<BinderLen:8, Binder:BinderLen/binary, Rest/binary>>) ->
[Binder | parse_psk_binders(Rest)];
parse_psk_binders(_) ->
[].
build_server_hello_extensions(PubKey, Opts) ->
%% Supported versions extension (mandatory for TLS 1.3)
SupportedVersions = encode_extension(?EXT_SUPPORTED_VERSIONS, <<?TLS_VERSION_1_3:16>>),
%% Key share extension — included for psk_dhe_ke and standard
%% handshakes; omitted for psk_ke (PSK-only, no DHE per RFC 8446
%% §4.2.9).
KeyShare =
case maps:get(selected_psk_mode, Opts, undefined) of
psk_ke ->
<<>>;
_ ->
Group = maps:get(key_share_group, Opts, x25519),
encode_extension(
?EXT_KEY_SHARE,
<<(group_to_code(Group)):16, (byte_size(PubKey)):16, PubKey/binary>>
)
end,
%% pre_shared_key extension echo when a PSK was selected
%% (RFC 8446 §4.2.11 — ServerHello carries just selected_identity).
PskExt =
case maps:find(selected_psk_identity, Opts) of
{ok, Idx} when is_integer(Idx) ->
encode_extension(?EXT_PRE_SHARED_KEY, <<Idx:16>>);
_ ->
<<>>
end,
<<SupportedVersions/binary, KeyShare/binary, PskExt/binary>>.
build_encrypted_extensions_list(Opts) ->
%% ALPN extension (if ALPN was negotiated)
ALPNExt =
case maps:find(alpn, Opts) of
{ok, ALPN} when is_binary(ALPN), byte_size(ALPN) > 0 ->
ALPNLen = byte_size(ALPN),
encode_extension(?EXT_ALPN, <<(ALPNLen + 1):16, ALPNLen:8, ALPN/binary>>);
_ ->
<<>>
end,
%% QUIC transport parameters
TPExt =
case maps:find(transport_params, Opts) of
{ok, TP} when map_size(TP) > 0 ->
TPData = encode_transport_params(TP),
encode_extension(?EXT_QUIC_TRANSPORT_PARAMS, TPData);
_ ->
<<>>
end,
%% Early data indication (RFC 8446 Section 4.2.10)
%% When server accepts early data, include empty early_data extension
EarlyDataExt =
case maps:get(early_data, Opts, false) of
true ->
encode_extension(?EXT_EARLY_DATA, <<>>);
false ->
<<>>
end,
<<ALPNExt/binary, TPExt/binary, EarlyDataExt/binary>>.
build_cert_list([]) ->
<<>>;
build_cert_list([Cert | Rest]) ->
CertLen = byte_size(Cert),
RestCerts = build_cert_list(Rest),
%% Each cert entry: cert_data<1..2^24-1> extensions<0..2^16-1>
<<CertLen:24, Cert/binary, 0:16, RestCerts/binary>>.
get_signature_params(?SIG_RSA_PSS_RSAE_SHA256) ->
{rsa, sha256, [{rsa_padding, rsa_pkcs1_pss_padding}, {rsa_pss_saltlen, -1}]};
get_signature_params(?SIG_RSA_PSS_RSAE_SHA384) ->
{rsa, sha384, [{rsa_padding, rsa_pkcs1_pss_padding}, {rsa_pss_saltlen, -1}]};
get_signature_params(?SIG_RSA_PSS_RSAE_SHA512) ->
{rsa, sha512, [{rsa_padding, rsa_pkcs1_pss_padding}, {rsa_pss_saltlen, -1}]};
get_signature_params(?SIG_ECDSA_SECP256R1_SHA256) ->
{ecdsa, sha256, []};
get_signature_params(?SIG_ECDSA_SECP384R1_SHA384) ->
{ecdsa, sha384, []};
get_signature_params(?SIG_ED25519) ->
%% EdDSA pre-hashes internally; no separate hash and no padding.
%% The curve atom rides in the key list (see convert_private_key).
{eddsa, none, []};
get_signature_params(Scheme) ->
%% No silent fallback — an unsupported scheme must fail loudly
%% rather than sign/verify with the wrong algorithm.
error({unsupported_signature_scheme, Scheme}).
%% Convert private key to format expected by crypto:sign
convert_private_key(
ecdsa, {'ECPrivateKey', _, PrivKeyBin, {namedCurve, {1, 2, 840, 10045, 3, 1, 7}}, _, _}
) ->
%% secp256r1 / P-256
[PrivKeyBin, secp256r1];
convert_private_key(ecdsa, {'ECPrivateKey', _, PrivKeyBin, {namedCurve, {1, 3, 132, 0, 34}}, _, _}) ->
%% secp384r1 / P-384
[PrivKeyBin, secp384r1];
convert_private_key(ecdsa, {'ECPrivateKey', _, PrivKeyBin, _, _, _}) ->
%% Default EC curve
[PrivKeyBin, secp256r1];
convert_private_key(rsa, {'RSAPrivateKey', _, N, E, D, _P, _Q, _Dp, _Dq, _Qi, _}) ->
%% crypto:sign expects [E, N, D] list format for RSA
[E, N, D];
convert_private_key(rsa, Key) when is_list(Key) ->
%% Already in list format
Key;
convert_private_key(eddsa, {ed_pri, ed25519, _Pub, Priv}) ->
%% crypto:sign(eddsa, none, Msg, [PrivKeyBin, ed25519])
[Priv, ed25519];
convert_private_key(eddsa, {'ECPrivateKey', _, PrivKeyBin, {namedCurve, {1, 3, 101, 112}}, _, _}) ->
[PrivKeyBin, ed25519];
convert_private_key(_, Key) ->
Key.
%%====================================================================
%% Client Certificate Support (Mutual TLS)
%%====================================================================
%% @doc Build a CertificateRequest message (RFC 8446 §4.3.2) with the
%% default advertised signature schemes.
-spec build_certificate_request(binary()) -> binary().
build_certificate_request(Context) ->
build_certificate_request(Context, [
ecdsa_secp256r1_sha256, rsa_pss_rsae_sha256, rsa_pkcs1_sha256, ed25519
]).
%% @doc Build a CertificateRequest advertising the given signature
%% schemes (RFC 8446 §4.3.2; signature_algorithms is required).
-spec build_certificate_request(binary(), [atom()]) -> binary().
build_certificate_request(Context, SigAlgAtoms) ->
SigAlgs = iolist_to_binary([<<(sig_alg_to_code(A)):16>> || A <- SigAlgAtoms]),
SigAlgsLen = byte_size(SigAlgs),
SigAlgsExt =
<<?EXT_SIGNATURE_ALGORITHMS:16, (SigAlgsLen + 2):16, SigAlgsLen:16, SigAlgs/binary>>,
ExtLen = byte_size(SigAlgsExt),
ContextLen = byte_size(Context),
Body = <<ContextLen:8, Context/binary, ExtLen:16, SigAlgsExt/binary>>,
encode_handshake_message(?TLS_CERTIFICATE_REQUEST, Body).
%% @doc Parse a CertificateRequest message. Returns the context and
%% the advertised signature_algorithms (wire codes) so an mTLS client
%% can pick a compatible CertificateVerify scheme.
-spec parse_certificate_request(binary()) -> {ok, map()} | {error, term()}.
parse_certificate_request(
<<ContextLen:8, Context:ContextLen/binary, ExtLen:16, Extensions:ExtLen/binary, _/binary>>
) ->
SigAlgs =
case parse_extensions(Extensions) of
{ok, ExtMap} ->
case maps:find(?EXT_SIGNATURE_ALGORITHMS, ExtMap) of
{ok, SaData} -> parse_signature_algorithms_ext(SaData);
error -> []
end;
_ ->
[]
end,
{ok, #{context => Context, signature_algorithms => SigAlgs}};
parse_certificate_request(_) ->
{error, invalid_certificate_request}.
%% @doc Build a CertificateVerify message for client (RFC 8446 Section 4.4.3).
%% Uses "TLS 1.3, client CertificateVerify" context string.
-spec build_certificate_verify_client(non_neg_integer(), term(), binary()) -> binary().
build_certificate_verify_client(SignatureAlgorithm, PrivateKey, TranscriptHash) ->
%% RFC 8446 Section 4.4.3: Client uses different context string
Spaces = binary:copy(<<32>>, 64),
ContextString = <<"TLS 1.3, client CertificateVerify">>,
Content = <<Spaces/binary, ContextString/binary, 0, TranscriptHash/binary>>,
{SigAlg, HashAlg, SignOpts} = get_signature_params(SignatureAlgorithm),
CryptoKey = convert_private_key(SigAlg, PrivateKey),
Signature = crypto:sign(SigAlg, HashAlg, Content, CryptoKey, SignOpts),
SigLen = byte_size(Signature),
Body = <<SignatureAlgorithm:16, SigLen:16, Signature/binary>>,
encode_handshake_message(?TLS_CERTIFICATE_VERIFY, Body).
%% @doc Verify CertificateVerify signature.
%% Role is 'client' or 'server' - determines context string.
-spec verify_certificate_verify(binary(), binary(), binary(), client | server) -> boolean().
verify_certificate_verify(Body, PeerCertDER, TranscriptHash, Role) ->
case parse_certificate_verify(Body) of
{ok, #{algorithm := Algorithm, signature := Signature}} ->
case extract_public_key_for_verify(PeerCertDER, Algorithm) of
{ok, PublicKey} ->
%% Build content that was signed
Spaces = binary:copy(<<32>>, 64),
ContextString =
case Role of
client -> <<"TLS 1.3, client CertificateVerify">>;
server -> <<"TLS 1.3, server CertificateVerify">>
end,
Content = <<Spaces/binary, ContextString/binary, 0, TranscriptHash/binary>>,
%% Verify signature
{SigAlg, HashAlg, VerifyOpts} = get_signature_params(Algorithm),
try
crypto:verify(SigAlg, HashAlg, Content, Signature, PublicKey, VerifyOpts)
catch
_:_ -> false
end;
{error, _} ->
false
end;
{error, _} ->
false
end.
%% @doc Extract public key from DER certificate and convert to crypto:verify format.
-spec extract_public_key_for_verify(binary(), non_neg_integer()) ->
{ok, term()} | {error, term()}.
extract_public_key_for_verify(CertDER, Algorithm) ->
try
OTPCert = public_key:pkix_decode_cert(CertDER, otp),
TBSCert = OTPCert#'OTPCertificate'.tbsCertificate,
SubjectPKInfo = TBSCert#'OTPTBSCertificate'.subjectPublicKeyInfo,
convert_public_key_for_verify(SubjectPKInfo, Algorithm)
catch
_:Reason -> {error, Reason}
end.
%% Convert public key info to format expected by crypto:verify/6
convert_public_key_for_verify(
#'OTPSubjectPublicKeyInfo'{
algorithm = #'PublicKeyAlgorithm'{algorithm = ?'id-ecPublicKey', parameters = Params},
subjectPublicKey = #'ECPoint'{point = ECPoint}
},
Algorithm
) when
Algorithm =:= ?SIG_ECDSA_SECP256R1_SHA256;
Algorithm =:= ?SIG_ECDSA_SECP384R1_SHA384
->
%% ECDSA: [ECPoint, NamedCurve]
Curve =
case Params of
{namedCurve, ?'secp256r1'} -> secp256r1;
{namedCurve, ?'secp384r1'} -> secp384r1;
_ -> secp256r1
end,
{ok, [ECPoint, Curve]};
convert_public_key_for_verify(
#'OTPSubjectPublicKeyInfo'{
algorithm = #'PublicKeyAlgorithm'{algorithm = ?'rsaEncryption'},
subjectPublicKey = #'RSAPublicKey'{modulus = N, publicExponent = E}
},
_Algorithm
) ->
%% RSA: [E, N]
{ok, [E, N]};
convert_public_key_for_verify(
#'OTPSubjectPublicKeyInfo'{
algorithm = #'PublicKeyAlgorithm'{algorithm = {1, 3, 101, 112}},
subjectPublicKey = #'ECPoint'{point = PubKey}
},
?SIG_ED25519
) ->
%% Ed25519 (OID 1.3.101.112): OTP wraps the 32-byte public key in
%% an ECPoint.
{ok, [PubKey, ed25519]};
convert_public_key_for_verify(
#'OTPSubjectPublicKeyInfo'{
algorithm = #'PublicKeyAlgorithm'{algorithm = {1, 3, 101, 112}},
subjectPublicKey = PubKey
},
?SIG_ED25519
) when is_binary(PubKey) ->
{ok, [PubKey, ed25519]};
convert_public_key_for_verify(_, _) ->
{error, unsupported_key_type}.