Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/components/settings/sections/ChangeMasterPasswordModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/utils/platform")>("@/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(<ChangeMasterPasswordModal onClose={() => {}} />);
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();
});
69 changes: 62 additions & 7 deletions src/components/settings/sections/ChangeMasterPasswordModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,6 +31,37 @@ export default function ChangeMasterPasswordModal({ twoFactorEnabled, onClose }:
const [done, setDone] = useState(false);
const [methods, setMethods] = useState<TwoFactorMethod[]>(["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;
Expand Down Expand Up @@ -92,13 +127,33 @@ export default function ChangeMasterPasswordModal({ twoFactorEnabled, onClose }:
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-2">
<SettingsInput
type="password"
placeholder={t("settings.account.changeMasterPassword.currentPlaceholder")}
value={currentPassword}
onChange={setCurrentPassword}
autoFocus
/>
{deviceVerified ? (
<div className="flex items-center gap-2 px-1 py-2 text-(--t-status-connected)">
<Icon icon="lucide:shield-check" width={14} />
<p className="text-xs font-medium">{t("settings.account.changeMasterPassword.verifiedWithDevice")}</p>
</div>
) : (
<>
<SettingsInput
type="password"
placeholder={t("settings.account.changeMasterPassword.currentPlaceholder")}
value={currentPassword}
onChange={setCurrentPassword}
autoFocus
/>
{deviceAvailable && (
<button
type="button"
onClick={() => void verifyWithDevice()}
disabled={verifying}
className="flex items-center gap-1.5 px-1 text-xs text-(--t-accent) hover:underline disabled:opacity-50"
>
<Icon icon="lucide:fingerprint" width={13} />
{t("settings.account.changeMasterPassword.verifyWithDevice", { method: t(systemAuthMethodKey(platform)) })}
</button>
)}
</>
)}
<SettingsInput
type="password"
placeholder={t("settings.account.changeMasterPassword.newPlaceholder")}
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/cs/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,11 @@
"sub": "Změní heslo bez opětovného šifrování trezoru",
"title": "Změnit hlavní heslo",
"currentPlaceholder": "Aktuální heslo",
"verifyWithDevice": "Zapomněli jste ho? Potvrďte to raději: {{method}}",
"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é",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,11 @@
"sub": "Update your password without re-encrypting your vault",
"title": "Change master password",
"currentPlaceholder": "Current password",
"verifyWithDevice": "Forgot it? Confirm with {{method}} instead",
"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",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/fr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,11 @@
"sub": "Mettez à jour votre mot de passe sans rechiffrer votre coffre-fort",
"title": "Changer le mot de passe maître",
"currentPlaceholder": "Mot de passe actuel",
"verifyWithDevice": "Oublié ? Confirmez plutôt avec {{method}}",
"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",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/ru/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,11 @@
"sub": "Обновите пароль без повторного шифрования хранилища",
"title": "Изменить мастер-пароль",
"currentPlaceholder": "Текущий пароль",
"verifyWithDevice": "Забыли? Подтвердите вместо этого: {{method}}",
"verifiedWithDevice": "Подтверждено на этом устройстве",
"deviceReason": "Подтвердите смену мастер-пароля",
"deviceFailed": "Это устройство не смогло подтвердить вашу личность. Введите текущий пароль.",
"deviceSignedOut": "Это устройство вышло из учётной записи, вероятно, после смены пароля на другом устройстве. Войдите снова с новым паролем.",
"newPlaceholder": "Новый пароль (мин. 8 символов)",
"confirmPlaceholder": "Подтвердите новый пароль",
"errorCurrentRequired": "Требуется текущий пароль",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/tr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,11 @@
"sub": "Kasanızı yeniden şifrelemeden parolanızı güncelleyin",
"title": "Ana parolayı değiştir",
"currentPlaceholder": "Mevcut parola",
"verifyWithDevice": "Unuttunuz mu? Bunun yerine {{method}} ile onaylayın",
"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",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/zh/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@
"sub": "更新密码而无需重新加密保险库",
"title": "更改主密码",
"currentPlaceholder": "当前密码",
"verifyWithDevice": "忘记了?改用{{method}}确认",
"verifiedWithDevice": "已在此设备上确认",
"deviceReason": "确认以更改主密码",
"deviceFailed": "此设备无法确认您的身份。请改为输入当前密码。",
"deviceSignedOut": "此设备已退出登录,可能是因为在其他设备上更改了密码。请使用新密码重新登录。",
"newPlaceholder": "新密码(至少 8 个字符)",
"confirmPlaceholder": "确认新密码",
"errorCurrentRequired": "需要当前密码",
Expand Down
47 changes: 43 additions & 4 deletions src/services/vaultBinding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const h = vi.hoisted(() => ({
bindResult: "ok",
openResult: "ok",
verify: vi.fn(async () => "ok"),
systemAuthAvailable: true,
systemAuthUnlock: true,
forgetOthers: vi.fn(async () => undefined),
}));
Expand All @@ -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(() => {
Expand All @@ -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<string, unknown>) => {
Expand Down Expand Up @@ -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);
});
Loading
Loading