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(()) +}