peach_gaps_spec.md
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-<segment-1>-<segment-2>-<segment-3>-<segment-4>-<segment-5>
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:
import { sign } from "@noble/ed25519";
const PRIVATE_KEY = process.env.PEACH_ED25519_PRIVATE_KEY; // 32-byte hex, secret
async function generateLicenseKey(orderId: string): Promise<string> {
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
// 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<LicenseInfo> {
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<LicenseInfo> {
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<LicenseInfo> {
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: callsactivateLicense(payload.key), returnsLicenseInfoGET_LICENSE: callsgetLicenseInfo(), returnsLicenseInfo
Feature gating
A thin helper:
export async function isProUser(): Promise<boolean> {
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/#pricingin 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.completedinvokesgenerateLicenseKey()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 keyvalidateLicense()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 typecheckclean;npm testincludes 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:
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:
- The password is copied from the popup (entry detail copy button)
- The username is copied from the popup
- The content script autofills the entry into a form (fill event)
- The content script detects the canary password being submitted (
save.tsdetects 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:
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 typecheckclean
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):
export async function unlock(password: string): Promise<boolean> {
// 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,getEntriesoperations targetpeach_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 typecheckclean
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:
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 typecheckclean;npm testupdated
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:
- Opens a mini generator panel that slides in below the breach badge (not a full overlay — keep the user on the entry detail)
- Pre-fills generator settings from the current password (infer length, charset)
- Shows a "Generate" button and a preview of the new password
- On accept: calls
UPDATE_ENTRYwith the new password, fires a fresh HIBP check, updatesmodifiedAt - 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 typecheckclean
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
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:
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/<title>?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
qrcodemodule does not appear in the main popup chunk (npm run buildverified)
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():
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 typecheckclean
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 typecheckclean
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 typecheckclean
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
- Add
"peach-legacy-json"toImportFormatunion. - Add to
detectImportFormat():if (envelope?.exportFormat === "peach-vault") return "peach-legacy-json"; - 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
- Filter
- Wire into
parseImportSource()switch withlabel: "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, usesmanifest.jsonv3chrome.*APIs throughout (notbrowser.*)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:
- Task 0 fixes the website copy
- This task is planned but not blocking
Known adaptation points
chrome.idleAPI: Firefox equivalent isbrowser.idle✓chrome.storage.local: Firefox equivalent via polyfill ✓chrome.runtime.connectNative: Firefox supports, but NMS host format differschrome.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:
npm run typecheck
npm test
npm run build
DISPLAY=:99 node test-extension.mjs
Additionally:
npm testmust 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.