// 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" "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 // DefaultAdminHost is a literal, not a name and not empty. // // The window grants device trust: whoever reaches /provision with the node password gets a key the // agent will obey. So the bind address decides who can even attempt that, and the default has to be // the narrowest thing that still lets the documented flow work. // // It cannot be 127.0.0.1. Pairing happens over the LAN by design — the phone dials the node's LAN // address, which is what makes a QR code unnecessary — and a listener on loopback is not reachable // from another host. A loopback default would not be a hardening measure; it would be a flow that // never works, and the operator's first experience of this feature would be a refused connection // with no explanation. // // It is a literal rather than a name for a different reason: a name is resolved at bind time, so the // listening address would depend on what DNS said at that moment. That is not a decision the // operator made, and on a node whose resolver is compromised it is a decision someone else made. // Operators who want a narrower or wider bind pass --pair-bind. DefaultAdminHost = "0.0.0.0" 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", "peer": peerOf(r)}) 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(), "peer": peerOf(r)}) 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, "peer": peerOf(r)}) 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", "peer": peerOf(r)}) 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", "peer": peerOf(r)}) 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 } // peerOf reports the caller's address, for the log only. // // It is the one piece of context the attempt ceiling cannot supply: "ten failed pairings" says the // window closed, and "ten failed pairings from 10.0.0.7" says someone is trying. Only the IP is kept, // never the port, because the port is a fresh ephemeral number on every attempt and would make two // attempts from one host look like two hosts. func peerOf(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } 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}) }