From d3f46389e4c5228854fae2255792a35047b35ddd Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 21:59:17 -0400 Subject: [PATCH] feat(ext/sw): unlock resolves second factor from params hint (keyfile branch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract handleUnlock from popup-only.ts handle() switch, add keyfile branch (keyfile_decode + unlock_with_secret), forward-compat guard for unknown second_factor values, loadKeyfileBase64 helper, loadSetupState isConfigured fix for keyfile vaults. Register error copy for three new error codes. Un-skip Task 4 TDD test — 440/440 green, build clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014THSV6cA4Gxa7bxFfisHBB --- .../__tests__/keyfile-unlock.test.ts | 7 +- .../src/service-worker/router/popup-only.ts | 81 ++++++++++++++----- extension/src/shared/error-copy.ts | 13 +++ 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/extension/src/service-worker/__tests__/keyfile-unlock.test.ts b/extension/src/service-worker/__tests__/keyfile-unlock.test.ts index a89155d..d725368 100644 --- a/extension/src/service-worker/__tests__/keyfile-unlock.test.ts +++ b/extension/src/service-worker/__tests__/keyfile-unlock.test.ts @@ -197,8 +197,7 @@ describe('handleCreateVault — keyfile mode (Task 2)', () => { // 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)', () => { +describe('handleUnlock — keyfile branch (Task 4)', () => { beforeEach(() => { // chrome.storage.local returns vaultConfig + keyfileBase64 (no imageBase64) const fakeVaultConfig = { @@ -246,7 +245,9 @@ describe.skip('handleUnlock — keyfile branch (Task 4)', () => { }); const state = makeState(wasm); - // handleUnlock does not exist yet — this call will throw TypeError at runtime. + // Wire the module-level wasm in vault.ts so fetchAndDecryptManifest's + // requireWasm() resolves to the same mock (mirrors production SW init). + vault.setWasm(wasm); await handleUnlock({ type: 'unlock', passphrase: 'pw' }, state); expect(wasm.unlock_with_secret).toHaveBeenCalled(); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index bbc6036..42c8792 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -37,24 +37,7 @@ export async function handle( case 'is_unlocked': return { ok: true, data: { unlocked: session.getCurrent() !== null } }; - case 'unlock': { - const w = state.wasm; - const config = await loadConfig(); - if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' }; - const imageB64 = await loadImageBase64(); - if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' }; - - const imageBytes = base64ToUint8Array(imageB64); - if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); - const meta = await vault.fetchVaultMeta(state.gitHost); - - const handle = w.unlock(msg.passphrase, imageBytes, meta.salt, meta.paramsJson); - session.setCurrent(handle); - (msg as { passphrase: string }).passphrase = ''; - - state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle); - return { ok: true }; - } + case 'unlock': return handleUnlock(msg, state); case 'lock': session.clearCurrent(); @@ -647,6 +630,55 @@ export async function handle( } } +// --- handleUnlock — second-factor-aware unlock --- + +export async function handleUnlock( + msg: Extract, + state: PopupState, +): Promise { + const w = state.wasm; + const config = await loadConfig(); + if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' }; + + if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); + const meta = await vault.fetchVaultMeta(state.gitHost); + + // Parse second_factor hint from vault params. Forward-compat guard: an unknown + // value is rejected immediately rather than silently falling back to image. + let secondFactor: 'image' | 'keyfile' = 'image'; + try { + const sf = (JSON.parse(meta.paramsJson) as Record).second_factor; + if (sf === 'keyfile') secondFactor = 'keyfile'; + else if (sf !== undefined && sf !== 'image') { + return { ok: false, error: 'unsupported second factor in vault params' }; + } + } catch { /* malformed params.json — default to image */ } + + let handle; + if (secondFactor === 'keyfile') { + const kfB64 = await loadKeyfileBase64(); + if (!kfB64) return { ok: false, error: 'Key file not set. Run setup first.' }; + let secret: Uint8Array; + try { + secret = w.keyfile_decode(base64ToUint8Array(kfB64)); + } catch { + return { ok: false, error: 'invalid_key_file' }; + } + handle = w.unlock_with_secret(msg.passphrase, secret, meta.salt, meta.paramsJson); + } else { + // Image path (default). + const imageB64 = await loadImageBase64(); + if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' }; + handle = w.unlock(msg.passphrase, base64ToUint8Array(imageB64), meta.salt, meta.paramsJson); + } + + // Shared tail for both paths. + session.setCurrent(handle); + (msg as { passphrase: string }).passphrase = ''; + state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle); + return { ok: true }; +} + // --- fill_credentials with captured-tab verification (audit M5) --- async function handleFillCredentials( @@ -696,10 +728,21 @@ async function loadImageBase64(): Promise { return (r.imageBase64 as string) ?? null; } +async function loadKeyfileBase64(): Promise { + const r = await chrome.storage.local.get('keyfileBase64'); + return (r.keyfileBase64 as string) ?? null; +} + async function loadSetupState(): Promise { const config = await loadConfig(); const imageBase64 = await loadImageBase64(); - return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null }; + const keyfileBase64 = await loadKeyfileBase64(); + // A keyfile vault has no imageBase64 but does have keyfileBase64; either factor counts as configured. + return { + config, + imageBase64, + isConfigured: config !== null && (imageBase64 !== null || keyfileBase64 !== null), + }; } function safeHostname(url: string): string | undefined { diff --git a/extension/src/shared/error-copy.ts b/extension/src/shared/error-copy.ts index 256a794..2d62e92 100644 --- a/extension/src/shared/error-copy.ts +++ b/extension/src/shared/error-copy.ts @@ -100,6 +100,19 @@ export const ERROR_COPY: Record = { body: 'Run setup to save your reference image.', cta: { label: 'Open setup', action: 'open_setup' }, }, + 'Key file not set. Run setup first.': { + title: 'Key file missing', + body: 'Run setup to save your key file on this device.', + cta: { label: 'Open setup', action: 'open_setup' }, + }, + invalid_key_file: { + title: 'Invalid key file', + body: 'The key file is corrupted or the wrong format — re-download it from your backup and try again.', + }, + 'unsupported second factor in vault params': { + title: 'Unsupported vault type', + body: 'This vault uses a second-factor type not supported by this version of Relicario — update the extension.', + }, }; export function lookupErrorCopy(code: string): ErrorCopy {