%%%-------------------------------------------------------------------
%%% @doc Resolve a macula-net IPv6 address to its hosting station's
%%% QUIC endpoint via the DHT.
%%%
%%% Phase 2 (PLAN_MACULA_NET_PHASE2.md ยง4.2). Two DHT lookups:
%%%
%%%
%%% - `address_pubkey_map' record keyed by
%%% `sha256("address_pubkey_map" || addr)' โ returns the station
%%% pubkey that owns the address.
%%% - `station_endpoint' record keyed by
%%% `sha256("station_endpoint" || pubkey)' โ returns
%%% `{quic_port, host_advertised}'.
%%%
%%%
%%% Both records are signature-verified. The redirect record is also
%%% address-bound: `derive_address(realm, record.key) == addr' MUST
%%% hold, otherwise the answer is rejected. This makes the cheapest
%%% spoof (sign a record claiming someone else's address) detectable
%%% without consulting any external trust authority โ the address
%%% derivation IS the proof of ownership.
%%%
%%% The DHT is decoupled via a `find_fn' callback. Production wires
%%% `fun(Key) -> macula:find_record(Client, Key) end'; tests pass a
%%% capture function.
%%% @end
%%%-------------------------------------------------------------------
-module(macula_resolve_address).
-export([resolve/3]).
-export_type([find_fn/0, endpoint/0, error_reason/0]).
-type find_fn() ::
fun((Key :: <<_:256>>) ->
{ok, macula_record:record()} | {error, not_found | term()}).
-type endpoint() :: #{
station_pubkey := <<_:256>>,
quic_port := 1..65535,
host_advertised := [binary()],
alpn := binary() | undefined,
expires_at := pos_integer(),
%% Phase 3: when set, the resolved address is hosted by the
%% station above on behalf of `daemon_pubkey'. Senders don't need
%% to act on this; it's diagnostic.
hosted_daemon => <<_:256>>
}.
-type error_reason() ::
not_found
| bad_signature
| bad_address_binding
| bad_delegation
| delegation_expired
| malformed_record
| term().
%% =============================================================================
%% Public API
%% =============================================================================
%% @doc Resolve a macula-net address. `Realm' is the 32-byte realm
%% pubkey used for address derivation; the binding check rejects any
%% redirect whose signer doesn't derive to `Addr' under that realm.
-spec resolve(Addr :: <<_:128>>,
Realm :: <<_:256>>,
find_fn()) -> {ok, endpoint()} | {error, error_reason()}.
resolve(Addr, Realm, FindFn)
when is_binary(Addr), byte_size(Addr) =:= 16,
is_binary(Realm), byte_size(Realm) =:= 32,
is_function(FindFn, 1) ->
%% Try station-owned first (Phase 2). On `not_found', fall back
%% to hosted (Phase 3). The two namespaces are disjoint, so a
%% legitimate address has at most one redirect; a misconfigured
%% one with both would silently take the station path here.
%% Phase 4 hardening: detect + flag the conflict.
T0 = erlang:monotonic_time(microsecond),
Result = redirect_lookup(FindFn(redirect_key(Addr)), Addr, Realm, FindFn),
emit_resolve_telemetry(Result, T0),
Result.
emit_resolve_telemetry({ok, _Endpoint}, T0) ->
Latency = erlang:monotonic_time(microsecond) - T0,
telemetry:execute([macula, net, resolve, complete],
#{latency_us => Latency},
#{outcome => <<"hit">>});
emit_resolve_telemetry({error, not_found}, T0) ->
Latency = erlang:monotonic_time(microsecond) - T0,
telemetry:execute([macula, net, resolve, complete],
#{latency_us => Latency},
#{outcome => <<"miss">>});
emit_resolve_telemetry({error, _Other}, T0) ->
Latency = erlang:monotonic_time(microsecond) - T0,
telemetry:execute([macula, net, resolve, complete],
#{latency_us => Latency},
#{outcome => <<"error">>}).
%% =============================================================================
%% Internals
%% =============================================================================
redirect_key(Addr) ->
crypto:hash(sha256, <<"address_pubkey_map", Addr/binary>>).
hosted_redirect_key(Addr) ->
crypto:hash(sha256, <<"hosted_address_map", Addr/binary>>).
endpoint_key(Pubkey) ->
crypto:hash(sha256, <<"station_endpoint", Pubkey/binary>>).
%% Step 1: redirect lookup result.
%% On `not_found' we fall through to the hosted-redirect path (Phase 3).
%% Any other error short-circuits.
redirect_lookup({error, not_found}, Addr, Realm, FindFn) ->
hosted_redirect_lookup(FindFn(hosted_redirect_key(Addr)),
Addr, Realm, FindFn);
redirect_lookup({error, _} = E, _Addr, _Realm, _FindFn) ->
E;
redirect_lookup({ok, RedirRecord}, Addr, Realm, FindFn) ->
verify_redirect(macula_record:verify(RedirRecord), Addr, Realm, FindFn).
%% Phase 3 hosted-redirect lookup. Same shape, different verification.
hosted_redirect_lookup({error, _} = E, _Addr, _Realm, _FindFn) ->
E;
hosted_redirect_lookup({ok, HRec}, Addr, Realm, FindFn) ->
verify_hosted_redirect(macula_record:verify(HRec), Addr, Realm, FindFn).
verify_redirect({error, _}, _Addr, _Realm, _FindFn) ->
{error, bad_signature};
verify_redirect({ok, RedirRecord}, Addr, Realm, FindFn) ->
bind_redirect(redirect_addr(RedirRecord), RedirRecord, Addr, Realm, FindFn).
redirect_addr(#{type := 16#13, payload := P}) ->
maps:get({text, <<"addr">>}, P, undefined);
redirect_addr(_) ->
undefined.
%% Address-binding check: the address claimed in the payload must
%% match `Addr', AND `derive_address(Realm, signer_key)' must also
%% match. Both checks together close the spoof surface.
bind_redirect(undefined, _R, _Addr, _Realm, _FindFn) ->
{error, malformed_record};
bind_redirect(ClaimedAddr, _R, Addr, _Realm, _FindFn)
when ClaimedAddr =/= Addr ->
{error, bad_address_binding};
bind_redirect(Addr, RedirRecord, Addr, Realm, FindFn) ->
PubKey = macula_record:key(RedirRecord),
Derived = macula_address:derive(Realm, PubKey),
bind_check(Derived, Addr, PubKey, FindFn).
bind_check(Addr, Addr, PubKey, FindFn) ->
endpoint_lookup(FindFn(endpoint_key(PubKey)), PubKey);
bind_check(_Derived, _Addr, _PubKey, _FindFn) ->
{error, bad_address_binding}.
%% Step 2: endpoint lookup result.
endpoint_lookup({error, _} = E, _PubKey) ->
E;
endpoint_lookup({ok, EndpointRecord}, PubKey) ->
verify_endpoint(macula_record:verify(EndpointRecord), PubKey).
verify_endpoint({error, _}, _PubKey) ->
{error, bad_signature};
verify_endpoint({ok, EndpointRecord}, PubKey) ->
%% The endpoint record's key MUST be the same pubkey we looked up.
%% A signed-but-impostor record would fail this check.
record_owner_check(macula_record:key(EndpointRecord), PubKey,
EndpointRecord).
record_owner_check(PubKey, PubKey, EndpointRecord) ->
extract_endpoint(EndpointRecord, PubKey);
record_owner_check(_Other, _PubKey, _EndpointRecord) ->
{error, bad_address_binding}.
extract_endpoint(#{type := 16#12, payload := P, expires_at := X}, PubKey) ->
extract_endpoint_payload(P, PubKey, X, undefined);
extract_endpoint(_Other, _PubKey) ->
{error, malformed_record}.
%% Same extraction with an optional `hosted_daemon' tag for Phase 3.
extract_endpoint_payload(P, PubKey, X, HostedDaemon) ->
endpoint_with_port(maps:get({text, <<"quic_port">>}, P, undefined),
P, PubKey, X, HostedDaemon).
endpoint_with_port(Port, P, PubKey, X, HostedDaemon)
when is_integer(Port), Port >= 1, Port =< 65535 ->
Base = #{station_pubkey => PubKey,
quic_port => Port,
host_advertised => endpoint_hosts(maps:get({text, <<"host_advertised">>}, P, [])),
alpn => endpoint_alpn(maps:get({text, <<"alpn">>}, P, undefined)),
expires_at => X},
{ok, attach_hosted_daemon(Base, HostedDaemon)};
endpoint_with_port(_, _P, _PubKey, _X, _HostedDaemon) ->
{error, malformed_record}.
endpoint_hosts(Bins) when is_list(Bins) -> Bins;
endpoint_hosts(_) -> [].
endpoint_alpn({text, A}) -> A;
endpoint_alpn(A) when is_binary(A) -> A;
endpoint_alpn(_) -> undefined.
attach_hosted_daemon(Map, undefined) -> Map;
attach_hosted_daemon(Map, DaemonPk) when is_binary(DaemonPk) ->
Map#{hosted_daemon => DaemonPk}.
%% =============================================================================
%% Phase 3: hosted-redirect verification + delegation chain check
%% =============================================================================
verify_hosted_redirect({error, _}, _Addr, _Realm, _FindFn) ->
{error, bad_signature};
verify_hosted_redirect({ok, HRec}, Addr, Realm, FindFn) ->
HostPk = macula_record:key(HRec),
Payload = macula_record:payload(HRec),
ClaimAddr = maps:get({text, <<"addr">>}, Payload, undefined),
DaemonPk = maps:get({text, <<"daemon">>}, Payload, undefined),
DelMap = maps:get({text, <<"delegation">>}, Payload, undefined),
chain_check(ClaimAddr, DaemonPk, DelMap, HRec, HostPk, Addr, Realm, FindFn).
chain_check(undefined, _, _, _, _, _, _, _) -> {error, malformed_record};
chain_check(_, undefined, _, _, _, _, _, _) -> {error, malformed_record};
chain_check(_, _, undefined, _, _, _, _, _) -> {error, malformed_record};
chain_check(ClaimAddr, _, _, _, _, Addr, _, _) when ClaimAddr =/= Addr ->
{error, bad_address_binding};
chain_check(Addr, DaemonPk, DelMap, HRec, HostPk, Addr, Realm, FindFn) ->
%% derive_address must bind to the daemon, not the host.
Derived = macula_address:derive(Realm, DaemonPk),
derived_check(Derived, Addr, DelMap, DaemonPk, HRec, HostPk, Realm, FindFn).
derived_check(Addr, Addr, DelMap, DaemonPk, HRec, HostPk, Realm, FindFn) ->
Delegation = parse_delegation(DelMap),
delegation_field_check(Delegation, DaemonPk, HostPk, Realm, HRec, FindFn);
derived_check(_Derived, _Addr, _, _, _, _, _, _) ->
{error, bad_address_binding}.
delegation_field_check(undefined, _, _, _, _, _) ->
{error, malformed_record};
delegation_field_check(#{daemon_pubkey := DaemonPk,
host_pubkey := HostPk,
realm_pubkey := Realm} = Delegation,
DaemonPk, HostPk, Realm, HRec, FindFn) ->
delegation_time_check(Delegation, DaemonPk, HRec, FindFn);
delegation_field_check(_OtherDel, _DaemonPk, _HostPk, _Realm, _HRec, _FindFn) ->
{error, bad_delegation}.
delegation_time_check(#{not_after_ms := NotAfter} = Delegation, DaemonPk, HRec, FindFn) ->
Now = erlang:system_time(millisecond),
delegation_time_result(Now < NotAfter, Delegation, DaemonPk, HRec, FindFn).
delegation_time_result(false, _Delegation, _DaemonPk, _HRec, _FindFn) ->
{error, delegation_expired};
delegation_time_result(true, Delegation, DaemonPk, HRec, FindFn) ->
case macula_record:verify_host_delegation(Delegation) of
{ok, _} -> hosted_endpoint_lookup(HRec, DaemonPk, FindFn);
{error, _} -> {error, bad_delegation}
end.
hosted_endpoint_lookup(HRec, DaemonPk, FindFn) ->
HostPk = macula_record:key(HRec),
finish_hosted_endpoint(FindFn(endpoint_key(HostPk)), HostPk, DaemonPk).
finish_hosted_endpoint({error, _} = E, _HostPk, _DaemonPk) -> E;
finish_hosted_endpoint({ok, ERec}, HostPk, DaemonPk) ->
hosted_endpoint_verified(macula_record:verify(ERec), ERec, HostPk, DaemonPk).
hosted_endpoint_verified({ok, _}, ERec, HostPk, DaemonPk) ->
hosted_endpoint_owned(macula_record:key(ERec), ERec, HostPk, DaemonPk);
hosted_endpoint_verified({error, _}, _ERec, _HostPk, _DaemonPk) ->
{error, bad_signature}.
hosted_endpoint_owned(HostPk, ERec, HostPk, DaemonPk) ->
extract_endpoint_payload(macula_record:payload(ERec),
HostPk,
macula_record:expires_at(ERec),
DaemonPk);
hosted_endpoint_owned(_Other, _ERec, _HostPk, _DaemonPk) ->
{error, bad_address_binding}.
%% Pull the embedded delegation back out of the CBOR map representation.
parse_delegation(#{ {text, <<"d">>} := DaemonPk,
{text, <<"h">>} := HostPk,
{text, <<"r">>} := Realm,
{text, <<"nb">>} := NB,
{text, <<"na">>} := NA,
{text, <<"s">>} := Sig })
when byte_size(DaemonPk) =:= 32, byte_size(HostPk) =:= 32,
byte_size(Realm) =:= 32, byte_size(Sig) =:= 64,
is_integer(NB), is_integer(NA) ->
#{daemon_pubkey => DaemonPk,
host_pubkey => HostPk,
realm_pubkey => Realm,
not_before_ms => NB,
not_after_ms => NA,
daemon_sig => Sig};
parse_delegation(_Other) ->
undefined.