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 <noreply@anthropic.com>
This commit is contained in:
@@ -3,16 +3,15 @@ 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.
|
||||
|
||||
vi.mock('../git-host', async () => {
|
||||
const actual = await vi.importActual<typeof import('../git-host')>('../git-host');
|
||||
|
||||
const makeHostMock = (): GitHost & { _calls: Record<string, unknown[][]> } => {
|
||||
// Shared factory used both inside vi.mock and in beforeEach re-wire.
|
||||
function makeHostMock(): GitHost & { _calls: Record<string, unknown[][]> } {
|
||||
const calls: Record<string, unknown[][]> = {
|
||||
writeFileCreateOnly: [],
|
||||
writeFile: [],
|
||||
@@ -21,6 +20,12 @@ vi.mock('../git-host', async () => {
|
||||
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}`);
|
||||
}),
|
||||
@@ -37,18 +42,19 @@ vi.mock('../git-host', async () => {
|
||||
getBlob: vi.fn(),
|
||||
deleteBlob: vi.fn(),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('../git-host', async () => {
|
||||
const actual = await vi.importActual<typeof import('../git-host')>('../git-host');
|
||||
|
||||
// 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;
|
||||
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | 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<typeof makeHostMock> | null }).__lastFakeGitHost = h;
|
||||
return h;
|
||||
}),
|
||||
};
|
||||
@@ -90,6 +96,7 @@ function makeWasm(overrides: Record<string, unknown> = {}) {
|
||||
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<typeof vi.spyOn>;
|
||||
|
||||
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<typeof makeHostMock> | 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<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', '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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<AttachVaultResponse | { ok: false; error: string }> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user