Skip to main content

hasp_backend_keyring/
lib.rs

1//! `keyring://` backend for hasp.
2//!
3//! Grammar: `keyring://service/account[?target=...]`
4//!
5//! - `service` (host): Required. Maps to the keyring service name.
6//! - `account` (first path segment): Required. Maps to the keyring account name.
7//! - `target` (query parameter, optional): Platform-specific modifier.
8//!
9//! Supported operations:
10//! - `get`, `put`, `exists`, `delete`: full support
11//! - `list`: `UnsupportedOperation`
12//!
13//! Platform-specific failure modes:
14//! - **macOS**: Keychain ACL may reject access after binary re-sign or move.
15//! - **Windows**: Credentials may roam across AD-joined machines.
16//! - **Linux (Secret Service)**: Requires a DBus session bus; fails in
17//!   headless containers without a secrets daemon.
18//! - **Linux (Keyutils)**: Alternative to Secret Service; not enabled by default.
19
20use hasp_core::{Backend, BackendFailureKind, Entry, Error, ExposeSecret, SecretString};
21use std::collections::HashMap;
22use std::sync::OnceLock;
23use url::Url;
24
25/// URL shape for `keyring://` addresses.
26///
27/// Host = service, first path segment = account, optional `?target=...` query.
28pub struct KeyringUrl {
29    pub service: String,
30    pub account: String,
31    pub target: Option<String>,
32}
33
34impl TryFrom<&Url> for KeyringUrl {
35    type Error = Error;
36
37    fn try_from(url: &Url) -> Result<Self, Self::Error> {
38        if url.scheme() != "keyring" {
39            return Err(Error::InvalidUrl("expected keyring:// scheme".into()));
40        }
41        let service = url
42            .host_str()
43            .ok_or_else(|| Error::InvalidUrl("keyring:// requires a host (service)".into()))?
44            .to_owned();
45        if service.is_empty() {
46            return Err(Error::InvalidUrl(
47                "keyring:// service must not be empty".into(),
48            ));
49        }
50
51        let mut segments = url.path_segments().into_iter().flatten();
52        let account = segments
53            .next()
54            .ok_or_else(|| Error::InvalidUrl("keyring:// requires an account path segment".into()))?
55            .to_owned();
56        if account.is_empty() {
57            return Err(Error::InvalidUrl(
58                "keyring:// account must not be empty".into(),
59            ));
60        }
61        if segments.next().is_some() {
62            return Err(Error::InvalidUrl(
63                "keyring:// supports exactly one path segment (account)".into(),
64            ));
65        }
66
67        let target = url
68            .query_pairs()
69            .find(|(k, _)| k == "target")
70            .map(|(_, v)| v.into_owned());
71
72        for (k, _) in url.query_pairs() {
73            if k != "target" {
74                return Err(Error::InvalidUrl(format!(
75                    "keyring:// unknown query parameter: {}",
76                    k
77                )));
78            }
79        }
80
81        Ok(KeyringUrl {
82            service,
83            account,
84            target,
85        })
86    }
87}
88
89/// OS keyring backend.
90///
91/// Initialization is lazy and thread-safe. The first operation triggers
92/// per-platform store selection via `keyring_core::set_default_store`. If a
93/// default store has already been set (for example, by a test harness), the
94/// backend will not replace it.
95pub struct KeyringBackend;
96
97impl KeyringBackend {
98    /// Create a new keyring backend.
99    ///
100    /// The actual platform store initialization happens lazily on the first
101    /// secret operation.
102    pub fn new() -> Self {
103        Self
104    }
105}
106
107static INIT: OnceLock<Result<(), String>> = OnceLock::new();
108
109fn ensure_init() -> Result<(), Error> {
110    INIT.get_or_init(|| {
111        // If a default store is already present (e.g., injected by tests),
112        // do not replace it.
113        if keyring_core::get_default_store().is_some() {
114            return Ok(());
115        }
116
117        let result = create_platform_store();
118        match result {
119            Ok(store) => {
120                keyring_core::set_default_store(store);
121                Ok(())
122            }
123            Err(e) => Err(format!("keyring store initialization failed: {e}")),
124        }
125    })
126    .clone()
127    .map_err(|msg| Error::Backend {
128        scheme: "keyring",
129        kind: BackendFailureKind::Permanent,
130        message: msg,
131    })
132}
133
134#[cfg(target_os = "macos")]
135fn create_platform_store(
136) -> Result<std::sync::Arc<keyring_core::CredentialStore>, keyring_core::Error> {
137    apple_native_keyring_store::Store::new()
138        .map(|s| s as std::sync::Arc<keyring_core::CredentialStore>)
139}
140
141#[cfg(target_os = "windows")]
142fn create_platform_store(
143) -> Result<std::sync::Arc<keyring_core::CredentialStore>, keyring_core::Error> {
144    windows_native_keyring_store::Store::new()
145        .map(|s| s as std::sync::Arc<keyring_core::CredentialStore>)
146}
147
148#[cfg(target_os = "linux")]
149fn create_platform_store(
150) -> Result<std::sync::Arc<keyring_core::CredentialStore>, keyring_core::Error> {
151    dbus_secret_service_keyring_store::Store::new()
152        .map(|s| s as std::sync::Arc<keyring_core::CredentialStore>)
153}
154
155#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
156fn create_platform_store(
157) -> Result<std::sync::Arc<keyring_core::CredentialStore>, keyring_core::Error> {
158    Err(keyring_core::Error::NotSupportedByStore(
159        "unsupported platform for keyring backend".into(),
160    ))
161}
162
163fn map_keyring_error(err: keyring_core::Error, operation: &'static str) -> Error {
164    use keyring_core::Error as K;
165    match err {
166        K::NoEntry => Error::NotFound("keyring entry not found".into()),
167        K::NoStorageAccess(_) => Error::Backend {
168            scheme: "keyring",
169            kind: BackendFailureKind::Permanent,
170            message: "keyring locked or unavailable".into(),
171        },
172        K::PlatformFailure(_) => Error::Backend {
173            scheme: "keyring",
174            kind: BackendFailureKind::Transient,
175            message: format!("keyring platform failure: {err}"),
176        },
177        K::Ambiguous(_) => Error::Backend {
178            scheme: "keyring",
179            kind: BackendFailureKind::Permanent,
180            message: "multiple keyring entries match".into(),
181        },
182        K::NotSupportedByStore(_) => Error::UnsupportedOperation {
183            scheme: "keyring",
184            operation,
185        },
186        K::TooLong(attr, limit) => Error::Backend {
187            scheme: "keyring",
188            kind: BackendFailureKind::Permanent,
189            message: format!("keyring value too long: '{attr}' exceeds {limit}"),
190        },
191        K::BadDataFormat(_, underlying) => Error::Backend {
192            scheme: "keyring",
193            kind: BackendFailureKind::Permanent,
194            message: format!("keyring data format error: {underlying}"),
195        },
196        K::BadEncoding(_) => Error::Backend {
197            scheme: "keyring",
198            kind: BackendFailureKind::Permanent,
199            message: "keyring password is not valid UTF-8".into(),
200        },
201        K::BadStoreFormat(reason) => Error::Backend {
202            scheme: "keyring",
203            kind: BackendFailureKind::Permanent,
204            message: format!("keyring store format error: {reason}"),
205        },
206        K::Invalid(attr, reason) => Error::Backend {
207            scheme: "keyring",
208            kind: BackendFailureKind::Permanent,
209            message: format!("keyring invalid '{attr}': {reason}"),
210        },
211        K::NoDefaultStore => Error::Backend {
212            scheme: "keyring",
213            kind: BackendFailureKind::Permanent,
214            message: "keyring default store not initialized".into(),
215        },
216        _ => Error::Backend {
217            scheme: "keyring",
218            kind: BackendFailureKind::Permanent,
219            message: format!("keyring unexpected error: {err}"),
220        },
221    }
222}
223
224fn make_entry(url: &KeyringUrl) -> Result<keyring_core::Entry, Error> {
225    if let Some(ref target) = url.target {
226        let modifiers = HashMap::from([("target", target.as_str())]);
227        keyring_core::Entry::new_with_modifiers(&url.service, &url.account, &modifiers)
228            .map_err(|e| map_keyring_error(e, "get"))
229    } else {
230        keyring_core::Entry::new(&url.service, &url.account)
231            .map_err(|e| map_keyring_error(e, "get"))
232    }
233}
234
235impl Default for KeyringBackend {
236    fn default() -> Self {
237        Self::new()
238    }
239}
240
241impl Backend for KeyringBackend {
242    fn scheme(&self) -> &'static str {
243        "keyring"
244    }
245
246    fn validate(&self, url: &Url) -> Result<(), Error> {
247        KeyringUrl::try_from(url).map(|_| ())
248    }
249
250    fn get(&self, url: &Url) -> Result<SecretString, Error> {
251        ensure_init()?;
252        let keyring_url = KeyringUrl::try_from(url)?;
253        let entry = make_entry(&keyring_url)?;
254        let password = entry
255            .get_password()
256            .map_err(|e| map_keyring_error(e, "get"))?;
257        Ok(SecretString::new(password.into()))
258    }
259
260    fn put(&self, url: &Url, value: &SecretString) -> Result<(), Error> {
261        ensure_init()?;
262        let keyring_url = KeyringUrl::try_from(url)?;
263        let entry = make_entry(&keyring_url)?;
264        entry
265            .set_password(value.expose_secret())
266            .map_err(|e| map_keyring_error(e, "put"))?;
267        Ok(())
268    }
269
270    fn list(&self, _url: &Url) -> Result<Vec<Entry>, Error> {
271        Err(Error::UnsupportedOperation {
272            scheme: "keyring",
273            operation: "list",
274        })
275    }
276
277    fn delete(&self, url: &Url) -> Result<(), Error> {
278        ensure_init()?;
279        let keyring_url = KeyringUrl::try_from(url)?;
280        let entry = make_entry(&keyring_url)?;
281        entry
282            .delete_credential()
283            .map_err(|e| map_keyring_error(e, "delete"))?;
284        Ok(())
285    }
286
287    fn exists(&self, url: &Url) -> Result<bool, Error> {
288        ensure_init()?;
289        let keyring_url = KeyringUrl::try_from(url)?;
290        let entry = make_entry(&keyring_url)?;
291        match entry.get_password() {
292            Ok(_) => Ok(true),
293            Err(keyring_core::Error::NoEntry) => Ok(false),
294            Err(e) => Err(map_keyring_error(e, "exists")),
295        }
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn parse_valid_url() {
305        let url = Url::parse("keyring://my-app/db-pass").unwrap();
306        let k = KeyringUrl::try_from(&url).unwrap();
307        assert_eq!(k.service, "my-app");
308        assert_eq!(k.account, "db-pass");
309        assert!(k.target.is_none());
310    }
311
312    #[test]
313    fn parse_url_with_target() {
314        let url = Url::parse("keyring://my-app/db-pass?target=prod").unwrap();
315        let k = KeyringUrl::try_from(&url).unwrap();
316        assert_eq!(k.service, "my-app");
317        assert_eq!(k.account, "db-pass");
318        assert_eq!(k.target, Some("prod".into()));
319    }
320
321    #[test]
322    fn parse_missing_service_fails() {
323        let url = Url::parse("keyring:///account").unwrap();
324        assert!(KeyringUrl::try_from(&url).is_err());
325    }
326
327    #[test]
328    fn parse_missing_account_fails() {
329        let url = Url::parse("keyring://service").unwrap();
330        assert!(KeyringUrl::try_from(&url).is_err());
331    }
332
333    #[test]
334    fn parse_extra_path_fails() {
335        let url = Url::parse("keyring://service/account/extra").unwrap();
336        assert!(KeyringUrl::try_from(&url).is_err());
337    }
338
339    #[test]
340    fn parse_unknown_query_fails() {
341        let url = Url::parse("keyring://service/account?unknown=val").unwrap();
342        assert!(KeyringUrl::try_from(&url).is_err());
343    }
344}