From 7acd1625a92c6b4f8f6ee2fdd75f72a49ebdfcc7 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 6 Jun 2026 13:12:28 +0800 Subject: [PATCH] fix(web): support encrypted config import over HTTP --- Cargo.lock | 4 + .../src/composables/useDialogSources.ts | 9 ++- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + apps/desktop/src/lib/api.ts | 1 + apps/desktop/src/lib/configCrypto.ts | 43 +++++++++-- apps/desktop/src/lib/http.ts | 4 + apps/desktop/src/lib/tauri.ts | 5 ++ crates/dbx-web/Cargo.toml | 4 + crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/app_settings.rs | 76 +++++++++++++++++++ packages/app-tests/configCrypto.test.ts | 24 +++++- 16 files changed, 171 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27a5d7297..a62ce89ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1935,17 +1935,21 @@ dependencies = [ name = "dbx-web" version = "0.5.29" dependencies = [ + "aes-gcm 0.10.3", "argon2", "async-stream", "axum", + "base64 0.22.1", "chrono", "dbx-core", "futures", "log", + "pbkdf2 0.12.2", "reqwest 0.12.28", "rustls 0.23.40", "serde", "serde_json", + "sha2 0.10.9", "tokio", "tokio-util", "tower-http", diff --git a/apps/desktop/src/composables/useDialogSources.ts b/apps/desktop/src/composables/useDialogSources.ts index 23d366448..5f84701a6 100644 --- a/apps/desktop/src/composables/useDialogSources.ts +++ b/apps/desktop/src/composables/useDialogSources.ts @@ -200,7 +200,8 @@ export function useDialogSources() { showConfigPassphraseDialog.value = false; toast(t("configExport.exportSuccess"), 2000); } catch (e: any) { - configPassphraseError.value = e?.message || String(e); + configPassphraseError.value = + e?.message === "crypto_unavailable" ? t("configExport.cryptoUnavailable") : e?.message || String(e); } } @@ -246,7 +247,11 @@ export function useDialogSources() { } } catch (e: any) { configPassphraseError.value = - e?.message === "wrong_passphrase" ? t("configExport.wrongPassphrase") : e?.message || String(e); + e?.message === "wrong_passphrase" + ? t("configExport.wrongPassphrase") + : e?.message === "crypto_unavailable" + ? t("configExport.cryptoUnavailable") + : e?.message || String(e); } } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index a8062513a..fc30a6c4c 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -813,6 +813,8 @@ export default { exportEncrypted: "Export", decryptImport: "Decrypt & Import", wrongPassphrase: "Wrong passphrase or corrupted file", + cryptoUnavailable: + "Encrypted config import is unavailable in this browser context. Use HTTPS, localhost, or DBX Desktop.", exportSuccess: "Connections exported successfully", importSuccess: "Imported {count} connection(s)", importNavicatSuccess: diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index f4811e163..520c42af5 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -751,6 +751,8 @@ export default { exportEncrypted: "Exportar", decryptImport: "Descifrar e importar", wrongPassphrase: "Frase de paso incorrecta o archivo corrupto", + cryptoUnavailable: + "La importación cifrada no está disponible en este navegador. Usa HTTPS, localhost o DBX Desktop.", exportSuccess: "Conexiones exportadas exitosamente", importSuccess: "Se importaron {count} conexión(es)", importNavicatSuccess: diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 582194230..9978db742 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -831,6 +831,8 @@ export default { exportEncrypted: "Esporta", decryptImport: "Decrittografa e Importa", wrongPassphrase: "Passphrase errata o file corrotto", + cryptoUnavailable: + "L'importazione cifrata non è disponibile in questo contesto del browser. Usa HTTPS, localhost o DBX Desktop.", exportSuccess: "Connessioni esportate con successo", importSuccess: "Importata/e {count} connessione/i", importNavicatSuccess: diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index e5051252e..7e82355e9 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -827,6 +827,8 @@ export default { exportEncrypted: "Exportar", decryptImport: "Descriptografar e Importar", wrongPassphrase: "Frase secreta incorreta ou arquivo corrompido", + cryptoUnavailable: + "A importação criptografada não está disponível neste contexto do navegador. Use HTTPS, localhost ou DBX Desktop.", exportSuccess: "Conexões exportadas com sucesso", importSuccess: "{count} conexão(ões) importada(s)", importNavicatSuccess: diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 76bd49744..00dd97dad 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -797,6 +797,7 @@ export default { exportEncrypted: "导出", decryptImport: "解密并导入", wrongPassphrase: "密码短语错误或文件已损坏", + cryptoUnavailable: "当前浏览器环境不支持加密配置导入,请使用 HTTPS、localhost 或 DBX 桌面版。", exportSuccess: "连接配置导出成功", importSuccess: "已导入 {count} 个连接", importNavicatSuccess: "已导入 {count} 个 Navicat 连接,若个别连接密码为空请补填后测试连接", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 3c998c0bb..03f032f9c 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -777,6 +777,7 @@ export default { exportEncrypted: "匯出", decryptImport: "解密並匯入", wrongPassphrase: "加密密碼錯誤或檔案已損壞", + cryptoUnavailable: "目前瀏覽器環境不支援加密設定匯入,請使用 HTTPS、localhost 或 DBX 桌面版。", exportSuccess: "連線設定匯出成功", importSuccess: "已匯入 {count} 個連線", importNavicatSuccess: "已匯入 {count} 個 Navicat 連線,若個別連線密碼為空請補填後測試連線", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 35a76b913..5d7066d14 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -38,6 +38,7 @@ export const closeDatabaseConnection = forward("closeDatabaseConnection"); export const refreshConnections = forward("refreshConnections"); export const saveConnections = forward("saveConnections"); export const loadConnections = forward("loadConnections"); +export const decryptConfig = forward("decryptConfig"); export const listPlugins = forward("listPlugins"); export const listJdbcDrivers = forward("listJdbcDrivers"); export const importJdbcDrivers = forward("importJdbcDrivers"); diff --git a/apps/desktop/src/lib/configCrypto.ts b/apps/desktop/src/lib/configCrypto.ts index 7d6aa4b3b..c79b96b4e 100644 --- a/apps/desktop/src/lib/configCrypto.ts +++ b/apps/desktop/src/lib/configCrypto.ts @@ -13,11 +13,27 @@ export interface PlainConfigPayload { } const PBKDF2_ITERATIONS = 100_000; +export const CONFIG_CRYPTO_UNAVAILABLE = "crypto_unavailable"; + +function getCrypto(): Crypto { + const runtimeCrypto = globalThis.crypto; + if (!runtimeCrypto?.getRandomValues || !runtimeCrypto.subtle) { + throw new Error(CONFIG_CRYPTO_UNAVAILABLE); + } + return runtimeCrypto; +} + +function getSubtleCrypto(): SubtleCrypto { + const subtle = globalThis.crypto?.subtle; + if (!subtle) throw new Error(CONFIG_CRYPTO_UNAVAILABLE); + return subtle; +} async function deriveKey(passphrase: string, salt: Uint8Array): Promise { const encoder = new TextEncoder(); - const baseKey = await crypto.subtle.importKey("raw", encoder.encode(passphrase), "PBKDF2", false, ["deriveKey"]); - return crypto.subtle.deriveKey( + const subtle = getSubtleCrypto(); + const baseKey = await subtle.importKey("raw", encoder.encode(passphrase), "PBKDF2", false, ["deriveKey"]); + return subtle.deriveKey( { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, baseKey, { name: "AES-GCM", length: 256 }, @@ -45,11 +61,12 @@ function fromBase64(base64: string): Uint8Array { } export async function encryptConfig(json: string, passphrase: string): Promise { - const salt = crypto.getRandomValues(new Uint8Array(16)); - const iv = crypto.getRandomValues(new Uint8Array(12)); + const runtimeCrypto = getCrypto(); + const salt = runtimeCrypto.getRandomValues(new Uint8Array(16)); + const iv = runtimeCrypto.getRandomValues(new Uint8Array(12)); const key = await deriveKey(passphrase, salt); const encoded = new TextEncoder().encode(json); - const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded); + const ciphertext = await runtimeCrypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded); return { format: "dbx-encrypted", version: 1, @@ -63,9 +80,21 @@ export async function decryptConfig(payload: EncryptedPayload, passphrase: strin const salt = fromBase64(payload.salt); const iv = fromBase64(payload.iv); const ciphertext = fromBase64(payload.data); - const key = await deriveKey(passphrase, salt); + let key: CryptoKey; try { - const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); + key = await deriveKey(passphrase, salt); + } catch (error) { + if (error instanceof Error && error.message === CONFIG_CRYPTO_UNAVAILABLE) { + const { isTauriRuntime } = await import("@/lib/tauriRuntime"); + if (!isTauriRuntime(globalThis)) { + const { decryptConfig: decryptConfigOnBackend } = await import("@/lib/api"); + return decryptConfigOnBackend(payload, passphrase); + } + } + throw error; + } + try { + const plaintext = await getSubtleCrypto().decrypt({ name: "AES-GCM", iv }, key, ciphertext); return new TextDecoder().decode(plaintext); } catch { throw new Error("wrong_passphrase"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 5d00cc00a..17bc1e5b7 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -167,6 +167,10 @@ export async function loadConnections(): Promise { return get("/api/connection/list"); } +export async function decryptConfig(payload: unknown, passphrase: string): Promise { + return post("/api/app-settings/config/decrypt", { payload, passphrase }); +} + export async function listSystemFonts(): Promise { return get("/api/system/fonts"); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 74477e532..26a41a61e 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -839,6 +839,11 @@ export async function loadConnections(): Promise { return invoke("load_connections"); } +export async function decryptConfig(payload: unknown, passphrase: string): Promise { + const { decryptConfig: decryptConfigPayload } = await import("@/lib/configCrypto"); + return decryptConfigPayload(payload as any, passphrase); +} + export async function listPlugins(): Promise { return invoke("list_plugins"); } diff --git a/crates/dbx-web/Cargo.toml b/crates/dbx-web/Cargo.toml index bbe133fe6..01ed3c754 100644 --- a/crates/dbx-web/Cargo.toml +++ b/crates/dbx-web/Cargo.toml @@ -25,3 +25,7 @@ rustls = { version = "0.23", features = ["aws-lc-rs"] } tokio-util = "0.7" reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } chrono = "0.4" +aes-gcm = "0.10" +base64 = "0.22" +pbkdf2 = "0.12" +sha2 = "0.10" diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 94699e802..c29820cf0 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -302,6 +302,7 @@ async fn main() { "/app-settings/pinned-tree-node-ids", get(routes::app_settings::load_pinned_tree_node_ids).post(routes::app_settings::save_pinned_tree_node_ids), ) + .route("/app-settings/config/decrypt", post(routes::app_settings::decrypt_config)) .layer(middleware::from_fn_with_state(web_state.clone(), auth::auth_middleware)) .with_state(web_state.clone()); diff --git a/crates/dbx-web/src/routes/app_settings.rs b/crates/dbx-web/src/routes/app_settings.rs index b0345e4b9..2bc2abe80 100644 --- a/crates/dbx-web/src/routes/app_settings.rs +++ b/crates/dbx-web/src/routes/app_settings.rs @@ -1,18 +1,42 @@ use std::sync::Arc; +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; use axum::extract::State; use axum::Json; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use pbkdf2::pbkdf2_hmac; use serde::Deserialize; +use sha2::Sha256; use crate::error::AppError; use crate::state::WebState; +const CONFIG_PBKDF2_ITERATIONS: u32 = 100_000; + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SavePinnedTreeNodeIdsRequest { pub ids: Vec, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedConfigPayload { + pub format: String, + pub version: u8, + pub salt: String, + pub iv: String, + pub data: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DecryptConfigRequest { + pub payload: EncryptedConfigPayload, + pub passphrase: String, +} + pub async fn load_pinned_tree_node_ids(State(state): State>) -> Result>, AppError> { let ids = state.app.storage.load_pinned_tree_node_ids().await.map_err(AppError)?; Ok(Json(ids)) @@ -25,3 +49,55 @@ pub async fn save_pinned_tree_node_ids( state.app.storage.save_pinned_tree_node_ids(&body.ids).await.map_err(AppError)?; Ok(Json(())) } + +pub async fn decrypt_config(Json(body): Json) -> Result, AppError> { + decrypt_config_payload(&body.payload, &body.passphrase).map(Json).map_err(AppError) +} + +fn decrypt_config_payload(payload: &EncryptedConfigPayload, passphrase: &str) -> Result { + if payload.format != "dbx-encrypted" || payload.version != 1 { + return Err("Unsupported encrypted config format".to_string()); + } + let salt = BASE64.decode(&payload.salt).map_err(|_| "wrong_passphrase".to_string())?; + let iv = BASE64.decode(&payload.iv).map_err(|_| "wrong_passphrase".to_string())?; + let ciphertext = BASE64.decode(&payload.data).map_err(|_| "wrong_passphrase".to_string())?; + if iv.len() != 12 { + return Err("wrong_passphrase".to_string()); + } + + let mut key = [0u8; 32]; + pbkdf2_hmac::(passphrase.as_bytes(), &salt, CONFIG_PBKDF2_ITERATIONS, &mut key); + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| "wrong_passphrase".to_string())?; + let plaintext = + cipher.decrypt(Nonce::from_slice(&iv), ciphertext.as_ref()).map_err(|_| "wrong_passphrase".to_string())?; + String::from_utf8(plaintext).map_err(|_| "wrong_passphrase".to_string()) +} + +#[cfg(test)] +mod tests { + use super::{decrypt_config_payload, EncryptedConfigPayload}; + + fn exported_browser_payload() -> EncryptedConfigPayload { + EncryptedConfigPayload { + format: "dbx-encrypted".to_string(), + version: 1, + salt: "AAECAwQFBgcICQoLDA0ODw==".to_string(), + iv: "EBESExQVFhcYGRob".to_string(), + data: "sCyBTex9XqcCCH5mOyJcF/UN9kpnMp+t0VeEtGrJBMt+QyR85kYhUWezuC9yEhM5jF0=".to_string(), + } + } + + #[test] + fn decrypts_browser_exported_config_payload() { + let plaintext = decrypt_config_payload(&exported_browser_payload(), "passphrase").unwrap(); + + assert_eq!(plaintext, r#"{"connections":[{"name":"local"}]}"#); + } + + #[test] + fn rejects_wrong_config_passphrase() { + let error = decrypt_config_payload(&exported_browser_payload(), "wrong").unwrap_err(); + + assert_eq!(error, "wrong_passphrase"); + } +} diff --git a/packages/app-tests/configCrypto.test.ts b/packages/app-tests/configCrypto.test.ts index 2949bda1f..603d72d25 100644 --- a/packages/app-tests/configCrypto.test.ts +++ b/packages/app-tests/configCrypto.test.ts @@ -1,6 +1,11 @@ import { strict as assert } from "node:assert"; import test from "node:test"; -import { encryptConfig, decryptConfig, isEncryptedConfig } from "../../apps/desktop/src/lib/configCrypto.ts"; +import { + CONFIG_CRYPTO_UNAVAILABLE, + encryptConfig, + decryptConfig, + isEncryptedConfig, +} from "../../apps/desktop/src/lib/configCrypto.ts"; test("encrypts and decrypts config round-trip", async () => { const original = JSON.stringify([{ id: "1", name: "test", password: "secret123" }]); @@ -32,3 +37,20 @@ test("detects encrypted config format", () => { assert.equal(isEncryptedConfig(null), false); assert.equal(isEncryptedConfig("string"), false); }); + +test("reports crypto unavailable when Web Crypto is missing", async () => { + const originalCrypto = globalThis.crypto; + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) }, + }); + + try { + await assert.rejects(() => encryptConfig("{}", "passphrase"), (err: Error) => err.message === CONFIG_CRYPTO_UNAVAILABLE); + } finally { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: originalCrypto, + }); + } +});