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:
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user