# Key-File Second Factor — Extension + Positioning Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Let users choose a key file instead of the stego image at setup, unlock with it in the browser, and re-lead the project's positioning on the durable thesis. **Architecture:** The setup wizard gains a second-factor container choice; in key-file mode the SW `create_vault` generates the 32-byte secret, returns the `.relkey` armor for download, stores `keyfileBase64` (exactly as `imageBase64` is stored today), and writes `params.json` `second_factor: "keyfile"`. The SW `unlock` handler branches on that hint — image path unchanged, key-file path calls `unlock_with_secret`. Then the docs lead with the thesis and frame stego as an option. **Tech Stack:** TypeScript (extension setup + SW), vitest; Markdown docs. Consumes Plan 4's core/WASM/params contract. ## Global Constraints - Release target: v0.9.0. - Consume Plan 4 verbatim: WASM `keyfile_encode`/`keyfile_decode`/`unlock_with_secret`; `params.json` `second_factor: "image"|"keyfile"` (absent ⇒ image). - Binary crosses `chrome.runtime.sendMessage` base64-enveloped (`shared/message-binary.ts`) — ArrayBuffers are dropped otherwise. - `keyfileBase64` is the second factor in the clear in `chrome.storage.local`, exactly the posture of today's `imageBase64`. Document it as equivalent, not weaker. - Existing image vaults must be unaffected (the `second_factor` default is `image`). - Keep `manifest.json`/`manifest.firefox.json` in sync. Capitalize "Relicario" in prose. --- ## File Structure - `extension/src/setup/setup-steps.ts` — `WizardState.secondFactor`; step-3 container-choice UI; key-file download flow. - `extension/src/service-worker/router/popup-only.ts` — `create_vault` key-file branch (`:636`); `unlock` branch on the params hint (`:40-51`); store `keyfileBase64` in `save_setup` (`:144`). - `extension/src/service-worker/vault.ts` — `create_vault` orchestration: key-file mode generates the secret + returns `.relkey`. - `extension/src/shared/messages.ts` — `create_vault` request gains `secondFactor`; response carries optional `relkeyBytes`. - Docs: `README.md`, `DESIGN.md`, `docs/CRYPTO.md`, `docs/FORMATS.md`, `docs/SECURITY.md`. - Tests: `extension/src/setup/__tests__/setup-steps.test.ts`, `extension/src/service-worker/__tests__/keyfile-unlock.test.ts`. --- ### Task 1: Wizard container choice (Image | Key File) **Files:** - Modify: `extension/src/setup/setup-steps.ts` (`WizardState` ~`:47`, step-3 new-vault render ~`:398`) - Test: `extension/src/setup/__tests__/setup-steps.test.ts` **Interfaces:** - Produces: `WizardState.secondFactor: 'image' | 'keyfile'` (default `'image'`); step-3 shows a radio/segmented control; selecting "Key File" hides the carrier-image drop and shows a "a 32-byte key file will be generated for you to save" note. - [ ] **Step 1: Write the failing test** ```ts import { renderStep3New, defaultWizardState } from '../setup-steps'; test('step 3 offers a second-factor choice; key-file hides the carrier drop', () => { const state = { ...defaultWizardState(), secondFactor: 'keyfile' as const }; const html = renderStep3New(state); expect(html).toContain('Key File'); expect(html).not.toContain('A 256-bit secret will be steganographically embedded'); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "second-factor choice"` Expected: FAIL — no `secondFactor` field / choice UI. - [ ] **Step 3: Implement** — add `secondFactor: 'image'|'keyfile'` to `WizardState` (default `'image'` in `defaultWizardState`, ~`:63`); add a segmented control to the step-3 new-vault markup (`:398-406`); when `'keyfile'`, replace the carrier drop with the key-file note. - [ ] **Step 4: Run to verify it passes** Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "second-factor choice"` Expected: PASS. - [ ] **Step 5: Type-check + commit** Run: `cd extension && npm run build:all` ```bash git add extension/src/setup/setup-steps.ts extension/src/setup/__tests__/setup-steps.test.ts git commit -m "feat(ext/setup): second-factor container choice (image | key file)" ``` --- ### Task 2: SW `create_vault` key-file branch **Files:** - Modify: `extension/src/service-worker/vault.ts` (`create_vault` orchestration), `router/popup-only.ts:636`, `extension/src/shared/messages.ts` - Test: `extension/src/service-worker/__tests__/keyfile-unlock.test.ts` **Interfaces:** - Consumes: `wasm.keyfile_encode`, `wasm.unlock_with_secret` (Plan 4). - Produces: `create_vault` request gains `secondFactor: 'image'|'keyfile'`; in key-file mode the SW generates a 32-byte secret (`crypto.getRandomValues`), derives via `unlock_with_secret`, writes `params.json` with `second_factor: "keyfile"`, stores `keyfileBase64`, and returns `{ ok, data: { relkeyBytes } }` (base64-enveloped) for download. Image mode is unchanged. - [ ] **Step 1: Write the failing test** ```ts test('create_vault keyfile mode stores keyfileBase64 and returns relkey bytes', async () => { const set = vi.spyOn(chrome.storage.local, 'set').mockResolvedValue(); const resp = await handleCreateVault({ secondFactor: 'keyfile', config: fakeConfig } as any, fakeState); expect(resp.ok).toBe(true); expect(resp.data.relkeyBytes).toBeDefined(); const stored = JSON.stringify(set.mock.calls); expect(stored).toContain('keyfileBase64'); expect(stored).not.toContain('imageBase64'); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `cd extension && npx vitest run src/service-worker/__tests__/keyfile-unlock.test.ts -t "create_vault keyfile"` Expected: FAIL — `create_vault` ignores `secondFactor`. - [ ] **Step 3: Implement** — in the `create_vault` path: if `secondFactor === 'keyfile'`, `const secret = crypto.getRandomValues(new Uint8Array(32))`; `const handle = w.unlock_with_secret(passphrase, secret, salt, paramsJsonWithKeyfileHint)`; encrypt+push empty manifest/settings (reuse the image path's tail); `storageUpdate.keyfileBase64 = base64(keyfile_encode(secret))`; set `params.json` `second_factor: "keyfile"`; return `{ relkeyBytes: keyfile_encode(secret) }` base64-enveloped. Add `secondFactor` to the `create_vault` request type and `relkeyBytes?` to its response in `messages.ts`. - [ ] **Step 4: Run to verify it passes** Run: `cd extension && npx vitest run src/service-worker/__tests__/keyfile-unlock.test.ts -t "create_vault keyfile"` Expected: PASS. - [ ] **Step 5: Type-check + commit** Run: `cd extension && npm run build:all` ```bash git add extension/src/service-worker/vault.ts extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts git commit -m "feat(ext/sw): create_vault key-file mode (generate secret, store keyfileBase64)" ``` --- ### Task 3: Wizard key-file download flow **Files:** - Modify: `extension/src/setup/setup-steps.ts` (finish/device step) - Test: `extension/src/setup/__tests__/setup-steps.test.ts` **Interfaces:** - Consumes: `create_vault` response `{ relkeyBytes }`. - Produces: after a key-file `create_vault`, the wizard triggers a download of `vault.relkey` (the returned bytes) and shows "save this key file — it is your second factor; you cannot unlock without it." - [ ] **Step 1: Write the failing test** ```ts test('keyfile setup triggers a .relkey download from create_vault response', async () => { const dl = vi.fn(); vi.stubGlobal('URL', { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} }); await finishKeyfileSetup({ relkeyBytes: new Uint8Array([1,2,3]) }, dl); // dl = injected download trigger expect(dl).toHaveBeenCalledWith('vault.relkey', expect.any(Blob)); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "relkey download"` Expected: FAIL — no download path. - [ ] **Step 3: Implement** — when `secondFactor === 'keyfile'`, the finish step sends `create_vault { secondFactor: 'keyfile' }`, decodes `relkeyBytes` (base64 envelope), and triggers a `vault.relkey` download (anchor + object URL); show the "save this key file" copy. - [ ] **Step 4: Run to verify it passes** Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "relkey download"` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add extension/src/setup/setup-steps.ts extension/src/setup/__tests__/setup-steps.test.ts git commit -m "feat(ext/setup): download the generated .relkey at finish" ``` --- ### Task 4: SW `unlock` branches on the params hint **Files:** - Modify: `extension/src/service-worker/router/popup-only.ts:40-51` - Test: `extension/src/service-worker/__tests__/keyfile-unlock.test.ts` **Interfaces:** - Consumes: `params.json` `second_factor`; `keyfileBase64`; `wasm.keyfile_decode`, `wasm.unlock_with_secret`. - [ ] **Step 1: Write the failing test** ```ts test('unlock uses unlock_with_secret when params say keyfile', async () => { chrome.storage.local.get = vi.fn().mockResolvedValue({ vaultConfig: fakeCfg, keyfileBase64: KF_B64 }); const w = { keyfile_decode: vi.fn(() => new Uint8Array(32)), unlock_with_secret: vi.fn(() => fakeHandle), unlock: vi.fn() }; await handleUnlock({ type: 'unlock', passphrase: 'pw' }, stateWith(w, /*params second_factor=keyfile*/)); expect(w.unlock_with_secret).toHaveBeenCalled(); expect(w.unlock).not.toHaveBeenCalled(); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `cd extension && npx vitest run src/service-worker/__tests__/keyfile-unlock.test.ts -t "unlock uses unlock_with_secret"` Expected: FAIL — unlock always calls `w.unlock`. - [ ] **Step 3: Implement** — parse `meta.paramsJson`; if `second_factor === 'keyfile'`: load `keyfileBase64`, `const secret = w.keyfile_decode(base64ToUint8Array(keyfileBase64))`, `w.unlock_with_secret(passphrase, secret, salt, paramsJson)`. Else the existing image path. Map a missing/garbled key file to `invalid_key_file`. - [ ] **Step 4: Run to verify it passes** Run: `cd extension && npx vitest run src/service-worker/__tests__/keyfile-unlock.test.ts` Expected: PASS (image-mode unlock test still green). - [ ] **Step 5: Type-check + commit** Run: `cd extension && npm run build:all` ```bash git add extension/src/service-worker/router/popup-only.ts git commit -m "feat(ext/sw): unlock resolves second factor from params hint" ``` --- ### Task 5: Attach-mode key-file picker **Files:** - Modify: `extension/src/setup/setup-steps.ts` (step-3 attach branch ~`:353-362`), `router/popup-only.ts` (`attach_vault`) - Test: `extension/src/setup/__tests__/setup-steps.test.ts` **Interfaces:** - Produces: when attaching to a vault whose probe/params indicate `second_factor: "keyfile"`, the attach step prompts for the `.relkey` file (mirroring the reference-image `` at `:357-360`) instead of the JPEG; the chosen bytes are stored as `keyfileBase64`. - [ ] **Step 1: Write the failing test** ```ts test('attach step asks for a key file when the vault uses keyfile', () => { const html = renderStep3Attach({ ...defaultWizardState(), attachSecondFactor: 'keyfile' } as any); expect(html).toContain('key file (.relkey)'); expect(html).not.toContain('reference image (JPEG)'); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "attach step asks for a key file"` Expected: FAIL. - [ ] **Step 3: Implement** — detect the vault's `second_factor` during the connection probe; in the attach step render a `.relkey` file input when keyfile; `attach_vault` stores `keyfileBase64` and verifies by attempting `unlock_with_secret`. - [ ] **Step 4: Run to verify it passes** Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts` Expected: PASS. - [ ] **Step 5: Full suite + commit** Run: `cd extension && npx vitest run && npm run build:all` ```bash git add extension/src/setup/setup-steps.ts extension/src/service-worker/router/popup-only.ts git commit -m "feat(ext/setup): attach via key file when the vault uses one" ``` --- ### Task 6: Positioning pivot — docs **Files:** - Modify: `README.md`, `DESIGN.md`, `docs/CRYPTO.md`, `docs/FORMATS.md`, `docs/SECURITY.md` No automated test — this is prose. The "verification" is the consistency checklist in Step 4. - [ ] **Step 1: Re-lead `README.md`.** Open with the thesis: "two independent secrets into the KDF, self-hosted, a server that holds only opaque ciphertext, and a git-backed audit log." Move the steganography explanation below that, framed as one **option** for the second factor (with the key file as the plain alternative); keep the dead-drop story as flavor, not the headline. Update the "How it works" diagram caption to say "passphrase + second factor (reference image or key file)". - [ ] **Step 2: `docs/CRYPTO.md` + `docs/FORMATS.md`.** CRYPTO: add the pluggable-transport framing — "the second factor is 32 bytes; the reference image, the key file, and the recovery QR are interchangeable containers for it; the Argon2id input and master-key derivation are identical regardless of container." FORMATS: document the `.relkey` armor (`relicario-keyfile-v1` + base64(32 bytes)) and the `params.json` `second_factor` field (`"image"|"keyfile"`, absent ⇒ image), citing `crates/relicario-core/src/keyfile.rs` and `crypto.rs` `KdfParams`. - [ ] **Step 3: `DESIGN.md` secrets-map + `docs/SECURITY.md`.** DESIGN: add the key file to the secrets map alongside the reference image. SECURITY: state that `.relkey` / `keyfileBase64` is the second factor in the clear — the same posture as the reference JPEG / `imageBase64` — protected by the passphrase being required too; it is NOT an encrypted artifact. - [ ] **Step 4: Consistency check + commit.** Verify: README leads with the thesis (not stego); every place that said "passphrase + reference image" now reads "passphrase + second factor (image or key file)"; FORMATS cites the source files; no doc claims the key file is encrypted. Per CLAUDE.md living-docs discipline, confirm scope headers/Next-footers still hold. ```bash git add README.md DESIGN.md docs/CRYPTO.md docs/FORMATS.md docs/SECURITY.md git commit -m "docs: re-lead positioning on the two-factor-KDF thesis; document the key-file second factor" ``` --- ## Notes - The attach-mode probe must learn the vault's `second_factor` before the user supplies the factor — read `params.json` during the existing connection-test/probe step (`setup/probe.ts`). - Security-review gate (per spec): after this plan, run `/security-review` on the key-file path — equivalence to the stego path, armor parsing, and the in-the-clear-storage documentation.