diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 6d873ffb..1a884920 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -208,6 +208,13 @@ jobs: true fi + - name: Keygen supports conversion from stdin + run: ./${{ matrix.alice }}-keygen | ./${{ matrix.bob }}-keygen -y + + - name: Keygen supports conversion from file + if: matrix.recipient == 'x25519' + run: ./${{ matrix.alice }}-keygen -y key.txt + - name: Update FiloSottile/age status with result if: always() && github.event.action == 'age-interop-request' run: | diff --git a/age/CHANGELOG.md b/age/CHANGELOG.md index 5597517d..b86b0dd4 100644 --- a/age/CHANGELOG.md +++ b/age/CHANGELOG.md @@ -11,17 +11,23 @@ to 1.0.0 are beta releases. ## [Unreleased] ### Added - `age::cli_common::file_io`: + - `FileReader` - `impl Debug for {LazyFile, OutputFormat, OutputWriter, StdoutWriter}` +- `age::identity::IdentityFile::from_input_reader` (behind `cli-common` feature + flag). - `impl Eq for age::ssh::{ParseRecipientKeyError, UnsupportedKey}` - `impl {Debug, PartialEq, Eq, Hash} for age::x25519::Recipient` ### Changed - MSRV is now 1.65.0. - Migrated to `base64 0.21`, `rsa 0.9`. -- `age::cli_common::file_io::OutputWriter::new` now takes an `allow_overwrite` - boolean argument. If `OutputWriter` will write to a file, this boolean enables - the caller to control whether the file will be overwritten if it exists - (instead of the implicit behaviour that was previously changed in 0.6.0). +- `age::cli_common::file_io`: + - `InputReader::File` enum variant now contains `FileReader` instead of + `std::fs::File`. + - `OutputWriter::new` now takes an `allow_overwrite` boolean argument. If + `OutputWriter` will write to a file, this boolean enables the caller to + control whether the file will be overwritten if it exists (instead of the + implicit behaviour that was previously changed in 0.6.0). - `age::ssh`: - `ParseRecipientKeyError` has a new variant `RsaModulusTooLarge`. - The following trait implementations now return diff --git a/age/src/cli_common/file_io.rs b/age/src/cli_common/file_io.rs index 7e5a2651..e24324f3 100644 --- a/age/src/cli_common/file_io.rs +++ b/age/src/cli_common/file_io.rs @@ -50,10 +50,16 @@ impl fmt::Display for DenyOverwriteFileError { impl std::error::Error for DenyOverwriteFileError {} +/// Wrapper around a [`File`]. +pub struct FileReader { + inner: File, + filename: String, +} + /// Wrapper around either a file or standard input. pub enum InputReader { /// Wrapper around a file. - File(File), + File(FileReader), /// Wrapper around standard input. Stdin(io::Stdin), } @@ -65,7 +71,10 @@ impl InputReader { // Respect the Unix convention that "-" as an input filename // parameter is an explicit request to use standard input. if filename != "-" { - return Ok(InputReader::File(File::open(filename)?)); + return Ok(InputReader::File(FileReader { + inner: File::open(&filename)?, + filename, + })); } } @@ -76,12 +85,20 @@ impl InputReader { pub fn is_terminal(&self) -> bool { matches!(self, Self::Stdin(_)) && io::stdin().is_terminal() } + + pub(crate) fn filename(&self) -> Option<&str> { + if let Self::File(f) = self { + Some(&f.filename) + } else { + None + } + } } impl Read for InputReader { fn read(&mut self, buf: &mut [u8]) -> io::Result { match self { - InputReader::File(f) => f.read(buf), + InputReader::File(f) => f.inner.read(buf), InputReader::Stdin(handle) => handle.read(buf), } } diff --git a/age/src/identity.rs b/age/src/identity.rs index 90c63b60..7f05710d 100644 --- a/age/src/identity.rs +++ b/age/src/identity.rs @@ -3,6 +3,9 @@ use std::io; use crate::{x25519, Callbacks, DecryptError, EncryptError}; +#[cfg(feature = "cli-common")] +use crate::cli_common::file_io::InputReader; + #[cfg(feature = "plugin")] use crate::plugin; @@ -70,6 +73,13 @@ impl IdentityFile { Self::parse_identities(None, data) } + /// Parses one or more identities from an [`InputReader`]; + #[cfg(feature = "cli-common")] + pub fn from_input_reader(reader: InputReader) -> io::Result { + let filename = reader.filename().map(String::from); + Self::parse_identities(filename, io::BufReader::new(reader)) + } + fn parse_identities(filename: Option, data: R) -> io::Result { let mut identities = vec![]; diff --git a/rage/CHANGELOG.md b/rage/CHANGELOG.md index 4c352264..78ed8e6b 100644 --- a/rage/CHANGELOG.md +++ b/rage/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to Rust's notion of to 1.0.0 are beta releases. ## [Unreleased] +### Added +- `rage-keygen -y IDENTITY_FILE` to convert identity files to recipients. + ### Changed - MSRV is now 1.65.0. - Migrated from `gumdrop` to `clap` for argument parsing. diff --git a/rage/build.rs b/rage/build.rs index 53ebe178..5c80c26c 100644 --- a/rage/build.rs +++ b/rage/build.rs @@ -39,11 +39,11 @@ macro_rules! fl { struct Example { text: String, cmd: &'static str, - output: Option, + output: Vec, } impl Example { - const fn new(text: String, cmd: &'static str, output: Option) -> Self { + const fn new(text: String, cmd: &'static str, output: Vec) -> Self { Self { text, cmd, output } } } @@ -139,38 +139,38 @@ impl Cli { Example::new( fl!("man-rage-example-enc-single"), "echo \"_o/\" | rage -o hello.age -r age1uvscypafkkxt6u2gkguxet62cenfmnpc0smzzlyun0lzszfatawq4kvf2u", - None, + vec![], ), Example::new( fl!("man-rage-example-enc-multiple"), "echo \"_o/\" | rage -r age1uvscypafkkxt6u2gkguxet62cenfmnpc0smzzlyun0lzszfatawq4kvf2u \ -r age1ex4ty8ppg02555at009uwu5vlk5686k3f23e7mac9z093uvzfp8sxr5jum > hello.age", - None, + vec![], ), Example::new( fl!("man-rage-example-enc-password"), "rage -p -o hello.txt.age hello.txt", - Some(format!("{}:", fl!("type-passphrase"))), + vec![format!("{}:", fl!("type-passphrase"))], ), Example::new( fl!("man-rage-example-enc-list"), "tar cv ~/xxx | rage -R recipients.txt > xxx.tar.age", - None, + vec![], ), Example::new( fl!("man-rage-example-enc-identities"), "tar cv ~/xxx | rage -e -i keyA.txt -i keyB.txt > xxx.tar.age", - None, + vec![], ), Example::new( fl!("man-rage-example-enc-url"), "echo \"_o/\" | rage -o hello.age -R <(curl https://github.com/str4d.keys)", - None, + vec![], ), Example::new( fl!("man-rage-example-dec-identities"), "rage -d -o hello -i keyA.txt -i keyB.txt hello.age", - None, + vec![], ), ]) .render(w) @@ -182,14 +182,36 @@ impl Cli { self.rage_keygen.about(fl!("man-keygen-about")), |_, w| { Examples([ - Example::new(fl!("man-keygen-example-stdout"), "rage-keygen", None), + Example::new( + fl!("man-keygen-example-stdout"), + "rage-keygen", + vec![ + format!( + "# {}: 2021-01-02T15:30:45+01:00", + fl!("identity-file-created"), + ), + format!( + "# {}: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z", + fl!("identity-file-pubkey"), + ), + "AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9".to_owned(), + ], + ), Example::new( fl!("man-keygen-example-file"), "rage-keygen -o key.txt", - Some(format!( + vec![format!( "{}: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p", fl!("tty-pubkey") - )), + )], + ), + Example::new( + fl!("man-keygen-example-convert"), + "rage-keygen -y key.txt", + vec![ + "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p" + .to_owned(), + ], ), ]) .render(w) @@ -204,12 +226,12 @@ impl Cli { Example::new( fl!("man-mount-example-identity"), "rage-mount -t tar -i key.txt encrypted.tar.age ./tmp", - None, + vec![], ), Example::new( fl!("man-mount-example-passphrase"), "rage-mount -t zip encrypted.zip.age ./tmp", - Some(format!("{}:", fl!("type-passphrase"))), + vec![format!("{}:", fl!("type-passphrase"))], ), ]) .render(w) diff --git a/rage/i18n/en-US/rage.ftl b/rage/i18n/en-US/rage.ftl index 4070790b..478e7d0e 100644 --- a/rage/i18n/en-US/rage.ftl +++ b/rage/i18n/en-US/rage.ftl @@ -79,6 +79,7 @@ rage-after-help-example = {" "}{$example_c} keygen-help-flag-output = {help-flag-output} Defaults to standard output. +keygen-help-flag-convert = Convert an identity file to a recipients file. ## Formatting @@ -100,7 +101,9 @@ warn-double-encrypting = Encrypting an already-encrypted file ## General errors +err-failed-to-open-input = Failed to open input: {$err} err-failed-to-open-output = Failed to open output: {$err} +err-failed-to-read-input = Failed to read from input: {$err} err-failed-to-write-output = Failed to write to output: {$err} err-identity-ambiguous = {-flag-identity} requires either {-flag-encrypt} or {-flag-decrypt}. err-mixed-encrypt-decrypt = {-flag-encrypt} can't be used with {-flag-decrypt}. @@ -112,6 +115,14 @@ err-ux-B = Tell us # Put (len(A) - len(B) - 32) spaces here. err-ux-C = {" "} +## Keygen errors + +err-identity-file-contains-plugin = Identity file '{$filename}' contains identities for '{-age-plugin-}{$plugin_name}'. +rec-identity-file-contains-plugin = Try using '{-age-plugin-}{$plugin_name}' to convert this identity to a recipient. + +err-no-identities-in-file = No identities found in file '{$filename}'. +err-no-identities-in-stdin = No identities found in standard input. + ## Encryption errors err-enc-broken-stdout = Could not write to stdout: {$err} @@ -215,6 +226,7 @@ man-keygen-about = Generate age-compatible encryption key pairs man-keygen-example-stdout = Generate a new key pair man-keygen-example-file = Generate a new key pair and save it to a file +man-keygen-example-convert = Convert an identity file to a recipient man-mount-about = Mount an age-encrypted filesystem diff --git a/rage/src/bin/rage-keygen/cli.rs b/rage/src/bin/rage-keygen/cli.rs index aa6c5aa7..fe07d08b 100644 --- a/rage/src/bin/rage-keygen/cli.rs +++ b/rage/src/bin/rage-keygen/cli.rs @@ -19,6 +19,11 @@ use crate::fl; #[command(disable_help_flag(true))] #[command(disable_version_flag(true))] pub(crate) struct AgeOptions { + #[arg(help_heading = fl!("args-header"))] + #[arg(value_name = fl!("input"))] + #[arg(help = fl!("help-arg-input"))] + pub(crate) input: Option, + #[arg(action = ArgAction::Help, short, long)] #[arg(help = fl!("help-flag-help"))] pub(crate) help: Option, @@ -31,4 +36,8 @@ pub(crate) struct AgeOptions { #[arg(value_name = fl!("output"))] #[arg(help = fl!("keygen-help-flag-output"))] pub(crate) output: Option, + + #[arg(short = 'y')] + #[arg(help = fl!("keygen-help-flag-convert"))] + pub(crate) convert: bool, } diff --git a/rage/src/bin/rage-keygen/error.rs b/rage/src/bin/rage-keygen/error.rs index 0e038bb3..43176b40 100644 --- a/rage/src/bin/rage-keygen/error.rs +++ b/rage/src/bin/rage-keygen/error.rs @@ -12,8 +12,17 @@ macro_rules! wlnfl { } pub(crate) enum Error { + FailedToOpenInput(io::Error), FailedToOpenOutput(io::Error), + FailedToReadInput(io::Error), FailedToWriteOutput(io::Error), + IdentityFileContainsPlugin { + filename: Option, + plugin_name: String, + }, + NoIdentities { + filename: Option, + }, } // Rust only supports `fn main() -> Result<(), E: Debug>`, so we implement `Debug` @@ -21,12 +30,40 @@ pub(crate) enum Error { impl fmt::Debug for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Error::FailedToOpenInput(e) => { + wlnfl!(f, "err-failed-to-open-input", err = e.to_string())? + } Error::FailedToOpenOutput(e) => { wlnfl!(f, "err-failed-to-open-output", err = e.to_string())? } + Error::FailedToReadInput(e) => { + wlnfl!(f, "err-failed-to-read-input", err = e.to_string())? + } Error::FailedToWriteOutput(e) => { wlnfl!(f, "err-failed-to-write-output", err = e.to_string())? } + Error::IdentityFileContainsPlugin { + filename, + plugin_name, + } => { + wlnfl!( + f, + "err-identity-file-contains-plugin", + filename = filename.as_deref().unwrap_or_default(), + plugin_name = plugin_name.as_str(), + )?; + wlnfl!( + f, + "rec-identity-file-contains-plugin", + plugin_name = plugin_name.as_str(), + )? + } + Error::NoIdentities { filename } => match filename { + Some(filename) => { + wlnfl!(f, "err-no-identities-in-file", filename = filename.as_str())? + } + None => wlnfl!(f, "err-no-identities-in-stdin")?, + }, } writeln!(f)?; writeln!(f, "[ {} ]", crate::fl!("err-ux-A"))?; diff --git a/rage/src/bin/rage-keygen/main.rs b/rage/src/bin/rage-keygen/main.rs index a5e9cf2a..6c947cab 100644 --- a/rage/src/bin/rage-keygen/main.rs +++ b/rage/src/bin/rage-keygen/main.rs @@ -3,7 +3,7 @@ use age::{cli_common::file_io, secrecy::ExposeSecret}; use clap::Parser; -use std::io::Write; +use std::io::{self, Write}; mod cli; mod error; @@ -35,7 +35,7 @@ fn main() -> Result<(), error::Error> { let opts = cli::AgeOptions::parse(); - let mut output = file_io::OutputWriter::new( + let output = file_io::OutputWriter::new( opts.output, false, file_io::OutputFormat::Text, @@ -44,24 +44,60 @@ fn main() -> Result<(), error::Error> { ) .map_err(error::Error::FailedToOpenOutput)?; + if opts.convert { + convert(opts.input, output) + } else { + generate(output).map_err(error::Error::FailedToWriteOutput) + } +} + +fn generate(mut output: file_io::OutputWriter) -> io::Result<()> { let sk = age::x25519::Identity::generate(); let pk = sk.to_public(); - (|| { - writeln!( - output, - "# {}: {}", - fl!("identity-file-created"), - chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) - )?; - writeln!(output, "# {}: {}", fl!("identity-file-pubkey"), pk)?; - writeln!(output, "{}", sk.to_string().expose_secret())?; - - if !output.is_terminal() { - eprintln!("{}: {}", fl!("tty-pubkey"), pk); + writeln!( + output, + "# {}: {}", + fl!("identity-file-created"), + chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + )?; + writeln!(output, "# {}: {}", fl!("identity-file-pubkey"), pk)?; + writeln!(output, "{}", sk.to_string().expose_secret())?; + + if !output.is_terminal() { + eprintln!("{}: {}", fl!("tty-pubkey"), pk); + } + + Ok(()) +} + +fn convert( + filename: Option, + mut output: file_io::OutputWriter, +) -> Result<(), error::Error> { + let file = age::IdentityFile::from_input_reader( + file_io::InputReader::new(filename.clone()).map_err(error::Error::FailedToOpenInput)?, + ) + .map_err(error::Error::FailedToReadInput)?; + + let identities = file.into_identities(); + if identities.is_empty() { + return Err(error::Error::NoIdentities { filename }); + } + + for identity in identities { + match identity { + age::IdentityFileEntry::Native(sk) => { + writeln!(output, "{}", sk.to_public()).map_err(error::Error::FailedToWriteOutput)? + } + age::IdentityFileEntry::Plugin(id) => { + return Err(error::Error::IdentityFileContainsPlugin { + filename, + plugin_name: id.plugin().to_string(), + }); + } } + } - Ok(()) - })() - .map_err(error::Error::FailedToWriteOutput) + Ok(()) }