1a7d31

feat: credit and debit card support — vault, autofill, save offer, decline memory (v1.7.0)

time
2026-04-23 22:50 UTC · 6 months ago
machine
koh2
parent
271721
16 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+14 −2src/background/vault.test.ts+84 −0src/background/vault.ts+82 −1src/content/detect.test.ts+38 −0src/content/detect.ts+45 −0src/content/fill.test.ts+66 −1src/content/fill.ts+52 −0src/crypto/cjkRoundTrip.test.ts+39 −0src/popup/styles/popup.css+26 −0src/popup/views/Add.tsx+139 −2src/popup/views/Entry.tsx+132 −4src/popup/views/Settings.tsx+28 −1src/shared/types.ts+23 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.6.0",4+  "version": "1.7.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.6.0",4+  "version": "1.7.0",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "1.6.0",3+  "version": "1.7.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.ts+14 −2
@@ -165,7 +165,7 @@165165         const matches = includeAll166166           ? vault167167               .getEntries()168-              .filter((entry) => entry.type !== "note" && entry.type !== "passkey")168+              .filter((entry) => entry.type !== "note" && entry.type !== "passkey" && entry.type !== "card")169169           : vault.queryDomain(domain);170170         return {171171           ok: true,@@ -432,6 +432,16 @@432432         });433433         return { ok: true };434434       }435+      case "DECLINE_CARD_SAVE": {436+        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };437+        await vault.declineCardSaveOffer(last4, expMonth, expYear);438+        return { ok: true };439+      }440+      case "IS_CARD_DECLINED": {441+        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };442+        const declined = await vault.isCardDeclined(last4, expMonth, expYear);443+        return { ok: true, data: declined };444+      }435445       default:436446         return { ok: false, error: "unknown message type" };437447     }@@ -463,7 +473,9 @@463473     type !== "LOCK" &&464474     type !== "UPDATE_SETTINGS" &&465475     type !== "GET_PENDING_PASSKEY" &&466-    type !== "GET_PASSKEY_PROVIDER_STATE"476+    type !== "GET_PASSKEY_PROVIDER_STATE" &&477+    type !== "DECLINE_CARD_SAVE" &&478+    type !== "IS_CARD_DECLINED"467479   );468480 }469481 

view file

src/background/vault.test.ts+84 −0
@@ -744,4 +744,88 @@744744       "STORAGE_QUOTA_EXCEEDED"745745     );746746   });747+748+  it("card entry round-trips through vault — all fields preserved", async () => {749+    const vault = await import("./vault");750+751+    await vault.createVault("card-test-pass-12345");752+    const entry = await vault.addEntry({753+      type: "card",754+      title: "Personal Visa",755+      cardholderName: "Jane Smith",756+      cardNumber: "4111111111111111",757+      expMonth: "05",758+      expYear: "2028",759+      cvc: "123",760+      cardNickname: "Personal Visa",761+      network: "visa",762+      last4: "1111",763+    });764+765+    expect(entry.type).toBe("card");766+    expect(entry.cardholderName).toBe("Jane Smith");767+    expect(entry.cardNumber).toBe("4111111111111111");768+    expect(entry.expMonth).toBe("05");769+    expect(entry.expYear).toBe("2028");770+    expect(entry.cvc).toBe("123");771+    expect(entry.network).toBe("visa");772+    expect(entry.last4).toBe("1111");773+774+    vault.lock();775+    await vault.unlock("card-test-pass-12345");776+    const entries = vault.getEntries();777+    expect(entries).toHaveLength(1);778+    expect(entries[0].cardNumber).toBe("4111111111111111");779+    expect(entries[0].cvc).toBe("123");780+  });781+782+  it("updateEntry — cannot clear card number on card entry", async () => {783+    const vault = await import("./vault");784+785+    await vault.createVault("card-guard-pass-12345");786+    const entry = await vault.addEntry({787+      type: "card",788+      title: "My Card",789+      cardholderName: "Jane Smith",790+      cardNumber: "4111111111111111",791+      expMonth: "05",792+      expYear: "2028",793+      cvc: "123",794+    });795+796+    await expect(797+      vault.updateEntry(entry.id, { cardNumber: "" })798+    ).rejects.toThrow("card number is required for card entries");799+800+    const entries = vault.getEntries();801+    expect(entries[0].cardNumber).toBe("4111111111111111");802+  });803+804+  it("declineCardSaveOffer — stores fingerprint in settings", async () => {805+    const vault = await import("./vault");806+807+    await vault.createVault("decline-pass-12345");808+    await vault.declineCardSaveOffer("1111", "05", "2028");809+810+    const settings = vault.getSettings();811+    expect(settings.declinedCardFingerprints).toHaveLength(1);812+  });813+814+  it("isCardDeclined — returns true for previously declined card", async () => {815+    const vault = await import("./vault");816+817+    await vault.createVault("declined-check-pass-12345");818+    await vault.declineCardSaveOffer("1111", "05", "2028");819+820+    expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);821+  });822+823+  it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {824+    const vault = await import("./vault");825+826+    await vault.createVault("declined-diff-pass-12345");827+    await vault.declineCardSaveOffer("1111", "05", "2028");828+829+    expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);830+  });747831 });

view file

src/background/vault.ts+82 −1
@@ -56,6 +56,7 @@5656   passkeyProviderEnabled?: boolean;5757   trashRetentionDays?: number;5858   manualSummonDomains?: Record<string, ManualSummonRecord>;59+  declinedCardFingerprints?: string[];5960 }6061 6162 let settings: VaultSettings = {};@@ -122,6 +123,28 @@122123   }123124 }124125 126+export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {127+  const fingerprint = await cardFingerprint(last4, expMonth, expYear);128+  await loadSettings();129+  settings = {130+    ...settings,131+    declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],132+  };133+  await persistSettings();134+}135+136+export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {137+  await loadSettings();138+  const fingerprint = await cardFingerprint(last4, expMonth, expYear);139+  return (settings.declinedCardFingerprints ?? []).includes(fingerprint);140+}141+142+async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {143+  const encoder = new TextEncoder();144+  const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));145+  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");146+}147+125148 function hex(buf: Uint8Array): string {126149   return Array.from(buf)127150     .map((b) => b.toString(16).padStart(2, "0"))@@ -294,7 +317,7 @@294317 295318   for (const entry of all) {296319     if (entry.deletedAt) continue;297-    if (entry.type === "note" || entry.type === "passkey") continue;320+    if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;298321 299322     const domain = extractDomain(entry.url ?? "");300323     if (!domain) continue;@@ -707,6 +730,12 @@707730     throw new Error("password is required for login entries");708731   }709732 733+  // Card entries must retain their card number734+  if (entries[idx].type === "card" && !entries[idx].cardNumber) {735+    entries[idx] = current;736+    throw new Error("card number is required for card entries");737+  }738+710739   await persistEntries();711740 712741   // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)@@ -856,6 +885,11 @@856885       throw new Error("invalid passkey entry");857886     }858887   }888+  if (type === "card") {889+    if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {890+      throw new Error("cardholder name and card number are required");891+    }892+  }859893 860894   return {861895     type,@@ -867,6 +901,7 @@867901     notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,868902     breach: type === "login" ? entry.breach : undefined,869903     canary: type === "login" ? entry.canary : undefined,904+    tags: entry.tags,870905     rpId: type === "passkey" ? entry.rpId?.trim() : undefined,871906     rpName: type === "passkey" ? entry.rpName?.trim() : undefined,872907     credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,@@ -881,6 +916,14 @@881916     backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,882917     backupState: type === "passkey" ? entry.backupState === true : undefined,883918     lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,919+    cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,920+    cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,921+    expMonth: type === "card" ? entry.expMonth?.trim() : undefined,922+    expYear: type === "card" ? entry.expYear?.trim() : undefined,923+    cvc: type === "card" ? entry.cvc?.trim() : undefined,924+    cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,925+    network: type === "card" ? entry.network : undefined,926+    last4: type === "card" ? entry.last4?.trim() : undefined,884927   };885928 }886929 @@ -983,6 +1026,36 @@9831026     nextPatch.lastUsedAt = patch.lastUsedAt;9841027   }9851028 1029+  if ("tags" in patch) {1030+    nextPatch.tags = patch.tags;1031+  }1032+1033+  if ("cardholderName" in patch) {1034+    nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1035+  }1036+  if ("cardNumber" in patch) {1037+    const n = patch.cardNumber ?? "";1038+    nextPatch.cardNumber = n || undefined;1039+  }1040+  if ("expMonth" in patch) {1041+    nextPatch.expMonth = patch.expMonth?.trim() || undefined;1042+  }1043+  if ("expYear" in patch) {1044+    nextPatch.expYear = patch.expYear?.trim() || undefined;1045+  }1046+  if ("cvc" in patch) {1047+    nextPatch.cvc = patch.cvc?.trim() || undefined;1048+  }1049+  if ("cardNickname" in patch) {1050+    nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1051+  }1052+  if ("network" in patch) {1053+    nextPatch.network = patch.network;1054+  }1055+  if ("last4" in patch) {1056+    nextPatch.last4 = patch.last4?.trim() || undefined;1057+  }1058+9861059   return nextPatch;9871060 }9881061 @@ -1020,6 +1093,14 @@10201093     modifiedAt,10211094     deletedAt: entry.deletedAt,10221095     tags: "tags" in entry ? entry.tags : undefined,1096+    cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1097+    cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1098+    expMonth: "expMonth" in entry ? entry.expMonth : undefined,1099+    expYear: "expYear" in entry ? entry.expYear : undefined,1100+    cvc: "cvc" in entry ? entry.cvc : undefined,1101+    cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1102+    network: "network" in entry ? entry.network : undefined,1103+    last4: "last4" in entry ? entry.last4 : undefined,10231104   };10241105 }10251106 

view file

src/content/detect.test.ts+38 −0
@@ -3,8 +3,10 @@33 import { beforeEach, describe, expect, it } from "vitest";44 55 import {6+  detectCardForm,67   detectLoginFields,78   getPageDomain,9+  hasPaymentProcessorIframe,810   isLoginForm,911   isSignupForm,1012 } from "./detect";@@ -92,5 +94,41 @@9294 9395   it("getPageDomain falls back to the current hostname when no registrable domain exists", () => {9496     expect(getPageDomain()).toBe(window.location.hostname);97+  });98+});99+100+describe("card form detection", () => {101+  beforeEach(() => {102+    document.body.innerHTML = "";103+  });104+105+  it("detectCardForm — finds number field by autocomplete attribute", () => {106+    document.body.innerHTML = '<input autocomplete="cc-number" /><input autocomplete="cc-exp-month" /><input autocomplete="cc-exp-year" /><input autocomplete="cc-csc" />';107+    const result = detectCardForm(document);108+    expect(result).not.toBeNull();109+    expect(result!.number).toBeTruthy();110+    expect(result!.expMonth).toBeTruthy();111+  });112+113+  it("detectCardForm — finds fields by name/id heuristics when no autocomplete", () => {114+    document.body.innerHTML = '<input name="cardnum" /><input name="expiry" /><input name="cvv" />';115+    const result = detectCardForm(document);116+    expect(result).not.toBeNull();117+    expect(result!.number).toBeTruthy();118+  });119+120+  it("detectCardForm — returns null when no card number field found", () => {121+    document.body.innerHTML = '<input type="text" name="username" />';122+    expect(detectCardForm(document)).toBeNull();123+  });124+125+  it("hasPaymentProcessorIframe — returns true when Stripe iframe present", () => {126+    document.body.innerHTML = '<iframe src="https://js.stripe.com/v3/"></iframe>';127+    expect(hasPaymentProcessorIframe()).toBe(true);128+  });129+130+  it("hasPaymentProcessorIframe — returns false on standard checkout page", () => {131+    document.body.innerHTML = '<input name="cardnum" />';132+    expect(hasPaymentProcessorIframe()).toBe(false);95133   });96134 });

view file

src/content/detect.ts+45 −0
@@ -171,3 +171,48 @@171171 export function getPageDomain(): string {172172   return extractDomain(location.hostname) ?? location.hostname;173173 }174+175+export interface CardFormFields {176+  number: HTMLInputElement | null;177+  expMonth: HTMLInputElement | null;178+  expYear: HTMLInputElement | null;179+  expiryCombo: HTMLInputElement | null;180+  cvc: HTMLInputElement | null;181+  name: HTMLInputElement | null;182+}183+184+export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {185+  const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');186+  const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');187+  const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');188+  const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');189+  const cvcByAC = root.querySelector<HTMLInputElement>(190+    'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'191+  );192+  const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');193+194+  if (numberByAC) {195+    return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };196+  }197+198+  const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));199+  const numberField = allInputs.find(i =>200+    /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)201+  ) ?? null;202+  const cvcField = allInputs.find(i =>203+    /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)204+  ) ?? null;205+  const expField = allInputs.find(i =>206+    /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)207+  ) ?? null;208+209+  if (!numberField) return null;210+  return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };211+}212+213+const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];214+215+export function hasPaymentProcessorIframe(): boolean {216+  const iframes = Array.from(document.querySelectorAll('iframe'));217+  return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));218+}

view file

src/content/fill.test.ts+66 −1
@@ -2,7 +2,7 @@22 33 import { beforeEach, describe, expect, it, vi } from "vitest";44 5-import { fillFields } from "./fill";5+import { fillFields, fillCardForm, formatCardNumber } from "./fill";66 77 describe("content fill", () => {88   beforeEach(() => {@@ -88,5 +88,70 @@8888     expect(usernameDispatch.mock.calls.map(([event]) => event.type)).toEqual(["input", "change"]);8989     expect(passwordDispatch.mock.calls.map(([event]) => event.type)).toEqual(["input", "change"]);9090     expect(passwordDispatch.mock.calls.every(([event]) => !(event instanceof CustomEvent))).toBe(true);91+  });92+});93+94+describe("card fill", () => {95+  beforeEach(() => {96+    document.body.innerHTML = "";97+  });98+99+  function makeInput(attrs: Record<string, string> = {}): HTMLInputElement {100+    const input = document.createElement("input");101+    for (const [k, v] of Object.entries(attrs)) input.setAttribute(k, v);102+    document.body.appendChild(input);103+    return input;104+  }105+106+  it("fillCardForm — fills number, expiry, CVC, and name fields", () => {107+    const number = makeInput({ type: "text" });108+    const expMonth = makeInput({ type: "text" });109+    const expYear = makeInput({ type: "text" });110+    const cvc = makeInput({ type: "text" });111+    const name = makeInput({ type: "text" });112+113+    fillCardForm(114+      { number, expMonth, expYear, expiryCombo: null, cvc, name },115+      { cardNumber: "4111111111111111", expMonth: "05", expYear: "2028", cvc: "123", cardholderName: "Jane Smith" }116+    );117+118+    expect(number.value).toBe("4111 1111 1111 1111");119+    expect(expMonth.value).toBe("05");120+    expect(expYear.value).toBe("2028");121+    expect(cvc.value).toBe("123");122+    expect(name.value).toBe("Jane Smith");123+  });124+125+  it("fillCardForm — handles combined expiry field (MM/YY)", () => {126+    const number = makeInput({ type: "text" });127+    const expiryCombo = makeInput({ type: "text" });128+    const cvc = makeInput({ type: "text" });129+130+    fillCardForm(131+      { number, expMonth: null, expYear: null, expiryCombo, cvc, name: null },132+      { cardNumber: "5555555555554444", expMonth: "12", expYear: "2026", cvc: "456", cardholderName: "John Doe" }133+    );134+135+    expect(expiryCombo.value).toBe("12/26");136+  });137+138+  it("fillCardForm — dispatches input and change events for React compatibility", () => {139+    const number = makeInput({ type: "text" });140+    const numberDispatch = vi.spyOn(number, "dispatchEvent");141+142+    fillCardForm(143+      { number, expMonth: null, expYear: null, expiryCombo: null, cvc: null, name: null },144+      { cardNumber: "4111111111111111", expMonth: "05", expYear: "2028", cvc: "123", cardholderName: "Jane" }145+    );146+147+    expect(numberDispatch.mock.calls.map(([event]) => event.type)).toEqual(["input", "change"]);148+  });149+150+  it("formatCardNumber — formats Visa as 4-4-4-4", () => {151+    expect(formatCardNumber("4111111111111111")).toBe("4111 1111 1111 1111");152+  });153+154+  it("formatCardNumber — formats Amex as 4-6-5", () => {155+    expect(formatCardNumber("378282246310005")).toBe("3782 822463 10005");91156   });92157 });

view file

src/content/fill.ts+52 −0
@@ -47,3 +47,55 @@4747   el.dispatchEvent(new Event("input", { bubbles: true }));4848   el.dispatchEvent(new Event("change", { bubbles: true }));4949 }50+51+export interface CardFillFields {52+  number: HTMLInputElement | null;53+  expMonth: HTMLInputElement | null;54+  expYear: HTMLInputElement | null;55+  expiryCombo: HTMLInputElement | null;56+  cvc: HTMLInputElement | null;57+  name: HTMLInputElement | null;58+}59+60+export function fillCardForm(fields: CardFillFields, card: { cardNumber: string; expMonth: string; expYear: string; cvc: string; cardholderName: string }): void {61+  const raw = card.cardNumber.replace(/\s/g, '');62+  if (fields.number) setNativeValue(fields.number, formatCardNumber(raw));63+  if (fields.expiryCombo) {64+    setNativeValue(fields.expiryCombo, `${card.expMonth}/${card.expYear.slice(-2)}`);65+  } else {66+    if (fields.expMonth) setNativeValue(fields.expMonth, card.expMonth);67+    if (fields.expYear) setNativeValue(fields.expYear, card.expYear);68+  }69+  if (fields.cvc) setNativeValue(fields.cvc, card.cvc);70+  if (fields.name) setNativeValue(fields.name, card.cardholderName);71+}72+73+export function formatCardNumber(raw: string): string {74+  if (/^3[47]/.test(raw)) return raw.replace(/(\d{4})(\d{6})(\d{5})/, '$1 $2 $3');75+  return raw.replace(/(\d{4})(?=\d)/g, '$1 ').trim();76+}77+78+export function detectCardNetwork(number: string): import('../shared/types').CardNetwork {79+  const n = number.replace(/\s/g, '');80+  if (/^4/.test(n)) return 'visa';81+  if (/^5[1-5]|^2[2-7]/.test(n)) return 'mastercard';82+  if (/^3[47]/.test(n)) return 'amex';83+  if (/^6(?:011|5)/.test(n)) return 'discover';84+  return 'unknown';85+}86+87+export function passesLuhn(number: string): boolean {88+  const digits = number.replace(/\s/g, '').split('').map(Number);89+  let sum = 0, isEven = false;90+  for (let i = digits.length - 1; i >= 0; i--) {91+    let d = digits[i]!;92+    if (isEven) { d *= 2; if (d > 9) d -= 9; }93+    sum += d;94+    isEven = !isEven;95+  }96+  return sum % 10 === 0;97+}98+99+export function extractLast4(number: string): string {100+  return number.replace(/\s/g, '').slice(-4);101+}

view file

src/crypto/cjkRoundTrip.test.ts+39 −0
@@ -260,6 +260,45 @@260260       /HEADER_COUNT_MISMATCH/261261     );262262   }, 30000);263+264+  it("CJK round-trip preserves card entries with all fields intact", async () => {265+    const cardEntry: VaultEntry = {266+      id: "card-one",267+      type: "card",268+      title: "Personal Visa",269+      cardholderName: "Jane Smith",270+      cardNumber: "4111111111111111",271+      expMonth: "05",272+      expYear: "2028",273+      cvc: "123",274+      cardNickname: "Everyday Card",275+      network: "visa",276+      last4: "1111",277+      createdAt: 1_700_000_005_000,278+      modifiedAt: 1_700_000_005_000,279+    };280+281+    const { phrase } = await exportToCJK({282+      entries: [cardEntry],283+      settings: {},284+      passphrase,285+      argon2: FAST_ARGON2,286+    });287+288+    const { entries: restored } = await importFromCJK(phrase, passphrase);289+    expect(restored).toHaveLength(1);290+291+    const card = restored[0];292+    expect(card.type).toBe("card");293+    expect(card.cardholderName).toBe("Jane Smith");294+    expect(card.cardNumber).toBe("4111111111111111");295+    expect(card.expMonth).toBe("05");296+    expect(card.expYear).toBe("2028");297+    expect(card.cvc).toBe("123");298+    expect(card.cardNickname).toBe("Everyday Card");299+    expect(card.network).toBe("visa");300+    expect(card.last4).toBe("1111");301+  }, 30000);263302 });264303 265304 describe("CJK phrase size sanity (§10.4)", () => {

view file

src/popup/styles/popup.css+26 −0
@@ -4408,3 +4408,29 @@44084408   border-color: var(--peach-t0);44094409   transform: scale(1.1);44104410 }4411+4412+/* ── Card entry styles ───────────────────────────────────────── */4413+.peach-card-number {4414+  font-family: var(--peach-mono);4415+  letter-spacing: 0.08em;4416+}4417+4418+.peach-card-network-badge {4419+  font-size: 10px;4420+  padding: 1px 5px;4421+  border-radius: 4px;4422+  border: 0.5px solid var(--peach-border);4423+  color: var(--peach-text-dim);4424+  margin-left: 6px;4425+  vertical-align: middle;4426+}4427+4428+.peach-card-expiry-row {4429+  display: flex;4430+  align-items: center;4431+  gap: 8px;4432+}4433+4434+.peach-card-expiry-sep {4435+  color: var(--peach-text-dim);4436+}

view file

src/popup/views/Add.tsx+139 −2
@@ -33,7 +33,20 @@3333   const [showNotes, setShowNotes] = createSignal(!!(props.entry?.notes));3434 3535   const isNoteEntry = () => props.entry?.type === "note";36-  const [selectedType, setSelectedType] = createSignal<"login" | "note">(isNoteEntry() ? "note" : "login");36+  const isCardEntry = () => props.entry?.type === "card";37+  const [selectedType, setSelectedType] = createSignal<"login" | "note" | "card">(38+    isNoteEntry() ? "note" : isCardEntry() ? "card" : "login"39+  );40+41+  // Card signals42+  const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");43+  const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");44+  const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");45+  const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");46+  const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");47+  const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");48+  const [showCvc, setShowCvc] = createSignal(false);49+  const [cardNumberFocused, setCardNumberFocused] = createSignal(false);3750   const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);3851 3952   function noteExec(cmd: string, val?: string) {@@ -41,9 +54,26 @@4154     noteEditorEl()?.focus();4255   }4356 57+  function getCardNetwork(num: string): string {58+    const n = num.replace(/\s/g, '');59+    if (/^4/.test(n)) return 'Visa';60+    if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';61+    if (/^3[47]/.test(n)) return 'Amex';62+    if (/^6(?:011|5)/.test(n)) return 'Discover';63+    return '';64+  }65+66+  function formatCardNumberDisplay(raw: string, focused: boolean): string {67+    const n = raw.replace(/\s/g, '');68+    if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);69+    if (/^3[47]/.test(n)) return n.replace(/(\d{4})(\d{1,6})?(\d{1,5})?/, (_, a, b, c) => [a, b, c].filter(Boolean).join(' '));70+    return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();71+  }72+4473   const canSave = () => {4574     if (!title().trim()) return false;4675     if (selectedType() === "login" && !password().trim()) return false;76+    if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false;4777     return true;4878   };4979 @@ -115,7 +145,13 @@115145 116146   async function handleSave() {117147     if (!canSave()) {118-      setError(selectedType() === "note" ? "title is required" : "title and password are required");148+      setError(149+        selectedType() === "note"150+          ? "title is required"151+          : selectedType() === "card"152+          ? "cardholder name and card number are required"153+          : "title and password are required"154+      );119155       return;120156     }121157 @@ -136,6 +172,19 @@136172       payload.totp = getNormalizedTotp();137173     }138174 175+    if (selectedType() === "card") {176+      const raw = cardNumber().replace(/\s/g, '');177+      payload.cardholderName = cardholderName().trim();178+      payload.cardNumber = raw;179+      payload.expMonth = expMonth().trim();180+      payload.expYear = expYear().trim();181+      payload.cvc = cvc().trim();182+      payload.cardNickname = cardNickname().trim() || undefined;183+      const n = raw;184+      payload.network = /^4/.test(n) ? 'visa' : /^5[1-5]|^2[2-7]/.test(n) ? 'mastercard' : /^3[47]/.test(n) ? 'amex' : /^6(?:011|5)/.test(n) ? 'discover' : 'unknown';185+      payload.last4 = raw.slice(-4);186+    }187+139188     if (props.entry) {140189       const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });141190       const result = res as { ok: boolean; error?: string };@@ -191,6 +240,10 @@191240               class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}192241               onClick={() => setSelectedType("note")}193242             >note</button>243+            <button244+              class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}245+              onClick={() => setSelectedType("card")}246+            >card</button>194247           </div>195248         </Show>196249 @@ -321,6 +374,90 @@321374                 </div>322375               </div>323376             </Show>377+          </section>378+        </Show>379+380+        {/* ── Card fields ── */}381+        <Show when={selectedType() === "card"}>382+          <section class="peach-fields">383+            {/* Nickname */}384+            <div class="peach-field-card">385+              <span class="peach-label">nickname</span>386+              <div class="peach-field-value-row">387+                <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"388+                  autocomplete="off" value={cardNickname()}389+                  onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />390+              </div>391+            </div>392+            {/* Cardholder name */}393+            <div class="peach-field-card">394+              <span class="peach-label">cardholder name</span>395+              <div class="peach-field-value-row">396+                <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"397+                  autocomplete="cc-name" value={cardholderName()}398+                  onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />399+              </div>400+            </div>401+            {/* Card number */}402+            <div class="peach-field-card">403+              <span class="peach-label">404+                card number405+                <Show when={getCardNetwork(cardNumber())}>406+                  <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>407+                </Show>408+              </span>409+              <div class="peach-field-value-row">410+                <input class="peach-field-value peach-field-value--edit peach-card-number"411+                  placeholder="•••• •••• •••• ••••"412+                  autocomplete="cc-number" inputmode="numeric" maxLength={19}413+                  value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}414+                  onFocus={() => setCardNumberFocused(true)}415+                  onBlur={() => setCardNumberFocused(false)}416+                  onInput={(e) => {417+                    const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');418+                    setCardNumber(raw);419+                  }} />420+              </div>421+            </div>422+            {/* Expiry */}423+            <div class="peach-field-card">424+              <span class="peach-label">expiry</span>425+              <div class="peach-card-expiry-row">426+                <input class="peach-field-value peach-field-value--edit" placeholder="MM"427+                  autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"428+                  value={expMonth()}429+                  onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />430+                <span class="peach-card-expiry-sep">/</span>431+                <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"432+                  autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"433+                  value={expYear()}434+                  onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />435+              </div>436+            </div>437+            {/* CVC */}438+            <div class="peach-field-card">439+              <span class="peach-label">CVC</span>440+              <div class="peach-field-value-row">441+                <input class="peach-field-value peach-field-value--edit"442+                  type={showCvc() ? "text" : "password"} placeholder="•••"443+                  autocomplete="cc-csc" inputmode="numeric" maxLength={4}444+                  value={cvc()}445+                  onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />446+                <button class="peach-inline-action peach-password-toggle-btn" type="button"447+                  onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>448+                  <Show when={showCvc()} fallback={449+                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">450+                      <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />451+                      <circle cx="7" cy="5" r="1.75" />452+                    </svg>453+                  }>454+                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">455+                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />456+                    </svg>457+                  </Show>458+                </button>459+              </div>460+            </div>324461           </section>325462         </Show>326463 

view file

src/popup/views/Entry.tsx+132 −4
@@ -24,6 +24,8 @@2424   const [showPassword, setShowPassword] = createSignal(false);2525   const [showGenerator, setShowGenerator] = createSignal(false);2626   const [copied, setCopied] = createSignal<string | null>(null);27+  const [showCardNumber, setShowCardNumber] = createSignal(false);28+  const [showCardCvc, setShowCardCvc] = createSignal(false);2729   const [totpTick, setTotpTick] = createSignal(0);2830   const [confirmDelete, setConfirmDelete] = createSignal(false);2931   const [totpHot, setTotpHot] = createSignal(false);@@ -314,7 +316,7 @@314316           {(entry) => (315317             <>316318               {/* ── Breach strip ─────────────────────────────────── */}317-              <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>319+              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>318320                 <div class="peach-breach-badge">319321                   <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">320322                     <path d="M6 1L11 10H1L6 1z" />@@ -418,7 +420,7 @@418420               </div>419421 420422               {/* ── Domain / URL ─────────────────────────────────── */}421-              <Show when={entry().type !== "note"}>423+              <Show when={entry().type !== "note" && entry().type !== "card"}>422424                 <div class="peach-domain" style="position:relative;z-index:1;">423425                   <Show424426                     when={editingField() === "url"}@@ -483,8 +485,134 @@483485                 </section>484486               </Show>485487 488+              {/* ── Card detail view ─────────────────────────────── */}489+              <Show when={entry().type === "card"}>490+                <section class="peach-fields" style="position:relative;z-index:1;">491+                  {/* Card subtitle: •••• last4 */}492+                  <Show when={entry().last4}>493+                    <div class="peach-field-card" style="padding-bottom:4px;">494+                      <span class="peach-label">495+                        <Show when={entry().network && entry().network !== "unknown"}>496+                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>497+                        </Show>498+                        {entry().cardNickname || `•••• ${entry().last4}`}499+                      </span>500+                    </div>501+                  </Show>502+503+                  {/* Cardholder name */}504+                  <div class="peach-field-card">505+                    <span class="peach-label">cardholder name</span>506+                    <div class="peach-field-value-row">507+                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>508+                      <Show when={entry().cardholderName}>509+                        <button510+                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}511+                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}512+                          aria-label="Copy cardholder name"513+                        >514+                          <CopyGlyph /><span class="peach-copy-sheen" />515+                        </button>516+                      </Show>517+                    </div>518+                  </div>519+520+                  {/* Card number */}521+                  <div class="peach-field-card">522+                    <span class="peach-label">card number</span>523+                    <div class="peach-field-value-row">524+                      <span class={`peach-field-value peach-card-number`}>525+                        <Show when={showCardNumber()} fallback={526+                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>527+                        }>528+                          <span>{entry().cardNumber ?? ""}</span>529+                        </Show>530+                      </span>531+                      <div class="peach-field-actions">532+                        <button533+                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"534+                          onClick={() => setShowCardNumber(!showCardNumber())}535+                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}536+                        >537+                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>538+                            <EyeClosedGlyph />539+                          </Show>540+                        </button>541+                        <Show when={entry().cardNumber}>542+                          <button543+                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}544+                            onClick={(e) => {545+                              const cardNum = entry().cardNumber!;546+                              if (entry().canary?.webhookUrl) {547+                                void sendMessage({548+                                  type: "FIRE_CANARY_PING",549+                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },550+                                });551+                              }552+                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);553+                            }}554+                            aria-label="Copy card number"555+                          >556+                            <CopyGlyph /><span class="peach-copy-sheen" />557+                          </button>558+                        </Show>559+                      </div>560+                    </div>561+                  </div>562+563+                  {/* Expiry */}564+                  <Show when={entry().expMonth || entry().expYear}>565+                    <div class="peach-field-card">566+                      <span class="peach-label">expiry</span>567+                      <div class="peach-field-value-row">568+                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>569+                        <button570+                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}571+                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}572+                          aria-label="Copy expiry"573+                        >574+                          <CopyGlyph /><span class="peach-copy-sheen" />575+                        </button>576+                      </div>577+                    </div>578+                  </Show>579+580+                  {/* CVC */}581+                  <Show when={entry().cvc}>582+                    <div class="peach-field-card">583+                      <span class="peach-label">CVC</span>584+                      <div class="peach-field-value-row">585+                        <span class="peach-field-value">586+                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>587+                            <span>{entry().cvc}</span>588+                          </Show>589+                        </span>590+                        <div class="peach-field-actions">591+                          <button592+                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"593+                            onClick={() => setShowCardCvc(!showCardCvc())}594+                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}595+                          >596+                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>597+                              <EyeClosedGlyph />598+                            </Show>599+                          </button>600+                          <button601+                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}602+                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}603+                            aria-label="Copy CVC"604+                          >605+                            <CopyGlyph /><span class="peach-copy-sheen" />606+                          </button>607+                        </div>608+                      </div>609+                    </div>610+                  </Show>611+                </section>612+              </Show>613+486614               {/* ── Login fields ─────────────────────────────────── */}487-              <Show when={entry().type !== "note"}>615+              <Show when={entry().type !== "note" && entry().type !== "card"}>488616                 <section class="peach-fields" style="position:relative;z-index:1;">489617 490618                   {/* Username */}@@ -631,7 +759,7 @@631759               </Show>632760 633761               {/* ── Notes (on login entries) ──────────────────────── */}634-              <Show when={entry().type !== "note"}>762+              <Show when={entry().type !== "note" && entry().type !== "card"}>635763                 <Show636764                   when={entry().notes || editingField() === "notes"}637765                   fallback={

view file

src/popup/views/Settings.tsx+28 −1
@@ -58,6 +58,9 @@5858   const [dedupRunning, setDedupRunning] = createSignal(false);5959   const [dedupDone, setDedupDone] = createSignal<number | null>(null);6060 61+  // Declined card fingerprints62+  const [declinedCardCount, setDeclinedCardCount] = createSignal(0);63+6164   // Change master password state6265   const [showChangePw, setShowChangePw] = createSignal(false);6366   const [changePwCurrent, setChangePwCurrent] = createSignal("");@@ -99,11 +102,12 @@99102   const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);100103 101104   onMount(async () => {102-    const [licenseRes, syncRes, passkeyRes, duressRes] = await Promise.all([105+    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([103106       sendMessage({ type: "GET_LICENSE" }),104107       sendMessage({ type: "GET_SYNC_STATE" }),105108       sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),106109       sendMessage({ type: "IS_DURESS_ENABLED" }),110+      sendMessage({ type: "GET_SETTINGS" }),107111     ]);108112 109113     if ((licenseRes as { ok: boolean }).ok) {@@ -137,6 +141,11 @@137141       setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);138142     }139143 144+    if ((settingsRes as { ok: boolean }).ok) {145+      const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;146+      setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);147+    }148+140149     chrome.runtime.onMessage.addListener((msg) => {141150       if (msg.type === "SYNC_STATE_UPDATE") {142151         setPairedDevices(msg.pairedDevices ?? []);@@ -301,6 +310,11 @@301310     }302311   }303312 313+  async function handleClearDeclinedCards() {314+    await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });315+    setDeclinedCardCount(0);316+  }317+304318   async function handleCountDuplicates() {305319     setDedupRunning(true);306320     setDedupDone(null);@@ -608,6 +622,19 @@608622             </a>609623           </div>610624         </div>625+626+        {/* ── Cards ─────────────────────────────────────────────────────── */}627+        <Show when={declinedCardCount() > 0}>628+          <div class="peach-settings-group">629+            <p class="peach-settings-label">cards</p>630+            <div class="peach-settings-row peach-settings-row--meta">631+              <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>632+              <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>633+                Clear634+              </button>635+            </div>636+          </div>637+        </Show>611638 612639         {/* ── Duress password ─────────────────────────────────────────────── */}613640         <div class="peach-settings-group">

view file

src/shared/types.ts+23 −1
@@ -11,9 +11,11 @@1111   pingCount?: number;1212 }1313 14+export type CardNetwork = "visa" | "mastercard" | "amex" | "discover" | "unknown";15+1416 export interface VaultEntry {1517   id: string; // crypto.randomUUID()16-  type?: "login" | "note" | "passkey"; // defaults to "login" for existing entries18+  type?: "login" | "note" | "passkey" | "card"; // defaults to "login" for existing entries1719   title: string;1820   username?: string; // only for login entries1921   password?: string; // only for login entries@@ -40,6 +42,15 @@4042   backupEligible?: boolean;4143   backupState?: boolean;4244   lastUsedAt?: number;45+  // Card entry fields46+  cardholderName?: string;47+  cardNumber?: string;       // full PAN, stored encrypted in vault48+  expMonth?: string;49+  expYear?: string;50+  cvc?: string;51+  cardNickname?: string;52+  network?: CardNetwork;53+  last4?: string;4354 }4455 4556 export interface LegacyVaultEntry {@@ -56,6 +67,15 @@5667   deletedAt?: number;5768 }5869 70+export interface CardEntry extends VaultEntry {71+  type: "card";72+  cardholderName: string;73+  cardNumber: string;74+  expMonth: string;75+  expYear: string;76+  cvc: string;77+}78+5979 export interface PasskeyEntry extends VaultEntry {6080   type: "passkey";6181   rpId: string;@@ -156,6 +176,8 @@156176   | "COUNT_DUPLICATES"157177   | "DEDUPLICATE_ENTRIES"158178   | "RECORD_MANUAL_SUMMON"179+  | "DECLINE_CARD_SAVE"180+  | "IS_CARD_DECLINED"159181   // Content-script-bound message types — sent BY the background TO content160182   // scripts. They never reach messages.ts's switch. Kept in this union so161183   // existing call sites type-check; do not add cases for them in the

view file