Skip to main content

src/nquic_path.erl

-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.