Macula Protocol and Gatekeeper Guide

View Source

This guide covers the macula_protocol behaviour and macula_gatekeeper module, which together ensure that only properly implemented applications can participate in the Macula mesh network.

Overview

The Macula mesh uses a gatekeeper pattern to validate applications before allowing them to join:

  1. macula_protocol - A behaviour that defines the contract mesh-worthy apps must implement
  2. macula_gatekeeper - A module that validates applications implement the protocol correctly

Gatekeeper Security Model

Why Gatekeeping?

Without gatekeeping, any application could join the mesh and potentially:

  • Consume resources without providing value
  • Send malformed messages that break other nodes
  • Claim capabilities they don't actually implement
  • Impersonate other applications

The gatekeeper ensures applications:

  • Implement all required callbacks
  • Present valid certificates
  • Declare capabilities truthfully
  • Can handle mesh operations correctly

The macula_protocol Behaviour

Required Callbacks

Every mesh application must implement these 8 callbacks:

Protocol Callbacks

Identity and Configuration

%% Returns the application's unique mesh identity
-callback mesh_identity() -> identity().
%% Example: <<"io.macula.alice.weather-app">>

%% Returns the list of capabilities this app requires
-callback mesh_capabilities() -> [capability()].
%% Example: [publish, subscribe, call]

%% Returns the API specification (topics, procedures, content types)
-callback mesh_api() -> api_spec().
%% Example: #{
%%     topics => [<<"weather.updates">>, <<"weather.alerts">>],
%%     procedures => [<<"weather.getForecast">>],
%%     content_types => []
%% }

Message Handlers

%% Handles incoming pub/sub events
-callback handle_mesh_event(Topic :: binary(), Payload :: term()) ->
    ok | {error, term()}.

%% Handles incoming RPC calls
-callback handle_rpc_call(Procedure :: binary(), Args :: list()) ->
    {ok, term()} | {error, term()}.

Content Transfer

%% Provides content when requested by MCID (Macula Content ID)
-callback provide_content(ContentId :: binary()) ->
    {ok, binary()} | {error, term()}.

%% Handles received content
-callback content_received(ContentId :: binary(), Content :: binary()) ->
    ok | {error, term()}.

Health

%% Returns health status (called periodically by gatekeeper)
-callback mesh_health() -> ok | {error, term()}.

Valid Capabilities

Applications must declare which capabilities they need:

CapabilityDescription
publishCan publish to topics
subscribeCan subscribe to topics
callCan invoke RPC procedures
registerCan register RPC procedures
provide_contentCan serve content via MCID
consume_contentCan request content via MCID

Example Implementation

-module(my_weather_app).
-behaviour(macula_protocol).

%% Protocol callbacks
-export([
    mesh_identity/0,
    mesh_capabilities/0,
    mesh_api/0,
    handle_mesh_event/2,
    handle_rpc_call/2,
    provide_content/1,
    content_received/2,
    mesh_health/0
]).

%%====================================================================
%% Identity & Configuration
%%====================================================================

mesh_identity() ->
    <<"io.macula.alice.weather-app">>.

mesh_capabilities() ->
    [publish, subscribe, call, register].

mesh_api() ->
    #{
        topics => [<<"weather.updates">>, <<"weather.alerts">>],
        procedures => [<<"weather.getForecast">>, <<"weather.getHistory">>],
        content_types => []
    }.

%%====================================================================
%% Event Handlers
%%====================================================================

handle_mesh_event(<<"weather.alerts">>, #{<<"severity">> := Sev} = Payload) ->
    logger:info("Weather alert received: severity=~p", [Sev]),
    process_alert(Payload),
    ok;
handle_mesh_event(Topic, _Payload) ->
    logger:debug("Ignoring event on ~s", [Topic]),
    ok.

handle_rpc_call(<<"weather.getForecast">>, [Location]) ->
    case fetch_forecast(Location) of
        {ok, Forecast} -> {ok, Forecast};
        {error, Reason} -> {error, Reason}
    end;
handle_rpc_call(<<"weather.getHistory">>, [Location, Days]) ->
    case fetch_history(Location, Days) of
        {ok, History} -> {ok, History};
        {error, Reason} -> {error, Reason}
    end;
handle_rpc_call(Procedure, _Args) ->
    {error, {unknown_procedure, Procedure}}.

%%====================================================================
%% Content Transfer
%%====================================================================

provide_content(_ContentId) ->
    %% This app doesn't provide content
    {error, not_implemented}.

content_received(_ContentId, _Content) ->
    %% This app doesn't consume content
    ok.

%%====================================================================
%% Health
%%====================================================================

mesh_health() ->
    %% Check internal state is healthy
    case check_database_connection() of
        ok -> ok;
        {error, Reason} -> {error, {database_unavailable, Reason}}
    end.

%%====================================================================
%% Internal Functions
%%====================================================================

process_alert(Payload) ->
    %% Handle alert...
    ok.

fetch_forecast(Location) ->
    %% Fetch forecast from database...
    {ok, #{<<"temperature">> => 22, <<"conditions">> => <<"sunny">>}}.

fetch_history(Location, Days) ->
    %% Fetch history from database...
    {ok, []}.

check_database_connection() ->
    ok.

The macula_gatekeeper Module

The gatekeeper validates applications using a pipeline pattern where each step must pass before proceeding to the next.

Verification Pipeline

Gatekeeper Flow

  1. Protocol Loaded - Ensure macula_protocol module is available on the target node
  2. Behaviour Check - Verify all 8 callbacks are exported
  3. Identity + Certificate - Validate certificate and extract fingerprint
  4. Capabilities + API - Validate declared capabilities and API spec
  5. Health Check - Call mesh_health/0 to verify app is functioning

API

verify_beam_app/2,3

Validates a BEAM (Erlang/Elixir) application:

%% Verify on local node
{ok, Manifest} = macula_gatekeeper:verify_beam_app(my_weather_app, CertPem).

%% Verify on remote node
{ok, Manifest} = macula_gatekeeper:verify_beam_app(my_weather_app, CertPem, 'node@host').

Returns {ok, app_manifest()} on success:

#{
    identity => <<"io.macula.alice.weather-app">>,
    capabilities => [publish, subscribe, call, register],
    api => #{topics => [...], procedures => [...], content_types => []},
    certificate_fingerprint => <<"A1B2C3D4...">>,
    verified_at => {{2026,1,23},{12,0,0}}
}

Or {error, Reason} on failure:

ErrorCause
no_macula_sdkmacula_protocol not available
{module_not_found, Module}Application module not loaded
{missing_callbacks, [...]}Not all callbacks exported
{certificate_invalid, Reason}Certificate parsing failed
invalid_capabilityUnknown capability declared
{rpc_failed, Reason}Remote node communication failed

verify_certificate/2

Validates a certificate independently (used for non-BEAM apps):

case macula_gatekeeper:verify_certificate(CertPem, ExpectedIdentity) of
    {ok, Fingerprint} ->
        %% Fingerprint is 64-char hex SHA-256
        logger:info("Certificate valid, fingerprint: ~s", [Fingerprint]);
    {error, {certificate_invalid, Reason}} ->
        logger:error("Invalid certificate: ~p", [Reason])
end.

validate_operation/3

Checks if an app can perform a specific operation (called per-operation):

Manifest = #{capabilities => [publish, subscribe]},

%% Allowed
ok = macula_gatekeeper:validate_operation(Manifest, publish, <<"topic">>),
ok = macula_gatekeeper:validate_operation(Manifest, subscribe, <<"topic">>),

%% Denied
{error, {capability_not_declared, call}} =
    macula_gatekeeper:validate_operation(Manifest, call, <<"procedure">>).

check_health/1

Performs health check on verified app:

ok = macula_gatekeeper:check_health(Manifest).

Integration with Console

The MaculaCluster.Mesh.AppConnector uses the gatekeeper to validate apps before registering them on the mesh:

# In AppConnector
defp validate_and_register(identity, cert_pem) do
  case :macula_gatekeeper.verify_certificate(cert_pem, identity) do
    {:ok, fingerprint} ->
      Logger.info("Certificate validated, fingerprint: #{fingerprint}")
      register_app_on_mesh(identity)

    {:error, reason} ->
      Logger.warning("Certificate validation failed: #{inspect(reason)}")
      {:error, {:certificate_validation_failed, reason}}
  end
end

Security Model

Hierarchical PKI

Portal (Root CA)
    
     Console A (Intermediate CA / Street Node)
            App 1 (End Entity)
            App 2 (End Entity)
    
     Console B (Intermediate CA / Street Node)
             App 3 (End Entity)
             App 4 (End Entity)

Each Console acts as an intermediate CA for its apps. The gatekeeper validates:

  1. Certificate chain is valid
  2. Certificate is not expired
  3. Certificate fingerprint is recorded for audit

Per-Operation Enforcement

Even after admission, each operation is checked:

%% Before publishing
case macula_gatekeeper:validate_operation(AppManifest, publish, Topic) of
    ok -> proceed_with_publish();
    {error, {capability_not_declared, publish}} -> deny_publish()
end.

This prevents privilege escalation - an app that declared only subscribe cannot later publish.

Utility Functions

The macula_protocol module also exports validation helpers:

%% Validate identity format (reverse domain, 3+ segments)
ok = macula_protocol:validate_identity(<<"io.macula.alice.myapp">>),
{error, insufficient_segments} = macula_protocol:validate_identity(<<"io.macula">>).

%% Check if capability is valid
true = macula_protocol:is_valid_capability(publish),
false = macula_protocol:is_valid_capability(invalid).

%% Validate capability list
ok = macula_protocol:validate_capabilities([publish, subscribe]),
{error, invalid_capability} = macula_protocol:validate_capabilities([publish, invalid]).

%% Validate API spec
ok = macula_protocol:validate_api_spec(#{topics => [<<"t">>], procedures => []}),
{error, invalid_api_entries} = macula_protocol:validate_api_spec(#{topics => ["not binary"]}).

Test Coverage

ModuleTestsCoverage
macula_protocol35Identity, capabilities, API validation
macula_gatekeeper20Certificate, operations, callbacks

Run tests:

rebar3 eunit --module=macula_protocol_tests
rebar3 eunit --module=macula_gatekeeper_tests

See Also