algae_cli/
keys.rs

1use std::path::PathBuf;
2
3use age::{x25519, Identity, IdentityFile, Recipient};
4use clap::Parser;
5use miette::{bail, miette, Context as _, IntoDiagnostic as _, Result};
6use tokio::fs::read_to_string;
7
8/// [Clap][clap] arguments for keys (public, secret, and identity).
9///
10/// ```no_run
11/// use clap::Parser;
12/// use miette::Result;
13/// use algae_cli::keys::KeyArgs;
14///
15/// /// Your CLI tool
16/// #[derive(Parser)]
17/// struct Args {
18///     #[command(flatten)]
19///     key: KeyArgs,
20/// }
21///
22/// #[tokio::main]
23/// async fn main() -> Result<()> {
24///     let args = Args::parse();
25///     let key = args.key.require_secret_key().await?;
26///     // use key somehow...
27/// # let _key = key;
28///     Ok(())
29/// }
30/// ```
31#[derive(Debug, Clone, Parser)]
32pub struct KeyArgs {
33	/// Path to the key or identity file to use for encrypting/decrypting.
34	///
35	/// The file can either be:
36	/// - an identity file, which contains both a public and secret key, in age format;
37	/// - a passphrase-protected identity file;
38	/// - a secret key in Bech32 encoding (starts with `AGE-SECRET-KEY`);
39	/// - when encrypting, a public key in Bech32 encoding (starts with `age`).
40	///
41	/// When encrypting and provided with a secret key, the corresponding public key
42	/// will be derived first; there is no way to encrypt with a secret key such that
43	/// a file is decodable with the public key.
44	///
45	/// ## Examples
46	///
47	/// An identity file:
48	///
49	/// ```identity.txt
50	/// # created: 2024-12-20T05:36:10.267871872+00:00
51	/// # public key: age1c3jdepjm05aey2dq9dgkfn4utj9a776zwqzqcar3879smuh04ysqttvmyd
52	/// AGE-SECRET-KEY-1N84CR29PJTUQA22ALHP4YDL5ZFMXPW5GVETVY3UK58ZD6NPNPDLS4MCZFS
53	/// ```
54	///
55	/// An passphrase-protected identity file:
56	///
57	/// ```identity.txt.age
58	/// age-encryption.org/v1
59	/// -> scrypt BIsqC5QmFKsr4IJmVyHovQ 20
60	/// GKscLTw0+n/z+vktrgcoW5eCh0qCfTkFnbTFLrhvXrI
61	/// --- rFMmV2H+FgP27oaLC6SHQOLy5d5DPGSp2pktFo/AOh8
62	/// U�`OZ�rGЕ~N}Ͷ
63	/// MbE/2m��`aQfl&$QCx
64	/// n:T?#�k!_�ΉIa�Y|�}j[頙߄)JJ{څ1y}cܪB���7�
65	/// ```
66	///
67	/// A public key file:
68	///
69	/// ```identity.pub
70	/// age1c3jdepjm05aey2dq9dgkfn4utj9a776zwqzqcar3879smuh04ysqttvmyd
71	/// ```
72	///
73	/// A secret key file:
74	///
75	/// ```identity.key
76	/// AGE-SECRET-KEY-1N84CR29PJTUQA22ALHP4YDL5ZFMXPW5GVETVY3UK58ZD6NPNPDLS4MCZFS
77	/// ```
78	#[arg(short, long, verbatim_doc_comment)]
79	pub key_path: Option<PathBuf>,
80
81	/// The key to use for encrypting/decrypting as a string.
82	///
83	/// This does not support the age identity format, only single keys.
84	///
85	/// When encrypting and provided with a secret key, the corresponding public key
86	/// will be derived first; there is no way to encrypt with a secret key such that
87	/// a file is decodable with the public key.
88	///
89	/// There is no support for password-protected secret keys.
90	///
91	/// ## Examples
92	///
93	/// With a public key:
94	///
95	/// ```console
96	/// --key age1c3jdepjm05aey2dq9dgkfn4utj9a776zwqzqcar3879smuh04ysqttvmyd
97	/// ```
98	///
99	/// With a secret key:
100	///
101	/// ```console
102	/// --key AGE-SECRET-KEY-1N84CR29PJTUQA22ALHP4YDL5ZFMXPW5GVETVY3UK58ZD6NPNPDLS4MCZFS
103	/// ```
104	#[arg(short = 'K', verbatim_doc_comment, conflicts_with = "key_path")]
105	pub key: Option<String>,
106
107	#[command(flatten)]
108	#[allow(missing_docs, reason = "don't interfere with clap")]
109	pub pass: crate::passphrases::PassphraseArgs,
110}
111
112impl KeyArgs {
113	/// Retrieve the secret key from the arguments, if one was provided.
114	///
115	/// Returns `None` if neither of `--key-path` or `--key` was given.
116	///
117	/// Use [`Self::require_secret_key`] instead of dealing with the `None` yourself if you need to
118	/// have a mandatory interface.
119	pub async fn get_secret_key(&self) -> Result<Option<Box<dyn Identity>>> {
120		self.secret_key(false).await
121	}
122
123	/// Retrieve the secret key from the arguments, and error if none is available.
124	///
125	/// Use [`Self::get_secret_key`] instead of parsing the error if you need to have optional keys.
126	pub async fn require_secret_key(&self) -> Result<Box<dyn Identity>> {
127		self.secret_key(true)
128			.await
129			.transpose()
130			.expect("BUG: when required:true, Some must not be produced")
131	}
132
133	/// Retrieve the public key from the arguments, if one was provided.
134	///
135	/// Returns `None` if neither of `--key-path` or `--key` was given.
136	///
137	/// Use [`Self::require_public_key`] instead of dealing with the `None` yourself if you need to
138	/// have a mandatory interface.
139	pub async fn get_public_key(&self) -> Result<Option<Box<dyn Recipient + Send>>> {
140		self.public_key(false).await
141	}
142
143	/// Retrieve the public key from the arguments, and error if none is available.
144	///
145	/// Use [`Self::get_public_key`] instead of parsing the error if you need to have optional keys.
146	pub async fn require_public_key(&self) -> Result<Box<dyn Recipient + Send>> {
147		self.public_key(true)
148			.await
149			.transpose()
150			.expect("BUG: when required:true, Some must not be produced")
151	}
152
153	async fn secret_key(&self, required: bool) -> Result<Option<Box<dyn Identity>>> {
154		match self {
155			Self {
156				key_path: None,
157				key: None,
158				..
159			} => {
160				if required {
161					bail!("one of `--key-path` or `--key` must be provided");
162				} else {
163					Ok(None)
164				}
165			}
166			Self {
167				key_path: Some(_),
168				key: Some(_),
169				..
170			} => {
171				bail!("one of `--key-path` or `--key` must be provided, not both");
172			}
173			Self { key: Some(key), .. } => key
174				.parse::<x25519::Identity>()
175				.map(|sec| Some(Box::new(sec) as _))
176				.map_err(|err| miette!("{err}").wrap_err("parsing secret key")),
177			Self {
178				key_path: Some(path),
179				pass,
180				..
181			} if path.extension().unwrap_or_default() == "age" => {
182				let key = tokio::fs::read(path).await.into_diagnostic()?;
183				let pass = pass.require().await?;
184				let id = age::decrypt(&pass, &key)
185					.into_diagnostic()
186					.wrap_err("revealing identity file")?;
187
188				parse_id_as_identity(
189					&String::from_utf8(id)
190						.into_diagnostic()
191						.wrap_err("parsing identity file as UTF-8")?,
192				)
193				.map(Some)
194			}
195			Self {
196				key_path: Some(path),
197				..
198			} => {
199				let key = read_to_string(&path)
200					.await
201					.into_diagnostic()
202					.wrap_err("reading identity file")?;
203				parse_id_as_identity(&key).map(Some)
204			}
205		}
206	}
207
208	async fn public_key(&self, required: bool) -> Result<Option<Box<dyn Recipient + Send>>> {
209		match self {
210			Self {
211				key_path: None,
212				key: None,
213				..
214			} => {
215				if required {
216					bail!("one of `--key-path` or `--key` must be provided");
217				} else {
218					Ok(None)
219				}
220			}
221			Self {
222				key_path: Some(_),
223				key: Some(_),
224				..
225			} => {
226				bail!("one of `--key-path` or `--key` must be provided, not both");
227			}
228			Self { key: Some(key), .. } if key.starts_with("age") => key
229				.parse::<x25519::Recipient>()
230				.map(|key| Some(Box::new(key) as _))
231				.map_err(|err| miette!("{err}").wrap_err("parsing public key")),
232			Self { key: Some(key), .. } if key.starts_with("AGE-SECRET-KEY") => key
233				.parse::<x25519::Identity>()
234				.map(|sec| Some(Box::new(sec.to_public()) as _))
235				.map_err(|err| miette!("{err}").wrap_err("parsing key")),
236			Self { key: Some(_), .. } => {
237				bail!("value passed to `--key` is not a public or secret age key");
238			}
239			Self {
240				key_path: Some(path),
241				pass,
242				..
243			} if path.extension().unwrap_or_default() == "age" => {
244				let key = tokio::fs::read(path).await.into_diagnostic()?;
245				let pass = pass.require().await?;
246				let id = age::decrypt(&pass, &key)
247					.into_diagnostic()
248					.wrap_err("revealing identity file")?;
249
250				parse_id_as_recipient(
251					&String::from_utf8(id)
252						.into_diagnostic()
253						.wrap_err("parsing identity file as UTF-8")?,
254				)
255				.map(Some)
256			}
257			Self {
258				key_path: Some(path),
259				..
260			} => {
261				let key = read_to_string(path)
262					.await
263					.into_diagnostic()
264					.wrap_err("reading identity file")?;
265				parse_id_as_recipient(&key).map(Some)
266			}
267		}
268	}
269}
270
271fn parse_id_as_identity(id: &str) -> Result<Box<dyn Identity>> {
272	if id.starts_with("AGE-SECRET-KEY") {
273		id.parse::<x25519::Identity>()
274			.map(|sec| Box::new(sec) as _)
275			.map_err(|err| miette!("{err}").wrap_err("parsing secret key"))
276	} else {
277		IdentityFile::from_buffer(id.as_bytes())
278			.into_diagnostic()
279			.wrap_err("parsing identity")?
280			.into_identities()
281			.into_diagnostic()
282			.wrap_err("parsing keys from identity")?
283			.pop()
284			.ok_or_else(|| miette!("no identity available"))
285	}
286}
287
288fn parse_id_as_recipient(id: &str) -> Result<Box<dyn Recipient + Send>> {
289	if id.starts_with("age") {
290		id.parse::<x25519::Recipient>()
291			.map(|key| Box::new(key) as _)
292			.map_err(|err| miette!("{err}").wrap_err("parsing public key"))
293	} else if id.starts_with("AGE-SECRET-KEY") {
294		id.parse::<x25519::Identity>()
295			.map(|sec| Box::new(sec.to_public()) as _)
296			.map_err(|err| miette!("{err}").wrap_err("parsing secret key"))
297	} else {
298		IdentityFile::from_buffer(id.as_bytes())
299			.into_diagnostic()
300			.wrap_err("parsing identity")?
301			.to_recipients()
302			.into_diagnostic()
303			.wrap_err("parsing recipients from identity")?
304			.pop()
305			.ok_or_else(|| miette!("no recipient available in identity"))
306	}
307}