信任边界
评审者的台账: 针对四跳中的每一跳, 列出强制执行它的机制和在此接受的每一项残余风险, 然后是各跳共用的信任记录、主机持有策略的台账, 以及变更不得倒退的不变量。面向读者的模型, 连同跳转图, 见 security.md。
这里的每一项残余风险都已接受并被跟踪。每个条目说明可能发生什么、前提条件、什么限定了它, 以及为什么接受。
边界 1: MCP 客户端 <-> Rust MCP 服务器 (stdio, JSON-RPC 2.0)
客户端程序 (harness) 只有在被准入之后才受信任。这一跳同时承载协议正确性和授权。
- 协议引擎: 官方的
rmcpSDK 在服务循环自己的行长度上限和解析门禁之后提供 JSON-RPC 与 MCP 服务; 关于信任面的决策见设计依据。未知方法得到-32601, 解析错误会上报而非致命, stdout 只承载协议。 - 准入: 在服务任何工具调用之前, 服务器先对派生它的进程做证明, 再把该身份与信任记录中已配对的客户端比对。准入以经过证明的锚点 (代码签名者或镜像哈希) 为键, 绝不以客户端自报的名称为键:
GENKAN_CLIENT_NAME只是日志标签。 - 测量什么: 在 Unix 上, 是父进程
getppid()所指向的正在运行的镜像 (macOS: 经验证的cdhash加签名 Team ID; Linux:/proc/<pid>/exe的 SHA-256)。在 Windows 上, 是服务器 stdin 管道的创建者 (镜像 SHA-256 加作为签名者的 Authenticode 发布者), 因为记录在案的父进程在CreateProcess时可由调用方任选。 - 姿态: 尚无任何客户端配对意味着不强制准入, 并在每次启动时以 ERROR 级别记录日志。一旦有客户端配对, 任何不匹配的情况都失败即关闭, 包括无法测量的身份和不可读的记录。
- 中继: 第二个 MCP 服务器实例通过边界 2 的套接字接入中介 (broker), 并在其接入帧中报告自己经证明的父进程, 之所以可信, 是因为中继连接本身已证明它运行的是本二进制。中介以同样的方式为每个中继裁决准入。
- 吊销: 准入不是一次性裁决。信任记录在会话内每个请求之前重新读取 (中介的逐请求门禁), 对空闲连接则按一秒定时器重新读取, 因此来自任何界面的吊销都会拒绝被吊销客户端程序的下一个请求及其重新接入。
- 紧急开关: 闩锁置位期间, 或记录不可读时, 来自每个客户端程序的每一次
tools/call都在任何路由之前以稳定的BRIDGE_KILLED代码应答。客户端程序的连接保持打开, 这样拒绝以带类型的错误送达, 而不是一次不明所以的断连。 - 解除: 只能显式进行。CLI 上的
unkill, 或扩展在边界 3 的在场交换之后发出的解除帧。两种状态转换都会被审计, 解除还会记录授权它的认证路径。
这一跳的残余风险:
- 被证明的一方是派生服务器的进程, 而非向其 stdin 写入的进程。 stdin 是匿名管道, 没有内核对端凭据, 而管道端可以被继承或传递, 所以派生者和写入者无法被证明是同一个。以 pid 为键的测量带有通常的微秒级 pid 复用竞争 (在 macOS 上镜像仍经签名验证)。
- 只测量一次: 父进程在进程启动时测量, 缓存的身份在每个请求上对照信任记录重新裁决。测量之前的重新认父会指向收割进程, 在已配对客户端之后被拒绝; 派生者在会话中途退出而另一个进程持有管道, 请求会继续在已准入的身份下得到服务。
- 证明识别的是二进制, 不是意图。 被攻陷的已配对客户端程序在被吊销之前始终受信任。
- Windows: 持有客户端程序管道一端的进程可以在其上运行服务器。 客户端程序以管道化 stdin 派生的子进程通过继承持有该端, 可以启动一个证明该客户端程序的服务器。匿名管道是单向的, 所以它只能读取客户端程序自己的请求; 对客户端程序拥有
PROCESS_DUP_HANDLE的同用户进程可以复制写入端并注入请求。 - Windows: 客户端程序被测量为 stdin 管道的创建者。 服务器要求管道两端指向服务器以外的同一个进程, 对控制台或来自两个进程的两端都失败即关闭。
- Windows: 发布者锚点跳过吊销检查。
WinVerifyTrust运行时不做吊销检查, 这与浏览器自身安装程序验证所用的策略相同: 在线检查会在离线时拖住启动, 仅缓存的检查则在从未获取过 CRL 的机器上失败。被吊销的发布者证书会保留其锚点, 直到revoke-client将其移除; 目录签名的镜像没有嵌入式签名, 仅按哈希锚定。
边界 2: Rust MCP 服务器 <-> 原生消息主机 (桥接套接字, NDJSON)
唯一一条针对本地对端设防的跳: 任何可能试图触达桥接的进程。
- 所有权: 第一个绑定套接字和锁的 MCP 服务器实例是中介。之后经证明的实例通过同一个套接字作为中继接入, 浏览器的原生消息主机也一并接入, 一个共享会话把每个客户端程序的工具调用复用到各浏览器连接上。
- 生命周期: 中介按客户端程序客户端做引用计数 (它自己的 stdio 客户端程序加上各中继; 浏览器刻意不计入), 并在最后一个脱离时退出。关闭协议经 loom 模型检查: 恰好在归零时关闭, 终局决定之后不再有接入。
- 无监听端口: 在 Unix 上, 桥接是一个位于 0700 每用户目录内的 0600 Unix 域套接字。在 Windows 上它是一个命名管道, 其安全描述符只允许当前用户, 由内核在打开时强制执行。
- 对端 UID 检查 (Unix): 在
accept时, 服务器从内核读取连接方的 UID (getpeereid或SO_PEERCRED), 在认证之前就丢弃任何不是来自自身 UID 的连接。 - 双向可执行文件证明: 仍在认证之前, 每一端都获取对端的由内核证明的身份, 并要求它与自己正在运行的镜像一致, 因此同一用户的另一个程序在此被拒绝。服务器在
accept后证明主机, 主机在connect后证明服务器。自身身份在启动时测量, 早于绑定、接受连接或发起连接。
| 操作系统 | 对端身份 | 绑定到 |
|---|---|---|
| Linux | 来自 SO_PEERCRED 的对端 pid, 然后是 /proc/<pid>/exe 的 SHA-256 | 正在运行的 inode; 解析时存在一个很窄的 pid 复用竞争 |
| macOS | 对端的内核审计令牌 (LOCAL_PEERTOKEN), 用 SecCodeCheckValidity 验证并按 cdhash 比对 | 正在运行的镜像, 这关闭了按路径重新打开的 TOCTOU 和 pid 复用竞争; 只有 ENOPROTOOPT (较旧的系统) 才回退到按 pid 识别的 SecCode, 此时那个很窄的 pid 复用竞争仍然存在, 任何其他错误都失败即关闭 |
| Windows | 内核为管道另一端记录的 pid, 然后是该 pid 所运行镜像文件的 SHA-256 | 文件路径, 重新打开; 见下文残余风险 |
- HMAC 质询-应答: 服务器发送一个新鲜的随机 nonce, 主机回复
HMAC-SHA256(secret, nonce), 以常量时间验证。每次运行的密钥存放在锁文件中, 按仅所有者规则保持私密, 从不在线路上传输; 每个连接的 nonce 挫败重放。 - 接入帧: 握手之后, 每个对端立即发送一个声明角色的帧 (浏览器, 或中继客户端), 读取时失败即关闭; 中继的接入帧携带其经证明的客户端程序身份, 供边界 1 裁决。同一标识下的浏览器重连会替换该标识先前的写入端。
- 上限: 最多 16 个不同的浏览器标识和 8 个客户端程序客户端, 最多 32 个处于握手中的连接, 一个对已准入空闲连接清除的 10 s 握手加接入读取超时, 以及丢弃泛洪中继的逐中继 GCRA 速率限制器 (突发 128, 补充 128/s)。每个连接都是经过大小检查的 NDJSON。
- 紧急开关: 闩锁置位期间, 中介的监视器在其一秒的节拍内切断每一条活跃的浏览器连接, 把进行中的调用排空为带类型的失败, 浏览器接入在准入处被拒绝。不可读的信任记录得到同样的处理。中继保持接入; 它们的调用在边界 1 被以带类型的方式拒绝。
这一跳的残余风险:
- 由同用户攻击者重新运行的同一个二进制, 与合法主机无法区分。 字节和
cdhash完全相同, 所以哈希和代码签名都无法区分它们。在 macOS 上, 信任固定到一个精确的cdhash; 为了同时接受另一个受信任的构建而做 Team-ID 或 designated-requirement 固定, 是推迟的后续工作。 - Windows 按路径测量镜像。 管道对端的 pid 解析为镜像路径, 哈希和签名从该文件读取, 而不是从已映射的镜像读取, 因为用户态拿不到后者的句柄。正在运行的可执行文件可以被重命名, 因此同用户进程可以在启动与测量之间把另一个文件放到该路径上; pid 复用竞争同样适用。
- Windows: 锁文件没有显式设置权限位。 每次运行的密钥依赖每用户运行时目录 (
LOCALAPPDATA, 或USERPROFILE\AppData\Local; 作为最后手段的临时目录不保证按用户隔离) 的默认权限。该密钥只是四道门禁之一, 不是唯一一道。 - 中介聚合了影响范围。 一个进程面向所有客户端, 上限缓解了但没有消除这一点; 它也是单点故障, 其崩溃会让每个已接入的客户端程序收到 EOF; 幸存者的重启会选出新的中介。
边界 3: Chrome <-> 原生消息主机 (Native Messaging 帧格式)
Chrome 按主机清单派生主机, 清单的 allowed_origins 固定了扩展 id, 所以只有我们的扩展能打开它。扩展则反过来固定它配对过的主机。
- 帧格式: 4 字节小端长度前缀加 JSON, 入站 64 MB 钳制, 出站 1 MB 上限, 单写入端且每帧刷新, 以及
panic = "abort"加 stderr panic 钩子, 使 panic 无法损坏帧流。主机在 stdin EOF 时关闭。 - 经模糊测试:
src/packages/core/fuzz/中的 cargo-fuzz 目标覆盖线路解析器 (Native Messaging 帧格式、MCP JSON-RPC、桥接与握手解码器) 及其背后的语义校验器 (握手 MAC 校验器、帧分类器、主机密钥质询构造器、清单归属判定)。 - 主机处理的控制帧由主机自己应答, 绝不转发给 MCP 服务器; 同类帧若从服务器一侧到达则作为注入丢弃。架构页面负责每种交换的时序; 台账列出主机终结哪些帧:
| 家族 | 来自扩展 | 来自主机 |
|---|---|---|
| 主机密钥 | enclave_challenge、enclave_revoke | enclave_proof、enclave_error (没有密钥时为 not_enrolled)、enclave_revoked |
| 客户端管理 | client_list、client_revoke、client_pair | client_list_result、client_revoke_result、client_pair_result; client_pair 先打开在场交换 |
| 紧急开关 | kill_status、kill_engage、kill_release | kill_status_result |
| WebAuthn | enroll_begin、enroll_finish、presence_begin、presence_assert、presence_confirm、browser_revoke | enroll_options、enroll_result、presence_request、presence_result、browser_revoke_result |
| 注册 | registration_status、registration_repair | registration_status_result |
| 健康报告 | doctor_report | doctor_report_result |
| 策略与语言 | policy_get、policy_restrict、policy_set、policy_history、policy_rollback、lang_get、lang_set | policy_current、policy_restrict_result、policy_set_result、policy_history_result、policy_rollback_result、lang_current; policy_set 与放宽的 policy_rollback 先打开在场交换 |
| 审计 | audit_event (即发即忘)、audit_read | audit_read_result |
audit_event受种类白名单限制 (只允许扩展拥有的确认与登记种类), 且由主机自行标记来源界面, 因此浏览器一侧无法向审计日志伪造主机侧事件。- 审计日志: 安全决策 (准入、拒绝、显示/允许/拒绝的确认、各界面的吊销、紧急开关转换、策略写入、工具调用) 记录到 stderr 和一个私有的、按大小上限轮转的
audit.log, 由genkan audit读取, 也由选项页经只读的audit_read帧读取。格式由 CLI 页面负责。- 扩展自己的环: 它的事件还会进入边界 4 受限存储之后的一个有界环 (200 条), 由同一个面板在主机日志旁边显示。
- 并非每项决策都到达主机的日志: 两种扩展本地的种类
policy_refused与policy_compromised按设计留在环中, 而一次失败的存储写入会丢弃该记录 (策略台账中的攻陷标记条目)。
- 已关停模式: 闩锁置位期间, 或信任记录不可读时, 主机只运行控制平面。它从不拨号中介, 丢弃桥接帧, 并保持控制帧可用, 使状态、启用和策略拉取仍可触达。刹车 (
kill_engage) 是一个没有门禁的帧; 解除则打开下文的在场交换。
主机身份。 扩展固定主机的 P-256 公钥, 该密钥由 genkan pair 在真实终端键入确认之后铸造, 通过 keyring 存入操作系统凭据存储 (钥匙串、凭据管理器或 Secret Service), 或用 --file-store 存入运行时目录中的一个文件, 按仅所有者规则保持私密。文件的存在本身就是这个选择; 存储失败会被报告, 绝不改道到文件。
- 固定: 用户把
pair打印的指纹与扩展显示的指纹比对, 这能挫败位于两者之间的主机。无法读取自己密钥的主机以失败即关闭的方式通不过固定;revoke --all删除密钥, 并在存储确认它已消失且主机密钥标记已变动之后推送一个由主机发起的吊销, 因此已固定的扩展无需等待重新验证即失败即关闭。 - 密钥的唯一工作: 向扩展标识这次安装, 以及签署策略基线。签名不以在场为门槛; 在场是下文浏览器的认证器。
WebAuthn 在场交换。 扩展请求的每一个恢复或授予能力的操作 (解除紧急开关、登记另一个浏览器, 以及策略路由到此处的 page_eval 或 page_upload) 都在一个由主机铸造并验证的请求之后运行。
host statement = domain || 0x00 || browser label || 0x00 || action || 0x00 || nonce
challenge = sha256(statement) -> presence_request
ext navigator.credentials.get({ publicKey: { challenge, rpId: <extension id>, ... } }) <- the human gesture
host verify: rpIdHash, the user-present flag, the challenge echoed in clientDataJSON,
ECDSA P-256 over authenticatorData || sha256(clientDataJSON), against the key stored at enrollment- 谁可以作答: 每条浏览器连接同时只有一个未决请求, 新请求取代旧请求。解除紧急开关只接受登记在本浏览器标识下的凭据 (否则为
wrong_browser_label); 登记另一个凭据接受任一已登记凭据, 这正是让第二个浏览器得以登记的条件; 未知凭据为credential_not_enrolled。 - 页面操作的请求 (
presence_begin) 把操作和页面的源以page_eval on <origin>的形式绑进签名语句, 所以为一个页面铸造的轻触无法为另一个页面担保。主机只为这两个操作、且只在形如浏览器所序列化的源上铸造它, 对其他任何请求一律拒绝且不留任何未决请求, 并只接纳本浏览器的凭据 (没有凭据时则接纳窗口)。 - 窗口: 扩展的确认窗口可以作答, 回显请求的 nonce, 但只有在按作答时的登记状态没有任何已登记凭据满足该请求的规则时才行 (否则为
software_confirmation_not_allowed)。浏览器自己的操作: 其标识下没有凭据。一次登记: 机器上没有凭据, 所以第二个浏览器由另一个浏览器的轻触登记, 绝不是一次点击。 - 计数器: 一旦任一方计数, 签名计数器就必须前进, 并在信任记录锁下持久化, 因此重放或被克隆的计数型认证器失败即关闭。从不计数的认证器在两侧都报告零, 计数器抓不住它的克隆。
- 登记:
navigator.credentials.create只接受attestation: "none", 只接受 ES256 (P-256); 凭据密钥取自经证明的凭据数据, 不信任任何证明链。机器上一个凭据都没有时的首次登记是首次使用即信任, 在写入时于锁下重新检查; 此后的每次登记都需要来自已登记凭据的断言。 - 忘记最后一个已登记的浏览器会重新打开首次使用。
revoke <browser>和浏览器自己的「忘记」操作不需要证明, 因为它们只移除能力; 对最后一条登记, 它们让机器回到首次使用即信任, 于是来自任何浏览器的下一个enroll_begin都不经质询即可登记, 又回到全新机器的残余风险。发生时 CLI 会说明。 - 验证失败的断言被拒绝并审计, 请求被消耗, 凭据保持登记。经验证的应答所产生的证明是一个只有在场模块才能铸造的线性见证, 因此解除路径不可能在未检查在场的情况下运行。
- CLI 的下限:
pair、pair-client、unkill与policy set没有 WebAuthn 客户端, 所以它们的证明是在被证实为终端的 stdin 上键入的一句话, stdin 被管道化时在任何提示之前即被拒绝。CLI 从不弹出浏览器提示。
这一跳的残余风险:
- Native Messaging 清单替换。 清单位于用户可写的目录中, 因此同用户攻击者可以把它的
path改指向一个直接与真正的扩展讲原生消息协议的二进制, 绕过边界 2 的套接字。allowed_origins固定了哪个扩展可以打开主机, 而非扩展接受哪个主机。- 固定关闭了什么: 无法读取主机密钥的被替换主机 (另一个用户的进程, 躲在锁定的凭据存储后面的冒名者) 无法应答质询, 已固定的扩展失败即关闭。
- 仍然敞开的: 密钥是同用户可读的软件密钥, MV3 在每次 Service Worker 重启时 (大约每空闲五分钟一次) 重新拉起主机, 而在场无法按每次重连要求, 所以换掉清单或重新执行我们二进制的同用户攻击者在重连时无法区分。
- 它无法伪造的: 来自浏览器认证器的 WebAuthn 断言。它可以伪造吊销推送, 让已固定的桥接失败即关闭: 这是针对用户自己的桥接的拒绝服务, 不授予任何能力。跳过自身验证的被替换主机, 就是 IPC 层本就不设防的那个同用户进程。
- 主机密钥是软件密钥。 能读取密钥的同用户进程可以签署一份通过固定验证的策略基线, 而无需 CLI 所要求的终端确认。这是逐次使用的在场迁移到浏览器认证器 (每个平台都有) 时接受的收窄; 密钥标识这次安装, 不再更强。
- 轻触背后的硬件是认证器的。 用户验证是平台认证器的 (生物识别、PIN 或安全密钥), 浏览器测试套件的虚拟认证器证明的是协议, 不是硬件。
- 下限证明的是意图, 不是硬件。 为没有任何已登记凭据可作答的请求准备的窗口, 和 CLI 的终端, 阻止的是静默、脚本化和意外的授予, 而非驱动 pty 的同用户进程。每一次经审计的操作都记录授权它的路径 (
auth=tty、auth=confirm_window、auth=webauthn:<fingerprint>), 因此由软件证明的授予始终可以区分。 - 从认证器或操作系统通行密钥存储中删除的凭据仍保持登记。 主机看到的是登记而非认证器, 所以登记比它的凭据活得更久: 该浏览器会一直拒绝窗口 (
software_confirmation_not_allowed), 并且无法应答它自己的在场请求, 直到revoke <browser>忘记这次登记。 - 密钥销毁的纪元递增是尽力而为的。
revoke --all和pair --reset在销毁临界区内清除签名的基线并递增主机密钥纪元, 但纪元写入可能失败: 销毁会立即告警, 其他界面只在下一次密钥验证时察觉, 而在销毁过程中签名的首次写入基线可能随后落地。revoke --all是例外: 同一次写入还会忘记登记和客户端, 基线清除或记录写入失败会作为重置失败报告, 绝不以告警带过。- 界限: 让它落地仍然需要一次在场证明, 而下一次配对在铸造前的基线清除会在新密钥存在之前将其移除; 该清除本身也是尽力而为的, 失败时告警。
- 存储不会应答的凭据存储条目, 比文件存储配对活得更久。
pair --reset --file-store和revoke --all在存储不应答时 (锁定或缺失的 Secret Service) 照常进行, 并警告存储可能持有的条目被留了下来; 文件密钥只在文件存在期间遮蔽它。- 序列: 存储变得不可达, 用户配对到文件, 之后在存储仍不可达时吊销该文件密钥, 然后存储恢复: 旧密钥再次生效。
- 界限: 重新固定到文件密钥的扩展, 没有任何固定能与重新浮现的密钥匹配。仍固定到旧存储密钥的扩展 (重新固定从未完成, 或因为主机只在存储明确答复密钥不存在时才推送吊销, 吊销推送被扣下) 会再次信任它。
- 无论哪种情况,
genkan enclave-status都会报告一把用户以为已经消失的密钥,policy set会用它签名, 直到存储应答之后再次运行pair --reset(或revoke --all, 它还会忘记每一个浏览器和客户端)。
- 注册修复不以在场为门槛。 选项页的修复帧通过与
doctor --fix和--browser相同的接缝重新注册检测到的浏览器, 或它指名的已知浏览器: 幂等, 只在此账户的作用域内, 只把浏览器指向这个二进制而非其他任何东西, 与 CLI 路径的姿态相同, 后者同样没有门禁。被攻陷的扩展所能获得的, 不过是任何同用户进程本就拥有的。- 唯一的区别: 另一个工具以我们的主机 id 写下的清单, 会被 CLI 的显式
--fix替换, 而被该帧留下。
- 唯一的区别: 另一个工具以我们的主机 id 写下的清单, 会被 CLI 的显式
边界 4: 扩展 <-> 网页 (Chrome API / 内容脚本 / DOM)
页面不受信任。这是安全关键的边界。
- 白名单: 页面级工具只在用户批准过的源上运行; 新的源会提示用户并请求主机权限。页面无法自我批准。
allowAllSites是显式的选择启用。 - 确认: 提交与链接点击、
page_press、page_select、page_eval、tab_close和page_upload都在扩展拥有的弹出窗口 (confirm.html) 上确认, 它是一个运行在自己进程中的chrome-extension://文档, 页面无法读取、聚焦、覆盖、自动点击或自动关闭它。路由器只接受来自该确切文档的就绪与决议消息。超时、关闭和提供者缺失都视为拒绝。 - 提示的范围: 只有这些高风险工具需要确认。导航、
page_text、tab_list和脱敏后的 Cookie 或存储读取不做逐操作提示, 因此一个已经能触达扩展的驱动者可以导航、读取脱敏后的内容、枚举标签页, 而用户看不到其中每一步。确认是针对危险操作的门禁; 它不承诺不会有任何事情悄悄发生。 - 绑定: 与打开中的确认竞争的导航, 由与动作原子执行的页内源断言捕获; 点击绑定到已批准的目标描述符。
page_upload在确认之后重新检查标签页的源, 并把附加绑定到那时解析出的文档节点, 因此提示期间的同源导航能通过那次检查。 - 宽限期: 一次已批准的提交或链接点击之后, 在按标签页键下的重复在
confirmGraceMs内跳过提示, 其默认值由同一节负责。page_eval被排除在外, 每次调用都重新确认;page_upload每次调用都重新确认, 并刻意不脱敏地显示路径, 让用户看到哪个文件会离开磁盘。 - 在场路由: 策略字段
presenceConfirm把page_eval和page_upload路由经主机的在场交换。服务在打开任何窗口之前先向主机请求一个绑定了该操作与页面源的请求; 同一个窗口显示载荷, 用户在那里应答该请求: 已登记认证器的轻触, 或在本浏览器没有凭据时的软件确认。主机验证并审计它。- 没有回退: 被拒绝的请求直接拒绝, 不开窗口; 路由裁决被快照进决策, 因此提示等待期间的策略变更无法改变它的路由。
- 脱敏: 页面文本、Cookie、存储和 eval 输出在 Service Worker 中于出口处脱敏, 两个页面后端共用这一处。规则由安全策略负责。
- 信任状态隔离: Chrome 默认把
storage.local暴露给内容脚本, 所以扩展用setAccessLevel(TRUSTED_CONTEXTS)把storage.local和storage.session限定在扩展上下文内。于是内容脚本无法读写登记的固定、已攻陷标记、强制执行的策略状态 (棘轮和切换标志) 或白名单, 登记门禁在该限制生效之前失败即关闭。 - 路由器同样受门禁: 发送者不是扩展自身页面的每一条运行时消息都被拒绝, 因此内容脚本无法植入白名单, 也无法读取已固定的密钥 id 和指纹。
- 紧急开关镜像与审计环: 主机紧急开关状态的仅 worker 可见镜像和扩展的审计环存放在同一片受限存储中。镜像读到已关停、未知或格式错误时, 门禁拒绝; 垃圾数据绝不映射到宽松的「缺失」状态。
- 谁写谁读: 镜像只由主机的紧急开关状态帧写入, 绝不由运行时消息写入, 且只有扩展自身的页面可以读取状态、切换开关或读取日志。
- 隔离: 内容脚本运行在隔离世界中;
page_eval使用Function构造函数, 而非内容脚本的作用域, 其结果在脱敏之前经过安全序列化 (循环引用、DOM 节点、特殊类型)。两个页面后端运行同一份共享的 DOM 实现 (src/apps/extension/src/lib/dom/page-api.ts); CDP 路径传送的是它的字符串化源码, 因此两者不会分叉。 - 凭据只读: 不写 Cookie 或存储。
这一跳的残余风险:
- 已批准源上的读取外泄。 一旦某个源进入白名单,
page_text、cookie_get和storage_get运行时没有逐操作确认, 只受启发式脱敏保护。在已批准且已登录的源上对模型实施提示注入的页面, 可以静默读取页面内容, 以及脱敏漏掉的任何秘密 (它匹配的是类令牌的形状, 不是含义)。- 被否决的替代方案: 对读取做逐操作提示, 会给几乎每一步代理操作都加上确认, 教会用户机械点过那些守护真正危险操作的提示。
- 界限: 源白名单 (页面必须已经处于用户批准过的地方)、作为尽力而为层的脱敏, 以及审计日志。
- 宽限期让无关的同源代码得以运行。 一次已批准的点击覆盖同一源在宽限期持续期间于同一动作类型下接下来做的任何事;
page_eval永远不搭这趟便车。 - 窗口证明的是一次点击, 不是在场。 它证明的是在页面无法触及的表面上发生了一次点击, 不是密码学意义上的用户在场。
- 没有凭据的浏览器上的
page_eval与page_upload以软件确认。presenceConfirm开启时, 这种浏览器的应答是窗口的确认, 由主机接受并以confirm_window审计; 关闭时, 则只由窗口确认。选择退出该字段仍然是一次经签名、以在场为门槛的策略写入。 page_upload可以附加调用者指名的任何本地文件。 当该工具启用时 (默认关闭), 它会附加调用提供的任意绝对路径, 因此被诱导调用它的模型可以把用户从未选择过的文件交给页面, 例如 SSH 密钥。- 门禁: 选择启用、源白名单, 以及在页面无法触及的窗口上显示确切路径的逐调用确认。
- 敞开的: 路径不受限于用户挑选的文件或安全目录; 一次被批准的调用附加的正是它所指名的文件。在推荐文件上传用于一般用途之前, 已跟踪待加固 (改用操作系统文件选择器, 让模型不指名任何路径)。
- 历史导航以当前源为门禁, 而非目标源。
page_back和page_forward对照标签页的当前源检查白名单; 它们落到的页面是历史所指向的任何地方。 - 信任状态的冷启动窗口。
setAccessLevel是异步的, 在 worker 启动之后才应用, 所以冷启动时有不到一毫秒的时间, 来自上一个 worker 生命周期、处于被攻陷渲染器中的内容脚本可以在限制生效之前写入被篡改的值。- 界限: 在路由器受门禁之后, 这是内容脚本通往信任状态的唯一剩余路径。没有用户空间 API 能关闭它, 固定的密码学检查限定了, 但不能消除, 一个被植入的固定所能达成的效果。
page_snapshot_precise会短暂附加调试器, 所以横幅会闪现。- CDP 模式 (
cdpMode, 一项主机持有的授予, 默认关闭) 把每一个页面级工具经由chrome.debugger在页面的 MAIN world 中路由。启用它是一次经签名、以在场为门槛的策略写入, 在没有主机密钥的地方被拒绝。- 代价: 它绕过页面 CSP (因此
page_eval能在严格 CSP 的站点上运行), 并保持一个持久的调试器附加, 所以「Started debugging this browser」横幅一直显示。白名单、确认和脱敏不变; 更宽的攻击面和被移除的 CSP 层是选择启用的代价。
- 代价: 它绕过页面 CSP (因此
信任记录
运行时目录中、紧挨锁文件的 trust.json: 仅所有者可读, 在运行时锁下原子写入, 以失败即关闭的方式解析 (deny_unknown_fields、版本检查、256 KiB 大小上限)。每个执行点都从同一份快照读取登记、紧急开关闩锁和客户端白名单。
仅所有者指的是 Unix。 主机写入的每一个私有文件 (trust.json、锁文件、audit.log、--file-store 密钥) 在 Unix 下都以 0600 模式创建: 原子写入的记录以 0600 创建后重命名到目标位置, 就地打开的文件在打开的句柄上重新收紧, 因此预先植入的更宽松文件无法保留其权限位。
在 Windows 上没有模式分支: 文件继承每用户运行时目录的 ACL, 即边界 2 点名的残余风险。
| 字段 | 含义 | 写入者 |
|---|---|---|
epoch | 每次写入都递增: 记录的全局排序, 作用域标记在其作用域变化时复制的值。从来不是权威: 没有任何决策或推送以它为键 (中介读取它只为去重一行日志), 每个决策都从记录重新读取 | 每个写入者 |
kill_epoch | 最后一次紧急开关闩锁写入 (启用或解除, 无论闩锁是否改变) 时的 epoch (0 = 从未), 与闩锁在同一次原子写入中盖章; 它在两次轮询之间变动时, 原生消息主机的监视器推送紧急开关状态 | killed 的写入者 |
host_key_epoch、policy_epoch、lang_epoch | 最后一次主机密钥销毁 (吊销或重置, 从不是铸造)、主机持有策略写入或语言变更时的 epoch (0 = 从未)。监视器在两次轮询之间比较各自的值, 只推送对应的帧 (吊销、policy_current、lang_current); 吊销推送还需要存储确认密钥已消失, 所以乱写的标记无法伪造一次推送。客户端或登记写入不盖任何章, 也不触发推送 | 该作用域变更的写入者, 在其自身存储之后的第二次写入中 |
killed | 全局紧急开关闩锁 | CLI 上的 kill 与 unkill; 扩展的启用与解除帧 |
clients | null 表示从未配对 (不强制准入); 一个列表, 即使为空, 表示已登记且已锁定 | CLI 上的 pair-client、revoke-client 与 revoke --all (后者清空已配对的列表, 而让 null 保持原样); 扩展经主机中介的客户端吊销帧 (其客户端列表帧只读) |
enrollments | 每个浏览器在其标识下登记的 WebAuthn 凭据, 连同其签名计数器 | 边界 3 的登记交换; 在场校验器, 在每一次接受的断言上推进凭据的计数器; CLI 上的 revoke <browser> 与 revoke --all, 以及扩展针对已连接浏览器自身标识的 browser_revoke 帧, 都不经证明即忘记, 因为那只移除能力 |
- 三个章是尽力而为的。
host_key_epoch、policy_epoch和lang_epoch在同一次持锁期间、在该作用域自身存储之后的第二次写入中落地。写入失败时变更仍然成立, 主机告警, 丢失的只是监视器的推送。- 谁仍能得知: 发起请求的浏览器会收到应答帧; 在别处作出的变更会在已连接扩展的下一次连接时到达, 那时策略和语言帧会无条件推送。销毁的情形是边界 3 的残余风险。
- 重新配对即替换:
pair-client替换同名条目, 这是哈希锚点在重新签名后的重新配对路径。revoke-client即使清空也保留列表。 - 解除配对时两半都删: 从任一侧解除配对都会删除凭据的两半; 扩展侧的吊销还会要求主机删除其密钥, 持久化地重发直到被确认。
- 吊销延迟: 套接字这一段是即时的; 扩展在下一次 Service Worker 唤醒时反映主机密钥吊销。
- 执行机制: 0700 运行时目录上的文件权限、原子写入纪律, 以及在每次准入和会话内请求时对记录的重新读取。
记录的残余风险:
- 同用户写入者拥有这份记录。 能运行我们 CLI 的同用户进程可以把自己配对进来、植入一条登记或翻转闩锁, 正如它可以删除这份记录一样。删除
trust.json会回到以 ERROR 记录的引导状态: 这是不可消减的同用户残余风险, 因为没有任何用户空间标记能在一个可以删除我们能写的任何文件的写入者面前幸存。 - 篡改纪元或某个作用域标记可以强制一次多余的推送, 或让每次读取都失败即关闭, 但无法让配对列表、主机密钥或登记所不认可的任何人获得准入。
- 启用有一个亚秒级窗口 (一个监视器节拍) 留给进行中的工作, 随后被切断的套接字将其排空。没有任何东西会自行清除闩锁: 没有超时、重启或重连; 被拒绝的断言绝不回退到窗口, 两种转换在记录不可读时都拒绝, 因为从未知状态解除会失败即开放。
主机持有策略的残余风险台账
安全策略由主机持有: 一份签名的基线、一个未签名的限制覆盖层、扩展侧的取值棘轮, 以及逐连接的分发屏障。架构页面负责机制; 本台账负责残余风险, 每一项都是有意接受的。
请把这些条目放在一起读; 它们会叠加。第一条的界限 (只接受存储修订号及以上的正版基线) 锚定在存储的棘轮记录上, 而冷启动条目使该记录在其窗口内可被删除: 锚点一旦被删除, 该密钥曾签过的最宽松的正版基线就会作为首次策略应用, 修订号不限。
- 一次失败的攻陷标记持久化加上一次 worker 重启, 会让屏障对捕获到的正版策略重新打开。 当一次推送针对固定的签名验证失败时, 扩展会立即在内存中闩上一个攻陷标志, 然后写入持久的标记。如果这次写入失败, 随后 worker 重启, 新的 worker 将在闩锁未置位的状态下启动, 于是重放的逐字节相同的推送能通过验证, 作为幂等重放推进棘轮, 并重新打开分发屏障。
- 前提条件: 一次持久存储写入失败、一次 worker 重启, 以及一个被捕获的正版帧。
- 界限: 屏障只会对已固定密钥在存储修订号及以上签过的某份正版策略重新打开。攻击者在捕获的文档中挑选, 但没有主机密钥就无法伪造一次越过棘轮和签名触及集规则的新放宽, 而能读取软件密钥的同用户进程拥有该密钥 (边界 3)。
- 重新证明只在用户选择启用周期性重新验证的地方收窄它 (
hostReverifyMs; 默认值 0 下没有任何重连会被质询)。在一个 worker 生命周期内, 内存闩锁无论如何都成立。 - 证据: 失败的持久化会被记录, 从不被吞掉, 但没有任何持久证据能幸存: 审计事件写入的是刚刚失败的存储, 而攻陷种类是扩展本地的, 从不转发到主机的日志。
- 接受, 因为内存闩锁无法在重启后幸存; 彻底关闭它需要「持久写入成功后再继续」或启动时重新证明, 而不是更宽的闩锁。
- 同密钥撤销路径中的一个微任务窗口。 恢复被取代的已存储策略记录的写入撤销, 会在其恢复性写入之前立即重新检查固定重置纪元, 但
chrome.storage不是事务性的, 所以在那次检查与写入之间完成的同密钥重新配对, 可能看到撤销恢复了重置刚刚移除的记录。- 按构造是单向的: 纪元在读取先前记录之前捕获, 所以另一侧的误触发会把恢复变成移除 (失败即关闭), 而幸存的方向只能复活一条惰性记录, 作用域检查会让它在任何其他固定下都不进入执行。接受: 没有事务性存储就无法修复。
- 一次推送内的分歧。 两个执行点 (主机分发、扩展门禁) 在策略变更前后可能短暂不一致; 窗口为一次推送, 且扩展在其边界上是权威。
- 与错误推送的验证竞争的请求。 策略帧在请求门禁之前路由, 但不与请求队列排序, 所以在错误签名的推送到达与其验证失败之间 (一次帧跳转、两次解析、一次 base64 解码、一次固定读取、一次 ECDSA 验证), 已经通过门禁的请求仍可以在仍然打开的屏障下分发。
- 界限: 生命周期内的攻陷闩锁在验证失败的那一刻同步置位, 且每个请求读取它两次 (在门禁处, 以及在分发自身的策略读取中), 所以请求必须在失败落地之前通过两次读取; 漏过的请求是在存储的有效策略下运行的, 即经棘轮推进的下限, 从不比用户上次看到的已应用策略更宽松。
- 被拒绝的在场请求会拒绝该操作, 不回退到窗口。 在场路由的探测就是主机对
presence_begin的应答: 一次拒绝 (扩展自己的: 已有交换在进行或主机已消失; 或主机的: 带有 名册 中的一个代码) 拒绝该操作; 一个提示没有凭据的请求则是主机在说窗口可以作答, 即有文档记录的降级表面。- 今天: 提供者选择是同步的, 而缺失或已攻陷的固定会在任何确认之前于登记门禁处拒绝页面操作。
- 为什么不回退: 回退会让任何探测故障把要求在场 (
presenceConfirm) 的批准降级为普通的窗口点击, 即在场阶梯的不降级规则的反转。代价是异常路径上的可用性, 从不是能力。
- 敌意的未固定主机可以用放宽提示占满确认 FIFO。 在没有固定的机器上, 放宽会被搁置等待用户的窗口批准。对待处理推送的逐字节相同重放 (相同的基线字节、相同的覆盖层、相同的连接) 会折叠到它的提示上; 不同的候选各自串行化为一个提示, 所以敌意主机可以让 FIFO 一直忙碌, 只要用户一直不作答。
- FIFO 是全局的, 跨所有确认类型, 所以这种占用也会饿死排在它后面的
page_eval、page_upload和点击确认。 - 两点细化: 折叠以 JSON 字符串化后的覆盖层为键, 所以键顺序被调换或做了细微变化的限制会使其失效 (代价是多一个提示, 从不抑制一次不同的推送); 以及伪造的未签名限制会静默应用, 完全没有提示, 这是策略设计让步的伪造限制拒绝服务, 因为它只会移除能力。
- 界限: 一次只有一个提示, 每次拒绝都被审计, 没有明确批准什么都不会放宽, 且已固定的机器不受影响 (那里不存在批准窗口)。
- 一处溢出: 语言通道共享同一条帧通道, 所以在未固定的机器上, 一个排队的 120 s 批准提示可能延迟语言选择的响应。无害: 本地的
uiLanguage写入已经落地, UI 也已在中继之前完成切换; 只有诊断用的sent标志被延迟, 而语言选择在未固定的机器上本来就报告false。 - 接受, 因为未固定的批准窗口本质上可被唯一获准请求它的对端占用。
- FIFO 是全局的, 跨所有确认类型, 所以这种占用也会饿死排在它后面的
- 明确选择「en」会在每次重连时重新提供首次配对的语言采用。 主机的语言存储把缺失的记录映射为默认值 (
"en", seq 0), 而携带已存储值的lang_set是刻意的空操作, 不递增任何东西, 所以针对从未设置过的存储, 一次明确的"en"会让 seq 停在 0。- 效果: 主机无法区分「从未设置」与「明确设置为默认值」, 这样的扩展在每次重连时都重新发送它那一个采用帧。
- 界限: 每次连接一帧 (逐连接的采用闩锁在一次连接内结束它), 该帧在主机侧是空操作, 且该通道纯粹是装饰性的: 语言是浏览器拥有的显示状态, 从来不是策略。按原样接受: 把「明确设置为默认值」折叠成一次 seq 递增, 会波及 seq 存在的目的所在的回声抑制语义, 为了装饰性收益冒真正的风险。
- 损坏的攻陷标记读作未攻陷。 持久标记的读取器对无法解析的记录返回 null, 所以损坏的标记在继承它的每一处读取点都读作「没有证据」, 包括登记门禁、在场能力探测和配对通知抑制。
- 界限: 写入路径被限定在受信任上下文的存储内, 所以制造这种损坏需要扩展上下文或冷启动窗口, 而不是网页或稳态下的内容脚本。固定记录自身的自检 (其密钥 id 必须与其存储的公钥匹配) 是内部一致性, 不是真实性, 但损坏的标记无法伪造或改变固定。
- 冷启动窗口也挡在策略状态前面。 边界 4 的亚毫秒级
setAccessLevel窗口覆盖策略棘轮、切换标志、持久攻陷标记和先前固定记录。与登记门禁的一点诚实差异: 策略帧在门禁之前路由, 且策略路径自身不等待限制生效, 所以策略状态依赖于限制被尽早应用, 而不是在使用时重新检查。 - 「已固定的扩展拒绝未签名基线」只是已固定情形下的界限。 在扩展没有固定的机器上, 批准窗口通道在一次新鲜的、明确的用户批准之后接受未签名文档。没有任何主机路径会写入未签名基线, 所以窗口通道自身的让步 (驱动扩展页面的软件可以替它回答提示) 就是全部的暴露面。
- 与
cdpMode吊销竞争的决策可能产生短暂的调试器附加。 新 CDP 会话的附加在附加后策略重检读取授予之前就已完成, 所以一个快照到cdpMode: true的进行中决策, 在吊销落地之后仍可能附加。- 界限: 该会话上不会发送任何 CDP 命令; 重检会将其拆除并使操作失败, 附带说明是会话层会吞掉分离错误, 所以拆除是尽力而为的。在附加前重检曾破坏已固定的附加协议顺序。
- 固定吊销会让存活的 CDP 附加留在原地。 策略驱动的拆除在存储写入时触发, 而吊销路径刻意保留存储的策略记录 (同密钥防重放锚点), 所以没有写入触发, 现有的附加连同横幅会一直留到标签页关闭。
- 界限: 切换之后, 分发屏障立即停止所有新工作; 切换之前的扩展没有屏障, 执行的是拒绝基线。该附加只能滞留, 无法行动。
不得倒退的不变量
- 两种二进制模式下的 stdout 都只有协议字节; 诊断信息写到 stderr。
- 桥接绝不服务未通过以下任一检查的连接: 同用户检查 (Unix 上的对端 UID, Windows 上的管道描述符)、可执行文件证明、HMAC 握手, 或强制的声明角色的接入帧。
- 一旦存在受信任客户端白名单, 除非客户端程序经证明的身份匹配某个条目, 否则不予服务; 无法测量的身份和不可读的白名单都失败即关闭, 客户端自报的名称绝不作为授权键。
- 主机清单的
allowed_origins始终恰好固定为我们的扩展 id。这固定了扩展到主机的方向; 主机到扩展这一跳由边界 3 的固定主机密钥把守。 - 任何页面级工具都不会在非白名单的源上运行, 除非设置了
allowAllSites。 - 任何工具都不写 Cookie 或 Web 存储; 按设计就没有
cookie_set或storage_set。 - 确认门禁默认开启, 放宽任何一道都是一次经签名、以在场为门槛的策略写入。
- 紧急开关启用期间, 或其记录不可读时, 不服务任何工具调用, 任何浏览器连接都不得存续; 开关只能由受信任界面的显式、经在场证明的解除清除, 绝不自动清除。
- 审计日志绝不作为决策的门禁: 记录是先决策后记录, 写入失败会以可见的方式丢弃该条记录 (
dropped计数器), 而不是让操作在任一方向上失败。
更改上述任何一条都是评审标准下的安全相关变更。