From 0e1e1a722d105b793d4882f96c91e54674c3eac0 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 14:23:52 -0400 Subject: [PATCH] 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;