feat(envelope): wrap a Tokener in an Envelope

This commit is contained in:
Steve Moyer
2024-09-09 08:52:57 -04:00
parent 2205d5d4ce
commit 719837e3cd
5 changed files with 447 additions and 0 deletions

View File

@@ -0,0 +1,270 @@
package envelope
import (
"bytes"
"errors"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/ipld/go-ipld-prime/node/bindnode"
"github.com/ipld/go-ipld-prime/schema"
crypto "github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/crypto/pb"
"github.com/ucan-wg/go-ucan/v1/internal/token"
"github.com/ucan-wg/go-ucan/v1/internal/varsig"
)
// Tokener represents a type that can be wrapped in a UCAN Envelope.
type Tokener interface {
// Tag returns the a string that indicates which of the sub-
// specifications define the structure of the underlying type.
Tag() string
Prototype() schema.TypedPrototype
}
// [Envelope] is a signed enclosure for types implementing Tokener.
//
// [Envelope]: https://github.com/ucan-wg/spec#envelope
type Envelope[T Tokener] struct {
signature []byte
sigPayload *sigPayload[T]
}
// New creates an Envelope containing a VarsigHeader and Signature for
// the data resulting from wrapping the provided Tokener in and IPLD
// datamodel.Node and encoding it using DAG-CBOR
func New[T Tokener](privKey crypto.PrivKey, token T) (*Envelope[T], error) {
sigPayload, err := newSigPayload[T](privKey.Type(), token)
if err != nil {
return nil, err
}
cbor, err := sigPayload.cbor()
if err != nil {
return nil, err
}
signature, err := privKey.Sign(cbor)
if err != nil {
return nil, err
}
return &Envelope[T]{
signature: signature,
sigPayload: sigPayload,
}, nil
}
// Wrap is syntactic sugar for creating an Envelope and wrapping it as an
// IPLD datamodel.Node in a single operation.
//
// Since the Envelope itself isn't returned, us this method only when
// the IPLD datamodel.Node is used directly. If the Envelope is also
// required, use New followed by Envelope.Wrap to avoid the need to
// unwrap the newly created datamodel.Node.
func Wrap[T Tokener](privKey crypto.PrivKey, token T) (datamodel.Node, error) {
env, err := New[T](privKey, token)
if err != nil {
return nil, err
}
return env.Wrap()
}
func Unwrap[T Tokener](node datamodel.Node) (*Envelope[T], error) {
signatureNode, err := node.LookupByIndex(0)
if err != nil {
return nil, err
}
signature, err := signatureNode.AsBytes()
if err != nil {
return nil, err
}
sigPayloadNode, err := node.LookupByIndex(1)
if err != nil {
return nil, err
}
sigPayload, err := unwrapSigPayload[T](sigPayloadNode)
if err != nil {
return nil, err
}
return &Envelope[T]{
signature: signature,
sigPayload: sigPayload,
}, nil
}
// Signature is an accessor that returns the cryptographic signature
// that was created when the Envelope was created or unwrapped.
func (e *Envelope[T]) Signature() []byte {
return e.signature
}
func (e *Envelope[T]) Token() T {
return e.sigPayload.tokenPayload
}
// VarsigHeader is an accessor that returns the [VarsigHeader] from the
// underlying [SigPayload] from the [Envelope].
//
// [Envelope]: https://github.com/ucan-wg/spec#envelope
// [SigPayload]: https://github.com/ucan-wg/spec#envelope
// [VarsigHeader]: https://github.com/ucan-wg/spec#envelope
func (e *Envelope[T]) VarsigHeader() []byte {
return e.sigPayload.varsigHeader
}
// Verify checks that the [Envelope]'s signature is correct for the
// data created by encoding the SigPayload as DAG-CBOR and the public
// key passed as the only argument.
//
// Note that for Delegation and Invocation Tokeners, the public key
// is retrieved from the DID's method specific identifier.
//
// [Envelope]: https://github.com/ucan-wg/spec#envelope
func (e *Envelope[T]) Verify(pubKey crypto.PubKey) (bool, error) {
cbor, err := e.sigPayload.cbor()
if err != nil {
return false, err
}
return pubKey.Verify(cbor, e.signature)
}
// Wrap encodes the Envelope as an IPLD datamodel.Node.
func (e *Envelope[T]) Wrap() (datamodel.Node, error) {
sn := bindnode.Wrap(&e.signature, nil)
spn, err := e.sigPayload.wrap()
if err != nil {
return nil, err
}
np := basicnode.Prototype.Any
lb := np.NewBuilder()
la, err := lb.BeginList(2)
if err != nil {
return nil, err
}
if err = la.AssembleValue().AssignNode(sn); err != nil {
return nil, err
}
if err := la.AssembleValue().AssignNode(spn); err != nil {
return nil, err
}
if err := la.Finish(); err != nil {
return nil, err
}
return lb.Build(), nil
}
//
// The types below are strictly to make it easier to Wrap and Unwrap the
// Envelope with an IPLD datamodel.Node. The Envelope itself provides
// accessors to the internals of these types.
//
type sigPayload[T Tokener] struct {
varsigHeader []byte
tokenPayload T
}
func newSigPayload[T Tokener](keyType pb.KeyType, token T) (*sigPayload[T], error) {
varsigHeader, err := varsig.Encode(keyType)
if err != nil {
return nil, err
}
return &sigPayload[T]{
varsigHeader: varsigHeader,
tokenPayload: token,
}, nil
}
func unwrapSigPayload[T Tokener](node datamodel.Node) (*sigPayload[T], error) {
tokenPayloadNode, err := node.LookupByString((*new(T)).Tag())
if err != nil {
return nil, err
}
tokenPayload := bindnode.Unwrap(tokenPayloadNode)
if tokenPayload == nil {
return nil, errors.New("unexpected type") // TODO
}
token, ok := tokenPayload.(T)
if !ok {
return nil, errors.New("unexpected type") // TODO
}
headerNode, err := node.LookupByString("h")
if err != nil {
return nil, err
}
header, err := headerNode.AsBytes()
if err != nil {
return nil, err
}
return &sigPayload[T]{
varsigHeader: header,
tokenPayload: token,
}, nil // TODO
}
func (sp *sigPayload[T]) cbor() ([]byte, error) {
node, err := sp.wrap()
if err != nil {
return nil, err
}
buf := &bytes.Buffer{}
if err = dagcbor.Encode(node, buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (sp *sigPayload[T]) wrap() (datamodel.Node, error) {
tpn := bindnode.Wrap(sp.tokenPayload, sp.tokenPayload.Prototype().Type(), token.BindnodeOptions()...)
np := basicnode.Prototype.Any
mb := np.NewBuilder()
ma, err := mb.BeginMap(2)
if err != nil {
return nil, err
}
ha, err := ma.AssembleEntry("h")
if err != nil {
return nil, err
}
ha.AssignBytes(sp.varsigHeader)
ta, err := ma.AssembleEntry(sp.tokenPayload.Tag())
if err != nil {
return nil, err
}
ta.AssignNode(tpn)
if err := ma.Finish(); err != nil {
return nil, err
}
return mb.Build(), nil
}

View File

@@ -0,0 +1,170 @@
package envelope_test
import (
"bytes"
"crypto/rand"
"crypto/rsa"
_ "embed"
"encoding/base64"
"testing"
"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/node/bindnode"
"github.com/ipld/go-ipld-prime/schema"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/v1/did"
"github.com/ucan-wg/go-ucan/v1/internal/envelope"
"github.com/ucan-wg/go-ucan/v1/internal/token"
"gotest.tools/v3/golden"
)
const (
exampleSignature = "a5BocvMSlifrDzWN7MQpDZ4cEciwe+b9twdQ7d5EZ/LlW3w1VIjk34ci8LqmzMCMwqJsoBqevArUMNS86RrDOLZEl+71+nSf1GJ9fK/E2o7ONSPTQt1wALH1xhJ4S/h5o8v0sWP/PWBvolSfMpro9lN1xCi9zC4iuFmizqdjOd3Ba3txHD5DGAculWBiob3N1mjkXZPbQYEQteCoLwSNDCmmHCE7VpRUkoi832N7UVHlu1FFucENB31qBWZQ+JTj8/oV56Do+LbhrDDiabNkTxulwQ7u+hdKA30vA6FWaA6QW+UE2/mCEKM5wvVAohLPZsapGXP6LoEcbBM3O758dw"
exampleTag = "ucan/example@v1.0.0-rc.1"
exampleVarsigHeader = "NIUkEoACcQ"
invalidSignature = "a5BocvMSlifrDzWN7MQpDZ4cEciwe+b9twdQ7d5EZ/LlW3w1VIjk34ci8LqmzMCMwqJsoBqevArUMNS86RrDOLZEl+71+nSf1GJ9fK/E2o7ONSPTQt1wALH1xhJ4S/h5o8v0sWP/PWBvolSfMpro9lN1xCi9zC4iuFmizqdjOd3Ba3txHD5DGAculWBiob3N1mjkXZPbQYEQteCoLwSNDCmmHCE7VpRUkoi832N7UVHlu1FFucENB31qBWZQ+JTj8/oV56Do+LbhrDDiabNkTxulwQ7u+hdKA30vA6FWaA6QW+UE2/mCEKM5wvVAohLPZsapGXP6LoEcbBM3O758dx"
priCfg = "CAASqAkwggSkAgEAAoIBAQDq39Aou82MEteoEz+iKpu7zwJc0dZfomAfB4Zpnl8+WhUOhZyveHDD9lr/UCc/fcN5ufeyZxutDRvIXcmUGG5DNTVRZ10ywT/wN8KO+x/hZ5QIxBAsCFukcyHbAPseLYpAK0J0HNnQhtF6cQcQkuThCnZH/Ofj42d7snuztbBUxwjButvHYHiWwolcJUeb99HCpGwtJYjkp004roFBqjkLayP4AWHrnW4mtCY0rw86gRCT60N1XBZ9zXKw+LJeuQg3RUgZqBL6hvVIs1LAY5ie0LSXVkdjg9bmV7j5SKJBgk9ABoKvt35/KSWA5HW/6g3y/UITCD2DQDrTFv8xzDIvAgMBAAECggEAFoGr6LtWTv3fPHPbvSZoFe8YQty4tiFRJKgL8UMDzW3EZsfW49metKh+v8hmemcKvDddzPKkbEi9SM3z6wUMS9Rlb4+AFsT94370Xc8ilu7d+JkRE6cZYQDHVb0aUyH6BXwfuhCprpm8qQb7rlLlK8tc2jkZ33SDDg9kWywl4XmiDabKm0fOJd68KGuO5FNpCfpipqG3ok/FYuKlSqpCz+7QH2p3z2eVGTa/uIbDMNxkFBoIuhEJT43eDR2elPOrSL9+AYgBPVrzcJoRRxZFVvDDQ+RIwI/A62DvZ3IpFEyzEk2VZwWpLWYKnAUElcSegxx22K7S4BAaWjL86I6cAQKBgQDr8Y1NLYKYffHJAkmWE/ssGcih5C06gOo9WInBU8ZroY4LAhzKLstS0yKsM0OtRSFhZsmCdR3M/IHO94c3KsXu+KtA7r9AG+58LMpmob1mvyGsXIowHFMAd95s3FDd/HOE10tMyrIE1c7eWLfII+s6yGo8MS0WHXOSFBlioukGbwKBgQD+1v3vC+iUNP0FMhVxnhGgONyUb0X+AEw9GOLeCpsugZqRXnharYSiTjGPjwPaT2YBVkyaraoX6VwK+ys3RCngUg4s9IeHUYsR6Xa0oheAlME0ZDtuzi5+lDo+Zpsg8vepgx6v//bKvUcJb+9YDcKlMfQgFnSb3bAwUN9Ru79wQQKBgD7Z/9wZTXq1whzbwSJ7fCNJUwrdL7cv9DYXScr4OBkf1ijUjTrGsF8F42yf011q1vONYAyiiie69BFgGuL1P/jiwSvw7X10c1kczWX9m+is7ZlupVkfknTDebriDaC0yUkP2P1B2Z40HoFYfMyR1O25yaLzLqF/gvPc6s49u3l9AoGBAOZ9d2EdKTfbEToAyYpgyFpc84zBc9G/XTUpbBAeEasnh7CRfFOvezX9eS/5zydGBuGQt2pzRlOoOhqof7bVzPZZ4P5iEK6gXyNNQJMxxAYFBRYozeRzUXQlBuTnksljWAMWV8whu4o1VanAdv7yOymEm+Ply4QqJzAcBU/8erLBAoGBAJ6120n/Q8B7kNW/tZvJ4Xi0u/kSEodNQ9TpF44SB32bn/aWwfe7qFS1x+3omO7XWcx3FLUUPhITQjmQcbNa2yWY0UZoqnzkHhDmJeG2PUILEMCHSLCKQHS+PNJxqEWvwQe2mX/gJIjf14U9983hgLnOL7gH9sVYf9M9yA8NVlem"
)
//go:embed testdata/example.ipldsch
var exampleSchema []byte
var _ envelope.Tokener = (*Example)(nil)
type Example struct {
Hello string
Id *did.DID
}
func (e *Example) Tag() string {
return exampleTag
}
func (e *Example) Prototype() schema.TypedPrototype {
ts, err := ipld.LoadSchemaBytes(exampleSchema)
if err != nil {
panic(err)
}
return bindnode.Prototype((*Example)(nil), ts.TypeByName("Example"), token.DIDConverter())
}
func TestNew(t *testing.T) {
t.Parallel()
exampleSignature, err := base64.RawStdEncoding.DecodeString(exampleSignature)
require.NoError(t, err)
varsigHeader, err := base64.RawStdEncoding.DecodeString(exampleVarsigHeader)
require.NoError(t, err)
env := exampleEnvelope(t)
assert.Equal(t, exampleSignature, env.Signature())
assert.Equal(t, varsigHeader, env.VarsigHeader())
tkn := env.Token()
assert.IsType(t, (*Example)(nil), tkn)
assert.Equal(t, exampleTag, tkn.Tag())
assert.Equal(t, "world", tkn.Hello)
}
func TestWrap(t *testing.T) {
t.Parallel()
envNode, err := envelope.Wrap(rsaPrivateKey(t), &Example{
Hello: "world",
})
assert.NoError(t, err)
assert.NotNil(t, envNode)
buf := &bytes.Buffer{}
require.NoError(t, dagcbor.Encode(envNode, buf))
golden.AssertBytes(t, buf.Bytes(), "example.cbor")
// TODO: use golden file
}
func TestEnvelope_Wrap(t *testing.T) {
t.Parallel()
env := exampleEnvelope(t)
envNode, err := env.Wrap()
require.NoError(t, err)
buf := &bytes.Buffer{}
require.NoError(t, dagjson.Encode(envNode, buf))
golden.AssertBytes(t, buf.Bytes(), "example.json")
t.Log(buf.String())
env1, err := envelope.Unwrap[*Example](envNode)
require.NoError(t, err)
assert.NotNil(t, env1)
t.Log(string(env1.Signature()))
assert.Equal(t, env.Signature(), env1.Signature())
assert.Equal(t, env.VarsigHeader(), env1.VarsigHeader())
assert.Equal(t, env.Token(), env1.Token())
t.Log("Got here")
// t.Fail()
}
func TestEnvelope_Verify(t *testing.T) {
t.Parallel()
t.Run("true with correct public key", func(t *testing.T) {
t.Parallel()
env := exampleEnvelope(t)
ok, err := env.Verify(rsaPublicKey(t))
require.NoError(t, err)
require.True(t, ok)
})
t.Run("false with wrong public key", func(t *testing.T) {
t.Parallel()
_, pub, err := crypto.GenerateRSAKeyPair(2048, rand.Reader)
require.NoError(t, err)
env := exampleEnvelope(t)
ok, err := env.Verify(pub)
assert.ErrorIs(t, err, rsa.ErrVerification)
assert.False(t, ok)
})
}
func exampleEnvelope(t *testing.T) *envelope.Envelope[*Example] {
t.Helper()
env, err := envelope.New(rsaPrivateKey(t), &Example{
Hello: "world",
})
require.NoError(t, err)
return env
}
func rsaPrivateKey(t *testing.T) crypto.PrivKey {
t.Helper()
priEnc, err := crypto.ConfigDecodeKey(priCfg)
require.NoError(t, err)
pri, err := crypto.UnmarshalPrivateKey(priEnc)
require.NoError(t, err)
return pri
}
func rsaPublicKey(t *testing.T) crypto.PubKey {
t.Helper()
return rsaPrivateKey(t).GetPublic()
}

BIN
internal/envelope/testdata/example.cbor vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,6 @@
type DID string
type Example struct {
hello String
id DID
}

View File

@@ -0,0 +1 @@
[{"/":{"bytes":"a5BocvMSlifrDzWN7MQpDZ4cEciwe+b9twdQ7d5EZ/LlW3w1VIjk34ci8LqmzMCMwqJsoBqevArUMNS86RrDOLZEl+71+nSf1GJ9fK/E2o7ONSPTQt1wALH1xhJ4S/h5o8v0sWP/PWBvolSfMpro9lN1xCi9zC4iuFmizqdjOd3Ba3txHD5DGAculWBiob3N1mjkXZPbQYEQteCoLwSNDCmmHCE7VpRUkoi832N7UVHlu1FFucENB31qBWZQ+JTj8/oV56Do+LbhrDDiabNkTxulwQ7u+hdKA30vA6FWaA6QW+UE2/mCEKM5wvVAohLPZsapGXP6LoEcbBM3O758dw"}},{"h":{"/":{"bytes":"NIUkEoACcQ"}},"ucan/example@v1.0.0-rc.1":{"hello":"world","id":"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"}}]