mod seed_password;
use std::{
fs,
io::{BufWriter, IsTerminal, Write, stdout},
num::NonZero,
path::Path,
sync::Arc,
};
use anyhow::{Context as _Context, Result};
use clap::{CommandFactory, Parser, error::ErrorKind};
use itertools::Itertools;
use onepass_conf::{Config, Error, KeyringSeed, RawSite};
use onepass_seed::{
ExposeSecret, SecretBox, SecretString,
dict::{BoxDict, Dict},
expr::{Context, Eval},
site::Site,
};
use readpassphrase_3::Flags as RpFlags;
#[derive(Debug, Parser)]
#[command(version, about, next_help_heading = "Site Options")]
struct Args {
#[arg(value_name = "SITE", help_heading = None)]
sites: Vec<String>,
#[arg(short, long)]
schema: Option<String>,
#[arg(short, long, value_name = "NUM")]
increment: Option<u32>,
#[arg(short, long)]
username: Option<String>,
#[arg(short = 'k', help_heading = "Keyring Integration")]
keyring: bool,
#[arg(
short = 'K',
conflicts_with = "keyring",
help_heading = "Keyring Integration"
)]
no_keyring: bool,
#[arg(short, long, help_heading = "Keyring Integration")]
reset_keyring: bool,
#[arg(short, long, help_heading = "Password Entry")]
describe: bool,
#[arg(short, long, help_heading = "Password Entry")]
confirm: bool,
#[arg(
short,
long,
value_name = "COUNT",
default_missing_value = "1",
num_args=0..=1,
require_equals = true,
help_heading = "Password Entry",
)]
learn: Option<u32>,
#[arg(long)]
stdin: bool,
#[arg(
short,
long = "words",
env = "ONEPASS_WORDS_FILE",
value_name = "WORDS_FILE",
help_heading = "Configuration"
)]
words_path: Option<Box<Path>>,
#[arg(
short = 'f',
long = "config",
env = "ONEPASS_CONFIG_FILE",
value_name = "CONFIG_FILE",
help_heading = "Configuration"
)]
config_path: Option<Box<Path>>,
#[arg(short, long, help_heading = "Configuration")]
print_sites: bool,
#[arg(short, long)]
verbose: bool,
}
fn main() -> Result<()> {
let args = Args::parse();
let config_path = args.config_path.as_deref();
let config = Config::from_or_init(config_path).context("failed to read config")?;
let seed_keyring = if args.no_keyring {
false
} else if args.keyring {
true
} else if args.stdin {
false
} else {
match config.global.keyring.seed {
KeyringSeed::Unspecified | KeyringSeed::Cache => true,
KeyringSeed::Off => false,
}
};
let rp_flags = if args.stdin {
RpFlags::STDIN
} else {
RpFlags::default()
};
if args.reset_keyring {
seed_password::delete()?;
}
if args.print_sites {
for site in config.sites().iter().unique_by(|&site| &site.url) {
println!("{}", site.url);
}
return Ok(());
}
if args.sites.is_empty() {
if args.confirm {
let _ = seed_password::read(seed_keyring, true, rp_flags)?;
}
if args.reset_keyring || args.confirm {
return Ok(());
}
Args::command()
.error(ErrorKind::TooFewValues, "specify at least one site")
.exit();
}
let words: Option<_> = read_words_str(&args, &config)?;
let dict = words
.as_deref()
.map(BoxDict::from_lines)
.map(|d| -> Arc<dyn Dict + '_> { Arc::new(d) });
let context = dict.map_or_else(Context::default, Context::with_dict);
if args.describe {
for url in &args.sites {
let site = lookup_site(url, &config, &args, &context)?;
let size = site.expr.size();
println!("{url}:");
println!("{}", site.expr);
let mut buf = BufWriter::new(Vec::new());
site.expr.write_to(&mut buf, &mut SecretBox::default())?;
let example = String::from_utf8(buf.into_inner()?)?;
println!("Looks like: {example:?}");
println!("about {} bits of entropy", size.bits_vartime());
}
return Ok(());
}
let mut stdout = stdout();
let seed = seed_password::read(seed_keyring, args.confirm, rp_flags)?;
for site in &args.sites {
let res = gen_password_config(seed.expose_secret(), site, &config, &args, &context)?;
stdout.write_all(res.expose_secret().as_bytes())?;
if stdout.is_terminal() || args.sites.len() > 1 {
writeln!(stdout)?;
}
if let Some(count) = args.learn {
let mut failures = 0;
for _ in 0..count {
if !seed_password::check_confirm(res.expose_secret())? {
failures += 1;
eprint!("✘ ");
}
}
if failures != 0 {
if count == 1 {
anyhow::bail!("password mismatch");
}
anyhow::bail!("{failures}/{count} attempts failed");
}
}
}
Ok(())
}
fn read_words_str(args: &Args, config: &Config) -> Result<Option<Box<str>>> {
let path = args
.words_path
.as_deref()
.or(config.global.words_path.as_deref());
path.map(|p| fs::read_to_string(p).map(|s| s.into_boxed_str()))
.transpose()
.context("failed reading words file")
}
fn gen_password_config(
seed: &str,
url: &str,
config: &Config,
args: &Args,
context: &Context<'_>,
) -> Result<SecretString> {
let site = lookup_site(url, config, args, context)?;
let size = site.expr.size();
let salt = format!("{site}");
if args.verbose {
eprintln!(
"schema for {2} has about {0} bits of entropy (0x{1} possible passwords)",
&size.bits(),
&size.to_string().trim_start_matches('0'),
url,
);
eprintln!("salt: {salt:?}");
}
site.password(seed).context("failed generating password")
}
fn lookup_site<'a>(
url: &str,
config: &Config,
args: &Args,
context: &'a Context<'a>,
) -> Result<Site<'a>> {
let username = args.username.as_deref();
let mut site = match config.find_site(url, username) {
Ok(site) => site,
Err(Error::UrlNotFound) => RawSite::new(url, username, None, 0),
Err(err) => return Err(err).context("failed finding site"),
};
if let Some(data) = site.data {
eprintln!("WARNING: ignoring data field on {url}:\n{data:?}");
}
if let Some(ref schema) = args.schema {
site.schema = Some(config.resolve_schema(schema));
}
if let Some(increment) = args.increment {
site.increment = NonZero::new(increment);
}
site.to_site_with_context(config.default_schema(), context)
.context("failed generating site")
}
#[cfg(test)]
mod tests {
use std::{fs::File, path::PathBuf};
use onepass_seed::{
dict::{BoxDict, Dict},
site::Site,
};
use tempfile::TempDir;
use super::*;
#[test]
#[ignore] fn test_passwords() -> Result<()> {
let tests = [
(
"impeach-duckling-outage-spur",
"arst",
"google.com",
"{words:4:-}",
0,
),
("(%\")&#+(&!/$", "password", "apple.com", "[!-/]{12}", 1),
];
for (want, seed, url, schema, increment) in tests {
let site = Site::new(url, None, schema, increment)?;
let got = site.password(seed)?;
assert_eq!(want, got.expose_secret());
}
Ok(())
}
#[test]
fn test_words_file() -> Result<()> {
let dir = TempDir::new()?;
let mut dir_path = PathBuf::from(dir.path());
dir_path.push("config");
let config_path = dir_path.clone().into_boxed_path();
dir_path.pop();
dir_path.push("words");
let words_path = dir_path.into_boxed_path();
let mut config_file = File::create(&config_path)?;
writeln!(config_file, "[global]")?;
writeln!(config_file, "words_path = {words_path:?}")?;
drop(config_file);
let mut words_file = File::create(&words_path)?;
writeln!(words_file, "bob")?;
writeln!(words_file, " dole")?;
writeln!(words_file, "bob ")?;
writeln!(words_file, "a")?;
drop(words_file);
let config = Config::from_file(&config_path)?;
let args: [&str; 0] = [];
let words = read_words_str(&Args::parse_from(args.iter()), &config)?
.context("failed reading words file")?;
let dict = BoxDict::from_lines(&words);
let words = dict.words();
assert_eq!(&["a", "bob", "dole"], words);
Ok(())
}
}