fix(web): support encrypted config import over HTTP
This commit is contained in:
parent
7cfdcff8c7
commit
7acd1625a9
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -797,6 +797,7 @@ export default {
|
|||
exportEncrypted: "导出",
|
||||
decryptImport: "解密并导入",
|
||||
wrongPassphrase: "密码短语错误或文件已损坏",
|
||||
cryptoUnavailable: "当前浏览器环境不支持加密配置导入,请使用 HTTPS、localhost 或 DBX 桌面版。",
|
||||
exportSuccess: "连接配置导出成功",
|
||||
importSuccess: "已导入 {count} 个连接",
|
||||
importNavicatSuccess: "已导入 {count} 个 Navicat 连接,若个别连接密码为空请补填后测试连接",
|
||||
|
|
|
|||
|
|
@ -777,6 +777,7 @@ export default {
|
|||
exportEncrypted: "匯出",
|
||||
decryptImport: "解密並匯入",
|
||||
wrongPassphrase: "加密密碼錯誤或檔案已損壞",
|
||||
cryptoUnavailable: "目前瀏覽器環境不支援加密設定匯入,請使用 HTTPS、localhost 或 DBX 桌面版。",
|
||||
exportSuccess: "連線設定匯出成功",
|
||||
importSuccess: "已匯入 {count} 個連線",
|
||||
importNavicatSuccess: "已匯入 {count} 個 Navicat 連線,若個別連線密碼為空請補填後測試連線",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue