From 651426a54f815dd3e9a25cbc9be9886c08906e3a Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 14:22:00 +0000 Subject: [PATCH 1/2] feat(account): change the master password with device verification (#629) A signed-in device keeps the master password in the keychain, so after the OS confirms the user (Windows Hello, Touch ID, polkit, or the sealed secret's own prompt on Android) it can stand in for the current password. The server still checks the old auth key and the second factor. --- .../ChangeMasterPasswordModal.test.tsx | 87 +++++++++++++++++++ .../sections/ChangeMasterPasswordModal.tsx | 62 +++++++++++-- src/i18n/locales/cs/settings.json | 4 + src/i18n/locales/en/settings.json | 4 + src/i18n/locales/fr/settings.json | 4 + src/i18n/locales/ru/settings.json | 4 + src/i18n/locales/tr/settings.json | 4 + src/i18n/locales/zh/settings.json | 4 + src/services/vaultBinding.test.ts | 47 +++++++++- src/services/vaultBinding.ts | 50 +++++++---- 10 files changed, 243 insertions(+), 27 deletions(-) create mode 100644 src/components/settings/sections/ChangeMasterPasswordModal.test.tsx diff --git a/src/components/settings/sections/ChangeMasterPasswordModal.test.tsx b/src/components/settings/sections/ChangeMasterPasswordModal.test.tsx new file mode 100644 index 000000000..9d99ae9e4 --- /dev/null +++ b/src/components/settings/sections/ChangeMasterPasswordModal.test.tsx @@ -0,0 +1,87 @@ +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) => {}), +})); + +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/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(); +}); +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(); +}); diff --git a/src/components/settings/sections/ChangeMasterPasswordModal.tsx b/src/components/settings/sections/ChangeMasterPasswordModal.tsx index 85568ff1e..48c735883 100644 --- a/src/components/settings/sections/ChangeMasterPasswordModal.tsx +++ b/src/components/settings/sections/ChangeMasterPasswordModal.tsx @@ -2,6 +2,9 @@ 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 { 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 +30,31 @@ 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(""); + 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 +120,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 { From ebf411ca45b86e8ed7fc41c5176a7d1c04119374 Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 17:44:55 +0000 Subject: [PATCH 2/2] fix(account): catch a session revoked elsewhere before the device check A password changed on another device revokes this device's refresh token but its access token stays valid for up to an hour, so the device option read the stored, now stale password and the server answered "current password incorrect". Refreshing first catches the revocation before any system prompt and points the user to sign in again. --- .../ChangeMasterPasswordModal.test.tsx | 22 +++++++++++++++++++ .../sections/ChangeMasterPasswordModal.tsx | 7 ++++++ src/i18n/locales/cs/settings.json | 1 + src/i18n/locales/en/settings.json | 1 + src/i18n/locales/fr/settings.json | 1 + src/i18n/locales/ru/settings.json | 1 + src/i18n/locales/tr/settings.json | 1 + src/i18n/locales/zh/settings.json | 1 + 8 files changed, 35 insertions(+) diff --git a/src/components/settings/sections/ChangeMasterPasswordModal.test.tsx b/src/components/settings/sections/ChangeMasterPasswordModal.test.tsx index 9d99ae9e4..a5273b615 100644 --- a/src/components/settings/sections/ChangeMasterPasswordModal.test.tsx +++ b/src/components/settings/sections/ChangeMasterPasswordModal.test.tsx @@ -5,6 +5,7 @@ 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", () => ({ @@ -17,6 +18,7 @@ vi.mock("@/utils/platform", async () => ({ 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, @@ -36,6 +38,8 @@ beforeEach(() => { h.read.mockReset(); h.read.mockResolvedValue({ outcome: "ok", password: "stored-pw" }); h.change.mockReset(); + h.refresh.mockReset(); + h.refresh.mockResolvedValue({ jwt: "J" }); }); afterEach(cleanup); @@ -85,3 +89,21 @@ test("no device verification is offered without a prompt to show", async () => { 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 48c735883..3527f0abd 100644 --- a/src/components/settings/sections/ChangeMasterPasswordModal.tsx +++ b/src/components/settings/sections/ChangeMasterPasswordModal.tsx @@ -3,6 +3,7 @@ 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"; @@ -46,6 +47,12 @@ export default function ChangeMasterPasswordModal({ twoFactorEnabled, onClose }: 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) { diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index 9d3cce0dc..c00949e2c 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -348,6 +348,7 @@ "verifiedWithDevice": "Potvrzeno na tomto zařízení", "deviceReason": "Potvrďte změnu hlavního hesla", "deviceFailed": "Toto zařízení nemohlo potvrdit vaši totožnost. Zadejte raději aktuální heslo.", + "deviceSignedOut": "Toto zařízení bylo odhlášeno, nejspíš po změně hesla na jiném zařízení. Přihlaste se znovu novým heslem.", "newPlaceholder": "Nové heslo (min. 8 znaků)", "confirmPlaceholder": "Potvrďte nové heslo", "errorCurrentRequired": "Aktuální heslo je povinné", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 5d14ef70d..778a6ea16 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -348,6 +348,7 @@ "verifiedWithDevice": "Confirmed on this device", "deviceReason": "Confirm to change your master password", "deviceFailed": "This device could not confirm it's you. Type your current password instead.", + "deviceSignedOut": "This device was signed out, likely after a password change on another device. Sign in again with the new password.", "newPlaceholder": "New password (min 8 chars)", "confirmPlaceholder": "Confirm new password", "errorCurrentRequired": "Current password is required", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 2009de486..9d8312502 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -348,6 +348,7 @@ "verifiedWithDevice": "Confirmé sur cet appareil", "deviceReason": "Confirmez pour changer votre mot de passe maître", "deviceFailed": "Cet appareil n'a pas pu confirmer votre identité. Saisissez plutôt votre mot de passe actuel.", + "deviceSignedOut": "Cet appareil a été déconnecté, sans doute après un changement de mot de passe sur un autre appareil. Reconnectez-vous avec le nouveau mot de passe.", "newPlaceholder": "Nouveau mot de passe (min 8 caractères)", "confirmPlaceholder": "Confirmer le nouveau mot de passe", "errorCurrentRequired": "Le mot de passe actuel est requis", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 791db4e3f..5ffbfb271 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -348,6 +348,7 @@ "verifiedWithDevice": "Подтверждено на этом устройстве", "deviceReason": "Подтвердите смену мастер-пароля", "deviceFailed": "Это устройство не смогло подтвердить вашу личность. Введите текущий пароль.", + "deviceSignedOut": "Это устройство вышло из учётной записи, вероятно, после смены пароля на другом устройстве. Войдите снова с новым паролем.", "newPlaceholder": "Новый пароль (мин. 8 символов)", "confirmPlaceholder": "Подтвердите новый пароль", "errorCurrentRequired": "Требуется текущий пароль", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 9cd31cde4..a21e78ef7 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -354,6 +354,7 @@ "verifiedWithDevice": "Bu cihazda onaylandı", "deviceReason": "Ana parolanızı değiştirmek için onaylayın", "deviceFailed": "Bu cihaz kimliğinizi doğrulayamadı. Bunun yerine mevcut parolanızı girin.", + "deviceSignedOut": "Bu cihazın oturumu kapatıldı, büyük olasılıkla parola başka bir cihazda değiştirildi. Yeni parolayla yeniden oturum açın.", "newPlaceholder": "Yeni parola (en az 8 karakter)", "confirmPlaceholder": "Yeni parolayı doğrula", "errorCurrentRequired": "Mevcut parola gereklidir", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index d71b41c80..098070c8a 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -242,6 +242,7 @@ "verifiedWithDevice": "已在此设备上确认", "deviceReason": "确认以更改主密码", "deviceFailed": "此设备无法确认您的身份。请改为输入当前密码。", + "deviceSignedOut": "此设备已退出登录,可能是因为在其他设备上更改了密码。请使用新密码重新登录。", "newPlaceholder": "新密码(至少 8 个字符)", "confirmPlaceholder": "确认新密码", "errorCurrentRequired": "需要当前密码",