add a new Attestation token, for proving claims or DID ownership
Specification is TBD
This commit is contained in:
committed by
Michael Muré
parent
4b99c9f1df
commit
3b8fb6d34d
265
pkg/claims/claims.go
Normal file
265
pkg/claims/claims.go
Normal 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
130
pkg/claims/claims_test.go
Normal 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
64
pkg/claims/readonly.go
Normal 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()
|
||||||
|
}
|
||||||
210
token/attestation/attestation.go
Normal file
210
token/attestation/attestation.go
Normal 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
|
||||||
|
}
|
||||||
22
token/attestation/attestation.ipldsch
Normal file
22
token/attestation/attestation.ipldsch
Normal 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
|
||||||
|
}
|
||||||
132
token/attestation/examples_test.go
Normal file
132
token/attestation/examples_test.go
Normal 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
227
token/attestation/ipld.go
Normal 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)
|
||||||
|
}
|
||||||
35
token/attestation/ipld_test.go
Normal file
35
token/attestation/ipld_test.go
Normal 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)
|
||||||
|
}
|
||||||
168
token/attestation/options.go
Normal file
168
token/attestation/options.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
73
token/attestation/schema.go
Normal file
73
token/attestation/schema.go
Normal 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
|
||||||
|
}
|
||||||
89
token/attestation/schema_test.go
Normal file
89
token/attestation/schema_test.go
Normal 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
|
||||||
|
}
|
||||||
1
token/attestation/testdata/new.dagjson
vendored
Normal file
1
token/attestation/testdata/new.dagjson
vendored
Normal 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"}}}}]
|
||||||
@@ -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.
|
// WithEncryptedMetaString adds a key/value pair in the "meta" field.
|
||||||
// The string value is encrypted with the given key.
|
// The string value is encrypted with the given key.
|
||||||
// The ciphertext will be 40 bytes larger than the plaintext due to encryption overhead.
|
// The ciphertext will be 40 bytes larger than the plaintext due to encryption overhead.
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ func WithArgument(key string, val any) Option {
|
|||||||
func WithArguments(args *args.Args) Option {
|
func WithArguments(args *args.Args) Option {
|
||||||
return func(t *Token) error {
|
return func(t *Token) error {
|
||||||
t.arguments.Include(args)
|
t.arguments.Include(args)
|
||||||
|
|
||||||
return nil
|
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.
|
// WithEncryptedMetaString adds a key/value pair in the "meta" field.
|
||||||
// The string value is encrypted with the given aesKey.
|
// The string value is encrypted with the given aesKey.
|
||||||
func WithEncryptedMetaString(key, val string, encryptionKey []byte) Option {
|
func WithEncryptedMetaString(key, val string, encryptionKey []byte) Option {
|
||||||
|
|||||||
Reference in New Issue
Block a user