From 201bf05adc547265c601b2dd95215c0e81ed8f7f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 22:19:41 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_014THSV6cA4Gxa7bxFfisHBB --- .../__tests__/keyfile-unlock.test.ts | 91 ++++++++++++- extension/src/service-worker/vault.ts | 39 +++++- extension/src/setup/__tests__/probe.test.ts | 30 ++++- .../src/setup/__tests__/setup-steps.test.ts | 20 ++- extension/src/setup/probe.ts | 21 ++- extension/src/setup/setup-steps.ts | 127 +++++++++++++----- extension/src/shared/messages.ts | 3 +- 7 files changed, 280 insertions(+), 51 deletions(-) diff --git a/extension/src/service-worker/__tests__/keyfile-unlock.test.ts b/extension/src/service-worker/__tests__/keyfile-unlock.test.ts index d725368..2a138bf 100644 --- a/extension/src/service-worker/__tests__/keyfile-unlock.test.ts +++ b/extension/src/service-worker/__tests__/keyfile-unlock.test.ts @@ -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; + + beforeEach(() => { + mockChromeStorage(); + vi.mocked(gitHostMod.createGitHost).mockImplementation(() => { + const h = makeHostMock(); + (globalThis as { __lastFakeGitHost?: ReturnType | 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[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 } } } }) + .chrome.storage.local.set.mock.calls; + const merged: Record = {}; + 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[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. diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index dd5ac1a..f404f93 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -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 { 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); diff --git a/extension/src/setup/__tests__/probe.test.ts b/extension/src/setup/__tests__/probe.test.ts index 2901cd8..d1028b4 100644 --- a/extension/src/setup/__tests__/probe.test.ts +++ b/extension/src/setup/__tests__/probe.test.ts @@ -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'); + }); }); diff --git a/extension/src/setup/__tests__/setup-steps.test.ts b/extension/src/setup/__tests__/setup-steps.test.ts index e574f81..0c74796 100644 --- a/extension/src/setup/__tests__/setup-steps.test.ts +++ b/extension/src/setup/__tests__/setup-steps.test.ts @@ -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)'); + }); +}); diff --git a/extension/src/setup/probe.ts b/extension/src/setup/probe.ts index 6f13928..160bd0f 100644 --- a/extension/src/setup/probe.ts +++ b/extension/src/setup/probe.ts @@ -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 { const [relicarioFiles, rootFiles] = await Promise.all([ host.listDir('.relicario'), @@ -18,6 +24,17 @@ export async function probeVault(host: GitHost): Promise { 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 }; } diff --git a/extension/src/setup/setup-steps.ts b/extension/src/setup/setup-steps.ts index e9f858a..426494b 100644 --- a/extension/src/setup/setup-steps.ts +++ b/extension/src/setup/setup-steps.ts @@ -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 ? ` +
+ +
+ + ${hasFactor ? '

key file loaded

' : '

click to select your .relkey file

'} +
+

The key file is the .relkey you saved when you first created this vault. It holds the 256-bit secret.

+
` : ` +
+ +
+ + ${hasFactor ? '

reference image loaded

' : '

click to select your reference JPEG

'} +
+

The reference image is the JPEG you saved when you first created this vault — not the original photo. It has the 256-bit secret embedded.

+
`; return `

attach 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.

-
- -
- - ${hasImage ? '

reference image loaded

' : '

click to select your reference JPEG

'} -
-

The reference image is the JPEG you saved when you first created this vault — not the original photo. It has the 256-bit secret embedded.

-
+

${intro}

+ ${factorGroup}
@@ -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; diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index b1bcd43..17cace7 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -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 ---