feat(cli): relicario get with masking, --show, and zeroize-clipboard
Secrets masked by default (audit M7). --show reveals plaintext. --copy writes to clipboard and spawns a detached 30s auto-clear thread holding a Zeroizing copy that wipes on drop (audit M6).
This commit is contained in:
@@ -657,7 +657,119 @@ fn commit_paths(vault: &crate::session::UnlockedVault, message: &str, paths: &[&
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_get(_query: String, _show: bool, _copy: bool) -> Result<()> { bail!("not yet implemented"); }
|
||||
fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
use relicario_core::ItemCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
let entry = resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
println!("ID: {}", item.id.as_str());
|
||||
println!("Title: {}", item.title);
|
||||
println!("Type: {:?}", item.r#type);
|
||||
if let Some(g) = &item.group { println!("Group: {g}"); }
|
||||
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
|
||||
println!("Created: {}", crate::helpers::iso8601(item.created));
|
||||
println!("Modified: {}", crate::helpers::iso8601(item.modified));
|
||||
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
|
||||
println!();
|
||||
|
||||
let primary_secret: Option<Zeroizing<String>> = match &item.core {
|
||||
ItemCore::Login(l) => {
|
||||
if let Some(u) = &l.username { println!("Username: {u}"); }
|
||||
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||
if let Some(p) = &l.password { Some(p.clone()) } else { None }
|
||||
}
|
||||
ItemCore::SecureNote(n) => {
|
||||
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||
else { println!("Body: ********"); }
|
||||
None
|
||||
}
|
||||
ItemCore::Identity(i) => {
|
||||
if let Some(v) = &i.full_name { println!("Name: {v}"); }
|
||||
if let Some(v) = &i.email { println!("Email: {v}"); }
|
||||
if let Some(v) = &i.phone { println!("Phone: {v}"); }
|
||||
if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); }
|
||||
None
|
||||
}
|
||||
ItemCore::Card(c) => {
|
||||
if let Some(h) = &c.holder { println!("Holder: {h}"); }
|
||||
if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); }
|
||||
println!("Kind: {:?}", c.kind);
|
||||
c.number.clone()
|
||||
}
|
||||
ItemCore::Key(k) => {
|
||||
if let Some(l) = &k.label { println!("Label: {l}"); }
|
||||
if let Some(a) = &k.algorithm { println!("Algo: {a}"); }
|
||||
if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); }
|
||||
Some(k.key_material.clone())
|
||||
}
|
||||
ItemCore::Document(d) => {
|
||||
println!("Filename: {}", d.filename);
|
||||
println!("MIME: {}", d.mime_type);
|
||||
None
|
||||
}
|
||||
ItemCore::Totp(t) => {
|
||||
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
|
||||
if let Some(l) = &t.label { println!("Label: {l}"); }
|
||||
println!("Period: {}s", t.config.period_seconds);
|
||||
println!("Digits: {}", t.config.digits);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(secret) = primary_secret {
|
||||
if show {
|
||||
println!("Secret: {}", secret.as_str());
|
||||
} else {
|
||||
println!("Secret: ******** (use --show to reveal, --copy to clipboard)");
|
||||
}
|
||||
if copy {
|
||||
copy_to_clipboard_then_clear(&secret)?;
|
||||
eprintln!("Copied to clipboard (auto-clears in 30s).");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_query<'a>(
|
||||
manifest: &'a relicario_core::Manifest,
|
||||
query: &str,
|
||||
) -> Result<&'a relicario_core::ManifestEntry> {
|
||||
if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) {
|
||||
return Ok(entry);
|
||||
}
|
||||
let hits: Vec<_> = manifest.search(query);
|
||||
match hits.len() {
|
||||
0 => anyhow::bail!("no item matches `{query}`"),
|
||||
1 => Ok(hits[0]),
|
||||
_ => {
|
||||
let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
|
||||
anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing<String>) -> Result<()> {
|
||||
use arboard::Clipboard;
|
||||
let mut cb = Clipboard::new().context("failed to access clipboard")?;
|
||||
cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?;
|
||||
let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned());
|
||||
// Unconditional clear (audit M6): spawn a detached thread that waits 30s
|
||||
// and then rewrites the clipboard with empty string. Even if the user
|
||||
// copies something else in the interim, we still overwrite once.
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
if let Ok(mut cb) = Clipboard::new() {
|
||||
let _ = cb.set_text(String::new());
|
||||
drop(cleared_copy); // zeroize the detached copy
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
fn cmd_list(_t: Option<String>, _g: Option<String>, _tag: Option<String>, _trashed: bool) -> Result<()> { bail!("not yet implemented"); }
|
||||
fn cmd_edit(_query: String) -> Result<()> { bail!("not yet implemented"); }
|
||||
fn cmd_rm(_query: String) -> Result<()> { bail!("not yet implemented"); }
|
||||
|
||||
Reference in New Issue
Block a user