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

1368 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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:
```ts
export async function renderSecuritySection(
container: HTMLElement,
sessionHandle: number | null,
): Promise<void>;
export function teardownSecuritySection(): void;
```
DEV-B has a stub. Your Task 9 provides the real implementation.
## 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.rs``pub mod recovery_qr` + re-exports
- `crates/relicario-core/src/error.rs` — add `RecoveryQr` variant
- `crates/relicario-wasm/Cargo.toml` — add `base64` if not present (check first)
- `crates/relicario-wasm/src/session.rs` — expand to `SessionData`
- `crates/relicario-wasm/src/lib.rs` — update `unlock()`, add new bindings
- `crates/relicario-cli/src/main.rs` — add `recovery-qr` subcommand group
- `extension/src/setup/setup.ts` — wizard redesign + Step 5 QR banner
---
### Task 1: Add `qrcode` crate
**Files:**
- Modify: `crates/relicario-core/Cargo.toml`
- [ ] **Step 1: Add dependency**
```toml
# in [dependencies] section of crates/relicario-core/Cargo.toml
qrcode = { version = "0.14", default-features = false }
```
- [ ] **Step 2: Verify it compiles**
```bash
cargo build -p relicario-core 2>&1 | tail -5
```
Expected: no errors (qrcode may show download progress, that's fine).
- [ ] **Step 3: Commit**
```bash
git add crates/relicario-core/Cargo.toml Cargo.lock
git commit -m "chore(core): add qrcode dependency for recovery QR"
```
---
### Task 2: `recovery_qr.rs` — core payload generation
**Files:**
- Create: `crates/relicario-core/src/recovery_qr.rs`
Binary payload layout (109 bytes):
```
[magic "RREC" 4B][version 0x01 1B][kdf_salt 32B][wrap_nonce 24B][ciphertext 48B]
```
`ciphertext` = `XChaCha20-Poly1305(wrap_key, wrap_nonce, image_secret)` where the AEAD tag is 16B → 32B + 16B = 48B.
KDF domain separation:
```
"relicario-recovery-v1\0" || u64_be(byte_len(nfc_passphrase)) || nfc_passphrase
```
Fed to Argon2id with production params (m=64MiB, t=3, p=4) and a 32-byte `kdf_salt` (OsRng).
- [ ] **Step 1: Write the file**
```rust
// crates/relicario-core/src/recovery_qr.rs
use chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead};
use rand::RngCore;
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;
use crate::{crypto::KdfParams, error::{RelicarioError, Result}};
const MAGIC: &[u8; 4] = b"RREC";
const VERSION: u8 = 0x01;
const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109
pub struct RecoveryQrPayload {
bytes: [u8; PAYLOAD_LEN],
}
impl RecoveryQrPayload {
pub fn as_bytes(&self) -> &[u8; PAYLOAD_LEN] {
&self.bytes
}
}
fn recovery_kdf_input(passphrase: &str) -> Vec<u8> {
let nfc: String = passphrase.nfc().collect();
let nfc_bytes = nfc.as_bytes();
let prefix = b"relicario-recovery-v1\0";
let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len());
input.extend_from_slice(prefix);
input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes());
input.extend_from_slice(nfc_bytes);
input
}
fn production_params() -> KdfParams {
KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }
}
fn derive_wrap_key(
passphrase: &str,
kdf_salt: &[u8; 32],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
let input = recovery_kdf_input(passphrase);
crate::crypto::derive_master_key_raw(&input, kdf_salt, params)
}
pub fn generate_recovery_qr(
passphrase: &str,
image_secret: &[u8; 32],
) -> Result<RecoveryQrPayload> {
generate_recovery_qr_with_params(passphrase, image_secret, &production_params())
}
#[doc(hidden)]
pub fn generate_recovery_qr_with_params(
passphrase: &str,
image_secret: &[u8; 32],
params: &KdfParams,
) -> Result<RecoveryQrPayload> {
let mut kdf_salt = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut kdf_salt);
let mut wrap_nonce = [0u8; 24];
rand::rngs::OsRng.fill_bytes(&mut wrap_nonce);
let wrap_key = derive_wrap_key(passphrase, &kdf_salt, params)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
let nonce = chacha20poly1305::XNonce::from_slice(&wrap_nonce);
let ciphertext = cipher.encrypt(nonce, image_secret.as_ref())
.map_err(|_| RelicarioError::RecoveryQr("wrap encrypt failed".into()))?;
let mut bytes = [0u8; PAYLOAD_LEN];
let mut pos = 0;
bytes[pos..pos+4].copy_from_slice(MAGIC); pos += 4;
bytes[pos] = VERSION; pos += 1;
bytes[pos..pos+32].copy_from_slice(&kdf_salt); pos += 32;
bytes[pos..pos+24].copy_from_slice(&wrap_nonce); pos += 24;
bytes[pos..pos+48].copy_from_slice(&ciphertext); // 48 = 32 + 16-tag
Ok(RecoveryQrPayload { bytes })
}
pub fn unwrap_recovery_qr(
payload_bytes: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; 32]>> {
unwrap_recovery_qr_with_params(payload_bytes, passphrase, &production_params())
}
#[doc(hidden)]
pub fn unwrap_recovery_qr_with_params(
payload_bytes: &[u8],
passphrase: &str,
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
if payload_bytes.len() != PAYLOAD_LEN {
return Err(RelicarioError::RecoveryQr(
format!("payload must be {PAYLOAD_LEN} bytes, got {}", payload_bytes.len())
));
}
if &payload_bytes[0..4] != MAGIC {
return Err(RelicarioError::RecoveryQr("bad magic".into()));
}
if payload_bytes[4] != VERSION {
return Err(RelicarioError::RecoveryQr(
format!("unsupported version 0x{:02x}", payload_bytes[4])
));
}
let kdf_salt: &[u8; 32] = payload_bytes[5..37].try_into().unwrap();
let wrap_nonce = &payload_bytes[37..61];
let ciphertext = &payload_bytes[61..109];
let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
let nonce = chacha20poly1305::XNonce::from_slice(wrap_nonce);
let plaintext = cipher.decrypt(nonce, ciphertext)
.map_err(|_| RelicarioError::Decrypt)?;
let mut out = Zeroizing::new([0u8; 32]);
out.copy_from_slice(&plaintext);
Ok(out)
}
```
- [ ] **Step 2: Add `RecoveryQr` variant to error.rs**
In `crates/relicario-core/src/error.rs`, add after the `HotpNotSupported` variant:
```rust
/// Recovery QR generation or parsing failed.
#[error("recovery QR: {0}")]
RecoveryQr(String),
```
- [ ] **Step 3: Check if `derive_master_key_raw` exists**
```bash
grep -n "pub fn derive_master_key" crates/relicario-core/src/crypto.rs
```
If only `derive_master_key` exists (taking `passphrase_bytes: &[u8], image_secret: &[u8; 32]`), you need to add a `derive_master_key_raw(input: &[u8], salt: &[u8; 32], params: &KdfParams)` variant. Check the crypto.rs implementation and add it if needed.
- [ ] **Step 4: Compile**
```bash
cargo build -p relicario-core 2>&1 | grep -E "error|warning: unused"
```
Expected: compiles clean or only pre-existing warnings.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-core/src/recovery_qr.rs crates/relicario-core/src/error.rs
git commit -m "feat(core): recovery_qr generate + unwrap functions"
```
---
### Task 3: `recovery_qr_to_svg`
**Files:**
- Modify: `crates/relicario-core/src/recovery_qr.rs`
- [ ] **Step 1: Add the SVG function**
Add to `recovery_qr.rs`:
```rust
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String {
use qrcode::{QrCode, EcLevel};
let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M)
.expect("109-byte payload always fits QR version 6");
let svg_str = code.render::<qrcode::render::svg::Color>()
.min_dimensions(140, 140)
.build();
svg_str
}
```
- [ ] **Step 2: Compile and check**
```bash
cargo build -p relicario-core 2>&1 | grep error
```
- [ ] **Step 3: Commit**
```bash
git add crates/relicario-core/src/recovery_qr.rs
git commit -m "feat(core): recovery_qr_to_svg renders 140px SVG"
```
---
### Task 4: Wire into `lib.rs`
**Files:**
- Modify: `crates/relicario-core/src/lib.rs`
- [ ] **Step 1: Add module and re-exports**
Add to `lib.rs` (after the `device` module block):
```rust
pub mod recovery_qr;
pub use recovery_qr::{
generate_recovery_qr, generate_recovery_qr_with_params,
recovery_qr_to_svg,
unwrap_recovery_qr, unwrap_recovery_qr_with_params,
RecoveryQrPayload,
};
```
- [ ] **Step 2: Compile**
```bash
cargo build -p relicario-core 2>&1 | grep error
```
- [ ] **Step 3: Commit**
```bash
git add crates/relicario-core/src/lib.rs
git commit -m "chore(core): re-export recovery_qr module"
```
---
### Task 5: Integration tests for recovery QR
**Files:**
- Create: `crates/relicario-core/tests/recovery_qr.rs`
- [ ] **Step 1: Write failing tests**
```rust
use relicario_core::{
crypto::KdfParams,
generate_recovery_qr_with_params, recovery_qr_to_svg, unwrap_recovery_qr_with_params,
};
fn fast_params() -> KdfParams {
KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
}
fn test_secret() -> [u8; 32] {
let mut s = [0u8; 32];
for (i, b) in s.iter_mut().enumerate() { *b = i as u8; }
s
}
#[test]
fn roundtrip_recovers_image_secret() {
let passphrase = "correct-horse-battery-staple";
let secret = test_secret();
let payload = generate_recovery_qr_with_params(passphrase, &secret, &fast_params())
.expect("generate ok");
let recovered = unwrap_recovery_qr_with_params(payload.as_bytes(), passphrase, &fast_params())
.expect("unwrap ok");
assert_eq!(recovered.as_ref(), &secret);
}
#[test]
fn wrong_passphrase_fails_decrypt() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("right-pass", &secret, &fast_params())
.expect("generate ok");
let result = unwrap_recovery_qr_with_params(payload.as_bytes(), "wrong-pass", &fast_params());
assert!(result.is_err());
}
#[test]
fn payload_is_109_bytes() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
.expect("generate ok");
assert_eq!(payload.as_bytes().len(), 109);
}
#[test]
fn svg_output_is_non_empty_xml() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
.expect("generate ok");
let svg = recovery_qr_to_svg(&payload);
assert!(svg.contains("<svg"), "SVG output should contain <svg tag");
assert!(!svg.is_empty());
}
#[test]
fn bad_magic_returns_error() {
let mut bad = [0u8; 109];
bad[0..4].copy_from_slice(b"NOPE");
let result = unwrap_recovery_qr_with_params(&bad, "pass", &fast_params());
assert!(result.is_err());
}
```
- [ ] **Step 2: Run and confirm they fail**
```bash
cargo test -p relicario-core --test recovery_qr 2>&1 | tail -20
```
Expected: compile errors or test panics (module not wired yet or functions not yet fully implemented).
- [ ] **Step 3: Run after Task 2/3/4 are complete and confirm they pass**
```bash
cargo test -p relicario-core --test recovery_qr -- --nocapture 2>&1 | tail -20
```
Expected: 5 tests pass.
- [ ] **Step 4: Run full test suite**
```bash
cargo test -p relicario-core 2>&1 | tail -10
```
Expected: all tests pass (130+ tests green).
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-core/tests/recovery_qr.rs
git commit -m "test(core): recovery_qr roundtrip + error cases"
```
---
### Task 6: Expand WASM `session.rs` to store `image_secret`
**Files:**
- Modify: `crates/relicario-wasm/src/session.rs`
Current: stores only `Zeroizing<[u8; 32]>` per handle.
New: stores `SessionData { master_key, image_secret }` per handle.
The public `with(handle, |key| ...)` signature is preserved (passes `&Zeroizing<[u8;32]>` = master_key). A new `with_image_secret(handle, |secret| ...)` is added.
- [ ] **Step 1: Rewrite session.rs**
```rust
use std::cell::RefCell;
use std::collections::HashMap;
use zeroize::Zeroizing;
pub struct SessionData {
pub master_key: Zeroizing<[u8; 32]>,
pub image_secret: Zeroizing<[u8; 32]>,
}
thread_local! {
static SESSIONS: RefCell<HashMap<u32, SessionData>> = RefCell::new(HashMap::new());
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
}
pub fn insert(master_key: Zeroizing<[u8; 32]>, image_secret: Zeroizing<[u8; 32]>) -> u32 {
let handle = NEXT_HANDLE.with(|n| {
let mut n = n.borrow_mut();
let h = *n;
*n = n.wrapping_add(1);
if *n == 0 { *n = 1; }
h
});
SESSIONS.with(|s| {
s.borrow_mut().insert(handle, SessionData { master_key, image_secret });
});
handle
}
/// Access the master key for a handle. Preserves original `with` signature for all existing callers.
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.master_key)))
}
/// Access the image_secret for a handle (used by recovery QR).
pub fn with_image_secret<F, R>(handle: u32, f: F) -> Option<R>
where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret)))
}
pub fn remove(handle: u32) -> bool {
SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
}
#[cfg(test)]
pub fn clear() {
SESSIONS.with(|s| s.borrow_mut().clear());
}
```
- [ ] **Step 2: Update `unlock()` in lib.rs to pass image_secret to session::insert**
In `crates/relicario-wasm/src/lib.rs`, find the `unlock()` function. Currently it extracts `image_secret` and discards it after `derive_master_key`. Change it to also store image_secret:
```rust
#[wasm_bindgen]
pub fn unlock(
passphrase: &str,
image_bytes: &[u8],
salt: &[u8],
params_json: &str,
) -> Result<SessionHandle, JsError> {
let params: KdfParams = serde_json::from_str(params_json)
.map_err(|e| JsError::new(&format!("params: {e}")))?;
let image_secret = imgsecret::extract(image_bytes)
.map_err(|e| JsError::new(&e.to_string()))?;
let salt_arr: &[u8; 32] = salt.try_into()
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params)
.map_err(|e| JsError::new(&e.to_string()))?;
let stored_image_secret = Zeroizing::new(image_secret);
let handle = session::insert(master_key, stored_image_secret);
Ok(SessionHandle(handle))
}
```
Add `use zeroize::Zeroizing;` to lib.rs imports if not already present.
- [ ] **Step 3: Compile**
```bash
cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | grep error
```
Expected: clean (all existing callers still compile because `session::with` signature is unchanged).
- [ ] **Step 4: Commit**
```bash
git add crates/relicario-wasm/src/session.rs crates/relicario-wasm/src/lib.rs
git commit -m "feat(wasm): session stores image_secret for recovery QR generation"
```
---
### Task 7: WASM bindings — `generate_recovery_qr` + `unwrap_recovery_qr`
**Files:**
- Modify: `crates/relicario-wasm/src/lib.rs`
- Modify: `crates/relicario-wasm/Cargo.toml` (add `base64` if not present)
- [ ] **Step 1: Check if base64 is already in wasm Cargo.toml**
```bash
grep "base64" crates/relicario-wasm/Cargo.toml
```
If not present, add it:
```toml
base64 = "0.22"
```
- [ ] **Step 2: Add WASM bindings to lib.rs**
Add at the end of `crates/relicario-wasm/src/lib.rs`:
```rust
use relicario_core::{generate_recovery_qr, recovery_qr_to_svg, unwrap_recovery_qr};
/// Generate a recovery QR SVG for the current session. Returns the SVG string.
/// The passphrase is needed because the QR wraps the image_secret under a
/// passphrase-derived key (separate from the master key).
#[wasm_bindgen]
pub fn wasm_generate_recovery_qr(
handle: &SessionHandle,
passphrase: &str,
) -> Result<String, JsError> {
let image_secret = session::with_image_secret(handle.0, |s| *s.as_ref())
.ok_or_else(|| JsError::new("invalid or locked session handle"))?;
let payload = generate_recovery_qr(passphrase, &image_secret)
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(recovery_qr_to_svg(&payload))
}
/// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase.
/// Returns the raw image_secret bytes (32 bytes).
#[wasm_bindgen]
pub fn wasm_unwrap_recovery_qr(
payload_b64: &str,
passphrase: &str,
) -> Result<Vec<u8>, JsError> {
use base64::{engine::general_purpose::STANDARD, Engine};
let bytes = STANDARD.decode(payload_b64)
.map_err(|e| JsError::new(&format!("base64: {e}")))?;
let recovered = unwrap_recovery_qr(&bytes, passphrase)
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(recovered.to_vec())
}
```
- [ ] **Step 3: Compile WASM**
```bash
cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | grep error
```
Expected: clean.
- [ ] **Step 4: Run WASM tests**
```bash
cargo test -p relicario-wasm 2>&1 | tail -10
```
Expected: existing 3 tests still pass.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-wasm/src/lib.rs crates/relicario-wasm/Cargo.toml
git commit -m "feat(wasm): expose generate_recovery_qr and unwrap_recovery_qr bindings"
```
---
### Task 8: CLI `recovery-qr` subcommand
**Files:**
- Modify: `crates/relicario-cli/src/main.rs`
- [ ] **Step 1: Add the subcommand group to the clap surface**
In `main.rs`, find the `Commands` enum and add:
```rust
/// Recovery QR operations.
RecoveryQr {
#[command(subcommand)]
cmd: RecoveryQrCmd,
},
```
Add a new enum:
```rust
#[derive(clap::Subcommand)]
enum RecoveryQrCmd {
/// Generate a recovery QR code and display it in the terminal.
Generate,
/// Unwrap a recovery QR payload (base64) and print the image_secret as hex.
Unwrap,
}
```
- [ ] **Step 2: Implement the handlers**
In `main.rs`, add to the command dispatch:
```rust
Commands::RecoveryQr { cmd } => {
match cmd {
RecoveryQrCmd::Generate => cmd_recovery_qr_generate(&vault_dir)?,
RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap()?,
}
}
```
Add the handler functions:
```rust
fn cmd_recovery_qr_generate(vault_dir: &std::path::Path) -> relicario_core::Result<()> {
use relicario_core::{generate_recovery_qr, recovery_qr_to_svg};
use rpassword::prompt_password;
// Load KDF params from vault settings to derive master key (not needed for QR,
// but we need to verify the passphrase is correct by attempting unlock first).
// Actually, generate_recovery_qr only needs the passphrase and image_secret.
// We need to: (1) prompt passphrase, (2) load reference image, (3) extract
// image_secret, (4) call generate_recovery_qr, (5) render SVG / ASCII.
let passphrase = Zeroizing::new(
prompt_password("Enter vault passphrase: ")
.map_err(|e| relicario_core::RelicarioError::RecoveryQr(e.to_string()))?
);
let img_path = vault_dir.join("secret.jpg");
let img_bytes = std::fs::read(&img_path)
.map_err(|e| relicario_core::RelicarioError::RecoveryQr(format!("read {}: {e}", img_path.display())))?;
let image_secret = relicario_core::imgsecret::extract(&img_bytes)?;
let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)?;
let svg = recovery_qr_to_svg(&payload);
// Try Kitty/iTerm2 inline protocol; fall back to ASCII
let term = std::env::var("TERM").unwrap_or_default();
let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
if term.contains("kitty") || term_program.contains("iTerm") {
// Render SVG to PNG via kitty protocol (best-effort; fall back if unavailable)
// For now, always use ASCII fallback
print_qr_ascii(&payload);
} else {
print_qr_ascii(&payload);
}
println!("\nRecovery QR generated. Print or photograph this code and store it securely.");
println!("The QR code has NOT been saved to disk.");
Ok(())
}
fn print_qr_ascii(payload: &relicario_core::RecoveryQrPayload) {
use qrcode::{QrCode, EcLevel, render::unicode};
let code = QrCode::with_error_correction_level(payload.as_bytes().as_ref(), EcLevel::M)
.expect("valid payload");
let image = code.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Dark)
.light_color(unicode::Dense1x2::Light)
.build();
println!("{}", image);
}
fn cmd_recovery_qr_unwrap() -> relicario_core::Result<()> {
use relicario_core::unwrap_recovery_qr;
use rpassword::prompt_password;
use std::io::{self, BufRead};
use base64::{engine::general_purpose::STANDARD, Engine};
println!("Paste the base64 recovery QR payload, then press Enter:");
let stdin = io::stdin();
let payload_b64 = stdin.lock().lines().next()
.ok_or_else(|| relicario_core::RelicarioError::RecoveryQr("no input".into()))?
.map_err(|e| relicario_core::RelicarioError::RecoveryQr(e.to_string()))?;
let payload_b64 = payload_b64.trim();
let bytes = STANDARD.decode(payload_b64)
.map_err(|e| relicario_core::RelicarioError::RecoveryQr(format!("base64: {e}")))?;
let passphrase = Zeroizing::new(
prompt_password("Enter passphrase: ")
.map_err(|e| relicario_core::RelicarioError::RecoveryQr(e.to_string()))?
);
let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())?;
println!("image_secret: {}", hex::encode(secret.as_ref()));
Ok(())
}
```
Add `use zeroize::Zeroizing;` and `use base64::{engine::general_purpose::STANDARD, Engine};` at the top of main.rs if not already present.
Also add `qrcode`, `base64`, and `hex` to `crates/relicario-cli/Cargo.toml` if not already present (check first with `grep "qrcode\|base64\|hex" crates/relicario-cli/Cargo.toml`).
- [ ] **Step 3: Compile**
```bash
cargo build -p relicario-cli 2>&1 | grep error
```
- [ ] **Step 4: Smoke test**
```bash
cargo run -p relicario-cli -- recovery-qr --help
```
Expected: shows subcommand help with `generate` and `unwrap` listed.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-cli/src/main.rs crates/relicario-cli/Cargo.toml
git commit -m "feat(cli): recovery-qr generate / unwrap subcommands"
```
---
### Task 9: Extension — `settings-security.ts` three-state component
**Files:**
- Create: `extension/src/popup/components/settings-security.ts`
States:
- **State 1 (no QR):** `chrome.storage.local.recovery_qr_generated_at` is null/undefined. Show amber warning + "Generate recovery QR…" button.
- **State 2 (exists, at rest):** timestamp is set. Show green status + "Show / print QR…" and "Regenerate…" buttons.
- **State 3 (explicit view):** modal overlay with rendered SVG QR, print button, done button.
- [ ] **Step 1: Write the component**
```ts
// extension/src/popup/components/settings-security.ts
import { sendMessage, escapeHtml } from '../../shared/state';
import type { DeviceEntry } from '../../shared/types';
export async function renderSecuritySection(
container: HTMLElement,
sessionHandle: number | null,
): Promise<void> {
const ts = await getQrGeneratedAt();
renderSecurityContent(container, ts, sessionHandle);
}
export function teardownSecuritySection(): void {
document.getElementById('relicario-qr-modal')?.remove();
}
async function getQrGeneratedAt(): Promise<number | null> {
return new Promise((resolve) => {
chrome.storage.local.get('recovery_qr_generated_at', (res) => {
resolve(res['recovery_qr_generated_at'] ?? null);
});
});
}
function renderSecurityContent(
container: HTMLElement,
qrGeneratedAt: number | null,
sessionHandle: number | null,
): void {
const dateStr = qrGeneratedAt
? new Date(qrGeneratedAt).toLocaleDateString(undefined, { dateStyle: 'medium' })
: null;
const qrCardHtml = qrGeneratedAt
? `<div class="setting-card setting-card--ok">
<div class="setting-card__status">◉ Recovery QR is set up · <span class="muted">${escapeHtml(dateStr!)}</span></div>
<div class="setting-card__actions">
<button class="btn" id="sec-show-qr" ${!sessionHandle ? 'disabled title="Vault must be unlocked"' : ''}>Show / print QR…</button>
<button class="btn btn-danger" id="sec-regen-qr" ${!sessionHandle ? 'disabled title="Vault must be unlocked"' : ''}>Regenerate…</button>
</div>
</div>`
: `<div class="setting-card setting-card--warn">
<div class="setting-card__status">▲ No recovery QR — losing your reference image would make this vault unrecoverable.</div>
<div class="setting-card__actions">
<button class="btn btn-primary" id="sec-gen-qr" ${!sessionHandle ? 'disabled title="Vault must be unlocked"' : ''}>Generate recovery QR…</button>
</div>
</div>`;
container.innerHTML = `
<div class="settings-section">
<div class="settings-section__title">Recovery QR</div>
${qrCardHtml}
</div>
<div class="settings-section" id="sec-devices-section">
<div class="settings-section__title">Trusted Devices</div>
<div id="sec-devices-list"><span class="muted">Loading…</span></div>
</div>
`;
document.getElementById('sec-gen-qr')?.addEventListener('click', () =>
handleGenerateQr(container, sessionHandle!, false));
document.getElementById('sec-show-qr')?.addEventListener('click', () =>
handleGenerateQr(container, sessionHandle!, false));
document.getElementById('sec-regen-qr')?.addEventListener('click', () => {
if (confirm('Regenerate recovery QR? This will overwrite any existing printed QR.')) {
handleGenerateQr(container, sessionHandle!, true);
}
});
loadDevices();
}
async function handleGenerateQr(
container: HTMLElement,
sessionHandle: number,
isRegen: boolean,
): Promise<void> {
const passphrase = prompt(
isRegen ? 'Enter passphrase to regenerate QR:' : 'Enter passphrase to generate QR:'
);
if (!passphrase) return;
try {
const { wasmGenerateRecoveryQr } = await import('../../shared/wasm');
const svg = await wasmGenerateRecoveryQr(sessionHandle, passphrase);
const now = Date.now();
await new Promise<void>((resolve) => {
chrome.storage.local.set({ recovery_qr_generated_at: now }, resolve);
});
showQrModal(svg);
// Re-render the section in state 2
renderSecurityContent(container, now, sessionHandle);
} catch (err) {
alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`);
}
}
function showQrModal(svg: string): void {
document.getElementById('relicario-qr-modal')?.remove();
const modal = document.createElement('div');
modal.id = 'relicario-qr-modal';
modal.className = 'qr-modal-overlay';
modal.innerHTML = `
<div class="qr-modal">
<div class="qr-modal__header">
<span class="qr-modal__title">Recovery QR</span>
<button class="btn" id="qr-modal-done">Done</button>
</div>
<div class="qr-modal__qr">${svg}</div>
<div class="qr-modal__warning">▲ Close this window before stepping away. This QR is only displayed, never saved.</div>
<div class="qr-modal__actions">
<button class="btn" id="qr-modal-print">⎙ Print</button>
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById('qr-modal-done')?.addEventListener('click', () => modal.remove());
document.getElementById('qr-modal-print')?.addEventListener('click', () => {
const iframe = document.createElement('iframe');
iframe.style.cssText = 'position:absolute;width:0;height:0;border:0;';
document.body.appendChild(iframe);
const doc = iframe.contentWindow!.document;
doc.open();
doc.write(`<html><body>${svg}</body></html>`);
doc.close();
iframe.contentWindow!.print();
setTimeout(() => iframe.remove(), 1000);
});
}
async function loadDevices(): Promise<void> {
const list = document.getElementById('sec-devices-list');
if (!list) return;
const resp = await sendMessage({ type: 'list_devices' });
if (!resp.ok) {
list.innerHTML = `<span class="muted">Failed to load devices: ${escapeHtml(resp.error ?? 'unknown')}</span>`;
return;
}
const data = resp.data as { devices: DeviceEntry[] };
if (data.devices.length === 0) {
list.innerHTML = '<span class="muted">No registered devices.</span>';
return;
}
list.innerHTML = data.devices.map((d) => `
<div class="device-row">
<div class="device-row__info">
<span class="device-row__name">${escapeHtml(d.name ?? 'unnamed')}</span>
<span class="device-row__fingerprint muted">${escapeHtml(d.fingerprint ?? '')}</span>
</div>
<button class="btn btn-danger device-row__revoke" data-fp="${escapeHtml(d.fingerprint ?? '')}">Revoke</button>
</div>
`).join('');
list.querySelectorAll('.device-row__revoke').forEach((btn) => {
btn.addEventListener('click', async () => {
const fp = (btn as HTMLElement).dataset.fp!;
if (!confirm(`Revoke device ${fp.slice(0, 16)}…?`)) return;
const r = await sendMessage({ type: 'revoke_device', fingerprint: fp });
if (r.ok) {
await renderSecuritySection(list.closest('.settings-section-content') as HTMLElement, null);
} else {
alert(`Revoke failed: ${r.error}`);
}
});
});
}
```
**Note:** The `wasmGenerateRecoveryQr` import from `../../shared/wasm` — check what the WASM module exports and match the function name. It may be `wasm_generate_recovery_qr` (Rust snake_case) or camelCase depending on wasm-pack. Adjust the import accordingly. Also check that `DeviceEntry` is exported from `../../shared/types`.
- [ ] **Step 2: Build and check TypeScript errors**
```bash
cd /home/alee/Sources/relicario.v0.5.1-stream-c/extension && bun run build 2>&1 | grep -E "error TS|ERROR"
```
- [ ] **Step 3: Commit**
```bash
git add extension/src/popup/components/settings-security.ts
git commit -m "feat(ext/settings): settings-security.ts three-state recovery QR + devices component"
```
---
### Task 10: Rebuild WASM artifact
Before the extension builds can link to the new WASM bindings, rebuild the artifact.
- [ ] **Step 1: Build WASM**
```bash
cd /home/alee/Sources/relicario.v0.5.1-stream-c
npm run build:wasm --prefix extension
```
Or equivalently:
```bash
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
```
- [ ] **Step 2: Run extension build**
```bash
cd extension && bun run build 2>&1 | grep -E "error|ERROR" | head -20
```
Expected: clean (only pre-existing bundle size warnings).
- [ ] **Step 3: Commit the updated WASM artifact**
```bash
git add extension/wasm/
git commit -m "chore(wasm): rebuild artifact with recovery QR bindings"
```
---
### Task 11: Setup wizard redesign — Style C
**Files:**
- Modify: `extension/src/setup/setup.ts`
Style C replaces the current glass-card layout:
- Full-page dark background (`--bg-page`)
- Logo glyph + wordmark centered at top
- **Colored progress track**: horizontal segments, `--success` for completed, `--gold` for current, `--border` for pending
- Centered card (max-width 560px): step eyebrow ("Step N of 5 · name"), h2 heading, hint, form, actions
- Glyphs not emoji. Mode cards use `◈` (create new) and `⌥` (attach), rendered at 28px.
- Action row: "◂ back" left, "Continue ▸" right
- [ ] **Step 1: Read the current setup.ts structure**
```bash
wc -l extension/src/setup/setup.ts
grep -n "^function render\|^async function render\|step[0-9]" extension/src/setup/setup.ts | head -30
```
- [ ] **Step 2: Add the progress track + card wrapper helpers**
At the top of setup.ts (after existing imports), add:
```ts
const STEP_NAMES = ['vault setup', 'choose mode', 'passphrase', 'reference image', 'done'];
function renderProgressTrack(current: number): string {
return `
<div class="setup-progress-track">
${STEP_NAMES.map((name, i) => {
const cls = i < current ? 'completed' : i === current ? 'active' : 'pending';
return `<div class="setup-progress-segment setup-progress-segment--${cls}" title="${name}"></div>`;
}).join('')}
</div>
`;
}
function wrapStepCard(stepIdx: number, heading: string, hint: string, bodyHtml: string, actionsHtml: string): string {
return `
<div class="setup-page">
<div class="setup-logo">
<img src="icons/relicario-logo-16.svg" alt="" class="setup-logo__img">
<span class="setup-logo__wordmark">Relicario</span>
</div>
${renderProgressTrack(stepIdx)}
<div class="setup-card">
<div class="setup-card__eyebrow">Step ${stepIdx + 1} of 5 · ${STEP_NAMES[stepIdx]}</div>
<h2 class="setup-card__heading">${heading}</h2>
<p class="setup-card__hint">${hint}</p>
<div class="setup-card__body">
${bodyHtml}
</div>
<div class="setup-card__actions">
${actionsHtml}
</div>
</div>
</div>
`;
}
```
- [ ] **Step 3: Rewrite `renderStep0` (intro) and `renderStep1` (mode selection)**
`renderStep1` currently shows the "create new / attach existing" mode cards with emoji. Change to `◈` / `⌥` at 28px, wrapped in `wrapStepCard(1, ...)`.
Find `renderStep1` in setup.ts and replace the mode card HTML:
```ts
// Old: emoji mode card icons → new: glyph at 28px
// Replace the mode card icon spans with:
// <span class="mode-card__icon" style="font-size:28px;">◈</span> // create new
// <span class="mode-card__icon" style="font-size:28px;">⌥</span> // attach existing
```
Wrap each `renderStepN` function body with `wrapStepCard(N, heading, hint, bodyHtml, actionsHtml)`.
- [ ] **Step 4: Add progress track CSS**
In the setup CSS (either inline in setup.ts or the extracted CSS file — check where the existing setup styles live):
```css
.setup-progress-track {
display: flex;
gap: 4px;
width: 100%;
max-width: 560px;
margin: 12px auto;
}
.setup-progress-segment {
flex: 1;
height: 4px;
border-radius: 2px;
}
.setup-progress-segment--completed { background: var(--success, #238636); }
.setup-progress-segment--active { background: var(--gold, #b8860b); }
.setup-progress-segment--pending { background: var(--border, #30363d); }
.setup-page {
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 40px 20px;
background: var(--bg-page, #0d1117);
}
.setup-card {
width: 100%;
max-width: 560px;
background: var(--bg-elevated, #161b22);
border: 1px solid var(--border, #30363d);
border-radius: 12px;
padding: 32px;
}
.setup-card__eyebrow { font-size: 11px; color: var(--text-muted, #8b949e); margin-bottom: 8px; }
.setup-card__heading { font-size: 20px; font-weight: 700; margin: 0 0 8px; }
.setup-card__hint { font-size: 13px; color: var(--text-muted, #8b949e); margin: 0 0 24px; }
.setup-card__actions { display: flex; justify-content: space-between; margin-top: 24px; }
.setup-logo {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
}
.setup-logo__img { width: 24px; height: 24px; }
.setup-logo__wordmark { font-size: 18px; font-weight: 700; }
```
- [ ] **Step 5: Build and verify**
```bash
cd /home/alee/Sources/relicario.v0.5.1-stream-c/extension && bun run build 2>&1 | grep -E "error TS|ERROR"
```
- [ ] **Step 6: Commit**
```bash
git add extension/src/setup/setup.ts
git commit -m "feat(ext/setup): wizard redesign — Style C card layout, progress track, glyphs"
```
---
### Task 12: Setup wizard — Step 5 recovery QR banner
**Files:**
- Modify: `extension/src/setup/setup.ts`
The final step ("done") adds a skippable banner above the "Download reference image" button.
- [ ] **Step 1: Find `renderStep5` (or whatever the final step is)**
```bash
grep -n "renderStep\|step.*5\|done\|finish\|download" extension/src/setup/setup.ts | tail -30
```
- [ ] **Step 2: Add banner HTML to the final step**
Find the "done" / final-step render function and add the recovery QR banner before the download button:
```ts
const qrBannerHtml = `
<div class="recovery-qr-banner" id="recovery-qr-banner">
<span class="recovery-qr-banner__icon">◫</span>
<div class="recovery-qr-banner__text">
<strong>Generate a recovery QR before you go</strong>
<p>If you lose your reference image, this QR lets you recover your vault.</p>
</div>
<div class="recovery-qr-banner__actions">
<button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
<button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
</div>
</div>
`;
```
Insert this banner into the step body HTML (before the download button).
- [ ] **Step 3: Wire the banner buttons**
In the step's `attachStepN()` wiring function, add:
```ts
document.getElementById('setup-gen-qr')?.addEventListener('click', async () => {
const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement;
btn.disabled = true;
btn.textContent = 'Generating…';
const passphrase = (document.getElementById('setup-passphrase') as HTMLInputElement)?.value;
// The passphrase field should still be accessible from state or the wizard's stored value.
// If not, prompt for it:
const finalPassphrase = passphrase || prompt('Enter passphrase to generate QR:') || '';
if (!finalPassphrase) { btn.disabled = false; btn.textContent = 'Generate now'; return; }
try {
const { wasmGenerateRecoveryQr } = await import('../shared/wasm');
// sessionHandle is available from the setup wizard's unlock step
const handle = getSetupSessionHandle(); // replace with actual accessor
const svg = await wasmGenerateRecoveryQr(handle, finalPassphrase);
await new Promise<void>((resolve) => {
chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve);
});
// Show inline QR
const banner = document.getElementById('recovery-qr-banner')!;
banner.classList.add('recovery-qr-banner--generated');
banner.innerHTML = `
<div class="qr-inline">${svg}</div>
<div class="recovery-qr-banner__ok">◉ Recovery QR generated — save or print this QR now.</div>
<div class="recovery-qr-banner__actions">
<button class="btn" id="setup-qr-done">Done</button>
</div>
`;
document.getElementById('setup-qr-done')?.addEventListener('click', () => {
banner.innerHTML = '<span class="muted">◉ Recovery QR generated.</span>';
});
} catch (err) {
btn.disabled = false;
btn.textContent = 'Generate now';
alert(`Failed: ${err instanceof Error ? err.message : String(err)}`);
}
});
document.getElementById('setup-skip-qr')?.addEventListener('click', () => {
const banner = document.getElementById('recovery-qr-banner');
if (banner) banner.style.display = 'none';
});
```
**Note:** `getSetupSessionHandle()` — you need to check how the wizard stores the session handle after unlock. Look for where `unlock()` is called in setup.ts and where the result is stored. Adjust accordingly.
- [ ] **Step 4: Add banner CSS**
```css
.recovery-qr-banner {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: var(--bg-elevated, #161b22);
border: 1px solid var(--gold, #b8860b);
border-radius: 8px;
margin-bottom: 16px;
}
.recovery-qr-banner__icon { font-size: 20px; }
.recovery-qr-banner__text p { margin: 4px 0 0; font-size: 12px; color: var(--text-muted, #8b949e); }
.recovery-qr-banner__actions { display: flex; gap: 8px; margin-top: 8px; }
.recovery-qr-banner--generated { border-color: var(--success, #238636); }
.qr-inline svg { display: block; margin: 0 auto; }
.recovery-qr-banner__ok { font-size: 12px; color: var(--success, #238636); }
```
- [ ] **Step 5: Build**
```bash
cd /home/alee/Sources/relicario.v0.5.1-stream-c/extension && bun run build 2>&1 | grep -E "error TS|ERROR"
```
- [ ] **Step 6: Run full test suites**
```bash
cd /home/alee/Sources/relicario.v0.5.1-stream-c
cargo test 2>&1 | tail -10
cd extension && bun run test 2>&1 | tail -10
```
Expected: all pass.
- [ ] **Step 7: Commit**
```bash
git add extension/src/setup/setup.ts
git commit -m "feat(ext/setup): recovery QR banner in final wizard step"
```
---
## Final steps
- [ ] Open PR: `gh pr create --title "feat: recovery QR (Stream C)" --base main`
- [ ] 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