use aegis_vault_utils::otp::EntryInfo::Totp;
use aegis_vault_utils::{
otp::{Entry, EntryInfo, calculate_remaining_time, generate_otp},
vault::{PasswordGetter, parse_vault},
};
use clap::builder::styling::{AnsiColor, Effects, Styles};
use clap::{Args, Parser};
use color_eyre::eyre::{Result, eyre};
use console::{Key, Style, Term};
use dialoguer::{FuzzySelect, Password, theme::ColorfulTheme};
use std::sync::mpsc::{self, TryRecvError};
use std::{fs, path::PathBuf, process::exit, thread, time::Duration};
use urlencoding::encode;
const STYLE: Styles = Styles::styled()
.header(AnsiColor::Green.on_default().effects(Effects::BOLD))
.usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
.literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
.placeholder(AnsiColor::Cyan.on_default())
.error(AnsiColor::Red.on_default().effects(Effects::BOLD))
.valid(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
.invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD));
#[derive(Parser)]
#[command(version, about, styles = STYLE, help_template(
"\
{before-help}{name} {version} - {about}
{usage-heading} {usage}
{all-args}{after-help}
"
))]
struct Cli {
#[clap(
help = "Encrypted Aegis Vault JSON file (separate it from name/issuer
filters by putting -- before it)",
env = "AEGIS_VAULT_FILE"
)]
vault_file: PathBuf,
#[clap(short, long, help = "Show OTP entries in plain text")]
otp: bool,
#[clap(short, long, help = "Export entries to Plain Aegis Vault JSON")]
json: bool,
#[clap(short, long, help = "Export entries in URL format")]
url: bool,
#[clap(flatten)]
password_input: PasswordInput,
#[clap(flatten, help = "Filter by ISSUER and/or NAME")]
entry_filter: EntryFilter,
}
#[derive(Args)]
struct PasswordInput {
#[clap(short, long, env = "AEGIS_PWFILE", help = "Aegis Vault passwordfile")]
pwfile: Option<PathBuf>,
#[clap(
short('P'),
long,
env = "AEGIS_PASSWORD",
help = "PASSWORD for Aegis Vault",
conflicts_with = "password_file",
hide_env_values = true
)]
password: Option<String>,
}
#[derive(Args)]
struct EntryFilter {
#[clap(short, long, num_args(1..), help = "Filter by ISSUER (multiple allowed)")]
issuer: Option<String>,
#[clap(short, long, num_args(1..), help = "Filter by NAME (multiple allowed)")]
name: Option<String>,
}
#[derive(Debug, serde::Serialize)]
struct EntryInfo_ {
secret: String,
algo: String,
digits: i32,
period: i32,
}
#[derive(Debug, serde::Serialize)]
struct Entry_ {
r#type: String,
name: String,
uuid: Option<String>,
issuer: String,
note: Option<String>,
icon: Option<String>,
icon_mime: Option<String>,
icon_hash: Option<String>,
favorite: bool,
info: EntryInfo_,
groups: Option<String>,
}
impl EntryFilter {
fn matches(&self, entry: &Entry) -> bool {
#[allow(clippy::collapsible_if)]
if let Some(issuer) = &self.issuer {
if !entry.issuer.to_lowercase().contains(&issuer.to_lowercase()) {
return false;
}
}
#[allow(clippy::collapsible_if)]
if let Some(name) = &self.name {
if !entry.name.to_lowercase().contains(&name.to_lowercase()) {
return false;
}
}
true
}
}
impl PasswordGetter for PasswordInput {
fn get_password(&self) -> Result<String> {
match (&self.password, &self.pwfile) {
(Some(password), None) => Ok(password.clone()),
(None, Some(password_file)) => {
let password = fs::read_to_string(password_file)?;
Ok(password.trim().to_string())
}
_ => Password::with_theme(&ColorfulTheme::default())
.with_prompt("Enter Aegis Vault Password")
.interact()
.map_err(|e| eyre!("Failed to get password: {}", e)),
}
}
}
fn set_sigint_hook() {
ctrlc::set_handler(move || {
Term::stdout().show_cursor().expect("Showing cursor");
})
.expect("Setting SIGINT handler");
}
fn print_otp_every_second(entry_info: &EntryInfo) -> Result<()> {
let term = Term::stdout();
term.hide_cursor()?;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let term = Term::stdout();
loop {
#[allow(clippy::collapsible_if)]
if let Ok(key) = term.read_key() {
if key == Key::Escape {
let _ = tx.send(());
break;
}
}
}
});
let mut clipboard = arboard::Clipboard::new().ok();
let mut otp_code = String::new();
let mut last_remaining_time = 0;
loop {
match rx.try_recv() {
Ok(_) | Err(TryRecvError::Disconnected) => {
term.clear_last_lines(1)?;
term.show_cursor()?;
break;
}
Err(TryRecvError::Empty) => {}
}
let remaining_time = calculate_remaining_time(entry_info)?;
if last_remaining_time < remaining_time {
otp_code = generate_otp(entry_info)?;
if let Some(clipboard) = clipboard.as_mut() {
clipboard.set_text(otp_code.clone())?;
}
}
let style = match remaining_time {
0..=5 => Style::new().red(),
6..=15 => Style::new().yellow(),
_ => Style::new().green(),
};
let line = style.bold().apply_to(format!("{otp_code} ({remaining_time}s left) - Esc to exit"));
term.write_line(line.to_string().as_str())?;
std::thread::sleep(Duration::from_millis(60));
term.clear_last_lines(1)?;
last_remaining_time = remaining_time;
}
Ok(())
}
fn entries_otp(entries: &[Entry]) -> Result<()> {
let mut remaining = 0;
entries.iter().for_each(|entry| {
println!("{} {}:{}", generate_otp(&entry.info).unwrap(), entry.issuer.clone(), entry.name.clone(),);
remaining = calculate_remaining_time(&entry.info).unwrap();
});
println!("Remaining: {remaining} s");
Ok(())
}
fn entries_to_json(entries: &[Entry]) -> Result<()> {
let output: Vec<Entry_> = entries
.iter()
.map(|entry| {
let Entry { info, .. } = entry;
let Totp(infototp) = info else { panic!("Invalid entry info") };
Ok(Entry_ {
r#type: "totp".to_string(),
uuid: None,
name: entry.name.clone(),
issuer: entry.issuer.clone(),
note: None,
icon: None,
icon_mime: None,
icon_hash: None,
favorite: false,
info: EntryInfo_ { secret: infototp.secret.to_string(), algo: "SHA1".to_string(), digits: infototp.digits, period: infototp.period },
groups: None,
})
})
.collect::<Result<Vec<Entry_>>>()?;
if output.is_empty() {
println!("No entries found");
} else {
println!("{}", serde_json::to_string_pretty(&output)?);
}
Ok(())
}
fn entries_to_url(entries: &[Entry]) {
for entry in entries.iter() {
let Entry { info, .. } = entry;
let Totp(infototp) = info else { panic!("Invalid entry") };
let algo = format!("{:?}", infototp.algo);
println!(
"otpauth://totp/{}?secret={}&algorithm={}&digits={}&period={}&issuer={}",
encode(&entry.name),
infototp.secret.replace("\"", "").clone(),
algo.to_string().to_uppercase(),
infototp.digits,
infototp.period,
encode(&entry.issuer),
);
}
}
fn fuzzy_select(entries: &[Entry]) -> Result<()> {
set_sigint_hook();
let items: Vec<String> = entries.iter().map(|entry| format!("{} ({})", entry.issuer.trim(), entry.name.trim())).collect();
loop {
let selection = match FuzzySelect::with_theme(&ColorfulTheme::default()).items(&items).default(0).clear(true).interact_opt() {
Ok(selection) => selection,
Err(_) => {
print!("\x1Bc");
exit(1);
}
};
match selection {
Some(index) => {
let entry_info = &entries.get(index).unwrap().info;
print_otp_every_second(entry_info)?;
}
None => {
exit(0);
}
}
}
}
fn main() -> Result<()> {
color_eyre::install()?;
let args = Cli::parse();
let file_contents = match fs::read_to_string(&args.vault_file) {
Ok(contents) => contents,
Err(e) => {
eprintln!("Failed to read Aegis Vault file: {e}");
exit(1);
}
};
let entries = match parse_vault(&file_contents, &args.password_input) {
Ok(db) => db
.entries
.into_iter()
.filter(|e| matches!(e.info, EntryInfo::Totp(_)))
.filter(|e| args.entry_filter.matches(e))
.collect::<Vec<Entry>>(),
Err(e) => {
eprintln!("Failed to open Aegis Vault: {e}");
exit(1);
}
};
if entries.is_empty() {
println!("No matching entries based on filters");
return Ok(());
}
if args.otp {
entries_otp(&entries)?;
} else if args.json {
entries_to_json(&entries)?;
} else if args.url {
entries_to_url(&entries);
} else {
fuzzy_select(&entries)?;
}
Ok(())
}