Files
relicario/docs/superpowers/plans/2026-04-12-idfoto-wasm-extension.md
adlee-was-taken 822547f349 docs: add Task 0 for heavy Rust code documentation
Adds a pre-implementation task to thoroughly document all existing
Rust code in idfoto-core and idfoto-cli with doc comments explaining
the crypto pipeline, steganography algorithm, and vault data model.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:15:33 -04:00

3291 lines
103 KiB
Markdown

# 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 0: Add Heavy Comments to Existing Rust Code
**Files:**
- Modify: `crates/idfoto-core/src/lib.rs`
- Modify: `crates/idfoto-core/src/error.rs`
- Modify: `crates/idfoto-core/src/crypto.rs`
- Modify: `crates/idfoto-core/src/entry.rs`
- Modify: `crates/idfoto-core/src/vault.rs`
- Modify: `crates/idfoto-core/src/imgsecret.rs`
- Modify: `crates/idfoto-cli/src/main.rs`
Add thorough documentation comments to all existing Rust code. Every public function, struct, field, constant, and non-trivial private function should have doc comments explaining what it does, why it exists, and any important constraints. Module-level docs should explain the module's role in the overall architecture.
Guidelines:
- Use `///` for public items and `//` for inline explanations of non-obvious logic
- Explain the "why" not just the "what" — e.g., why XChaCha20 over AES-GCM, why mid-frequency DCT coefficients, why majority voting
- Reference the crypto pipeline and threat model from the design spec where relevant
- Document parameters, return values, and error conditions
- For `imgsecret.rs`, explain the steganography algorithm step by step — this is the novel component and should read like a tutorial
- For `crypto.rs`, document the binary format and why each field exists
- For `entry.rs`, document the vault data model and serialization strategy
- For `main.rs`, document the CLI flow and unlock sequence
- [ ] **Step 1: Add module-level docs and comments to `lib.rs`**
```rust
//! # idfoto-core
//!
//! Platform-agnostic core library for the idfoto password manager.
//!
//! This crate is deliberately bytes-in/bytes-out — no filesystem, no network,
//! no git operations. This makes it portable to WASM (browser extension),
//! Android (JNI), and iOS (Swift bridge) without modification.
//!
//! ## Modules
//!
//! - [`crypto`] — Argon2id key derivation + XChaCha20-Poly1305 authenticated encryption
//! - [`entry`] — Entry, ManifestEntry, and Manifest data structures
//! - [`vault`] — High-level encrypt/decrypt operations for entries and manifests
//! - [`imgsecret`] — DCT-based 256-bit secret embedding in JPEG images
//! - [`error`] — Error types for all operations
```
- [ ] **Step 2: Add comments to `error.rs`**
Document each error variant with when it occurs and what the caller should do about it.
- [ ] **Step 3: Add comments to `crypto.rs`**
Document:
- The binary ciphertext format (version byte + nonce + ciphertext + tag) and why each field exists
- Why XChaCha20-Poly1305 was chosen over AES-GCM (192-bit nonce eliminates collision risk, fast in WASM/ARM without AES-NI)
- The KDF pipeline: how passphrase + image_secret are concatenated as the Argon2id password input
- KdfParams and why they're configurable (future parameter upgrades, different hardware profiles)
- Constants and their significance (VERSION_BYTE, NONCE_LEN, TAG_LEN, HEADER_LEN)
- [ ] **Step 4: Add comments to `entry.rs`**
Document:
- The vault data model: Entry (full credential), ManifestEntry (index metadata), Manifest (entry index)
- Why the manifest exists (decrypt one file to list/search entries without decrypting every entry)
- `skip_serializing_if` strategy for optional fields (backwards compatibility, compact JSON)
- Entry ID format (random 8-char hex, 32 bits — sufficient for family vault sizes)
- The search implementation and its case-insensitive substring matching
- [ ] **Step 5: Add comments to `vault.rs`**
Document:
- The relationship between vault.rs and crypto.rs (vault = typed wrappers around raw encrypt/decrypt)
- The serialization path: struct → JSON → encrypt → ciphertext bytes (and reverse)
- Why a single master_key is used for both entries and manifest (simpler, sufficient for family vault sizes)
- [ ] **Step 6: Add comments to `imgsecret.rs`**
This is the technically novel component. Document heavily:
- Module-level doc explaining the DCT steganography approach and why it was chosen
- Constants: BLOCK_SIZE, QUANT_STEP (why 50.0 — higher than typical 25 to survive JPEG recompression), MIN_DIMENSION, SECRET_BITS, MIN_COPIES, BLOCKS_PER_COPY, EMBED_POSITIONS (mid-frequency DCT coefficients in zig-zag order)
- YChannel: what it represents, why only luminance (survives re-encoding best, Cb/Cr often subsampled)
- EmbedRegion: the central 70% concept, the 15% crumple zone for crop tolerance
- DCT functions: what DCT is, why 8x8 blocks (matches JPEG's own block size), the 2D DCT as row-then-column 1D DCTs
- QIM (Quantization Index Modulation): how it encodes bits by rounding to quantization grids, why it's robust to re-quantization
- The embedding process step by step: decode → extract Y → define region → block DCT → select blocks → encode with redundancy → QIM embed → reconstruct
- The extraction process: canonical try → crop recovery search → majority voting with confidence threshold
- Bit conversion helpers
- Block selection strategy (evenly spaced, fixed geometric pattern)
- The reconstruct_jpeg function and the YCbCr ↔ RGB color space conversion
- [ ] **Step 7: Add comments to `main.rs` (CLI)**
Document:
- Module-level doc explaining this is the platform layer (filesystem, git, terminal I/O)
- The unlock flow: passphrase prompt → read reference image → extract image_secret → read salt/params → derive master_key
- Each CLI command's purpose and flow
- Helper functions: why git_commit uses `git add -A`, why generate_password uses OsRng, why now_iso8601 uses UNIX epoch seconds
- Device management: ed25519 key generation, why device keys are separate from the KDF
- [ ] **Step 8: Run tests to verify comments didn't break anything**
Run: `cargo test`
Expected: All tests pass unchanged.
- [ ] **Step 9: Commit**
```bash
git add crates/idfoto-core/src/ crates/idfoto-cli/src/main.rs
git commit -m "docs: add heavy documentation comments to all Rust code"
```
---
## 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
pub password: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub totp_secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
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<Vec<u8>, 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, &params)
.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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<String, JsValue> {
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<Vec<u8>, 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<String, JsValue> {
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<String, JsValue> {
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::<Sha1>::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": ["<all_urls>"],
"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": ["<all_urls>"],
"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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=360">
<link rel="stylesheet" href="styles.css">
<title>idfoto</title>
</head>
<body>
<div id="app"></div>
<script src="popup.js"></script>
</body>
</html>
```
- [ ] **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<string, ManifestEntry>;
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<Entry, 'created_at' | 'updated_at'> }
| { type: 'update_entry'; id: string; entry: Omit<Entry, 'created_at' | 'updated_at'> }
| { 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<Uint8Array>;
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
deleteFile(path: string, message: string): Promise<void>;
listDir(path: string): Promise<string[]>;
}
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<Uint8Array>;
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
deleteFile(path: string, message: string): Promise<void>;
listDir(path: string): Promise<string[]>;
}
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<Uint8Array> {
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<void> {
// 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<string, string> = {
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<void> {
// 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<string[]> {
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<Uint8Array> {
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<void> {
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<string, string> = {
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<void> {
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<string[]> {
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<Manifest> {
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<Entry> {
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<void> {
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<void> {
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<typeof import('../../wasm/idfoto_wasm')> {
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<void> {
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<Response> {
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<Response> {
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 = `
<div class="brand">idfoto</div>
<div style="margin-top: 16px">
<div class="label">PASSPHRASE</div>
<input type="password" id="passphrase" placeholder="Enter passphrase..." autofocus>
<div id="unlock-error" class="error" style="margin-top: 4px"></div>
<div id="unlock-spinner" style="margin-top: 8px; display: none">
<span class="spinner"></span> <span class="secondary">Deriving key...</span>
</div>
</div>
<div class="actions" style="margin-top: 16px">
<button class="btn muted" id="btn-settings">settings</button>
<button class="btn-primary btn" id="btn-unlock">ENTER unlock</button>
</div>
`;
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 = `
<div class="header">
<div class="brand">idfoto</div>
<div class="status">🔓 unlocked</div>
</div>
<div class="search-bar">
<input type="text" id="search" placeholder="/ search...">
</div>
<div class="group-tabs" id="group-tabs"></div>
<div class="entry-list" id="entry-list">
<div class="empty">Loading...</div>
</div>
<div class="keyhints">↑↓ navigate · ENTER open · / search · + add</div>
`;
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<string>();
allEntries.forEach((e) => { if (e.group) groups.add(e.group); });
let html = `<button class="group-tab ${!activeGroup ? 'active' : ''}" data-group="">all</button>`;
for (const g of Array.from(groups).sort()) {
html += `<button class="group-tab ${activeGroup === g ? 'active' : ''}" data-group="${g}">${g}</button>`;
}
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 = '<div class="empty">No entries found.</div>';
return;
}
listEl.innerHTML = entries
.map((e, i) => {
const domain = e.url ? extractDomain(e.url) : '';
const meta = [e.username, domain].filter(Boolean).join(' · ');
return `<div class="entry-row ${i === selectedIndex ? 'selected' : ''}" data-index="${i}" data-id="${e.id}">
<div class="entry-name">${escapeHtml(e.name)}</div>
<div class="entry-meta">${escapeHtml(meta)}</div>
</div>`;
})
.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 = `
<div class="detail-header">
<span class="secondary" style="font-size:11px; cursor:pointer" id="back">← ESC back</span>
<span class="muted" id="entry-group"></span>
</div>
<div class="detail-title" id="entry-name">Loading...</div>
<div id="entry-fields" style="margin-top: 12px"></div>
<div class="keyhints">f fill · e edit · d delete</div>
`;
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 += `
<div class="field">
<div class="label">URL</div>
<div class="secondary">${escapeHtml(entry.url)}</div>
</div>`;
}
if (entry.username) {
fieldsHtml += `
<div class="field">
<div class="label">USERNAME</div>
<div class="field-value">
<span>${escapeHtml(entry.username)}</span>
<span class="hint" data-copy="username">c copy</span>
</div>
</div>`;
}
fieldsHtml += `
<div class="field">
<div class="label">PASSWORD</div>
<div class="field-value">
<span id="pw-display">••••••••••</span>
<span class="hint" data-copy="password">p copy</span>
</div>
</div>`;
if (entry.totp_secret) {
fieldsHtml += `
<div class="field">
<div class="label">TOTP</div>
<div class="field-value">
<span class="totp-code" id="totp-code">------</span>
<span class="hint"><span id="totp-remaining">--</span>s · <span data-copy="totp">t copy</span></span>
</div>
<div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill"></div></div>
</div>`;
}
if (entry.notes) {
fieldsHtml += `
<div class="field">
<div class="label">NOTES</div>
<div class="secondary" style="white-space: pre-wrap">${escapeHtml(entry.notes)}</div>
</div>`;
}
fieldsEl.innerHTML = fieldsHtml;
// ─── TOTP countdown ────────────────────────────────────────────────────
let totpInterval: ReturnType<typeof setInterval> | 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 = `
<div class="confirm-box">
<p>Delete <strong>${escapeHtml(name)}</strong>?</p>
<p class="secondary" style="margin-top: 4px; font-size: 11px">This commits a removal to the vault.</p>
<div class="actions" style="margin-top: 12px">
<button class="btn" id="confirm-no">No</button>
<button class="btn btn-danger" id="confirm-yes">Yes, delete</button>
</div>
</div>
`;
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 = '<div class="error">Failed to load entry</div>';
return;
}
existing = (resp as { ok: true; entry: Entry }).entry;
}
container.innerHTML = `
<div class="detail-header">
<span class="secondary" style="font-size:11px; cursor:pointer" id="back">← ESC back</span>
<span class="brand">${isEdit ? 'edit' : 'add'}</span>
</div>
<div class="form-group">
<div class="label">NAME *</div>
<input type="text" id="f-name" value="${escapeAttr(existing?.name ?? '')}" autofocus>
</div>
<div class="form-group">
<div class="label">URL</div>
<input type="text" id="f-url" value="${escapeAttr(existing?.url ?? '')}" placeholder="https://...">
</div>
<div class="form-group">
<div class="label">USERNAME</div>
<input type="text" id="f-username" value="${escapeAttr(existing?.username ?? '')}">
</div>
<div class="form-group">
<div class="label">PASSWORD</div>
<div style="display:flex;gap:4px">
<input type="text" id="f-password" value="${escapeAttr(existing?.password ?? '')}" style="flex:1">
<button class="btn" id="btn-generate" title="Generate">gen</button>
</div>
</div>
<div class="form-group">
<div class="label">TOTP SECRET</div>
<input type="text" id="f-totp" value="${escapeAttr(existing?.totp_secret ?? '')}" placeholder="Base32 encoded">
</div>
<div class="form-group">
<div class="label">GROUP</div>
<input type="text" id="f-group" value="${escapeAttr(existing?.group ?? '')}" placeholder="personal, work, ...">
</div>
<div class="form-group">
<div class="label">NOTES</div>
<textarea id="f-notes" rows="3">${escapeHtml(existing?.notes ?? '')}</textarea>
</div>
<div id="form-error" class="error"></div>
<div class="actions">
<button class="btn" id="btn-cancel">Cancel</button>
<button class="btn btn-primary" id="btn-save">Save</button>
</div>
`;
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
```
- [ ] **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<VaultConfig> = { hostType: 'gitea' };
let imageBase64: string | null = null;
function render() {
container.innerHTML = `
<div class="brand">idfoto setup</div>
<div class="wizard-step">step ${step} of 3 — ${['repository', 'reference image', 'test unlock'][step - 1]}</div>
<div class="progress-bar"><div class="progress-bar-fill" style="width: ${(step / 3) * 100}%"></div></div>
<div id="wizard-content"></div>
<div id="wizard-error" class="error" style="margin-top: 8px"></div>
<div class="actions" id="wizard-actions"></div>
`;
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 = `
<div class="form-group">
<div class="label">HOST TYPE</div>
<div class="host-toggle">
<button class="group-tab ${config.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">gitea</button>
<button class="group-tab ${config.hostType === 'github' ? 'active' : ''}" data-host="github">github</button>
</div>
</div>
<div class="form-group">
<div class="label">HOST URL</div>
<input type="text" id="w-host" value="${config.hostUrl ?? ''}" placeholder="https://git.example.com">
</div>
<div class="form-group">
<div class="label">REPO PATH</div>
<input type="text" id="w-repo" value="${config.repoPath ?? ''}" placeholder="owner/repo">
</div>
<div class="form-group">
<div class="label">API TOKEN</div>
<input type="password" id="w-token" value="${config.apiToken ?? ''}" placeholder="Paste token here">
</div>
`;
actions.innerHTML = `<button class="btn btn-primary" id="btn-next">Next →</button>`;
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 = `
<div class="form-group">
<div class="label">REFERENCE IMAGE</div>
<p class="secondary" style="margin-bottom: 8px; font-size: 11px">
Upload the JPEG with your embedded secret. Stored locally, never sent to the server.
</p>
<input type="file" id="w-image" accept="image/jpeg">
<div id="image-status" class="secondary" style="margin-top: 4px; font-size: 11px">
${imageBase64 ? '✓ Image loaded' : 'No image selected'}
</div>
</div>
`;
actions.innerHTML = `
<button class="btn" id="btn-back">← Back</button>
<button class="btn btn-primary" id="btn-next">Next →</button>
`;
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 = `
<div class="form-group">
<div class="label">TEST UNLOCK</div>
<p class="secondary" style="margin-bottom: 8px; font-size: 11px">
Enter your passphrase to verify everything works.
</p>
<input type="password" id="w-passphrase" placeholder="Passphrase" autofocus>
<div id="test-spinner" style="margin-top: 8px; display: none">
<span class="spinner"></span> <span class="secondary">Testing...</span>
</div>
</div>
`;
actions.innerHTML = `
<button class="btn" id="btn-back">← Back</button>
<button class="btn btn-primary" id="btn-finish">Test & Save</button>
`;
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<HTMLInputElement>('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<HTMLInputElement>('input[autocomplete="username"]');
if (byAutocompleteUser) return byAutocompleteUser;
// Priority 2: autocomplete="email"
const byAutocompleteEmail = scope.querySelector<HTMLInputElement>('input[autocomplete="email"]');
if (byAutocompleteEmail) return byAutocompleteEmail;
// Priority 3: type="email"
const byTypeEmail = scope.querySelector<HTMLInputElement>('input[type="email"]');
if (byTypeEmail) return byTypeEmail;
// Priority 4: name matching pattern
const byNamePattern = scope.querySelector<HTMLInputElement>(
'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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('input[autocomplete="username"]') ??
scope.querySelector<HTMLInputElement>('input[autocomplete="email"]') ??
scope.querySelector<HTMLInputElement>('input[type="email"]') ??
scope.querySelector<HTMLInputElement>(
'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<HTMLButtonElement>('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 = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="14" height="14" rx="2" fill="#1f6feb" fill-opacity="0.9"/>
<text x="8" y="11.5" text-anchor="middle" fill="white" font-size="9" font-family="monospace" font-weight="bold">id</text>
</svg>`;
const injectedFields = new WeakSet<HTMLInputElement>();
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 = `
<div>${escapeHtml(candidate.name)}</div>
<div style="color:#484f58;font-size:10px">${escapeHtml(candidate.username ?? '')}</div>
`;
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 |
|------|-------------|--------------|
| 0 | Add heavy comments to existing Rust code | None |
| 1 | Add `group` field to core data model | Task 0 |
| 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.