From 5cc16ddffbc56bc612bf74972fff7d55801861e5 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 21:49:09 -0400 Subject: [PATCH] feat(ext/sw): create_vault key-file mode (generate secret, unlock_with_secret, store keyfileBase64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add KEYFILE_PARAMS_JSON const (same KDF params as default + second_factor:"keyfile" hint) - Generalize registerDeviceAndPersistConfig: takes factorStorage union instead of referenceImageBytes Uint8Array; spreads { imageBase64 } or { keyfileBase64 } into chrome.storage.local.set so exactly one factor key is present - Branch handleCreateVault on msg.secondFactor === 'keyfile': skip carrier-image guards and embed_image_secret; call unlock_with_secret + keyfile_encode; share the file-write / device-registration / session tail with the image path - Update handleAttachVault call to registerDeviceAndPersistConfig to pass { imageBase64: uint8ArrayToBase64(referenceImageBytes) } - Relax carrierImageBytes to optional in messages.ts create_vault request type - Skip Task-4 describe block in keyfile-unlock.test.ts (un-skip in Task 4) TDD: Task-2 test RED → GREEN; Task-4 describe.skipped; full suite 439 passed/1 skipped; build clean Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014THSV6cA4Gxa7bxFfisHBB --- .../__tests__/keyfile-unlock.test.ts | 255 ++++++++++++++++++ extension/src/service-worker/vault.ts | 86 ++++-- extension/src/shared/messages.ts | 2 +- 3 files changed, 315 insertions(+), 28 deletions(-) create mode 100644 extension/src/service-worker/__tests__/keyfile-unlock.test.ts diff --git a/extension/src/service-worker/__tests__/keyfile-unlock.test.ts b/extension/src/service-worker/__tests__/keyfile-unlock.test.ts new file mode 100644 index 0000000..a89155d --- /dev/null +++ b/extension/src/service-worker/__tests__/keyfile-unlock.test.ts @@ -0,0 +1,255 @@ +// HELD: Task 2 adds keyfile branch to handleCreateVault + Task 4 extracts handleUnlock. +// Both tests are intentionally RED until the respective tasks land after Dev-D merges. +// Do NOT commit this file while it is red. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vault from '../vault'; +import * as session from '../session'; +import type { PopupState } from '../router/popup-only'; +import type { GitHost } from '../git-host'; +import * as gitHostMod from '../git-host'; + +// ---- Mirror the makeHostMock / vi.mock / mockChromeStorage harness from vault.test.ts ---- + +function makeHostMock(): GitHost & { _calls: Record } { + const calls: Record = { + writeFileCreateOnly: [], + writeFile: [], + readFile: [], + }; + return { + _calls: calls, + readFile: vi.fn().mockImplementation(async (path: string) => { + 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]); + 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'); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = null; + return { + ...actual, + createGitHost: vi.fn().mockImplementation(() => { + const h = makeHostMock(); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; + return h; + }), + }; +}); + +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; +} + +function makeFakeHandle() { + return { free: vi.fn() }; +} + +function makeWasm(overrides: Record = {}) { + const fakeHandle = makeFakeHandle(); + return { + _handle: fakeHandle, + embed_image_secret: vi.fn(() => new Uint8Array([1, 2, 3])), + unlock: vi.fn(() => fakeHandle), + manifest_encrypt: vi.fn(() => new Uint8Array([9])), + manifest_decrypt: vi.fn(() => ({ schema_version: 2, items: {} })), + default_vault_settings_json: vi.fn(() => '{}'), + settings_encrypt: vi.fn(() => new Uint8Array([8])), + register_device: vi.fn(() => ({ signing_public_key: 'pk', deploy_public_key: 'dk' })), + lock: vi.fn(), + ...overrides, + }; +} + +function makeState(wasm: ReturnType): PopupState { + return { + manifest: null, + gitHost: null, + wasm, + }; +} + +const BASE_MSG = { + config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' }, + passphrase: 'pw', + // Must begin with the JPEG SOI magic (0xFF 0xD8) to pass handleCreateVault's + // pre-embed format guard; the WASM embed itself is mocked. + carrierImageBytes: new Uint8Array([0xff, 0xd8, 0x00]).buffer, + deviceName: 'Dev', +}; + +// ---- Task 2: create_vault keyfile mode ---- +// ASSERTION-red: handleCreateVault currently ignores secondFactor. +// Will go green when Task 2 adds the keyfile branch. + +describe('handleCreateVault — keyfile mode (Task 2)', () => { + let setCurrent: ReturnType; + + beforeEach(() => { + mockChromeStorage(); + 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('routes to keyfile path when secondFactor is keyfile', async () => { + const keyfileHandle = makeFakeHandle(); + const wasm = makeWasm({ + keyfile_encode: vi.fn(() => new Uint8Array([10, 11, 12])), + unlock_with_secret: vi.fn(() => keyfileHandle), + }); + const state = makeState(wasm); + + const resp = await vault.handleCreateVault( + // secondFactor is already in the messages.ts type but handleCreateVault + // does not yet read it — cast to pass it. + { ...BASE_MSG, secondFactor: 'keyfile' } as Parameters[0], + state, + ); + + expect(resp.ok).toBe(true); + if (!resp.ok) throw new Error('expected ok:true'); + + // resp.data.relkeyBytes must be present (the armored .relkey for the wizard to download) + expect((resp.data as { relkeyBytes?: Uint8Array }).relkeyBytes).toBeInstanceOf(Uint8Array); + + // Correct WASM path + expect(wasm.keyfile_encode).toHaveBeenCalled(); + expect(wasm.unlock_with_secret).toHaveBeenCalled(); + + // Image path must NOT have been taken + expect(wasm.unlock).not.toHaveBeenCalled(); + expect(wasm.embed_image_secret).not.toHaveBeenCalled(); + + // chrome.storage.local.set: keyfileBase64 present, imageBase64 absent + 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('keyfileBase64'); + expect(merged).not.toHaveProperty('imageBase64'); + + // params.json writeFileCreateOnly call must embed "second_factor":"keyfile" + const fakeHost = (globalThis as { + __lastFakeGitHost?: { writeFileCreateOnly: ReturnType } | null; + }).__lastFakeGitHost; + expect(fakeHost).not.toBeNull(); + const wfco = fakeHost!.writeFileCreateOnly as ReturnType; + const paramsCall = (wfco.mock.calls as unknown[][]).find( + (c: unknown[]) => c[0] === '.relicario/params.json', + ); + expect(paramsCall).toBeDefined(); + const paramsBytes = paramsCall![1] as Uint8Array; + const paramsObj = JSON.parse(new TextDecoder().decode(paramsBytes)) as Record; + expect(paramsObj).toHaveProperty('second_factor', 'keyfile'); + + // Void session.setCurrent — avoid lint unused-variable + void setCurrent; + }); +}); + +// ---- Task 4: unlock resolves second factor from params hint ---- +// HELD: Task 4 extracts handleUnlock + adds the keyfile branch; green after Dev-D merges. +// IMPORT-red: handleUnlock does not exist yet. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const { handleUnlock } = await import('../router/popup-only') as any; + +// un-skip in Task 4 +describe.skip('handleUnlock — keyfile branch (Task 4)', () => { + beforeEach(() => { + // chrome.storage.local returns vaultConfig + keyfileBase64 (no imageBase64) + const fakeVaultConfig = { + hostType: 'gitea', + hostUrl: 'https://g', + repoPath: 'u/v', + apiToken: 't', + }; + // A minimal base64 string for keyfileBase64 + const keyfileBase64 = btoa(String.fromCharCode(...new Uint8Array(32))); + mockChromeStorage({ + vaultConfig: fakeVaultConfig, + keyfileBase64, + }); + + // The gitHost readFile for params.json must return second_factor:keyfile + vi.mocked(gitHostMod.createGitHost).mockImplementation(() => { + const h = makeHostMock(); + // Override readFile for params.json to include second_factor + (h.readFile as ReturnType).mockImplementation(async (path: string) => { + 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,"second_factor":"keyfile"}', + ); + } + if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]); + throw new Error(`404: ${path}`); + }); + (globalThis as { __lastFakeGitHost?: ReturnType | null }).__lastFakeGitHost = h; + return h; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls unlock_with_secret (not unlock) when params hint is keyfile', async () => { + const keyfileHandle = makeFakeHandle(); + const wasm = makeWasm({ + keyfile_decode: vi.fn(() => new Uint8Array(32)), + unlock_with_secret: vi.fn(() => keyfileHandle), + manifest_decrypt: vi.fn(() => ({ schema_version: 2, items: {} })), + }); + const state = makeState(wasm); + + // handleUnlock does not exist yet — this call will throw TypeError at runtime. + await handleUnlock({ type: 'unlock', passphrase: 'pw' }, state); + + expect(wasm.unlock_with_secret).toHaveBeenCalled(); + expect(wasm.unlock).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index b5a1a9e..dd5ac1a 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -22,16 +22,20 @@ function requireWasm(): any { } const DEFAULT_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; +const KEYFILE_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4,"second_factor":"keyfile"}'; /// Register this device on the remote (devices.json) and persist the vault -/// config + reference image locally so future unlocks work. Shared by the +/// config + factor storage locally so future unlocks work. Shared by the /// create and attach flows — both finish with this identical tail. +/// `factorStorage` is either `{ imageBase64 }` (image mode) or +/// `{ keyfileBase64 }` (key-file mode); it is spread directly into the +/// chrome.storage.local.set call so exactly one key is present. async function registerDeviceAndPersistConfig( // eslint-disable-next-line @typescript-eslint/no-explicit-any w: any, git: GitHost, config: VaultConfig, - referenceImageBytes: Uint8Array, + factorStorage: { imageBase64: string } | { keyfileBase64: string }, deviceName: string, ): Promise { const keys = w.register_device(deviceName) as { signing_public_key: string }; @@ -42,51 +46,79 @@ async function registerDeviceAndPersistConfig( }); await chrome.storage.local.set({ vaultConfig: config, - imageBase64: uint8ArrayToBase64(referenceImageBytes), + ...factorStorage, device_name: deviceName, }); } export async function handleCreateVault( - msg: { config: VaultConfig; passphrase: string; carrierImageBytes: ArrayBuffer; deviceName: string }, + msg: { config: VaultConfig; passphrase: string; secondFactor?: 'image' | 'keyfile'; + carrierImageBytes?: ArrayBuffer; deviceName: string }, state: PopupState, ): Promise { const w = state.wasm; let handle: SessionHandle | null = null; try { - const carrierBytes = new Uint8Array(msg.carrierImageBytes); - // Actionable guard: the file is only a hint, - // and a broken ArrayBuffer transport would arrive empty. Both surface as - // the opaque WASM error "no SOF marker found in JPEG", so classify here - // before handing bytes to the steganography embed. - if (carrierBytes.length === 0) { - return { ok: false, error: 'carrier_image_empty' }; - } - if (carrierBytes[0] !== 0xff || carrierBytes[1] !== 0xd8) { - return { ok: false, error: 'carrier_image_not_jpeg' }; - } - const imageSecret = new Uint8Array(32); - crypto.getRandomValues(imageSecret); - const referenceImageBytes = new Uint8Array(w.embed_image_secret(carrierBytes, imageSecret)); - const salt = new Uint8Array(32); crypto.getRandomValues(salt); - // Capture the unlock result in a non-null binding for the in-scope ops; - // `handle` stays the ownership tracker the finally block cleans up. - const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, salt, DEFAULT_PARAMS_JSON); - handle = h; + // --- Branch on second factor --- + let h: SessionHandle; + let paramsJson: string; + let factorStorage: { imageBase64: string } | { keyfileBase64: string }; + let responseData: { referenceImageBytes?: Uint8Array; relkeyBytes?: Uint8Array; + deviceName: string; recoveryQrAvailable: true }; + + if (msg.secondFactor === 'keyfile') { + // KEY-FILE branch: skip carrier-image guards and embed_image_secret. + // Generate a fresh 32-byte secret, derive via unlock_with_secret, and + // armor it with keyfile_encode for the wizard download. + const secret = crypto.getRandomValues(new Uint8Array(32)); + // Capture the unlock result in a non-null binding for the in-scope ops; + // `handle` stays the ownership tracker the finally block cleans up. + h = w.unlock_with_secret(msg.passphrase, secret, salt, KEYFILE_PARAMS_JSON) as SessionHandle; + handle = h; + const relkeyBytes = new Uint8Array(w.keyfile_encode(secret)); + paramsJson = KEYFILE_PARAMS_JSON; + factorStorage = { keyfileBase64: uint8ArrayToBase64(relkeyBytes) }; + responseData = { relkeyBytes, deviceName: msg.deviceName, recoveryQrAvailable: true }; + } else { + // IMAGE branch (default): existing behavior — carrier guards, stego embed, unlock. + const carrierBytes = new Uint8Array(msg.carrierImageBytes!); + // Actionable guard: the file is only a hint, + // and a broken ArrayBuffer transport would arrive empty. Both surface as + // the opaque WASM error "no SOF marker found in JPEG", so classify here + // before handing bytes to the steganography embed. + if (carrierBytes.length === 0) { + return { ok: false, error: 'carrier_image_empty' }; + } + if (carrierBytes[0] !== 0xff || carrierBytes[1] !== 0xd8) { + return { ok: false, error: 'carrier_image_not_jpeg' }; + } + const imageSecret = new Uint8Array(32); + crypto.getRandomValues(imageSecret); + const referenceImageBytes = new Uint8Array(w.embed_image_secret(carrierBytes, imageSecret)); + // Capture the unlock result in a non-null binding for the in-scope ops; + // `handle` stays the ownership tracker the finally block cleans up. + h = w.unlock(msg.passphrase, referenceImageBytes, salt, DEFAULT_PARAMS_JSON) as SessionHandle; + handle = h; + paramsJson = DEFAULT_PARAMS_JSON; + factorStorage = { imageBase64: uint8ArrayToBase64(referenceImageBytes) }; + responseData = { referenceImageBytes, deviceName: msg.deviceName, recoveryQrAvailable: true }; + } + + // --- Shared tail (both branches): encrypt init files, device registration, session --- const encryptedManifest = new Uint8Array(w.manifest_encrypt(h, '{"schema_version":2,"items":{}}')); const encryptedSettings = new Uint8Array(w.settings_encrypt(h, w.default_vault_settings_json())); const { config } = msg; const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); await git.writeFileCreateOnly('.relicario/salt', salt, 'init: vault salt'); - await git.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(DEFAULT_PARAMS_JSON), 'init: KDF parameters'); + await git.writeFileCreateOnly('.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'); - await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName); + await registerDeviceAndPersistConfig(w, git, config, factorStorage, msg.deviceName); // SW now owns the unlocked session — keeps the handle alive (enables recoveryQrAvailable). session.setCurrent(h); @@ -94,7 +126,7 @@ export async function handleCreateVault( 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 } }; + return { ok: true, data: responseData }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } finally { @@ -129,7 +161,7 @@ export async function handleAttachVault( // manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure. const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest; - await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName); + await registerDeviceAndPersistConfig(w, git, config, { imageBase64: uint8ArrayToBase64(referenceImageBytes) }, msg.deviceName); // SW now owns the unlocked session — transfer ownership to the session. session.setCurrent(h); diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 8bd1744..b1bcd43 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -65,7 +65,7 @@ export type PopupMessage = | { type: 'generate_recovery_qr'; passphrase: string } | { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string } | { type: 'create_vault'; config: VaultConfig; passphrase: string; - secondFactor?: 'image' | 'keyfile'; carrierImageBytes: ArrayBuffer; deviceName: string } + secondFactor?: 'image' | 'keyfile'; carrierImageBytes?: ArrayBuffer; deviceName: string } | { type: 'attach_vault'; config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string } | { type: 'get_vault_status' };