- coordination/v0.5.1-pm-prompt.md — PM coordinates 3 streams, enforces interface contracts (A-B settings signature, B-C security component), owns merge order and pre-tag checklist - coordination/v0.5.1-dev-a-prompt.md — Stream A: fullscreen 3-column layout, sidebar category nav, detail drawer, bottom sheet, popup type- picker polish, per-type glyph icons, empty states, toast system (13 tasks) - coordination/v0.5.1-dev-b-prompt.md — Stream B: settings left-nav redesign (Autofill, Display, Security, Generator, Retention, Backup, Import sections), security component stub (10 tasks) - coordination/v0.5.1-dev-c-prompt.md — Stream C: recovery_qr.rs core, WASM session expansion, CLI subcommand, settings-security.ts three-state component, setup wizard Style C redesign + QR banner (12 tasks) - Archive v0.5.0 coordination files to coordination/archive/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
43 KiB
Dev C Kickoff Prompt — v0.5.1 Stream C (Recovery QR)
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development(recommended) orsuperpowers:executing-plansto 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
CLAUDE.md— project rulesdocs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md— spec sections C1–C6crates/relicario-core/src/crypto.rs— existing KDF and AEAD implementation (read the public API)crates/relicario-wasm/src/session.rs— current session storage (you will expand this)crates/relicario-wasm/src/lib.rs— existing WASM bindings (you will add to these)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. Onlyrecovery_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 inSessionDataalongside 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.
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 implementationcrates/relicario-core/tests/recovery_qr.rs— integration testsextension/src/popup/components/settings-security.ts— three-state component
Modify:
crates/relicario-core/Cargo.toml— addqrcodecrates/relicario-core/src/lib.rs—pub mod recovery_qr+ re-exportscrates/relicario-core/src/error.rs— addRecoveryQrvariantcrates/relicario-wasm/Cargo.toml— addbase64if not present (check first)crates/relicario-wasm/src/session.rs— expand toSessionDatacrates/relicario-wasm/src/lib.rs— updateunlock(), add new bindingscrates/relicario-cli/src/main.rs— addrecovery-qrsubcommand groupextension/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
RecoveryQrvariant 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_rawexists
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, ¶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
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(addbase64if 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_atis 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,
--successfor completed,--goldfor current,--borderfor 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) andrenderStep1(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 - Post
## STATUS UPDATE — DEV-C / Action: REVIEW-READYwith PR URL to PM - Respond to any PM review comments