#![forbid(unsafe_code)] mod support; use std::{ collections::{BTreeMap, VecDeque}, error::Error, num::NonZeroUsize, sync::{Arc, Mutex}, time::Duration, }; use ironstorage::{ config::ConfigLoader, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError}, git::{GitCredentialProvider as _, GitError}, repository::{EncryptedEntry, SecretBytes}, secret_store::{ OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, SecretReference, SecretStore, SecretStoreBackend, SecretStoreError, }, }; use support::compatibility::FixtureSet; type TestResult = Result<(), Box>; #[derive(Default)] struct MemoryState { values: BTreeMap, fault: Option, create_fault: Option, retrieves: usize, protections: Vec, } #[derive(Clone, Default)] struct MemoryBackend(Arc>); 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 { 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)?; 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 { 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) } } #[derive(Clone, Default)] struct MemoryPrompt(Arc>); #[derive(Default)] struct MemoryPromptState { responses: VecDeque, OpenPgpPassphrasePromptError>>, requested: Vec, } 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 { self.0.lock().expect("test mutex").requested.clone() } } impl OpenPgpPassphrasePrompt for MemoryPrompt { fn request_passphrase( &mut self, key: &KeyInfo, ) -> Result { 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 { 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(()) } #[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(()) }