mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 19:22:44 +08:00
440 lines
21 KiB
Markdown
440 lines
21 KiB
Markdown
# 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 秒速览
|
||
|
||
1. Clash Verge 客户端会在新建 / 导入订阅、手动更新未注册订阅,以及已注册订阅的后续更新中自动附加 `X-CVD-*` 请求头,携带一个 32 字节 X25519 设备公钥。
|
||
2. 面板收到后可以:用该公钥把订阅 YAML 做 **HPKE(RFC 9180)加密**后返回;或返回明文(客户端自动按「不支持 CVD」处理)。
|
||
3. 面板按 `(token, 公钥)` 维护「设备槽位」,可设上限;超限返回 `403 device_limit_exceeded`。
|
||
4. **不改造也不会坏**:面板什么都不做时,客户端收到明文,按现有流程工作。是否上线 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 / 其它头**:
|
||
|
||
```http
|
||
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 表结构(参考)
|
||
|
||
```sql
|
||
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
|
||
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
|
||
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. 客户端校验规则清单(你的响应必须满足)
|
||
|
||
客户端按以下规则解析响应(顺序即优先级):
|
||
|
||
1. `403` 且 `X-CVD-Error` 去空白后等于 `device_limit_exceeded` → 设备超限错误。
|
||
2. 任意非 2xx(除上一条)→ 错误。
|
||
3. 2xx 且 `X-CVD-Encrypted` 去空白后等于字符串 `1`:
|
||
- 读 `X-CVD-AEAD`,去空白后必须是 `2` 或 `3`,否则错误;
|
||
- body 去空白后做 base64url-nopad 解码,失败则错误;
|
||
- 解码长度 < 48 则错误;
|
||
- 否则取前 32 字节为 `enc`、其余为 `ciphertext`,用响应头的 AEAD + `info="cvd-v1"` + 空 AAD 做 HPKE-Open。
|
||
4. 2xx 且无 `X-CVD-Encrypted` 头 → 明文。
|
||
|
||
对照实现:`parse_response` / `hpke_open`(`src-tauri/src/cvd/mod.rs`)。
|
||
|
||
---
|
||
|
||
## 11. 强制策略(真正产生防护价值,由面板决定)
|
||
|
||
协议本身**不强制**任何东西;只有加上以下策略才真正抬高获取者成本:
|
||
|
||
1. **对受保护 token 拒绝明文请求**:对启用 CVD 的 token,拒绝**不带** `X-CVD-Pub`(或带了但你不认)的请求——否则攻击者只要不发这个头就拿到明文,加密形同虚设。这是「成本」的来源。
|
||
2. **设备上限 N**:按套餐设定(如 3/5)。
|
||
3. **按 token / 源 IP 限制「新公钥注册」频率**:防止持有 token 的攻击者刷满槽位,把真实用户锁在外面(§2 提到的 DoS)。
|
||
4. **`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)
|
||
|
||
```go
|
||
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)
|
||
|
||
```js
|
||
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 标签极易出错。
|
||
|
||
---
|
||
|
||
## 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}
|
||
```
|
||
|
||
### 怎么用它验证你的实现
|
||
|
||
1. **验证套件互通(最关键)**:用你的 HPKE 库,以 `RECIPIENT_PRIV` 对 `BODY`(取前 32 字节为 `enc`、其余为 `ciphertext`)做 **OpenBase**(同套件、`info="cvd-v1"`、`aad=""`)。结果必须**逐字节等于** `PLAINTEXT`。
|
||
- 这条通过 ⇒ 你的库与客户端套件参数完全一致。
|
||
2. **验证你的 seal 路径**:用你的实现对 `RECIPIENT_PUB` 加密 `PLAINTEXT`,再用 `RECIPIENT_PRIV` 解密,应能往返得到 `PLAINTEXT`。(注意:`enc` 含随机临时密钥,你产出的 `BODY` 不会与上面的逐字节相同,这是正常的。)
|
||
3. **验证公钥派生**:`RECIPIENT_PRIV` 的 X25519 公钥应等于 `RECIPIENT_PUB`。
|
||
4. (可选)参照 RFC 9180 Appendix A 的标准 KAT 验证底层原语。
|
||
|
||
---
|
||
|
||
## 15. 端到端示例
|
||
|
||
**① 客户端请求**
|
||
|
||
```http
|
||
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
|
||
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
|
||
HTTP/1.1 403 Forbidden
|
||
X-CVD-Error: device_limit_exceeded
|
||
X-CVD-Max-Devices: 3
|
||
```
|
||
|
||
**④ 明文回退(面板未支持 / 未对该 token 启用 CVD)**
|
||
|
||
```http
|
||
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`:KEM `0x0020` / KDF `0x0001` / AEAD `0x0003`或`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 为准。*
|