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>
22 KiB
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:
- 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.
- Domain separation in the recovery KDF input prevents collision with the main
derive_master_keyoutput namespace under adversarial inputs. - 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
- Provide an offline, paper-or-photo fallback that recovers
image_secretwhen the reference image is lost but the passphrase is known. - 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.
- Enforce a passphrase entropy floor at vault init so the recovery-QR security guarantee is not silently undermined.
- Surface the feature in CLI, extension vault tab, and the new-vault wizard with parity (see
feedback_cli_extension_parityin 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
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
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)
[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:
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; reusederive_master_key's pattern).- The 32-byte
image_secretgoing into the wrap — already wrapped inZeroizingupstream byimgsecret::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-coreexposesrecovery_qr::generate(passphrase, image_secret) -> Vec<u8>(returns the 109-byte payload). It does not exposegenerate_to_fileor accept aPath.relicario-wasmexposesgenerate_recovery_payload(passphrase, image_secret) -> Vec<u8>. Same constraint.relicario-clisubcommandrecovery-qr generaterenders to TTY using a Unicode block-drawing QR (e.g. viaqrcodecrate'srender::unicode::Dense1x2). Offers no--outflag. A--printflag pipes a PostScript QR tolp(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'swindow.print()integrates with the OS print dialog more cleanly than a one-off CLI shell-out.- Extension routes to a dedicated
recovery-qr.htmlpage that renders the QR onto a<canvas>. Two buttons: Display (the page IS the display) and Print (callswindow.print()on the same page with a@media printstylesheet 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 butoncontextmenuis 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
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)
#[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:
- Round-trip:
image_secret = bytes; payload = generate(passphrase, image_secret); recovered = unwrap(passphrase, payload); assert_eq!(image_secret, recovered). - Wrong passphrase rejected:
unwrap("wrong", payload)returnsRelicarioError::Decrypt, no information leaked about which bit was wrong. - Tampered payload rejected: flip a byte anywhere in the 109 bytes — payload rejects.
- Domain separation: assert the recovery KDF output for a given
(passphrase, salt)differs fromderive_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. - NFC parity: passphrase encoded as NFC vs NFD recovers identically — and explicitly call
normalize_passphrasefrom both paths in the test setup to assert the helper is the single source of truth. - Weak-params unconstructable: type-level — there is no public path to construct
RecoveryKdfParamswithargon2_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:
- No
--outor file-write flag exists: assert the clap surface forrecovery-qr generatehas no flags accepting a path. Negative test on the help output. - 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:
- Init rejects weak passphrase:
relicario initwith passphrase"correcthorse"exits with code 2 andWeakPassphraseerror. - Init accepts strong passphrase:
relicario initwith a fresh BIP39 4-word passphrase succeeds. - 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):
- Wizard rejects weak passphrase: Next button disabled until score ≥ 3.
- Recovery QR popup never writes a file: assert no
<a download>or Blob URL appears in the popup DOM. Doneclears canvas: after Done,getImageDataon the canvas returns all-zero bytes.
Open questions
None remaining at design time. Defer to implementation:
- The exact CLI flag spelling (
--recovery-qr-payloadvs--recovervs--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).