- edit_secure_note / edit_key now call the module's resolve_secret_multiline instead of open-coding the eprintln-hint + read-to-EOF pattern (the helper exists precisely to centralize this; build_secure_note/build_key already use it). - drop redundant fn-local imports: `use zeroize::Zeroizing;` from the five edit_* helpers and the re-imported `TotpAlgorithm` from edit_login/build_login (all covered by module-level imports; leftover from the verbatim A2/A3 move). - build_login passes the password_stdin flag through to resolve_secret_line for consistency with build_card/build_totp (behavior identical — that branch is only reached when password_stdin is true). - restore #[allow(clippy::too_many_arguments)] on build_totp (8 args; the old build_totp_item carried the same allow — signature is frozen for B/C).
319 lines
13 KiB
Rust
319 lines
13 KiB
Rust
//! Shared per-type item construction + interactive editing for both the
|
|
//! personal vault (`commands/add.rs`, `commands/edit.rs`) and the org vault
|
|
//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use anyhow::{Context, Result};
|
|
use zeroize::Zeroizing;
|
|
|
|
use relicario_core::item::FieldHistoryEntry;
|
|
use relicario_core::item_types::{CardKind, TotpAlgorithm};
|
|
use relicario_core::time::now_unix;
|
|
use relicario_core::{EncryptedAttachment, FieldId, Item, ItemCore};
|
|
|
|
use crate::parse::base32_decode_lenient;
|
|
use crate::prompt::{prompt_keep_opt, prompt_secret, prompt_yesno};
|
|
|
|
pub(crate) type FieldHistory = HashMap<FieldId, Vec<FieldHistoryEntry>>;
|
|
|
|
/// Resolve a single-line secret: from stdin when `from_stdin`, else an
|
|
/// interactive masked prompt (which honours `RELICARIO_TEST_ITEM_SECRET`).
|
|
pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result<String> {
|
|
if from_stdin {
|
|
let mut s = String::new();
|
|
std::io::stdin().read_line(&mut s)?;
|
|
Ok(s.trim_end_matches(['\n', '\r']).to_string())
|
|
} else {
|
|
crate::prompt::prompt_secret(&format!("{label}: "))
|
|
}
|
|
}
|
|
|
|
/// Resolve a multiline secret (key material, note body). Both paths read stdin
|
|
/// to EOF; the interactive path first prints `hint` to stderr.
|
|
pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result<String> {
|
|
if !from_stdin {
|
|
eprintln!("{hint}");
|
|
}
|
|
let mut s = String::new();
|
|
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
|
Ok(s)
|
|
}
|
|
|
|
pub(crate) fn parse_card_kind(s: &str) -> Result<CardKind> {
|
|
Ok(match s {
|
|
"credit" => CardKind::Credit,
|
|
"debit" => CardKind::Debit,
|
|
"gift" => CardKind::Gift,
|
|
"loyalty" => CardKind::Loyalty,
|
|
"other" => CardKind::Other,
|
|
other => anyhow::bail!("unknown card kind: {other}"),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn parse_totp_algorithm(s: &str) -> Result<TotpAlgorithm> {
|
|
Ok(match s.to_ascii_lowercase().as_str() {
|
|
"sha1" => TotpAlgorithm::Sha1,
|
|
"sha256" => TotpAlgorithm::Sha256,
|
|
"sha512" => TotpAlgorithm::Sha512,
|
|
other => anyhow::bail!("unknown algorithm: {other}"),
|
|
})
|
|
}
|
|
|
|
// --- Per-type interactive edit helpers (moved from commands/edit.rs). Each
|
|
// mutates its core slice in place; history-tracked variants take the
|
|
// item's field_history map so they can record the prior value.
|
|
|
|
pub(crate) fn edit_login(
|
|
l: &mut relicario_core::item_types::LoginCore,
|
|
history: &mut FieldHistory,
|
|
totp_qr: Option<PathBuf>,
|
|
) -> Result<()> {
|
|
use relicario_core::item_types::{TotpConfig, TotpKind};
|
|
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()))? {
|
|
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
|
|
}
|
|
if prompt_yesno("Change password?")? {
|
|
let old = l.password.clone();
|
|
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
|
|
if let Some(old_pw) = old {
|
|
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(())
|
|
}
|
|
|
|
pub(crate) fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
|
|
if prompt_yesno("Edit body?")? {
|
|
let old = n.body.clone();
|
|
let s = resolve_secret_multiline(false, "Enter new body; end with Ctrl-D:")?;
|
|
n.body = Zeroizing::new(s);
|
|
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
|
|
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
|
|
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
|
|
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
|
|
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
|
|
if prompt_yesno("Change card number?")? {
|
|
let old = c.number.clone();
|
|
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
|
|
if let Some(o) = old {
|
|
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
|
|
if prompt_yesno("Replace key material?")? {
|
|
let s = resolve_secret_multiline(false, "Paste new key material; end with Ctrl-D:")?;
|
|
let old = k.key_material.clone();
|
|
k.key_material = Zeroizing::new(s);
|
|
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn edit_document_message() {
|
|
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
|
}
|
|
|
|
pub(crate) fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
|
|
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
|
|
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
|
|
if prompt_yesno("Change TOTP secret?")? {
|
|
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
|
|
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
|
|
let new_bytes = base32_decode_lenient(&new_b32)?;
|
|
t.config.secret = Zeroizing::new(new_bytes);
|
|
push_history(history, "totp_secret", Zeroizing::new(old_b32));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn build_login(
|
|
title: String, username: Option<String>, url: Option<String>,
|
|
password: Option<String>, password_stdin: bool, password_prompt: bool,
|
|
totp_qr: Option<PathBuf>,
|
|
) -> Result<Item> {
|
|
use relicario_core::item_types::{LoginCore, TotpConfig, TotpKind};
|
|
let parsed_url = match url {
|
|
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
|
None => None,
|
|
};
|
|
let password = if let Some(p) = password {
|
|
Some(Zeroizing::new(p))
|
|
} else if password_stdin {
|
|
Some(Zeroizing::new(resolve_secret_line(password_stdin, "Password")?))
|
|
} else if password_prompt {
|
|
Some(Zeroizing::new(crate::prompt::prompt_secret("Password: ")?))
|
|
} 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 };
|
|
Ok(Item::new(title, ItemCore::Login(LoginCore { username, password, url: parsed_url, totp })))
|
|
}
|
|
|
|
pub(crate) fn build_secure_note(title: String, body: Option<String>, body_stdin: bool) -> Result<Item> {
|
|
use relicario_core::item_types::SecureNoteCore;
|
|
let body = match body {
|
|
Some(b) => b,
|
|
None => resolve_secret_multiline(body_stdin, "Enter note body; end with Ctrl-D on a blank line:")?,
|
|
};
|
|
Ok(Item::new(title, ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new(body) })))
|
|
}
|
|
|
|
pub(crate) fn build_identity(
|
|
title: String, full_name: Option<String>, email: Option<String>,
|
|
phone: Option<String>, date_of_birth: Option<String>,
|
|
) -> Result<Item> {
|
|
use relicario_core::item_types::IdentityCore;
|
|
let dob = match date_of_birth {
|
|
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
|
|
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
|
|
None => None,
|
|
};
|
|
Ok(Item::new(title, ItemCore::Identity(IdentityCore {
|
|
full_name, address: None, phone, email, date_of_birth: dob,
|
|
})))
|
|
}
|
|
|
|
pub(crate) fn build_card(
|
|
title: String, holder: Option<String>, expiry: Option<String>, kind: &str,
|
|
number_stdin: bool, cvv_stdin: bool, pin_stdin: bool,
|
|
) -> Result<Item> {
|
|
use relicario_core::item_types::CardCore;
|
|
let number = Zeroizing::new(resolve_secret_line(number_stdin, "Card number")?);
|
|
let cvv = resolve_secret_line(cvv_stdin, "CVV (blank to skip)")?;
|
|
let cvv = if cvv.is_empty() { None } else { Some(Zeroizing::new(cvv)) };
|
|
let pin = resolve_secret_line(pin_stdin, "PIN (blank to skip)")?;
|
|
let pin = if pin.is_empty() { None } else { Some(Zeroizing::new(pin)) };
|
|
let parsed_expiry = match expiry { Some(s) => Some(crate::parse::parse_month_year(&s)?), None => None };
|
|
Ok(Item::new(title, ItemCore::Card(CardCore {
|
|
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parse_card_kind(kind)?,
|
|
})))
|
|
}
|
|
|
|
pub(crate) fn build_key(
|
|
title: String, label: Option<String>, algorithm: Option<String>,
|
|
public_key: Option<String>, material_stdin: bool,
|
|
) -> Result<Item> {
|
|
use relicario_core::item_types::KeyCore;
|
|
let key_material = resolve_secret_multiline(material_stdin, "Paste key material; end with Ctrl-D on a blank line:")?;
|
|
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
|
|
Ok(Item::new(title, ItemCore::Key(KeyCore {
|
|
key_material: Zeroizing::new(key_material), label, public_key, algorithm,
|
|
})))
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn build_totp(
|
|
title: String, issuer: Option<String>, label: Option<String>,
|
|
secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: &str,
|
|
) -> Result<Item> {
|
|
use relicario_core::item_types::{TotpConfig, TotpCore, TotpKind};
|
|
let secret_b32 = match secret {
|
|
Some(s) => s,
|
|
None => resolve_secret_line(secret_stdin, "TOTP secret (base32)")?,
|
|
};
|
|
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
|
Ok(Item::new(title, ItemCore::Totp(TotpCore {
|
|
config: TotpConfig {
|
|
secret: Zeroizing::new(secret_bytes), algorithm: parse_totp_algorithm(algorithm)?,
|
|
digits, period_seconds: period, kind: TotpKind::Totp,
|
|
},
|
|
issuer, label,
|
|
})))
|
|
}
|
|
|
|
pub(crate) fn build_document(
|
|
title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64,
|
|
) -> Result<(Item, EncryptedAttachment)> {
|
|
use relicario_core::item_types::DocumentCore;
|
|
use relicario_core::{encrypt_attachment, AttachmentRef};
|
|
let bytes = std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?;
|
|
let enc = encrypt_attachment(&bytes, key, max_bytes)?;
|
|
let filename = file.file_name()
|
|
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
|
|
.to_string_lossy().into_owned();
|
|
let mime_type = crate::parse::guess_mime(&filename);
|
|
let primary_attachment = enc.id.clone();
|
|
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
|
|
filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: primary_attachment.clone(),
|
|
}));
|
|
item.attachments.push(AttachmentRef {
|
|
id: primary_attachment, filename, mime_type, size: bytes.len() as u64, created: item.created,
|
|
});
|
|
Ok((item, enc))
|
|
}
|
|
|
|
pub(crate) fn push_history(
|
|
history: &mut FieldHistory,
|
|
synthetic_key: &str,
|
|
old_value: zeroize::Zeroizing<String>,
|
|
) {
|
|
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
|
|
// custom-field UUIDs can't collide).
|
|
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
|
|
history.entry(fid).or_default().push(FieldHistoryEntry {
|
|
value: old_value,
|
|
replaced_at: now_unix(),
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use relicario_core::item_types::{CardKind, TotpAlgorithm};
|
|
|
|
#[test]
|
|
fn card_kind_parses_known_values() {
|
|
assert_eq!(parse_card_kind("credit").unwrap(), CardKind::Credit);
|
|
assert_eq!(parse_card_kind("loyalty").unwrap(), CardKind::Loyalty);
|
|
}
|
|
|
|
#[test]
|
|
fn card_kind_rejects_unknown() {
|
|
assert!(parse_card_kind("platinum").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn totp_algorithm_is_case_insensitive() {
|
|
assert_eq!(parse_totp_algorithm("SHA256").unwrap(), TotpAlgorithm::Sha256);
|
|
}
|
|
|
|
#[test]
|
|
fn totp_algorithm_rejects_unknown() {
|
|
assert!(parse_totp_algorithm("md5").is_err());
|
|
}
|
|
}
|