diff --git a/Cargo.lock b/Cargo.lock index ff63030a4..8ab0ff5a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -600,6 +600,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -932,6 +941,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cc" version = "1.2.61" @@ -983,6 +1001,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.0" @@ -994,6 +1023,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.44" @@ -1043,6 +1085,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", "inout 0.1.4", + "zeroize", ] [[package]] @@ -1111,6 +1154,8 @@ dependencies = [ "futures", "gethostname", "getrandom 0.4.2", + "hpke", + "keyring", "log", "nanoid", "network-interface", @@ -1121,6 +1166,7 @@ dependencies = [ "open", "parking_lot", "percent-encoding", + "rand_core 0.6.4", "regex", "reqwest 0.13.4", "reqwest_dav", @@ -1158,6 +1204,8 @@ dependencies = [ "webpki-roots", "windows 0.62.2", "winreg 0.56.0", + "x25519-dalek", + "zeroize", "zip 8.6.0", ] @@ -1770,6 +1818,32 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version 0.4.1", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "dark-light" version = "2.0.0" @@ -1779,7 +1853,7 @@ dependencies = [ "objc2-foundation", "web-sys", "winreg 0.55.0", - "zbus", + "zbus 5.15.0", ] [[package]] @@ -1853,6 +1927,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes 0.8.4", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2 0.10.9", + "zeroize", +] + [[package]] name = "deelevate" version = "0.2.0" @@ -1987,6 +2079,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -2408,6 +2501,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -3212,6 +3311,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac" version = "0.13.0" @@ -3230,6 +3347,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hpke" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4917627a14198c3603282c5158b815ad5534795451d3c074b53cf3cee0960b11" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "digest 0.10.7", + "generic-array", + "hkdf", + "hmac 0.12.1", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "x25519-dalek", + "zeroize", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -3682,6 +3819,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -3971,6 +4109,22 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kode-bridge" version = "0.4.0" @@ -4477,6 +4631,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -4532,7 +4699,7 @@ dependencies = [ "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus", + "zbus 5.15.0", ] [[package]] @@ -4553,6 +4720,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -4564,6 +4745,15 @@ dependencies = [ "serde", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -4590,6 +4780,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -5090,7 +5302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ "digest 0.11.2", - "hmac", + "hmac 0.13.0", ] [[package]] @@ -5437,6 +5649,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -5848,7 +6071,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "chacha20", + "chacha20 0.10.0", "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -6413,7 +6636,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -6441,7 +6664,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -6564,6 +6787,38 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes 0.8.4", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2 0.10.9", + "zbus 4.4.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -10286,6 +10541,18 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xattr" version = "1.6.1" @@ -10302,6 +10569,16 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xkeysym" version = "0.2.1" @@ -10343,6 +10620,38 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1 0.10.6", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.15.0" @@ -10373,9 +10682,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.2", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.15.0", + "zbus_names 4.3.2", + "zvariant 5.10.1", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -10388,9 +10710,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.2", + "zvariant 5.10.1", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -10401,7 +10734,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", "winnow 1.0.2", - "zvariant", + "zvariant 5.10.1", ] [[package]] @@ -10450,6 +10783,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -10511,7 +10858,7 @@ dependencies = [ "deflate64", "flate2", "getrandom 0.4.2", - "hmac", + "hmac 0.13.0", "indexmap 2.14.0", "lzma-rust2", "memchr", @@ -10592,6 +10939,19 @@ dependencies = [ "zune-core", ] +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.10.1" @@ -10602,8 +10962,21 @@ dependencies = [ "enumflags2", "serde", "winnow 1.0.2", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.10.1", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -10616,7 +10989,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/Changelog.md b/Changelog.md index e36bd7564..9c8692a48 100644 --- a/Changelog.md +++ b/Changelog.md @@ -12,6 +12,7 @@ ✨ 新增功能 - 增加 TrustTunnel, OpenVPN, Tailscale, GostRelay 节点显示支持 +- 新增 CVD 订阅设备绑定与加密下发支持,降低订阅链接泄露后的持续滥用风险。了解更多:[CVD 方案介绍](./docs/cvd-protocol-introduction.md);订阅服务开发者请参考:[服务端集成文档](./docs/cvd-server-integration.md)。 diff --git a/crates/clash-verge-i18n/locales/ar.yml b/crates/clash-verge-i18n/locales/ar.yml index 3be3bbfe9..0baaa21fe 100644 --- a/crates/clash-verge-i18n/locales/ar.yml +++ b/crates/clash-verge-i18n/locales/ar.yml @@ -1,4 +1,8 @@ _version: 1 +errors: + cvdDeviceLimit: تم الوصول إلى الحد الأقصى لعدد الأجهزة. يرجى إلغاء ربط جهاز قديم من لوحة تحكم المزوّد ثم إعادة المحاولة. + cvdKeyMissing: مفتاح الجهاز لهذا الاشتراك مفقود (ربما تمت استعادة الجهاز). أزل الاشتراك وأعد إضافته لإعادة تسجيله. + cvdKeyReregistered: تمت إعادة تسجيل مفتاح الجهاز لهذا الاشتراك؛ وستتم المزامنة في التحديث التالي. notifications: dashboardToggled: title: لوحة التحكم diff --git a/crates/clash-verge-i18n/locales/de.yml b/crates/clash-verge-i18n/locales/de.yml index a345f0967..84152832b 100644 --- a/crates/clash-verge-i18n/locales/de.yml +++ b/crates/clash-verge-i18n/locales/de.yml @@ -1,4 +1,8 @@ _version: 1 +errors: + cvdDeviceLimit: Gerätelimit erreicht. Entferne ein altes Gerät in der Konsole deines Anbieters und versuche es erneut. + cvdKeyMissing: Der Geräteschlüssel für dieses Abonnement fehlt (das Gerät wurde möglicherweise wiederhergestellt). Entferne das Abonnement und füge es erneut hinzu, um es neu zu registrieren. + cvdKeyReregistered: Geräteschlüssel für dieses Abonnement neu registriert; er wird bei der nächsten Aktualisierung synchronisiert. notifications: dashboardToggled: title: Übersicht diff --git a/crates/clash-verge-i18n/locales/en.yml b/crates/clash-verge-i18n/locales/en.yml index 5d0f0e679..ed48be28f 100644 --- a/crates/clash-verge-i18n/locales/en.yml +++ b/crates/clash-verge-i18n/locales/en.yml @@ -1,4 +1,8 @@ _version: 1 +errors: + cvdDeviceLimit: Device limit reached. Unbind an old device in your provider's dashboard, then try again. + cvdKeyMissing: This subscription's device key is missing (the device may have been restored). Remove and re-add the subscription to re-register it. + cvdKeyReregistered: Device key re-registered for this subscription; it will sync on the next update. notifications: dashboardToggled: title: Dashboard diff --git a/crates/clash-verge-i18n/locales/es.yml b/crates/clash-verge-i18n/locales/es.yml index f95ea264a..941bdc4f1 100644 --- a/crates/clash-verge-i18n/locales/es.yml +++ b/crates/clash-verge-i18n/locales/es.yml @@ -1,4 +1,8 @@ _version: 1 +errors: + cvdDeviceLimit: Se alcanzó el límite de dispositivos. Desvincula un dispositivo antiguo en el panel de tu proveedor e inténtalo de nuevo. + cvdKeyMissing: Falta la clave de dispositivo de esta suscripción (es posible que el dispositivo se haya restaurado). Elimina y vuelve a añadir la suscripción para registrarla de nuevo. + cvdKeyReregistered: Se volvió a registrar la clave de dispositivo de esta suscripción; se sincronizará en la próxima actualización. notifications: dashboardToggled: title: Panel diff --git a/crates/clash-verge-i18n/locales/fa.yml b/crates/clash-verge-i18n/locales/fa.yml index a521cdddd..8718b4de4 100644 --- a/crates/clash-verge-i18n/locales/fa.yml +++ b/crates/clash-verge-i18n/locales/fa.yml @@ -1,4 +1,8 @@ _version: 1 +errors: + cvdDeviceLimit: تعداد دستگاه‌ها به حد مجاز رسیده است. لطفاً در کنترل‌پنل سرویس‌دهنده یک دستگاه قدیمی را لغو پیوند کرده و دوباره تلاش کنید. + cvdKeyMissing: کلید دستگاه برای این اشتراک موجود نیست (ممکن است دستگاه بازیابی شده باشد). برای ثبت دوباره، اشتراک را حذف کرده و دوباره اضافه کنید. + cvdKeyReregistered: کلید دستگاه برای این اشتراک دوباره ثبت شد؛ در به‌روزرسانی بعدی همگام‌سازی می‌شود. notifications: dashboardToggled: title: داشبورد diff --git a/crates/clash-verge-i18n/locales/id.yml b/crates/clash-verge-i18n/locales/id.yml index 151246d2b..9c64f264a 100644 --- a/crates/clash-verge-i18n/locales/id.yml +++ b/crates/clash-verge-i18n/locales/id.yml @@ -1,4 +1,8 @@ _version: 1 +errors: + cvdDeviceLimit: Batas perangkat tercapai. Lepaskan perangkat lama di dasbor penyedia Anda, lalu coba lagi. + cvdKeyMissing: Kunci perangkat untuk langganan ini tidak ada (perangkat mungkin telah dipulihkan). Hapus dan tambahkan kembali langganan untuk mendaftarkannya ulang. + cvdKeyReregistered: Kunci perangkat untuk langganan ini didaftarkan ulang; akan disinkronkan pada pembaruan berikutnya. notifications: dashboardToggled: title: Dasbor diff --git a/crates/clash-verge-i18n/locales/jp.yml b/crates/clash-verge-i18n/locales/jp.yml index 965ffbd31..7ed25a398 100644 --- a/crates/clash-verge-i18n/locales/jp.yml +++ b/crates/clash-verge-i18n/locales/jp.yml @@ -1,4 +1,8 @@ _version: 1 +errors: + cvdDeviceLimit: デバイス数が上限に達しました。プロバイダーの管理画面で古いデバイスの登録を解除してから再試行してください。 + cvdKeyMissing: このサブスクリプションのデバイスキーが見つかりません(デバイスを復元した可能性があります)。登録し直すには、サブスクリプションを削除して再追加してください。 + cvdKeyReregistered: このサブスクリプションのデバイスキーを再登録しました。次回の更新時に同期されます。 notifications: dashboardToggled: title: ダッシュボード diff --git a/crates/clash-verge-i18n/locales/ko.yml b/crates/clash-verge-i18n/locales/ko.yml index 38d61818d..ad65004e6 100644 --- a/crates/clash-verge-i18n/locales/ko.yml +++ b/crates/clash-verge-i18n/locales/ko.yml @@ -1,4 +1,8 @@ _version: 1 +errors: + cvdDeviceLimit: 기기 수가 한도에 도달했습니다. 제공자 콘솔에서 기존 기기를 해제한 후 다시 시도하세요. + cvdKeyMissing: 이 구독의 기기 키가 없습니다(기기를 복원했을 수 있습니다). 다시 등록하려면 구독을 삭제한 후 다시 추가하세요. + cvdKeyReregistered: 이 구독의 기기 키를 다시 등록했습니다. 다음 업데이트 시 동기화됩니다. notifications: dashboardToggled: title: 대시보드 diff --git a/crates/clash-verge-i18n/locales/ru.yml b/crates/clash-verge-i18n/locales/ru.yml index 77c7647f7..51d02bd4f 100644 --- a/crates/clash-verge-i18n/locales/ru.yml +++ b/crates/clash-verge-i18n/locales/ru.yml @@ -1,4 +1,8 @@ _version: 1 +errors: + cvdDeviceLimit: Достигнут лимит устройств. Отвяжите старое устройство в панели управления провайдера и повторите попытку. + cvdKeyMissing: Ключ устройства для этой подписки отсутствует (возможно, устройство было восстановлено). Удалите и снова добавьте подписку, чтобы зарегистрировать её заново. + cvdKeyReregistered: Ключ устройства для этой подписки перерегистрирован; он синхронизируется при следующем обновлении. notifications: dashboardToggled: title: Панель diff --git a/crates/clash-verge-i18n/locales/tr.yml b/crates/clash-verge-i18n/locales/tr.yml index b6804fde5..fa4ae8cdb 100644 --- a/crates/clash-verge-i18n/locales/tr.yml +++ b/crates/clash-verge-i18n/locales/tr.yml @@ -1,4 +1,8 @@ _version: 1 +errors: + cvdDeviceLimit: Cihaz sınırına ulaşıldı. Lütfen sağlayıcı panelinden eski bir cihazı kaldırıp tekrar deneyin. + cvdKeyMissing: Bu aboneliğin cihaz anahtarı eksik (cihaz geri yüklenmiş olabilir). Yeniden kaydetmek için aboneliği kaldırıp tekrar ekleyin. + cvdKeyReregistered: Bu abonelik için cihaz anahtarı yeniden kaydedildi; bir sonraki güncellemede eşitlenecek. notifications: dashboardToggled: title: Gösterge Paneli diff --git a/crates/clash-verge-i18n/locales/tt.yml b/crates/clash-verge-i18n/locales/tt.yml index 6b0283e10..44e24659a 100644 --- a/crates/clash-verge-i18n/locales/tt.yml +++ b/crates/clash-verge-i18n/locales/tt.yml @@ -1,4 +1,8 @@ _version: 1 +errors: + cvdDeviceLimit: Җиһазлар саны иң югары чиккә җитте. Зинһар, провайдер консолендә иске җиһазны чишегез һәм яңадан тырышып карагыз. + cvdKeyMissing: Бу язылуның җиһаз ачкычы юк (җиһаз торгызылган булырга мөмкин). Кабат теркәү өчен язылуны бетереп, яңадан өстәгез. + cvdKeyReregistered: Бу язылу өчен җиһаз ачкычы яңадан теркәлде; киләсе яңартуда синхронлашачак. notifications: dashboardToggled: title: Идарә панеле diff --git a/crates/clash-verge-i18n/locales/zh.yml b/crates/clash-verge-i18n/locales/zh.yml index 5a23340e0..4baf92d3d 100644 --- a/crates/clash-verge-i18n/locales/zh.yml +++ b/crates/clash-verge-i18n/locales/zh.yml @@ -1,4 +1,8 @@ _version: 1 +errors: + cvdDeviceLimit: 设备数已达上限,请前往机场控制台解绑旧设备后重试 + cvdKeyMissing: 该订阅的设备密钥缺失(可能因恢复到新设备)。请删除并重新添加该订阅以重新注册。 + cvdKeyReregistered: 已为该订阅重新注册设备密钥,将在下次更新时同步。 notifications: dashboardToggled: title: 仪表板 diff --git a/crates/clash-verge-i18n/locales/zhtw.yml b/crates/clash-verge-i18n/locales/zhtw.yml index 5c7d126a3..08b39d6cf 100644 --- a/crates/clash-verge-i18n/locales/zhtw.yml +++ b/crates/clash-verge-i18n/locales/zhtw.yml @@ -1,4 +1,8 @@ _version: 1 +errors: + cvdDeviceLimit: 裝置數已達上限,請前往機場控制台解綁舊裝置後重試 + cvdKeyMissing: 此訂閱的裝置密鑰遺失(可能因還原到新裝置)。請刪除並重新加入該訂閱以重新註冊。 + cvdKeyReregistered: 已為此訂閱重新註冊裝置密鑰,將在下次更新時同步。 notifications: dashboardToggled: title: 儀表板 diff --git a/crates/clash-verge-i18n/src/lib.rs b/crates/clash-verge-i18n/src/lib.rs index 04b04fdfb..cd41640e8 100644 --- a/crates/clash-verge-i18n/src/lib.rs +++ b/crates/clash-verge-i18n/src/lib.rs @@ -90,6 +90,29 @@ macro_rules! t { mod test { use super::resolve_supported_language; + /// Keys surfaced to users (e.g. CVD device-limit) must exist in every locale, otherwise that + /// locale silently falls back to Chinese. Cheap structural check: each `.yml` must mention the + /// key. Add new user-facing backend keys here when introduced. + #[test] + #[allow(clippy::expect_used)] + fn all_locales_define_user_facing_keys() { + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/locales"); + let required = ["cvdDeviceLimit", "cvdKeyMissing", "cvdKeyReregistered"]; + let mut checked = 0; + for entry in std::fs::read_dir(dir).expect("read locales dir") { + let path = entry.expect("dir entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("yml") { + continue; + } + let content = std::fs::read_to_string(&path).expect("read locale file"); + for key in required { + assert!(content.contains(key), "{path:?} is missing required key `{key}`"); + } + checked += 1; + } + assert!(checked >= 13, "expected at least 13 locale files, found {checked}"); + } + #[test] fn test_resolve_supported_language() { assert_eq!(resolve_supported_language("en").as_deref(), Some("en")); diff --git a/docs/cvd-protocol-introduction.md b/docs/cvd-protocol-introduction.md new file mode 100644 index 000000000..67be49ddc --- /dev/null +++ b/docs/cvd-protocol-introduction.md @@ -0,0 +1,98 @@ +# CVD 订阅设备绑定:让订阅链接从“拿到就能用”升级为“登记设备后才能持续更新” + +CVD(Clash Verge Device-binding Protocol)是一套面向订阅服务的设备绑定与加密下发方案。 + +过去,一条订阅链接一旦泄露,第三方可能直接把链接粘贴到浏览器或脚本中,持续拉取最新节点。CVD 在原有订阅流程上增加了设备公钥、加密响应和设备槽位管理:服务提供方可以识别设备、限制数量、解绑旧设备,并为每台设备单独加密订阅内容。 + +对于普通用户,它几乎不改变使用习惯;对于服务提供方,它提供了一条可以逐步上线的安全加固路径。 + +这不是给订阅链接换一个外壳,也不是再增加一组所有设备共享的验证密码。简单的链接变形、固定密码或单层加密,可以增加一点复制步骤,却很难回答更关键的问题:**到底是哪台设备在持续拉取订阅?已经登记了多少台设备?某一台设备是否应该被限制或解绑?** + +CVD 直接把这些问题纳入协议流程。每个订阅 profile 使用独立设备密钥,服务端可以按公钥识别设备,并结合槽位上限、新设备注册频率限制、解绑和过期回收进行治理。它不是让订阅“看起来更难复制”,而是从技术实现上把持续获取订阅的门槛,从“拿到链接即可请求”提高到“登记设备后才能持续更新”。 + +## 一句话理解 + +传统订阅链接像一把可以反复复制的门禁卡。CVD 为它增加了“设备登记”: + +1. Clash Verge 为订阅生成一对设备密钥。 +2. 公钥随订阅请求发送给服务端,私钥只保存在本机系统钥匙串。 +3. 服务端用公钥加密订阅内容,并按设备公钥记录槽位。 +4. 只有持有本机私钥的设备才能解密本次订阅响应。 + +服务端还可以限制每个账号允许登记的设备数,并允许用户在控制台解绑旧设备。 + +## CVD 带来的价值 + +### 1. 提高订阅链接泄露后的滥用成本 + +仅拿到订阅 URL,不再等于可以用浏览器或通用 `curl` 持续复制明文节点。第三方必须实现 CVD 协议、登记设备并占用可见槽位,服务端也可以据此限制新设备注册频率、限制数量和吊销设备。 + +相比之下,共享密码只能证明“请求者知道密码”,无法区分不同设备;单纯加密订阅内容,如果缺少设备身份和服务端策略,也难以阻止泄露者反复请求。CVD 的重点不是堆叠一道验证,而是让每次持续访问都进入可识别、可计量、可治理的设备流程。 + +### 2. 每台设备独立加密下发 + +CVD 使用标准 HPKE(RFC 9180)方案。服务端针对设备公钥加密订阅内容,不同设备拿到的密文不同。协议采用 X25519、HKDF-SHA256,以及 ChaCha20-Poly1305 或 AES-256-GCM 等成熟算法组合。 + +### 3. 私钥留在系统钥匙串 + +设备私钥不会写入普通配置文件,也不会随应用备份导出。它由 macOS Keychain、Windows Credential Manager 或 Linux Secret Service 等系统能力保存。服务端只接收公钥,不接触用户私钥。 + +### 4. 平滑兼容现有订阅生态 + +CVD 采用渐进启用方式: + +- 服务端尚未支持 CVD 时,仍可按原有明文订阅流程工作。 +- 服务端支持 CVD 后,可以先灰度测试,再按套餐或用户组启用设备限制。 +- 新建、导入订阅和手动更新会自动尝试登记;已登记订阅会在后续更新中复用同一设备公钥。 +- 尚未登记的旧订阅不会在静默自动更新时主动创建密钥,避免系统启动时出现不必要的钥匙串授权弹窗。 + +### 5. 面向真实使用场景的自恢复设计 + +用户换机、重装系统或清理钥匙串后,本机私钥可能丢失。客户端会识别这种情况,并准备新的设备密钥用于后续更新。服务提供方配合提供设备解绑和过期回收后,用户可以恢复正常使用。 + +## 为什么说它更先进 + +CVD 没有重新发明一套私有加密算法,而是组合成熟标准与实际运营能力: + +| 能力 | CVD 的做法 | +|------|------------| +| 设备身份 | 直接使用 X25519 公钥,无需额外 `device_id` | +| 订阅加密 | 使用 RFC 9180 HPKE 标准方案 | +| 私钥保护 | 仅保存在操作系统钥匙串 | +| 风险控制 | 服务端设备槽位、新设备注册频率限制、解绑和过期回收 | +| 上线方式 | 兼容未改造服务端,支持灰度启用 | +| 用户体验 | 自动协商、复用公钥、按需访问钥匙串 | + +它与常见轻量方案的差异,不只体现在“有没有加密”,而在于是否形成完整闭环: + +| 方案 | 能解决什么 | 仍然缺少什么 | +|------|------------|--------------| +| 更换或隐藏订阅链接 | 降低链接被随手发现的概率 | 链接一旦泄露,仍可持续复制和请求 | +| 增加共享验证密码 | 增加一道访问门槛 | 所有设备共享凭据,难以单独识别、限制或吊销 | +| 仅对订阅内容加密 | 避免直接看到明文 | 如果没有设备身份和槽位策略,仍难治理持续滥用 | +| **CVD 设备绑定** | **设备独立密钥、标准加密下发、槽位计量、新设备注册频率限制、解绑和回收** | **需要服务端按建议策略接入,才能发挥完整价值** | + +这使 CVD 不只是“加一层加密”,而是把订阅分发升级为可识别、可计量、可限制、可吊销的设备级流程。它把安全能力落实到协议、客户端密钥管理和服务端运营策略三个层面,而不是停留在表面上的访问障碍。 + +## 服务提供方如何获得完整防护价值 + +仅返回加密响应还不够。服务端应同时实施以下策略: + +1. 对受保护订阅拒绝不带 CVD 公钥的明文请求。 +2. 按账号或套餐设置设备槽位上限。 +3. 按账号和来源 IP 限制新公钥注册频率,防止攻击者刷满设备槽位。 +4. 提供设备查看、解绑和长期未使用设备回收能力。 +5. 保留 HTTPS,并确保 CDN、WAF 和反向代理透传 `X-CVD-*` 请求头。 + +完整的服务端字段、响应格式、测试向量和接入示例,请阅读 [CVD 服务端集成技术文档](./cvd-server-integration.md)。 + +## 需要诚实说明的边界 + +CVD 是安全加固方案,不是 DRM,也不承诺“订阅永远无法泄露”: + +- 合法设备解密后仍能看到节点信息。 +- 愿意专门开发工具的第三方仍可尝试登记设备。 +- CVD 不能替代 HTTPS,也不负责解决中间人攻击、服务端投毒等问题。 +- 真正的治理效果来自加密下发与服务端设备槽位、新设备注册频率限制、解绑策略的组合。 + +CVD 的目标很明确:让低成本、批量化、长期持续的订阅滥用变得更难,让服务提供方获得可执行的治理手段。 diff --git a/docs/cvd-server-integration.md b/docs/cvd-server-integration.md new file mode 100644 index 000000000..7791c62a0 --- /dev/null +++ b/docs/cvd-server-integration.md @@ -0,0 +1,439 @@ +# 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: + 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 为准。* diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 90072d683..079450e01 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -85,6 +85,11 @@ zip = "8.6.0" reqwest_dav = "0.3.3" aes-gcm = { version = "0.10.3", features = ["std"] } base64 = "0.22.1" +keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] } +hpke = { version = "0.12", default-features = false, features = ["x25519", "alloc"] } +x25519-dalek = { version = "2", features = ["static_secrets", "zeroize"] } +zeroize = "1" +rand_core = { version = "0.6", features = ["getrandom"] } getrandom = "0.4.2" futures = "0.3.32" gethostname = "1.1.0" diff --git a/src-tauri/src/cmd/profile.rs b/src-tauri/src/cmd/profile.rs index 8eb631dc7..864a2e7d4 100644 --- a/src-tauri/src/cmd/profile.rs +++ b/src-tauri/src/cmd/profile.rs @@ -70,27 +70,38 @@ pub async fn enhance_profiles() -> CmdResult { pub async fn import_profile(url: std::string::String, option: Option) -> CmdResult { logging!(info, Type::Cmd, "[导入订阅] 开始导入: {}", help::mask_url(&url)); - // 直接依赖 PrfItem::from_url 自身的超时/重试逻辑,不再使用 tokio::time::timeout 包裹 - 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()); - } - }; + // 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); - match profiles_append_item_safe(item).await { - Ok(_) => match profiles_save_file_safe().await { - Ok(_) => { - logging!(info, Type::Cmd, "[导入订阅] 配置文件保存成功"); + // 直接依赖 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); + 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 { + logging!(info, Type::Cmd, "[导入订阅] 配置文件保存成功"); + } + } Err(e) => { logging!(error, Type::Cmd, "[导入订阅] 保存配置失败: {}", e); return Err(format!("导入订阅失败: {}", e).into()); @@ -157,9 +168,27 @@ pub async fn update_profile(index: String, option: Option) -> 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}"); } diff --git a/src-tauri/src/config/prfitem.rs b/src-tauri/src/config/prfitem.rs index 6b992ba1f..27ec81d14 100644 --- a/src-tauri/src/config/prfitem.rs +++ b/src-tauri/src/config/prfitem.rs @@ -58,6 +58,20 @@ pub struct PrfItem { #[serde(skip_serializing_if = "Option::is_none")] pub home: Option, + /// 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, + + /// 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/`. 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, + /// the file data #[serde(skip)] pub file_data: Option, @@ -154,7 +168,10 @@ impl PrfOption { impl PrfItem { /// From partial item /// must contain `itype` - pub async fn from(item: &Self, file_data: Option) -> Result { + /// 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) -> Result<(Self, Option)> { if item.itype.is_none() { bail!("type should not be null"); } @@ -171,14 +188,42 @@ impl PrfItem { .ok_or_else(|| anyhow::anyhow!("url should not be null"))?; let name = item.name.as_ref(); let desc = item.desc.as_ref(); - let option = item.option.as_ref(); - Self::from_url(url, name, desc, option).await + + // 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))) } "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(); - Self::from_local(name, desc, file_data, option).await + Ok((Self::from_local(name, desc, file_data, option).await?, None)) } typ => bail!("invalid profile item type \"{typ}\""), } @@ -246,6 +291,8 @@ 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())), }) @@ -253,11 +300,17 @@ 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 `.yaml` and keychain `cvd/`). 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 { 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)); @@ -283,6 +336,18 @@ 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( @@ -291,6 +356,7 @@ impl PrfItem { Some(timeout), user_agent.clone(), accept_invalid_certs, + cvd_headers, ) .await { @@ -302,10 +368,49 @@ impl PrfItem { }; let status_code = resp.status(); - if !status_code.is_success() { + + // 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 = 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() { 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 @@ -374,12 +479,14 @@ 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 = resp.text_with_charset()?; + let data = match cvd_yaml.as_deref() { + Some(yaml) => yaml, + None => resp.text_with_charset()?, + }; // process the charset "UTF-8 with BOM" let data = data.trim_start_matches('\u{feff}'); @@ -437,6 +544,9 @@ 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()), }) diff --git a/src-tauri/src/config/profiles.rs b/src-tauri/src/config/profiles.rs index c98dc4b4d..60782bf0e 100644 --- a/src-tauri/src/config/profiles.rs +++ b/src-tauri/src/config/profiles.rs @@ -214,6 +214,23 @@ 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) -> 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<()> { @@ -529,8 +546,22 @@ impl IProfiles { use crate::config::Config; pub async fn profiles_append_item_with_filedata_safe(item: &PrfItem, file_data: Option) -> Result<()> { - let item = &mut PrfItem::from(item, file_data).await?; - profiles_append_item_safe(item).await + 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(()) } pub async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> { @@ -592,3 +623,16 @@ 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) -> 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 +} diff --git a/src-tauri/src/cvd/mod.rs b/src-tauri/src/cvd/mod.rs new file mode 100644 index 000000000..50acc8de3 --- /dev/null +++ b/src-tauri/src/cvd/mod.rs @@ -0,0 +1,556 @@ +//! 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 { + 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> { + if payload.len() < 48 { + bail!("cvd payload too short ({} bytes)", payload.len()); + } + let (enc_bytes, ciphertext) = payload.split_at(32); + let sk = ::PrivateKey::from_bytes(secret).map_err(|e| anyhow!("bad cvd private key: {e}"))?; + let enc = ::EncappedKey::from_bytes(enc_bytes).map_err(|e| anyhow!("bad cvd enc key: {e}"))?; + let pt = match aead { + CvdAead::ChaCha20Poly1305 => { + single_shot_open::(&OpModeR::Base, &sk, &enc, INFO, ciphertext, b"") + } + CvdAead::Aes256Gcm => { + single_shot_open::(&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 }, + /// 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/`). 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 { + 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>> { + 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> = 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 { + 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 { + 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 { + use hpke::{OpModeS, Serializable as _, single_shot_seal}; + let pk = ::PublicKey::from_bytes(public).expect("valid pubkey"); + let mut rng = rand_core::OsRng; + let (enc, ct) = match aead { + CvdAead::ChaCha20Poly1305 => single_shot_seal::( + &OpModeS::Base, + &pk, + INFO, + plaintext, + b"", + &mut rng, + ), + CvdAead::Aes256Gcm => { + single_shot_seal::(&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>> { + static STORE: OnceLock>>> = 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> { + 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> { + 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::().is_some()); + } +} diff --git a/src-tauri/src/feat/profile.rs b/src-tauri/src/feat/profile.rs index acebff858..2d6aabd35 100644 --- a/src-tauri/src/feat/profile.rs +++ b/src-tauri/src/feat/profile.rs @@ -1,6 +1,9 @@ use crate::{ cmd, - config::{Config, PrfItem, PrfOption, profiles::profiles_draft_update_item_safe}, + config::{ + Config, PrfItem, PrfOption, + profiles::{profiles_draft_update_item_safe, profiles_set_cvd_pub_safe}, + }, core::{CoreManager, handle, tray, validate::ValidationOutcome}, utils::help::{mask_err, mask_url}, }; @@ -97,6 +100,82 @@ 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 { + // Only handle the missing-key signal; any other error → None (caller keeps retrying). + err.downcast_ref::()?; + 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, @@ -117,15 +196,39 @@ 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()).await { + match PrfItem::from_url(url, None, None, merged_opt.as_ref(), uid.as_str(), cvd_mode).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, @@ -139,15 +242,19 @@ 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()).await { + match PrfItem::from_url(url, None, None, merged_opt.as_ref(), uid.as_str(), cvd_mode).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, @@ -161,15 +268,19 @@ 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()).await { + match PrfItem::from_url(url, None, None, merged_opt.as_ref(), uid.as_str(), cvd_mode).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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55156df32..4e96a0a11 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod cmd; pub mod config; mod constants; mod core; +mod cvd; mod enhance; mod feat; mod module; diff --git a/src-tauri/src/utils/network.rs b/src-tauri/src/utils/network.rs index e9463cc99..b4344ed6e 100644 --- a/src-tauri/src/utils/network.rs +++ b/src-tauri/src/utils/network.rs @@ -178,6 +178,7 @@ impl NetworkManager { .await } + #[allow(clippy::too_many_arguments)] async fn get_with_tls_mode( &self, url: &str, @@ -186,6 +187,7 @@ impl NetworkManager { user_agent: Option, accept_invalid_certs: bool, tls_root_mode: TlsRootMode, + cvd_headers: HeaderMap, ) -> Result { let mut parsed = Url::parse(url)?; let mut extra_headers = HeaderMap::new(); @@ -218,6 +220,10 @@ 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); } @@ -292,6 +298,7 @@ impl NetworkManager { timeout_secs: Option, user_agent: Option, accept_invalid_certs: bool, + extra_headers: HeaderMap, ) -> Result { let platform_result = self .get_with_tls_mode( @@ -301,6 +308,7 @@ impl NetworkManager { user_agent.clone(), accept_invalid_certs, TlsRootMode::PlatformVerifier, + extra_headers.clone(), ) .await; @@ -314,6 +322,7 @@ impl NetworkManager { user_agent, accept_invalid_certs, TlsRootMode::StaticWebpkiRoots, + extra_headers, ) .await .map_err(|fallback_err| { diff --git a/src-tauri/src/utils/resolve/scheme.rs b/src-tauri/src/utils/resolve/scheme.rs index efdaa5e38..c3dfb7da2 100644 --- a/src-tauri/src/utils/resolve/scheme.rs +++ b/src-tauri/src/utils/resolve/scheme.rs @@ -85,7 +85,7 @@ async fn import_subscription(url: &str, name: Option<&String>) { profiles.latest_arc().current.is_some() }; - let Some(mut item) = fetch_profile_item(url, name).await else { + let Some((mut item, device_key)) = fetch_profile_item(url, name).await else { return; }; @@ -97,6 +97,18 @@ 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( @@ -107,9 +119,12 @@ 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 { - match PrfItem::from_url(url, name, None, None).await { - Ok(item) => Some(item), +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)), Err(e) => { logging!(error, Type::Config, "failed to parse profile from url: {:?}", e); handle::Handle::notice_message("import_sub_url::error", e.to_string()); diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index 1de50cd89..3d3b1894d 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -133,7 +133,8 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { await patchProfile(form.uid, item) } } else { - // 远程配置使用回退机制 + // 远程配置:编辑用代理回退重试;新建的代理回退已下沉到后端, + // 后端用同一个 uid(同一 CVD 设备密钥/槽位)跨代理重试,前端不再重试。 try { // 尝试正常操作 if (openType === 'new') { @@ -142,13 +143,16 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { if (!form.uid) throw new Error('UID not found') await patchProfile(form.uid, item) } - } catch { - // 首次创建/更新失败,尝试使用自身代理 + } catch (err) { + // 新建:后端已跨代理重试并复用同一密钥,前端再次 createProfile 会注册新公钥、 + // 可能额外占用一个设备槽位,因此直接上抛由外层提示错误。 + if (openType === 'new') throw err + + // 编辑:用自身代理重试 patch,再恢复原始代理设置 + if (!form.uid) throw new Error('UID not found', { cause: err }) showNotice.info( 'profiles.modals.profileForm.feedback.notifications.creationRetry', ) - - // 使用自身代理的配置 const retryItem = { ...item, option: { @@ -157,17 +161,9 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { self_proxy: true, }, } - - // 使用自身代理再次尝试 - if (openType === 'new') { - await createProfile(retryItem, fileDataRef.current) - } else { - if (!form.uid) throw new Error('UID not found') - await patchProfile(form.uid, retryItem) - - // 编辑模式下恢复原始代理设置 - await patchProfile(form.uid, { option: originalOptions }) - } + await patchProfile(form.uid, retryItem) + // 编辑模式下恢复原始代理设置 + await patchProfile(form.uid, { option: originalOptions }) showNotice.success( 'profiles.modals.profileForm.feedback.notifications.creationSuccess', diff --git a/src/components/setting/mods/backup-history-viewer.tsx b/src/components/setting/mods/backup-history-viewer.tsx index 2a0d97628..e6a834f59 100644 --- a/src/components/setting/mods/backup-history-viewer.tsx +++ b/src/components/setting/mods/backup-history-viewer.tsx @@ -472,6 +472,15 @@ export const BackupHistoryViewer = ({ {pendingConfirmation.filename} )} + {pendingConfirmation?.action === 'restore' && ( + + {t('settings.modals.backup.messages.restoreCvdWarning')} + + )} ) diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index bd3d7b55a..b1c915a73 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -352,7 +352,8 @@ "webdavRefreshSuccess": "WebDAV refresh succeeded", "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "هل تريد بالتأكيد حذف ملف النسخة الاحتياطية هذا؟", - "confirmRestore": "هل تريد بالتأكيد استعادة ملف النسخة الاحتياطية هذا؟" + "confirmRestore": "هل تريد بالتأكيد استعادة ملف النسخة الاحتياطية هذا؟", + "restoreCvdWarning": "Subscription device keys live in this device's keychain, not in the backup. Restoring on the same device reuses the existing keys. On a new device or after reinstalling (keychain cleared), each remote subscription registers a new device key on its next update — if your provider limits devices, you may need to unbind old ones." }, "auto": { "title": "Automatic backup", diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index 14bc1570e..86adf2c78 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -352,7 +352,8 @@ "webdavRefreshSuccess": "WebDAV refresh succeeded", "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Confirm to delete this backup file?", - "confirmRestore": "Confirm to restore this backup file?" + "confirmRestore": "Confirm to restore this backup file?", + "restoreCvdWarning": "Subscription device keys live in this device's keychain, not in the backup. Restoring on the same device reuses the existing keys. On a new device or after reinstalling (keychain cleared), each remote subscription registers a new device key on its next update — if your provider limits devices, you may need to unbind old ones." }, "auto": { "title": "Automatic backup", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 0b5d41f54..70395d031 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -352,7 +352,8 @@ "webdavRefreshSuccess": "WebDAV refresh succeeded", "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Confirm to delete this backup file?", - "confirmRestore": "Confirm to restore this backup file?" + "confirmRestore": "Confirm to restore this backup file?", + "restoreCvdWarning": "Subscription device keys live in this device's keychain, not in the backup. Restoring on the same device reuses the existing keys. On a new device or after reinstalling (keychain cleared), each remote subscription registers a new device key on its next update — if your provider limits devices, you may need to unbind old ones." }, "auto": { "title": "Automatic backup", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 49bbab1f3..e43430b39 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -352,7 +352,8 @@ "webdavRefreshSuccess": "WebDAV refresh succeeded", "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Confirm to delete this backup file?", - "confirmRestore": "Confirm to restore this backup file?" + "confirmRestore": "Confirm to restore this backup file?", + "restoreCvdWarning": "Subscription device keys live in this device's keychain, not in the backup. Restoring on the same device reuses the existing keys. On a new device or after reinstalling (keychain cleared), each remote subscription registers a new device key on its next update — if your provider limits devices, you may need to unbind old ones." }, "auto": { "title": "Automatic backup", diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index ae5f50f1b..f21d43037 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -352,7 +352,8 @@ "webdavRefreshSuccess": "WebDAV refresh succeeded", "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "آیا از حذف این فایل پشتیبان اطمینان دارید؟", - "confirmRestore": "آیا از بازیابی این فایل پشتیبان اطمینان دارید؟" + "confirmRestore": "آیا از بازیابی این فایل پشتیبان اطمینان دارید؟", + "restoreCvdWarning": "Subscription device keys live in this device's keychain, not in the backup. Restoring on the same device reuses the existing keys. On a new device or after reinstalling (keychain cleared), each remote subscription registers a new device key on its next update — if your provider limits devices, you may need to unbind old ones." }, "auto": { "title": "Automatic backup", diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index 7d7f7f2f0..1ec02c612 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -352,7 +352,8 @@ "webdavRefreshSuccess": "WebDAV refresh succeeded", "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Konfirmasi untuk menghapus file cadangan ini?", - "confirmRestore": "Konfirmasi untuk memulihkan file cadangan ini?" + "confirmRestore": "Konfirmasi untuk memulihkan file cadangan ini?", + "restoreCvdWarning": "Subscription device keys live in this device's keychain, not in the backup. Restoring on the same device reuses the existing keys. On a new device or after reinstalling (keychain cleared), each remote subscription registers a new device key on its next update — if your provider limits devices, you may need to unbind old ones." }, "auto": { "title": "Automatic backup", diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index 5b863b2e9..65295e29a 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -352,7 +352,8 @@ "webdavRefreshSuccess": "WebDAV refresh succeeded", "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Confirm to delete this backup file?", - "confirmRestore": "Confirm to restore this backup file?" + "confirmRestore": "Confirm to restore this backup file?", + "restoreCvdWarning": "Subscription device keys live in this device's keychain, not in the backup. Restoring on the same device reuses the existing keys. On a new device or after reinstalling (keychain cleared), each remote subscription registers a new device key on its next update — if your provider limits devices, you may need to unbind old ones." }, "auto": { "title": "Automatic backup", diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 98cc5fed9..df35f622a 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -352,7 +352,8 @@ "webdavRefreshSuccess": "WebDAV refresh succeeded", "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "이 백업 파일을 삭제하시겠습니까?", - "confirmRestore": "이 백업 파일을 복원하시겠습니까?" + "confirmRestore": "이 백업 파일을 복원하시겠습니까?", + "restoreCvdWarning": "Subscription device keys live in this device's keychain, not in the backup. Restoring on the same device reuses the existing keys. On a new device or after reinstalling (keychain cleared), each remote subscription registers a new device key on its next update — if your provider limits devices, you may need to unbind old ones." }, "auto": { "title": "Automatic backup", diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index e79374e33..2db029c24 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -352,7 +352,8 @@ "webdavRefreshSuccess": "Список WebDAV успешно обновлён", "webdavRefreshFailed": "Не удалось обновить список WebDAV: {{error}}", "confirmDelete": "Вы уверены, что хотите удалить этот файл резервной копии?", - "confirmRestore": "Вы уверены, что хотите восстановить этот файл резервной копии?" + "confirmRestore": "Вы уверены, что хотите восстановить этот файл резервной копии?", + "restoreCvdWarning": "Subscription device keys live in this device's keychain, not in the backup. Restoring on the same device reuses the existing keys. On a new device or after reinstalling (keychain cleared), each remote subscription registers a new device key on its next update — if your provider limits devices, you may need to unbind old ones." }, "auto": { "title": "Автоматическое резервное копирование", diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index ffe2f5b0d..d993f3fe4 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -352,7 +352,8 @@ "webdavRefreshSuccess": "WebDAV refresh succeeded", "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Bu yedek dosyasını silmeyi onaylıyor musunuz?", - "confirmRestore": "Bu yedek dosyasını geri yüklemeyi onaylıyor musunuz?" + "confirmRestore": "Bu yedek dosyasını geri yüklemeyi onaylıyor musunuz?", + "restoreCvdWarning": "Subscription device keys live in this device's keychain, not in the backup. Restoring on the same device reuses the existing keys. On a new device or after reinstalling (keychain cleared), each remote subscription registers a new device key on its next update — if your provider limits devices, you may need to unbind old ones." }, "auto": { "title": "Automatic backup", diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index d5c3b881c..565f0fe5b 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -352,7 +352,8 @@ "webdavRefreshSuccess": "WebDAV refresh succeeded", "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Бу резерв копия файлын бетерергә телисезме?", - "confirmRestore": "Бу резерв копия файлын кире кайтарырга телисезме?" + "confirmRestore": "Бу резерв копия файлын кире кайтарырга телисезме?", + "restoreCvdWarning": "Subscription device keys live in this device's keychain, not in the backup. Restoring on the same device reuses the existing keys. On a new device or after reinstalling (keychain cleared), each remote subscription registers a new device key on its next update — if your provider limits devices, you may need to unbind old ones." }, "auto": { "title": "Automatic backup", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 585c50e5b..0fdffd302 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -352,7 +352,8 @@ "webdavRefreshSuccess": "WebDAV 刷新成功", "webdavRefreshFailed": "WebDAV 刷新失败: {{error}}", "confirmDelete": "确认删除此备份文件吗?", - "confirmRestore": "确认恢复此份文件吗?" + "confirmRestore": "确认恢复此份文件吗?", + "restoreCvdWarning": "订阅的设备密钥仅存于本机钥匙串,不随备份导出。在同一设备恢复会复用已有密钥;仅当在新设备或重装系统后(钥匙串已清空)恢复时,每个远程订阅才会在下次更新时注册新的设备密钥,此时若机场限制设备数,可能需要先解绑旧设备。" }, "auto": { "title": "自动备份", diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index 66851da3e..8862445ee 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -352,7 +352,8 @@ "webdavRefreshSuccess": "WebDAV 更新成功", "webdavRefreshFailed": "WebDAV 更新失敗: {{error}}", "confirmDelete": "確認是否刪除此備份檔案嗎?", - "confirmRestore": "確認還原此份檔案嗎?" + "confirmRestore": "確認還原此份檔案嗎?", + "restoreCvdWarning": "Subscription device keys live in this device's keychain, not in the backup. Restoring on the same device reuses the existing keys. On a new device or after reinstalling (keychain cleared), each remote subscription registers a new device key on its next update — if your provider limits devices, you may need to unbind old ones." }, "auto": { "title": "自動備份", diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 8bbe53909..f041b56aa 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -526,6 +526,7 @@ export const translationKeys = [ 'settings.modals.backup.messages.webdavRefreshFailed', 'settings.modals.backup.messages.confirmDelete', 'settings.modals.backup.messages.confirmRestore', + 'settings.modals.backup.messages.restoreCvdWarning', 'settings.modals.backup.auto.title', 'settings.modals.backup.auto.scheduleLabel', 'settings.modals.backup.auto.scheduleHelper', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index 9834e7901..0500a0f1c 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -764,6 +764,7 @@ export interface TranslationResources { localBackupImported: string localBackupImportFailed: string passwordRequired: string + restoreCvdWarning: string restoreSuccess: string usernameRequired: string webdavConfigSaved: string