mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 10:57:03 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
ProviderCatalogKeyCredentialsCasUpdate, StoredProviderCatalogKey,
|
||||
};
|
||||
|
||||
use super::AppState;
|
||||
use crate::handlers::shared::{
|
||||
open_provider_catalog_credential, seal_provider_catalog_credential,
|
||||
ProviderCatalogCredentialField, ProviderCatalogCredentialProjection,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
|
||||
impl AppState {
|
||||
pub(super) fn protect_provider_catalog_key_credentials(
|
||||
&self,
|
||||
key: &StoredProviderCatalogKey,
|
||||
) -> Result<StoredProviderCatalogKey, GatewayError> {
|
||||
let mut protected = key.clone();
|
||||
protected.encrypted_api_key = self
|
||||
.project_provider_catalog_key_credential(
|
||||
key,
|
||||
ProviderCatalogCredentialField::ApiKey,
|
||||
key.encrypted_api_key.as_deref(),
|
||||
)?
|
||||
.map(|projection| projection.protected);
|
||||
protected.encrypted_auth_config = self
|
||||
.project_provider_catalog_key_credential(
|
||||
key,
|
||||
ProviderCatalogCredentialField::AuthConfig,
|
||||
key.encrypted_auth_config.as_deref(),
|
||||
)?
|
||||
.map(|projection| projection.protected);
|
||||
Ok(protected)
|
||||
}
|
||||
|
||||
pub(super) async fn open_provider_catalog_key_credentials_once(
|
||||
&self,
|
||||
key: &mut StoredProviderCatalogKey,
|
||||
) -> Result<bool, GatewayError> {
|
||||
let observed_api_key = key.encrypted_api_key.clone();
|
||||
let observed_auth_config = key.encrypted_auth_config.clone();
|
||||
let api_key = self.project_provider_catalog_key_credential(
|
||||
key,
|
||||
ProviderCatalogCredentialField::ApiKey,
|
||||
observed_api_key.as_deref(),
|
||||
)?;
|
||||
let auth_config = self.project_provider_catalog_key_credential(
|
||||
key,
|
||||
ProviderCatalogCredentialField::AuthConfig,
|
||||
observed_auth_config.as_deref(),
|
||||
)?;
|
||||
let migration_required = api_key
|
||||
.as_ref()
|
||||
.is_some_and(|projection| projection.migration_required)
|
||||
|| auth_config
|
||||
.as_ref()
|
||||
.is_some_and(|projection| projection.migration_required);
|
||||
if !migration_required {
|
||||
return Ok(true);
|
||||
}
|
||||
if !self.has_provider_catalog_data_writer() {
|
||||
return Err(provider_catalog_credential_error(
|
||||
"stored provider catalog credentials require migration but the catalog writer is unavailable",
|
||||
));
|
||||
}
|
||||
|
||||
let protected_api_key = api_key.map(|projection| projection.protected);
|
||||
let protected_auth_config = auth_config.map(|projection| projection.protected);
|
||||
let updated = self
|
||||
.data
|
||||
.compare_and_swap_provider_catalog_key_credentials(
|
||||
&ProviderCatalogKeyCredentialsCasUpdate {
|
||||
key_id: key.id.clone(),
|
||||
expected_provider_id: key.provider_id.clone(),
|
||||
expected_encrypted_api_key: observed_api_key,
|
||||
expected_encrypted_auth_config: observed_auth_config,
|
||||
encrypted_api_key: protected_api_key.clone(),
|
||||
encrypted_auth_config: protected_auth_config.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if updated {
|
||||
key.encrypted_api_key = protected_api_key;
|
||||
key.encrypted_auth_config = protected_auth_config;
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub(crate) fn decrypt_provider_catalog_key_api_key(
|
||||
&self,
|
||||
key: &StoredProviderCatalogKey,
|
||||
) -> Result<Option<String>, GatewayError> {
|
||||
self.project_provider_catalog_key_credential(
|
||||
key,
|
||||
ProviderCatalogCredentialField::ApiKey,
|
||||
key.encrypted_api_key.as_deref(),
|
||||
)
|
||||
.map(|projection| projection.map(|projection| projection.plaintext))
|
||||
}
|
||||
|
||||
pub(crate) fn decrypt_provider_catalog_key_auth_config(
|
||||
&self,
|
||||
key: &StoredProviderCatalogKey,
|
||||
) -> Result<Option<String>, GatewayError> {
|
||||
self.project_provider_catalog_key_credential(
|
||||
key,
|
||||
ProviderCatalogCredentialField::AuthConfig,
|
||||
key.encrypted_auth_config.as_deref(),
|
||||
)
|
||||
.map(|projection| projection.map(|projection| projection.plaintext))
|
||||
}
|
||||
|
||||
pub(crate) fn seal_provider_catalog_key_api_key(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
plaintext: &str,
|
||||
) -> Result<String, GatewayError> {
|
||||
seal_provider_catalog_credential(
|
||||
self,
|
||||
provider_id,
|
||||
key_id,
|
||||
ProviderCatalogCredentialField::ApiKey,
|
||||
plaintext,
|
||||
)
|
||||
.map_err(provider_catalog_credential_error)
|
||||
}
|
||||
|
||||
pub(crate) fn seal_provider_catalog_key_auth_config(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
plaintext: &str,
|
||||
) -> Result<String, GatewayError> {
|
||||
seal_provider_catalog_credential(
|
||||
self,
|
||||
provider_id,
|
||||
key_id,
|
||||
ProviderCatalogCredentialField::AuthConfig,
|
||||
plaintext,
|
||||
)
|
||||
.map_err(provider_catalog_credential_error)
|
||||
}
|
||||
|
||||
pub(super) fn validate_protected_provider_catalog_key_api_key(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
stored: &str,
|
||||
) -> Result<(), GatewayError> {
|
||||
self.validate_protected_provider_catalog_key_credential(
|
||||
provider_id,
|
||||
key_id,
|
||||
ProviderCatalogCredentialField::ApiKey,
|
||||
stored,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn validate_protected_provider_catalog_key_auth_config(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
stored: &str,
|
||||
) -> Result<(), GatewayError> {
|
||||
self.validate_protected_provider_catalog_key_credential(
|
||||
provider_id,
|
||||
key_id,
|
||||
ProviderCatalogCredentialField::AuthConfig,
|
||||
stored,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_protected_provider_catalog_key_credential(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
field: ProviderCatalogCredentialField,
|
||||
stored: &str,
|
||||
) -> Result<(), GatewayError> {
|
||||
let projection = open_provider_catalog_credential(self, provider_id, key_id, field, stored)
|
||||
.map_err(provider_catalog_credential_error)?;
|
||||
if projection.migration_required || projection.protected != stored {
|
||||
return Err(provider_catalog_credential_error(
|
||||
"provider catalog credential write requires a bound v2 ciphertext",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn project_provider_catalog_key_credential(
|
||||
&self,
|
||||
key: &StoredProviderCatalogKey,
|
||||
field: ProviderCatalogCredentialField,
|
||||
stored: Option<&str>,
|
||||
) -> Result<Option<ProviderCatalogCredentialProjection>, GatewayError> {
|
||||
let Some(stored) = stored else {
|
||||
return Ok(None);
|
||||
};
|
||||
if stored.is_empty() {
|
||||
return Err(provider_catalog_credential_error(
|
||||
"stored provider catalog credential is empty",
|
||||
));
|
||||
}
|
||||
open_provider_catalog_credential(self, &key.provider_id, &key.id, field, stored)
|
||||
.map(Some)
|
||||
.map_err(provider_catalog_credential_error)
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_catalog_credential_error(message: &'static str) -> GatewayError {
|
||||
GatewayError::Internal(message.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
ProviderCatalogKeyCredentialsCasUpdate, ProviderCatalogReadRepository,
|
||||
ProviderCatalogWriteRepository, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
|
||||
use crate::{data::GatewayDataState, AppState};
|
||||
|
||||
fn sample_provider(id: &str) -> StoredProviderCatalogProvider {
|
||||
StoredProviderCatalogProvider::new(
|
||||
id.to_string(),
|
||||
format!("Provider {id}"),
|
||||
Some("https://example.test".to_string()),
|
||||
"openai".to_string(),
|
||||
)
|
||||
.expect("provider should build")
|
||||
}
|
||||
|
||||
fn sample_key(
|
||||
id: &str,
|
||||
provider_id: &str,
|
||||
encrypted_api_key: Option<String>,
|
||||
encrypted_auth_config: Option<String>,
|
||||
) -> StoredProviderCatalogKey {
|
||||
StoredProviderCatalogKey::new(
|
||||
id.to_string(),
|
||||
provider_id.to_string(),
|
||||
format!("Key {id}"),
|
||||
"oauth".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("key should build")
|
||||
.with_transport_fields(
|
||||
None,
|
||||
encrypted_api_key,
|
||||
encrypted_auth_config,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("key transport should build")
|
||||
}
|
||||
|
||||
fn state_with_repository(repository: Arc<InMemoryProviderCatalogReadRepository>) -> AppState {
|
||||
AppState::new()
|
||||
.expect("test state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_state_migrates_both_legacy_fields_with_one_exact_cas() {
|
||||
let legacy_api =
|
||||
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "legacy-api-key")
|
||||
.expect("legacy API key should encrypt");
|
||||
let legacy_auth = encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"refresh_token":"legacy-refresh"}"#,
|
||||
)
|
||||
.expect("legacy auth config should encrypt");
|
||||
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-1")],
|
||||
Vec::new(),
|
||||
vec![sample_key(
|
||||
"key-1",
|
||||
"provider-1",
|
||||
Some(legacy_api),
|
||||
Some(legacy_auth),
|
||||
)],
|
||||
));
|
||||
let state = state_with_repository(Arc::clone(&repository));
|
||||
|
||||
let opened = state
|
||||
.list_provider_catalog_keys_by_ids(&["key-1".to_string()])
|
||||
.await
|
||||
.expect("legacy key should migrate")
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("key should exist");
|
||||
assert_eq!(
|
||||
state
|
||||
.decrypt_provider_catalog_key_api_key(&opened)
|
||||
.expect("API key should open")
|
||||
.as_deref(),
|
||||
Some("legacy-api-key")
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.decrypt_provider_catalog_key_auth_config(&opened)
|
||||
.expect("auth config should open")
|
||||
.as_deref(),
|
||||
Some(r#"{"refresh_token":"legacy-refresh"}"#)
|
||||
);
|
||||
|
||||
let stored = repository
|
||||
.list_keys_by_ids(&["key-1".to_string()])
|
||||
.await
|
||||
.expect("stored key should read")
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("stored key should exist");
|
||||
assert!(stored
|
||||
.encrypted_api_key
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.starts_with("aether-provider-catalog-credential-v2:")));
|
||||
assert!(stored
|
||||
.encrypted_auth_config
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.starts_with("aether-provider-catalog-credential-v2:")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_state_rejects_ciphertext_copied_to_another_key() {
|
||||
let empty_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-1")],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
));
|
||||
let bootstrap = state_with_repository(Arc::clone(&empty_repository));
|
||||
let copied = bootstrap
|
||||
.seal_provider_catalog_key_api_key("provider-1", "key-1", "secret")
|
||||
.expect("credential should seal");
|
||||
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-1")],
|
||||
Vec::new(),
|
||||
vec![sample_key("key-2", "provider-1", Some(copied), None)],
|
||||
));
|
||||
let state = state_with_repository(repository);
|
||||
|
||||
assert!(state
|
||||
.list_provider_catalog_keys_by_ids(&["key-2".to_string()])
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credential_cas_fences_provider_and_both_ciphertexts() {
|
||||
let repository = InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-1"), sample_provider("provider-2")],
|
||||
Vec::new(),
|
||||
vec![sample_key(
|
||||
"key-1",
|
||||
"provider-2",
|
||||
Some("api-before".to_string()),
|
||||
Some("auth-before".to_string()),
|
||||
)],
|
||||
);
|
||||
let update = ProviderCatalogKeyCredentialsCasUpdate {
|
||||
key_id: "key-1".to_string(),
|
||||
expected_provider_id: "provider-1".to_string(),
|
||||
expected_encrypted_api_key: Some("api-before".to_string()),
|
||||
expected_encrypted_auth_config: Some("auth-before".to_string()),
|
||||
encrypted_api_key: Some("api-after".to_string()),
|
||||
encrypted_auth_config: Some("auth-after".to_string()),
|
||||
};
|
||||
|
||||
assert!(!repository
|
||||
.compare_and_swap_key_credentials(&update)
|
||||
.await
|
||||
.expect("provider-fenced CAS should execute"));
|
||||
let stored = repository
|
||||
.list_keys_by_ids(&["key-1".to_string()])
|
||||
.await
|
||||
.expect("key should read")
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("key should exist");
|
||||
assert_eq!(stored.encrypted_api_key.as_deref(), Some("api-before"));
|
||||
assert_eq!(stored.encrypted_auth_config.as_deref(), Some("auth-before"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user