Implement secure OS-backed secret storage
This commit is contained in:
351
crates/storage/tests/secret_store.rs
Normal file
351
crates/storage/tests/secret_store.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod support;
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
error::Error,
|
||||
num::NonZeroUsize,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ironstorage::{
|
||||
config::ConfigLoader,
|
||||
crypto::KeyStore,
|
||||
git::{GitCredentialProvider as _, GitError},
|
||||
repository::{EncryptedEntry, SecretBytes},
|
||||
secret_store::{
|
||||
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy,
|
||||
SecretReference, SecretStore, SecretStoreBackend, SecretStoreError,
|
||||
},
|
||||
};
|
||||
use support::compatibility::FixtureSet;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryState {
|
||||
values: BTreeMap<SecretLocator, SecretBytes>,
|
||||
fault: Option<SecretStoreError>,
|
||||
retrieves: usize,
|
||||
protections: Vec<SecretProtection>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct MemoryBackend(Arc<Mutex<MemoryState>>);
|
||||
|
||||
impl MemoryBackend {
|
||||
fn fail_next(&self, error: SecretStoreError) {
|
||||
self.0.lock().expect("test mutex").fault = Some(error);
|
||||
}
|
||||
|
||||
fn corrupt_first(&self) {
|
||||
let mut state = self.0.lock().expect("test mutex");
|
||||
let value = state.values.values_mut().next().expect("stored test value");
|
||||
*value = SecretBytes::new(b"not an IronStorage record".to_vec());
|
||||
}
|
||||
|
||||
fn retrieves(&self) -> usize {
|
||||
self.0.lock().expect("test mutex").retrieves
|
||||
}
|
||||
|
||||
fn protections(&self) -> Vec<SecretProtection> {
|
||||
self.0.lock().expect("test mutex").protections.clone()
|
||||
}
|
||||
|
||||
fn take_fault(state: &mut MemoryState) -> Result<(), SecretStoreError> {
|
||||
match state.fault.take() {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretStoreBackend for MemoryBackend {
|
||||
fn create(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)?;
|
||||
state.protections.push(protection);
|
||||
if state.values.contains_key(locator) {
|
||||
return Err(SecretStoreError::AlreadyExists);
|
||||
}
|
||||
state
|
||||
.values
|
||||
.insert(locator.clone(), SecretBytes::new(value.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retrieve(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<SecretBytes, SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)?;
|
||||
state.retrieves += 1;
|
||||
state.protections.push(protection);
|
||||
state
|
||||
.values
|
||||
.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 state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)?;
|
||||
state.protections.push(protection);
|
||||
let existing = state
|
||||
.values
|
||||
.get_mut(locator)
|
||||
.ok_or(SecretStoreError::Missing)?;
|
||||
*existing = SecretBytes::new(value.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)?;
|
||||
state.protections.push(protection);
|
||||
state
|
||||
.values
|
||||
.remove(locator)
|
||||
.map(drop)
|
||||
.ok_or(SecretStoreError::Missing)
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)
|
||||
}
|
||||
|
||||
fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)
|
||||
}
|
||||
}
|
||||
|
||||
fn store(backend: MemoryBackend) -> SecretStore<MemoryBackend> {
|
||||
SecretStore::new(
|
||||
backend,
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
)
|
||||
}
|
||||
|
||||
fn fingerprint_reference() -> SecretReference {
|
||||
SecretReference::openpgp_passphrase("0123456789ABCDEF0123456789ABCDEF01234567")
|
||||
.expect("valid fingerprint")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_is_explicit_and_create_never_silently_replaces() -> TestResult {
|
||||
let backend = MemoryBackend::default();
|
||||
let store = store(backend);
|
||||
let reference = fingerprint_reference();
|
||||
|
||||
assert!(matches!(
|
||||
store.retrieve(&reference),
|
||||
Err(SecretStoreError::Locked)
|
||||
));
|
||||
store.unlock()?;
|
||||
assert!(matches!(
|
||||
store.retrieve(&reference),
|
||||
Err(SecretStoreError::Missing)
|
||||
));
|
||||
store.create(&reference, SecretBytes::new(b"first".to_vec()))?;
|
||||
assert_eq!(store.retrieve(&reference)?.expose(), b"first");
|
||||
assert_eq!(
|
||||
store.create(&reference, SecretBytes::new(b"other".to_vec())),
|
||||
Err(SecretStoreError::AlreadyExists)
|
||||
);
|
||||
store.replace(&reference, SecretBytes::new(b"second".to_vec()))?;
|
||||
assert_eq!(store.retrieve(&reference)?.expose(), b"second");
|
||||
store.delete(&reference)?;
|
||||
assert!(matches!(
|
||||
store.retrieve(&reference),
|
||||
Err(SecretStoreError::Missing)
|
||||
));
|
||||
assert_eq!(store.delete(&reference), Err(SecretStoreError::Missing));
|
||||
store.lock()?;
|
||||
assert!(store.is_locked());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_cancelled_unavailable_and_corrupted_are_typed_and_redacted() -> TestResult {
|
||||
let backend = MemoryBackend::default();
|
||||
backend.fail_next(SecretStoreError::Cancelled);
|
||||
let store = store(backend.clone());
|
||||
assert_eq!(store.unlock(), Err(SecretStoreError::Cancelled));
|
||||
assert!(store.is_locked());
|
||||
store.unlock()?;
|
||||
|
||||
let reference = SecretReference::https_git_credential(
|
||||
"fixture-server",
|
||||
"fixture-app",
|
||||
"secret-account-name",
|
||||
)?;
|
||||
backend.fail_next(SecretStoreError::Denied);
|
||||
assert!(matches!(
|
||||
store.retrieve(&reference),
|
||||
Err(SecretStoreError::Denied)
|
||||
));
|
||||
backend.fail_next(SecretStoreError::Unavailable);
|
||||
assert!(matches!(
|
||||
store.retrieve(&reference),
|
||||
Err(SecretStoreError::Unavailable)
|
||||
));
|
||||
store.create(&reference, SecretBytes::new(b"token".to_vec()))?;
|
||||
backend.corrupt_first();
|
||||
assert!(matches!(
|
||||
store.retrieve(&reference),
|
||||
Err(SecretStoreError::Corrupted)
|
||||
));
|
||||
|
||||
let rendered = format!("{reference:?} {store:?} {}", SecretStoreError::Corrupted);
|
||||
assert!(!rendered.contains("secret-account-name"));
|
||||
assert!(!rendered.contains("not an IronStorage record"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResult {
|
||||
let backend = MemoryBackend::default();
|
||||
let policy = SecretCachePolicy::timed(
|
||||
Duration::from_secs(60),
|
||||
NonZeroUsize::new(1).expect("non-zero"),
|
||||
)?;
|
||||
let store = SecretStore::new(
|
||||
backend.clone(),
|
||||
policy,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
);
|
||||
store.unlock()?;
|
||||
let alice = SecretReference::https_git_credential("server", "application", "alice")?;
|
||||
store.create(&alice, SecretBytes::new(b"token".to_vec()))?;
|
||||
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
|
||||
assert_eq!(
|
||||
backend.retrieves(),
|
||||
0,
|
||||
"create populated the explicit cache"
|
||||
);
|
||||
|
||||
let bob = SecretReference::https_git_credential("server", "application", "bob")?;
|
||||
assert!(matches!(
|
||||
store.retrieve(&bob),
|
||||
Err(SecretStoreError::Missing)
|
||||
));
|
||||
assert_eq!(
|
||||
store.replace(&bob, SecretBytes::new(b"other".to_vec())),
|
||||
Err(SecretStoreError::Missing)
|
||||
);
|
||||
assert_eq!(store.delete(&bob), Err(SecretStoreError::Missing));
|
||||
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
|
||||
let passphrase = fingerprint_reference();
|
||||
store.create(&passphrase, SecretBytes::new(b"passphrase".to_vec()))?;
|
||||
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
|
||||
assert_eq!(backend.retrieves(), 3, "capacity evicted the older record");
|
||||
store.lock()?;
|
||||
store.unlock()?;
|
||||
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
|
||||
assert_eq!(backend.retrieves(), 4, "lock discarded cached bytes");
|
||||
assert!(
|
||||
SecretCachePolicy::timed(
|
||||
Duration::from_secs(16 * 60),
|
||||
NonZeroUsize::new(1).expect("non-zero")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let key = fixture.key("alice")?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let backend = MemoryBackend::default();
|
||||
let mut store = SecretStore::new(
|
||||
backend.clone(),
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::user_presence_for_openpgp(),
|
||||
);
|
||||
store.unlock()?;
|
||||
let passphrase = SecretReference::openpgp_passphrase(&key.primary_fingerprint)?;
|
||||
store.create(
|
||||
&passphrase,
|
||||
SecretBytes::new(key.passphrase.as_bytes().to_vec()),
|
||||
)?;
|
||||
let entry = fixture
|
||||
.generated
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.store == "basic" && entry.path == "email/personal.gpg")
|
||||
.expect("compatibility entry exists");
|
||||
assert_eq!(
|
||||
keys.decrypt(
|
||||
&EncryptedEntry::new(fixture.read(format!("stores/basic/{}", entry.path))?),
|
||||
&mut store,
|
||||
)?
|
||||
.expose(),
|
||||
fixture.read("expected/basic/email/personal.txt")?
|
||||
);
|
||||
|
||||
let temporary = tempfile::tempdir()?;
|
||||
fs_config(&temporary)?;
|
||||
let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native"))
|
||||
.load(Some(&temporary.path().join("config.toml")))?;
|
||||
let remote = &config.git_remotes()[0];
|
||||
backend.fail_next(SecretStoreError::Cancelled);
|
||||
assert!(matches!(
|
||||
store.credential(remote.server_id(), remote.application_id()),
|
||||
Err(GitError::CredentialCancelled)
|
||||
));
|
||||
backend.fail_next(SecretStoreError::Denied);
|
||||
assert!(matches!(
|
||||
store.credential(remote.server_id(), remote.application_id()),
|
||||
Err(GitError::CredentialAccessDenied)
|
||||
));
|
||||
let git = SecretReference::https_git_credential(
|
||||
remote.server_id().as_str(),
|
||||
remote.application_id().as_str(),
|
||||
"alice",
|
||||
)?;
|
||||
store.create(&git, SecretBytes::new(b"https-token".to_vec()))?;
|
||||
let credential = store.credential(remote.server_id(), remote.application_id())?;
|
||||
assert_eq!(credential.username(), "alice");
|
||||
assert_eq!(credential.password(), b"https-token");
|
||||
assert!(
|
||||
backend
|
||||
.protections()
|
||||
.contains(&SecretProtection::RequireUserPresence)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fs_config(temporary: &tempfile::TempDir) -> TestResult {
|
||||
std::fs::create_dir_all(temporary.path().join("keys"))?;
|
||||
std::fs::create_dir_all(temporary.path().join("vault"))?;
|
||||
std::fs::create_dir_all(temporary.path().join("native"))?;
|
||||
std::fs::write(
|
||||
temporary.path().join("config.toml"),
|
||||
"vault = 'vault'\ndefault_key = 'alice'\nkey_material = 'keys'\n[[git.remotes]]\nname = 'origin'\nurl = 'https://example.test/store.git'\nserver_id = 'server'\napplication_id = 'application'\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user