diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index ef92f14..84c7cb4 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -1,46 +1,46 @@ -/// Vault initialization wizard — 5-step flow for creating new relicario vaults. +/// Vault initialization wizard — UI-only step registry. /// -/// 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: Name this device (generates ed25519 keypair, registers with vault) -/// Step 5: Finish (download reference image, push config to extension or copy JSON) +/// mode → host → connection → vault → device → done +/// +/// All crypto/remote/device orchestration lives in the service worker +/// (create_vault / attach_vault). This module is presentation + validation +/// only: it collects inputs, then fires a single SW message from the device +/// step (where the device name is known) and renders the result. -import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host'; -import { addDevice } from '../service-worker/devices'; +import { createGitHost } from '../service-worker/git-host'; import { probeVault } from './probe'; -import { - escapeHtml, - ratePassphrase, - scheduleRate, - STRENGTH_LABELS, - entropyText, -} from './setup-helpers'; +import { escapeHtml, ratePassphrase, scheduleRate, STRENGTH_LABELS, entropyText } from './setup-helpers'; import { GLYPH_NEXT } from '../shared/glyphs'; import type { VaultConfig } from '../shared/types'; -import type { SessionHandle } from 'relicario-wasm'; +import type { Request, Response } from '../shared/messages'; -// --- WASM module (loaded dynamically) --- +// --- SW messaging (setup does not register a StateHost) --- -type WasmModule = typeof import('relicario-wasm'); -let wasm: WasmModule | null = null; +function swSend(msg: Request): Promise { + return new Promise((resolve) => chrome.runtime.sendMessage(msg, (r: Response) => resolve(r))); +} -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; +// --- Step registry types --- + +type StepId = 'mode' | 'host' | 'connection' | 'vault' | 'device' | 'done'; + +interface StepContext { + state: WizardState; + rerender: () => void; + goto: (id: StepId) => void; +} + +interface SetupStep { + id: StepId; + render: (ctx: StepContext) => string; + attach: (root: HTMLElement, ctx: StepContext) => () => void; } // --- State --- interface WizardState { - step: number; // now 0..5; was 1..5 - mode: 'new' | 'attach' | null; // null until Step 0 picks + stepId: StepId; + mode: 'new' | 'attach' | null; hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; @@ -53,44 +53,22 @@ interface WizardState { passphraseConfirm: string; // zxcvbn meter state — -1 means "not yet scored" (empty passphrase). passphraseScore: number; - passphraseGuessesLog10: number; // -1 before first rating + passphraseGuessesLog10: number; passphraseVisible: boolean; confirmVisible: boolean; referenceImageBytes: Uint8Array | null; - verifiedHandle: SessionHandle | null; creating: boolean; attaching: boolean; error: string | null; - extensionDetected: boolean; - configPushed: boolean; deviceName: string; } const state: WizardState = { - step: 0, - 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, - verifiedHandle: null, - creating: false, - attaching: false, - error: null, - extensionDetected: false, - configPushed: false, - deviceName: '', + 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: '', }; // --- Progress track --- @@ -106,9 +84,8 @@ function renderProgressTrack(current: number): string { // --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) --- -/// 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. +/// Update just the meter DOM without a full re-render (so the input keeps focus and +/// the cursor position is preserved). Also updates the char counter and match indicator. function updateStrengthUi(): void { const bar = document.getElementById('strength-bar'); const label = document.getElementById('strength-label'); @@ -116,327 +93,117 @@ function updateStrengthUi(): void { 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 { + 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); + 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; - 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 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' + ? (score < 3 ? 'passphrase must score "good" or better' : !state.passphraseConfirm ? 'confirm your passphrase' - : !matchOk ? 'passphrases do not match' - : '') + : !matchOk ? 'passphrases do not match' : '') : ''; } } -// --- Render --- - -function render(): void { - const app = document.getElementById('app'); - if (!app) return; - - const progressHtml = renderProgressTrack(state.step); - - let stepHtml = ''; - switch (state.step) { - case 0: stepHtml = renderStep0(); break; - case 1: stepHtml = renderStep1(); break; - case 2: stepHtml = renderStep2(); break; - case 3: stepHtml = state.mode === 'attach' ? renderStep3Attach() : renderStep3New(); break; - case 4: stepHtml = renderStep4(); break; - case 5: stepHtml = renderStep5(); break; - } - - app.innerHTML = ` -
-
- -
Relicario vault setup
- ${progressHtml} - ${state.error ? `
${escapeHtml(state.error)}
` : ''} - ${stepHtml} -
-
- `; - - switch (state.step) { - case 0: attachStep0(); break; - case 1: attachStep1(); break; - case 2: attachStep2(); break; - case 3: state.mode === 'attach' ? attachStep3Attach() : attachStep3New(); break; - case 4: attachStep4(); break; - case 5: attachStep5(); break; - } +function vaultConfig(): VaultConfig { + return { + hostType: state.hostType, + hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl, + repoPath: state.repoPath, + apiToken: state.apiToken, + }; } -// --- Step 0: Mode picker --- +// --- mode --- -function renderStep0(): string { - const isNew = state.mode === 'new'; - const isAttach = state.mode === 'attach'; - return ` +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? -

+

How are you using Relicario on this device?

-
- `; -} - -function attachStep0(): void { - document.querySelectorAll('.mode-card').forEach((btn) => { - btn.addEventListener('click', () => { - state.mode = (btn as HTMLElement).dataset.mode as 'new' | 'attach'; - render(); + `; + }, + 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) return; - state.step = 1; - state.error = null; - render(); - }); -} + document.getElementById('next-btn')?.addEventListener('click', () => { + if (state.mode) ctx.goto('host'); + }); + return () => {}; + }, +}; -// --- Step 3 (attach variant) --- +// --- host --- -function renderStep3Attach(): string { - const p = state.passphrase; - const pType = state.passphraseVisible ? 'text' : 'password'; - const pToggle = state.passphraseVisible ? 'hide' : 'show'; - const hasImage = !!state.referenceImageBytesAttach; - const gateDisabled = state.attaching || !p || !hasImage; +const GITEA_INSTRUCTIONS = ` +
    +
  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. +
`; - return ` -
-

attach this device

-

- Use your existing passphrase and reference image to attach this browser - to your vault. We'll verify both before registering this device. -

+const GITHUB_INSTRUCTIONS = ` +
    +
  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. +
`; -
- -
- - ${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 attachStep3Attach(): 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; - render(); - }; - 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.attaching || !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', () => { - state.step = 2; - state.error = null; - render(); - }); - - document.getElementById('attach-btn')?.addEventListener('click', async () => { - if (!state.referenceImageBytesAttach || !state.passphrase) return; - state.attaching = true; - state.error = null; - render(); - - const log = (stage: string, detail?: unknown) => console.log(`[relicario setup] ${stage}`, detail ?? ''); - let stage = 'init'; - let handle: SessionHandle | null = null; - try { - stage = 'load wasm'; - log(stage); - const w = await loadWasm(); - - stage = 'fetch vault metadata'; - log(stage); - const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl; - const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); - const [salt, paramsBytes, encryptedManifest] = await Promise.all([ - host.readFile('.relicario/salt'), - host.readFile('.relicario/params.json'), - host.readFile('manifest.enc'), - ]); - const paramsJson = new TextDecoder().decode(paramsBytes); - - stage = 'derive session handle'; - log(stage); - handle = w.unlock(state.passphrase, state.referenceImageBytesAttach, salt, paramsJson); - - stage = 'decrypt manifest'; - log(stage); - // Throws if AEAD verification fails — wrong passphrase or wrong image. - w.manifest_decrypt(handle, encryptedManifest); - - log('attach verified — advancing'); - state.verifiedHandle = handle; - state.attaching = false; - state.step = 4; - state.error = null; - render(); - } catch (err: unknown) { - console.error(`[relicario setup] attach FAILED during "${stage}":`, err); - state.attaching = false; - // Lock any partial handle to avoid leaking key material. - if (handle !== null) { - try { (await loadWasm()).lock(handle); } catch { /* best effort */ } - } - state.verifiedHandle = null; - const detail = err instanceof Error ? err.message : String(err); - // Stage-aware copy: if we got past 'fetch', this is a credential failure. - if (stage === 'derive session handle' || stage === 'decrypt manifest') { - state.error = 'Could not decrypt vault — wrong passphrase or reference image.'; - } else { - state.error = `Attach failed at "${stage}": ${detail}`; - } - render(); - } - }); -} - -// --- 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 ` +const hostStep: SetupStep = { + id: 'host', + render() { + return `

choose host

@@ -446,38 +213,28 @@ function renderStep1(): string {
- ${state.hostType === 'gitea' ? giteaInstructions : githubInstructions} + ${state.hostType === 'gitea' ? GITEA_INSTRUCTIONS : GITHUB_INSTRUCTIONS}
- - `; -} - -function attachStep1(): void { - document.getElementById('back-btn')?.addEventListener('click', () => { - state.step = 0; - state.error = null; - render(); - }); - - document.querySelectorAll('.toggle-group button').forEach(btn => { - btn.addEventListener('click', () => { - state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github'; - state.connectionTested = false; - render(); + `; + }, + 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 () => {}; + }, +}; - document.getElementById('next-btn')?.addEventListener('click', () => { - state.step = 2; - state.error = null; - render(); - }); -} - -// --- Step 2: Configure Connection --- +// --- connection --- function renderProbeBanner(): string { const probe = state.vaultProbe; @@ -490,12 +247,8 @@ function renderProbeBanner(): string { `; } if (state.mode === 'attach' && !probe.exists) { @@ -503,9 +256,7 @@ function renderProbeBanner(): string { `; } if (state.mode === 'attach' && probe.exists) { @@ -517,18 +268,17 @@ function renderProbeBanner(): string { `; } // mode = new, !exists - return ` - `; + return ``; } -function renderStep2(): string { - const probe = state.vaultProbe; - const modeMismatch = - !!probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists)); - const nextDisabled = !state.connectionTested || !probe || modeMismatch; - 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

@@ -552,123 +302,127 @@ function renderStep2(): string {
-
- `; -} - -function attachStep2(): void { - 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'; - 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; - 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) { + `; + }, + attach(_root, ctx) { + document.getElementById('test-btn')?.addEventListener('click', async () => { state.connectionTested = false; state.vaultProbe = null; - state.error = `Connection failed: ${err instanceof Error ? err.message : String(err)}`; - } - render(); - }); + 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(); - document.getElementById('back-btn')?.addEventListener('click', () => { - state.step = 1; - state.error = null; - render(); - }); + 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 () => {}; + }, +}; - document.getElementById('next-btn')?.addEventListener('click', () => { - if (!state.connectionTested) return; - state.step = 3; - state.error = null; - render(); - }); +// --- vault --- - document.getElementById('switch-mode-btn')?.addEventListener('click', (e) => { - const target = (e.currentTarget as HTMLElement).dataset.target as 'new' | 'attach'; - state.mode = target; - state.error = null; - render(); - }); +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.

+
+
+ +
+ + +
+
+
+ + +
+
`; } -// --- Step 3 (new-vault variant): Create Vault --- - -function renderStep3New(): string { +function renderVaultNew(): 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 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

'} + ${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. -
- +
A long phrase of unrelated words is stronger than a short complex password. Your vault needs good (score ≥ 3) to continue.
@@ -676,11 +430,7 @@ function renderStep3New(): string {

${labelText}

@@ -688,7 +438,6 @@ function renderStep3New(): string {

${escapeHtml(entropy || ' ')}

-
@@ -697,23 +446,72 @@ function renderStep3New(): string {
-
- +
-
- `; + `; } -function attachStep3New(): void { +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; @@ -721,32 +519,27 @@ function attachStep3New(): void { reader.onload = () => { state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer); state.error = null; - render(); + 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; - // 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', () => { @@ -756,7 +549,6 @@ function attachStep3New(): void { 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'; @@ -764,436 +556,260 @@ function attachStep3New(): void { 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('back-btn')?.addEventListener('click', () => ctx.goto('connection')); 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(); + ctx.rerender(); return; } if (!state.passphrase) { state.error = 'Passphrase is required'; - render(); + 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 in the UI when score < 3 (audit H3). + // 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).'; - render(); + ctx.rerender(); return; } if (state.passphrase !== state.passphraseConfirm) { state.error = 'Passphrases do not match'; - render(); + ctx.rerender(); 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 = 'encrypt default settings'; - log(stage); - const settingsJson = w.default_vault_settings_json(); - const encryptedSettings = w.settings_encrypt(handle, settingsJson); - log('settings encrypted', { bytes: encryptedSettings.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.writeFileCreateOnly('.relicario/salt', salt, 'init: vault salt'); - - log('write .relicario/params.json'); - const paramsBytes = new TextEncoder().encode(paramsJson); - await host.writeFileCreateOnly('.relicario/params.json', paramsBytes, 'init: KDF parameters'); - - log('write manifest.enc'); - await host.writeFileCreateOnly( - 'manifest.enc', - new Uint8Array(encryptedManifest), - 'init: encrypted manifest', - ); - - log('write settings.enc'); - await host.writeFileCreateOnly( - 'settings.enc', - new Uint8Array(encryptedSettings), - 'init: encrypted settings', - ); - - stage = 'release handle'; - w.lock(handle); - - log('vault created — advancing to step 4 (device name)'); - state.creating = false; - state.step = 4; // device name step - state.error = null; - 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); - if (/already exists/.test(detail)) { - const path = detail.replace(/^.*?writeFileCreateOnly: /, '').replace(/ already exists$/, ''); - state.error = `A file at ${path} already exists on the remote — refusing to overwrite. Re-run setup; the wizard will offer to attach to the existing vault.`; - } else { - state.error = `Vault creation failed at "${stage}": ${detail}`; - } - render(); - } + ctx.goto('device'); }); + return () => {}; } -// --- Step 4: Device Name --- +// --- device --- -function renderStep4(): string { - 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}`; - - return ` +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. -

+

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

- +
- - + +
-
- `; -} - -function attachStep4(): void { - document.getElementById('back-btn')?.addEventListener('click', () => { - state.step = 3; - state.error = null; - render(); - }); - - document.getElementById('next-btn')?.addEventListener('click', async () => { - const nameInput = document.getElementById('device-name') as HTMLInputElement; - const name = nameInput.value.trim(); - if (!name) { - state.error = 'Device name is required'; - render(); - return; - } - - state.deviceName = name; - state.step = 5; - state.error = null; - detectExtension(); - render(); - }); -} - -// --- Step 5: 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 renderStep5(): 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); - const isAttach = state.mode === 'attach'; - - const qrBannerHtml = (!isAttach && state.verifiedHandle !== null) ? ` -
-
- - 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. -

-
- - -
-
- ` : ''; - - return ` -
-
-

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

-

- ${isAttach - ? 'Your passphrase and reference image decrypt the vault successfully.' - : 'Your vault has been initialized and pushed to the repository.'} -

-
- - ${qrBannerHtml} - - ${isAttach ? '' : ` -
- -

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

- -
- `} - - ${state.extensionDetected ? ` -
- - - ${state.configPushed ? 'done' : ''} -
- ` : ` -
- -

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

-
${escapeHtml(configJson)}
- -
- `} -
- `; -} - -function attachStep5(): void { - document.getElementById('setup-gen-qr')?.addEventListener('click', async () => { - if (!state.verifiedHandle) return; - const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null; - if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; } - try { - const { sendMessage } = await import('../shared/state'); - const resp = await sendMessage({ - type: 'generate_recovery_qr', - sessionHandle: state.verifiedHandle.value, - passphrase: state.passphrase, - } as any) as any; - if (!resp.ok || !resp.data) throw new Error(resp.error ?? 'unknown 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('push-config-btn')?.addEventListener('click', async () => { - state.error = null; - render(); - - const config: VaultConfig = { - hostType: state.hostType, - hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl, - repoPath: state.repoPath, - apiToken: state.apiToken, - }; - - try { - const w = await loadWasm(); - // register_device keeps private keys internal — only public keys returned - const keypair = w.register_device(state.deviceName); - - // 1) Save device name locally (private keys stay in WASM memory). - await chrome.storage.local.set({ - device_name: state.deviceName, - }); - - // 2) Save vault config + reference image to extension storage. - const imageBytes = state.referenceImageBytes ?? state.referenceImageBytesAttach; - const imageBase64 = imageBytes ? uint8ArrayToBase64(imageBytes) : ''; - const saveOk = await new Promise((resolve) => { - chrome.runtime.sendMessage( - { type: 'save_setup', config, imageBase64 }, - (response: { ok: boolean; error?: string }) => { - if (!response?.ok) { - state.error = response?.error ?? 'Failed to save config to extension'; - resolve(false); return; - } - resolve(true); - }, - ); - }); - if (!saveOk) { - if (state.verifiedHandle !== null) { - try { w.lock(state.verifiedHandle); } catch { /* best effort */ } - state.verifiedHandle = null; - } - render(); + `; + }, + 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; } - - // 3) Register device on the remote (read-modify-write devices.json). - const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl; - const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); - await addDevice(host, { - name: state.deviceName, - public_key: keypair.signing_public_key, - added_at: Math.floor(Date.now() / 1000), - }); - - // 4) Release any attach-mode WASM handle. - if (state.verifiedHandle !== null) { - try { w.lock(state.verifiedHandle); } catch { /* best effort */ } - state.verifiedHandle = null; + 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 () => {}; + }, +}; - state.configPushed = true; - render(); - void finishSetup(); - } catch (err: unknown) { - console.error('[relicario setup] register device failed:', err); - state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`; - if (state.verifiedHandle !== null) { - try { (await loadWasm()).lock(state.verifiedHandle); } catch { /* best effort */ } - state.verifiedHandle = null; +// --- 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} +
+ +
+
`; + }, + 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 { + // The SW uses its current session (set by create_vault) — no handle passed. + 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)}`); } - render(); - } - }); + }); + 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('open-vault-btn')?.addEventListener('click', () => void finishSetup()); + return () => {}; + }, +}; - 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); - } - }); +// --- Registry + render loop --- + +const STEPS: ReadonlyArray = [ + modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep, +]; + +let teardown: (() => void) | null = null; + +function rerender(): void { + const app = document.getElementById('app'); + if (!app) return; + teardown?.(); + const ctx: StepContext = { state, rerender, goto }; + const step = STEPS.find((s) => s.id === state.stepId)!; + const idx = STEPS.findIndex((s) => s.id === state.stepId); + app.innerHTML = `
+ +
Relicario vault setup
+ ${renderProgressTrack(idx)} + ${state.error ? `
${escapeHtml(state.error)}
` : ''} + ${step.render(ctx)} +
`; + teardown = step.attach(app, ctx); +} + +function goto(id: StepId): void { + if (id === 'mode') clearWizardState(); + state.stepId = id; + state.error = null; + rerender(); +} + +// --- 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 --- @@ -1216,5 +832,8 @@ export async function finishSetup(): Promise { // --- Boot --- document.addEventListener('DOMContentLoaded', () => { - render(); + window.addEventListener('beforeunload', clearWizardState); + rerender(); }); + +export { STEPS };