Documentation

UCAN Authorization

Most authorization systems decide whether a user may do something by consulting a database of permissions. That database is a central point of trust, a central point of failure, and the reason sharing anything needs an administrator. UCAN, User Controlled Authorization Network, turns the model around. The permission is the token. A signed delegation says who granted what to whom, on which subject, for how long. Anyone holding the issuer's public key can verify it offline.

Ekayana implements UCAN 1.0 through two crates it publishes. pq-ucan is the token format, policy language and chain validator. pq-space lets a did:bio identity sign and verify those tokens and can carry a content key inside a grant. Tokens are signed by the researcher's wallet. The service prepares the bytes and validates the result. It never signs in anyone's name.

The token on the wire

A UCAN 1.0 token is a DAG-CBOR array of two elements, a signature and the map it signs.

ONE TOKEN, ON THE WIREstrict DAG-CBOR; the decoder refuses any other encodingarray(2)CID = sha256 over these bytes.0signaturebytes, by iss, over theDAG-CBOR bytes of .1Ed25519: 64 bytesML-DSA-87: 4627 bytesThe header decides which.Nothing is re-encodedto verify it..1SigPayload - map(2)hvarsig header, names the algorithm34 01 ed 01 ed 01 13 71 (Ed25519) 34 01 92 24 71 (ML-DSA-87)"ucan/dlg@1.0.0"the delegation payloadississuer = the signeraudaudiencesubsubject, or powerlinecmd/file/readpol[["==", ".cid", cid]]exprequired; null = nevernonce12 random bytesmetaspace/key, wrapped keyAn invocation has the same shape under the tag ucan/inv@1.0.0, with args, prf (the proof CIDs) and an optional aud, the executor.A builder takes no issuer. The signer's DID is the issuer, so a token whose iss does not match its key cannot exist.
A UCAN 1.0 token on the wire. It is a two element DAG-CBOR array holding the signature and a signature payload whose two keys are the varsig header and the tagged delegation payload.

The encoder produces strict canonical DAG-CBOR and the decoder refuses anything else, including indefinite lengths, nonminimal integers, unsorted or duplicate keys and trailing bytes. Canonicalization attacks are the reason varsig exists, and refusing a second encoding is cheaper than reasoning about it. A decoded token also keeps the bytes it came from. The CID is checked over the received envelope and the signature over the received payload bytes. The one exception is a token whose header says it was signed as DAG-JSON, which is how wallets sign, and its signature is checked over the canonical DAG-JSON text of the payload. Both encodings are canonical, so what a signer held is still exactly what a verifier sees.

TokenTag
Delegationucan/dlg@1.0.0
Invocationucan/inv@1.0.0

The varsig header names the algorithm. The three the specification requires are there, and ML-DSA is added at the same table as an ordinary entry.

AlgorithmHeader bytesSignatureStatus
Ed2551934 01 ed 01 ed 01 13 7164 bytesspec
ES256 (P-256)34 01 ec 01 80 24 12 7164 bytesspec
ES256K (secp256k1)34 01 ec 01 e7 01 12 7164 bytesspec
ML-DSA-44 / 65 / 8734 01 90 24 71 / 91 24 / 92 242420 / 3309 / 4627 bytesprovisional

A delegation's payload carries iss, aud, sub, cmd, pol, nonce, exp, and optionally meta and nbf. An invocation carries iss, sub, cmd, args, prf, which is the list of proof CIDs with the root first, nonce, exp, and optionally aud, the executor. A token's identifier is the CID of its bytes, which is what the platform calls a token_hash.

Delegation and invocation

A delegation is a grant. Its sub is the principal whose authority is being passed on, its aud the principal receiving it, and its cmd a /-separated path that names what may be done. sub: null is a powerline delegation. Whatever authority the audience later proves it has, this token passes along. The platform uses one for the browser session key.

An invocation is the act. It names the subject, the command, the arguments, and the chain of delegations in prf that connect the invoker back to the subject. The executor, aud, is who is being asked to act. In Ekayana that is always the service's own did:bio.

Commands only narrow

A command covers another when it is /, equals it, or is a prefix of it on a segment boundary. /file covers /file/read and does not cover /filesystem. Every hop in a chain must be covered by the hop above it, and the invocation by the last hop. There is no way to hold /file/read and mint /file.

DELEGATION CHAINreadwritedeleteDataset ownerroot authorityreadwritedeleteUCAN #1full delegationreadwritedeleteUCAN #2lab managerreadwrite-drops deleteUCAN #3researcherread--drops writeEach hop's command must be covered by its parent's; the validator walks from the root and names the hop that fails.Revoking any token invalidates everything below it, without touching those tokens.
A UCAN delegation chain narrowing at every hop. The dataset owner holds read, write and delete. The first delegation takes the full set, the second drops delete for the lab manager, and the third keeps only read for the researcher.

Policy

A delegation can further constrain the invocation's arguments with a policy, a list of statements that all have to hold.

FormMeaning
["==", sel, value], ["!=", ...]selected value equals, or does not equal, value
["<", sel, n] and <=, >, >=numeric comparison, and nonnumeric values are false
["like", sel, pattern]glob match on a string, * matches anything
["and", [...]], ["or", [...]], ["not", stmt]combinators
["all", sel, stmt], ["any", sel, stmt]quantifiers over a list or a map's values

Selectors are jq style paths into args, such as .cid, .tags[0] or .meta?. A selector that fails to resolve makes its statement false rather than raising. A share of one file with two actions is, in the platform, the command /file with the policy [["==", ".cid", "bafy..."], ["or", [["==", ".action", "read"], ["==", ".action", "write"]]]].

Validation

Validator::validate runs a fixed sequence of checks over an invocation and its proof chain and stops at the first failure, naming the hop and the rule. The error reads like "proof 2: audience did:key:z6Mk... is not the next issuer did:key:z6Mk..." rather than "invalid token".

VALIDATOR - twelve checks, first failure wins, the hop is namedCHECKRULEFAILS WITHSTRUCTUREEXECUTOR 1Proofs resolveevery CID in prf is in the storeMissingProof 2Nonempty chainat least one proofEmptyChain 3Root authorityhop 0 issuer is the subjectRootNotSubject 4Powerline placementhop 0 names a subjectPowerlineAtRoot 5Subject alignmenteach hop keeps or inherits the subjectSubjectMismatch 6Principal alignmenteach audience is the next issuerPrincipalMismatch 7Command attenuationeach command covers the nextCommandNotCovered 8Time boundsnbf and exp on every hop, 60 s skewExpired, NotYetValid 9Signaturesevery hop and the invocationBadSignature10Policyargs satisfy every statement of every hopPolicyRejected11Executoraud, else sub, is this serviceWrongExecutor12Replayinvocation CID unseen; recorded only nowReplayStructure first, signatures before policy so nothing unsigned is evaluated, replay last so a rejected call spends no nonce.A pass yields a Proof: the resolved chain, the effective subject and the invocation. The executor acts on that.
The twelve validation checks pq-ucan runs, in order, grouped as structure, time, signatures, policy, executor and replay, each with the error it raises

The order matters. Structural checks are cheap and give the best diagnostics, so they go first. Signatures are the expensive step and run before policy, so no policy is ever evaluated for a token nobody signed. The replay guard is last, so a rejected invocation never spends a nonce.

The validator leaves three things unchecked on purpose. It does not check whether the subject controls the resource, because the executor is the resource, or asks it. It does not check semantic conditions like quotas, which belong in execution. It does not check revocation, because the crate has no store for it yet, so the service keeps a revoked_at column and consults it before the validator runs.

Detached signing

A wallet will sign bytes you hand it, and it will not hand you its key. The builders therefore have two endings. sign(&signer) produces the token in one step. prepare(issuer, algorithm) returns the exact bytes to sign, and assemble(bytes, signature) builds the token from them. The result is byte for byte what sign would have produced, and the service verifies it under the issuer's key once assembled, so a wallet cannot be talked into signing one thing and the service recording another. The platform itself calls prepare_with with a header that says the wallet signs DAG-JSON, because Solana wallets sign only text.

rust
let unsigned = Delegation::builder(recipient, Subject::Did(owner.clone()), Command::parse("/file/read")?)
    .policy(policy)
    .nonce(Nonce::random(&mut rng))
    .expires_at(now.plus_seconds(7 * 24 * 3600))
    .prepare(owner, Algorithm::Ed25519)?;
// ... the wallet signs unsigned.signing_bytes() ...
let grant = Delegation::assemble(unsigned.signing_bytes(), signature.as_bytes())?;

A builder takes no issuer. The signer's DID is the issuer, so a token whose iss does not match its key cannot exist. It also will not sign until an expiry and a nonce have been chosen. Both are decisions rather than defaults.

How the platform uses it

Every share is one delegation the owner's wallet signs. Every read is one invocation a browser key signs. The service is the executor in the middle.

[owner shares a file]POST /share/file/preparesigning bytes, expirysign the delegationEd25519 signaturePOST /share/file, signedassemble, verify, store by CIDtoken, token_hash[recipient, once per session]POST /ucan/session/preparesign 24 h /file/read grantsignaturePOST /ucan/session[each read]POST /ucan/invoke/preparesession key signsGET /download/{cid} + headersValidator, executor = servicedecrypted bytesOwner's walletOwner's browserEkayana APIRecipient's browserRecipient's wallet
One share from the owner's wallet to the recipient's download. The delegation is prepared, signed, assembled and stored. A session key is registered with a 24 hour read grant. An invocation is prepared and signed per read. The API validates with itself as executor.

Issuing

POST /api/ucan/issue/prepare takes an audience, capabilities and an expiration in seconds, 24 hours by default and at most ten years, and returns the bytes to sign and when the token will expire. POST /api/ucan/issue takes the same request plus signing_bytes and signature, assembles the token, checks that it says what was asked and verifies under the issuer's key, and records it.

bash
curl -X POST https://api.ekayana.io/api/ucan/issue/prepare \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "audience": "did:bio:devnet:HP3Hj9en...",
    "capabilities": [{ "with": "ipfs://bafybeigdyrexample", "can": "read" }],
    "expiration": 604800
  }'
# -> { "signing_bytes": "<base64url>", "expires_at": 1753833600 }
bash
curl -X POST https://api.ekayana.io/api/ucan/issue \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "audience": "did:bio:devnet:HP3Hj9en...",
    "capabilities": [{ "with": "ipfs://bafybeigdyrexample", "can": "read" }],
    "expiration": 604800,
    "signing_bytes": "<base64url>",
    "signature": "<base64url, from the wallet>"
  }'
# -> { "token": "<base64url of the DAG-CBOR bytes>", "token_hash": "<CID>", "expires_at": 1753833600 }

Capabilities map onto commands and policy. One action is the command /file/<action>. Several actions are /file with a policy on .action. A specific CID adds a policy on .cid. * as the resource means every file the issuer owns. A token covers one resource. The sharing tutorial wraps these two calls in /api/share/file/prepare and /api/share/file.

Presenting

A recipient never presents a bare delegation. They present it together with an invocation their own key signed, in two headers.

bash
curl https://api.ekayana.io/api/download/bafybeigdyrexample \
  -H "Authorization: Bearer $TOKEN" \
  -H "x-ucan-token: <delegation>" \
  -H "x-ucan-invocation: <invocation>"

POST /api/ucan/invoke/prepare builds the invocation bytes for a token, a CID and an action. The invocation lives 60 seconds. For read, the issuer is the session's browser key and the proof chain is two hops, the wallet's 24 hour powerline grant of /file/read to that key and then the share. For write and delete the wallet itself signs. The service checks the delegation's signature, time window and revocation, decodes the invocation, confirms its cid and action match the request, and runs the validator with itself as executor.

Endpoints

MethodPathPurpose
POST/api/ucan/issue/prepareBytes for the wallet to sign
POST/api/ucan/issueAssemble, verify and record a signed delegation
POST/api/ucan/session/prepareBytes for the 24 hour read grant to a browser key
POST/api/ucan/sessionRegister the signed session grant
POST/api/ucan/invoke/prepareBytes for an invocation over one token, CID and action
POST/api/ucan/validateVerify a token. Public, needs no account
POST/api/ucan/revokeRevoke a token you issued, given the token
DELETE/api/share/revoke/{token_hash}Revoke a pass you issued, by its CID
GET/api/ucan/issuedTokens you have issued
GET/api/ucan/receivedTokens delegated to your DID
GET/api/ucan/token/{cid}Fetch a token by id

Only the issuer may revoke, and revocation is immediate. The next presentation fails before the validator runs. Anything delegated beneath a revoked token fails with it, because the chain no longer resolves.

Private spaces

For a sealed blob, a capability alone is not enough, because the recipient also needs the content key. pq-space wraps that key to the recipient's ML-KEM-1024 encapsulation key and carries it inside the delegation, in the meta map under space/key.

CAPABILITY AND DECRYPTION TRAVEL TOGETHERSPACE OWNERcontent key32 random bytes per blobwrapML-KEM-1024, HKDF, AES-256-GCMaad = subject || "|" || commandto the recipient's encapsulation key,published in a DID service entrySpaceEncapsulationKeyattachDelegationsigned by the issuersub: the space DIDaud: the recipientcmd: /space/blob/getmeta["space/key"]issuer: a did:bio researcher,or an Ed25519 or ML-DSA-87 did:keypresentRECIPIENTverify the chainBioResolver, no network callunwrapML-KEM secret key + the same aadopen the blobAES-256-GCM, content keyThe wrap is bound to one subject and one command: lifted into another grant it will not open.Both identifiers resolve from the string itself, so checking a chain costs no round trip.No custody service, no KMS: whoever holds the delegation and the ML-KEM key can read, nobody else.
How a content key travels inside the delegation that grants access to it. The space owner wraps it to the recipient's ML-KEM-1024 key with associated data binding subject and command and attaches it under meta space/key. The recipient verifies the chain, decapsulates and opens the blob.

The wrap is ML-KEM-1024 -> HKDF-SHA256 -> AES-256-GCM, with associated data subject|command, so a wrapped key lifted into another grant will not open. The recipient publishes its encapsulation key as a DID service entry of type SpaceEncapsulationKey, and both did:bio and did:key resolve from the identifier itself, so checking a chain costs no round trip. There is no custody service and no KMS in the path. Whoever holds the delegation and the decapsulation key can read, and nobody else.

See Privacy Controls for the storage side view of the same envelope.

Post quantum principals

pq-ucan treats ML-DSA, FIPS 204, as an ordinary algorithm rather than a fork. It has a did:key prefix, the registered multicodec mldsa-87-pub with code 0x1212, a varsig header, a signer and a verifier, and nothing else in the crate knows it is post quantum. A chain may mix principals freely. A did:bio researcher can delegate to an ML-DSA-87 device key that redelegates to an Ed25519 colleague.

Principal`did:key`Where it appears
Ed25519z6Mk...wallets, browser session keys
P-256 / secp256k1zDn... / zQ3s...interoperability with other UCAN implementations
ML-DSA-44 / 65 / 87over 0x1210 / 0x1211 / 0x1212post quantum device and service keys
did:bioresolved by pq-spaceresearcher and asset identities

Two caveats apply. The ML-DSA varsig headers are provisional until the varsig registry assigns tags. They are self describing and intended for deployments that control both ends, which this one does. What the platform signs with today is Ed25519, because that is what wallets sign with. The post quantum path is there for the keys that need it.

Best practices

  • Issue one narrow token per share. Revoking it affects exactly one recipient. A * resource with * actions grants everything the issuer owns until it expires.
  • Keep invocations short and delegations longer. An invocation is a single act and lives a minute. A delegation is a standing grant. A week is plenty for a collaborator, and it can always be reissued.
  • Keep tokens out of URLs. A delegation is a few hundred bytes with an Ed25519 issuer and several kilobytes with an ML-DSA one. Put it in headers or a request body, never in a query string.

Resources