-module(envelope). -export([validate_shape/1, get_field/2, canonical_bytes/1, verify_signature/2]). %% Activity envelope per design §3.1. %% %% Erlang maps (#{...}) are not supported by this port, so envelopes %% are represented as property lists of {atom_key, value} pairs. This %% port's binary syntax also can't carry string literals; values that %% would naturally be binaries in real Erlang are kept as atoms or %% integer-segment binaries in the test corpus. %% %% Required fields: id, type, actor, published, signature. %% The signature value is itself a property list with key_id, %% algorithm, value. %% %% validate_shape/1 returns ok | {error, Reason}. Reasons: %% not_a_proplist %% {missing_field, FieldName} %% {bad_signature, BadSigReason} %% %% get_field/2 returns {ok, Value} | not_found. validate_shape(Env) when is_list(Env) -> case check_required([id, type, actor, published, signature], Env) of ok -> validate_signature_shape(Env); Err -> Err end; validate_shape(_) -> {error, not_a_proplist}. get_field(_, []) -> not_found; get_field(K, [{K, V} | _]) -> {ok, V}; get_field(K, [_ | Rest]) -> get_field(K, Rest). check_required([], _) -> ok; check_required([F | Rest], Env) -> case get_field(F, Env) of {ok, _} -> check_required(Rest, Env); not_found -> {error, {missing_field, F}} end. validate_signature_shape(Env) -> {ok, Sig} = get_field(signature, Env), case is_list(Sig) of true -> case check_required([key_id, algorithm, value], Sig) of ok -> ok; {error, {missing_field, F}} -> {error, {bad_signature, {missing_field, F}}} end; false -> {error, {bad_signature, not_a_proplist}} end. %% canonical_bytes/1 — the byte string the signature covers. %% %% Real fed-sx will use dag-cbor over a JSON-LD-canonicalised form %% (design §3.2). For milestone 1 we stand in for that with the host %% BIF `cid:to_string/1`, which produces a CIDv1 over the deterministic %% textual form of the term. Two prior steps make this work: %% 1. The signature pair is stripped (sig covers everything except %% itself). %% 2. The top-level property list is sorted by key so field order in %% the source envelope is not load-bearing. %% %% The result is an Erlang binary suitable as the sig-cover input. canonical_bytes(Env) when is_list(Env) -> Stripped = strip_signature(Env), Sorted = sort_pairs(Stripped), cid:to_string(Sorted). strip_signature([]) -> []; strip_signature([{signature, _} | Rest]) -> strip_signature(Rest); strip_signature([P | Rest]) -> [P | strip_signature(Rest)]. sort_pairs([]) -> []; sort_pairs([H | T]) -> insert_pair(H, sort_pairs(T)). insert_pair(P, []) -> [P]; insert_pair({K1, V1}, [{K2, V2} | Rest]) -> case K1 < K2 of true -> [{K1, V1}, {K2, V2} | Rest]; false -> [{K2, V2} | insert_pair({K1, V1}, Rest)] end. %% verify_signature/2 — time-aware sig verification per design §9.6. %% %% Activity carries a `signature` proplist with `key_id`, `algorithm`, %% `value`. ActorState carries `public_keys` — a list of key proplists %% with `id`, `created`, optionally `superseded_at`, and `value` (the %% key material). %% %% A key is active at time T iff `created =< T` AND %% (no `superseded_at` OR T < `superseded_at`). Verification picks the %% first matching active key whose `id == signature.key_id` at the %% activity's `published` timestamp, then recomputes the MAC %% `crypto:hash(sha256, <>)` %% and compares it to `signature.value`. %% %% Returns ok | {error, Reason}. Reasons: %% no_signature | no_key_id | no_published | no_keys | %% no_active_key | bad_signature %% %% Real RSA-SHA256 / Ed25519 verification is deferred to milestone 2: %% Phase 8 only ships `crypto:hash/2`, so we stand in with an HMAC-shaped %% MAC that exercises the same key-lookup and canonical-bytes pipeline. verify_signature(Activity, ActorState) -> case get_field(signature, Activity) of not_found -> {error, no_signature}; {ok, Sig} -> case get_field(key_id, Sig) of not_found -> {error, no_key_id}; {ok, KeyId} -> case get_field(published, Activity) of not_found -> {error, no_published}; {ok, Published} -> verify_with_keys(Activity, Sig, KeyId, Published, ActorState) end end end. verify_with_keys(Activity, Sig, KeyId, Published, ActorState) -> case get_field(public_keys, ActorState) of not_found -> {error, no_keys}; {ok, Keys} -> case find_active_key(KeyId, Published, Keys) of not_found -> {error, no_active_key}; {ok, Key} -> verify_mac(Activity, Sig, Key) end end. find_active_key(_, _, []) -> not_found; find_active_key(KeyId, Now, [Key | Rest]) -> case is_matching_active_key(Key, KeyId, Now) of true -> {ok, Key}; false -> find_active_key(KeyId, Now, Rest) end. is_matching_active_key(Key, WantId, Now) -> case get_field(id, Key) of {ok, WantId} -> is_active_at(Key, Now); _ -> false end. is_active_at(Key, Now) -> case get_field(created, Key) of not_found -> false; {ok, Created} -> case Now >= Created of false -> false; true -> case get_field(superseded_at, Key) of not_found -> true; {ok, SupAt} -> Now < SupAt end end end. verify_mac(Activity, Sig, Key) -> case get_field(value, Sig) of not_found -> {error, bad_signature}; {ok, SigValue} -> case get_field(value, Key) of not_found -> {error, bad_signature}; {ok, KeyMat} -> Bytes = canonical_bytes(Activity), Computed = crypto:hash(sha256, <>), case SigValue =:= Computed of true -> ok; false -> {error, bad_signature} end end end.