%%%-------------------------------------------------------------------
%%% @doc macula-net address derivation: pubkey -> IPv6.
%%%
%%% Per the macula-net spec (PLAN_MACULA_NET.md §3.1):
%%%
%%%
%%% addr = 0xfd | blake3(realm_master_pubkey)[0:40 bits]
%%% | blake3(identity_pubkey)[0:80 bits]
%%%
%%%
%%% Uses {@link macula_blake3_nif:hash/1} for the BLAKE3 primitive — no
%%% new NIF; reuses the SDK's existing crypto layer.
%%%
%%% == Realm-scoped identity ==
%%%
%%% An "identity" here is a realm-scoped Ed25519 keypair. A user/daemon
%%% in N realms holds N realm-scoped keypairs and gets N addresses (one
%%% per realm, all unlinkable at L3). See spec §3.6 for the full model.
%%% @end
%%%-------------------------------------------------------------------
-module(macula_address).
-export([
derive/2,
format/1,
derive_and_format/2
]).
-export_type([
pubkey/0,
ipv6_address/0,
ipv6_text/0
]).
-type pubkey() :: <<_:256>>. %% 32-byte Ed25519 pubkey
-type ipv6_address() :: <<_:128>>. %% 16-byte IPv6
-type ipv6_text() :: binary(). %% RFC 5952 lowercase
%% =============================================================================
%% Public API
%% =============================================================================
%% @doc Derive a macula-net IPv6 address from a realm + identity keypair.
-spec derive(RealmMasterPubkey :: pubkey(), IdentityPubkey :: pubkey()) ->
ipv6_address().
derive(RealmMasterPubkey, IdentityPubkey)
when is_binary(RealmMasterPubkey), byte_size(RealmMasterPubkey) =:= 32,
is_binary(IdentityPubkey), byte_size(IdentityPubkey) =:= 32 ->
<> =
macula_blake3_nif:hash(RealmMasterPubkey),
<> =
macula_blake3_nif:hash(IdentityPubkey),
<<16#fd, RealmHashHead/binary, IdHashHead/binary>>.
%% @doc Render a 16-byte IPv6 binary as RFC 5952 lowercase text.
-spec format(ipv6_address()) -> ipv6_text().
format(<>) ->
Groups = [A, B, C, D, E, F, G, H],
iolist_to_binary(format_rfc5952(Groups)).
%% @doc Convenience: derive + format in one call.
-spec derive_and_format(pubkey(), pubkey()) -> ipv6_text().
derive_and_format(RealmMasterPubkey, IdentityPubkey) ->
format(derive(RealmMasterPubkey, IdentityPubkey)).
%% =============================================================================
%% RFC 5952 formatter
%% =============================================================================
%% Find the longest run of consecutive zero groups (length >= 2) and
%% replace it with '::'. The first zero of any tie wins per RFC 5952 §4.2.3.
format_rfc5952(Groups) ->
{Start, Len} = longest_zero_run(Groups),
case Len >= 2 of
true -> format_with_compression(Groups, Start, Len);
false -> hex_groups_joined(Groups, ":")
end.
format_with_compression(Groups, Start, Len) ->
{Head, Tail0} = lists:split(Start, Groups),
{_, Tail} = lists:split(Len, Tail0),
HeadStr = case Head of
[] -> "";
_ -> hex_groups_joined(Head, ":")
end,
TailStr = case Tail of
[] -> "";
_ -> hex_groups_joined(Tail, ":")
end,
[HeadStr, "::", TailStr].
hex_groups_joined(Groups, Sep) ->
lists:join(Sep, [io_lib:format("~.16b", [G]) || G <- Groups]).
%% Returns {StartIndex, RunLength} of the longest run of zeros.
longest_zero_run(Groups) ->
longest_zero_run(Groups, 0, 0, 0, 0, 0).
longest_zero_run([], _Idx, _CurStart, _CurLen, BestStart, BestLen) ->
{BestStart, BestLen};
longest_zero_run([0 | T], Idx, CurStart0, CurLen, BestStart, BestLen) ->
{CurStart, NewCurLen} =
case CurLen of
0 -> {Idx, 1};
_ -> {CurStart0, CurLen + 1}
end,
case NewCurLen > BestLen of
true -> longest_zero_run(T, Idx + 1, CurStart, NewCurLen, CurStart, NewCurLen);
false -> longest_zero_run(T, Idx + 1, CurStart, NewCurLen, BestStart, BestLen)
end;
longest_zero_run([_ | T], Idx, _CurStart, _CurLen, BestStart, BestLen) ->
longest_zero_run(T, Idx + 1, 0, 0, BestStart, BestLen).