feat(invocation): validate delegation proof chain

This commit is contained in:
Steve Moyer
2024-11-12 15:16:43 -05:00
committed by Michael Muré
parent 89e4d5d419
commit 0f70557309
4 changed files with 503 additions and 4 deletions

View File

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

View File

@@ -3,6 +3,45 @@
// from the [envelope]-enclosed, signed and DAG-CBOR-encoded form that // from the [envelope]-enclosed, signed and DAG-CBOR-encoded form that
// should most commonly be used for transport and storage. // 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 // [envelope]: https://github.com/ucan-wg/spec#envelope
// [invocation]: https://github.com/ucan-wg/invocation // [invocation]: https://github.com/ucan-wg/invocation
package invocation package invocation
@@ -104,16 +143,71 @@ type DelegationLoader interface {
GetDelegation(cid cid.Cid) (*delegation.Token, error) 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) 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)) return t.executionAllowed(loader, hook(t.arguments))
} }
func (t *Token) executionAllowed(loader DelegationLoader, arguments *args.Args) bool { func (t *Token) executionAllowed(loader DelegationLoader, arguments *args.Args) (bool, error) {
panic("TODO") // 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. // Issuer returns the did.DID representing the Token's issuer.

View File

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

View File

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