From d784c92c293ce5a67e14c919bdb5c8f8fa9a7244 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 24 Oct 2024 10:44:38 -0400 Subject: [PATCH 01/32] feat(invocation): provide New constructor and encoding to wire-format --- token/invocation/invocation.go | 103 ++++++++++++++++----- token/invocation/invocation.ipldsch | 29 +++--- token/invocation/ipld.go | 47 ++++++++-- token/invocation/options.go | 134 ++++++++++++++++++++++++++++ token/invocation/schema.go | 34 ++++--- 5 files changed, 295 insertions(+), 52 deletions(-) create mode 100644 token/invocation/options.go diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index d48fee8..3a17ea2 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -13,6 +13,8 @@ import ( "fmt" "time" + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/datamodel" "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" @@ -20,25 +22,70 @@ import ( // Token is an immutable type that holds the fields of a UCAN invocation. type Token struct { - // Issuer DID (invoker) + // The DID of the Invoker issuer did.DID - // Audience DID (receiver/executor) - audience did.DID - // Subject DID (subject being invoked) + // The DID of Subject being invoked subject did.DID - // The Command to invoke + // The DID of the intended Executor if different from the Subject + audience did.DID + + // The Command command command.Command - // TODO: args - // TODO: prf - // A unique, random nonce - nonce []byte + // The Command's Arguments + arguments map[string]datamodel.Node + // Delegations that prove the chain of authority + proof []cid.Cid + // 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 invokedAt *time.Time - // TODO: cause + + // An optional CID of the Receipt that enqueued the Task + cause *cid.Cid +} + +// New creates an invocation 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 invokedAt is provided, the current time is used. Use the +// WithInvokedAt or WithInvokedAtIn options to specify a different time. +// +// With the exception of the WithMeta option, all other will overwrite +// the previous contents of their target field. +func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) (*Token, error) { + nonce := make([]byte, 12) + + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + + iat := time.Now() + + tkn := Token{ + issuer: iss, + subject: sub, + command: cmd, + proof: prf, + nonce: nonce, + invokedAt: &iat, + } + + for _, opt := range opts { + if err := opt(&tkn); err != nil { + return nil, err + } + } + + return &tkn, nil } // Issuer returns the did.DID representing the Token's issuer. @@ -46,28 +93,27 @@ func (t *Token) Issuer() did.DID { return t.issuer } +// Subject returns the did.DID representing the Token's subject. +func (t *Token) Subject() did.DID { + return t.subject +} + // Audience returns the did.DID representing the Token's audience. func (t *Token) Audience() did.DID { return t.audience } -// Subject returns the did.DID representing the Token's subject. -// -// This field may be did.Undef for delegations that are [Powerlined] but -// must be equal to the value returned by the Issuer method for root -// tokens. -func (t *Token) Subject() did.DID { - return t.subject -} - // Command returns the capability's command.Command. func (t *Token) Command() command.Command { return t.command } -// Nonce returns the random Nonce encapsulated in this Token. -func (t *Token) Nonce() []byte { - return t.nonce +func (t *Token) Arguments() map[string]datamodel.Node { + return t.arguments +} + +func (t *Token) Proof() []cid.Cid { + return t.proof } // Meta returns the Token's metadata. @@ -75,11 +121,24 @@ func (t *Token) Meta() *meta.Meta { return t.meta } +// 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 } +func (t *Token) InvokedAt() *time.Time { + return t.invokedAt +} + +func (t *Token) Cause() *cid.Cid { + return t.cause +} + func (t *Token) validate() error { var errs error diff --git a/token/invocation/invocation.ipldsch b/token/invocation/invocation.ipldsch index 2acab27..ba2396a 100644 --- a/token/invocation/invocation.ipldsch +++ b/token/invocation/invocation.ipldsch @@ -1,23 +1,32 @@ + type DID string # The Invocation Payload attaches sender, receiver, and provenance to the Task. type Payload struct { - # Issuer DID (sender) + # The DID of the invoker iss DID - # Audience DID (receiver) - aud DID - # Principal that the chain is about (the Subject) - sub optional DID + # The Subject being invoked + sub DID + # The DID of the intended Executor if different from the Subject + aud optional DID - # The Command to eventually invoke + # The Command cmd String - - # A unique, random nonce - nonce Bytes + # The Command's Arguments + args { String : Any} + # Delegations that prove the chain of authority + prf [ Link ] # Arbitrary Metadata - meta {String : Any} + meta optional { String : Any } + # A unique, random nonce + nonce optional Bytes # The timestamp at which the Invocation becomes invalid exp nullable Int + # The Timestamp at which the Invocation was created + iat optional Int + + # An optional CID of the Receipt that enqueued the Task + cause optional Link } diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 897f608..467e2be 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -193,29 +193,58 @@ func FromIPLD(node datamodel.Node) (*Token, error) { } func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { - var sub *string + var aud *string - if t.subject != did.Undef { - s := t.subject.String() - sub = &s + if t.audience != did.Undef { + a := t.audience.String() + aud = &a } - // TODO - var exp *int64 if t.expiration != nil { u := t.expiration.Unix() exp = &u } + var iat *int64 + if t.invokedAt != nil { + i := t.invokedAt.Unix() + iat = &i + } + + argsKey := make([]string, len(t.arguments)) + i := 0 + + for k := range t.arguments { + argsKey[i] = k + i++ + } + + args := struct { + Keys []string + Values map[string]datamodel.Node + }{ + Keys: argsKey, + Values: t.arguments, + } + + prf := make([]cid.Cid, len(t.proof)) + for i, c := range t.proof { + prf[i] = c + } + model := &tokenPayloadModel{ Iss: t.issuer.String(), - Aud: t.audience.String(), - Sub: sub, + Aud: aud, + Sub: t.subject.String(), Cmd: t.command.String(), + Args: args, + Prf: prf, + Meta: t.meta, Nonce: t.nonce, - Meta: *t.meta, Exp: exp, + Iat: iat, + Cause: t.cause, } return envelope.ToIPLD(privKey, model) diff --git a/token/invocation/options.go b/token/invocation/options.go new file mode 100644 index 0000000..08c7969 --- /dev/null +++ b/token/invocation/options.go @@ -0,0 +1,134 @@ +package invocation + +import ( + "time" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" +) + +// Option is a type that allows optional fields to be set during the +// creation of a Token. +type Option func(*Token) error + +// WithArgument adds a key/value pair to the Token's Arguments field. +func WithArgument(key string, val datamodel.Node) Option { + return func(t *Token) error { + t.arguments[key] = val + + return nil + } +} + +// WithArguments sets the Token's Arguments field to the provided map. +// +// Note that this will overwrite any existing Arguments whether provided +// by a previous call to this function or by one or more calls to +// WithArgument. +func WithArguments(args map[string]datamodel.Node) Option { + return func(t *Token) error { + t.arguments = args + + return nil + } +} + +// WithAudience sets the Token's audience to the provided did.DID. +// +// If the provided did.DID is the same as the Token's subject, the +// audience is not set. +func WithAudience(aud did.DID) Option { + return func(t *Token) error { + if t.subject.String() != aud.String() { + t.audience = aud + } + + return nil + } +} + +// 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) + } +} + +// 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 require. +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 +// idem +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 { + t.expiration = &exp + + return nil + } +} + +// WithExpirationIn set's the Token's optional "expiration" field to +// Now() plus the given duration. +func WithExpirationIn(exp time.Duration) Option { + return func(t *Token) error { + expTime := time.Now().Add(exp) + t.expiration = &expTime + + return nil + } +} + +// WithInvokedAt sets the Token's invokedAt field to the provided +// time.Time. +func WithInvokedAt(iat time.Time) Option { + return func(t *Token) error { + t.invokedAt = &iat + + return nil + } +} + +// WithInvokedAtIn sets the Token's invokedAt field to Now() plus the +// given duration. +func WithInvokedAtIn(after time.Duration) Option { + return func(t *Token) error { + iat := time.Now().Add(after) + t.invokedAt = &iat + + return nil + } +} + +// WithCause sets the Token's cause field to the provided cid.Cid. +func WithCause(cause *cid.Cid) Option { + return func(t *Token) error { + t.cause = cause + + return nil + } +} diff --git a/token/invocation/schema.go b/token/invocation/schema.go index e6941a2..f438e1f 100644 --- a/token/invocation/schema.go +++ b/token/invocation/schema.go @@ -5,7 +5,9 @@ import ( "fmt" "sync" + "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/datamodel" "github.com/ipld/go-ipld-prime/node/bindnode" "github.com/ipld/go-ipld-prime/schema" @@ -46,26 +48,36 @@ var _ envelope.Tokener = (*tokenPayloadModel)(nil) // TODO type tokenPayloadModel struct { - // Issuer DID (sender) + // The DID of the Invoker Iss string - // Audience DID (receiver) - Aud string - // Principal that the chain is about (the Subject) - // optional: can be nil - Sub *string + // The DID of Subject being invoked + Sub string + // The DID of the intended Executor if different from the Subject + Aud *string - // The Command to eventually invoke + // The Command Cmd string + // The Command's Arguments + Args struct { + Keys []string + Values map[string]datamodel.Node + } + // Delegations that prove the chain of authority + Prf []cid.Cid + + // Arbitrary Metadata + Meta *meta.Meta // A unique, random nonce Nonce []byte - - // Arbitrary Metadata - Meta meta.Meta - // The timestamp at which the Invocation becomes invalid // optional: can be nil Exp *int64 + // The timestamp at which the Invocation was created + Iat *int64 + + // An optional CID of the Receipt that enqueued the Task + Cause *cid.Cid } func (e *tokenPayloadModel) Prototype() schema.TypedPrototype { From f44cf8af78001063a3fac53bfcd6c4ebf02525e2 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 24 Oct 2024 12:59:38 -0400 Subject: [PATCH 02/32] feat(invocation): produce example output similar to spec --- token/invocation/examples_test.go | 220 ++++++++++++++++++++++++++++++ token/invocation/invocation.go | 6 + token/invocation/options.go | 9 ++ 3 files changed, 235 insertions(+) create mode 100644 token/invocation/examples_test.go diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go new file mode 100644 index 0000000..530bcc0 --- /dev/null +++ b/token/invocation/examples_test.go @@ -0,0 +1,220 @@ +package invocation_test + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/ipfs/go-cid" + "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/datamodel" + "github.com/ipld/go-ipld-prime/fluent/qp" + "github.com/ipld/go-ipld-prime/node/basicnode" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/token/invocation" +) + +func ExampleNew() { + privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() + if err != nil { + fmt.Errorf("failed to create setup: %w", err) + + return + } + + inv, err := invocation.New( + iss, + sub, + cmd, + prf, + invocation.WithArguments(args), + invocation.WithMeta("env", "development"), + invocation.WithMeta("tags", meta["tags"]), + invocation.WithExpirationIn(time.Minute), + invocation.WithoutInvokedAt()) + if err != nil { + fmt.Errorf("failed to create invocation: %w", err) + + return + } + + data, cid, err := inv.ToSealed(privKey) + if err != nil { + fmt.Errorf("failed to seal invocation: %w", err) + + return + } + + json, err := prettyDAGJSON(data) + if err != nil { + fmt.Errorf("failed to pretty DAG-JSON: %w", err) + + return + } + + fmt.Println("CID:", cid) + fmt.Println("Token (pretty DAG-JSON):") + fmt.Println(json) + + // Expected CID and DAG-JSON output: + // CID: bafyreid2n5q45vk4osned7k5huocbe3mxbisonh5vujepqftc5ftr543ae + // Token (pretty DAG-JSON): + // [ + // { + // "/": { + // "bytes": "gvyL7kdSkgmaDpDU/Qj9ohRwxYLCHER52HFMSFEqQqEcQC9qr4JCPP1f/WybvGGuVzYiA0Hx4JO+ohNz8BxUAA" + // } + // }, + // { + // "h": { + // "/": { + // "bytes": "NO0BcQ" + // } + // }, + // "ucan/inv@1.0.0-rc.1": { + // "args": { + // "headers": { + // "Content-Type": "application/json" + // }, + // "payload": { + // "body": "UCAN is great", + // "draft": true, + // "title": "UCAN for Fun and Profit", + // "topics": [ + // "authz", + // "journal" + // ] + // }, + // "uri": "https://example.com/blog/posts" + // }, + // "cmd": "/crud/create", + // "exp": 1729788921, + // "iss": "did:key:z6MkhniGGyP88eZrq2dpMvUPdS2RQMhTUAWzcu6kVGUvEtCJ", + // "meta": { + // "env": "development", + // "tags": [ + // "blog", + // "post", + // "pr#123" + // ] + // }, + // "nonce": { + // "/": { + // "bytes": "2xXPoZwWln1TfXIp" + // } + // }, + // "prf": [ + // { + // "/": "bafyreigx3qxd2cndpe66j2mdssj773ecv7tqd7wovcnz5raguw6lj7sjoe" + // }, + // { + // "/": "bafyreib34ira254zdqgehz6f2bhwme2ja2re3ltcalejv4x4tkcveujvpa" + // }, + // { + // "/": "bafyreibkb66tpo2ixqx3fe5hmekkbuasrod6olt5bwm5u5pi726mduuwlq" + // } + // ], + // "sub": "did:key:z6MktWuvPvBe5UyHnDGuEdw8aJ5qrhhwLG6jy7cQYM6ckP6P" + // } + // } + // ] +} + +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 crypto.PrivKey, iss, sub did.DID, cmd command.Command, args map[string]datamodel.Node, prf []cid.Cid, meta map[string]datamodel.Node, errs error) { + var err error + + privKey, iss, err = did.GenerateEd25519() + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to generate Issuer identity: %w", err)) + } + + _, sub, err = did.GenerateEd25519() + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to generate Subject identity: %w", err)) + } + + cmd, err = command.Parse("/crud/create") + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to parse command: %w", err)) + } + + headers, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "Content-Type", qp.String("application/json")) + }) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to build headers: %w", err)) + } + payload, err := qp.BuildMap(basicnode.Prototype.Any, 4, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "title", qp.String("UCAN for Fun and Profit")) + qp.MapEntry(ma, "body", qp.String("UCAN is great")) + qp.MapEntry(ma, "topics", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("authz")) + qp.ListEntry(la, qp.String("journal")) + })) + qp.MapEntry(ma, "draft", qp.Bool(true)) + }) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to build payload: %w", err)) + } + + args = map[string]datamodel.Node{ + "uri": basicnode.NewString("https://example.com/blog/posts"), + "headers": headers, + "payload": payload, + } + + prf = make([]cid.Cid, 3) + for i, v := range []string{ + "zdpuAzx4sBrBCabrZZqXgvK3NDzh7Mf5mKbG11aBkkMCdLtCp", + "zdpuApTCXfoKh2sB1KaUaVSGofCBNPUnXoBb6WiCeitXEibZy", + "zdpuAoFdXRPw4n6TLcncoDhq1Mr6FGbpjAiEtqSBrTSaYMKkf", + } { + prf[i], err = cid.Parse(v) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to parse proof cid: %w", err)) + } + } + + tags, err := qp.BuildList(basicnode.Prototype.Any, 3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("blog")) + qp.ListEntry(la, qp.String("post")) + qp.ListEntry(la, qp.String("pr#123")) + }) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to build tags: %w", err)) + } + + meta = map[string]datamodel.Node{ + "env": basicnode.NewString("development"), + "tags": tags, + } + + return // WARNING: named return values +} diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 3a17ea2..0d43bc5 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -69,12 +69,14 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } iat := time.Now() + metadata := meta.NewMeta() tkn := Token{ issuer: iss, subject: sub, command: cmd, proof: prf, + meta: metadata, nonce: nonce, invokedAt: &iat, } @@ -85,6 +87,10 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } } + if len(tkn.meta.Keys) == 0 { + tkn.meta = nil + } + return &tkn, nil } diff --git a/token/invocation/options.go b/token/invocation/options.go index 08c7969..0d232fe 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -124,6 +124,15 @@ func WithInvokedAtIn(after time.Duration) Option { } } +// WithoutInvokedAt clears the Token's invokedAt field. +func WithoutInvokedAt() Option { + return func(t *Token) error { + t.invokedAt = nil + + return nil + } +} + // WithCause sets the Token's cause field to the provided cid.Cid. func WithCause(cause *cid.Cid) Option { return func(t *Token) error { From d3ad6715d9c83efbe71f562d0706d0c0681d039d Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Mon, 4 Nov 2024 16:07:11 -0500 Subject: [PATCH 03/32] feat(invocation): ipld unseal to invocation --- token/invocation/examples_test.go | 23 ++++++++--- token/invocation/invocation.go | 66 +++++++++++++++++++++++++++++-- token/invocation/ipld.go | 10 +++-- token/invocation/options.go | 3 +- token/invocation/schema.go | 7 +--- 5 files changed, 88 insertions(+), 21 deletions(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index 530bcc0..484643b 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -23,7 +23,7 @@ import ( func ExampleNew() { privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() if err != nil { - fmt.Errorf("failed to create setup: %w", err) + fmt.Println("failed to create setup:", err.Error()) return } @@ -39,21 +39,21 @@ func ExampleNew() { invocation.WithExpirationIn(time.Minute), invocation.WithoutInvokedAt()) if err != nil { - fmt.Errorf("failed to create invocation: %w", err) + fmt.Println("failed to create invocation:", err.Error()) return } data, cid, err := inv.ToSealed(privKey) if err != nil { - fmt.Errorf("failed to seal invocation: %w", err) + fmt.Println("failed to seal invocation:", err.Error()) return } json, err := prettyDAGJSON(data) if err != nil { - fmt.Errorf("failed to pretty DAG-JSON: %w", err) + fmt.Println("failed to pretty DAG-JSON:", err.Error()) return } @@ -139,6 +139,8 @@ func prettyDAGJSON(data []byte) (string, error) { return "", err } + fmt.Println(string(jsonData)) + var out bytes.Buffer if err := json.Indent(&out, jsonData, "", " "); err != nil { return "", err @@ -171,14 +173,19 @@ func setupExampleNew() (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Co if err != nil { errs = errors.Join(errs, fmt.Errorf("failed to build headers: %w", err)) } + + // ***** WARNING - do not change the order of these elements. DAG-CBOR + // will order them alphabetically and the unsealed + // result won't match if the input isn't also created in + // alphabetical order. payload, err := qp.BuildMap(basicnode.Prototype.Any, 4, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, "title", qp.String("UCAN for Fun and Profit")) qp.MapEntry(ma, "body", qp.String("UCAN is great")) + qp.MapEntry(ma, "draft", qp.Bool(true)) + qp.MapEntry(ma, "title", qp.String("UCAN for Fun and Profit")) qp.MapEntry(ma, "topics", qp.List(2, func(la datamodel.ListAssembler) { qp.ListEntry(la, qp.String("authz")) qp.ListEntry(la, qp.String("journal")) })) - qp.MapEntry(ma, "draft", qp.Bool(true)) }) if err != nil { errs = errors.Join(errs, fmt.Errorf("failed to build payload: %w", err)) @@ -202,6 +209,10 @@ func setupExampleNew() (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Co } } + // ***** WARNING - do not change the order of these elements. DAG-CBOR + // will order them alphabetically and the unsealed + // result won't match if the input isn't also created in + // alphabetical order. tags, err := qp.BuildList(basicnode.Prototype.Any, 3, func(la datamodel.ListAssembler) { qp.ListEntry(la, qp.String("blog")) qp.ListEntry(la, qp.String("post")) diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 0d43bc5..d5703a9 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -62,9 +62,8 @@ type Token struct { // With the exception of the WithMeta option, all other will overwrite // the previous contents of their target field. func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) (*Token, error) { - nonce := make([]byte, 12) - - if _, err := rand.Read(nonce); err != nil { + nonce, err := generateNonce() + if err != nil { return nil, err } @@ -141,6 +140,7 @@ func (t *Token) InvokedAt() *time.Time { return t.invokedAt } +// Cause returns the (optional) func (t *Token) Cause() *cid.Cid { return t.cause } @@ -170,9 +170,41 @@ func (t *Token) validate() error { func tokenFromModel(m tokenPayloadModel) (*Token, error) { var ( tkn Token + err error ) - // TODO + if tkn.issuer, err = did.Parse(m.Iss); err != nil { + return nil, err + } + + if tkn.subject, err = did.Parse(m.Sub); err != nil { + return nil, err + } + + if tkn.audience, err = parseOptionalDID(m.Aud); err != nil { + return nil, err + } + + if tkn.command, err = command.Parse(m.Cmd); err != nil { + return nil, err + } + + tkn.arguments = m.Args.Values + tkn.proof = m.Prf + tkn.meta = m.Meta + tkn.nonce = m.Nonce + + if tkn.expiration, err = parseOptionalTimestamp(m.Exp); err != nil { + return nil, err + } + + if tkn.invokedAt, err = parseOptionalTimestamp(m.Iat); err != nil { + return nil, err + } + + if tkn.cause, err = parseOptionalCID(m.Cause); err != nil { + return nil, err + } return &tkn, nil } @@ -187,3 +219,29 @@ func generateNonce() ([]byte, error) { } return res, nil } + +func parseOptionalCID(c *cid.Cid) (*cid.Cid, error) { + if c == nil { + return nil, nil + } + + return c, nil +} + +func parseOptionalDID(s *string) (did.DID, error) { + if s == nil { + return did.Undef, nil + } + + return did.Parse(*s) +} + +func parseOptionalTimestamp(sec *int64) (*time.Time, error) { + if sec == nil { + return nil, nil + } + + t := time.Unix(*sec, 0) + + return &t, nil +} diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 467e2be..817b265 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -192,6 +192,11 @@ func FromIPLD(node datamodel.Node) (*Token, error) { return tkn, err } +type stringAny struct { + Keys []string + Values map[string]datamodel.Node +} + func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { var aud *string @@ -220,10 +225,7 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { i++ } - args := struct { - Keys []string - Values map[string]datamodel.Node - }{ + args := stringAny{ Keys: argsKey, Values: t.arguments, } diff --git a/token/invocation/options.go b/token/invocation/options.go index 0d232fe..13a28d0 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -86,6 +86,7 @@ func WithEmptyNonce() Option { // 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 @@ -96,7 +97,7 @@ func WithExpiration(exp time.Time) Option { // Now() plus the given duration. func WithExpirationIn(exp time.Duration) Option { return func(t *Token) error { - expTime := time.Now().Add(exp) + expTime := time.Now().Add(exp).Round(time.Minute) t.expiration = &expTime return nil diff --git a/token/invocation/schema.go b/token/invocation/schema.go index f438e1f..b2b99f2 100644 --- a/token/invocation/schema.go +++ b/token/invocation/schema.go @@ -7,7 +7,6 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" - "github.com/ipld/go-ipld-prime/datamodel" "github.com/ipld/go-ipld-prime/node/bindnode" "github.com/ipld/go-ipld-prime/schema" @@ -46,7 +45,6 @@ func payloadType() schema.Type { var _ envelope.Tokener = (*tokenPayloadModel)(nil) -// TODO type tokenPayloadModel struct { // The DID of the Invoker Iss string @@ -58,10 +56,7 @@ type tokenPayloadModel struct { // The Command Cmd string // The Command's Arguments - Args struct { - Keys []string - Values map[string]datamodel.Node - } + Args stringAny // Delegations that prove the chain of authority Prf []cid.Cid From d7454156d20073fdb98ac69038e37e0147a088da Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 07:39:51 -0500 Subject: [PATCH 04/32] docs(invocation): fix truncated WithEmptyNonce description --- token/invocation/options.go | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/token/invocation/options.go b/token/invocation/options.go index 13a28d0..ca79577 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -73,7 +73,7 @@ func WithNonce(nonce []byte) Option { // WithEmptyNonce sets the Token's nonce to an empty byte slice as // suggested by the UCAN spec for invocation tokens that represent -// idem +// idempotent operations. func WithEmptyNonce() Option { return func(t *Token) error { t.nonce = []byte{} @@ -96,12 +96,7 @@ func WithExpiration(exp time.Time) Option { // WithExpirationIn set's the Token's optional "expiration" field to // Now() plus the given duration. func WithExpirationIn(exp time.Duration) Option { - return func(t *Token) error { - expTime := time.Now().Add(exp).Round(time.Minute) - t.expiration = &expTime - - return nil - } + return WithExpiration(time.Now().Add(exp)) } // WithInvokedAt sets the Token's invokedAt field to the provided @@ -117,12 +112,7 @@ func WithInvokedAt(iat time.Time) Option { // WithInvokedAtIn sets the Token's invokedAt field to Now() plus the // given duration. func WithInvokedAtIn(after time.Duration) Option { - return func(t *Token) error { - iat := time.Now().Add(after) - t.invokedAt = &iat - - return nil - } + return WithInvokedAt(time.Now().Add(after)) } // WithoutInvokedAt clears the Token's invokedAt field. From 7a7db684c378c51754dd54686e554929d7437b1f Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 08:39:43 -0500 Subject: [PATCH 05/32] test(invocation): adds schema round-trip test --- token/invocation/examples_test.go | 2 - token/invocation/ipld_test.go | 50 +++++++++++++++ token/invocation/schema_test.go | 89 +++++++++++++++++++++++++++ token/invocation/testdata/new.dagjson | 1 + 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 token/invocation/ipld_test.go create mode 100644 token/invocation/schema_test.go create mode 100644 token/invocation/testdata/new.dagjson diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index 484643b..83ec04e 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -139,8 +139,6 @@ func prettyDAGJSON(data []byte) (string, error) { return "", err } - fmt.Println(string(jsonData)) - var out bytes.Buffer if err := json.Indent(&out, jsonData, "", " "); err != nil { return "", err diff --git a/token/invocation/ipld_test.go b/token/invocation/ipld_test.go new file mode 100644 index 0000000..ae52685 --- /dev/null +++ b/token/invocation/ipld_test.go @@ -0,0 +1,50 @@ +package invocation_test + +import ( + "testing" + "time" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/token/invocation" +) + +func TestSealUnsealRoundtrip(t *testing.T) { + t.Parallel() + + privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() + require.NoError(t, err) + + tkn1, err := invocation.New( + iss, + sub, + cmd, + prf, + invocation.WithArguments(args), + invocation.WithMeta("env", "development"), + invocation.WithMeta("tags", meta["tags"]), + invocation.WithExpirationIn(time.Minute), + invocation.WithoutInvokedAt()) + require.NoError(t, err) + + data, cid1, err := tkn1.ToSealed(privKey) + require.NoError(t, err) + + tkn2, cid2, err := invocation.FromSealed(data) + require.NoError(t, err) + + assert.Equal(t, cid1, cid2) + assert.Equal(t, tkn1, tkn2) +} + +func setupNew(t *testing.T) (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Command, args map[string]datamodel.Node, prf []cid.Cid, meta map[string]datamodel.Node) { + privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() + require.NoError(t, err) + + return // WARNING: named return values +} diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go new file mode 100644 index 0000000..8fcc857 --- /dev/null +++ b/token/invocation/schema_test.go @@ -0,0 +1,89 @@ +package invocation_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/token/internal/envelope" + "github.com/ucan-wg/go-ucan/token/invocation" + "gotest.tools/v3/golden" +) + +const ( + issuerPrivKeyCfg = "CAESQK45xBfqIxRp7ZdRdck3tIJZKocCqvANQc925dCJhFwO7DJNA2j94zkF0TNx5mpXV0s6utfkFdHddWTaPVU6yZc=" + newCID = "zdpuAqY6Zypg4UnpbSUgDvYGneyFaTKaZevzxgSxV4rmv3Fpp" +) + +func TestSchemaRoundTrip(t *testing.T) { + t.Parallel() + + invocationJson := golden.Get(t, "new.dagjson") + 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 := invocation.FromDagJson(invocationJson) + require.NoError(t, err) + + cborBytes, id, err := p1.ToSealed(privKey) + require.NoError(t, err) + assert.Equal(t, newCID, envelope.CIDToBase58BTC(id)) + fmt.Println("cborBytes length", len(cborBytes)) + fmt.Println("cbor", string(cborBytes)) + + p2, c2, err := invocation.FromSealed(cborBytes) + require.NoError(t, err) + assert.Equal(t, id, c2) + fmt.Println("read Cbor", p2) + + readJson, err := p2.ToDagJson(privKey) + require.NoError(t, err) + fmt.Println("readJson length", len(readJson)) + fmt.Println("json: ", string(readJson)) + + assert.JSONEq(t, string(invocationJson), string(readJson)) + }) + + t.Run("via streaming", func(t *testing.T) { + t.Parallel() + + buf := bytes.NewBuffer(invocationJson) + + // format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson + // function: DecodeDagJson() Seal() Unseal() EncodeDagJson() + + p1, err := invocation.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 := invocation.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(invocationJson), readJson.String()) + }) +} +func privKey(t require.TestingT, privKeyCfg string) crypto.PrivKey { + privKeyMar, err := crypto.ConfigDecodeKey(privKeyCfg) + require.NoError(t, err) + + privKey, err := crypto.UnmarshalPrivateKey(privKeyMar) + require.NoError(t, err) + + return privKey +} diff --git a/token/invocation/testdata/new.dagjson b/token/invocation/testdata/new.dagjson new file mode 100644 index 0000000..c6a9b3f --- /dev/null +++ b/token/invocation/testdata/new.dagjson @@ -0,0 +1 @@ +[{"/":{"bytes":"o/vTvTs8SEkD9QL/eNhhW0fAng/SGBouywCbUnOfsF2RFHxaV02KTCyzgDxlJLZ2XN/Vk5igLmlKL3QIXMaeCQ"}},{"h":{"/":{"bytes":"NO0BcQ"}},"ucan/inv@1.0.0-rc.1":{"args":{"headers":{"Content-Type":"application/json"},"payload":{"body":"UCAN is great","draft":true,"title":"UCAN for Fun and Profit","topics":["authz","journal"]},"uri":"https://example.com/blog/posts"},"cmd":"/crud/create","exp":1730812145,"iss":"did:key:z6MkvMGkN5nbUQLBVqJhr13Zdqyh9rR1VuF16PuZbfocBxpv","meta":{"env":"development","tags":["blog","post","pr#123"]},"nonce":{"/":{"bytes":"q1AH6MJrqoTH6av7"}},"prf":[{"/":"bafyreigx3qxd2cndpe66j2mdssj773ecv7tqd7wovcnz5raguw6lj7sjoe"},{"/":"bafyreib34ira254zdqgehz6f2bhwme2ja2re3ltcalejv4x4tkcveujvpa"},{"/":"bafyreibkb66tpo2ixqx3fe5hmekkbuasrod6olt5bwm5u5pi726mduuwlq"}],"sub":"did:key:z6MkuFj35aiTL7YQiVMobuSeUQju92g7wZzufS3HAc6NFFcQ"}}] \ No newline at end of file From f2b4c3ac206237e461f705bcbda887fad02f0a44 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 09:35:55 -0500 Subject: [PATCH 06/32] docs(invocation): edit (and finish) Go docs for exported types --- token/invocation/invocation.go | 12 ++++++++++-- token/invocation/options.go | 14 ++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index d5703a9..55ef4a2 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -57,7 +57,8 @@ type Token struct { // or to leave the nonce empty respectively. // // If no invokedAt is provided, the current time is used. Use the -// WithInvokedAt or WithInvokedAtIn options to specify a different time. +// WithInvokedAt or WithInvokedAtIn Options to specify a different time +// or the WithoutInvokedAt Option to clear the Token's invokedAt field. // // With the exception of the WithMeta option, all other will overwrite // the previous contents of their target field. @@ -113,10 +114,14 @@ func (t *Token) Command() command.Command { return t.command } +// Arguments returns the arguments to be used when the command is +// invoked. func (t *Token) Arguments() map[string]datamodel.Node { return t.arguments } +// Proof() returns the ordered list of cid.Cids which referenced the +// delegation Tokens that authorize this invocation. func (t *Token) Proof() []cid.Cid { return t.proof } @@ -136,11 +141,14 @@ func (t *Token) Expiration() *time.Time { return t.expiration } +// InvokedAt returns the time.Time at which the invocation token was +// created. func (t *Token) InvokedAt() *time.Time { return t.invokedAt } -// Cause returns the (optional) +// Cause returns the Token's (optional) cause field which may specify +// which describes the Receipt that requested the invocation. func (t *Token) Cause() *cid.Cid { return t.cause } diff --git a/token/invocation/options.go b/token/invocation/options.go index ca79577..c8656d5 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -9,7 +9,7 @@ import ( ) // Option is a type that allows optional fields to be set during the -// creation of a Token. +// creation of a invocation Token. type Option func(*Token) error // WithArgument adds a key/value pair to the Token's Arguments field. @@ -62,7 +62,9 @@ func WithMeta(key string, val any) Option { // 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 require. +// 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 @@ -95,12 +97,16 @@ func WithExpiration(exp time.Time) Option { // WithExpirationIn set's the Token's optional "expiration" field to // Now() plus the given duration. -func WithExpirationIn(exp time.Duration) Option { - return WithExpiration(time.Now().Add(exp)) +func WithExpirationIn(after time.Duration) Option { + return WithExpiration(time.Now().Add(after)) } // WithInvokedAt sets the Token's invokedAt 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 WithoutInvokedAt Option. func WithInvokedAt(iat time.Time) Option { return func(t *Token) error { t.invokedAt = &iat From 728696f169d26e0e5a8682b285eb9143c1bd03a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Tue, 5 Nov 2024 17:39:39 +0100 Subject: [PATCH 07/32] invocation: round of cleanups/fixes --- token/delegation/delegation.go | 30 +++-------- token/internal/parse/parse.go | 22 ++++++++ token/invocation/examples_test.go | 7 +-- token/invocation/invocation.go | 83 +++++++++++-------------------- token/invocation/ipld.go | 9 +++- token/invocation/ipld_test.go | 22 ++------ token/invocation/options.go | 3 +- token/invocation/schema_test.go | 4 +- 8 files changed, 79 insertions(+), 101 deletions(-) create mode 100644 token/internal/parse/parse.go diff --git a/token/delegation/delegation.go b/token/delegation/delegation.go index ccaed08..83b1ab3 100644 --- a/token/delegation/delegation.go +++ b/token/delegation/delegation.go @@ -21,6 +21,7 @@ import ( "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" "github.com/ucan-wg/go-ucan/pkg/policy" + "github.com/ucan-wg/go-ucan/token/internal/parse" ) // Token is an immutable type that holds the fields of a UCAN delegation. @@ -184,27 +185,19 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) { return nil, fmt.Errorf("parse iss: %w", err) } - tkn.audience, err = did.Parse(m.Aud) - if err != nil { + if tkn.audience, err = did.Parse(m.Aud); err != nil { return nil, fmt.Errorf("parse audience: %w", err) } - if m.Sub != nil { - tkn.subject, err = did.Parse(*m.Sub) - if err != nil { - return nil, fmt.Errorf("parse subject: %w", err) - } - } else { - tkn.subject = did.Undef + if tkn.subject, err = parse.OptionalDID(m.Sub); err != nil { + return nil, fmt.Errorf("parse subject: %w", err) } - tkn.command, err = command.Parse(m.Cmd) - if err != nil { + if tkn.command, err = command.Parse(m.Cmd); err != nil { return nil, fmt.Errorf("parse command: %w", err) } - tkn.policy, err = policy.FromIPLD(m.Pol) - if err != nil { + if tkn.policy, err = policy.FromIPLD(m.Pol); err != nil { return nil, fmt.Errorf("parse policy: %w", err) } @@ -215,15 +208,8 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) { tkn.meta = &m.Meta - if m.Nbf != nil { - t := time.Unix(*m.Nbf, 0) - tkn.notBefore = &t - } - - if m.Exp != nil { - t := time.Unix(*m.Exp, 0) - tkn.expiration = &t - } + tkn.notBefore = parse.OptionalTimestamp(m.Nbf) + tkn.expiration = parse.OptionalTimestamp(m.Exp) if err := tkn.validate(); err != nil { return nil, err diff --git a/token/internal/parse/parse.go b/token/internal/parse/parse.go new file mode 100644 index 0000000..147b308 --- /dev/null +++ b/token/internal/parse/parse.go @@ -0,0 +1,22 @@ +package parse + +import ( + "time" + + "github.com/ucan-wg/go-ucan/did" +) + +func OptionalDID(s *string) (did.DID, error) { + if s == nil { + return did.Undef, nil + } + return did.Parse(*s) +} + +func OptionalTimestamp(sec *int64) *time.Time { + if sec == nil { + return nil + } + t := time.Unix(*sec, 0) + return &t +} diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index 83ec04e..f948041 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -15,6 +15,7 @@ import ( "github.com/ipld/go-ipld-prime/fluent/qp" "github.com/ipld/go-ipld-prime/node/basicnode" "github.com/libp2p/go-libp2p/core/crypto" + "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/token/invocation" @@ -28,11 +29,7 @@ func ExampleNew() { return } - inv, err := invocation.New( - iss, - sub, - cmd, - prf, + inv, err := invocation.New(iss, sub, cmd, prf, invocation.WithArguments(args), invocation.WithMeta("env", "development"), invocation.WithMeta("tags", meta["tags"]), diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 55ef4a2..7f68a02 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -15,9 +15,11 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" + "github.com/ucan-wg/go-ucan/token/internal/parse" ) // Token is an immutable type that holds the fields of a UCAN invocation. @@ -60,24 +62,18 @@ type Token struct { // WithInvokedAt or WithInvokedAtIn Options to specify a different time // or the WithoutInvokedAt Option to clear the Token's invokedAt field. // -// With the exception of the WithMeta option, all other will overwrite +// With the exception of the WithMeta option, all others will overwrite // the previous contents of their target field. func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) (*Token, error) { - nonce, err := generateNonce() - if err != nil { - return nil, err - } - iat := time.Now() - metadata := meta.NewMeta() tkn := Token{ issuer: iss, subject: sub, command: cmd, proof: prf, - meta: metadata, - nonce: nonce, + meta: meta.NewMeta(), + nonce: nil, invokedAt: &iat, } @@ -87,8 +83,15 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } } - if len(tkn.meta.Keys) == 0 { - tkn.meta = nil + if len(tkn.nonce) == 0 { + tkn.nonce, err = generateNonce() + if err != nil { + return nil, err + } + } + + if err := tkn.validate(); err != nil { + return nil, err } return &tkn, nil @@ -120,7 +123,7 @@ func (t *Token) Arguments() map[string]datamodel.Node { return t.arguments } -// Proof() returns the ordered list of cid.Cids which referenced the +// Proof() returns the ordered list of cid.Cid which reference the // delegation Tokens that authorize this invocation. func (t *Token) Proof() []cid.Cid { return t.proof @@ -163,8 +166,7 @@ func (t *Token) validate() error { } requiredDID(t.issuer, "Issuer") - - // TODO + requiredDID(t.subject, "Subject") if len(t.nonce) < 12 { errs = errors.Join(errs, fmt.Errorf("token nonce too small")) @@ -182,35 +184,36 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) { ) if tkn.issuer, err = did.Parse(m.Iss); err != nil { - return nil, err + return nil, fmt.Errorf("parse iss: %w", err) } if tkn.subject, err = did.Parse(m.Sub); err != nil { - return nil, err + return nil, fmt.Errorf("parse subject: %w", err) } - if tkn.audience, err = parseOptionalDID(m.Aud); err != nil { - return nil, err + if tkn.audience, err = parse.OptionalDID(m.Aud); err != nil { + return nil, fmt.Errorf("parse audience: %w", err) } if tkn.command, err = command.Parse(m.Cmd); err != nil { - return nil, err + return nil, fmt.Errorf("parse command: %w", err) } + if len(m.Nonce) == 0 { + return nil, fmt.Errorf("nonce is required") + } + tkn.nonce = m.Nonce + tkn.arguments = m.Args.Values tkn.proof = m.Prf tkn.meta = m.Meta - tkn.nonce = m.Nonce - if tkn.expiration, err = parseOptionalTimestamp(m.Exp); err != nil { - return nil, err - } + tkn.expiration = parse.OptionalTimestamp(m.Exp) + tkn.invokedAt = parse.OptionalTimestamp(m.Iat) - if tkn.invokedAt, err = parseOptionalTimestamp(m.Iat); err != nil { - return nil, err - } + tkn.cause = m.Cause - if tkn.cause, err = parseOptionalCID(m.Cause); err != nil { + if err := tkn.validate(); err != nil { return nil, err } @@ -227,29 +230,3 @@ func generateNonce() ([]byte, error) { } return res, nil } - -func parseOptionalCID(c *cid.Cid) (*cid.Cid, error) { - if c == nil { - return nil, nil - } - - return c, nil -} - -func parseOptionalDID(s *string) (did.DID, error) { - if s == nil { - return did.Undef, nil - } - - return did.Parse(*s) -} - -func parseOptionalTimestamp(sec *int64) (*time.Time, error) { - if sec == nil { - return nil, nil - } - - t := time.Unix(*sec, 0) - - return &t, nil -} diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 817b265..431e3a4 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -218,8 +218,9 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { } argsKey := make([]string, len(t.arguments)) - i := 0 + // TODO: make specialized type and builder? + i := 0 for k := range t.arguments { argsKey[i] = k i++ @@ -230,6 +231,7 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { Values: t.arguments, } + // TODO: reuse instead of copy? it's immutable prf := make([]cid.Cid, len(t.proof)) for i, c := range t.proof { prf[i] = c @@ -249,5 +251,10 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { Cause: t.cause, } + // seems like it's a requirement to have a null meta if there are no values? + if len(model.Meta.Keys) == 0 { + model.Meta = nil + } + return envelope.ToIPLD(privKey, model) } diff --git a/token/invocation/ipld_test.go b/token/invocation/ipld_test.go index ae52685..ec3c04f 100644 --- a/token/invocation/ipld_test.go +++ b/token/invocation/ipld_test.go @@ -4,13 +4,9 @@ import ( "testing" "time" - "github.com/ipfs/go-cid" - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/libp2p/go-libp2p/core/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/ucan-wg/go-ucan/did" - "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/token/invocation" ) @@ -20,16 +16,13 @@ func TestSealUnsealRoundtrip(t *testing.T) { privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() require.NoError(t, err) - tkn1, err := invocation.New( - iss, - sub, - cmd, - prf, + tkn1, err := invocation.New(iss, sub, cmd, prf, invocation.WithArguments(args), invocation.WithMeta("env", "development"), invocation.WithMeta("tags", meta["tags"]), invocation.WithExpirationIn(time.Minute), - invocation.WithoutInvokedAt()) + invocation.WithoutInvokedAt(), + ) require.NoError(t, err) data, cid1, err := tkn1.ToSealed(privKey) @@ -41,10 +34,3 @@ func TestSealUnsealRoundtrip(t *testing.T) { assert.Equal(t, cid1, cid2) assert.Equal(t, tkn1, tkn2) } - -func setupNew(t *testing.T) (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Command, args map[string]datamodel.Node, prf []cid.Cid, meta map[string]datamodel.Node) { - privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() - require.NoError(t, err) - - return // WARNING: named return values -} diff --git a/token/invocation/options.go b/token/invocation/options.go index c8656d5..1c557b9 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -5,11 +5,12 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" ) // Option is a type that allows optional fields to be set during the -// creation of a invocation Token. +// creation of an invocation Token. type Option func(*Token) error // WithArgument adds a key/value pair to the Token's Arguments field. diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go index 8fcc857..77b1afb 100644 --- a/token/invocation/schema_test.go +++ b/token/invocation/schema_test.go @@ -8,9 +8,10 @@ import ( "github.com/libp2p/go-libp2p/core/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gotest.tools/v3/golden" + "github.com/ucan-wg/go-ucan/token/internal/envelope" "github.com/ucan-wg/go-ucan/token/invocation" - "gotest.tools/v3/golden" ) const ( @@ -78,6 +79,7 @@ func TestSchemaRoundTrip(t *testing.T) { assert.JSONEq(t, string(invocationJson), readJson.String()) }) } + func privKey(t require.TestingT, privKeyCfg string) crypto.PrivKey { privKeyMar, err := crypto.ConfigDecodeKey(privKeyCfg) require.NoError(t, err) From a1aaf47d7c110470d65b79ac15744ac367532043 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 15:28:31 -0500 Subject: [PATCH 08/32] chore(invocation): clean up left-over (and unneeded) conversion of prf --- token/invocation/ipld.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 431e3a4..b477eb7 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -231,19 +231,13 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { Values: t.arguments, } - // TODO: reuse instead of copy? it's immutable - prf := make([]cid.Cid, len(t.proof)) - for i, c := range t.proof { - prf[i] = c - } - model := &tokenPayloadModel{ Iss: t.issuer.String(), Aud: aud, Sub: t.subject.String(), Cmd: t.command.String(), Args: args, - Prf: prf, + Prf: t.proof, Meta: t.meta, Nonce: t.nonce, Exp: exp, From 824c8fe5235847aae9333800c09dfc29403b0d29 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 6 Nov 2024 09:25:51 -0500 Subject: [PATCH 09/32] test(policy): adds example test case from invocation specification --- pkg/policy/spec_test.go | 93 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 pkg/policy/spec_test.go diff --git a/pkg/policy/spec_test.go b/pkg/policy/spec_test.go new file mode 100644 index 0000000..507dc4d --- /dev/null +++ b/pkg/policy/spec_test.go @@ -0,0 +1,93 @@ +package policy_test + +import ( + "testing" + + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/fluent/qp" + basicnode "github.com/ipld/go-ipld-prime/node/basic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/pkg/policy" +) + +// TestInvocationValidation applies the example policy to the example +// arguments as defined in the [Validation] section of the invocation +// specification. +// +// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation +func TestInvocationValidation(t *testing.T) { + t.Parallel() + + polNode, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("==")) + qp.ListEntry(la, qp.String(".from")) + qp.ListEntry(la, qp.String("alice@example.com")) + })) + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("any")) + qp.ListEntry(la, qp.String(".to")) + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("like")) + qp.ListEntry(la, qp.String(".")) + qp.ListEntry(la, qp.String("*@example.com")) + })) + })) + }) + require.NoError(t, err) + + pol, err := policy.FromIPLD(polNode) + require.NoError(t, err) + + t.Run("with passing args", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("alice@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@example.com")) + qp.ListEntry(la, qp.String("carol@not.example.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.True(t, pol.Match(argsNode)) + }) + + t.Run("fails on sender (first statement)", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("dan@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@example.com")) + qp.ListEntry(la, qp.String("carol@not.example.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.False(t, pol.Match(argsNode)) + }) + + t.Run("fails on recipients (second statement)", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("alice@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@null.com")) + qp.ListEntry(la, qp.String("carol@not.null.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.False(t, pol.Match(argsNode)) + }) +} From b4e222f8a0fa35e9af16352a3e088516b0abf382 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 6 Nov 2024 10:17:18 -0500 Subject: [PATCH 10/32] test(policy): refactor matching test from delegation spec --- pkg/policy/match_test.go | 51 ++++++++++++++++++++++ pkg/policy/spec_test.go | 93 ---------------------------------------- 2 files changed, 51 insertions(+), 93 deletions(-) delete mode 100644 pkg/policy/spec_test.go diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 9e3de4a..9940799 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -7,8 +7,11 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" "github.com/ipld/go-ipld-prime/codec/dagjson" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/fluent/qp" cidlink "github.com/ipld/go-ipld-prime/linking/cid" "github.com/ipld/go-ipld-prime/node/basicnode" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ucan-wg/go-ucan/pkg/policy/literal" @@ -512,3 +515,51 @@ func FuzzMatch(f *testing.F) { policy.Match(dataNode) }) } + +// TestInvocationValidation applies the example policy to the second +// example arguments as defined in the [Validation] section of the +// invocation specification. +// +// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation +func TestInvocationValidationSpecExamples(t *testing.T) { + t.Parallel() + + pol := MustConstruct( + Equal(".from", literal.String("alice@example.com")), + Any(".to", Like(".", "*@example.com")), + ) + + t.Run("with passing args", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("alice@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@example.com")) + qp.ListEntry(la, qp.String("carol@not.example.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.True(t, pol.Match(argsNode)) + }) + + t.Run("fails on recipients (second statement)", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("alice@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@null.com")) + qp.ListEntry(la, qp.String("carol@elsewhere.example.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.False(t, pol.Match(argsNode)) + }) +} diff --git a/pkg/policy/spec_test.go b/pkg/policy/spec_test.go deleted file mode 100644 index 507dc4d..0000000 --- a/pkg/policy/spec_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package policy_test - -import ( - "testing" - - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/ipld/go-ipld-prime/fluent/qp" - basicnode "github.com/ipld/go-ipld-prime/node/basic" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/ucan-wg/go-ucan/pkg/policy" -) - -// TestInvocationValidation applies the example policy to the example -// arguments as defined in the [Validation] section of the invocation -// specification. -// -// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation -func TestInvocationValidation(t *testing.T) { - t.Parallel() - - polNode, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("==")) - qp.ListEntry(la, qp.String(".from")) - qp.ListEntry(la, qp.String("alice@example.com")) - })) - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("any")) - qp.ListEntry(la, qp.String(".to")) - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("like")) - qp.ListEntry(la, qp.String(".")) - qp.ListEntry(la, qp.String("*@example.com")) - })) - })) - }) - require.NoError(t, err) - - pol, err := policy.FromIPLD(polNode) - require.NoError(t, err) - - t.Run("with passing args", func(t *testing.T) { - t.Parallel() - - argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, "from", qp.String("alice@example.com")) - qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("bob@example.com")) - qp.ListEntry(la, qp.String("carol@not.example.com")) - })) - qp.MapEntry(ma, "title", qp.String("Coffee")) - qp.MapEntry(ma, "body", qp.String("Still on for coffee")) - }) - require.NoError(t, err) - - assert.True(t, pol.Match(argsNode)) - }) - - t.Run("fails on sender (first statement)", func(t *testing.T) { - t.Parallel() - - argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, "from", qp.String("dan@example.com")) - qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("bob@example.com")) - qp.ListEntry(la, qp.String("carol@not.example.com")) - })) - qp.MapEntry(ma, "title", qp.String("Coffee")) - qp.MapEntry(ma, "body", qp.String("Still on for coffee")) - }) - require.NoError(t, err) - - assert.False(t, pol.Match(argsNode)) - }) - - t.Run("fails on recipients (second statement)", func(t *testing.T) { - t.Parallel() - - argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, "from", qp.String("alice@example.com")) - qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("bob@null.com")) - qp.ListEntry(la, qp.String("carol@not.null.com")) - })) - qp.MapEntry(ma, "title", qp.String("Coffee")) - qp.MapEntry(ma, "body", qp.String("Still on for coffee")) - }) - require.NoError(t, err) - - assert.False(t, pol.Match(argsNode)) - }) -} From d2b004c405aa1f9a6a160b93a10b64cdf2ec821f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Wed, 6 Nov 2024 18:06:46 +0100 Subject: [PATCH 11/32] meta: prevent overwrite of values --- pkg/meta/meta.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/meta/meta.go b/pkg/meta/meta.go index a51eec7..552612a 100644 --- a/pkg/meta/meta.go +++ b/pkg/meta/meta.go @@ -99,6 +99,9 @@ func (m *Meta) GetNode(key string) (ipld.Node, error) { // Accepted types for the value are: bool, string, int, int32, int64, []byte, // and ipld.Node. func (m *Meta) Add(key string, val any) error { + if _, ok := m.Values[key]; ok { + return fmt.Errorf("duplicate key %q", key) + } switch val := val.(type) { case bool: m.Values[key] = basicnode.NewBool(val) From 31d16ac4680822f822b9840136b04c9f2204f39e Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 24 Oct 2024 10:44:38 -0400 Subject: [PATCH 12/32] feat(invocation): provide New constructor and encoding to wire-format --- token/invocation/invocation.go | 103 ++++++++++++++++----- token/invocation/invocation.ipldsch | 29 +++--- token/invocation/ipld.go | 47 ++++++++-- token/invocation/options.go | 134 ++++++++++++++++++++++++++++ token/invocation/schema.go | 34 ++++--- 5 files changed, 295 insertions(+), 52 deletions(-) create mode 100644 token/invocation/options.go diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index b268481..845a69b 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -13,6 +13,8 @@ import ( "fmt" "time" + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/datamodel" "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" @@ -20,25 +22,70 @@ import ( // Token is an immutable type that holds the fields of a UCAN invocation. type Token struct { - // Issuer DID (invoker) + // The DID of the Invoker issuer did.DID - // Audience DID (receiver/executor) - audience did.DID - // Subject DID (subject being invoked) + // The DID of Subject being invoked subject did.DID - // The Command to invoke + // The DID of the intended Executor if different from the Subject + audience did.DID + + // The Command command command.Command - // TODO: args - // TODO: prf - // A unique, random nonce - nonce []byte + // The Command's Arguments + arguments map[string]datamodel.Node + // Delegations that prove the chain of authority + proof []cid.Cid + // 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 invokedAt *time.Time - // TODO: cause + + // An optional CID of the Receipt that enqueued the Task + cause *cid.Cid +} + +// New creates an invocation 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 invokedAt is provided, the current time is used. Use the +// WithInvokedAt or WithInvokedAtIn options to specify a different time. +// +// With the exception of the WithMeta option, all other will overwrite +// the previous contents of their target field. +func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) (*Token, error) { + nonce := make([]byte, 12) + + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + + iat := time.Now() + + tkn := Token{ + issuer: iss, + subject: sub, + command: cmd, + proof: prf, + nonce: nonce, + invokedAt: &iat, + } + + for _, opt := range opts { + if err := opt(&tkn); err != nil { + return nil, err + } + } + + return &tkn, nil } // Issuer returns the did.DID representing the Token's issuer. @@ -46,28 +93,27 @@ func (t *Token) Issuer() did.DID { return t.issuer } +// Subject returns the did.DID representing the Token's subject. +func (t *Token) Subject() did.DID { + return t.subject +} + // Audience returns the did.DID representing the Token's audience. func (t *Token) Audience() did.DID { return t.audience } -// Subject returns the did.DID representing the Token's subject. -// -// This field may be did.Undef for delegations that are [Powerlined] but -// must be equal to the value returned by the Issuer method for root -// tokens. -func (t *Token) Subject() did.DID { - return t.subject -} - // Command returns the capability's command.Command. func (t *Token) Command() command.Command { return t.command } -// Nonce returns the random Nonce encapsulated in this Token. -func (t *Token) Nonce() []byte { - return t.nonce +func (t *Token) Arguments() map[string]datamodel.Node { + return t.arguments +} + +func (t *Token) Proof() []cid.Cid { + return t.proof } // Meta returns the Token's metadata. @@ -75,11 +121,24 @@ 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 } +func (t *Token) InvokedAt() *time.Time { + return t.invokedAt +} + +func (t *Token) Cause() *cid.Cid { + return t.cause +} + func (t *Token) validate() error { var errs error diff --git a/token/invocation/invocation.ipldsch b/token/invocation/invocation.ipldsch index 2acab27..ba2396a 100644 --- a/token/invocation/invocation.ipldsch +++ b/token/invocation/invocation.ipldsch @@ -1,23 +1,32 @@ + type DID string # The Invocation Payload attaches sender, receiver, and provenance to the Task. type Payload struct { - # Issuer DID (sender) + # The DID of the invoker iss DID - # Audience DID (receiver) - aud DID - # Principal that the chain is about (the Subject) - sub optional DID + # The Subject being invoked + sub DID + # The DID of the intended Executor if different from the Subject + aud optional DID - # The Command to eventually invoke + # The Command cmd String - - # A unique, random nonce - nonce Bytes + # The Command's Arguments + args { String : Any} + # Delegations that prove the chain of authority + prf [ Link ] # Arbitrary Metadata - meta {String : Any} + meta optional { String : Any } + # A unique, random nonce + nonce optional Bytes # The timestamp at which the Invocation becomes invalid exp nullable Int + # The Timestamp at which the Invocation was created + iat optional Int + + # An optional CID of the Receipt that enqueued the Task + cause optional Link } diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 897f608..467e2be 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -193,29 +193,58 @@ func FromIPLD(node datamodel.Node) (*Token, error) { } func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { - var sub *string + var aud *string - if t.subject != did.Undef { - s := t.subject.String() - sub = &s + if t.audience != did.Undef { + a := t.audience.String() + aud = &a } - // TODO - var exp *int64 if t.expiration != nil { u := t.expiration.Unix() exp = &u } + var iat *int64 + if t.invokedAt != nil { + i := t.invokedAt.Unix() + iat = &i + } + + argsKey := make([]string, len(t.arguments)) + i := 0 + + for k := range t.arguments { + argsKey[i] = k + i++ + } + + args := struct { + Keys []string + Values map[string]datamodel.Node + }{ + Keys: argsKey, + Values: t.arguments, + } + + prf := make([]cid.Cid, len(t.proof)) + for i, c := range t.proof { + prf[i] = c + } + model := &tokenPayloadModel{ Iss: t.issuer.String(), - Aud: t.audience.String(), - Sub: sub, + Aud: aud, + Sub: t.subject.String(), Cmd: t.command.String(), + Args: args, + Prf: prf, + Meta: t.meta, Nonce: t.nonce, - Meta: *t.meta, Exp: exp, + Iat: iat, + Cause: t.cause, } return envelope.ToIPLD(privKey, model) diff --git a/token/invocation/options.go b/token/invocation/options.go new file mode 100644 index 0000000..08c7969 --- /dev/null +++ b/token/invocation/options.go @@ -0,0 +1,134 @@ +package invocation + +import ( + "time" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" +) + +// Option is a type that allows optional fields to be set during the +// creation of a Token. +type Option func(*Token) error + +// WithArgument adds a key/value pair to the Token's Arguments field. +func WithArgument(key string, val datamodel.Node) Option { + return func(t *Token) error { + t.arguments[key] = val + + return nil + } +} + +// WithArguments sets the Token's Arguments field to the provided map. +// +// Note that this will overwrite any existing Arguments whether provided +// by a previous call to this function or by one or more calls to +// WithArgument. +func WithArguments(args map[string]datamodel.Node) Option { + return func(t *Token) error { + t.arguments = args + + return nil + } +} + +// WithAudience sets the Token's audience to the provided did.DID. +// +// If the provided did.DID is the same as the Token's subject, the +// audience is not set. +func WithAudience(aud did.DID) Option { + return func(t *Token) error { + if t.subject.String() != aud.String() { + t.audience = aud + } + + return nil + } +} + +// 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) + } +} + +// 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 require. +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 +// idem +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 { + t.expiration = &exp + + return nil + } +} + +// WithExpirationIn set's the Token's optional "expiration" field to +// Now() plus the given duration. +func WithExpirationIn(exp time.Duration) Option { + return func(t *Token) error { + expTime := time.Now().Add(exp) + t.expiration = &expTime + + return nil + } +} + +// WithInvokedAt sets the Token's invokedAt field to the provided +// time.Time. +func WithInvokedAt(iat time.Time) Option { + return func(t *Token) error { + t.invokedAt = &iat + + return nil + } +} + +// WithInvokedAtIn sets the Token's invokedAt field to Now() plus the +// given duration. +func WithInvokedAtIn(after time.Duration) Option { + return func(t *Token) error { + iat := time.Now().Add(after) + t.invokedAt = &iat + + return nil + } +} + +// WithCause sets the Token's cause field to the provided cid.Cid. +func WithCause(cause *cid.Cid) Option { + return func(t *Token) error { + t.cause = cause + + return nil + } +} diff --git a/token/invocation/schema.go b/token/invocation/schema.go index e6941a2..f438e1f 100644 --- a/token/invocation/schema.go +++ b/token/invocation/schema.go @@ -5,7 +5,9 @@ import ( "fmt" "sync" + "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/datamodel" "github.com/ipld/go-ipld-prime/node/bindnode" "github.com/ipld/go-ipld-prime/schema" @@ -46,26 +48,36 @@ var _ envelope.Tokener = (*tokenPayloadModel)(nil) // TODO type tokenPayloadModel struct { - // Issuer DID (sender) + // The DID of the Invoker Iss string - // Audience DID (receiver) - Aud string - // Principal that the chain is about (the Subject) - // optional: can be nil - Sub *string + // The DID of Subject being invoked + Sub string + // The DID of the intended Executor if different from the Subject + Aud *string - // The Command to eventually invoke + // The Command Cmd string + // The Command's Arguments + Args struct { + Keys []string + Values map[string]datamodel.Node + } + // Delegations that prove the chain of authority + Prf []cid.Cid + + // Arbitrary Metadata + Meta *meta.Meta // A unique, random nonce Nonce []byte - - // Arbitrary Metadata - Meta meta.Meta - // The timestamp at which the Invocation becomes invalid // optional: can be nil Exp *int64 + // The timestamp at which the Invocation was created + Iat *int64 + + // An optional CID of the Receipt that enqueued the Task + Cause *cid.Cid } func (e *tokenPayloadModel) Prototype() schema.TypedPrototype { From a98653b7695baeb8a48be15b6cea7f5958cf86fd Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 24 Oct 2024 12:59:38 -0400 Subject: [PATCH 13/32] feat(invocation): produce example output similar to spec --- token/invocation/examples_test.go | 220 ++++++++++++++++++++++++++++++ token/invocation/invocation.go | 6 + token/invocation/options.go | 9 ++ 3 files changed, 235 insertions(+) create mode 100644 token/invocation/examples_test.go diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go new file mode 100644 index 0000000..530bcc0 --- /dev/null +++ b/token/invocation/examples_test.go @@ -0,0 +1,220 @@ +package invocation_test + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/ipfs/go-cid" + "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/datamodel" + "github.com/ipld/go-ipld-prime/fluent/qp" + "github.com/ipld/go-ipld-prime/node/basicnode" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/token/invocation" +) + +func ExampleNew() { + privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() + if err != nil { + fmt.Errorf("failed to create setup: %w", err) + + return + } + + inv, err := invocation.New( + iss, + sub, + cmd, + prf, + invocation.WithArguments(args), + invocation.WithMeta("env", "development"), + invocation.WithMeta("tags", meta["tags"]), + invocation.WithExpirationIn(time.Minute), + invocation.WithoutInvokedAt()) + if err != nil { + fmt.Errorf("failed to create invocation: %w", err) + + return + } + + data, cid, err := inv.ToSealed(privKey) + if err != nil { + fmt.Errorf("failed to seal invocation: %w", err) + + return + } + + json, err := prettyDAGJSON(data) + if err != nil { + fmt.Errorf("failed to pretty DAG-JSON: %w", err) + + return + } + + fmt.Println("CID:", cid) + fmt.Println("Token (pretty DAG-JSON):") + fmt.Println(json) + + // Expected CID and DAG-JSON output: + // CID: bafyreid2n5q45vk4osned7k5huocbe3mxbisonh5vujepqftc5ftr543ae + // Token (pretty DAG-JSON): + // [ + // { + // "/": { + // "bytes": "gvyL7kdSkgmaDpDU/Qj9ohRwxYLCHER52HFMSFEqQqEcQC9qr4JCPP1f/WybvGGuVzYiA0Hx4JO+ohNz8BxUAA" + // } + // }, + // { + // "h": { + // "/": { + // "bytes": "NO0BcQ" + // } + // }, + // "ucan/inv@1.0.0-rc.1": { + // "args": { + // "headers": { + // "Content-Type": "application/json" + // }, + // "payload": { + // "body": "UCAN is great", + // "draft": true, + // "title": "UCAN for Fun and Profit", + // "topics": [ + // "authz", + // "journal" + // ] + // }, + // "uri": "https://example.com/blog/posts" + // }, + // "cmd": "/crud/create", + // "exp": 1729788921, + // "iss": "did:key:z6MkhniGGyP88eZrq2dpMvUPdS2RQMhTUAWzcu6kVGUvEtCJ", + // "meta": { + // "env": "development", + // "tags": [ + // "blog", + // "post", + // "pr#123" + // ] + // }, + // "nonce": { + // "/": { + // "bytes": "2xXPoZwWln1TfXIp" + // } + // }, + // "prf": [ + // { + // "/": "bafyreigx3qxd2cndpe66j2mdssj773ecv7tqd7wovcnz5raguw6lj7sjoe" + // }, + // { + // "/": "bafyreib34ira254zdqgehz6f2bhwme2ja2re3ltcalejv4x4tkcveujvpa" + // }, + // { + // "/": "bafyreibkb66tpo2ixqx3fe5hmekkbuasrod6olt5bwm5u5pi726mduuwlq" + // } + // ], + // "sub": "did:key:z6MktWuvPvBe5UyHnDGuEdw8aJ5qrhhwLG6jy7cQYM6ckP6P" + // } + // } + // ] +} + +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 crypto.PrivKey, iss, sub did.DID, cmd command.Command, args map[string]datamodel.Node, prf []cid.Cid, meta map[string]datamodel.Node, errs error) { + var err error + + privKey, iss, err = did.GenerateEd25519() + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to generate Issuer identity: %w", err)) + } + + _, sub, err = did.GenerateEd25519() + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to generate Subject identity: %w", err)) + } + + cmd, err = command.Parse("/crud/create") + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to parse command: %w", err)) + } + + headers, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "Content-Type", qp.String("application/json")) + }) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to build headers: %w", err)) + } + payload, err := qp.BuildMap(basicnode.Prototype.Any, 4, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "title", qp.String("UCAN for Fun and Profit")) + qp.MapEntry(ma, "body", qp.String("UCAN is great")) + qp.MapEntry(ma, "topics", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("authz")) + qp.ListEntry(la, qp.String("journal")) + })) + qp.MapEntry(ma, "draft", qp.Bool(true)) + }) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to build payload: %w", err)) + } + + args = map[string]datamodel.Node{ + "uri": basicnode.NewString("https://example.com/blog/posts"), + "headers": headers, + "payload": payload, + } + + prf = make([]cid.Cid, 3) + for i, v := range []string{ + "zdpuAzx4sBrBCabrZZqXgvK3NDzh7Mf5mKbG11aBkkMCdLtCp", + "zdpuApTCXfoKh2sB1KaUaVSGofCBNPUnXoBb6WiCeitXEibZy", + "zdpuAoFdXRPw4n6TLcncoDhq1Mr6FGbpjAiEtqSBrTSaYMKkf", + } { + prf[i], err = cid.Parse(v) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to parse proof cid: %w", err)) + } + } + + tags, err := qp.BuildList(basicnode.Prototype.Any, 3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("blog")) + qp.ListEntry(la, qp.String("post")) + qp.ListEntry(la, qp.String("pr#123")) + }) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to build tags: %w", err)) + } + + meta = map[string]datamodel.Node{ + "env": basicnode.NewString("development"), + "tags": tags, + } + + return // WARNING: named return values +} diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 845a69b..23dda06 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -69,12 +69,14 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } iat := time.Now() + metadata := meta.NewMeta() tkn := Token{ issuer: iss, subject: sub, command: cmd, proof: prf, + meta: metadata, nonce: nonce, invokedAt: &iat, } @@ -85,6 +87,10 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } } + if len(tkn.meta.Keys) == 0 { + tkn.meta = nil + } + return &tkn, nil } diff --git a/token/invocation/options.go b/token/invocation/options.go index 08c7969..0d232fe 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -124,6 +124,15 @@ func WithInvokedAtIn(after time.Duration) Option { } } +// WithoutInvokedAt clears the Token's invokedAt field. +func WithoutInvokedAt() Option { + return func(t *Token) error { + t.invokedAt = nil + + return nil + } +} + // WithCause sets the Token's cause field to the provided cid.Cid. func WithCause(cause *cid.Cid) Option { return func(t *Token) error { From 187e7a869c576fb0e845bc4c266765dd9f506ecb Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Mon, 4 Nov 2024 16:07:11 -0500 Subject: [PATCH 14/32] feat(invocation): ipld unseal to invocation --- token/invocation/examples_test.go | 23 ++++++++--- token/invocation/invocation.go | 66 +++++++++++++++++++++++++++++-- token/invocation/ipld.go | 10 +++-- token/invocation/options.go | 3 +- token/invocation/schema.go | 7 +--- 5 files changed, 88 insertions(+), 21 deletions(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index 530bcc0..484643b 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -23,7 +23,7 @@ import ( func ExampleNew() { privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() if err != nil { - fmt.Errorf("failed to create setup: %w", err) + fmt.Println("failed to create setup:", err.Error()) return } @@ -39,21 +39,21 @@ func ExampleNew() { invocation.WithExpirationIn(time.Minute), invocation.WithoutInvokedAt()) if err != nil { - fmt.Errorf("failed to create invocation: %w", err) + fmt.Println("failed to create invocation:", err.Error()) return } data, cid, err := inv.ToSealed(privKey) if err != nil { - fmt.Errorf("failed to seal invocation: %w", err) + fmt.Println("failed to seal invocation:", err.Error()) return } json, err := prettyDAGJSON(data) if err != nil { - fmt.Errorf("failed to pretty DAG-JSON: %w", err) + fmt.Println("failed to pretty DAG-JSON:", err.Error()) return } @@ -139,6 +139,8 @@ func prettyDAGJSON(data []byte) (string, error) { return "", err } + fmt.Println(string(jsonData)) + var out bytes.Buffer if err := json.Indent(&out, jsonData, "", " "); err != nil { return "", err @@ -171,14 +173,19 @@ func setupExampleNew() (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Co if err != nil { errs = errors.Join(errs, fmt.Errorf("failed to build headers: %w", err)) } + + // ***** WARNING - do not change the order of these elements. DAG-CBOR + // will order them alphabetically and the unsealed + // result won't match if the input isn't also created in + // alphabetical order. payload, err := qp.BuildMap(basicnode.Prototype.Any, 4, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, "title", qp.String("UCAN for Fun and Profit")) qp.MapEntry(ma, "body", qp.String("UCAN is great")) + qp.MapEntry(ma, "draft", qp.Bool(true)) + qp.MapEntry(ma, "title", qp.String("UCAN for Fun and Profit")) qp.MapEntry(ma, "topics", qp.List(2, func(la datamodel.ListAssembler) { qp.ListEntry(la, qp.String("authz")) qp.ListEntry(la, qp.String("journal")) })) - qp.MapEntry(ma, "draft", qp.Bool(true)) }) if err != nil { errs = errors.Join(errs, fmt.Errorf("failed to build payload: %w", err)) @@ -202,6 +209,10 @@ func setupExampleNew() (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Co } } + // ***** WARNING - do not change the order of these elements. DAG-CBOR + // will order them alphabetically and the unsealed + // result won't match if the input isn't also created in + // alphabetical order. tags, err := qp.BuildList(basicnode.Prototype.Any, 3, func(la datamodel.ListAssembler) { qp.ListEntry(la, qp.String("blog")) qp.ListEntry(la, qp.String("post")) diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 23dda06..c55fd9e 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -62,9 +62,8 @@ type Token struct { // With the exception of the WithMeta option, all other will overwrite // the previous contents of their target field. func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) (*Token, error) { - nonce := make([]byte, 12) - - if _, err := rand.Read(nonce); err != nil { + nonce, err := generateNonce() + if err != nil { return nil, err } @@ -141,6 +140,7 @@ func (t *Token) InvokedAt() *time.Time { return t.invokedAt } +// Cause returns the (optional) func (t *Token) Cause() *cid.Cid { return t.cause } @@ -170,9 +170,41 @@ func (t *Token) validate() error { func tokenFromModel(m tokenPayloadModel) (*Token, error) { var ( tkn Token + err error ) - // TODO + if tkn.issuer, err = did.Parse(m.Iss); err != nil { + return nil, err + } + + if tkn.subject, err = did.Parse(m.Sub); err != nil { + return nil, err + } + + if tkn.audience, err = parseOptionalDID(m.Aud); err != nil { + return nil, err + } + + if tkn.command, err = command.Parse(m.Cmd); err != nil { + return nil, err + } + + tkn.arguments = m.Args.Values + tkn.proof = m.Prf + tkn.meta = m.Meta + tkn.nonce = m.Nonce + + if tkn.expiration, err = parseOptionalTimestamp(m.Exp); err != nil { + return nil, err + } + + if tkn.invokedAt, err = parseOptionalTimestamp(m.Iat); err != nil { + return nil, err + } + + if tkn.cause, err = parseOptionalCID(m.Cause); err != nil { + return nil, err + } return &tkn, nil } @@ -187,3 +219,29 @@ func generateNonce() ([]byte, error) { } return res, nil } + +func parseOptionalCID(c *cid.Cid) (*cid.Cid, error) { + if c == nil { + return nil, nil + } + + return c, nil +} + +func parseOptionalDID(s *string) (did.DID, error) { + if s == nil { + return did.Undef, nil + } + + return did.Parse(*s) +} + +func parseOptionalTimestamp(sec *int64) (*time.Time, error) { + if sec == nil { + return nil, nil + } + + t := time.Unix(*sec, 0) + + return &t, nil +} diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 467e2be..817b265 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -192,6 +192,11 @@ func FromIPLD(node datamodel.Node) (*Token, error) { return tkn, err } +type stringAny struct { + Keys []string + Values map[string]datamodel.Node +} + func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { var aud *string @@ -220,10 +225,7 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { i++ } - args := struct { - Keys []string - Values map[string]datamodel.Node - }{ + args := stringAny{ Keys: argsKey, Values: t.arguments, } diff --git a/token/invocation/options.go b/token/invocation/options.go index 0d232fe..13a28d0 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -86,6 +86,7 @@ func WithEmptyNonce() Option { // 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 @@ -96,7 +97,7 @@ func WithExpiration(exp time.Time) Option { // Now() plus the given duration. func WithExpirationIn(exp time.Duration) Option { return func(t *Token) error { - expTime := time.Now().Add(exp) + expTime := time.Now().Add(exp).Round(time.Minute) t.expiration = &expTime return nil diff --git a/token/invocation/schema.go b/token/invocation/schema.go index f438e1f..b2b99f2 100644 --- a/token/invocation/schema.go +++ b/token/invocation/schema.go @@ -7,7 +7,6 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" - "github.com/ipld/go-ipld-prime/datamodel" "github.com/ipld/go-ipld-prime/node/bindnode" "github.com/ipld/go-ipld-prime/schema" @@ -46,7 +45,6 @@ func payloadType() schema.Type { var _ envelope.Tokener = (*tokenPayloadModel)(nil) -// TODO type tokenPayloadModel struct { // The DID of the Invoker Iss string @@ -58,10 +56,7 @@ type tokenPayloadModel struct { // The Command Cmd string // The Command's Arguments - Args struct { - Keys []string - Values map[string]datamodel.Node - } + Args stringAny // Delegations that prove the chain of authority Prf []cid.Cid From 1b61f2e4dbe5e4e790ca2808e006687ca2967109 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 07:39:51 -0500 Subject: [PATCH 15/32] docs(invocation): fix truncated WithEmptyNonce description --- token/invocation/options.go | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/token/invocation/options.go b/token/invocation/options.go index 13a28d0..ca79577 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -73,7 +73,7 @@ func WithNonce(nonce []byte) Option { // WithEmptyNonce sets the Token's nonce to an empty byte slice as // suggested by the UCAN spec for invocation tokens that represent -// idem +// idempotent operations. func WithEmptyNonce() Option { return func(t *Token) error { t.nonce = []byte{} @@ -96,12 +96,7 @@ func WithExpiration(exp time.Time) Option { // WithExpirationIn set's the Token's optional "expiration" field to // Now() plus the given duration. func WithExpirationIn(exp time.Duration) Option { - return func(t *Token) error { - expTime := time.Now().Add(exp).Round(time.Minute) - t.expiration = &expTime - - return nil - } + return WithExpiration(time.Now().Add(exp)) } // WithInvokedAt sets the Token's invokedAt field to the provided @@ -117,12 +112,7 @@ func WithInvokedAt(iat time.Time) Option { // WithInvokedAtIn sets the Token's invokedAt field to Now() plus the // given duration. func WithInvokedAtIn(after time.Duration) Option { - return func(t *Token) error { - iat := time.Now().Add(after) - t.invokedAt = &iat - - return nil - } + return WithInvokedAt(time.Now().Add(after)) } // WithoutInvokedAt clears the Token's invokedAt field. From 08f821f23d8222e1b31f02c607097dc94e4d9695 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 08:39:43 -0500 Subject: [PATCH 16/32] test(invocation): adds schema round-trip test --- token/invocation/examples_test.go | 2 - token/invocation/ipld_test.go | 50 +++++++++++++++ token/invocation/schema_test.go | 89 +++++++++++++++++++++++++++ token/invocation/testdata/new.dagjson | 1 + 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 token/invocation/ipld_test.go create mode 100644 token/invocation/schema_test.go create mode 100644 token/invocation/testdata/new.dagjson diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index 484643b..83ec04e 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -139,8 +139,6 @@ func prettyDAGJSON(data []byte) (string, error) { return "", err } - fmt.Println(string(jsonData)) - var out bytes.Buffer if err := json.Indent(&out, jsonData, "", " "); err != nil { return "", err diff --git a/token/invocation/ipld_test.go b/token/invocation/ipld_test.go new file mode 100644 index 0000000..ae52685 --- /dev/null +++ b/token/invocation/ipld_test.go @@ -0,0 +1,50 @@ +package invocation_test + +import ( + "testing" + "time" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/token/invocation" +) + +func TestSealUnsealRoundtrip(t *testing.T) { + t.Parallel() + + privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() + require.NoError(t, err) + + tkn1, err := invocation.New( + iss, + sub, + cmd, + prf, + invocation.WithArguments(args), + invocation.WithMeta("env", "development"), + invocation.WithMeta("tags", meta["tags"]), + invocation.WithExpirationIn(time.Minute), + invocation.WithoutInvokedAt()) + require.NoError(t, err) + + data, cid1, err := tkn1.ToSealed(privKey) + require.NoError(t, err) + + tkn2, cid2, err := invocation.FromSealed(data) + require.NoError(t, err) + + assert.Equal(t, cid1, cid2) + assert.Equal(t, tkn1, tkn2) +} + +func setupNew(t *testing.T) (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Command, args map[string]datamodel.Node, prf []cid.Cid, meta map[string]datamodel.Node) { + privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() + require.NoError(t, err) + + return // WARNING: named return values +} diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go new file mode 100644 index 0000000..8fcc857 --- /dev/null +++ b/token/invocation/schema_test.go @@ -0,0 +1,89 @@ +package invocation_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/token/internal/envelope" + "github.com/ucan-wg/go-ucan/token/invocation" + "gotest.tools/v3/golden" +) + +const ( + issuerPrivKeyCfg = "CAESQK45xBfqIxRp7ZdRdck3tIJZKocCqvANQc925dCJhFwO7DJNA2j94zkF0TNx5mpXV0s6utfkFdHddWTaPVU6yZc=" + newCID = "zdpuAqY6Zypg4UnpbSUgDvYGneyFaTKaZevzxgSxV4rmv3Fpp" +) + +func TestSchemaRoundTrip(t *testing.T) { + t.Parallel() + + invocationJson := golden.Get(t, "new.dagjson") + 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 := invocation.FromDagJson(invocationJson) + require.NoError(t, err) + + cborBytes, id, err := p1.ToSealed(privKey) + require.NoError(t, err) + assert.Equal(t, newCID, envelope.CIDToBase58BTC(id)) + fmt.Println("cborBytes length", len(cborBytes)) + fmt.Println("cbor", string(cborBytes)) + + p2, c2, err := invocation.FromSealed(cborBytes) + require.NoError(t, err) + assert.Equal(t, id, c2) + fmt.Println("read Cbor", p2) + + readJson, err := p2.ToDagJson(privKey) + require.NoError(t, err) + fmt.Println("readJson length", len(readJson)) + fmt.Println("json: ", string(readJson)) + + assert.JSONEq(t, string(invocationJson), string(readJson)) + }) + + t.Run("via streaming", func(t *testing.T) { + t.Parallel() + + buf := bytes.NewBuffer(invocationJson) + + // format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson + // function: DecodeDagJson() Seal() Unseal() EncodeDagJson() + + p1, err := invocation.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 := invocation.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(invocationJson), readJson.String()) + }) +} +func privKey(t require.TestingT, privKeyCfg string) crypto.PrivKey { + privKeyMar, err := crypto.ConfigDecodeKey(privKeyCfg) + require.NoError(t, err) + + privKey, err := crypto.UnmarshalPrivateKey(privKeyMar) + require.NoError(t, err) + + return privKey +} diff --git a/token/invocation/testdata/new.dagjson b/token/invocation/testdata/new.dagjson new file mode 100644 index 0000000..c6a9b3f --- /dev/null +++ b/token/invocation/testdata/new.dagjson @@ -0,0 +1 @@ +[{"/":{"bytes":"o/vTvTs8SEkD9QL/eNhhW0fAng/SGBouywCbUnOfsF2RFHxaV02KTCyzgDxlJLZ2XN/Vk5igLmlKL3QIXMaeCQ"}},{"h":{"/":{"bytes":"NO0BcQ"}},"ucan/inv@1.0.0-rc.1":{"args":{"headers":{"Content-Type":"application/json"},"payload":{"body":"UCAN is great","draft":true,"title":"UCAN for Fun and Profit","topics":["authz","journal"]},"uri":"https://example.com/blog/posts"},"cmd":"/crud/create","exp":1730812145,"iss":"did:key:z6MkvMGkN5nbUQLBVqJhr13Zdqyh9rR1VuF16PuZbfocBxpv","meta":{"env":"development","tags":["blog","post","pr#123"]},"nonce":{"/":{"bytes":"q1AH6MJrqoTH6av7"}},"prf":[{"/":"bafyreigx3qxd2cndpe66j2mdssj773ecv7tqd7wovcnz5raguw6lj7sjoe"},{"/":"bafyreib34ira254zdqgehz6f2bhwme2ja2re3ltcalejv4x4tkcveujvpa"},{"/":"bafyreibkb66tpo2ixqx3fe5hmekkbuasrod6olt5bwm5u5pi726mduuwlq"}],"sub":"did:key:z6MkuFj35aiTL7YQiVMobuSeUQju92g7wZzufS3HAc6NFFcQ"}}] \ No newline at end of file From 3dc0011628b8e573e3c467b8c35083a36f502dde Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 09:35:55 -0500 Subject: [PATCH 17/32] docs(invocation): edit (and finish) Go docs for exported types --- token/invocation/invocation.go | 12 ++++++++++-- token/invocation/options.go | 14 ++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index c55fd9e..38b1c68 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -57,7 +57,8 @@ type Token struct { // or to leave the nonce empty respectively. // // If no invokedAt is provided, the current time is used. Use the -// WithInvokedAt or WithInvokedAtIn options to specify a different time. +// WithInvokedAt or WithInvokedAtIn Options to specify a different time +// or the WithoutInvokedAt Option to clear the Token's invokedAt field. // // With the exception of the WithMeta option, all other will overwrite // the previous contents of their target field. @@ -113,10 +114,14 @@ func (t *Token) Command() command.Command { return t.command } +// Arguments returns the arguments to be used when the command is +// invoked. func (t *Token) Arguments() map[string]datamodel.Node { return t.arguments } +// Proof() returns the ordered list of cid.Cids which referenced the +// delegation Tokens that authorize this invocation. func (t *Token) Proof() []cid.Cid { return t.proof } @@ -136,11 +141,14 @@ func (t *Token) Expiration() *time.Time { return t.expiration } +// InvokedAt returns the time.Time at which the invocation token was +// created. func (t *Token) InvokedAt() *time.Time { return t.invokedAt } -// Cause returns the (optional) +// Cause returns the Token's (optional) cause field which may specify +// which describes the Receipt that requested the invocation. func (t *Token) Cause() *cid.Cid { return t.cause } diff --git a/token/invocation/options.go b/token/invocation/options.go index ca79577..c8656d5 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -9,7 +9,7 @@ import ( ) // Option is a type that allows optional fields to be set during the -// creation of a Token. +// creation of a invocation Token. type Option func(*Token) error // WithArgument adds a key/value pair to the Token's Arguments field. @@ -62,7 +62,9 @@ func WithMeta(key string, val any) Option { // 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 require. +// 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 @@ -95,12 +97,16 @@ func WithExpiration(exp time.Time) Option { // WithExpirationIn set's the Token's optional "expiration" field to // Now() plus the given duration. -func WithExpirationIn(exp time.Duration) Option { - return WithExpiration(time.Now().Add(exp)) +func WithExpirationIn(after time.Duration) Option { + return WithExpiration(time.Now().Add(after)) } // WithInvokedAt sets the Token's invokedAt 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 WithoutInvokedAt Option. func WithInvokedAt(iat time.Time) Option { return func(t *Token) error { t.invokedAt = &iat From 7d4f973171cde87339c0b740863ace6f3015ba1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Tue, 5 Nov 2024 17:39:39 +0100 Subject: [PATCH 18/32] invocation: round of cleanups/fixes --- token/delegation/delegation.go | 30 +++-------- token/internal/parse/parse.go | 22 ++++++++ token/invocation/examples_test.go | 7 +-- token/invocation/invocation.go | 83 +++++++++++-------------------- token/invocation/ipld.go | 9 +++- token/invocation/ipld_test.go | 22 ++------ token/invocation/options.go | 3 +- token/invocation/schema_test.go | 4 +- 8 files changed, 79 insertions(+), 101 deletions(-) create mode 100644 token/internal/parse/parse.go diff --git a/token/delegation/delegation.go b/token/delegation/delegation.go index 77ba14d..f1e5553 100644 --- a/token/delegation/delegation.go +++ b/token/delegation/delegation.go @@ -21,6 +21,7 @@ import ( "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" "github.com/ucan-wg/go-ucan/pkg/policy" + "github.com/ucan-wg/go-ucan/token/internal/parse" ) // Token is an immutable type that holds the fields of a UCAN delegation. @@ -184,27 +185,19 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) { return nil, fmt.Errorf("parse iss: %w", err) } - tkn.audience, err = did.Parse(m.Aud) - if err != nil { + if tkn.audience, err = did.Parse(m.Aud); err != nil { return nil, fmt.Errorf("parse audience: %w", err) } - if m.Sub != nil { - tkn.subject, err = did.Parse(*m.Sub) - if err != nil { - return nil, fmt.Errorf("parse subject: %w", err) - } - } else { - tkn.subject = did.Undef + if tkn.subject, err = parse.OptionalDID(m.Sub); err != nil { + return nil, fmt.Errorf("parse subject: %w", err) } - tkn.command, err = command.Parse(m.Cmd) - if err != nil { + if tkn.command, err = command.Parse(m.Cmd); err != nil { return nil, fmt.Errorf("parse command: %w", err) } - tkn.policy, err = policy.FromIPLD(m.Pol) - if err != nil { + if tkn.policy, err = policy.FromIPLD(m.Pol); err != nil { return nil, fmt.Errorf("parse policy: %w", err) } @@ -215,15 +208,8 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) { tkn.meta = m.Meta - if m.Nbf != nil { - t := time.Unix(*m.Nbf, 0) - tkn.notBefore = &t - } - - if m.Exp != nil { - t := time.Unix(*m.Exp, 0) - tkn.expiration = &t - } + tkn.notBefore = parse.OptionalTimestamp(m.Nbf) + tkn.expiration = parse.OptionalTimestamp(m.Exp) if err := tkn.validate(); err != nil { return nil, err diff --git a/token/internal/parse/parse.go b/token/internal/parse/parse.go new file mode 100644 index 0000000..147b308 --- /dev/null +++ b/token/internal/parse/parse.go @@ -0,0 +1,22 @@ +package parse + +import ( + "time" + + "github.com/ucan-wg/go-ucan/did" +) + +func OptionalDID(s *string) (did.DID, error) { + if s == nil { + return did.Undef, nil + } + return did.Parse(*s) +} + +func OptionalTimestamp(sec *int64) *time.Time { + if sec == nil { + return nil + } + t := time.Unix(*sec, 0) + return &t +} diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index 83ec04e..f948041 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -15,6 +15,7 @@ import ( "github.com/ipld/go-ipld-prime/fluent/qp" "github.com/ipld/go-ipld-prime/node/basicnode" "github.com/libp2p/go-libp2p/core/crypto" + "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/token/invocation" @@ -28,11 +29,7 @@ func ExampleNew() { return } - inv, err := invocation.New( - iss, - sub, - cmd, - prf, + inv, err := invocation.New(iss, sub, cmd, prf, invocation.WithArguments(args), invocation.WithMeta("env", "development"), invocation.WithMeta("tags", meta["tags"]), diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 38b1c68..a1fdcb9 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -15,9 +15,11 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" + "github.com/ucan-wg/go-ucan/token/internal/parse" ) // Token is an immutable type that holds the fields of a UCAN invocation. @@ -60,24 +62,18 @@ type Token struct { // WithInvokedAt or WithInvokedAtIn Options to specify a different time // or the WithoutInvokedAt Option to clear the Token's invokedAt field. // -// With the exception of the WithMeta option, all other will overwrite +// With the exception of the WithMeta option, all others will overwrite // the previous contents of their target field. func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) (*Token, error) { - nonce, err := generateNonce() - if err != nil { - return nil, err - } - iat := time.Now() - metadata := meta.NewMeta() tkn := Token{ issuer: iss, subject: sub, command: cmd, proof: prf, - meta: metadata, - nonce: nonce, + meta: meta.NewMeta(), + nonce: nil, invokedAt: &iat, } @@ -87,8 +83,15 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } } - if len(tkn.meta.Keys) == 0 { - tkn.meta = nil + if len(tkn.nonce) == 0 { + tkn.nonce, err = generateNonce() + if err != nil { + return nil, err + } + } + + if err := tkn.validate(); err != nil { + return nil, err } return &tkn, nil @@ -120,7 +123,7 @@ func (t *Token) Arguments() map[string]datamodel.Node { return t.arguments } -// Proof() returns the ordered list of cid.Cids which referenced the +// Proof() returns the ordered list of cid.Cid which reference the // delegation Tokens that authorize this invocation. func (t *Token) Proof() []cid.Cid { return t.proof @@ -163,8 +166,7 @@ func (t *Token) validate() error { } requiredDID(t.issuer, "Issuer") - - // TODO + requiredDID(t.subject, "Subject") if len(t.nonce) < 12 { errs = errors.Join(errs, fmt.Errorf("token nonce too small")) @@ -182,35 +184,36 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) { ) if tkn.issuer, err = did.Parse(m.Iss); err != nil { - return nil, err + return nil, fmt.Errorf("parse iss: %w", err) } if tkn.subject, err = did.Parse(m.Sub); err != nil { - return nil, err + return nil, fmt.Errorf("parse subject: %w", err) } - if tkn.audience, err = parseOptionalDID(m.Aud); err != nil { - return nil, err + if tkn.audience, err = parse.OptionalDID(m.Aud); err != nil { + return nil, fmt.Errorf("parse audience: %w", err) } if tkn.command, err = command.Parse(m.Cmd); err != nil { - return nil, err + return nil, fmt.Errorf("parse command: %w", err) } + if len(m.Nonce) == 0 { + return nil, fmt.Errorf("nonce is required") + } + tkn.nonce = m.Nonce + tkn.arguments = m.Args.Values tkn.proof = m.Prf tkn.meta = m.Meta - tkn.nonce = m.Nonce - if tkn.expiration, err = parseOptionalTimestamp(m.Exp); err != nil { - return nil, err - } + tkn.expiration = parse.OptionalTimestamp(m.Exp) + tkn.invokedAt = parse.OptionalTimestamp(m.Iat) - if tkn.invokedAt, err = parseOptionalTimestamp(m.Iat); err != nil { - return nil, err - } + tkn.cause = m.Cause - if tkn.cause, err = parseOptionalCID(m.Cause); err != nil { + if err := tkn.validate(); err != nil { return nil, err } @@ -227,29 +230,3 @@ func generateNonce() ([]byte, error) { } return res, nil } - -func parseOptionalCID(c *cid.Cid) (*cid.Cid, error) { - if c == nil { - return nil, nil - } - - return c, nil -} - -func parseOptionalDID(s *string) (did.DID, error) { - if s == nil { - return did.Undef, nil - } - - return did.Parse(*s) -} - -func parseOptionalTimestamp(sec *int64) (*time.Time, error) { - if sec == nil { - return nil, nil - } - - t := time.Unix(*sec, 0) - - return &t, nil -} diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 817b265..431e3a4 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -218,8 +218,9 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { } argsKey := make([]string, len(t.arguments)) - i := 0 + // TODO: make specialized type and builder? + i := 0 for k := range t.arguments { argsKey[i] = k i++ @@ -230,6 +231,7 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { Values: t.arguments, } + // TODO: reuse instead of copy? it's immutable prf := make([]cid.Cid, len(t.proof)) for i, c := range t.proof { prf[i] = c @@ -249,5 +251,10 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { Cause: t.cause, } + // seems like it's a requirement to have a null meta if there are no values? + if len(model.Meta.Keys) == 0 { + model.Meta = nil + } + return envelope.ToIPLD(privKey, model) } diff --git a/token/invocation/ipld_test.go b/token/invocation/ipld_test.go index ae52685..ec3c04f 100644 --- a/token/invocation/ipld_test.go +++ b/token/invocation/ipld_test.go @@ -4,13 +4,9 @@ import ( "testing" "time" - "github.com/ipfs/go-cid" - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/libp2p/go-libp2p/core/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/ucan-wg/go-ucan/did" - "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/token/invocation" ) @@ -20,16 +16,13 @@ func TestSealUnsealRoundtrip(t *testing.T) { privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() require.NoError(t, err) - tkn1, err := invocation.New( - iss, - sub, - cmd, - prf, + tkn1, err := invocation.New(iss, sub, cmd, prf, invocation.WithArguments(args), invocation.WithMeta("env", "development"), invocation.WithMeta("tags", meta["tags"]), invocation.WithExpirationIn(time.Minute), - invocation.WithoutInvokedAt()) + invocation.WithoutInvokedAt(), + ) require.NoError(t, err) data, cid1, err := tkn1.ToSealed(privKey) @@ -41,10 +34,3 @@ func TestSealUnsealRoundtrip(t *testing.T) { assert.Equal(t, cid1, cid2) assert.Equal(t, tkn1, tkn2) } - -func setupNew(t *testing.T) (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Command, args map[string]datamodel.Node, prf []cid.Cid, meta map[string]datamodel.Node) { - privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() - require.NoError(t, err) - - return // WARNING: named return values -} diff --git a/token/invocation/options.go b/token/invocation/options.go index c8656d5..1c557b9 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -5,11 +5,12 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" ) // Option is a type that allows optional fields to be set during the -// creation of a invocation Token. +// creation of an invocation Token. type Option func(*Token) error // WithArgument adds a key/value pair to the Token's Arguments field. diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go index 8fcc857..77b1afb 100644 --- a/token/invocation/schema_test.go +++ b/token/invocation/schema_test.go @@ -8,9 +8,10 @@ import ( "github.com/libp2p/go-libp2p/core/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gotest.tools/v3/golden" + "github.com/ucan-wg/go-ucan/token/internal/envelope" "github.com/ucan-wg/go-ucan/token/invocation" - "gotest.tools/v3/golden" ) const ( @@ -78,6 +79,7 @@ func TestSchemaRoundTrip(t *testing.T) { assert.JSONEq(t, string(invocationJson), readJson.String()) }) } + func privKey(t require.TestingT, privKeyCfg string) crypto.PrivKey { privKeyMar, err := crypto.ConfigDecodeKey(privKeyCfg) require.NoError(t, err) From ce7f653ab053a8d910471ab0192a74cffe328f5b Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 15:28:31 -0500 Subject: [PATCH 19/32] chore(invocation): clean up left-over (and unneeded) conversion of prf --- token/invocation/ipld.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 431e3a4..b477eb7 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -231,19 +231,13 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { Values: t.arguments, } - // TODO: reuse instead of copy? it's immutable - prf := make([]cid.Cid, len(t.proof)) - for i, c := range t.proof { - prf[i] = c - } - model := &tokenPayloadModel{ Iss: t.issuer.String(), Aud: aud, Sub: t.subject.String(), Cmd: t.command.String(), Args: args, - Prf: prf, + Prf: t.proof, Meta: t.meta, Nonce: t.nonce, Exp: exp, From 58bb5cdb8fe61f4da4eda45ae7f114a8ddded0af Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 6 Nov 2024 09:25:51 -0500 Subject: [PATCH 20/32] test(policy): adds example test case from invocation specification --- pkg/policy/spec_test.go | 93 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 pkg/policy/spec_test.go diff --git a/pkg/policy/spec_test.go b/pkg/policy/spec_test.go new file mode 100644 index 0000000..507dc4d --- /dev/null +++ b/pkg/policy/spec_test.go @@ -0,0 +1,93 @@ +package policy_test + +import ( + "testing" + + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/fluent/qp" + basicnode "github.com/ipld/go-ipld-prime/node/basic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/pkg/policy" +) + +// TestInvocationValidation applies the example policy to the example +// arguments as defined in the [Validation] section of the invocation +// specification. +// +// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation +func TestInvocationValidation(t *testing.T) { + t.Parallel() + + polNode, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("==")) + qp.ListEntry(la, qp.String(".from")) + qp.ListEntry(la, qp.String("alice@example.com")) + })) + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("any")) + qp.ListEntry(la, qp.String(".to")) + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("like")) + qp.ListEntry(la, qp.String(".")) + qp.ListEntry(la, qp.String("*@example.com")) + })) + })) + }) + require.NoError(t, err) + + pol, err := policy.FromIPLD(polNode) + require.NoError(t, err) + + t.Run("with passing args", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("alice@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@example.com")) + qp.ListEntry(la, qp.String("carol@not.example.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.True(t, pol.Match(argsNode)) + }) + + t.Run("fails on sender (first statement)", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("dan@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@example.com")) + qp.ListEntry(la, qp.String("carol@not.example.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.False(t, pol.Match(argsNode)) + }) + + t.Run("fails on recipients (second statement)", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("alice@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@null.com")) + qp.ListEntry(la, qp.String("carol@not.null.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.False(t, pol.Match(argsNode)) + }) +} From 962e897ff5445c52804ed13611ca7492eeae8496 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 6 Nov 2024 10:17:18 -0500 Subject: [PATCH 21/32] Otest(policy): refactor matching test from delegation spec --- pkg/policy/match_test.go | 51 ++++++++++++++++++++++ pkg/policy/spec_test.go | 93 ---------------------------------------- 2 files changed, 51 insertions(+), 93 deletions(-) delete mode 100644 pkg/policy/spec_test.go diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 7d10d43..a3cbc39 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -7,8 +7,11 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" "github.com/ipld/go-ipld-prime/codec/dagjson" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/fluent/qp" cidlink "github.com/ipld/go-ipld-prime/linking/cid" "github.com/ipld/go-ipld-prime/node/basicnode" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ucan-wg/go-ucan/pkg/policy/literal" @@ -889,3 +892,51 @@ func TestPartialMatch(t *testing.T) { }) } } + +// TestInvocationValidation applies the example policy to the second +// example arguments as defined in the [Validation] section of the +// invocation specification. +// +// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation +func TestInvocationValidationSpecExamples(t *testing.T) { + t.Parallel() + + pol := MustConstruct( + Equal(".from", literal.String("alice@example.com")), + Any(".to", Like(".", "*@example.com")), + ) + + t.Run("with passing args", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("alice@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@example.com")) + qp.ListEntry(la, qp.String("carol@not.example.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.True(t, pol.Match(argsNode)) + }) + + t.Run("fails on recipients (second statement)", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("alice@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@null.com")) + qp.ListEntry(la, qp.String("carol@elsewhere.example.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.False(t, pol.Match(argsNode)) + }) +} diff --git a/pkg/policy/spec_test.go b/pkg/policy/spec_test.go deleted file mode 100644 index 507dc4d..0000000 --- a/pkg/policy/spec_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package policy_test - -import ( - "testing" - - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/ipld/go-ipld-prime/fluent/qp" - basicnode "github.com/ipld/go-ipld-prime/node/basic" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/ucan-wg/go-ucan/pkg/policy" -) - -// TestInvocationValidation applies the example policy to the example -// arguments as defined in the [Validation] section of the invocation -// specification. -// -// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation -func TestInvocationValidation(t *testing.T) { - t.Parallel() - - polNode, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("==")) - qp.ListEntry(la, qp.String(".from")) - qp.ListEntry(la, qp.String("alice@example.com")) - })) - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("any")) - qp.ListEntry(la, qp.String(".to")) - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("like")) - qp.ListEntry(la, qp.String(".")) - qp.ListEntry(la, qp.String("*@example.com")) - })) - })) - }) - require.NoError(t, err) - - pol, err := policy.FromIPLD(polNode) - require.NoError(t, err) - - t.Run("with passing args", func(t *testing.T) { - t.Parallel() - - argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, "from", qp.String("alice@example.com")) - qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("bob@example.com")) - qp.ListEntry(la, qp.String("carol@not.example.com")) - })) - qp.MapEntry(ma, "title", qp.String("Coffee")) - qp.MapEntry(ma, "body", qp.String("Still on for coffee")) - }) - require.NoError(t, err) - - assert.True(t, pol.Match(argsNode)) - }) - - t.Run("fails on sender (first statement)", func(t *testing.T) { - t.Parallel() - - argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, "from", qp.String("dan@example.com")) - qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("bob@example.com")) - qp.ListEntry(la, qp.String("carol@not.example.com")) - })) - qp.MapEntry(ma, "title", qp.String("Coffee")) - qp.MapEntry(ma, "body", qp.String("Still on for coffee")) - }) - require.NoError(t, err) - - assert.False(t, pol.Match(argsNode)) - }) - - t.Run("fails on recipients (second statement)", func(t *testing.T) { - t.Parallel() - - argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, "from", qp.String("alice@example.com")) - qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("bob@null.com")) - qp.ListEntry(la, qp.String("carol@not.null.com")) - })) - qp.MapEntry(ma, "title", qp.String("Coffee")) - qp.MapEntry(ma, "body", qp.String("Still on for coffee")) - }) - require.NoError(t, err) - - assert.False(t, pol.Match(argsNode)) - }) -} From a52b48cf47b9b6472051dc1f22293ccc61e814a7 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 24 Oct 2024 10:44:38 -0400 Subject: [PATCH 22/32] feat(invocation): provide New constructor and encoding to wire-format --- token/invocation/invocation.go | 1 - token/invocation/options.go | 1 - 2 files changed, 2 deletions(-) diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index a1fdcb9..0e0a306 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -15,7 +15,6 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" - "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" diff --git a/token/invocation/options.go b/token/invocation/options.go index 1c557b9..f2f870a 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -5,7 +5,6 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" - "github.com/ucan-wg/go-ucan/did" ) From 4932e320523822f69bb65d737eee85a4314e963f Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 24 Oct 2024 12:59:38 -0400 Subject: [PATCH 23/32] feat(invocation): produce example output similar to spec --- token/invocation/examples_test.go | 1 - token/invocation/invocation.go | 11 +++++++++++ token/invocation/options.go | 9 +++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index f948041..ce1743f 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -15,7 +15,6 @@ import ( "github.com/ipld/go-ipld-prime/fluent/qp" "github.com/ipld/go-ipld-prime/node/basicnode" "github.com/libp2p/go-libp2p/core/crypto" - "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/token/invocation" diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 0e0a306..ff6275f 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -65,14 +65,20 @@ type Token struct { // the previous contents of their target field. func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) (*Token, error) { iat := time.Now() + metadata := meta.NewMeta() tkn := Token{ issuer: iss, subject: sub, command: cmd, proof: prf, +<<<<<<< HEAD meta: meta.NewMeta(), nonce: nil, +======= + meta: metadata, + nonce: nonce, +>>>>>>> f44cf8a (feat(invocation): produce example output similar to spec) invokedAt: &iat, } @@ -82,6 +88,7 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } } +<<<<<<< HEAD if len(tkn.nonce) == 0 { tkn.nonce, err = generateNonce() if err != nil { @@ -91,6 +98,10 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( if err := tkn.validate(); err != nil { return nil, err +======= + if len(tkn.meta.Keys) == 0 { + tkn.meta = nil +>>>>>>> f44cf8a (feat(invocation): produce example output similar to spec) } return &tkn, nil diff --git a/token/invocation/options.go b/token/invocation/options.go index f2f870a..fc6ca34 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -130,6 +130,15 @@ func WithoutInvokedAt() Option { } } +// WithoutInvokedAt clears the Token's invokedAt field. +func WithoutInvokedAt() Option { + return func(t *Token) error { + t.invokedAt = nil + + return nil + } +} + // WithCause sets the Token's cause field to the provided cid.Cid. func WithCause(cause *cid.Cid) Option { return func(t *Token) error { From d89fb395e33003653757ca4631510f8ffd4dbdd8 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Mon, 4 Nov 2024 16:07:11 -0500 Subject: [PATCH 24/32] feat(invocation): ipld unseal to invocation --- token/invocation/examples_test.go | 2 ++ token/invocation/invocation.go | 37 ++++++++++++++++++++++--------- token/invocation/options.go | 9 -------- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index ce1743f..1f2e713 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -135,6 +135,8 @@ func prettyDAGJSON(data []byte) (string, error) { return "", err } + fmt.Println(string(jsonData)) + var out bytes.Buffer if err := json.Indent(&out, jsonData, "", " "); err != nil { return "", err diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index ff6275f..0a44285 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -65,20 +65,14 @@ type Token struct { // the previous contents of their target field. func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) (*Token, error) { iat := time.Now() - metadata := meta.NewMeta() tkn := Token{ issuer: iss, subject: sub, command: cmd, proof: prf, -<<<<<<< HEAD meta: meta.NewMeta(), nonce: nil, -======= - meta: metadata, - nonce: nonce, ->>>>>>> f44cf8a (feat(invocation): produce example output similar to spec) invokedAt: &iat, } @@ -88,7 +82,6 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } } -<<<<<<< HEAD if len(tkn.nonce) == 0 { tkn.nonce, err = generateNonce() if err != nil { @@ -98,10 +91,6 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( if err := tkn.validate(); err != nil { return nil, err -======= - if len(tkn.meta.Keys) == 0 { - tkn.meta = nil ->>>>>>> f44cf8a (feat(invocation): produce example output similar to spec) } return &tkn, nil @@ -240,3 +229,29 @@ func generateNonce() ([]byte, error) { } return res, nil } + +func parseOptionalCID(c *cid.Cid) (*cid.Cid, error) { + if c == nil { + return nil, nil + } + + return c, nil +} + +func parseOptionalDID(s *string) (did.DID, error) { + if s == nil { + return did.Undef, nil + } + + return did.Parse(*s) +} + +func parseOptionalTimestamp(sec *int64) (*time.Time, error) { + if sec == nil { + return nil, nil + } + + t := time.Unix(*sec, 0) + + return &t, nil +} diff --git a/token/invocation/options.go b/token/invocation/options.go index fc6ca34..f2f870a 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -130,15 +130,6 @@ func WithoutInvokedAt() Option { } } -// WithoutInvokedAt clears the Token's invokedAt field. -func WithoutInvokedAt() Option { - return func(t *Token) error { - t.invokedAt = nil - - return nil - } -} - // WithCause sets the Token's cause field to the provided cid.Cid. func WithCause(cause *cid.Cid) Option { return func(t *Token) error { From d754c5837bb7f89e37266720050da82d81e223af Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 08:39:43 -0500 Subject: [PATCH 25/32] test(invocation): adds schema round-trip test --- token/invocation/examples_test.go | 2 -- token/invocation/schema_test.go | 1 - 2 files changed, 3 deletions(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index 1f2e713..ce1743f 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -135,8 +135,6 @@ func prettyDAGJSON(data []byte) (string, error) { return "", err } - fmt.Println(string(jsonData)) - var out bytes.Buffer if err := json.Indent(&out, jsonData, "", " "); err != nil { return "", err diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go index 77b1afb..92483ac 100644 --- a/token/invocation/schema_test.go +++ b/token/invocation/schema_test.go @@ -79,7 +79,6 @@ func TestSchemaRoundTrip(t *testing.T) { assert.JSONEq(t, string(invocationJson), readJson.String()) }) } - func privKey(t require.TestingT, privKeyCfg string) crypto.PrivKey { privKeyMar, err := crypto.ConfigDecodeKey(privKeyCfg) require.NoError(t, err) From bcdaf0cca3810e59525f0d7b96cbf622bc4ae23c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Tue, 5 Nov 2024 17:39:39 +0100 Subject: [PATCH 26/32] invocation: round of cleanups/fixes --- token/invocation/examples_test.go | 1 + token/invocation/invocation.go | 27 +-------------------------- token/invocation/options.go | 1 + token/invocation/schema_test.go | 1 + 4 files changed, 4 insertions(+), 26 deletions(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index ce1743f..f948041 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -15,6 +15,7 @@ import ( "github.com/ipld/go-ipld-prime/fluent/qp" "github.com/ipld/go-ipld-prime/node/basicnode" "github.com/libp2p/go-libp2p/core/crypto" + "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/token/invocation" diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 0a44285..a1fdcb9 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -15,6 +15,7 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" @@ -229,29 +230,3 @@ func generateNonce() ([]byte, error) { } return res, nil } - -func parseOptionalCID(c *cid.Cid) (*cid.Cid, error) { - if c == nil { - return nil, nil - } - - return c, nil -} - -func parseOptionalDID(s *string) (did.DID, error) { - if s == nil { - return did.Undef, nil - } - - return did.Parse(*s) -} - -func parseOptionalTimestamp(sec *int64) (*time.Time, error) { - if sec == nil { - return nil, nil - } - - t := time.Unix(*sec, 0) - - return &t, nil -} diff --git a/token/invocation/options.go b/token/invocation/options.go index f2f870a..1c557b9 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -5,6 +5,7 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" ) diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go index 92483ac..77b1afb 100644 --- a/token/invocation/schema_test.go +++ b/token/invocation/schema_test.go @@ -79,6 +79,7 @@ func TestSchemaRoundTrip(t *testing.T) { assert.JSONEq(t, string(invocationJson), readJson.String()) }) } + func privKey(t require.TestingT, privKeyCfg string) crypto.PrivKey { privKeyMar, err := crypto.ConfigDecodeKey(privKeyCfg) require.NoError(t, err) From adc2b8d0da9e7d24ad8bb426c874f074c143fff7 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 6 Nov 2024 09:25:51 -0500 Subject: [PATCH 27/32] test(policy): adds example test case from invocation specification --- pkg/policy/spec_test.go | 93 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 pkg/policy/spec_test.go diff --git a/pkg/policy/spec_test.go b/pkg/policy/spec_test.go new file mode 100644 index 0000000..507dc4d --- /dev/null +++ b/pkg/policy/spec_test.go @@ -0,0 +1,93 @@ +package policy_test + +import ( + "testing" + + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/fluent/qp" + basicnode "github.com/ipld/go-ipld-prime/node/basic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/pkg/policy" +) + +// TestInvocationValidation applies the example policy to the example +// arguments as defined in the [Validation] section of the invocation +// specification. +// +// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation +func TestInvocationValidation(t *testing.T) { + t.Parallel() + + polNode, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("==")) + qp.ListEntry(la, qp.String(".from")) + qp.ListEntry(la, qp.String("alice@example.com")) + })) + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("any")) + qp.ListEntry(la, qp.String(".to")) + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("like")) + qp.ListEntry(la, qp.String(".")) + qp.ListEntry(la, qp.String("*@example.com")) + })) + })) + }) + require.NoError(t, err) + + pol, err := policy.FromIPLD(polNode) + require.NoError(t, err) + + t.Run("with passing args", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("alice@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@example.com")) + qp.ListEntry(la, qp.String("carol@not.example.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.True(t, pol.Match(argsNode)) + }) + + t.Run("fails on sender (first statement)", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("dan@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@example.com")) + qp.ListEntry(la, qp.String("carol@not.example.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.False(t, pol.Match(argsNode)) + }) + + t.Run("fails on recipients (second statement)", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("alice@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@null.com")) + qp.ListEntry(la, qp.String("carol@not.null.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.False(t, pol.Match(argsNode)) + }) +} From 85557ab6b55eda7d3078368e903dfe15f0f71d52 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 6 Nov 2024 10:17:18 -0500 Subject: [PATCH 28/32] test(policy): refactor matching test from delegation spec --- pkg/policy/spec_test.go | 93 ----------------------------------------- 1 file changed, 93 deletions(-) delete mode 100644 pkg/policy/spec_test.go diff --git a/pkg/policy/spec_test.go b/pkg/policy/spec_test.go deleted file mode 100644 index 507dc4d..0000000 --- a/pkg/policy/spec_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package policy_test - -import ( - "testing" - - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/ipld/go-ipld-prime/fluent/qp" - basicnode "github.com/ipld/go-ipld-prime/node/basic" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/ucan-wg/go-ucan/pkg/policy" -) - -// TestInvocationValidation applies the example policy to the example -// arguments as defined in the [Validation] section of the invocation -// specification. -// -// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation -func TestInvocationValidation(t *testing.T) { - t.Parallel() - - polNode, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("==")) - qp.ListEntry(la, qp.String(".from")) - qp.ListEntry(la, qp.String("alice@example.com")) - })) - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("any")) - qp.ListEntry(la, qp.String(".to")) - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("like")) - qp.ListEntry(la, qp.String(".")) - qp.ListEntry(la, qp.String("*@example.com")) - })) - })) - }) - require.NoError(t, err) - - pol, err := policy.FromIPLD(polNode) - require.NoError(t, err) - - t.Run("with passing args", func(t *testing.T) { - t.Parallel() - - argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, "from", qp.String("alice@example.com")) - qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("bob@example.com")) - qp.ListEntry(la, qp.String("carol@not.example.com")) - })) - qp.MapEntry(ma, "title", qp.String("Coffee")) - qp.MapEntry(ma, "body", qp.String("Still on for coffee")) - }) - require.NoError(t, err) - - assert.True(t, pol.Match(argsNode)) - }) - - t.Run("fails on sender (first statement)", func(t *testing.T) { - t.Parallel() - - argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, "from", qp.String("dan@example.com")) - qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("bob@example.com")) - qp.ListEntry(la, qp.String("carol@not.example.com")) - })) - qp.MapEntry(ma, "title", qp.String("Coffee")) - qp.MapEntry(ma, "body", qp.String("Still on for coffee")) - }) - require.NoError(t, err) - - assert.False(t, pol.Match(argsNode)) - }) - - t.Run("fails on recipients (second statement)", func(t *testing.T) { - t.Parallel() - - argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, "from", qp.String("alice@example.com")) - qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("bob@null.com")) - qp.ListEntry(la, qp.String("carol@not.null.com")) - })) - qp.MapEntry(ma, "title", qp.String("Coffee")) - qp.MapEntry(ma, "body", qp.String("Still on for coffee")) - }) - require.NoError(t, err) - - assert.False(t, pol.Match(argsNode)) - }) -} From cfcb199818458823905d650484f6c6828962c753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Wed, 6 Nov 2024 18:06:46 +0100 Subject: [PATCH 29/32] meta: prevent overwrite of values --- pkg/meta/meta.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/meta/meta.go b/pkg/meta/meta.go index d1af3b8..ffc21f7 100644 --- a/pkg/meta/meta.go +++ b/pkg/meta/meta.go @@ -98,6 +98,9 @@ func (m *Meta) GetNode(key string) (ipld.Node, error) { // Accepted types for the value are: bool, string, int, int32, int64, []byte, // and ipld.Node. func (m *Meta) Add(key string, val any) error { + if _, ok := m.Values[key]; ok { + return fmt.Errorf("duplicate key %q", key) + } switch val := val.(type) { case bool: m.Values[key] = basicnode.NewBool(val) From d353dfe6529c90dfbd26949f91637687747cfb9d Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 7 Nov 2024 12:58:53 -0500 Subject: [PATCH 30/32] feat(args): create a specialized type to manage invocation Arguments --- pkg/args/args.go | 206 ++++++++++++++++++++++++++++++++++++++++++ pkg/args/args_test.go | 192 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 398 insertions(+) create mode 100644 pkg/args/args.go create mode 100644 pkg/args/args_test.go diff --git a/pkg/args/args.go b/pkg/args/args.go new file mode 100644 index 0000000..5436a37 --- /dev/null +++ b/pkg/args/args.go @@ -0,0 +1,206 @@ +// Package args provides the type that represents the Arguments passed to +// a command within an invocation.Token as well as a convenient Add method +// to incrementally build the underlying map. +package args + +import ( + "fmt" + "reflect" + "sync" + + "github.com/ipfs/go-cid" + "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/policy/literal" +) + +const ( + argsSchema = "type Args { String : Any }" + argsName = "Args" +) + +var ( + once sync.Once + ts *schema.TypeSystem + err error +) + +func argsType() schema.Type { + once.Do(func() { + ts, err = ipld.LoadSchemaBytes([]byte(argsSchema)) + }) + if err != nil { + panic(err) + } + + return ts.TypeByName(argsName) +} + +var ErrUnsupported = fmt.Errorf("failure adding unsupported type to meta") + +// Args are the Command's argumennts when an invocation Token is processed +// by the executor. +// +// This type must be compatible with the IPLD type represented by the IPLD +// schema { String : Any }. +type Args struct { + Keys []string + Values map[string]ipld.Node +} + +// New returns a pointer to an initialized Args value. +func New() *Args { + return &Args{ + Values: map[string]ipld.Node{}, + } +} + +// FromIPLD unwraps an Args instance from an ipld.Node. +func FromIPLD(node ipld.Node) (*Args, error) { + var err error + + defer func() { + err = handlePanic(recover()) + }() + + obj := bindnode.Unwrap(node) + + args, ok := obj.(*Args) + if !ok { + err = fmt.Errorf("failed to convert to Args") + } + + return args, err +} + +// Add inserts a key/value pair in the Args set. +// +// Accepted types for val are: bool, string, int, int8, int16, +// int32, int64, uint, uint8, uint16, uint32, float32, float64, []byte, +// []any, map[string]any, ipld.Node and nil. +func (m *Args) Add(key string, val any) error { + if _, ok := m.Values[key]; ok { + return fmt.Errorf("duplicate key %q", key) + } + + node, err := anyNode(val) + if err != nil { + return err + } + + m.Values[key] = node + m.Keys = append(m.Keys, key) + + return nil +} + +// Include merges the provided arguments into the existing arguments. +// +// If duplicate keys are encountered, the new value is silently dropped +// without causing an error. +func (m *Args) Include(other *Args) { + for _, key := range other.Keys { + if _, ok := m.Values[key]; ok { + // don't overwrite + continue + } + m.Values[key] = other.Values[key] + m.Keys = append(m.Keys, key) + } +} + +// ToIPLD wraps an instance of an Args with an ipld.Node. +func (m *Args) ToIPLD() (ipld.Node, error) { + var err error + + defer func() { + err = handlePanic(recover()) + }() + + return bindnode.Wrap(m, argsType()), err +} + +func anyNode(val any) (ipld.Node, error) { + var err error + + defer func() { + err = handlePanic(recover()) + }() + + if val == nil { + return literal.Null(), nil + } + + if cast, ok := val.(ipld.Node); ok { + return cast, nil + } + + if cast, ok := val.(cid.Cid); ok { + return literal.LinkCid(cast), err + } + + var rv reflect.Value + + rv.Kind() + + if cast, ok := val.(reflect.Value); ok { + rv = cast + } else { + rv = reflect.ValueOf(val) + } + + for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface { + rv = rv.Elem() + } + + switch rv.Kind() { + case reflect.Slice: + if rv.Type().Elem().Kind() == reflect.Uint8 { + return literal.Bytes(val.([]byte)), nil + } + + l := make([]reflect.Value, rv.Len()) + + for i := 0; i < rv.Len(); i++ { + l[i] = rv.Index(i) + } + + return literal.List(l) + case reflect.Map: + if rv.Type().Key().Kind() != reflect.String { + return nil, fmt.Errorf("unsupported map key type: %s", rv.Type().Key().Name()) + } + + m := make(map[string]reflect.Value, rv.Len()) + it := rv.MapRange() + + for it.Next() { + m[it.Key().String()] = it.Value() + } + + return literal.Map(m) + case reflect.String: + return literal.String(rv.String()), nil + case reflect.Bool: + return literal.Bool(rv.Bool()), nil + // reflect.Int64 may exceed the safe 53-bit limit of JavaScript + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return literal.Int(rv.Int()), nil + // reflect.Uint64 can't be safely converted to int64 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32: + return literal.Int(int64(rv.Uint())), nil + case reflect.Float32, reflect.Float64: + return literal.Float(rv.Float()), nil + default: + return nil, fmt.Errorf("unsupported Args type: %s", rv.Type().Name()) + } +} + +func handlePanic(rec any) error { + if err, ok := rec.(error); ok { + return err + } + + return fmt.Errorf("%v", rec) +} diff --git a/pkg/args/args_test.go b/pkg/args/args_test.go new file mode 100644 index 0000000..2cccb82 --- /dev/null +++ b/pkg/args/args_test.go @@ -0,0 +1,192 @@ +package args_test + +import ( + "sync" + "testing" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/codec/dagcbor" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/node/bindnode" + "github.com/ipld/go-ipld-prime/schema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/pkg/args" + "github.com/ucan-wg/go-ucan/pkg/policy/literal" +) + +const ( + argsSchema = "type Args { String : Any }" + argsName = "Args" +) + +var ( + once sync.Once + ts *schema.TypeSystem + err error +) + +func argsType() schema.Type { + once.Do(func() { + ts, err = ipld.LoadSchemaBytes([]byte(argsSchema)) + }) + if err != nil { + panic(err) + } + + return ts.TypeByName(argsName) +} + +func argsPrototype() schema.TypedPrototype { + return bindnode.Prototype((*args.Args)(nil), argsType()) +} + +func TestArgs(t *testing.T) { + t.Parallel() + + const ( + intKey = "intKey" + mapKey = "mapKey" + nilKey = "nilKey" + boolKey = "boolKey" + linkKey = "linkKey" + listKey = "listKey" + nodeKey = "nodeKey" + uintKey = "uintKey" + bytesKey = "bytesKey" + floatKey = "floatKey" + stringKey = "stringKey" + ) + + const ( + expIntVal = int64(-42) + expBoolVal = true + expUintVal = uint(42) + expStringVal = "stringVal" + ) + + var ( + expMapVal = map[string]string{"keyOne": "valOne", "keyTwo": "valTwo"} + // expNilVal = (map[string]string)(nil) + expLinkVal = cid.MustParse("bafzbeigai3eoy2ccc7ybwjfz5r3rdxqrinwi4rwytly24tdbh6yk7zslrm") + expListVal = []string{"elem1", "elem2", "elem3"} + expNodeVal = literal.String("nodeVal") + expBytesVal = []byte{0xde, 0xad, 0xbe, 0xef} + expFloatVal = 42.0 + ) + + argsIn := args.New() + + // WARNING: Do not change the order of these statements as this is the + // order which will be present when decoded from DAG-CBOR ( + // per RFC7049 default canonical ordering?). + for _, a := range []struct { + key string + val any + }{ + {key: intKey, val: expIntVal}, + {key: mapKey, val: expMapVal}, + // {key: nilKey, val: expNilVal}, + {key: boolKey, val: expBoolVal}, + {key: linkKey, val: expLinkVal}, + {key: listKey, val: expListVal}, + {key: nodeKey, val: expNodeVal}, + {key: uintKey, val: expUintVal}, + {key: bytesKey, val: expBytesVal}, + {key: floatKey, val: expFloatVal}, + {key: stringKey, val: expStringVal}, + } { + require.NoError(t, argsIn.Add(a.key, a.val)) + } + + // Round-trip to DAG-CBOR here as ToIPLD/FromIPLD is only a wrapper + argsOut := roundTripThroughDAGCBOR(t, argsIn) + assert.Equal(t, argsIn, argsOut) + + actMapVal := map[string]string{} + mit := argsOut.Values[mapKey].MapIterator() + + es := errorSwallower(t) + + for !mit.Done() { + k, v, err := mit.Next() + require.NoError(t, err) + ks := es(k.AsString()).(string) + vs := es(v.AsString()).(string) + + actMapVal[ks] = vs + } + + actListVal := []string{} + lit := argsOut.Values[listKey].ListIterator() + + for !lit.Done() { + _, v, err := lit.Next() + require.NoError(t, err) + vs := es(v.AsString()).(string) + + actListVal = append(actListVal, vs) + } + + assert.Equal(t, expIntVal, es(argsOut.Values[intKey].AsInt())) + assert.Equal(t, expMapVal, actMapVal) // TODO: special accessor + // TODO: the nil map comes back empty (but the right type) + // assert.Equal(t, expNilVal, actNilVal) + assert.Equal(t, expBoolVal, es(argsOut.Values[boolKey].AsBool())) + assert.Equal(t, expLinkVal.String(), es(argsOut.Values[linkKey].AsLink()).(datamodel.Link).String()) // TODO: special accessor + assert.Equal(t, expListVal, actListVal) // TODO: special accessor + assert.Equal(t, expNodeVal, argsOut.Values[nodeKey]) + assert.Equal(t, expUintVal, uint(es(argsOut.Values[uintKey].AsInt()).(int64))) + assert.Equal(t, expBytesVal, es(argsOut.Values[bytesKey].AsBytes())) + assert.Equal(t, expFloatVal, es(argsOut.Values[floatKey].AsFloat())) + assert.Equal(t, expStringVal, es(argsOut.Values[stringKey].AsString())) +} + +func TestArgs_Include(t *testing.T) { + t.Parallel() + + argsIn := args.New() + require.NoError(t, argsIn.Add("key1", "val1")) + require.NoError(t, argsIn.Add("key2", "val2")) + + argsOther := args.New() + require.NoError(t, argsOther.Add("key2", "valOther")) // This should not overwrite key2 above + require.NoError(t, argsOther.Add("key3", "val3")) + require.NoError(t, argsOther.Add("key4", "val4")) + + argsIn.Include(argsOther) + + es := errorSwallower(t) + + assert.Len(t, argsIn.Values, 4) + assert.Equal(t, "val1", es(argsIn.Values["key1"].AsString())) + assert.Equal(t, "val2", es(argsIn.Values["key2"].AsString())) + assert.Equal(t, "val3", es(argsIn.Values["key3"].AsString())) + assert.Equal(t, "val4", es(argsIn.Values["key4"].AsString())) +} + +func errorSwallower(t *testing.T) func(any, error) any { + return func(val any, err error) any { + require.NoError(t, err) + + return val + } +} + +func roundTripThroughDAGCBOR(t *testing.T, argsIn *args.Args) *args.Args { + t.Helper() + + node, err := argsIn.ToIPLD() + require.NoError(t, err) + + data, err := ipld.Encode(node, dagcbor.Encode) + require.NoError(t, err) + node, err = ipld.DecodeUsingPrototype(data, dagcbor.Decode, argsPrototype()) + require.NoError(t, err) + + argsOut, err := args.FromIPLD(node) + require.NoError(t, err) + + return argsOut +} From 11bc085c60f02a873090df4111b1cf2f53210eee Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 7 Nov 2024 13:17:22 -0500 Subject: [PATCH 31/32] test(policy): update to handel statement returned from Match --- pkg/policy/match_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 582d3dd..6a85512 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -932,7 +932,9 @@ func TestInvocationValidationSpecExamples(t *testing.T) { }) require.NoError(t, err) - assert.True(t, pol.Match(argsNode)) + exec, stmt := pol.Match(argsNode) + assert.True(t, exec) + assert.Nil(t, stmt) }) t.Run("fails on recipients (second statement)", func(t *testing.T) { @@ -949,6 +951,8 @@ func TestInvocationValidationSpecExamples(t *testing.T) { }) require.NoError(t, err) - assert.False(t, pol.Match(argsNode)) + exec, stmt := pol.Match(argsNode) + assert.False(t, exec) + assert.NotNil(t, stmt) }) } From 1fa2b5e6fc0f0735a2dde7899213c418bb03ee06 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 7 Nov 2024 13:50:20 -0500 Subject: [PATCH 32/32] feat(invocation): use dedicated type for invocation.Token.Arguments --- token/invocation/examples_test.go | 4 +++- token/invocation/invocation.go | 9 +++++---- token/invocation/ipld.go | 21 +-------------------- token/invocation/ipld_test.go | 4 +++- token/invocation/options.go | 20 ++------------------ token/invocation/schema.go | 3 ++- 6 files changed, 16 insertions(+), 45 deletions(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index f948041..7ecf349 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -30,7 +30,9 @@ func ExampleNew() { } inv, err := invocation.New(iss, sub, cmd, prf, - invocation.WithArguments(args), + invocation.WithArgument("uri", args["uri"]), + invocation.WithArgument("headers", args["headers"]), + invocation.WithArgument("payload", args["payload"]), invocation.WithMeta("env", "development"), invocation.WithMeta("tags", meta["tags"]), invocation.WithExpirationIn(time.Minute), diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index a1fdcb9..f6eb07a 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -14,9 +14,9 @@ import ( "time" "github.com/ipfs/go-cid" - "github.com/ipld/go-ipld-prime/datamodel" "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/args" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" "github.com/ucan-wg/go-ucan/token/internal/parse" @@ -34,7 +34,7 @@ type Token struct { // The Command command command.Command // The Command's Arguments - arguments map[string]datamodel.Node + arguments *args.Args // Delegations that prove the chain of authority proof []cid.Cid @@ -71,6 +71,7 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( issuer: iss, subject: sub, command: cmd, + arguments: args.New(), proof: prf, meta: meta.NewMeta(), nonce: nil, @@ -119,7 +120,7 @@ func (t *Token) Command() command.Command { // Arguments returns the arguments to be used when the command is // invoked. -func (t *Token) Arguments() map[string]datamodel.Node { +func (t *Token) Arguments() *args.Args { return t.arguments } @@ -204,7 +205,7 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) { } tkn.nonce = m.Nonce - tkn.arguments = m.Args.Values + tkn.arguments = m.Args tkn.proof = m.Prf tkn.meta = m.Meta diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index b477eb7..acaaf98 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -192,11 +192,6 @@ func FromIPLD(node datamodel.Node) (*Token, error) { return tkn, err } -type stringAny struct { - Keys []string - Values map[string]datamodel.Node -} - func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { var aud *string @@ -217,26 +212,12 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { iat = &i } - argsKey := make([]string, len(t.arguments)) - - // TODO: make specialized type and builder? - i := 0 - for k := range t.arguments { - argsKey[i] = k - i++ - } - - args := stringAny{ - Keys: argsKey, - Values: t.arguments, - } - model := &tokenPayloadModel{ Iss: t.issuer.String(), Aud: aud, Sub: t.subject.String(), Cmd: t.command.String(), - Args: args, + Args: t.arguments, Prf: t.proof, Meta: t.meta, Nonce: t.nonce, diff --git a/token/invocation/ipld_test.go b/token/invocation/ipld_test.go index ec3c04f..9754c16 100644 --- a/token/invocation/ipld_test.go +++ b/token/invocation/ipld_test.go @@ -17,7 +17,9 @@ func TestSealUnsealRoundtrip(t *testing.T) { require.NoError(t, err) tkn1, err := invocation.New(iss, sub, cmd, prf, - invocation.WithArguments(args), + invocation.WithArgument("uri", args["uri"]), + invocation.WithArgument("headers", args["headers"]), + invocation.WithArgument("payload", args["payload"]), invocation.WithMeta("env", "development"), invocation.WithMeta("tags", meta["tags"]), invocation.WithExpirationIn(time.Minute), diff --git a/token/invocation/options.go b/token/invocation/options.go index 1c557b9..9322cd7 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -4,7 +4,6 @@ import ( "time" "github.com/ipfs/go-cid" - "github.com/ipld/go-ipld-prime/datamodel" "github.com/ucan-wg/go-ucan/did" ) @@ -14,24 +13,9 @@ import ( type Option func(*Token) error // WithArgument adds a key/value pair to the Token's Arguments field. -func WithArgument(key string, val datamodel.Node) Option { +func WithArgument(key string, val any) Option { return func(t *Token) error { - t.arguments[key] = val - - return nil - } -} - -// WithArguments sets the Token's Arguments field to the provided map. -// -// Note that this will overwrite any existing Arguments whether provided -// by a previous call to this function or by one or more calls to -// WithArgument. -func WithArguments(args map[string]datamodel.Node) Option { - return func(t *Token) error { - t.arguments = args - - return nil + return t.arguments.Add(key, val) } } diff --git a/token/invocation/schema.go b/token/invocation/schema.go index b2b99f2..d51cf4f 100644 --- a/token/invocation/schema.go +++ b/token/invocation/schema.go @@ -10,6 +10,7 @@ import ( "github.com/ipld/go-ipld-prime/node/bindnode" "github.com/ipld/go-ipld-prime/schema" + "github.com/ucan-wg/go-ucan/pkg/args" "github.com/ucan-wg/go-ucan/pkg/meta" "github.com/ucan-wg/go-ucan/token/internal/envelope" ) @@ -56,7 +57,7 @@ type tokenPayloadModel struct { // The Command Cmd string // The Command's Arguments - Args stringAny + Args *args.Args // Delegations that prove the chain of authority Prf []cid.Cid