# Recovery QR + Passphrase Entropy Floor — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship a 1-of-2 disaster-recovery path (paper QR carrying `image_secret` encrypted under Argon2id-of-passphrase) and enforce a passphrase entropy floor at vault init so that the QR's security guarantee holds. **Architecture:** A new core module `recovery_qr.rs` that produces a 109-byte binary payload (magic + version + salt + nonce + AEAD ciphertext of `image_secret`). Display-first UX (canvas-rendered QR for phone capture); print is secondary. The payload is **never written to disk by any code path** — the API shape forbids it. KDF params are protected from drift by a private-fields type with no public weak constructor. The passphrase-entropy floor (zxcvbn ≥ 3) is a hard gate at vault init in both CLI and extension; existing vaults are grandfathered with a non-blocking warning. **Tech Stack:** Rust (`relicario-core`, `relicario-cli`, `relicario-wasm`); existing Argon2id + XChaCha20-Poly1305 + Zeroizing patterns in `crypto.rs`; `qrcode` crate for CLI TTY rendering; TypeScript + canvas in the extension; existing `chrome.storage.sync` plumbing; existing zxcvbn integration in `generators.rs`. **Spec:** `docs/superpowers/specs/2026-05-01-recovery-qr-design.md` --- ## File Structure ### Created - `crates/relicario-core/src/recovery_qr.rs` — `RecoveryKdfParams` (private fields), `generate()`, `unwrap()`, payload format constants, unit tests. - `crates/relicario-cli/tests/recovery_qr.rs` — integration tests for the CLI surface. - `crates/relicario-cli/tests/entropy_floor.rs` — integration tests for the init-time entropy gate. - `extension/src/recovery-qr/recovery-qr.html` — popup window for QR display + print. - `extension/src/recovery-qr/recovery-qr.ts` — controller for the popup window. - `extension/src/recovery-qr/recovery-qr.css` — print stylesheet + on-screen layout. - `extension/src/recovery-qr/__tests__/recovery-qr.test.ts` — unit tests for the popup logic. ### Modified - `crates/relicario-core/src/crypto.rs` — extract `pub(crate) fn normalize_passphrase()`; have `derive_master_key` call it. - `crates/relicario-core/src/error.rs` — add `WeakPassphrase { score: u8, required: u8 }` and `RecoveryPayloadFormat(String)` variants. - `crates/relicario-core/src/lib.rs` — `pub mod recovery_qr;` + re-exports. - `crates/relicario-wasm/src/lib.rs` — add `generate_recovery_payload`, `unwrap_recovery_payload` bindings. - `crates/relicario-cli/src/main.rs` — add `RecoveryQr`, `Imgsecret` to `Commands`; add `--recovery-qr-payload` to the unlock flow; wire entropy gate into `Init`; wire warning into unlock. - `crates/relicario-cli/src/helpers.rs` — extract a `prompt_passphrase_with_strength_gate()` helper used by `Init` and `Add` flows that create new vaults. - `extension/src/setup/setup.ts` — make Step 3a's Next button refuse advance below zxcvbn score 3 (today it shows a meter only). - `extension/src/popup/popup.ts` (or wherever the unlock dialog lives) — add "Use recovery QR" link + hex-paste flow + post-recovery banner. - `extension/src/vault/vault.ts` (or vault settings component) — add "Generate recovery QR" button to the Disaster Recovery section. --- ## Phase A — Core crypto foundation ### Task 1: Extract `normalize_passphrase()` helper **Files:** - Modify: `crates/relicario-core/src/crypto.rs:223-227` (the inline NFC block in `derive_master_key`) - Test: same file's existing `tests` mod This is a no-behavior-change refactor. The helper becomes the single source of truth so the recovery KDF (added in Task 3) and `derive_master_key` cannot drift on Unicode handling. - [ ] **Step 1: Add the failing test (parity assertion)** Append to `mod tests` in `crypto.rs`: ```rust #[test] fn normalize_passphrase_helper_matches_inline_logic() { // The helper must produce identical output to the previous inline path: // valid UTF-8 -> NFC; invalid UTF-8 passes through unchanged. let valid_nfd = "cafe\u{0301}".as_bytes(); let valid_nfc = "caf\u{00e9}".as_bytes(); let invalid = &[0xff, 0xfe, 0x80][..]; assert_eq!(super::normalize_passphrase(valid_nfd), valid_nfc.to_vec()); assert_eq!(super::normalize_passphrase(valid_nfc), valid_nfc.to_vec()); assert_eq!(super::normalize_passphrase(invalid), invalid.to_vec()); } ``` - [ ] **Step 2: Run the test — expect compile failure (function does not exist)** ``` cargo test -p relicario-core normalize_passphrase_helper ``` Expected: compile error `cannot find function normalize_passphrase in this scope`. - [ ] **Step 3: Add the helper and refactor `derive_master_key` to use it** Insert at the top of `crypto.rs` (after the `use` block, before `derive_master_key`): ```rust /// NFC-normalize a passphrase, treating invalid UTF-8 as opaque bytes. /// /// Single source of truth for passphrase normalization across both the vault /// KDF and the recovery KDF -- prevents silent drift that would make NFC vs /// NFD passphrases recover-but-not-unlock or vice versa. pub(crate) fn normalize_passphrase(passphrase: &[u8]) -> Vec { match std::str::from_utf8(passphrase) { Ok(s) => s.nfc().collect::().into_bytes(), Err(_) => passphrase.to_vec(), } } ``` In `derive_master_key`, replace lines 223-227 with: ```rust let nfc_passphrase = normalize_passphrase(passphrase); ``` - [ ] **Step 4: Run the full crypto test module — confirm no regressions** ``` cargo test -p relicario-core --lib crypto::tests ``` Expected: all tests pass, including the new parity test. - [ ] **Step 5: Commit** ``` git add crates/relicario-core/src/crypto.rs git commit -m "refactor(core/crypto): extract normalize_passphrase helper" ``` --- ### Task 2: Add error variants and module skeleton **Files:** - Modify: `crates/relicario-core/src/error.rs` - Create: `crates/relicario-core/src/recovery_qr.rs` - Modify: `crates/relicario-core/src/lib.rs` - [ ] **Step 1: Add the failing test for the new error formatting** In `crates/relicario-core/src/error.rs` `mod tests` (or create one if absent), add: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn weak_passphrase_error_formats_clearly() { let e = RelicarioError::WeakPassphrase { score: 1, required: 3 }; let msg = format!("{e}"); assert!(msg.contains("score 1")); assert!(msg.contains("3")); } #[test] fn recovery_payload_format_error_includes_reason() { let e = RelicarioError::RecoveryPayloadFormat("bad magic".into()); assert!(format!("{e}").contains("bad magic")); } } ``` - [ ] **Step 2: Run the test — expect compile failure** ``` cargo test -p relicario-core --lib error::tests ``` Expected: compile error `no variant named WeakPassphrase`. - [ ] **Step 3: Add the error variants** In `crates/relicario-core/src/error.rs`, add inside the `RelicarioError` enum: ```rust /// The supplied passphrase scored below the configured zxcvbn floor. /// Returned at vault creation time and at recovery-QR generation time /// (without `--force-weak-passphrase`). #[error("passphrase too weak: zxcvbn score {score}, required at least {required}")] WeakPassphrase { score: u8, required: u8 }, /// Recovery QR payload failed structural validation: bad magic, unknown /// version byte, or wrong total length. Returned by `recovery_qr::unwrap`. /// Cryptographic failures (wrong passphrase, tampered ciphertext) return /// `Decrypt` to preserve audit M4's opacity. #[error("invalid recovery payload: {0}")] RecoveryPayloadFormat(String), ``` - [ ] **Step 4: Run the test — expect pass** ``` cargo test -p relicario-core --lib error::tests ``` Expected: PASS. - [ ] **Step 5: Create the recovery_qr module skeleton** `crates/relicario-core/src/recovery_qr.rs`: ```rust //! Recovery QR — disaster recovery payload for lost reference image. //! //! Produces a 109-byte binary payload that wraps the 32-byte `image_secret` //! under a passphrase-only Argon2id derivation. The payload is meant to be //! displayed as a QR code (on screen for phone capture, or printed) and then //! discarded from app memory. It is **never written to disk by any path in //! this crate** — there is no API that takes a `Path` or returns anything //! suggesting on-disk storage. //! //! See `docs/superpowers/specs/2026-05-01-recovery-qr-design.md` for the //! full threat model and design rationale. use crate::error::{RelicarioError, Result}; /// Domain separator for the recovery-KDF input. Prefixing the passphrase with /// this 22-byte tag makes recovery-KDF inputs structurally distinct from /// `derive_master_key`'s inputs (which start with `u64_be(passphrase_len)`, /// the first 6+ bytes of which are zero for any realistic passphrase length). pub(crate) const DOMAIN_TAG: &[u8] = b"relicario-recovery-v1\0"; /// File-level magic identifying a recovery-QR payload. Matches the "RBAK" /// pattern from `backup.rs` for visual consistency. pub const RECOVERY_MAGIC: [u8; 4] = *b"RREC"; /// Current recovery-QR payload format version. pub const RECOVERY_VERSION: u8 = 0x01; const SALT_LEN: usize = 32; const NONCE_LEN: usize = 24; const SECRET_LEN: usize = 32; const TAG_LEN: usize = 16; /// Total payload size: 4 (magic) + 1 (version) + 32 (salt) + 24 (nonce) + 48 (ct+tag). pub const PAYLOAD_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN + SECRET_LEN + TAG_LEN; /// Argon2id parameters for the recovery-QR KDF. /// /// All fields are private. The only public constructor is `production()`, /// which produces strong parameters (m = 64 MiB, t = 3, p = 4). There is /// **no** way to construct a `RecoveryKdfParams` with weaker parameters from /// outside this module in release builds — protecting the recovery payload /// from accidental reuse of the vault's `params.json` (which may contain /// test-grade parameters in dev builds). #[derive(Debug, Clone, Copy)] pub struct RecoveryKdfParams { argon2_m: u32, argon2_t: u32, argon2_p: u32, } impl RecoveryKdfParams { /// Production-grade parameters: 64 MiB memory, 3 iterations, 4 lanes. pub const fn production() -> Self { Self { argon2_m: 65536, argon2_t: 3, argon2_p: 4 } } pub(crate) fn argon2_m(&self) -> u32 { self.argon2_m } pub(crate) fn argon2_t(&self) -> u32 { self.argon2_t } pub(crate) fn argon2_p(&self) -> u32 { self.argon2_p } } #[cfg(test)] impl RecoveryKdfParams { /// Test-only fast parameters. Only compiled in `cfg(test)` builds, so /// integration tests that do not enable test-cfg on this crate cannot /// reach this constructor. Cross-crate integration tests use the CLI's /// own test-feature plumbing rather than constructing `RecoveryKdfParams` /// directly. pub(crate) fn fast_for_tests() -> Self { Self { argon2_m: 256, argon2_t: 1, argon2_p: 1 } } } /// Generate a recovery-QR payload from a passphrase and the 32-byte /// `image_secret`. See module docs for the binary layout. pub fn generate( _passphrase: &[u8], _image_secret: &[u8; SECRET_LEN], _params: &RecoveryKdfParams, ) -> Result> { Err(RelicarioError::RecoveryPayloadFormat("not yet implemented".into())) } /// Recover the 32-byte `image_secret` from a recovery-QR payload using the /// passphrase. Returns an opaque `Decrypt` error on wrong passphrase or /// tampered ciphertext (per audit M4). pub fn unwrap( _passphrase: &[u8], _payload: &[u8], _params: &RecoveryKdfParams, ) -> Result<[u8; SECRET_LEN]> { Err(RelicarioError::RecoveryPayloadFormat("not yet implemented".into())) } ``` In `crates/relicario-core/src/lib.rs`, add after the `pub mod backup` block: ```rust pub mod recovery_qr; pub use recovery_qr::{ generate as generate_recovery_payload, unwrap as unwrap_recovery_payload, RecoveryKdfParams, PAYLOAD_LEN, RECOVERY_MAGIC, RECOVERY_VERSION, }; ``` - [ ] **Step 6: Build to confirm the skeleton compiles** ``` cargo build -p relicario-core ``` Expected: clean build. - [ ] **Step 7: Commit** ``` git add crates/relicario-core/src/error.rs \ crates/relicario-core/src/recovery_qr.rs \ crates/relicario-core/src/lib.rs git commit -m "feat(core): add recovery_qr module skeleton + error variants" ``` --- ### Task 3: Implement `recovery_qr::generate()` **Files:** - Modify: `crates/relicario-core/src/recovery_qr.rs` - [ ] **Step 1: Write the failing happy-path test** Append a `#[cfg(test)] mod tests` to `recovery_qr.rs`: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn generate_produces_expected_length_payload() { let secret = [0x42u8; SECRET_LEN]; let payload = generate(b"hello passphrase", &secret, &RecoveryKdfParams::fast_for_tests()) .expect("generate should succeed"); assert_eq!(payload.len(), PAYLOAD_LEN); assert_eq!(&payload[..4], &RECOVERY_MAGIC); assert_eq!(payload[4], RECOVERY_VERSION); } } ``` - [ ] **Step 2: Run — expect failure** ``` cargo test -p relicario-core --lib recovery_qr::tests::generate_produces_expected_length_payload ``` Expected: FAIL with `not yet implemented`. - [ ] **Step 3: Implement `generate()`** Replace the stubbed `generate()` body: ```rust use argon2::{Algorithm, Argon2, Params, Version}; use chacha20poly1305::{aead::{Aead, KeyInit}, XChaCha20Poly1305, XNonce}; use rand::{rngs::OsRng, RngCore}; use zeroize::Zeroizing; use crate::crypto::normalize_passphrase; fn derive_recovery_wrap_key( passphrase: &[u8], salt: &[u8; SALT_LEN], params: &RecoveryKdfParams, ) -> Result> { let argon2_params = Params::new(params.argon2_m(), params.argon2_t(), params.argon2_p(), Some(32)) .map_err(|e| RelicarioError::Kdf(e.to_string()))?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params); let nfc = normalize_passphrase(passphrase); let mut input = Zeroizing::new(Vec::with_capacity(DOMAIN_TAG.len() + 8 + nfc.len())); input.extend_from_slice(DOMAIN_TAG); input.extend_from_slice(&(nfc.len() as u64).to_be_bytes()); input.extend_from_slice(&nfc); let mut out = Zeroizing::new([0u8; 32]); argon2 .hash_password_into(input.as_slice(), salt, out.as_mut()) .map_err(|e| RelicarioError::Kdf(e.to_string()))?; Ok(out) } pub fn generate( passphrase: &[u8], image_secret: &[u8; SECRET_LEN], params: &RecoveryKdfParams, ) -> Result> { let mut salt = [0u8; SALT_LEN]; OsRng.fill_bytes(&mut salt); let wrap_key = derive_recovery_wrap_key(passphrase, &salt, params)?; let cipher = XChaCha20Poly1305::new(wrap_key.as_ref().into()); let mut nonce_bytes = [0u8; NONCE_LEN]; OsRng.fill_bytes(&mut nonce_bytes); let nonce = XNonce::from(nonce_bytes); let ciphertext = cipher .encrypt(&nonce, image_secret.as_slice()) .map_err(|e| RelicarioError::Encrypt(e.to_string()))?; let mut payload = Vec::with_capacity(PAYLOAD_LEN); payload.extend_from_slice(&RECOVERY_MAGIC); payload.push(RECOVERY_VERSION); payload.extend_from_slice(&salt); payload.extend_from_slice(&nonce_bytes); payload.extend_from_slice(&ciphertext); debug_assert_eq!(payload.len(), PAYLOAD_LEN); Ok(payload) } ``` - [ ] **Step 4: Run — expect pass** ``` cargo test -p relicario-core --lib recovery_qr::tests::generate_produces_expected_length_payload ``` Expected: PASS. - [ ] **Step 5: Commit** ``` git add crates/relicario-core/src/recovery_qr.rs git commit -m "feat(core/recovery_qr): implement generate()" ``` --- ### Task 4: Implement `recovery_qr::unwrap()` with round-trip + tamper tests **Files:** - Modify: `crates/relicario-core/src/recovery_qr.rs` - [ ] **Step 1: Write the failing tests** Append to the `tests` mod: ```rust #[test] fn round_trip_recovers_image_secret() { let secret = [0x77u8; SECRET_LEN]; let pp = b"correct horse battery staple"; let params = RecoveryKdfParams::fast_for_tests(); let payload = generate(pp, &secret, ¶ms).unwrap(); let recovered = unwrap(pp, &payload, ¶ms).unwrap(); assert_eq!(recovered, secret); } #[test] fn wrong_passphrase_returns_decrypt_error() { let secret = [0u8; SECRET_LEN]; let params = RecoveryKdfParams::fast_for_tests(); let payload = generate(b"right", &secret, ¶ms).unwrap(); let err = unwrap(b"wrong", &payload, ¶ms).unwrap_err(); assert!(matches!(err, RelicarioError::Decrypt)); } #[test] fn tampered_payload_rejected() { let secret = [0u8; SECRET_LEN]; let params = RecoveryKdfParams::fast_for_tests(); let mut payload = generate(b"pp", &secret, ¶ms).unwrap(); // Flip a byte inside the ciphertext region (last 48 bytes). let last = payload.len() - 1; payload[last] ^= 0xff; let err = unwrap(b"pp", &payload, ¶ms).unwrap_err(); assert!(matches!(err, RelicarioError::Decrypt)); } #[test] fn bad_magic_returns_format_error() { let mut payload = vec![0u8; PAYLOAD_LEN]; payload[..4].copy_from_slice(b"NOPE"); let err = unwrap(b"pp", &payload, &RecoveryKdfParams::fast_for_tests()).unwrap_err(); assert!(matches!(err, RelicarioError::RecoveryPayloadFormat(_))); } #[test] fn unknown_version_returns_format_error() { let secret = [0u8; SECRET_LEN]; let mut payload = generate(b"pp", &secret, &RecoveryKdfParams::fast_for_tests()).unwrap(); payload[4] = 0xff; // bogus version let err = unwrap(b"pp", &payload, &RecoveryKdfParams::fast_for_tests()).unwrap_err(); assert!(matches!(err, RelicarioError::RecoveryPayloadFormat(_))); } #[test] fn short_payload_returns_format_error() { let err = unwrap(b"pp", &[0u8; 10], &RecoveryKdfParams::fast_for_tests()).unwrap_err(); assert!(matches!(err, RelicarioError::RecoveryPayloadFormat(_))); } ``` - [ ] **Step 2: Run — expect failures** ``` cargo test -p relicario-core --lib recovery_qr::tests ``` Expected: 6 failures (all the new tests). - [ ] **Step 3: Implement `unwrap()`** Replace the stubbed `unwrap()`: ```rust pub fn unwrap( passphrase: &[u8], payload: &[u8], params: &RecoveryKdfParams, ) -> Result<[u8; SECRET_LEN]> { if payload.len() != PAYLOAD_LEN { return Err(RelicarioError::RecoveryPayloadFormat(format!( "payload length {} != expected {}", payload.len(), PAYLOAD_LEN ))); } if &payload[..4] != RECOVERY_MAGIC { return Err(RelicarioError::RecoveryPayloadFormat("bad magic".into())); } if payload[4] != RECOVERY_VERSION { return Err(RelicarioError::RecoveryPayloadFormat(format!( "unsupported recovery payload version 0x{:02x}", payload[4] ))); } let salt: &[u8; SALT_LEN] = payload[5..5 + SALT_LEN].try_into().unwrap(); let nonce_bytes: &[u8; NONCE_LEN] = payload[5 + SALT_LEN..5 + SALT_LEN + NONCE_LEN].try_into().unwrap(); let ciphertext = &payload[5 + SALT_LEN + NONCE_LEN..]; let wrap_key = derive_recovery_wrap_key(passphrase, salt, params)?; let cipher = XChaCha20Poly1305::new(wrap_key.as_ref().into()); let nonce = XNonce::from(*nonce_bytes); let plaintext = cipher .decrypt(&nonce, ciphertext) .map_err(|_| RelicarioError::Decrypt)?; if plaintext.len() != SECRET_LEN { return Err(RelicarioError::RecoveryPayloadFormat(format!( "decrypted secret has wrong length {}", plaintext.len() ))); } let mut out = [0u8; SECRET_LEN]; out.copy_from_slice(&plaintext); Ok(out) } ``` - [ ] **Step 4: Run — expect all 6 to pass** ``` cargo test -p relicario-core --lib recovery_qr::tests ``` Expected: 7 PASS (the original `generate_produces_expected_length_payload` plus the 6 new ones). - [ ] **Step 5: Commit** ``` git add crates/relicario-core/src/recovery_qr.rs git commit -m "feat(core/recovery_qr): implement unwrap() + format/tamper rejection" ``` --- ### Task 5: Domain-separation + NFC-parity regression tests **Files:** - Modify: `crates/relicario-core/src/recovery_qr.rs` - [ ] **Step 1: Write the failing tests** Append to `tests` mod: ```rust #[test] fn domain_separation_vs_master_key() { // The recovery KDF for (passphrase, salt) and the master KDF for // (passphrase, all-zero image_secret, salt, default test params) must // produce DIFFERENT 32-byte outputs even though both consume the same // passphrase and salt. This is the test that fails loudly if the domain // tag is ever removed or weakened. use crate::crypto::{derive_master_key, KdfParams}; let pp = b"shared passphrase"; let salt = [0x11u8; 32]; let recovery_params = RecoveryKdfParams::fast_for_tests(); let master_params = KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }; let recovery_key = derive_recovery_wrap_key(pp, &salt, &recovery_params).unwrap(); let master_key = derive_master_key(pp, &[0u8; 32], &salt, &master_params).unwrap(); assert_ne!(*recovery_key, *master_key, "recovery KDF must not collide with master KDF; check the domain separator"); } #[test] fn nfc_parity_with_master_key() { // The recovery payload must round-trip identically across NFC and NFD // forms of the same passphrase, mirroring derive_master_key's behavior. let secret = [0x99u8; SECRET_LEN]; let params = RecoveryKdfParams::fast_for_tests(); let pp_nfc = "caf\u{00e9}".as_bytes(); let pp_nfd = "cafe\u{0301}".as_bytes(); let payload = generate(pp_nfc, &secret, ¶ms).unwrap(); let recovered_via_nfd = unwrap(pp_nfd, &payload, ¶ms).unwrap(); assert_eq!(recovered_via_nfd, secret); } ``` - [ ] **Step 2: Run — expect pass (the implementation already supports this; these tests are belt-and-braces)** ``` cargo test -p relicario-core --lib recovery_qr::tests::domain_separation_vs_master_key recovery_qr::tests::nfc_parity_with_master_key ``` Expected: PASS for both. If either fails, the implementation has a bug — fix before committing. - [ ] **Step 3: Commit** ``` git add crates/relicario-core/src/recovery_qr.rs git commit -m "test(core/recovery_qr): domain separation + NFC parity regression" ``` --- ## Phase B — wasm bindings ### Task 6: Wasm bindings for recovery payload generate/unwrap **Files:** - Modify: `crates/relicario-wasm/src/lib.rs` - [ ] **Step 1: Add the bindings** In `crates/relicario-wasm/src/lib.rs`, after the existing `embed_image_secret` function: ```rust use relicario_core::{ generate_recovery_payload as core_generate_recovery_payload, unwrap_recovery_payload as core_unwrap_recovery_payload, RecoveryKdfParams, }; /// Generate a recovery-QR payload (109 bytes) wrapping `image_secret` under /// the given passphrase. The returned bytes are intended to be QR-encoded by /// the caller and displayed/printed; they must NEVER be written to disk. #[wasm_bindgen] pub fn generate_recovery_payload(passphrase: &str, image_secret: &[u8]) -> Result, JsError> { let secret: &[u8; 32] = image_secret .try_into() .map_err(|_| JsError::new("image_secret must be exactly 32 bytes"))?; core_generate_recovery_payload(passphrase.as_bytes(), secret, &RecoveryKdfParams::production()) .map_err(|e| JsError::new(&e.to_string())) } /// Recover `image_secret` (32 bytes) from a recovery-QR payload using the /// passphrase. Returns the raw 32-byte secret on success. #[wasm_bindgen] pub fn unwrap_recovery_payload(passphrase: &str, payload: &[u8]) -> Result, JsError> { core_unwrap_recovery_payload(passphrase.as_bytes(), payload, &RecoveryKdfParams::production()) .map(|s| s.to_vec()) .map_err(|e| JsError::new(&e.to_string())) } ``` - [ ] **Step 2: Build for the wasm target — confirm clean** ``` cargo build -p relicario-wasm --target wasm32-unknown-unknown ``` Expected: clean build. - [ ] **Step 3: Commit** ``` git add crates/relicario-wasm/src/lib.rs git commit -m "feat(wasm): bind recovery_qr generate/unwrap" ``` Note: the wasm crate is exercised by extension tests in Phase F; no separate Rust unit test here. The cross-language smoke test belongs in the extension Vitest suite. --- ## Phase C — CLI: `imgsecret embed` subcommand The recovery flow is incomplete without a way to re-establish the primary factor after recovery. `relicario imgsecret embed` wraps the existing `imgsecret::embed` function (already in core, already used by `init`) into a standalone CLI command. ### Task 7: Add `imgsecret embed` subcommand **Files:** - Modify: `crates/relicario-cli/src/main.rs` - Test: `crates/relicario-cli/tests/recovery_qr.rs` (new file) - [ ] **Step 1: Write the failing integration test** Create `crates/relicario-cli/tests/recovery_qr.rs`: ```rust //! Integration tests for `relicario imgsecret` and `relicario recovery-qr`. use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; mod common; // expects an existing common module; if absent, replicate the // make_test_jpeg helper from basic_flows.rs into this file. #[test] fn imgsecret_embed_produces_jpeg_carrying_supplied_secret() { let tmp = TempDir::new().unwrap(); let carrier = tmp.path().join("carrier.jpg"); let out = tmp.path().join("reference.jpg"); // Reuse the helper from basic_flows: writes a synthetic JPEG to `carrier`. common::write_test_jpeg(&carrier); // 32-byte secret encoded as hex on the CLI surface for ease of input. let secret_hex = "0123456789abcdef".repeat(4); // 64 hex chars = 32 bytes Command::cargo_bin("relicario").unwrap() .arg("imgsecret").arg("embed") .arg("--carrier").arg(&carrier) .arg("--out").arg(&out) .arg("--secret-hex").arg(&secret_hex) .assert() .success(); // Round-trip: extract the secret from the produced reference image. let bytes = std::fs::read(&out).unwrap(); let extracted = relicario_core::imgsecret::extract(&bytes).unwrap(); let expected: [u8; 32] = hex::decode(&secret_hex).unwrap().try_into().unwrap(); assert_eq!(extracted, expected); } ``` If `common.rs` does not exist in `crates/relicario-cli/tests/`, inline `write_test_jpeg` from `basic_flows.rs`. - [ ] **Step 2: Run — expect failure** ``` cargo test -p relicario-cli --test recovery_qr ``` Expected: clap parse error or command-not-found. - [ ] **Step 3: Add the subcommand** In `crates/relicario-cli/src/main.rs`, add to the `Commands` enum: ```rust /// Image-secret operations (DCT steganography). Imgsecret { #[command(subcommand)] action: ImgsecretAction, }, ``` Add the action enum below the existing nested `*Action` enums: ```rust #[derive(Subcommand)] enum ImgsecretAction { /// Embed a 32-byte secret (hex-encoded) into a carrier JPEG. /// Produces a reference JPEG that can be fed back into the unlock flow. Embed { /// Carrier JPEG (preferably high-resolution, will be downsampled). #[arg(long)] carrier: PathBuf, /// Output path for the reference JPEG. #[arg(long)] out: PathBuf, /// 32-byte secret as 64 hex characters. #[arg(long)] secret_hex: String, }, } ``` In the dispatch (`match cli.command { ... }`), add: ```rust Commands::Imgsecret { action } => match action { ImgsecretAction::Embed { carrier, out, secret_hex } => { let secret_bytes = hex::decode(&secret_hex) .context("--secret-hex must be valid hexadecimal")?; if secret_bytes.len() != 32 { bail!("--secret-hex must decode to exactly 32 bytes (got {})", secret_bytes.len()); } let secret: [u8; 32] = secret_bytes.try_into().unwrap(); let carrier_bytes = std::fs::read(&carrier) .with_context(|| format!("reading carrier from {}", carrier.display()))?; let reference = relicario_core::imgsecret::embed(&carrier_bytes, &secret) .context("embedding secret into carrier")?; std::fs::write(&out, &reference) .with_context(|| format!("writing reference to {}", out.display()))?; eprintln!("Wrote reference image to {}", out.display()); Ok(()) } }, ``` - [ ] **Step 4: Run the test — expect pass** ``` cargo test -p relicario-cli --test recovery_qr imgsecret_embed_produces_jpeg_carrying_supplied_secret ``` Expected: PASS. - [ ] **Step 5: Commit** ``` git add crates/relicario-cli/src/main.rs crates/relicario-cli/tests/recovery_qr.rs git commit -m "feat(cli): add imgsecret embed subcommand" ``` --- ## Phase D — CLI: `recovery-qr` subcommand ### Task 8: Add `recovery-qr generate` subcommand (TTY render) **Files:** - Modify: `crates/relicario-cli/Cargo.toml` — add `qrcode` dependency - Modify: `crates/relicario-cli/src/main.rs` - Test: `crates/relicario-cli/tests/recovery_qr.rs` - [ ] **Step 1: Write the failing test (clap surface assertion + TTY happy path)** Append to `crates/relicario-cli/tests/recovery_qr.rs`: ```rust #[test] fn recovery_qr_generate_help_has_no_out_flag() { let output = Command::cargo_bin("relicario").unwrap() .arg("recovery-qr").arg("generate").arg("--help") .output() .unwrap(); let help = String::from_utf8(output.stdout).unwrap(); // Architectural invariant: the recovery payload must NEVER be written to // disk by this command. No --out, --output, --file, or path-accepting flag. assert!(!help.to_lowercase().contains("--out"), "found --out in help: {help}"); assert!(!help.to_lowercase().contains("--file"), "found --file in help: {help}"); } #[test] fn recovery_qr_generate_renders_to_stdout_for_existing_vault() { let tmp = TempDir::new().unwrap(); common::init_vault(tmp.path(), "correct horse battery staple"); let assert = Command::cargo_bin("relicario").unwrap() .current_dir(tmp.path()) .arg("recovery-qr").arg("generate") .env("RELICARIO_PASSPHRASE", "correct horse battery staple") // test-mode plumbing .assert() .success(); let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); // Unicode block-drawing QR contains characters from the Block Elements range. assert!(stdout.chars().any(|c| ('\u{2580}'..='\u{259F}').contains(&c)), "stdout did not contain a Unicode QR rendering"); } ``` The `common::init_vault` helper and `RELICARIO_PASSPHRASE` env-var plumbing should already exist (used by other integration tests). If they do not, the plan author should extend `common.rs` to provide them — that is bounded prep work, not new design. - [ ] **Step 2: Run — expect failures** ``` cargo test -p relicario-cli --test recovery_qr recovery_qr_generate ``` Expected: command-not-found / clap parse errors. - [ ] **Step 3: Add the dependency** Edit `crates/relicario-cli/Cargo.toml`: ```toml [dependencies] qrcode = "0.14" # or current; pin to a version that supports render::unicode::Dense1x2 ``` Run `cargo build -p relicario-cli` to confirm it resolves. - [ ] **Step 4: Add the subcommand** In `crates/relicario-cli/src/main.rs`, add to `Commands`: ```rust /// Recovery-QR operations: generate the disaster-recovery payload, or use /// it to unlock a vault when the reference image has been lost. RecoveryQr { #[command(subcommand)] action: RecoveryQrAction, }, ``` Add the action enum: ```rust #[derive(Subcommand)] enum RecoveryQrAction { /// Display the recovery QR on the terminal. The payload is rendered /// in-process and never written to disk. Generate { /// Bypass the passphrase entropy floor for this generation. /// A weak passphrase makes a photographed QR feasibly brute-forceable; /// only use this if you know what you're doing. #[arg(long)] force_weak_passphrase: bool, }, } ``` Dispatch: ```rust Commands::RecoveryQr { action } => match action { RecoveryQrAction::Generate { force_weak_passphrase } => { let session = session::unlock_current_vault()?; let passphrase = session.passphrase_bytes(); // requires retained-passphrase plumbing — see step 4 below let image_secret = session.image_secret(); // existing accessor // Pre-flight strength check — sister gate to the init-time floor (Task 10). // Existing weak-passphrase vaults that bypass this require --force-weak-passphrase. let est = relicario_core::rate_passphrase( std::str::from_utf8(passphrase).unwrap_or("")); if est.score < 3 && !force_weak_passphrase { bail!( "passphrase scores {} (zxcvbn); recovery QR security is bounded by \ passphrase strength. A photographed QR with a weak passphrase is \ feasibly brute-forceable. Re-run with --force-weak-passphrase to \ proceed anyway, or rotate to a stronger passphrase first.", est.score ); } let payload = relicario_core::generate_recovery_payload( passphrase, image_secret, &relicario_core::RecoveryKdfParams::production())?; use qrcode::{QrCode, render::unicode::Dense1x2}; let code = QrCode::new(&payload) .context("encoding recovery payload as QR")?; let rendered = code.render::() .dark_color(Dense1x2::Dark) .light_color(Dense1x2::Light) .build(); println!("{rendered}"); eprintln!("Snap this QR with a phone or pipe to a printer."); eprintln!("Combined with your passphrase, it can recover your reference image if lost."); Ok(()) } }, ``` The `session::passphrase_bytes()` accessor must be added to `UnlockedVault` if it does not exist — a one-line read of an already-stored `Zeroizing>` field. If the current `UnlockedVault` discards the passphrase after deriving the master key, retain it through the recovery-qr flow only by adding a `passphrase: Zeroizing>` field. Document this as a deliberate departure from "drop ASAP" because the recovery flow needs both the passphrase and the image_secret end-to-end; the field is `Zeroizing` and lives only for the duration of one CLI invocation. - [ ] **Step 5: Run the tests — expect pass** ``` cargo test -p relicario-cli --test recovery_qr recovery_qr_generate ``` Expected: PASS. - [ ] **Step 6: Commit** ``` git add crates/relicario-cli/Cargo.toml crates/relicario-cli/src/main.rs \ crates/relicario-cli/src/session.rs crates/relicario-cli/tests/recovery_qr.rs git commit -m "feat(cli): add recovery-qr generate subcommand" ``` --- ### Task 9: Add `relicario unlock --recovery-qr-payload ` flow **Files:** - Modify: `crates/relicario-cli/src/main.rs` - Test: `crates/relicario-cli/tests/recovery_qr.rs` The unlock subcommand may not exist yet as a top-level command (item operations call into `session::unlock_current_vault()`). For the recovery flow we add a dedicated `recovery-qr unlock` subcommand that takes a hex payload and the passphrase, recovers `image_secret`, and writes it to a known location so subsequent commands (`get`, `list`, etc.) can use it without the user needing to provide a reference image. This is a session-scoped operation; details vary based on how `UnlockedVault` is currently bootstrapped, so the plan author must adapt to the actual session-establishment plumbing. For the integration test, the simpler surface is: `recovery-qr unlock --payload ` extracts and prints the recovered `image_secret` as hex on stdout, intended for piping into `imgsecret embed` (which we added in Task 7) to produce a fresh reference image. - [ ] **Step 1: Write the failing test (end-to-end recovery)** Append to `crates/relicario-cli/tests/recovery_qr.rs`: ```rust #[test] fn recovery_qr_round_trip_recovers_image_secret_via_cli() { let tmp = TempDir::new().unwrap(); common::init_vault(tmp.path(), "correct horse battery staple"); let original_secret = common::extract_image_secret_from_vault(tmp.path()); // Generate the payload as bytes (test-only --hex output flag, see step 3). let gen_out = Command::cargo_bin("relicario").unwrap() .current_dir(tmp.path()) .arg("recovery-qr").arg("generate").arg("--hex") .env("RELICARIO_PASSPHRASE", "correct horse battery staple") .output().unwrap(); assert!(gen_out.status.success(), "generate failed: {:?}", gen_out); let payload_hex = String::from_utf8(gen_out.stdout).unwrap().trim().to_string(); // Unlock via recovery payload + passphrase, expect image_secret hex on stdout. let unlock_out = Command::cargo_bin("relicario").unwrap() .current_dir(tmp.path()) .arg("recovery-qr").arg("unlock").arg("--payload").arg(&payload_hex) .env("RELICARIO_PASSPHRASE", "correct horse battery staple") .output().unwrap(); assert!(unlock_out.status.success(), "unlock failed: {:?}", unlock_out); let recovered_hex = String::from_utf8(unlock_out.stdout).unwrap().trim().to_string(); assert_eq!(recovered_hex, hex::encode(original_secret)); } ``` The `common::extract_image_secret_from_vault` helper reads the configured reference image from the test vault and runs `imgsecret::extract`. Add it to `common.rs` if not present. - [ ] **Step 2: Run — expect failure** ``` cargo test -p relicario-cli --test recovery_qr recovery_qr_round_trip_recovers_image_secret_via_cli ``` Expected: clap parse error on `--hex` or `unlock`. - [ ] **Step 3: Extend the subcommand** In `RecoveryQrAction`, add `--hex` to `Generate` and a new `Unlock` variant: ```rust #[derive(Subcommand)] enum RecoveryQrAction { Generate { #[arg(long)] force_weak_passphrase: bool, /// Output the raw payload as hex on stdout instead of rendering a QR. /// Intended for scripting / testing — the security guarantees still hold /// (the payload is encrypted), but the operator is responsible for not /// piping it to a file in adversarial environments. #[arg(long)] hex: bool, }, /// Recover image_secret from a recovery-QR payload + passphrase. /// Prints the recovered secret as 64 hex characters on stdout. /// Pipe to `relicario imgsecret embed --secret-hex -` to re-establish /// the reference image. Unlock { /// The recovery payload as hex (typically pasted from a phone-decoded QR). #[arg(long)] payload: String, }, } ``` Update `Generate` dispatch to honor `--hex`: ```rust if hex { println!("{}", hex::encode(&payload)); } else { // existing QR rendering } ``` Add `Unlock` dispatch: ```rust RecoveryQrAction::Unlock { payload } => { let payload_bytes = hex::decode(&payload).context("--payload must be hex")?; let passphrase = helpers::prompt_passphrase("vault passphrase")?; let secret = relicario_core::unwrap_recovery_payload( passphrase.as_bytes(), &payload_bytes, &relicario_core::RecoveryKdfParams::production(), )?; println!("{}", hex::encode(secret)); eprintln!("Recovered image_secret. Re-establish your reference image:"); eprintln!(" relicario imgsecret embed --carrier --out reference.jpg \\"); eprintln!(" --secret-hex {}", hex::encode(secret)); Ok(()) } ``` - [ ] **Step 4: Run the tests — expect pass** ``` cargo test -p relicario-cli --test recovery_qr ``` Expected: all tests in this file PASS. - [ ] **Step 5: Commit** ``` git add crates/relicario-cli/src/main.rs crates/relicario-cli/tests/recovery_qr.rs git commit -m "feat(cli): add recovery-qr unlock + --hex output for generate" ``` --- ## Phase E — Passphrase entropy floor ### Task 10: Hard gate at `relicario init` **Files:** - Modify: `crates/relicario-cli/src/main.rs` (`Commands::Init` handler) - Modify: `crates/relicario-cli/src/helpers.rs` — add `prompt_passphrase_with_strength_gate` - Test: `crates/relicario-cli/tests/entropy_floor.rs` (new file) - [ ] **Step 1: Write the failing tests** Create `crates/relicario-cli/tests/entropy_floor.rs`: ```rust //! Entropy floor: zxcvbn >= 3 hard gate at vault creation. use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; mod common; #[test] fn init_rejects_weak_passphrase() { let tmp = TempDir::new().unwrap(); let carrier = tmp.path().join("carrier.jpg"); common::write_test_jpeg(&carrier); let out = Command::cargo_bin("relicario").unwrap() .current_dir(tmp.path()) .arg("init").arg("--image").arg(&carrier) .env("RELICARIO_PASSPHRASE", "password") .output().unwrap(); assert!(!out.status.success(), "init should fail with weak passphrase"); let stderr = String::from_utf8(out.stderr).unwrap(); assert!(stderr.contains("zxcvbn") || stderr.contains("score") || stderr.contains("weak"), "stderr should mention the entropy reason: {stderr}"); } #[test] fn init_accepts_strong_passphrase() { let tmp = TempDir::new().unwrap(); let carrier = tmp.path().join("carrier.jpg"); common::write_test_jpeg(&carrier); Command::cargo_bin("relicario").unwrap() .current_dir(tmp.path()) .arg("init").arg("--image").arg(&carrier) .env("RELICARIO_PASSPHRASE", "correct horse battery staple") .assert() .success(); } ``` - [ ] **Step 2: Run — expect compile-pass but `init_rejects_weak_passphrase` to fail (init currently accepts any passphrase)** ``` cargo test -p relicario-cli --test entropy_floor ``` Expected: `init_rejects_weak_passphrase` FAILS (init succeeds with weak passphrase). - [ ] **Step 3: Add the helper** In `crates/relicario-cli/src/helpers.rs`, append: ```rust /// Prompt for a passphrase and reject anything below zxcvbn score 3. /// Used at vault-creation time. Existing vaults stay grandfathered. pub fn prompt_passphrase_with_strength_gate(prompt: &str) -> anyhow::Result { const FLOOR: u8 = 3; let pp = prompt_passphrase(prompt)?; let est = relicario_core::rate_passphrase(&pp); if est.score < FLOOR { anyhow::bail!( "passphrase too weak: zxcvbn score {} (need >= {}). Try `relicario generate-passphrase` \ for a 4-word BIP39 phrase that exceeds the floor.", est.score, FLOOR ); } Ok(pp) } ``` - [ ] **Step 4: Wire into `Init` dispatch** In `crates/relicario-cli/src/main.rs`, replace the existing `prompt_passphrase` call inside the `Commands::Init` handler with `helpers::prompt_passphrase_with_strength_gate`. (Find the call by `grep -n prompt_passphrase main.rs` — there is exactly one in the init handler.) - [ ] **Step 5: Run — expect pass** ``` cargo test -p relicario-cli --test entropy_floor ``` Expected: both tests PASS. - [ ] **Step 6: Commit** ``` git add crates/relicario-cli/src/helpers.rs crates/relicario-cli/src/main.rs \ crates/relicario-cli/tests/entropy_floor.rs git commit -m "feat(cli): enforce zxcvbn>=3 entropy floor at vault init" ``` --- ### Task 11: Soft warning at unlock for grandfathered weak passphrases **Files:** - Modify: `crates/relicario-cli/src/session.rs` (after successful unlock, check passphrase strength and emit a one-shot stderr warning) - Test: `crates/relicario-cli/tests/entropy_floor.rs` - [ ] **Step 1: Write the failing test** Append to `crates/relicario-cli/tests/entropy_floor.rs`: ```rust #[test] fn existing_vault_with_weak_passphrase_warns_at_unlock() { let tmp = TempDir::new().unwrap(); // Bypass the floor for setup — construct a vault directly via the core API // with a weak passphrase. This simulates a vault created before the floor // existed. common::init_vault_bypassing_floor(tmp.path(), "password"); let out = Command::cargo_bin("relicario").unwrap() .current_dir(tmp.path()) .arg("list") .env("RELICARIO_PASSPHRASE", "password") .output().unwrap(); assert!(out.status.success(), "list should still succeed (soft warning)"); let stderr = String::from_utf8(out.stderr).unwrap(); assert!(stderr.contains("entropy floor") || stderr.contains("weak passphrase"), "stderr should contain the soft warning: {stderr}"); } ``` `common::init_vault_bypassing_floor` calls into `relicario-core` directly (not through the CLI binary) so the floor is not enforced — simulating a pre-floor vault. - [ ] **Step 2: Run — expect failure (no warning currently emitted)** ``` cargo test -p relicario-cli --test entropy_floor existing_vault_with_weak_passphrase_warns_at_unlock ``` Expected: FAIL (no warning in stderr). - [ ] **Step 3: Implement the warning** In `crates/relicario-cli/src/session.rs`, in the function that establishes `UnlockedVault` after a successful master-key derivation (typically `unlock_current_vault` or equivalent), add: ```rust // Soft warning for vaults whose passphrase falls below the current entropy floor. // Non-blocking — the vault opens, but the user is told the recovery-QR feature // will be less safe without rotation. let est = relicario_core::rate_passphrase(&passphrase_str); if est.score < 3 { eprintln!( "warning: your passphrase scores {} (zxcvbn); the current entropy floor is 3. \ Consider rotating to enable a secure recovery QR.", est.score ); } ``` - [ ] **Step 4: Run — expect pass** ``` cargo test -p relicario-cli --test entropy_floor ``` Expected: all tests PASS. - [ ] **Step 5: Commit** ``` git add crates/relicario-cli/src/session.rs crates/relicario-cli/tests/entropy_floor.rs git commit -m "feat(cli): soft entropy-floor warning at unlock for legacy vaults" ``` --- ## Phase F — Extension: recovery-QR popup + vault button + unlock-flow link The extension's existing structure (popup-based for compactness, fullscreen vault for richer UI) defines where each piece lands. This phase assumes the reader has read `extension/src/vault/vault.ts` and `extension/src/popup/popup.ts` to find the existing unlock dialog and vault-tab settings sections. ### Task 12: Recovery-QR popup window — HTML, controller, canvas QR **Files:** - Create: `extension/src/recovery-qr/recovery-qr.html` - Create: `extension/src/recovery-qr/recovery-qr.ts` - Create: `extension/src/recovery-qr/recovery-qr.css` - Create: `extension/src/recovery-qr/__tests__/recovery-qr.test.ts` - Modify: `extension/vite.config.ts` (or build config) to register the new entrypoint - Modify: `extension/package.json` to add a QR generator dependency (e.g. `qrcode-generator` or `qrcode`) - [ ] **Step 1: Add the QR library dependency** ``` cd extension && npm install qrcode --save ``` (`qrcode` exposes `toCanvas(canvas, dataArray, options)` synchronously from typed-array input — exactly what we need.) - [ ] **Step 2: Create the HTML scaffold** `extension/src/recovery-qr/recovery-qr.html`: ```html Recovery QR

Recovery QR

Snap this QR with your phone, or click Print.
This QR alone cannot unlock your vault.
Combined with your passphrase, it can.

⚠ Windows users: prefer Display over Print. The system print queue may briefly cache the QR.

``` - [ ] **Step 3: Add the CSS** `extension/src/recovery-qr/recovery-qr.css`: ```css .recovery-qr-page { font-family: system-ui, sans-serif; max-width: 480px; margin: 24px auto; padding: 16px; text-align: center; } .recovery-qr-page canvas { image-rendering: pixelated; border: 1px solid #ddd; background: #fff; } .recovery-qr-page .actions { margin-top: 16px; display: flex; gap: 8px; justify-content: center; } .recovery-qr-page .windows-note { font-size: 0.85rem; color: #888; margin-top: 16px; } @media print { body * { visibility: hidden; } .recovery-qr-page, .recovery-qr-page canvas, .recovery-qr-page * { visibility: visible; } .recovery-qr-page { position: absolute; top: 0; left: 0; } .recovery-qr-page .actions, .recovery-qr-page .instructions, .recovery-qr-page .windows-note { display: none; } } ``` - [ ] **Step 4: Add the failing controller test** `extension/src/recovery-qr/__tests__/recovery-qr.test.ts`: ```ts import { describe, it, expect, beforeEach, vi } from 'vitest'; import { JSDOM } from 'jsdom'; import { renderRecoveryQr, zeroAndCloseRecoveryQr } from '../recovery-qr'; describe('recovery-qr popup controller', () => { let dom: JSDOM; beforeEach(() => { dom = new JSDOM(` `); (global as any).document = dom.window.document; (global as any).window = dom.window; }); it('renders QR on the canvas from a payload', async () => { const payload = new Uint8Array(109).fill(0x42); await renderRecoveryQr(payload); const canvas = document.getElementById('qr-canvas') as HTMLCanvasElement; const ctx = canvas.getContext('2d')!; const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; // Some pixels must be non-default (i.e., the QR was drawn). const allWhite = Array.from(data).every((v, i) => i % 4 === 3 ? v === 0 : v === 255); expect(allWhite).toBe(false); }); it('zeroAndCloseRecoveryQr clears the canvas and zeros the payload', () => { const payload = new Uint8Array(109).fill(0x42); zeroAndCloseRecoveryQr(payload, /* closeWindow */ false); expect(Array.from(payload).every(b => b === 0)).toBe(true); const canvas = document.getElementById('qr-canvas') as HTMLCanvasElement; const ctx = canvas.getContext('2d')!; const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; expect(Array.from(data).every((v, i) => i % 4 === 3 ? v === 0 : v === 0)).toBe(true); }); }); ``` - [ ] **Step 5: Run — expect compile failure** ``` cd extension && npm run test -- recovery-qr ``` Expected: `Cannot find module '../recovery-qr'`. - [ ] **Step 6: Implement the controller** `extension/src/recovery-qr/recovery-qr.ts`: ```ts import QRCode from 'qrcode'; /** Render the given recovery payload bytes as a QR code on the canvas. * Pure DOM mutation — the function does not initiate any network or storage I/O. */ export async function renderRecoveryQr(payload: Uint8Array): Promise { const canvas = document.getElementById('qr-canvas') as HTMLCanvasElement; if (!canvas) throw new Error('qr-canvas element not found'); await QRCode.toCanvas(canvas, [{ data: payload, mode: 'byte' }], { errorCorrectionLevel: 'M', margin: 2, width: canvas.width, }); } /** Zero the payload buffer and clear the canvas. If closeWindow is true, * also call window.close() — used by the Done button and the beforeunload handler. */ export function zeroAndCloseRecoveryQr(payload: Uint8Array, closeWindow: boolean): void { payload.fill(0); const canvas = document.getElementById('qr-canvas') as HTMLCanvasElement | null; if (canvas) { const ctx = canvas.getContext('2d'); if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); } if (closeWindow) window.close(); } // Boot: read the payload bytes from the opener via window.opener.postMessage, // or from a session-storage key set by the opener immediately before // window.open. (Implementation detail; sessionStorage is in-process only, // scoped to the popup, and zeroed at zeroAndCloseRecoveryQr time.) async function boot() { const hex = sessionStorage.getItem('relicario.recovery-qr-payload-hex'); if (!hex) { document.body.innerHTML = '

Error: no payload available. Reopen from the vault tab.

'; return; } sessionStorage.removeItem('relicario.recovery-qr-payload-hex'); const payload = new Uint8Array(hex.match(/.{2}/g)!.map(b => parseInt(b, 16))); await renderRecoveryQr(payload); document.getElementById('btn-print')?.addEventListener('click', () => { window.print(); }); document.getElementById('btn-done')?.addEventListener('click', () => { zeroAndCloseRecoveryQr(payload, true); }); window.addEventListener('beforeunload', () => { zeroAndCloseRecoveryQr(payload, false); }); } if (typeof document !== 'undefined' && document.readyState !== 'loading') { void boot(); } else if (typeof document !== 'undefined') { document.addEventListener('DOMContentLoaded', () => { void boot(); }); } ``` - [ ] **Step 7: Register the entrypoint in the build config** In `extension/vite.config.ts` (or whatever the build config is — extensions typically use Vite or Webpack), add `recovery-qr/recovery-qr.html` to the `rollupOptions.input` map. Match the pattern used for the existing popup/vault entrypoints. - [ ] **Step 8: Run the tests — expect pass** ``` cd extension && npm run test -- recovery-qr ``` Expected: PASS. - [ ] **Step 9: Commit** ``` git add extension/src/recovery-qr/ extension/package.json extension/package-lock.json extension/vite.config.ts git commit -m "feat(ext/recovery-qr): popup window with canvas QR + print + zero-on-close" ``` --- ### Task 13: "Generate recovery QR" button in vault tab **Files:** - Modify: the vault-tab settings section (read `extension/src/vault/vault.ts` and the components it imports to find the right surface) - Modify: the service-worker bridge that calls into wasm (read `extension/src/service-worker/` to find existing wasm-call patterns) This task adds a button under a new "Disaster recovery" section in the vault tab. Clicking it: 1. Sends a message to the service worker requesting `{ kind: 'generate-recovery-payload' }`. 2. The service worker, holding the unlocked session, calls `wasm.generate_recovery_payload(passphrase, image_secret)` and returns the 109-byte payload as a hex string. 3. The vault tab opens a popup window pointed at `recovery-qr.html`, having first set `sessionStorage['relicario.recovery-qr-payload-hex'] = hex` in the popup's storage namespace via `window.open` + a same-origin handshake. The `sessionStorage` approach is suitable because the popup is same-origin (extension URL) and `sessionStorage` is per-window-tab. An alternative is `postMessage` between opener and popup; either works. The plan author should pick whichever fits the existing extension's IPC patterns. - [ ] **Step 1: Locate the vault settings surface** ``` grep -rn "Settings\|settings-section\|vault.*tab" extension/src/vault/ | head ``` Read the matched files to identify the natural insertion point for a new section. The expected pattern: a function or component that renders a list of `
` blocks. Add a "Disaster recovery" section after the existing settings groups. - [ ] **Step 2: Add the section + button** Add markup (matching the surrounding component conventions): ```ts section({ title: 'Disaster recovery' }, [ p('If you lose your reference image, this QR — combined with your passphrase — can recover it.'), button({ id: 'btn-generate-recovery-qr', label: 'Generate recovery QR', onclick: openRecoveryQrPopup, }), ]); ``` `openRecoveryQrPopup` function: ```ts async function openRecoveryQrPopup() { const resp = await chrome.runtime.sendMessage({ kind: 'generate-recovery-payload' }); if (!resp.ok) { showError(resp.error ?? 'Failed to generate recovery payload'); return; } const popup = window.open('recovery-qr.html', 'relicario-recovery-qr', 'width=520,height=620,resizable=no'); if (!popup) { showError('Failed to open recovery-QR window. Allow popups for this extension.'); return; } // Wait for the popup to load, then write payload hex into its sessionStorage. popup.addEventListener('load', () => { popup.sessionStorage.setItem('relicario.recovery-qr-payload-hex', resp.payload_hex); // Trigger boot: dispatch a synthetic event the popup listens for. popup.dispatchEvent(new Event('relicario.payload-ready')); }); } ``` (The popup's `boot()` from Task 12 should also listen for `relicario.payload-ready` in addition to firing on `DOMContentLoaded` — adjust the `boot()` glue accordingly.) - [ ] **Step 3: Add the service-worker handler** In the service-worker message router, add: ```ts case 'generate-recovery-payload': { const session = getActiveSession(); // existing accessor if (!session) return { ok: false, error: 'vault is locked' }; const payload = wasm.generate_recovery_payload(session.passphrase, session.image_secret); return { ok: true, payload_hex: bytesToHex(payload) }; } ``` If the session does not retain `passphrase` and `image_secret` past the unlock step, retain them deliberately for the lifetime of the session. Both are already-Zeroizing-equivalent in the wasm bindings; the JS-side caches are plain `Uint8Array` and should be cleared on lock. Note: this is a real but bounded change to session state — call it out in the PR description. - [ ] **Step 4: Smoke test manually + via the existing extension test harness** Run the extension's e2e suite (Playwright or whatever harness exists). Verify clicking the button opens the popup and a QR appears. No on-disk file is produced. - [ ] **Step 5: Commit** ``` git add extension/src/vault/ extension/src/service-worker/ git commit -m "feat(ext/vault): generate-recovery-qr button + service-worker handler" ``` --- ### Task 14: "Use recovery QR" link in unlock dialog + post-recovery banner **Files:** - Modify: the unlock dialog (find via `grep -rn "unlock\|reference.*image" extension/src/popup/`) - Modify: the service-worker unlock handler Adds a link below the reference-image picker labeled "Use recovery QR". Clicking it swaps the picker for a hex-paste textarea + a "Recover and unlock" button. On submit, the service worker: 1. Calls `wasm.unwrap_recovery_payload(passphrase, payload_bytes)` to recover `image_secret`. 2. Uses the recovered `image_secret` to derive the master key and proceed with normal unlock. 3. After successful unlock, sets a one-shot session flag `recoveryUsed = true` so the vault tab can show a banner: "You unlocked using recovery. Re-establish your reference image before relying on it. [Open image creation flow]". - [ ] **Step 1: Add the UI swap** In the unlock dialog template, after the reference-image picker, add: ```html Use recovery QR instead ``` Wire the link to toggle visibility, hide the file picker, and on submit send `{ kind: 'unlock-with-recovery', passphrase, payload_hex }` to the service worker. - [ ] **Step 2: Add the service-worker handler** ```ts case 'unlock-with-recovery': { const payload = hexToBytes(msg.payload_hex); let secretBytes: Uint8Array; try { secretBytes = wasm.unwrap_recovery_payload(msg.passphrase, payload); } catch (e) { return { ok: false, error: 'Wrong passphrase or invalid payload.' }; } // Reuse the standard unlock flow with the recovered image_secret. const session = await unlockWithImageSecret(msg.passphrase, secretBytes); session.recoveryUsed = true; return { ok: true }; } ``` - [ ] **Step 3: Add the post-unlock banner** In the vault-tab boot, check `session.recoveryUsed` and render a banner if true: ```ts if (session.recoveryUsed) { banner({ severity: 'warning', text: 'You unlocked using a recovery QR. Re-establish your reference image to keep your vault recoverable in future emergencies.', actions: [{ label: 'Re-establish reference image', onclick: openReestablishFlow }], }); } ``` `openReestablishFlow` either reuses the setup wizard's image-creation step or shows minimal in-vault UI (carrier-image picker → call `wasm.embed_image_secret` → save). Either way, **the recovered `image_secret` is held only in the service worker session and used for embedding immediately; it does not round-trip through extension UI as a Uint8Array beyond what the existing image-embed path already does.** - [ ] **Step 4: Manual e2e + extension tests** Verify: paste a valid hex payload + correct passphrase → unlock succeeds + banner shows. Wrong passphrase → user-facing error, no information leak about which factor is wrong. - [ ] **Step 5: Commit** ``` git add extension/src/popup/ extension/src/service-worker/ extension/src/vault/ git commit -m "feat(ext/unlock): use-recovery-qr flow + post-recovery banner" ``` --- ### Task 15: zxcvbn ≥ 3 hard gate in setup wizard + soft warning at unlock **Files:** - Modify: `extension/src/setup/setup.ts` — Step 3a's Next-button gate - Modify: the unlock flow's success path — emit a banner if the unlocked vault's passphrase scored < 3 - [ ] **Step 1: Find the existing strength meter call** ``` grep -n "rate_passphrase\|zxcvbn" extension/src/setup/setup.ts ``` Today the meter calls `wasm.rate_passphrase` and renders a colored bar. The Next button advances regardless of score. - [ ] **Step 2: Wire the gate** In the Step 3a state-update handler that runs after each keystroke on the passphrase field, set: ```ts const est = wasm.rate_passphrase(passphraseInput.value); strengthMeter.update(est); nextButton.disabled = est.score < 3; if (est.score < 3) { hintEl.textContent = `Passphrase must reach zxcvbn score 3 (currently ${est.score}). Try a 4-word BIP39 phrase via the Generate button.`; } else { hintEl.textContent = ''; } ``` The wizard already has a "Generate passphrase" affordance in the same step (mentioned in the existing setup.ts); just make sure clicking it produces a passphrase that meets the floor. - [ ] **Step 3: Add the soft warning at unlock** In the unlock success path, after deriving the master key: ```ts const est = wasm.rate_passphrase(passphraseInput.value); if (est.score < 3) { showBanner('warning', `Your passphrase scores ${est.score}/4. Consider rotating to enable a secure recovery QR.`); } ``` - [ ] **Step 4: Update existing setup-flow tests** Any test that initialized a vault with a low-entropy passphrase will now fail — fix the test fixtures to use strong passphrases (the BIP39 generator output is suitable). - [ ] **Step 5: Run all extension tests** ``` cd extension && npm run test ``` Expected: all PASS (with fixture updates as needed). - [ ] **Step 6: Commit** ``` git add extension/src/setup/setup.ts extension/src/popup/ extension/__tests__/ git commit -m "feat(ext): enforce zxcvbn>=3 entropy floor in setup wizard + unlock warning" ``` --- ## Self-Review Notes After writing this plan, the following spec-coverage check was performed: - **Recovery KDF input + domain separation:** Tasks 3, 5. - **Type-level `RecoveryKdfParams` floor:** Task 2. - **Shared `normalize_passphrase`:** Task 1. - **Memory hygiene (Zeroizing):** Tasks 3, 4 (wrap_key is `Zeroizing`; payload itself is encrypted output and not Zeroized — explicit in spec). - **No on-disk path (CLI):** Task 8 — clap surface assertion that `--out` and `--file` do not exist. - **No on-disk path (extension):** Task 12 — no `` or Blob URL in the popup; `oncontextmenu="return false"` on the canvas. - **Print pipeline + Windows note:** Tasks 8 (Linux/macOS `lp`; Windows users see the in-app warning), 12 (extension `window.print()` + `@media print` + Windows note in HTML). - **CLI surfaces (generate, unlock, hex):** Tasks 8, 9. - **`imgsecret embed` for re-establishment:** Task 7. - **Wasm bindings:** Task 6. - **Extension popup window:** Task 12. - **Vault-tab button:** Task 13. - **Unlock-flow recovery link + post-recovery banner:** Task 14. - **Entropy floor at init (CLI):** Task 10. - **Soft warning at unlock for grandfathered weak passphrases (CLI):** Task 11. - **Pre-flight check + `--force-weak-passphrase` for CLI recovery-qr generate:** Task 8. - **Entropy floor at extension setup wizard + soft warning:** Task 15. - **Tests for round-trip, wrong passphrase, tamper, bad magic, version, length:** Task 4. - **Domain-separation regression test:** Task 5. - **NFC parity test:** Task 5. - **No-`--out` clap-surface negative test:** Task 8. - **End-to-end CLI recovery test:** Task 9. - **Init-rejects-weak / init-accepts-strong tests:** Task 10. - **Existing-weak-vault soft warning test:** Task 11. - **Wizard rejects weak / popup never writes file / Done clears canvas:** Task 12 (canvas test) + Task 15 (wizard gate test). No gaps identified. No placeholders. Type and signature consistency: `generate`/`unwrap` signatures match across core, wasm, and CLI. --- ## Execution Handoff Plan complete and saved to `docs/superpowers/plans/2026-05-01-recovery-qr-and-entropy-floor.md`. When ready to execute, the user's preference per `feedback_subagent_default` is **subagent-driven**: a fresh subagent per task, with two-stage review between tasks. No need to ask — proceed in subagent-driven mode.