use crate::{AuthProfileResolutionError, ResolvedAuthProfile};
use bcode_provider_auth_models::AuthMethodContribution;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use zeroize::{Zeroize as _, Zeroizing};
#[cfg(unix)]
fn zeroize_vault_values(vault: &mut sshenv_vault::Vault) {
for values in vault.profiles.profiles.values_mut() {
for value in values.values_mut() {
value.zeroize();
}
}
}
#[cfg(unix)]
fn generate_custody_key(
source: Option<&dyn crate::operations::AuthCustodyKeySource>,
) -> Result<sshenv_vault::DataKey, AuthVaultLifecycleError> {
source.map_or_else(
|| Ok(sshenv_vault::crypto::generate_data_key()),
|source| {
source
.generate()
.map(|bytes| sshenv_vault::DataKey::new(*bytes))
},
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthVaultDiagnostic {
pub code: &'static str,
pub message: String,
pub remediation: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthVaultProfileStatus {
pub profile_exists: bool,
pub present_credentials: BTreeSet<String>,
pub diagnostics: Vec<AuthVaultDiagnostic>,
}
#[derive(Debug, thiserror::Error)]
pub enum AuthVaultLifecycleError {
#[error("durable refresh coordination unavailable")]
RefreshUnsupported,
#[error("credential refresh outcome unresolved; re-enroll the owned profile")]
RefreshBlocked,
#[error("credential refresh snapshot changed; reload before retrying")]
RefreshStale,
#[error(transparent)]
Ownership(#[from] AuthProfileResolutionError),
#[error("auth method '{method_id}' is not registered for provider '{provider_id}'")]
UnknownMethod {
provider_id: String,
method_id: String,
},
#[error("auth credential '{credential_id}' is not owned by method '{method_id}'")]
UnknownCredential {
method_id: String,
credential_id: String,
},
#[error("credential values are incomplete or invalid for this method")]
InvalidCredential,
#[error("auth credential already exists; import never overwrites it")]
CredentialExists,
#[error("auth profile backend '{0}' is unsupported for integrated vault operations")]
UnsupportedBackend(String),
#[error("auth profile has no authentication scheme")]
MissingScheme,
#[error("auth profile scheme '{actual}' does not match selected method '{expected}'")]
SchemeMismatch { expected: String, actual: String },
#[error("auth vault is unavailable: {0}")]
VaultUnavailable(String),
#[error("auth vault profile is unavailable: {0}")]
ProfileUnavailable(String),
#[error("auth vault write failed: {0}")]
WriteFailed(String),
#[error("auth device-seal requirement is not satisfied")]
DeviceSealRequired(Vec<crate::security::AuthSecurityDiagnostic>),
}
pub struct AuthVaultLifecycle<'a> {
resolved: &'a ResolvedAuthProfile,
method: &'a AuthMethodContribution,
}
impl<'a> AuthVaultLifecycle<'a> {
pub fn prepare_custody(
&self,
recipient: &str,
keys: &dyn crate::operations::AuthCustodyKeySource,
) -> Result<Vec<u8>, AuthVaultLifecycleError> {
let failure =
|| AuthVaultLifecycleError::WriteFailed("custody initialization failed".into());
let key = keys.generate()?;
let (mut vault, key) =
sshenv_vault::Vault::create_with_data_key(recipient, sshenv_vault::DataKey::new(*key))
.map_err(|_| failure())?;
vault
.migrate_to_v2(&[recipient.to_owned()])
.map_err(|_| failure())?;
vault.enable_profile_keys().map_err(|_| failure())?;
vault
.profiles
.profiles
.insert(self.storage_profile().to_owned(), BTreeMap::new());
let mut ciphertext = Vec::new();
vault
.save_with_effects(
&key,
|| {
keys.generate()
.map(|key| sshenv_vault::DataKey::new(*key))
.map_err(Into::into)
},
|bytes, _| {
ciphertext = bytes.to_vec();
Ok(())
},
)
.map_err(|_| failure())?;
Ok(ciphertext)
}
pub fn new(
resolved: &'a ResolvedAuthProfile,
registered_provider_id: &str,
registered_owner_plugin_id: &str,
method: &'a AuthMethodContribution,
) -> Result<Self, AuthVaultLifecycleError> {
validate_resolved_owner(resolved, registered_provider_id, registered_owner_plugin_id)?;
if resolved.profile.backend != "sshenv" {
return Err(AuthVaultLifecycleError::UnsupportedBackend(
resolved.profile.backend.clone(),
));
}
let scheme = resolved
.profile
.scheme
.as_deref()
.ok_or(AuthVaultLifecycleError::MissingScheme)?;
if scheme != method.method_id() {
return Err(AuthVaultLifecycleError::SchemeMismatch {
expected: method.method_id().to_owned(),
actual: scheme.to_owned(),
});
}
Ok(Self { resolved, method })
}
pub fn inspect(&self) -> Result<AuthVaultProfileStatus, AuthVaultLifecycleError> {
let vault = self.vault_path();
if !vault.exists() {
return Ok(AuthVaultProfileStatus {
profile_exists: false,
present_credentials: BTreeSet::new(),
diagnostics: vec![AuthVaultDiagnostic {
code: "auth_vault_missing",
message: format!("Auth vault at {} does not exist.", vault.display()),
remediation: Some("Run the provider login command to create it.".to_owned()),
}],
});
}
let profile = crate::security::read_auth_vault_profile(&vault, self.storage_profile())
.map_err(AuthVaultLifecycleError::ProfileUnavailable)?;
let Some(values) = profile else {
return Ok(AuthVaultProfileStatus {
profile_exists: false,
present_credentials: BTreeSet::new(),
diagnostics: vec![AuthVaultDiagnostic {
code: "auth_vault_profile_missing",
message: format!(
"Auth vault profile '{}' does not exist.",
self.storage_profile()
),
remediation: Some("Run the provider login command.".to_owned()),
}],
});
};
let present_credentials = self
.credential_storage_keys()?
.into_iter()
.filter_map(|(credential, key)| values.contains_key(&key).then_some(credential))
.collect();
Ok(AuthVaultProfileStatus {
profile_exists: true,
present_credentials,
diagnostics: Vec::new(),
})
}
pub fn locally_available(&self) -> Result<bool, AuthVaultLifecycleError> {
let mut values = self.read()?;
let available = match self.method {
AuthMethodContribution::SecretFields { fields, .. } => fields.iter().all(|field| {
values
.get(&field.credential_id)
.map_or(field.optional, |value| {
!value.is_empty() && field.validation.validate_secret(value).is_ok()
})
}),
AuthMethodContribution::Interactive { .. } => false,
};
for value in values.values_mut() {
value.zeroize();
}
Ok(available)
}
pub fn read(&self) -> Result<BTreeMap<String, String>, AuthVaultLifecycleError> {
#[cfg(unix)]
let _guard = crate::operations::refresh::native::read_guard(&self.vault_path())?;
self.read_uncoordinated()
}
pub(crate) fn read_uncoordinated(
&self,
) -> Result<BTreeMap<String, String>, AuthVaultLifecycleError> {
self.read_with(|| {
let vault = self.vault_path();
if !vault.exists() {
return Err(AuthVaultLifecycleError::VaultUnavailable(format!(
"vault at {} does not exist",
vault.display()
)));
}
crate::security::read_auth_vault_profile_uncoordinated(&vault, self.storage_profile())
.map_err(AuthVaultLifecycleError::ProfileUnavailable)?
.ok_or_else(|| {
AuthVaultLifecycleError::ProfileUnavailable(format!(
"profile '{}' does not exist",
self.storage_profile()
))
})
})
}
pub fn read_with(
&self,
read: impl FnOnce() -> Result<BTreeMap<String, String>, AuthVaultLifecycleError>,
) -> Result<BTreeMap<String, String>, AuthVaultLifecycleError> {
let keys = self.credential_storage_keys()?;
let values: BTreeMap<_, _> = read()?
.into_iter()
.map(|(key, value)| (key, Zeroizing::new(value)))
.collect();
Ok(keys
.into_iter()
.filter_map(|(credential, key)| {
values
.get(&key)
.map(|value| (credential, value.to_string()))
})
.collect())
}
#[cfg(unix)]
pub fn read_from_custody(
&self,
custody: &dyn crate::custody_storage::AuthCustodyStorage,
identities: &[&str],
passphrase: Option<&str>,
) -> Result<BTreeMap<String, String>, AuthVaultLifecycleError> {
self.read_from_custody_with_device(custody, identities, passphrase, None)
}
#[cfg(unix)]
pub(crate) fn read_from_custody_with_device(
&self,
custody: &dyn crate::custody_storage::AuthCustodyStorage,
identities: &[&str],
passphrase: Option<&str>,
device: Option<&dyn crate::operations::AuthDeviceFactorSource>,
) -> Result<BTreeMap<String, String>, AuthVaultLifecycleError> {
custody
.ensure_no_refresh()
.map_err(|_| AuthVaultLifecycleError::RefreshBlocked)?;
self.read_with(|| {
let bytes = custody.read().map_err(|_| {
AuthVaultLifecycleError::VaultUnavailable("credential custody unavailable".into())
})?;
let ciphertext = sshenv_vault::Vault::decode_ciphertext(&bytes).map_err(|_| {
AuthVaultLifecycleError::VaultUnavailable("invalid credential custody".into())
})?;
let key = sshenv_vault::recipient::unwrap_data_key_with_strings(
&ciphertext.recipients,
identities,
)
.map_err(|_| {
AuthVaultLifecycleError::ProfileUnavailable(
"could not unlock credential custody".into(),
)
})?;
let (mut vault, key) =
sshenv_vault::Vault::unlock_metadata_with_data_key_and_device_factor(
ciphertext,
key,
passphrase,
|factor| {
let source = device
.ok_or_else(|| std::io::Error::other("device custody unavailable"))?;
source
.retrieve(
&factor.id,
factor.recipient_fingerprint.as_deref(),
&factor.params,
)
.map_err(Into::into)
},
)
.map_err(|_| {
AuthVaultLifecycleError::ProfileUnavailable(
"could not unlock credential custody".into(),
)
})?;
self.enforce_custody_policy(&mut vault)?;
vault
.unlock_profile_with_device_factor(
self.storage_profile(),
&key,
passphrase,
|factor| {
let source = device
.ok_or_else(|| std::io::Error::other("device custody unavailable"))?;
source
.retrieve(
&factor.id,
factor.recipient_fingerprint.as_deref(),
&factor.params,
)
.map_err(Into::into)
},
)
.map_err(|_| {
AuthVaultLifecycleError::ProfileUnavailable(
"could not unlock selected credential profile".into(),
)
})?;
let selected = vault.profiles.profiles.remove(self.storage_profile());
for values in vault.profiles.profiles.values_mut() {
for value in values.values_mut() {
value.zeroize();
}
}
selected.ok_or_else(|| {
AuthVaultLifecycleError::ProfileUnavailable(
"credential custody profile unavailable".into(),
)
})
})
}
#[cfg(unix)]
pub fn materialize_from_custody(
&self,
custody: &dyn crate::custody_storage::AuthCustodyStorage,
identities: &[&str],
passphrase: Option<&str>,
) -> Result<crate::ResolvedProviderAuth, AuthVaultLifecycleError> {
self.materialize_from_custody_with_device(custody, identities, passphrase, None)
}
#[cfg(unix)]
pub(crate) fn materialize_from_custody_with_device(
&self,
custody: &dyn crate::custody_storage::AuthCustodyStorage,
identities: &[&str],
passphrase: Option<&str>,
device: Option<&dyn crate::operations::AuthDeviceFactorSource>,
) -> Result<crate::ResolvedProviderAuth, AuthVaultLifecycleError> {
let keys = self.credential_storage_keys()?;
let credentials =
self.read_from_custody_with_device(custody, identities, passphrase, device)?;
let env = credentials
.into_iter()
.map(|(id, value)| (keys[&id].clone(), value))
.collect();
let auth = crate::provider_auth_context(
&self.resolved.profile_name,
&self.resolved.profile,
self.storage_profile(),
None,
&env,
Vec::new(),
);
Ok(crate::ResolvedProviderAuth { auth, env })
}
#[cfg(unix)]
pub(crate) fn persist_to_custody(
&self,
custody: &mut dyn crate::custody_storage::AuthCustodyStorage,
identities: &[&str],
passphrase: Option<&str>,
changes: BTreeMap<String, Option<String>>,
key_source: Option<&dyn crate::operations::AuthCustodyKeySource>,
device: Option<&dyn crate::operations::AuthDeviceFactorSource>,
) -> Result<Vec<crate::security::AuthSecurityDiagnostic>, AuthVaultLifecycleError> {
self.validate_custody_changes(&changes)?;
custody
.ensure_no_refresh()
.map_err(|_| AuthVaultLifecycleError::RefreshBlocked)?;
custody.ensure_no_provisioning().map_err(|_| {
AuthVaultLifecycleError::WriteFailed(
"credential provisioning requires maintenance".into(),
)
})?;
let before = custody.read().map_err(|_| {
AuthVaultLifecycleError::VaultUnavailable("credential custody unavailable".into())
})?;
let ciphertext = sshenv_vault::Vault::decode_ciphertext(&before).map_err(|_| {
AuthVaultLifecycleError::VaultUnavailable("invalid credential custody".into())
})?;
let key = sshenv_vault::recipient::unwrap_data_key_with_strings(
&ciphertext.recipients,
identities,
)
.map_err(|_| {
AuthVaultLifecycleError::VaultUnavailable("could not unlock custody".into())
})?;
let (mut vault, key) =
sshenv_vault::Vault::unlock_metadata_with_data_key_and_device_factor(
ciphertext,
key,
passphrase,
|factor| {
let source = device
.ok_or_else(|| std::io::Error::other("device custody unavailable"))?;
source
.retrieve(
&factor.id,
factor.recipient_fingerprint.as_deref(),
&factor.params,
)
.map_err(Into::into)
},
)
.map_err(|_| {
AuthVaultLifecycleError::VaultUnavailable("could not unlock custody".into())
})?;
vault
.unlock_profile_with_device_factor(self.storage_profile(), &key, passphrase, |factor| {
let source =
device.ok_or_else(|| std::io::Error::other("device custody unavailable"))?;
source
.retrieve(
&factor.id,
factor.recipient_fingerprint.as_deref(),
&factor.params,
)
.map_err(Into::into)
})
.map_err(|_| {
AuthVaultLifecycleError::ProfileUnavailable(
"could not unlock selected profile".into(),
)
})?;
let provisioning = self.begin_custody_provisioning(custody, &vault, device)?;
let diagnostics = crate::security::prepare_retained_device_policy(
&mut vault,
self.resolved,
self.storage_profile(),
device,
provisioning.as_ref(),
)?;
self.apply_custody_changes(&mut vault, changes)?;
let result = vault.save_with_effects(
&key,
|| generate_custody_key(key_source).map_err(Into::into),
|bytes, _| {
custody
.compare_and_publish(&before, bytes)
.map_err(Into::into)
},
);
zeroize_vault_values(&mut vault);
result.map_err(|_| {
AuthVaultLifecycleError::WriteFailed(
"credential publication failed; reload before retrying".into(),
)
})?;
if provisioning.is_some() && diagnostics.is_empty() {
custody.finish_provisioning().map_err(|_| {
AuthVaultLifecycleError::WriteFailed(
"credential provisioning requires maintenance".into(),
)
})?;
}
Ok(diagnostics)
}
#[cfg(unix)]
fn begin_custody_provisioning(
&self,
custody: &dyn crate::custody_storage::AuthCustodyStorage,
vault: &sshenv_vault::Vault,
selected: Option<&dyn crate::operations::AuthDeviceFactorSource>,
) -> Result<Option<crate::operations::AuthProvisioningIntent>, AuthVaultLifecycleError> {
let needed = selected.is_some()
&& crate::security::validate_retained_device_policy(
vault,
self.storage_profile(),
crate::security::device_seal_options_for_auth_profile(&self.resolved.profile),
)
.is_err();
if needed {
let identity = selected
.ok_or(AuthVaultLifecycleError::InvalidCredential)?
.provisioning_identity(self.resolved);
let (source, operation) = match identity {
Ok(identity) => identity,
Err(_)
if crate::security::device_seal_options_for_auth_profile(
&self.resolved.profile,
)
.policy
== crate::security::AuthDeviceSealPolicy::Preferred =>
{
return Ok(None);
}
Err(_) => {
return Err(AuthVaultLifecycleError::WriteFailed(
"recoverable provisioning unavailable".into(),
));
}
};
let intent = crate::operations::AuthProvisioningIntent {
version: 2,
source,
operation,
profile_binding: crate::operations::provisioning_binding(self.resolved)?,
};
custody.begin_provisioning(&intent).map_err(|_| {
AuthVaultLifecycleError::WriteFailed(
"credential provisioning requires maintenance".into(),
)
})?;
return Ok(Some(intent));
}
Ok(None)
}
#[cfg(unix)]
fn apply_custody_changes(
&self,
vault: &mut sshenv_vault::Vault,
changes: BTreeMap<String, Option<String>>,
) -> Result<(), AuthVaultLifecycleError> {
let values = vault
.profiles
.profiles
.get_mut(self.storage_profile())
.ok_or_else(|| {
AuthVaultLifecycleError::ProfileUnavailable("selected profile missing".into())
})?;
for (name, value) in changes {
let previous = if let Some(value) = value {
values.insert(name, value)
} else {
values.remove(&name)
};
if let Some(mut previous) = previous {
previous.zeroize();
}
}
Ok(())
}
#[cfg(unix)]
pub(crate) fn validate_enrollment_custody(
&self,
custody: &dyn crate::custody_storage::AuthCustodyStorage,
identities: &[&str],
) -> Result<(), AuthVaultLifecycleError> {
let failure = || AuthVaultLifecycleError::InvalidCredential;
let bytes = custody.read().map_err(|_| failure())?;
let ciphertext = sshenv_vault::Vault::decode_ciphertext(&bytes).map_err(|_| failure())?;
let metadata = ciphertext.policy_metadata.as_ref().ok_or_else(failure)?;
if !metadata.profile_keys_enabled
|| !metadata.remote_factors.is_empty()
|| !metadata.recovery_share_sets.is_empty()
|| metadata
.policies
.iter()
.flat_map(|policy| &policy.factors)
.any(|factor| factor.kind != sshenv_vault::models::UnlockFactorKindV2::SshRecipient)
|| metadata
.recipients
.iter()
.any(|recipient| !recipient.public_descriptor.starts_with("age1"))
{
return Err(failure());
}
let key = sshenv_vault::recipient::unwrap_data_key_with_strings(
&ciphertext.recipients,
identities,
)
.map_err(|_| failure())?;
let (mut vault, _) = sshenv_vault::Vault::unlock_metadata_with_data_key_and_device_factor(
ciphertext,
key,
None,
|_| Err(std::io::Error::other("unsupported enrollment factor").into()),
)
.map_err(|_| failure())?;
let supported = vault
.profiles
.profile_policies
.get(self.storage_profile())
.is_none_or(|policy| {
policy.required_factors.is_empty() && policy.factor_metadata.is_empty()
})
&& (vault.profiles.profiles.contains_key(self.storage_profile())
|| vault
.profiles
.profile_entries
.contains_key(self.storage_profile()));
zeroize_vault_values(&mut vault);
if !supported {
return Err(failure());
}
Ok(())
}
#[cfg(unix)]
fn enforce_custody_policy(
&self,
vault: &mut sshenv_vault::Vault,
) -> Result<(), AuthVaultLifecycleError> {
let options = crate::security::device_seal_options_for_auth_profile(&self.resolved.profile);
if options.policy != crate::security::AuthDeviceSealPolicy::Required {
return Ok(());
}
let result = crate::security::validate_retained_device_policy(
vault,
self.storage_profile(),
options,
);
if result.is_err() {
zeroize_vault_values(vault);
}
result
}
#[cfg(unix)]
fn validate_custody_changes(
&self,
changes: &BTreeMap<String, Option<String>>,
) -> Result<(), AuthVaultLifecycleError> {
let allowed = self.credential_storage_keys()?;
if changes
.keys()
.any(|key| !allowed.values().any(|value| value == key))
{
return Err(AuthVaultLifecycleError::InvalidCredential);
}
Ok(())
}
pub fn import_new(
&self,
credentials: BTreeMap<String, String>,
) -> Result<Vec<crate::security::AuthSecurityDiagnostic>, AuthVaultLifecycleError> {
let AuthMethodContribution::SecretFields { fields, .. } = self.method else {
return Err(AuthVaultLifecycleError::UnknownMethod {
provider_id: self.resolved.provider_id.clone(),
method_id: self.method.method_id().to_owned(),
});
};
for field in fields {
if let Some(value) = credentials.get(&field.credential_id) {
field
.validation
.validate_secret(value)
.map_err(|_| AuthVaultLifecycleError::InvalidCredential)?;
} else if !field.optional {
return Err(AuthVaultLifecycleError::InvalidCredential);
}
}
let status = self.inspect()?;
if credentials
.keys()
.any(|key| status.present_credentials.contains(key))
{
return Err(AuthVaultLifecycleError::CredentialExists);
}
if credentials.len() != 1 {
return Err(AuthVaultLifecycleError::InvalidCredential);
}
let storage_keys = self.credential_storage_keys()?;
let (credential, value) = credentials
.into_iter()
.next()
.ok_or(AuthVaultLifecycleError::InvalidCredential)?;
let key = storage_keys
.get(&credential)
.ok_or(AuthVaultLifecycleError::InvalidCredential)?;
let (store, recipient) = self.open_or_initialize_store()?;
store
.insert_secret_if_absent(self.storage_profile(), key, Zeroizing::new(value))
.map_err(|_| {
AuthVaultLifecycleError::WriteFailed(
"Import conflicted or vault could not be saved; reload before retrying"
.to_owned(),
)
})?;
self.reconcile_device_seal(Some(&recipient))
}
pub fn upsert(
&self,
credentials: BTreeMap<String, String>,
) -> Result<Vec<crate::security::AuthSecurityDiagnostic>, AuthVaultLifecycleError> {
let storage_keys = self.credential_storage_keys()?;
for credential in credentials.keys() {
if !storage_keys.contains_key(credential) {
return Err(AuthVaultLifecycleError::UnknownCredential {
method_id: self.method.method_id().to_owned(),
credential_id: credential.clone(),
});
}
}
let (store, recipient_key) = self.open_or_initialize_store()?;
let mut values = match store.get_profile(self.storage_profile()) {
Ok(Some(values)) => values,
Ok(None) => BTreeMap::new(),
Err(error) => {
return Err(AuthVaultLifecycleError::ProfileUnavailable(
error.to_string(),
));
}
};
for (credential, value) in credentials {
let key = &storage_keys[&credential];
values.insert(key.clone(), Zeroizing::new(value));
}
store
.replace_profile(self.storage_profile(), values)
.map_err(|error| AuthVaultLifecycleError::WriteFailed(error.to_string()))?;
self.reconcile_device_seal(Some(&recipient_key))
}
pub fn update(
&self,
credentials: BTreeMap<String, Option<String>>,
) -> Result<Vec<crate::security::AuthSecurityDiagnostic>, AuthVaultLifecycleError> {
self.update_with(credentials, |changes| self.persist_storage_updates(changes))
}
pub fn update_with(
&self,
credentials: BTreeMap<String, Option<String>>,
persist: impl FnOnce(
BTreeMap<String, Option<String>>,
) -> Result<
Vec<crate::security::AuthSecurityDiagnostic>,
AuthVaultLifecycleError,
>,
) -> Result<Vec<crate::security::AuthSecurityDiagnostic>, AuthVaultLifecycleError> {
let storage_keys = self.credential_storage_keys()?;
for credential in credentials.keys() {
if !storage_keys.contains_key(credential) {
return Err(AuthVaultLifecycleError::UnknownCredential {
method_id: self.method.method_id().to_owned(),
credential_id: credential.clone(),
});
}
}
persist(
credentials
.into_iter()
.map(|(credential, value)| (storage_keys[&credential].clone(), value))
.collect(),
)
}
fn persist_storage_updates(
&self,
changes: BTreeMap<String, Option<String>>,
) -> Result<Vec<crate::security::AuthSecurityDiagnostic>, AuthVaultLifecycleError> {
#[cfg(unix)]
let _guard = {
if !self.vault_path().exists() {
self.open_or_initialize_store()?;
}
crate::operations::refresh::native::guard(&self.vault_path())?
};
self.persist_storage_updates_uncoordinated(changes)
}
pub(crate) fn persist_storage_updates_uncoordinated(
&self,
changes: BTreeMap<String, Option<String>>,
) -> Result<Vec<crate::security::AuthSecurityDiagnostic>, AuthVaultLifecycleError> {
let (_store, recipient_key) = self.open_or_initialize_store()?;
let vault_path = self.vault_path();
let (mut vault, data_key) = sshenv_vault::load_and_unlock_metadata_with_private_key_paths(
&vault_path,
&crate::security::vault_private_key_paths(&vault_path),
)
.map_err(|_| {
AuthVaultLifecycleError::VaultUnavailable("could not unlock credential metadata".into())
})?;
let profile = self.storage_profile();
if vault.profiles.profile_entries.contains_key(profile) {
vault
.unlock_profile_with_passphrase(profile, &data_key, None)
.map_err(|_| {
AuthVaultLifecycleError::ProfileUnavailable(
"could not unlock credential profile".into(),
)
})?;
}
let values = vault
.profiles
.profiles
.entry(profile.to_owned())
.or_default();
for (key, value) in changes {
if let Some(value) = value {
if let Some(mut previous) = values.insert(key, value) {
previous.zeroize();
}
} else if let Some(mut previous) = values.remove(&key) {
previous.zeroize();
}
}
let options = crate::security::device_seal_options_for_auth_profile(&self.resolved.profile);
let mut prepared = vault.clone();
let diagnostics = match crate::security::prepare_auth_vault_security(
&mut prepared,
&data_key,
&vault_path,
profile,
options,
Some(&recipient_key),
) {
Ok((actions, _)) => {
vault = prepared;
actions
.into_iter()
.map(|action| {
crate::security::AuthSecurityDiagnostic::info(
"auth_vault_security_refreshed",
action,
)
})
.collect()
}
Err(_) if options.policy == crate::security::AuthDeviceSealPolicy::Required => {
return Err(AuthVaultLifecycleError::DeviceSealRequired(vec![
crate::security::AuthSecurityDiagnostic::error(
"auth_vault_security_required_unsatisfied",
"Required device seal could not be prepared; credentials were not published",
"Restore device custody before retrying the credential update",
),
]));
}
Err(_) => vec![crate::security::AuthSecurityDiagnostic::warning(
"auth_vault_security_refresh_skipped",
"Preferred device seal could not be prepared; existing profile policy preserved",
"Restore device custody to enable the preferred seal",
)],
};
vault.save(&vault_path, &data_key).map_err(|_| {
AuthVaultLifecycleError::WriteFailed("could not publish credential update".into())
})?;
Ok(diagnostics)
}
pub fn replace_owned(
&self,
credentials: BTreeMap<String, String>,
) -> Result<Vec<crate::security::AuthSecurityDiagnostic>, AuthVaultLifecycleError> {
let storage_keys = self.credential_storage_keys()?;
for credential in credentials.keys() {
if !storage_keys.contains_key(credential) {
return Err(AuthVaultLifecycleError::UnknownCredential {
method_id: self.method.method_id().to_owned(),
credential_id: credential.clone(),
});
}
}
let mut credentials = credentials;
self.persist_storage_updates(
storage_keys
.into_iter()
.map(|(credential, key)| (key, credentials.remove(&credential)))
.collect(),
)
}
pub fn delete(&self) -> Result<(), AuthVaultLifecycleError> {
#[cfg(unix)]
let _guard = crate::operations::refresh::native::read_guard(&self.vault_path())?;
let vault = self.vault_path();
if !vault.exists() {
return Ok(());
}
let store = auth_store(&vault);
let Some(mut values) = store
.get_profile(self.storage_profile())
.map_err(|error| AuthVaultLifecycleError::ProfileUnavailable(error.to_string()))?
else {
return Ok(());
};
for key in self.credential_storage_keys()?.into_values() {
values.remove(&key);
}
store
.replace_profile(self.storage_profile(), values)
.map_err(|error| AuthVaultLifecycleError::WriteFailed(error.to_string()))
}
pub(crate) fn credential_storage_keys(
&self,
) -> Result<BTreeMap<String, String>, AuthVaultLifecycleError> {
let mut keys = BTreeMap::new();
match self.method {
AuthMethodContribution::SecretFields { fields, .. } => {
for field in fields {
let configured = self
.resolved
.profile
.map
.get(&field.credential_id)
.and_then(|mapping| mapping.key.as_ref().or(mapping.env.as_ref()))
.cloned()
.unwrap_or_else(|| field.storage_key.clone());
keys.insert(field.credential_id.clone(), configured);
}
}
AuthMethodContribution::Interactive { credentials, .. } => {
for credential in credentials {
let configured = self
.resolved
.profile
.map
.get(&credential.credential_id)
.and_then(|mapping| mapping.key.as_ref().or(mapping.env.as_ref()))
.cloned()
.unwrap_or_else(|| credential.storage_key.clone());
keys.insert(credential.credential_id.clone(), configured);
}
}
}
if keys.is_empty() {
return Err(AuthVaultLifecycleError::UnknownMethod {
provider_id: self.resolved.provider_id.clone(),
method_id: self.method.method_id().to_owned(),
});
}
Ok(keys)
}
fn storage_profile(&self) -> &str {
self.resolved
.profile
.settings
.get("profile")
.map_or(self.resolved.profile_name.as_str(), String::as_str)
}
pub(crate) fn vault_path(&self) -> PathBuf {
self.resolved
.profile
.settings
.get("vault")
.map_or_else(bcode_config::default_auth_vault_path, PathBuf::from)
}
fn open_or_initialize_store(
&self,
) -> Result<(sshenv_vault::SshenvStore, String), AuthVaultLifecycleError> {
let vault = self.vault_path();
let recipient_key = crate::security::ensure_vault_recipient_key(&vault)
.map_err(|error| AuthVaultLifecycleError::VaultUnavailable(error.to_string()))?;
let store = auth_store(&vault);
if vault.exists() {
sshenv_vault::load_and_unlock_metadata_with_private_key_paths(
&vault,
&crate::security::vault_private_key_paths(&vault),
)
.map_err(|error| AuthVaultLifecycleError::VaultUnavailable(error.to_string()))?;
} else {
initialize_vault(&vault, &store, &recipient_key)?;
}
Ok((store, recipient_key))
}
fn reconcile_device_seal(
&self,
recipient_key: Option<&str>,
) -> Result<Vec<crate::security::AuthSecurityDiagnostic>, AuthVaultLifecycleError> {
let report = crate::security::reconcile_auth_vault_security_report_with_options(
&self.vault_path(),
self.storage_profile(),
crate::security::device_seal_options_for_auth_profile(&self.resolved.profile),
recipient_key,
);
if report.diagnostics.iter().any(|diagnostic| {
diagnostic.severity == crate::security::AuthSecurityDiagnosticSeverity::Error
}) {
return Err(AuthVaultLifecycleError::DeviceSealRequired(
report.diagnostics,
));
}
Ok(report.diagnostics)
}
}
fn validate_resolved_owner(
resolved: &ResolvedAuthProfile,
provider_id: &str,
owner_plugin_id: &str,
) -> Result<(), AuthProfileResolutionError> {
if resolved.provider_id != provider_id {
return Err(AuthProfileResolutionError::ProviderMismatch {
profile: resolved.profile_name.clone(),
expected: provider_id.to_owned(),
actual: resolved.provider_id.clone(),
});
}
if resolved.owner_plugin_id != owner_plugin_id {
return Err(AuthProfileResolutionError::OwnerMismatch {
profile: resolved.profile_name.clone(),
expected: owner_plugin_id.to_owned(),
actual: resolved.owner_plugin_id.clone(),
});
}
super::validate_auth_profile_ownership(
&resolved.profile_name,
&resolved.profile,
provider_id,
owner_plugin_id,
)
}
fn auth_store(vault: &Path) -> sshenv_vault::SshenvStore {
sshenv_vault::SshenvStore::new(
sshenv_vault::SshenvStoreConfig::new(vault.to_path_buf())
.with_private_key_paths(crate::security::vault_private_key_paths(vault)),
)
}
fn initialize_vault(
vault_path: &Path,
store: &sshenv_vault::SshenvStore,
recipient_key: &str,
) -> Result<(), AuthVaultLifecycleError> {
store
.init(recipient_key)
.map_err(|error| AuthVaultLifecycleError::WriteFailed(error.to_string()))?;
let (mut vault, data_key) = store
.load_and_unlock()
.map_err(|error| AuthVaultLifecycleError::VaultUnavailable(error.to_string()))?;
vault
.migrate_to_v2(&[recipient_key.to_owned()])
.map_err(|error| AuthVaultLifecycleError::WriteFailed(error.to_string()))?;
vault
.enable_profile_keys()
.map_err(|error| AuthVaultLifecycleError::WriteFailed(error.to_string()))?;
vault
.save(vault_path, &data_key)
.map_err(|error| AuthVaultLifecycleError::WriteFailed(error.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(feature = "custody-simulation"))]
use bcode_provider_auth_models::AuthCredentialStorage;
use bcode_provider_auth_models::{AuthSecretField, AuthSecretValidation};
fn resolved(vault: &Path) -> ResolvedAuthProfile {
ResolvedAuthProfile {
profile_name: "exa".to_owned(),
provider_id: "exa".to_owned(),
owner_plugin_id: "bcode.web-search".to_owned(),
profile: bcode_config::AuthProfileConfig {
backend: "sshenv".to_owned(),
provider_id: Some("exa".to_owned()),
owner_plugin_id: Some("bcode.web-search".to_owned()),
scheme: Some("api_key".to_owned()),
map: BTreeMap::new(),
settings: BTreeMap::from([
("profile".to_owned(), "exa".to_owned()),
("vault".to_owned(), vault.display().to_string()),
("device_seal".to_owned(), "off".to_owned()),
]),
},
source: crate::AuthProfileSource::Declarative,
}
}
#[cfg(unix)]
#[cfg(not(feature = "custody-simulation"))]
struct RefreshFixture {
root: tempfile::TempDir,
directory: PathBuf,
identity: String,
profile: ResolvedAuthProfile,
registration: AuthMethodContribution,
retained: crate::operations::RetainedAuthRequestCustody,
}
#[cfg(unix)]
#[cfg(not(feature = "custody-simulation"))]
fn refresh_fixture() -> RefreshFixture {
use crate::operations::RetainedAuthRequestCustody;
let root = tempfile::tempdir().unwrap();
let identity_path = root.path().join("identity");
let public = crate::security::ensure_vault_recipient_key(&identity_path).unwrap();
let identity =
std::fs::read_to_string(crate::security::vault_private_key_path(&identity_path))
.unwrap();
let (mut vault, key) = sshenv_vault::Vault::create(&public).unwrap();
vault.migrate_to_v2(&[public]).unwrap();
vault.enable_profile_keys().unwrap();
vault.profiles.profiles.insert(
"exa".into(),
BTreeMap::from([
("TEST_PROVIDER_API_KEY".into(), "old".into()),
("REFRESH_TOKEN".into(), "old-refresh".into()),
("UNRELATED".into(), "preserved".into()),
]),
);
let mut bytes = Vec::new();
vault
.save_with_storage(&key, |value, _| {
bytes = value.to_vec();
Ok(())
})
.unwrap();
let directory = root.path().join("custody");
let storage =
crate::custody_storage::CredentialCustodyStorage::create(&directory, &bytes).unwrap();
let profile = resolved(&root.path().join("unused"));
let mut registration = method();
if let AuthMethodContribution::SecretFields { fields, .. } = &mut registration {
let mut refresh_field = fields[0].clone();
refresh_field.credential_id = "refresh_token".into();
refresh_field.storage_key = "REFRESH_TOKEN".into();
fields.push(refresh_field);
}
let retained = RetainedAuthRequestCustody::new(
storage,
profile.clone(),
"exa",
"bcode.web-search",
registration.clone(),
vec![Zeroizing::new(identity.clone())],
None,
)
.unwrap();
RefreshFixture {
root,
directory,
identity,
profile,
registration,
retained,
}
}
#[test]
#[cfg(unix)]
#[cfg(not(feature = "custody-simulation"))]
fn native_refresh_fences_reads_and_writes_and_preserves_complete_rotation() {
use crate::operations::refresh::{RefreshOperation, native};
use bcode_provider_auth_models::{
AuthRefreshBeginRequest, AuthRefreshBeginResponse, AuthRefreshCommitRequest,
};
let root = tempfile::tempdir().unwrap();
let path = root.path().join("vault");
let profile = resolved(&path);
let mut registration = method();
if let AuthMethodContribution::SecretFields { fields, .. } = &mut registration {
let mut field = fields[0].clone();
field.credential_id = "refresh_token".into();
field.storage_key = "REFRESH_TOKEN".into();
fields.push(field);
}
let lifecycle =
AuthVaultLifecycle::new(&profile, "exa", "bcode.web-search", ®istration).unwrap();
lifecycle
.update(BTreeMap::from([
("api_key".into(), Some("old".into())),
("refresh_token".into(), Some("old-refresh".into())),
]))
.unwrap();
let begin = AuthRefreshBeginRequest {
schema_version: 1,
provider_id: "exa".into(),
profile: profile.profile_name.clone(),
credentials: BTreeMap::from([
("api_key".into(), Some("old".into())),
("refresh_token".into(), Some("old-refresh".into())),
]),
};
let admitted: AuthRefreshBeginResponse = serde_json::from_value(
native::resolve(
&profile,
®istration,
RefreshOperation::Begin(begin.clone()),
)
.unwrap(),
)
.unwrap();
assert!(lifecycle.read().is_err());
assert!(
lifecycle
.update(BTreeMap::from([("api_key".into(), Some("other".into()))]))
.is_err()
);
assert!(lifecycle.delete().is_err());
assert!(
native::resolve(
&profile,
®istration,
RefreshOperation::Begin(begin.clone())
)
.is_err()
);
let commit = AuthRefreshCommitRequest {
schema_version: 1,
provider_id: "exa".into(),
profile: profile.profile_name.clone(),
attempt: admitted.attempt,
credentials: BTreeMap::from([
("api_key".into(), Some("rotated".into())),
("refresh_token".into(), Some("rotated-refresh".into())),
]),
};
let mut incomplete = commit.clone();
incomplete.credentials.remove("refresh_token");
assert!(
native::resolve(
&profile,
®istration,
RefreshOperation::Commit(incomplete)
)
.is_err()
);
let mut foreign = commit.clone();
foreign.attempt = "f".repeat(64);
assert!(
native::resolve(&profile, ®istration, RefreshOperation::Commit(foreign)).is_err()
);
native::resolve(
&profile,
®istration,
RefreshOperation::Commit(commit.clone()),
)
.unwrap();
assert_eq!(
lifecycle.read().unwrap()["refresh_token"],
"rotated-refresh"
);
assert!(
native::resolve(&profile, ®istration, RefreshOperation::Commit(commit)).is_err()
);
assert!(native::resolve(&profile, ®istration, RefreshOperation::Begin(begin)).is_err());
}
#[test]
#[cfg(unix)]
#[cfg(not(feature = "custody-simulation"))]
fn refresh_serializes_and_commits_complete_records() {
use crate::operations::{AuthCredentialCustody as _, AuthRequestCustody as _};
use bcode_provider_auth_models::{AuthRefreshBeginRequest, AuthRefreshCommitRequest};
let fixture = refresh_fixture();
let profile = fixture.profile.clone();
let retained = &fixture.retained;
let begin = AuthRefreshBeginRequest {
schema_version: 1,
provider_id: "exa".into(),
profile: "exa".into(),
credentials: BTreeMap::from([
("api_key".into(), Some("old".into())),
("refresh_token".into(), Some("old-refresh".into())),
]),
};
let mut foreign_selection = begin.clone();
foreign_selection.profile = "other-profile".into();
assert!(retained.begin_refresh(&profile, foreign_selection).is_err());
let mut foreign_provider = begin.clone();
foreign_provider.provider_id = "other-provider".into();
assert!(retained.begin_refresh(&profile, foreign_provider).is_err());
let mut stale = begin.clone();
stale
.credentials
.insert("api_key".into(), Some("stale".into()));
assert!(retained.begin_refresh(&profile, stale).is_err());
let (left, right) = std::thread::scope(|scope| {
let left = scope.spawn(|| retained.begin_refresh(&profile, begin.clone()));
let right = scope.spawn(|| retained.begin_refresh(&profile, begin.clone()));
(left.join().unwrap(), right.join().unwrap())
});
assert_ne!(left.is_ok(), right.is_ok());
let attempt = left.or(right).unwrap();
assert!(retained.begin_refresh(&profile, begin).is_err());
assert!(
retained
.persist(
&profile,
BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), Some("bypass".into()))])
)
.is_err()
);
let context = bcode_model::ProviderRequestContext {
auth_profile: Some("exa".into()),
..Default::default()
};
assert!(retained.materialize("bcode.web-search", &context).is_err());
let commit = AuthRefreshCommitRequest {
schema_version: 1,
provider_id: "exa".into(),
profile: "exa".into(),
attempt: attempt.attempt,
credentials: BTreeMap::from([
("api_key".into(), Some("rotated".into())),
("refresh_token".into(), Some("rotated-refresh".into())),
]),
};
let mut incomplete = commit.clone();
incomplete.credentials.remove("refresh_token");
assert!(retained.commit_refresh(&profile, incomplete).is_err());
let mut foreign = commit.clone();
foreign.attempt = "f".repeat(64);
assert!(retained.commit_refresh(&profile, foreign).is_err());
retained.commit_refresh(&profile, commit.clone()).unwrap();
assert!(retained.commit_refresh(&profile, commit).is_err());
let materialized = retained.materialize("bcode.web-search", &context).unwrap();
assert_eq!(materialized.env["TEST_PROVIDER_API_KEY"], "rotated");
assert_eq!(materialized.env["REFRESH_TOKEN"], "rotated-refresh");
}
#[test]
#[cfg(unix)]
#[cfg(not(feature = "custody-simulation"))]
fn unresolved_refresh_requires_owned_reenrollment() {
use crate::operations::{
AuthCredentialCustody as _, AuthRequestCustody as _, RetainedAuthRequestCustody,
};
use bcode_provider_auth_models::{AuthRefreshBeginRequest, AuthRefreshCommitRequest};
let RefreshFixture {
root: _root,
directory,
identity,
profile,
registration,
retained,
} = refresh_fixture();
let context = bcode_model::ProviderRequestContext {
auth_profile: Some("exa".into()),
..Default::default()
};
let next = AuthRefreshBeginRequest {
schema_version: 1,
provider_id: "exa".into(),
profile: "exa".into(),
credentials: BTreeMap::from([
("api_key".into(), Some("old".into())),
("refresh_token".into(), Some("old-refresh".into())),
]),
};
let unresolved = retained.begin_refresh(&profile, next.clone()).unwrap();
drop(retained);
let storage = crate::custody_storage::CredentialCustodyStorage::open(&directory).unwrap();
let reopened = RetainedAuthRequestCustody::new(
storage,
profile.clone(),
"exa",
"bcode.web-search",
registration,
vec![Zeroizing::new(identity.clone())],
None,
)
.unwrap();
assert!(reopened.materialize("bcode.web-search", &context).is_err());
assert!(reopened.begin_refresh(&profile, next).is_err());
assert!(
reopened
.commit_refresh(
&profile,
AuthRefreshCommitRequest {
schema_version: 1,
provider_id: "exa".into(),
profile: "exa".into(),
attempt: unresolved.attempt,
credentials: BTreeMap::from([
("api_key".into(), Some("late".into())),
("refresh_token".into(), Some("late-refresh".into()))
]),
}
)
.is_err()
);
let mut wrong_owner = profile.clone();
wrong_owner.owner_plugin_id = "foreign".into();
let enrolled = BTreeMap::from([
("api_key".into(), Some("reenrolled".into())),
("refresh_token".into(), Some("reenrolled-refresh".into())),
]);
assert!(
reopened
.reenroll_after_refresh(&wrong_owner, enrolled.clone())
.is_err()
);
reopened.reenroll_after_refresh(&profile, enrolled).unwrap();
assert_eq!(
reopened
.materialize("bcode.web-search", &context)
.unwrap()
.env["TEST_PROVIDER_API_KEY"],
"reenrolled"
);
drop(reopened);
let storage = crate::custody_storage::CredentialCustodyStorage::open(&directory).unwrap();
let ciphertext = sshenv_vault::Vault::decode_ciphertext(&storage.read().unwrap()).unwrap();
let key = sshenv_vault::recipient::unwrap_data_key_with_strings(
&ciphertext.recipients,
&[identity.as_str()],
)
.unwrap();
let (mut persisted, key) =
sshenv_vault::Vault::unlock_metadata_with_data_key_and_device_factor(
ciphertext,
key,
None,
|_| Err(std::io::Error::other("unexpected factor").into()),
)
.unwrap();
persisted
.unlock_profile_with_passphrase("exa", &key, None)
.unwrap();
let values = &persisted.profiles.profiles["exa"];
assert_eq!(values["TEST_PROVIDER_API_KEY"], "reenrolled");
assert_eq!(values["REFRESH_TOKEN"], "reenrolled-refresh");
assert_eq!(values["UNRELATED"], "preserved");
}
#[test]
#[cfg(target_os = "macos")]
fn native_operation_source_rejects_foreign_intents_before_effects() {
use crate::operations::{AuthDeviceFactorSource as _, AuthProvisioningIntent};
let profile = resolved(Path::new("/unused-native-source-test"));
let source =
crate::native_device::MacosOperationFactorSource::new(profile.clone()).unwrap();
let intent = AuthProvisioningIntent {
version: 2,
source: "macos-operation-v1".into(),
operation: "a".repeat(64),
profile_binding: crate::operations::provisioning_binding(&profile).unwrap(),
};
let mut foreign = profile;
foreign.owner_plugin_id = "foreign-plugin".into();
assert!(source.provisioning_identity(&foreign).is_err());
let mut cases = Vec::new();
let mut changed = intent.clone();
changed.source = "foreign-source".into();
cases.push(changed);
let mut changed = intent.clone();
changed.profile_binding = crate::operations::provisioning_binding(&foreign).unwrap();
cases.push(changed);
let mut changed = intent.clone();
changed.version = 3;
cases.push(changed);
let mut changed = intent;
changed.operation = "../invalid".into();
cases.push(changed);
for invalid in cases {
assert!(source.provision_attempt(&invalid).is_err());
assert!(source.reconcile_provisioning(&invalid).is_err());
}
assert!(
source
.retrieve("foreign-factor", None, &BTreeMap::new())
.is_err()
);
}
#[cfg(target_os = "macos")]
struct NativeTestSource {
source: crate::native_device::MacosOperationFactorSource,
slots: std::sync::Mutex<Vec<String>>,
}
#[cfg(target_os = "macos")]
impl crate::operations::AuthDeviceFactorSource for NativeTestSource {
fn provisioning_identity(
&self,
profile: &ResolvedAuthProfile,
) -> Result<(String, String), AuthVaultLifecycleError> {
self.source.provisioning_identity(profile)
}
fn provision_attempt(
&self,
intent: &crate::operations::AuthProvisioningIntent,
) -> Result<crate::operations::AuthProvisionedDeviceFactor, AuthVaultLifecycleError>
{
use sha2::Digest as _;
let slot = hex::encode(sha2::Sha256::digest(
format!(
"sshenv-operation-v1:{}:{}",
intent.profile_binding, intent.operation
)
.as_bytes(),
));
self.slots.lock().unwrap().push(slot);
self.source.provision_attempt(intent)
}
fn retrieve(
&self,
id: &str,
recipient: Option<&str>,
params: &BTreeMap<String, String>,
) -> Result<Zeroizing<[u8; 32]>, AuthVaultLifecycleError> {
self.source.retrieve(id, recipient, params)
}
fn reconcile_provisioning(
&self,
intent: &crate::operations::AuthProvisioningIntent,
) -> Result<(), AuthVaultLifecycleError> {
self.source.reconcile_provisioning(intent)
}
}
#[cfg(target_os = "macos")]
impl Drop for NativeTestSource {
fn drop(&mut self) {
for slot in self.slots.get_mut().unwrap().iter() {
let output = std::process::Command::new("/usr/bin/security")
.args([
"delete-generic-password",
"-s",
"sshenv.operation-factor.v1",
"-a",
slot,
])
.output()
.expect("run test record cleanup");
if !output.status.success() {
eprintln!(
"Disposable Keychain record cleanup failed for {slot}: status {}",
output.status
);
}
}
}
}
#[test]
#[ignore = "creates disposable native Keychain factors; requires authorization"]
#[cfg(target_os = "macos")]
fn native_custody_publication_failure_reopen_reconcile_and_retry() {
use crate::operations::{
AuthCredentialCustody as _, AuthRequestCustody as _, RetainedAuthRequestCustody,
};
struct FailedKey;
impl crate::operations::AuthCustodyKeySource for FailedKey {
fn generate(&self) -> Result<Zeroizing<[u8; 32]>, AuthVaultLifecycleError> {
Err(AuthVaultLifecycleError::InvalidCredential)
}
}
let root = tempfile::tempdir().unwrap();
let path = root.path().join("identity");
let public = crate::security::ensure_vault_recipient_key(&path).unwrap();
let identity = Zeroizing::new(
std::fs::read_to_string(crate::security::vault_private_key_path(&path)).unwrap(),
);
let (mut vault, key) = sshenv_vault::Vault::create(&public).unwrap();
vault.migrate_to_v2(&[public]).unwrap();
vault.enable_profile_keys().unwrap();
vault.profiles.profiles.insert(
"exa".into(),
BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), "original".into())]),
);
let mut bytes = Vec::new();
vault
.save_with_storage(&key, |value, _| {
bytes = value.to_vec();
Ok(())
})
.unwrap();
let directory = root.path().join("custody");
let storage =
crate::custody_storage::CredentialCustodyStorage::create(&directory, &bytes).unwrap();
let mut profile = resolved(&root.path().join("unused"));
profile
.profile
.settings
.insert("device_seal".into(), "required".into());
profile.profile.settings.insert(
"device_seal_backend".into(),
"macos-keychain-device-only".into(),
);
let source = std::sync::Arc::new(NativeTestSource {
source: crate::native_device::MacosOperationFactorSource::new(profile.clone()).unwrap(),
slots: std::sync::Mutex::new(Vec::new()),
});
let open = |storage| {
RetainedAuthRequestCustody::new(
storage,
profile.clone(),
"exa",
"bcode.web-search",
method(),
vec![identity.clone()],
None,
)
.unwrap()
.device_source(source.clone())
};
let retained = open(storage).key_source(std::sync::Arc::new(FailedKey));
let changes = BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), Some("updated".into()))]);
assert!(retained.persist(&profile, changes.clone()).is_err());
assert_eq!(source.slots.lock().unwrap().len(), 1);
let slot = source.slots.lock().unwrap()[0].clone();
let factor_key = sshenv_vault::device::retrieve_operation_factor(&slot).unwrap();
drop(retained);
let storage = crate::custody_storage::CredentialCustodyStorage::open(&directory).unwrap();
assert_eq!(storage.read().unwrap(), bytes);
let retained = open(storage);
assert!(retained.persist(&profile, changes.clone()).is_err());
assert_eq!(source.slots.lock().unwrap().len(), 1);
retained.reconcile_provisioning().unwrap();
assert_eq!(
*sshenv_vault::device::retrieve_operation_factor(&slot).unwrap(),
*factor_key
);
assert!(retained.persist(&profile, changes).unwrap().is_empty());
drop(retained);
let retained =
open(crate::custody_storage::CredentialCustodyStorage::open(&directory).unwrap());
let context = bcode_model::ProviderRequestContext {
auth_profile: Some("exa".into()),
..Default::default()
};
assert_eq!(
retained
.materialize("bcode.web-search", &context)
.unwrap()
.env,
BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), "updated".into())])
);
assert_eq!(source.slots.lock().unwrap().len(), 2);
}
#[test]
#[cfg(all(unix, feature = "custody-simulation"))]
fn controlled_initialization_updates_and_reopens_real_custody() {
use crate::operations::{
AuthCredentialCustody as _, AuthRequestCustody as _, RetainedAuthRequestCustody,
};
struct Keys;
impl crate::operations::AuthCustodyKeySource for Keys {
fn generate(&self) -> Result<Zeroizing<[u8; 32]>, AuthVaultLifecycleError> {
Ok(Zeroizing::new([7; 32]))
}
}
fn run() -> Vec<u8> {
let profile = resolved(Path::new("/not-accessed"));
let method = method();
let lifecycle =
AuthVaultLifecycle::new(&profile, "exa", "bcode.web-search", &method).unwrap();
let bytes = lifecycle.prepare_custody("sim-age:owned", &Keys).unwrap();
let (simulation, storage) =
crate::custody_storage::simulation::CustodySimulation::create(&bytes).unwrap();
let retained = RetainedAuthRequestCustody::from_storage(
storage,
profile.clone(),
"exa",
"bcode.web-search",
method.clone(),
vec![Zeroizing::new("sim-age:owned".into())],
None,
)
.unwrap()
.key_source(std::sync::Arc::new(Keys));
retained
.persist(
&profile,
BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), Some("synthetic".into()))]),
)
.unwrap();
drop(retained);
let storage = simulation.open().unwrap();
let after = storage.read().unwrap();
let retained = RetainedAuthRequestCustody::from_storage(
storage,
profile,
"exa",
"bcode.web-search",
method,
vec![Zeroizing::new("sim-age:owned".into())],
None,
)
.unwrap();
let context = bcode_model::ProviderRequestContext {
auth_profile: Some("exa".into()),
..Default::default()
};
assert_eq!(
retained
.materialize("bcode.web-search", &context)
.unwrap()
.env["TEST_PROVIDER_API_KEY"],
"synthetic"
);
after
}
assert_eq!(run(), run());
}
fn method() -> AuthMethodContribution {
AuthMethodContribution::SecretFields {
method_id: "api_key".to_owned(),
display_name: "API key".to_owned(),
fields: vec![AuthSecretField {
invocation_env: Vec::new(),
discovery_sources: Vec::new(),
credential_id: "api_key".to_owned(),
storage_key: "TEST_PROVIDER_API_KEY".to_owned(),
prompt: "Exa API key".to_owned(),
optional: false,
validation: AuthSecretValidation::default(),
}],
supports_verification: false,
supports_revocation: false,
}
}
#[test]
#[cfg(not(feature = "custody-simulation"))]
fn required_seal_failure_preserves_credentials_for_updates_and_replacement() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("vault");
let mut resolved = resolved(&path);
let method = method();
AuthVaultLifecycle::new(&resolved, "exa", "bcode.web-search", &method)
.unwrap()
.upsert(BTreeMap::from([("api_key".into(), "original".into())]))
.unwrap();
let before = std::fs::read(&path).unwrap();
resolved
.profile
.settings
.insert("device_seal".into(), "required".into());
let unavailable = if cfg!(target_os = "windows") {
"linux-tpm"
} else {
"windows-dpapi"
};
resolved
.profile
.settings
.insert("device_seal_backend".into(), unavailable.into());
resolved
.profile
.settings
.insert("device_seal_strict".into(), "true".into());
let lifecycle =
AuthVaultLifecycle::new(&resolved, "exa", "bcode.web-search", &method).unwrap();
assert!(matches!(
lifecycle.update(BTreeMap::from([("api_key".into(), Some("changed".into()))])),
Err(AuthVaultLifecycleError::DeviceSealRequired(_))
));
assert_eq!(std::fs::read(&path).unwrap(), before);
assert!(matches!(
lifecycle.replace_owned(BTreeMap::from([("api_key".into(), "changed".into())])),
Err(AuthVaultLifecycleError::DeviceSealRequired(_))
));
assert_eq!(std::fs::read(&path).unwrap(), before);
}
#[test]
#[cfg(unix)]
#[cfg(not(feature = "custody-simulation"))]
fn retained_policy_rejects_unprovisioned_required_and_preferred() {
let root = tempfile::tempdir().unwrap();
let public = crate::security::ensure_vault_recipient_key(&root.path().join("key")).unwrap();
let (vault, _) = sshenv_vault::Vault::create(&public).unwrap();
for options in [
crate::security::AuthDeviceSealOptions::preferred_prompt_free(),
crate::security::AuthDeviceSealOptions::required_transparent_device_only(),
] {
assert!(
crate::security::validate_retained_device_policy(&vault, "exa", options).is_err()
);
}
assert!(
crate::security::validate_retained_device_policy(
&vault,
"exa",
crate::security::AuthDeviceSealOptions::from_policy(
crate::security::AuthDeviceSealPolicy::Off
)
)
.is_ok()
);
}
#[test]
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(not(feature = "custody-simulation"))]
fn protected_custody_update_preserves_factor_and_rejects_failed_retrieval() {
use crate::operations::{AuthCredentialCustody as _, AuthRequestCustody as _};
struct Device(std::sync::atomic::AtomicBool);
impl crate::operations::AuthDeviceFactorSource for Device {
fn retrieve(
&self,
id: &str,
_: Option<&str>,
_: &BTreeMap<String, String>,
) -> Result<Zeroizing<[u8; 32]>, AuthVaultLifecycleError> {
assert_eq!(id, "isolated-test-device");
if self.0.load(std::sync::atomic::Ordering::SeqCst) {
Ok(Zeroizing::new([17; 32]))
} else {
Err(AuthVaultLifecycleError::InvalidCredential)
}
}
}
let root = tempfile::tempdir().unwrap();
let path = root.path().join("identity");
let public = crate::security::ensure_vault_recipient_key(&path).unwrap();
let identity = Zeroizing::new(
std::fs::read_to_string(crate::security::vault_private_key_path(&path)).unwrap(),
);
let (mut vault, key) = sshenv_vault::Vault::create(&public).unwrap();
vault.migrate_to_v2(&[public]).unwrap();
vault.enable_profile_keys().unwrap();
vault.profiles.profiles.insert(
"exa".into(),
BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), "original".into())]),
);
vault.require_profile_device_seal_with("exa", || Ok((serde_json::from_value(serde_json::json!({
"id": "isolated-test-device", "kind": "device-seal", "recipient_fingerprint": null, "params": {}
})).unwrap(), Zeroizing::new([17; 32])))).unwrap();
let mut bytes = Vec::new();
vault
.save_with_storage(&key, |value, _| {
bytes = value.to_vec();
Ok(())
})
.unwrap();
let directory = root.path().join("custody");
let storage =
crate::custody_storage::CredentialCustodyStorage::create(&directory, &bytes).unwrap();
let before = std::fs::read(directory.join("custody")).unwrap();
let mut profile = resolved(&root.path().join("unused"));
profile
.profile
.settings
.insert("device_seal".into(), "preferred".into());
let device = std::sync::Arc::new(Device(std::sync::atomic::AtomicBool::new(false)));
let retained = crate::operations::RetainedAuthRequestCustody::new(
storage,
profile.clone(),
"exa",
"bcode.web-search",
method(),
vec![identity],
None,
)
.unwrap()
.device_source(device.clone());
let context = bcode_model::ProviderRequestContext {
auth_profile: Some("exa".into()),
..Default::default()
};
let changes = BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), Some("updated".into()))]);
assert!(retained.persist(&profile, changes.clone()).is_err());
assert_eq!(std::fs::read(directory.join("custody")).unwrap(), before);
device.0.store(true, std::sync::atomic::Ordering::SeqCst);
retained.persist(&profile, changes).unwrap();
assert_eq!(
retained
.materialize("bcode.web-search", &context)
.unwrap()
.env,
BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), "updated".into())])
);
device.0.store(false, std::sync::atomic::Ordering::SeqCst);
assert!(retained.materialize("bcode.web-search", &context).is_err());
}
#[test]
#[cfg(unix)]
#[cfg(not(feature = "custody-simulation"))]
fn provisioned_factor_must_be_retrievable_before_policy_changes() {
struct MismatchedDevice;
impl crate::operations::AuthDeviceFactorSource for MismatchedDevice {
fn provision_attempt(
&self,
_: &crate::operations::AuthProvisioningIntent,
) -> Result<crate::operations::AuthProvisionedDeviceFactor, AuthVaultLifecycleError>
{
Ok(crate::operations::AuthProvisionedDeviceFactor {
id: "test-factor".into(),
recipient_fingerprint: None,
parameters: BTreeMap::new(),
key: Zeroizing::new([17; 32]),
})
}
fn retrieve(
&self,
id: &str,
_: Option<&str>,
_: &BTreeMap<String, String>,
) -> Result<Zeroizing<[u8; 32]>, AuthVaultLifecycleError> {
assert_eq!(id, "test-factor");
Ok(Zeroizing::new([18; 32]))
}
}
let root = tempfile::tempdir().unwrap();
let public =
crate::security::ensure_vault_recipient_key(&root.path().join("identity")).unwrap();
let (mut vault, _) = sshenv_vault::Vault::create(&public).unwrap();
vault.migrate_to_v2(&[public]).unwrap();
vault.profiles.profiles.insert(
"exa".into(),
BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), "original".into())]),
);
let before = serde_json::to_value(&vault.profiles).unwrap();
let mut profile = resolved(&root.path().join("unused"));
for policy in ["required", "preferred"] {
profile
.profile
.settings
.insert("device_seal".into(), policy.into());
let result = crate::security::prepare_retained_device_policy(
&mut vault,
&profile,
"exa",
Some(&MismatchedDevice),
Some(&crate::operations::AuthProvisioningIntent {
version: 2,
source: "test".into(),
operation: "mismatch".into(),
profile_binding: crate::operations::provisioning_binding(&profile).unwrap(),
}),
);
if policy == "required" {
assert!(
matches!(result, Err(AuthVaultLifecycleError::WriteFailed(message)) if message == "provisioned device factor mismatch")
);
} else {
assert_eq!(result.unwrap().len(), 1);
}
assert_eq!(serde_json::to_value(&vault.profiles).unwrap(), before);
}
}
#[cfg(unix)]
#[cfg(not(feature = "custody-simulation"))]
struct UnavailableDevice(std::sync::atomic::AtomicUsize);
#[cfg(unix)]
#[cfg(not(feature = "custody-simulation"))]
impl crate::operations::AuthDeviceFactorSource for UnavailableDevice {
fn provisioning_identity(
&self,
_: &ResolvedAuthProfile,
) -> Result<(String, String), AuthVaultLifecycleError> {
Ok((
"isolated-test".into(),
format!(
"attempt-{}",
self.0.load(std::sync::atomic::Ordering::SeqCst)
),
))
}
fn provision_attempt(
&self,
intent: &crate::operations::AuthProvisioningIntent,
) -> Result<crate::operations::AuthProvisionedDeviceFactor, AuthVaultLifecycleError>
{
assert_eq!(intent.source, "isolated-test");
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err(AuthVaultLifecycleError::InvalidCredential)
}
fn reconcile_provisioning(
&self,
intent: &crate::operations::AuthProvisioningIntent,
) -> Result<(), AuthVaultLifecycleError> {
assert_eq!(intent.source, "isolated-test");
assert_eq!(intent.operation, "attempt-0");
Ok(()) }
fn provision(
&self,
_: &ResolvedAuthProfile,
) -> Result<crate::operations::AuthProvisionedDeviceFactor, AuthVaultLifecycleError>
{
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err(AuthVaultLifecycleError::InvalidCredential)
}
fn retrieve(
&self,
_: &str,
_: Option<&str>,
_: &BTreeMap<String, String>,
) -> Result<Zeroizing<[u8; 32]>, AuthVaultLifecycleError> {
panic!("unsealed fallback must not retrieve a device factor")
}
}
#[test]
#[cfg(unix)]
#[cfg(not(feature = "custody-simulation"))]
fn selected_key_source_failure_preserves_ciphertext_and_retry_uses_source() {
use crate::operations::{AuthCredentialCustody as _, AuthRequestCustody as _};
struct Keys(std::sync::atomic::AtomicUsize);
impl crate::operations::AuthCustodyKeySource for Keys {
fn generate(&self) -> Result<Zeroizing<[u8; 32]>, AuthVaultLifecycleError> {
let call = self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if call == 0 {
return Err(AuthVaultLifecycleError::WriteFailed(
"key source unavailable".into(),
));
}
Ok(Zeroizing::new([42; 32]))
}
}
let root = tempfile::tempdir().unwrap();
let identity_path = root.path().join("identity");
let public = crate::security::ensure_vault_recipient_key(&identity_path).unwrap();
let identity = Zeroizing::new(
std::fs::read_to_string(crate::security::vault_private_key_path(&identity_path))
.unwrap(),
);
let (mut vault, key) = sshenv_vault::Vault::create(&public).unwrap();
vault.migrate_to_v2(&[public]).unwrap();
vault.enable_profile_keys().unwrap();
vault.profiles.profiles.insert(
"exa".into(),
BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), "original".into())]),
);
let mut bytes = Vec::new();
vault
.save_with_storage(&key, |value, _| {
bytes = value.to_vec();
Ok(())
})
.unwrap();
let directory = root.path().join("custody");
let storage =
crate::custody_storage::CredentialCustodyStorage::create(&directory, &bytes).unwrap();
let before = std::fs::read(directory.join("custody")).unwrap();
let mut profile = resolved(&root.path().join("unused"));
profile
.profile
.settings
.insert("device_seal".into(), "preferred".into());
let mut required = profile.clone();
required
.profile
.settings
.insert("device_seal".into(), "required".into());
assert!(
AuthVaultLifecycle::new(&required, "exa", "bcode.web-search", &method())
.unwrap()
.read_from_custody(&storage, &[identity.as_str()], None)
.is_err()
);
assert_eq!(std::fs::read(directory.join("custody")).unwrap(), before);
let source = std::sync::Arc::new(Keys(std::sync::atomic::AtomicUsize::new(0)));
let device = std::sync::Arc::new(UnavailableDevice(std::sync::atomic::AtomicUsize::new(0)));
let retained = crate::operations::RetainedAuthRequestCustody::new(
storage,
profile.clone(),
"exa",
"bcode.web-search",
method(),
vec![identity],
None,
)
.unwrap()
.key_source(source.clone());
let changes = BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), Some("updated".into()))]);
assert!(retained.persist(&profile, changes.clone()).is_err());
assert_eq!(std::fs::read(directory.join("custody")).unwrap(), before);
assert_eq!(source.0.load(std::sync::atomic::Ordering::SeqCst), 1);
let retained = retained.device_source(device.clone());
let diagnostics = retained.persist(&profile, changes.clone()).unwrap();
assert_eq!(diagnostics.len(), 1);
assert_eq!(device.0.load(std::sync::atomic::Ordering::SeqCst), 1);
assert!(retained.persist(&profile, changes).is_err());
assert_eq!(device.0.load(std::sync::atomic::Ordering::SeqCst), 1);
retained.reconcile_provisioning().unwrap();
assert!(!directory.join("provisioning-v1").exists());
assert_eq!(source.0.load(std::sync::atomic::Ordering::SeqCst), 2);
let context = bcode_model::ProviderRequestContext {
auth_profile: Some("exa".into()),
..Default::default()
};
assert_eq!(
retained
.materialize("bcode.web-search", &context)
.unwrap()
.env,
BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), "updated".into())])
);
}
#[cfg(unix)]
#[cfg(not(feature = "custody-simulation"))]
fn selected_test_storage(
custody: crate::custody_storage::CredentialCustodyStorage,
bytes: &[u8],
) -> Box<dyn crate::custody_storage::AuthCustodyStorage> {
#[cfg(feature = "simulation")]
{
drop(custody);
crate::custody_storage::simulation::CustodySimulation::create(bytes)
.unwrap()
.1
}
#[cfg(not(feature = "simulation"))]
{
let _ = bytes;
Box::new(custody)
}
}
#[test]
#[cfg(unix)]
#[cfg(not(feature = "custody-simulation"))]
fn retained_custody_reads_canonical_credentials_without_native_fallback() {
use crate::operations::{AuthCredentialCustody as _, AuthRequestCustody as _};
let directory = tempfile::tempdir().unwrap();
let unused = directory.path().join("unused.vault");
let resolved = resolved(&unused);
let method = method();
let lifecycle =
AuthVaultLifecycle::new(&resolved, "exa", "bcode.web-search", &method).unwrap();
let key_path = directory.path().join("identity-source");
let public = crate::security::ensure_vault_recipient_key(&key_path).unwrap();
let identity = Zeroizing::new(
std::fs::read_to_string(crate::security::vault_private_key_path(&key_path)).unwrap(),
);
let (mut vault, key) = sshenv_vault::Vault::create(&public).unwrap();
vault.profiles.profiles.insert(
lifecycle.storage_profile().into(),
BTreeMap::from([
("TEST_PROVIDER_API_KEY".into(), "controlled".into()),
("unrelated".into(), "excluded".into()),
]),
);
vault.migrate_to_v2(&[public]).unwrap();
vault.enable_profile_keys().unwrap();
vault.profiles.profiles.insert(
"unrelated-protected".into(),
BTreeMap::from([("SECRET".into(), "not-authorized".into())]),
);
vault
.require_profile_passphrase("unrelated-protected", "unrelated-passphrase")
.unwrap();
let mut bytes = Vec::new();
vault
.save_with_storage(&key, |value, _| {
bytes = value.to_vec();
Ok(())
})
.unwrap();
let custody = crate::custody_storage::CredentialCustodyStorage::create(
&directory.path().join("custody"),
&bytes,
)
.unwrap();
assert_eq!(
lifecycle
.read_from_custody(&custody, &[identity.as_str()], None)
.unwrap(),
BTreeMap::from([("api_key".into(), "controlled".into())])
);
assert!(lifecycle.read_from_custody(&custody, &[], None).is_err());
assert_eq!(custody.read().unwrap(), bytes);
assert!(!unused.exists());
let update_profile = resolved.clone();
let selected = selected_test_storage(custody, &bytes);
let retained = crate::operations::RetainedAuthRequestCustody::from_storage(
selected,
resolved,
"exa",
"bcode.web-search",
method,
vec![identity],
None,
)
.unwrap();
let mut context = bcode_model::ProviderRequestContext {
auth_profile: Some("exa".into()),
..Default::default()
};
assert_eq!(
retained
.materialize("bcode.web-search", &context)
.unwrap()
.env,
BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), "controlled".into())])
);
retained
.persist(
&update_profile,
BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), Some("refreshed".into()))]),
)
.unwrap();
assert_eq!(
retained
.materialize("bcode.web-search", &context)
.unwrap()
.env,
BTreeMap::from([("TEST_PROVIDER_API_KEY".into(), "refreshed".into())])
);
assert!(
retained
.persist(
&update_profile,
BTreeMap::from([("UNDECLARED".into(), Some("bad".into()))])
)
.is_err()
);
assert!(retained.materialize("foreign.plugin", &context).is_err());
context.auth_pool = Some("unsupported-pool".into());
assert!(retained.materialize("bcode.web-search", &context).is_err());
}
#[test]
fn selected_read_custody_maps_only_owned_credentials_without_native_access() {
let directory = tempfile::tempdir().unwrap();
let vault = directory.path().join("unused.vault");
let resolved = resolved(&vault);
let method = method();
let lifecycle =
AuthVaultLifecycle::new(&resolved, "exa", "bcode.web-search", &method).unwrap();
let credentials = lifecycle
.read_with(|| {
Ok(BTreeMap::from([
("TEST_PROVIDER_API_KEY".to_owned(), "controlled".to_owned()),
("unrelated".to_owned(), "excluded".to_owned()),
]))
})
.unwrap();
assert_eq!(
credentials,
BTreeMap::from([("api_key".to_owned(), "controlled".to_owned())])
);
assert!(matches!(
lifecycle.read_with(|| Err(AuthVaultLifecycleError::InvalidCredential)),
Err(AuthVaultLifecycleError::InvalidCredential)
));
assert!(!vault.exists());
}
#[test]
fn selected_update_custody_receives_only_validated_storage_changes() {
let directory = tempfile::tempdir().unwrap();
let vault = directory.path().join("unused.vault");
let resolved = resolved(&vault);
let method = method();
let lifecycle =
AuthVaultLifecycle::new(&resolved, "exa", "bcode.web-search", &method).unwrap();
let mut calls = 0;
for value in [Some("controlled".to_owned()), None] {
lifecycle
.update_with(
BTreeMap::from([("api_key".to_owned(), value.clone())]),
|changes| {
calls += 1;
assert_eq!(
changes,
BTreeMap::from([("TEST_PROVIDER_API_KEY".to_owned(), value)])
);
Ok(Vec::new())
},
)
.unwrap();
}
assert_eq!(calls, 2);
let result = lifecycle.update_with(
BTreeMap::from([("undeclared".to_owned(), Some("rejected".to_owned()))]),
|_| panic!("invalid credentials must not reach custody"),
);
assert!(matches!(
result,
Err(AuthVaultLifecycleError::UnknownCredential { .. })
));
assert!(!vault.exists());
}
#[cfg(not(feature = "custody-simulation"))]
fn interactive_method() -> AuthMethodContribution {
AuthMethodContribution::Interactive {
method_id: "browser".to_owned(),
display_name: "Browser OAuth".to_owned(),
operation: "flow".to_owned(),
credentials: vec![
AuthCredentialStorage {
credential_id: "access_token".to_owned(),
storage_key: "BCODE_OPENAI_CODEX_ACCESS_TOKEN".to_owned(),
},
AuthCredentialStorage {
credential_id: "refresh_token".to_owned(),
storage_key: "BCODE_OPENAI_CODEX_REFRESH_TOKEN".to_owned(),
},
],
supports_revocation: false,
}
}
#[test]
#[cfg(not(feature = "custody-simulation"))]
fn import_uses_declared_key_and_never_overwrites() {
let temp = tempfile::tempdir().expect("tempdir");
let resolved = resolved(&temp.path().join("vault"));
let method = method();
let lifecycle = AuthVaultLifecycle::new(&resolved, "exa", "bcode.web-search", &method)
.expect("lifecycle");
lifecycle
.import_new(BTreeMap::from([("api_key".to_owned(), "first".to_owned())]))
.expect("import");
assert!(
lifecycle
.import_new(BTreeMap::from([(
"api_key".to_owned(),
"second".to_owned()
)]))
.is_err()
);
assert_eq!(lifecycle.read().expect("read")["api_key"], "first");
}
#[test]
#[cfg(not(feature = "custody-simulation"))]
fn interactive_credentials_use_declared_storage_and_reject_undeclared_values() {
let temp = tempfile::tempdir().expect("tempdir");
let vault = temp.path().join("vault");
let mut resolved = resolved(&vault);
resolved.profile.scheme = Some("browser".to_owned());
let method = interactive_method();
let lifecycle = AuthVaultLifecycle::new(&resolved, "exa", "bcode.web-search", &method)
.expect("interactive lifecycle");
lifecycle
.upsert(BTreeMap::from([
("access_token".to_owned(), "access".to_owned()),
("refresh_token".to_owned(), "refresh".to_owned()),
]))
.expect("interactive credentials stored");
assert_eq!(
lifecycle.read().expect("interactive credentials read"),
BTreeMap::from([
("access_token".to_owned(), "access".to_owned()),
("refresh_token".to_owned(), "refresh".to_owned()),
])
);
lifecycle
.replace_owned(BTreeMap::from([(
"access_token".to_owned(),
"replacement".to_owned(),
)]))
.expect("interactive credentials replaced");
assert_eq!(
lifecycle.read().expect("replacement read"),
BTreeMap::from([("access_token".to_owned(), "replacement".to_owned())])
);
assert!(matches!(
lifecycle.upsert(BTreeMap::from([(
"id_token".to_owned(),
"undeclared".to_owned(),
)])),
Err(AuthVaultLifecycleError::UnknownCredential { .. })
));
}
#[test]
fn ownership_and_scheme_are_checked_before_vault_access() {
let temp = tempfile::tempdir().expect("tempdir");
let resolved = resolved(&temp.path().join("vault"));
assert!(matches!(
AuthVaultLifecycle::new(&resolved, "exa", "bcode.other", &method()),
Err(AuthVaultLifecycleError::Ownership(
AuthProfileResolutionError::OwnerMismatch { .. }
))
));
let mut wrong_scheme = resolved;
wrong_scheme.profile.scheme = Some("oauth".to_owned());
assert!(matches!(
AuthVaultLifecycle::new(&wrong_scheme, "exa", "bcode.web-search", &method()),
Err(AuthVaultLifecycleError::SchemeMismatch { .. })
));
}
#[test]
#[cfg(not(feature = "custody-simulation"))]
fn upsert_read_targeted_delete_and_status_preserve_unrelated_values() {
let temp = tempfile::tempdir().expect("tempdir");
let vault = temp.path().join("vault");
let resolved = resolved(&vault);
let method = method();
let lifecycle = AuthVaultLifecycle::new(&resolved, "exa", "bcode.web-search", &method)
.expect("lifecycle");
lifecycle
.upsert(BTreeMap::from([(
"api_key".to_owned(),
"secret".to_owned(),
)]))
.expect("upsert");
assert_eq!(
lifecycle.read().expect("read").get("api_key"),
Some(&"secret".to_owned())
);
assert!(
lifecycle
.inspect()
.expect("inspect")
.present_credentials
.contains("api_key")
);
let store = auth_store(&vault);
let mut raw = store
.get_profile("exa")
.expect("read raw")
.expect("profile");
raw.insert("UNRELATED".to_owned(), Zeroizing::new("keep".to_owned()));
store.replace_profile("exa", raw).expect("add unrelated");
lifecycle.delete().expect("targeted delete");
let raw = store
.get_profile("exa")
.expect("read raw")
.expect("profile");
assert!(!raw.contains_key("TEST_PROVIDER_API_KEY"));
assert_eq!(
raw.get("UNRELATED").map(|value| value.as_str()),
Some("keep")
);
}
#[test]
#[cfg(not(feature = "custody-simulation"))]
fn required_local_file_device_seal_is_applied_and_reported() {
let temp = tempfile::tempdir().expect("tempdir");
let vault = temp.path().join("vault");
let mut resolved = resolved(&vault);
resolved
.profile
.settings
.insert("device_seal".to_owned(), "required".to_owned());
resolved
.profile
.settings
.insert("device_seal_backend".to_owned(), "local-file".to_owned());
resolved
.profile
.settings
.insert("device_seal_strict".to_owned(), "true".to_owned());
let method = method();
let lifecycle = AuthVaultLifecycle::new(&resolved, "exa", "bcode.web-search", &method)
.expect("lifecycle");
lifecycle
.upsert(BTreeMap::from([(
"api_key".to_owned(),
"secret".to_owned(),
)]))
.expect("sealed upsert");
let status = crate::security::inspect_auth_vault_security(
&vault,
"exa",
crate::security::AuthDeviceSealPolicy::Required,
);
assert!(status.profile_exists);
assert!(status.profile_device_sealed);
assert_eq!(status.device_seal_backend.as_deref(), Some("local-file"));
}
#[test]
fn damaged_vault_fails_without_resetting_or_mutating_it() {
let temp = tempfile::tempdir().expect("tempdir");
let vault = temp.path().join("vault");
std::fs::write(&vault, b"damaged-vault").expect("write damaged vault");
let before = std::fs::read(&vault).expect("read before");
let resolved = resolved(&vault);
let method = method();
let lifecycle = AuthVaultLifecycle::new(&resolved, "exa", "bcode.web-search", &method)
.expect("lifecycle");
assert!(matches!(
lifecycle.upsert(BTreeMap::from([(
"api_key".to_owned(),
"secret".to_owned()
)])),
Err(AuthVaultLifecycleError::VaultUnavailable(_))
));
assert_eq!(std::fs::read(&vault).expect("read after"), before);
}
#[test]
fn undeclared_credentials_fail_before_mutation() {
let temp = tempfile::tempdir().expect("tempdir");
let vault = temp.path().join("vault");
let resolved = resolved(&vault);
let method = method();
let lifecycle = AuthVaultLifecycle::new(&resolved, "exa", "bcode.web-search", &method)
.expect("lifecycle");
assert!(matches!(
lifecycle.upsert(BTreeMap::from([(
"refresh_token".to_owned(),
"secret".to_owned()
)])),
Err(AuthVaultLifecycleError::UnknownCredential { .. })
));
assert!(!vault.exists());
}
}