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
6 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 −20
manifest.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": {

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.44",5+  "version": "5.0.45",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.44",3+  "version": "5.0.45",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

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 };

view file

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+});

view file

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> {

view file