algae_cli/cli/
keygen.rs

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