macula_ucan (macula v17.1.1)

View Source

UCAN tokens in the node's crypto profile (plan decision D7), and a provider's authorization of one (D7 check 2).

A token is a JWT, header.payload.signature, each part base64url without padding. The header names the profile's algorithm: RFC 9964's ML-DSA-87 in pq_pure, and ML-DSA-87-PS384, the IETF LAMPS composite id-MLDSA87-RSA4096-PSS-SHA512, in pq_hybrid (JOSE has no name for it). The signature is the issuer's node key's signature over the header and payload as they are sent, made by macula_node_keys:sign/2, so ML-DSA-87 is macula-mldsa and the composite's RSA-PSS half is OTP's.

The payload's iss is a did:key for the issuer's key as carried (D13): multibase base58btc over the varint of a multicodec and the key. pq_pure uses mldsa-87-pub, 0x1212; pq_hybrid has no multicodec yet and uses Macula's own key type, 0x300087, from the private-use range. aud is the audience's node_id in lowercase hex, since a token is presented by the node it names, inside a request that node signed (D7 check 2). Every token has an exp, in seconds, at most max_lifetime/0 past now: UCAN_V1 has no revocation, so a token's exp is the only bound on its life.

authorize/3 is the provider's check, after the request's own signature and target have verified: the header and payload are verified over the bytes as received, never as re-encoded JSON, then the issuer, the audience, the validity window and the capability.

A token may be one the caller was delegated. Its prf names its parent by proof_id/1, the lowercase hex of the SHA-384 of that parent's bytes as they travel, and the parents travel beside it in the request's caller-signed proofs set, which reaches this module as the context's proofs map. The chain is walked to its root, which must be the issuer the policy names, and every step is checked: the parent's audience is the node_id of the child's issuer key, each link's own signature and validity window hold, can is equal at every step, and a child's capability is covered by one of its parent's (covers/2, D7's narrowing matrix). A token names at most one parent, and every proof that travelled must be referenced, so an unused one is refused rather than ignored.

A capability's with is an MRI: mri:realm:<realm>, mri:org:<realm>/<org>, mri:proc:<realm>/<procedure> or mri:topic:<realm>/<path>, where a procedure's org is the text before its first /. A topic's grant is the topic itself behind mri:topic:, since a topic's first segment is its realm's name (macula_topic). A realm id is SHA-256 over the normalised realm name (D7), so the grant's name is checked against the realm id the request carries, with nothing looked up.

A procedure or topic grant whose last segment is * is a prefix: it covers every name below the segments before the *, never that name itself, and only whole segments, so mri:proc:<realm>/acme/* covers acme/api.users.get and mri:topic:<realm>/acme/orders/* covers <realm>/acme/orders/placed_v1, but acme/ord/* covers nothing of acme/orders/. A * segment anywhere else, a prefix with an empty segment, and a prefix with no segment before its * are not grants; a * inside a longer segment is a literal. A request names one procedure or one topic, never a prefix.

Summary

Types

The issuer a policy requires: a node's node_id for ucan_required, a realm key's key id for realm_member_required.

A realm member policy names the can a request needs, or any for a gate that verifies the token once and keeps every capability it grants, which grants/4 then answers requests from (macula#128).

What a capability grants, parsed from its with: a realm, an org of that realm, one procedure or topic of that realm, or a prefix of procedures or of topics, held as the segments before its *.

Functions

Whether Token authorizes the verified caller under Policy, in the provider's profile at Now in seconds: its claims when it does, or the first reason it does not. Never raises on the token.

The second from which a token that authorize/3 accepted stops authorizing: the earliest exp of the token and of its proofs. authorize/3 spends every proof that travelled with the request, so every one of them counts. It reads the claims and verifies nothing, so it is only meaningful for a token and proofs authorize/3 has accepted.

The key a did:key carries, when it is a key in its one carried form for the profile (D13). A did:key longer than max_did_key_encoded/0 is refused before it is decoded.

Whether a grant covers another grant or a request, by D7's narrowing matrix: a realm grant covers its realm, an org grant covers that org, its procedures and their prefixes, a prefix covers every name and prefix below it and itself, and a procedure or topic grant covers only itself. A procedure grant never covers a topic, nor a topic grant a procedure, and an org grant covers no topic: a topic's org slot carries macula_topic's tier sentinels, so topics are granted by realm, topic or topic prefix. False for a grant that is not an MRI of the four forms, whose realm name is not canonical, whose procedure has no org namespace, or whose * is not a prefix's last segment.

A token from the issuer's key, a key of a purpose that signs with the profile's identity algorithms, for the audience's node_id, granting Capabilities until exp. nbf, nnc, fct and prf are optional.

The did:key for a key as carried in a profile.

Whether capabilities a token already verified grants Can on one request in the realm whose id is Realm, with nothing verified again: a station that verified a member's token once per link, under a realm member policy of any, answers each SUBSCRIBE and PUBLISH from the claims it kept (macula#128). Caps are the token's cap claim as authorize/3 returned it.

The longest base58btc text after "did:key:z" that a did:key for a carried key can have (macula#87): a pq_hybrid key, the longest, is the 3-byte codec varint, the 2,592-byte ML-DSA-87 key and a DER RSA-4096 public key of about 526 bytes, some 4,270 characters. Base58 decodes in time quadratic in its length, ahead of the signature check, so a longer did:key is refused before it is decoded. Every SDK holds the same bound (ucan_v1.json, did_key_length).

The furthest past now, in seconds, a token's exp may lie: ten years of 365.25 days. An exp in milliseconds lies far beyond it, so a token minted with one is refused rather than valid for tens of thousands of years.

The id a child's prf names a parent token by: the lowercase hex of the SHA-384 of that token's bytes as they travel, the three base64url parts and their dots. A token re-encoded on the way has another id (D7, D24).

The node_id a chain's authority belongs to: the audience of its root, the token the policy's issuer issued. Under a realm member policy that is the member the realm endorsed, which may have handed its rights on to the caller through the chain (macula#120). A token with no prf is its own root. Like authorized_until/2 it reads the claims, following each prf to the proof that carries it, and verifies nothing, so it is only meaningful for a token and proofs authorize/3 has accepted.

Types

capability/0

-type capability() :: #{with := binary(), can := binary()}.

The issuer a policy requires: a node's node_id for ucan_required, a realm key's key id for realm_member_required.

grant/0

-type grant() ::
          {realm, binary()} |
          {org, binary(), binary()} |
          {proc, binary(), binary()} |
          {proc_prefix, binary(), binary()} |
          {topic, binary(), binary()} |
          {topic_prefix, binary(), binary()}.

issuer_id/0

-type issuer_id() :: <<_:256>>.

A realm member policy names the can a request needs, or any for a gate that verifies the token once and keeps every capability it grants, which grants/4 then answers requests from (macula#128).

policy/0

-type policy() :: {ucan_required, issuer_id()} | {realm_member_required, issuer_id(), binary() | any}.

refusal/0

-type refusal() ::
          malformed | wrong_algorithm | signature_invalid | not_the_issuer | not_the_audience |
          expired | exp_beyond_max_lifetime | not_yet_valid | missing_capability | missing_proof |
          unreferenced_proof | not_the_delegate | chain_not_linear | grants_more_than_proof |
          can_changed | wrong_realm | realm_name_not_canonical | procedure_without_org |
          topic_without_realm.

What a capability grants, parsed from its with: a realm, an org of that realm, one procedure or topic of that realm, or a prefix of procedures or of topics, held as the segments before its *.

Functions

authorize(Token, Policy, Context)

-spec authorize(term(),
                policy(),
                #{caller := macula_node_keys:node_id(),
                  profile := macula_crypto_profile:profile(),
                  now := integer(),
                  realm => <<_:256>>,
                  procedure => binary(),
                  topic => binary(),
                  group => binary(),
                  proofs => #{binary() => binary()}}) ->
                   {ok, map()} | {error, refusal()}.

Whether Token authorizes the verified caller under Policy, in the provider's profile at Now in seconds: its claims when it does, or the first reason it does not. Never raises on the token.

The context names the caller, the profile and the time, and, for a request, the realm id and the procedure or topic it is for and the proofs that travelled with it. A topic is named in full, its realm's name first. A group names a sealed group's prefix the same way, and asks for the whole group: the prefix and every topic below it, so the grants must cover both mri:topic:P and mri:topic:P/* (macula#129). A context names at most one of procedure, topic and group. A context with no realm and none of them checks the token alone, as a gate that has no request in front of it.

authorized_until(Token, Proofs)

-spec authorized_until(binary(), [binary()]) -> integer().

The second from which a token that authorize/3 accepted stops authorizing: the earliest exp of the token and of its proofs. authorize/3 spends every proof that travelled with the request, so every one of them counts. It reads the claims and verifies nothing, so it is only meaningful for a token and proofs authorize/3 has accepted.

carried_key(Other, Profile)

-spec carried_key(binary(), macula_crypto_profile:profile()) -> {ok, binary()} | error.

The key a did:key carries, when it is a key in its one carried form for the profile (D13). A did:key longer than max_did_key_encoded/0 is refused before it is decoded.

covers(Parent, Child)

-spec covers(binary(), binary()) -> boolean().

Whether a grant covers another grant or a request, by D7's narrowing matrix: a realm grant covers its realm, an org grant covers that org, its procedures and their prefixes, a prefix covers every name and prefix below it and itself, and a procedure or topic grant covers only itself. A procedure grant never covers a topic, nor a topic grant a procedure, and an org grant covers no topic: a topic's org slot carries macula_topic's tier sentinels, so topics are granted by realm, topic or topic prefix. False for a grant that is not an MRI of the four forms, whose realm name is not canonical, whose procedure has no org namespace, or whose * is not a prefix's last segment.

create(Key, Audience, Capabilities, Opts)

-spec create(macula_node_keys:node_key(),
             macula_node_keys:node_id(),
             [capability()],
             #{exp := non_neg_integer(),
               nbf => non_neg_integer(),
               nnc => binary(),
               fct => map(),
               prf => [binary()]}) ->
                binary().

A token from the issuer's key, a key of a purpose that signs with the profile's identity algorithms, for the audience's node_id, granting Capabilities until exp. nbf, nnc, fct and prf are optional.

A window no verifier accepts is not minted: an exp more than max_lifetime/0 past now raises {exp_beyond_max_lifetime, #{exp, now, at_most}}, and an nbf not before exp raises {window_never_opens, #{nbf, exp}}.

did_key(Carried, Profile)

-spec did_key(binary(), macula_crypto_profile:profile()) -> binary().

The did:key for a key as carried in a profile.

grants(Caps, Can, Realm, Request)

-spec grants([map()],
             binary(),
             <<_:256>>,
             {topic, binary()} | {subscription, binary()} | {procedure, binary()}) ->
                ok | {error, refusal()}.

Whether capabilities a token already verified grants Can on one request in the realm whose id is Realm, with nothing verified again: a station that verified a member's token once per link, under a realm member policy of any, answers each SUBSCRIBE and PUBLISH from the claims it kept (macula#128). Caps are the token's cap claim as authorize/3 returned it.

A {topic, T} or {procedure, P} request is checked as authorize/3 checks one. A {subscription, Pattern} is a subscription, whose * segments each match one segment (macula_topic_pattern): a grant must cover every topic the pattern can match, so the pattern is read as the prefix of the segments before its first *, and a grant covers it only when it covers that prefix (D7's matrix, covers/2). orders/eu/* needs a grant on orders/eu/* or wider, and a pattern whose first * follows the realm's name alone needs the realm grant. A pattern without * is a topic.

max_did_key_encoded()

-spec max_did_key_encoded() -> pos_integer().

The longest base58btc text after "did:key:z" that a did:key for a carried key can have (macula#87): a pq_hybrid key, the longest, is the 3-byte codec varint, the 2,592-byte ML-DSA-87 key and a DER RSA-4096 public key of about 526 bytes, some 4,270 characters. Base58 decodes in time quadratic in its length, ahead of the signature check, so a longer did:key is refused before it is decoded. Every SDK holds the same bound (ucan_v1.json, did_key_length).

max_lifetime()

-spec max_lifetime() -> pos_integer().

The furthest past now, in seconds, a token's exp may lie: ten years of 365.25 days. An exp in milliseconds lies far beyond it, so a token minted with one is refused rather than valid for tens of thousands of years.

proof_id(Token)

-spec proof_id(binary()) -> binary().

The id a child's prf names a parent token by: the lowercase hex of the SHA-384 of that token's bytes as they travel, the three base64url parts and their dots. A token re-encoded on the way has another id (D7, D24).

root_audience(Token, Proofs)

-spec root_audience(binary(), [binary()]) -> <<_:256>>.

The node_id a chain's authority belongs to: the audience of its root, the token the policy's issuer issued. Under a realm member policy that is the member the realm endorsed, which may have handed its rights on to the caller through the chain (macula#120). A token with no prf is its own root. Like authorized_until/2 it reads the claims, following each prf to the proof that carries it, and verifies nothing, so it is only meaningful for a token and proofs authorize/3 has accepted.