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
4 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/alarms.test.ts+137 −0
manifest.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": {

view file

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",

view file

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",

view file

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 });

view file