400 lines
11 KiB
Go
400 lines
11 KiB
Go
// 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 VarsigBytes 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]:https://github.com/ucan-wg/spec#envelope
|
|
// [TokenPayload]: https://github.com/ucan-wg/spec#envelope
|
|
// [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/ucan-wg/go-varsig"
|
|
|
|
"code.sonr.org/go/did-it"
|
|
"code.sonr.org/go/did-it/crypto"
|
|
)
|
|
|
|
const (
|
|
VarsigHeaderKey = "h"
|
|
UCANTagPrefix = "ucan/"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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, resolvOpts ...did.ResolutionOption) (T, error) {
|
|
node, err := ipld.Decode(b, decFn)
|
|
if err != nil {
|
|
return *new(T), err
|
|
}
|
|
|
|
return FromIPLD[T](node, resolvOpts...)
|
|
}
|
|
|
|
// DecodeReader is the same as Decode, but accept an io.Reader.
|
|
func DecodeReader[T Tokener](r io.Reader, decFn codec.Decoder, resolvOpts ...did.ResolutionOption) (T, error) {
|
|
node, err := ipld.DecodeStreaming(r, decFn)
|
|
if err != nil {
|
|
return *new(T), err
|
|
}
|
|
|
|
return FromIPLD[T](node, resolvOpts...)
|
|
}
|
|
|
|
// 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, resolvOpts ...did.ResolutionOption) (T, error) {
|
|
return Decode[T](b, dagcbor.Decode, resolvOpts...)
|
|
}
|
|
|
|
// FromDagCborReader is the same as FromDagCbor, but accept an io.Reader.
|
|
func FromDagCborReader[T Tokener](r io.Reader, resolvOpts ...did.ResolutionOption) (T, error) {
|
|
return DecodeReader[T](r, dagcbor.Decode, resolvOpts...)
|
|
}
|
|
|
|
// 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, resolvOpts ...did.ResolutionOption) (T, error) {
|
|
return Decode[T](b, dagjson.Decode, resolvOpts...)
|
|
}
|
|
|
|
// FromDagJsonReader is the same as FromDagJson, but accept an io.Reader.
|
|
func FromDagJsonReader[T Tokener](r io.Reader, resolvOpts ...did.ResolutionOption) (T, error) {
|
|
return DecodeReader[T](r, dagjson.Decode, resolvOpts...)
|
|
}
|
|
|
|
// 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, resolvOpts ...did.ResolutionOption) (T, error) {
|
|
zero := *new(T)
|
|
|
|
info, err := Inspect(node)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
|
|
if info.Tag != zero.Tag() {
|
|
return zero, errors.New("data doesn't match the expected type")
|
|
}
|
|
|
|
// This needs to be done before converting this node to its schema
|
|
// representation (afterwards, the field might be renamed, so it's safer
|
|
// to use the wire name).
|
|
issuerNode, err := info.tokenPayloadNode.LookupByString("iss")
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
|
|
// Replaces the datamodel.Node in tokenPayloadNode with a
|
|
// schema.TypedNode so that we can cast it to a *token.Token after
|
|
// unwrapping it.
|
|
nb := zero.Prototype().Representation().NewBuilder()
|
|
|
|
err = nb.AssignNode(info.tokenPayloadNode)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
|
|
tokenPayloadNode := nb.Build()
|
|
|
|
tokenPayload := bindnode.Unwrap(tokenPayloadNode)
|
|
if tokenPayload == nil {
|
|
return zero, errors.New("failed to Unwrap the TokenPayload")
|
|
}
|
|
|
|
tkn, ok := tokenPayload.(T)
|
|
if !ok {
|
|
return zero, 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 VarsigBytes and then verify the SigPayload.
|
|
issuer, err := issuerNode.AsString()
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
|
|
issuerDID, err := did.Parse(issuer)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
|
|
issuerDoc, err := issuerDID.Document(resolvOpts...)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
|
|
vsig, err := varsig.Decode(info.VarsigBytes)
|
|
if err != nil {
|
|
return zero, fmt.Errorf("failed to decode varsig: %w", err)
|
|
}
|
|
|
|
var data []byte
|
|
|
|
switch vsig.PayloadEncoding() {
|
|
case varsig.PayloadEncodingDAGCBOR:
|
|
// TODO: can we use the already serialized CBOR data here, instead of encoding again the payload?
|
|
data, err = ipld.Encode(info.sigPayloadNode, dagcbor.Encode)
|
|
case varsig.PayloadEncodingDAGJSON:
|
|
data, err = ipld.Encode(info.sigPayloadNode, dagjson.Encode)
|
|
default:
|
|
return zero, errors.New("unsupported payload encoding")
|
|
}
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
|
|
// TODO: use CapabilityDelegation() or CapabilityInvocation()
|
|
|
|
ok, _ = did.TryAllVerifyBytes(issuerDoc.CapabilityDelegation(), data, info.Signature, crypto.WithVarsig(vsig))
|
|
if !ok {
|
|
return zero, 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.PrivateKeySigningBytes, 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.PrivateKeySigningBytes, 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.PrivateKeySigningBytes, 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.PrivateKeySigningBytes, token Tokener) error {
|
|
return EncodeWriter(w, privKey, token, dagcbor.Encode)
|
|
}
|
|
|
|
// ToDagJson marshals the Tokener to the DAG-JSON format.
|
|
func ToDagJson(privKey crypto.PrivateKeySigningBytes, 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.PrivateKeySigningBytes, token Tokener) error {
|
|
return EncodeWriter(w, privKey, token, dagjson.Encode)
|
|
}
|
|
|
|
// ToIPLD wraps the Tokener in an IPLD datamodel.Node.
|
|
func ToIPLD(privKey crypto.PrivateKeySigningBytes, token Tokener) (datamodel.Node, error) {
|
|
tokenPayloadNode := bindnode.Wrap(token, token.Prototype().Type()).Representation()
|
|
|
|
opts := []crypto.SigningOption{crypto.WithPayloadEncoding(varsig.PayloadEncodingDAGCBOR)}
|
|
vsig := privKey.Varsig(opts...)
|
|
|
|
sigPayloadNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) {
|
|
qp.MapEntry(ma, VarsigHeaderKey, qp.Bytes(vsig.Encode()))
|
|
qp.MapEntry(ma, token.Tag(), qp.Node(tokenPayloadNode))
|
|
})
|
|
|
|
data, err := ipld.Encode(sigPayloadNode, dagcbor.Encode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
signature, err := privKey.SignToBytes(data, opts...)
|
|
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))
|
|
})
|
|
}
|
|
|
|
// FindTag inspects the given token IPLD representation and extract the token tag.
|
|
func FindTag(node datamodel.Node) (string, error) {
|
|
sigPayloadNode, err := node.LookupByIndex(1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if sigPayloadNode.Kind() != datamodel.Kind_Map {
|
|
return "", fmt.Errorf("unexpected type instead of map")
|
|
}
|
|
|
|
it := sigPayloadNode.MapIterator()
|
|
i := 0
|
|
|
|
for !it.Done() {
|
|
if i >= 2 {
|
|
return "", fmt.Errorf("expected two and only two fields in SigPayload")
|
|
}
|
|
i++
|
|
|
|
k, _, err := it.Next()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
key, err := k.AsString()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if strings.HasPrefix(key, UCANTagPrefix) {
|
|
return key, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("no token tag found")
|
|
}
|
|
|
|
type Info struct {
|
|
Tag string
|
|
Signature []byte
|
|
VarsigBytes []byte
|
|
sigPayloadNode datamodel.Node // private, we don't want to expose that
|
|
tokenPayloadNode datamodel.Node // private, we don't want to expose that
|
|
}
|
|
|
|
// Inspect inspects the given token IPLD representation and extract some envelope facts.
|
|
func Inspect(node datamodel.Node) (Info, error) {
|
|
var res Info
|
|
|
|
signatureNode, err := node.LookupByIndex(0)
|
|
if err != nil {
|
|
return Info{}, err
|
|
}
|
|
|
|
res.Signature, err = signatureNode.AsBytes()
|
|
if err != nil {
|
|
return Info{}, err
|
|
}
|
|
|
|
res.sigPayloadNode, err = node.LookupByIndex(1)
|
|
if err != nil {
|
|
return Info{}, err
|
|
}
|
|
|
|
if res.sigPayloadNode.Kind() != datamodel.Kind_Map {
|
|
return Info{}, fmt.Errorf("unexpected type instead of map")
|
|
}
|
|
|
|
it := res.sigPayloadNode.MapIterator()
|
|
foundVarsigHeader := false
|
|
foundTokenPayload := false
|
|
i := 0
|
|
|
|
for !it.Done() {
|
|
if i >= 2 {
|
|
return Info{}, fmt.Errorf("expected two and only two fields in SigPayload")
|
|
}
|
|
i++
|
|
|
|
k, v, err := it.Next()
|
|
if err != nil {
|
|
return Info{}, err
|
|
}
|
|
|
|
key, err := k.AsString()
|
|
if err != nil {
|
|
return Info{}, err
|
|
}
|
|
|
|
switch {
|
|
case key == VarsigHeaderKey:
|
|
foundVarsigHeader = true
|
|
res.VarsigBytes, err = v.AsBytes()
|
|
if err != nil {
|
|
return Info{}, err
|
|
}
|
|
case strings.HasPrefix(key, UCANTagPrefix):
|
|
foundTokenPayload = true
|
|
res.Tag = key
|
|
res.tokenPayloadNode = v
|
|
default:
|
|
return Info{}, fmt.Errorf("unexpected key type %q", key)
|
|
}
|
|
}
|
|
|
|
if i != 2 {
|
|
return Info{}, fmt.Errorf("expected two and only two fields in SigPayload: %d", i)
|
|
}
|
|
if !foundVarsigHeader {
|
|
return Info{}, errors.New("failed to find VarsigHeader field")
|
|
}
|
|
if !foundTokenPayload {
|
|
return Info{}, errors.New("failed to find TokenPayload field")
|
|
}
|
|
|
|
return res, nil
|
|
}
|