package config import ( "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" "os" "path/filepath" "strings" "testing" "time" ) // liveConfig is a config whose device keys are real Ed25519 public keys. // // The shared `valid()` fixture uses "AAAA", which is fine for the loader's shape checks — it does not // verify key sizes — but LiveDevices parses the keys, because a device it cannot build a public key // for is a device it must not silently trust. So these tests need a usable key, and the generator // keeps them from depending on a literal that would rot. func liveConfig(t *testing.T) File { t.Helper() cfg := valid() public, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } cfg.Devices = []Device{{ ID: "ap_5b1d0000", SignKey: base64.StdEncoding.EncodeToString(public), RelayToken: strings.Repeat("d", 48), }} return cfg } // The property these tests exist for: a device destroyed on the node stops being trusted by the // running daemon, without a restart. Before this, the daemon held the list it read at startup and // re-pushed it to the relay on every handshake, so a revoked phone stayed authorised at the node and // stayed routable at the relay — the documented revocation path did not work. func TestLiveDevicesSeesAReloadedList(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if err := Save(path, liveConfig(t)); err != nil { t.Fatalf("Save: %v", err) } live, err := NewLiveDevices(path) if err != nil { t.Fatalf("NewLiveDevices: %v", err) } if live.Len() != 1 { t.Fatalf("expected one paired device, got %d", live.Len()) } if live.SigningKey("ap_5b1d0000") == nil { t.Fatal("the paired device is not trusted") } // The operator destroys the list. Save writes through a rename, which is how the real path // changes the file, so this exercises the inode branch of the change detection. destroyed := liveConfig(t) destroyed.Devices = nil if err := Save(path, destroyed); err != nil { t.Fatalf("Save after destroy: %v", err) } changed, err := live.Refresh() if err != nil { t.Fatalf("Refresh: %v", err) } if !changed { t.Fatal("a destroyed device list was not noticed") } if live.Len() != 0 { t.Fatalf("the destroyed device is still trusted: %d", live.Len()) } if live.SigningKey("ap_5b1d0000") != nil { t.Fatal("the destroyed device still has a signing key") } // The registration push must now carry an empty set: that is what retires the bearer at the // relay, and without it the revocation is only half done. if got := live.Devices(); len(got) != 0 { t.Fatalf("the relay would be told about %d devices after a destroy", len(got)) } } // A pairing performed while the daemon is running must be picked up too, and it is the same // mechanism: the file changed, so the set is re-read and re-asserted. func TestLiveDevicesSeesAnAddedDevice(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") cfg := liveConfig(t) cfg.Devices = nil if err := Save(path, cfg); err != nil { t.Fatalf("Save: %v", err) } live, err := NewLiveDevices(path) if err != nil { t.Fatalf("NewLiveDevices: %v", err) } if live.Len() != 0 { t.Fatalf("expected no devices, got %d", live.Len()) } paired := liveConfig(t) if err := Save(path, paired); err != nil { t.Fatalf("Save after pairing: %v", err) } changed, err := live.Refresh() if err != nil { t.Fatalf("Refresh: %v", err) } if !changed { t.Fatal("a new pairing was not noticed") } if live.Len() != 1 || live.SigningKey("ap_5b1d0000") == nil { t.Fatal("the newly paired device is not trusted") } if got := live.Devices(); len(got) != 1 || got[0].ID != "ap_5b1d0000" { t.Fatalf("the relay would be told the wrong set: %+v", got) } } // Refresh is called once per poll cycle, so the common case — nothing changed — must be cheap and // must not report a change. A false positive would force a handshake every cycle, which is the // defect the agent's loop comment warns about: each handshake replaces the session and drops the // parked poll. func TestLiveDevicesReportsNoChangeWhenNothingChanged(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if err := Save(path, liveConfig(t)); err != nil { t.Fatalf("Save: %v", err) } live, err := NewLiveDevices(path) if err != nil { t.Fatalf("NewLiveDevices: %v", err) } for i := 0; i < 3; i++ { changed, err := live.Refresh() if err != nil { t.Fatalf("Refresh %d: %v", i, err) } if changed { t.Fatalf("Refresh %d reported a change with no write", i) } } } // A rewrite that keeps the same content but changes the file is still a change worth noticing: the // operator may have re-saved the file, and re-asserting the set at the relay costs one handshake. // What must not happen is the opposite — a change that is missed. func TestLiveDevicesNoticesAChangeWithinTheSameSecond(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if err := Save(path, liveConfig(t)); err != nil { t.Fatalf("Save: %v", err) } live, err := NewLiveDevices(path) if err != nil { t.Fatalf("NewLiveDevices: %v", err) } // A destroy immediately after a pairing, which is exactly the sequence an operator performs when // they revoke the wrong phone and re-pair. The two writes land inside one clock tick on a // filesystem with coarse timestamps, so the size and the inode are what has to catch it. cfg := liveConfig(t) cfg.Devices = nil if err := Save(path, cfg); err != nil { t.Fatalf("Save: %v", err) } _ = time.Now() changed, err := live.Refresh() if err != nil { t.Fatalf("Refresh: %v", err) } if !changed { t.Fatal("a destroy inside the same timestamp tick was missed") } if live.Len() != 0 { t.Fatal("the destroyed device is still trusted") } } // An unreadable or invalid file leaves the previous set in place. Replacing it with an empty set // would be the worst outcome: the node would lock out every paired phone because of a transient // filesystem error, and the user would have to reach the node to fix it. func TestLiveDevicesKeepsTheCurrentSetWhenTheFileCannotBeRead(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if err := Save(path, liveConfig(t)); err != nil { t.Fatalf("Save: %v", err) } live, err := NewLiveDevices(path) if err != nil { t.Fatalf("NewLiveDevices: %v", err) } if err := os.WriteFile(path, []byte("{ this is not json"), 0o600); err != nil { t.Fatal(err) } if _, err := live.Refresh(); err == nil { t.Fatal("a corrupt config was accepted") } if live.Len() != 1 || live.SigningKey("ap_5b1d0000") == nil { t.Fatal("a failed reload dropped the trusted device set") } // And a missing file is the same story: the set stays, because the node is still the node. if err := os.Remove(path); err != nil { t.Fatal(err) } if _, err := live.Refresh(); err == nil { t.Fatal("a missing config was accepted") } if live.Len() != 1 { t.Fatal("a missing config dropped the trusted device set") } } // A device whose key cannot be used is a config error rather than a device to skip: skipping it // would leave a phone paired in the UI and unable to command anything, with nothing to point at. func TestLiveDevicesRejectsAnUnusableKey(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") cfg := liveConfig(t) cfg.Devices[0].SignKey = base64.StdEncoding.EncodeToString([]byte("too short")) encoded, err := json.Marshal(cfg) if err != nil { t.Fatal(err) } if err := os.WriteFile(path, encoded, 0o600); err != nil { t.Fatal(err) } if _, err := NewLiveDevices(path); err == nil { t.Fatal("a device with an unusable key was accepted") } }