fix(export): harden config export passphrase handling
This commit is contained in:
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">
|
||||
|
||||
+55
-1
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user