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:
@@ -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::*;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user