peach_daily_driver_build_guide.md

51.9 KB · raw

rendered · source

peach

Daily-Driver Build Guide

Extension 1.0 RC · Tonight's target

From-scratch rebuild. Verifiable crypto. Real autofill. Daily-driverable tonight.

§ 0 --- What You Are Building Tonight

This is not Peach 2.0 complete. This is Peach Extension 1.0 RC --- the version you can trust with your real passwords today. The goal is one confident night of work that ends with a daily-driverable extension, not a month of polish before you can migrate.

The current repo is a patch-fest. The strategy here is a clean-room rebuild of the extension layer only, using the exact stack the spec demands. Do not attempt to salvage broken state. Start clean, move fast, stop at the right scope.

Tonight's Outcome (Non-Negotiable)


Crypto Vault create / unlock / lock with Argon2id + AES-256-GCM. Master password never written to disk, never logged, never transmitted.

Vault UI Add, edit, delete, search, copy, TOTP, generator --- functional and feeling like Peach.

Autofill Form detection, user-triggered fill, strict domain matching, Shadow DOM selector.

Save / Submitted credentials detected, save/update Update banner offered.

Import Generic CSV + 1Password JSON. Your passwords migrate tonight.

Export Encrypted backup. CJK phrase export. One safe path verified.

Lock Auto-lock on inactivity, clipboard clear, proper semantics state purge on lock.

Visual feel Existing motion language, warm dark palette, ring + pip behavior intact.


What Is Explicitly Out Tonight

⚠ These are not cut for quality reasons. They are cut because you need to use Peach tonight, not ship Peach 2.0.

§ 1 --- Stack & Project Bootstrap

Build with the spec's exact choices. Do not improvise. These are load-bearing decisions.

Tech Choices


Runtime Manifest V3 Chrome extension. Service worker for background. Popup for UI.

UI framework SolidJS --- ~7KB runtime, zero virtual DOM, renders to real DOM. Spec mandates this for <100ms popup open.

Bundler Vite with @crxjs/vite-plugin --- the only bundler that handles MV3 service worker bundling correctly without hacks.

Crypto (KDF) hash-wasm --- provides Argon2id in WASM, runs in service worker. Do not use any other library for KDF.

Crypto Web Crypto API (SubtleCrypto) --- native (encrypt) AES-256-GCM. No external library needed.

TOTP otpauth --- correct RFC 6238 implementation, 4KB, tree-shakeable.

Language TypeScript strict mode throughout. Zero any escapes in crypto paths.

Domain tldts --- Public Suffix List based eTLD+1 parsing extraction. Required for strict domain matching.

Import papaparse for CSV. parsing


Scaffold Command Sequence

Run these in order. Do not deviate.

# 1. Create the project npm create vite@latest peach-ext -- --template solid-ts cd peach-ext # 2. Install runtime dependencies npm install hash-wasm otpauth tldts papaparse # 3. Install extension tooling npm install -D @crxjs/vite-plugin webextension-polyfill npm install -D @types/chrome # 4. Install dev deps npm install -D vitest @vitest/ui

vite.config.ts

import { defineConfig } from "vite"; import solidPlugin from "vite-plugin-solid"; import { crx } from "@crxjs/vite-plugin"; import manifest from "./manifest.json"; export default defineConfig({ plugins: [solidPlugin(), crx({ manifest })], build: { target: "esnext", rollupOptions: { input: { popup: "src/popup/index.html" } } } });

manifest.json --- Complete

Copy this exactly. Every permission is justified in the spec. Do NOT add permissions not listed here.

{ "manifest_version": 3, "name": "Peach", "version": "1.0.0", "description": "Zero-knowledge password manager", "action": { "default_popup": "src/popup/index.html" }, "background": { "service_worker": "src/background/index.ts", "type": "module" }, "content_scripts": [{ "matches": ["<all_urls>"], "js": ["src/content/index.ts"], "run_at": "document_idle" }], "permissions": ["storage", "activeTab", "scripting", "clipboardWrite", "alarms"], "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'" }, "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" } }

🔴 CRITICAL: No <all_urls> host permission appears anywhere else. content_scripts matching <all_urls> is necessary for autofill detection. The permissions array must never include "tabs", "webNavigation", "history", or "cookies".

§ 2 --- File Structure

This structure maps directly to the spec's architectural boundaries. Content script cannot import from background. Crypto is shared. Types are shared.

src/ crypto/ argon2.ts # Argon2id KDF --- only file that touches hash-wasm aes.ts # AES-256-GCM encrypt/decrypt --- SubtleCrypto only totp.ts # RFC 6238 TOTP --- otpauth wrapper index.ts # re-exports background/ vault.ts # unlock/lock state machine, key held here messages.ts # validated message handler (sendMessage router) autofill.ts # domain index, entry matching clipboard.ts # copy + 30s clear timer alarms.ts # auto-lock alarm setup index.ts # service worker entry content/ detect.ts # form detection, MutationObserver inject.ts # Shadow DOM popup construction fill.ts # synchronous field fill save.ts # submit detection, save banner index.ts # content script entry popup/ index.html # pre-rendered lock shell (no blank flash) App.tsx # SolidJS root views/ Lock.tsx Vault.tsx Entry.tsx Add.tsx Settings.tsx Import.tsx Export.tsx components/ Ring.tsx # SVG accent ring Pip.tsx # status pip Generator.tsx # password generator TOTP.tsx # TOTP code + countdown styles/ vars.css # CSS custom properties (palette, motion curves) popup.css # popup shell layout shared/ types.ts # VaultEntry, StoredVault, Message types domain.ts # eTLD+1 extraction wrapper generate.ts # password generator logic

§ 3 --- Cryptographic Core (Build This First)

The crypto layer is the product. If this is wrong, nothing else matters. Build it in isolation, test it before touching any UI. The service worker holds the key in memory. Nothing else does.

src/shared/types.ts --- Canonical Types

Every file imports from here. Types never drift.

export interface VaultEntry { id: string; // crypto.randomUUID() title: string; username: string; password: string; url: string; totp?: string; // base32 seed, stored encrypted notes?: string; breach?: boolean; // set by HIBP check created: number; // Date.now() updated: number; } export interface StoredVault { version: 1; // AAD for AES-GCM --- prevents downgrade salt: string; // hex --- 16 bytes, CSPRNG, stored not secret nonce: string; // hex --- 12 bytes, CSPRNG, fresh per write ciphertext: string; // hex --- AES-256-GCM output tag: string; // hex --- 16-byte GCM auth tag } export type MessageType = | "UNLOCK" | "LOCK" | "IS_UNLOCKED" | "GET_ENTRIES" | "ADD_ENTRY" | "UPDATE_ENTRY" | "DELETE_ENTRY" | "AUTOFILL_QUERY" | "COPY" | "CREATE_VAULT"; export interface Message { type: MessageType; payload?: unknown; } export interface MessageResponse { ok: boolean; data?: unknown; error?: string; }

src/crypto/argon2.ts --- Key Derivation

import { argon2id } from "hash-wasm"; /** Derive a 32-byte AES key from the master password. * Parameters per spec: m=65536, t=3, p=4. * salt must be 16 CSPRNG bytes (not a string). * Returns Uint8Array(32). Never returns the password. */ export async function deriveKey( password: string, salt: Uint8Array ): Promise<Uint8Array> { const result = await argon2id({ password, salt, parallelism: 4, iterations: 3, memorySize: 65536, // 64 MB hashLength: 32, outputType: "binary", }); return result; } /** Generate a 16-byte CSPRNG salt. Store this. It is not secret. */ export function generateSalt(): Uint8Array { return crypto.getRandomValues(new Uint8Array(16)); }

src/crypto/aes.ts --- Encryption / Decryption

const VERSION_BYTE = new Uint8Array([1]); // AAD --- prevents downgrade attacks function hex(buf: ArrayBuffer): string { return Array.from(new Uint8Array(buf)) .map(b => b.toString(16).padStart(2, "0")).join(""); } function unhex(s: string): Uint8Array { const out = new Uint8Array(s.length / 2); for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.slice(i, i + 2), 16); return out; } export async function encrypt( keyBytes: Uint8Array, plaintext: string ): Promise<{ nonce: string; ciphertext: string; tag: string }> { const key = await crypto.subtle.importKey( "raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt"] ); // CRITICAL: fresh random nonce every single call const nonce = crypto.getRandomValues(new Uint8Array(12)); const enc = new TextEncoder(); const raw = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce, additionalData: VERSION_BYTE, tagLength: 128 }, key, enc.encode(plaintext) ); // SubtleCrypto appends the 16-byte tag to the ciphertext buffer const full = new Uint8Array(raw); const ct = full.slice(0, full.length - 16); const tag = full.slice(full.length - 16); return { nonce: hex(nonce), ciphertext: hex(ct), tag: hex(tag) }; } export async function decrypt( keyBytes: Uint8Array, nonce: string, ciphertext: string, tag: string ): Promise<string> { const key = await crypto.subtle.importKey( "raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"] ); // Recombine ciphertext + tag (SubtleCrypto expects them concatenated) const ct = unhex(ciphertext); const tg = unhex(tag); const combined = new Uint8Array(ct.length + tg.length); combined.set(ct); combined.set(tg, ct.length); // If auth tag fails, SubtleCrypto throws --- NEVER returns partial plaintext const raw = await crypto.subtle.decrypt( { name: "AES-GCM", iv: unhex(nonce), additionalData: VERSION_BYTE, tagLength: 128 }, key, combined ); return new TextDecoder().decode(raw); } export function zeroKey(keyBytes: Uint8Array): void { keyBytes.fill(0); }

🔴 CRITICAL: zeroKey() must be called on every lock path. If you lock and do not call zeroKey(), the derived key lingers in the service worker's V8 heap. This is a zero-knowledge violation.

src/crypto/totp.ts --- RFC 6238 TOTP

import { TOTP } from "otpauth"; export function generateTOTP(base32Secret: string): { code: string; remaining: number } { const totp = new TOTP({ secret: base32Secret, digits: 6, period: 30 }); const code = totp.generate(); const remaining = 30 - (Math.floor(Date.now() / 1000) % 30); return { code, remaining }; } /** Parse an otpauth:// URI and extract the base32 secret. */ export function parseOtpauthURI(uri: string): string | null { try { const totp = TOTP.parse(uri); return totp.secret.base32; } catch { return null; } }

Crypto Verification Test (Run Before Anything Else)

Put this in src/crypto/crypto.test.ts and run: npx vitest run

import { describe, it, expect } from "vitest"; import { deriveKey, generateSalt } from "./argon2"; import { encrypt, decrypt, zeroKey } from "./aes"; describe("vault crypto", () => { it("round-trips plaintext through encrypt/decrypt", async () => { const salt = generateSalt(); const key = await deriveKey("correct-horse", salt); const { nonce, ciphertext, tag } = await encrypt(key, "hello peach"); const plain = await decrypt(key, nonce, ciphertext, tag); expect(plain).toBe("hello peach"); }); it("rejects tampered ciphertext", async () => { const salt = generateSalt(); const key = await deriveKey("correct-horse", salt); const { nonce, ciphertext, tag } = await encrypt(key, "secret data"); const tampered = ciphertext.slice(0, -2) + "ff"; await expect(decrypt(key, nonce, tampered, tag)).rejects.toThrow(); }); it("rejects wrong password", async () => { const salt = generateSalt(); const key1 = await deriveKey("right-password", salt); const { nonce, ciphertext, tag } = await encrypt(key1, "secret"); const key2 = await deriveKey("wrong-password", salt); await expect(decrypt(key2, nonce, ciphertext, tag)).rejects.toThrow(); }); it("generates fresh nonce on each encrypt", async () => { const salt = generateSalt(); const key = await deriveKey("test", salt); const e1 = await encrypt(key, "data"); const e2 = await encrypt(key, "data"); expect(e1.nonce).not.toBe(e2.nonce); // CRITICAL: nonces must differ }); });

✦ RULE: All four tests must pass before you write a single line of UI code. If any test fails, the extension does not exist yet.

§ 4 --- Background Service Worker

The service worker is the only place that holds the derived key. It manages vault state, answers messages from both the popup and the content script, and runs the auto-lock alarm.

src/background/vault.ts --- State Machine

This is the heart of the extension. The key lives here and only here.

import { deriveKey, generateSalt } from "../crypto/argon2"; import { encrypt, decrypt, zeroKey } from "../crypto/aes"; import type { VaultEntry, StoredVault } from "../shared/types"; // ── In-memory session state ────────────────────────────────────── // Nothing here is ever written to disk. let derivedKey: Uint8Array | null = null; let entries: VaultEntry[] = []; let domainIndex: Map<string, VaultEntry[]> = new Map(); // ── Persistence (encrypted) ────────────────────────────────────── async function loadStoredVault(): Promise<StoredVault | null> { const { vault } = await chrome.storage.local.get("vault"); return vault ?? null; } async function saveStoredVault(v: StoredVault): Promise<void> { await chrome.storage.local.set({ vault: v }); } // ── Domain index for O(1) autofill lookup ──────────────────────── function buildDomainIndex(all: VaultEntry[]): void { domainIndex = new Map(); for (const e of all) { try { const { getDomain } = await import("../shared/domain"); // buildDomainIndex is sync --- use a cached result in practice // see domain.ts for the eTLD+1 extraction } catch {} } } // ── Public API ─────────────────────────────────────────────────── export async function createVault(password: string): Promise<void> { const salt = generateSalt(); derivedKey = await deriveKey(password, salt); entries = []; const plain = JSON.stringify(entries); const { nonce, ciphertext, tag } = await encrypt(derivedKey, plain); const stored: StoredVault = { version: 1, salt: hex(salt), nonce, ciphertext, tag }; await saveStoredVault(stored); setupAutoLock(); } export async function unlock(password: string): Promise<boolean> { const stored = await loadStoredVault(); if (!stored) return false; try { const salt = unhex(stored.salt); const key = await deriveKey(password, salt); const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag); entries = JSON.parse(plain) as VaultEntry[]; derivedKey = key; buildDomainIndexSync(entries); setupAutoLock(); return true; } catch { // Wrong password --- decrypt throws, do not expose which byte failed return false; } } export function lock(): void { if (derivedKey) { zeroKey(derivedKey); // overwrite bytes before dereferencing derivedKey = null; } entries = []; domainIndex = new Map(); chrome.alarms.clear("autolock"); } export function isUnlocked(): boolean { return derivedKey !== null; } export function getEntries(): VaultEntry[] { if (!derivedKey) throw new Error("vault locked"); return entries; } export async function addEntry(e: Omit<VaultEntry, "id" | "created" | "updated">): Promise<VaultEntry> { if (!derivedKey) throw new Error("vault locked"); const entry: VaultEntry = { ...e, id: crypto.randomUUID(), created: Date.now(), updated: Date.now() }; entries.push(entry); await persistEntries(); return entry; } export async function updateEntry(id: string, patch: Partial<VaultEntry>): Promise<void> { if (!derivedKey) throw new Error("vault locked"); const idx = entries.findIndex(e => e.id === id); if (idx === -1) throw new Error("entry not found"); entries[idx] = { ...entries[idx], ...patch, updated: Date.now() }; await persistEntries(); } export async function deleteEntry(id: string): Promise<void> { if (!derivedKey) throw new Error("vault locked"); entries = entries.filter(e => e.id !== id); await persistEntries(); } export function queryDomain(domain: string): VaultEntry[] { return domainIndex.get(domain) ?? []; } async function persistEntries(): Promise<void> { if (!derivedKey) throw new Error("vault locked"); const plain = JSON.stringify(entries); const { nonce, ciphertext, tag } = await encrypt(derivedKey, plain); const stored = await loadStoredVault(); if (!stored) throw new Error("no stored vault header"); await saveStoredVault({ ...stored, nonce, ciphertext, tag }); buildDomainIndexSync(entries); } function buildDomainIndexSync(all: VaultEntry[]): void { domainIndex = new Map(); for (const e of all) { const d = extractDomain(e.url); if (!d) continue; const bucket = domainIndex.get(d) ?? []; bucket.push(e); domainIndex.set(d, bucket); } } function extractDomain(url: string): string | null { try { const { hostname } = new URL(url.startsWith("http") ? url : "https://" + url); // Simple eTLD+1: take last two parts. For tonight this is sufficient. // Full PSL via tldts for production path. const parts = hostname.split("."); return parts.slice(-2).join("."); } catch { return null; } } function setupAutoLock(): void { chrome.alarms.clear("autolock"); // 15 minutes default --- spec §2.3 chrome.alarms.create("autolock", { delayInMinutes: 15 }); } // hex helpers (duplicated to avoid circular imports) function hex(buf: Uint8Array): string { return Array.from(buf).map(b => b.toString(16).padStart(2, "0")).join(""); } function unhex(s: string): Uint8Array { const out = new Uint8Array(s.length / 2); for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.slice(i, i + 2), 16); return out; }

src/background/messages.ts --- Validated Message Router

Every message is validated. No blind trust. The popup and content script are untrusted callers.

import type { Message, MessageResponse } from "../shared/types"; import * as vault from "./vault"; import { copyToClipboard } from "./clipboard"; export function setupMessageHandler(): void { chrome.runtime.onMessage.addListener( (msg: unknown, sender, sendResponse) => { // Validate structure before touching anything if (!isMessage(msg)) { sendResponse({ ok: false, error: "invalid message" }); return true; } handleMessage(msg, sender).then(sendResponse); return true; // keep port open for async response } ); } function isMessage(m: unknown): m is Message { return typeof m === "object" && m !== null && "type" in m; } async function handleMessage(msg: Message, sender: chrome.runtime.MessageSender): Promise<MessageResponse> { try { switch (msg.type) { case "CREATE_VAULT": { const { password } = msg.payload as { password: string }; await vault.createVault(password); return { ok: true }; } case "UNLOCK": { const { password } = msg.payload as { password: string }; const ok = await vault.unlock(password); return { ok }; } case "LOCK": vault.lock(); return { ok: true }; case "IS_UNLOCKED": return { ok: true, data: vault.isUnlocked() }; case "GET_ENTRIES": return { ok: true, data: vault.getEntries() }; case "ADD_ENTRY": { const entry = await vault.addEntry(msg.payload as any); return { ok: true, data: entry }; } case "UPDATE_ENTRY": { const { id, patch } = msg.payload as any; await vault.updateEntry(id, patch); return { ok: true }; } case "DELETE_ENTRY": { const { id } = msg.payload as { id: string }; await vault.deleteEntry(id); return { ok: true }; } case "AUTOFILL_QUERY": { const { domain } = msg.payload as { domain: string }; const matches = vault.queryDomain(domain); // Return only what content script needs --- never full vault return { ok: true, data: matches.map(e => ({ id: e.id, title: e.title, username: e.username, domain: e.url })) }; } case "COPY": { const { text, label } = msg.payload as { text: string; label: string }; await copyToClipboard(text, label); return { ok: true }; } default: return { ok: false, error: "unknown message type" }; } } catch (err) { // Never expose raw error messages to content scripts return { ok: false, error: "operation failed" }; } }

src/background/index.ts --- Service Worker Entry

import { setupMessageHandler } from "./messages"; import { lock } from "./vault"; setupMessageHandler(); // Auto-lock alarm handler chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === "autolock") lock(); }); // Lock on system idle (spec §13.4) chrome.idle.setDetectionInterval(60); chrome.idle.onStateChanged.addListener((state) => { if (state === "locked") lock(); });

§ 5 --- Popup

The popup is SolidJS. 320px wide, minimum height. Pre-rendered lock shell in HTML so there is never a blank flash on open. Spec mandates <100ms to interactive.

src/popup/index.html --- Pre-Rendered Shell

This HTML is the first pixel the user sees. It should contain the lock screen structure in raw HTML so Chrome shows something before JS parses. SolidJS hydrates into it.

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="./styles/vars.css"> <link rel="stylesheet" href="./styles/popup.css"> <title>Peach</title> </head> <body> <!-- Pre-rendered lock shell. SolidJS mounts into #app. --> <!-- Chrome renders this BEFORE JS loads --- no blank flash. --> <div id="app"> <div class="popup-shell"> <header class="chrome"> <span class="wordmark">peach<span class="pip"></span></span> </header> <div class="view-lock"> <p class="unlock-headline">welcome back.</p> <p class="unlock-sub">your vault is waiting</p> <input class="pw-field" type="password" placeholder="master password" /> <button class="btn-primary">unlock</button> </div> </div> </div> <script type="module" src="./App.tsx"></script> </body> </html>

CSS Variables --- src/popup/styles/vars.css

These are the exact values from the spec. Do not change them. Every Peach component references these variables, never hardcoded hex values.

:root { /* Palette */ --bg: #0C0A06; --bg-1: #111009; --bg-2: #1A1710; --bg-3: #221F14; /* Text */ --t0: #F0E8D8; --t1: rgba(240, 232, 216, 0.70); --t2: rgba(240, 232, 216, 0.35); --t3: rgba(240, 232, 216, 0.12); --t4: rgba(240, 232, 216, 0.04); /* Accent */ --ac: #D96830; /* Motion curves --- from motion language spec */ --spring: cubic-bezier(0.16, 1, 0.3, 1); --retreat: cubic-bezier(0.4, 0, 1, 1); --settle: cubic-bezier(0.34, 1.56, 0.64, 1); /* Duration tokens */ --dur-enter: 500ms; --dur-exit: 280ms; --dur-micro: 150ms; }

Popup Layout --- src/popup/styles/popup.css

body { width: 320px; min-height: 480px; max-height: 600px; background: var(--bg); color: var(--t0); font-family: "Inter", sans-serif; overflow: hidden; margin: 0; } .popup-shell { display: flex; flex-direction: column; height: 100%; position: relative; } /* Wordmark */ .wordmark { font-family: "Cormorant Garamond", serif; font-size: 16px; font-style: italic; font-weight: 300; color: var(--t0); display: flex; align-items: center; gap: 5px; } .pip { width: 5px; height: 5px; border-radius: 50%; background: var(--ac); box-shadow: 0 0 0 0 rgba(217,104,48,0.5); transition: box-shadow 400ms var(--settle); } .pip.pulse { animation: pipPulse 400ms var(--settle) both; } @keyframes pipPulse { 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(217,104,48,0.7); } 50% { transform: scale(2.2); box-shadow: 0 0 8px 4px rgba(217,104,48,0.3); } 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(217,104,48,0); } } /* Unlock view */ .view-lock { padding: 40px 24px 24px; display: flex; flex-direction: column; gap: 16px; } .unlock-headline { font-family: "Cormorant Garamond", serif; font-size: 44px; font-style: italic; font-weight: 300; color: var(--t0); margin: 0; } .unlock-sub { font-family: "IBM Plex Mono", monospace; font-size: 10px; font-style: italic; color: var(--t2); letter-spacing: 0.1em; margin: 0; } .pw-field { background: transparent; border: none; border-bottom: 1px solid var(--t3); color: var(--t0); font-family: "IBM Plex Mono", monospace; font-size: 18px; letter-spacing: 0.24em; padding: 8px 0; outline: none; width: 100%; } .pw-field:focus { border-bottom-color: var(--ac); } .pw-field.shake { animation: shake 300ms ease-in-out; } @keyframes shake { 0% { transform: translateX(0); } 20% { transform: translateX(-6px); } 40% { transform: translateX(6px); } 60% { transform: translateX(-4px); } 80% { transform: translateX(4px); } 100%{ transform: translateX(0); } } /* Primary button */ .btn-primary { background: var(--ac); color: rgba(0,0,0,0.75); font-family: "Inter", sans-serif; font-size: 11px; font-weight: 400; letter-spacing: 0.12em; text-transform: uppercase; border: none; border-radius: 8px; padding: 12px 20px; cursor: pointer; width: 100%; transition: opacity 150ms ease; } .btn-primary:hover { opacity: 0.88; } .btn-primary:active { transform: scale(0.98); }

The Accent Ring Component --- src/popup/components/Ring.tsx

This implements the full ring state machine from the interaction spec. 380ms draw, 3.5% overshoot, 1200ms hold, 600ms fade. Never fires on hover or passive events.

import { onMount, onCleanup } from "solid-js"; interface RingProps { target: () => HTMLElement | null; } export function Ring(props: RingProps) { let svgEl: SVGSVGElement | undefined; let rectEl: SVGRectElement | undefined; let fadeTimer: ReturnType<typeof setTimeout>; function calibrate() { const el = props.target(); if (!el || !svgEl || !rectEl) return; const { width, height } = el.getBoundingClientRect(); const r = 12; // match popup border-radius const perim = 2 * (width + height); svgEl.setAttribute("viewBox", `0 0 ${width} ${height}`); svgEl.setAttribute("width", String(width)); svgEl.setAttribute("height", String(height)); rectEl.setAttribute("width", String(width)); rectEl.setAttribute("height", String(height)); rectEl.setAttribute("rx", String(r)); rectEl.setAttribute("ry", String(r)); rectEl.style.setProperty("--perim", String(perim)); rectEl.style.strokeDasharray = String(perim); rectEl.style.strokeDashoffset = String(perim); } // Exported: call this on meaningful user actions function trigger() { if (!rectEl) return; rectEl.style.animation = "none"; void rectEl.offsetWidth; // force reflow rectEl.style.animation = "ringDraw 380ms ease-out both"; clearTimeout(fadeTimer); fadeTimer = setTimeout(beginFade, 380 + 1200); } function beginFade() { if (!rectEl) return; rectEl.style.transition = "opacity 600ms var(--retreat)"; rectEl.style.opacity = "0"; } onMount(() => { calibrate(); // Re-calibrate if popup height changes const ro = new ResizeObserver(calibrate); const el = props.target(); if (el) ro.observe(el); onCleanup(() => ro.disconnect()); }); // Make trigger available to parent via ref pattern (window as any).__peachRingTrigger = trigger; return ( <svg ref={svgEl} style={{ position: "absolute", inset: 0, "pointer-events": "none", "z-index": 10 }} > <style>{` @keyframes ringDraw { 0% { stroke-dashoffset: var(--perim); opacity: 0; } 60% { stroke-dashoffset: calc(var(--perim) * -0.035); opacity: 0.85; } 78% { stroke-dashoffset: calc(var(--perim) * 0.008); } 100% { stroke-dashoffset: 0; opacity: 0.85; } } `}</style> <rect ref={rectEl} x="0" y="0" fill="none" stroke="var(--ac)" stroke-width="1" style={{ filter: "drop-shadow(0 0 5px rgba(217,104,48,0.8))", transform: `rotate(-90deg)`, "transform-origin": "50% 50%", opacity: "0", }} /> </svg> ); } // Call this from any interaction handler export function triggerRing() { (window as any).__peachRingTrigger?.(); }

§ 6 --- Autofill: Content Script

The content script runs in every page. It must be fast (detection < 50ms), secure (never expose vault), and resilient (MutationObserver for SPAs). It communicates ONLY via chrome.runtime.sendMessage.

🔴 CRITICAL: Content scripts NEVER have access to vault entries. The service worker returns only the id, title, username, and domain of matching entries. Passwords are filled directly by the service worker responding to a FILL message --- the content script never sees the password string.

src/content/detect.ts --- Form Detection

export interface DetectedField { type: "username" | "password" | "email"; el: HTMLInputElement; } const USERNAME_PATTERNS = /user|email|login|mail|account|name/i; const PASSWORD_PATTERNS = /pass|passwd|pwd/i; export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] { const inputs = Array.from(root.querySelectorAll<HTMLInputElement>("input")); const fields: DetectedField[] = []; for (const el of inputs) { if (!isVisible(el)) continue; const ac = el.autocomplete; const type = el.type.toLowerCase(); const name = (el.name + el.id + el.placeholder).toLowerCase(); // Password: autocomplete > type > name if (ac === "current-password" || ac === "new-password" || type === "password") { fields.push({ type: "password", el }); continue; } if (ac === "username" || ac === "email" || type === "email") { fields.push({ type: "username", el }); continue; } if (USERNAME_PATTERNS.test(name)) { fields.push({ type: "username", el }); } } return fields; } export function isVisible(el: HTMLElement): boolean { const s = getComputedStyle(el); const r = el.getBoundingClientRect(); return ( s.display !== "none" && s.visibility !== "hidden" && parseFloat(s.opacity) > 0 && r.width > 0 && r.height > 0 ); } export function getPageDomain(): string { const parts = location.hostname.split("."); return parts.slice(-2).join("."); }

src/content/inject.ts --- Shadow DOM Selector

The autofill selector lives in a Shadow DOM. Page CSS cannot touch it. Page JS cannot read it. The dropdown is removed from the DOM on dismiss --- not hidden.

import type { VaultEntry } from "../shared/types"; export interface SelectorEntry { id: string; title: string; username: string; } export function showAutofillSelector( anchorEl: HTMLInputElement, entries: SelectorEntry[], onSelect: (id: string) => void ): () => void { // Create isolated container const host = document.createElement("div"); host.style.cssText = ` position: absolute; z-index: 2147483647; `; const shadow = host.attachShadow({ mode: "closed" }); // closed = invisible to page JS const rect = anchorEl.getBoundingClientRect(); const scrollX = window.scrollX, scrollY = window.scrollY; host.style.top = `${rect.bottom + scrollY + 4}px`; host.style.left = `${rect.left + scrollX}px`; host.style.width = `${Math.max(rect.width, 240)}px`; shadow.innerHTML = ` <style> :host { all: initial; } .selector { background: #111009; border: 1px solid rgba(240,232,216,0.09); border-radius: 12px; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.8); font-family: Inter, sans-serif; } .entry { display: flex; flex-direction: column; padding: 10px 14px; cursor: pointer; border-bottom: 1px solid rgba(240,232,216,0.05); transition: background 120ms; } .entry:last-child { border-bottom: none; } .entry:hover { background: rgba(240,232,216,0.04); } .entry-title { font-size: 13px; color: #F0E8D8; } .entry-user { font-size: 10px; color: rgba(240,232,216,0.4); font-family: "Courier New", monospace; } </style> <div class="selector"> ${entries.map(e => ` <div class="entry" data-id="${e.id}"> <span class="entry-title">${escapeHtml(e.title)}</span> <span class="entry-user">${escapeHtml(e.username)}</span> </div> `).join("")} </div> `; shadow.querySelectorAll(".entry").forEach(el => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.id!; onSelect(id); dismiss(); }); }); document.body.appendChild(host); function dismiss() { host.remove(); // remove, not hide document.removeEventListener("click", outsideClick); } function outsideClick(e: MouseEvent) { if (!host.contains(e.target as Node)) dismiss(); } setTimeout(() => document.addEventListener("click", outsideClick), 0); return dismiss; } function escapeHtml(s: string): string { return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }

src/content/fill.ts --- Synchronous Field Fill

Fill is synchronous. No awaits during the fill path. The entry data is already in memory from the AUTOFILL_QUERY response. This is how you achieve <16ms fill time.

import { isVisible } from "./detect"; /** Fill username + password into detected fields. * Both fields are set in a single tick --- no race conditions. * Native input event dispatched so React/Vue/Angular state updates. */ export function fillFields( usernameEl: HTMLInputElement | null, passwordEl: HTMLInputElement | null, username: string, password: string ): void { // Security: verify fields are still visible before filling if (usernameEl && isVisible(usernameEl)) { setNativeValue(usernameEl, username); } if (passwordEl && isVisible(passwordEl)) { setNativeValue(passwordEl, password); } } /** Dispatch synthetic input + change events so framework state updates. */ function setNativeValue(el: HTMLInputElement, value: string): void { const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; nativeInputValueSetter?.call(el, value); el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); }

⚠ nativeInputValueSetter is required for React-controlled inputs. Setting el.value directly does not trigger React's synthetic event system. This is the correct approach --- the same technique used by Bitwarden and 1Password.

src/content/index.ts --- Content Script Orchestration

import { detectLoginFields, getPageDomain } from "./detect"; import { showAutofillSelector } from "./inject"; import { fillFields } from "./fill"; import { setupSaveDetection } from "./save"; let currentFields: ReturnType<typeof detectLoginFields> = []; async function checkVaultAndInject() { const { data: unlocked } = await chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }); if (!unlocked) return; const domain = getPageDomain(); const { data: matches } = await chrome.runtime.sendMessage({ type: "AUTOFILL_QUERY", payload: { domain } }); if (!matches || matches.length === 0) return; currentFields = detectLoginFields(); const pwField = currentFields.find(f => f.type === "password"); const userField = currentFields.find(f => f.type === "username"); if (!pwField) return; // Inject Peach icon into the password field injectPeachIcon(pwField.el, matches, userField?.el ?? null); } function injectPeachIcon( passwordEl: HTMLInputElement, matches: any[], usernameEl: HTMLInputElement | null ) { const icon = document.createElement("button"); icon.textContent = "🍑"; icon.style.cssText = ` position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; font-size: 16px; z-index: 2147483646; padding: 4px; `; const wrapper = passwordEl.parentElement; if (!wrapper) return; const prevPos = getComputedStyle(wrapper).position; if (prevPos === "static") wrapper.style.position = "relative"; wrapper.appendChild(icon); icon.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); showAutofillSelector(passwordEl, matches, async (selectedId) => { // Request the actual password from the background (content never sees it) const { data: entry } = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: selectedId } }); if (entry) fillFields(usernameEl, passwordEl, entry.username, entry.password); }); }); } // Run on load if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", checkVaultAndInject); } else { checkVaultAndInject(); } // Watch for SPAs that load forms after initial render new MutationObserver(() => checkVaultAndInject()).observe( document.body, { childList: true, subtree: true } ); setupSaveDetection();

⚠ You need to add a GET_ENTRY_FOR_FILL message type to messages.ts that returns the full entry including password. This message must validate that the caller is the extension itself, not a web page.

§ 7 --- Import & Export

CSV Import

Generic CSV. Column mapping is explicit --- never guess. Preview before apply.

import Papa from "papaparse"; import type { VaultEntry } from "../shared/types"; export interface CSVMapping { title?: string; username?: string; password?: string; url?: string; notes?: string; } export function parseCSV(raw: string): { headers: string[]; rows: Record<string, string>[] } { const result = Papa.parse<Record<string, string>>(raw, { header: true, skipEmptyLines: true, transformHeader: h => h.trim() }); return { headers: result.meta.fields ?? [], rows: result.data }; } export function mapCSVToEntries( rows: Record<string, string>[], mapping: CSVMapping ): Array<Omit<VaultEntry, "id" | "created" | "updated">> { return rows .map(row => ({ title: row[mapping.title ?? ""] ?? "", username: row[mapping.username ?? ""] ?? "", password: row[mapping.password ?? ""] ?? "", url: row[mapping.url ?? ""] ?? "", notes: row[mapping.notes ?? ""] ?? "", })) .filter(e => e.password); // skip rows with no password }

1Password JSON Import

// 1Password 1PUX export uses a nested structure. // This handles the most common format. export function parse1PasswordJSON(raw: string): Array<Omit<VaultEntry, "id" | "created" | "updated">> { const data = JSON.parse(raw); const entries: Array<Omit<VaultEntry, "id" | "created" | "updated">> = []; // 1Password exports: { accounts: [{ vaults: [{ items: [...] }] }] } const vaults = data?.accounts?.[0]?.vaults ?? []; for (const vault of vaults) { for (const item of vault.items ?? []) { if (item.trashed) continue; const fields = item.details?.loginFields ?? []; const username = fields.find((f: any) => f.designation === "username")?.value ?? ""; const password = fields.find((f: any) => f.designation === "password")?.value ?? ""; if (!password) continue; entries.push({ title: item.overview?.title ?? "Untitled", username, password, url: item.overview?.url ?? "", notes: item.details?.notesPlain ?? "", }); } } return entries; }

Encrypted Export

// Export vault as encrypted JSON blob. // Uses a separate password (can be the same master password) and a fresh salt. export async function exportEncrypted( entries: VaultEntry[], exportPassword: string ): Promise<string> { const { deriveKey, generateSalt } = await import("../crypto/argon2"); const { encrypt } = await import("../crypto/aes"); // Fresh salt --- independent of vault at-rest salt const salt = generateSalt(); const key = await deriveKey(exportPassword, salt); const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(entries)); const blob = JSON.stringify({ version: 1, type: "peach-export", salt: Array.from(salt).map(b => b.toString(16).padStart(2,"0")).join(""), nonce, ciphertext, tag }); return blob; } export async function importEncrypted( blob: string, exportPassword: string ): Promise<VaultEntry[]> { const { deriveKey } = await import("../crypto/argon2"); const { decrypt } = await import("../crypto/aes"); const data = JSON.parse(blob); if (data.type !== "peach-export" || data.version !== 1) throw new Error("Not a Peach export file"); const salt = new Uint8Array(data.salt.match(/../g).map((h: string) => parseInt(h, 16))); const key = await deriveKey(exportPassword, salt); const plain = await decrypt(key, data.nonce, data.ciphertext, data.tag); return JSON.parse(plain); }

§ 8 --- Release Gate: Manual Test Matrix

Run every item in this list on Chrome with the unpacked extension before calling it daily-driverable. Do not skip rows.

Vault Core

Autofill --- Real Sites

Test at minimum these five categories on real sites:

For each real-site test, verify:

Save / Update

Import

Export & Restore

Release Blockers (Never Ship with These)

🔴 CRITICAL: Wrong credentials filled on the wrong domain

🔴 CRITICAL: Fill into hidden fields

🔴 CRITICAL: Fill without user interaction (silent autofill)

🔴 CRITICAL: Vault fails to lock cleanly --- key lingers in memory

🔴 CRITICAL: Import corrupts or overwrites entries incorrectly

🔴 CRITICAL: Backup restore fails or returns wrong data

🔴 CRITICAL: console.log anywhere in crypto paths

§ 9 --- How to Prompt Your Agent Effectively

You are working with AI agents that lose context, overshoot scope, and invent solutions not in spec. These rules reduce that.

Prompt Structure for Each Phase

Give each agent exactly one phase at a time. Never bundle phases. The moment you say "and also do X and Y", drift starts.

Good prompt pattern:

You are building the crypto layer of a Chrome extension called Peach. Your task for this session: implement src/crypto/argon2.ts and src/crypto/aes.ts. Requirements: - argon2.ts must export: deriveKey(password: string, salt: Uint8Array): Promise<Uint8Array> Parameters: m=65536, t=3, p=4, hashLength=32 Library: hash-wasm (already installed) - aes.ts must export: encrypt(...), decrypt(...), zeroKey(...) Algorithm: AES-256-GCM via SubtleCrypto Fresh CSPRNG nonce every encrypt call AAD: Uint8Array([1]) version byte Type definitions are in src/shared/types.ts (do not modify it). When done, output the file contents only. Do not modify any other files. Do not add error tracking, analytics, or logging of any kind.

When the Agent Drifts

Phase Commit Protocol

After each phase:

⚠ A "clean baseline" means typecheck passes, build succeeds, and the extension loads in Chrome without errors. If you do not have this, you do not have a baseline --- you have unknown state.

§ 10 --- Tonight's Execution Order

Follow this sequence exactly. Every phase has a stop condition. If the stop condition is not met, do not proceed.


Phase 0 SCAFFOLD: npm create, install deps, vite.config, manifest.json, file structure. STOP: npm run dev starts without errors.

Phase 1 CRYPTO: Write argon2.ts, aes.ts, totp.ts. Write and run all four crypto tests. STOP: all tests pass.

Phase 2 SERVICE WORKER: Write vault.ts, messages.ts, clipboard.ts, alarms, index.ts. STOP: typecheck passes, build succeeds.

Phase 3 POPUP SHELL: Write popup.html with pre-rendered lock HTML. Write vars.css, popup.css. Mount SolidJS. STOP: popup opens, shows unlock screen, no blank flash.

Phase 4 VAULT UI: Lock/unlock flow, vault list, add/edit/delete, search, TOTP display, generator, copy with pip pulse and ring. STOP: all vault operations work end-to-end.

Phase 5 AUTOFILL: detect.ts, inject.ts, fill.ts, content index. STOP: Peach icon appears on a login form, selector shows correct entries, fill works.

Phase 6 SAVE/UPDATE: save.ts, submit detection, save/update banner. STOP: logging into a new site triggers the save banner.

Phase 7 IMPORT: CSV + 1Password JSON adapter, preview UI, duplicate handling. STOP: your real passwords are in Peach.

Phase 8 EXPORT: encrypted export + restore verification. STOP: you can restore your vault from the backup file.

Phase 9 TEST MATRIX: every row in § 8. Fix only release blockers. STOP: no release blockers remain.

Phase 10 MIGRATE: add your real passwords, use Peach tonight.


✦ RULE: Do not start Phase 5 (autofill) until Phase 4 (vault UI) is solid. A working vault UI with broken autofill is a daily-driverable extension. A working autofill with a broken vault is not.

§ 11 --- Common Failure Modes & Fixes

Service Worker Losing State

MV3 service workers terminate when idle. derivedKey will be null when the service worker wakes back up even if the vault was unlocked.

// In messages.ts, every handler that touches vault state should: case "GET_ENTRIES": if (!vault.isUnlocked()) { return { ok: false, error: "locked" }; // popup shows lock screen } return { ok: true, data: vault.getEntries() }; // In the popup App.tsx, on mount: onMount(async () => { const { data } = await sendMessage({ type: "IS_UNLOCKED" }); if (!data) showLockScreen(); else loadEntries(); }); // This is the correct pattern. The popup always checks state on open. // Never assume the service worker is in the same state it was in last time.

React-Controlled Inputs Not Filling

If fill.ts is setting el.value directly and the site's password manager doesn't respond, you need the nativeInputValueSetter approach in fill.ts (already shown in § 6). The key insight: React's synthetic event system tracks the "internal" value, not el.value. You must call the native setter to bypass React's wrapper.

Content Script / Popup Communication Failing

// Always use this wrapper instead of raw chrome.runtime.sendMessage async function sendMessage(msg: Message): Promise<MessageResponse> { return new Promise((resolve) => { chrome.runtime.sendMessage(msg, (response) => { if (chrome.runtime.lastError) { resolve({ ok: false, error: chrome.runtime.lastError.message }); } else { resolve(response); } }); }); } // chrome.runtime.lastError MUST be read or Chrome throws a console error. // This wrapper handles it. Use it everywhere.

SolidJS Not Updating After Message

// SolidJS is fine-grained reactive. You need createSignal. // Never mutate arrays directly --- create new array references. const [entries, setEntries] = createSignal<VaultEntry[]>([]); // WRONG: entries().push(newEntry); // CORRECT: setEntries(prev => [...prev, newEntry]);

Argon2id Timing Out in Service Worker

Chrome may kill the service worker during Argon2id derivation (up to 1.2s). Prevent this:

// Keep the service worker alive during KDF by sending periodic alarms // OR use the simpler approach: call chrome.storage.local.get() which keeps SW alive export async function unlock(password: string): Promise<boolean> { // Ping storage to keep SW alive during derivation const keepAliveInterval = setInterval(() => { chrome.storage.local.get("ping"); }, 100); try { const stored = await loadStoredVault(); if (!stored) return false; const salt = unhex(stored.salt); const key = await deriveKey(password, salt); // up to 1.2s // ... rest of unlock return true; } finally { clearInterval(keepAliveInterval); } }

Final Checklist --- Call It Done When All of This Is True

That is a daily-driver. That is tonight.