From 3b8fb6d34d7c56210caa41fa2f19e78caeb8b520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Wed, 7 Jan 2026 13:11:07 +0100 Subject: [PATCH] add a new Attestation token, for proving claims or DID ownership Specification is TBD --- pkg/claims/claims.go | 265 +++++++++++++++++++++++++ pkg/claims/claims_test.go | 130 ++++++++++++ pkg/claims/readonly.go | 64 ++++++ token/attestation/attestation.go | 210 ++++++++++++++++++++ token/attestation/attestation.ipldsch | 22 ++ token/attestation/examples_test.go | 132 ++++++++++++ token/attestation/ipld.go | 227 +++++++++++++++++++++ token/attestation/ipld_test.go | 35 ++++ token/attestation/options.go | 168 ++++++++++++++++ token/attestation/schema.go | 73 +++++++ token/attestation/schema_test.go | 89 +++++++++ token/attestation/testdata/new.dagjson | 1 + token/delegation/options.go | 17 ++ token/invocation/options.go | 18 +- 14 files changed, 1450 insertions(+), 1 deletion(-) create mode 100644 pkg/claims/claims.go create mode 100644 pkg/claims/claims_test.go create mode 100644 pkg/claims/readonly.go create mode 100644 token/attestation/attestation.go create mode 100644 token/attestation/attestation.ipldsch create mode 100644 token/attestation/examples_test.go create mode 100644 token/attestation/ipld.go create mode 100644 token/attestation/ipld_test.go create mode 100644 token/attestation/options.go create mode 100644 token/attestation/schema.go create mode 100644 token/attestation/schema_test.go create mode 100644 token/attestation/testdata/new.dagjson diff --git a/pkg/claims/claims.go b/pkg/claims/claims.go new file mode 100644 index 0000000..abb2e86 --- /dev/null +++ b/pkg/claims/claims.go @@ -0,0 +1,265 @@ +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 +} diff --git a/pkg/claims/claims_test.go b/pkg/claims/claims_test.go new file mode 100644 index 0000000..68acc53 --- /dev/null +++ b/pkg/claims/claims_test.go @@ -0,0 +1,130 @@ +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())) +} diff --git a/pkg/claims/readonly.go b/pkg/claims/readonly.go new file mode 100644 index 0000000..5b64043 --- /dev/null +++ b/pkg/claims/readonly.go @@ -0,0 +1,64 @@ +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() +} diff --git a/token/attestation/attestation.go b/token/attestation/attestation.go new file mode 100644 index 0000000..f0b925d --- /dev/null +++ b/token/attestation/attestation.go @@ -0,0 +1,210 @@ +// 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 +} diff --git a/token/attestation/attestation.ipldsch b/token/attestation/attestation.ipldsch new file mode 100644 index 0000000..5029294 --- /dev/null +++ b/token/attestation/attestation.ipldsch @@ -0,0 +1,22 @@ +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 +} diff --git a/token/attestation/examples_test.go b/token/attestation/examples_test.go new file mode 100644 index 0000000..8eef906 --- /dev/null +++ b/token/attestation/examples_test.go @@ -0,0 +1,132 @@ +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 +} diff --git a/token/attestation/ipld.go b/token/attestation/ipld.go new file mode 100644 index 0000000..7a24898 --- /dev/null +++ b/token/attestation/ipld.go @@ -0,0 +1,227 @@ +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) +} diff --git a/token/attestation/ipld_test.go b/token/attestation/ipld_test.go new file mode 100644 index 0000000..3b666ed --- /dev/null +++ b/token/attestation/ipld_test.go @@ -0,0 +1,35 @@ +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) +} diff --git a/token/attestation/options.go b/token/attestation/options.go new file mode 100644 index 0000000..6e3c9a3 --- /dev/null +++ b/token/attestation/options.go @@ -0,0 +1,168 @@ +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 + } +} diff --git a/token/attestation/schema.go b/token/attestation/schema.go new file mode 100644 index 0000000..234df01 --- /dev/null +++ b/token/attestation/schema.go @@ -0,0 +1,73 @@ +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 +} diff --git a/token/attestation/schema_test.go b/token/attestation/schema_test.go new file mode 100644 index 0000000..f15c123 --- /dev/null +++ b/token/attestation/schema_test.go @@ -0,0 +1,89 @@ +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 +} diff --git a/token/attestation/testdata/new.dagjson b/token/attestation/testdata/new.dagjson new file mode 100644 index 0000000..d3290ca --- /dev/null +++ b/token/attestation/testdata/new.dagjson @@ -0,0 +1 @@ +[{"/":{"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"}}}}] \ No newline at end of file diff --git a/token/delegation/options.go b/token/delegation/options.go index bd26744..31bc031 100644 --- a/token/delegation/options.go +++ b/token/delegation/options.go @@ -42,6 +42,23 @@ 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. diff --git a/token/invocation/options.go b/token/invocation/options.go index 5788c57..5ce418b 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -31,7 +31,6 @@ func WithArgument(key string, val any) Option { func WithArguments(args *args.Args) Option { return func(t *Token) error { t.arguments.Include(args) - return nil } } @@ -65,6 +64,23 @@ 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 {