Compare commits
14 Commits
b2749826b1
...
feature/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33d2a4a311 | ||
|
|
f17944a404 | ||
|
|
4851857070 | ||
|
|
a6071b4c0c | ||
|
|
ada00895d4 | ||
|
|
42b746f9af | ||
|
|
762a008171 | ||
|
|
f93bce7388 | ||
|
|
8eabaf5f31 | ||
|
|
04142dc116 | ||
|
|
8739f1f67b | ||
|
|
7d6fd76e86 | ||
|
|
4dc034d846 | ||
|
|
3021ef9d9f |
@@ -54,6 +54,10 @@ two confirmed bugs).
|
||||
|
||||
### Added
|
||||
|
||||
- **Sidebar logo in the fullscreen vault tab.** The
|
||||
`vault-sidebar__header` now renders the 16-optimized SVG logo
|
||||
inline before the "Relicario" wordmark (20×20 px, `flex-shrink: 0`
|
||||
so it survives narrow-pane wraps). Popup unaffected.
|
||||
- **Password coloring (P1).** Revealed passwords in the popup
|
||||
item-detail, fullscreen item view, field-history viewer, and
|
||||
generator preview render digits and symbols in distinct colors.
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2198,6 +2198,7 @@ dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
"image",
|
||||
"qrcode",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -28,10 +28,10 @@ clap_complete = "4"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||
rqrr = "0.7"
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
qrcode = { version = "0.14", features = ["svg"] }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
tempfile = "3"
|
||||
qrcode = "0.14"
|
||||
serde_json = "1"
|
||||
|
||||
@@ -196,6 +196,12 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
action: DeviceAction,
|
||||
},
|
||||
|
||||
/// Recovery QR operations — generate or unwrap the 2FA recovery code.
|
||||
RecoveryQr {
|
||||
#[command(subcommand)]
|
||||
cmd: RecoveryQrCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -403,6 +409,14 @@ enum DeviceAction {
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand)]
|
||||
enum RecoveryQrCmd {
|
||||
/// Generate a recovery QR code and display it as ASCII art in the terminal.
|
||||
Generate,
|
||||
/// Unwrap a recovery QR payload (base64) to recover the image_secret as hex.
|
||||
Unwrap,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
@@ -436,6 +450,7 @@ fn main() -> Result<()> {
|
||||
}
|
||||
Commands::Rate { passphrase } => cmd_rate(passphrase),
|
||||
Commands::Device { action } => cmd_device(action),
|
||||
Commands::RecoveryQr { cmd } => cmd_recovery_qr(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2560,3 +2575,67 @@ fn cmd_device(action: DeviceAction) -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> {
|
||||
match cmd {
|
||||
RecoveryQrCmd::Generate => cmd_recovery_qr_generate(),
|
||||
RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_recovery_qr_generate() -> Result<()> {
|
||||
use relicario_core::{generate_recovery_qr, imgsecret};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let image_path = crate::session::get_image_path()?;
|
||||
let image_bytes = std::fs::read(&image_path)
|
||||
.with_context(|| format!("read reference image {}", image_path.display()))?;
|
||||
let image_secret = imgsecret::extract(&image_bytes)
|
||||
.context("extract image secret")?;
|
||||
|
||||
let passphrase = Zeroizing::new(
|
||||
rpassword::prompt_password("Enter vault passphrase: ")
|
||||
.context("read passphrase")?
|
||||
);
|
||||
|
||||
let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
use qrcode::{EcLevel, QrCode, render::unicode};
|
||||
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M)
|
||||
.expect("valid payload");
|
||||
let image = code
|
||||
.render::<unicode::Dense1x2>()
|
||||
.dark_color(unicode::Dense1x2::Dark)
|
||||
.light_color(unicode::Dense1x2::Light)
|
||||
.build();
|
||||
println!("{image}");
|
||||
println!("Recovery QR generated. Print or photograph this code and store it securely.");
|
||||
println!("The QR has NOT been saved to disk.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_recovery_qr_unwrap() -> Result<()> {
|
||||
use relicario_core::unwrap_recovery_qr;
|
||||
use std::io::BufRead;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
println!("Paste the base64 recovery QR payload and press Enter:");
|
||||
let stdin = std::io::stdin();
|
||||
let payload_b64 = stdin.lock().lines().next()
|
||||
.context("no input")??;
|
||||
let payload_b64 = payload_b64.trim().to_owned();
|
||||
|
||||
let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?;
|
||||
|
||||
let passphrase = Zeroizing::new(
|
||||
rpassword::prompt_password("Enter passphrase: ")
|
||||
.context("read passphrase")?
|
||||
);
|
||||
|
||||
let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
println!("image_secret: {}", hex::encode(secret.as_ref()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -31,5 +31,6 @@ zstd = { version = "0.13", default-features = false }
|
||||
tar = { version = "0.4", default-features = false }
|
||||
base64 = "0.22"
|
||||
csv = "1"
|
||||
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -243,6 +243,23 @@ pub fn derive_master_key(
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Like `derive_master_key` but takes an already-assembled `input` byte slice directly,
|
||||
/// allowing callers to apply their own domain separation before KDF.
|
||||
pub fn derive_master_key_raw(
|
||||
input: &[u8],
|
||||
salt: &[u8; 32],
|
||||
params: &KdfParams,
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let argon2_params = Params::new(params.argon2_m, params.argon2_t, params.argon2_p, Some(32))
|
||||
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||
let mut output = Zeroizing::new([0u8; 32]);
|
||||
argon2
|
||||
.hash_password_into(input, salt, output.as_mut())
|
||||
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -119,6 +119,10 @@ pub enum RelicarioError {
|
||||
/// immediately. Use TOTP instead.
|
||||
#[error("HOTP is not supported: counter persistence requires vault save after each use")]
|
||||
HotpNotSupported,
|
||||
|
||||
/// Recovery QR generation or parsing failed.
|
||||
#[error("recovery QR: {0}")]
|
||||
RecoveryQr(String),
|
||||
}
|
||||
|
||||
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
||||
|
||||
@@ -89,3 +89,11 @@ pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign,
|
||||
|
||||
pub mod tar_safe;
|
||||
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
129
crates/relicario-core/src/recovery_qr.rs
Normal file
129
crates/relicario-core/src/recovery_qr.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
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);
|
||||
|
||||
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().expect("slice length validated above");
|
||||
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)
|
||||
}
|
||||
|
||||
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 bytes fits well within QR v40 capacity at EcLevel::M");
|
||||
code.render::<qrcode::render::svg::Color>()
|
||||
.min_dimensions(140, 140)
|
||||
.build()
|
||||
}
|
||||
60
crates/relicario-core/tests/recovery_qr.rs
Normal file
60
crates/relicario-core/tests/recovery_qr.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
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());
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod session;
|
||||
mod device;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use relicario_core::{derive_master_key, imgsecret, KdfParams};
|
||||
|
||||
@@ -36,7 +37,8 @@ pub fn unlock(
|
||||
.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 handle = session::insert(master_key);
|
||||
let stored_secret = Zeroizing::new(image_secret);
|
||||
let handle = session::insert(master_key, stored_secret);
|
||||
Ok(SessionHandle(handle))
|
||||
}
|
||||
|
||||
@@ -484,6 +486,39 @@ pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
|
||||
Ok(json.to_string())
|
||||
}
|
||||
|
||||
// ── Recovery QR bindings ─────────────────────────────────────────────────────
|
||||
|
||||
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 wraps the image_secret under a
|
||||
/// separate key (domain-separated from the master key derivation).
|
||||
#[wasm_bindgen]
|
||||
pub fn wasm_generate_recovery_qr(
|
||||
handle: &SessionHandle,
|
||||
passphrase: &str,
|
||||
) -> Result<String, JsError> {
|
||||
let payload = session::with_image_secret(handle.0, |s| generate_recovery_qr(passphrase, s))
|
||||
.ok_or_else(|| JsError::new("invalid or locked session handle"))?
|
||||
.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())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_tests {
|
||||
use super::*;
|
||||
@@ -492,7 +527,7 @@ mod session_tests {
|
||||
#[test]
|
||||
fn insert_then_remove_clears_entry() {
|
||||
session::clear();
|
||||
let h = session::insert(Zeroizing::new([0x11u8; 32]));
|
||||
let h = session::insert(Zeroizing::new([0x11u8; 32]), Zeroizing::new([0u8; 32]));
|
||||
assert_ne!(h, 0);
|
||||
assert!(session::remove(h));
|
||||
assert!(!session::remove(h)); // second remove false
|
||||
@@ -501,7 +536,7 @@ mod session_tests {
|
||||
#[test]
|
||||
fn with_yields_key_only_while_session_lives() {
|
||||
session::clear();
|
||||
let h = session::insert(Zeroizing::new([0x22u8; 32]));
|
||||
let h = session::insert(Zeroizing::new([0x22u8; 32]), Zeroizing::new([0u8; 32]));
|
||||
let byte = session::with(h, |k| k[0]);
|
||||
assert_eq!(byte, Some(0x22));
|
||||
session::remove(h);
|
||||
@@ -513,7 +548,7 @@ mod session_tests {
|
||||
fn manifest_round_trip_via_handle() {
|
||||
use relicario_core::{Manifest, decrypt_manifest};
|
||||
session::clear();
|
||||
let h = session::insert(Zeroizing::new([0x55u8; 32]));
|
||||
let h = session::insert(Zeroizing::new([0x55u8; 32]), Zeroizing::new([0u8; 32]));
|
||||
let handle = SessionHandle(h);
|
||||
let key = Zeroizing::new([0x55u8; 32]);
|
||||
let empty = Manifest::new();
|
||||
|
||||
@@ -6,12 +6,17 @@ 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, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new());
|
||||
static SESSIONS: RefCell<HashMap<u32, SessionData>> = RefCell::new(HashMap::new());
|
||||
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
|
||||
}
|
||||
|
||||
pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
|
||||
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;
|
||||
@@ -19,15 +24,26 @@ pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
|
||||
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
|
||||
h
|
||||
});
|
||||
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); });
|
||||
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(f))
|
||||
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 {
|
||||
|
||||
1448
docs/superpowers/coordination/v0.5.1-dev-a-prompt.md
Normal file
1448
docs/superpowers/coordination/v0.5.1-dev-a-prompt.md
Normal file
File diff suppressed because it is too large
Load Diff
1074
docs/superpowers/coordination/v0.5.1-dev-b-prompt.md
Normal file
1074
docs/superpowers/coordination/v0.5.1-dev-b-prompt.md
Normal file
File diff suppressed because it is too large
Load Diff
1353
docs/superpowers/coordination/v0.5.1-dev-c-prompt.md
Normal file
1353
docs/superpowers/coordination/v0.5.1-dev-c-prompt.md
Normal file
File diff suppressed because it is too large
Load Diff
165
docs/superpowers/coordination/v0.5.1-pm-prompt.md
Normal file
165
docs/superpowers/coordination/v0.5.1-pm-prompt.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# PM Kickoff Prompt — v0.5.1 UX Polish + Recovery QR
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are the **project manager** for the Relicario v0.5.1 release. Three senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all four terminals and relays messages between them.
|
||||
|
||||
## Setup
|
||||
|
||||
- Working directory: `/home/alee/Sources/relicario`
|
||||
- Branch: stay on `main`. Do not check out feature branches.
|
||||
- Today: 2026-05-03. Project rules in `CLAUDE.md` apply (Spanish flourish, capitalization, autonomy defaults, never run git-destructive commands without asking).
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md` — full spec
|
||||
3. `docs/superpowers/coordination/v0.5.1-dev-a-prompt.md` — Dev A's plan (Stream A: fullscreen + popup layout)
|
||||
4. `docs/superpowers/coordination/v0.5.1-dev-b-prompt.md` — Dev B's plan (Stream B: settings UX)
|
||||
5. `docs/superpowers/coordination/v0.5.1-dev-c-prompt.md` — Dev C's plan (Stream C: recovery QR)
|
||||
|
||||
## Your authority
|
||||
|
||||
- Approve or deny scope changes from devs
|
||||
- Review and merge PRs from all three feature branches
|
||||
- **Drive the interface contract** between B and C (see below) — this is your first hands-on action
|
||||
- Write the `CHANGELOG.md` entry for v0.5.1
|
||||
- Tag `v0.5.1` once everything is integrated **— but only after explicit user approval**
|
||||
|
||||
## Your boundaries
|
||||
|
||||
- Don't write feature code yourself. Edits to docs / CHANGELOG / CLAUDE.md are fine.
|
||||
- Don't deviate from the spec without user approval.
|
||||
- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm.
|
||||
- Don't tag without user approval.
|
||||
- Project rule: ask the user before any git-destructive op.
|
||||
|
||||
## Stream overview
|
||||
|
||||
| Stream | Branch | Owner | Core files |
|
||||
|--------|--------|-------|-----------|
|
||||
| A — Fullscreen + popup layout | `feature/v0.5.1-stream-a-layout` | DEV-A | `vault.ts`, `vault.css`, `item-list.ts`, `item-form.ts`, `glyphs.ts`, `toast.ts` |
|
||||
| B — Settings UX | `feature/v0.5.1-stream-b-settings` | DEV-B | `settings.ts`, `settings-vault.ts` (decomposed), `settings-security.ts` (stub only) |
|
||||
| C — Recovery QR | `feature/v0.5.1-stream-c-recovery-qr` | DEV-C | `recovery_qr.rs`, WASM `session.rs`/`lib.rs`, `settings-security.ts`, `setup.ts` |
|
||||
|
||||
## Interface contracts (enforce before work starts)
|
||||
|
||||
### A–B: Settings component signature
|
||||
|
||||
DEV-B's settings component is wired into vault.ts by DEV-A. Both must agree before either proceeds with their vault.ts / settings.ts work.
|
||||
|
||||
**Agreed interface** (post to both devs as your first directive):
|
||||
|
||||
```ts
|
||||
// extension/src/popup/components/settings.ts
|
||||
|
||||
/**
|
||||
* Render the full sectioned settings view into `container`.
|
||||
* May be called from vault.ts (fullscreen, full-width pane) or popup.ts (popup).
|
||||
*/
|
||||
export async function renderSettings(container: HTMLElement): Promise<void>;
|
||||
|
||||
/**
|
||||
* Teardown: close any open generator panel, remove keyboard listeners.
|
||||
* Call before navigating away from the settings view.
|
||||
*/
|
||||
export function teardownSettings(): void;
|
||||
```
|
||||
|
||||
DEV-A imports `{ renderSettings, teardownSettings }` from `settings.ts` in vault.ts.
|
||||
DEV-B exports these names with these exact signatures.
|
||||
|
||||
### B–C: Security section component signature
|
||||
|
||||
DEV-C owns and implements `settings-security.ts`. DEV-B imports it for the Security section. They must agree before DEV-B writes B4 (Security section) or DEV-C writes C8 (settings-security.ts).
|
||||
|
||||
**Agreed interface** (post to both devs as your first directive):
|
||||
|
||||
```ts
|
||||
// extension/src/popup/components/settings-security.ts
|
||||
|
||||
/**
|
||||
* Render the three-state Recovery QR + trusted devices security section
|
||||
* into `container`. `sessionHandle` is the current WASM session handle value
|
||||
* (from the service-worker's session), or null if the vault is locked.
|
||||
*/
|
||||
export async function renderSecuritySection(
|
||||
container: HTMLElement,
|
||||
sessionHandle: number | null,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Teardown: remove any event listeners attached during render.
|
||||
*/
|
||||
export function teardownSecuritySection(): void;
|
||||
```
|
||||
|
||||
DEV-B stubs this interface in `settings-security.ts` immediately after receiving this directive. DEV-C replaces it with the real implementation.
|
||||
|
||||
## Merge order and strategy
|
||||
|
||||
1. **C lands first** (or concurrently with A; no A or B dependency). Merge once DEV-C posts REVIEW-READY.
|
||||
2. **A and B can merge in either order** after C is on main, since both will rebase/merge main before PR.
|
||||
3. No squash merges — git history is preserved per project rule.
|
||||
4. No force pushes. Each dev opens a PR; PM reviews diff; PM merges with `gh pr merge --merge`.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. The user relays messages.
|
||||
|
||||
**You receive:** `## STATUS UPDATE — DEV-A/B/C` or `## QUESTION TO PM — DEV-X` blocks.
|
||||
|
||||
**You emit:** a `## DIRECTIVE TO DEV-X` block. Format:
|
||||
|
||||
```
|
||||
## DIRECTIVE TO DEV-A (or B or C)
|
||||
Time: <iso8601>
|
||||
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
|
||||
Notes: <one paragraph max>
|
||||
Next: <one concrete instruction or "continue plan">
|
||||
```
|
||||
|
||||
When asked "status?" by the user at any time:
|
||||
|
||||
```
|
||||
## RELEASE STATUS — v0.5.1
|
||||
Dev A: <task X of Y, status>
|
||||
Dev B: <task X of Y, status>
|
||||
Dev C: <task X of Y, status>
|
||||
PM: <current action>
|
||||
Blockers: <list, or "none">
|
||||
Next milestone: <e.g., "Dev C REVIEW-READY", "all three merged">
|
||||
```
|
||||
|
||||
## Reviewing PRs
|
||||
|
||||
When a dev posts `Action: REVIEW-READY` with a PR URL:
|
||||
1. `gh pr view <url>` to read description and CI status
|
||||
2. `gh pr diff <url>` to read changes
|
||||
3. Check diff against the spec sections owned by that stream
|
||||
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge`
|
||||
5. If red: post `Action: HOLD` with specific concerns
|
||||
|
||||
## Pre-tag checklist
|
||||
|
||||
Before tagging v0.5.1:
|
||||
|
||||
- [ ] `feature/v0.5.1-stream-a-layout` merged to main
|
||||
- [ ] `feature/v0.5.1-stream-b-settings` merged to main
|
||||
- [ ] `feature/v0.5.1-stream-c-recovery-qr` merged to main
|
||||
- [ ] `cargo test` green on main
|
||||
- [ ] `bun run test` green (extension)
|
||||
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green
|
||||
- [ ] `bun run build` + `bun run build:firefox` clean (extension)
|
||||
- [ ] No emoji in any UI surface (grep: `'🔑\|📝\|🪪\|💳\|🗝\|📄\|⏱️'` in `extension/src/`)
|
||||
- [ ] `GLYPH_VAULT_TAB` in glyphs.ts; no inline `⤴` anywhere
|
||||
- [ ] `recovery_qr_generated_at` is the only persisted QR artifact (grep: no QR SVG in chrome.storage calls)
|
||||
- [ ] Settings left-nav sections all render without console errors
|
||||
- [ ] `CHANGELOG.md` entry for v0.5.1 written
|
||||
- [ ] Explicit user approval to tag
|
||||
|
||||
## First action
|
||||
|
||||
After reading: post a `## RELEASE STATUS — v0.5.1` block, then post your first directive to all three devs simultaneously — confirming the A–B and B–C interface contracts above. Wait for devs to acknowledge before instructing them to proceed with their task lists.
|
||||
@@ -0,0 +1,357 @@
|
||||
# v0.5.x — UX Polish, Settings Redesign & Recovery QR — Design
|
||||
|
||||
**Date:** 2026-05-03
|
||||
**Status:** Draft
|
||||
**Target:** v0.5.1 / next release train
|
||||
|
||||
## Overview
|
||||
|
||||
Three parallel streams building on the v0.5.0 base:
|
||||
|
||||
- **Stream A — Fullscreen + popup layout polish** — fullscreen vault tab gets a new 3-column layout (sidebar with type-category nav, full-width list, slide-in detail drawer); popup gets a polished type-picker; glyph additions; toast system; empty states.
|
||||
- **Stream B — Settings UX redesign** — replace the current flat settings dump with a left-nav sectioned settings page; Security section with trusted-devices and Recovery QR integration.
|
||||
- **Stream C — Recovery QR + setup wizard** — implement the recovery QR cryptographic feature (Rust core + WASM); integrate into the setup wizard's final step; wire into the vault-tab Security settings section.
|
||||
|
||||
Streams A and B share no files with Stream C (Rust/WASM). A and B share only `glyphs.ts` and `styles.css`; all other files are disjoint. All three can run in parallel.
|
||||
|
||||
---
|
||||
|
||||
## Stream A — Fullscreen + Popup Layout Polish
|
||||
|
||||
### A1. Fullscreen vault tab — 3-column layout
|
||||
|
||||
**Current state.** `extension/src/vault/vault.ts` renders a fixed sidebar (~220px) with brand, search, item list, and bottom nav buttons. Clicking `+ new item` navigates to the type-picker. The main pane shows the selected item in a single-column layout.
|
||||
|
||||
**New layout.**
|
||||
|
||||
```
|
||||
┌─────────────┬──────────────────────────┬──────────────────────────┐
|
||||
│ sidebar │ full-width list │ detail drawer (440px) │
|
||||
│ (200px) │ (flex: 1) │ slides in on row click │
|
||||
└─────────────┴──────────────────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
**Sidebar changes:**
|
||||
- Replaces the current flat item list with **type-category nav**: all items are listed by section (Logins N, Secure Notes N, Cards N, Identities N, TOTP N, Keys N, Documents N) plus an "All items" entry at the top.
|
||||
- Search bar stays above the category list.
|
||||
- Bottom nav buttons remain (+ new item, ▦ trash, ⌬ devices, ⚙ settings, ⏻ lock) — the `+ new item` button triggers the bottom sheet (see A3).
|
||||
- `⧉` replaces the current `⤴` pop-out button in the **popup toolbar only** — it stays in the popup toolbar and is not added to the fullscreen sidebar (you're already there).
|
||||
|
||||
**Full-width list:**
|
||||
- Each row: 32px type icon (rounded, gold-tinted on selection) + title (13px) + subtitle (URL or type description, 11px muted) + `last-modified` age (10px dim, right-aligned).
|
||||
- Clicking a row: highlights the row and slides in the detail drawer from the right. The list narrows to accommodate the 440px drawer — flex layout handles this naturally.
|
||||
- Active row stays highlighted while drawer is open.
|
||||
|
||||
**Detail drawer (440px):**
|
||||
- Header: type pill (e.g. `LOGIN`) left, action buttons right (`edit`, `history`, `copy pwd` where applicable), `✕` close.
|
||||
- Body: title (18-20px bold) + subtitle (URL/description, muted), then a **2-column field grid** for sibling fields (username/password, first/last name, number/expiry, etc.). Full-width spans for URL, notes, address, and any field without a natural pair.
|
||||
- Close (`✕` or Esc): drawer slides out, list returns to full width.
|
||||
- At ≤ 720px viewport: drawer pushes full-page (list hidden), back breadcrumb `← <Section>` navigates back.
|
||||
|
||||
**Files affected:**
|
||||
- `extension/src/vault/vault.ts` — full layout rewrite (sidebar list → category nav, main pane wiring, drawer state)
|
||||
- `extension/src/vault/vault.css` — layout rules for 3-column, drawer, list rows, responsive breakpoint
|
||||
|
||||
### A2. Fullscreen vault tab — "new item" bottom sheet
|
||||
|
||||
**Current state.** Clicking `+ new item` in the sidebar sets `state.newType = null` and calls `renderPane()` which renders the type-picker inline in the main pane.
|
||||
|
||||
**New behaviour.** A bottom sheet slides up from the bottom edge of the **main pane** (pane-only scrim — sidebar stays interactive).
|
||||
|
||||
- Sheet structure: drag handle, "New item — choose type" label, 7-item type grid (Login, Secure Note, TOTP, Card, Identity, SSH/API Key, Document) as cards with large glyph (28px), name (11px muted). Selected type border turns gold on hover.
|
||||
- Clicking a type: sheet closes, main pane renders the add form for that type.
|
||||
- Dismissing (Esc, click scrim, `✕`): sheet closes, main pane returns to previous state.
|
||||
- Scrim covers the main pane only (not the sidebar). Sidebar nav remains clickable.
|
||||
|
||||
**Files affected:**
|
||||
- `extension/src/vault/vault.ts` — sheet trigger, render, dismiss logic
|
||||
- `extension/src/vault/vault.css` — sheet, scrim, type-card styles
|
||||
|
||||
### A3. Popup — polished type-picker page
|
||||
|
||||
**Current state.** `+ new` button in the popup toolbar navigates directly to the `add` route. `renderItemForm` is called with `state.newType = null`, which presumably renders a type picker inline.
|
||||
|
||||
**New behaviour.** Keep the current navigation model (navigate to `add` route) but upgrade the type-picker page:
|
||||
- Back arrow + "New item" title in the search-bar row (replacing search input).
|
||||
- 2-column grid of type cards: icon (glyph, 20px), name (12px bold), description (10px muted). E.g. "Login / Username + password", "TOTP / 2FA token".
|
||||
- Glyphs not emoji for type icons (use the per-type glyph table from A5).
|
||||
- `Esc` navigates back to the list.
|
||||
- Keyhint bar updates to show `Esc back`.
|
||||
|
||||
**Files affected:**
|
||||
- `extension/src/popup/components/item-list.ts` — `+ new` button label/glyph, keyhint
|
||||
- `extension/src/popup/components/item-form.ts` (or wherever the type picker lives) — card layout, glyphs
|
||||
|
||||
### A4. Glyphs
|
||||
|
||||
Add to `extension/src/shared/glyphs.ts`:
|
||||
|
||||
```ts
|
||||
export const GLYPH_VAULT_TAB = '⧉'; // pop-out to fullscreen vault tab (replaces ⤴)
|
||||
```
|
||||
|
||||
Remove the inline `⤴` from `extension/src/popup/components/item-list.ts:69` and replace with `GLYPH_VAULT_TAB`.
|
||||
|
||||
### A5. Item row type icons
|
||||
|
||||
The popup item list (`buildRowsHtml` in `item-list.ts`) currently renders title-only rows with no visual type anchor. Add a per-type glyph to each row using the item's `ManifestEntry.type` field:
|
||||
|
||||
| Type | Glyph |
|
||||
|------|-------|
|
||||
| login | `◉` |
|
||||
| secure_note | `◫` |
|
||||
| totp | `⊡` |
|
||||
| card | `▭` |
|
||||
| identity | `⌬` |
|
||||
| key | `⊹` |
|
||||
| document | `≡` |
|
||||
|
||||
Icon: 26×26px, rounded, `--bg-elevated` fill, gold-tinted border on active row.
|
||||
|
||||
**Files affected:** `extension/src/popup/components/item-list.ts`, `extension/src/popup/styles.css`
|
||||
|
||||
### A6. Empty states
|
||||
|
||||
Two surfaces:
|
||||
|
||||
1. **Popup item list, vault empty** — centered message: glyph `◈` (28px dim), "No items yet", "Press `+` to add your first item."
|
||||
2. **Popup item list, search returns nothing** — centered message: glyph `⊘` (28px dim), "No results for "{query}"", "Try a shorter search term."
|
||||
3. **Fullscreen list pane, section empty** — same treatment scaled for the wider pane.
|
||||
|
||||
**Files affected:** `extension/src/popup/components/item-list.ts`, `extension/src/vault/vault.ts`
|
||||
|
||||
### A7. Toast notification system
|
||||
|
||||
Replace the current ad-hoc `sync-status` div with a shared toast system:
|
||||
|
||||
- `showToast(message: string, type: 'success' | 'error' | 'info', durationMs = 2500)` in `extension/src/shared/toast.ts`.
|
||||
- Toasts appear bottom-center of the popup / bottom-right of the vault tab, auto-dismiss.
|
||||
- Used for: sync success/failure, copy-to-clipboard confirmation, device registration success.
|
||||
|
||||
**Files affected:** new `extension/src/shared/toast.ts`, `extension/src/popup/styles.css`, `extension/src/vault/vault.css`, call sites in `item-list.ts` and `vault.ts`
|
||||
|
||||
---
|
||||
|
||||
## Stream B — Settings UX Redesign
|
||||
|
||||
### B1. Settings page structure
|
||||
|
||||
Replace the current flat settings dump (`settings.ts` + `settings-vault.ts`) with a unified settings page that renders within the fullscreen vault tab's main pane (and a compact equivalent in the popup).
|
||||
|
||||
**Left-nav sections:**
|
||||
|
||||
```
|
||||
Device
|
||||
⊙ Autofill
|
||||
◈ Display
|
||||
Vault
|
||||
◉ Security ← Recovery QR + trusted devices (replaces devices.ts nav)
|
||||
↻ Generator
|
||||
▦ Retention
|
||||
⤓ Backup
|
||||
≡ Import
|
||||
```
|
||||
|
||||
Each section renders its content in the right panel. The left nav is 148px; content area fills the remainder.
|
||||
|
||||
**Device vs Vault distinction:**
|
||||
- "Device" sections read/write `chrome.storage.local` (per-browser settings).
|
||||
- "Vault" sections read/write encrypted `VaultSettings` (shared across devices via git).
|
||||
|
||||
**Files affected:**
|
||||
- `extension/src/popup/components/settings.ts` — rewrite as sectioned layout
|
||||
- `extension/src/popup/components/settings-vault.ts` — content moves into new section components
|
||||
|
||||
**Note on vault.ts:** DEV-B delivers the settings component with a stable export signature. The `⚙ settings` nav wiring in `vault.ts` is updated as part of Stream A's vault.ts rewrite. DEV-A and DEV-B must agree on the component's export signature before either lands.
|
||||
|
||||
### B2. Autofill section (Device)
|
||||
|
||||
Content replaces the current flat settings dump:
|
||||
|
||||
- **Capture** group: "Auto-detect logins" toggle (was checkbox); "Prompt style" select (bar / toast).
|
||||
- **Blocked sites** group: list of blacklisted hostnames, each with a remove button. Add-hostname input at bottom.
|
||||
|
||||
All options use the standardised `setting-row` pattern: left (title + description), right (control).
|
||||
|
||||
### B3. Display section (Device)
|
||||
|
||||
Moves the existing password-coloring UI (digit color picker, symbol color picker, live swatch, reset) from its current location into a proper Display section card.
|
||||
|
||||
### B4. Security section (Vault)
|
||||
|
||||
**Recovery QR card** (three states, see Stream C for implementation):
|
||||
|
||||
- **State 1 — no QR:** amber warning ("▲ No recovery QR generated — losing your reference image would make this vault unrecoverable"), single "Generate recovery QR…" button.
|
||||
- **State 2 — QR exists, at rest:** green status ("◉ Recovery QR is set up"), last-generated date. Buttons: "Show / print QR…" and "Regenerate…". **No QR is visible in this state.**
|
||||
- **State 3 — explicit view:** modal overlay (scrim over main pane only). QR rendered at ~140×140px. Warning: "▲ Close this window before stepping away. This QR is only displayed, never saved." Actions: "⎙ Print" (triggers `window.print()` scoped to modal) and "Done" (dismisses).
|
||||
|
||||
**Trusted devices** group: subsumes the current `⌬ devices` sidebar nav entry. Each registered device shows name, registration date, fingerprint, and a revoke button. "Register this device" entry for unregistered browsers. Once Stream B lands, the `⌬ devices` button is removed from the vault sidebar nav (settings → Security replaces it).
|
||||
|
||||
### B5. Generator section (Vault)
|
||||
|
||||
Pulls the existing generator-defaults content from `settings-vault.ts` into the new section layout. No functional changes — just consistent styling.
|
||||
|
||||
### B6. Retention section (Vault)
|
||||
|
||||
Pulls the existing retention content (trash retention, field history retention). No functional changes.
|
||||
|
||||
### B7. Backup section (Vault)
|
||||
|
||||
Pulls the existing backup & restore section. No functional changes.
|
||||
|
||||
### B8. Import section (Vault)
|
||||
|
||||
Pulls the existing import section. No functional changes.
|
||||
|
||||
---
|
||||
|
||||
## Stream C — Recovery QR
|
||||
|
||||
### C1. Rust core — `relicario-core/src/recovery_qr.rs`
|
||||
|
||||
Per the existing spec at `docs/superpowers/specs/2026-05-01-recovery-qr-design.md`. Key implementation points:
|
||||
|
||||
**KDF input:**
|
||||
```
|
||||
b"relicario-recovery-v1\0" || u64_be(len(nfc(passphrase))) || nfc(passphrase)
|
||||
```
|
||||
Fed to Argon2id with production params (`m=64MiB, t=3, p=4`), fresh 32-byte salt per generation.
|
||||
|
||||
**Wrap:** `XChaCha20-Poly1305(wrap_key, nonce=OsRng(24), image_secret)` — 32+16=48 bytes ciphertext.
|
||||
|
||||
**Binary payload (109 bytes):**
|
||||
```
|
||||
[magic "RREC" 4B][version 0x01 1B][salt 32B][nonce 24B][ciphertext 48B]
|
||||
```
|
||||
|
||||
**QR encoding:** byte mode, error-correction M, version 6 (41×41 modules). Library: `qrcode` crate (already in workspace or add it).
|
||||
|
||||
**API surface:**
|
||||
```rust
|
||||
pub struct RecoveryQrPayload { /* opaque */ }
|
||||
|
||||
pub fn generate_recovery_qr(
|
||||
passphrase: &str,
|
||||
image_secret: &[u8; 32],
|
||||
) -> Result<RecoveryQrPayload, RelicarioError>;
|
||||
|
||||
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String;
|
||||
|
||||
pub fn unwrap_recovery_qr(
|
||||
payload_bytes: &[u8],
|
||||
passphrase: &str,
|
||||
) -> Result<Zeroizing<[u8; 32]>, RelicarioError>;
|
||||
```
|
||||
|
||||
The payload bytes are never written to disk by this module — callers are responsible for rendering only.
|
||||
|
||||
**Passphrase entropy floor:** enforce `zxcvbn score ≥ 3` at vault init in the CLI and the setup wizard (already gated in the extension by 1C-α; confirm CLI `create` command applies the same gate).
|
||||
|
||||
**Files affected:**
|
||||
- `crates/relicario-core/src/recovery_qr.rs` — new module
|
||||
- `crates/relicario-core/src/lib.rs` — pub mod recovery_qr
|
||||
- `crates/relicario-core/src/error.rs` — add `RecoveryQr` error variants if needed
|
||||
- `crates/relicario-core/Cargo.toml` — add `qrcode` crate
|
||||
- `crates/relicario-core/tests/` — new `recovery_qr.rs` test file
|
||||
|
||||
### C2. CLI — `relicario recovery-qr` subcommand group
|
||||
|
||||
```
|
||||
relicario recovery-qr generate # prompts passphrase, renders QR to terminal (kitty/iTerm2 inline protocol or ASCII fallback)
|
||||
relicario recovery-qr unwrap # prompts passphrase, prints image_secret as hex
|
||||
```
|
||||
|
||||
`generate` never writes a file. It renders the QR inline in the terminal using the Kitty graphics protocol if `$TERM` indicates support, falling back to ASCII art via the `qrcode` crate's built-in ASCII renderer.
|
||||
|
||||
**Files affected:** `crates/relicario-cli/src/main.rs`
|
||||
|
||||
### C3. WASM bindings
|
||||
|
||||
```ts
|
||||
// relicario-wasm/src/lib.rs
|
||||
generate_recovery_qr(passphrase: &str, image_secret: &[u8]) -> Result<String, JsValue> // returns SVG string
|
||||
unwrap_recovery_qr(payload_b64: &str, passphrase: &str) -> Result<Vec<u8>, JsValue> // returns image_secret bytes
|
||||
```
|
||||
|
||||
**Files affected:** `crates/relicario-wasm/src/lib.rs`, `crates/relicario-wasm/Cargo.toml`
|
||||
|
||||
### C4. Extension — Recovery QR in Security settings
|
||||
|
||||
Implement the three-state Security section card described in B4:
|
||||
|
||||
- State determined by `chrome.storage.local.recovery_qr_generated_at` (timestamp or null).
|
||||
- "Generate recovery QR…" button: calls WASM `generate_recovery_qr(passphrase, image_secret)` → stores `recovery_qr_generated_at = Date.now()` in local storage → transitions to State 3 (show modal with SVG).
|
||||
- "Show / print QR…" button: re-derives QR (requires vault to be unlocked, master key in session) → shows State 3 modal.
|
||||
- "Regenerate…" button: same as generate, with a confirmation step first.
|
||||
- Print: injects SVG into a `<iframe>` styled for print, calls `iframe.contentWindow.print()`.
|
||||
|
||||
**Files affected:**
|
||||
- New `extension/src/popup/components/settings-security.ts`
|
||||
- `extension/src/popup/components/settings.ts` — wire Security section
|
||||
|
||||
### C5. Extension — Recovery QR in setup wizard (Step 5 "Done")
|
||||
|
||||
The wizard's final step adds a **skippable banner** above the "Download reference image" button:
|
||||
|
||||
```
|
||||
◫ Generate a recovery QR before you go
|
||||
If you lose your reference image, this QR lets you recover your vault.
|
||||
[Generate now] [Skip — I'll do this in Settings]
|
||||
```
|
||||
|
||||
- "Generate now": calls WASM → shows QR modal inline on the wizard page. After dismissing, banner becomes green "◉ Recovery QR generated".
|
||||
- "Skip": dismisses banner permanently for this session; user can generate later from Settings → Security.
|
||||
- The banner is informational, not a blocker. Vault is fully usable without a recovery QR.
|
||||
|
||||
**Files affected:** `extension/src/setup/setup.ts`
|
||||
|
||||
### C6. Setup wizard redesign (Style C)
|
||||
|
||||
Redesign the setup wizard from the current single-column glass-card layout to **Style C (centered hero card)**:
|
||||
|
||||
- Full-page dark background (`--bg-page`).
|
||||
- Relicario logo glyph + wordmark centered at top.
|
||||
- **Colored progress track**: 5 segments, `--success` fill for completed, `--gold` for current, `--border` for pending.
|
||||
- Centered card (max-width 560px): step eyebrow label ("Step N of 5 · <step name>"), h2 heading, hint text, form content, action row.
|
||||
- **Glyphs not emoji** throughout. Mode cards use `◈` (create new) and `⌥` (attach). Mode-card glyphs at 28px. All other icons from the existing glyph set.
|
||||
- Probe-banner success state uses `◉` (filled circle, matches ⊙/⊘ family).
|
||||
- Action row: "◂ back" text button (left), "Continue ▸" primary button (right).
|
||||
|
||||
This is a pure CSS/markup change — no logic changes.
|
||||
|
||||
**Files affected:** `extension/src/setup/setup.ts`, setup CSS (inline or extracted)
|
||||
|
||||
---
|
||||
|
||||
## Responsive behaviour
|
||||
|
||||
| Viewport | Fullscreen behaviour |
|
||||
|---|---|
|
||||
| ≥ 960px | 3-column: sidebar + list + drawer |
|
||||
| 720–960px | 2-column: sidebar + list; drawer pushes full-pane on click |
|
||||
| ≤ 720px | Sidebar collapses (hamburger/icon strip); list full-width; detail is full-page push |
|
||||
|
||||
The popup is always narrow (~340px) — popup-specific components are unaffected by the fullscreen responsive rules.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria (shared)
|
||||
|
||||
- `cargo test` green. `bun run test` green. `bun run build` + `bun run build:firefox` clean.
|
||||
- No raw `snake_case` error codes in any UI surface.
|
||||
- No emoji in any UI surface — all icons are Unicode monochrome glyphs.
|
||||
- `glyphs.ts` is the single source of truth for all icon constants; no inline Unicode literals at call sites.
|
||||
- QR code is never written to any file, `chrome.storage`, or git. `recovery_qr_generated_at` (timestamp only) is the only persisted artifact.
|
||||
- Settings left-nav sections all render without console errors. Device sections read/write `chrome.storage.local`. Vault sections read/write `VaultSettings`.
|
||||
|
||||
---
|
||||
|
||||
## Stream split summary (for multi-agent kickoff)
|
||||
|
||||
| Stream | Owner | Core files | Dependency |
|
||||
|---|---|---|---|
|
||||
| A — Fullscreen + popup layout | DEV-A | `vault.ts`, `vault.css`, `item-list.ts`, `glyphs.ts` | none |
|
||||
| B — Settings UX | DEV-B | `settings.ts`, `settings-vault.ts`, new `settings-security.ts` | waits for C4 interface (can stub) |
|
||||
| C — Recovery QR | DEV-C | `recovery_qr.rs`, `relicario-wasm/src/lib.rs`, `setup.ts`, `settings-security.ts` | none |
|
||||
|
||||
B and C share `settings-security.ts` — DEV-C owns the file, DEV-B wires it into the nav. Coordinate on interface (component export signature) before DEV-B proceeds with B4.
|
||||
329
extension/src/popup/components/settings-security.ts
Normal file
329
extension/src/popup/components/settings-security.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/// Security settings section — three-state Recovery QR + Trusted Devices panel.
|
||||
///
|
||||
/// Exported contract:
|
||||
/// renderSecuritySection(container, sessionHandle): renders into `container`
|
||||
/// teardownSecuritySection(): removes any open QR modal
|
||||
|
||||
import { sendMessage, escapeHtml } from '../../shared/state';
|
||||
import type { Device } from '../../shared/types';
|
||||
|
||||
// --- Relative time helper ---
|
||||
|
||||
function relativeTime(unixSec: number): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = now - unixSec;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
|
||||
return `${Math.floor(diff / 2592000)}mo ago`;
|
||||
}
|
||||
|
||||
// --- Modal helpers ---
|
||||
|
||||
const MODAL_ID = 'relicario-qr-modal';
|
||||
|
||||
function removeModal(): void {
|
||||
document.getElementById(MODAL_ID)?.remove();
|
||||
}
|
||||
|
||||
function showQrModal(svgContent: string): void {
|
||||
removeModal();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = MODAL_ID;
|
||||
overlay.style.cssText = [
|
||||
'position:fixed', 'inset:0', 'z-index:9999',
|
||||
'background:rgba(0,0,0,0.85)',
|
||||
'display:flex', 'flex-direction:column',
|
||||
'align-items:center', 'justify-content:center',
|
||||
'padding:16px', 'box-sizing:border-box',
|
||||
].join(';');
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div style="
|
||||
background:#161b22; border:1px solid #30363d; border-radius:8px;
|
||||
padding:16px; max-width:340px; width:100%; text-align:center;
|
||||
">
|
||||
<div style="font-size:13px; font-weight:600; margin-bottom:8px; color:#e6edf3;">
|
||||
Recovery QR
|
||||
</div>
|
||||
<div style="font-size:11px; color:#8b949e; margin-bottom:12px;">
|
||||
Print or store this QR. It encodes your reference image secret,
|
||||
protected by your passphrase.
|
||||
</div>
|
||||
<div id="relicario-qr-svg" style="
|
||||
background:#fff; border-radius:4px; padding:8px;
|
||||
display:inline-block; max-width:280px; width:100%;
|
||||
">
|
||||
${svgContent}
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-top:12px; justify-content:center;">
|
||||
<button id="relicario-qr-print" class="btn btn-primary" style="font-size:12px;">
|
||||
Print
|
||||
</button>
|
||||
<button id="relicario-qr-done" class="btn" style="font-size:12px;">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('relicario-qr-done')?.addEventListener('click', removeModal);
|
||||
|
||||
document.getElementById('relicario-qr-print')?.addEventListener('click', () => {
|
||||
const win = window.open('', '_blank', 'width=400,height=500');
|
||||
if (!win) return;
|
||||
win.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html><head><title>Recovery QR</title>
|
||||
<style>
|
||||
body { margin: 0; display: flex; flex-direction: column; align-items: center;
|
||||
font-family: sans-serif; padding: 24px; }
|
||||
h2 { font-size: 16px; margin-bottom: 8px; }
|
||||
p { font-size: 12px; color: #555; margin-bottom: 16px; text-align: center; }
|
||||
svg { max-width: 280px; width: 100%; }
|
||||
</style></head><body>
|
||||
<h2>Relicario Recovery QR</h2>
|
||||
<p>Scan with the Relicario app to recover your reference image secret.<br>
|
||||
Keep this page in a safe physical location.</p>
|
||||
${svgContent}
|
||||
<script>window.onload = () => { window.print(); window.close(); }<\/script>
|
||||
</body></html>
|
||||
`);
|
||||
win.document.close();
|
||||
});
|
||||
|
||||
// Close on backdrop click
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) removeModal();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Main render ---
|
||||
|
||||
export async function renderSecuritySection(
|
||||
container: HTMLElement,
|
||||
sessionHandle: number | null,
|
||||
): Promise<void> {
|
||||
// Read timestamp from device-local storage (never the QR payload itself)
|
||||
const stored = await chrome.storage.local.get(['recovery_qr_generated_at']);
|
||||
const generatedAt: number | null = (stored.recovery_qr_generated_at as number) ?? null;
|
||||
|
||||
const isUnlocked = sessionHandle !== null;
|
||||
|
||||
// --- QR status section ---
|
||||
let qrStatusHtml: string;
|
||||
if (generatedAt === null) {
|
||||
qrStatusHtml = `
|
||||
<div style="
|
||||
display:flex; align-items:flex-start; gap:10px;
|
||||
background:#2d1f00; border:1px solid #7c5719; border-radius:6px;
|
||||
padding:10px; margin-bottom:12px;
|
||||
">
|
||||
<span style="font-size:16px;">⚠</span>
|
||||
<div style="flex:1; font-size:12px;">
|
||||
<div style="color:#e3a726; font-weight:600; margin-bottom:2px;">
|
||||
No recovery QR generated
|
||||
</div>
|
||||
<div style="color:#8b949e;">
|
||||
If you lose access to your reference image, you will be locked out permanently.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
id="sec-generate-qr"
|
||||
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
|
||||
style="width:100%; font-size:12px; margin-bottom:4px;"
|
||||
>
|
||||
Generate recovery QR…
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
qrStatusHtml = `
|
||||
<div style="
|
||||
display:flex; align-items:flex-start; gap:10px;
|
||||
background:#0a2a1a; border:1px solid #238636; border-radius:6px;
|
||||
padding:10px; margin-bottom:12px;
|
||||
">
|
||||
<span style="font-size:16px;">✓</span>
|
||||
<div style="flex:1; font-size:12px;">
|
||||
<div style="color:#3fb950; font-weight:600; margin-bottom:2px;">
|
||||
Recovery QR set up
|
||||
</div>
|
||||
<div style="color:#8b949e;">
|
||||
Generated ${relativeTime(generatedAt)}. Store the printout in a safe place.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-bottom:4px;">
|
||||
<button
|
||||
class="btn"
|
||||
id="sec-show-qr"
|
||||
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
|
||||
style="flex:1; font-size:12px;"
|
||||
>
|
||||
Show / print QR…
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
id="sec-regenerate-qr"
|
||||
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
|
||||
style="flex:1; font-size:12px;"
|
||||
>
|
||||
Regenerate…
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Devices section ---
|
||||
const devicesResp = await sendMessage({ type: 'list_devices' });
|
||||
let devicesHtml: string;
|
||||
if (!devicesResp.ok) {
|
||||
devicesHtml = `<p class="muted" style="font-size:12px;">Could not load devices.</p>`;
|
||||
} else {
|
||||
const devices = (devicesResp.data as { devices: Device[] }).devices;
|
||||
const currentDeviceNameStored = await chrome.storage.local.get(['device_name']);
|
||||
const currentDeviceName: string | undefined = currentDeviceNameStored.device_name as string | undefined;
|
||||
|
||||
if (devices.length === 0) {
|
||||
devicesHtml = `<p class="muted" style="font-size:12px; text-align:center; margin-top:8px;">No devices registered.</p>`;
|
||||
} else {
|
||||
devicesHtml = devices.map((d) => {
|
||||
const isCurrent = d.name === currentDeviceName;
|
||||
return `
|
||||
<div class="device-row" style="display:flex; align-items:center; justify-content:space-between; padding:6px 0; border-bottom:1px solid #21262d;">
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div style="font-size:12px; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||
${escapeHtml(d.name)}${isCurrent ? ' <span style="color:#8b949e; font-weight:400; font-size:11px;">(this device)</span>' : ''}
|
||||
</div>
|
||||
<div style="font-size:11px; color:#8b949e;">added ${relativeTime(d.added_at)}</div>
|
||||
</div>
|
||||
${isCurrent ? '' : `
|
||||
<button
|
||||
class="btn sec-revoke-btn"
|
||||
data-device-name="${escapeHtml(d.name)}"
|
||||
style="font-size:11px; margin-left:8px; flex-shrink:0;"
|
||||
>revoke</button>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Assemble ---
|
||||
container.innerHTML = `
|
||||
<div class="settings-section" style="margin-top:0;">
|
||||
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
|
||||
Recovery QR
|
||||
</div>
|
||||
${qrStatusHtml}
|
||||
<div id="sec-qr-error" style="font-size:11px; color:#f85149; margin-top:4px; min-height:14px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:16px;">
|
||||
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
|
||||
Trusted Devices
|
||||
</div>
|
||||
<div id="sec-devices-list">
|
||||
${devicesHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- Wire handlers ---
|
||||
|
||||
const setQrError = (msg: string): void => {
|
||||
const el = document.getElementById('sec-qr-error');
|
||||
if (el) el.textContent = msg;
|
||||
};
|
||||
|
||||
async function doGenerateQr(isRegen: boolean): Promise<void> {
|
||||
const passphrase = prompt(
|
||||
isRegen
|
||||
? 'Enter your vault passphrase to regenerate the recovery QR:'
|
||||
: 'Enter your vault passphrase to generate the recovery QR:',
|
||||
);
|
||||
if (!passphrase) return;
|
||||
|
||||
const btn = document.getElementById(isRegen ? 'sec-regenerate-qr' : 'sec-generate-qr') as HTMLButtonElement | null;
|
||||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||||
|
||||
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
|
||||
if (!resp.ok) {
|
||||
setQrError(`Failed: ${resp.error}`);
|
||||
if (btn) { btn.disabled = false; btn.textContent = isRegen ? 'Regenerate…' : 'Generate recovery QR…'; }
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = (resp.data as { svg: string }).svg;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Store only the timestamp, NEVER the QR payload
|
||||
await chrome.storage.local.set({ recovery_qr_generated_at: now });
|
||||
|
||||
showQrModal(svg);
|
||||
|
||||
// Re-render to reflect new state (timestamp now exists)
|
||||
await renderSecuritySection(container, sessionHandle);
|
||||
}
|
||||
|
||||
document.getElementById('sec-generate-qr')?.addEventListener('click', () => {
|
||||
void doGenerateQr(false);
|
||||
});
|
||||
|
||||
document.getElementById('sec-regenerate-qr')?.addEventListener('click', () => {
|
||||
void doGenerateQr(true);
|
||||
});
|
||||
|
||||
document.getElementById('sec-show-qr')?.addEventListener('click', async () => {
|
||||
const passphrase = prompt('Enter your vault passphrase to view the recovery QR:');
|
||||
if (!passphrase) return;
|
||||
|
||||
const btn = document.getElementById('sec-show-qr') as HTMLButtonElement | null;
|
||||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||||
|
||||
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
|
||||
if (!resp.ok) {
|
||||
setQrError(`Failed: ${resp.error}`);
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
|
||||
const svg = (resp.data as { svg: string }).svg;
|
||||
showQrModal(svg);
|
||||
});
|
||||
|
||||
// Revoke buttons
|
||||
container.querySelectorAll<HTMLButtonElement>('.sec-revoke-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const name = btn.dataset.deviceName;
|
||||
if (!name) return;
|
||||
if (!confirm(`Revoke "${name}"? This device will no longer be authorized.`)) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
|
||||
const result = await sendMessage({ type: 'revoke_device', name });
|
||||
if (result.ok) {
|
||||
await sendMessage({ type: 'sync' });
|
||||
// Re-render to refresh device list
|
||||
await renderSecuritySection(container, sessionHandle);
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'revoke';
|
||||
setQrError(`Revoke failed: ${result.error}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function teardownSecuritySection(): void {
|
||||
removeModal();
|
||||
}
|
||||
@@ -424,6 +424,41 @@ textarea {
|
||||
background: #aa812a;
|
||||
}
|
||||
|
||||
/* Setup wizard — Style C progress track */
|
||||
.setup-progress-track {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
margin: 8px auto 16px;
|
||||
}
|
||||
.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 wizard — Recovery QR banner */
|
||||
.recovery-qr-banner {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-elevated, #161b22);
|
||||
border: 1px solid var(--gold, #b8860b);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.recovery-qr-banner__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.recovery-qr-banner__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
|
||||
@@ -574,6 +574,26 @@ export async function handle(
|
||||
}
|
||||
}
|
||||
|
||||
case 'generate_recovery_qr': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle) return { ok: false, error: 'vault_locked' };
|
||||
try {
|
||||
const svg: string = state.wasm.wasm_generate_recovery_qr(handle, msg.passphrase);
|
||||
return { ok: true, data: { svg } };
|
||||
} catch (e) {
|
||||
return { ok: false, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'unwrap_recovery_qr': {
|
||||
try {
|
||||
const imageSecretBytes: Uint8Array = state.wasm.wasm_unwrap_recovery_qr(msg.payload_b64, msg.passphrase);
|
||||
return { ok: true, data: { image_secret: Array.from(imageSecretBytes) } };
|
||||
} catch (e) {
|
||||
return { ok: false, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'import_lastpass_commit': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
|
||||
@@ -93,6 +93,17 @@ const state: WizardState = {
|
||||
deviceName: '',
|
||||
};
|
||||
|
||||
// --- Progress track ---
|
||||
|
||||
const SETUP_STEP_NAMES = ['mode', 'host', 'connection', 'vault', 'device', 'done'];
|
||||
|
||||
function renderProgressTrack(current: number): string {
|
||||
return `<div class="setup-progress-track">${SETUP_STEP_NAMES.map((_, i) => {
|
||||
const cls = i < current ? 'completed' : i === current ? 'active' : 'pending';
|
||||
return `<div class="setup-progress-segment setup-progress-segment--${cls}" title="${SETUP_STEP_NAMES[i]}"></div>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) ---
|
||||
|
||||
/// Update just the meter DOM without a full re-render (so the input keeps
|
||||
@@ -168,16 +179,7 @@ function render(): void {
|
||||
const app = document.getElementById('app');
|
||||
if (!app) return;
|
||||
|
||||
const progressHtml = `
|
||||
<div class="progress-bar">
|
||||
<div class="step ${state.step > 0 ? 'done' : state.step === 0 ? 'current' : ''}"></div>
|
||||
<div class="step ${state.step > 1 ? 'done' : state.step === 1 ? 'current' : ''}"></div>
|
||||
<div class="step ${state.step > 2 ? 'done' : state.step === 2 ? 'current' : ''}"></div>
|
||||
<div class="step ${state.step > 3 ? 'done' : state.step === 3 ? 'current' : ''}"></div>
|
||||
<div class="step ${state.step > 4 ? 'done' : state.step === 4 ? 'current' : ''}"></div>
|
||||
<div class="step ${state.step > 5 ? 'done' : state.step === 5 ? 'current' : ''}"></div>
|
||||
</div>
|
||||
`;
|
||||
const progressHtml = renderProgressTrack(state.step);
|
||||
|
||||
let stepHtml = '';
|
||||
switch (state.step) {
|
||||
@@ -224,6 +226,7 @@ function renderStep0(): string {
|
||||
</p>
|
||||
<div class="mode-cards">
|
||||
<button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
|
||||
<span class="mode-card__icon" style="font-size:28px;">◈</span>
|
||||
<div class="mode-card-title">create new vault</div>
|
||||
<p class="mode-card-blurb">
|
||||
I'm setting up Relicario for the first time. This will create a fresh
|
||||
@@ -231,6 +234,7 @@ function renderStep0(): string {
|
||||
</p>
|
||||
</button>
|
||||
<button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach">
|
||||
<span class="mode-card__icon" style="font-size:28px;">⌥</span>
|
||||
<div class="mode-card-title">attach this device</div>
|
||||
<p class="mode-card-blurb">
|
||||
I already have a vault on another device. Connect this browser to it
|
||||
@@ -981,6 +985,22 @@ function renderStep5(): string {
|
||||
const configJson = JSON.stringify(config, null, 2);
|
||||
const isAttach = state.mode === 'attach';
|
||||
|
||||
const qrBannerHtml = (!isAttach && state.verifiedHandle !== null) ? `
|
||||
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
|
||||
<div class="recovery-qr-banner__header">
|
||||
<span style="font-size:20px;">◫</span>
|
||||
<strong>Generate a recovery QR before you go</strong>
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px;margin:4px 0 8px;">
|
||||
If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.
|
||||
</p>
|
||||
<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>
|
||||
` : '';
|
||||
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<div class="success-box">
|
||||
@@ -992,6 +1012,8 @@ function renderStep5(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
${qrBannerHtml}
|
||||
|
||||
${isAttach ? '' : `
|
||||
<div class="form-group">
|
||||
<label class="label">reference image</label>
|
||||
@@ -1026,6 +1048,48 @@ function renderStep5(): string {
|
||||
}
|
||||
|
||||
function attachStep5(): void {
|
||||
document.getElementById('setup-gen-qr')?.addEventListener('click', async () => {
|
||||
if (!state.verifiedHandle) return;
|
||||
const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; }
|
||||
try {
|
||||
const { sendMessage } = await import('../shared/state');
|
||||
const resp = await sendMessage({
|
||||
type: 'generate_recovery_qr',
|
||||
sessionHandle: state.verifiedHandle.value,
|
||||
passphrase: state.passphrase,
|
||||
} as any) as any;
|
||||
if (!resp.ok || !resp.data) throw new Error(resp.error ?? 'unknown error');
|
||||
const svg = (resp.data as { svg: string }).svg;
|
||||
await new Promise<void>((resolve) => {
|
||||
chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve);
|
||||
});
|
||||
const banner = document.getElementById('recovery-qr-banner');
|
||||
if (banner) {
|
||||
banner.innerHTML = `
|
||||
<div style="text-align:center;">${svg}</div>
|
||||
<p style="font-size:12px;color:var(--success,#238636);margin:8px 0 0;">
|
||||
◉ Recovery QR generated — save or print this now.
|
||||
</p>
|
||||
<div style="margin-top:8px;">
|
||||
<button class="btn" id="setup-qr-done">Done</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('setup-qr-done')?.addEventListener('click', () => {
|
||||
banner.style.display = 'none';
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; }
|
||||
alert(`Failed to generate QR: ${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';
|
||||
});
|
||||
|
||||
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
|
||||
if (!state.referenceImageBytes) return;
|
||||
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
||||
|
||||
@@ -61,7 +61,9 @@ export type PopupMessage =
|
||||
}
|
||||
| { type: 'parse_lastpass_csv'; bytes: ArrayBuffer }
|
||||
| { type: 'import_lastpass_commit'; items: Item[] }
|
||||
| { type: 'preview_totp_from_secret'; secret_b32: string };
|
||||
| { type: 'preview_totp_from_secret'; secret_b32: string }
|
||||
| { type: 'generate_recovery_qr'; passphrase: string }
|
||||
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string };
|
||||
|
||||
// --- Messages a content script may send ---
|
||||
|
||||
@@ -173,6 +175,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||||
'export_backup', 'restore_backup',
|
||||
'parse_lastpass_csv', 'import_lastpass_commit',
|
||||
'preview_totp_from_secret',
|
||||
'generate_recovery_qr', 'unwrap_recovery_qr',
|
||||
] as PopupMessage['type'][]);
|
||||
|
||||
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
||||
|
||||
@@ -1290,6 +1290,13 @@ textarea {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.vault-sidebar__header .brand-logo {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vault-sidebar__search {
|
||||
padding: 8px 12px;
|
||||
|
||||
@@ -258,6 +258,7 @@ function renderShell(app: HTMLElement): void {
|
||||
app.innerHTML = `
|
||||
<div class="vault-sidebar">
|
||||
<div class="vault-sidebar__header">
|
||||
<img class="brand-logo" src="icons/relicario-logo-16.svg" alt="">
|
||||
<span class="brand">Relicario</span>
|
||||
</div>
|
||||
<div class="vault-sidebar__search">
|
||||
|
||||
3
extension/src/wasm.d.ts
vendored
3
extension/src/wasm.d.ts
vendored
@@ -79,6 +79,9 @@ declare module 'relicario-wasm' {
|
||||
export function clear_device(): void;
|
||||
export function get_field_history(item_json: string): unknown;
|
||||
|
||||
export function wasm_generate_recovery_qr(handle: SessionHandle, passphrase: string): string;
|
||||
export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array;
|
||||
|
||||
export default function init(module_or_path?: unknown): Promise<void>;
|
||||
export function initSync(args: { module: WebAssembly.Module }): void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user