diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts new file mode 100644 index 0000000..0a9146c --- /dev/null +++ b/extension/src/setup/setup.ts @@ -0,0 +1,592 @@ +/// 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. +
  3. Go to Settings → Applications
  4. +
  5. Generate a new token with repo (read/write) permission
  6. +
  7. Copy the token — you will need it in the next step
  8. +
+
+ `; + + const githubInstructions = ` +
+
    +
  1. Create a new private repository on GitHub (e.g. vault)
  2. +
  3. Go to Settings → Developer settings → Personal access tokens → Fine-grained tokens
  4. +
  5. Generate a new token scoped to the vault repo with Contents read/write permission
  6. +
  7. Copy the token — you will need it in the next step
  8. +
+
+ `; + + 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(); +});