Implement secure OS-backed secret storage

This commit is contained in:
Hermes Agent
2026-08-10 00:23:40 +00:00
parent 75ce19da00
commit b685a4864c
14 changed files with 1838 additions and 9 deletions

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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.

View 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)
));
}
}

View 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
}

View 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(())
}