Fix CLI passphrase provisioning

Closes #60
This commit is contained in:
Hermes Agent
2026-08-10 07:43:19 +00:00
parent b503de8b2e
commit c82c792699
4 changed files with 514 additions and 35 deletions

View File

@@ -39,8 +39,8 @@ use ironstorage::{
}, },
repository::{DirectoryPath, Repository, SecretBytes}, repository::{DirectoryPath, Repository, SecretBytes},
secret_store::{ secret_store::{
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStore, NativeSecretStore, OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError,
SecretStoreBackend, SecretCachePolicy, SecretProtectionPolicy, SecretStore, SecretStoreBackend,
}, },
write::{ write::{
EntryCommit, EntryCommitError, EntryCommitter, InsertContent, NoGitEntryCommitter, EntryCommit, EntryCommitError, EntryCommitter, InsertContent, NoGitEntryCommitter,
@@ -128,7 +128,7 @@ where
) )
.and_then(|store| { .and_then(|store| {
store.unlock()?; store.unlock()?;
Ok(store) Ok(store.with_openpgp_passphrase_prompt(NativeOpenPgpPassphrasePrompt))
}) { }) {
Ok(secrets) => secrets, Ok(secrets) => secrets,
Err(error) => { Err(error) => {
@@ -1010,6 +1010,30 @@ trait CliInteraction: OtpInteraction {
struct NativeOtpInteraction; struct NativeOtpInteraction;
struct NativeOpenPgpPassphrasePrompt;
impl OpenPgpPassphrasePrompt for NativeOpenPgpPassphrasePrompt {
fn request_passphrase(
&mut self,
key: &ironstorage::crypto::KeyInfo,
) -> Result<SecretBytes, OpenPgpPassphrasePromptError> {
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 { impl OtpInteraction for NativeOtpInteraction {
fn standard_input_is_terminal(&self) -> bool { fn standard_input_is_terminal(&self) -> bool {
std::io::stdin().is_terminal() std::io::stdin().is_terminal()
@@ -1477,13 +1501,15 @@ mod tests {
RemoveRequest, ShowRequest, RemoveRequest, ShowRequest,
}, },
config::Config, config::Config,
crypto::KeyInfo,
git::GitCredentialProvider as _, git::GitCredentialProvider as _,
otp::OtpInput, otp::OtpInput,
presentation::{ClipboardTimeout, QrMatrix}, presentation::{ClipboardTimeout, QrMatrix},
repository::{EntryPath, Repository, SecretBytes}, repository::{EntryPath, Repository, SecretBytes},
secret_store::{ secret_store::{
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy,
SecretReference, SecretStore, SecretStoreBackend, SecretStoreError, SecretLocator, SecretProtection, SecretProtectionPolicy, SecretReference, SecretStore,
SecretStoreBackend, SecretStoreError,
}, },
write::{InsertContent, OverwriteDecision}, write::{InsertContent, OverwriteDecision},
}; };
@@ -1499,6 +1525,47 @@ mod tests {
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct MemoryBackend(Arc<Mutex<BTreeMap<SecretLocator, SecretBytes>>>); struct MemoryBackend(Arc<Mutex<BTreeMap<SecretLocator, SecretBytes>>>);
#[derive(Clone, Default)]
struct MemoryOpenPgpPrompt(Arc<Mutex<MemoryOpenPgpPromptState>>);
#[derive(Default)]
struct MemoryOpenPgpPromptState {
responses: VecDeque<Result<Vec<u8>, OpenPgpPassphrasePromptError>>,
requests: Vec<String>,
}
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<String> {
self.0.lock().expect("test mutex").requests.clone()
}
}
impl OpenPgpPassphrasePrompt for MemoryOpenPgpPrompt {
fn request_passphrase(
&mut self,
key: &KeyInfo,
) -> Result<SecretBytes, OpenPgpPassphrasePromptError> {
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)] #[derive(Default)]
struct MemoryPresentation { struct MemoryPresentation {
clipboard: Vec<Vec<u8>>, clipboard: Vec<Vec<u8>>,
@@ -1802,6 +1869,62 @@ mod tests {
Ok(()) 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] #[test]
fn cli_clipboard_and_qr_paths_never_emit_plaintext() -> TestResult { fn cli_clipboard_and_qr_paths_never_emit_plaintext() -> TestResult {
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";

View File

@@ -93,13 +93,27 @@ impl KeyInfo {
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SecretProviderError { pub enum SecretProviderError {
Missing,
Denied,
Unavailable, Unavailable,
Cancelled, Cancelled,
PersistenceFailed,
} }
/// Supplies an unlock secret without exposing it to configuration or the key store. /// Supplies an unlock secret without exposing it to configuration or the key store.
pub trait SecretProvider { pub trait SecretProvider {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError>; fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError>;
/// 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)] #[derive(Clone, Eq, PartialEq)]
@@ -417,27 +431,27 @@ impl KeyStore {
return Err(CryptoError::MissingSecretKey); return Err(CryptoError::MissingSecretKey);
} }
let mut unavailable = None; let mut provider_failure = None;
let mut attempted = false; let mut attempted = false;
for (fingerprint, material) in &candidates { for (fingerprint, material) in &candidates {
let secret = material.secret.as_ref().expect("filtered secret key"); let secret = material.secret.as_ref().expect("filtered secret key");
let supplied = if secret_requires_password(secret) { let key = secret_requires_password(secret).then(|| key_info(material));
let info = key_info(material); let supplied = if let Some(info) = key.as_ref() {
match provider.secret_for(&info) { match provider.secret_for(info) {
Ok(passphrase) => passphrase, Ok(passphrase) => passphrase,
Err(SecretProviderError::Unavailable) => {
unavailable.get_or_insert_with(|| CryptoError::SecretProvider {
fingerprint: (*fingerprint).clone(),
reason: SecretProviderError::Unavailable,
});
continue;
}
Err(reason @ SecretProviderError::Cancelled) => { Err(reason @ SecretProviderError::Cancelled) => {
return Err(CryptoError::SecretProvider { return Err(CryptoError::SecretProvider {
fingerprint: (*fingerprint).clone(), fingerprint: (*fingerprint).clone(),
reason, reason,
}); });
} }
Err(reason) => {
provider_failure.get_or_insert_with(|| CryptoError::SecretProvider {
fingerprint: (*fingerprint).clone(),
reason,
});
continue;
}
} }
} else { } else {
SecretBytes::new(Vec::new()) SecretBytes::new(Vec::new())
@@ -449,13 +463,24 @@ impl KeyStore {
if let Ok(mut decrypted) = message.decrypt(&password, secret) if let Ok(mut decrypted) = message.decrypt(&password, secret)
&& let Ok(plaintext) = decrypted.as_data_vec() && 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)); return Ok(SecretBytes::new(plaintext));
} }
if let Some(info) = key.as_ref() {
provider.secret_rejected(info);
}
} }
if attempted { if attempted {
Err(CryptoError::DecryptionFailed) Err(CryptoError::DecryptionFailed)
} else { } 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 { let target = signing_target(secret).ok_or_else(|| CryptoError::MissingSigningKey {
fingerprint: signer.0.clone(), fingerprint: signer.0.clone(),
})?; })?;
let supplied = if signing_target_requires_password(target) { let key = signing_target_requires_password(target).then(|| key_info(material));
provider.secret_for(&key_info(material)).map_err(|reason| { let supplied = if let Some(info) = key.as_ref() {
CryptoError::SecretProvider { provider
.secret_for(info)
.map_err(|reason| CryptoError::SecretProvider {
fingerprint: signer.0.clone(), fingerprint: signer.0.clone(),
reason, reason,
} })?
})?
} else { } else {
SecretBytes::new(Vec::new()) SecretBytes::new(Vec::new())
}; };
let password = Password::from(supplied.expose()); let password = Password::from(supplied.expose());
let signature = match target { let signature = match match target {
SigningTarget::Primary(key) => sign_data(key, &password, data), SigningTarget::Primary(key) => sign_data(key, &password, data),
SigningTarget::Subkey(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(); let mut bytes = Vec::new();
signature signature
.to_writer(&mut bytes) .to_writer(&mut bytes)

View File

@@ -25,6 +25,8 @@ const RECORD_VERSION: u8 = 1;
const MAX_SECRET_BYTES: usize = 1024; const MAX_SECRET_BYTES: usize = 1024;
const MAX_CACHE_LIFETIME: Duration = Duration::from_secs(15 * 60); const MAX_CACHE_LIFETIME: Duration = Duration::from_secs(15 * 60);
const MAX_CACHE_CAPACITY: usize = 128; 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. /// The purpose and stable, non-secret identity of an OS credential.
#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] #[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
@@ -154,15 +156,12 @@ impl SecretLocator {
fn service_and_user(&self) -> (&'static str, String) { fn service_and_user(&self) -> (&'static str, String) {
match self { match self {
Self::OpenPgpPassphrase { fingerprint } => { Self::OpenPgpPassphrase { fingerprint } => {
("org.ironstorage.openpgp-passphrase", fingerprint.clone()) (OPENPGP_PASSPHRASE_SERVICE, fingerprint.clone())
} }
Self::HttpsGitCredential { Self::HttpsGitCredential {
server_id, server_id,
application_id, application_id,
} => ( } => (HTTPS_GIT_SERVICE, format!("{server_id}/{application_id}")),
"org.ironstorage.https-git",
format!("{server_id}/{application_id}"),
),
} }
} }
} }
@@ -276,6 +275,24 @@ impl fmt::Display for SecretStoreError {
impl Error 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<SecretBytes, OpenPgpPassphrasePromptError>;
}
/// Mockable contract implemented by each operating-system adapter. /// Mockable contract implemented by each operating-system adapter.
pub trait SecretStoreBackend: Send + Sync { pub trait SecretStoreBackend: Send + Sync {
fn create( fn create(
@@ -318,6 +335,12 @@ struct StoreState {
unlocked: bool, unlocked: bool,
sequence: u64, sequence: u64,
cache: BTreeMap<SecretLocator, CachedSecret>, cache: BTreeMap<SecretLocator, CachedSecret>,
pending_openpgp: Option<PendingOpenPgpPassphrase>,
}
struct PendingOpenPgpPassphrase {
reference: SecretReference,
value: SecretBytes,
} }
pub struct SecretStore<B> { pub struct SecretStore<B> {
@@ -325,6 +348,7 @@ pub struct SecretStore<B> {
cache_policy: SecretCachePolicy, cache_policy: SecretCachePolicy,
protections: SecretProtectionPolicy, protections: SecretProtectionPolicy,
state: Mutex<StoreState>, state: Mutex<StoreState>,
openpgp_prompt: Option<Box<dyn OpenPgpPassphrasePrompt>>,
} }
impl<B> fmt::Debug for SecretStore<B> { impl<B> fmt::Debug for SecretStore<B> {
@@ -352,10 +376,20 @@ impl<B: SecretStoreBackend> SecretStore<B> {
unlocked: false, unlocked: false,
sequence: 0, sequence: 0,
cache: BTreeMap::new(), 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 { pub fn is_locked(&self) -> bool {
self.state.lock().map_or(true, |state| !state.unlocked) self.state.lock().map_or(true, |state| !state.unlocked)
} }
@@ -377,6 +411,7 @@ impl<B: SecretStoreBackend> SecretStore<B> {
.map_err(|_| SecretStoreError::Unavailable)?; .map_err(|_| SecretStoreError::Unavailable)?;
state.unlocked = false; state.unlocked = false;
state.cache.clear(); state.cache.clear();
state.pending_openpgp = None;
self.backend.lock() self.backend.lock()
} }
@@ -503,6 +538,17 @@ impl<B: SecretStoreBackend> SecretStore<B> {
Ok(state) 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<SecretBytes> { fn cache_get(&self, state: &mut StoreState, locator: &SecretLocator) -> Option<SecretBytes> {
let SecretCachePolicy::Timed { .. } = self.cache_policy else { let SecretCachePolicy::Timed { .. } = self.cache_policy else {
return None; return None;
@@ -563,7 +609,63 @@ impl<B: SecretStoreBackend> SecretProvider for SecretStore<B> {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> { fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
let reference = SecretReference::openpgp_passphrase(key.fingerprint().as_str()) let reference = SecretReference::openpgp_passphrase(key.fingerprint().as_str())
.map_err(|_| SecretProviderError::Unavailable)?; .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<B: SecretStoreBackend> GitCredentialProvider for SecretStore<B> {
fn provider_error(error: SecretStoreError) -> SecretProviderError { fn provider_error(error: SecretStoreError) -> SecretProviderError {
match error { match error {
SecretStoreError::Missing => SecretProviderError::Missing,
SecretStoreError::Denied => SecretProviderError::Denied,
SecretStoreError::Cancelled => SecretProviderError::Cancelled, SecretStoreError::Cancelled => SecretProviderError::Cancelled,
_ => SecretProviderError::Unavailable, _ => 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 { fn git_provider_error(error: SecretStoreError) -> GitError {
match error { match error {
SecretStoreError::Cancelled => GitError::CredentialCancelled, SecretStoreError::Cancelled => GitError::CredentialCancelled,
@@ -746,7 +868,10 @@ fn copy_secret(value: &SecretBytes) -> SecretBytes {
#[cfg(test)] #[cfg(test)]
mod tests { 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 { fn openpgp_record(fingerprint: &[u8], secret: &[u8]) -> SecretBytes {
let mut record = Vec::new(); let mut record = Vec::new();
@@ -774,4 +899,28 @@ mod tests {
Err(SecretStoreError::Corrupted) 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."));
}
} }

View File

@@ -3,7 +3,7 @@
mod support; mod support;
use std::{ use std::{
collections::BTreeMap, collections::{BTreeMap, VecDeque},
error::Error, error::Error,
num::NonZeroUsize, num::NonZeroUsize,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
@@ -12,12 +12,13 @@ use std::{
use ironstorage::{ use ironstorage::{
config::ConfigLoader, config::ConfigLoader,
crypto::KeyStore, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError},
git::{GitCredentialProvider as _, GitError}, git::{GitCredentialProvider as _, GitError},
repository::{EncryptedEntry, SecretBytes}, repository::{EncryptedEntry, SecretBytes},
secret_store::{ secret_store::{
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretLocator,
SecretReference, SecretStore, SecretStoreBackend, SecretStoreError, SecretProtection, SecretProtectionPolicy, SecretReference, SecretStore, SecretStoreBackend,
SecretStoreError,
}, },
}; };
use support::compatibility::FixtureSet; use support::compatibility::FixtureSet;
@@ -28,6 +29,7 @@ type TestResult = Result<(), Box<dyn Error>>;
struct MemoryState { struct MemoryState {
values: BTreeMap<SecretLocator, SecretBytes>, values: BTreeMap<SecretLocator, SecretBytes>,
fault: Option<SecretStoreError>, fault: Option<SecretStoreError>,
create_fault: Option<SecretStoreError>,
retrieves: usize, retrieves: usize,
protections: Vec<SecretProtection>, protections: Vec<SecretProtection>,
} }
@@ -40,6 +42,10 @@ impl MemoryBackend {
self.0.lock().expect("test mutex").fault = Some(error); 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) { fn corrupt_first(&self) {
let mut state = self.0.lock().expect("test mutex"); let mut state = self.0.lock().expect("test mutex");
let value = state.values.values_mut().next().expect("stored test value"); let value = state.values.values_mut().next().expect("stored test value");
@@ -71,6 +77,9 @@ impl SecretStoreBackend for MemoryBackend {
) -> Result<(), SecretStoreError> { ) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?; Self::take_fault(&mut state)?;
if let Some(error) = state.create_fault.take() {
return Err(error);
}
state.protections.push(protection); state.protections.push(protection);
if state.values.contains_key(locator) { if state.values.contains_key(locator) {
return Err(SecretStoreError::AlreadyExists); return Err(SecretStoreError::AlreadyExists);
@@ -140,6 +149,47 @@ impl SecretStoreBackend for MemoryBackend {
} }
} }
#[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> { fn store(backend: MemoryBackend) -> SecretStore<MemoryBackend> {
SecretStore::new( SecretStore::new(
backend, backend,
@@ -339,6 +389,121 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult
Ok(()) 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 { fn fs_config(temporary: &tempfile::TempDir) -> TestResult {
std::fs::create_dir_all(temporary.path().join("keys"))?; 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("vault"))?;