feat(ext/sw): unlock resolves second factor from params hint (keyfile branch)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014THSV6cA4Gxa7bxFfisHBB
This commit is contained in:
adlee-was-taken
2026-06-26 21:59:17 -04:00
parent 5cc16ddffb
commit d3f46389e4
3 changed files with 79 additions and 22 deletions

View File

@@ -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();

View File

@@ -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<PopupMessage, { type: 'unlock' }>,
state: PopupState,
): Promise<Response> {
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<string, unknown>).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<string | null> {
return (r.imageBase64 as string) ?? null;
}
async function loadKeyfileBase64(): Promise<string | null> {
const r = await chrome.storage.local.get('keyfileBase64');
return (r.keyfileBase64 as string) ?? null;
}
async function loadSetupState(): Promise<SetupState> {
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 {

View File

@@ -100,6 +100,19 @@ export const ERROR_COPY: Record<string, ErrorCopy> = {
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 {