docs(ios): 中继实现修订——长轮询替 WSS、响应方向临时×临时、agent 本机登录

This commit is contained in:
chick
2026-09-16 09:15:26 +08:00
parent 95c653b3d3
commit 99752bd355
+68 -37
View File
@@ -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": "<uuid4>",
"node": "nd_7f3a...",
"req": "<uuid4>",
"app": "ap_5b1d...",
"ts": 1727289600,
"alg": "X25519-ED25519-AES256GCM-HKDFSHA256",
"ek": "<base64 32B>", // 本次请求的临时 X25519 公钥
"payload": "<base64 AES-256-GCM>",
"sig": "<base64 Ed25519>"
"p": "<base64 AES-256-GCM>",
"s": "<base64 Ed25519>"
}
```
`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": "<sha256(method|path|canonical body)>"},
"deadline": 1727289630 }
{ "m": "POST", "p": "/api/sms/send",
"intent": "<base64 sha256(upper(trim(m))|p|canonicalJSON(body))>",
"body": "<base64 原始请求体>" }
```
`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. 本方案明确不做的事