From 0e1e1a722d105b793d4882f96c91e54674c3eac0 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 14:23:52 -0400 Subject: [PATCH 1/7] feat(ext/sw): create_vault handler (Plan C Phase 3) Lifts the full create-vault flow out of setup.ts into the SW: embed image secret, unlock, encrypt empty manifest + default settings, push the vault layout (create-only), register this device + write devices.json, persist config + reference image locally, and transition the SW to the unlocked state (handle becomes SW-owned, enabling recoveryQrAvailable). On failure the handle is locked then freed per Plan A's .free() policy; ownership only transfers to the session on success. Co-Authored-By: Claude Opus 4.8 --- .../service-worker/__tests__/vault.test.ts | 210 ++++++++++++++++++ .../src/service-worker/router/popup-only.ts | 9 +- extension/src/service-worker/vault.ts | 68 +++++- 3 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 extension/src/service-worker/__tests__/vault.test.ts 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..ad8f02f --- /dev/null +++ b/extension/src/service-worker/__tests__/vault.test.ts @@ -0,0 +1,210 @@ +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'; + +// --- Mock git-host module --- +// createGitHost is called internally by handleCreateVault; we need to intercept +// it and return a fake GitHost. uint8ArrayToBase64 must still work — vault.ts +// calls it for the imageBase64 storage value. + +vi.mock('../git-host', async () => { + const actual = await vi.importActual('../git-host'); + + const makeHostMock = (): GitHost & { _calls: Record } => { + const calls: Record = { + writeFileCreateOnly: [], + writeFile: [], + readFile: [], + }; + return { + _calls: calls, + readFile: vi.fn().mockImplementation(async (path: string) => { + // .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(), + }; + }; + + // Expose a handle so tests can grab the last-created fake host. + let lastHost: ReturnType | null = null; + (globalThis as { __lastFakeGitHost?: typeof lastHost }).__lastFakeGitHost = null; + + return { + ...actual, + createGitHost: vi.fn().mockImplementation(() => { + const h = makeHostMock(); + lastHost = h; + (globalThis as { __lastFakeGitHost?: typeof lastHost }).__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])), + 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(); + }); +}); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 0be5e5b..9da12a1 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -628,9 +628,12 @@ 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); + + // attach_vault lands in Task 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. 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..d33ccb6 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 { 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,66 @@ function requireWasm(): any { return wasm; } +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); + const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; + handle = w.unlock(msg.passphrase, referenceImageBytes, salt, paramsJson); + + const encryptedManifest = new Uint8Array(w.manifest_encrypt(handle, '{"schema_version":2,"items":{}}')); + const settingsJson = w.default_vault_settings_json(); + const encryptedSettings = new Uint8Array(w.settings_encrypt(handle, settingsJson)); + + 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(paramsJson), 'init: KDF parameters'); + await git.writeFileCreateOnly('manifest.enc', encryptedManifest, 'init: encrypted manifest'); + await git.writeFileCreateOnly('settings.enc', encryptedSettings, 'init: encrypted settings'); + + const keys = w.register_device(msg.deviceName) as { signing_public_key: string; deploy_public_key: string }; + await devices.addDevice(git, { + name: msg.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: msg.deviceName, + }); + + // SW now owns the unlocked session — keeps the handle alive (enables recoveryQrAvailable). + session.setCurrent(handle); + 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 interface VaultMeta { salt: Uint8Array; paramsJson: string; From 0befd4e62927adf55201010dee40e7b2af75b365 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 15:34:16 -0400 Subject: [PATCH 2/7] feat(ext/sw): attach_vault handler (Plan C Phase 3) Same shape as create_vault: the SW owns the attach flow end to end -- fetch salt/params/manifest from the remote, unlock with the user's reference image, manifest_decrypt to verify the passphrase+image, register this device, persist config + reference image, and transition the SW to the unlocked state. On failure the handle is locked then freed; ownership transfers to the session only on success. Co-Authored-By: Claude Opus 4.8 --- .../service-worker/__tests__/vault.test.ts | 162 ++++++++++++++---- .../src/service-worker/router/popup-only.ts | 5 +- extension/src/service-worker/vault.ts | 52 +++++- 3 files changed, 183 insertions(+), 36 deletions(-) diff --git a/extension/src/service-worker/__tests__/vault.test.ts b/extension/src/service-worker/__tests__/vault.test.ts index ad8f02f..f926151 100644 --- a/extension/src/service-worker/__tests__/vault.test.ts +++ b/extension/src/service-worker/__tests__/vault.test.ts @@ -3,52 +3,58 @@ 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; we need to intercept -// it and return a fake GitHost. uint8ArrayToBase64 must still work — vault.ts -// calls it for the imageBase64 storage value. +// 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'); - const makeHostMock = (): GitHost & { _calls: Record } => { - const calls: Record = { - writeFileCreateOnly: [], - writeFile: [], - readFile: [], - }; - return { - _calls: calls, - readFile: vi.fn().mockImplementation(async (path: string) => { - // .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(), - }; - }; - // Expose a handle so tests can grab the last-created fake host. - let lastHost: ReturnType | null = null; - (globalThis as { __lastFakeGitHost?: typeof lastHost }).__lastFakeGitHost = null; + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = null; return { ...actual, createGitHost: vi.fn().mockImplementation(() => { const h = makeHostMock(); - lastHost = h; - (globalThis as { __lastFakeGitHost?: typeof lastHost }).__lastFakeGitHost = h; + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; return h; }), }; @@ -90,6 +96,7 @@ function makeWasm(overrides: Record = {}) { 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' })), @@ -208,3 +215,90 @@ describe('handleCreateVault', () => { 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 9da12a1..8df97e9 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -631,7 +631,10 @@ export async function handle( case 'create_vault': return vault.handleCreateVault(msg, state); - // attach_vault lands in Task 3.3; get_vault_status in Phase 6 (Dev-C). + 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: diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index d33ccb6..29df581 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -7,7 +7,7 @@ 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 { CreateVaultResponse } from '../shared/messages'; +import type { AttachVaultResponse, CreateVaultResponse } from '../shared/messages'; import type { PopupState } from './router/popup-only'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -81,6 +81,56 @@ export async function handleCreateVault( } } +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); + + const meta = await fetchVaultMeta(git); + const encryptedManifest = await git.readFile('manifest.enc'); + + handle = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson); + // manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure. + const manifest = w.manifest_decrypt(handle, encryptedManifest) as Manifest; + + const keys = w.register_device(msg.deviceName) as { signing_public_key: string; deploy_public_key: string }; + await devices.addDevice(git, { + name: msg.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: msg.deviceName, + }); + + // SW now owns the unlocked session — transfer ownership to the session. + session.setCurrent(handle); + 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; From 9fd5e33cd4e88c8ed781023e42d018137205b6a9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 17:08:23 -0400 Subject: [PATCH 3/7] refactor(ext/setup): SW migration + step registry + clearWizardState (Plan C Phase 3) setup.ts is now UI-only: deletes all direct WASM orchestration (loadWasm, the wasm binding, verifiedHandle, the SessionHandle import). Vault creation and attach go through sendMessage({type:'create_vault'|'attach_vault'}) fired from the device step (where the device name is known); the SW owns the entire crypto+remote+device flow. The six renderStepN/attachStepN pairs collapse into the SetupStep registry (mode/host/connection/vault/device/done). The done step drops the now-redundant register-device + copy-JSON paths, keeping reference download + recovery QR (off the SW session) + open-vault. clearWizardState zero-fills sensitive Uint8Array fields on beforeunload and on goto('mode'). Co-Authored-By: Claude Opus 4.8 --- extension/src/setup/setup.ts | 1365 ++++++++++++---------------------- 1 file changed, 492 insertions(+), 873 deletions(-) diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index ef92f14..84c7cb4 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -1,46 +1,46 @@ -/// Vault initialization wizard — 5-step flow for creating new relicario vaults. +/// Vault initialization wizard — UI-only step registry. /// -/// Step 1: Choose host type (Gitea / GitHub) -/// Step 2: Configure connection (URL, repo, token) + test -/// Step 3: Create vault (carrier image, passphrase, generate secrets, push files) -/// Step 4: Name this device (generates ed25519 keypair, registers with vault) -/// Step 5: Finish (download reference image, push config to extension or copy JSON) +/// mode → host → connection → vault → device → done +/// +/// All crypto/remote/device orchestration lives in the service worker +/// (create_vault / attach_vault). This module is presentation + validation +/// only: it collects inputs, then fires a single SW message from the device +/// step (where the device name is known) and renders the result. -import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host'; -import { addDevice } from '../service-worker/devices'; +import { createGitHost } from '../service-worker/git-host'; import { probeVault } from './probe'; -import { - escapeHtml, - ratePassphrase, - scheduleRate, - STRENGTH_LABELS, - entropyText, -} from './setup-helpers'; +import { escapeHtml, ratePassphrase, scheduleRate, STRENGTH_LABELS, entropyText } from './setup-helpers'; import { GLYPH_NEXT } from '../shared/glyphs'; import type { VaultConfig } from '../shared/types'; -import type { SessionHandle } from 'relicario-wasm'; +import type { Request, Response } from '../shared/messages'; -// --- WASM module (loaded dynamically) --- +// --- SW messaging (setup does not register a StateHost) --- -type WasmModule = typeof import('relicario-wasm'); -let wasm: WasmModule | null = null; +function swSend(msg: Request): Promise { + return new Promise((resolve) => chrome.runtime.sendMessage(msg, (r: Response) => resolve(r))); +} -async function loadWasm(): Promise { - if (wasm) return wasm; - const mod = await import( - // @ts-ignore TS2307 — resolved at runtime, not by TS/webpack - /* webpackIgnore: true */ '../relicario_wasm.js' - ) as WasmModule & { default: (input?: string | URL) => Promise }; - await mod.default('../relicario_wasm_bg.wasm'); - wasm = mod; - return mod; +// --- Step registry types --- + +type StepId = 'mode' | 'host' | 'connection' | 'vault' | 'device' | 'done'; + +interface StepContext { + state: WizardState; + rerender: () => void; + goto: (id: StepId) => void; +} + +interface SetupStep { + id: StepId; + render: (ctx: StepContext) => string; + attach: (root: HTMLElement, ctx: StepContext) => () => void; } // --- State --- interface WizardState { - step: number; // now 0..5; was 1..5 - mode: 'new' | 'attach' | null; // null until Step 0 picks + stepId: StepId; + mode: 'new' | 'attach' | null; hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; @@ -53,44 +53,22 @@ interface WizardState { passphraseConfirm: string; // zxcvbn meter state — -1 means "not yet scored" (empty passphrase). passphraseScore: number; - passphraseGuessesLog10: number; // -1 before first rating + passphraseGuessesLog10: number; passphraseVisible: boolean; confirmVisible: boolean; referenceImageBytes: Uint8Array | null; - verifiedHandle: SessionHandle | null; creating: boolean; attaching: boolean; error: string | null; - extensionDetected: boolean; - configPushed: boolean; deviceName: string; } const state: WizardState = { - step: 0, - mode: null, - hostType: 'gitea', - hostUrl: '', - repoPath: '', - apiToken: '', - connectionTested: false, - vaultProbe: null, - carrierImageBytes: null, - referenceImageBytesAttach: null, - passphrase: '', - passphraseConfirm: '', - passphraseScore: -1, - passphraseGuessesLog10: -1, - passphraseVisible: false, - confirmVisible: false, - referenceImageBytes: null, - verifiedHandle: null, - creating: false, - attaching: false, - error: null, - extensionDetected: false, - configPushed: false, - deviceName: '', + stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '', + connectionTested: false, vaultProbe: null, carrierImageBytes: null, referenceImageBytesAttach: null, + passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1, + passphraseVisible: false, confirmVisible: false, referenceImageBytes: null, + creating: false, attaching: false, error: null, deviceName: '', }; // --- Progress track --- @@ -106,9 +84,8 @@ function renderProgressTrack(current: number): string { // --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) --- -/// Update just the meter DOM without a full re-render (so the input keeps -/// focus and the user's cursor position is preserved). Also updates the -/// char counter and confirm-match indicator live. +/// Update just the meter DOM without a full re-render (so the input keeps focus and +/// the cursor position is preserved). Also updates the char counter and match indicator. function updateStrengthUi(): void { const bar = document.getElementById('strength-bar'); const label = document.getElementById('strength-label'); @@ -116,327 +93,117 @@ function updateStrengthUi(): void { const counter = document.getElementById('passphrase-counter'); const matchInd = document.getElementById('match-indicator'); const create = document.getElementById('create-btn') as HTMLButtonElement | null; - const score = state.passphraseScore; - const guessesLog10 = state.passphraseGuessesLog10; if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`; - if (label) { - if (score < 0) { - label.className = 'strength-label'; - label.innerHTML = ' '; - } else { + if (score < 0) { label.className = 'strength-label'; label.innerHTML = ' '; } + else { const meta = STRENGTH_LABELS[score] ?? STRENGTH_LABELS[0]; label.className = `strength-label ${meta.cls}`; label.textContent = meta.text; } } - if (entropy) { - const txt = entropyText(guessesLog10); + const txt = entropyText(state.passphraseGuessesLog10); entropy.textContent = txt; entropy.style.visibility = txt ? 'visible' : 'hidden'; } - if (counter) { const n = state.passphrase.length; counter.textContent = n === 0 ? '' : `${n} character${n === 1 ? '' : 's'}`; } - if (matchInd) { - const p = state.passphrase; - const c = state.passphraseConfirm; - if (!p || !c) { - matchInd.className = 'match-indicator'; - matchInd.textContent = ''; - } else if (p === c) { - matchInd.className = 'match-indicator ok'; - matchInd.textContent = '✓'; - } else { - matchInd.className = 'match-indicator bad'; - matchInd.textContent = '✗'; - } + const p = state.passphrase, c = state.passphraseConfirm; + if (!p || !c) { matchInd.className = 'match-indicator'; matchInd.textContent = ''; } + else if (p === c) { matchInd.className = 'match-indicator ok'; matchInd.textContent = '✓'; } + else { matchInd.className = 'match-indicator bad'; matchInd.textContent = '✗'; } } - const matchOk = !state.passphraseConfirm || state.passphrase === state.passphraseConfirm; if (create) { const disabled = state.creating || score < 3 || !state.passphraseConfirm || !matchOk; create.disabled = disabled; create.title = disabled - ? (score < 3 - ? 'passphrase must score "good" or better' + ? (score < 3 ? 'passphrase must score "good" or better' : !state.passphraseConfirm ? 'confirm your passphrase' - : !matchOk ? 'passphrases do not match' - : '') + : !matchOk ? 'passphrases do not match' : '') : ''; } } -// --- Render --- - -function render(): void { - const app = document.getElementById('app'); - if (!app) return; - - const progressHtml = renderProgressTrack(state.step); - - let stepHtml = ''; - switch (state.step) { - case 0: stepHtml = renderStep0(); break; - case 1: stepHtml = renderStep1(); break; - case 2: stepHtml = renderStep2(); break; - case 3: stepHtml = state.mode === 'attach' ? renderStep3Attach() : renderStep3New(); break; - case 4: stepHtml = renderStep4(); break; - case 5: stepHtml = renderStep5(); break; - } - - app.innerHTML = ` -
-
- -
Relicario vault setup
- ${progressHtml} - ${state.error ? `
${escapeHtml(state.error)}
` : ''} - ${stepHtml} -
-
- `; - - switch (state.step) { - case 0: attachStep0(); break; - case 1: attachStep1(); break; - case 2: attachStep2(); break; - case 3: state.mode === 'attach' ? attachStep3Attach() : attachStep3New(); break; - case 4: attachStep4(); break; - case 5: attachStep5(); break; - } +function vaultConfig(): VaultConfig { + return { + hostType: state.hostType, + hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl, + repoPath: state.repoPath, + apiToken: state.apiToken, + }; } -// --- Step 0: Mode picker --- +// --- mode --- -function renderStep0(): string { - const isNew = state.mode === 'new'; - const isAttach = state.mode === 'attach'; - return ` +const modeStep: SetupStep = { + id: 'mode', + render() { + const isNew = state.mode === 'new'; + const isAttach = state.mode === 'attach'; + return `

set up Relicario

-

- How are you using Relicario on this device? -

+

How are you using Relicario on this device?

-
- `; -} - -function attachStep0(): void { - document.querySelectorAll('.mode-card').forEach((btn) => { - btn.addEventListener('click', () => { - state.mode = (btn as HTMLElement).dataset.mode as 'new' | 'attach'; - render(); + `; + }, + attach(_root, ctx) { + document.querySelectorAll('.mode-card').forEach((btn) => { + btn.addEventListener('click', () => { + state.mode = (btn as HTMLElement).dataset.mode as 'new' | 'attach'; + ctx.rerender(); + }); }); - }); - document.getElementById('next-btn')?.addEventListener('click', () => { - if (!state.mode) return; - state.step = 1; - state.error = null; - render(); - }); -} + document.getElementById('next-btn')?.addEventListener('click', () => { + if (state.mode) ctx.goto('host'); + }); + return () => {}; + }, +}; -// --- Step 3 (attach variant) --- +// --- host --- -function renderStep3Attach(): string { - const p = state.passphrase; - const pType = state.passphraseVisible ? 'text' : 'password'; - const pToggle = state.passphraseVisible ? 'hide' : 'show'; - const hasImage = !!state.referenceImageBytesAttach; - const gateDisabled = state.attaching || !p || !hasImage; +const GITEA_INSTRUCTIONS = ` +
    +
  1. Create a new private repository on your Gitea instance (e.g. vault)
  2. +
  3. Go to Settings → Applications
  4. +
  5. Generate a new token with repo (read/write) permission
  6. +
  7. Copy the token — you will need it in the next step
  8. +
`; - return ` -
-

attach this device

-

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

+const GITHUB_INSTRUCTIONS = ` +
    +
  1. Create a new private repository on GitHub (e.g. vault)
  2. +
  3. Go to Settings → Developer settings → Personal access tokens → Fine-grained tokens
  4. +
  5. Generate a new token scoped to the vault repo with Contents read/write permission
  6. +
  7. Copy the token — you will need it in the next step
  8. +
`; -
- -
- - ${hasImage - ? '

reference image loaded

' - : '

click to select your reference JPEG

'} -
-

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

-
- -
- -
- - -
-
- -
- - -
-
- `; -} - -function attachStep3Attach(): void { - const refDrop = document.getElementById('ref-drop')!; - const refInput = document.getElementById('ref-input') as HTMLInputElement; - - refDrop.addEventListener('click', () => refInput.click()); - refInput.addEventListener('change', () => { - const file = refInput.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer); - state.error = null; - render(); - }; - reader.readAsArrayBuffer(file); - }); - - const passInput = document.getElementById('passphrase') as HTMLInputElement | null; - passInput?.addEventListener('input', (e) => { - state.passphrase = (e.target as HTMLInputElement).value; - const btn = document.getElementById('attach-btn') as HTMLButtonElement | null; - if (btn) btn.disabled = state.attaching || !state.passphrase || !state.referenceImageBytesAttach; - }); - - document.getElementById('eye-btn')?.addEventListener('click', () => { - state.passphraseVisible = !state.passphraseVisible; - if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password'; - const btn = document.getElementById('eye-btn'); - if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show'; - passInput?.focus(); - }); - - document.getElementById('back-btn')?.addEventListener('click', () => { - state.step = 2; - state.error = null; - render(); - }); - - document.getElementById('attach-btn')?.addEventListener('click', async () => { - if (!state.referenceImageBytesAttach || !state.passphrase) return; - state.attaching = true; - state.error = null; - render(); - - const log = (stage: string, detail?: unknown) => console.log(`[relicario setup] ${stage}`, detail ?? ''); - let stage = 'init'; - let handle: SessionHandle | null = null; - try { - stage = 'load wasm'; - log(stage); - const w = await loadWasm(); - - stage = 'fetch vault metadata'; - log(stage); - const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl; - const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); - const [salt, paramsBytes, encryptedManifest] = await Promise.all([ - host.readFile('.relicario/salt'), - host.readFile('.relicario/params.json'), - host.readFile('manifest.enc'), - ]); - const paramsJson = new TextDecoder().decode(paramsBytes); - - stage = 'derive session handle'; - log(stage); - handle = w.unlock(state.passphrase, state.referenceImageBytesAttach, salt, paramsJson); - - stage = 'decrypt manifest'; - log(stage); - // Throws if AEAD verification fails — wrong passphrase or wrong image. - w.manifest_decrypt(handle, encryptedManifest); - - log('attach verified — advancing'); - state.verifiedHandle = handle; - state.attaching = false; - state.step = 4; - state.error = null; - render(); - } catch (err: unknown) { - console.error(`[relicario setup] attach FAILED during "${stage}":`, err); - state.attaching = false; - // Lock any partial handle to avoid leaking key material. - if (handle !== null) { - try { (await loadWasm()).lock(handle); } catch { /* best effort */ } - } - state.verifiedHandle = null; - const detail = err instanceof Error ? err.message : String(err); - // Stage-aware copy: if we got past 'fetch', this is a credential failure. - if (stage === 'derive session handle' || stage === 'decrypt manifest') { - state.error = 'Could not decrypt vault — wrong passphrase or reference image.'; - } else { - state.error = `Attach failed at "${stage}": ${detail}`; - } - render(); - } - }); -} - -// --- Step 1: Choose Host --- - -function renderStep1(): string { - const giteaInstructions = ` -
-
    -
  1. Create a new private repository on your Gitea instance (e.g. vault)
  2. -
  3. Go to Settings → Applications
  4. -
  5. Generate a new token with repo (read/write) permission
  6. -
  7. Copy the token — you will need it in the next step
  8. -
-
- `; - - const githubInstructions = ` -
-
    -
  1. Create a new private repository on GitHub (e.g. vault)
  2. -
  3. Go to Settings → Developer settings → Personal access tokens → Fine-grained tokens
  4. -
  5. Generate a new token scoped to the vault repo with Contents read/write permission
  6. -
  7. Copy the token — you will need it in the next step
  8. -
-
- `; - - return ` +const hostStep: SetupStep = { + id: 'host', + render() { + return `

choose host

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

configure connection

@@ -552,123 +302,127 @@ function renderStep2(): string {
-
- `; -} - -function attachStep2(): void { - document.getElementById('test-btn')?.addEventListener('click', async () => { - state.connectionTested = false; - state.vaultProbe = null; - const hostUrl = state.hostType === 'github' - ? 'https://api.github.com' - : (document.getElementById('host-url') as HTMLInputElement).value.trim(); - const repoPath = (document.getElementById('repo-path') as HTMLInputElement).value.trim(); - const apiToken = (document.getElementById('api-token') as HTMLInputElement).value.trim(); - - if (!repoPath || !apiToken) { - state.error = 'Repository path and API token are required'; - render(); - return; - } - if (state.hostType === 'gitea' && !hostUrl) { - state.error = 'Host URL is required for Gitea'; - render(); - return; - } - - state.hostUrl = hostUrl; - state.repoPath = repoPath; - state.apiToken = apiToken; - - try { - const host = createGitHost(state.hostType, hostUrl, repoPath, apiToken); - await host.listDir(''); - state.connectionTested = true; - state.error = null; - try { - state.vaultProbe = await probeVault(host); - } catch (probeErr) { - state.vaultProbe = null; - state.error = `Could not check repo state: ${probeErr instanceof Error ? probeErr.message : String(probeErr)}`; - } - } catch (err: unknown) { + `; + }, + attach(_root, ctx) { + document.getElementById('test-btn')?.addEventListener('click', async () => { state.connectionTested = false; state.vaultProbe = null; - state.error = `Connection failed: ${err instanceof Error ? err.message : String(err)}`; - } - render(); - }); + const hostUrl = state.hostType === 'github' + ? 'https://api.github.com' + : (document.getElementById('host-url') as HTMLInputElement).value.trim(); + const repoPath = (document.getElementById('repo-path') as HTMLInputElement).value.trim(); + const apiToken = (document.getElementById('api-token') as HTMLInputElement).value.trim(); - document.getElementById('back-btn')?.addEventListener('click', () => { - state.step = 1; - state.error = null; - render(); - }); + if (!repoPath || !apiToken) { + state.error = 'Repository path and API token are required'; + ctx.rerender(); + return; + } + if (state.hostType === 'gitea' && !hostUrl) { + state.error = 'Host URL is required for Gitea'; + ctx.rerender(); + return; + } + state.hostUrl = hostUrl; + state.repoPath = repoPath; + state.apiToken = apiToken; + try { + const host = createGitHost(state.hostType, hostUrl, repoPath, apiToken); + await host.listDir(''); + state.connectionTested = true; + state.error = null; + try { + state.vaultProbe = await probeVault(host); + } catch (probeErr) { + state.vaultProbe = null; + state.error = `Could not check repo state: ${probeErr instanceof Error ? probeErr.message : String(probeErr)}`; + } + } catch (err: unknown) { + state.connectionTested = false; + state.vaultProbe = null; + state.error = `Connection failed: ${err instanceof Error ? err.message : String(err)}`; + } + ctx.rerender(); + }); + document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('host')); + document.getElementById('next-btn')?.addEventListener('click', () => { + if (state.connectionTested) ctx.goto('vault'); + }); + document.getElementById('switch-mode-btn')?.addEventListener('click', (e) => { + state.mode = (e.currentTarget as HTMLElement).dataset.target as 'new' | 'attach'; + state.error = null; + ctx.rerender(); + }); + return () => {}; + }, +}; - document.getElementById('next-btn')?.addEventListener('click', () => { - if (!state.connectionTested) return; - state.step = 3; - state.error = null; - render(); - }); +// --- vault --- - document.getElementById('switch-mode-btn')?.addEventListener('click', (e) => { - const target = (e.currentTarget as HTMLElement).dataset.target as 'new' | 'attach'; - state.mode = target; - state.error = null; - render(); - }); +function renderVaultAttach(): string { + const p = state.passphrase; + const pType = state.passphraseVisible ? 'text' : 'password'; + const pToggle = state.passphraseVisible ? 'hide' : 'show'; + const hasImage = !!state.referenceImageBytesAttach; + const gateDisabled = !p || !hasImage; + return ` +
+

attach this device

+

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

+
+ +
+ + ${hasImage ? '

reference image loaded

' : '

click to select your reference JPEG

'} +
+

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

+
+
+ +
+ + +
+
+
+ + +
+
`; } -// --- Step 3 (new-vault variant): Create Vault --- - -function renderStep3New(): string { +function renderVaultNew(): string { const score = state.passphraseScore; - const guessesLog10 = state.passphraseGuessesLog10; const hasScore = score >= 0; const meterClass = hasScore ? `s${score}` : ''; const labelMeta = hasScore ? STRENGTH_LABELS[score] : null; const labelClass = labelMeta?.cls ?? ''; const labelText = labelMeta?.text ?? ' '; - const entropy = entropyText(guessesLog10); - - const p = state.passphrase; - const c = state.passphraseConfirm; + const entropy = entropyText(state.passphraseGuessesLog10); + const p = state.passphrase, c = state.passphraseConfirm; const matchState = !p || !c ? '' : p === c ? 'ok' : 'bad'; const matchGlyph = matchState === 'ok' ? '✓' : matchState === 'bad' ? '✗' : ''; - const pType = state.passphraseVisible ? 'text' : 'password'; const cType = state.confirmVisible ? 'text' : 'password'; const pToggle = state.passphraseVisible ? 'hide' : 'show'; const cToggle = state.confirmVisible ? 'hide' : 'show'; - const matchOk = !c || p === c; const gateDisabled = state.creating || score < 3 || !c || !matchOk; - const nChars = p.length; const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`; - return `

create vault

-
- ${state.carrierImageBytes - ? '

image loaded

' - : '

click to select a JPEG photo

'} + ${state.carrierImageBytes ? '

image loaded

' : '

click to select a JPEG photo

'}

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

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

${labelText}

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

${escapeHtml(entropy || ' ')}

-
@@ -697,23 +446,72 @@ function renderStep3New(): string {
-
- +
-
- `; + `; } -function attachStep3New(): void { +const vaultStep: SetupStep = { + id: 'vault', + render(ctx) { + return ctx.state.mode === 'attach' ? renderVaultAttach() : renderVaultNew(); + }, + attach(_root, ctx) { + return state.mode === 'attach' ? attachVaultAttach(ctx) : attachVaultNew(ctx); + }, +}; + +function attachVaultAttach(ctx: StepContext): () => void { + const refDrop = document.getElementById('ref-drop')!; + const refInput = document.getElementById('ref-input') as HTMLInputElement; + refDrop.addEventListener('click', () => refInput.click()); + refInput.addEventListener('change', () => { + const file = refInput.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer); + state.error = null; + ctx.rerender(); + }; + reader.readAsArrayBuffer(file); + }); + const passInput = document.getElementById('passphrase') as HTMLInputElement | null; + passInput?.addEventListener('input', (e) => { + state.passphrase = (e.target as HTMLInputElement).value; + const btn = document.getElementById('attach-btn') as HTMLButtonElement | null; + if (btn) btn.disabled = !state.passphrase || !state.referenceImageBytesAttach; + }); + document.getElementById('eye-btn')?.addEventListener('click', () => { + state.passphraseVisible = !state.passphraseVisible; + if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password'; + const btn = document.getElementById('eye-btn'); + if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show'; + passInput?.focus(); + }); + document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('connection')); + document.getElementById('attach-btn')?.addEventListener('click', () => { + if (!state.referenceImageBytesAttach) { + state.error = 'Please select your reference JPEG image'; + ctx.rerender(); + return; + } + if (!state.passphrase) { + state.error = 'Passphrase is required'; + ctx.rerender(); + return; + } + ctx.goto('device'); + }); + return () => {}; +} + +function attachVaultNew(ctx: StepContext): () => void { const fileDrop = document.getElementById('file-drop')!; const fileInput = document.getElementById('file-input') as HTMLInputElement; - fileDrop.addEventListener('click', () => fileInput.click()); - fileInput.addEventListener('change', () => { const file = fileInput.files?.[0]; if (!file) return; @@ -721,32 +519,27 @@ function attachStep3New(): void { reader.onload = () => { state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer); state.error = null; - render(); + ctx.rerender(); }; reader.readAsArrayBuffer(file); }); - // Track passphrase changes inline (no full re-render) so the input keeps focus. // zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate. const passInput = document.getElementById('passphrase') as HTMLInputElement | null; passInput?.addEventListener('input', (e) => { state.passphrase = (e.target as HTMLInputElement).value; - // Update char counter + match indicator + button gate immediately on every keystroke. updateStrengthUi(); - // Score updates on the 150ms debounce to avoid SW hammering. scheduleRate(state.passphrase, (s) => { state.passphraseScore = s.score; state.passphraseGuessesLog10 = s.guessesLog10; updateStrengthUi(); }); }); - const confirmInput = document.getElementById('passphrase-confirm') as HTMLInputElement | null; confirmInput?.addEventListener('input', (e) => { state.passphraseConfirm = (e.target as HTMLInputElement).value; updateStrengthUi(); }); - // Eye toggles — flip the input type and label without a full re-render so // focus + cursor position survive the click. document.getElementById('eye-btn')?.addEventListener('click', () => { @@ -756,7 +549,6 @@ function attachStep3New(): void { if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show'; passInput?.focus(); }); - document.getElementById('confirm-eye-btn')?.addEventListener('click', () => { state.confirmVisible = !state.confirmVisible; if (confirmInput) confirmInput.type = state.confirmVisible ? 'text' : 'password'; @@ -764,436 +556,260 @@ function attachStep3New(): void { if (btn) btn.textContent = state.confirmVisible ? 'hide' : 'show'; confirmInput?.focus(); }); - - document.getElementById('back-btn')?.addEventListener('click', () => { - state.step = 2; - state.error = null; - render(); - }); - + document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('connection')); document.getElementById('create-btn')?.addEventListener('click', async () => { - // Read current values from DOM state.passphrase = (document.getElementById('passphrase') as HTMLInputElement).value; state.passphraseConfirm = (document.getElementById('passphrase-confirm') as HTMLInputElement).value; - if (!state.carrierImageBytes) { state.error = 'Please select a carrier JPEG image'; - render(); + ctx.rerender(); return; } if (!state.passphrase) { state.error = 'Passphrase is required'; - render(); + ctx.rerender(); return; } - // Re-rate synchronously in case the button was clicked before the - // debounced rater fired. Defence in depth — the button is already - // disabled in the UI when score < 3 (audit H3). + // Re-rate synchronously in case the button was clicked before the debounced + // rater fired. Defence in depth — the button is already disabled when score < 3. const strength = await ratePassphrase(state.passphrase); state.passphraseScore = strength.score; state.passphraseGuessesLog10 = strength.guessesLog10; if (state.passphraseScore < 3) { state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).'; - render(); + ctx.rerender(); return; } if (state.passphrase !== state.passphraseConfirm) { state.error = 'Passphrases do not match'; - render(); + ctx.rerender(); return; } - - state.creating = true; - state.error = null; - render(); - - // Structured logging so silent failures become visible in DevTools. - // eslint-disable-next-line no-console - const log = (stage: string, detail?: unknown) => console.log(`[relicario setup] ${stage}`, detail ?? ''); - - let stage = 'init'; - try { - stage = 'load wasm'; - log(stage); - const w = await loadWasm(); - - stage = 'generate image secret'; - log(stage); - const imageSecret = new Uint8Array(32); - crypto.getRandomValues(imageSecret); - - stage = 'embed image secret'; - log(stage, { carrierBytes: state.carrierImageBytes.byteLength }); - state.referenceImageBytes = new Uint8Array( - w.embed_image_secret(state.carrierImageBytes, imageSecret), - ); - log('embedded', { referenceBytes: state.referenceImageBytes.byteLength }); - - stage = 'generate salt'; - const salt = new Uint8Array(32); - crypto.getRandomValues(salt); - const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; - - stage = 'derive session handle'; - log(stage); - // unlock() takes JPEG bytes with embedded secret (it extracts internally), - // not the raw 32-byte secret. - const handle = w.unlock(state.passphrase, state.referenceImageBytes, salt, paramsJson); - log('handle acquired'); - - stage = 'encrypt empty manifest'; - log(stage); - const manifestJson = '{"schema_version":2,"items":{}}'; - const encryptedManifest = w.manifest_encrypt(handle, manifestJson); - log('manifest encrypted', { bytes: encryptedManifest.length }); - - stage = 'encrypt default settings'; - log(stage); - const settingsJson = w.default_vault_settings_json(); - const encryptedSettings = w.settings_encrypt(handle, settingsJson); - log('settings encrypted', { bytes: encryptedSettings.length }); - - stage = 'push vault files'; - log(stage); - const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl; - const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); - - log('write .relicario/salt'); - await host.writeFileCreateOnly('.relicario/salt', salt, 'init: vault salt'); - - log('write .relicario/params.json'); - const paramsBytes = new TextEncoder().encode(paramsJson); - await host.writeFileCreateOnly('.relicario/params.json', paramsBytes, 'init: KDF parameters'); - - log('write manifest.enc'); - await host.writeFileCreateOnly( - 'manifest.enc', - new Uint8Array(encryptedManifest), - 'init: encrypted manifest', - ); - - log('write settings.enc'); - await host.writeFileCreateOnly( - 'settings.enc', - new Uint8Array(encryptedSettings), - 'init: encrypted settings', - ); - - stage = 'release handle'; - w.lock(handle); - - log('vault created — advancing to step 4 (device name)'); - state.creating = false; - state.step = 4; // device name step - state.error = null; - render(); - } catch (err: unknown) { - // eslint-disable-next-line no-console - console.error(`[relicario setup] vault creation FAILED during "${stage}":`, err); - state.creating = false; - const detail = err instanceof Error ? err.message : String(err); - if (/already exists/.test(detail)) { - const path = detail.replace(/^.*?writeFileCreateOnly: /, '').replace(/ already exists$/, ''); - state.error = `A file at ${path} already exists on the remote — refusing to overwrite. Re-run setup; the wizard will offer to attach to the existing vault.`; - } else { - state.error = `Vault creation failed at "${stage}": ${detail}`; - } - render(); - } + ctx.goto('device'); }); + return () => {}; } -// --- Step 4: Device Name --- +// --- device --- -function renderStep4(): string { - const platform = navigator.platform.toLowerCase(); - const isChrome = /chrome/i.test(navigator.userAgent) && !/edg/i.test(navigator.userAgent); - const isFirefox = /firefox/i.test(navigator.userAgent); - const browser = isFirefox ? 'Firefox' : isChrome ? 'Chrome' : 'Browser'; - const os = platform.includes('mac') ? 'macOS' : platform.includes('win') ? 'Windows' : 'Linux'; - const defaultName = state.deviceName || `${browser} on ${os}`; - - return ` +const deviceStep: SetupStep = { + id: 'device', + render() { + const busy = state.creating || state.attaching; + const platform = navigator.platform.toLowerCase(); + const isChrome = /chrome/i.test(navigator.userAgent) && !/edg/i.test(navigator.userAgent); + const isFirefox = /firefox/i.test(navigator.userAgent); + const browser = isFirefox ? 'Firefox' : isChrome ? 'Chrome' : 'Browser'; + const os = platform.includes('mac') ? 'macOS' : platform.includes('win') ? 'Windows' : 'Linux'; + const defaultName = state.deviceName || `${browser} on ${os}`; + const busyLabel = state.attaching ? 'attaching…' : 'creating…'; + return `

name this device

-

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

+

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

- +
- - + +
-
- `; -} - -function attachStep4(): void { - document.getElementById('back-btn')?.addEventListener('click', () => { - state.step = 3; - state.error = null; - render(); - }); - - document.getElementById('next-btn')?.addEventListener('click', async () => { - const nameInput = document.getElementById('device-name') as HTMLInputElement; - const name = nameInput.value.trim(); - if (!name) { - state.error = 'Device name is required'; - render(); - return; - } - - state.deviceName = name; - state.step = 5; - state.error = null; - detectExtension(); - render(); - }); -} - -// --- Step 5: Finish --- - -function detectExtension(): void { - try { - if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) { - // Try to ping the extension - chrome.runtime.sendMessage({ type: 'is_unlocked' }, (response) => { - if (chrome.runtime.lastError) { - state.extensionDetected = false; - } else { - state.extensionDetected = true; - } - render(); - }); - } - } catch { - state.extensionDetected = false; - } -} - -function renderStep5(): string { - const config: VaultConfig = { - hostType: state.hostType, - hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl, - repoPath: state.repoPath, - apiToken: state.apiToken, - }; - const configJson = JSON.stringify(config, null, 2); - const isAttach = state.mode === 'attach'; - - const qrBannerHtml = (!isAttach && state.verifiedHandle !== null) ? ` -
-
- - Generate a recovery QR before you go -
-

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

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

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

-

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

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

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

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

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

-
${escapeHtml(configJson)}
- -
- `} -
- `; -} - -function attachStep5(): void { - document.getElementById('setup-gen-qr')?.addEventListener('click', async () => { - if (!state.verifiedHandle) return; - const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null; - if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; } - try { - const { sendMessage } = await import('../shared/state'); - const resp = await sendMessage({ - type: 'generate_recovery_qr', - sessionHandle: state.verifiedHandle.value, - passphrase: state.passphrase, - } as any) as any; - if (!resp.ok || !resp.data) throw new Error(resp.error ?? 'unknown error'); - const svg = (resp.data as { svg: string }).svg; - await new Promise((resolve) => { - chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve); - }); - const banner = document.getElementById('recovery-qr-banner'); - if (banner) { - banner.innerHTML = ` -
${svg}
-

- ◉ Recovery QR generated — save or print this now. -

-
- -
- `; - document.getElementById('setup-qr-done')?.addEventListener('click', () => { - banner.style.display = 'none'; - }); - } - } catch (err) { - if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; } - alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`); - } - }); - - document.getElementById('setup-skip-qr')?.addEventListener('click', () => { - const banner = document.getElementById('recovery-qr-banner'); - if (banner) banner.style.display = 'none'; - }); - - document.getElementById('download-ref-btn')?.addEventListener('click', () => { - if (!state.referenceImageBytes) return; - const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'reference.jpg'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }); - - document.getElementById('push-config-btn')?.addEventListener('click', async () => { - state.error = null; - render(); - - const config: VaultConfig = { - hostType: state.hostType, - hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl, - repoPath: state.repoPath, - apiToken: state.apiToken, - }; - - try { - const w = await loadWasm(); - // register_device keeps private keys internal — only public keys returned - const keypair = w.register_device(state.deviceName); - - // 1) Save device name locally (private keys stay in WASM memory). - await chrome.storage.local.set({ - device_name: state.deviceName, - }); - - // 2) Save vault config + reference image to extension storage. - const imageBytes = state.referenceImageBytes ?? state.referenceImageBytesAttach; - const imageBase64 = imageBytes ? uint8ArrayToBase64(imageBytes) : ''; - const saveOk = await new Promise((resolve) => { - chrome.runtime.sendMessage( - { type: 'save_setup', config, imageBase64 }, - (response: { ok: boolean; error?: string }) => { - if (!response?.ok) { - state.error = response?.error ?? 'Failed to save config to extension'; - resolve(false); return; - } - resolve(true); - }, - ); - }); - if (!saveOk) { - if (state.verifiedHandle !== null) { - try { w.lock(state.verifiedHandle); } catch { /* best effort */ } - state.verifiedHandle = null; - } - render(); + `; + }, + attach(_root, ctx) { + document.getElementById('back-btn')?.addEventListener('click', () => { + if (!state.creating && !state.attaching) ctx.goto('vault'); + }); + document.getElementById('next-btn')?.addEventListener('click', async () => { + if (state.creating || state.attaching) return; + const name = (document.getElementById('device-name') as HTMLInputElement).value.trim(); + if (!name) { + state.error = 'Device name is required'; + ctx.rerender(); return; } - - // 3) Register device on the remote (read-modify-write devices.json). - const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl; - const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); - await addDevice(host, { - name: state.deviceName, - public_key: keypair.signing_public_key, - added_at: Math.floor(Date.now() / 1000), - }); - - // 4) Release any attach-mode WASM handle. - if (state.verifiedHandle !== null) { - try { w.lock(state.verifiedHandle); } catch { /* best effort */ } - state.verifiedHandle = null; + state.deviceName = name; + state.error = null; + if (state.mode === 'attach') { + state.attaching = true; + ctx.rerender(); + const resp = await swSend({ + type: 'attach_vault', + config: vaultConfig(), + passphrase: state.passphrase, + referenceImageBytes: state.referenceImageBytesAttach!.buffer as ArrayBuffer, + deviceName: state.deviceName, + }); + state.attaching = false; + if (resp.ok) ctx.goto('done'); + else { state.error = resp.error; ctx.rerender(); } + } else { + state.creating = true; + ctx.rerender(); + const resp = await swSend({ + type: 'create_vault', + config: vaultConfig(), + passphrase: state.passphrase, + carrierImageBytes: state.carrierImageBytes!.buffer as ArrayBuffer, + deviceName: state.deviceName, + }); + state.creating = false; + if (resp.ok) { + const data = resp.data as { referenceImageBytes: Uint8Array }; + state.referenceImageBytes = new Uint8Array(data.referenceImageBytes); + ctx.goto('done'); + } else { state.error = resp.error; ctx.rerender(); } } + }); + return () => {}; + }, +}; - state.configPushed = true; - render(); - void finishSetup(); - } catch (err: unknown) { - console.error('[relicario setup] register device failed:', err); - state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`; - if (state.verifiedHandle !== null) { - try { (await loadWasm()).lock(state.verifiedHandle); } catch { /* best effort */ } - state.verifiedHandle = null; +// --- done --- + +const doneStep: SetupStep = { + id: 'done', + render() { + const isAttach = state.mode === 'attach'; + const qrBannerHtml = isAttach ? '' : ` +
+
+ + Generate a recovery QR before you go +
+

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

+
+ + +
+
`; + const refSection = isAttach ? '' : ` +
+ +

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

+ +
`; + return ` +
+
+

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

+

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

+
+ ${qrBannerHtml} + ${refSection} +
+ +
+
`; + }, + attach(_root, _ctx) { + document.getElementById('setup-gen-qr')?.addEventListener('click', async () => { + const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null; + if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; } + try { + // The SW uses its current session (set by create_vault) — no handle passed. + const resp = await swSend({ type: 'generate_recovery_qr', passphrase: state.passphrase }); + if (!resp.ok || !resp.data) throw new Error(resp.ok ? 'unknown error' : resp.error); + const svg = (resp.data as { svg: string }).svg; + await new Promise((resolve) => { + chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve); + }); + const banner = document.getElementById('recovery-qr-banner'); + if (banner) { + banner.innerHTML = ` +
${svg}
+

◉ Recovery QR generated — save or print this now.

+
`; + document.getElementById('setup-qr-done')?.addEventListener('click', () => { + banner.style.display = 'none'; + }); + } + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; } + alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`); } - render(); - } - }); + }); + document.getElementById('setup-skip-qr')?.addEventListener('click', () => { + const banner = document.getElementById('recovery-qr-banner'); + if (banner) banner.style.display = 'none'; + }); + document.getElementById('download-ref-btn')?.addEventListener('click', () => { + if (!state.referenceImageBytes) return; + const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'reference.jpg'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }); + document.getElementById('open-vault-btn')?.addEventListener('click', () => void finishSetup()); + return () => {}; + }, +}; - document.getElementById('copy-config-btn')?.addEventListener('click', async () => { - const blob = document.getElementById('config-blob'); - if (!blob) return; - try { - await navigator.clipboard.writeText(blob.textContent ?? ''); - const btn = document.getElementById('copy-config-btn')!; - btn.textContent = 'copied!'; - setTimeout(() => { btn.textContent = 'copy to clipboard'; }, 2000); - } catch { - // Fallback: select the text - const range = document.createRange(); - range.selectNodeContents(blob); - const sel = window.getSelection(); - sel?.removeAllRanges(); - sel?.addRange(range); - } - }); +// --- Registry + render loop --- + +const STEPS: ReadonlyArray = [ + modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep, +]; + +let teardown: (() => void) | null = null; + +function rerender(): void { + const app = document.getElementById('app'); + if (!app) return; + teardown?.(); + const ctx: StepContext = { state, rerender, goto }; + const step = STEPS.find((s) => s.id === state.stepId)!; + const idx = STEPS.findIndex((s) => s.id === state.stepId); + app.innerHTML = `
+ +
Relicario vault setup
+ ${renderProgressTrack(idx)} + ${state.error ? `
${escapeHtml(state.error)}
` : ''} + ${step.render(ctx)} +
`; + teardown = step.attach(app, ctx); +} + +function goto(id: StepId): void { + if (id === 'mode') clearWizardState(); + state.stepId = id; + state.error = null; + rerender(); +} + +// --- Sensitive-state cleanup --- + +export function clearWizardState(): void { + // Best-effort wipe — JS strings are GC-only (see spec Risks); zero-fill the Uint8Arrays. + state.carrierImageBytes?.fill(0); + state.referenceImageBytes?.fill(0); + state.referenceImageBytesAttach?.fill(0); + state.mode = null; + state.hostType = 'gitea'; + state.hostUrl = ''; + state.repoPath = ''; + state.apiToken = ''; + state.connectionTested = false; + state.vaultProbe = null; + state.carrierImageBytes = null; + state.referenceImageBytesAttach = null; + state.passphrase = ''; + state.passphraseConfirm = ''; + state.passphraseScore = -1; + state.passphraseGuessesLog10 = -1; + state.passphraseVisible = false; + state.confirmVisible = false; + state.referenceImageBytes = null; + state.creating = false; + state.attaching = false; + state.error = null; + state.deviceName = ''; } // --- Completion handoff --- @@ -1216,5 +832,8 @@ export async function finishSetup(): Promise { // --- Boot --- document.addEventListener('DOMContentLoaded', () => { - render(); + window.addEventListener('beforeunload', clearWizardState); + rerender(); }); + +export { STEPS }; From bceb44f8af1cca45c768fce30a965e6be20b1ab4 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 17:25:22 -0400 Subject: [PATCH 4/7] refactor(ext/setup): split step registry into setup-steps.ts; restore copy-config escape hatch Hits the Task 7.1 <=500 LOC gate for setup.ts by extracting the SetupStep registry, the WizardState singleton, clearWizardState and finishSetup into a sibling setup-steps.ts; setup.ts is now a thin shell (progress track + render loop + boot + re-exports). The import is one-directional (setup -> setup-steps), no cycle. Also restores the non-extension copy-vault-config-JSON escape hatch on the done step (per product decision) while keeping the redundant register-device button dropped (the SW handler registers the device). Co-Authored-By: Claude Opus 4.8 --- extension/src/setup/setup-steps.ts | 805 +++++++++++++++++++++++++++++ extension/src/setup/setup.ts | 801 +--------------------------- 2 files changed, 815 insertions(+), 791 deletions(-) create mode 100644 extension/src/setup/setup-steps.ts diff --git a/extension/src/setup/setup-steps.ts b/extension/src/setup/setup-steps.ts new file mode 100644 index 0000000..09dad61 --- /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' : '') + : ''; + } +} + +export 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 84c7cb4..5d04ace 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -1,75 +1,12 @@ -/// Vault initialization wizard — UI-only step registry. +/// Vault initialization wizard — thin shell (render loop + boot). /// -/// mode → host → connection → vault → device → done -/// -/// All crypto/remote/device orchestration lives in the service worker -/// (create_vault / attach_vault). This module is presentation + validation -/// only: it collects inputs, then fires a single SW message from the device -/// step (where the device name is known) and renders the result. +/// 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 } from '../service-worker/git-host'; -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 { Request, Response } from '../shared/messages'; - -// --- SW messaging (setup does not register a StateHost) --- - -function swSend(msg: Request): Promise { - return new Promise((resolve) => chrome.runtime.sendMessage(msg, (r: Response) => resolve(r))); -} - -// --- Step registry types --- - -type StepId = 'mode' | 'host' | 'connection' | 'vault' | 'device' | 'done'; - -interface StepContext { - state: WizardState; - rerender: () => void; - goto: (id: StepId) => void; -} - -interface SetupStep { - id: StepId; - render: (ctx: StepContext) => string; - attach: (root: HTMLElement, ctx: StepContext) => () => void; -} - -// --- State --- - -interface WizardState { - stepId: StepId; - mode: 'new' | 'attach' | null; - 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; - passphraseVisible: boolean; - confirmVisible: boolean; - referenceImageBytes: Uint8Array | null; - creating: boolean; - attaching: boolean; - error: string | null; - deviceName: string; -} - -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: '', -}; +import { STEPS, state, clearWizardState, finishSetup } from './setup-steps'; +import type { StepId, StepContext } from './setup-steps'; +import { escapeHtml } from './setup-helpers'; // --- Progress track --- @@ -82,680 +19,7 @@ function renderProgressTrack(current: number): string { }).join('')}`; } -// --- 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 cursor position is preserved). Also updates the char counter and match indicator. -function updateStrengthUi(): void { - const bar = document.getElementById('strength-bar'); - const label = document.getElementById('strength-label'); - 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} -
- -
-
`; - }, - attach(_root, _ctx) { - document.getElementById('setup-gen-qr')?.addEventListener('click', async () => { - const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null; - if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; } - try { - // The SW uses its current session (set by create_vault) — no handle passed. - const resp = await swSend({ type: 'generate_recovery_qr', passphrase: state.passphrase }); - if (!resp.ok || !resp.data) throw new Error(resp.ok ? 'unknown error' : resp.error); - const svg = (resp.data as { svg: string }).svg; - await new Promise((resolve) => { - chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve); - }); - const banner = document.getElementById('recovery-qr-banner'); - if (banner) { - banner.innerHTML = ` -
${svg}
-

◉ Recovery QR generated — save or print this now.

-
`; - document.getElementById('setup-qr-done')?.addEventListener('click', () => { - banner.style.display = 'none'; - }); - } - } catch (err) { - if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; } - alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`); - } - }); - document.getElementById('setup-skip-qr')?.addEventListener('click', () => { - const banner = document.getElementById('recovery-qr-banner'); - if (banner) banner.style.display = 'none'; - }); - document.getElementById('download-ref-btn')?.addEventListener('click', () => { - if (!state.referenceImageBytes) return; - const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'reference.jpg'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }); - document.getElementById('open-vault-btn')?.addEventListener('click', () => void finishSetup()); - return () => {}; - }, -}; - -// --- Registry + render loop --- - -const STEPS: ReadonlyArray = [ - modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep, -]; +// --- Render loop --- let teardown: (() => void) | null = null; @@ -783,52 +47,6 @@ function goto(id: StepId): void { rerender(); } -// --- Sensitive-state cleanup --- - -export function clearWizardState(): void { - // Best-effort wipe — JS strings are GC-only (see spec Risks); zero-fill the Uint8Arrays. - state.carrierImageBytes?.fill(0); - state.referenceImageBytes?.fill(0); - state.referenceImageBytesAttach?.fill(0); - state.mode = null; - state.hostType = 'gitea'; - state.hostUrl = ''; - state.repoPath = ''; - state.apiToken = ''; - state.connectionTested = false; - state.vaultProbe = null; - state.carrierImageBytes = null; - state.referenceImageBytesAttach = null; - state.passphrase = ''; - state.passphraseConfirm = ''; - state.passphraseScore = -1; - state.passphraseGuessesLog10 = -1; - state.passphraseVisible = false; - state.confirmVisible = false; - state.referenceImageBytes = null; - state.creating = false; - state.attaching = false; - state.error = null; - state.deviceName = ''; -} - -// --- Completion handoff --- - -/// 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. - } -} - // --- Boot --- document.addEventListener('DOMContentLoaded', () => { @@ -836,4 +54,5 @@ document.addEventListener('DOMContentLoaded', () => { rerender(); }); -export { STEPS }; +// Re-exports so existing test and bundle imports resolve unchanged. +export { STEPS, clearWizardState, finishSetup } from './setup-steps'; From d300d62c6000af58710b7d12e42618b71964defe Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 18:09:10 -0400 Subject: [PATCH 5/7] polish(ext/setup): honest vault-step button labels + drop needless export Both vault-step buttons now read "continue" -- they collect input and advance to the device step, where the SW actually performs create_vault/attach_vault (with its own busy spinner). The old "create vault" / "verify and attach" labels implied the action happened on that click, which is no longer true. Drops the unused export on vaultConfig(). Co-Authored-By: Claude Opus 4.8 --- extension/src/setup/setup-steps.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extension/src/setup/setup-steps.ts b/extension/src/setup/setup-steps.ts index 09dad61..ed0e1f2 100644 --- a/extension/src/setup/setup-steps.ts +++ b/extension/src/setup/setup-steps.ts @@ -109,7 +109,7 @@ function updateStrengthUi(): void { } } -export function vaultConfig(): VaultConfig { +function vaultConfig(): VaultConfig { return { hostType: state.hostType, hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl, @@ -365,7 +365,7 @@ function renderVaultAttach(): string {
- +
`; } @@ -426,7 +426,7 @@ function renderVaultNew(): string {
- +
`; } From 8044310fbad5358b7bd23d25b5077bff60f72432 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 18:11:53 -0400 Subject: [PATCH 6/7] test(ext/setup): cover SetupStep registry shape + clearWizardState (Plan C Phase 3) Asserts STEPS has the six steps in canonical order, each renders non-empty HTML and returns a teardown from attach, and clearWizardState zero-fills the reachable Uint8Array fields before resetting state. Keeps the existing finishSetup tests. Co-Authored-By: Claude Opus 4.8 --- extension/src/setup/__tests__/setup.test.ts | 49 ++++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) 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(); + }); +}); From eed48e2bbbc87687f1ca90c924ed19d635bd02c7 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 19:39:48 -0400 Subject: [PATCH 7/7] fix(ext/sw): type-correct session.setCurrent + simplify create/attach handlers Fixes a TS2345 that npx tsc --noEmit missed (it cannot resolve the generated wasm/relicario_wasm types, degrading SessionHandle) but the webpack build catches with real types: session.setCurrent(handle) was passed a SessionHandle|null. Capture the unlock result in a non-null `const h: SessionHandle` for the in-scope ops; `handle` remains the ownership tracker the finally block cleans up. Simplify pass: extract the shared register_device + addDevice + persist-config tail into registerDeviceAndPersistConfig (both handlers ended identically), hoist the Argon2 params literal to DEFAULT_PARAMS_JSON, and fan out the two independent read-only GETs in the attach path via Promise.all. Co-Authored-By: Claude Opus 4.8 --- extension/src/service-worker/vault.ts | 81 +++++++++++++++------------ 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index 29df581..d12cb89 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -21,6 +21,32 @@ 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, @@ -35,35 +61,25 @@ export async function handleCreateVault( const salt = new Uint8Array(32); crypto.getRandomValues(salt); - const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; - handle = w.unlock(msg.passphrase, referenceImageBytes, salt, paramsJson); + // 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(handle, '{"schema_version":2,"items":{}}')); - const settingsJson = w.default_vault_settings_json(); - const encryptedSettings = new Uint8Array(w.settings_encrypt(handle, settingsJson)); + 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(paramsJson), 'init: KDF parameters'); + 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'); - const keys = w.register_device(msg.deviceName) as { signing_public_key: string; deploy_public_key: string }; - await devices.addDevice(git, { - name: msg.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: msg.deviceName, - }); + await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName); // SW now owns the unlocked session — keeps the handle alive (enables recoveryQrAvailable). - session.setCurrent(handle); + 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 @@ -92,28 +108,21 @@ export async function handleAttachVault( const { config } = msg; const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); - const meta = await fetchVaultMeta(git); - const encryptedManifest = await git.readFile('manifest.enc'); + // The vault metadata and manifest are independent read-only GETs — fan out. + const [meta, encryptedManifest] = await Promise.all([ + fetchVaultMeta(git), + git.readFile('manifest.enc'), + ]); - handle = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson); + 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(handle, encryptedManifest) as Manifest; + const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest; - const keys = w.register_device(msg.deviceName) as { signing_public_key: string; deploy_public_key: string }; - await devices.addDevice(git, { - name: msg.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: msg.deviceName, - }); + await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName); // SW now owns the unlocked session — transfer ownership to the session. - session.setCurrent(handle); + session.setCurrent(h); state.gitHost = git; state.manifest = manifest; handle = null; // ownership transferred — do NOT lock-and-free in finally