Files
ucan/token/attestation/attestation.go
2026-01-07 14:06:08 +01:00

211 lines
5.1 KiB
Go

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