%%% -*- erlang -*- %%% %%% QUIC Session Ticket Management %%% RFC 9001 Section 4.6 - 0-RTT Support %%% %%% Copyright (c) 2024-2026 Benoit Chesneau %%% Apache License 2.0 %%% %%% @doc Session ticket storage and PSK derivation for 0-RTT. %%% %%% This module handles: %%% - Storing and retrieving NewSessionTicket messages %%% - Deriving PSK from resumption_master_secret %%% - Managing ticket lifetimes and early data limits -module(quic_ticket). -export([ %% Ticket storage new_store/0, store_ticket/3, lookup_ticket/2, clear_ticket/2, clear_expired/1, %% PSK derivation derive_resumption_secret/4, derive_psk/2, %% Ticket parsing parse_new_session_ticket/1, %% Ticket creation (server-side) create_ticket/5, build_new_session_ticket/1 ]). -include("quic.hrl"). %%==================================================================== %% Types %%==================================================================== -type ticket_store() :: #{binary() => #session_ticket{}}. -type session_ticket() :: #session_ticket{}. -export_type([ticket_store/0, session_ticket/0]). %%==================================================================== %% Ticket Storage API %%==================================================================== %% @doc Create a new empty ticket store. -spec new_store() -> ticket_store(). new_store() -> #{}. %% @doc Store a session ticket for a server. %% The ticket is indexed by server name. -spec store_ticket(binary(), session_ticket(), ticket_store()) -> ticket_store(). store_ticket(ServerName, Ticket, Store) -> maps:put(ServerName, Ticket, Store). %% @doc Look up a ticket for a server. %% Returns {ok, Ticket} if found and not expired, error otherwise. -spec lookup_ticket(binary(), ticket_store()) -> {ok, session_ticket()} | error. lookup_ticket(ServerName, Store) -> case maps:find(ServerName, Store) of {ok, #session_ticket{received_at = ReceivedAt, lifetime = Lifetime} = Ticket} -> Now = erlang:system_time(second), Age = Now - ReceivedAt, case Age =< Lifetime of true -> {ok, Ticket}; false -> error % Expired end; error -> error end. %% @doc Remove a ticket for a server. -spec clear_ticket(binary(), ticket_store()) -> ticket_store(). clear_ticket(ServerName, Store) -> maps:remove(ServerName, Store). %% @doc Remove all expired tickets from the store. -spec clear_expired(ticket_store()) -> ticket_store(). clear_expired(Store) -> Now = erlang:system_time(second), maps:filter( fun(_ServerName, #session_ticket{received_at = ReceivedAt, lifetime = Lifetime}) -> (Now - ReceivedAt) =< Lifetime end, Store). %%==================================================================== %% PSK Derivation %%==================================================================== %% @doc Derive the resumption_master_secret from the master secret. %% RFC 8446 Section 7.1: %% resumption_master_secret = Derive-Secret(Master Secret, "res master", ClientHello..client Finished) -spec derive_resumption_secret(atom(), binary(), binary(), binary()) -> binary(). derive_resumption_secret(Cipher, MasterSecret, TranscriptHash, _ClientFinished) -> Hash = quic_crypto:cipher_to_hash(Cipher), quic_crypto:derive_secret(Hash, MasterSecret, <<"res master">>, TranscriptHash). %% @doc Derive PSK from resumption_master_secret and ticket nonce. %% RFC 8446 Section 4.6.1: %% PSK = HKDF-Expand-Label(resumption_master_secret, "resumption", ticket_nonce, Hash.length) -spec derive_psk(binary(), #session_ticket{}) -> binary(). derive_psk(ResumptionSecret, #session_ticket{nonce = Nonce, cipher = Cipher}) -> Hash = quic_crypto:cipher_to_hash(Cipher), HashLen = quic_crypto:hash_len(Hash), quic_hkdf:expand_label(Hash, ResumptionSecret, <<"resumption">>, Nonce, HashLen). %%==================================================================== %% Ticket Parsing %%==================================================================== %% @doc Parse a NewSessionTicket message from the server. %% RFC 8446 Section 4.6.1: %% struct { %% uint32 ticket_lifetime; %% uint32 ticket_age_add; %% opaque ticket_nonce<0..255>; %% opaque ticket<1..2^16-1>; %% Extension extensions<0..2^16-2>; %% } NewSessionTicket; -spec parse_new_session_ticket(binary()) -> {ok, #{lifetime := non_neg_integer(), age_add := non_neg_integer(), nonce := binary(), ticket := binary(), max_early_data := non_neg_integer()}} | {error, term()}. parse_new_session_ticket(<>) -> case Rest of <> when byte_size(Rest1) >= TicketLen -> <> = Rest1, case byte_size(Extensions) >= ExtLen of true -> <> = Extensions, MaxEarlyData = parse_early_data_extension(ExtData), {ok, #{ lifetime => Lifetime, age_add => AgeAdd, nonce => Nonce, ticket => Ticket, max_early_data => MaxEarlyData }}; false -> {error, invalid_extensions} end; _ -> {error, invalid_ticket} end; parse_new_session_ticket(_) -> {error, invalid_format}. %% Parse early_data extension from NewSessionTicket %% Extension type 42 (0x002a) = early_data parse_early_data_extension(<<>>) -> 0; parse_early_data_extension(<<16#00, 16#2a, 4:16, MaxEarlyData:32, _Rest/binary>>) -> MaxEarlyData; parse_early_data_extension(<<_Type:16, Len:16, _Data:Len/binary, Rest/binary>>) -> parse_early_data_extension(Rest); parse_early_data_extension(_) -> 0. %%==================================================================== %% Ticket Creation (for server use) %%==================================================================== %% @doc Create a session ticket from connection state. %% This is used by the server to issue tickets to clients. -spec create_ticket(binary(), binary(), non_neg_integer(), atom(), binary() | undefined) -> #session_ticket{}. create_ticket(ServerName, ResumptionSecret, MaxEarlyData, Cipher, ALPN) -> %% Use crypto:strong_rand_bytes for age_add to prevent replay attacks <> = crypto:strong_rand_bytes(4), #session_ticket{ server_name = ServerName, ticket = crypto:strong_rand_bytes(32), lifetime = 86400, % 24 hours age_add = AgeAdd, nonce = crypto:strong_rand_bytes(8), resumption_secret = ResumptionSecret, max_early_data = MaxEarlyData, received_at = erlang:system_time(second), cipher = Cipher, alpn = ALPN }. %% @doc Build a NewSessionTicket message. -spec build_new_session_ticket(#session_ticket{}) -> binary(). build_new_session_ticket(#session_ticket{ lifetime = Lifetime, age_add = AgeAdd, nonce = Nonce, ticket = Ticket, max_early_data = MaxEarlyData }) -> NonceLen = byte_size(Nonce), TicketLen = byte_size(Ticket), %% Build early_data extension if max_early_data > 0 Extensions = case MaxEarlyData of 0 -> <<>>; _ -> <<16#00, 16#2a, 4:16, MaxEarlyData:32>> % early_data extension end, ExtLen = byte_size(Extensions), <>.