package pair import ( "bytes" "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "fun.chick/msa-agent/internal/config" ) func agentConfig(t *testing.T) (string, config.File, ed25519.PrivateKey) { t.Helper() _, sign, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } kx := make([]byte, 32) rand.Read(kx) cfg := config.File{ NodeID: "nd_7f3a9c2e", RelayURL: "https://relay.example", AgentToken: "agent-token", LocalPort: 8788, SignKey: base64.StdEncoding.EncodeToString(sign), KxKey: base64.StdEncoding.EncodeToString(kx), AuditPath: filepath.Join(t.TempDir(), "audit.log"), NodePassword: "hunter2", } path := filepath.Join(t.TempDir(), "config.json") if err := config.Save(path, cfg); err != nil { t.Fatal(err) } return path, cfg, sign } func phoneKeys(t *testing.T) (string, string) { t.Helper() pub, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } kx := make([]byte, 32) rand.Read(kx) return base64.StdEncoding.EncodeToString(pub), base64.StdEncoding.EncodeToString(kx) } func provisionBody(t *testing.T, password, signPub, kxPub string) *bytes.Buffer { t.Helper() body, err := json.Marshal(map[string]string{ "password": password, "appSignPub": signPub, "appKxPub": kxPub, "deviceName": "chick", }) if err != nil { t.Fatal(err) } return bytes.NewBuffer(body) } func openHandler(t *testing.T, path string, cfg config.File) *Handler { t.Helper() h, err := New(path, cfg, nil) if err != nil { t.Fatalf("New: %v", err) } return h } func TestProvisionPairsThePhoneAndClosesTheWindow(t *testing.T) { path, cfg, sign := agentConfig(t) h := openHandler(t, path, cfg) signPub, kxPub := phoneKeys(t) rec := httptest.NewRecorder() h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision", provisionBody(t, "hunter2", signPub, kxPub))) if rec.Code != 200 { t.Fatalf("provision status %d: %s", rec.Code, rec.Body) } var out provisionResponse if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatal(err) } if out.NodeID != "nd_7f3a9c2e" || !strings.HasPrefix(out.AppID, "ap_") || out.DeviceToken == "" { t.Fatalf("response: %+v", out) } if len(out.AgentSignPub) != base64.StdEncoding.EncodedLen(ed25519.PublicKeySize) { t.Fatalf("agent signing key not published: %q", out.AgentSignPub) } // The published key must verify what the agent's private key signs: the pairing response is // the phone's only source for the trust anchor. proof := ed25519.Sign(sign, []byte("x")) gotPub, err := base64.StdEncoding.DecodeString(out.AgentSignPub) if err != nil || !ed25519.Verify(gotPub, []byte("x"), proof) { t.Fatal("published signing key does not match the private key") } // The config on disk carries the device, with a relay token generated by the agent. reloaded, err := config.Load(path) if err != nil { t.Fatalf("config after pairing must be loadable: %v", err) } if len(reloaded.Devices) != 1 || reloaded.Devices[0].ID != out.AppID || reloaded.Devices[0].SignKey != signPub || reloaded.Devices[0].RelayToken != out.DeviceToken { t.Fatalf("persisted devices: %+v", reloaded.Devices) } // The window closed with the pairing: a second phone is refused. rec = httptest.NewRecorder() h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision", provisionBody(t, "hunter2", signPub, kxPub))) if rec.Code != http.StatusGone { t.Fatalf("second pairing got %d, want 410", rec.Code) } select { case <-h.Done: default: t.Fatal("Done was not closed after a completed pairing") } } func TestAWrongSecretIsRefusedAndNothingIsPersisted(t *testing.T) { path, cfg, _ := agentConfig(t) h := openHandler(t, path, cfg) signPub, kxPub := phoneKeys(t) rec := httptest.NewRecorder() h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision", provisionBody(t, "wrong", signPub, kxPub))) if rec.Code != http.StatusUnauthorized { t.Fatalf("status %d", rec.Code) } // LoadForPairing, not Load: this config legitimately has no devices yet, which is exactly the // state that must survive a refused attempt. reloaded, err := config.LoadForPairing(path) if err != nil { t.Fatal(err) } if len(reloaded.Devices) != 0 { t.Fatal("a refused pairing was still persisted") } } // The pairing secret is a human password and the window is LAN-reachable, so the attempt ceiling is // the difference between a management port and a password oracle. func TestTheAttemptCeilingShutsTheWindow(t *testing.T) { path, cfg, _ := agentConfig(t) h := openHandler(t, path, cfg) signPub, kxPub := phoneKeys(t) for i := 0; i < MaxAttempts; i++ { rec := httptest.NewRecorder() h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision", provisionBody(t, "guess", signPub, kxPub))) if rec.Code != http.StatusUnauthorized { t.Fatalf("attempt %d: status %d", i, rec.Code) } } rec := httptest.NewRecorder() h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision", provisionBody(t, "hunter2", signPub, kxPub))) if rec.Code != http.StatusGone { t.Fatalf("after the ceiling, a correct password got %d, want 410", rec.Code) } } func TestBadKeyMaterialIsRefused(t *testing.T) { path, cfg, _ := agentConfig(t) h := openHandler(t, path, cfg) _, kxPub := phoneKeys(t) for _, body := range []string{ `{"password":"hunter2","appSignPub":"not base64!!","appKxPub":"` + kxPub + `"}`, // 31 bytes decodes fine but is the wrong size; the strict length check is the point. `{"password":"hunter2","appSignPub":"` + base64.StdEncoding.EncodeToString(make([]byte, 31)) + `","appKxPub":"` + kxPub + `"}`, `{"password":"hunter2","appSignPub":"` + strings.Repeat("A", 44) + `","appKxPub":"` + base64.StdEncoding.EncodeToString(make([]byte, 1)) + `"}`, } { rec := httptest.NewRecorder() h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision", strings.NewReader(body))) if rec.Code != http.StatusBadRequest { t.Fatalf("accepted key material %q with %d", body, rec.Code) } } } func TestDestroyClearsDevicesAndCloses(t *testing.T) { path, cfg, _ := agentConfig(t) h := openHandler(t, path, cfg) signPub, kxPub := phoneKeys(t) rec := httptest.NewRecorder() h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision", provisionBody(t, "hunter2", signPub, kxPub))) if rec.Code != 200 { t.Fatal(rec.Body.String()) } // Destroy after close (pairing closed it) is Gone: the one-window-one-action rule. rec = httptest.NewRecorder() h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/destroy", bytes.NewBufferString(`{"password":"hunter2"}`))) if rec.Code != http.StatusGone { t.Fatalf("destroy after pairing got %d, want 410", rec.Code) } // A fresh window can destroy an existing device list. h2 := openHandler(t, path, cfg) cfg2 := config.File{} raw, _ := os.ReadFile(path) json.Unmarshal(raw, &cfg2) h2 = openHandler(t, path, cfg2) rec = httptest.NewRecorder() h2.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/destroy", bytes.NewBufferString(`{"password":"hunter2"}`))) if rec.Code != 200 { t.Fatalf("destroy got %d: %s", rec.Code, rec.Body) } reloaded, err := config.LoadForPairing(path) if err != nil { t.Fatal(err) } if len(reloaded.Devices) != 0 { t.Fatal("destroy left devices behind") } } func TestAnUnprotectedNodeNeedsAnExplicitSecret(t *testing.T) { path, cfg, _ := agentConfig(t) cfg.NodePassword = "" if _, err := New(path, cfg, nil); err != ErrNoSecret { t.Fatalf("got %v, want ErrNoSecret", err) } cfg.ProvisionSecret = "pair-only" h, err := New(path, cfg, nil) if err != nil { t.Fatalf("explicit secret was refused: %v", err) } signPub, kxPub := phoneKeys(t) rec := httptest.NewRecorder() h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/provision", provisionBody(t, "pair-only", signPub, kxPub))) if rec.Code != 200 { t.Fatalf("status %d: %s", rec.Code, rec.Body) } } func TestUnknownRoutesDoNotDescribeTheSurface(t *testing.T) { path, cfg, _ := agentConfig(t) h := openHandler(t, path, cfg) rec := httptest.NewRecorder() h.Routes().ServeHTTP(rec, httptest.NewRequest("GET", "/provision", nil)) if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound { t.Fatalf("GET /provision got %d", rec.Code) } rec = httptest.NewRecorder() h.Routes().ServeHTTP(rec, httptest.NewRequest("POST", "/admin/list-devices", nil)) if rec.Code != http.StatusNotFound || len(rec.Body.Bytes()) != 0 { t.Fatalf("unknown route leaked: %d %q", rec.Code, rec.Body.String()) } } // Serve enforces the wall-clock deadline even if nobody ever pairs: a management socket that // outlives the operator's session is the second hole the design forbids. func TestServeDeadlineClosesTheWindow(t *testing.T) { path, cfg, _ := agentConfig(t) h := openHandler(t, path, cfg) server := httptest.NewServer(h.Routes()) defer server.Close() deadline := time.NewTimer(50 * time.Millisecond) <-deadline.C h.Close() signPub, kxPub := phoneKeys(t) response, err := http.Post(server.URL+"/provision", "application/json", provisionBody(t, "hunter2", signPub, kxPub)) if err != nil { t.Fatal(err) } defer response.Body.Close() if response.StatusCode != http.StatusGone { t.Fatalf("status after the deadline: %d", response.StatusCode) } } // The window's default bind has to satisfy two things at once, and they pull in opposite directions. // // It must be reachable from the phone: pairing happens over the LAN by design, so a loopback default // would be a flow that never works. And it must be a literal rather than a name: a name is resolved at // bind time, so the listening address would depend on what DNS said, which is not a decision the // operator made. func TestTheDefaultBindIsALiteralAndIsReachableFromTheLAN(t *testing.T) { if net.ParseIP(DefaultAdminHost) == nil { t.Fatalf("%q is not an IP literal; a name would be resolved at bind time", DefaultAdminHost) } ip := net.ParseIP(DefaultAdminHost) if ip.IsLoopback() { t.Fatal("a loopback default cannot be reached from the phone, and pairing is a LAN flow") } // Any-address is the default because the documented flow requires it; the operator narrows it with // --pair-bind when the node is routable from somewhere it should not be. if !ip.IsUnspecified() { t.Fatalf("the default bind changed to %q; the documented flow and the deployment notes assume %q", DefaultAdminHost, "0.0.0.0") } } // The log has to name the caller. "Ten failed pairings" tells an operator the window closed; "ten // failed pairings from 10.0.0.7" tells them someone is trying, which is the only actionable form of // the same fact and the one thing the attempt ceiling cannot supply. func TestAPairingAttemptIsLoggedWithItsPeer(t *testing.T) { path, cfg, _ := agentConfig(t) var mu sync.Mutex var attempts []map[string]any h, err := New(path, cfg, func(event string, fields map[string]any) { if event != "pair_attempt" { return } mu.Lock() defer mu.Unlock() attempts = append(attempts, fields) }) if err != nil { t.Fatalf("New: %v", err) } signPub, kxPub := phoneKeys(t) request := httptest.NewRequest("POST", "/provision", provisionBody(t, "wrong", signPub, kxPub)) // A caller behind a NAT, which is the shape a real attempt has. request.RemoteAddr = "10.0.0.7:54321" h.Routes().ServeHTTP(httptest.NewRecorder(), request) mu.Lock() defer mu.Unlock() if len(attempts) != 1 { t.Fatalf("expected one logged attempt, got %d", len(attempts)) } if attempts[0]["outcome"] != "bad_secret" { t.Fatalf("outcome is %v, want bad_secret", attempts[0]["outcome"]) } // The host without its ephemeral port: the port is a fresh number on every attempt, so keeping it // would make two attempts from one host look like two hosts. if attempts[0]["peer"] != "10.0.0.7" { t.Fatalf("peer is %v, want the host alone", attempts[0]["peer"]) } }