dbus_secret_service/
lib.rs

1// Copyright 2016-2024 dbus-secret-service Contributors
2//
3// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
4// http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
5// http://opensource.org/licenses/MIT>, at your option. This file may not be
6// copied, modified, or distributed except according to those terms.
7
8#![allow(clippy::needless_doctest_main)]
9
10//! # Dbus-Based access to the Secret Service
11//!
12//! This library implements a rust wrapper that uses libdbus to access the Secret Service.
13//! Its use requires that a session `DBus` is available on the target machine.
14//!
15//! ## About the Secret Service
16//!
17//! <https://standards.freedesktop.org/secret-service/>
18//!
19//! The Secret Service provides a secure mechanism for persistent storage of data.
20//! Both the Gnome keyring and the KWallet implement the Secret Service API.
21//!
22//! ## Basic Usage
23//!
24//! ```
25//! use dbus_secret_service::SecretService;
26//! use dbus_secret_service::EncryptionType;
27//! use std::collections::HashMap;
28//!
29//! fn main() {
30//!    // initialize secret service (dbus connection and encryption session)
31//!    let ss = SecretService::connect(EncryptionType::Plain).unwrap();
32//!
33//!    // get default collection
34//!    let collection = ss.get_default_collection().unwrap();
35//!
36//!    let mut properties = HashMap::new();
37//!    properties.insert("test", "test_value");
38//!
39//!    //create new item
40//!    collection.create_item(
41//!        "test_label", // label
42//!        properties,
43//!        b"test_secret", //secret
44//!        false, // replace item with same attributes
45//!        "text/plain" // secret content type
46//!    ).unwrap();
47//!
48//!    // search items by properties
49//!    let search_items = ss.search_items(
50//!        HashMap::from([("test", "test_value")])
51//!    ).unwrap();
52//!
53//!    // retrieve one item, first by checking the unlocked items
54//!    let item = match search_items.unlocked.first() {
55//!        Some(item) => item,
56//!        None => {
57//!            // if there aren't any, check the locked items and unlock the first one
58//!            let locked_item = search_items
59//!                .locked
60//!                .first()
61//!                .expect("Search didn't return any items!");
62//!            locked_item.unlock().unwrap();
63//!            locked_item
64//!        }
65//!    };
66//!
67//!    // retrieve secret from the item
68//!    let secret = item.get_secret().unwrap();
69//!    assert_eq!(secret, b"test_secret");
70//!
71//!    // delete item (deletes the dbus object, not the struct instance)
72//!    item.delete().unwrap()
73//! }
74//! ```
75//!
76//! ## Overview of this library:
77//!
78//! ### Entry point
79//! The entry point for this library is the [`SecretService`] struct. Creating an instance
80//! of this structure will initialize the dbus connection and create a session with the
81//! Secret Service.
82//!
83//! ```
84//! # use dbus_secret_service::SecretService;
85//! # use dbus_secret_service::EncryptionType;
86//! # fn call() {
87//! SecretService::connect(EncryptionType::Plain).unwrap();
88//! # }
89//! ```
90//! A session started with `EncryptionType::Plain` does not obscure the content
91//! of secrets in memory when sending them to and from the Secret Service.  These
92//! secrets _are_ encrypted by the Secret Service when put into its secure store.
93//!
94//! If you have specified a crypto feature (`crypto-rust` or `crypto-openssl`),
95//! then you can use `EncryptionType:Dh` to force Diffie-Hellman shared key encryption
96//! of secrets in memory when they are being sent to and received from the Secret Service.
97//!
98//! Once you have created a `SecretService` struct, you can use it to search for items,
99//! connect to the default collection of items, and to create new collections. The lifetimes
100//! of all the collection and item objects you retrieve from the service are tied to
101//! the service, so they cannot outlive the service instance. This restriction will
102//! be enforced by the Rust compiler.
103//!
104//! ### Collections and Items
105//! The Secret Service API organizes secrets into collections and holds each secret
106//! in an item.
107//!
108//! Items consist of a label, attributes, and the secret. The most common way to find
109//! an item is a search by attributes.
110//!
111//! While it's possible to create new collections, most users will simply create items
112//! within the default collection.
113//!
114//! ### Actions overview
115//! The most common supported actions are `create`, `get`, `search`, and `delete` for
116//! `Collections` and `Items`. For more specifics and exact method names, please see
117//! each structure's documentation.
118//!
119//! In addition, `set` and `get` actions are available for secrets contained in an `Item`.
120//!
121//! ## Headless usage
122//!
123//! If you must use the secret-service on a headless linux box,
124//! be aware that there are known issues with getting
125//! dbus and secret-service and the gnome keyring
126//! to work properly in headless environments.
127//! For a quick workaround, look at how this project's
128//! [CI workflow](https://github.com/hwchen/keyring-rs/blob/master/.github/workflows/ci.yaml)
129//! starts the Gnome keyring unlocked with a known password;
130//! a similar solution is also documented in the
131//! [Python Keyring docs](https://pypi.org/project/keyring/)
132//! (search for "Using Keyring on headless Linux systems").
133//! The following `bash` function may be helpful:
134//!
135//! ```shell
136//! function unlock-keyring ()
137//! {
138//! read -rsp "Password: " pass
139//! echo -n "$pass" | gnome-keyring-daemon --unlock
140//! unset pass
141//! }
142//! ```
143//!
144//! For an excellent treatment of all the headless dbus issues, see
145//! [this answer on ServerFault](https://serverfault.com/a/906224/79617).
146//!
147use std::collections::HashMap;
148
149use dbus::arg::RefArg;
150pub use dbus::strings::Path;
151use dbus::{
152    arg::{PropMap, Variant},
153    blocking::{Connection, Proxy},
154};
155
156pub use collection::Collection;
157pub use error::Error;
158pub use item::Item;
159use proxy::{new_proxy, service::Service};
160pub use session::EncryptionType;
161use session::Session;
162use ss::{SS_COLLECTION_LABEL, SS_DBUS_PATH};
163
164mod collection;
165mod error;
166mod item;
167mod prompt;
168mod proxy;
169mod session;
170mod ss;
171
172/// Encapsulates a session connected to the Secret Service.
173pub struct SecretService {
174    connection: Connection,
175    session: Session,
176    timeout: Option<u64>,
177}
178
179/// Represents the results of doing a service-wide search.
180///
181/// The returned items are organized in two vectors: one
182/// holds unlocked items and the other holds locked items.
183/// (Reading or writing the secret of a locked item requires
184/// prompting the user interactively for permission.  This
185/// prompting is done by the Secret Service itself.)
186pub struct SearchItemsResult<T> {
187    pub unlocked: Vec<T>,
188    pub locked: Vec<T>,
189}
190
191pub(crate) enum LockAction {
192    Lock,
193    Unlock,
194}
195
196impl SecretService {
197    /// Connect to the DBus and return a new [SecretService] instance.
198    ///
199    /// If this service instance needs to prompt a user for permission to
200    /// access a locked item or collection, it will block indefinitely waiting for
201    /// the user's response  See [connect_with_timeout] if you want
202    /// different behavior.
203    pub fn connect(encryption: EncryptionType) -> Result<Self, Error> {
204        let connection = Connection::new_session()?;
205        let session = Session::new(new_proxy(&connection, SS_DBUS_PATH), encryption)?;
206        Ok(SecretService {
207            connection,
208            session,
209            timeout: None,
210        })
211    }
212
213    /// Connect to the DBus and return a new [SecretService] instance.
214    ///
215    /// If this service instance needs to prompt a user for permission to
216    /// access a locked item or collection,
217    /// it will only block for the given number of seconds,
218    /// after which it will dismiss the prompt and cancel the operation.
219    /// (Specifying 0 for the number of seconds will prevent the prompt
220    /// from appearing at all: the operation will immediately be canceled.)
221    pub fn connect_with_max_prompt_timeout(
222        encryption: EncryptionType,
223        seconds: u64,
224    ) -> Result<Self, Error> {
225        let mut service = Self::connect(encryption)?;
226        service.timeout = Some(seconds);
227        Ok(service)
228    }
229
230    /// Get the service proxy (internal)
231    fn proxy(&self) -> Proxy<'_, &Connection> {
232        new_proxy(&self.connection, SS_DBUS_PATH)
233    }
234
235    /// Get all collections
236    pub fn get_all_collections(&'_ self) -> Result<Vec<Collection<'_>>, Error> {
237        let paths = self.proxy().collections()?;
238        let collections = paths
239            .into_iter()
240            .map(|path| Collection::new(self, path))
241            .collect();
242        Ok(collections)
243    }
244
245    /// Get a collection by alias.
246    ///
247    /// The most common would be the `default` alias, but there
248    /// is also a specific method for getting the collection
249    /// by default alias.
250    pub fn get_collection_by_alias(&'_ self, alias: &str) -> Result<Collection<'_>, Error> {
251        let path = self.proxy().read_alias(alias)?;
252        if path == Path::new("/")? {
253            Err(Error::NoResult)
254        } else {
255            Ok(Collection::new(self, path))
256        }
257    }
258
259    /// Get the default collection.
260    /// (The collection whose alias is `default`)
261    pub fn get_default_collection(&self) -> Result<Collection<'_>, Error> {
262        self.get_collection_by_alias("default")
263    }
264
265    /// Get any collection.
266    /// First tries `default` collection, then `session`
267    /// collection, then the first collection when it
268    /// gets all collections.
269    pub fn get_any_collection(&self) -> Result<Collection<'_>, Error> {
270        self.get_default_collection()
271            .or_else(|_| self.get_collection_by_alias("session"))
272            .or_else(|_| {
273                let mut collections = self.get_all_collections()?;
274                if collections.is_empty() {
275                    Err(Error::NoResult)
276                } else {
277                    Ok(collections.swap_remove(0))
278                }
279            })
280    }
281
282    /// Creates a new collection with a label and an alias.
283    pub fn create_collection(&self, label: &str, alias: &str) -> Result<Collection<'_>, Error> {
284        let mut properties: PropMap = HashMap::new();
285        properties.insert(
286            SS_COLLECTION_LABEL.to_string(),
287            Variant(Box::new(label.to_string()) as Box<dyn RefArg>),
288        );
289        // create a collection returning the collection path and prompt path
290        let (c_path, p_path) = self.proxy().create_collection(properties, alias)?;
291        let created = {
292            if c_path == Path::new("/")? {
293                // no creation path, so prompt
294                self.prompt_for_create(&p_path)?
295            } else {
296                c_path
297            }
298        };
299        Ok(Collection::new(self, created))
300    }
301
302    /// Searches all items by attributes
303    pub fn search_items(
304        &self,
305        attributes: HashMap<&str, &str>,
306    ) -> Result<SearchItemsResult<Item<'_>>, Error> {
307        let (unlocked, locked) = self.proxy().search_items(attributes)?;
308        let result = SearchItemsResult {
309            unlocked: unlocked.into_iter().map(|p| Item::new(self, p)).collect(),
310            locked: locked.into_iter().map(|p| Item::new(self, p)).collect(),
311        };
312        Ok(result)
313    }
314
315    /// Unlock all items in a batch
316    pub fn unlock_all(&self, items: &[&Item<'_>]) -> Result<(), Error> {
317        let paths = items.iter().map(|i| i.path.clone()).collect();
318        self.lock_unlock_all(LockAction::Unlock, paths)
319    }
320
321    pub(crate) fn lock_unlock_all(
322        &self,
323        action: LockAction,
324        paths: Vec<Path>,
325    ) -> Result<(), Error> {
326        let (_, p_path) = match action {
327            LockAction::Lock => self.proxy().lock(paths)?,
328            LockAction::Unlock => self.proxy().unlock(paths)?,
329        };
330        if p_path == Path::new("/")? {
331            Ok(())
332        } else {
333            self.prompt_for_lock_unlock_delete(&p_path)
334        }
335    }
336}
337
338#[cfg(test)]
339mod test {
340    use super::*;
341
342    #[test]
343    fn should_create_secret_service() {
344        SecretService::connect(EncryptionType::Plain).unwrap();
345    }
346
347    #[test]
348    fn should_get_all_collections() {
349        // Assumes that there will always be a default collection
350        let ss = SecretService::connect(EncryptionType::Plain).unwrap();
351        let collections = ss.get_all_collections().unwrap();
352        assert!(!collections.is_empty(), "no collections found");
353    }
354
355    #[test]
356    fn should_get_collection_by_alias() {
357        let ss = SecretService::connect(EncryptionType::Plain).unwrap();
358        ss.get_collection_by_alias("session").unwrap();
359    }
360
361    #[test]
362    fn should_return_error_if_collection_doesnt_exist() {
363        let ss = SecretService::connect(EncryptionType::Plain).unwrap();
364
365        match ss.get_collection_by_alias("definitely_definitely_does_not_exist") {
366            Err(Error::NoResult) => {}
367            _ => panic!(),
368        };
369    }
370
371    #[test]
372    fn should_get_default_collection() {
373        let ss = SecretService::connect(EncryptionType::Plain).unwrap();
374        ss.get_default_collection().unwrap();
375    }
376
377    #[test]
378    fn should_get_any_collection() {
379        let ss = SecretService::connect(EncryptionType::Plain).unwrap();
380        let _ = ss.get_any_collection().unwrap();
381    }
382
383    #[test]
384    #[ignore] // can't run headless - prompts
385    fn should_create_and_delete_collection() {
386        let ss = SecretService::connect(EncryptionType::Plain).unwrap();
387        let test_collection = ss.create_collection("TestCreateDelete", "").unwrap();
388        assert!(test_collection
389            .path
390            .starts_with("/org/freedesktop/secrets/collection/Test"));
391        test_collection.delete().unwrap();
392    }
393
394    #[test]
395    fn should_search_items() {
396        let ss = SecretService::connect(EncryptionType::Plain).unwrap();
397        let collection = ss.get_default_collection().unwrap();
398
399        // Create an item
400        let item = collection
401            .create_item(
402                "test",
403                HashMap::from([("test_attribute_in_ss", "test_value")]),
404                b"test_secret",
405                false,
406                "text/plain",
407            )
408            .unwrap();
409
410        // handle empty vec search
411        ss.search_items(HashMap::new()).unwrap();
412
413        // handle no result
414        let bad_search = ss.search_items(HashMap::from([("test", "test")])).unwrap();
415        assert_eq!(bad_search.unlocked.len(), 0);
416        assert_eq!(bad_search.locked.len(), 0);
417
418        // handle correct search for item and compare
419        let search_item = ss
420            .search_items(HashMap::from([("test_attribute_in_ss", "test_value")]))
421            .unwrap();
422
423        assert_eq!(item.path, search_item.unlocked[0].path);
424        assert_eq!(search_item.locked.len(), 0);
425        item.delete().unwrap();
426    }
427
428    #[test]
429    #[ignore] // can't run headless - prompts
430    fn should_lock_and_unlock() {
431        // Assumes that there will always be at least one collection
432        let ss = SecretService::connect(EncryptionType::Plain).unwrap();
433        let collections = ss.get_all_collections().unwrap();
434        assert!(!collections.is_empty(), "no collections found");
435        let paths: Vec<Path> = collections.iter().map(|c| c.path.clone()).collect();
436        ss.lock_unlock_all(LockAction::Lock, paths.clone()).unwrap();
437        ss.lock_unlock_all(LockAction::Unlock, paths).unwrap();
438    }
439}