From 0f705573092c76723fe234f4beee06099d961b36 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 12 Nov 2024 15:16:43 -0500 Subject: [PATCH] feat(invocation): validate delegation proof chain --- token/invocation/errors.go | 32 +++ token/invocation/invocation.go | 102 ++++++- token/invocation/invocation_test.go | 334 +++++++++++++++++++++++ token/invocation/invocationtest/field.go | 39 +++ 4 files changed, 503 insertions(+), 4 deletions(-) create mode 100644 token/invocation/errors.go create mode 100644 token/invocation/invocation_test.go create mode 100644 token/invocation/invocationtest/field.go diff --git a/token/invocation/errors.go b/token/invocation/errors.go new file mode 100644 index 0000000..e58a8d1 --- /dev/null +++ b/token/invocation/errors.go @@ -0,0 +1,32 @@ +package invocation + +import "errors" + +var ( + // ErrDelegationExpired is returned if one of the delegations in the + // proof chain has expired. + ErrDelegationExpired = errors.New("delegation in proof chain has expired") + + // ErrDelegationInactive is returned if one of the delegations in the + // proof chain is not yet active. + ErrDelegationInactive = errors.New("delegation in proof chain not yet active") + + // 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") + + // ErrMissingDelegation + ErrMissingDelegation = errors.New("loader missing delegation for proof chain") + + // 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") + + // ErrNotIssuedToInvoder is returned if the first delegation token's + // Audience DID is not the Invoker's Issuer DID. + ErrNotIssuedToInvoker = errors.New("first delegation token is not issued to invoker") + + // ErrBrokenChain is returned when the Audience of each delegation is + // not the Issuer of the previous one. + ErrBrokenChain = errors.New("delegation proof chain is broken") +) diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 4b67dfa..8135229 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -3,6 +3,45 @@ // from the [envelope]-enclosed, signed and DAG-CBOR-encoded form that // should most commonly be used for transport and storage. // +// # Invocation token validation +// +// Per the specification, invocation Tokens must be validated before the +// command is executed. This validation can happen in multiple stages: +// +// 1. When the invocation is unsealed from its containing envelope: +// 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 a 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 invocation is created or passes step one: +// a. The Issuer field is not empty. +// b. The Subject field is not empty +// c. The Command field is not empty and the Command is not a wildcard. +// d. The Policy field is present (but may be empty). +// e. The Arguments field is present (but may be empty). +// 3. When an unsealed invocation passes steps one and two for execution: +// a. The invocation can not be expired. +// b. Invoked at should not be in the future. +// 4. When the proof chain is being validated: +// 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 token must not be expired (expiration in the future or absent). +// e. The token must be active (nbf in the past or absent). +// f. The Issuer of each delegation must be the Audience in the next +// one. +// g. The last token must be a root delegation. +// h. The Subject of each delegation must equal the invocation's +// Audience field. +// i. The command of each delegation must "allow" the one before it. +// 5. If steps 1-4 pass: +// a. The policy must "match" the arguments. +// b. The nonce (if present) is not reused. +// // [envelope]: https://github.com/ucan-wg/spec#envelope // [invocation]: https://github.com/ucan-wg/invocation package invocation @@ -104,16 +143,71 @@ type DelegationLoader interface { GetDelegation(cid cid.Cid) (*delegation.Token, error) } -func (t *Token) ExecutionAllowed(loader DelegationLoader) bool { +func (t *Token) ExecutionAllowed(loader DelegationLoader) (bool, error) { return t.executionAllowed(loader, t.arguments) } -func (t *Token) ExecutionAllowedWithArgsHook(loader DelegationLoader, hook func(*args.Args) *args.Args) bool { +func (t *Token) ExecutionAllowedWithArgsHook(loader DelegationLoader, hook func(*args.Args) *args.Args) (bool, error) { return t.executionAllowed(loader, hook(t.arguments)) } -func (t *Token) executionAllowed(loader DelegationLoader, arguments *args.Args) bool { - panic("TODO") +func (t *Token) executionAllowed(loader DelegationLoader, arguments *args.Args) (bool, error) { + // There must be at least one delegation referenced - 4a + if len(t.proof) < 1 { + return false, ErrNoProof + } + + type chainer interface { + Issuer() did.DID + Subject() did.DID // TODO: if the invocation token's Audience is nil, copy the subject into it + Command() command.Command + } + + // This starts as the invocation token but will be the root delegation + // after the for loop below completes + var lastChainer chainer = t + + for i, dlgCid := range t.proof { + // The token must be present - 4b + dlg, err := loader.GetDelegation(dlgCid) + if err != nil { + return false, fmt.Errorf("%w: need %s", ErrMissingDelegation, dlgCid) + } + + // No tokens in the proof chain may be expired - 4d + if dlg.Expiration() != nil && dlg.Expiration().Before(time.Now()) { + return false, fmt.Errorf("%w: CID is %s", ErrDelegationExpired, dlgCid) + } + + // No tokens in the proof chain may be inactive - 4e + if dlg.NotBefore() != nil && dlg.NotBefore().After(time.Now()) { + return false, fmt.Errorf("%w: CID is %s", ErrDelegationInactive, dlgCid) + } + + // First proof must have the invoker's Issuer as the Audience - 4c + if i == 0 && dlg.Audience() != t.Issuer() { + return false, fmt.Errorf("%w: expected %s, got %s", ErrNotIssuedToInvoker, t.issuer, dlg.Audience()) + } + + // Tokens must form a chain with current issuer equal to the + // next audience - 4f + if lastChainer.Issuer() != dlg.Audience() { + return false, fmt.Errorf("%w: expected %s, got %s", ErrBrokenChain, lastChainer.Issuer(), dlg.Audience()) + } + + // TODO: Checking the subject consistency can happen here - 4h + // TODO: Checking the command equivalence or attenuation can happen here - 4i + + lastChainer = dlg + } + + // The last prf value must be a root delegation (have the issuer field + // match the Subject field) - 4g + if lastChainer.Issuer() != lastChainer.Subject() { + return false, fmt.Errorf("%w: expected %s, got %s", ErrLastNotRoot, lastChainer.Subject(), lastChainer.Issuer()) + } + + return true, nil } // Issuer returns the did.DID representing the Token's issuer. diff --git a/token/invocation/invocation_test.go b/token/invocation/invocation_test.go new file mode 100644 index 0000000..93e967e --- /dev/null +++ b/token/invocation/invocation_test.go @@ -0,0 +1,334 @@ +package invocation_test + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/ipfs/go-cid" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/require" + "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/policy" + "github.com/ucan-wg/go-ucan/token/delegation" + "github.com/ucan-wg/go-ucan/token/invocation" + "github.com/ucan-wg/go-ucan/token/invocation/invocationtest" + + "github.com/stretchr/testify/assert" +) + +const ( + rootPrivKeyCfg = "CAESQNMjVXyblHUrtmvdzjfkdrUlLkxh/cHZMFirki7dJLFjW7cCs74VxjO8Wh04f8Xg0uqyKw7N0wTUBiPy2h4hGPQ=" + rootDIDStr = "did:key:z6MkkdH136Kee5akqx1DiK3hE3WGx4SKmHo11tYw2cWmjjZV" + rootTknCIDStr = "bafyreibk6lkgd32zldpqqqsdn7diyosokelrhder5lic4ujadtxl5blkei" + + rootOnlyTknCIDStr = "bafyreidyknlfnsah63jujdkhe5vvjil2yoznlgoaezeq62qqhghaxzpfya" + + dlg0PrivKeyCfg = "CAESQFIb3aD0lZzidUzTtwdpHtCyx1VJxe+Uq4x/S+XQFDDgz/NZIi/TR3rhgUn550RSBOSxNmw0QnR0FOPmAB7SXAg=" + dlg0DIDStr = "did:key:z6MktT1f5LXQ2MFSUwwTTY9DDU2QdBzZA11V5bjou4YDQY6K" + dlg0TknCIDStr = "bafyreihocbstcdvgyeczjoijiyo2bdppyplm2aglqumwtutyapd2zlp2bi" + + expiredTknDagJson = `[{"/":{"bytes":"U4IH1Q52UrdOyOHkHtXSkH0uf5ouk10k/LTOMz3UvP2k1kqv9/rbGXUwhQCy6JP3s8hc+U3h/lBXYFYzIlULAw"}},{"h":{"/":{"bytes":"NO0BcQ"}},"ucan/dlg@1.0.0-rc.1":{"aud":"did:key:z6MkqTkYrRV6WpFCWRcEHBXnTV3cVqzMtVZREhJKAAswrrGq","cmd":"/seg0","exp":1731439015,"iss":"did:key:z6MktT1f5LXQ2MFSUwwTTY9DDU2QdBzZA11V5bjou4YDQY6K","nonce":{"/":{"bytes":"AAECAwQFBgcICQoL"}},"pol":[],"sub":"did:key:z6MkkdH136Kee5akqx1DiK3hE3WGx4SKmHo11tYw2cWmjjZV"}}]` + expiredDlg0TknCIDStr = "bafyreigezbrohmjmzv5nxdw2nhjabnrvs52vcv4uvbuscnbyr2jzep2vru" + + inactiveDlg0TknCIDStr = "bafyreiffflxvtfiv722i3lomrqcogc6nncxai3voxeltvrphpuwecdfbgq" + + dlg1PrivKeyCfg = "CAESQPHkXp4OatPxpJ1veMAxEYzP4rd3/sPUz9PRQuJaDKTco5DJTdJC5iXxjCe1VDYAmRwlJOBvBdbsvS3qFgV6zoI=" + dlg1DIDStr = "did:key:z6MkqTkYrRV6WpFCWRcEHBXnTV3cVqzMtVZREhJKAAswrrGq" + dlg1TknCIDStr = "bafyreihjbkvom3tdivzolh6aieuwamz4x6bu3dh667bxytdc5vzo37obo4" + + // dlg2PrivKeyCfg = "CAESQJryJWe3uGt+7BTH2doqsN+2H83rNXv7Yw0tMoKE+lBKTqByESll668G1Jiy9gW/8hm9/jVcrFD9Y1BWyokfNBE=" + // dlg2DIDStr = "did:key:z6MkjkBk9hzMhcr6rYMXHPEsXy1LAWK5dHniNdbaEPojGXr8" + + // dlg3PrivKeyCfg = "CAESQPrLMlX2p+Dgz70YCyVXbHAJfVMT7lLUYAbuZ1rBKQmBLiD7WJ4Spc4VFZAsJ7HUnkneJWNTk/FFaN2z3pb/OZI=" + // dlg3DIDStr = "did:key:z6MkhZKy9X4sLtbH1fGQmPVMjXmBAEQ3vAN6DRSfebSBCqpu" + + invPrivKeyCfg = "CAESQHJW8WYTZDRzxjLBjrFN35raIGvVsPoXAJB/5X+J8miboVWVLZFyQmxCAIXOMpwLqWW7R2I98qsCGvxgEJZ5qgY=" + invDIDStr = "did:key:z6MkqK3NgTnZZo77iptQdU9proJn1ozMmcTSKR98t8sZzJAq" + invTknCIDStr = "" + + missingPrivKeyCfg = "CAESQMjRvrEIjpPYRQKmkAGw/pV0XgE958rYa4vlnKJjl1zz/sdnGnyV1xKLJk8D39edyjhHWyqcpgFnozQK62SG16k=" + missingDIDStr = "bafyreigwypmw6eul6vadi6g6lnfbsfo2zck7gfzsbjoroqs3djhnzzc7mm" + missingTknCIDStr = "did:key:z6MkwboxFsH3kEuehBZ5fLkRmxi68yv1u38swA4r9Jm2VRma" +) + +func TestToken_ExecutionAllowed(t *testing.T) { + t.Parallel() + + t.Run("passes - only root", func(t *testing.T) { + t.Parallel() + + args := invocationtest.EmptyArguments + prf := invocationtest.Proof(t, rootOnlyTknCIDStr) + testPasses(t, []string{"seg0"}, args, prf) + }) + + t.Run("passes - valid chain", func(t *testing.T) { + t.Parallel() + + args := invocationtest.EmptyArguments + prf := invocationtest.Proof(t, dlg1TknCIDStr, dlg0TknCIDStr, rootTknCIDStr) + testPasses(t, []string{"seg0"}, args, prf) + }) + + t.Run("fails - no proof", func(t *testing.T) { + t.Parallel() + + args := args.New() + testFails(t, invocation.ErrNoProof, []string{"seg0"}, args, []cid.Cid{}) + }) + + t.Run("fails - missing referenced delegation", func(t *testing.T) { + t.Parallel() + + args := invocationtest.EmptyArguments + prf := invocationtest.Proof(t, missingDIDStr, rootTknCIDStr) + testFails(t, invocation.ErrMissingDelegation, []string{"seg0"}, args, prf) + }) + + t.Run("fails - referenced delegation expired", func(t *testing.T) { + t.Parallel() + + args := invocationtest.EmptyArguments + prf := invocationtest.Proof(t, dlg1TknCIDStr, expiredDlg0TknCIDStr, rootTknCIDStr) + testFails(t, invocation.ErrDelegationExpired, []string{"seg0"}, args, prf) + }) + + t.Run("fails - referenced delegation inactive", func(t *testing.T) { + t.Parallel() + + args := invocationtest.EmptyArguments + prf := invocationtest.Proof(t, dlg1TknCIDStr, inactiveDlg0TknCIDStr, rootTknCIDStr) + testFails(t, invocation.ErrDelegationInactive, []string{"seg0"}, args, prf) + }) + + t.Run("fails - last (or only) delegation not root", func(t *testing.T) { + t.Parallel() + + args := args.New() + prf := invocationtest.Proof(t, dlg1TknCIDStr) + testFails(t, invocation.ErrLastNotRoot, []string{"seg0"}, args, prf) + }) + + t.Run("fails - broken chain", func(t *testing.T) { + t.Parallel() + + args := invocationtest.EmptyArguments + prf := invocationtest.Proof(t, dlg1TknCIDStr, rootTknCIDStr) + testFails(t, invocation.ErrBrokenChain, []string{"seg0"}, args, prf) + }) + + t.Run("fails - first not issued to invoker", func(t *testing.T) { + t.Parallel() + + args := invocationtest.EmptyArguments + prf := invocationtest.Proof(t, dlg0TknCIDStr, rootTknCIDStr) + testFails(t, invocation.ErrNotIssuedToInvoker, []string{"seg0"}, args, prf) + }) +} + +func test(t *testing.T, cmdSegs []string, args *args.Args, prf []cid.Cid, opts ...invocation.Option) (bool, error) { + ldr := newTestDelegationLoader(t) + tkn := testInvocation(t, invPrivKeyCfg, rootDIDStr, rootDIDStr, cmdSegs, args, prf, opts...) + + return tkn.ExecutionAllowed(ldr) +} + +func testFails(t *testing.T, expErr error, cmdSegs []string, args *args.Args, prf []cid.Cid, opts ...invocation.Option) { + t.Helper() + + ok, err := test(t, cmdSegs, args, prf, opts...) + require.ErrorIs(t, err, expErr) + assert.False(t, ok) +} + +func testPasses(t *testing.T, cmdSegs []string, args *args.Args, prf []cid.Cid, opts ...invocation.Option) { + ok, err := test(t, cmdSegs, args, prf, opts...) + require.NoError(t, err) + assert.True(t, ok) +} + +var _ invocation.DelegationLoader = (*testDelegationLoader)(nil) + +type testDelegationLoader struct { + tkns map[cid.Cid]*delegation.Token +} + +func newTestDelegationLoader(t *testing.T) *testDelegationLoader { + t.Helper() + + cmdSegs := []string{"seg0"} + ldr := &testDelegationLoader{ + tkns: map[cid.Cid]*delegation.Token{}, + } + + // aud, err := did.Parse(dlg0DIDStr) + // require.NoError(t, err) + // cmd := command.New(cmdSegs...) + + // pol, err := policy.FromDagJson("[]") + // require.NoError(t, err) + + // rootDlg, err := delegation.Root(rootPrivKey, aud, cmd, pol) + // require.NoError(t, err) + // _, rootCID, err := rootDlg.ToSealed(rootPrivKey) + // require.NoError(t, err) + + // Do not add this one to the loader + _, missingCID := testDelegation(t, missingPrivKeyCfg, dlg1DIDStr, rootDIDStr, cmdSegs, "[]") + t.Log("Missing CID", missingCID) + + rootTkn, rootDlgCID := testDelegation(t, rootPrivKeyCfg, dlg0DIDStr, rootDIDStr, cmdSegs, "[]") + t.Log("Root chain CID:", rootDlgCID) + ldr.tkns[rootDlgCID] = rootTkn + + rootOnlyTkn, rootOnlyDlgCID := testDelegation(t, rootPrivKeyCfg, invDIDStr, rootDIDStr, cmdSegs, "[]") + t.Log("Root only chain CID:", rootOnlyDlgCID) + ldr.tkns[rootOnlyDlgCID] = rootOnlyTkn + + dlg0Tkn, dlg0CID := testDelegation(t, dlg0PrivKeyCfg, dlg1DIDStr, rootDIDStr, cmdSegs, "[]") + t.Log("Dlg0 CID:", dlg0CID) + ldr.tkns[dlg0CID] = dlg0Tkn + + // exp := time.Now().Add(time.Second) + + // expiredDlg0Tkn, expiredDlg0CID := testDelegation(t, dlg0PrivKeyCfg, dlg1DIDStr, rootDIDStr, cmdSegs, "[]", delegation.WithExpiration(exp)) + // t.Log("Expired Dlg0 CID:", expiredDlg0CID) + + // dlg0PrivKeyEnc, err := crypto.ConfigDecodeKey(dlg0PrivKeyCfg) + // require.NoError(t, err) + // dlg0PrivKey, err := crypto.UnmarshalPrivateKey(dlg0PrivKeyEnc) + // require.NoError(t, err) + + // dlg0DagJson, err := expiredDlg0Tkn.ToDagJson(dlg0PrivKey) + // require.NoError(t, err) + + // t.Log("Expired token DAGJSON:", string(dlg0DagJson)) + + expiredDlg0Tkn, err := delegation.FromDagJson([]byte(expiredTknDagJson)) + require.NoError(t, err) + expiredDlg0TknCID, err := cid.Parse(expiredDlg0TknCIDStr) + ldr.tkns[expiredDlg0TknCID] = expiredDlg0Tkn + + nbf, err := time.Parse(time.RFC3339, "2500-01-01T00:00:00Z") + require.NoError(t, err) + + inactiveDlg0Tkn, inactiveDlg0CID := testDelegation(t, dlg0PrivKeyCfg, dlg1DIDStr, rootDIDStr, cmdSegs, "[]", delegation.WithNotBefore(nbf)) + t.Log("Inactive Dlg0 CID:", inactiveDlg0CID) + ldr.tkns[inactiveDlg0CID] = inactiveDlg0Tkn + + dlg1Tkn, dlg1CID := testDelegation(t, dlg1PrivKeyCfg, invDIDStr, rootDIDStr, cmdSegs, "[]") + t.Log("Dlg1 CID:", dlg1CID) + ldr.tkns[dlg1CID] = dlg1Tkn + + // for i := 0; i < 5; i++ { + // privKey, id, err := did.GenerateEd25519() + // require.NoError(t, err) + + // privKeyEnc, err := crypto.MarshalPrivateKey(privKey) + // require.NoError(t, err) + + // t.Log("PrivKey:", crypto.ConfigEncodeKey(privKeyEnc), "DID:", id) + + // } + + t.Log("Delegation loader length:", len(ldr.tkns)) + + return ldr +} + +var ErrUnknownDelegationTokenCID = errors.New("unknown delegation token CID") + +func (l *testDelegationLoader) GetDelegation(c cid.Cid) (*delegation.Token, error) { + tkn, ok := l.tkns[c] + if !ok { + return nil, fmt.Errorf("%w: %s", ErrUnknownDelegationTokenCID, c.String()) + } + + return tkn, nil +} + +func testDelegation(t *testing.T, privKeyCfg string, audStr string, subStr string, cmdSegs []string, polDAGJSON string, opts ...delegation.Option) (*delegation.Token, cid.Cid) { + t.Helper() + + privKey, aud, sub, cmd := parseTokenArgs(t, privKeyCfg, audStr, subStr, cmdSegs) + + pol, err := policy.FromDagJson(polDAGJSON) + require.NoError(t, err) + + opts = append( + opts, + delegation.WithNonce([]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b}), + delegation.WithSubject(sub), + ) + + tkn, err := delegation.New(privKey, aud, cmd, pol, opts...) + require.NoError(t, err) + + _, id, err := tkn.ToSealed(privKey) + require.NoError(t, err) + + return tkn, id +} + +func testInvocation(t *testing.T, privKeyCfg string, audStr string, subStr string, cmdSegs []string, args *args.Args, prf []cid.Cid, opts ...invocation.Option) *invocation.Token { + t.Helper() + + privKey, _, sub, cmd := parseTokenArgs(t, privKeyCfg, audStr, subStr, cmdSegs) + + iss, err := did.FromPrivKey(privKey) + require.NoError(t, err) + + tkn, err := invocation.New(iss, sub, cmd, prf) + require.NoError(t, err) + + return tkn +} + +func testProof(t *testing.T, cidStrs ...string) []cid.Cid { + var prf []cid.Cid + + for cidStr := range cidStrs { + id, err := cid.Parse(cidStr) + require.NoError(t, err) + + prf = append(prf, id) + } + + return prf +} + +func parseTokenArgs( + t *testing.T, + privKeyCfg string, + audStr string, + subStr string, + cmdSegs []string, +) ( + privKey crypto.PrivKey, + aud did.DID, + sub did.DID, + cmd command.Command, +) { + t.Helper() + + var err error + + privKeyEnc, err := crypto.ConfigDecodeKey(privKeyCfg) + require.NoError(t, err) + privKey, err = crypto.UnmarshalPrivateKey(privKeyEnc) + require.NoError(t, err) + + aud, err = did.Parse(audStr) + require.NoError(t, err) + + sub, err = did.Parse(subStr) + require.NoError(t, err) + + cmd = command.New(cmdSegs...) + + return +} diff --git a/token/invocation/invocationtest/field.go b/token/invocation/invocationtest/field.go new file mode 100644 index 0000000..1d31ccf --- /dev/null +++ b/token/invocation/invocationtest/field.go @@ -0,0 +1,39 @@ +package invocationtest + +import ( + "testing" + + "github.com/ipfs/go-cid" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/pkg/args" + "github.com/ucan-wg/go-ucan/pkg/policy" +) + +var ( + EmptyArguments = args.New() + EmptyPolicy = emptyPolicy() +) + +func emptyPolicy() policy.Policy { + pol, err := policy.FromDagJson("[]") + if err != nil { + panic(err) + } + + return pol +} + +func Proof(t *testing.T, cidStrs ...string) []cid.Cid { + // t.Helper() + + prf := make([]cid.Cid, len(cidStrs)) + + for i, cidStr := range cidStrs { + id, err := cid.Parse(cidStr) + require.NoError(t, err) + + prf[i] = id + } + + return prf +}