Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion cmd/pilotctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1351,7 +1359,7 @@ Diagnostic commands:
pilotctl listen <port> [--count <n>] [--timeout <dur>]
pilotctl broadcast <network_id> <message>
pilotctl update [--pin <tag>] run the updater once — check and install new release
pilotctl updates [--count <n>] [--scope <scope>] read https://teoslayer.github.io/pilot-changelog/feed.xml
pilotctl updates [--count <n>] [--scope <scope>] read https://pilot-protocol.github.io/pilot-changelog/feed.xml

Agent tool discovery:
pilotctl context
Expand Down
2 changes: 1 addition & 1 deletion cmd/pilotctl/updates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions cmd/pilotctl/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"os"
"time"

"github.com/pilot-protocol/common/badgeverify"
"github.com/pilot-protocol/common/crypto"
Expand Down Expand Up @@ -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)
Expand All @@ -324,3 +335,25 @@ func cmdRecoveryRecover(args []string) {
"registry": resp,
})
}

// backupIdentity copies an existing identity file to
// "<path>.bak-<unix-ts>" 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
}
86 changes: 86 additions & 0 deletions cmd/pilotctl/zz_backup_identity_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
36 changes: 30 additions & 6 deletions cmd/updater/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"time"

Expand All @@ -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")
Expand All @@ -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 {
Expand All @@ -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()
Expand All @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions cmd/updater/zz_envbool_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
2 changes: 1 addition & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Diagnostic commands:
pilotctl listen <port> [--count <n>] [--timeout <dur>]
pilotctl broadcast <network_id> <message>
pilotctl update [--pin <tag>] run the updater once — check and install new release
pilotctl updates [--count <n>] [--scope <scope>] read https://teoslayer.github.io/pilot-changelog/feed.xml
pilotctl updates [--count <n>] [--scope <scope>] read https://pilot-protocol.github.io/pilot-changelog/feed.xml

Agent tool discovery:
pilotctl context
Expand Down
2 changes: 1 addition & 1 deletion internal/motd/motd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions pkg/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading