Files
relicario/docs/superpowers/plans/2026-06-20-v0.9.0-keyfile-ext-positioning.md
adlee-was-taken 74cee8ac67 docs(plans): v0.9.0 implementation plans — 5 streams across 2 specs
Full-TDD per-stream plans for the v0.9.0 multi-agent train:
- org-a-foundation (A0+A1): WASM org_unwrap_key + multi-context SW session +
  org config + grant-filtered manifest read.
- org-b-read-ui (A2): org switcher + grant-filtered browse/read + offline banner.
- org-c-write (A3): GO/NO-GO signing spike first, then commitSigned + org write
  handlers + UI. Spike-gated; NO-GO ships read-only.
- keyfile-core-cli (B1+B2): core armor + unlock_with_secret + params hint +
  WASM bindings + CLI init/unlock --key-file.
- keyfile-ext-positioning (B3+B4): setup container choice + unlock + the
  README/DESIGN/CRYPTO/FORMATS positioning pivot.

Cross-plan contracts pinned and self-reviewed for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQbgrP6KQW5pibjbPEoTSs
2026-06-21 09:35:44 -04:00

15 KiB

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.tsWizardState.secondFactor; step-3 container-choice UI; key-file download flow.
  • extension/src/service-worker/router/popup-only.tscreate_vault key-file branch (:636); unlock branch on the params hint (:40-51); store keyfileBase64 in save_setup (:144).
  • extension/src/service-worker/vault.tscreate_vault orchestration: key-file mode generates the secret + returns .relkey.
  • extension/src/shared/messages.tscreate_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

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

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

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

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

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

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

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 <input type="file"> at :357-360) instead of the JPEG; the chosen bytes are stored as keyfileBase64.

  • Step 1: Write the failing test

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

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.

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.