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:
Contains?func(resourceURI string) bool
Is there any attenuation whose resource URI matches exactly?
func(resourceURI string) boolGetCapabilitiesForResource?func(resourceURI string) []Capability
All capabilities attached to that exact URI.
func(resourceURI string) []CapabilityCanPerform?func(resourceURI string, actions []string) bool
Does any capability on that URI grant every one of these actions?
func(resourceURI string, actions []string) boolIsSubsetOf?func(parent AttenuationList) bool
Every child attenuation must be matched by a parent whose resource Matches and whose capability Contains it.
func(parent AttenuationList) boolAttenuation
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
CreateSimpleAttenuation?func(action, resourceURI string) Attenuation
SimpleCapability + a SimpleResource parsed from the URI.
func(action, resourceURI string) AttenuationCreateMultiAttenuation?func(actions []string, resourceURI string) Attenuation
MultiCapability + SimpleResource.
func(actions []string, resourceURI string) AttenuationCreateVaultAttenuation?func(actions []string, enclaveDataCID, vaultAddress string) Attenuation
MultiCapability + VaultResource with scheme "ipfs" and URI "ipfs://<cid>".
func(actions []string, enclaveDataCID, vaultAddress string) AttenuationCreateDIDAttenuation?func(actions []string, didPattern string, caveats []string) Attenuation
DIDCapability + DIDResource with URI "did:<pattern>".
func(actions []string, didPattern string, caveats []string) AttenuationCreateDWNAttenuation?func(actions []string, recordPattern string, caveats []string) Attenuation
DWNCapability + DWNResource.
func(actions []string, recordPattern string, caveats []string) AttenuationCreateDEXAttenuation?func(actions []string, poolPattern string, caveats []string, maxAmount string) Attenuation
DEXCapability + DEXResource.
func(actions []string, poolPattern string, caveats []string, maxAmount string) AttenuationCreateServiceAttenuation?func(actions []string, serviceID, domain string) Attenuation
MultiCapability + ServiceResource with URI "service://<id>".
func(actions []string, serviceID, domain string) AttenuationNewCapability?func(issuer, resource string, abilities []string) (Attenuation, error)
MultiCapability + SimpleResource with scheme "generic". The issuer argument is IGNORED and the error is always nil.
func(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.
func(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
NewMPCTokenBuilder?func(enclave mpc.Enclave) (*MPCTokenBuilder, error)
Errors if !enclave.IsValid(). Derives the issuer DID and address from enclave.PubKeyBytes().
func(enclave mpc.Enclave) (*MPCTokenBuilder, error)MPCTokenBuilder.CreateOriginToken?func(audienceDID string, att []Attenuation, facts []Fact, notBefore, expiresAt time.Time) (*Token, error)
Root token: no proofs.
func(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.
func(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.
func(audienceDID, vaultAddress, enclaveDataCID string, actions []string, expiresAt time.Time) (*Token, error)MPCTokenBuilder.GetIssuerDID?func() string
The did:key derived from the enclave public key.
func() stringMPCTokenBuilder.GetAddress?func() string
keys.DID.Address() — a truncated hex prefix, not a chain address.
func() stringNewMPCCapabilityBuilder?func(enclave mpc.Enclave) (*MPCCapabilityBuilder, error)
Emits vault attenuations: CreateVaultAdminCapability, CreateVaultReadOnlyCapability, CreateVaultSigningCapability, CreateCustomCapability.
func(enclave mpc.Enclave) (*MPCCapabilityBuilder, error)NewMPCKeyshareSource?func(enclave mpc.Enclave) (KeyshareSource, error)
The higher-level source interface — see below.
func(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:
NewVerifier?func(didResolver DIDResolver) *Verifier
Constructs a verifier over a DID resolver.
func(didResolver DIDResolver) *VerifierVerifyToken?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.
func(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`.
func(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.
func(ctx, tokenString string) errorMPCVerifier 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
SupportedSigningMethods?func() []jwt.SigningMethod
RS256, RS384, RS512, EdDSA. Note: no ECDSA and no MPC256.
func() []jwt.SigningMethodValidateSignature?func(tokenString string, verifyKey any) error
Parses and validates the signature against a supplied key.
func(tokenString string, verifyKey any) errorExtractUnsignedToken?func(tokenString string) (string, error)
The "header.payload" prefix — the exact bytes that were signed.
func(tokenString string) (string, error)ExtractSignature?func(tokenString string) ([]byte, error)
The decoded third segment.
func(tokenString string) ([]byte, error)ExtractSignatureInfo?func(tokenString string, verifyKey any) (*SignatureInfo, error)
Algorithm, key type, signing string, signature, and validity in one struct.
func(tokenString string, verifyKey any) (*SignatureInfo, error)GetHashAlgorithmForMethod?func(method jwt.SigningMethod) (crypto.Hash, error)
The crypto.Hash a signing method expects.
func(method jwt.SigningMethod) (crypto.Hash, error)CreateHasher?func(hashAlg crypto.Hash) (hash.Hash, error)
Instantiates that hash.
func(hashAlg crypto.Hash) (hash.Hash, error)VerifyEd25519Signature?func(signingString string, signature []byte, publicKey ed25519.PublicKey) error
Raw Ed25519 verification over the signing string.
func(signingString string, signature []byte, publicKey ed25519.PublicKey) errorVerifyRSASignature?func(signingString string, signature []byte, publicKey *rsa.PublicKey, hashAlg crypto.Hash) error
Raw RSA verification.
func(signingString string, signature []byte, publicKey *rsa.PublicKey, hashAlg crypto.Hash) errorNewSigningValidator?func() *SigningValidator
Allows every method in SupportedSigningMethods. ValidateSigningMethod and ValidateTokenSignature.
func() *SigningValidatorNewKeyValidator?func() *KeyValidator
ValidateEd25519PublicKey and ValidateRSAPublicKey.
func() *KeyValidatorSecurityConfig
AllowedSigningMethods[]jwt.SigningMethod
Permitted JWT algorithms.
[]jwt.SigningMethodSupportedSigningMethods() — RS256, RS384, RS512, EdDSAMinRSAKeySizeint
Smallest accepted RSA modulus in bits. ValidateSecurityConfig rejects anything below 1024.
int2048MaxRSAKeySizeint
Largest accepted RSA modulus. Must be >= MinRSAKeySize and <= 16384.
int8192RequireSecureAlgsbool
Marks the config as rejecting weak algorithms.
booltrueRestrictiveSecurityConfig() 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.
AllowedActionsmap[string][]string
resource scheme -> permitted actions. A scheme that is ABSENT from the map is allowed unconditionally.
map[string][]stringempty mapDefaultExpirationtime.Duration
Used by GetDefaultExpirationTime().
time.Duration24hMaxExpirationtime.Duration
ValidateExpiration rejects an exp further out than this.
time.Duration720h (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>.
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`.
z.StructValidateVaultCapability?func(att map[string]any) error
Runs a decoded attenuation map through VaultCapabilitySchema.
func(att map[string]any) errorValidateVaultTokenCapability?func(token *Token, enclaveDataCID, requiredAction string) error
Requires requiredAction in {read, write, sign, export, import, delete} and an attenuation on ipfs://<cid> granting it.
func(token *Token, enclaveDataCID, requiredAction string) errorGetEnclaveDataCID?func(token *Token) (string, error)
The first attenuation resource with an ipfs:// prefix, minus the prefix.
func(token *Token) (string, error)ValidateIPFSCID?func(value *string, ctx z.Ctx) bool
zog TestFunc: requires an ipfs:// prefix and a well-formed CID.
func(value *string, ctx z.Ctx) boolValidateEnclaveDataCIDIntegrity?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.
func(enclaveDataCID string, enclaveData []byte) errorValidateEnclaveDataIntegrity?func(enclaveData *mpc.EnclaveData, expectedCID string) error
Structural checks on the EnclaveData (non-nil, non-empty PubBytes) before the CID comparison.
func(enclaveData *mpc.EnclaveData, expectedCID string) errorVaultAdminAction 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 |
nil — still accepted |
Issue and RevokeCapability in the same second |
"token has been revoked" |
Issue with a 2 h duration, RevokeCapability (which uses 1 h) |
nil — still accepted |
Next
MPC Enclave
The signing key behind the issuer DID, and why mpc/spec should be avoided.
did:key Identifiers
How issuer and audience strings are encoded and parsed.
Security Notes
Every stub and defect in the module, in one place.
ECIES
Encrypting a payload to the holder of a key, rather than authorizing them.