feat(ext/setup+sw): attach via key file when the vault uses one (probe second_factor, .relkey picker, handleAttachVault keyfile branch)

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 22:19:41 -04:00
parent 2f7a034a1b
commit 201bf05adc
7 changed files with 280 additions and 51 deletions

View File

@@ -1,6 +1,6 @@
// 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.
// Keyfile-mode SW coverage: handleCreateVault keyfile branch (Task 2), handleUnlock
// second-factor resolution (Task 4), and handleAttachVault keyfile branch (Task 5).
// All green now that Tasks 2/4 have landed and Task 5 wires the attach path.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as vault from '../vault';
@@ -190,6 +190,91 @@ describe('handleCreateVault — keyfile mode (Task 2)', () => {
});
});
// ---- Task 5: attach_vault keyfile mode ----
// handleAttachVault must branch on secondFactor:'keyfile' — decode the uploaded
// .relkey, derive via unlock_with_secret (NOT unlock), and persist keyfileBase64.
describe('handleAttachVault — keyfile mode (Task 5)', () => {
let setCurrent: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mockChromeStorage();
vi.mocked(gitHostMod.createGitHost).mockImplementation(() => {
const h = makeHostMock();
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = h;
return h;
});
setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
const KF_ATTACH_MSG = {
config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' },
passphrase: 'pw',
secondFactor: 'keyfile' as const,
keyfileBytes: new Uint8Array([10, 11, 12]).buffer,
deviceName: 'Dev2',
};
it('decodes the .relkey and unlocks with the secret (not unlock), storing keyfileBase64', async () => {
const keyfileHandle = makeFakeHandle();
const wasm = makeWasm({
keyfile_decode: vi.fn(() => new Uint8Array(32)),
unlock_with_secret: vi.fn(() => keyfileHandle),
});
const state = makeState(wasm);
const resp = await vault.handleAttachVault(
KF_ATTACH_MSG as Parameters<typeof vault.handleAttachVault>[0],
state,
);
expect(resp.ok).toBe(true);
if (!resp.ok) throw new Error('expected ok:true');
expect(resp.data.deviceName).toBe('Dev2');
// Keyfile path: keyfile_decode + unlock_with_secret; the image unlock must NOT run.
expect(wasm.keyfile_decode).toHaveBeenCalled();
expect(wasm.unlock_with_secret).toHaveBeenCalled();
expect(wasm.unlock).not.toHaveBeenCalled();
// The existing manifest_decrypt verification still runs; device registered.
expect(wasm.manifest_decrypt).toHaveBeenCalled();
expect(wasm.register_device).toHaveBeenCalledWith('Dev2');
// chrome.storage.local.set: keyfileBase64 present, imageBase64 absent.
const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType<typeof vi.fn> } } } })
.chrome.storage.local.set.mock.calls;
const merged: Record<string, unknown> = {};
for (const [kv] of chromeSets) Object.assign(merged, kv);
expect(merged).toHaveProperty('keyfileBase64');
expect(merged).not.toHaveProperty('imageBase64');
expect(setCurrent).toHaveBeenCalled();
});
it('returns invalid_key_file when keyfile_decode throws (and never unlocks)', async () => {
const wasm = makeWasm({
keyfile_decode: vi.fn(() => { throw new Error('bad armor'); }),
unlock_with_secret: vi.fn(),
});
const state = makeState(wasm);
const resp = await vault.handleAttachVault(
KF_ATTACH_MSG as Parameters<typeof vault.handleAttachVault>[0],
state,
);
expect(resp).toEqual({ ok: false, error: 'invalid_key_file' });
expect(wasm.unlock_with_secret).not.toHaveBeenCalled();
expect(wasm.register_device).not.toHaveBeenCalled();
expect(setCurrent).not.toHaveBeenCalled();
});
});
// ---- 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.

View File

@@ -140,13 +140,13 @@ export async function handleCreateVault(
}
export async function handleAttachVault(
msg: { config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string },
msg: { config: VaultConfig; passphrase: string; secondFactor?: 'image' | 'keyfile';
referenceImageBytes?: ArrayBuffer; keyfileBytes?: ArrayBuffer; deviceName: string },
state: PopupState,
): Promise<AttachVaultResponse | { ok: false; error: string }> {
const w = state.wasm;
let handle: SessionHandle | null = null;
try {
const referenceImageBytes = new Uint8Array(msg.referenceImageBytes);
const { config } = msg;
const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
@@ -156,12 +156,39 @@ export async function handleAttachVault(
git.readFile('manifest.enc'),
]);
const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson);
handle = h;
// manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure.
// --- Branch on second factor: derive the unlock handle + factor storage ---
// Only the secret derivation and the stored artifact differ; the verify
// (manifest_decrypt), device registration, and session wiring are shared.
let h: SessionHandle;
let factorStorage: { imageBase64: string } | { keyfileBase64: string };
if (msg.secondFactor === 'keyfile') {
// KEY-FILE branch: decode the uploaded .relkey to its 32-byte secret and
// derive via unlock_with_secret. We persist the armored .relkey verbatim
// (same artifact create stores) so future unlocks can re-derive.
const relkeyBytes = new Uint8Array(msg.keyfileBytes!);
let secret: Uint8Array;
try {
secret = w.keyfile_decode(relkeyBytes);
} catch {
return { ok: false, error: 'invalid_key_file' };
}
h = w.unlock_with_secret(msg.passphrase, secret, meta.salt, meta.paramsJson) as SessionHandle;
handle = h;
factorStorage = { keyfileBase64: uint8ArrayToBase64(relkeyBytes) };
} else {
// IMAGE branch (default): unlock with the reference JPEG (the secret is
// recovered from the embedded steganography inside the WASM unlock).
const referenceImageBytes = new Uint8Array(msg.referenceImageBytes!);
h = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson) as SessionHandle;
handle = h;
factorStorage = { imageBase64: uint8ArrayToBase64(referenceImageBytes) };
}
// manifest_decrypt verifies the passphrase + second factor — throws on AEAD failure.
const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest;
await registerDeviceAndPersistConfig(w, git, config, { imageBase64: uint8ArrayToBase64(referenceImageBytes) }, msg.deviceName);
await registerDeviceAndPersistConfig(w, git, config, factorStorage, msg.deviceName);
// SW now owns the unlocked session — transfer ownership to the session.
session.setCurrent(h);

View File

@@ -6,6 +6,7 @@ function fakeHost(opts: {
relicarioFiles?: string[];
rootFiles?: string[];
commit?: { sha: string; author: string; date: string } | null;
paramsJson?: string;
} = {}): GitHost {
return {
listDir: vi.fn().mockImplementation(async (p: string) => {
@@ -14,7 +15,14 @@ function fakeHost(opts: {
return [];
}),
lastCommit: vi.fn().mockResolvedValue(opts.commit ?? null),
readFile: vi.fn(), writeFile: vi.fn(), writeFileCreateOnly: vi.fn(),
readFile: vi.fn().mockImplementation(async (p: string) => {
if (p === '.relicario/params.json') {
if (opts.paramsJson === undefined) throw new Error(`404: ${p}`);
return new TextEncoder().encode(opts.paramsJson);
}
throw new Error(`404: ${p}`);
}),
writeFile: vi.fn(), writeFileCreateOnly: vi.fn(),
deleteFile: vi.fn(), putBlob: vi.fn(), getBlob: vi.fn(), deleteBlob: vi.fn(),
};
}
@@ -46,4 +54,24 @@ describe('probeVault', () => {
expect(result.exists).toBe(true);
expect(result.lastCommit).toBeUndefined();
});
it('reports secondFactor=keyfile when params.json declares it', async () => {
const host = fakeHost({
relicarioFiles: ['salt', 'params.json'],
paramsJson: '{"argon2_m":65536,"argon2_t":3,"argon2_p":4,"second_factor":"keyfile"}',
});
const result = await probeVault(host);
expect(result.exists).toBe(true);
expect(result.secondFactor).toBe('keyfile');
});
it('defaults secondFactor=image when params.json omits second_factor (back-compat)', async () => {
const host = fakeHost({
relicarioFiles: ['salt', 'params.json'],
paramsJson: '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}',
});
const result = await probeVault(host);
expect(result.exists).toBe(true);
expect(result.secondFactor).toBe('image');
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { state, clearWizardState, renderVaultNew, triggerRelkeyDownload } from '../setup-steps';
import { state, clearWizardState, renderVaultNew, renderVaultAttach, triggerRelkeyDownload } from '../setup-steps';
describe('triggerRelkeyDownload', () => {
afterEach(() => clearWizardState());
@@ -32,3 +32,21 @@ describe('renderVaultNew — second-factor container choice', () => {
expect(html).toContain('A 32-byte key file will be generated');
});
});
describe('renderVaultAttach — second-factor picker follows the probed vault', () => {
afterEach(() => clearWizardState());
it('shows the .relkey picker (and hides the JPEG picker) for a keyfile vault', () => {
state.vaultProbe = { exists: true, secondFactor: 'keyfile' };
const html = renderVaultAttach();
expect(html).toContain('key file (.relkey)');
expect(html).not.toContain('reference image (JPEG)');
});
it('shows the reference-image picker for an image vault (default)', () => {
state.vaultProbe = { exists: true, secondFactor: 'image' };
const html = renderVaultAttach();
expect(html).toContain('reference image (JPEG)');
expect(html).not.toContain('key file (.relkey)');
});
});

View File

@@ -3,11 +3,17 @@ import type { GitHost } from '../service-worker/git-host';
export interface VaultProbe {
exists: boolean;
lastCommit?: { sha: string; author: string; date: string };
// The vault's second factor, read from params.json's top-level `second_factor`
// (absent ⇒ image). Only populated when `exists` is true. Drives the attach
// wizard's picker (reference JPEG vs .relkey).
secondFactor?: 'image' | 'keyfile';
}
/// Detect whether the configured remote already contains a relicario vault.
/// Considered present if any of: .relicario/salt, .relicario/params.json,
/// manifest.enc exists. Best-effort metadata fetch via lastCommit().
/// manifest.enc exists. Best-effort metadata fetch via lastCommit(). When a
/// vault is present, also resolves its second factor from params.json so the
/// attach flow can prompt for the right artifact.
export async function probeVault(host: GitHost): Promise<VaultProbe> {
const [relicarioFiles, rootFiles] = await Promise.all([
host.listDir('.relicario'),
@@ -18,6 +24,17 @@ export async function probeVault(host: GitHost): Promise<VaultProbe> {
relicarioFiles.includes('params.json') ||
rootFiles.includes('manifest.enc');
if (!exists) return { exists: false };
// Resolve the second factor from params.json (top-level `second_factor`).
// Tolerate absence or malformed JSON — older vaults predate the field and an
// unreadable params.json should not break the probe; default to image.
let secondFactor: 'image' | 'keyfile' = 'image';
try {
const raw = await host.readFile('.relicario/params.json');
const sf = (JSON.parse(new TextDecoder().decode(raw)) as { second_factor?: unknown }).second_factor;
secondFactor = sf === 'keyfile' ? 'keyfile' : 'image';
} catch { /* params.json absent or unparsable — default to image */ }
const lastCommit = await host.lastCommit('manifest.enc');
return lastCommit ? { exists, lastCommit } : { exists };
return lastCommit ? { exists, lastCommit, secondFactor } : { exists, secondFactor };
}

View File

@@ -47,6 +47,7 @@ export interface WizardState {
carrierImageBytes: Uint8Array | null;
secondFactor: 'image' | 'keyfile';
referenceImageBytesAttach: Uint8Array | null;
keyfileBytesAttach: Uint8Array | null;
passphrase: string;
passphraseConfirm: string;
passphraseScore: number;
@@ -63,7 +64,8 @@ export interface WizardState {
export const state: WizardState = {
stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '',
connectionTested: false, vaultProbe: null, carrierImageBytes: null, secondFactor: 'image', referenceImageBytesAttach: null,
connectionTested: false, vaultProbe: null, carrierImageBytes: null, secondFactor: 'image',
referenceImageBytesAttach: null, keyfileBytesAttach: null,
passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1,
passphraseVisible: false, confirmVisible: false, referenceImageBytes: null, relkeyBytes: null,
creating: false, attaching: false, error: null, deviceName: '',
@@ -345,24 +347,40 @@ const connectionStep: SetupStep = {
// --- vault ---
function renderVaultAttach(): string {
export function renderVaultAttach(): string {
const p = state.passphrase;
const pType = state.passphraseVisible ? 'text' : 'password';
const pToggle = state.passphraseVisible ? 'hide' : 'show';
const hasImage = !!state.referenceImageBytesAttach;
const gateDisabled = !p || !hasImage;
// Mirror the probed vault's second factor — a keyfile vault needs the .relkey,
// an image vault needs the reference JPEG. Default to image (back-compat).
const isKeyfile = state.vaultProbe?.secondFactor === 'keyfile';
const hasFactor = isKeyfile ? !!state.keyfileBytesAttach : !!state.referenceImageBytesAttach;
const gateDisabled = !p || !hasFactor;
const intro = isKeyfile
? 'Use your existing passphrase and key file (.relkey) to attach this browser to your vault. We\'ll verify both when you register this device.'
: 'Use your existing passphrase and reference image to attach this browser to your vault. We\'ll verify both when you register this device.';
const factorGroup = isKeyfile ? `
<div class="form-group">
<label class="label">key file (.relkey)</label>
<div class="file-drop ${hasFactor ? 'has-file' : ''}" id="kf-drop">
<input type="file" id="kf-input" accept=".relkey,text/plain" style="display:none;">
${hasFactor ? '<p class="secondary">key file loaded</p>' : '<p class="secondary">click to select your .relkey file</p>'}
</div>
<p class="muted" style="margin-top:4px;">The key file is the <code>.relkey</code> you saved when you first created this vault. It holds the 256-bit secret.</p>
</div>` : `
<div class="form-group">
<label class="label">reference image (JPEG)</label>
<div class="file-drop ${hasFactor ? 'has-file' : ''}" id="ref-drop">
<input type="file" id="ref-input" accept="image/jpeg" style="display:none;">
${hasFactor ? '<p class="secondary">reference image loaded</p>' : '<p class="secondary">click to select your reference JPEG</p>'}
</div>
<p class="muted" style="margin-top:4px;">The reference image is the JPEG you saved when you first created this vault — <strong>not the original photo</strong>. It has the 256-bit secret embedded.</p>
</div>`;
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>attach this device</h3>
<p class="muted" style="margin-bottom:12px;">Use your existing passphrase and reference image to attach this browser to your vault. We'll verify both when you register this device.</p>
<div class="form-group">
<label class="label">reference image (JPEG)</label>
<div class="file-drop ${hasImage ? 'has-file' : ''}" id="ref-drop">
<input type="file" id="ref-input" accept="image/jpeg" style="display:none;">
${hasImage ? '<p class="secondary">reference image loaded</p>' : '<p class="secondary">click to select your reference JPEG</p>'}
</div>
<p class="muted" style="margin-top:4px;">The reference image is the JPEG you saved when you first created this vault — <strong>not the original photo</strong>. It has the 256-bit secret embedded.</p>
</div>
<p class="muted" style="margin-bottom:12px;">${intro}</p>
${factorGroup}
<div class="form-group">
<label class="label" for="passphrase">passphrase</label>
<div class="passphrase-field">
@@ -460,25 +478,47 @@ const vaultStep: SetupStep = {
};
function attachVaultAttach(ctx: StepContext): () => void {
const refDrop = document.getElementById('ref-drop')!;
const refInput = document.getElementById('ref-input') as HTMLInputElement;
refDrop.addEventListener('click', () => refInput.click());
refInput.addEventListener('change', () => {
const file = refInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer);
state.error = null;
ctx.rerender();
};
reader.readAsArrayBuffer(file);
});
// Only one picker is rendered (renderVaultAttach branches on the probed second
// factor); wire whichever exists. Mirrors attachVaultNew's guard pattern — the
// inactive factor's elements are simply not in the DOM.
const isKeyfile = state.vaultProbe?.secondFactor === 'keyfile';
const hasFactor = () => (isKeyfile ? !!state.keyfileBytesAttach : !!state.referenceImageBytesAttach);
if (isKeyfile) {
const kfDrop = document.getElementById('kf-drop')!;
const kfInput = document.getElementById('kf-input') as HTMLInputElement;
kfDrop.addEventListener('click', () => kfInput.click());
kfInput.addEventListener('change', () => {
const file = kfInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
state.keyfileBytesAttach = new Uint8Array(reader.result as ArrayBuffer);
state.error = null;
ctx.rerender();
};
reader.readAsArrayBuffer(file);
});
} else {
const refDrop = document.getElementById('ref-drop')!;
const refInput = document.getElementById('ref-input') as HTMLInputElement;
refDrop.addEventListener('click', () => refInput.click());
refInput.addEventListener('change', () => {
const file = refInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer);
state.error = null;
ctx.rerender();
};
reader.readAsArrayBuffer(file);
});
}
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
passInput?.addEventListener('input', (e) => {
state.passphrase = (e.target as HTMLInputElement).value;
const btn = document.getElementById('attach-btn') as HTMLButtonElement | null;
if (btn) btn.disabled = !state.passphrase || !state.referenceImageBytesAttach;
if (btn) btn.disabled = !state.passphrase || !hasFactor();
});
document.getElementById('eye-btn')?.addEventListener('click', () => {
state.passphraseVisible = !state.passphraseVisible;
@@ -489,8 +529,8 @@ function attachVaultAttach(ctx: StepContext): () => void {
});
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('connection'));
document.getElementById('attach-btn')?.addEventListener('click', () => {
if (!state.referenceImageBytesAttach) {
state.error = 'Please select your reference JPEG image';
if (!hasFactor()) {
state.error = isKeyfile ? 'Please select your .relkey key file' : 'Please select your reference JPEG image';
ctx.rerender();
return;
}
@@ -638,13 +678,24 @@ const deviceStep: SetupStep = {
if (state.mode === 'attach') {
state.attaching = true;
ctx.rerender();
const resp = await swSend({
type: 'attach_vault',
config: vaultConfig(),
passphrase: state.passphrase,
referenceImageBytes: state.referenceImageBytesAttach!.buffer as ArrayBuffer,
deviceName: state.deviceName,
});
// Send whichever factor the probed vault uses. keyfileBytes/referenceImageBytes
// are ArrayBuffers; message-binary.ts base64-envelopes them across the channel.
const resp = state.vaultProbe?.secondFactor === 'keyfile'
? await swSend({
type: 'attach_vault',
config: vaultConfig(),
passphrase: state.passphrase,
secondFactor: 'keyfile',
keyfileBytes: state.keyfileBytesAttach!.buffer as ArrayBuffer,
deviceName: state.deviceName,
})
: await swSend({
type: 'attach_vault',
config: vaultConfig(),
passphrase: state.passphrase,
referenceImageBytes: state.referenceImageBytesAttach!.buffer as ArrayBuffer,
deviceName: state.deviceName,
});
state.attaching = false;
if (resp.ok) ctx.goto('done');
else { state.error = lookupErrorCopy(resp.error).body; ctx.rerender(); }
@@ -839,6 +890,7 @@ export function clearWizardState(): void {
state.carrierImageBytes?.fill(0);
state.referenceImageBytes?.fill(0);
state.referenceImageBytesAttach?.fill(0);
state.keyfileBytesAttach?.fill(0);
state.relkeyBytes?.fill(0);
state.mode = null;
state.hostType = 'gitea';
@@ -850,6 +902,7 @@ export function clearWizardState(): void {
state.carrierImageBytes = null;
state.secondFactor = 'image';
state.referenceImageBytesAttach = null;
state.keyfileBytesAttach = null;
state.passphrase = '';
state.passphraseConfirm = '';
state.passphraseScore = -1;

View File

@@ -67,7 +67,8 @@ export type PopupMessage =
| { type: 'create_vault'; config: VaultConfig; passphrase: string;
secondFactor?: 'image' | 'keyfile'; carrierImageBytes?: ArrayBuffer; deviceName: string }
| { type: 'attach_vault'; config: VaultConfig; passphrase: string;
referenceImageBytes: ArrayBuffer; deviceName: string }
secondFactor?: 'image' | 'keyfile'; referenceImageBytes?: ArrayBuffer;
keyfileBytes?: ArrayBuffer; deviceName: string }
| { type: 'get_vault_status' };
// --- Messages a content script may send ---