mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-06 00:32:13 +08:00
Revert "feat(cvd): add device-bound subscription protocol"
This reverts commit 2cb9c13ab6.
This commit is contained in:
@@ -70,38 +70,27 @@ pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
|
||||
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
|
||||
logging!(info, Type::Cmd, "[导入订阅] 开始导入: {}", help::mask_url(&url));
|
||||
|
||||
// New remote profile: own the CVD device key here and persist it only after the profile is saved.
|
||||
let uid = help::get_uid("R");
|
||||
let key = crate::cvd::DeviceKey::generate(&uid);
|
||||
|
||||
// 直接依赖 PrfItem::from_url 自身的超时/重试逻辑,不再使用 tokio::time::timeout 包裹
|
||||
let item =
|
||||
&mut match PrfItem::from_url(&url, None, None, option.as_ref(), &uid, crate::cvd::CvdMode::New(&key)).await {
|
||||
Ok(it) => {
|
||||
logging!(info, Type::Cmd, "[导入订阅] 下载完成,开始保存配置");
|
||||
it
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Cmd, "[导入订阅] 下载失败: {}", e);
|
||||
return Err(profile_import_error(&e).into());
|
||||
}
|
||||
};
|
||||
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
|
||||
Ok(it) => {
|
||||
logging!(info, Type::Cmd, "[导入订阅] 下载完成,开始保存配置");
|
||||
it
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Cmd, "[导入订阅] 下载失败: {}", e);
|
||||
return Err(profile_import_error(&e).into());
|
||||
}
|
||||
};
|
||||
|
||||
match profiles_append_item_safe(item).await {
|
||||
Ok(_) => {
|
||||
// Persist the device key only if CVD actually engaged (item cached a pubkey). A plaintext
|
||||
// airport leaves cvd_pub None, so we skip the keychain write (and its macOS prompt).
|
||||
if item.cvd_pub.is_some()
|
||||
&& let Err(e) = key.persist()
|
||||
{
|
||||
logging!(warn, Type::Cmd, "cvd: failed to persist device key after import: {e}");
|
||||
}
|
||||
if let Err(e) = profiles_save_file_safe().await {
|
||||
logging!(error, Type::Cmd, "[导入订阅] 保存配置文件失败: {}", e);
|
||||
} else {
|
||||
Ok(_) => match profiles_save_file_safe().await {
|
||||
Ok(_) => {
|
||||
logging!(info, Type::Cmd, "[导入订阅] 配置文件保存成功");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Cmd, "[导入订阅] 保存配置文件失败: {}", e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
logging!(error, Type::Cmd, "[导入订阅] 保存配置失败: {}", e);
|
||||
return Err(format!("导入订阅失败: {}", e).into());
|
||||
@@ -168,27 +157,9 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
|
||||
/// 删除配置文件
|
||||
#[tauri::command]
|
||||
pub async fn delete_profile(index: String) -> CmdResult {
|
||||
// Only touch the keychain if this profile actually cached a CVD key (invariant: cvd_pub ⟺
|
||||
// keychain key). Read that BEFORE deleting the item. Skipping the keychain for the common
|
||||
// (plaintext / non-CVD) case matters on macOS: keyring's delete does a find + a delete, each of
|
||||
// which prompts for the keychain password on an item this binary isn't trusted for — so an
|
||||
// unconditional delete_key popped the dialog twice on every profile deletion.
|
||||
let had_cvd_key = {
|
||||
let profiles = Config::profiles().await;
|
||||
profiles
|
||||
.latest_arc()
|
||||
.get_item(index.as_str())
|
||||
.ok()
|
||||
.is_some_and(|it| it.cvd_pub.is_some())
|
||||
};
|
||||
|
||||
// 使用Send-safe helper函数
|
||||
let should_update = profiles_delete_item_safe(&index).await.stringify_err()?;
|
||||
profiles_save_file_safe().await.stringify_err()?;
|
||||
// Best-effort cleanup of the CVD private key (only when the profile actually had one).
|
||||
if had_cvd_key && let Err(e) = crate::cvd::delete_key(&index) {
|
||||
logging!(warn, Type::Cmd, "Warning: 清理 CVD 密钥失败: {e}");
|
||||
}
|
||||
if let Err(e) = Tray::global().update_tooltip().await {
|
||||
logging!(warn, Type::Cmd, "Warning: 异步更新托盘提示失败: {e}");
|
||||
}
|
||||
|
||||
@@ -58,20 +58,6 @@ pub struct PrfItem {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub home: Option<String>,
|
||||
|
||||
/// Retained for backward compatibility only. This field does NOT gate CVD; a missing or `false`
|
||||
/// value never disables it. Kept so older `profiles.yaml` entries deserialize without losing
|
||||
/// data.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cvd_enabled: Option<bool>,
|
||||
|
||||
/// Cached CVD device **public** key (base64url, 32 bytes) for this remote subscription. This is
|
||||
/// NOT a secret — it is advertised in the `X-CVD-Pub` header whenever CVD is active. It lives
|
||||
/// here so a refresh can build the request header without touching the OS keychain (which, per
|
||||
/// profile, would prompt on macOS). The matching private key stays in the keychain under
|
||||
/// `cvd/<uid>`. Absent for local profiles, pre-CVD remotes, and airports we never tried CVD on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cvd_pub: Option<String>,
|
||||
|
||||
/// the file data
|
||||
#[serde(skip)]
|
||||
pub file_data: Option<String>,
|
||||
@@ -168,10 +154,7 @@ impl PrfOption {
|
||||
impl PrfItem {
|
||||
/// From partial item
|
||||
/// must contain `itype`
|
||||
/// Build a new profile item. For remote items the returned [`crate::cvd::DeviceKey`] (if any)
|
||||
/// must be persisted by the caller **after** the item is saved — see
|
||||
/// [`crate::config::profiles_append_item_with_filedata_safe`].
|
||||
pub async fn from(item: &Self, file_data: Option<String>) -> Result<(Self, Option<crate::cvd::DeviceKey>)> {
|
||||
pub async fn from(item: &Self, file_data: Option<String>) -> Result<Self> {
|
||||
if item.itype.is_none() {
|
||||
bail!("type should not be null");
|
||||
}
|
||||
@@ -188,42 +171,14 @@ impl PrfItem {
|
||||
.ok_or_else(|| anyhow::anyhow!("url should not be null"))?;
|
||||
let name = item.name.as_ref();
|
||||
let desc = item.desc.as_ref();
|
||||
|
||||
// Mint the uid and the device key once, and reuse the key across the proxy-fallback
|
||||
// retry so both attempts present the SAME public key (one server slot). The key is
|
||||
// returned to the caller, which persists it to the keychain only after the profile is
|
||||
// saved — so a failed create never writes (and never orphans) a key.
|
||||
let uid = help::get_uid("R");
|
||||
let key = crate::cvd::DeviceKey::generate(&uid);
|
||||
|
||||
let built = match Self::from_url(
|
||||
url,
|
||||
name,
|
||||
desc,
|
||||
item.option.as_ref(),
|
||||
&uid,
|
||||
crate::cvd::CvdMode::New(&key),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(out) => out,
|
||||
Err(primary_err) => {
|
||||
// Retry once via the Clash self-proxy, reusing the same uid/key.
|
||||
let mut retry_opt = item.option.clone().unwrap_or_default();
|
||||
retry_opt.self_proxy = Some(true);
|
||||
retry_opt.with_proxy = Some(false);
|
||||
Self::from_url(url, name, desc, Some(&retry_opt), &uid, crate::cvd::CvdMode::New(&key))
|
||||
.await
|
||||
.map_err(|_| primary_err)?
|
||||
}
|
||||
};
|
||||
Ok((built, Some(key)))
|
||||
let option = item.option.as_ref();
|
||||
Self::from_url(url, name, desc, option).await
|
||||
}
|
||||
"local" => {
|
||||
let name = item.name.clone().unwrap_or_else(|| "Local File".into());
|
||||
let desc = item.desc.clone().unwrap_or_else(|| "".into());
|
||||
let option = item.option.as_ref();
|
||||
Ok((Self::from_local(name, desc, file_data, option).await?, None))
|
||||
Self::from_local(name, desc, file_data, option).await
|
||||
}
|
||||
typ => bail!("invalid profile item type \"{typ}\""),
|
||||
}
|
||||
@@ -291,8 +246,6 @@ impl PrfItem {
|
||||
..PrfOption::default()
|
||||
}),
|
||||
home: None,
|
||||
cvd_enabled: None,
|
||||
cvd_pub: None,
|
||||
updated: Some(chrono::Local::now().timestamp() as usize),
|
||||
file_data: Some(file_data.unwrap_or_else(|| tmpl::ITEM_LOCAL.into())),
|
||||
})
|
||||
@@ -300,17 +253,11 @@ impl PrfItem {
|
||||
|
||||
/// ## Remote type
|
||||
/// create a new item from url
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
pub async fn from_url(
|
||||
url: &str,
|
||||
name: Option<&String>,
|
||||
desc: Option<&String>,
|
||||
option: Option<&PrfOption>,
|
||||
// The stable uid for this profile (= its `<uid>.yaml` and keychain `cvd/<uid>`). The caller
|
||||
// always chooses it: a fresh id for a new profile, or the existing id when refreshing.
|
||||
uid: &str,
|
||||
// How to use CVD this fetch. The caller owns the device-key lifecycle; `from_url` only reads.
|
||||
cvd: crate::cvd::CvdMode<'_>,
|
||||
) -> Result<Self> {
|
||||
let with_proxy = option.is_some_and(|o| o.with_proxy.unwrap_or(false));
|
||||
let self_proxy = option.is_some_and(|o| o.self_proxy.unwrap_or(false));
|
||||
@@ -336,18 +283,6 @@ impl PrfItem {
|
||||
|
||||
let url = fix_dirty_url(url)?;
|
||||
|
||||
let uid: String = uid.into();
|
||||
|
||||
// Build the CVD request header from the caller-supplied key (new) or cached public key
|
||||
// (refresh). `from_url` never writes the keychain: new keys are persisted by the caller only
|
||||
// after the profile is saved, and refreshes read the private key lazily (only to decrypt).
|
||||
let cvd_headers = cvd
|
||||
.header_public()
|
||||
.map(|pk| crate::cvd::request_headers(&pk))
|
||||
.unwrap_or_default();
|
||||
// Public key (non-secret) to cache on the built item so future refreshes need no keychain.
|
||||
let cvd_pub_cache = cvd.cached_public_b64();
|
||||
|
||||
// 使用网络管理器发送请求
|
||||
let resp = match NetworkManager::new()
|
||||
.get_with_interrupt(
|
||||
@@ -356,7 +291,6 @@ impl PrfItem {
|
||||
Some(timeout),
|
||||
user_agent.clone(),
|
||||
accept_invalid_certs,
|
||||
cvd_headers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -368,49 +302,10 @@ impl PrfItem {
|
||||
};
|
||||
|
||||
let status_code = resp.status();
|
||||
|
||||
// CVD: classify the response before the generic status check (device-limit is a 403).
|
||||
// Only when CVD is active this fetch (we advertised a public key).
|
||||
let cvd_yaml: Option<std::string::String> = if cvd.is_active() {
|
||||
match crate::cvd::parse_response(status_code, resp.headers(), resp.text_with_charset()?) {
|
||||
crate::cvd::CvdResponse::Encrypted { aead, payload } => match &cvd {
|
||||
// New profile: decrypt with the caller's in-memory key (not yet in the keychain).
|
||||
crate::cvd::CvdMode::New(key) => Some(key.open(aead, &payload)?),
|
||||
// Refresh: read the private key lazily — only now, only for an actual ciphertext.
|
||||
crate::cvd::CvdMode::Existing { .. } => match crate::cvd::load_device_secret(uid.as_str())? {
|
||||
Some(secret) => Some(crate::cvd::open_with_secret(&secret, aead, &payload)?),
|
||||
// Cached public key but no private key → the device was restored, or an earlier
|
||||
// persist failed. This response was sealed to the lost key and is unrecoverable;
|
||||
// signal the caller (which re-registers a fresh key for the next refresh). We
|
||||
// stay pure here — no keychain or profile write inside from_url.
|
||||
None => return Err(anyhow::Error::new(crate::cvd::CvdKeyMissing)),
|
||||
},
|
||||
crate::cvd::CvdMode::Disabled => None, // unreachable: cvd.is_active() == false here
|
||||
},
|
||||
crate::cvd::CvdResponse::DeviceLimit => {
|
||||
bail!("{}", clash_verge_i18n::t!("errors.cvdDeviceLimit"))
|
||||
}
|
||||
crate::cvd::CvdResponse::Error(msg) => bail!(msg),
|
||||
crate::cvd::CvdResponse::Plaintext => None, // airport does not support CVD — use plaintext
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if cvd_yaml.is_none() && !status_code.is_success() {
|
||||
if !status_code.is_success() {
|
||||
bail!("failed to fetch remote profile with status {status_code}")
|
||||
}
|
||||
|
||||
// Keep a device key ONLY when CVD actually engaged (the server returned ciphertext). A
|
||||
// brand-new profile whose airport answered in plaintext drops the key entirely: caching its
|
||||
// public key and persisting its private key would be a pointless keychain write — and on
|
||||
// macOS, a password prompt — for a feature the server isn't using. Refreshes keep their
|
||||
// existing cached key regardless (cvd_yaml.is_some() iff we decrypted ciphertext).
|
||||
let cvd_pub_cache = match cvd {
|
||||
crate::cvd::CvdMode::New(_) if cvd_yaml.is_none() => None,
|
||||
_ => cvd_pub_cache,
|
||||
};
|
||||
|
||||
let header = resp.headers();
|
||||
|
||||
// parse the Subscription UserInfo
|
||||
@@ -479,14 +374,12 @@ impl PrfItem {
|
||||
None => None,
|
||||
};
|
||||
|
||||
let uid = help::get_uid("R").into();
|
||||
let file = format!("{uid}.yaml").into();
|
||||
let name = name
|
||||
.map(|s| s.to_owned())
|
||||
.unwrap_or_else(|| filename.map(|s| s.into()).unwrap_or_else(|| "Remote File".into()));
|
||||
let data = match cvd_yaml.as_deref() {
|
||||
Some(yaml) => yaml,
|
||||
None => resp.text_with_charset()?,
|
||||
};
|
||||
let data = resp.text_with_charset()?;
|
||||
|
||||
// process the charset "UTF-8 with BOM"
|
||||
let data = data.trim_start_matches('\u{feff}');
|
||||
@@ -544,9 +437,6 @@ impl PrfItem {
|
||||
..PrfOption::default()
|
||||
}),
|
||||
home,
|
||||
cvd_enabled: None,
|
||||
// Cache the (non-secret) public key so future refreshes need no keychain access.
|
||||
cvd_pub: cvd_pub_cache.map(Into::into),
|
||||
updated: Some(chrono::Local::now().timestamp() as usize),
|
||||
file_data: Some(data.into()),
|
||||
})
|
||||
|
||||
@@ -214,23 +214,6 @@ impl IProfiles {
|
||||
bail!("failed to find the profile item \"uid:{uid}\"")
|
||||
}
|
||||
|
||||
/// Set (or clear with `None`) the cached CVD public key for a profile, then persist. Used by the
|
||||
/// refresh recovery to repoint a profile at a freshly re-registered key, or to drop the claim
|
||||
/// when the keychain is unavailable. Touches only `cvd_pub` metadata — never the keychain.
|
||||
pub async fn set_cvd_pub(&mut self, uid: &str, cvd_pub: Option<String>) -> Result<()> {
|
||||
let items = self
|
||||
.items
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("no profiles loaded"))?;
|
||||
for each in items.iter_mut() {
|
||||
if each.uid.as_deref() == Some(uid) {
|
||||
each.cvd_pub = cvd_pub;
|
||||
return self.save_file().await;
|
||||
}
|
||||
}
|
||||
bail!("failed to find the profile item \"uid:{uid}\"")
|
||||
}
|
||||
|
||||
/// be used to update the remote item
|
||||
/// only patch `updated` `extra` `file_data`
|
||||
pub async fn update_item(&mut self, uid: &String, item: &mut PrfItem) -> Result<()> {
|
||||
@@ -546,22 +529,8 @@ impl IProfiles {
|
||||
use crate::config::Config;
|
||||
|
||||
pub async fn profiles_append_item_with_filedata_safe(item: &PrfItem, file_data: Option<String>) -> Result<()> {
|
||||
let (mut built, device_key) = PrfItem::from(item, file_data).await?;
|
||||
profiles_append_item_safe(&mut built).await?;
|
||||
// Write the CVD private key only now that the profile is durably saved (write-key-last), and only
|
||||
// if CVD actually engaged (item cached a pubkey). A plaintext airport leaves cvd_pub None, so we
|
||||
// skip the keychain write (and its macOS prompt) entirely.
|
||||
if let Some(key) = device_key
|
||||
&& built.cvd_pub.is_some()
|
||||
&& let Err(e) = key.persist()
|
||||
{
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"cvd: failed to persist device key after profile create: {e}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
let item = &mut PrfItem::from(item, file_data).await?;
|
||||
profiles_append_item_safe(item).await
|
||||
}
|
||||
|
||||
pub async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> {
|
||||
@@ -623,16 +592,3 @@ pub async fn profiles_draft_update_item_safe(index: &String, item: &mut PrfItem)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Set (or clear) a profile's cached CVD public key. `with_data_modify` clones-then-commits and
|
||||
/// holds no lock across the await, so this is safe to call from inside a refresh.
|
||||
pub async fn profiles_set_cvd_pub_safe(uid: &str, cvd_pub: Option<String>) -> Result<()> {
|
||||
let uid = uid.to_owned();
|
||||
Config::profiles()
|
||||
.await
|
||||
.with_data_modify(|mut profiles| async move {
|
||||
profiles.set_cvd_pub(&uid, cvd_pub).await?;
|
||||
Ok((profiles, ()))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,556 +0,0 @@
|
||||
//! CVD (Clash Verge Device-binding) protocol — client side.
|
||||
//! See docs/cvd-server-integration.md
|
||||
|
||||
/// HPKE `info` string, fixed per spec §3.
|
||||
const INFO: &[u8] = b"cvd-v1";
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use hpke::{
|
||||
Deserializable as _, OpModeR,
|
||||
aead::{AesGcm256, ChaCha20Poly1305},
|
||||
kdf::HkdfSha256,
|
||||
kem::X25519HkdfSha256,
|
||||
single_shot_open,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// HPKE KEM fixed per spec §3: DHKEM(X25519, HKDF-SHA256).
|
||||
type Kem = X25519HkdfSha256;
|
||||
|
||||
/// AEAD suite ids per RFC 9180 §7.3 / spec §3.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CvdAead {
|
||||
Aes256Gcm = 0x0002,
|
||||
ChaCha20Poly1305 = 0x0003,
|
||||
}
|
||||
|
||||
impl CvdAead {
|
||||
/// Parse the decimal value of `X-CVD-AEAD` (`"2"` / `"3"`).
|
||||
pub fn from_header_value(v: &str) -> Option<Self> {
|
||||
match v.trim() {
|
||||
"2" => Some(Self::Aes256Gcm),
|
||||
"3" => Some(Self::ChaCha20Poly1305),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate an X25519 static keypair. Returns (private 32B zeroizing, public 32B).
|
||||
pub fn generate_keypair() -> (Zeroizing<[u8; 32]>, [u8; 32]) {
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
let secret = StaticSecret::random_from_rng(rand_core::OsRng);
|
||||
let public = PublicKey::from(&secret);
|
||||
(Zeroizing::new(secret.to_bytes()), public.to_bytes())
|
||||
}
|
||||
|
||||
/// HPKE Base-mode open. `payload = enc[32] || ciphertext`, `info = b"cvd-v1"`.
|
||||
pub fn hpke_open(secret: &[u8; 32], aead: CvdAead, payload: &[u8]) -> Result<Vec<u8>> {
|
||||
if payload.len() < 48 {
|
||||
bail!("cvd payload too short ({} bytes)", payload.len());
|
||||
}
|
||||
let (enc_bytes, ciphertext) = payload.split_at(32);
|
||||
let sk = <Kem as hpke::Kem>::PrivateKey::from_bytes(secret).map_err(|e| anyhow!("bad cvd private key: {e}"))?;
|
||||
let enc = <Kem as hpke::Kem>::EncappedKey::from_bytes(enc_bytes).map_err(|e| anyhow!("bad cvd enc key: {e}"))?;
|
||||
let pt = match aead {
|
||||
CvdAead::ChaCha20Poly1305 => {
|
||||
single_shot_open::<ChaCha20Poly1305, HkdfSha256, Kem>(&OpModeR::Base, &sk, &enc, INFO, ciphertext, b"")
|
||||
}
|
||||
CvdAead::Aes256Gcm => {
|
||||
single_shot_open::<AesGcm256, HkdfSha256, Kem>(&OpModeR::Base, &sk, &enc, INFO, ciphertext, b"")
|
||||
}
|
||||
}
|
||||
.map_err(|e| anyhow!("cvd decryption failed: {e}"))?;
|
||||
Ok(pt)
|
||||
}
|
||||
|
||||
/// Build the opportunistic CVD request headers (spec §5.1). Does not touch URL/query.
|
||||
pub fn request_headers(public: &[u8; 32]) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("X-CVD-Ver", HeaderValue::from_static("1"));
|
||||
h.insert("X-CVD-AEAD", HeaderValue::from_static("3")); // declared preference: ChaCha20-Poly1305
|
||||
let pub_b64 = URL_SAFE_NO_PAD.encode(public);
|
||||
if let Ok(v) = HeaderValue::from_str(&pub_b64) {
|
||||
h.insert("X-CVD-Pub", v);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Outcome of inspecting a subscription response under CVD.
|
||||
#[derive(Debug)]
|
||||
pub enum CvdResponse {
|
||||
/// `payload = enc[32] || ciphertext`, already base64url-decoded.
|
||||
Encrypted { aead: CvdAead, payload: Vec<u8> },
|
||||
/// No `X-CVD-Encrypted` header — unmodified airport / token without CVD policy.
|
||||
Plaintext,
|
||||
/// 403 + `X-CVD-Error: device_limit_exceeded`.
|
||||
DeviceLimit,
|
||||
/// Other non-2xx, or `X-CVD-Encrypted: 1` with malformed body. Never falls back to plaintext.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Decide how to handle a subscription response. `body` is the (text-decoded) HTTP body;
|
||||
/// for encrypted responses it is base64url(enc || ciphertext).
|
||||
pub fn parse_response(status: StatusCode, headers: &HeaderMap, body: &str) -> CvdResponse {
|
||||
// 1. Device-limit takes precedence (it is a 403, checked before any success check).
|
||||
if status == StatusCode::FORBIDDEN
|
||||
&& headers
|
||||
.get("X-CVD-Error")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| v.trim() == "device_limit_exceeded")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return CvdResponse::DeviceLimit;
|
||||
}
|
||||
|
||||
// 2. Per spec §5.4, any other non-2xx is an error (even if it carries encrypted headers).
|
||||
if !status.is_success() {
|
||||
return CvdResponse::Error(format!("failed to fetch remote profile with status {status}"));
|
||||
}
|
||||
|
||||
// 3. Status is 2xx — check for encrypted response.
|
||||
let is_encrypted = headers
|
||||
.get("X-CVD-Encrypted")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| v.trim() == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_encrypted {
|
||||
let aead = match headers
|
||||
.get("X-CVD-AEAD")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(CvdAead::from_header_value)
|
||||
{
|
||||
Some(a) => a,
|
||||
None => return CvdResponse::Error("missing or invalid X-CVD-AEAD on encrypted response".into()),
|
||||
};
|
||||
let payload = match URL_SAFE_NO_PAD.decode(body.trim().as_bytes()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return CvdResponse::Error(format!("cvd body is not valid base64url: {e}")),
|
||||
};
|
||||
if payload.len() < 48 {
|
||||
return CvdResponse::Error(format!("cvd payload too short ({} bytes)", payload.len()));
|
||||
}
|
||||
return CvdResponse::Encrypted { aead, payload };
|
||||
}
|
||||
|
||||
// 4. Success, no encryption headers — plaintext.
|
||||
CvdResponse::Plaintext
|
||||
}
|
||||
|
||||
const KEYCHAIN_SERVICE: &str = "clash-verge-rev";
|
||||
|
||||
fn keychain_user(profile_uid: &str) -> String {
|
||||
format!("cvd/{profile_uid}")
|
||||
}
|
||||
|
||||
/// Delete the keychain private key for `profile_uid`. Missing entry is treated as success.
|
||||
pub fn delete_key(profile_uid: &str) -> Result<()> {
|
||||
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, &keychain_user(profile_uid))
|
||||
.map_err(|e| anyhow!("keychain unavailable: {e}"))?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(anyhow!("failed to delete cvd key: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// An X25519 device keypair held in memory. The private key is written to the OS keychain only via
|
||||
/// [`persist`], which the caller invokes *after* the owning profile has been saved — so a fetch or
|
||||
/// save that fails never leaves an orphaned key (or the server slot it would register). The public
|
||||
/// key is cached (non-secret) in `profiles.yaml` so later refreshes never need to touch the keychain.
|
||||
///
|
||||
/// [`persist`]: DeviceKey::persist
|
||||
pub struct DeviceKey {
|
||||
uid: String,
|
||||
public: [u8; 32],
|
||||
secret: Zeroizing<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl DeviceKey {
|
||||
/// Generate a fresh keypair in memory. Does not touch the keychain.
|
||||
pub fn generate(uid: &str) -> Self {
|
||||
let (secret, public) = generate_keypair();
|
||||
Self {
|
||||
uid: uid.to_owned(),
|
||||
public,
|
||||
secret,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 32-byte public key (sent in the `X-CVD-Pub` header).
|
||||
pub const fn public(&self) -> &[u8; 32] {
|
||||
&self.public
|
||||
}
|
||||
|
||||
/// base64url(no-pad) of the public key, for caching in `profiles.yaml`.
|
||||
pub fn public_b64(&self) -> String {
|
||||
URL_SAFE_NO_PAD.encode(self.public)
|
||||
}
|
||||
|
||||
/// Write the private key to the OS keychain (`cvd/<uid>`). Call this only after the owning
|
||||
/// profile is durably saved, so a failed create never persists a key.
|
||||
pub fn persist(&self) -> Result<()> {
|
||||
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, &keychain_user(&self.uid))
|
||||
.map_err(|e| anyhow!("keychain unavailable: {e}"))?;
|
||||
entry
|
||||
.set_password(&URL_SAFE_NO_PAD.encode(*self.secret))
|
||||
.map_err(|e| anyhow!("failed to store cvd key: {e}"))
|
||||
}
|
||||
|
||||
/// HPKE-open a payload with this in-memory secret (used on the create path, where the key is not
|
||||
/// yet in the keychain). Returns plaintext YAML.
|
||||
pub fn open(&self, aead: CvdAead, payload: &[u8]) -> Result<String> {
|
||||
open_with_secret(&self.secret, aead, payload)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a cached base64url public key (from `profiles.yaml`) into 32 raw bytes.
|
||||
pub fn public_from_b64(b64: &str) -> Option<[u8; 32]> {
|
||||
let bytes = URL_SAFE_NO_PAD.decode(b64.trim()).ok()?;
|
||||
bytes.as_slice().try_into().ok()
|
||||
}
|
||||
|
||||
/// Load the private key for `profile_uid` from the keychain. `Ok(None)` means there is no entry
|
||||
/// (e.g. the profile was restored to a new device, or its key was never persisted) — the caller uses
|
||||
/// that to trigger re-registration. Used on the refresh path, lazily, only when a ciphertext actually
|
||||
/// needs decrypting, so plain (non-CVD) refreshes never touch the keychain.
|
||||
pub fn load_device_secret(profile_uid: &str) -> Result<Option<Zeroizing<[u8; 32]>>> {
|
||||
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, &keychain_user(profile_uid))
|
||||
.map_err(|e| anyhow!("keychain unavailable: {e}"))?;
|
||||
match entry.get_password() {
|
||||
Ok(b64) => {
|
||||
let bytes: Zeroizing<Vec<u8>> = Zeroizing::new(
|
||||
URL_SAFE_NO_PAD
|
||||
.decode(b64.trim())
|
||||
.map_err(|e| anyhow!("corrupt stored cvd key: {e}"))?,
|
||||
);
|
||||
let arr: [u8; 32] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("stored cvd key is not 32 bytes"))?;
|
||||
Ok(Some(Zeroizing::new(arr)))
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(anyhow!("keychain read failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// HPKE-open a payload with a raw 32-byte secret. Returns plaintext YAML.
|
||||
pub fn open_with_secret(secret: &[u8; 32], aead: CvdAead, payload: &[u8]) -> Result<String> {
|
||||
let pt = hpke_open(secret, aead, payload)?;
|
||||
String::from_utf8(pt).map_err(|e| anyhow!("decrypted cvd payload is not valid UTF-8: {e}"))
|
||||
}
|
||||
|
||||
/// Marker error: a refresh received ciphertext but the profile's private key is absent from the
|
||||
/// keychain (the device was restored, or an earlier `persist` failed). The caller detects this via
|
||||
/// `downcast_ref` and re-registers a fresh key instead of retrying — this response was sealed to the
|
||||
/// lost key and can never be decrypted.
|
||||
#[derive(Debug)]
|
||||
pub struct CvdKeyMissing;
|
||||
|
||||
impl std::fmt::Display for CvdKeyMissing {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "cvd private key missing")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CvdKeyMissing {}
|
||||
|
||||
/// How a single remote fetch should use CVD. Chosen by the caller, who owns the device-key
|
||||
/// lifecycle (generate → use → persist-after-save), so `from_url` never writes to the keychain.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum CvdMode<'a> {
|
||||
/// Brand-new profile (or the first key for a pre-CVD one): use this in-memory key for the
|
||||
/// header and to decrypt. The caller calls [`DeviceKey::persist`] only after the profile is
|
||||
/// saved, so a failed fetch/save never leaves an orphaned key or server slot.
|
||||
New(&'a DeviceKey),
|
||||
/// Refreshing a profile that already cached a public key: advertise the cached key; read the
|
||||
/// private key from the keychain lazily, only to decrypt an actual ciphertext.
|
||||
Existing { public: [u8; 32] },
|
||||
/// No CVD this fetch (keychain-free) — e.g. a silent auto-update of a profile that has no
|
||||
/// cached key yet.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl CvdMode<'_> {
|
||||
/// The public key to advertise in the request header, if any.
|
||||
pub const fn header_public(&self) -> Option<[u8; 32]> {
|
||||
match self {
|
||||
Self::New(k) => Some(*k.public()),
|
||||
Self::Existing { public } => Some(*public),
|
||||
Self::Disabled => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The base64url public key to cache on the built item, if any.
|
||||
pub fn cached_public_b64(&self) -> Option<std::string::String> {
|
||||
match self {
|
||||
Self::New(k) => Some(k.public_b64()),
|
||||
Self::Existing { public } => Some(URL_SAFE_NO_PAD.encode(public)),
|
||||
Self::Disabled => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether CVD is active for this fetch (i.e. a header was sent).
|
||||
pub const fn is_active(&self) -> bool {
|
||||
!matches!(self, Self::Disabled)
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only HPKE seal, mirrors the server's encryption (spec §5.5).
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
pub fn hpke_seal(public: &[u8; 32], aead: CvdAead, plaintext: &[u8]) -> Vec<u8> {
|
||||
use hpke::{OpModeS, Serializable as _, single_shot_seal};
|
||||
let pk = <Kem as hpke::Kem>::PublicKey::from_bytes(public).expect("valid pubkey");
|
||||
let mut rng = rand_core::OsRng;
|
||||
let (enc, ct) = match aead {
|
||||
CvdAead::ChaCha20Poly1305 => single_shot_seal::<ChaCha20Poly1305, HkdfSha256, Kem, _>(
|
||||
&OpModeS::Base,
|
||||
&pk,
|
||||
INFO,
|
||||
plaintext,
|
||||
b"",
|
||||
&mut rng,
|
||||
),
|
||||
CvdAead::Aes256Gcm => {
|
||||
single_shot_seal::<AesGcm256, HkdfSha256, Kem, _>(&OpModeS::Base, &pk, INFO, plaintext, b"", &mut rng)
|
||||
}
|
||||
}
|
||||
.expect("seal ok");
|
||||
let mut payload = enc.to_bytes().to_vec();
|
||||
payload.extend_from_slice(&ct);
|
||||
payload
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Once, OnceLock};
|
||||
|
||||
#[test]
|
||||
fn hpke_roundtrip_chacha() {
|
||||
let (sk, pk) = generate_keypair();
|
||||
let plaintext = b"proxies:\n - {name: a}\n";
|
||||
let payload = hpke_seal(&pk, CvdAead::ChaCha20Poly1305, plaintext);
|
||||
let out = hpke_open(&sk, CvdAead::ChaCha20Poly1305, &payload).unwrap();
|
||||
assert_eq!(out, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hpke_roundtrip_aes() {
|
||||
let (sk, pk) = generate_keypair();
|
||||
let plaintext = b"hello-cvd";
|
||||
let payload = hpke_seal(&pk, CvdAead::Aes256Gcm, plaintext);
|
||||
let out = hpke_open(&sk, CvdAead::Aes256Gcm, &payload).unwrap();
|
||||
assert_eq!(out, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hpke_open_rejects_short_payload() {
|
||||
let (sk, _pk) = generate_keypair();
|
||||
let err = hpke_open(&sk, CvdAead::ChaCha20Poly1305, &[0u8; 10]);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hpke_open_wrong_key_fails() {
|
||||
let (_sk1, pk1) = generate_keypair();
|
||||
let (sk2, _pk2) = generate_keypair();
|
||||
let payload = hpke_seal(&pk1, CvdAead::ChaCha20Poly1305, b"secret");
|
||||
assert!(hpke_open(&sk2, CvdAead::ChaCha20Poly1305, &payload).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_headers_format() {
|
||||
let pk = [7u8; 32];
|
||||
let h = request_headers(&pk);
|
||||
assert_eq!(h.get("X-CVD-Ver").unwrap(), "1");
|
||||
assert_eq!(h.get("X-CVD-AEAD").unwrap(), "3");
|
||||
let pub_hdr = h.get("X-CVD-Pub").unwrap().to_str().unwrap();
|
||||
assert_eq!(pub_hdr.len(), 43);
|
||||
assert!(!pub_hdr.contains('=') && !pub_hdr.contains('+') && !pub_hdr.contains('/'));
|
||||
}
|
||||
|
||||
fn hdrs(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
|
||||
let mut h = reqwest::header::HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
h.insert(
|
||||
reqwest::header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
|
||||
reqwest::header::HeaderValue::from_str(v).unwrap(),
|
||||
);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_plaintext_when_no_cvd_header() {
|
||||
let r = parse_response(StatusCode::OK, &hdrs(&[]), "proxies: []");
|
||||
assert!(matches!(r, CvdResponse::Plaintext));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_device_limit() {
|
||||
let h = hdrs(&[("X-CVD-Error", "device_limit_exceeded")]);
|
||||
let r = parse_response(StatusCode::FORBIDDEN, &h, "");
|
||||
assert!(matches!(r, CvdResponse::DeviceLimit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_encrypted_ok() {
|
||||
let (sk, pk) = generate_keypair();
|
||||
let payload = hpke_seal(&pk, CvdAead::ChaCha20Poly1305, b"proxies: []");
|
||||
let body = URL_SAFE_NO_PAD.encode(&payload);
|
||||
let h = hdrs(&[("X-CVD-Encrypted", "1"), ("X-CVD-AEAD", "3")]);
|
||||
match parse_response(StatusCode::OK, &h, &body) {
|
||||
CvdResponse::Encrypted { aead, payload: p } => {
|
||||
assert_eq!(aead, CvdAead::ChaCha20Poly1305);
|
||||
assert_eq!(hpke_open(&sk, aead, &p).unwrap(), b"proxies: []");
|
||||
}
|
||||
other => panic!("expected Encrypted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_encrypted_bad_aead_is_error() {
|
||||
let h = hdrs(&[("X-CVD-Encrypted", "1"), ("X-CVD-AEAD", "9")]);
|
||||
let r = parse_response(StatusCode::OK, &h, "AAAA");
|
||||
assert!(matches!(r, CvdResponse::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_encrypted_short_body_is_error() {
|
||||
let body = URL_SAFE_NO_PAD.encode([0u8; 10]);
|
||||
let h = hdrs(&[("X-CVD-Encrypted", "1"), ("X-CVD-AEAD", "3")]);
|
||||
let r = parse_response(StatusCode::OK, &h, &body);
|
||||
assert!(matches!(r, CvdResponse::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_non_success_without_cvd_is_error() {
|
||||
let r = parse_response(StatusCode::INTERNAL_SERVER_ERROR, &hdrs(&[]), "");
|
||||
assert!(matches!(r, CvdResponse::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_encrypted_non_success_is_error() {
|
||||
let h = hdrs(&[("X-CVD-Encrypted", "1"), ("X-CVD-AEAD", "3")]);
|
||||
let r = parse_response(StatusCode::SERVICE_UNAVAILABLE, &h, "AAAA");
|
||||
assert!(matches!(r, CvdResponse::Error(_)));
|
||||
}
|
||||
|
||||
// keyring 3.6.3's built-in `mock` store keeps state in the `Entry` instance only
|
||||
// (`CredentialPersistence::EntryOnly`), so two `Entry::new` calls for the same
|
||||
// service/user do NOT share data — which defeats the cross-call idempotency this test
|
||||
// needs. We supply a tiny shared in-memory keystore keyed by service+user instead.
|
||||
fn store() -> &'static Mutex<HashMap<String, Vec<u8>>> {
|
||||
static STORE: OnceLock<Mutex<HashMap<String, Vec<u8>>>> = OnceLock::new();
|
||||
STORE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SharedCredential {
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl keyring::credential::CredentialApi for SharedCredential {
|
||||
fn set_secret(&self, secret: &[u8]) -> keyring::Result<()> {
|
||||
store().lock().insert(self.key.clone(), secret.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
fn get_secret(&self) -> keyring::Result<Vec<u8>> {
|
||||
store().lock().get(&self.key).cloned().ok_or(keyring::Error::NoEntry)
|
||||
}
|
||||
fn delete_credential(&self) -> keyring::Result<()> {
|
||||
let removed = store().lock().remove(&self.key);
|
||||
match removed {
|
||||
Some(_) => Ok(()),
|
||||
None => Err(keyring::Error::NoEntry),
|
||||
}
|
||||
}
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedBuilder;
|
||||
impl keyring::credential::CredentialBuilderApi for SharedBuilder {
|
||||
fn build(
|
||||
&self,
|
||||
_target: Option<&str>,
|
||||
service: &str,
|
||||
user: &str,
|
||||
) -> keyring::Result<Box<keyring::credential::Credential>> {
|
||||
Ok(Box::new(SharedCredential {
|
||||
key: format!("{service}\u{0}{user}"),
|
||||
}))
|
||||
}
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
static MOCK_INIT: Once = Once::new();
|
||||
fn init_mock_keyring() {
|
||||
MOCK_INIT.call_once(|| {
|
||||
keyring::set_default_credential_builder(Box::new(SharedBuilder));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_key_public_b64_is_43_chars() {
|
||||
let k = DeviceKey::generate("Rdk01");
|
||||
let b64 = k.public_b64();
|
||||
assert_eq!(b64.len(), 43);
|
||||
assert!(!b64.contains('=') && !b64.contains('+') && !b64.contains('/'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_from_b64_roundtrips_device_public() {
|
||||
let k = DeviceKey::generate("Rdk02");
|
||||
let parsed = public_from_b64(&k.public_b64()).expect("parse cached pubkey");
|
||||
assert_eq!(&parsed, k.public());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_key_open_decrypts_payload_sealed_to_its_public() {
|
||||
let k = DeviceKey::generate("Rdk03");
|
||||
let payload = hpke_seal(k.public(), CvdAead::ChaCha20Poly1305, b"proxies: []\n");
|
||||
assert_eq!(k.open(CvdAead::ChaCha20Poly1305, &payload).unwrap(), "proxies: []\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_then_load_device_secret_decrypts() {
|
||||
init_mock_keyring();
|
||||
let uid = "Rdkpersist01";
|
||||
let k = DeviceKey::generate(uid);
|
||||
let pubk = *k.public();
|
||||
k.persist().unwrap();
|
||||
let secret = load_device_secret(uid).unwrap().expect("secret present after persist");
|
||||
let payload = hpke_seal(&pubk, CvdAead::Aes256Gcm, b"hello");
|
||||
assert_eq!(
|
||||
open_with_secret(&secret, CvdAead::Aes256Gcm, &payload).unwrap(),
|
||||
"hello"
|
||||
);
|
||||
delete_key(uid).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_device_secret_is_none_when_absent() {
|
||||
init_mock_keyring();
|
||||
assert!(load_device_secret("Rdkabsent01").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cvd_key_missing_survives_anyhow_downcast() {
|
||||
// The refresh recovery relies on detecting this exact type through anyhow::Error.
|
||||
let err = anyhow!(CvdKeyMissing);
|
||||
assert!(err.downcast_ref::<CvdKeyMissing>().is_some());
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
use crate::{
|
||||
cmd,
|
||||
config::{
|
||||
Config, PrfItem, PrfOption,
|
||||
profiles::{profiles_draft_update_item_safe, profiles_set_cvd_pub_safe},
|
||||
},
|
||||
config::{Config, PrfItem, PrfOption, profiles::profiles_draft_update_item_safe},
|
||||
core::{CoreManager, handle, tray, validate::ValidationOutcome},
|
||||
utils::help::{mask_err, mask_url},
|
||||
};
|
||||
@@ -100,82 +97,6 @@ async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a freshly-created CVD device key after a successful update (write-key-last), but ONLY if
|
||||
/// CVD actually engaged this fetch — i.e. the built item cached a public key because the server
|
||||
/// returned ciphertext. A plaintext airport leaves `cvd_pub` None, so we never write the keychain
|
||||
/// (which on macOS would pop a password prompt for a key the server isn't even using). Best-effort:
|
||||
/// a failed write just means the key self-heals on the next ciphertext.
|
||||
fn persist_cvd_key(item: &PrfItem, key: Option<&crate::cvd::DeviceKey>) {
|
||||
if item.cvd_pub.is_some()
|
||||
&& let Some(key) = key
|
||||
&& let Err(e) = key.persist()
|
||||
{
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"cvd: failed to persist device key after update: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// On a missing-private-key error from a refresh (the device was restored, or an earlier `persist`
|
||||
/// failed), re-register: mint a fresh key, store it, and repoint the profile's cached public key so
|
||||
/// the NEXT refresh registers it with the airport and decrypts. If the keychain itself is
|
||||
/// unavailable, clear the cached pubkey instead — the profile goes dormant until a later manual
|
||||
/// update re-creates it (so we never loop, minting a new server slot each retry).
|
||||
///
|
||||
/// Returns `Some(message)` if it handled a key-missing error (the caller must stop retrying: this
|
||||
/// response was sealed to the lost key and can't be read), or `None` for any other error.
|
||||
///
|
||||
/// No orphan risk: the key is written for a profile that already exists — there is no `append` that
|
||||
/// could fail and strand it.
|
||||
async fn try_cvd_reregister(uid: &String, err: &anyhow::Error) -> Option<std::string::String> {
|
||||
// Only handle the missing-key signal; any other error → None (caller keeps retrying).
|
||||
err.downcast_ref::<crate::cvd::CvdKeyMissing>()?;
|
||||
let newkey = crate::cvd::DeviceKey::generate(uid);
|
||||
|
||||
// ORDER MATTERS: record the new public key BEFORE writing the private key. If we persisted first
|
||||
// and the cvd_pub write then failed, the keychain would hold the NEW private key while
|
||||
// profiles.yaml still advertised the OLD public key. The server would seal to OLD, we'd hold only
|
||||
// NEW, and decryption would fail as a plain *error* (not "key missing") — so recovery would never
|
||||
// re-trigger and the profile would be stuck forever. Writing cvd_pub first means every failure
|
||||
// below leaves a self-healing "key missing" state (no private key for the advertised pubkey).
|
||||
if let Err(e) = profiles_set_cvd_pub_safe(uid.as_str(), Some(newkey.public_b64().into())).await {
|
||||
// Could not record the new pubkey → leave the existing missing-key state untouched; it
|
||||
// self-heals on the next refresh (cvd_pub still points at a key we don't have → CvdKeyMissing).
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"[CVD] could not record re-registered pubkey for {uid}: {e}; will retry next refresh"
|
||||
);
|
||||
return Some(clash_verge_i18n::t!("errors.cvdKeyMissing").into_owned());
|
||||
}
|
||||
|
||||
// cvd_pub now references the new key; persist the matching private key.
|
||||
match newkey.persist() {
|
||||
Ok(()) => {
|
||||
logging!(
|
||||
info,
|
||||
Type::Config,
|
||||
"[CVD] re-registered device key for {uid}; next refresh will sync"
|
||||
);
|
||||
Some(clash_verge_i18n::t!("errors.cvdKeyReregistered").into_owned())
|
||||
}
|
||||
Err(e) => {
|
||||
// Keychain unavailable/denied → drop the cvd_pub claim so the profile goes dormant instead
|
||||
// of re-minting (and burning a server slot) on every refresh. Best-effort: if this clear
|
||||
// also fails the state is still "key missing" (self-healing), never the stuck mismatch.
|
||||
let _ = profiles_set_cvd_pub_safe(uid.as_str(), None).await;
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"[CVD] re-register failed (keychain unavailable) for {uid}: {e}"
|
||||
);
|
||||
Some(clash_verge_i18n::t!("errors.cvdKeyMissing").into_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn perform_profile_update(
|
||||
uid: &String,
|
||||
url: &String,
|
||||
@@ -196,39 +117,15 @@ async fn perform_profile_update(
|
||||
.cloned()
|
||||
.unwrap_or_else(|| String::from("UnKnown Profile"));
|
||||
|
||||
// Decide how this refresh uses CVD. A profile that already cached its public key refreshes with
|
||||
// it WITHOUT touching the keychain (so silent auto-updates never prompt on macOS); the private
|
||||
// key is read lazily, only if the server actually returns ciphertext. A pre-CVD profile (no
|
||||
// cached key) gets a new key only on a MANUAL update — a silent auto-update stays plaintext so
|
||||
// it can never trigger a keychain prompt. All three proxy retries share one key → one slot.
|
||||
let cvd_pub_cached = profiles_arc
|
||||
.get_item(uid.as_str())
|
||||
.ok()
|
||||
.and_then(|it| it.cvd_pub.as_deref().and_then(crate::cvd::public_from_b64));
|
||||
let new_key = if cvd_pub_cached.is_none() && is_mannual_trigger {
|
||||
Some(crate::cvd::DeviceKey::generate(uid))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cvd_mode = match (cvd_pub_cached, new_key.as_ref()) {
|
||||
(Some(public), _) => crate::cvd::CvdMode::Existing { public },
|
||||
(None, Some(key)) => crate::cvd::CvdMode::New(key),
|
||||
(None, None) => crate::cvd::CvdMode::Disabled,
|
||||
};
|
||||
|
||||
let mut last_err;
|
||||
|
||||
match PrfItem::from_url(url, None, None, merged_opt.as_ref(), uid.as_str(), cvd_mode).await {
|
||||
match PrfItem::from_url(url, None, None, merged_opt.as_ref()).await {
|
||||
Ok(mut item) => {
|
||||
logging!(info, Type::Config, "[订阅更新] 更新订阅配置成功");
|
||||
profiles_draft_update_item_safe(uid, &mut item).await?;
|
||||
persist_cvd_key(&item, new_key.as_ref());
|
||||
return Ok(is_current);
|
||||
}
|
||||
Err(err) => {
|
||||
if let Some(msg) = try_cvd_reregister(uid, &err).await {
|
||||
bail!("{msg}");
|
||||
}
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
@@ -242,19 +139,15 @@ async fn perform_profile_update(
|
||||
merged_opt.get_or_insert_with(PrfOption::default).self_proxy = Some(true);
|
||||
merged_opt.get_or_insert_with(PrfOption::default).with_proxy = Some(false);
|
||||
|
||||
match PrfItem::from_url(url, None, None, merged_opt.as_ref(), uid.as_str(), cvd_mode).await {
|
||||
match PrfItem::from_url(url, None, None, merged_opt.as_ref()).await {
|
||||
Ok(mut item) => {
|
||||
logging!(info, Type::Config, "[订阅更新] 使用 Clash代理 更新订阅配置成功");
|
||||
profiles_draft_update_item_safe(uid, &mut item).await?;
|
||||
persist_cvd_key(&item, new_key.as_ref());
|
||||
handle::Handle::notice_message("update_with_clash_proxy", profile_name);
|
||||
drop(last_err);
|
||||
return Ok(is_current);
|
||||
}
|
||||
Err(err) => {
|
||||
if let Some(msg) = try_cvd_reregister(uid, &err).await {
|
||||
bail!("{msg}");
|
||||
}
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
@@ -268,19 +161,15 @@ async fn perform_profile_update(
|
||||
merged_opt.get_or_insert_with(PrfOption::default).self_proxy = Some(false);
|
||||
merged_opt.get_or_insert_with(PrfOption::default).with_proxy = Some(true);
|
||||
|
||||
match PrfItem::from_url(url, None, None, merged_opt.as_ref(), uid.as_str(), cvd_mode).await {
|
||||
match PrfItem::from_url(url, None, None, merged_opt.as_ref()).await {
|
||||
Ok(mut item) => {
|
||||
logging!(info, Type::Config, "[订阅更新] 使用 系统代理 更新订阅配置成功");
|
||||
profiles_draft_update_item_safe(uid, &mut item).await?;
|
||||
persist_cvd_key(&item, new_key.as_ref());
|
||||
handle::Handle::notice_message("update_with_clash_proxy", profile_name);
|
||||
drop(last_err);
|
||||
return Ok(is_current);
|
||||
}
|
||||
Err(err) => {
|
||||
if let Some(msg) = try_cvd_reregister(uid, &err).await {
|
||||
bail!("{msg}");
|
||||
}
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
|
||||
@@ -5,7 +5,6 @@ mod cmd;
|
||||
pub mod config;
|
||||
mod constants;
|
||||
mod core;
|
||||
mod cvd;
|
||||
mod enhance;
|
||||
mod feat;
|
||||
mod module;
|
||||
|
||||
@@ -178,7 +178,6 @@ impl NetworkManager {
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn get_with_tls_mode(
|
||||
&self,
|
||||
url: &str,
|
||||
@@ -187,7 +186,6 @@ impl NetworkManager {
|
||||
user_agent: Option<String>,
|
||||
accept_invalid_certs: bool,
|
||||
tls_root_mode: TlsRootMode,
|
||||
cvd_headers: HeaderMap,
|
||||
) -> Result<HttpResponse> {
|
||||
let mut parsed = Url::parse(url)?;
|
||||
let mut extra_headers = HeaderMap::new();
|
||||
@@ -220,10 +218,6 @@ impl NetworkManager {
|
||||
|
||||
let mut request_builder = client.get(parsed);
|
||||
|
||||
// Merge CVD headers (and any future caller-supplied headers) with URL-userinfo auth.
|
||||
for (key, value) in cvd_headers.iter() {
|
||||
extra_headers.insert(key.clone(), value.clone());
|
||||
}
|
||||
for (key, value) in extra_headers.iter() {
|
||||
request_builder = request_builder.header(key, value);
|
||||
}
|
||||
@@ -298,7 +292,6 @@ impl NetworkManager {
|
||||
timeout_secs: Option<u64>,
|
||||
user_agent: Option<String>,
|
||||
accept_invalid_certs: bool,
|
||||
extra_headers: HeaderMap,
|
||||
) -> Result<HttpResponse> {
|
||||
let platform_result = self
|
||||
.get_with_tls_mode(
|
||||
@@ -308,7 +301,6 @@ impl NetworkManager {
|
||||
user_agent.clone(),
|
||||
accept_invalid_certs,
|
||||
TlsRootMode::PlatformVerifier,
|
||||
extra_headers.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -322,7 +314,6 @@ impl NetworkManager {
|
||||
user_agent,
|
||||
accept_invalid_certs,
|
||||
TlsRootMode::StaticWebpkiRoots,
|
||||
extra_headers,
|
||||
)
|
||||
.await
|
||||
.map_err(|fallback_err| {
|
||||
|
||||
@@ -85,7 +85,7 @@ async fn import_subscription(url: &str, name: Option<&String>) {
|
||||
profiles.latest_arc().current.is_some()
|
||||
};
|
||||
|
||||
let Some((mut item, device_key)) = fetch_profile_item(url, name).await else {
|
||||
let Some(mut item) = fetch_profile_item(url, name).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -97,18 +97,6 @@ async fn import_subscription(url: &str, name: Option<&String>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist the device key only if CVD actually engaged (item cached a pubkey). A plaintext airport
|
||||
// leaves cvd_pub None, so we skip the keychain write (and its macOS prompt).
|
||||
if item.cvd_pub.is_some()
|
||||
&& let Err(e) = device_key.persist()
|
||||
{
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"cvd: failed to persist device key after deep-link import: {e}"
|
||||
);
|
||||
}
|
||||
|
||||
Config::profiles().await.apply();
|
||||
logging_error!(Type::Config, Config::profiles().await.data_arc().save_file().await);
|
||||
handle::Handle::notice_message(
|
||||
@@ -119,12 +107,9 @@ async fn import_subscription(url: &str, name: Option<&String>) {
|
||||
post_import_updates(&uid, had_current_profile).await;
|
||||
}
|
||||
|
||||
async fn fetch_profile_item(url: &str, name: Option<&String>) -> Option<(PrfItem, crate::cvd::DeviceKey)> {
|
||||
// New remote profile: own the device key; the caller persists it after the profile is saved.
|
||||
let uid = help::get_uid("R");
|
||||
let key = crate::cvd::DeviceKey::generate(&uid);
|
||||
match PrfItem::from_url(url, name, None, None, &uid, crate::cvd::CvdMode::New(&key)).await {
|
||||
Ok(item) => Some((item, key)),
|
||||
async fn fetch_profile_item(url: &str, name: Option<&String>) -> Option<PrfItem> {
|
||||
match PrfItem::from_url(url, name, None, None).await {
|
||||
Ok(item) => Some(item),
|
||||
Err(e) => {
|
||||
logging!(error, Type::Config, "failed to parse profile from url: {:?}", e);
|
||||
handle::Handle::notice_message("import_sub_url::error", e.to_string());
|
||||
|
||||
Reference in New Issue
Block a user