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 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-31 14:23:52 -04:00
parent 2cf74968e0
commit 0e1e1a722d
3 changed files with 282 additions and 5 deletions

View File

@@ -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<typeof import('../git-host')>('../git-host');
const makeHostMock = (): GitHost & { _calls: Record<string, unknown[][]> } => {
const calls: Record<string, unknown[][]> = {
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<typeof makeHostMock> | 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<string, unknown> = {}) {
const store: Record<string, unknown> = { ...initial };
(global as { chrome: unknown }).chrome = {
storage: {
local: {
get: vi.fn((keys: string | string[]) => {
const arr = Array.isArray(keys) ? keys : [keys];
const out: Record<string, unknown> = {};
for (const k of arr) if (k in store) out[k] = store[k];
return Promise.resolve(out);
}),
set: vi.fn((kv: Record<string, unknown>) => {
Object.assign(store, kv);
return Promise.resolve();
}),
},
},
} as never;
return store;
}
// --- Helpers ---
function makeFakeHandle() {
return { free: vi.fn() };
}
function makeWasm(overrides: Record<string, unknown> = {}) {
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<typeof makeWasm>): 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<typeof vi.spyOn>;
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<typeof vi.fn> } }).__lastFakeGitHost;
expect(fakeHost).not.toBeNull();
const wfco = fakeHost!.writeFileCreateOnly as ReturnType<typeof vi.fn>;
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<typeof vi.fn> } } } })
.chrome.storage.local.set.mock.calls;
const merged: Record<string, unknown> = {};
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<typeof vi.fn> } }).__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<typeof vi.fn>).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();
});
});

View File

@@ -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}` };
}

View File

@@ -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<CreateVaultResponse | { ok: false; error: string }> {
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;