Repository: theupdateframework/go-tuf
Pull request #137 (2021-07-12 11:28:24)

Support delegations in client
This PR adds the support of delegations in the client

#### Notable changes
- support delegations in client.Download, following spec 1.0.19 section 5.6
- root is no longer required in the snapshot meta

#### Notes
- ignore the commit `Add TUF3 php test`, this commit only imports test files
- copied TUF3 files from php fixtures to test delegations logic

#### Follow ups
- add chained root downloading to comply with spec and add other fixtures from php

#### Review walkthrough
##### 1. `data/types.go data/types_test.go` 
-  delegations fields are added to the structs with JSON support
-  `MatchesPath` matches a file to a delegation with the support of Paths and PathHashPrefixes
##### 2. `verify/db.go`
- reuse DB struct verifiers to verify delegated roles from a parent Targets keys
- `delegationsVerifier` is added to bypass specific logic dedicated to the 4 top roles
##### 3. `client/client.go`
- client.Download will now try to fetch FileMeta through the graph of delegations if it's not already cached. Previous pattern was  check cache, check local top level Targets. New pattern is check cache, check local top level Targets, check local delegations, download missing delegations.
- root is no longer required in snapshot
- MaxDelegations parameter is introduced to bound a search in the delegations
##### 4. `client/delegations.go client/delegations_test.go`
- `getTargetFileMeta` implements the delegation search logic



trishankatdatadog: Thanks, @raphaelgavache!

Would you please summarize the changes over the source code, so that we know what to expect while reviewing?
raphaelgavache: Thanks Ethan, the delegation verifier code is now a lot cleaner! 
I'll apply your other comments tomorrow morning and add an error when both Paths and PathsPrefixes are set to stick to the spec
trishankatdatadog: Thanks for your review, @ethan-lowman-dd!

@raphaelgavache: does this implement a single-pass (i.e., look for only one target at a time) or a multi-pass (i.e., look for multiple targets at once) approach to delegations?
raphaelgavache: It's the single-pass one that searches for one target at a time
coveralls: ## Pull Request Test Coverage Report for [Build 1024427245](https://coveralls.io/builds/41308181)

* **148** of **203**   **(72.91%)**  changed or added relevant lines in **7** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.5%**) to **67.853%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [client/client.go](https://coveralls.io/builds/41308181/source?filename=client%2Fclient.go#L607) | 20 | 21 | 95.24%
| [client/errors.go](https://coveralls.io/builds/41308181/source?filename=client%2Ferrors.go#L48) | 0 | 10 | 0.0%
| [verify/db.go](https://coveralls.io/builds/41308181/source?filename=verify%2Fdb.go#L36) | 2 | 17 | 11.76%
| [client/delegations.go](https://coveralls.io/builds/41308181/source?filename=client%2Fdelegations.go#L14) | 107 | 136 | 78.68%
<!-- | **Total:** | **148** | **203** | **72.91%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/41308181/badge)](https://coveralls.io/builds/41308181) |
| :-- | --: |
| Change from base [Build 987132054](https://coveralls.io/builds/41013434): |  0.5% |
| Covered Lines: | 1716 |
| Relevant Lines: | 2529 |

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

trishankatdatadog: As a sanity-check, can we cross-check for correctness against [this recursive implementation](https://github.com/theupdateframework/tuf/pull/1463)?

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..1c142c4 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
 }
 
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/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)
+			}
+		})
+	}
+}