11473a0cb44f
key the declined-card digest to root-key material and reject legacy unsalted records (PB-26-108, v5.0.45)
- time
- 2026-09-02 04:26 UTC · 1 month ago
- machine
- koh2
- agent
- hy4-preview
- session
- assessment remediation corrections
- parent
- e8d9c9d2d975
↓ peach-browser-11473a0c.tar.gzatlas at this save
files changed6 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+4 −0src/background/vault.card-decline.test.ts+201 −0src/background/vault.ts+43 −20manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.44",4+ "version": "5.0.45",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.44",5+ "version": "5.0.45",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.44",3+ "version": "5.0.45",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/messages.ts+4 −0
@@ -1335,6 +1335,10 @@13351335 return { ok: true };13361336 }13371337 case "DECLINE_CARD_SAVE": {1338+ // PB-26-108: the declined-card digest is keyed by root-key material, so1339+ // a decline cannot be recorded while locked. Offers are not shown while1340+ // locked either, so this is unreachable in practice.1341+ if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };13381342 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };13391343 await vault.declineCardSaveOffer(last4, expMonth, expYear);13401344 return { ok: true };src/background/vault.card-decline.test.ts+201 −0
@@ -0,0 +1,201 @@1+// PB-26-108: declined-card metadata must not be offline-enumerable.2+//3+// `peach_settings` lives unencrypted in chrome.storage.local, and on Firefox a4+// content script can read it directly. The declined-card record was a truncated5+// SHA-256 over `last4|expMonth|expYear` "salted" with a per-vault random value6+// stored right next to it. A stored salt defeats precomputed tables, not an7+// attacker holding the file: the candidate space is ~10^4 last-four values x 128+// months x a handful of years, so the card's last four and expiry were9+// recoverable by brute force in seconds.10+//11+// The fix keys the digest with material that is never at rest — a value derived12+// from the vault root key, available only while unlocked. Storage alone no13+// longer contains the inputs needed to recompute a record, so enumeration has14+// nothing to enumerate against.15+16+import { beforeEach, describe, expect, it, vi } from "vitest";17+18+type StorageShape = Record<string, unknown>;19+20+function installChromeMock(storage: StorageShape, sessionStorage: StorageShape) {21+ const chrome = {22+ storage: {23+ local: {24+ get: vi.fn(async (key?: string | string[]) => {25+ if (!key) return { ...storage };26+ const keys = Array.isArray(key) ? key : [key];27+ return keys.reduce<StorageShape>((acc, k) => {28+ acc[k] = storage[k];29+ return acc;30+ }, {});31+ }),32+ set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),33+ remove: vi.fn(async (keys: string | string[]) => {34+ for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];35+ }),36+ },37+ session: {38+ get: vi.fn(async () => ({ ...sessionStorage })),39+ set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }),40+ remove: vi.fn(async () => {}),41+ },42+ },43+ alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },44+ idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },45+ runtime: {46+ id: "test-extension-id",47+ getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),48+ },49+ } as unknown as typeof globalThis.chrome;50+ (globalThis as Record<string, unknown>).chrome = chrome;51+}52+53+/** The pre-fix record: unsalted, truncated SHA-256 over the card's own fields. */54+async function legacyUnsaltedFingerprint(last4: string, month: string, year: string): Promise<string> {55+ const buf = await crypto.subtle.digest(56+ "SHA-256",57+ new TextEncoder().encode(`${last4}|${month}|${year}`),58+ );59+ return Array.from(new Uint8Array(buf).slice(0, 8))60+ .map((b) => b.toString(16).padStart(2, "0"))61+ .join("");62+}63+64+describe("PB-26-108: declined-card metadata at rest", () => {65+ let storage: StorageShape;66+ let sessionStorage: StorageShape;67+68+ beforeEach(() => {69+ vi.resetModules();70+ storage = {};71+ sessionStorage = {};72+ installChromeMock(storage, sessionStorage);73+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));74+ });75+76+ async function freshVault(password: string) {77+ const vault = await import("./vault");78+ await vault.createVault(password);79+ return vault;80+ }81+82+ function persistedSettings(): Record<string, unknown> {83+ return storage.peach_settings as Record<string, unknown>;84+ }85+86+ it("persists no plaintext domain ledger and no card-fingerprint salt", async () => {87+ const vault = await freshVault("card-meta-pass-12345");88+ await vault.declineCardSaveOffer("4242", "12", "2029");89+90+ const blob = persistedSettings();91+ expect(blob.manualSummonDomains).toBeUndefined();92+ expect(blob.manualSummonSalt).toBeUndefined();93+ expect(blob.cardFingerprintSalt).toBeUndefined();94+ expect(blob.declinedCardFingerprints).toHaveLength(1);95+ });96+97+ // The enumeration attack, run with nothing but the contents of storage. The98+ // attacker enumerates every last-four value for the card's real expiry and99+ // keys the digest with whatever storage offers — the stored salt under the100+ // old scheme, nothing at all under the new one.101+ //102+ // Bug this catches: restoring a stored `cardFingerprintSalt` next to the103+ // records makes this loop terminate on "4242", which is exactly the104+ // ~1.2M-candidate offline recovery the finding is about.105+ it("a declined card cannot be recovered by brute force from storage alone", async () => {106+ const vault = await freshVault("card-brute-pass-12345");107+ await vault.declineCardSaveOffer("4242", "12", "2029");108+109+ const blob = persistedSettings();110+ const [stored] = blob.declinedCardFingerprints as string[];111+ const storedSalt = blob.cardFingerprintSalt as string | undefined;112+ expect(stored).toBeDefined();113+114+ const digest = async (value: string, salt: string) => {115+ const buf = await crypto.subtle.digest(116+ "SHA-256",117+ new TextEncoder().encode(`${salt}:${value}`),118+ );119+ return Array.from(new Uint8Array(buf).slice(0, 16))120+ .map((b) => b.toString(16).padStart(2, "0"))121+ .join("");122+ };123+124+ for (let candidate = 0; candidate < 10_000; candidate += 1) {125+ const last4 = candidate.toString().padStart(4, "0");126+ const fingerprint = await legacyUnsaltedFingerprint(last4, "12", "2029");127+ const guess = storedSalt ? await digest(fingerprint, storedSalt) : fingerprint;128+ expect(guess).not.toBe(stored);129+ }130+ });131+132+ // Same card, different vault (therefore different root key) — the record is133+ // a function of secret material, not of the card.134+ it("the same card records a different value under a different root key", async () => {135+ const first = await freshVault("card-key-a-pass-12345");136+ await first.declineCardSaveOffer("4242", "12", "2029");137+ const firstRecord = (persistedSettings().declinedCardFingerprints as string[])[0];138+139+ vi.resetModules();140+ storage = {};141+ sessionStorage = {};142+ installChromeMock(storage, sessionStorage);143+ const second = await freshVault("card-key-b-pass-12345");144+ await second.declineCardSaveOffer("4242", "12", "2029");145+ const secondRecord = (persistedSettings().declinedCardFingerprints as string[])[0];146+147+ expect(firstRecord).toBeDefined();148+ expect(secondRecord).toBeDefined();149+ expect(secondRecord).not.toBe(firstRecord);150+ });151+152+ it("a legacy unsalted record is no longer accepted", async () => {153+ const vault = await freshVault("card-legacy-pass-12345");154+ // Materialise the settings blob with an unrelated decline, then plant a155+ // legacy-format record the way an upgraded profile would carry one.156+ await vault.declineCardSaveOffer("1111", "01", "2027");157+ const legacy = await legacyUnsaltedFingerprint("4242", "12", "2029");158+ (persistedSettings().declinedCardFingerprints as string[]).push(legacy);159+160+ expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(false);161+ });162+163+ it("a legacy unsalted record is pruned on the next decline", async () => {164+ const vault = await freshVault("card-prune-pass-12345");165+ const legacy = await legacyUnsaltedFingerprint("1111", "01", "2027");166+ await vault.declineCardSaveOffer("4242", "12", "2029");167+ (persistedSettings().declinedCardFingerprints as string[]).unshift(legacy);168+169+ await vault.declineCardSaveOffer("9999", "06", "2030");170+171+ const records = persistedSettings().declinedCardFingerprints as string[];172+ expect(records).not.toContain(legacy);173+ expect(records).toHaveLength(2);174+ // The genuine declines survive the prune.175+ expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(true);176+ expect(await vault.isCardDeclined("9999", "06", "2030")).toBe(true);177+ });178+179+ // Locked means the root key is gone, so no record can be checked. Offers are180+ // not shown while locked, so this is not a functional regression.181+ it("declines cannot be evaluated while the vault is locked", async () => {182+ const vault = await freshVault("card-locked-pass-12345");183+ await vault.declineCardSaveOffer("4242", "12", "2029");184+ expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(true);185+186+ vault.lock();187+188+ expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(false);189+ });190+191+ // The feature itself must still work: a declined card stays declined for192+ // exactly that card.193+ it("still distinguishes cards by last four and expiry", async () => {194+ const vault = await freshVault("card-distinct-pass-12345");195+ await vault.declineCardSaveOffer("4242", "12", "2029");196+197+ expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(true);198+ expect(await vault.isCardDeclined("4242", "11", "2029")).toBe(false);199+ expect(await vault.isCardDeclined("1111", "12", "2029")).toBe(false);200+ });201+});src/background/vault.ts+43 −20
@@ -125,7 +125,6 @@125125 manualSummonDomains?: Record<string, ManualSummonRecord>;126126 manualSummonSalt?: string;127127 declinedCardFingerprints?: string[];128- cardFingerprintSalt?: string;129128 130129 /** Metadata is bounded because it remains outside the encrypted vault blob. */131130 metadataVersion?: 1;@@ -171,16 +170,11 @@171170 172171 let settings: VaultSettings = {};173172 const CARD_DECLINE_MAX = 256;174-const METADATA_SALT_BYTES = 16;173+/** Hex length of a keyed card-decline digest. Legacy records are 16 hex. */174+const CARD_DECLINE_DIGEST_HEX = 32;175175 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}`);176+async function metadataDigest(value: string, key: string): Promise<string> {177+ const bytes = new TextEncoder().encode(`${key}:${value}`);184178 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));185179 return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");186180 }@@ -366,13 +360,12 @@366360 367361 async function persistSettings(): Promise<void> {368362 try {369- const salt = settings.cardFingerprintSalt ?? randomMetadataSalt();370363 await chrome.storage.local.set({371364 peach_settings: {372365 ...settings,373366 manualSummonDomains: undefined,374367 manualSummonSalt: undefined,375- cardFingerprintSalt: salt,368+ cardFingerprintSalt: undefined,376369 declinedCardFingerprints: settings.declinedCardFingerprints ?? [],377370 metadataVersion: 1,378371 },@@ -456,15 +449,20 @@456449 }457450 458451 export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {452+ // Unreachable from the message layer, which gates on isUnlocked first.453+ const key = await cardDeclineKey();454+ if (!key) return;459455 const fingerprint = await cardFingerprint(last4, expMonth, expYear);460456 await loadSettings();461- const salt = settings.cardFingerprintSalt ?? randomMetadataSalt();462457 settings = {463458 ...settings,464- cardFingerprintSalt: salt,465459 declinedCardFingerprints: [466- ...(settings.declinedCardFingerprints ?? []),467- await metadataDigest(fingerprint, salt),460+ // Legacy records predate the keyed digest and are silently dropped here:461+ // they are 16 hex characters rather than 32, and are no longer accepted.462+ ...(settings.declinedCardFingerprints ?? []).filter(463+ (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,464+ ),465+ await metadataDigest(fingerprint, key),468466 ].slice(-CARD_DECLINE_MAX),469467 };470468 await persistSettings();@@ -472,11 +470,36 @@472470 473471 export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {474472 await loadSettings();473+ const key = await cardDeclineKey();474+ if (!key) return false;475475 const fingerprint = await cardFingerprint(last4, expMonth, expYear);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);476+ const candidate = await metadataDigest(fingerprint, key);477+ return (settings.declinedCardFingerprints ?? []).some(478+ (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,479+ );480+}481+482+/**483+ * Keying material for the declined-card digest, derived from the vault root484+ * key so it is available only while unlocked and never written to storage.485+ *486+ * PB-26-108: the previous scheme stored a random salt beside the records. A487+ * salt defeats precomputed tables, not an attacker holding the file — the488+ * candidate space here is ~10^4 last-four values x 12 months x a few years, so489+ * a stored salt left the card's last four and expiry recoverable offline in490+ * seconds. `peach_settings` is unencrypted and directly readable by a content491+ * script on Firefox, so the key has to live outside storage entirely.492+ */493+async function cardDeclineKey(): Promise<string | null> {494+ const root = getRootKeyHex();495+ if (!root) return null;496+ const digest = new Uint8Array(497+ await crypto.subtle.digest(498+ "SHA-256",499+ new TextEncoder().encode(`peach-card-decline-v1:${root}`),500+ ),501+ );502+ return hex(digest);480503 }481504 482505 async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {