cli: --totp-qr <path> flag on add login + edit (rqrr decode)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-01 22:22:20 -04:00
parent bd8102c9ad
commit 8855078179
5 changed files with 224 additions and 10 deletions

67
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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<String> {
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::*;

View File

@@ -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<PathBuf>,
},
/// 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<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] favorite: bool,
/// Decode an `otpauth://` QR image to fill the TOTP secret.
#[arg(long, value_name = "PATH")] totp_qr: Option<PathBuf>,
},
SecureNote {
#[arg(long)] title: Option<String>,
@@ -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<String>,
tags: Vec<String>,
favorite: bool,
totp_qr: Option<PathBuf>,
) -> Result<relicario_core::Item> {
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<PathBuf>) -> 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<relicario_core::item::FieldHistoryEntry>,
>;
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<PathBuf>,
) -> 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(())
}

View File

@@ -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<Luma<u8>, Vec<u8>> = code
.render::<Luma<u8>>()
.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}"
);
}