docs(specs): recovery QR + passphrase entropy floor; password coloring

Two design specs landed together because they're driven by the same
brainstorm session and target the same release window:

- 2026-05-01-recovery-qr-design.md: 1-of-2 disaster recovery via a
  paper-or-photo QR carrying image_secret encrypted under Argon2id-of-
  passphrase. Display-first UX (snap with phone), print as secondary.
  Memory-only — architecturally no API path produces a file. Includes
  domain-separation tag, type-level KDF params floor, shared NFC
  normalization helper, and a passphrase entropy floor (zxcvbn >= 3)
  enforced at vault init.
- 2026-05-01-password-coloring-design.md: 1Password-style character-
  class coloring on revealed passwords (digits/symbols/letters with
  user-customizable colors via chrome.storage.sync). Single shared
  colorizePassword() helper, default scheme blue/red/inherit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-01 16:15:14 -04:00
parent 87e63c2f77
commit 00da7e7931
2 changed files with 386 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
# Password display character-class coloring
**Status:** design
**Target release:** v0.4.0 (or earlier — bundles cleanly with active fullscreen UX work)
**Scope:** extension only — popup (`extension/src/popup/`), fullscreen vault (`extension/src/vault/`), settings UI for color customization
**Out of scope:** CLI parity (TTY color escapes for revealed passwords are a separate problem; defer until there's user demand), per-item color overrides, theming the rest of the extension, coloring inside copy-to-clipboard payloads (clipboard always carries plaintext).
## Background
When a password is revealed in 1Password's UI, characters are colored by class to make passwords easier to scan, dictate, compare, and transcribe:
- digits — distinct color (1Password uses blue)
- symbols — distinct color (1Password uses red)
- letters — default text color
Concrete benefits:
- reading a generated password aloud without confusing similar-shaped characters
- spotting transcription errors when typing a password into a non-relicario field
- visually parsing dense symbol runs in long generated passwords
relicario's password reveal currently renders a flat-colored monospace string. Today this happens in:
- `extension/src/popup/components/field-history.ts` (history viewer's revealed cells)
- whatever vault item-detail view renders the current password value (popup + fullscreen `vault/`)
- the generator preview as a candidate password is rolled
This spec adds a single shared utility that colorizes those renders, plus a settings surface for users to pick custom digit/symbol colors.
## Goals
1. Color-code revealed password characters by class — digit, symbol, letter — across all extension surfaces that display revealed passwords.
2. Default scheme: digits blue, symbols red. Letters use existing text color.
3. User-customizable digit and symbol colors via a settings page, persisted in `chrome.storage.sync` so preferences follow the user across browser profiles.
4. Single source of truth: one `colorizePassword()` helper used everywhere, so adding a new password-display surface in the future inherits the coloring automatically.
## Non-goals
- Coloring confusable-character pairs (`l`/`1`/`I`, `0`/`O`) with a third color. Possible future work; out of scope here.
- CLI parity. The CLI currently doesn't render revealed passwords inline (it shells the value to clipboard or stdout); ANSI coloring would be a separate decision.
- Coloring OTP/2FA codes. They are all digits and would gain nothing.
- Affecting the copy-to-clipboard pathway. Clipboard payloads remain plaintext.
## Design
### Character classification
Three classes via simple regex over Unicode codepoints:
- `digit``/^\d$/` (matches Unicode `Nd` category via JS `\d`)
- `letter``/^[\p{L}]$/u`
- `symbol` — anything else (punctuation, symbols, whitespace)
Each codepoint is classified once; the helper batches consecutive same-class codepoints into a single `<span>` to keep the DOM small for long passwords.
### `colorizePassword` utility
New file: `extension/src/popup/components/password-coloring.ts` (or `extension/src/shared/` if a shared module already exists; reviewer of the plan to decide).
```ts
export function colorizePassword(password: string): DocumentFragment {
// Returns a fragment of <span class="pwd-digit|pwd-symbol|pwd-letter">…</span>
// runs covering the full input. Empty input → empty fragment.
}
```
Pure function, no DOM mutation outside the returned fragment. Easy to unit-test with `JSDOM`.
### CSS
Defined in the existing extension stylesheet(s) — popup and vault both import a shared rule set:
```css
:root {
--relicario-pwd-digit-color: #2563eb; /* blue-600 */
--relicario-pwd-symbol-color: #dc2626; /* red-600 */
}
.pwd-digit { color: var(--relicario-pwd-digit-color); }
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
.pwd-letter { color: inherit; }
```
User customization is implemented by a tiny `applyColorScheme()` boot step that reads `chrome.storage.sync.password_display_scheme` at popup/vault startup and writes the values onto `document.documentElement.style` as inline `--relicario-pwd-*-color` overrides. No CSS-in-JS, no runtime style injection for each render — set once, read by every subsequent `colorizePassword()` output.
### Storage shape
```jsonc
// chrome.storage.sync key: "password_display_scheme"
{
"digit_color": "#2563eb", // hex string, validated on read
"symbol_color": "#dc2626"
}
```
Missing key or invalid values → fall back to defaults, no error surface. Sync (not local) so preferences propagate across the user's browser profiles. No security implication — purely cosmetic.
### Settings UI
Adds a **Display** section to the existing extension settings page (the page reachable from the gear icon — exact route to be confirmed by the plan; the existing settings surface is in `extension/src/popup/components/settings.ts` or equivalent).
- Two color pickers labelled "Digit color" and "Symbol color".
- Live preview swatch beneath the pickers showing a sample password (`Abc123!@#xyz`) rendered with the candidate colors. Updates as the user changes pickers.
- "Reset to defaults" button — clears the storage key, swatch reverts to defaults.
- Inline accessibility hint: if the chosen color falls below WCAG AA contrast (≥ 4.5 : 1) against the surface's background color, show a subtle "may be hard to read on this background" warning under the picker. Non-blocking — the user can still save.
### Surfaces to update
Each touchpoint just swaps a textContent assignment for `colorizePassword(value)` and appends the returned fragment.
- Vault item detail (popup view) — wherever the password field renders its revealed value.
- Vault item detail (fullscreen view) — same logic for `extension/src/vault/`.
- Field-history viewer (`field-history.ts:72`) — the `<div class="history-entry__value revealed">` content swap.
- Generator preview — the live candidate-password preview as the generator rolls.
The fullscreen UX redesign (Phase 1, per `docs/superpowers/plans/2026-04-30-fullscreen-ux-phase-1-visual-foundation.md` and the recent commit `9ed7e7c`) is currently in flight. This spec coordinates with that work: if any reveal-rendering code is being rewritten as part of Phase 1, the rewrite should call `colorizePassword()` instead of plain text-content. The plan author should confirm with the user (they're doing the web-UX work themselves, per their own message) whether to land this concurrently or stage it as a follow-up.
## Migration
Additive only. No data migration. Users without a stored scheme get defaults. Existing tests for password-display behavior may need updating to expect the span structure instead of plain text — those updates are part of this work.
## Testing
Unit (Vitest, matching existing extension test conventions):
1. `colorizePassword("aB3$xY")` returns spans in correct classification order: `pwd-letter "aB"`, `pwd-digit "3"`, `pwd-symbol "$"`, `pwd-letter "xY"`.
2. Empty string returns empty fragment, zero children.
3. All-letters / all-digits / all-symbols inputs produce a single span of the appropriate class.
4. Unicode letters (e.g., `áñü`) classify as `pwd-letter` via `\p{L}`.
5. Whitespace classifies as `pwd-symbol` (verified, not accidental).
6. Snapshot test on a representative password: `aB3$xY7&_!` → expected fragment structure.
Integration:
7. `applyColorScheme()` reads `chrome.storage.sync` and sets CSS variables on `document.documentElement`. Mock `chrome.storage.sync.get`, assert resulting inline style.
8. Settings UI: changing a picker writes to storage; reset clears storage; both reflected in subsequent renders via the storage event listener.
Visual regression:
9. Manual: open vault, reveal a password with mixed character classes, confirm coloring matches expectation in popup and fullscreen views.
## Open questions
- Whether to colorize concealed (non-password) fields — the existing `concealed` field type also reveals on click. Default position: yes, apply the same coloring; concealed fields are typically API tokens/keys with mixed character classes, so they benefit equally. Confirm with user during implementation.
- Whether to add a third color for "look-alike" characters (defer; small follow-up if/when the user asks).
- Exact route for the Display section in settings (popup-settings vs vault-settings vs both). Plan to resolve based on existing settings architecture.

View File

@@ -0,0 +1,241 @@
# Recovery QR + passphrase entropy floor — disaster recovery for lost reference image
**Status:** design
**Target release:** v0.4.0 (post-v0.3.0 train)
**Scope:** `relicario-core` (new `recovery_qr` module + extracted `normalize_passphrase`), `relicario-cli` (new `recovery-qr` subcommand group), `relicario-wasm` (bindings), extension (display/print route + vault-tab button + init-wizard zxcvbn gate)
**Out of scope:** passphrase-loss recovery (deliberate non-goal), online or server-mediated recovery, multi-device key sharing, threshold schemes, device onboarding "magic link" (separate effort), in-extension webcam QR scanning (a future feature; v1 unlocks via paste).
## Background
relicario's two-factor model derives `master_key = Argon2id(len-prefixed(passphrase) || image_secret, salt, params)` (`crates/relicario-core/src/crypto.rs:207`). Lose either factor and the vault is unrecoverable. The reference image is the more loseable factor — it lives outside the user's head, often as a "dead drop" on social media or a personal site, and a single platform takedown or accidental deletion permanently bricks the vault.
The original design spec already sketched a post-V1 recovery path (`docs/superpowers/specs/2026-04-11-relicario-design.md:342-349`): a small encrypted file containing only `image_secret`, locked under the passphrase via a separate Argon2id derivation, stored offline. This spec finalizes that sketch with three refinements landed during brainstorming:
1. The artifact is a **QR code displayed on screen** (primary) or printed (secondary) — never written to disk. The user snaps the displayed QR with a phone or prints a hard copy. "Memory-only" is enforced architecturally: no API path produces a file.
2. **Domain separation** in the recovery KDF input prevents collision with the main `derive_master_key` output namespace under adversarial inputs.
3. A **passphrase entropy floor** is enforced at vault init. Recovery-QR security is exactly `passphrase_strength × Argon2id_cost`; without an entropy floor at init, a user can configure their vault into a state where the recovery QR is brute-forceable on commodity hardware.
## Goals
1. Provide an offline, paper-or-photo fallback that recovers `image_secret` when the reference image is lost but the passphrase is known.
2. Make it impossible — by API shape, not convention — to (a) write the recovery payload to disk, (b) generate it with weak Argon2id parameters, or (c) compute it without NFC-normalizing the passphrase identically to the main KDF.
3. Enforce a passphrase entropy floor at vault init so the recovery-QR security guarantee is not silently undermined.
4. Surface the feature in CLI, extension vault tab, and the new-vault wizard with parity (see `feedback_cli_extension_parity` in user memory).
## Non-goals
- Recovering from a forgotten passphrase. Forgotten passphrases remain unrecoverable; this is the deliberate stance for a self-hosted password manager with no recovery server.
- Re-introducing TOTP, online recovery, or any third factor. The brainstorm explicitly settled on 1-of-2 with a paper substitute for the second factor.
- Retroactively forcing existing vaults whose passphrases are below the new entropy floor to rotate. Existing vaults are grandfathered with a non-blocking warning.
- Vault format change. The recovery QR is a derived artifact; the vault on disk is unchanged.
## Threat model
| Attacker capability | What this protects | What it does not protect |
| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Photographs the displayed QR or steals the printed paper | Recovery payload alone is useless: it's `image_secret` encrypted under Argon2id-of-passphrase. Attacker must additionally brute-force the passphrase, gated by Argon2id cost (m=64 MiB, t=3, p=4). With a passphrase at the enforced entropy floor (zxcvbn ≥ 3, ≈ 10¹⁰ guesses), brute-force is infeasible on commodity hardware. | A weak passphrase (zxcvbn < 3) below the floor — but the floor is enforced at init, so this only applies to grandfathered vaults that pre-date this feature. |
| Captures recovery payload + already knows passphrase | Nothing — equivalent to the existing "compromised reference image + passphrase" failure mode that the vault has always accepted as the universal worst case. | Same. |
| Reads files written to disk by relicario | Recovery payload is never written to disk by any code path. No file artifact exists to read. | OS print spooler may briefly cache a print job (Windows: `C:\Windows\System32\spool\PRINTERS\`). Print is the secondary path; users with concerns use the display path. |
| MitM on git transport | Recovery payload never traverses git or any network — it lives only in user-rendered output. | N/A |
| Crafts adversarial inputs to confuse vault KDF and recovery KDF outputs | Domain separation tag `b"relicario-recovery-v1\0"` prefixes the recovery KDF input, ensuring no input can produce identical Argon2id outputs across the two namespaces. | N/A |
## Cryptographic design
### Recovery KDF input
```text
recovery_kdf_input =
b"relicario-recovery-v1\0" // 22-byte domain separator
|| u64_be(len(nfc(passphrase))) // 8 bytes
|| nfc(passphrase) // variable
```
Fed to Argon2id with `RecoveryKdfParams::production()` and a fresh 32-byte salt generated at recovery-QR creation time (separate from the vault salt). Output is a 32-byte `wrap_key`.
Argon2id is a PRF, so distinct inputs yield uncorrelated outputs with negligible collision probability. The domain separator's role is to make inputs structurally distinguishable: the vault KDF input begins with `u64_be(passphrase_len)`, whose first 6+ bytes are zero for any realistic passphrase length (< 2⁴⁸ bytes), while the recovery KDF input begins with the literal ASCII `relicario-recovery-v1\0` — non-zero from byte 0. This is robust against any adversarially crafted passphrase value because the structural prefix difference is independent of passphrase content.
### Wrap
```text
nonce = OsRng(24)
ciphertext = XChaCha20-Poly1305(wrap_key, nonce, image_secret) // 32 + 16 = 48 bytes
```
Same AEAD primitive as the vault. Reuses `crypto::encrypt`/`crypto::decrypt` after the wrap key is derived.
### QR payload (binary)
```text
[magic "RREC" 4 bytes ] // matches the "RBAK" pattern from backup.rs:29
[version 0x01 1 byte ]
[salt 32 bytes ]
[nonce 24 bytes ]
[ciphertext 48 bytes ] // 32 plaintext + 16 Poly1305 tag
// ───────────
// 109 bytes total
```
Salt is included so recovery is self-sufficient — the user does not need to bring along the original `.relicario/salt`. The salt is not secret; storing it in the QR is not a confidentiality concern, and excluding it would tie recovery to a specific repo clone, which is the wrong invariant.
QR encoding: byte mode, error-correction level **M** (15% recovery — comfortable for paper-and-camera workflows). Payload + ECC fits in QR version 6 (41×41 modules, ≈ 30 mm at typical 300 DPI). Plenty of room.
### `RecoveryKdfParams` — type-level params floor
New type in `crates/relicario-core/src/recovery_qr.rs`:
```rust
pub struct RecoveryKdfParams {
argon2_m: u32, // private
argon2_t: u32, // private
argon2_p: u32, // private
}
impl RecoveryKdfParams {
pub const fn production() -> Self { /* m=65536, t=3, p=4 */ }
// No `new`, no `with_*`, no public field, no `Deserialize`.
// Test code that needs fast params must use a `#[cfg(test)]`-gated constructor.
}
```
This is the type-system enforcement of the "hard floor on KDF params" requirement. There is no runtime path — adversarial JSON, accidental `params.json` reuse, or developer error — that produces a `RecoveryKdfParams` with weak parameters. Test-only fast params (for unit and integration tests) are exposed via a feature-gated or `cfg(test)`-gated constructor; the exact mechanism (test feature flag vs. crate-internal helper accessed via a dedicated test-only re-export) is an implementation-time decision deferred to the plan, but the constraint is firm: no public path to weak params in release builds.
### Shared `normalize_passphrase` helper
Currently `derive_master_key` does NFC normalization inline (`crypto.rs:224-227`). Extract this into `pub(crate) fn normalize_passphrase(p: &[u8]) -> Vec<u8>` in `crypto.rs` and have both `derive_master_key` and the recovery KDF call it. Add a regression test that asserts the two paths use the same helper (a doctest or a test that compares both code paths' inputs to Argon2id is sufficient — the goal is to make drift fail loudly).
## Memory hygiene
All intermediate buffers are `Zeroizing<…>` end-to-end:
- `wrap_key``Zeroizing<[u8; 32]>` (already the convention; reuse `derive_master_key`'s pattern).
- The 32-byte `image_secret` going into the wrap — already wrapped in `Zeroizing` upstream by `imgsecret::extract`; the recovery path must not copy it into a non-Zeroizing buffer.
- The encrypted payload buffer (109 bytes, no plaintext) does not need Zeroizing — it's the artifact we display.
The wasm binding returns the encoded payload as `Vec<u8>` (the QR-encodable bytes) for the extension to render. The 32-byte `image_secret` never crosses the wasm boundary; only the encrypted blob does.
## Display + print pipeline (no on-disk path)
There is no API in any crate that writes a recovery payload to a file. Reviewer-visible invariant.
- **`relicario-core`** exposes `recovery_qr::generate(passphrase, image_secret) -> Vec<u8>` (returns the 109-byte payload). It does **not** expose `generate_to_file` or accept a `Path`.
- **`relicario-wasm`** exposes `generate_recovery_payload(passphrase, image_secret) -> Vec<u8>`. Same constraint.
- **`relicario-cli`** subcommand `recovery-qr generate` renders to TTY using a Unicode block-drawing QR (e.g. via `qrcode` crate's `render::unicode::Dense1x2`). Offers no `--out` flag. A `--print` flag pipes a PostScript QR to `lp` (Linux/macOS); on Windows the CLI's print path is best-effort and the in-app help recommends the extension's print flow instead, since the extension's `window.print()` integrates with the OS print dialog more cleanly than a one-off CLI shell-out.
- **Extension** routes to a dedicated `recovery-qr.html` page that renders the QR onto a `<canvas>`. Two buttons: **Display** (the page IS the display) and **Print** (calls `window.print()` on the same page with a `@media print` stylesheet that scales the canvas appropriately). No `<img>` or Blob URL — those create right-click-save attack vectors. The canvas itself is non-rightclick-save in practice but `oncontextmenu` is also blocked on this route as defense in depth.
The Windows print-spooler caveat (`C:\Windows\System32\spool\PRINTERS\` cache) is documented in the in-app copy on the Print button: "Display is recommended on Windows. The system print queue may briefly cache the QR before printing."
## Passphrase entropy floor
zxcvbn integration already exists in `crates/relicario-core/src/generators.rs` (`rate_passphrase` returning `score` and `guesses_log10`). This work wires it into the gate at vault init.
**Threshold:** zxcvbn `score >= 3` (= "safely unguessable: moderate protection from offline slow-hash scenarios", ≈ 10¹⁰ guesses). Score 4 is "very unguessable" and is the upper rung; we do not require it because user research consistently shows 4-word diceware (~51 bits, score 3) is the realistic ceiling for real-world adoption.
**Where enforced:**
| Surface | Enforcement |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `relicario init` (CLI) | Hard gate — refuses to create the vault, returns exit code 2 with `RelicarioError::WeakPassphrase { score, required: 3 }`. Suggests using `relicario generate-passphrase` (which already produces score-4 BIP39 outputs). |
| Extension setup wizard, "create new vault" branch | Hard gate at the passphrase step. The wizard already shows zxcvbn feedback; this change makes the Next button refuse to advance below score 3. Mirrors the existing attach-flow's structure (see `2026-04-27-attach-existing-vault-design.md` Step 3a). |
| Existing vaults at unlock (CLI + extension) | Soft warning: "Your passphrase scores below the current entropy floor. Consider rotating it to enable a secure recovery QR." Non-blocking. Surfaces once per session. |
| `recovery-qr generate` | Pre-flight check: if the unlock passphrase scores below 3, print a stronger warning and require a `--force-weak-passphrase` flag to proceed. The warning explains: "A recovery QR generated with a weak passphrase is feasibly brute-forceable from a photograph or printout." |
The weak-passphrase warning copy is the same in CLI and extension to keep the threat narrative consistent.
## Surfaces
### CLI
```bash
relicario recovery-qr generate # interactive: prompts passphrase, displays QR in TTY
relicario recovery-qr generate --print # secondary: pipes to system printer
relicario recovery-qr unlock --payload <hex> # one-shot recover image_secret from a scanned QR's hex
# (caller decoded the QR; we accept the payload bytes)
relicario unlock --recovery-qr-payload <hex> # alternative: full unlock using recovery payload + passphrase,
# bypassing the reference-image prompt for this invocation only
```
The `unlock --recovery-qr-payload` form is the actual disaster-recovery flow: the user is on a fresh device with no reference image, has just scanned their printed QR with a phone, and pastes the hex payload to unlock. After successful unlock, the CLI prints a recovery-completion notice and a pointer to the re-establishment flow:
> Recovered image_secret. Your reference image is currently lost — re-embed the recovered secret into a new carrier JPEG before relying on it. Run: `relicario imgsecret embed --carrier <new.jpg> --out <reference.jpg>` (uses the secret recovered in this session).
This requires a **new CLI subcommand `relicario imgsecret embed`** that wraps the existing `imgsecret::embed` function (already in `relicario-core/src/imgsecret.rs` and exposed via wasm at `relicario-wasm/src/lib.rs:273`). The command takes a fresh carrier JPEG and writes a reference image carrying the in-session-recovered secret. Bringing this to the CLI is in-scope for this spec because the disaster-recovery flow is incomplete without a path to re-establish the primary factor; the extension's existing image-creation flow already covers the equivalent there.
### Extension
Vault tab grows a **Disaster recovery** section with one button: **Generate recovery QR**. Clicking opens `recovery-qr.html` in a popup window (not a modal — popup gives `window.print()` cleaner ownership of the print dialog). Page contents:
```
┌──────────────────────────────────────────┐
│ Recovery QR │
│ │
│ [ canvas-rendered QR ] │
│ │
│ Snap with your phone, or click Print. │
│ This QR alone cannot unlock your vault. │
│ Combined with your passphrase, it can. │
│ │
│ [ Print ] [ Done ] │
│ │
│ ⚠ Windows users: prefer Display over │
│ Print. The system print queue may │
│ briefly cache the QR. │
└──────────────────────────────────────────┘
```
`Done` clears the canvas and closes the window. The wasm-returned 109-byte payload is held only in the popup's `window` scope; both `Done` and the `beforeunload` event handler zero it via `payload.fill(0)` before the window's JS context is torn down. (The 109-byte blob is encrypted, so its sensitivity is bounded by the passphrase strength regardless — but zeroing is cheap and removes one layer of "what if a browser extension snoops popup memory" worry.)
The init wizard's Step 3a (passphrase entry for new vaults) gains the score-3 hard gate — an inline change to `extension/src/setup/setup.ts` near where `rate_passphrase` is already called for the strength meter.
The unlock dialog gains a **Use recovery QR** link below the reference-image picker. Clicking opens a paste field for the hex payload; submitting recovers the image_secret in-process and continues the normal unlock flow with that recovered secret. After successful unlock, a banner suggests re-establishing the reference image.
### wasm bindings (additions to `relicario-wasm/src/lib.rs`)
```rust
#[wasm_bindgen]
pub fn generate_recovery_payload(passphrase: &str, image_secret: &[u8]) -> Result<Vec<u8>, JsError>;
#[wasm_bindgen]
pub fn unwrap_recovery_payload(passphrase: &str, payload: &[u8]) -> Result<Vec<u8>, JsError>;
// returns the 32-byte image_secret on success
```
## Migration & backwards compatibility
Additive only. No vault format change, no `params.json` change, no `manifest.enc` change. Existing vaults gain access to the feature on upgrade.
The passphrase entropy floor only gates **new** vault creation. Existing vaults (which may have weaker passphrases) continue to unlock normally; they receive a soft warning at unlock-time as described above. There is no forced rotation.
## Testing strategy
`crates/relicario-core/src/recovery_qr.rs`:
1. **Round-trip:** `image_secret = bytes; payload = generate(passphrase, image_secret); recovered = unwrap(passphrase, payload); assert_eq!(image_secret, recovered)`.
2. **Wrong passphrase rejected:** `unwrap("wrong", payload)` returns `RelicarioError::Decrypt`, no information leaked about which bit was wrong.
3. **Tampered payload rejected:** flip a byte anywhere in the 109 bytes — payload rejects.
4. **Domain separation:** assert the recovery KDF output for a given `(passphrase, salt)` differs from `derive_master_key`'s output for that same passphrase paired with the all-zero image_secret and the same salt. This regression guards against accidental input-shape collisions.
5. **NFC parity:** passphrase encoded as NFC vs NFD recovers identically — and explicitly call `normalize_passphrase` from both paths in the test setup to assert the helper is the single source of truth.
6. **Weak-params unconstructable:** type-level — there is no public path to construct `RecoveryKdfParams` with `argon2_m < 65536`. Asserted by a compile-fail test (trybuild) or by the absence of a public constructor (sufficient on its own; trybuild is gravy).
`crates/relicario-cli/tests/recovery_qr.rs`:
7. **No `--out` or file-write flag exists:** assert the clap surface for `recovery-qr generate` has no flags accepting a path. Negative test on the help output.
8. **End-to-end:** init a vault, generate a recovery QR (hex form for test purposes), purge the reference image, run `unlock --recovery-qr-payload <hex>` with the passphrase, assert the vault opens.
`crates/relicario-cli/tests/entropy_floor.rs`:
9. **Init rejects weak passphrase:** `relicario init` with passphrase `"correcthorse"` exits with code 2 and `WeakPassphrase` error.
10. **Init accepts strong passphrase:** `relicario init` with a fresh BIP39 4-word passphrase succeeds.
11. **Existing weak vault unlocks with warning:** simulate an existing vault with a weak passphrase; unlock succeeds and emits the soft warning to stderr.
Extension tests (Playwright or equivalent, following existing extension test patterns):
12. **Wizard rejects weak passphrase:** Next button disabled until score ≥ 3.
13. **Recovery QR popup never writes a file:** assert no `<a download>` or Blob URL appears in the popup DOM.
14. **`Done` clears canvas:** after Done, `getImageData` on the canvas returns all-zero bytes.
## Open questions
None remaining at design time. Defer to implementation:
- The exact CLI flag spelling (`--recovery-qr-payload` vs `--recover` vs `--recovery <hex>`). To be settled when the unlock-flow plan is written.
- Whether the extension popup's recovery flow accepts photographed-QR upload (image → QR-decode → payload) or only manual hex paste. The spec ships hex-paste only; image upload + decode is a follow-up that needs its own threat-model pass (uploading an image to the extension reintroduces a file-write vector that this design carefully avoided).