# 端到端密封会话（sealed sessions）

> HTML: https://remote.ivjn.us/docs/zh-CN/sealed-sessions/

Android Console 的端到端加密入口位于设置页，设备操作可在页面内选择目标。Android 和 Web Console 的账号设置支持设置或修改密码、绑定或更换邮箱；新邮箱验证完成前原邮箱仍有效。Console 默认将根私钥以空口令备份到 Exchange；已有口令备份不被自动覆盖。删除备份会关闭本客户端的自动保存。空口令意味着 Exchange 或数据库持有者可解密根私钥。手动恢复先核对工作区根，再持久保存并同步授权。

目标：即使 Exchange 主机被攻破或运营方好奇，也**读不到**命令、输出、PTY 字节、文件内容、屏幕截图和 ubus 数据，也**无法决定或伪造**谁能查看/管理设备。Exchange 退化为「只转发密文 + 路由元数据」的中继。

定义：`crates/proto/src/seal.rs`（线路类型、本地授权规则）、`crates/proto/src/seal/crypto.rs`（会话密码学，仅 ring）、`crates/proto/src/seal/backup.rs`（口令备份：Argon2id + ChaCha20-Poly1305，agent 不链接）、`crates/agent/src/seal.rs`（设备端）、`crates/cli/src/seal.rs`（CLI 端）、`shared/seal-session.js`（Web Console 与 Android Console 共用的会话实现，@noble/curves · @noble/hashes · @noble/ciphers）、`shared/seal-backup.js`（同一套库上的 Argon2id 口令备份）。跨语言测试向量：`shared/seal-vectors.json`（JS 生成，Rust `javascript_vectors_interoperate` 校验签名/HKDF/密文逐字节一致）、`shared/seal-rust-vectors.json`（Rust 设备应答，JS `shared/seal-session.test.js` 完成握手并解密）。

`seal approve` 仍会自动发布授权。随后执行 `seal grant --publish`（或登录后的默认发布）时，若已有相同签发者公钥、接收者公钥、设备和 level 的未过期授权，则提示“已发布”并跳过，不输出新令牌；可用 `--renew-before DAYS` 主动续签临近到期的授权，`--no-publish` 仍可离线签发令牌。此检查防止顺序重试产生重复，不提供跨客户端并发发布的事务保证，也不删除历史重复记录。`seal show` 会创建本机身份，并在已登录时登记公钥；仅为试验身份时请使用独立 profile。


## 威胁模型

| 项目 | 密封会话的保护与边界 |
|---|---|
| 命令行、stdout/stderr、退出码 | **不可见**；审计只记 `sealed:true`、操作员公钥、授权级别与到期时间、结束结果 |
| PTY 键入与屏幕输出 | **不可见**，密文帧不进审计 |
| 文件内容、截图、界面树、ubus 参数与结果 | **不可见** |
| 遥控输入（指针、按键、文字、音量） | **不可见**；一次握手承载多条输入。端点审计中，指针移动、点击和截图刷新只记一条会话汇总（`remote_summary`），按键、文字、媒体键和音量调整逐条写入根公钥加密副本 |
| 谁能做什么（超级管理员/管理员/运维/只读、仅查看/可管理） | **设备判定**：只认本机固定的工作区根公钥签发的授权；Exchange 无法签发、扩大或伪造 |
| 冒充设备 | 不可能：原生设备身份密钥签名 + CLI 固定（pin）设备公钥；浏览器扩展验证工作区根链证明与应答签名 |
| 向会话注入按键/命令 | 不可能：AEAD 认证 + 严格递增计数器；agent 丢弃发往密封会话编号的任何明文 |
| 重放、重排、丢包、截断 | 被检测并关闭会话（fail closed） |
| 仍可见的元数据 | 设备 ID、操作员账号、会话编号、时间、包大小与数量、是否 stdout/stderr 不可见（类型在密文内） |
| 仍可做的事 | 拒绝服务：不转发、断开、关闭会话（`sealed_close`）。这是可用性问题，不是权限 |

不在保护范围：设备本身被攻破；操作员电脑被攻破；首次连接（TOFU）时 Exchange 已经在冒充设备——可通过带外比对指纹避免。所有设备操作只接受密封会话；固定指令目录的定义和浏览器根链授权的边界见下表。

## 路径覆盖与设计

| 路径 | 密封会话行为 | 设备端授权与边界 |
|---|---|---|
| HTTP `POST /api/devices/{id}/exec` | **明确拒绝**，HTTP 409，提示使用 `open_sealed` WebSocket 会话 | 即使 Exchange 忽略策略，设备仍拒绝明文 `exec_req` |
| HTTP 固定指令 `/commands/{cmd}/run` | **明确拒绝**，同样提示密封 WS | 服务器数据库里的指令不是设备端授权 |
| Web Console / CLI 固定指令 | 从目录取模板，由操作员在密封 WS 内发送 `exec_req`，输出也密封 | 设备验证 hello、根签名 grant、撤销、有效期和 `manage` 级别；`view` 不能执行模板 |
| LAN `/ws/operator` | 只接受密封会话，hello 内联 grant 与证书链；没有 Bearer 令牌 | 与 Exchange 相同的根链校验和 view/manage。外层只检查私网地址与 Origin。`/api/devices` 只返回设备 ID 和 seal 公钥/模式 |
| 浏览器 Agent / 原生密封 routes | 使用 `open_sealed` / `sealed_input` / `sealed_event`，拒绝旧 browser channel | 扩展验证操作员根签名 grant / 证书链；CLI / Console 验证扩展 `SealAccept.peer` 的根链，并要求其公钥等于应答签名公钥 |
| CLI `exec-attach` / `exec-hold-tail` / `exec-hold-cat` | 原生密封请求、输出帧与结束事件 | 需要 `manage`；密封作业不能被明文读取/附加 |
| CLI `sensors --live` | 每次采样使用独立密封会话（单操作/会话），Ctrl-C 结束刷新 | `view` 即可；不把多个采样塞进单操作隧道 |
| CLI `net` | `net_req` 和 `net_resp` 密封，包括 URL、headers、body、结果 | 需要 `manage` |
| CLI `device update` | `update_req`、所有进度和最终状态密封 | 需要 `manage`；CLI 顶层 `update` 更新本机客户端，不是远端会话 |
| exec / PTY / file / ubus / Android / sensors 单次 / hold list、kill | 已有原生密封实现，保持不变 | 使用同一 `required_level` 和设备端 dispatcher |
| 设备列表、指标、在线状态、Exchange sessions 列表 | 路由/监控元数据，不是密封设备操作 | Exchange 可见；不作为设备操作授权 |

### HTTP 与固定指令

原生密封操作现已支持纯 HTTP 调用端。使用 `remote-cli --transport http` 或 `REMOTE_TRANSPORT=http`。`POST /api/sealed/connections` 创建连接；`POST /input` 提交签名握手或密文，`GET /events` 长轮询结果，`DELETE` 关闭连接。客户端继续负责端到端加解密与设备授权验证。原有设备操作接口仍返回 409，设备端连接保持不变。

https://remote.ivjn.us/sealed-http.md


固定指令的创建、编辑和列表仍是 Exchange 数据库管理面，**目录中的模板文本、名称和描述不是密封数据，也不是根签名的命令 allow-list**。控制台和 CLI 在持有密钥时把取出的模板作为普通密封 exec（非 hold）执行；设备只信本机策略和操作员 grant，不信目录 ID、服务器角色或“这是固定指令”的说法。仅持有 `view` grant 的成员不能运行固定指令；需要根授予 `manage`。目录被篡改不能扩大 grant，但 `manage` 本就允许任意 exec，所以请核对模板，勿把密码/令牌写入服务器目录。输出仅在操作员本地解密并展示；该运行路径审计仅记 sealed 元数据，不记录命令和输出。

### LAN 与客户端选择

CLI 设备会话只使用端到端密封，不依赖 Exchange 上报 `seal_key`、capability 或策略提示。缺少或被删除的能力信息不会降级为明文。读取本地固定数据库失败时直接报错，不忽略损坏的信任记录。

密封 held / 非 held exec 和文件任务在发布到全局任务目录前即标为密封，不能借助“尚未处理首条出站事件便关闭会话”的竞态读取明文预览。设备日志不记录 HTTP 探测的目标或方法、ubus 对象或方法。

LAN 复用原生 envelope、签名 transcript、计数器、AEAD、设备固定和撤销规则，没有另造密码算法。LAN 出站先经过 `Tunnels::seal_outbound` 再翻译为 `OperatorDown`；密封会话编号的明文注入被 device dispatcher 丢弃。连接关闭仍分离 held exec、取消其它操作，晚到输出被 fenced id 丢弃。没有局域网令牌：客户端把离线缓存的 grant 和证书链放进 hello，设备按与 Exchange 相同的 `authorize` 决定 view/manage。外层只检查私网地址和 Origin。明文会话在 LAN 上始终拒绝，不论历史策略值为何。托管网页控制台仍连接 Exchange。

可选设备信息 `info.seal_mode`（`allow` / `require`）、`seal_key` 和 `sealed` capability 保持兼容。它们不是授权，也不是可信设备公钥；最终由设备执行策略。CLI、Web Console、Android Console 的命令、终端、文件、ubus、传感器、作业、网络探测、桌面、设备更新、固定指令和浏览器操作全部使用原生密封会话，不提供明文路径。策略值 `allow` 也拒绝明文操作。握手、设备固定、grant、解密失败即停止，不重试明文。在线策略变更后，LAN 信息即时刷新，云端信息在重连注册时刷新。

### 浏览器 Agent 的工作区根链认证

浏览器扩展上报 `sealed` capability、Ed25519 `seal_key` 与 `seal_mode: require`。配对或设备配置后，它使用设备凭据和签名持钥证明调用 `POST /api/seal/device`；Exchange 根据设备确定工作区并绑定公钥。持有可信工作区根私钥的客户端为该公钥签发仅限本设备的 view grant。登记和设备 bearer 只用于接入与取得签名凭据，不能替代浏览器会话的证书认证。

插件验证来访操作员的签名、根链、设备范围、权限、有效期和本地吊销策略，然后签名 `SealAccept` 并附带自身的签名 `SealHello` 作为 `peer`。CLI 的 `finish_workspace_operator` 和共享 `finishBrowser` 验证该证明，并要求 `peer.operator_key == accept.device_key`，再验证应答签名。缺少或无效证明会关闭会话；双方验证通过后自动建立会话。Raw CDP 另需本地逐次确认或按操作员公钥、服务器和设备保存的无人值守许可，view 不能使用 CDP。

插件按服务器 origin、工作区及设备保存授权记录；本地身份种子仅对可信扩展上下文开放。首次工作区根在本机固定，变化时拒绝。CLI 与 Console 使用通用 seal 身份和工作区根链，无需独立浏览器 pin 编辑器。根密钥签发、证书权限与设备接入凭据属于不同检查；原生设备公钥 TOFU 仍单独适用。

**安全边界：** 首次根 TOFU 不能识别恶意中继提供的假根，应带外核对；PoP 只证明持钥，不证明成员获批。根客户端的自动批准依赖名册，敏感部署应审核新钥匙。保护扩展 profile / 种子与网页控制台发布链；端点被攻破或向恶意操作员开放 full CDP 不在密封保护内。删除目录记录不撤销已缓存的签名凭据；设备本机撤销与到期规则仍重要。Exchange 仍可观察路由元数据、扣留凭据和拒绝服务。

**身份不可用时：** 设备身份无法加载或写入时密封会话不可用，所有明文操作仍被拒绝（失败即关闭）。无策略文件、策略损坏或旧策略值都不会开启明文路径；必须在设备本机修复密钥存储。

HTTP 连接须固定到创建它的 Exchange 实例，90 秒无活动后过期。串行提交输入，重试时保持序号和密文一致；处理结果后用 `after` 确认。Web Console、移动端和 LAN 保持现有传输。执行固定命令仍需 `manage` 权限。

### 回归验证

Rust 覆盖设备 grant 的实际握手、exec/attach/tail/cat/sensors/net/update 的密封结果、所有策略值下的明文拒绝、LAN WebSocket 的密封握手/帧/策略修改、HTTP 409 指引、browser require 关闭路由，以及客户端与设备信息的密封强制不变量。JavaScript 覆盖原有 Rust/JS 双向向量、操作 envelope、固定指令密封输出与拒绝后不回退、浏览器配置/注册/设备拒绝/本机策略变化和客户端固定错误。测试中的进程级 seal singleton 在隔离子进程内初始化，避免并发测试修改彼此的设备策略。

```sh
CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 cargo test --workspace
cargo fmt --all --check
CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 cargo clippy --workspace --all-targets -- -D warnings
npm test --prefix shared
npm test --prefix website/console
npm test --prefix browser-agent
node --test scripts/test-pages-worker.mjs
```

JavaScript 使用 Node 24+。新增密封 LAN 回归通过内存 duplex 流完成真实 HTTP Upgrade / WebSocket，能够在禁止网络 socket 的 sandbox 中验证握手、帧和 所有策略值下的密封强制行为。已有的 loopback WS/TLS/HTTP 测试仍需要允许绑定端口的环境，否则会以 `Operation not permitted` 失败，不能作为全套测试通过的证据。网络探测和更新路径测试验证密封成功/错误结果，不下载或安装更新。

**Web Console 的特殊风险：** 网页代码本身由 Pages 站点下发，被攻破的站点/CDN 可以下发恶意脚本读走本页的明文或用本浏览器的密钥签名（但拿不走不可导出的私钥）。对 Exchange 运营方保密的前提是你信任静态站点的发布链；对此不放心时请用 CLI 或 Android Console。

## 身份与密钥

| 密钥 | 类型 | 存放 | 谁能看到公钥 |
|---|---|---|---|
| 设备身份 | Ed25519 | agent：`agent.seal.key`（0600）。状态文件在 tmpfs（`/var/run`、`/run`、`/tmp`）时改存 `/etc/remote-agent/`，可用 `REMOTE_SEAL_DIR` 覆盖；OpenWrt 已列入 conffiles | 注册时 `info.seal_key` 上报（仅供显示，CLI 不信任这份副本） |
| 操作员身份 | Ed25519 | CLI：`config.toml` 同目录 `seal-identity.pk8`（0600）；Web Console：IndexedDB `remote-seal` 中**不可导出**的 WebCrypto Ed25519 `CryptoKeyPair`（浏览器不支持 WebCrypto Ed25519 时退回 IndexedDB 中的随机种子）；Android Console：`expo-secure-store`（Android Keystore 加密）中的种子 | 握手时发送 |
| 工作区根 | Ed25519 | 就是超级管理员客户端上的那把操作员密钥。Web Console 上必须是可导出的种子（超级管理员生成根时会把不可导出的 WebCrypto 密钥换成本地种子）；可导出为 `rsk1.` 文本、由 Console 默认以空口令备份到 Exchange | `rsr1.` 根记录（Exchange 公布、设备固定） |
| 会话密钥 | X25519 临时密钥 → HKDF-SHA256 → 每方向一把 ChaCha20-Poly1305 | 只在内存，会话结束即丢弃（前向保密） | 无 |

操作员私钥保存在本机。Console 默认上传根私钥的空口令备份；Exchange 或数据库持有者可以解密。可手动设置备份口令，或删除备份以关闭本客户端的自动保存。

## 授权（Exchange 不是权威）

设备只信任**一把工作区根公钥**。本地策略 `agent.seal.json`：

```json
{"mode":"require","root_key":"<base64 Ed25519>","revoked_keys":[]}
```

agent 对每个 `sealed_open` 自行判定级别：

1. 设备还没有固定根 → 拒绝（`no workspace root pinned on this device`）。
2. 操作员公钥在 `revoked_keys` → 拒绝。
3. 操作员公钥就是 `root_key` → `owner`（可管理 + 修改本地策略）。
4. 携带授权 `grant`：签名有效、`operator_key` 与握手一致、`device_id` 为本机或 `*`、在有效期内（±5 分钟时钟偏差）。签名者是已固定根，或者 hello 带一条 `rsc1.` 证书链（`hello.chain`，不包含在握手签名里）：从根签发的那张排到 grant 的签名者，每张都能继续签发，路径长度、级别、设备范围和到期时间都不超过上一张。授权级别是 `view` 或 `manage`，永远不能是 `owner`。
5. 其它 → 拒绝。

## 证书链约束

设备认的不是单独一张中间证书，而是一条能验回已固定工作区根的 `rsc1.` 链（`hello.chain`，从根签发的那张排到 grant 的签名者）。链的层数**不设业务上限**（组织可以有 10 层以上），只有一道防 DoS 的宽松护栏：`MAX_CERT_CHAIN` = **64** 张，且整条链的编码大小（每张证书规范 `rsc1.` 令牌长度之和）`MAX_CERT_CHAIN_BYTES` ≤ **64 KiB**（`crates/proto/src/seal.rs` / `shared/seal-cert.js` 的 `check_chain_limits` / `checkChainLimits`，设备、Exchange、CLI、Web、Android 规则相同）。超出直接拒绝。

每张证书还有自己的 `max_path_len`（0–255），是颁发者**可选**的约束，限制它下面还能再挂几张证书；`seal cert issue` 不带 `--max-path` 时默认不限（取颁发者剩余的全部深度，255 实际等于不限）。校验时，这张之后剩余的张数不能超过 `max_path_len`。根给管理员签发的中间证书 `max_path_len` 为 0：管理员可以签 grant，不能再签发下一张证书。`can_issue` 为假也不能继续签发。

子证书必须落在父证书里面（设备上的 `cert_within_parent` / `verify_chain`）：

- **级别**不超过父级。根是 `owner`；被授予的级别只能是 `view` 或 `manage`，且不能高于父证书。`owner` 不能出现在证书或 grant 里。
- **设备范围**是父级的子集。只有父级包含 `*` 时，子级才能覆盖全部设备；否则子级的每台设备都必须在父级列表中。子级写成 `*` 仅当父级也是 `*`。
- **到期时间**不能晚于父证书。根没有到期上限。grant 同样不能晚于签发它的那张证书，设备也不能超出那张证书的范围。

**吊销一张中间证书会级联到它下面的一切。** 设备把链上每一张的 `subject_key` 和 `serial` 对策略里的 `revoked_keys` 检查。中间那张被吊销后，任何仍然带着它的更长链都失败，因此它签发的下级证书、以及那些证书签出的授权，一并失效。从 Exchange 删除证书只停止继续分发，设备上已经持有的副本在过期前仍然有效；要让下面整条链立刻失效，把该证书的主体公钥（或序列号）写进设备策略的 `revoked_keys`。

级别与操作的对应（`proto::seal::required_level`）：`view`（仅查看）允许传感器、hold 作业列表、Android 只读动作（status/tree/screenshot/find_text）；其它一切（exec、PTY、文件、ubus、网络探测、自更新、Android 输入）需要 `manage`。

授权是根签名的 JSON，Exchange 篡改任何字段（例如 view→manage、换设备、延长有效期、换被授权人）都会让签名失效；握手签名还把授权绑定到本次会话。Exchange 侧的账号角色只决定「能不能把消息路由到这台设备」（租户可见性），不会扩大或缩小密封会话的权限。没有设备级「认领」「所有者」「共同所有者」：唯一的 `owner` 就是根。

**设备怎样固定根：**

- **TOFU：** 支持密封会话的设备连上 Exchange 后，Exchange 下发 `seal_root {token, rotation?}`（`rsr1.` 自签名根记录）。设备校验自签名；本地还没有根时固定它并写入 `agent.seal.json`。
- **轮换：** 已固定根之后，Exchange 公布的根变了，设备只在同时收到一份**由已固定根签名**的 `rsx1.` 轮换记录（`old_root` = 已固定根，`new_root` = 新根，同一工作区）时改信新根；否则忽略并记日志。根也可以在密封会话里用 `seal_policy_set {root_key}` 改（只有当前根有 `owner` 级别）。
- **本机设置：** `remote-agent seal root <key>` 直接写入根；`remote-agent seal forget-root` 清掉，下次连上 Exchange 时重新 TOFU（重启 agent 生效）。

**风险：** TOFU 意味着设备第一次连上时 Exchange 若已被攻破，它可以塞给设备一把假根。担心这一点时，在设备上线前用 `remote-agent seal root <key>` 写死根公钥（指纹见 `remote-cli seal root`）。固定之后 Exchange 就换不掉根了。

**吊销：** 根 `remote-cli seal policy <设备> --revoke <key>`，或在设备上 `remote-agent seal revoke <key>`。根自己不能被吊销。授权自带有效期（默认 90 天）。从 Exchange 删除一份已发布的 grant 只是不再分发；已经导入本地的副本在过期前仍有效，必须以设备策略吊销。

## Exchange 作为授权中继（不可信）

同事不必再把 `rsg1.` 令牌或公钥抄到聊天窗口：Exchange 提供工作区级的**操作员公钥目录**和 **grant 中继**。这不改变威胁模型——Exchange 仍然看不到明文，也不能签发、扩大或伪造授权。它只保存：

1. 操作员用本机私钥证明持有的 Ed25519 公钥（`POST /api/seal/keys`）
2. 已经签好的 `rsg1.` 令牌原文（`POST /api/seal/grants`）
3. 已经签好的 `rsc1.` 中间证书原文（`POST /api/seal/certs`）。Exchange 校验颁发者的 Ed25519 签名，但不能伪造证书。`GET /api/seal/certs/chain?subject_key=` 沿颁发关系取回（受同一 64 张 / 64 KiB 护栏限制）令牌，根签发的在前

设备授权仍然只认已固定根，或一条能验回这把根的 `rsc1.` 证书链。Exchange 解析 token 只是为了按 `owner_key` / `operator_key` / 到期时间建索引，**从不把上传当作授权**。最坏情况是运营方扣留令牌（对方拿不到 grant，表现为 `no grant`），不能把 `view` 改成 `manage`，也不能冒充根。

任何工作区成员都可以上传 grant：令牌自带签名，上传不是授权行为；只有根签名的 grant 对设备有效。CLI 在已登录时默认 `--publish`；Web / Android 控制台在「签发并发布」时走同一接口。

密钥登记的 possession proof 绑定：

```text
remote-seal-key-register:v1:<workspace_id>:<operator_id>:<public_key>:<issued_at_ms>
```

由该公钥做 Ed25519 签名，`issued_at_ms` 须在服务器时间 ±10 分钟内。CLI 在 `login` 和 `seal show` 时尽力登记；控制台在登录后登记。

操作员在密封操作前若本地没有匹配 grant，会尽力 `seal sync`；握手因 `no grant` 被拒时再同步一次并重试一次。

签发者客户端上线时会自动续签自己签发、即将过期的已发布授权。签名仍在本机完成；Exchange 只保存新的 `rsg1.` 令牌并删除旧记录，不能改级别或换被授权人。

- **何时运行：** CLI 在登录成功后，以及任意已登录命令开始时（`seal renew` 走手动路径，不再套一层自动续签）。Web Console 在登录并进入工作区时（页面加载后若会话已存在，同样会进入工作区）。Android Console 在登录后，以及应用回到前台时。未登录，或这把公钥没有已发布的 grant 时，不续签。
- **续签规则：** `GET /api/seal/grants?owner_key=<本机公钥>`。将在 30 天内到期的授权，用原来的 `device_id`、`operator_key`、`level` 重新签发 90 天，发布成功后再 `DELETE` 旧 grant。若 `GET /api/seal/keys` 成功，公钥目录里已经没有的 `operator_key` 会跳过；目录请求失败时不按目录过滤。
- **节流：** Web/Android: 同一签发公钥最多每 12 小时尝试一次。CLI 记在配置目录的 `seal-renew-state.json`，Web Console 用 `localStorage`，Android Console 用 SecureStore。失败不影响当前操作：CLI 只在 stderr 打一行警告，控制台使用 `console.warn`。续签成功时 CLI 在 stderr 打一行简短说明。持有根的 Web Console 进入工作区时会先签发一轮，Android 打开「端到端加密」时也会先签发一轮；这两次不受 12 小时节流。同一次停留里之后的刷新才节流。
- **CLI 续期：** 按服务器和签发公钥分别记录成功时间；成功后 12 小时内跳过续期网络请求，失败或中断只退避 5 分钟。整轮续期最多等待 30 秒，错误包含阶段和耗时，主命令失败时立即取消后台续期。未完成的设备撤销不受成功节流影响，并使用独立的 4 秒预算。可运行 `remote-cli seal renew` 显式续期。 登录成功后会强制刷新一次。
- **根客户端：** 持有工作区根的超级管理员客户端用下一节的按角色签发代替这次通用续签，以免把刚删除的授权又签回去。
- **手动：** 仍可用 `remote-cli seal renew`（默认 `--before 30 --days 90`），规则与自动续签相同。

```bash
# 按工作区用户名授权对方每一把已登记的钥匙，并发布到 Exchange（默认 90 天）
remote-cli seal grant --device lab-router --to alice --level view
# 只打印 token，不上传
remote-cli seal grant --device lab-router --operator-key <KEY> --no-publish
remote-cli seal keys          # 工作区公钥目录
remote-cli seal sync          # 拉取签给自己钥匙的 grant 并导入本地
remote-cli seal renew         # 手动续签，默认 --before 30 --days 90
```

## 工作区超级管理员是信任根

工作区根就是超级管理员客户端上的 Ed25519 密钥。设备只信任这把根公钥，以及能沿 `rsc1.` 证书链验回这把根的 `rsg1.` 授权；根客户端按账号角色自动签发、续签成员授权。Exchange 仍然不可信：它只保存自签名的 `rsr1.` 根记录、`rsx1.` 轮换记录和 `rsi1.` 邀请，并检查格式与 Ed25519 签名，**不决定谁被批准**。恶意 Exchange 可以扣留记录，或在名册里塞进假成员；它不能伪造根签名。CLI、Web 与 Android 上自动批准默认开启，名册里的新公钥会被这些根客户端直接签名。只有明确关闭自动批准后，从未批准、也没有被这把根发布过的新公钥才会留在待批列表。

### 信任模型与取舍

- **根公钥首次使用时固定（TOFU）。** 客户端第一次看到校验通过的根记录时，把公钥固定在本地。CLI 写在 `config.toml` 旁边的 `seal-root.json`（0600），键是 `服务器|工作区 id`。Web Console 用 `localStorage`（`remote.seal.root.<工作区 id>`）。Android Console 用 SecureStore（`remote.seal.rootstate`，按服务器和工作区放在同一份 JSON 里）。之后每次核对自签名和指纹。已固定的根公钥变了就大声警告，**客户端不会自动改信**（设备会按 `rsx1.` 轮换记录自动改信，见上文）。核对指纹后显式执行 `remote-cli seal root --trust <key>`，或在控制台点「信任新根公钥」。
- **邀请只打开登记。** 根公钥签发 `rsi1.`（工作区 id、根公钥、16 字节 nonce、到期时间）。管理员、操作员和只读账号的客户端在登录、后续命令、页面加载或应用回到前台时拉取邀请，用**已固定**的根公钥验签，然后走原有的 `POST /api/seal/keys` 登记自己的操作员公钥。邀请不是授权。
- **按角色签发，本地批准新钥匙。** 根公钥所在的超级管理员客户端读取 `GET /api/seal/roster`（仅超级管理员；含已禁用成员、角色、`operator_device_grants`、已登记公钥）和自己已发布的 grant，算出期望授权。同一把已登记公钥共用这一组范围：
  - 超级管理员，以及未受限的管理员：`manage` `*`
  - 受限管理员：按设备授权，`can_manage` 为 `manage`，否则 `view`
  - 未受限操作员：`view` `*`，外加每条 `can_manage` 设备的 `manage`
  - 受限操作员：按设备授权 `manage` 或 `view`
  - 未受限只读：`view` `*`
  - 受限只读：每台已授权设备 `view`。只读账号不能操作设备，因此即使 `can_manage` 也不发 `manage`
  - 已禁用的成员：不发
  - 默认 90 天，剩余不足 30 天续签
- **管理员中间证书。** 根客户端给启用的管理员签发 `rsc1.`（`manage`，`can_issue`，`max_path_len` 为 0，90 天）。未受限管理员的设备范围是 `*`；受限管理员是其被委派 `can_manage` 的设备。只剩 30 天时续签。尚未批准的新公钥不发证书。管理员客户端持有仍有效的中间证书时，用自己的钥匙和这条链，给自己管理的成员签发 grant，范围是证书与设备委派的交集；若根已经有一份仍然新鲜的 grant 覆盖同一操作员、设备和级别（根的 `*` 覆盖具体设备，`manage` 覆盖 `view`），就不再签。从 Exchange 撤销证书只停止继续分发；设备上已有的副本在过期前仍然有效。硬吊销靠设备策略里的 `revoked_keys`。
- **自动批准默认开启。** Web Console（`localStorage`）和 Android Console（SecureStore）里，记录没有这个字段时视为开启（absent = on），只有明确的 `false` 保持关闭，已经关掉的不会被默认逻辑重新打开。开启后根客户端按名册直接签名，**较弱**：被攻破的 Exchange 可以塞进假成员并立刻得到签名。开关文案是「自动批准新公钥」。CLI 与 Web/Android Console 一样默认开启：没有 `seal-approved.json` 文件或缺少 `auto_approve` 字段时视为开启，显式 `"auto_approve": false` 保持关闭。用 `remote-cli seal approve --no-auto-approve` 关掉、`remote-cli seal approve --auto-approve` 重新打开；选择会持久化保存。关掉之后，从未批准过、也没有被这把根公钥发布过的钥匙进入本机待批列表（CLI `seal-approved.json`，Web `localStorage`，Android SecureStore）。`remote-cli seal pending` 显示用户名和指纹；`seal approve --all` 或 `seal approve <key>…` 批准后立即签发。已经批准过的钥匙，以及这把根公钥已经发布过的钥匙，续签和角色变化自动完成。签发客户端自己的公钥不需要再批准一次。
- **打开就签。** 持有根的客户端在 Web Console 进入工作区、以及 Android 打开「端到端加密」时立刻签发，这一轮不受 12 小时节流。同一次停留里之后的自动刷新仍节流。改开关或点「批准」会强制再签一次。
- **账号对话框、徽章和失败行。** Web Console 的开关和待批名单在设备页的「账号」对话框里（工具栏「账号」）。有钥匙等待批准时，同一工具栏显示徽章「N 个公钥待批准」，点徽章打开这个对话框。签发失败时工具栏有一行状态：「工作区根签发失败：…」。密封令牌和长密钥材料会从这行里抹掉。Android 的开关、同一句徽章和同一行失败说明在「端到端加密」页。
- **降级不吊销设备上的旧授权。** 成员被移除、禁用或公钥从目录消失时：从 Exchange 删除 grant、停止续签，并尽力在在线且支持密封会话的设备上写入 `revoked_keys`。角色降级只换一份新 grant，不把钥匙放进 `revoked_keys`。设备上已经导入的旧 grant 在过期前仍然有效。根公钥自己永远不会被放进吊销列表。没在 4 秒预算里做完的吊销记在本地，下次命令继续，不受 12 小时签发节流限制。钥匙重新变成期望授权时，只在已经成功吊销过的设备上撤销吊销。
- **根记录自动生成。** 超级管理员账号创建（`register`/工作区第一个账号）或之后任何一次超级管理员登录时，若 Exchange 上还没有根记录，超级管理员的客户端（CLI / Web / Android 任一）在本地把自己的密钥公布为根，并立刻发一份 90 天邀请。之后邀请缺失或将在 30 天内到期时，随签发节流刷新。`remote-cli seal root --publish` 仍可手动执行。

### 一次性设置

1. 超级管理员注册或登录任一客户端：根自动生成并公布（CLI 在 stderr 打印提示；控制台弹出提示）。
2. 保管根私钥（二选一或都做）：
   - 只自己保管、什么都不上传：`remote-cli seal root --export root.rsk`（0600 文件；`-` 输出到 stdout），或控制台「导出根私钥」。
   - 手动设置云端备份：`remote-cli seal backup`，或控制台「根私钥云端备份」。口令可留空，但**口令为空或太弱时 Exchange 能解开根私钥并控制所有设备**。
3. 设备只要支持密封会话并连上 Exchange，就会自动固定根（TOFU）；更稳妥的做法是上线前在设备上 `remote-agent seal root <根公钥>`。
4. 新成员登录后会登记公钥。CLI、Web 与 Android 上自动批准默认开启，根客户端会直接签名。若已关掉自动批准：`remote-cli seal pending` 查看，`remote-cli seal approve --all`（或控制台「批准」）批准后立即签发。

```bash
remote-cli seal root                       # 显示已固定的根公钥和指纹
remote-cli seal root --export root.rsk     # 导出根私钥（rsk1. 文本）
remote-cli seal root --import root.rsk     # 在另一台机器装上根私钥（或 seal restore）
remote-cli seal root --rotate              # 生成新根，旧根签 rsx1. 轮换，自动按新根重签成员授权
remote-cli seal invite                     # 单独刷新邀请（默认 90 天，--days N）
remote-cli seal pending
remote-cli seal approve --all              # 或 seal approve <key>…；--no-auto-approve 关闭自动批准（保持开启较弱）
```

### 换另一台机器 / 换根

- **在别的设备上用根：** 超级管理员登录后，若本机还没有已公布的根、且 Exchange 上有 `rsb1.` 云端备份，CLI / Web / Android 会**自动尝试恢复**（见「登录时自动恢复」）。否则客户端提示手动导入：`seal root --import <文件|->`、控制台粘贴 `rsk1.` 后「导入根私钥」，或从 Exchange 恢复加密备份（`seal restore` / 控制台「从 Exchange 恢复」）。导入的就是当前根时只是装到本机，不改任何记录。
- **轮换：** `seal root --rotate`（控制台「生成新根并轮换」）或导入一把**不同于当前根**的 `rsk1.` 密钥。本机持有当前根时，客户端用旧根签一份 `rsx1.` 轮换记录，与新的 `rsr1.` 一起 `PUT /api/seal/root`；Exchange 推给在线设备，设备验证轮换由已固定根签名后改信新根，离线设备下次连上时收到。旧根批准过的成员在本机继续算已批准，随后用新根重签全部成员授权（90 天）。旧本地密钥保留为 `seal-identity.<毫秒>.previous.pk8`。轮换后请重新导出或备份新根。
- **旧根已经丢失：** 无法签轮换记录。客户端仍会公布新根，但已固定旧根的设备不会接受；需要在每台设备上执行 `remote-agent seal root <新根公钥>` 并重启 agent。
- **超级管理员移交：** 见「超管交接 = 根证书交接」。只改账号角色不够，设备信任的是根私钥。

## 登录时自动恢复根私钥

超级管理员登录成功后，在创建本机密封身份之前**，CLI / Web Console / Android Console 会按同一套纯决策表（Rust `decide_auto_restore`，JS `decideAutoRestore`）尝试从 Exchange 的 `rsb1.` 备份装回已公布的工作区根：

1. **跳过：** 非超级管理员；本机已持有与已公布根相同的密钥；云端备份已关闭（CLI：`REMOTE_SEAL_CLOUD_BACKUP=0`；Web：`localStorage.seal.cloudBackup=off`；Android：SecureStore `remote.seal.cloudBackup=off`）；Exchange 上没有已公布的根。
2. **拉取一次备份：** `GET /api/seal/backup`（受每小时次数限制）。没有备份时提示根在别处，不阻塞登录。
3. **空口令静默尝试：** 先用空字符串解密。成功且解密出的公钥等于已公布根、且本机没有另一把不同密钥时，装入本机并固定为工作区根。
4. **否则提示口令：** CLI 再试 `REMOTE_SEAL_BACKUP_PASSPHRASE`，TTY 上隐藏输入；Web / Android 只发**非阻塞**提示，请到密封页输入口令后点「从 Exchange 恢复」。不弹阻塞对话框，登录照常完成。
5. **安全边界：** 从不静默覆盖本机已有的另一把密封密钥（需 `seal restore --force` 或控制台确认恢复）；解密出的公钥必须等于已公布根；失败只警告，不让登录失败；从不打印口令、种子或 `rsb1.` 密文。
6. **风险提示（非阻塞）：** 装上根后提示「多台机器持有根会增加泄露风险」；若空口令解开成功，再提示 Exchange 能解开根私钥。

## 超管交接 = 根证书交接


超级管理员这个账号角色，和设备信任的工作区根，是两回事。只把角色交给对方，新超级管理员没有根私钥，设备不会改信他。交接就是把**根私钥**交给对方，再立刻轮换，让交出去的那一份作废。

1. 现任超级管理员在本机持有已公布的根时转让：`POST /api/superadmin/transfer`，正文 `{operator_id, root_handover?}`。`root_handover` 是口令加密的 `rsb1.`，算法与云端备份相同（Argon2id 64 MiB、t=3、p=1，ChaCha20-Poly1305）。口令可以留空。CLI：`remote-cli seal handover <账号>`。Web / Android 控制台在「转让超级管理员」时，若本机持有根私钥，会提示可选口令并带上这个包。
2. Exchange **只保存、不解密**。它检查前缀是 `rsb1.` 或 `rsk1.`、长度不超过 16384、以及只含 ASCII URL 安全字符。请求里没有 `root_handover` 时，删掉该工作区残留的交接包。
3. 新超级管理员 `GET /api/superadmin/handover` 取回 `{blob, from_operator, created_at}`（没有则 404），用口令解开，装到本机，然后走和「生成新根并轮换」相同的轮换：旧根签发 `rsx1.`，公布新根。最后 `DELETE /api/superadmin/handover`。CLI：`remote-cli seal accept-handover`。控制台的密封页在有交接包时显示「接收根私钥交接」。
4. **轮换让旧副本失效。** 交出去的是轮换前的根。在线设备只在轮换记录由它们已经信任的旧根签名时改信新根。前任手里的旧私钥签不出新根的授权，也改不了设备已经改信的根。轮换完成前，空口令或弱口令的 `rsb1.` 仍能被解开；口令请分开传递。

**带外：** 不想把密文放进 Exchange 时，CLI 用 `remote-cli seal handover <账号> --out-of-band`。标准输出只有一行 `rsb1.`，转让请求不带 `root_handover`。把这一行和口令经其他渠道交给对方，对方执行 `remote-cli seal accept-handover <那一行>`。控制台在口令提示上点取消（Android 为「只转让账号」）同样只转让角色、不上传根私钥；根私钥仍要另交（导出 `rsk1.`，或带外的 `rsb1.`）。

## 握手与线路格式

```text
CLI                         Exchange                       agent
 │ open_sealed{session_id,    │                               │
 │   device_id, hello}        │──sealed_open{session_id,hello}▶│ 验证 hello 签名、授权 → 级别
 │                            │◀─sealed_accept{session_id,     │
 │◀─sealed_accept (原样转发) ──│        accept}                │
 │ 验证设备签名 + pin          │                               │
 │ sealed_input{packet} ─────▶│──sealed_input────────────────▶│ 解密、校验级别、执行
 │ 二进制帧 kind=4 (PTY 键入)─▶│── 只改写帧头编号 ─────────────▶│
 │◀── sealed_event{packet,last}│◀── sealed_event ──────────────│ 所有输出在出站队列加密
 │◀── 二进制帧 kind=4 ─────────│◀── 二进制帧 kind=4 ───────────│
```

- `hello` = `{v, operator_key, operator_ephemeral, nonce, grant?, sig}`，签名覆盖 `["remote-seal-v1/hello", v, device_id, operator_key, operator_ephemeral, nonce, grant.sig|""]`（JSON 数组，即规范字节）。
- 根记录 `rsr1.` 签名覆盖 `["remote-seal-v1/workspace-root", v, workspace_id, root_key, issued_at_ms]`；轮换 `rsx1.` = `{v, workspace_id, old_root, new_root, issued_at_ms, sig}`，由 `old_root` 签名覆盖 `["remote-seal-v1/root-rotation", v, workspace_id, old_root, new_root, issued_at_ms]`。Exchange → agent：`{"type":"seal_root","token":"rsr1.…","rotation":"rsx1.…"?}`（只接受来自 Exchange 的明文控制消息，隧道内无效）。
- `accept` = `{v, device_key, device_ephemeral, nonce, level, sig}`，签名覆盖 `["remote-seal-v1/accept", v, device_id, operator_key, operator_ephemeral, hello.nonce, hello.sig, device_key, device_ephemeral, nonce, level]`。Exchange 改 `level` 会使签名失效。
- 会话密钥：`salt = SHA256(accept 签名字节)`，`PRK = HKDF-Extract(salt, X25519(o, b))`，`K_od = Expand("remote-seal-v1/operator_to_device")`，`K_do = Expand("remote-seal-v1/device_to_operator")`。
- AEAD：ChaCha20-Poly1305，AAD = `remote-seal-v1`，nonce = 通道字节（0 文本 / 1 二进制）‖ 3 个 0 ‖ `u64` 计数器（大端）。每方向、每通道计数器从 0 严格加一；任何不符直接关闭会话。
- 文本：`{"counter":n,"ciphertext":"<base64>"}`。明文是 JSON：操作员 → 设备为 `SealedRequest`（现有 `DeviceDown` 消息，或根管理消息 `seal_policy_get` / `seal_policy_set {mode?, root_key?, revoke?, unrevoke?}`）；设备 → 操作员为 `SealedResponse`（现有 `DeviceUp` 消息或 `seal_policy`）。内层 `session_id` 由 agent 改写为隧道编号。
- 二进制帧：`StreamKind::Sealed = 4`，载荷 = `counter(u64 BE) ‖ AEAD(inner_kind ‖ bytes)`，`inner_kind` 为 1/2/3（stdout/stderr/PTY）。
- 每个密封会话承载**一个**操作（一次 exec、一个 PTY、一次文件传输…），沿用 1:1 会话模型。终结消息由 agent 加密并在外层标 `last:true`，Exchange 据此释放路由和回填审计，无需看懂内容。
- `close_session` → `sealed_close{detach:false}`（终止）；运维断开 → `sealed_close{detach:true}`（由 agent 判断：hold exec 只 detach，其它终止）。
- 大小上限：密文 base64 约 2.8 MiB（容纳 2 MiB Android 截图），外层包装 `MAX_SEALED_WIRE`。

agent 侧防线：出站队列中属于密封会话编号的明文只会被加密或丢弃，会话结束后该编号永久隔离（hold 进程的迟到输出被丢弃而不是明文发出）；通过密封会话启动/附加的 hold 作业不出现在旧路径的列表中，也不能被旧路径 attach/tail/cat/kill。

## 根私钥导出与备份

根私钥能控制工作区里的所有设备。保管方式由超级管理员决定，**Console 默认以空口令上传备份，可删除以关闭自动保存**：

- **导出（不上传）：** `rsk1.` + base64url（无填充）32 字节种子。CLI `seal root --export FILE|-`（文件 0600），控制台「导出根私钥」（Web 显示在输入框，Android 复制到剪贴板）。任何拿到它的人都能控制全部设备。
- **加密备份到 Exchange（Console 默认保存，CLI 手动操作）：** 只有超级管理员、且本机密钥就是已公布的根时才能上传。密文是版本化的 `rsb1.` 令牌（base64url 编码的 JSON，最长 4096 字符）。密钥由 Argon2id 从口令派生，生产参数固定为内存 64 MiB、迭代 3、并行度 1，并写在令牌里。盐 16 字节，ChaCha20-Poly1305 的 nonce 12 字节，每次备份随机生成。附加认证数据是 `remote-seal-v1/key-backup:{version}:{public_key}`，改版本、公钥或密文都会验证失败；解开之后还要检查种子导出的公钥等于令牌里的公钥。解码器在跑 Argon2 之前拒绝过弱或过大的参数（内存 8 KiB–256 MiB，生产解密至少 64 MiB；迭代 1–8，生产至少 3；并行度只能是 1）。
- **口令可选，不强制强度。** 空口令同样走 Argon2id（口令为空字符串），格式一致。客户端只做提示：口令为空或较弱（少于 12 个字符、字符类别太少、重复、常见口令）时，CLI / Web / Android 都会明确警告：**Exchange（或能读数据库的人）可以解开根私钥并控制所有设备。** Rust 和 JavaScript 用同一套提示规则和同一组测试向量（含空口令向量）。

`PUT` / `GET` / `DELETE /api/seal/backup` 只服务当前登录账号；`PUT` 和 `GET` 只允许超级管理员。请求体是 `{blob}`。Exchange 检查 `rsb1.` 前缀和长度，**从不解密**。`GET` 每个账号、每个进程、每小时最多 5 次，超出返回 429；这只减慢在线猜测，库被拷走后弱口令可被离线穷举。读、写、删会记审计，详情只有字节数。

- CLI：`remote-cli seal backup`（隐藏输入口令两次，可直接回车留空；或 `--passphrase-stdin`）；`remote-cli seal restore`（已有不同钥匙时加 `--force`，旧密钥保留为 `seal-identity.<毫秒>.previous.pk8`），恢复后把它固定为本机的工作区根。登录时也会自动尝试恢复（见上节）。不打印私钥、口令或密文。
- Web Console：根私钥必须可导出。超级管理员生成根时，浏览器若持有不可导出的 WebCrypto 密钥，会改用 IndexedDB 中的本地种子作为根。登录 bootstrap 在创建身份前自动恢复；`localStorage.seal.cloudBackup=off` 可关闭。
- Android Console：种子在 expo-secure-store。`bootstrapSeal` 同样先自动恢复；`remote.seal.cloudBackup=off` 可关闭。

## 使用

```bash
remote-cli seal show                       # 生成/显示本机操作员公钥与指纹
remote-cli exec lab-router -- uname -a
remote-cli shell lab-router
remote-cli seal policy lab-router          # 根：查看设备授权与吊销策略

# 根给同事手动授权（通常不需要：根客户端按角色自动签发）（默认 90 天；对方已登录的客户端会自动登记公钥）
remote-cli seal grant --device lab-router --to alice --level view
# 或仍用公钥；--no-publish 只打印 token
remote-cli seal grant --device lab-router --operator-key <KEY> --level view --no-publish
# 手动续签即将到期的已发布授权（默认 --before 30 --days 90；上线时也会自动续签）
remote-cli seal renew
# 同事：拉取 Exchange 上签给自己的授权（登录后也会自动 sync）
remote-cli seal sync
# 仍可手动导入
remote-cli seal import-grant rsg1.xxxxx

# 根私钥：导出（不上传）或可选的加密云端备份
remote-cli seal root --export root.rsk
remote-cli seal backup
remote-cli seal restore                    # 已有不同钥匙时加 --force

# 带外固定设备指纹（在设备上 `remote-agent seal show`）
remote-cli seal trust-device lab-router <DEVICE_KEY>
```

**Web Console / Android Console：** 所有设备操作只使用原生密封会话，命令、hold 列表/kill/tail/cat、终端、文件、屏幕截图与输入（Android/桌面）、界面树、传感器、ubus、系统日志、自更新全部走密封隧道；页面上显示「🔒 端到端加密」。Web 设备详情或 Android 设置 →「端到端加密」面板与 CLI 等价：显示本机公钥/指纹、工作区根（超级管理员：导出/导入/轮换根私钥、接收根私钥交接、批准新公钥）、查看/修改设备密钥和吊销策略（需要根）、签发授权（输出 `rsg1.` 令牌，CLI 可直接 `seal import-grant`）、导入授权、信任设备公钥，以及默认保存的根私钥云端备份（口令可空，弱口令时明确警告 Exchange 能解开）。首次连接固定设备指纹（TOFU），之后指纹变化直接拒绝并提示核对。普通成员在 CLI、浏览器、手机上是三把不同的钥匙，各自登记后由根客户端按角色签发。超级管理员要在多台设备上当根，可上传口令备份后在新设备登录时自动恢复，或把根私钥导入每一台。

设备本机：`remote-agent seal show | root <key> | forget-root | revoke <key>`（修改后重启 agent 生效；通过密封会话的 `seal policy` 修改即时生效）。

## 默认行为

| 范围 | 当前行为 |
|---|---|
| CLI、Web Console、Android Console | 设备操作全部使用原生密封会话，无关闭开关或明文回退 |
| 设备本机策略 | 所有策略值（包括 `allow`）都拒绝明文操作 |

- 新设备连上 Exchange 后固定工作区根（TOFU），根及根签发的授权可操作。担心首次连接时 Exchange 不可信，应先在设备本机运行 `remote-agent seal root <key>`。
- 身份密钥不可用或策略损坏时失败即关闭；在设备本机修复密钥和策略后再连接。
