secret_service/
lib.rs

1//! # Secret Service libary
2//!
3//! This library implements a rust interface to the Secret Service API which is implemented
4//! in Linux.
5//!
6//! ## About Secret Service API
7//! <https://standards.freedesktop.org/secret-service/>
8//!
9//! Secret Service provides a secure place to store secrets.
10//! Gnome keyring and KWallet implement the Secret Service API.
11//!
12//! ## Basic Usage
13//! ```
14//! use secret_service::SecretService;
15//! use secret_service::EncryptionType;
16//! use std::collections::HashMap;
17//!
18//! #[tokio::main(flavor = "current_thread")]
19//! async fn main() {
20//!    // initialize secret service (dbus connection and encryption session)
21//!    let ss = SecretService::connect(EncryptionType::Dh).await.unwrap();
22//!
23//!    // get default collection
24//!    let collection = ss.get_default_collection().await.unwrap();
25//!
26//!    let mut properties = HashMap::new();
27//!    properties.insert("test", "test_value");
28//!
29//!    //create new item
30//!    collection.create_item(
31//!        "test_label", // label
32//!        properties,
33//!        b"test_secret", //secret
34//!        false, // replace item with same attributes
35//!        "text/plain" // secret content type
36//!    ).await.unwrap();
37//!
38//!    // search items by properties
39//!    let search_items = ss.search_items(
40//!        HashMap::from([("test", "test_value")])
41//!    ).await.unwrap();
42//!
43//!    // retrieve one item, first by checking the unlocked items
44//!    let item = match search_items.unlocked.first() {
45//!        Some(item) => item,
46//!        None => {
47//!            // if there aren't any, check the locked items and unlock the first one
48//!            let locked_item = search_items
49//!                .locked
50//!                .first()
51//!                .expect("Search didn't return any items!");
52//!            locked_item.unlock().await.unwrap();
53//!            locked_item
54//!        }
55//!    };
56//!
57//!    // retrieve secret from item
58//!    let secret = item.get_secret().await.unwrap();
59//!    assert_eq!(secret, b"test_secret");
60//!
61//!    // delete item (deletes the dbus object, not the struct instance)
62//!    item.delete().await.unwrap()
63//! }
64//! ```
65//!
66//! ## Overview of this library:
67//! ### Entry point
68//! The entry point for this library is the `SecretService` struct. A new instance of
69//! `SecretService` will initialize the dbus connection and negotiate an encryption session.
70//!
71//! ```
72//! # use secret_service::SecretService;
73//! # use secret_service::EncryptionType;
74//! # async fn call() {
75//! SecretService::connect(EncryptionType::Plain).await.unwrap();
76//! # }
77//! ```
78//!
79//! or
80//!
81//! ```
82//! # use secret_service::SecretService;
83//! # use secret_service::EncryptionType;
84//! # async fn call() {
85//! SecretService::connect(EncryptionType::Dh).await.unwrap();
86//! # }
87//! ```
88//!
89//! Once the SecretService struct is initialized, it can be used to navigate to a collection.
90//! Items can also be directly searched for without getting a collection first.
91//!
92//! ### Collections and Items
93//! The Secret Service API organizes secrets into collections, and holds each secret
94//! in an item.
95//!
96//! Items consist of a label, attributes, and the secret. The most common way to find
97//! an item is a search by attributes.
98//!
99//! While it's possible to create new collections, most users will simply create items
100//! within the default collection.
101//!
102//! ### Actions overview
103//! The most common supported actions are `create`, `get`, `search`, and `delete` for
104//! `Collections` and `Items`. For more specifics and exact method names, please see
105//! each struct's documentation.
106//!
107//! In addition, `set` and `get` actions are available for secrets contained in an `Item`.
108//!
109//! ### Crypto
110//! Specifics in SecretService API Draft Proposal:
111//! <https://standards.freedesktop.org/secret-service/>
112//!
113//! ### Async
114//!
115//! This crate, following `zbus`, is async by default. If you want a synchronous interface
116//! that blocks, see the [blocking] module instead.
117//
118// Util currently has interfaces (dbus method namespace) to make it easier to call methods.
119// Util contains function to execute prompts (used in many collection and item methods, like
120// delete)
121
122pub mod blocking;
123mod error;
124mod proxy;
125mod session;
126mod ss;
127mod util;
128
129mod collection;
130pub use collection::Collection;
131
132pub use error::Error;
133
134mod item;
135pub use item::Item;
136
137pub use session::EncryptionType;
138
139use crate::proxy::service::ServiceProxy;
140use crate::session::Session;
141use crate::ss::SS_COLLECTION_LABEL;
142use crate::util::exec_prompt;
143use futures_util::TryFutureExt;
144use std::collections::HashMap;
145use zbus::zvariant::{ObjectPath, OwnedObjectPath, Value};
146
147/// Secret Service Struct.
148///
149/// This the main entry point for usage of the library.
150///
151/// Creating a new [SecretService] will also initialize dbus
152/// and negotiate a new cryptographic session
153/// ([EncryptionType::Plain] or [EncryptionType::Dh])
154pub struct SecretService<'a> {
155    conn: zbus::Connection,
156    session: Session,
157    service_proxy: ServiceProxy<'a>,
158}
159
160/// Used to indicate locked and unlocked items in the
161/// return value of [SecretService::search_items]
162/// and [blocking::SecretService::search_items].
163pub struct SearchItemsResult<T> {
164    pub unlocked: Vec<T>,
165    pub locked: Vec<T>,
166}
167
168impl<'a> SecretService<'a> {
169    /// Create a new [SecretService] instance.
170    ///
171    /// This will initialize its own connection to the session bus.
172    pub async fn connect(encryption: EncryptionType) -> Result<SecretService<'a>, Error> {
173        let conn = zbus::Connection::session()
174            .await
175            .map_err(util::handle_conn_error)?;
176
177        Self::connect_with_existing(encryption, conn).await
178    }
179
180    /// Creates a new [SecretService] instance, utilizing an existing connection handle.
181    ///
182    /// `session_conn` should be connected to the session/user message bus.
183    pub async fn connect_with_existing(
184        encryption: EncryptionType,
185        session_conn: zbus::Connection,
186    ) -> Result<SecretService<'a>, Error> {
187        let service_proxy = ServiceProxy::new(&session_conn)
188            .await
189            .map_err(util::handle_conn_error)?;
190
191        let session = Session::new(&service_proxy, encryption).await?;
192
193        Ok(SecretService {
194            conn: session_conn,
195            session,
196            service_proxy,
197        })
198    }
199
200    /// Get all collections
201    pub async fn get_all_collections(&self) -> Result<Vec<Collection<'_>>, Error> {
202        let collections = self.service_proxy.collections().await?;
203
204        futures_util::future::join_all(collections.into_iter().map(|object_path| {
205            Collection::new(
206                self.conn.clone(),
207                &self.session,
208                &self.service_proxy,
209                object_path.into(),
210            )
211        }))
212        .await
213        .into_iter()
214        .collect::<Result<_, _>>()
215    }
216
217    /// Get collection by alias.
218    ///
219    /// Most common would be the `default` alias, but there
220    /// is also a specific method for getting the collection
221    /// by default alias.
222    pub async fn get_collection_by_alias(&self, alias: &str) -> Result<Collection<'_>, Error> {
223        let object_path = self.service_proxy.read_alias(alias).await?;
224
225        if object_path.as_str() == "/" {
226            Err(Error::NoResult)
227        } else {
228            Collection::new(
229                self.conn.clone(),
230                &self.session,
231                &self.service_proxy,
232                object_path,
233            )
234            .await
235        }
236    }
237
238    /// Get default collection.
239    /// (The collection whos alias is `default`)
240    pub async fn get_default_collection(&self) -> Result<Collection<'_>, Error> {
241        self.get_collection_by_alias("default").await
242    }
243
244    /// Get any collection.
245    /// First tries `default` collection, then `session`
246    /// collection, then the first collection when it
247    /// gets all collections.
248    pub async fn get_any_collection(&self) -> Result<Collection<'_>, Error> {
249        // default first, then session, then first
250
251        self.get_default_collection()
252            .or_else(|_| self.get_collection_by_alias("session"))
253            .or_else(|_| async {
254                let mut collections = self.get_all_collections().await?;
255                if collections.is_empty() {
256                    Err(Error::NoResult)
257                } else {
258                    Ok(collections.swap_remove(0))
259                }
260            })
261            .await
262    }
263
264    /// Creates a new collection with a label and an alias.
265    pub async fn create_collection(
266        &self,
267        label: &str,
268        alias: &str,
269    ) -> Result<Collection<'_>, Error> {
270        let mut properties: HashMap<&str, Value> = HashMap::new();
271        properties.insert(SS_COLLECTION_LABEL, label.into());
272
273        let created_collection = self
274            .service_proxy
275            .create_collection(properties, alias)
276            .await?;
277
278        // This prompt handling is practically identical to create_collection
279        let collection_path: ObjectPath = {
280            // Get path of created object
281            let created_path = created_collection.collection;
282
283            // Check if that path is "/", if so should execute a prompt
284            if created_path.as_str() == "/" {
285                let prompt_path = created_collection.prompt;
286
287                // Exec prompt and parse result
288                let prompt_res = exec_prompt(self.conn.clone(), &prompt_path).await?;
289                prompt_res.try_into()?
290            } else {
291                // if not, just return created path
292                created_path.into()
293            }
294        };
295
296        Collection::new(
297            self.conn.clone(),
298            &self.session,
299            &self.service_proxy,
300            collection_path.into(),
301        )
302        .await
303    }
304
305    /// Searches all items by attributes
306    pub async fn search_items(
307        &self,
308        attributes: HashMap<&str, &str>,
309    ) -> Result<SearchItemsResult<Item<'_>>, Error> {
310        let items = self.service_proxy.search_items(attributes).await?;
311
312        let object_paths_to_items = |items: Vec<_>| {
313            futures_util::future::join_all(items.into_iter().map(|item_path| {
314                Item::new(
315                    self.conn.clone(),
316                    &self.session,
317                    &self.service_proxy,
318                    item_path,
319                )
320            }))
321        };
322
323        Ok(SearchItemsResult {
324            unlocked: object_paths_to_items(items.unlocked)
325                .await
326                .into_iter()
327                .collect::<Result<_, _>>()?,
328            locked: object_paths_to_items(items.locked)
329                .await
330                .into_iter()
331                .collect::<Result<_, _>>()?,
332        })
333    }
334
335    /// Unlock all items in a batch
336    pub async fn unlock_all(&self, items: &[&Item<'_>]) -> Result<(), Error> {
337        let objects = items.iter().map(|i| &*i.item_path).collect();
338        let lock_action_res = self.service_proxy.unlock(objects).await?;
339
340        if lock_action_res.object_paths.is_empty() {
341            exec_prompt(self.conn.clone(), &lock_action_res.prompt).await?;
342        }
343
344        Ok(())
345    }
346
347    pub async fn get_item_by_path(&'a self, item_path: OwnedObjectPath) -> Result<Item<'a>, Error> {
348        Item::new(
349            self.conn.clone(),
350            &self.session,
351            &self.service_proxy,
352            item_path,
353        )
354        .await
355    }
356
357    pub async fn get_collection_by_path(
358        &'a self,
359        collection_path: OwnedObjectPath,
360    ) -> Result<Collection<'a>, Error> {
361        Collection::new(
362            self.conn.clone(),
363            &self.session,
364            &self.service_proxy,
365            collection_path,
366        )
367        .await
368    }
369}
370
371#[cfg(test)]
372mod test {
373    use super::*;
374    use std::convert::TryFrom;
375    use zbus::zvariant::ObjectPath;
376
377    #[tokio::test]
378    async fn should_create_secret_service() {
379        SecretService::connect(EncryptionType::Plain).await.unwrap();
380    }
381
382    #[tokio::test]
383    async fn should_get_all_collections() {
384        // Assumes that there will always be a default collection
385        let ss = SecretService::connect(EncryptionType::Plain).await.unwrap();
386        let collections = ss.get_all_collections().await.unwrap();
387        assert!(!collections.is_empty(), "no collections found");
388    }
389
390    #[tokio::test]
391    async fn should_get_collection_by_alias() {
392        let ss = SecretService::connect(EncryptionType::Plain).await.unwrap();
393        ss.get_collection_by_alias("session").await.unwrap();
394    }
395
396    #[tokio::test]
397    async fn should_return_error_if_collection_doesnt_exist() {
398        let ss = SecretService::connect(EncryptionType::Plain).await.unwrap();
399
400        match ss
401            .get_collection_by_alias("definitely_defintely_does_not_exist")
402            .await
403        {
404            Err(Error::NoResult) => {}
405            _ => panic!(),
406        };
407    }
408
409    #[tokio::test]
410    async fn should_get_default_collection() {
411        let ss = SecretService::connect(EncryptionType::Plain).await.unwrap();
412        ss.get_default_collection().await.unwrap();
413    }
414
415    #[tokio::test]
416    async fn should_get_any_collection() {
417        let ss = SecretService::connect(EncryptionType::Plain).await.unwrap();
418        let _ = ss.get_any_collection().await.unwrap();
419    }
420
421    #[test_with::no_env(GITHUB_ACTIONS)]
422    #[tokio::test]
423    async fn should_create_and_delete_collection() {
424        let ss = SecretService::connect(EncryptionType::Plain).await.unwrap();
425        let test_collection = ss.create_collection("Test", "").await.unwrap();
426        assert_eq!(
427            ObjectPath::from(test_collection.collection_path.clone()),
428            ObjectPath::try_from("/org/freedesktop/secrets/collection/Test").unwrap()
429        );
430        test_collection.delete().await.unwrap();
431    }
432
433    #[tokio::test]
434    async fn should_search_items() {
435        let ss = SecretService::connect(EncryptionType::Plain).await.unwrap();
436        let collection = ss.get_default_collection().await.unwrap();
437
438        // Create an item
439        let item = collection
440            .create_item(
441                "test",
442                HashMap::from([("test_attribute_in_ss", "test_value")]),
443                b"test_secret",
444                false,
445                "text/plain",
446            )
447            .await
448            .unwrap();
449
450        // handle empty vec search
451        ss.search_items(HashMap::new()).await.unwrap();
452
453        // handle no result
454        let bad_search = ss
455            .search_items(HashMap::from([("test", "test")]))
456            .await
457            .unwrap();
458        assert_eq!(bad_search.unlocked.len(), 0);
459        assert_eq!(bad_search.locked.len(), 0);
460
461        // handle correct search for item and compare
462        let search_item = ss
463            .search_items(HashMap::from([("test_attribute_in_ss", "test_value")]))
464            .await
465            .unwrap();
466
467        assert_eq!(item.item_path, search_item.unlocked[0].item_path);
468        assert_eq!(search_item.locked.len(), 0);
469        item.delete().await.unwrap();
470    }
471}