Macula Authorization Guide
View SourceThis guide covers Macula's identity and authorization primitives: decentralized identifiers, self-sovereign certificates, and UCAN capability tokens.
Overview
Macula's authorization is:
- Self-sovereign: Identity controlled by the owner's Ed25519 keypair
- Cryptographically verifiable: No network calls needed for validation
- Capability-based: Fine-grained permissions via UCAN tokens
- Offline-capable: All validation happens locally
What's actually gated today. The SDK's only enforced authorization point is per-procedure:
macula:advertise/5'sauthopt (and the same opt onmacula:advertise_stream/6for streaming procedures) takesopen(default — serve any identified caller; every QUIC session is Ed25519 peer-bound, so "open" is not "anonymous"),{ucan_required, Issuer}(a caller must present a valid UCAN signed byIssuerand minted for the caller itself, passed throughcall_station/7'sucan_tokenopt), or{realm_member_required, RealmDid, RequiredCan}(a caller must present a UCAN signed by a realm's own DID, likewise minted for the caller itself, carrying a capability whosecanmatchesRequiredCanexactly, which closes the tier gapIssuer-only checking can't: a realm can mint membership UCANs at more than one tier from the same key, e.g. a human-confirmed tier versus a self-service device tier, andRequiredCanis mandatory so a service names the tier it actually needs). Both policies bind a token's audience (aud) to the wire-authenticated caller, so a token copied from someone else is refused. There is no automatic DID-namespace-ownership check on publish/subscribe/call — the primitives below (DIDs, certs, UCANs) are what you build a stronger policy from, not a policy the SDK enforces on its own. See Direct-Dial Dual-Trust for the one place the SDK does enforce something end-to-end: cert-chain verification against squatted advertisements.
Core Concepts
Decentralized Identifiers (DIDs)
A DID (Decentralized Identifier) is a globally unique identifier that enables verifiable, decentralized digital identity. DIDs are defined by the W3C DID Core Specification.
Macula DID Format
did:macula:io.macula.rgfaber
| | +------------------ Method-specific identifier (namespace)
| +-------------------------- Method (macula)
+------------------------------- Scheme (always "did")Key Properties:
| Property | Description |
|---|---|
| Self-sovereign | Controlled by owner's Ed25519 keypair |
| Human-readable | Hierarchical namespace format |
| Cryptographically verifiable | Ownership proven via signature |
User Controlled Authorization Networks (UCANs)
UCAN (User Controlled Authorization Networks) is a capability-based authorization system built on JWT (JSON Web Tokens). UCANs enable delegation chains where permissions can be granted and re-delegated without involving a central authority.
UCAN Token Structure
macula_ucan_nif:create/4,5 builds a JWT with these claims:
| Claim | Description |
|---|---|
iss | Issuer DID - Who created and signed this token |
aud | Audience DID - Who this token is granted to |
exp | Expiration (optional) - Unix timestamp when token expires |
nbf | Not Before (optional) - Token valid only after this time |
cap | Capabilities - Array of permission grants |
prf | Proofs - Chain of parent UCANs (for delegation) |
nnc | Nonce (optional) - for uniqueness |
fct | Facts (optional) - metadata |
{ok, Token} = macula_ucan_nif:create(IssuerDID, AudienceDID, Capabilities, PrivKey),
{ok, Payload} = macula_ucan_nif:verify(Token, IssuerPubKey),
{ok, Issuer} = macula_ucan_nif:get_issuer(Token),
{ok, Audience} = macula_ucan_nif:get_audience(Token),
{ok, Caps} = macula_ucan_nif:get_capabilities(Token),
false = macula_ucan_nif:is_expired(Token).Further Reading:
Certificates removed in 11.0.0
The 10.x certificate form is gone: macula 11.0.0 issues no X.509
certificates (design B1), so macula_cert and macula_cert_system are
removed, and a provider authorization is only the realm-signed org
directory and the org-signed procedure delegation, carried inside the
provider's procedure_advertisement (see
consumer → provider below).
macula_trust_store remains as a standalone module, for nodes that keep a
local trust registry of their own.
Direct-Dial Dual-Trust
Direct-dial RPC (a consumer resolves a procedure_advertisement and dials the
provider's station) collapses the path to one QUIC/TLS session between two
sovereign identities — the natural place for a mutual check. Trust is
bidirectional, unlike the one-directional server-authenticates-client of classic
RPC:
- consumer → provider — is this the legitimate server of the procedure, not a squatter who wrote an advertisement next to the real one?
- provider → consumer — should I serve this caller at all? Direct-dial makes every station a public front door, so the provider decides who it answers.
Both stay compatible with fully-open, permissionless discovery: the discovery layer is always open, and each endpoint independently chooses what it checks.
consumer → provider: provider authorization
A procedure with an org namespace, the text before the first / of its name,
is served only by a provider that org authorized, and a caller checks that
before it calls. The provider's procedure_advertisement carries its
authorization: the realm-signed org directory and the org-signed procedure
delegation that names the provider, checked against the realm key. It is the
only form. 11.0.0 has no certificate form, and an advertisement carrying any
other authorization is refused as authorization_form_unsupported. A
procedure without an org namespace carries none, and an advertisement
expires no later than any part of its authorization. A provider publishes
its authorization with macula_response:advertise_direct/7's
authorization option.
A caller's pool pins each realm's key when it starts, as
realm_trust => #{RealmId => RealmKey} in macula:connect/2's options, and
resolution checks an advertisement only against the key pinned for its realm.
Without that key, the advertisement is never trusted, so writing an
advertisement next to the real one does not make a node the server of an org's
procedure. A realm key never arrives with a request: realm_trust on a call
is refused with {error, {removed_option, realm_trust}}, as the 10.x options
verify_cert_chain and cert_chain are.
A caller checks the authorization from the advertisement alone and looks up no tombstone. A delegation its org withdraws is honoured until it expires, so the caller-side revocation bound is the delegation's maximum lifetime, six hours, and it lengthens if that lifetime does.
%% consumer side (the check resolution runs on each verified advertisement)
ok = macula_record:verify_authorization(Advertisement,
#{profile => Profile, realm_key => RealmKey},
erlang:system_time(millisecond)).Note on the realm tag: the 32-byte realm tag is
SHA-256(realm_name)— a keyless label, not a signing key. Trust therefore roots in the realm CA (a real key the realm holds and distributes at issuance), not the tag.
provider → consumer: UCAN-gated procedures
A bare advertisement serves any identified caller (every QUIC session is
Ed25519 peer-bound, so "open" is not "anonymous"). A provider can instead require
a UCAN per procedure via advertise/5's #{auth => {ucan_required, Issuer}} —
a caller presents a ucan_token on the CALL (call_station/7's Opts), and a
caller without a valid one is refused with a BOLT#4 unauthorized code rather
than a timeout. The token is verified offline against the chain the provider
recognises — no live authority in the path.
A valid token is signed by Issuer, unexpired, and minted for the caller that
presents it: its aud must be the calling identity's public key in lowercase
hex. macula_ucan_nif:verify/2 checks signature and expiry only, so the
procedure gate compares aud with the wire-authenticated caller of the CALL
or STREAM_OPEN. A genuine token minted for someone else is refused like no
token at all, so a copied token does not work for whoever holds the copy.
Managed realms are the first target for this model; the fully-open public realm keeps discovery permissionless and layers authorization on top only where a provider opts in.
provider → consumer: realm-membership-gated procedures
{ucan_required, Issuer} gates a procedure to exactly one known identity by
direct signature. {realm_member_required, RealmDid, RequiredCan} gates on
membership in a realm instead — any caller holding a valid UCAN signed by the
realm's own DID (not the 32-byte realm tag; a realm's DID is a real Ed25519
keypair it holds), whose aud names the caller itself and whose capability
list carries RequiredCan, is admitted:
%% RealmDid: a realm's own DID, e.g. read from its RealmUcanIssuer's
%% published `iss` (never the 32-byte realm tag used for `-realm` flags).
%% RequiredCan: mandatory -- name the exact tier this procedure needs, since
%% a realm can mint membership UCANs at more than one tier from the same
%% key (a human-confirmed tier and a weaker self-service tier are both
%% "genuine, correctly-signed" tokens; only the capability tells them apart).
Opts = #{auth => {realm_member_required, RealmDid, <<"member/email-verified">>}},
ok = macula:advertise(Pool, Realm, <<"private.procedure">>, Handler, Opts).This policy binds the audience the same way ucan_required does: a token that
is genuinely realm-signed and unexpired, but minted for a different member, is
refused. What it adds is trust in a realm rather than one issuer, and the tier
check: RequiredCan must appear in the token's capabilities.
provider → consumer: gated streaming procedures
A streaming procedure takes the same policies. Pass auth to
macula:advertise_stream/6 (or in the Opts of macula_streamer:advertise/6
and advertise_direct/7), and a consumer presents its token with
call_stream/5's ucan_token opt:
Opts = #{auth => {realm_member_required, RealmDid, <<"member/email-verified">>}},
ok = macula:advertise_stream(Pool, Realm, <<"private.feed">>, server_stream,
Handler, Opts),
{ok, Stream} = macula:call_stream(Pool, Realm, <<"private.feed">>, Args,
#{ucan_token => Token}).The provider first verifies the STREAM_OPEN's signature against its caller,
then applies the policy before the handler runs. A refused STREAM_OPEN gets a
STREAM_ERROR with code unauthorized on its stream, and the handler never
runs.
Best Practices
Token lifetime guidelines
| Use Case | Recommended Lifetime |
|---|---|
| API calls | 1-24 hours |
| Long-term partnerships | Months (narrow scope) |
| Sensitive operations | Always short |
Security recommendations
- Use short-lived tokens for sensitive operations
- Narrow capability scope — grant only what's needed
- Store UCAN tokens securely (encrypted at rest, treat as credentials)
- Protect private keys — never leave the generating node
Glossary
| Term | Definition |
|---|---|
| DID | Decentralized Identifier - globally unique, self-sovereign identity |
| UCAN | User Controlled Authorization Network - capability-based auth token |
| Capability | Permission grant with resource and operation |
| Ed25519 | Elliptic curve signature algorithm |
| Realm Certificate | Self-signed root certificate for a Macula realm |
| Instance Certificate | Certificate signed by a realm certificate for a specific node |
| Trust Store | Local store of trusted realm certificates |
References
Standards
- W3C DID Core 1.0 - Decentralized Identifiers specification
- UCAN Specification - User Controlled Authorization Networks
- RFC 7519 - JWT - JSON Web Token specification
- RFC 8032 - Ed25519 - Edwards-Curve Digital Signature Algorithm