diff --git a/README.md b/README.md index 2ba0a7b7..5ea8554f 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 ` + +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= ` + +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 ` + + +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_test.go b/client/client_test.go index 85fa57e1..2085dcd8 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/cmd/tuf/add_signatures.go b/cmd/tuf/add_signatures.go new file mode 100644 index 00000000..65087360 --- /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 + +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[""] + + f := args.String[""] + 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 4017987e..137420f1 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 00000000..8cc0c2ff --- /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 + +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[""]) + 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 00000000..8da5642b --- /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= + +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[""]) + 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 09df0390..0051c439 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 0553fd61..e4992fe3 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/repo_test.go b/repo_test.go index c71bf5f0..089cf8f6 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) + } +}