Skip to main content

origin_secrets_system/
lib.rs

1//! Credentials in the operating system's own credential store.
2//!
3//! | Platform | Backend |
4//! | --- | --- |
5//! | macOS | Keychain |
6//! | Windows | Credential Manager |
7//! | Linux | Secret Service (D-Bus) |
8//!
9//! The backend is chosen by the `keyring` crate at compile time. Callers see only
10//! [`origin_secrets::SecretStore`] and never learn which one they got.
11
12use async_trait::async_trait;
13use keyring::Entry;
14use origin_domain::{AppError, Result};
15use origin_secrets::{Secret, SecretKey, SecretStore};
16
17/// System credential store, scoped to one application.
18///
19/// `service_prefix` keeps two Origin applications on the same machine from reading
20/// each other's credentials.
21#[derive(Debug, Clone)]
22pub struct SystemSecretStore {
23    service_prefix: String,
24}
25
26impl SystemSecretStore {
27    pub fn new(service_prefix: impl Into<String>) -> Self {
28        Self {
29            service_prefix: service_prefix.into(),
30        }
31    }
32
33    fn entry(&self, key: &SecretKey) -> Result<Entry> {
34        let service = format!("{}.{}", self.service_prefix, key.namespace());
35        Entry::new(&service, key.name()).map_err(to_app_error)
36    }
37}
38
39#[async_trait]
40impl SecretStore for SystemSecretStore {
41    async fn get(&self, key: &SecretKey) -> Result<Option<Secret>> {
42        let entry = self.entry(key)?;
43        blocking(move || match entry.get_password() {
44            Ok(password) => Ok(Some(Secret::new(password))),
45            // A missing credential is a normal outcome, not a failure.
46            Err(keyring::Error::NoEntry) => Ok(None),
47            Err(error) => Err(to_app_error(error)),
48        })
49        .await
50    }
51
52    async fn set(&self, key: &SecretKey, value: Secret) -> Result<()> {
53        let entry = self.entry(key)?;
54        blocking(move || entry.set_password(value.expose()).map_err(to_app_error)).await
55    }
56
57    async fn delete(&self, key: &SecretKey) -> Result<()> {
58        let entry = self.entry(key)?;
59        blocking(move || match entry.delete_credential() {
60            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
61            Err(error) => Err(to_app_error(error)),
62        })
63        .await
64    }
65}
66
67/// Keychain access is blocking and can show a system prompt, so it must not run on
68/// the async runtime's worker threads.
69async fn blocking<T, F>(operation: F) -> Result<T>
70where
71    T: Send + 'static,
72    F: FnOnce() -> Result<T> + Send + 'static,
73{
74    tokio::task::spawn_blocking(operation)
75        .await
76        .map_err(|error| AppError::internal(format!("credential task failed: {error}")))?
77}
78
79/// Translates keyring failures into the Origin error model.
80///
81/// A denied keychain prompt is an authentication problem the user can fix, not an
82/// internal error — the UI needs that distinction to show the right message.
83fn to_app_error(error: keyring::Error) -> AppError {
84    match error {
85        keyring::Error::NoEntry => AppError::Authentication("no credential stored".to_owned()),
86        keyring::Error::NoDefaultStore => {
87            AppError::Configuration("no credential store available on this system".to_owned())
88        }
89        keyring::Error::Ambiguous(_) => AppError::Storage(
90            "several credentials matched — the credential store needs manual cleanup".to_owned(),
91        ),
92        keyring::Error::PlatformFailure(inner) => {
93            AppError::Storage(format!("credential store unavailable: {inner}"))
94        }
95        keyring::Error::NoStorageAccess(inner) => {
96            AppError::Permission(format!("credential store access denied: {inner}"))
97        }
98        other => AppError::Storage(other.to_string()),
99    }
100}