@@ -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";
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))?;
|
||||||
|
|||||||
Reference in New Issue
Block a user