1# Peach — Gap Closure Spec · v1.3 baseline2**Generated:** 2026-04-20 3**Current version:** 1.2.2 4**Scope:** All features promised on peachpasswords.com that are not yet implemented, plus the licensing system required to sell anything. Peach Sync is covered in `peach_sync_spec.md`.56---78## Guiding principle910Every gap in this document exists because someone read the website and formed a reasonable expectation. Closing these gaps is not optional polish — it is the difference between a product that delivers on its promises and one that doesn't. The standard remains: seamless, magical, snappy, brilliant.1112---1314## Version bump rule1516Bump `version` in `package.json`, `manifest.json`, and `package-lock.json` after every task. Tasks that add user-facing features get a MINOR bump; tasks that close copy/infrastructure gaps get a PATCH bump.1718---1920## Task 0 — Website copy fixes *(non-code, do immediately)*2122### Why it matters23Two install options on `peachpasswords.com/install` are marked `"available now"` and link to real store pages for products that do not exist. This is a live false advertising claim.2425### Changes required in `peachpasswords/src/data/site.ts`2627**Firefox add-on** — change `note` from `'browser add-on / available now'` to `'browser add-on / coming soon'`. Change `ctaLabel` to `'Notify me'` and `href` to `'#notify'` or a mailing list form.2829**Android app** — change `note` from `'mobile app / available now'` to `'mobile app / coming soon'`. Same CTA treatment as Firefox.3031No other copy changes needed at this time — everything else on the site is either correctly marked "coming soon" or is genuinely implemented.3233---3435## Task 1 — Licensing system *(target: v1.3.0)*3637### Why it matters38The FAQ describes the licensing system in concrete terms: Ed25519 key, delivered by email within 60 seconds, validates locally without a server call. None of this exists. Nothing can be sold until it does.3940### Architecture4142**Key format**4344A Peach Pro license key is a self-contained, offline-verifiable token. No server call is required to validate it — the extension carries the public key in its source.4546```47Key string format:48 PPK-<segment-1>-<segment-2>-<segment-3>-<segment-4>-<segment-5>4950Where the concatenated segments are base58-encoded bytes of:51 [0..11] Payload:52 byte 0: version = 0x0153 byte 1: product = 0x01 (peach-pro)54 byte 2: plan = 0x01 (lifetime)55 bytes 3..6: issued = Unix timestamp / 86400 (uint32 LE, day precision)56 bytes 7..11: order = 5-byte random nonce (makes each key unique)57 [12..75] Signature: Ed25519 signature over bytes [0..11] (64 bytes)58```5960Total: 76 bytes → base58 → ~103 chars → split into 5 groups of ~20 chars with `-` separators. 61Example: `PPK-3vQtRmKjA8Fnx2YwLp-uHsD6bCeM9WZRN1-kXoT4qBPVfGaJ7-yEhUiS5rWcv2n3-dKmN8pFZtLxQ`6263**Key generation (server-side)**6465Lives in a small Node.js or Rust service invoked after payment confirmation:66```ts67import { sign } from "@noble/ed25519";6869const PRIVATE_KEY = process.env.PEACH_ED25519_PRIVATE_KEY; 7071async function generateLicenseKey(orderId: string): Promise<string> {72 const payload = new Uint8Array(12);73 payload[0] = 0x01; 74 payload[1] = 0x01; 75 payload[2] = 0x01; 76 new DataView(payload.buffer).setUint32(3, Math.floor(Date.now() / 86400000), true);77 crypto.getRandomValues(payload.subarray(7, 12));7879 const sig = await sign(payload, PRIVATE_KEY);80 const combined = new Uint8Array(76);81 combined.set(payload, 0);82 combined.set(sig, 12);8384 return "PPK-" + toBase58Groups(combined, 5);85}86```8788The public key (32 bytes) is hardcoded in the extension. The private key never leaves the license server.8990**Key validation (extension-side)**9192New file: `src/background/license.ts`9394```ts959697const PUBLIC_KEY_HEX = "..."; 9899export interface LicenseInfo {100 valid: boolean;101 plan: "free" | "pro";102 issued?: Date;103}104105export async function validateLicense(keyString: string): Promise<LicenseInfo> {106 try {107 const bytes = fromBase58Groups(keyString.replace(/^PPK-/, ""));108 if (bytes.length !== 76) return { valid: false, plan: "free" };109110 const payload = bytes.subarray(0, 12);111 const sig = bytes.subarray(12, 76);112113 const version = payload[0];114 const product = payload[1];115 const plan = payload[2];116117 if (version !== 0x01 || product !== 0x01) return { valid: false, plan: "free" };118119 const publicKey = await crypto.subtle.importKey(120 "raw", hexToBytes(PUBLIC_KEY_HEX),121 { name: "Ed25519" }, false, ["verify"]122 );123124 const ok = await crypto.subtle.verify("Ed25519", publicKey, sig, payload);125 if (!ok) return { valid: false, plan: "free" };126127 const daysSinceEpoch = new DataView(payload.buffer).getUint32(3, true);128 const issued = new Date(daysSinceEpoch * 86400000);129130 return { valid: true, plan: plan === 0x01 ? "pro" : "free", issued };131 } catch {132 return { valid: false, plan: "free" };133 }134}135136export async function getLicenseInfo(): Promise<LicenseInfo> {137 const stored = await chrome.storage.local.get("peach_license");138 if (!stored.peach_license) return { valid: false, plan: "free" };139 return validateLicense(stored.peach_license as string);140}141142export async function activateLicense(keyString: string): Promise<LicenseInfo> {143 const info = await validateLicense(keyString);144 if (info.valid) {145 await chrome.storage.local.set({ peach_license: keyString });146 }147 return info;148}149```150151**`src/shared/types.ts`** — add `ACTIVATE_LICENSE`, `GET_LICENSE` to `MessageType`.152153**`src/background/messages.ts`** — add handlers:154- `ACTIVATE_LICENSE`: calls `activateLicense(payload.key)`, returns `LicenseInfo`155- `GET_LICENSE`: calls `getLicenseInfo()`, returns `LicenseInfo`156157**Feature gating**158159A thin helper:160```ts161export async function isProUser(): Promise<boolean> {162 const info = await getLicenseInfo();163 return info.valid && info.plan === "pro";164}165```166167Pro-gated features for v1.3: Peach Sync (future), passkeys (future). All current features remain free. The gating infrastructure must exist before Pro features ship — wire it up now even if no features use it yet.168169**Settings UI**170171`src/popup/views/Settings.tsx` — add a "License" section:172- If free: input field "Enter license key" + "Activate" button → calls `ACTIVATE_LICENSE`, shows success/error173- If pro: shows "Peach Pro · activated [date]" with a muted key prefix display174- Link: "Purchase →" opening `https://peachpasswords.com/#pricing` in a new tab175176**Payment infrastructure** *(outside the extension — server/backend work)*177178- Stripe Checkout for card payments: product configured as one-time $39, webhook on `checkout.session.completed` invokes `generateLicenseKey()` and sends email179- Coinbase Commerce for crypto: webhook on `charge:confirmed`, same path180- Email delivery: transactional email (Postmark or Resend) with the key in plaintext and a copy-paste-friendly format181- Re-send endpoint: `POST /api/resend-license { orderRef }` — rate-limited, looks up order and resends email182183**Acceptance criteria**184- `validateLicense()` returns `{ valid: true, plan: "pro" }` for a correctly signed key185- `validateLicense()` returns `{ valid: false, plan: "free" }` for a tampered, truncated, or unknown key186- Activation persists across service worker restarts (stored in `chrome.storage.local`)187- Settings view shows correct state for free and pro users188- `npm run typecheck` clean; `npm test` includes at least 4 license validation unit tests189190---191192## Task 2 — Canary entries *(target: v1.3.1)*193194### What the website promises195*"Decoy credentials can alert you if an exfiltrated vault is being explored by someone else."* — `securityFeatureNotes` on the security/features page.196197### How it works198199A canary entry is a vault entry marked as a honeypot. If your vault is stolen (raw storage exfiltrated), an attacker exploring the credentials will eventually try to copy or use a canary password. When that happens, Peach silently fires a webhook so you know the breach is active.200201The alert mechanism is user-configured: the user provides a webhook URL (Zapier, Make, a self-hosted endpoint, or Peach's optional hosted canary service). Peach makes zero outbound connections on its own — the webhook URL is entirely under user control.202203### Data model204205**`src/shared/types.ts`** — add to `VaultEntry`:206```ts207canary?: {208 webhookUrl: string; 209 label?: string; 210 lastPinged?: number; 211 pingCount?: number; 212};213```214215### Trigger conditions216217A canary ping fires when **any** of these happen to an entry with `entry.canary` set:2181. The password is copied from the popup (entry detail copy button)2192. The username is copied from the popup2203. The content script autofills the entry into a form (fill event)2214. The content script detects the canary password being submitted (`save.ts` detects form submission containing a known password)222223The ping is fire-and-forget. It must not block the UI and must not reveal to the user that a ping fired (the attacker may be watching). It must not fire when the legitimate user is actively authenticated — i.e., only fire when the vault is unlocked AND the canary entry is accessed.224225Wait — there is an obvious problem: if the legitimate user copies a canary password, the ping fires on them too. The solution: a "this is me" suppression UI. In the popup entry detail for a canary entry, show a small "Suppress canary ping" toggle that the user can activate before copying. When suppressed, no ping is sent for that session's copy actions on that entry. The toggle auto-resets on vault lock.226227**`src/background/messages.ts`** — add `FIRE_CANARY_PING`:228```ts229case "FIRE_CANARY_PING": {230 const { webhookUrl, entryTitle, trigger } = payload;231 232 fetch(webhookUrl, {233 method: "POST",234 headers: { "Content-Type": "application/json" },235 body: JSON.stringify({236 event: "peach_canary_accessed",237 entry: entryTitle,238 trigger, 239 timestamp: new Date().toISOString(),240 source: "peach-extension",241 }),242 }).catch(() => {}); 243 break;244}245```246247### UI248249**Entry detail view (`Entry.tsx`)** 250When `entry.canary` is set:251- Show a small "🪤 canary" badge next to the entry title (styled like the breach badge but amber/warning-coloured)252- Show "Suppress ping this session" checkbox before the copy buttons253- Entry header background: very subtle amber tint to distinguish at a glance254255**Add/Edit entry view (`Add.tsx`)** 256New collapsible "Canary settings" section at the bottom of the entry form:257- Toggle: "Mark as canary entry"258- When toggled on: webhook URL input field + optional label field259- Webhook URL validation: must be a valid `https://` URL260- Helper text: "Peach will silently ping this URL if this entry is ever accessed. Use a Zapier/Make webhook, or any endpoint you control."261- Test button: sends a test ping immediately so user can verify the webhook works262263**Vault list** 264Canary entries show the 🪤 indicator in the list row.265266**Add entry creation shortcut** 267Settings → Security → "Add canary entry" → opens Add view with canary section pre-expanded.268269### Acceptance criteria270- A canary entry's webhook is called when the password is copied and the ping is not suppressed271- The ping is NOT called when the suppress toggle is active272- The ping payload contains `event`, `trigger`, `timestamp`, `source`273- Canary entries are visually distinguishable in the vault list and detail view274- `npm run typecheck` clean275276---277278## Task 3 — Duress password *(target: v1.3.2)*279280### What the website promises281*"A second master password opens a decoy vault for border crossings and coercive situations."* — `securityFeatureNotes`.282283### How it works284285The user sets a second master password ("duress password"). When someone is forced to unlock Peach under coercion, they enter the duress password instead of the real one. Peach unlocks normally — same UI, same animation — but loads a separate decoy vault containing whatever fake entries the user has placed there. The real vault is never touched.286287This must be **completely invisible** from the unlock UI. There is no "duress mode" indicator, no different unlock animation, no sign that a decoy was loaded. The only difference is which vault is decrypted.288289### Storage290291Main vault: `peach_vault` (existing) 292Decoy vault: `peach_decoy_vault` (same `StoredVault` format as `peach_vault`) 293Duress indicator: `peach_duress` = `{ enabled: boolean }` — does NOT store the password or a hash of it. The "duress password" can only be inferred by successfully decrypting `peach_decoy_vault`.294295### Unlock logic change296297**`src/background/vault.ts`** — modify `unlock(password: string)`:298299```ts300export async function unlock(password: string): Promise<boolean> {301 302 const mainVault = await loadStoredVault("peach_vault");303 if (mainVault) {304 const key = await deriveKey(password, mainVault.salt);305 const ok = await tryDecrypt(mainVault, key);306 if (ok) {307 derivedKey = key;308 vaultMode = "real";309 await loadVaultEntries();310 await loadSettings();311 return true;312 }313 }314315 316 const duressConfig = await chrome.storage.local.get("peach_duress");317 if (duressConfig.peach_duress?.enabled) {318 const decoyVault = await loadStoredVault("peach_decoy_vault");319 if (decoyVault) {320 const key = await deriveKey(password, decoyVault.salt);321 const ok = await tryDecrypt(decoyVault, key);322 if (ok) {323 derivedKey = key;324 vaultMode = "decoy";325 await loadVaultEntries("peach_decoy_vault");326 await loadSettings();327 return true;328 }329 }330 }331332 return false;333}334```335336When `vaultMode === "decoy"`:337- All `addEntry`, `updateEntry`, `deleteEntry`, `getEntries` operations target `peach_decoy_vault`338- The popup is completely unaware — it receives entries and saves them exactly as it would in normal mode339- The decoy vault has its own encryption salt and nonce340341### Setup flow342343**Settings → Security → "Duress password"** (Pro feature):344345Step 1: User enters their **current** main master password to authenticate the setup (prevents someone setting a duress password without vault access). 346Step 2: User enters and confirms the duress password. zxcvbn strength meter shown. Minimum score 2 required. 347Step 3: Peach creates `peach_decoy_vault` with the duress password (empty vault, or user can be prompted to add some fake entries now). 348Step 4: `peach_duress = { enabled: true }` written to storage.349350Show a warning: *"Your duress password opens a separate empty vault. Add a few plausible-looking fake entries before you need it."*351352**Decoy vault management** 353When in duress mode, the user can add/edit/delete entries in the decoy vault just like the real one. From their perspective, everything looks identical. The only way to return to the real vault is to lock and unlock with the real password.354355**Disabling duress** 356Settings → Security → "Duress password" → "Disable" → requires entering the current main master password.357358### Acceptance criteria359- Unlocking with the main password loads `peach_vault`360- Unlocking with the duress password loads `peach_decoy_vault`361- Unlocking with an incorrect password fails for both vaults362- No visual difference in the popup between real and decoy mode363- Disabling duress requires the main master password364- `npm run typecheck` clean365366---367368## Task 4 — Secure notes *(target: v1.3.3)*369370### What the website promises371Free tier pricing card: *"Unlimited passwords & secure notes"*372373### Data model change374375**`src/shared/types.ts`** — add `type` field to `VaultEntry`:376```ts377export interface VaultEntry {378 id: string;379 type: "login" | "note"; 380 title: string;381 382 username?: string;383 password?: string;384 url?: string;385 totp?: string;386 387 notes?: string;388 breach?: number;389 createdAt: number;390 modifiedAt: number;391 deletedAt?: number;392 canary?: { webhookUrl: string; label?: string; lastPinged?: number; pingCount?: number; };393 tags?: string[]; 394}395```396397For `type: "note"`, only `title` and `notes` are meaningful. `username`, `password`, `url`, `totp`, and `breach` are undefined. Notes entries are never breach-checked.398399### Migration400401On `unlock()`, migrate all existing entries: any entry without a `type` field gets `type: "login"`. No re-encryption needed — just a normalisation pass before writing back.402403### UI changes404405**Vault list (`Vault.tsx`)** 406Note entries show a document/note icon instead of the key icon. The `username` meta line shows the first 40 chars of the note content instead.407408**Add entry view (`Add.tsx`)** 409Add a type selector at the top: two tabs — `Login` and `Note`. The `Login` tab is the existing form. The `Note` tab shows only `Title` and a large `Content` textarea. No URL, username, password, or TOTP fields.410411**Entry detail view (`Entry.tsx`)** 412For note entries: show title, full note content (scrollable, selectable), copy-content button. No password field, no breach badge, no TOTP.413414**Edit flow** 415Editing a note entry shows the same two-tab form with `Note` tab pre-selected and type locked (no switching a login to a note or vice versa after creation — entries have a fixed type).416417**Search** 418Note entries are included in search results. The fuzzy match runs against `entry.title + " " + entry.notes`.419420### Acceptance criteria421- Creating a note entry stores `type: "note"` with title and content, no password422- Note entries appear in the vault list with the correct icon423- Existing login entries are unaffected and migrate cleanly424- Search finds note entries by title and content425- `npm run typecheck` clean; `npm test` updated426427---428429## Task 5 — One-tap breach password regeneration *(target: v1.3.4)*430431### What the website promises432Features page: *"Compromised entries are flagged with one-tap regeneration."*433434### Implementation435436**`src/popup/views/Entry.tsx`**437438In the breach badge section (currently shows count and "BREACHED" label), add a "Regenerate password" button rendered inline next to the badge. Clicking it:4394401. Opens a mini generator panel that slides in below the breach badge (not a full overlay — keep the user on the entry detail)4412. Pre-fills generator settings from the current password (infer length, charset)4423. Shows a "Generate" button and a preview of the new password4434. On accept: calls `UPDATE_ENTRY` with the new password, fires a fresh HIBP check, updates `modifiedAt`4445. Shows a success state: "Password updated — change it on [site] next"445446The mini generator panel uses the existing `Generator` component logic but rendered inline, not in the overlay. It should be dismissable (close button, Escape key).447448A "Copy new password" button is available so the user can immediately go update it on the site.449450**`src/popup/views/Entry.tsx`** — add `showRegenPanel` signal (`createSignal(false)`) toggled by the regenerate button.451452### Acceptance criteria453- The regenerate button is visible only when `entry.breach > 0`454- Accepting a generated password updates the entry and clears the breach count (a fresh HIBP check runs)455- The panel is dismissable without saving456- `npm run typecheck` clean457458---459460## Task 6 — QR code for CJK phrase *(target: v1.3.5)*461462### What the website promises463Homepage hero section: *"Your entire vault, in a QR code."* — a full section with `CjkQr.astro` displaying a QR as a primary product visual.464465### Reality constraint466For vaults up to ~80 entries, the CJK phrase fits in a single QR (Version 40, ECC L, ~2,953 bytes). For larger vaults it does not. The extension must handle both cases honestly.467468### Implementation469470**Dependency**471```bash472npm install qrcode473npm install --save-dev @types/qrcode474```475Import dynamically (`const { default: QRCode } = await import("qrcode")`) so it does not land in the initial popup chunk.476477**`src/popup/views/CJKExport.tsx`**478479After the CJK phrase is generated and displayed, add a "Show as QR" button:480481```ts482async function showQR(phrase: string) {483 const byteLen = new TextEncoder().encode(phrase).length;484 if (byteLen > 2800) {485 486 setQrState("too-large");487 return;488 }489 const { default: QRCode } = await import("qrcode");490 const dataUrl = await QRCode.toDataURL(phrase, {491 errorCorrectionLevel: "L",492 width: 320,493 margin: 2,494 color: { dark: "#E8A87C", light: "#00000000" }, 495 });496 setQrDataUrl(dataUrl);497 setQrState("ready");498}499```500501When `qrState === "too-large"`: render a note — *"Your vault is too large to fit in a single QR code. Copy the phrase above and paste it into Peach on your other device."*502503When `qrState === "ready"`: show the QR image in an overlay modal (same pattern as other overlays) with:504- Label: "Scan with Peach on another device"505- Instruction: "Open Peach → Import → Restore from CJK phrase"506- The QR image centred, 320×320 px507- A close button (Escape also closes)508509**`src/popup/views/Entry.tsx`** — TOTP QR (separate, always small)510511In the TOTP display row, add a QR icon button. On click, generates `otpauth://totp/<title>?secret=<totp>&issuer=<domain>` and shows it in a modal overlay. Always fits in a QR — no size guard needed.512513### Acceptance criteria514- A vault with ≤ 80 entries shows a working QR that another Peach instance can scan and import515- A vault > 80 entries shows the "too large" fallback message516- TOTP QR is scannable by Google Authenticator and Aegis517- `qrcode` module does not appear in the main popup chunk (`npm run build` verified)518519---520521## Task 7 — Smart password generation with site policy detection *(target: v1.3.6)*522523### What the website promises524Features page: *"Peach detects password requirements on the page — minimum length, required special characters, maximum length, no symbols — and automatically generates the strongest password the site supports. No manual settings. No guessing."*525526### Content script changes527528**`src/content/index.ts`** — add `detectPasswordPolicy()`:529530```ts531interface PasswordPolicy {532 minLength?: number;533 maxLength?: number;534 requiresUppercase?: boolean;535 requiresLowercase?: boolean;536 requiresNumbers?: boolean;537 requiresSymbols?: boolean;538 forbidsSymbols?: boolean;539 allowedSymbols?: string;540}541542function detectPasswordPolicy(doc: Document = document): PasswordPolicy {543 const policy: PasswordPolicy = {};544545 546 const passwordField = doc.querySelector<HTMLInputElement>(547 'input[autocomplete="new-password"], input[type="password"]'548 );549 if (passwordField?.maxLength > 0) policy.maxLength = passwordField.maxLength;550 if (passwordField?.pattern) {551 552 const pat = passwordField.pattern;553 const minMatch = pat.match(/\{(\d+),/);554 const maxMatch = pat.match(/,(\d+)\}/);555 if (minMatch) policy.minLength = parseInt(minMatch[1]);556 if (maxMatch) policy.maxLength = parseInt(maxMatch[1]);557 }558559 560 const requirementText = extractNearbyText(passwordField);561 const minMatch = requirementText.match(/(?:at least|minimum|min)[^\d]*(\d+)/i);562 const maxMatch = requirementText.match(/(?:at most|maximum|max|up to)[^\d]*(\d+)/i);563 if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]);564 if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]);565566 if (/no\s+(?:special|symbol)/i.test(requirementText)) policy.forbidsSymbols = true;567 if (/(?:special|symbol|punctuation)/i.test(requirementText)) policy.requiresSymbols = true;568 if (/(?:uppercase|capital)/i.test(requirementText)) policy.requiresUppercase = true;569 if (/number|digit/i.test(requirementText)) policy.requiresNumbers = true;570571 return policy;572}573574function extractNearbyText(el: HTMLInputElement | null): string {575 if (!el) return "";576 577 const describedBy = el.getAttribute("aria-describedby");578 if (describedBy) {579 const desc = document.getElementById(describedBy);580 if (desc) return desc.textContent ?? "";581 }582 let node: Element | null = el;583 for (let i = 0; i < 3; i++) {584 node = node?.parentElement ?? null;585 if (!node) break;586 const hints = node.querySelectorAll("p, span, li, small, .hint, .requirement, .password-rule");587 if (hints.length > 0) return Array.from(hints).map(h => h.textContent).join(" ");588 }589 return "";590}591```592593**New message type: `GET_SITE_PASSWORD_POLICY`**594595`src/shared/types.ts` — add to `MessageType`.596597The content script responds to this message with the detected `PasswordPolicy`. The background passes it through. The popup's Add/Edit view and Generator view request it when opening on a site that has an active password field.598599**`src/popup/views/Add.tsx`** and **`src/popup/components/Generator.tsx`**600601On mount, send `GET_SITE_PASSWORD_POLICY`. If a policy is returned:602- Pre-set generator min/max length to match603- Disable symbol characters if `forbidsSymbols === true`604- Enforce symbol characters if `requiresSymbols === true`605- Show a small "Detected site requirements" badge above the generator settings606- The user can still override — detected constraints are defaults, not locks607608### Acceptance criteria609- On a site with `<input type="password" maxlength="20">`, the generator defaults to max 20 characters610- On a site with visible text "Must be 8–24 characters", the generator defaults to 8–24611- On a site with visible text "No special characters", symbols are disabled612- `npm run typecheck` clean613614---615616## Task 8 — Tags *(target: v1.3.7)*617618### Why619900 entries in a flat list with only fuzzy search is not workable long-term. Tags let users group and filter without imposing a rigid folder hierarchy.620621### Data model622623`src/shared/types.ts` — `tags?: string[]` added to `VaultEntry` (already shown in Task 4's type definition).624625### UI626627**Add/Edit entry (`Add.tsx`)** 628Below the Notes field: a tag input. Typing and pressing Enter/comma adds a tag chip. Clicking a chip's × removes it. Auto-complete: as the user types, show existing tags from across the vault in a dropdown (fetched from all entries in memory — no new message needed).629630Tags stored lowercase, trimmed, max 32 chars each, max 10 tags per entry.631632**Vault list (`Vault.tsx`)** 633Below the search bar: a horizontal scrollable tag filter strip. Shows all unique tags in use across the vault, sorted alphabetically. Clicking a tag filters the list to entries containing that tag. Clicking it again deselects. Multiple tags are AND-filtered (entry must have all selected tags).634635The filter strip is only shown if at least one entry has a tag.636637**Entry detail (`Entry.tsx`)** 638Tags shown as read-only chips below the metadata block.639640**Search interaction** 641Search and tag filter compose: both are active simultaneously. A query of `"github"` with tag filter `"work"` shows entries matching the text AND tagged "work".642643### Acceptance criteria644- Adding tags to an entry and saving persists them645- The tag filter strip appears and correctly filters the vault list646- Search and tag filter compose correctly647- Existing entries without tags are unaffected648- `npm run typecheck` clean649650---651652## Task 9 — Password health dashboard *(target: v1.3.8)*653654### Why655HIBP breach counts are already stored per-entry. With 900 entries, a health overview is essential to know where to focus.656657### Entry point658Settings overlay → new "Vault Health" row → opens the health dashboard as a full overlay view.659660### Four health categories661662**Breached** — entries where `breach > 0`. Sorted by `breach` count descending (highest exposure first).663664**Reused** — entries sharing the same password value. Computed in-memory: SHA-256 each password, group by hash, return groups with count ≥ 2. Sorted by group size descending.665666**Weak** — entries where `zxcvbn(entry.password).score <= 1`. zxcvbn is already in the bundle. Sorted by score ascending.667668**Old** — entries where `modifiedAt < Date.now() - (365 * 24 * 60 * 60 * 1000)` (not changed in over a year). Sorted by `modifiedAt` ascending (oldest first). Only entries with `type: "login"` are included.669670### View layout671672`src/popup/views/Health.tsx` — new file.673674Four collapsible sections, each with a count badge:675676```677Vault Health678679[3] Breached ▾680 · GitHub 2,847 exposures [Fix →]681 · Twitter 541 exposures [Fix →]682 · OldSite 12 exposures [Fix →]683684[12] Reused ▾685 Used in 5 places:686 · Netflix, Hulu, DisneyPlus, HBOMax, Peacock [View →]687 Used in 3 places:688 · GitHub, GitLab, Bitbucket [View →]689 ...690691[47] Weak ▾692 · example.com score: 0 (very weak) [Fix →]693 ...694695[28] Old ▾696 · ancientsite.com last changed 3 years ago [Fix →]697 ...698```699700`[Fix →]` for breached and weak entries opens the entry detail with the regeneration panel pre-expanded. 701`[View →]` for reused groups opens a temporary filtered list showing all entries in the group. 702`[Fix →]` for old entries opens the entry detail for that entry.703704**Summary header** at the top: "N entries need attention" or "Your vault looks healthy." if all counts are zero.705706**"Check all breaches now"** button at the top — same as the existing `CHECK_ALL_BREACHES` message, triggers a fresh HIBP sweep and refreshes the dashboard when complete.707708### Performance note709All computation (hashing, scoring) is done in the background, not in the service worker. Send `GET_ENTRIES`, then compute in the popup component using `createMemo`. zxcvbn is synchronous; the SHA-256 reuse check uses `crypto.subtle.digest` (async, batch with `Promise.all`).710711### Acceptance criteria712- Dashboard shows correct counts for each category713- `[Fix →]` links navigate to the correct entry with regeneration panel open714- `[View →]` correctly groups and shows reused entries715- "Check all breaches" triggers a sweep and refreshes the view when complete716- `npm run typecheck` clean717718---719720## Task 10 — Legacy Peach JSON import *(target: v1.3.9)*721722### Status723It is unclear from CONTINUITY.md whether this made it into v1.2.2. Verify by checking whether `detectImportFormat()` in `src/shared/import.ts` handles `exportFormat === "peach-vault"`. If it does, this task is already done — mark it and skip. If not, implement it.724725### Implementation (if not already done)726727**`src/shared/import.ts`**7287291. Add `"peach-legacy-json"` to `ImportFormat` union.7302. Add to `detectImportFormat()`:731 ```ts732 if (envelope?.exportFormat === "peach-vault") return "peach-legacy-json";733 ```7343. Add `parsePeachLegacyJSON(raw: string): { entries: ImportableEntry[]; totalItems: number }`:735 - Filter `entry.type === "login"` only736 - Map: `name → title`, `login.urls[0] → url`, `login.username → username`, `login.password → password`, `login.totp?.secret → totp`, `login.notes → notes`, `created → createdAt`, `modified → modifiedAt`, `trashedAt → deletedAt`7374. Wire into `parseImportSource()` switch with `label: "Peach (legacy)"`, `requiresPassword: false`738739**Tests** — add to `src/shared/import.test.ts`:740- Parses valid legacy export, returns correct entries741- Skips non-login entries and reports them in `skippedItems`742- Handles missing optional fields without throwing743744---745746## Task 11 — Firefox extension *(target: v1.4.0)*747748### Why749The install page originally listed Firefox as "available now" (fixed in Task 0, but the promise remains implicit). Firefox represents ~3–5% of browser market share — a meaningful user segment.750751### Technical approach752753The codebase already uses:754- `@crxjs/vite-plugin` — Chrome-specific, uses `manifest.json` v3755- `chrome.*` APIs throughout (not `browser.*`)756- `chrome.storage`, `chrome.idle`, `chrome.runtime`, `chrome.sockets`757758Firefox uses Manifest V2 (MV2) and the `browser.*` namespace (though Firefox has added limited MV3 support).759760**Recommended approach:** Use `webextension-polyfill` (already in `package.json`) consistently throughout the codebase to replace all `chrome.*` calls with `browser.*` equivalents, then add a Firefox build target using `web-ext` instead of `@crxjs/vite-plugin`.761762This is a significant porting effort — a separate sprint. For now:7631. Task 0 fixes the website copy7642. This task is planned but not blocking765766### Known adaptation points767- `chrome.idle` API: Firefox equivalent is `browser.idle` ✓768- `chrome.storage.local`: Firefox equivalent via polyfill ✓769- `chrome.runtime.connectNative`: Firefox supports, but NMS host format differs770- `chrome.sockets.*`: Firefox has no equivalent — Peach Sync in Firefox will need a different transport (deferred to Sync spec)771- ONNX Runtime Web: should work in Firefox unchanged772- `chrome.action.openPopup()`: Firefox 57+ ✓773774---775776## Verification gate777778After all tasks are complete, the following must pass:779780```bash781npm run typecheck782npm test783npm run build784DISPLAY=:99 node test-extension.mjs785```786787Additionally:788- `npm test` must cover: license validation (≥4 tests), canary ping trigger logic (≥2 tests), secure note type isolation (≥2 tests), tag filter composition (≥2 tests), legacy import parsing (≥3 tests)789- CONTINUITY.md updated with `[OUTCOMES]` entries for all tasks completed790791---792793## Appendix — Feature/tier matrix794795| Feature | Free | Pro |796|---|---|---|797| Unlimited passwords & secure notes | ✓ | ✓ |798| Browser extension with autofill | ✓ | ✓ |799| Password generator + site policy detection | ✓ | ✓ |800| TOTP authenticator | ✓ | ✓ |801| Breach monitoring | ✓ | ✓ |802| Import from any manager | ✓ | ✓ |803| CJK phrase export & restore | ✓ | ✓ |804| Tags | ✓ | ✓ |805| Password health dashboard | ✓ | ✓ |806| Canary entries | ✓ | ✓ |807| Duress password | ✓ | ✓ |808| QR code for CJK phrase | ✓ | ✓ |809| Peach Sync | ✗ | ✓ |810| Android app | ✗ | ✓ |811| Passkeys | ✗ | ✓ (coming soon) |812813*Note: Canary and Duress are security features that should be free — putting them behind a paywall would send the wrong signal for a privacy-first product.*