From ec87d579aa3da30dd67609ee682ac95410e5c7a3 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Thu, 8 Jan 2026 15:21:07 -0500 Subject: [PATCH] feat(ucan): add delegation and invocation builders --- internal/crypto/ucan/delegation.go | 271 +++++++++++++++++++++++++++++ internal/crypto/ucan/invocation.go | 259 +++++++++++++++++++++++++++ internal/crypto/ucan/policy.go | 213 +++++++++++++++++++++++ internal/crypto/ucan/types.go | 261 +++++++++++++++++++++++++++ internal/crypto/ucan/ucan.go | 195 +++++++++++++++++++++ 5 files changed, 1199 insertions(+) create mode 100644 internal/crypto/ucan/delegation.go create mode 100644 internal/crypto/ucan/invocation.go create mode 100644 internal/crypto/ucan/policy.go create mode 100644 internal/crypto/ucan/types.go create mode 100644 internal/crypto/ucan/ucan.go diff --git a/internal/crypto/ucan/delegation.go b/internal/crypto/ucan/delegation.go new file mode 100644 index 0000000..100162d --- /dev/null +++ b/internal/crypto/ucan/delegation.go @@ -0,0 +1,271 @@ +package ucan + +import ( + "fmt" + "time" + + "github.com/MetaMask/go-did-it" + "github.com/MetaMask/go-did-it/crypto" + "github.com/ipfs/go-cid" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/pkg/policy" + "github.com/ucan-wg/go-ucan/token/delegation" +) + +// DelegationBuilder provides a fluent API for creating UCAN delegations. +// A delegation grants authority from issuer to audience to perform a command on a subject. +type DelegationBuilder struct { + issuer did.DID + audience did.DID + subject did.DID + cmd command.Command + pol policy.Policy + opts []delegation.Option + err error + isPowerline bool +} + +// NewDelegationBuilder creates a builder for constructing UCAN delegations. +func NewDelegationBuilder() *DelegationBuilder { + return &DelegationBuilder{ + pol: policy.Policy{}, + opts: make([]delegation.Option, 0), + } +} + +// Issuer sets the delegation issuer (the entity granting authority). +func (b *DelegationBuilder) Issuer(iss did.DID) *DelegationBuilder { + b.issuer = iss + return b +} + +// IssuerString parses and sets the issuer from a DID string. +func (b *DelegationBuilder) IssuerString(issStr string) *DelegationBuilder { + iss, err := did.Parse(issStr) + if err != nil { + b.err = fmt.Errorf("invalid issuer DID: %w", err) + return b + } + b.issuer = iss + return b +} + +// Audience sets the delegation audience (the entity receiving authority). +func (b *DelegationBuilder) Audience(aud did.DID) *DelegationBuilder { + b.audience = aud + return b +} + +// AudienceString parses and sets the audience from a DID string. +func (b *DelegationBuilder) AudienceString(audStr string) *DelegationBuilder { + aud, err := did.Parse(audStr) + if err != nil { + b.err = fmt.Errorf("invalid audience DID: %w", err) + return b + } + b.audience = aud + return b +} + +// Subject sets the delegation subject (the resource being delegated). +// For root delegations, subject should equal issuer. +// For powerline delegations, use Powerline() instead. +func (b *DelegationBuilder) Subject(sub did.DID) *DelegationBuilder { + b.subject = sub + b.isPowerline = false + return b +} + +// SubjectString parses and sets the subject from a DID string. +func (b *DelegationBuilder) SubjectString(subStr string) *DelegationBuilder { + sub, err := did.Parse(subStr) + if err != nil { + b.err = fmt.Errorf("invalid subject DID: %w", err) + return b + } + b.subject = sub + b.isPowerline = false + return b +} + +// Powerline creates a powerline delegation (subject = nil). +// Powerline automatically delegates all future delegations regardless of subject. +// Use with caution - this is a very powerful pattern. +func (b *DelegationBuilder) Powerline() *DelegationBuilder { + b.subject = nil + b.isPowerline = true + return b +} + +// AsRoot sets the subject equal to the issuer (root delegation). +// Root delegations are typically used to create the initial grant of authority. +func (b *DelegationBuilder) AsRoot() *DelegationBuilder { + b.subject = b.issuer + b.isPowerline = false + return b +} + +// Command sets the command being delegated. +func (b *DelegationBuilder) Command(cmd command.Command) *DelegationBuilder { + b.cmd = cmd + return b +} + +// CommandString parses and sets the command from a string. +func (b *DelegationBuilder) CommandString(cmdStr string) *DelegationBuilder { + cmd, err := command.Parse(cmdStr) + if err != nil { + b.err = fmt.Errorf("invalid command: %w", err) + return b + } + b.cmd = cmd + return b +} + +// Policy sets the policy constraints on the delegation. +func (b *DelegationBuilder) Policy(pol policy.Policy) *DelegationBuilder { + b.pol = pol + return b +} + +// ExpiresAt sets when the delegation expires. +func (b *DelegationBuilder) ExpiresAt(exp time.Time) *DelegationBuilder { + b.opts = append(b.opts, delegation.WithExpiration(exp)) + return b +} + +// ExpiresIn sets the delegation to expire after a duration from now. +func (b *DelegationBuilder) ExpiresIn(d time.Duration) *DelegationBuilder { + b.opts = append(b.opts, delegation.WithExpirationIn(d)) + return b +} + +// NotBefore sets when the delegation becomes valid. +func (b *DelegationBuilder) NotBefore(nbf time.Time) *DelegationBuilder { + b.opts = append(b.opts, delegation.WithNotBefore(nbf)) + return b +} + +// NotBeforeIn sets the delegation to become valid after a duration from now. +func (b *DelegationBuilder) NotBeforeIn(d time.Duration) *DelegationBuilder { + b.opts = append(b.opts, delegation.WithNotBeforeIn(d)) + return b +} + +// Meta adds metadata to the delegation. +func (b *DelegationBuilder) Meta(key string, value any) *DelegationBuilder { + b.opts = append(b.opts, delegation.WithMeta(key, value)) + return b +} + +// Nonce sets a custom nonce. If not called, a random 12-byte nonce is generated. +func (b *DelegationBuilder) Nonce(nonce []byte) *DelegationBuilder { + b.opts = append(b.opts, delegation.WithNonce(nonce)) + return b +} + +// Build creates the delegation token. +func (b *DelegationBuilder) Build() (*delegation.Token, error) { + if b.err != nil { + return nil, b.err + } + + if b.issuer == nil { + return nil, fmt.Errorf("issuer is required") + } + if b.audience == nil { + return nil, fmt.Errorf("audience is required") + } + if b.cmd == "" { + return nil, fmt.Errorf("command is required") + } + + if b.isPowerline { + return delegation.Powerline(b.issuer, b.audience, b.cmd, b.pol, b.opts...) + } + + // If subject not set and not powerline, default to root delegation + if b.subject == nil { + b.subject = b.issuer + } + + return delegation.New(b.issuer, b.audience, b.cmd, b.pol, b.subject, b.opts...) +} + +// BuildSealed creates and signs the delegation, returning DAG-CBOR bytes and CID. +func (b *DelegationBuilder) BuildSealed(privKey crypto.PrivateKeySigningBytes) ([]byte, cid.Cid, error) { + tkn, err := b.Build() + if err != nil { + return nil, cid.Cid{}, err + } + return tkn.ToSealed(privKey) +} + +// --- Sonr-specific Delegation Builders --- + +// NewVaultDelegation creates a delegation for vault operations. +// cmd should be one of: VaultRead, VaultWrite, VaultSign, VaultExport, VaultImport, VaultDelete, VaultAdmin, or Vault (all). +func NewVaultDelegation(issuer, audience did.DID, cmd command.Command, vaultCID string, opts ...delegation.Option) (*delegation.Token, error) { + pol := VaultPolicy(vaultCID) + return delegation.Root(issuer, audience, cmd, pol, opts...) +} + +// NewVaultReadDelegation creates a delegation to read from a specific vault. +func NewVaultReadDelegation(issuer, audience did.DID, vaultCID string, opts ...delegation.Option) (*delegation.Token, error) { + return NewVaultDelegation(issuer, audience, VaultRead, vaultCID, opts...) +} + +// NewVaultWriteDelegation creates a delegation to write to a specific vault. +func NewVaultWriteDelegation(issuer, audience did.DID, vaultCID string, opts ...delegation.Option) (*delegation.Token, error) { + return NewVaultDelegation(issuer, audience, VaultWrite, vaultCID, opts...) +} + +// NewVaultSignDelegation creates a delegation to sign with keys in a specific vault. +func NewVaultSignDelegation(issuer, audience did.DID, vaultCID string, opts ...delegation.Option) (*delegation.Token, error) { + return NewVaultDelegation(issuer, audience, VaultSign, vaultCID, opts...) +} + +// NewVaultFullAccessDelegation creates a delegation for all vault operations on a specific vault. +func NewVaultFullAccessDelegation(issuer, audience did.DID, vaultCID string, opts ...delegation.Option) (*delegation.Token, error) { + return NewVaultDelegation(issuer, audience, Vault, vaultCID, opts...) +} + +// NewDIDDelegation creates a delegation for DID operations. +// cmd should be one of: DIDCreate, DIDUpdate, DIDDeactivate, or DID (all). +func NewDIDDelegation(issuer, audience did.DID, cmd command.Command, didPattern string, opts ...delegation.Option) (*delegation.Token, error) { + pol := DIDPolicy(didPattern) + return delegation.Root(issuer, audience, cmd, pol, opts...) +} + +// NewDIDUpdateDelegation creates a delegation to update DIDs matching a pattern. +func NewDIDUpdateDelegation(issuer, audience did.DID, didPattern string, opts ...delegation.Option) (*delegation.Token, error) { + return NewDIDDelegation(issuer, audience, DIDUpdate, didPattern, opts...) +} + +// NewDIDFullAccessDelegation creates a delegation for all DID operations on DIDs matching a pattern. +func NewDIDFullAccessDelegation(issuer, audience did.DID, didPattern string, opts ...delegation.Option) (*delegation.Token, error) { + return NewDIDDelegation(issuer, audience, DID, didPattern, opts...) +} + +// NewDWNDelegation creates a delegation for DWN (Decentralized Web Node) operations. +// cmd should be one of: DWNRecordsWrite, DWNRecordsRead, DWNRecordsDelete, or DWN (all). +func NewDWNDelegation(issuer, audience did.DID, cmd command.Command, recordType string, opts ...delegation.Option) (*delegation.Token, error) { + pol := RecordTypePolicy(recordType) + return delegation.Root(issuer, audience, cmd, pol, opts...) +} + +// NewDWNReadDelegation creates a delegation to read DWN records of a specific type. +func NewDWNReadDelegation(issuer, audience did.DID, recordType string, opts ...delegation.Option) (*delegation.Token, error) { + return NewDWNDelegation(issuer, audience, DWNRecordsRead, recordType, opts...) +} + +// NewDWNWriteDelegation creates a delegation to write DWN records of a specific type. +func NewDWNWriteDelegation(issuer, audience did.DID, recordType string, opts ...delegation.Option) (*delegation.Token, error) { + return NewDWNDelegation(issuer, audience, DWNRecordsWrite, recordType, opts...) +} + +// NewRootCapabilityDelegation creates a root delegation granting all capabilities. +// Use with extreme caution - this grants full authority. +func NewRootCapabilityDelegation(issuer, audience did.DID, opts ...delegation.Option) (*delegation.Token, error) { + return delegation.Root(issuer, audience, Root, EmptyPolicy(), opts...) +} diff --git a/internal/crypto/ucan/invocation.go b/internal/crypto/ucan/invocation.go new file mode 100644 index 0000000..fa0018c --- /dev/null +++ b/internal/crypto/ucan/invocation.go @@ -0,0 +1,259 @@ +package ucan + +import ( + "fmt" + "time" + + "github.com/MetaMask/go-did-it" + "github.com/MetaMask/go-did-it/crypto" + "github.com/ipfs/go-cid" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/token/invocation" +) + +// InvocationBuilder provides a fluent API for creating UCAN invocations. +// An invocation exercises a delegated capability to perform an action. +type InvocationBuilder struct { + issuer did.DID + subject did.DID + cmd command.Command + proofs []cid.Cid + opts []invocation.Option + err error +} + +// NewInvocationBuilder creates a builder for constructing UCAN invocations. +func NewInvocationBuilder() *InvocationBuilder { + return &InvocationBuilder{ + proofs: make([]cid.Cid, 0), + opts: make([]invocation.Option, 0), + } +} + +// Issuer sets the invocation issuer (the entity invoking the capability). +func (b *InvocationBuilder) Issuer(iss did.DID) *InvocationBuilder { + b.issuer = iss + return b +} + +// IssuerString parses and sets the issuer from a DID string. +func (b *InvocationBuilder) IssuerString(issStr string) *InvocationBuilder { + iss, err := did.Parse(issStr) + if err != nil { + b.err = fmt.Errorf("invalid issuer DID: %w", err) + return b + } + b.issuer = iss + return b +} + +// Subject sets the invocation subject (the resource being acted upon). +func (b *InvocationBuilder) Subject(sub did.DID) *InvocationBuilder { + b.subject = sub + return b +} + +// SubjectString parses and sets the subject from a DID string. +func (b *InvocationBuilder) SubjectString(subStr string) *InvocationBuilder { + sub, err := did.Parse(subStr) + if err != nil { + b.err = fmt.Errorf("invalid subject DID: %w", err) + return b + } + b.subject = sub + return b +} + +// Command sets the command being invoked. +func (b *InvocationBuilder) Command(cmd command.Command) *InvocationBuilder { + b.cmd = cmd + return b +} + +// CommandString parses and sets the command from a string. +func (b *InvocationBuilder) CommandString(cmdStr string) *InvocationBuilder { + cmd, err := command.Parse(cmdStr) + if err != nil { + b.err = fmt.Errorf("invalid command: %w", err) + return b + } + b.cmd = cmd + return b +} + +// Proof adds a delegation CID to the proof chain. +// Proofs should be ordered from leaf (matching invocation's issuer as audience) +// to root delegation. +func (b *InvocationBuilder) Proof(delegationCID cid.Cid) *InvocationBuilder { + b.proofs = append(b.proofs, delegationCID) + return b +} + +// ProofString parses and adds a delegation CID from a string. +func (b *InvocationBuilder) ProofString(cidStr string) *InvocationBuilder { + c, err := cid.Parse(cidStr) + if err != nil { + b.err = fmt.Errorf("invalid proof CID: %w", err) + return b + } + b.proofs = append(b.proofs, c) + return b +} + +// Proofs sets all proofs at once, replacing any previously added. +func (b *InvocationBuilder) Proofs(cids []cid.Cid) *InvocationBuilder { + b.proofs = cids + return b +} + +// Arg adds an argument to the invocation. +func (b *InvocationBuilder) Arg(key string, value any) *InvocationBuilder { + b.opts = append(b.opts, invocation.WithArgument(key, value)) + return b +} + +// Audience sets the invocation's audience (executor if different from subject). +func (b *InvocationBuilder) Audience(aud did.DID) *InvocationBuilder { + b.opts = append(b.opts, invocation.WithAudience(aud)) + return b +} + +// AudienceString parses and sets the audience from a DID string. +func (b *InvocationBuilder) AudienceString(audStr string) *InvocationBuilder { + aud, err := did.Parse(audStr) + if err != nil { + b.err = fmt.Errorf("invalid audience DID: %w", err) + return b + } + b.opts = append(b.opts, invocation.WithAudience(aud)) + return b +} + +// ExpiresAt sets when the invocation expires. +func (b *InvocationBuilder) ExpiresAt(exp time.Time) *InvocationBuilder { + b.opts = append(b.opts, invocation.WithExpiration(exp)) + return b +} + +// ExpiresIn sets the invocation to expire after a duration from now. +func (b *InvocationBuilder) ExpiresIn(d time.Duration) *InvocationBuilder { + b.opts = append(b.opts, invocation.WithExpirationIn(d)) + return b +} + +// Meta adds metadata to the invocation. +func (b *InvocationBuilder) Meta(key string, value any) *InvocationBuilder { + b.opts = append(b.opts, invocation.WithMeta(key, value)) + return b +} + +// Nonce sets a custom nonce. If not called, a random 12-byte nonce is generated. +func (b *InvocationBuilder) Nonce(nonce []byte) *InvocationBuilder { + b.opts = append(b.opts, invocation.WithNonce(nonce)) + return b +} + +// EmptyNonce sets an empty nonce for idempotent operations. +func (b *InvocationBuilder) EmptyNonce() *InvocationBuilder { + b.opts = append(b.opts, invocation.WithEmptyNonce()) + return b +} + +// Cause sets the receipt CID that enqueued this invocation. +func (b *InvocationBuilder) Cause(receiptCID cid.Cid) *InvocationBuilder { + b.opts = append(b.opts, invocation.WithCause(&receiptCID)) + return b +} + +// Build creates the invocation token. +func (b *InvocationBuilder) Build() (*invocation.Token, error) { + if b.err != nil { + return nil, b.err + } + + if b.issuer == nil { + return nil, fmt.Errorf("issuer is required") + } + if b.subject == nil { + return nil, fmt.Errorf("subject is required") + } + if b.cmd == "" { + return nil, fmt.Errorf("command is required") + } + + return invocation.New(b.issuer, b.cmd, b.subject, b.proofs, b.opts...) +} + +// BuildSealed creates and signs the invocation, returning DAG-CBOR bytes and CID. +func (b *InvocationBuilder) BuildSealed(privKey crypto.PrivateKeySigningBytes) ([]byte, cid.Cid, error) { + tkn, err := b.Build() + if err != nil { + return nil, cid.Cid{}, err + } + return tkn.ToSealed(privKey) +} + +// --- Sonr-specific Invocation Builders --- + +// VaultReadInvocation creates an invocation to read from a vault. +func VaultReadInvocation(issuer, subject did.DID, proofs []cid.Cid, vaultCID string, opts ...invocation.Option) (*invocation.Token, error) { + allOpts := append([]invocation.Option{invocation.WithArgument("vault", vaultCID)}, opts...) + return invocation.New(issuer, VaultRead, subject, proofs, allOpts...) +} + +// VaultWriteInvocation creates an invocation to write to a vault. +func VaultWriteInvocation(issuer, subject did.DID, proofs []cid.Cid, vaultCID string, data any, opts ...invocation.Option) (*invocation.Token, error) { + allOpts := append([]invocation.Option{ + invocation.WithArgument("vault", vaultCID), + invocation.WithArgument("data", data), + }, opts...) + return invocation.New(issuer, VaultWrite, subject, proofs, allOpts...) +} + +// VaultSignInvocation creates an invocation to sign with a key in a vault. +func VaultSignInvocation(issuer, subject did.DID, proofs []cid.Cid, vaultCID string, keyID string, payload []byte, opts ...invocation.Option) (*invocation.Token, error) { + allOpts := append([]invocation.Option{ + invocation.WithArgument("vault", vaultCID), + invocation.WithArgument("key_id", keyID), + invocation.WithArgument("payload", payload), + }, opts...) + return invocation.New(issuer, VaultSign, subject, proofs, allOpts...) +} + +// DIDUpdateInvocation creates an invocation to update a DID document. +func DIDUpdateInvocation(issuer, subject did.DID, proofs []cid.Cid, targetDID string, updates map[string]any, opts ...invocation.Option) (*invocation.Token, error) { + allOpts := append([]invocation.Option{ + invocation.WithArgument("did", targetDID), + invocation.WithArgument("updates", updates), + }, opts...) + return invocation.New(issuer, DIDUpdate, subject, proofs, allOpts...) +} + +// DWNRecordsWriteInvocation creates an invocation to write a DWN record. +func DWNRecordsWriteInvocation(issuer, subject did.DID, proofs []cid.Cid, recordType string, data any, opts ...invocation.Option) (*invocation.Token, error) { + allOpts := append([]invocation.Option{ + invocation.WithArgument("record_type", recordType), + invocation.WithArgument("data", data), + }, opts...) + return invocation.New(issuer, DWNRecordsWrite, subject, proofs, allOpts...) +} + +// DWNRecordsReadInvocation creates an invocation to read DWN records. +func DWNRecordsReadInvocation(issuer, subject did.DID, proofs []cid.Cid, recordType string, filter map[string]any, opts ...invocation.Option) (*invocation.Token, error) { + allOpts := []invocation.Option{ + invocation.WithArgument("record_type", recordType), + } + if filter != nil { + allOpts = append(allOpts, invocation.WithArgument("filter", filter)) + } + allOpts = append(allOpts, opts...) + return invocation.New(issuer, DWNRecordsRead, subject, proofs, allOpts...) +} + +// RevocationInvocation creates an invocation to revoke a delegation. +func RevocationInvocation(issuer, subject did.DID, proofs []cid.Cid, delegationCID cid.Cid, opts ...invocation.Option) (*invocation.Token, error) { + allOpts := append([]invocation.Option{ + invocation.WithArgument("ucan", delegationCID), + }, opts...) + return invocation.New(issuer, UCANRevoke, subject, proofs, allOpts...) +} diff --git a/internal/crypto/ucan/policy.go b/internal/crypto/ucan/policy.go new file mode 100644 index 0000000..5b7092e --- /dev/null +++ b/internal/crypto/ucan/policy.go @@ -0,0 +1,213 @@ +package ucan + +import ( + "github.com/ipld/go-ipld-prime" + "github.com/ucan-wg/go-ucan/pkg/policy" + "github.com/ucan-wg/go-ucan/pkg/policy/literal" +) + +// PolicyBuilder provides a fluent API for constructing UCAN policies. +// Policies are arrays of statements that form an implicit AND. +type PolicyBuilder struct { + constructors []policy.Constructor +} + +// NewPolicy creates a new PolicyBuilder. +func NewPolicy() *PolicyBuilder { + return &PolicyBuilder{ + constructors: make([]policy.Constructor, 0), + } +} + +// Build constructs the final Policy from all added statements. +func (b *PolicyBuilder) Build() (Policy, error) { + return policy.Construct(b.constructors...) +} + +// MustBuild constructs the final Policy, panicking on error. +func (b *PolicyBuilder) MustBuild() Policy { + return policy.MustConstruct(b.constructors...) +} + +// Equal adds an equality constraint: selector == value +func (b *PolicyBuilder) Equal(selector string, value any) *PolicyBuilder { + node, err := toIPLDNode(value) + if err != nil { + // Store error for Build() to return + b.constructors = append(b.constructors, func() (policy.Statement, error) { + return nil, err + }) + return b + } + b.constructors = append(b.constructors, policy.Equal(selector, node)) + return b +} + +// NotEqual adds an inequality constraint: selector != value +func (b *PolicyBuilder) NotEqual(selector string, value any) *PolicyBuilder { + node, err := toIPLDNode(value) + if err != nil { + b.constructors = append(b.constructors, func() (policy.Statement, error) { + return nil, err + }) + return b + } + b.constructors = append(b.constructors, policy.NotEqual(selector, node)) + return b +} + +// GreaterThan adds a comparison constraint: selector > value +func (b *PolicyBuilder) GreaterThan(selector string, value int64) *PolicyBuilder { + b.constructors = append(b.constructors, policy.GreaterThan(selector, literal.Int(value))) + return b +} + +// GreaterThanOrEqual adds a comparison constraint: selector >= value +func (b *PolicyBuilder) GreaterThanOrEqual(selector string, value int64) *PolicyBuilder { + b.constructors = append(b.constructors, policy.GreaterThanOrEqual(selector, literal.Int(value))) + return b +} + +// LessThan adds a comparison constraint: selector < value +func (b *PolicyBuilder) LessThan(selector string, value int64) *PolicyBuilder { + b.constructors = append(b.constructors, policy.LessThan(selector, literal.Int(value))) + return b +} + +// LessThanOrEqual adds a comparison constraint: selector <= value +func (b *PolicyBuilder) LessThanOrEqual(selector string, value int64) *PolicyBuilder { + b.constructors = append(b.constructors, policy.LessThanOrEqual(selector, literal.Int(value))) + return b +} + +// Like adds a glob pattern constraint: selector matches pattern +// Use * for wildcard, \* for literal asterisk. +func (b *PolicyBuilder) Like(selector, pattern string) *PolicyBuilder { + b.constructors = append(b.constructors, policy.Like(selector, pattern)) + return b +} + +// Not negates a statement +func (b *PolicyBuilder) Not(stmt policy.Constructor) *PolicyBuilder { + b.constructors = append(b.constructors, policy.Not(stmt)) + return b +} + +// And adds a logical AND of multiple statements +func (b *PolicyBuilder) And(stmts ...policy.Constructor) *PolicyBuilder { + b.constructors = append(b.constructors, policy.And(stmts...)) + return b +} + +// Or adds a logical OR of multiple statements +func (b *PolicyBuilder) Or(stmts ...policy.Constructor) *PolicyBuilder { + b.constructors = append(b.constructors, policy.Or(stmts...)) + return b +} + +// All adds a universal quantifier: all elements at selector must satisfy statement +func (b *PolicyBuilder) All(selector string, stmt policy.Constructor) *PolicyBuilder { + b.constructors = append(b.constructors, policy.All(selector, stmt)) + return b +} + +// Any adds an existential quantifier: at least one element at selector must satisfy statement +func (b *PolicyBuilder) Any(selector string, stmt policy.Constructor) *PolicyBuilder { + b.constructors = append(b.constructors, policy.Any(selector, stmt)) + return b +} + +// toIPLDNode converts a Go value to an IPLD node for policy evaluation. +func toIPLDNode(value any) (ipld.Node, error) { + // Handle IPLD nodes directly + if node, ok := value.(ipld.Node); ok { + return node, nil + } + + // Use literal package for conversion + return literal.Any(value) +} + +// --- Sonr-specific Policy Helpers --- + +// VaultPolicy creates a policy that restricts operations to a specific vault. +// The vault is identified by its CID. +func VaultPolicy(vaultCID string) Policy { + return NewPolicy().Equal(".vault", vaultCID).MustBuild() +} + +// DIDPolicy creates a policy that restricts operations to DIDs matching a pattern. +// Use glob patterns: "did:sonr:*" matches all Sonr DIDs. +func DIDPolicy(didPattern string) Policy { + return NewPolicy().Like(".did", didPattern).MustBuild() +} + +// ChainPolicy creates a policy that restricts operations to a specific chain. +func ChainPolicy(chainID string) Policy { + return NewPolicy().Equal(".chain_id", chainID).MustBuild() +} + +// AccountPolicy creates a policy that restricts operations to a specific account address. +func AccountPolicy(address string) Policy { + return NewPolicy().Equal(".address", address).MustBuild() +} + +// RecordTypePolicy creates a policy for DWN operations on specific record types. +func RecordTypePolicy(recordType string) Policy { + return NewPolicy().Equal(".record_type", recordType).MustBuild() +} + +// CombinePolicies merges multiple policies into one (implicit AND). +func CombinePolicies(policies ...Policy) Policy { + combined := make(Policy, 0) + for _, p := range policies { + combined = append(combined, p...) + } + return combined +} + +// EmptyPolicy returns an empty policy (no constraints). +func EmptyPolicy() Policy { + return Policy{} +} + +// --- Policy Constructor Helpers --- + +// These return policy.Constructor for use with And/Or/Not/All/Any + +// EqualTo creates an equality constructor for nested policy building. +func EqualTo(selector string, value any) policy.Constructor { + return func() (policy.Statement, error) { + node, err := toIPLDNode(value) + if err != nil { + return nil, err + } + return policy.Equal(selector, node)() + } +} + +// NotEqualTo creates an inequality constructor for nested policy building. +func NotEqualTo(selector string, value any) policy.Constructor { + return func() (policy.Statement, error) { + node, err := toIPLDNode(value) + if err != nil { + return nil, err + } + return policy.NotEqual(selector, node)() + } +} + +// Matches creates a glob pattern constructor for nested policy building. +func Matches(selector, pattern string) policy.Constructor { + return policy.Like(selector, pattern) +} + +// GreaterThanValue creates a comparison constructor. +func GreaterThanValue(selector string, value int64) policy.Constructor { + return policy.GreaterThan(selector, literal.Int(value)) +} + +// LessThanValue creates a comparison constructor. +func LessThanValue(selector string, value int64) policy.Constructor { + return policy.LessThan(selector, literal.Int(value)) +} diff --git a/internal/crypto/ucan/types.go b/internal/crypto/ucan/types.go new file mode 100644 index 0000000..47670d1 --- /dev/null +++ b/internal/crypto/ucan/types.go @@ -0,0 +1,261 @@ +package ucan + +import ( + "github.com/ipfs/go-cid" + "github.com/ucan-wg/go-ucan/pkg/policy" +) + +// ValidationErrorCode represents UCAN validation error types. +// These codes match the TypeScript ValidationErrorCode type in src/ucan.ts. +type ValidationErrorCode string + +const ( + ErrCodeExpired ValidationErrorCode = "EXPIRED" + ErrCodeNotYetValid ValidationErrorCode = "NOT_YET_VALID" + ErrCodeInvalidSignature ValidationErrorCode = "INVALID_SIGNATURE" + ErrCodePrincipalMisaligned ValidationErrorCode = "PRINCIPAL_MISALIGNMENT" + ErrCodePolicyViolation ValidationErrorCode = "POLICY_VIOLATION" + ErrCodeRevoked ValidationErrorCode = "REVOKED" + ErrCodeInvalidProofChain ValidationErrorCode = "INVALID_PROOF_CHAIN" + ErrCodeUnknownCommand ValidationErrorCode = "UNKNOWN_COMMAND" + ErrCodeMalformedToken ValidationErrorCode = "MALFORMED_TOKEN" +) + +// ValidationError represents a UCAN validation failure. +type ValidationError struct { + Code ValidationErrorCode `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details,omitempty"` +} + +func (e *ValidationError) Error() string { + return e.Message +} + +// NewValidationError creates a validation error with the given code and message. +func NewValidationError(code ValidationErrorCode, message string) *ValidationError { + return &ValidationError{ + Code: code, + Message: message, + } +} + +// NewValidationErrorWithDetails creates a validation error with additional details. +func NewValidationErrorWithDetails(code ValidationErrorCode, message string, details map[string]any) *ValidationError { + return &ValidationError{ + Code: code, + Message: message, + Details: details, + } +} + +// Capability represents the semantically-relevant claim of a delegation. +// It combines subject, command, and policy into a single authorization unit. +type Capability struct { + // Subject DID (resource owner) - nil for powerline delegations + Subject string `json:"sub"` + // Command being delegated + Command string `json:"cmd"` + // Policy constraints on invocation arguments + Policy policy.Policy `json:"pol"` +} + +// ValidationResult represents the outcome of UCAN validation. +type ValidationResult struct { + Valid bool `json:"valid"` + Capability *Capability `json:"capability,omitempty"` + Error *ValidationError `json:"error,omitempty"` +} + +// ValidationSuccess creates a successful validation result. +func ValidationSuccess(cap *Capability) *ValidationResult { + return &ValidationResult{ + Valid: true, + Capability: cap, + } +} + +// ValidationFailure creates a failed validation result. +func ValidationFailure(err *ValidationError) *ValidationResult { + return &ValidationResult{ + Valid: false, + Error: err, + } +} + +// CryptoAlgorithm represents supported signature algorithms. +type CryptoAlgorithm string + +const ( + AlgorithmEd25519 CryptoAlgorithm = "Ed25519" + AlgorithmP256 CryptoAlgorithm = "P-256" + AlgorithmSecp256k1 CryptoAlgorithm = "secp256k1" +) + +// ExecutionResult represents the outcome of an invocation execution. +type ExecutionResult[T any, E any] struct { + Ok *T `json:"ok,omitempty"` + Err *E `json:"err,omitempty"` +} + +// IsSuccess returns true if the result is successful. +func (r *ExecutionResult[T, E]) IsSuccess() bool { + return r.Ok != nil +} + +// IsError returns true if the result is an error. +func (r *ExecutionResult[T, E]) IsError() bool { + return r.Err != nil +} + +// Success creates a successful execution result. +func Success[T any, E any](value T) *ExecutionResult[T, E] { + return &ExecutionResult[T, E]{Ok: &value} +} + +// Failure creates a failed execution result. +func Failure[T any, E any](err E) *ExecutionResult[T, E] { + return &ExecutionResult[T, E]{Err: &err} +} + +// ReceiptPayload represents the result of an invocation execution. +// This matches the TypeScript ReceiptPayload in src/ucan.ts. +type ReceiptPayload struct { + // Executor DID + Issuer string `json:"iss"` + // CID of executed invocation + Ran cid.Cid `json:"ran"` + // Execution result + Out *ExecutionResult[any, any] `json:"out"` + // Effects - CIDs of Tasks to enqueue + Effects []cid.Cid `json:"fx,omitempty"` + // Optional metadata + Meta map[string]any `json:"meta,omitempty"` + // Issuance timestamp (Unix seconds) + IssuedAt *int64 `json:"iat,omitempty"` +} + +// RevocationPayload represents a UCAN revocation request. +// This matches the TypeScript RevocationPayload in src/ucan.ts. +type RevocationPayload struct { + // Revoker DID - must be issuer in delegation chain + Issuer string `json:"iss"` + // Subject of delegation being revoked + Subject string `json:"sub"` + // Revocation command (always "/ucan/revoke") + Command string `json:"cmd"` + // Revocation arguments + Args RevocationArgs `json:"args"` + // Proof chain + Proof []cid.Cid `json:"prf"` + // Nonce + Nonce []byte `json:"nonce"` + // Expiration (Unix seconds or null) + Expiration *int64 `json:"exp"` +} + +// RevocationArgs contains the arguments for a revocation invocation. +type RevocationArgs struct { + // CID of delegation to revoke + UCAN cid.Cid `json:"ucan"` +} + +// Task represents a subset of Invocation fields that uniquely determine work. +// The Task ID is the CID of these fields. +type Task struct { + // Subject DID + Subject string `json:"sub"` + // Command to execute + Command string `json:"cmd"` + // Command arguments + Args map[string]any `json:"args"` + // Nonce for uniqueness + Nonce []byte `json:"nonce"` +} + +// --- Sonr-specific Types --- + +// VaultCapability represents authorization for vault operations. +type VaultCapability struct { + // VaultCID identifies the specific vault + VaultCID string `json:"vault_cid"` + // Operations allowed (read, write, sign, export, import, delete, admin) + Operations []string `json:"operations"` +} + +// DIDCapability represents authorization for DID operations. +type DIDCapability struct { + // DIDPattern is a glob pattern matching allowed DIDs + DIDPattern string `json:"did_pattern"` + // Operations allowed (create, update, deactivate) + Operations []string `json:"operations"` +} + +// DWNCapability represents authorization for DWN operations. +type DWNCapability struct { + // RecordType specifies the allowed record type + RecordType string `json:"record_type"` + // Operations allowed (read, write, delete) + Operations []string `json:"operations"` +} + +// AccountCapability represents authorization for account operations. +type AccountCapability struct { + // ChainID specifies the blockchain + ChainID string `json:"chain_id"` + // Address specifies the account (or "*" for all) + Address string `json:"address"` + // Operations allowed + Operations []string `json:"operations"` +} + +// SealedToken represents a signed UCAN token with its CID and raw bytes. +type SealedToken struct { + // CID is the content identifier of the sealed token + CID cid.Cid `json:"cid"` + // Data is the DAG-CBOR encoded envelope + Data []byte `json:"data"` + // Type indicates if this is a delegation or invocation + Type string `json:"type"` // "delegation" or "invocation" +} + +// SealedDelegation is a type alias for a sealed delegation token. +type SealedDelegation = SealedToken + +// SealedInvocation is a type alias for a sealed invocation token. +type SealedInvocation = SealedToken + +// ProofChain represents an ordered list of delegation CIDs. +// Ordered from leaf (matching invocation issuer) to root delegation. +type ProofChain []cid.Cid + +// NewProofChain creates a new proof chain from CIDs. +func NewProofChain(cids ...cid.Cid) ProofChain { + return ProofChain(cids) +} + +// Add appends a CID to the proof chain. +func (p *ProofChain) Add(c cid.Cid) { + *p = append(*p, c) +} + +// IsEmpty returns true if the proof chain is empty. +func (p ProofChain) IsEmpty() bool { + return len(p) == 0 +} + +// Root returns the root delegation CID (last in chain). +func (p ProofChain) Root() (cid.Cid, bool) { + if len(p) == 0 { + return cid.Cid{}, false + } + return p[len(p)-1], true +} + +// Leaf returns the leaf delegation CID (first in chain). +func (p ProofChain) Leaf() (cid.Cid, bool) { + if len(p) == 0 { + return cid.Cid{}, false + } + return p[0], true +} diff --git a/internal/crypto/ucan/ucan.go b/internal/crypto/ucan/ucan.go new file mode 100644 index 0000000..31112b8 --- /dev/null +++ b/internal/crypto/ucan/ucan.go @@ -0,0 +1,195 @@ +// Package ucan provides UCAN v1.0.0-rc.1 compliant authorization +// for the Sonr network using the official go-ucan library. +// +// This package wraps github.com/ucan-wg/go-ucan to provide: +// - Delegation creation and validation +// - Invocation creation and validation +// - Policy evaluation +// - Sonr-specific capability types (vault, did, dwn) +// +// UCAN Envelope Format (DAG-CBOR): +// +// [ +// Signature, // Varsig-encoded signature +// { +// "h": VarsigHeader, // Algorithm metadata +// "ucan/dlg@1.0.0-rc.1": DelegationPayload // or "ucan/inv@1.0.0-rc.1" +// } +// ] +package ucan + +import ( + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/pkg/policy" + "github.com/ucan-wg/go-ucan/token/delegation" + "github.com/ucan-wg/go-ucan/token/invocation" +) + +// Re-export key types from go-ucan for convenience. +// Users should import this package instead of go-ucan directly +// for Sonr-specific functionality. +type ( + // Delegation is an immutable UCAN delegation token. + Delegation = delegation.Token + + // Invocation is an immutable UCAN invocation token. + Invocation = invocation.Token + + // Command is a validated UCAN command string (e.g., "/vault/read"). + Command = command.Command + + // Policy is a list of policy statements that constrain invocation arguments. + Policy = policy.Policy + + // Statement is a single policy statement (equality, like, and, or, etc.). + Statement = policy.Statement + + // DelegationOption configures optional fields when creating a delegation. + DelegationOption = delegation.Option + + // InvocationOption configures optional fields when creating an invocation. + InvocationOption = invocation.Option +) + +// Re-export constructors +var ( + // NewDelegation creates a delegation: "(issuer) allows (audience) to perform (cmd+pol) on (subject)". + NewDelegation = delegation.New + + // NewRootDelegation creates a root delegation where subject == issuer. + NewRootDelegation = delegation.Root + + // NewPowerlineDelegation creates a powerline delegation (subject = nil). + // Powerline automatically delegates all future delegations regardless of subject. + NewPowerlineDelegation = delegation.Powerline + + // NewInvocation creates an invocation: "(issuer) executes (command) on (subject)". + NewInvocation = invocation.New + + // ParseCommand validates and parses a command string. + ParseCommand = command.Parse + + // MustParseCommand parses a command string, panicking on error. + MustParseCommand = command.MustParse + + // TopCommand returns "/" - the most powerful capability (grants everything). + TopCommand = command.Top + + // NewCommand creates a command from segments (e.g., NewCommand("vault", "read") -> "/vault/read"). + NewCommand = command.New +) + +// Re-export delegation options +var ( + // WithExpiration sets the delegation's expiration time. + WithExpiration = delegation.WithExpiration + + // WithExpirationIn sets expiration to now + duration. + WithExpirationIn = delegation.WithExpirationIn + + // WithNotBefore sets when the delegation becomes valid. + WithNotBefore = delegation.WithNotBefore + + // WithNotBeforeIn sets not-before to now + duration. + WithNotBeforeIn = delegation.WithNotBeforeIn + + // WithDelegationMeta adds metadata to the delegation. + WithDelegationMeta = delegation.WithMeta + + // WithDelegationNonce sets a custom nonce (default: random 12 bytes). + WithDelegationNonce = delegation.WithNonce +) + +// Re-export invocation options +var ( + // WithArgument adds a single argument to the invocation. + WithArgument = invocation.WithArgument + + // WithAudience sets the invocation's audience (executor if different from subject). + WithAudience = invocation.WithAudience + + // WithInvocationMeta adds metadata to the invocation. + WithInvocationMeta = invocation.WithMeta + + // WithInvocationNonce sets a custom nonce. + WithInvocationNonce = invocation.WithNonce + + // WithEmptyNonce sets an empty nonce for idempotent operations. + WithEmptyNonce = invocation.WithEmptyNonce + + // WithInvocationExpiration sets the invocation's expiration time. + WithInvocationExpiration = invocation.WithExpiration + + // WithInvocationExpirationIn sets expiration to now + duration. + WithInvocationExpirationIn = invocation.WithExpirationIn + + // WithIssuedAt sets when the invocation was created. + WithIssuedAt = invocation.WithIssuedAt + + // WithCause sets the receipt CID that enqueued this task. + WithCause = invocation.WithCause +) + +// Standard Sonr commands following UCAN v1.0.0-rc.1 command format. +// Commands must be lowercase, start with '/', and have no trailing slash. +const ( + // Vault commands + CmdVaultRead = "/vault/read" + CmdVaultWrite = "/vault/write" + CmdVaultSign = "/vault/sign" + CmdVaultExport = "/vault/export" + CmdVaultImport = "/vault/import" + CmdVaultDelete = "/vault/delete" + CmdVaultAdmin = "/vault/admin" + CmdVault = "/vault" // Superuser - grants all vault commands + + // DID commands + CmdDIDCreate = "/did/create" + CmdDIDUpdate = "/did/update" + CmdDIDDeactivate = "/did/deactivate" + CmdDID = "/did" // Superuser - grants all DID commands + + // DWN commands + CmdDWNRecordsWrite = "/dwn/records/write" + CmdDWNRecordsRead = "/dwn/records/read" + CmdDWNRecordsDelete = "/dwn/records/delete" + CmdDWN = "/dwn" // Superuser - grants all DWN commands + + // UCAN meta commands + CmdUCANRevoke = "/ucan/revoke" + + // Root command - grants everything + CmdRoot = "/" +) + +// Pre-parsed Sonr commands for convenience +var ( + VaultRead = command.MustParse(CmdVaultRead) + VaultWrite = command.MustParse(CmdVaultWrite) + VaultSign = command.MustParse(CmdVaultSign) + VaultExport = command.MustParse(CmdVaultExport) + VaultImport = command.MustParse(CmdVaultImport) + VaultDelete = command.MustParse(CmdVaultDelete) + VaultAdmin = command.MustParse(CmdVaultAdmin) + Vault = command.MustParse(CmdVault) + + DIDCreate = command.MustParse(CmdDIDCreate) + DIDUpdate = command.MustParse(CmdDIDUpdate) + DIDDeactivate = command.MustParse(CmdDIDDeactivate) + DID = command.MustParse(CmdDID) + + DWNRecordsWrite = command.MustParse(CmdDWNRecordsWrite) + DWNRecordsRead = command.MustParse(CmdDWNRecordsRead) + DWNRecordsDelete = command.MustParse(CmdDWNRecordsDelete) + DWN = command.MustParse(CmdDWN) + + UCANRevoke = command.MustParse(CmdUCANRevoke) + Root = command.Top() +) + +// CommandSubsumes checks if parent command subsumes child command. +// A command subsumes another if the child is a path extension of parent. +// Example: "/vault" subsumes "/vault/read" and "/vault/write" +func CommandSubsumes(parent, child Command) bool { + return parent.Covers(child) +}