import { createGitHost } from '../service-worker/git-host'; import { probeVault } from './probe'; import type { VaultProbe } from './probe'; import { escapeHtml, ratePassphrase, scheduleRate, STRENGTH_LABELS, entropyText } from './setup-helpers'; import { GLYPH_NEXT } from '../shared/glyphs'; import type { VaultConfig } from '../shared/types'; import type { Request, Response } from '../shared/messages'; // --- SW messaging --- export function swSend(msg: Request): Promise { return new Promise((resolve) => chrome.runtime.sendMessage(msg, (r: Response) => resolve(r))); } // --- Step registry types --- export type StepId = 'mode' | 'host' | 'connection' | 'vault' | 'device' | 'done'; export interface StepContext { state: WizardState; rerender: () => void; goto: (id: StepId) => void; } export interface SetupStep { id: StepId; render: (ctx: StepContext) => string; attach: (root: HTMLElement, ctx: StepContext) => () => void; } // --- State --- export interface WizardState { stepId: StepId; mode: 'new' | 'attach' | null; hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: string; connectionTested: boolean; vaultProbe: VaultProbe | null; carrierImageBytes: Uint8Array | null; referenceImageBytesAttach: Uint8Array | null; passphrase: string; passphraseConfirm: string; passphraseScore: number; passphraseGuessesLog10: number; passphraseVisible: boolean; confirmVisible: boolean; referenceImageBytes: Uint8Array | null; creating: boolean; attaching: boolean; error: string | null; deviceName: string; } export const state: WizardState = { stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '', connectionTested: false, vaultProbe: null, carrierImageBytes: null, referenceImageBytesAttach: null, passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1, passphraseVisible: false, confirmVisible: false, referenceImageBytes: null, creating: false, attaching: false, error: null, deviceName: '', }; // --- State-coupled helpers --- 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; 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(state.passphraseGuessesLog10); 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, 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' : '') : ''; } } function vaultConfig(): VaultConfig { return { hostType: state.hostType, hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl, repoPath: state.repoPath, apiToken: state.apiToken, }; } // --- mode --- const modeStep: SetupStep = { id: 'mode', render() { const isNew = state.mode === 'new'; const isAttach = state.mode === 'attach'; return `

set up Relicario

How are you using Relicario on this device?

`; }, attach(_root, ctx) { document.querySelectorAll('.mode-card').forEach((btn) => { btn.addEventListener('click', () => { state.mode = (btn as HTMLElement).dataset.mode as 'new' | 'attach'; ctx.rerender(); }); }); document.getElementById('next-btn')?.addEventListener('click', () => { if (state.mode) ctx.goto('host'); }); return () => {}; }, }; // --- host --- const GITEA_INSTRUCTIONS = `
  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 GITHUB_INSTRUCTIONS = `
  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
`; const hostStep: SetupStep = { id: 'host', render() { return `

choose host

${state.hostType === 'gitea' ? GITEA_INSTRUCTIONS : GITHUB_INSTRUCTIONS}
`; }, attach(_root, ctx) { document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('mode')); document.querySelectorAll('.toggle-group button').forEach((btn) => { btn.addEventListener('click', () => { state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github'; state.connectionTested = false; ctx.rerender(); }); }); document.getElementById('next-btn')?.addEventListener('click', () => ctx.goto('connection')); return () => {}; }, }; // --- connection --- function renderProbeBanner(): string { const probe = state.vaultProbe; if (!state.connectionTested || !probe) return ''; const meta = probe.lastCommit ? `Last commit: ${escapeHtml(probe.lastCommit.sha)} by ${escapeHtml(probe.lastCommit.author)} on ${escapeHtml(probe.lastCommit.date.slice(0, 10))}.` : ''; if (state.mode === 'new' && probe.exists) { return ` `; } if (state.mode === 'attach' && !probe.exists) { return ` `; } if (state.mode === 'attach' && probe.exists) { return ` `; } // mode = new, !exists return ``; } const connectionStep: SetupStep = { id: 'connection', render() { const probe = state.vaultProbe; const modeMismatch = !!probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists)); const nextDisabled = !state.connectionTested || !probe || modeMismatch; return `

configure connection

${state.connectionTested ? 'connected' : ''}
${renderProbeBanner()}
`; }, attach(_root, ctx) { document.getElementById('test-btn')?.addEventListener('click', async () => { state.connectionTested = false; state.vaultProbe = null; 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'; ctx.rerender(); return; } if (state.hostType === 'gitea' && !hostUrl) { state.error = 'Host URL is required for Gitea'; ctx.rerender(); 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; try { state.vaultProbe = await probeVault(host); } catch (probeErr) { state.vaultProbe = null; state.error = `Could not check repo state: ${probeErr instanceof Error ? probeErr.message : String(probeErr)}`; } } catch (err: unknown) { state.connectionTested = false; state.vaultProbe = null; state.error = `Connection failed: ${err instanceof Error ? err.message : String(err)}`; } ctx.rerender(); }); document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('host')); document.getElementById('next-btn')?.addEventListener('click', () => { if (state.connectionTested) ctx.goto('vault'); }); document.getElementById('switch-mode-btn')?.addEventListener('click', (e) => { state.mode = (e.currentTarget as HTMLElement).dataset.target as 'new' | 'attach'; state.error = null; ctx.rerender(); }); return () => {}; }, }; // --- vault --- function renderVaultAttach(): string { const p = state.passphrase; const pType = state.passphraseVisible ? 'text' : 'password'; const pToggle = state.passphraseVisible ? 'hide' : 'show'; const hasImage = !!state.referenceImageBytesAttach; const gateDisabled = !p || !hasImage; return `

attach this device

Use your existing passphrase and reference image to attach this browser to your vault. We'll verify both when you register this device.

${hasImage ? '

reference image loaded

' : '

click to select your reference JPEG

'}

The reference image is the JPEG you saved when you first created this vault — not the original photo. It has the 256-bit secret embedded.

`; } function renderVaultNew(): string { const score = state.passphraseScore; 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(state.passphraseGuessesLog10); const p = state.passphrase, 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}
`; } const vaultStep: SetupStep = { id: 'vault', render(ctx) { return ctx.state.mode === 'attach' ? renderVaultAttach() : renderVaultNew(); }, attach(_root, ctx) { return state.mode === 'attach' ? attachVaultAttach(ctx) : attachVaultNew(ctx); }, }; function attachVaultAttach(ctx: StepContext): () => void { const refDrop = document.getElementById('ref-drop')!; const refInput = document.getElementById('ref-input') as HTMLInputElement; refDrop.addEventListener('click', () => refInput.click()); refInput.addEventListener('change', () => { const file = refInput.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer); state.error = null; ctx.rerender(); }; reader.readAsArrayBuffer(file); }); const passInput = document.getElementById('passphrase') as HTMLInputElement | null; passInput?.addEventListener('input', (e) => { state.passphrase = (e.target as HTMLInputElement).value; const btn = document.getElementById('attach-btn') as HTMLButtonElement | null; if (btn) btn.disabled = !state.passphrase || !state.referenceImageBytesAttach; }); 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('back-btn')?.addEventListener('click', () => ctx.goto('connection')); document.getElementById('attach-btn')?.addEventListener('click', () => { if (!state.referenceImageBytesAttach) { state.error = 'Please select your reference JPEG image'; ctx.rerender(); return; } if (!state.passphrase) { state.error = 'Passphrase is required'; ctx.rerender(); return; } ctx.goto('device'); }); return () => {}; } function attachVaultNew(ctx: StepContext): () => 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; ctx.rerender(); }; 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; updateStrengthUi(); 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', () => ctx.goto('connection')); document.getElementById('create-btn')?.addEventListener('click', async () => { 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'; ctx.rerender(); return; } if (!state.passphrase) { state.error = 'Passphrase is required'; ctx.rerender(); return; } // Re-rate synchronously in case the button was clicked before the debounced // rater fired. Defence in depth — the button is already disabled when score < 3. 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).'; ctx.rerender(); return; } if (state.passphrase !== state.passphraseConfirm) { state.error = 'Passphrases do not match'; ctx.rerender(); return; } ctx.goto('device'); }); return () => {}; } // --- device --- const deviceStep: SetupStep = { id: 'device', render() { const busy = state.creating || state.attaching; const platform = navigator.platform.toLowerCase(); const isChrome = /chrome/i.test(navigator.userAgent) && !/edg/i.test(navigator.userAgent); const isFirefox = /firefox/i.test(navigator.userAgent); const browser = isFirefox ? 'Firefox' : isChrome ? 'Chrome' : 'Browser'; const os = platform.includes('mac') ? 'macOS' : platform.includes('win') ? 'Windows' : 'Linux'; const defaultName = state.deviceName || `${browser} on ${os}`; const busyLabel = state.attaching ? 'attaching…' : 'creating…'; return `

name this device

This helps you identify which devices have access to your vault.

`; }, attach(_root, ctx) { document.getElementById('back-btn')?.addEventListener('click', () => { if (!state.creating && !state.attaching) ctx.goto('vault'); }); document.getElementById('next-btn')?.addEventListener('click', async () => { if (state.creating || state.attaching) return; const name = (document.getElementById('device-name') as HTMLInputElement).value.trim(); if (!name) { state.error = 'Device name is required'; ctx.rerender(); return; } state.deviceName = name; state.error = null; if (state.mode === 'attach') { state.attaching = true; ctx.rerender(); const resp = await swSend({ type: 'attach_vault', config: vaultConfig(), passphrase: state.passphrase, referenceImageBytes: state.referenceImageBytesAttach!.buffer as ArrayBuffer, deviceName: state.deviceName, }); state.attaching = false; if (resp.ok) ctx.goto('done'); else { state.error = resp.error; ctx.rerender(); } } else { state.creating = true; ctx.rerender(); const resp = await swSend({ type: 'create_vault', config: vaultConfig(), passphrase: state.passphrase, carrierImageBytes: state.carrierImageBytes!.buffer as ArrayBuffer, deviceName: state.deviceName, }); state.creating = false; if (resp.ok) { const data = resp.data as { referenceImageBytes: Uint8Array }; state.referenceImageBytes = new Uint8Array(data.referenceImageBytes); ctx.goto('done'); } else { state.error = resp.error; ctx.rerender(); } } }); return () => {}; }, }; // --- done --- const doneStep: SetupStep = { id: 'done', render() { const isAttach = state.mode === 'attach'; const qrBannerHtml = isAttach ? '' : `
Generate a recovery QR before you go

If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.

`; const refSection = isAttach ? '' : `

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

`; return `

${isAttach ? 'device attached' : 'vault created'}

${isAttach ? 'This device is now attached to your vault.' : 'Your vault has been initialized and pushed to the repository.'}

${qrBannerHtml} ${refSection}

Copy this JSON to configure Relicario on another setup, or save it for later.

${escapeHtml(JSON.stringify(vaultConfig(), null, 2))}
`; }, attach(_root, _ctx) { document.getElementById('setup-gen-qr')?.addEventListener('click', async () => { const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null; if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; } try { const resp = await swSend({ type: 'generate_recovery_qr', passphrase: state.passphrase }); if (!resp.ok || !resp.data) throw new Error(resp.ok ? 'unknown error' : resp.error); const svg = (resp.data as { svg: string }).svg; await new Promise((resolve) => { chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve); }); const banner = document.getElementById('recovery-qr-banner'); if (banner) { banner.innerHTML = `
${svg}

◉ Recovery QR generated — save or print this now.

`; document.getElementById('setup-qr-done')?.addEventListener('click', () => { banner.style.display = 'none'; }); } } catch (err) { if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; } alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`); } }); document.getElementById('setup-skip-qr')?.addEventListener('click', () => { const banner = document.getElementById('recovery-qr-banner'); if (banner) banner.style.display = 'none'; }); 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('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 { const range = document.createRange(); range.selectNodeContents(blob); const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(range); } }); document.getElementById('open-vault-btn')?.addEventListener('click', () => void finishSetup()); return () => {}; }, }; // --- Registry --- export const STEPS: ReadonlyArray = [ modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep, ]; // --- Sensitive-state cleanup --- export function clearWizardState(): void { // Best-effort wipe — JS strings are GC-only (see spec Risks); zero-fill the Uint8Arrays. state.carrierImageBytes?.fill(0); state.referenceImageBytes?.fill(0); state.referenceImageBytesAttach?.fill(0); state.mode = null; state.hostType = 'gitea'; state.hostUrl = ''; state.repoPath = ''; state.apiToken = ''; state.connectionTested = false; state.vaultProbe = null; state.carrierImageBytes = null; state.referenceImageBytesAttach = null; state.passphrase = ''; state.passphraseConfirm = ''; state.passphraseScore = -1; state.passphraseGuessesLog10 = -1; state.passphraseVisible = false; state.confirmVisible = false; state.referenceImageBytes = null; state.creating = false; state.attaching = false; state.error = null; state.deviceName = ''; } // --- Completion handoff --- /// Open the fullscreen vault tab and best-effort close the setup tab. export async function finishSetup(): Promise { const vaultUrl = chrome.runtime.getURL('vault.html'); await chrome.tabs.create({ url: vaultUrl }); try { const current = await chrome.tabs.getCurrent(); if (current?.id !== undefined) { await chrome.tabs.remove(current.id); } } catch { // Setup tab may not be closeable (e.g., opened as popup rather than a tab). // The vault tab is open — that's the user-visible success. } }