diff --git a/docs/ios-relay-architecture.md b/docs/ios-relay-architecture.md index 76ab52b..8cfb23c 100644 --- a/docs/ios-relay-architecture.md +++ b/docs/ios-relay-architecture.md @@ -17,12 +17,18 @@ 少了密码学,这套东西就只是一个更难用的 VPN。 ```text -iPhone ──HTTPS(签名信封)──> 中继(VPS, 哑投递) ──<──出站 WSS── 节点A agent ──HTTP──> 127.0.0.1:3000 +iPhone ──HTTPS(签名信封)──> 中继(VPS, 哑投递) <──出站 HTTPS 长轮询── 节点A agent ──HTTP──> 127.0.0.1:3000 每节点一条 agent 连接,agent 只被允许访问本机回环 ``` `<──` 表示连接由 agent 侧发起,内网无任何监听端口。 +> **实现修订(2026-09)**:agent↔中继采用**长轮询 HTTPS**而非 WSS。理由是实现约束而非协议偏好: +> agent 必须只用 Go 标准库交叉编译(WebSocket 的 framing/masking/ping-pong/close 要几百行且需要 +> 自己测试),而"连接由内网侧发起、内网无监听"这一安全性质与承载协议无关。握手为两段式 +> challenge-response(`/v1/agent/hello` → 签服务端随机数),投递为 `/v1/agent/poll`(25s 挂起) +> + `/v1/agent/result`,代码在 `simadmin-relay/agent/internal/transport/` 与 `relay/src/`。 + ## 2. 为什么是"每节点一个 agent"而不是"内网一个网关 agent" 用户提出的这个选择是正确的,且理由比运维便利性更强:**它把 SSRF 跳板的爆炸半径从整个内网收缩到 @@ -54,54 +60,75 @@ iPhone ──HTTPS(签名信封)──> 中继(VPS, 哑投递) ──<──出 ```jsonc { "v": 1, - "nodeID": "nd_7f3a...", - "reqID": "", + "node": "nd_7f3a...", + "req": "", + "app": "ap_5b1d...", "ts": 1727289600, + "alg": "X25519-ED25519-AES256GCM-HKDFSHA256", "ek": "", // 本次请求的临时 X25519 公钥 - "payload": "", - "sig": "" + "p": "", + "s": "" } ``` -`sig` 覆盖 `{v,nodeID,reqID,ts,ek,payload}` 的规范化字节串(固定字段序 + 长度前缀,不做 JSON -字符串拼接签名)。这一点是实质性的:签名包含 `nodeID`,中继就无法把一个对节点 A 有效的请求 -改投给节点 B;签名包含 `reqID` 与 `ek`,就无法做响应错配或临时公钥替换。 +短字段名是刻意的:这是三语言共享的线上格式,Go / Node / Swift 各自的结构体标签必须逐字一致, +而长名在手写解码器里最容易抄错。字段名以 `agent/internal/wire/wire.go` 为权威。 + +`s` 覆盖 `v,node,req,app,ts,ek,p` 的规范化字节串(固定字段序 + 每个字段 uint32be 长度前缀, +不做 JSON 字符串拼接签名)。这一点是实质性的:签名包含 `node`,中继就无法把一个对节点 A 有效 +的请求改投给节点 B;签名包含 `req` 与 `ek`,就无法做响应错配或临时公钥替换;签名包含 `app`, +中继就无法把一台手机的流量改标成另一台的。响应信封用另一个域字符串 `msa-relay-v1-resp` 且 +**不含 `app`**,因此两个方向的签名不能互换。 + +规范化字节串的确切定义(含 `v`/`ts` 也带 `00000008` 长度前缀这一条)以 +`contracts/test-vectors.json` 为准,该文件由 `agent/cmd/msa-vectors` 生成,并由 Node +(`test/wire.test.js`)与 Swift 各自重算。三语言互不共享代码,因此它们的字节一致是真实证据, +不一致则说明编码漂移,而不是"某一端坏了"。 + +`alg` 只有唯一合法值,且在任何其他字段之前校验。未知值一律硬错误,不做算法协商——降级攻击 +就发生在协商里。 `payload` 解密后的内层: ```jsonc -{ "method": "POST", "path": "/api/sms/send", "query": {}, "body": {...}, - "confirm": {"intent": ""}, - "deadline": 1727289630 } +{ "m": "POST", "p": "/api/sms/send", + "intent": "", + "body": "" } ``` +`body` 以原始字节透传,agent 不解析也不重新序列化:一旦重新编码 JSON,intent 所覆盖的字节就 +和真正转发上去的字节不同,确认语义随之失效。`p` 携带完整路径与查询串,agent 不做路径拼接, +因此不存在来自中继的路径穿越面。 + +`ts` 由信封层统一负责新鲜度(`|now - ts| > clockSkewSeconds` 即拒),内层不再带 `deadline` +字段:两套过期语义只会互相冲突,而且信封层的那一个受签名保护,内层的那一个不受。 + 密钥派生:请求方向 `HKDF(X25519(appEphPriv, K_agent_kx.pub), salt=nodeID, info="msa-relay-v1-req")`, agent 侧用 `X25519(K_agent_kx.priv, ek)` 得到同一密钥;响应方向由 agent 每次新生成临时密钥对, -把公钥放在响应信封的 `ek` 里(同样被签名覆盖),手机用 `X25519(K_app_kx.priv, ek)` 解密。 +把公钥放在响应信封的 `ek` 里(同样被签名覆盖),与**手机本次请求的 `ek`** 做 DH——即响应方向是 +临时×临时,手机不需要在配对后保留可用的 `K_app_kx` 私钥于响应路径(第 11 节第 5 项的缓存方案 +因此已由实现取代)。 -**这里的前向安全性质要说准,不能含糊。** 两个方向都是"临时-静态"单消息 DH(HPKE base 模式的 -同一形状),因此每个方向只对**发送方**的长期密钥泄露是前向安全的,对**接收方**的不是: +**这里的前向安全性质要说准,不能含糊。** 请求方向是"临时-静态"单消息 DH(HPKE base 模式的 +同一形状),因此只对**发送方**(手机)的长期密钥泄露是前向安全的,对**接收方**(agent)的不是; +响应方向(实现后)双向临时,任一方长期密钥泄露都不能解密历史响应: - 请求方向的 `ek` 以明文出现在被签名的信封里。日后 `K_agent_kx.priv` 泄露的人,可以用它和录到的 `ek` 直接算出历史请求的密钥,从而读出历史请求内容。 -- 响应方向同理:`K_app_kx.priv` 泄露即可解密录到的历史响应。 - 反过来,`appEphPriv` 与 `agentEphPriv` 用后即弃且从不上传线,所以只泄露"发送方"的长期密钥 不足以解密历史。 -如果要求双向都对长期密钥泄露前向安全,必须让两个方向都变成"临时-临时",即双方各贡献一个临时 -密钥。可行的低成本做法:agent 在**每个**响应信封里带上自己的当前临时公钥(被 `K_agent_sign` -签名覆盖),手机缓存它,下一次请求用这个缓存公钥而非 `K_agent_kx.pub`。代价是配对后的第一条 -请求没有缓存、只能用静态公钥引导(bootstrap 那一条不具前向安全),以及 agent 重启后缓存需要 -失效重取。**建议直接按缓存方案实现**,因为节点凭据与短信内容都在 payload 里,"中继被长期记录 + -日后某台 agent 被拔盘"并不是遥远的组合。是否接受 bootstrap 那一条的弱化需要确认。 +> **实现修订**:响应方向已改为对手机**本次请求的临时 `ek`** 加密,无需额外往返,因此上段"建议的 +> 缓存方案"对响应方向已无必要。残留的弱化只剩请求方向:要让请求也对 `K_agent_kx` 泄露前向安全, +> 才需要手机缓存 agent 的当前临时公钥(bootstrap 第一条除外)。是否值得做,留待 P4 后再定。 GCM IV 取 `HKDF(S_req, salt=reqID, info="iv")` 的前 12 字节,显式派生而不是全零。虽然每个请求 都用一次性密钥、全零 IV 在实践中不会重用,但一旦有人日后把 `S_req` 改成按会话复用(很常见的 "优化"),全零 IV 立刻变成 nonce-reuse 灾难;显式派生把这条退路堵死。 重放防护:agent 维护 `reqID` 的 LRU(建议 4096 条)拒绝重复;拒绝 `|now - ts| > 120s` 的请求; -`deadline` 到期即丢弃,不执行。不需要额外的 nonce 字段——`reqID` 是 uuid4 且被签名覆盖,中继 -无法把一个有效请求的 `reqID` 换掉再投,因此它本身就是唯一性来源。 +不执行过期请求。不需要额外的 nonce 字段——`req` 是 uuid4 且被签名覆盖,中继无法把一个有效请求 +的 `req` 换掉再投,因此它本身就是唯一性来源。 时钟偏移是本方案最容易被忽略的故障源:节点设备如果长期离线且时钟漂移,表现为"所有指令都被拒", 所以 agent 日志必须把拒绝原因写成可区分的三类(签名无效 / 重放 / 超时),手机侧错误文案也要能 @@ -129,7 +156,7 @@ GCM IV 取 `HKDF(S_req, salt=reqID, info="iv")` 的前 12 字节,显式派生 - 设备鉴权:配对时下发的长随机 bearer token(仅用于访问控制与滥用限制,**不构成信任**)。 - agent 鉴权:bearer token + 用 `K_agent_sign` 签一个服务端随机数。仅偷到 token 无法冒充 agent。 -- 注册表:`nodeID -> 唯一活跃 WSS 会话`;agent 断线即标记不可达,不做请求排队等待重连。 +- 注册表:`nodeID -> 唯一活跃会话`(长轮询挂起槽,后到的 agent 握手顶掉前一个);agent 断线即标记不可达,不做请求排队等待重连。 - 邮箱:每节点一条有界 FIFO(深度建议 8),`reqID` 关联请求与响应,30s 超时向上返回 - 错误一律用仓库既有的 RFC 9457 信封 `ProblemDetails`(`packages/contracts/src/errors.ts`), 不另造形状;其中 `requestId` 直接取 `reqID`,这样中继日志、控制台 request coordinator 与手机 @@ -239,7 +266,7 @@ agent 都从它加载,并加一个测试断言两侧解析结果一致。这 | 阶段 | 内容 | 依赖 | |---|---|---| | P1 | 契约先行:策略抽为共享 JSON、枚举中继错误码、把 `FRESHNESS_STATUSES` 引到 iOS;加双端一致性测试 | 无 | -| P2 | agent:出站 WSS、信封验签解密、策略执行、回环硬校验、审计日志、配对端点 | P1 | +| P2 | agent:出站长轮询、信封验签解密、策略执行、回环硬校验、审计日志、本机登录、配对端点 | P1 | | P3 | 中继:设备/agent 鉴权、注册表、有界邮箱、超时、元数据日志 | P1 | | P4 | iOS:`InstanceEndpoint` + 迁移 + `NodeTransport` 抽象 + 密钥与配对 | P2,P3 | | P5 | iOS:通道徽标、分层错误、危险写确认、吊销与轮换入口 | P4 | @@ -261,19 +288,23 @@ P2/P3 可并行。P4 是唯一动到现有 iOS 数据模型的部分,风险集 不是理论问题。 - 内网仍无任何公网入口,但**出站被完全掐断的环境(仅白名单出网的内网)会让 agent 无法工作**。 -## 11. 待你确认 +## 11. 已确认项与仍待确认项 -1. **节点设备的操作系统与架构**。决定 agent 用静态 Go 二进制、Rust,还是 Android 前台服务, - 也决定 P2 的工作量。上游是 Rust 后端 + 电话类 API(SIM/短信/频段锁/eUICC LPAC),但我在 - 仓库里没找到节点运行环境的记载,这个信息只能由你提供。 -2. 8788 控制台与中继是否同机部署。同机能少一套 TLS 与进程管理,但控制台目前绑 `127.0.0.1`, - 同机意味着它也要开始监听公网口,等于扩大控制台暴露面,我倾向**不同机**。 -3. 设备私钥放沙盒文件还是必须进 Secure Enclave(6.4)。 -4. 所有节点是否都在同一内网且 SimAdmin 就在本机回环。跨网段或异机的节点不适用第 2 节的 - 回环硬校验,需要显式 CIDR 白名单,安全性质会下降一档。 -5. 第 10 节的元数据泄露是否接受。 -6. 是否采用第 3 节的双临时缓存方案(代价:配对后第一条请求不具前向安全 + agent 重启需失效重取)。 - 不采用则明确接受"任一长期密钥泄露可解密历史流量"。 +已确认(据此实现): + +1. 节点设备为 **ARM 架构 Debian**,且 SimAdmin 与 agent 同机运行。因此 agent 是 Go 静态二进制, + 交叉编译 `linux/arm64` 与 `linux/arm`,回环硬校验(第 2 节)对所有节点成立,无需 CIDR 白名单。 +2. 设备私钥放在**应用沙盒文件**,不进钥匙串(6.4)。代价已记录,此处不再悬置。 +3. 8788 控制台与中继**不同机**,理由见第 2 项旧表:同机会把控制台推到公网口。 + +仍待确认: + +4. 第 10 节的元数据泄露(谁在何时对哪个节点做了哪类操作)是否接受。这是本方案不可消除的泄露面。 +5. ~~双临时缓存方案~~ **已定**:响应方向实现为对手机请求临时 `ek` 加密(双向临时),无需缓存; + 请求方向维持临时-静态,即明确接受"`K_agent_kx.priv` 泄露可解密历史请求"。若要连请求方向也 + 补齐,再评估缓存方案(代价见第 3 节修订注)。 +6. agent 侧即时吊销(每次连上中继拉一次受信设备密钥集快照)是否纳入第一期。不纳入则"丢手机" + 的真实后果是:中继能拒绝投递,但 agent 仍接受该密钥的指令直到重新配对。 ## 12. 本方案明确不做的事