Merge branch 'v1' into v1-meta-encryption

Signed-off-by: Fabio Bozzo <fabio.bozzo@gmail.com>
This commit is contained in:
Fabio Bozzo
2024-11-12 15:07:19 +01:00
committed by GitHub
29 changed files with 2170 additions and 375 deletions

View File

@@ -10,7 +10,6 @@ package delegation
// TODO: change the "delegation" link above when the specification is merged
import (
"crypto/rand"
"errors"
"fmt"
"time"
@@ -21,6 +20,8 @@ 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/nonce"
"github.com/ucan-wg/go-ucan/token/internal/parse"
)
// Token is an immutable type that holds the fields of a UCAN delegation.
@@ -73,16 +74,12 @@ func New(privKey crypto.PrivKey, aud did.DID, cmd command.Command, pol policy.Po
}
if len(tkn.nonce) == 0 {
tkn.nonce, err = generateNonce()
tkn.nonce, err = nonce.Generate()
if err != nil {
return nil, err
}
}
if len(tkn.meta.Keys) < 1 {
tkn.meta = nil
}
if err := tkn.validate(); err != nil {
return nil, err
}
@@ -142,8 +139,8 @@ func (t *Token) Nonce() []byte {
}
// Meta returns the Token's metadata.
func (t *Token) Meta() *meta.Meta {
return t.meta
func (t *Token) Meta() meta.ReadOnly {
return t.meta.ReadOnly()
}
// NotBefore returns the time at which the Token becomes "active".
@@ -188,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)
}
@@ -219,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
@@ -235,14 +217,3 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) {
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
}

View File

@@ -103,8 +103,6 @@ func TestConstructors(t *testing.T) {
data, err := tkn.ToDagJson(privKey)
require.NoError(t, err)
t.Log(string(data))
golden.Assert(t, string(data), "new.dagjson")
})
@@ -122,8 +120,6 @@ func TestConstructors(t *testing.T) {
data, err := tkn.ToDagJson(privKey)
require.NoError(t, err)
t.Log(string(data))
golden.Assert(t, string(data), "root.dagjson")
})
}

View File

@@ -229,5 +229,10 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) {
Exp: exp,
}
// 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)
}

View File

@@ -3,7 +3,6 @@ package delegation_test
import (
"bytes"
_ "embed"
"fmt"
"testing"
"github.com/ipld/go-ipld-prime"
@@ -36,18 +35,13 @@ func TestSchemaRoundTrip(t *testing.T) {
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 := delegation.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(delegationJson), string(readJson))
})
@@ -65,7 +59,6 @@ func TestSchemaRoundTrip(t *testing.T) {
cborBytes := &bytes.Buffer{}
id, err := p1.ToSealedWriter(cborBytes, privKey)
t.Log(len(id.Bytes()), id.Bytes())
require.NoError(t, err)
assert.Equal(t, newCID, envelope.CIDToBase58BTC(id))

View File

@@ -17,7 +17,7 @@ type Token interface {
// Issuer returns the did.DID representing the Token's issuer.
Issuer() did.DID
// Meta returns the Token's metadata.
Meta() *meta.Meta
Meta() meta.ReadOnly
}
type Marshaller interface {

View File

@@ -0,0 +1,14 @@
package nonce
import "crypto/rand"
// Generate creates a 12-byte random nonce.
// TODO: some crypto scheme require more, is that our case?
func Generate() ([]byte, error) {
res := make([]byte, 12)
_, err := rand.Read(res)
if err != nil {
return nil, err
}
return res, nil
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -8,37 +8,94 @@
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/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/nonce"
"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 = nonce.Generate()
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,40 +103,60 @@ 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
}
// 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.
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
}
// 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
}
// 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,20 +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
}
// 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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)
}

125
token/invocation/options.go Normal file
View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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
}

1
token/invocation/testdata/new.dagjson vendored Normal file
View File

@@ -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"}}]