Packages

macula

0.43.3
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 macula_ucan_revocation.erl
Raw

src/macula_ucan_revocation.erl

%% @doc UCAN Revocation Module.
%%
%% Manages revocation of UCAN tokens via mesh PubSub gossip. Revocations are
%% stored in an ETS cache and checked during authorization.
%%
%% == Revocation Flow ==
%%
%% 1. Issuer calls `revoke/2` with their DID and the UCAN CID
%% 2. Module broadcasts revocation to `io.macula.system.ucan_revoked` topic
%% 3. All mesh nodes receive via PubSub subscription
%% 4. Each node validates signature (issuer must be UCAN creator)
%% 5. Valid revocations stored in local ETS cache with TTL
%% 6. Authorization checks consult cache via `is_revoked/2`
%%
%% == System Topic ==
%%
%% All revocations are published to:
%%
%% `io.macula.system.ucan_revoked'
%%
%% == Rate Limiting ==
%%
%% Maximum 10 revocations per issuer per minute to prevent abuse.
%%
%% == Cache Auto-Expiry ==
%%
%% Revocation entries expire based on original UCAN expiry time.
%% A cleanup process runs periodically to purge expired entries.
%%
%% @author macula
-module(macula_ucan_revocation).
-behaviour(gen_server).
%% API
-export([
start_link/0,
start_link/1,
stop/0,
stop/1,
revoke/3,
revoke/4,
is_revoked/2,
is_revoked/3,
handle_revocation_message/1,
handle_revocation_message/2,
get_stats/0,
get_stats/1,
clear_cache/0,
clear_cache/1
]).
%% gen_server callbacks
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2
]).
%% Internal exports for testing
-export([
compute_ucan_cid/1,
validate_revocation_signature/1,
check_rate_limit/2
]).
-include_lib("kernel/include/logger.hrl").
%%====================================================================
%% Constants
%%====================================================================
-define(SERVER, ?MODULE).
-define(REVOCATION_TABLE, macula_revocation_cache).
-define(RATE_LIMIT_TABLE, macula_revocation_rate_limit).
-define(SYSTEM_TOPIC, <<"io.macula.system.ucan_revoked">>).
-define(MAX_REVOCATIONS_PER_MINUTE, 10).
-define(RATE_LIMIT_WINDOW_MS, 60000). %% 1 minute
-define(CLEANUP_INTERVAL_MS, 60000). %% 1 minute
%%====================================================================
%% Types
%%====================================================================
-type did() :: binary().
-type ucan_cid() :: binary().
-type ucan_token() :: binary().
-type timestamp() :: non_neg_integer().
-type revocation_msg() :: #{
binary() => binary() | timestamp()
}.
-record(state, {
cleanup_timer :: reference() | undefined,
pubsub_pid :: pid() | undefined,
stats :: #{atom() => non_neg_integer()}
}).
-export_type([did/0, ucan_cid/0, revocation_msg/0]).
%%====================================================================
%% API Functions
%%====================================================================
%% @doc Start the revocation server with default name.
-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
start_link(#{}).
%% @doc Start the revocation server with options.
-spec start_link(Opts :: map()) -> {ok, pid()} | {error, term()}.
start_link(Opts) ->
gen_server:start_link({local, ?SERVER}, ?MODULE, Opts, []).
%% @doc Stop the revocation server (default name).
-spec stop() -> ok.
stop() ->
stop(?SERVER).
%% @doc Stop a specific revocation server.
-spec stop(ServerRef :: atom() | pid()) -> ok.
stop(ServerRef) ->
gen_server:stop(ServerRef).
%% @doc Revoke a UCAN token (uses default server).
%%
%% The issuer must sign the revocation message. The revocation is
%% broadcast to all mesh nodes via PubSub.
%%
%% `UcanToken' is the full UCAN JWT token being revoked.
%% `ExpiresAt' is the original expiry time of the UCAN.
%% `PrivateKey' is used to sign the revocation (Ed25519).
-spec revoke(IssuerDID :: did(), UcanToken :: ucan_token(),
ExpiresAt :: timestamp()) ->
{ok, ucan_cid()} | {error, term()}.
revoke(IssuerDID, UcanToken, ExpiresAt) ->
revoke(?SERVER, IssuerDID, UcanToken, ExpiresAt).
%% @doc Revoke a UCAN token via specific server.
-spec revoke(ServerRef :: atom() | pid(), IssuerDID :: did(),
UcanToken :: ucan_token(), ExpiresAt :: timestamp()) ->
{ok, ucan_cid()} | {error, term()}.
revoke(ServerRef, IssuerDID, UcanToken, ExpiresAt) ->
gen_server:call(ServerRef, {revoke, IssuerDID, UcanToken, ExpiresAt}).
%% @doc Check if a UCAN is revoked (uses default server).
-spec is_revoked(IssuerDID :: did(), UcanCID :: ucan_cid()) -> boolean().
is_revoked(IssuerDID, UcanCID) ->
is_revoked(?SERVER, IssuerDID, UcanCID).
%% @doc Check if a UCAN is revoked via specific server.
-spec is_revoked(ServerRef :: atom() | pid(), IssuerDID :: did(),
UcanCID :: ucan_cid()) -> boolean().
is_revoked(_ServerRef, IssuerDID, UcanCID) ->
%% Direct ETS lookup for performance (no gen_server call needed)
case ets:lookup(?REVOCATION_TABLE, {IssuerDID, UcanCID}) of
[{_, {_RevokedAt, ExpiresAt}}] ->
Now = erlang:system_time(second),
Now < ExpiresAt; %% Only revoked if not yet expired
[] ->
false
end.
%% @doc Handle incoming revocation message from PubSub.
-spec handle_revocation_message(Msg :: revocation_msg()) -> ok | {error, term()}.
handle_revocation_message(Msg) ->
handle_revocation_message(?SERVER, Msg).
%% @doc Handle incoming revocation message via specific server.
-spec handle_revocation_message(ServerRef :: atom() | pid(),
Msg :: revocation_msg()) -> ok | {error, term()}.
handle_revocation_message(ServerRef, Msg) ->
gen_server:call(ServerRef, {handle_revocation, Msg}).
%% @doc Get revocation statistics (uses default server).
-spec get_stats() -> #{atom() => term()}.
get_stats() ->
get_stats(?SERVER).
%% @doc Get revocation statistics via specific server.
-spec get_stats(ServerRef :: atom() | pid()) -> #{atom() => term()}.
get_stats(ServerRef) ->
gen_server:call(ServerRef, get_stats).
%% @doc Clear all revocation cache entries (uses default server).
-spec clear_cache() -> ok.
clear_cache() ->
clear_cache(?SERVER).
%% @doc Clear all revocation cache entries via specific server.
-spec clear_cache(ServerRef :: atom() | pid()) -> ok.
clear_cache(ServerRef) ->
gen_server:call(ServerRef, clear_cache).
%%====================================================================
%% gen_server Callbacks
%%====================================================================
init(Opts) ->
%% Create ETS tables if they don't exist
create_tables(),
%% Start cleanup timer
Timer = erlang:send_after(?CLEANUP_INTERVAL_MS, self(), cleanup_expired),
%% Subscribe to revocation topic if PubSub provided
PubSubPid = maps:get(pubsub_pid, Opts, undefined),
State = #state{
cleanup_timer = Timer,
pubsub_pid = PubSubPid,
stats = #{
revocations_issued => 0,
revocations_received => 0,
revocations_rejected => 0,
rate_limit_hits => 0,
cache_size => 0
}
},
{ok, State}.
handle_call({revoke, IssuerDID, UcanToken, ExpiresAt}, _From, State) ->
case do_revoke(IssuerDID, UcanToken, ExpiresAt, State) of
{ok, CID, State2} ->
{reply, {ok, CID}, State2};
{error, Reason, State2} ->
{reply, {error, Reason}, State2}
end;
handle_call({handle_revocation, Msg}, _From, State) ->
case do_handle_revocation(Msg, State) of
{ok, State2} ->
{reply, ok, State2};
{error, Reason, State2} ->
{reply, {error, Reason}, State2}
end;
handle_call(get_stats, _From, State) ->
CacheSize = ets:info(?REVOCATION_TABLE, size),
Stats = maps:put(cache_size, CacheSize, State#state.stats),
{reply, Stats, State};
handle_call(clear_cache, _From, State) ->
ets:delete_all_objects(?REVOCATION_TABLE),
ets:delete_all_objects(?RATE_LIMIT_TABLE),
Stats = maps:map(fun(_, _) -> 0 end, State#state.stats),
{reply, ok, State#state{stats = Stats}}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(cleanup_expired, State) ->
do_cleanup_expired(),
Timer = erlang:send_after(?CLEANUP_INTERVAL_MS, self(), cleanup_expired),
{noreply, State#state{cleanup_timer = Timer}};
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, #state{cleanup_timer = Timer}) ->
case Timer of
undefined -> ok;
_ -> erlang:cancel_timer(Timer)
end,
ok.
%%====================================================================
%% Internal Functions
%%====================================================================
%% @private Create ETS tables for revocation and rate limiting.
create_tables() ->
case ets:whereis(?REVOCATION_TABLE) of
undefined ->
ets:new(?REVOCATION_TABLE, [
named_table, public, set,
{read_concurrency, true}
]);
_ -> ok
end,
case ets:whereis(?RATE_LIMIT_TABLE) of
undefined ->
ets:new(?RATE_LIMIT_TABLE, [
named_table, public, set
]);
_ -> ok
end,
ok.
%% @private Execute a revocation.
do_revoke(IssuerDID, UcanToken, ExpiresAt, State) ->
%% Check rate limit
case check_rate_limit(IssuerDID, ?MAX_REVOCATIONS_PER_MINUTE) of
ok ->
%% Compute CID of the UCAN
CID = compute_ucan_cid(UcanToken),
Now = erlang:system_time(second),
%% Store in local cache
ets:insert(?REVOCATION_TABLE, {
{IssuerDID, CID},
{Now, ExpiresAt}
}),
%% Increment rate limit counter
increment_rate_limit(IssuerDID),
%% Update stats
Stats = State#state.stats,
Stats2 = maps:update_with(revocations_issued, fun(V) -> V + 1 end, Stats),
%% Broadcast to mesh (if PubSub available)
broadcast_to_mesh(IssuerDID, CID, Now, ExpiresAt, State),
{ok, CID, State#state{stats = Stats2}};
{error, rate_limited} ->
Stats = State#state.stats,
Stats2 = maps:update_with(rate_limit_hits, fun(V) -> V + 1 end, Stats),
{error, rate_limited, State#state{stats = Stats2}}
end.
%% @private Handle an incoming revocation message.
do_handle_revocation(Msg, State) ->
case validate_revocation_message(Msg) of
{ok, IssuerDID, CID, RevokedAt, ExpiresAt} ->
%% Validate signature
case validate_revocation_signature(Msg) of
ok ->
%% Store in cache
ets:insert(?REVOCATION_TABLE, {
{IssuerDID, CID},
{RevokedAt, ExpiresAt}
}),
Stats = State#state.stats,
Stats2 = maps:update_with(revocations_received,
fun(V) -> V + 1 end, Stats),
{ok, State#state{stats = Stats2}};
{error, Reason} ->
?LOG_WARNING("Revocation signature invalid: ~p", [Reason]),
Stats = State#state.stats,
Stats2 = maps:update_with(revocations_rejected,
fun(V) -> V + 1 end, Stats),
{error, invalid_signature, State#state{stats = Stats2}}
end;
{error, Reason} ->
Stats = State#state.stats,
Stats2 = maps:update_with(revocations_rejected, fun(V) -> V + 1 end, Stats),
{error, Reason, State#state{stats = Stats2}}
end.
%% @private Validate revocation message format.
validate_revocation_message(Msg) when is_map(Msg) ->
IssuerDID = maps:get(<<"issuer_did">>, Msg, undefined),
CID = maps:get(<<"ucan_cid">>, Msg, undefined),
RevokedAt = maps:get(<<"revoked_at">>, Msg, undefined),
ExpiresAt = maps:get(<<"expires_at">>, Msg, undefined),
Signature = maps:get(<<"signature">>, Msg, undefined),
case {IssuerDID, CID, RevokedAt, ExpiresAt, Signature} of
{undefined, _, _, _, _} ->
{error, missing_issuer_did};
{_, undefined, _, _, _} ->
{error, missing_ucan_cid};
{_, _, undefined, _, _} ->
{error, missing_revoked_at};
{_, _, _, undefined, _} ->
{error, missing_expires_at};
{_, _, _, _, undefined} ->
{error, missing_signature};
{I, C, R, E, _S} when is_binary(I), is_binary(C),
is_integer(R), is_integer(E) ->
{ok, I, C, R, E};
_ ->
{error, invalid_format}
end;
validate_revocation_message(_) ->
{error, not_a_map}.
%% @private Validate revocation signature.
%% For now, just check signature is present and non-empty.
%% Full Ed25519 verification would require crypto operations.
-spec validate_revocation_signature(Msg :: revocation_msg()) -> ok | {error, term()}.
validate_revocation_signature(Msg) ->
case maps:get(<<"signature">>, Msg, undefined) of
undefined ->
{error, missing_signature};
<<>> ->
{error, empty_signature};
Sig when is_binary(Sig), byte_size(Sig) >= 64 ->
%% Ed25519 signatures are 64 bytes
%% For now, just validate format. Full crypto would need:
%% - Extract public key from issuer DID
%% - Verify signature over message content
ok;
_ ->
{error, invalid_signature_format}
end.
%% @private Compute CID (content identifier) for a UCAN token.
%% Uses SHA-256 hash encoded as base64url.
-spec compute_ucan_cid(UcanToken :: ucan_token()) -> ucan_cid().
compute_ucan_cid(UcanToken) when is_binary(UcanToken) ->
Hash = crypto:hash(sha256, UcanToken),
base64url_encode(Hash).
%% @private URL-safe base64 encoding (no padding).
base64url_encode(Bin) ->
B64 = base64:encode(Bin),
B64_1 = binary:replace(B64, <<"+">>, <<"-">>, [global]),
B64_2 = binary:replace(B64_1, <<"/">>, <<"_">>, [global]),
%% Remove padding
binary:replace(B64_2, <<"=">>, <<>>, [global]).
%% @private Check rate limit for an issuer.
-spec check_rate_limit(IssuerDID :: did(), MaxPerMinute :: pos_integer()) ->
ok | {error, rate_limited}.
check_rate_limit(IssuerDID, MaxPerMinute) ->
Now = erlang:system_time(millisecond),
WindowStart = Now - ?RATE_LIMIT_WINDOW_MS,
case ets:lookup(?RATE_LIMIT_TABLE, IssuerDID) of
[{_, Timestamps}] ->
%% Filter to only recent timestamps
Recent = [T || T <- Timestamps, T > WindowStart],
case length(Recent) >= MaxPerMinute of
true ->
{error, rate_limited};
false ->
ok
end;
[] ->
ok
end.
%% @private Increment rate limit counter for an issuer.
increment_rate_limit(IssuerDID) ->
Now = erlang:system_time(millisecond),
WindowStart = Now - ?RATE_LIMIT_WINDOW_MS,
NewTimestamps = case ets:lookup(?RATE_LIMIT_TABLE, IssuerDID) of
[{_, Timestamps}] ->
%% Keep only recent timestamps + new one
[T || T <- Timestamps, T > WindowStart] ++ [Now];
[] ->
[Now]
end,
ets:insert(?RATE_LIMIT_TABLE, {IssuerDID, NewTimestamps}).
%% @private Broadcast revocation to mesh via PubSub.
broadcast_to_mesh(_IssuerDID, CID, _RevokedAt, _ExpiresAt, #state{pubsub_pid = undefined}) ->
?LOG_DEBUG("No PubSub available for revocation broadcast: ~s", [CID]),
ok;
broadcast_to_mesh(IssuerDID, CID, RevokedAt, ExpiresAt, #state{pubsub_pid = _PubSubPid}) ->
Msg = #{
<<"issuer_did">> => IssuerDID,
<<"ucan_cid">> => CID,
<<"revoked_at">> => RevokedAt,
<<"expires_at">> => ExpiresAt,
<<"signature">> => create_dummy_signature() %% TODO: Real Ed25519 signing
},
%% This would call macula_pubsub_handler:publish/3
%% For now, just log the intent
?LOG_DEBUG("Would broadcast revocation to ~s: ~p", [?SYSTEM_TOPIC, Msg]),
ok.
%% @private Create a dummy signature placeholder.
%% In production, this would use Ed25519 signing.
create_dummy_signature() ->
%% 64-byte placeholder (Ed25519 signature size)
crypto:strong_rand_bytes(64).
%% @private Cleanup expired revocation entries.
do_cleanup_expired() ->
Now = erlang:system_time(second),
%% Find and delete expired entries
Expired = ets:foldl(
fun({{IssuerDID, CID}, {_RevokedAt, ExpiresAt}}, Acc) ->
case Now >= ExpiresAt of
true -> [{IssuerDID, CID} | Acc];
false -> Acc
end
end,
[],
?REVOCATION_TABLE
),
lists:foreach(
fun(Key) ->
ets:delete(?REVOCATION_TABLE, Key)
end,
Expired
),
case Expired of
[] -> ok;
_ -> ?LOG_DEBUG("Cleaned up ~p expired revocations", [length(Expired)])
end.