From 70dc12d68e01be6eb91ca234fc17c2c54acaab16 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 18 Sep 2024 07:50:02 -0400 Subject: [PATCH 1/3] refactor(envelope): more tests/docs and functions not a type --- go.mod | 2 + go.sum | 12 +- internal/envelope/example_test.go | 136 +++++++++ internal/envelope/ipld.go | 330 +++++++++++++++++++++ internal/envelope/ipld_test.go | 100 +++++++ internal/envelope/testdata/example.dagcbor | 1 + internal/envelope/testdata/example.dagjson | 1 + internal/envelope/testdata/example.ipldsch | 6 + internal/varsig/varsig.go | 3 +- internal/varsig/varsig_test.go | 11 +- 10 files changed, 596 insertions(+), 6 deletions(-) create mode 100644 internal/envelope/example_test.go create mode 100644 internal/envelope/ipld.go create mode 100644 internal/envelope/ipld_test.go create mode 100644 internal/envelope/testdata/example.dagcbor create mode 100644 internal/envelope/testdata/example.dagjson create mode 100644 internal/envelope/testdata/example.ipldsch diff --git a/go.mod b/go.mod index 9a34565..b98bc2b 100644 --- a/go.mod +++ b/go.mod @@ -13,11 +13,13 @@ require ( github.com/multiformats/go-multicodec v0.9.0 github.com/multiformats/go-varint v0.0.7 github.com/stretchr/testify v1.9.0 + gotest.tools/v3 v3.5.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/google/go-cmp v0.5.9 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mr-tron/base58 v1.2.0 // indirect diff --git a/go.sum b/go.sum index 3d3968c..a692ce2 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -25,8 +27,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/libp2p/go-libp2p v0.36.2 h1:BbqRkDaGC3/5xfaJakLV/BrpjlAuYqSB0lRvtzL3B/U= -github.com/libp2p/go-libp2p v0.36.2/go.mod h1:XO3joasRE4Eup8yCTTP/+kX+g92mOgRaadk46LmPhHY= +github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= +github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= github.com/libp2p/go-libp2p v0.36.3 h1:NHz30+G7D8Y8YmznrVZZla0ofVANrvBl2c+oARfMeDQ= github.com/libp2p/go-libp2p v0.36.3/go.mod h1:4Y5vFyCUiJuluEPmpnKYf6WFx5ViKPUYs/ixe9ANFZ8= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= @@ -37,6 +39,8 @@ github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aG github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multiaddr v0.13.0 h1:BCBzs61E3AGHcYYTv8dqRH43ZfyrqM8RXVPT8t13tLQ= +github.com/multiformats/go-multiaddr v0.13.0/go.mod h1:sBXrNzucqkFJhvKOiwwLyqamGa/P5EIXNPLovyhQCII= github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= @@ -67,6 +71,8 @@ github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvS golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -83,5 +89,7 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= diff --git a/internal/envelope/example_test.go b/internal/envelope/example_test.go new file mode 100644 index 0000000..d5a7ba8 --- /dev/null +++ b/internal/envelope/example_test.go @@ -0,0 +1,136 @@ +package envelope_test + +import ( + _ "embed" + "encoding/base64" + "fmt" + "sync" + "testing" + + "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/fluent/qp" + "github.com/ipld/go-ipld-prime/node/basicnode" + "github.com/ipld/go-ipld-prime/node/bindnode" + "github.com/ipld/go-ipld-prime/schema" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/internal/envelope" + "gotest.tools/v3/golden" +) + +const ( + exampleDID = "did:key:z6MkpuK2Amsu1RqcLGgmHHQHhvmeXCCBVsM4XFSg2cCyg4Nh" + exampleGreeting = "world" + examplePrivKeyCfg = "CAESQP9v2uqECTuIi45dyg3znQvsryvf2IXmOF/6aws6aCehm0FVrj0zHR5RZSDxWNjcpcJqsGym3sjCungX9Zt5oA4=" + exampleSignatureStr = "PZV6A2aI7n+MlyADqcqmWhkuyNrgUCDz+qSLSnI9bpasOwOhKUTx95m5Nu5CO/INa1LqzHGioD9+PVf6qdtTBg" + exampleTag = "ucan/example@v1.0.0-rc.1" + exampleTypeName = "Example" + exampleVarsigHeaderStr = "NO0BcQ" + + invalidSignatureStr = "PZV6A2aI7n+MlyADqcqmWhkuyNrgUCDz+qSLSnI9bpasOwOhKUTx95m5Nu5CO/INa1LqzHGioD9+PVf6qdtTBK" + + exampleDAGCBORFilename = "example.dagcbor" + exampleDAGJSONFilename = "example.dagjson" +) + +//go:embed testdata/example.ipldsch +var schemaBytes []byte + +var ( + once sync.Once + ts *schema.TypeSystem + err error +) + +func mustLoadSchema() *schema.TypeSystem { + once.Do(func() { + ts, err = ipld.LoadSchemaBytes(schemaBytes) + }) + + if err != nil { + panic(fmt.Errorf("failed to load IPLD schema: %s", err)) + } + + return ts +} + +func exampleType() schema.Type { + return mustLoadSchema().TypeByName(exampleTypeName) +} + +var _ envelope.Tokener = (*Example)(nil) + +type Example struct { + Hello string + Issuer string +} + +func newExample(t *testing.T) *Example { + t.Helper() + + return &Example{ + Hello: exampleGreeting, + Issuer: exampleDID, + } +} + +func (e *Example) Prototype() schema.TypedPrototype { + return bindnode.Prototype(e, exampleType()) +} + +func (*Example) Tag() string { + return exampleTag +} + +func exampleGoldenNode(t *testing.T) datamodel.Node { + t.Helper() + + cbor := golden.Get(t, exampleDAGCBORFilename) + + node, err := ipld.Decode(cbor, dagcbor.Decode) + require.NoError(t, err) + + return node +} + +func examplePrivKey(t *testing.T) crypto.PrivKey { + t.Helper() + + privKeyEnc, err := crypto.ConfigDecodeKey(examplePrivKeyCfg) + require.NoError(t, err) + + privKey, err := crypto.UnmarshalPrivateKey(privKeyEnc) + require.NoError(t, err) + + return privKey +} + +func exampleSignature(t *testing.T) []byte { + t.Helper() + + sig, err := base64.RawStdEncoding.DecodeString(exampleSignatureStr) + require.NoError(t, err) + + return sig +} + +func invalidNodeFromGolden(t *testing.T) datamodel.Node { + t.Helper() + + invalidSig, err := base64.RawStdEncoding.DecodeString(invalidSignatureStr) + require.NoError(t, err) + + envelNode := exampleGoldenNode(t) + sigPayloadNode, err := envelNode.LookupByIndex(1) + require.NoError(t, err) + + node, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.Bytes(invalidSig)) + qp.ListEntry(la, qp.Node(sigPayloadNode)) + }) + require.NoError(t, err) + + return node +} diff --git a/internal/envelope/ipld.go b/internal/envelope/ipld.go new file mode 100644 index 0000000..3dffc3c --- /dev/null +++ b/internal/envelope/ipld.go @@ -0,0 +1,330 @@ +// Package envelope provides functions that convert between wire-format +// encoding of a [UCAN] token's [Envelope] and the Go type representing +// a verified [TokenPayload]. +// +// Encoding functions in this package require a private key as a +// parameter so the VarsigHeader can be set and so that a +// cryptographic signature can be generated. +// +// Decoding functions in this package likewise perform the signature +// verification using a public key extracted from the TokenPayload as +// described by requirement two below. +// +// Types that wish to be marshaled and unmarshaled from the using +// is package have two requirements. +// +// 1. The type must implement the Tokener interface. +// +// 2. The IPLD Representation of the type must include an "iss" +// field when the TokenPayload is extracted from the Envelope. +// This field must contain the string representation of a +// "did:key" so that a public key can be extracted from the +// +// [Envelope]: +// [TokenPayload]: +// [UCAN]: https://ucan.xyz +package envelope + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/codec" + "github.com/ipld/go-ipld-prime/codec/dagcbor" + "github.com/ipld/go-ipld-prime/codec/dagjson" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/fluent/qp" + "github.com/ipld/go-ipld-prime/node/basicnode" + "github.com/ipld/go-ipld-prime/node/bindnode" + "github.com/ipld/go-ipld-prime/schema" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/internal/varsig" +) + +type Tokener interface { + Prototype() schema.TypedPrototype + Tag() string +} + +// Decode unmarshals the input data using the format specified by the +// provided codec.Decoder into a Tokener. +// +// An error is returned if the conversion fails, or if the resulting +// Tokener is invalid. +func Decode[T Tokener](b []byte, decFn codec.Decoder) (T, error) { + node, err := ipld.Decode(b, decFn) + if err != nil { + return *new(T), err + } + + return FromIPLD[T](node) +} + +// DecodeReader is the same as Decode, but accept an io.Reader. +func DecodeReader[T Tokener](r io.Reader, decFn codec.Decoder) (T, error) { + node, err := ipld.DecodeStreaming(r, decFn) + if err != nil { + return *new(T), err + } + + return FromIPLD[T](node) +} + +// FromDagCbor unmarshals the input data into a Tokener. +// +// An error is returned if the conversion fails, or if the resulting +// Tokener is invalid. +func FromDagCbor[T Tokener](b []byte) (T, error) { + return Decode[T](b, dagcbor.Decode) +} + +// FromDagCborReader is the same as FromDagCbor, but accept an io.Reader. +func FromDagCborReader[T Tokener](r io.Reader) (T, error) { + return DecodeReader[T](r, dagcbor.Decode) +} + +// FromDagJson unmarshals the input data into a Tokener. +// +// An error is returned if the conversion fails, or if the resulting +// Tokener is invalid. +func FromDagJson[T Tokener](b []byte) (T, error) { + return Decode[T](b, dagjson.Decode) +} + +// FromDagJsonReader is the same as FromDagJson, but accept an io.Reader. +func FromDagJsonReader[T Tokener](r io.Reader) (T, error) { + return DecodeReader[T](r, dagjson.Decode) +} + +// FromIPLD unwraps a Tokener from the provided IPLD datamodel.Node. +// +// An error is returned if the conversion fails, or if the resulting +// Tokener is invalid. +func FromIPLD[T Tokener](node datamodel.Node) (T, error) { + undef := *new(T) + + signatureNode, err := node.LookupByIndex(0) + if err != nil { + return undef, err + } + + signature, err := signatureNode.AsBytes() + if err != nil { + return undef, err + } + + sigPayloadNode, err := node.LookupByIndex(1) + if err != nil { + return undef, err + } + + // Normally we could look up the VarsigHeader and TokenPayload using + // node.LookupByString() - this works for the "h" key used for the + // VarsigHeader but not for the TokenPayload's key (tag) as all we + // know is that it starts with "ucan/" and as explained below, must + // decode to a schema.TypedNode for the representation provided by the + // token.Prototype(). + // vvv + mi := sigPayloadNode.MapIterator() + if mi == nil { + return undef, fmt.Errorf("the SigPayload node is not a map: %s", sigPayloadNode.Kind().String()) + } + + var ( + varsigHeaderNode datamodel.Node + tokenPayloadNode datamodel.Node + tag string + ) + + keyCount := 0 + + for !mi.Done() { + k, v, err := mi.Next() + if err != nil { + return undef, err + } + + kStr, err := k.AsString() + if err != nil { + return undef, fmt.Errorf("the SigPayload keys are not strings: %w", err) + } + + keyCount++ + + if kStr == "h" { + varsigHeaderNode = v + + continue + } + + if strings.HasPrefix(kStr, "ucan/") { + tokenPayloadNode = v + tag = kStr + } + } + + if keyCount != 2 { + return undef, fmt.Errorf("the SigPayload map should have exactly two keys: %d", keyCount) + } + + if undef.Tag() != tag { + return undef, fmt.Errorf("the TokenPayload tag doesn't match the Tokener tag: expected %s, got %s", undef.Tag(), tag) + } + + // This needs to be done before converting this node to it's schema + // representation (afterwards, the field might be renamed os it's safer + // to use the wire name). + issuerNode, err := tokenPayloadNode.LookupByString("iss") + if err != nil { + return undef, err + } + + // ^^^ + + // Replaces the datamodel.Node in tokenPayloadNode with a + // schema.TypedNode so that we can cast it to a *token.Token after + // unwrapping it. + // vvv + nb := undef.Prototype().Representation().NewBuilder() + + err = nb.AssignNode(tokenPayloadNode) + if err != nil { + return undef, err + } + + tokenPayloadNode = nb.Build() + // ^^^ + + tokenPayload := bindnode.Unwrap(tokenPayloadNode) + if tokenPayload == nil { + return undef, errors.New("failed to Unwrap the TokenPayload") + } + + tkn, ok := tokenPayload.(T) + if !ok { + return undef, errors.New("failed to assert the TokenPayload type as *token.Token") + } + + // Check that the issuer's DID contains a public key with a type that + // matches the VarsigHeader and then verify the SigPayload. + // vvv + issuer, err := issuerNode.AsString() + if err != nil { + return undef, err + } + + issuerDID, err := did.Parse(issuer) + if err != nil { + return undef, err + } + + issuerPubKey, err := issuerDID.PubKey() + if err != nil { + return undef, err + } + + issuerVarsigHeader, err := varsig.Encode(issuerPubKey.Type()) + if err != nil { + return undef, err + } + + varsigHeader, err := varsigHeaderNode.AsBytes() + if err != nil { + return undef, err + } + + if string(varsigHeader) != string(issuerVarsigHeader) { + return undef, errors.New("the VarsigHeader key type doesn't match the issuer's key type") + } + + data, err := ipld.Encode(sigPayloadNode, dagcbor.Encode) + if err != nil { + return undef, err + } + + ok, err = issuerPubKey.Verify(data, signature) + if err != nil || !ok { + return undef, errors.New("failed to verify the token's signature") + } + // ^^^ + + return tkn, nil +} + +// Encode marshals a Tokener to the format specified by the provided +// codec.Encoder. +func Encode(privKey crypto.PrivKey, token Tokener, encFn codec.Encoder) ([]byte, error) { + node, err := ToIPLD(privKey, token) + if err != nil { + return nil, err + } + + return ipld.Encode(node, encFn) +} + +// EncodeWriter is the same as Encode but outputs to an io.Writer instead +// of encoding into a []byte. +func EncodeWriter(w io.Writer, privKey crypto.PrivKey, token Tokener, encFn codec.Encoder) error { + node, err := ToIPLD(privKey, token) + if err != nil { + return err + } + + return ipld.EncodeStreaming(w, node, encFn) +} + +// ToDagCbor marshals the Tokener to the DAG-CBOR format. +func ToDagCbor(privKey crypto.PrivKey, token Tokener) ([]byte, error) { + return Encode(privKey, token, dagcbor.Encode) +} + +// ToDagCborWriter is the same as ToDagCbor but outputs to an io.Writer +// instead of encoding into a []byte. +func ToDagCborWriter(w io.Writer, privKey crypto.PrivKey, token Tokener) error { + return EncodeWriter(w, privKey, token, dagcbor.Encode) +} + +// ToDagJson marshals the Tokener to the DAG-JSON format. +func ToDagJson(privKey crypto.PrivKey, token Tokener) ([]byte, error) { + return Encode(privKey, token, dagjson.Encode) +} + +// ToDagJsonWriter is the same as ToDagJson but outputs to an io.Writer +// instead of encoding into a []byte. +func ToDagJsonWriter(w io.Writer, privKey crypto.PrivKey, token Tokener) error { + return EncodeWriter(w, privKey, token, dagjson.Encode) +} + +// ToIPLD wraps the Tokener in an IPLD datamodel.Node. +func ToIPLD(privKey crypto.PrivKey, token Tokener) (datamodel.Node, error) { + tokenPayloadNode := bindnode.Wrap(token, token.Prototype().Type()).Representation() + + varsigHeader, err := varsig.Encode(privKey.Type()) + if err != nil { + return nil, err + } + + sigPayloadNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "h", qp.Bytes(varsigHeader)) + qp.MapEntry(ma, token.Tag(), qp.Node(tokenPayloadNode)) + }) + + data, err := ipld.Encode(sigPayloadNode, dagcbor.Encode) + if err != nil { + return nil, err + } + + signature, err := privKey.Sign(data) + if err != nil { + return nil, err + } + + return qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.Bytes(signature)) + qp.ListEntry(la, qp.Node(sigPayloadNode)) + }) +} diff --git a/internal/envelope/ipld_test.go b/internal/envelope/ipld_test.go new file mode 100644 index 0000000..4633ba2 --- /dev/null +++ b/internal/envelope/ipld_test.go @@ -0,0 +1,100 @@ +package envelope_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/internal/envelope" + "gotest.tools/v3/golden" +) + +func TestDecode(t *testing.T) { + t.Parallel() + + t.Run("via FromDagCbor", func(t *testing.T) { + t.Parallel() + + data := golden.Get(t, "example.dagcbor") + + tkn, err := envelope.FromDagCbor[*Example](data) + require.NoError(t, err) + assert.Equal(t, exampleGreeting, tkn.Hello) + assert.Equal(t, exampleDID, tkn.Issuer) + }) + + t.Run("via FromDagJson", func(t *testing.T) { + t.Parallel() + + data := golden.Get(t, "example.dagjson") + + tkn, err := envelope.FromDagJson[*Example](data) + require.NoError(t, err) + assert.Equal(t, exampleGreeting, tkn.Hello) + assert.Equal(t, exampleDID, tkn.Issuer) + }) +} + +func TestEncode(t *testing.T) { + t.Parallel() + + t.Run("via ToDagCbor", func(t *testing.T) { + t.Parallel() + + data, err := envelope.ToDagCbor(examplePrivKey(t), newExample(t)) + require.NoError(t, err) + golden.AssertBytes(t, data, exampleDAGCBORFilename) + }) + + t.Run("via ToDagJson", func(t *testing.T) { + t.Parallel() + + data, err := envelope.ToDagJson(examplePrivKey(t), newExample(t)) + require.NoError(t, err) + golden.Assert(t, string(data), exampleDAGJSONFilename) + }) +} + +func TestRoundtrip(t *testing.T) { + t.Parallel() + + t.Run("via FromDagCborReader/ToDagCborWriter", func(t *testing.T) { + t.Parallel() + + data := golden.Get(t, exampleDAGCBORFilename) + + tkn, err := envelope.FromDagCborReader[*Example](bytes.NewReader(data)) + require.NoError(t, err) + assert.Equal(t, exampleGreeting, tkn.Hello) + assert.Equal(t, exampleDID, tkn.Issuer) + + w := &bytes.Buffer{} + require.NoError(t, envelope.ToDagCborWriter(w, examplePrivKey(t), newExample(t))) + assert.Equal(t, data, w.Bytes()) + }) + + t.Run("via FromDagCbor/ToDagCbor", func(t *testing.T) { + t.Parallel() + + dataIn := golden.Get(t, exampleDAGCBORFilename) + + tkn, err := envelope.FromDagCbor[*Example](dataIn) + require.NoError(t, err) + assert.Equal(t, exampleGreeting, tkn.Hello) + assert.Equal(t, exampleDID, tkn.Issuer) + + dataOut, err := envelope.ToDagCbor(examplePrivKey(t), newExample(t)) + require.NoError(t, err) + assert.Equal(t, dataIn, dataOut) + }) +} + +func TestFromIPLD_with_invalid_signature(t *testing.T) { + t.Parallel() + + node := invalidNodeFromGolden(t) + tkn, err := envelope.FromIPLD[*Example](node) + assert.Nil(t, tkn) + require.EqualError(t, err, "failed to verify the token's signature") +} diff --git a/internal/envelope/testdata/example.dagcbor b/internal/envelope/testdata/example.dagcbor new file mode 100644 index 0000000..d18e26e --- /dev/null +++ b/internal/envelope/testdata/example.dagcbor @@ -0,0 +1 @@ +‚X@|úŸÀ½â–ðõ+ÁŠ­!µ.®ÿhéÍúGO- ü¬”jÉsyÖsY¨quëiþ“ä°¬Íuý#ò¼’ç˜ c¢ahD4íqxucan/example@v1.0.0-rc.1¢cissx8did:key:z6MkpuK2Amsu1RqcLGgmHHQHhvmeXCCBVsM4XFSg2cCyg4Nhehelloeworld \ No newline at end of file diff --git a/internal/envelope/testdata/example.dagjson b/internal/envelope/testdata/example.dagjson new file mode 100644 index 0000000..3db25a5 --- /dev/null +++ b/internal/envelope/testdata/example.dagjson @@ -0,0 +1 @@ +[{"/":{"bytes":"fPqfwL3iFpbw9SvBiq0DIbUurv9o6c36R08tC/yslGrJcwV51ghzWahxdetpEf6T5LCszXX9I/K8khvnmAxjAg"}},{"h":{"/":{"bytes":"NO0BcQ"}},"ucan/example@v1.0.0-rc.1":{"hello":"world","iss":"did:key:z6MkpuK2Amsu1RqcLGgmHHQHhvmeXCCBVsM4XFSg2cCyg4Nh"}}] \ No newline at end of file diff --git a/internal/envelope/testdata/example.ipldsch b/internal/envelope/testdata/example.ipldsch new file mode 100644 index 0000000..92ad02c --- /dev/null +++ b/internal/envelope/testdata/example.ipldsch @@ -0,0 +1,6 @@ +type DID string + +type Example struct { + hello String + issuer DID (rename "iss") +} diff --git a/internal/varsig/varsig.go b/internal/varsig/varsig.go index 03acce7..4645a90 100644 --- a/internal/varsig/varsig.go +++ b/internal/varsig/varsig.go @@ -24,7 +24,6 @@ package varsig import ( - "encoding/base64" "encoding/binary" "errors" "fmt" @@ -130,5 +129,5 @@ func header(vals ...multicodec.Code) string { buf = binary.AppendUvarint(buf, uint64(val)) } - return base64.RawStdEncoding.EncodeToString(buf) + return string(buf) } diff --git a/internal/varsig/varsig_test.go b/internal/varsig/varsig_test.go index eb13ed1..56c8bb5 100644 --- a/internal/varsig/varsig_test.go +++ b/internal/varsig/varsig_test.go @@ -21,7 +21,14 @@ func TestDecode(t *testing.T) { } func ExampleDecode() { - keyType, _ := varsig.Decode([]byte("NIUkEoACcQ")) + hdr, err := base64.RawStdEncoding.DecodeString("NIUkEoACcQ") + if err != nil { + fmt.Println(err.Error()) + + return + } + + keyType, _ := varsig.Decode(hdr) fmt.Println(keyType.String()) // Output: // RSA @@ -37,7 +44,7 @@ func TestEncode(t *testing.T) { func ExampleEncode() { header, _ := varsig.Encode(pb.KeyType_RSA) - fmt.Println(string(header)) + fmt.Println(base64.RawStdEncoding.EncodeToString(header)) // Output: // NIUkEoACcQ From c66dd5b2a4b12b51d62652d085079f2423aea061 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 18 Sep 2024 08:22:28 -0400 Subject: [PATCH 2/3] feat(envelope): decode functions also return the Envelope's CID --- internal/envelope/ipld.go | 77 +++++++++++++++++++--------------- internal/envelope/ipld_test.go | 10 ++--- 2 files changed, 49 insertions(+), 38 deletions(-) diff --git a/internal/envelope/ipld.go b/internal/envelope/ipld.go index 3dffc3c..96b687f 100644 --- a/internal/envelope/ipld.go +++ b/internal/envelope/ipld.go @@ -8,7 +8,8 @@ // // Decoding functions in this package likewise perform the signature // verification using a public key extracted from the TokenPayload as -// described by requirement two below. +// described by requirement two below. Additionally, the decode functions +// also return the CID for the verified Envelope. // // Types that wish to be marshaled and unmarshaled from the using // is package have two requirements. @@ -20,8 +21,8 @@ // This field must contain the string representation of a // "did:key" so that a public key can be extracted from the // -// [Envelope]: -// [TokenPayload]: +// [Envelope]:https://github.com/ucan-wg/spec#envelope +// [TokenPayload]: https://github.com/ucan-wg/spec#envelope // [UCAN]: https://ucan.xyz package envelope @@ -31,6 +32,7 @@ import ( "io" "strings" + "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" "github.com/ipld/go-ipld-prime/codec" "github.com/ipld/go-ipld-prime/codec/dagcbor" @@ -45,8 +47,17 @@ import ( "github.com/ucan-wg/go-ucan/internal/varsig" ) +// Tokener must be implemented by types that wish to be enclosed in a +// UCAN Envelope (presumbably one of the UCAN token types). type Tokener interface { + // Prototype provides the schema representation for an IPLD type so + // that the incoming datamodel.Kinds can be mapped to the appropriate + // schema.Kinds. Prototype() schema.TypedPrototype + + // Tag returns the expected key denoting the name of the IPLD node + // that should be processed as the token payload while decoding + // incoming bytes. Tag() string } @@ -55,20 +66,20 @@ type Tokener interface { // // An error is returned if the conversion fails, or if the resulting // Tokener is invalid. -func Decode[T Tokener](b []byte, decFn codec.Decoder) (T, error) { +func Decode[T Tokener](b []byte, decFn codec.Decoder) (T, cid.Cid, error) { node, err := ipld.Decode(b, decFn) if err != nil { - return *new(T), err + return *new(T), cid.Undef, err } return FromIPLD[T](node) } // DecodeReader is the same as Decode, but accept an io.Reader. -func DecodeReader[T Tokener](r io.Reader, decFn codec.Decoder) (T, error) { +func DecodeReader[T Tokener](r io.Reader, decFn codec.Decoder) (T, cid.Cid, error) { node, err := ipld.DecodeStreaming(r, decFn) if err != nil { - return *new(T), err + return *new(T), cid.Undef, err } return FromIPLD[T](node) @@ -78,12 +89,12 @@ func DecodeReader[T Tokener](r io.Reader, decFn codec.Decoder) (T, error) { // // An error is returned if the conversion fails, or if the resulting // Tokener is invalid. -func FromDagCbor[T Tokener](b []byte) (T, error) { +func FromDagCbor[T Tokener](b []byte) (T, cid.Cid, error) { return Decode[T](b, dagcbor.Decode) } // FromDagCborReader is the same as FromDagCbor, but accept an io.Reader. -func FromDagCborReader[T Tokener](r io.Reader) (T, error) { +func FromDagCborReader[T Tokener](r io.Reader) (T, cid.Cid, error) { return DecodeReader[T](r, dagcbor.Decode) } @@ -91,12 +102,12 @@ func FromDagCborReader[T Tokener](r io.Reader) (T, error) { // // An error is returned if the conversion fails, or if the resulting // Tokener is invalid. -func FromDagJson[T Tokener](b []byte) (T, error) { +func FromDagJson[T Tokener](b []byte) (T, cid.Cid, error) { return Decode[T](b, dagjson.Decode) } // FromDagJsonReader is the same as FromDagJson, but accept an io.Reader. -func FromDagJsonReader[T Tokener](r io.Reader) (T, error) { +func FromDagJsonReader[T Tokener](r io.Reader) (T, cid.Cid, error) { return DecodeReader[T](r, dagjson.Decode) } @@ -104,22 +115,22 @@ func FromDagJsonReader[T Tokener](r io.Reader) (T, error) { // // An error is returned if the conversion fails, or if the resulting // Tokener is invalid. -func FromIPLD[T Tokener](node datamodel.Node) (T, error) { +func FromIPLD[T Tokener](node datamodel.Node) (T, cid.Cid, error) { undef := *new(T) signatureNode, err := node.LookupByIndex(0) if err != nil { - return undef, err + return undef, cid.Undef, err } signature, err := signatureNode.AsBytes() if err != nil { - return undef, err + return undef, cid.Undef, err } sigPayloadNode, err := node.LookupByIndex(1) if err != nil { - return undef, err + return undef, cid.Undef, err } // Normally we could look up the VarsigHeader and TokenPayload using @@ -131,7 +142,7 @@ func FromIPLD[T Tokener](node datamodel.Node) (T, error) { // vvv mi := sigPayloadNode.MapIterator() if mi == nil { - return undef, fmt.Errorf("the SigPayload node is not a map: %s", sigPayloadNode.Kind().String()) + return undef, cid.Undef, fmt.Errorf("the SigPayload node is not a map: %s", sigPayloadNode.Kind().String()) } var ( @@ -145,12 +156,12 @@ func FromIPLD[T Tokener](node datamodel.Node) (T, error) { for !mi.Done() { k, v, err := mi.Next() if err != nil { - return undef, err + return undef, cid.Undef, err } kStr, err := k.AsString() if err != nil { - return undef, fmt.Errorf("the SigPayload keys are not strings: %w", err) + return undef, cid.Undef, fmt.Errorf("the SigPayload keys are not strings: %w", err) } keyCount++ @@ -168,11 +179,11 @@ func FromIPLD[T Tokener](node datamodel.Node) (T, error) { } if keyCount != 2 { - return undef, fmt.Errorf("the SigPayload map should have exactly two keys: %d", keyCount) + return undef, cid.Undef, fmt.Errorf("the SigPayload map should have exactly two keys: %d", keyCount) } if undef.Tag() != tag { - return undef, fmt.Errorf("the TokenPayload tag doesn't match the Tokener tag: expected %s, got %s", undef.Tag(), tag) + return undef, cid.Undef, fmt.Errorf("the TokenPayload tag doesn't match the Tokener tag: expected %s, got %s", undef.Tag(), tag) } // This needs to be done before converting this node to it's schema @@ -180,7 +191,7 @@ func FromIPLD[T Tokener](node datamodel.Node) (T, error) { // to use the wire name). issuerNode, err := tokenPayloadNode.LookupByString("iss") if err != nil { - return undef, err + return undef, cid.Undef, err } // ^^^ @@ -193,7 +204,7 @@ func FromIPLD[T Tokener](node datamodel.Node) (T, error) { err = nb.AssignNode(tokenPayloadNode) if err != nil { - return undef, err + return undef, cid.Undef, err } tokenPayloadNode = nb.Build() @@ -201,12 +212,12 @@ func FromIPLD[T Tokener](node datamodel.Node) (T, error) { tokenPayload := bindnode.Unwrap(tokenPayloadNode) if tokenPayload == nil { - return undef, errors.New("failed to Unwrap the TokenPayload") + return undef, cid.Undef, errors.New("failed to Unwrap the TokenPayload") } tkn, ok := tokenPayload.(T) if !ok { - return undef, errors.New("failed to assert the TokenPayload type as *token.Token") + return undef, cid.Undef, errors.New("failed to assert the TokenPayload type as *token.Token") } // Check that the issuer's DID contains a public key with a type that @@ -214,45 +225,45 @@ func FromIPLD[T Tokener](node datamodel.Node) (T, error) { // vvv issuer, err := issuerNode.AsString() if err != nil { - return undef, err + return undef, cid.Undef, err } issuerDID, err := did.Parse(issuer) if err != nil { - return undef, err + return undef, cid.Undef, err } issuerPubKey, err := issuerDID.PubKey() if err != nil { - return undef, err + return undef, cid.Undef, err } issuerVarsigHeader, err := varsig.Encode(issuerPubKey.Type()) if err != nil { - return undef, err + return undef, cid.Undef, err } varsigHeader, err := varsigHeaderNode.AsBytes() if err != nil { - return undef, err + return undef, cid.Undef, err } if string(varsigHeader) != string(issuerVarsigHeader) { - return undef, errors.New("the VarsigHeader key type doesn't match the issuer's key type") + return undef, cid.Undef, errors.New("the VarsigHeader key type doesn't match the issuer's key type") } data, err := ipld.Encode(sigPayloadNode, dagcbor.Encode) if err != nil { - return undef, err + return undef, cid.Undef, err } ok, err = issuerPubKey.Verify(data, signature) if err != nil || !ok { - return undef, errors.New("failed to verify the token's signature") + return undef, cid.Undef, errors.New("failed to verify the token's signature") } // ^^^ - return tkn, nil + return tkn, cid.Undef, nil } // Encode marshals a Tokener to the format specified by the provided diff --git a/internal/envelope/ipld_test.go b/internal/envelope/ipld_test.go index 4633ba2..132106d 100644 --- a/internal/envelope/ipld_test.go +++ b/internal/envelope/ipld_test.go @@ -18,7 +18,7 @@ func TestDecode(t *testing.T) { data := golden.Get(t, "example.dagcbor") - tkn, err := envelope.FromDagCbor[*Example](data) + tkn, _, err := envelope.FromDagCbor[*Example](data) require.NoError(t, err) assert.Equal(t, exampleGreeting, tkn.Hello) assert.Equal(t, exampleDID, tkn.Issuer) @@ -29,7 +29,7 @@ func TestDecode(t *testing.T) { data := golden.Get(t, "example.dagjson") - tkn, err := envelope.FromDagJson[*Example](data) + tkn, _, err := envelope.FromDagJson[*Example](data) require.NoError(t, err) assert.Equal(t, exampleGreeting, tkn.Hello) assert.Equal(t, exampleDID, tkn.Issuer) @@ -64,7 +64,7 @@ func TestRoundtrip(t *testing.T) { data := golden.Get(t, exampleDAGCBORFilename) - tkn, err := envelope.FromDagCborReader[*Example](bytes.NewReader(data)) + tkn, _, err := envelope.FromDagCborReader[*Example](bytes.NewReader(data)) require.NoError(t, err) assert.Equal(t, exampleGreeting, tkn.Hello) assert.Equal(t, exampleDID, tkn.Issuer) @@ -79,7 +79,7 @@ func TestRoundtrip(t *testing.T) { dataIn := golden.Get(t, exampleDAGCBORFilename) - tkn, err := envelope.FromDagCbor[*Example](dataIn) + tkn, _, err := envelope.FromDagCbor[*Example](dataIn) require.NoError(t, err) assert.Equal(t, exampleGreeting, tkn.Hello) assert.Equal(t, exampleDID, tkn.Issuer) @@ -94,7 +94,7 @@ func TestFromIPLD_with_invalid_signature(t *testing.T) { t.Parallel() node := invalidNodeFromGolden(t) - tkn, err := envelope.FromIPLD[*Example](node) + tkn, _, err := envelope.FromIPLD[*Example](node) assert.Nil(t, tkn) require.EqualError(t, err, "failed to verify the token's signature") } From 7107d6bc850be82fcfc7fe85455fb94667e73742 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 18 Sep 2024 12:12:44 -0400 Subject: [PATCH 3/3] fix(envelope): address PR comments RE IPLD iteration --- internal/envelope/ipld.go | 62 ++++++--------------------------------- 1 file changed, 9 insertions(+), 53 deletions(-) diff --git a/internal/envelope/ipld.go b/internal/envelope/ipld.go index 96b687f..d12b1be 100644 --- a/internal/envelope/ipld.go +++ b/internal/envelope/ipld.go @@ -28,9 +28,7 @@ package envelope import ( "errors" - "fmt" "io" - "strings" "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" @@ -47,6 +45,8 @@ import ( "github.com/ucan-wg/go-ucan/internal/varsig" ) +const varsigHeaderKey = "h" + // Tokener must be implemented by types that wish to be enclosed in a // UCAN Envelope (presumbably one of the UCAN token types). type Tokener interface { @@ -133,57 +133,14 @@ func FromIPLD[T Tokener](node datamodel.Node) (T, cid.Cid, error) { return undef, cid.Undef, err } - // Normally we could look up the VarsigHeader and TokenPayload using - // node.LookupByString() - this works for the "h" key used for the - // VarsigHeader but not for the TokenPayload's key (tag) as all we - // know is that it starts with "ucan/" and as explained below, must - // decode to a schema.TypedNode for the representation provided by the - // token.Prototype(). - // vvv - mi := sigPayloadNode.MapIterator() - if mi == nil { - return undef, cid.Undef, fmt.Errorf("the SigPayload node is not a map: %s", sigPayloadNode.Kind().String()) + varsigHeaderNode, err := sigPayloadNode.LookupByString(varsigHeaderKey) + if err != nil { + return undef, cid.Undef, err } - var ( - varsigHeaderNode datamodel.Node - tokenPayloadNode datamodel.Node - tag string - ) - - keyCount := 0 - - for !mi.Done() { - k, v, err := mi.Next() - if err != nil { - return undef, cid.Undef, err - } - - kStr, err := k.AsString() - if err != nil { - return undef, cid.Undef, fmt.Errorf("the SigPayload keys are not strings: %w", err) - } - - keyCount++ - - if kStr == "h" { - varsigHeaderNode = v - - continue - } - - if strings.HasPrefix(kStr, "ucan/") { - tokenPayloadNode = v - tag = kStr - } - } - - if keyCount != 2 { - return undef, cid.Undef, fmt.Errorf("the SigPayload map should have exactly two keys: %d", keyCount) - } - - if undef.Tag() != tag { - return undef, cid.Undef, fmt.Errorf("the TokenPayload tag doesn't match the Tokener tag: expected %s, got %s", undef.Tag(), tag) + tokenPayloadNode, err := sigPayloadNode.LookupByString(undef.Tag()) + if err != nil { + return undef, cid.Undef, err } // This needs to be done before converting this node to it's schema @@ -193,7 +150,6 @@ func FromIPLD[T Tokener](node datamodel.Node) (T, cid.Cid, error) { if err != nil { return undef, cid.Undef, err } - // ^^^ // Replaces the datamodel.Node in tokenPayloadNode with a @@ -320,7 +276,7 @@ func ToIPLD(privKey crypto.PrivKey, token Tokener) (datamodel.Node, error) { } sigPayloadNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, "h", qp.Bytes(varsigHeader)) + qp.MapEntry(ma, varsigHeaderKey, qp.Bytes(varsigHeader)) qp.MapEntry(ma, token.Tag(), qp.Node(tokenPayloadNode)) })