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