# Dev C Kickoff Prompt — v0.5.1 Stream C (Recovery QR)

> **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.

Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.

---

You are a **senior developer** owning Stream C for the Relicario v0.5.1 release. Stream C implements the Recovery QR feature: Rust core + WASM bindings + CLI subcommand + setup wizard redesign + settings-security.ts component.

**Goal:** Ship `generate_recovery_qr` / `unwrap_recovery_qr` in relicario-core and WASM, a `recovery-qr` CLI subcommand, a redesigned setup wizard (Style C with glyphs), and a three-state security section component for the settings page.

**Architecture:** Rust core is the canonical implementation (bytes-in/bytes-out). WASM wraps it for the extension. The extension component (`settings-security.ts`) is fully owned by this stream — DEV-B stubs the import. Session storage in WASM is extended to hold `image_secret` alongside `master_key` so QR generation doesn't require re-uploading the reference image.

**Tech Stack:** Rust, `qrcode` crate (SVG output), `chacha20poly1305`, `argon2`, `unicode-normalization`; TypeScript (vitest), wasm-bindgen.

---

## Setup (do this first)

```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git worktree add ../relicario.v0.5.1-stream-c -b feature/v0.5.1-stream-c-recovery-qr
cd ../relicario.v0.5.1-stream-c
pwd  # should print /home/alee/Sources/relicario.v0.5.1-stream-c
```

**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.5.1-stream-c`**. Every subagent prompt MUST begin with `cd /home/alee/Sources/relicario.v0.5.1-stream-c`.

Today: 2026-05-03. Project rules in `CLAUDE.md` apply.

## Required reading

1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md` — spec sections C1–C6
3. `crates/relicario-core/src/crypto.rs` — existing KDF and AEAD implementation (read the public API)
4. `crates/relicario-wasm/src/session.rs` — current session storage (you will expand this)
5. `crates/relicario-wasm/src/lib.rs` — existing WASM bindings (you will add to these)
6. `extension/src/setup/setup.ts` — current setup wizard (you will redesign this)

## Execution mode

Use **subagent-driven-development**. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.v0.5.1-stream-c`.

## Scope and boundaries

**In scope:** C1 (recovery_qr.rs), C2 (CLI), C3 (WASM bindings), C4 (settings-security.ts), C5 (setup wizard QR banner), C6 (setup wizard redesign), session.rs expansion.

**Out of scope:** Stream A and B work. If you find a bug outside your scope, post it via `## QUESTION TO PM`.

**Hard rules:**
- QR payload bytes must NEVER be written to `chrome.storage`, IndexedDB, git, or the filesystem. Only `recovery_qr_generated_at` (timestamp) is persisted.
- The passphrase must NOT be logged, stored in a non-Zeroizing container, or leaked through error messages.
- `unlock()` change: image_secret must be stored in `SessionData` alongside master_key.
- Don't merge to main. The PM owns merges.

## Interface contract with DEV-B

You own `extension/src/popup/components/settings-security.ts`. DEV-B imports it. The agreed export signature:

```ts
export async function renderSecuritySection(
  container: HTMLElement,
  sessionHandle: number | null,
): Promise<void>;

export function teardownSecuritySection(): void;
```

DEV-B has a stub. Your Task 9 provides the real implementation.

## Coordination protocol

```
## STATUS UPDATE — DEV-C
Time: <iso8601>
Task: <N of 13>
Status: IN-PROGRESS | BLOCKED | REVIEW-READY
Summary: <one line>
Next: <next task or "waiting for PM">
```

---

## Files

**Create:**
- `crates/relicario-core/src/recovery_qr.rs` — core implementation
- `crates/relicario-core/tests/recovery_qr.rs` — integration tests
- `extension/src/popup/components/settings-security.ts` — three-state component

**Modify:**
- `crates/relicario-core/Cargo.toml` — add `qrcode`
- `crates/relicario-core/src/lib.rs` — `pub mod recovery_qr` + re-exports
- `crates/relicario-core/src/error.rs` — add `RecoveryQr` variant
- `crates/relicario-wasm/Cargo.toml` — add `base64` if not present (check first)
- `crates/relicario-wasm/src/session.rs` — expand to `SessionData`
- `crates/relicario-wasm/src/lib.rs` — update `unlock()`, add new bindings
- `crates/relicario-cli/src/main.rs` — add `recovery-qr` subcommand group
- `extension/src/setup/setup.ts` — wizard redesign + Step 5 QR banner

---

### Task 1: Add `qrcode` crate

**Files:**
- Modify: `crates/relicario-core/Cargo.toml`

- [ ] **Step 1: Add dependency**

```toml
# in [dependencies] section of crates/relicario-core/Cargo.toml
qrcode = { version = "0.14", default-features = false }
```

- [ ] **Step 2: Verify it compiles**

```bash
cargo build -p relicario-core 2>&1 | tail -5
```

Expected: no errors (qrcode may show download progress, that's fine).

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/Cargo.toml Cargo.lock
git commit -m "chore(core): add qrcode dependency for recovery QR"
```

---

### Task 2: `recovery_qr.rs` — core payload generation

**Files:**
- Create: `crates/relicario-core/src/recovery_qr.rs`

Binary payload layout (109 bytes):
```
[magic "RREC" 4B][version 0x01 1B][kdf_salt 32B][wrap_nonce 24B][ciphertext 48B]
```

`ciphertext` = `XChaCha20-Poly1305(wrap_key, wrap_nonce, image_secret)` where the AEAD tag is 16B → 32B + 16B = 48B.

KDF domain separation:
```
"relicario-recovery-v1\0" || u64_be(byte_len(nfc_passphrase)) || nfc_passphrase
```
Fed to Argon2id with production params (m=64MiB, t=3, p=4) and a 32-byte `kdf_salt` (OsRng).

- [ ] **Step 1: Write the file**

```rust
// crates/relicario-core/src/recovery_qr.rs
use chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead};
use rand::RngCore;
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;
use crate::{crypto::KdfParams, error::{RelicarioError, Result}};

const MAGIC: &[u8; 4] = b"RREC";
const VERSION: u8 = 0x01;
const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109

pub struct RecoveryQrPayload {
    bytes: [u8; PAYLOAD_LEN],
}

impl RecoveryQrPayload {
    pub fn as_bytes(&self) -> &[u8; PAYLOAD_LEN] {
        &self.bytes
    }
}

fn recovery_kdf_input(passphrase: &str) -> Vec<u8> {
    let nfc: String = passphrase.nfc().collect();
    let nfc_bytes = nfc.as_bytes();
    let prefix = b"relicario-recovery-v1\0";
    let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len());
    input.extend_from_slice(prefix);
    input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes());
    input.extend_from_slice(nfc_bytes);
    input
}

fn production_params() -> KdfParams {
    KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }
}

fn derive_wrap_key(
    passphrase: &str,
    kdf_salt: &[u8; 32],
    params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
    let input = recovery_kdf_input(passphrase);
    crate::crypto::derive_master_key_raw(&input, kdf_salt, params)
}

pub fn generate_recovery_qr(
    passphrase: &str,
    image_secret: &[u8; 32],
) -> Result<RecoveryQrPayload> {
    generate_recovery_qr_with_params(passphrase, image_secret, &production_params())
}

#[doc(hidden)]
pub fn generate_recovery_qr_with_params(
    passphrase: &str,
    image_secret: &[u8; 32],
    params: &KdfParams,
) -> Result<RecoveryQrPayload> {
    let mut kdf_salt = [0u8; 32];
    rand::rngs::OsRng.fill_bytes(&mut kdf_salt);

    let mut wrap_nonce = [0u8; 24];
    rand::rngs::OsRng.fill_bytes(&mut wrap_nonce);

    let wrap_key = derive_wrap_key(passphrase, &kdf_salt, params)?;
    let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
    let nonce = chacha20poly1305::XNonce::from_slice(&wrap_nonce);
    let ciphertext = cipher.encrypt(nonce, image_secret.as_ref())
        .map_err(|_| RelicarioError::RecoveryQr("wrap encrypt failed".into()))?;

    let mut bytes = [0u8; PAYLOAD_LEN];
    let mut pos = 0;
    bytes[pos..pos+4].copy_from_slice(MAGIC);     pos += 4;
    bytes[pos] = VERSION;                           pos += 1;
    bytes[pos..pos+32].copy_from_slice(&kdf_salt); pos += 32;
    bytes[pos..pos+24].copy_from_slice(&wrap_nonce); pos += 24;
    bytes[pos..pos+48].copy_from_slice(&ciphertext); // 48 = 32 + 16-tag

    Ok(RecoveryQrPayload { bytes })
}

pub fn unwrap_recovery_qr(
    payload_bytes: &[u8],
    passphrase: &str,
) -> Result<Zeroizing<[u8; 32]>> {
    unwrap_recovery_qr_with_params(payload_bytes, passphrase, &production_params())
}

#[doc(hidden)]
pub fn unwrap_recovery_qr_with_params(
    payload_bytes: &[u8],
    passphrase: &str,
    params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
    if payload_bytes.len() != PAYLOAD_LEN {
        return Err(RelicarioError::RecoveryQr(
            format!("payload must be {PAYLOAD_LEN} bytes, got {}", payload_bytes.len())
        ));
    }
    if &payload_bytes[0..4] != MAGIC {
        return Err(RelicarioError::RecoveryQr("bad magic".into()));
    }
    if payload_bytes[4] != VERSION {
        return Err(RelicarioError::RecoveryQr(
            format!("unsupported version 0x{:02x}", payload_bytes[4])
        ));
    }
    let kdf_salt: &[u8; 32] = payload_bytes[5..37].try_into().unwrap();
    let wrap_nonce = &payload_bytes[37..61];
    let ciphertext = &payload_bytes[61..109];

    let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?;
    let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
    let nonce = chacha20poly1305::XNonce::from_slice(wrap_nonce);
    let plaintext = cipher.decrypt(nonce, ciphertext)
        .map_err(|_| RelicarioError::Decrypt)?;

    let mut out = Zeroizing::new([0u8; 32]);
    out.copy_from_slice(&plaintext);
    Ok(out)
}
```

- [ ] **Step 2: Add `RecoveryQr` variant to error.rs**

In `crates/relicario-core/src/error.rs`, add after the `HotpNotSupported` variant:

```rust
    /// Recovery QR generation or parsing failed.
    #[error("recovery QR: {0}")]
    RecoveryQr(String),
```

- [ ] **Step 3: Check if `derive_master_key_raw` exists**

```bash
grep -n "pub fn derive_master_key" crates/relicario-core/src/crypto.rs
```

If only `derive_master_key` exists (taking `passphrase_bytes: &[u8], image_secret: &[u8; 32]`), you need to add a `derive_master_key_raw(input: &[u8], salt: &[u8; 32], params: &KdfParams)` variant. Check the crypto.rs implementation and add it if needed.

- [ ] **Step 4: Compile**

```bash
cargo build -p relicario-core 2>&1 | grep -E "error|warning: unused"
```

Expected: compiles clean or only pre-existing warnings.

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-core/src/recovery_qr.rs crates/relicario-core/src/error.rs
git commit -m "feat(core): recovery_qr generate + unwrap functions"
```

---

### Task 3: `recovery_qr_to_svg`

**Files:**
- Modify: `crates/relicario-core/src/recovery_qr.rs`

- [ ] **Step 1: Add the SVG function**

Add to `recovery_qr.rs`:

```rust
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String {
    use qrcode::{QrCode, EcLevel};
    let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M)
        .expect("109-byte payload always fits QR version 6");
    let svg_str = code.render::<qrcode::render::svg::Color>()
        .min_dimensions(140, 140)
        .build();
    svg_str
}
```

- [ ] **Step 2: Compile and check**

```bash
cargo build -p relicario-core 2>&1 | grep error
```

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/src/recovery_qr.rs
git commit -m "feat(core): recovery_qr_to_svg renders 140px SVG"
```

---

### Task 4: Wire into `lib.rs`

**Files:**
- Modify: `crates/relicario-core/src/lib.rs`

- [ ] **Step 1: Add module and re-exports**

Add to `lib.rs` (after the `device` module block):

```rust
pub mod recovery_qr;
pub use recovery_qr::{
    generate_recovery_qr, generate_recovery_qr_with_params,
    recovery_qr_to_svg,
    unwrap_recovery_qr, unwrap_recovery_qr_with_params,
    RecoveryQrPayload,
};
```

- [ ] **Step 2: Compile**

```bash
cargo build -p relicario-core 2>&1 | grep error
```

- [ ] **Step 3: Commit**

```bash
git add crates/relicario-core/src/lib.rs
git commit -m "chore(core): re-export recovery_qr module"
```

---

### Task 5: Integration tests for recovery QR

**Files:**
- Create: `crates/relicario-core/tests/recovery_qr.rs`

- [ ] **Step 1: Write failing tests**

```rust
use relicario_core::{
    crypto::KdfParams,
    generate_recovery_qr_with_params, recovery_qr_to_svg, unwrap_recovery_qr_with_params,
};

fn fast_params() -> KdfParams {
    KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
}

fn test_secret() -> [u8; 32] {
    let mut s = [0u8; 32];
    for (i, b) in s.iter_mut().enumerate() { *b = i as u8; }
    s
}

#[test]
fn roundtrip_recovers_image_secret() {
    let passphrase = "correct-horse-battery-staple";
    let secret = test_secret();
    let payload = generate_recovery_qr_with_params(passphrase, &secret, &fast_params())
        .expect("generate ok");
    let recovered = unwrap_recovery_qr_with_params(payload.as_bytes(), passphrase, &fast_params())
        .expect("unwrap ok");
    assert_eq!(recovered.as_ref(), &secret);
}

#[test]
fn wrong_passphrase_fails_decrypt() {
    let secret = test_secret();
    let payload = generate_recovery_qr_with_params("right-pass", &secret, &fast_params())
        .expect("generate ok");
    let result = unwrap_recovery_qr_with_params(payload.as_bytes(), "wrong-pass", &fast_params());
    assert!(result.is_err());
}

#[test]
fn payload_is_109_bytes() {
    let secret = test_secret();
    let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
        .expect("generate ok");
    assert_eq!(payload.as_bytes().len(), 109);
}

#[test]
fn svg_output_is_non_empty_xml() {
    let secret = test_secret();
    let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
        .expect("generate ok");
    let svg = recovery_qr_to_svg(&payload);
    assert!(svg.contains("<svg"), "SVG output should contain <svg tag");
    assert!(!svg.is_empty());
}

#[test]
fn bad_magic_returns_error() {
    let mut bad = [0u8; 109];
    bad[0..4].copy_from_slice(b"NOPE");
    let result = unwrap_recovery_qr_with_params(&bad, "pass", &fast_params());
    assert!(result.is_err());
}
```

- [ ] **Step 2: Run and confirm they fail**

```bash
cargo test -p relicario-core --test recovery_qr 2>&1 | tail -20
```

Expected: compile errors or test panics (module not wired yet or functions not yet fully implemented).

- [ ] **Step 3: Run after Task 2/3/4 are complete and confirm they pass**

```bash
cargo test -p relicario-core --test recovery_qr -- --nocapture 2>&1 | tail -20
```

Expected: 5 tests pass.

- [ ] **Step 4: Run full test suite**

```bash
cargo test -p relicario-core 2>&1 | tail -10
```

Expected: all tests pass (130+ tests green).

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-core/tests/recovery_qr.rs
git commit -m "test(core): recovery_qr roundtrip + error cases"
```

---

### Task 6: Expand WASM `session.rs` to store `image_secret`

**Files:**
- Modify: `crates/relicario-wasm/src/session.rs`

Current: stores only `Zeroizing<[u8; 32]>` per handle.
New: stores `SessionData { master_key, image_secret }` per handle.

The public `with(handle, |key| ...)` signature is preserved (passes `&Zeroizing<[u8;32]>` = master_key). A new `with_image_secret(handle, |secret| ...)` is added.

- [ ] **Step 1: Rewrite session.rs**

```rust
use std::cell::RefCell;
use std::collections::HashMap;
use zeroize::Zeroizing;

pub struct SessionData {
    pub master_key: Zeroizing<[u8; 32]>,
    pub image_secret: Zeroizing<[u8; 32]>,
}

thread_local! {
    static SESSIONS: RefCell<HashMap<u32, SessionData>> = RefCell::new(HashMap::new());
    static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
}

pub fn insert(master_key: Zeroizing<[u8; 32]>, image_secret: Zeroizing<[u8; 32]>) -> u32 {
    let handle = NEXT_HANDLE.with(|n| {
        let mut n = n.borrow_mut();
        let h = *n;
        *n = n.wrapping_add(1);
        if *n == 0 { *n = 1; }
        h
    });
    SESSIONS.with(|s| {
        s.borrow_mut().insert(handle, SessionData { master_key, image_secret });
    });
    handle
}

/// Access the master key for a handle. Preserves original `with` signature for all existing callers.
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
where
    F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
    SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.master_key)))
}

/// Access the image_secret for a handle (used by recovery QR).
pub fn with_image_secret<F, R>(handle: u32, f: F) -> Option<R>
where
    F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
    SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret)))
}

pub fn remove(handle: u32) -> bool {
    SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
}

#[cfg(test)]
pub fn clear() {
    SESSIONS.with(|s| s.borrow_mut().clear());
}
```

- [ ] **Step 2: Update `unlock()` in lib.rs to pass image_secret to session::insert**

In `crates/relicario-wasm/src/lib.rs`, find the `unlock()` function. Currently it extracts `image_secret` and discards it after `derive_master_key`. Change it to also store image_secret:

```rust
#[wasm_bindgen]
pub fn unlock(
    passphrase: &str,
    image_bytes: &[u8],
    salt: &[u8],
    params_json: &str,
) -> Result<SessionHandle, JsError> {
    let params: KdfParams = serde_json::from_str(params_json)
        .map_err(|e| JsError::new(&format!("params: {e}")))?;
    let image_secret = imgsecret::extract(image_bytes)
        .map_err(|e| JsError::new(&e.to_string()))?;
    let salt_arr: &[u8; 32] = salt.try_into()
        .map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
    let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params)
        .map_err(|e| JsError::new(&e.to_string()))?;
    let stored_image_secret = Zeroizing::new(image_secret);
    let handle = session::insert(master_key, stored_image_secret);
    Ok(SessionHandle(handle))
}
```

Add `use zeroize::Zeroizing;` to lib.rs imports if not already present.

- [ ] **Step 3: Compile**

```bash
cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | grep error
```

Expected: clean (all existing callers still compile because `session::with` signature is unchanged).

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-wasm/src/session.rs crates/relicario-wasm/src/lib.rs
git commit -m "feat(wasm): session stores image_secret for recovery QR generation"
```

---

### Task 7: WASM bindings — `generate_recovery_qr` + `unwrap_recovery_qr`

**Files:**
- Modify: `crates/relicario-wasm/src/lib.rs`
- Modify: `crates/relicario-wasm/Cargo.toml` (add `base64` if not present)

- [ ] **Step 1: Check if base64 is already in wasm Cargo.toml**

```bash
grep "base64" crates/relicario-wasm/Cargo.toml
```

If not present, add it:
```toml
base64 = "0.22"
```

- [ ] **Step 2: Add WASM bindings to lib.rs**

Add at the end of `crates/relicario-wasm/src/lib.rs`:

```rust
use relicario_core::{generate_recovery_qr, recovery_qr_to_svg, unwrap_recovery_qr};

/// Generate a recovery QR SVG for the current session. Returns the SVG string.
/// The passphrase is needed because the QR wraps the image_secret under a
/// passphrase-derived key (separate from the master key).
#[wasm_bindgen]
pub fn wasm_generate_recovery_qr(
    handle: &SessionHandle,
    passphrase: &str,
) -> Result<String, JsError> {
    let image_secret = session::with_image_secret(handle.0, |s| *s.as_ref())
        .ok_or_else(|| JsError::new("invalid or locked session handle"))?;
    let payload = generate_recovery_qr(passphrase, &image_secret)
        .map_err(|e| JsError::new(&e.to_string()))?;
    Ok(recovery_qr_to_svg(&payload))
}

/// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase.
/// Returns the raw image_secret bytes (32 bytes).
#[wasm_bindgen]
pub fn wasm_unwrap_recovery_qr(
    payload_b64: &str,
    passphrase: &str,
) -> Result<Vec<u8>, JsError> {
    use base64::{engine::general_purpose::STANDARD, Engine};
    let bytes = STANDARD.decode(payload_b64)
        .map_err(|e| JsError::new(&format!("base64: {e}")))?;
    let recovered = unwrap_recovery_qr(&bytes, passphrase)
        .map_err(|e| JsError::new(&e.to_string()))?;
    Ok(recovered.to_vec())
}
```

- [ ] **Step 3: Compile WASM**

```bash
cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | grep error
```

Expected: clean.

- [ ] **Step 4: Run WASM tests**

```bash
cargo test -p relicario-wasm 2>&1 | tail -10
```

Expected: existing 3 tests still pass.

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-wasm/src/lib.rs crates/relicario-wasm/Cargo.toml
git commit -m "feat(wasm): expose generate_recovery_qr and unwrap_recovery_qr bindings"
```

---

### Task 8: CLI `recovery-qr` subcommand

**Files:**
- Modify: `crates/relicario-cli/src/main.rs`

- [ ] **Step 1: Add the subcommand group to the clap surface**

In `main.rs`, find the `Commands` enum and add:

```rust
/// Recovery QR operations.
RecoveryQr {
    #[command(subcommand)]
    cmd: RecoveryQrCmd,
},
```

Add a new enum:

```rust
#[derive(clap::Subcommand)]
enum RecoveryQrCmd {
    /// Generate a recovery QR code and display it in the terminal.
    Generate,
    /// Unwrap a recovery QR payload (base64) and print the image_secret as hex.
    Unwrap,
}
```

- [ ] **Step 2: Implement the handlers**

In `main.rs`, add to the command dispatch:

```rust
Commands::RecoveryQr { cmd } => {
    match cmd {
        RecoveryQrCmd::Generate => cmd_recovery_qr_generate(&vault_dir)?,
        RecoveryQrCmd::Unwrap  => cmd_recovery_qr_unwrap()?,
    }
}
```

Add the handler functions:

```rust
fn cmd_recovery_qr_generate(vault_dir: &std::path::Path) -> relicario_core::Result<()> {
    use relicario_core::{generate_recovery_qr, recovery_qr_to_svg};
    use rpassword::prompt_password;

    // Load KDF params from vault settings to derive master key (not needed for QR,
    // but we need to verify the passphrase is correct by attempting unlock first).
    // Actually, generate_recovery_qr only needs the passphrase and image_secret.
    // We need to: (1) prompt passphrase, (2) load reference image, (3) extract
    // image_secret, (4) call generate_recovery_qr, (5) render SVG / ASCII.

    let passphrase = Zeroizing::new(
        prompt_password("Enter vault passphrase: ")
            .map_err(|e| relicario_core::RelicarioError::RecoveryQr(e.to_string()))?
    );

    let img_path = vault_dir.join("secret.jpg");
    let img_bytes = std::fs::read(&img_path)
        .map_err(|e| relicario_core::RelicarioError::RecoveryQr(format!("read {}: {e}", img_path.display())))?;
    let image_secret = relicario_core::imgsecret::extract(&img_bytes)?;

    let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)?;
    let svg = recovery_qr_to_svg(&payload);

    // Try Kitty/iTerm2 inline protocol; fall back to ASCII
    let term = std::env::var("TERM").unwrap_or_default();
    let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
    if term.contains("kitty") || term_program.contains("iTerm") {
        // Render SVG to PNG via kitty protocol (best-effort; fall back if unavailable)
        // For now, always use ASCII fallback
        print_qr_ascii(&payload);
    } else {
        print_qr_ascii(&payload);
    }

    println!("\nRecovery QR generated. Print or photograph this code and store it securely.");
    println!("The QR code has NOT been saved to disk.");
    Ok(())
}

fn print_qr_ascii(payload: &relicario_core::RecoveryQrPayload) {
    use qrcode::{QrCode, EcLevel, render::unicode};
    let code = QrCode::with_error_correction_level(payload.as_bytes().as_ref(), EcLevel::M)
        .expect("valid payload");
    let image = code.render::<unicode::Dense1x2>()
        .dark_color(unicode::Dense1x2::Dark)
        .light_color(unicode::Dense1x2::Light)
        .build();
    println!("{}", image);
}

fn cmd_recovery_qr_unwrap() -> relicario_core::Result<()> {
    use relicario_core::unwrap_recovery_qr;
    use rpassword::prompt_password;
    use std::io::{self, BufRead};
    use base64::{engine::general_purpose::STANDARD, Engine};

    println!("Paste the base64 recovery QR payload, then press Enter:");
    let stdin = io::stdin();
    let payload_b64 = stdin.lock().lines().next()
        .ok_or_else(|| relicario_core::RelicarioError::RecoveryQr("no input".into()))?
        .map_err(|e| relicario_core::RelicarioError::RecoveryQr(e.to_string()))?;
    let payload_b64 = payload_b64.trim();

    let bytes = STANDARD.decode(payload_b64)
        .map_err(|e| relicario_core::RelicarioError::RecoveryQr(format!("base64: {e}")))?;

    let passphrase = Zeroizing::new(
        prompt_password("Enter passphrase: ")
            .map_err(|e| relicario_core::RelicarioError::RecoveryQr(e.to_string()))?
    );

    let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())?;
    println!("image_secret: {}", hex::encode(secret.as_ref()));
    Ok(())
}
```

Add `use zeroize::Zeroizing;` and `use base64::{engine::general_purpose::STANDARD, Engine};` at the top of main.rs if not already present.

Also add `qrcode`, `base64`, and `hex` to `crates/relicario-cli/Cargo.toml` if not already present (check first with `grep "qrcode\|base64\|hex" crates/relicario-cli/Cargo.toml`).

- [ ] **Step 3: Compile**

```bash
cargo build -p relicario-cli 2>&1 | grep error
```

- [ ] **Step 4: Smoke test**

```bash
cargo run -p relicario-cli -- recovery-qr --help
```

Expected: shows subcommand help with `generate` and `unwrap` listed.

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-cli/src/main.rs crates/relicario-cli/Cargo.toml
git commit -m "feat(cli): recovery-qr generate / unwrap subcommands"
```

---

### Task 9: Extension — `settings-security.ts` three-state component

**Files:**
- Create: `extension/src/popup/components/settings-security.ts`

States:
- **State 1 (no QR):** `chrome.storage.local.recovery_qr_generated_at` is null/undefined. Show amber warning + "Generate recovery QR…" button.
- **State 2 (exists, at rest):** timestamp is set. Show green status + "Show / print QR…" and "Regenerate…" buttons.
- **State 3 (explicit view):** modal overlay with rendered SVG QR, print button, done button.

- [ ] **Step 1: Write the component**

```ts
// extension/src/popup/components/settings-security.ts

import { sendMessage, escapeHtml } from '../../shared/state';
import type { DeviceEntry } from '../../shared/types';

export async function renderSecuritySection(
  container: HTMLElement,
  sessionHandle: number | null,
): Promise<void> {
  const ts = await getQrGeneratedAt();
  renderSecurityContent(container, ts, sessionHandle);
}

export function teardownSecuritySection(): void {
  document.getElementById('relicario-qr-modal')?.remove();
}

async function getQrGeneratedAt(): Promise<number | null> {
  return new Promise((resolve) => {
    chrome.storage.local.get('recovery_qr_generated_at', (res) => {
      resolve(res['recovery_qr_generated_at'] ?? null);
    });
  });
}

function renderSecurityContent(
  container: HTMLElement,
  qrGeneratedAt: number | null,
  sessionHandle: number | null,
): void {
  const dateStr = qrGeneratedAt
    ? new Date(qrGeneratedAt).toLocaleDateString(undefined, { dateStyle: 'medium' })
    : null;

  const qrCardHtml = qrGeneratedAt
    ? `<div class="setting-card setting-card--ok">
        <div class="setting-card__status">◉ Recovery QR is set up · <span class="muted">${escapeHtml(dateStr!)}</span></div>
        <div class="setting-card__actions">
          <button class="btn" id="sec-show-qr" ${!sessionHandle ? 'disabled title="Vault must be unlocked"' : ''}>Show / print QR…</button>
          <button class="btn btn-danger" id="sec-regen-qr" ${!sessionHandle ? 'disabled title="Vault must be unlocked"' : ''}>Regenerate…</button>
        </div>
      </div>`
    : `<div class="setting-card setting-card--warn">
        <div class="setting-card__status">▲ No recovery QR — losing your reference image would make this vault unrecoverable.</div>
        <div class="setting-card__actions">
          <button class="btn btn-primary" id="sec-gen-qr" ${!sessionHandle ? 'disabled title="Vault must be unlocked"' : ''}>Generate recovery QR…</button>
        </div>
      </div>`;

  container.innerHTML = `
    <div class="settings-section">
      <div class="settings-section__title">Recovery QR</div>
      ${qrCardHtml}
    </div>
    <div class="settings-section" id="sec-devices-section">
      <div class="settings-section__title">Trusted Devices</div>
      <div id="sec-devices-list"><span class="muted">Loading…</span></div>
    </div>
  `;

  document.getElementById('sec-gen-qr')?.addEventListener('click', () =>
    handleGenerateQr(container, sessionHandle!, false));
  document.getElementById('sec-show-qr')?.addEventListener('click', () =>
    handleGenerateQr(container, sessionHandle!, false));
  document.getElementById('sec-regen-qr')?.addEventListener('click', () => {
    if (confirm('Regenerate recovery QR? This will overwrite any existing printed QR.')) {
      handleGenerateQr(container, sessionHandle!, true);
    }
  });

  loadDevices();
}

async function handleGenerateQr(
  container: HTMLElement,
  sessionHandle: number,
  isRegen: boolean,
): Promise<void> {
  const passphrase = prompt(
    isRegen ? 'Enter passphrase to regenerate QR:' : 'Enter passphrase to generate QR:'
  );
  if (!passphrase) return;

  try {
    const { wasmGenerateRecoveryQr } = await import('../../shared/wasm');
    const svg = await wasmGenerateRecoveryQr(sessionHandle, passphrase);
    const now = Date.now();
    await new Promise<void>((resolve) => {
      chrome.storage.local.set({ recovery_qr_generated_at: now }, resolve);
    });
    showQrModal(svg);
    // Re-render the section in state 2
    renderSecurityContent(container, now, sessionHandle);
  } catch (err) {
    alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`);
  }
}

function showQrModal(svg: string): void {
  document.getElementById('relicario-qr-modal')?.remove();

  const modal = document.createElement('div');
  modal.id = 'relicario-qr-modal';
  modal.className = 'qr-modal-overlay';
  modal.innerHTML = `
    <div class="qr-modal">
      <div class="qr-modal__header">
        <span class="qr-modal__title">Recovery QR</span>
        <button class="btn" id="qr-modal-done">Done</button>
      </div>
      <div class="qr-modal__qr">${svg}</div>
      <div class="qr-modal__warning">▲ Close this window before stepping away. This QR is only displayed, never saved.</div>
      <div class="qr-modal__actions">
        <button class="btn" id="qr-modal-print">⎙ Print</button>
      </div>
    </div>
  `;
  document.body.appendChild(modal);

  document.getElementById('qr-modal-done')?.addEventListener('click', () => modal.remove());
  document.getElementById('qr-modal-print')?.addEventListener('click', () => {
    const iframe = document.createElement('iframe');
    iframe.style.cssText = 'position:absolute;width:0;height:0;border:0;';
    document.body.appendChild(iframe);
    const doc = iframe.contentWindow!.document;
    doc.open();
    doc.write(`<html><body>${svg}</body></html>`);
    doc.close();
    iframe.contentWindow!.print();
    setTimeout(() => iframe.remove(), 1000);
  });
}

async function loadDevices(): Promise<void> {
  const list = document.getElementById('sec-devices-list');
  if (!list) return;
  const resp = await sendMessage({ type: 'list_devices' });
  if (!resp.ok) {
    list.innerHTML = `<span class="muted">Failed to load devices: ${escapeHtml(resp.error ?? 'unknown')}</span>`;
    return;
  }
  const data = resp.data as { devices: DeviceEntry[] };
  if (data.devices.length === 0) {
    list.innerHTML = '<span class="muted">No registered devices.</span>';
    return;
  }
  list.innerHTML = data.devices.map((d) => `
    <div class="device-row">
      <div class="device-row__info">
        <span class="device-row__name">${escapeHtml(d.name ?? 'unnamed')}</span>
        <span class="device-row__fingerprint muted">${escapeHtml(d.fingerprint ?? '')}</span>
      </div>
      <button class="btn btn-danger device-row__revoke" data-fp="${escapeHtml(d.fingerprint ?? '')}">Revoke</button>
    </div>
  `).join('');

  list.querySelectorAll('.device-row__revoke').forEach((btn) => {
    btn.addEventListener('click', async () => {
      const fp = (btn as HTMLElement).dataset.fp!;
      if (!confirm(`Revoke device ${fp.slice(0, 16)}…?`)) return;
      const r = await sendMessage({ type: 'revoke_device', fingerprint: fp });
      if (r.ok) {
        await renderSecuritySection(list.closest('.settings-section-content') as HTMLElement, null);
      } else {
        alert(`Revoke failed: ${r.error}`);
      }
    });
  });
}
```

**Note:** The `wasmGenerateRecoveryQr` import from `../../shared/wasm` — check what the WASM module exports and match the function name. It may be `wasm_generate_recovery_qr` (Rust snake_case) or camelCase depending on wasm-pack. Adjust the import accordingly. Also check that `DeviceEntry` is exported from `../../shared/types`.

- [ ] **Step 2: Build and check TypeScript errors**

```bash
cd /home/alee/Sources/relicario.v0.5.1-stream-c/extension && bun run build 2>&1 | grep -E "error TS|ERROR"
```

- [ ] **Step 3: Commit**

```bash
git add extension/src/popup/components/settings-security.ts
git commit -m "feat(ext/settings): settings-security.ts three-state recovery QR + devices component"
```

---

### Task 10: Rebuild WASM artifact

Before the extension builds can link to the new WASM bindings, rebuild the artifact.

- [ ] **Step 1: Build WASM**

```bash
cd /home/alee/Sources/relicario.v0.5.1-stream-c
npm run build:wasm --prefix extension
```

Or equivalently:
```bash
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
```

- [ ] **Step 2: Run extension build**

```bash
cd extension && bun run build 2>&1 | grep -E "error|ERROR" | head -20
```

Expected: clean (only pre-existing bundle size warnings).

- [ ] **Step 3: Commit the updated WASM artifact**

```bash
git add extension/wasm/
git commit -m "chore(wasm): rebuild artifact with recovery QR bindings"
```

---

### Task 11: Setup wizard redesign — Style C

**Files:**
- Modify: `extension/src/setup/setup.ts`

Style C replaces the current glass-card layout:
- Full-page dark background (`--bg-page`)
- Logo glyph + wordmark centered at top
- **Colored progress track**: horizontal segments, `--success` for completed, `--gold` for current, `--border` for pending
- Centered card (max-width 560px): step eyebrow ("Step N of 5 · name"), h2 heading, hint, form, actions
- Glyphs not emoji. Mode cards use `◈` (create new) and `⌥` (attach), rendered at 28px.
- Action row: "◂ back" left, "Continue ▸" right

- [ ] **Step 1: Read the current setup.ts structure**

```bash
wc -l extension/src/setup/setup.ts
grep -n "^function render\|^async function render\|step[0-9]" extension/src/setup/setup.ts | head -30
```

- [ ] **Step 2: Add the progress track + card wrapper helpers**

At the top of setup.ts (after existing imports), add:

```ts
const STEP_NAMES = ['vault setup', 'choose mode', 'passphrase', 'reference image', 'done'];

function renderProgressTrack(current: number): string {
  return `
    <div class="setup-progress-track">
      ${STEP_NAMES.map((name, i) => {
        const cls = i < current ? 'completed' : i === current ? 'active' : 'pending';
        return `<div class="setup-progress-segment setup-progress-segment--${cls}" title="${name}"></div>`;
      }).join('')}
    </div>
  `;
}

function wrapStepCard(stepIdx: number, heading: string, hint: string, bodyHtml: string, actionsHtml: string): string {
  return `
    <div class="setup-page">
      <div class="setup-logo">
        <img src="icons/relicario-logo-16.svg" alt="" class="setup-logo__img">
        <span class="setup-logo__wordmark">Relicario</span>
      </div>
      ${renderProgressTrack(stepIdx)}
      <div class="setup-card">
        <div class="setup-card__eyebrow">Step ${stepIdx + 1} of 5 · ${STEP_NAMES[stepIdx]}</div>
        <h2 class="setup-card__heading">${heading}</h2>
        <p class="setup-card__hint">${hint}</p>
        <div class="setup-card__body">
          ${bodyHtml}
        </div>
        <div class="setup-card__actions">
          ${actionsHtml}
        </div>
      </div>
    </div>
  `;
}
```

- [ ] **Step 3: Rewrite `renderStep0` (intro) and `renderStep1` (mode selection)**

`renderStep1` currently shows the "create new / attach existing" mode cards with emoji. Change to `◈` / `⌥` at 28px, wrapped in `wrapStepCard(1, ...)`.

Find `renderStep1` in setup.ts and replace the mode card HTML:

```ts
// Old: emoji mode card icons → new: glyph at 28px
// Replace the mode card icon spans with:
// <span class="mode-card__icon" style="font-size:28px;">◈</span>  // create new
// <span class="mode-card__icon" style="font-size:28px;">⌥</span>  // attach existing
```

Wrap each `renderStepN` function body with `wrapStepCard(N, heading, hint, bodyHtml, actionsHtml)`.

- [ ] **Step 4: Add progress track CSS**

In the setup CSS (either inline in setup.ts or the extracted CSS file — check where the existing setup styles live):

```css
.setup-progress-track {
  display: flex;
  gap: 4px;
  width: 100%;
  max-width: 560px;
  margin: 12px auto;
}

.setup-progress-segment {
  flex: 1;
  height: 4px;
  border-radius: 2px;
}

.setup-progress-segment--completed { background: var(--success, #238636); }
.setup-progress-segment--active    { background: var(--gold, #b8860b); }
.setup-progress-segment--pending   { background: var(--border, #30363d); }

.setup-page {
  display: flex;
  flex-direction: column;
  align-items: center;
  min-height: 100vh;
  padding: 40px 20px;
  background: var(--bg-page, #0d1117);
}

.setup-card {
  width: 100%;
  max-width: 560px;
  background: var(--bg-elevated, #161b22);
  border: 1px solid var(--border, #30363d);
  border-radius: 12px;
  padding: 32px;
}

.setup-card__eyebrow { font-size: 11px; color: var(--text-muted, #8b949e); margin-bottom: 8px; }
.setup-card__heading { font-size: 20px; font-weight: 700; margin: 0 0 8px; }
.setup-card__hint    { font-size: 13px; color: var(--text-muted, #8b949e); margin: 0 0 24px; }
.setup-card__actions { display: flex; justify-content: space-between; margin-top: 24px; }

.setup-logo {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 24px;
}
.setup-logo__img      { width: 24px; height: 24px; }
.setup-logo__wordmark { font-size: 18px; font-weight: 700; }
```

- [ ] **Step 5: Build and verify**

```bash
cd /home/alee/Sources/relicario.v0.5.1-stream-c/extension && bun run build 2>&1 | grep -E "error TS|ERROR"
```

- [ ] **Step 6: Commit**

```bash
git add extension/src/setup/setup.ts
git commit -m "feat(ext/setup): wizard redesign — Style C card layout, progress track, glyphs"
```

---

### Task 12: Setup wizard — Step 5 recovery QR banner

**Files:**
- Modify: `extension/src/setup/setup.ts`

The final step ("done") adds a skippable banner above the "Download reference image" button.

- [ ] **Step 1: Find `renderStep5` (or whatever the final step is)**

```bash
grep -n "renderStep\|step.*5\|done\|finish\|download" extension/src/setup/setup.ts | tail -30
```

- [ ] **Step 2: Add banner HTML to the final step**

Find the "done" / final-step render function and add the recovery QR banner before the download button:

```ts
const qrBannerHtml = `
  <div class="recovery-qr-banner" id="recovery-qr-banner">
    <span class="recovery-qr-banner__icon">◫</span>
    <div class="recovery-qr-banner__text">
      <strong>Generate a recovery QR before you go</strong>
      <p>If you lose your reference image, this QR lets you recover your vault.</p>
    </div>
    <div class="recovery-qr-banner__actions">
      <button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
      <button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
    </div>
  </div>
`;
```

Insert this banner into the step body HTML (before the download button).

- [ ] **Step 3: Wire the banner buttons**

In the step's `attachStepN()` wiring function, add:

```ts
document.getElementById('setup-gen-qr')?.addEventListener('click', async () => {
  const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement;
  btn.disabled = true;
  btn.textContent = 'Generating…';
  const passphrase = (document.getElementById('setup-passphrase') as HTMLInputElement)?.value;
  // The passphrase field should still be accessible from state or the wizard's stored value.
  // If not, prompt for it:
  const finalPassphrase = passphrase || prompt('Enter passphrase to generate QR:') || '';
  if (!finalPassphrase) { btn.disabled = false; btn.textContent = 'Generate now'; return; }

  try {
    const { wasmGenerateRecoveryQr } = await import('../shared/wasm');
    // sessionHandle is available from the setup wizard's unlock step
    const handle = getSetupSessionHandle(); // replace with actual accessor
    const svg = await wasmGenerateRecoveryQr(handle, finalPassphrase);
    await new Promise<void>((resolve) => {
      chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve);
    });
    // Show inline QR
    const banner = document.getElementById('recovery-qr-banner')!;
    banner.classList.add('recovery-qr-banner--generated');
    banner.innerHTML = `
      <div class="qr-inline">${svg}</div>
      <div class="recovery-qr-banner__ok">◉ Recovery QR generated — save or print this QR now.</div>
      <div class="recovery-qr-banner__actions">
        <button class="btn" id="setup-qr-done">Done</button>
      </div>
    `;
    document.getElementById('setup-qr-done')?.addEventListener('click', () => {
      banner.innerHTML = '<span class="muted">◉ Recovery QR generated.</span>';
    });
  } catch (err) {
    btn.disabled = false;
    btn.textContent = 'Generate now';
    alert(`Failed: ${err instanceof Error ? err.message : String(err)}`);
  }
});

document.getElementById('setup-skip-qr')?.addEventListener('click', () => {
  const banner = document.getElementById('recovery-qr-banner');
  if (banner) banner.style.display = 'none';
});
```

**Note:** `getSetupSessionHandle()` — you need to check how the wizard stores the session handle after unlock. Look for where `unlock()` is called in setup.ts and where the result is stored. Adjust accordingly.

- [ ] **Step 4: Add banner CSS**

```css
.recovery-qr-banner {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 16px;
  background: var(--bg-elevated, #161b22);
  border: 1px solid var(--gold, #b8860b);
  border-radius: 8px;
  margin-bottom: 16px;
}

.recovery-qr-banner__icon { font-size: 20px; }

.recovery-qr-banner__text p { margin: 4px 0 0; font-size: 12px; color: var(--text-muted, #8b949e); }

.recovery-qr-banner__actions { display: flex; gap: 8px; margin-top: 8px; }

.recovery-qr-banner--generated { border-color: var(--success, #238636); }

.qr-inline svg { display: block; margin: 0 auto; }

.recovery-qr-banner__ok { font-size: 12px; color: var(--success, #238636); }
```

- [ ] **Step 5: Build**

```bash
cd /home/alee/Sources/relicario.v0.5.1-stream-c/extension && bun run build 2>&1 | grep -E "error TS|ERROR"
```

- [ ] **Step 6: Run full test suites**

```bash
cd /home/alee/Sources/relicario.v0.5.1-stream-c
cargo test 2>&1 | tail -10
cd extension && bun run test 2>&1 | tail -10
```

Expected: all pass.

- [ ] **Step 7: Commit**

```bash
git add extension/src/setup/setup.ts
git commit -m "feat(ext/setup): recovery QR banner in final wizard step"
```

---

## Final steps

- [ ] Open PR: `gh pr create --title "feat: recovery QR (Stream C)" --base main`
- [ ] Post `## STATUS UPDATE — DEV-C / Action: REVIEW-READY` with PR URL to PM
- [ ] Respond to any PM review comments
