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	#[cfg_attr(docsrs, doc("\n\n**Flag**: `-k, --key-path PATH`"))]
79	#[arg(short, long, verbatim_doc_comment)]
80	pub key_path: Option<PathBuf>,
81
82	/// The key to use for encrypting/decrypting as a string.
83	///
84	/// This does not support the age identity format, only single keys.
85	///
86	/// When encrypting and provided with a secret key, the corresponding public key
87	/// will be derived first; there is no way to encrypt with a secret key such that
88	/// a file is decodable with the public key.
89	///
90	/// There is no support for password-protected secret keys.
91	///
92	/// ## Examples
93	///
94	/// With a public key:
95	///
96	/// ```console
97	/// --key age1c3jdepjm05aey2dq9dgkfn4utj9a776zwqzqcar3879smuh04ysqttvmyd
98	/// ```
99	///
100	/// With a secret key:
101	///
102	/// ```console
103	/// --key AGE-SECRET-KEY-1N84CR29PJTUQA22ALHP4YDL5ZFMXPW5GVETVY3UK58ZD6NPNPDLS4MCZFS
104	/// ```
105	#[cfg_attr(docsrs, doc("\n\n**Flag**: `-K, --key STRING`"))]
106	#[arg(short = 'K', verbatim_doc_comment, conflicts_with = "key_path")]
107	pub key: Option<String>,
108
109	#[command(flatten)]
110	#[allow(missing_docs, reason = "don't interfere with clap")]
111	pub pass: crate::passphrases::PassphraseArgs,
112}
113
114impl KeyArgs {
115	/// Retrieve the secret key from the arguments, if one was provided.
116	///
117	/// Returns `None` if neither of `--key-path` or `--key` was given.
118	///
119	/// Use [`Self::require_secret_key`] instead of dealing with the `None` yourself if you need to
120	/// have a mandatory interface.
121	pub async fn get_secret_key(&self) -> Result<Option<Box<dyn Identity>>> {
122		self.secret_key(false).await
123	}
124
125	/// Retrieve the secret key from the arguments, and error if none is available.
126	///
127	/// Use [`Self::get_secret_key`] instead of parsing the error if you need to have optional keys.
128	pub async fn require_secret_key(&self) -> Result<Box<dyn Identity>> {
129		self.secret_key(true)
130			.await
131			.transpose()
132			.expect("BUG: when required:true, Some must not be produced")
133	}
134
135	/// Retrieve the public key from the arguments, if one was provided.
136	///
137	/// Returns `None` if neither of `--key-path` or `--key` was given.
138	///
139	/// Use [`Self::require_public_key`] instead of dealing with the `None` yourself if you need to
140	/// have a mandatory interface.
141	pub async fn get_public_key(&self) -> Result<Option<Box<dyn Recipient + Send>>> {
142		self.public_key(false).await
143	}
144
145	/// Retrieve the public key from the arguments, and error if none is available.
146	///
147	/// Use [`Self::get_public_key`] instead of parsing the error if you need to have optional keys.
148	pub async fn require_public_key(&self) -> Result<Box<dyn Recipient + Send>> {
149		self.public_key(true)
150			.await
151			.transpose()
152			.expect("BUG: when required:true, Some must not be produced")
153	}
154
155	async fn secret_key(&self, required: bool) -> Result<Option<Box<dyn Identity>>> {
156		match self {
157			Self {
158				key_path: None,
159				key: None,
160				..
161			} => {
162				if required {
163					bail!("one of `--key-path` or `--key` must be provided");
164				} else {
165					Ok(None)
166				}
167			}
168			Self {
169				key_path: Some(_),
170				key: Some(_),
171				..
172			} => {
173				bail!("one of `--key-path` or `--key` must be provided, not both");
174			}
175			Self { key: Some(key), .. } => key
176				.parse::<x25519::Identity>()
177				.map(|sec| Some(Box::new(sec) as _))
178				.map_err(|err| miette!("{err}").wrap_err("parsing secret key")),
179			Self {
180				key_path: Some(path),
181				pass,
182				..
183			} if path.extension().unwrap_or_default() == "age" => {
184				let key = tokio::fs::read(path).await.into_diagnostic()?;
185				let pass = pass.require().await?;
186				let id = age::decrypt(&pass, &key)
187					.into_diagnostic()
188					.wrap_err("revealing identity file")?;
189
190				parse_id_as_identity(
191					&String::from_utf8(id)
192						.into_diagnostic()
193						.wrap_err("parsing identity file as UTF-8")?,
194				)
195				.map(Some)
196			}
197			Self {
198				key_path: Some(path),
199				..
200			} => {
201				let key = read_to_string(&path)
202					.await
203					.into_diagnostic()
204					.wrap_err("reading identity file")?;
205				parse_id_as_identity(&key).map(Some)
206			}
207		}
208	}
209
210	async fn public_key(&self, required: bool) -> Result<Option<Box<dyn Recipient + Send>>> {
211		match self {
212			Self {
213				key_path: None,
214				key: None,
215				..
216			} => {
217				if required {
218					bail!("one of `--key-path` or `--key` must be provided");
219				} else {
220					Ok(None)
221				}
222			}
223			Self {
224				key_path: Some(_),
225				key: Some(_),
226				..
227			} => {
228				bail!("one of `--key-path` or `--key` must be provided, not both");
229			}
230			Self { key: Some(key), .. } if key.starts_with("age") => key
231				.parse::<x25519::Recipient>()
232				.map(|key| Some(Box::new(key) as _))
233				.map_err(|err| miette!("{err}").wrap_err("parsing public key")),
234			Self { key: Some(key), .. } if key.starts_with("AGE-SECRET-KEY") => key
235				.parse::<x25519::Identity>()
236				.map(|sec| Some(Box::new(sec.to_public()) as _))
237				.map_err(|err| miette!("{err}").wrap_err("parsing key")),
238			Self { key: Some(_), .. } => {
239				bail!("value passed to `--key` is not a public or secret age key");
240			}
241			Self {
242				key_path: Some(path),
243				pass,
244				..
245			} if path.extension().unwrap_or_default() == "age" => {
246				let key = tokio::fs::read(path).await.into_diagnostic()?;
247				let pass = pass.require().await?;
248				let id = age::decrypt(&pass, &key)
249					.into_diagnostic()
250					.wrap_err("revealing identity file")?;
251
252				parse_id_as_recipient(
253					&String::from_utf8(id)
254						.into_diagnostic()
255						.wrap_err("parsing identity file as UTF-8")?,
256				)
257				.map(Some)
258			}
259			Self {
260				key_path: Some(path),
261				..
262			} => {
263				let key = read_to_string(path)
264					.await
265					.into_diagnostic()
266					.wrap_err("reading identity file")?;
267				parse_id_as_recipient(&key).map(Some)
268			}
269		}
270	}
271}
272
273fn parse_id_as_identity(id: &str) -> Result<Box<dyn Identity>> {
274	if id.starts_with("AGE-SECRET-KEY") {
275		id.parse::<x25519::Identity>()
276			.map(|sec| Box::new(sec) as _)
277			.map_err(|err| miette!("{err}").wrap_err("parsing secret key"))
278	} else {
279		IdentityFile::from_buffer(id.as_bytes())
280			.into_diagnostic()
281			.wrap_err("parsing identity")?
282			.into_identities()
283			.into_diagnostic()
284			.wrap_err("parsing keys from identity")?
285			.pop()
286			.ok_or_else(|| miette!("no identity available"))
287	}
288}
289
290fn parse_id_as_recipient(id: &str) -> Result<Box<dyn Recipient + Send>> {
291	if id.starts_with("age") {
292		id.parse::<x25519::Recipient>()
293			.map(|key| Box::new(key) as _)
294			.map_err(|err| miette!("{err}").wrap_err("parsing public key"))
295	} else if id.starts_with("AGE-SECRET-KEY") {
296		id.parse::<x25519::Identity>()
297			.map(|sec| Box::new(sec.to_public()) as _)
298			.map_err(|err| miette!("{err}").wrap_err("parsing secret key"))
299	} else {
300		IdentityFile::from_buffer(id.as_bytes())
301			.into_diagnostic()
302			.wrap_err("parsing identity")?
303			.to_recipients()
304			.into_diagnostic()
305			.wrap_err("parsing recipients from identity")?
306			.pop()
307			.ok_or_else(|| miette!("no recipient available in identity"))
308	}
309}