Skip to main content

algae_cli/
passphrases.rs

1use std::{io::IsTerminal, path::PathBuf};
2
3use age::{Identity, Recipient};
4use clap::Parser;
5use dialoguer::Password;
6use miette::{bail, miette, Context as _, IntoDiagnostic as _, Result};
7use pinentry::PassphraseInput;
8use tokio::fs::read_to_string;
9
10/// Re-exported from [`age`] so dependents can name and read passphrase secrets
11/// without taking a direct dependency on age.
12pub use age::secrecy::{ExposeSecret, SecretString};
13
14/// [Clap][clap] arguments for passphrases.
15///
16/// ```no_run
17/// use clap::Parser;
18/// use miette::Result;
19/// use algae_cli::passphrases::PassphraseArgs;
20///
21/// /// Your CLI tool
22/// #[derive(Parser)]
23/// struct Args {
24///     #[command(flatten)]
25///     pass: PassphraseArgs,
26/// }
27///
28/// #[tokio::main]
29/// async fn main() -> Result<()> {
30///     let args = Args::parse();
31///     let key = args.pass.require().await?;
32///     // use key somehow...
33/// # let _key = key;
34///     Ok(())
35/// }
36/// ```
37#[derive(Debug, Clone, Parser)]
38pub struct PassphraseArgs {
39	/// Path to a file containing a passphrase.
40	///
41	/// The contents of the file will be trimmed of whitespace.
42	#[arg(short = 'P', long)]
43	pub passphrase_path: Option<PathBuf>,
44
45	/// A passphrase as a string.
46	///
47	/// This is extremely insecure, only use when there is no other option. When on an interactive
48	/// terminal, make sure to wipe this command line from your history, or better yet not record it
49	/// in the first place (in Bash you often can do that by prepending a space to your command).
50	#[arg(long, conflicts_with = "passphrase_path")]
51	pub insecure_passphrase: Option<SecretString>,
52}
53
54impl PassphraseArgs {
55	/// Retrieve a passphrase from the user.
56	pub async fn require(&self) -> Result<Passphrase> {
57		self.get(false).await
58	}
59
60	/// Retrieve a passphrase from the user, with confirmation when prompting.
61	pub async fn require_with_confirmation(&self) -> Result<Passphrase> {
62		self.get(true).await
63	}
64
65	/// Retrieve a passphrase from the user, as a [`SecretString`].
66	pub async fn require_phrase(&self) -> Result<SecretString> {
67		self.get_phrase(false).await
68	}
69
70	/// Retrieve a passphrase from the user, as a [`SecretString`], with confirmation when prompting.
71	pub async fn require_phrase_with_confirmation(&self) -> Result<SecretString> {
72		self.get_phrase(true).await
73	}
74
75	async fn get(&self, confirm: bool) -> Result<Passphrase> {
76		self.get_phrase(confirm).await.map(Passphrase::new)
77	}
78
79	async fn get_phrase(&self, confirm: bool) -> Result<SecretString> {
80		if let Some(ref phrase) = self.insecure_passphrase {
81			Ok(phrase.clone())
82		} else if let Some(ref path) = self.passphrase_path {
83			Ok(read_to_string(path)
84				.await
85				.into_diagnostic()
86				.wrap_err("reading keyfile")?
87				.trim()
88				.into())
89		} else {
90			prompt_passphrase(confirm)
91		}
92	}
93}
94
95/// How pinentry should be driven, given the surrounding environment.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97enum PinentryMode {
98	/// No terminal and no display: prompting can never succeed, so don't try.
99	Unavailable,
100	/// Force pinentry onto the controlling terminal. Used over SSH, where a GUI
101	/// pinentry can't render its dialog and would hang forever at `GETPIN`.
102	ForceTty,
103	/// Let pinentry pick its own backend: a local GUI if a display is present,
104	/// otherwise the terminal.
105	Default,
106}
107
108/// Decide how to drive pinentry from what the environment offers.
109///
110/// Over SSH a GUI pinentry (gnome3/gtk/qt) has no display it can actually draw
111/// on, so it blocks indefinitely; force it onto the terminal instead. With
112/// neither a terminal nor a display there's nothing to prompt on at all.
113fn pinentry_mode(stdin_is_tty: bool, has_display: bool, over_ssh: bool) -> PinentryMode {
114	if over_ssh && stdin_is_tty {
115		PinentryMode::ForceTty
116	} else if stdin_is_tty || has_display {
117		PinentryMode::Default
118	} else {
119		PinentryMode::Unavailable
120	}
121}
122
123/// Prompt the user for a passphrase, via pinentry when available.
124fn prompt_passphrase(confirm: bool) -> Result<SecretString> {
125	let ssh_tty = std::env::var("SSH_TTY").ok().filter(|tty| !tty.is_empty());
126	let over_ssh = ssh_tty.is_some() || std::env::var_os("SSH_CONNECTION").is_some();
127	let has_display = ["DISPLAY", "WAYLAND_DISPLAY"]
128		.into_iter()
129		.any(|key| std::env::var_os(key).is_some_and(|val| !val.is_empty()));
130	let stdin_is_tty = std::io::stdin().is_terminal();
131
132	let mode = pinentry_mode(stdin_is_tty, has_display, over_ssh);
133	if mode == PinentryMode::Unavailable {
134		bail!(
135			"no terminal or display is available to prompt for a passphrase; pass --passphrase-path or --insecure-passphrase"
136		);
137	}
138
139	if let Some(mut input) = PassphraseInput::with_default_binary() {
140		input
141			.with_prompt("Passphrase:")
142			.required("Cannot use an empty passphrase");
143		if confirm {
144			input.with_confirmation("Confirm passphrase:", "Passphrases do not match");
145		}
146
147		// Over SSH the inherited DISPLAY/WAYLAND_DISPLAY makes pinentry pick a GUI
148		// backend that can't render and hangs at GETPIN. Clear them so it falls back
149		// to the curses/tty backend, and point ttyname at the SSH terminal.
150		#[cfg(unix)]
151		if mode == PinentryMode::ForceTty {
152			use pinentry::unix::Options;
153			let mut builder = Options::builder().x11_display("").wayland_display("");
154			if let Some(ref tty) = ssh_tty {
155				builder = builder.tty_name(tty);
156			}
157			input.with_unix_options(builder.build());
158		}
159
160		input.interact().map_err(|err| miette!("{err}"))
161	} else {
162		let mut prompt = Password::new().with_prompt("Passphrase");
163		if confirm {
164			prompt = prompt.with_confirmation("Confirm passphrase", "Passphrases do not match");
165		}
166		let phrase = prompt.interact().into_diagnostic()?;
167		Ok(phrase.into())
168	}
169}
170
171/// A wrapper around [`age::scrypt::Recipient`] and [`age::scrypt::Identity`].
172///
173/// Such that a single struct implements both [`Recipient`] and [`Identity`]
174/// traits for a single passphrase, simplifying usage.
175pub struct Passphrase(age::scrypt::Recipient, age::scrypt::Identity);
176
177impl Passphrase {
178	/// Initialise from a string.
179	pub fn new(secret: SecretString) -> Self {
180		Self(
181			age::scrypt::Recipient::new(secret.clone()),
182			age::scrypt::Identity::new(secret),
183		)
184	}
185
186	/// Initialise from a string, with a fixed scrypt work factor (`N = 2^log_n`).
187	///
188	/// [`Passphrase::new`] calibrates the work factor to take about one second,
189	/// which on a fast machine means hundreds of MiB of scrypt arena (memory
190	/// scales with `N`: 128 × 8 × 2^log_n bytes). That hardness only matters
191	/// when the secret is a guessable human passphrase; for a high-entropy
192	/// generated secret it buys nothing, and a low work factor avoids the
193	/// memory and CPU spike.
194	///
195	/// # Panics
196	///
197	/// Panics if `log_n == 0` or `log_n >= 64`.
198	pub fn with_work_factor(secret: SecretString, log_n: u8) -> Self {
199		let mut recipient = age::scrypt::Recipient::new(secret.clone());
200		recipient.set_work_factor(log_n);
201		Self(recipient, age::scrypt::Identity::new(secret))
202	}
203}
204
205impl Recipient for Passphrase {
206	fn wrap_file_key(
207		&self,
208		file_key: &age_core::format::FileKey,
209	) -> std::result::Result<
210		(
211			Vec<age_core::format::Stanza>,
212			std::collections::HashSet<String>,
213		),
214		age::EncryptError,
215	> {
216		self.0.wrap_file_key(file_key)
217	}
218}
219
220impl Identity for Passphrase {
221	fn unwrap_stanza(
222		&self,
223		stanza: &age_core::format::Stanza,
224	) -> Option<std::result::Result<age_core::format::FileKey, age::DecryptError>> {
225		self.1.unwrap_stanza(stanza)
226	}
227
228	fn unwrap_stanzas(
229		&self,
230		stanzas: &[age_core::format::Stanza],
231	) -> Option<std::result::Result<age_core::format::FileKey, age::DecryptError>> {
232		self.1.unwrap_stanzas(stanzas)
233	}
234}
235
236#[cfg(test)]
237mod tests {
238	use super::{pinentry_mode, PinentryMode};
239
240	#[test]
241	fn ssh_with_terminal_forces_tty() {
242		assert_eq!(
243			pinentry_mode(true, false, true),
244			PinentryMode::ForceTty,
245			"interactive SSH session must use the terminal, not a GUI"
246		);
247		assert_eq!(
248			pinentry_mode(true, true, true),
249			PinentryMode::ForceTty,
250			"a forwarded display over SSH must not override the terminal"
251		);
252	}
253
254	#[test]
255	fn local_terminal_or_display_uses_defaults() {
256		assert_eq!(pinentry_mode(true, false, false), PinentryMode::Default);
257		assert_eq!(pinentry_mode(false, true, false), PinentryMode::Default);
258		assert_eq!(pinentry_mode(true, true, false), PinentryMode::Default);
259	}
260
261	#[test]
262	fn no_terminal_and_no_display_is_unavailable() {
263		assert_eq!(
264			pinentry_mode(false, false, false),
265			PinentryMode::Unavailable
266		);
267		assert_eq!(
268			pinentry_mode(false, false, true),
269			PinentryMode::Unavailable,
270			"SSH without a pty (ssh -T) and no display can't prompt"
271		);
272	}
273
274	#[test]
275	fn ssh_without_terminal_but_forwarded_display_uses_defaults() {
276		assert_eq!(pinentry_mode(false, true, true), PinentryMode::Default);
277	}
278}