age/
cli_common.rs

1//! Common helpers for CLI binaries.
2
3use age_core::secrecy::{ExposeSecret, SecretString};
4use pinentry::{ConfirmationDialog, PassphraseInput};
5use rand::{
6    distributions::{Distribution, Uniform},
7    rngs::OsRng,
8    CryptoRng, RngCore,
9};
10use rpassword::prompt_password;
11
12use std::io;
13use subtle::ConstantTimeEq;
14
15use crate::{fl, Callbacks};
16
17mod error;
18pub use error::ReadError;
19
20pub mod file_io;
21
22mod identities;
23pub use identities::read_identities;
24
25mod recipients;
26pub use recipients::read_recipients;
27
28const BIP39_WORDLIST: &str = include_str!("../assets/bip39-english.txt");
29
30/// A guard that helps to ensure that standard input is only used once.
31pub struct StdinGuard {
32    stdin_used: bool,
33}
34
35impl StdinGuard {
36    /// Constructs a new `StdinGuard`.
37    ///
38    /// `input_is_stdin` should be set to `true` if standard input is being used for
39    /// plaintext input during encryption, or ciphertext input during decryption.
40    pub fn new(input_is_stdin: bool) -> Self {
41        Self {
42            stdin_used: input_is_stdin,
43        }
44    }
45
46    fn open(&mut self, filename: String) -> Result<file_io::InputReader, ReadError> {
47        let input = file_io::InputReader::new(Some(filename))?;
48        if matches!(input, file_io::InputReader::Stdin(_)) {
49            if self.stdin_used {
50                return Err(ReadError::MultipleStdin);
51            }
52            self.stdin_used = true;
53        }
54        Ok(input)
55    }
56}
57
58fn confirm(query: &str, ok: &str, cancel: Option<&str>) -> pinentry::Result<bool> {
59    if let Some(mut input) = ConfirmationDialog::with_default_binary() {
60        // pinentry binary is available!
61        input.with_ok(ok).with_timeout(30);
62        if let Some(cancel) = cancel {
63            input.with_cancel(cancel);
64        }
65        input.confirm(query)
66    } else {
67        // Fall back to CLI interface.
68        let term = console::Term::stderr();
69        let initial = format!("{}: (y/n) ", query);
70        loop {
71            term.write_str(&initial)?;
72            let response = term.read_line()?.to_lowercase();
73            if ["y", "yes"].contains(&response.as_str()) {
74                break Ok(true);
75            } else if ["n", "no"].contains(&response.as_str()) {
76                break Ok(false);
77            }
78        }
79    }
80}
81
82/// Requests a secret from the user.
83///
84/// If a `pinentry` binary is available on the system, it is used to request the secret.
85/// If not, we fall back to requesting directly in the CLI via a TTY.
86///
87/// This API does not take the secret directly from stdin, because it is specifically
88/// intended to take the secret from a human.
89///
90/// # Parameters
91///
92/// - `description` is the primary information provided to the user about the secret
93///   being requested. It is printed in all cases.
94/// - `prompt` is a short phrase such as "Passphrase" or "PIN". It is printed in front of
95///   the input field when `pinentry` is used.
96/// - `confirm` is an optional short phrase such as "Confirm passphrase". Setting it
97///   enables input confirmation.
98/// - If `confirm.is_some()` then an empty secret is allowed.
99pub fn read_secret(
100    description: &str,
101    prompt: &str,
102    confirm: Option<&str>,
103) -> pinentry::Result<SecretString> {
104    // Check for the pinentry environment variable. If it's not present try to use the default
105    // binary.
106    let input = if let Ok(pinentry) = std::env::var("PINENTRY_PROGRAM") {
107        PassphraseInput::with_binary(pinentry)
108    } else {
109        PassphraseInput::with_default_binary()
110    };
111
112    if let Some(mut input) = input {
113        // User-set or default pinentry binary is available!
114        let mismatch_error = fl!("cli-secret-input-mismatch");
115        let empty_error = fl!("cli-secret-input-required");
116        input
117            .with_description(description)
118            .with_prompt(prompt)
119            .with_timeout(30);
120        if let Some(confirm_prompt) = confirm {
121            input.with_confirmation(confirm_prompt, &mismatch_error);
122        } else {
123            input.required(&empty_error);
124        }
125        input.interact()
126    } else {
127        // Fall back to CLI interface.
128        let passphrase = prompt_password(format!("{}: ", description)).map(SecretString::from)?;
129        if let Some(confirm_prompt) = confirm {
130            let confirm_passphrase =
131                prompt_password(format!("{}: ", confirm_prompt)).map(SecretString::from)?;
132
133            if !bool::from(
134                passphrase
135                    .expose_secret()
136                    .as_bytes()
137                    .ct_eq(confirm_passphrase.expose_secret().as_bytes()),
138            ) {
139                return Err(pinentry::Error::Io(io::Error::new(
140                    io::ErrorKind::InvalidInput,
141                    fl!("cli-secret-input-mismatch"),
142                )));
143            }
144        } else if passphrase.expose_secret().is_empty() {
145            return Err(pinentry::Error::Cancelled);
146        }
147
148        Ok(passphrase)
149    }
150}
151
152/// Implementation of age callbacks that makes requests to the user via the UI.
153#[derive(Clone, Copy)]
154pub struct UiCallbacks;
155
156impl Callbacks for UiCallbacks {
157    fn display_message(&self, message: &str) {
158        eprintln!("{}", message);
159    }
160
161    fn confirm(&self, message: &str, yes_string: &str, no_string: Option<&str>) -> Option<bool> {
162        confirm(message, yes_string, no_string).ok()
163    }
164
165    fn request_public_string(&self, description: &str) -> Option<String> {
166        let term = console::Term::stderr();
167        term.write_str(description).ok()?;
168        term.read_line().ok().filter(|s| !s.is_empty())
169    }
170
171    fn request_passphrase(&self, description: &str) -> Option<SecretString> {
172        read_secret(description, &fl!("cli-passphrase-prompt"), None).ok()
173    }
174}
175
176/// A passphrase.
177pub enum Passphrase {
178    /// Typed by the user.
179    Typed(SecretString),
180    /// Generated.
181    Generated(SecretString),
182}
183
184impl Passphrase {
185    /// Generates a secure passphrase.
186    pub fn random<R: RngCore + CryptoRng>(mut rng: R) -> Self {
187        let between = Uniform::from(0..2048);
188        let new_passphrase = (0..10)
189            .map(|_| {
190                BIP39_WORDLIST
191                    .lines()
192                    .nth(between.sample(&mut rng))
193                    .expect("index is in range")
194            })
195            .fold(String::new(), |acc, s| {
196                if acc.is_empty() {
197                    acc + s
198                } else {
199                    acc + "-" + s
200                }
201            });
202        Passphrase::Generated(SecretString::from(new_passphrase))
203    }
204}
205
206/// Reads a passphrase from stdin, or generates a secure one if none is provided.
207pub fn read_or_generate_passphrase() -> pinentry::Result<Passphrase> {
208    let res = read_secret(
209        &fl!("cli-passphrase-desc"),
210        &fl!("cli-passphrase-prompt"),
211        Some(&fl!("cli-passphrase-confirm")),
212    )?;
213
214    if res.expose_secret().is_empty() {
215        Ok(Passphrase::random(OsRng))
216    } else {
217        Ok(Passphrase::Typed(res))
218    }
219}