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

@@ -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 {