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
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.jsonsecond_factor: "image"|"keyfile"(absent ⇒ image). - Binary crosses
chrome.runtime.sendMessagebase64-enveloped (shared/message-binary.ts) — ArrayBuffers are dropped otherwise. keyfileBase64is the second factor in the clear inchrome.storage.local, exactly the posture of today'simageBase64. Document it as equivalent, not weaker.- Existing image vaults must be unaffected (the
second_factordefault isimage). - Keep
manifest.json/manifest.firefox.jsonin 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_vaultkey-file branch (:636);unlockbranch on the params hint (:40-51); storekeyfileBase64insave_setup(:144).extension/src/service-worker/vault.ts—create_vaultorchestration: key-file mode generates the secret + returns.relkey.extension/src/shared/messages.ts—create_vaultrequest gainssecondFactor; response carries optionalrelkeyBytes.- 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'toWizardState(default'image'indefaultWizardState, ~: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_vaultorchestration),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_vaultrequest gainssecondFactor: 'image'|'keyfile'; in key-file mode the SW generates a 32-byte secret (crypto.getRandomValues), derives viaunlock_with_secret, writesparams.jsonwithsecond_factor: "keyfile", storeskeyfileBase64, 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_vaultpath: ifsecondFactor === '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)); setparams.jsonsecond_factor: "keyfile"; return{ relkeyBytes: keyfile_encode(secret) }base64-enveloped. AddsecondFactorto thecreate_vaultrequest type andrelkeyBytes?to its response inmessages.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_vaultresponse{ relkeyBytes }. -
Produces: after a key-file
create_vault, the wizard triggers a download ofvault.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 sendscreate_vault { secondFactor: 'keyfile' }, decodesrelkeyBytes(base64 envelope), and triggers avault.relkeydownload (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.jsonsecond_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; ifsecond_factor === 'keyfile': loadkeyfileBase64,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 toinvalid_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.relkeyfile (mirroring the reference-image<input type="file">at:357-360) instead of the JPEG; the chosen bytes are stored askeyfileBase64. -
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_factorduring the connection probe; in the attach step render a.relkeyfile input when keyfile;attach_vaultstoreskeyfileBase64and verifies by attemptingunlock_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.relkeyarmor (relicario-keyfile-v1+ base64(32 bytes)) and theparams.jsonsecond_factorfield ("image"|"keyfile", absent ⇒ image), citingcrates/relicario-core/src/keyfile.rsandcrypto.rsKdfParams. -
Step 3:
DESIGN.mdsecrets-map +docs/SECURITY.md. DESIGN: add the key file to the secrets map alongside the reference image. SECURITY: state that.relkey/keyfileBase64is 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_factorbefore the user supplies the factor — readparams.jsonduring the existing connection-test/probe step (setup/probe.ts). - Security-review gate (per spec): after this plan, run
/security-reviewon the key-file path — equivalence to the stego path, armor parsing, and the in-the-clear-storage documentation.