fix(web): support encrypted config import over HTTP

This commit is contained in:
t8y2 2026-06-06 13:12:28 +08:00
parent 7cfdcff8c7
commit 7acd1625a9
16 changed files with 171 additions and 10 deletions

4
Cargo.lock generated
View File

@ -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",

View File

@ -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);
}
}

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -797,6 +797,7 @@ export default {
exportEncrypted: "导出",
decryptImport: "解密并导入",
wrongPassphrase: "密码短语错误或文件已损坏",
cryptoUnavailable: "当前浏览器环境不支持加密配置导入,请使用 HTTPS、localhost 或 DBX 桌面版。",
exportSuccess: "连接配置导出成功",
importSuccess: "已导入 {count} 个连接",
importNavicatSuccess: "已导入 {count} 个 Navicat 连接,若个别连接密码为空请补填后测试连接",

View File

@ -777,6 +777,7 @@ export default {
exportEncrypted: "匯出",
decryptImport: "解密並匯入",
wrongPassphrase: "加密密碼錯誤或檔案已損壞",
cryptoUnavailable: "目前瀏覽器環境不支援加密設定匯入,請使用 HTTPS、localhost 或 DBX 桌面版。",
exportSuccess: "連線設定匯出成功",
importSuccess: "已匯入 {count} 個連線",
importNavicatSuccess: "已匯入 {count} 個 Navicat 連線,若個別連線密碼為空請補填後測試連線",

View File

@ -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");

View File

@ -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<ArrayBuffer>): Promise<CryptoKey> {
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<ArrayBuffer> {
}
export async function encryptConfig(json: string, passphrase: string): Promise<EncryptedPayload> {
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");

View File

@ -167,6 +167,10 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
return get("/api/connection/list");
}
export async function decryptConfig(payload: unknown, passphrase: string): Promise<string> {
return post("/api/app-settings/config/decrypt", { payload, passphrase });
}
export async function listSystemFonts(): Promise<string[]> {
return get("/api/system/fonts");
}

View File

@ -839,6 +839,11 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
return invoke("load_connections");
}
export async function decryptConfig(payload: unknown, passphrase: string): Promise<string> {
const { decryptConfig: decryptConfigPayload } = await import("@/lib/configCrypto");
return decryptConfigPayload(payload as any, passphrase);
}
export async function listPlugins(): Promise<InstalledPlugin[]> {
return invoke("list_plugins");
}

View File

@ -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"

View File

@ -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());

View File

@ -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<String>,
}
#[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<Arc<WebState>>) -> Result<Json<Vec<String>>, 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<DecryptConfigRequest>) -> Result<Json<String>, AppError> {
decrypt_config_payload(&body.payload, &body.passphrase).map(Json).map_err(AppError)
}
fn decrypt_config_payload(payload: &EncryptedConfigPayload, passphrase: &str) -> Result<String, String> {
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::<Sha256>(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");
}
}

View File

@ -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,
});
}
});