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() });
|
||||
|
||||
|
||||
Reference in new issue
Block a user