2 Commits

Author SHA1 Message Date
Michael Muré
a7e698e4ec toolkit/client: fix FindProof to handle self-delegation properly 2025-12-08 20:25:28 +01:00
Michael Muré
4b3a0c590a invocation: add support for self-signed invocations (issuer=subject) 2025-12-08 18:11:58 +01:00
33 changed files with 443 additions and 1603 deletions

View File

@@ -1,265 +0,0 @@
package claims
import (
"errors"
"fmt"
"iter"
"sort"
"strings"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/printer"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
"github.com/ucan-wg/go-ucan/pkg/secretbox"
)
var ErrNotFound = errors.New("key not found in claims")
var ErrNotEncryptable = errors.New("value of this type cannot be encrypted")
// Claims is a container for claims key-value pairs in an attestation token.
// This also serves as a way to construct the underlying IPLD data with minimum allocations
// and transformations, while hiding the IPLD complexity from the caller.
type Claims struct {
// This type must be compatible with the IPLD type represented by the IPLD
// schema { String : Any }.
Keys []string
Values map[string]ipld.Node
}
// NewClaims constructs a new Claims.
func NewClaims() *Claims {
return &Claims{Values: map[string]ipld.Node{}}
}
// GetBool retrieves a value as a bool.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Claims) GetBool(key string) (bool, error) {
v, ok := m.Values[key]
if !ok {
return false, ErrNotFound
}
return v.AsBool()
}
// GetString retrieves a value as a string.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Claims) GetString(key string) (string, error) {
v, ok := m.Values[key]
if !ok {
return "", ErrNotFound
}
return v.AsString()
}
// GetEncryptedString decorates GetString and decrypt its output with the given symmetric encryption key.
func (m *Claims) GetEncryptedString(key string, encryptionKey []byte) (string, error) {
v, err := m.GetBytes(key)
if err != nil {
return "", err
}
decrypted, err := secretbox.DecryptStringWithKey(v, encryptionKey)
if err != nil {
return "", err
}
return string(decrypted), nil
}
// GetInt64 retrieves a value as an int64.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Claims) GetInt64(key string) (int64, error) {
v, ok := m.Values[key]
if !ok {
return 0, ErrNotFound
}
return v.AsInt()
}
// GetFloat64 retrieves a value as a float64.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Claims) GetFloat64(key string) (float64, error) {
v, ok := m.Values[key]
if !ok {
return 0, ErrNotFound
}
return v.AsFloat()
}
// GetBytes retrieves a value as a []byte.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Claims) GetBytes(key string) ([]byte, error) {
v, ok := m.Values[key]
if !ok {
return nil, ErrNotFound
}
return v.AsBytes()
}
// GetEncryptedBytes decorates GetBytes and decrypt its output with the given symmetric encryption key.
func (m *Claims) GetEncryptedBytes(key string, encryptionKey []byte) ([]byte, error) {
v, err := m.GetBytes(key)
if err != nil {
return nil, err
}
decrypted, err := secretbox.DecryptStringWithKey(v, encryptionKey)
if err != nil {
return nil, err
}
return decrypted, nil
}
// GetNode retrieves a value as a raw IPLD node.
// Returns ErrNotFound if the given key is missing.
func (m *Claims) GetNode(key string) (ipld.Node, error) {
v, ok := m.Values[key]
if !ok {
return nil, ErrNotFound
}
return v, nil
}
// Add adds a key/value pair in the claims set.
// Accepted types for val are any CBOR compatible type, or directly IPLD values.
func (m *Claims) Add(key string, val any) error {
if _, ok := m.Values[key]; ok {
return fmt.Errorf("duplicate key %q", key)
}
node, err := literal.Any(val)
if err != nil {
return err
}
m.Keys = append(m.Keys, key)
m.Values[key] = node
return nil
}
// AddEncrypted adds a key/value pair in the claims set.
// The value is encrypted with the given encryptionKey.
// Accepted types for the value are: string, []byte.
// The ciphertext will be 40 bytes larger than the plaintext due to encryption overhead.
func (m *Claims) AddEncrypted(key string, val any, encryptionKey []byte) error {
var encrypted []byte
var err error
switch val := val.(type) {
case string:
encrypted, err = secretbox.EncryptWithKey([]byte(val), encryptionKey)
if err != nil {
return err
}
case []byte:
encrypted, err = secretbox.EncryptWithKey(val, encryptionKey)
if err != nil {
return err
}
default:
return ErrNotEncryptable
}
return m.Add(key, encrypted)
}
type Iterator interface {
Iter() iter.Seq2[string, ipld.Node]
}
// Include merges the provided claims into the existing one.
//
// If duplicate keys are encountered, the new value is silently dropped
// without causing an error.
func (m *Claims) Include(other Iterator) {
for key, value := range other.Iter() {
if _, ok := m.Values[key]; ok {
// don't overwrite
continue
}
m.Values[key] = value
m.Keys = append(m.Keys, key)
}
}
// Len returns the number of key/values.
func (m *Claims) Len() int {
return len(m.Values)
}
// Iter iterates over the claims key/values
func (m *Claims) Iter() iter.Seq2[string, ipld.Node] {
return func(yield func(string, ipld.Node) bool) {
for _, key := range m.Keys {
if !yield(key, m.Values[key]) {
return
}
}
}
}
// Equals tells if two Claims hold the same key/values.
func (m *Claims) Equals(other *Claims) bool {
if len(m.Keys) != len(other.Keys) {
return false
}
if len(m.Values) != len(other.Values) {
return false
}
for _, key := range m.Keys {
if !ipld.DeepEqual(m.Values[key], other.Values[key]) {
return false
}
}
return true
}
func (m *Claims) String() string {
sort.Strings(m.Keys)
buf := strings.Builder{}
buf.WriteString("{")
for key, node := range m.Values {
buf.WriteString("\n\t")
buf.WriteString(key)
buf.WriteString(": ")
buf.WriteString(strings.ReplaceAll(printer.Sprint(node), "\n", "\n\t"))
buf.WriteString(",")
}
if len(m.Values) > 0 {
buf.WriteString("\n")
}
buf.WriteString("}")
return buf.String()
}
// ReadOnly returns a read-only version of Claims.
func (m *Claims) ReadOnly() ReadOnly {
return ReadOnly{claims: m}
}
// Clone makes a deep copy.
func (m *Claims) Clone() *Claims {
res := &Claims{
Keys: make([]string, len(m.Keys)),
Values: make(map[string]ipld.Node, len(m.Values)),
}
copy(res.Keys, m.Keys)
for k, v := range m.Values {
res.Values[k] = v
}
return res
}

View File

@@ -1,130 +0,0 @@
package claims_test
import (
"crypto/rand"
"maps"
"testing"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/claims"
)
func TestClaims_Add(t *testing.T) {
t.Parallel()
type Unsupported struct{}
t.Run("error if not primitive or Node", func(t *testing.T) {
t.Parallel()
err := (&claims.Claims{}).Add("invalid", &Unsupported{})
require.Error(t, err)
})
t.Run("encrypted claims", func(t *testing.T) {
t.Parallel()
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err)
m := claims.NewClaims()
// string encryption
err = m.AddEncrypted("secret", "hello world", key)
require.NoError(t, err)
_, err = m.GetString("secret")
require.Error(t, err) // the ciphertext is saved as []byte instead of string
decrypted, err := m.GetEncryptedString("secret", key)
require.NoError(t, err)
require.Equal(t, "hello world", decrypted)
// bytes encryption
originalBytes := make([]byte, 128)
_, err = rand.Read(originalBytes)
require.NoError(t, err)
err = m.AddEncrypted("secret-bytes", originalBytes, key)
require.NoError(t, err)
encryptedBytes, err := m.GetBytes("secret-bytes")
require.NoError(t, err)
require.NotEqual(t, originalBytes, encryptedBytes)
decryptedBytes, err := m.GetEncryptedBytes("secret-bytes", key)
require.NoError(t, err)
require.Equal(t, originalBytes, decryptedBytes)
// error cases
t.Run("error on unsupported type", func(t *testing.T) {
err := m.AddEncrypted("invalid", 123, key)
require.ErrorIs(t, err, claims.ErrNotEncryptable)
})
t.Run("error on invalid key size", func(t *testing.T) {
err := m.AddEncrypted("invalid", "test", []byte("short-key"))
require.Error(t, err)
require.Contains(t, err.Error(), "invalid key size")
})
t.Run("error on nil key", func(t *testing.T) {
err := m.AddEncrypted("invalid", "test", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "encryption key is required")
})
})
}
func TestIterCloneEquals(t *testing.T) {
m := claims.NewClaims()
require.NoError(t, m.Add("foo", "bar"))
require.NoError(t, m.Add("baz", 1234))
expected := map[string]ipld.Node{
"foo": basicnode.NewString("bar"),
"baz": basicnode.NewInt(1234),
}
// claims -> iter
require.Equal(t, expected, maps.Collect(m.Iter()))
// readonly -> iter
ro := m.ReadOnly()
require.Equal(t, expected, maps.Collect(ro.Iter()))
// claims -> clone -> iter
clone := m.Clone()
require.Equal(t, expected, maps.Collect(clone.Iter()))
// readonly -> WriteableClone -> iter
wclone := ro.WriteableClone()
require.Equal(t, expected, maps.Collect(wclone.Iter()))
require.True(t, m.Equals(wclone))
require.True(t, ro.Equals(wclone.ReadOnly()))
}
func TestInclude(t *testing.T) {
m1 := claims.NewClaims()
require.NoError(t, m1.Add("samekey", "bar"))
require.NoError(t, m1.Add("baz", 1234))
m2 := claims.NewClaims()
require.NoError(t, m2.Add("samekey", "othervalue")) // check no overwrite
require.NoError(t, m2.Add("otherkey", 1234))
m1.Include(m2)
require.Equal(t, map[string]ipld.Node{
"samekey": basicnode.NewString("bar"),
"baz": basicnode.NewInt(1234),
"otherkey": basicnode.NewInt(1234),
}, maps.Collect(m1.Iter()))
}

View File

@@ -1,64 +0,0 @@
package claims
import (
"iter"
"github.com/ipld/go-ipld-prime"
)
// ReadOnly wraps a Claims into a read-only facade.
type ReadOnly struct {
claims *Claims
}
func (r ReadOnly) GetBool(key string) (bool, error) {
return r.claims.GetBool(key)
}
func (r ReadOnly) GetString(key string) (string, error) {
return r.claims.GetString(key)
}
func (r ReadOnly) GetEncryptedString(key string, encryptionKey []byte) (string, error) {
return r.claims.GetEncryptedString(key, encryptionKey)
}
func (r ReadOnly) GetInt64(key string) (int64, error) {
return r.claims.GetInt64(key)
}
func (r ReadOnly) GetFloat64(key string) (float64, error) {
return r.claims.GetFloat64(key)
}
func (r ReadOnly) GetBytes(key string) ([]byte, error) {
return r.claims.GetBytes(key)
}
func (r ReadOnly) GetEncryptedBytes(key string, encryptionKey []byte) ([]byte, error) {
return r.claims.GetEncryptedBytes(key, encryptionKey)
}
func (r ReadOnly) GetNode(key string) (ipld.Node, error) {
return r.claims.GetNode(key)
}
func (r ReadOnly) Len() int {
return r.claims.Len()
}
func (r ReadOnly) Iter() iter.Seq2[string, ipld.Node] {
return r.claims.Iter()
}
func (r ReadOnly) Equals(other ReadOnly) bool {
return r.claims.Equals(other.claims)
}
func (r ReadOnly) String() string {
return r.claims.String()
}
func (r ReadOnly) WriteableClone() *Claims {
return r.claims.Clone()
}

View File

@@ -1,210 +0,0 @@
// Package attestation implements the UCAN [attestation] specification with
// an immutable Token type as well as methods to convert the Token to and
// from the [envelope]-enclosed, signed and DAG-CBOR-encoded form that
// should most commonly be used for transport and storage.
//
// [envelope]: https://github.com/ucan-wg/spec#envelope
// [attestation]: TBD
package attestation
import (
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"github.com/MetaMask/go-did-it"
"github.com/ucan-wg/go-ucan/pkg/claims"
"github.com/ucan-wg/go-ucan/pkg/meta"
"github.com/ucan-wg/go-ucan/token/internal/nonce"
"github.com/ucan-wg/go-ucan/token/internal/parse"
)
// Token is an immutable type that holds the fields of a UCAN attestation.
type Token struct {
// The DID of the Invoker
issuer did.DID
// TODO: should this exist?
// audience did.DID
// Arbitrary Claims
claims *claims.Claims
// Arbitrary Metadata
meta *meta.Meta
// A unique, random nonce
nonce []byte
// The timestamp at which the Invocation becomes invalid
expiration *time.Time
// The timestamp at which the Invocation was created
issuedAt *time.Time
}
// New creates an attestation Token with the provided options.
//
// If no nonce is provided, a random 12-byte nonce is generated. Use the
// WithNonce or WithEmptyNonce options to specify provide your own nonce
// or to leave the nonce empty respectively.
//
// If no IssuedAt is provided, the current time is used. Use the
// IssuedAt or WithIssuedAtIn Options to specify a different time
// or the WithoutIssuedAt Option to clear the Token's IssuedAt field.
//
// With the exception of the WithMeta option, all others will overwrite
// the previous contents of their target field.
//
// You can read it as "(Issuer - I) attest (arbitrary claim)".
func New(iss did.DID, opts ...Option) (*Token, error) {
iat := time.Now()
tkn := Token{
issuer: iss,
claims: claims.NewClaims(),
meta: meta.NewMeta(),
nonce: nil,
issuedAt: &iat,
}
for _, opt := range opts {
if err := opt(&tkn); err != nil {
return nil, err
}
}
var err error
if len(tkn.nonce) == 0 {
tkn.nonce, err = nonce.Generate()
if err != nil {
return nil, err
}
}
if err := tkn.validate(); err != nil {
return nil, err
}
return &tkn, nil
}
// Issuer returns the did.DID representing the Token's issuer.
func (t *Token) Issuer() did.DID {
return t.issuer
}
// Claims returns the Token's claims.
func (t *Token) Claims() claims.ReadOnly {
return t.claims.ReadOnly()
}
// Meta returns the Token's metadata.
func (t *Token) Meta() meta.ReadOnly {
return t.meta.ReadOnly()
}
// Nonce returns the random Nonce encapsulated in this Token.
func (t *Token) Nonce() []byte {
return t.nonce
}
// Expiration returns the time at which the Token expires.
func (t *Token) Expiration() *time.Time {
return t.expiration
}
// IssuedAt returns the time.Time at which the invocation token was
// created.
func (t *Token) IssuedAt() *time.Time {
return t.issuedAt
}
// IsValidNow verifies that the token can be used at the current time, based on expiration or "not before" fields.
// This does NOT do any other kind of verifications.
func (t *Token) IsValidNow() bool {
return t.IsValidAt(time.Now())
}
// IsValidAt verifies that the token can be used at the given time, based on expiration or "not before" fields.
// This does NOT do any other kind of verifications.
func (t *Token) IsValidAt(ti time.Time) bool {
if t.expiration != nil && ti.After(*t.expiration) {
return false
}
return true
}
func (t *Token) String() string {
var res strings.Builder
res.WriteString(fmt.Sprintf("Issuer: %s\n", t.Issuer()))
res.WriteString(fmt.Sprintf("Nonce: %s\n", base64.StdEncoding.EncodeToString(t.Nonce())))
res.WriteString(fmt.Sprintf("Meta: %s\n", t.Meta()))
res.WriteString(fmt.Sprintf("Expiration: %v\n", t.Expiration()))
res.WriteString(fmt.Sprintf("Issued At: %v\n", t.IssuedAt()))
return res.String()
}
func (t *Token) validate() error {
var errs error
requiredDID := func(id did.DID, fieldname string) {
if id == nil {
errs = errors.Join(errs, fmt.Errorf(`a valid did is required for %s: %s`, fieldname, id.String()))
}
}
requiredDID(t.issuer, "Issuer")
if len(t.nonce) < 12 {
errs = errors.Join(errs, fmt.Errorf("token nonce too small"))
}
return errs
}
// tokenFromModel build a decoded view of the raw IPLD data.
// This function also serves as validation.
func tokenFromModel(m tokenPayloadModel) (*Token, error) {
var (
tkn Token
err error
)
if tkn.issuer, err = did.Parse(m.Iss); err != nil {
return nil, fmt.Errorf("parse iss: %w", err)
}
tkn.claims = m.Claims
if tkn.claims == nil {
tkn.claims = claims.NewClaims()
}
tkn.meta = m.Meta
if tkn.meta == nil {
tkn.meta = meta.NewMeta()
}
if len(m.Nonce) == 0 {
return nil, fmt.Errorf("nonce is required")
}
tkn.nonce = m.Nonce
tkn.expiration, err = parse.OptionalTimestamp(m.Exp)
if err != nil {
return nil, fmt.Errorf("parse expiration: %w", err)
}
tkn.issuedAt, err = parse.OptionalTimestamp(m.Iat)
if err != nil {
return nil, fmt.Errorf("parse IssuedAt: %w", err)
}
if err := tkn.validate(); err != nil {
return nil, err
}
return &tkn, nil
}

View File

@@ -1,22 +0,0 @@
type DID string
# The Attestation payload
type Payload struct {
# Issuer DID (sender)
iss DID
# Audience DID (receiver) TODO: should that exist?
# aud DID
# Arbitrary claims
claims optional {String: Any}
# Arbitrary Metadata
meta optional {String : Any}
# A unique, random nonce
nonce Bytes
# The timestamp at which the Invocation becomes invalid
exp nullable Int
# The Timestamp at which the Invocation was created
iat optional Int
}

View File

@@ -1,132 +0,0 @@
package attestation_test
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/MetaMask/go-did-it"
didkeyctl "github.com/MetaMask/go-did-it/controller/did-key"
"github.com/MetaMask/go-did-it/crypto/ed25519"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/ucan-wg/go-ucan/token/attestation"
)
func ExampleNew() {
privKey, iss, _, _, err := setupExampleNew()
if err != nil {
fmt.Println("failed to create setup:", err.Error())
return
}
att, err := attestation.New(iss,
attestation.WithClaim("claim1", "UCAN is great"),
attestation.WithMeta("env", "development"),
attestation.WithExpirationIn(time.Minute),
attestation.WithoutIssuedAt())
if err != nil {
fmt.Println("failed to create attestation:", err.Error())
return
}
// foo, _ := att.ToDagJson(privKey)
// os.WriteFile("testdata/new.dagjson", foo, 0666)
// fmt.Println(base64.StdEncoding.EncodeToString(privKey.ToBytes()))
data, cid, err := att.ToSealed(privKey)
if err != nil {
fmt.Println("failed to seal attestation:", err.Error())
return
}
json, err := prettyDAGJSON(data)
if err != nil {
fmt.Println("failed to pretty DAG-JSON:", err.Error())
return
}
fmt.Println("CID:", cid)
fmt.Println("Token (pretty DAG-JSON):")
fmt.Println(json)
// Expected CID and DAG-JSON output:
// CID: bafyreibm5vo6gk75oreefkg6xkrrfb4d5dgkccgmutirjgtzi5j45svjm4
// Token (pretty DAG-JSON):
// [
// {
// "/": {
// "bytes": "ApuXUsUYhqostO2zfKZK50GW0gXYPtrlpoVA8EwGFdyYahQecOizVpl+9wy64aqk2rMP4Q0UEUKCTV0ONMdPAw"
// }
// },
// {
// "h": {
// "/": {
// "bytes": "NAHtAe0BE3E"
// }
// },
// "ucan/att@tbd": {
// "claims": {
// "claim1": "UCAN is great"
// },
// "exp": 1767790971,
// "iss": "did:key:z6Mkm4RzzBDfSHqmwV9dp5jFsLkVgKRYp1PhSj7VybCcLHC4",
// "meta": {
// "env": "development"
// },
// "nonce": {
// "/": {
// "bytes": "NjS8QPvft97jbtUG"
// }
// }
// }
// }
// ]
}
func prettyDAGJSON(data []byte) (string, error) {
var node ipld.Node
node, err := ipld.Decode(data, dagcbor.Decode)
if err != nil {
return "", err
}
jsonData, err := ipld.Encode(node, dagjson.Encode)
if err != nil {
return "", err
}
var out bytes.Buffer
if err := json.Indent(&out, jsonData, "", " "); err != nil {
return "", err
}
return out.String(), nil
}
func setupExampleNew() (privKey ed25519.PrivateKey, iss did.DID, claims map[string]any, meta map[string]any, errs error) {
var err error
_, privKey, err = ed25519.GenerateKeyPair()
if err != nil {
errs = errors.Join(errs, fmt.Errorf("failed to generate Ed25519 keypair: %w", err))
return
}
iss = didkeyctl.FromPrivateKey(privKey)
claims = map[string]any{
"claim1": "UCAN is great",
}
meta = map[string]any{
"env": basicnode.NewString("development"),
}
return // WARNING: named return values
}

View File

@@ -1,227 +0,0 @@
package attestation
import (
"io"
"github.com/MetaMask/go-did-it"
"github.com/MetaMask/go-did-it/crypto"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ucan-wg/go-ucan/token/internal/envelope"
)
// ToSealed wraps the attestation token in an envelope, generates the
// signature, encodes the result to DAG-CBOR and calculates the CID of
// the resulting binary data.
func (t *Token) ToSealed(privKey crypto.PrivateKeySigningBytes) ([]byte, cid.Cid, error) {
data, err := t.ToDagCbor(privKey)
if err != nil {
return nil, cid.Undef, err
}
id, err := envelope.CIDFromBytes(data)
if err != nil {
return nil, cid.Undef, err
}
return data, id, nil
}
// ToSealedWriter is the same as ToSealed but accepts an io.Writer.
func (t *Token) ToSealedWriter(w io.Writer, privKey crypto.PrivateKeySigningBytes) (cid.Cid, error) {
cidWriter := envelope.NewCIDWriter(w)
if err := t.ToDagCborWriter(cidWriter, privKey); err != nil {
return cid.Undef, err
}
return cidWriter.CID()
}
// FromSealed decodes the provided binary data from the DAG-CBOR format,
// verifies that the envelope's signature is correct based on the public
// key taken from the issuer (iss) field and calculates the CID of the
// incoming data.
func FromSealed(data []byte, resolvOpts ...did.ResolutionOption) (*Token, cid.Cid, error) {
tkn, err := FromDagCbor(data, resolvOpts...)
if err != nil {
return nil, cid.Undef, err
}
id, err := envelope.CIDFromBytes(data)
if err != nil {
return nil, cid.Undef, err
}
return tkn, id, nil
}
// FromSealedReader is the same as Unseal but accepts an io.Reader.
func FromSealedReader(r io.Reader, resolvOpts ...did.ResolutionOption) (*Token, cid.Cid, error) {
cidReader := envelope.NewCIDReader(r)
tkn, err := FromDagCborReader(cidReader, resolvOpts...)
if err != nil {
return nil, cid.Undef, err
}
id, err := cidReader.CID()
if err != nil {
return nil, cid.Undef, err
}
return tkn, id, nil
}
// Encode marshals a Token to the format specified by the provided
// codec.Encoder.
func (t *Token) Encode(privKey crypto.PrivateKeySigningBytes, encFn codec.Encoder) ([]byte, error) {
node, err := t.toIPLD(privKey)
if err != nil {
return nil, err
}
return ipld.Encode(node, encFn)
}
// EncodeWriter is the same as Encode, but accepts an io.Writer.
func (t *Token) EncodeWriter(w io.Writer, privKey crypto.PrivateKeySigningBytes, encFn codec.Encoder) error {
node, err := t.toIPLD(privKey)
if err != nil {
return err
}
return ipld.EncodeStreaming(w, node, encFn)
}
// ToDagCbor marshals the Token to the DAG-CBOR format.
func (t *Token) ToDagCbor(privKey crypto.PrivateKeySigningBytes) ([]byte, error) {
return t.Encode(privKey, dagcbor.Encode)
}
// ToDagCborWriter is the same as ToDagCbor, but it accepts an io.Writer.
func (t *Token) ToDagCborWriter(w io.Writer, privKey crypto.PrivateKeySigningBytes) error {
return t.EncodeWriter(w, privKey, dagcbor.Encode)
}
// ToDagJson marshals the Token to the DAG-JSON format.
func (t *Token) ToDagJson(privKey crypto.PrivateKeySigningBytes) ([]byte, error) {
return t.Encode(privKey, dagjson.Encode)
}
// ToDagJsonWriter is the same as ToDagJson, but it accepts an io.Writer.
func (t *Token) ToDagJsonWriter(w io.Writer, privKey crypto.PrivateKeySigningBytes) error {
return t.EncodeWriter(w, privKey, dagjson.Encode)
}
// Decode unmarshals the input data using the format specified by the
// provided codec.Decoder into a Token.
//
// An error is returned if the conversion fails or if the resulting
// Token is invalid.
func Decode(b []byte, decFn codec.Decoder, resolvOpts ...did.ResolutionOption) (*Token, error) {
node, err := ipld.Decode(b, decFn)
if err != nil {
return nil, err
}
return FromIPLD(node, resolvOpts...)
}
// DecodeReader is the same as Decode, but accept an io.Reader.
func DecodeReader(r io.Reader, decFn codec.Decoder, resolvOpts ...did.ResolutionOption) (*Token, error) {
node, err := ipld.DecodeStreaming(r, decFn)
if err != nil {
return nil, err
}
return FromIPLD(node, resolvOpts...)
}
// FromDagCbor unmarshals the input data into a Token.
//
// An error is returned if the conversion fails or if the resulting
// Token is invalid.
func FromDagCbor(data []byte, resolvOpts ...did.ResolutionOption) (*Token, error) {
pay, err := envelope.FromDagCbor[*tokenPayloadModel](data, resolvOpts...)
if err != nil {
return nil, err
}
tkn, err := tokenFromModel(*pay)
if err != nil {
return nil, err
}
return tkn, err
}
// FromDagCborReader is the same as FromDagCbor, but accept an io.Reader.
func FromDagCborReader(r io.Reader, resolvOpts ...did.ResolutionOption) (*Token, error) {
return DecodeReader(r, dagcbor.Decode, resolvOpts...)
}
// FromDagJson unmarshals the input data into a Token.
//
// An error is returned if the conversion fails or if the resulting
// Token is invalid.
func FromDagJson(data []byte, resolvOpts ...did.ResolutionOption) (*Token, error) {
return Decode(data, dagjson.Decode, resolvOpts...)
}
// FromDagJsonReader is the same as FromDagJson, but accept an io.Reader.
func FromDagJsonReader(r io.Reader, resolvOpts ...did.ResolutionOption) (*Token, error) {
return DecodeReader(r, dagjson.Decode, resolvOpts...)
}
// FromIPLD decode the given IPLD representation into a Token.
func FromIPLD(node datamodel.Node, resolvOpts ...did.ResolutionOption) (*Token, error) {
pay, err := envelope.FromIPLD[*tokenPayloadModel](node, resolvOpts...)
if err != nil {
return nil, err
}
tkn, err := tokenFromModel(*pay)
if err != nil {
return nil, err
}
return tkn, err
}
func (t *Token) toIPLD(privKey crypto.PrivateKeySigningBytes) (datamodel.Node, error) {
var exp *int64
if t.expiration != nil {
u := t.expiration.Unix()
exp = &u
}
var iat *int64
if t.issuedAt != nil {
i := t.issuedAt.Unix()
iat = &i
}
model := &tokenPayloadModel{
Iss: t.issuer.String(),
Claims: t.claims,
Meta: t.meta,
Nonce: t.nonce,
Exp: exp,
Iat: iat,
}
if len(model.Claims.Keys) == 0 {
model.Claims = nil
}
// seems like it's a requirement to have a null meta if there are no values?
if len(model.Meta.Keys) == 0 {
model.Meta = nil
}
return envelope.ToIPLD(privKey, model)
}

View File

@@ -1,35 +0,0 @@
package attestation_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/token/attestation"
)
func TestSealUnsealRoundtrip(t *testing.T) {
t.Parallel()
privKey, iss, claims, meta, err := setupExampleNew()
require.NoError(t, err)
tkn1, err := attestation.New(iss,
attestation.WithClaimMap(claims),
attestation.WithMetaMap(meta),
attestation.WithExpirationIn(time.Minute),
attestation.WithoutIssuedAt(),
)
require.NoError(t, err)
data, cid1, err := tkn1.ToSealed(privKey)
require.NoError(t, err)
tkn2, cid2, err := attestation.FromSealed(data)
require.NoError(t, err)
assert.Equal(t, cid1, cid2)
assert.Equal(t, tkn1, tkn2)
}

View File

@@ -1,168 +0,0 @@
package attestation
import (
"time"
)
// Option is a type that allows optional fields to be set during the
// creation of a Token.
type Option func(*Token) error
// WithClaim adds a key/value pair in the "claims" field.
//
// WithClaims can be used multiple times in the same call.
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
// and ipld.Node.
func WithClaim(key string, val any) Option {
return func(t *Token) error {
return t.claims.Add(key, val)
}
}
// WithClaimsMap adds all key/value pairs in the provided map to the
// Token's "claims" field.
//
// WithClaimsMap can be used multiple times in the same call.
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
// and ipld.Node.
func WithClaimMap(m map[string]any) Option {
return func(t *Token) error {
for k, v := range m {
if err := t.claims.Add(k, v); err != nil {
return err
}
}
return nil
}
}
// WithEncryptedClaimsString adds a key/value pair in the "claims" field.
// The string value is encrypted with the given aesKey.
func WithEncryptedClaimsString(key, val string, encryptionKey []byte) Option {
return func(t *Token) error {
return t.claims.AddEncrypted(key, val, encryptionKey)
}
}
// WithEncryptedClaimsBytes adds a key/value pair in the "claims" field.
// The []byte value is encrypted with the given aesKey.
func WithEncryptedClaimsBytes(key string, val, encryptionKey []byte) Option {
return func(t *Token) error {
return t.claims.AddEncrypted(key, val, encryptionKey)
}
}
// WithMeta adds a key/value pair in the "meta" field.
//
// WithMeta can be used multiple times in the same call.
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
// and ipld.Node.
func WithMeta(key string, val any) Option {
return func(t *Token) error {
return t.meta.Add(key, val)
}
}
// WithMetaMap adds all key/value pairs in the provided map to the
// Token's "meta" field.
//
// WithMetaMap can be used multiple times in the same call.
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
// and ipld.Node.
func WithMetaMap(m map[string]any) Option {
return func(t *Token) error {
for k, v := range m {
if err := t.meta.Add(k, v); err != nil {
return err
}
}
return nil
}
}
// WithEncryptedMetaString adds a key/value pair in the "meta" field.
// The string value is encrypted with the given aesKey.
func WithEncryptedMetaString(key, val string, encryptionKey []byte) Option {
return func(t *Token) error {
return t.meta.AddEncrypted(key, val, encryptionKey)
}
}
// WithEncryptedMetaBytes adds a key/value pair in the "meta" field.
// The []byte value is encrypted with the given aesKey.
func WithEncryptedMetaBytes(key string, val, encryptionKey []byte) Option {
return func(t *Token) error {
return t.meta.AddEncrypted(key, val, encryptionKey)
}
}
// WithNonce sets the Token's nonce with the given value.
//
// If this option is not used, a random 12-byte nonce is generated for
// this required field. If you truly want to create an invocation Token
// without a nonce, use the WithEmptyNonce Option which will set the
// nonce to an empty byte array.
func WithNonce(nonce []byte) Option {
return func(t *Token) error {
t.nonce = nonce
return nil
}
}
// WithEmptyNonce sets the Token's nonce to an empty byte slice as
// suggested by the UCAN spec for invocation tokens that represent
// idempotent operations.
func WithEmptyNonce() Option {
return func(t *Token) error {
t.nonce = []byte{}
return nil
}
}
// WithExpiration set's the Token's optional "expiration" field to the
// value of the provided time.Time.
func WithExpiration(exp time.Time) Option {
return func(t *Token) error {
exp = exp.Round(time.Second)
t.expiration = &exp
return nil
}
}
// WithExpirationIn set's the Token's optional "expiration" field to
// Now() plus the given duration.
func WithExpirationIn(after time.Duration) Option {
return WithExpiration(time.Now().Add(after))
}
// WithIssuedAt sets the Token's IssuedAt field to the provided
// time.Time.
//
// If this Option is not provided, the invocation Token's iat field will
// be set to the value of time.Now(). If you want to create an invocation
// Token without this field being set, use the WithoutIssuedAt Option.
func WithIssuedAt(iat time.Time) Option {
return func(t *Token) error {
t.issuedAt = &iat
return nil
}
}
// WithIssuedAtIn sets the Token's IssuedAt field to Now() plus the
// given duration.
func WithIssuedAtIn(after time.Duration) Option {
return WithIssuedAt(time.Now().Add(after))
}
// WithoutIssuedAt clears the Token's IssuedAt field.
func WithoutIssuedAt() Option {
return func(t *Token) error {
t.issuedAt = nil
return nil
}
}

View File

@@ -1,73 +0,0 @@
package attestation
import (
_ "embed"
"fmt"
"sync"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/node/bindnode"
"github.com/ipld/go-ipld-prime/schema"
"github.com/ucan-wg/go-ucan/pkg/claims"
"github.com/ucan-wg/go-ucan/pkg/meta"
"github.com/ucan-wg/go-ucan/token/internal/envelope"
)
// [Tag] is the string used as a key within the SigPayload that identifies
// that the TokenPayload is an attestation.
//
// [Tag]: TODO: TBD
const Tag = "ucan/att@tbd" // TODO: TBD
//go:embed attestation.ipldsch
var schemaBytes []byte
var (
once sync.Once
ts *schema.TypeSystem
errSchema error
)
func mustLoadSchema() *schema.TypeSystem {
once.Do(func() {
ts, errSchema = ipld.LoadSchemaBytes(schemaBytes)
})
if errSchema != nil {
panic(fmt.Errorf("failed to load IPLD schema: %s", errSchema))
}
return ts
}
func payloadType() schema.Type {
return mustLoadSchema().TypeByName("Payload")
}
var _ envelope.Tokener = (*tokenPayloadModel)(nil)
type tokenPayloadModel struct {
// The DID of the Invoker
Iss string
// Arbitrary claims
Claims *claims.Claims
// Arbitrary Metadata
Meta *meta.Meta
// A unique, random nonce
Nonce []byte
// The timestamp at which the Invocation becomes invalid
// optional: can be nil
Exp *int64
// The timestamp at which the Invocation was created
Iat *int64
}
func (e *tokenPayloadModel) Prototype() schema.TypedPrototype {
return bindnode.Prototype((*tokenPayloadModel)(nil), payloadType())
}
func (*tokenPayloadModel) Tag() string {
return Tag
}

View File

@@ -1,89 +0,0 @@
package attestation_test
import (
"bytes"
_ "embed"
"encoding/base64"
"testing"
"github.com/MetaMask/go-did-it/crypto"
"github.com/MetaMask/go-did-it/crypto/ed25519"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/token/attestation"
"github.com/ucan-wg/go-ucan/token/internal/envelope"
)
//go:embed testdata/new.dagjson
var newDagJson []byte
const (
issuerPrivKeyCfg = "8bX3+HJxxlIGgNZ8yFG+t48oMGygEGyWD5Cy8ugeCIRksEIVyCabkuLVXbMZYj1lpXgL22Fok8nv52clGfEMXA=="
newCID = "zdpuAyWCG3GWfebFME3e4oG926tzpJodw4WTa9VjBwqPNiVWF"
)
func TestSchemaRoundTrip(t *testing.T) {
t.Parallel()
privKey := privKey(t, issuerPrivKeyCfg)
t.Run("via buffers", func(t *testing.T) {
t.Parallel()
// format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson
// function: DecodeDagJson() Seal() Unseal() EncodeDagJson()
p1, err := attestation.FromDagJson(newDagJson)
require.NoError(t, err)
cborBytes, id, err := p1.ToSealed(privKey)
require.NoError(t, err)
assert.Equal(t, newCID, envelope.CIDToBase58BTC(id))
p2, c2, err := attestation.FromSealed(cborBytes)
require.NoError(t, err)
assert.Equal(t, id, c2)
readJson, err := p2.ToDagJson(privKey)
require.NoError(t, err)
assert.JSONEq(t, string(newDagJson), string(readJson))
})
t.Run("via streaming", func(t *testing.T) {
t.Parallel()
buf := bytes.NewBuffer(newDagJson)
// format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson
// function: DecodeDagJson() Seal() Unseal() EncodeDagJson()
p1, err := attestation.FromDagJsonReader(buf)
require.NoError(t, err)
cborBytes := &bytes.Buffer{}
id, err := p1.ToSealedWriter(cborBytes, privKey)
require.NoError(t, err)
assert.Equal(t, newCID, envelope.CIDToBase58BTC(id))
p2, c2, err := attestation.FromSealedReader(cborBytes)
require.NoError(t, err)
assert.Equal(t, envelope.CIDToBase58BTC(id), envelope.CIDToBase58BTC(c2))
readJson := &bytes.Buffer{}
require.NoError(t, p2.ToDagJsonWriter(readJson, privKey))
assert.JSONEq(t, string(newDagJson), readJson.String())
})
}
func privKey(t require.TestingT, privKeyCfg string) crypto.PrivateKeySigningBytes {
privBytes, err := base64.StdEncoding.DecodeString(privKeyCfg)
require.NoError(t, err)
privKey, err := ed25519.PrivateKeyFromBytes(privBytes)
require.NoError(t, err)
return privKey
}

View File

@@ -1 +0,0 @@
[{"/":{"bytes":"9lfCwLn+HqFGPNMbD9mIuMjhZarhZk1mOSq2eGLIBfRM6B5dtIftDh25TOG3qJrWRvZtvupd0az/PiVv/8zMCg"}},{"h":{"/":{"bytes":"NAHtAe0BE3E"}},"ucan/att@tbd":{"claims":{"claim1":"UCAN is great"},"exp":1767790946,"iss":"did:key:z6MkmEJhVC9xHMREKTw1HpPrwVh6fcUbJ8hoVEa3UQdP9sNs","meta":{"env":"development"},"nonce":{"/":{"bytes":"jPnfQhL20Eoq/8fu"}}}}]

View File

@@ -238,7 +238,7 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) {
tkn.issuer, err = did.Parse(m.Iss)
if err != nil {
return nil, fmt.Errorf("parse iss: %w", err)
return nil, fmt.Errorf("parse issuer: %w", err)
}
if tkn.audience, err = did.Parse(m.Aud); err != nil {

View File

@@ -71,6 +71,25 @@ type generator struct {
chains []proof
}
func (g *generator) createSelfDelegations(personas []didtest.Persona) error {
for _, persona := range personas {
_, err := g.createDelegation(newDelegationParams{
privKey: persona.PrivKey(),
aud: persona.DID(),
cmd: delegationtest.NominalCommand,
pol: policytest.EmptyPolicy,
sub: persona.DID(),
opts: []delegation.Option{
delegation.WithNonce(constantNonce),
},
}, persona.Name()+persona.Name(), noopVariant())
if err != nil {
return err
}
}
return nil
}
func (g *generator) chainPersonas(personas []didtest.Persona, acc acc, vari variant) error {
acc.name += personas[0].Name()

View File

@@ -6,7 +6,11 @@ import (
func main() {
gen := &generator{}
err := gen.chainPersonas(didtest.Personas(), acc{}, noopVariant())
err := gen.createSelfDelegations(didtest.Personas())
if err != nil {
panic(err)
}
err = gen.chainPersonas(didtest.Personas(), acc{}, noopVariant())
if err != nil {
panic(err)
}

View File

@@ -8,6 +8,48 @@ import (
"github.com/ucan-wg/go-ucan/token/delegation"
)
var (
TokenAliceAliceCID = cid.MustParse("bafyreiddqsv5rrpcormtcs3dg7hzwjr2grxyyozc2f2surxdbnctdqpfzi")
TokenAliceAliceSealed = mustGetBundle(TokenAliceAliceCID).Sealed
TokenAliceAliceBundle = mustGetBundle(TokenAliceAliceCID)
TokenAliceAlice = mustGetBundle(TokenAliceAliceCID).Decoded
)
var (
TokenBobBobCID = cid.MustParse("bafyreid4dwdov4yijvnb7xxhcndsxifzw5yry4sm4frex6relttlnledo4")
TokenBobBobSealed = mustGetBundle(TokenBobBobCID).Sealed
TokenBobBobBundle = mustGetBundle(TokenBobBobCID)
TokenBobBob = mustGetBundle(TokenBobBobCID).Decoded
)
var (
TokenCarolCarolCID = cid.MustParse("bafyreiekuehdsubdfllqecsat4gsfveyqq6442ejuiqfsgu3tplrus5l3e")
TokenCarolCarolSealed = mustGetBundle(TokenCarolCarolCID).Sealed
TokenCarolCarolBundle = mustGetBundle(TokenCarolCarolCID)
TokenCarolCarol = mustGetBundle(TokenCarolCarolCID).Decoded
)
var (
TokenDanDanCID = cid.MustParse("bafyreigzd442yhyizbx54kd76ewxssh5owuxv26ziittnblnj4h3a555dm")
TokenDanDanSealed = mustGetBundle(TokenDanDanCID).Sealed
TokenDanDanBundle = mustGetBundle(TokenDanDanCID)
TokenDanDan = mustGetBundle(TokenDanDanCID).Decoded
)
var (
TokenErinErinCID = cid.MustParse("bafyreigl5lbogpzq7iyz6qkzhicv4zscu26j62k4ydgcqogdiqmks5tz7q")
TokenErinErinSealed = mustGetBundle(TokenErinErinCID).Sealed
TokenErinErinBundle = mustGetBundle(TokenErinErinCID)
TokenErinErin = mustGetBundle(TokenErinErinCID).Decoded
)
var (
TokenFrankFrankCID = cid.MustParse("bafyreic6hgmqf2vwszboldlqeobpy2plpkcmj4dhhug76akcnafb2pt6em")
TokenFrankFrankSealed = mustGetBundle(TokenFrankFrankCID).Sealed
TokenFrankFrankBundle = mustGetBundle(TokenFrankFrankCID)
TokenFrankFrank = mustGetBundle(TokenFrankFrankCID).Decoded
)
var (
TokenAliceBobCID = cid.MustParse("bafyreifa35rjstdm37cjudzs72ab22rnh5blny725khtapox63fnsj6pbe")
TokenAliceBobSealed = mustGetBundle(TokenAliceBobCID).Sealed
@@ -170,6 +212,12 @@ var (
)
var AllTokens = []*delegation.Token{
TokenAliceAlice,
TokenBobBob,
TokenCarolCarol,
TokenDanDan,
TokenErinErin,
TokenFrankFrank,
TokenAliceBob,
TokenBobCarol,
TokenCarolDan,
@@ -196,6 +244,12 @@ var AllTokens = []*delegation.Token{
}
var AllBundles = []delegation.Bundle{
TokenAliceAliceBundle,
TokenBobBobBundle,
TokenCarolCarolBundle,
TokenDanDanBundle,
TokenErinErinBundle,
TokenFrankFrankBundle,
TokenAliceBobBundle,
TokenBobCarolBundle,
TokenCarolDanBundle,
@@ -222,6 +276,12 @@ var AllBundles = []delegation.Bundle{
}
var cidToName = map[cid.Cid]string{
TokenAliceAliceCID: "TokenAliceAlice",
TokenBobBobCID: "TokenBobBob",
TokenCarolCarolCID: "TokenCarolCarol",
TokenDanDanCID: "TokenDanDan",
TokenErinErinCID: "TokenErinErin",
TokenFrankFrankCID: "TokenFrankFrank",
TokenAliceBobCID: "TokenAliceBob",
TokenBobCarolCID: "TokenBobCarol",
TokenCarolDanCID: "TokenCarolDan",

View File

@@ -42,23 +42,6 @@ func WithMeta(key string, val any) Option {
}
}
// WithMetaMap adds all key/value pairs in the provided map to the
// Token's "meta" field.
//
// WithMetaMap can be used multiple times in the same call.
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
// and ipld.Node.
func WithMetaMap(m map[string]any) Option {
return func(t *Token) error {
for k, v := range m {
if err := t.meta.Add(k, v); err != nil {
return err
}
}
return nil
}
}
// WithEncryptedMetaString adds a key/value pair in the "meta" field.
// The string value is encrypted with the given key.
// The ciphertext will be 40 bytes larger than the plaintext due to encryption overhead.

View File

@@ -109,10 +109,23 @@ func New(iss did.DID, cmd command.Command, sub did.DID, prf []cid.Cid, opts ...O
return &tkn, nil
}
// NewSelfSigned is similar to New, but self-signs the invocation, and therefore does not require a proof.
// It's similar to having an invocation with a delegation from the invoker to itself.
// This can be useful in some protocols where the invoker is the same as the subject, or to prove ownership of a resource.
//
// You can read it as "(Issuer - I) executes (command) on itself".
func NewSelfSigned(iss did.DID, cmd command.Command, opts ...Option) (*Token, error) {
return New(iss, cmd, iss, nil, opts...)
}
// ExecutionAllowed verifies that the invocation respects the rules and can be executed.
// IMPORTANT: this function does NOT verify that the subject (and audience if set) makes sense in your context.
func (t *Token) ExecutionAllowed(loader delegation.Loader) error {
return t.executionAllowed(loader, t.arguments)
}
// ExecutionAllowedWithArgsHook is the same as ExecutionAllowed, but allows to modify the arguments before verifying them.
// IMPORTANT: this function does NOT verify that the subject (and audience if set) makes sense in your context.
func (t *Token) ExecutionAllowedWithArgsHook(loader delegation.Loader, hook func(args args.ReadOnly) (*args.Args, error)) error {
newArgs, err := hook(t.arguments.ReadOnly())
if err != nil {
@@ -204,6 +217,11 @@ func (t *Token) Cause() *cid.Cid {
return t.cause
}
// IsSelfSigned returns true if the token is self-signed, ie it has the same issuer and subject.
func (t *Token) IsSelfSigned() bool {
return t.issuer.Equal(t.subject)
}
// IsValidNow verifies that the token can be used at the current time, based on expiration or "not before" fields.
// This does NOT do any other kind of verifications.
func (t *Token) IsValidNow() bool {
@@ -276,7 +294,7 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) {
)
if tkn.issuer, err = did.Parse(m.Iss); err != nil {
return nil, fmt.Errorf("parse iss: %w", err)
return nil, fmt.Errorf("parse issuer: %w", err)
}
if tkn.subject, err = did.Parse(m.Sub); err != nil {

View File

@@ -3,6 +3,7 @@ package invocation_test
import (
_ "embed"
"testing"
"time"
"github.com/MetaMask/go-did-it/didtest"
"github.com/ipfs/go-cid"
@@ -18,144 +19,257 @@ import (
//go:embed testdata/new.dagjson
var newDagJson []byte
const (
missingTknCIDStr = "bafyreigwypmw6eul6vadi6g6lnfbsfo2zck7gfzsbjoroqs3djhnzzc7mm"
)
//go:embed testdata/selfsigned.dagjson
var selfsignedDagJson []byte
const missingTknCIDStr = "bafyreigwypmw6eul6vadi6g6lnfbsfo2zck7gfzsbjoroqs3djhnzzc7mm"
var emptyArguments = args.New()
func TestToken_ExecutionAllowed(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
issuer didtest.Persona
cmd command.Command
args *args.Args
proofs []cid.Cid
opts []invocation.Option
err error
}{
// Passes
{
name: "passes - only root",
issuer: didtest.PersonaBob,
cmd: delegationtest.NominalCommand,
args: emptyArguments,
proofs: delegationtest.ProofAliceBob,
err: nil,
},
{
name: "passes - valid chain",
issuer: didtest.PersonaFrank,
cmd: delegationtest.NominalCommand,
args: emptyArguments,
proofs: delegationtest.ProofAliceBobCarolDanErinFrank,
err: nil,
},
{
name: "passes - proof chain attenuates command",
issuer: didtest.PersonaFrank,
cmd: delegationtest.AttenuatedCommand,
args: emptyArguments,
proofs: delegationtest.ProofAliceBobCarolDanErinFrank_ValidAttenuatedCommand,
err: nil,
},
{
name: "passes - invocation attenuates command",
issuer: didtest.PersonaFrank,
cmd: delegationtest.AttenuatedCommand,
args: emptyArguments,
proofs: delegationtest.ProofAliceBobCarolDanErinFrank,
err: nil,
},
{
name: "passes - arguments satisfy empty policy",
issuer: didtest.PersonaFrank,
cmd: delegationtest.NominalCommand,
args: policytest.SpecValidArguments,
proofs: delegationtest.ProofAliceBobCarolDanErinFrank,
err: nil,
},
{
name: "passes - arguments satisfy example policy",
issuer: didtest.PersonaFrank,
cmd: delegationtest.NominalCommand,
args: policytest.SpecValidArguments,
proofs: delegationtest.ProofAliceBobCarolDanErinFrank_ValidExamplePolicy,
err: nil,
},
{
name: "passes - self-signed invocation doesn't require proof",
issuer: didtest.PersonaAlice,
cmd: delegationtest.NominalCommand,
args: emptyArguments,
proofs: nil,
err: nil,
},
{
name: "passes - self-signed invocation accepts a delegation to itself",
issuer: didtest.PersonaAlice,
cmd: delegationtest.NominalCommand,
args: emptyArguments,
proofs: []cid.Cid{delegationtest.TokenAliceAliceCID},
err: nil,
},
t.Run("passes - only root", func(t *testing.T) {
t.Parallel()
// Fails
{
name: "fails - no proof",
issuer: didtest.PersonaCarol,
cmd: delegationtest.NominalCommand,
args: emptyArguments,
proofs: delegationtest.ProofEmpty,
err: invocation.ErrNoProof,
},
{
name: "fails - missing referenced delegation",
issuer: didtest.PersonaCarol,
cmd: delegationtest.NominalCommand,
args: emptyArguments,
proofs: []cid.Cid{cid.MustParse(missingTknCIDStr), delegationtest.TokenAliceBobCID},
err: invocation.ErrMissingDelegation,
},
{
name: "fails - referenced delegation expired",
issuer: didtest.PersonaFrank,
cmd: delegationtest.NominalCommand,
args: emptyArguments,
proofs: delegationtest.ProofAliceBobCarolDanErinFrank_InvalidExpired,
err: invocation.ErrTokenInvalidNow,
},
{
name: "fails - referenced delegation inactive",
issuer: didtest.PersonaFrank,
cmd: delegationtest.NominalCommand,
args: emptyArguments,
proofs: delegationtest.ProofAliceBobCarolDanErinFrank_InvalidInactive,
err: invocation.ErrTokenInvalidNow,
},
{
name: "fails - last (or only) delegation not root",
issuer: didtest.PersonaFrank,
cmd: delegationtest.NominalCommand,
args: emptyArguments,
proofs: []cid.Cid{delegationtest.TokenErinFrankCID, delegationtest.TokenDanErinCID, delegationtest.TokenCarolDanCID},
err: invocation.ErrLastNotRoot,
},
{
name: "fails - broken chain",
issuer: didtest.PersonaFrank,
cmd: delegationtest.NominalCommand,
args: emptyArguments,
proofs: []cid.Cid{delegationtest.TokenCarolDanCID, delegationtest.TokenAliceBobCID},
err: invocation.ErrBrokenChain,
},
{
name: "fails - first not issued to invoker",
issuer: didtest.PersonaFrank,
cmd: delegationtest.NominalCommand,
args: emptyArguments,
proofs: []cid.Cid{delegationtest.TokenBobCarolCID, delegationtest.TokenAliceBobCID},
err: invocation.ErrBrokenChain,
},
{
name: "fails - proof chain expands command",
issuer: didtest.PersonaFrank,
cmd: delegationtest.NominalCommand,
args: emptyArguments,
proofs: delegationtest.ProofAliceBobCarolDanErinFrank_InvalidExpandedCommand,
err: invocation.ErrCommandNotCovered,
},
{
name: "fails - invocation expands command",
issuer: didtest.PersonaFrank,
cmd: delegationtest.ExpandedCommand,
args: emptyArguments,
proofs: delegationtest.ProofAliceBobCarolDanErinFrank,
err: invocation.ErrCommandNotCovered,
},
{
name: "fails - inconsistent subject",
issuer: didtest.PersonaFrank,
cmd: delegationtest.ExpandedCommand,
args: emptyArguments,
proofs: delegationtest.ProofAliceBobCarolDanErinFrank_InvalidSubject,
err: invocation.ErrWrongSub,
},
{
name: "fails - arguments don't satisfy example policy",
issuer: didtest.PersonaFrank,
cmd: delegationtest.NominalCommand,
args: policytest.SpecInvalidArguments,
proofs: delegationtest.ProofAliceBobCarolDanErinFrank_ValidExamplePolicy,
err: invocation.ErrPolicyNotSatisfied,
},
{
name: "fails - self-signed invocation refuses a delegation to itself for a different DID",
issuer: didtest.PersonaAlice,
cmd: delegationtest.NominalCommand,
args: emptyArguments,
proofs: []cid.Cid{delegationtest.TokenBobBobCID},
err: invocation.ErrWrongSub,
},
} {
t.Run(tc.name, func(t *testing.T) {
tc.opts = append(tc.opts, invocation.WithArguments(tc.args))
testPasses(t, didtest.PersonaBob, delegationtest.NominalCommand, emptyArguments, delegationtest.ProofAliceBob)
})
tkn, err := invocation.New(tc.issuer.DID(), tc.cmd, didtest.PersonaAlice.DID(), tc.proofs, tc.opts...)
require.NoError(t, err)
t.Run("passes - valid chain", func(t *testing.T) {
t.Parallel()
t.Log(tkn.String())
testPasses(t, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank)
})
err = tkn.ExecutionAllowed(delegationtest.GetDelegationLoader())
t.Run("passes - proof chain attenuates command", func(t *testing.T) {
t.Parallel()
if tc.err != nil {
require.ErrorIs(t, err, tc.err)
} else {
require.NoError(t, err)
}
})
}
}
testPasses(t, didtest.PersonaFrank, delegationtest.AttenuatedCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank_ValidAttenuatedCommand)
})
const (
nonce = "6roDhGi0kiNriQAz7J3d+bOeoI/tj8ENikmQNbtjnD0"
subjectCmd = "/foo/bar"
)
t.Run("passes - invocation attenuates command", func(t *testing.T) {
t.Parallel()
func TestConstructors(t *testing.T) {
cmd, err := command.Parse(subjectCmd)
require.NoError(t, err)
testPasses(t, didtest.PersonaFrank, delegationtest.AttenuatedCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank)
})
iat, err := time.Parse(time.RFC3339, "2100-01-01T00:00:00Z")
require.NoError(t, err)
t.Run("passes - arguments satisfy empty policy", func(t *testing.T) {
t.Parallel()
exp, err := time.Parse(time.RFC3339, "2200-01-01T00:00:00Z")
require.NoError(t, err)
testPasses(t, didtest.PersonaFrank, delegationtest.NominalCommand, policytest.SpecValidArguments, delegationtest.ProofAliceBobCarolDanErinFrank)
})
t.Run("passes - arguments satify example policy", func(t *testing.T) {
t.Parallel()
testPasses(t, didtest.PersonaFrank, delegationtest.NominalCommand, policytest.SpecValidArguments, delegationtest.ProofAliceBobCarolDanErinFrank_ValidExamplePolicy)
})
t.Run("fails - no proof", func(t *testing.T) {
t.Parallel()
testFails(t, invocation.ErrNoProof, didtest.PersonaCarol, delegationtest.NominalCommand, emptyArguments, delegationtest.ProofEmpty)
})
t.Run("fails - missing referenced delegation", func(t *testing.T) {
t.Parallel()
missingTknCID, err := cid.Parse(missingTknCIDStr)
t.Run("New", func(t *testing.T) {
tkn, err := invocation.New(
didtest.PersonaAlice.DID(), cmd, didtest.PersonaBob.DID(),
delegationtest.ProofAliceBob,
invocation.WithNonce([]byte(nonce)),
invocation.WithIssuedAt(iat),
invocation.WithExpiration(exp),
invocation.WithArgument("foo", "bar"),
invocation.WithMeta("baz", 123),
)
require.NoError(t, err)
prf := []cid.Cid{missingTknCID, delegationtest.TokenAliceBobCID}
testFails(t, invocation.ErrMissingDelegation, didtest.PersonaCarol, delegationtest.NominalCommand, emptyArguments, prf)
require.False(t, tkn.IsSelfSigned())
data, err := tkn.ToDagJson(didtest.PersonaAlice.PrivKey())
require.NoError(t, err)
require.JSONEq(t, string(newDagJson), string(data))
})
t.Run("fails - referenced delegation expired", func(t *testing.T) {
t.Parallel()
t.Run("Self-Signed", func(t *testing.T) {
tkn, err := invocation.NewSelfSigned(
didtest.PersonaAlice.DID(), cmd,
invocation.WithNonce([]byte(nonce)),
invocation.WithIssuedAt(iat),
invocation.WithExpiration(exp),
invocation.WithArgument("foo", "bar"),
invocation.WithMeta("baz", 123),
)
require.NoError(t, err)
testFails(t, invocation.ErrTokenInvalidNow, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank_InvalidExpired)
require.True(t, tkn.IsSelfSigned())
data, err := tkn.ToDagJson(didtest.PersonaAlice.PrivKey())
require.NoError(t, err)
require.JSONEq(t, string(selfsignedDagJson), string(data))
})
t.Run("fails - referenced delegation inactive", func(t *testing.T) {
t.Parallel()
testFails(t, invocation.ErrTokenInvalidNow, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank_InvalidInactive)
})
t.Run("fails - last (or only) delegation not root", func(t *testing.T) {
t.Parallel()
prf := []cid.Cid{delegationtest.TokenErinFrankCID, delegationtest.TokenDanErinCID, delegationtest.TokenCarolDanCID}
testFails(t, invocation.ErrLastNotRoot, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, prf)
})
t.Run("fails - broken chain", func(t *testing.T) {
t.Parallel()
prf := []cid.Cid{delegationtest.TokenCarolDanCID, delegationtest.TokenAliceBobCID}
testFails(t, invocation.ErrBrokenChain, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, prf)
})
t.Run("fails - first not issued to invoker", func(t *testing.T) {
t.Parallel()
prf := []cid.Cid{delegationtest.TokenBobCarolCID, delegationtest.TokenAliceBobCID}
testFails(t, invocation.ErrBrokenChain, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, prf)
})
t.Run("fails - proof chain expands command", func(t *testing.T) {
t.Parallel()
testFails(t, invocation.ErrCommandNotCovered, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank_InvalidExpandedCommand)
})
t.Run("fails - invocation expands command", func(t *testing.T) {
t.Parallel()
testFails(t, invocation.ErrCommandNotCovered, didtest.PersonaFrank, delegationtest.ExpandedCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank)
})
t.Run("fails - inconsistent subject", func(t *testing.T) {
t.Parallel()
testFails(t, invocation.ErrWrongSub, didtest.PersonaFrank, delegationtest.ExpandedCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank_InvalidSubject)
})
t.Run("passes - arguments satisfy example policy", func(t *testing.T) {
t.Parallel()
testFails(t, invocation.ErrPolicyNotSatisfied, didtest.PersonaFrank, delegationtest.NominalCommand, policytest.SpecInvalidArguments, delegationtest.ProofAliceBobCarolDanErinFrank_ValidExamplePolicy)
})
}
func test(t *testing.T, persona didtest.Persona, cmd command.Command, args *args.Args, prf []cid.Cid, opts ...invocation.Option) error {
t.Helper()
opts = append(opts, invocation.WithArguments(args))
tkn, err := invocation.New(persona.DID(), cmd, didtest.PersonaAlice.DID(), prf, opts...)
require.NoError(t, err)
return tkn.ExecutionAllowed(delegationtest.GetDelegationLoader())
}
func testFails(t *testing.T, expErr error, persona didtest.Persona, cmd command.Command, args *args.Args, prf []cid.Cid, opts ...invocation.Option) {
err := test(t, persona, cmd, args, prf, opts...)
require.ErrorIs(t, err, expErr)
}
func testPasses(t *testing.T, persona didtest.Persona, cmd command.Command, args *args.Args, prf []cid.Cid, opts ...invocation.Option) {
err := test(t, persona, cmd, args, prf, opts...)
require.NoError(t, err)
}

View File

@@ -31,6 +31,7 @@ func WithArgument(key string, val any) Option {
func WithArguments(args *args.Args) Option {
return func(t *Token) error {
t.arguments.Include(args)
return nil
}
}
@@ -64,23 +65,6 @@ func WithMeta(key string, val any) Option {
}
}
// WithMetaMap adds all key/value pairs in the provided map to the
// Token's "meta" field.
//
// WithMetaMap can be used multiple times in the same call.
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
// and ipld.Node.
func WithMetaMap(m map[string]any) Option {
return func(t *Token) error {
for k, v := range m {
if err := t.meta.Add(k, v); err != nil {
return err
}
}
return nil
}
}
// WithEncryptedMetaString adds a key/value pair in the "meta" field.
// The string value is encrypted with the given aesKey.
func WithEncryptedMetaString(key, val string, encryptionKey []byte) Option {

View File

@@ -18,11 +18,11 @@ import (
// 1. When a token is read/unsealed from its containing envelope (`envelope` package):
// a. The envelope can be decoded.
// b. The envelope contains a Signature, VarsigHeader and Payload.
// c. The Payload contains an iss field that contains a valid `did:key`.
// d. The public key can be extracted from the `did:key`.
// e. The public key type is supported by go-ucan.
// c. The Payload contains an iss field that contains a valid DID.
// d. One or more public keys can be derived from the DID.
// e. One or more public keys are supported by go-ucan.
// f. The Signature can be decoded per the VarsigHeader.
// g. The SigPayload can be verified using the Signature and public key.
// g. The SigPayload can be verified using the Signature and one public key.
// h. The field key of the TokenPayload matches the expected tag.
//
// 2. When the token is created or passes step one (token constructor or decoder):
@@ -35,7 +35,7 @@ import (
// c. All the delegation must be active (nbf in the past or absent).
//
// 4. When the proof chain is being validated (verifyProofs below):
// a. There must be at least one delegation in the proof chain.
// a. Self-signed invocations (issuer == subject) are allowed and don't require further proof. Otherwise, proof is required.
// b. All referenced delegations must be available.
// c. The first proof must be issued to the Invoker (audience DID).
// d. The Issuer of each delegation must be the Audience in the next one.
@@ -51,8 +51,11 @@ import (
// - principal alignment
// - command alignment
func (t *Token) verifyProofs(delegations []*delegation.Token) error {
// There must be at least one delegation referenced - 4a
if len(delegations) < 1 {
// Self-signed invocations (issuer == subject) are allowed and don't require further proof. Otherwise, proof is required. - 4a
if len(delegations) == 0 && t.issuer.Equal(t.subject) {
return nil
}
if len(delegations) == 0 {
return ErrNoProof
}

View File

@@ -2,6 +2,7 @@ package invocation_test
import (
"bytes"
_ "embed"
"encoding/base64"
"testing"
@@ -14,9 +15,12 @@ import (
"github.com/ucan-wg/go-ucan/token/invocation"
)
//go:embed testdata/full_example.dagjson
var fullExampleDagJson []byte
const (
issuerPrivKeyCfg = "BeAgktAj8irGgWjp4PGk/fV67e5CcML/KRmmHSldco3etP5lRiuYQ+VVO/39ol3XXruJC8deSuBxoEXzgdYpYw=="
newCID = "zdpuB1NjhETofEUp5iYzoHjSc2KKgZvSoT6FBaLMoVzzsxiR1"
fullExampleCID = "zdpuB1NjhETofEUp5iYzoHjSc2KKgZvSoT6FBaLMoVzzsxiR1"
)
func TestSchemaRoundTrip(t *testing.T) {
@@ -30,12 +34,12 @@ func TestSchemaRoundTrip(t *testing.T) {
// format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson
// function: DecodeDagJson() Seal() Unseal() EncodeDagJson()
p1, err := invocation.FromDagJson(newDagJson)
p1, err := invocation.FromDagJson(fullExampleDagJson)
require.NoError(t, err)
cborBytes, id, err := p1.ToSealed(privKey)
require.NoError(t, err)
assert.Equal(t, newCID, envelope.CIDToBase58BTC(id))
assert.Equal(t, fullExampleCID, envelope.CIDToBase58BTC(id))
p2, c2, err := invocation.FromSealed(cborBytes)
require.NoError(t, err)
@@ -44,13 +48,13 @@ func TestSchemaRoundTrip(t *testing.T) {
readJson, err := p2.ToDagJson(privKey)
require.NoError(t, err)
assert.JSONEq(t, string(newDagJson), string(readJson))
assert.JSONEq(t, string(fullExampleDagJson), string(readJson))
})
t.Run("via streaming", func(t *testing.T) {
t.Parallel()
buf := bytes.NewBuffer(newDagJson)
buf := bytes.NewBuffer(fullExampleDagJson)
// format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson
// function: DecodeDagJson() Seal() Unseal() EncodeDagJson()
@@ -61,7 +65,7 @@ func TestSchemaRoundTrip(t *testing.T) {
cborBytes := &bytes.Buffer{}
id, err := p1.ToSealedWriter(cborBytes, privKey)
require.NoError(t, err)
assert.Equal(t, newCID, envelope.CIDToBase58BTC(id))
assert.Equal(t, fullExampleCID, envelope.CIDToBase58BTC(id))
p2, c2, err := invocation.FromSealedReader(cborBytes)
require.NoError(t, err)
@@ -70,7 +74,7 @@ func TestSchemaRoundTrip(t *testing.T) {
readJson := &bytes.Buffer{}
require.NoError(t, p2.ToDagJsonWriter(readJson, privKey))
assert.JSONEq(t, string(newDagJson), readJson.String())
assert.JSONEq(t, string(fullExampleDagJson), readJson.String())
})
}

View File

@@ -0,0 +1 @@
[{"/":{"bytes":"tRKNRahqwdyR6OpytuGIdcYI7HxXvKI5I594zznCLbN2C6WP5f8FIfIQlo0Nnqg4xFgKjJGAbIEVqeCZdib1Dw"}},{"h":{"/":{"bytes":"NAHtAe0BE3E"}},"ucan/inv@1.0.0-rc.1":{"args":{"headers":{"Content-Type":"application/json"},"payload":{"body":"UCAN is great","draft":true,"title":"UCAN for Fun and Profit","topics":["authz","journal"]},"uri":"https://example.com/blog/posts"},"cmd":"/crud/create","exp":1753965668,"iss":"did:key:z6MkuScdGeTmbWubyoWWpPmX9wkwdZAshkTcLKb1bf4Cyj8N","meta":{"env":"development","tags":["blog","post","pr#123"]},"nonce":{"/":{"bytes":"BBR5znl7VpRof4ac"}},"prf":[{"/":"bafyreigx3qxd2cndpe66j2mdssj773ecv7tqd7wovcnz5raguw6lj7sjoe"},{"/":"bafyreib34ira254zdqgehz6f2bhwme2ja2re3ltcalejv4x4tkcveujvpa"},{"/":"bafyreibkb66tpo2ixqx3fe5hmekkbuasrod6olt5bwm5u5pi726mduuwlq"}],"sub":"did:key:z6MkuQU8kqxCAUeurotHyrnMgkMUBtJN8ozYxkwctnop4zzB"}}]

View File

@@ -1 +1 @@
[{"/":{"bytes":"tRKNRahqwdyR6OpytuGIdcYI7HxXvKI5I594zznCLbN2C6WP5f8FIfIQlo0Nnqg4xFgKjJGAbIEVqeCZdib1Dw"}},{"h":{"/":{"bytes":"NAHtAe0BE3E"}},"ucan/inv@1.0.0-rc.1":{"args":{"headers":{"Content-Type":"application/json"},"payload":{"body":"UCAN is great","draft":true,"title":"UCAN for Fun and Profit","topics":["authz","journal"]},"uri":"https://example.com/blog/posts"},"cmd":"/crud/create","exp":1753965668,"iss":"did:key:z6MkuScdGeTmbWubyoWWpPmX9wkwdZAshkTcLKb1bf4Cyj8N","meta":{"env":"development","tags":["blog","post","pr#123"]},"nonce":{"/":{"bytes":"BBR5znl7VpRof4ac"}},"prf":[{"/":"bafyreigx3qxd2cndpe66j2mdssj773ecv7tqd7wovcnz5raguw6lj7sjoe"},{"/":"bafyreib34ira254zdqgehz6f2bhwme2ja2re3ltcalejv4x4tkcveujvpa"},{"/":"bafyreibkb66tpo2ixqx3fe5hmekkbuasrod6olt5bwm5u5pi726mduuwlq"}],"sub":"did:key:z6MkuQU8kqxCAUeurotHyrnMgkMUBtJN8ozYxkwctnop4zzB"}}]
[{"/":{"bytes":"8BxXBbXtPVoqn/z804w2w2gZH9m6kT55ivv7u2kxqptAfDcFzlRWBu3YKE9ijfIezpa79Btq5ja0PpqwjfSLAw"}},{"h":{"/":{"bytes":"NAHtAe0BE3E"}},"ucan/inv@1.0.0-rc.1":{"args":{"foo":"bar"},"cmd":"/foo/bar","exp":7258118400,"iat":4102444800,"iss":"did:key:z6MknUz1mSj4pvS6aUUHekCHdUPv7HBhDyDBZQ2W3Vujc5qC","meta":{"baz":123},"nonce":{"/":{"bytes":"NnJvRGhHaTBraU5yaVFBejdKM2QrYk9lb0kvdGo4RU5pa21RTmJ0am5EMA"}},"prf":[{"/":"bafyreifa35rjstdm37cjudzs72ab22rnh5blny725khtapox63fnsj6pbe"}],"sub":"did:key:z6Mkf4WtCwPDtamsZvBJA4eSVcE7vZuRPy5Skm4HaoQv81i1"}}]

View File

@@ -0,0 +1 @@
[{"/":{"bytes":"ejXoQIdp3OGXewEkfQF4Z4Vd8c3H0XF319dsNh5DEP/2l9Nt9H1IhMpks1+HXoYFOKN3QmtxpPMoYmf/rhKaAQ"}},{"h":{"/":{"bytes":"NAHtAe0BE3E"}},"ucan/inv@1.0.0-rc.1":{"args":{"foo":"bar"},"cmd":"/foo/bar","exp":7258118400,"iat":4102444800,"iss":"did:key:z6MknUz1mSj4pvS6aUUHekCHdUPv7HBhDyDBZQ2W3Vujc5qC","meta":{"baz":123},"nonce":{"/":{"bytes":"NnJvRGhHaTBraU5yaVFBejdKM2QrYk9lb0kvdGo4RU5pa21RTmJ0am5EMA"}},"prf":[],"sub":"did:key:z6MknUz1mSj4pvS6aUUHekCHdUPv7HBhDyDBZQ2W3Vujc5qC"}}]

View File

@@ -21,8 +21,7 @@ import (
// Note: the returned delegation(s) don't have to match exactly the parameters, as long as they allow them.
// Note: the implemented algorithm won't perform well with a large number of delegations.
func FindProof(dlgs func() iter.Seq[*delegation.Bundle], issuer did.DID, cmd command.Command, subject did.DID) []cid.Cid {
// TODO: maybe that should be part of delegation.Token directly?
dlgMatch := func(dlg *delegation.Token, issuer did.DID, cmd command.Command, subject did.DID) bool {
continuePath := func(dlg *delegation.Token, issuer did.DID, cmd command.Command, subject did.DID) bool {
// The Subject of each delegation must equal the invocation's Subject (or Audience if defined). - 4f
if !dlg.Subject().Equal(subject) {
return false
@@ -47,7 +46,7 @@ func FindProof(dlgs func() iter.Seq[*delegation.Bundle], issuer did.DID, cmd com
var candidateLeaf []*delegation.Bundle
for bundle := range dlgs() {
if !dlgMatch(bundle.Decoded, issuer, cmd, subject) {
if !continuePath(bundle.Decoded, issuer, cmd, subject) {
continue
}
candidateLeaf = append(candidateLeaf, bundle)
@@ -83,7 +82,12 @@ func FindProof(dlgs func() iter.Seq[*delegation.Bundle], issuer did.DID, cmd com
// find parent delegation for our current delegation
for candidate := range dlgs() {
if !dlgMatch(candidate.Decoded, at.Decoded.Issuer(), at.Decoded.Command(), subject) {
// Prune the delegations that don't match the current proof.
if !continuePath(candidate.Decoded, at.Decoded.Issuer(), at.Decoded.Command(), subject) {
continue
}
// Prune the self-delegations as they can't get us closer to what we are looking for.
if candidate.Decoded.Issuer().Equal(candidate.Decoded.Audience()) {
continue
}

View File

@@ -4,7 +4,9 @@ import (
"iter"
"testing"
"github.com/MetaMask/go-did-it"
"github.com/MetaMask/go-did-it/didtest"
"github.com/ipfs/go-cid"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/command"
@@ -23,17 +25,76 @@ func TestFindProof(t *testing.T) {
}
}
require.Equal(t, delegationtest.ProofAliceBob,
FindProof(dlgs, didtest.PersonaBob.DID(), delegationtest.NominalCommand, didtest.PersonaAlice.DID()))
require.Equal(t, delegationtest.ProofAliceBobCarol,
FindProof(dlgs, didtest.PersonaCarol.DID(), delegationtest.NominalCommand, didtest.PersonaAlice.DID()))
require.Equal(t, delegationtest.ProofAliceBobCarolDan,
FindProof(dlgs, didtest.PersonaDan.DID(), delegationtest.NominalCommand, didtest.PersonaAlice.DID()))
require.Equal(t, delegationtest.ProofAliceBobCarolDanErin,
FindProof(dlgs, didtest.PersonaErin.DID(), delegationtest.NominalCommand, didtest.PersonaAlice.DID()))
require.Equal(t, delegationtest.ProofAliceBobCarolDanErinFrank,
FindProof(dlgs, didtest.PersonaFrank.DID(), delegationtest.NominalCommand, didtest.PersonaAlice.DID()))
for _, tc := range []struct {
name string
issuer did.DID
command command.Command
subject did.DID
expected []cid.Cid
}{
// Passes
{
name: "Alice --> Alice (self-delegation)",
issuer: didtest.PersonaAlice.DID(),
command: delegationtest.NominalCommand,
subject: didtest.PersonaAlice.DID(),
expected: []cid.Cid{delegationtest.TokenAliceAliceCID},
},
{
name: "Alice --> Bob",
issuer: didtest.PersonaBob.DID(),
command: delegationtest.NominalCommand,
subject: didtest.PersonaAlice.DID(),
expected: delegationtest.ProofAliceBob,
},
{
name: "Alice --> Bob --> Carol",
issuer: didtest.PersonaCarol.DID(),
command: delegationtest.NominalCommand,
subject: didtest.PersonaAlice.DID(),
expected: delegationtest.ProofAliceBobCarol,
},
{
name: "Alice --> Bob --> Carol --> Dan",
issuer: didtest.PersonaDan.DID(),
command: delegationtest.NominalCommand,
subject: didtest.PersonaAlice.DID(),
expected: delegationtest.ProofAliceBobCarolDan,
},
{
name: "Alice --> Bob --> Carol --> Dan --> Erin",
issuer: didtest.PersonaErin.DID(),
command: delegationtest.NominalCommand,
subject: didtest.PersonaAlice.DID(),
expected: delegationtest.ProofAliceBobCarolDanErin,
},
{
name: "Alice --> Bob --> Carol --> Dan --> Erin --> Frank",
issuer: didtest.PersonaFrank.DID(),
command: delegationtest.NominalCommand,
subject: didtest.PersonaAlice.DID(),
expected: delegationtest.ProofAliceBobCarolDanErinFrank,
},
// wrong command
require.Empty(t, FindProof(dlgs, didtest.PersonaBob.DID(), command.New("foo"), didtest.PersonaAlice.DID()))
// Fails
{
name: "wrong command",
issuer: didtest.PersonaBob.DID(),
command: command.New("foo"),
subject: didtest.PersonaAlice.DID(),
expected: nil,
},
{
name: "wrong subject",
issuer: didtest.PersonaBob.DID(),
command: delegationtest.NominalCommand,
subject: didtest.PersonaDan.DID(),
expected: nil,
},
} {
t.Run(tc.name, func(t *testing.T) {
res := FindProof(dlgs, tc.issuer, tc.command, tc.subject)
require.Equal(t, tc.expected, res)
})
}
}