Repository: theupdateframework/go-tuf
Pull request #138 (2021-07-20 14:13:13)

Add the ability to sign with an external tool (Dump signable content + append signature)
Allows users to sign content with an external tool. Required
* Making `SignedMeta` public to access the signable content as the payload to sign
* AppendSignature: adds a signature to the metadata. Checks that this is a valid key for the role and replaces existing sigs with the same ID
* Adding a method to add a verification key, similar to the python reference implementation. This adds the public key data for a role to the root metadata without needing the private key

Signed-off-by: Asra Ali <asraa@google.com>

coveralls: ## Pull Request Test Coverage Report for [Build 1049158805](https://coveralls.io/builds/41508480)

* **30** of **47**   **(63.83%)**  changed or added relevant lines in **1** file are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.08%**) to **67.311%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [repo.go](https://coveralls.io/builds/41508480/source?filename=repo.go#L348) | 30 | 47 | 63.83%
<!-- | **Total:** | **30** | **47** | **63.83%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/41508480/badge)](https://coveralls.io/builds/41508480) |
| :-- | --: |
| Change from base [Build 987132054](https://coveralls.io/builds/41013434): |  -0.08% |
| Covered Lines: | 1602 |
| Relevant Lines: | 2380 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/client/client.go b/client/client.go
index 129ec31..991fc5c 100644
--- a/client/client.go
+++ b/client/client.go
@@ -18,6 +18,7 @@ const (
 	// big it is.
 	defaultRootDownloadLimit      = 512000
 	defaultTimestampDownloadLimit = 16384
+	defaultMaxDelegations         = 32
 )
 
 // LocalStore is local storage for downloaded top-level metadata.
@@ -81,12 +82,17 @@ type Client struct {
 	// consistentSnapshot indicates whether the remote storage is using
 	// consistent snapshots (as specified in root.json)
 	consistentSnapshot bool
+
+	// MaxDelegations limits by default the number of delegations visited for any
+	// target
+	MaxDelegations int
 }
 
 func NewClient(local LocalStore, remote RemoteStore) *Client {
 	return &Client{
-		local:  local,
-		remote: remote,
+		local:          local,
+		remote:         remote,
+		MaxDelegations: defaultMaxDelegations,
 	}
 }
 
@@ -189,16 +195,18 @@ func (c *Client) update(latestRoot bool) (data.TargetFiles, error) {
 		return nil, ErrLatestSnapshot{c.snapshotVer}
 	}
 
-	// Get snapshot.json, then extract root.json and targets.json file meta.
+	// Get snapshot.json, then extract file metas.
 	//
-	// The snapshot.json is only saved locally after checking root.json and
+	// The snapshot.json is only saved locally after checking
 	// targets.json so that it will be re-downloaded on subsequent updates
 	// if this update fails.
+	// root.json meta should not be stored in the snapshot, if it is,
+	// the root will be checked, re-downloaded
 	snapshotJSON, err := c.downloadMetaFromTimestamp("snapshot.json", snapshotMeta)
 	if err != nil {
 		return nil, err
 	}
-	rootMeta, targetsMeta, err := c.decodeSnapshot(snapshotJSON)
+	snapshotMetas, err := c.decodeSnapshot(snapshotJSON)
 	if err != nil {
 		// ErrRoleThreshold could indicate snapshot keys have been
 		// revoked, so retry with the latest root.json
@@ -210,13 +218,17 @@ func (c *Client) update(latestRoot bool) (data.TargetFiles, error) {
 
 	// If we don't have the root.json, download it, save it in local
 	// storage and restart the update
-	if !c.hasMetaFromSnapshot("root.json", rootMeta) {
-		return c.updateWithLatestRoot(&rootMeta)
+	// Root should no longer be pinned in snapshot meta https://github.com/theupdateframework/tuf/pull/988
+	if rootMeta, ok := snapshotMetas["root.json"]; ok {
+		if !c.hasMetaFromSnapshot("root.json", rootMeta) {
+			return c.updateWithLatestRoot(&rootMeta)
+		}
 	}
 
 	// If we don't have the targets.json, download it, determine updated
 	// targets and save targets.json in local storage
 	var updatedTargets data.TargetFiles
+	targetsMeta := snapshotMetas["targets.json"]
 	if !c.hasMetaFromSnapshot("targets.json", targetsMeta) {
 		targetsJSON, err := c.downloadMetaFromSnapshot("targets.json", targetsMeta)
 		if err != nil {
@@ -539,13 +551,13 @@ func (c *Client) decodeRoot(b json.RawMessage) error {
 
 // decodeSnapshot decodes and verifies snapshot metadata, and returns the new
 // root and targets file meta.
-func (c *Client) decodeSnapshot(b json.RawMessage) (data.SnapshotFileMeta, data.SnapshotFileMeta, error) {
+func (c *Client) decodeSnapshot(b json.RawMessage) (data.SnapshotFiles, error) {
 	snapshot := &data.Snapshot{}
 	if err := c.db.Unmarshal(b, snapshot, "snapshot", c.snapshotVer); err != nil {
-		return data.SnapshotFileMeta{}, data.SnapshotFileMeta{}, ErrDecodeFailed{"snapshot.json", err}
+		return data.SnapshotFiles{}, ErrDecodeFailed{"snapshot.json", err}
 	}
 	c.snapshotVer = snapshot.Version
-	return snapshot.Meta["root.json"], snapshot.Meta["targets.json"], nil
+	return snapshot.Meta, nil
 }
 
 // decodeTargets decodes and verifies targets metadata, sets c.targets and
@@ -582,18 +594,24 @@ func (c *Client) decodeTimestamp(b json.RawMessage) (data.TimestampFileMeta, err
 	return timestamp.Meta["snapshot.json"], nil
 }
 
-// hasSnapshotMeta checks whether local metadata has the given meta
+// hasMetaFromSnapshot checks whether local metadata has the given meta
 func (c *Client) hasMetaFromSnapshot(name string, m data.SnapshotFileMeta) bool {
+	_, ok := c.localMetaFromSnapshot(name, m)
+	return ok
+}
+
+// localMetaFromSnapshot returns localmetadata if it matches the snapshot
+func (c *Client) localMetaFromSnapshot(name string, m data.SnapshotFileMeta) (json.RawMessage, bool) {
 	b, ok := c.localMeta[name]
 	if !ok {
-		return false
+		return nil, false
 	}
 	meta, err := util.GenerateSnapshotFileMeta(bytes.NewReader(b), m.HashAlgorithms()...)
 	if err != nil {
-		return false
+		return nil, false
 	}
 	err = util.SnapshotFileMetaEqual(meta, m)
-	return err == nil
+	return b, err == nil
 }
 
 // hasTargetsMeta checks whether local metadata has the given snapshot meta
@@ -634,7 +652,8 @@ type Destination interface {
 // dest will be deleted and an error returned in the following situations:
 //
 //   * The target does not exist in the local targets.json
-//   * The target does not exist in remote storage
+//   * Failed to fetch the chain of delegations accessible from local snapshot.json
+//   * The target does not exist in any targets
 //   * Metadata cannot be generated for the downloaded data
 //   * Generated metadata does not match local metadata for the given file
 func (c *Client) Download(name string, dest Destination) (err error) {
@@ -652,11 +671,14 @@ func (c *Client) Download(name string, dest Destination) (err error) {
 		}
 	}
 
-	// return ErrUnknownTarget if the file is not in the local targets.json
 	normalizedName := util.NormalizeTarget(name)
 	localMeta, ok := c.targets[normalizedName]
 	if !ok {
-		return ErrUnknownTarget{name}
+		// search in delegations
+		localMeta, err = c.getTargetFileMeta(normalizedName)
+		if err != nil {
+			return err
+		}
 	}
 
 	// get the data from remote storage
diff --git a/client/delegations.go b/client/delegations.go
new file mode 100644
index 0000000..9bc6d41
--- /dev/null
+++ b/client/delegations.go
@@ -0,0 +1,190 @@
+package client
+
+import (
+	"github.com/theupdateframework/go-tuf/data"
+	"github.com/theupdateframework/go-tuf/verify"
+)
+
+// getTargetFileMeta searches for a verified TargetFileMeta matching a target
+// Requires a local snapshot to be loaded and is locked to the snapshot versions.
+// Searches through delegated targets following TUF spec 1.0.19 section 5.6.
+func (c *Client) getTargetFileMeta(target string) (data.TargetFileMeta, error) {
+	snapshot, err := c.loadLocalSnapshot()
+	if err != nil {
+		return data.TargetFileMeta{}, err
+	}
+
+	// delegationsIterator covers 5.6.7
+	// - pre-order depth-first search starting with the top targets
+	// - filter delegations with paths or path_hash_prefixes matching searched target
+	// - 5.6.7.1 cycles protection
+	// - 5.6.7.2 terminations
+	delegations := newDelegationsIterator(target)
+	for i := 0; i < c.MaxDelegations; i++ {
+		d, ok := delegations.next()
+		if !ok {
+			return data.TargetFileMeta{}, ErrUnknownTarget{target, snapshot.Version}
+		}
+
+		// covers 5.6.{1,2,3,4,5,6}
+		targets, err := c.loadDelegatedTargets(snapshot, d.delegatee.Name, d.verifier)
+		if err != nil {
+			return data.TargetFileMeta{}, err
+		}
+
+		// stop when the searched TargetFileMeta is found
+		if m, ok := targets.Targets[target]; ok {
+			return m, nil
+		}
+
+		if targets.Delegations != nil {
+			delegationsVerifier, err := verify.NewDelegationsVerifier(targets.Delegations)
+			if err != nil {
+				return data.TargetFileMeta{}, err
+			}
+			err = delegations.add(targets.Delegations.Roles, d.delegatee.Name, delegationsVerifier)
+			if err != nil {
+				return data.TargetFileMeta{}, err
+			}
+		}
+	}
+
+	return data.TargetFileMeta{}, ErrMaxDelegations{
+		Target:          target,
+		MaxDelegations:  c.MaxDelegations,
+		SnapshotVersion: snapshot.Version,
+	}
+}
+
+func (c *Client) loadLocalSnapshot() (*data.Snapshot, error) {
+	if err := c.getLocalMeta(); err != nil {
+		return nil, err
+	}
+
+	rawS, ok := c.localMeta["snapshot.json"]
+	if !ok {
+		return nil, ErrNoLocalSnapshot
+	}
+
+	snapshot := &data.Snapshot{}
+	if err := c.db.Unmarshal(rawS, snapshot, "snapshot", c.snapshotVer); err != nil {
+		return nil, ErrDecodeFailed{"snapshot.json", err}
+	}
+	return snapshot, nil
+}
+
+// loadDelegatedTargets downloads, decodes, verifies and stores targets
+func (c *Client) loadDelegatedTargets(snapshot *data.Snapshot, role string, verifier verify.DelegationsVerifier) (*data.Targets, error) {
+	var err error
+	fileName := role + ".json"
+	fileMeta, ok := snapshot.Meta[fileName]
+	if !ok {
+		return nil, ErrRoleNotInSnapshot{role, snapshot.Version}
+	}
+
+	// 5.6.1 download target if not in the local store
+	// 5.6.2 check against snapshot hash
+	// 5.6.4 check against snapshot version
+	raw, alreadyStored := c.localMetaFromSnapshot(fileName, fileMeta)
+	if !alreadyStored {
+		raw, err = c.downloadMetaFromSnapshot(fileName, fileMeta)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	targets := &data.Targets{}
+	// 5.6.3 verify signature with parent public keys
+	// 5.6.5 verify that the targets is not expired
+	// role "targets" is a top role verified by root keys loaded in the client db
+	if role == "targets" {
+		err = c.db.Unmarshal(raw, targets, role, fileMeta.Version)
+	} else {
+		err = verifier.Unmarshal(raw, targets, role, fileMeta.Version)
+	}
+	if err != nil {
+		return nil, ErrDecodeFailed{fileName, err}
+	}
+
+	// 5.6.6 persist
+	if !alreadyStored {
+		if err := c.local.SetMeta(fileName, raw); err != nil {
+			return nil, err
+		}
+	}
+	return targets, nil
+}
+
+type delegation struct {
+	delegator string
+	verifier  verify.DelegationsVerifier
+	delegatee data.DelegatedRole
+}
+
+type delegationsIterator struct {
+	stack        []delegation
+	target       string
+	visitedRoles map[string]struct{}
+}
+
+// newDelegationsIterator initialises an iterator with a first step
+// on top level targets
+func newDelegationsIterator(target string) *delegationsIterator {
+	i := &delegationsIterator{
+		target: target,
+		stack: []delegation{
+			{
+				delegatee: data.DelegatedRole{Name: "targets"},
+			},
+		},
+		visitedRoles: make(map[string]struct{}),
+	}
+	return i
+}
+
+func (d *delegationsIterator) next() (value delegation, ok bool) {
+	if len(d.stack) == 0 {
+		return delegation{}, false
+	}
+	delegation := d.stack[len(d.stack)-1]
+	d.stack = d.stack[:len(d.stack)-1]
+
+	// 5.6.7.1: If this role has been visited before, then skip this role (so
+	// that cycles in the delegation graph are avoided).
+	roleName := delegation.delegatee.Name
+	if _, ok := d.visitedRoles[roleName]; ok {
+		return d.next()
+	}
+	d.visitedRoles[roleName] = struct{}{}
+
+	// 5.6.7.2 trim delegations to visit, only the current role and its delegations
+	// will be considered
+	// https://github.com/theupdateframework/specification/issues/168
+	if delegation.delegatee.Terminating {
+		// Empty the stack.
+		d.stack = d.stack[0:0]
+	}
+	return delegation, true
+}
+
+func (d *delegationsIterator) add(roles []data.DelegatedRole, delegator string, verifier verify.DelegationsVerifier) error {
+	for i := len(roles) - 1; i >= 0; i-- {
+		// Push the roles onto the stack in reverse so we get an preorder traversal
+		// of the delegations graph.
+		r := roles[i]
+		matchesPath, err := r.MatchesPath(d.target)
+		if err != nil {
+			return err
+		}
+		if matchesPath {
+			delegation := delegation{
+				delegator: delegator,
+				delegatee: r,
+				verifier:  verifier,
+			}
+			d.stack = append(d.stack, delegation)
+		}
+	}
+
+	return nil
+}
diff --git a/client/errors.go b/client/errors.go
index e9997f5..723a6ea 100644
--- a/client/errors.go
+++ b/client/errors.go
@@ -10,6 +10,7 @@ import (
 var (
 	ErrNoRootKeys       = errors.New("tuf: no root keys found in local meta store")
 	ErrInsufficientKeys = errors.New("tuf: insufficient keys to meet threshold")
+	ErrNoLocalSnapshot  = errors.New("tuf: no snapshot stored locally")
 )
 
 type ErrMissingRemoteMetadata struct {
@@ -38,6 +39,16 @@ func (e ErrDecodeFailed) Error() string {
 	return fmt.Sprintf("tuf: failed to decode %s: %s", e.File, e.Err)
 }
 
+type ErrMaxDelegations struct {
+	Target          string
+	MaxDelegations  int
+	SnapshotVersion int
+}
+
+func (e ErrMaxDelegations) Error() string {
+	return fmt.Sprintf("tuf: max delegation of %d reached searching for %s with snapshot version %d", e.MaxDelegations, e.Target, e.SnapshotVersion)
+}
+
 func isDecodeFailedWithErrRoleThreshold(err error) bool {
 	e, ok := err.(ErrDecodeFailed)
 	if !ok {
@@ -88,11 +99,12 @@ func IsLatestSnapshot(err error) bool {
 }
 
 type ErrUnknownTarget struct {
-	Name string
+	Name            string
+	SnapshotVersion int
 }
 
 func (e ErrUnknownTarget) Error() string {
-	return fmt.Sprintf("tuf: unknown target file: %s", e.Name)
+	return fmt.Sprintf("tuf: unknown target file: %s with snapshot version %d", e.Name, e.SnapshotVersion)
 }
 
 type ErrMetaTooLarge struct {
@@ -112,3 +124,12 @@ type ErrInvalidURL struct {
 func (e ErrInvalidURL) Error() string {
 	return fmt.Sprintf("tuf: invalid repository URL %s", e.URL)
 }
+
+type ErrRoleNotInSnapshot struct {
+	Role            string
+	SnapshotVersion int
+}
+
+func (e ErrRoleNotInSnapshot) Error() string {
+	return fmt.Sprintf("tuf: role %s not in snapshot version %d", e.Role, e.SnapshotVersion)
+}
diff --git a/data/hex_bytes.go b/data/hex_bytes.go
index 5dee51c..ec20041 100644
--- a/data/hex_bytes.go
+++ b/data/hex_bytes.go
@@ -1,6 +1,7 @@
 package data
 
 import (
+	"crypto/sha256"
 	"encoding/hex"
 	"errors"
 )
@@ -31,3 +32,11 @@ func (b HexBytes) MarshalJSON() ([]byte, error) {
 func (b HexBytes) String() string {
 	return hex.EncodeToString(b)
 }
+
+// 4.5. File formats: targets.json and delegated target roles:
+// ...each target path, when hashed with the SHA-256 hash function to produce
+// a 64-byte hexadecimal digest (HEX_DIGEST)...
+func PathHexDigest(s string) string {
+	b := sha256.Sum256([]byte(s))
+	return hex.EncodeToString(b[:])
+}
diff --git a/data/types.go b/data/types.go
index 27519f5..ff231e8 100644
--- a/data/types.go
+++ b/data/types.go
@@ -4,6 +4,9 @@ import (
 	"crypto/sha256"
 	"encoding/hex"
 	"encoding/json"
+	"errors"
+	"path/filepath"
+	"strings"
 	"sync"
 	"time"
 
@@ -19,7 +22,8 @@ const (
 )
 
 var (
-	KeyAlgorithms = []string{"sha256", "sha512"}
+	KeyAlgorithms            = []string{"sha256", "sha512"}
+	ErrPathsAndPathHashesSet = errors.New("tuf: failed validation of delegated target: paths and path_hash_prefixes are both set")
 )
 
 type Signed struct {
@@ -208,11 +212,93 @@ func (f TargetFileMeta) HashAlgorithms() []string {
 }
 
 type Targets struct {
-	Type        string      `json:"_type"`
-	SpecVersion string      `json:"spec_version"`
-	Version     int         `json:"version"`
-	Expires     time.Time   `json:"expires"`
-	Targets     TargetFiles `json:"targets"`
+	Type        string       `json:"_type"`
+	SpecVersion string       `json:"spec_version"`
+	Version     int          `json:"version"`
+	Expires     time.Time    `json:"expires"`
+	Targets     TargetFiles  `json:"targets"`
+	Delegations *Delegations `json:"delegations,omitempty"`
+}
+
+// Delegations represents the edges from a parent Targets role to one or more
+// delegated target roles. See spec v1.0.19 section 4.5.
+type Delegations struct {
+	Keys  map[string]*Key `json:"keys"`
+	Roles []DelegatedRole `json:"roles"`
+}
+
+// DelegatedRole describes a delegated role, including what paths it is
+// reponsible for. See spec v1.0.19 section 4.5.
+type DelegatedRole struct {
+	Name             string   `json:"name"`
+	KeyIDs           []string `json:"keyids"`
+	Threshold        int      `json:"threshold"`
+	Terminating      bool     `json:"terminating"`
+	PathHashPrefixes []string `json:"path_hash_prefixes,omitempty"`
+	Paths            []string `json:"paths"`
+}
+
+// MatchesPath evaluates whether the path patterns or path hash prefixes match
+// a given file. This determines whether a delegated role is responsible for
+// signing and verifying the file.
+func (d *DelegatedRole) MatchesPath(file string) (bool, error) {
+	if err := d.validatePaths(); err != nil {
+		return false, err
+	}
+
+	for _, pattern := range d.Paths {
+		if matched, _ := filepath.Match(pattern, file); matched {
+			return true, nil
+		}
+	}
+
+	pathHash := PathHexDigest(file)
+	for _, hashPrefix := range d.PathHashPrefixes {
+		if strings.HasPrefix(pathHash, hashPrefix) {
+			return true, nil
+		}
+	}
+
+	return false, nil
+}
+
+// validatePaths enforces the spec
+// https://theupdateframework.github.io/specification/v1.0.19/index.html#file-formats-targets
+// 'role MUST specify only one of the "path_hash_prefixes" or "paths"'
+// Marshalling and unmarshalling JSON will fail and return
+// ErrPathsAndPathHashesSet if both fields are set and not empty.
+func (d *DelegatedRole) validatePaths() error {
+	if len(d.PathHashPrefixes) > 0 && len(d.Paths) > 0 {
+		return ErrPathsAndPathHashesSet
+	}
+
+	return nil
+}
+
+// MarshalJSON is called when writing the struct to JSON. We validate prior to
+// marshalling to ensure that an invalid delegated role can not be serialized
+// to JSON.
+func (d *DelegatedRole) MarshalJSON() ([]byte, error) {
+	type delegatedRoleAlias DelegatedRole
+
+	if err := d.validatePaths(); err != nil {
+		return nil, err
+	}
+
+	return json.Marshal((*delegatedRoleAlias)(d))
+}
+
+// UnmarshalJSON is called when reading the struct from JSON. We validate once
+// unmarshalled to ensure that an error is thrown if an invalid delegated role
+// is read.
+func (d *DelegatedRole) UnmarshalJSON(b []byte) error {
+	type delegatedRoleAlias DelegatedRole
+
+	if err := json.Unmarshal(b, (*delegatedRoleAlias)(d)); err != nil {
+		return err
+	}
+
+	return d.validatePaths()
 }
 
 func NewTargets() *Targets {
diff --git a/go.mod b/go.mod
index a353992..47b7218 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/dustin/go-humanize v1.0.0
 	github.com/flynn/go-docopt v0.0.0-20140912013429-f6dd2ebbb31e
 	github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
+	github.com/stretchr/testify v1.7.0 // indirect
 	github.com/syndtr/goleveldb v1.0.0
 	github.com/tent/canonical-json-go v0.0.0-20130607151641-96e4ba3a7613
 	golang.org/x/crypto v0.0.0-20190424203555-c05e17bb3b2d
diff --git a/go.sum b/go.sum
index 9a22576..7dd8a82 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/flynn/go-docopt v0.0.0-20140912013429-f6dd2ebbb31e h1:Ss/B3/5wWRh8+emnK0++g5zQzwDTi30W10pKxKc4JXI=
@@ -15,6 +17,11 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA
 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
 github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
 github.com/tent/canonical-json-go v0.0.0-20130607151641-96e4ba3a7613 h1:iGnD/q9160NWqKZZ5vY4p0dMiYMRknzctfSkqA4nBDw=
@@ -36,3 +43,5 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8
 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/local_store.go b/local_store.go
index f16c9da..ca2d0a0 100644
--- a/local_store.go
+++ b/local_store.go
@@ -50,7 +50,7 @@ func (m *memoryStore) SetMeta(name string, meta json.RawMessage) error {
 	return nil
 }
 
-func (m *memoryStore) WalkStagedTargets(paths []string, targetsFn targetsWalkFunc) error {
+func (m *memoryStore) WalkStagedTargets(paths []string, targetsFn TargetsWalkFunc) error {
 	if len(paths) == 0 {
 		for path, data := range m.files {
 			if err := targetsFn(path, bytes.NewReader(data)); err != nil {
@@ -166,7 +166,7 @@ func (f *fileSystemStore) createDirs() error {
 	return nil
 }
 
-func (f *fileSystemStore) WalkStagedTargets(paths []string, targetsFn targetsWalkFunc) error {
+func (f *fileSystemStore) WalkStagedTargets(paths []string, targetsFn TargetsWalkFunc) error {
 	if len(paths) == 0 {
 		walkFunc := func(path string, info os.FileInfo, err error) error {
 			if err != nil {
diff --git a/repo.go b/repo.go
index 15ada8b..969097c 100644
--- a/repo.go
+++ b/repo.go
@@ -36,20 +36,33 @@ var snapshotManifests = []string{
 	"targets.json",
 }
 
-type targetsWalkFunc func(path string, target io.Reader) error
+// TargetsWalkFunc is a function of a target path name and a target payload used to
+// execute some function on each staged target file. For example, it may normalize path
+// names and generate target file metadata with additional custom metadata.
+type TargetsWalkFunc func(path string, target io.Reader) error
 
 type LocalStore interface {
+	// GetMeta returns a map from manifest file names (e.g. root.json) to their raw JSON payload or an error.
 	GetMeta() (map[string]json.RawMessage, error)
+
+	// SetMeta is used to update a manifest file name with a JSON payload.
 	SetMeta(string, json.RawMessage) error
 
 	// WalkStagedTargets calls targetsFn for each staged target file in paths.
 	//
 	// If paths is empty, all staged target files will be walked.
-	WalkStagedTargets(paths []string, targetsFn targetsWalkFunc) error
+	WalkStagedTargets(paths []string, targetsFn TargetsWalkFunc) error
 
+	// Commit is used to publish staged files to the repository
 	Commit(bool, map[string]int, map[string]data.Hashes) error
+
+	// GetSigningKeys return a list of signing keys for a role.
 	GetSigningKeys(string) ([]sign.Signer, error)
+
+	// SavePrivateKey adds a signing key to a role.
 	SavePrivateKey(string, *sign.PrivateKey) error
+
+	// Clean is used to remove all staged manifests.
 	Clean() error
 }
 
@@ -322,11 +335,6 @@ func (r *Repo) AddPrivateKey(role string, key *sign.PrivateKey) error {
 }
 
 func (r *Repo) AddPrivateKeyWithExpires(keyRole string, key *sign.PrivateKey, expires time.Time) error {
-	root, err := r.root()
-	if err != nil {
-		return err
-	}
-
 	if !verify.ValidRole(keyRole) {
 		return ErrInvalidRole{keyRole}
 	}
@@ -340,6 +348,19 @@ func (r *Repo) AddPrivateKeyWithExpires(keyRole string, key *sign.PrivateKey, ex
 	}
 	pk := key.PublicData()
 
+	return r.AddVerificationKeyWithExpiration(keyRole, pk, expires)
+}
+
+func (r *Repo) AddVerificationKey(keyRole string, pk *data.Key) error {
+	return r.AddVerificationKeyWithExpiration(keyRole, pk, data.DefaultExpires(keyRole))
+}
+
+func (r *Repo) AddVerificationKeyWithExpiration(keyRole string, pk *data.Key, expires time.Time) error {
+	root, err := r.root()
+	if err != nil {
+		return err
+	}
+
 	role, ok := root.Roles[keyRole]
 	if !ok {
 		role = &data.Role{KeyIDs: []string{}, Threshold: 1}
@@ -479,8 +500,8 @@ func (r *Repo) jsonMarshal(v interface{}) ([]byte, error) {
 	return out.Bytes(), nil
 }
 
-func (r *Repo) setMeta(name string, meta interface{}) error {
-	keys, err := r.getSigningKeys(strings.TrimSuffix(name, ".json"))
+func (r *Repo) setMeta(roleFilename string, meta interface{}) error {
+	keys, err := r.getSigningKeys(strings.TrimSuffix(roleFilename, ".json"))
 	if err != nil {
 		return err
 	}
@@ -492,17 +513,17 @@ func (r *Repo) setMeta(name string, meta interface{}) error {
 	if err != nil {
 		return err
 	}
-	r.meta[name] = b
-	return r.local.SetMeta(name, b)
+	r.meta[roleFilename] = b
+	return r.local.SetMeta(roleFilename, b)
 }
 
-func (r *Repo) Sign(name string) error {
-	role := strings.TrimSuffix(name, ".json")
+func (r *Repo) Sign(roleFilename string) error {
+	role := strings.TrimSuffix(roleFilename, ".json")
 	if !verify.ValidRole(role) {
 		return ErrInvalidRole{role}
 	}
 
-	s, err := r.signedMeta(name)
+	s, err := r.SignedMeta(roleFilename)
 	if err != nil {
 		return err
 	}
@@ -512,7 +533,7 @@ func (r *Repo) Sign(name string) error {
 		return err
 	}
 	if len(keys) == 0 {
-		return ErrInsufficientKeys{name}
+		return ErrInsufficientKeys{roleFilename}
 	}
 	for _, k := range keys {
 		sign.Sign(s, k)
@@ -522,8 +543,61 @@ func (r *Repo) Sign(name string) error {
 	if err != nil {
 		return err
 	}
-	r.meta[name] = b
-	return r.local.SetMeta(name, b)
+	r.meta[roleFilename] = b
+	return r.local.SetMeta(roleFilename, b)
+}
+
+// AddOrUpdateSignature allows users to add or update a signature generated with an external tool.
+// The name must be a valid manifest name, like root.json.
+func (r *Repo) AddOrUpdateSignature(roleFilename string, signature data.Signature) error {
+	role := strings.TrimSuffix(roleFilename, ".json")
+	if !verify.ValidRole(role) {
+		return ErrInvalidRole{role}
+	}
+
+	// Check key ID is in valid for the role.
+	db, err := r.db()
+	if err != nil {
+		return err
+	}
+	roleData := db.GetRole(role)
+	if roleData == nil {
+		return ErrInvalidRole{role}
+	}
+	if !roleData.ValidKey(signature.KeyID) {
+		return verify.ErrInvalidKey
+	}
+
+	s, err := r.SignedMeta(roleFilename)
+	if err != nil {
+		return err
+	}
+
+	// Add or update signature.
+	signatures := make([]data.Signature, 0, len(s.Signatures)+1)
+	for _, sig := range s.Signatures {
+		if sig.KeyID != signature.KeyID {
+			signatures = append(signatures, sig)
+		}
+	}
+	signatures = append(signatures, signature)
+	s.Signatures = signatures
+
+	// Check signature on signed meta. Ignore threshold errors as this may not be fully
+	// signed.
+	if err := db.VerifySignatures(s, role); err != nil {
+		if _, ok := err.(verify.ErrRoleThreshold); !ok {
+			return err
+		}
+	}
+
+	b, err := r.jsonMarshal(s)
+	if err != nil {
+		return err
+	}
+	r.meta[roleFilename] = b
+
+	return r.local.SetMeta(roleFilename, b)
 }
 
 // getSigningKeys returns available signing keys.
@@ -562,10 +636,11 @@ func (r *Repo) getSigningKeys(name string) ([]sign.Signer, error) {
 	return keys, nil
 }
 
-func (r *Repo) signedMeta(name string) (*data.Signed, error) {
-	b, ok := r.meta[name]
+// Used to retrieve the signable portion of the metadata when using an external signing tool.
+func (r *Repo) SignedMeta(roleFilename string) (*data.Signed, error) {
+	b, ok := r.meta[roleFilename]
 	if !ok {
-		return nil, ErrMissingMetadata{name}
+		return nil, ErrMissingMetadata{roleFilename}
 	}
 	s := &data.Signed{}
 	if err := json.Unmarshal(b, s); err != nil {
@@ -574,9 +649,9 @@ func (r *Repo) signedMeta(name string) (*data.Signed, error) {
 	return s, nil
 }
 
-func validManifest(name string) bool {
+func validManifest(roleFilename string) bool {
 	for _, m := range topLevelManifests {
-		if m == name {
+		if m == roleFilename {
 			return true
 		}
 	}
@@ -889,30 +964,30 @@ func (r *Repo) Clean() error {
 	return r.local.Clean()
 }
 
-func (r *Repo) verifySignature(name string, db *verify.DB) error {
-	s, err := r.signedMeta(name)
+func (r *Repo) verifySignature(roleFilename string, db *verify.DB) error {
+	s, err := r.SignedMeta(roleFilename)
 	if err != nil {
 		return err
 	}
-	role := strings.TrimSuffix(name, ".json")
+	role := strings.TrimSuffix(roleFilename, ".json")
 	if err := db.Verify(s, role, 0); err != nil {
-		return ErrInsufficientSignatures{name, err}
+		return ErrInsufficientSignatures{roleFilename, err}
 	}
 	return nil
 }
 
-func (r *Repo) snapshotFileMeta(name string) (data.SnapshotFileMeta, error) {
-	b, ok := r.meta[name]
+func (r *Repo) snapshotFileMeta(roleFilename string) (data.SnapshotFileMeta, error) {
+	b, ok := r.meta[roleFilename]
 	if !ok {
-		return data.SnapshotFileMeta{}, ErrMissingMetadata{name}
+		return data.SnapshotFileMeta{}, ErrMissingMetadata{roleFilename}
 	}
 	return util.GenerateSnapshotFileMeta(bytes.NewReader(b), r.hashAlgorithms...)
 }
 
-func (r *Repo) timestampFileMeta(name string) (data.TimestampFileMeta, error) {
-	b, ok := r.meta[name]
+func (r *Repo) timestampFileMeta(roleFilename string) (data.TimestampFileMeta, error) {
+	b, ok := r.meta[roleFilename]
 	if !ok {
-		return data.TimestampFileMeta{}, ErrMissingMetadata{name}
+		return data.TimestampFileMeta{}, ErrMissingMetadata{roleFilename}
 	}
 	return util.GenerateTimestampFileMeta(bytes.NewReader(b), r.hashAlgorithms...)
 }
diff --git a/verify/db.go b/verify/db.go
index 8bc80a3..7c6e91e 100644
--- a/verify/db.go
+++ b/verify/db.go
@@ -26,6 +26,39 @@ func NewDB() *DB {
 	}
 }
 
+type DelegationsVerifier struct {
+	DB *DB
+}
+
+func (d *DelegationsVerifier) Unmarshal(b []byte, v interface{}, role string, minVersion int) error {
+	return d.DB.Unmarshal(b, v, role, minVersion)
+}
+
+// NewDelegationsVerifier returns a DelegationsVerifier that verifies delegations
+// of a given Targets. It reuses the DB struct to leverage verified keys, roles
+// unmarshals.
+func NewDelegationsVerifier(d *data.Delegations) (DelegationsVerifier, error) {
+	db := &DB{
+		roles: make(map[string]*Role, len(d.Roles)),
+		keys:  make(map[string]*data.Key, len(d.Keys)),
+	}
+	for _, r := range d.Roles {
+		if _, ok := topLevelRoles[r.Name]; ok {
+			return DelegationsVerifier{}, ErrInvalidDelegatedRole
+		}
+		role := &data.Role{Threshold: r.Threshold, KeyIDs: r.KeyIDs}
+		if err := db.addRole(r.Name, role); err != nil {
+			return DelegationsVerifier{}, err
+		}
+	}
+	for id, k := range d.Keys {
+		if err := db.AddKey(id, k); err != nil {
+			return DelegationsVerifier{}, err
+		}
+	}
+	return DelegationsVerifier{db}, nil
+}
+
 func (db *DB) AddKey(id string, k *data.Key) error {
 	v, ok := Verifiers[k.Type]
 	if !ok {
@@ -43,22 +76,31 @@ func (db *DB) AddKey(id string, k *data.Key) error {
 	return nil
 }
 
-var validRoles = map[string]struct{}{
+var topLevelRoles = map[string]struct{}{
 	"root":      {},
 	"targets":   {},
 	"snapshot":  {},
 	"timestamp": {},
 }
 
+// ValidRole checks if a role is a top level role.
 func ValidRole(name string) bool {
-	_, ok := validRoles[name]
+	return isTopLevelRole(name)
+}
+
+func isTopLevelRole(name string) bool {
+	_, ok := topLevelRoles[name]
 	return ok
 }
 
 func (db *DB) AddRole(name string, r *data.Role) error {
-	if !ValidRole(name) {
+	if !isTopLevelRole(name) {
 		return ErrInvalidRole
 	}
+	return db.addRole(name, r)
+}
+
+func (db *DB) addRole(name string, r *data.Role) error {
 	if r.Threshold < 1 {
 		return ErrInvalidThreshold
 	}
diff --git a/verify/errors.go b/verify/errors.go
index 6e4587b..78556c7 100644
--- a/verify/errors.go
+++ b/verify/errors.go
@@ -7,16 +7,17 @@ import (
 )
 
 var (
-	ErrMissingKey       = errors.New("tuf: missing key")
-	ErrNoSignatures     = errors.New("tuf: data has no signatures")
-	ErrInvalid          = errors.New("tuf: signature verification failed")
-	ErrWrongMethod      = errors.New("tuf: invalid signature type")
-	ErrWrongMetaType    = errors.New("tuf: meta file has wrong type")
-	ErrExists           = errors.New("tuf: key already in db")
-	ErrInvalidKey       = errors.New("tuf: invalid key")
-	ErrInvalidRole      = errors.New("tuf: invalid role")
-	ErrInvalidKeyID     = errors.New("tuf: invalid key id")
-	ErrInvalidThreshold = errors.New("tuf: invalid role threshold")
+	ErrMissingKey           = errors.New("tuf: missing key")
+	ErrNoSignatures         = errors.New("tuf: data has no signatures")
+	ErrInvalid              = errors.New("tuf: signature verification failed")
+	ErrWrongMethod          = errors.New("tuf: invalid signature type")
+	ErrWrongMetaType        = errors.New("tuf: meta file has wrong type")
+	ErrExists               = errors.New("tuf: key already in db")
+	ErrInvalidKey           = errors.New("tuf: invalid key")
+	ErrInvalidRole          = errors.New("tuf: invalid role")
+	ErrInvalidDelegatedRole = errors.New("tuf: invalid delegated role")
+	ErrInvalidKeyID         = errors.New("tuf: invalid key id")
+	ErrInvalidThreshold     = errors.New("tuf: invalid role threshold")
 )
 
 type ErrWrongID struct{}
diff --git a/verify/verify.go b/verify/verify.go
index eb618ae..b6d4162 100644
--- a/verify/verify.go
+++ b/verify/verify.go
@@ -24,12 +24,24 @@ func (db *DB) Verify(s *data.Signed, role string, minVersion int) error {
 	if err := json.Unmarshal(s.Signed, sm); err != nil {
 		return err
 	}
-	if strings.ToLower(sm.Type) != strings.ToLower(role) {
-		return ErrWrongMetaType
+
+	if isTopLevelRole(role) {
+		// Top-level roles can only sign metadata of the same type (e.g. snapshot
+		// metadata must be signed by the snapshot role).
+		if strings.ToLower(sm.Type) != strings.ToLower(role) {
+			return ErrWrongMetaType
+		}
+	} else {
+		// Delegated (non-top-level) roles may only sign targets metadata.
+		if strings.ToLower(sm.Type) != "targets" {
+			return ErrWrongMetaType
+		}
 	}
+
 	if IsExpired(sm.Expires) {
 		return ErrExpired{sm.Expires}
 	}
+
 	if sm.Version < minVersion {
 		return ErrLowVersion{sm.Version, minVersion}
 	}


Test patch:
diff --git a/client/client_test.go b/client/client_test.go
index 39bb019..a8ca536 100644
--- a/client/client_test.go
+++ b/client/client_test.go
@@ -804,7 +804,7 @@ func (t *testDestination) Delete() error {
 func (s *ClientSuite) TestDownloadUnknownTarget(c *C) {
 	client := s.updatedClient(c)
 	var dest testDestination
-	c.Assert(client.Download("nonexistent", &dest), Equals, ErrUnknownTarget{"nonexistent"})
+	c.Assert(client.Download("nonexistent", &dest), Equals, ErrUnknownTarget{Name: "nonexistent", SnapshotVersion: 1})
 	c.Assert(dest.deleted, Equals, true)
 }
 
diff --git a/client/delegations_test.go b/client/delegations_test.go
new file mode 100644
index 0000000..ff9b1de
--- /dev/null
+++ b/client/delegations_test.go
@@ -0,0 +1,481 @@
+package client
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net"
+	"net/http"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/theupdateframework/go-tuf/data"
+	"github.com/theupdateframework/go-tuf/verify"
+)
+
+var (
+	defaultPathPatterns = []string{"tmp", "*"}
+	noMatchPathPatterns = []string{"vars", "null"}
+)
+
+func TestDelegationsIterator(t *testing.T) {
+	var iteratorTests = []struct {
+		testName    string
+		roles       map[string][]data.DelegatedRole
+		file        string
+		resultOrder []string
+		err         error
+	}{
+		{
+			testName: "no termination",
+			roles: map[string][]data.DelegatedRole{
+				"targets": {
+					{Name: "b", Paths: defaultPathPatterns},
+					{Name: "e", Paths: defaultPathPatterns},
+				},
+				"b": {
+					{Name: "c", Paths: defaultPathPatterns},
+				},
+				"c": {
+					{Name: "d", Paths: defaultPathPatterns},
+				},
+				"e": {
+					{Name: "f", Paths: defaultPathPatterns},
+					{Name: "g", Paths: defaultPathPatterns},
+				},
+				"g": {
+					{Name: "h", Paths: defaultPathPatterns},
+					{Name: "i", Paths: defaultPathPatterns},
+					{Name: "j", Paths: defaultPathPatterns},
+				},
+			},
+			file:        "",
+			resultOrder: []string{"targets", "b", "c", "d", "e", "f", "g", "h", "i", "j"},
+		},
+		{
+			testName: "terminated in b",
+			roles: map[string][]data.DelegatedRole{
+				"targets": {
+					{Name: "b", Paths: defaultPathPatterns, Terminating: true},
+					{Name: "e", Paths: defaultPathPatterns},
+				},
+				"b": {
+					{Name: "c", Paths: defaultPathPatterns},
+					{Name: "d", Paths: defaultPathPatterns},
+				},
+			},
+			file:        "",
+			resultOrder: []string{"targets", "b", "c", "d"},
+		},
+		{
+			testName: "path does not match b",
+			roles: map[string][]data.DelegatedRole{
+				"targets": {
+					{Name: "b", Paths: noMatchPathPatterns},
+					{Name: "e", Paths: defaultPathPatterns},
+				},
+				"b": {
+					{Name: "c", Paths: defaultPathPatterns},
+					{Name: "d", Paths: defaultPathPatterns},
+				},
+			},
+			file:        "",
+			resultOrder: []string{"targets", "e"},
+		},
+		{
+			testName: "path does not match b - path prefixes",
+			roles: map[string][]data.DelegatedRole{
+				"targets": {
+					{Name: "b", PathHashPrefixes: []string{"33472a4909"}},
+					{Name: "c", PathHashPrefixes: []string{"34c85d1ee84f61f10d7dc633"}},
+				},
+				"c": {
+					{Name: "d", PathHashPrefixes: []string{"8baf"}},
+					{Name: "e", PathHashPrefixes: []string{"34c85d1ee84f61f10d7dc633472a49096ed87f8f764bd597831eac371f40ac39"}},
+				},
+			},
+			file:        "/e/f/g.txt",
+			resultOrder: []string{"targets", "c", "e"},
+		},
+		{
+			testName: "err paths and pathHashPrefixes are set",
+			roles: map[string][]data.DelegatedRole{
+				"targets": {
+					{Name: "b", Paths: defaultPathPatterns, PathHashPrefixes: defaultPathPatterns},
+				},
+				"b": {},
+			},
+			file:        "",
+			resultOrder: []string{"targets"},
+			err:         data.ErrPathsAndPathHashesSet,
+		},
+		{
+			testName: "cycle avoided 1",
+			roles: map[string][]data.DelegatedRole{
+				"targets": {
+					{Name: "b", Paths: defaultPathPatterns},
+					{Name: "e", Paths: defaultPathPatterns},
+				},
+				"b": {
+					{Name: "targets", Paths: defaultPathPatterns},
+					{Name: "d", Paths: defaultPathPatterns},
+				},
+			},
+			file:        "",
+			resultOrder: []string{"targets", "b", "d", "e"},
+		},
+		{
+			testName: "cycle avoided 2",
+			roles: map[string][]data.DelegatedRole{
+				"targets": {
+					{Name: "targets", Paths: defaultPathPatterns},
+					{Name: "b", Paths: defaultPathPatterns},
+				},
+				"b": {
+					{Name: "targets", Paths: defaultPathPatterns},
+					{Name: "b", Paths: defaultPathPatterns},
+					{Name: "c", Paths: defaultPathPatterns},
+				},
+				"c": {
+					{Name: "c", Paths: defaultPathPatterns},
+				},
+			},
+			file:        "",
+			resultOrder: []string{"targets", "b", "c"},
+		},
+		{
+			testName: "diamond delegation",
+			roles: map[string][]data.DelegatedRole{
+				"targets": {
+					{Name: "b", Paths: defaultPathPatterns},
+					{Name: "c", Paths: defaultPathPatterns},
+				},
+				"b": {
+					{Name: "d", Paths: defaultPathPatterns},
+				},
+				"c": {
+					{Name: "d", Paths: defaultPathPatterns},
+				},
+			},
+			file:        "",
+			resultOrder: []string{"targets", "b", "d", "c"},
+		},
+		{
+			testName: "simple cycle",
+			roles: map[string][]data.DelegatedRole{
+				"targets": {
+					{Name: "a", Paths: defaultPathPatterns},
+				},
+				"a": {
+					{Name: "a", Paths: defaultPathPatterns},
+				},
+			},
+			file:        "",
+			resultOrder: []string{"targets", "a"},
+		},
+	}
+
+	for _, tt := range iteratorTests {
+		t.Run(tt.testName, func(t *testing.T) {
+			d := newDelegationsIterator(tt.file)
+			var iterationOrder []string
+			for {
+				r, ok := d.next()
+				if !ok {
+					break
+				}
+				iterationOrder = append(iterationOrder, r.delegatee.Name)
+				delegations, ok := tt.roles[r.delegatee.Name]
+				if !ok {
+					continue
+				}
+				err := d.add(delegations, r.delegatee.Name, verify.DelegationsVerifier{})
+				assert.Equal(t, tt.err, err)
+			}
+			assert.Equal(t, tt.resultOrder, iterationOrder)
+		})
+	}
+}
+
+func TestGetTargetMeta(t *testing.T) {
+	verify.IsExpired = func(t time.Time) bool { return false }
+	c, closer := initTestDelegationClient(t, "testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation")
+	defer func() { assert.Nil(t, closer()) }()
+	_, err := c.Update()
+	assert.Nil(t, err)
+
+	f, err := c.getTargetFileMeta("f.txt")
+	assert.Nil(t, err)
+	assert.Equal(t, int64(15), f.Length)
+}
+
+func TestMaxDelegations(t *testing.T) {
+	verify.IsExpired = func(t time.Time) bool { return false }
+	c, closer := initTestDelegationClient(t, "testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation")
+	defer func() { assert.Nil(t, closer()) }()
+	_, err := c.Update()
+	assert.Nil(t, err)
+	c.MaxDelegations = 2
+	_, err = c.getTargetFileMeta("c.txt")
+	assert.Equal(t, ErrMaxDelegations{Target: "c.txt", MaxDelegations: 2, SnapshotVersion: 2}, err)
+}
+
+func TestMetaNotFound(t *testing.T) {
+	verify.IsExpired = func(t time.Time) bool { return false }
+	c, closer := initTestDelegationClient(t, "testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation")
+	defer func() { assert.Nil(t, closer()) }()
+	_, err := c.Update()
+	assert.Nil(t, err)
+	_, err = c.getTargetFileMeta("unknown.txt")
+	assert.Equal(t, ErrUnknownTarget{Name: "unknown.txt", SnapshotVersion: 2}, err)
+}
+
+type fakeRemote struct {
+	getMeta   func(name string) (stream io.ReadCloser, size int64, err error)
+	getTarget func(path string) (stream io.ReadCloser, size int64, err error)
+}
+
+func (f fakeRemote) GetMeta(name string) (stream io.ReadCloser, size int64, err error) {
+	return f.getMeta(name)
+}
+
+func (f fakeRemote) GetTarget(name string) (stream io.ReadCloser, size int64, err error) {
+	return f.getTarget(name)
+}
+
+func TestTargetsNotFound(t *testing.T) {
+	verify.IsExpired = func(t time.Time) bool { return false }
+	c, closer := initTestDelegationClient(t, "testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation")
+	defer func() { assert.Nil(t, closer()) }()
+	_, err := c.Update()
+	assert.Nil(t, err)
+
+	previousRemote := c.remote
+	newRemote := fakeRemote{
+		getMeta: func(path string) (stream io.ReadCloser, size int64, err error) {
+			if path == "1.c.json" {
+				return nil, 0, ErrNotFound{}
+			}
+			return previousRemote.GetMeta(path)
+		},
+		getTarget: previousRemote.GetTarget,
+	}
+	c.remote = newRemote
+
+	_, err = c.getTargetFileMeta("c.txt")
+	assert.Equal(t, ErrMissingRemoteMetadata{Name: "c.json"}, err)
+}
+
+func TestUnverifiedTargets(t *testing.T) {
+	verify.IsExpired = func(t time.Time) bool { return false }
+	c, closer := initTestDelegationClient(t, "testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation")
+	defer closer()
+	_, err := c.Update()
+	assert.Nil(t, err)
+
+	previousRemote := c.remote
+	newRemote := fakeRemote{
+		getMeta: func(path string) (stream io.ReadCloser, size int64, err error) {
+			if path == "1.c.json" {
+				// returns a snapshot that does not match
+				return previousRemote.GetMeta("1.d.json")
+			}
+			return previousRemote.GetMeta(path)
+		},
+		getTarget: previousRemote.GetTarget,
+	}
+	c.remote = newRemote
+
+	_, err = c.getTargetFileMeta("c.txt")
+	assert.Equal(t, ErrDecodeFailed{File: "c.json", Err: verify.ErrRoleThreshold{Expected: 1, Actual: 0}}, err)
+}
+
+func TestPersistedMeta(t *testing.T) {
+	verify.IsExpired = func(t time.Time) bool { return false }
+	c, closer := initTestDelegationClient(t, "testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation")
+	defer closer()
+	_, err := c.Update()
+	assert.Nil(t, err)
+
+	_, err = c.local.GetMeta()
+	assert.Nil(t, err)
+
+	type expectedTargets struct {
+		name    string
+		version int
+	}
+	var persistedTests = []struct {
+		file          string
+		targets       []expectedTargets
+		downloadError error
+		fileContent   string
+	}{
+		{
+			file: "unknown",
+			targets: []expectedTargets{
+				{
+					name:    "targets.json",
+					version: 2,
+				},
+			},
+			downloadError: ErrUnknownTarget{Name: "unknown", SnapshotVersion: 2},
+			fileContent:   "",
+		},
+		{
+			file: "b.txt",
+			targets: []expectedTargets{
+				{
+					name:    "targets.json",
+					version: 2,
+				},
+				{
+					name:    "a.json",
+					version: 1,
+				},
+				{
+					name:    "b.json",
+					version: 1,
+				},
+			},
+			downloadError: nil,
+			fileContent:   "Contents: b.txt",
+		},
+		{
+			file: "f.txt",
+			targets: []expectedTargets{
+				{
+					name:    "targets.json",
+					version: 2,
+				},
+				{
+					name:    "a.json",
+					version: 1,
+				},
+				{
+					name:    "b.json",
+					version: 1,
+				},
+				{
+					name:    "c.json",
+					version: 1,
+				},
+				{
+					name:    "d.json",
+					version: 1,
+				},
+				{
+					name:    "e.json",
+					version: 1,
+				},
+				{
+					name:    "f.json",
+					version: 1,
+				},
+			},
+			downloadError: nil,
+			fileContent:   "Contents: f.txt",
+		},
+	}
+
+	for _, tt := range persistedTests {
+		t.Run("search "+tt.file, func(t *testing.T) {
+			var dest testDestination
+			err = c.Download(tt.file, &dest)
+			assert.Equal(t, tt.downloadError, err)
+			assert.Equal(t, tt.fileContent, dest.String())
+
+			p, err := c.local.GetMeta()
+			assert.Nil(t, err)
+			persisted := copyStore(p)
+			// trim non targets metas
+			for _, notTargets := range []string{"root.json", "snapshot.json", "timestamp.json"} {
+				delete(persisted, notTargets)
+			}
+			for _, targets := range tt.targets {
+				storedVersion, err := versionOfStoredTargets(targets.name, persisted)
+				assert.Equal(t, targets.version, storedVersion)
+				assert.Nil(t, err)
+				delete(persisted, targets.name)
+			}
+			assert.Empty(t, persisted)
+		})
+	}
+}
+
+func versionOfStoredTargets(name string, store map[string]json.RawMessage) (int, error) {
+	rawTargets, ok := store[name]
+	if !ok {
+		return 0, nil
+	}
+	s := &data.Signed{}
+	if err := json.Unmarshal(rawTargets, s); err != nil {
+		return 0, err
+	}
+	targets := &data.Targets{}
+	if err := json.Unmarshal(s.Signed, targets); err != nil {
+		return 0, err
+	}
+	return targets.Version, nil
+}
+
+func initTestDelegationClient(t *testing.T, dirPrefix string) (*Client, func() error) {
+	serverDir := dirPrefix + "/server"
+	initialStateDir := dirPrefix + "/client/metadata/current"
+	l, err := net.Listen("tcp", "127.0.0.1:0")
+	assert.Nil(t, err)
+	addr := l.Addr().String()
+	go http.Serve(l, http.FileServer(http.Dir(serverDir)))
+
+	opts := &HTTPRemoteOptions{
+		MetadataPath: "metadata",
+		TargetsPath:  "targets",
+	}
+	remote, err := HTTPRemoteStore(fmt.Sprintf("http://%s/", addr), opts, nil)
+
+	c := NewClient(MemoryLocalStore(), remote)
+	rawFile, err := ioutil.ReadFile(initialStateDir + "/" + "root.json")
+	assert.Nil(t, err)
+	s := &data.Signed{}
+	root := &data.Root{}
+	assert.Nil(t, json.Unmarshal(rawFile, s))
+	assert.Nil(t, json.Unmarshal(s.Signed, root))
+	var keys []*data.Key
+	for _, sig := range s.Signatures {
+		k, ok := root.Keys[sig.KeyID]
+		if ok {
+			keys = append(keys, k)
+		}
+	}
+
+	assert.Nil(t, c.Init(keys, 1))
+	files, err := ioutil.ReadDir(initialStateDir)
+	assert.Nil(t, err)
+
+	// load local files
+	for _, f := range files {
+		if f.IsDir() {
+			continue
+		}
+		name := f.Name()
+		// ignoring consistent snapshot when loading initial state
+		if len(strings.Split(name, ".")) == 1 && strings.HasSuffix(name, ".json") {
+			rawFile, err := ioutil.ReadFile(initialStateDir + "/" + name)
+			assert.Nil(t, err)
+			assert.Nil(t, c.local.SetMeta(name, rawFile))
+		}
+	}
+	return c, l.Close
+}
+
+func copyStore(store map[string]json.RawMessage) map[string]json.RawMessage {
+	new := make(map[string]json.RawMessage, len(store))
+	for k, raw := range store {
+		newRaw := make([]byte, len(raw))
+		copy(newRaw, []byte(raw))
+		new[k] = json.RawMessage(newRaw)
+	}
+	return new
+}
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/__init__.py b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/__init__.py
new file mode 100644
index 0000000..36285a6
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/__init__.py
@@ -0,0 +1,35 @@
+# Delegation tree
+#
+#             Targets
+#             /     \
+#            a       f
+#           / \
+#          b   e
+#         / \
+#        c   d
+#
+# No terminating delegations.
+#
+# Roles should be evaluated in the order:
+# Targets > a > b > c > d > e > f
+
+from fixtures.builder import FixtureBuilder
+
+
+def build():
+    FixtureBuilder('TUFTestFixture3LevelDelegation')\
+        .publish(with_client=True)\
+        .create_target('targets.txt')\
+        .delegate('a', ['*.txt'])\
+        .create_target('a.txt', signing_role='a')\
+        .delegate('b', ['*.txt'], parent='a') \
+        .create_target('b.txt', signing_role='b') \
+        .delegate('c', ['*.txt'], parent='b') \
+        .create_target('c.txt', signing_role='c') \
+        .delegate('d', ['*.txt'], parent='b') \
+        .create_target('d.txt', signing_role='d') \
+        .delegate('e', ['*.txt'], parent='a') \
+        .create_target('e.txt', signing_role='e') \
+        .delegate('f', ['*.txt']) \
+        .create_target('f.txt', signing_role='f') \
+        .publish()
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/1.root.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/1.root.json
new file mode 100644
index 0000000..0898610
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/1.root.json
@@ -0,0 +1,87 @@
+{
+ "signatures": [
+  {
+   "keyid": "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129",
+   "sig": "d0bf76a5cfc0aee1b8a1b1bf0ed8ca646a1a6d5f205945c515e8546bfd3c1e6b5e07cc0b93836bd030dd05ba68f177aecb05f6bf90c6702fd178e53310022506"
+  }
+ ],
+ "signed": {
+  "_type": "root",
+  "consistent_snapshot": true,
+  "expires": "2020-12-31T05:48:20Z",
+  "keys": {
+   "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6bac59b8d9e1aae02fae6fba6e7fe3fc9fe5b4a9fe98c3fca255d8c8ec3e5b35"
+    },
+    "scheme": "ed25519"
+   },
+   "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6400d770c7c1bce4b3d59ce0079ed686e843b6500bbea77d869a1ae7df4565a1"
+    },
+    "scheme": "ed25519"
+   },
+   "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "28bf74baa87ed923f8fa27e3292684f8ec4730ce0bdc65150ed58199206ce089"
+    },
+    "scheme": "ed25519"
+   },
+   "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "e6ae9d3b67d7b3ce274130291dd90287f32b8fd72bfb4ac5430859ebd1c28a46"
+    },
+    "scheme": "ed25519"
+   }
+  },
+  "roles": {
+   "root": {
+    "keyids": [
+     "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129"
+    ],
+    "threshold": 1
+   },
+   "snapshot": {
+    "keyids": [
+     "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93"
+    ],
+    "threshold": 1
+   },
+   "targets": {
+    "keyids": [
+     "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4"
+    ],
+    "threshold": 1
+   },
+   "timestamp": {
+    "keyids": [
+     "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae"
+    ],
+    "threshold": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/1.snapshot.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/1.snapshot.json
new file mode 100644
index 0000000..dcbd2f4
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/1.snapshot.json
@@ -0,0 +1,19 @@
+{
+ "signatures": [
+  {
+   "keyid": "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93",
+   "sig": "61db8765350398f7f750853337d9a55c5d6e790812d29146b5b45d5fd43d2a42c474a7a9fab263c3a50a28114a82f79dbf24ff1f99ae737a8d06f332f9f7d103"
+  }
+ ],
+ "signed": {
+  "_type": "snapshot",
+  "expires": "2020-01-08T00:00:00Z",
+  "meta": {
+   "targets.json": {
+    "version": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/1.targets.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/1.targets.json
new file mode 100644
index 0000000..820691e
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/1.targets.json
@@ -0,0 +1,19 @@
+{
+ "signatures": [
+  {
+   "keyid": "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4",
+   "sig": "c150e8ed5d352f366a979f4c4b9d556350c414c2da7ef1279045aaed3438c60872142d0dfe5ddbb627fec2d8fb7c5d8e692e04a87230b78d74714c5db035620a"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {},
+   "roles": []
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {},
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/1.timestamp.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/1.timestamp.json
new file mode 100644
index 0000000..aae05fb
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/1.timestamp.json
@@ -0,0 +1,24 @@
+{
+ "signatures": [
+  {
+   "keyid": "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae",
+   "sig": "1d668531c7a0960cf90825faa684106a8aef0799c1b47e72301bac45d87f2dd42c14f1a3ac7db862323ca5177dd4fd686573ea92aea99638f17414dde561c00b"
+  }
+ ],
+ "signed": {
+  "_type": "timestamp",
+  "expires": "2020-01-02T00:00:00Z",
+  "meta": {
+   "snapshot.json": {
+    "hashes": {
+     "sha256": "f4ca389c2c9fbc592d91d4e693c31113b8803a11bcb5ecd973581fa0e3d34ce0",
+     "sha512": "92a0989e44c0e9f16d3e56268a3b8dd4e4416ee2ac91a4c871a405f1e426062651ec4effa0078fc4409c8b0422ccad9b1aa197db58f178406f398562b2e98195"
+    },
+    "length": 431,
+    "version": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/root.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/root.json
new file mode 100644
index 0000000..0898610
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/root.json
@@ -0,0 +1,87 @@
+{
+ "signatures": [
+  {
+   "keyid": "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129",
+   "sig": "d0bf76a5cfc0aee1b8a1b1bf0ed8ca646a1a6d5f205945c515e8546bfd3c1e6b5e07cc0b93836bd030dd05ba68f177aecb05f6bf90c6702fd178e53310022506"
+  }
+ ],
+ "signed": {
+  "_type": "root",
+  "consistent_snapshot": true,
+  "expires": "2020-12-31T05:48:20Z",
+  "keys": {
+   "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6bac59b8d9e1aae02fae6fba6e7fe3fc9fe5b4a9fe98c3fca255d8c8ec3e5b35"
+    },
+    "scheme": "ed25519"
+   },
+   "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6400d770c7c1bce4b3d59ce0079ed686e843b6500bbea77d869a1ae7df4565a1"
+    },
+    "scheme": "ed25519"
+   },
+   "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "28bf74baa87ed923f8fa27e3292684f8ec4730ce0bdc65150ed58199206ce089"
+    },
+    "scheme": "ed25519"
+   },
+   "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "e6ae9d3b67d7b3ce274130291dd90287f32b8fd72bfb4ac5430859ebd1c28a46"
+    },
+    "scheme": "ed25519"
+   }
+  },
+  "roles": {
+   "root": {
+    "keyids": [
+     "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129"
+    ],
+    "threshold": 1
+   },
+   "snapshot": {
+    "keyids": [
+     "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93"
+    ],
+    "threshold": 1
+   },
+   "targets": {
+    "keyids": [
+     "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4"
+    ],
+    "threshold": 1
+   },
+   "timestamp": {
+    "keyids": [
+     "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae"
+    ],
+    "threshold": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/snapshot.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/snapshot.json
new file mode 100644
index 0000000..dcbd2f4
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/snapshot.json
@@ -0,0 +1,19 @@
+{
+ "signatures": [
+  {
+   "keyid": "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93",
+   "sig": "61db8765350398f7f750853337d9a55c5d6e790812d29146b5b45d5fd43d2a42c474a7a9fab263c3a50a28114a82f79dbf24ff1f99ae737a8d06f332f9f7d103"
+  }
+ ],
+ "signed": {
+  "_type": "snapshot",
+  "expires": "2020-01-08T00:00:00Z",
+  "meta": {
+   "targets.json": {
+    "version": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/targets.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/targets.json
new file mode 100644
index 0000000..820691e
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/targets.json
@@ -0,0 +1,19 @@
+{
+ "signatures": [
+  {
+   "keyid": "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4",
+   "sig": "c150e8ed5d352f366a979f4c4b9d556350c414c2da7ef1279045aaed3438c60872142d0dfe5ddbb627fec2d8fb7c5d8e692e04a87230b78d74714c5db035620a"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {},
+   "roles": []
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {},
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/timestamp.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/timestamp.json
new file mode 100644
index 0000000..aae05fb
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/current/timestamp.json
@@ -0,0 +1,24 @@
+{
+ "signatures": [
+  {
+   "keyid": "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae",
+   "sig": "1d668531c7a0960cf90825faa684106a8aef0799c1b47e72301bac45d87f2dd42c14f1a3ac7db862323ca5177dd4fd686573ea92aea99638f17414dde561c00b"
+  }
+ ],
+ "signed": {
+  "_type": "timestamp",
+  "expires": "2020-01-02T00:00:00Z",
+  "meta": {
+   "snapshot.json": {
+    "hashes": {
+     "sha256": "f4ca389c2c9fbc592d91d4e693c31113b8803a11bcb5ecd973581fa0e3d34ce0",
+     "sha512": "92a0989e44c0e9f16d3e56268a3b8dd4e4416ee2ac91a4c871a405f1e426062651ec4effa0078fc4409c8b0422ccad9b1aa197db58f178406f398562b2e98195"
+    },
+    "length": 431,
+    "version": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/1.root.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/1.root.json
new file mode 100644
index 0000000..0898610
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/1.root.json
@@ -0,0 +1,87 @@
+{
+ "signatures": [
+  {
+   "keyid": "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129",
+   "sig": "d0bf76a5cfc0aee1b8a1b1bf0ed8ca646a1a6d5f205945c515e8546bfd3c1e6b5e07cc0b93836bd030dd05ba68f177aecb05f6bf90c6702fd178e53310022506"
+  }
+ ],
+ "signed": {
+  "_type": "root",
+  "consistent_snapshot": true,
+  "expires": "2020-12-31T05:48:20Z",
+  "keys": {
+   "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6bac59b8d9e1aae02fae6fba6e7fe3fc9fe5b4a9fe98c3fca255d8c8ec3e5b35"
+    },
+    "scheme": "ed25519"
+   },
+   "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6400d770c7c1bce4b3d59ce0079ed686e843b6500bbea77d869a1ae7df4565a1"
+    },
+    "scheme": "ed25519"
+   },
+   "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "28bf74baa87ed923f8fa27e3292684f8ec4730ce0bdc65150ed58199206ce089"
+    },
+    "scheme": "ed25519"
+   },
+   "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "e6ae9d3b67d7b3ce274130291dd90287f32b8fd72bfb4ac5430859ebd1c28a46"
+    },
+    "scheme": "ed25519"
+   }
+  },
+  "roles": {
+   "root": {
+    "keyids": [
+     "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129"
+    ],
+    "threshold": 1
+   },
+   "snapshot": {
+    "keyids": [
+     "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93"
+    ],
+    "threshold": 1
+   },
+   "targets": {
+    "keyids": [
+     "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4"
+    ],
+    "threshold": 1
+   },
+   "timestamp": {
+    "keyids": [
+     "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae"
+    ],
+    "threshold": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/1.snapshot.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/1.snapshot.json
new file mode 100644
index 0000000..dcbd2f4
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/1.snapshot.json
@@ -0,0 +1,19 @@
+{
+ "signatures": [
+  {
+   "keyid": "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93",
+   "sig": "61db8765350398f7f750853337d9a55c5d6e790812d29146b5b45d5fd43d2a42c474a7a9fab263c3a50a28114a82f79dbf24ff1f99ae737a8d06f332f9f7d103"
+  }
+ ],
+ "signed": {
+  "_type": "snapshot",
+  "expires": "2020-01-08T00:00:00Z",
+  "meta": {
+   "targets.json": {
+    "version": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/1.targets.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/1.targets.json
new file mode 100644
index 0000000..820691e
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/1.targets.json
@@ -0,0 +1,19 @@
+{
+ "signatures": [
+  {
+   "keyid": "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4",
+   "sig": "c150e8ed5d352f366a979f4c4b9d556350c414c2da7ef1279045aaed3438c60872142d0dfe5ddbb627fec2d8fb7c5d8e692e04a87230b78d74714c5db035620a"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {},
+   "roles": []
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {},
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/1.timestamp.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/1.timestamp.json
new file mode 100644
index 0000000..aae05fb
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/1.timestamp.json
@@ -0,0 +1,24 @@
+{
+ "signatures": [
+  {
+   "keyid": "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae",
+   "sig": "1d668531c7a0960cf90825faa684106a8aef0799c1b47e72301bac45d87f2dd42c14f1a3ac7db862323ca5177dd4fd686573ea92aea99638f17414dde561c00b"
+  }
+ ],
+ "signed": {
+  "_type": "timestamp",
+  "expires": "2020-01-02T00:00:00Z",
+  "meta": {
+   "snapshot.json": {
+    "hashes": {
+     "sha256": "f4ca389c2c9fbc592d91d4e693c31113b8803a11bcb5ecd973581fa0e3d34ce0",
+     "sha512": "92a0989e44c0e9f16d3e56268a3b8dd4e4416ee2ac91a4c871a405f1e426062651ec4effa0078fc4409c8b0422ccad9b1aa197db58f178406f398562b2e98195"
+    },
+    "length": 431,
+    "version": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/root.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/root.json
new file mode 100644
index 0000000..0898610
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/root.json
@@ -0,0 +1,87 @@
+{
+ "signatures": [
+  {
+   "keyid": "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129",
+   "sig": "d0bf76a5cfc0aee1b8a1b1bf0ed8ca646a1a6d5f205945c515e8546bfd3c1e6b5e07cc0b93836bd030dd05ba68f177aecb05f6bf90c6702fd178e53310022506"
+  }
+ ],
+ "signed": {
+  "_type": "root",
+  "consistent_snapshot": true,
+  "expires": "2020-12-31T05:48:20Z",
+  "keys": {
+   "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6bac59b8d9e1aae02fae6fba6e7fe3fc9fe5b4a9fe98c3fca255d8c8ec3e5b35"
+    },
+    "scheme": "ed25519"
+   },
+   "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6400d770c7c1bce4b3d59ce0079ed686e843b6500bbea77d869a1ae7df4565a1"
+    },
+    "scheme": "ed25519"
+   },
+   "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "28bf74baa87ed923f8fa27e3292684f8ec4730ce0bdc65150ed58199206ce089"
+    },
+    "scheme": "ed25519"
+   },
+   "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "e6ae9d3b67d7b3ce274130291dd90287f32b8fd72bfb4ac5430859ebd1c28a46"
+    },
+    "scheme": "ed25519"
+   }
+  },
+  "roles": {
+   "root": {
+    "keyids": [
+     "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129"
+    ],
+    "threshold": 1
+   },
+   "snapshot": {
+    "keyids": [
+     "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93"
+    ],
+    "threshold": 1
+   },
+   "targets": {
+    "keyids": [
+     "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4"
+    ],
+    "threshold": 1
+   },
+   "timestamp": {
+    "keyids": [
+     "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae"
+    ],
+    "threshold": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/snapshot.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/snapshot.json
new file mode 100644
index 0000000..dcbd2f4
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/snapshot.json
@@ -0,0 +1,19 @@
+{
+ "signatures": [
+  {
+   "keyid": "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93",
+   "sig": "61db8765350398f7f750853337d9a55c5d6e790812d29146b5b45d5fd43d2a42c474a7a9fab263c3a50a28114a82f79dbf24ff1f99ae737a8d06f332f9f7d103"
+  }
+ ],
+ "signed": {
+  "_type": "snapshot",
+  "expires": "2020-01-08T00:00:00Z",
+  "meta": {
+   "targets.json": {
+    "version": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/targets.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/targets.json
new file mode 100644
index 0000000..820691e
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/targets.json
@@ -0,0 +1,19 @@
+{
+ "signatures": [
+  {
+   "keyid": "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4",
+   "sig": "c150e8ed5d352f366a979f4c4b9d556350c414c2da7ef1279045aaed3438c60872142d0dfe5ddbb627fec2d8fb7c5d8e692e04a87230b78d74714c5db035620a"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {},
+   "roles": []
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {},
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/timestamp.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/timestamp.json
new file mode 100644
index 0000000..aae05fb
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/client/metadata/previous/timestamp.json
@@ -0,0 +1,24 @@
+{
+ "signatures": [
+  {
+   "keyid": "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae",
+   "sig": "1d668531c7a0960cf90825faa684106a8aef0799c1b47e72301bac45d87f2dd42c14f1a3ac7db862323ca5177dd4fd686573ea92aea99638f17414dde561c00b"
+  }
+ ],
+ "signed": {
+  "_type": "timestamp",
+  "expires": "2020-01-02T00:00:00Z",
+  "meta": {
+   "snapshot.json": {
+    "hashes": {
+     "sha256": "f4ca389c2c9fbc592d91d4e693c31113b8803a11bcb5ecd973581fa0e3d34ce0",
+     "sha512": "92a0989e44c0e9f16d3e56268a3b8dd4e4416ee2ac91a4c871a405f1e426062651ec4effa0078fc4409c8b0422ccad9b1aa197db58f178406f398562b2e98195"
+    },
+    "length": 431,
+    "version": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.a.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.a.json
new file mode 100644
index 0000000..90237d1
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.a.json
@@ -0,0 +1,74 @@
+{
+ "signatures": [
+  {
+   "keyid": "05e17c1501d627b2597322f80d33aacec6f30a507552d3326a88913422b0e30b",
+   "sig": "35eae38d12b845e5e2fb89c957b1e4b16bccfb5717160e0c1a36924b3ae89972d78e0837639e266cf5957ddf9d1f7eb184148ef2d242d7ff99b844342b48020a"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {
+    "15024498e03f033ec92758a1dc7107b34eebe759b09827b02a7fb3c64ca3e586": {
+     "keyid_hash_algorithms": [
+      "sha256",
+      "sha512"
+     ],
+     "keytype": "ed25519",
+     "keyval": {
+      "public": "e104199cda6e018d7d9044fa6225aa5dc9c2af5ee4e1c0fe6d16ad002220390d"
+     },
+     "scheme": "ed25519"
+    },
+    "718fedad390b4d0d470b890781eb8c94e5a7e975aebe65fc0862246c945fce68": {
+     "keyid_hash_algorithms": [
+      "sha256",
+      "sha512"
+     ],
+     "keytype": "ed25519",
+     "keyval": {
+      "public": "82f52e4503dbb364fabe8e5567f1cf909d4175d45468a021dfe75653db9ac98c"
+     },
+     "scheme": "ed25519"
+    }
+   },
+   "roles": [
+    {
+     "keyids": [
+      "718fedad390b4d0d470b890781eb8c94e5a7e975aebe65fc0862246c945fce68"
+     ],
+     "name": "b",
+     "paths": [
+      "*.txt"
+     ],
+     "terminating": false,
+     "threshold": 1
+    },
+    {
+     "keyids": [
+      "15024498e03f033ec92758a1dc7107b34eebe759b09827b02a7fb3c64ca3e586"
+     ],
+     "name": "e",
+     "paths": [
+      "*.txt"
+     ],
+     "terminating": false,
+     "threshold": 1
+    }
+   ]
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "a.txt": {
+    "custom": {},
+    "hashes": {
+     "sha256": "3f90cedf303207851bbdc5f857e018daf93b4c0083306cef17df547b42e4e985",
+     "sha512": "f4631ef7ea7b015d7b88e411842fafeb78a72f0181bec72ea9754604ede74ea0e491bf8411659aabc96304fc764d0131ce49ba86066ab5f7b7480dde719e0bfd"
+    },
+    "length": 15
+   }
+  },
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.b.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.b.json
new file mode 100644
index 0000000..bee9490
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.b.json
@@ -0,0 +1,74 @@
+{
+ "signatures": [
+  {
+   "keyid": "718fedad390b4d0d470b890781eb8c94e5a7e975aebe65fc0862246c945fce68",
+   "sig": "a99aa2bd1aa6473f48fc9c3a93289de4efea45071906bc4deecd3a85ffe0e6bc2c1b1751a2b0975e8f28d1b8114ac964f6dc55e5e160f602d56eff51d25c7907"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {
+    "9ca81f7ff17f6218246474a51b47eb035741bc472557ef5ac493e279f446b85b": {
+     "keyid_hash_algorithms": [
+      "sha256",
+      "sha512"
+     ],
+     "keytype": "ed25519",
+     "keyval": {
+      "public": "06e4dee0de7826c8d539a6112940b7459892b4ecaf696e67dc064aea0923f95c"
+     },
+     "scheme": "ed25519"
+    },
+    "e9829d3f2fdff6d6f31002c17cf7f20cf0398e215ca0c0c44d075ccd76a26f62": {
+     "keyid_hash_algorithms": [
+      "sha256",
+      "sha512"
+     ],
+     "keytype": "ed25519",
+     "keyval": {
+      "public": "fa386632ae9cc358ad0b56565edef362ad10d7fadb05bc8dc8995627372b990e"
+     },
+     "scheme": "ed25519"
+    }
+   },
+   "roles": [
+    {
+     "keyids": [
+      "9ca81f7ff17f6218246474a51b47eb035741bc472557ef5ac493e279f446b85b"
+     ],
+     "name": "c",
+     "paths": [
+      "*.txt"
+     ],
+     "terminating": false,
+     "threshold": 1
+    },
+    {
+     "keyids": [
+      "e9829d3f2fdff6d6f31002c17cf7f20cf0398e215ca0c0c44d075ccd76a26f62"
+     ],
+     "name": "d",
+     "paths": [
+      "*.txt"
+     ],
+     "terminating": false,
+     "threshold": 1
+    }
+   ]
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "b.txt": {
+    "custom": {},
+    "hashes": {
+     "sha256": "949c6a4318dabe8bbd140cef99ea669ba031919ccf9bce0f5b4d0b61d1c0aa2e",
+     "sha512": "f4cc9ce5c73b37e2a6707af7a0ea614ea5fa428bd2509e3af4528a5d330ce98a09c4dd98c859ad9b27b8aba24e1eacbf1af8393fdbfed899cecb995c87a11e3c"
+    },
+    "length": 15
+   }
+  },
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.c.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.c.json
new file mode 100644
index 0000000..9305d64
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.c.json
@@ -0,0 +1,28 @@
+{
+ "signatures": [
+  {
+   "keyid": "9ca81f7ff17f6218246474a51b47eb035741bc472557ef5ac493e279f446b85b",
+   "sig": "9f0c5ef7e0a11012256c4a47ed757f9e90e930238f6a6e5c758a014f1b768519acb2b7d9aa9bd0456d71ba09b032598086e42d832830391e307c926c16f5b303"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {},
+   "roles": []
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "c.txt": {
+    "custom": {},
+    "hashes": {
+     "sha256": "946e2ea9180de673891ae09ce0edfb207966bc32bd6324ebdad2c50c82075ffd",
+     "sha512": "ddb5fb256a368d778b5fdd03d497ad79944c766245f3cccfc8b098b14c488ec424a68b86a6a2add36db4ef0f0214f15dbe0d63fbc5ca7a9619fb4c39544d78a9"
+    },
+    "length": 15
+   }
+  },
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.d.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.d.json
new file mode 100644
index 0000000..2c9f111
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.d.json
@@ -0,0 +1,28 @@
+{
+ "signatures": [
+  {
+   "keyid": "e9829d3f2fdff6d6f31002c17cf7f20cf0398e215ca0c0c44d075ccd76a26f62",
+   "sig": "04d8264dd6d75fad106d599603801842057de038803935cf9b043fff4d5ee2618675c85291822f18efff770fa89056ed960de35309356a24baef45d9f64c8c0f"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {},
+   "roles": []
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "d.txt": {
+    "custom": {},
+    "hashes": {
+     "sha256": "b2ce56612fa7e840d56d263f75804f60baaad5d6e0a7d544e65e1791f2df5e55",
+     "sha512": "79b44a183eaa0025f3997b856537502ac1759de74263985ad1dd9ab0bb5cc8ce46b6c45c1bf37adb87c2839cca54ae784c3d3488e994d29f003decc81638d589"
+    },
+    "length": 15
+   }
+  },
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.e.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.e.json
new file mode 100644
index 0000000..a473d7d
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.e.json
@@ -0,0 +1,28 @@
+{
+ "signatures": [
+  {
+   "keyid": "15024498e03f033ec92758a1dc7107b34eebe759b09827b02a7fb3c64ca3e586",
+   "sig": "11ea2f743a5e5ec7f5bea84804c1fdc2e70f42a1f955668b0072fc6bed00e6535574c129270f97e8849e589a57f48a077903244250ab970eac45f7c46c43b003"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {},
+   "roles": []
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "e.txt": {
+    "custom": {},
+    "hashes": {
+     "sha256": "027b3d1814dae8547aed3d7e7b99aa0d59d82cc18411037b0ef0318eb754ce86",
+     "sha512": "ffca8c347335e5e019deba8a15212297fe25afc734ac06fcd01b4d9dae12b16d750d7a44bdafcada254148990aa6611bc9ed2f2841da2c0dd70bb870be7275f6"
+    },
+    "length": 15
+   }
+  },
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.f.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.f.json
new file mode 100644
index 0000000..941ae66
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.f.json
@@ -0,0 +1,28 @@
+{
+ "signatures": [
+  {
+   "keyid": "f581c9aeff9989106aeeea35319d1d1f067149619a2ff005249d6f60560557be",
+   "sig": "ff5f1d3bfc5d31f71997b1c020c06d7c1683e0f7e747cfdbdf6c718f608181b4c81f3212a93ff8e109b377a73af5883e1568257b050a5b6137058a10463f640e"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {},
+   "roles": []
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "f.txt": {
+    "custom": {},
+    "hashes": {
+     "sha256": "f0b07df15d4a3e98ffc6ee6a4c00d04623b0171de458d94dbec4abb7646bc60e",
+     "sha512": "b8061c4c83d478aaadc6758a74861f749420e83dd6bf07e931738b0619404a75d12758429b12f8d047643d6d13047ec0b65bb253cf5f5b961515658864301207"
+    },
+    "length": 15
+   }
+  },
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.root.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.root.json
new file mode 100644
index 0000000..0898610
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.root.json
@@ -0,0 +1,87 @@
+{
+ "signatures": [
+  {
+   "keyid": "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129",
+   "sig": "d0bf76a5cfc0aee1b8a1b1bf0ed8ca646a1a6d5f205945c515e8546bfd3c1e6b5e07cc0b93836bd030dd05ba68f177aecb05f6bf90c6702fd178e53310022506"
+  }
+ ],
+ "signed": {
+  "_type": "root",
+  "consistent_snapshot": true,
+  "expires": "2020-12-31T05:48:20Z",
+  "keys": {
+   "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6bac59b8d9e1aae02fae6fba6e7fe3fc9fe5b4a9fe98c3fca255d8c8ec3e5b35"
+    },
+    "scheme": "ed25519"
+   },
+   "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6400d770c7c1bce4b3d59ce0079ed686e843b6500bbea77d869a1ae7df4565a1"
+    },
+    "scheme": "ed25519"
+   },
+   "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "28bf74baa87ed923f8fa27e3292684f8ec4730ce0bdc65150ed58199206ce089"
+    },
+    "scheme": "ed25519"
+   },
+   "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "e6ae9d3b67d7b3ce274130291dd90287f32b8fd72bfb4ac5430859ebd1c28a46"
+    },
+    "scheme": "ed25519"
+   }
+  },
+  "roles": {
+   "root": {
+    "keyids": [
+     "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129"
+    ],
+    "threshold": 1
+   },
+   "snapshot": {
+    "keyids": [
+     "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93"
+    ],
+    "threshold": 1
+   },
+   "targets": {
+    "keyids": [
+     "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4"
+    ],
+    "threshold": 1
+   },
+   "timestamp": {
+    "keyids": [
+     "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae"
+    ],
+    "threshold": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.snapshot.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.snapshot.json
new file mode 100644
index 0000000..dcbd2f4
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.snapshot.json
@@ -0,0 +1,19 @@
+{
+ "signatures": [
+  {
+   "keyid": "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93",
+   "sig": "61db8765350398f7f750853337d9a55c5d6e790812d29146b5b45d5fd43d2a42c474a7a9fab263c3a50a28114a82f79dbf24ff1f99ae737a8d06f332f9f7d103"
+  }
+ ],
+ "signed": {
+  "_type": "snapshot",
+  "expires": "2020-01-08T00:00:00Z",
+  "meta": {
+   "targets.json": {
+    "version": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.targets.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.targets.json
new file mode 100644
index 0000000..820691e
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.targets.json
@@ -0,0 +1,19 @@
+{
+ "signatures": [
+  {
+   "keyid": "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4",
+   "sig": "c150e8ed5d352f366a979f4c4b9d556350c414c2da7ef1279045aaed3438c60872142d0dfe5ddbb627fec2d8fb7c5d8e692e04a87230b78d74714c5db035620a"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {},
+   "roles": []
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {},
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.timestamp.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.timestamp.json
new file mode 100644
index 0000000..aae05fb
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/1.timestamp.json
@@ -0,0 +1,24 @@
+{
+ "signatures": [
+  {
+   "keyid": "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae",
+   "sig": "1d668531c7a0960cf90825faa684106a8aef0799c1b47e72301bac45d87f2dd42c14f1a3ac7db862323ca5177dd4fd686573ea92aea99638f17414dde561c00b"
+  }
+ ],
+ "signed": {
+  "_type": "timestamp",
+  "expires": "2020-01-02T00:00:00Z",
+  "meta": {
+   "snapshot.json": {
+    "hashes": {
+     "sha256": "f4ca389c2c9fbc592d91d4e693c31113b8803a11bcb5ecd973581fa0e3d34ce0",
+     "sha512": "92a0989e44c0e9f16d3e56268a3b8dd4e4416ee2ac91a4c871a405f1e426062651ec4effa0078fc4409c8b0422ccad9b1aa197db58f178406f398562b2e98195"
+    },
+    "length": 431,
+    "version": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/2.root.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/2.root.json
new file mode 100644
index 0000000..2d5222c
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/2.root.json
@@ -0,0 +1,87 @@
+{
+ "signatures": [
+  {
+   "keyid": "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129",
+   "sig": "e911db300e8eb378a2cabc48979e4c8f72beca30335db0579a7a1d81ae90c8bc63c27d92ce5a17846e3b1adb63a45a411bb4308e018e0d3cc4c9908311b5f208"
+  }
+ ],
+ "signed": {
+  "_type": "root",
+  "consistent_snapshot": true,
+  "expires": "2020-12-31T05:48:20Z",
+  "keys": {
+   "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6bac59b8d9e1aae02fae6fba6e7fe3fc9fe5b4a9fe98c3fca255d8c8ec3e5b35"
+    },
+    "scheme": "ed25519"
+   },
+   "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6400d770c7c1bce4b3d59ce0079ed686e843b6500bbea77d869a1ae7df4565a1"
+    },
+    "scheme": "ed25519"
+   },
+   "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "28bf74baa87ed923f8fa27e3292684f8ec4730ce0bdc65150ed58199206ce089"
+    },
+    "scheme": "ed25519"
+   },
+   "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "e6ae9d3b67d7b3ce274130291dd90287f32b8fd72bfb4ac5430859ebd1c28a46"
+    },
+    "scheme": "ed25519"
+   }
+  },
+  "roles": {
+   "root": {
+    "keyids": [
+     "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129"
+    ],
+    "threshold": 1
+   },
+   "snapshot": {
+    "keyids": [
+     "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93"
+    ],
+    "threshold": 1
+   },
+   "targets": {
+    "keyids": [
+     "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4"
+    ],
+    "threshold": 1
+   },
+   "timestamp": {
+    "keyids": [
+     "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae"
+    ],
+    "threshold": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 2
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/2.snapshot.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/2.snapshot.json
new file mode 100644
index 0000000..11e7e07
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/2.snapshot.json
@@ -0,0 +1,37 @@
+{
+ "signatures": [
+  {
+   "keyid": "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93",
+   "sig": "a12c5320e165f6a674b5d030c1c2f6a1716ad413ad6bda8942febab0af19f510958998d36c76bb47274fe20d3531f67f99f6f6dde8bc3bc0bca7098d8042510c"
+  }
+ ],
+ "signed": {
+  "_type": "snapshot",
+  "expires": "2020-01-08T00:00:00Z",
+  "meta": {
+   "a.json": {
+    "version": 1
+   },
+   "b.json": {
+    "version": 1
+   },
+   "c.json": {
+    "version": 1
+   },
+   "d.json": {
+    "version": 1
+   },
+   "e.json": {
+    "version": 1
+   },
+   "f.json": {
+    "version": 1
+   },
+   "targets.json": {
+    "version": 2
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 2
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/2.targets.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/2.targets.json
new file mode 100644
index 0000000..bece42a
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/2.targets.json
@@ -0,0 +1,73 @@
+{
+ "signatures": [
+  {
+   "keyid": "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4",
+   "sig": "91abfb44e844a293837d1e081953acc4a7d179ff695353ea0472eb71846f8aee9903b352fa595084243218bec93336defc184ed0b4a1bc50e497878ed7f03c0c"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {
+    "05e17c1501d627b2597322f80d33aacec6f30a507552d3326a88913422b0e30b": {
+     "keyid_hash_algorithms": [
+      "sha256",
+      "sha512"
+     ],
+     "keytype": "ed25519",
+     "keyval": {
+      "public": "f758af464295e62a1da4d3267be6d13f4aba9c7d52166d01b6bd5b4559496c9d"
+     },
+     "scheme": "ed25519"
+    },
+    "f581c9aeff9989106aeeea35319d1d1f067149619a2ff005249d6f60560557be": {
+     "keyid_hash_algorithms": [
+      "sha256",
+      "sha512"
+     ],
+     "keytype": "ed25519",
+     "keyval": {
+      "public": "683d345a948a9baa343be4e44c076ca115da3838e72c28a06340c8ec1b3ef6be"
+     },
+     "scheme": "ed25519"
+    }
+   },
+   "roles": [
+    {
+     "keyids": [
+      "05e17c1501d627b2597322f80d33aacec6f30a507552d3326a88913422b0e30b"
+     ],
+     "name": "a",
+     "paths": [
+      "*.txt"
+     ],
+     "terminating": false,
+     "threshold": 1
+    },
+    {
+     "keyids": [
+      "f581c9aeff9989106aeeea35319d1d1f067149619a2ff005249d6f60560557be"
+     ],
+     "name": "f",
+     "paths": [
+      "*.txt"
+     ],
+     "terminating": false,
+     "threshold": 1
+    }
+   ]
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "targets.txt": {
+    "hashes": {
+     "sha256": "6da6f624811b0d7792c57dc9ed58f1af8cbf4ebfaaaa3ea2011b57b4af63f7e6",
+     "sha512": "0951c719b5adf26a55700bd1ac6b657fb513b04e4adb335a935a0da7f12f2ac99834b47442d0b832e5f4ffdd365ef538f14d3a52221c0cf81ef7b68ce312476a"
+    },
+    "length": 21
+   }
+  },
+  "version": 2
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/2.timestamp.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/2.timestamp.json
new file mode 100644
index 0000000..218e6de
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/2.timestamp.json
@@ -0,0 +1,24 @@
+{
+ "signatures": [
+  {
+   "keyid": "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae",
+   "sig": "c48177d304312637216cfa3e424e1f7a214dfbd2121597e2b6ef10b40c000d48720078742503d68d19e50dd39da093e5d25d72ff6a634e7e8880bd9992fc110b"
+  }
+ ],
+ "signed": {
+  "_type": "timestamp",
+  "expires": "2020-01-02T00:00:00Z",
+  "meta": {
+   "snapshot.json": {
+    "hashes": {
+     "sha256": "b5a9218242238080aa294948c18f9d48022b1c3ca68ebdde92fed68bbc0c3c09",
+     "sha512": "ffb8446b2517a5db4a0ca7b59f4800bcd00f807e918b884af53c7c82b941df242d790e64feaba08ae33edd92902c494f85c9f7c25e8b7071ab0f0b8a1e61206d"
+    },
+    "length": 659,
+    "version": 2
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 2
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/a.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/a.json
new file mode 100644
index 0000000..90237d1
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/a.json
@@ -0,0 +1,74 @@
+{
+ "signatures": [
+  {
+   "keyid": "05e17c1501d627b2597322f80d33aacec6f30a507552d3326a88913422b0e30b",
+   "sig": "35eae38d12b845e5e2fb89c957b1e4b16bccfb5717160e0c1a36924b3ae89972d78e0837639e266cf5957ddf9d1f7eb184148ef2d242d7ff99b844342b48020a"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {
+    "15024498e03f033ec92758a1dc7107b34eebe759b09827b02a7fb3c64ca3e586": {
+     "keyid_hash_algorithms": [
+      "sha256",
+      "sha512"
+     ],
+     "keytype": "ed25519",
+     "keyval": {
+      "public": "e104199cda6e018d7d9044fa6225aa5dc9c2af5ee4e1c0fe6d16ad002220390d"
+     },
+     "scheme": "ed25519"
+    },
+    "718fedad390b4d0d470b890781eb8c94e5a7e975aebe65fc0862246c945fce68": {
+     "keyid_hash_algorithms": [
+      "sha256",
+      "sha512"
+     ],
+     "keytype": "ed25519",
+     "keyval": {
+      "public": "82f52e4503dbb364fabe8e5567f1cf909d4175d45468a021dfe75653db9ac98c"
+     },
+     "scheme": "ed25519"
+    }
+   },
+   "roles": [
+    {
+     "keyids": [
+      "718fedad390b4d0d470b890781eb8c94e5a7e975aebe65fc0862246c945fce68"
+     ],
+     "name": "b",
+     "paths": [
+      "*.txt"
+     ],
+     "terminating": false,
+     "threshold": 1
+    },
+    {
+     "keyids": [
+      "15024498e03f033ec92758a1dc7107b34eebe759b09827b02a7fb3c64ca3e586"
+     ],
+     "name": "e",
+     "paths": [
+      "*.txt"
+     ],
+     "terminating": false,
+     "threshold": 1
+    }
+   ]
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "a.txt": {
+    "custom": {},
+    "hashes": {
+     "sha256": "3f90cedf303207851bbdc5f857e018daf93b4c0083306cef17df547b42e4e985",
+     "sha512": "f4631ef7ea7b015d7b88e411842fafeb78a72f0181bec72ea9754604ede74ea0e491bf8411659aabc96304fc764d0131ce49ba86066ab5f7b7480dde719e0bfd"
+    },
+    "length": 15
+   }
+  },
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/b.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/b.json
new file mode 100644
index 0000000..bee9490
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/b.json
@@ -0,0 +1,74 @@
+{
+ "signatures": [
+  {
+   "keyid": "718fedad390b4d0d470b890781eb8c94e5a7e975aebe65fc0862246c945fce68",
+   "sig": "a99aa2bd1aa6473f48fc9c3a93289de4efea45071906bc4deecd3a85ffe0e6bc2c1b1751a2b0975e8f28d1b8114ac964f6dc55e5e160f602d56eff51d25c7907"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {
+    "9ca81f7ff17f6218246474a51b47eb035741bc472557ef5ac493e279f446b85b": {
+     "keyid_hash_algorithms": [
+      "sha256",
+      "sha512"
+     ],
+     "keytype": "ed25519",
+     "keyval": {
+      "public": "06e4dee0de7826c8d539a6112940b7459892b4ecaf696e67dc064aea0923f95c"
+     },
+     "scheme": "ed25519"
+    },
+    "e9829d3f2fdff6d6f31002c17cf7f20cf0398e215ca0c0c44d075ccd76a26f62": {
+     "keyid_hash_algorithms": [
+      "sha256",
+      "sha512"
+     ],
+     "keytype": "ed25519",
+     "keyval": {
+      "public": "fa386632ae9cc358ad0b56565edef362ad10d7fadb05bc8dc8995627372b990e"
+     },
+     "scheme": "ed25519"
+    }
+   },
+   "roles": [
+    {
+     "keyids": [
+      "9ca81f7ff17f6218246474a51b47eb035741bc472557ef5ac493e279f446b85b"
+     ],
+     "name": "c",
+     "paths": [
+      "*.txt"
+     ],
+     "terminating": false,
+     "threshold": 1
+    },
+    {
+     "keyids": [
+      "e9829d3f2fdff6d6f31002c17cf7f20cf0398e215ca0c0c44d075ccd76a26f62"
+     ],
+     "name": "d",
+     "paths": [
+      "*.txt"
+     ],
+     "terminating": false,
+     "threshold": 1
+    }
+   ]
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "b.txt": {
+    "custom": {},
+    "hashes": {
+     "sha256": "949c6a4318dabe8bbd140cef99ea669ba031919ccf9bce0f5b4d0b61d1c0aa2e",
+     "sha512": "f4cc9ce5c73b37e2a6707af7a0ea614ea5fa428bd2509e3af4528a5d330ce98a09c4dd98c859ad9b27b8aba24e1eacbf1af8393fdbfed899cecb995c87a11e3c"
+    },
+    "length": 15
+   }
+  },
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/c.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/c.json
new file mode 100644
index 0000000..9305d64
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/c.json
@@ -0,0 +1,28 @@
+{
+ "signatures": [
+  {
+   "keyid": "9ca81f7ff17f6218246474a51b47eb035741bc472557ef5ac493e279f446b85b",
+   "sig": "9f0c5ef7e0a11012256c4a47ed757f9e90e930238f6a6e5c758a014f1b768519acb2b7d9aa9bd0456d71ba09b032598086e42d832830391e307c926c16f5b303"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {},
+   "roles": []
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "c.txt": {
+    "custom": {},
+    "hashes": {
+     "sha256": "946e2ea9180de673891ae09ce0edfb207966bc32bd6324ebdad2c50c82075ffd",
+     "sha512": "ddb5fb256a368d778b5fdd03d497ad79944c766245f3cccfc8b098b14c488ec424a68b86a6a2add36db4ef0f0214f15dbe0d63fbc5ca7a9619fb4c39544d78a9"
+    },
+    "length": 15
+   }
+  },
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/d.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/d.json
new file mode 100644
index 0000000..2c9f111
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/d.json
@@ -0,0 +1,28 @@
+{
+ "signatures": [
+  {
+   "keyid": "e9829d3f2fdff6d6f31002c17cf7f20cf0398e215ca0c0c44d075ccd76a26f62",
+   "sig": "04d8264dd6d75fad106d599603801842057de038803935cf9b043fff4d5ee2618675c85291822f18efff770fa89056ed960de35309356a24baef45d9f64c8c0f"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {},
+   "roles": []
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "d.txt": {
+    "custom": {},
+    "hashes": {
+     "sha256": "b2ce56612fa7e840d56d263f75804f60baaad5d6e0a7d544e65e1791f2df5e55",
+     "sha512": "79b44a183eaa0025f3997b856537502ac1759de74263985ad1dd9ab0bb5cc8ce46b6c45c1bf37adb87c2839cca54ae784c3d3488e994d29f003decc81638d589"
+    },
+    "length": 15
+   }
+  },
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/e.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/e.json
new file mode 100644
index 0000000..a473d7d
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/e.json
@@ -0,0 +1,28 @@
+{
+ "signatures": [
+  {
+   "keyid": "15024498e03f033ec92758a1dc7107b34eebe759b09827b02a7fb3c64ca3e586",
+   "sig": "11ea2f743a5e5ec7f5bea84804c1fdc2e70f42a1f955668b0072fc6bed00e6535574c129270f97e8849e589a57f48a077903244250ab970eac45f7c46c43b003"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {},
+   "roles": []
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "e.txt": {
+    "custom": {},
+    "hashes": {
+     "sha256": "027b3d1814dae8547aed3d7e7b99aa0d59d82cc18411037b0ef0318eb754ce86",
+     "sha512": "ffca8c347335e5e019deba8a15212297fe25afc734ac06fcd01b4d9dae12b16d750d7a44bdafcada254148990aa6611bc9ed2f2841da2c0dd70bb870be7275f6"
+    },
+    "length": 15
+   }
+  },
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/f.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/f.json
new file mode 100644
index 0000000..941ae66
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/f.json
@@ -0,0 +1,28 @@
+{
+ "signatures": [
+  {
+   "keyid": "f581c9aeff9989106aeeea35319d1d1f067149619a2ff005249d6f60560557be",
+   "sig": "ff5f1d3bfc5d31f71997b1c020c06d7c1683e0f7e747cfdbdf6c718f608181b4c81f3212a93ff8e109b377a73af5883e1568257b050a5b6137058a10463f640e"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {},
+   "roles": []
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "f.txt": {
+    "custom": {},
+    "hashes": {
+     "sha256": "f0b07df15d4a3e98ffc6ee6a4c00d04623b0171de458d94dbec4abb7646bc60e",
+     "sha512": "b8061c4c83d478aaadc6758a74861f749420e83dd6bf07e931738b0619404a75d12758429b12f8d047643d6d13047ec0b65bb253cf5f5b961515658864301207"
+    },
+    "length": 15
+   }
+  },
+  "version": 1
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/root.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/root.json
new file mode 100644
index 0000000..2d5222c
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/root.json
@@ -0,0 +1,87 @@
+{
+ "signatures": [
+  {
+   "keyid": "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129",
+   "sig": "e911db300e8eb378a2cabc48979e4c8f72beca30335db0579a7a1d81ae90c8bc63c27d92ce5a17846e3b1adb63a45a411bb4308e018e0d3cc4c9908311b5f208"
+  }
+ ],
+ "signed": {
+  "_type": "root",
+  "consistent_snapshot": true,
+  "expires": "2020-12-31T05:48:20Z",
+  "keys": {
+   "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6bac59b8d9e1aae02fae6fba6e7fe3fc9fe5b4a9fe98c3fca255d8c8ec3e5b35"
+    },
+    "scheme": "ed25519"
+   },
+   "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "6400d770c7c1bce4b3d59ce0079ed686e843b6500bbea77d869a1ae7df4565a1"
+    },
+    "scheme": "ed25519"
+   },
+   "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "28bf74baa87ed923f8fa27e3292684f8ec4730ce0bdc65150ed58199206ce089"
+    },
+    "scheme": "ed25519"
+   },
+   "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4": {
+    "keyid_hash_algorithms": [
+     "sha256",
+     "sha512"
+    ],
+    "keytype": "ed25519",
+    "keyval": {
+     "public": "e6ae9d3b67d7b3ce274130291dd90287f32b8fd72bfb4ac5430859ebd1c28a46"
+    },
+    "scheme": "ed25519"
+   }
+  },
+  "roles": {
+   "root": {
+    "keyids": [
+     "d4dab4b4d68b91665a6d0dac5b4e64677aa6d853fc787669168b4b4ba9822129"
+    ],
+    "threshold": 1
+   },
+   "snapshot": {
+    "keyids": [
+     "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93"
+    ],
+    "threshold": 1
+   },
+   "targets": {
+    "keyids": [
+     "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4"
+    ],
+    "threshold": 1
+   },
+   "timestamp": {
+    "keyids": [
+     "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae"
+    ],
+    "threshold": 1
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 2
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/snapshot.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/snapshot.json
new file mode 100644
index 0000000..11e7e07
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/snapshot.json
@@ -0,0 +1,37 @@
+{
+ "signatures": [
+  {
+   "keyid": "77dfdca206c0fe1b8e55d67d21dd0e195a0998a9d2b56c6d3ee8f68d04c21e93",
+   "sig": "a12c5320e165f6a674b5d030c1c2f6a1716ad413ad6bda8942febab0af19f510958998d36c76bb47274fe20d3531f67f99f6f6dde8bc3bc0bca7098d8042510c"
+  }
+ ],
+ "signed": {
+  "_type": "snapshot",
+  "expires": "2020-01-08T00:00:00Z",
+  "meta": {
+   "a.json": {
+    "version": 1
+   },
+   "b.json": {
+    "version": 1
+   },
+   "c.json": {
+    "version": 1
+   },
+   "d.json": {
+    "version": 1
+   },
+   "e.json": {
+    "version": 1
+   },
+   "f.json": {
+    "version": 1
+   },
+   "targets.json": {
+    "version": 2
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 2
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/targets.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/targets.json
new file mode 100644
index 0000000..bece42a
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/targets.json
@@ -0,0 +1,73 @@
+{
+ "signatures": [
+  {
+   "keyid": "e4dae3872d28d29f7624a702bfd25f68453544d597229ee9e0a8569d1f940cf4",
+   "sig": "91abfb44e844a293837d1e081953acc4a7d179ff695353ea0472eb71846f8aee9903b352fa595084243218bec93336defc184ed0b4a1bc50e497878ed7f03c0c"
+  }
+ ],
+ "signed": {
+  "_type": "targets",
+  "delegations": {
+   "keys": {
+    "05e17c1501d627b2597322f80d33aacec6f30a507552d3326a88913422b0e30b": {
+     "keyid_hash_algorithms": [
+      "sha256",
+      "sha512"
+     ],
+     "keytype": "ed25519",
+     "keyval": {
+      "public": "f758af464295e62a1da4d3267be6d13f4aba9c7d52166d01b6bd5b4559496c9d"
+     },
+     "scheme": "ed25519"
+    },
+    "f581c9aeff9989106aeeea35319d1d1f067149619a2ff005249d6f60560557be": {
+     "keyid_hash_algorithms": [
+      "sha256",
+      "sha512"
+     ],
+     "keytype": "ed25519",
+     "keyval": {
+      "public": "683d345a948a9baa343be4e44c076ca115da3838e72c28a06340c8ec1b3ef6be"
+     },
+     "scheme": "ed25519"
+    }
+   },
+   "roles": [
+    {
+     "keyids": [
+      "05e17c1501d627b2597322f80d33aacec6f30a507552d3326a88913422b0e30b"
+     ],
+     "name": "a",
+     "paths": [
+      "*.txt"
+     ],
+     "terminating": false,
+     "threshold": 1
+    },
+    {
+     "keyids": [
+      "f581c9aeff9989106aeeea35319d1d1f067149619a2ff005249d6f60560557be"
+     ],
+     "name": "f",
+     "paths": [
+      "*.txt"
+     ],
+     "terminating": false,
+     "threshold": 1
+    }
+   ]
+  },
+  "expires": "2020-04-01T07:27:10Z",
+  "spec_version": "1.0.0",
+  "targets": {
+   "targets.txt": {
+    "hashes": {
+     "sha256": "6da6f624811b0d7792c57dc9ed58f1af8cbf4ebfaaaa3ea2011b57b4af63f7e6",
+     "sha512": "0951c719b5adf26a55700bd1ac6b657fb513b04e4adb335a935a0da7f12f2ac99834b47442d0b832e5f4ffdd365ef538f14d3a52221c0cf81ef7b68ce312476a"
+    },
+    "length": 21
+   }
+  },
+  "version": 2
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/timestamp.json b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/timestamp.json
new file mode 100644
index 0000000..218e6de
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/metadata/timestamp.json
@@ -0,0 +1,24 @@
+{
+ "signatures": [
+  {
+   "keyid": "3a05831328273e4b821c3bbe1fed0c5332749d8e071675879af26a401a5c85ae",
+   "sig": "c48177d304312637216cfa3e424e1f7a214dfbd2121597e2b6ef10b40c000d48720078742503d68d19e50dd39da093e5d25d72ff6a634e7e8880bd9992fc110b"
+  }
+ ],
+ "signed": {
+  "_type": "timestamp",
+  "expires": "2020-01-02T00:00:00Z",
+  "meta": {
+   "snapshot.json": {
+    "hashes": {
+     "sha256": "b5a9218242238080aa294948c18f9d48022b1c3ca68ebdde92fed68bbc0c3c09",
+     "sha512": "ffb8446b2517a5db4a0ca7b59f4800bcd00f807e918b884af53c7c82b941df242d790e64feaba08ae33edd92902c494f85c9f7c25e8b7071ab0f0b8a1e61206d"
+    },
+    "length": 659,
+    "version": 2
+   }
+  },
+  "spec_version": "1.0.0",
+  "version": 2
+ }
+}
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/027b3d1814dae8547aed3d7e7b99aa0d59d82cc18411037b0ef0318eb754ce86.e.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/027b3d1814dae8547aed3d7e7b99aa0d59d82cc18411037b0ef0318eb754ce86.e.txt
new file mode 100644
index 0000000..7bece39
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/027b3d1814dae8547aed3d7e7b99aa0d59d82cc18411037b0ef0318eb754ce86.e.txt
@@ -0,0 +1,1 @@
+Contents: e.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/0951c719b5adf26a55700bd1ac6b657fb513b04e4adb335a935a0da7f12f2ac99834b47442d0b832e5f4ffdd365ef538f14d3a52221c0cf81ef7b68ce312476a.targets.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/0951c719b5adf26a55700bd1ac6b657fb513b04e4adb335a935a0da7f12f2ac99834b47442d0b832e5f4ffdd365ef538f14d3a52221c0cf81ef7b68ce312476a.targets.txt
new file mode 100644
index 0000000..5f1e603
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/0951c719b5adf26a55700bd1ac6b657fb513b04e4adb335a935a0da7f12f2ac99834b47442d0b832e5f4ffdd365ef538f14d3a52221c0cf81ef7b68ce312476a.targets.txt
@@ -0,0 +1,1 @@
+Contents: targets.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/3f90cedf303207851bbdc5f857e018daf93b4c0083306cef17df547b42e4e985.a.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/3f90cedf303207851bbdc5f857e018daf93b4c0083306cef17df547b42e4e985.a.txt
new file mode 100644
index 0000000..3c40561
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/3f90cedf303207851bbdc5f857e018daf93b4c0083306cef17df547b42e4e985.a.txt
@@ -0,0 +1,1 @@
+Contents: a.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/6da6f624811b0d7792c57dc9ed58f1af8cbf4ebfaaaa3ea2011b57b4af63f7e6.targets.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/6da6f624811b0d7792c57dc9ed58f1af8cbf4ebfaaaa3ea2011b57b4af63f7e6.targets.txt
new file mode 100644
index 0000000..5f1e603
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/6da6f624811b0d7792c57dc9ed58f1af8cbf4ebfaaaa3ea2011b57b4af63f7e6.targets.txt
@@ -0,0 +1,1 @@
+Contents: targets.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/79b44a183eaa0025f3997b856537502ac1759de74263985ad1dd9ab0bb5cc8ce46b6c45c1bf37adb87c2839cca54ae784c3d3488e994d29f003decc81638d589.d.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/79b44a183eaa0025f3997b856537502ac1759de74263985ad1dd9ab0bb5cc8ce46b6c45c1bf37adb87c2839cca54ae784c3d3488e994d29f003decc81638d589.d.txt
new file mode 100644
index 0000000..36ce6ad
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/79b44a183eaa0025f3997b856537502ac1759de74263985ad1dd9ab0bb5cc8ce46b6c45c1bf37adb87c2839cca54ae784c3d3488e994d29f003decc81638d589.d.txt
@@ -0,0 +1,1 @@
+Contents: d.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/946e2ea9180de673891ae09ce0edfb207966bc32bd6324ebdad2c50c82075ffd.c.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/946e2ea9180de673891ae09ce0edfb207966bc32bd6324ebdad2c50c82075ffd.c.txt
new file mode 100644
index 0000000..924df50
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/946e2ea9180de673891ae09ce0edfb207966bc32bd6324ebdad2c50c82075ffd.c.txt
@@ -0,0 +1,1 @@
+Contents: c.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/949c6a4318dabe8bbd140cef99ea669ba031919ccf9bce0f5b4d0b61d1c0aa2e.b.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/949c6a4318dabe8bbd140cef99ea669ba031919ccf9bce0f5b4d0b61d1c0aa2e.b.txt
new file mode 100644
index 0000000..976e178
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/949c6a4318dabe8bbd140cef99ea669ba031919ccf9bce0f5b4d0b61d1c0aa2e.b.txt
@@ -0,0 +1,1 @@
+Contents: b.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/a.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/a.txt
new file mode 100644
index 0000000..3c40561
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/a.txt
@@ -0,0 +1,1 @@
+Contents: a.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/b.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/b.txt
new file mode 100644
index 0000000..976e178
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/b.txt
@@ -0,0 +1,1 @@
+Contents: b.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/b2ce56612fa7e840d56d263f75804f60baaad5d6e0a7d544e65e1791f2df5e55.d.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/b2ce56612fa7e840d56d263f75804f60baaad5d6e0a7d544e65e1791f2df5e55.d.txt
new file mode 100644
index 0000000..36ce6ad
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/b2ce56612fa7e840d56d263f75804f60baaad5d6e0a7d544e65e1791f2df5e55.d.txt
@@ -0,0 +1,1 @@
+Contents: d.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/b8061c4c83d478aaadc6758a74861f749420e83dd6bf07e931738b0619404a75d12758429b12f8d047643d6d13047ec0b65bb253cf5f5b961515658864301207.f.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/b8061c4c83d478aaadc6758a74861f749420e83dd6bf07e931738b0619404a75d12758429b12f8d047643d6d13047ec0b65bb253cf5f5b961515658864301207.f.txt
new file mode 100644
index 0000000..f7c5df5
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/b8061c4c83d478aaadc6758a74861f749420e83dd6bf07e931738b0619404a75d12758429b12f8d047643d6d13047ec0b65bb253cf5f5b961515658864301207.f.txt
@@ -0,0 +1,1 @@
+Contents: f.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/c.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/c.txt
new file mode 100644
index 0000000..924df50
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/c.txt
@@ -0,0 +1,1 @@
+Contents: c.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/d.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/d.txt
new file mode 100644
index 0000000..36ce6ad
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/d.txt
@@ -0,0 +1,1 @@
+Contents: d.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/ddb5fb256a368d778b5fdd03d497ad79944c766245f3cccfc8b098b14c488ec424a68b86a6a2add36db4ef0f0214f15dbe0d63fbc5ca7a9619fb4c39544d78a9.c.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/ddb5fb256a368d778b5fdd03d497ad79944c766245f3cccfc8b098b14c488ec424a68b86a6a2add36db4ef0f0214f15dbe0d63fbc5ca7a9619fb4c39544d78a9.c.txt
new file mode 100644
index 0000000..924df50
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/ddb5fb256a368d778b5fdd03d497ad79944c766245f3cccfc8b098b14c488ec424a68b86a6a2add36db4ef0f0214f15dbe0d63fbc5ca7a9619fb4c39544d78a9.c.txt
@@ -0,0 +1,1 @@
+Contents: c.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/e.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/e.txt
new file mode 100644
index 0000000..7bece39
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/e.txt
@@ -0,0 +1,1 @@
+Contents: e.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/f.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/f.txt
new file mode 100644
index 0000000..f7c5df5
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/f.txt
@@ -0,0 +1,1 @@
+Contents: f.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/f0b07df15d4a3e98ffc6ee6a4c00d04623b0171de458d94dbec4abb7646bc60e.f.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/f0b07df15d4a3e98ffc6ee6a4c00d04623b0171de458d94dbec4abb7646bc60e.f.txt
new file mode 100644
index 0000000..f7c5df5
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/f0b07df15d4a3e98ffc6ee6a4c00d04623b0171de458d94dbec4abb7646bc60e.f.txt
@@ -0,0 +1,1 @@
+Contents: f.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/f4631ef7ea7b015d7b88e411842fafeb78a72f0181bec72ea9754604ede74ea0e491bf8411659aabc96304fc764d0131ce49ba86066ab5f7b7480dde719e0bfd.a.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/f4631ef7ea7b015d7b88e411842fafeb78a72f0181bec72ea9754604ede74ea0e491bf8411659aabc96304fc764d0131ce49ba86066ab5f7b7480dde719e0bfd.a.txt
new file mode 100644
index 0000000..3c40561
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/f4631ef7ea7b015d7b88e411842fafeb78a72f0181bec72ea9754604ede74ea0e491bf8411659aabc96304fc764d0131ce49ba86066ab5f7b7480dde719e0bfd.a.txt
@@ -0,0 +1,1 @@
+Contents: a.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/f4cc9ce5c73b37e2a6707af7a0ea614ea5fa428bd2509e3af4528a5d330ce98a09c4dd98c859ad9b27b8aba24e1eacbf1af8393fdbfed899cecb995c87a11e3c.b.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/f4cc9ce5c73b37e2a6707af7a0ea614ea5fa428bd2509e3af4528a5d330ce98a09c4dd98c859ad9b27b8aba24e1eacbf1af8393fdbfed899cecb995c87a11e3c.b.txt
new file mode 100644
index 0000000..976e178
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/f4cc9ce5c73b37e2a6707af7a0ea614ea5fa428bd2509e3af4528a5d330ce98a09c4dd98c859ad9b27b8aba24e1eacbf1af8393fdbfed899cecb995c87a11e3c.b.txt
@@ -0,0 +1,1 @@
+Contents: b.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/ffca8c347335e5e019deba8a15212297fe25afc734ac06fcd01b4d9dae12b16d750d7a44bdafcada254148990aa6611bc9ed2f2841da2c0dd70bb870be7275f6.e.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/ffca8c347335e5e019deba8a15212297fe25afc734ac06fcd01b4d9dae12b16d750d7a44bdafcada254148990aa6611bc9ed2f2841da2c0dd70bb870be7275f6.e.txt
new file mode 100644
index 0000000..7bece39
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/ffca8c347335e5e019deba8a15212297fe25afc734ac06fcd01b4d9dae12b16d750d7a44bdafcada254148990aa6611bc9ed2f2841da2c0dd70bb870be7275f6.e.txt
@@ -0,0 +1,1 @@
+Contents: e.txt
\ No newline at end of file
diff --git a/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/targets.txt b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/targets.txt
new file mode 100644
index 0000000..5f1e603
--- /dev/null
+++ b/client/testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation/server/targets/targets.txt
@@ -0,0 +1,1 @@
+Contents: targets.txt
\ No newline at end of file
diff --git a/data/types_test.go b/data/types_test.go
index d5b00f2..4b64cec 100644
--- a/data/types_test.go
+++ b/data/types_test.go
@@ -2,7 +2,10 @@ package data
 
 import (
 	"encoding/json"
+	"testing"
 
+	"github.com/stretchr/testify/assert"
+	cjson "github.com/tent/canonical-json-go"
 	. "gopkg.in/check.v1"
 )
 
@@ -91,3 +94,138 @@ func (TypesSuite) TestRoleAddKeyIDs(c *C) {
 	c.Assert(role.AddKeyIDs(key.IDs()), Equals, true)
 	c.Assert(role.KeyIDs, DeepEquals, []string{keyid10, keyid10algos})
 }
+
+func TestDelegatedRolePathMatch(t *testing.T) {
+	var tts = []struct {
+		testName         string
+		pathPatterns     []string
+		pathHashPrefixes []string
+		file             string
+		shouldMatch      bool
+	}{
+		{
+			testName: "no path",
+			file:     "licence.txt",
+		},
+		{
+			testName:     "match path *",
+			pathPatterns: []string{"null", "targets/*.tgz"},
+			file:         "targets/foo.tgz",
+			shouldMatch:  true,
+		},
+		{
+			testName:     "does not match path *",
+			pathPatterns: []string{"null", "targets/*.tgz"},
+			file:         "targets/foo.txt",
+			shouldMatch:  false,
+		},
+		{
+			testName:     "match path ?",
+			pathPatterns: []string{"foo-version-?.tgz"},
+			file:         "foo-version-a.tgz",
+			shouldMatch:  true,
+		},
+		{
+			testName:     "does not match ?",
+			pathPatterns: []string{"foo-version-?.tgz"},
+			file:         "foo-version-alpha.tgz",
+			shouldMatch:  false,
+		},
+		// picked from https://github.com/theupdateframework/tuf/blob/30ba6e9f9ab25e0370e29ce574dada2d8809afa0/tests/test_updater.py#L1726-L1734
+		{
+			testName:         "match hash prefix",
+			pathHashPrefixes: []string{"badd", "8baf"},
+			file:             "/file3.txt",
+			shouldMatch:      true,
+		},
+		{
+			testName:         "does not match hash prefix",
+			pathHashPrefixes: []string{"badd"},
+			file:             "/file3.txt",
+			shouldMatch:      false,
+		},
+		{
+			testName:         "hash prefix first char",
+			pathHashPrefixes: []string{"2"},
+			file:             "/a/b/c/file_d.txt",
+			shouldMatch:      true,
+		},
+		{
+			testName:         "full hash prefix",
+			pathHashPrefixes: []string{"34c85d1ee84f61f10d7dc633472a49096ed87f8f764bd597831eac371f40ac39"},
+			file:             "/e/f/g.txt",
+			shouldMatch:      true,
+		},
+	}
+	for _, tt := range tts {
+		t.Run(tt.testName, func(t *testing.T) {
+			d := DelegatedRole{
+				Paths:            tt.pathPatterns,
+				PathHashPrefixes: tt.pathHashPrefixes,
+			}
+			assert.NoError(t, d.validatePaths())
+
+			matchesPath, err := d.MatchesPath(tt.file)
+			assert.NoError(t, err)
+			assert.Equal(t, tt.shouldMatch, matchesPath)
+		})
+
+	}
+}
+
+func TestDelegatedRoleJSON(t *testing.T) {
+	var tts = []struct {
+		testName string
+		d        *DelegatedRole
+		rawCJSON string
+	}{{
+		testName: "all fields with hashes",
+		d: &DelegatedRole{
+			Name:             "n1",
+			KeyIDs:           []string{"k1"},
+			Threshold:        5,
+			Terminating:      true,
+			PathHashPrefixes: []string{"8f"},
+		},
+		rawCJSON: `{"keyids":["k1"],"name":"n1","path_hash_prefixes":["8f"],"paths":null,"terminating":true,"threshold":5}`,
+	},
+		{
+			testName: "paths only",
+			d: &DelegatedRole{
+				Name:      "n2",
+				KeyIDs:    []string{"k1", "k3"},
+				Threshold: 12,
+				Paths:     []string{"*.txt"},
+			},
+			rawCJSON: `{"keyids":["k1","k3"],"name":"n2","paths":["*.txt"],"terminating":false,"threshold":12}`,
+		},
+		{
+			testName: "default",
+			d:        &DelegatedRole{},
+			rawCJSON: `{"keyids":null,"name":"","paths":null,"terminating":false,"threshold":0}`,
+		},
+	}
+
+	for _, tt := range tts {
+		t.Run(tt.testName, func(t *testing.T) {
+			b, err := cjson.Marshal(tt.d)
+			assert.NoError(t, err)
+			assert.Equal(t, tt.rawCJSON, string(b))
+
+			newD := &DelegatedRole{}
+			err = json.Unmarshal(b, newD)
+			assert.NoError(t, err)
+			assert.Equal(t, tt.d, newD)
+		})
+	}
+}
+
+func TestDelegatedRoleUnmarshalErr(t *testing.T) {
+	targetsWithBothMatchers := []byte(`{"keyids":null,"name":"","paths":["*.txt"],"path_hash_prefixes":["8f"],"terminating":false,"threshold":0}`)
+	var d DelegatedRole
+	assert.Equal(t, ErrPathsAndPathHashesSet, json.Unmarshal(targetsWithBothMatchers, &d))
+
+	// test for type errors
+	err := json.Unmarshal([]byte(`{"keyids":"a"}`), &d)
+	assert.Equal(t, "keyids", err.(*json.UnmarshalTypeError).Field)
+}
diff --git a/repo_test.go b/repo_test.go
index 43694f2..9956464 100644
--- a/repo_test.go
+++ b/repo_test.go
@@ -1,12 +1,15 @@
 package tuf
 
 import (
+	"crypto"
+	"crypto/rand"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"io/ioutil"
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 	"time"
 
@@ -1430,3 +1433,160 @@ func (rs *RepoSuite) TestThreshold(c *C) {
 	c.Assert(err, IsNil)
 	c.Assert(timestampVersion, Equals, 2)
 }
+
+func (rs *RepoSuite) TestAddOrUpdateSignatures(c *C) {
+	files := map[string][]byte{"foo.txt": []byte("foo")}
+	local := MemoryStore(make(map[string]json.RawMessage), files)
+	r, err := NewRepo(local)
+	c.Assert(err, IsNil)
+
+	// don't use consistent snapshots to make the checks simpler
+	c.Assert(r.Init(false), IsNil)
+
+	// generate root key offline and add as a verification key
+	rootKey, err := sign.GenerateEd25519Key()
+	c.Assert(err, IsNil)
+	c.Assert(r.AddVerificationKey("root", rootKey.PublicData()), IsNil)
+	targetsKey, err := sign.GenerateEd25519Key()
+	c.Assert(err, IsNil)
+	c.Assert(r.AddVerificationKey("targets", targetsKey.PublicData()), IsNil)
+	snapshotKey, err := sign.GenerateEd25519Key()
+	c.Assert(err, IsNil)
+	c.Assert(r.AddVerificationKey("snapshot", snapshotKey.PublicData()), IsNil)
+	timestampKey, err := sign.GenerateEd25519Key()
+	c.Assert(err, IsNil)
+	c.Assert(r.AddVerificationKey("timestamp", timestampKey.PublicData()), IsNil)
+
+	// generate signatures externally and append
+	rootMeta, err := r.SignedMeta("root.json")
+	c.Assert(err, IsNil)
+	rootSig, err := rootKey.Signer().Sign(rand.Reader, rootMeta.Signed, crypto.Hash(0))
+	c.Assert(err, IsNil)
+	for _, id := range rootKey.Signer().IDs() {
+		c.Assert(r.AddOrUpdateSignature("root.json", data.Signature{
+			KeyID:     id,
+			Signature: rootSig}), IsNil)
+	}
+
+	// add targets and sign
+	c.Assert(r.AddTarget("foo.txt", nil), IsNil)
+	targetsMeta, err := r.SignedMeta("targets.json")
+	c.Assert(err, IsNil)
+	targetsSig, err := targetsKey.Signer().Sign(rand.Reader, targetsMeta.Signed, crypto.Hash(0))
+	c.Assert(err, IsNil)
+	for _, id := range targetsKey.Signer().IDs() {
+		r.AddOrUpdateSignature("targets.json", data.Signature{
+			KeyID:     id,
+			Signature: targetsSig})
+	}
+
+	// snapshot and timestamp
+	c.Assert(r.Snapshot(CompressionTypeNone), IsNil)
+	snapshotMeta, err := r.SignedMeta("snapshot.json")
+	c.Assert(err, IsNil)
+	snapshotSig, err := snapshotKey.Signer().Sign(rand.Reader, snapshotMeta.Signed, crypto.Hash(0))
+	c.Assert(err, IsNil)
+	for _, id := range snapshotKey.Signer().IDs() {
+		r.AddOrUpdateSignature("snapshot.json", data.Signature{
+			KeyID:     id,
+			Signature: snapshotSig})
+	}
+
+	c.Assert(r.Timestamp(), IsNil)
+	timestampMeta, err := r.SignedMeta("timestamp.json")
+	c.Assert(err, IsNil)
+	timestampSig, err := timestampKey.Signer().Sign(rand.Reader, timestampMeta.Signed, crypto.Hash(0))
+	c.Assert(err, IsNil)
+	for _, id := range timestampKey.Signer().IDs() {
+		r.AddOrUpdateSignature("timestamp.json", data.Signature{
+			KeyID:     id,
+			Signature: timestampSig})
+	}
+
+	// commit successfully!
+	c.Assert(r.Commit(), IsNil)
+}
+
+func (rs *RepoSuite) TestBadAddOrUpdateSignatures(c *C) {
+	files := map[string][]byte{"foo.txt": []byte("foo")}
+	local := MemoryStore(make(map[string]json.RawMessage), files)
+	r, err := NewRepo(local)
+	c.Assert(err, IsNil)
+
+	// don't use consistent snapshots to make the checks simpler
+	c.Assert(r.Init(false), IsNil)
+
+	// generate root key offline and add as a verification key
+	rootKey, err := sign.GenerateEd25519Key()
+	c.Assert(err, IsNil)
+	c.Assert(r.AddVerificationKey("root", rootKey.PublicData()), IsNil)
+	targetsKey, err := sign.GenerateEd25519Key()
+	c.Assert(err, IsNil)
+	c.Assert(r.AddVerificationKey("targets", targetsKey.PublicData()), IsNil)
+	snapshotKey, err := sign.GenerateEd25519Key()
+	c.Assert(err, IsNil)
+	c.Assert(r.AddVerificationKey("snapshot", snapshotKey.PublicData()), IsNil)
+	timestampKey, err := sign.GenerateEd25519Key()
+	c.Assert(err, IsNil)
+	c.Assert(r.AddVerificationKey("timestamp", timestampKey.PublicData()), IsNil)
+
+	// add a signature with a bad role
+	rootMeta, err := r.SignedMeta("root.json")
+	c.Assert(err, IsNil)
+	rootSig, err := rootKey.Signer().Sign(rand.Reader, rootMeta.Signed, crypto.Hash(0))
+	c.Assert(err, IsNil)
+	for _, id := range rootKey.Signer().IDs() {
+		c.Assert(r.AddOrUpdateSignature("invalid_root.json", data.Signature{
+			KeyID:     id,
+			Signature: rootSig}), Equals, ErrInvalidRole{"invalid_root"})
+	}
+
+	// add a root signature with an key ID that is for the targets role
+	for _, id := range targetsKey.Signer().IDs() {
+		c.Assert(r.AddOrUpdateSignature("root.json", data.Signature{
+			KeyID:     id,
+			Signature: rootSig}), Equals, verify.ErrInvalidKey)
+	}
+
+	// attempt to add a bad signature to root
+	badSig, err := rootKey.Signer().Sign(rand.Reader, []byte(""), crypto.Hash(0))
+	c.Assert(err, IsNil)
+	for _, id := range rootKey.Signer().IDs() {
+		c.Assert(r.AddOrUpdateSignature("root.json", data.Signature{
+			KeyID:     id,
+			Signature: badSig}), Equals, verify.ErrInvalid)
+	}
+
+	// add the correct root signature
+	for _, id := range rootKey.Signer().IDs() {
+		c.Assert(r.AddOrUpdateSignature("root.json", data.Signature{
+			KeyID:     id,
+			Signature: rootSig}), IsNil)
+	}
+	checkSigIDs := func(role string) {
+		s, err := r.SignedMeta(role)
+		c.Assert(err, IsNil)
+		db, err := r.db()
+		c.Assert(err, IsNil)
+		// keys is a map of key IDs.
+		keys := db.GetRole(strings.TrimSuffix(role, ".json")).KeyIDs
+		c.Assert(s.Signatures, HasLen, len(keys))
+		// If the lengths are equal, and each signature key ID appears
+		// in the role keys, they Sig IDs are equal to keyIDs.
+		for _, sig := range s.Signatures {
+			if _, ok := keys[sig.KeyID]; !ok {
+				c.Fatal("missing key ID in signatures")
+			}
+		}
+	}
+	checkSigIDs("root.json")
+
+	// re-adding should not duplicate. this is checked by verifying
+	// signature key IDs match with the map of role key IDs.
+	for _, id := range rootKey.Signer().IDs() {
+		c.Assert(r.AddOrUpdateSignature("root.json", data.Signature{
+			KeyID:     id,
+			Signature: rootSig}), IsNil)
+	}
+	checkSigIDs("root.json")
+}
diff --git a/verify/db_test.go b/verify/db_test.go
new file mode 100644
index 0000000..8921993
--- /dev/null
+++ b/verify/db_test.go
@@ -0,0 +1,57 @@
+package verify
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/theupdateframework/go-tuf/data"
+)
+
+func TestDelegationsVerifier(t *testing.T) {
+	var verifierTests = []struct {
+		testName     string
+		delegations  *data.Delegations
+		initErr      error
+		unmarshalErr error
+	}{
+		{
+			testName:     "empty state",
+			delegations:  &data.Delegations{},
+			unmarshalErr: ErrNoSignatures,
+		},
+		{
+			testName: "top level role",
+			delegations: &data.Delegations{Roles: []data.DelegatedRole{
+				{Name: "root"},
+			}},
+			initErr: ErrInvalidDelegatedRole,
+		},
+		{
+			testName: "invalid role",
+			delegations: &data.Delegations{Roles: []data.DelegatedRole{
+				{Threshold: 0},
+			}},
+			initErr: ErrInvalidThreshold,
+		},
+		{
+			testName: "invalid keys",
+			delegations: &data.Delegations{Keys: map[string]*data.Key{
+				"a": &data.Key{Type: data.KeySchemeEd25519},
+			}},
+			initErr: ErrWrongID{},
+		},
+	}
+
+	for _, tt := range verifierTests {
+		t.Run(tt.testName, func(t *testing.T) {
+			verifier, err := NewDelegationsVerifier(tt.delegations)
+			assert.NotNil(t, verifier)
+			assert.Equal(t, tt.initErr, err)
+			if err == nil {
+				var targets data.Targets
+				err = verifier.Unmarshal([]byte(`{"a":"b"}`), targets, "tree", 0)
+				assert.Equal(t, tt.unmarshalErr, err)
+			}
+		})
+	}
+}