Skip to main content

sioul_sync/
secret.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Passwords live in the system keyring, never in a file Sioul writes: the
5//! Secret Service on Linux (KWallet, GNOME Keyring), the Keychain on macOS,
6//! the Credential Manager on Windows (Cargo.toml chooses the backend per
7//! system). Each is filed under the service `sioul` and `<login> on
8//! <server>`, so it is easy to find, check or remove there.
9
10use crate::SyncError;
11use keyring::Entry;
12use sioul_core::config::Account;
13
14const SERVICE: &str = "sioul";
15
16fn entry(account: &Account) -> Result<Entry, SyncError> {
17    let host = account.host.as_deref().ok_or(SyncError::NoServer)?;
18    let login = account.login().ok_or(SyncError::NoServer)?;
19    Entry::new(SERVICE, &format!("{login} on {host}")).map_err(|e| SyncError::Keyring(e.to_string()))
20}
21
22/// Keeps the password for the account, replacing any older one.
23pub fn save(account: &Account, password: &str) -> Result<(), SyncError> {
24    if testing() {
25        return Ok(());
26    }
27    entry(account)?.set_password(password).map_err(|e| SyncError::Keyring(e.to_string()))
28}
29
30/// Tests against a local server leave the keyring alone (never in Sioul's builds).
31fn testing() -> bool {
32    #[cfg(feature = "insecure-test-tls")]
33    if std::env::var_os("SIOUL_TEST_PASSWORD").is_some() {
34        return true;
35    }
36    false
37}
38
39/// The password kept for the account. A mail account signed in with Google
40/// has none: what stands for it is its grant's mark (`sasl::OAuth::mark`),
41/// unused by the servers, which are given an access token instead; no grant
42/// kept here, Google's sign-in is asked again.
43pub fn password(account: &Account) -> Result<String, SyncError> {
44    if let Some(oauth) = crate::sasl::OAuth::of(account) {
45        return oauth.mark(account.login().ok_or(SyncError::NoServer)?);
46    }
47    // Tests against a local server leave the keyring alone (never in Sioul's builds).
48    #[cfg(feature = "insecure-test-tls")]
49    if let Ok(password) = std::env::var("SIOUL_TEST_PASSWORD") {
50        return Ok(password);
51    }
52    match entry(account)?.get_password() {
53        Ok(password) => Ok(password),
54        Err(keyring::Error::NoEntry) => Err(SyncError::NoPassword),
55        Err(e) => Err(SyncError::Keyring(e.to_string())),
56    }
57}
58
59fn passphrase_entry(fingerprint: &str) -> Result<Entry, SyncError> {
60    Entry::new(SERVICE, &format!("OpenPGP key {fingerprint}")).map_err(|e| SyncError::Keyring(e.to_string()))
61}
62
63/// The passphrase of your OpenPGP key, when the keyring keeps it.
64pub fn pgp_passphrase(fingerprint: &str) -> Option<String> {
65    #[cfg(feature = "insecure-test-tls")]
66    if let Ok(passphrase) = std::env::var("SIOUL_TEST_PASSWORD") {
67        return Some(passphrase);
68    }
69    passphrase_entry(fingerprint).ok()?.get_password().ok()
70}
71
72/// In a test build with `SIOUL_TEST_PASSWORD`, the passphrase every key gets
73/// (the keyring is left alone); None in Sioul's builds.
74pub fn test_passphrase() -> Option<String> {
75    #[cfg(feature = "insecure-test-tls")]
76    if let Ok(passphrase) = std::env::var("SIOUL_TEST_PASSWORD") {
77        return Some(passphrase);
78    }
79    None
80}
81
82/// Keeps the passphrase of your OpenPGP key in the keyring.
83pub fn save_pgp_passphrase(fingerprint: &str, passphrase: &str) -> Result<(), SyncError> {
84    if testing() {
85        return Ok(());
86    }
87    passphrase_entry(fingerprint)?.set_password(passphrase).map_err(|e| SyncError::Keyring(e.to_string()))
88}
89
90/// A secret kept under a name of Sioul's ("Bitwarden device token for …").
91pub fn named(name: &str) -> Option<String> {
92    if testing() {
93        return None;
94    }
95    Entry::new(SERVICE, name).ok()?.get_password().ok()
96}
97
98/// Keeps a secret under a name of Sioul's.
99pub fn save_named(name: &str, secret: &str) -> Result<(), SyncError> {
100    if testing() {
101        return Ok(());
102    }
103    Entry::new(SERVICE, name).and_then(|e| e.set_password(secret)).map_err(|e| SyncError::Keyring(e.to_string()))
104}
105
106/// Forgets a secret kept under a name of Sioul's; nothing to do if there was none.
107pub fn forget_named(name: &str) -> Result<(), SyncError> {
108    if testing() {
109        return Ok(());
110    }
111    match Entry::new(SERVICE, name).and_then(|e| e.delete_credential()) {
112        Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
113        Err(e) => Err(SyncError::Keyring(e.to_string())),
114    }
115}
116
117/// Forgets the account's password; nothing to do if there was none.
118pub fn forget(account: &Account) -> Result<(), SyncError> {
119    if testing() {
120        return Ok(());
121    }
122    match entry(account)?.delete_credential() {
123        Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
124        Err(e) => Err(SyncError::Keyring(e.to_string())),
125    }
126}