# 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` 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` (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` (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`. 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 ``. 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 `` 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 # 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 # 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 --out ` (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, JsError>; #[wasm_bindgen] pub fn unwrap_recovery_payload(passphrase: &str, payload: &[u8]) -> Result, 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 ` 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 `` 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 `). 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).