From 1f46b7000d849b6d349767fc4cb15c4fa3ea4224 Mon Sep 17 00:00:00 2001 From: chick Date: Wed, 16 Sep 2026 19:20:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=83=A8=E7=BD=B2=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E4=B8=8E=E5=8D=95=E5=85=83=E6=96=87=E4=BB=B6=EF=BC=9BP6=20?= =?UTF-8?q?=E6=B8=97=E9=80=8F=E8=87=AA=E6=9F=A5=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B8=80=E5=A4=84=E7=9C=9F=E5=AE=9E=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/deployment.md:中继(反代要点含长轮询超时)、agent(msa-keygen 生成配置)、 配对窗口、按拒绝原因分流的排障表 - agent/cmd/msa-keygen:生成节点密钥、nodeID、agent token 与配对秘密,打印中继注册条目; 不生成设备密钥(那是手机的事,能生成就能冒充) - relay/systemd/simadmin-relay.service:保护指令与 agent 单元的差异逐条写明 (无 MemoryDenyWriteExecute,因为 V8 JIT 需要 W+X 页) - test/adversarial.test.js(12 项):回环硬校验(结构性断言中继无出站路径)、 重放与新鲜度的责任划分、邮箱深度与 handover、设备注册越权、信封校验边界 - 修 registry.resolve 未校验 nodeID:pending 表只按 reqID 索引,被控节点知道 另一节点的在途 reqID 即可抢先应答使其变 orphan,合法应答被丢弃 - .gitignore 排除交叉编译产物 --- cmd/msa-keygen/main.go | 125 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 cmd/msa-keygen/main.go diff --git a/cmd/msa-keygen/main.go b/cmd/msa-keygen/main.go new file mode 100644 index 0000000..caed954 --- /dev/null +++ b/cmd/msa-keygen/main.go @@ -0,0 +1,125 @@ +// Command msa-keygen mints the key material and identifiers a node needs, and prints a config file +// skeleton to stdout. +// +// It exists because the agent's config cannot be written by hand: the two private keys are raw +// 32-byte values that must be base64, and a node id, an agent token and a device id all have to +// match patterns the agent and the relay both enforce. An operator generating those with openssl and +// a text editor will eventually produce a file that fails at startup with a validation error whose +// cause is a character in the wrong place. +// +// It generates the *agent's* keys only. The phone's keys are generated on the phone, at pairing +// time, and never leave it — a tool that could mint a device key would be a tool that can impersonate +// a phone. +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + + "fun.chick/msa-agent/internal/wire" +) + +func main() { + var ( + nodeID = flag.String("node", "", "node id (nd_...); generated when empty") + relayURL = flag.String("relay", "https://relay.example", "relay base URL, https") + localPort = flag.Int("port", 3000, "the local SimAdmin port on 127.0.0.1") + auditPath = flag.String("audit", "/var/log/msa-agent/audit.log", "audit log path") + agentToken = flag.String("token", "", "relay bearer for this agent; generated when empty") + secret = flag.String("provision-secret", "", "pairing secret for an unprotected node; generated when empty") + write = flag.String("write", "", "write the config here instead of stdout (mode 0600)") + ) + flag.Parse() + + // The signing key is Ed25519 for challenge-response at the relay and for signing responses. + _, signingKey, err := ed25519.GenerateKey(rand.Reader) + check(err) + // The key-agreement key is raw X25519: clamping is the curve library's job, and a stored key + // that had already been clamped would be a second, non-standard encoding to keep in sync across + // three languages. + kxKey := make([]byte, wire.KeyBytes) + _, err = rand.Read(kxKey) + check(err) + + id := *nodeID + if id == "" { + id = "nd_" + randomHex(5) + } + token := *agentToken + if token == "" { + token = randomHex(32) + } + // The pairing secret is generated even though it is only needed for an unprotected node: a + // config without one cannot pair at all, and discovering that at pairing time means another trip + // to the node. When the node *is* protected, `nodePassword` takes precedence over this value, so + // carrying it is harmless. + pairingSecret := *secret + if pairingSecret == "" { + pairingSecret = randomHex(16) + } + + config := map[string]any{ + "nodeId": id, + "relayUrl": *relayURL, + "agentToken": token, + "localPort": *localPort, + "signKey": base64.StdEncoding.EncodeToString(signingKey), + "kxKey": base64.StdEncoding.EncodeToString(kxKey), + // The node password is intentionally absent: it is the operator's, and it is the one secret + // this tool must not invent. An unprotected node needs no value; a protected one needs the + // real password or every command fails with node_auth_failed. + "auditPath": *auditPath, + // Only used to authorise the pairing window on an unprotected node. Delete it after pairing + // if you like; nothing else reads it. + "provisionSecret": pairingSecret, + // Empty on purpose: devices are added by pairing (`msa-agent --pair`), which is the only + // path that should ever be able to introduce a trusted phone. + "devices": []any{}, + } + + encoded, err := json.MarshalIndent(config, "", " ") + check(err) + encoded = append(encoded, '\n') + + if *write == "" { + // The relay's own registration is printed alongside, because the two files have to agree on + // the node id and token and copying them by hand is where that agreement breaks. + fmt.Fprintf(os.Stderr, "# agent config (paste into %s, then add \"nodePassword\" if the node is protected)\n", "/etc/msa-agent/config.json") + os.Stdout.Write(encoded) + fmt.Fprintf(os.Stderr, "\n# relay registration: add this entry to MSA_AGENTS\n") + fmt.Fprintf(os.Stderr, "{\"nodeID\":%q,\"token\":%q}\n", id, token) + fmt.Fprintf(os.Stderr, "\n# pairing secret (unprotected nodes only; protected ones use nodePassword): %s\n", pairingSecret) + fmt.Fprintf(os.Stderr, "# next: run `msa-agent --pair` on the node once, then pair the phone from the app\n") + return + } + if err := os.MkdirAll(filepath.Dir(*write), 0o755); err != nil { + check(err) + } + // 0600: this file holds the agent's private keys and the node's password. + if err := os.WriteFile(*write, encoded, 0o600); err != nil { + check(err) + } + fmt.Printf("wrote %s (mode 0600)\n", *write) + fmt.Printf("relay registration: {\"nodeID\":%q,\"token\":%q}\n", id, token) +} + +func randomHex(bytes int) string { + raw := make([]byte, bytes) + _, err := rand.Read(raw) + check(err) + return hex.EncodeToString(raw) +} + +func check(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "msa-keygen:", err) + os.Exit(1) + } +}