Files
MoShiro/docs/access.md
T
justme a2f88a344b
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled
feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
Join codes need no passkey touch and carry only public values (genesis
fingerprint, owner credentials, installer hashes). Nodes pin the genesis and
owners at enrollment and only follow passkey-signed operator set versions.
The panel's server key is listed in the set; pinned nodes require its
signature and run the control link under AES-256-GCM. Password sessions carry
no capabilities. The updater adopts a next_key from a signed manifest.

Bump version to 0.2.0. [CI SKIP]
2026-10-08 03:21:44 +08:00

50 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 登录;信任类操作要提权窗口(passkey 触碰)

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

还没有的:吊销表;任务和配置内容的操作员签名;节点自证跑的是官方构建;extension 本身和属主的 controllers、接收者的实际加密(只是被固定在节点本地,没有行为)。

1. 名词

名词 含义
账户 一个人。有角色、若干登录方式、若干 passkey
角色 anonymous、admin、owner,决定能力的上限
能力(capability) 一项具体权限,如 fleet.write。端点声明自己需要哪项能力
会话 登录得到的状态,只带 L1 能力
提权窗口 用 passkey 触控换来的、有时限的信任类能力(类似 sudo)
操作员集合(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 外的全部;信任类(fleet.enroll、fleet.revoke)要提权
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 的断言,也不该让 IdP 或密码进入信任域。
  • 引导:初始管理员用密码登录,注册第一个 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),只有 passkey 登录的会话以后能提权;API key 不是用户,不能注册 passkey。
  • 测试:SoftwarePasskey 在测试里模拟认证器走完整的注册和登录,覆盖错误的挑战、来源、RP、签名、缺少 UV、挑战重放、计数器回退、只能管理自己的 passkey;管理端用 Chromium 的 WebAuthn 虚拟认证器验证过注册和登录。

4. 提权窗口(信任类能力各管各的)

窗口 覆盖 默认时长 行为
接入 fleet.enroll 15 分钟(5–60 可调) 窗口内连续生成代码不再弹 passkey
吊销 fleet.revoke 5 分钟 破坏性操作,窗口更短
凭证 accounts.manage、创建带信任类能力的 key 无窗口 每次都要当场触控
  • 窗口互不覆盖:开了接入窗口不能吊销节点。
  • 「用到才提权」:页面照常打开,点信任类操作时没有窗口才弹 passkey,通过后接着做原来的动作。
  • 管理端顶部显示生效的窗口和剩余时间,每个窗口有「立即结束」。
  • 接入窗口的实现:一次触控让 passkey 签一份委托(见第 7 节),授权浏览器里生成的不可导出临时密钥 D;窗口内用 D 签入网券,到期自动失效。
  • 默认值待确认(15 分钟、5 分钟)。

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 只是让它不能再签新版本;任务和配置没有操作员签名;节点没有自证构建;服务器密钥换新要靠签一个新集合版本,忘了签会让固定了集合的节点认不出面板(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 之后改不了;从集合里去掉的凭证不再有效。默认是操作员集合里的全部凭证,空 = 不允许任何 extension 会话。接收者名单(recipients)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。

8.1 添加节点时选属主

  • API:POST /api/admin/join-tokens 带可选的 controllers: [credential_id…];缺省 = 操作员集合里的全部凭证。校验:每个 id 必须是操作员集合里的凭证。
  • 界面:「添加 Agent / Probe」对话框里一个「属主(passkey)」多选列表;只是选,不用触碰。选定的写进安装命令,节点装上时固定。
  • 以后改属主要现有属主的 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 节)、提权窗口(接入 15 分钟、吊销 5 分钟、凭证类 60 秒用一次;窗口属于会话,在内存里,API key 不受影响;创建带信任类能力的 key 和增删 passkey 要凭证窗口,第一个 passkey 例外)、审计日志(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 验证只发生在登录、提权、创建 key;API 请求认证是一次哈希查表,主体缓存 30 秒并在撤销时主动失效。
  • enroll key 签一张券是一次 HKDF 加一次 ECDSA 签名(不到 1 毫秒)。
  • 审计日志的写入并进 SQLite 单写入者的批量提交,不拖慢摄取路径。
  • webhook 投递、数据拉取都在 Backend 里完成(见 5.4),对节点零开销。

日常摩擦

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

17. 待确认

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