From b2f8c6b973d1ddf86ec1683e056a63d768d98dce Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Wed, 25 Mar 2020 00:27:32 +1300 Subject: [PATCH 1/5] rage-keygen: Print the public key to stderr if output is not TTY This makes it easier for the user to access the public key; they can copy it directly from the terminal instead of opening the output file. --- age/CHANGELOG.md | 1 + age/src/cli_common/file_io.rs | 8 ++++++++ rage/examples/generate-docs.rs | 5 ++++- rage/src/bin/rage-keygen/main.rs | 7 ++++++- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/age/CHANGELOG.md b/age/CHANGELOG.md index 1951d966..7b4d7d6e 100644 --- a/age/CHANGELOG.md +++ b/age/CHANGELOG.md @@ -19,6 +19,7 @@ to 1.0.0 are beta releases. - `age::Encryptor::with_user_passphrase(SecretString)` - Support for encrypted OpenSSH keys created with `ssh-keygen` prior to OpenSSH 7.6. +- `age::cli_common::file_io::OutputWriter::is_terminal` ### Changed - `age::Decryptor` has been refactored to auto-detect the decryption type. As a diff --git a/age/src/cli_common/file_io.rs b/age/src/cli_common/file_io.rs index 495f8809..fff6057c 100644 --- a/age/src/cli_common/file_io.rs +++ b/age/src/cli_common/file_io.rs @@ -206,6 +206,14 @@ impl OutputWriter { Ok(OutputWriter::Stdout(StdoutWriter::new(format, is_tty))) } + + /// Returns true if this output is to a terminal, and a user will likely see it. + pub fn is_terminal(&self) -> bool { + match self { + OutputWriter::File(..) => false, + OutputWriter::Stdout(w) => w.is_tty, + } + } } impl Write for OutputWriter { diff --git a/rage/examples/generate-docs.rs b/rage/examples/generate-docs.rs index 49aa9235..dff12ff4 100644 --- a/rage/examples/generate-docs.rs +++ b/rage/examples/generate-docs.rs @@ -168,7 +168,10 @@ fn rage_keygen_page() { .example( Example::new() .text("Generate a new key pair and save it to a file") - .command("rage-keygen -o key.txt"), + .command("rage-keygen -o key.txt") + .output( + "Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p", + ), ) .render(); diff --git a/rage/src/bin/rage-keygen/main.rs b/rage/src/bin/rage-keygen/main.rs index 7c8b6c37..5c32550b 100644 --- a/rage/src/bin/rage-keygen/main.rs +++ b/rage/src/bin/rage-keygen/main.rs @@ -36,14 +36,19 @@ fn main() { }; let sk = age::keys::SecretKey::generate(); + let pk = sk.to_public(); if let Err(e) = (|| { + if !output.is_terminal() { + eprintln!("Public key: {}", pk); + } + writeln!( output, "# created: {}", chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) )?; - writeln!(output, "# public key: {}", sk.to_public())?; + writeln!(output, "# public key: {}", pk)?; writeln!(output, "{}", sk.to_string().expose_secret()) })() { error!("Failed to write to output: {}", e); From 908216c297361da73a6a9a52ea634469b6275b85 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Wed, 25 Mar 2020 00:57:27 +1300 Subject: [PATCH 2/5] Overhaul rage help text and manpage --- rage/examples/generate-docs.rs | 28 +++++++-------- rage/src/bin/rage/main.rs | 63 ++++++++++++++++++++++++---------- 2 files changed, 59 insertions(+), 32 deletions(-) diff --git a/rage/examples/generate-docs.rs b/rage/examples/generate-docs.rs index dff12ff4..a98bf780 100644 --- a/rage/examples/generate-docs.rs +++ b/rage/examples/generate-docs.rs @@ -22,54 +22,54 @@ fn rage_page() { Flag::new() .short("-h") .long("--help") - .help("Display help text and exit"), + .help("Display help text and exit."), ) .flag( Flag::new() .short("-V") .long("--version") - .help("Display version and exit"), + .help("Display version info and exit."), ) .flag( Flag::new() .short("-d") .long("--decrypt") - .help("Decrypt the input (default is to encrypt)"), + .help("Decrypt the input. By default, the input is encrypted."), ) .flag( Flag::new() .short("-p") .long("--passphrase") - .help("Use a passphrase instead of public keys"), + .help("Encrypt with a passphrase instead of recipients."), ) .flag( Flag::new() .short("-a") .long("--armor") - .help("Create ASCII armored output (default is age binary format)"), + .help("Encrypt to a PEM encoded format."), ) .option( - Opt::new("recipient") + Opt::new("RECIPIENT") .short("-r") .long("--recipient") - .help("A recipient to encrypt to (can be repeated)"), + .help("Encrypt to the specified RECIPIENT. May be repeated."), ) .option( - Opt::new("identity") + Opt::new("IDENTITY") .short("-i") .long("--identity") - .help("An identity to decrypt with (can be repeated)"), + .help("Use the private key file at IDENTITY. May be repeated."), ) .option( - Opt::new("output") + Opt::new("OUTPUT") .short("-o") .long("--output") - .help("The file path to write output to (defaults to stdout)"), + .help("Write the result to the file at path OUTPUT. Defaults to standard output."), ) .option( Opt::new("WF") .long("--max-work-factor") - .help("The maximum work factor to allow for passphrase decryption"), + .help("The maximum work factor to allow for passphrase decryption."), ) .arg(Arg::new("[INPUT_FILE (defaults to stdin)]")) .example(Example::new().text("Encryption to a public key").command( @@ -116,9 +116,9 @@ fn rage_page() { #[cfg(feature = "unstable")] let builder = builder .option( - Opt::new("aliases") + Opt::new("ALIASES") .long("--aliases") - .help("The list of aliases to load (defaults to ~/.config/age/aliases.txt)"), + .help("Load the aliases list from ALIASES. Defaults to ~/.config/age/aliases.txt"), ) .example( Example::new() diff --git a/rage/src/bin/rage/main.rs b/rage/src/bin/rage/main.rs index 1cccc390..9d3c4823 100644 --- a/rage/src/bin/rage/main.rs +++ b/rage/src/bin/rage/main.rs @@ -5,7 +5,7 @@ use age::{ }, Format, }; -use gumdrop::Options; +use gumdrop::{Options, ParsingStyle}; use log::{error, warn}; use secrecy::ExposeSecret; use std::collections::HashMap; @@ -161,42 +161,42 @@ fn read_recipients( #[derive(Debug, Options)] struct AgeOptions { - #[options(free, help = "file to read input from (default stdin)")] + #[options(free, help = "Path to a file to read from.")] input: Option, - #[options(help = "print help message")] + #[options(help = "Print this help message and exit.")] help: bool, - #[options(help = "print version info and exit", short = "V")] + #[options(help = "Print version info and exit.", short = "V")] version: bool, - #[options(help = "decrypt the input (default is to encrypt)")] + #[options(help = "Decrypt the input.")] decrypt: bool, - #[options(help = "use a passphrase instead of public keys")] + #[options(help = "Encrypt with a passphrase instead of recipients.")] passphrase: bool, #[options( - help = "maximum work factor to allow for passphrase decryption", + help = "Maximum work factor to allow for passphrase decryption.", meta = "WF", no_short )] max_work_factor: Option, - #[options(help = "create ASCII armored output (default is age binary format)")] + #[options(help = "Encrypt to a PEM encoded format.")] armor: bool, - #[options(help = "recipient to encrypt to (may be repeated)")] + #[options(help = "Encrypt to the specified RECIPIENT. May be repeated.")] recipient: Vec, - #[options(help = "identity to decrypt with (may be repeated)")] + #[options(help = "Use the private key file at IDENTITY. May be repeated.")] identity: Vec, - #[options(help = "output to OUTPUT (default stdout)")] + #[options(help = "Write the result to the file at path OUTPUT.")] output: Option, #[cfg(feature = "unstable")] - #[options(help = "load the aliases list from ALIASES", no_short)] + #[options(help = "Load the aliases list from ALIASES.", no_short)] aliases: Option, } @@ -368,20 +368,47 @@ fn main() -> Result<(), error::Error> { let args = args().collect::>(); + let opts = AgeOptions::parse_args(&args[1..], ParsingStyle::default()).unwrap_or_else(|e| { + eprintln!("{}: {}", args[0], e); + std::process::exit(2); + }); + // If you are piping input with no other args, this will not allow // it. - if console::user_attended() && args.len() == 1 { - // If gumdrop ever merges that PR, that can be used here - // instead. - println!("Usage: {} [OPTIONS]", args[0]); + if (console::user_attended() && args.len() == 1) || opts.help_requested() { + println!("Usage:"); + println!(" {} -r RECIPIENT [-a] [-o OUTPUT] [INPUT]", args[0]); + println!(" {} --decrypt [-i IDENTITY] [-o OUTPUT] [INPUT]", args[0]); println!(); println!("{}", AgeOptions::usage()); + println!(); + println!("INPUT defaults to standard input, and OUTPUT defaults to standard output."); + println!(); + println!("RECIPIENT can be:"); + println!( + "- An age public key, as generated by {}-keygen (\"age1...\").", + args[0] + ); + println!("- An SSH public key (\"ssh-ed25519 AAAA...\", \"ssh-rsa AAAA...\")."); + println!("- A path or HTTPS URL to a file containing age recipients, one per line"); + println!(" (ignoring \"#\" prefixed comments and empty lines)."); + println!(); + println!("IDENTITY is a path to a file with age identities, one per line"); + println!("(ignoring \"#\" prefixed comments and empty lines), or to an SSH key file."); + println!("Multiple identities may be provided, and any unused ones will be ignored."); + println!(); + println!("Example:"); + println!(" $ {}-keygen -o key.txt", args[0]); + println!(" Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"); + println!(" $ tar cvz ~/data | {} -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age", args[0]); + println!( + " $ {} -d -i key.txt -o data.tar.gz data.tar.gz.age", + args[0] + ); return Ok(()); } - let opts = AgeOptions::parse_args_default_or_exit(); - if opts.version { println!("rage {}", env!("CARGO_PKG_VERSION")); Ok(()) From 25ad23bd2ae31795e0130499aef43cad0fa7f4d1 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Wed, 25 Mar 2020 00:58:15 +1300 Subject: [PATCH 3/5] Tidy up rage-keygen and rage-mount help text and manpages --- rage/examples/generate-docs.rs | 21 ++++++++++----------- rage/src/bin/rage-keygen/main.rs | 6 +++--- rage/src/bin/rage-mount/main.rs | 14 +++++++------- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/rage/examples/generate-docs.rs b/rage/examples/generate-docs.rs index a98bf780..f7b05993 100644 --- a/rage/examples/generate-docs.rs +++ b/rage/examples/generate-docs.rs @@ -146,19 +146,18 @@ fn rage_keygen_page() { Flag::new() .short("-h") .long("--help") - .help("Display help text and exit"), + .help("Display help text and exit."), ) .flag( Flag::new() .short("-V") .long("--version") - .help("Display version and exit"), + .help("Display version info and exit."), ) .option( - Opt::new("output") - .short("-o") - .long("--output") - .help("The file path to write the key pair to (defaults to stdout)"), + Opt::new("OUTPUT").short("-o").long("--output").help( + "Write the key pair to the file at path OUTPUT. Defaults to standard output.", + ), ) .example( Example::new() @@ -186,25 +185,25 @@ fn rage_mount_page() { Flag::new() .short("-h") .long("--help") - .help("Display help text and exit"), + .help("Display help text and exit."), ) .flag( Flag::new() .short("-V") .long("--version") - .help("Display version and exit"), + .help("Display version info and exit."), ) .flag( Flag::new() .short("-t") .long("--types") - .help("The type of the filesystem (one of \"tar\", \"zip\")"), + .help("The type of the filesystem (one of \"tar\", \"zip\")."), ) .option( - Opt::new("identity") + Opt::new("IDENTITY") .short("-i") .long("--identity") - .help("An identity to decrypt with (can be repeated)"), + .help("Use the private key file at IDENTITY. May be repeated."), ) .arg(Arg::new("filename")) .arg(Arg::new("mountpoint")) diff --git a/rage/src/bin/rage-keygen/main.rs b/rage/src/bin/rage-keygen/main.rs index 5c32550b..f2d4977f 100644 --- a/rage/src/bin/rage-keygen/main.rs +++ b/rage/src/bin/rage-keygen/main.rs @@ -6,13 +6,13 @@ use std::io::Write; #[derive(Debug, Options)] struct AgeOptions { - #[options(help = "print help message")] + #[options(help = "Print this help message and exit.")] help: bool, - #[options(help = "print version info and exit", short = "V")] + #[options(help = "Print version info and exit.", short = "V")] version: bool, - #[options(help = "output to OUTPUT (default stdout)")] + #[options(help = "Write the result to the file at path OUTPUT. Defaults to standard output.")] output: Option, } diff --git a/rage/src/bin/rage-mount/main.rs b/rage/src/bin/rage-mount/main.rs index f89a702e..70fcb823 100644 --- a/rage/src/bin/rage-mount/main.rs +++ b/rage/src/bin/rage-mount/main.rs @@ -79,29 +79,29 @@ impl fmt::Debug for Error { #[derive(Debug, Options)] struct AgeMountOptions { - #[options(free, help = "The encrypted filesystem to mount")] + #[options(free, help = "The encrypted filesystem to mount.")] filename: String, - #[options(free, help = "The directory to mount the filesystem at")] + #[options(free, help = "The directory to mount the filesystem at.")] mountpoint: String, - #[options(help = "print help message")] + #[options(help = "Print this help message and exit.")] help: bool, - #[options(help = "print version info and exit", short = "V")] + #[options(help = "Print version info and exit.", short = "V")] version: bool, - #[options(help = "indicates the filesystem type (one of \"tar\", \"zip\")")] + #[options(help = "Indicates the filesystem type (one of \"tar\", \"zip\").")] types: String, #[options( - help = "maximum work factor to allow for passphrase decryption", + help = "Maximum work factor to allow for passphrase decryption.", meta = "WF", no_short )] max_work_factor: Option, - #[options(help = "identity to decrypt with (may be repeated)")] + #[options(help = "Use the private key file at IDENTITY. May be repeated.")] identity: Vec, } From afc1f511817b3e12f3cc93150be7f06126e6f56e Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Wed, 25 Mar 2020 00:58:41 +1300 Subject: [PATCH 4/5] More consistent naming in manpages --- rage/examples/generate-docs.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/rage/examples/generate-docs.rs b/rage/examples/generate-docs.rs index f7b05993..845e1a37 100644 --- a/rage/examples/generate-docs.rs +++ b/rage/examples/generate-docs.rs @@ -72,12 +72,12 @@ fn rage_page() { .help("The maximum work factor to allow for passphrase decryption."), ) .arg(Arg::new("[INPUT_FILE (defaults to stdin)]")) - .example(Example::new().text("Encryption to a public key").command( + .example(Example::new().text("Encryption to a recipient").command( "echo \"_o/\" | rage -o hello.age -r age1uvscypafkkxt6u2gkguxet62cenfmnpc0smzzlyun0lzszfatawq4kvf2u", )) .example( Example::new() - .text("Encryption to multiple public keys (with default output to stdout)") + .text("Encryption to multiple recipients (with default output to stdout)") .command( "echo \"_o/\" | rage -r age1uvscypafkkxt6u2gkguxet62cenfmnpc0smzzlyun0lzszfatawq4kvf2u \ -r age1ex4ty8ppg02555at009uwu5vlk5686k3f23e7mac9z093uvzfp8sxr5jum > hello.age", @@ -85,7 +85,7 @@ fn rage_page() { ) .example( Example::new() - .text("Encryption with a password (interactive only, use public keys for batch!)") + .text("Encryption with a password (interactive only, use recipients for batch!)") .command("rage -p -o hello.txt.age hello.txt") .output("Type passphrase:"), ) @@ -104,13 +104,13 @@ fn rage_page() { ) .example( Example::new() - .text("Decryption with keys at ~/.config/age/keys.txt") + .text("Decryption with identities at ~/.config/age/keys.txt") .command("rage --decrypt hello.age") .output("_o/"), ) .example( Example::new() - .text("Decryption with custom keys") + .text("Decryption with custom identities") .command("rage -d -o hello -i keyA.txt -i keyB.txt hello.age"), ); #[cfg(feature = "unstable")] From e79dc5a0840e58e19f57753aa47af79ec90e0f83 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Wed, 25 Mar 2020 01:16:24 +1300 Subject: [PATCH 5/5] Provide a more useful error when an identity file is not found --- age/CHANGELOG.md | 2 ++ age/src/cli_common.rs | 14 ++++++++++++-- rage/src/bin/rage/error.rs | 4 ++++ rage/src/bin/rage/main.rs | 10 +++++++--- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/age/CHANGELOG.md b/age/CHANGELOG.md index 7b4d7d6e..85867a59 100644 --- a/age/CHANGELOG.md +++ b/age/CHANGELOG.md @@ -32,6 +32,8 @@ to 1.0.0 are beta releases. constructors. - `age::Encryptor::wrap_output` now consumes `self`, making it harder to accidentally reuse a passphrase for multiple encrypted files. +- `age::cli_common::read_identities` now takes an additional `file_not_found` + parameter for customising the error when an identity filename is not found. ### Removed - `age::Decryptor::trial_decrypt` (replaced by context-specific decryptors). diff --git a/age/src/cli_common.rs b/age/src/cli_common.rs index e469d155..14cb5532 100644 --- a/age/src/cli_common.rs +++ b/age/src/cli_common.rs @@ -39,10 +39,15 @@ pub fn get_config_dir() -> Option { /// Reads identities from the provided files if given, or the default system /// locations if no files are given. -pub fn read_identities(filenames: Vec, no_default: F) -> Result, E> +pub fn read_identities( + filenames: Vec, + no_default: F, + file_not_found: G, +) -> Result, E> where E: From, F: FnOnce(&str) -> E, + G: Fn(String) -> E, { let mut identities = vec![]; @@ -61,7 +66,12 @@ where identities.extend(Identity::from_buffer(buf)?); } else { for filename in filenames { - identities.extend(Identity::from_file(filename)?); + identities.extend(Identity::from_file(filename.clone()).map_err( + |e| match e.kind() { + io::ErrorKind::NotFound => file_not_found(filename), + _ => e.into(), + }, + )?); } } diff --git a/rage/src/bin/rage/error.rs b/rage/src/bin/rage/error.rs index 2269dd91..c9b88697 100644 --- a/rage/src/bin/rage/error.rs +++ b/rage/src/bin/rage/error.rs @@ -67,6 +67,7 @@ impl fmt::Display for EncryptError { pub(crate) enum DecryptError { Age(age::Error), ArmorFlag, + IdentityNotFound(String), Io(io::Error), MissingIdentities(String), PassphraseFlag, @@ -103,6 +104,9 @@ impl fmt::Display for DecryptError { writeln!(f, "-a/--armor can't be used with -d/--decrypt.")?; write!(f, "Note that armored files are detected automatically.") } + DecryptError::IdentityNotFound(filename) => { + write!(f, "Identity file not found: {}", filename) + } DecryptError::Io(e) => write!(f, "{}", e), DecryptError::MissingIdentities(default_filename) => { writeln!(f, "Missing identities.")?; diff --git a/rage/src/bin/rage/main.rs b/rage/src/bin/rage/main.rs index 9d3c4823..aade2b6b 100644 --- a/rage/src/bin/rage/main.rs +++ b/rage/src/bin/rage/main.rs @@ -339,9 +339,13 @@ fn decrypt(opts: AgeOptions) -> Result<(), error::DecryptError> { } } age::Decryptor::Recipients(decryptor) => { - let identities = read_identities(opts.identity, |default_filename| { - error::DecryptError::MissingIdentities(default_filename.to_string()) - })?; + let identities = read_identities( + opts.identity, + |default_filename| { + error::DecryptError::MissingIdentities(default_filename.to_string()) + }, + |filename| error::DecryptError::IdentityNotFound(filename), + )?; // Check for unsupported keys and alert the user for identity in &identities {