Client-side hardening of the CPX airport plugin, squashed from 46 commits
forked at 89c2bb0e. Behaviour changes:
- Unified operation model (§0.4/0.5): one deadline + one AbortSignal + one
persistence commit per operation; per-plugin lock → vault lock hierarchy;
tombstone on delete; every wait (lock, DNS preflight, proxy resolution,
vault decrypt) is bounded by the same budget.
- Routing: direct/proxy auto-fallback with a pre-send guard; proxied https
builds its own CONNECT tunnel (an aborted hung CONNECT closes its socket);
invalid local-proxy ports are refused instead of falling back to :80; the
core's inbound credentials are carried to the local proxy; NAT64 and
site-local IPv6 ranges are non-public.
- Gateways: multi-gateway recovery with one rediscovery per operation,
normalized endpoint paths, signed discovery documents (Ed25519, seq/digest
accept/align/rollback/equivocation), commit order vault → plugin.yaml.
- Subscriptions: a fetched subscription is validated by the core (mihomo -t)
against the current override set before it replaces the profile, inside
the profile write critical section (profile.yaml and override.yaml share
one write queue); schedule fields are read at write time; the first
subscription is activated through the real switch flow; profile deletion
removes the record last so any failure stays retryable.
- Devices: a re-login that replaces a still-valid device records it in the
vault (staleDevices) and retires it after the login, after later
successful fetches and on removal; enroll compensation restores the old
vault and keeps an un-revoked new device for retirement.
- Vault: on Linux the vault is persisted only behind a system secret store
(backend name + ciphertext-prefix canary); otherwise it stays in memory.
A cache-miss read releases the caller at the budget while the lock is held
until the decrypt ends.
- Config caches (plugin.yaml, profile.yaml, override.yaml) can no longer be
rolled back by a late cold read.
- Reference gateway and provider guides updated (deploy contract, discoveryUrls
same-origin rule, /revoke after re-login); https-proxy-agent dropped.
Reviewed in a Codex loop (gpt-6, 38 calls): 74 findings, 68 fixed and
verified, 6 invalid, no backlog. Tests: vitest 501, gateway 106.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
40 KiB
机场服务端对接指南(v2)
本文面向机场/服务商的服务端开发人员。v2 协议对应客户端 spec:cpx-plugin/2。
相关文件:
- 参考服务端:
deploy/gateway/ - 英文版:
PROVIDER_INTEGRATION_v2.md - 签名测试向量:
src/main/resolve/plugin/__fixtures__/sign-vectors.json
开发过程中曾有一版 v1 方案(账号密码 + 加密容器)。该方案已弃用,当前代码库未包含 v1 实现。
1. 接入模型
v2 不再把真实订阅 URL 或 API 域名写入客户端文件。用户导入的 .cpx 只包含登录入口和服务商展示信息;登录通过系统浏览器完成;客户端本地生成 Ed25519 设备密钥;后续订阅更新通过网关的 challenge/config 流程完成。
服务端需要提供三类能力:
| 模块 | 所在域名 | 用途 |
|---|---|---|
| OAuth authorize 登录页 | 登录域名,即 .cpx 中 loginUrl 的 host |
用户登录,签发一次性 code |
/.well-known/cpx-gateway |
同登录域名、同端口 | 返回当前网关 origin 和端点路径 |
| 网关端点 | 网关域名,可与登录域名不同 | 设备注册、发放 nonce、拉订阅、解绑设备 |
登录域名是信任根,会固化在已经发出的 .cpx 文件中;网关域名由 /.well-known/cpx-gateway 动态发现,可以替换。
| 故障位置 | 影响 |
|---|---|
| 网关域名 | 客户端可经登录域名重新发现新网关 |
| 登录域名 | 已登录设备可继续访问现有网关;无法新登录、重登,也无法重新发现网关 |
| 两者都故障 | 客户端失联,需要重新分发新的 .cpx |
端到端流程:
安装:
用户导入 .cpx
客户端校验描述文件,创建本地记录,不联网
登录:
1. GET https://<login-host>/.well-known/cpx-gateway
2. 客户端生成 Ed25519 设备密钥和 deviceId(UUIDv4)
3. 系统浏览器打开 loginUrl?response_type=code&code_challenge=...&state=...
4. 用户在服务端页面登录,服务端 302 回 http://127.0.0.1:<port>/callback?code=...&state=...
5. POST {gateway}/enroll,用 code 和 PKCE verifier 注册设备
6. POST {gateway}/challenge,领取一次性 nonce
7. POST {gateway}/config,签名后拉取 Clash YAML
更新:
定时重复 challenge -> config,不再打开浏览器
重新登录:
服务端返回 {"error":"revoked"} 或 {"error":"device_revoked"}
客户端标记为需要重新登录,用户重新走登录流程
删除:
客户端尽力调用 /revoke,然后删除本地记录
2. 参考服务端
deploy/gateway/ 是完整参考实现,包含 Docker 部署、SQLite 账号库和 cpx-admin 管理命令。新接入建议先跑通这套服务,再接入自己的面板。
主要文件:
| 文件 | 说明 |
|---|---|
deploy/gateway/src/auth.mjs |
登录页、authorize、一次性 code |
deploy/gateway/src/gateway.mjs |
enroll/challenge/config/revoke |
deploy/gateway/src/crypto.mjs |
PKCE、Ed25519、签名输入 |
deploy/gateway/src/origin.mjs |
拉取隐藏订阅 origin |
已有面板接入时,通常只需要新增:
- 设备绑定表:
user_id、device_id、device_pubkey、created_at等。 - nonce 暂存:Redis、内存或数据库均可,短 TTL,用完即删。
- OAuth authorize 登录页:复用现有账号密码校验。
/.well-known/cpx-gateway:返回当前网关配置。- 四个网关端点:内部调用现有用户状态和订阅生成逻辑。
3. .cpx 文件
.cpx 是公开 JSON 文件,不包含用户信息、token、API host 或订阅 URL。所有用户可以使用同一份文件。
{
"magic": "CPXF",
"v": 2,
"spec": "cpx-plugin/2",
"loginUrl": "https://panel.example.com/oauth/authorize",
"provider": {
"name": "Example 机场",
"icon": "data:image/png;base64,iVBORw0K...",
"site": "https://example.com"
}
}
字段要求:
| 字段 | 要求 |
|---|---|
magic |
字符串 "CPXF" |
v |
数字 2 |
spec |
字符串 "cpx-plugin/2" |
| 顶层字段 | 只能包含 magic、v、spec、loginUrl、provider、discoveryUrls、providerPubKey |
loginUrl |
HTTPS URL;不能包含 query、fragment、userinfo;host 不能是私网、环回、localhost、*.localhost |
discoveryUrls |
可选。1..8 个公网 HTTPS origin(无 path/query/fragment/userinfo),去重,不得与 loginUrl 同 origin。备用发现源,见第 5 节。只有加入该字段的版本之后的客户端才接受此键,旧客户端会拒绝导入 |
providerPubKey |
可选。Ed25519 原始 32 字节公钥,标准 base64 带 padding。一旦存在,客户端强制要求签名发现文档(第 5a 节),拒绝未签名文档。每个 .cpx 谱系使用独立密钥。旧客户端会拒绝导入 |
provider |
对象,只能包含 name、icon、site、description |
provider.name |
必填,非空字符串 |
provider.icon |
可选;仅支持 data:image/png;base64,、data:image/jpeg;base64,、data:image/webp;base64,;总长度不超过 65536 |
provider.site |
可选;HTTPS URL;host 约束同 loginUrl,可包含路径 |
provider.description |
可选;给用户看的说明文字,清洗规则同错误 message(第 6 节),上限 500 码点。旧客户端会拒绝导入 |
loginUrl 必须是 authorize 端点,不是普通登录首页。客户端会在该 URL 后拼接 OAuth 参数。
生成脚本:
node scripts/plugin/gen-cpx.mjs <loginUrl> <providerName> [site] [output] [--discovery <origin>]...
--discovery 可重复,每个值对应 discoveryUrls 的一项。
分发方式
-
文件分发:直接提供
.cpx下载。已注册文件关联的客户端支持双击.cpx启动应用,并打开插件预览确认页。 -
Deep link:将同一份
.cpx托管在公网 HTTPS 地址,再把以下链接用于网页按钮或二维码:clash://install-plugin?url=https%3A%2F%2Fprovider.example.com%2Fapp.cpxmihomo://与clash://等价。url参数应进行 URL 编码;客户端下载并校验文件后会安装插件并打开系统浏览器登录。下载地址必须使用公网 HTTPS、不允许重定向,文件大小不超过 1 MiB。
4. OAuth authorize
登录流程使用 OAuth 2.0 Authorization Code + PKCE(S256)。客户端打开系统浏览器访问 loginUrl,密码只提交到服务商页面,客户端不接触密码。
客户端请求参数:
| 参数 | 值 |
|---|---|
response_type |
code |
client_id |
mihomo-party |
redirect_uri |
http://127.0.0.1:<random-port>/callback |
code_challenge |
BASE64URL(SHA256(code_verifier)),无 padding |
code_challenge_method |
S256 |
state |
随机串,服务端必须原样带回 |
scope |
subscribe |
authorize 端点要求:
- 允许
http://127.0.0.1:<random-port>/callback形式的回环重定向。端口每次登录随机,不能按固定端口白名单处理。 - 用户登录成功后,
302到redirect_uri?code=...&state=...。 code必须一次性、短 TTL(不超过 60 秒)。- 签发
code时记录user_id、redirect_uri、client_id、code_challenge。/enroll时逐字节比对。
原生应用的 loopback redirect 允许使用 HTTP,依据 RFC 8252。不要因为 redirect_uri 不是 HTTPS 而拒绝。
5. 网关发现
客户端按 loginUrl 的精确 host 请求:
GET https://<login-host>/.well-known/cpx-gateway
响应体:
{
"spec": "cpx-plugin/2",
"gateway": "https://gw.front.example.net",
"gateways": ["https://gw.front.example.net", "https://gw2-cdn.example.com"],
"endpoints": {
"enroll": "/enroll",
"challenge": "/challenge",
"config": "/config",
"revoke": "/revoke"
}
}
字段要求:
| 字段 | 要求 |
|---|---|
spec |
字符串 "cpx-plugin/2" |
gateway |
HTTPS origin,仅包含 scheme、host、可选 port;不能有 path、query、fragment、userinfo;host 必须公网可达。有 gateways 时必须等于归一化后的 gateways[0] |
gateways |
可选。1..3 个 HTTPS origin,规则同 gateway,按归一化结果去重。缺失时客户端按 [gateway] 处理。任一项非法、空列表、超过 3 个都会使整份文档无效 |
endpoints |
必须包含 enroll、challenge、config、revoke;每个值是以 / 开头的相对路径,不能是绝对 URL,不能包含 ?、#、反斜杠。所有网关共用同一组 endpoints |
客户端请求 /.well-known/cpx-gateway 时只走 HTTPS,不跟随重定向,响应体上限 64 KiB。非 2xx、JSON 非法、字段校验失败均视为发现失败。
旧客户端只读 gateway;新客户端读 gateways,缺失时退回 [gateway]。两者请从同一个列表生成,保证一致。
多网关必须共享状态。 所有 gateways 必须指向同一份后端状态(authorize code / 设备 / nonce):客户端可能在一个网关上 /challenge 拿到 nonce,超时后到另一个网关上 /config。参考部署由 Caddy 为所有网关域名终止 TLS 并转发到同一个网关进程,不支持多副本。
网关轮换与切换
替换网关时,更新 /.well-known/cpx-gateway 即可。
在一次客户端操作(一个完整业务动作,如 challenge + config)内,客户端按缓存列表逐个尝试——上次成功的网关优先,其余按原序——在当前网关出现下列情况时换到下一个:
- HTTP
410或 JSON{"error":"gateway_retired"}; - 网络层不可达:DNS 失败、连接失败、TLS 握手失败;
- 超时(完全没有 HTTP 响应)。
除退役标记外的任何 HTTP 响应都会结束本次操作:普通 5xx、429、revoked 都不会换网关。只有当全部缓存网关都以上述可切换方式失败时,客户端才重新发现一次并尝试新列表,且跳过本次操作已试过的目标(同 origin 且同 endpoints)。仍失败则按瞬时故障退避。
每个网关 origin 的出口路由也是独立选择的:客户端可能直连一个网关、经本地代理访问另一个网关(见第 6 节)。
重新发现依赖至少一个发现源可用(登录域名,以及 .cpx 里的 discoveryUrls)。
多发现源
登录域名是重新发现的单点:它被封后,已登录设备就再也拿不到新网关。因此 .cpx 可以在 discoveryUrls(第 3 节)里列出备用发现源,信任级别与 loginUrl 相同——都是用户导入时接受的静态信任根。
发现顺序为 [loginUrl 的 origin, …discoveryUrls],每个源请求 https://<origin>/.well-known/cpx-gateway。某个源的任何失败——网络错误、非 2xx、JSON 非法、字段非法、客户端本地 guard 拒绝——都会换到下一个源;备用源可能只是 CDN 上的一个静态文件,404 只表示“此处不提供”。全部失败时以最后一个错误为准。每个源是独立 origin,出口路由(第 6 节)也各自选择。
备用源只需在该路径上通过公网 HTTPS 提供同一份 JSON 文件,任何 CDN / 对象存储都可以;网关本身已经提供该路径,因此把网关 origin 列进 discoveryUrls 是最省事的做法。备用源只帮助已登录设备自愈;新登录仍然依赖登录域名,因为 OAuth 页面是在系统浏览器里打开的。
5a. 签名发现文档
.cpx 带 providerPubKey 后,信任根从"主机名"换成"密钥":发现文档从哪拿到都无所谓——登录域名、任一网关、静态 CDN 文件、/config 响应——只要能用该密钥验签、且序号不早于客户端已接受的版本即可。登录域名不再是单点,gateways、endpoints、loginUrl、discoveryUrls 都可以在不动 .cpx 的情况下轮换。
信封。 一个字符串 "<payloadB64>.<sigB64>":
payloadB64:payload JSON 的 UTF-8 字节,标准 base64 带 padding。sigB64:Ed25519 对"CPX2-DISCOVERY\0" || payloadBytes的 64 字节签名——ASCII 前缀、一个 NUL 字节、然后是 payload 原始字节——标准 base64 带 padding。前缀提供域分离,防止同一密钥签出的其他类型消息被冒用。- 恰好一个
.。两段都必须是规范 base64(解码后重新编码必须与输入完全一致)。payload 最多 4 KiB;经 base64 与签名后约 5.6 KiB,在常见 CDN / 反向代理 8 KiB 单头限制内。
签发方自己序列化 payload 并对那串字节签名;客户端验的是收到的那串字节,验过之后才解析。两边都不需要规范化(不用 JCS)。
payload。
{
"spec": "cpx-plugin/2",
"seq": 12,
"gateways": ["https://gw1.example.net", "https://gw2-cdn.example.com"],
"endpoints": {
"enroll": "/enroll",
"challenge": "/challenge",
"config": "/config",
"revoke": "/revoke"
},
"loginUrl": "https://panel-new.example.com/oauth/authorize",
"discoveryUrls": ["https://gw2-cdn.example.com"]
}
| 字段 | 要求 |
|---|---|
spec |
"cpx-plugin/2" |
seq |
整数,1 ≤ seq ≤ 2^53−1。每次变更必须严格递增 |
gateways |
规则同第 5 节(1..3 个公网 HTTPS origin) |
endpoints |
规则同第 5 节;可选的 bootstrap 路径留给后续阶段 |
loginUrl |
可选;规则同 .cpx 字段。接受后替换已保存的登录地址,下次登录打开新地址 |
discoveryUrls |
可选;缺失 = 不改,[] = 清空,非空时规则同 .cpx(1..8)。payload 同时给出 loginUrl 时,列表不得包含其 origin,否则整份文档无效,签发前须移除;未给出 loginUrl 时,客户端在应用阶段按已保存的登录地址去除同源项 |
不允许其他键。
投放位置——两处,同一格式、同一校验:
- well-known 文档新增可选顶层
signed。旧字段gateway/gateways/endpoints照旧保留给无密钥与旧客户端;有密钥客户端会把它们与 payload 比较,不一致则整个源无效,因此两者请从同一份 payload 生成。 /config成功响应可带响应头X-CPX-Discovery: <payloadB64>.<sigB64>。只发一个头值;重复头会被客户端忽略。
客户端行为。
.cpx 有 providerPubKey |
well-known 有 signed |
行为 |
|---|---|---|
| 否 | 任意 | 走无签名路径(第 5 节);忽略 signed 与 X-CPX-Discovery |
| 是 | 否 | 该源发现失败(降级攻击防护),试下一个源 |
| 是 | 是 | 验签 → 解析 → 序号校验 → 应用 |
客户端保存最后接受的 seq 以及 payload 字节的 SHA-256。对新到达的文档:未存过 → 接受;seq 更大 → 接受;seq 相同且摘要相同 → 作为幂等重放接受;seq 相同但摘要不同 → 拒绝(同一序号下的两份不同文档,例如多 CDN 副本不一致);seq 更小 → 拒绝。被拒绝的 well-known 源和其他发现失败一样跳过。被拒绝或畸形的 X-CPX-Discovery 头只记日志:已认证成功的 /config 响应绝不因此作废。
序号防的是网络侧重放更旧的文档;它不防有本机文件系统权限的攻击者。
接受后的应用顺序。 网关列表与端点先写入客户端的加密缓存;然后 loginUrl、discoveryUrls、seq 与摘要一步写入插件记录。序号是提交标记:中间任何一步失败都不推进标记,下一次同一文档到达时幂等修复。新登录时发现文档在打开浏览器之前就应用,因此轮换后的 loginUrl 立即生效,取消登录之后也不会被拉回更旧的文档。
密钥管理。 离线签发。网关进程不持有私钥,只读取预先签好的信封文件(参考网关的 DISCOVERY_SIGNED_FILE,同时用于 signed 与响应头)。cpx-admin keygen 与 cpx-admin sign-discovery <payload.json>(或 scripts/plugin/sign-discovery.mjs)从文件或 stdin 读取 seed,绝不放在命令行参数里。每个 .cpx 谱系使用独立密钥:同一密钥签出的文档在共用该密钥的插件之间可以互相冒用。本期不做密钥轮换——密钥泄露或丢失都需要重新发放 .cpx。
发布顺序。 先上线带 signed 的 well-known,再分发含 providerPubKey 的 .cpx。顺序反了,所有有密钥的客户端在 signed 出现之前都会发现失败。
6. 网关公共约定
四个端点均为 POST,请求体为 JSON。客户端使用加固 HTTPS 客户端访问:只走 HTTPS,不跟随重定向,响应体上限 10 MiB。
不使用 Authorization header,不下发 bearer token。设备身份由 deviceId、设备公钥和 Ed25519 签名确认。
错误信号按以下优先级处理:
| 客户端判定 | 条件 | 客户端行为 |
|---|---|---|
retired |
HTTP 410,或 JSON {"error":"gateway_retired"} |
换下一个缓存网关;全部用尽后重新发现一次 |
revoked |
JSON {"error":"revoked"} 或 {"error":"device_revoked"} |
标记为需要重新登录 |
unreachable |
DNS 失败、连接失败、TLS 握手失败 | 换下一条路由,再换下一个网关;全部用尽后重新发现一次 |
transient |
其它非 2xx(5xx、429、空 401/403),或超时 | 超时:换路由 / 换网关。任何 HTTP 响应:退避重试 |
blocked |
网关 host 在客户端本地解析到私网/环回地址(不会发出任何请求) | 退避;用户需修正目标或显式选择代理模式 |
| 成功 | 2xx 且没有错误标记 | 正常处理 |
账号到期、设备被踢、用户被禁用时,返回体必须包含 revoked 或 device_revoked。单独返回空的 401/403 会被客户端当作瞬时失败处理。
可选的 message。 任何错误 JSON 都可以附带给用户看的 message,客户端会显示在插件卡片上、固定状态文案之下:
{ "error": "revoked", "message": "订阅已于 2026-09-01 到期,续费后请重新登录。" }
客户端只在 message 是字符串时读取;trim、去除除换行(U+000A)外的控制字符、按码点截断到 200、清洗后为空视为无。渲染为纯文本(不做 Markdown、不识别链接)。该消息随插件保存,直到下一次操作成功时清空。.cpx 里的静态 provider.description 使用同一套清洗规则(上限 500 码点),显示在安装确认页与卡片上。
路由。 每个请求要么直连、要么经用户本地代理发出。默认的 自动 模式下,客户端记住该插件上次成功的路由并优先使用,只在网络层失败或超时时换另一条——任何 HTTP 响应都不会换路由。粘性按 origin 计:某个网关 origin 一旦在某条路由上有响应,本次操作对它的后续请求都走这条路由。经代理发出任何请求之前,客户端先在本地解析网关 host,解析到私网地址即拒绝(blocked)。剩余两条风险与显式代理模式相同:预检与代理侧解析之间的 DNS rebinding;本地解析失败的 host 仍会放行经代理。
/enroll 不会重放。 authorize code 会被第一个到达网关的请求消费。因此客户端只在请求确定没有离开本机时(写入请求前的连接拒绝 / DNS / TLS 失败)才为 /enroll 换路由或换网关;超时、发送后的连接重置、任何 HTTP 响应都会直接结束本次登录,用户重新登录取新 code 即可。
7. POST {gateway}/enroll
作用:用 authorize 阶段签发的 code 注册设备,相当于 OAuth token exchange,但不返回 bearer token。
请求体:
{
"code": "<authorize code>",
"code_verifier": "<PKCE verifier>",
"redirect_uri": "http://127.0.0.1:<port>/callback",
"client_id": "mihomo-party",
"devicePubKey": "<base64 Ed25519 public key>",
"deviceId": "<UUIDv4>"
}
服务端处理:
- 查找
code,确认未过期、未使用。 - 校验
code_verifier:BASE64URL(SHA256(code_verifier))必须等于签发 code 时保存的code_challenge。 - 校验
redirect_uri、client_id与签发 code 时保存的值逐字节一致。 - 从
code关联到user_id。 - 保存设备绑定:
(user_id, deviceId, devicePubKey)。 - 将
code标记为已使用。 - 返回 2xx,例如
{"ok":true}。
注意:
deviceId由客户端生成,服务端不要替换。- 一个用户可绑定多台设备,建议设置设备数上限和清理策略。
- enroll 成功后,即使首次 config 失败,该设备绑定仍然有效。不要因为订阅拉取失败删除绑定。
- 用户重新登录会生成新设备密钥,也会产生新的设备绑定;随后客户端会注销被替换的旧设备(见第 10 节),同一台电脑反复登录不会堆积绑定。
8. POST {gateway}/challenge
作用:为指定设备发放一次性 nonce。
请求体:
{ "deviceId": "<UUIDv4>" }
成功响应:
{
"nonceId": "<opaque id>",
"nonce": "<base64 32 bytes>",
"exp": 60
}
要求:
- 按
deviceId维护待用 nonce 池,允许并发存在多个 nonce。 - nonce 为 32 字节安全随机数。
- TTL 建议不超过 60 秒。
- nonce 用完即删;过期定期清理。
- 每个设备的待用 nonce 数应设置上限,例如 8 个。
nonceId是不透明句柄,可见 ASCII,长度不超过 64,不能包含空格或控制字符。nonce使用标准 base64,带=padding;解码后必须正好 32 字节。exp只是提示,客户端不强依赖。
如果设备不存在、账号到期或设备已被服务端吊销,返回 JSON 错误:
{ "error": "revoked" }
9. POST {gateway}/config
作用:验证设备签名,并返回该用户的 Clash YAML。
请求体:
{
"deviceId": "<UUIDv4>",
"nonceId": "<challenge nonceId>",
"nonce": "<challenge nonce>",
"ts": 1700000000000,
"sig": "<base64 Ed25519 signature>"
}
服务端处理:
- 按
deviceId、nonceId查找待用 nonce。 - 校验请求中的
nonce与服务端保存值一致。 - 校验 nonce 未过期、未消费。
- 校验时钟偏差:
abs(now_ms - ts) <= 300000。 - 读取该设备绑定的
devicePubKey。 - 按第 11 节构造签名输入,
op=1,验证 Ed25519 签名。 - 消费 nonce。
- 由
deviceId关联到user_id,内部调用现有订阅生成逻辑或隐藏 origin。 - 返回 HTTP 200,body 为 Clash YAML 文本。
成功响应还可以附带响应头 X-CPX-Discovery: <payloadB64>.<sigB64>(第 5a 节)。客户端只对带 providerPubKey 的插件读取它、只接受单一头值;校验失败只记日志并忽略,YAML 正文照常应用。
成功响应不是 JSON。客户端会把 body 当作 Clash 配置解析,要求 YAML 可解析为对象,并且至少包含 proxies 或 proxy-providers 之一。
订阅 URL、origin API、内部鉴权 token 不应下发给客户端。
10. POST {gateway}/revoke
作用:解绑设备。客户端在两种情况下尽力调用该端点:
- 用户删除插件——注销当前设备,以及下文"待回收"名单里的设备;
- 重新登录换了新设备——登录完成后立即用旧设备的密钥注销旧设备。若这次调用失败,客户端保留旧密钥,在之后每次
/config拉取成功后重试,每次只处理一台。
旧设备的 /challenge 返回 revoked / device_revoked 即视为"已解绑",客户端停止重试。除第 7 节的错误码约定与下文的幂等要求外,网关不需要新增任何东西;唯一可见的变化是 /revoke 现在也会在重新登录之后到来,针对的是账号里已不是最新的那台设备——只解绑该 deviceId 即可。
请求体同 /config:
{
"deviceId": "<UUIDv4>",
"nonceId": "<challenge nonceId>",
"nonce": "<challenge nonce>",
"ts": 1700000000000,
"sig": "<base64 Ed25519 signature>"
}
处理流程同 /config,但签名输入中的 op=2。验签通过后删除该 deviceId 的绑定记录,并消费 nonce。
/revoke 必须幂等。设备已经不存在时,仍可返回 2xx。
11. 设备签名
签名输入是确定性字节串:
SignInput = "CPX2" // 4 bytes ASCII
| uint8(op) // config=1, revoke=2
| uint8(len(deviceId)) | deviceId // UTF-8 bytes
| uint8(len(nonceId)) | nonceId // UTF-8 bytes
| nonce // 32 raw bytes
| uint64_be(ts) // Unix milliseconds
签名算法:
sig = Ed25519_sign(devicePrivKey, SignInput)
sig 为 64 原始字节,在线路中使用标准 base64 编码。
实现要点:
nonce放入签名输入前必须先 base64 解码,使用 32 原始字节。ts是 Unix 毫秒时间戳,用 8 字节无符号大端整数编码。deviceId、nonceId长度前缀为 1 字节无符号整数。/config使用op=1,/revoke使用op=2。
12. 编码规则
| 字段 | 编码 |
|---|---|
devicePubKey |
Ed25519 公钥,32 原始字节,标准 base64,带 padding |
sig |
Ed25519 签名,64 原始字节,标准 base64,带 padding |
nonce |
32 原始字节,标准 base64,带 padding,通常 44 字符 |
deviceId |
UUIDv4,36 字符,小写带连字符;服务端上限不超过 64 |
nonceId |
服务端生成的不透明可见 ASCII,长度不超过 64 |
ts |
整数,Unix 毫秒 |
op |
uint8;config=1,revoke=2 |
code |
authorize 签发的不透明字符串,长度不超过 2048 |
code_challenge / code_verifier |
RFC 7636 base64url,无 padding;verifier 长度 43-128,字符集 [A-Za-z0-9-._~] |
providerPubKey |
Ed25519 公钥,32 原始字节,标准 base64,带 padding |
signed / X-CPX-Discovery |
<payloadB64>.<sigB64>;两段都是规范的标准 base64,带 padding |
除 PKCE 的 code_challenge、code_verifier 外,其余二进制字段全部使用标准 base64,带 = padding。不要混用 base64url。
13. PHP 参考代码
以下片段只展示关键校验。生产代码仍需补齐参数校验、nonce 状态、用户状态和错误处理。
PKCE:
function pkce_ok(string $verifier, string $challenge): bool {
$calc = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
return hash_equals($challenge, $calc);
}
签名输入和验签:
function build_sign_input(int $op, string $deviceId, string $nonceId, string $nonceRaw, int $tsMs): string {
return "CPX2"
. chr($op)
. chr(strlen($deviceId)) . $deviceId
. chr(strlen($nonceId)) . $nonceId
. $nonceRaw
. pack('J', $tsMs); // uint64 big-endian
}
$nonceRaw = base64_decode($req['nonce'], true);
$pub = base64_decode($devicePubKeyB64, true);
$sig = base64_decode($req['sig'], true);
$ts = (int)$req['ts'];
if ($nonceRaw === false || $pub === false || $sig === false) { /* 400 */ }
if (strlen($nonceRaw) !== 32 || strlen($pub) !== 32 || strlen($sig) !== 64) { /* 400 */ }
if (abs((int)(microtime(true) * 1000) - $ts) > 300000) { /* 400 */ }
// 还需要校验 nonceId/nonce 属于该 deviceId,且未过期、未消费。
$op = ($endpoint === 'config') ? 1 : 2;
$input = build_sign_input($op, $req['deviceId'], $req['nonceId'], $nonceRaw, $ts);
$ok = sodium_crypto_sign_verify_detached($sig, $input, $pub);
if (!$ok) { /* 401 or 400 */ }
// 验签通过后消费 nonce。
吊销响应:
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'revoked']);
签名发现文档(第 5a 节),离线执行:
$payload = json_encode($doc, JSON_UNESCAPED_SLASHES); // 签的就是这串字节
$sig = sodium_crypto_sign_detached("CPX2-DISCOVERY\0" . $payload, $secretKey);
$signed = base64_encode($payload) . '.' . base64_encode($sig);
// .cpx 的 providerPubKey:base64_encode(sodium_crypto_sign_publickey($keypair))
14. 签名自测
测试向量文件:
src/main/resolve/plugin/__fixtures__/sign-vectors.json
每条向量包含:
opdeviceIdnonceIdprivSeedB64pubKeyB64nonceB64tsinputHexsigB64
服务端实现至少验证两项:
- 按本协议构造出的签名输入,其十六进制与
inputHex完全一致。 - 使用
pubKeyB64验证sigB64可以通过。
如果 inputHex 不一致,先检查 nonce 是否用了原始字节、ts 是否按 uint64 大端编码、op 是否正确。
重新生成向量:
node scripts/plugin/gen-sign-vectors.mjs
签名发现文档向量(第 5a 节):
src/main/resolve/plugin/__fixtures__/discovery-vectors.json
每条包含 seedB64、pubKeyB64、payloadJson、payloadB64、signInputHex(前缀 + payload)、sigB64、signed、digestHex。验证:用 seed 对 signInputHex 签名得到 sigB64;signed 能用 pubKeyB64 验过;payload 字节的 SHA-256 等于 digestHex。重新生成:node scripts/plugin/gen-discovery-vectors.mjs。
仓库中另有 scripts/plugin/example-gateway.mjs,仅用于查看协议形状。它使用明文 HTTP 和 loopback origin,会被真实客户端的 HTTPS 校验拒绝;真实联调用 deploy/gateway/。
15. 上线检查
.cpx:
magic、v、spec、loginUrl、provider字段符合第 3 节。loginUrl是 HTTPS authorize 端点,不带 query、fragment、userinfo。provider.icon如有提供,使用允许的 data URI 格式,大小不超过限制。
登录域名:
https://<login-host>/.well-known/cpx-gateway返回合法 JSON。- authorize 允许
http://127.0.0.1:<random-port>/callback。 - 登录成功后 302 回调,带
code和原始state。 code一次性、TTL 不超过 60 秒。code记录了redirect_uri、client_id、code_challenge。
网关:
gateway是公网 HTTPS origin,无 path、query、fragment、userinfo。- 如提供
gateways:1..3 个公网 HTTPS origin,且gateway等于gateways[0]。 - 列出的每个网关都落到同一份后端状态(code / 设备 / nonce),没有独立副本。
.cpx带providerPubKey时:well-known 已含signed、顶层字段与 payload 一致,且签名文档先于.cpx发布。- 四个 endpoint 都是相对路径,不含反斜杠、query、fragment。
/enroll校验 PKCE、code、redirect_uri、client_id,写入设备绑定。/challenge发放 32 字节随机 nonce,标准 base64,短 TTL,待用池有上限。/config校验 nonce、时钟、签名,消费 nonce,返回 Clash YAML。/revoke使用op=2验签,消费 nonce,幂等解绑。- 账号到期、设备吊销返回
{"error":"revoked"}或{"error":"device_revoked"}。 - 网关退役返回 HTTP
410或{"error":"gateway_retired"}。
编码和兼容:
devicePubKey、sig、nonce使用标准 base64,带 padding。- PKCE 字段使用 base64url,无 padding。
- 签名输入中的 nonce 是解码后的 32 原始字节。
ts使用 uint64 大端。- 已用
sign-vectors.json做过互通测试。
16. 常见问题
客户端不提示重新登录,只是一直重试
通常是服务端只返回了 401 或 403。需要在 JSON 响应体中返回:
{ "error": "revoked" }
或:
{ "error": "device_revoked" }
nonce 校验失败
检查 nonce 是否为标准 base64、是否带 padding、解码后是否正好 32 字节。签名输入中使用的是解码后的原始字节,不是 base64 字符串。
authorize 拒绝回环重定向
redirect_uri 是 http://127.0.0.1:<random-port>/callback,端口随机。按 scheme、host、path 校验即可,不要固定端口,也不要要求 HTTPS。
网关发现失败
检查 gateway 是否为公网 HTTPS origin,不能写成 https://gw.example.com/base,也不能使用 localhost、私网 IP、环回 IP。
endpoint 拼接失败
endpoints.* 必须是 /enroll 这类相对路径。不要写绝对 URL、//host/path,也不要包含反斜杠。
验签失败
按顺序检查:
nonce是否先 base64 解码为 32 原始字节。ts是否按 uint64 大端编码。op是否正确:config=1,revoke=2。deviceId、nonceId的长度前缀是否是 1 字节。devicePubKey、sig是否分别解码为 32/64 字节。- 生成的
inputHex是否与测试向量一致。
/config 返回后客户端仍然更新失败
成功响应必须是 Clash YAML。客户端要求 YAML 可解析为对象,并且包含 proxies 或 proxy-providers。返回 JSON、HTML、空 body 或非 Clash 配置都会被视为瞬时失败。
17. 日志
服务端日志不要记录以下内容:
- authorize
code code_verifier- nonce 和 nonceId
- 设备私钥(服务端本不应持有)
- 订阅 URL、origin token、完整 Clash YAML
- 用户密码或登录表单原文
可记录 user_id、deviceId、端点名、错误码、请求耗时、网关版本等运维字段。