feat: 配对管理端点与中继侧设备注册上报

- 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 状态
This commit is contained in:
chick
2026-09-16 11:43:52 +08:00
parent 443af20e0a
commit 78e0d303ab
7 changed files with 891 additions and 36 deletions
+275
View File
@@ -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})
}
+282
View File
@@ -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)
}
}