# 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; 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: Task: Status: IN-PROGRESS | BLOCKED | REVIEW-READY Summary: Next: ``` --- ## 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 { 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> { 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 { 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 { 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> { 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> { 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::() .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("&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> = RefCell::new(HashMap::new()); static NEXT_HANDLE: RefCell = 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(handle: u32, f: F) -> Option 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(handle: u32, f: F) -> Option 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 { 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, ¶ms) .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 { 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, 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::() .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 { const ts = await getQrGeneratedAt(); renderSecurityContent(container, ts, sessionHandle); } export function teardownSecuritySection(): void { document.getElementById('relicario-qr-modal')?.remove(); } async function getQrGeneratedAt(): Promise { 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 ? `
◉ Recovery QR is set up · ${escapeHtml(dateStr!)}
` : `
▲ No recovery QR — losing your reference image would make this vault unrecoverable.
`; container.innerHTML = `
Recovery QR
${qrCardHtml}
Trusted Devices
Loading…
`; 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 { 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((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 = `
Recovery QR
${svg}
▲ Close this window before stepping away. This QR is only displayed, never saved.
`; 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(`${svg}`); doc.close(); iframe.contentWindow!.print(); setTimeout(() => iframe.remove(), 1000); }); } async function loadDevices(): Promise { const list = document.getElementById('sec-devices-list'); if (!list) return; const resp = await sendMessage({ type: 'list_devices' }); if (!resp.ok) { list.innerHTML = `Failed to load devices: ${escapeHtml(resp.error ?? 'unknown')}`; return; } const data = resp.data as { devices: DeviceEntry[] }; if (data.devices.length === 0) { list.innerHTML = 'No registered devices.'; return; } list.innerHTML = data.devices.map((d) => `
${escapeHtml(d.name ?? 'unnamed')} ${escapeHtml(d.fingerprint ?? '')}
`).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 `
${STEP_NAMES.map((name, i) => { const cls = i < current ? 'completed' : i === current ? 'active' : 'pending'; return `
`; }).join('')}
`; } function wrapStepCard(stepIdx: number, heading: string, hint: string, bodyHtml: string, actionsHtml: string): string { return `
${renderProgressTrack(stepIdx)}
Step ${stepIdx + 1} of 5 · ${STEP_NAMES[stepIdx]}

${heading}

${hint}

${bodyHtml}
${actionsHtml}
`; } ``` - [ ] **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: // // create new // // 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 = `
Generate a recovery QR before you go

If you lose your reference image, this QR lets you recover your vault.

`; ``` 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((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 = `
${svg}
◉ Recovery QR generated — save or print this QR now.
`; document.getElementById('setup-qr-done')?.addEventListener('click', () => { banner.innerHTML = '◉ Recovery QR generated.'; }); } 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` - [ ] Call `post_message(from="dev-c", to="pm", kind="status", body="## STATUS UPDATE — DEV-C\nTime: \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