From a7037dbc470b1a06437d7f74d673d44a19921ee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Wed, 2 Oct 2024 10:53:30 +0200 Subject: [PATCH] token: add invocation partial stub --- token/invocation/invocation.go | 135 +++++++++++++++++ token/invocation/invocation.ipldsch | 23 +++ token/invocation/ipld.go | 226 ++++++++++++++++++++++++++++ token/invocation/schema.go | 77 ++++++++++ token/read.go | 3 + 5 files changed, 464 insertions(+) create mode 100644 token/invocation/invocation.go create mode 100644 token/invocation/invocation.ipldsch create mode 100644 token/invocation/ipld.go create mode 100644 token/invocation/schema.go diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go new file mode 100644 index 0000000..84a97a9 --- /dev/null +++ b/token/invocation/invocation.go @@ -0,0 +1,135 @@ +// Package delegation implements the UCAN [invocation] specification with +// an immutable Token type as well as methods to convert the Token to and +// from the [envelope]-enclosed, signed and DAG-CBOR-encoded form that +// should most commonly be used for transport and storage. +// +// [envelope]: https://github.com/ucan-wg/spec#envelope +// [invocation]: https://github.com/ucan-wg/invocation +package invocation + +import ( + "crypto/rand" + "errors" + "fmt" + "time" + + "github.com/ipfs/go-cid" + + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/pkg/meta" +) + +// Token is an immutable type that holds the fields of a UCAN invocation. +type Token struct { + // Issuer DID (invoker) + issuer did.DID + // Audience DID (receiver/executor) + audience did.DID + // Subject DID (subject being invoked) + subject did.DID + // The Command to invoke + command command.Command + // TODO: args + // TODO: prf + // A unique, random nonce + nonce []byte + // Arbitrary Metadata + meta *meta.Meta + // The timestamp at which the Invocation becomes invalid + expiration *time.Time + // The timestamp at which the Invocation was created + invokedAt *time.Time + // TODO: cause + // The CID of the Token when enclosed in an Envelope and encoded to DAG-CBOR + cid cid.Cid +} + +// Issuer returns the did.DID representing the Token's issuer. +func (t *Token) Issuer() did.DID { + return t.issuer +} + +// 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 +} + +// Meta returns the Token's metadata. +func (t *Token) Meta() *meta.Meta { + return t.meta +} + +// Expiration returns the time at which the Token expires. +func (t *Token) Expiration() *time.Time { + return t.expiration +} + +// CID returns the content identifier of the Token model when enclosed +// in an Envelope and encoded to DAG-CBOR. +// Returns cid.Undef if the token has not been serialized or deserialized yet. +func (t *Token) CID() cid.Cid { + return t.cid +} + +func (t *Token) validate() error { + var errs error + + requiredDID := func(id did.DID, fieldname string) { + if !id.Defined() { + errs = errors.Join(errs, fmt.Errorf(`a valid did is required for %s: %s`, fieldname, id.String())) + } + } + + requiredDID(t.issuer, "Issuer") + + // TODO + + if len(t.nonce) < 12 { + errs = errors.Join(errs, fmt.Errorf("token nonce too small")) + } + + return errs +} + +// tokenFromModel build a decoded view of the raw IPLD data. +// This function also serves as validation. +func tokenFromModel(m tokenPayloadModel) (*Token, error) { + var ( + tkn Token + ) + + // TODO + + return &tkn, nil +} + +// generateNonce creates a 12-byte random nonce. +// TODO: some crypto scheme require more, is that our case? +func generateNonce() ([]byte, error) { + res := make([]byte, 12) + _, err := rand.Read(res) + if err != nil { + return nil, err + } + return res, nil +} diff --git a/token/invocation/invocation.ipldsch b/token/invocation/invocation.ipldsch new file mode 100644 index 0000000..2acab27 --- /dev/null +++ b/token/invocation/invocation.ipldsch @@ -0,0 +1,23 @@ +type DID string + +# The Invocation Payload attaches sender, receiver, and provenance to the Task. +type Payload struct { + # Issuer DID (sender) + iss DID + # Audience DID (receiver) + aud DID + # Principal that the chain is about (the Subject) + sub optional DID + + # The Command to eventually invoke + cmd String + + # A unique, random nonce + nonce Bytes + + # Arbitrary Metadata + meta {String : Any} + + # The timestamp at which the Invocation becomes invalid + exp nullable Int +} diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go new file mode 100644 index 0000000..cf9ce10 --- /dev/null +++ b/token/invocation/ipld.go @@ -0,0 +1,226 @@ +package invocation + +import ( + "io" + + "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" + "github.com/ipld/go-ipld-prime/codec/dagjson" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/libp2p/go-libp2p/core/crypto" + + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/token/internal/envelope" +) + +// ToSealed wraps the invocation token in an envelope, generates the +// signature, encodes the result to DAG-CBOR and calculates the CID of +// the resulting binary data. +func (t *Token) ToSealed(privKey crypto.PrivKey) ([]byte, cid.Cid, error) { + data, err := t.ToDagCbor(privKey) + if err != nil { + return nil, cid.Undef, err + } + + id, err := envelope.CIDFromBytes(data) + if err != nil { + return nil, cid.Undef, err + } + + return data, id, nil +} + +// ToSealedWriter is the same as ToSealed but accepts an io.Writer. +func (t *Token) ToSealedWriter(w io.Writer, privKey crypto.PrivKey) (cid.Cid, error) { + cidWriter := envelope.NewCIDWriter(w) + + if err := t.ToDagCborWriter(cidWriter, privKey); err != nil { + return cid.Undef, err + } + + return cidWriter.CID() +} + +// FromSealed decodes the provided binary data from the DAG-CBOR format, +// verifies that the envelope's signature is correct based on the public +// key taken from the issuer (iss) field and calculates the CID of the +// incoming data. +func FromSealed(data []byte) (*Token, error) { + tkn, err := FromDagCbor(data) + if err != nil { + return nil, err + } + + id, err := envelope.CIDFromBytes(data) + if err != nil { + return nil, err + } + + tkn.cid = id + + return tkn, nil +} + +// FromSealedReader is the same as Unseal but accepts an io.Reader. +func FromSealedReader(r io.Reader) (*Token, error) { + cidReader := envelope.NewCIDReader(r) + + tkn, err := FromDagCborReader(cidReader) + if err != nil { + return nil, err + } + + id, err := cidReader.CID() + if err != nil { + return nil, err + } + + tkn.cid = id + + return tkn, nil +} + +// Encode marshals a Token to the format specified by the provided +// codec.Encoder. +func (t *Token) Encode(privKey crypto.PrivKey, encFn codec.Encoder) ([]byte, error) { + node, err := t.toIPLD(privKey) + if err != nil { + return nil, err + } + + return ipld.Encode(node, encFn) +} + +// EncodeWriter is the same as Encode, but accepts an io.Writer. +func (t *Token) EncodeWriter(w io.Writer, privKey crypto.PrivKey, encFn codec.Encoder) error { + node, err := t.toIPLD(privKey) + if err != nil { + return err + } + + return ipld.EncodeStreaming(w, node, encFn) +} + +// ToDagCbor marshals the Token to the DAG-CBOR format. +func (t *Token) ToDagCbor(privKey crypto.PrivKey) ([]byte, error) { + return t.Encode(privKey, dagcbor.Encode) +} + +// ToDagCborWriter is the same as ToDagCbor, but it accepts an io.Writer. +func (t *Token) ToDagCborWriter(w io.Writer, privKey crypto.PrivKey) error { + return t.EncodeWriter(w, privKey, dagcbor.Encode) +} + +// ToDagJson marshals the Token to the DAG-JSON format. +func (t *Token) ToDagJson(privKey crypto.PrivKey) ([]byte, error) { + return t.Encode(privKey, dagjson.Encode) +} + +// ToDagJsonWriter is the same as ToDagJson, but it accepts an io.Writer. +func (t *Token) ToDagJsonWriter(w io.Writer, privKey crypto.PrivKey) error { + return t.EncodeWriter(w, privKey, dagjson.Encode) +} + +// Decode unmarshals the input data using the format specified by the +// provided codec.Decoder into a Token. +// +// An error is returned if the conversion fails, or if the resulting +// Token is invalid. +func Decode(b []byte, decFn codec.Decoder) (*Token, error) { + node, err := ipld.Decode(b, decFn) + if err != nil { + return nil, err + } + return FromIPLD(node) +} + +// DecodeReader is the same as Decode, but accept an io.Reader. +func DecodeReader(r io.Reader, decFn codec.Decoder) (*Token, error) { + node, err := ipld.DecodeStreaming(r, decFn) + if err != nil { + return nil, err + } + return FromIPLD(node) +} + +// FromDagCbor unmarshals the input data into a Token. +// +// An error is returned if the conversion fails, or if the resulting +// Token is invalid. +func FromDagCbor(data []byte) (*Token, error) { + pay, err := envelope.FromDagCbor[*tokenPayloadModel](data) + if err != nil { + return nil, err + } + + tkn, err := tokenFromModel(*pay) + if err != nil { + return nil, err + } + + return tkn, err +} + +// FromDagCborReader is the same as FromDagCbor, but accept an io.Reader. +func FromDagCborReader(r io.Reader) (*Token, error) { + return DecodeReader(r, dagcbor.Decode) +} + +// FromDagJson unmarshals the input data into a Token. +// +// An error is returned if the conversion fails, or if the resulting +// Token is invalid. +func FromDagJson(data []byte) (*Token, error) { + return Decode(data, dagjson.Decode) +} + +// FromDagJsonReader is the same as FromDagJson, but accept an io.Reader. +func FromDagJsonReader(r io.Reader) (*Token, error) { + return DecodeReader(r, dagjson.Decode) +} + +// FromIPLD decode the given IPLD representation into a Token. +func FromIPLD(node datamodel.Node) (*Token, error) { + pay, err := envelope.FromIPLD[*tokenPayloadModel](node) + if err != nil { + return nil, err + } + + tkn, err := tokenFromModel(*pay) + if err != nil { + return nil, err + } + + return tkn, err +} + +func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { + var sub *string + + if t.subject != did.Undef { + s := t.subject.String() + sub = &s + } + + // TODO + + var exp *int64 + if t.expiration != nil { + u := t.expiration.Unix() + exp = &u + } + + model := &tokenPayloadModel{ + Iss: t.issuer.String(), + Aud: t.audience.String(), + Sub: sub, + Cmd: t.command.String(), + Nonce: t.nonce, + Meta: *t.meta, + Exp: exp, + } + + return envelope.ToIPLD(privKey, model) +} diff --git a/token/invocation/schema.go b/token/invocation/schema.go new file mode 100644 index 0000000..e6941a2 --- /dev/null +++ b/token/invocation/schema.go @@ -0,0 +1,77 @@ +package invocation + +import ( + _ "embed" + "fmt" + "sync" + + "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/meta" + "github.com/ucan-wg/go-ucan/token/internal/envelope" +) + +// [Tag] is the string used as a key within the SigPayload that identifies +// that the TokenPayload is an invocation. +// +// [Tag]: https://github.com/ucan-wg/invocation#type-tag +const Tag = "ucan/inv@1.0.0-rc.1" + +//go:embed invocation.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 payloadType() schema.Type { + return mustLoadSchema().TypeByName("Payload") +} + +var _ envelope.Tokener = (*tokenPayloadModel)(nil) + +// TODO +type tokenPayloadModel struct { + // Issuer DID (sender) + Iss string + // Audience DID (receiver) + Aud string + // Principal that the chain is about (the Subject) + // optional: can be nil + Sub *string + + // The Command to eventually invoke + Cmd string + + // 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 +} + +func (e *tokenPayloadModel) Prototype() schema.TypedPrototype { + return bindnode.Prototype((*tokenPayloadModel)(nil), payloadType()) +} + +func (*tokenPayloadModel) Tag() string { + return Tag +} diff --git a/token/read.go b/token/read.go index 2a9b151..513f2a3 100644 --- a/token/read.go +++ b/token/read.go @@ -12,6 +12,7 @@ import ( "github.com/ucan-wg/go-ucan/token/delegation" "github.com/ucan-wg/go-ucan/token/internal/envelope" + "github.com/ucan-wg/go-ucan/token/invocation" ) // Decode unmarshals the input data using the format specified by the @@ -74,6 +75,8 @@ func fromIPLD(node datamodel.Node) (Token, error) { switch tag { case delegation.Tag: return delegation.FromIPLD(node) + case invocation.Tag: + return invocation.FromIPLD(node) default: return nil, fmt.Errorf(`unknown tag "%s"`, tag) }