"十次失败"说明窗口关了,"十次来自 10.0.0.7 的失败"说明有人在试——后者才是可行动 的那句话,而它是尝试次数上限无法提供的信息。每次尝试与每次成功现在都带 peer。 只记 IP 不记端口:端口每次都是新的临时号,带上会让同一台机器的两次尝试看起来像 两台,而"有几台机器在试"正是要回答的问题。 测试断言失败尝试带上了 peer,且是去掉端口后的主机。变异验证:去掉 peer,测试失败。 Go 全包、Node 120 项、Swift 107 项通过。
347 lines
12 KiB
Go
347 lines
12 KiB
Go
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"])
|
|
}
|
|
}
|