# idfoto Vault Initialization Wizard 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:** Build a browser-based wizard that creates a new idfoto vault, pushes it to Gitea/GitHub via API, downloads the reference image, and optionally configures the Chrome extension. **Architecture:** Single HTML page (`extension/setup.html`) bundled by webpack as a new entry point. Reuses the existing git API layer and WASM module. New `embed_image_secret` function added to the WASM crate. The wizard runs entirely client-side — all crypto happens in the browser via WASM. **Tech Stack:** TypeScript, wasm-bindgen (existing WASM crate), webpack, Chrome extension APIs **Spec:** `docs/superpowers/specs/2026-04-12-idfoto-init-wizard-design.md` --- ## File Structure ### Rust (modified) ``` crates/idfoto-wasm/src/lib.rs # Add embed_image_secret function ``` ### Extension (new) ``` extension/ ├── setup.html # Standalone wizard page └── src/ └── setup/ └── setup.ts # 4-step wizard logic ``` ### Extension (modified) ``` extension/webpack.config.js # Add 'setup' entry point + copy setup.html extension/manifest.json # Add web_accessible_resources for setup.html ``` --- ## Task 1: Add `embed_image_secret` to WASM Crate **Files:** - Modify: `crates/idfoto-wasm/src/lib.rs` - [ ] **Step 1: Write the test** Add to the `#[cfg(test)] mod tests` block in `crates/idfoto-wasm/src/lib.rs`: ```rust #[test] fn embed_then_extract_round_trip() { // Create a synthetic test JPEG (same approach as idfoto-core tests) use image::codecs::jpeg::JpegEncoder; use image::{ImageBuffer, ImageEncoder, Rgb}; let img = ImageBuffer::from_fn(400, 300, |x, y| { Rgb([ ((x * 7 + y * 13) % 256) as u8, ((x * 11 + y * 3) % 256) as u8, ((x * 5 + y * 17) % 256) as u8, ]) }); let mut jpeg_buf = Vec::new(); let encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, 92); encoder .write_image(img.as_raw(), 400, 300, image::ExtendedColorType::Rgb8) .unwrap(); let secret = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1Cu8]; let stego = embed_image_secret(&jpeg_buf, &secret).unwrap(); let extracted = extract_image_secret(&stego).unwrap(); assert_eq!(extracted, secret); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test -p idfoto-wasm embed_then_extract` Expected: FAIL — `embed_image_secret` not defined. - [ ] **Step 3: Add `image` dev-dependency to Cargo.toml** Add to `crates/idfoto-wasm/Cargo.toml` under `[dev-dependencies]`: ```toml [dev-dependencies] wasm-bindgen-test = "0.3" image = { version = "0.25", default-features = false, features = ["jpeg"] } ``` - [ ] **Step 4: Implement the function** Add to `crates/idfoto-wasm/src/lib.rs`, after the `extract_image_secret` function: ```rust /// Embed a 256-bit secret into a carrier JPEG image. /// /// Takes the raw bytes of a JPEG image and a 32-byte secret, returns the /// modified JPEG with the secret embedded via DCT steganography. /// /// The returned JPEG should be saved as the "reference image" — the user /// needs it alongside their passphrase to unlock the vault. #[wasm_bindgen] pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result, JsValue> { let secret: [u8; 32] = secret .try_into() .map_err(|_| JsValue::from_str("secret must be exactly 32 bytes"))?; idfoto_core::imgsecret::embed(carrier_jpeg, &secret) .map_err(|e| JsValue::from_str(&e.to_string())) } ``` - [ ] **Step 5: Run test to verify it passes** Run: `cargo test -p idfoto-wasm embed_then_extract` Expected: PASS - [ ] **Step 6: Rebuild WASM** Run: `wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm` Expected: Builds successfully. - [ ] **Step 7: Commit** ```bash git add crates/idfoto-wasm/src/lib.rs crates/idfoto-wasm/Cargo.toml git commit -m "feat: add embed_image_secret to WASM crate" ``` --- ## Task 2: Add WASM Type Declaration for New Function **Files:** - Modify: `extension/src/wasm.d.ts` - [ ] **Step 1: Add the type declaration** Add to `extension/src/wasm.d.ts` alongside the existing declarations: ```typescript export function embed_image_secret(carrier_jpeg: Uint8Array, secret: Uint8Array): Uint8Array; ``` - [ ] **Step 2: Commit** ```bash git add extension/src/wasm.d.ts git commit -m "feat: add embed_image_secret type declaration" ``` --- ## Task 3: Create Setup Page HTML **Files:** - Create: `extension/setup.html` - [ ] **Step 1: Create the HTML file** Create `extension/setup.html`: ```html idfoto — vault setup
``` - [ ] **Step 2: Commit** ```bash git add extension/setup.html git commit -m "feat: add setup wizard HTML page" ``` --- ## Task 4: Create Setup Wizard TypeScript **Files:** - Create: `extension/src/setup/setup.ts` This is the main task. The wizard is a 4-step state machine that reuses the existing git API layer and WASM module. - [ ] **Step 1: Create the setup wizard** Create `extension/src/setup/setup.ts`: ```typescript /// Standalone vault initialization wizard. /// /// 4-step flow: /// 1. Choose git host (Gitea/GitHub) with setup instructions /// 2. Configure connection (URL, repo, token) with test button /// 3. Create vault (carrier image, passphrase, generate + push) /// 4. Finish (download reference image, push config to extension) import { createGitHost, uint8ArrayToBase64, base64ToUint8Array } from '../service-worker/git-host'; import type { GitHost } from '../service-worker/git-host'; import type { VaultConfig } from '../shared/types'; // --- State --- interface WizardState { step: number; hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: string; connectionTested: boolean; carrierImageBytes: Uint8Array | null; carrierImageName: string; passphrase: string; passphraseConfirm: string; referenceImageBytes: Uint8Array | null; creating: boolean; error: string; extensionDetected: boolean; configPushed: boolean; } let state: WizardState = { step: 1, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '', connectionTested: false, carrierImageBytes: null, carrierImageName: '', passphrase: '', passphraseConfirm: '', referenceImageBytes: null, creating: false, error: '', extensionDetected: false, configPushed: false, }; // --- WASM --- type WasmModule = typeof import('idfoto-wasm'); let wasm: WasmModule | null = null; async function initWasm(): Promise { if (wasm) return wasm; const mod = await import(/* webpackIgnore: true */ '../idfoto_wasm.js'); await mod.default(); wasm = mod; return mod; } // --- Helpers --- function escapeHtml(s: string): string { const div = document.createElement('div'); div.textContent = s; return div.innerHTML; } function passwordStrength(pw: string): { label: string; color: string; pct: number } { if (pw.length < 8) return { label: 'too short', color: '#f85149', pct: 10 }; let score = 0; if (pw.length >= 12) score++; if (pw.length >= 16) score++; if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++; if (/[0-9]/.test(pw)) score++; if (/[^a-zA-Z0-9]/.test(pw)) score++; if (score <= 1) return { label: 'weak', color: '#f85149', pct: 30 }; if (score <= 3) return { label: 'ok', color: '#d29922', pct: 60 }; return { label: 'strong', color: '#3fb950', pct: 100 }; } // --- Render --- function render(): void { const app = document.getElementById('app')!; const stepNames = ['git host', 'connection', 'create vault', 'done']; let html = `
idfoto setup
step ${state.step} of 4 — ${stepNames[state.step - 1]}
`; if (state.error) { html += `
${escapeHtml(state.error)}
`; } switch (state.step) { case 1: html += renderStep1(); break; case 2: html += renderStep2(); break; case 3: html += renderStep3(); break; case 4: html += renderStep4(); break; } app.innerHTML = html; attachListeners(); } // --- Step 1: Choose Git Host --- function renderStep1(): string { return `
HOST TYPE
${state.hostType === 'gitea' ? `
GITEA SETUP
  1. Log in to your Gitea instance
  2. Click +New Repository
  3. Name it (e.g. idfoto-vault), leave it empty — no README, no .gitignore
  4. Go to SettingsApplicationsManage Access Tokens
  5. Generate a new token with repo scope (read/write)
  6. Copy the token — you'll need it in the next step
` : `
GITHUB SETUP
  1. Go to github.comNew Repository
  2. Name it (e.g. idfoto-vault), set to Private, leave it empty — no README, no .gitignore, no license
  3. Go to SettingsDeveloper SettingsPersonal Access TokensFine-grained tokens
  4. Click Generate new token
  5. Select only the vault repository under "Repository access"
  6. Under Permissions → Repository → Contents: set to Read and write
  7. Generate and copy the token
`}
`; } // --- Step 2: Configure Connection --- function renderStep2(): string { const defaultUrl = state.hostType === 'github' ? 'https://github.com' : ''; return `
HOST URL
REPO PATH
API TOKEN
`; } // --- Step 3: Create Vault --- function renderStep3(): string { const strength = state.passphrase ? passwordStrength(state.passphrase) : null; const mismatch = state.passphraseConfirm && state.passphrase !== state.passphraseConfirm; return `
CARRIER IMAGE

Pick any JPEG photo. A phone photo works great — at least 400x300 pixels.

${state.carrierImageName ? `
✓ ${escapeHtml(state.carrierImageName)}
` : ''}
PASSPHRASE
${strength ? `
${strength.label}
` : ''}
CONFIRM PASSPHRASE
${mismatch ? '
Passphrases do not match
' : ''}
`; } // --- Step 4: Finish --- function renderStep4(): string { return `
✓ Vault created

Your vault has been pushed to ${escapeHtml(state.repoPath)}.

REFERENCE IMAGE

Download this image and keep it safe. You need it alongside your passphrase to unlock the vault. Store it somewhere you won't lose it — a USB drive, a safe, your phone's photo library.

${state.extensionDetected ? `
EXTENSION
${state.configPushed ? `

✓ Extension configured. Open the extension popup and enter your passphrase to unlock.

` : `

idfoto extension detected. Push your vault config to it?

`}
` : `
EXTENSION SETUP

Install the idfoto extension, then enter these details in the setup wizard:

${escapeHtml(JSON.stringify({ hostType: state.hostType, hostUrl: state.hostUrl, repoPath: state.repoPath, apiToken: state.apiToken, }, null, 2))}
Click to copy
`} `; } // --- Event Listeners --- function attachListeners(): void { // Host type toggle document.querySelectorAll('[data-action="host"]').forEach(btn => { btn.addEventListener('click', () => { state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github'; state.connectionTested = false; render(); }); }); // Navigation document.querySelectorAll('[data-action="next"]').forEach(btn => { btn.addEventListener('click', () => { readInputs(); state.error = ''; state.step++; render(); }); }); document.querySelectorAll('[data-action="back"]').forEach(btn => { btn.addEventListener('click', () => { readInputs(); state.error = ''; state.step--; render(); }); }); // Test connection document.querySelector('[data-action="test-connection"]')?.addEventListener('click', async () => { readInputs(); state.error = ''; if (!state.hostUrl || !state.repoPath || !state.apiToken) { state.error = 'All fields are required'; render(); return; } const resultEl = document.getElementById('test-result')!; resultEl.innerHTML = '
Testing...
'; try { const git = createGitHost(state.hostType, state.hostUrl, state.repoPath, state.apiToken); // Try to list the root directory — if the repo exists and token works, this succeeds await git.listDir(''); state.connectionTested = true; resultEl.innerHTML = '
✓ Connected
'; // Re-render to enable Next button const nextBtn = document.querySelector('[data-action="next"]') as HTMLButtonElement; if (nextBtn) nextBtn.disabled = false; } catch (err) { state.connectionTested = false; resultEl.innerHTML = `
✗ ${escapeHtml(String(err))}
`; } }); // Carrier image file picker document.getElementById('carrier-image')?.addEventListener('change', (e) => { const input = e.target as HTMLInputElement; const file = input.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { const arrayBuf = reader.result as ArrayBuffer; state.carrierImageBytes = new Uint8Array(arrayBuf); state.carrierImageName = file.name; render(); }; reader.readAsArrayBuffer(file); }); // Passphrase inputs (read on change, re-render for strength indicator) document.getElementById('passphrase')?.addEventListener('input', (e) => { state.passphrase = (e.target as HTMLInputElement).value; // Only re-render the strength indicator, not the whole page (avoids losing focus) const strength = passwordStrength(state.passphrase); const bar = document.querySelector('.strength-bar-fill') as HTMLElement; if (bar) { bar.style.width = `${strength.pct}%`; bar.style.background = strength.color; } const label = bar?.parentElement?.nextElementSibling as HTMLElement; if (label) { label.textContent = strength.label; label.style.color = strength.color; } }); document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => { state.passphraseConfirm = (e.target as HTMLInputElement).value; }); // Create vault document.querySelector('[data-action="create-vault"]')?.addEventListener('click', async () => { readInputs(); state.error = ''; // Validation if (!state.carrierImageBytes) { state.error = 'Select a carrier image'; render(); return; } if (state.passphrase.length < 8) { state.error = 'Passphrase must be at least 8 characters'; render(); return; } if (state.passphrase !== state.passphraseConfirm) { state.error = 'Passphrases do not match'; render(); return; } state.creating = true; render(); try { await createVault(); state.creating = false; // Detect extension state.extensionDetected = await detectExtension(); state.step = 4; render(); } catch (err) { state.creating = false; state.error = `Vault creation failed: ${String(err)}`; render(); } }); // Download reference image document.querySelector('[data-action="download-image"]')?.addEventListener('click', () => { if (!state.referenceImageBytes) return; const blob = new Blob([state.referenceImageBytes], { type: 'image/jpeg' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'reference.jpg'; a.click(); URL.revokeObjectURL(url); }); // Push config to extension document.querySelector('[data-action="push-to-extension"]')?.addEventListener('click', async () => { try { const config: VaultConfig = { hostType: state.hostType, hostUrl: state.hostUrl, repoPath: state.repoPath, apiToken: state.apiToken, }; const imageBase64 = uint8ArrayToBase64(state.referenceImageBytes!); chrome.runtime.sendMessage( { type: 'save_setup', config, imageBase64 }, (response) => { if (response?.ok) { state.configPushed = true; render(); } } ); } catch { state.error = 'Failed to push config to extension'; render(); } }); // Copy config blob document.querySelector('[data-action="copy-config"]')?.addEventListener('click', async () => { const config = JSON.stringify({ hostType: state.hostType, hostUrl: state.hostUrl, repoPath: state.repoPath, apiToken: state.apiToken, }, null, 2); await navigator.clipboard.writeText(config); const el = document.querySelector('[data-action="copy-config"]')!; el.classList.add('test-ok'); setTimeout(() => el.classList.remove('test-ok'), 1500); }); } // --- Read form inputs into state --- function readInputs(): void { const hostUrl = document.getElementById('host-url') as HTMLInputElement; const repoPath = document.getElementById('repo-path') as HTMLInputElement; const apiToken = document.getElementById('api-token') as HTMLInputElement; const passphrase = document.getElementById('passphrase') as HTMLInputElement; const passphraseConfirm = document.getElementById('passphrase-confirm') as HTMLInputElement; if (hostUrl) state.hostUrl = hostUrl.value.trim(); if (repoPath) state.repoPath = repoPath.value.trim(); if (apiToken) state.apiToken = apiToken.value.trim(); if (passphrase) state.passphrase = passphrase.value; if (passphraseConfirm) state.passphraseConfirm = passphraseConfirm.value; } // --- Vault Creation --- async function createVault(): Promise { const w = await initWasm(); const git = createGitHost(state.hostType, state.hostUrl, state.repoPath, state.apiToken); // 1. Generate random 32-byte image_secret const imageSecret = new Uint8Array(32); crypto.getRandomValues(imageSecret); // 2. Embed secret into carrier JPEG const referenceJpeg = w.embed_image_secret(state.carrierImageBytes!, imageSecret); state.referenceImageBytes = new Uint8Array(referenceJpeg); // 3. Generate random 32-byte salt const salt = new Uint8Array(32); crypto.getRandomValues(salt); // 4. Create KDF params const params = { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; const paramsJson = JSON.stringify(params); // 5. Derive master_key const masterKey = w.derive_master_key(state.passphrase, imageSecret, salt, paramsJson); // 6. Encrypt empty manifest const emptyManifest = JSON.stringify({ entries: {}, version: 1 }); const manifestEnc = w.encrypt_manifest(emptyManifest, masterKey); // 7. Push vault files to repo await git.writeFile('.idfoto/salt', salt, 'feat: initialize idfoto vault'); await git.writeFile('.idfoto/params.json', new TextEncoder().encode(paramsJson), 'chore: add KDF params'); await git.writeFile('.idfoto/devices.json', new TextEncoder().encode('[]'), 'chore: add empty devices list'); await git.writeFile('manifest.enc', new Uint8Array(manifestEnc), 'feat: add encrypted manifest'); } // --- Extension Detection --- function detectExtension(): Promise { return new Promise((resolve) => { try { if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) { resolve(false); return; } chrome.runtime.sendMessage( { type: 'get_setup_state' }, (response) => { if (chrome.runtime.lastError || !response) { resolve(false); } else { resolve(true); } } ); } catch { resolve(false); } }); } // --- Init --- document.addEventListener('DOMContentLoaded', render); ``` - [ ] **Step 2: Commit** ```bash git add extension/src/setup/setup.ts git commit -m "feat: add vault initialization wizard" ``` --- ## Task 5: Update Webpack and Manifest **Files:** - Modify: `extension/webpack.config.js` - Modify: `extension/manifest.json` - [ ] **Step 1: Add setup entry point to webpack** In `extension/webpack.config.js`, add `setup` to the `entry` object: ```javascript entry: { 'service-worker': './src/service-worker/index.ts', popup: './src/popup/popup.ts', content: './src/content/detector.ts', setup: './src/setup/setup.ts', }, ``` Add `setup.html` to the CopyPlugin patterns array: ```javascript { from: 'setup.html', to: '.' }, ``` - [ ] **Step 2: Add web_accessible_resources to manifest.json** Add to `extension/manifest.json`, after the `content_security_policy` block: ```json "web_accessible_resources": [{ "resources": ["setup.html", "setup.js", "styles.css", "idfoto_wasm_bg.wasm", "idfoto_wasm.js"], "matches": [""] }] ``` - [ ] **Step 3: Build and verify** ```bash cd extension && bun run build ``` Expected: Builds successfully with `dist/setup.js` and `dist/setup.html` in the output. - [ ] **Step 4: Commit** ```bash git add extension/webpack.config.js extension/manifest.json git commit -m "feat: add setup wizard to webpack build and extension manifest" ``` --- ## Task 6: Build and Manual Test **Files:** None (integration testing) - [ ] **Step 1: Rebuild WASM** ```bash wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm ``` - [ ] **Step 2: Rebuild extension** ```bash cd extension && bun run build ``` - [ ] **Step 3: Run Rust tests** ```bash cargo test ``` Expected: All tests pass (including the new `embed_then_extract_round_trip`). - [ ] **Step 4: Load in Chrome and test** 1. Open `chrome://extensions/`, reload the unpacked extension from `extension/dist/` 2. Open `chrome-extension:///setup.html` 3. Verify: - Step 1: host toggle switches between Gitea/GitHub instructions - Step 2: enter real host/token/repo, test connection works - Step 3: pick a JPEG, enter passphrase, create vault pushes files - Step 4: download reference image works, extension detection works 4. Verify the vault repo now has `.idfoto/salt`, `.idfoto/params.json`, `.idfoto/devices.json`, `manifest.enc` 5. Open extension popup, unlock with passphrase — should work with the just-created vault - [ ] **Step 5: Fix any issues found** - [ ] **Step 6: Final commit** ```bash git add -A git commit -m "feat: complete vault initialization wizard" ``` --- ## Task Summary | Task | Description | Dependencies | |------|-------------|--------------| | 1 | Add `embed_image_secret` to WASM crate | None | | 2 | Add WASM type declaration | Task 1 | | 3 | Create setup page HTML | None | | 4 | Create setup wizard TypeScript | Task 2, 3 | | 5 | Update webpack and manifest | Task 3, 4 | | 6 | Build and manual test | All | Tasks 1 and 3 can run in parallel. Tasks 2 and 4 are sequential after 1. Task 5 depends on 3+4. Task 6 is final integration.