From 78e0d303ab750a1a4e328c6fae69d396a202631c Mon Sep 17 00:00:00 2001 From: chick Date: Wed, 16 Sep 2026 11:43:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=85=8D=E5=AF=B9=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=E4=B8=8E=E4=B8=AD=E7=BB=A7=E4=BE=A7=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E6=B3=A8=E5=86=8C=E4=B8=8A=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent --pair:临时监听 :3001 仅开放 /provision 与 /destroy,以节点密码为共享秘密; 窗口由三个独立条件关闭(首次成功配对 / 失败尝试 10 次 / 10 分钟超时),配对成功后 管理监听即关(设计 §7 第 4 步) - agent 为每台配对手机生成中继 bearer(config.Save 0600 原子写;启动时为手写条目补发) - transport.PushDevices:握手成功后全量上报本节点受信设备集;空集是吊销声明而非无操作 - relay /v1/agent/devices:agent 会话后鉴权的设备注册;全量替换本节点条目; 操作员 MSA_DEVICES 为 static 不可被覆盖;跨节点 id 不可抢占 - 中继设备注册替代了 §11 第 6 项的一部分:节点 destroy + 重握手即可在路由层吊销 - 配置:provisionSecret 字段(无密码保护节点需显式设置才可配对);Validate(pairMode) - 测试:pair 流程 8 项、设备注册 6 项、transport 1 项;文档 §7 实现修订注记与 README 状态 --- cmd/msa-agent/main.go | 96 ++++++++- internal/config/config.go | 154 ++++++++++++--- internal/config/config_test.go | 57 +++++- internal/pair/pair.go | 275 ++++++++++++++++++++++++++ internal/pair/pair_test.go | 282 +++++++++++++++++++++++++++ internal/transport/transport.go | 26 ++- internal/transport/transport_test.go | 37 ++++ 7 files changed, 891 insertions(+), 36 deletions(-) create mode 100644 internal/pair/pair.go create mode 100644 internal/pair/pair_test.go diff --git a/cmd/msa-agent/main.go b/cmd/msa-agent/main.go index 15e3d60..86ae20b 100644 --- a/cmd/msa-agent/main.go +++ b/cmd/msa-agent/main.go @@ -14,13 +14,16 @@ import ( "flag" "fmt" "log/slog" + "net/http" "os" "os/signal" + "strconv" "syscall" "time" "fun.chick/msa-agent/internal/agent" "fun.chick/msa-agent/internal/config" + "fun.chick/msa-agent/internal/pair" "fun.chick/msa-agent/internal/policy" "fun.chick/msa-agent/internal/runtime" "fun.chick/msa-agent/internal/transport" @@ -41,21 +44,86 @@ func main() { configPath = flag.String("config", "/etc/msa-agent/config.json", "path to the agent config file") check = flag.Bool("check", false, "validate the config and policy, then exit") once = flag.Bool("once", false, "run a single poll cycle and exit") + pairMode = flag.Bool("pair", false, "open the temporary pairing window on the LAN and exit when it closes") + pairPort = flag.Int("pair-port", pair.DefaultAdminPort, "port for the pairing window's management listener") ) flag.Parse() logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger) - if err := run(*configPath, *check, *once); err != nil { + var err error + if *pairMode { + err = runPair(*configPath, *pairPort) + } else { + err = run(*configPath, *check, *once) + } + if err != nil { // One line, then exit non-zero. systemd restarts it, and the journal keeps the reason. logger.Error("msa-agent exiting", "error", err) os.Exit(1) } } +// runPair serves design §7's management surface: a LAN listener offering /provision and /destroy +// for as long as the window is open, then exit. It is a separate mode from the poll loop because +// the invariant "the agent binds no socket" is a property of the daemon; the pairing window is a +// deliberate, operator-started exception with three independent shutdowns (first pairing, attempt +// ceiling, deadline). Pairing is run again by the operator for each additional phone. +func runPair(configPath string, adminPort int) error { + cfg, err := config.LoadForPairing(configPath) + if err != nil { + return err + } + handler, err := pair.New(configPath, cfg, func(event string, fields map[string]any) { + slog.Info(event, fieldsToArgs(fields)...) + }) + if err != nil { + return err + } + server := &http.Server{ + Addr: ":" + strconv.Itoa(adminPort), + Handler: handler.Routes(), + ReadHeaderTimeout: 5 * time.Second, + } + go func() { + slog.Info("pairing window open", "port", adminPort, "node", cfg.NodeID) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("pairing listener failed", "error", err) + handler.Close() + } + }() + defer server.Close() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + deadline := time.NewTimer(pair.DefaultWindow) + defer deadline.Stop() + select { + case <-handler.Done: + slog.Info("pairing window closed") + case <-deadline.C: + slog.Warn("pairing window expired unused") + case <-ctx.Done(): + slog.Info("pairing window interrupted") + } + // The listener is already closing via defer; Stop with a short grace so an in-flight request + // finishes rather than being truncated mid-JSON. + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + return server.Shutdown(shutdownCtx) +} + +func fieldsToArgs(fields map[string]any) []any { + args := make([]any, 0, len(fields)*2) + for key, value := range fields { + args = append(args, key, value) + } + return args +} + func run(configPath string, checkOnly, once bool) error { - cfg, err := config.Load(configPath) + cfg, _, err := config.EnsureRelayTokens(configPath) if err != nil { return err } @@ -126,7 +194,15 @@ func run(configPath string, checkOnly, once bool) error { if once { return cycle(ctx, node, client, pol) } - return loop(ctx, node, client, pol) + return loop(ctx, node, client, pol, registrationsFrom(cfg)) +} + +func registrationsFrom(cfg config.File) []transport.DeviceRegistration { + out := make([]transport.DeviceRegistration, 0, len(cfg.Devices)) + for _, device := range cfg.Devices { + out = append(out, transport.DeviceRegistration{App: device.ID, Token: device.RelayToken}) + } + return out } // loop is the agent's whole life: handshake, then poll forever, re-handshaking whenever the relay @@ -135,13 +211,13 @@ func run(configPath string, checkOnly, once bool) error { // Errors are classified here rather than handled per call site, because the three situations look // identical from inside a function and need three different responses: retry now, re-authenticate, // or wait. -func loop(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error { +func loop(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy, devices []transport.DeviceRegistration) error { backoff := minBackoff for { if ctx.Err() != nil { return nil } - err := handshakeAndPoll(ctx, node, client, pol) + err := handshakeAndPoll(ctx, node, client, pol, devices) switch { case err == nil: backoff = minBackoff @@ -176,10 +252,18 @@ func next(current time.Duration) time.Duration { return doubled } -func handshakeAndPoll(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error { +func handshakeAndPoll(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy, devices []transport.DeviceRegistration) error { if _, err := client.Hello(ctx, version, pol.SHA256()); err != nil { return err } + // The relay holds no device state across restarts, and it must never trust a device claim it + // cannot tie to an authenticated agent. Reporting the full set after every handshake is what + // makes that true without a second channel: the node's paired-device set, including any + // revocations from a destroy, is re-asserted each time the session is established. Empty is + // pushed too, because "empty" is exactly the claim that revocation needs. + if err := client.PushDevices(ctx, devices); err != nil { + return err + } return cycle(ctx, node, client, pol) } diff --git a/internal/config/config.go b/internal/config/config.go index c2e765d..2f9d416 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,8 @@ package config import ( "bytes" + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -24,6 +26,10 @@ type Device struct { // it and holds no corresponding secret, so a phone cannot be impersonated from the relay side. SignKey string `json:"signKey"` Name string `json:"name,omitempty"` + // RelayToken is the bearer credential the phone presents to the relay for this pairing. The + // agent generated it at pairing time and re-registers it with the relay on every handshake, + // because the relay holds no state across restarts. + RelayToken string `json:"relayToken,omitempty"` } // File is the on-disk shape. @@ -45,33 +51,23 @@ type File struct { // commands with node_auth_failed rather than looping against a lockout. NodePassword string `json:"nodePassword,omitempty"` // AuditPath is where the audit log is appended. - AuditPath string `json:"auditPath"` - Devices []Device `json:"devices"` -} - -// Load reads and validates a config file. -// -// Validation is exhaustive rather than partial on purpose: an agent that starts with a missing -// device or a bad port would sit there healthy and uncommandable, which is harder to notice than a -// crash at boot. -func Load(path string) (File, error) { - var cfg File - raw, err := os.ReadFile(path) - if err != nil { - return cfg, fmt.Errorf("config: %w", err) - } - decoder := json.NewDecoder(bytes.NewReader(raw)) - if err := decoder.Decode(&cfg); err != nil { - return cfg, fmt.Errorf("config: decode %s: %w", path, err) - } - if err := cfg.Validate(); err != nil { - return cfg, fmt.Errorf("config: %s: %w", path, err) - } - return cfg, nil + AuditPath string `json:"auditPath"` + // Devices may be empty only in pair mode (--pair). In normal operation an agent with no + // paired devices refuses to start, because it could authorise nothing. + Devices []Device `json:"devices"` + // ProvisionSecret is the shared secret a phone must present to pair during the temporary + // --pair window (design §7 step 2, "以节点密码作为共享秘密"). When the node is protected it + // defaults to NodePassword; an unprotected node needs an explicit value here or pairing is + // refused. It is secret material and lives in the config file like the private keys do. + ProvisionSecret string `json:"provisionSecret,omitempty"` } // Validate checks every field the agent depends on. -func (c File) Validate() error { +// +// pairMode relaxes exactly one rule: devices may be empty while the agent is waiting to be +// paired. Everything else stays enforced, because a pair-mode agent with a broken nodeId or a +// missing key would pair successfully and then command nothing. +func (c File) Validate(pairMode bool) error { if !nodePattern.MatchString(c.NodeID) { return errors.New("nodeId must look like nd_xxxxxxxx") } @@ -93,7 +89,7 @@ func (c File) Validate() error { if c.AuditPath == "" { return errors.New("auditPath is required") } - if len(c.Devices) == 0 { + if len(c.Devices) == 0 && !pairMode { return errors.New("devices is empty, so no phone could ever be authorised") } seen := map[string]bool{} @@ -111,3 +107,111 @@ func (c File) Validate() error { } return nil } + +// Load reads a config file for the normal (commanding) mode. +func Load(path string) (File, error) { + cfg, err := loadFile(path) + if err != nil { + return cfg, err + } + if err := cfg.Validate(false); err != nil { + return cfg, fmt.Errorf("config: %s: %w", path, err) + } + return cfg, nil +} + +// LoadForPairing reads a config file for --pair mode, where an empty device list is the starting +// state rather than a misconfiguration. +func LoadForPairing(path string) (File, error) { + cfg, err := loadFile(path) + if err != nil { + return cfg, err + } + if err := cfg.Validate(true); err != nil { + return cfg, fmt.Errorf("config: %s: %w", path, err) + } + return cfg, nil +} + +func loadFile(path string) (File, error) { + var cfg File + raw, err := os.ReadFile(path) + if err != nil { + return cfg, fmt.Errorf("config: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + if err := decoder.Decode(&cfg); err != nil { + return cfg, fmt.Errorf("config: decode %s: %w", path, err) + } + return cfg, nil +} + +// Save writes the config back with 0600, atomically. +// +// Atomic (temp file + rename) because the pairing flow writes the device list at moments when the +// agent may be concurrently read by systemd restarts: a half-written config is a config that fails +// to load, which turns a completed pairing into an uncommandable node. +func Save(path string, cfg File) error { + if err := cfg.Validate(true); err != nil { + return fmt.Errorf("config: refusing to save an invalid file: %w", err) + } + encoded, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + encoded = append(encoded, '\n') + temp := path + ".tmp" + if err := os.WriteFile(temp, encoded, 0o600); err != nil { + return err + } + return os.Rename(temp, path) +} + +// NewRelayToken mints a device bearer for the relay: 24 random bytes, hex. Hex because the relay +// only requires a bearer string and hex never contains a character that needs escaping when it +// travels through JSON or headers. +func NewRelayToken() string { + raw := make([]byte, 24) + if _, err := rand.Read(raw); err != nil { + // rand.Read only fails if the system entropy source is gone, in which case nothing in the + // agent can run safely anyway. + panic(fmt.Sprintf("config: entropy source failed: %v", err)) + } + return hex.EncodeToString(raw) +} + +// NewDeviceID mints an app id for a freshly paired phone. The relay and the agent both match ids +// against ^ap_[A-Za-z0-9_-]{8,64}$, so 12 hex characters is the narrowest thing that fits. +func NewDeviceID() string { + raw := make([]byte, 6) + if _, err := rand.Read(raw); err != nil { + panic(fmt.Sprintf("config: entropy source failed: %v", err)) + } + return "ap_" + hex.EncodeToString(raw) +} + +// EnsureRelayTokens fills in bearer tokens for hand-written device entries that lack one and +// persists the result. It reports whether the file changed so a caller can log the event rather +// than silently rewriting an operator's file on every boot. +func EnsureRelayTokens(path string) (File, bool, error) { + cfg, err := loadFile(path) + if err != nil { + return cfg, false, err + } + if err := cfg.Validate(false); err != nil { + return cfg, false, fmt.Errorf("config: %s: %w", path, err) + } + changed := false + for i := range cfg.Devices { + if cfg.Devices[i].RelayToken == "" { + cfg.Devices[i].RelayToken = NewRelayToken() + changed = true + } + } + if changed { + if err := Save(path, cfg); err != nil { + return cfg, false, err + } + } + return cfg, changed, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8bd0ff4..44ef263 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -23,11 +23,64 @@ func valid() File { } func TestValidateAcceptsACompleteFile(t *testing.T) { - if err := valid().Validate(); err != nil { + if err := valid().Validate(false); err != nil { t.Fatalf("a complete file was refused: %v", err) } } +// Pair mode relaxes exactly one rule: an empty device list is the starting state of a node that +// has not been paired yet, not a misconfiguration. Every other field still validates, because a +// pair-mode agent with a broken nodeId pairs successfully and then commands nothing. +func TestPairModeRelaxesOnlyTheDeviceList(t *testing.T) { + cfg := valid() + cfg.Devices = nil + if err := cfg.Validate(true); err != nil { + t.Fatalf("pair mode refused an empty device list: %v", err) + } + cfg.NodeID = "node-1" + if err := cfg.Validate(true); err == nil { + t.Fatal("pair mode accepted a malformed nodeId") + } +} + +func TestSaveRoundTripsAndIsSixHundred(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + cfg := valid() + if err := Save(path, cfg); err != nil { + t.Fatalf("Save: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("saved config mode is %v, want 0600", info.Mode().Perm()) + } + reloaded, err := Load(path) + if err != nil { + t.Fatalf("Load after Save: %v", err) + } + if reloaded.NodeID != cfg.NodeID || len(reloaded.Devices) != 1 || + reloaded.Devices[0].SignKey != cfg.Devices[0].SignKey { + t.Fatalf("round-trip lost fields: %+v", reloaded) + } +} + +// A save must not be able to write a file that the next boot would refuse: the validation runs +// before the rename, so a caller building a device list with a bad id gets an error, not a +// corrupted file. +func TestSaveRefusesAnInvalidFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + cfg := valid() + cfg.Devices[0].ID = "not-an-app-id" + if err := Save(path, cfg); err == nil { + t.Fatal("saved a device list the loader would refuse") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("a failed save left a file behind") + } +} + func TestValidateNamesEveryMissingField(t *testing.T) { cases := map[string]func(*File){ "nodeId": func(c *File) { c.NodeID = "" }, @@ -47,7 +100,7 @@ func TestValidateNamesEveryMissingField(t *testing.T) { for name, mutate := range cases { cfg := valid() mutate(&cfg) - if err := cfg.Validate(); err == nil { + if err := cfg.Validate(false); err == nil { t.Errorf("%s: a broken config was accepted", name) } } diff --git a/internal/pair/pair.go b/internal/pair/pair.go new file mode 100644 index 0000000..a9bf36e --- /dev/null +++ b/internal/pair/pair.go @@ -0,0 +1,275 @@ +// Package pair implements the agent's temporary management surface from design §7. +// +// It is the only place the agent binds a listening socket, and it exists only while the operator +// runs `msa-agent --pair`. That is the whole reason the design requires the window to be temporary: +// an admin port that can inject trusted devices and stays open indefinitely is a second hole beside +// the relay, reachable by anyone on the LAN without ever touching a phone. So the window is closed +// three ways: the first successful pairing shuts it, a failed-attempt ceiling shuts it, and a +// deadline shuts it even if nothing happens at all. +// +// Authentication is the node password, presented by the phone over the LAN. This works because of +// the one fact the pairing flow leans on: at pairing time the phone is necessarily already on the +// internal network, so the shared secret never has to traverse the relay. +package pair + +import ( + "crypto/ed25519" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "fun.chick/msa-agent/internal/config" + "fun.chick/msa-agent/internal/wire" +) + +// Limits. The attempt ceiling is small on purpose: the pairing secret is a human password, and a +// window where anyone on the LAN can try it a thousand times is a window that leaks the node. +const ( + MaxAttempts = 10 + DefaultWindow = 10 * time.Minute + MaxRequestBytes = 8 << 10 + DefaultAdminPort = 3001 +) + +var ( + ErrNoSecret = errors.New("pair: the node is unprotected and no provisionSecret is configured, so pairing cannot be authorised") + ErrClosed = errors.New("pair: the pairing window is closed") + ErrRefused = errors.New("pair: too many failed pairing attempts") +) + +// Handler serves /provision and /destroy and nothing else. +type Handler struct { + mu sync.Mutex + cfg config.File + cfgPath string + nodeID string + agentSign ed25519.PublicKey + agentKx []byte + secret string + attempts int + closed bool + // Done is closed once the window should end: a completed pairing, the attempt ceiling, or an + // explicit destroy. main.go selects on it to shut the listener and exit. + Done chan struct{} + + log func(event string, fields map[string]any) +} + +// New builds the handler from the loaded config. SignKey/KxKey are the agent's private keys; the +// public halves are derived here so a phone can never be handed a key that disagrees with the +// private one the agent commands with. +func New(cfgPath string, cfg config.File, log func(string, map[string]any)) (*Handler, error) { + if len(cfg.SignKey) == 0 || len(cfg.KxKey) == 0 { + return nil, errors.New("pair: agent keys are missing from the config") + } + signRaw, err := base64.StdEncoding.Strict().DecodeString(cfg.SignKey) + if err != nil || len(signRaw) != ed25519.PrivateKeySize { + return nil, errors.New("pair: signKey must be a base64 ed25519 private key") + } + kxRaw, err := base64.StdEncoding.Strict().DecodeString(cfg.KxKey) + if err != nil || len(kxRaw) != 32 { + return nil, errors.New("pair: kxKey must be a base64 32-byte x25519 private key") + } + secret := cfg.NodePassword + if secret == "" { + secret = cfg.ProvisionSecret + } + if secret == "" { + return nil, ErrNoSecret + } + h := &Handler{ + cfg: cfg, + cfgPath: cfgPath, + nodeID: cfg.NodeID, + agentSign: ed25519.PrivateKey(signRaw).Public().(ed25519.PublicKey), + agentKx: kxRaw, + secret: secret, + Done: make(chan struct{}), + log: log, + } + if h.log == nil { + h.log = func(string, map[string]any) {} + } + return h, nil +} + +// Routes is the complete surface. Anything else is 404, and 404 carries no body that could hint at +// what does exist here. +func (h *Handler) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /provision", h.provision) + mux.HandleFunc("POST /destroy", h.destroy) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) }) + return mux +} + +type provisionRequest struct { + Password string `json:"password"` + AppSignPub string `json:"appSignPub"` + AppKxPub string `json:"appKxPub"` + DeviceName string `json:"deviceName,omitempty"` +} + +type provisionResponse struct { + NodeID string `json:"nodeID"` + AppID string `json:"appID"` + AgentSignPub string `json:"agentSignPub"` + AgentKxPub string `json:"agentKxPub"` + RelayURL string `json:"relayURL"` + DeviceToken string `json:"deviceToken"` +} + +// Close ends the window: after a completed pairing or an explicit destroy, no further request is +// answered even one that races in before the listener stops. +func (h *Handler) Close() { + h.mu.Lock() + defer h.mu.Unlock() + h.closeLocked() +} + +func (h *Handler) closeLocked() { + if !h.closed { + h.closed = true + close(h.Done) + } +} + +func (h *Handler) provision(w http.ResponseWriter, r *http.Request) { + var body provisionRequest + limited := http.MaxBytesReader(w, r.Body, MaxRequestBytes) + if err := json.NewDecoder(limited).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "the pairing request is not valid JSON") + return + } + if _, err := decodeKey(body.AppSignPub, ed25519.PublicKeySize, "appSignPub"); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if _, err := decodeKey(body.AppKxPub, 32, "appKxPub"); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + if h.closed { + writeError(w, http.StatusGone, "the pairing window is closed") + return + } + if !h.authorize(body.Password) { + h.log("pair_attempt", map[string]any{"outcome": "bad_secret"}) + writeError(w, http.StatusUnauthorized, "the shared secret did not match") + return + } + + deviceID := config.NewDeviceID() + relayToken := config.NewRelayToken() + h.cfg.Devices = append(h.cfg.Devices, config.Device{ + ID: deviceID, + SignKey: body.AppSignPub, + Name: body.DeviceName, + RelayToken: relayToken, + }) + if err := config.Save(h.cfgPath, h.cfg); err != nil { + // The device was not added if the save failed, so a retry is safe and the phone must not + // be handed a keypair the agent will not honour. + h.log("pair_attempt", map[string]any{"outcome": "save_failed", "error": err.Error()}) + writeError(w, http.StatusInternalServerError, "the agent could not persist the pairing") + return + } + // One pairing closes the window (design §7 step 4: 配对成功后 agent 关闭管理监听). Adding a + // second phone means re-running --pair, which is the right amount of friction for a socket + // that grants device trust. + h.closeLocked() + h.log("pair_provision", map[string]any{"outcome": "ok", "app": deviceID}) + writeJSON(w, provisionResponse{ + NodeID: h.nodeID, + AppID: deviceID, + AgentSignPub: base64.StdEncoding.EncodeToString(h.agentSign), + AgentKxPub: base64.StdEncoding.EncodeToString(mustPublic(h.agentKx)), + RelayURL: h.cfg.RelayURL, + DeviceToken: relayToken, + }) +} + +func (h *Handler) destroy(w http.ResponseWriter, r *http.Request) { + var body struct { + Password string `json:"password"` + } + limited := http.MaxBytesReader(w, r.Body, MaxRequestBytes) + if err := json.NewDecoder(limited).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "the destroy request is not valid JSON") + return + } + h.mu.Lock() + defer h.mu.Unlock() + if h.closed { + writeError(w, http.StatusGone, "the pairing window is closed") + return + } + if !h.authorize(body.Password) { + h.log("pair_attempt", map[string]any{"outcome": "bad_secret_destroy"}) + writeError(w, http.StatusUnauthorized, "the shared secret did not match") + return + } + h.cfg.Devices = nil + if err := config.Save(h.cfgPath, h.cfg); err != nil { + writeError(w, http.StatusInternalServerError, "the agent could not persist the destroy") + return + } + h.closeLocked() + h.log("pair_destroy", map[string]any{"outcome": "ok"}) + writeJSON(w, map[string]any{"destroyed": true}) +} + +// authorize burns an attempt slot on every failure and closes the window at the ceiling. The +// compare is constant time; the counter is what stops the window from being a password oracle. +func (h *Handler) authorize(presented string) bool { + match := subtle.ConstantTimeCompare([]byte(presented), []byte(h.secret)) == 1 + if !match { + h.attempts++ + if h.attempts >= MaxAttempts { + h.log("pair_attempt", map[string]any{"outcome": "too_many"}) + h.closeLocked() + } + } + return match +} + +func decodeKey(value string, size int, field string) ([]byte, error) { + raw, err := base64.StdEncoding.Strict().DecodeString(value) + if err != nil { + return nil, fmt.Errorf("%s is not valid base64", field) + } + if len(raw) != size { + return nil, fmt.Errorf("%s must be %d bytes", field, size) + } + return raw, nil +} + +func mustPublic(private []byte) []byte { + // Derive, never store: a published kx key that disagreed with the agent's private key would + // fail as an unreadable request with no useful cause. + key, err := wire.PublicFrom(private) + if err != nil { + panic(fmt.Sprintf("pair: agent kx key is unusable: %v", err)) + } + return key +} + +func writeJSON(w http.ResponseWriter, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, detail string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]any{"error": detail}) +} diff --git a/internal/pair/pair_test.go b/internal/pair/pair_test.go new file mode 100644 index 0000000..614929c --- /dev/null +++ b/internal/pair/pair_test.go @@ -0,0 +1,282 @@ +package pair + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "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) + } +} diff --git a/internal/transport/transport.go b/internal/transport/transport.go index 9c3ad6c..375b2bb 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -27,9 +27,10 @@ import ( // Paths on the relay. Versioned, because an agent and a relay will not be upgraded on the same day. const ( - PathHello = "/v1/agent/hello" - PathPoll = "/v1/agent/poll" - PathResult = "/v1/agent/result" + PathHello = "/v1/agent/hello" + PathPoll = "/v1/agent/poll" + PathResult = "/v1/agent/result" + PathDevices = "/v1/agent/devices" ) var ( @@ -196,6 +197,25 @@ func (c *Client) Result(ctx context.Context, envelope json.RawMessage) error { func (c *Client) session() string { return c.token } +// DeviceRegistration is one paired phone as the relay needs to know it: the bearer token the phone +// presents, minted by the agent at pairing time and never seen by the relay before that moment. +type DeviceRegistration struct { + App string `json:"app"` + Token string `json:"token"` +} + +// PushDevices reports the node's complete paired-device set after the handshake. +// +// Full set, not a delta: the relay replaces everything it holds for this node, which is what makes +// revocation work without a second channel. Destroying the device list (or re-pairing) and letting +// the next hello carry the smaller set removes the revoked bearer from the relay's routing, and an +// agent that never reconnects cannot revoke anything but is also unable to command itself. +func (c *Client) PushDevices(ctx context.Context, devices []DeviceRegistration) error { + return c.post(ctx, PathDevices, map[string]any{ + "node": c.nodeID, "devices": devices, + }, &json.RawMessage{}, c.session(), false) +} + // post is the single place a relay request is made, so authentication, size limits, status mapping // and body draining cannot drift between the three endpoints. // diff --git a/internal/transport/transport_test.go b/internal/transport/transport_test.go index 0453b32..a770099 100644 --- a/internal/transport/transport_test.go +++ b/internal/transport/transport_test.go @@ -205,6 +205,43 @@ func TestResultPostsTheEnvelopeVerbatim(t *testing.T) { } } +// The device registration posts the node's authoritative paired set under the session, which is +// what lets the relay learn about freshly paired phones. Empty is posted too: "nobody" is a +// revocation claim, not a no-op. +func TestPushDevicesPostsTheFullSetUnderTheSession(t *testing.T) { + var got map[string]any + client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != PathDevices { + t.Fatalf("posted to %q", r.URL.Path) + } + if r.Header.Get("X-MSA-Session") == "" { + t.Error("the device push must carry the session") + } + json.NewDecoder(r.Body).Decode(&got) + w.WriteHeader(http.StatusOK) + }), "t") + err := client.PushDevices(context.Background(), []DeviceRegistration{ + {App: "ap_5b1d0000", Token: "tok-a"}, + }) + if err != nil { + t.Fatal(err) + } + if got["node"] != "nd_test0001" { + t.Fatalf("node is %v", got["node"]) + } + devices, _ := got["devices"].([]any) + if len(devices) != 1 { + t.Fatalf("devices: %v", got["devices"]) + } + // An empty list is a real claim, not a skipped call. + client2, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }), "t") + if err := client2.PushDevices(context.Background(), []DeviceRegistration{}); err != nil { + t.Fatalf("empty push: %v", err) + } +} + func TestRetryAfterHasASaneDefault(t *testing.T) { client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK)