# Peach — Gap Closure Spec · v1.3 baseline **Generated:** 2026-04-20 **Current version:** 1.2.2 **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`. --- ## Guiding principle Every 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. --- ## Version bump rule Bump `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. --- ## Task 0 — Website copy fixes *(non-code, do immediately)* ### Why it matters Two 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. ### Changes required in `peachpasswords/src/data/site.ts` **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. **Android app** — change `note` from `'mobile app / available now'` to `'mobile app / coming soon'`. Same CTA treatment as Firefox. No other copy changes needed at this time — everything else on the site is either correctly marked "coming soon" or is genuinely implemented. --- ## Task 1 — Licensing system *(target: v1.3.0)* ### Why it matters The 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. ### Architecture **Key format** A 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. ``` Key string format: PPK----- Where the concatenated segments are base58-encoded bytes of: [0..11] Payload: byte 0: version = 0x01 byte 1: product = 0x01 (peach-pro) byte 2: plan = 0x01 (lifetime) bytes 3..6: issued = Unix timestamp / 86400 (uint32 LE, day precision) bytes 7..11: order = 5-byte random nonce (makes each key unique) [12..75] Signature: Ed25519 signature over bytes [0..11] (64 bytes) ``` Total: 76 bytes → base58 → ~103 chars → split into 5 groups of ~20 chars with `-` separators. Example: `PPK-3vQtRmKjA8Fnx2YwLp-uHsD6bCeM9WZRN1-kXoT4qBPVfGaJ7-yEhUiS5rWcv2n3-dKmN8pFZtLxQ` **Key generation (server-side)** Lives in a small Node.js or Rust service invoked after payment confirmation: ```ts import { sign } from "@noble/ed25519"; const PRIVATE_KEY = process.env.PEACH_ED25519_PRIVATE_KEY; // 32-byte hex, secret async function generateLicenseKey(orderId: string): Promise { const payload = new Uint8Array(12); payload[0] = 0x01; // version payload[1] = 0x01; // product: peach-pro payload[2] = 0x01; // plan: lifetime new DataView(payload.buffer).setUint32(3, Math.floor(Date.now() / 86400000), true); crypto.getRandomValues(payload.subarray(7, 12)); const sig = await sign(payload, PRIVATE_KEY); const combined = new Uint8Array(76); combined.set(payload, 0); combined.set(sig, 12); return "PPK-" + toBase58Groups(combined, 5); } ``` The public key (32 bytes) is hardcoded in the extension. The private key never leaves the license server. **Key validation (extension-side)** New file: `src/background/license.ts` ```ts // Hardcoded Ed25519 public key — corresponds to the private key on the license server. // If the private key is rotated, a new public key must be shipped in an extension update. const PUBLIC_KEY_HEX = "..."; // 64-char hex string export interface LicenseInfo { valid: boolean; plan: "free" | "pro"; issued?: Date; } export async function validateLicense(keyString: string): Promise { try { const bytes = fromBase58Groups(keyString.replace(/^PPK-/, "")); if (bytes.length !== 76) return { valid: false, plan: "free" }; const payload = bytes.subarray(0, 12); const sig = bytes.subarray(12, 76); const version = payload[0]; const product = payload[1]; const plan = payload[2]; if (version !== 0x01 || product !== 0x01) return { valid: false, plan: "free" }; const publicKey = await crypto.subtle.importKey( "raw", hexToBytes(PUBLIC_KEY_HEX), { name: "Ed25519" }, false, ["verify"] ); const ok = await crypto.subtle.verify("Ed25519", publicKey, sig, payload); if (!ok) return { valid: false, plan: "free" }; const daysSinceEpoch = new DataView(payload.buffer).getUint32(3, true); const issued = new Date(daysSinceEpoch * 86400000); return { valid: true, plan: plan === 0x01 ? "pro" : "free", issued }; } catch { return { valid: false, plan: "free" }; } } export async function getLicenseInfo(): Promise { const stored = await chrome.storage.local.get("peach_license"); if (!stored.peach_license) return { valid: false, plan: "free" }; return validateLicense(stored.peach_license as string); } export async function activateLicense(keyString: string): Promise { const info = await validateLicense(keyString); if (info.valid) { await chrome.storage.local.set({ peach_license: keyString }); } return info; } ``` **`src/shared/types.ts`** — add `ACTIVATE_LICENSE`, `GET_LICENSE` to `MessageType`. **`src/background/messages.ts`** — add handlers: - `ACTIVATE_LICENSE`: calls `activateLicense(payload.key)`, returns `LicenseInfo` - `GET_LICENSE`: calls `getLicenseInfo()`, returns `LicenseInfo` **Feature gating** A thin helper: ```ts export async function isProUser(): Promise { const info = await getLicenseInfo(); return info.valid && info.plan === "pro"; } ``` Pro-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. **Settings UI** `src/popup/views/Settings.tsx` — add a "License" section: - If free: input field "Enter license key" + "Activate" button → calls `ACTIVATE_LICENSE`, shows success/error - If pro: shows "Peach Pro · activated [date]" with a muted key prefix display - Link: "Purchase →" opening `https://peachpasswords.com/#pricing` in a new tab **Payment infrastructure** *(outside the extension — server/backend work)* - Stripe Checkout for card payments: product configured as one-time $39, webhook on `checkout.session.completed` invokes `generateLicenseKey()` and sends email - Coinbase Commerce for crypto: webhook on `charge:confirmed`, same path - Email delivery: transactional email (Postmark or Resend) with the key in plaintext and a copy-paste-friendly format - Re-send endpoint: `POST /api/resend-license { orderRef }` — rate-limited, looks up order and resends email **Acceptance criteria** - `validateLicense()` returns `{ valid: true, plan: "pro" }` for a correctly signed key - `validateLicense()` returns `{ valid: false, plan: "free" }` for a tampered, truncated, or unknown key - Activation persists across service worker restarts (stored in `chrome.storage.local`) - Settings view shows correct state for free and pro users - `npm run typecheck` clean; `npm test` includes at least 4 license validation unit tests --- ## Task 2 — Canary entries *(target: v1.3.1)* ### What the website promises *"Decoy credentials can alert you if an exfiltrated vault is being explored by someone else."* — `securityFeatureNotes` on the security/features page. ### How it works A 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. The 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. ### Data model **`src/shared/types.ts`** — add to `VaultEntry`: ```ts canary?: { webhookUrl: string; // user-configured endpoint to ping label?: string; // optional note, e.g. "AWS root credentials decoy" lastPinged?: number; // Date.now() of most recent ping pingCount?: number; // total pings sent }; ``` ### Trigger conditions A canary ping fires when **any** of these happen to an entry with `entry.canary` set: 1. The password is copied from the popup (entry detail copy button) 2. The username is copied from the popup 3. The content script autofills the entry into a form (fill event) 4. The content script detects the canary password being submitted (`save.ts` detects form submission containing a known password) The 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. Wait — 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. **`src/background/messages.ts`** — add `FIRE_CANARY_PING`: ```ts case "FIRE_CANARY_PING": { const { webhookUrl, entryTitle, trigger } = payload; // fire-and-forget, no await at call site fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ event: "peach_canary_accessed", entry: entryTitle, trigger, // "password_copied" | "username_copied" | "autofilled" | "submitted" timestamp: new Date().toISOString(), source: "peach-extension", }), }).catch(() => {}); // silent failure — never surface errors break; } ``` ### UI **Entry detail view (`Entry.tsx`)** When `entry.canary` is set: - Show a small "🪤 canary" badge next to the entry title (styled like the breach badge but amber/warning-coloured) - Show "Suppress ping this session" checkbox before the copy buttons - Entry header background: very subtle amber tint to distinguish at a glance **Add/Edit entry view (`Add.tsx`)** New collapsible "Canary settings" section at the bottom of the entry form: - Toggle: "Mark as canary entry" - When toggled on: webhook URL input field + optional label field - Webhook URL validation: must be a valid `https://` URL - Helper text: "Peach will silently ping this URL if this entry is ever accessed. Use a Zapier/Make webhook, or any endpoint you control." - Test button: sends a test ping immediately so user can verify the webhook works **Vault list** Canary entries show the 🪤 indicator in the list row. **Add entry creation shortcut** Settings → Security → "Add canary entry" → opens Add view with canary section pre-expanded. ### Acceptance criteria - A canary entry's webhook is called when the password is copied and the ping is not suppressed - The ping is NOT called when the suppress toggle is active - The ping payload contains `event`, `trigger`, `timestamp`, `source` - Canary entries are visually distinguishable in the vault list and detail view - `npm run typecheck` clean --- ## Task 3 — Duress password *(target: v1.3.2)* ### What the website promises *"A second master password opens a decoy vault for border crossings and coercive situations."* — `securityFeatureNotes`. ### How it works The 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. This 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. ### Storage Main vault: `peach_vault` (existing) Decoy vault: `peach_decoy_vault` (same `StoredVault` format as `peach_vault`) Duress 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`. ### Unlock logic change **`src/background/vault.ts`** — modify `unlock(password: string)`: ```ts export async function unlock(password: string): Promise { // Try main vault const mainVault = await loadStoredVault("peach_vault"); if (mainVault) { const key = await deriveKey(password, mainVault.salt); const ok = await tryDecrypt(mainVault, key); if (ok) { derivedKey = key; vaultMode = "real"; await loadVaultEntries(); await loadSettings(); return true; } } // Try decoy vault (only if duress is configured) const duressConfig = await chrome.storage.local.get("peach_duress"); if (duressConfig.peach_duress?.enabled) { const decoyVault = await loadStoredVault("peach_decoy_vault"); if (decoyVault) { const key = await deriveKey(password, decoyVault.salt); const ok = await tryDecrypt(decoyVault, key); if (ok) { derivedKey = key; vaultMode = "decoy"; await loadVaultEntries("peach_decoy_vault"); await loadSettings(); return true; } } } return false; } ``` When `vaultMode === "decoy"`: - All `addEntry`, `updateEntry`, `deleteEntry`, `getEntries` operations target `peach_decoy_vault` - The popup is completely unaware — it receives entries and saves them exactly as it would in normal mode - The decoy vault has its own encryption salt and nonce ### Setup flow **Settings → Security → "Duress password"** (Pro feature): Step 1: User enters their **current** main master password to authenticate the setup (prevents someone setting a duress password without vault access). Step 2: User enters and confirms the duress password. zxcvbn strength meter shown. Minimum score 2 required. Step 3: Peach creates `peach_decoy_vault` with the duress password (empty vault, or user can be prompted to add some fake entries now). Step 4: `peach_duress = { enabled: true }` written to storage. Show a warning: *"Your duress password opens a separate empty vault. Add a few plausible-looking fake entries before you need it."* **Decoy vault management** When 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. **Disabling duress** Settings → Security → "Duress password" → "Disable" → requires entering the current main master password. ### Acceptance criteria - Unlocking with the main password loads `peach_vault` - Unlocking with the duress password loads `peach_decoy_vault` - Unlocking with an incorrect password fails for both vaults - No visual difference in the popup between real and decoy mode - Disabling duress requires the main master password - `npm run typecheck` clean --- ## Task 4 — Secure notes *(target: v1.3.3)* ### What the website promises Free tier pricing card: *"Unlimited passwords & secure notes"* ### Data model change **`src/shared/types.ts`** — add `type` field to `VaultEntry`: ```ts export interface VaultEntry { id: string; type: "login" | "note"; // new field — defaults to "login" for existing entries title: string; // Login fields — undefined/empty for note entries username?: string; password?: string; url?: string; totp?: string; // Shared fields notes?: string; breach?: number; createdAt: number; modifiedAt: number; deletedAt?: number; canary?: { webhookUrl: string; label?: string; lastPinged?: number; pingCount?: number; }; tags?: string[]; // from Task 9 } ``` For `type: "note"`, only `title` and `notes` are meaningful. `username`, `password`, `url`, `totp`, and `breach` are undefined. Notes entries are never breach-checked. ### Migration On `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. ### UI changes **Vault list (`Vault.tsx`)** Note 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. **Add entry view (`Add.tsx`)** Add 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. **Entry detail view (`Entry.tsx`)** For note entries: show title, full note content (scrollable, selectable), copy-content button. No password field, no breach badge, no TOTP. **Edit flow** Editing 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). **Search** Note entries are included in search results. The fuzzy match runs against `entry.title + " " + entry.notes`. ### Acceptance criteria - Creating a note entry stores `type: "note"` with title and content, no password - Note entries appear in the vault list with the correct icon - Existing login entries are unaffected and migrate cleanly - Search finds note entries by title and content - `npm run typecheck` clean; `npm test` updated --- ## Task 5 — One-tap breach password regeneration *(target: v1.3.4)* ### What the website promises Features page: *"Compromised entries are flagged with one-tap regeneration."* ### Implementation **`src/popup/views/Entry.tsx`** In the breach badge section (currently shows count and "BREACHED" label), add a "Regenerate password" button rendered inline next to the badge. Clicking it: 1. Opens a mini generator panel that slides in below the breach badge (not a full overlay — keep the user on the entry detail) 2. Pre-fills generator settings from the current password (infer length, charset) 3. Shows a "Generate" button and a preview of the new password 4. On accept: calls `UPDATE_ENTRY` with the new password, fires a fresh HIBP check, updates `modifiedAt` 5. Shows a success state: "Password updated — change it on [site] next" The mini generator panel uses the existing `Generator` component logic but rendered inline, not in the overlay. It should be dismissable (close button, Escape key). A "Copy new password" button is available so the user can immediately go update it on the site. **`src/popup/views/Entry.tsx`** — add `showRegenPanel` signal (`createSignal(false)`) toggled by the regenerate button. ### Acceptance criteria - The regenerate button is visible only when `entry.breach > 0` - Accepting a generated password updates the entry and clears the breach count (a fresh HIBP check runs) - The panel is dismissable without saving - `npm run typecheck` clean --- ## Task 6 — QR code for CJK phrase *(target: v1.3.5)* ### What the website promises Homepage hero section: *"Your entire vault, in a QR code."* — a full section with `CjkQr.astro` displaying a QR as a primary product visual. ### Reality constraint For 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. ### Implementation **Dependency** ```bash npm install qrcode npm install --save-dev @types/qrcode ``` Import dynamically (`const { default: QRCode } = await import("qrcode")`) so it does not land in the initial popup chunk. **`src/popup/views/CJKExport.tsx`** After the CJK phrase is generated and displayed, add a "Show as QR" button: ```ts async function showQR(phrase: string) { const byteLen = new TextEncoder().encode(phrase).length; if (byteLen > 2800) { // Show fallback message instead of button setQrState("too-large"); return; } const { default: QRCode } = await import("qrcode"); const dataUrl = await QRCode.toDataURL(phrase, { errorCorrectionLevel: "L", width: 320, margin: 2, color: { dark: "#E8A87C", light: "#00000000" }, // match site palette }); setQrDataUrl(dataUrl); setQrState("ready"); } ``` When `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."* When `qrState === "ready"`: show the QR image in an overlay modal (same pattern as other overlays) with: - Label: "Scan with Peach on another device" - Instruction: "Open Peach → Import → Restore from CJK phrase" - The QR image centred, 320×320 px - A close button (Escape also closes) **`src/popup/views/Entry.tsx`** — TOTP QR (separate, always small) In the TOTP display row, add a QR icon button. On click, generates `otpauth://totp/?secret=<totp>&issuer=<domain>` and shows it in a modal overlay. Always fits in a QR — no size guard needed. ### Acceptance criteria - A vault with ≤ 80 entries shows a working QR that another Peach instance can scan and import - A vault > 80 entries shows the "too large" fallback message - TOTP QR is scannable by Google Authenticator and Aegis - `qrcode` module does not appear in the main popup chunk (`npm run build` verified) --- ## Task 7 — Smart password generation with site policy detection *(target: v1.3.6)* ### What the website promises Features 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."* ### Content script changes **`src/content/index.ts`** — add `detectPasswordPolicy()`: ```ts interface PasswordPolicy { minLength?: number; maxLength?: number; requiresUppercase?: boolean; requiresLowercase?: boolean; requiresNumbers?: boolean; requiresSymbols?: boolean; forbidsSymbols?: boolean; allowedSymbols?: string; } function detectPasswordPolicy(doc: Document = document): PasswordPolicy { const policy: PasswordPolicy = {}; // 1. maxlength attribute on the new-password field const passwordField = doc.querySelector<HTMLInputElement>( 'input[autocomplete="new-password"], input[type="password"]' ); if (passwordField?.maxLength > 0) policy.maxLength = passwordField.maxLength; if (passwordField?.pattern) { // Parse common pattern forms const pat = passwordField.pattern; const minMatch = pat.match(/\{(\d+),/); const maxMatch = pat.match(/,(\d+)\}/); if (minMatch) policy.minLength = parseInt(minMatch[1]); if (maxMatch) policy.maxLength = parseInt(maxMatch[1]); } // 2. Visible password requirement text near the field const requirementText = extractNearbyText(passwordField); const minMatch = requirementText.match(/(?:at least|minimum|min)[^\d]*(\d+)/i); const maxMatch = requirementText.match(/(?:at most|maximum|max|up to)[^\d]*(\d+)/i); if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]); if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]); if (/no\s+(?:special|symbol)/i.test(requirementText)) policy.forbidsSymbols = true; if (/(?:special|symbol|punctuation)/i.test(requirementText)) policy.requiresSymbols = true; if (/(?:uppercase|capital)/i.test(requirementText)) policy.requiresUppercase = true; if (/number|digit/i.test(requirementText)) policy.requiresNumbers = true; return policy; } function extractNearbyText(el: HTMLInputElement | null): string { if (!el) return ""; // Check: aria-describedby, nearby <p>/<span>/<li> within 3 parent levels const describedBy = el.getAttribute("aria-describedby"); if (describedBy) { const desc = document.getElementById(describedBy); if (desc) return desc.textContent ?? ""; } let node: Element | null = el; for (let i = 0; i < 3; i++) { node = node?.parentElement ?? null; if (!node) break; const hints = node.querySelectorAll("p, span, li, small, .hint, .requirement, .password-rule"); if (hints.length > 0) return Array.from(hints).map(h => h.textContent).join(" "); } return ""; } ``` **New message type: `GET_SITE_PASSWORD_POLICY`** `src/shared/types.ts` — add to `MessageType`. The 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. **`src/popup/views/Add.tsx`** and **`src/popup/components/Generator.tsx`** On mount, send `GET_SITE_PASSWORD_POLICY`. If a policy is returned: - Pre-set generator min/max length to match - Disable symbol characters if `forbidsSymbols === true` - Enforce symbol characters if `requiresSymbols === true` - Show a small "Detected site requirements" badge above the generator settings - The user can still override — detected constraints are defaults, not locks ### Acceptance criteria - On a site with `<input type="password" maxlength="20">`, the generator defaults to max 20 characters - On a site with visible text "Must be 8–24 characters", the generator defaults to 8–24 - On a site with visible text "No special characters", symbols are disabled - `npm run typecheck` clean --- ## Task 8 — Tags *(target: v1.3.7)* ### Why 900 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. ### Data model `src/shared/types.ts` — `tags?: string[]` added to `VaultEntry` (already shown in Task 4's type definition). ### UI **Add/Edit entry (`Add.tsx`)** Below 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). Tags stored lowercase, trimmed, max 32 chars each, max 10 tags per entry. **Vault list (`Vault.tsx`)** Below 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). The filter strip is only shown if at least one entry has a tag. **Entry detail (`Entry.tsx`)** Tags shown as read-only chips below the metadata block. **Search interaction** Search and tag filter compose: both are active simultaneously. A query of `"github"` with tag filter `"work"` shows entries matching the text AND tagged "work". ### Acceptance criteria - Adding tags to an entry and saving persists them - The tag filter strip appears and correctly filters the vault list - Search and tag filter compose correctly - Existing entries without tags are unaffected - `npm run typecheck` clean --- ## Task 9 — Password health dashboard *(target: v1.3.8)* ### Why HIBP breach counts are already stored per-entry. With 900 entries, a health overview is essential to know where to focus. ### Entry point Settings overlay → new "Vault Health" row → opens the health dashboard as a full overlay view. ### Four health categories **Breached** — entries where `breach > 0`. Sorted by `breach` count descending (highest exposure first). **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. **Weak** — entries where `zxcvbn(entry.password).score <= 1`. zxcvbn is already in the bundle. Sorted by score ascending. **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. ### View layout `src/popup/views/Health.tsx` — new file. Four collapsible sections, each with a count badge: ``` Vault Health [3] Breached ▾ · GitHub 2,847 exposures [Fix →] · Twitter 541 exposures [Fix →] · OldSite 12 exposures [Fix →] [12] Reused ▾ Used in 5 places: · Netflix, Hulu, DisneyPlus, HBOMax, Peacock [View →] Used in 3 places: · GitHub, GitLab, Bitbucket [View →] ... [47] Weak ▾ · example.com score: 0 (very weak) [Fix →] ... [28] Old ▾ · ancientsite.com last changed 3 years ago [Fix →] ... ``` `[Fix →]` for breached and weak entries opens the entry detail with the regeneration panel pre-expanded. `[View →]` for reused groups opens a temporary filtered list showing all entries in the group. `[Fix →]` for old entries opens the entry detail for that entry. **Summary header** at the top: "N entries need attention" or "Your vault looks healthy." if all counts are zero. **"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. ### Performance note All 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`). ### Acceptance criteria - Dashboard shows correct counts for each category - `[Fix →]` links navigate to the correct entry with regeneration panel open - `[View →]` correctly groups and shows reused entries - "Check all breaches" triggers a sweep and refreshes the view when complete - `npm run typecheck` clean --- ## Task 10 — Legacy Peach JSON import *(target: v1.3.9)* ### Status It 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. ### Implementation (if not already done) **`src/shared/import.ts`** 1. Add `"peach-legacy-json"` to `ImportFormat` union. 2. Add to `detectImportFormat()`: ```ts if (envelope?.exportFormat === "peach-vault") return "peach-legacy-json"; ``` 3. Add `parsePeachLegacyJSON(raw: string): { entries: ImportableEntry[]; totalItems: number }`: - Filter `entry.type === "login"` only - 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` 4. Wire into `parseImportSource()` switch with `label: "Peach (legacy)"`, `requiresPassword: false` **Tests** — add to `src/shared/import.test.ts`: - Parses valid legacy export, returns correct entries - Skips non-login entries and reports them in `skippedItems` - Handles missing optional fields without throwing --- ## Task 11 — Firefox extension *(target: v1.4.0)* ### Why The 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. ### Technical approach The codebase already uses: - `@crxjs/vite-plugin` — Chrome-specific, uses `manifest.json` v3 - `chrome.*` APIs throughout (not `browser.*`) - `chrome.storage`, `chrome.idle`, `chrome.runtime`, `chrome.sockets` Firefox uses Manifest V2 (MV2) and the `browser.*` namespace (though Firefox has added limited MV3 support). **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`. This is a significant porting effort — a separate sprint. For now: 1. Task 0 fixes the website copy 2. This task is planned but not blocking ### Known adaptation points - `chrome.idle` API: Firefox equivalent is `browser.idle` ✓ - `chrome.storage.local`: Firefox equivalent via polyfill ✓ - `chrome.runtime.connectNative`: Firefox supports, but NMS host format differs - `chrome.sockets.*`: Firefox has no equivalent — Peach Sync in Firefox will need a different transport (deferred to Sync spec) - ONNX Runtime Web: should work in Firefox unchanged - `chrome.action.openPopup()`: Firefox 57+ ✓ --- ## Verification gate After all tasks are complete, the following must pass: ```bash npm run typecheck npm test npm run build DISPLAY=:99 node test-extension.mjs ``` Additionally: - `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) - CONTINUITY.md updated with `[OUTCOMES]` entries for all tasks completed --- ## Appendix — Feature/tier matrix | Feature | Free | Pro | |---|---|---| | Unlimited passwords & secure notes | ✓ | ✓ | | Browser extension with autofill | ✓ | ✓ | | Password generator + site policy detection | ✓ | ✓ | | TOTP authenticator | ✓ | ✓ | | Breach monitoring | ✓ | ✓ | | Import from any manager | ✓ | ✓ | | CJK phrase export & restore | ✓ | ✓ | | Tags | ✓ | ✓ | | Password health dashboard | ✓ | ✓ | | Canary entries | ✓ | ✓ | | Duress password | ✓ | ✓ | | QR code for CJK phrase | ✓ | ✓ | | Peach Sync | ✗ | ✓ | | Android app | ✗ | ✓ | | Passkeys | ✗ | ✓ (coming soon) | *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.*