fix(delegation): simplify package API and restore convenient encoding

This commit is contained in:
Steve Moyer
2024-09-16 13:50:11 -04:00
parent 7cead1bf8d
commit 4c05d866f2
11 changed files with 338 additions and 389 deletions

View File

@@ -1,19 +1,14 @@
package delegation package delegation
// Code generated by github.com/launchdarkly/go-options. DO NOT EDIT. // Code generated by github.com/selesy/go-options. DO NOT EDIT.
import "fmt"
import ( import (
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/did"
"time" "time"
) )
type ApplyOptionFunc func(c *config) error type Option func(c *config) error
func (f ApplyOptionFunc) apply(c *config) error {
return f(c)
}
func newConfig(options ...Option) (config, error) { func newConfig(options ...Option) (config, error) {
var c config var c config
@@ -23,152 +18,52 @@ func newConfig(options ...Option) (config, error) {
func applyConfigOptions(c *config, options ...Option) error { func applyConfigOptions(c *config, options ...Option) error {
for _, o := range options { for _, o := range options {
if err := o.apply(c); err != nil { if err := o(c); err != nil {
return err return err
} }
} }
return nil return nil
} }
type Option interface {
apply(*config) error
}
type withExpirationImpl struct {
o *time.Time
}
func (o withExpirationImpl) apply(c *config) error {
c.Expiration = o.o
return nil
}
func (o withExpirationImpl) String() string {
name := "WithExpiration"
// hack to avoid go vet error about passing a function to Sprintf
var value interface{} = o.o
return fmt.Sprintf("%s: %+v", name, value)
}
func WithExpiration(o *time.Time) Option { func WithExpiration(o *time.Time) Option {
return withExpirationImpl{ return func(c *config) error {
o: o, c.Expiration = o
return nil
} }
} }
type withMetaImpl struct { func WithMeta(o map[string]datamodel.Node) Option {
o map[string]any return func(c *config) error {
} c.Meta = o
return nil
func (o withMetaImpl) apply(c *config) error {
c.Meta = o.o
return nil
}
func (o withMetaImpl) String() string {
name := "WithMeta"
// hack to avoid go vet error about passing a function to Sprintf
var value interface{} = o.o
return fmt.Sprintf("%s: %+v", name, value)
}
func WithMeta(o map[string]any) Option {
return withMetaImpl{
o: o,
} }
} }
type withNoExpirationImpl struct {
o bool
}
func (o withNoExpirationImpl) apply(c *config) error {
c.NoExpiration = o.o
return nil
}
func (o withNoExpirationImpl) String() string {
name := "WithNoExpiration"
// hack to avoid go vet error about passing a function to Sprintf
var value interface{} = o.o
return fmt.Sprintf("%s: %+v", name, value)
}
func WithNoExpiration(o bool) Option { func WithNoExpiration(o bool) Option {
return withNoExpirationImpl{ return func(c *config) error {
o: o, c.NoExpiration = o
return nil
} }
} }
type withNotBeforeImpl struct {
o *time.Time
}
func (o withNotBeforeImpl) apply(c *config) error {
c.NotBefore = o.o
return nil
}
func (o withNotBeforeImpl) String() string {
name := "WithNotBefore"
// hack to avoid go vet error about passing a function to Sprintf
var value interface{} = o.o
return fmt.Sprintf("%s: %+v", name, value)
}
func WithNotBefore(o *time.Time) Option { func WithNotBefore(o *time.Time) Option {
return withNotBeforeImpl{ return func(c *config) error {
o: o, c.NotBefore = o
return nil
} }
} }
type withSubjectImpl struct {
o *did.DID
}
func (o withSubjectImpl) apply(c *config) error {
c.Subject = o.o
return nil
}
func (o withSubjectImpl) String() string {
name := "WithSubject"
// hack to avoid go vet error about passing a function to Sprintf
var value interface{} = o.o
return fmt.Sprintf("%s: %+v", name, value)
}
// WithSubject is a did.DID representing the Subject. // WithSubject is a did.DID representing the Subject.
func WithSubject(o *did.DID) Option { func WithSubject(o *did.DID) Option {
return withSubjectImpl{ return func(c *config) error {
o: o, c.Subject = o
return nil
} }
} }
type withPowerlineImpl struct {
o bool
}
func (o withPowerlineImpl) apply(c *config) error {
c.Powerline = o.o
return nil
}
func (o withPowerlineImpl) String() string {
name := "WithPowerline"
// hack to avoid go vet error about passing a function to Sprintf
var value interface{} = o.o
return fmt.Sprintf("%s: %+v", name, value)
}
func WithPowerline(o bool) Option { func WithPowerline(o bool) Option {
return withPowerlineImpl{ return func(c *config) error {
o: o, c.Powerline = o
return nil
} }
} }

View File

@@ -1,11 +1,12 @@
package delegation package delegation
import ( import (
"errors"
"fmt"
"time" "time"
"github.com/ipld/go-ipld-prime/datamodel" "github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/node/bindnode" "github.com/libp2p/go-libp2p/core/crypto"
"github.com/ipld/go-ipld-prime/schema"
"github.com/ucan-wg/go-ucan/capability/command" "github.com/ucan-wg/go-ucan/capability/command"
"github.com/ucan-wg/go-ucan/capability/policy" "github.com/ucan-wg/go-ucan/capability/policy"
"github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/did"
@@ -14,15 +15,19 @@ import (
) )
const ( const (
Tag = "ucan/dlg@" Tag = "ucan/dlg@1.0.0-rc.1"
) )
//go:generate -command options go run github.com/launchdarkly/go-options type Delegation struct {
//go:generate options -type=config -prefix=With -output=delegatiom_options.go -cmp=false -imports=time,github.com/ucan-wg/go-ucan/did envel *envelope.Envelope
}
//go:generate -command options go run github.com/selesy/go-options
//go:generate options -type=config -prefix=With -output=delegatiom_options.go -cmp=false -stringer=false -imports=time,github.com/ucan-wg/go-ucan/did,github.com/ipld/go-ipld-prime/datamodel
type config struct { type config struct {
Expiration *time.Time Expiration *time.Time
Meta map[string]any Meta map[string]datamodel.Node
NoExpiration bool NoExpiration bool
NotBefore *time.Time NotBefore *time.Time
// is a did.DID representing the Subject. // is a did.DID representing the Subject.
@@ -30,81 +35,105 @@ type config struct {
Powerline bool Powerline bool
} }
type Meta struct { // Required fields for delegation
Keys []string
Values map[string]datamodel.Node
}
func NewMeta(meta map[string]any) Meta { // Requirements for root
keys := make([]string, len(meta))
values := make(map[string]datamodel.Node, len(meta))
i := 0
for k, v := range meta { func New(privKey crypto.PrivKey, iss did.DID, aud did.DID, cmd *command.Command, pol *policy.Policy, exp *time.Time, nonce []byte, opts ...Option) (*Delegation, error) {
keys[i] = k
values[k] = bindnode.Wrap(&v, nil)
}
return Meta{
Keys: keys,
Values: values,
}
}
var _ envelope.Tokener = (*Token)(nil)
type Token struct {
// Issuer DID (sender)
Issuer did.DID
// Audience DID (receiver)
Audience did.DID
// Principal that the chain is about (the Subject)
Subject *did.DID
// The Command to eventually invoke
Command *command.Command
// The delegation policy
Policy *policy.Policy
// A unique, random nonce
Nonce []byte
// Arbitrary Metadata
Meta Meta
// "Not before" UTC Unix Timestamp in seconds (valid from), 53-bits integer
NotBefore *time.Time
// The timestamp at which the Invocation becomes invalid
Expiration *time.Time
}
func New(iss did.DID, aud did.DID, prf []Token, cmd *command.Command, pol *policy.Policy, exp *time.Time, nonce []byte, opts ...Option) (*Token, error) {
cfg, err := newConfig(opts...) cfg, err := newConfig(opts...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
tkn := &Token{ if !iss.Defined() {
Issuer: iss, return nil, fmt.Errorf("%w: %s", token.ErrMissingRequiredDID, "iss")
Audience: aud,
Subject: cfg.Subject,
Command: cmd,
Policy: pol,
Nonce: nonce,
NotBefore: cfg.NotBefore,
} }
if !aud.Defined() {
return nil, fmt.Errorf("%w: %s", token.ErrMissingRequiredDID, "aud")
}
audience := aud.String()
var subject *string
if cfg.Subject != nil && cfg.Subject.Defined() {
s := cfg.Subject.String()
subject = &s
}
policy, err := pol.ToIPLD()
if err != nil {
return nil, err
}
nonce = []uint8(nonce)
var notBefore *int
if cfg.NotBefore != nil {
n := int(cfg.NotBefore.Unix())
notBefore = &n
}
var meta *token.Map__String__Any
if len(cfg.Meta) > 0 { if len(cfg.Meta) > 0 {
tkn.Meta = NewMeta(cfg.Meta) m := token.ToIPLDMapStringAny(cfg.Meta)
meta = &m
} }
var expiration *int
if exp != nil && !cfg.NoExpiration { if exp != nil && !cfg.NoExpiration {
tkn.Expiration = exp e := int(cfg.NotBefore.Unix())
expiration = &e
} }
return tkn, nil tkn := &token.Token{
Issuer: iss.String(),
Audience: &audience,
Subject: subject,
Command: cmd.String(),
Policy: &policy,
Nonce: &nonce,
Meta: meta,
NotBefore: notBefore,
Expiration: expiration,
}
envel, err := envelope.New(privKey, tkn, Tag)
if err != nil {
return nil, err
}
dlg := &Delegation{envel: envel}
if err := dlg.Validate(); err != nil {
return nil, err
}
return dlg, nil
} }
func (d *Token) Tag() string { type validateFunc func() error
return Tag
func (d *Delegation) Validate() error {
return errors.Join(
d.validateDID("iss", &d.envel.TokenPayload().Issuer, false),
d.validateDID("aud", d.envel.TokenPayload().Audience, false),
d.validateDID("sub", d.envel.TokenPayload().Subject, true),
)
} }
func (d *Token) Prototype() schema.TypedPrototype { func (d *Delegation) validateDID(fieldName string, identity *string, nullableOrOptional bool) error {
return bindnode.Prototype((*Token)(nil), mustLoadSchema().TypeByName("Delegation"), token.BindnodeOptions()...) if identity == nil && !nullableOrOptional {
return fmt.Errorf("a required DID is missing: %s", fieldName)
}
id, err := did.Parse(*identity)
if err != nil {
}
if !id.Defined() && !id.Key() {
return fmt.Errorf("a required DID is missing: %s", fieldName)
}
return nil
} }

View File

@@ -1,60 +0,0 @@
type DID string
# The Delegation payload MUST describe the authorization claims, who is involved, and its validity period.
type Payload struct {
# Issuer DID (sender)
iss DID
# Audience DID (receiver)
aud DID
# Principal that the chain is about (the Subject)
sub optional DID
# The Command to eventually invoke
cmd String
# The delegation policy
# It doesn't seem possible to represent it with a schema.
pol Any
# A unique, random nonce
nonce Bytes
# Arbitrary Metadata
meta {String : Any}
# "Not before" UTC Unix Timestamp in seconds (valid from), 53-bits integer
nbf optional Int
# The timestamp at which the Invocation becomes invalid
exp nullable Int
}
type Delegation struct {
# Issuer DID (sender)
issuer DID (rename "iss")
# Audience DID (receiver)
audience DID (rename "aud")
# Principal that the chain is about (the Subject)
subject optional DID (rename "sub")
# The Command to eventually invoke
command String (rename "cmd")
# The delegation policy
# It doesn't seem possible to represent it with a schema.
policy Any (rename "pol")
# A unique, random nonce
nonce Bytes
# Arbitrary Metadata
meta {String : Any}
# "Not before" UTC Unix Timestamp in seconds (valid from), 53-bits integer
notBefore optional Int (rename "nbf")
# The timestamp at which the Invocation becomes invalid
expiration nullable Int (rename "exp")
}
type Save struct {
}

View File

@@ -1,87 +0,0 @@
package delegation_test
import (
"testing"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/ipld/go-ipld-prime/node/bindnode"
"github.com/ipld/go-ipld-prime/schema"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/delegation"
"github.com/ucan-wg/go-ucan/internal/token"
)
func TestToken_Proto(t *testing.T) {
t.Parallel()
const delegationJson = `
{
"aud":"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
"cmd":"/foo/bar",
"exp":123456,
"iss":"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
"meta":{
"bar":"baaar",
"foo":"fooo"
},
"nbf":123456,
"nonce":{
"/":{
"bytes":"c3VwZXItcmFuZG9t"
}
},
"pol":[
["==", ".status", "draft"],
["all", ".reviewer", [
["like", ".email", "*@example.com"]]
],
["any", ".tags", [
["or", [
["==", ".", "news"],
["==", ".", "press"]]
]]
]
],
"sub":"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
}
`
proto := (*delegation.Token)(nil).Prototype()
node, err := ipld.DecodeUsingPrototype([]byte(delegationJson), dagjson.Decode, proto)
require.NoError(t, err)
tkn, ok := bindnode.Unwrap(node).(*delegation.Token)
require.True(t, ok)
t.Log("Token:")
t.Log(" Audience:", tkn.Audience)
t.Log(" Command: ", tkn.Command)
// t.Log(" Expiration: ", token.Expiration)
t.Log(" Issuer:", tkn.Issuer)
// t.Log(" Meta:", token.Meta)
// t.Log(" NotBefore", token.NotBefore)
// t.Log(" Nonce:", token.Nonce)
// t.Log(" Policy:", token.Policy)
t.Log(" Subject:", tkn.Subject)
// token.Command = nil
// token.Meta = nil
// token.Policy = nil
// token.Expiration = nil
// token.NotBefore = nil
_ = bindnode.Wrap(tkn, proto.Type(), token.BindnodeOptions()...)
typed, ok := node.(schema.TypedNode)
require.True(t, ok)
json, err := ipld.Encode(typed.Representation(), dagjson.Encode)
require.NoError(t, err)
require.JSONEq(t, delegationJson, string(json))
t.Log(string(json))
t.Fail()
}

93
delegation/encoding.go Normal file
View File

@@ -0,0 +1,93 @@
package delegation
import (
"fmt"
"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/internal/envelope"
)
// Encode marshals a Delegation to the the format specified by the provided
// codec.Encoder.
func (d *Delegation) Encode(encFn codec.Encoder) ([]byte, error) {
node, err := d.ToIPLD()
if err != nil {
return nil, err
}
return ipld.Encode(node, encFn)
}
// ToDagCbor marshals the Delegation to the DAG-CBOR format.
func (d *Delegation) ToDagCbor() ([]byte, error) {
return d.Encode(dagcbor.Encode)
}
// ToDagJson marshals the Delegation to the DAG-JSON format.
func (d *Delegation) ToDagJson() ([]byte, error) {
return d.Encode(dagjson.Encode)
}
// ToIPLD wraps the Delegation in an IPLD datamodel.Node.
func (d *Delegation) ToIPLD() (datamodel.Node, error) {
return d.envel.Wrap()
}
// Decode unmarshals the input data using the format specified by the
// provided codec.Decoder into a Delegation.
//
// An error is returned if the conversion fails, or if the resulting
// Delegation is invalid.
func Decode(b []byte, decFn codec.Decoder) (*Delegation, error) {
node, err := ipld.Decode(b, decFn)
if err != nil {
return nil, err
}
return FromIPLD(node)
}
// FromDagCbor unmarshals the input data into a Delegation.
//
// An error is returned if the conversion fails, or if the resulting
// Delegation is invalid.
func FromDagCbor(data []byte) (*Delegation, error) {
return Decode(data, dagcbor.Decode)
}
// FromDagsjon unmarshals the input data into a Delegation.
//
// An error is returned if the conversion fails, or if the resulting
// Delegation is invalid.
func FromDagJson(data []byte) (*Delegation, error) {
return Decode(data, dagjson.Decode)
}
// FromIPLD unwraps a Delegation from the provided IPLD datamodel.Node
//
// An error is returned if the conversion fails, or if the resulting
// Delegation is invalid.
func FromIPLD(node datamodel.Node) (*Delegation, error) {
envel, err := envelope.Unwrap(node)
if err != nil {
return nil, err
}
if envel.Tag() != Tag {
return nil, fmt.Errorf("wrong tag for TokenPayload: received %s but expected %s", envel.Tag(), Tag)
}
dlg := &Delegation{
envel: envel,
}
if err := dlg.Validate(); err != nil {
return nil, err
}
return dlg, nil
}

101
delegation/encoding_test.go Normal file
View File

@@ -0,0 +1,101 @@
package delegation_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/delegation"
)
func TestEncodingRoundTrip(t *testing.T) {
const delegationJson = `
[
{
"/": {
"bytes": "QWr0Pk+sSWE1nszuBMQzggbHX4ofJb8QRdwrLJK/AGCx2p4s/xaCRieomfstDjsV4ezBzX1HARvcoNgdwDQ8Aw"
}
},
{
"h": {
"/": {
"bytes": "NO0BcQ"
}
},
"ucan/dlg@1.0.0-rc.1": {
"aud": "did:key:z6Mkpzn2n3ZGT2VaqMGSQC3tzmzV4TS9S71iFsDXE1WnoNH2",
"cmd": "/foo/bar",
"exp": -62135596800,
"iss": "did:key:z6Mkpzn2n3ZGT2VaqMGSQC3tzmzV4TS9S71iFsDXE1WnoNH2",
"meta": {
"bar": "barr",
"foo": "fooo"
},
"nbf": -62135596800,
"nonce": {
"/": {
"bytes": "X93ORvN1QIXrKPyEP5m5XoVK9VLX9nX8VV/+HlWrp9c"
}
},
"pol": [
[
"==",
".status",
"draft"
],
[
"all",
".reviewer",
[
"like",
".email",
"*@example.com"
]
],
[
"any",
".tags",
[
"or",
[
[
"==",
".",
"news"
],
[
"==",
".",
"press"
]
]
]
]
],
"sub": "did:key:z6MktA1uBdCpq4uJBqE9jjMiLyxZBg9a6xgPPKJjMqss6Zc2"
}
}
]
`
// format: dagJson --> Delegation --> dagCbor --> Delegation --> dagJson
// function: FromDagJson() ToDagCbor() FromDagCbor() ToDagJson()
p1, err := delegation.FromDagJson([]byte(delegationJson))
require.NoError(t, err)
cborBytes, err := p1.ToDagCbor()
require.NoError(t, err)
fmt.Println("cborBytes length", len(cborBytes))
fmt.Println("cbor", string(cborBytes))
p2, err := delegation.FromDagCbor(cborBytes)
require.NoError(t, err)
fmt.Println("read Cbor", p2)
readJson, err := p2.ToDagJson()
require.NoError(t, err)
fmt.Println("readJson length", len(readJson))
fmt.Println("json: ", string(readJson))
require.JSONEq(t, delegationJson, string(readJson))
}

View File

View File

@@ -1,33 +0,0 @@
package delegation
import (
_ "embed"
"fmt"
"sync"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/schema"
)
//go:embed delegation.ipldsch
var schemaBytes []byte
var (
once sync.Once
ts *schema.TypeSystem
err error
)
func mustLoadSchema() *schema.TypeSystem {
once.Do(func() {
ts, err = ipld.LoadSchemaBytes(schemaBytes)
})
if err != nil {
panic(fmt.Errorf("failed to load IPLD schema: %s", err))
}
return ts
}
func PayloadType() schema.Type {
return mustLoadSchema().TypeByName("Payload")
}

View File

@@ -1,14 +0,0 @@
package delegation
import (
"testing"
"github.com/ipld/go-ipld-prime"
)
func BenchmarkSchemaLoad(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = ipld.LoadSchemaBytes(schemaBytes)
}
}

View File

@@ -98,10 +98,16 @@ func Unwrap(node datamodel.Node) (*Envelope, error) {
return nil, err return nil, err
} }
return &Envelope{ envel := &Envelope{
signature: signature, signature: signature,
sigPayload: sigPayload, sigPayload: sigPayload,
}, nil }
if ok, err := envel.Verify(); !ok || err != nil {
return nil, fmt.Errorf("envelope was not signed by issuer")
}
return envel, nil
} }
// Signature returns the cryptographic signature of the Envelope's // Signature returns the cryptographic signature of the Envelope's

View File

@@ -1 +1,20 @@
[{"/":{"bytes":"PZV6A2aI7n+MlyADqcqmWhkuyNrgUCDz+qSLSnI9bpasOwOhKUTx95m5Nu5CO/INa1LqzHGioD9+PVf6qdtTBg"}},{"h":{"/":{"bytes":"NO0BcQ"}},"ucan/example@v1.0.0-rc.1":{"cmd":"","exp":null,"iss":"did:key:z6MkpuK2Amsu1RqcLGgmHHQHhvmeXCCBVsM4XFSg2cCyg4Nh","sub":null}}] [
{
"/": {
"bytes": "PZV6A2aI7n+MlyADqcqmWhkuyNrgUCDz+qSLSnI9bpasOwOhKUTx95m5Nu5CO/INa1LqzHGioD9+PVf6qdtTBg"
}
},
{
"h": {
"/": {
"bytes": "NO0BcQ"
}
},
"ucan/example@v1.0.0-rc.1": {
"cmd": "",
"exp": null,
"iss": "did:key:z6MkpuK2Amsu1RqcLGgmHHQHhvmeXCCBVsM4XFSg2cCyg4Nh",
"sub": null
}
}
]