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 +} 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) diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 108037a..6a85512 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" @@ -901,3 +904,55 @@ 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) + + exec, stmt := pol.Match(argsNode) + assert.True(t, exec) + assert.Nil(t, stmt) + }) + + 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) + + exec, stmt := pol.Match(argsNode) + assert.False(t, exec) + assert.NotNil(t, stmt) + }) +} 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 new file mode 100644 index 0000000..7ecf349 --- /dev/null +++ b/token/invocation/examples_test.go @@ -0,0 +1,228 @@ +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.Println("failed to create setup:", err.Error()) + + return + } + + inv, err := invocation.New(iss, sub, cmd, prf, + 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), + invocation.WithoutInvokedAt()) + if err != nil { + fmt.Println("failed to create invocation:", err.Error()) + + return + } + + data, cid, err := inv.ToSealed(privKey) + if err != nil { + fmt.Println("failed to seal invocation:", err.Error()) + + return + } + + json, err := prettyDAGJSON(data) + if err != nil { + fmt.Println("failed to pretty DAG-JSON:", err.Error()) + + return + } + + fmt.Println("CID:", cid) + fmt.Println("Token (pretty DAG-JSON):") + fmt.Println(json) + + // Expected CID and DAG-JSON output: + // CID: 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)) + } + + // ***** 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, "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")) + })) + }) + 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)) + } + } + + // ***** 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")) + 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 b268481..f6eb07a 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -13,32 +13,89 @@ import ( "fmt" "time" + "github.com/ipfs/go-cid" + "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" ) // 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 *args.Args + // 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 +// or the WithoutInvokedAt Option to clear the Token's invokedAt field. +// +// 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) { + iat := time.Now() + + tkn := Token{ + issuer: iss, + subject: sub, + command: cmd, + arguments: args.New(), + proof: prf, + meta: meta.NewMeta(), + nonce: nil, + invokedAt: &iat, + } + + for _, opt := range opts { + if err := opt(&tkn); err != nil { + return nil, err + } + } + + 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 } // Issuer returns the did.DID representing the Token's issuer. @@ -46,28 +103,31 @@ 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 +// Arguments returns the arguments to be used when the command is +// invoked. +func (t *Token) Arguments() *args.Args { + return t.arguments +} + +// 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 } // Meta returns the Token's metadata. @@ -75,11 +135,28 @@ 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 } +// InvokedAt returns the time.Time at which the invocation token was +// created. +func (t *Token) InvokedAt() *time.Time { + return t.invokedAt +} + +// 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 +} + func (t *Token) validate() error { var errs error @@ -90,8 +167,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")) @@ -105,9 +181,42 @@ 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, fmt.Errorf("parse iss: %w", err) + } + + if tkn.subject, err = did.Parse(m.Sub); err != nil { + return nil, fmt.Errorf("parse subject: %w", 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, 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 + tkn.proof = m.Prf + tkn.meta = m.Meta + + tkn.expiration = parse.OptionalTimestamp(m.Exp) + tkn.invokedAt = parse.OptionalTimestamp(m.Iat) + + tkn.cause = m.Cause + + if err := tkn.validate(); err != nil { + return nil, err + } return &tkn, nil } 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..acaaf98 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -193,29 +193,42 @@ 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 + } + model := &tokenPayloadModel{ Iss: t.issuer.String(), - Aud: t.audience.String(), - Sub: sub, + Aud: aud, + Sub: t.subject.String(), Cmd: t.command.String(), + Args: t.arguments, + Prf: t.proof, + Meta: t.meta, Nonce: t.nonce, - Meta: *t.meta, Exp: exp, + Iat: iat, + 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 new file mode 100644 index 0000000..9754c16 --- /dev/null +++ b/token/invocation/ipld_test.go @@ -0,0 +1,38 @@ +package invocation_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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.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), + 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) +} diff --git a/token/invocation/options.go b/token/invocation/options.go new file mode 100644 index 0000000..9322cd7 --- /dev/null +++ b/token/invocation/options.go @@ -0,0 +1,125 @@ +package invocation + +import ( + "time" + + "github.com/ipfs/go-cid" + + "github.com/ucan-wg/go-ucan/did" +) + +// Option is a type that allows optional fields to be set during the +// creation of an invocation Token. +type Option func(*Token) error + +// WithArgument adds a key/value pair to the Token's Arguments field. +func WithArgument(key string, val any) Option { + return func(t *Token) error { + return t.arguments.Add(key, val) + } +} + +// 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 required field. If you truly want to create an invocation Token +// without a nonce, use the WithEmptyNonce Option which will set the +// nonce to an empty byte array. +func WithNonce(nonce []byte) Option { + return func(t *Token) error { + t.nonce = nonce + + return nil + } +} + +// WithEmptyNonce sets the Token's nonce to an empty byte slice as +// suggested by the UCAN spec for invocation tokens that represent +// idempotent operations. +func WithEmptyNonce() Option { + return func(t *Token) error { + t.nonce = []byte{} + + return nil + } +} + +// WithExpiration set's the Token's optional "expiration" field to the +// value of the provided time.Time. +func WithExpiration(exp time.Time) Option { + return func(t *Token) error { + exp = exp.Round(time.Second) + t.expiration = &exp + + return nil + } +} + +// WithExpirationIn set's the Token's optional "expiration" field to +// Now() plus the given duration. +func WithExpirationIn(after time.Duration) Option { + return WithExpiration(time.Now().Add(after)) +} + +// 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 + + return nil + } +} + +// WithInvokedAtIn sets the Token's invokedAt field to Now() plus the +// given duration. +func WithInvokedAtIn(after time.Duration) Option { + return WithInvokedAt(time.Now().Add(after)) +} + +// 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 { + t.cause = cause + + return nil + } +} diff --git a/token/invocation/schema.go b/token/invocation/schema.go index e6941a2..d51cf4f 100644 --- a/token/invocation/schema.go +++ b/token/invocation/schema.go @@ -5,10 +5,12 @@ import ( "fmt" "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/args" "github.com/ucan-wg/go-ucan/pkg/meta" "github.com/ucan-wg/go-ucan/token/internal/envelope" ) @@ -44,28 +46,34 @@ func payloadType() schema.Type { 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 *args.Args + // 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 { diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go new file mode 100644 index 0000000..77b1afb --- /dev/null +++ b/token/invocation/schema_test.go @@ -0,0 +1,91 @@ +package invocation_test + +import ( + "bytes" + "fmt" + "testing" + + "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" +) + +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