From a66cffb692daceee3588c867881fbc1e47a2d4a4 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 3 May 2026 12:46:49 +0800 Subject: [PATCH] feat: add configuration export/import with passphrase encryption --- src/App.vue | 61 ++++++++++- .../config/ConfigPassphraseDialog.vue | 103 ++++++++++++++++++ src/i18n/locales/en.ts | 19 ++++ src/i18n/locales/zh-CN.ts | 19 ++++ src/lib/configCrypto.ts | 79 ++++++++++++++ src/stores/connectionStore.ts | 43 ++++++-- tests/configCrypto.test.ts | 34 ++++++ 7 files changed, 347 insertions(+), 11 deletions(-) create mode 100644 src/components/config/ConfigPassphraseDialog.vue create mode 100644 src/lib/configCrypto.ts create mode 100644 tests/configCrypto.test.ts diff --git a/src/App.vue b/src/App.vue index 412378f99d..af77b6dc5a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -42,6 +42,7 @@ const TableImportDialog = defineAsyncComponent(() => import("@/components/import const TableStructureEditorDialog = defineAsyncComponent(() => import("@/components/structure/TableStructureEditorDialog.vue")); const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue")); const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue")); +const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue")); import type { ConnectionConfig } from "@/types/database"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; @@ -131,6 +132,10 @@ const showDiagramDialog = ref(false); const showTableImportDialog = ref(false); const showStructureEditorDialog = ref(false); const showFieldLineageDialog = ref(false); +const showConfigPassphraseDialog = ref(false); +const configPassphraseMode = ref<"export" | "import">("export"); +const configPassphraseError = ref(""); +const pendingImportContent = ref(""); const transferPrefillConnectionId = ref(""); const transferPrefillDatabase = ref(""); const schemaDiffPrefillConnectionId = ref(""); @@ -318,6 +323,50 @@ async function openLineageTarget(target: LineageNavigationTarget) { } } +function onExportClick() { + configPassphraseMode.value = "export"; + configPassphraseError.value = ""; + showConfigPassphraseDialog.value = true; +} + +async function onExportConfirm(passphrase: string) { + try { + await connectionStore.exportConnectionsToFile(passphrase); + showConfigPassphraseDialog.value = false; + toast(t("configExport.exportSuccess"), 2000); + } catch (e: any) { + configPassphraseError.value = e?.message || String(e); + } +} + +async function onImportClick() { + try { + const result = await connectionStore.readImportFile(); + if (!result) return; + pendingImportContent.value = result.content; + if (result.encrypted) { + configPassphraseMode.value = "import"; + configPassphraseError.value = ""; + showConfigPassphraseDialog.value = true; + } else { + const count = await connectionStore.importConnectionsFromFile(result.content, null); + toast(count > 0 ? t("configExport.importSuccess", { count }) : t("configExport.importNone"), 2000); + } + } catch (e: any) { + toast(e?.message || String(e), 4000); + } +} + +async function onImportConfirm(passphrase: string) { + try { + const count = await connectionStore.importConnectionsFromFile(pendingImportContent.value, passphrase); + showConfigPassphraseDialog.value = false; + toast(count > 0 ? t("configExport.importSuccess", { count }) : t("configExport.importNone"), 2000); + } catch (e: any) { + configPassphraseError.value = e?.message === "wrong_passphrase" ? t("configExport.wrongPassphrase") : (e?.message || String(e)); + } +} + function onConnectionConnectStarted(name: string) { toast(t("connection.connecting", { name }), 30000); } @@ -969,7 +1018,7 @@ async function setupFileDrop() { - @@ -977,7 +1026,7 @@ async function setupFileDrop() { - @@ -1393,7 +1442,7 @@ async function setupFileDrop() { -
@@ -1492,6 +1541,12 @@ async function setupFileDrop() { :prefill-column="lineagePrefillColumn" @open-target="openLineageTarget" /> + diff --git a/src/components/config/ConfigPassphraseDialog.vue b/src/components/config/ConfigPassphraseDialog.vue new file mode 100644 index 0000000000..f232e73d3a --- /dev/null +++ b/src/components/config/ConfigPassphraseDialog.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index eb8cefdc1b..960c29b9d1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -222,6 +222,25 @@ export default { cancel: "Cancel", refresh: "Analyze Again", }, + configExport: { + passphraseTitle: "Encrypt & Export", + passphraseImportTitle: "Decrypt & Import", + passphraseExportHint: "To include connection passwords in the export, set a passphrase for encryption. You will need the same passphrase to import.", + passphraseImportHint: "This file is encrypted. Enter the passphrase to decrypt and import.", + passphrase: "Passphrase", + passphraseConfirm: "Confirm Passphrase", + passphrasePlaceholder: "Enter passphrase", + passphraseConfirmPlaceholder: "Re-enter passphrase", + passphraseRequired: "Passphrase is required", + passphraseMismatch: "Passphrases do not match", + passphraseTooShort: "Passphrase must be at least 4 characters", + exportEncrypted: "Export", + decryptImport: "Decrypt & Import", + wrongPassphrase: "Wrong passphrase or corrupted file", + exportSuccess: "Connections exported successfully", + importSuccess: "Imported {count} connection(s)", + importNone: "No new connections to import", + }, ai: { placeholder: "Describe your query in natural language...", settings: "AI Settings", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index f040ed671f..a8a3785c7a 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -222,6 +222,25 @@ export default { cancel: "取消读取", refresh: "重新分析", }, + configExport: { + passphraseTitle: "加密导出", + passphraseImportTitle: "解密导入", + passphraseExportHint: "如需导出连接密码,请设置密码短语进行加密保护。导入时需要输入相同的密码短语才能解密。", + passphraseImportHint: "此文件已加密,请输入密码短语来解密并导入。", + passphrase: "密码短语", + passphraseConfirm: "确认密码短语", + passphrasePlaceholder: "输入密码短语", + passphraseConfirmPlaceholder: "再次输入密码短语", + passphraseRequired: "请输入密码短语", + passphraseMismatch: "两次输入的密码短语不一致", + passphraseTooShort: "密码短语至少需要 4 个字符", + exportEncrypted: "导出", + decryptImport: "解密并导入", + wrongPassphrase: "密码短语错误或文件已损坏", + exportSuccess: "连接配置导出成功", + importSuccess: "已导入 {count} 个连接", + importNone: "没有新的连接需要导入", + }, ai: { placeholder: "描述你想查什么...", settings: "AI 设置", diff --git a/src/lib/configCrypto.ts b/src/lib/configCrypto.ts new file mode 100644 index 0000000000..e0ac236694 --- /dev/null +++ b/src/lib/configCrypto.ts @@ -0,0 +1,79 @@ +export interface EncryptedPayload { + format: "dbx-encrypted"; + version: 1; + salt: string; + iv: string; + data: string; +} + +export interface PlainConfigPayload { + format: "dbx-config"; + version: 1; + connections: unknown[]; +} + +const PBKDF2_ITERATIONS = 100_000; + +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( + { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, + baseKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +function toBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function fromBase64(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +export async function encryptConfig(json: string, passphrase: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.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); + return { + format: "dbx-encrypted", + version: 1, + salt: toBase64(salt), + iv: toBase64(iv), + data: toBase64(ciphertext), + }; +} + +export async function decryptConfig(payload: EncryptedPayload, passphrase: string): Promise { + const salt = fromBase64(payload.salt); + const iv = fromBase64(payload.iv); + const ciphertext = fromBase64(payload.data); + const key = await deriveKey(passphrase, salt); + try { + const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); + return new TextDecoder().decode(plaintext); + } catch { + throw new Error("wrong_passphrase"); + } +} + +export function isEncryptedConfig(data: unknown): data is EncryptedPayload { + if (typeof data !== "object" || data === null) return false; + const obj = data as Record; + return obj.format === "dbx-encrypted" && obj.version === 1 && typeof obj.salt === "string" && typeof obj.iv === "string" && typeof obj.data === "string"; +} diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index 8e6f2ec73b..74c570b09c 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -577,29 +577,55 @@ export const useConnectionStore = defineStore("connection", () => { await api.saveConnections(nextConnections); } - async function exportConnectionsToFile() { + async function exportConnectionsToFile(passphrase: string) { const { save } = await import("@tauri-apps/plugin-dialog"); const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + const { encryptConfig } = await import("@/lib/configCrypto"); const path = await save({ filters: [{ name: "JSON", extensions: ["json"] }], defaultPath: "dbx-connections.json" }); if (!path) return; - const data = connections.value.map((c) => ({ ...c, password: "", ssh_password: "", connection_string: undefined })); - await writeTextFile(path, JSON.stringify(data, null, 2)); + const json = JSON.stringify(connections.value); + const payload = await encryptConfig(json, passphrase); + await writeTextFile(path, JSON.stringify(payload, null, 2)); } - async function importConnectionsFromFile() { + async function readImportFile(): Promise<{ content: string; encrypted: boolean } | null> { const { open } = await import("@tauri-apps/plugin-dialog"); const { readTextFile } = await import("@tauri-apps/plugin-fs"); const path = await open({ filters: [{ name: "JSON", extensions: ["json"] }], multiple: false }); - if (!path) return; - const json = await readTextFile(path as string); - const imported: ConnectionConfig[] = JSON.parse(json); + if (!path) return null; + const content = await readTextFile(path as string); + const { isEncryptedConfig } = await import("@/lib/configCrypto"); + const parsed = JSON.parse(content); + return { content, encrypted: isEncryptedConfig(parsed) }; + } + + async function importConnectionsFromFile(content: string, passphrase: string | null): Promise { + let imported: ConnectionConfig[]; + const parsed = JSON.parse(content); + + if (passphrase) { + const { decryptConfig } = await import("@/lib/configCrypto"); + const json = await decryptConfig(parsed, passphrase); + imported = JSON.parse(json); + } else if (Array.isArray(parsed)) { + imported = parsed; + } else if (parsed.format === "dbx-config" && Array.isArray(parsed.connections)) { + imported = parsed.connections; + } else { + imported = []; + } + + let count = 0; for (const config of imported) { - if (!connections.value.find((c) => c.id === config.id)) { + const duplicate = connections.value.find((c) => c.name === config.name && c.host === config.host && c.port === config.port); + if (!duplicate) { config.id = crypto.randomUUID(); const normalized = normalizeConnection(config); await addConnection(normalized); + count++; } } + return count; } async function initFromDisk() { @@ -656,6 +682,7 @@ export const useConnectionStore = defineStore("connection", () => { listCompletionTables, listCompletionColumns, exportConnectionsToFile, + readImportFile, importConnectionsFromFile, transferSource, schemaDiffSource, diff --git a/tests/configCrypto.test.ts b/tests/configCrypto.test.ts new file mode 100644 index 0000000000..98fac1cc17 --- /dev/null +++ b/tests/configCrypto.test.ts @@ -0,0 +1,34 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { encryptConfig, decryptConfig, isEncryptedConfig } from "../src/lib/configCrypto.ts"; + +test("encrypts and decrypts config round-trip", async () => { + const original = JSON.stringify([{ id: "1", name: "test", password: "secret123" }]); + const encrypted = await encryptConfig(original, "my-passphrase"); + + assert.equal(encrypted.format, "dbx-encrypted"); + assert.equal(encrypted.version, 1); + assert.ok(encrypted.salt); + assert.ok(encrypted.iv); + assert.ok(encrypted.data); + + const decrypted = await decryptConfig(encrypted, "my-passphrase"); + assert.equal(decrypted, original); +}); + +test("fails to decrypt with wrong passphrase", async () => { + const encrypted = await encryptConfig('{"test":true}', "correct-passphrase"); + + await assert.rejects( + () => decryptConfig(encrypted, "wrong-passphrase"), + (err: Error) => err.message === "wrong_passphrase", + ); +}); + +test("detects encrypted config format", () => { + assert.equal(isEncryptedConfig({ format: "dbx-encrypted", version: 1, salt: "a", iv: "b", data: "c" }), true); + assert.equal(isEncryptedConfig({ format: "dbx-config", version: 1, connections: [] }), false); + assert.equal(isEncryptedConfig([{ id: "1" }]), false); + assert.equal(isEncryptedConfig(null), false); + assert.equal(isEncryptedConfig("string"), false); +});