Implement secure OS-backed secret storage
This commit is contained in:
@@ -13,6 +13,7 @@ clap.workspace = true
|
||||
flate2.workspace = true
|
||||
gix.workspace = true
|
||||
gix-config.workspace = true
|
||||
keyring-core.workspace = true
|
||||
pgp.workspace = true
|
||||
rand.workspace = true
|
||||
regex.workspace = true
|
||||
@@ -24,6 +25,17 @@ toml.workspace = true
|
||||
url.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
|
||||
apple-native-keyring-store.workspace = true
|
||||
security-framework.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-native-keyring-store.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
secret-service.workspace = true
|
||||
zbus-secret-service-keyring-store.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
hex = "0.4"
|
||||
rand_chacha = "0.3"
|
||||
|
||||
@@ -17,6 +17,7 @@ use gix::{
|
||||
objs::tree::EntryKind,
|
||||
};
|
||||
use sha1::{Digest as _, Sha1};
|
||||
use zeroize::Zeroize as _;
|
||||
|
||||
use crate::{
|
||||
config::{ApplicationId, GitRemote, ServerId},
|
||||
@@ -184,6 +185,8 @@ pub enum GitError {
|
||||
name: String,
|
||||
},
|
||||
CredentialsUnavailable,
|
||||
CredentialAccessDenied,
|
||||
CredentialCancelled,
|
||||
AuthenticationFailed,
|
||||
NonFastForward,
|
||||
MergeConflicts {
|
||||
@@ -223,6 +226,12 @@ impl fmt::Display for GitError {
|
||||
Self::CredentialsUnavailable => {
|
||||
formatter.write_str("HTTPS Git credentials are unavailable")
|
||||
}
|
||||
Self::CredentialAccessDenied => {
|
||||
formatter.write_str("access to HTTPS Git credentials was denied")
|
||||
}
|
||||
Self::CredentialCancelled => {
|
||||
formatter.write_str("HTTPS Git credential authentication was cancelled")
|
||||
}
|
||||
Self::AuthenticationFailed => formatter.write_str("HTTPS Git authentication failed"),
|
||||
Self::NonFastForward => formatter.write_str("the remote update is not a fast-forward"),
|
||||
Self::MergeConflicts { paths } => write!(
|
||||
@@ -254,7 +263,7 @@ pub struct GitCredential {
|
||||
}
|
||||
|
||||
impl GitCredential {
|
||||
pub fn new(username: impl Into<String>, password: Vec<u8>) -> Result<Self, GitError> {
|
||||
pub fn new(username: impl Into<String>, mut password: Vec<u8>) -> Result<Self, GitError> {
|
||||
let username = username.into();
|
||||
if username.is_empty()
|
||||
|| username.contains(['\n', '\r', '\0'])
|
||||
@@ -262,6 +271,7 @@ impl GitCredential {
|
||||
|| password.contains(&b'\r')
|
||||
|| password.contains(&0)
|
||||
{
|
||||
password.zeroize();
|
||||
return Err(GitError::CredentialsUnavailable);
|
||||
}
|
||||
Ok(Self {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod mutation;
|
||||
pub mod read;
|
||||
pub mod recipient;
|
||||
pub mod repository;
|
||||
pub mod secret_store;
|
||||
pub mod write;
|
||||
|
||||
/// Product name shared by the presentation adapters.
|
||||
|
||||
775
crates/storage/src/secret_store.rs
Normal file
775
crates/storage/src/secret_store.rs
Normal file
@@ -0,0 +1,775 @@
|
||||
//! OS-backed secret storage, opaque references, and bounded in-memory caching.
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
error::Error,
|
||||
fmt,
|
||||
num::NonZeroUsize,
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{ApplicationId, ServerId},
|
||||
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
||||
git::{GitCredential, GitCredentialProvider, GitError},
|
||||
repository::SecretBytes,
|
||||
};
|
||||
|
||||
mod platform;
|
||||
|
||||
const RECORD_MAGIC: &[u8] = b"IRONSTORAGE-SECRET\0";
|
||||
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;
|
||||
|
||||
/// The purpose and stable, non-secret identity of an OS credential.
|
||||
#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct SecretReference {
|
||||
kind: SecretReferenceKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
|
||||
enum SecretReferenceKind {
|
||||
OpenPgpPassphrase {
|
||||
fingerprint: String,
|
||||
},
|
||||
HttpsGitCredential {
|
||||
server_id: String,
|
||||
application_id: String,
|
||||
account: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl SecretReference {
|
||||
pub fn openpgp_passphrase(fingerprint: impl Into<String>) -> Result<Self, SecretStoreError> {
|
||||
let fingerprint = fingerprint.into();
|
||||
if !matches!(fingerprint.len(), 40 | 64)
|
||||
|| !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
{
|
||||
return Err(SecretStoreError::InvalidReference);
|
||||
}
|
||||
Ok(Self {
|
||||
kind: SecretReferenceKind::OpenPgpPassphrase {
|
||||
fingerprint: fingerprint.to_ascii_uppercase(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn https_git_credential(
|
||||
server_id: impl Into<String>,
|
||||
application_id: impl Into<String>,
|
||||
account: impl Into<String>,
|
||||
) -> Result<Self, SecretStoreError> {
|
||||
let server_id = server_id.into();
|
||||
let application_id = application_id.into();
|
||||
let account = account.into();
|
||||
validate_identifier(&server_id)?;
|
||||
validate_identifier(&application_id)?;
|
||||
if account.is_empty()
|
||||
|| account.len() > 512
|
||||
|| account.trim() != account
|
||||
|| account.chars().any(char::is_control)
|
||||
{
|
||||
return Err(SecretStoreError::InvalidReference);
|
||||
}
|
||||
Ok(Self {
|
||||
kind: SecretReferenceKind::HttpsGitCredential {
|
||||
server_id,
|
||||
application_id,
|
||||
account,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn account(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
SecretReferenceKind::OpenPgpPassphrase { .. } => None,
|
||||
SecretReferenceKind::HttpsGitCredential { account, .. } => Some(account),
|
||||
}
|
||||
}
|
||||
|
||||
fn locator(&self) -> SecretLocator {
|
||||
match &self.kind {
|
||||
SecretReferenceKind::OpenPgpPassphrase { fingerprint } => {
|
||||
SecretLocator::OpenPgpPassphrase {
|
||||
fingerprint: fingerprint.clone(),
|
||||
}
|
||||
}
|
||||
SecretReferenceKind::HttpsGitCredential {
|
||||
server_id,
|
||||
application_id,
|
||||
..
|
||||
} => SecretLocator::HttpsGitCredential {
|
||||
server_id: server_id.clone(),
|
||||
application_id: application_id.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for SecretReference {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self {
|
||||
kind: SecretReferenceKind::OpenPgpPassphrase { .. },
|
||||
} => formatter.write_str("SecretReference::OpenPgpPassphrase([REDACTED])"),
|
||||
Self {
|
||||
kind: SecretReferenceKind::HttpsGitCredential { .. },
|
||||
} => formatter.write_str("SecretReference::HttpsGitCredential([REDACTED])"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A backend key. Git accounts live inside the protected record so a configured
|
||||
/// server/application pair can retrieve its account without TOML metadata.
|
||||
#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub enum SecretLocator {
|
||||
OpenPgpPassphrase {
|
||||
fingerprint: String,
|
||||
},
|
||||
HttpsGitCredential {
|
||||
server_id: String,
|
||||
application_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Debug for SecretLocator {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::OpenPgpPassphrase { .. } => {
|
||||
formatter.write_str("SecretLocator::OpenPgpPassphrase([REDACTED])")
|
||||
}
|
||||
Self::HttpsGitCredential { .. } => {
|
||||
formatter.write_str("SecretLocator::HttpsGitCredential([REDACTED])")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretLocator {
|
||||
fn service_and_user(&self) -> (&'static str, String) {
|
||||
match self {
|
||||
Self::OpenPgpPassphrase { fingerprint } => {
|
||||
("org.ironstorage.openpgp-passphrase", fingerprint.clone())
|
||||
}
|
||||
Self::HttpsGitCredential {
|
||||
server_id,
|
||||
application_id,
|
||||
} => (
|
||||
"org.ironstorage.https-git",
|
||||
format!("{server_id}/{application_id}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SecretProtection {
|
||||
DeviceUnlocked,
|
||||
RequireUserPresence,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SecretProtectionPolicy {
|
||||
openpgp: SecretProtection,
|
||||
git: SecretProtection,
|
||||
}
|
||||
|
||||
impl SecretProtectionPolicy {
|
||||
pub const fn new(openpgp: SecretProtection, git: SecretProtection) -> Self {
|
||||
Self { openpgp, git }
|
||||
}
|
||||
|
||||
pub const fn device_unlocked() -> Self {
|
||||
Self::new(
|
||||
SecretProtection::DeviceUnlocked,
|
||||
SecretProtection::DeviceUnlocked,
|
||||
)
|
||||
}
|
||||
|
||||
pub const fn user_presence_for_openpgp() -> Self {
|
||||
Self::new(
|
||||
SecretProtection::RequireUserPresence,
|
||||
SecretProtection::DeviceUnlocked,
|
||||
)
|
||||
}
|
||||
|
||||
fn for_reference(self, reference: &SecretReference) -> SecretProtection {
|
||||
match &reference.kind {
|
||||
SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp,
|
||||
SecretReferenceKind::HttpsGitCredential { .. } => self.git,
|
||||
}
|
||||
}
|
||||
|
||||
fn for_locator(self, locator: &SecretLocator) -> SecretProtection {
|
||||
match locator {
|
||||
SecretLocator::OpenPgpPassphrase { .. } => self.openpgp,
|
||||
SecretLocator::HttpsGitCredential { .. } => self.git,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SecretProtectionPolicy {
|
||||
fn default() -> Self {
|
||||
Self::device_unlocked()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SecretCachePolicy {
|
||||
Disabled,
|
||||
Timed {
|
||||
lifetime: Duration,
|
||||
capacity: NonZeroUsize,
|
||||
},
|
||||
}
|
||||
|
||||
impl SecretCachePolicy {
|
||||
pub fn timed(lifetime: Duration, capacity: NonZeroUsize) -> Result<Self, SecretStoreError> {
|
||||
if lifetime.is_zero()
|
||||
|| lifetime > MAX_CACHE_LIFETIME
|
||||
|| capacity.get() > MAX_CACHE_CAPACITY
|
||||
{
|
||||
return Err(SecretStoreError::InvalidCachePolicy);
|
||||
}
|
||||
Ok(Self::Timed { lifetime, capacity })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SecretStoreError {
|
||||
InvalidReference,
|
||||
InvalidCachePolicy,
|
||||
AlreadyExists,
|
||||
Missing,
|
||||
Locked,
|
||||
Denied,
|
||||
Cancelled,
|
||||
Corrupted,
|
||||
UnsupportedProtection,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
impl fmt::Display for SecretStoreError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let message = match self {
|
||||
Self::InvalidReference => "the secret reference is invalid",
|
||||
Self::InvalidCachePolicy => "the secret cache policy is invalid",
|
||||
Self::AlreadyExists => "the referenced secret already exists",
|
||||
Self::Missing => "the referenced secret does not exist",
|
||||
Self::Locked => "the secret store is locked",
|
||||
Self::Denied => "access to the secret store was denied",
|
||||
Self::Cancelled => "secret-store authentication was cancelled",
|
||||
Self::Corrupted => "the stored secret record is corrupted",
|
||||
Self::UnsupportedProtection => {
|
||||
"the requested secret protection is unsupported on this platform"
|
||||
}
|
||||
Self::Unavailable => "the operating-system secret store is unavailable",
|
||||
};
|
||||
formatter.write_str(message)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for SecretStoreError {}
|
||||
|
||||
/// Mockable contract implemented by each operating-system adapter.
|
||||
pub trait SecretStoreBackend: Send + Sync {
|
||||
fn create(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError>;
|
||||
fn retrieve(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<SecretBytes, SecretStoreError>;
|
||||
fn replace(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError>;
|
||||
fn delete(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<(), SecretStoreError>;
|
||||
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||
Ok(())
|
||||
}
|
||||
fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct CachedSecret {
|
||||
value: SecretBytes,
|
||||
expires_at: Instant,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
struct StoreState {
|
||||
unlocked: bool,
|
||||
sequence: u64,
|
||||
cache: BTreeMap<SecretLocator, CachedSecret>,
|
||||
}
|
||||
|
||||
pub struct SecretStore<B> {
|
||||
backend: B,
|
||||
cache_policy: SecretCachePolicy,
|
||||
protections: SecretProtectionPolicy,
|
||||
state: Mutex<StoreState>,
|
||||
}
|
||||
|
||||
impl<B> fmt::Debug for SecretStore<B> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("SecretStore")
|
||||
.field("cache_policy", &self.cache_policy)
|
||||
.field("protections", &self.protections)
|
||||
.field("contents", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: SecretStoreBackend> SecretStore<B> {
|
||||
pub fn new(
|
||||
backend: B,
|
||||
cache_policy: SecretCachePolicy,
|
||||
protections: SecretProtectionPolicy,
|
||||
) -> Self {
|
||||
Self {
|
||||
backend,
|
||||
cache_policy,
|
||||
protections,
|
||||
state: Mutex::new(StoreState {
|
||||
unlocked: false,
|
||||
sequence: 0,
|
||||
cache: BTreeMap::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_locked(&self) -> bool {
|
||||
self.state.lock().map_or(true, |state| !state.unlocked)
|
||||
}
|
||||
|
||||
pub fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| SecretStoreError::Unavailable)?;
|
||||
self.backend.unlock()?;
|
||||
state.unlocked = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn lock(&self) -> Result<(), SecretStoreError> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| SecretStoreError::Unavailable)?;
|
||||
state.unlocked = false;
|
||||
state.cache.clear();
|
||||
self.backend.lock()
|
||||
}
|
||||
|
||||
pub fn create(
|
||||
&self,
|
||||
reference: &SecretReference,
|
||||
value: SecretBytes,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
validate_secret(&value)?;
|
||||
let locator = reference.locator();
|
||||
let encoded = encode_record(reference, &value)?;
|
||||
let mut state = self.unlocked_state()?;
|
||||
self.backend.create(
|
||||
&locator,
|
||||
self.protections.for_reference(reference),
|
||||
encoded.expose(),
|
||||
)?;
|
||||
self.cache_insert(&mut state, locator, encoded);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn retrieve(&self, reference: &SecretReference) -> Result<SecretBytes, SecretStoreError> {
|
||||
let locator = reference.locator();
|
||||
let mut state = self.unlocked_state()?;
|
||||
let encoded = match self.cache_get(&mut state, &locator) {
|
||||
Some(value) => value,
|
||||
None => self
|
||||
.backend
|
||||
.retrieve(&locator, self.protections.for_reference(reference))?,
|
||||
};
|
||||
let record = decode_record(encoded)?;
|
||||
if &record.reference != reference {
|
||||
return Err(SecretStoreError::Missing);
|
||||
}
|
||||
let output = copy_secret(&record.value);
|
||||
self.cache_insert(
|
||||
&mut state,
|
||||
locator,
|
||||
encode_record(&record.reference, &record.value)?,
|
||||
);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub fn replace(
|
||||
&self,
|
||||
reference: &SecretReference,
|
||||
value: SecretBytes,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
validate_secret(&value)?;
|
||||
let locator = reference.locator();
|
||||
let encoded = encode_record(reference, &value)?;
|
||||
let mut state = self.unlocked_state()?;
|
||||
self.require_exact_record(reference, &locator)?;
|
||||
self.backend.replace(
|
||||
&locator,
|
||||
self.protections.for_reference(reference),
|
||||
encoded.expose(),
|
||||
)?;
|
||||
self.cache_insert(&mut state, locator, encoded);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> {
|
||||
let locator = reference.locator();
|
||||
let mut state = self.unlocked_state()?;
|
||||
self.require_exact_record(reference, &locator)?;
|
||||
self.backend
|
||||
.delete(&locator, self.protections.for_reference(reference))?;
|
||||
state.cache.remove(&locator);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retrieve_git_record(
|
||||
&self,
|
||||
server: &ServerId,
|
||||
application: &ApplicationId,
|
||||
) -> Result<SecretRecord, SecretStoreError> {
|
||||
let locator = SecretLocator::HttpsGitCredential {
|
||||
server_id: server.as_str().to_owned(),
|
||||
application_id: application.as_str().to_owned(),
|
||||
};
|
||||
let mut state = self.unlocked_state()?;
|
||||
if let Some(value) = self.cache_get(&mut state, &locator) {
|
||||
return decode_record(value);
|
||||
}
|
||||
let encoded = self
|
||||
.backend
|
||||
.retrieve(&locator, self.protections.for_locator(&locator))?;
|
||||
let record = decode_record(encoded)?;
|
||||
if record.reference.locator() != locator {
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
}
|
||||
self.cache_insert(
|
||||
&mut state,
|
||||
locator,
|
||||
encode_record(&record.reference, &record.value)?,
|
||||
);
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn require_exact_record(
|
||||
&self,
|
||||
reference: &SecretReference,
|
||||
locator: &SecretLocator,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let encoded = self
|
||||
.backend
|
||||
.retrieve(locator, self.protections.for_reference(reference))?;
|
||||
let record = decode_record(encoded)?;
|
||||
if &record.reference != reference {
|
||||
return Err(SecretStoreError::Missing);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unlocked_state(&self) -> Result<std::sync::MutexGuard<'_, StoreState>, SecretStoreError> {
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| SecretStoreError::Unavailable)?;
|
||||
if !state.unlocked {
|
||||
return Err(SecretStoreError::Locked);
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn cache_get(&self, state: &mut StoreState, locator: &SecretLocator) -> Option<SecretBytes> {
|
||||
let SecretCachePolicy::Timed { .. } = self.cache_policy else {
|
||||
return None;
|
||||
};
|
||||
let now = Instant::now();
|
||||
state.cache.retain(|_, entry| entry.expires_at > now);
|
||||
state
|
||||
.cache
|
||||
.get(locator)
|
||||
.map(|entry| copy_secret(&entry.value))
|
||||
}
|
||||
|
||||
fn cache_insert(&self, state: &mut StoreState, locator: SecretLocator, value: SecretBytes) {
|
||||
let SecretCachePolicy::Timed { lifetime, capacity } = self.cache_policy else {
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
state.cache.retain(|_, entry| entry.expires_at > now);
|
||||
state.sequence = state.sequence.wrapping_add(1);
|
||||
let sequence = state.sequence;
|
||||
state.cache.insert(
|
||||
locator,
|
||||
CachedSecret {
|
||||
value,
|
||||
expires_at: now + lifetime,
|
||||
sequence,
|
||||
},
|
||||
);
|
||||
while state.cache.len() > capacity.get() {
|
||||
let oldest = state
|
||||
.cache
|
||||
.iter()
|
||||
.min_by_key(|(_, entry)| entry.sequence)
|
||||
.map(|(locator, _)| locator.clone());
|
||||
if let Some(oldest) = oldest {
|
||||
state.cache.remove(&oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type NativeSecretStore = SecretStore<platform::NativeSecretBackend>;
|
||||
|
||||
impl NativeSecretStore {
|
||||
pub fn system(
|
||||
cache_policy: SecretCachePolicy,
|
||||
protections: SecretProtectionPolicy,
|
||||
) -> Result<Self, SecretStoreError> {
|
||||
Ok(Self::new(
|
||||
platform::NativeSecretBackend::new()?,
|
||||
cache_policy,
|
||||
protections,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: SecretStoreBackend> SecretProvider for SecretStore<B> {
|
||||
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
||||
let reference = SecretReference::openpgp_passphrase(key.fingerprint().as_str())
|
||||
.map_err(|_| SecretProviderError::Unavailable)?;
|
||||
self.retrieve(&reference).map_err(provider_error)
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: SecretStoreBackend> GitCredentialProvider for SecretStore<B> {
|
||||
fn credential(
|
||||
&self,
|
||||
server: &ServerId,
|
||||
application: &ApplicationId,
|
||||
) -> Result<GitCredential, GitError> {
|
||||
let record = self
|
||||
.retrieve_git_record(server, application)
|
||||
.map_err(git_provider_error)?;
|
||||
let Some(account) = record.reference.account() else {
|
||||
return Err(GitError::CredentialsUnavailable);
|
||||
};
|
||||
if record.value.expose().contains(&b'\n')
|
||||
|| record.value.expose().contains(&b'\r')
|
||||
|| record.value.expose().contains(&0)
|
||||
{
|
||||
return Err(GitError::CredentialsUnavailable);
|
||||
}
|
||||
GitCredential::new(account, record.value.expose().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_error(error: SecretStoreError) -> SecretProviderError {
|
||||
match error {
|
||||
SecretStoreError::Cancelled => SecretProviderError::Cancelled,
|
||||
_ => SecretProviderError::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
fn git_provider_error(error: SecretStoreError) -> GitError {
|
||||
match error {
|
||||
SecretStoreError::Cancelled => GitError::CredentialCancelled,
|
||||
SecretStoreError::Denied => GitError::CredentialAccessDenied,
|
||||
_ => GitError::CredentialsUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
struct SecretRecord {
|
||||
reference: SecretReference,
|
||||
value: SecretBytes,
|
||||
}
|
||||
|
||||
fn encode_record(
|
||||
reference: &SecretReference,
|
||||
value: &SecretBytes,
|
||||
) -> Result<SecretBytes, SecretStoreError> {
|
||||
validate_secret(value)?;
|
||||
let mut encoded = Vec::with_capacity(RECORD_MAGIC.len() + value.expose().len() + 1024);
|
||||
encoded.extend_from_slice(RECORD_MAGIC);
|
||||
encoded.push(RECORD_VERSION);
|
||||
match &reference.kind {
|
||||
SecretReferenceKind::OpenPgpPassphrase { fingerprint } => {
|
||||
encoded.push(1);
|
||||
write_field(&mut encoded, fingerprint.as_bytes())?;
|
||||
}
|
||||
SecretReferenceKind::HttpsGitCredential {
|
||||
server_id,
|
||||
application_id,
|
||||
account,
|
||||
} => {
|
||||
encoded.push(2);
|
||||
write_field(&mut encoded, server_id.as_bytes())?;
|
||||
write_field(&mut encoded, application_id.as_bytes())?;
|
||||
write_field(&mut encoded, account.as_bytes())?;
|
||||
}
|
||||
}
|
||||
let length = u32::try_from(value.expose().len()).map_err(|_| SecretStoreError::Corrupted)?;
|
||||
encoded.extend_from_slice(&length.to_be_bytes());
|
||||
encoded.extend_from_slice(value.expose());
|
||||
Ok(SecretBytes::new(encoded))
|
||||
}
|
||||
|
||||
fn decode_record(encoded: SecretBytes) -> Result<SecretRecord, SecretStoreError> {
|
||||
let bytes = encoded.expose();
|
||||
let Some(mut remainder) = bytes.strip_prefix(RECORD_MAGIC) else {
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
};
|
||||
let Some((&version, rest)) = remainder.split_first() else {
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
};
|
||||
if version != RECORD_VERSION {
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
}
|
||||
let Some((&kind, rest)) = rest.split_first() else {
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
};
|
||||
remainder = rest;
|
||||
let reference = match kind {
|
||||
1 => {
|
||||
let (fingerprint, rest) = read_field(remainder)?;
|
||||
remainder = rest;
|
||||
SecretReference::openpgp_passphrase(read_text(fingerprint)?)
|
||||
.map_err(|_| SecretStoreError::Corrupted)?
|
||||
}
|
||||
2 => {
|
||||
let (server_id, rest) = read_field(remainder)?;
|
||||
let (application_id, rest) = read_field(rest)?;
|
||||
let (account, rest) = read_field(rest)?;
|
||||
remainder = rest;
|
||||
SecretReference::https_git_credential(
|
||||
read_text(server_id)?,
|
||||
read_text(application_id)?,
|
||||
read_text(account)?,
|
||||
)
|
||||
.map_err(|_| SecretStoreError::Corrupted)?
|
||||
}
|
||||
_ => return Err(SecretStoreError::Corrupted),
|
||||
};
|
||||
if remainder.len() < 4 {
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
}
|
||||
let length = u32::from_be_bytes(
|
||||
remainder[..4]
|
||||
.try_into()
|
||||
.map_err(|_| SecretStoreError::Corrupted)?,
|
||||
) as usize;
|
||||
let value = &remainder[4..];
|
||||
if length == 0 || length != value.len() || length > MAX_SECRET_BYTES {
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
}
|
||||
Ok(SecretRecord {
|
||||
reference,
|
||||
value: SecretBytes::new(value.to_vec()),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_field(output: &mut Vec<u8>, field: &[u8]) -> Result<(), SecretStoreError> {
|
||||
let length = u16::try_from(field.len()).map_err(|_| SecretStoreError::InvalidReference)?;
|
||||
output.extend_from_slice(&length.to_be_bytes());
|
||||
output.extend_from_slice(field);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_field(input: &[u8]) -> Result<(&[u8], &[u8]), SecretStoreError> {
|
||||
if input.len() < 2 {
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
}
|
||||
let length = u16::from_be_bytes(
|
||||
input[..2]
|
||||
.try_into()
|
||||
.map_err(|_| SecretStoreError::Corrupted)?,
|
||||
) as usize;
|
||||
if input.len() < 2 + length {
|
||||
return Err(SecretStoreError::Corrupted);
|
||||
}
|
||||
Ok((&input[2..2 + length], &input[2 + length..]))
|
||||
}
|
||||
|
||||
fn read_text(input: &[u8]) -> Result<String, SecretStoreError> {
|
||||
std::str::from_utf8(input)
|
||||
.map(str::to_owned)
|
||||
.map_err(|_| SecretStoreError::Corrupted)
|
||||
}
|
||||
|
||||
fn validate_identifier(value: &str) -> Result<(), SecretStoreError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 128
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return Err(SecretStoreError::InvalidReference);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_secret(value: &SecretBytes) -> Result<(), SecretStoreError> {
|
||||
if value.expose().is_empty() || value.expose().len() > MAX_SECRET_BYTES {
|
||||
return Err(SecretStoreError::InvalidReference);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_secret(value: &SecretBytes) -> SecretBytes {
|
||||
SecretBytes::new(value.expose().to_vec())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{RECORD_MAGIC, RECORD_VERSION, SecretBytes, SecretStoreError, decode_record};
|
||||
|
||||
fn openpgp_record(fingerprint: &[u8], secret: &[u8]) -> SecretBytes {
|
||||
let mut record = Vec::new();
|
||||
record.extend_from_slice(RECORD_MAGIC);
|
||||
record.push(RECORD_VERSION);
|
||||
record.push(1);
|
||||
record.extend_from_slice(&(fingerprint.len() as u16).to_be_bytes());
|
||||
record.extend_from_slice(fingerprint);
|
||||
record.extend_from_slice(&(secret.len() as u32).to_be_bytes());
|
||||
record.extend_from_slice(secret);
|
||||
SecretBytes::new(record)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_classifies_invalid_stored_fields_as_corruption() {
|
||||
assert!(matches!(
|
||||
decode_record(openpgp_record(b"not-a-fingerprint", b"secret")),
|
||||
Err(SecretStoreError::Corrupted)
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_record(openpgp_record(
|
||||
b"0123456789ABCDEF0123456789ABCDEF01234567",
|
||||
b""
|
||||
)),
|
||||
Err(SecretStoreError::Corrupted)
|
||||
));
|
||||
}
|
||||
}
|
||||
236
crates/storage/src/secret_store/platform.rs
Normal file
236
crates/storage/src/secret_store/platform.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
//! Safe adapters around native credential-store crates.
|
||||
//!
|
||||
//! This is the only module that selects operating-system implementations.
|
||||
//! IronStorage performs no direct FFI and contains no unsafe code: the Apple,
|
||||
//! Windows, and Linux crates own their respective Security Framework,
|
||||
//! Credential Manager, and Secret Service boundaries. Operations are serialized
|
||||
//! by the parent `SecretStore`, as required by the Windows adapter contract.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
use std::collections::HashMap;
|
||||
|
||||
use keyring_core::{CredentialStore, Entry};
|
||||
|
||||
use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError};
|
||||
use crate::repository::SecretBytes;
|
||||
|
||||
pub struct NativeSecretBackend {
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
store: Arc<CredentialStore>,
|
||||
#[cfg(target_os = "ios")]
|
||||
protected: Arc<CredentialStore>,
|
||||
#[cfg(target_os = "macos")]
|
||||
keychain: Arc<CredentialStore>,
|
||||
#[cfg(target_os = "macos")]
|
||||
protected: Arc<CredentialStore>,
|
||||
}
|
||||
|
||||
impl NativeSecretBackend {
|
||||
pub fn new() -> Result<Self, SecretStoreError> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let store: Arc<CredentialStore> =
|
||||
zbus_secret_service_keyring_store::Store::new().map_err(map_error)?;
|
||||
Ok(Self { store })
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let store: Arc<CredentialStore> =
|
||||
windows_native_keyring_store::Store::new().map_err(map_error)?;
|
||||
Ok(Self { store })
|
||||
}
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
let protected: Arc<CredentialStore> =
|
||||
apple_native_keyring_store::protected::Store::new().map_err(map_error)?;
|
||||
Ok(Self { protected })
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let keychain: Arc<CredentialStore> =
|
||||
apple_native_keyring_store::keychain::Store::new().map_err(map_error)?;
|
||||
let protected: Arc<CredentialStore> =
|
||||
apple_native_keyring_store::protected::Store::new().map_err(map_error)?;
|
||||
Ok(Self {
|
||||
keychain,
|
||||
protected,
|
||||
})
|
||||
}
|
||||
#[cfg(not(any(
|
||||
target_os = "ios",
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "windows"
|
||||
)))]
|
||||
{
|
||||
Err(SecretStoreError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
fn entry(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<Entry, SecretStoreError> {
|
||||
let (service, user) = locator.service_and_user();
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
{
|
||||
if protection == SecretProtection::RequireUserPresence {
|
||||
return Err(SecretStoreError::UnsupportedProtection);
|
||||
}
|
||||
self.store.build(service, &user, None).map_err(map_error)
|
||||
}
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
let modifiers = presence_modifiers(protection);
|
||||
self.protected
|
||||
.build(service, &user, modifiers.as_ref())
|
||||
.map_err(map_error)
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
match protection {
|
||||
SecretProtection::DeviceUnlocked => {
|
||||
self.keychain.build(service, &user, None).map_err(map_error)
|
||||
}
|
||||
SecretProtection::RequireUserPresence => {
|
||||
let modifiers = presence_modifiers(protection);
|
||||
self.protected
|
||||
.build(service, &user, modifiers.as_ref())
|
||||
.map_err(map_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(
|
||||
target_os = "ios",
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "windows"
|
||||
)))]
|
||||
{
|
||||
let _ = (service, user, protection);
|
||||
Err(SecretStoreError::Unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretStoreBackend for NativeSecretBackend {
|
||||
fn create(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let entry = self.entry(locator, protection)?;
|
||||
match entry.get_secret() {
|
||||
Ok(existing) => {
|
||||
drop(SecretBytes::new(existing));
|
||||
Err(SecretStoreError::AlreadyExists)
|
||||
}
|
||||
Err(keyring_core::Error::NoEntry) => entry.set_secret(value).map_err(map_error),
|
||||
Err(error) => Err(map_error(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn retrieve(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<SecretBytes, SecretStoreError> {
|
||||
self.entry(locator, protection)?
|
||||
.get_secret()
|
||||
.map(SecretBytes::new)
|
||||
.map_err(map_error)
|
||||
}
|
||||
|
||||
fn replace(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let entry = self.entry(locator, protection)?;
|
||||
let existing = entry.get_secret().map_err(map_error)?;
|
||||
drop(SecretBytes::new(existing));
|
||||
entry.set_secret(value).map_err(map_error)
|
||||
}
|
||||
|
||||
fn delete(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
self.entry(locator, protection)?
|
||||
.delete_credential()
|
||||
.map_err(map_error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
fn presence_modifiers(protection: SecretProtection) -> Option<HashMap<&'static str, &'static str>> {
|
||||
match protection {
|
||||
SecretProtection::DeviceUnlocked => None,
|
||||
SecretProtection::RequireUserPresence => {
|
||||
Some(HashMap::from([("access-policy", "require-user-presence")]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_error(error: keyring_core::Error) -> SecretStoreError {
|
||||
match error {
|
||||
keyring_core::Error::NoEntry => SecretStoreError::Missing,
|
||||
keyring_core::Error::NoStorageAccess(error) => platform_access_error(&error),
|
||||
keyring_core::Error::BadEncoding(bytes) => {
|
||||
drop(SecretBytes::new(bytes));
|
||||
SecretStoreError::Corrupted
|
||||
}
|
||||
keyring_core::Error::BadDataFormat(bytes, _) => {
|
||||
drop(SecretBytes::new(bytes));
|
||||
SecretStoreError::Corrupted
|
||||
}
|
||||
keyring_core::Error::BadStoreFormat(_) | keyring_core::Error::Ambiguous(_) => {
|
||||
SecretStoreError::Corrupted
|
||||
}
|
||||
keyring_core::Error::TooLong(_, _) | keyring_core::Error::Invalid(_, _) => {
|
||||
SecretStoreError::InvalidReference
|
||||
}
|
||||
keyring_core::Error::NotSupportedByStore(_) | keyring_core::Error::NoDefaultStore => {
|
||||
SecretStoreError::Unavailable
|
||||
}
|
||||
keyring_core::Error::PlatformFailure(error) => {
|
||||
if platform_cancelled(&error) {
|
||||
SecretStoreError::Cancelled
|
||||
} else {
|
||||
SecretStoreError::Unavailable
|
||||
}
|
||||
}
|
||||
_ => SecretStoreError::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
fn platform_cancelled(error: &keyring_core::error::PlatformError) -> bool {
|
||||
error
|
||||
.downcast_ref::<security_framework::base::Error>()
|
||||
.is_some_and(|error| error.code() == -128)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||
fn platform_cancelled(_error: &keyring_core::error::PlatformError) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform_access_error(error: &keyring_core::error::PlatformError) -> SecretStoreError {
|
||||
match error.downcast_ref::<secret_service::Error>() {
|
||||
Some(secret_service::Error::Prompt) => SecretStoreError::Cancelled,
|
||||
_ => SecretStoreError::Denied,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn platform_access_error(_error: &keyring_core::error::PlatformError) -> SecretStoreError {
|
||||
SecretStoreError::Denied
|
||||
}
|
||||
351
crates/storage/tests/secret_store.rs
Normal file
351
crates/storage/tests/secret_store.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod support;
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
error::Error,
|
||||
num::NonZeroUsize,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ironstorage::{
|
||||
config::ConfigLoader,
|
||||
crypto::KeyStore,
|
||||
git::{GitCredentialProvider as _, GitError},
|
||||
repository::{EncryptedEntry, SecretBytes},
|
||||
secret_store::{
|
||||
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>,
|
||||
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 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)?;
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResult {
|
||||
let backend = MemoryBackend::default();
|
||||
let policy = SecretCachePolicy::timed(
|
||||
Duration::from_secs(60),
|
||||
NonZeroUsize::new(1).expect("non-zero"),
|
||||
)?;
|
||||
let store = SecretStore::new(
|
||||
backend.clone(),
|
||||
policy,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
);
|
||||
store.unlock()?;
|
||||
let alice = SecretReference::https_git_credential("server", "application", "alice")?;
|
||||
store.create(&alice, SecretBytes::new(b"token".to_vec()))?;
|
||||
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
|
||||
assert_eq!(
|
||||
backend.retrieves(),
|
||||
0,
|
||||
"create populated the explicit cache"
|
||||
);
|
||||
|
||||
let bob = SecretReference::https_git_credential("server", "application", "bob")?;
|
||||
assert!(matches!(
|
||||
store.retrieve(&bob),
|
||||
Err(SecretStoreError::Missing)
|
||||
));
|
||||
assert_eq!(
|
||||
store.replace(&bob, SecretBytes::new(b"other".to_vec())),
|
||||
Err(SecretStoreError::Missing)
|
||||
);
|
||||
assert_eq!(store.delete(&bob), Err(SecretStoreError::Missing));
|
||||
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
|
||||
let passphrase = fingerprint_reference();
|
||||
store.create(&passphrase, SecretBytes::new(b"passphrase".to_vec()))?;
|
||||
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
|
||||
assert_eq!(backend.retrieves(), 3, "capacity evicted the older record");
|
||||
store.lock()?;
|
||||
store.unlock()?;
|
||||
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
|
||||
assert_eq!(backend.retrieves(), 4, "lock discarded cached bytes");
|
||||
assert!(
|
||||
SecretCachePolicy::timed(
|
||||
Duration::from_secs(16 * 60),
|
||||
NonZeroUsize::new(1).expect("non-zero")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let key = fixture.key("alice")?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let backend = MemoryBackend::default();
|
||||
let mut store = SecretStore::new(
|
||||
backend.clone(),
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::user_presence_for_openpgp(),
|
||||
);
|
||||
store.unlock()?;
|
||||
let passphrase = SecretReference::openpgp_passphrase(&key.primary_fingerprint)?;
|
||||
store.create(
|
||||
&passphrase,
|
||||
SecretBytes::new(key.passphrase.as_bytes().to_vec()),
|
||||
)?;
|
||||
let entry = fixture
|
||||
.generated
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.store == "basic" && entry.path == "email/personal.gpg")
|
||||
.expect("compatibility entry exists");
|
||||
assert_eq!(
|
||||
keys.decrypt(
|
||||
&EncryptedEntry::new(fixture.read(format!("stores/basic/{}", entry.path))?),
|
||||
&mut store,
|
||||
)?
|
||||
.expose(),
|
||||
fixture.read("expected/basic/email/personal.txt")?
|
||||
);
|
||||
|
||||
let temporary = tempfile::tempdir()?;
|
||||
fs_config(&temporary)?;
|
||||
let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native"))
|
||||
.load(Some(&temporary.path().join("config.toml")))?;
|
||||
let remote = &config.git_remotes()[0];
|
||||
backend.fail_next(SecretStoreError::Cancelled);
|
||||
assert!(matches!(
|
||||
store.credential(remote.server_id(), remote.application_id()),
|
||||
Err(GitError::CredentialCancelled)
|
||||
));
|
||||
backend.fail_next(SecretStoreError::Denied);
|
||||
assert!(matches!(
|
||||
store.credential(remote.server_id(), remote.application_id()),
|
||||
Err(GitError::CredentialAccessDenied)
|
||||
));
|
||||
let git = SecretReference::https_git_credential(
|
||||
remote.server_id().as_str(),
|
||||
remote.application_id().as_str(),
|
||||
"alice",
|
||||
)?;
|
||||
store.create(&git, SecretBytes::new(b"https-token".to_vec()))?;
|
||||
let credential = store.credential(remote.server_id(), remote.application_id())?;
|
||||
assert_eq!(credential.username(), "alice");
|
||||
assert_eq!(credential.password(), b"https-token");
|
||||
assert!(
|
||||
backend
|
||||
.protections()
|
||||
.contains(&SecretProtection::RequireUserPresence)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user