/// Vault initialization wizard — 4-step flow for creating new idfoto vaults. /// /// Step 1: Choose host type (Gitea / GitHub) /// Step 2: Configure connection (URL, repo, token) + test /// Step 3: Create vault (carrier image, passphrase, generate secrets, push files) /// Step 4: Finish (download reference image, push config to extension or copy JSON) import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host'; import type { GitHost } from '../service-worker/git-host'; import type { VaultConfig } from '../shared/types'; // --- WASM module (loaded dynamically) --- type WasmModule = typeof import('idfoto-wasm'); let wasm: WasmModule | null = null; async function loadWasm(): Promise { if (wasm) return wasm; const mod = await import( // @ts-ignore TS2307 — resolved at runtime, not by TS/webpack /* webpackIgnore: true */ '../idfoto_wasm.js' ) as WasmModule & { default: (input?: string | URL) => Promise }; await mod.default('../idfoto_wasm_bg.wasm'); wasm = mod; return mod; } // --- State --- interface WizardState { step: number; hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: string; connectionTested: boolean; carrierImageBytes: Uint8Array | null; passphrase: string; passphraseConfirm: string; referenceImageBytes: Uint8Array | null; creating: boolean; error: string | null; extensionDetected: boolean; configPushed: boolean; } const state: WizardState = { step: 1, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '', connectionTested: false, carrierImageBytes: null, passphrase: '', passphraseConfirm: '', referenceImageBytes: null, creating: false, error: null, extensionDetected: false, configPushed: false, }; // --- Helpers --- function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function passphraseStrength(pw: string): 'weak' | 'fair' | 'good' | 'strong' { let score = 0; if (pw.length >= 8) score++; if (pw.length >= 14) 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 'weak'; if (score <= 2) return 'fair'; if (score <= 3) return 'good'; return 'strong'; } // --- Render --- function render(): void { const app = document.getElementById('app'); if (!app) return; const progressHtml = `
`; let stepHtml = ''; switch (state.step) { case 1: stepHtml = renderStep1(); break; case 2: stepHtml = renderStep2(); break; case 3: stepHtml = renderStep3(); break; case 4: stepHtml = renderStep4(); break; } app.innerHTML = `
idfoto vault setup
${progressHtml} ${state.error ? `
${escapeHtml(state.error)}
` : ''} ${stepHtml}
`; switch (state.step) { case 1: attachStep1(); break; case 2: attachStep2(); break; case 3: attachStep3(); break; case 4: attachStep4(); break; } } // --- Step 1: Choose Host --- function renderStep1(): string { const giteaInstructions = `
  1. Create a new private repository on your Gitea instance (e.g. vault)
  2. Go to Settings → Applications
  3. Generate a new token with repo (read/write) permission
  4. Copy the token — you will need it in the next step
`; const githubInstructions = `
  1. Create a new private repository on GitHub (e.g. vault)
  2. Go to Settings → Developer settings → Personal access tokens → Fine-grained tokens
  3. Generate a new token scoped to the vault repo with Contents read/write permission
  4. Copy the token — you will need it in the next step
`; return `

choose host

${state.hostType === 'gitea' ? giteaInstructions : githubInstructions}
`; } function attachStep1(): void { document.querySelectorAll('.toggle-group button').forEach(btn => { btn.addEventListener('click', () => { state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github'; state.connectionTested = false; render(); }); }); document.getElementById('next-btn')?.addEventListener('click', () => { state.step = 2; state.error = null; render(); }); } // --- Step 2: Configure Connection --- function renderStep2(): string { return `

configure connection

${state.connectionTested ? 'connected' : ''}
`; } function attachStep2(): void { document.getElementById('test-btn')?.addEventListener('click', async () => { const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : (document.getElementById('host-url') as HTMLInputElement).value.trim(); const repoPath = (document.getElementById('repo-path') as HTMLInputElement).value.trim(); const apiToken = (document.getElementById('api-token') as HTMLInputElement).value.trim(); if (!repoPath || !apiToken) { state.error = 'Repository path and API token are required'; render(); return; } if (state.hostType === 'gitea' && !hostUrl) { state.error = 'Host URL is required for Gitea'; render(); return; } state.hostUrl = hostUrl; state.repoPath = repoPath; state.apiToken = apiToken; try { const host = createGitHost(state.hostType, hostUrl, repoPath, apiToken); await host.listDir(''); state.connectionTested = true; state.error = null; } catch (err: unknown) { state.connectionTested = false; state.error = `Connection failed: ${err instanceof Error ? err.message : String(err)}`; } render(); }); document.getElementById('back-btn')?.addEventListener('click', () => { state.step = 1; state.error = null; render(); }); document.getElementById('next-btn')?.addEventListener('click', () => { if (!state.connectionTested) return; state.step = 3; state.error = null; render(); }); } // --- Step 3: Create Vault --- function renderStep3(): string { const strength = state.passphrase ? passphraseStrength(state.passphrase) : null; return `

create vault

${state.carrierImageBytes ? '

image loaded

' : '

click to select a JPEG photo

'}

A 256-bit secret will be steganographically embedded in this image.

${strength ? `

strength: ${strength}

` : ''}
`; } function attachStep3(): void { const fileDrop = document.getElementById('file-drop')!; const fileInput = document.getElementById('file-input') as HTMLInputElement; fileDrop.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', () => { const file = fileInput.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer); state.error = null; render(); }; reader.readAsArrayBuffer(file); }); // Track passphrase changes without full re-render document.getElementById('passphrase')?.addEventListener('input', (e) => { state.passphrase = (e.target as HTMLInputElement).value; // Update strength bar inline const strength = passphraseStrength(state.passphrase); const bar = document.querySelector('.strength-bar-fill') as HTMLElement | null; const label = document.querySelector('.strength-bar + .muted') as HTMLElement | null; if (bar) { bar.className = `strength-bar-fill ${strength}`; } if (label) { label.textContent = `strength: ${strength}`; } if (!bar && state.passphrase) { render(); } }); document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => { state.passphraseConfirm = (e.target as HTMLInputElement).value; }); document.getElementById('back-btn')?.addEventListener('click', () => { state.step = 2; state.error = null; render(); }); document.getElementById('create-btn')?.addEventListener('click', async () => { // Read current values from DOM state.passphrase = (document.getElementById('passphrase') as HTMLInputElement).value; state.passphraseConfirm = (document.getElementById('passphrase-confirm') as HTMLInputElement).value; if (!state.carrierImageBytes) { state.error = 'Please select a carrier JPEG image'; render(); return; } if (!state.passphrase) { state.error = 'Passphrase is required'; render(); return; } if (state.passphrase !== state.passphraseConfirm) { state.error = 'Passphrases do not match'; render(); return; } state.creating = true; state.error = null; render(); try { const w = await loadWasm(); // 1. Generate 32-byte image secret const imageSecret = new Uint8Array(32); crypto.getRandomValues(imageSecret); // 2. Embed secret into carrier JPEG state.referenceImageBytes = new Uint8Array( w.embed_image_secret(state.carrierImageBytes, imageSecret) ); // 3. Generate 32-byte salt const salt = new Uint8Array(32); crypto.getRandomValues(salt); // 4. Create KDF params const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; // 5. Derive master key const masterKey = w.derive_master_key( state.passphrase, imageSecret, salt, paramsJson, ); // 6. Encrypt empty manifest const manifestJson = '{"entries":{},"version":1}'; const encryptedManifest = w.encrypt_manifest(manifestJson, masterKey); // 7. Push vault files via git API const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl; const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); await host.writeFile( '.idfoto/salt', salt, 'init: vault salt', ); const paramsBytes = new TextEncoder().encode(paramsJson); await host.writeFile( '.idfoto/params.json', paramsBytes, 'init: KDF parameters', ); const devicesJson = '{"devices":[]}'; const devicesBytes = new TextEncoder().encode(devicesJson); await host.writeFile( '.idfoto/devices.json', devicesBytes, 'init: device registry', ); await host.writeFile( 'manifest.enc', new Uint8Array(encryptedManifest), 'init: encrypted manifest', ); // 8. Advance to step 4 state.creating = false; state.step = 4; state.error = null; // Detect extension detectExtension(); render(); } catch (err: unknown) { state.creating = false; state.error = `Vault creation failed: ${err instanceof Error ? err.message : String(err)}`; render(); } }); } // --- Step 4: Finish --- function detectExtension(): void { try { if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) { // Try to ping the extension chrome.runtime.sendMessage({ type: 'is_unlocked' }, (response) => { if (chrome.runtime.lastError) { state.extensionDetected = false; } else { state.extensionDetected = true; } render(); }); } } catch { state.extensionDetected = false; } } function renderStep4(): string { const config: VaultConfig = { hostType: state.hostType, hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl, repoPath: state.repoPath, apiToken: state.apiToken, }; const configJson = JSON.stringify(config, null, 2); return `

vault created

Your vault has been initialized and pushed to the repository.

Download and store this image securely. It is your second factor for decryption. Without it, you cannot unlock the vault.

${state.extensionDetected ? `
${state.configPushed ? 'saved' : ''}
` : `

Copy this JSON and paste it into the extension setup, or save it for later.

${escapeHtml(configJson)}
`}
`; } function attachStep4(): void { document.getElementById('download-ref-btn')?.addEventListener('click', () => { if (!state.referenceImageBytes) return; const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'reference.jpg'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }); document.getElementById('push-config-btn')?.addEventListener('click', async () => { if (!state.referenceImageBytes) return; const config: VaultConfig = { hostType: state.hostType, hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl, repoPath: state.repoPath, apiToken: state.apiToken, }; const imageBase64 = uint8ArrayToBase64(state.referenceImageBytes); try { chrome.runtime.sendMessage( { type: 'save_setup', config, imageBase64 }, (response: { ok: boolean; error?: string }) => { if (response?.ok) { state.configPushed = true; } else { state.error = response?.error ?? 'Failed to save config to extension'; } render(); }, ); } catch (err: unknown) { state.error = `Failed to communicate with extension: ${err instanceof Error ? err.message : String(err)}`; render(); } }); document.getElementById('copy-config-btn')?.addEventListener('click', async () => { const blob = document.getElementById('config-blob'); if (!blob) return; try { await navigator.clipboard.writeText(blob.textContent ?? ''); const btn = document.getElementById('copy-config-btn')!; btn.textContent = 'copied!'; setTimeout(() => { btn.textContent = 'copy to clipboard'; }, 2000); } catch { // Fallback: select the text const range = document.createRange(); range.selectNodeContents(blob); const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(range); } }); } // --- Boot --- document.addEventListener('DOMContentLoaded', () => { render(); });