1 Commits

Author SHA1 Message Date
Michael Muré
3b8fb6d34d add a new Attestation token, for proving claims or DID ownership
Specification is TBD
2026-01-07 14:06:08 +01:00
14 changed files with 1450 additions and 1 deletions

265
pkg/claims/claims.go Normal file
View File

@@ -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
}

130
pkg/claims/claims_test.go Normal file
View File

@@ -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()))
}

64
pkg/claims/readonly.go Normal file
View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

227
token/attestation/ipld.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"}}}}]

View File

@@ -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.

View File

@@ -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 {