%%% -*- 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, 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 = <>, %% 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}, BaseExtensions = build_client_hello_extensions(PubKey, OptsWithModes), %% 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 = <>, IdentitiesLen = byte_size(PskIdentity), Identities = <>, BinderPlaceholder = <<0:BinderLen/unit:8>>, BindersSection = <<(BinderLen + 1):16, BinderLen:8, BinderPlaceholder/binary>>, PskExtBody = <>, PskExtLen = byte_size(PskExtBody), PskExt = <>, AllExtensions = <>, ExtensionsLen = byte_size(AllExtensions), SessionId = maps:get(session_id, Opts, <<>>), CipherSuites = <>, 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, <> = ClientHelloBody, TruncatedMsg = encode_handshake_message(?TLS_CLIENT_HELLO, TruncatedBody), 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 = <>, 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, <>} -> {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; _ -> 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. -spec parse_encrypted_extensions(binary()) -> {ok, #{alpn => binary(), transport_params => map()}} | {error, term()}. parse_encrypted_extensions(<>) -> 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, {ok, #{alpn => Alpn, transport_params => TransportParams}}; 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( <> ) -> Certs = parse_certificate_list(CertsData), {ok, #{context => Context, certificates => Certs}}; parse_certificate(_) -> {error, invalid_certificate}. parse_certificate_list(<<>>) -> []; parse_certificate_list( <> ) -> [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(<>) -> {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 <> = 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(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)), <>. %% @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), <> = 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} -> <>; 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} -> <>; undefined -> <<0:128, 0:16>> end, CIDLen = byte_size(CID), <>. %%==================================================================== %% 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), <>. %% @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(<>) -> {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) -> <>. 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(<>, 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(<>, _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(<>) -> {ok, PubKey}; parse_server_key_share(<>) -> {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) -> <>; encode_psk_mode(psk_dhe_ke) -> <>. %%==================================================================== %% 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 <> 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, <> ), KeyShare = encode_extension( ?EXT_KEY_SHARE, <<(group_to_code(SelectedGroup)):16>> ), Extensions = <>, 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 = <>, 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 = <>, 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 = <>, %% 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 = <>, encode_handshake_message(?TLS_CERTIFICATE_VERIFY, Body). %%==================================================================== %% Server-side Internal Functions %%==================================================================== parse_cipher_suites(<<>>) -> []; parse_cipher_suites(<>) -> [Suite | parse_cipher_suites(Rest)]. parse_client_key_shares(<>) -> parse_key_share_entries(Data); parse_client_key_shares(_) -> undefined. parse_key_share_entries(<<>>) -> []; parse_key_share_entries(<>) -> [{Group, KeyData} | parse_key_share_entries(Rest)]; parse_key_share_entries(_) -> []. parse_server_name_ext(<>) -> 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(<>) -> parse_alpn_list(Data); parse_alpn_ext(_) -> []. parse_alpn_list(<<>>) -> []; parse_alpn_list(<>) -> [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( <> ) -> 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(<>) -> [code_to_group(C) || <> <= 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(<>) -> [C || <> <= 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: <>. parse_psk_modes_ext(<>) -> [decode_psk_mode(M) || <> <= 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 = <> case PskData of <> -> %% 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, <> = FullHandshakeMsg, %% Rebuild handshake header with the truncated length per RFC 8446 %% §4.2.11.2 — the length reflects the truncated body. TruncatedBodyLen = AbsoluteBindersOff - 4, <> = TruncatedBytes, Rewrapped = <>, quic_crypto:transcript_hash(Cipher, Rewrapped). %% @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). parse_psk_identities(<<>>) -> []; parse_psk_identities( <> ) -> [{Identity, ObfuscatedAge} | parse_psk_identities(Rest)]; parse_psk_identities(_) -> []. parse_psk_binders(<<>>) -> []; parse_psk_binders(<>) -> [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, <>), %% 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, <>); _ -> <<>> end, <>. 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, <>. 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> <>. 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 = <>, ExtLen = byte_size(SigAlgsExt), ContextLen = byte_size(Context), Body = <>, 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( <> ) -> 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 = <>, {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 = <>, 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 = <>, %% 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}.