/// Vault initialization wizard — 4-step flow for creating new relicario 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 { VaultConfig } from '../shared/types'; // --- WASM module (loaded dynamically) --- type WasmModule = typeof import('relicario-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 */ '../relicario_wasm.js' ) as WasmModule & { default: (input?: string | URL) => Promise }; await mod.default('../relicario_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; // zxcvbn meter state — -1 means "not yet scored" (empty passphrase). passphraseScore: number; passphraseGuessesLog10: number; // -1 before first rating passphraseVisible: boolean; confirmVisible: boolean; 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: '', passphraseScore: -1, passphraseGuessesLog10: -1, passphraseVisible: false, confirmVisible: false, referenceImageBytes: null, creating: false, error: null, extensionDetected: false, configPushed: false, }; // --- Helpers --- function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } interface Strength { score: number; guessesLog10: number } /// Call the SW to score a passphrase with zxcvbn. Returns score in [0, 4] /// and guesses_log10, or -1 on both if the round-trip failed. function ratePassphrase(passphrase: string): Promise { return new Promise((resolve) => { try { chrome.runtime.sendMessage( { type: 'rate_passphrase', passphrase }, (response: { ok: boolean; data?: { score: number; guesses_log10: number }; error?: string }) => { if (chrome.runtime.lastError) { // eslint-disable-next-line no-console console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError); resolve({ score: -1, guessesLog10: -1 }); return; } if (!response?.ok) { // eslint-disable-next-line no-console console.warn('[relicario setup] rate_passphrase rejected by SW:', response); resolve({ score: -1, guessesLog10: -1 }); return; } resolve({ score: response.data?.score ?? -1, guessesLog10: response.data?.guesses_log10 ?? -1, }); }, ); } catch (err) { // eslint-disable-next-line no-console console.warn('[relicario setup] rate_passphrase threw:', err); resolve({ score: -1, guessesLog10: -1 }); } }); } /// 150ms debounce around the rate_passphrase call so we don't hammer the SW /// on every keystroke. The last invocation wins. let rateDebounceTimer: ReturnType | null = null; function scheduleRate(passphrase: string, onResult: (s: Strength) => void): void { if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer); rateDebounceTimer = setTimeout(async () => { rateDebounceTimer = null; if (!passphrase) { onResult({ score: -1, guessesLog10: -1 }); return; } onResult(await ratePassphrase(passphrase)); }, 150); } const STRENGTH_LABELS: Record = { 0: { text: 'very weak', cls: 's-very-weak' }, 1: { text: 'weak', cls: 's-weak' }, 2: { text: 'fair', cls: 's-fair' }, 3: { text: 'good', cls: 's-good' }, 4: { text: 'strong', cls: 's-strong' }, }; /// Render the entropy readout as "~10^N guesses to crack" or a friendlier /// shorthand for large values. Returns empty string when no data. function entropyText(guessesLog10: number): string { if (guessesLog10 < 0) return ''; const rounded = Math.round(guessesLog10); if (rounded < 6) return `~10^${rounded} guesses — trivially crackable`; if (rounded < 9) return `~10^${rounded} guesses — minutes on a single GPU`; if (rounded < 12) return `~10^${rounded} guesses — hours to days on a GPU`; if (rounded < 15) return `~10^${rounded} guesses — years on consumer hardware`; if (rounded < 20) return `~10^${rounded} guesses — beyond consumer-hardware reach`; return `~10^${rounded} guesses — effectively uncrackable`; } /// Update just the meter DOM without a full re-render (so the input keeps /// focus and the user's cursor position is preserved). Also updates the /// char counter and confirm-match indicator live. function updateStrengthUi(): void { const bar = document.getElementById('strength-bar'); const label = document.getElementById('strength-label'); const entropy = document.getElementById('entropy-line'); const counter = document.getElementById('passphrase-counter'); const matchInd = document.getElementById('match-indicator'); const create = document.getElementById('create-btn') as HTMLButtonElement | null; const score = state.passphraseScore; const guessesLog10 = state.passphraseGuessesLog10; if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`; if (label) { if (score < 0) { label.className = 'strength-label'; label.innerHTML = ' '; } else { const meta = STRENGTH_LABELS[score] ?? STRENGTH_LABELS[0]; label.className = `strength-label ${meta.cls}`; label.textContent = meta.text; } } if (entropy) { const txt = entropyText(guessesLog10); entropy.textContent = txt; entropy.style.visibility = txt ? 'visible' : 'hidden'; } if (counter) { const n = state.passphrase.length; counter.textContent = n === 0 ? '' : `${n} character${n === 1 ? '' : 's'}`; } if (matchInd) { const p = state.passphrase; const c = state.passphraseConfirm; if (!p || !c) { matchInd.className = 'match-indicator'; matchInd.textContent = ''; } else if (p === c) { matchInd.className = 'match-indicator ok'; matchInd.textContent = '✓'; } else { matchInd.className = 'match-indicator bad'; matchInd.textContent = '✗'; } } const matchOk = !state.passphraseConfirm || state.passphrase === state.passphraseConfirm; if (create) { const disabled = state.creating || score < 3 || !state.passphraseConfirm || !matchOk; create.disabled = disabled; create.title = disabled ? (score < 3 ? 'passphrase must score "good" or better' : !state.passphraseConfirm ? 'confirm your passphrase' : !matchOk ? 'passphrases do not match' : '') : ''; } } // --- 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 = `
relicario 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 score = state.passphraseScore; const guessesLog10 = state.passphraseGuessesLog10; const hasScore = score >= 0; const meterClass = hasScore ? `s${score}` : ''; const labelMeta = hasScore ? STRENGTH_LABELS[score] : null; const labelClass = labelMeta?.cls ?? ''; const labelText = labelMeta?.text ?? ' '; const entropy = entropyText(guessesLog10); const p = state.passphrase; const c = state.passphraseConfirm; const matchState = !p || !c ? '' : p === c ? 'ok' : 'bad'; const matchGlyph = matchState === 'ok' ? '✓' : matchState === 'bad' ? '✗' : ''; const pType = state.passphraseVisible ? 'text' : 'password'; const cType = state.confirmVisible ? 'text' : 'password'; const pToggle = state.passphraseVisible ? 'hide' : 'show'; const cToggle = state.confirmVisible ? 'hide' : 'show'; const matchOk = !c || p === c; const gateDisabled = state.creating || score < 3 || !c || !matchOk; const nChars = p.length; const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`; return `

create vault

${state.carrierImageBytes ? '

image loaded

' : '

click to select a JPEG photo

'}

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

A long phrase of unrelated words is stronger than a short complex password. Your vault needs good (score ≥ 3) to continue.

${labelText}

${escapeHtml(counterText)}

${escapeHtml(entropy || ' ')}

${matchGlyph}
`; } 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 inline (no full re-render) so the input keeps focus. // zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate. const passInput = document.getElementById('passphrase') as HTMLInputElement | null; passInput?.addEventListener('input', (e) => { state.passphrase = (e.target as HTMLInputElement).value; // Update char counter + match indicator + button gate immediately on every keystroke. updateStrengthUi(); // Score updates on the 150ms debounce to avoid SW hammering. scheduleRate(state.passphrase, (s) => { state.passphraseScore = s.score; state.passphraseGuessesLog10 = s.guessesLog10; updateStrengthUi(); }); }); const confirmInput = document.getElementById('passphrase-confirm') as HTMLInputElement | null; confirmInput?.addEventListener('input', (e) => { state.passphraseConfirm = (e.target as HTMLInputElement).value; updateStrengthUi(); }); // Eye toggles — flip the input type and label without a full re-render so // focus + cursor position survive the click. document.getElementById('eye-btn')?.addEventListener('click', () => { state.passphraseVisible = !state.passphraseVisible; if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password'; const btn = document.getElementById('eye-btn'); if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show'; passInput?.focus(); }); document.getElementById('confirm-eye-btn')?.addEventListener('click', () => { state.confirmVisible = !state.confirmVisible; if (confirmInput) confirmInput.type = state.confirmVisible ? 'text' : 'password'; const btn = document.getElementById('confirm-eye-btn'); if (btn) btn.textContent = state.confirmVisible ? 'hide' : 'show'; confirmInput?.focus(); }); 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; } // Re-rate synchronously in case the button was clicked before the // debounced rater fired. Defence in depth — the button is already // disabled in the UI when score < 3 (audit H3). const strength = await ratePassphrase(state.passphrase); state.passphraseScore = strength.score; state.passphraseGuessesLog10 = strength.guessesLog10; if (state.passphraseScore < 3) { state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).'; render(); return; } if (state.passphrase !== state.passphraseConfirm) { state.error = 'Passphrases do not match'; render(); return; } state.creating = true; state.error = null; render(); // Structured logging so silent failures become visible in DevTools. // eslint-disable-next-line no-console const log = (stage: string, detail?: unknown) => console.log(`[relicario setup] ${stage}`, detail ?? ''); let stage = 'init'; try { stage = 'load wasm'; log(stage); const w = await loadWasm(); stage = 'generate image secret'; log(stage); const imageSecret = new Uint8Array(32); crypto.getRandomValues(imageSecret); stage = 'embed image secret'; log(stage, { carrierBytes: state.carrierImageBytes.byteLength }); state.referenceImageBytes = new Uint8Array( w.embed_image_secret(state.carrierImageBytes, imageSecret), ); log('embedded', { referenceBytes: state.referenceImageBytes.byteLength }); stage = 'generate salt'; const salt = new Uint8Array(32); crypto.getRandomValues(salt); const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; stage = 'derive session handle'; log(stage); // unlock() takes JPEG bytes with embedded secret (it extracts internally), // not the raw 32-byte secret. const handle = w.unlock(state.passphrase, state.referenceImageBytes, salt, paramsJson); log('handle acquired'); stage = 'encrypt empty manifest'; log(stage); const manifestJson = '{"schema_version":2,"items":{}}'; const encryptedManifest = w.manifest_encrypt(handle, manifestJson); log('manifest encrypted', { bytes: encryptedManifest.length }); stage = 'push vault files'; log(stage); const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl; const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); log('write .relicario/salt'); await host.writeFile('.relicario/salt', salt, 'init: vault salt'); log('write .relicario/params.json'); const paramsBytes = new TextEncoder().encode(paramsJson); await host.writeFile('.relicario/params.json', paramsBytes, 'init: KDF parameters'); log('write .relicario/devices.json'); const devicesJson = '{"devices":[]}'; const devicesBytes = new TextEncoder().encode(devicesJson); await host.writeFile('.relicario/devices.json', devicesBytes, 'init: device registry'); log('write manifest.enc'); await host.writeFile( 'manifest.enc', new Uint8Array(encryptedManifest), 'init: encrypted manifest', ); stage = 'release handle'; w.lock(handle); log('vault created — advancing to step 4'); state.creating = false; state.step = 4; state.error = null; detectExtension(); render(); } catch (err: unknown) { // eslint-disable-next-line no-console console.error(`[relicario setup] vault creation FAILED during "${stage}":`, err); state.creating = false; const detail = err instanceof Error ? err.message : String(err); state.error = `Vault creation failed at "${stage}": ${detail}`; 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(); });