-module(nquic_tls_server). -moduledoc """ Server-side TLS 1.3 handshake flow for QUIC per RFC 9001. Processes ClientHello, builds the server handshake flight (EncryptedExtensions, Certificate, CertificateVerify, Finished) for full handshakes and the abbreviated EE+Finished flight for PSK resumption, and verifies the client Finished. Codec helpers shared with the client live in `nquic_tls`; PSK / NewSessionTicket helpers remain there too because they straddle both roles. """. -include("nquic_tls.hrl"). -include_lib("public_key/include/public_key.hrl"). -export([ make_server_handshake_flight/6, make_server_handshake_flight_psk/4, process_client_hello/3, process_client_hello/4, validate_psk_offer/4, verify_client_finished/3, verify_client_finished/4 ]). -export([ encode_extensions/1, find_alpn/1, find_cipher_match/2, find_match/2, make_certificate_message/2, parse_alpn_items/1, parse_cipher_suites/1, select_alpn/2, select_cipher/1, select_cipher/2 ]). -record(server_hello, { server_version, random, session_id, cipher_suite, compression_method, extensions }). -record(key_share_server_hello, { server_share }). -doc "Build the server handshake flight (EncryptedExtensions, Certificate, CertificateVerify, Finished).". -spec make_server_handshake_flight( binary(), map(), map(), binary(), [binary()], public_key:private_key() ) -> {ok, binary(), map(), map()} | {error, nquic_error:any_reason()}. make_server_handshake_flight(HandshakeSecret, Keys, State, CertDER, CertChain, PrivKey) -> try maybe Ctx0 = maps:get(transcript_ctx, Keys), ServerSecret = maps:get(server_secret, Keys), TransportParams = maps:get(transport_params, State), Cipher = maps:get(cipher, State, aes_128_gcm), Version = maps:get(quic_version, State, 1), Hash = nquic_keys:cipher_to_hash(Cipher), HashLen = nquic_tls:hash_length(Hash), TPBin = nquic_transport:encode(TransportParams), ExtID = 57, ExtLen = byte_size(TPBin), ExtData0 = <>, ExtData = case maps:get(selected_alpn, State, undefined) of undefined -> ExtData0; Proto -> P = <<(byte_size(Proto)):8, Proto/binary>>, ALPNData = <<(byte_size(P)):16, P/binary>>, ALPNBin = <<16:16, (byte_size(ALPNData)):16, ALPNData/binary>>, <> end, EELen = byte_size(ExtData), EEBody = <>, EEHeader = <<8, (byte_size(EEBody)):24>>, EEBin = <>, CertMsg = make_certificate_message(CertDER, CertChain), Ctx1 = crypto:hash_update(Ctx0, EEBin), Ctx2 = crypto:hash_update(Ctx1, CertMsg), TranscriptHashCV = crypto:hash_final(Ctx2), {ok, CertVerifyMsg} ?= make_certificate_verify_message(PrivKey, TranscriptHashCV), Ctx3 = crypto:hash_update(Ctx2, CertVerifyMsg), FinishedKey = nquic_keys:qhkdf_expand(ServerSecret, <<"finished">>, <<>>, HashLen), TranscriptHashFin = crypto:hash_final(Ctx3), VerifyData = crypto:mac(hmac, Hash, FinishedKey, TranscriptHashFin), FinLen = byte_size(VerifyData), FinBody = <>, FinHeader = <<20, FinBody/binary>>, FinBin = FinHeader, Ctx4 = crypto:hash_update(Ctx3, FinBin), TranscriptHashFinal = crypto:hash_final(Ctx4), {ClientAppSecret, ServerAppSecret} = nquic_keys:master_secrets( HandshakeSecret, TranscriptHashFinal, Hash ), {CKey, CIV, CHP} = nquic_keys:derive_packet_protection( ClientAppSecret, Cipher, Version ), {SKey, SIV, SHP} = nquic_keys:derive_packet_protection( ServerAppSecret, Cipher, Version ), AppKeys = #{ client_secret => ClientAppSecret, server_secret => ServerAppSecret, client_key => CKey, client_iv => CIV, client_hp => CHP, server_key => SKey, server_iv => SIV, server_hp => SHP, transcript_ctx => Ctx4, quic_version => Version }, FlightBin = <>, ClientHSSecret = maps:get(client_secret, Keys), NewState = State#{transcript_ctx => Ctx4, client_secret => ClientHSSecret}, {ok, FlightBin, AppKeys, NewState} end catch error:Reason -> {error, {flight_generation_failed, Reason}} end. -doc """ Build the server handshake flight for PSK resumption (no Certificate/CertificateVerify). EncryptedExtensions + Finished only. Optionally includes early_data extension to signal 0-RTT acceptance. """. -spec make_server_handshake_flight_psk( binary(), map(), map(), boolean() ) -> {ok, binary(), map(), map()} | {error, term()}. make_server_handshake_flight_psk(HandshakeSecret, Keys, State, AcceptEarlyData) -> try Ctx0 = maps:get(transcript_ctx, Keys), ServerSecret = maps:get(server_secret, Keys), Cipher = maps:get(cipher, State, aes_128_gcm), Version = maps:get(quic_version, State, 1), Hash = nquic_keys:cipher_to_hash(Cipher), HashLen = nquic_tls:hash_length(Hash), TransportParams = maps:get(transport_params, State), TPBin = nquic_transport:encode(TransportParams), ExtID = 57, ExtLen = byte_size(TPBin), ExtData0 = <>, ExtData1 = case maps:get(selected_alpn, State, undefined) of undefined -> ExtData0; Proto -> P = <<(byte_size(Proto)):8, Proto/binary>>, ALPNData = <<(byte_size(P)):16, P/binary>>, ALPNBin = <<16:16, (byte_size(ALPNData)):16, ALPNData/binary>>, <> end, ExtData = case AcceptEarlyData of true -> <>; false -> ExtData1 end, EELen = byte_size(ExtData), EEBody = <>, EEHeader = <<8, (byte_size(EEBody)):24>>, EEBin = <>, Ctx1 = crypto:hash_update(Ctx0, EEBin), FinishedKey = nquic_keys:qhkdf_expand(ServerSecret, <<"finished">>, <<>>, HashLen), TranscriptHashFin = crypto:hash_final(Ctx1), VerifyData = crypto:mac(hmac, Hash, FinishedKey, TranscriptHashFin), FinLen = byte_size(VerifyData), FinBody = <>, FinBin = <<20, FinBody/binary>>, Ctx2 = crypto:hash_update(Ctx1, FinBin), TranscriptHashFinal = crypto:hash_final(Ctx2), {ClientAppSecret, ServerAppSecret} = nquic_keys:master_secrets( HandshakeSecret, TranscriptHashFinal, Hash ), {CKey, CIV, CHP} = nquic_keys:derive_packet_protection(ClientAppSecret, Cipher, Version), {SKey, SIV, SHP} = nquic_keys:derive_packet_protection(ServerAppSecret, Cipher, Version), AppKeys = #{ client_secret => ClientAppSecret, server_secret => ServerAppSecret, client_key => CKey, client_iv => CIV, client_hp => CHP, server_key => SKey, server_iv => SIV, server_hp => SHP, transcript_ctx => Ctx2, quic_version => Version }, FlightBin = <>, ClientHSSecret = maps:get(client_secret, Keys), NewState = State#{transcript_ctx => Ctx2, client_secret => ClientHSSecret}, {ok, FlightBin, AppKeys, NewState} catch error:Reason -> {error, {psk_flight_generation_failed, Reason}} end. -doc "Process a ClientHello, generate a ServerHello, and derive handshake secrets.". -spec process_client_hello(binary(), nquic_transport:params(), [binary()] | undefined) -> {ok, binary(), map(), map()} | {error, term()}. process_client_hello(ClientHelloBin, TransportParams, SupportedALPNs) -> process_client_hello(ClientHelloBin, TransportParams, SupportedALPNs, #{}). -doc """ Process a ClientHello with options. Opts may include `psk_selected => Index` to include pre_shared_key in ServerHello. """. -spec process_client_hello(binary(), nquic_transport:params(), [binary()] | undefined, map()) -> {ok, binary(), map(), map()} | {error, term()}. process_client_hello(ClientHelloBin, TransportParams, SupportedALPNs, Opts) -> try maybe {ok, SessionID, ClientKeyShare, RemoteTP, ClientALPNs, ClientCiphers, PSKInfo} ?= parse_client_hello(ClientHelloBin), {ok, SelectedALPN} ?= select_alpn(ClientALPNs, SupportedALPNs), Preferred = maps:get(cipher_suites, Opts, undefined), {ok, Cipher} ?= select_cipher(ClientCiphers, Preferred), Hash = nquic_keys:cipher_to_hash(Cipher), {ServerPubKey, ServerPrivKey} = crypto:generate_key(ecdh, x25519), BaseExtensions = #{ server_hello_versions => #server_hello_versions{versions = {3, 4}}, key_share => #key_share_server_hello{ server_share = #key_share_entry{group = x25519, key_exchange = ServerPubKey} } }, Extensions = case maps:get(psk_selected, Opts, undefined) of undefined -> BaseExtensions; Index -> BaseExtensions#{pre_shared_key => Index} end, SH = #server_hello{ server_version = {3, 3}, random = crypto:strong_rand_bytes(32), session_id = SessionID, cipher_suite = nquic_tls:encode_cipher_suite(Cipher), compression_method = 0, extensions = Extensions }, ServerHelloBin = manual_encode_server_hello(SH), SharedSecret = crypto:compute_key(ecdh, ClientKeyShare, ServerPrivKey, x25519), Ctx0 = crypto:hash_init(Hash), Ctx1 = crypto:hash_update(Ctx0, ClientHelloBin), Ctx2 = crypto:hash_update(Ctx1, ServerHelloBin), TranscriptHash = crypto:hash_final(Ctx2), PSKValue = maps:get(psk_value, Opts, undefined), {ClientHSSecret, ServerHSSecret, HandshakeSecret} = nquic_keys:handshake_secrets( SharedSecret, TranscriptHash, Hash, PSKValue ), Version = maps:get(quic_version, Opts, 1), {CKey, CIV, CHP} = nquic_keys:derive_packet_protection(ClientHSSecret, Cipher, Version), {SKey, SIV, SHP} = nquic_keys:derive_packet_protection(ServerHSSecret, Cipher, Version), Keys = #{ client_secret => ClientHSSecret, server_secret => ServerHSSecret, client_key => CKey, client_iv => CIV, client_hp => CHP, server_key => SKey, server_iv => SIV, server_hp => SHP, handshake_secret => HandshakeSecret, transcript_ctx => Ctx2, remote_params => RemoteTP, cipher => Cipher, quic_version => Version }, State0 = #{ priv_key => ServerPrivKey, role => server, transport_params => TransportParams, client_secret => ClientHSSecret, server_secret => ServerHSSecret, handshake_secret => HandshakeSecret, transcript_ctx => Ctx2, remote_params => RemoteTP, selected_alpn => SelectedALPN, cipher => Cipher, quic_version => Version }, State = case PSKInfo of undefined -> State0; _ -> State0#{ psk_info => PSKInfo, client_hello_bin => ClientHelloBin } end, {ok, ServerHelloBin, Keys, State} end catch error:Reason -> {error, {processing_failed, Reason}} end. -doc """ Validate a PSK offer from a ClientHello against the server's ticket. Decrypts the first matching identity, verifies the binder, and returns the PSK and whether 0-RTT should be accepted. StaticKey is the server's ticket encryption key. ClientHelloBin is the raw ClientHello (needed for binder verification). """. -spec validate_psk_offer(map(), binary(), binary(), atom()) -> {ok, binary(), atom(), boolean(), binary()} | {error, term()}. validate_psk_offer(PSKInfo, ClientHelloBin, StaticKey, NegCipher) -> #{identities := Identities, binders := Binders, early_data := HasEarlyData} = PSKInfo, validate_psk_identities( Identities, Binders, ClientHelloBin, StaticKey, NegCipher, HasEarlyData ). -doc "Verify the client Finished message using the client handshake traffic secret.". -spec verify_client_finished(binary(), binary(), crypto:hash_state()) -> ok | {error, nquic_error:any_reason()}. verify_client_finished(Data, ClientSecret, TranscriptCtx) -> verify_client_finished(Data, ClientSecret, TranscriptCtx, aes_128_gcm). -doc "Verify the client Finished message with an explicit cipher suite.". -spec verify_client_finished( binary(), binary(), crypto:hash_state(), aes_128_gcm | aes_256_gcm | chacha20_poly1305 ) -> ok | {error, term()}. verify_client_finished(Data, ClientSecret, TranscriptCtx, Cipher) -> try Hash = nquic_keys:cipher_to_hash(Cipher), HashLen = nquic_tls:hash_length(Hash), <<20:8, Len:24, VerifyData:Len/binary>> = Data, FinishedKey = nquic_keys:qhkdf_expand(ClientSecret, <<"finished">>, <<>>, HashLen), TranscriptHash = crypto:hash_final(TranscriptCtx), ExpectedData = crypto:mac(hmac, Hash, FinishedKey, TranscriptHash), if VerifyData =:= ExpectedData -> ok; true -> {error, client_finished_verification_failed} end catch error:{badmatch, _} -> {error, malformed_finished} end. %%%----------------------------------------------------------------------------- %% INTERNAL SERVERHELLO ENCODING %%%----------------------------------------------------------------------------- -spec encode_extensions(map()) -> binary(). encode_extensions(Exts) -> V = case maps:get(server_hello_versions, Exts, undefined) of #server_hello_versions{versions = {Maj, Min}} -> <<0, 43, 0, 2, Maj, Min>>; _ -> <<>> end, K = case maps:get(key_share, Exts, undefined) of #key_share_server_hello{ server_share = #key_share_entry{group = x25519, key_exchange = Key} } -> Group = 16#001d, KLen = byte_size(Key), Entry = <>, ExtLen = byte_size(Entry), <<0, 51, ExtLen:16, Entry/binary>>; _ -> <<>> end, PSK = case maps:get(pre_shared_key, Exts, undefined) of undefined -> <<>>; SelectedIndex when is_integer(SelectedIndex) -> <<0, 41, 0, 2, SelectedIndex:16>> end, <>. -spec manual_encode_server_hello(#server_hello{}) -> binary(). manual_encode_server_hello(SH) -> #server_hello{ server_version = {Major, Minor}, random = Random, session_id = SessionID, cipher_suite = CipherSuite, compression_method = CompMethod, extensions = ExtensionsMap } = SH, VerBin = <>, SIDLen = byte_size(SessionID), SIDBin = <>, CSBin = CipherSuite, CompBin = <>, ExtsBin = encode_extensions(ExtensionsMap), ExtsLen = byte_size(ExtsBin), ExtsLenBin = <>, Body = <>, Type = 2, Len = byte_size(Body), <>. %%%----------------------------------------------------------------------------- %% INTERNAL CERTIFICATE / CERTIFICATEVERIFY %%%----------------------------------------------------------------------------- -spec encode_cert_entries([binary()]) -> iolist(). encode_cert_entries([]) -> []; encode_cert_entries([CertDER | Rest]) -> CertLen = byte_size(CertDER), [<> | encode_cert_entries(Rest)]. -spec make_certificate_message(binary(), [binary()]) -> binary(). make_certificate_message(LeafDER, ChainDERs) -> Entries = encode_cert_entries([LeafDER | ChainDERs]), ListLen = iolist_size(Entries), Body = [<<0:8, ListLen:24>>, Entries], BodyBin = iolist_to_binary(Body), MsgLen = byte_size(BodyBin), <<11:8, MsgLen:24, BodyBin/binary>>. -spec make_certificate_verify_message(public_key:private_key(), binary()) -> {ok, binary()} | {error, nquic_error:any_reason()}. make_certificate_verify_message(PrivKey, Hash) -> Pad = binary:copy(<<16#20>>, 64), Context = <<"TLS 1.3, server CertificateVerify">>, Input = <>, maybe {ok, {Alg, Sig}} ?= sign(PrivKey, Input), SigLen = byte_size(Sig), Body = <>, MsgLen = byte_size(Body), {ok, <<15:8, MsgLen:24, Body/binary>>} end. -spec sign(public_key:private_key(), binary()) -> {ok, {non_neg_integer(), binary()}} | {error, nquic_error:any_reason()}. sign(#'RSAPrivateKey'{} = Key, Data) -> Sig = public_key:sign(Data, sha256, Key, [ {rsa_padding, rsa_pkcs1_pss_padding}, {rsa_pss_saltlen, -1} ]), {ok, {16#0804, Sig}}; sign(#'ECPrivateKey'{parameters = {namedCurve, {1, 2, 840, 10045, 3, 1, 7}}} = Key, Data) -> Sig = public_key:sign(Data, sha256, Key), {ok, {16#0403, Sig}}; sign(_, _) -> {error, unsupported_key_type}. %%%----------------------------------------------------------------------------- %% INTERNAL CLIENTHELLO PARSING %%%----------------------------------------------------------------------------- -spec client_hello_key_share(binary() | undefined) -> {ok, binary()} | {error, nquic_error:any_reason()}. client_hello_key_share(undefined) -> {error, key_share_not_found}; client_hello_key_share(Bin) -> find_x25519(Bin). -spec client_hello_quic_params( {ok, nquic_transport:params()} | {error, nquic_error:any_reason()} ) -> {ok, nquic_transport:params()} | {error, nquic_error:any_reason()}. client_hello_quic_params({ok, P}) -> {ok, P}; client_hello_quic_params({error, {tls_alert, _} = Alert}) -> {error, Alert}; client_hello_quic_params({error, Reason}) -> {error, {transport_parameter_error, Reason}}. -spec find_alpn(map()) -> [binary()] | undefined. find_alpn(ExtMap) -> case maps:get(16, ExtMap, undefined) of undefined -> undefined; Bin -> parse_alpn_list(Bin) end. -spec find_cipher_match([atom()], [atom()]) -> atom() | undefined. find_cipher_match([], _) -> undefined; find_cipher_match([C | Rest], ClientCiphers) -> case lists:member(C, ClientCiphers) of true -> C; false -> find_cipher_match(Rest, ClientCiphers) end. -spec find_match([binary()], [binary()]) -> binary() | undefined. find_match([], _) -> undefined; find_match([P | Rest], ServerProtos) -> case lists:member(P, ServerProtos) of true -> P; false -> find_match(Rest, ServerProtos) end. -spec find_x25519(binary()) -> {ok, binary()} | {error, nquic_error:any_reason()}. find_x25519(<>) when byte_size(Rest) == VecLen -> find_x25519_in_vector(Rest); find_x25519(_) -> {error, invalid_key_share_format}. -spec find_x25519_in_vector(binary()) -> {ok, binary()} | {error, nquic_error:any_reason()}. find_x25519_in_vector(<<>>) -> {error, x25519_not_found}; find_x25519_in_vector(<>) -> case Group of 16#001d -> {ok, Key}; _ -> find_x25519_in_vector(Rest) end. -spec parse_alpn_items(binary()) -> [binary()]. parse_alpn_items(<<>>) -> []; parse_alpn_items(<>) -> [Proto | parse_alpn_items(Rest)]. -spec parse_alpn_list(binary()) -> [binary()]. parse_alpn_list(<>) when byte_size(Rest) == Len -> parse_alpn_items(Rest). -spec parse_cipher_suites(binary()) -> [aes_128_gcm | aes_256_gcm | chacha20_poly1305]. parse_cipher_suites(<<>>) -> []; parse_cipher_suites(<>) -> case Suite of <<19, 1>> -> [aes_128_gcm | parse_cipher_suites(Rest)]; <<19, 2>> -> [aes_256_gcm | parse_cipher_suites(Rest)]; <<19, 3>> -> [chacha20_poly1305 | parse_cipher_suites(Rest)]; _ -> parse_cipher_suites(Rest) end. -spec parse_client_hello(binary()) -> {ok, binary(), binary(), nquic_transport:params(), [binary()] | undefined, [aes_128_gcm | aes_256_gcm | chacha20_poly1305], map() | undefined} | {error, nquic_error:any_reason()}. parse_client_hello(<>) when Type =:= 1 -> <<_Version:2/binary, _Random:32/binary, Rest1/binary>> = Body, {SessionID, Rest2} = nquic_tls:parse_vec8(Rest1), {CipherSuitesBin, Rest3} = nquic_tls:parse_vec16(Rest2), {_CompMethods, Rest4} = nquic_tls:parse_vec8(Rest3), <> = Rest4, ExtMap = nquic_tls:parse_extensions_recursive(ExtData), maybe {ok, KeyShare} ?= client_hello_key_share(maps:get(51, ExtMap, undefined)), {ok, TP} ?= client_hello_quic_params(nquic_tls:find_quic_params(ExtMap, client)), ALPN = find_alpn(ExtMap), Ciphers = parse_cipher_suites(CipherSuitesBin), PSKData = nquic_tls:parse_psk_extension(ExtMap), HasDHEKE = nquic_tls:has_psk_dhe_ke_mode(ExtMap), HasEarlyData = maps:is_key(42, ExtMap), PSKInfo = case {PSKData, HasDHEKE} of {{ok, Identities, Binders}, true} -> #{identities => Identities, binders => Binders, early_data => HasEarlyData}; _ -> undefined end, {ok, SessionID, KeyShare, TP, ALPN, Ciphers, PSKInfo} end; parse_client_hello(_) -> {error, invalid_client_hello}. -spec select_alpn([binary()] | undefined, [binary()] | undefined) -> {ok, binary() | undefined} | {error, nquic_error:any_reason()}. select_alpn(_, undefined) -> {ok, undefined}; select_alpn(undefined, _ServerProtos) -> {error, {tls_alert, no_application_protocol}}; select_alpn([], _ServerProtos) -> {error, {tls_alert, no_application_protocol}}; select_alpn(ClientProtos, ServerProtos) -> case find_match(ClientProtos, ServerProtos) of undefined -> {error, {tls_alert, no_application_protocol}}; Match -> {ok, Match} end. -spec select_cipher([aes_128_gcm | aes_256_gcm | chacha20_poly1305]) -> {ok, aes_128_gcm | aes_256_gcm | chacha20_poly1305} | {error, nquic_error:any_reason()}. select_cipher(ClientCiphers) -> select_cipher(ClientCiphers, undefined). -spec select_cipher( [aes_128_gcm | aes_256_gcm | chacha20_poly1305], [aes_128_gcm | aes_256_gcm | chacha20_poly1305] | undefined ) -> {ok, aes_128_gcm | aes_256_gcm | chacha20_poly1305} | {error, nquic_error:any_reason()}. select_cipher(ClientCiphers, undefined) -> select_cipher(ClientCiphers, [aes_128_gcm, aes_256_gcm, chacha20_poly1305]); select_cipher(ClientCiphers, Preferred) when is_list(Preferred), Preferred =/= [] -> case find_cipher_match(Preferred, ClientCiphers) of undefined -> {error, {tls_alert, handshake_failure}}; Cipher -> {ok, Cipher} end. %%%----------------------------------------------------------------------------- %% INTERNAL PSK IDENTITY VALIDATION %%%----------------------------------------------------------------------------- -spec compute_binders_wire_len([binary()]) -> non_neg_integer(). compute_binders_wire_len(Binders) -> ContentLen = lists:foldl(fun(B, Acc) -> Acc + 1 + byte_size(B) end, 0, Binders), erlang:floor(2 + ContentLen). -spec validate_psk_identities( [{binary(), non_neg_integer()}], [binary()], binary(), binary(), atom(), boolean() ) -> {ok, binary(), atom(), boolean(), binary()} | {error, term()}. validate_psk_identities([], _, _, _, _, _) -> {error, no_matching_psk}; validate_psk_identities( [{Identity, _ObfAge} | RestIds], [Binder | RestBinders], ClientHelloBin, StaticKey, NegCipher, HasEarlyData ) -> case nquic_tls:decrypt_ticket(Identity, StaticKey) of {ok, PSK, TicketCipher} -> case TicketCipher =:= NegCipher of false -> validate_psk_identities( RestIds, RestBinders, ClientHelloBin, StaticKey, NegCipher, HasEarlyData ); true -> Hash = nquic_keys:cipher_to_hash(NegCipher), HashLen = nquic_tls:hash_length(Hash), BindersLen = compute_binders_wire_len( [Binder | RestBinders] ), PartialCH = nquic_tls:extract_partial_client_hello( ClientHelloBin, BindersLen ), case nquic_tls:verify_psk_binder(PSK, PartialCH, Binder, Hash, HashLen) of ok -> {ok, PSK, TicketCipher, HasEarlyData, Identity}; {error, _} -> {error, binder_verification_failed} end end; {error, _} -> validate_psk_identities( RestIds, RestBinders, ClientHelloBin, StaticKey, NegCipher, HasEarlyData ) end; validate_psk_identities(_, _, _, _, _, _) -> {error, psk_identity_binder_mismatch}.