diff --git a/cmd/pilotctl/main.go b/cmd/pilotctl/main.go index 0641d0c0..ca24ee60 100644 --- a/cmd/pilotctl/main.go +++ b/cmd/pilotctl/main.go @@ -550,6 +550,14 @@ func maybeAutoHandshake(d *driver.Driver, addr protocol.Addr, skip bool) { } // Branch 2 — peer is in the embedded trusted-agents allowlist. + // + // TODO(security/H4): IsTrusted keys on node_id only, with no pubkey + // binding. This call is outbound (we initiate toward addr), so the + // peer's authenticated pubkey is not in scope here — node_id match is + // all we can check. Pubkey pinning (IsTrustedWithKey) must be added in + // the upstream github.com/pilot-protocol/trustedagents module and wired + // at the inbound auto-accept path inside its NewService(), where the + // presented key IS available. See that repo, not this call site. if name, ok := trustedagents.IsTrusted(addr.Node); ok { if !jsonOutput { fmt.Fprintf(os.Stderr, "establishing handshake with Trusted Agent %s (%s)...\n", name, addr) @@ -1351,7 +1359,7 @@ Diagnostic commands: pilotctl listen [--count ] [--timeout ] pilotctl broadcast pilotctl update [--pin ] run the updater once — check and install new release - pilotctl updates [--count ] [--scope ] read https://teoslayer.github.io/pilot-changelog/feed.xml + pilotctl updates [--count ] [--scope ] read https://pilot-protocol.github.io/pilot-changelog/feed.xml Agent tool discovery: pilotctl context diff --git a/cmd/pilotctl/updates.go b/cmd/pilotctl/updates.go index f3385e52..69bc5634 100644 --- a/cmd/pilotctl/updates.go +++ b/cmd/pilotctl/updates.go @@ -94,7 +94,7 @@ func cmdAutoUpdateStatus() { // we can stay on the standard library only — no JSON-feed dep needed. // // Declared as a var (not const) so tests can point at httptest.Server. -var changelogFeedURL = "https://teoslayer.github.io/pilot-changelog/feed.xml" +var changelogFeedURL = "https://pilot-protocol.github.io/pilot-changelog/feed.xml" // rssDoc is the minimal RSS 2.0 shape we care about. Only fields needed // for the human-readable + JSON output are decoded; unknown elements are diff --git a/cmd/pilotctl/verify.go b/cmd/pilotctl/verify.go index e2630b34..c674f9db 100644 --- a/cmd/pilotctl/verify.go +++ b/cmd/pilotctl/verify.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "os" + "time" "github.com/pilot-protocol/common/badgeverify" "github.com/pilot-protocol/common/crypto" @@ -311,6 +312,16 @@ func cmdRecoveryRecover(args []string) { // The registry rotated the address to the new key; install it locally so // the daemon can authenticate as the recovered node after a restart. idPath := flagString(flags, "identity", configDir()+"/identity.json") + // Irreversible-overwrite guard: SaveIdentity replaces the live daemon + // identity in place. If one already exists, copy it aside first so a + // recovery run can never silently destroy the prior key. Refuse rather + // than overwrite blind if the backup can't be written. + if bak, err := backupIdentity(idPath); err != nil { + fatalCode("internal_error", + "recovery recover: refusing to overwrite existing identity %s: backup failed: %v", idPath, err) + } else if bak != "" && !jsonOutput { + fmt.Fprintf(os.Stderr, "backed up existing identity to %s\n", bak) + } if err := crypto.SaveIdentity(idPath, id); err != nil { fatalCode("internal_error", "recovery recover: registry rotated key but installing new identity at %s failed: %v", idPath, err) @@ -324,3 +335,25 @@ func cmdRecoveryRecover(args []string) { "registry": resp, }) } + +// backupIdentity copies an existing identity file to +// ".bak-" before it is overwritten by a recovery run. +// Returns the backup path on success, "" if no file existed at path (so +// there was nothing to back up), or an error if a file exists but the +// backup could not be written — in which case the caller MUST refuse to +// overwrite. Timestamped so repeated recovery attempts never clobber an +// earlier backup. Crypto is untouched: this is a pure file copy. +func backupIdentity(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + bakPath := fmt.Sprintf("%s.bak-%d", path, time.Now().Unix()) + if err := os.WriteFile(bakPath, data, 0o600); err != nil { + return "", err + } + return bakPath, nil +} diff --git a/cmd/pilotctl/zz_backup_identity_test.go b/cmd/pilotctl/zz_backup_identity_test.go new file mode 100644 index 00000000..27a9937d --- /dev/null +++ b/cmd/pilotctl/zz_backup_identity_test.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestBackupIdentityNoFile: nothing to back up when the path is absent. +// Returns ("", nil) so the caller proceeds to write a fresh identity. +func TestBackupIdentityNoFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "identity.json") + + bak, err := backupIdentity(path) + if err != nil { + t.Fatalf("backupIdentity on missing file: unexpected error %v", err) + } + if bak != "" { + t.Fatalf("expected empty backup path for missing file, got %q", bak) + } +} + +// TestBackupIdentityCopiesExisting: an existing identity is copied to a +// timestamped .bak file with its contents preserved, and the original is +// left in place for SaveIdentity to overwrite. +func TestBackupIdentityCopiesExisting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "identity.json") + const content = `{"node_id":42,"private_key":"old"}` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("seed identity: %v", err) + } + + bak, err := backupIdentity(path) + if err != nil { + t.Fatalf("backupIdentity: unexpected error %v", err) + } + if bak == "" { + t.Fatal("expected a backup path, got empty") + } + if !strings.HasPrefix(bak, path+".bak-") { + t.Fatalf("backup path %q lacks expected prefix %q.bak-", bak, path) + } + + got, err := os.ReadFile(bak) + if err != nil { + t.Fatalf("read backup: %v", err) + } + if string(got) != content { + t.Fatalf("backup content mismatch: got %q want %q", got, content) + } + + // Original must still exist (we copy, not move) so the overwrite path + // has something to replace. + if _, err := os.Stat(path); err != nil { + t.Fatalf("original identity should remain: %v", err) + } +} + +// TestBackupIdentityRefusesUnwritable: when a file exists but the backup +// cannot be written, an error is returned so the caller refuses to +// overwrite the live identity blind. +func TestBackupIdentityRefusesUnwritable(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "identity.json") + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("seed identity: %v", err) + } + // Make the directory read-only so the .bak sibling can't be created. + if err := os.Chmod(dir, 0o500); err != nil { + t.Fatalf("chmod dir: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) + + if os.Geteuid() == 0 { + t.Skip("running as root bypasses directory permissions") + } + + if _, err := backupIdentity(path); err == nil { + t.Fatal("expected error when backup cannot be written, got nil") + } +} diff --git a/cmd/updater/main.go b/cmd/updater/main.go index 6ab976d3..79cdf6b7 100644 --- a/cmd/updater/main.go +++ b/cmd/updater/main.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "os/signal" + "strings" "syscall" "time" @@ -28,6 +29,18 @@ func defaultStatePath() string { return home + "/.pilot/auto-update.json" } +// envBool reports whether the named environment variable is set to a truthy +// value ("1", "true", "yes", case-insensitive). Used as the default for +// --skip-attestation so the opt-out can be set without a CLI flag. +func envBool(name string) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + func main() { installDir := flag.String("install-dir", "", "directory containing pilot binaries (required)") repo := flag.String("repo", "pilot-protocol/pilotprotocol", "GitHub owner/repo for releases") @@ -37,6 +50,13 @@ func main() { logFormat := flag.String("log-format", "text", "log format (text, json)") showVersion := flag.Bool("version", false, "print version and exit") statePath := flag.String("state-path", defaultStatePath(), "JSON control file {\"enabled\":bool} for automatic updates; auto-update is OFF until enabled (e.g. via `pilotctl update enable`)") + // --skip-attestation opts out of SLSA provenance verification of + // checksums.txt. The updater module fails CLOSED if `gh` is absent (it + // cannot verify attestations), so a host genuinely without `gh` needs an + // explicit way to proceed. Default false: verification stays on in + // production. Mirrors the --state-path pattern with an env fallback. + skipAttestation := flag.Bool("skip-attestation", envBool("PILOT_UPDATER_SKIP_ATTESTATION"), + "skip SLSA attestation verification (default off); use only on hosts without `gh` available") flag.Parse() if *showVersion { @@ -52,12 +72,13 @@ func main() { setupLogging(*logLevel, *logFormat) u := updater.New(updater.Config{ - CheckInterval: *interval, - Repo: *repo, - InstallDir: *installDir, - Version: version, - PinnedVersion: *pin, - StatePath: *statePath, + CheckInterval: *interval, + Repo: *repo, + InstallDir: *installDir, + Version: version, + PinnedVersion: *pin, + StatePath: *statePath, + SkipAttestation: *skipAttestation, }) u.Start() @@ -69,6 +90,9 @@ func main() { if *pin != "" { slog.Info("version pinned", "tag", *pin) } + if *skipAttestation { + slog.Warn("SLSA attestation verification disabled (--skip-attestation); update provenance will NOT be checked") + } sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) diff --git a/cmd/updater/zz_envbool_test.go b/cmd/updater/zz_envbool_test.go new file mode 100644 index 00000000..5538af3d --- /dev/null +++ b/cmd/updater/zz_envbool_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import "testing" + +func TestEnvBool(t *testing.T) { + const key = "PILOT_UPDATER_TEST_ENVBOOL" + cases := []struct { + val string + want bool + }{ + {"1", true}, + {"true", true}, + {"TRUE", true}, + {"Yes", true}, + {" on ", true}, + {"0", false}, + {"false", false}, + {"", false}, + {"nope", false}, + } + for _, c := range cases { + t.Setenv(key, c.val) + if got := envBool(key); got != c.want { + t.Errorf("envBool(%q) = %v, want %v", c.val, got, c.want) + } + } + + // Unset variable is false. + if got := envBool("PILOT_UPDATER_DEFINITELY_UNSET_XYZ"); got { + t.Error("envBool on unset var should be false") + } +} diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 67fe95b8..2e66819b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -77,7 +77,7 @@ Diagnostic commands: pilotctl listen [--count ] [--timeout ] pilotctl broadcast pilotctl update [--pin ] run the updater once — check and install new release - pilotctl updates [--count ] [--scope ] read https://teoslayer.github.io/pilot-changelog/feed.xml + pilotctl updates [--count ] [--scope ] read https://pilot-protocol.github.io/pilot-changelog/feed.xml Agent tool discovery: pilotctl context diff --git a/internal/motd/motd.go b/internal/motd/motd.go index 451ded69..8a9b28d8 100644 --- a/internal/motd/motd.go +++ b/internal/motd/motd.go @@ -41,7 +41,7 @@ const ( // banner is active and its `title` is the banner text. Publishing or // clearing a motd entry there propagates to every daemon on its next // poll (subject to GitHub's raw CDN cache, typically a few minutes). - DefaultFeedURL = "https://raw.githubusercontent.com/TeoSlayer/pilot-changelog/main/feed-motd.json" + DefaultFeedURL = "https://raw.githubusercontent.com/pilot-protocol/pilot-changelog/main/feed-motd.json" // DefaultInterval is how often the daemon re-fetches the feed when no // interval is configured. diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index cbd3634c..f9e9dd75 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -3374,6 +3374,12 @@ func (d *Daemon) dialConnectionLocked(ctx context.Context, dstAddr protocol.Addr // a nil-deref. The on-wire SYN still goes out below; we just skip the // proactive handshake when the plugin isn't loaded. if d.handshakes != nil && !d.handshakes.IsTrusted(dstAddr.Node) { + // TODO(security/H4): IsTrusted keys on node_id only (no pubkey + // binding). This gate is outbound — we proactively initiate toward + // dstAddr — so the peer's authenticated pubkey is not in scope and + // node_id match is all we can check here. Pubkey pinning belongs in + // the upstream github.com/pilot-protocol/trustedagents module at its + // inbound auto-accept path, where the presented key is available. if _, ok := trustedagents.IsTrusted(dstAddr.Node); ok { // Route through HandshakeSendRequest (not the plugin's raw // SendRequest) so the per-peer in-flight dedup catches the