/// Vault initialization wizard — 5-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: Name this device (generates ed25519 keypair, registers with vault) /// Step 5: Finish (download reference image, push config to extension or copy JSON) import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host'; import { addDevice } from '../service-worker/devices'; import { probeVault } from './probe'; import { escapeHtml, ratePassphrase, scheduleRate, STRENGTH_LABELS, entropyText, } from './setup-helpers'; import type { VaultConfig } from '../shared/types'; import type { SessionHandle } from 'relicario-wasm'; // --- 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; // now 0..5; was 1..5 mode: 'new' | 'attach' | null; // null until Step 0 picks hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: string; connectionTested: boolean; vaultProbe: import('./probe').VaultProbe | null; carrierImageBytes: Uint8Array | null; referenceImageBytesAttach: 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; 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: '', }; // --- 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. 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 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; } } // --- Step 0: Mode picker --- function renderStep0(): string { const isNew = state.mode === 'new'; const isAttach = state.mode === 'attach'; return `

set up relicario

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(); }); }); document.getElementById('next-btn')?.addEventListener('click', () => { if (!state.mode) return; state.step = 1; state.error = null; render(); }); } // --- Step 3 (attach variant) --- 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; 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.

${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. 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.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(); }); }); document.getElementById('next-btn')?.addEventListener('click', () => { state.step = 2; state.error = null; render(); }); } // --- Step 2: Configure 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 ` `; } 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 `

configure connection

${state.connectionTested ? 'connected' : ''}
${renderProbeBanner()}
`; } 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) { state.connectionTested = false; state.vaultProbe = null; 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(); }); 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(); }); } // --- Step 3 (new-vault variant): Create Vault --- function renderStep3New(): 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 attachStep3New(): 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 = '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(); } }); } // --- Step 4: Device Name --- 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 `

name this device

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'; 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.'}

${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('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(); const keypair = JSON.parse(w.generate_device_keypair()) as { public_key_hex: string; private_key_base64: string; }; // 1) Save private key + name locally. await chrome.storage.local.set({ device_name: state.deviceName, device_private_key: keypair.private_key_base64, }); // 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(); 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.public_key_hex, 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.configPushed = true; render(); } 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; } 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(); });