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:
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user