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/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 042f2b3b..5c80c26c 100644 --- a/rage/build.rs +++ b/rage/build.rs @@ -205,6 +205,14 @@ impl Cli { fl!("tty-pubkey") )], ), + Example::new( + fl!("man-keygen-example-convert"), + "rage-keygen -y key.txt", + vec![ + "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p" + .to_owned(), + ], + ), ]) .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..9a799aa0 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,61 @@ 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().to_string()) + .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(()) }