Implement secure OS-backed secret storage

This commit is contained in:
Hermes Agent
2026-08-10 00:23:40 +00:00
parent 75ce19da00
commit b685a4864c
14 changed files with 1838 additions and 9 deletions

View File

@@ -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(())
}
}