Skip to main content

pimalaya_cli/wizard/
keyring.rs

1//! OS-aware credential-provider picker shared by the account wizards.
2//!
3//! A secret is read from a well-known credential CLI, from a custom
4//! shell command, or stored raw in the configuration. Two flavours share
5//! the same machinery:
6//!
7//! - a **password** is read from an OS keyring ([`KeyringProvider`]);
8//! - an **OAuth 2.0 access token** is read from a token broker
9//!   ([`TokenBroker`]) that refreshes it on every read.
10//!
11//! A known provider or broker yields an argv command (no shell), so the
12//! config serializes it as a TOML array; only a custom command falls
13//! back to a shell string. The picker never *writes* the secret: it just
14//! records the read command, leaving the value for the user to store
15//! under the chosen entry beforehand.
16
17#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
18use core::fmt;
19
20#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
21use secrecy::SecretString;
22
23#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
24use crate::prompt::{self, PromptResult};
25
26/// A well-known credential-provider CLI a password can be read from.
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum KeyringProvider {
29    /// `secret-tool`, over the Secret Service (GNOME Keyring).
30    SecretTool,
31    /// `kwallet-query`, over the KDE Wallet.
32    KwalletQuery,
33    /// `security`, over the macOS Keychain.
34    Security,
35    /// `pass`, the standard unix password manager.
36    Pass,
37}
38
39impl KeyringProvider {
40    /// The providers relevant on the running OS, most native first.
41    /// Empty on platforms without a known stdin-friendly provider
42    /// (Windows), where the picker goes straight to a custom command.
43    pub fn available() -> Vec<Self> {
44        let mut providers = Vec::new();
45
46        if cfg!(target_os = "linux") {
47            providers.push(Self::SecretTool);
48            providers.push(Self::KwalletQuery);
49        }
50
51        if cfg!(target_os = "macos") {
52            providers.push(Self::Security);
53        }
54
55        if cfg!(unix) {
56            providers.push(Self::Pass);
57        }
58
59        providers
60    }
61
62    /// Display name of the provider, for the pick-list labels.
63    pub fn name(self) -> &'static str {
64        match self {
65            Self::SecretTool => "secret-tool (GNOME Keyring / Secret Service)",
66            Self::KwalletQuery => "kwallet-query (KDE Wallet)",
67            Self::Security => "security (macOS Keychain)",
68            Self::Pass => "pass (password store)",
69        }
70    }
71
72    /// The argv (program + arguments, no shell) printing the secret at
73    /// `key` on stdout — the value of the `*.command` config field.
74    ///
75    /// `key` is used **verbatim** as the entry identifier: `service` is
76    /// an optional namespace a *self-owning* broker (which stores and
77    /// reads its own value, e.g. an OAuth token manager) adds — a path
78    /// prefix for `pass`/`kwallet`, a distinct attribute for
79    /// `secret-tool`/`security`. Pass `None` to read a pre-existing entry
80    /// exactly as named.
81    pub fn read_command(self, service: Option<&str>, key: &str) -> Vec<String> {
82        match self {
83            Self::SecretTool => match service {
84                Some(service) => {
85                    argv(["secret-tool", "lookup", "service", service, "account", key])
86                }
87                None => argv(["secret-tool", "lookup", "account", key]),
88            },
89            Self::KwalletQuery => {
90                let entry = path(service, key);
91                argv(["kwallet-query", "-r", &entry, "kdewallet"])
92            }
93            Self::Security => match service {
94                Some(service) => argv([
95                    "security",
96                    "find-generic-password",
97                    "-s",
98                    service,
99                    "-a",
100                    key,
101                    "-w",
102                ]),
103                None => argv(["security", "find-generic-password", "-a", key, "-w"]),
104            },
105            Self::Pass => {
106                let entry = path(service, key);
107                argv(["pass", "show", &entry])
108            }
109        }
110    }
111
112    /// The shell command line *persisting* a secret it receives on stdin
113    /// — the write half of a store/read pair for a broker that owns the
114    /// value (e.g. an OAuth token manager), as opposed to a pre-existing
115    /// entry the user stores themselves. A shell line rather than an
116    /// argv: the writes rely on shell features (`$(cat)` on macOS).
117    pub fn write_command(self, service: Option<&str>, key: &str) -> String {
118        match self {
119            Self::SecretTool => match service {
120                Some(service) => format!(
121                    "secret-tool store --label {service}/{key} service {service} account {key}"
122                ),
123                None => format!("secret-tool store --label {key} account {key}"),
124            },
125            Self::KwalletQuery => format!("kwallet-query -w {} kdewallet", path(service, key)),
126            // `security` takes the secret as an argument, not on stdin;
127            // `$(cat)` bridges it, and `-U` overwrites an existing entry.
128            Self::Security => match service {
129                Some(service) => {
130                    format!("security add-generic-password -U -s {service} -a {key} -w \"$(cat)\"")
131                }
132                None => format!("security add-generic-password -U -a {key} -w \"$(cat)\""),
133            },
134            Self::Pass => format!("pass insert -m -f {}", path(service, key)),
135        }
136    }
137}
138
139/// A well-known OAuth 2.0 token broker: an external CLI that owns the
140/// account's refresh token and prints a *fresh* access token on stdout.
141///
142/// Himalaya (and the other Pimalaya tools) ship no OAuth flow of their
143/// own; they call one of these on every connection. Reference
144/// implementation ([`Ortie`](Self::Ortie)) first.
145#[derive(Clone, Copy, Debug, Eq, PartialEq)]
146pub enum TokenBroker {
147    /// `ortie`, the Pimalaya OAuth 2.0 token broker.
148    Ortie,
149    /// `pizauth`, an OAuth 2.0 token daemon.
150    Pizauth,
151    /// `oama`, the OAuth Anywhere Mail Agent.
152    Oama,
153}
154
155impl TokenBroker {
156    /// The known brokers, reference implementation first. Not
157    /// OS-specific: all are cross-platform CLIs.
158    pub fn available() -> Vec<Self> {
159        vec![Self::Ortie, Self::Pizauth, Self::Oama]
160    }
161
162    /// Display name of the broker, for the pick-list labels.
163    pub fn name(self) -> &'static str {
164        match self {
165            Self::Ortie => "ortie (Pimalaya OAuth 2.0 token broker)",
166            Self::Pizauth => "pizauth (OAuth 2.0 token daemon)",
167            Self::Oama => "oama (OAuth Anywhere Mail Agent)",
168        }
169    }
170
171    /// The argv (program + arguments, no shell) printing a fresh access
172    /// token for `account` on stdout — the value of the `*.command`
173    /// config field.
174    ///
175    /// `account` is the broker's own account handle: a name for `ortie`
176    /// and `pizauth`, the email address for `oama`. It defaults to the
177    /// Himalaya account name; the user adjusts it to match the broker's
178    /// configuration.
179    pub fn read_command(self, account: &str) -> Vec<String> {
180        match self {
181            Self::Ortie => argv(["ortie", "token", "show", "-a", account]),
182            Self::Pizauth => argv(["pizauth", "show", account]),
183            Self::Oama => argv(["oama", "access", account]),
184        }
185    }
186}
187
188/// Collects `parts` into an owned argv vector.
189fn argv<const N: usize>(parts: [&str; N]) -> Vec<String> {
190    parts.iter().map(|part| part.to_string()).collect()
191}
192
193/// Renders a path-based entry: `key` alone, or `service/key` when a
194/// namespace is given.
195fn path(service: Option<&str>, key: &str) -> String {
196    match service {
197        Some(service) => format!("{service}/{key}"),
198        None => key.to_owned(),
199    }
200}
201
202/// A secret collected by the picker.
203///
204/// A known provider or broker yields an argv [`Command`](Self::Command)
205/// (serialized as a TOML array); a user-typed command is a
206/// [`Shell`](Self::Shell) line (serialized as a string), the fallback
207/// form run through the platform shell.
208#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
209pub enum SecretChoice {
210    /// An argv command (program + arguments, no shell) whose stdout is
211    /// the secret. The preferred form.
212    Command(Vec<String>),
213    /// A raw shell command line whose stdout is the secret — the
214    /// fallback, run through the platform shell.
215    Shell(String),
216    /// The secret stored raw (plaintext) in the configuration.
217    Raw(SecretString),
218}
219
220/// One entry in the secret pick list: a keyring provider, an OAuth
221/// broker, a custom command, or a raw value.
222#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
223enum Choice {
224    Keyring(KeyringProvider),
225    Broker(TokenBroker),
226    Custom,
227    Raw,
228}
229
230#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
231impl PartialEq for Choice {
232    fn eq(&self, other: &Self) -> bool {
233        match (self, other) {
234            (Self::Keyring(a), Self::Keyring(b)) => a == b,
235            (Self::Broker(a), Self::Broker(b)) => a == b,
236            (Self::Custom, Self::Custom) | (Self::Raw, Self::Raw) => true,
237            _ => false,
238        }
239    }
240}
241
242#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
243impl Eq for Choice {}
244
245#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
246impl fmt::Display for Choice {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        match self {
249            Self::Keyring(provider) => f.write_str(provider.name()),
250            Self::Broker(broker) => f.write_str(broker.name()),
251            Self::Custom => f.write_str("Custom shell command"),
252            Self::Raw => f.write_str("Store raw in the configuration (plaintext, NOT recommended)"),
253        }
254    }
255}
256
257/// Prompts for a password: a pick list of the OS keyring providers, then
258/// a custom command, then a raw value. See [`prompt_choice`] for how the
259/// entry is resolved.
260#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
261pub fn prompt_secret(label: &str, key_default: &str) -> PromptResult<SecretChoice> {
262    let mut choices: Vec<Choice> = KeyringProvider::available()
263        .into_iter()
264        .map(Choice::Keyring)
265        .collect();
266    choices.push(Choice::Custom);
267    choices.push(Choice::Raw);
268
269    prompt_choice(label, key_default, choices)
270}
271
272/// Prompts for an API token: a pick list combining the OS keyrings (for
273/// a token the user generated on the provider and stored themselves) and,
274/// when `oauth` is true, the OAuth 2.0 token brokers (which refresh and
275/// print a fresh token on every read), then a custom command and a raw
276/// value. Same aim as [`prompt_secret`] — a command that returns the
277/// token — merging both acquisition paths behind one strategy prompt. The
278/// brokers are hidden unless the service advertises OAuth.
279#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
280pub fn prompt_token(label: &str, key_default: &str, oauth: bool) -> PromptResult<SecretChoice> {
281    let mut choices: Vec<Choice> = KeyringProvider::available()
282        .into_iter()
283        .map(Choice::Keyring)
284        .collect();
285    if oauth {
286        choices.extend(TokenBroker::available().into_iter().map(Choice::Broker));
287    }
288    choices.push(Choice::Custom);
289    choices.push(Choice::Raw);
290
291    prompt_choice(label, key_default, choices)
292}
293
294/// Renders the pick list and resolves the selection into a
295/// [`SecretChoice`].
296///
297/// `label` names the secret ("IMAP password", "API token") and
298/// `key_default` seeds the entry/account prompt. A keyring entry is used
299/// **verbatim** (no namespace), so a pre-existing secret is read exactly
300/// as named; the value must already be stored under it, and a missing one
301/// surfaces when the caller tests the account right after.
302#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
303fn prompt_choice(
304    label: &str,
305    key_default: &str,
306    choices: Vec<Choice>,
307) -> PromptResult<SecretChoice> {
308    match prompt::item(format!("{label} strategy:"), choices, None)? {
309        Choice::Keyring(provider) => {
310            let key = prompt::text(
311                format!("{label} keyring entry:"),
312                Some(key_default.to_owned()),
313            )?;
314
315            Ok(SecretChoice::Command(provider.read_command(None, &key)))
316        }
317        Choice::Broker(broker) => {
318            let account = prompt::text(format!("{label} account:"), Some(key_default.to_owned()))?;
319
320            Ok(SecretChoice::Command(broker.read_command(&account)))
321        }
322        Choice::Custom => {
323            let command = prompt::text(format!("{label} shell command:"), None::<String>)?;
324            Ok(SecretChoice::Shell(command))
325        }
326        Choice::Raw => {
327            let secret = prompt::password(format!("{label}:"), format!("Confirm {label}:"))?;
328            Ok(SecretChoice::Raw(secret))
329        }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn keyring_read_command_uses_the_entry_verbatim_without_a_namespace() {
339        let entry = "pimalaya/posteo";
340        assert_eq!(
341            KeyringProvider::Pass.read_command(None, entry),
342            ["pass", "show", "pimalaya/posteo"],
343        );
344        assert_eq!(
345            KeyringProvider::SecretTool.read_command(None, entry),
346            ["secret-tool", "lookup", "account", "pimalaya/posteo"],
347        );
348        assert_eq!(
349            KeyringProvider::Security.read_command(None, entry),
350            [
351                "security",
352                "find-generic-password",
353                "-a",
354                "pimalaya/posteo",
355                "-w"
356            ],
357        );
358        assert_eq!(
359            KeyringProvider::KwalletQuery.read_command(None, entry),
360            ["kwallet-query", "-r", "pimalaya/posteo", "kdewallet"],
361        );
362    }
363
364    #[test]
365    fn keyring_read_command_namespaces_the_entry_when_a_service_is_given() {
366        let (service, account) = (Some("ortie"), "acme");
367        assert_eq!(
368            KeyringProvider::Pass.read_command(service, account),
369            ["pass", "show", "ortie/acme"],
370        );
371        assert_eq!(
372            KeyringProvider::SecretTool.read_command(service, account),
373            [
374                "secret-tool",
375                "lookup",
376                "service",
377                "ortie",
378                "account",
379                "acme"
380            ],
381        );
382        assert_eq!(
383            KeyringProvider::Security.read_command(service, account),
384            [
385                "security",
386                "find-generic-password",
387                "-s",
388                "ortie",
389                "-a",
390                "acme",
391                "-w"
392            ],
393        );
394    }
395
396    #[test]
397    fn broker_read_command_targets_the_account_per_broker() {
398        assert_eq!(
399            TokenBroker::Ortie.read_command("acme"),
400            ["ortie", "token", "show", "-a", "acme"],
401        );
402        assert_eq!(
403            TokenBroker::Pizauth.read_command("acme"),
404            ["pizauth", "show", "acme"]
405        );
406        assert_eq!(
407            TokenBroker::Oama.read_command("me@acme.test"),
408            ["oama", "access", "me@acme.test"],
409        );
410    }
411
412    #[test]
413    fn available_lists_are_non_empty() {
414        assert!(!TokenBroker::available().is_empty());
415        if cfg!(unix) {
416            assert!(!KeyringProvider::available().is_empty());
417        }
418    }
419}