From d784c92c293ce5a67e14c919bdb5c8f8fa9a7244 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 24 Oct 2024 10:44:38 -0400 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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)