diff --git a/did/did.go b/did/did.go index bf92946..19042bf 100644 --- a/did/did.go +++ b/did/did.go @@ -78,7 +78,7 @@ func MustParse(str string) DID { // Defined tells if the DID is defined, not equal to Undef. func (d DID) Defined() bool { - return d.code == 0 || len(d.bytes) > 0 + return d.code != 0 || len(d.bytes) > 0 } // PubKey returns the public key encapsulated by the did:key. diff --git a/did/didtest/crypto.go b/did/didtest/crypto.go new file mode 100644 index 0000000..3d9de8d --- /dev/null +++ b/did/didtest/crypto.go @@ -0,0 +1,127 @@ +// Package didtest provides Personas that can be used for testing. Each +// Persona has a name, crypto.PrivKey and associated crypto.PubKey and +// did.DID. +package didtest + +import ( + "fmt" + "testing" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/require" + + "github.com/ucan-wg/go-ucan/did" +) + +const ( + alicePrivKeyB64 = "CAESQHdNJLBBiuc1AdwPHBkubB2KS1p0cv2JEF7m8tfwtrcm5ajaYPm+XmVCmtcHOF2lGDlmaiDA7emfwD3IrcyES0M=" + bobPrivKeyB64 = "CAESQHBz+AIop1g+9iBDj+ufUc/zm9/ry7c6kDFO8Wl/D0+H63V9hC6s9l4npf3pYEFCjBtlR0AMNWMoFQKSlYNKo20=" + carolPrivKeyB64 = "CAESQPrCgkcHnYFXDT9AlAydhPECBEivEuuVx9dJxLjVvDTmJIVNivfzg6H4mAiPfYS+5ryVVUZTHZBzvMuvvvG/Ks0=" + danPrivKeyB64 = "CAESQCgNhzofKhC+7hW6x+fNd7iMPtQHeEmKRhhlduf/I7/TeOEFYAEflbJ0sAhMeDJ/HQXaAvsWgHEbJ3ZLhP8q2B0=" + erinPrivKeyB64 = "CAESQKhCJo5UBpQcthko8DKMFsbdZ+qqQ5oc01CtLCqrE90dF2GfRlrMmot3WPHiHGCmEYi5ZMEHuiSI095e/6O4Bpw=" + frankPrivKeyB64 = "CAESQDlXPKsy3jHh7OWTWQqyZF95Ueac5DKo7xD0NOBE5F2BNr1ZVxRmJ2dBELbOt8KP9sOACcO9qlCB7uMA1UQc7sk=" +) + +// Persona is a generic participant used for cryptographic testing. +type Persona int + +// The provided Personas were selected from the first few generic +// participants listed in this [table]. +// +// [table]: https://en.wikipedia.org/wiki/Alice_and_Bob#Cryptographic_systems +const ( + PersonaAlice Persona = iota + PersonaBob + PersonaCarol + PersonaDan + PersonaErin + PersonaFrank +) + +var privKeys map[Persona]crypto.PrivKey + +func init() { + privKeys = make(map[Persona]crypto.PrivKey, 6) + for persona, privKeyCfg := range privKeyB64() { + privKeyMar, err := crypto.ConfigDecodeKey(privKeyCfg) + if err != nil { + return + } + + privKey, err := crypto.UnmarshalPrivateKey(privKeyMar) + if err != nil { + return + } + + privKeys[persona] = privKey + } +} + +// DID returns a did.DID based on the Persona's Ed25519 public key. +func (p Persona) DID() did.DID { + d, err := did.FromPrivKey(p.PrivKey()) + if err != nil { + panic(err) + } + return d +} + +// Name returns the username of the Persona. +func (p Persona) Name() string { + name, ok := map[Persona]string{ + PersonaAlice: "Alice", + PersonaBob: "Bob", + PersonaCarol: "Carol", + PersonaDan: "Dan", + PersonaErin: "Erin", + PersonaFrank: "Frank", + }[p] + if !ok { + panic(fmt.Sprintf("Unknown persona: %v", p)) + } + + return name +} + +// PrivKey returns the Ed25519 private key for the Persona. +func (p Persona) PrivKey() crypto.PrivKey { + return privKeys[p] +} + +// PubKey returns the Ed25519 public key for the Persona. +func (p Persona) PubKey() crypto.PubKey { + return p.PrivKey().GetPublic() +} + +// PubKeyConfig returns the marshaled and encoded Ed25519 public key +// for the Persona. +func (p Persona) PubKeyConfig(t *testing.T) string { + pubKeyMar, err := crypto.MarshalPublicKey(p.PrivKey().GetPublic()) + require.NoError(t, err) + + return crypto.ConfigEncodeKey(pubKeyMar) +} + +func privKeyB64() map[Persona]string { + return map[Persona]string{ + PersonaAlice: alicePrivKeyB64, + PersonaBob: bobPrivKeyB64, + PersonaCarol: carolPrivKeyB64, + PersonaDan: danPrivKeyB64, + PersonaErin: erinPrivKeyB64, + PersonaFrank: frankPrivKeyB64, + } +} + +// Personas returns an (alphabetically) ordered list of the defined +// Persona values. +func Personas() []Persona { + return []Persona{ + PersonaAlice, + PersonaBob, + PersonaCarol, + PersonaDan, + PersonaErin, + PersonaFrank, + } +} diff --git a/go.mod b/go.mod index 2193ff4..51d0d0e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/ucan-wg/go-ucan go 1.23 require ( + github.com/dave/jennifer v1.7.1 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 github.com/ipfs/go-cid v0.4.1 github.com/ipld/go-ipld-prime v0.21.0 diff --git a/go.sum b/go.sum index 82932d5..b7534ef 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= +github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= diff --git a/pkg/args/args.go b/pkg/args/args.go index 4ac9d94..d046d93 100644 --- a/pkg/args/args.go +++ b/pkg/args/args.go @@ -6,11 +6,13 @@ package args import ( "fmt" "sort" + "strings" "github.com/ipld/go-ipld-prime" "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/printer" "github.com/ucan-wg/go-ucan/pkg/policy/literal" ) @@ -70,6 +72,7 @@ func (a *Args) Include(other *Args) { // ToIPLD wraps an instance of an Args with an ipld.Node. func (a *Args) ToIPLD() (ipld.Node, error) { sort.Strings(a.Keys) + return qp.BuildMap(basicnode.Prototype.Any, int64(len(a.Keys)), func(ma datamodel.MapAssembler) { for _, key := range a.Keys { qp.MapEntry(ma, key, qp.Node(a.Values[key])) @@ -92,3 +95,43 @@ func (a *Args) Equals(other *Args) bool { } return true } + +func (a *Args) String() string { + sort.Strings(a.Keys) + + buf := strings.Builder{} + buf.WriteString("{") + + for _, key := range a.Keys { + buf.WriteString("\n\t") + buf.WriteString(key) + buf.WriteString(": ") + buf.WriteString(strings.ReplaceAll(printer.Sprint(a.Values[key]), "\n", "\n\t")) + buf.WriteString(",") + } + + if len(a.Keys) > 0 { + buf.WriteString("\n") + } + buf.WriteString("}") + + return buf.String() +} + +// ReadOnly returns a read-only version of Args. +func (a *Args) ReadOnly() ReadOnly { + return ReadOnly{args: a} +} + +// Clone makes a deep copy. +func (a *Args) Clone() *Args { + res := &Args{ + Keys: make([]string, len(a.Keys)), + Values: make(map[string]ipld.Node, len(a.Values)), + } + copy(res.Keys, a.Keys) + for k, v := range a.Values { + res.Values[k] = v + } + return res +} diff --git a/pkg/args/readonly.go b/pkg/args/readonly.go new file mode 100644 index 0000000..a708807 --- /dev/null +++ b/pkg/args/readonly.go @@ -0,0 +1,23 @@ +package args + +import "github.com/ipld/go-ipld-prime" + +type ReadOnly struct { + args *Args +} + +func (r ReadOnly) ToIPLD() (ipld.Node, error) { + return r.args.ToIPLD() +} + +func (r ReadOnly) Equals(other *Args) bool { + return r.args.Equals(other) +} + +func (r ReadOnly) String() string { + return r.args.String() +} + +func (r ReadOnly) WriteableClone() *Args { + return r.args.Clone() +} diff --git a/pkg/container/reader.go b/pkg/container/reader.go index 8c29fb1..74ba06a 100644 --- a/pkg/container/reader.go +++ b/pkg/container/reader.go @@ -2,6 +2,7 @@ package container import ( "encoding/base64" + "errors" "fmt" "io" "iter" @@ -34,13 +35,16 @@ func (ctn Reader) GetToken(cid cid.Cid) (token.Token, error) { // GetDelegation is the same as GetToken but only return a delegation.Token, with the right type. func (ctn Reader) GetDelegation(cid cid.Cid) (*delegation.Token, error) { tkn, err := ctn.GetToken(cid) + if errors.Is(err, ErrNotFound) { + return nil, delegation.ErrDelegationNotFound + } if err != nil { return nil, err } if tkn, ok := tkn.(*delegation.Token); ok { return tkn, nil } - return nil, fmt.Errorf("not a delegation token") + return nil, delegation.ErrDelegationNotFound } // GetAllDelegations returns all the delegation.Token in the container. diff --git a/pkg/meta/meta.go b/pkg/meta/meta.go index a08d97c..913530f 100644 --- a/pkg/meta/meta.go +++ b/pkg/meta/meta.go @@ -12,9 +12,7 @@ import ( "github.com/ucan-wg/go-ucan/pkg/policy/literal" ) -var ErrUnsupported = errors.New("failure adding unsupported type to meta") - -var ErrNotFound = errors.New("key-value not found in meta") +var ErrNotFound = errors.New("key not found in meta") var ErrNotEncryptable = errors.New("value of this type cannot be encrypted") @@ -193,18 +191,19 @@ func (m *Meta) String() string { buf := strings.Builder{} buf.WriteString("{") - var i int for key, node := range m.Values { - if i > 0 { - buf.WriteString(", ") - } - i++ + buf.WriteString("\n\t") buf.WriteString(key) - buf.WriteString(":") - buf.WriteString(printer.Sprint(node)) + buf.WriteString(": ") + buf.WriteString(strings.ReplaceAll(printer.Sprint(node), "\n", "\n\t")) + buf.WriteString(",") } + if len(m.Values) > 0 { + buf.WriteString("\n") + } buf.WriteString("}") + return buf.String() } diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go index 26ed239..c7f8e9f 100644 --- a/pkg/policy/policy_test.go +++ b/pkg/policy/policy_test.go @@ -37,6 +37,37 @@ func ExamplePolicy() { // ] } +func ExamplePolicy_accumulate() { + var statements []policy.Constructor + + statements = append(statements, policy.Equal(".status", literal.String("draft"))) + + statements = append(statements, policy.All(".reviewer", + policy.Like(".email", "*@example.com"), + )) + + statements = append(statements, policy.Any(".tags", policy.Or( + policy.Equal(".", literal.String("news")), + policy.Equal(".", literal.String("press")), + ))) + + pol := policy.MustConstruct(statements...) + + fmt.Println(pol) + + // Output: + // [ + // ["==", ".status", "draft"], + // ["all", ".reviewer", + // ["like", ".email", "*@example.com"]], + // ["any", ".tags", + // ["or", [ + // ["==", ".", "news"], + // ["==", ".", "press"]]] + // ] + // ] +} + func TestConstruct(t *testing.T) { pol, err := policy.Construct( policy.Equal(".status", literal.String("draft")), diff --git a/token/delegation/delegation.go b/token/delegation/delegation.go index 599f773..6ab32c6 100644 --- a/token/delegation/delegation.go +++ b/token/delegation/delegation.go @@ -48,7 +48,7 @@ type Token struct { // New creates a validated Token from the provided parameters and options. // -// When creating a delegated token, the Issuer's (iss) DID is assembed +// When creating a delegated token, the Issuer's (iss) DID is assembled // using the public key associated with the private key sent as the first // parameter. func New(privKey crypto.PrivKey, aud did.DID, cmd command.Command, pol policy.Policy, opts ...Option) (*Token, error) { @@ -153,6 +153,24 @@ func (t *Token) Expiration() *time.Time { return t.expiration } +// IsValidNow verifies that the token can be used at the current time, based on expiration or "not before" fields. +// This does NOT do any other kind of verifications. +func (t *Token) IsValidNow() bool { + return t.IsValidAt(time.Now()) +} + +// IsValidNow verifies that the token can be used at the given time, based on expiration or "not before" fields. +// This does NOT do any other kind of verifications. +func (t *Token) IsValidAt(ti time.Time) bool { + if t.expiration != nil && ti.After(*t.expiration) { + return false + } + if t.notBefore != nil && ti.Before(*t.notBefore) { + return false + } + return true +} + func (t *Token) validate() error { var errs error diff --git a/token/delegation/delegationtest/README.md b/token/delegation/delegationtest/README.md new file mode 100644 index 0000000..406875d --- /dev/null +++ b/token/delegation/delegationtest/README.md @@ -0,0 +1,5 @@ +# delegationtest + +See the package documentation for instructions on how to use the generated +tokens as well as information on how to regenerate the code if changes have +been made. diff --git a/token/delegation/delegationtest/data/TokenAliceBob.dagcbor b/token/delegation/delegationtest/data/TokenAliceBob.dagcbor new file mode 100644 index 0000000..2122c4d Binary files /dev/null and b/token/delegation/delegationtest/data/TokenAliceBob.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenBobCarol.dagcbor b/token/delegation/delegationtest/data/TokenBobCarol.dagcbor new file mode 100644 index 0000000..8d68e2b Binary files /dev/null and b/token/delegation/delegationtest/data/TokenBobCarol.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenCarolDan.dagcbor b/token/delegation/delegationtest/data/TokenCarolDan.dagcbor new file mode 100644 index 0000000..809ab4a Binary files /dev/null and b/token/delegation/delegationtest/data/TokenCarolDan.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenCarolDan_InvalidExpandedCommand.dagcbor b/token/delegation/delegationtest/data/TokenCarolDan_InvalidExpandedCommand.dagcbor new file mode 100644 index 0000000..c8bb4b3 Binary files /dev/null and b/token/delegation/delegationtest/data/TokenCarolDan_InvalidExpandedCommand.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenCarolDan_InvalidExpired.dagcbor b/token/delegation/delegationtest/data/TokenCarolDan_InvalidExpired.dagcbor new file mode 100644 index 0000000..c67b5e2 Binary files /dev/null and b/token/delegation/delegationtest/data/TokenCarolDan_InvalidExpired.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenCarolDan_InvalidInactive.dagcbor b/token/delegation/delegationtest/data/TokenCarolDan_InvalidInactive.dagcbor new file mode 100644 index 0000000..fae7e21 Binary files /dev/null and b/token/delegation/delegationtest/data/TokenCarolDan_InvalidInactive.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenCarolDan_InvalidSubject.dagcbor b/token/delegation/delegationtest/data/TokenCarolDan_InvalidSubject.dagcbor new file mode 100644 index 0000000..7523f1a Binary files /dev/null and b/token/delegation/delegationtest/data/TokenCarolDan_InvalidSubject.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenCarolDan_ValidAttenuatedCommand.dagcbor b/token/delegation/delegationtest/data/TokenCarolDan_ValidAttenuatedCommand.dagcbor new file mode 100644 index 0000000..6ddf106 Binary files /dev/null and b/token/delegation/delegationtest/data/TokenCarolDan_ValidAttenuatedCommand.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenDanErin.dagcbor b/token/delegation/delegationtest/data/TokenDanErin.dagcbor new file mode 100644 index 0000000..baaac2e Binary files /dev/null and b/token/delegation/delegationtest/data/TokenDanErin.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenDanErin_InvalidExpandedCommand.dagcbor b/token/delegation/delegationtest/data/TokenDanErin_InvalidExpandedCommand.dagcbor new file mode 100644 index 0000000..777c0aa Binary files /dev/null and b/token/delegation/delegationtest/data/TokenDanErin_InvalidExpandedCommand.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenDanErin_InvalidExpired.dagcbor b/token/delegation/delegationtest/data/TokenDanErin_InvalidExpired.dagcbor new file mode 100644 index 0000000..2cbeeba Binary files /dev/null and b/token/delegation/delegationtest/data/TokenDanErin_InvalidExpired.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenDanErin_InvalidInactive.dagcbor b/token/delegation/delegationtest/data/TokenDanErin_InvalidInactive.dagcbor new file mode 100644 index 0000000..de938fd Binary files /dev/null and b/token/delegation/delegationtest/data/TokenDanErin_InvalidInactive.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenDanErin_InvalidSubject.dagcbor b/token/delegation/delegationtest/data/TokenDanErin_InvalidSubject.dagcbor new file mode 100644 index 0000000..ba61f8d Binary files /dev/null and b/token/delegation/delegationtest/data/TokenDanErin_InvalidSubject.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenDanErin_ValidAttenuatedCommand.dagcbor b/token/delegation/delegationtest/data/TokenDanErin_ValidAttenuatedCommand.dagcbor new file mode 100644 index 0000000..6399585 Binary files /dev/null and b/token/delegation/delegationtest/data/TokenDanErin_ValidAttenuatedCommand.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenErinFrank.dagcbor b/token/delegation/delegationtest/data/TokenErinFrank.dagcbor new file mode 100644 index 0000000..1c85482 Binary files /dev/null and b/token/delegation/delegationtest/data/TokenErinFrank.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenErinFrank_InvalidExpandedCommand.dagcbor b/token/delegation/delegationtest/data/TokenErinFrank_InvalidExpandedCommand.dagcbor new file mode 100644 index 0000000..ceccd46 Binary files /dev/null and b/token/delegation/delegationtest/data/TokenErinFrank_InvalidExpandedCommand.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenErinFrank_InvalidExpired.dagcbor b/token/delegation/delegationtest/data/TokenErinFrank_InvalidExpired.dagcbor new file mode 100644 index 0000000..ef55b22 Binary files /dev/null and b/token/delegation/delegationtest/data/TokenErinFrank_InvalidExpired.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenErinFrank_InvalidInactive.dagcbor b/token/delegation/delegationtest/data/TokenErinFrank_InvalidInactive.dagcbor new file mode 100644 index 0000000..ae90da1 Binary files /dev/null and b/token/delegation/delegationtest/data/TokenErinFrank_InvalidInactive.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenErinFrank_InvalidSubject.dagcbor b/token/delegation/delegationtest/data/TokenErinFrank_InvalidSubject.dagcbor new file mode 100644 index 0000000..b8bac8a Binary files /dev/null and b/token/delegation/delegationtest/data/TokenErinFrank_InvalidSubject.dagcbor differ diff --git a/token/delegation/delegationtest/data/TokenErinFrank_ValidAttenuatedCommand.dagcbor b/token/delegation/delegationtest/data/TokenErinFrank_ValidAttenuatedCommand.dagcbor new file mode 100644 index 0000000..822aa18 Binary files /dev/null and b/token/delegation/delegationtest/data/TokenErinFrank_ValidAttenuatedCommand.dagcbor differ diff --git a/token/delegation/delegationtest/doc.go b/token/delegation/delegationtest/doc.go new file mode 100644 index 0000000..526c6fa --- /dev/null +++ b/token/delegation/delegationtest/doc.go @@ -0,0 +1,33 @@ +// Package delegationtest provides a set of pre-built delegation tokens +// for a variety of test cases. +// +// For all delegation tokens, the name of the delegation token is the +// Issuer appended with the Audience. The tokens are generated so that +// an invocation can be created for any didtest.Persona. +// +// Delegation proof-chain names contain each didtest.Persona name in +// order starting with the root delegation (which will always be generated +// by Alice). This is the opposite of the list of cic.Cids that represent the +// proof chain. +// +// For both the generated delegation tokens granted to Carol's Persona and +// the proof chains containing Carol's delegations to Dan, if there is no +// suffix, the proof chain will be deemed valid. If there is a suffix, it +// will consist of either the word "Valid" or "Invalid" and the name of the +// field that has been altered. Only optional fields will generate proof +// chains with Valid suffixes. +// +// If changes are made to the list of Personas included in the chain, or +// in the variants that are specified, the generated Go file and delegation +// tokens stored in the data/ directory should be regenerated by running +// the following command in this directory: +// +// cd generator && go run . +// +// Generated delegation Tokens are stored in the data/ directory and loaded +// into the delegation.Loader. +// Generated references to these tokens and the tokens themselves are +// created in the token_gen.go file. See /token/invocation/invocation_test.go +// for an example of how these delegation tokens and proof-chains can +// be used during testing. +package delegationtest diff --git a/token/delegation/delegationtest/generator/generator.go b/token/delegation/delegationtest/generator/generator.go new file mode 100644 index 0000000..2eaacb2 --- /dev/null +++ b/token/delegation/delegationtest/generator/generator.go @@ -0,0 +1,229 @@ +package main + +import ( + "os" + "path/filepath" + "slices" + "time" + + "github.com/dave/jennifer/jen" + "github.com/ipfs/go-cid" + "github.com/libp2p/go-libp2p/core/crypto" + + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/did/didtest" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/pkg/policy" + "github.com/ucan-wg/go-ucan/token/delegation" + "github.com/ucan-wg/go-ucan/token/delegation/delegationtest" +) + +const ( + tokenNamePrefix = "Token" + proorChainNamePrefix = "Proof" + tokenExt = ".dagcbor" +) + +var constantNonce = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b} + +type newDelegationParams struct { + privKey crypto.PrivKey + aud did.DID + sub did.DID + cmd command.Command + pol policy.Policy + opts []delegation.Option +} + +type token struct { + name string + id cid.Cid +} + +type proof struct { + name string + prf []cid.Cid +} + +type acc struct { + name string + chain []cid.Cid +} + +type variant struct { + name string + variant func(*newDelegationParams) +} + +func noopVariant() variant { + return variant{ + name: "", + variant: func(_ *newDelegationParams) {}, + } +} + +type generator struct { + dlgs []token + chains []proof +} + +func (g *generator) chainPersonas(personas []didtest.Persona, acc acc, vari variant) error { + acc.name += personas[0].Name() + + proofName := acc.name + if len(vari.name) > 0 { + proofName += "_" + vari.name + } + g.createProofChain(proofName, acc.chain) + + if len(personas) < 2 { + return nil + } + + name := personas[0].Name() + personas[1].Name() + + params := newDelegationParams{ + privKey: personas[0].PrivKey(), + aud: personas[1].DID(), + cmd: delegationtest.NominalCommand, + pol: policy.Policy{}, + opts: []delegation.Option{ + delegation.WithSubject(didtest.PersonaAlice.DID()), + delegation.WithNonce(constantNonce), + }, + } + + // Create each nominal token and continue the chain + id, err := g.createDelegation(params, name, vari) + if err != nil { + return err + } + acc.chain = append(acc.chain, id) + err = g.chainPersonas(personas[1:], acc, vari) + if err != nil { + return err + } + + // If the user is Carol, create variants for each invalid and/or optional + // parameter and also continue the chain + if personas[0] == didtest.PersonaCarol { + variants := []variant{ + {name: "InvalidExpandedCommand", variant: func(p *newDelegationParams) { + p.cmd = delegationtest.ExpandedCommand + }}, + {name: "ValidAttenuatedCommand", variant: func(p *newDelegationParams) { + p.cmd = delegationtest.AttenuatedCommand + }}, + {name: "InvalidSubject", variant: func(p *newDelegationParams) { + p.opts = append(p.opts, delegation.WithSubject(didtest.PersonaBob.DID())) + }}, + {name: "InvalidExpired", variant: func(p *newDelegationParams) { + // Note: this makes the generator not deterministic + p.opts = append(p.opts, delegation.WithExpiration(time.Now().Add(time.Second))) + }}, + {name: "InvalidInactive", variant: func(p *newDelegationParams) { + nbf, err := time.Parse(time.RFC3339, "2070-01-01T00:00:00Z") + if err != nil { + panic(err) + } + p.opts = append(p.opts, delegation.WithNotBefore(nbf)) + }}, + } + + // Start a branch in the recursion for each of the variants + for _, v := range variants { + id, err := g.createDelegation(params, name, v) + if err != nil { + return err + } + + // replace the previous Carol token id with the one from the variant + acc.chain[len(acc.chain)-1] = id + err = g.chainPersonas(personas[1:], acc, v) + if err != nil { + return err + } + } + } + return nil +} + +func (g *generator) createDelegation(params newDelegationParams, name string, vari variant) (cid.Cid, error) { + vari.variant(¶ms) + + tkn, err := delegation.New(params.privKey, params.aud, params.cmd, params.pol, params.opts...) + if err != nil { + return cid.Undef, err + } + + data, id, err := tkn.ToSealed(params.privKey) + if err != nil { + return cid.Undef, err + } + + dlgName := tokenNamePrefix + name + if len(vari.name) > 0 { + dlgName += "_" + vari.name + } + + err = os.WriteFile(filepath.Join("..", delegationtest.TokenDir, dlgName+tokenExt), data, 0o644) + if err != nil { + return cid.Undef, err + } + + g.dlgs = append(g.dlgs, token{ + name: dlgName, + id: id, + }) + + return id, nil +} + +func (g *generator) createProofChain(name string, prf []cid.Cid) { + if len(prf) < 1 { + return + } + + clone := make([]cid.Cid, len(prf)) + copy(clone, prf) + + g.chains = append(g.chains, proof{ + name: proorChainNamePrefix + name, + prf: clone, + }) +} + +func (g *generator) writeGoFile() error { + file := jen.NewFile("delegationtest") + file.HeaderComment("Code generated by delegationtest - DO NOT EDIT.") + + refs := map[cid.Cid]string{} + + for _, d := range g.dlgs { + refs[d.id] = d.name + "CID" + + file.Var().Defs( + jen.Id(d.name+"CID").Op("=").Qual("github.com/ipfs/go-cid", "MustParse").Call(jen.Lit(d.id.String())), + jen.Id(d.name).Op("=").Id("mustGetDelegation").Call(jen.Id(d.name+"CID")), + ) + file.Line() + } + + for _, c := range g.chains { + g := jen.CustomFunc(jen.Options{ + Multi: true, + Separator: ",", + Close: "\n", + }, func(g *jen.Group) { + slices.Reverse(c.prf) + for _, p := range c.prf { + g.Id(refs[p]) + } + }) + + file.Var().Id(c.name).Op("=").Index().Qual("github.com/ipfs/go-cid", "Cid").Values(g) + file.Line() + } + + return file.Save("../token_gen.go") +} diff --git a/token/delegation/delegationtest/generator/main.go b/token/delegation/delegationtest/generator/main.go new file mode 100644 index 0000000..994ae42 --- /dev/null +++ b/token/delegation/delegationtest/generator/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/ucan-wg/go-ucan/did/didtest" +) + +func main() { + gen := &generator{} + err := gen.chainPersonas(didtest.Personas(), acc{}, noopVariant()) + if err != nil { + panic(err) + } + err = gen.writeGoFile() + if err != nil { + panic(err) + } +} diff --git a/token/delegation/delegationtest/token.go b/token/delegation/delegationtest/token.go new file mode 100644 index 0000000..e425733 --- /dev/null +++ b/token/delegation/delegationtest/token.go @@ -0,0 +1,112 @@ +package delegationtest + +import ( + "embed" + "path/filepath" + "sync" + + "github.com/ipfs/go-cid" + + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/token/delegation" +) + +var ( + // ExpandedCommand is the parent of the NominalCommand and represents + // the cases where the delegation proof-chain or invocation token tries + // to increase the privileges granted by the root delegation token. + // Execution of this command is generally prohibited in tests. + ExpandedCommand = command.MustParse("/expanded") + + // NominalCommand is the command used for most test tokens and proof- + // chains. Execution of this command is generally allowed in tests. + NominalCommand = ExpandedCommand.Join("nominal") + + // AttenuatedCommand is a sub-command of the NominalCommand. Execution + // of this command is generally allowed in tests. + AttenuatedCommand = NominalCommand.Join("attenuated") +) + +// ProofEmpty provides an empty proof chain for testing purposes. +var ProofEmpty = []cid.Cid{} + +const TokenDir = "data" + +//go:embed data +var fs embed.FS + +var _ delegation.Loader = (*delegationLoader)(nil) + +type delegationLoader struct { + tokens map[cid.Cid]*delegation.Token +} + +var ( + once sync.Once + ldr delegation.Loader +) + +// GetDelegationLoader returns a singleton instance of a test +// DelegationLoader containing all the tokens present in the data/ +// directory. +func GetDelegationLoader() delegation.Loader { + once.Do(func() { + var err error + ldr, err = loadDelegations() + if err != nil { + panic(err) + } + }) + return ldr +} + +// GetDelegation implements invocation.DelegationLoader. +func (l *delegationLoader) GetDelegation(id cid.Cid) (*delegation.Token, error) { + tkn, ok := l.tokens[id] + if !ok { + return nil, delegation.ErrDelegationNotFound + } + + return tkn, nil +} + +func loadDelegations() (delegation.Loader, error) { + dirEntries, err := fs.ReadDir(TokenDir) + if err != nil { + return nil, err + } + + tkns := make(map[cid.Cid]*delegation.Token, len(dirEntries)) + + for _, dirEntry := range dirEntries { + data, err := fs.ReadFile(filepath.Join(TokenDir, dirEntry.Name())) + if err != nil { + return nil, err + } + + tkn, id, err := delegation.FromSealed(data) + if err != nil { + return nil, err + } + + tkns[id] = tkn + } + + return &delegationLoader{ + tokens: tkns, + }, nil +} + +// GetDelegation is a shortcut that gets (or creates) the DelegationLoader +// and attempts to return the token referenced by the provided CID. +func GetDelegation(id cid.Cid) (*delegation.Token, error) { + return GetDelegationLoader().GetDelegation(id) +} + +func mustGetDelegation(id cid.Cid) *delegation.Token { + tkn, err := GetDelegation(id) + if err != nil { + panic(err) + } + return tkn +} diff --git a/token/delegation/delegationtest/token_gen.go b/token/delegation/delegationtest/token_gen.go new file mode 100644 index 0000000..2a24424 --- /dev/null +++ b/token/delegation/delegationtest/token_gen.go @@ -0,0 +1,240 @@ +// Code generated by delegationtest - DO NOT EDIT. + +package delegationtest + +import gocid "github.com/ipfs/go-cid" + +var ( + TokenAliceBobCID = gocid.MustParse("bafyreicidrwvmac5lvjypucgityrtjsknojraio7ujjli4r5eyby66wjzm") + TokenAliceBob = mustGetDelegation(TokenAliceBobCID) +) + +var ( + TokenBobCarolCID = gocid.MustParse("bafyreihxv2uhq43oxllzs2xfvxst7wtvvvl7pohb2chcz6hjvfv2ntea5u") + TokenBobCarol = mustGetDelegation(TokenBobCarolCID) +) + +var ( + TokenCarolDanCID = gocid.MustParse("bafyreihclsgiroazq3heqdswvj2cafwqbpboicq7immo65scl7ahktpsdq") + TokenCarolDan = mustGetDelegation(TokenCarolDanCID) +) + +var ( + TokenDanErinCID = gocid.MustParse("bafyreicja6ihewy64p3ake56xukotafjlkh4uqep2qhj52en46zzfwby3e") + TokenDanErin = mustGetDelegation(TokenDanErinCID) +) + +var ( + TokenErinFrankCID = gocid.MustParse("bafyreicjlx3lobxm6hl5s4htd4ydwkkqeiou6rft4rnvulfdyoew565vka") + TokenErinFrank = mustGetDelegation(TokenErinFrankCID) +) + +var ( + TokenCarolDan_InvalidExpandedCommandCID = gocid.MustParse("bafyreid3m3pk53gqgp5rlzqhvpedbwsqbidqlp4yz64vknwbzj7bxrmsr4") + TokenCarolDan_InvalidExpandedCommand = mustGetDelegation(TokenCarolDan_InvalidExpandedCommandCID) +) + +var ( + TokenDanErin_InvalidExpandedCommandCID = gocid.MustParse("bafyreifn4sy5onwajx3kqvot5mib6m6xarzrqjozqbzgmzpmc5ox3g2uzm") + TokenDanErin_InvalidExpandedCommand = mustGetDelegation(TokenDanErin_InvalidExpandedCommandCID) +) + +var ( + TokenErinFrank_InvalidExpandedCommandCID = gocid.MustParse("bafyreidmpgd36jznmq42bs34o4qi3fcbrsh4idkg6ejahudejzwb76fwxe") + TokenErinFrank_InvalidExpandedCommand = mustGetDelegation(TokenErinFrank_InvalidExpandedCommandCID) +) + +var ( + TokenCarolDan_ValidAttenuatedCommandCID = gocid.MustParse("bafyreiekhtm237vyapk3c6voeb5lnz54crebqdqi3x4wn4u4cbrrhzsqfe") + TokenCarolDan_ValidAttenuatedCommand = mustGetDelegation(TokenCarolDan_ValidAttenuatedCommandCID) +) + +var ( + TokenDanErin_ValidAttenuatedCommandCID = gocid.MustParse("bafyreicrvzqferyy7rgo75l5rn6r2nl7zyeexxjmu3dm4ff7rn2coblj4y") + TokenDanErin_ValidAttenuatedCommand = mustGetDelegation(TokenDanErin_ValidAttenuatedCommandCID) +) + +var ( + TokenErinFrank_ValidAttenuatedCommandCID = gocid.MustParse("bafyreie6fhspk53kplcc2phla3e7z7fzldlbmmpuwk6nbow5q6s2zjmw2q") + TokenErinFrank_ValidAttenuatedCommand = mustGetDelegation(TokenErinFrank_ValidAttenuatedCommandCID) +) + +var ( + TokenCarolDan_InvalidSubjectCID = gocid.MustParse("bafyreifgksz6756if42tnc6rqsnbaa2u3fdrveo7ek44lnj2d64d5sw26u") + TokenCarolDan_InvalidSubject = mustGetDelegation(TokenCarolDan_InvalidSubjectCID) +) + +var ( + TokenDanErin_InvalidSubjectCID = gocid.MustParse("bafyreibdwew5nypsxrm4fq73wu6hw3lgwwiolj3bi33xdrbgcf3ogm6fty") + TokenDanErin_InvalidSubject = mustGetDelegation(TokenDanErin_InvalidSubjectCID) +) + +var ( + TokenErinFrank_InvalidSubjectCID = gocid.MustParse("bafyreicr364mj3n7x4iyhcksxypelktcqkkw3ptg7ggxtqegw3p3mr6zc4") + TokenErinFrank_InvalidSubject = mustGetDelegation(TokenErinFrank_InvalidSubjectCID) +) + +var ( + TokenCarolDan_InvalidExpiredCID = gocid.MustParse("bafyreigenypixaxvhzlry5rjnywvjyl4xvzlzxz2ui74uzys7qdhos4bbu") + TokenCarolDan_InvalidExpired = mustGetDelegation(TokenCarolDan_InvalidExpiredCID) +) + +var ( + TokenDanErin_InvalidExpiredCID = gocid.MustParse("bafyreifvnfb7zqocpdysedcvjkb4y7tqfuziuqjhbbdoay4zg33pwpbzqi") + TokenDanErin_InvalidExpired = mustGetDelegation(TokenDanErin_InvalidExpiredCID) +) + +var ( + TokenErinFrank_InvalidExpiredCID = gocid.MustParse("bafyreicvydzt3obkqx7krmoi3zu4tlirlksibxfks5jc7vlvjxjamv2764") + TokenErinFrank_InvalidExpired = mustGetDelegation(TokenErinFrank_InvalidExpiredCID) +) + +var ( + TokenCarolDan_InvalidInactiveCID = gocid.MustParse("bafyreicea5y2nvlitvxijkupeavtg23i7ktjk3uejnaquguurzptiabk4u") + TokenCarolDan_InvalidInactive = mustGetDelegation(TokenCarolDan_InvalidInactiveCID) +) + +var ( + TokenDanErin_InvalidInactiveCID = gocid.MustParse("bafyreifsgqzkmxj2vexuts3z766mwcjreiisjg2jykyzf7tbj5sclutpvq") + TokenDanErin_InvalidInactive = mustGetDelegation(TokenDanErin_InvalidInactiveCID) +) + +var ( + TokenErinFrank_InvalidInactiveCID = gocid.MustParse("bafyreifbfegon24c6dndiqyktahzs65vhyasrygbw7nhsvojn6distsdre") + TokenErinFrank_InvalidInactive = mustGetDelegation(TokenErinFrank_InvalidInactiveCID) +) + +var ProofAliceBob = []gocid.Cid{ + TokenAliceBobCID, +} + +var ProofAliceBobCarol = []gocid.Cid{ + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDan = []gocid.Cid{ + TokenCarolDanCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDanErin = []gocid.Cid{ + TokenDanErinCID, + TokenCarolDanCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDanErinFrank = []gocid.Cid{ + TokenErinFrankCID, + TokenDanErinCID, + TokenCarolDanCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDan_InvalidExpandedCommand = []gocid.Cid{ + TokenCarolDan_InvalidExpandedCommandCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDanErin_InvalidExpandedCommand = []gocid.Cid{ + TokenDanErin_InvalidExpandedCommandCID, + TokenCarolDan_InvalidExpandedCommandCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDanErinFrank_InvalidExpandedCommand = []gocid.Cid{ + TokenErinFrank_InvalidExpandedCommandCID, + TokenDanErin_InvalidExpandedCommandCID, + TokenCarolDan_InvalidExpandedCommandCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDan_ValidAttenuatedCommand = []gocid.Cid{ + TokenCarolDan_ValidAttenuatedCommandCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDanErin_ValidAttenuatedCommand = []gocid.Cid{ + TokenDanErin_ValidAttenuatedCommandCID, + TokenCarolDan_ValidAttenuatedCommandCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDanErinFrank_ValidAttenuatedCommand = []gocid.Cid{ + TokenErinFrank_ValidAttenuatedCommandCID, + TokenDanErin_ValidAttenuatedCommandCID, + TokenCarolDan_ValidAttenuatedCommandCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDan_InvalidSubject = []gocid.Cid{ + TokenCarolDan_InvalidSubjectCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDanErin_InvalidSubject = []gocid.Cid{ + TokenDanErin_InvalidSubjectCID, + TokenCarolDan_InvalidSubjectCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDanErinFrank_InvalidSubject = []gocid.Cid{ + TokenErinFrank_InvalidSubjectCID, + TokenDanErin_InvalidSubjectCID, + TokenCarolDan_InvalidSubjectCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDan_InvalidExpired = []gocid.Cid{ + TokenCarolDan_InvalidExpiredCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDanErin_InvalidExpired = []gocid.Cid{ + TokenDanErin_InvalidExpiredCID, + TokenCarolDan_InvalidExpiredCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDanErinFrank_InvalidExpired = []gocid.Cid{ + TokenErinFrank_InvalidExpiredCID, + TokenDanErin_InvalidExpiredCID, + TokenCarolDan_InvalidExpiredCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDan_InvalidInactive = []gocid.Cid{ + TokenCarolDan_InvalidInactiveCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDanErin_InvalidInactive = []gocid.Cid{ + TokenDanErin_InvalidInactiveCID, + TokenCarolDan_InvalidInactiveCID, + TokenBobCarolCID, + TokenAliceBobCID, +} + +var ProofAliceBobCarolDanErinFrank_InvalidInactive = []gocid.Cid{ + TokenErinFrank_InvalidInactiveCID, + TokenDanErin_InvalidInactiveCID, + TokenCarolDan_InvalidInactiveCID, + TokenBobCarolCID, + TokenAliceBobCID, +} diff --git a/token/delegation/delegationtest/token_test.go b/token/delegation/delegationtest/token_test.go new file mode 100644 index 0000000..b24a02c --- /dev/null +++ b/token/delegation/delegationtest/token_test.go @@ -0,0 +1,30 @@ +package delegationtest_test + +import ( + "testing" + + "github.com/ipfs/go-cid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ucan-wg/go-ucan/token/delegation" + "github.com/ucan-wg/go-ucan/token/delegation/delegationtest" +) + +func TestGetDelegation(t *testing.T) { + t.Run("passes with valid CID", func(t *testing.T) { + t.Parallel() + + tkn, err := delegationtest.GetDelegation(delegationtest.TokenAliceBobCID) + require.NoError(t, err) + assert.NotZero(t, tkn) + }) + + t.Run("fails with unknown CID", func(t *testing.T) { + t.Parallel() + + tkn, err := delegationtest.GetDelegation(cid.Undef) + require.ErrorIs(t, err, delegation.ErrDelegationNotFound) + assert.Nil(t, tkn) + }) +} diff --git a/token/delegation/loader.go b/token/delegation/loader.go new file mode 100644 index 0000000..13dd81d --- /dev/null +++ b/token/delegation/loader.go @@ -0,0 +1,17 @@ +package delegation + +import ( + "fmt" + + "github.com/ipfs/go-cid" +) + +// ErrDelegationNotFound is returned if a delegation token is not found +var ErrDelegationNotFound = fmt.Errorf("delegation not found") + +// Loader is a delegation token loader. +type Loader interface { + // GetDelegation returns the delegation.Token matching the given CID. + // If not found, ErrDelegationNotFound is returned. + GetDelegation(cid cid.Cid) (*Token, error) +} diff --git a/token/delegation/schema.go b/token/delegation/schema.go index 1c6873c..13721e9 100644 --- a/token/delegation/schema.go +++ b/token/delegation/schema.go @@ -26,17 +26,17 @@ const Tag = "ucan/dlg@1.0.0-rc.1" var schemaBytes []byte var ( - once sync.Once - ts *schema.TypeSystem - err error + once sync.Once + ts *schema.TypeSystem + errSchema error ) func mustLoadSchema() *schema.TypeSystem { once.Do(func() { - ts, err = ipld.LoadSchemaBytes(schemaBytes) + ts, errSchema = ipld.LoadSchemaBytes(schemaBytes) }) - if err != nil { - panic(fmt.Errorf("failed to load IPLD schema: %s", err)) + if errSchema != nil { + panic(fmt.Errorf("failed to load IPLD schema: %s", errSchema)) } return ts } diff --git a/token/interface.go b/token/interface.go index 3079f56..b3a8d21 100644 --- a/token/interface.go +++ b/token/interface.go @@ -2,22 +2,22 @@ package token import ( "io" + "time" "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/codec" "github.com/libp2p/go-libp2p/core/crypto" - - "github.com/ucan-wg/go-ucan/did" - "github.com/ucan-wg/go-ucan/pkg/meta" ) type Token interface { Marshaller - // Issuer returns the did.DID representing the Token's issuer. - Issuer() did.DID - // Meta returns the Token's metadata. - Meta() meta.ReadOnly + // IsValidNow verifies that the token can be used at the current time, based on expiration or "not before" fields. + // This does NOT do any other kind of verifications. + IsValidNow() bool + // IsValidNow verifies that the token can be used at the given time, based on expiration or "not before" fields. + // This does NOT do any other kind of verifications. + IsValidAt(t time.Time) bool } type Marshaller interface { diff --git a/token/internal/envelope/ipld.go b/token/internal/envelope/ipld.go index 6e9533d..5b6ed15 100644 --- a/token/internal/envelope/ipld.go +++ b/token/internal/envelope/ipld.go @@ -187,8 +187,7 @@ func FromIPLD[T Tokener](node datamodel.Node) (T, error) { return zero, errors.New("the VarsigHeader key type doesn't match the issuer's key type") } - // TODO: this re-encode the payload! Is there a less wasteful way? - + // TODO: can we use the already serialized CBOR data here, instead of encoding again the payload? data, err := ipld.Encode(info.sigPayloadNode, dagcbor.Encode) if err != nil { return zero, err diff --git a/token/internal/nonce/nonce.go b/token/internal/nonce/nonce.go index 3bda21b..f4928a8 100644 --- a/token/internal/nonce/nonce.go +++ b/token/internal/nonce/nonce.go @@ -2,8 +2,25 @@ package nonce import "crypto/rand" -// Generate creates a 12-byte random nonce. // TODO: some crypto scheme require more, is that our case? +// +// The spec mention: +// The REQUIRED nonce parameter nonce MAY be any value. +// A randomly generated string is RECOMMENDED to provide a unique UCAN, though it MAY +// also be a monotonically increasing count of the number of links in the hash chain. +// This field helps prevent replay attacks and ensures a unique CID per delegation. +// The iss, aud, and exp fields together will often ensure that UCANs are unique, +// but adding the nonce ensures uniqueness. +// +// The recommended size of the nonce differs by key type. In many cases, a random +// 12-byte nonce is sufficient. If uncertain, check the nonce in your DID's crypto suite. +// +// 12 bytes is 10^28, 16 bytes is 10^38. Both sounds like a lot of random to achieve +// those goals, but maybe the crypto voodoo require more. +// +// The rust implementation use 16 bytes nonce. + +// Generate creates a 12-byte random nonce. func Generate() ([]byte, error) { res := make([]byte, 12) _, err := rand.Read(res) diff --git a/token/invocation/errors.go b/token/invocation/errors.go new file mode 100644 index 0000000..5335763 --- /dev/null +++ b/token/invocation/errors.go @@ -0,0 +1,37 @@ +package invocation + +import "errors" + +// Loading errors +var ( + // ErrMissingDelegation + ErrMissingDelegation = errors.New("loader missing delegation for proof chain") +) + +// Time bound errors +var ( + // ErrTokenExpired is returned if a token is invalid at execution time + ErrTokenInvalidNow = errors.New("token has expired") +) + +// Principal alignment errors +var ( + // ErrNoProof is returned when no delegations were provided to prove + // that the invocation should be executed. + ErrNoProof = errors.New("at least one delegation must be provided to validate the invocation") + + // ErrLastNotRoot is returned if the last delegation token in the proof + // chain is not a root delegation token. + ErrLastNotRoot = errors.New("the last delegation token in proof chain must be a root token") + + // ErrBrokenChain is returned when the Audience of a delegation is + // not the Issuer of the previous one. + ErrBrokenChain = errors.New("delegation proof chain doesn't connect the invocation to the subject") + + // ErrWrongSub is returned when the Subject of a delegation is not the invocation audience. + ErrWrongSub = errors.New("delegation subject need to match the invocation audience") + + // ErrCommandNotCovered is returned when a delegation command doesn't cover (identical or parent of) the + // next delegation or invocation's command. + ErrCommandNotCovered = errors.New("allowed command doesn't cover the next delegation or invocation") +) diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index c48121f..12f0a08 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -18,6 +18,7 @@ import ( "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/delegation" "github.com/ucan-wg/go-ucan/token/internal/nonce" "github.com/ucan-wg/go-ucan/token/internal/parse" ) @@ -33,11 +34,13 @@ type Token struct { // The Command command command.Command - // The Command's Arguments + // The Command's arguments arguments *args.Args - // Delegations that prove the chain of authority + // CIDs of the delegation.Token that prove the chain of authority + // They need to form a strictly linear chain, and being ordered starting from the + // leaf Delegation (with aud matching the invocation's iss), in a strict sequence + // where the iss of the previous Delegation matches the aud of the next Delegation. proof []cid.Cid - // Arbitrary Metadata meta *meta.Meta @@ -84,6 +87,7 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } } + var err error if len(tkn.nonce) == 0 { tkn.nonce, err = nonce.Generate() if err != nil { @@ -98,6 +102,40 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( return &tkn, nil } +func (t *Token) ExecutionAllowed(loader delegation.Loader) error { + return t.executionAllowed(loader, t.arguments) +} + +func (t *Token) ExecutionAllowedWithArgsHook(loader delegation.Loader, hook func(args args.ReadOnly) (*args.Args, error)) error { + newArgs, err := hook(t.arguments.ReadOnly()) + if err != nil { + return err + } + return t.executionAllowed(loader, newArgs) +} + +func (t *Token) executionAllowed(loader delegation.Loader, arguments *args.Args) error { + delegations, err := t.loadProofs(loader) + if err != nil { + // All referenced delegations must be available - 4b + return err + } + + if err := t.verifyProofs(delegations); err != nil { + return err + } + + if err := t.verifyTimeBound(delegations); err != nil { + return err + } + + if err := t.verifyArgs(delegations, arguments); err != nil { + return err + } + + return nil +} + // Issuer returns the did.DID representing the Token's issuer. func (t *Token) Issuer() did.DID { return t.issuer @@ -120,8 +158,8 @@ func (t *Token) Command() command.Command { // Arguments returns the arguments to be used when the command is // invoked. -func (t *Token) Arguments() *args.Args { - return t.arguments +func (t *Token) Arguments() args.ReadOnly { + return t.arguments.ReadOnly() } // Proof() returns the ordered list of cid.Cid which reference the @@ -157,6 +195,21 @@ func (t *Token) Cause() *cid.Cid { return t.cause } +// IsValidNow verifies that the token can be used at the current time, based on expiration or "not before" fields. +// This does NOT do any other kind of verifications. +func (t *Token) IsValidNow() bool { + return t.IsValidAt(time.Now()) +} + +// IsValidNow verifies that the token can be used at the given time, based on expiration or "not before" fields. +// This does NOT do any other kind of verifications. +func (t *Token) IsValidAt(ti time.Time) bool { + if t.expiration != nil && ti.After(*t.expiration) { + return false + } + return true +} + func (t *Token) validate() error { var errs error @@ -176,6 +229,17 @@ func (t *Token) validate() error { return errs } +func (t *Token) loadProofs(loader delegation.Loader) (res []*delegation.Token, err error) { + res = make([]*delegation.Token, len(t.proof)) + for i, c := range t.proof { + res[i], err = loader.GetDelegation(c) + if err != nil { + return nil, fmt.Errorf("%w: need %s", ErrMissingDelegation, c) + } + } + return res, nil +} + // tokenFromModel build a decoded view of the raw IPLD data. // This function also serves as validation. func tokenFromModel(m tokenPayloadModel) (*Token, error) { diff --git a/token/invocation/invocation_test.go b/token/invocation/invocation_test.go new file mode 100644 index 0000000..20ce554 --- /dev/null +++ b/token/invocation/invocation_test.go @@ -0,0 +1,139 @@ +package invocation_test + +import ( + "testing" + + "github.com/ipfs/go-cid" + "github.com/stretchr/testify/require" + + "github.com/ucan-wg/go-ucan/did/didtest" + "github.com/ucan-wg/go-ucan/pkg/args" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/token/delegation/delegationtest" + "github.com/ucan-wg/go-ucan/token/invocation" +) + +const ( + missingPrivKeyCfg = "CAESQMjRvrEIjpPYRQKmkAGw/pV0XgE958rYa4vlnKJjl1zz/sdnGnyV1xKLJk8D39edyjhHWyqcpgFnozQK62SG16k=" + missingTknCIDStr = "bafyreigwypmw6eul6vadi6g6lnfbsfo2zck7gfzsbjoroqs3djhnzzc7mm" + missingDIDStr = "did:key:z6MkwboxFsH3kEuehBZ5fLkRmxi68yv1u38swA4r9Jm2VRma" +) + +var emptyArguments = args.New() + +func TestToken_ExecutionAllowed(t *testing.T) { + t.Parallel() + + t.Run("passes - only root", func(t *testing.T) { + t.Parallel() + + testPasses(t, didtest.PersonaBob, delegationtest.NominalCommand, emptyArguments, delegationtest.ProofAliceBob) + }) + + t.Run("passes - valid chain", func(t *testing.T) { + t.Parallel() + + testPasses(t, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank) + }) + + t.Run("passes - proof chain attenuates command", func(t *testing.T) { + t.Parallel() + + testPasses(t, didtest.PersonaFrank, delegationtest.AttenuatedCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank_ValidAttenuatedCommand) + }) + + t.Run("passes - invocation attenuates command", func(t *testing.T) { + t.Parallel() + + testPasses(t, didtest.PersonaFrank, delegationtest.AttenuatedCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank) + }) + + t.Run("fails - no proof", func(t *testing.T) { + t.Parallel() + + testFails(t, invocation.ErrNoProof, didtest.PersonaCarol, delegationtest.NominalCommand, emptyArguments, delegationtest.ProofEmpty) + }) + + t.Run("fails - missing referenced delegation", func(t *testing.T) { + t.Parallel() + + missingTknCID, err := cid.Parse(missingTknCIDStr) + require.NoError(t, err) + + prf := []cid.Cid{missingTknCID, delegationtest.TokenAliceBobCID} + testFails(t, invocation.ErrMissingDelegation, didtest.PersonaCarol, delegationtest.NominalCommand, emptyArguments, prf) + }) + + t.Run("fails - referenced delegation expired", func(t *testing.T) { + t.Parallel() + + testFails(t, invocation.ErrTokenInvalidNow, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank_InvalidExpired) + + }) + + t.Run("fails - referenced delegation inactive", func(t *testing.T) { + t.Parallel() + + testFails(t, invocation.ErrTokenInvalidNow, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank_InvalidInactive) + }) + + t.Run("fails - last (or only) delegation not root", func(t *testing.T) { + t.Parallel() + + prf := []cid.Cid{delegationtest.TokenErinFrankCID, delegationtest.TokenDanErinCID, delegationtest.TokenCarolDanCID} + testFails(t, invocation.ErrLastNotRoot, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, prf) + }) + + t.Run("fails - broken chain", func(t *testing.T) { + t.Parallel() + + prf := []cid.Cid{delegationtest.TokenCarolDanCID, delegationtest.TokenAliceBobCID} + testFails(t, invocation.ErrBrokenChain, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, prf) + }) + + t.Run("fails - first not issued to invoker", func(t *testing.T) { + t.Parallel() + + prf := []cid.Cid{delegationtest.TokenBobCarolCID, delegationtest.TokenAliceBobCID} + testFails(t, invocation.ErrBrokenChain, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, prf) + }) + + t.Run("fails - proof chain expands command", func(t *testing.T) { + t.Parallel() + + testFails(t, invocation.ErrCommandNotCovered, didtest.PersonaFrank, delegationtest.NominalCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank_InvalidExpandedCommand) + }) + + t.Run("fails - invocation expands command", func(t *testing.T) { + t.Parallel() + + testFails(t, invocation.ErrCommandNotCovered, didtest.PersonaFrank, delegationtest.ExpandedCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank) + }) + + t.Run("fails - inconsistent subject", func(t *testing.T) { + t.Parallel() + + testFails(t, invocation.ErrWrongSub, didtest.PersonaFrank, delegationtest.ExpandedCommand, emptyArguments, delegationtest.ProofAliceBobCarolDanErinFrank_InvalidSubject) + }) +} + +func test(t *testing.T, persona didtest.Persona, cmd command.Command, args *args.Args, prf []cid.Cid, opts ...invocation.Option) error { + t.Helper() + + // TODO: use the args and add minimal test to check that they are verified against the policy + + tkn, err := invocation.New(persona.DID(), didtest.PersonaAlice.DID(), cmd, prf, opts...) + require.NoError(t, err) + + return tkn.ExecutionAllowed(delegationtest.GetDelegationLoader()) +} + +func testFails(t *testing.T, expErr error, persona didtest.Persona, cmd command.Command, args *args.Args, prf []cid.Cid, opts ...invocation.Option) { + err := test(t, persona, cmd, args, prf, opts...) + require.ErrorIs(t, err, expErr) +} + +func testPasses(t *testing.T, persona didtest.Persona, cmd command.Command, args *args.Args, prf []cid.Cid, opts ...invocation.Option) { + err := test(t, persona, cmd, args, prf, opts...) + require.NoError(t, err) +} diff --git a/token/invocation/proof.go b/token/invocation/proof.go new file mode 100644 index 0000000..a61e022 --- /dev/null +++ b/token/invocation/proof.go @@ -0,0 +1,141 @@ +package invocation + +import ( + "fmt" + "time" + + "github.com/ucan-wg/go-ucan/pkg/args" + "github.com/ucan-wg/go-ucan/pkg/policy" + "github.com/ucan-wg/go-ucan/token/delegation" +) + +// # Invocation token validation +// +// Per the specification, invocation Tokens must be validated before the command is executed. +// This validation effectively happens in multiple places in the codebase. +// Steps 1 and 2 are the same for all token types. +// +// 1. When a token is read/unsealed from its containing envelope (`envelope` package): +// a. The envelope can be decoded. +// b. The envelope contains a Signature, VarsigHeader and Payload. +// c. The Payload contains an iss field that contains a valid `did:key`. +// d. The public key can be extracted from the `did:key`. +// e. The public key type is supported by go-ucan. +// f. The Signature can be decoded per the VarsigHeader. +// g. The SigPayload can be verified using the Signature and public key. +// h. The field key of the TokenPayload matches the expected tag. +// +// 2. When the token is created or passes step one (token constructor or decoder): +// a. All required fields are present +// b. All populated fields respect their own rules (example: a policy is legal) +// +// 3. When an unsealed invocation passes steps one and two for execution (verifyTimeBound below): +// a. The invocation cannot be expired (expiration in the future or absent). +// b. All the delegation must not be expired (expiration in the future or absent). +// c. All the delegation must be active (nbf in the past or absent). +// +// 4. When the proof chain is being validated (verifyProofs below): +// a. There must be at least one delegation in the proof chain. +// b. All referenced delegations must be available. +// c. The first proof must be issued to the Invoker (audience DID). +// d. The Issuer of each delegation must be the Audience in the next one. +// e. The last token must be a root delegation. +// f. The Subject of each delegation must equal the invocation's Audience field. +// g. The command of each delegation must "allow" the one before it. +// +// 5. If steps 1-4 pass: +// a. The policy must "match" the arguments. (verifyArgs below) +// b. The nonce (if present) is not reused. (out of scope for go-ucan) + +// verifyProofs controls that the proof chain allows the invocation: +// - principal alignment +// - command alignment +func (t *Token) verifyProofs(delegations []*delegation.Token) error { + // There must be at least one delegation referenced - 4a + if len(delegations) < 1 { + return ErrNoProof + } + + cmd := t.command + iss := t.issuer + aud := t.audience + if !aud.Defined() { + aud = t.subject + } + + // control from the invocation to the root + for i, dlgCid := range t.proof { + dlg := delegations[i] + + // The Subject of each delegation must equal the invocation's Audience field. - 4f + if dlg.Subject() != aud { + return fmt.Errorf("%w: delegation %s, expected %s, got %s", ErrWrongSub, dlgCid, aud, dlg.Subject()) + } + + // The first proof must be issued to the Invoker (audience DID). - 4c + // The Issuer of each delegation must be the Audience in the next one. - 4d + if dlg.Audience() != iss { + return fmt.Errorf("%w: delegation %s, expected %s, got %s", ErrBrokenChain, dlgCid, iss, dlg.Audience()) + } + iss = dlg.Issuer() + + // The command of each delegation must "allow" the one before it. - 4g + if !dlg.Command().Covers(cmd) { + return fmt.Errorf("%w: delegation %s, %s doesn't cover %s", ErrCommandNotCovered, dlgCid, dlg.Command(), cmd) + } + cmd = dlg.Command() + } + + // The last prf value must be a root delegation (have the issuer field match the Subject field) - 4e + if last := delegations[len(delegations)-1]; last.Issuer() != last.Subject() { + return fmt.Errorf("%w: expected %s, got %s", ErrLastNotRoot, last.Subject(), last.Issuer()) + } + + return nil +} + +func (t *Token) verifyTimeBound(dlgs []*delegation.Token) error { + return t.verifyTimeBoundAt(time.Now(), dlgs) +} + +func (t *Token) verifyTimeBoundAt(at time.Time, delegations []*delegation.Token) error { + // The invocation cannot be expired (expiration in the future or absent). - 3a + if !t.IsValidAt(at) { + return fmt.Errorf("%w: invocation", ErrTokenInvalidNow) + } + + for i, dlgCid := range t.proof { + dlg := delegations[i] + + // All the delegation must not be expired (expiration in the future or absent). - 3b + // All the delegation must be active (nbf in the past or absent). - 3c + if !dlg.IsValidAt(at) { + return fmt.Errorf("%w: delegation %s", ErrTokenInvalidNow, dlgCid) + } + } + return nil +} + +func (t *Token) verifyArgs(delegations []*delegation.Token, arguments *args.Args) error { + var count int + for i := range t.proof { + count += len(delegations[i].Policy()) + } + + policies := make(policy.Policy, 0, count) + for i := range t.proof { + policies = append(policies, delegations[i].Policy()...) + } + + argsIpld, err := arguments.ToIPLD() + if err != nil { + return err + } + + ok, statement := policies.Match(argsIpld) + if !ok { + return fmt.Errorf("the following UCAN policy is not satisfied: %v", statement.String()) + } + + return nil +} diff --git a/token/invocation/schema.go b/token/invocation/schema.go index d51cf4f..5a30e66 100644 --- a/token/invocation/schema.go +++ b/token/invocation/schema.go @@ -25,17 +25,17 @@ const Tag = "ucan/inv@1.0.0-rc.1" var schemaBytes []byte var ( - once sync.Once - ts *schema.TypeSystem - err error + once sync.Once + ts *schema.TypeSystem + errSchema error ) func mustLoadSchema() *schema.TypeSystem { once.Do(func() { - ts, err = ipld.LoadSchemaBytes(schemaBytes) + ts, errSchema = ipld.LoadSchemaBytes(schemaBytes) }) - if err != nil { - panic(fmt.Errorf("failed to load IPLD schema: %s", err)) + if errSchema != nil { + panic(fmt.Errorf("failed to load IPLD schema: %s", errSchema)) } return ts }