Skip to content

Commit 2e5bf75

Browse files
authored
fleetd to start up when TUF signatures are expired (#23102)
#22740 Full QA is still a WIP but this is ready for review. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Added/updated tests - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [x] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [x] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [x] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)).
1 parent 48578a0 commit 2e5bf75

File tree

19 files changed

+456
-127
lines changed

19 files changed

+456
-127
lines changed

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ fleet-dev: fleet
127127
fleetctl: .prefix .pre-build .pre-fleetctl
128128
# Race requires cgo
129129
$(eval CGO_ENABLED := $(shell [[ "${GO_BUILD_RACE_ENABLED_VAR}" = "true" ]] && echo 1 || echo 0))
130-
CGO_ENABLED=${CGO_ENABLED} go build -race=${GO_BUILD_RACE_ENABLED_VAR} -o build/fleetctl -ldflags ${LDFLAGS_VERSION} ./cmd/fleetctl
130+
$(eval FLEETCTL_LDFLAGS := $(shell echo "${LDFLAGS_VERSION} ${EXTRA_FLEETCTL_LDFLAGS}"))
131+
CGO_ENABLED=${CGO_ENABLED} go build -race=${GO_BUILD_RACE_ENABLED_VAR} -o build/fleetctl -ldflags="${FLEETCTL_LDFLAGS}" ./cmd/fleetctl
131132

132133
fleetctl-dev: GO_BUILD_RACE_ENABLED_VAR=true
133134
fleetctl-dev: fleetctl

cmd/fleetctl/preview.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -827,8 +827,8 @@ func downloadOrbitAndStart(destDir, enrollSecret, address, orbitChannel, osquery
827827
updateOpt := update.DefaultOptions
828828

829829
// Override default channels with the provided values.
830-
updateOpt.Targets.SetTargetChannel("orbit", orbitChannel)
831-
updateOpt.Targets.SetTargetChannel("osqueryd", osquerydChannel)
830+
updateOpt.Targets.SetTargetChannel(constant.OrbitTUFTargetName, orbitChannel)
831+
updateOpt.Targets.SetTargetChannel(constant.OsqueryTUFTargetName, osquerydChannel)
832832

833833
updateOpt.RootDirectory = destDir
834834

@@ -843,9 +843,9 @@ func downloadOrbitAndStart(destDir, enrollSecret, address, orbitChannel, osquery
843843
return fmt.Errorf("initialize updates: %w", err)
844844
}
845845

846-
orbitPath, err := update.NewDisabled(updateOpt).ExecutableLocalPath("orbit")
846+
orbitPath, err := update.NewDisabled(updateOpt).ExecutableLocalPath(constant.OrbitTUFTargetName)
847847
if err != nil {
848-
return fmt.Errorf("failed to locate executable for orbit: %w", err)
848+
return fmt.Errorf("failed to locate executable for %s: %w", constant.OrbitTUFTargetName, err)
849849
}
850850

851851
cmd := exec.Command(orbitPath,

ee/fleetctl/updates.go

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,33 +22,53 @@ import (
2222
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
2323
"github.com/theupdateframework/go-tuf"
2424
"github.com/urfave/cli/v2"
25-
"golang.org/x/crypto/ssh/terminal"
25+
"golang.org/x/term"
2626
)
2727

2828
const (
2929
// consistentSnapshots are not needed due to the low update frequency of
3030
// these repositories.
3131
consistentSnapshots = false
3232

33-
// ~10 years
34-
keyExpirationDuration = 10 * 365 * 24 * time.Hour
35-
36-
// Expirations from
37-
// https://github.com/theupdateframework/notary/blob/e87b31f46cdc5041403c64b7536df236d5e35860/docs/best_practices.md#expiration-prevention
38-
// ~10 years
39-
rootExpirationDuration = 10 * 365 * 24 * time.Hour //nolint:unused,deadcode
40-
// ~3 years
41-
targetsExpirationDuration = 3 * 365 * 24 * time.Hour
42-
// ~3 years
43-
snapshotExpirationDuration = 3 * 365 * 24 * time.Hour
44-
// 14 days
45-
timestampExpirationDuration = 14 * 24 * time.Hour
46-
4733
decryptionFailedError = "encrypted: decryption failed"
4834

4935
backupDirectory = ".backup"
5036
)
5137

38+
// The following are defined as string variables so that we can use/set them in tests and test tooling.
39+
var (
40+
// keyExpirationDuration is used when generating new keys (repository init)
41+
// or when rotating the root key.
42+
// ~10 years (10 * 365 * 24 hours)
43+
keyExpirationDuration = "87600h"
44+
45+
//
46+
// Expirations from
47+
// https://github.com/theupdateframework/notary/blob/e87b31f46cdc5041403c64b7536df236d5e35860/docs/best_practices.md#expiration-prevention
48+
//
49+
// They are defined as string so we can modify them at build time for testing purposes.
50+
//
51+
52+
// rootExpirationDuration is used to set the expiration of root.json when revoking the current root key.
53+
// ~10 years (10 * 365 * 24 hours)
54+
rootExpirationDuration = "87600h"
55+
// targetsExpirationDuration is used to set the expiration of the targets.json signature.
56+
// ~3 years (3 * 365 * 24 hours)
57+
targetsExpirationDuration = "26280h"
58+
// snapshotExpirationDuration is used to set the expiration of the snapshot.json signature.
59+
// ~3 years (3 * 365 * 24 hours)
60+
snapshotExpirationDuration = "26280h"
61+
// timestampExpirationDuration is used to set the expiration of the timestamp.json signature.
62+
// 14 days (14 * 24 hours)
63+
timestampExpirationDuration = "336h"
64+
65+
keyExpirationDuration_ = mustParseDuration(keyExpirationDuration)
66+
rootExpirationDuration_ = mustParseDuration(rootExpirationDuration)
67+
targetsExpirationDuration_ = mustParseDuration(targetsExpirationDuration)
68+
snapshotExpirationDuration_ = mustParseDuration(snapshotExpirationDuration)
69+
timestampExpirationDuration_ = mustParseDuration(timestampExpirationDuration)
70+
)
71+
5272
var passHandler = newPassphraseHandler()
5373

5474
func UpdatesCommand() *cli.Command {
@@ -87,6 +107,14 @@ func updatesInitCommand() *cli.Command {
87107
}
88108
}
89109

110+
func mustParseDuration(s string) time.Duration {
111+
d, err := time.ParseDuration(s)
112+
if err != nil {
113+
panic(err)
114+
}
115+
return d
116+
}
117+
90118
func updatesInitFunc(c *cli.Context) error {
91119
path := c.String("path")
92120
store := tuf.FileSystemStore(path, passHandler.getPassphrase)
@@ -134,14 +162,14 @@ func updatesInitFunc(c *cli.Context) error {
134162
if err := repo.AddTargetsWithExpires(
135163
nil,
136164
nil,
137-
time.Now().Add(targetsExpirationDuration),
165+
time.Now().Add(targetsExpirationDuration_),
138166
); err != nil {
139167
return fmt.Errorf("initialize targets: %w", err)
140168
}
141-
if err := repo.SnapshotWithExpires(time.Now().Add(snapshotExpirationDuration)); err != nil {
169+
if err := repo.SnapshotWithExpires(time.Now().Add(snapshotExpirationDuration_)); err != nil {
142170
return fmt.Errorf("make snapshot: %w", err)
143171
}
144-
if err := repo.TimestampWithExpires(time.Now().Add(timestampExpirationDuration)); err != nil {
172+
if err := repo.TimestampWithExpires(time.Now().Add(timestampExpirationDuration_)); err != nil {
145173
return fmt.Errorf("make timestamp: %w", err)
146174
}
147175

@@ -256,10 +284,10 @@ func updatesAddFunc(c *cli.Context) error {
256284
dstPath = filepath.Join(name, platform, tag, name)
257285
}
258286
switch {
259-
case name == "desktop" && platform == "windows":
287+
case name == constant.DesktopTUFTargetName && platform == "windows":
260288
// This is a special case for the desktop target on Windows.
261289
dstPath = filepath.Join(filepath.Dir(dstPath), constant.DesktopAppExecName+".exe")
262-
case name == "desktop" && (platform == "linux" || platform == "linux-arm64"):
290+
case name == constant.DesktopTUFTargetName && (platform == "linux" || platform == "linux-arm64"):
263291
// This is a special case for the desktop target on Linux.
264292
dstPath += ".tar.gz"
265293
// The convention for Windows extensions is to use the extension `.ext.exe`
@@ -294,16 +322,16 @@ func updatesAddFunc(c *cli.Context) error {
294322
if err := repo.AddTargetsWithExpires(
295323
paths,
296324
meta,
297-
time.Now().Add(targetsExpirationDuration),
325+
time.Now().Add(targetsExpirationDuration_),
298326
); err != nil {
299327
return fmt.Errorf("add targets: %w", err)
300328
}
301329

302-
if err := repo.SnapshotWithExpires(time.Now().Add(snapshotExpirationDuration)); err != nil {
330+
if err := repo.SnapshotWithExpires(time.Now().Add(snapshotExpirationDuration_)); err != nil {
303331
return fmt.Errorf("make snapshot: %w", err)
304332
}
305333

306-
if err := repo.TimestampWithExpires(time.Now().Add(timestampExpirationDuration)); err != nil {
334+
if err := repo.TimestampWithExpires(time.Now().Add(timestampExpirationDuration_)); err != nil {
307335
return fmt.Errorf("make timestamp: %w", err)
308336
}
309337

@@ -336,7 +364,7 @@ func updatesTimestampFunc(c *cli.Context) error {
336364
}
337365

338366
if err := repo.TimestampWithExpires(
339-
time.Now().Add(timestampExpirationDuration),
367+
time.Now().Add(timestampExpirationDuration_),
340368
); err != nil {
341369
return fmt.Errorf("make timestamp: %w", err)
342370
}
@@ -415,7 +443,7 @@ func updatesRotateFunc(c *cli.Context) error {
415443
// Delete old keys for role
416444
for _, key := range keys {
417445
id := key.PublicData().IDs()[0]
418-
err := repo.RevokeKeyWithExpires(role, id, time.Now().Add(rootExpirationDuration))
446+
err := repo.RevokeKeyWithExpires(role, id, time.Now().Add(rootExpirationDuration_))
419447
if err != nil {
420448
// go-tuf keeps keys around even after they are revoked from the manifest. We can skip
421449
// tuf.ErrKeyNotFound as these represent keys that are not present in the manifest and
@@ -441,15 +469,15 @@ func updatesRotateFunc(c *cli.Context) error {
441469

442470
// Generate new metadata for each role (technically some of these may not need regeneration
443471
// depending on which key was rotated, but there should be no harm in generating new ones for each).
444-
if err := repo.AddTargetsWithExpires(nil, nil, time.Now().Add(targetsExpirationDuration)); err != nil {
472+
if err := repo.AddTargetsWithExpires(nil, nil, time.Now().Add(targetsExpirationDuration_)); err != nil {
445473
return fmt.Errorf("generate targets: %w", err)
446474
}
447475

448-
if err := repo.SnapshotWithExpires(time.Now().Add(snapshotExpirationDuration)); err != nil {
476+
if err := repo.SnapshotWithExpires(time.Now().Add(snapshotExpirationDuration_)); err != nil {
449477
return fmt.Errorf("generate snapshot: %w", err)
450478
}
451479

452-
if err := repo.TimestampWithExpires(time.Now().Add(timestampExpirationDuration)); err != nil {
480+
if err := repo.TimestampWithExpires(time.Now().Add(timestampExpirationDuration_)); err != nil {
453481
return fmt.Errorf("generate timestamp: %w", err)
454482
}
455483

@@ -619,7 +647,7 @@ func copyTarget(srcPath, dstPath string) error {
619647
}
620648

621649
func updatesGenKey(repo *tuf.Repo, role string) error {
622-
keyids, err := repo.GenKeyWithExpires(role, time.Now().Add(keyExpirationDuration))
650+
keyids, err := repo.GenKeyWithExpires(role, time.Now().Add(keyExpirationDuration_))
623651
if err != nil {
624652
return fmt.Errorf("generate %s key: %w", role, err)
625653
}
@@ -715,7 +743,7 @@ func (p *passphraseHandler) readPassphrase(role string, confirm bool) ([]byte, e
715743

716744
fmt.Printf("Enter %s key passphrase: ", role)
717745
// the int(...) conversion is required as on Windows syscall.Stdin is of type Handle.
718-
passphrase, err := terminal.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
746+
passphrase, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
719747
fmt.Println()
720748
if err != nil {
721749
return nil, fmt.Errorf("read password: %w", err)
@@ -727,7 +755,7 @@ func (p *passphraseHandler) readPassphrase(role string, confirm bool) ([]byte, e
727755

728756
fmt.Printf("Repeat %s key passphrase: ", role)
729757
// the int(...) conversion is required as on Windows syscall.Stdin is of type Handle.
730-
confirmation, err := terminal.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
758+
confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
731759
fmt.Println()
732760
if err != nil {
733761
return nil, fmt.Errorf("read password confirmation: %w", err)

ee/fleetctl/updates_test.go

Lines changed: 137 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,8 @@ func TestCommit(t *testing.T) {
398398
// Make rotations that change repo
399399
require.NoError(t, updatesGenKey(repo, "root"))
400400
require.NoError(t, repo.Sign("root.json"))
401-
require.NoError(t, repo.SnapshotWithExpires(time.Now().Add(snapshotExpirationDuration)))
402-
require.NoError(t, repo.TimestampWithExpires(time.Now().Add(timestampExpirationDuration)))
401+
require.NoError(t, repo.SnapshotWithExpires(time.Now().Add(mustParseDuration(snapshotExpirationDuration))))
402+
require.NoError(t, repo.TimestampWithExpires(time.Now().Add(mustParseDuration(timestampExpirationDuration))))
403403
require.NoError(t, repo.Commit())
404404

405405
// Assert directory has changed after commit.
@@ -437,8 +437,8 @@ func TestRollback(t *testing.T) {
437437
// Make rotations that change repo
438438
require.NoError(t, updatesGenKey(repo, "root"))
439439
require.NoError(t, repo.Sign("root.json"))
440-
require.NoError(t, repo.SnapshotWithExpires(time.Now().Add(snapshotExpirationDuration)))
441-
require.NoError(t, repo.TimestampWithExpires(time.Now().Add(timestampExpirationDuration)))
440+
require.NoError(t, repo.SnapshotWithExpires(time.Now().Add(mustParseDuration(snapshotExpirationDuration))))
441+
require.NoError(t, repo.TimestampWithExpires(time.Now().Add(mustParseDuration(timestampExpirationDuration))))
442442
require.NoError(t, repo.Commit())
443443

444444
// Assert directory has NOT changed after rollback.
@@ -456,3 +456,136 @@ func TestRollback(t *testing.T) {
456456
roots := getRoots(t, tmpDir)
457457
assert.Equal(t, initialRoots, roots)
458458
}
459+
460+
// TestIntegrationsUpdatesExpiredSignatures is used to test the expected behavior
461+
// of go-tuf@v0.5.2 methods client.Update and client.Target when signatures are expired (their
462+
// behavior depends on which of the roles has the expired signature).
463+
func TestIntegrationsUpdatesExpiredSignatures(t *testing.T) {
464+
// Not t.Parallel() due to modifications to environment and global variables.
465+
466+
setPassphrases(t)
467+
const timeToExpire = 5 * time.Second
468+
469+
for _, tc := range []struct {
470+
name string
471+
overrideKeyExpirationFunc func(t *testing.T)
472+
updateMetadataFails bool
473+
lookupFails bool
474+
}{
475+
{
476+
name: "root expired",
477+
overrideKeyExpirationFunc: func(t *testing.T) {
478+
oldKeyExpirationDuration := keyExpirationDuration_
479+
t.Cleanup(func() {
480+
keyExpirationDuration_ = oldKeyExpirationDuration
481+
})
482+
keyExpirationDuration_ = timeToExpire
483+
},
484+
// When the root signature is expired both client.Update fails and client.Target fail.
485+
updateMetadataFails: true,
486+
lookupFails: true,
487+
},
488+
{
489+
name: "snapshot expired",
490+
overrideKeyExpirationFunc: func(t *testing.T) {
491+
oldKeyExpirationDuration := snapshotExpirationDuration_
492+
t.Cleanup(func() {
493+
snapshotExpirationDuration_ = oldKeyExpirationDuration
494+
})
495+
snapshotExpirationDuration_ = timeToExpire
496+
},
497+
// When the snapshot signature is expired client.Update does not fail and client.Target does fail.
498+
updateMetadataFails: false,
499+
lookupFails: true,
500+
},
501+
{
502+
name: "targets expired",
503+
overrideKeyExpirationFunc: func(t *testing.T) {
504+
oldKeyExpirationDuration := targetsExpirationDuration_
505+
t.Cleanup(func() {
506+
targetsExpirationDuration_ = oldKeyExpirationDuration
507+
})
508+
targetsExpirationDuration_ = timeToExpire
509+
},
510+
// When the targets signature is expired client.Update does not fail and client.Target does fail.
511+
updateMetadataFails: false,
512+
lookupFails: true,
513+
},
514+
{
515+
name: "timestamp expired",
516+
overrideKeyExpirationFunc: func(t *testing.T) {
517+
oldKeyExpirationDuration := timestampExpirationDuration_
518+
t.Cleanup(func() {
519+
timestampExpirationDuration_ = oldKeyExpirationDuration
520+
})
521+
timestampExpirationDuration_ = timeToExpire
522+
},
523+
// When the timestamp signature is expired client.Update fails and client.Target does not fail.
524+
updateMetadataFails: true,
525+
lookupFails: false,
526+
},
527+
} {
528+
t.Run(tc.name, func(t *testing.T) {
529+
tc.overrideKeyExpirationFunc(t)
530+
531+
tmpDir := t.TempDir()
532+
err := runUpdatesCommand("init", "--path", tmpDir)
533+
require.NoError(t, err)
534+
535+
roots := getRoots(t, tmpDir)
536+
537+
// Use the current binary as target for this test so that it is a binary that
538+
// is valid for execution on the current system.
539+
testPath, err := os.Executable()
540+
require.NoError(t, err)
541+
542+
// Add a dummy target
543+
require.NoError(t, runUpdatesCommand("add", "--path", tmpDir, "--target", testPath, "--platform", "macos", "--name", "test", "--version", "1.3.3.7"))
544+
assertFileExists(t, filepath.Join(tmpDir, "repository", "targets", "test", "macos", "1.3.3.7", "test"))
545+
546+
// Run an HTTP server to serve the update metadata
547+
server := httptest.NewServer(http.FileServer(http.Dir(filepath.Join(tmpDir, "repository"))))
548+
t.Cleanup(server.Close)
549+
550+
// Initialize an update client
551+
localStore, err := filestore.New(filepath.Join(tmpDir, "tuf-metadata.json"))
552+
require.NoError(t, err)
553+
updater, err := update.NewUpdater(update.Options{
554+
RootDirectory: tmpDir,
555+
ServerURL: server.URL,
556+
RootKeys: roots,
557+
LocalStore: localStore,
558+
Targets: update.Targets{
559+
"test": update.TargetInfo{
560+
Platform: "macos",
561+
Channel: "1.3.3.7",
562+
TargetFile: "test",
563+
},
564+
},
565+
})
566+
require.NoError(t, err)
567+
err = updater.UpdateMetadata()
568+
require.NoError(t, err)
569+
570+
time.Sleep(timeToExpire + 1*time.Second)
571+
572+
// Expect UpdateMetadata (client.Update) to fail when the signature has expired.
573+
err = updater.UpdateMetadata()
574+
if tc.updateMetadataFails {
575+
require.Error(t, err)
576+
require.True(t, update.IsExpiredErr(err))
577+
} else {
578+
require.NoError(t, err)
579+
}
580+
581+
// Expect Lookup (client.Target) to fail when the signature has expired.
582+
_, err = updater.Lookup("test")
583+
if tc.lookupFails {
584+
require.Error(t, err)
585+
require.True(t, update.IsExpiredErr(err))
586+
} else {
587+
require.NoError(t, err)
588+
}
589+
})
590+
}
591+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* Fixed orbit startup to not exit when "root.json", "snapshot.json", or "targets.json" TUF signatures have expired.

0 commit comments

Comments
 (0)