Files
IronStorage/crates/storage/tests/secret_store.rs
Chili Palmer b93ae852e0
Some checks failed
Dependency security audit / rustsec (push) Has been cancelled
Reuse native macOS Git credentials
2026-08-26 18:53:55 +02:00

733 lines
24 KiB
Rust

#![forbid(unsafe_code)]
mod support;
use std::{
collections::{BTreeMap, VecDeque},
error::Error,
num::NonZeroUsize,
sync::{Arc, Mutex},
time::Duration,
};
use ironstorage::{
config::{ConfigLoader, GitRemote, SshFingerprint},
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError},
git::{GitCredentialProvider as _, GitError, SshPassphraseProvider as _},
repository::{EncryptedEntry, SecretBytes},
secret_store::{
OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, 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>,
create_fault: Option<SecretStoreError>,
retrieves: usize,
protections: Vec<SecretProtection>,
platform_https: Option<(String, SecretBytes)>,
platform_https_requests: Vec<String>,
}
#[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 fail_next_create(&self, error: SecretStoreError) {
self.0.lock().expect("test mutex").create_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 with_platform_https(account: &str, password: &[u8]) -> Self {
let backend = Self::default();
backend.0.lock().expect("test mutex").platform_https =
Some((account.to_owned(), SecretBytes::new(password.to_vec())));
backend
}
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)?;
if let Some(error) = state.create_fault.take() {
return Err(error);
}
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 platform_https_git_credential(
&self,
endpoint: &url::Url,
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
state
.platform_https_requests
.push(endpoint.as_str().to_owned());
Ok(state.platform_https.as_ref().map(|(account, password)| {
(
account.clone(),
SecretBytes::new(password.expose().to_vec()),
)
}))
}
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)
}
}
#[test]
fn endpoint_lookup_uses_the_native_https_credential_before_private_storage() -> TestResult {
let backend = MemoryBackend::with_platform_https("alice", b"native-token");
let store = store(backend.clone());
store.unlock()?;
let remote = GitRemote::https(
"origin",
"https://example.test/team/store.git",
"server",
"application",
)?;
let (server, application) = remote.https_credentials().expect("HTTPS credentials");
store.store_https_git_credential(
server,
application,
"private-account",
SecretBytes::new(b"private-token".to_vec()),
)?;
let retrieves_before = backend.retrieves();
let credential = store.credential_for(&remote)?;
assert_eq!(credential.username(), "alice");
assert_eq!(credential.password(), b"native-token");
assert_eq!(backend.retrieves(), retrieves_before);
assert_eq!(
backend
.0
.lock()
.expect("test mutex")
.platform_https_requests,
["https://example.test/team/store.git".to_owned()]
);
Ok(())
}
#[derive(Clone, Default)]
struct MemoryPrompt(Arc<Mutex<MemoryPromptState>>);
#[derive(Default)]
struct MemoryPromptState {
responses: VecDeque<Result<Vec<u8>, OpenPgpPassphrasePromptError>>,
requested: Vec<String>,
}
impl MemoryPrompt {
fn responding(response: Result<&[u8], OpenPgpPassphrasePromptError>) -> Self {
let prompt = Self::default();
prompt
.0
.lock()
.expect("test mutex")
.responses
.push_back(response.map(<[u8]>::to_vec));
prompt
}
fn requested(&self) -> Vec<String> {
self.0.lock().expect("test mutex").requested.clone()
}
}
impl OpenPgpPassphrasePrompt for MemoryPrompt {
fn request_passphrase(
&mut self,
key: &KeyInfo,
) -> Result<SecretBytes, OpenPgpPassphrasePromptError> {
let mut state = self.0.lock().expect("test mutex");
state.requested.push(key.fingerprint().as_str().to_owned());
state
.responses
.pop_front()
.unwrap_or(Err(OpenPgpPassphrasePromptError::Unavailable))
.map(SecretBytes::new)
}
}
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 ssh_passphrases_are_retrieved_by_fingerprint_with_typed_access_failures() -> TestResult {
let backend = MemoryBackend::default();
let store = store(backend.clone());
store.unlock()?;
let fingerprint = SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")?;
let reference = SecretReference::ssh_key_passphrase(fingerprint.clone());
store.create(
&reference,
SecretBytes::new(b"protected-passphrase".to_vec()),
)?;
assert_eq!(
store.ssh_key_passphrase(&fingerprint)?.expose(),
b"protected-passphrase"
);
store.persist_verified_ssh_passphrase(
&fingerprint,
SecretBytes::new(b"verified-replacement".to_vec()),
)?;
assert_eq!(
store.ssh_key_passphrase(&fingerprint)?.expose(),
b"verified-replacement"
);
backend.fail_next(SecretStoreError::Denied);
assert!(matches!(
store.ssh_key_passphrase(&fingerprint),
Err(GitError::SshKeyPassphraseDenied { fingerprint: denied }) if denied == fingerprint
));
backend.fail_next(SecretStoreError::Cancelled);
assert!(matches!(
store.ssh_key_passphrase(&fingerprint),
Err(GitError::SshKeyPassphraseCancelled { fingerprint: cancelled })
if cancelled == fingerprint
));
let missing = SshFingerprint::parse("SHA256:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE")?;
assert!(matches!(
store.ssh_key_passphrase(&missing),
Err(GitError::SshKeyPassphraseUnavailable { fingerprint }) if fingerprint == missing
));
assert!(!format!("{reference:?}").contains("protected-passphrase"));
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 git_account_status_and_removal_never_expose_the_token() -> TestResult {
let backend = MemoryBackend::default();
let store = SecretStore::new(
backend,
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
);
let remote = GitRemote::https(
"origin",
"https://git.example.test/alice/store.git",
"personal-git",
"ironstorage-mobile",
)?;
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
store.unlock()?;
store.store_https_git_credential(
server_id,
application_id,
"alice",
SecretBytes::new(b"private-token".to_vec()),
)?;
assert_eq!(
store.https_git_credential_account(server_id, application_id)?,
"alice"
);
store.delete_https_git_credential(server_id, application_id)?;
assert!(matches!(
store.https_git_credential_account(server_id, application_id),
Err(SecretStoreError::Missing)
));
assert!(!format!("{store:?}").contains("private-token"));
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];
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
backend.fail_next(SecretStoreError::Cancelled);
assert!(matches!(
store.credential(server_id, application_id),
Err(GitError::CredentialCancelled)
));
backend.fail_next(SecretStoreError::Denied);
assert!(matches!(
store.credential(server_id, application_id),
Err(GitError::CredentialAccessDenied)
));
let git = SecretReference::https_git_credential(
server_id.as_str(),
application_id.as_str(),
"alice",
)?;
store.create(&git, SecretBytes::new(b"https-token".to_vec()))?;
let credential = store.credential(server_id, application_id)?;
assert_eq!(credential.username(), "alice");
assert_eq!(credential.password(), b"https-token");
store.store_https_git_credential(
server_id,
application_id,
"bob",
SecretBytes::new(b"replacement-token".to_vec()),
)?;
assert!(matches!(
store.retrieve(&git),
Err(SecretStoreError::Missing)
));
let credential = store.credential(server_id, application_id)?;
assert_eq!(credential.username(), "bob");
assert_eq!(credential.password(), b"replacement-token");
assert!(
backend
.protections()
.contains(&SecretProtection::RequireUserPresence)
);
Ok(())
}
#[test]
fn missing_openpgp_passphrases_are_prompted_verified_and_only_then_persisted() -> TestResult {
let fixture = FixtureSet::load()?;
let key = fixture.key("alice")?;
let keys = KeyStore::load(fixture.path("keys"))?;
let entry = fixture
.generated
.entries
.iter()
.find(|entry| entry.store == "basic" && entry.path == "email/personal.gpg")
.expect("compatibility entry exists");
let ciphertext = EncryptedEntry::new(fixture.read(format!("stores/basic/{}", entry.path))?);
let expected = fixture.read("expected/basic/email/personal.txt")?;
let reference = SecretReference::openpgp_passphrase(&key.primary_fingerprint)?;
let key_info = keys
.infos()
.find(|info| info.fingerprint().as_str() == key.primary_fingerprint)
.expect("fixture key info");
let mut unprompted = store(MemoryBackend::default());
unprompted.unlock()?;
assert!(matches!(
unprompted.secret_for(&key_info),
Err(SecretProviderError::Missing)
));
let backend = MemoryBackend::default();
let prompt = MemoryPrompt::responding(Ok(key.passphrase.as_bytes()));
let mut provisioned = store(backend).with_openpgp_passphrase_prompt(prompt.clone());
provisioned.unlock()?;
assert_eq!(
keys.decrypt(&ciphertext, &mut provisioned)?.expose(),
expected
);
assert_eq!(
provisioned.retrieve(&reference)?.expose(),
key.passphrase.as_bytes()
);
assert_eq!(
keys.decrypt(&ciphertext, &mut provisioned)?.expose(),
expected
);
assert_eq!(
prompt.requested(),
std::slice::from_ref(&key.primary_fingerprint)
);
let wrong_backend = MemoryBackend::default();
let wrong_prompt = MemoryPrompt::responding(Ok(b"incorrect passphrase"));
let mut wrong = store(wrong_backend).with_openpgp_passphrase_prompt(wrong_prompt.clone());
wrong.unlock()?;
let wrong_error = keys
.decrypt(&ciphertext, &mut wrong)
.expect_err("incorrect prompted passphrase must fail");
assert!(matches!(&wrong_error, CryptoError::DecryptionFailed));
assert!(!format!("{wrong_error:?} {wrong_error}").contains("incorrect passphrase"));
assert!(matches!(
wrong.retrieve(&reference),
Err(SecretStoreError::Missing)
));
assert_eq!(
wrong_prompt.requested(),
std::slice::from_ref(&key.primary_fingerprint)
);
let cancelled_prompt = MemoryPrompt::responding(Err(OpenPgpPassphrasePromptError::Cancelled));
let mut cancelled =
store(MemoryBackend::default()).with_openpgp_passphrase_prompt(cancelled_prompt);
cancelled.unlock()?;
assert!(matches!(
keys.decrypt(&ciphertext, &mut cancelled),
Err(CryptoError::SecretProvider {
reason: SecretProviderError::Cancelled,
..
})
));
assert!(matches!(
cancelled.retrieve(&reference),
Err(SecretStoreError::Missing)
));
let denied_backend = MemoryBackend::default();
let denied_prompt = MemoryPrompt::responding(Ok(key.passphrase.as_bytes()));
let mut denied =
store(denied_backend.clone()).with_openpgp_passphrase_prompt(denied_prompt.clone());
denied.unlock()?;
denied_backend.fail_next(SecretStoreError::Denied);
assert!(matches!(
keys.decrypt(&ciphertext, &mut denied),
Err(CryptoError::SecretProvider {
reason: SecretProviderError::Denied,
..
})
));
assert!(denied_prompt.requested().is_empty());
let failed_backend = MemoryBackend::default();
let failed_prompt = MemoryPrompt::responding(Ok(key.passphrase.as_bytes()));
let mut failed = store(failed_backend.clone()).with_openpgp_passphrase_prompt(failed_prompt);
failed.unlock()?;
failed_backend.fail_next_create(SecretStoreError::AlreadyExists);
assert!(matches!(
keys.decrypt(&ciphertext, &mut failed),
Err(CryptoError::SecretProvider {
reason: SecretProviderError::PersistenceFailed,
..
})
));
assert!(matches!(
failed.retrieve(&reference),
Err(SecretStoreError::Missing)
));
Ok(())
}
#[test]
fn gnupg_aead_passphrases_are_persisted_only_after_authenticated_decryption() -> TestResult {
let fixture = FixtureSet::load()?;
let key = fixture.key("alice")?;
let keys = KeyStore::load(fixture.path(&key.secret_armor))?;
let ciphertext = fixture.read(&fixture.gnupg_aead.entry)?;
let expected = fixture.read(&fixture.gnupg_aead.plaintext)?;
let reference = SecretReference::openpgp_passphrase(&key.primary_fingerprint)?;
let prompt = MemoryPrompt::responding(Ok(key.passphrase.as_bytes()));
let mut provisioned =
store(MemoryBackend::default()).with_openpgp_passphrase_prompt(prompt.clone());
provisioned.unlock()?;
assert_eq!(
keys.decrypt(&EncryptedEntry::new(ciphertext.clone()), &mut provisioned)?
.expose(),
expected
);
assert_eq!(
provisioned.retrieve(&reference)?.expose(),
key.passphrase.as_bytes()
);
assert_eq!(
prompt.requested(),
std::slice::from_ref(&key.primary_fingerprint)
);
let wrong_prompt = MemoryPrompt::responding(Ok(b"incorrect AEAD passphrase"));
let mut wrong =
store(MemoryBackend::default()).with_openpgp_passphrase_prompt(wrong_prompt.clone());
wrong.unlock()?;
assert!(matches!(
keys.decrypt(&EncryptedEntry::new(ciphertext.clone()), &mut wrong),
Err(CryptoError::DecryptionFailed)
));
assert!(matches!(
wrong.retrieve(&reference),
Err(SecretStoreError::Missing)
));
assert_eq!(
wrong_prompt.requested(),
std::slice::from_ref(&key.primary_fingerprint)
);
let mut tampered_ciphertext = ciphertext;
*tampered_ciphertext
.last_mut()
.expect("non-empty AEAD fixture") ^= 0x01;
let tampered_prompt = MemoryPrompt::responding(Ok(key.passphrase.as_bytes()));
let mut tampered =
store(MemoryBackend::default()).with_openpgp_passphrase_prompt(tampered_prompt);
tampered.unlock()?;
assert!(matches!(
keys.decrypt(&EncryptedEntry::new(tampered_ciphertext), &mut tampered),
Err(CryptoError::DecryptionFailed)
));
assert!(matches!(
tampered.retrieve(&reference),
Err(SecretStoreError::Missing)
));
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(())
}