21 KiB
CVD 服务端集成技术文档(订阅服务对接)
CVD = Clash Verge Device-binding Protocol · 协议版本 X-CVD-Ver: 1
适用对象:订阅服务(**board / 自研面板等)的后端开发者
目的:让面板按本文实现「向已注册设备公钥下发 HPKE 加密订阅」的服务端逻辑,与 Clash Verge 客户端互通。
权威来源:本文所有协议常量、字节格式、校验规则均与 Clash Verge 客户端当前实现严格一致(src-tauri/src/cvd/mod.rs)。如有歧义,以 §14 的互操作测试向量(KAT)为准。
0. 30 秒速览
- Clash Verge 客户端会在新建 / 导入订阅、手动更新未注册订阅,以及已注册订阅的后续更新中自动附加
X-CVD-*请求头,携带一个 32 字节 X25519 设备公钥。 - 面板收到后可以:用该公钥把订阅 YAML 做 HPKE(RFC 9180)加密后返回;或返回明文(客户端自动按「不支持 CVD」处理)。
- 面板按
(token, 公钥)维护「设备槽位」,可设上限;超限返回403 device_limit_exceeded。 - 不改造也不会坏:面板什么都不做时,客户端收到明文,按现有流程工作。是否上线 CVD 完全由面板决定。
⚠️ 加密本身不是防护的核心,真正抬高成本的是「设备槽位上限」+「对受保护 token 拒绝明文请求」这两条服务端策略(见 §2、§11)。
1. 工作模型(务必先理解)
- 机会式(opportunistic):客户端会在可安全建立或复用设备密钥时自动尝试 CVD;服务端「支持就加密、不支持就明文」,两端都能正常工作。
- 公钥即设备身份:不存在 device_id。同一设备(同一订阅 profile)每次更新都发送同一个持久公钥;面板据此识别设备、计数、可吊销。
- 私钥只在客户端本机 keychain:面板永远只拿到公钥,永远不接触私钥。
- 向后兼容:老客户端 / 浏览器 / curl 不带
X-CVD-Pub;面板需对「无公钥请求」单独决策(明文或拒绝,见 §11)。
2. 必须诚实的边界(先读,避免误用)
CVD 不是 DRM,请不要据此对外宣称「订阅无法被盗」。客观能力:
- 挡不住肯写代码的人:协议公开,任何人可自己生成密钥对、注册一个设备槽位,照样解出明文。
- 一次解密 = 当前节点全泄露:解密后的 YAML 含真实节点凭据(密码/UUID/IP),可直接使用。设备上限限制的是「持续获取更新」的设备数,不是「一次性批量抓取」。
- 挡不住账号持有者本人泄露:合法设备正常解密后可手动复制明文。CVD 防的是「只拿到 token 的第三方」。
- 不防 MITM / 重放 / 投毒:这些不在协议目标内,HTTPS 仍然必须开启。
- 加密的现实价值:让「把订阅 URL 贴进浏览器/通用 curl 就抄到节点」的懒人路径失效,逼对方必须跑真正的协议代码。
- 设备槽位限额可被持有 token 的攻击者用来 DoS 真人(刷满槽位把真实用户锁在外面)——必须配合新公钥注册频率限制(§11)。
3. 请求格式(客户端 → 面板)
客户端对远程订阅发起标准 GET,仅新增以下请求头,不改 URL / query / 其它头:
GET /api/v1/client/subscribe?token=xxxxxxxx HTTP/1.1
Host: panel.example.com
X-CVD-Ver: 1
X-CVD-Pub: 8n7xxaMI-PlvyMurFN4etuMZNqUtg9lvru9vgRUAUxU
X-CVD-AEAD: 3
| 头 | 含义 | 格式 |
|---|---|---|
X-CVD-Ver |
协议版本 | 十进制整数,当前恒为 1 |
X-CVD-Pub |
设备 X25519 公钥 | base64url 无填充(字母表 A–Z a–z 0–9 - _,无 =),解码后恰好 32 字节 |
X-CVD-AEAD |
客户端偏好的 AEAD | 十进制:3=ChaCha20-Poly1305,2=AES-256-GCM。仅为偏好;面板可忽略并自选(见 §6) |
要点:
X-CVD-Pub解码后必须是 32 字节,否则面板应视为非法 CVD 请求(按明文或拒绝处理)。- 同一设备的公钥跨多次更新保持不变,因此「注册」是幂等的:
(token, pub)已存在就只刷新last_seen。 - 客户端当前实现没有用户开关。新建 / 导入订阅会自动尝试 CVD;尚未注册的旧订阅会在手动更新时尝试注册;已有
cvd_pub缓存的订阅会在后续更新中持续携带这三个头。 - 尚未注册的旧订阅在静默自动更新时不会主动创建密钥,以避免系统启动时触发 keychain 授权弹窗。keychain 不可用时,客户端也会跳过 CVD、按无 CVD 请求处理。
4. 服务端处理流程(每个带 X-CVD-Pub 的请求)
1. token = 你现有的订阅鉴权流程解析出的 token / 用户
2. pubkey = base64url_decode(X-CVD-Pub) // 必须 32 字节,否则当作无 CVD 请求
3. 设备注册 / 限额检查(事务安全,见 §5)
├─ 已注册 或 注册成功 → 4
└─ 超过设备上限 → 返回 403 device_limit_exceeded(见 §9)
4. yaml = 生成该用户的订阅明文(你现有逻辑)
5. body = HPKE_Seal(pubkey, yaml) // 见 §6 §7
6. 返回 200 + X-CVD-Encrypted: 1 + X-CVD-AEAD: <实际AEAD> + body
对不带 X-CVD-Pub 的请求:按 §11 的策略决定返回明文还是拒绝。
5. 设备注册与限额(数据库 + 并发安全)
5.1 表结构(参考)
CREATE TABLE cvd_devices (
token_hash CHAR(64) NOT NULL, -- sha256(subscription_token) 的十六进制;或直接用你的 user_id
pub_key CHAR(43) NOT NULL, -- X-CVD-Pub 原文(base64url-nopad,32 字节 → 43 字符)
last_seen TIMESTAMP NOT NULL,
PRIMARY KEY (token_hash, pub_key)
);
CREATE INDEX idx_cvd_token ON cvd_devices (token_hash);
5.2 注册逻辑(必须事务安全,否则会超卖槽位)
BEGIN TRANSACTION
key = (token_hash = sha256(token), pub_key = X-CVD-Pub 原文)
row = SELECT * FROM cvd_devices WHERE token_hash=? AND pub_key=? FOR UPDATE
IF row 存在:
UPDATE last_seen = now()
COMMIT
→ 允许,用 pub_key 加密返回
ELSE:
cnt = SELECT COUNT(*) FROM cvd_devices WHERE token_hash=? -- 同一事务内
IF cnt >= 设备上限 N:
ROLLBACK
→ 403 device_limit_exceeded (可带 X-CVD-Max-Devices: N)
ELSE:
INSERT (token_hash, pub_key, last_seen=now())
COMMIT
→ 允许,用 pub_key 加密返回
并发不超卖:
COUNT与INSERT之间存在竞态。必须把「计数 + 插入」放在同一事务(如FOR UPDATE行锁),或用数据库唯一约束 + token 维度计数兜底。仅靠应用层「先查后插」会被并发突破上限。 幂等:(token_hash, pub_key)唯一,同一公钥重复请求不会重复占槽。
5.3 槽位回收(强烈建议)
客户端的私钥只在本机 keychain,不随备份导出。用户换机 / 重装系统 / 清空 keychain 后,会用新公钥重注册一个新槽位(旧槽位变成死槽)。因此:
- 建议按
last_seen设过期回收(例如 30/60 天未出现即释放),否则真实用户长期使用会被自己的换机历史塞满槽位。 - 面板控制台应提供「查看/解绑设备」入口,给用户自助清理的能力。
6. 加密响应格式(面板 → 客户端)
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Cache-Control: no-store
X-CVD-Encrypted: 1
X-CVD-AEAD: 3
I9RQxCOpo0QI30wTsl2wCrNqByOLNJr1baCXumWU9Sh...(base64url-nopad 文本)
X-CVD-Encrypted: 1:表示这是 CVD 密文。只有在 body 确实是合法密文时才设置它(否则客户端会报错,且不回退明文——见 §9 §10)。X-CVD-AEAD:填你实际使用的 AEAD(2或3)。客户端用响应头里这个值解密,而不是它请求时的偏好。所以你可以无视客户端偏好自选其一,客户端两种都支持。- Body =
base64url_nopad( enc || ciphertext ):enc:HPKE 封装的临时公钥(X25519,32 字节)。ciphertext:AEAD 密文,含 16 字节 tag。- 解码后长度必须 ≥ 48 字节(32 + ≥16)。
- 必须 base64url 文本:客户端把 HTTP body 当文本读取后再 base64url 解码。不要直接发二进制,会被字符集解码破坏。
- 不可跨设备缓存:每个设备的
enc是临时随机的、响应各不相同。务必Cache-Control: no-store(或按设备公钥 Vary),不要让 CDN 缓存加密响应。
7. HPKE 参数(必须逐项完全一致)
实现按 RFC 9180 Single-Shot SealBase:
| 项 | 值 |
|---|---|
| Mode | Base(mode_base = 0x00,无 PSK、无发送方认证) |
| KEM | DHKEM(X25519, HKDF-SHA256),kem_id = 0x0020 |
| KDF | HKDF-SHA256,kdf_id = 0x0001 |
| AEAD | ChaCha20-Poly1305 0x0003(推荐)或 AES-256-GCM 0x0002 |
info |
ASCII 字节串 cvd-v1(6 字节:63 76 64 2d 76 31,无结尾 0) |
aad |
空(长度 0) |
psk / psk_id |
无(Base 模式) |
| 输出 | (enc, ciphertext) = SealBase(pkR, info, aad="", pt=订阅YAML字节) |
pkR = X-CVD-Pub 解码出的 32 字节原始 X25519 公钥。Nk(key)=32、Nn(nonce)=12、Nenc=32、Nsecret=32。
这是 RFC 9180 标准套件,主流库都直接支持,不要自定义任何环节(标签、info、AAD、序列化)。务必用 §14 的 KAT 验证你的实现确实与客户端互通。
8. 明文响应(不支持 / 不强制 CVD 时)
HTTP/1.1 200 OK
(没有 X-CVD-Encrypted 头)
<明文订阅 YAML>
客户端检测到无 X-CVD-Encrypted 头即按「订阅服务不支持 CVD」处理,使用明文。未改造的面板天然就是这个行为。
9. 错误响应
| 场景 | 状态码 | 响应头 | 客户端行为 |
|---|---|---|---|
| 设备数超限 | 403 |
X-CVD-Error: device_limit_exceeded(可选 X-CVD-Max-Devices: N) |
提示用户「前往订阅服务控制台解绑旧设备后重试」,不回退明文 |
| 其它任意非 2xx | 4xx/5xx | 任意 | 视为本次更新失败,不回退明文 |
设了 X-CVD-Encrypted: 1 但 body 非法 / 缺 X-CVD-AEAD / 解码后 < 48 字节 |
200 | — | 视为错误,不回退明文 |
关键:只在能正确产出合法密文时才设
X-CVD-Encrypted: 1。一旦设了却给不出合法密文,客户端这次更新会直接失败(不会悄悄退回明文)。无法加密时,要么返回纯明文(不带该头),要么返回明确错误状态码。
10. 客户端校验规则清单(你的响应必须满足)
客户端按以下规则解析响应(顺序即优先级):
403且X-CVD-Error去空白后等于device_limit_exceeded→ 设备超限错误。- 任意非 2xx(除上一条)→ 错误。
- 2xx 且
X-CVD-Encrypted去空白后等于字符串1:- 读
X-CVD-AEAD,去空白后必须是2或3,否则错误; - body 去空白后做 base64url-nopad 解码,失败则错误;
- 解码长度 < 48 则错误;
- 否则取前 32 字节为
enc、其余为ciphertext,用响应头的 AEAD +info="cvd-v1"+ 空 AAD 做 HPKE-Open。
- 读
- 2xx 且无
X-CVD-Encrypted头 → 明文。
对照实现:parse_response / hpke_open(src-tauri/src/cvd/mod.rs)。
11. 强制策略(真正产生防护价值,由面板决定)
协议本身不强制任何东西;只有加上以下策略才真正抬高获取者成本:
- 对受保护 token 拒绝明文请求:对启用 CVD 的 token,拒绝不带
X-CVD-Pub(或带了但你不认)的请求——否则攻击者只要不发这个头就拿到明文,加密形同虚设。这是「成本」的来源。 - 设备上限 N:按套餐设定(如 3/5)。
- 按 token / 源 IP 限制「新公钥注册」频率:防止持有 token 的攻击者刷满槽位,把真实用户锁在外面(§2 提到的 DoS)。
last_seen回收死槽(§5.3)。
仅实现 §3–§9 而不加策略 1,CVD 只能挡住懒人脚本;要把成本真正抬起来,策略 1 是必须的,且其副作用(误伤老客户端/其它工具)需你自己权衡灰度。
12. 部署注意
- HTTPS 必须保留:CVD 不替代 TLS,不防 MITM / 重放。
- CDN / WAF / 反向代理必须透传
X-CVD-*请求头到处理订阅的后端,不要在边缘层剥离,否则后端永远收不到公钥。 - 加密响应必须是 base64url 文本 +
Cache-Control: no-store(每设备不同,禁止跨设备缓存)。 - 同时兼容无 CVD 请求:更新后的客户端会在新建 / 导入或手动更新后逐步启用
X-CVD-*;老客户端、其它工具,以及尚未注册的静默更新仍可能不带。后端两种都要能处理。 - 版本:当前只有
X-CVD-Ver: 1。若未来见到更高且你不支持的版本,按明文处理(不要用不兼容方案加密)。
13. 服务端参考实现
13.1 算法(语言无关,等价于 RFC 9180 SealBase)
# pkR = 32 字节接收方公钥; info = "cvd-v1"; aad = ""; pt = 订阅 YAML 字节
(skE, pkE) = X25519_GenerateKeyPair()
dh = X25519(skE, pkR) # 32 字节
enc = pkE # 32 字节
shared = DHKEM_ExtractAndExpand(dh, enc || pkR) # RFC 9180 §4.1,HKDF-SHA256
(key, base_nonce) = KeySchedule_Base(shared, info) # RFC 9180 §5.1
ciphertext = AEAD_Seal(key, base_nonce, aad="", pt) # seq=0,nonce=base_nonce
body = base64url_nopad(enc || ciphertext)
各步骤的
LabeledExtract/LabeledExpand标签("HPKE-v1"、suite_id、"eae_prk"、"shared_secret"、"secret"、"key"、"base_nonce"等)严格按 RFC 9180。强烈建议直接用成熟库而非手写,并用 §14 的 KAT 验证。
13.2 推荐库
| 语言 | 库 | 说明 |
|---|---|---|
| Go | github.com/cloudflare/circl/hpke |
成熟、API 直观,推荐 |
| Node.js / TS | @hpke/core(hpke-js) |
维护良好 |
| Python | pyhpke |
直接支持本套件 |
| Rust | hpke / rust-hpke |
客户端即用此 |
| PHP | 无成熟原生库 | 见 §13.4 |
13.3 Go 示例(CIRCL)
import (
"crypto/rand"
"encoding/base64"
"github.com/cloudflare/circl/hpke"
)
func sealForDevice(pubB64url string, yaml []byte) (body string, aead string, err error) {
suite := hpke.NewSuite(
hpke.KEM_X25519_HKDF_SHA256,
hpke.KDF_HKDF_SHA256,
hpke.AEAD_ChaCha20Poly1305, // 对应 X-CVD-AEAD: 3
)
pkBytes, err := base64.RawURLEncoding.DecodeString(pubB64url) // 32 字节
if err != nil { return "", "", err }
pkR, err := hpke.KEM_X25519_HKDF_SHA256.Scheme().UnmarshalBinaryPublicKey(pkBytes)
if err != nil { return "", "", err }
sender, err := suite.NewSender(pkR, []byte("cvd-v1")) // info
if err != nil { return "", "", err }
enc, sealer, err := sender.Setup(rand.Reader)
if err != nil { return "", "", err }
ct, err := sealer.Seal(yaml, nil) // aad = nil(空)
if err != nil { return "", "", err }
payload := append(append([]byte{}, enc...), ct...) // enc(32) || ciphertext
return base64.RawURLEncoding.EncodeToString(payload), "3", nil
}
// 响应:200 + X-CVD-Encrypted: 1 + X-CVD-AEAD: <aead> + body
13.4 Node.js 示例(hpke-js,亦可作为 PHP 面板的 sidecar)
import { CipherSuite, KemId, KdfId, AeadId } from "@hpke/core";
async function sealForDevice(pubB64url, yamlBytes) {
const suite = new CipherSuite({
kem: KemId.DhkemX25519HkdfSha256,
kdf: KdfId.HkdfSha256,
aead: AeadId.Chacha20Poly1305, // X-CVD-AEAD: 3
});
const raw = Buffer.from(pubB64url, "base64url"); // 32 字节
const pkR = await suite.kem.importKey("raw", raw, true); // 公钥
const sender = await suite.createSenderContext({
recipientPublicKey: pkR,
info: new TextEncoder().encode("cvd-v1"),
});
const ct = new Uint8Array(await sender.seal(yamlBytes)); // aad 默认空
const enc = new Uint8Array(sender.enc); // 32 字节
const body = new Uint8Array(enc.length + ct.length);
body.set(enc, 0); body.set(ct, enc.length);
return Buffer.from(body).toString("base64url"); // enc || ct
}
库的 API 在不同版本可能略有差异;以 §14 的 KAT 跑通为准。
13.5 PHP 面板(V2board / Xboard 等)
PHP 没有成熟的 HPKE 原生库。两条路:
- 推荐:起一个 Go / Node 的 sidecar(如 §13.3 / §13.4 包成一个内网 HTTP/CLI 服务),PHP 把
pubkey + yaml传进去拿回body。最省事、最不易出错。 - 原生实现:基于 libsodium(PHP
sodium_*)按 RFC 9180 Base 模式手写:- X25519:
sodium_crypto_scalarmult($skE, $pkR);临时密钥对sodium_crypto_box_keypair取其 X25519 部分,或直接sodium_crypto_kx_*的底层 scalarmult。 - HKDF-SHA256 的 Extract/Expand:用
hash_hmac('sha256', ...)自行实现 RFC 5869,并套上 RFC 9180 的LabeledExtract/LabeledExpand(标签前缀"HPKE-v1"+suite_id)。 - AEAD:
sodium_crypto_aead_chacha20poly1305_ietf_encrypt(对应3)或openssl_encrypt('aes-256-gcm', ...)(对应2)。 - 务必用 §14 KAT 验证后再上线;手写 KDF 标签极易出错。
- X25519:
14. 互操作测试向量(KAT)
下列向量由 Clash Verge 客户端的真实加解密代码产出,套件为 DHKEM(X25519,HKDF-SHA256) + HKDF-SHA256 + ChaCha20-Poly1305,info = "cvd-v1",aad = ""。
⚠️ 仅用于测试,切勿在生产中使用此密钥对。
# 接收方(设备)X25519 私钥,base64url-nopad(32 字节)
RECIPIENT_PRIV = wJnpUjoQOd2ih2Ppz4FvpHZKknrif6m0yxo1ZXOAijY
# 对应公钥,即客户端会发来的 X-CVD-Pub,base64url-nopad(32 字节)
RECIPIENT_PUB = 8n7xxaMI-PlvyMurFN4etuMZNqUtg9lvru9vgRUAUxU
AEAD = 3 (ChaCha20-Poly1305)
INFO = "cvd-v1"
# 一条合法密文 body = base64url-nopad(enc(32) || ciphertext)
BODY = I9RQxCOpo0QI30wTsl2wCrNqByOLNJr1baCXumWU9ShlRKEQKchHMcSqWqm9XrUqkCbVIS0Ye1bzB3trh8jQTWM-XBpnx2IOqIxxrywhC2Zb2iha75U1gCwtuzeXOzqDtha4Ktr7eE1edJvjvMShPvHvFjyUE0alwDopvlf-w2A4PjWC7LO-AQnWnnzPzcsgu2dIJDtdXPU
# BODY 解密后应得到的明文(注意结尾换行):
PLAINTEXT =
proxies:
- {name: demo, type: ss, server: 1.2.3.4, port: 8388, cipher: aes-256-gcm, password: s3cret}
怎么用它验证你的实现
- 验证套件互通(最关键):用你的 HPKE 库,以
RECIPIENT_PRIV对BODY(取前 32 字节为enc、其余为ciphertext)做 OpenBase(同套件、info="cvd-v1"、aad="")。结果必须逐字节等于PLAINTEXT。- 这条通过 ⇒ 你的库与客户端套件参数完全一致。
- 验证你的 seal 路径:用你的实现对
RECIPIENT_PUB加密PLAINTEXT,再用RECIPIENT_PRIV解密,应能往返得到PLAINTEXT。(注意:enc含随机临时密钥,你产出的BODY不会与上面的逐字节相同,这是正常的。) - 验证公钥派生:
RECIPIENT_PRIV的 X25519 公钥应等于RECIPIENT_PUB。 - (可选)参照 RFC 9180 Appendix A 的标准 KAT 验证底层原语。
15. 端到端示例
① 客户端请求
GET /api/v1/client/subscribe?token=USER_TOKEN HTTP/1.1
Host: panel.example.com
X-CVD-Ver: 1
X-CVD-Pub: 8n7xxaMI-PlvyMurFN4etuMZNqUtg9lvru9vgRUAUxU
X-CVD-AEAD: 3
User-Agent: clash-verge/vX.Y.Z
② 加密响应(已注册 / 注册成功)
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Cache-Control: no-store
X-CVD-Encrypted: 1
X-CVD-AEAD: 3
I9RQxCOpo0QI30wTsl2wCrNqByOLNJr1baCXumWU9Sh...(base64url-nopad)
③ 设备超限
HTTP/1.1 403 Forbidden
X-CVD-Error: device_limit_exceeded
X-CVD-Max-Devices: 3
④ 明文回退(面板未支持 / 未对该 token 启用 CVD)
HTTP/1.1 200 OK
Content-Type: text/yaml
proxies:
- { ... 明文节点 ... }
16. 实现核对清单(Checklist)
- CDN/WAF/反代已透传
X-CVD-*请求头到后端 - 解析
X-CVD-Pub→ base64url-nopad → 校验 32 字节 (token_hash, pub_key)设备表 + 事务安全的注册/计数/限额- HPKE
SealBase:KEM0x0020/ KDF0x0001/ AEAD0x0003或0x0002/info="cvd-v1"/aad="" - 响应:
200+X-CVD-Encrypted: 1+X-CVD-AEAD: <实际值>+Cache-Control: no-store - body =
base64url_nopad(enc(32) || ciphertext),文本返回 - 超限 →
403+X-CVD-Error: device_limit_exceeded - 无法加密时绝不设
X-CVD-Encrypted: 1(改返回明文或错误码) - 用 §14 KAT 跑通 Open,确认与客户端互通
- (策略)对受保护 token 拒绝明文请求;限制新公钥注册频率;
last_seen回收死槽 - 控制台提供「查看/解绑设备」入口
本文随客户端实现演进;协议常量以仓库 src-tauri/src/cvd/mod.rs 与 §14 KAT 为准。