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
↓ peach-browser-0af21be3.tar.gzatlas at this save
files changed12 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 −0manifest.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": {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",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'",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",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",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 });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);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 () => {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 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 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"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+});