Skip to content
Sonr Crypto
Esc
navigateopen⌘Jpreview
On this page

UCAN Capability Tokens

JWT-based User-Controlled Authorization Network tokens signed by an MPC enclave — capabilities, attenuation, delegation chains, templates, and the authorization checks that are not implemented.

github.com/sonr-io/crypto/ucan implements UCAN — capability tokens where authority flows from a key rather than from a server-side ACL. A token is a JWT whose issuer (iss) is a did:key, whose audience (aud) is the recipient’s DID, and whose att claim is a list of attenuations: (capability, resource) pairs. The holder of a token can mint a new token that grants a subset of its own authority to someone else, attaching the parent token as a proof in prf. Verification walks that chain back to a root the verifier trusts.

Tokens carry the UCAN version in a ucv JWT header. In this package that value is the literal "0.9.0", written inline in ucan/source.go; the only exported constant naming it is spec.UCANVersion in mpc/spec.

Reach for this when you need offline-verifiable, expiring, narrowable authorization derived from a key you control.

Read this first

The capability model

Two interfaces carry the whole model.

type Capability interface {
	GetActions() []string          // the actions this capability grants
	Grants(abilities []string) bool // does it grant all of these?
	Contains(other Capability) bool // does it subsume another capability?
	String() string
}

type Resource interface {
	GetScheme() string          // "ipfs", "did", "dwn", "service", ...
	GetValue() string           // the path/identifier
	GetURI() string             // the full "scheme://value"
	Matches(other Resource) bool // equivalence, by URI
}

type Attenuation struct {
	Capability Capability `json:"can"`
	Resource   Resource   `json:"with"`
}

AttenuationList is []Attenuation with query helpers:

PropType
Contains?func(resourceURI string) bool

Is there any attenuation whose resource URI matches exactly?

Typefunc(resourceURI string) bool
GetCapabilitiesForResource?func(resourceURI string) []Capability

All capabilities attached to that exact URI.

Typefunc(resourceURI string) []Capability
CanPerform?func(resourceURI string, actions []string) bool

Does any capability on that URI grant every one of these actions?

Typefunc(resourceURI string, actions []string) bool
IsSubsetOf?func(parent AttenuationList) bool

Every child attenuation must be matched by a parent whose resource Matches and whose capability Contains it.

Typefunc(parent AttenuationList) bool

Attenuation

Attenuation is the invariant that makes UCAN safe to hand around: a delegated token may only narrow its parent’s authority, never widen it. IsSubsetOf is the check:

parent := ucan.AttenuationList{
	ucan.CreateMultiAttenuation([]string{"read", "write", "delete"}, "service://api"),
}
child := ucan.AttenuationList{
	ucan.CreateSimpleAttenuation("read", "service://api"),
}

child.IsSubsetOf(parent) // true  — narrower
parent.IsSubsetOf(child) // false — wider

The rule composes: for every attenuation in the child list there must exist a parent attenuation whose Resource.Matches is true and whose Capability.Contains is true. Resource matching is plain URI string equality (SimpleResource.Matches), so there is no prefix or wildcard matching at the resource level — only at the action level, via "*".

Capability types

Every type below implements Capability. The module-specific ones exist so that a verifier can pick the right caveat and serialization path from the resource scheme.

Type Shape Grants semantics
SimpleCapability {Action string} Grants exactly its one action
MultiCapability {Actions []string} Grants every requested action present in the set
VaultCapability Action, Actions, VaultAddress, Caveats, EnclaveDataCID, Metadata Vault operations; JSON tags can/vault/cavs
DIDCapability Action, Actions, Caveats, Metadata DID document operations
DWNCapability Action, Actions, Caveats, Metadata Decentralized Web Node records
DEXCapability plus MaxAmount string Swap/liquidity operations with an amount cap
CrossModuleCapability {Modules map[string]Capability} Composes per-module capabilities
GaslessCapability embeds Capability, plus AllowGasless bool, GasLimit uint64 Decorator; adds SupportsGasless() and GetGasLimit()

GetActions() on the module types returns Actions when non-empty and []string{Action} otherwise; Grants short-circuits to true when Action == "*".

Resources mirror them, each embedding SimpleResource: VaultResource (VaultAddress, EnclaveDataCID), VaultResourceExt, DIDResource (DIDMethod, DIDSubject), DWNResource (RecordType, Protocol, Owner), DEXResource (PoolID, AssetPair, OrderID), and ServiceResource (ServiceID, Domain, plus SupportsDelegate()).

Constructors

PropType
CreateSimpleAttenuation?func(action, resourceURI string) Attenuation

SimpleCapability + a SimpleResource parsed from the URI.

Typefunc(action, resourceURI string) Attenuation
CreateMultiAttenuation?func(actions []string, resourceURI string) Attenuation

MultiCapability + SimpleResource.

Typefunc(actions []string, resourceURI string) Attenuation
CreateVaultAttenuation?func(actions []string, enclaveDataCID, vaultAddress string) Attenuation

MultiCapability + VaultResource with scheme "ipfs" and URI "ipfs://<cid>".

Typefunc(actions []string, enclaveDataCID, vaultAddress string) Attenuation
CreateDIDAttenuation?func(actions []string, didPattern string, caveats []string) Attenuation

DIDCapability + DIDResource with URI "did:<pattern>".

Typefunc(actions []string, didPattern string, caveats []string) Attenuation
CreateDWNAttenuation?func(actions []string, recordPattern string, caveats []string) Attenuation

DWNCapability + DWNResource.

Typefunc(actions []string, recordPattern string, caveats []string) Attenuation
CreateDEXAttenuation?func(actions []string, poolPattern string, caveats []string, maxAmount string) Attenuation

DEXCapability + DEXResource.

Typefunc(actions []string, poolPattern string, caveats []string, maxAmount string) Attenuation
CreateServiceAttenuation?func(actions []string, serviceID, domain string) Attenuation

MultiCapability + ServiceResource with URI "service://<id>".

Typefunc(actions []string, serviceID, domain string) Attenuation
NewCapability?func(issuer, resource string, abilities []string) (Attenuation, error)

MultiCapability + SimpleResource with scheme "generic". The issuer argument is IGNORED and the error is always nil.

Typefunc(issuer, resource string, abilities []string) (Attenuation, error)
VaultAttenuationConstructor?func(m map[string]any) (Attenuation, error)

Builds a vault attenuation from a decoded claim map, running ValidateVaultCapability first.

Typefunc(m map[string]any) (Attenuation, error)

The Token type

type Token struct {
	Raw          string        `json:"raw"`
	Issuer       string        `json:"iss"`
	Audience     string        `json:"aud"`
	ExpiresAt    int64         `json:"exp,omitempty"`
	NotBefore    int64         `json:"nbf,omitempty"`
	Attenuations []Attenuation `json:"att"`
	Proofs       []Proof       `json:"prf,omitempty"`
	Facts        []Fact        `json:"fct,omitempty"`
}

type Proof string             // a JWT string or a CID
type Fact struct{ Data json.RawMessage `json:"data"` }

Raw is the encoded JWT when the token came from a verifier or a signing builder, and "" when it came from TokenBuilder, which does not sign.

TokenBuilder

TokenBuilder and TokenBuilderInterface (CreateOriginToken, CreateDelegatedToken) live in ucan/stubs.go and are exactly what the filename says: they assemble a *Token struct with Raw: "" and no signature. CreateDelegatedToken copies parentToken.Raw into Proofs if it is non-empty and sets Audience: parentToken.Issuer.

They exist because NewVaultAdminToken(builder TokenBuilderInterface, vaultOwnerDID, vaultAddress, enclaveDataCID string, exp time.Time) takes the interface. Pass an MPCTokenBuilder-backed implementation if you need a signed result; &TokenBuilder{} gives you an unsigned struct.

MPC signing and verification

This is the path with real cryptography. MPCSigningMethod plugs an mpc.Enclave into golang-jwt/jwt/v5:

func NewMPCSigningMethod(name string, enclave mpc.Enclave) *MPCSigningMethod
func (m *MPCSigningMethod) Alg() string  // returns m.Name; "MPC256" everywhere in this package
func (m *MPCSigningMethod) Sign(signingString string, key any) ([]byte, error)
func (m *MPCSigningMethod) Verify(signingString string, signature []byte, key any) error

Sign computes sha256.Sum256(signingString) and passes that digest to enclave.Sign, which hashes again with SHA3-256 internally. Verify does the mirror image via enclave.Verify.

Builders and validators

PropType
NewMPCTokenBuilder?func(enclave mpc.Enclave) (*MPCTokenBuilder, error)

Errors if !enclave.IsValid(). Derives the issuer DID and address from enclave.PubKeyBytes().

Typefunc(enclave mpc.Enclave) (*MPCTokenBuilder, error)
MPCTokenBuilder.CreateOriginToken?func(audienceDID string, att []Attenuation, facts []Fact, notBefore, expiresAt time.Time) (*Token, error)

Root token: no proofs.

Typefunc(audienceDID string, att []Attenuation, facts []Fact, notBefore, expiresAt time.Time) (*Token, error)
MPCTokenBuilder.CreateDelegatedToken?func(parent *Token, audienceDID string, att []Attenuation, facts []Fact, notBefore, expiresAt time.Time) (*Token, error)

Attaches the parent as a proof. Does NOT check the subset property — see the callout below.

Typefunc(parent *Token, audienceDID string, att []Attenuation, facts []Fact, notBefore, expiresAt time.Time) (*Token, error)
MPCTokenBuilder.CreateVaultCapabilityToken?func(audienceDID, vaultAddress, enclaveDataCID string, actions []string, expiresAt time.Time) (*Token, error)

Convenience origin token carrying a single vault attenuation.

Typefunc(audienceDID, vaultAddress, enclaveDataCID string, actions []string, expiresAt time.Time) (*Token, error)
MPCTokenBuilder.GetIssuerDID?func() string

The did:key derived from the enclave public key.

Typefunc() string
MPCTokenBuilder.GetAddress?func() string

keys.DID.Address() — a truncated hex prefix, not a chain address.

Typefunc() string
NewMPCCapabilityBuilder?func(enclave mpc.Enclave) (*MPCCapabilityBuilder, error)

Emits vault attenuations: CreateVaultAdminCapability, CreateVaultReadOnlyCapability, CreateVaultSigningCapability, CreateCustomCapability.

Typefunc(enclave mpc.Enclave) (*MPCCapabilityBuilder, error)
NewMPCKeyshareSource?func(enclave mpc.Enclave) (KeyshareSource, error)

The higher-level source interface — see below.

Typefunc(enclave mpc.Enclave) (KeyshareSource, error)

KeyshareSource bundles identity and token minting over one enclave:

type KeyshareSource interface {
	Address() string
	Issuer() string
	ChainCode() ([]byte, error)
	OriginToken() (*Token, error)
	SignData(data []byte) ([]byte, error)
	VerifyData(data []byte, sig []byte) (bool, error)
	Enclave() mpc.Enclave

	NewOriginToken(audienceDID string, att []Attenuation, fct []Fact, notBefore, expires time.Time) (*Token, error)
	NewAttenuatedToken(parent *Token, audienceDID string, att []Attenuation, fct []Fact, nbf, exp time.Time) (*Token, error)
}

ChainCode() signs the address string with the enclave. Because DKLs18 ECDSA signing is randomized, ChainCode() returns different 32 bytes on every call despite the doc comment calling it deterministic — measured directly: two successive calls on the same source disagree. Treat it as a fresh signature, not a derivation.

Verification plumbing

type DIDResolver interface {
	ResolveDIDKey(ctx context.Context, did string) (keys.DID, error)
}
Resolver Behaviour
StringDIDResolver{} keys.Parse(didStr) — pure decode, no network
MPCDIDResolver (NewMPCDIDResolver(enclave, fallback)) Short-circuits its own enclave-derived DID; otherwise delegates to fallback, or keys.Parse if fallback is nil

Verifier is the general path:

PropType
NewVerifier?func(didResolver DIDResolver) *Verifier

Constructs a verifier over a DID resolver.

Typefunc(didResolver DIDResolver) *Verifier
VerifyToken?func(ctx, tokenString string) (*Token, error)

jwt.Parse with a resolver-backed key func, then parses att/prf/fct and checks iss, aud, at least one attenuation, nbf and exp.

Typefunc(ctx, tokenString string) (*Token, error)
VerifyCapability?func(ctx, tokenString, resource string, abilities []string) (*Token, error)

VerifyToken plus: some attenuation's resource URI equals `resource` exactly and its capability Grants all `abilities`.

Typefunc(ctx, tokenString, resource string, abilities []string) (*Token, error)
VerifyDelegationChain?func(ctx, tokenString string) error

Verifies the token, then every JWT in Proofs, then the delegation relationship between each pair.

Typefunc(ctx, tokenString string) error

MPCVerifier and MPCTokenValidator layer on top:

func NewMPCVerifier(enclave mpc.Enclave) *MPCVerifier
func (v *MPCVerifier) VerifyMPCToken(ctx context.Context, tokenString string) (*Token, error)

func NewMPCTokenValidator(enclave mpc.Enclave, enableEnclaveValidation bool) *MPCTokenValidator
func (v *MPCTokenValidator) ValidateTokenForResource(ctx, tokenString, resourceURI string, requiredAbilities []string) (*Token, error)
func (v *MPCTokenValidator) ValidateTokenForVaultOperation(ctx, tokenString, enclaveDataCID, requiredAction, vaultAddress string) (*Token, error)

ValidateTokenForVaultOperation is the most complete check in the package, in five ordered steps: verify the token, ValidateVaultTokenCapability, optionally match the enclave-data CID, optionally match the vault address, and finally VerifyDelegationChain if Proofs is non-empty. The two “optionally” steps run only when enableEnclaveValidation was true at construction — pass true unless you know why not.

Signature helpers

PropType
SupportedSigningMethods?func() []jwt.SigningMethod

RS256, RS384, RS512, EdDSA. Note: no ECDSA and no MPC256.

Typefunc() []jwt.SigningMethod
ValidateSignature?func(tokenString string, verifyKey any) error

Parses and validates the signature against a supplied key.

Typefunc(tokenString string, verifyKey any) error
ExtractUnsignedToken?func(tokenString string) (string, error)

The "header.payload" prefix — the exact bytes that were signed.

Typefunc(tokenString string) (string, error)
ExtractSignature?func(tokenString string) ([]byte, error)

The decoded third segment.

Typefunc(tokenString string) ([]byte, error)
ExtractSignatureInfo?func(tokenString string, verifyKey any) (*SignatureInfo, error)

Algorithm, key type, signing string, signature, and validity in one struct.

Typefunc(tokenString string, verifyKey any) (*SignatureInfo, error)
GetHashAlgorithmForMethod?func(method jwt.SigningMethod) (crypto.Hash, error)

The crypto.Hash a signing method expects.

Typefunc(method jwt.SigningMethod) (crypto.Hash, error)
CreateHasher?func(hashAlg crypto.Hash) (hash.Hash, error)

Instantiates that hash.

Typefunc(hashAlg crypto.Hash) (hash.Hash, error)
VerifyEd25519Signature?func(signingString string, signature []byte, publicKey ed25519.PublicKey) error

Raw Ed25519 verification over the signing string.

Typefunc(signingString string, signature []byte, publicKey ed25519.PublicKey) error
VerifyRSASignature?func(signingString string, signature []byte, publicKey *rsa.PublicKey, hashAlg crypto.Hash) error

Raw RSA verification.

Typefunc(signingString string, signature []byte, publicKey *rsa.PublicKey, hashAlg crypto.Hash) error
NewSigningValidator?func() *SigningValidator

Allows every method in SupportedSigningMethods. ValidateSigningMethod and ValidateTokenSignature.

Typefunc() *SigningValidator
NewKeyValidator?func() *KeyValidator

ValidateEd25519PublicKey and ValidateRSAPublicKey.

Typefunc() *KeyValidator

SecurityConfig

PropType
AllowedSigningMethods[]jwt.SigningMethod

Permitted JWT algorithms.

Type[]jwt.SigningMethod
DefaultSupportedSigningMethods() — RS256, RS384, RS512, EdDSA
MinRSAKeySizeint

Smallest accepted RSA modulus in bits. ValidateSecurityConfig rejects anything below 1024.

Typeint
Default2048
MaxRSAKeySizeint

Largest accepted RSA modulus. Must be >= MinRSAKeySize and <= 16384.

Typeint
Default8192
RequireSecureAlgsbool

Marks the config as rejecting weak algorithms.

Typebool
Defaulttrue

RestrictiveSecurityConfig() narrows those to {RS256, EdDSA}, MinRSAKeySize: 3072, MaxRSAKeySize: 4096, RequireSecureAlgs: true. ValidateSecurityConfig(config) enforces the bounds noted above.

Templates and policy

CapabilityTemplate is an allow-list of actions per resource scheme, plus lifetime bounds.

PropType
AllowedActionsmap[string][]string

resource scheme -> permitted actions. A scheme that is ABSENT from the map is allowed unconditionally.

Typemap[string][]string
Defaultempty map
DefaultExpirationtime.Duration

Used by GetDefaultExpirationTime().

Typetime.Duration
Default24h
MaxExpirationtime.Duration

ValidateExpiration rejects an exp further out than this.

Typetime.Duration
Default720h (30 days)
tpl := ucan.NewCapabilityTemplate()
tpl.AddAllowedActions("service", []string{"read", "write"})

err := tpl.ValidateAttenuation(ucan.CreateSimpleAttenuation("delete", "service://api"))
// -> "action delete not allowed for resource type service"

err = tpl.ValidateExpiration(tpl.GetDefaultExpirationTime()) // nil

ValidateExpiration treats expiresAt == 0 as “no expiration” and returns nil; a past timestamp errors, and one beyond MaxExpiration errors. "*" in an attenuation is only accepted if "*" is itself in the allow-list for that scheme.

Prebuilt templates, each a NewCapabilityTemplate() with one or two schemes populated:

Function Schemes populated
StandardVaultTemplate() ipfs, vault
StandardServiceTemplate() service, https, http
StandardDIDTemplate() did
StandardDWNTemplate() dwn
StandardDEXTemplate() dex
EnhancedServiceTemplate() service, with delegation actions

StandardTemplate is a package-level var populated in ucan/jwt.go’s init() with actions for vault, service, did, dwn, dex, pool and svc. It is the template that VerifyJWTToken and VerifyModuleJWTToken validate against.

Vault and IPFS integration

Vault capabilities address an enclave backup stored in IPFS, so the resource URI is ipfs://<CID>.

PropType
VaultCapabilitySchema?z.Struct

A zog schema requiring `can` from a fixed action set, `with` as a valid ipfs:// URI, a non-empty `vault`, and optional `actions`/`cavs`.

Typez.Struct
ValidateVaultCapability?func(att map[string]any) error

Runs a decoded attenuation map through VaultCapabilitySchema.

Typefunc(att map[string]any) error
ValidateVaultTokenCapability?func(token *Token, enclaveDataCID, requiredAction string) error

Requires requiredAction in {read, write, sign, export, import, delete} and an attenuation on ipfs://<cid> granting it.

Typefunc(token *Token, enclaveDataCID, requiredAction string) error
GetEnclaveDataCID?func(token *Token) (string, error)

The first attenuation resource with an ipfs:// prefix, minus the prefix.

Typefunc(token *Token) (string, error)
ValidateIPFSCID?func(value *string, ctx z.Ctx) bool

zog TestFunc: requires an ipfs:// prefix and a well-formed CID.

Typefunc(value *string, ctx z.Ctx) bool
ValidateEnclaveDataCIDIntegrity?func(enclaveDataCID string, enclaveData []byte) error

Recomputes the CID over the bytes and compares. Errors on an empty CID, empty data, a malformed CID, or a mismatch.

Typefunc(enclaveDataCID string, enclaveData []byte) error
ValidateEnclaveDataIntegrity?func(enclaveData *mpc.EnclaveData, expectedCID string) error

Structural checks on the EnclaveData (non-nil, non-empty PubBytes) before the CID comparison.

Typefunc(enclaveData *mpc.EnclaveData, expectedCID string) error

VaultAdminAction is the constant "vault/admin". Note that the vault schema’s can set uses slash-prefixed values (vault/read, vault/sign, …) while ValidateVaultTokenCapability and the templates use bare ones (read, sign, …); they are different vocabularies applied at different layers.

TestValidateEnclaveDataCIDIntegrity in ucan/ucan_test.go is the one genuinely end-to-end test in the package, covering empty-CID, empty-data, malformed-CID, matching and mismatching cases.

End-to-end example

Enclave → issuer DID → signed origin token → narrowed delegated token → manual signature check. This uses KeyshareSource, the delegation API that actually enforces attenuation. Every line was run against this package; the printed values below are the observed output.

package main

import (
	"crypto/sha256"
	"fmt"
	"time"

	"github.com/sonr-io/crypto/keys"
	"github.com/sonr-io/crypto/mpc"
	"github.com/sonr-io/crypto/ucan"
)

func main() {
	enclave, err := mpc.NewEnclave()
	if err != nil {
		panic(err)
	}

	// KeyshareSource enforces the subset property on delegation.
	src, err := ucan.NewMPCKeyshareSource(enclave)
	if err != nil {
		panic(err)
	}
	fmt.Println("issuer:", src.Issuer()) // did:key:z...

	// The delegate's identity — here just another enclave's DID.
	delegateEnclave, err := mpc.NewEnclave()
	if err != nil {
		panic(err)
	}
	delegateDID, err := keys.NewFromMPCPubKey(delegateEnclave.PubKeyBytes())
	if err != nil {
		panic(err)
	}

	now := time.Now()

	// Origin token: broad authority over one service resource.
	origin, err := src.NewOriginToken(
		delegateDID.String(),
		[]ucan.Attenuation{
			ucan.CreateMultiAttenuation([]string{"read", "write", "delete"}, "service://api"),
		},
		nil, now, now.Add(time.Hour),
	)
	if err != nil {
		panic(err)
	}

	// Widening is rejected at issuance.
	_, err = src.NewAttenuatedToken(origin, delegateDID.String(),
		[]ucan.Attenuation{
			ucan.CreateMultiAttenuation([]string{"read", "write", "delete", "admin"}, "service://api"),
		},
		nil, now, now.Add(time.Hour))
	fmt.Println("widening rejected:", err)
	// -> "scope of ucan attenuations must be less than its parent"

	// Narrowing is accepted: read only, half the lifetime.
	delegated, err := src.NewAttenuatedToken(origin, delegateDID.String(),
		[]ucan.Attenuation{ucan.CreateSimpleAttenuation("read", "service://api")},
		nil, now, now.Add(30*time.Minute))
	if err != nil {
		panic(err)
	}
	fmt.Println("proofs:", len(delegated.Proofs)) // 1 — the origin token

	// The attenuation invariant, checked locally.
	child := ucan.AttenuationList(delegated.Attenuations)
	parent := ucan.AttenuationList(origin.Attenuations)
	fmt.Println("narrows:", child.IsSubsetOf(parent)) // true
	fmt.Println("widens:", parent.IsSubsetOf(child))  // false
	fmt.Println("can read:", child.CanPerform("service://api", []string{"read"}))     // true
	fmt.Println("can delete:", child.CanPerform("service://api", []string{"delete"})) // false

	// Signature verification, done directly against the public key.
	unsigned, err := ucan.ExtractUnsignedToken(delegated.Raw)
	if err != nil {
		panic(err)
	}
	sig, err := ucan.ExtractSignature(delegated.Raw)
	if err != nil {
		panic(err)
	}
	digest := sha256.Sum256([]byte(unsigned))
	ok, err := mpc.VerifyWithPubKey(enclave.PubKeyBytes(), digest[:], sig)
	fmt.Println("signature valid:", ok, err) // true <nil>
}

Not actually implemented

Each item below was verified by reading the named source file and then confirmed by running it. Delegation enforcement is covered separately, under Only KeyshareSource enforces attenuation at issuance.

Measured against this package:

Sequence VerifyJWTToken after revoking
Issue, wait 1.5 s, RevokeCapability nilstill accepted
Issue and RevokeCapability in the same second "token has been revoked"
Issue with a 2 h duration, RevokeCapability (which uses 1 h) nilstill accepted

Next

Last updated on September 2, 2026

Was this page helpful?