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
This commit is contained in:
adlee-was-taken
2026-06-21 09:35:44 -04:00
parent 9b38aac188
commit 74cee8ac67
5 changed files with 1678 additions and 0 deletions

View File

@@ -0,0 +1,277 @@
# 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 `<input type="file">` 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.