Files
clash-verge-rev/docs/cvd-server-integration.md
T

440 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 为准。*