-module(nquic_path).
-moduledoc """
Path validation for QUIC connection migration (RFC 9000 Section 9).
Pure-functional module operating on `#path_state{}`. Handles peer address
change detection, PATH_CHALLENGE/PATH_RESPONSE tracking, and anti-amplification
limits on new paths. All state lives in the connection's `#conn_state{}` record;
this module provides the logic without owning any processes.
""".
-include("nquic_conn.hrl").
-include("nquic_frame.hrl").
-include("nquic_path.hrl").
-export([
detect_peer_change/2,
get_previous_peer/1,
initiate_validation/2,
is_nat_rebinding/1,
is_validated/1,
is_validating/1,
new/1,
on_response/2,
on_timeout/1
]).
-export_type([state/0]).
-type state() :: #path_state{}.
%%%-----------------------------------------------------------------------------
%% API
%%%-----------------------------------------------------------------------------
-doc """
Detect if the source address of a received packet differs from the current peer.
Returns `unchanged` when the addresses match or `{changed, UpdatedPathState}`
when the source differs. A previously-undefined peer is treated as a first-time
set rather than a change.
""".
-spec detect_peer_change(state(), nquic_socket:sockaddr()) ->
unchanged | {changed, state()}.
detect_peer_change(#path_state{peer = undefined} = PS, Source) ->
{changed, PS#path_state{peer = Source, path_validated = true}};
detect_peer_change(#path_state{peer = Current} = PS, Source) ->
case same_address(Current, Source) of
true ->
unchanged;
false ->
{changed, PS#path_state{
previous_peer = Current,
peer = Source,
path_validated = false,
new_path_bytes_sent = 0,
new_path_bytes_received = 0
}}
end.
-doc "Return the previous peer address (for fallback on validation failure).".
-spec get_previous_peer(state()) -> nquic_socket:sockaddr() | undefined.
get_previous_peer(#path_state{previous_peer = P}) -> P.
-doc """
Initiate path validation by generating an 8-byte challenge.
Stores the challenge data and candidate address. Returns the updated path state
and a PATH_CHALLENGE frame to send to the new address.
""".
-spec initiate_validation(state(), nquic_socket:sockaddr()) ->
{state(), #path_challenge{}}.
initiate_validation(PS, NewPeer) ->
ChallengeData = crypto:strong_rand_bytes(8),
Now = erlang:monotonic_time(microsecond),
NewPS = PS#path_state{
previous_peer = PS#path_state.peer,
peer = NewPeer,
pending_challenge = ChallengeData,
challenge_sent_time = Now,
challenge_retries = 0,
path_validated = false,
new_path_bytes_sent = 0,
new_path_bytes_received = 0
},
Frame = #path_challenge{data = ChallengeData},
{NewPS, Frame}.
-doc """
True if the migration is a likely NAT rebinding: same IP, different port.
When true, callers should skip CC/RTT reset since the underlying network path
has not changed (RFC 9000 Section 9.3.1).
""".
-spec is_nat_rebinding(state()) -> boolean().
is_nat_rebinding(#path_state{peer = undefined}) -> false;
is_nat_rebinding(#path_state{previous_peer = undefined}) -> false;
is_nat_rebinding(#path_state{peer = #{addr := Addr}, previous_peer = #{addr := Addr}}) -> true;
is_nat_rebinding(#path_state{}) -> false.
%%%-----------------------------------------------------------------------------
%% INTERNAL
%%%-----------------------------------------------------------------------------
-doc "True if the current path has been validated.".
-spec is_validated(state()) -> boolean().
is_validated(#path_state{path_validated = V}) -> V.
-doc "True if a path validation is currently in progress.".
-spec is_validating(state()) -> boolean().
is_validating(#path_state{pending_challenge = undefined}) -> false;
is_validating(#path_state{}) -> true.
-doc "Create a new path state with the given initial peer address.".
-spec new(nquic_socket:sockaddr() | undefined) -> state().
new(Peer) ->
#path_state{
peer = Peer,
previous_peer = undefined,
pending_challenge = undefined,
challenge_sent_time = 0,
challenge_retries = 0,
path_validated = true,
new_path_bytes_sent = 0,
new_path_bytes_received = 0
}.
-doc """
Process a PATH_RESPONSE. If the response data matches the pending challenge,
the path is validated. Otherwise the state is unchanged.
""".
-spec on_response(state(), binary()) ->
{validated, state()} | {mismatch, state()}.
on_response(#path_state{pending_challenge = undefined} = PS, _ResponseData) ->
{mismatch, PS};
on_response(#path_state{pending_challenge = Expected} = PS, ResponseData) ->
case ResponseData of
Expected ->
{validated, PS#path_state{
pending_challenge = undefined,
path_validated = true,
previous_peer = undefined
}};
_ ->
{mismatch, PS}
end.
-doc """
Handle path validation timeout. Retries up to 3 times with a fresh challenge.
After 3 retries, returns `failed` so the caller can revert to the previous peer.
""".
-spec on_timeout(state()) ->
{retry, state(), #path_challenge{}} | {failed, state()}.
on_timeout(#path_state{challenge_retries = Retries} = PS) when Retries >= 3 ->
FailedPS = PS#path_state{
peer = PS#path_state.previous_peer,
previous_peer = undefined,
pending_challenge = undefined,
path_validated = true,
new_path_bytes_sent = 0,
new_path_bytes_received = 0
},
{failed, FailedPS};
on_timeout(PS) ->
NewChallenge = crypto:strong_rand_bytes(8),
Now = erlang:monotonic_time(microsecond),
NewPS = PS#path_state{
pending_challenge = NewChallenge,
challenge_sent_time = Now,
challenge_retries = PS#path_state.challenge_retries + 1
},
Frame = #path_challenge{data = NewChallenge},
{retry, NewPS, Frame}.
-spec same_address(nquic_socket:sockaddr(), nquic_socket:sockaddr()) -> boolean().
same_address(#{addr := Addr, port := Port}, #{addr := Addr, port := Port}) ->
true;
same_address(_, _) ->
false.