Skip to content

Commit

Permalink
Implement flat-file keyring backend (#287)
Browse files Browse the repository at this point in the history
This adds support for storing warg credentials as flat files in the warg
config directory, following up on #278.
  • Loading branch information
dfoxfranke authored May 15, 2024
1 parent 9406777 commit de8152b
Show file tree
Hide file tree
Showing 3 changed files with 207 additions and 26 deletions.
20 changes: 19 additions & 1 deletion crates/client/src/keyring/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ enum KeyringErrorImpl {
UnknownBackend {
backend: String,
},
BackendInitFailure {
backend: &'static str,
cause: KeyringErrorCause,
},
NoDefaultSigningKey {
backend: &'static str,
},
Expand Down Expand Up @@ -57,6 +61,16 @@ impl KeyringError {
KeyringError(KeyringErrorImpl::UnknownBackend { backend })
}

pub(super) fn backend_init_failure(
backend: &'static str,
cause: impl Into<KeyringErrorCause>,
) -> Self {
KeyringError(KeyringErrorImpl::BackendInitFailure {
backend,
cause: cause.into(),
})
}

pub(super) fn no_default_signing_key(backend: &'static str) -> Self {
KeyringError(KeyringErrorImpl::NoDefaultSigningKey { backend })
}
Expand Down Expand Up @@ -110,6 +124,9 @@ impl std::fmt::Display for KeyringError {
KeyringErrorImpl::UnknownBackend { backend } => {
write!(f, "unknown backend '{backend}'. Run `warg config --keyring_backend <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 <alg:base64>` or `warg key new`")
Expand Down Expand Up @@ -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()),
},
Expand Down
154 changes: 154 additions & 0 deletions crates/client/src/keyring/flatfile.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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<Self> {
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<Box<Credential>> {
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<String> {
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
));
}
59 changes: 34 additions & 25 deletions crates/client/src/keyring/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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",
Expand All @@ -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];
Expand All @@ -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<Box<keyring::CredentialBuilder>> {
fn load_backend(backend: &str) -> Result<Box<keyring::CredentialBuilder>> {
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}")
Expand All @@ -105,24 +116,22 @@ impl Keyring {
///
/// The argument should be an element of [Self::SUPPORTED_BACKENDS].
pub fn new(backend: &str) -> Result<Self> {
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.
pub fn from_config(config: &Config) -> Result<Self> {
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)
}
}

Expand Down

0 comments on commit de8152b

Please sign in to comment.