Implement secure OS-backed secret storage
This commit is contained in:
@@ -5,10 +5,19 @@ use std::{ffi::OsString, io::Write, process::ExitCode};
|
||||
|
||||
use ironstorage::{
|
||||
command::{
|
||||
CliAction, CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, HelpTopic,
|
||||
OtpRequest, help_text, otp_version_text, parse_from, version_text,
|
||||
CliAction, CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE,
|
||||
GitRequest, HelpTopic, OtpRequest, Presentation, help_text, otp_version_text, parse_from,
|
||||
version_text,
|
||||
},
|
||||
config::Config,
|
||||
crypto::KeyStore,
|
||||
git::{GitIdentity, GitRepository},
|
||||
read::{ShowOutput, ShowResult, VaultReader},
|
||||
repository::Repository,
|
||||
secret_store::{
|
||||
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStore,
|
||||
SecretStoreBackend,
|
||||
},
|
||||
};
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -73,7 +82,24 @@ where
|
||||
.map_err(|_| ())?;
|
||||
Ok(EXIT_SUCCESS)
|
||||
}
|
||||
_ => match Config::load(invocation.config()) {
|
||||
request => match Config::load(invocation.config()) {
|
||||
Ok(config) if needs_secret_store(request) => {
|
||||
let mut secrets = match NativeSecretStore::system(
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
)
|
||||
.and_then(|store| {
|
||||
store.unlock()?;
|
||||
Ok(store)
|
||||
}) {
|
||||
Ok(secrets) => secrets,
|
||||
Err(error) => {
|
||||
writeln!(stderr, "{error}").map_err(|_| ())?;
|
||||
return Ok(EXIT_UNAVAILABLE);
|
||||
}
|
||||
};
|
||||
execute_secure(&config, request, &mut secrets, &mut stdout, &mut stderr)
|
||||
}
|
||||
Ok(_) => {
|
||||
stderr
|
||||
.write_all(
|
||||
@@ -91,16 +117,177 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_secret_store(request: &CommandRequest) -> bool {
|
||||
matches!(
|
||||
request,
|
||||
CommandRequest::Show(_) | CommandRequest::Git(GitRequest::Fetch { .. })
|
||||
)
|
||||
}
|
||||
|
||||
fn execute_secure<B: SecretStoreBackend, O: Write, E: Write>(
|
||||
config: &Config,
|
||||
request: &CommandRequest,
|
||||
secrets: &mut SecretStore<B>,
|
||||
stdout: &mut O,
|
||||
stderr: &mut E,
|
||||
) -> Result<u8, ()> {
|
||||
match request {
|
||||
CommandRequest::Show(request) if request.presentation == Presentation::Terminal => {
|
||||
let repository = match Repository::open(config.vault()) {
|
||||
Ok(repository) => repository,
|
||||
Err(error) => return operation_error(stderr, error),
|
||||
};
|
||||
let keys = match KeyStore::load(config.key_material()) {
|
||||
Ok(keys) => keys,
|
||||
Err(error) => return operation_error(stderr, error),
|
||||
};
|
||||
match VaultReader::new(&repository, &keys).execute_show(request, secrets) {
|
||||
Ok(ShowOutput::Display(ShowResult::Entry(secret))) => {
|
||||
stdout.write_all(secret.expose()).map_err(|_| ())?;
|
||||
Ok(EXIT_SUCCESS)
|
||||
}
|
||||
Ok(ShowOutput::Display(ShowResult::Directory(tree))) => {
|
||||
stdout
|
||||
.write_all(tree.render_plain().as_bytes())
|
||||
.map_err(|_| ())?;
|
||||
Ok(EXIT_SUCCESS)
|
||||
}
|
||||
Ok(ShowOutput::Present(_)) => Ok(EXIT_UNAVAILABLE),
|
||||
Err(error) => operation_error(stderr, error),
|
||||
}
|
||||
}
|
||||
CommandRequest::Git(GitRequest::Fetch { remote }) => {
|
||||
let configured = match select_remote(config, remote.as_deref()) {
|
||||
Some(configured) => configured,
|
||||
None => {
|
||||
stderr
|
||||
.write_all(b"the requested HTTPS Git remote is not configured\n")
|
||||
.map_err(|_| ())?;
|
||||
return Ok(EXIT_CONFIG);
|
||||
}
|
||||
};
|
||||
let repository = match Repository::open(config.vault()) {
|
||||
Ok(repository) => repository,
|
||||
Err(error) => return operation_error(stderr, error),
|
||||
};
|
||||
let identity = GitIdentity::new("IronStorage", "ironstorage@localhost")
|
||||
.expect("the built-in Git identity is valid");
|
||||
let git = match GitRepository::open(&repository, identity) {
|
||||
Ok(git) => git,
|
||||
Err(error) => return operation_error(stderr, error),
|
||||
};
|
||||
match git.fetch(configured, secrets) {
|
||||
Ok(_) => Ok(EXIT_SUCCESS),
|
||||
Err(error) => operation_error(stderr, error),
|
||||
}
|
||||
}
|
||||
_ => Ok(EXIT_UNAVAILABLE),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_remote<'a>(
|
||||
config: &'a Config,
|
||||
requested: Option<&str>,
|
||||
) -> Option<&'a ironstorage::config::GitRemote> {
|
||||
match requested {
|
||||
Some(requested) => config
|
||||
.git_remotes()
|
||||
.iter()
|
||||
.find(|remote| remote.name().as_str() == requested),
|
||||
None => config.git_remotes().first(),
|
||||
}
|
||||
}
|
||||
|
||||
fn operation_error<E: Write>(stderr: &mut E, error: impl std::fmt::Display) -> Result<u8, ()> {
|
||||
writeln!(stderr, "{error}").map_err(|_| ())?;
|
||||
Ok(EXIT_FAILURE)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{error::Error, ffi::OsString, fs};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
error::Error,
|
||||
ffi::OsString,
|
||||
fs,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ironstorage::command::{EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE};
|
||||
use ironstorage::{
|
||||
command::{
|
||||
CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE, Presentation,
|
||||
ShowRequest,
|
||||
},
|
||||
config::Config,
|
||||
git::GitCredentialProvider as _,
|
||||
repository::SecretBytes,
|
||||
secret_store::{
|
||||
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy,
|
||||
SecretReference, SecretStore, SecretStoreBackend, SecretStoreError,
|
||||
},
|
||||
};
|
||||
|
||||
use super::run_with;
|
||||
use super::{execute_secure, run_with};
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error>>;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct MemoryBackend(Arc<Mutex<BTreeMap<SecretLocator, SecretBytes>>>);
|
||||
|
||||
impl SecretStoreBackend for MemoryBackend {
|
||||
fn create(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
_protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let mut values = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
if values.contains_key(locator) {
|
||||
return Err(SecretStoreError::AlreadyExists);
|
||||
}
|
||||
values.insert(locator.clone(), SecretBytes::new(value.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retrieve(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
_protection: SecretProtection,
|
||||
) -> Result<SecretBytes, SecretStoreError> {
|
||||
self.0
|
||||
.lock()
|
||||
.map_err(|_| SecretStoreError::Unavailable)?
|
||||
.get(locator)
|
||||
.map(|value| SecretBytes::new(value.expose().to_vec()))
|
||||
.ok_or(SecretStoreError::Missing)
|
||||
}
|
||||
|
||||
fn replace(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
_protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let mut values = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
let existing = values.get_mut(locator).ok_or(SecretStoreError::Missing)?;
|
||||
*existing = SecretBytes::new(value.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
_protection: SecretProtection,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
self.0
|
||||
.lock()
|
||||
.map_err(|_| SecretStoreError::Unavailable)?
|
||||
.remove(locator)
|
||||
.map(drop)
|
||||
.ok_or(SecretStoreError::Missing)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_and_usage_errors_have_stable_streams_and_exit_codes() -> TestResult {
|
||||
let mut stdout = Vec::new();
|
||||
@@ -170,7 +357,71 @@ mod tests {
|
||||
.expect("writing to memory cannot fail");
|
||||
assert_eq!(code, EXIT_UNAVAILABLE);
|
||||
assert!(stdout.is_empty());
|
||||
assert!(String::from_utf8(stderr)?.contains("not available yet"));
|
||||
assert!(String::from_utf8(stderr)?.contains("secret store is unavailable"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_session_unlocks_protected_entries_and_https_credentials_by_reference() -> TestResult {
|
||||
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
||||
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../crates/storage/tests/fixtures/compatibility");
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let config_path = temporary.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n[[git.remotes]]\nname = 'origin'\nurl = 'https://example.test/store.git'\nserver_id = 'fixture-server'\napplication_id = 'fixture-app'\n",
|
||||
fixtures.join("stores/basic"),
|
||||
FINGERPRINT,
|
||||
fixtures.join("keys"),
|
||||
),
|
||||
)?;
|
||||
let config = Config::load(Some(&config_path))?;
|
||||
let mut secrets = SecretStore::new(
|
||||
MemoryBackend::default(),
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
);
|
||||
secrets.unlock()?;
|
||||
secrets.create(
|
||||
&SecretReference::openpgp_passphrase(FINGERPRINT)?,
|
||||
SecretBytes::new(b"fixture-alice-passphrase".to_vec()),
|
||||
)?;
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
assert_eq!(
|
||||
execute_secure(
|
||||
&config,
|
||||
&CommandRequest::Show(ShowRequest {
|
||||
entry: Some("email/personal".to_owned()),
|
||||
presentation: Presentation::Terminal,
|
||||
}),
|
||||
&mut secrets,
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
)
|
||||
.expect("memory output cannot fail"),
|
||||
EXIT_SUCCESS
|
||||
);
|
||||
assert_eq!(
|
||||
stdout,
|
||||
fs::read(fixtures.join("expected/basic/email/personal.txt"))?
|
||||
);
|
||||
assert!(stderr.is_empty());
|
||||
|
||||
let remote = &config.git_remotes()[0];
|
||||
secrets.create(
|
||||
&SecretReference::https_git_credential(
|
||||
remote.server_id().as_str(),
|
||||
remote.application_id().as_str(),
|
||||
"fixture-account",
|
||||
)?,
|
||||
SecretBytes::new(b"fixture-token".to_vec()),
|
||||
)?;
|
||||
let credential = secrets.credential(remote.server_id(), remote.application_id())?;
|
||||
assert_eq!(credential.username(), "fixture-account");
|
||||
assert_eq!(credential.password(), b"fixture-token");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user