6dfff89fba08

harden browser-local origin, passkey, import, export, TOTP, and metadata boundaries (PB-26-106–114, v5.0.38)

time
2026-09-01 22:02 UTC · 1 month ago
machine
koh2
agent
claude-opus-5
session
2026-09-01 security assessment remediation
parent
f557c2f0ee93
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+29 −3src/background/passkey.ts+19 −4src/background/vault.ts+41 −3src/popup/App.tsx+6 −1src/popup/views/Entry.tsx+6 −1src/popup/views/Export.tsx+9 −1src/popup/views/Import.tsx+5 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.37",4+  "version": "5.0.38",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.37",5+  "version": "5.0.38",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.37",3+  "version": "5.0.38",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/messages.ts+29 −3
@@ -207,6 +207,22 @@207207   return undefined;208208 }209209 210+function entryAllowsSender(entry: VaultEntry, sender: Partial<chrome.runtime.MessageSender>): boolean {211+  if (sender.url?.startsWith("chrome-extension://") || sender.url?.startsWith("moz-extension://")) return true;212+  if (sender.tab === undefined && sender.url === undefined) return false;213+  const origin = attestedSenderOrigin(sender);214+  if (!origin) return false;215+  let hostname: string;216+  try { hostname = new URL(origin).hostname; } catch { return false; }217+  const domains = [entry.url, ...(entry.additionalUrls ?? [])]218+    .map((url) => extractDomain(url ?? ""))219+    .filter((domain): domain is string => Boolean(domain));220+  return domains.includes(extractDomain(hostname) ?? "");221+}222+223+function patchChangesEntryOrigin(patch: object): boolean {224+  return Object.hasOwn(patch, "url") || Object.hasOwn(patch, "additionalUrls");225+}210226 // Resolves once restoreFromSession() has run, so handlers see the correct211227 // post-restore unlock state. entry.ts registers this BEFORE the message212228 // handler so the onMessage listener is added synchronously at SW startup@@ -516,6 +532,11 @@516532         if (typeof id !== "string" || !id || !patch || typeof patch !== "object" || Array.isArray(patch)) {517533           return { ok: false, error: "invalid_payload" };518534         }535+        const existingEntry = vault.getEntryById(id);536+        if (!existingEntry) return { ok: false, error: "entry_not_found" };537+        if ((patchChangesEntryOrigin(patch) || sender.tab !== undefined || sender.url !== undefined) && !entryAllowsSender(existingEntry, sender)) {538+          return { ok: false, error: "origin_binding_required" };539+        }519540         // Single enforcement point for the Canary Entries Pro gate. Only520541         // gates the transition into a canary entry — clearing/disabling a521542         // canary (`canary: undefined`) is always allowed, so existing@@ -537,6 +558,7 @@537558         if (!trimmed) return { ok: false, error: "url_required" };538559         const existing = vault.getEntryById(id);539560         if (!existing) return { ok: false, error: "entry_not_found" };561+        if (!entryAllowsSender(existing, sender)) return { ok: false, error: "origin_binding_required" };540562         if (existing.type !== "login") return { ok: false, error: "login_entries_only" };541563         const merged = [...(existing.additionalUrls ?? []), trimmed];542564         await vault.updateEntry(id, { additionalUrls: merged });@@ -1221,14 +1243,16 @@12211243 12221244       case "PASSKEY_OFFER_DECLINE": {12231245         const { domain } = (msg.payload ?? {}) as { domain?: string };1224-        if (!domain) return { ok: false, error: "domain_required" };1246+        const origin = attestedSenderOrigin(sender);1247+        if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };12251248         await recordPasskeyOfferDecline(domain);12261249         return { ok: true };12271250       }12281251 12291252       case "GET_PASSKEY_OFFER_DECLINED": {12301253         const { domain } = (msg.payload ?? {}) as { domain?: string };1231-        if (!domain) return { ok: false, error: "domain_required" };1254+        const origin = attestedSenderOrigin(sender);1255+        if (!domain || !origin || !isValidRpId(domain, origin)) return { ok: false, error: "invalid_domain" };12321256         return { ok: true, data: await isPasskeyOfferDeclined(domain) };12331257       }12341258 @@ -1632,7 +1656,9 @@16321656     type !== "GET_PENDING_PASSKEY" &&16331657     type !== "GET_PASSKEY_PROVIDER_STATE" &&16341658     type !== "DECLINE_CARD_SAVE" &&1635-    type !== "IS_CARD_DECLINED"1659+    type !== "IS_CARD_DECLINED" &&1660+    type !== "AUTOFILL_QUERY" &&1661+    type !== "ML_PREDICT_BATCH"16361662   );16371663 }16381664 

view file

src/background/passkey.ts+19 −4
@@ -930,18 +930,33 @@930930   return { ok: true };931931 }932932 933+const MAX_DECLINE_DOMAIN_LENGTH = 253;934+935+function normaliseDeclineDomain(domain: string): string | null {936+  if (!domain || domain.length > MAX_DECLINE_DOMAIN_LENGTH) return null;937+  try {938+    const url = new URL(`https://${domain}`);939+    return isValidRpId(url.hostname, url.origin) ? url.hostname : null;940+  } catch {941+    return null;942+  }943+}944+933945 function declineKey(domain: string): string {934946   return `${FIREFOX_DECLINE_PREFIX}${domain}`;935947 }936948 937949 export async function recordPasskeyOfferDecline(domain: string): Promise<void> {938-  if (!domain) return;939-  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });950+  const validDomain = normaliseDeclineDomain(domain);951+  if (!validDomain) return;952+  await chrome.storage.local.set({ [declineKey(validDomain)]: Date.now() });940953 }941954 942955 export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {943-  if (!domain) return false;944-  const key = declineKey(domain);956+  const validDomain = normaliseDeclineDomain(domain);957+  if (!validDomain) return false;958+  const key = declineKey(validDomain);959+945960   const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;946961   const at = stored[key];947962   if (typeof at !== "number") return false;

view file

src/background/vault.ts+41 −3
@@ -123,7 +123,12 @@123123   passkeyProviderEnabled?: boolean;124124   trashRetentionDays?: number;125125   manualSummonDomains?: Record<string, ManualSummonRecord>;126+  manualSummonSalt?: string;126127   declinedCardFingerprints?: string[];128+  cardFingerprintSalt?: string;129+130+  /** Metadata is bounded because it remains outside the encrypted vault blob. */131+  metadataVersion?: 1;127132   /**128133    * Text size scale for body copy and entry titles. Stops at "large" by129134    * design — XL clips dense screens (Vault, Health) inside the fixed@@ -165,6 +170,21 @@165170 const PIN_MAX_ATTEMPTS = 3;166171 167172 let settings: VaultSettings = {};173+const CARD_DECLINE_MAX = 256;174+const METADATA_SALT_BYTES = 16;175+176+function randomMetadataSalt(): string {177+  return Array.from(crypto.getRandomValues(new Uint8Array(METADATA_SALT_BYTES)))178+    .map((byte) => byte.toString(16).padStart(2, "0"))179+    .join("");180+}181+182+async function metadataDigest(value: string, salt: string): Promise<string> {183+  const bytes = new TextEncoder().encode(`${salt}:${value}`);184+  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));185+  return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");186+}187+168188 let inMemoryPinWrap: StoredPinWrap | null = null;169189 let inMemoryPinFailedAttempts = 0;170190 let sessionPinStorageFailed = false;@@ -346,7 +366,17 @@346366 347367 async function persistSettings(): Promise<void> {348368   try {349-    await chrome.storage.local.set({ peach_settings: settings });369+    const salt = settings.cardFingerprintSalt ?? randomMetadataSalt();370+    await chrome.storage.local.set({371+      peach_settings: {372+        ...settings,373+        manualSummonDomains: undefined,374+        manualSummonSalt: undefined,375+        cardFingerprintSalt: salt,376+        declinedCardFingerprints: settings.declinedCardFingerprints ?? [],377+        metadataVersion: 1,378+      },379+    });350380   } catch (err) {351381     throw mapStorageError(err);352382   }@@ -428,9 +458,14 @@428458 export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {429459   const fingerprint = await cardFingerprint(last4, expMonth, expYear);430460   await loadSettings();461+  const salt = settings.cardFingerprintSalt ?? randomMetadataSalt();431462   settings = {432463     ...settings,433-    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],464+    cardFingerprintSalt: salt,465+    declinedCardFingerprints: [466+      ...(settings.declinedCardFingerprints ?? []),467+      await metadataDigest(fingerprint, salt),468+    ].slice(-CARD_DECLINE_MAX),434469   };435470   await persistSettings();436471 }@@ -438,7 +473,10 @@438473 export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {439474   await loadSettings();440475   const fingerprint = await cardFingerprint(last4, expMonth, expYear);441-  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);476+  const salt = settings.cardFingerprintSalt;477+  if (!salt) return false;478+  const candidate = await metadataDigest(fingerprint, salt);479+  return (settings.declinedCardFingerprints ?? []).some((stored) => stored === fingerprint || stored === candidate);442480 }443481 444482 async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {

view file

src/popup/App.tsx+6 −1
@@ -769,7 +769,12 @@769769         const entry = selectedEntry();770770         if (!entry?.totp) return;771771         try {772-          const { code } = generateTOTP(entry.totp);772+          const { code } = generateTOTP(773+            entry.totp,774+            entry.totpAlgorithm,775+            entry.totpDigits,776+            entry.totpPeriod,777+          );773778           void popupCopy(code, "2FA").then((ok) => { if (ok) triggerRing(); });774779         } catch { /* invalid TOTP secret — silent */ }775780       },

view file

src/popup/views/Entry.tsx+6 −1
@@ -213,7 +213,12 @@213213   const totpState = createMemo(() => {214214     void totpTick();215215     if (!props.entry?.totp) return null;216-    return generateTOTP(props.entry.totp);216+    return generateTOTP(217+      props.entry.totp,218+      props.entry.totpAlgorithm,219+      props.entry.totpDigits,220+      props.entry.totpPeriod,221+    );217222   });218223 219224   let prevTotpCode: string | undefined;

view file

src/popup/views/Export.tsx+9 −1
@@ -7,6 +7,7 @@77 import { sendMessage } from "../App";88 import { buildPayload } from "../../crypto/peachPayload";99 import { encodePeachScript } from "../../crypto/peachScript";10+import { IMPORT_LIMITS } from "../../shared/import-limits";1011 import { RuneCanvas } from "../components/RuneCanvas";1112 import type { PeachLicenseExport, VaultEntry } from "../../shared/types";1213 @@ -156,9 +157,16 @@156157       const env = await assembleEnvelope(pass());157158       setEntryCount(env.entryCount);158159       setStats(env.stats);160+      if (env.bytes.length > IMPORT_LIMITS.MAX_JSON_BYTES) {161+        throw new Error("PEACHSCRIPT_EXPORT_TOO_LARGE");162+      }159163       setScript(encodePeachScript(env.bytes));160164     } catch (err) {161-      setError(err instanceof Error ? err.message : String(err));165+      setError(166+        err instanceof Error && err.message === "PEACHSCRIPT_EXPORT_TOO_LARGE"167+          ? "This backup is too large to import later (limit: 10 MiB)."168+          : "Couldn't create PeachScript backup.",169+      );162170     } finally {163171       setWorking(false);164172     }

view file

src/popup/views/Import.tsx+5 −0
@@ -469,6 +469,11 @@469469     setError(null);470470     setAwaitingReplaceConfirm(false);471471 472+    if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {473+      setError("That file is too large.");474+      input.value = "";475+      return;476+    }472477     const text = await file.text();473478 474479     // Route .peach files to the PeachScript subview. Detection is two-pronged:

view file