Files
relicario/docs/superpowers/coordination/v0.5.1-dev-c-prompt.md
adlee-was-taken 450de33c0a docs(coordination): architecture-review kickoff prompts + followup planning
Adds the four kickoff prompts that drove the 2026-05-04 whole-codebase
architecture audit (PM + DEV-A/B/C reviewers), the planning prompt
that converts the synthesis into three implementation plans, and the
PM + DEV-A/B/C kickoff prompts for executing those plans in parallel.

Also updates the existing v0.5.1-* prompts with the relay-server
fallback section that references the new tools/relay/call.py shim.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 17:49:34 -04:00

44 KiB
Raw Blame History

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)

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

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.

Relay server

A message-bus MCP server is running on localhost:7331. You have three native tools:

  • post_message(from, to, kind, body) — push a message; your from is always "dev-c"
  • read_messages(for) — drain your inbox; call with for="dev-c" before each task
  • list_pending(for) — check inbox count without consuming

Recipients: pm, dev-a, dev-b, dev-c. Use these instead of asking the user to copy-paste. Before starting each task: read_messages(for="dev-c"). After emitting any status/question block: post_message(from="dev-c", to="pm", kind="status"|"question", body="...").

Coordination protocol

Before starting each task, call read_messages(for="dev-c") to drain your inbox.

When posting a status update, call post_message(from="dev-c", to="pm", kind="status", body="...") with the body:

## 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.rspub 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

# in [dependencies] section of crates/relicario-core/Cargo.toml
qrcode = { version = "0.14", default-features = false }
  • Step 2: Verify it compiles
cargo build -p relicario-core 2>&1 | tail -5

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

  • Step 3: Commit
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
// 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:

    /// Recovery QR generation or parsing failed.
    #[error("recovery QR: {0}")]
    RecoveryQr(String),
  • Step 3: Check if derive_master_key_raw exists
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
cargo build -p relicario-core 2>&1 | grep -E "error|warning: unused"

Expected: compiles clean or only pre-existing warnings.

  • Step 5: Commit
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:

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
cargo build -p relicario-core 2>&1 | grep error
  • Step 3: Commit
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):

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
cargo build -p relicario-core 2>&1 | grep error
  • Step 3: Commit
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

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
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
cargo test -p relicario-core --test recovery_qr -- --nocapture 2>&1 | tail -20

Expected: 5 tests pass.

  • Step 4: Run full test suite
cargo test -p relicario-core 2>&1 | tail -10

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

  • Step 5: Commit
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
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:

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

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

If not present, add it:

base64 = "0.22"
  • Step 2: Add WASM bindings to lib.rs

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

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
cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | grep error

Expected: clean.

  • Step 4: Run WASM tests
cargo test -p relicario-wasm 2>&1 | tail -10

Expected: existing 3 tests still pass.

  • Step 5: Commit
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:

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

Add a new enum:

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

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

Add the handler functions:

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
cargo build -p relicario-cli 2>&1 | grep error
  • Step 4: Smoke test
cargo run -p relicario-cli -- recovery-qr --help

Expected: shows subcommand help with generate and unwrap listed.

  • Step 5: Commit
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

// 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
cd /home/alee/Sources/relicario.v0.5.1-stream-c/extension && bun run build 2>&1 | grep -E "error TS|ERROR"
  • Step 3: Commit
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
cd /home/alee/Sources/relicario.v0.5.1-stream-c
npm run build:wasm --prefix extension

Or equivalently:

wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
  • Step 2: Run extension build
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
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

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:

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:

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

.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
cd /home/alee/Sources/relicario.v0.5.1-stream-c/extension && bun run build 2>&1 | grep -E "error TS|ERROR"
  • Step 6: Commit
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)
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:

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:

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
.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
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
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
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
  • Call post_message(from="dev-c", to="pm", kind="status", body="## STATUS UPDATE — DEV-C\nTime: <iso8601>\nTask: 13 of 13\nStatus: REVIEW-READY\nSummary: All 13 tasks complete. PR open. Recovery QR implemented end-to-end.\nNext: waiting for PM")
  • Respond to any PM review comments