*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** - Canary entries - Duress password - Full LAN sync (Peach Sync) - Passkeys - Tauri desktop app - HIBP breach surface beyond badge storage - More than 2 import adapters > **โš  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\": > \[\"\\"\], \"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 \ host permission appears anywhere else. > content_scripts matching \ 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\ > { 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\ { 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\ VaultEntry\[\]\> = new Map(); // โ”€โ”€ Persistence (encrypted) > โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function > loadStoredVault(): Promise\ { const { vault } = > await chrome.storage.local.get(\"vault\"); return vault ?? null; } > async function saveStoredVault(v: StoredVault): Promise\ { > 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\ { 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\ { 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\): > Promise\ { 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\): Promise\ > { 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\ { 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\ { 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\ { 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. > \ \ \ \ charset=\"UTF-8\"\> \ content=\"width=device-width, initial-scale=1.0\"\> \ rel=\"stylesheet\" href=\"./styles/vars.css\"\> \ rel=\"stylesheet\" href=\"./styles/popup.css\"\> > \Peach\ \ \ \ shell. SolidJS mounts into #app. \--\> \ BEFORE JS loads --- no blank flash. \--\> \
\
class=\"popup-shell\"\> \
\ class=\"wordmark\"\>peach\\\ > \ \
\

class=\"unlock-headline\"\>welcome back.\ \

class=\"unlock-sub\"\>your vault is waiting\ \ class=\"pw-field\" type=\"password\" placeholder=\"master password\" > /\> \