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>
1368 lines
44 KiB
Markdown
1368 lines
44 KiB
Markdown
# Dev C Kickoff Prompt — v0.5.1 Stream C (Recovery QR)
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||
|
||
---
|
||
|
||
You are a **senior developer** owning Stream C for the Relicario v0.5.1 release. Stream C implements the Recovery QR feature: Rust core + WASM bindings + CLI subcommand + setup wizard redesign + settings-security.ts component.
|
||
|
||
**Goal:** Ship `generate_recovery_qr` / `unwrap_recovery_qr` in relicario-core and WASM, a `recovery-qr` CLI subcommand, a redesigned setup wizard (Style C with glyphs), and a three-state security section component for the settings page.
|
||
|
||
**Architecture:** Rust core is the canonical implementation (bytes-in/bytes-out). WASM wraps it for the extension. The extension component (`settings-security.ts`) is fully owned by this stream — DEV-B stubs the import. Session storage in WASM is extended to hold `image_secret` alongside `master_key` so QR generation doesn't require re-uploading the reference image.
|
||
|
||
**Tech Stack:** Rust, `qrcode` crate (SVG output), `chacha20poly1305`, `argon2`, `unicode-normalization`; TypeScript (vitest), wasm-bindgen.
|
||
|
||
---
|
||
|
||
## Setup (do this first)
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git fetch
|
||
git checkout main
|
||
git pull
|
||
git worktree add ../relicario.v0.5.1-stream-c -b feature/v0.5.1-stream-c-recovery-qr
|
||
cd ../relicario.v0.5.1-stream-c
|
||
pwd # should print /home/alee/Sources/relicario.v0.5.1-stream-c
|
||
```
|
||
|
||
**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.5.1-stream-c`**. Every subagent prompt MUST begin with `cd /home/alee/Sources/relicario.v0.5.1-stream-c`.
|
||
|
||
Today: 2026-05-03. Project rules in `CLAUDE.md` apply.
|
||
|
||
## Required reading
|
||
|
||
1. `CLAUDE.md` — project rules
|
||
2. `docs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md` — spec sections C1–C6
|
||
3. `crates/relicario-core/src/crypto.rs` — existing KDF and AEAD implementation (read the public API)
|
||
4. `crates/relicario-wasm/src/session.rs` — current session storage (you will expand this)
|
||
5. `crates/relicario-wasm/src/lib.rs` — existing WASM bindings (you will add to these)
|
||
6. `extension/src/setup/setup.ts` — current setup wizard (you will redesign this)
|
||
|
||
## Execution mode
|
||
|
||
Use **subagent-driven-development**. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.v0.5.1-stream-c`.
|
||
|
||
## Scope and boundaries
|
||
|
||
**In scope:** C1 (recovery_qr.rs), C2 (CLI), C3 (WASM bindings), C4 (settings-security.ts), C5 (setup wizard QR banner), C6 (setup wizard redesign), session.rs expansion.
|
||
|
||
**Out of scope:** Stream A and B work. If you find a bug outside your scope, post it via `## QUESTION TO PM`.
|
||
|
||
**Hard rules:**
|
||
- QR payload bytes must NEVER be written to `chrome.storage`, IndexedDB, git, or the filesystem. Only `recovery_qr_generated_at` (timestamp) is persisted.
|
||
- The passphrase must NOT be logged, stored in a non-Zeroizing container, or leaked through error messages.
|
||
- `unlock()` change: image_secret must be stored in `SessionData` alongside master_key.
|
||
- Don't merge to main. The PM owns merges.
|
||
|
||
## Interface contract with DEV-B
|
||
|
||
You own `extension/src/popup/components/settings-security.ts`. DEV-B imports it. The agreed export signature:
|
||
|
||
```ts
|
||
export async function renderSecuritySection(
|
||
container: HTMLElement,
|
||
sessionHandle: number | null,
|
||
): Promise<void>;
|
||
|
||
export function teardownSecuritySection(): void;
|
||
```
|
||
|
||
DEV-B has a stub. Your Task 9 provides the real implementation.
|
||
|
||
## 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, ¶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<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
|