Packages

macula

3.16.0
7.1.0 7.0.0 6.0.0 5.2.2 5.2.1 5.2.0 5.1.0 5.0.0 4.8.0 4.7.1 4.7.0 4.6.0 4.5.0 4.4.10 4.4.9 4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.1 4.3.0 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.1 4.1.0 4.0.0 3.16.0 3.15.3 3.15.2 3.15.1 3.14.0 3.13.0 3.12.1 3.12.0 3.11.1 3.11.0 3.10.3 3.10.2 3.10.1 3.9.0 3.8.0 3.7.0 3.5.0 3.4.0 3.3.0 3.2.0 3.1.0 3.0.0 2.1.1 2.1.0 2.0.0 1.5.2 1.5.1 1.4.30 1.4.29 1.4.28 1.4.27 1.4.26 1.4.25 1.4.24 1.4.23 1.4.22 1.4.21 1.4.20 1.4.19 1.4.18 1.4.17 1.4.16 1.4.15 1.4.14 1.4.13 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.1 1.3.0 1.2.0 1.1.0 1.0.10 1.0.9 1.0.8 1.0.7 1.0.6 1.0.5 1.0.4 1.0.3 1.0.2 1.0.1 1.0.0 0.48.6 0.48.5 0.48.4 0.48.3 0.48.2 0.48.1 0.48.0 0.47.1 0.47.0 0.46.3 0.46.1 0.46.0 0.45.3 0.45.2 0.45.1 0.45.0 0.44.2 0.44.1 0.44.0 0.43.3 0.43.2 0.43.1 0.43.0 0.42.9 0.42.8 0.42.7 0.42.6 0.42.5 0.42.4 0.42.3 0.42.2 0.42.1 0.42.0 0.41.1 0.41.0 0.40.1 0.40.0 0.39.9 0.39.8 0.39.7 0.39.6 0.39.5 0.39.4 0.39.3 0.39.2 0.39.1 0.39.0 0.38.8 0.38.7 0.38.6 0.38.5 0.38.4 0.38.3 0.38.2 0.38.1 0.38.0 0.37.7 0.37.6 0.37.5 0.37.4 0.37.3 0.37.2 0.37.1 0.37.0 0.36.6 0.36.5 0.36.4 0.36.3 0.36.2 0.36.1 0.36.0 0.35.4 0.35.3 0.35.2 0.35.1 0.35.0 0.34.1 0.34.0 0.33.1 0.33.0 0.32.5 0.32.4 0.32.3 0.32.2 0.32.1 0.32.0 0.31.9 0.31.8 0.31.7 0.31.6 0.31.5 0.31.4 0.31.3 0.31.2 0.31.1 0.31.0 0.30.10 0.30.9 0.30.8 0.30.7 0.30.6 0.30.5 0.30.4 0.30.3 0.30.2 0.30.1 0.30.0 0.29.0 0.28.3 0.28.2 0.28.1 0.28.0 0.27.1 0.27.0 0.26.1 0.26.0 0.25.6 0.25.5 0.25.4 0.25.3 0.25.2 0.25.1 0.25.0 0.24.6 0.24.5 0.24.4 0.24.3 0.24.2 0.24.1 0.24.0 0.23.3 0.23.2 0.23.1 0.23.0 0.22.12 0.22.11 0.22.10 0.22.9 0.22.8 0.22.7 0.22.6 0.22.5 0.22.4 0.22.3 0.22.2 0.22.1 0.22.0 0.21.7 0.21.6 0.21.5 0.21.4 0.21.2 0.21.1 0.21.0 0.20.25 0.20.24 0.20.23 0.20.22 0.20.21 0.20.20 0.20.19 0.20.18 0.20.17 0.20.16 0.20.15 0.20.14 0.20.13 0.20.12 0.20.11 0.20.10 0.20.9 0.20.8 0.20.7 0.20.6 0.20.5 0.20.3 0.20.2 0.20.1 0.20.0 0.19.2 0.19.1 0.19.0 0.18.1 0.18.0 0.17.4 0.17.3 0.17.2 0.17.1 0.17.0 0.16.6 0.16.5 0.16.4 0.16.3 0.16.2 0.16.1 0.16.0 0.15.1 0.15.0 0.14.3 0.14.2 0.14.1 0.14.0 0.12.6 0.12.5 0.12.3 0.11.3 0.10.2 0.10.1 0.10.0 0.9.2 0.9.1 0.9.0 0.8.25 0.8.24 0.8.23 0.8.22 0.8.21 0.8.20 0.8.19 0.8.18 0.8.17 0.8.16 0.8.15 0.8.14 0.8.13 0.8.12 0.8.11 0.8.10 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.3 0.8.2 0.8.1 0.8.0 0.7.30 0.7.29 0.7.28 0.7.27 0.7.26 0.7.25 0.7.24 0.7.23 0.7.22 0.7.21 0.7.20 0.7.19 0.7.18 0.7.17 0.7.16 0.7.15 0.7.14 0.7.13 0.7.12 0.7.11 0.7.10 0.7.9 0.7.8 0.7.7 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.7 0.6.6 0.6.5 0.6.4 0.6.3 0.6.2 0.6.1 0.6.0 0.5.0 0.4.4 0.4.3 0.4.2 0.4.1 0.4.0 0.3.4 0.3.3 0.3.2 0.3.1

Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise

Current section

Files

Jump to
macula src resolve_address macula_resolve_address.erl
Raw

src/resolve_address/macula_resolve_address.erl

%%%-------------------------------------------------------------------
%%% @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:
%%%
%%% <ol>
%%% <li>`address_pubkey_map' record keyed by
%%% `sha256("address_pubkey_map" || addr)' — returns the station
%%% pubkey that owns the address.</li>
%%% <li>`station_endpoint' record keyed by
%%% `sha256("station_endpoint" || pubkey)' — returns
%%% `{quic_port, host_advertised}'.</li>
%%% </ol>
%%%
%%% 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) ->
case maps:get({text, <<"quic_port">>}, P, undefined) of
Port when is_integer(Port), Port >= 1, Port =< 65535 ->
Hosts = case maps:get({text, <<"host_advertised">>}, P, []) of
Bins when is_list(Bins) -> Bins;
_ -> []
end,
Alpn = case maps:get({text, <<"alpn">>}, P, undefined) of
{text, A} -> A;
A when is_binary(A) -> A;
_ -> undefined
end,
Base = #{station_pubkey => PubKey,
quic_port => Port,
host_advertised => Hosts,
alpn => Alpn,
expires_at => X},
{ok, attach_hosted_daemon(Base, HostedDaemon)};
_ ->
{error, malformed_record}
end.
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) ->
case macula_record:verify(ERec) of
{ok, _} ->
case macula_record:key(ERec) of
HostPk ->
extract_endpoint_payload(macula_record:payload(ERec),
HostPk,
macula_record:expires_at(ERec),
DaemonPk);
_Other ->
{error, bad_address_binding}
end;
{error, _} ->
{error, bad_signature}
end.
%% 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.