Implement secure OS-backed secret storage
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user