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}