%% @doc PKARR-compatible signed records (envelope + node_record + tombstone). %% %% A record is a map carrying: %% %% %% On the wire records are CBOR maps with single-letter keys %% (`t', `k', `v', `c', `x', `p', `s') per `Part 6 §9'. %% Signatures are Ed25519 over `"macula-v2-record\0" ++ canonical_cbor(unsigned)'. -module(macula_record). -export([ %% Constructors node_record/3, node_record/4, realm_directory/3, realm_directory/4, realm_stations/2, realm_stations/3, realm_member_endorsement/2, realm_member_endorsement/3, procedure_advertisement/3, procedure_advertisement/4, content_announcement/3, content_announcement/4, foundation_seed_list/2, foundation_seed_list/3, foundation_parameter/3, foundation_parameter/4, foundation_realm_trust_list/2, foundation_realm_trust_list/3, foundation_t3_attestation/3, foundation_t3_attestation/4, tombstone/3, tombstone/4, %% Sign / verify sign/2, verify/1, %% Owner refresh — new version + timestamps, re-sign (Part 3 §11) refresh/2, %% Wire codec encode/1, decode/1, %% Accessors type/1, key/1, version/1, created_at/1, expires_at/1, payload/1, signature/1, %% DHT storage-key derivation (Part 3 §3.3) storage_key/1 ]). -export_type([ record/0, type_tag/0, version/0, node_record_opts/0, realm_directory_opts/0, realm_station_entry/0, realm_stations_opts/0, realm_member_endorsement_opts/0, procedure_advertisement_opts/0, content_announcement_opts/0, foundation_seed/0, foundation_seed_list_opts/0, foundation_parameter_opts/0, foundation_realm_trust_list_opts/0, foundation_t3_attestation_opts/0, tombstone_opts/0 ]). %% Domain separation prefix for record signatures (Part 6 §10.2). -define(SIG_DOMAIN, "macula-v2-record\0"). -define(TYPE_NODE_RECORD, 16#01). -define(TYPE_REALM_DIRECTORY, 16#03). -define(TYPE_REALM_STATIONS, 16#04). -define(TYPE_REALM_MEMBER_ENDORSEMENT, 16#05). -define(TYPE_PROCEDURE_ADVERTISEMENT, 16#06). -define(TYPE_TOMBSTONE, 16#0C). -define(TYPE_FOUNDATION_SEED_LIST, 16#0D). -define(TYPE_FOUNDATION_PARAMETER, 16#0E). -define(TYPE_FOUNDATION_REALM_TRUST_LIST, 16#0F). -define(TYPE_FOUNDATION_T3_ATTESTATION, 16#10). -define(TYPE_CONTENT_ANNOUNCEMENT, 16#11). %% Domain separation for derived storage keys (Part 3 §3.3). -define(STORAGE_DOMAIN_STATION_SET, <<"station_set">>). -define(STORAGE_DOMAIN_MEMBER_ENDORSE, <<"member_endorsement">>). -define(STORAGE_DOMAIN_FOUND_SEED, <<"foundation_seed_list">>). -define(STORAGE_DOMAIN_FOUND_PARAM, <<"foundation_parameter">>). -define(STORAGE_DOMAIN_FOUND_TRUST, <<"foundation_realm_trust_list">>). -define(STORAGE_DOMAIN_FOUND_ATTEST, <<"foundation_t3_attestation">>). %% Default endorsement validity window (30 days). -define(DEFAULT_ENDORSEMENT_TTL_MS, 30 * 24 * 60 * 60 * 1000). %% Default record TTL (Part 4 §11): expire 48h after creation. -define(DEFAULT_TTL_MS, 48 * 60 * 60 * 1000). -type type_tag() :: 1..16#FF. -type version() :: <<_:128>>. -type record() :: #{ type := type_tag(), key := <<_:256>>, version := version(), created_at := pos_integer(), expires_at := pos_integer(), payload := map(), signature => <<_:512>> }. -type node_record_opts() :: #{ station_id => macula_identity:pubkey(), caps_hint => binary(), display_name => binary(), ttl_ms => pos_integer(), %% Optional self-described location + reach metadata. %% Subscribers (e.g. realm dashboards) read these from the %% record payload to render maps without polling a side-channel. %% All four geo fields travel together — supplying any without %% the others is allowed but generally reduces utility. hostname => binary(), endpoint => binary(), city => binary(), country => binary(), lat => float() | integer(), lng => float() | integer() }. -type realm_directory_opts() :: #{ policy_url => binary(), ttl_ms => pos_integer() }. -type realm_station_entry() :: #{ station_id := macula_identity:pubkey(), roles := [binary()] }. -type realm_stations_opts() :: #{ttl_ms => pos_integer()}. -type realm_member_endorsement_opts() :: #{ valid_from => pos_integer(), valid_until => pos_integer(), ttl_ms => pos_integer() }. -type procedure_advertisement_opts() :: #{ session_token_hint => binary(), rate_limit_qps => non_neg_integer(), max_concurrency => non_neg_integer(), ttl_ms => pos_integer() }. -type content_announcement_opts() :: #{ name => binary(), size => non_neg_integer(), chunk_count => non_neg_integer(), ttl_ms => pos_integer() }. -type foundation_seed() :: #{ node_id := macula_identity:pubkey(), addresses := [map()], tier := 3 | 4 }. -type foundation_seed_list_opts() :: #{ valid_from => pos_integer(), valid_until => pos_integer(), ttl_ms => pos_integer() }. -type foundation_parameter_value() :: integer() | binary() | [integer() | binary()] | boolean(). -type foundation_parameter_opts() :: #{ valid_from => pos_integer(), valid_until => pos_integer(), prior_version => <<_:128>> | undefined, ttl_ms => pos_integer() }. -type foundation_realm_trust_list_opts() :: #{ realms_revoked => [macula_identity:pubkey()], valid_until => pos_integer(), ttl_ms => pos_integer() }. -type foundation_t3_attestation_opts() :: #{ valid_until => pos_integer(), notes => binary(), ttl_ms => pos_integer() }. -type tombstone_opts() :: #{ detail => binary(), ttl_ms => pos_integer() }. %%------------------------------------------------------------------ %% Constructors — node_record (Part 6 §9.2) %%------------------------------------------------------------------ -spec node_record(macula_identity:pubkey(), [macula_identity:pubkey()], non_neg_integer()) -> record(). node_record(NodeId, Realms, Capabilities) -> node_record(NodeId, Realms, Capabilities, #{}). -spec node_record(macula_identity:pubkey(), [macula_identity:pubkey()], non_neg_integer(), node_record_opts()) -> record(). node_record(NodeId, Realms, Capabilities, Opts) when is_binary(NodeId), byte_size(NodeId) =:= 32, is_list(Realms), is_integer(Capabilities), Capabilities >= 0 -> StationId = maps:get(station_id, Opts, NodeId), Payload = node_payload(NodeId, StationId, Realms, Capabilities, Opts), envelope(?TYPE_NODE_RECORD, NodeId, Payload, Opts). %%------------------------------------------------------------------ %% Constructors — realm_directory (Part 6 §9.4) %% %% A realm's "meta" record — name, admin key, optional policy URL. %% Owning key is the RealmId; storage key is also the RealmId. %%------------------------------------------------------------------ -spec realm_directory(macula_identity:pubkey(), binary(), macula_identity:pubkey()) -> record(). realm_directory(RealmId, Name, AdminKey) -> realm_directory(RealmId, Name, AdminKey, #{}). -spec realm_directory(macula_identity:pubkey(), binary(), macula_identity:pubkey(), realm_directory_opts()) -> record(). realm_directory(RealmId, Name, AdminKey, Opts) when is_binary(RealmId), byte_size(RealmId) =:= 32, is_binary(Name), is_binary(AdminKey), byte_size(AdminKey) =:= 32 -> Payload = realm_directory_payload(RealmId, Name, AdminKey, Opts), envelope(?TYPE_REALM_DIRECTORY, RealmId, Payload, Opts). %%------------------------------------------------------------------ %% Constructors — realm_stations (Part 6 §9.5) %% %% Stored at storage key SHA-256("station_set" || RealmId) so a %% fresh station can look up all stations serving a realm without %% knowing the realm admin's NodeId. Envelope key remains the %% RealmId (admin signs the record). %%------------------------------------------------------------------ -spec realm_stations(macula_identity:pubkey(), [realm_station_entry()]) -> record(). realm_stations(RealmId, Entries) -> realm_stations(RealmId, Entries, #{}). -spec realm_stations(macula_identity:pubkey(), [realm_station_entry()], realm_stations_opts()) -> record(). realm_stations(RealmId, Entries, Opts) when is_binary(RealmId), byte_size(RealmId) =:= 32, is_list(Entries) -> Payload = realm_stations_payload(RealmId, Entries), envelope(?TYPE_REALM_STATIONS, RealmId, Payload, Opts). %%------------------------------------------------------------------ %% Constructors — realm_member_endorsement (Part 6 §9.6) %% %% Admin-signed statement that `MemberNode' is authorised to act as %% a member of the realm. Stored at a derived storage key so a new %% station joining the realm can look it up by `{realm, node}' pair %% without knowing the record version. Envelope key is the RealmId %% (admin signs). %%------------------------------------------------------------------ -spec realm_member_endorsement(macula_identity:pubkey(), #{realm := macula_identity:pubkey(), member_node := macula_identity:pubkey(), roles := [binary()]}) -> record(). realm_member_endorsement(RealmId, Spec) -> realm_member_endorsement(RealmId, Spec, #{}). -spec realm_member_endorsement(macula_identity:pubkey(), #{realm := macula_identity:pubkey(), member_node := macula_identity:pubkey(), roles := [binary()]}, realm_member_endorsement_opts()) -> record(). realm_member_endorsement(RealmId, #{realm := RealmId, member_node := Member, roles := Roles} = _Spec, Opts) when is_binary(RealmId), byte_size(RealmId) =:= 32, is_binary(Member), byte_size(Member) =:= 32, is_list(Roles) -> NowMs = erlang:system_time(millisecond), ValidFrom = maps:get(valid_from, Opts, NowMs), ValidUntil = maps:get(valid_until, Opts, NowMs + ?DEFAULT_ENDORSEMENT_TTL_MS), Payload = realm_member_endorsement_payload(RealmId, Member, Roles, ValidFrom, ValidUntil), envelope(?TYPE_REALM_MEMBER_ENDORSEMENT, RealmId, Payload, Opts). %%------------------------------------------------------------------ %% Constructors — procedure_advertisement (Part 6 §9.7) %% %% Stored at storage key SHA-256(procedure_uri). Envelope key is %% the advertiser's NodeId (advertiser signs the record). %%------------------------------------------------------------------ -spec procedure_advertisement(macula_identity:pubkey(), binary(), macula_identity:pubkey()) -> record(). procedure_advertisement(AdvertiserNode, ProcedureUri, ServingStation) -> procedure_advertisement(AdvertiserNode, ProcedureUri, ServingStation, #{}). -spec procedure_advertisement(macula_identity:pubkey(), binary(), macula_identity:pubkey(), procedure_advertisement_opts()) -> record(). procedure_advertisement(AdvertiserNode, ProcedureUri, ServingStation, Opts) when is_binary(AdvertiserNode), byte_size(AdvertiserNode) =:= 32, is_binary(ProcedureUri), is_binary(ServingStation), byte_size(ServingStation) =:= 32 -> Payload = procedure_advertisement_payload(AdvertiserNode, ProcedureUri, ServingStation, Opts), envelope(?TYPE_PROCEDURE_ADVERTISEMENT, AdvertiserNode, Payload, Opts). %%------------------------------------------------------------------ %% Constructors — content_announcement (Part 6 §9.x) %% %% Signed announcement that `AnnouncerNode' is hosting the content %% identified by `MCID' (34-byte Macula Content IDentifier) and %% reachable at `Endpoint'. Optional metadata fields carry the %% manifest's display name, byte size, and chunk count so locators %% can prioritise without fetching the manifest first. %%------------------------------------------------------------------ -spec content_announcement(macula_identity:pubkey(), binary(), binary()) -> record(). content_announcement(AnnouncerNode, MCID, Endpoint) -> content_announcement(AnnouncerNode, MCID, Endpoint, #{}). -spec content_announcement(macula_identity:pubkey(), binary(), binary(), content_announcement_opts()) -> record(). content_announcement(AnnouncerNode, MCID, Endpoint, Opts) when is_binary(AnnouncerNode), byte_size(AnnouncerNode) =:= 32, is_binary(MCID), byte_size(MCID) =:= 34, is_binary(Endpoint) -> Payload = content_announcement_payload(AnnouncerNode, MCID, Endpoint, Opts), envelope(?TYPE_CONTENT_ANNOUNCEMENT, AnnouncerNode, Payload, Opts). %%------------------------------------------------------------------ %% Constructors — foundation_seed_list (Part 6 §9.14) %% %% FROST-Ed25519 threshold-signed. `FoundationKey' is the aggregated %% pubkey (32 bytes) — same as any Ed25519 key on the wire. Embedded %% in station firmware via `macula_foundation'. %%------------------------------------------------------------------ -spec foundation_seed_list(macula_identity:pubkey(), [foundation_seed()]) -> record(). foundation_seed_list(FoundationKey, Seeds) -> foundation_seed_list(FoundationKey, Seeds, #{}). -spec foundation_seed_list(macula_identity:pubkey(), [foundation_seed()], foundation_seed_list_opts()) -> record(). foundation_seed_list(FoundationKey, Seeds, Opts) when is_binary(FoundationKey), byte_size(FoundationKey) =:= 32, is_list(Seeds) -> NowMs = erlang:system_time(millisecond), TtlMs = maps:get(ttl_ms, Opts, ?DEFAULT_TTL_MS), ValidFrom = maps:get(valid_from, Opts, NowMs), ValidUntil = maps:get(valid_until, Opts, NowMs + TtlMs), Envelope = envelope(?TYPE_FOUNDATION_SEED_LIST, FoundationKey, #{}, Opts), Payload = foundation_seed_list_payload( maps:get(version, Envelope), ValidFrom, ValidUntil, Seeds), Envelope#{payload => Payload}. %%------------------------------------------------------------------ %% Constructors — foundation_parameter (Part 6 §9.15) %%------------------------------------------------------------------ -spec foundation_parameter(macula_identity:pubkey(), binary(), foundation_parameter_value()) -> record(). foundation_parameter(FoundationKey, Name, Value) -> foundation_parameter(FoundationKey, Name, Value, #{}). -spec foundation_parameter(macula_identity:pubkey(), binary(), foundation_parameter_value(), foundation_parameter_opts()) -> record(). foundation_parameter(FoundationKey, Name, Value, Opts) when is_binary(FoundationKey), byte_size(FoundationKey) =:= 32, is_binary(Name) -> NowMs = erlang:system_time(millisecond), TtlMs = maps:get(ttl_ms, Opts, ?DEFAULT_TTL_MS), ValidFrom = maps:get(valid_from, Opts, NowMs), ValidUntil = maps:get(valid_until, Opts, NowMs + TtlMs), PriorV = maps:get(prior_version, Opts, undefined), Envelope = envelope(?TYPE_FOUNDATION_PARAMETER, FoundationKey, #{}, Opts), Payload = foundation_parameter_payload( Name, Value, maps:get(version, Envelope), ValidFrom, ValidUntil, PriorV), Envelope#{payload => Payload}. %%------------------------------------------------------------------ %% Constructors — foundation_realm_trust_list (Part 6 §9.16) %%------------------------------------------------------------------ -spec foundation_realm_trust_list(macula_identity:pubkey(), [macula_identity:pubkey()]) -> record(). foundation_realm_trust_list(FoundationKey, Trusted) -> foundation_realm_trust_list(FoundationKey, Trusted, #{}). -spec foundation_realm_trust_list(macula_identity:pubkey(), [macula_identity:pubkey()], foundation_realm_trust_list_opts()) -> record(). foundation_realm_trust_list(FoundationKey, Trusted, Opts) when is_binary(FoundationKey), byte_size(FoundationKey) =:= 32, is_list(Trusted) -> NowMs = erlang:system_time(millisecond), TtlMs = maps:get(ttl_ms, Opts, ?DEFAULT_TTL_MS), ValidUntil = maps:get(valid_until, Opts, NowMs + TtlMs), Revoked = maps:get(realms_revoked, Opts, []), Envelope = envelope(?TYPE_FOUNDATION_REALM_TRUST_LIST, FoundationKey, #{}, Opts), Payload = foundation_realm_trust_list_payload( Trusted, Revoked, maps:get(version, Envelope), ValidUntil), Envelope#{payload => Payload}. %%------------------------------------------------------------------ %% Constructors — foundation_t3_attestation (Part 6 §9.17) %%------------------------------------------------------------------ -spec foundation_t3_attestation(macula_identity:pubkey(), macula_identity:pubkey(), pos_integer()) -> record(). foundation_t3_attestation(FoundationKey, StationId, AuditDate) -> foundation_t3_attestation(FoundationKey, StationId, AuditDate, #{}). -spec foundation_t3_attestation(macula_identity:pubkey(), macula_identity:pubkey(), pos_integer(), foundation_t3_attestation_opts()) -> record(). foundation_t3_attestation(FoundationKey, StationId, AuditDate, Opts) when is_binary(FoundationKey), byte_size(FoundationKey) =:= 32, is_binary(StationId), byte_size(StationId) =:= 32, is_integer(AuditDate), AuditDate > 0 -> NowMs = erlang:system_time(millisecond), TtlMs = maps:get(ttl_ms, Opts, ?DEFAULT_TTL_MS), ValidUntil = maps:get(valid_until, Opts, NowMs + TtlMs), Notes = maps:get(notes, Opts, undefined), Envelope = envelope(?TYPE_FOUNDATION_T3_ATTESTATION, FoundationKey, #{}, Opts), Payload = foundation_t3_attestation_payload( StationId, AuditDate, ValidUntil, Notes), Envelope#{payload => Payload}. %%------------------------------------------------------------------ %% Constructors — tombstone (Part 6 §9.13) %%------------------------------------------------------------------ -spec tombstone(macula_identity:pubkey(), type_tag(), atom()) -> record(). tombstone(SupersededKey, SupersededType, Reason) -> tombstone(SupersededKey, SupersededType, Reason, #{}). -spec tombstone(macula_identity:pubkey(), type_tag(), atom(), tombstone_opts()) -> record(). tombstone(SupersededKey, SupersededType, Reason, Opts) when is_binary(SupersededKey), byte_size(SupersededKey) =:= 32, is_integer(SupersededType), SupersededType > 0, is_atom(Reason) -> NowMs = erlang:system_time(millisecond), Detail = maps:get(detail, Opts, undefined), Payload = tombstone_payload(SupersededKey, SupersededType, NowMs, Reason, Detail), envelope(?TYPE_TOMBSTONE, SupersededKey, Payload, Opts). %%------------------------------------------------------------------ %% Sign / verify %%------------------------------------------------------------------ -spec sign(record(), macula_identity:key_pair() | macula_identity:privkey()) -> record(). sign(Record, Identity) -> Bytes = canonical_unsigned(Record), Sig = macula_identity:sign([?SIG_DOMAIN, Bytes], Identity), Record#{signature => Sig}. -spec verify(record()) -> {ok, record()} | {error, term()}. verify(#{signature := Sig, key := Pub} = Record) when is_binary(Sig), byte_size(Sig) =:= 64, is_binary(Pub), byte_size(Pub) =:= 32 -> Bytes = canonical_unsigned(Record), verify_signature(macula_identity:verify([?SIG_DOMAIN, Bytes], Sig, Pub), Record); verify(_) -> {error, bad_record}. verify_signature(true, Record) -> expiry_check(Record); verify_signature(false, _Record) -> {error, signature_invalid}. %% @doc Rebuild a record with a fresh UUIDv7 version and a new %% `created_at' / `expires_at' pair (preserving the original TTL), %% then re-sign with `Identity'. Used by the owner's tRepublish %% loop (Part 3 §11) to keep a record alive across churn without %% changing its type, key, or payload. %% %% The new signature replaces any prior signature; callers can %% safely pass an already-signed record — the prior signature is %% stripped before re-signing. -spec refresh(record(), macula_identity:key_pair() | macula_identity:privkey()) -> record(). refresh(Record, Identity) -> NowMs = erlang:system_time(millisecond), TtlMs = maps:get(expires_at, Record) - maps:get(created_at, Record), Fresh = #{ type => maps:get(type, Record), key => maps:get(key, Record), version => macula_record_uuid:v7(NowMs), created_at => NowMs, expires_at => NowMs + TtlMs, payload => maps:get(payload, Record) }, sign(Fresh, Identity). expiry_check(#{expires_at := X} = Record) -> case erlang:system_time(millisecond) >= X of true -> {error, expired}; false -> {ok, Record} end. %%------------------------------------------------------------------ %% Wire codec %%------------------------------------------------------------------ -spec encode(record()) -> binary(). encode(#{signature := Sig} = Record) when is_binary(Sig), byte_size(Sig) =:= 64 -> macula_record_cbor:encode(to_envelope_map(Record)). -spec decode(binary()) -> {ok, record()} | {error, term()}. decode(Bin) when is_binary(Bin) -> decode_value(macula_record_cbor:decode(Bin)). decode_value(Map) when is_map(Map) -> G = fun(Key) -> maps:get({text, Key}, Map, undefined) end, parse_envelope(G(<<"t">>), G(<<"k">>), G(<<"v">>), G(<<"c">>), G(<<"x">>), G(<<"p">>), G(<<"s">>)); decode_value(_Other) -> {error, bad_record}. parse_envelope(T, K, V, C, X, P, S) when is_integer(T), T > 0, is_binary(K), byte_size(K) =:= 32, is_binary(V), byte_size(V) =:= 16, is_integer(C), C > 0, is_integer(X), X > 0, is_map(P), is_binary(S), byte_size(S) =:= 64 -> {ok, #{type => T, key => K, version => V, created_at => C, expires_at => X, payload => P, signature => S}}; parse_envelope(_, _, _, _, _, _, undefined) -> {error, missing_signature}; parse_envelope(_, _, _, _, _, _, _) -> {error, bad_record}. %%------------------------------------------------------------------ %% Accessors %%------------------------------------------------------------------ type(#{type := T}) -> T. key(#{key := K}) -> K. version(#{version := V}) -> V. created_at(#{created_at := C}) -> C. expires_at(#{expires_at := X}) -> X. payload(#{payload := P}) -> P. signature(#{signature := S}) -> S. %%------------------------------------------------------------------ %% Internals %%------------------------------------------------------------------ envelope(Type, Key, Payload, Opts) -> NowMs = erlang:system_time(millisecond), TtlMs = maps:get(ttl_ms, Opts, ?DEFAULT_TTL_MS), #{ type => Type, key => Key, version => macula_record_uuid:v7(NowMs), created_at => NowMs, expires_at => NowMs + TtlMs, payload => Payload }. node_payload(NodeId, StationId, Realms, Caps, Opts) -> Base = #{ {text, <<"node_id">>} => NodeId, {text, <<"station_id">>} => StationId, {text, <<"realms">>} => Realms, {text, <<"capabilities">>} => Caps }, M1 = with_text(Base, <<"caps_hint">>, maps:get(caps_hint, Opts, undefined)), M2 = with_text(M1, <<"display_name">>, maps:get(display_name, Opts, undefined)), M3 = with_text(M2, <<"hostname">>, maps:get(hostname, Opts, undefined)), M4 = with_text(M3, <<"endpoint">>, maps:get(endpoint, Opts, undefined)), M5 = with_text(M4, <<"city">>, maps:get(city, Opts, undefined)), M6 = with_text(M5, <<"country">>, maps:get(country, Opts, undefined)), M7 = with_geo(M6, <<"lat">>, maps:get(lat, Opts, undefined)), with_geo(M7, <<"lng">>, maps:get(lng, Opts, undefined)). with_text(Map, _Key, undefined) -> Map; with_text(Map, Key, Bin) when is_binary(Bin) -> Map#{ {text, Key} => {text, Bin} }. %% Geo coordinates travel as CBOR text strings — float canonicalisation %% is fragile across language implementations, while a fixed-decimals %% text rendering is stable. Subscribers parse with `binary_to_float/1' %% (or `binary_to_integer/1' if the source was an integer like 0). with_geo(Map, _Key, undefined) -> Map; with_geo(Map, Key, V) when is_float(V) -> Map#{ {text, Key} => {text, float_to_binary(V, [{decimals, 6}, compact])} }; with_geo(Map, Key, V) when is_integer(V) -> Map#{ {text, Key} => {text, integer_to_binary(V)} }. tombstone_payload(SupKey, SupType, ReplacedAt, Reason, Detail) -> Base = #{ {text, <<"superseded_key">>} => SupKey, {text, <<"superseded_type">>} => SupType, {text, <<"replaced_at">>} => ReplacedAt, {text, <<"reason">>} => {text, atom_to_binary(Reason, utf8)} }, Base#{ {text, <<"detail">>} => detail_value(Detail) }. detail_value(undefined) -> null; detail_value(Bin) when is_binary(Bin) -> {text, Bin}. realm_directory_payload(RealmId, Name, AdminKey, Opts) -> Base = #{ {text, <<"realm_id">>} => RealmId, {text, <<"name">>} => {text, Name}, {text, <<"admin_key">>} => AdminKey, {text, <<"created_at">>} => erlang:system_time(millisecond) }, with_text(Base, <<"policy_url">>, maps:get(policy_url, Opts, undefined)). realm_stations_payload(RealmId, Entries) -> #{ {text, <<"realm_id">>} => RealmId, {text, <<"stations">>} => [realm_station_entry(E) || E <- Entries] }. realm_station_entry(#{station_id := SId, roles := Roles}) when is_binary(SId), byte_size(SId) =:= 32, is_list(Roles) -> #{ {text, <<"station_id">>} => SId, {text, <<"roles">>} => [{text, R} || R <- Roles, is_binary(R)] }. realm_member_endorsement_payload(RealmId, Member, Roles, ValidFrom, ValidUntil) -> #{ {text, <<"realm">>} => RealmId, {text, <<"member_node">>} => Member, {text, <<"roles">>} => [{text, R} || R <- Roles, is_binary(R)], {text, <<"valid_from">>} => ValidFrom, {text, <<"valid_until">>} => ValidUntil }. procedure_advertisement_payload(AdvertiserNode, ProcedureUri, ServingStation, Opts) -> Base = #{ {text, <<"procedure_uri">>} => {text, ProcedureUri}, {text, <<"advertiser_node">>} => AdvertiserNode, {text, <<"serving_station">>} => ServingStation }, M1 = with_text(Base, <<"session_token_hint">>, maps:get(session_token_hint, Opts, undefined)), M2 = with_uint(M1, <<"rate_limit_qps">>, maps:get(rate_limit_qps, Opts, undefined)), with_uint(M2, <<"max_concurrency">>, maps:get(max_concurrency, Opts, undefined)). with_uint(Map, _Key, undefined) -> Map; with_uint(Map, Key, N) when is_integer(N), N >= 0 -> Map#{ {text, Key} => N }. content_announcement_payload(AnnouncerNode, MCID, Endpoint, Opts) -> Base = #{ {text, <<"announcer_node">>} => AnnouncerNode, {text, <<"mcid">>} => MCID, {text, <<"endpoint">>} => {text, Endpoint} }, M1 = with_text(Base, <<"name">>, maps:get(name, Opts, undefined)), M2 = with_uint(M1, <<"size">>, maps:get(size, Opts, undefined)), with_uint(M2, <<"chunk_count">>, maps:get(chunk_count, Opts, undefined)). foundation_seed_list_payload(Version, ValidFrom, ValidUntil, Seeds) -> #{ {text, <<"version">>} => Version, {text, <<"valid_from">>} => ValidFrom, {text, <<"valid_until">>} => ValidUntil, {text, <<"seeds">>} => [foundation_seed_entry(S) || S <- Seeds] }. foundation_seed_entry(#{node_id := NodeId, addresses := Addrs, tier := Tier}) when is_binary(NodeId), byte_size(NodeId) =:= 32, is_list(Addrs), (Tier =:= 3 orelse Tier =:= 4) -> #{ {text, <<"node_id">>} => NodeId, {text, <<"addresses">>} => [foundation_address(A) || A <- Addrs], {text, <<"tier">>} => Tier }. foundation_address(#{} = Addr) -> %% ip_address_rec() per Part 6 §9.18 — tolerated as an opaque map %% of text-keyed fields here; caller is responsible for building it. Addr. foundation_parameter_payload(Name, Value, Version, ValidFrom, ValidUntil, PriorV) -> Base = #{ {text, <<"param_name">>} => {text, Name}, {text, <<"param_value">>} => parameter_value(Value), {text, <<"version">>} => Version, {text, <<"valid_from">>} => ValidFrom, {text, <<"valid_until">>} => ValidUntil }, Base#{ {text, <<"prior_version">>} => prior_version_value(PriorV) }. parameter_value(V) when is_integer(V); is_boolean(V) -> V; parameter_value(V) when is_binary(V) -> {text, V}; parameter_value(V) when is_list(V) -> [parameter_value(X) || X <- V]. prior_version_value(undefined) -> null; prior_version_value(V) when is_binary(V), byte_size(V) =:= 16 -> V. foundation_realm_trust_list_payload(Trusted, Revoked, Version, ValidUntil) -> #{ {text, <<"realms_trusted">>} => [R || R <- Trusted, is_binary(R), byte_size(R) =:= 32], {text, <<"realms_revoked">>} => [R || R <- Revoked, is_binary(R), byte_size(R) =:= 32], {text, <<"version">>} => Version, {text, <<"valid_until">>} => ValidUntil }. foundation_t3_attestation_payload(StationId, AuditDate, ValidUntil, Notes) -> Base = #{ {text, <<"station_id">>} => StationId, {text, <<"tier_attested">>} => 3, {text, <<"audit_date">>} => AuditDate, {text, <<"valid_until">>} => ValidUntil }, with_text(Base, <<"notes">>, Notes). %%------------------------------------------------------------------ %% DHT storage-key derivation (Part 3 §3.3) %%------------------------------------------------------------------ -spec storage_key(record()) -> <<_:256>>. storage_key(#{type := Type, key := K}) when Type =:= ?TYPE_NODE_RECORD; Type =:= ?TYPE_REALM_DIRECTORY; Type =:= ?TYPE_TOMBSTONE -> K; storage_key(#{type := ?TYPE_REALM_STATIONS, key := RealmId}) -> crypto:hash(sha256, <>); storage_key(#{type := ?TYPE_REALM_MEMBER_ENDORSEMENT, key := RealmId, payload := P}) -> Member = maps:get({text, <<"member_node">>}, P), crypto:hash(sha256, <>); storage_key(#{type := ?TYPE_PROCEDURE_ADVERTISEMENT, payload := P}) -> {text, Uri} = maps:get({text, <<"procedure_uri">>}, P), crypto:hash(sha256, Uri); storage_key(#{type := ?TYPE_FOUNDATION_SEED_LIST, key := Fk}) -> crypto:hash(sha256, <>); storage_key(#{type := ?TYPE_FOUNDATION_PARAMETER, key := Fk, payload := P}) -> {text, Name} = maps:get({text, <<"param_name">>}, P), crypto:hash(sha256, <>); storage_key(#{type := ?TYPE_FOUNDATION_REALM_TRUST_LIST, key := Fk}) -> crypto:hash(sha256, <>); storage_key(#{type := ?TYPE_FOUNDATION_T3_ATTESTATION, payload := P}) -> Sid = maps:get({text, <<"station_id">>}, P), crypto:hash(sha256, <>). %% Build the envelope CBOR map. Includes signature when present. to_envelope_map(#{type := T, key := K, version := V, created_at := C, expires_at := X, payload := P} = R) -> Base = #{ {text, <<"t">>} => T, {text, <<"k">>} => K, {text, <<"v">>} => V, {text, <<"c">>} => C, {text, <<"x">>} => X, {text, <<"p">>} => P }, add_signature(Base, maps:get(signature, R, undefined)). add_signature(M, undefined) -> M; add_signature(M, Sig) when is_binary(Sig) -> M#{ {text, <<"s">>} => Sig }. %% Canonical CBOR of the record minus its signature — what gets signed/verified. canonical_unsigned(Record) -> macula_record_cbor:encode(to_envelope_map(maps:without([signature], Record))).