672 lines
22 KiB
Rust
672 lines
22 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>,
|
|
}
|
|
|
|
#[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 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 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<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(())
|
|
}
|