From 8855078179b8280397806b27df4af0f8366197ad Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 1 May 2026 22:22:20 -0400 Subject: [PATCH] cli: --totp-qr flag on add login + edit (rqrr decode) Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 67 ++++++++++++++++++++ crates/relicario-cli/Cargo.toml | 4 +- crates/relicario-cli/src/helpers.rs | 28 +++++++++ crates/relicario-cli/src/main.rs | 63 ++++++++++++++++--- crates/relicario-cli/tests/smart_inputs.rs | 72 ++++++++++++++++++++++ 5 files changed, 224 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1ef94a..29546f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -758,6 +764,34 @@ dependencies = [ "slab", ] +[[package]] +name = "g2gen" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" +dependencies = [ + "g2poly", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "g2p" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" +dependencies = [ + "g2gen", + "g2poly", +] + +[[package]] +name = "g2poly" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" + [[package]] name = "generic-array" version = "0.14.7" @@ -833,6 +867,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1148,6 +1184,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1489,6 +1534,15 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -1620,9 +1674,11 @@ dependencies = [ "hex", "image", "predicates", + "qrcode", "rand", "relicario-core", "rpassword", + "rqrr", "serde", "serde_json", "tar", @@ -1690,6 +1746,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rqrr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0cd0432e6beb2f86aa4c8af1bb5edcf3c9bcb9d4836facc048664205458575" +dependencies = [ + "g2p", + "image", + "lru", +] + [[package]] name = "rtoolbox" version = "0.0.5" diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml index f07da01..321d03b 100644 --- a/crates/relicario-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -26,10 +26,12 @@ url = "2" data-encoding = "2" tar = { version = "0.4", default-features = false } clap_complete = "4" +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } +rqrr = "0.7" [dev-dependencies] assert_cmd = "2" predicates = "3" tempfile = "3" -image = { version = "0.25", default-features = false, features = ["jpeg"] } +qrcode = "0.14" serde_json = "1" diff --git a/crates/relicario-cli/src/helpers.rs b/crates/relicario-cli/src/helpers.rs index 2d09b5c..f794baf 100644 --- a/crates/relicario-cli/src/helpers.rs +++ b/crates/relicario-cli/src/helpers.rs @@ -115,6 +115,34 @@ pub fn write_groups_cache( std::fs::write(path, body) } +/// Decode a QR image at `path`. Returns the otpauth secret (base32) if the +/// QR decodes to an `otpauth://...` URI with a `secret` query param. +pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result { + let img = image::open(path) + .map_err(|e| anyhow::anyhow!("failed to read image: {e}"))? + .to_luma8(); + let mut prepared = rqrr::PreparedImage::prepare(img); + let grids = prepared.detect_grids(); + let grid = grids + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("no QR code found in image"))?; + let (_meta, content) = grid + .decode() + .map_err(|e| anyhow::anyhow!("QR decode failed: {e}"))?; + if !content.starts_with("otpauth://") { + return Err(anyhow::anyhow!("not a TOTP URI (expected otpauth://...)")); + } + let parsed = + url::Url::parse(&content).map_err(|e| anyhow::anyhow!("invalid otpauth URI: {e}"))?; + let secret = parsed + .query_pairs() + .find(|(k, _)| k == "secret") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("otpauth URI missing `secret` parameter"))?; + Ok(secret) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 98d4022..0d3f52c 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -66,7 +66,12 @@ enum Commands { }, /// Edit an item interactively. - Edit { query: String }, + Edit { + query: String, + /// Decode an `otpauth://` QR image to set the TOTP secret (login items only). + #[arg(long, value_name = "PATH")] + totp_qr: Option, + }, /// View captured field history for an item. Values are masked by /// default; pass `--show` to reveal them. @@ -203,6 +208,8 @@ enum AddKind { #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, #[arg(long)] favorite: bool, + /// Decode an `otpauth://` QR image to fill the TOTP secret. + #[arg(long, value_name = "PATH")] totp_qr: Option, }, SecureNote { #[arg(long)] title: Option, @@ -360,7 +367,7 @@ fn main() -> Result<()> { Commands::Add { kind } => cmd_add(kind), Commands::Get { query, show, copy } => cmd_get(query, show, copy), Commands::List { r#type, group, tag, trashed } => cmd_list(r#type, group, tag, trashed), - Commands::Edit { query } => cmd_edit(query), + Commands::Edit { query, totp_qr } => cmd_edit(query, totp_qr), Commands::History { query, show, field } => cmd_history(query, show, field), Commands::Rm { query } => cmd_rm(query), Commands::Restore { query } => cmd_restore(query), @@ -526,8 +533,8 @@ fn cmd_add(kind: AddKind) -> Result<()> { let mut manifest = vault.load_manifest()?; let item = match kind { - AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite } => - build_login_item(title, username, url, password_prompt, password, group, tags, favorite)?, + AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } => + build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?, AddKind::SecureNote { title, body_prompt, group, tags } => build_secure_note_item(title, body_prompt, group, tags)?, AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => @@ -576,8 +583,9 @@ fn build_login_item( group: Option, tags: Vec, favorite: bool, + totp_qr: Option, ) -> Result { - use relicario_core::item_types::LoginCore; + use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind}; use relicario_core::{Item, ItemCore}; use zeroize::Zeroizing; @@ -595,8 +603,21 @@ fn build_login_item( } else { None }; + let totp = if let Some(path) = totp_qr { + let secret_b32 = crate::helpers::decode_totp_qr(&path)?; + let secret_bytes = base32_decode_lenient(&secret_b32)?; + Some(TotpConfig { + secret: Zeroizing::new(secret_bytes), + algorithm: TotpAlgorithm::Sha1, + digits: 6, + period_seconds: 30, + kind: TotpKind::Totp, + }) + } else { + None + }; let mut item = Item::new(title, ItemCore::Login(LoginCore { - username, password, url: parsed_url, totp: None, + username, password, url: parsed_url, totp, })); item.group = group; item.tags = tags; @@ -904,6 +925,13 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> { ItemCore::Login(l) => { if let Some(u) = &l.username { println!("Username: {u}"); } if let Some(u) = &l.url { println!("URL: {u}"); } + if let Some(t) = &l.totp { + if show { + println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret)); + } else { + println!("TOTP: **** (use --show to reveal)"); + } + } if let Some(p) = &l.password { Some(p.clone()) } else { None } } ItemCore::SecureNote(n) => { @@ -1043,7 +1071,7 @@ fn cmd_list( } Ok(()) } -fn cmd_edit(query: String) -> Result<()> { +fn cmd_edit(query: String, totp_qr: Option) -> Result<()> { use relicario_core::time::now_unix; use relicario_core::ItemCore; @@ -1065,7 +1093,7 @@ fn cmd_edit(query: String) -> Result<()> { let history = &mut item.field_history; match &mut item.core { - ItemCore::Login(l) => edit_login(l, history)?, + ItemCore::Login(l) => edit_login(l, history, totp_qr)?, ItemCore::SecureNote(n) => edit_secure_note(n, history)?, ItemCore::Identity(i) => edit_identity(i)?, ItemCore::Card(c) => edit_card(c, history)?, @@ -1094,7 +1122,12 @@ type FieldHistory = std::collections::HashMap< Vec, >; -fn edit_login(l: &mut relicario_core::item_types::LoginCore, history: &mut FieldHistory) -> Result<()> { +fn edit_login( + l: &mut relicario_core::item_types::LoginCore, + history: &mut FieldHistory, + totp_qr: Option, +) -> Result<()> { + use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind}; use zeroize::Zeroizing; if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); } if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? { @@ -1107,6 +1140,18 @@ fn edit_login(l: &mut relicario_core::item_types::LoginCore, history: &mut Field push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string())); } } + if let Some(path) = totp_qr { + let secret_b32 = crate::helpers::decode_totp_qr(&path)?; + let secret_bytes = base32_decode_lenient(&secret_b32)?; + l.totp = Some(TotpConfig { + secret: Zeroizing::new(secret_bytes), + algorithm: TotpAlgorithm::Sha1, + digits: 6, + period_seconds: 30, + kind: TotpKind::Totp, + }); + eprintln!("TOTP secret set from QR image."); + } Ok(()) } diff --git a/crates/relicario-cli/tests/smart_inputs.rs b/crates/relicario-cli/tests/smart_inputs.rs index 5795980..2938ee9 100644 --- a/crates/relicario-cli/tests/smart_inputs.rs +++ b/crates/relicario-cli/tests/smart_inputs.rs @@ -136,3 +136,75 @@ fn rate_reads_from_stdin_when_arg_is_dash() { .success() .stdout(contains("score:")); } + +fn make_test_qr(uri: &str, dest: &std::path::Path) { + use image::{ImageBuffer, Luma}; + let code = qrcode::QrCode::new(uri).expect("QR encode failed"); + let img: ImageBuffer, Vec> = code + .render::>() + .module_dimensions(8, 8) + .build(); + img.save(dest).expect("save QR PNG"); +} + +#[test] +fn add_login_totp_qr_decodes_otpauth_uri() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + let qr_path = tmp.path().join("test.png"); + make_test_qr( + "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example", + &qr_path, + ); + + let v = common::TestVault::init(); + + let out = v.run(&[ + "add", "login", + "--title", "TotpTest", + "--password", "hunter2", + "--totp-qr", qr_path.to_str().unwrap(), + ]); + assert!(out.status.success(), "add failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr)); + + let out = v.run(&["get", "TotpTest", "--show"]); + assert!(out.status.success(), "get failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + // BASE32.encode(BASE32.decode("JBSWY3DPEHPK3PXP")) should round-trip. + // The secret bytes from JBSWY3DPEHPK3PXP decode to specific bytes, + // then re-encode to JBSWY3DPEHPK3PXP====; we check for the core chars. + assert!( + stdout.contains("JBSWY3DPEHPK3PXP"), + "expected TOTP secret in get output, got:\n{stdout}" + ); +} + +#[test] +fn add_login_totp_qr_errors_on_non_otpauth_qr() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + let qr_path = tmp.path().join("nottotp.png"); + make_test_qr("https://example.com", &qr_path); + + let v = common::TestVault::init(); + + let out = v.run(&[ + "add", "login", + "--title", "BadQR", + "--password", "hunter2", + "--totp-qr", qr_path.to_str().unwrap(), + ]); + assert!( + !out.status.success(), + "expected nonzero exit for non-otpauth QR, but command succeeded" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("not a TOTP URI"), + "expected 'not a TOTP URI' in stderr, got:\n{stderr}" + ); +}