diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bccfa6e..d010a83 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -56,6 +56,12 @@ jobs: - "async-secret-service,async-io,crypto-rust" - "async-secret-service,tokio,crypto-openssl" - "async-secret-service,async-io,crypto-openssl" + - "linux-native-sync-persistent,crypto-rust" + - "linux-native-sync-persistent,crypto-openssl" + - "linux-native-async-persistent,tokio,crypto-rust" + - "linux-native-async-persistent,async-io,crypto-rust" + - "linux-native-async-persistent,tokio,crypto-openssl" + - "linux-native-async-persistent,async-io,crypto-openssl" steps: - name: Install CI dependencies diff --git a/Cargo.toml b/Cargo.toml index d722746..432b518 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,8 @@ linux-native = ["dep:linux-keyutils"] apple-native = ["dep:security-framework"] windows-native = ["dep:windows-sys", "dep:byteorder"] +linux-native-sync-persistent = ["linux-native", "sync-secret-service"] +linux-native-async-persistent = ["linux-native", "async-secret-service"] sync-secret-service = ["dep:dbus-secret-service"] async-secret-service = ["dep:secret-service", "dep:zbus"] crypto-rust = ["dbus-secret-service?/crypto-rust", "secret-service?/crypto-rust"] diff --git a/README.md b/README.md index ff0ec5b..d5ea62a 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,14 @@ This crate allows clients to "bring their own credential store" by providing tra This crate provides built-in implementations of the following platform-specific credential stores: -* _Linux_: The DBus-based Secret Service and the kernel keyutils. +* _Linux_: The DBus-based Secret Service, the kernel keyutils, and a combo of the two. * _FreeBSD_, _OpenBSD_: The DBus-based Secret Service. * _macOS_, _iOS_: The local keychain. * _Windows_: The Windows Credential Manager. To enable the stores you want, you use features: there is one feature for each possibly-included credential store. If you specify a feature (e.g., `dbus-secret-service`) _and_ your target platform (e.g., `freebsd`) supports that credential store, it will be included as the default credential store in that build. That way you can have a build command that specifies a single credential store for each of your target platforms, and use that same build command for all targets. -If you don't enable any credential stores that are supported on a given platform, or you enable multiple credential stores for some platform, the _mock_ keystore will be the default on that platform. See the [developer docs](https://docs.rs/keyring/) for details of which features control the inclusion of which credential stores (and which platforms each credential store targets). +If you don't enable any credential stores that are supported on a given platform, the _mock_ keystore will be the default on that platform. See the [developer docs](https://docs.rs/keyring/) for details of which features control the inclusion of which credential stores. ### Platform-specific issues diff --git a/build-xplat-docs.sh b/build-xplat-docs.sh index 77d6c90..56b855b 100644 --- a/build-xplat-docs.sh +++ b/build-xplat-docs.sh @@ -1,5 +1,11 @@ #!/bin/bash -cargo doc --no-deps --features=linux-native --target aarch64-unknown-linux-musl $OPEN_DOCS -cargo doc --no-deps --features=windows-native --target aarch64-pc-windows-msvc $OPEN_DOCS -cargo doc --no-deps --features=apple-native --target aarch64-apple-darwin $OPEN_DOCS -cargo doc --no-deps --features=apple-native --target aarch64-apple-ios $OPEN_DOCS +if [[ "$OSTYPE" == "linux"* ]]; then + cargo doc --no-deps --features=linux-native-sync-persistent $OPEN_DOCS + cargo doc --no-deps --features=sync-secret-service $OPEN_DOCS + cargo doc --no-deps --features=linux-native $OPEN_DOCS +elif [[ "$OSTYPE" == "darwin"* ]]; then + cargo doc --no-deps --features=linux-native --target aarch64-unknown-linux-musl $OPEN_DOCS + cargo doc --no-deps --features=windows-native --target aarch64-pc-windows-msvc $OPEN_DOCS + cargo doc --no-deps --features=apple-native --target aarch64-apple-darwin $OPEN_DOCS + cargo doc --no-deps --features=apple-native --target aarch64-apple-ios $OPEN_DOCS +fi \ No newline at end of file diff --git a/src/keyutils_persistent.rs b/src/keyutils_persistent.rs new file mode 100644 index 0000000..d9ff4d9 --- /dev/null +++ b/src/keyutils_persistent.rs @@ -0,0 +1,265 @@ +/*! + +# Linux (keyutils) store with Secret Service backing + +This store, contributed by [@soywod](https://github.com/soywod), +uses the [keyutils module](crate::keyutils) as a cache +available to headless processes, while using the +[secret-service module](crate::secret_service) +to provide credential storage beyond reboot. +The expected usage pattern +for this module is as follows: + +- Processes that run on headless systems are built with `keyutils` support via the + `linux-native` feature of this crate. After each reboot, these processes + are either launched after the keyutils cache has been reloaded from the secret service, + or (if launched immediately) they wait until the keyutils cache has been reloaded. +- A headed "configuration" process is built with this module that allows its user + to configure the credentials needed by the headless processes. After each reboot, + this process unlocks the secret service (see both the keyutils and secret-service + module for information about how this can be done headlessly, if desired) and then + accesses each of the configured credentials (which loads them into keyutils). At + that point the headless clients can be started (or become active, if already started). + +This store works by creating a keyutils entry and a secret-service entry for +each of its entries. Because keyutils entries don't have attributes, entries +in this store don't expose attributes either. Because keyutils entries can't +store empty passwords/secrets, this store's entries can't either. + +See the documentation for the `keyutils` and `secret-service` modules if you +want details about how the underlying storage is handled. + */ + +use log::debug; + +use super::credential::{ + Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence, +}; +use super::error::{Error, Result}; +use super::keyutils::KeyutilsCredential; +use super::secret_service::{SsCredential, SsCredentialBuilder}; + +/// Representation of a keyutils-persistent credential. +/// +/// The credential owns a [KeyutilsCredential] for in-memory usage and +/// a [SsCredential] for persistence. +#[derive(Debug, Clone)] +pub struct KeyutilsPersistentCredential { + keyutils: KeyutilsCredential, + ss: SsCredential, +} + +impl CredentialApi for KeyutilsPersistentCredential { + /// Set a password in the underlying store + fn set_password(&self, password: &str) -> Result<()> { + self.set_secret(password.as_bytes()) + } + + /// Set a secret in the underlying store + /// + /// It sets first the secret in keyutils, then in + /// secret-service. If the latter set fails, the former + /// is reverted. + fn set_secret(&self, secret: &[u8]) -> Result<()> { + let prev_secret = self.keyutils.get_secret(); + self.keyutils.set_secret(secret)?; + + if let Err(err) = self.ss.set_secret(secret) { + debug!("Failed set of secret-service: {err}; reverting keyutils"); + match prev_secret { + Ok(ref secret) => self.keyutils.set_secret(secret), + Err(Error::NoEntry) => self.keyutils.delete_credential(), + Err(err) => Err(err), + }?; + + return Err(err); + } + + Ok(()) + } + + /// Retrieve a password from the underlying store + /// + /// The password is retrieved from keyutils. In case of error, the + /// password is retrieved from secret-service instead (and + /// keyutils is updated). + fn get_password(&self) -> Result { + match self.keyutils.get_password() { + Ok(password) => { + return Ok(password); + } + Err(err) => { + debug!("Failed get from keyutils: {err}; trying secret service") + } + } + + let password = self.ss.get_password().map_err(ambiguous_to_no_entry)?; + self.keyutils.set_password(&password)?; + + Ok(password) + } + + /// Retrieve a secret from the underlying store + /// + /// The secret is retrieved from keyutils. In case of error, the + /// secret is retrieved from secret-service instead (and keyutils + /// is updated). + fn get_secret(&self) -> Result> { + match self.keyutils.get_secret() { + Ok(secret) => { + return Ok(secret); + } + Err(err) => { + debug!("Failed get from keyutils: {err}; trying secret service") + } + } + + let secret = self.ss.get_secret().map_err(ambiguous_to_no_entry)?; + self.keyutils.set_secret(&secret)?; + + Ok(secret) + } + + /// Delete a password from the underlying store. + /// + /// The credential is deleted from both keyutils and + /// secret-service. + fn delete_credential(&self) -> Result<()> { + if let Err(err) = self.keyutils.delete_credential() { + debug!("cannot delete keyutils credential: {err}"); + } + + self.ss.delete_credential() + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(self, f) + } +} + +impl KeyutilsPersistentCredential { + /// Create the platform credential for a Keyutils entry. + /// + /// This just passes the arguments to the underlying two stores + /// and wraps their results with an entry that holds both. + pub fn new_with_target(target: Option<&str>, service: &str, user: &str) -> Result { + let ss = SsCredential::new_with_target(target, service, user)?; + let keyutils = KeyutilsCredential::new_with_target(target, service, user)?; + Ok(Self { keyutils, ss }) + } +} + +/// The builder for keyutils-persistent credentials +#[derive(Debug, Default)] +pub struct KeyutilsPersistentCredentialBuilder {} + +/// Returns an instance of the keyutils-persistent credential builder. +/// +/// If keyutils-persistent is the default credential store, this is +/// called once when an entry is first created. +pub fn default_credential_builder() -> Box { + Box::new(KeyutilsPersistentCredentialBuilder {}) +} + +impl CredentialBuilderApi for KeyutilsPersistentCredentialBuilder { + /// Build a [KeyutilsPersistentCredential] for the given target, service, and user. + fn build(&self, target: Option<&str>, service: &str, user: &str) -> Result> { + Ok(Box::new(SsCredential::new_with_target( + target, service, user, + )?)) + } + + /// Return the underlying builder object with an `Any` type so that it can + /// be downgraded to a [KeyutilsPersistentCredentialBuilder] for platform-specific processing. + fn as_any(&self) -> &dyn std::any::Any { + self + } + + /// Return the persistence of this store. + /// + /// This store's persistence derives from that of the secret service. + fn persistence(&self) -> CredentialPersistence { + SsCredentialBuilder {}.persistence() + } +} + +/// Replace any Ambiguous error with a NoEntry one +fn ambiguous_to_no_entry(err: Error) -> Error { + if let Error::Ambiguous(_) = err { + return Error::NoEntry; + }; + + err +} + +#[cfg(test)] +mod tests { + use crate::{Entry, Error}; + + use super::KeyutilsPersistentCredential; + + fn entry_new(service: &str, user: &str) -> Entry { + crate::tests::entry_from_constructor( + KeyutilsPersistentCredential::new_with_target, + service, + user, + ) + } + + #[test] + fn test_invalid_parameter() { + let credential = KeyutilsPersistentCredential::new_with_target(Some(""), "service", "user"); + assert!( + matches!(credential, Err(Error::Invalid(_, _))), + "Created entry with empty target" + ); + } + + #[test] + fn test_empty_service_and_user() { + crate::tests::test_empty_service_and_user(entry_new); + } + + #[test] + fn test_missing_entry() { + crate::tests::test_missing_entry(entry_new); + } + + #[test] + fn test_empty_password() { + let entry = entry_new("empty password service", "empty password user"); + assert!( + matches!(entry.set_password(""), Err(Error::Invalid(_, _))), + "Able to set empty password" + ); + } + + #[test] + fn test_round_trip_ascii_password() { + crate::tests::test_round_trip_ascii_password(entry_new); + } + + #[test] + fn test_round_trip_non_ascii_password() { + crate::tests::test_round_trip_non_ascii_password(entry_new); + } + + #[test] + fn test_round_trip_random_secret() { + crate::tests::test_round_trip_random_secret(entry_new); + } + + #[test] + fn test_update() { + crate::tests::test_update(entry_new); + } + + #[test] + fn test_noop_get_update_attributes() { + crate::tests::test_noop_get_update_attributes(entry_new); + } +} diff --git a/src/lib.rs b/src/lib.rs index 50ebd54..5ad2db0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,7 +64,6 @@ example, the macOS Keychain credential store is only included if the `"apple-nat feature is specified (and the crate is built with a macOS target). If no specified credential store features apply to a given platform, -or multiple credential store features apply to a given platform, this crate will use the (platform-independent) _mock_ credential store (see below) on that platform. There are no default features in this crate: you must specify explicitly which platform-specific @@ -78,6 +77,18 @@ Here are the available credential store features: - `linux-native`: Provides access to the `keyutils` storage on Linux. +- `linux-native-sync-persistent`: Uses both `keyutils` and `sync-secret-service` + (see below) for storage. See the docs for the `keyutils_persistent` + module for a full explanation of why both are used. Because this + store uses the `sync-secret-service`, you can use additional features related + to that store (described below). + +- `linux-native-async-persistent`: Uses both `keyutils` and `async-secret-service` + (see below) for storage. See the docs for the `keyutils_persistent` + module for a full explanation of why both are used. + Because this store uses the `async-secret-service`, you + must specify the additional features required by that store (described below). + - `sync-secret-service`: Provides access to the DBus-based [Secret Service](https://specifications.freedesktop.org/secret-service/latest/) storage on Linux, FreeBSD, and OpenBSD. This is a _synchronous_ keystore that provides @@ -99,17 +110,13 @@ Here are the available credential store features: installed on the user's machine, specify the `vendored` feature to statically link them with the built crate. -You cannot specify both the `sync-secret-service` and `async-secret-service` features; -this will produce a compile error. You must pick one or the other if you want to use -the secret service for credential storage. - The Linux platform is the only one for which this crate supplies multiple keystores: -secret-service and keyutils. The secret-service is the more widely used store, because -it provides persistence of credentials beyond reboot (which keyutils does not). However, -because secret-service relies on system UI for unlocking credentials, it often isn't -available on headless Linux installations, so keyutils is provided for those situations. -If you enable both the secret-service store and the keyutils store, the secret-service -store will be used as the default. +native (keyutils), sync or async secret service, and sync or async "combo" (both +keyutils and secret service). You cannot specify use of both sync and async +keystores; this will lead to a compile error. If you enable a combo keystore on Linux, +that will be the default keystore. If you don't enable a +combo keystore on Linux, but you do enable both the native and secret service keystores, +the secret service will be the default. ## Client-provided Credential Stores @@ -184,48 +191,72 @@ pub mod mock; // // can't use both sync and async secret service // -#[cfg(all(feature = "sync-secret-service", feature = "async-secret-service"))] -compile_error!("This crate cannot use the secret-service both synchronously and asynchronously"); +#[cfg(any( + all(feature = "sync-secret-service", feature = "async-secret-service"), + all( + feature = "linux-native-sync-persistent", + feature = "linux-native-async-persistent", + ) +))] +compile_error!("This crate cannot use both the sync and async versions of any credential store"); // // pick the *nix keystore // - #[cfg(all(target_os = "linux", feature = "linux-native"))] pub mod keyutils; -// use keyutils as default if secret-service is not available #[cfg(all( target_os = "linux", feature = "linux-native", - not(any(feature = "sync-secret-service", feature = "async-secret-service")) + not(feature = "sync-secret-service"), + not(feature = "async-secret-service"), ))] pub use keyutils as default; #[cfg(all( any(target_os = "linux", target_os = "freebsd", target_os = "openbsd"), - any(feature = "sync-secret-service", feature = "async-secret-service") + any(feature = "sync-secret-service", feature = "async-secret-service"), ))] pub mod secret_service; -// use secret-service as default if it's available #[cfg(all( any(target_os = "linux", target_os = "freebsd", target_os = "openbsd"), any(feature = "sync-secret-service", feature = "async-secret-service"), + not(any( + feature = "linux-native-sync-persistent", + feature = "linux-native-async-persistent", + )), ))] pub use secret_service as default; +#[cfg(all( + target_os = "linux", + any( + feature = "linux-native-sync-persistent", + feature = "linux-native-async-persistent", + ) +))] +pub mod keyutils_persistent; +#[cfg(all( + target_os = "linux", + any( + feature = "linux-native-sync-persistent", + feature = "linux-native-async-persistent", + ), +))] +pub use keyutils_persistent as default; + // fallback to mock if neither keyutils nor secret service is available #[cfg(any( all( target_os = "linux", - not(any( - feature = "linux-native", - feature = "sync-secret-service", - feature = "async-secret-service" - )) + not(feature = "linux-native"), + not(feature = "sync-secret-service"), + not(feature = "async-secret-service"), ), all( any(target_os = "freebsd", target_os = "openbsd"), - not(any(feature = "sync-secret-service", feature = "async-secret-service")) + not(feature = "sync-secret-service"), + not(feature = "async-secret-service"), ) ))] pub use mock as default; @@ -250,7 +281,6 @@ pub use mock as default; // // pick the Windows keystore // - #[cfg(all(target_os = "windows", feature = "windows-native"))] pub mod windows; #[cfg(all(target_os = "windows", not(feature = "windows-native")))] diff --git a/src/secret_service.rs b/src/secret_service.rs index a27870e..2d600c4 100644 --- a/src/secret_service.rs +++ b/src/secret_service.rs @@ -402,7 +402,7 @@ impl SsCredential { let attributes = self.search_attributes(true); let search = collection.search_items(attributes).map_err(decode_error)?; if require_unique { - if search.len() == 0 && require_unique { + if search.is_empty() && require_unique { return Err(ErrorCode::NoEntry); } else if search.len() > 1 { let mut creds: Vec> = vec![]; @@ -520,7 +520,7 @@ pub fn get_item_password(item: &Item) -> Result { decode_password(bytes) } -//// Given an existing item, retrieve its secret. +/// Given an existing item, retrieve its secret. pub fn get_item_secret(item: &Item) -> Result> { let secret = item.get_secret().map_err(decode_error)?; Ok(secret)