diff --git a/extension/src/service-worker/__tests__/vault.test.ts b/extension/src/service-worker/__tests__/vault.test.ts new file mode 100644 index 0000000..f926151 --- /dev/null +++ b/extension/src/service-worker/__tests__/vault.test.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vault from '../vault'; +import * as session from '../session'; +import type { PopupState } from '../router/popup-only'; +import type { GitHost } from '../git-host'; +import * as gitHostMod from '../git-host'; + +// --- Mock git-host module --- +// createGitHost is called internally by handleCreateVault / handleAttachVault; +// we need to intercept it and return a fake GitHost. uint8ArrayToBase64 must +// still work — vault.ts calls it for the imageBase64 storage value. + +// Shared factory used both inside vi.mock and in beforeEach re-wire. +function makeHostMock(): GitHost & { _calls: Record } { + const calls: Record = { + writeFileCreateOnly: [], + writeFile: [], + readFile: [], + }; + return { + _calls: calls, + readFile: vi.fn().mockImplementation(async (path: string) => { + // Serve the vault-meta files needed by fetchVaultMeta + attach flow. + if (path === '.relicario/salt') return new Uint8Array(32); + if (path === '.relicario/params.json') { + return new TextEncoder().encode('{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'); + } + if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]); + // .relicario/devices.json throws so readDevices falls back to []. + throw new Error(`404: ${path}`); + }), + writeFile: vi.fn().mockImplementation(async (...args: unknown[]) => { + calls.writeFile.push(args); + }), + writeFileCreateOnly: vi.fn().mockImplementation(async (...args: unknown[]) => { + calls.writeFileCreateOnly.push(args); + }), + deleteFile: vi.fn(), + listDir: vi.fn().mockResolvedValue([]), + lastCommit: vi.fn().mockResolvedValue(null), + putBlob: vi.fn(), + getBlob: vi.fn(), + deleteBlob: vi.fn(), + }; +} + +vi.mock('../git-host', async () => { + const actual = await vi.importActual('../git-host'); + + // Expose a handle so tests can grab the last-created fake host. + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = null; + + return { + ...actual, + createGitHost: vi.fn().mockImplementation(() => { + const h = makeHostMock(); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; + return h; + }), + }; +}); + +// --- Chrome storage mock --- + +function mockChromeStorage(initial: Record = {}) { + const store: Record = { ...initial }; + (global as { chrome: unknown }).chrome = { + storage: { + local: { + get: vi.fn((keys: string | string[]) => { + const arr = Array.isArray(keys) ? keys : [keys]; + const out: Record = {}; + for (const k of arr) if (k in store) out[k] = store[k]; + return Promise.resolve(out); + }), + set: vi.fn((kv: Record) => { + Object.assign(store, kv); + return Promise.resolve(); + }), + }, + }, + } as never; + return store; +} + +// --- Helpers --- + +function makeFakeHandle() { + return { free: vi.fn() }; +} + +function makeWasm(overrides: Record = {}) { + const fakeHandle = makeFakeHandle(); + return { + _handle: fakeHandle, + embed_image_secret: vi.fn(() => new Uint8Array([1, 2, 3])), + unlock: vi.fn(() => fakeHandle), + manifest_encrypt: vi.fn(() => new Uint8Array([9])), + manifest_decrypt: vi.fn(() => ({ schema_version: 2, items: {} })), + default_vault_settings_json: vi.fn(() => '{}'), + settings_encrypt: vi.fn(() => new Uint8Array([8])), + register_device: vi.fn(() => ({ signing_public_key: 'pk', deploy_public_key: 'dk' })), + lock: vi.fn(), + ...overrides, + }; +} + +function makeState(wasm: ReturnType): PopupState { + return { + manifest: null, + gitHost: null, + wasm, + }; +} + +const BASE_MSG = { + config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' }, + passphrase: 'pw', + carrierImageBytes: new Uint8Array([0, 0, 0]).buffer, + deviceName: 'Dev', +}; + +// --- Tests --- + +describe('handleCreateVault', () => { + let setCurrent: ReturnType; + + beforeEach(() => { + mockChromeStorage(); + setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('Test 1 (happy path): returns ok:true with expected data and correct side effects', async () => { + const wasm = makeWasm(); + const state = makeState(wasm); + + const resp = await vault.handleCreateVault(BASE_MSG, state); + + expect(resp.ok).toBe(true); + if (!resp.ok) throw new Error('expected ok:true'); + + // Response shape + expect(resp.data.referenceImageBytes).toBeInstanceOf(Uint8Array); + expect(resp.data.deviceName).toBe('Dev'); + expect(resp.data.recoveryQrAvailable).toBe(true); + + // Fake GitHost captures the four writeFileCreateOnly calls + const fakeHost = (globalThis as { __lastFakeGitHost?: { writeFileCreateOnly: ReturnType } }).__lastFakeGitHost; + expect(fakeHost).not.toBeNull(); + const wfco = fakeHost!.writeFileCreateOnly as ReturnType; + const paths = wfco.mock.calls.map((c: unknown[]) => c[0]); + expect(paths).toContain('.relicario/salt'); + expect(paths).toContain('.relicario/params.json'); + expect(paths).toContain('manifest.enc'); + expect(paths).toContain('settings.enc'); + + // register_device called with the device name + expect(wasm.register_device).toHaveBeenCalledWith('Dev'); + + // chrome.storage.local.set called with vaultConfig + imageBase64 + device_name + const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType } } } }) + .chrome.storage.local.set.mock.calls; + const merged: Record = {}; + for (const [kv] of chromeSets) Object.assign(merged, kv); + expect(merged).toHaveProperty('vaultConfig'); + expect(merged).toHaveProperty('imageBase64'); + expect(merged).toHaveProperty('device_name', 'Dev'); + + // session.setCurrent was called (ownership transferred — handle NOT freed) + expect(setCurrent).toHaveBeenCalled(); + expect(wasm._handle.free).not.toHaveBeenCalled(); + }); + + it('Test 2 (failure path — early throw): ok:false, no writeFileCreateOnly calls', async () => { + const wasm = makeWasm({ + embed_image_secret: vi.fn(() => { throw new Error('embed failed'); }), + }); + const state = makeState(wasm); + + const resp = await vault.handleCreateVault(BASE_MSG, state); + + expect(resp.ok).toBe(false); + if (resp.ok) throw new Error('expected ok:false'); + expect(resp.error).toBeTruthy(); + expect(resp.error.length).toBeGreaterThan(0); + + const fakeHost = (globalThis as { __lastFakeGitHost?: { writeFileCreateOnly: ReturnType } }).__lastFakeGitHost; + // No GitHost was created at all (failed before createGitHost call), OR + // if somehow created, no writeFileCreateOnly calls happened. + if (fakeHost) { + expect((fakeHost.writeFileCreateOnly as ReturnType).mock.calls).toHaveLength(0); + } + }); + + it('Test 3 (handle cleanup on mid-flight failure): lock + free called, ok:false', async () => { + const wasm = makeWasm({ + manifest_encrypt: vi.fn(() => { throw new Error('encrypt failed'); }), + }); + const state = makeState(wasm); + + const resp = await vault.handleCreateVault(BASE_MSG, state); + + expect(resp.ok).toBe(false); + + // unlock succeeded (handle was acquired), manifest_encrypt failed after that. + // Finally block must: lock(handle) then handle.free(). + expect(wasm.lock).toHaveBeenCalledWith(wasm._handle); + expect(wasm._handle.free).toHaveBeenCalled(); + + // Ownership was NOT transferred — setCurrent must NOT have been called. + expect(setCurrent).not.toHaveBeenCalled(); + }); +}); + +// --- attach_vault --- + +const ATTACH_MSG = { + config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' }, + passphrase: 'pw', + referenceImageBytes: new Uint8Array([1, 2, 3]).buffer, + deviceName: 'Dev2', +}; + +describe('handleAttachVault', () => { + let setCurrent: ReturnType; + + beforeEach(() => { + mockChromeStorage(); + // Re-wire createGitHost: vi.restoreAllMocks() in the create-vault afterEach + // strips the mockImplementation from the vi.fn(), leaving it returning undefined. + // We re-establish it here so each attach test starts with a fresh fake host. + vi.mocked(gitHostMod.createGitHost).mockImplementation(() => { + const h = makeHostMock(); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; + return h; + }); + setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('Test 1 (happy path): returns ok:true, state populated, handle ownership transferred', async () => { + const wasm = makeWasm(); + const state = makeState(wasm); + + const resp = await vault.handleAttachVault(ATTACH_MSG, state); + + expect(resp.ok).toBe(true); + if (!resp.ok) throw new Error('expected ok:true'); + expect(resp.data.deviceName).toBe('Dev2'); + + // WASM calls in order: unlock → manifest_decrypt (verification) → register_device + expect(wasm.unlock).toHaveBeenCalled(); + expect(wasm.manifest_decrypt).toHaveBeenCalled(); + expect(wasm.register_device).toHaveBeenCalledWith('Dev2'); + + // chrome.storage.local.set received vaultConfig + imageBase64 + device_name + const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType } } } }) + .chrome.storage.local.set.mock.calls; + const merged: Record = {}; + for (const [kv] of chromeSets) Object.assign(merged, kv); + expect(merged).toHaveProperty('vaultConfig'); + expect(merged).toHaveProperty('imageBase64'); + expect(merged).toHaveProperty('device_name', 'Dev2'); + + // session.setCurrent called — ownership transferred; handle NOT freed + expect(setCurrent).toHaveBeenCalled(); + expect(wasm._handle.free).not.toHaveBeenCalled(); + + // State wired up + expect(state.manifest).not.toBeNull(); + expect(state.gitHost).not.toBeNull(); + }); + + it('Test 2 (wrong credentials — manifest_decrypt throws): ok:false, handle locked+freed, no side effects', async () => { + const wasm = makeWasm({ + manifest_decrypt: vi.fn(() => { throw new Error('AEAD verification failed'); }), + }); + const state = makeState(wasm); + + const resp = await vault.handleAttachVault(ATTACH_MSG, state); + + expect(resp.ok).toBe(false); + if (resp.ok) throw new Error('expected ok:false'); + expect(resp.error).toBeTruthy(); + expect(resp.error.length).toBeGreaterThan(0); + + // register_device must NOT be called (we failed before it) + expect(wasm.register_device).not.toHaveBeenCalled(); + + // Finally block must lock then free the handle we own + expect(wasm.lock).toHaveBeenCalledWith(wasm._handle); + expect(wasm._handle.free).toHaveBeenCalled(); + + // Session must NOT have been updated + expect(setCurrent).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 0be5e5b..8df97e9 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -628,9 +628,15 @@ export async function handle( } } - // create_vault / attach_vault land in Phase 3 Tasks 3.2-3.3; get_vault_status - // in Phase 6 (Dev-C). Until each case lands, an unhandled popup message - // returns an explicit error rather than falling through with no return. + case 'create_vault': + return vault.handleCreateVault(msg, state); + + case 'attach_vault': + return vault.handleAttachVault(msg, state); + + // get_vault_status lands in Phase 6 (Dev-C). + // Until each case lands, an unhandled popup message returns an explicit + // error rather than falling through with no return. default: return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` }; } diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index c10f9bb..d12cb89 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -3,8 +3,12 @@ import type { SessionHandle } from '../../wasm/relicario_wasm'; import type { GitHost } from './git-host'; -import { uint8ArrayToBase64 } from './git-host'; -import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types'; +import { createGitHost, uint8ArrayToBase64 } from './git-host'; +import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings, VaultConfig } from '../shared/types'; +import * as session from './session'; +import * as devices from './devices'; +import type { AttachVaultResponse, CreateVaultResponse } from '../shared/messages'; +import type { PopupState } from './router/popup-only'; // eslint-disable-next-line @typescript-eslint/no-explicit-any let wasm: any = null; @@ -17,6 +21,125 @@ function requireWasm(): any { return wasm; } +const DEFAULT_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; + +/// Register this device on the remote (devices.json) and persist the vault +/// config + reference image locally so future unlocks work. Shared by the +/// create and attach flows — both finish with this identical tail. +async function registerDeviceAndPersistConfig( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + w: any, + git: GitHost, + config: VaultConfig, + referenceImageBytes: Uint8Array, + deviceName: string, +): Promise { + const keys = w.register_device(deviceName) as { signing_public_key: string }; + await devices.addDevice(git, { + name: deviceName, + public_key: keys.signing_public_key, + added_at: Math.floor(Date.now() / 1000), + }); + await chrome.storage.local.set({ + vaultConfig: config, + imageBase64: uint8ArrayToBase64(referenceImageBytes), + device_name: deviceName, + }); +} + +export async function handleCreateVault( + msg: { config: VaultConfig; passphrase: string; carrierImageBytes: ArrayBuffer; deviceName: string }, + state: PopupState, +): Promise { + const w = state.wasm; + let handle: SessionHandle | null = null; + try { + const carrierBytes = new Uint8Array(msg.carrierImageBytes); + const imageSecret = new Uint8Array(32); + crypto.getRandomValues(imageSecret); + const referenceImageBytes = new Uint8Array(w.embed_image_secret(carrierBytes, imageSecret)); + + const salt = new Uint8Array(32); + crypto.getRandomValues(salt); + // Capture the unlock result in a non-null binding for the in-scope ops; + // `handle` stays the ownership tracker the finally block cleans up. + const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, salt, DEFAULT_PARAMS_JSON); + handle = h; + + const encryptedManifest = new Uint8Array(w.manifest_encrypt(h, '{"schema_version":2,"items":{}}')); + const encryptedSettings = new Uint8Array(w.settings_encrypt(h, w.default_vault_settings_json())); + + const { config } = msg; + const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); + await git.writeFileCreateOnly('.relicario/salt', salt, 'init: vault salt'); + await git.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(DEFAULT_PARAMS_JSON), 'init: KDF parameters'); + await git.writeFileCreateOnly('manifest.enc', encryptedManifest, 'init: encrypted manifest'); + await git.writeFileCreateOnly('settings.enc', encryptedSettings, 'init: encrypted settings'); + + await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName); + + // SW now owns the unlocked session — keeps the handle alive (enables recoveryQrAvailable). + session.setCurrent(h); + state.gitHost = git; + state.manifest = { schema_version: 2, items: {} } as Manifest; + handle = null; // ownership transferred — do NOT lock-and-free in finally + + return { ok: true, data: { referenceImageBytes, deviceName: msg.deviceName, recoveryQrAvailable: true } }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } finally { + // Plan A .free() policy (docs/...extension-restructure-design.md Risks): lock THEN free, + // and only if we still own the handle (success path transfers ownership to session.setCurrent). + if (handle) { + try { w.lock(handle); } catch { /* lock may already have happened */ } + handle.free(); + } + } +} + +export async function handleAttachVault( + msg: { config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string }, + state: PopupState, +): Promise { + const w = state.wasm; + let handle: SessionHandle | null = null; + try { + const referenceImageBytes = new Uint8Array(msg.referenceImageBytes); + const { config } = msg; + const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); + + // The vault metadata and manifest are independent read-only GETs — fan out. + const [meta, encryptedManifest] = await Promise.all([ + fetchVaultMeta(git), + git.readFile('manifest.enc'), + ]); + + const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson); + handle = h; + // manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure. + const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest; + + await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName); + + // SW now owns the unlocked session — transfer ownership to the session. + session.setCurrent(h); + state.gitHost = git; + state.manifest = manifest; + handle = null; // ownership transferred — do NOT lock-and-free in finally + + return { ok: true, data: { deviceName: msg.deviceName } }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } finally { + // Same .free() policy as handleCreateVault: lock THEN free, only if we still + // own the handle (success path transfers ownership to session.setCurrent). + if (handle) { + try { w.lock(handle); } catch { /* lock may already have happened */ } + handle.free(); + } + } +} + export interface VaultMeta { salt: Uint8Array; paramsJson: string; diff --git a/extension/src/setup/__tests__/setup.test.ts b/extension/src/setup/__tests__/setup.test.ts index c148f83..85340c6 100644 --- a/extension/src/setup/__tests__/setup.test.ts +++ b/extension/src/setup/__tests__/setup.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { finishSetup } from '../setup'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { finishSetup, STEPS } from '../setup'; +import { state, clearWizardState } from '../setup-steps'; describe('finishSetup', () => { beforeEach(() => { @@ -35,3 +36,47 @@ describe('finishSetup', () => { expect(chrome.tabs.create).toHaveBeenCalled(); }); }); + +describe('setup step registry', () => { + it('has the six steps in canonical order', () => { + expect(STEPS.map((s) => s.id)).toEqual(['mode', 'host', 'connection', 'vault', 'device', 'done']); + }); + + it('each step renders non-empty HTML and attach returns a teardown', () => { + const ctx = { state: {} as never, rerender: vi.fn(), goto: vi.fn() }; + for (const step of STEPS) { + const html = step.render(ctx as never); + expect(typeof html).toBe('string'); + expect(html.length).toBeGreaterThan(0); + // render output must be in the DOM before attach (attach wires getElementById listeners) + document.body.innerHTML = `
${html}
`; + const teardown = step.attach(document.body, ctx as never); + expect(typeof teardown).toBe('function'); + teardown(); // must not throw + } + }); +}); + +describe('clearWizardState', () => { + afterEach(() => { + clearWizardState(); + }); + + it('zero-fills the reachable Uint8Array fields and resets state', () => { + const carrier = new Uint8Array([1, 2, 3, 4]); + const ref = new Uint8Array([5, 6, 7, 8]); + state.carrierImageBytes = carrier; + state.referenceImageBytes = ref; + state.passphrase = 'secret'; + state.mode = 'new'; + + clearWizardState(); + + expect(Array.from(carrier)).toEqual([0, 0, 0, 0]); // fill(0) ran on the captured ref + expect(Array.from(ref)).toEqual([0, 0, 0, 0]); + expect(state.carrierImageBytes).toBeNull(); // field reset + expect(state.referenceImageBytes).toBeNull(); + expect(state.passphrase).toBe(''); + expect(state.mode).toBeNull(); + }); +}); diff --git a/extension/src/setup/setup-steps.ts b/extension/src/setup/setup-steps.ts new file mode 100644 index 0000000..ed0e1f2 --- /dev/null +++ b/extension/src/setup/setup-steps.ts @@ -0,0 +1,805 @@ +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. +
  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 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. +
`; + +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. + } +} diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index ef92f14..5d04ace 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -1,97 +1,12 @@ -/// Vault initialization wizard — 5-step flow for creating new relicario vaults. +/// Vault initialization wizard — thin shell (render loop + boot). /// -/// 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) +/// Step registry, state, and all step implementations live in ./setup-steps. +/// This module owns only: progress track, rerender/goto loop, and the +/// DOMContentLoaded boot. -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 { GLYPH_NEXT } from '../shared/glyphs'; -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: '', -}; +import { STEPS, state, clearWizardState, finishSetup } from './setup-steps'; +import type { StepId, StepContext } from './setup-steps'; +import { escapeHtml } from './setup-helpers'; // --- Progress track --- @@ -104,1117 +19,40 @@ function renderProgressTrack(current: number): string { }).join('')}`; } -// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) --- +// --- Render loop --- -/// 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; +let teardown: (() => void) | null = 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 { +function rerender(): 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; - } + 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); } -// --- 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. -
  3. Go to Settings → Applications
  4. -
  5. Generate a new token with repo (read/write) permission
  6. -
  7. Copy the token — you will need it in the next step
  8. -
-
- `; - - const githubInstructions = ` -
-
    -
  1. Create a new private repository on GitHub (e.g. vault)
  2. -
  3. Go to Settings → Developer settings → Personal access tokens → Fine-grained tokens
  4. -
  5. Generate a new token scoped to the vault repo with Contents read/write permission
  6. -
  7. Copy the token — you will need it in the next step
  8. -
-
- `; - - return ` -
-

choose host

-
- -
- - -
-
- ${state.hostType === 'gitea' ? giteaInstructions : githubInstructions} -
- - -
-
- `; -} - -function attachStep1(): void { - document.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'; - - 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(); - 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.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; - } - 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); - } - }); -} - -// --- 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. - } +function goto(id: StepId): void { + if (id === 'mode') clearWizardState(); + state.stepId = id; + state.error = null; + rerender(); } // --- Boot --- document.addEventListener('DOMContentLoaded', () => { - render(); + window.addEventListener('beforeunload', clearWizardState); + rerender(); }); + +// Re-exports so existing test and bundle imports resolve unchanged. +export { STEPS, clearWizardState, finishSetup } from './setup-steps';