Repository: theupdateframework/go-tuf (go)

Issue:
TUF CLI: Improve experience by allowing offline signature generation
This would help using the TUF CLI in more tightly controlled key environments, e.g. an air-gapped situation.

I propose two methods:

`tuf payload <role>` that outputs the canonicalized, signable content of the role's metadata file
`tuf add-signature <role> --signature <sig_file>` that adds a signature (hex-encoded bytes), generated by an offline tool, to the role. This should verify the signature first.

@haydentherapper

Pull request:
feat: add `payload` and `add-signature` commands.
Fixes #205.

Patch:
diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml
index 68627f2..c283a6b 100644
--- a/.github/workflows/changelog.yml
+++ b/.github/workflows/changelog.yml
@@ -13,6 +13,6 @@ jobs:
     runs-on: "ubuntu-latest"
     steps:
       - name:
-        uses: amannn/action-semantic-pull-request@0eb081bc9c35210408951834a444794406eff6f8
+        uses: amannn/action-semantic-pull-request@91682d013dea3ff257520b9b68c9cb93ced4fe9b
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 212efcd..056a925 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -38,11 +38,11 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@v2
+      uses: actions/checkout@v3
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@v1
+      uses: github/codeql-action/init@v2
       with:
         languages: ${{ matrix.language }}
         # If you wish to specify custom queries, you can do so here or in a config file.
@@ -53,7 +53,7 @@ jobs:
     # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
     # If this step fails, then you should remove it and run the build manually (see below)
     - name: Autobuild
-      uses: github/codeql-action/autobuild@v1
+      uses: github/codeql-action/autobuild@v2
 
     # ℹ️ Command-line programs to run using the OS shell.
     # 📚 https://git.io/JvXDl
@@ -67,4 +67,4 @@ jobs:
     #   make release
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v1
+      uses: github/codeql-action/analyze@v2
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ac54755..387fa0c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -12,14 +12,12 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
+        uses: actions/checkout@v3
         with:
           fetch-depth: 0
       - name: Get Go version
-        uses: arnested/go-version-action@d44f8fbecf1ac5ea61d81603e99dfec9833f592f
+        uses: arnested/go-version-action@9e7ad93209f2baf5ad9529968fd23cf29c215966
         id: go-version
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
       - name: Set up Go
         uses: actions/setup-go@f6164bd8c8acb4a71fb2791a8b6c4024ff038dab
         with:
diff --git a/MAINTAINERS b/MAINTAINERS
index b01e605..4d0cfe7 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -3,5 +3,6 @@ Trishank Karthik Kuppusamy <trishank.kuppusamy@datadoghq.com> (github: trishanka
 Joshua Lock <jlock@vmware.com> (github: joshuagl)
 Ethan Lownman <ethan.lowman@datadoghq.com> (github: ethan-lowman-dd)
 Marina Moore <mm9693@nyu.edu> (github: mnm678)
+Zack Newman <zjn@chainguard.dev> (github: znewman01)
 Hossein Siadati <hossein.siadati@datadoghq.com> (github: hosseinsia)
 Radoslav Dimitrov <dimitrovr@vmware.com> (github: rdimitrov)
\ No newline at end of file
diff --git a/README.md b/README.md
index 2ba0a7b..5ea8554 100644
--- a/README.md
+++ b/README.md
@@ -130,6 +130,28 @@ Changes the passphrase for given role keys file. The CLI supports reading
 both the existing and the new passphrase via the following environment
 variables - `TUF_{{ROLE}}_PASSPHRASE` and respectively `TUF_NEW_{{ROLE}}_PASSPHRASE`
 
+#### `tuf payload <metadata>`
+
+Outputs the metadata file for a role in a ready-to-sign (canonicalized) format.
+
+See also `tuf sign-payload` and `tuf add-signatures`.
+
+#### `tuf sign-payload --role=<role> <path>`
+
+Sign a file (outside of the TUF repo) using keys (in the TUF keys database,
+typically produced by `tuf gen-key`) for the given `role` (from the TUF repo).
+
+Typically, `path` will be a file containing the output of `tuf payload`.
+
+See also `tuf add-signatures`.
+
+#### `tuf add-signatures --signatures <sig_file> <metadata>`
+
+
+Adds signatures (the output of `tuf sign-payload`) to the given role metadata file.
+
+If the signature does not verify, it will not be added.
+
 #### Usage of environment variables
 
 The `tuf` CLI supports receiving passphrases via environment variables in
@@ -229,6 +251,46 @@ Enter root keys passphrase:
 The staged `root.json` can now be copied back to the repo box ready to be
 committed alongside other metadata files.
 
+#### Alternate signing flow
+
+Instead of manually copying `root.json` into the TUF repository on the root box,
+you can use the `tuf payload`, `tuf sign-payload`, `tuf add-signatures` flow.
+
+On the repo box, get the `root.json` payload in a canonical format:
+
+``` bash
+$ tuf payload root.json > root.json.payload
+```
+
+Copy `root.json.payload` to the root box and sign it:
+
+
+``` bash
+$ tuf sign-payload --role=root root.json.payload > root.json.sigs
+Enter root keys passphrase:
+```
+
+Copy `root.json.sigs` back to the repo box and import the signatures:
+
+``` bash
+$ tuf add-signatures --signatures=root.json.sigs root.json
+```
+
+This achieves the same state as the above flow for the repo box:
+
+```bash
+$ tree .
+.
+├── keys
+│   ├── snapshot.json
+│   ├── targets.json
+│   └── timestamp.json
+├── repository
+└── staged
+    ├── root.json
+    └── targets
+```
+
 #### Add a target file
 
 Assuming a staged, signed `root` metadata file and the file to add exists at
diff --git a/client/client.go b/client/client.go
index bf22041..0241a74 100644
--- a/client/client.go
+++ b/client/client.go
@@ -177,33 +177,38 @@ func (c *Client) Update() (data.TargetFiles, error) {
 		return nil, err
 	}
 
-	// Get timestamp.json, extract snapshot.json file meta and save the
-	// timestamp.json locally
+	// Load trusted metadata files, if any, and verify them against the latest root
+	c.getLocalMeta()
+
+	// 5.4.1 - Download the timestamp metadata
 	timestampJSON, err := c.downloadMetaUnsafe("timestamp.json", defaultTimestampDownloadLimit)
 	if err != nil {
 		return nil, err
 	}
+	// 5.4.(2,3 and 4) - Verify timestamp against various attacks
+	// Returns the extracted snapshot metadata
 	snapshotMeta, err := c.decodeTimestamp(timestampJSON)
 	if err != nil {
 		return nil, err
 	}
+	// 5.4.5 - Persist the timestamp metadata
 	if err := c.local.SetMeta("timestamp.json", timestampJSON); err != nil {
 		return nil, err
 	}
 
-	// Get snapshot.json, then extract file metas.
-	// root.json meta should not be stored in the snapshot, if it is,
-	// the root will be checked, re-downloaded
+	// 5.5.1 - Download snapshot metadata
+	// 5.5.2 and 5.5.4 - Check against timestamp role's snapshot hash and version
 	snapshotJSON, err := c.downloadMetaFromTimestamp("snapshot.json", snapshotMeta)
 	if err != nil {
 		return nil, err
 	}
+	// 5.5.(3,5 and 6) - Verify snapshot against various attacks
+	// Returns the extracted metadata files
 	snapshotMetas, err := c.decodeSnapshot(snapshotJSON)
 	if err != nil {
 		return nil, err
 	}
-
-	// Save the snapshot.json
+	// 5.5.7 - Persist snapshot metadata
 	if err := c.local.SetMeta("snapshot.json", snapshotJSON); err != nil {
 		return nil, err
 	}
@@ -213,14 +218,18 @@ func (c *Client) Update() (data.TargetFiles, error) {
 	var updatedTargets data.TargetFiles
 	targetsMeta := snapshotMetas["targets.json"]
 	if !c.hasMetaFromSnapshot("targets.json", targetsMeta) {
+		// 5.6.1 - Download the top-level targets metadata file
+		// 5.6.2 and 5.6.4 - Check against snapshot role's targets hash and version
 		targetsJSON, err := c.downloadMetaFromSnapshot("targets.json", targetsMeta)
 		if err != nil {
 			return nil, err
 		}
+		// 5.6.(3 and 5) - Verify signatures and check against freeze attack
 		updatedTargets, err = c.decodeTargets(targetsJSON)
 		if err != nil {
 			return nil, err
 		}
+		// 5.6.6 - Persist targets metadata
 		if err := c.local.SetMeta("targets.json", targetsJSON); err != nil {
 			return nil, err
 		}
@@ -393,44 +402,69 @@ func (c *Client) UpdateRoots() error {
 // getLocalMeta decodes and verifies metadata from local storage.
 // The verification of local files is purely for consistency, if an attacker
 // has compromised the local storage, there is no guarantee it can be trusted.
+// Before trying to load the metadata files, it clears the in-memory copy of the local metadata.
+// This is to insure that all of the loaded metadata files at the end are indeed verified by the latest root.
+// If some of the metadata files fail to load it will proceed with trying to load the rest,
+// but still return an error at the end, if such occurred. Otherwise returns nil.
 func (c *Client) getLocalMeta() error {
+	var retErr error
+	loadFailed := false
+	// Clear the in-memory copy of the local metadata. The goal is to reload and take into account
+	// only the metadata files that are verified by the latest root. Otherwise, their content should
+	// be ignored.
+	c.localMeta = make(map[string]json.RawMessage)
+
+	// Load the latest root meta
 	if err := c.loadAndVerifyLocalRootMeta( /*ignoreExpiredCheck=*/ false); err != nil {
 		return err
 	}
 
+	// Load into memory the existing meta, if any, from the local storage
 	meta, err := c.local.GetMeta()
 	if err != nil {
 		return nil
 	}
 
+	// Verify the top-level metadata (timestamp, snapshot and targets) against the latest root and load it, if okay
 	if timestampJSON, ok := meta["timestamp.json"]; ok {
 		timestamp := &data.Timestamp{}
 		if err := c.db.UnmarshalTrusted(timestampJSON, timestamp, "timestamp"); err != nil {
-			return err
+			loadFailed = true
+			retErr = err
+		} else {
+			c.localMeta["timestamp.json"] = meta["timestamp.json"]
+			c.timestampVer = timestamp.Version
 		}
-		c.timestampVer = timestamp.Version
 	}
 
 	if snapshotJSON, ok := meta["snapshot.json"]; ok {
 		snapshot := &data.Snapshot{}
 		if err := c.db.UnmarshalTrusted(snapshotJSON, snapshot, "snapshot"); err != nil {
-			return err
+			loadFailed = true
+			retErr = err
+		} else {
+			c.localMeta["snapshot.json"] = meta["snapshot.json"]
+			c.snapshotVer = snapshot.Version
 		}
-		c.snapshotVer = snapshot.Version
 	}
 
 	if targetsJSON, ok := meta["targets.json"]; ok {
 		targets := &data.Targets{}
 		if err := c.db.UnmarshalTrusted(targetsJSON, targets, "targets"); err != nil {
-			return err
+			loadFailed = true
+			retErr = err
+		} else {
+			c.localMeta["targets.json"] = meta["targets.json"]
+			c.targetsVer = targets.Version
+			// FIXME(TUF-0.9) temporarily support files with leading path separators.
+			// c.targets = targets.Targets
+			c.loadTargets(targets.Targets)
 		}
-		c.targetsVer = targets.Version
-		// FIXME(TUF-0.9) temporarily support files with leading path separators.
-		// c.targets = targets.Targets
-		c.loadTargets(targets.Targets)
 	}
-
-	c.localMeta = meta
+	if loadFailed {
+		// If any of the metadata failed to be verified, return the reason for that failure
+		return retErr
+	}
 	return nil
 }
 
@@ -660,6 +694,7 @@ func (c *Client) downloadMetaFromSnapshot(name string, m data.SnapshotFileMeta)
 	if err != nil {
 		return nil, err
 	}
+	// 5.6.2 and 5.6.4 - Check against snapshot role's targets hash and version
 	if err := util.SnapshotFileMetaEqual(meta, m); err != nil {
 		return nil, ErrDownloadFailed{name, err}
 	}
@@ -676,6 +711,7 @@ func (c *Client) downloadMetaFromTimestamp(name string, m data.TimestampFileMeta
 	if err != nil {
 		return nil, err
 	}
+	// 5.5.2 and 5.5.4 - Check against timestamp role's snapshot hash and version
 	if err := util.TimestampFileMetaEqual(meta, m); err != nil {
 		return nil, ErrDownloadFailed{name, err}
 	}
@@ -697,10 +733,41 @@ func (c *Client) decodeRoot(b json.RawMessage) error {
 // root and targets file meta.
 func (c *Client) decodeSnapshot(b json.RawMessage) (data.SnapshotFiles, error) {
 	snapshot := &data.Snapshot{}
+	// 5.5.(3 and 6) - Verify it's signed correctly and it's not expired
 	if err := c.db.Unmarshal(b, snapshot, "snapshot", c.snapshotVer); err != nil {
 		return data.SnapshotFiles{}, ErrDecodeFailed{"snapshot.json", err}
 	}
-	c.snapshotVer = snapshot.Version
+	// 5.5.5 - Check for top-level targets rollback attack
+	// Verify explicitly that current targets meta version is less than or equal to the new one
+	if snapshot.Meta["targets.json"].Version < c.targetsVer {
+		return data.SnapshotFiles{}, verify.ErrLowVersion{Actual: snapshot.Meta["targets.json"].Version, Current: c.targetsVer}
+	}
+
+	// 5.5.5 - Get the local/trusted snapshot metadata, if any, and check all target metafiles against rollback attack
+	// In case the local snapshot metadata was not verified by the keys in the latest root during getLocalMeta(),
+	// snapshot.json won't be present in c.localMeta and thus this check will not be processed.
+	if snapshotJSON, ok := c.localMeta["snapshot.json"]; ok {
+		currentSnapshot := &data.Snapshot{}
+		if err := c.db.UnmarshalTrusted(snapshotJSON, currentSnapshot, "snapshot"); err != nil {
+			return data.SnapshotFiles{}, err
+		}
+		// 5.5.5 - Check for rollback attacks in both top-level and delegated targets roles (note that the Meta object includes both)
+		for path, local := range currentSnapshot.Meta {
+			if newMeta, ok := snapshot.Meta[path]; ok {
+				// 5.5.5 - Check for rollback attack
+				if newMeta.Version < local.Version {
+					return data.SnapshotFiles{}, verify.ErrLowVersion{Actual: newMeta.Version, Current: local.Version}
+				}
+			} else {
+				// 5.5.5 - Abort the update if a target file has been removed from the new snapshot file
+				return data.SnapshotFiles{}, verify.ErrMissingTargetFile
+			}
+		}
+	}
+	// At this point we can trust the new snapshot, the top-level targets, and any delegated targets versions it refers to
+	// so we can update the client's trusted versions and proceed with persisting the new snapshot metadata
+	// c.snapshotVer was already set when we verified the timestamp metadata
+	c.targetsVer = snapshot.Meta["targets.json"].Version
 	return snapshot.Meta, nil
 }
 
@@ -708,9 +775,11 @@ func (c *Client) decodeSnapshot(b json.RawMessage) (data.SnapshotFiles, error) {
 // returns updated targets.
 func (c *Client) decodeTargets(b json.RawMessage) (data.TargetFiles, error) {
 	targets := &data.Targets{}
+	// 5.6.(3 and 5) - Verify signatures and check against freeze attack
 	if err := c.db.Unmarshal(b, targets, "targets", c.targetsVer); err != nil {
 		return nil, ErrDecodeFailed{"targets.json", err}
 	}
+	// Generate a list with the updated targets
 	updatedTargets := make(data.TargetFiles)
 	for path, meta := range targets.Targets {
 		if local, ok := c.targets[path]; ok {
@@ -720,7 +789,7 @@ func (c *Client) decodeTargets(b json.RawMessage) (data.TargetFiles, error) {
 		}
 		updatedTargets[path] = meta
 	}
-	c.targetsVer = targets.Version
+	// c.targetsVer was already updated when we verified the snapshot metadata
 	// FIXME(TUF-0.9) temporarily support files with leading path separators.
 	// c.targets = targets.Targets
 	c.loadTargets(targets.Targets)
@@ -734,7 +803,15 @@ func (c *Client) decodeTimestamp(b json.RawMessage) (data.TimestampFileMeta, err
 	if err := c.db.Unmarshal(b, timestamp, "timestamp", c.timestampVer); err != nil {
 		return data.TimestampFileMeta{}, ErrDecodeFailed{"timestamp.json", err}
 	}
+	// 5.4.3.2 - Check for snapshot rollback attack
+	// Verify that the current snapshot meta version is less than or equal to the new one
+	if timestamp.Meta["snapshot.json"].Version < c.snapshotVer {
+		return data.TimestampFileMeta{}, verify.ErrLowVersion{Actual: timestamp.Meta["snapshot.json"].Version, Current: c.snapshotVer}
+	}
+	// At this point we can trust the new timestamp and the snaphost version it refers to
+	// so we can update the client's trusted versions and proceed with persisting the new timestamp
 	c.timestampVer = timestamp.Version
+	c.snapshotVer = timestamp.Meta["snapshot.json"].Version
 	return timestamp.Meta["snapshot.json"], nil
 }
 
diff --git a/client/errors.go b/client/errors.go
index 0edd957..3e7a5dc 100644
--- a/client/errors.go
+++ b/client/errors.go
@@ -70,19 +70,6 @@ func (e ErrWrongSize) Error() string {
 	return fmt.Sprintf("tuf: unexpected file size: %s (expected %d bytes, got %d bytes)", e.File, e.Expected, e.Actual)
 }
 
-type ErrLatestSnapshot struct {
-	Version int64
-}
-
-func (e ErrLatestSnapshot) Error() string {
-	return fmt.Sprintf("tuf: the local snapshot version (%d) is the latest", e.Version)
-}
-
-func IsLatestSnapshot(err error) bool {
-	_, ok := err.(ErrLatestSnapshot)
-	return ok
-}
-
 type ErrUnknownTarget struct {
 	Name            string
 	SnapshotVersion int64
diff --git a/cmd/tuf-client/get.go b/cmd/tuf-client/get.go
index 3922b94..1f60429 100644
--- a/cmd/tuf-client/get.go
+++ b/cmd/tuf-client/get.go
@@ -31,7 +31,7 @@ func (t *tmpFile) Delete() error {
 }
 
 func cmdGet(args *docopt.Args, client *tuf.Client) error {
-	if _, err := client.Update(); err != nil && !tuf.IsLatestSnapshot(err) {
+	if _, err := client.Update(); err != nil {
 		return err
 	}
 	target := util.NormalizeTarget(args.String["<target>"])
diff --git a/cmd/tuf-client/list.go b/cmd/tuf-client/list.go
index a3cca66..f00209d 100644
--- a/cmd/tuf-client/list.go
+++ b/cmd/tuf-client/list.go
@@ -22,7 +22,7 @@ List available target files.
 }
 
 func cmdList(args *docopt.Args, client *tuf.Client) error {
-	if _, err := client.Update(); err != nil && !tuf.IsLatestSnapshot(err) {
+	if _, err := client.Update(); err != nil {
 		return err
 	}
 	targets, err := client.Targets()
diff --git a/cmd/tuf/add_signatures.go b/cmd/tuf/add_signatures.go
new file mode 100644
index 0000000..6508736
--- /dev/null
+++ b/cmd/tuf/add_signatures.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/flynn/go-docopt"
+	"github.com/theupdateframework/go-tuf"
+	"github.com/theupdateframework/go-tuf/data"
+)
+
+func init() {
+	register("add-signatures", cmdAddSignature, `
+usage: tuf add-signatures --signatures <sig_file> <metadata>
+
+Adds signatures (the output of "sign-payload") to the given role metadata file.
+
+If the signature does not verify, it will not be added.
+`)
+}
+
+func cmdAddSignature(args *docopt.Args, repo *tuf.Repo) error {
+	roleFilename := args.String["<metadata>"]
+
+	f := args.String["<sig_file>"]
+	sigBytes, err := os.ReadFile(f)
+	if err != nil {
+		return err
+	}
+	sigs := []data.Signature{}
+	if err = json.Unmarshal(sigBytes, &sigs); err != nil {
+		return err
+	}
+	for _, sig := range sigs {
+		if err = repo.AddOrUpdateSignature(roleFilename, sig); err != nil {
+			return err
+		}
+	}
+	fmt.Fprintln(os.Stderr, "tuf: added", len(sigs), "new signature(s)")
+	return nil
+}
diff --git a/cmd/tuf/main.go b/cmd/tuf/main.go
index 4017987..137420f 100644
--- a/cmd/tuf/main.go
+++ b/cmd/tuf/main.go
@@ -36,7 +36,10 @@ Commands:
   remove             Remove a target file
   snapshot           Update the snapshot metadata file
   timestamp          Update the timestamp metadata file
+  payload            Output a role's metadata file for signing
+  add-signatures     Adds signatures generated offline
   sign               Sign a role's metadata file
+  sign-payload       Sign a file from the "payload" command.
   commit             Commit staged files to the repository
   regenerate         Recreate the targets metadata file [Not supported yet]
   set-threshold      Sets the threshold for a role
diff --git a/cmd/tuf/payload.go b/cmd/tuf/payload.go
new file mode 100644
index 0000000..8cc0c2f
--- /dev/null
+++ b/cmd/tuf/payload.go
@@ -0,0 +1,25 @@
+package main
+
+import (
+	"fmt"
+
+	"github.com/flynn/go-docopt"
+	"github.com/theupdateframework/go-tuf"
+)
+
+func init() {
+	register("payload", cmdPayload, `
+usage: tuf payload <metadata>
+
+Outputs the metadata file for a role in a ready-to-sign (canonicalized) format.
+`)
+}
+
+func cmdPayload(args *docopt.Args, repo *tuf.Repo) error {
+	p, err := repo.Payload(args.String["<metadata>"])
+	if err != nil {
+		return err
+	}
+	fmt.Print(string(p))
+	return nil
+}
diff --git a/cmd/tuf/sign_payload.go b/cmd/tuf/sign_payload.go
new file mode 100644
index 0000000..8da5642
--- /dev/null
+++ b/cmd/tuf/sign_payload.go
@@ -0,0 +1,43 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/flynn/go-docopt"
+	tuf "github.com/theupdateframework/go-tuf"
+	tufdata "github.com/theupdateframework/go-tuf/data"
+)
+
+func init() {
+	register("sign-payload", cmdSignPayload, `
+usage: tuf sign-payload --role=<role> <path>
+
+Sign a file (outside of the TUF repo) using keys for the given role (from the TUF repo).
+
+Typically, path will be the output of "tuf payload".
+`)
+}
+
+func cmdSignPayload(args *docopt.Args, repo *tuf.Repo) error {
+	payload, err := os.ReadFile(args.String["<path>"])
+	if err != nil {
+		return err
+	}
+	signed := tufdata.Signed{Signed: payload, Signatures: make([]tufdata.Signature, 0)}
+
+	numKeys, err := repo.SignPayload(args.String["--role"], &signed)
+	if err != nil {
+		return err
+	}
+
+	bytes, err := json.Marshal(signed.Signatures)
+	if err != nil {
+		return err
+	}
+	fmt.Print(string(bytes))
+
+	fmt.Fprintln(os.Stderr, "tuf: signed with", numKeys, "key(s)")
+	return nil
+}
diff --git a/errors.go b/errors.go
index 09df039..0051c43 100644
--- a/errors.go
+++ b/errors.go
@@ -17,7 +17,7 @@ type ErrMissingMetadata struct {
 }
 
 func (e ErrMissingMetadata) Error() string {
-	return fmt.Sprintf("tuf: missing metadata %s", e.Name)
+	return fmt.Sprintf("tuf: missing metadata file %s", e.Name)
 }
 
 type ErrFileNotFound struct {
@@ -28,12 +28,12 @@ func (e ErrFileNotFound) Error() string {
 	return fmt.Sprintf("tuf: file not found %s", e.Path)
 }
 
-type ErrInsufficientKeys struct {
+type ErrNoKeys struct {
 	Name string
 }
 
-func (e ErrInsufficientKeys) Error() string {
-	return fmt.Sprintf("tuf: insufficient keys to sign %s", e.Name)
+func (e ErrNoKeys) Error() string {
+	return fmt.Sprintf("tuf: no keys available to sign %s", e.Name)
 }
 
 type ErrInsufficientSignatures struct {
diff --git a/repo.go b/repo.go
index 0553fd6..e4992fe 100644
--- a/repo.go
+++ b/repo.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"encoding/hex"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"path"
@@ -11,6 +12,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/secure-systems-lab/go-securesystemslib/cjson"
 	"github.com/theupdateframework/go-tuf/data"
 	"github.com/theupdateframework/go-tuf/internal/roles"
 	"github.com/theupdateframework/go-tuf/internal/sets"
@@ -734,33 +736,48 @@ func (r *Repo) setMeta(roleFilename string, meta interface{}) error {
 	return r.local.SetMeta(roleFilename, b)
 }
 
-func (r *Repo) Sign(roleFilename string) error {
-	role := strings.TrimSuffix(roleFilename, ".json")
-
-	s, err := r.SignedMeta(roleFilename)
-	if err != nil {
-		return err
-	}
-
+// SignPayload signs the given payload using the key(s) associated with role.
+//
+// It returns the total number of keys used for signing, 0 (along with
+// ErrNoKeys) if no keys were found, or -1 (along with an error) in error cases.
+func (r *Repo) SignPayload(role string, payload *data.Signed) (int, error) {
 	keys, err := r.signersForRole(role)
 	if err != nil {
-		return err
+		return -1, err
 	}
 	if len(keys) == 0 {
-		return ErrInsufficientKeys{roleFilename}
+		return 0, ErrNoKeys{role}
 	}
 	for _, k := range keys {
-		sign.Sign(s, k)
+		if err = sign.Sign(payload, k); err != nil {
+			return -1, err
+		}
 	}
+	return len(keys), nil
+}
 
-	b, err := r.jsonMarshal(s)
+func (r *Repo) Sign(roleFilename string) error {
+	signed, err := r.SignedMeta(roleFilename)
+	if err != nil {
+		return err
+	}
+
+	role := strings.TrimSuffix(roleFilename, ".json")
+	numKeys, err := r.SignPayload(role, signed)
+	if errors.Is(err, ErrNoKeys{role}) {
+		return ErrNoKeys{roleFilename}
+	} else if err != nil {
+		return err
+	}
+
+	b, err := r.jsonMarshal(signed)
 	if err != nil {
 		return err
 	}
 	r.meta[roleFilename] = b
 	err = r.local.SetMeta(roleFilename, b)
 	if err == nil {
-		fmt.Println("Signed", roleFilename, "with", len(keys), "key(s)")
+		fmt.Println("Signed", roleFilename, "with", numKeys, "key(s)")
 	}
 	return err
 }
@@ -1527,3 +1544,17 @@ func (r *Repo) timestampFileMeta(roleFilename string) (data.TimestampFileMeta, e
 	}
 	return util.GenerateTimestampFileMeta(bytes.NewReader(b), r.hashAlgorithms...)
 }
+
+func (r *Repo) Payload(roleFilename string) ([]byte, error) {
+	s, err := r.SignedMeta(roleFilename)
+	if err != nil {
+		return nil, err
+	}
+
+	p, err := cjson.EncodeCanonical(s.Signed)
+	if err != nil {
+		return nil, err
+	}
+
+	return p, nil
+}
diff --git a/util/util.go b/util/util.go
index b6bf9cf..5690b88 100644
--- a/util/util.go
+++ b/util/util.go
@@ -118,13 +118,13 @@ func SnapshotFileMetaEqual(actual data.SnapshotFileMeta, expected data.SnapshotF
 	if expected.Length != 0 && actual.Length != expected.Length {
 		return ErrWrongLength{expected.Length, actual.Length}
 	}
-
+	// 5.6.2 - Check against snapshot role's targets hash
 	if len(expected.Hashes) != 0 {
 		if err := hashEqual(actual.Hashes, expected.Hashes); err != nil {
 			return err
 		}
 	}
-
+	// 5.6.4 - Check against snapshot role's snapshot version
 	if err := versionEqual(actual.Version, expected.Version); err != nil {
 		return err
 	}
@@ -137,13 +137,18 @@ func TargetFileMetaEqual(actual data.TargetFileMeta, expected data.TargetFileMet
 }
 
 func TimestampFileMetaEqual(actual data.TimestampFileMeta, expected data.TimestampFileMeta) error {
-	// As opposed to snapshots, the length and hashes are still required in
-	// TUF-1.0. See:
-	// https://github.com/theupdateframework/specification/issues/38
-	if err := FileMetaEqual(actual.FileMeta, expected.FileMeta); err != nil {
-		return err
+	// TUF no longer considers the length and hashes to be a required
+	// member of Timestamp.
+	if expected.Length != 0 && actual.Length != expected.Length {
+		return ErrWrongLength{expected.Length, actual.Length}
 	}
-
+	// 5.5.2 - Check against timestamp role's snapshot hash
+	if len(expected.Hashes) != 0 {
+		if err := hashEqual(actual.Hashes, expected.Hashes); err != nil {
+			return err
+		}
+	}
+	// 5.5.4 - Check against timestamp role's snapshot version
 	if err := versionEqual(actual.Version, expected.Version); err != nil {
 		return err
 	}
diff --git a/verify/errors.go b/verify/errors.go
index 11dfa14..f94321e 100644
--- a/verify/errors.go
+++ b/verify/errors.go
@@ -18,6 +18,7 @@ var (
 	ErrInvalidDelegatedRole = errors.New("tuf: invalid delegated role")
 	ErrInvalidKeyID         = errors.New("tuf: invalid key id")
 	ErrInvalidThreshold     = errors.New("tuf: invalid role threshold")
+	ErrMissingTargetFile    = errors.New("tuf: missing previously listed targets metadata file")
 )
 
 type ErrWrongID struct{}
diff --git a/verify/verify.go b/verify/verify.go
index 7b6726f..c12aaaf 100644
--- a/verify/verify.go
+++ b/verify/verify.go
@@ -47,7 +47,7 @@ func (db *DB) VerifyIgnoreExpiredCheck(s *data.Signed, role string, minVersion i
 }
 
 func (db *DB) Verify(s *data.Signed, role string, minVersion int64) error {
-
+	// Verify signatures and versions
 	err := db.VerifyIgnoreExpiredCheck(s, role, minVersion)
 
 	if err != nil {
@@ -58,7 +58,7 @@ func (db *DB) Verify(s *data.Signed, role string, minVersion int64) error {
 	if err := json.Unmarshal(s.Signed, sm); err != nil {
 		return err
 	}
-
+	// Verify expiration
 	if IsExpired(sm.Expires) {
 		return ErrExpired{sm.Expires}
 	}


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 033fd6a..66cc679 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -8,11 +8,9 @@ jobs:
     outputs:
       matrix: ${{ steps.versions.outputs.matrix }}
     steps:
-      - uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
-      - uses: arnested/go-version-action@d44f8fbecf1ac5ea61d81603e99dfec9833f592f
+      - uses: actions/checkout@v3
+      - uses: arnested/go-version-action@9e7ad93209f2baf5ad9529968fd23cf29c215966
         id: versions
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
   run:
     strategy:
@@ -23,7 +21,7 @@ jobs:
     needs: get-go-versions
     steps:
       - name: Checkout code
-        uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
+        uses: actions/checkout@v3
 
       - name: Setup - Go ${{ matrix.go-version }}
         uses: actions/setup-go@f6164bd8c8acb4a71fb2791a8b6c4024ff038dab
@@ -31,7 +29,7 @@ jobs:
           go-version: ${{ matrix.go-version }}
 
       - name: Setup - Python
-        uses: actions/setup-python@7f80679172b057fc5e90d70d197929d454754a5a
+        uses: actions/setup-python@98f2ad02fd48d057ee3b4d4f66525b231c3e52b6
         with:
           python-version: 3.6
           cache: "pip"
@@ -61,8 +59,9 @@ jobs:
     needs: get-go-versions
     steps:
       - uses: actions/setup-go@f6164bd8c8acb4a71fb2791a8b6c4024ff038dab
-      - uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
+      - uses: actions/checkout@v3
       - name: golangci-lint
         uses: golangci/golangci-lint-action@5c56cd6c9dc07901af25baab6f2b0d9f3b7c3018
         with:
           version: v1.45.2
+          args: --timeout 3m 
diff --git a/client/client_test.go b/client/client_test.go
index 85fa57e..2085dcd 100644
--- a/client/client_test.go
+++ b/client/client_test.go
@@ -733,6 +733,36 @@ func (s *ClientSuite) TestNewTargetsKey(c *C) {
 	c.Assert(role.KeyIDs, DeepEquals, sets.StringSliceToSet(newIDs))
 }
 
+func (s *ClientSuite) TestOfflineSignatureFlow(c *C) {
+	client := s.newClient(c)
+
+	// replace key
+	oldIDs := s.keyIDs["targets"]
+	c.Assert(s.repo.RevokeKey("targets", oldIDs[0]), IsNil)
+	_ = s.genKey(c, "targets")
+
+	// re-sign targets using offline flow and generate new snapshot and timestamp
+	payload, err := s.repo.Payload("targets.json")
+	c.Assert(err, IsNil)
+	signed := data.Signed{Signed: payload}
+	_, err = s.repo.SignPayload("targets", &signed)
+	c.Assert(err, IsNil)
+	for _, sig := range signed.Signatures {
+		// This method checks that the signature verifies!
+		err = s.repo.AddOrUpdateSignature("targets.json", sig)
+		c.Assert(err, IsNil)
+	}
+	c.Assert(s.repo.Snapshot(), IsNil)
+	c.Assert(s.repo.Timestamp(), IsNil)
+	c.Assert(s.repo.Commit(), IsNil)
+	s.syncRemote(c)
+
+	// check update gets new metadata
+	c.Assert(client.getLocalMeta(), IsNil)
+	_, err = client.Update()
+	c.Assert(err, IsNil)
+}
+
 func (s *ClientSuite) TestLocalExpired(c *C) {
 	client := s.newClient(c)
 
diff --git a/client/interop_test.go b/client/interop_test.go
index 19a8d9b..8e4becb 100644
--- a/client/interop_test.go
+++ b/client/interop_test.go
@@ -9,7 +9,6 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
-	"strconv"
 
 	"github.com/theupdateframework/go-tuf/util"
 	. "gopkg.in/check.v1"
@@ -159,9 +158,17 @@ func (t *testCase) runStep(c *C, stepName string) {
 	// check update returns the correct updated targets
 	files, err := client.Update()
 	c.Assert(err, IsNil)
-	l, _ := strconv.Atoi(stepName)
-	c.Assert(files, HasLen, l+1)
-
+	if stepName != "2" {
+		// The rest of the test cases add one target file at a time for each cycle, so this is why we expect that
+		// the number of updated targets returned by Update() should equals to 1
+		c.Assert(files, HasLen, 1)
+	} else {
+		// The following test case (#2) verifies that when a targets key has been rotated in the latest 3.root.json,
+		// the local targets.json meta is indeed ignored since it's signed with a key that has been now changed.
+		// The reason we check for 3 here is that the updated targets corresponds to all target files listed in the
+		// targets.json for test case #2
+		c.Assert(files, HasLen, 3)
+	}
 	targetName := stepName
 	t.targets[targetName] = []byte(targetName)
 
diff --git a/repo_test.go b/repo_test.go
index c71bf5f..089cf8f 100644
--- a/repo_test.go
+++ b/repo_test.go
@@ -1,6 +1,7 @@
 package tuf
 
 import (
+	"bytes"
 	"crypto"
 	"crypto/rand"
 	"encoding/hex"
@@ -638,8 +639,8 @@ func (rs *RepoSuite) TestSign(c *C) {
 
 	c.Assert(r.Sign("foo.json"), Equals, ErrMissingMetadata{"foo.json"})
 
-	// signing with no keys returns ErrInsufficientKeys
-	c.Assert(r.Sign("root.json"), Equals, ErrInsufficientKeys{"root.json"})
+	// signing with no keys returns ErrNoKeys
+	c.Assert(r.Sign("root.json"), Equals, ErrNoKeys{"root.json"})
 
 	checkSigIDs := func(keyIDs ...string) {
 		meta, err := local.GetMeta()
@@ -1784,6 +1785,13 @@ func (rs *RepoSuite) TestBadAddOrUpdateSignatures(c *C) {
 	c.Assert(err, IsNil)
 	c.Assert(r.AddVerificationKey("timestamp", timestampKey.PublicData()), IsNil)
 
+	// attempt to sign `root`, rather than `root.json`
+	for _, id := range rootKey.PublicData().IDs() {
+		c.Assert(r.AddOrUpdateSignature("root", data.Signature{
+			KeyID:     id,
+			Signature: nil}), Equals, ErrMissingMetadata{"root"})
+	}
+
 	// add a signature with a bad role
 	rootMeta, err := r.SignedMeta("root.json")
 	c.Assert(err, IsNil)
@@ -2544,3 +2552,46 @@ func (rs *RepoSuite) TestAddOrUpdateSignatureWithDelegations(c *C) {
 	c.Assert(r.Timestamp(), IsNil)
 	c.Assert(r.Commit(), IsNil)
 }
+
+// Test the offline signature flow: Payload -> SignPayload -> AddSignature
+func (rs *RepoSuite) TestOfflineFlow(c *C) {
+	// Set up repo.
+	meta := make(map[string]json.RawMessage)
+	local := MemoryStore(meta, nil)
+	r, err := NewRepo(local)
+	c.Assert(err, IsNil)
+	c.Assert(r.Init(false), IsNil)
+	_, err = r.GenKey("root")
+	c.Assert(err, IsNil)
+
+	// Get the payload to sign
+	_, err = r.Payload("badrole.json")
+	c.Assert(err, Equals, ErrMissingMetadata{"badrole.json"})
+	_, err = r.Payload("root")
+	c.Assert(err, Equals, ErrMissingMetadata{"root"})
+	payload, err := r.Payload("root.json")
+	c.Assert(err, IsNil)
+
+	root, err := r.SignedMeta("root.json")
+	c.Assert(err, IsNil)
+	rootCanonical, err := cjson.EncodeCanonical(root.Signed)
+	c.Assert(err, IsNil)
+	if !bytes.Equal(payload, rootCanonical) {
+		c.Fatalf("Payload(): not canonical.\n%s\n%s", string(payload), string(rootCanonical))
+	}
+
+	// Sign the payload
+	signed := data.Signed{Signed: payload}
+	_, err = r.SignPayload("targets", &signed)
+	c.Assert(err, Equals, ErrNoKeys{"targets"})
+	numKeys, err := r.SignPayload("root", &signed)
+	c.Assert(err, IsNil)
+	c.Assert(numKeys, Equals, 1)
+
+	// Add the payload signatures back
+	for _, sig := range signed.Signatures {
+		// This method checks that the signature verifies!
+		err = r.AddOrUpdateSignature("root.json", sig)
+		c.Assert(err, IsNil)
+	}
+}