Files
relicario/docs/superpowers/specs/2026-06-20-pluggable-second-factor-design.md
adlee-was-taken 9b38aac188 docs(specs): v0.9.0 design — extension org GUI + pluggable second factor
Product audit (product-expert skill) recommended two priority items; this
lands the audit record plus the two approved design specs that will drive
the v0.9.0 multi-agent train.

- reviews/2026-06-20-product-audit.md — the roadmap audit (reality check,
  recommendations, PM brief) that drove the two items.
- specs/2026-06-20-extension-org-gui-design.md — bring the org vault to the
  extension at read+write parity. Org write is gated on a Day-1 signing
  spike (the org hook rejects unsigned commits; the extension pushes
  unsigned today; sign_for_git exists in WASM but is unused). Spike-fail
  degrades to read-only + write follow-up.
- specs/2026-06-20-pluggable-second-factor-design.md — key file as an
  alternative second factor (same 32-byte secret, same KDF; crypto-light),
  chosen at setup via a non-secret params hint, plus the positioning pivot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQbgrP6KQW5pibjbPEoTSs
2026-06-20 23:01:53 -04:00

77 lines
7.4 KiB
Markdown

# Pluggable Second Factor (Key File) + Positioning Pivot — Design Spec
- **Date:** 2026-06-20
- **Status:** Approved (brainstorming) — ready for writing-plans
- **Release target:** v0.9.0 (one multi-agent train, alongside the Extension Org Vault GUI spec)
- **Anchor:** `main` post-v0.8.1 (`2fa4d68` tag; HEAD `59ebc28`)
- **Driver:** Product audit `docs/superpowers/reviews/2026-06-20-product-audit.md` recommendation #2 (PIVOT) — re-lead positioning with the durable thesis (two secrets into the KDF + zero server metadata + git audit) and treat the steganographic image as **one option** for the second factor, with a plain key file as the alternative.
- **Builds on:** `2026-04-11-relicario-design.md` (crypto pipeline), `docs/CRYPTO.md`, `docs/FORMATS.md`, `extension/ARCHITECTURE.md` (setup wizard).
## Purpose & scope
Make the vault's second factor **pluggable**: the 256-bit secret can be carried by the existing steganographic reference image (default) **or** by a plain **key file** — chosen at vault creation, with the same secret and the same KDF underneath. Re-lead the project's positioning on the durable thesis and frame stego as an option rather than the headline.
**Key insight — this is crypto-light.** The second factor is *already* just 32 bytes (`image_secret`); stego is only the storage/transport. The Argon2id KDF (`passphrase || image_secret → master_key`) and everything downstream are **byte-for-byte unchanged**. Only the *source* of the 32 bytes changes. No new crypto primitive.
**Mental model (chosen in brainstorming):** at creation you pick the container — **Reference Image** (default) or **Key File** — and both materialize the same random 32-byte secret. The vault records a non-secret container-type hint so unlock prompts for the right thing. Because it is literally the same secret, the recovery-QR already fits this model, and export/convert between containers is a natural (optional) add-on.
**In scope:** key-file generation at init; unlock from a key file; the non-secret container hint; CLI + extension support; the positioning/docs pivot.
**Out of scope (optional stretch, not core):** `keyfile export` / convert-an-existing-image-vault-to-keyfile. It needs the secret in hand (a re-provide-then-write flow), so it is deferred to keep the lift tight; noted as a fast-follow.
## Crypto model
- **Container hint.** `.relicario/params.json` (non-secret, already holds Argon2id params) gains `"second_factor": "image" | "keyfile"`. **Absent ⇒ `"image"`** (back-compat for every existing vault). Read pre-unlock to choose the prompt; reveals nothing secret (container type is not a secret).
- **Raw-secret unlock path.** Today `unlock(passphrase, jpeg_bytes, salt, params)` extracts the 32-byte secret from the JPEG internally (`extension/ARCHITECTURE.md` notes "unlock takes JPEG bytes … extracts internally"). Add an explicit `unlock_with_secret(passphrase, secret: &[u8;32], salt, params)` that skips extraction. The KDF and AEAD are identical; this is the only core seam.
- **Key-file armor.** Core owns the format so CLI and WASM share it: `keyfile_encode(secret) -> Vec<u8>` and `keyfile_decode(bytes) -> [u8;32]`. Layout: a `relicario-keyfile-v1` header line + base64 of the 32 bytes + trailing newline. `keyfile_decode` validates the header, rejects malformed input, and holds the secret in `Zeroizing`. Suggested extension: `.relkey`.
## Stream decomposition
### B1 · core + WASM
- `relicario-core`: `keyfile_encode` / `keyfile_decode` (Zeroizing), `unlock_with_secret`, and read/write of the `second_factor` field in the params struct (default `image`).
- `relicario-wasm`: bind `keyfile_encode`, `keyfile_decode`, `unlock_with_secret`.
- Equivalence test: for a given 32-byte secret, `unlock_with_secret(pass, secret, …)` derives the **same** master key as unlocking from a JPEG that embeds that secret — proves the seam is transport-only.
### B2 · CLI
- `relicario init`: a container choice — `--key-file <path>` (or interactive) generates the 32-byte secret, writes the `.relkey` via `keyfile_encode`, and sets params `second_factor: "keyfile"`; the existing `--image`/`--output` path stays the default and sets `"image"`.
- `unlock` across all commands: read the factor per the params hint — if `keyfile`, from `--key-file` or `RELICARIO_KEYFILE` (mirroring `RELICARIO_IMAGE`); `keyfile_decode``unlock_with_secret`.
- Help text + `docs/user_docs/` reflect the choice.
### B3 · extension
- **Setup wizard step 3** gains a container choice (Reference Image | Key File). Key-file mode: generate the secret, offer the `.relkey` for download, set the params hint.
- **Unlock**: per the params hint, prompt for the key file (file picker) instead of the image; `keyfile_decode``unlock_with_secret`.
- **Local storage (chosen default):** store the key-file bytes in `chrome.storage.local` as `keyfileBase64`, re-read each unlock — exactly as `imageBase64` works today. Same "something you have" threat model and the same offline behavior; documented as equivalent, not weaker.
### B4 · docs / positioning pivot
- **README** re-led: open with the thesis (two independent secrets into the KDF, self-host, zero server metadata, git audit); present the steganographic image as a distinctive **option** for the second factor (with the key file as the plain alternative), not the headline. Keep the dead-drop story as flavor, not the lead.
- **DESIGN.md** secrets-map + **docs/CRYPTO.md** (pluggable-transport framing: "the second factor is 32 bytes; image/key-file/recovery-QR are interchangeable containers") + **docs/FORMATS.md** (`.relkey` armor + params `second_factor` field).
## Security-review gate (before merge)
A focused `/security-review` pass on the key-file path:
- **No weaker than stego:** same 32-byte entropy, same Argon2id, same AEAD. The equivalence test (B1) is the evidence.
- **Armor parsing** rejects malformed/short input without panics or oracles.
- **Threat-model honesty:** `.relkey` and `keyfileBase64` are the second factor *in the clear* — exactly the same posture as the reference JPEG / `imageBase64` today. Document this in `docs/SECURITY.md`; do not imply the key file is encrypted (it is the "something you have", protected by needing the passphrase too).
- **No oracle differences** between the image and key-file unlock failure paths (both surface the deliberately-ambiguous "wrong passphrase or reference image/key").
## Error handling
- Malformed/empty key file → `invalid_key_file` (CLI + extension), distinct from a wrong-secret AEAD failure.
- Missing key file at unlock when params say `keyfile` → prompt/`RELICARIO_KEYFILE` guidance.
- Params `second_factor` present but unknown value → reject with a clear message (forward-compat guard).
## Testing
- **core:** keyfile encode/decode round-trip; `unlock_with_secret` master-key equivalence vs JPEG unlock; params back-compat (absent ⇒ image); malformed-armor rejection.
- **CLI:** `init --key-file → unlock --key-file` lifecycle against a temp vault; `RELICARIO_KEYFILE` env path; image vault still unlocks unchanged.
- **extension (vitest):** setup key-file path writes the hint + offers download; unlock reads `keyfileBase64` and derives a session; an existing image vault is unaffected.
## Living-docs impact
`README.md`, `DESIGN.md`, `docs/CRYPTO.md`, `docs/FORMATS.md`, `docs/SECURITY.md`, `crates/relicario-core/ARCHITECTURE.md` (new core functions), `extension/ARCHITECTURE.md` (setup container choice + `keyfileBase64`), `CHANGELOG.md`.