UCAN Authorization
Most authorization systems answer "may this user do this?" by consulting a database of permissions. That database is a central point of trust, a central point of failure, and the reason sharing anything requires an admin. UCAN (User Controlled Authorization Network) inverts the model: the permission is the token. A signed capability says who granted what to whom, for how long, and any party holding the public key can verify it, offline, without asking a server's opinion.
Delegation falls out naturally: a token holder can re-grant a subset of what they hold, forming verifiable chains, which is how a lab shares data without anyone handing over credentials. And every token Ekayana issues is signed with ML-DSA-87 (FIPS 204), so the authorization layer is post-quantum end to end.
Core Concepts
Capabilities
A capability is a pair - a resource and the action permitted on it:
{ "with": "ipfs://bafybeigdyr...", "can": "read" }Resource URIs are namespaced by scheme:
| Resource URI | Meaning |
|---|---|
ipfs://<cid> | A stored file or payload |
did:bio:<network>:<id> | A DID document |
workspace:<id> | A workspace |
workspace:<id>/page:<id> | A page inside a workspace |
Actions are read, write, delete, share, update, admin, or *.
Token structure
An Ekayana capability token is three dot-separated parts:
ucan.v1.<base64url(payload)>.<base64url(signature)>The payload carries every field a verifier trusts, and the ML-DSA-87 signature covers all of it:
{
"id": "6f1c...", // token id (primary key)
"iss": "did:key:z...", // issuer - the service DID
"aud": "did:bio:devnet:2T6zLF...", // audience
"iat": 1753228800, // issued at
"exp": 1753315200, // expires at
"cap": [["ipfs://bafybeigdyr...", "read"]],
"prf": "3a0e..." // parent token id, when delegated
}Why capabilities live inside the signed payload. A verifier must never read permissions from a part of the token the holder can edit. Becausecapis covered by the signature, wideningreadto*invalidates the token - the forgery is rejected before any field is trusted.
Signature suite
| Property | Value |
|---|---|
| Algorithm | ML-DSA-87 (FIPS 204) |
| Public key | 2592 bytes |
| Signature | 4627 bytes |
| Backend | aws-lc-rs (AWS-LC; FIPS-validated module available) |
| Issuer DID | did:key:z... over multicodec mldsa-87-pub (0x1212) |
Verification needs only the public key. The service's signing identity is derived deterministically from a 32-byte seed (MLDSA_SEED), so every replica reproduces the same issuer DID.
Validation pipeline
Every authorization decision runs these steps in order - the cryptography gates everything that follows:
1. Parse ucan.v1.<payload>.<signature>
2. Verify signature ML-DSA-87 over "ucan.v1.<payload>" - fail => reject
3. Look up token id in the registry - missing => reject
4. Check revocation flag - revoked => reject
5. Check expiry (signed exp AND stored exp) - expired => reject
6. Walk the proof chain to the root - broken => reject
7. Match the requested (resource, action) against the signed capabilitiesSteps 3-6 are why a valid signature alone is not sufficient: revocation and delegation state live in the registry, so a leaked token can always be killed.
Bio-DID-Seq capability namespaces
| Namespace | Description | Example actions |
|---|---|---|
ipfs | Stored research payloads | read, write, delete |
did | DID management | read, update |
workspace | Collaborative workspaces | read, write, admin |
kg | Knowledge graph | query, insert |
bioagents | AI processing | process, query |
Issuing tokens
Tokens are minted by the platform API. The caller states the audience, the capabilities to grant, and a lifetime; the service signs the resulting payload.
curl -X POST https://api.ekayana.com/api/ucan/issue -H "Authorization: Bearer $AUTH_TOKEN" -H "Content-Type: application/json" -d '{
"audience": "did:bio:devnet:2T6zLFvMx7NJac5qQtiKTaPhMwHLkwKETWjUK1yKv4tc",
"capabilities": [
{ "with": "ipfs://bafybeigdyrexample", "can": "read" }
],
"expiration": 604800
}'{
"token": "ucan.v1.eyJpZCI6IjZmMWM....gAt3xK9...",
"expires_at": 1753833600
}The same call from JavaScript:
const res = await fetch('https://api.ekayana.com/api/ucan/issue', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
audience: recipientDid,
capabilities: [{ with: `ipfs://${cid}`, can: 'read' }],
expiration: 7 * 24 * 3600,
}),
});
const { token: ucan, expires_at } = await res.json();Using a token
The recipient presents the UCAN alongside their own session token - the session token says who they are, the UCAN says what they may do. The gateway validates the capability against the requested resource and action before serving any bytes.
curl https://api.ekayana.com/api/download/bafybeigdyrexample -H "Authorization: Bearer $TOKEN" -H "X-UCAN-Token: ucan.v1.eyJpZCI6..."Delegation and attenuation
A token holder may delegate a subset of what they hold. The service enforces attenuation at issue time: a capability that exceeds the parent is rejected with Capability (...) exceeds parent token permissions.
Each delegated token records its parent (prf). Validation walks that chain to the root - bounded at 10 hops - and rejects the token if any ancestor is missing, revoked, or expired. Revoking UCAN #1 therefore invalidates #2 and #3 instantly, without touching them.
Capability matching rules
A stored capability grants a requested (resource, action) when:
| Rule | Capability | Grants |
|---|---|---|
| Exact | ipfs://bafyA + read | ipfs://bafyA read |
| Action wildcard | ipfs://bafyA + * | any action on that CID |
| Resource wildcard | * + read | read on any resource |
| Type wildcard | ipfs://* + read | read on any CID |
| Hierarchical | workspace:w1 + write | workspace:w1/page:p2 write |
| Admin | workspace:w1 + admin | read, write, delete, share, update |
Revocation
curl -X POST https://api.ekayana.com/api/ucan/revoke -H "Authorization: Bearer $AUTH_TOKEN" -H "Content-Type: application/json" -d '{ "token": "ucan.v1.eyJpZCI6..." }'Only the issuing user may revoke. Revocation is immediate and permanent, and it cascades to every token delegated beneath it.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST | /api/ucan/issue | Mint a signed capability token |
POST | /api/ucan/validate | Verify a token and return its capabilities |
POST | /api/ucan/revoke | Revoke a token you issued |
GET | /api/ucan/issued | Tokens you have issued |
GET | /api/ucan/received | Tokens delegated to your DID |
GET | /api/ucan/token/{hash} | Fetch a token by id |
/api/ucan/validate is public - verification requires only the service public key, so any party can independently check a token.
Private spaces: capability and decryption together
For encrypted payloads, the capability alone is not enough - the recipient also needs the content key. Ekayana wraps that key to the recipient's ML-KEM-1024 (FIPS 203) public key and carries it inside the delegation itself, under the meta key space/key:
ML-KEM-1024 encapsulation -> HKDF-SHA256 -> AES-256-GCM(content key)The wrap is bound by AAD to the delegation's subject and command, so a wrapped key copied into a different grant will not open. Whoever holds the delegation and the ML-KEM decapsulation key can read the blob, replacing custody services (KMS, Lit Protocol) with pure cryptographic delegation that is post-quantum on both the signature and the encryption side.
See Privacy Controls for the storage-side view.
Post-quantum principals
Ekayana contributes ML-DSA-87 support to the UCAN ecosystem:
did:keyprincipals for ML-DSA-87, encoded over multicodecmldsa-87-pub(0x1212) -did:key:z+ base58btc(0x92 0x24|| key).- Varsig configuration for ML-DSA-44/65/87, so UCAN envelopes carry a post-quantum signature suite.
- Mixed principals, letting one delegation run from a
did:bioresearcher identity to a post-quantum device key - classical and PQ identities interoperate in a single chain.
Best practices
Token lifetime
| Use | Lifetime |
|---|---|
| Sensitive operations | 5 minutes |
| Interactive session | 24 hours |
| Service account | 30 days |
Capability scoping
// Good: specific and minimal
{ "with": "ipfs://bafybeigdyrexample", "can": "read" }
// Bad: overly broad - grants everything, forever
{ "with": "*", "can": "*" }Prefer one narrowly scoped token per share over a broad token reused everywhere: revoking the former affects exactly one recipient.