Files
MoShiro/docs/access.md
T

55 KiB
Raw Blame History

访问控制与入网信任链

这份文档定义谁能做什么(账户、角色、能力、API key)和谁能让节点入网(passkey 到节点的签名链)。它是 security.md 第 5、6 节的展开:security.md 讲为什么(缺口和威胁),这里讲怎么做。状态:部分实现(当前实现见第 0 节,完整设计的实施顺序见第 12 节)。

一句话:登录和授权分开。登录证明「你是谁」,密码、passkey、OIDC 都可以;改变信任(加节点、吊销、改凭证)只认 passkey 的签名,并且这个签名要一路传到节点,Backend 只搬运,伪造不了。

0. 当前实现(以这一节为准)

下面的第 1~16 节是完整设计,其中「成员证明」那一整套(Grant、Ticket、Binding、每个节点的入网链,第 7.1~7.3 节)当前没有采用:评估之后,被攻破的 Backend 往名册里塞节点拿不到比 Backend 已经有的更多的东西(监测数据本来就是明文给 Backend 的;敏感的 extension 靠节点属主,不靠名册),成本却是每个节点一次触碰、一套证明格式和一套自动化委托。现在实现的是它的精简版:

 两个根,各管一摊
   根 A:CI(代码)  发布公钥写在安装脚本和节点二进制里
        └─ CI 签名的发布清单 → 镜像 digest → 节点跑的二进制;安装脚本的 SHA-256 也记在清单里,并公布在发布页
        假设:CI(Woodpecker、Gitea、镜像仓库、发布密钥)可信;CI 被攻破等于能给所有节点签恶意更新
   根 B:你的 passkey(运维)
        └─ 操作员集合(passkey 签,版本递增;创世指纹在安装时固定到节点)
             ├─ 操作员的 passkey 公钥
             ├─ 服务器密钥:Backend 的身份密钥(节点据此认服务器,链路用应用层加密)
             └─ 域名(rp、origins)

 添加节点:创建加入码不需要 passkey、不需要触碰,一个码可以装任意多台。命令里只有公开的值:
   面板地址、角色、一次性码、创世指纹、属主(passkey 凭证 id)、安装脚本的哈希(命令先核对哈希再执行)
 节点注册:向面板取操作员集合链,从命令里的创世指纹验起,属主必须是集合里的凭证;任何一步不对就不注册、不交出密钥;
   通过后写 trust.json(创世指纹、链、属主),之后只认从这个创世出发的新版本
 节点和 Backend 之间:集合里登记了服务器密钥之后,节点的认证挑战要求服务器用它签名;两边各出一把临时 ECDH 密钥,
   认证签名覆盖双方的临时公钥,之后控制链路每一帧都是 AES-256-GCM(每个方向一把密钥、严格递增的计数器)。
   所以 TLS 在 Cloudflare 或被冒充的证书那里终止,中间人没有服务器密钥,既冒充不了服务器,也读不了、改不了这条链路
 密码登录和 passkey 登录的会话拿同样的能力,授权只看能力;passkey 只用在节点自己会验证的事上(现在是操作员集合的签名,以后是 extension 会话和加密数据)。
 Backend 自己拦自己没有意义,所以没有提权窗口

发布密钥可以换:见 docs/deployment.md 的「换发布密钥」;回滚已经由清单的 seq 防住(更新器只收比已安装更大的)。

节点二进制绑定(第 16 节):节点第一次握手成功的那一刻,Backend 把它正在运行的二进制的 SHA-256 绑定到这个节点;之后哈希对不上、又不在 CI 签名的更新清单里,就拒绝连接。

还没有的:吊销表;任务和配置内容的操作员签名;节点向别人证明自己没有撒谎(二进制绑定抓的是文件被换、降级、起了不对的二进制,抓不到故意照抄哈希的恶意二进制,见第 16 节);extension 本身和属主的 controllers、接收者的实际加密(只是被固定在节点本地,没有行为)。

1. 名词

名词 含义
账户 一个人。有角色、若干登录方式、若干 passkey
角色 anonymous、admin、owner,决定能力的上限
能力(capability) 一项具体权限,如 fleet.write。端点声明自己需要哪项能力
会话 登录得到的状态,只带 L1 能力
操作员集合(OpSet) 节点固定的、带版本的 passkey 公钥列表,每个带角色和限制
委托(Grant) passkey 签名授权一把临时密钥,在范围、时间内代表它签名
API key 一串种子;enroll 类的 key 创建时由 passkey 签出一份委托
入网券(Ticket) 委托的密钥签的「允许一个持有 K_code 的节点入网」
属主(controllers) 一个节点绑定的一组 passkey:只有它们能授权这个节点上的 extension(web ssh 等)。管理员没有这份权力,见第 8 节

2. 角色与能力

2.1 能力表

组 能力 说明 信任类
展示 public.read 看隐藏线路;站点私有时看展示页
节点 fleet.read 看 Agent、Probe、线路、加入代码
fleet.write 改名、标签、可见性、手填地址、线路开关、断开会话
fleet.enroll 生成加入代码(签入网券);可以吊销自己加的节点 是
fleet.revoke 吊销或删除任意节点 是
任务 tasks.read · tasks.write 任务模板、覆盖、发起测试
数据 data.read · data.write 原始块、导出;清理
设置 settings.read · settings.write 站点设置、保留策略、重启计划
更新 update.read · update.confirm 看 Backend 更新、确认更新
release.publish CI 登记发布清单(只此一件事)
令牌 keys.read · keys.manage 列出、创建、撤销 API key
账户 accounts.manage 账户、passkey、操作员集合 是
运维 audit.read · ops.metrics 审计日志;/metrics
集成(预留) events.read · data.export 拉取事件流和批量数据,见 5.4
webhooks.read · webhooks.manage 外部回调订阅,见 5.4
  • Backend 更新确认是 L1:更新清单由 CI 的发布密钥签名,seq 防回滚,真正的门是发布密钥,不是确认这一步。
  • 一个能力只在一个地方声明:Capability 常量(MoShiro.Protocol),端点用 .Requires(Capability.X);Principal.Has(capability) 是唯一的判断入口,会话、API key、委托都走它。
  • 对账测试:所有 /api/admin 端点必须声明能力,没声明测试就失败;OpenAPI 的每个操作带 x-moshiro-capability。

2.2 角色

角色 能力
anonymous 只有 public.read,且站点公开时才有。不是账户
admin 除 accounts.manage 外的全部
owner 全部;accounts.manage 每次都要当场触控
  • 角色是上限:能力是否可用取决于角色。
  • 不能越权:创建 API key 时勾选的能力必须是创建者当前拥有能力的子集;带信任类能力的 key 创建时要 passkey 签一次。
  • 角色写在操作员集合里,节点自己执行:admin 凭证签的入网券有效;只有 owner 凭证签的操作员集合更新才有效。被攻破的 Backend 给自己造一个 admin 账户也加不进节点。

3. 登录与会话

登录方式 能得到 说明
passkey L1 会话;还能给操作员集合签名(节点验证) WebAuthn,只支持 ES256,attestation 选 none
密码 L1 会话 保留用于首次引导和兜底;PBKDF2
OIDC L1 会话 账户绑定 iss + sub,登录时按它找账户,不自动建账户。放在 passkey 之后做
  • 密码和 OIDC 会话可以管理(有能力),但进不了信任域:节点验证不了 IdP 的断言,所以它们改不了操作员集合。
  • 引导(认领向导,没有默认账户):面板里还没有任何用户时,打开 /admin/ 进向导——用后端日志里的认领码(或 MoShiro__ClaimCode)创建第一个管理员(用户名和密码自己填),注册第一个 passkey,再用它给操作员集合签名建立信任根。谁先打开页面谁就能当管理员,所以要认领码;创建完码作废、入口关闭(POST /api/admin/auth/setup 之后返回 409)。后两步是引导不是门槛,可以稍后补,没有 passkey 也能用密码登录管理。之后新增的 passkey 不会自动成为操作员:要由现有操作员在信任页看清「新增了哪些」再签新版本,节点才认(见第 7 节)。
  • 建议每个账户至少注册两个 passkey(手机加硬件密钥)。
  • 离线恢复密钥:创建 owner 时同时生成一把离线的 ECDSA 恢复密钥,作为 type=ecdsa、recovery=true 的 owner 凭证写进操作员集合;私钥打印或放进密码管理器,平时不用。节点强制它只能签操作员集合更新,不能签委托、入网券和吊销表,所以即使被偷走,也加不了节点;它的用处是 passkey 全部丢失时签一版新的操作员集合、重新加 passkey,节点不用重装。

3.1 passkey 登录的实现(已完成)

  • 流程:登录页「使用 Passkey 登录」→ POST /api/admin/auth/passkey/options 取挑战 → 浏览器 navigator.credentials.get(可发现凭证,不用输用户名)→ POST /api/admin/auth/passkey/login 提交断言 → 建立会话(Cookie,或跨域前端用 return_session)。注册:已登录用户在「系统 → 用户 → 我的 Passkey」添加,POST /api/admin/auth/passkeys/options 加 POST /api/admin/auth/passkeys;每个用户最多 10 个。
  • 校验:挑战在内存里,一次有效、5 分钟过期,并且区分注册和登录、绑定用户;rpIdHash、UP 和 UV 标志、clientDataJSON 的 type、challenge、origin、ES256 签名;签名计数器只增不减(两边都是 0 的同步型 passkey 不检查)。登录失败一律 401,不区分原因,和密码登录共用限流。
  • 验证断言的纯函数是 Fleet.Identity.WebAuthnAssertion(只用 BCL,节点以后验证 passkey 签出的委托也用它);注册里的 CBOR 和 attestation 解析在 Backend(WebAuthnRegistration),attestation 一律当 none,节点不带 CBOR。
  • 依赖方(RP):MoShiro__WebAuthnRpId(缺省取 PublicUrl 的主机,没有就取请求的主机)和 MoShiro__WebAuthnOrigins(缺省取 PublicUrl 的来源)。**passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。**只有和后端同站的页面能用 passkey 登录,管理端在「添加其他后端」(跨域)时不显示这个入口。
  • 会话记录登录方式(password 或 passkey,sessions.auth_method),只用于审计和显示;API key 不是用户,不能注册 passkey。
  • 测试:SoftwarePasskey 在测试里模拟认证器走完整的注册和登录,覆盖错误的挑战、来源、RP、签名、缺少 UV、挑战重放、计数器回退、只能管理自己的 passkey;管理端用 Chromium 的 WebAuthn 虚拟认证器验证过注册和登录。

4. passkey 用在哪里(没有提权窗口)

原则:passkey 不是 Backend 用来拦操作的一道门(Backend 被攻破时,它自己做的拦截也会被一起绕过),而是节点或密钥持有者需要它才能验证、解密的东西。

事 谁验证 要 passkey
建立、更新操作员集合(信任根) 节点验链 是(现在唯一做了的)
extension 会话(web ssh 等) 节点验 passkey 签名的临时密钥 是(还没做)
读加密存储的数据 私钥持有者 是(还没做)
吊销或删除节点、账号、API key、加入码、设置 只有 Backend 否,只看能力
  • 这些「只有 Backend」的事靠能力限权:API key 只带需要的能力,登录会话按角色。密码泄露的后果是这些能力被滥用,改不了信任根,也用不了以后的 extension。
  • 唯一的风险是往账号里塞一把 passkey 再等人签名:信任页的提案会列出新增和移除的凭证,签名之前要看一眼是不是自己加的;签了才算授权。

5. API key

5.1 类型

类型 创建时要不要 passkey 能力
普通 key 不要 勾选的非信任类能力(只读、运维、CI 发布等),只能是创建者能力的子集
enroll key 要,签一次 勾选的非信任类能力 + fleet.enroll(可吊销自己加的节点)
  • 现有令牌迁移(破坏性):read → 全部 *.read;admin → 全部非信任类能力;release → release.publish。迁移后,旧的 admin 令牌不能再生成加入代码,需要换成 enroll key。
  • 每把 key 可设:有效期、可选的 IP 白名单(CIDR,待确认)、enroll 类的数量上限和单券 TTL 上限;预留:资源范围、速率和数据配额(见 5.4)。

5.2 enroll key:自动化添加节点

当前实现:它就是一把带 fleet.enroll 的普通 API key,用 POST /api/admin/join-tokens 创建加入码,响应里直接给出安装命令(带创世指纹、属主和脚本哈希,没有任何私钥)。因为创建码不是信任动作,所以不需要 passkey;CI 和脚本照常工作。信任在两处:命令里的创世指纹和属主由面板生成,节点注册时固定;以后改属主和集合要操作员 passkey 签名。

早先设计过的「委托密钥」(passkey 签一次委托,API key 自己签入网券,节点验证整条链)属于第 7.1~7.3 节的完整设计,当前没有采用,见第 0 节的取舍。

5.3 创建界面

  • 名称、有效期、可选 IP 白名单;enroll 类多出「最多加几个节点」「单券有效期」。
  • 能力用分组复选框:全局全选(带半选状态)、组内全选、反选、搜索;预设按钮一键套用:只读、运维、接入、CI 发布、全部;超出创建者能力的项灰色不可选,需要 passkey 的项带标记(⚿),勾选后创建时提示。
  • 创建后只显示一次 key,同时列出它拥有的能力和过期时间。
  • 列表:API key、加入代码、节点、审计日志都支持多选和全选做批量操作(撤销、吊销、删除、筛选)。API key 列表显示能力数、最后使用时间、过期时间。

5.4 预留:外部 webhook 回调与数据拉取

后面要让外部系统能订阅事件、触发回调、拉数据。接口和权限现在先留,实现放后面;全部发生在 Backend 里,节点零开销。

  • 能力(已列入能力表,标预留):
    • events.read:拉取事件流,游标分页(GET /api/admin/events?after=<cursor>),适合外部系统轮询。
    • data.export:按线路和时间范围批量拉数据,有限速和配额;data.read 仍然是交互式查询。
    • webhooks.read · webhooks.manage:创建、修改、删除外部回调订阅,以及手动触发一次测试回调(POST /api/admin/webhooks/{id}/test)。 能力在目录里先存在,旧 key 以后可以直接带上它们,不需要再迁移。
  • key 上预留的字段:
    • resource_filter {agent_ids?, labels?}:限制这把 key 能看到哪些节点、线路;事件和 webhook 同样受它限制。
    • rate_limit(每分钟请求数)和 quota(每天拉取的行数或字节数):超出返回 429 带 Retry-After,计入审计。
  • webhook 的形状:订阅 {id, url, events[], secret, 创建者(key 或账户), enabled}。事件:节点上线或下线、线路告警阈值、安全事件(节点准入、吊销、key 创建)等。投递是 POST JSON,头带 X-MoShiro-Signature: sha256=<HMAC(secret, 时间戳 ‖ body)>、时间戳和投递 id;至少一次投递、指数退避重试、接收方按投递 id 幂等。
  • 订阅受创建者能力约束:只能订阅创建者自己读得到的事件,不能借 webhook 越权。
  • SSRF 约束(TODO 里已有):URL 解析后检查地址,只放行公网地址;不跟随重定向到内网;连接和响应有超时和大小上限;secret 只在创建时显示一次。
  • 这些都属于 L1:改的是 Backend 的集成配置,不改变节点信任;但每次增删订阅和改 URL 都进审计日志。

6. 归属与审计

  • 账户表:id、名字、角色、登录方式、passkey;加入代码记 created_by,节点记 added_by;管理页的 Agent、Probe 详情显示「由 xxx 于 xxx 添加」。
  • 审计日志 (时间, 账户, 凭证 id, 动作, 对象, 详情):创建或撤销 key、加入代码、吊销、删除、手填地址、确认更新、因能力不足被拒绝的请求。保留 365 天(同事件表)。
  • 可验证的一层:委托里写进签名它的 passkey 凭证 id,入网券和节点绑定带着它,节点在 hello 里报告「由哪个凭证准入」。「谁加的」不只是 Backend 的一面之词。

7. 入网信任链(完整设计,当前没有采用;当前实现见第 0 节)

第 7.1~7.3 节描述的 Grant、Ticket、Binding、成员证明和节点验证在代码里不存在。要不要叠加回来,取决于你要不要防「被攻破的 Backend 往名册里塞节点」;操作员集合和创世固定已经在,叠加时不用重装节点。

7.1 文档

 ① 操作员集合 OpSet {version, creds:[{id, type=webauthn|ecdsa, 公钥, 角色, 账户, recovery?, max_ticket_ttl, 作用域}]}
    签名人:owner 的 passkey;创世版本由安装时固定的第一个 owner 公钥自证
 ② 委托 Grant {grant_id, 委托公钥 D 或 P, 作用域, 额度, not_before, not_after, 签发凭证 id}
    签名人:passkey(WebAuthn 断言,challenge = SHA256(Grant))
 ③ 入网券 Ticket {ticket_id, K_code 公钥, 角色, controllers, issued_at, expires_at, grant_id}
    签名人:委托私钥。生成加入代码时签,用户那时不用在场
 ④ 节点绑定 Binding {node_id, 角色, 节点密钥公钥, 对等面公钥, ticket_id}   (controllers 随 Ticket 一起被签名,节点绑定引用它)
    签名人:K_code 私钥。它就在安装命令里,节点注册时用一次
 ⑤ 吊销表 RL {version, issued_at, 被吊销的节点公钥指纹[]}
    签名人:passkey 当场签;`enroll` 委托的密钥只能签针对「自己加的节点」的条目(节点校验条目指向的 Binding 属于这份委托)
  • 安装命令里的代码就是 K_code 的种子;Backend 的一次性加入令牌由它派生,所以命令仍然是一行。
  • 所有签名带用途前缀(域隔离),规范化编码;格式不协商、不降级。

7.2 节点验证(本地,Backend 不参与信任)

节点固定创世 owner 公钥。链随期望状态下发(Agent 的 peers、Probe 的 probe_agents 里每个成员带着自己的链),节点在状态同步时验,每个成员验一次、结果进成员表;对等握手(敲门加现有的对端认证)完全不变,没有额外字节和运算。Agent 接纳 Probe、Probe 接纳 Agent,各自验同一条链:

  1. OpSet:从创世公钥起,每个版本由 owner 凭证签、版本递增;节点持久化见过的最大版本。
  2. Grant:签发凭证在 OpSet 里且角色够;WebAuthn 断言有效(rpIdHash、UP/UV 标志、clientData.type = webauthn.get、challenge = SHA256(Grant)、ES256 验签);当前时间在有效期内。
  3. Ticket:由 Grant 的委托公钥签;issued_at 落在 Grant 窗口内;expires_at − issued_at ≤ max_ticket_ttl(来自 OpSet 里签发凭证的限制);节点当前时间 ≤ expires_at。
  4. Binding:由 Ticket 的 K_code 签;节点密钥和对等面公钥与这个成员条目里的一致;角色匹配(Probe 只能是 probe)。握手时对端证明自己持有这把对等面密钥,用的是现有的认证。
  5. RL:指纹不在最新吊销表里(持久化见过的最大版本)。

通过后记进本地成员表(持久化,离线也认),同时进入敲门的名册(KnockGate 的 peers);没通过的成员条目直接丢弃并计数,不回详细原因。Probe 只拨公网地址(拒绝私网、回环、链路本地),解决「被攻破的 Backend 让 Probe 探测客户内网」。

7.3 能强制什么,不能强制什么

  • 节点能强制:谁是成员(链)、时间(Grant 窗口、Ticket 过期、单券 TTL 上限)、角色、吊销。
  • 节点强制不了:数量上限(它不知道别的节点)。由签名人执行(浏览器或 Backend 的 key 路径)。

7.4 现状的缺口:节点没有任何东西可以用来识别 Backend

今天的链路(已核对代码):

 安装      curl https://<面板>/install?code=jt_…  | bash         信任 HTTPS 证书,第一次联系即信任(TOFU)
 注册      Agent 生成 node.key 和 tls.pfx,用 jt_ 码把公钥交给 Backend
 连接      Backend 发挑战,Agent 用 node.key 签名                 只有「Agent 向 Backend 证明自己」
 反方向    Agent 验证 Backend?没有。只靠 wss 的 WebPKI 证书;节点本地只存 node_id、面板地址、自己的证书指纹

后果:Backend 的身份就是「能让 Agent 连上的、证书有效的服务器」。DNS 或前置代理被攻破、被签发了该域名的证书、或 Backend 机器被拿下,攻击者都等于 Backend,可以改 Agent 的 peers 名单(把自己的 Probe 公钥放进去)、下发任务、读全部数据。「换一个 Backend 公钥配自己的私钥」在这里甚至不需要,因为节点从来没有存过 Backend 的公钥。

要求:信任根必须是操作员的 passkey 公钥,在节点第一次安装时固定,之后 Backend 无法替换:

  • 节点收到的所有「改信任」的内容(成员名单、授权、吊销表)都必须带操作员的签名,节点自己用固定下来的创世公钥验证。Backend 只能转发,签不出新的。
  • Backend 被冒充也只能转发操作员签过的东西:已有的授权可以重放(受有效期、吊销表约束),新授权签不出来。
  • 弱点:创世公钥指纹放在安装命令里,命令本身要从可信渠道拿到(管理端复制)。改了命令的人可以换指纹;缓解见第 15 节(静态签名脚本、按 digest 拉镜像),并让管理端展示指纹供核对。

7.5 第一个落地切片(重启、改配置的票和 webssh 先不做,只留接口)

步 内容 验证
a 创世:第一次用 passkey 登记操作员公钥(OpSet v1,自证);安装命令带上指纹 命令里的指纹和管理端展示一致
b Grant:生成加入码时由 passkey 签(challenge = SHA256(Grant));Agent 和 Probe 注册时验签,通过才保存操作员公钥 没有签名或签名不对的码,节点拒绝注册
c 成员名单带签名:Agent 的 peers、Probe 的 probe_agents 每项带自己的链,节点在状态同步时验,不过就丢弃并计数 冒充的 Backend 往名单里加 Probe 公钥,节点不接纳
d 数据加密(PRF)、enroll key、Ticket 作为后续,接口先留在 Grant 里(controllers、数据公钥字段)

不在这个切片里:重启和改配置不需要签名的票;webssh 等 extension 见第 8 节。

7.6 当前实现要点

  • 语句是 key=value 文本,第一行是带版本的标题,签名覆盖整段文本;WebAuthn 的挑战是语句的 SHA-256。库在 Fleet.Identity 的 OperatorTrust(操作员集合的编码、解析、链验证)和 SecureChannel(服务器证明和链路加密),都是只用 BCL 的纯函数,节点和 Backend 共用。
  • 创世:第一次建立信任根时,把账号下所有 passkey 和这个面板的服务器密钥登记成一个操作员集合,passkey 对它签名;创世语句的 SHA-256 就是命令里的 MOSHIRO_GENESIS。之后加删 passkey 或换服务器密钥要签新版本(上一版里的凭证签),页面会提示;签名更新不会让节点重装。
  • 操作员集合版本随期望状态(trust_chain)下发,节点从固定的创世验起,版本比现有的新才采用并保存。
  • 节点注册:fleet-enroll --genesis … --controllers …:先 POST /api/agent/v1/enroll/trust 取链,验过(创世指纹、签名、属主在集合里)才把密钥交出去;之后写 trust.json。没有命令里的指纹(--genesis)就不固定,节点按旧方式工作。
  • 服务器身份和安全链路:ChallengeParams 带服务器的临时公钥和签名(server_eph、server_sig),AuthParams 带节点的临时公钥(node_eph),节点签名的用途是 auth2,覆盖双方的临时公钥;通过后两边用 HKDF 派生每个方向一把 AES-256-GCM 密钥,每帧 计数器(8) ‖ 密文 ‖ 标签(16),计数器严格递增,明文帧、改动、重放、乱序一律断开。集合里没有服务器密钥的节点(旧节点)保持明文,不会被降级成放行。
  • 服务器密钥存在 Backend 的设置里(server.identity_key):它防的是面板被冒充,不是面板被攻破;面板被攻破时它也泄露,那时要由 passkey 签一个换掉它的新集合版本。集合里没有这个面板的服务器密钥的版本,Backend 不收(否则固定了它的节点就认不出这个面板了)。
  • 安装脚本是静态的:不替换任何值,所以 SHA-256 固定;CI 把哈希写进签名的发布清单和发布页,面板的信任页显示它和清单是否一致;命令先下载、核对哈希,一致才执行。
  • 密码登录和 passkey 登录的会话能力相同;创建加入码需要 fleet.enroll,没有提权窗口。passkey 只用在节点自己会验证的事上。
  • 现状的缺口:没有吊销表;删除一把操作员 passkey 只是让它不能再签新版本;任务和配置没有操作员签名;节点没有自证构建;服务器密钥换新要靠签一个新集合版本,忘了签会让固定了集合的节点认不出面板(fail closed)。

8. 节点属主与 extension 授权(预留)

后面会做 extension(web ssh、文件、抓包,见 security.md 第 11 节)。这类能力连管理员都不信任:管理员能删,不能读,也不能用。所以入网链现在就要给每个节点留出「属主」这个概念,即使 extension 还没做,字段也先固定下来,之后不用重装节点。

 属主 controllers = 一组 passkey 凭证 [{credential_id, sign_pubkey(ES256), enc_pubkey?}]
   默认:添加者(签入网券的那个账户)的全部 passkey
   添加节点时可以从已有的 passkey 里选(含全选):允许为该节点绑定一组 passkey,而不是只绑一个人的一把
   写在哪:入网券里(由添加者的委托签名),节点绑定引用它;节点本地持久化,随成员表一起保存

 extension 的授权(每次调用)
   操作员签名的一次性能力令牌 {节点 ID, extension, 命令哈希, not_before, not_after, nonce}
   签名必须是属主里某把 passkey 的 WebAuthn 断言(challenge = 令牌哈希),节点用自己存的属主公钥验
   —— 不是「操作员集合里任意 admin/owner 的 passkey」,而是「这个节点的属主」

 谁能做什么
   属主            启用或使用 extension;增删属主(需要现有属主中的一把签名的 ControllerUpdate)
   admin / owner   只能删除节点或卸载 extension(吊销表,照常当场 passkey);读不到、用不了
   Backend         只搬运。交互式会话的内容端到端加密(浏览器 ↔ extension),Backend 看不到明文

要点:

  • 删除和使用分开:吊销表没有「使用」的含义;账户角色(owner、admin)不赋予任何 extension 权力。owner 想用某个节点的 web ssh,也得是那个节点的属主。
  • 改属主不经过 Backend 的账户系统:ControllerUpdate {node_id, version, controllers} 由现有属主的 passkey 签名,节点持久化见过的最大版本;账户被删除、角色被改都改不了某个节点的属主。
  • 属主是 passkey 凭证,不是账户:账户只是 Backend 里给人看的标签;节点只认公钥。一个人丢了全部属主 passkey,这个节点上的 extension 就没人能用了,要用就得重装节点(和第 3 节的恢复一致),所以添加时建议绑两把以上。
  • 属主字段在**步骤 4(入网链)**就写进 Ticket;extension 本身、ControllerUpdate 和能力令牌放在阶段六。

8.0 范围:这一节先固定协议和要求,extension 本身先不做

  • web ssh、文件、抓包等 extension 现在都不实现,Agent 和 Probe 一样(Probe 是节点,同样要在本地执行下面的要求)。这里只把「谁来强制」和「怎么证明」定下来,以后实现时不改信任链,也不用重装节点。
  • 已经固定在节点本地的字段:controllers(允许授权本节点 extension 会话的 passkey 凭证 id,必须是操作员集合里的凭证)由安装命令带来,节点注册时保存在 trust.json,Backend 之后改不了;从集合里去掉的凭证不再有效。每个 Agent 和 Probe 都必须有属主,至少一把——而且这是节点自己的要求,不是 Backend 的要求:节点启动时检查本地固定的属主,没有(或者属主的 passkey 已经不在操作员集合里)就不运行(Agent 和 Probe 共用 Fleet.Hosting 里的 OwnerGuard;配置 fleet.require_owner,MOSHIRO_ALLOW_NO_OWNER=1 放行)。面板被攻破也改不了节点本地的属主。属主是节点敏感内容(extension 会话、加密数据)绑定的人——这些内容只有属主的 passkey 能授权、解密,没有属主的节点没有人能用它的敏感能力,也就不该存在。接收者名单(recipients)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。

8.1 添加节点时选属主

  • API:POST /api/admin/join-tokens 的 controllers: [credential_id…]:不给就是操作员集合里的全部凭证;给了就必须非空,且每个 id 都是操作员集合里的凭证。还没有建立信任根、或者一把属主都没有时,面板提前拒绝创建加入码(400,字段 controllers)——这只是尽早失败,免得装出一个起不来的节点;真正的强制在节点(上面)。MoShiro__RequireOwner=false 可以关掉这个提前检查(只给没有信任根的老部署)。
  • 逆推出绑定关系:节点握手时把本地固定的属主(passkey 凭证 id)报给面板(hello 的 owners),面板顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上,显示在「系统 → 版本」的「属主」列(对不上任何已注册 passkey 的显示「未知凭证」)。上报只是自述,真正的授权由节点自己校验 passkey 签名;以后给节点更多的接口和操作(extension 会话、加密内容)时,据此决定谁有资格用,不会越权到别人的节点。
  • 界面:「添加 Agent / Probe」对话框里一个「属主(passkey)」多选列表,必选(一把都不选时创建按钮不可用);只是选,不用触碰。选定的写进安装命令,节点装上时固定。
  • 安装端也强制:install.sh、install.ps1、容器入口缺了 MOSHIRO_GENESIS 或 MOSHIRO_CONTROLLERS 就不装(只有没有信任根的老面板才用 MOSHIRO_ALLOW_NO_OWNER=1);fleet-enroll --genesis 不带 --controllers 也拒绝。
  • 设计意图(还没做):属主的 passkey 是敏感内容的钥匙。节点和 Probe 这一侧要在宿主侧拿到属主 passkey 的证明,才可以解密、才可以开 extension 会话;Backend 在这里只是搬运,见第 8.2、8.4 节。
  • 以后改属主要现有属主的 passkey 签名(ControllerUpdate,没有实现);管理员改不了。

8.2 谁来强制:节点本地,不是 Backend

web ssh 这类会话要不要加密、要不要验证,由 Agent(和 Probe)自己要求,不是 Backend。Backend 是被防的一方:让它决定,被攻破的 Backend 一句「这次不用加密」或自己充当会话的一端就绕过去了。所以要求落在节点本地,Backend 只能转发:

节点本地强制的要求 为什么不能交给 Backend
没有操作员的现场证明就不启动会话 Backend 说「已经授权过了」不算数
会话密钥只和证明里的那把密钥协商,不和 Backend 转来的任何公钥协商 否则 Backend 换一把自己的公钥就成了中间人
加密字段缺失就拒绝,没有明文降级 防降级
能开哪些 extension、哪些 passkey 能授权,只看本节点属主(passkey 签的 controllers) Backend 改不了属主,账户角色也不赋予任何权力
这些要求写在节点本地(编进二进制或本地 trust.json),Backend 下发的配置、任务、更新清单都覆盖不了 配置是 Backend 能碰的东西

要求证明的方向是反的:节点向请求者要证明,不信任 Backend 的转述。 一次会话由节点先出题(nonce 加它的临时公钥,用节点身份密钥签名),浏览器把这个 nonce 和自己的临时公钥一起放进 passkey 断言的挑战里(见 8.3 的流程);节点验:断言来自固定的操作员集合里的凭证、要求用户验证、挑战里的节点 id、extension、过期时间都对得上、这把凭证在本节点的 controllers 里、nonce 是它自己刚出的且没用过。任何一条不满足,节点直接拒绝,不看 Backend 的任何说法。

Backend 在这件事里仍然管:谁有资格发起请求(一项 extensions.use 能力)、审计、限流、转发密文。这些只挡「不该问的人」;真正的放行始终是节点本地验过证明之后。

交互式会话和静态数据用不同的密钥:

  • 交互式会话(终端、ssh):现场签名的临时密钥协商,不依赖 PRF,passkey 只用来签名授权,不参与解密,所以不支持 PRF 的 passkey 也能用,还有前向保密。
  • 静态数据(抓包文件、会话录像):需要事后解密,才用接收者名单里的公钥加密(recipients,来自 WebAuthn PRF 或浏览器里的不可导出密钥,见 8.3)。

8.3 数据用属主的私钥解密:Backend 解不开,也改不了

目标:交互式会话(web ssh、终端)、以及以后 extension 产生的数据(文件、抓包),只有属主 passkey 对应的私钥能解开。Backend 被篡改或被攻破,拿到的只有密文,且改动密文会被发现。

 浏览器(属主)                         Backend(只搬运)                  节点上的 extension
   ⓪ 节点先出题:nonce 和它的临时公钥 n,签名后经 Backend 转给浏览器(见 8.2:要求证明的是节点)
   ① 生成会话临时 ECDH 密钥 e
   ② passkey 对「e 的公钥 ‖ 节点 ID ‖ 命令哈希 ‖ 节点出的 nonce ‖ not_after」做 WebAuthn 断言(能力令牌)
   ──────────── 能力令牌 + e 公钥 ───────▶ 转发 ─────────────────────────▶ ③ 验:断言来自本节点属主
                                                                              + 令牌新鲜、nonce 未用
   ◀── 节点的会话公钥(节点身份密钥签名)── 转发 ◀────────────────────────── ④ 节点生成临时密钥 n,签名后返回
   ⑤ 双方 ECDH(e, n) → HKDF(绑定两边公钥、令牌哈希、用途标签)→ 会话密钥
   ⑥ 之后双向都是 AEAD 密文(ChaCha20-Poly1305 或 AES-GCM,BCL 自带),每帧带序号,
      序号进 AAD,重放、乱序、丢帧、篡改都会解密失败
  • 终端输出等从节点流向属主的数据,只能被拿着会话私钥 e 的浏览器解开;Backend 没有 e,也没有节点的 n,算不出会话密钥。
  • 临时密钥带来前向保密:长期的 passkey 只用来签名授权,不参与解密,所以以后 passkey 泄露也解不开历史会话。
  • Backend 篡改密文、重放、乱序:AEAD 校验失败,会话立即断开并计数。Backend 仍然能做的:拒绝服务、延迟、丢包,以及看到元数据(谁连哪台节点、什么时候、流量大小)。
  • **静态数据(以后的抓包文件、会话录像)**需要事后解密,不能只靠临时密钥:属主记录里的 enc_pubkey(P-256 ECDH 公钥)就是为此预留的,数据用它封装(ECIES:临时密钥 + HKDF + AEAD)。私钥有两种来源,按设备能力选:
    1. WebAuthn PRF 扩展:由 passkey 派生私钥种子(浏览器用 prf 取 32 字节,经 HKDF 得到 P-256 私钥);私钥不落盘,每次要解密时再触控一次。设备不支持 PRF 就退到下一种。
    2. 浏览器里的不可导出密钥:添加属主时在这台浏览器生成,只能在这台浏览器用,丢了就解不开。 属主记录里没有 enc_pubkey 的节点只支持实时会话,不能存加密的静态数据。
  • 加密发生在属主这一侧的浏览器和节点上的 extension 之间,不经过 Backend 的任何账户、角色、会话机制,所以改 Backend 的数据库或代码也无从下手。

8.4 数据边界:哪些明文,哪些加密,谁能解

数据 形态 谁能读
监测数据(延迟、丢包、吞吐曲线、事件、主机资源汇总) 明文:Backend 要做分析、聚合和展示页 有 data.read 等能力的人和 key
extension 会话(web ssh、文件、抓包等,以后)、原始 payload 端到端加密:节点加密给接收者名单里的公钥,Backend 只存和转发密文 只有接收者私钥的持有者
  • 读取权不是能力,是私钥:Backend 不持有任何接收者私钥,所以 data.read、data.export 对加密流只返回密文。API key 解不开,除非你登记过读者密钥。
  • 接收者名单由操作员固定(做加密时和 controllers 一样,写进安装命令并固定在节点本地;现在还没有加密功能):
    • 默认只有操作员自己的 passkey(经 WebAuthn PRF 派生的公钥)。
    • 接入密钥签出来的码,名单必须和操作员签的接入委托完全一致:它能让节点加入,不能让节点把数据加密给自己。
  • 读者密钥(预留):自动化想读明文,要操作员用 passkey 显式登记一把读者公钥(范围、有效期),私钥由读者自己保管;吊销只管之后的数据,已经加密给它的历史数据它仍然能解。
  • 管和读互不相通:passkey 或接入密钥能让节点加入和批准,但不能解密;解密靠私钥,Backend 被攻破也拿不到。

9. 流程

流程 步骤
引导 初始 owner 密码登录 → 注册第一个 passkey → 一行安装命令带创世公钥 → 节点首次注册时固定(和加入代码同一信任通道)
加节点(人) 接入窗口内点「添加」→ 浏览器用 D 签入网券 → 生成命令(含 K_code 种子)→ 用户一行安装 → 节点用 K_code 签 Binding → Backend 存好并随 peers、probe_agents 下发。人不在场也行(券在有效期内);过期就重新生成
加节点(自动化) enroll key 调 POST /api/admin/join-tokens → Backend 用种子现算签名密钥签券 → 其余同上
吊销 吊销窗口内当场 passkey 签 RL → 全部节点持久化最大版本 → 被吊销的节点连不进来,旧表复活不了它。enroll key 可以吊销自己加的节点
增删 passkey、换角色 owner 当场触控签新 OpSet 版本 → 节点自己同步,不用重装
恢复 passkey 全丢:用离线恢复密钥签新的操作员集合,重新加 passkey(节点不用重装)。恢复密钥也丢了才重装节点并换创世公钥(部署脚本 --operator-key)
弱信任(开发、实验室、升级过渡) 节点安装时显式选择不固定操作员,行为回到「信任 Backend」;管理页标「弱信任」。这是显式降级,不会静默发生

10. 存储

  • Backend:accounts、credentials(passkey 公钥、计数器)、sessions、api_keys(认证值哈希、能力、委托、IP 白名单、有效期、最后使用)、grants、tickets、opsets、revocations、audit_log;join_tokens、agents、probes 加归属列和 Binding。
  • 节点本地(ConfigStore):创世公钥、见过的最大 OpSet 和 RL 版本、成员表(含准入凭证 id)。

11. 落点(不新增项目)

机制 落点
WebAuthn 断言、OpSet、Grant、Ticket、Binding、RL 的编码和验签(纯函数,只用 BCL 的 ECDSA) Fleet.Identity
trust 状态类型(OpSet + RL)、成员条目带链、同步时验证、成员表持久化 Fleet.Link 的 StateHandler(对等握手不变)
能力常量、Principal.Has、端点 .Requires、对账测试 MoShiro.Protocol、Backend.Api
账户、passkey 注册与登录、审计、API key Backend.Application、Storage.Sqlite、Backend.Api
管理端:passkey 登录与注册、信任页(列出新增和移除的凭证)、API key 创建界面(预设、全选)、审计页 web/admin
安装命令带创世公钥和 K_code;fleet-enroll 签 Binding packaging/install、Fleet.Enrollment.Client

12. 实施顺序(每步可独立验证和推送)

  1. 能力模型与守卫:Capability、Principal.Has、端点声明、对账测试、API key 创建界面(预设、全选、能力子集校验)。不改节点。已实现(破坏性:旧令牌作废,数据库迁移 v11 重建 api_tokens)。登录会话在步骤 2 之前拥有全部能力(包括信任类),信任类的 passkey 要求从步骤 2 起强制。IP 白名单推迟:本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置,才做得对。events.read 对应现有的 GET /api/admin/events,不是预留。
  2. 账户、passkey、会话分级、审计:WebAuthn 注册与登录、审计日志、归属列和详情页显示。已实现:passkey 登录(第 3.1 节)、审计日志(GET /api/admin/audit,写操作和登录自动记录,保留 365 天)、归属(加入代码的创建者、节点的添加者)。
  3. 操作员集合与创世公钥固定(落地切片见第 7.5 节):fleet-enroll 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。
  4. 入网链:Grant、Ticket(含属主 controllers)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;enroll key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。
  5. 吊销表与操作员集合更新;自动化夹具(测试里的软件 ECDSA 凭证);安装脚本的供应链(第 15 节:静态签名脚本、按 digest 拉镜像)。

实现状态:能力模型、passkey 登录、审计、操作员集合与创世固定、服务器身份和安全链路、静态安装脚本和哈希核对、属主固定都已实现(第 0 节);成员证明(第 7.1~7.3 节)没有采用;吊销表、任务和配置的操作员签名、节点自证构建、extension 属主的实际强制还没有做。

13. 局限

  • 数量上限只能由签名人执行,节点只强制时间;窗口过期后设备被攻破的人,最多还能多签有效期内的入网券(受节点时钟约束)。
  • 节点的时钟被攻破或严重偏差会影响 Ticket 过期判断。
  • WebAuthn 只验 ES256 和 attestation = none,不验设备证明。
  • passkey 和恢复密钥都丢失才需要重装节点;某个节点的属主 passkey 全部丢失,这个节点上的 extension 就没人能授权。
  • enroll key 是持有者凭证:谁拿到种子 S,谁就能在它的范围和有效期内加节点,必须按密码对待。

14. 节点信任状态上报

Backend 被篡改时,你需要一个不经过 Backend 账户系统的信号。节点把自己的信任状态报给面板,面板显示对比:

  • 节点在 hello、状态变化时和每 5 分钟(不进每次心跳)上报:创世公钥指纹(前 8 字节)、见过的操作员集合版本和吊销表版本、成员数、是否弱信任、各原因的拒绝计数。每次不到 100 字节。
  • 面板的节点列表显示一个信任状态:正常、版本落后(节点的操作员集合或吊销表版本低于面板持有的)、弱信任、时钟偏差过大(超过入网券 TTL 的一半,或超过 60 秒;面板用 hello 的 NowUtcMs 算)、拒绝计数上升、超过 10 分钟没有上报。
  • 这些是节点经认证的链路上报的,被攻破的 Backend 伪造不了,但可以压住不显示——所以「长时间没有上报」本身也是红色,面板同时显示 Backend 自己持有的版本和节点上报的版本,对不上就是信号。
  • 被攻破的节点可以说谎,但它说不了别的节点的状态。

15. 安装脚本的供应链

一行安装要执行 Backend 吐出的脚本、拉仓库里的镜像。Backend 或仓库被攻破,就能在新节点上执行任意命令。收束办法,优先级最高的是第一跳的哈希核对:

① 脚本是静态产物,哈希固定(优先做)

  • 不在服务端做字符串替换。CI 构建时算出 install.sh、install.ps1 的 SHA-256,写进发布清单(发布密钥签名),同时公布在 Gitea 发布页——这条渠道独立于 Backend,这是哈希核对有意义的前提:哈希若和脚本从同一处来,攻击者两个一起换就行。
  • 参数(代码、面板地址)通过命令行传入,代码也因此不再出现在 URL 和反向代理日志里。
  • 管理端给的命令把「下载、核对、执行」合成一行,哈希核对不通过就不执行:
curl -fsSL https://panel/install -o /tmp/moshiro-install.sh && echo "<sha256>  /tmp/moshiro-install.sh" | sha256sum -c - && sh /tmp/moshiro-install.sh '<code>'

macOS 用 shasum -a 256 -c,Windows 用 Get-FileHash 比较,不通过就 throw。命令里的 <sha256> 由 Backend 填入,所以管理端同时标出「与 Gitea 发布页的 SHA-256 一致」并提供链接;严格的人对照一次即可(脚本是静态的,哈希在一个版本内不变,只需要核对一次)。

  • 进一步:操作员确认这个版本的发布时(update.confirm,要 passkey),把脚本哈希一起签进去,Backend 被攻破也改不了命令里的哈希;节点和安装命令里的创世公钥指纹(第 7.5 节)来自同一次签名。

② 镜像按 digest 拉取,二进制按 SHA-256 校验

  • 脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、seq 防回滚),用脚本里内置的发布公钥验签,再按清单里的 digest 拉镜像(repo@sha256:…);原生二进制则核对清单里的 SHA-256 再安装。Backend 改不了 digest,最多让节点装到一个旧的合法版本。
  • 发布公钥因此既在脚本里,也编进 Agent 和 Probe 二进制(一个常量,来自 packaging/update/release-key.pub.pem),节点之后的更新由节点自己验,不依赖宿主机更新器和 Backend 转发的内容。发布密钥轮换时,新公钥必须由旧密钥签名的清单带下来,否则节点不认。
  • 更新建议要求双签:CI 的发布签名,加操作员 passkey 对清单摘要的批准签名。只攻破 CI 或只攻破 Backend 都不够。

③ 其余

  • 角色(Agent 还是 Probe)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。
  • Windows 版同理,另外可以加 Authenticode 签名。
  • 今天的现状:一行安装不装更新器,也不带发布公钥,节点自己不验任何签名;这一节是要补的缺口。

16. 效率(运行时开销与日常摩擦)

目标机器是 1 核 1 GB 的小鸡,甚至路由器。原则:重活留在 Backend 和浏览器,节点只做最小的验证;节点不增加线程、定时器、依赖,数据路径上不增加任何东西。

节点的预算(实现时当成验收标准)

项 预算 怎么做到
数据路径 零增量:UDP 数据面、测量、对等握手、敲门都不变 链随期望状态下发,在状态同步时验,不在握手里验(见 7.2)
常驻内存 增量 ≤ 1 MB 成员表上限 256 条(每条几百字节)、操作员集合上限 64 个凭证;不缓存原始链,只存验过的结果和哈希
二进制 增量 ≤ 150 KB 只用 BCL 的 ECDSA、HKDF、SHA-256;WebAuthn 只验断言(固定结构的 authenticatorData、对 clientDataJSON 只比三个字段),不带通用 CBOR 解析;注册和 attestation 解析只在 Backend 和浏览器里
CPU 每个成员一次完整验证(约 4–6 次 ECDSA 验签),小核心(Cortex-A53、MIPS 级)上 ≤ 100 毫秒;不重复验 只在状态同步时做;文档版本或成员链哈希没变就跳过;操作员集合或吊销表版本变化时,只重验受影响的成员
线程和定时器 不新增 信任状态上报搭现有心跳计数(每 N 次心跳带一次,约 5 分钟);验证在已有的状态处理里串行完成
流量 链约 2 KB,只在成员首次出现或变化时随期望状态下发;上报每次 < 100 字节 同步时带成员标识(哈希),未变化的不重发
输入上限 单条链 ≤ 8 KB、成员 ≤ 256、凭证 ≤ 64、单次同步最多验 N 个成员 超限在做任何密码学运算之前拒绝;Backend 也只下发它自己预先验过的链(挡掉垃圾,节点仍然独立验证)

把预算变成测试

  • Fleet.Tests:链验证耗时断言(CI 机器上每个成员 ≤ 20 毫秒)、输入上限的拒绝测试、同一份状态重复同步不重复验证(计数断言)。
  • 现有 ArchitectureTests 继续禁止节点引用新包;加一条检查:节点项目不引用 WebAuthn/CBOR 之类的库。
  • CI 记录节点二进制大小和启动后的常驻内存,增量超出预算就失败。

Backend 侧

  • WebAuthn 验证只发生在登录;API 请求认证是一次哈希查表,主体缓存 30 秒并在撤销时主动失效。
  • enroll key 签一张券是一次 HKDF 加一次 ECDSA 签名(不到 1 毫秒)。
  • 审计日志的写入并进 SQLite 单写入者的批量提交,不拖慢摄取路径。
  • webhook 投递、数据拉取都在 Backend 里完成(见 5.4),对节点零开销。

日常摩擦

  • 进页面不要求 passkey,只有给操作员集合签名时才触碰。
  • 批量操作一次触控:批量吊销是一张吊销表一次签名,批量添加是窗口内连续签券;列表都支持全选。
  • 自动化走 enroll key,不需要人在场;其余自动化走普通 key。
  • 离线恢复密钥(见第 3 节)避免因为 passkey 丢失而重装节点。

17. 待确认

  1. API key 的 IP 白名单要不要做(倾向做,放在第 1 步)。
  2. 迁移后,现有 admin 令牌不能生成加入代码,需要触控创建 enroll key 来替换。
  3. owner 与 admin 之外要不要加只读的认证角色(目前用 read 能力的 API key 满足)。
  4. 属主的默认值:添加者的全部 passkey,还是只有签这次入网券的那一把;添加时最多允许绑定几把。
  5. ControllerUpdate 要几把属主签名(一把还是多数)。
  6. 静态数据的私钥来源:优先 WebAuthn PRF,不支持时是否接受「浏览器里的不可导出密钥」(只在这台浏览器能用)。
  7. 会话 AEAD 选 ChaCha20-Poly1305 还是 AES-GCM(两者 BCL 都有;小机器没有 AES 硬件指令时前者更快)。
  8. 信任状态上报的阈值:时钟偏差红线(入网券 TTL 的一半与 60 秒取小)、「长时间没有上报」是 10 分钟,是否合适。
  9. 安装脚本的哈希在哪里公布(Gitea 发布页之外还要不要放 DNS TXT 或另一个独立渠道)。
  10. 节点预算的数字(常驻内存 ≤ 1 MB、二进制 ≤ 150 KB、每个成员验证 ≤ 100 毫秒、成员上限 256)是否合适;超出预算时优先砍什么(比如先去掉操作员集合的 64 个凭证上限换成更小的)。
  11. webhook 和数据拉取的配额默认值(每分钟请求数、每天行数)和事件清单。

16. 节点二进制绑定

目的:被换掉的、版本被改的、降级的二进制,不能继续当这个节点运行。

 CI 构建:算出镜像里节点二进制的 SHA-256,写进签名的清单(binaries)
 宿主机更新器:把验过的清单和签名写进节点只读挂载的 update-status/(installed.manifest、installed.sig)
 节点握手(auth 里):binary = 正在运行的二进制的哈希(Linux 读 /proc/self/exe,是进程启动时映射的那个映像)
                       release、release_sig = 上面那份清单和签名
 Backend:
   ① 第一次握手成功 → 绑定 (节点, 哈希, 清单 seq)
   ② 以后每次握手:哈希和绑定一致 → 放行
   ③ 不一致 → 必须是一份 CI 签名清单(用内嵌的发布公钥验)里列出的官方二进制,并且清单 seq 不低于绑定时的(合法升级,改绑);
      否则拒绝连接,节点页标红「不符」。不许降级。
  • 清单是节点带来的,Backend 自己验签,所以不依赖面板有没有收到过这次发布;被攻破的 Backend 也加不进「官方」哈希(没有 CI 私钥)。
  • 旧节点(不上报哈希):没绑定过就放行(显示未绑定);绑定过的不许退回不上报。
  • 没有更新器的节点(二进制安装)没有清单,首次握手按上报的哈希绑定(显示「首次绑定」);手工升级之后哈希变了,会被拒绝,管理员在「系统 → 版本」点「接受当前二进制」清除绑定,下一次连接重新绑定。
  • 策略 MoShiro__BinaryPolicy:enforce(默认)拒绝;warn 只记录不拒绝;off 不检查。
  • 边界:哈希是节点自己报的。一个被换进来、故意撒谎的恶意二进制可以照抄原来的哈希,Backend 分辨不出——上报者就是被怀疑的对象。要堵这一点,必须让容器之外的东西背书:宿主机更新器用安装时绑定的密钥对「当前运行的镜像 digest + 挑战」签名,由 agent 转发;容器里的进程被换掉也伪造不了。这一步还没做。