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
10pub use age::secrecy::{ExposeSecret, SecretString};
13
14#[derive(Debug, Clone, Parser)]
38pub struct PassphraseArgs {
39 #[arg(short = 'P', long)]
43 pub passphrase_path: Option<PathBuf>,
44
45 #[arg(long, conflicts_with = "passphrase_path")]
51 pub insecure_passphrase: Option<SecretString>,
52}
53
54impl PassphraseArgs {
55 pub async fn require(&self) -> Result<Passphrase> {
57 self.get(false).await
58 }
59
60 pub async fn require_with_confirmation(&self) -> Result<Passphrase> {
62 self.get(true).await
63 }
64
65 pub async fn require_phrase(&self) -> Result<SecretString> {
67 self.get_phrase(false).await
68 }
69
70 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97enum PinentryMode {
98 Unavailable,
100 ForceTty,
103 Default,
106}
107
108fn 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
123fn 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 #[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
171pub struct Passphrase(age::scrypt::Recipient, age::scrypt::Identity);
176
177impl Passphrase {
178 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 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}