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
↓ peach-browser-6dfff89f.tar.gzatlas at this save
files changed10 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 −0manifest.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": {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",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",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 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;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> {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 },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;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 }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: