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/// Whether the keyring is left alone: tests against a local server
31/// (`SIOUL_TEST_PASSWORD`), and the tests of the crates built on this one
32/// (the `no-keyring` feature); never in Sioul's builds, whose binaries the
33/// final pass reads for this mark. On Windows, the one test of this crate
34/// that checks the Credential Manager on purpose reaches it whatever the
35/// feature says, on its own thread (`with_real_keyring`).
36pub(crate) fn testing() -> bool {
37    #[cfg(all(test, windows))]
38    if REAL_KEYRING.with(std::cell::Cell::get) {
39        return false;
40    }
41    #[cfg(feature = "insecure-test-tls")]
42    if std::env::var_os("SIOUL_TEST_PASSWORD").is_some() {
43        return true;
44    }
45    no_keyring()
46}
47
48#[cfg(test)]
49thread_local! {
50    /// Set by `with_real_keyring`, on its thread alone.
51    static REAL_KEYRING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
52}
53
54/// `f`, with the system's keyring reached on this thread on Windows alone:
55/// the Windows job's own check of the Credential Manager
56/// (`microsoft::tests::windows_keeps_a_4_kb_token`). CI tests core, sync and
57/// the command line in one command (build.yml), and Cargo then turns the
58/// command line's `no-keyring` on for this crate's tests too (run
59/// 39216227853: the test found nothing kept, the feature keeping nothing).
60/// Anywhere else the keyring is the person's own, and nothing changes; on
61/// every other thread neither. In this crate's own tests only: never in a
62/// build of Sioul, nor in another crate's tests.
63#[cfg(test)]
64pub(crate) fn with_real_keyring<T>(f: impl FnOnce() -> T) -> T {
65    struct Reset;
66    impl Drop for Reset {
67        fn drop(&mut self) {
68            REAL_KEYRING.with(|real| real.set(false));
69        }
70    }
71    REAL_KEYRING.with(|real| real.set(true));
72    let _reset = Reset;
73    f()
74}
75
76/// The `no-keyring` feature, its mark kept in the binary for the final pass to find.
77#[cfg(feature = "no-keyring")]
78fn no_keyring() -> bool {
79    !std::hint::black_box("SIOUL_TEST_NO_KEYRING: this build keeps nothing in the keyring").is_empty()
80}
81
82#[cfg(not(feature = "no-keyring"))]
83fn no_keyring() -> bool {
84    false
85}
86
87/// The password kept for the account. A mail account signed in with Google
88/// has none: what stands for it is its grant's mark (`sasl::OAuth::mark`),
89/// unused by the servers, which are given an access token instead; no grant
90/// kept here, Google's sign-in is asked again.
91pub fn password(account: &Account) -> Result<String, SyncError> {
92    if let Some(oauth) = crate::sasl::OAuth::of(account) {
93        return oauth.mark(account.login().ok_or(SyncError::NoServer)?);
94    }
95    // Tests against a local server leave the keyring alone (never in Sioul's builds).
96    #[cfg(feature = "insecure-test-tls")]
97    if let Ok(password) = std::env::var("SIOUL_TEST_PASSWORD") {
98        return Ok(password);
99    }
100    if testing() {
101        return Err(SyncError::NoPassword);
102    }
103    match entry(account)?.get_password() {
104        Ok(password) => Ok(password),
105        Err(keyring::Error::NoEntry) => Err(SyncError::NoPassword),
106        Err(e) => Err(SyncError::Keyring(e.to_string())),
107    }
108}
109
110fn passphrase_entry(fingerprint: &str) -> Result<Entry, SyncError> {
111    Entry::new(SERVICE, &format!("OpenPGP key {fingerprint}")).map_err(|e| SyncError::Keyring(e.to_string()))
112}
113
114/// The passphrase of your OpenPGP key, when the keyring keeps it.
115pub fn pgp_passphrase(fingerprint: &str) -> Option<String> {
116    #[cfg(feature = "insecure-test-tls")]
117    if let Ok(passphrase) = std::env::var("SIOUL_TEST_PASSWORD") {
118        return Some(passphrase);
119    }
120    // This crate's own tests, and those built on it, leave the keyring alone:
121    // their stand-in keys have no passphrase.
122    if cfg!(test) || testing() {
123        return None;
124    }
125    passphrase_entry(fingerprint).ok()?.get_password().ok()
126}
127
128/// In a test build with `SIOUL_TEST_PASSWORD`, the passphrase every key gets
129/// (the keyring is left alone); None in Sioul's builds.
130pub fn test_passphrase() -> Option<String> {
131    #[cfg(feature = "insecure-test-tls")]
132    if let Ok(passphrase) = std::env::var("SIOUL_TEST_PASSWORD") {
133        return Some(passphrase);
134    }
135    None
136}
137
138/// Keeps the passphrase of your OpenPGP key in the keyring.
139pub fn save_pgp_passphrase(fingerprint: &str, passphrase: &str) -> Result<(), SyncError> {
140    if testing() {
141        return Ok(());
142    }
143    passphrase_entry(fingerprint)?.set_password(passphrase).map_err(|e| SyncError::Keyring(e.to_string()))
144}
145
146/// A secret kept under a name of Sioul's ("Bitwarden device token for …").
147pub fn named(name: &str) -> Option<String> {
148    if testing() {
149        return None;
150    }
151    Entry::new(SERVICE, name).ok()?.get_password().ok()
152}
153
154/// Keeps a secret under a name of Sioul's.
155pub fn save_named(name: &str, secret: &str) -> Result<(), SyncError> {
156    if testing() {
157        return Ok(());
158    }
159    Entry::new(SERVICE, name).and_then(|e| e.set_password(secret)).map_err(|e| SyncError::Keyring(e.to_string()))
160}
161
162/// Forgets a secret kept under a name of Sioul's; nothing to do if there was none.
163pub fn forget_named(name: &str) -> Result<(), SyncError> {
164    if testing() {
165        return Ok(());
166    }
167    match Entry::new(SERVICE, name).and_then(|e| e.delete_credential()) {
168        Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
169        Err(e) => Err(SyncError::Keyring(e.to_string())),
170    }
171}
172
173/// The most a keyring entry holds on every system: Windows' Credential
174/// Manager keeps 2560 bytes (`CRED_MAX_CREDENTIAL_BLOB_SIZE`), and a text
175/// kept as a password there takes two bytes a character (UTF-16). A secret
176/// kept as bytes (`save_named_bytes`) takes its UTF-8 length; this leaves a margin.
177pub const ENTRY_BYTES: usize = 2400;
178
179/// A secret kept as bytes under a name of Sioul's (`save_named_bytes`):
180/// None when there is none, Err when the keyring did not answer.
181pub fn named_bytes(name: &str) -> Result<Option<Vec<u8>>, SyncError> {
182    if testing() {
183        return Ok(None);
184    }
185    match Entry::new(SERVICE, name).and_then(|e| e.get_secret()) {
186        Ok(bytes) => Ok(Some(bytes)),
187        Err(keyring::Error::NoEntry) => Ok(None),
188        Err(e) => Err(SyncError::Keyring(e.to_string())),
189    }
190}
191
192/// Keeps a secret as bytes (UTF-8 as it is, not Windows' UTF-16), replacing
193/// any older one: at most `ENTRY_BYTES`, which a longer secret is split
194/// into by its owner (`microsoft.rs` does for its long tokens).
195pub fn save_named_bytes(name: &str, secret: &[u8]) -> Result<(), SyncError> {
196    if testing() {
197        return Ok(());
198    }
199    if secret.len() > ENTRY_BYTES {
200        return Err(SyncError::Keyring(format!("{name}: {} bytes, more than an entry holds everywhere ({ENTRY_BYTES})", secret.len())));
201    }
202    Entry::new(SERVICE, name).and_then(|e| e.set_secret(secret)).map_err(|e| SyncError::Keyring(e.to_string()))
203}
204
205/// Forgets the account's password; nothing to do if there was none.
206pub fn forget(account: &Account) -> Result<(), SyncError> {
207    if testing() {
208        return Ok(());
209    }
210    match entry(account)?.delete_credential() {
211        Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
212        Err(e) => Err(SyncError::Keyring(e.to_string())),
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    /// CI's Windows job tests core, sync and the command line in one
221    /// command, which turns the command line's `no-keyring` on for this
222    /// crate's tests too (run 39216227853, `windows_keeps_a_4_kb_token`:
223    /// "the grant written did not read back whole"). The one test that
224    /// checks Windows' Credential Manager reaches it there, on its own
225    /// thread; on Linux and macOS, and on any other thread, the keyring is
226    /// left as the build says, whatever that test asks.
227    #[test]
228    fn only_the_windows_test_of_the_keyring_reaches_it_and_only_there() {
229        let built = testing();
230        let inside = with_real_keyring(|| (testing(), std::thread::spawn(testing).join().unwrap()));
231        assert_eq!(inside, (built && !cfg!(windows), built), "on Windows its own thread alone reaches the keyring");
232        assert_eq!(testing(), built, "given back after");
233    }
234}