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(())
}