diff --git a/src/components/settings/sections/ChangeMasterPasswordModal.test.tsx b/src/components/settings/sections/ChangeMasterPasswordModal.test.tsx new file mode 100644 index 000000000..a5273b615 --- /dev/null +++ b/src/components/settings/sections/ChangeMasterPasswordModal.test.tsx @@ -0,0 +1,109 @@ +import { test, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; + +const h = vi.hoisted(() => ({ + canRead: true, + read: vi.fn(async (_reason: string) => ({ outcome: "ok", password: "stored-pw" as string | null })), + change: vi.fn(async (_current: string, _next: string, _proof: unknown) => {}), + refresh: vi.fn(async () => ({ jwt: "J" }) as { jwt: string } | "revoked" | "failed"), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@/utils/platform", async () => ({ + ...(await vi.importActual("@/utils/platform")), + usePlatform: () => "windows", +})); +vi.mock("@/services/account", () => ({ changeMasterPassword: h.change })); +vi.mock("@/services/authTokens", () => ({ refreshAccessToken: h.refresh })); +vi.mock("@/services/vaultBinding", () => ({ + canReadPasswordWithSystemAuth: async () => h.canRead, + readPasswordWithSystemAuth: h.read, +})); +vi.mock("@/services/twoFactor", () => ({ + factorMethods: () => ["totp"], + getFactors: async () => ({}), + passkeyStepUp: vi.fn(), +})); + +const { default: ChangeMasterPasswordModal } = await import("./ChangeMasterPasswordModal"); + +const KEY = "settings.account.changeMasterPassword"; + +beforeEach(() => { + h.canRead = true; + h.read.mockReset(); + h.read.mockResolvedValue({ outcome: "ok", password: "stored-pw" }); + h.change.mockReset(); + h.refresh.mockReset(); + h.refresh.mockResolvedValue({ jwt: "J" }); +}); +afterEach(cleanup); + +async function renderModal() { + render( {}} />); + await act(async () => {}); +} + +function fillNew(value: string) { + fireEvent.change(screen.getByPlaceholderText(`${KEY}.newPlaceholder`), { target: { value } }); + fireEvent.change(screen.getByPlaceholderText(`${KEY}.confirmPlaceholder`), { target: { value } }); +} + +test("a device verification stands in for the current password", async () => { + await renderModal(); + await act(async () => fireEvent.click(screen.getByText(`${KEY}.verifyWithDevice`))); + + expect(h.read).toHaveBeenCalledWith(`${KEY}.deviceReason`); + expect(screen.queryByPlaceholderText(`${KEY}.currentPlaceholder`)).toBeNull(); + expect(screen.getByText(`${KEY}.verifiedWithDevice`)).toBeTruthy(); + + fillNew("brand-new-pw"); + await act(async () => fireEvent.click(screen.getByText(`${KEY}.changeBtn`))); + expect(h.change).toHaveBeenCalledWith("stored-pw", "brand-new-pw", null); +}); + +test("a cancelled prompt keeps the typed field without an error", async () => { + h.read.mockResolvedValue({ outcome: "cancelled", password: null }); + await renderModal(); + await act(async () => fireEvent.click(screen.getByText(`${KEY}.verifyWithDevice`))); + + expect(screen.getByPlaceholderText(`${KEY}.currentPlaceholder`)).toBeTruthy(); + expect(screen.queryByText(`${KEY}.deviceFailed`)).toBeNull(); +}); + +test("a device that cannot confirm the user says so and keeps the typed field", async () => { + h.read.mockResolvedValue({ outcome: "declined", password: null }); + await renderModal(); + await act(async () => fireEvent.click(screen.getByText(`${KEY}.verifyWithDevice`))); + + expect(screen.getByPlaceholderText(`${KEY}.currentPlaceholder`)).toBeTruthy(); + expect(screen.getByText(`${KEY}.deviceFailed`)).toBeTruthy(); +}); + +test("no device verification is offered without a prompt to show", async () => { + h.canRead = false; + await renderModal(); + expect(screen.queryByText(`${KEY}.verifyWithDevice`)).toBeNull(); +}); + +test("a session revoked by a password change elsewhere is caught before any prompt", async () => { + h.refresh.mockResolvedValue("revoked"); + await renderModal(); + await act(async () => fireEvent.click(screen.getByText(`${KEY}.verifyWithDevice`))); + + expect(h.read).not.toHaveBeenCalled(); + expect(screen.getByText(`${KEY}.deviceSignedOut`)).toBeTruthy(); + expect(screen.getByPlaceholderText(`${KEY}.currentPlaceholder`)).toBeTruthy(); +}); + +test("an unreachable server does not block the device check", async () => { + h.refresh.mockResolvedValue("failed"); + await renderModal(); + await act(async () => fireEvent.click(screen.getByText(`${KEY}.verifyWithDevice`))); + + expect(h.read).toHaveBeenCalled(); +}); diff --git a/src/components/settings/sections/ChangeMasterPasswordModal.tsx b/src/components/settings/sections/ChangeMasterPasswordModal.tsx index 85568ff1e..3527f0abd 100644 --- a/src/components/settings/sections/ChangeMasterPasswordModal.tsx +++ b/src/components/settings/sections/ChangeMasterPasswordModal.tsx @@ -2,6 +2,10 @@ import { useEffect, useState, type FormEvent } from "react"; import { Icon } from "@iconify/react"; import { useTranslation } from "react-i18next"; import { changeMasterPassword } from "@/services/account"; +import { canReadPasswordWithSystemAuth, readPasswordWithSystemAuth } from "@/services/vaultBinding"; +import { refreshAccessToken } from "@/services/authTokens"; +import { systemAuthMethodKey } from "@/hooks/useSystemAuthPrompt"; +import { usePlatform } from "@/utils/platform"; import { INPUT_CLASS } from "@/components/layout/authParts"; import { EMPTY_TWO_FACTOR_ENTRY, TwoFactorField, entryProof, usePasskeyCeremony, wasCancelled, type TwoFactorEntry, @@ -27,6 +31,37 @@ export default function ChangeMasterPasswordModal({ twoFactorEnabled, onClose }: const [done, setDone] = useState(false); const [methods, setMethods] = useState(["totp", "recovery"]); const passkey = usePasskeyCeremony(); + const platform = usePlatform(); + const [deviceAvailable, setDeviceAvailable] = useState(false); + const [deviceVerified, setDeviceVerified] = useState(false); + const [verifying, setVerifying] = useState(false); + + useEffect(() => { + let alive = true; + void canReadPasswordWithSystemAuth().then((ok) => alive && setDeviceAvailable(ok)).catch(() => {}); + return () => { + alive = false; + }; + }, []); + + const verifyWithDevice = async () => { + setVerifying(true); + setError(""); + // A password changed elsewhere revokes this session before its access token expires, leaving the stored one stale. + if ((await refreshAccessToken().catch(() => "failed" as const)) === "revoked") { + setVerifying(false); + setError(t("settings.account.changeMasterPassword.deviceSignedOut")); + return; + } + const { outcome, password } = await readPasswordWithSystemAuth(t("settings.account.changeMasterPassword.deviceReason")); + setVerifying(false); + if (password) { + setCurrentPassword(password); + setDeviceVerified(true); + } else if (outcome !== "cancelled") { + setError(t("settings.account.changeMasterPassword.deviceFailed")); + } + }; useEffect(() => { if (!twoFactorEnabled) return; @@ -92,13 +127,33 @@ export default function ChangeMasterPasswordModal({ twoFactorEnabled, onClose }: ) : (
- + {deviceVerified ? ( +
+ +

{t("settings.account.changeMasterPassword.verifiedWithDevice")}

+
+ ) : ( + <> + + {deviceAvailable && ( + + )} + + )} ({ bindResult: "ok", openResult: "ok", verify: vi.fn(async () => "ok"), + systemAuthAvailable: true, systemAuthUnlock: true, forgetOthers: vi.fn(async () => undefined), })); @@ -17,15 +18,18 @@ vi.mock("@/services/account", () => ({ getAccountMode: vi.fn(async () => h.store.mode ?? null), isCurrentMasterPassword: vi.fn(async (p: string) => p === "pw"), })); -vi.mock("@/services/appLock", () => ({ systemAuthVerify: h.verify })); +vi.mock("@/services/appLock", () => ({ + systemAuthVerify: h.verify, + systemAuthAvailable: async () => h.systemAuthAvailable, +})); vi.mock("@/services/savedAccounts", () => ({ forgetOtherPlainPasswords: h.forgetOthers })); vi.mock("@/stores/securityStore", () => ({ useSecurityStore: { getState: () => ({ systemAuthUnlock: h.systemAuthUnlock }) }, })); import { - bindNow, bindingStatus, disableBinding, disableWithPassword, enableBinding, - rebindAfterPassword, unlockWithSystemAuth, verifyForLockScreen, + bindNow, bindingStatus, canReadPasswordWithSystemAuth, disableBinding, disableWithPassword, enableBinding, + readPasswordWithSystemAuth, rebindAfterPassword, unlockWithSystemAuth, verifyForLockScreen, } from "./vaultBinding"; beforeEach(() => { @@ -34,7 +38,9 @@ beforeEach(() => { h.bindResult = "ok"; h.openResult = "ok"; h.systemAuthUnlock = true; - h.verify.mockClear(); + h.systemAuthAvailable = true; + h.verify.mockReset(); + h.verify.mockResolvedValue("ok"); h.forgetOthers.mockClear(); h.invoke.mockReset(); h.invoke.mockImplementation(async (cmd: string, args: Record) => { @@ -181,3 +187,36 @@ test("a cancelled bind leaves other saved accounts alone", async () => { await bindNow("r"); expect(h.forgetOthers).not.toHaveBeenCalled(); }); + +test("reading the password of a plain secret asks for the presence check first", async () => { + h.store.master_password = "pw"; + expect(await readPasswordWithSystemAuth("r")).toEqual({ outcome: "ok", password: "pw" }); + expect(h.verify).toHaveBeenCalledWith("r"); +}); + +test("a declined presence check gives no password", async () => { + h.store.master_password = "pw"; + h.verify.mockResolvedValue("cancelled"); + expect(await readPasswordWithSystemAuth("r")).toEqual({ outcome: "cancelled", password: null }); +}); + +test("reading a sealed secret relies on its own prompt", async () => { + h.store.master_password_sealed = "S:pw"; + expect(await readPasswordWithSystemAuth("r")).toEqual({ outcome: "ok", password: "pw" }); + expect(h.verify).not.toHaveBeenCalled(); +}); + +test("no stored secret gives no password and no prompt", async () => { + expect(await readPasswordWithSystemAuth("r")).toEqual({ outcome: "declined", password: null }); + expect(h.verify).not.toHaveBeenCalled(); +}); + +test("the password is readable only behind a prompt the device can show", async () => { + expect(await canReadPasswordWithSystemAuth()).toBe(false); + h.store.master_password = "pw"; + expect(await canReadPasswordWithSystemAuth()).toBe(true); + h.systemAuthAvailable = false; + expect(await canReadPasswordWithSystemAuth()).toBe(false); + h.store.master_password_sealed = "S:pw"; + expect(await canReadPasswordWithSystemAuth()).toBe(true); +}); diff --git a/src/services/vaultBinding.ts b/src/services/vaultBinding.ts index 26b4c2dbb..7245a88a6 100644 --- a/src/services/vaultBinding.ts +++ b/src/services/vaultBinding.ts @@ -1,5 +1,5 @@ import { getAccountMode, isCurrentMasterPassword, openWithStoredSecret } from "@/services/account"; -import { systemAuthVerify } from "@/services/appLock"; +import { systemAuthAvailable, systemAuthVerify } from "@/services/appLock"; import { useSecurityStore } from "@/stores/securityStore"; import { canLockVault } from "@/utils/accountMode"; import { forgetOtherPlainPasswords } from "@/services/savedAccounts"; @@ -42,29 +42,47 @@ async function bindPlain(reason: string): Promise<{ status: UnlockOutcome; passw return status === "unavailable" ? null : { status, password }; } -export async function unlockWithSystemAuth(reason: string): Promise { +export async function canReadPasswordWithSystemAuth(): Promise { + const state = await secretState().catch(() => "none" as const); + return state === "sealed" || (state === "plain" && (await systemAuthAvailable())); +} + +/** The stored master password, given only once the OS confirms the user. */ +export async function readPasswordWithSystemAuth( + reason: string, +): Promise<{ outcome: UnlockOutcome; password: string | null }> { + const refused = (outcome: UnlockOutcome) => ({ outcome, password: null }); const state = await secretState().catch(() => "none" as const); + if (state === "none") return refused("declined"); if (state === "sealed") { const read = await readSecret(reason); - if (read.outcome !== "ok" || !read.value) return read.outcome === "none" ? "declined" : (read.outcome as UnlockOutcome); - const opened = await open(read.value); - if (opened === "wrong-key") { - // The blob holds a password this account no longer has (changed on another device). - await clearSecret().catch(() => {}); - return "invalidated"; - } - if (opened !== "ok") return "failed"; - if (!(await wantsBinding())) await importSecret({ kind: "plain", value: read.value }).catch(() => {}); - return "ok"; + if (read.outcome === "ok" && read.value) return { outcome: "ok", password: read.value }; + return refused(read.outcome === "none" || read.outcome === "ok" ? "declined" : read.outcome); } + const verified = await systemAuthVerify(reason); + if (verified !== "ok") return refused(verified); + const password = await readPlainSecret().catch(() => null); + return password ? { outcome: "ok", password } : refused("declined"); +} + +export async function unlockWithSystemAuth(reason: string): Promise { + const state = await secretState().catch(() => "none" as const); if (state === "plain" && (await wantsBinding())) { const bound = await bindPlain(reason); if (bound) return bound.status === "ok" ? unlocked(bound.password) : bound.status; } - const verified = await systemAuthVerify(reason); - if (verified !== "ok") return verified; - const password = await readPlainSecret().catch(() => null); - return password ? unlocked(password) : "declined"; + const { outcome, password } = await readPasswordWithSystemAuth(reason); + if (!password) return outcome; + if (state !== "sealed") return unlocked(password); + const opened = await open(password); + if (opened === "wrong-key") { + // The blob holds a password this account no longer has (changed on another device). + await clearSecret().catch(() => {}); + return "invalidated"; + } + if (opened !== "ok") return "failed"; + if (!(await wantsBinding())) await importSecret({ kind: "plain", value: password }).catch(() => {}); + return "ok"; } export async function verifyForLockScreen(reason: string): Promise {