diff --git a/crates/client/src/keyring/error.rs b/crates/client/src/keyring/error.rs index 594766f6..613c3b12 100644 --- a/crates/client/src/keyring/error.rs +++ b/crates/client/src/keyring/error.rs @@ -9,6 +9,10 @@ enum KeyringErrorImpl { UnknownBackend { backend: String, }, + BackendInitFailure { + backend: &'static str, + cause: KeyringErrorCause, + }, NoDefaultSigningKey { backend: &'static str, }, @@ -57,6 +61,16 @@ impl KeyringError { KeyringError(KeyringErrorImpl::UnknownBackend { backend }) } + pub(super) fn backend_init_failure( + backend: &'static str, + cause: impl Into, + ) -> Self { + KeyringError(KeyringErrorImpl::BackendInitFailure { + backend, + cause: cause.into(), + }) + } + pub(super) fn no_default_signing_key(backend: &'static str) -> Self { KeyringError(KeyringErrorImpl::NoDefaultSigningKey { backend }) } @@ -110,6 +124,9 @@ impl std::fmt::Display for KeyringError { KeyringErrorImpl::UnknownBackend { backend } => { write!(f, "unknown backend '{backend}'. Run `warg config --keyring_backend ` to configure a keyring backend supported on this platform.") } + KeyringErrorImpl::BackendInitFailure { backend, .. } => { + write!(f, "failed to initialize backend '{backend}'.") + } KeyringErrorImpl::NoDefaultSigningKey { backend } => { let _ = backend; write!(f, "no default signing key is set. Please create one by running `warg key set ` or `warg key new`") @@ -166,7 +183,8 @@ impl std::fmt::Display for KeyringError { impl std::error::Error for KeyringError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match &self.0 { - KeyringErrorImpl::AccessError { cause, .. } => match cause { + KeyringErrorImpl::AccessError { cause, .. } + | KeyringErrorImpl::BackendInitFailure { cause, .. } => match cause { KeyringErrorCause::Backend(e) => Some(e), KeyringErrorCause::Other(e) => Some(e.as_ref()), }, diff --git a/crates/client/src/keyring/flatfile.rs b/crates/client/src/keyring/flatfile.rs new file mode 100644 index 00000000..7fc031da --- /dev/null +++ b/crates/client/src/keyring/flatfile.rs @@ -0,0 +1,154 @@ +//! Flat-file keyring backend +//! +//! This backend stores credentials as unencrypted flat files in the user's +//! configuration directory. It is portable to all platforms, but the lack of +//! encryption can make it a less secure option than the platform-specific +//! encrypted backends such as `secret-service`. + +use keyring::credential::{Credential, CredentialApi, CredentialBuilderApi}; + +use std::fs::File; +use std::io::{Read, Write}; +use std::path::PathBuf; +use url::form_urlencoded::Serializer; + +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + +/// Builder for flat-file credentials +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FlatfileCredentialBuilder(PathBuf); + +/// A credential stored in a flat file +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FlatfileCredential(PathBuf); + +impl FlatfileCredentialBuilder { + /// Construct the credential builder, storing credentials in + /// `$XDG_CONFIG_HOME/warg/keyring`. + pub fn new() -> keyring::Result { + let dir = dirs::config_dir() + .ok_or(keyring::Error::NoEntry)? + .join("warg") + .join("keyring"); + Self::new_with_basepath(dir) + } + + /// Construct the credential builder, storing all credentials in the + /// given directory. The directory will be created if it is does not exist. + pub fn new_with_basepath(basepath: PathBuf) -> keyring::Result { + std::fs::create_dir_all(basepath.as_path()) + .map_err(|e| keyring::Error::PlatformFailure(Box::new(e)))?; + + #[cfg(unix)] + std::fs::set_permissions(basepath.as_path(), std::fs::Permissions::from_mode(0o700)) + .map_err(|e| keyring::Error::PlatformFailure(Box::new(e)))?; + + Ok(Self(basepath)) + } +} + +impl CredentialBuilderApi for FlatfileCredentialBuilder { + fn build( + &self, + target: Option<&str>, + service: &str, + user: &str, + ) -> keyring::Result> { + let mut serializer = Serializer::new(String::new()); + if let Some(target) = target { + serializer.append_pair("target", target); + } + serializer.append_pair("service", service); + serializer.append_pair("user", user); + + let filename = serializer.finish(); + + let path = self.0.join(filename); + Ok(Box::new(FlatfileCredential(path))) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +impl CredentialApi for FlatfileCredential { + fn set_password(&self, password: &str) -> keyring::Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + options.mode(0o600); + + let mut f = options + .open(self.0.as_path()) + .map_err(|e| keyring::Error::PlatformFailure(Box::new(e)))?; + f.write_all(password.as_bytes()) + .map_err(|e| keyring::Error::PlatformFailure(Box::new(e)))?; + Ok(()) + } + + fn get_password(&self) -> keyring::Result { + match File::open(self.0.as_path()) { + Ok(mut f) => { + let mut buf = Vec::new(); + f.read_to_end(&mut buf) + .map_err(|e| keyring::Error::PlatformFailure(Box::new(e)))?; + String::from_utf8(buf).map_err(|e| keyring::Error::BadEncoding(e.into_bytes())) + } + Err(e) => { + if e.kind() == std::io::ErrorKind::NotFound { + Err(keyring::Error::NoEntry) + } else { + Err(keyring::Error::PlatformFailure(Box::new(e))) + } + } + } + } + + fn delete_password(&self) -> keyring::Result<()> { + match std::fs::remove_file(self.0.as_path()) { + Ok(()) => Ok(()), + Err(e) => { + if e.kind() == std::io::ErrorKind::NotFound { + Err(keyring::Error::NoEntry) + } else { + Err(keyring::Error::PlatformFailure(Box::new(e))) + } + } + } + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +#[test] +fn test_smoke() { + let basepath = tempfile::tempdir().unwrap(); + let keyring = + FlatfileCredentialBuilder::new_with_basepath(basepath.as_ref().to_owned()).unwrap(); + let cred = keyring.build(None, "service1", "user1").unwrap(); + assert!(matches!( + cred.get_password().unwrap_err(), + keyring::Error::NoEntry + )); + cred.set_password("correct horse battery staple").unwrap(); + assert_eq!(cred.get_password().unwrap(), "correct horse battery staple"); + + let _dirattr = std::fs::metadata(basepath.as_ref()).unwrap(); + #[cfg(unix)] + assert_eq!(_dirattr.permissions().mode() & 0o7777, 0o700); + + let filepath = basepath.as_ref().join("service=service1&user=user1"); + let _fileattr = std::fs::metadata(filepath.as_path()).unwrap(); + #[cfg(unix)] + assert_eq!(_fileattr.permissions().mode() & 0o7777, 0o600); + + cred.delete_password().unwrap(); + assert!(matches!( + cred.get_password().unwrap_err(), + keyring::Error::NoEntry + )); +} diff --git a/crates/client/src/keyring/mod.rs b/crates/client/src/keyring/mod.rs index 55eb58d7..c02a8964 100644 --- a/crates/client/src/keyring/mod.rs +++ b/crates/client/src/keyring/mod.rs @@ -10,6 +10,8 @@ mod error; use error::KeyringAction; pub use error::KeyringError; +pub mod flatfile; + /// Interface to a pluggable keyring backend #[derive(Debug)] pub struct Keyring { @@ -24,19 +26,20 @@ impl Keyring { #[cfg(target_os = "linux")] /// List of supported credential store backends pub const SUPPORTED_BACKENDS: &'static [&'static str] = - &["secret-service", "linux-keyutils", "mock"]; + &["secret-service", "flat-file", "linux-keyutils", "mock"]; #[cfg(any(target_os = "freebsd", target_os = "openbsd"))] /// List of supported credential store backends - pub const SUPPORTED_BACKENDS: &'static [&'static str] = &["secret-service", "mock"]; + pub const SUPPORTED_BACKENDS: &'static [&'static str] = + &["secret-service", "flat-file", "mock"]; #[cfg(target_os = "windows")] /// List of supported credential store backends - pub const SUPPORTED_BACKENDS: &'static [&'static str] = &["windows", "mock"]; + pub const SUPPORTED_BACKENDS: &'static [&'static str] = &["windows", "flat-file", "mock"]; #[cfg(target_os = "macos")] /// List of supported credential store backends - pub const SUPPORTED_BACKENDS: &'static [&'static str] = &["macos", "mock"]; + pub const SUPPORTED_BACKENDS: &'static [&'static str] = &["macos", "flat-file", "mock"]; #[cfg(target_os = "ios")] /// List of supported credential store backends - pub const SUPPORTED_BACKENDS: &'static [&'static str] = &["ios", "mock"]; + pub const SUPPORTED_BACKENDS: &'static [&'static str] = &["ios", "flat-file", "mock"]; #[cfg(not(any( target_os = "linux", target_os = "freebsd", @@ -46,7 +49,7 @@ impl Keyring { target_os = "windows", )))] /// List of supported credential store backends - pub const SUPPORTED_BACKENDS: &'static [&'static str] = &["mock"]; + pub const SUPPORTED_BACKENDS: &'static [&'static str] = &["flat-file", "mock"]; /// The default backend when no configuration option is set pub const DEFAULT_BACKEND: &'static str = Self::SUPPORTED_BACKENDS[0]; @@ -59,43 +62,51 @@ impl Keyring { "windows" => "Windows Credential Manager", "macos" => "MacOS Keychain", "ios" => "Apple iOS Keychain", + "flat-file" => "Unencrypted flat files in your warg config directory", "mock" => "Mock credential store with no persistence (for testing only)", _ => "(no description available)" } } - fn load_backend(backend: &str) -> Option> { + fn load_backend(backend: &str) -> Result> { if !Self::SUPPORTED_BACKENDS.contains(&backend) { - return None; + return Err(KeyringError::unknown_backend(backend.to_owned())); } #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd"))] if backend == "secret-service" { - return Some(keyring::secret_service::default_credential_builder()); + return Ok(keyring::secret_service::default_credential_builder()); } #[cfg(target_os = "linux")] if backend == "linux-keyutils" { - return Some(keyring::keyutils::default_credential_builder()); + return Ok(keyring::keyutils::default_credential_builder()); } #[cfg(target_os = "macos")] if backend == "macos" { - return Some(keyring::macos::default_credential_builder()); + return Ok(keyring::macos::default_credential_builder()); } #[cfg(target_os = "ios")] if backend == "ios" { - return Some(keyring::ios::default_credential_builder()); + return Ok(keyring::ios::default_credential_builder()); } #[cfg(target_os = "windows")] if backend == "windows" { - return Some(keyring::windows::default_credential_builder()); + return Ok(keyring::windows::default_credential_builder()); + } + + if backend == "flat-file" { + return Ok(Box::new( + flatfile::FlatfileCredentialBuilder::new() + .map_err(|e| KeyringError::backend_init_failure("flat-file", e))?, + )); } if backend == "mock" { - return Some(keyring::mock::default_credential_builder()); + return Ok(keyring::mock::default_credential_builder()); } unreachable!("missing logic for backend {backend}") @@ -105,16 +116,14 @@ impl Keyring { /// /// The argument should be an element of [Self::SUPPORTED_BACKENDS]. pub fn new(backend: &str) -> Result { - Self::load_backend(backend) - .ok_or_else(|| KeyringError::unknown_backend(backend.to_string())) - .map(|imp| Self { - imp, - // Get an equivalent &'static str from our &str - name: Self::SUPPORTED_BACKENDS - .iter() - .find(|s| **s == backend) - .expect("successfully-loaded backend should be found in SUPPORTED_BACKENDS"), - }) + Self::load_backend(backend).map(|imp| Self { + imp, + // Get an equivalent &'static str from our &str + name: Self::SUPPORTED_BACKENDS + .iter() + .find(|s| **s == backend) + .expect("successfully-loaded backend should be found in SUPPORTED_BACKENDS"), + }) } /// Instantiate a new keyring using the backend specified in a configuration file. @@ -122,7 +131,7 @@ impl Keyring { if let Some(ref backend) = config.keyring_backend { Self::new(backend.as_str()) } else { - Self::new(Self::SUPPORTED_BACKENDS[0]) + Self::new(Self::DEFAULT_BACKEND) } }