algae_cli/cli/keygen.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143
use std::path::PathBuf;
use age::{
secrecy::{ExposeSecret, SecretString},
x25519,
};
use clap::Parser;
use itertools::Itertools;
use miette::{Context as _, IntoDiagnostic as _, Result};
use tokio::{fs::File, io::AsyncWriteExt};
use crate::passphrases::{Passphrase, PassphraseArgs};
/// Generate an identity (key pair) to encrypt and decrypt files
///
/// This creates a passphrase-protected identity file which contains both public
/// and secret keys:
///
/// ```identity.txt
/// # created: 2024-12-20T05:36:10.267871872+00:00
/// # public key: age1c3jdepjm05aey2dq9dgkfn4utj9a776zwqzqcar3879smuh04ysqttvmyd
/// AGE-SECRET-KEY-1N84CR29PJTUQA22ALHP4YDL5ZFMXPW5GVETVY3UK58ZD6NPNPDLS4MCZFS
/// ```
///
/// As well as a plaintext public key file which contains just the public key:
///
/// ```identity.pub
/// age1c3jdepjm05aey2dq9dgkfn4utj9a776zwqzqcar3879smuh04ysqttvmyd
/// ```
///
/// The public key is also printed to stdout.
///
/// By default this command prompts for a passphrase. This can be disabled with
/// `--plaintext`; the default path `identity.txt` instead of `identity.txt.age`
/// is used if `--output` isn't given, and the contents will be in plain text
/// (in the format shown above).
///
/// On encrypting machines (e.g. servers uploading backups), you should always
/// prefer to store _just_ the public key, and only upload and use the
/// passphrase-protected identity file as necessary, deleting it afterwards.
///
/// Identity files (both plaintext and passphrase-protected) generated by this
/// command are compatible with the `age` CLI tool. Note that the reverse might
/// not be true (there are age-generated identities that this tool cannot handle).
#[derive(Debug, Clone, Parser)]
#[clap(verbatim_doc_comment)]
pub struct KeygenArgs {
/// Path to write the identity file to.
///
/// Defaults to identity.txt.age, and to identity.txt if --plaintext is given.
#[arg(short, long)]
pub output: Option<PathBuf>,
/// Path to write the public key file to.
///
/// Set to a single hyphen (`-`) to disable writing this file; the public key
/// will be printed to stdout in any case.
#[arg(long = "public", default_value = "identity.pub")]
pub public_path: PathBuf,
/// INSECURE: write a plaintext identity.
#[arg(long)]
pub plaintext: bool,
/// Generate a random passphrase.
///
/// Instead of entering a passphrase yourself, this will generate one with
/// random words (from the Minilock wordlist) and print it out for you.
#[arg(short = 'R', long, conflicts_with = "plaintext")]
pub random_passphrase: bool,
#[command(flatten)]
#[allow(missing_docs, reason = "don't interfere with clap")]
pub key: PassphraseArgs,
}
/// CLI command for the `keygen` operation (keypair generation).
pub async fn run(
KeygenArgs {
output,
public_path,
plaintext,
random_passphrase,
key,
}: KeygenArgs,
) -> Result<()> {
let secret = x25519::Identity::generate();
let public = secret.to_public();
let output = output.unwrap_or_else(|| {
if plaintext {
"identity.txt"
} else {
"identity.txt.age"
}
.into()
});
let identity = SecretString::from(format!(
"# created: {}\n# public key: {public}\n{}\n",
jiff::Timestamp::now(),
secret.to_string().expose_secret()
));
let identity = if plaintext {
identity.expose_secret().as_bytes().to_owned()
} else {
let key = if random_passphrase {
use rand::seq::SliceRandom;
let rng = &mut rand::thread_rng();
let words = diceware_wordlists::MINILOCK_WORDLIST.choose_multiple(rng, 6);
let phrase: String = Itertools::intersperse(words.map(|item| *item), "-").collect();
println!("passphrase: {phrase}");
Passphrase::new(phrase.into())
} else {
key.require_with_confirmation().await?
};
age::encrypt(&key, identity.expose_secret().as_bytes()).into_diagnostic()?
};
File::create_new(&output)
.await
.into_diagnostic()
.wrap_err("opening the identity file")?
.write_all(&identity)
.await
.into_diagnostic()
.wrap_err("writing the identity")?;
println!("public key: {public}");
if public_path.to_string_lossy() != "-" {
File::create_new(&public_path)
.await
.into_diagnostic()
.wrap_err("opening the public key file")?
.write_all(public.to_string().as_bytes())
.await
.into_diagnostic()
.wrap_err("writing the public key")?;
}
Ok(())
}