Files
relicario/docs/superpowers/plans/2026-05-01-recovery-qr-and-entropy-floor.md
adlee-was-taken eb443c38b4 docs(plans): recovery QR + entropy floor; password coloring
Two implementation plans, one per spec landed in 00da7e7. Each plan
decomposes its spec into bite-sized TDD tasks with exact file paths,
complete code, and per-task commits.

- recovery-qr-and-entropy-floor.md (15 tasks, 6 phases): core crypto
  module + wasm bindings + CLI subcommands (imgsecret embed, recovery-qr
  generate/unlock, --force-weak-passphrase) + extension popup window
  with canvas QR + vault-tab button + unlock-flow recovery link +
  zxcvbn>=3 hard gate at init (CLI + setup wizard) + soft warning at
  unlock for grandfathered weak vaults.
- password-coloring.md (9 tasks, 6 phases): pure colorizePassword()
  utility + chrome.storage.sync round-trip + applyColorScheme() boot
  step + four reveal-surface integrations (field history, popup item
  detail, fullscreen item detail, generator preview) + settings UI
  with color pickers and live-preview swatch. Task 6 (fullscreen)
  flagged for coordination with in-flight Phase 1 UX work.

Both plans follow the subagent-driven execution preference per
feedback_subagent_default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 16:25:33 -04:00

65 KiB

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.rsRecoveryKdfParams (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.rspub 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:

#[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):

/// 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<u8> {
    match std::str::from_utf8(passphrase) {
        Ok(s) => s.nfc().collect::<String>().into_bytes(),
        Err(_) => passphrase.to_vec(),
    }
}

In derive_master_key, replace lines 223-227 with:

    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:

#[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:

    /// 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:

//! 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<Vec<u8>> {
    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:

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:

#[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:

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<Zeroizing<[u8; 32]>> {
    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<Vec<u8>> {
    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:

#[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, &params).unwrap();
    let recovered = unwrap(pp, &payload, &params).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, &params).unwrap();
    let err = unwrap(b"wrong", &payload, &params).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, &params).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, &params).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():

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:

#[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, &params).unwrap();
    let recovered_via_nfd = unwrap(pp_nfd, &payload, &params).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:

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<Vec<u8>, 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<Vec<u8>, 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:

//! 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:

    /// Image-secret operations (DCT steganography).
    Imgsecret {
        #[command(subcommand)]
        action: ImgsecretAction,
    },

Add the action enum below the existing nested *Action enums:

#[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:

        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:

#[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:

[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:

    /// 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:

#[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:

        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::<Dense1x2>()
                    .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<Vec<u8>> 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<Vec<u8>> 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 <hex> 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 <hex> 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:

#[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:

#[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:

    if hex {
        println!("{}", hex::encode(&payload));
    } else {
        // existing QR rendering
    }

Add Unlock dispatch:

        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 <new.jpg> --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:

//! 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:

/// 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<String> {
    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:

#[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:

// 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"

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:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Recovery QR</title>
  <link rel="stylesheet" href="./recovery-qr.css">
</head>
<body>
  <main class="recovery-qr-page">
    <h1>Recovery QR</h1>
    <canvas id="qr-canvas" width="320" height="320" oncontextmenu="return false;"></canvas>
    <p class="instructions">
      Snap this QR with your phone, or click <strong>Print</strong>.<br>
      <em>This QR alone cannot unlock your vault.</em><br>
      Combined with your passphrase, it can.
    </p>
    <div class="actions">
      <button id="btn-print">Print</button>
      <button id="btn-done">Done</button>
    </div>
    <p class="windows-note">
      ⚠ Windows users: prefer <em>Display</em> over <em>Print</em>.
      The system print queue may briefly cache the QR.
    </p>
  </main>
  <script type="module" src="./recovery-qr.ts"></script>
</body>
</html>
  • Step 3: Add the CSS

extension/src/recovery-qr/recovery-qr.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:

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(`<!DOCTYPE html><body>
      <canvas id="qr-canvas" width="320" height="320"></canvas>
    </body>`);
    (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:

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<void> {
  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 = '<p>Error: no payload available. Reopen from the vault tab.</p>';
    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 <section> blocks. Add a "Disaster recovery" section after the existing settings groups.

  • Step 2: Add the section + button

Add markup (matching the surrounding component conventions):

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:

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:

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:

<a href="#" id="link-use-recovery-qr">Use recovery QR instead</a>

<div id="recovery-paste" hidden>
  <label>Paste the hex payload from your recovery QR:</label>
  <textarea id="recovery-payload-hex" rows="4"></textarea>
  <button id="btn-recovery-unlock">Recover and unlock</button>
</div>

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
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:

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:

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:

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 <a download> 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.