From c82c7926995cd5a1f71242b3cbebec8f2b342dc6 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 10 Aug 2026 07:43:19 +0000 Subject: [PATCH] Fix CLI passphrase provisioning Closes #60 --- apps/cli/src/main.rs | 133 +++++++++++++++++++- crates/storage/src/crypto.rs | 80 ++++++++++--- crates/storage/src/secret_store.rs | 163 +++++++++++++++++++++++-- crates/storage/tests/secret_store.rs | 173 ++++++++++++++++++++++++++- 4 files changed, 514 insertions(+), 35 deletions(-) diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 3b46cc9..e79d8b8 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -39,8 +39,8 @@ use ironstorage::{ }, repository::{DirectoryPath, Repository, SecretBytes}, secret_store::{ - NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStore, - SecretStoreBackend, + NativeSecretStore, OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, + SecretCachePolicy, SecretProtectionPolicy, SecretStore, SecretStoreBackend, }, write::{ EntryCommit, EntryCommitError, EntryCommitter, InsertContent, NoGitEntryCommitter, @@ -128,7 +128,7 @@ where ) .and_then(|store| { store.unlock()?; - Ok(store) + Ok(store.with_openpgp_passphrase_prompt(NativeOpenPgpPassphrasePrompt)) }) { Ok(secrets) => secrets, Err(error) => { @@ -1010,6 +1010,30 @@ trait CliInteraction: OtpInteraction { struct NativeOtpInteraction; +struct NativeOpenPgpPassphrasePrompt; + +impl OpenPgpPassphrasePrompt for NativeOpenPgpPassphrasePrompt { + fn request_passphrase( + &mut self, + key: &ironstorage::crypto::KeyInfo, + ) -> Result { + if !std::io::stdin().is_terminal() { + return Err(OpenPgpPassphrasePromptError::Unavailable); + } + rpassword::prompt_password(format!( + "Enter OpenPGP passphrase for {}: ", + key.fingerprint() + )) + .map(|value| SecretBytes::new(value.into_bytes())) + .map_err(|error| match error.kind() { + std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof => { + OpenPgpPassphrasePromptError::Cancelled + } + _ => OpenPgpPassphrasePromptError::Unavailable, + }) + } +} + impl OtpInteraction for NativeOtpInteraction { fn standard_input_is_terminal(&self) -> bool { std::io::stdin().is_terminal() @@ -1477,13 +1501,15 @@ mod tests { RemoveRequest, ShowRequest, }, config::Config, + crypto::KeyInfo, git::GitCredentialProvider as _, otp::OtpInput, presentation::{ClipboardTimeout, QrMatrix}, repository::{EntryPath, Repository, SecretBytes}, secret_store::{ - SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, - SecretReference, SecretStore, SecretStoreBackend, SecretStoreError, + OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, + SecretLocator, SecretProtection, SecretProtectionPolicy, SecretReference, SecretStore, + SecretStoreBackend, SecretStoreError, }, write::{InsertContent, OverwriteDecision}, }; @@ -1499,6 +1525,47 @@ mod tests { #[derive(Clone, Default)] struct MemoryBackend(Arc>>); + #[derive(Clone, Default)] + struct MemoryOpenPgpPrompt(Arc>); + + #[derive(Default)] + struct MemoryOpenPgpPromptState { + responses: VecDeque, OpenPgpPassphrasePromptError>>, + requests: Vec, + } + + impl MemoryOpenPgpPrompt { + fn with_passphrase(passphrase: &[u8]) -> Self { + let prompt = Self::default(); + prompt + .0 + .lock() + .expect("test mutex") + .responses + .push_back(Ok(passphrase.to_vec())); + prompt + } + + fn requests(&self) -> Vec { + self.0.lock().expect("test mutex").requests.clone() + } + } + + impl OpenPgpPassphrasePrompt for MemoryOpenPgpPrompt { + fn request_passphrase( + &mut self, + key: &KeyInfo, + ) -> Result { + let mut state = self.0.lock().expect("test mutex"); + state.requests.push(key.fingerprint().as_str().to_owned()); + state + .responses + .pop_front() + .unwrap_or(Err(OpenPgpPassphrasePromptError::Unavailable)) + .map(SecretBytes::new) + } + } + #[derive(Default)] struct MemoryPresentation { clipboard: Vec>, @@ -1802,6 +1869,62 @@ mod tests { Ok(()) } + #[test] + fn cli_prompts_for_a_missing_openpgp_passphrase_then_stores_and_reuses_it() -> TestResult { + const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; + const PASSPHRASE: &[u8] = b"fixture-alice-passphrase"; + 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", + fixtures.join("stores/basic"), + FINGERPRINT, + fixtures.join("keys"), + ), + )?; + let config = Config::load(Some(&config_path))?; + let prompt = MemoryOpenPgpPrompt::with_passphrase(PASSPHRASE); + let mut secrets = SecretStore::new( + MemoryBackend::default(), + SecretCachePolicy::Disabled, + SecretProtectionPolicy::device_unlocked(), + ) + .with_openpgp_passphrase_prompt(prompt.clone()); + secrets.unlock()?; + let request = CommandRequest::Show(ShowRequest { + entry: Some("email/personal".to_owned()), + presentation: Presentation::Terminal, + }); + + for _ in 0..2 { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + execute_secure(&config, &request, &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()); + } + + assert_eq!(prompt.requests(), [FINGERPRINT.to_owned()]); + assert_eq!( + secrets + .retrieve(&SecretReference::openpgp_passphrase(FINGERPRINT)?)? + .expose(), + PASSPHRASE + ); + Ok(()) + } + #[test] fn cli_clipboard_and_qr_paths_never_emit_plaintext() -> TestResult { const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; diff --git a/crates/storage/src/crypto.rs b/crates/storage/src/crypto.rs index ebe5369..160d79a 100644 --- a/crates/storage/src/crypto.rs +++ b/crates/storage/src/crypto.rs @@ -93,13 +93,27 @@ impl KeyInfo { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SecretProviderError { + Missing, + Denied, Unavailable, Cancelled, + PersistenceFailed, } /// Supplies an unlock secret without exposing it to configuration or the key store. pub trait SecretProvider { fn secret_for(&mut self, key: &KeyInfo) -> Result; + + /// Confirm that the supplied secret successfully unlocked the key. + /// + /// Providers that provision secrets interactively can defer persistence + /// until this callback proves that the candidate was correct. + fn secret_accepted(&mut self, _key: &KeyInfo) -> Result<(), SecretProviderError> { + Ok(()) + } + + /// Report that the supplied secret did not unlock the key. + fn secret_rejected(&mut self, _key: &KeyInfo) {} } #[derive(Clone, Eq, PartialEq)] @@ -417,27 +431,27 @@ impl KeyStore { return Err(CryptoError::MissingSecretKey); } - let mut unavailable = None; + let mut provider_failure = None; let mut attempted = false; for (fingerprint, material) in &candidates { let secret = material.secret.as_ref().expect("filtered secret key"); - let supplied = if secret_requires_password(secret) { - let info = key_info(material); - match provider.secret_for(&info) { + let key = secret_requires_password(secret).then(|| key_info(material)); + let supplied = if let Some(info) = key.as_ref() { + match provider.secret_for(info) { Ok(passphrase) => passphrase, - Err(SecretProviderError::Unavailable) => { - unavailable.get_or_insert_with(|| CryptoError::SecretProvider { - fingerprint: (*fingerprint).clone(), - reason: SecretProviderError::Unavailable, - }); - continue; - } Err(reason @ SecretProviderError::Cancelled) => { return Err(CryptoError::SecretProvider { fingerprint: (*fingerprint).clone(), reason, }); } + Err(reason) => { + provider_failure.get_or_insert_with(|| CryptoError::SecretProvider { + fingerprint: (*fingerprint).clone(), + reason, + }); + continue; + } } } else { SecretBytes::new(Vec::new()) @@ -449,13 +463,24 @@ impl KeyStore { if let Ok(mut decrypted) = message.decrypt(&password, secret) && let Ok(plaintext) = decrypted.as_data_vec() { + if let Some(info) = key.as_ref() { + provider.secret_accepted(info).map_err(|reason| { + CryptoError::SecretProvider { + fingerprint: (*fingerprint).clone(), + reason, + } + })?; + } return Ok(SecretBytes::new(plaintext)); } + if let Some(info) = key.as_ref() { + provider.secret_rejected(info); + } } if attempted { Err(CryptoError::DecryptionFailed) } else { - Err(unavailable.expect("every candidate was unavailable")) + Err(provider_failure.expect("every candidate had a provider failure")) } } @@ -475,21 +500,38 @@ impl KeyStore { let target = signing_target(secret).ok_or_else(|| CryptoError::MissingSigningKey { fingerprint: signer.0.clone(), })?; - let supplied = if signing_target_requires_password(target) { - provider.secret_for(&key_info(material)).map_err(|reason| { - CryptoError::SecretProvider { + let key = signing_target_requires_password(target).then(|| key_info(material)); + let supplied = if let Some(info) = key.as_ref() { + provider + .secret_for(info) + .map_err(|reason| CryptoError::SecretProvider { fingerprint: signer.0.clone(), reason, - } - })? + })? } else { SecretBytes::new(Vec::new()) }; let password = Password::from(supplied.expose()); - let signature = match target { + let signature = match match target { SigningTarget::Primary(key) => sign_data(key, &password, data), SigningTarget::Subkey(key) => sign_data(key, &password, data), - }?; + } { + Ok(signature) => signature, + Err(error) => { + if let Some(info) = key.as_ref() { + provider.secret_rejected(info); + } + return Err(error); + } + }; + if let Some(info) = key.as_ref() { + provider + .secret_accepted(info) + .map_err(|reason| CryptoError::SecretProvider { + fingerprint: signer.0.clone(), + reason, + })?; + } let mut bytes = Vec::new(); signature .to_writer(&mut bytes) diff --git a/crates/storage/src/secret_store.rs b/crates/storage/src/secret_store.rs index 1e82678..581ff85 100644 --- a/crates/storage/src/secret_store.rs +++ b/crates/storage/src/secret_store.rs @@ -25,6 +25,8 @@ const RECORD_VERSION: u8 = 1; const MAX_SECRET_BYTES: usize = 1024; const MAX_CACHE_LIFETIME: Duration = Duration::from_secs(15 * 60); const MAX_CACHE_CAPACITY: usize = 128; +const OPENPGP_PASSPHRASE_SERVICE: &str = "de.rfc1437.ironstorage.openpgp-passphrase"; +const HTTPS_GIT_SERVICE: &str = "de.rfc1437.ironstorage.https-git"; /// The purpose and stable, non-secret identity of an OS credential. #[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] @@ -154,15 +156,12 @@ impl SecretLocator { fn service_and_user(&self) -> (&'static str, String) { match self { Self::OpenPgpPassphrase { fingerprint } => { - ("org.ironstorage.openpgp-passphrase", fingerprint.clone()) + (OPENPGP_PASSPHRASE_SERVICE, fingerprint.clone()) } Self::HttpsGitCredential { server_id, application_id, - } => ( - "org.ironstorage.https-git", - format!("{server_id}/{application_id}"), - ), + } => (HTTPS_GIT_SERVICE, format!("{server_id}/{application_id}")), } } } @@ -276,6 +275,24 @@ impl fmt::Display for SecretStoreError { impl Error for SecretStoreError {} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OpenPgpPassphrasePromptError { + Cancelled, + Unavailable, +} + +/// Collects an OpenPGP passphrase for storage-owned provisioning. +/// +/// Implementations are presentation adapters only. Storage decides when a +/// prompt is required and persists the candidate only after the crypto layer +/// confirms that it successfully unlocked the requested key. +pub trait OpenPgpPassphrasePrompt: Send + Sync { + fn request_passphrase( + &mut self, + key: &KeyInfo, + ) -> Result; +} + /// Mockable contract implemented by each operating-system adapter. pub trait SecretStoreBackend: Send + Sync { fn create( @@ -318,6 +335,12 @@ struct StoreState { unlocked: bool, sequence: u64, cache: BTreeMap, + pending_openpgp: Option, +} + +struct PendingOpenPgpPassphrase { + reference: SecretReference, + value: SecretBytes, } pub struct SecretStore { @@ -325,6 +348,7 @@ pub struct SecretStore { cache_policy: SecretCachePolicy, protections: SecretProtectionPolicy, state: Mutex, + openpgp_prompt: Option>, } impl fmt::Debug for SecretStore { @@ -352,10 +376,20 @@ impl SecretStore { unlocked: false, sequence: 0, cache: BTreeMap::new(), + pending_openpgp: None, }), + openpgp_prompt: None, } } + pub fn with_openpgp_passphrase_prompt( + mut self, + prompt: impl OpenPgpPassphrasePrompt + 'static, + ) -> Self { + self.openpgp_prompt = Some(Box::new(prompt)); + self + } + pub fn is_locked(&self) -> bool { self.state.lock().map_or(true, |state| !state.unlocked) } @@ -377,6 +411,7 @@ impl SecretStore { .map_err(|_| SecretStoreError::Unavailable)?; state.unlocked = false; state.cache.clear(); + state.pending_openpgp = None; self.backend.lock() } @@ -503,6 +538,17 @@ impl SecretStore { Ok(state) } + fn clear_pending_openpgp(&self, reference: &SecretReference) { + if let Ok(mut state) = self.state.lock() + && state + .pending_openpgp + .as_ref() + .is_some_and(|pending| &pending.reference == reference) + { + state.pending_openpgp = None; + } + } + fn cache_get(&self, state: &mut StoreState, locator: &SecretLocator) -> Option { let SecretCachePolicy::Timed { .. } = self.cache_policy else { return None; @@ -563,7 +609,63 @@ impl SecretProvider for SecretStore { fn secret_for(&mut self, key: &KeyInfo) -> Result { let reference = SecretReference::openpgp_passphrase(key.fingerprint().as_str()) .map_err(|_| SecretProviderError::Unavailable)?; - self.retrieve(&reference).map_err(provider_error) + self.clear_pending_openpgp(&reference); + match self.retrieve(&reference) { + Ok(secret) => Ok(secret), + Err(SecretStoreError::Missing) if self.openpgp_prompt.is_some() => { + let candidate = self + .openpgp_prompt + .as_mut() + .expect("checked prompt") + .request_passphrase(key) + .map_err(prompt_error)?; + validate_secret(&candidate).map_err(|_| SecretProviderError::PersistenceFailed)?; + let output = copy_secret(&candidate); + let mut state = self + .state + .lock() + .map_err(|_| SecretProviderError::Unavailable)?; + if !state.unlocked { + return Err(SecretProviderError::Unavailable); + } + state.pending_openpgp = Some(PendingOpenPgpPassphrase { + reference, + value: candidate, + }); + Ok(output) + } + Err(error) => Err(provider_error(error)), + } + } + + fn secret_accepted(&mut self, key: &KeyInfo) -> Result<(), SecretProviderError> { + let reference = SecretReference::openpgp_passphrase(key.fingerprint().as_str()) + .map_err(|_| SecretProviderError::Unavailable)?; + let pending = { + let mut state = self + .state + .lock() + .map_err(|_| SecretProviderError::Unavailable)?; + match state.pending_openpgp.take() { + Some(pending) if pending.reference == reference => Some(pending), + Some(pending) => { + state.pending_openpgp = Some(pending); + None + } + None => None, + } + }; + if let Some(pending) = pending { + self.create(&pending.reference, pending.value) + .map_err(persistence_error)?; + } + Ok(()) + } + + fn secret_rejected(&mut self, key: &KeyInfo) { + if let Ok(reference) = SecretReference::openpgp_passphrase(key.fingerprint().as_str()) { + self.clear_pending_openpgp(&reference); + } } } @@ -591,11 +693,31 @@ impl GitCredentialProvider for SecretStore { fn provider_error(error: SecretStoreError) -> SecretProviderError { match error { + SecretStoreError::Missing => SecretProviderError::Missing, + SecretStoreError::Denied => SecretProviderError::Denied, SecretStoreError::Cancelled => SecretProviderError::Cancelled, _ => SecretProviderError::Unavailable, } } +fn prompt_error(error: OpenPgpPassphrasePromptError) -> SecretProviderError { + match error { + OpenPgpPassphrasePromptError::Cancelled => SecretProviderError::Cancelled, + OpenPgpPassphrasePromptError::Unavailable => SecretProviderError::Unavailable, + } +} + +fn persistence_error(error: SecretStoreError) -> SecretProviderError { + match error { + SecretStoreError::Denied => SecretProviderError::Denied, + SecretStoreError::Cancelled => SecretProviderError::Cancelled, + SecretStoreError::Unavailable | SecretStoreError::Locked => { + SecretProviderError::Unavailable + } + _ => SecretProviderError::PersistenceFailed, + } +} + fn git_provider_error(error: SecretStoreError) -> GitError { match error { SecretStoreError::Cancelled => GitError::CredentialCancelled, @@ -746,7 +868,10 @@ fn copy_secret(value: &SecretBytes) -> SecretBytes { #[cfg(test)] mod tests { - use super::{RECORD_MAGIC, RECORD_VERSION, SecretBytes, SecretStoreError, decode_record}; + use super::{ + HTTPS_GIT_SERVICE, OPENPGP_PASSPHRASE_SERVICE, RECORD_MAGIC, RECORD_VERSION, SecretBytes, + SecretLocator, SecretStoreError, decode_record, + }; fn openpgp_record(fingerprint: &[u8], secret: &[u8]) -> SecretBytes { let mut record = Vec::new(); @@ -774,4 +899,28 @@ mod tests { Err(SecretStoreError::Corrupted) )); } + + #[test] + fn native_service_names_use_the_canonical_bundle_identifier() { + let openpgp = SecretLocator::OpenPgpPassphrase { + fingerprint: "0123456789ABCDEF0123456789ABCDEF01234567".to_owned(), + }; + let git = SecretLocator::HttpsGitCredential { + server_id: "server".to_owned(), + application_id: "application".to_owned(), + }; + assert_eq!( + openpgp.service_and_user(), + ( + OPENPGP_PASSPHRASE_SERVICE, + "0123456789ABCDEF0123456789ABCDEF01234567".to_owned() + ) + ); + assert_eq!( + git.service_and_user(), + (HTTPS_GIT_SERVICE, "server/application".to_owned()) + ); + assert!(OPENPGP_PASSPHRASE_SERVICE.starts_with("de.rfc1437.ironstorage.")); + assert!(HTTPS_GIT_SERVICE.starts_with("de.rfc1437.ironstorage.")); + } } diff --git a/crates/storage/tests/secret_store.rs b/crates/storage/tests/secret_store.rs index 7ed1a6e..e1f9799 100644 --- a/crates/storage/tests/secret_store.rs +++ b/crates/storage/tests/secret_store.rs @@ -3,7 +3,7 @@ mod support; use std::{ - collections::BTreeMap, + collections::{BTreeMap, VecDeque}, error::Error, num::NonZeroUsize, sync::{Arc, Mutex}, @@ -12,12 +12,13 @@ use std::{ use ironstorage::{ config::ConfigLoader, - crypto::KeyStore, + crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError}, git::{GitCredentialProvider as _, GitError}, repository::{EncryptedEntry, SecretBytes}, secret_store::{ - SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, - SecretReference, SecretStore, SecretStoreBackend, SecretStoreError, + OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretLocator, + SecretProtection, SecretProtectionPolicy, SecretReference, SecretStore, SecretStoreBackend, + SecretStoreError, }, }; use support::compatibility::FixtureSet; @@ -28,6 +29,7 @@ type TestResult = Result<(), Box>; struct MemoryState { values: BTreeMap, fault: Option, + create_fault: Option, retrieves: usize, protections: Vec, } @@ -40,6 +42,10 @@ impl MemoryBackend { 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"); @@ -71,6 +77,9 @@ impl SecretStoreBackend for MemoryBackend { ) -> 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); @@ -140,6 +149,47 @@ impl SecretStoreBackend for MemoryBackend { } } +#[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, @@ -339,6 +389,121 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult 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(()) +} + 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"))?;