# 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).
