From 01d5fd5d0d9fde26b15aed806e1a29287a19fccf Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 12 Apr 2026 00:14:03 -0400 Subject: [PATCH] docs: add WASM + Chrome MV3 extension implementation plan 11 tasks covering core data model changes, WASM crate with TOTP, extension scaffolding, git API layer, service worker, popup UI with terminal aesthetic, content script autofill, and build integration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-12-idfoto-wasm-extension.md | 3179 +++++++++++++++++ 1 file changed, 3179 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-12-idfoto-wasm-extension.md diff --git a/docs/superpowers/plans/2026-04-12-idfoto-wasm-extension.md b/docs/superpowers/plans/2026-04-12-idfoto-wasm-extension.md new file mode 100644 index 0000000..5ffdfac --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-idfoto-wasm-extension.md @@ -0,0 +1,3179 @@ +# idfoto WASM + Chrome MV3 Extension Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Compile `idfoto-core` to WASM and wrap it in a Chrome MV3 browser extension with a terminal-aesthetic popup, conservative autofill, and direct Gitea/GitHub API access. + +**Architecture:** Monolith service worker loads the WASM module and holds all state (master_key, cached manifest). Popup and content script are thin UI layers communicating via `chrome.runtime.sendMessage`. Vault data is fetched/committed directly via Gitea/GitHub REST APIs — no local clone, no CLI dependency. + +**Tech Stack:** Rust + wasm-bindgen (WASM crate), TypeScript + webpack (extension), Chrome MV3 APIs + +**Spec:** `docs/superpowers/specs/2026-04-12-idfoto-wasm-extension-design.md` + +--- + +## File Structure + +### Rust (new crate) + +``` +crates/idfoto-wasm/ +├── Cargo.toml +└── src/ + └── lib.rs # wasm-bindgen wrappers + TOTP implementation +``` + +### Rust (modified) + +``` +crates/idfoto-core/src/entry.rs # Add group field to Entry and ManifestEntry +Cargo.toml # Add idfoto-wasm to workspace members +``` + +### Extension (all new) + +``` +extension/ +├── manifest.json +├── package.json +├── tsconfig.json +├── webpack.config.js +├── src/ +│ ├── service-worker/ +│ │ ├── index.ts # WASM init, message router, state +│ │ ├── vault.ts # vault CRUD operations +│ │ ├── git-host.ts # GitHost interface + factory +│ │ ├── gitea.ts # Gitea API implementation +│ │ └── github.ts # GitHub API implementation +│ ├── popup/ +│ │ ├── index.html # popup HTML shell +│ │ ├── popup.ts # state machine: setup → locked → list → detail → edit +│ │ ├── styles.css # terminal dark theme +│ │ └── components/ +│ │ ├── unlock.ts # passphrase prompt +│ │ ├── entry-list.ts # search + group filter + entry rows +│ │ ├── entry-detail.ts # field display + TOTP countdown +│ │ ├── entry-form.ts # add/edit form +│ │ └── setup-wizard.ts # 3-step setup flow +│ ├── content/ +│ │ ├── detector.ts # login form detection +│ │ ├── fill.ts # credential injection +│ │ └── icon.ts # field icon injection + inline picker +│ └── shared/ +│ ├── messages.ts # typed message definitions +│ └── types.ts # Entry, ManifestEntry, VaultConfig types +└── icons/ + ├── icon-16.png + ├── icon-48.png + └── icon-128.png +``` + +--- + +## Task 1: Add `group` Field to Core Data Model + +**Files:** +- Modify: `crates/idfoto-core/src/entry.rs` +- Modify: `crates/idfoto-core/src/vault.rs` (test helpers) +- Modify: `crates/idfoto-cli/src/main.rs` (Entry construction sites) +- Test: `crates/idfoto-core/src/entry.rs` (inline tests) + +- [ ] **Step 1: Add `group` field to `Entry` struct** + +In `crates/idfoto-core/src/entry.rs`, add the field after `totp_secret`: + +```rust +pub struct Entry { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub totp_secret: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, + pub created_at: String, + pub updated_at: String, +} +``` + +- [ ] **Step 2: Add `group` field to `ManifestEntry` struct** + +In the same file: + +```rust +pub struct ManifestEntry { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, + pub updated_at: String, +} +``` + +- [ ] **Step 3: Fix all Entry construction sites in tests and CLI** + +Update every place that constructs `Entry` or `ManifestEntry` to include `group: None`. These are: + +In `crates/idfoto-core/src/entry.rs` tests — `entry_serialization_round_trip`, `manifest_add_and_lookup`, `manifest_serialization_round_trip`, `manifest_search_case_insensitive`: + +```rust +// Every Entry construction gets: +group: None, + +// Every ManifestEntry construction gets: +group: None, +``` + +In `crates/idfoto-core/src/vault.rs` tests — `sample_entry()` helper and `manifest_encrypt_decrypt_round_trip`: + +```rust +// sample_entry() gets: +group: None, + +// ManifestEntry in manifest_encrypt_decrypt_round_trip gets: +group: None, +``` + +In `crates/idfoto-core/tests/integration.rs` — `full_vault_workflow()` Entry construction (line ~55) and ManifestEntry (line ~101): + +```rust +// Entry construction gets: +group: None, + +// ManifestEntry construction gets: +group: None, +``` + +In `crates/idfoto-cli/src/main.rs` — `cmd_add()` Entry construction (line ~328), `cmd_add()` ManifestEntry (line ~349), `cmd_edit()` Entry construction (line ~513), `cmd_edit()` ManifestEntry (line ~536): + +```rust +// Every Entry construction gets: +group: None, + +// Every ManifestEntry construction gets: +group: None, +``` + +- [ ] **Step 4: Add a test for backwards compatibility (deserialize without group)** + +In `crates/idfoto-core/src/entry.rs` tests: + +```rust +#[test] +fn entry_deserializes_without_group_field() { + let json = r#"{ + "name": "Legacy", + "password": "old-pw", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + }"#; + let entry: Entry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.name, "Legacy"); + assert_eq!(entry.group, None); +} + +#[test] +fn manifest_entry_deserializes_without_group_field() { + let json = r#"{ + "name": "Legacy", + "updated_at": "2024-01-01T00:00:00Z" + }"#; + let entry: ManifestEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.name, "Legacy"); + assert_eq!(entry.group, None); +} + +#[test] +fn entry_with_group_round_trips() { + let entry = Entry { + name: "Work GitHub".to_string(), + url: Some("https://github.com".to_string()), + username: Some("alee-work".to_string()), + password: "s3cr3t".to_string(), + notes: None, + totp_secret: None, + group: Some("work".to_string()), + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }; + let json = serde_json::to_string(&entry).unwrap(); + assert!(json.contains("\"group\":\"work\"")); + let decoded: Entry = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.group, Some("work".to_string())); +} +``` + +- [ ] **Step 5: Run all tests** + +Run: `cargo test` +Expected: All tests pass, including new backwards-compatibility tests. + +- [ ] **Step 6: Commit** + +```bash +git add crates/idfoto-core/src/entry.rs crates/idfoto-core/src/vault.rs crates/idfoto-core/tests/integration.rs crates/idfoto-cli/src/main.rs +git commit -m "feat: add group field to Entry and ManifestEntry" +``` + +--- + +## Task 2: Create `idfoto-wasm` Crate + +**Files:** +- Create: `crates/idfoto-wasm/Cargo.toml` +- Create: `crates/idfoto-wasm/src/lib.rs` +- Modify: `Cargo.toml` (workspace members) + +- [ ] **Step 1: Create Cargo.toml** + +Create `crates/idfoto-wasm/Cargo.toml`: + +```toml +[package] +name = "idfoto-wasm" +version = "0.1.0" +edition = "2021" +description = "WASM bindings for idfoto password manager" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +idfoto-core = { path = "../idfoto-core" } +wasm-bindgen = "0.2" +js-sys = "0.3" +serde_json = "1" +hmac = "0.12" +sha1 = "0.10" +data-encoding = "2" + +[dev-dependencies] +wasm-bindgen-test = "0.3" +``` + +- [ ] **Step 2: Add to workspace** + +In root `Cargo.toml`, add `"crates/idfoto-wasm"` to the members list: + +```toml +[workspace] +resolver = "2" +members = [ + "crates/idfoto-core", + "crates/idfoto-cli", + "crates/idfoto-wasm", +] +``` + +- [ ] **Step 3: Write the WASM wrapper** + +Create `crates/idfoto-wasm/src/lib.rs`: + +```rust +use wasm_bindgen::prelude::*; + +/// Derive a 32-byte master key from passphrase + image_secret + salt. +/// `params_json` is a JSON string like: {"argon2_m":65536,"argon2_t":3,"argon2_p":4} +#[wasm_bindgen] +pub fn derive_master_key( + passphrase: &str, + image_secret: &[u8], + salt: &[u8], + params_json: &str, +) -> Result, JsValue> { + let params: idfoto_core::KdfParams = + serde_json::from_str(params_json).map_err(|e| JsValue::from_str(&e.to_string()))?; + + let image_secret: [u8; 32] = image_secret + .try_into() + .map_err(|_| JsValue::from_str("image_secret must be exactly 32 bytes"))?; + let salt: [u8; 32] = salt + .try_into() + .map_err(|_| JsValue::from_str("salt must be exactly 32 bytes"))?; + + let key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + Ok(key.to_vec()) +} + +/// Encrypt plaintext bytes with a 32-byte key. Returns version+nonce+ciphertext+tag. +#[wasm_bindgen] +pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result, JsValue> { + let key: [u8; 32] = key + .try_into() + .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; + idfoto_core::crypto::encrypt(&key, plaintext).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Decrypt ciphertext with a 32-byte key. Returns plaintext bytes. +#[wasm_bindgen] +pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result, JsValue> { + let key: [u8; 32] = key + .try_into() + .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; + idfoto_core::crypto::decrypt(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Extract a 256-bit secret from a JPEG with an embedded secret. +#[wasm_bindgen] +pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result, JsValue> { + let secret = + idfoto_core::imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?; + Ok(secret.to_vec()) +} + +/// Encrypt an entry (JSON string) with a 32-byte key. Returns encrypted bytes. +#[wasm_bindgen] +pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result, JsValue> { + let key: [u8; 32] = key + .try_into() + .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; + let entry: idfoto_core::Entry = + serde_json::from_str(entry_json).map_err(|e| JsValue::from_str(&e.to_string()))?; + idfoto_core::encrypt_entry(&key, &entry).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Decrypt an entry from encrypted bytes. Returns JSON string. +#[wasm_bindgen] +pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result { + let key: [u8; 32] = key + .try_into() + .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; + let entry = + idfoto_core::decrypt_entry(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?; + serde_json::to_string(&entry).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Encrypt a manifest (JSON string) with a 32-byte key. Returns encrypted bytes. +#[wasm_bindgen] +pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result, JsValue> { + let key: [u8; 32] = key + .try_into() + .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; + let manifest: idfoto_core::Manifest = + serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?; + idfoto_core::encrypt_manifest(&key, &manifest).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Decrypt a manifest from encrypted bytes. Returns JSON string. +#[wasm_bindgen] +pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result { + let key: [u8; 32] = key + .try_into() + .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; + let manifest = idfoto_core::decrypt_manifest(&key, ciphertext) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + serde_json::to_string(&manifest).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Generate a TOTP code for the given base32-encoded secret and unix timestamp. +/// Returns a 6-digit zero-padded string. +#[wasm_bindgen] +pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result { + use hmac::{Hmac, Mac}; + use sha1::Sha1; + + let secret = data_encoding::BASE32_NOPAD + .decode(secret_base32.to_uppercase().as_bytes()) + .or_else(|_| data_encoding::BASE32.decode(secret_base32.to_uppercase().as_bytes())) + .map_err(|e| JsValue::from_str(&format!("invalid base32 secret: {e}")))?; + + let counter = timestamp_secs / 30; + let counter_bytes = counter.to_be_bytes(); + + let mut mac = Hmac::::new_from_slice(&secret) + .map_err(|e| JsValue::from_str(&format!("HMAC error: {e}")))?; + mac.update(&counter_bytes); + let result = mac.finalize().into_bytes(); + + let offset = (result[19] & 0x0f) as usize; + let code = ((result[offset] as u32 & 0x7f) << 24) + | ((result[offset + 1] as u32) << 16) + | ((result[offset + 2] as u32) << 8) + | (result[offset + 3] as u32); + let code = code % 1_000_000; + + Ok(format!("{:06}", code)) +} + +/// Generate a random password of the given length. +#[wasm_bindgen] +pub fn generate_password(length: u32) -> String { + use js_sys::Math; + + const CHARSET: &[u8] = + b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+"; + + (0..length) + .map(|_| { + let idx = (Math::random() * CHARSET.len() as f64).floor() as usize; + CHARSET[idx % CHARSET.len()] as char + }) + .collect() +} + +/// Generate a random 8-character hex entry ID. +#[wasm_bindgen] +pub fn generate_entry_id() -> String { + use js_sys::Math; + + let mut bytes = [0u8; 4]; + for b in &mut bytes { + *b = (Math::random() * 256.0).floor() as u8; + } + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn totp_rfc6238_test_vector() { + // RFC 6238 test vector: SHA1, secret = "12345678901234567890" (ASCII), + // time = 59, expected code = 287082 + let secret_ascii = b"12345678901234567890"; + let secret_b32 = data_encoding::BASE32.encode(secret_ascii); + let result = generate_totp(&secret_b32, 59).unwrap(); + assert_eq!(result, "287082"); + } + + #[test] + fn totp_rfc6238_test_vector_2() { + // time = 1111111109, expected = 081804 + let secret_ascii = b"12345678901234567890"; + let secret_b32 = data_encoding::BASE32.encode(secret_ascii); + let result = generate_totp(&secret_b32, 1111111109).unwrap(); + assert_eq!(result, "081804"); + } + + #[test] + fn totp_rfc6238_test_vector_3() { + // time = 1234567890, expected = 005924 + let secret_ascii = b"12345678901234567890"; + let secret_b32 = data_encoding::BASE32.encode(secret_ascii); + let result = generate_totp(&secret_b32, 1234567890).unwrap(); + assert_eq!(result, "005924"); + } + + #[test] + fn totp_invalid_base32_fails() { + let result = generate_totp("not-valid-base32!!!", 1000); + assert!(result.is_err()); + } + + #[test] + fn derive_key_via_wasm_wrapper() { + let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#; + let image_secret = [0x42u8; 32]; + let salt = [0x01u8; 32]; + let key = derive_master_key("test-passphrase", &image_secret, &salt, params).unwrap(); + assert_eq!(key.len(), 32); + + // Same inputs should produce same output + let key2 = derive_master_key("test-passphrase", &image_secret, &salt, params).unwrap(); + assert_eq!(key, key2); + } + + #[test] + fn encrypt_decrypt_via_wasm_wrapper() { + let key = [0xABu8; 32]; + let plaintext = b"hello wasm"; + let ciphertext = encrypt(plaintext, &key).unwrap(); + let decrypted = decrypt(&ciphertext, &key).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn encrypt_entry_decrypt_entry_round_trip() { + let key = [0xABu8; 32]; + let entry_json = r#"{"name":"Test","password":"secret","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#; + let ciphertext = encrypt_entry(entry_json, &key).unwrap(); + let result = decrypt_entry(&ciphertext, &key).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["name"], "Test"); + assert_eq!(parsed["password"], "secret"); + } +} +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cargo build -p idfoto-wasm` +Expected: Compiles successfully. + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p idfoto-wasm` +Expected: All tests pass, including TOTP RFC 6238 test vectors. + +- [ ] **Step 6: Test WASM compilation** + +Run: `cargo install wasm-pack` (if not already installed), then: +```bash +wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm +``` +Expected: Produces `extension/wasm/idfoto_wasm.js` and `extension/wasm/idfoto_wasm_bg.wasm`. Note the WASM binary size for later reference. + +- [ ] **Step 7: Commit** + +```bash +git add crates/idfoto-wasm/ Cargo.toml extension/wasm/ +git commit -m "feat: add idfoto-wasm crate with wasm-bindgen wrappers and TOTP" +``` + +--- + +## Task 3: Extension Scaffolding + +**Files:** +- Create: `extension/manifest.json` +- Create: `extension/package.json` +- Create: `extension/tsconfig.json` +- Create: `extension/webpack.config.js` +- Create: `extension/src/popup/index.html` +- Create: `extension/icons/` (placeholder icons) +- Modify: `.gitignore` (add extension build artifacts) + +- [ ] **Step 1: Create `extension/package.json`** + +```json +{ + "name": "idfoto-extension", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "webpack --mode production", + "dev": "webpack --mode development --watch", + "build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm", + "build:all": "npm run build:wasm && npm run build" + }, + "devDependencies": { + "typescript": "^5.4", + "ts-loader": "^9.5", + "webpack": "^5.90", + "webpack-cli": "^5.1", + "copy-webpack-plugin": "^12.0", + "@anthropic-ai/sdk": "^0.39" + } +} +``` + +Note: `@anthropic-ai/sdk` is NOT needed — remove that. The devDependencies should be: + +```json +{ + "name": "idfoto-extension", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "webpack --mode production", + "dev": "webpack --mode development --watch", + "build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm", + "build:all": "npm run build:wasm && npm run build" + }, + "devDependencies": { + "typescript": "^5.4", + "ts-loader": "^9.5", + "webpack": "^5.90", + "webpack-cli": "^5.1", + "copy-webpack-plugin": "^12.0", + "@anthropic-ai/sdk": "^0.39.0" + } +} +``` + +Actually, strike that — no anthropic SDK. Final version: + +```json +{ + "name": "idfoto-extension", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "webpack --mode production", + "dev": "webpack --mode development --watch", + "build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm", + "build:all": "npm run build:wasm && npm run build" + }, + "devDependencies": { + "typescript": "^5.4", + "ts-loader": "^9.5", + "webpack": "^5.90", + "webpack-cli": "^5.1", + "copy-webpack-plugin": "^12.0" + } +} +``` + +- [ ] **Step 2: Create `extension/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "outDir": "./dist", + "rootDir": "./src", + "sourceMap": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "wasm"] +} +``` + +- [ ] **Step 3: Create `extension/webpack.config.js`** + +```javascript +const path = require('path'); +const CopyPlugin = require('copy-webpack-plugin'); + +module.exports = { + entry: { + 'service-worker': './src/service-worker/index.ts', + popup: './src/popup/popup.ts', + content: './src/content/detector.ts', + }, + output: { + path: path.resolve(__dirname, 'dist'), + filename: '[name].js', + clean: true, + }, + resolve: { + extensions: ['.ts', '.js'], + }, + module: { + rules: [ + { + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + ], + }, + plugins: [ + new CopyPlugin({ + patterns: [ + { from: 'manifest.json', to: '.' }, + { from: 'src/popup/index.html', to: 'popup.html' }, + { from: 'src/popup/styles.css', to: 'styles.css' }, + { from: 'icons', to: 'icons' }, + { from: 'wasm/idfoto_wasm_bg.wasm', to: '.' }, + { from: 'wasm/idfoto_wasm.js', to: '.' }, + ], + }), + ], + experiments: { + asyncWebAssembly: true, + }, +}; +``` + +- [ ] **Step 4: Create `extension/manifest.json`** + +```json +{ + "manifest_version": 3, + "name": "idfoto", + "version": "0.1.0", + "description": "Two-factor encrypted password manager", + "permissions": ["storage", "activeTab", "clipboardWrite"], + "host_permissions": [""], + "background": { + "service_worker": "service-worker.js", + "type": "module" + }, + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_idle" + } + ], + "content_security_policy": { + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" + } +} +``` + +- [ ] **Step 5: Create popup HTML shell** + +Create `extension/src/popup/index.html`: + +```html + + + + + + + idfoto + + +
+ + + +``` + +- [ ] **Step 6: Create placeholder icons** + +Generate simple placeholder PNGs for `extension/icons/`. These are 1x1 blue pixels scaled to 16, 48, 128 — functional placeholders to avoid Chrome errors. Use a simple script or create them manually. + +```bash +cd extension && mkdir -p icons +# Create minimal valid PNGs (can replace with real icons later) +python3 -c " +import struct, zlib +def make_png(size, path): + raw = b'' + for y in range(size): + raw += b'\x00' + b'\x1f\x6f\xeb\xff' * size + def chunk(ctype, data): + c = ctype + data + return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff) + ihdr = struct.pack('>IIBBBBB', size, size, 8, 6, 0, 0, 0) + with open(path, 'wb') as f: + f.write(b'\x89PNG\r\n\x1a\n') + f.write(chunk(b'IHDR', ihdr)) + f.write(chunk(b'IDAT', zlib.compress(raw))) + f.write(chunk(b'IEND', b'')) +make_png(16, 'icons/icon-16.png') +make_png(48, 'icons/icon-48.png') +make_png(128, 'icons/icon-128.png') +" +``` + +- [ ] **Step 7: Add to `.gitignore`** + +Append to the root `.gitignore`: + +``` +extension/node_modules/ +extension/dist/ +.superpowers/ +``` + +- [ ] **Step 8: Commit** + +```bash +git add extension/manifest.json extension/package.json extension/tsconfig.json extension/webpack.config.js extension/src/popup/index.html extension/icons/ .gitignore +git commit -m "feat: add extension scaffolding (manifest, webpack, tsconfig)" +``` + +--- + +## Task 4: Shared Types and Message Definitions + +**Files:** +- Create: `extension/src/shared/types.ts` +- Create: `extension/src/shared/messages.ts` + +- [ ] **Step 1: Create shared types** + +Create `extension/src/shared/types.ts`: + +```typescript +export interface Entry { + name: string; + url?: string; + username?: string; + password: string; + notes?: string; + totp_secret?: string; + group?: string; + created_at: string; + updated_at: string; +} + +export interface ManifestEntry { + name: string; + url?: string; + username?: string; + group?: string; + updated_at: string; +} + +export interface Manifest { + entries: Record; + version: number; +} + +export interface VaultConfig { + hostType: 'gitea' | 'github'; + hostUrl: string; + repoPath: string; + apiToken: string; +} + +export interface SetupState { + config: VaultConfig | null; + imageBase64: string | null; // reference JPEG stored as base64 + isConfigured: boolean; +} +``` + +- [ ] **Step 2: Create message definitions** + +Create `extension/src/shared/messages.ts`: + +```typescript +import type { Entry, ManifestEntry, VaultConfig } from './types'; + +// ─── Requests (popup/content → service worker) ───────────────────────────── + +export type Request = + | { type: 'unlock'; passphrase: string } + | { type: 'lock' } + | { type: 'is_unlocked' } + | { type: 'list_entries'; group?: string } + | { type: 'get_entry'; id: string } + | { type: 'search_entries'; query: string } + | { type: 'add_entry'; entry: Omit } + | { type: 'update_entry'; id: string; entry: Omit } + | { type: 'delete_entry'; id: string } + | { type: 'get_totp'; id: string } + | { type: 'get_autofill_candidates'; url: string } + | { type: 'get_credentials'; id: string } + | { type: 'sync' } + | { type: 'get_setup_state' } + | { type: 'save_setup'; config: VaultConfig; imageBase64: string } + | { type: 'fill_credentials'; username: string; password: string }; + +// ─── Responses (service worker → popup/content) ──────────────────────────── + +export type Response = + | { ok: true } + | { ok: true; id: string } + | { ok: true; unlocked: boolean } + | { ok: true; entries: Array<{ id: string } & ManifestEntry> } + | { ok: true; entry: Entry } + | { ok: true; code: string; remaining_seconds: number } + | { ok: true; candidates: Array<{ id: string } & ManifestEntry> } + | { ok: true; username: string; password: string } + | { ok: true; state: import('./types').SetupState } + | { error: string }; +``` + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/shared/ +git commit -m "feat: add shared types and message definitions" +``` + +--- + +## Task 5: Git API Layer + +**Files:** +- Create: `extension/src/service-worker/git-host.ts` +- Create: `extension/src/service-worker/gitea.ts` +- Create: `extension/src/service-worker/github.ts` + +- [ ] **Step 1: Create the GitHost interface** + +Create `extension/src/service-worker/git-host.ts`: + +```typescript +export interface GitHost { + readFile(path: string): Promise; + writeFile(path: string, content: Uint8Array, message: string): Promise; + deleteFile(path: string, message: string): Promise; + listDir(path: string): Promise; +} + +export function createGitHost( + hostType: 'gitea' | 'github', + hostUrl: string, + repoPath: string, + apiToken: string +): GitHost { + switch (hostType) { + case 'gitea': + return new (require('./gitea').GiteaHost)(hostUrl, repoPath, apiToken); + case 'github': + return new (require('./github').GitHubHost)(hostUrl, repoPath, apiToken); + } +} +``` + +Actually, since we're using ES modules and webpack, use dynamic imports or just import both. Simpler approach: + +```typescript +import { GiteaHost } from './gitea'; +import { GitHubHost } from './github'; + +export interface GitHost { + readFile(path: string): Promise; + writeFile(path: string, content: Uint8Array, message: string): Promise; + deleteFile(path: string, message: string): Promise; + listDir(path: string): Promise; +} + +export function createGitHost( + hostType: 'gitea' | 'github', + hostUrl: string, + repoPath: string, + apiToken: string +): GitHost { + if (hostType === 'gitea') { + return new GiteaHost(hostUrl, repoPath, apiToken); + } + return new GitHubHost(hostUrl, repoPath, apiToken); +} +``` + +- [ ] **Step 2: Create Gitea implementation** + +Create `extension/src/service-worker/gitea.ts`: + +```typescript +import type { GitHost } from './git-host'; + +export class GiteaHost implements GitHost { + private baseUrl: string; + private headers: HeadersInit; + + constructor(hostUrl: string, repoPath: string, apiToken: string) { + // Remove trailing slash from hostUrl + const host = hostUrl.replace(/\/+$/, ''); + this.baseUrl = `${host}/api/v1/repos/${repoPath}/contents`; + this.headers = { + Authorization: `token ${apiToken}`, + 'Content-Type': 'application/json', + }; + } + + async readFile(path: string): Promise { + const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (!resp.ok) { + throw new Error(`Failed to read ${path}: ${resp.status} ${resp.statusText}`); + } + const data = await resp.json(); + const decoded = atob(data.content); + const bytes = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i++) { + bytes[i] = decoded.charCodeAt(i); + } + return bytes; + } + + async writeFile(path: string, content: Uint8Array, message: string): Promise { + // Check if file exists to get its SHA + let sha: string | undefined; + try { + const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (resp.ok) { + const data = await resp.json(); + sha = data.sha; + } + } catch { + // File doesn't exist, that's fine + } + + const body: Record = { + message, + content: uint8ArrayToBase64(content), + }; + if (sha) { + body.sha = sha; + } + + const method = sha ? 'PUT' : 'POST'; + const resp = await fetch(`${this.baseUrl}/${path}`, { + method, + headers: this.headers, + body: JSON.stringify(body), + }); + if (!resp.ok) { + throw new Error(`Failed to write ${path}: ${resp.status} ${resp.statusText}`); + } + } + + async deleteFile(path: string, message: string): Promise { + // Get file SHA first + const getResp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (!getResp.ok) { + throw new Error(`Failed to read ${path} for deletion: ${getResp.status}`); + } + const data = await getResp.json(); + + const resp = await fetch(`${this.baseUrl}/${path}`, { + method: 'DELETE', + headers: this.headers, + body: JSON.stringify({ message, sha: data.sha }), + }); + if (!resp.ok) { + throw new Error(`Failed to delete ${path}: ${resp.status} ${resp.statusText}`); + } + } + + async listDir(path: string): Promise { + const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (!resp.ok) { + if (resp.status === 404) return []; + throw new Error(`Failed to list ${path}: ${resp.status} ${resp.statusText}`); + } + const data = await resp.json(); + if (!Array.isArray(data)) return []; + return data.map((item: { name: string }) => item.name); + } +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} +``` + +- [ ] **Step 3: Create GitHub implementation** + +Create `extension/src/service-worker/github.ts`: + +```typescript +import type { GitHost } from './git-host'; + +export class GitHubHost implements GitHost { + private baseUrl: string; + private headers: HeadersInit; + + constructor(_hostUrl: string, repoPath: string, apiToken: string) { + this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`; + this.headers = { + Authorization: `Bearer ${apiToken}`, + Accept: 'application/vnd.github.v3+json', + 'Content-Type': 'application/json', + }; + } + + async readFile(path: string): Promise { + const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (!resp.ok) { + throw new Error(`Failed to read ${path}: ${resp.status} ${resp.statusText}`); + } + const data = await resp.json(); + const decoded = atob(data.content.replace(/\n/g, '')); + const bytes = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i++) { + bytes[i] = decoded.charCodeAt(i); + } + return bytes; + } + + async writeFile(path: string, content: Uint8Array, message: string): Promise { + let sha: string | undefined; + try { + const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (resp.ok) { + const data = await resp.json(); + sha = data.sha; + } + } catch { + // File doesn't exist + } + + const body: Record = { + message, + content: uint8ArrayToBase64(content), + }; + if (sha) { + body.sha = sha; + } + + const resp = await fetch(`${this.baseUrl}/${path}`, { + method: 'PUT', + headers: this.headers, + body: JSON.stringify(body), + }); + if (!resp.ok) { + throw new Error(`Failed to write ${path}: ${resp.status} ${resp.statusText}`); + } + } + + async deleteFile(path: string, message: string): Promise { + const getResp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (!getResp.ok) { + throw new Error(`Failed to read ${path} for deletion: ${getResp.status}`); + } + const data = await getResp.json(); + + const resp = await fetch(`${this.baseUrl}/${path}`, { + method: 'DELETE', + headers: this.headers, + body: JSON.stringify({ message, sha: data.sha }), + }); + if (!resp.ok) { + throw new Error(`Failed to delete ${path}: ${resp.status} ${resp.statusText}`); + } + } + + async listDir(path: string): Promise { + const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (!resp.ok) { + if (resp.status === 404) return []; + throw new Error(`Failed to list ${path}: ${resp.status} ${resp.statusText}`); + } + const data = await resp.json(); + if (!Array.isArray(data)) return []; + return data.map((item: { name: string }) => item.name); + } +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/service-worker/git-host.ts extension/src/service-worker/gitea.ts extension/src/service-worker/github.ts +git commit -m "feat: add git API layer with Gitea and GitHub implementations" +``` + +--- + +## Task 6: Service Worker — Vault Operations + +**Files:** +- Create: `extension/src/service-worker/vault.ts` + +- [ ] **Step 1: Create vault operations module** + +Create `extension/src/service-worker/vault.ts`: + +```typescript +import type { GitHost } from './git-host'; +import type { Entry, Manifest, ManifestEntry } from '../shared/types'; + +// These will be set by the service worker index after WASM init +let wasm: typeof import('../../wasm/idfoto_wasm'); + +export function setWasm(w: typeof wasm) { + wasm = w; +} + +export async function fetchVaultMeta(git: GitHost): Promise<{ salt: Uint8Array; paramsJson: string }> { + const salt = await git.readFile('.idfoto/salt'); + const paramsBytes = await git.readFile('.idfoto/params.json'); + const paramsJson = new TextDecoder().decode(paramsBytes); + return { salt, paramsJson }; +} + +export async function fetchAndDecryptManifest(git: GitHost, masterKey: Uint8Array): Promise { + const encBytes = await git.readFile('manifest.enc'); + const json = wasm.decrypt_manifest(encBytes, masterKey); + return JSON.parse(json); +} + +export async function fetchAndDecryptEntry(git: GitHost, masterKey: Uint8Array, id: string): Promise { + const encBytes = await git.readFile(`entries/${id}.enc`); + const json = wasm.decrypt_entry(encBytes, masterKey); + return JSON.parse(json); +} + +export async function encryptAndWriteEntry( + git: GitHost, + masterKey: Uint8Array, + id: string, + entry: Entry, + commitMsg: string +): Promise { + const json = JSON.stringify(entry); + const encBytes = wasm.encrypt_entry(json, masterKey); + await git.writeFile(`entries/${id}.enc`, encBytes, commitMsg); +} + +export async function encryptAndWriteManifest( + git: GitHost, + masterKey: Uint8Array, + manifest: Manifest, + commitMsg: string +): Promise { + const json = JSON.stringify(manifest); + const encBytes = wasm.encrypt_manifest(json, masterKey); + await git.writeFile('manifest.enc', encBytes, commitMsg); +} + +export function listEntries(manifest: Manifest, group?: string): Array<{ id: string } & ManifestEntry> { + const entries = Object.entries(manifest.entries).map(([id, entry]) => ({ id, ...entry })); + if (group) { + return entries.filter((e) => e.group === group); + } + return entries; +} + +export function searchEntries(manifest: Manifest, query: string): Array<{ id: string } & ManifestEntry> { + const q = query.toLowerCase(); + return Object.entries(manifest.entries) + .filter(([, e]) => { + return ( + e.name.toLowerCase().includes(q) || + (e.url && e.url.toLowerCase().includes(q)) || + (e.username && e.username.toLowerCase().includes(q)) + ); + }) + .map(([id, entry]) => ({ id, ...entry })); +} + +export function findByUrl(manifest: Manifest, url: string): Array<{ id: string } & ManifestEntry> { + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + return []; + } + return Object.entries(manifest.entries) + .filter(([, e]) => { + if (!e.url) return false; + try { + return new URL(e.url).hostname === hostname; + } catch { + return false; + } + }) + .map(([id, entry]) => ({ id, ...entry })); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add extension/src/service-worker/vault.ts +git commit -m "feat: add vault operations module for service worker" +``` + +--- + +## Task 7: Service Worker — Main Entry Point + +**Files:** +- Create: `extension/src/service-worker/index.ts` + +- [ ] **Step 1: Create the service worker entry point** + +Create `extension/src/service-worker/index.ts`: + +```typescript +import type { Manifest, VaultConfig, SetupState } from '../shared/types'; +import type { Request, Response } from '../shared/messages'; +import { createGitHost, type GitHost } from './git-host'; +import { + setWasm, + fetchVaultMeta, + fetchAndDecryptManifest, + fetchAndDecryptEntry, + encryptAndWriteEntry, + encryptAndWriteManifest, + listEntries, + searchEntries, + findByUrl, +} from './vault'; + +// ─── State ────────────────────────────────────────────────────────────────── + +let masterKey: Uint8Array | null = null; +let manifest: Manifest | null = null; +let gitHost: GitHost | null = null; +let wasm: typeof import('../../wasm/idfoto_wasm') | null = null; + +// ─── WASM initialization ─────────────────────────────────────────────────── + +async function initWasm(): Promise { + if (wasm) return wasm; + const mod = await import(/* webpackIgnore: true */ './idfoto_wasm.js'); + await mod.default(); + wasm = mod; + setWasm(mod); + return mod; +} + +// ─── Config helpers ───────────────────────────────────────────────────────── + +async function loadConfig(): Promise<{ config: VaultConfig; imageBase64: string } | null> { + const data = await chrome.storage.local.get(['vaultConfig', 'imageBase64']); + if (!data.vaultConfig || !data.imageBase64) return null; + return { config: data.vaultConfig, imageBase64: data.imageBase64 }; +} + +async function saveConfig(config: VaultConfig, imageBase64: string): Promise { + await chrome.storage.local.set({ vaultConfig: config, imageBase64 }); +} + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function isoNow(): string { + return new Date().toISOString(); +} + +// ─── Message handler ──────────────────────────────────────────────────────── + +chrome.runtime.onMessage.addListener( + (request: Request, _sender, sendResponse: (response: Response) => void) => { + handleMessage(request) + .then(sendResponse) + .catch((err) => sendResponse({ error: String(err) })); + return true; // async response + } +); + +async function handleMessage(req: Request): Promise { + const w = await initWasm(); + + switch (req.type) { + case 'get_setup_state': { + const stored = await loadConfig(); + const state: SetupState = { + config: stored?.config ?? null, + imageBase64: stored?.imageBase64 ?? null, + isConfigured: stored !== null, + }; + return { ok: true, state } as Response; + } + + case 'save_setup': { + await saveConfig(req.config, req.imageBase64); + return { ok: true }; + } + + case 'is_unlocked': { + return { ok: true, unlocked: masterKey !== null } as Response; + } + + case 'unlock': { + const stored = await loadConfig(); + if (!stored) return { error: 'Extension not configured. Run setup first.' }; + + const imageBytes = base64ToUint8Array(stored.imageBase64); + const imageSecret = w.extract_image_secret(imageBytes); + + gitHost = createGitHost( + stored.config.hostType, + stored.config.hostUrl, + stored.config.repoPath, + stored.config.apiToken + ); + + const { salt, paramsJson } = await fetchVaultMeta(gitHost); + const key = w.derive_master_key(req.passphrase, imageSecret, salt, paramsJson); + masterKey = new Uint8Array(key); + + manifest = await fetchAndDecryptManifest(gitHost, masterKey); + return { ok: true }; + } + + case 'lock': { + masterKey = null; + manifest = null; + return { ok: true }; + } + + case 'list_entries': { + if (!manifest) return { error: 'Vault is locked' }; + const entries = listEntries(manifest, req.group); + return { ok: true, entries } as Response; + } + + case 'search_entries': { + if (!manifest) return { error: 'Vault is locked' }; + const entries = searchEntries(manifest, req.query); + return { ok: true, entries } as Response; + } + + case 'get_entry': { + if (!masterKey || !gitHost) return { error: 'Vault is locked' }; + const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id); + return { ok: true, entry } as Response; + } + + case 'add_entry': { + if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' }; + const now = isoNow(); + const entry = { ...req.entry, created_at: now, updated_at: now }; + const id = w.generate_entry_id(); + + await encryptAndWriteEntry(gitHost, masterKey, id, entry, `add entry '${entry.name}'`); + + manifest.entries[id] = { + name: entry.name, + url: entry.url, + username: entry.username, + group: entry.group, + updated_at: now, + }; + await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: add '${entry.name}'`); + + return { ok: true, id } as Response; + } + + case 'update_entry': { + if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' }; + const existing = manifest.entries[req.id]; + if (!existing) return { error: `Entry ${req.id} not found` }; + + const now = isoNow(); + const oldEntry = await fetchAndDecryptEntry(gitHost, masterKey, req.id); + const updated = { ...req.entry, created_at: oldEntry.created_at, updated_at: now }; + + await encryptAndWriteEntry(gitHost, masterKey, req.id, updated, `edit entry '${updated.name}'`); + + manifest.entries[req.id] = { + name: updated.name, + url: updated.url, + username: updated.username, + group: updated.group, + updated_at: now, + }; + await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: edit '${updated.name}'`); + + return { ok: true }; + } + + case 'delete_entry': { + if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' }; + const entry = manifest.entries[req.id]; + if (!entry) return { error: `Entry ${req.id} not found` }; + + await gitHost.deleteFile(`entries/${req.id}.enc`, `remove entry '${entry.name}'`); + delete manifest.entries[req.id]; + await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: remove '${entry.name}'`); + + return { ok: true }; + } + + case 'get_totp': { + if (!masterKey || !gitHost) return { error: 'Vault is locked' }; + const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id); + if (!entry.totp_secret) return { error: 'No TOTP secret for this entry' }; + + const now = Math.floor(Date.now() / 1000); + const code = w.generate_totp(entry.totp_secret, BigInt(now)); + const remaining = 30 - (now % 30); + return { ok: true, code, remaining_seconds: remaining } as Response; + } + + case 'get_autofill_candidates': { + if (!manifest) return { error: 'Vault is locked' }; + const candidates = findByUrl(manifest, req.url); + return { ok: true, candidates } as Response; + } + + case 'get_credentials': { + if (!masterKey || !gitHost) return { error: 'Vault is locked' }; + const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id); + return { ok: true, username: entry.username ?? '', password: entry.password } as Response; + } + + case 'sync': { + if (!masterKey || !gitHost) return { error: 'Vault is locked' }; + manifest = await fetchAndDecryptManifest(gitHost, masterKey); + return { ok: true }; + } + + case 'fill_credentials': { + // This is handled by the content script directly, not the service worker + return { ok: true }; + } + + default: + return { error: `Unknown message type: ${(req as Request).type}` }; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add extension/src/service-worker/index.ts +git commit -m "feat: add service worker with WASM init, state management, and message router" +``` + +--- + +## Task 8: Popup — Styles + +**Files:** +- Create: `extension/src/popup/styles.css` + +- [ ] **Step 1: Create the terminal dark theme** + +Create `extension/src/popup/styles.css`: + +```css +/* ─── Reset & Base ───────────────────────────────────────────────────────── */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + width: 360px; + min-height: 200px; + max-height: 500px; + overflow-y: auto; + background: #0d1117; + color: #c9d1d9; + font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', 'Menlo', monospace; + font-size: 12px; + line-height: 1.5; +} + +#app { + padding: 16px; +} + +/* ─── Typography ─────────────────────────────────────────────────────────── */ + +.brand { + color: #58a6ff; + font-size: 14px; + letter-spacing: 2px; + font-weight: normal; +} + +.label { + color: #484f58; + font-size: 10px; + text-transform: uppercase; + margin-bottom: 2px; +} + +.secondary { + color: #8b949e; +} + +.muted { + color: #484f58; +} + +.error { + color: #f85149; + font-size: 11px; +} + +/* ─── Inputs ─────────────────────────────────────────────────────────────── */ + +input, textarea { + background: #161b22; + border: 1px solid #30363d; + color: #c9d1d9; + font-family: inherit; + font-size: 12px; + padding: 6px 10px; + border-radius: 2px; + width: 100%; + outline: none; +} + +input:focus, textarea:focus { + border-color: #58a6ff; +} + +input::placeholder, textarea::placeholder { + color: #484f58; +} + +/* ─── Buttons ────────────────────────────────────────────────────────────── */ + +.btn { + background: #21262d; + border: 1px solid #30363d; + color: #c9d1d9; + font-family: inherit; + font-size: 11px; + padding: 4px 12px; + border-radius: 2px; + cursor: pointer; +} + +.btn:hover { + background: #30363d; +} + +.btn-primary { + background: #1f6feb; + border-color: #1f6feb; + color: #fff; +} + +.btn-primary:hover { + background: #388bfd; +} + +.btn-danger { + background: #da3633; + border-color: #da3633; + color: #fff; +} + +.btn-danger:hover { + background: #f85149; +} + +/* ─── Header ─────────────────────────────────────────────────────────────── */ + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.status { + color: #484f58; + font-size: 10px; +} + +/* ─── Search ─────────────────────────────────────────────────────────────── */ + +.search-bar { + margin-bottom: 8px; +} + +/* ─── Group Tabs ─────────────────────────────────────────────────────────── */ + +.group-tabs { + display: flex; + gap: 6px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.group-tab { + background: #21262d; + border: 1px solid #30363d; + padding: 2px 8px; + border-radius: 2px; + font-size: 10px; + color: #8b949e; + cursor: pointer; + font-family: inherit; +} + +.group-tab:hover { + background: #30363d; +} + +.group-tab.active { + background: #1f6feb; + border-color: #1f6feb; + color: #fff; +} + +/* ─── Entry List ─────────────────────────────────────────────────────────── */ + +.entry-list { + max-height: 300px; + overflow-y: auto; +} + +.entry-row { + padding: 6px 10px; + border-left: 2px solid transparent; + cursor: pointer; +} + +.entry-row:hover { + background: #161b22; +} + +.entry-row.selected { + border-left-color: #58a6ff; + background: #161b22; +} + +.entry-name { + color: #c9d1d9; + font-size: 12px; +} + +.entry-meta { + color: #484f58; + font-size: 10px; +} + +/* ─── Entry Detail ───────────────────────────────────────────────────────── */ + +.detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.detail-title { + color: #58a6ff; + font-size: 14px; +} + +.field { + margin-bottom: 8px; +} + +.field-value { + display: flex; + justify-content: space-between; + align-items: center; +} + +.field-value .hint { + color: #484f58; + font-size: 10px; +} + +/* ─── TOTP ───────────────────────────────────────────────────────────────── */ + +.totp-code { + color: #3fb950; + font-size: 16px; + letter-spacing: 4px; +} + +.totp-bar { + height: 2px; + background: #21262d; + margin-top: 4px; + border-radius: 1px; +} + +.totp-bar-fill { + height: 2px; + background: #3fb950; + border-radius: 1px; + transition: width 1s linear; +} + +/* ─── Footer / Keybinding Hints ──────────────────────────────────────────── */ + +.keyhints { + color: #484f58; + font-size: 10px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #21262d; +} + +/* ─── Setup Wizard ───────────────────────────────────────────────────────── */ + +.wizard-step { + color: #484f58; + font-size: 10px; + margin-bottom: 4px; +} + +.progress-bar { + height: 2px; + background: #21262d; + margin-bottom: 16px; + border-radius: 1px; +} + +.progress-bar-fill { + height: 2px; + background: #1f6feb; + border-radius: 1px; + transition: width 0.3s; +} + +.form-group { + margin-bottom: 12px; +} + +.host-toggle { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +/* ─── Spinner ────────────────────────────────────────────────────────────── */ + +.spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid #30363d; + border-top-color: #58a6ff; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ─── Actions Bar ────────────────────────────────────────────────────────── */ + +.actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 12px; +} + +/* ─── Confirm Dialog ─────────────────────────────────────────────────────── */ + +.confirm-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.confirm-box { + background: #161b22; + border: 1px solid #30363d; + padding: 16px; + border-radius: 4px; + max-width: 300px; +} + +/* ─── Empty State ────────────────────────────────────────────────────────── */ + +.empty { + color: #484f58; + text-align: center; + padding: 24px 0; + font-size: 11px; +} + +/* ─── Scrollbar ──────────────────────────────────────────────────────────── */ + +::-webkit-scrollbar { + width: 4px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #30363d; + border-radius: 2px; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add extension/src/popup/styles.css +git commit -m "feat: add terminal dark theme for popup" +``` + +--- + +## Task 9: Popup — State Machine and Components + +**Files:** +- Create: `extension/src/popup/popup.ts` +- Create: `extension/src/popup/components/unlock.ts` +- Create: `extension/src/popup/components/entry-list.ts` +- Create: `extension/src/popup/components/entry-detail.ts` +- Create: `extension/src/popup/components/entry-form.ts` +- Create: `extension/src/popup/components/setup-wizard.ts` + +This is the largest task. Each component is a function that renders into a container element and attaches its own event listeners. The popup state machine orchestrates transitions. + +- [ ] **Step 1: Create the popup state machine** + +Create `extension/src/popup/popup.ts`: + +```typescript +import type { Request, Response } from '../shared/messages'; +import { renderUnlock } from './components/unlock'; +import { renderEntryList } from './components/entry-list'; +import { renderEntryDetail } from './components/entry-detail'; +import { renderEntryForm } from './components/entry-form'; +import { renderSetupWizard } from './components/setup-wizard'; + +export type PopupState = + | { view: 'setup' } + | { view: 'locked' } + | { view: 'list' } + | { view: 'detail'; entryId: string } + | { view: 'add' } + | { view: 'edit'; entryId: string }; + +let currentState: PopupState = { view: 'locked' }; + +export function sendMessage(request: Request): Promise { + return chrome.runtime.sendMessage(request); +} + +export function navigate(state: PopupState) { + currentState = state; + render(); +} + +function render() { + const app = document.getElementById('app')!; + app.innerHTML = ''; + + switch (currentState.view) { + case 'setup': + renderSetupWizard(app); + break; + case 'locked': + renderUnlock(app); + break; + case 'list': + renderEntryList(app); + break; + case 'detail': + renderEntryDetail(app, currentState.entryId); + break; + case 'add': + renderEntryForm(app, null); + break; + case 'edit': + renderEntryForm(app, currentState.entryId); + break; + } +} + +// ─── Init ─────────────────────────────────────────────────────────────────── + +async function init() { + const resp = await sendMessage({ type: 'get_setup_state' }); + if ('error' in resp) { + navigate({ view: 'setup' }); + return; + } + + const state = (resp as { ok: true; state: import('../shared/types').SetupState }).state; + if (!state.isConfigured) { + navigate({ view: 'setup' }); + return; + } + + const unlockResp = await sendMessage({ type: 'is_unlocked' }); + if ('error' in unlockResp) { + navigate({ view: 'locked' }); + return; + } + + const unlocked = (unlockResp as { ok: true; unlocked: boolean }).unlocked; + navigate(unlocked ? { view: 'list' } : { view: 'locked' }); +} + +document.addEventListener('DOMContentLoaded', init); +``` + +- [ ] **Step 2: Create unlock component** + +Create `extension/src/popup/components/unlock.ts`: + +```typescript +import { sendMessage, navigate } from '../popup'; + +export function renderUnlock(container: HTMLElement) { + container.innerHTML = ` +
idfoto
+
+
PASSPHRASE
+ +
+ +
+
+ + +
+ `; + + const input = container.querySelector('#passphrase') as HTMLInputElement; + const errorEl = container.querySelector('#unlock-error') as HTMLElement; + const spinnerEl = container.querySelector('#unlock-spinner') as HTMLElement; + const btnUnlock = container.querySelector('#btn-unlock') as HTMLButtonElement; + const btnSettings = container.querySelector('#btn-settings') as HTMLButtonElement; + + async function doUnlock() { + const passphrase = input.value; + if (!passphrase) return; + + errorEl.textContent = ''; + spinnerEl.style.display = 'block'; + btnUnlock.disabled = true; + + const resp = await sendMessage({ type: 'unlock', passphrase }); + spinnerEl.style.display = 'none'; + btnUnlock.disabled = false; + + if ('error' in resp) { + errorEl.textContent = resp.error; + input.value = ''; + input.focus(); + } else { + navigate({ view: 'list' }); + } + } + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') doUnlock(); + if (e.key === 'Escape') window.close(); + }); + + btnUnlock.addEventListener('click', doUnlock); + btnSettings.addEventListener('click', () => navigate({ view: 'setup' })); +} +``` + +- [ ] **Step 3: Create entry list component** + +Create `extension/src/popup/components/entry-list.ts`: + +```typescript +import { sendMessage, navigate } from '../popup'; +import type { ManifestEntry } from '../../shared/types'; + +export async function renderEntryList(container: HTMLElement) { + container.innerHTML = ` +
+
idfoto
+
🔓 unlocked
+
+ +
+
+
Loading...
+
+
↑↓ navigate · ENTER open · / search · + add
+ `; + + const searchInput = container.querySelector('#search') as HTMLInputElement; + const groupTabsEl = container.querySelector('#group-tabs') as HTMLElement; + const listEl = container.querySelector('#entry-list') as HTMLElement; + + let entries: Array<{ id: string } & ManifestEntry> = []; + let selectedIndex = 0; + let activeGroup: string | undefined; + + async function loadEntries() { + const resp = await sendMessage({ type: 'list_entries', group: activeGroup }); + if ('error' in resp) return; + entries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries; + entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + selectedIndex = 0; + renderList(); + renderGroupTabs(); + } + + function renderGroupTabs() { + // Get all groups from entries (fetch all, not filtered) + sendMessage({ type: 'list_entries' }).then((resp) => { + if ('error' in resp) return; + const allEntries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries; + const groups = new Set(); + allEntries.forEach((e) => { if (e.group) groups.add(e.group); }); + + let html = ``; + for (const g of Array.from(groups).sort()) { + html += ``; + } + groupTabsEl.innerHTML = html; + + groupTabsEl.querySelectorAll('.group-tab').forEach((btn) => { + btn.addEventListener('click', () => { + const g = (btn as HTMLElement).dataset.group; + activeGroup = g || undefined; + loadEntries(); + }); + }); + }); + } + + function renderList() { + if (entries.length === 0) { + listEl.innerHTML = '
No entries found.
'; + return; + } + + listEl.innerHTML = entries + .map((e, i) => { + const domain = e.url ? extractDomain(e.url) : ''; + const meta = [e.username, domain].filter(Boolean).join(' · '); + return `
+
${escapeHtml(e.name)}
+ +
`; + }) + .join(''); + + listEl.querySelectorAll('.entry-row').forEach((row) => { + row.addEventListener('click', () => { + const id = (row as HTMLElement).dataset.id!; + navigate({ view: 'detail', entryId: id }); + }); + }); + } + + function handleSearch() { + const query = searchInput.value.trim(); + if (!query) { + loadEntries(); + return; + } + sendMessage({ type: 'search_entries', query }).then((resp) => { + if ('error' in resp) return; + entries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries; + entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + selectedIndex = 0; + renderList(); + }); + } + + searchInput.addEventListener('input', handleSearch); + + document.addEventListener('keydown', function handler(e) { + // Clean up when we navigate away + if (!container.isConnected) { + document.removeEventListener('keydown', handler); + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (selectedIndex < entries.length - 1) { + selectedIndex++; + renderList(); + } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (selectedIndex > 0) { + selectedIndex--; + renderList(); + } + } else if (e.key === 'Enter' && document.activeElement !== searchInput) { + if (entries[selectedIndex]) { + navigate({ view: 'detail', entryId: entries[selectedIndex].id }); + } + } else if (e.key === '/' && document.activeElement !== searchInput) { + e.preventDefault(); + searchInput.focus(); + } else if (e.key === '+' && document.activeElement !== searchInput) { + e.preventDefault(); + navigate({ view: 'add' }); + } else if (e.key === 'Escape') { + if (document.activeElement === searchInput) { + searchInput.value = ''; + searchInput.blur(); + loadEntries(); + } else { + window.close(); + } + } + }); + + await loadEntries(); +} + +function extractDomain(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +function escapeHtml(s: string): string { + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; +} +``` + +- [ ] **Step 4: Create entry detail component** + +Create `extension/src/popup/components/entry-detail.ts`: + +```typescript +import { sendMessage, navigate } from '../popup'; +import type { Entry } from '../../shared/types'; + +export async function renderEntryDetail(container: HTMLElement, entryId: string) { + container.innerHTML = ` +
+ ← ESC back + +
+
Loading...
+
+
f fill · e edit · d delete
+ `; + + const backBtn = container.querySelector('#back') as HTMLElement; + const nameEl = container.querySelector('#entry-name') as HTMLElement; + const groupEl = container.querySelector('#entry-group') as HTMLElement; + const fieldsEl = container.querySelector('#entry-fields') as HTMLElement; + + backBtn.addEventListener('click', () => navigate({ view: 'list' })); + + const resp = await sendMessage({ type: 'get_entry', id: entryId }); + if ('error' in resp) { + nameEl.textContent = 'Error loading entry'; + return; + } + + const entry = (resp as { ok: true; entry: Entry }).entry; + nameEl.textContent = entry.name; + groupEl.textContent = entry.group ?? ''; + + let fieldsHtml = ''; + + if (entry.url) { + fieldsHtml += ` +
+
URL
+
${escapeHtml(entry.url)}
+
`; + } + + if (entry.username) { + fieldsHtml += ` +
+
USERNAME
+
+ ${escapeHtml(entry.username)} + c copy +
+
`; + } + + fieldsHtml += ` +
+
PASSWORD
+
+ •••••••••• + p copy +
+
`; + + if (entry.totp_secret) { + fieldsHtml += ` +
+
TOTP
+
+ ------ + --s · t copy +
+
+
`; + } + + if (entry.notes) { + fieldsHtml += ` +
+
NOTES
+
${escapeHtml(entry.notes)}
+
`; + } + + fieldsEl.innerHTML = fieldsHtml; + + // ─── TOTP countdown ──────────────────────────────────────────────────── + + let totpInterval: ReturnType | null = null; + + if (entry.totp_secret) { + async function updateTotp() { + const totpResp = await sendMessage({ type: 'get_totp', id: entryId }); + if ('error' in totpResp) return; + const { code, remaining_seconds } = totpResp as { ok: true; code: string; remaining_seconds: number }; + + const codeEl = container.querySelector('#totp-code'); + const remainEl = container.querySelector('#totp-remaining'); + const barEl = container.querySelector('#totp-bar-fill') as HTMLElement | null; + + if (codeEl) codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3); + if (remainEl) remainEl.textContent = String(remaining_seconds); + if (barEl) barEl.style.width = `${(remaining_seconds / 30) * 100}%`; + } + + await updateTotp(); + totpInterval = setInterval(updateTotp, 1000); + } + + // ─── Copy handlers ──────────────────────────────────────────────────── + + async function copyToClipboard(text: string) { + await navigator.clipboard.writeText(text); + } + + container.querySelectorAll('[data-copy]').forEach((el) => { + el.addEventListener('click', async () => { + const field = (el as HTMLElement).dataset.copy; + if (field === 'username' && entry.username) { + await copyToClipboard(entry.username); + } else if (field === 'password') { + await copyToClipboard(entry.password); + } else if (field === 'totp') { + const totpResp = await sendMessage({ type: 'get_totp', id: entryId }); + if (!('error' in totpResp)) { + await copyToClipboard((totpResp as { ok: true; code: string }).code); + } + } + }); + }); + + // ─── Keyboard shortcuts ─────────────────────────────────────────────── + + document.addEventListener('keydown', function handler(e) { + if (!container.isConnected) { + if (totpInterval) clearInterval(totpInterval); + document.removeEventListener('keydown', handler); + return; + } + + if (e.key === 'Escape') { + navigate({ view: 'list' }); + } else if (e.key === 'c') { + if (entry.username) copyToClipboard(entry.username); + } else if (e.key === 'p') { + copyToClipboard(entry.password); + } else if (e.key === 't' && entry.totp_secret) { + sendMessage({ type: 'get_totp', id: entryId }).then((resp) => { + if (!('error' in resp)) { + copyToClipboard((resp as { ok: true; code: string }).code); + } + }); + } else if (e.key === 'e') { + navigate({ view: 'edit', entryId }); + } else if (e.key === 'f') { + // Autofill active tab + chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => { + if (!tabs[0]?.id) return; + const creds = await sendMessage({ type: 'get_credentials', id: entryId }); + if ('error' in creds) return; + const { username, password } = creds as { ok: true; username: string; password: string }; + chrome.tabs.sendMessage(tabs[0].id, { type: 'fill_credentials', username, password }); + }); + } else if (e.key === 'd') { + showDeleteConfirm(container, entryId, entry.name); + } + }); +} + +function showDeleteConfirm(container: HTMLElement, entryId: string, name: string) { + const overlay = document.createElement('div'); + overlay.className = 'confirm-overlay'; + overlay.innerHTML = ` +
+

Delete ${escapeHtml(name)}?

+

This commits a removal to the vault.

+
+ + +
+
+ `; + container.appendChild(overlay); + + overlay.querySelector('#confirm-no')!.addEventListener('click', () => overlay.remove()); + overlay.querySelector('#confirm-yes')!.addEventListener('click', async () => { + await sendMessage({ type: 'delete_entry', id: entryId }); + navigate({ view: 'list' }); + }); +} + +function escapeHtml(s: string): string { + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; +} +``` + +- [ ] **Step 5: Create entry form component (add/edit)** + +Create `extension/src/popup/components/entry-form.ts`: + +```typescript +import { sendMessage, navigate } from '../popup'; +import type { Entry } from '../../shared/types'; + +export async function renderEntryForm(container: HTMLElement, entryId: string | null) { + const isEdit = entryId !== null; + let existing: Entry | null = null; + + if (isEdit) { + const resp = await sendMessage({ type: 'get_entry', id: entryId }); + if ('error' in resp) { + container.innerHTML = '
Failed to load entry
'; + return; + } + existing = (resp as { ok: true; entry: Entry }).entry; + } + + container.innerHTML = ` +
+ ← ESC back + ${isEdit ? 'edit' : 'add'} +
+
+
NAME *
+ +
+
+
URL
+ +
+
+
USERNAME
+ +
+
+
PASSWORD
+
+ + +
+
+
+
TOTP SECRET
+ +
+
+
GROUP
+ +
+
+
NOTES
+ +
+
+
+ + +
+ `; + + const backBtn = container.querySelector('#back') as HTMLElement; + const btnCancel = container.querySelector('#btn-cancel') as HTMLButtonElement; + const btnSave = container.querySelector('#btn-save') as HTMLButtonElement; + const btnGenerate = container.querySelector('#btn-generate') as HTMLButtonElement; + const errorEl = container.querySelector('#form-error') as HTMLElement; + + function goBack() { + if (isEdit) { + navigate({ view: 'detail', entryId: entryId! }); + } else { + navigate({ view: 'list' }); + } + } + + backBtn.addEventListener('click', goBack); + btnCancel.addEventListener('click', goBack); + + btnGenerate.addEventListener('click', async () => { + const resp = await sendMessage({ type: 'is_unlocked' }); // Just to trigger WASM load + // Generate locally via service worker — but we can just use crypto.getRandomValues + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+'; + const array = new Uint8Array(24); + crypto.getRandomValues(array); + const password = Array.from(array, (b) => chars[b % chars.length]).join(''); + (container.querySelector('#f-password') as HTMLInputElement).value = password; + }); + + btnSave.addEventListener('click', async () => { + const name = (container.querySelector('#f-name') as HTMLInputElement).value.trim(); + if (!name) { + errorEl.textContent = 'Name is required'; + return; + } + + const entry = { + name, + url: (container.querySelector('#f-url') as HTMLInputElement).value.trim() || undefined, + username: (container.querySelector('#f-username') as HTMLInputElement).value.trim() || undefined, + password: (container.querySelector('#f-password') as HTMLInputElement).value, + totp_secret: (container.querySelector('#f-totp') as HTMLInputElement).value.trim() || undefined, + group: (container.querySelector('#f-group') as HTMLInputElement).value.trim() || undefined, + notes: (container.querySelector('#f-notes') as HTMLTextAreaElement).value.trim() || undefined, + }; + + btnSave.disabled = true; + errorEl.textContent = ''; + + let resp; + if (isEdit) { + resp = await sendMessage({ type: 'update_entry', id: entryId!, entry }); + } else { + resp = await sendMessage({ type: 'add_entry', entry }); + } + + if ('error' in resp) { + errorEl.textContent = resp.error; + btnSave.disabled = false; + return; + } + + if (isEdit) { + navigate({ view: 'detail', entryId: entryId! }); + } else { + navigate({ view: 'list' }); + } + }); + + document.addEventListener('keydown', function handler(e) { + if (!container.isConnected) { + document.removeEventListener('keydown', handler); + return; + } + if (e.key === 'Escape') goBack(); + }); +} + +function escapeHtml(s: string): string { + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; +} + +function escapeAttr(s: string): string { + return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +} +``` + +- [ ] **Step 6: Create setup wizard component** + +Create `extension/src/popup/components/setup-wizard.ts`: + +```typescript +import { sendMessage, navigate } from '../popup'; +import type { VaultConfig } from '../../shared/types'; + +export function renderSetupWizard(container: HTMLElement) { + let step = 1; + let config: Partial = { hostType: 'gitea' }; + let imageBase64: string | null = null; + + function render() { + container.innerHTML = ` +
idfoto setup
+
step ${step} of 3 — ${['repository', 'reference image', 'test unlock'][step - 1]}
+
+
+
+
+ `; + const content = container.querySelector('#wizard-content') as HTMLElement; + const actions = container.querySelector('#wizard-actions') as HTMLElement; + + if (step === 1) renderStep1(content, actions); + else if (step === 2) renderStep2(content, actions); + else if (step === 3) renderStep3(content, actions); + } + + function renderStep1(content: HTMLElement, actions: HTMLElement) { + content.innerHTML = ` +
+
HOST TYPE
+
+ + +
+
+
+
HOST URL
+ +
+
+
REPO PATH
+ +
+
+
API TOKEN
+ +
+ `; + actions.innerHTML = ``; + + content.querySelectorAll('[data-host]').forEach((btn) => { + btn.addEventListener('click', () => { + config.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github'; + render(); + }); + }); + + actions.querySelector('#btn-next')!.addEventListener('click', () => { + config.hostUrl = (content.querySelector('#w-host') as HTMLInputElement).value.trim(); + config.repoPath = (content.querySelector('#w-repo') as HTMLInputElement).value.trim(); + config.apiToken = (content.querySelector('#w-token') as HTMLInputElement).value.trim(); + + const errorEl = container.querySelector('#wizard-error') as HTMLElement; + if (!config.hostUrl || !config.repoPath || !config.apiToken) { + errorEl.textContent = 'All fields are required'; + return; + } + step = 2; + render(); + }); + } + + function renderStep2(content: HTMLElement, actions: HTMLElement) { + content.innerHTML = ` +
+
REFERENCE IMAGE
+

+ Upload the JPEG with your embedded secret. Stored locally, never sent to the server. +

+ +
+ ${imageBase64 ? '✓ Image loaded' : 'No image selected'} +
+
+ `; + actions.innerHTML = ` + + + `; + + const fileInput = content.querySelector('#w-image') as HTMLInputElement; + const statusEl = content.querySelector('#image-status') as HTMLElement; + + fileInput.addEventListener('change', () => { + const file = fileInput.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + // Strip the data:image/jpeg;base64, prefix + imageBase64 = dataUrl.split(',')[1]; + statusEl.textContent = `✓ ${file.name} (${(file.size / 1024).toFixed(0)} KB)`; + }; + reader.readAsDataURL(file); + }); + + actions.querySelector('#btn-back')!.addEventListener('click', () => { step = 1; render(); }); + actions.querySelector('#btn-next')!.addEventListener('click', () => { + const errorEl = container.querySelector('#wizard-error') as HTMLElement; + if (!imageBase64) { + errorEl.textContent = 'Please select a reference image'; + return; + } + step = 3; + render(); + }); + } + + function renderStep3(content: HTMLElement, actions: HTMLElement) { + content.innerHTML = ` +
+
TEST UNLOCK
+

+ Enter your passphrase to verify everything works. +

+ + +
+ `; + actions.innerHTML = ` + + + `; + + actions.querySelector('#btn-back')!.addEventListener('click', () => { step = 2; render(); }); + actions.querySelector('#btn-finish')!.addEventListener('click', async () => { + const passphrase = (content.querySelector('#w-passphrase') as HTMLInputElement).value; + const errorEl = container.querySelector('#wizard-error') as HTMLElement; + const spinnerEl = content.querySelector('#test-spinner') as HTMLElement; + + if (!passphrase) { + errorEl.textContent = 'Enter your passphrase'; + return; + } + + errorEl.textContent = ''; + spinnerEl.style.display = 'block'; + + // Save config first + await sendMessage({ + type: 'save_setup', + config: config as VaultConfig, + imageBase64: imageBase64!, + }); + + // Try to unlock + const resp = await sendMessage({ type: 'unlock', passphrase }); + spinnerEl.style.display = 'none'; + + if ('error' in resp) { + errorEl.textContent = `Test failed: ${resp.error}`; + return; + } + + navigate({ view: 'list' }); + }); + } + + render(); +} +``` + +- [ ] **Step 7: Install npm dependencies** + +```bash +cd extension && npm install +``` + +Expected: `node_modules/` created with TypeScript, webpack, etc. + +- [ ] **Step 8: Build the extension** + +```bash +cd extension && npm run build +``` + +Expected: `dist/` directory with `service-worker.js`, `popup.js`, `content.js`, `popup.html`, `styles.css`, `manifest.json`, WASM files, and icons. + +If build fails, fix any TypeScript errors and retry. + +- [ ] **Step 9: Commit** + +```bash +git add extension/src/popup/ extension/src/service-worker/index.ts +git commit -m "feat: add popup UI with state machine, all components, and setup wizard" +``` + +--- + +## Task 10: Content Script — Form Detection and Autofill + +**Files:** +- Create: `extension/src/content/detector.ts` +- Create: `extension/src/content/fill.ts` +- Create: `extension/src/content/icon.ts` + +- [ ] **Step 1: Create the form detector and content script entry point** + +Create `extension/src/content/detector.ts`: + +```typescript +import { injectFieldIcons } from './icon'; +import { setupFillListener } from './fill'; + +function detectLoginForms(): { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[] { + const passwordFields = document.querySelectorAll('input[type="password"]'); + const results: { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[] = []; + + for (const pwField of passwordFields) { + if (!pwField.offsetParent) continue; // skip hidden fields + + const usernameField = findUsernameField(pwField); + results.push({ passwordField: pwField, usernameField }); + } + + return results; +} + +function findUsernameField(passwordField: HTMLInputElement): HTMLInputElement | null { + const form = passwordField.closest('form'); + const scope = form ?? document; + + // Priority 1: autocomplete="username" + const byAutocompleteUser = scope.querySelector('input[autocomplete="username"]'); + if (byAutocompleteUser) return byAutocompleteUser; + + // Priority 2: autocomplete="email" + const byAutocompleteEmail = scope.querySelector('input[autocomplete="email"]'); + if (byAutocompleteEmail) return byAutocompleteEmail; + + // Priority 3: type="email" + const byTypeEmail = scope.querySelector('input[type="email"]'); + if (byTypeEmail) return byTypeEmail; + + // Priority 4: name matching pattern + const byNamePattern = scope.querySelector( + 'input[name*="user" i], input[name*="email" i], input[name*="login" i], input[name*="account" i]' + ); + if (byNamePattern && byNamePattern !== passwordField) return byNamePattern; + + // Priority 5: nearest preceding text input in same form + if (form) { + const inputs = Array.from(form.querySelectorAll('input[type="text"], input[type="email"], input:not([type])')); + const pwIndex = Array.from(form.querySelectorAll('input')).indexOf(passwordField); + for (let i = inputs.length - 1; i >= 0; i--) { + const inp = inputs[i]; + const inpIndex = Array.from(form.querySelectorAll('input')).indexOf(inp); + if (inpIndex < pwIndex) return inp; + } + } + + return null; +} + +// ─── Init ─────────────────────────────────────────────────────────────────── + +function init() { + const forms = detectLoginForms(); + if (forms.length > 0) { + injectFieldIcons(forms); + } + setupFillListener(); +} + +// Run detection after DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} + +// Re-detect on DOM changes (for SPAs that load forms dynamically) +const observer = new MutationObserver(() => { + const forms = detectLoginForms(); + if (forms.length > 0) { + injectFieldIcons(forms); + } +}); +observer.observe(document.body, { childList: true, subtree: true }); +``` + +- [ ] **Step 2: Create the fill module** + +Create `extension/src/content/fill.ts`: + +```typescript +export function setupFillListener() { + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message.type === 'fill_credentials') { + fillFields(message.username, message.password); + sendResponse({ ok: true }); + } + return false; + }); +} + +export function fillFields(username: string, password: string) { + const passwordField = document.querySelector('input[type="password"]'); + if (!passwordField) return; + + // Fill password + setInputValue(passwordField, password); + + // Find and fill username + const form = passwordField.closest('form'); + const scope = form ?? document; + + const usernameField = + scope.querySelector('input[autocomplete="username"]') ?? + scope.querySelector('input[autocomplete="email"]') ?? + scope.querySelector('input[type="email"]') ?? + scope.querySelector( + 'input[name*="user" i], input[name*="email" i], input[name*="login" i]' + ); + + if (usernameField && username) { + setInputValue(usernameField, username); + } + + // Focus submit button or password field + const submitBtn = form?.querySelector('button[type="submit"], input[type="submit"]'); + if (submitBtn) { + submitBtn.focus(); + } else { + passwordField.focus(); + } +} + +function setInputValue(input: HTMLInputElement, value: string) { + // Use native setter to bypass React/Vue/Angular controlled component checks + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + if (nativeSetter) { + nativeSetter.call(input, value); + } else { + input.value = value; + } + + // Dispatch events that frameworks listen for + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); +} +``` + +- [ ] **Step 3: Create the field icon module** + +Create `extension/src/content/icon.ts`: + +```typescript +import type { ManifestEntry } from '../shared/types'; +import { fillFields } from './fill'; + +const ICON_SVG = ` + + id +`; + +const injectedFields = new WeakSet(); + +export function injectFieldIcons( + forms: { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[] +) { + for (const { passwordField, usernameField } of forms) { + if (injectedFields.has(passwordField)) continue; + injectedFields.add(passwordField); + + const icon = document.createElement('div'); + icon.innerHTML = ICON_SVG; + icon.style.cssText = ` + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + cursor: pointer; + z-index: 10000; + opacity: 0.8; + transition: opacity 0.2s; + `; + icon.addEventListener('mouseenter', () => { icon.style.opacity = '1'; }); + icon.addEventListener('mouseleave', () => { icon.style.opacity = '0.8'; }); + + // Wrap the password field in a relative container if needed + const parent = passwordField.parentElement; + if (parent) { + const computed = getComputedStyle(parent); + if (computed.position === 'static') { + parent.style.position = 'relative'; + } + parent.appendChild(icon); + } + + icon.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + const url = window.location.href; + const resp = await chrome.runtime.sendMessage({ type: 'get_autofill_candidates', url }); + + if ('error' in resp || !resp.candidates || resp.candidates.length === 0) { + // No matches — open popup + return; + } + + const candidates = resp.candidates as Array<{ id: string } & ManifestEntry>; + + if (candidates.length === 1) { + // Single match — fill immediately + const creds = await chrome.runtime.sendMessage({ type: 'get_credentials', id: candidates[0].id }); + if (!('error' in creds)) { + fillFields(creds.username, creds.password); + } + } else { + // Multiple matches — show picker + showPicker(icon, candidates, usernameField, passwordField); + } + }); + } +} + +function showPicker( + anchor: HTMLElement, + candidates: Array<{ id: string } & ManifestEntry>, + usernameField: HTMLInputElement | null, + passwordField: HTMLInputElement +) { + // Remove any existing picker + document.querySelectorAll('.idfoto-picker').forEach((el) => el.remove()); + + const picker = document.createElement('div'); + picker.className = 'idfoto-picker'; + picker.style.cssText = ` + position: absolute; + right: 0; + top: 100%; + margin-top: 4px; + background: #161b22; + border: 1px solid #30363d; + border-radius: 4px; + min-width: 200px; + z-index: 10001; + font-family: monospace; + font-size: 12px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + `; + + for (const candidate of candidates) { + const row = document.createElement('div'); + row.style.cssText = ` + padding: 6px 10px; + cursor: pointer; + color: #c9d1d9; + border-bottom: 1px solid #21262d; + `; + row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; }); + row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; }); + row.innerHTML = ` +
${escapeHtml(candidate.name)}
+
${escapeHtml(candidate.username ?? '')}
+ `; + row.addEventListener('click', async () => { + const creds = await chrome.runtime.sendMessage({ type: 'get_credentials', id: candidate.id }); + if (!('error' in creds)) { + fillFields(creds.username, creds.password); + } + picker.remove(); + }); + picker.appendChild(row); + } + + anchor.parentElement?.appendChild(picker); + + // Close picker on outside click + document.addEventListener('click', function handler(e) { + if (!picker.contains(e.target as Node) && e.target !== anchor) { + picker.remove(); + document.removeEventListener('click', handler); + } + }); +} + +function escapeHtml(s: string): string { + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; +} +``` + +- [ ] **Step 4: Update webpack config entry for content script** + +The webpack config should bundle all three content script files together. Update the content entry in `extension/webpack.config.js`: + +The entry `content: './src/content/detector.ts'` already imports `icon.ts` and `fill.ts`, so webpack will bundle them together. No change needed. + +- [ ] **Step 5: Build and verify** + +```bash +cd extension && npm run build +``` + +Expected: `dist/content.js` contains the bundled content script. + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/content/ +git commit -m "feat: add content script with form detection, field icon, and autofill" +``` + +--- + +## Task 11: Build Integration and Manual Test + +**Files:** +- Modify: `extension/webpack.config.js` (if needed) +- Create: `Makefile` (optional convenience) + +- [ ] **Step 1: Full build from scratch** + +```bash +# Build WASM +wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm + +# Install deps and build extension +cd extension && npm install && npm run build +``` + +Expected: `extension/dist/` contains all files needed to load as an unpacked Chrome extension. + +- [ ] **Step 2: Note the WASM binary size** + +```bash +ls -lh extension/wasm/idfoto_wasm_bg.wasm +ls -lh extension/dist/idfoto_wasm_bg.wasm +``` + +Record the size for reference. If >2 MB uncompressed, consider optimizing later. + +- [ ] **Step 3: Load in Chrome** + +Open `chrome://extensions/`, enable Developer Mode, click "Load unpacked", select `extension/dist/`. Verify: +- Extension appears in the toolbar +- Clicking the icon opens the popup +- Setup wizard renders correctly +- No console errors in the service worker (inspect via "Service Worker" link on the extensions page) + +- [ ] **Step 4: Test the full flow manually** + +1. Setup wizard: enter Gitea/GitHub host, repo, token, upload reference image +2. Test unlock: enter passphrase, verify spinner + successful unlock +3. Entry list: verify entries load from remote vault +4. Add entry: create a new entry with a group +5. Entry detail: verify TOTP countdown (if entry has TOTP), copy shortcuts +6. Autofill: navigate to a login page, verify field icon appears, click to fill +7. Lock/re-unlock: close popup, wait, reopen — verify re-unlock is required + +- [ ] **Step 5: Fix any issues found during manual testing** + +Address any bugs or UX issues discovered during manual testing. Each fix gets its own commit. + +- [ ] **Step 6: Final commit** + +```bash +git add -A +git commit -m "feat: complete WASM + Chrome MV3 extension build" +``` + +--- + +## Task Summary + +| Task | Description | Dependencies | +|------|-------------|--------------| +| 1 | Add `group` field to core data model | None | +| 2 | Create `idfoto-wasm` crate | Task 1 | +| 3 | Extension scaffolding | Task 2 | +| 4 | Shared types and messages | Task 3 | +| 5 | Git API layer | Task 4 | +| 6 | Service worker — vault operations | Task 4, 5 | +| 7 | Service worker — main entry point | Task 5, 6 | +| 8 | Popup styles | Task 3 | +| 9 | Popup state machine and components | Task 4, 7, 8 | +| 10 | Content script | Task 4 | +| 11 | Build integration and manual test | All | + +Tasks 3-4, 5, 8 can be parallelized. Tasks 6-7 are sequential. Task 9 depends on 7+8. Task 10 is independent of 9. Task 11 is the final integration.