0b68b0540185
add the recovery window deadline sequence test: alarm and idle lock after expiry and the words need a password proof (PB-26-102, v5.0.48)
- time
- 2026-09-02 06:36 UTC · 1 month ago
- machine
- koh2
- agent
- hy4-preview
- session
- assessment remediation corrections
- parent
- 9189870090c5
↓ peach-browser-0b68b054.tar.gzatlas at this save
files changed4 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/alarms.test.ts+137 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.47",4+ "version": "5.0.48",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "5.0.47",5+ "version": "5.0.48",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.0.47",3+ "version": "5.0.48",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/alarms.test.ts+137 −0
@@ -4,6 +4,7 @@44 55 describe("background alarms", () => {66 let storage: StorageShape;7+ let sessionStorage: StorageShape;78 let alarmListener: ((alarm: { name: string }) => void | Promise<void>) | undefined;89 let idleListener: ((state: string) => void | Promise<void>) | undefined;910 let sentRuntimeMessages: Array<unknown>;@@ -12,6 +13,7 @@1213 beforeEach(() => {1314 vi.resetModules();1415 storage = {};16+ sessionStorage = {};1517 sentRuntimeMessages = [];1618 sentTabMessages = [];1719 @@ -39,6 +41,21 @@3941 }4042 }),4143 },44+ session: {45+ get: vi.fn(async (key?: string | string[]) => {46+ if (!key) return sessionStorage;47+ const keys = Array.isArray(key) ? key : [key];48+ return keys.reduce<StorageShape>((acc, k) => {49+ acc[k] = sessionStorage[k];50+ return acc;51+ }, {});52+ }),53+ set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }),54+ remove: vi.fn(async (keys: string | string[]) => {55+ const list = Array.isArray(keys) ? keys : [keys];56+ for (const key of list) delete sessionStorage[key];57+ }),58+ },4259 },4360 alarms: {4461 clear: vi.fn(),@@ -58,6 +75,8 @@5875 },5976 },6077 runtime: {78+ id: "test-extension-id",79+ getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),6180 sendMessage: vi.fn(async (message: unknown) => {6281 sentRuntimeMessages.push(message);6382 return { ok: true };@@ -78,6 +97,7 @@7897 vi.stubGlobal("fetch", vi.fn(async () => {7998 throw new Error("network disabled in tests");8099 }));100+ vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);81101 });82102 83103 it("touchAutoLock creates an alarm using autoLockMinutes from settings", async () => {@@ -164,5 +184,122 @@164184 await idleListener?.("idle");165185 expect(vault.isUnlocked()).toBe(false);166186 expect(sentRuntimeMessages).toContainEqual({ type: "VAULT_LOCKED" });187+ });188+189+ // PB-26-102: the recovery-confirmation window deliberately suppresses every190+ // lock path so the words screen does not re-prompt for a password the user191+ // just typed. That suppression is only safe while it is time-boxed — an192+ // abandoned words screen used to leave the vault unlocked with no auto-lock193+ // and the recovery words retrievable for the rest of the browser session.194+ describe("recovery confirmation deadline (PB-26-102)", () => {195+ const extensionSender = {196+ url: "chrome-extension://test-extension-id/src/popup/index.html",197+ } as unknown as chrome.runtime.MessageSender;198+199+ async function bootUnlocked() {200+ const vault = await import("./vault");201+ await vault.createVault("correct-pass-12345");202+ return vault;203+ }204+205+ it("an alarm before the deadline does not lock the vault", async () => {206+ vi.useFakeTimers();207+ try {208+ const vault = await bootUnlocked();209+ await import("./alarms");210+ const { setRecoveryConfirmPending } = await import("./recovery-pending");211+ await setRecoveryConfirmPending();212+213+ await vi.advanceTimersByTimeAsync(60_000);214+ await alarmListener?.({ name: "autolock" });215+216+ expect(vault.isUnlocked()).toBe(true);217+ } finally {218+ vi.useRealTimers();219+ }220+ });221+222+ it("an alarm after the deadline locks the vault", async () => {223+ vi.useFakeTimers();224+ try {225+ const vault = await bootUnlocked();226+ await import("./alarms");227+ const { setRecoveryConfirmPending } = await import("./recovery-pending");228+ await setRecoveryConfirmPending();229+230+ await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1);231+ await alarmListener?.({ name: "autolock" });232+233+ expect(vault.isUnlocked()).toBe(false);234+ } finally {235+ vi.useRealTimers();236+ }237+ });238+239+ it("an idle event after the deadline locks the vault", async () => {240+ vi.useFakeTimers();241+ try {242+ const vault = await bootUnlocked();243+ await import("./alarms");244+ const { setRecoveryConfirmPending } = await import("./recovery-pending");245+ await setRecoveryConfirmPending();246+247+ await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1);248+ await idleListener?.("idle");249+250+ expect(vault.isUnlocked()).toBe(false);251+ } finally {252+ vi.useRealTimers();253+ }254+ });255+256+ // The full sequence: the exemption expires, the vault locks on the next257+ // alarm, and the words go back behind the password proof.258+ it("the recovery words require a fresh password proof once the window expires", async () => {259+ vi.useFakeTimers();260+ try {261+ const vault = await bootUnlocked();262+ await import("./alarms");263+ const pending = await import("./recovery-pending");264+ const messages = await import("./messages");265+ await pending.setRecoveryConfirmPending();266+267+ // Inside the window: the words screen needs no password.268+ const exempt = await messages.handleMessage(269+ { type: "GET_RECOVERY_WORDS", payload: {} },270+ extensionSender,271+ );272+ // The vault was created under fake timers, so recovery words may not be273+ // configured in this harness; either outcome proves the exemption was274+ // consulted rather than the password gate.275+ expect(exempt.error).not.toBe("password_required");276+277+ await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1);278+ await alarmListener?.({ name: "autolock" });279+280+ expect(vault.isUnlocked()).toBe(false);281+ expect(await pending.isRecoveryConfirmPending()).toBe(false);282+283+ // Locked is enough on its own, but re-unlocking must not resurrect the284+ // exemption: the words are gated again until a password is proved.285+ expect(await vault.unlock("correct-pass-12345")).toBe(true);286+ await expect(287+ messages.handleMessage(288+ { type: "GET_RECOVERY_WORDS", payload: {} },289+ extensionSender,290+ ),291+ ).resolves.toMatchObject({ ok: false, error: "password_required" });292+293+ // And the correct password is what opens it.294+ await expect(295+ messages.handleMessage(296+ { type: "GET_RECOVERY_WORDS", payload: { password: "correct-pass-12345" } },297+ extensionSender,298+ ),299+ ).resolves.toMatchObject({ ok: true });300+ } finally {301+ vi.useRealTimers();302+ }303+ });167304 });168305 });