fix(export): harden config export passphrase handling

This commit is contained in:
dadong authored and GitHub committed 2026-08-28 11:11:35 +08:00
1 parent d8ec9bb8d0
commit 99a9961712
7 files changed
+202 -10

No files matched your search

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Lock } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { getRememberedExportPassphrase } from "@/lib/backend/exportPassphraseSession";
const props = defineProps<{
open: boolean;
@@ -29,15 +30,24 @@ const dialogOpen = computed({
const passphrase = ref("");
const passphraseConfirm = ref("");
const error = ref("");
// 密码短语输入框组件引用,打开对话框时用于手动聚焦
const passphraseInput = ref<InstanceType<typeof PasswordInput> | null>(null);
watch(
dialogOpen,
(open) => {
if (open) {
passphrase.value = "";
passphraseConfirm.value = "";
error.value = "";
}
async (open) => {
if (!open) return;
// 导出模式回显本次会话上次使用的密码短语(仅内存保存,不落盘);导入模式始终从空开始
const remembered = props.mode === "export" ? getRememberedExportPassphrase() : "";
passphrase.value = remembered;
passphraseConfirm.value = remembered;
error.value = "";
await nextTick();
// 手动聚焦并把光标定位到末尾,避免新输入插到回显内容前面
const inputEl = passphraseInput.value?.$el?.querySelector("input");
inputEl?.focus();
const length = String(inputEl?.value ?? "").length;
inputEl?.setSelectionRange(length, length);
},
{ immediate: true },
);
@@ -64,7 +74,8 @@ const displayError = computed(() => error.value || props.externalError || "");
<template>
<Dialog v-model:open="dialogOpen">
<DialogContent class="sm:max-w-[440px]">
<!-- initial-focus=false:禁用默认自动聚焦,改由打开对话框时的手动聚焦把光标定位到回显内容末尾 -->
<DialogContent class="sm:max-w-[440px]" :initial-focus="false">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Lock class="h-5 w-5" />
@@ -79,7 +90,7 @@ const displayError = computed(() => error.value || props.externalError || "");
<div class="grid gap-2">
<Label>{{ t("configExport.passphrase") }}</Label>
<PasswordInput v-model="passphrase" :placeholder="t('configExport.passphrasePlaceholder')" :toggle-tab-index="-1" :disabled="busy" @keydown.enter="mode === 'import' ? confirm() : undefined" />
<PasswordInput ref="passphraseInput" v-model="passphrase" :placeholder="t('configExport.passphrasePlaceholder')" :toggle-tab-index="-1" :disabled="busy" @keydown.enter="mode === 'import' ? confirm() : undefined" />
</div>
<div v-if="mode === 'export'" class="grid gap-2">
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
vi.mock("@/components/ui/dialog", async () => {
@@ -63,9 +63,14 @@ vi.mock("@lucide/vue", async () => {
});
import ConfigPassphraseDialog from "@/components/config/ConfigPassphraseDialog.vue";
import { clearRememberedExportPassphrase, rememberExportPassphrase } from "@/lib/backend/exportPassphraseSession";
const mountedApps: App[] = [];
beforeEach(() => {
clearRememberedExportPassphrase();
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
@@ -113,6 +118,55 @@ describe("ConfigPassphraseDialog", () => {
expect(document.body.textContent).toContain("Passphrase is required");
});
it("blocks encrypted export when the confirmation passphrase does not match", async () => {
const confirm = vi.fn();
await mountDialog({ open: true, mode: "export", onConfirm: confirm });
const [passphraseInput, confirmInput] = [...document.body.querySelectorAll("input")];
passphraseInput.value = "correct-pass";
passphraseInput.dispatchEvent(new Event("input"));
confirmInput.value = "mismatched-pass";
confirmInput.dispatchEvent(new Event("input"));
const encrypted = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Export encrypted"));
encrypted?.click();
await nextTick();
expect(confirm).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Passphrases do not match");
});
it("confirms encrypted export when both passphrase entries match", async () => {
const confirm = vi.fn();
await mountDialog({ open: true, mode: "export", onConfirm: confirm });
const [passphraseInput, confirmInput] = [...document.body.querySelectorAll("input")];
passphraseInput.value = "matching-pass";
passphraseInput.dispatchEvent(new Event("input"));
confirmInput.value = "matching-pass";
confirmInput.dispatchEvent(new Event("input"));
const encrypted = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Export encrypted"));
encrypted?.click();
await nextTick();
expect(confirm).toHaveBeenCalledWith("matching-pass");
});
it("prefills the session-remembered passphrase into both export fields", async () => {
rememberExportPassphrase("session-pass");
await mountDialog({ open: true, mode: "export", onConfirm: vi.fn() });
expect([...document.body.querySelectorAll("input")].map((input) => input.value)).toEqual(["session-pass", "session-pass"]);
});
it("never prefills the passphrase in import mode", async () => {
rememberExportPassphrase("session-pass");
await mountDialog({ open: true, mode: "import", onConfirm: vi.fn() });
expect([...document.body.querySelectorAll("input")].map((input) => input.value)).toEqual([""]);
});
it("keeps import mode free of the unencrypted export action", async () => {
await mountDialog({ open: true, mode: "import", onConfirm: vi.fn() });
@@ -32,12 +32,14 @@ vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => mocks.sto
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
import { useDialogSources } from "@/composables/useDialogSources";
import { clearRememberedExportPassphrase, getRememberedExportPassphrase } from "@/lib/backend/exportPassphraseSession";
const mountedApps: App[] = [];
beforeEach(() => {
mocks.store.connections = [];
mocks.store.exportConnectionsToFile.mockReset().mockResolvedValue("saved");
clearRememberedExportPassphrase();
});
afterEach(() => {
@@ -178,4 +180,50 @@ describe("useDialogSources", () => {
await second;
expect(dialogs.configExportBusy.value).toBe(false);
});
it("keeps the passphrase dialog open and reports an error when writing the encrypted export fails", async () => {
mocks.store.connections = [conn("selected")];
mocks.store.exportConnectionsToFile.mockRejectedValue(new Error("disk full"));
const dialogs = await mountDialogs();
dialogs.onExportClick();
dialogs.onConfigConnectionSelectConfirm(["selected"]);
await dialogs.onExportConfirm("passphrase");
expect(dialogs.showConfigPassphraseDialog.value).toBe(true);
expect(dialogs.configPassphraseError.value).toBe("Failed to export connections");
expect(mocks.toast).not.toHaveBeenCalled();
expect(getRememberedExportPassphrase()).toBe("");
});
it("reports an error instead of success when writing the plaintext export fails", async () => {
mocks.store.connections = [conn("selected")];
mocks.store.exportConnectionsToFile.mockRejectedValue(new Error("disk full"));
const dialogs = await mountDialogs();
dialogs.onExportClick();
dialogs.onConfigConnectionSelectConfirm(["selected"]);
dialogs.onRequestUnencryptedExport();
await dialogs.onConfigUnencryptedExportConfirm();
expect(dialogs.showConfigUnencryptedExportConfirm.value).toBe(false);
expect(dialogs.showConfigPassphraseDialog.value).toBe(true);
expect(dialogs.configPassphraseError.value).toBe("Failed to export connections");
expect(mocks.toast).not.toHaveBeenCalled();
});
it("remembers the passphrase only after the export file is actually written", async () => {
mocks.store.connections = [conn("selected")];
mocks.store.exportConnectionsToFile.mockResolvedValueOnce("cancelled").mockResolvedValueOnce("saved");
const dialogs = await mountDialogs();
dialogs.onExportClick();
dialogs.onConfigConnectionSelectConfirm(["selected"]);
await dialogs.onExportConfirm("passphrase");
expect(getRememberedExportPassphrase()).toBe("");
await dialogs.onExportConfirm("passphrase");
expect(getRememberedExportPassphrase()).toBe("passphrase");
});
});
@@ -2,6 +2,7 @@ import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { rememberExportPassphrase } from "@/lib/backend/exportPassphraseSession";
import { hasSidebarLayoutEntries } from "@/lib/sidebar/sidebarLayout";
import type { ConnectionConfigBundle } from "@/lib/connection/connectionConfigTransfer";
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
@@ -337,6 +338,8 @@ export function useDialogSources() {
try {
const result = await connectionStore.exportConnectionsToFile({ mode: "encrypted", passphrase }, pendingExportConnectionIds.value);
if (result === "cancelled") return;
// 仅在文件写入成功后才记住密码短语,供同一会话内下次导出对话框回显(仅内存,不落盘)
rememberExportPassphrase(passphrase);
showConfigPassphraseDialog.value = false;
clearPendingExportState();
toast(t("configExport.exportSuccess"), 2000);
@@ -0,0 +1,31 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { clearRememberedExportPassphrase, getRememberedExportPassphrase, rememberExportPassphrase } from "@/lib/backend/exportPassphraseSession";
describe("exportPassphraseSession", () => {
beforeEach(() => {
clearRememberedExportPassphrase();
localStorage.clear();
sessionStorage.clear();
});
it("starts with no remembered passphrase", () => {
expect(getRememberedExportPassphrase()).toBe("");
});
it("remembers the passphrase within the current session", () => {
rememberExportPassphrase("session-passphrase");
expect(getRememberedExportPassphrase()).toBe("session-passphrase");
rememberExportPassphrase("second-passphrase");
expect(getRememberedExportPassphrase()).toBe("second-passphrase");
});
it("never persists the passphrase to localStorage or sessionStorage", () => {
rememberExportPassphrase("session-passphrase");
expect(localStorage.length).toBe(0);
expect(sessionStorage.length).toBe(0);
});
});
@@ -0,0 +1,19 @@
// 加密导出的密码短语只保留在当前会话的内存中,应用重启或页面刷新后自动清空。
// 相比 localStorage 等持久化存储,明文密钥不会长期落盘,
// 同源脚本或 WebView 存储检查都无法在会话之外读取到它。
let rememberedExportPassphrase = "";
// 记住本次加密导出使用的密码短语,同一会话内下次打开导出对话框时自动回显
export function rememberExportPassphrase(passphrase: string) {
rememberedExportPassphrase = passphrase;
}
// 读取本会话上次加密导出使用的密码短语,没有则返回空字符串
export function getRememberedExportPassphrase(): string {
return rememberedExportPassphrase;
}
// 清空记住的密码短语,供测试重置会话状态使用
export function clearRememberedExportPassphrase() {
rememberedExportPassphrase = "";
}
@@ -382,6 +382,32 @@ describe("connectionStore timeout recovery", () => {
expect(writeTextFile).not.toHaveBeenCalled();
});
it("fails the export when writing the file fails instead of reporting success", async () => {
const save = vi.fn().mockResolvedValue("/home/user/dbx-connections.json");
const writeTextFile = vi.fn().mockRejectedValue(new Error("disk full"));
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.doMock("@tauri-apps/plugin-dialog", () => ({ save }));
vi.doMock("@tauri-apps/plugin-fs", () => ({ writeTextFile }));
vi.doMock("@/lib/backend/api", () => ({
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadEditorSettings: vi.fn().mockResolvedValue(null),
loadConnections: vi.fn().mockResolvedValue([postgresConnection()]),
loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]),
loadSidebarLayout: vi.fn().mockResolvedValue(null),
loadTunnelProfiles: vi.fn().mockResolvedValue([]),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveEditorSettings: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.initFromDisk();
await expect(store.exportConnectionsToFile({ mode: "plaintext" })).rejects.toThrow("disk full");
expect(writeTextFile).toHaveBeenCalledOnce();
});
it("clears connection node loading when health check timeout forces reconnect failure", async () => {
const checkConnectionHealth = vi.fn(() => new Promise(() => undefined));
const connectDb = vi.fn().mockRejectedValue(new Error("reconnect failed"));