From 8739f1f67bcedc578cb376e89ea69a30af484c99 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 3 May 2026 20:48:38 -0400 Subject: [PATCH 01/11] chore(core): add qrcode dependency for recovery QR --- Cargo.lock | 1 + crates/relicario-core/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index bdd36ba..dea9169 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2198,6 +2198,7 @@ dependencies = [ "hex", "hmac", "image", + "qrcode", "rand", "serde", "serde_json", diff --git a/crates/relicario-core/Cargo.toml b/crates/relicario-core/Cargo.toml index 3e5cc50..7f49598 100644 --- a/crates/relicario-core/Cargo.toml +++ b/crates/relicario-core/Cargo.toml @@ -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 } [dev-dependencies] From 04142dc116a243ffee7ce56465739b635d30f611 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 3 May 2026 20:51:29 -0400 Subject: [PATCH 02/11] feat(core): add derive_master_key_raw + RecoveryQr error variant --- crates/relicario-core/src/crypto.rs | 17 +++++++++++++++++ crates/relicario-core/src/error.rs | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/crates/relicario-core/src/crypto.rs b/crates/relicario-core/src/crypto.rs index d28b0bb..78426b4 100644 --- a/crates/relicario-core/src/crypto.rs +++ b/crates/relicario-core/src/crypto.rs @@ -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> { + 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::*; diff --git a/crates/relicario-core/src/error.rs b/crates/relicario-core/src/error.rs index 5b3d131..076d1f3 100644 --- a/crates/relicario-core/src/error.rs +++ b/crates/relicario-core/src/error.rs @@ -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. From 8eabaf5f3123215f04e8900c4d2c4977e70c227b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 3 May 2026 20:51:33 -0400 Subject: [PATCH 03/11] feat(core): recovery_qr generate + unwrap + SVG functions --- crates/relicario-core/Cargo.toml | 2 +- crates/relicario-core/src/recovery_qr.rs | 129 +++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 crates/relicario-core/src/recovery_qr.rs diff --git a/crates/relicario-core/Cargo.toml b/crates/relicario-core/Cargo.toml index 7f49598..6b0af65 100644 --- a/crates/relicario-core/Cargo.toml +++ b/crates/relicario-core/Cargo.toml @@ -31,6 +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 } +qrcode = { version = "0.14", default-features = false, features = ["svg"] } [dev-dependencies] diff --git a/crates/relicario-core/src/recovery_qr.rs b/crates/relicario-core/src/recovery_qr.rs new file mode 100644 index 0000000..15becad --- /dev/null +++ b/crates/relicario-core/src/recovery_qr.rs @@ -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 { + let nfc: String = passphrase.nfc().collect(); + let nfc_bytes = nfc.as_bytes(); + let prefix = b"relicario-recovery-v1\0"; + let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len()); + input.extend_from_slice(prefix); + input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes()); + input.extend_from_slice(nfc_bytes); + input +} + +fn production_params() -> KdfParams { + KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 } +} + +fn derive_wrap_key( + passphrase: &str, + kdf_salt: &[u8; 32], + params: &KdfParams, +) -> Result> { + let input = recovery_kdf_input(passphrase); + crate::crypto::derive_master_key_raw(&input, kdf_salt, params) +} + +pub fn generate_recovery_qr( + passphrase: &str, + image_secret: &[u8; 32], +) -> Result { + generate_recovery_qr_with_params(passphrase, image_secret, &production_params()) +} + +#[doc(hidden)] +pub fn generate_recovery_qr_with_params( + passphrase: &str, + image_secret: &[u8; 32], + params: &KdfParams, +) -> Result { + let mut kdf_salt = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut kdf_salt); + + let mut wrap_nonce = [0u8; 24]; + rand::rngs::OsRng.fill_bytes(&mut wrap_nonce); + + let wrap_key = derive_wrap_key(passphrase, &kdf_salt, params)?; + let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref())); + let nonce = chacha20poly1305::XNonce::from_slice(&wrap_nonce); + let ciphertext = cipher.encrypt(nonce, image_secret.as_ref()) + .map_err(|_| RelicarioError::RecoveryQr("wrap encrypt failed".into()))?; + + let mut bytes = [0u8; PAYLOAD_LEN]; + let mut pos = 0; + bytes[pos..pos+4].copy_from_slice(MAGIC); pos += 4; + bytes[pos] = VERSION; pos += 1; + bytes[pos..pos+32].copy_from_slice(&kdf_salt); pos += 32; + bytes[pos..pos+24].copy_from_slice(&wrap_nonce); pos += 24; + bytes[pos..pos+48].copy_from_slice(&ciphertext); + + Ok(RecoveryQrPayload { bytes }) +} + +pub fn unwrap_recovery_qr( + payload_bytes: &[u8], + passphrase: &str, +) -> Result> { + unwrap_recovery_qr_with_params(payload_bytes, passphrase, &production_params()) +} + +#[doc(hidden)] +pub fn unwrap_recovery_qr_with_params( + payload_bytes: &[u8], + passphrase: &str, + params: &KdfParams, +) -> Result> { + if payload_bytes.len() != PAYLOAD_LEN { + return Err(RelicarioError::RecoveryQr( + format!("payload must be {PAYLOAD_LEN} bytes, got {}", payload_bytes.len()) + )); + } + if &payload_bytes[0..4] != MAGIC { + return Err(RelicarioError::RecoveryQr("bad magic".into())); + } + if payload_bytes[4] != VERSION { + return Err(RelicarioError::RecoveryQr( + format!("unsupported version 0x{:02x}", payload_bytes[4]) + )); + } + let kdf_salt: &[u8; 32] = payload_bytes[5..37].try_into().unwrap(); + let wrap_nonce = &payload_bytes[37..61]; + let ciphertext = &payload_bytes[61..109]; + + let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?; + let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref())); + let nonce = chacha20poly1305::XNonce::from_slice(wrap_nonce); + let plaintext = cipher.decrypt(nonce, ciphertext) + .map_err(|_| RelicarioError::Decrypt)?; + + let mut out = Zeroizing::new([0u8; 32]); + out.copy_from_slice(&plaintext); + Ok(out) +} + +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"); + code.render::() + .min_dimensions(140, 140) + .build() +} From f93bce73887f428e059d3b05c9f81919819fdcaa Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 3 May 2026 20:51:36 -0400 Subject: [PATCH 04/11] chore(core): re-export recovery_qr module --- crates/relicario-core/src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index e7b49a1..0d397c6 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -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, +}; From 762a008171cda6362c3a75d7ca57f7265e42e5e0 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 3 May 2026 20:53:59 -0400 Subject: [PATCH 05/11] test(core): recovery_qr roundtrip + error cases --- crates/relicario-core/tests/recovery_qr.rs | 60 ++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 crates/relicario-core/tests/recovery_qr.rs diff --git a/crates/relicario-core/tests/recovery_qr.rs b/crates/relicario-core/tests/recovery_qr.rs new file mode 100644 index 0000000..e1ec7ed --- /dev/null +++ b/crates/relicario-core/tests/recovery_qr.rs @@ -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(" Date: Sun, 3 May 2026 20:56:39 -0400 Subject: [PATCH 06/11] feat(wasm): session stores image_secret for recovery QR generation --- crates/relicario-wasm/src/lib.rs | 10 ++++++---- crates/relicario-wasm/src/session.rs | 24 ++++++++++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index ff1bed6..7ef8569 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -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)) } @@ -492,7 +494,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 +503,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 +515,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(); diff --git a/crates/relicario-wasm/src/session.rs b/crates/relicario-wasm/src/session.rs index 6b553ad..ae084ba 100644 --- a/crates/relicario-wasm/src/session.rs +++ b/crates/relicario-wasm/src/session.rs @@ -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>> = RefCell::new(HashMap::new()); + static SESSIONS: RefCell> = RefCell::new(HashMap::new()); static NEXT_HANDLE: RefCell = 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(handle: u32, f: F) -> Option 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(handle: u32, f: F) -> Option +where + F: FnOnce(&Zeroizing<[u8; 32]>) -> R, +{ + SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret))) } pub fn remove(handle: u32) -> bool { From ada00895d44c9b0876d9d3cb1ba53a0b477f0671 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 3 May 2026 20:57:55 -0400 Subject: [PATCH 07/11] feat(wasm): expose generate_recovery_qr and unwrap_recovery_qr bindings --- crates/relicario-wasm/src/lib.rs | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 7ef8569..c23c1fa 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -486,6 +486,42 @@ pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result { 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 { + let image_secret_bytes = session::with_image_secret(handle.0, |s| s.to_vec()) + .ok_or_else(|| JsError::new("invalid or locked session handle"))?; + let image_secret: &[u8; 32] = image_secret_bytes.as_slice().try_into() + .map_err(|_| JsError::new("image_secret must be 32 bytes"))?; + let payload = generate_recovery_qr(passphrase, image_secret) + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(recovery_qr_to_svg(&payload)) +} + +/// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase. +/// Returns the raw image_secret bytes (32 bytes). +#[wasm_bindgen] +pub fn wasm_unwrap_recovery_qr( + payload_b64: &str, + passphrase: &str, +) -> Result, JsError> { + use base64::{engine::general_purpose::STANDARD, Engine}; + let bytes = STANDARD.decode(payload_b64) + .map_err(|e| JsError::new(&format!("base64: {e}")))?; + let recovered = unwrap_recovery_qr(&bytes, passphrase) + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(recovered.to_vec()) +} + #[cfg(test)] mod session_tests { use super::*; From a6071b4c0c3d13a9c1776933456143576ff52d9f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 3 May 2026 21:01:29 -0400 Subject: [PATCH 08/11] feat(cli): recovery-qr generate / unwrap subcommands Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-cli/Cargo.toml | 2 +- crates/relicario-cli/src/main.rs | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml index 4ecdb20..7cafeea 100644 --- a/crates/relicario-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -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" diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 6cb1d05..1ebf32f 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -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::() + .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(()) +} From 4851857070ca5a796521f0f98b43091566c9f22f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 3 May 2026 21:06:43 -0400 Subject: [PATCH 09/11] feat(ext/settings): settings-security.ts three-state recovery QR + devices component - Add settings-security.ts with renderSecuritySection / teardownSecuritySection - Three states: amber warning (no QR), green status (QR set up), modal overlay (show/print SVG) - Device list with inline revoke; passphrase collected via prompt() - QR payload never written to chrome.storage; only recovery_qr_generated_at timestamp stored - Add generate_recovery_qr / unwrap_recovery_qr message types to messages.ts + POPUP_ONLY_TYPES - Add SW handlers in popup-only.ts delegating to wasm_generate_recovery_qr / wasm_unwrap_recovery_qr - Declare wasm_generate_recovery_qr and wasm_unwrap_recovery_qr in wasm.d.ts Co-Authored-By: Claude Sonnet 4.6 --- .../src/popup/components/settings-security.ts | 329 ++++++++++++++++++ .../src/service-worker/router/popup-only.ts | 20 ++ extension/src/shared/messages.ts | 5 +- extension/src/wasm.d.ts | 3 + 4 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 extension/src/popup/components/settings-security.ts diff --git a/extension/src/popup/components/settings-security.ts b/extension/src/popup/components/settings-security.ts new file mode 100644 index 0000000..71926be --- /dev/null +++ b/extension/src/popup/components/settings-security.ts @@ -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 = ` +
+
+ Recovery QR +
+
+ Print or store this QR. It encodes your reference image secret, + protected by your passphrase. +
+
+ ${svgContent} +
+
+ + +
+
+ `; + + 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(` + + Recovery QR + +

Relicario Recovery QR

+

Scan with the Relicario app to recover your reference image secret.
+ Keep this page in a safe physical location.

+ ${svgContent} +