0af21be3f7f9

fix: make PIN unlock session-scoped with attempt limits (v5.0.22)

time
2026-08-30 11:52 UTC · 1 month ago
machine
koh2
parent
34ea8d9a31d0
12 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+2 −2src/background/message-policy.test.ts+1 −1src/background/message-policy.ts+1 −0src/background/messages.test.ts+18 −0src/background/messages.ts+14 −1src/background/vault.test.ts+62 −2src/background/vault.ts+86 −7src/popup/views/Lock.tsx+20 −5src/shared/types.ts+1 −0tests/pb26016-sender-context.spec.ts+41 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.20",4+  "version": "5.0.22",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.20",5+  "version": "5.0.22",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+2 −2
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.0.20",3+  "version": "5.0.22",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",@@ -18,7 +18,7 @@1818     "typecheck": "tsc --noEmit",1919     "test": "vitest run",2020     "test:watch": "vitest",21-    "test:e2e": "npm run build:chrome && playwright test tests/vault-lifecycle.spec.ts tests/entry-crud.spec.ts tests/search.spec.ts tests/settings.spec.ts tests/lock-screen.spec.ts tests/vault-security.spec.ts tests/sync-alerts.spec.ts",21+    "test:e2e": "npm run build:chrome && playwright test tests/vault-lifecycle.spec.ts tests/entry-crud.spec.ts tests/search.spec.ts tests/settings.spec.ts tests/lock-screen.spec.ts tests/vault-security.spec.ts tests/sync-alerts.spec.ts tests/pb26016-sender-context.spec.ts",2222     "test:passkeys": "npm run build:e2e && playwright test tests/passkeys.spec.ts",2323     "ci": "bun run scripts/ci.ts",2424     "package:source": "zip -r peach-source.zip src scripts public manifest.json manifest.firefox.json package.json bun.lock tsconfig.json vite.config.ts --exclude 'node_modules/*' --exclude 'dist-*/*' --exclude '*.zip'",

view file

src/background/message-policy.test.ts+1 −1
@@ -13,7 +13,7 @@1313     const declaredTypes: MessageType[] = [1414       "GET_SYNC_ALERTS", "CLEAR_CLIPBOARD", "GET_PENDING_VOUCHED_DEVICES", "CONFIRM_VOUCHED_DEVICE",1515       "REJECT_VOUCHED_DEVICE", "VOUCHED_DEVICE_PENDING", "ROTATE_SYNC_IDENTITY", "DEVICE_KEY_ROTATED",16-      "UNLOCK", "UNLOCK_WITH_PIN", "LOCK", "IS_UNLOCKED", "HAS_VAULT", "GET_ENTRIES", "ADD_ENTRY",16+      "UNLOCK", "UNLOCK_WITH_PIN", "GET_PIN_STATUS", "LOCK", "IS_UNLOCKED", "HAS_VAULT", "GET_ENTRIES", "ADD_ENTRY",1717       "BATCH_ADD_ENTRIES", "RESTORE_ENTRIES", "CLEAR_VAULT_ENTRIES", "UPDATE_ENTRY", "ADD_ADDITIONAL_URL",1818       "DELETE_ENTRY", "AUTOFILL_QUERY", "COPY", "CREATE_VAULT", "CREATE_VAULT_FROM_ROOT_KEY",1919       "GET_ENTRY_FOR_FILL", "GET_ENTRY_FOR_EDIT", "CHECK_ALL_BREACHES", "GET_SETTINGS", "UPDATE_SETTINGS",

view file

src/background/message-policy.ts+1 −0
@@ -25,6 +25,7 @@2525   ROTATE_SYNC_IDENTITY: "extension-page-only",2626   UNLOCK: "extension-page-only",2727   UNLOCK_WITH_PIN: "extension-page-only",28+  GET_PIN_STATUS: "extension-page-only",2829   LOCK: "extension-page-only",2930   IS_UNLOCKED: "extension-page-only",3031   HAS_VAULT: "extension-page-only",

view file

src/background/messages.test.ts+18 −0
@@ -1386,4 +1386,22 @@13861386     expect(res.ok).toBe(true);13871387     expect((res.data as string[]).length).toBe(12);13881388   });1389+1390+  it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1391+    const vault = await import("./vault");1392+    const password = "correct-pass-12345";1393+    const popupSender = {1394+      id: "peach-extension",1395+      url: "chrome-extension://peach-extension/src/popup/index.html",1396+    } as chrome.runtime.MessageSender;1397+1398+    await vault.createVault(password);1399+    await vault.setupPinUnlock(password, "1234");1400+    await vault.lock();1401+1402+    await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1403+      ok: true,1404+      data: { configured: true, available: true, attemptsRemaining: 3 },1405+    });1406+  });13891407 });

view file

src/background/messages.ts+14 −1
@@ -327,7 +327,18 @@327327         const { pin } = (msg.payload ?? {}) as { pin?: unknown };328328         if (typeof pin !== "string") return { ok: false, error: "invalid_payload" };329329         const ok = await vault.unlockWithPin(pin);330-        if (!ok) return { ok: false };330+        if (!ok) {331+          const status = await vault.getPinUnlockStatus();332+          if (status.configured && !status.available) {333+            return {334+              ok: false,335+              error: status.attemptsRemaining === 0336+                ? "pin_attempts_exceeded"337+                : "pin_session_expired",338+            };339+          }340+          return { ok: false };341+        }331342         const kat = await runSelfTest();332343         if (!kat.ok) {333344           await vault.lock();@@ -339,6 +350,8 @@339350         onVaultUnlocked();340351         return { ok: true };341352       }353+      case "GET_PIN_STATUS":354+        return { ok: true, data: await vault.getPinUnlockStatus() };342355       case "VALIDATE_RECOVERY_WORDS": {343356         const { words } = msg.payload as { words: string[] };344357         const invalid = findInvalidWords(words);

view file

src/background/vault.test.ts+62 −2
@@ -7,11 +7,13 @@77 88 describe("background vault", () => {99   let storage: StorageShape;10+  let sessionStorage: StorageShape;1011   let alarms = { created: [] as Array<{ name: string; delayInMinutes: number }>, cleared: [] as string[] };1112 1213   beforeEach(() => {1314     vi.resetModules();1415     storage = {};16+    sessionStorage = {};1517     alarms = { created: [], cleared: [] };1618 1719     const chromeMock = {@@ -38,6 +40,26 @@3840             }3941           }),4042         },43+        session: {44+          get: vi.fn(async (key?: string | string[]) => {45+            if (!key) return sessionStorage;46+            if (typeof key === "string") return { [key]: sessionStorage[key] };47+            return key.reduce<Record<string, unknown>>((result, entry) => {48+              result[entry] = sessionStorage[entry];49+              return result;50+            }, {});51+          }),52+          set: vi.fn(async (next: Record<string, unknown>) => {53+            Object.assign(sessionStorage, next);54+          }),55+          remove: vi.fn(async (keys: string | string[]) => {56+            const list = Array.isArray(keys) ? keys : [keys];57+            for (const key of list) delete sessionStorage[key];58+          }),59+          clear: vi.fn(async () => {60+            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];61+          }),62+        },4163       },4264       alarms: {4365         clear: vi.fn((name: string) => {@@ -87,7 +109,7 @@87109     expect(storage.peach_decoy_vault).toBeUndefined();88110   });89111 90-  it("wraps the derived vault key for PIN unlock and invalidates it on password change", async () => {112+  it("keeps the PIN wrapper in session storage and invalidates it on password change", async () => {91113     const vault = await import("./vault");92114     const password = "correct horse battery staple";93115 @@ -95,7 +117,8 @@95117     expect(await vault.setupPinUnlock(password, "123")).toBe(false);96118     expect(await vault.setupPinUnlock(password, "1234567890123")).toBe(false);97119     expect(await vault.setupPinUnlock(password, "1234")).toBe(true);98-    expect((storage.peach_pin_wrap as { ciphertext: string }).ciphertext).toBeTruthy();120+    expect((sessionStorage.peach_pin_wrap as { ciphertext: string }).ciphertext).toBeTruthy();121+    expect(storage.peach_pin_wrap).toBeUndefined();99122 100123     vault.lock();101124     expect(await vault.unlockWithPin("000000")).toBe(false);@@ -106,6 +129,43 @@106129     vault.lock();107130     expect(await vault.unlockWithPin("1234")).toBe(false);108131     expect(await vault.unlock("new correct horse battery")).toBe(true);132+  });133+134+  it("requires the master password after three incorrect PIN attempts", async () => {135+    const vault = await import("./vault");136+    const password = "correct horse battery staple";137+138+    await vault.createVault(password);139+    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);140+    vault.lock();141+142+    expect(await vault.unlockWithPin("0000")).toBe(false);143+    expect(await vault.unlockWithPin("0000")).toBe(false);144+    expect(await vault.unlockWithPin("0000")).toBe(false);145+    expect(await vault.unlockWithPin("1234")).toBe(false);146+    expect(await vault.unlock(password)).toBe(true);147+148+    vault.lock();149+    expect(await vault.unlockWithPin("1234")).toBe(true);150+  });151+152+  it("cannot unlock from a copied local-storage snapshot after session storage is cleared", async () => {153+    const vault = await import("./vault");154+    const password = "correct horse battery staple";155+156+    await vault.createVault(password);157+    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);158+    const copiedLocalStorage = { ...storage };159+    await (chrome.storage.session as typeof chrome.storage.session).clear();160+    vault.lock();161+162+    expect(copiedLocalStorage.peach_pin_wrap).toBeUndefined();163+    await expect(vault.unlockWithPin("1234")).resolves.toBe(false);164+    await expect(vault.getPinUnlockStatus()).resolves.toMatchObject({165+      configured: true,166+      available: false,167+      attemptsRemaining: 0,168+    });109169   });110170 111171   it("slot A and slot B have matching ciphertext size at fresh install", async () => {

view file

src/background/vault.ts+86 −7
@@ -156,10 +156,15 @@156156 }157157 158158 const PIN_WRAP_STORAGE = "peach_pin_wrap";159+const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";159160 const PIN_MIN_LENGTH = 4;160161 const PIN_MAX_LENGTH = 12;162+const PIN_MAX_ATTEMPTS = 3;161163 162164 let settings: VaultSettings = {};165+let inMemoryPinWrap: StoredPinWrap | null = null;166+let inMemoryPinFailedAttempts = 0;167+let sessionPinStorageFailed = false;163168 164169 export function getSettings(): VaultSettings {165170   return settings;@@ -198,9 +203,7 @@198203   const pinKey = await deriveKey(pin, salt);199204   try {200205     const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");201-    await chrome.storage.local.set({202-      [PIN_WRAP_STORAGE]: { version: 1, salt: hex(salt), ...wrapped } satisfies StoredPinWrap,203-    });206+    await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });204207     await updateSettings({ pinEnabled: true });205208     return true;206209   } finally {@@ -209,7 +212,12 @@209212 }210213 211214 async function clearPinUnlockStorage(): Promise<void> {212-  await chrome.storage.local.remove(PIN_WRAP_STORAGE);215+  inMemoryPinWrap = null;216+  inMemoryPinFailedAttempts = 0;217+  sessionPinStorageFailed = false;218+  if (chrome.storage.session) {219+    await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);220+  }213221   await updateSettings({ pinEnabled: false });214222 }215223 @@ -227,9 +235,8 @@227235   await loadSettings();228236   if (!settings.pinEnabled) return false;229237 230-  const stored = (await chrome.storage.local.get(PIN_WRAP_STORAGE))[PIN_WRAP_STORAGE] as231-    | StoredPinWrap232-    | undefined;238+  const { stored, failedAttempts } = await loadPinState();239+  if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;233240   if (234241     !stored ||235242     stored.version !== 1 ||@@ -254,8 +261,10 @@254261     const result = await unlockWithKey(masterKeyHex);255262     if (!result.ok) return false;256263     await saveSessionKey(unwrappedKey, "real");264+    await resetPinFailedAttempts();257265     return true;258266   } catch {267+    await recordPinFailure(failedAttempts + 1);259268     return false;260269   } finally {261270     zeroKey(pinKey);@@ -263,6 +272,75 @@263272   }264273 }265274 275+async function savePinWrap(stored: StoredPinWrap): Promise<void> {276+  inMemoryPinWrap = stored;277+  inMemoryPinFailedAttempts = 0;278+  sessionPinStorageFailed = !chrome.storage.session;279+  if (!chrome.storage.session) return;280+  try {281+    await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });282+    sessionPinStorageFailed = false;283+  } catch {284+    // Firefox fallback: keep the wrapper only in this background context.285+    sessionPinStorageFailed = true;286+  }287+}288+289+async function loadPinState(): Promise<{290+  stored: StoredPinWrap | undefined;291+  failedAttempts: number;292+}> {293+  if (!chrome.storage.session) {294+    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };295+  }296+  try {297+    const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);298+    return {299+      stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??300+        (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),301+      failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"302+        ? result[PIN_ATTEMPTS_STORAGE]303+        : 0,304+    };305+  } catch {306+    return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };307+  }308+}309+310+async function recordPinFailure(failedAttempts: number): Promise<void> {311+  inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);312+  if (!chrome.storage.session) return;313+  try {314+    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });315+  } catch { /* in-memory counter remains the fallback */ }316+}317+318+async function resetPinFailedAttempts(): Promise<void> {319+  inMemoryPinFailedAttempts = 0;320+  if (!chrome.storage.session) return;321+  try {322+    await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });323+  } catch { /* in-memory counter remains the fallback */ }324+}325+326+export async function getPinUnlockStatus(): Promise<{327+  configured: boolean;328+  available: boolean;329+  attemptsRemaining: number;330+}> {331+  await loadSettings();332+  if (!settings.pinEnabled) {333+    return { configured: false, available: false, attemptsRemaining: 0 };334+  }335+  const { stored, failedAttempts } = await loadPinState();336+  const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;337+  return {338+    configured: true,339+    available,340+    attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,341+  };342+}343+266344 async function persistSettings(): Promise<void> {267345   try {268346     await chrome.storage.local.set({ peach_settings: settings });@@ -1326,6 +1404,7 @@13261404     await lockedSyncUnlockHook?.().catch((error) => {13271405       console.warn("[Peach sync] locked inbox drain failed:", error);13281406     });1407+    await resetPinFailedAttempts();13291408     return true;13301409   }13311410 

view file

src/popup/views/Lock.tsx+20 −5
@@ -171,7 +171,15 @@171171       setLoading(false);172172       return;173173     }174-    setError(getDisplayError(result.error, "incorrect PIN"));174+    if (result.error === "pin_attempts_exceeded") {175+      setUsingPin(false);176+      setError("too many incorrect PIN attempts — use your master password");177+    } else if (result.error === "pin_session_expired") {178+      setUsingPin(false);179+      setError("PIN is unavailable until you unlock with your master password");180+    } else {181+      setError(getDisplayError(result.error, "incorrect PIN"));182+    }175183     setPin("");176184     const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined;177185     pinField?.classList.add("shake");@@ -182,11 +190,18 @@182190   async function checkPinAvailable(): Promise<void> {183191     if (!props.hasVault) return;184192     try {185-      const response = await sendMessage({ type: "GET_SETTINGS" });186-      const settings = (response as { ok: boolean; data?: { pinEnabled?: boolean; pinPreferred?: boolean } }).data;187-      const enabled = settings?.pinEnabled === true;193+      const response = await sendMessage({ type: "GET_PIN_STATUS" });194+      const status = (response as {195+        ok: boolean;196+        data?: { configured?: boolean; available?: boolean; attemptsRemaining?: number; pinPreferred?: boolean };197+      }).data;198+      const enabled = status?.configured === true && status.available === true;188199       setPinAvailable(enabled);189-      if (enabled && settings?.pinPreferred === true) setUsingPin(true);200+      if (enabled) {201+        const settingsResponse = await sendMessage({ type: "GET_SETTINGS" });202+        const settings = (settingsResponse as { ok: boolean; data?: { pinPreferred?: boolean } }).data;203+        if (settings?.pinPreferred === true) setUsingPin(true);204+      }190205     } catch { /* settings are optional at the lock screen */ }191206   }192207 

view file

src/shared/types.ts+1 −0
@@ -204,6 +204,7 @@204204   | "DEVICE_KEY_ROTATED"205205   | "UNLOCK"206206   | "UNLOCK_WITH_PIN"207+  | "GET_PIN_STATUS"207208   | "LOCK"208209   | "IS_UNLOCKED"209210   | "HAS_VAULT"

view file

tests/pb26016-sender-context.spec.ts+41 −0
@@ -0,0 +1,41 @@1+import { test, expect } from "@playwright/test";2+3+import { loadPopup, makeContext } from "./helpers/vault";4+5+test("PB-26-016: real content-script sender cannot read local storage or bulk entries", async () => {6+  const context = await makeContext("pb26016-sender");7+  try {8+    const page = await context.newPage();9+    await page.goto("https://example.com/");10+    const { popup } = await loadPopup(context);11+12+    const result = await popup.evaluate(async () => {13+      const tabs = await chrome.tabs.query({ url: "https://example.com/*" });14+      const tab = tabs.find((candidate) => candidate.id !== undefined);15+      if (tab?.id === undefined) throw new Error("test page tab was not found");16+17+      const executions = await chrome.scripting.executeScript({18+        target: { tabId: tab.id },19+        func: async () => {20+          let localStorageReadable = true;21+          try {22+            await chrome.storage.local.get("peach_recovery_pending");23+          } catch {24+            localStorageReadable = false;25+          }26+          const response = await chrome.runtime.sendMessage({ type: "GET_ENTRIES" });27+          return { localStorageReadable, response };28+        },29+      });30+      return executions[0]?.result as {31+        localStorageReadable: boolean;32+        response: { ok?: boolean; error?: string };33+      } | undefined;34+    });35+36+    expect(result?.localStorageReadable).toBe(false);37+    expect(result?.response).toEqual({ ok: false, error: "invalid_sender" });38+  } finally {39+    await context.close();40+  }41+});

view file