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:
elky
2026-09-04 03:45:52 +08:00
parent ddcbeb3ae9
commit 579f2c7cc1
1019 changed files with 190437 additions and 26080 deletions
@@ -50,3 +50,39 @@ pub(crate) fn attach_admin_audit_response(
attach_admin_audit_event(&mut response, event_name, action, target_type, target_id);
response
}
pub(crate) fn mark_sensitive_admin_response_no_store(
mut response: Response<Body>,
) -> Response<Body> {
response.headers_mut().insert(
http::header::CACHE_CONTROL,
http::HeaderValue::from_static("no-store"),
);
response.headers_mut().insert(
http::header::PRAGMA,
http::HeaderValue::from_static("no-cache"),
);
response
}
#[cfg(test)]
mod tests {
use super::mark_sensitive_admin_response_no_store;
use axum::{http, response::IntoResponse, Json};
#[test]
fn plaintext_admin_secret_responses_are_never_cacheable() {
let response = mark_sensitive_admin_response_no_store(
Json(serde_json::json!({ "key": "secret" })).into_response(),
);
assert_eq!(
response.headers().get(http::header::CACHE_CONTROL),
Some(&http::HeaderValue::from_static("no-store"))
);
assert_eq!(
response.headers().get(http::header::PRAGMA),
Some(&http::HeaderValue::from_static("no-cache"))
);
}
}
@@ -68,18 +68,44 @@ pub(crate) fn generate_gateway_api_key_plaintext() -> String {
generate_gateway_api_key_plaintext_with_prefix(&configured_api_key_prefix())
}
pub(crate) fn masked_secret_display(
value: &str,
preferred_prefix_chars: usize,
preferred_suffix_chars: usize,
separator: &str,
) -> String {
let char_count = value.chars().count();
if char_count <= 4 {
return "***".to_string();
}
// Never reveal more than half of a short secret. For normal generated keys,
// the preferred prefix/suffix remains stable while a meaningful middle
// section is always hidden.
let visible_budget =
(char_count / 2).min(preferred_prefix_chars.saturating_add(preferred_suffix_chars));
if visible_budget == 0 {
return "***".to_string();
}
let suffix_chars = preferred_suffix_chars.min(visible_budget / 2);
let prefix_chars = preferred_prefix_chars.min(visible_budget.saturating_sub(suffix_chars));
if prefix_chars == 0 && suffix_chars == 0 {
return "***".to_string();
}
let prefix = value.chars().take(prefix_chars).collect::<String>();
let suffix = value
.chars()
.skip(char_count.saturating_sub(suffix_chars))
.collect::<String>();
format!("{prefix}{separator}{suffix}")
}
pub(crate) fn masked_gateway_api_key_display(full_key: Option<&str>) -> String {
let Some(full_key) = full_key.map(str::trim).filter(|value| !value.is_empty()) else {
return api_key_placeholder_display();
};
let prefix_len = full_key.len().min(10);
let prefix = &full_key[..prefix_len];
let suffix = if full_key.len() >= 4 {
&full_key[full_key.len().saturating_sub(4)..]
} else {
""
};
format!("{prefix}...{suffix}")
masked_secret_display(full_key, 10, 4, "...")
}
pub(crate) fn normalize_optional_api_key_concurrent_limit(
@@ -96,7 +122,7 @@ mod tests {
use super::{
api_key_placeholder_display_with_prefix, configured_api_key_prefix_from_lookup,
generate_gateway_api_key_plaintext_with_prefix, generate_gateway_secret_plaintext,
masked_gateway_api_key_display,
masked_gateway_api_key_display, masked_secret_display,
};
#[test]
@@ -149,7 +175,17 @@ mod tests {
fn masks_plaintext_api_key_without_changing_prefix() {
assert_eq!(
masked_gateway_api_key_display(Some("ak-1234567890abcdef")),
"ak-1234567...cdef".to_string()
"ak-12...cdef".to_string()
);
}
#[test]
fn masking_never_discloses_an_entire_short_or_unicode_secret() {
for secret in ["abc", "sk-short", "测试密钥一二三"] {
let masked = masked_secret_display(secret, 10, 4, "...");
assert_ne!(masked, secret);
assert!(!masked.contains(secret));
assert!(masked.contains('*') || masked.contains("..."));
}
}
}
@@ -0,0 +1,359 @@
use aether_crypto::looks_like_python_fernet_ciphertext;
use sha2::{Digest, Sha256};
use crate::{AppState, GatewayError};
use super::{
decrypt_catalog_secret_with_fallbacks, open_runtime_secret_payload, seal_runtime_secret_payload,
};
const AUTH_API_KEY_SECRET_MIGRATION_RETRIES: usize = 8;
const AUTH_API_KEY_SECRET_ENVELOPE_FAMILY: &str = "aether-auth-api-key-secret-";
const AUTH_API_KEY_SECRET_ENVELOPE_V2: &str = "aether-auth-api-key-secret-v2:";
const AUTH_API_KEY_SECRET_PURPOSE_V2: &str = "auth-api-key-secret-bound-v2";
const AETHER_ENVELOPE_FAMILY: &str = "aether-";
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct AuthApiKeySecretProjection {
pub(crate) plaintext: String,
pub(crate) protected: String,
pub(crate) migration_required: bool,
}
fn auth_api_key_secret_purpose(
user_id: &str,
api_key_id: &str,
key_hash: &str,
is_standalone: bool,
) -> Result<String, &'static str> {
if user_id.is_empty() {
return Err("API-key secret owner is empty");
}
if api_key_id.is_empty() {
return Err("API-key secret record ID is empty");
}
if key_hash.is_empty() {
return Err("API-key secret hash is empty");
}
let scope = if is_standalone { "standalone" } else { "user" };
Ok(format!(
"{AUTH_API_KEY_SECRET_PURPOSE_V2}\0scope={scope}\0owner-bytes={}\0{user_id}\0api-key-id-bytes={}\0{api_key_id}\0hash-bytes={}\0{key_hash}\0field=key",
user_id.len(),
api_key_id.len(),
key_hash.len(),
))
}
pub(crate) fn seal_auth_api_key_secret(
state: &AppState,
user_id: &str,
api_key_id: &str,
key_hash: &str,
is_standalone: bool,
plaintext: &str,
) -> Result<String, &'static str> {
if plaintext.contains('\0') {
return Err("API-key plaintext contains reserved secret framing");
}
if sha256_hex(plaintext) != key_hash {
return Err("API-key plaintext does not match its hash");
}
let purpose = auth_api_key_secret_purpose(user_id, api_key_id, key_hash, is_standalone)?;
let sealed = seal_runtime_secret_payload(state, &purpose, plaintext)
.ok_or("API-key encryption key is not configured")?;
Ok(format!("{AUTH_API_KEY_SECRET_ENVELOPE_V2}{sealed}"))
}
pub(crate) fn open_auth_api_key_secret(
state: &AppState,
record: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,
) -> Result<AuthApiKeySecretProjection, &'static str> {
let observed_raw = record
.key_encrypted
.as_deref()
.ok_or("API-key ciphertext is not stored")?;
let stored = observed_raw.trim();
if stored.is_empty() {
return Err("API-key ciphertext is empty");
}
let purpose = auth_api_key_secret_purpose(
&record.user_id,
&record.api_key_id,
&record.key_hash,
record.is_standalone,
)?;
let (plaintext, protected, migration_required) =
if let Some(sealed) = stored.strip_prefix(AUTH_API_KEY_SECRET_ENVELOPE_V2) {
let plaintext = open_runtime_secret_payload(state, &purpose, sealed)
.ok_or("API-key secret authentication failed")?;
(
plaintext,
stored.to_string(),
observed_raw.as_bytes() != stored.as_bytes(),
)
} else {
if stored.starts_with(AUTH_API_KEY_SECRET_ENVELOPE_FAMILY) {
return Err("unsupported API-key secret envelope");
}
// Every purpose-bound secret family in Aether uses an `aether-` envelope. Never feed
// a foreign or future envelope into the legacy Fernet path.
if stored.starts_with(AETHER_ENVELOPE_FAMILY) {
return Err("secret envelope has the wrong purpose");
}
if !looks_like_python_fernet_ciphertext(stored) {
return Err("API-key secret is not an authenticated ciphertext");
}
let plaintext = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), stored)
.ok_or("legacy API-key secret authentication failed")?;
let protected = seal_auth_api_key_secret(
state,
&record.user_id,
&record.api_key_id,
&record.key_hash,
record.is_standalone,
&plaintext,
)?;
(plaintext, protected, true)
};
if plaintext.contains('\0') {
return Err("API-key plaintext contains reserved secret framing");
}
if sha256_hex(&plaintext) != record.key_hash {
return Err("API-key plaintext integrity check failed");
}
Ok(AuthApiKeySecretProjection {
plaintext,
protected,
migration_required,
})
}
pub(crate) async fn decrypt_or_migrate_auth_api_key_secret(
state: &AppState,
initial: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,
) -> Result<String, GatewayError> {
let identity = (
initial.user_id.clone(),
initial.api_key_id.clone(),
initial.key_hash.clone(),
initial.is_standalone,
);
let mut current = initial.clone();
for _ in 0..AUTH_API_KEY_SECRET_MIGRATION_RETRIES {
if current.user_id != identity.0
|| current.api_key_id != identity.1
|| current.key_hash != identity.2
|| current.is_standalone != identity.3
{
return Err(api_key_secret_error(
"stored API-key secret identity changed during migration",
));
}
let projection = open_auth_api_key_secret(state, &current)
.map_err(|message| api_key_secret_error(message))?;
if !projection.migration_required {
return Ok(projection.plaintext);
}
let observed = current.key_encrypted.clone().ok_or_else(|| {
api_key_secret_error("stored API-key ciphertext disappeared during migration")
})?;
let mutation = aether_data::repository::auth::CompareAndSwapAuthApiKeyCiphertext {
user_id: identity.0.clone(),
api_key_id: identity.1.clone(),
key_hash: identity.2.clone(),
is_standalone: identity.3,
expected_key_encrypted: observed,
key_encrypted: projection.protected,
};
if state.compare_and_swap_api_key_ciphertext(&mutation).await? {
return Ok(projection.plaintext);
}
let mut matches = state
.list_auth_api_key_export_records_by_ids(std::slice::from_ref(&identity.1))
.await?
.into_iter()
.filter(|record| record.api_key_id == identity.1);
let Some(next) = matches.next() else {
return Err(api_key_secret_error(
"stored API-key secret is unavailable during migration",
));
};
if matches.next().is_some() {
return Err(api_key_secret_error(
"stored API-key identity is not unique during migration",
));
}
current = next;
}
Err(api_key_secret_error(
"stored API-key secret migration did not stabilize",
))
}
fn sha256_hex(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
format!("{:x}", hasher.finalize())
}
fn api_key_secret_error(message: &str) -> GatewayError {
GatewayError::Internal(message.to_string())
}
#[cfg(test)]
mod tests {
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use super::{open_auth_api_key_secret, seal_auth_api_key_secret, sha256_hex};
use crate::handlers::shared::encrypt_catalog_secret_with_fallbacks;
use crate::{data::GatewayDataState, AppState};
fn state_with_encryption_key() -> AppState {
AppState::new()
.expect("test state should build")
.with_data_state_for_tests(
GatewayDataState::disabled()
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
)
}
fn record(
plaintext: &str,
user_id: &str,
api_key_id: &str,
is_standalone: bool,
key_encrypted: Option<String>,
) -> aether_data::repository::auth::StoredAuthApiKeyExportRecord {
aether_data::repository::auth::StoredAuthApiKeyExportRecord {
user_id: user_id.to_string(),
api_key_id: api_key_id.to_string(),
key_hash: sha256_hex(plaintext),
key_encrypted,
name: None,
allowed_providers: None,
allowed_api_formats: None,
allowed_models: None,
ip_rules: None,
rate_limit: None,
concurrent_limit: None,
force_capabilities: None,
feature_settings: None,
is_active: true,
expires_at_unix_secs: None,
auto_delete_on_expiry: false,
total_requests: 0,
total_tokens: 0,
total_cost_usd: 0.0,
last_used_at_unix_secs: None,
created_at_unix_secs: None,
updated_at_unix_secs: None,
is_standalone,
}
}
#[test]
fn v2_ciphertext_is_bound_to_owner_record_scope_and_hash() {
let state = state_with_encryption_key();
let plaintext = "sk-record-bound-secret";
let key_hash = sha256_hex(plaintext);
let ciphertext =
seal_auth_api_key_secret(&state, "owner-a", "key-a", &key_hash, false, plaintext)
.expect("API-key secret should seal");
let source = record(
plaintext,
"owner-a",
"key-a",
false,
Some(ciphertext.clone()),
);
assert_eq!(
open_auth_api_key_secret(&state, &source)
.expect("source record should open")
.plaintext,
plaintext
);
for mut copied in [
record(
plaintext,
"owner-b",
"key-a",
false,
Some(ciphertext.clone()),
),
record(
plaintext,
"owner-a",
"key-b",
false,
Some(ciphertext.clone()),
),
record(
plaintext,
"owner-a",
"key-a",
true,
Some(ciphertext.clone()),
),
] {
assert!(open_auth_api_key_secret(&state, &copied).is_err());
copied.key_hash = sha256_hex("different-secret");
assert!(open_auth_api_key_secret(&state, &copied).is_err());
}
}
#[test]
fn legacy_ciphertext_requires_matching_record_hash_before_migration() {
let state = state_with_encryption_key();
let plaintext = "sk-legacy-record-secret";
let ciphertext = encrypt_catalog_secret_with_fallbacks(&state, plaintext)
.expect("legacy secret should encrypt");
let source = record(
plaintext,
"owner-a",
"key-a",
false,
Some(ciphertext.clone()),
);
let projection =
open_auth_api_key_secret(&state, &source).expect("legacy secret should open");
assert_eq!(projection.plaintext, plaintext);
assert!(projection.migration_required);
assert!(projection
.protected
.starts_with("aether-auth-api-key-secret-v2:"));
let copied = record(
"another-record-secret",
"owner-b",
"key-b",
false,
Some(ciphertext),
);
assert!(open_auth_api_key_secret(&state, &copied).is_err());
}
#[test]
fn foreign_and_unknown_envelopes_never_fall_back_to_legacy_decryption() {
let state = state_with_encryption_key();
for ciphertext in [
"aether-auth-api-key-secret-v3:unknown",
"aether-system-config-secret-v2:unknown",
"plaintext-secret",
] {
let record = record(
"plaintext-secret",
"owner-a",
"key-a",
false,
Some(ciphertext.to_string()),
);
assert!(open_auth_api_key_secret(&state, &record).is_err());
}
}
}
@@ -1,4 +1,5 @@
use crate::handlers::shared::{json_string_list, unix_secs_to_rfc3339};
use crate::handlers::shared::{json_string_list, masked_secret_display, unix_secs_to_rfc3339};
use crate::model_fetch::safe_model_fetch_error;
use crate::provider_key_auth::{
provider_key_auth_config_is_agent_identity, provider_key_auth_config_uses_header_authorization,
provider_key_auth_semantics, provider_key_can_export_oauth, provider_key_can_refresh_oauth,
@@ -6,10 +7,17 @@ use crate::provider_key_auth::{
};
use crate::AppState;
use aether_admin::provider::quota as admin_provider_quota_pure;
use aether_admin::provider::redaction::{
admin_provider_oauth_invalid_reason_safe_text, admin_provider_status_snapshot_safe_json,
admin_provider_upstream_metadata_safe_json, admin_secret_safe_json, admin_secret_safe_proxy,
};
use aether_admin::provider::status as admin_provider_status_pure;
#[cfg(test)]
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use aether_crypto::{decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext};
use aether_crypto::{
decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext,
looks_like_python_fernet_ciphertext,
};
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use aether_provider_pool::{
grok_pool_tier_from_quota_bucket, grok_supported_quota_windows_for_tier,
@@ -24,6 +32,12 @@ const OAUTH_EXPIRED_PREFIX: &str = "[OAUTH_EXPIRED] ";
const OAUTH_REFRESH_FAILED_PREFIX: &str = "[REFRESH_FAILED] ";
const OAUTH_REQUEST_FAILED_PREFIX: &str = "[REQUEST_FAILED] ";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum StoredCatalogSecret {
Encrypted(String),
LegacyPlaintext(String),
}
pub(crate) fn provider_catalog_key_supports_format(
key: &StoredProviderCatalogKey,
provider_type: &str,
@@ -73,8 +87,29 @@ pub(crate) fn decrypt_catalog_secret_with_fallbacks(
None
}
pub(crate) fn decrypt_catalog_secret_or_legacy_plaintext(
encryption_key: Option<&str>,
stored_value: &str,
) -> Result<StoredCatalogSecret, ()> {
if let Some(plaintext) = decrypt_catalog_secret_with_fallbacks(encryption_key, stored_value) {
return Ok(StoredCatalogSecret::Encrypted(plaintext));
}
if looks_like_python_fernet_ciphertext(stored_value) {
return Err(());
}
Ok(StoredCatalogSecret::LegacyPlaintext(
stored_value.to_string(),
))
}
pub(crate) fn effective_catalog_encryption_key(state: &AppState) -> Option<Cow<'_, str>> {
let encryption_key = state.encryption_key().map(str::trim).unwrap_or("");
effective_catalog_encryption_key_from_config(state.encryption_key())
}
pub(crate) fn effective_catalog_encryption_key_from_config(
encryption_key: Option<&str>,
) -> Option<Cow<'_, str>> {
let encryption_key = encryption_key.map(str::trim).unwrap_or("");
if !encryption_key.is_empty() {
return Some(Cow::Borrowed(encryption_key));
}
@@ -103,7 +138,14 @@ pub(crate) fn encrypt_catalog_secret_with_fallbacks(
state: &AppState,
plaintext: &str,
) -> Option<String> {
let encryption_key = effective_catalog_encryption_key(state)?;
encrypt_catalog_secret_with_configured_key_fallbacks(state.encryption_key(), plaintext)
}
pub(crate) fn encrypt_catalog_secret_with_configured_key_fallbacks(
encryption_key: Option<&str>,
plaintext: &str,
) -> Option<String> {
let encryption_key = effective_catalog_encryption_key_from_config(encryption_key)?;
encrypt_python_fernet_plaintext(encryption_key.as_ref(), plaintext).ok()
}
@@ -151,18 +193,11 @@ pub(crate) fn masked_catalog_api_key(state: &AppState, key: &StoredProviderCatal
else {
return "[未设置]".to_string();
};
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)
.map(|value| {
if value.chars().count() <= 12 {
format!("{value}***")
} else {
format!(
"{}***{}",
take_secret_prefix(&value, 8),
take_secret_suffix(&value, 4)
)
}
})
state
.decrypt_provider_catalog_key_api_key(key)
.ok()
.flatten()
.map(|value| masked_secret_display(&value, 8, 4, "***"))
.unwrap_or_else(|| "***ERROR***".to_string())
}
}
@@ -189,7 +224,10 @@ pub(crate) fn parse_catalog_auth_config_json(
if ciphertext.is_empty() {
return None;
}
let plaintext = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)?;
let plaintext = state
.decrypt_provider_catalog_key_auth_config(key)
.ok()
.flatten()?;
serde_json::from_str::<serde_json::Value>(&plaintext)
.ok()?
.as_object()
@@ -399,7 +437,9 @@ pub(crate) fn sync_provider_key_oauth_status_snapshot(
"oauth".to_string(),
build_provider_key_oauth_status_snapshot(key),
);
Some(Value::Object(snapshot))
Some(admin_provider_status_snapshot_safe_json(Some(
&Value::Object(snapshot),
)))
}
fn build_provider_key_account_status_snapshot(
@@ -2231,7 +2271,9 @@ pub(crate) fn sync_provider_key_quota_status_snapshot(
.or_else(|| default_snapshot.as_object().cloned())
.unwrap_or_default();
snapshot.insert("quota".to_string(), quota);
Some(Value::Object(snapshot))
Some(admin_provider_status_snapshot_safe_json(Some(
&Value::Object(snapshot),
)))
}
fn quota_snapshot_has_materialized_data(
@@ -2414,7 +2456,7 @@ pub(crate) fn provider_key_status_snapshot_payload(
"account".to_string(),
build_provider_key_account_status_snapshot(key, provider_type),
);
Value::Object(snapshot)
admin_provider_status_snapshot_safe_json(Some(&Value::Object(snapshot)))
}
pub(crate) fn provider_key_health_summary(
@@ -2730,7 +2772,10 @@ pub(crate) fn build_admin_provider_key_response(
);
payload.insert("oauth_header_auth".to_string(), json!(oauth_header_auth));
payload.insert("name".to_string(), json!(key.name));
payload.insert("rate_multipliers".to_string(), json!(key.rate_multipliers));
payload.insert(
"rate_multipliers".to_string(),
admin_secret_safe_json(key.rate_multipliers.as_ref()),
);
payload.insert(
"internal_priority".to_string(),
json!(key.internal_priority),
@@ -2750,7 +2795,10 @@ pub(crate) fn build_admin_provider_key_response(
.collect(),
),
);
payload.insert("capabilities".to_string(), json!(key.capabilities));
payload.insert(
"capabilities".to_string(),
admin_secret_safe_json(key.capabilities.as_ref()),
);
payload.insert(
"oauth_expires_at".to_string(),
json!(auth_semantics
@@ -2809,7 +2857,7 @@ pub(crate) fn build_admin_provider_key_response(
);
payload.insert(
"oauth_organizations".to_string(),
serde_json::Value::Array(oauth_organizations),
admin_secret_safe_json(Some(&serde_json::Value::Array(oauth_organizations))),
);
payload.insert("oauth_temporary".to_string(), json!(oauth_temporary));
payload.insert(
@@ -2823,12 +2871,17 @@ pub(crate) fn build_admin_provider_key_response(
"oauth_invalid_reason".to_string(),
json!(auth_semantics
.can_show_oauth_metadata()
.then_some(key.oauth_invalid_reason.clone())
.then(|| {
admin_provider_oauth_invalid_reason_safe_text(key.oauth_invalid_reason.as_deref())
})
.flatten()),
);
payload.insert(
"status_snapshot".to_string(),
provider_key_status_snapshot_payload(key, provider_type),
admin_provider_status_snapshot_safe_json(Some(&provider_key_status_snapshot_payload(
key,
provider_type,
))),
);
payload.insert(
"cache_ttl_minutes".to_string(),
@@ -2838,10 +2891,13 @@ pub(crate) fn build_admin_provider_key_response(
"max_probe_interval_minutes".to_string(),
json!(key.max_probe_interval_minutes),
);
payload.insert("health_by_format".to_string(), json!(key.health_by_format));
payload.insert(
"health_by_format".to_string(),
admin_secret_safe_json(key.health_by_format.as_ref()),
);
payload.insert(
"circuit_breaker_by_format".to_string(),
json!(key.circuit_breaker_by_format),
admin_secret_safe_json(key.circuit_breaker_by_format.as_ref()),
);
payload.insert("health_score".to_string(), json!(health_score));
payload.insert(
@@ -2890,10 +2946,9 @@ pub(crate) fn build_admin_provider_key_response(
);
payload.insert(
"request_results_window".to_string(),
circuit_sample
.and_then(|value| value.get("request_results_window"))
.cloned()
.unwrap_or(serde_json::Value::Null),
admin_secret_safe_json(
circuit_sample.and_then(|value| value.get("request_results_window")),
),
);
payload.insert("request_count".to_string(), json!(request_count));
payload.insert("success_count".to_string(), json!(success_count));
@@ -2912,7 +2967,7 @@ pub(crate) fn build_admin_provider_key_response(
payload.insert("effective_limit".to_string(), json!(effective_limit));
payload.insert(
"utilization_samples".to_string(),
json!(key.utilization_samples),
admin_secret_safe_json(key.utilization_samples.as_ref()),
);
payload.insert(
"last_probe_increase_at".to_string(),
@@ -2943,7 +2998,10 @@ pub(crate) fn build_admin_provider_key_response(
);
payload.insert(
"last_models_fetch_error".to_string(),
json!(key.last_models_fetch_error),
json!(key
.last_models_fetch_error
.as_deref()
.map(safe_model_fetch_error)),
);
payload.insert("locked_models".to_string(), json!(key.locked_models));
payload.insert(
@@ -2956,10 +3014,16 @@ pub(crate) fn build_admin_provider_key_response(
);
payload.insert(
"upstream_metadata".to_string(),
json!(key.upstream_metadata),
admin_provider_upstream_metadata_safe_json(key.upstream_metadata.as_ref()),
);
payload.insert(
"proxy".to_string(),
admin_secret_safe_proxy(key.proxy.as_ref()),
);
payload.insert(
"fingerprint".to_string(),
admin_secret_safe_json(key.fingerprint.as_ref()),
);
payload.insert("proxy".to_string(), json!(key.proxy));
payload.insert("fingerprint".to_string(), json!(key.fingerprint));
payload.insert(
"last_used_at".to_string(),
json!(key.last_used_at_unix_secs.and_then(unix_secs_to_rfc3339)),
@@ -3062,6 +3126,40 @@ mod tests {
assert_ne!(masked, "***ERROR***");
}
#[test]
fn masked_catalog_api_key_never_returns_a_complete_short_secret() {
let state = AppState::new().expect("gateway should build");
let plaintext = "sk-test-a";
let encrypted_api_key =
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, plaintext)
.expect("api key ciphertext should build");
let key = StoredProviderCatalogKey::new(
"key-short".to_string(),
"provider-test".to_string(),
"default".to_string(),
"api_key".to_string(),
None,
true,
)
.expect("key should build")
.with_transport_fields(
Some(json!(["openai:chat"])),
encrypted_api_key,
None,
None,
None,
None,
None,
None,
None,
)
.expect("key transport should build");
let masked = masked_catalog_api_key(&state, &key);
assert_ne!(masked, plaintext);
assert!(!masked.contains(plaintext));
}
#[test]
fn provider_aware_mask_labels_agent_identity_without_exposing_placeholder() {
let state = AppState::new().expect("gateway should build");
@@ -4400,10 +4498,7 @@ mod tests {
assert_eq!(account.get("code"), Some(&json!("account_disabled")));
assert_eq!(account.get("label"), Some(&json!("账号停用")));
assert_eq!(
account.get("reason"),
Some(&json!("account has been deactivated"))
);
assert_eq!(account.get("reason"), Some(&json!("Account is disabled")));
assert_eq!(account.get("blocked"), Some(&json!(true)));
assert_eq!(account.get("source"), Some(&json!("oauth_invalid")));
}
@@ -4449,4 +4544,79 @@ mod tests {
assert_eq!(account.get("blocked"), Some(&json!(true)));
assert_eq!(account.get("source"), Some(&json!("metadata")));
}
#[test]
fn admin_provider_key_response_projects_historical_sensitive_diagnostics() {
let state = AppState::new().expect("gateway should build");
let mut key = sample_catalog_key();
key.auth_type = "oauth".to_string();
key.oauth_invalid_at_unix_secs = Some(1_777_000_000);
key.oauth_invalid_reason = Some(
"[ACCOUNT_BLOCK] account has been deactivated: Authorization: Bearer upstream-secret https://user:[email protected]?q=secret"
.to_string(),
);
key.last_models_fetch_error = Some(
"request failed for https://user:[email protected]/models?q=secret; Authorization: Bearer upstream-secret"
.to_string(),
);
key.status_snapshot = Some(json!({
"oauth": {
"code": "invalid",
"reason": "Authorization: Bearer upstream-secret"
},
"account": {
"code": "account_disabled",
"reason": "https://user:[email protected]?q=secret",
"blocked": true
},
"quota": {
"provider_type": "codex",
"code": "cooldown",
"reason": "Authorization: Bearer upstream-secret",
"exhausted": false,
"reset_credits": {
"detail_error": "https://user:[email protected]?q=secret"
},
"unknown": {"body": "upstream-secret"}
}
}));
key.upstream_metadata = Some(json!({
"codex": {
"primary_used_percent": 25.0,
"message": "Authorization: Bearer upstream-secret",
"reset_credits": {
"detail_error": "https://user:[email protected]?q=secret"
}
}
}));
let payload = build_admin_provider_key_response(
&state,
&key,
"codex",
&["openai:responses".to_string()],
1_777_000_001,
);
assert_eq!(
payload["oauth_invalid_reason"],
json!("[ACCOUNT_BLOCK] Account is disabled")
);
assert_eq!(
payload["last_models_fetch_error"],
json!("Upstream models fetch failed")
);
assert_eq!(
payload.pointer("/status_snapshot/account/reason"),
Some(&json!("Account is disabled"))
);
assert_eq!(
payload.pointer("/upstream_metadata/codex/primary_used_percent"),
Some(&json!(25.0))
);
let serialized = payload.to_string();
assert!(!serialized.contains("upstream-secret"));
assert!(!serialized.contains("user:password"));
assert!(!serialized.contains("q=secret"));
}
}
@@ -1,7 +1,14 @@
use super::system_config_string;
use crate::{AppState, GatewayError};
use aether_admin::system::{
admin_email_template_html_is_valid, admin_email_template_subject_is_valid,
ADMIN_EMAIL_TEMPLATE_MAX_HTML_BYTES, ADMIN_EMAIL_TEMPLATE_MAX_PREVIEW_VALUE_BYTES,
};
use regex::Regex;
use serde_json::json;
const MAX_RENDERED_EMAIL_HTML_BYTES: usize = 512 * 1024;
pub(crate) struct AdminEmailTemplateDefinition {
pub(crate) template_type: &'static str,
pub(crate) name: &'static str,
@@ -173,10 +180,24 @@ pub(crate) async fn read_admin_email_template_payload(
let html = state
.read_system_config_json_value(&admin_email_template_html_key(definition.template_type))
.await?;
let subject = system_config_string(subject.as_ref())
.unwrap_or_else(|| definition.default_subject.to_string());
let html =
system_config_string(html.as_ref()).unwrap_or_else(|| definition.default_html.to_string());
let subject = match system_config_string(subject.as_ref()) {
Some(value) if admin_email_template_subject_is_valid(&value) => value,
Some(_) => {
return Err(GatewayError::Internal(
"stored email template subject is invalid or oversized".to_string(),
));
}
None => definition.default_subject.to_string(),
};
let html = match system_config_string(html.as_ref()) {
Some(value) if admin_email_template_html_is_valid(&value) => value,
Some(_) => {
return Err(GatewayError::Internal(
"stored email template html is invalid or oversized".to_string(),
));
}
None => definition.default_html.to_string(),
};
let is_custom = subject != definition.default_subject || html != definition.default_html;
Ok(Some(json!({
@@ -204,13 +225,76 @@ pub(crate) fn render_admin_email_template_html(
template_html: &str,
variables: &std::collections::BTreeMap<String, String>,
) -> Result<String, GatewayError> {
if template_html.len() > ADMIN_EMAIL_TEMPLATE_MAX_HTML_BYTES {
return Err(GatewayError::Internal(
"email template html exceeds the allowed size".to_string(),
));
}
if !admin_email_template_html_is_valid(template_html) {
return Err(GatewayError::Internal(
"email template html contains invalid control bytes".to_string(),
));
}
let mut rendered = template_html.to_string();
for (key, value) in variables {
let pattern = regex::Regex::new(&format!(r"\{{\{{\s*{}\s*\}}\}}", regex::escape(key)))
if value.len() > ADMIN_EMAIL_TEMPLATE_MAX_PREVIEW_VALUE_BYTES {
return Err(GatewayError::Internal(
"email template variable is oversized".to_string(),
));
}
let pattern = Regex::new(&format!(r"\{{\{{\s*{}\s*\}}\}}", regex::escape(key)))
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let escaped = escape_admin_email_template_html(value);
let (matched_bytes, occurrences) = pattern
.find_iter(&rendered)
.fold((0usize, 0usize), |(matched, count), found| {
(matched.saturating_add(found.as_str().len()), count + 1)
});
let replacement_bytes = occurrences.checked_mul(escaped.len()).and_then(|bytes| {
rendered
.len()
.checked_sub(matched_bytes)?
.checked_add(bytes)
});
if replacement_bytes.is_none_or(|bytes| bytes > MAX_RENDERED_EMAIL_HTML_BYTES) {
return Err(GatewayError::Internal(
"rendered email template exceeds the allowed size".to_string(),
));
}
rendered = pattern
.replace_all(&rendered, escape_admin_email_template_html(value))
.replace_all(&rendered, regex::NoExpand(escaped.as_str()))
.into_owned();
}
if rendered.len() > MAX_RENDERED_EMAIL_HTML_BYTES {
return Err(GatewayError::Internal(
"rendered email template exceeds the allowed size".to_string(),
));
}
Ok(rendered)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn renderer_escapes_values_without_regex_replacement_expansion() {
let variables = BTreeMap::from([(String::from("app_name"), String::from("$1<&"))]);
let rendered = render_admin_email_template_html("<p>{{app_name}}</p>", &variables)
.expect("normal template should render");
assert_eq!(rendered, "<p>$1&lt;&amp;</p>");
}
#[test]
fn renderer_rejects_control_bytes_and_expansion_bombs() {
let controls = render_admin_email_template_html("<p>bad\u{0001}</p>", &BTreeMap::new());
assert!(controls.is_err());
let template = "{{value}}".repeat(100_000);
let variables = BTreeMap::from([(String::from("value"), String::from("x".repeat(64)))]);
let error = render_admin_email_template_html(&template, &variables)
.expect_err("rendered output must remain bounded");
assert!(format!("{error:?}").contains("exceeds"));
}
}
@@ -0,0 +1,634 @@
use std::future::Future;
use aether_crypto::looks_like_python_fernet_ciphertext;
use aether_data::repository::oauth_providers::{
validate_oauth_redirect_uri, StoredOAuthProviderConfig, UpsertOAuthProviderConfigRecord,
};
use url::{Host, Url};
use crate::{AppState, GatewayError};
use super::{
decrypt_catalog_secret_with_fallbacks, open_runtime_secret_payload, seal_runtime_secret_payload,
};
const IDENTITY_OAUTH_CLIENT_SECRET_MIGRATION_RETRIES: usize = 8;
const IDENTITY_OAUTH_CLIENT_SECRET_ENVELOPE_FAMILY: &str = "aether-identity-oauth-client-secret-";
const IDENTITY_OAUTH_CLIENT_SECRET_ENVELOPE_V2: &str = "aether-identity-oauth-client-secret-v2:";
const IDENTITY_OAUTH_CLIENT_SECRET_ENVELOPE_V3: &str = "aether-identity-oauth-client-secret-v3:";
const IDENTITY_OAUTH_CLIENT_SECRET_PURPOSE_V2: &str = "identity-oauth-client-secret-bound-v2";
const IDENTITY_OAUTH_CLIENT_SECRET_PURPOSE_V3: &str = "identity-oauth-client-secret-bound-v3";
const IDENTITY_OAUTH_CLIENT_SECRET_FIELD: &str = "client_secret_encrypted";
const LINUXDO_AUTHORIZATION_URL: &str = "https://connect.linux.do/oauth2/authorize";
const LINUXDO_TOKEN_URL: &str = "https://connect.linux.do/oauth2/token";
const LINUXDO_USERINFO_URL: &str = "https://connect.linux.do/api/user";
#[derive(Clone, PartialEq, Eq)]
struct IdentityOAuthClientSecretProjection {
plaintext: String,
protected: String,
migration_required: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct IdentityOAuthClientSecretBinding {
provider_type: String,
client_id: String,
authorization_url: String,
token_url: String,
userinfo_url: String,
redirect_uri: String,
}
fn normalized_identity_oauth_provider_type(provider_type: &str) -> Result<String, &'static str> {
let provider_type = provider_type.trim().to_ascii_lowercase();
if provider_type.is_empty() {
return Err("identity OAuth provider type is empty");
}
if provider_type.contains('\0') {
return Err("identity OAuth provider type contains reserved framing");
}
Ok(provider_type)
}
fn identity_oauth_client_secret_purpose_v2(provider_type: &str) -> Result<String, &'static str> {
let provider_type = normalized_identity_oauth_provider_type(provider_type)?;
Ok(format!(
"{IDENTITY_OAUTH_CLIENT_SECRET_PURPOSE_V2}\0provider-type-bytes={}\0{provider_type}\0field-bytes={}\0{IDENTITY_OAUTH_CLIENT_SECRET_FIELD}",
provider_type.len(),
IDENTITY_OAUTH_CLIENT_SECRET_FIELD.len(),
))
}
fn canonical_identity_oauth_endpoint(raw: &str) -> Result<String, &'static str> {
if raw.contains('\0') {
return Err("identity OAuth endpoint contains reserved framing");
}
let mut parsed = Url::parse(raw.trim()).map_err(|_| "identity OAuth endpoint is invalid")?;
if parsed.scheme() != "https"
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.fragment().is_some()
|| matches!(parsed.host(), Some(Host::Ipv4(_)) | Some(Host::Ipv6(_)))
{
return Err("identity OAuth endpoint is not a canonical HTTPS DNS URL");
}
let host = parsed
.host_str()
.map(|host| host.trim_end_matches('.').to_ascii_lowercase())
.filter(|host| !host.is_empty())
.ok_or("identity OAuth endpoint is missing a host")?;
parsed
.set_host(Some(&host))
.map_err(|_| "identity OAuth endpoint host is invalid")?;
if parsed.port() == Some(443) {
parsed
.set_port(None)
.map_err(|_| "identity OAuth endpoint port is invalid")?;
}
Ok(parsed.to_string())
}
fn canonical_identity_oauth_redirect_uri(raw: &str) -> Result<String, &'static str> {
if raw.contains('\0') {
return Err("identity OAuth redirect URI contains reserved framing");
}
let raw = raw.trim();
validate_oauth_redirect_uri(raw).map_err(|_| "identity OAuth redirect URI is invalid")?;
Url::parse(raw)
.map(|url| url.to_string())
.map_err(|_| "identity OAuth redirect URI is invalid")
}
fn effective_identity_oauth_endpoint<'a>(
provider_type: &str,
override_value: Option<&'a str>,
linuxdo_default: &'static str,
) -> Result<&'a str, &'static str> {
if let Some(value) = override_value
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Ok(value);
}
if provider_type == "linuxdo" {
// The static default can be shortened to the caller lifetime.
return Ok(linuxdo_default);
}
Err("identity OAuth endpoint is missing")
}
fn identity_oauth_client_secret_binding(
provider_type: &str,
client_id: &str,
authorization_url_override: Option<&str>,
token_url_override: Option<&str>,
userinfo_url_override: Option<&str>,
redirect_uri: &str,
) -> Result<IdentityOAuthClientSecretBinding, &'static str> {
let provider_type = normalized_identity_oauth_provider_type(provider_type)?;
let client_id = client_id.trim();
if client_id.is_empty() {
return Err("identity OAuth client ID is empty");
}
if client_id.contains('\0') {
return Err("identity OAuth client ID contains reserved framing");
}
let authorization_url = effective_identity_oauth_endpoint(
&provider_type,
authorization_url_override,
LINUXDO_AUTHORIZATION_URL,
)?;
let token_url =
effective_identity_oauth_endpoint(&provider_type, token_url_override, LINUXDO_TOKEN_URL)?;
let userinfo_url = effective_identity_oauth_endpoint(
&provider_type,
userinfo_url_override,
LINUXDO_USERINFO_URL,
)?;
Ok(IdentityOAuthClientSecretBinding {
provider_type,
client_id: client_id.to_string(),
authorization_url: canonical_identity_oauth_endpoint(authorization_url)?,
token_url: canonical_identity_oauth_endpoint(token_url)?,
userinfo_url: canonical_identity_oauth_endpoint(userinfo_url)?,
redirect_uri: canonical_identity_oauth_redirect_uri(redirect_uri)?,
})
}
fn stored_identity_oauth_client_secret_binding(
provider: &StoredOAuthProviderConfig,
) -> Result<IdentityOAuthClientSecretBinding, &'static str> {
identity_oauth_client_secret_binding(
&provider.provider_type,
&provider.client_id,
provider.authorization_url_override.as_deref(),
provider.token_url_override.as_deref(),
provider.userinfo_url_override.as_deref(),
&provider.redirect_uri,
)
}
fn upsert_identity_oauth_client_secret_binding(
provider: &UpsertOAuthProviderConfigRecord,
) -> Result<IdentityOAuthClientSecretBinding, &'static str> {
identity_oauth_client_secret_binding(
&provider.provider_type,
&provider.client_id,
provider.authorization_url_override.as_deref(),
provider.token_url_override.as_deref(),
provider.userinfo_url_override.as_deref(),
&provider.redirect_uri,
)
}
fn identity_oauth_client_secret_purpose_v3(binding: &IdentityOAuthClientSecretBinding) -> String {
format!(
"{IDENTITY_OAUTH_CLIENT_SECRET_PURPOSE_V3}\0provider-type-bytes={}\0{}\0client-id-bytes={}\0{}\0authorization-url-bytes={}\0{}\0token-url-bytes={}\0{}\0userinfo-url-bytes={}\0{}\0redirect-uri-bytes={}\0{}\0field-bytes={}\0{IDENTITY_OAUTH_CLIENT_SECRET_FIELD}",
binding.provider_type.len(),
binding.provider_type,
binding.client_id.len(),
binding.client_id,
binding.authorization_url.len(),
binding.authorization_url,
binding.token_url.len(),
binding.token_url,
binding.userinfo_url.len(),
binding.userinfo_url,
binding.redirect_uri.len(),
binding.redirect_uri,
IDENTITY_OAUTH_CLIENT_SECRET_FIELD.len(),
)
}
pub(crate) fn identity_oauth_provider_secret_binding_matches(
stored: &StoredOAuthProviderConfig,
replacement: &UpsertOAuthProviderConfigRecord,
) -> Result<bool, &'static str> {
Ok(stored_identity_oauth_client_secret_binding(stored)?
== upsert_identity_oauth_client_secret_binding(replacement)?)
}
pub(crate) fn seal_identity_oauth_provider_client_secret(
state: &AppState,
provider: &UpsertOAuthProviderConfigRecord,
plaintext: &str,
) -> Result<String, &'static str> {
if plaintext.contains('\0') {
return Err("identity OAuth client secret contains reserved framing");
}
let binding = upsert_identity_oauth_client_secret_binding(provider)?;
let purpose = identity_oauth_client_secret_purpose_v3(&binding);
let sealed = seal_runtime_secret_payload(state, &purpose, plaintext)
.ok_or("identity OAuth client secret encryption key is not configured")?;
Ok(format!(
"{IDENTITY_OAUTH_CLIENT_SECRET_ENVELOPE_V3}{sealed}"
))
}
fn seal_identity_oauth_provider_client_secret_for_binding(
state: &AppState,
binding: &IdentityOAuthClientSecretBinding,
plaintext: &str,
) -> Result<String, &'static str> {
if plaintext.contains('\0') {
return Err("identity OAuth client secret contains reserved framing");
}
let purpose = identity_oauth_client_secret_purpose_v3(binding);
let sealed = seal_runtime_secret_payload(state, &purpose, plaintext)
.ok_or("identity OAuth client secret encryption key is not configured")?;
Ok(format!(
"{IDENTITY_OAUTH_CLIENT_SECRET_ENVELOPE_V3}{sealed}"
))
}
fn open_identity_oauth_provider_client_secret(
state: &AppState,
provider: &StoredOAuthProviderConfig,
stored: &str,
) -> Result<IdentityOAuthClientSecretProjection, &'static str> {
let binding = stored_identity_oauth_client_secret_binding(provider)?;
let purpose = identity_oauth_client_secret_purpose_v3(&binding);
if let Some(sealed) = stored.strip_prefix(IDENTITY_OAUTH_CLIENT_SECRET_ENVELOPE_V3) {
let plaintext = open_runtime_secret_payload(state, &purpose, sealed)
.ok_or("identity OAuth client secret authentication failed")?;
if plaintext.contains('\0') {
return Err("identity OAuth client secret contains reserved framing");
}
return Ok(IdentityOAuthClientSecretProjection {
plaintext,
protected: stored.to_string(),
migration_required: false,
});
}
if let Some(sealed) = stored.strip_prefix(IDENTITY_OAUTH_CLIENT_SECRET_ENVELOPE_V2) {
let legacy_purpose = identity_oauth_client_secret_purpose_v2(&provider.provider_type)?;
let plaintext = open_runtime_secret_payload(state, &legacy_purpose, sealed)
.ok_or("identity OAuth client secret authentication failed")?;
if plaintext.contains('\0') {
return Err("identity OAuth client secret contains reserved framing");
}
let protected =
seal_identity_oauth_provider_client_secret_for_binding(state, &binding, &plaintext)?;
return Ok(IdentityOAuthClientSecretProjection {
plaintext,
protected,
migration_required: true,
});
}
if stored.starts_with(IDENTITY_OAUTH_CLIENT_SECRET_ENVELOPE_FAMILY) {
return Err("unsupported identity OAuth client secret envelope");
}
if stored.starts_with("aether-") {
return Err("Aether secret envelope has the wrong record binding");
}
if !looks_like_python_fernet_ciphertext(stored) {
return Err("identity OAuth client secret is not an authenticated ciphertext");
}
let plaintext = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), stored)
.ok_or("legacy identity OAuth client secret authentication failed")?;
if plaintext.contains('\0') {
return Err("legacy identity OAuth client secret contains reserved framing");
}
let protected =
seal_identity_oauth_provider_client_secret_for_binding(state, &binding, &plaintext)?;
Ok(IdentityOAuthClientSecretProjection {
plaintext,
protected,
migration_required: true,
})
}
pub(crate) async fn decrypt_or_migrate_identity_oauth_provider_client_secret(
state: &AppState,
provider: &StoredOAuthProviderConfig,
) -> Result<Option<String>, GatewayError> {
decrypt_or_migrate_identity_oauth_provider_client_secret_with_before_compare(
state,
provider,
|| async {},
)
.await
}
async fn decrypt_or_migrate_identity_oauth_provider_client_secret_with_before_compare<
BeforeCompare,
CompareFuture,
>(
state: &AppState,
provider: &StoredOAuthProviderConfig,
before_compare: BeforeCompare,
) -> Result<Option<String>, GatewayError>
where
BeforeCompare: Fn() -> CompareFuture,
CompareFuture: Future<Output = ()>,
{
let provider_storage_key = provider.provider_type.trim();
let original_binding =
stored_identity_oauth_client_secret_binding(provider).map_err(secret_error)?;
for _ in 0..IDENTITY_OAUTH_CLIENT_SECRET_MIGRATION_RETRIES {
let current = state
.get_oauth_provider_config(provider_storage_key)
.await?
.ok_or_else(|| secret_error("identity OAuth provider is unavailable"))?;
if stored_identity_oauth_client_secret_binding(&current).map_err(secret_error)?
!= original_binding
{
return Err(secret_error(
"identity OAuth provider record binding changed unexpectedly",
));
}
let Some(observed) = current.client_secret_encrypted.as_deref() else {
return Ok(None);
};
if observed.is_empty() {
return Err(secret_error("stored identity OAuth client secret is empty"));
}
let projection = open_identity_oauth_provider_client_secret(state, &current, observed)
.map_err(secret_error)?;
if !projection.migration_required {
return Ok(Some(projection.plaintext));
}
before_compare().await;
if state
.compare_and_swap_oauth_provider_client_secret(
provider_storage_key,
observed,
&projection.protected,
)
.await?
{
return Ok(Some(projection.plaintext));
}
}
Err(secret_error(
"identity OAuth client secret migration did not stabilize",
))
}
fn secret_error(message: &'static str) -> GatewayError {
GatewayError::Internal(message.to_string())
}
#[cfg(test)]
mod tests {
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use aether_data::repository::oauth_providers::{
EncryptedSecretUpdate, InMemoryOAuthProviderRepository, OAuthProviderReadRepository,
OAuthProviderWriteRepository, StoredOAuthProviderConfig, UpsertOAuthProviderConfigRecord,
};
use super::{
decrypt_or_migrate_identity_oauth_provider_client_secret,
decrypt_or_migrate_identity_oauth_provider_client_secret_with_before_compare,
open_identity_oauth_provider_client_secret, seal_identity_oauth_provider_client_secret,
IDENTITY_OAUTH_CLIENT_SECRET_ENVELOPE_V3,
};
use crate::handlers::shared::{
encrypt_catalog_secret_with_fallbacks, seal_runtime_secret_payload,
};
use crate::{data::GatewayDataState, AppState};
fn state_with_encryption_key() -> AppState {
AppState::new()
.expect("test state should build")
.with_data_state_for_tests(
GatewayDataState::disabled()
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
)
}
fn sample_provider(provider_type: &str, encrypted: &str) -> StoredOAuthProviderConfig {
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
StoredOAuthProviderConfig::new(
provider_type.to_string(),
format!("{normalized_provider_type} display"),
format!("{normalized_provider_type}-client"),
format!("https://{normalized_provider_type}.example.com/redirect"),
"https://frontend.example.com/auth/callback".to_string(),
)
.expect("provider should build")
.with_config_fields(
Some(encrypted.to_string()),
Some("https://connect.linux.do/oauth2/authorize".to_string()),
Some("https://connect.linux.do/oauth2/token".to_string()),
None,
Some(vec!["openid".to_string()]),
None,
None,
None,
true,
)
.with_timestamps(Some(10), Some(20))
}
fn sample_upsert(
provider_type: &str,
encrypted: EncryptedSecretUpdate,
display_name: &str,
) -> UpsertOAuthProviderConfigRecord {
UpsertOAuthProviderConfigRecord {
provider_type: provider_type.to_string(),
display_name: display_name.to_string(),
client_id: format!("{provider_type}-client"),
client_secret_encrypted: encrypted,
authorization_url_override: Some(
"https://connect.linux.do/oauth2/authorize".to_string(),
),
token_url_override: Some("https://connect.linux.do/oauth2/token".to_string()),
userinfo_url_override: None,
scopes: Some(vec!["openid".to_string()]),
redirect_uri: format!("https://{provider_type}.example.com/redirect"),
frontend_callback_url: "https://frontend.example.com/auth/callback".to_string(),
attribute_mapping: None,
extra_config: None,
icon_url: None,
is_enabled: true,
}
}
fn sample_binding_upsert() -> UpsertOAuthProviderConfigRecord {
sample_upsert("linuxdo", EncryptedSecretUpdate::Preserve, "Linux.do")
}
#[test]
fn v2_round_trip_binds_normalized_provider_and_rejects_tampering() {
let state = state_with_encryption_key();
let record = sample_binding_upsert();
let sealed = seal_identity_oauth_provider_client_secret(&state, &record, "client-secret")
.expect("client secret should seal");
assert!(sealed.starts_with(IDENTITY_OAUTH_CLIENT_SECRET_ENVELOPE_V3));
let provider = sample_provider(" LinuxDo ", &sealed);
assert_eq!(
open_identity_oauth_provider_client_secret(&state, &provider, &sealed)
.expect("matching provider should open")
.plaintext,
"client-secret"
);
let wrong_provider = sample_provider("github", &sealed);
assert!(
open_identity_oauth_provider_client_secret(&state, &wrong_provider, &sealed).is_err()
);
let mut tampered = sealed.into_bytes();
let last = tampered
.last_mut()
.expect("sealed value should not be empty");
*last = if *last == b'A' { b'B' } else { b'A' };
let tampered = String::from_utf8(tampered).expect("ciphertext should remain UTF-8");
assert!(open_identity_oauth_provider_client_secret(&state, &provider, &tampered).is_err());
}
#[test]
fn reader_rejects_unknown_and_cross_family_aether_envelopes() {
let state = state_with_encryption_key();
for stored in [
"aether-identity-oauth-client-secret-v3:unknown",
"aether-system-config-secret-v2:foreign",
"aether-proxy-node-secret-v2:foreign",
"plaintext-secret",
] {
assert!(
open_identity_oauth_provider_client_secret(
&state,
&sample_provider("linuxdo", stored),
stored
)
.is_err(),
"unexpectedly accepted {stored}"
);
}
let other_runtime = seal_runtime_secret_payload(&state, "another-purpose", "secret")
.expect("runtime secret should seal");
assert!(open_identity_oauth_provider_client_secret(
&state,
&sample_provider("linuxdo", &other_runtime),
&other_runtime,
)
.is_err());
let stripped = other_runtime
.strip_prefix("aether-runtime-secret-v1:")
.expect("runtime envelope should contain its Fernet payload");
assert!(
open_identity_oauth_provider_client_secret(
&state,
&sample_provider("linuxdo", stripped),
stripped
)
.is_err(),
"stripping a foreign runtime envelope must not turn it into a legacy secret",
);
}
#[tokio::test]
async fn legacy_fernet_is_migrated_to_record_bound_v2() {
let bootstrap = state_with_encryption_key();
let legacy = encrypt_catalog_secret_with_fallbacks(&bootstrap, "legacy-secret")
.expect("legacy secret should encrypt");
let provider = sample_provider("linuxdo", &legacy);
let repository = Arc::new(InMemoryOAuthProviderRepository::seed([provider.clone()]));
let state = AppState::new()
.expect("test state should build")
.with_data_state_for_tests(
GatewayDataState::with_oauth_provider_repository_for_tests(Arc::clone(&repository))
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
);
assert_eq!(
decrypt_or_migrate_identity_oauth_provider_client_secret(&state, &provider)
.await
.expect("legacy secret should migrate")
.as_deref(),
Some("legacy-secret")
);
let stored = repository
.get_oauth_provider_config("linuxdo")
.await
.expect("provider should read")
.expect("provider should exist")
.client_secret_encrypted
.expect("secret should remain configured");
assert!(stored.starts_with(IDENTITY_OAUTH_CLIENT_SECRET_ENVELOPE_V3));
}
#[tokio::test]
async fn migration_cas_miss_rereads_and_preserves_concurrent_non_secret_update() {
let bootstrap = state_with_encryption_key();
let legacy_before = encrypt_catalog_secret_with_fallbacks(&bootstrap, "before")
.expect("legacy secret should encrypt");
let legacy_after = encrypt_catalog_secret_with_fallbacks(&bootstrap, "after")
.expect("rotated legacy secret should encrypt");
let provider = sample_provider("linuxdo", &legacy_before);
let repository = Arc::new(InMemoryOAuthProviderRepository::seed([provider.clone()]));
let state = AppState::new()
.expect("test state should build")
.with_data_state_for_tests(
GatewayDataState::with_oauth_provider_repository_for_tests(Arc::clone(&repository))
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
);
let first_compare = Arc::new(AtomicBool::new(true));
let repository_for_race = Arc::clone(&repository);
let legacy_after_for_race = legacy_after.clone();
let plaintext =
decrypt_or_migrate_identity_oauth_provider_client_secret_with_before_compare(
&state,
&provider,
move || {
let first_compare = Arc::clone(&first_compare);
let repository = Arc::clone(&repository_for_race);
let legacy_after = legacy_after_for_race.clone();
async move {
if first_compare.swap(false, Ordering::SeqCst) {
repository
.upsert_oauth_provider_config(&sample_upsert(
"linuxdo",
EncryptedSecretUpdate::Set(legacy_after),
"concurrent display",
))
.await
.expect("concurrent provider update should persist");
}
}
},
)
.await
.expect("migration should retry after CAS miss")
.expect("secret should remain configured");
assert_eq!(plaintext, "after");
let current = repository
.get_oauth_provider_config("linuxdo")
.await
.expect("provider should read")
.expect("provider should exist");
assert_eq!(current.display_name, "concurrent display");
let stored = current
.client_secret_encrypted
.expect("secret should remain configured");
assert!(stored.starts_with(IDENTITY_OAUTH_CLIENT_SECRET_ENVELOPE_V3));
assert_eq!(
open_identity_oauth_provider_client_secret(
&state,
&repository
.get_oauth_provider_config("linuxdo")
.await
.unwrap()
.unwrap(),
&stored
)
.expect("migrated secret should open")
.plaintext,
"after"
);
}
}
+73 -14
View File
@@ -1,35 +1,47 @@
mod admin_proxy;
mod api_keys;
mod auth_api_key_secret;
mod catalog;
mod email_templates;
mod external_models;
mod identity_oauth_provider_secret;
mod multipart;
mod normalize;
mod payloads;
mod payment_currency;
mod payment_direct;
mod payment_gateway_config;
mod payment_gateway_secret;
mod payment_order_stripe_secret;
mod provider_catalog_credential;
mod provider_ops_credential;
pub(crate) mod provider_pool;
mod request_utils;
mod runtime_secret;
mod system_config_values;
mod usage_stats;
pub(crate) use self::admin_proxy::{
attach_admin_audit_response, build_admin_proxy_auth_required_response,
build_unhandled_admin_proxy_response,
build_unhandled_admin_proxy_response, mark_sensitive_admin_response_no_store,
};
pub(crate) use self::api_keys::{
api_key_placeholder_display, configured_api_key_prefix, generate_gateway_api_key_plaintext,
generate_gateway_secret_plaintext, masked_gateway_api_key_display,
generate_gateway_secret_plaintext, masked_gateway_api_key_display, masked_secret_display,
normalize_optional_api_key_concurrent_limit,
};
pub(crate) use self::auth_api_key_secret::{
decrypt_or_migrate_auth_api_key_secret, open_auth_api_key_secret, seal_auth_api_key_secret,
};
pub(crate) use self::catalog::{
build_admin_provider_key_response, decrypt_catalog_secret_with_fallbacks,
default_provider_key_status_snapshot, effective_catalog_encryption_key,
encrypt_catalog_secret_with_fallbacks, masked_catalog_api_key,
masked_catalog_api_key_for_provider, parse_catalog_auth_config_json,
build_admin_provider_key_response, decrypt_catalog_secret_or_legacy_plaintext,
decrypt_catalog_secret_with_fallbacks, default_provider_key_status_snapshot,
effective_catalog_encryption_key, encrypt_catalog_secret_with_fallbacks,
masked_catalog_api_key, masked_catalog_api_key_for_provider, parse_catalog_auth_config_json,
provider_catalog_key_supports_format, provider_key_health_summary,
provider_key_health_summary_at, provider_key_status_snapshot_payload,
sync_provider_key_oauth_status_snapshot, sync_provider_key_quota_status_snapshot,
take_secret_prefix, take_secret_suffix,
take_secret_prefix, take_secret_suffix, StoredCatalogSecret,
};
pub(crate) use self::email_templates::{
admin_email_template_definition, admin_email_template_html_key,
@@ -37,6 +49,14 @@ pub(crate) use self::email_templates::{
read_admin_email_template_payload, render_admin_email_template_html,
};
pub(crate) use self::external_models::OFFICIAL_EXTERNAL_MODEL_PROVIDERS;
pub(crate) use self::identity_oauth_provider_secret::{
decrypt_or_migrate_identity_oauth_provider_client_secret,
identity_oauth_provider_secret_binding_matches, seal_identity_oauth_provider_client_secret,
};
pub(crate) use self::multipart::{
find_multipart_boundary, find_multipart_boundary_after_crlf, parse_multipart_boundary,
MAX_MULTIPART_PARTS, MAX_MULTIPART_PART_HEADER_BYTES,
};
pub(crate) use self::normalize::{
deserialize_optional_json_patch, deserialize_optional_string_list_patch,
ip_rule_pattern_matches, ip_rules_allow, json_ip_rules_allow, normalize_feature_settings,
@@ -47,30 +67,69 @@ pub(crate) use self::payloads::{
InternalGatewayAuthContextRequest, InternalGatewayExecuteRequest,
InternalGatewayResolveRequest, InternalTunnelHeartbeatRequest, InternalTunnelNodeStatusRequest,
};
pub(crate) use self::payment_currency::{
effective_payment_exchange_rate, normalize_payment_currency, stripe_amount_to_major,
stripe_amount_to_minor,
};
pub(crate) use self::payment_direct::{
close_direct_gateway_order, create_alipay_direct_checkout, create_stripe_direct_checkout,
create_wxpay_direct_checkout, direct_payment_client_ip, refund_direct_gateway_order,
close_direct_gateway_checkout, close_direct_gateway_order, create_alipay_direct_checkout,
create_stripe_direct_checkout, create_wxpay_direct_checkout, find_payment_callback_order,
payment_callback_settlement_values, public_payment_http_client, refund_direct_gateway_order,
verify_alipay_notify_callback, verify_wxpay_notify_callback, DirectGatewayRefundResult,
DirectPaymentCheckoutInput,
DirectPaymentCheckoutError, DirectPaymentCheckoutInput,
};
pub(crate) use self::payment_gateway_config::{
normalize_payment_callback_base_url, normalize_payment_https_url,
payment_gateway_allow_user_refund, payment_gateway_channels_config_json,
payment_gateway_channels_json, payment_gateway_config_json,
payment_gateway_provider_for_payment_method, payment_gateway_refund_enabled,
payment_gateway_secret_keys_json,
};
pub(crate) use self::payment_gateway_secret::{
open_payment_gateway_secret, payment_gateway_secret_is_legacy_unbound,
seal_payment_gateway_secret, PaymentGatewaySecretBinding, PaymentGatewaySecretProjection,
};
pub(crate) use self::payment_order_stripe_secret::{
normalize_stripe_client_secret, open_payment_order_stripe_client_secret,
seal_payment_order_stripe_client_secret, PaymentOrderStripeSecretBinding,
PaymentOrderStripeSecretProjection, STRIPE_CLIENT_SECRET_ENCRYPTED_KEY,
};
pub(crate) use self::provider_catalog_credential::{
open_provider_catalog_credential, seal_provider_catalog_credential,
ProviderCatalogCredentialField, ProviderCatalogCredentialProjection,
};
pub(crate) use self::provider_ops_credential::{
canonicalize_provider_ops_base_url, open_provider_ops_credential,
provider_ops_credential_binding_from_config, provider_ops_credential_field_is_secret,
provider_ops_outbound_policy_digest, resolve_provider_ops_same_origin_url,
seal_provider_ops_credential, ProviderOpsCanonicalDestination, ProviderOpsCredentialBinding,
ProviderOpsCredentialProjection, PROVIDER_OPS_PERSISTENT_SECRET_FIELDS,
PROVIDER_OPS_TRANSIENT_METADATA_FIELDS, PROVIDER_OPS_TRANSIENT_SECRET_FIELDS,
};
pub(crate) use self::request_utils::{
admin_proxy_local_requires_buffered_body, internal_proxy_local_requires_buffered_body,
json_string_list, local_proxy_route_requires_buffered_body,
mark_external_models_official_providers, public_support_local_requires_buffered_body,
query_param_bool, query_param_optional_bool, query_param_value,
request_enables_control_execute, rust_auth_terminates_provider_credentials,
sanitize_upstream_path_and_query, should_strip_forwarded_provider_credential_header,
should_strip_forwarded_trusted_admin_header, strip_query_param, unix_ms_to_rfc3339,
unix_secs_to_rfc3339,
sanitize_upstream_path_and_query, security_log_url_origin,
should_strip_forwarded_provider_credential_header, should_strip_forwarded_trusted_admin_header,
strip_query_param, unix_ms_to_rfc3339, unix_secs_to_rfc3339,
};
pub(crate) use self::runtime_secret::{
open_runtime_secret_payload, open_runtime_secret_payload_with_encryption_key,
runtime_secret_payload_is_sealed, seal_runtime_secret_payload,
seal_runtime_secret_payload_with_encryption_key,
};
pub(crate) use self::system_config_values::{
module_available_from_env, system_config_bool, system_config_string,
bark_device_key_binding, canonical_bark_server_url, decrypt_or_migrate_bark_device_key,
decrypt_or_migrate_ldap_bind_password, decrypt_or_migrate_smtp_password,
decrypt_or_migrate_system_config_secret, decrypt_system_config_secret, encrypt_bark_device_key,
encrypt_ldap_bind_password, encrypt_smtp_password, encrypt_system_config_secret,
ldap_attribute_description_is_valid, ldap_bind_password_binding_matches,
ldap_distinguished_name_is_valid, ldap_module_config_is_valid, ldap_search_filter_is_valid,
module_available_from_env, normalize_ldap_transport_server_url, smtp_password_binding,
system_config_bool, system_config_string, BarkDeviceKeyBinding, SmtpPasswordBinding,
};
pub(crate) use self::usage_stats::{
admin_stats_bad_request_response, parse_bounded_u32, round_to, AdminStatsTimeRange,
@@ -0,0 +1,350 @@
/// RFC 2046 limits a multipart boundary to at most 70 characters. Using
/// HTTP token syntax here also keeps the value safe to embed in the byte
/// delimiter used by the lightweight parsers.
pub(crate) const MAX_MULTIPART_BOUNDARY_BYTES: usize = 70;
/// Keep multipart metadata bounded independently of the file payload size.
pub(crate) const MAX_MULTIPART_PARTS: usize = 128;
pub(crate) const MAX_MULTIPART_PART_HEADER_BYTES: usize = 64 * 1024;
/// Find a multipart delimiter at the beginning of a buffer or after CRLF.
/// The returned index points at the delimiter itself (not the preceding CRLF).
pub(crate) fn find_multipart_boundary(haystack: &[u8], delimiter: &[u8]) -> Option<usize> {
find_multipart_boundary_inner(haystack, delimiter, true)
}
/// Find a multipart delimiter that is preceded by CRLF. This variant is used
/// while scanning a part payload, where an apparent delimiter at byte zero is
/// payload data rather than a valid framing boundary.
pub(crate) fn find_multipart_boundary_after_crlf(
haystack: &[u8],
delimiter: &[u8],
) -> Option<usize> {
find_multipart_boundary_inner(haystack, delimiter, false)
}
fn find_multipart_boundary_inner(
haystack: &[u8],
delimiter: &[u8],
allow_start: bool,
) -> Option<usize> {
if delimiter.is_empty() {
return None;
}
if allow_start && multipart_boundary_is_valid_at(haystack, 0, delimiter) {
return Some(0);
}
let window_len = delimiter.len().checked_add(2)?;
haystack
.windows(window_len)
.enumerate()
.find_map(|(index, window)| {
if &window[..2] != b"\r\n" || &window[2..] != delimiter {
return None;
}
let delimiter_index = index + 2;
multipart_boundary_is_valid_at(haystack, delimiter_index, delimiter)
.then_some(delimiter_index)
})
}
fn multipart_boundary_is_valid_at(haystack: &[u8], index: usize, delimiter: &[u8]) -> bool {
let suffix_start = index.checked_add(delimiter.len());
let Some(suffix_start) = suffix_start else {
return false;
};
if !haystack
.get(index..)
.is_some_and(|remaining| remaining.starts_with(delimiter))
{
return false;
}
let Some(suffix) = haystack.get(suffix_start..) else {
return false;
};
if suffix.starts_with(b"\r\n") {
return true;
}
suffix
.strip_prefix(b"--")
.is_some_and(|remaining| remaining.is_empty() || remaining.starts_with(b"\r\n"))
}
/// Extract and validate a multipart boundary parameter.
///
/// The parameter may use the usual optional surrounding quotes, but the
/// boundary value itself must be an ASCII HTTP token. Rejecting malformed
/// values at the content-type boundary prevents parser ambiguity and bounds
/// the work performed by downstream delimiter scans.
pub(crate) fn parse_multipart_boundary(content_type: &str) -> Option<String> {
let segments = split_multipart_header_parameters(content_type)?;
let media_type = segments.first()?.trim();
if !media_type.eq_ignore_ascii_case("multipart/form-data") {
return None;
}
let mut boundary = None;
let mut seen_keys = Vec::new();
for segment in segments.into_iter().skip(1) {
let segment = segment.trim();
if segment.is_empty() {
return None;
}
let (raw_key, raw_value) = segment.split_once('=')?;
let key = raw_key.trim();
if key.is_empty() || !key.as_bytes().iter().copied().all(is_http_token_byte) {
return None;
}
if seen_keys
.iter()
.any(|seen: &String| seen.eq_ignore_ascii_case(key))
{
return None;
}
seen_keys.push(key.to_ascii_lowercase());
let (value, had_escape) = parse_multipart_parameter_value(raw_value.trim())?;
if !key.eq_ignore_ascii_case("boundary") {
continue;
}
// Keep boundary parsing deliberately narrower than generic quoted
// parameter parsing: escaped boundary values are ambiguous across
// HTTP stacks and are rejected here.
if had_escape {
return None;
}
if !is_valid_multipart_boundary(&value) {
return None;
}
boundary = Some(value);
}
boundary
}
/// Split a semicolon-delimited HTTP header while honoring quoted strings.
/// Returning `None` for an unterminated quote or escape prevents a malformed
/// parameter from being reinterpreted by a downstream parser.
fn split_multipart_header_parameters(value: &str) -> Option<Vec<&str>> {
let mut segments = Vec::new();
let mut start = 0usize;
let mut in_quotes = false;
let mut escaped = false;
for (index, character) in value.char_indices() {
if character.is_ascii_control() {
return None;
}
if in_quotes {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
in_quotes = false;
}
} else if character == '"' {
in_quotes = true;
} else if character == ';' {
segments.push(&value[start..index]);
start = index + character.len_utf8();
}
}
if in_quotes || escaped {
return None;
}
segments.push(&value[start..]);
Some(segments)
}
fn parse_multipart_parameter_value(value: &str) -> Option<(String, bool)> {
if value.is_empty() {
return None;
}
if value.starts_with('"') {
if value.len() < 2 || !value.ends_with('"') {
return None;
}
let inner = &value[1..value.len() - 1];
let mut parsed = String::with_capacity(inner.len());
let mut escaped = false;
let mut had_escape = false;
for character in inner.chars() {
if escaped {
if character.is_ascii_control() {
return None;
}
parsed.push(character);
escaped = false;
had_escape = true;
} else if character == '\\' {
escaped = true;
} else {
if character == '"' || character.is_ascii_control() {
return None;
}
parsed.push(character);
}
}
if escaped {
return None;
}
return Some((parsed, had_escape));
}
value
.as_bytes()
.iter()
.copied()
.all(is_http_token_byte)
.then(|| (value.to_string(), false))
}
fn is_valid_multipart_boundary(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_MULTIPART_BOUNDARY_BYTES
&& value.as_bytes().iter().copied().all(is_http_token_byte)
}
fn is_http_token_byte(byte: u8) -> bool {
matches!(
byte,
b'0'..=b'9'
| b'A'..=b'Z'
| b'a'..=b'z'
| b'!'
| b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
}
#[cfg(test)]
mod tests {
use super::{
find_multipart_boundary, find_multipart_boundary_after_crlf, parse_multipart_boundary,
MAX_MULTIPART_BOUNDARY_BYTES, MAX_MULTIPART_PARTS, MAX_MULTIPART_PART_HEADER_BYTES,
};
#[test]
fn accepts_token_boundary_and_optional_quotes() {
assert_eq!(
parse_multipart_boundary("Multipart/Form-Data; boundary=----WebKitFormBoundaryabc123")
.as_deref(),
Some("----WebKitFormBoundaryabc123")
);
assert_eq!(
parse_multipart_boundary("multipart/form-data; boundary=\"quoted-boundary\"")
.as_deref(),
Some("quoted-boundary")
);
}
#[test]
fn rejects_non_token_control_quote_and_oversized_boundaries() {
for content_type in [
"multipart/form-data; boundary=",
"multipart/form-data; boundary=bad boundary",
"multipart/form-data; boundary=\"bad;boundary\"",
"multipart/form-data; boundary=bad\r\nvalue",
"multipart/form-data; boundary=bad\"quote",
"multipart/form-data; boundary=\"unterminated",
"multipart/form-data; foo",
"multipart/form-data; foo=\"unterminated; boundary=valid",
"multipart/form-data; boundary=valid trailing",
] {
assert!(
parse_multipart_boundary(content_type).is_none(),
"{content_type:?}"
);
}
let oversized = "a".repeat(MAX_MULTIPART_BOUNDARY_BYTES + 1);
assert!(
parse_multipart_boundary(&format!("multipart/form-data; boundary={oversized}"))
.is_none()
);
}
#[test]
fn rejects_duplicate_boundary_parameters() {
for content_type in [
"multipart/form-data; boundary=first; boundary=second",
"multipart/form-data; boundary=first; BOUNDARY=second",
] {
assert!(
parse_multipart_boundary(content_type).is_none(),
"duplicate boundary parameters must be rejected: {content_type}"
);
}
}
#[test]
fn accepts_quoted_unknown_parameters_with_semicolons() {
assert_eq!(
parse_multipart_boundary(
"multipart/form-data; note=\"semi;colon\"; boundary=quoted-token"
)
.as_deref(),
Some("quoted-token")
);
}
#[test]
fn rejects_escaped_boundary_and_duplicate_unknown_parameters() {
for content_type in [
"multipart/form-data; boundary=\"escaped\\\"token\"",
"multipart/form-data; note=one; NOTE=two; boundary=token",
"multipart/form-data; note=\"unterminated; boundary=token",
"multipart/form-data; note=\"closed\"trailing; boundary=token",
] {
assert!(
parse_multipart_boundary(content_type).is_none(),
"malformed content type must be rejected: {content_type:?}"
);
}
}
#[test]
fn rejects_non_multipart_media_types() {
assert!(parse_multipart_boundary("application/json; boundary=abc").is_none());
assert!(parse_multipart_boundary("x-multipart/form-data; boundary=abc").is_none());
}
#[test]
fn multipart_metadata_limits_remain_bounded() {
assert_eq!(MAX_MULTIPART_PARTS, 128);
assert_eq!(MAX_MULTIPART_PART_HEADER_BYTES, 64 * 1024);
}
#[test]
fn boundary_scanner_ignores_embedded_markers_and_invalid_suffixes() {
let delimiter = b"--boundary";
let payload = b"prefix\r\n--boundaryX\r\nmore\r\n--boundary\r\n";
assert_eq!(
find_multipart_boundary_after_crlf(payload, delimiter),
Some(payload.len() - delimiter.len() - 2)
);
assert_eq!(
find_multipart_boundary(b"payload--boundary\r\n", delimiter),
None
);
assert_eq!(find_multipart_boundary(b"--boundaryX\r\n", delimiter), None);
assert_eq!(
find_multipart_boundary_after_crlf(b"payload\r\n--boundaryX\r\n", delimiter),
None
);
}
}
@@ -151,12 +151,15 @@ pub(crate) fn ip_rules_allow(rules: Option<&[String]>, remote_ip: IpAddr) -> boo
for raw in rules {
let rule = raw.trim();
if rule.is_empty() {
continue;
return false;
}
let (deny, pattern) = match rule.strip_prefix('!') {
Some(pattern) => (true, pattern.trim()),
None => (false, rule),
};
if pattern.is_empty() || !valid_ip_rule_pattern(pattern) {
return false;
}
let matched = ip_rule_pattern_matches(pattern, remote_ip);
if deny && matched {
return false;
@@ -181,11 +184,14 @@ pub(crate) fn json_ip_rules_allow(value: Option<&Value>, remote_ip: IpAddr) -> b
return true;
};
if value.is_null() {
return true;
return false;
}
let Some(items) = value.as_array() else {
return false;
};
if items.is_empty() {
return false;
}
let mut rules = Vec::with_capacity(items.len());
for item in items {
let Some(rule) = item.as_str() else {
@@ -505,10 +511,24 @@ mod tests {
#[test]
fn json_ip_rules_allow_rejects_invalid_stored_shape() {
assert!(!json_ip_rules_allow(
Some(&serde_json::Value::Null),
v4(10, 0, 0, 1)
));
assert!(!json_ip_rules_allow(Some(&json!([])), v4(10, 0, 0, 1)));
assert!(!json_ip_rules_allow(
Some(&json!({"bad": true})),
v4(10, 0, 0, 1)
));
assert!(!json_ip_rules_allow(Some(&json!([123])), v4(10, 0, 0, 1)));
assert!(!json_ip_rules_allow(
Some(&json!(["!not-an-ip"])),
v4(10, 0, 0, 1)
));
assert!(!json_ip_rules_allow(
Some(&json!(["203.0.113.0/999"])),
v4(203, 0, 113, 1)
));
assert!(!json_ip_rules_allow(Some(&json!([""])), v4(10, 0, 0, 1)));
}
}
@@ -4,6 +4,7 @@ use std::collections::BTreeMap;
#[derive(Debug, Deserialize)]
pub(crate) struct InternalTunnelHeartbeatRequest {
pub(crate) node_id: String,
pub(crate) heartbeat_session_id: String,
pub(crate) heartbeat_id: u64,
#[serde(default)]
pub(crate) heartbeat_interval: Option<i32>,
@@ -0,0 +1,101 @@
/// Normalize a payment currency at an external payment boundary.
///
/// Payment-order storage uses a three-character ISO-style code. Keep the
/// boundary deliberately narrow so values cannot be truncated differently by
/// the individual database adapters or payment providers.
pub(crate) fn normalize_payment_currency(value: &str, field: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.len() != 3 || !trimmed.bytes().all(|byte| byte.is_ascii_alphabetic()) {
return Err(format!("{field} must be a 3-letter currency code"));
}
Ok(trimmed.to_ascii_uppercase())
}
/// Return the exchange rate that should be persisted and used for settlement.
/// USD is already the canonical accounting currency, so a configured
/// conversion rate (often the CNY default) must never be applied to USD
/// checkouts.
pub(crate) fn effective_payment_exchange_rate(
pay_currency: &str,
configured_rate: f64,
) -> Result<f64, String> {
let currency = normalize_payment_currency(pay_currency, "pay_currency")?;
if !configured_rate.is_finite() || configured_rate <= 0.0 {
return Err("usd_exchange_rate must be finite and positive".to_string());
}
Ok(if currency == "USD" {
1.0
} else {
configured_rate
})
}
pub(crate) fn stripe_minor_unit_multiplier(currency: &str) -> f64 {
match currency.trim().to_ascii_lowercase().as_str() {
"bif" | "clp" | "djf" | "gnf" | "jpy" | "kmf" | "krw" | "mga" | "pyg" | "rwf" | "ugx"
| "vnd" | "vuv" | "xaf" | "xof" | "xpf" => 1.0,
"bhd" | "jod" | "kwd" | "omr" | "tnd" => 1_000.0,
_ => 100.0,
}
}
pub(crate) fn stripe_amount_to_minor(amount_major: f64, currency: &str) -> Option<i64> {
let amount_minor = (amount_major * stripe_minor_unit_multiplier(currency)).round();
if !amount_minor.is_finite() || amount_minor <= 0.0 || amount_minor >= i64::MAX as f64 {
return None;
}
Some(amount_minor as i64)
}
pub(crate) fn stripe_amount_to_major(amount_minor: i64, currency: &str) -> f64 {
amount_minor as f64 / stripe_minor_unit_multiplier(currency)
}
#[cfg(test)]
mod tests {
use super::{
effective_payment_exchange_rate, normalize_payment_currency, stripe_amount_to_major,
stripe_amount_to_minor,
};
#[test]
fn payment_currency_is_trimmed_uppercased_and_bounded_to_ascii_three_letters() {
assert_eq!(
normalize_payment_currency(" cny ", "pay_currency"),
Ok("CNY".to_string())
);
for value in ["CN", "CNYY", "C1Y", "人民币", ""] {
assert!(
normalize_payment_currency(value, "pay_currency").is_err(),
"invalid currency should be rejected: {value}"
);
}
}
#[test]
fn usd_uses_unit_effective_exchange_rate() {
assert_eq!(effective_payment_exchange_rate(" usd ", 7.2), Ok(1.0));
assert_eq!(effective_payment_exchange_rate("CNY", 7.2), Ok(7.2));
assert!(effective_payment_exchange_rate("USD", f64::NAN).is_err());
assert!(effective_payment_exchange_rate("CN", 7.2).is_err());
}
#[test]
fn stripe_amounts_handle_zero_two_and_three_decimal_currencies() {
assert_eq!(stripe_amount_to_minor(1234.0, "JPY"), Some(1234));
assert_eq!(stripe_amount_to_major(1234, "jpy"), 1234.0);
assert_eq!(stripe_amount_to_minor(12.34, "USD"), Some(1234));
assert_eq!(stripe_amount_to_major(1234, "usd"), 12.34);
assert_eq!(stripe_amount_to_minor(1.234, "KWD"), Some(1234));
assert_eq!(stripe_amount_to_major(1234, "kwd"), 1.234);
}
#[test]
fn stripe_minor_amount_rejects_invalid_or_overflowing_values() {
assert_eq!(stripe_amount_to_minor(f64::NAN, "usd"), None);
assert_eq!(stripe_amount_to_minor(0.0, "usd"), None);
assert_eq!(stripe_amount_to_minor(f64::MAX, "kwd"), None);
}
}
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,43 @@ use serde_json::{json, Value};
const REFUND_ENABLED_KEY: &str = "refund_enabled";
const ALLOW_USER_REFUND_KEY: &str = "allow_user_refund";
pub(crate) fn normalize_payment_https_url(value: &str, field: &str) -> Result<String, String> {
let trimmed = value.trim();
let parsed =
url::Url::parse(trimmed).map_err(|_| format!("{field} must be an absolute HTTPS URL"))?;
if parsed.scheme() != "https"
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.fragment().is_some()
{
return Err(format!(
"{field} must be an absolute HTTPS URL without credentials or a fragment"
));
}
let literal_ip = match parsed.host() {
Some(url::Host::Ipv4(address)) => Some(std::net::IpAddr::V4(address)),
Some(url::Host::Ipv6(address)) => Some(std::net::IpAddr::V6(address)),
Some(url::Host::Domain(_)) | None => None,
};
if literal_ip.is_some_and(aether_http::is_private_or_reserved_ip) {
return Err(format!(
"{field} must not target a private or reserved address"
));
}
Ok(trimmed.to_string())
}
pub(crate) fn normalize_payment_callback_base_url(value: &str) -> Result<String, String> {
let normalized = normalize_payment_https_url(value, "callback_base_url")?;
let parsed = url::Url::parse(&normalized)
.map_err(|_| "callback_base_url must be an absolute HTTPS URL".to_string())?;
if parsed.query().is_some() {
return Err("callback_base_url must not contain a query string".to_string());
}
Ok(normalized.trim_end_matches('/').to_string())
}
fn json_bool(value: Option<&Value>) -> bool {
match value {
Some(Value::Bool(value)) => *value,
@@ -83,3 +120,43 @@ pub(crate) fn payment_gateway_provider_for_payment_method(
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{normalize_payment_callback_base_url, normalize_payment_https_url};
#[test]
fn payment_urls_require_absolute_https_without_embedded_credentials() {
assert_eq!(
normalize_payment_https_url(" https://pay.example/submit.php ", "endpoint_url"),
Ok("https://pay.example/submit.php".to_string())
);
for value in [
"javascript:alert(1)",
"data:text/html,attack",
"//pay.example/submit.php",
"/submit.php",
"http://pay.example/submit.php",
"https://user:[email protected]/submit.php",
"https://pay.example/submit.php#fragment",
"https://127.0.0.1/submit.php",
"https://169.254.169.254/latest/meta-data",
"https://[::1]/submit.php",
] {
assert!(
normalize_payment_https_url(value, "endpoint_url").is_err(),
"unsafe URL should be rejected: {value}"
);
}
}
#[test]
fn callback_base_url_rejects_query_strings_and_trims_trailing_slashes() {
assert_eq!(
normalize_payment_callback_base_url("https://app.example/"),
Ok("https://app.example".to_string())
);
assert!(normalize_payment_callback_base_url("https://app.example/?tenant=one").is_err());
}
}
@@ -0,0 +1,428 @@
use aether_crypto::looks_like_python_fernet_ciphertext;
use crate::AppState;
use super::{
decrypt_catalog_secret_with_fallbacks, open_runtime_secret_payload, seal_runtime_secret_payload,
};
const PAYMENT_GATEWAY_SECRET_ENVELOPE_FAMILY: &str = "aether-payment-gateway-secret-";
const PAYMENT_GATEWAY_SECRET_ENVELOPE_V2: &str = "aether-payment-gateway-secret-v2:";
const PAYMENT_GATEWAY_SECRET_ENVELOPE_V3: &str = "aether-payment-gateway-secret-v3:";
const PAYMENT_GATEWAY_SECRET_PURPOSE_V2: &str = "payment-gateway-secret-bound-v2";
const PAYMENT_GATEWAY_SECRET_PURPOSE_V3: &str = "payment-gateway-secret-bound-v3";
const RUNTIME_SECRET_ENVELOPE_FAMILY: &str = "aether-runtime-secret-";
const ALIPAY_DEFAULT_GATEWAY_URL: &str = "https://openapi.alipay.com/gateway.do";
const WXPAY_DEFAULT_BASE_URL: &str = "https://api.mch.weixin.qq.com";
const STRIPE_DEFAULT_API_URL: &str = "https://api.stripe.com";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PaymentGatewaySecretBinding {
pub(crate) provider: String,
pub(crate) endpoint_url: String,
pub(crate) merchant_id: String,
}
impl PaymentGatewaySecretBinding {
pub(crate) fn new(
provider: &str,
endpoint_url: &str,
merchant_id: &str,
) -> Result<Self, &'static str> {
let provider = provider.trim().to_ascii_lowercase();
if provider.is_empty() || provider.contains('\0') || provider.chars().any(char::is_control)
{
return Err("payment gateway secret provider is invalid");
}
let endpoint_url = canonical_payment_gateway_endpoint(&provider, endpoint_url)?;
let merchant_id = merchant_id.trim().to_string();
if merchant_id.chars().any(char::is_control) {
return Err("payment gateway secret merchant_id contains reserved framing");
}
if merchant_id.len() > 256 {
return Err("payment gateway secret merchant_id is too long");
}
Ok(Self {
provider,
endpoint_url,
merchant_id,
})
}
pub(crate) fn from_record(
record: &aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
) -> Result<Self, &'static str> {
Self::new(&record.provider, &record.endpoint_url, &record.merchant_id)
}
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct PaymentGatewaySecretProjection {
pub(crate) plaintext: String,
pub(crate) protected: String,
pub(crate) migration_required: bool,
}
/// Returns whether a stored gateway secret predates destination binding.
///
/// Legacy Fernet values carry no gateway identity at all, while the v2
/// envelope authenticates only the provider. Neither format can prove that
/// a value belongs to a newly supplied endpoint/merchant pair, so callers
/// performing a destination-changing mutation must require an explicit
/// replacement secret instead of silently reusing it.
pub(crate) fn payment_gateway_secret_is_legacy_unbound(stored: &str) -> bool {
let stored = stored.trim();
if stored.is_empty() {
return false;
}
if stored.starts_with(PAYMENT_GATEWAY_SECRET_ENVELOPE_V2) {
return true;
}
if stored.starts_with(PAYMENT_GATEWAY_SECRET_ENVELOPE_FAMILY)
|| stored.starts_with(RUNTIME_SECRET_ENVELOPE_FAMILY)
|| stored.starts_with("aether-")
{
return false;
}
looks_like_python_fernet_ciphertext(stored)
}
fn payment_gateway_secret_purpose(provider: &str) -> Result<String, &'static str> {
let provider = provider.trim().to_ascii_lowercase();
if provider.is_empty() {
return Err("payment gateway secret provider is empty");
}
Ok(format!(
"{PAYMENT_GATEWAY_SECRET_PURPOSE_V2}\0provider-bytes={}\0{provider}\0field=merchant-key",
provider.len()
))
}
fn payment_gateway_secret_purpose_v3(
binding: &PaymentGatewaySecretBinding,
) -> Result<String, &'static str> {
for value in [
binding.provider.as_str(),
binding.endpoint_url.as_str(),
binding.merchant_id.as_str(),
] {
if value.contains('\0') {
return Err("payment gateway secret binding contains reserved framing");
}
}
Ok(format!(
"{PAYMENT_GATEWAY_SECRET_PURPOSE_V3}\0provider-bytes={}\0{}\0endpoint-url-bytes={}\0{}\0merchant-id-bytes={}\0{}\0field=merchant-key",
binding.provider.len(),
binding.provider,
binding.endpoint_url.len(),
binding.endpoint_url,
binding.merchant_id.len(),
binding.merchant_id,
))
}
fn canonical_payment_gateway_endpoint(
provider: &str,
endpoint_url: &str,
) -> Result<String, &'static str> {
let endpoint_url = endpoint_url.trim();
let endpoint_url = if endpoint_url.is_empty() {
match provider {
"alipay" => ALIPAY_DEFAULT_GATEWAY_URL,
"wxpay" => WXPAY_DEFAULT_BASE_URL,
"stripe" => STRIPE_DEFAULT_API_URL,
// EPay requires an explicit endpoint at checkout time. Keep an
// explicit marker for legacy records so their secret remains
// bound to the empty value instead of silently changing scope.
_ => return Ok("<empty>".to_string()),
}
} else {
endpoint_url
};
let endpoint_url = super::normalize_payment_https_url(endpoint_url, "endpoint_url")
.map_err(|_| "payment gateway secret endpoint_url is invalid")?;
let mut parsed = url::Url::parse(&endpoint_url)
.map_err(|_| "payment gateway secret endpoint_url is invalid")?;
if parsed.scheme() != "https"
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err("payment gateway secret endpoint_url must be an HTTPS URL without credentials or a fragment");
}
if let Some(host) = parsed.host_str() {
let host = host.trim_end_matches('.').to_ascii_lowercase();
if host.is_empty() {
return Err("payment gateway secret endpoint_url host is empty");
}
parsed
.set_host(Some(&host))
.map_err(|_| "payment gateway secret endpoint_url host is invalid")?;
}
if parsed.port() == Some(443) {
parsed
.set_port(None)
.map_err(|_| "payment gateway secret endpoint_url port is invalid")?;
}
let canonical = parsed.to_string().trim_end_matches('/').to_string();
// Stripe requests are intentionally sent to the official API origin in
// the checkout/refund implementations below. Accepting a configurable
// destination here would bind the credential to one host while sending
// it to another, which defeats the purpose of destination binding and
// could silently route a live secret through an unintended proxy.
if provider == "stripe" && canonical != STRIPE_DEFAULT_API_URL {
return Err("Stripe endpoint_url must use the official API endpoint");
}
Ok(canonical)
}
pub(crate) fn seal_payment_gateway_secret(
state: &AppState,
binding: &PaymentGatewaySecretBinding,
plaintext: &str,
) -> Result<String, &'static str> {
if plaintext.contains('\0') {
return Err("payment gateway secret contains reserved framing");
}
let purpose = payment_gateway_secret_purpose_v3(binding)?;
let sealed = seal_runtime_secret_payload(state, &purpose, plaintext)
.ok_or("payment gateway secret encryption key is not configured")?;
Ok(format!("{PAYMENT_GATEWAY_SECRET_ENVELOPE_V3}{sealed}"))
}
pub(crate) fn open_payment_gateway_secret(
state: &AppState,
binding: &PaymentGatewaySecretBinding,
stored: &str,
) -> Result<PaymentGatewaySecretProjection, &'static str> {
let purpose = payment_gateway_secret_purpose_v3(binding)?;
if let Some(sealed) = stored.strip_prefix(PAYMENT_GATEWAY_SECRET_ENVELOPE_V3) {
let plaintext = open_runtime_secret_payload(state, &purpose, sealed)
.ok_or("payment gateway secret authentication or binding failed")?;
if plaintext.contains('\0') {
return Err("payment gateway secret contains reserved framing");
}
return Ok(PaymentGatewaySecretProjection {
plaintext,
protected: stored.to_string(),
migration_required: false,
});
}
if let Some(sealed) = stored.strip_prefix(PAYMENT_GATEWAY_SECRET_ENVELOPE_V2) {
// v2 was bound only to provider. Authenticate it with the historical
// purpose, then immediately re-seal under the complete destination
// binding before returning the plaintext to a caller.
let plaintext = open_runtime_secret_payload(
state,
&payment_gateway_secret_purpose(&binding.provider)?,
sealed,
)
.ok_or("legacy payment gateway secret authentication failed")?;
if plaintext.contains('\0') {
return Err("legacy payment gateway secret contains reserved framing");
}
let protected = seal_payment_gateway_secret(state, binding, &plaintext)?;
return Ok(PaymentGatewaySecretProjection {
plaintext,
protected,
migration_required: true,
});
}
if stored.starts_with(PAYMENT_GATEWAY_SECRET_ENVELOPE_FAMILY) {
return Err("unsupported payment gateway secret envelope");
}
if stored.starts_with(RUNTIME_SECRET_ENVELOPE_FAMILY) {
return Err("runtime secret envelope has the wrong purpose");
}
if !looks_like_python_fernet_ciphertext(stored) {
return Err("payment gateway secret is not an authenticated ciphertext");
}
let plaintext = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), stored)
.ok_or("legacy payment gateway secret authentication failed")?;
if plaintext.contains('\0') {
return Err("legacy payment gateway secret contains reserved framing");
}
let protected = seal_payment_gateway_secret(state, binding, &plaintext)?;
Ok(PaymentGatewaySecretProjection {
plaintext,
protected,
migration_required: true,
})
}
#[cfg(test)]
mod tests {
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use super::{
open_payment_gateway_secret, seal_payment_gateway_secret, PaymentGatewaySecretBinding,
PAYMENT_GATEWAY_SECRET_ENVELOPE_V2, PAYMENT_GATEWAY_SECRET_ENVELOPE_V3,
STRIPE_DEFAULT_API_URL,
};
use crate::handlers::shared::{
encrypt_catalog_secret_with_fallbacks, seal_runtime_secret_payload,
};
use crate::{data::GatewayDataState, AppState};
fn state_with_encryption_key() -> AppState {
AppState::new()
.expect("test state should build")
.with_data_state_for_tests(
GatewayDataState::disabled()
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
)
}
#[test]
fn v3_round_trip_binds_destination_and_rejects_tampering() {
let state = state_with_encryption_key();
let binding = PaymentGatewaySecretBinding::new(
" EPay ",
"https://payments.example.test:443/checkout/",
" merchant-1 ",
)
.expect("payment gateway binding should build");
let sealed = seal_payment_gateway_secret(&state, &binding, "secret-value")
.expect("payment gateway secret should seal");
let opened = open_payment_gateway_secret(&state, &binding, &sealed)
.expect("payment gateway secret should open");
assert_eq!(opened.plaintext, "secret-value");
assert!(!opened.migration_required);
assert!(open_payment_gateway_secret(
&state,
&PaymentGatewaySecretBinding::new(
"epay",
"https://payments.example.test/other",
"merchant-1",
)
.unwrap(),
&sealed,
)
.is_err());
assert!(open_payment_gateway_secret(
&state,
&PaymentGatewaySecretBinding::new(
"epay",
"https://payments.example.test/checkout/",
"merchant-2",
)
.unwrap(),
&sealed,
)
.is_err());
assert!(open_payment_gateway_secret(
&state,
&PaymentGatewaySecretBinding::new(
"alipay",
"https://payments.example.test/checkout/",
"merchant-1",
)
.unwrap(),
&sealed,
)
.is_err());
let stripped = sealed
.strip_prefix(PAYMENT_GATEWAY_SECRET_ENVELOPE_V3)
.and_then(|value| value.strip_prefix("aether-runtime-secret-v1:"))
.expect("test value should contain both envelope layers");
assert!(open_payment_gateway_secret(&state, &binding, stripped).is_err());
let mut tampered = sealed.into_bytes();
let last = tampered
.last_mut()
.expect("sealed value should not be empty");
*last = if *last == b'A' { b'B' } else { b'A' };
let tampered = String::from_utf8(tampered).expect("ciphertext should remain utf-8");
assert!(open_payment_gateway_secret(&state, &binding, &tampered).is_err());
}
#[test]
fn authenticated_legacy_values_migrate_to_v3_destination_binding() {
let state = state_with_encryption_key();
let binding = PaymentGatewaySecretBinding::new(
"epay",
"https://pay.example.test/submit.php",
"merchant-1",
)
.unwrap();
let legacy = encrypt_catalog_secret_with_fallbacks(&state, "legacy-secret")
.expect("legacy secret should encrypt");
let opened = open_payment_gateway_secret(&state, &binding, &legacy)
.expect("legacy secret should migrate");
assert_eq!(opened.plaintext, "legacy-secret");
assert!(opened.migration_required);
assert!(opened
.protected
.starts_with("aether-payment-gateway-secret-v3:"));
let old_v2 = seal_runtime_secret_payload(
&state,
"payment-gateway-secret-bound-v2\0provider-bytes=4\0epay\0field=merchant-key",
"v2-secret",
)
.expect("legacy v2 secret should encrypt");
let old_v2 = format!("{PAYMENT_GATEWAY_SECRET_ENVELOPE_V2}{old_v2}");
let migrated = open_payment_gateway_secret(&state, &binding, &old_v2)
.expect("legacy v2 secret should migrate");
assert_eq!(migrated.plaintext, "v2-secret");
assert!(migrated.migration_required);
assert!(migrated
.protected
.starts_with(PAYMENT_GATEWAY_SECRET_ENVELOPE_V3));
assert!(open_payment_gateway_secret(
&state,
&binding,
"aether-payment-gateway-secret-v4:unknown",
)
.is_err());
assert!(open_payment_gateway_secret(&state, &binding, "plaintext-secret").is_err());
let other_runtime = seal_runtime_secret_payload(&state, "another-purpose", "secret")
.expect("runtime secret should seal");
assert!(open_payment_gateway_secret(&state, &binding, &other_runtime).is_err());
}
#[test]
fn canonical_binding_uses_provider_defaults_and_rejects_unsafe_urls() {
let default_alipay = PaymentGatewaySecretBinding::new("ALIPAY", "", "merchant")
.expect("default Alipay endpoint should be accepted");
let explicit_alipay = PaymentGatewaySecretBinding::new(
"alipay",
"https://OPENAPI.ALIPAY.COM:443/gateway.do",
"merchant",
)
.expect("explicit Alipay endpoint should be accepted");
assert_eq!(default_alipay.endpoint_url, explicit_alipay.endpoint_url);
let default_stripe = PaymentGatewaySecretBinding::new("stripe", "", "merchant")
.expect("default Stripe endpoint should be accepted");
let explicit_stripe =
PaymentGatewaySecretBinding::new("stripe", "https://API.STRIPE.COM:443/", "merchant")
.expect("official Stripe endpoint should be accepted");
assert_eq!(default_stripe.endpoint_url, STRIPE_DEFAULT_API_URL);
assert_eq!(default_stripe, explicit_stripe);
assert!(PaymentGatewaySecretBinding::new(
"stripe",
"https://stripe-proxy.example.test",
"merchant",
)
.is_err());
for endpoint in [
"http://payments.example.test",
"https://user:[email protected]",
"https://127.0.0.1/pay",
"https://payments.example.test/#fragment",
] {
assert!(
PaymentGatewaySecretBinding::new("stripe", endpoint, "merchant").is_err(),
"unsafe endpoint should be rejected: {endpoint}"
);
}
}
}
@@ -0,0 +1,278 @@
use aether_crypto::looks_like_python_fernet_ciphertext;
use crate::AppState;
use super::{
decrypt_catalog_secret_with_fallbacks, open_runtime_secret_payload, seal_runtime_secret_payload,
};
pub(crate) const STRIPE_CLIENT_SECRET_ENCRYPTED_KEY: &str = "_stripe_client_secret_encrypted";
const MAX_STRIPE_CLIENT_SECRET_BYTES: usize = 1024;
const PAYMENT_ORDER_STRIPE_SECRET_ENVELOPE_FAMILY: &str =
"aether-payment-order-stripe-client-secret-";
const PAYMENT_ORDER_STRIPE_SECRET_ENVELOPE_V2: &str =
"aether-payment-order-stripe-client-secret-v2:";
const PAYMENT_ORDER_STRIPE_SECRET_PURPOSE_V2: &str = "payment-order-stripe-client-secret-bound-v2";
const AETHER_ENVELOPE_FAMILY: &str = "aether-";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PaymentOrderStripeSecretBinding {
pub(crate) order_no: String,
pub(crate) user_id: Option<String>,
pub(crate) order_kind: String,
pub(crate) payment_provider: String,
}
impl PaymentOrderStripeSecretBinding {
pub(crate) fn new(
order_no: &str,
user_id: Option<&str>,
order_kind: &str,
payment_provider: &str,
) -> Result<Self, &'static str> {
validate_identity_component(order_no, "payment order number", 128)?;
if let Some(user_id) = user_id {
validate_identity_component(user_id, "payment order user ID", 128)?;
}
let order_kind = order_kind.trim().to_ascii_lowercase();
if !matches!(order_kind.as_str(), "wallet_recharge" | "plan_purchase") {
return Err("payment order kind is not eligible for a Stripe client secret");
}
let payment_provider = payment_provider.trim().to_ascii_lowercase();
if payment_provider != "stripe" {
return Err("payment order provider is not Stripe");
}
Ok(Self {
order_no: order_no.to_string(),
user_id: user_id.map(ToOwned::to_owned),
order_kind,
payment_provider,
})
}
pub(crate) fn from_order(
order: &aether_data::repository::wallet::StoredAdminPaymentOrder,
) -> Result<Self, &'static str> {
Self::new(
&order.order_no,
order.user_id.as_deref(),
&order.order_kind,
order
.payment_provider
.as_deref()
.unwrap_or(order.payment_method.as_str()),
)
}
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct PaymentOrderStripeSecretProjection {
pub(crate) plaintext: String,
pub(crate) protected: String,
pub(crate) migration_required: bool,
}
fn validate_identity_component(
value: &str,
_label: &'static str,
max_bytes: usize,
) -> Result<(), &'static str> {
if value.is_empty() {
return Err("payment order secret binding contains an empty identity component");
}
if value.as_bytes().len() > max_bytes {
return Err("payment order secret binding identity component is too long");
}
if value.chars().any(char::is_control) {
return Err("payment order secret binding identity component contains control characters");
}
Ok(())
}
fn payment_order_stripe_secret_purpose(
binding: &PaymentOrderStripeSecretBinding,
) -> Result<String, &'static str> {
let user_binding = match binding.user_id.as_deref() {
Some(user_id) => format!(
"user-id-present=1\0user-id-bytes={}\0{user_id}",
user_id.len()
),
None => "user-id-present=0".to_string(),
};
Ok(format!(
"{PAYMENT_ORDER_STRIPE_SECRET_PURPOSE_V2}\0provider-bytes={}\0{}\0order-no-bytes={}\0{}\0{}\0order-kind-bytes={}\0{}\0field-bytes={}\0{}",
binding.payment_provider.len(),
binding.payment_provider,
binding.order_no.len(),
binding.order_no,
user_binding,
binding.order_kind.len(),
binding.order_kind,
STRIPE_CLIENT_SECRET_ENCRYPTED_KEY.len(),
STRIPE_CLIENT_SECRET_ENCRYPTED_KEY,
))
}
pub(crate) fn normalize_stripe_client_secret(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()
&& value.len() <= MAX_STRIPE_CLIENT_SECRET_BYTES
&& value.starts_with("pi_")
&& value.contains("_secret_")
&& !value.chars().any(char::is_control))
.then_some(value)
}
pub(crate) fn seal_payment_order_stripe_client_secret(
state: &AppState,
binding: &PaymentOrderStripeSecretBinding,
plaintext: &str,
) -> Result<String, &'static str> {
let plaintext = normalize_stripe_client_secret(plaintext)
.ok_or("Stripe client secret format is invalid")?;
let purpose = payment_order_stripe_secret_purpose(binding)?;
let sealed = seal_runtime_secret_payload(state, &purpose, plaintext)
.ok_or("payment order Stripe client secret encryption key is not configured")?;
Ok(format!("{PAYMENT_ORDER_STRIPE_SECRET_ENVELOPE_V2}{sealed}"))
}
pub(crate) fn open_payment_order_stripe_client_secret(
state: &AppState,
binding: &PaymentOrderStripeSecretBinding,
stored: &str,
) -> Result<PaymentOrderStripeSecretProjection, &'static str> {
let purpose = payment_order_stripe_secret_purpose(binding)?;
let observed = stored;
let stored = observed.trim();
if stored.is_empty() {
return Err("payment order Stripe client secret ciphertext is empty");
}
let (plaintext, protected, migration_required) = if let Some(sealed) =
stored.strip_prefix(PAYMENT_ORDER_STRIPE_SECRET_ENVELOPE_V2)
{
let plaintext = open_runtime_secret_payload(state, &purpose, sealed)
.ok_or("payment order Stripe client secret authentication failed")?;
(
plaintext,
stored.to_string(),
observed.as_bytes() != stored.as_bytes(),
)
} else {
if stored.starts_with(PAYMENT_ORDER_STRIPE_SECRET_ENVELOPE_FAMILY) {
return Err("unsupported payment order Stripe client secret envelope");
}
if stored.starts_with(AETHER_ENVELOPE_FAMILY) {
return Err("secret envelope has the wrong payment order binding");
}
if !looks_like_python_fernet_ciphertext(stored) {
return Err("payment order Stripe client secret is not an authenticated ciphertext");
}
let plaintext = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), stored)
.ok_or("legacy payment order Stripe client secret authentication failed")?;
if plaintext.contains('\0') {
return Err("legacy payment order Stripe client secret contains reserved framing");
}
let protected = seal_payment_order_stripe_client_secret(state, binding, &plaintext)?;
(plaintext, protected, true)
};
let plaintext = normalize_stripe_client_secret(&plaintext)
.ok_or("Stripe client secret plaintext format is invalid")?
.to_string();
Ok(PaymentOrderStripeSecretProjection {
plaintext,
protected,
migration_required,
})
}
#[cfg(test)]
mod tests {
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use super::{
open_payment_order_stripe_client_secret, seal_payment_order_stripe_client_secret,
PaymentOrderStripeSecretBinding,
};
use crate::handlers::shared::{
encrypt_catalog_secret_with_fallbacks, seal_runtime_secret_payload,
};
use crate::{data::GatewayDataState, AppState};
fn state_with_encryption_key() -> AppState {
AppState::new()
.expect("test state should build")
.with_data_state_for_tests(
GatewayDataState::disabled()
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
)
}
fn binding(order_no: &str, user_id: &str, order_kind: &str) -> PaymentOrderStripeSecretBinding {
PaymentOrderStripeSecretBinding::new(order_no, Some(user_id), order_kind, "stripe")
.expect("test binding should be valid")
}
#[test]
fn v2_ciphertext_is_bound_to_order_owner_kind_provider_and_field() {
let state = state_with_encryption_key();
let source = binding("po-source", "user-a", "wallet_recharge");
let sealed =
seal_payment_order_stripe_client_secret(&state, &source, "pi_source_secret_capability")
.expect("client secret should seal");
let opened = open_payment_order_stripe_client_secret(&state, &source, &sealed)
.expect("matching order should open");
assert_eq!(opened.plaintext, "pi_source_secret_capability");
assert!(!opened.migration_required);
for foreign in [
binding("po-foreign", "user-a", "wallet_recharge"),
binding("po-source", "user-b", "wallet_recharge"),
binding("po-source", "user-a", "plan_purchase"),
] {
assert!(open_payment_order_stripe_client_secret(&state, &foreign, &sealed).is_err());
}
assert!(PaymentOrderStripeSecretBinding::new(
"po-source",
Some("user-a"),
"wallet_recharge",
"alipay",
)
.is_err());
}
#[test]
fn reader_migrates_only_real_legacy_fernet_and_rejects_other_envelopes() {
let state = state_with_encryption_key();
let binding = binding("po-source", "user-a", "wallet_recharge");
let legacy = encrypt_catalog_secret_with_fallbacks(&state, "pi_legacy_secret_capability")
.expect("legacy secret should encrypt");
let opened = open_payment_order_stripe_client_secret(&state, &binding, &legacy)
.expect("real legacy Fernet should open");
assert!(opened.migration_required);
assert!(opened
.protected
.starts_with("aether-payment-order-stripe-client-secret-v2:"));
for stored in [
"plaintext-secret",
"aether-payment-order-stripe-client-secret-v3:unknown",
"aether-payment-gateway-secret-v2:foreign",
] {
assert!(open_payment_order_stripe_client_secret(&state, &binding, stored).is_err());
}
let foreign_runtime =
seal_runtime_secret_payload(&state, "another-purpose", "pi_x_secret_y")
.expect("runtime secret should seal");
assert!(
open_payment_order_stripe_client_secret(&state, &binding, &foreign_runtime,).is_err()
);
let invalid_legacy = encrypt_catalog_secret_with_fallbacks(&state, "not-a-stripe-secret")
.expect("legacy value should encrypt");
assert!(
open_payment_order_stripe_client_secret(&state, &binding, &invalid_legacy,).is_err()
);
}
}
@@ -0,0 +1,281 @@
use aether_crypto::looks_like_python_fernet_ciphertext;
use crate::AppState;
use super::{
decrypt_catalog_secret_with_fallbacks, open_runtime_secret_payload, seal_runtime_secret_payload,
};
const PROVIDER_CATALOG_CREDENTIAL_ENVELOPE_FAMILY: &str = "aether-provider-catalog-credential-";
const PROVIDER_CATALOG_CREDENTIAL_ENVELOPE_V2: &str = "aether-provider-catalog-credential-v2:";
const PROVIDER_CATALOG_CREDENTIAL_PURPOSE_V2: &str = "provider-catalog-credential-bound-v2";
const RUNTIME_SECRET_ENVELOPE_FAMILY: &str = "aether-runtime-secret-";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProviderCatalogCredentialField {
ApiKey,
AuthConfig,
}
impl ProviderCatalogCredentialField {
fn label(self) -> &'static str {
match self {
Self::ApiKey => "api-key",
Self::AuthConfig => "auth-config",
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct ProviderCatalogCredentialProjection {
pub(crate) plaintext: String,
pub(crate) protected: String,
pub(crate) migration_required: bool,
}
fn provider_catalog_credential_purpose(
provider_id: &str,
key_id: &str,
field: ProviderCatalogCredentialField,
) -> Result<String, &'static str> {
if provider_id.is_empty() {
return Err("provider catalog credential provider_id is empty");
}
if key_id.is_empty() {
return Err("provider catalog credential key_id is empty");
}
Ok(format!(
"{PROVIDER_CATALOG_CREDENTIAL_PURPOSE_V2}\0provider-id-bytes={}\0{provider_id}\0key-id-bytes={}\0{key_id}\0field={}",
provider_id.len(),
key_id.len(),
field.label(),
))
}
pub(crate) fn seal_provider_catalog_credential(
state: &AppState,
provider_id: &str,
key_id: &str,
field: ProviderCatalogCredentialField,
plaintext: &str,
) -> Result<String, &'static str> {
if plaintext.contains('\0') {
return Err("provider catalog credential contains reserved framing");
}
let purpose = provider_catalog_credential_purpose(provider_id, key_id, field)?;
let sealed = seal_runtime_secret_payload(state, &purpose, plaintext)
.ok_or("provider catalog credential encryption key is not configured")?;
Ok(format!("{PROVIDER_CATALOG_CREDENTIAL_ENVELOPE_V2}{sealed}"))
}
pub(crate) fn open_provider_catalog_credential(
state: &AppState,
provider_id: &str,
key_id: &str,
field: ProviderCatalogCredentialField,
stored: &str,
) -> Result<ProviderCatalogCredentialProjection, &'static str> {
let purpose = provider_catalog_credential_purpose(provider_id, key_id, field)?;
if let Some(sealed) = stored.strip_prefix(PROVIDER_CATALOG_CREDENTIAL_ENVELOPE_V2) {
let plaintext = open_runtime_secret_payload(state, &purpose, sealed)
.ok_or("provider catalog credential authentication failed")?;
if plaintext.contains('\0') {
return Err("provider catalog credential contains reserved framing");
}
return Ok(ProviderCatalogCredentialProjection {
plaintext,
protected: stored.to_string(),
migration_required: false,
});
}
if stored.starts_with(PROVIDER_CATALOG_CREDENTIAL_ENVELOPE_FAMILY) {
return Err("unsupported provider catalog credential envelope");
}
if stored.starts_with(RUNTIME_SECRET_ENVELOPE_FAMILY) || stored.starts_with("aether-") {
return Err("Aether secret envelope has the wrong record binding");
}
if !looks_like_python_fernet_ciphertext(stored) {
return Err("provider catalog credential is not an authenticated ciphertext");
}
let plaintext = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), stored)
.ok_or("legacy provider catalog credential authentication failed")?;
// A stripped runtime envelope decrypts to `purpose\0payload`. Rejecting
// reserved framing prevents it from being accepted as a legacy value.
if plaintext.contains('\0') {
return Err("legacy provider catalog credential contains reserved framing");
}
let protected =
seal_provider_catalog_credential(state, provider_id, key_id, field, &plaintext)?;
Ok(ProviderCatalogCredentialProjection {
plaintext,
protected,
migration_required: true,
})
}
#[cfg(test)]
mod tests {
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use super::{
open_provider_catalog_credential, seal_provider_catalog_credential,
ProviderCatalogCredentialField, PROVIDER_CATALOG_CREDENTIAL_ENVELOPE_V2,
};
use crate::handlers::shared::{
encrypt_catalog_secret_with_fallbacks, seal_runtime_secret_payload,
};
use crate::{data::GatewayDataState, AppState};
fn state_with_encryption_key() -> AppState {
AppState::new()
.expect("test state should build")
.with_data_state_for_tests(
GatewayDataState::disabled()
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
)
}
#[test]
fn v2_round_trip_binds_provider_key_and_field() {
let state = state_with_encryption_key();
for field in [
ProviderCatalogCredentialField::ApiKey,
ProviderCatalogCredentialField::AuthConfig,
] {
let sealed = seal_provider_catalog_credential(
&state,
"provider-1",
"key-1",
field,
"secret-value",
)
.expect("credential should seal");
assert!(sealed.starts_with(PROVIDER_CATALOG_CREDENTIAL_ENVELOPE_V2));
assert_eq!(
open_provider_catalog_credential(&state, "provider-1", "key-1", field, &sealed,)
.expect("credential should open")
.plaintext,
"secret-value"
);
assert!(open_provider_catalog_credential(
&state,
"provider-2",
"key-1",
field,
&sealed,
)
.is_err());
assert!(open_provider_catalog_credential(
&state,
"provider-1",
"key-2",
field,
&sealed,
)
.is_err());
let other_field = match field {
ProviderCatalogCredentialField::ApiKey => {
ProviderCatalogCredentialField::AuthConfig
}
ProviderCatalogCredentialField::AuthConfig => {
ProviderCatalogCredentialField::ApiKey
}
};
assert!(open_provider_catalog_credential(
&state,
"provider-1",
"key-1",
other_field,
&sealed,
)
.is_err());
}
}
#[test]
fn v2_reader_rejects_tampering_unknown_envelopes_and_stripping() {
let state = state_with_encryption_key();
let sealed = seal_provider_catalog_credential(
&state,
"provider-1",
"key-1",
ProviderCatalogCredentialField::ApiKey,
"secret-value",
)
.expect("credential should seal");
let mut tampered = sealed.clone().into_bytes();
let last = tampered
.last_mut()
.expect("sealed value should not be empty");
*last = if *last == b'A' { b'B' } else { b'A' };
let tampered = String::from_utf8(tampered).expect("ciphertext should remain UTF-8");
assert!(open_provider_catalog_credential(
&state,
"provider-1",
"key-1",
ProviderCatalogCredentialField::ApiKey,
&tampered,
)
.is_err());
for invalid in [
"aether-provider-catalog-credential-v3:unknown",
"aether-payment-gateway-secret-v2:foreign",
"plaintext-secret",
] {
assert!(open_provider_catalog_credential(
&state,
"provider-1",
"key-1",
ProviderCatalogCredentialField::ApiKey,
invalid,
)
.is_err());
}
let other_runtime = seal_runtime_secret_payload(&state, "another-purpose", "secret")
.expect("runtime secret should seal");
assert!(open_provider_catalog_credential(
&state,
"provider-1",
"key-1",
ProviderCatalogCredentialField::ApiKey,
&other_runtime,
)
.is_err());
let stripped = sealed
.strip_prefix(PROVIDER_CATALOG_CREDENTIAL_ENVELOPE_V2)
.and_then(|value| value.strip_prefix("aether-runtime-secret-v1:"))
.expect("test value should contain both envelope layers");
assert!(open_provider_catalog_credential(
&state,
"provider-1",
"key-1",
ProviderCatalogCredentialField::ApiKey,
stripped,
)
.is_err());
}
#[test]
fn only_real_legacy_fernet_values_are_migrated() {
let state = state_with_encryption_key();
let legacy = encrypt_catalog_secret_with_fallbacks(&state, "legacy-secret")
.expect("legacy credential should encrypt");
let opened = open_provider_catalog_credential(
&state,
"provider-1",
"key-1",
ProviderCatalogCredentialField::AuthConfig,
&legacy,
)
.expect("legacy credential should migrate");
assert_eq!(opened.plaintext, "legacy-secret");
assert!(opened.migration_required);
assert!(opened
.protected
.starts_with(PROVIDER_CATALOG_CREDENTIAL_ENVELOPE_V2));
}
}
@@ -0,0 +1,381 @@
use aether_crypto::looks_like_python_fernet_ciphertext;
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::AppState;
use super::{
decrypt_catalog_secret_with_fallbacks, open_runtime_secret_payload, seal_runtime_secret_payload,
};
const PROVIDER_OPS_CREDENTIAL_ENVELOPE_FAMILY: &str = "aether-provider-ops-credential-";
const PROVIDER_OPS_CREDENTIAL_ENVELOPE_V2: &str = "aether-provider-ops-credential-v2:";
const PROVIDER_OPS_CREDENTIAL_PURPOSE_V2: &str = "provider-ops-credential-bound-v2";
pub(crate) const PROVIDER_OPS_PERSISTENT_SECRET_FIELDS: &[&str] = &[
"api_key",
"password",
"refresh_token",
"session_token",
"session_cookie",
"token_cookie",
"auth_cookie",
"cookie_string",
"cookie",
];
pub(crate) const PROVIDER_OPS_TRANSIENT_SECRET_FIELDS: &[&str] = &["_cached_access_token"];
pub(crate) const PROVIDER_OPS_TRANSIENT_METADATA_FIELDS: &[&str] = &["_cached_token_expires_at"];
pub(crate) fn provider_ops_credential_field_is_secret(field: &str) -> bool {
PROVIDER_OPS_PERSISTENT_SECRET_FIELDS.contains(&field)
|| PROVIDER_OPS_TRANSIENT_SECRET_FIELDS.contains(&field)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProviderOpsCanonicalDestination {
canonical_base_url: String,
canonical_origin: String,
}
impl ProviderOpsCanonicalDestination {
pub(crate) fn base_url(&self) -> &str {
&self.canonical_base_url
}
pub(crate) fn origin(&self) -> &str {
&self.canonical_origin
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProviderOpsCredentialBinding {
pub(crate) provider_id: String,
pub(crate) architecture_id: String,
pub(crate) auth_type: String,
pub(crate) destination: ProviderOpsCanonicalDestination,
pub(crate) outbound_policy_digest: String,
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct ProviderOpsCredentialProjection {
pub(crate) plaintext: String,
pub(crate) protected: String,
pub(crate) migration_required: bool,
}
pub(crate) fn canonicalize_provider_ops_base_url(
raw: &str,
) -> Result<ProviderOpsCanonicalDestination, &'static str> {
let raw = raw.trim();
if raw.is_empty() {
return Err("Provider Ops base_url 不能为空");
}
let mut parsed = url::Url::parse(raw).map_err(|_| "Provider Ops base_url 无效")?;
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
return Err("Provider Ops base_url 必须是有效的 HTTP(S) URL");
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err("Provider Ops base_url 不允许包含认证信息");
}
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err("Provider Ops base_url 不允许包含 query 或 fragment");
}
let normalized_path = parsed.path().trim_end_matches('/').to_string();
parsed.set_path(if normalized_path.is_empty() {
"/"
} else {
normalized_path.as_str()
});
let canonical_origin = parsed.origin().ascii_serialization();
let mut canonical_base_url = parsed.to_string();
if parsed.path() == "/" {
canonical_base_url.truncate(canonical_base_url.len().saturating_sub(1));
}
Ok(ProviderOpsCanonicalDestination {
canonical_base_url,
canonical_origin,
})
}
pub(crate) fn resolve_provider_ops_same_origin_url(
destination: &ProviderOpsCanonicalDestination,
endpoint: &str,
) -> Result<String, &'static str> {
let endpoint = endpoint.trim();
if endpoint.is_empty() {
return Ok(destination.canonical_base_url.clone());
}
if endpoint.starts_with("//") {
return Err("Provider Ops endpoint 不允许使用 scheme-relative URL");
}
let candidate = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
url::Url::parse(endpoint).map_err(|_| "Provider Ops endpoint URL 无效")?
} else {
if !endpoint.starts_with('/') {
return Err("Provider Ops endpoint 必须是以 / 开头的路径或同源绝对 URL");
}
let base = url::Url::parse(&format!("{}/", destination.canonical_origin))
.map_err(|_| "Provider Ops canonical origin 无效")?;
base.join(endpoint)
.map_err(|_| "Provider Ops endpoint 路径无效")?
};
if !matches!(candidate.scheme(), "http" | "https")
|| candidate.host_str().is_none()
|| !candidate.username().is_empty()
|| candidate.password().is_some()
|| candidate.fragment().is_some()
{
return Err("Provider Ops endpoint URL 无效");
}
if candidate.origin().ascii_serialization() != destination.canonical_origin {
return Err("Provider Ops endpoint 必须与 base_url 同源");
}
Ok(candidate.to_string())
}
pub(crate) fn provider_ops_outbound_policy_digest(
architecture_id: &str,
auth_type: &str,
destination: &ProviderOpsCanonicalDestination,
connector_config: Option<&Value>,
actions: Option<&Value>,
) -> String {
let policy = serde_json::json!({
"architecture_id": architecture_id,
"auth_type": auth_type,
"canonical_base_url": destination.base_url(),
"canonical_origin": destination.origin(),
"connector_config": connector_config.cloned().unwrap_or_else(|| serde_json::json!({})),
"actions": actions.cloned().unwrap_or_else(|| serde_json::json!({})),
});
let mut canonical = String::new();
append_canonical_json(&policy, &mut canonical);
format!("{:x}", Sha256::digest(canonical.as_bytes()))
}
pub(crate) fn provider_ops_credential_binding_from_config(
provider_id: &str,
provider_ops_config: &serde_json::Map<String, Value>,
effective_base_url: &str,
) -> Result<ProviderOpsCredentialBinding, &'static str> {
if provider_id.trim().is_empty() {
return Err("Provider Ops provider_id 不能为空");
}
let raw_architecture_id = provider_ops_config
.get("architecture_id")
.and_then(Value::as_str)
.unwrap_or("generic_api")
.trim();
let architecture_id =
aether_admin::provider::ops::normalize_architecture_id(raw_architecture_id);
if !raw_architecture_id.is_empty() && raw_architecture_id != architecture_id {
return Err("Provider Ops architecture_id 无效");
}
let connector = provider_ops_config
.get("connector")
.and_then(Value::as_object);
let auth_type = connector
.and_then(|connector| connector.get("auth_type"))
.and_then(Value::as_str)
.unwrap_or("api_key")
.trim();
if !aether_admin::provider::ops::admin_provider_ops_is_supported_auth_type(auth_type) {
return Err("Provider Ops connector.auth_type 无效");
}
let destination = canonicalize_provider_ops_base_url(effective_base_url)?;
let outbound_policy_digest = provider_ops_outbound_policy_digest(
architecture_id,
auth_type,
&destination,
connector.and_then(|connector| connector.get("config")),
provider_ops_config.get("actions"),
);
Ok(ProviderOpsCredentialBinding {
provider_id: provider_id.to_string(),
architecture_id: architecture_id.to_string(),
auth_type: auth_type.to_string(),
destination,
outbound_policy_digest,
})
}
pub(crate) fn seal_provider_ops_credential(
state: &AppState,
binding: &ProviderOpsCredentialBinding,
field: &str,
plaintext: &str,
) -> Result<String, &'static str> {
if plaintext.contains('\0') {
return Err("Provider Ops credential 包含保留分隔符");
}
let purpose = provider_ops_credential_purpose(binding, field)?;
let sealed = seal_runtime_secret_payload(state, &purpose, plaintext)
.ok_or("gateway 未配置 Provider Ops 加密密钥")?;
Ok(format!("{PROVIDER_OPS_CREDENTIAL_ENVELOPE_V2}{sealed}"))
}
pub(crate) fn open_provider_ops_credential(
state: &AppState,
binding: &ProviderOpsCredentialBinding,
field: &str,
stored: &str,
) -> Result<ProviderOpsCredentialProjection, &'static str> {
let purpose = provider_ops_credential_purpose(binding, field)?;
if let Some(sealed) = stored.strip_prefix(PROVIDER_OPS_CREDENTIAL_ENVELOPE_V2) {
let plaintext = open_runtime_secret_payload(state, &purpose, sealed)
.ok_or("Provider Ops credential 认证或绑定校验失败")?;
if plaintext.contains('\0') {
return Err("Provider Ops credential 包含保留分隔符");
}
return Ok(ProviderOpsCredentialProjection {
plaintext,
protected: stored.to_string(),
migration_required: false,
});
}
if stored.starts_with(PROVIDER_OPS_CREDENTIAL_ENVELOPE_FAMILY) {
return Err("不支持的 Provider Ops credential envelope 版本");
}
if stored.starts_with("aether-") {
return Err("Aether secret envelope 的 Provider Ops 记录绑定错误");
}
let plaintext = if looks_like_python_fernet_ciphertext(stored) {
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), stored)
.ok_or("历史 Provider Ops credential 密文无法解密")?
} else {
stored.to_string()
};
if plaintext.contains('\0') {
return Err("历史 Provider Ops credential 包含保留分隔符");
}
let protected = seal_provider_ops_credential(state, binding, field, &plaintext)?;
Ok(ProviderOpsCredentialProjection {
plaintext,
protected,
migration_required: !stored.is_empty(),
})
}
fn provider_ops_credential_purpose(
binding: &ProviderOpsCredentialBinding,
field: &str,
) -> Result<String, &'static str> {
for value in [
binding.provider_id.as_str(),
binding.architecture_id.as_str(),
binding.auth_type.as_str(),
binding.destination.base_url(),
binding.destination.origin(),
binding.outbound_policy_digest.as_str(),
field,
] {
if value.is_empty() || value.contains('\0') {
return Err("Provider Ops credential binding 无效");
}
}
Ok(format!(
"{PROVIDER_OPS_CREDENTIAL_PURPOSE_V2}\0provider-id-bytes={}\0{}\0architecture-id-bytes={}\0{}\0auth-type-bytes={}\0{}\0base-url-bytes={}\0{}\0origin-bytes={}\0{}\0policy-sha256={}\0field-bytes={}\0{}",
binding.provider_id.len(),
binding.provider_id,
binding.architecture_id.len(),
binding.architecture_id,
binding.auth_type.len(),
binding.auth_type,
binding.destination.base_url().len(),
binding.destination.base_url(),
binding.destination.origin().len(),
binding.destination.origin(),
binding.outbound_policy_digest,
field.len(),
field,
))
}
fn append_canonical_json(value: &Value, output: &mut String) {
match value {
Value::Null => output.push_str("null"),
Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
Value::Number(value) => output.push_str(&value.to_string()),
Value::String(value) => output.push_str(
&serde_json::to_string(value).expect("serializing a JSON string cannot fail"),
),
Value::Array(items) => {
output.push('[');
for (index, item) in items.iter().enumerate() {
if index > 0 {
output.push(',');
}
append_canonical_json(item, output);
}
output.push(']');
}
Value::Object(map) => {
output.push('{');
let mut keys = map.keys().collect::<Vec<_>>();
keys.sort_unstable();
for (index, key) in keys.into_iter().enumerate() {
if index > 0 {
output.push(',');
}
output.push_str(
&serde_json::to_string(key).expect("serializing a JSON key cannot fail"),
);
output.push(':');
append_canonical_json(&map[key], output);
}
output.push('}');
}
}
}
#[cfg(test)]
mod tests {
use super::{
canonicalize_provider_ops_base_url, provider_ops_outbound_policy_digest,
resolve_provider_ops_same_origin_url,
};
#[test]
fn canonical_destination_normalizes_and_enforces_origin() {
let destination = canonicalize_provider_ops_base_url(" HTTPS://Example.COM:443/api/ ")
.expect("base URL should normalize");
assert_eq!(destination.base_url(), "https://example.com/api");
assert_eq!(destination.origin(), "https://example.com");
assert!(resolve_provider_ops_same_origin_url(&destination, "/v1/me").is_ok());
assert!(
resolve_provider_ops_same_origin_url(&destination, "https://example.com/v1/me").is_ok()
);
assert!(
resolve_provider_ops_same_origin_url(&destination, "https://evil.test/v1/me").is_err()
);
assert!(resolve_provider_ops_same_origin_url(&destination, "//evil.test/v1/me").is_err());
}
#[test]
fn policy_digest_is_independent_of_json_object_order() {
let destination = canonicalize_provider_ops_base_url("https://example.com")
.expect("base URL should normalize");
let left = serde_json::json!({"b": 2, "a": 1});
let right = serde_json::json!({"a": 1, "b": 2});
assert_eq!(
provider_ops_outbound_policy_digest(
"generic_api",
"api_key",
&destination,
Some(&left),
None,
),
provider_ops_outbound_policy_digest(
"generic_api",
"api_key",
&destination,
Some(&right),
None,
)
);
}
}
@@ -93,15 +93,29 @@ pub(crate) fn sanitize_upstream_path_and_query(
let Some(decision) = decision else {
return base;
};
if !rust_auth_terminates_provider_credentials(Some(decision))
|| decision.route_family.as_deref() != Some("gemini")
{
if !rust_auth_terminates_provider_credentials(Some(decision)) {
return base;
}
strip_query_param(&base, "key")
}
pub(crate) fn security_log_url_origin(value: &str) -> String {
let Ok(parsed) = url::Url::parse(value.trim()) else {
return "-".to_string();
};
if !matches!(parsed.scheme(), "http" | "https") {
return "-".to_string();
}
let Some(host) = parsed.host_str() else {
return "-".to_string();
};
match parsed.port() {
Some(port) => format!("{}://{host}:{port}", parsed.scheme()),
None => format!("{}://{host}", parsed.scheme()),
}
}
pub(crate) fn strip_query_param(path_and_query: &str, key_to_strip: &str) -> String {
let Some((path, query)) = path_and_query.split_once('?') else {
return path_and_query.to_string();
@@ -531,3 +545,68 @@ pub(crate) fn local_proxy_route_requires_buffered_body(
|| internal_proxy_local_requires_buffered_body(request_context)
|| public_support_local_requires_buffered_body(request_context)
}
#[cfg(test)]
mod tests {
use super::sanitize_upstream_path_and_query;
use crate::control::{GatewayControlAuthContext, GatewayControlDecision};
fn authenticated_ai_decision(
route_family: &str,
path_and_query: &str,
) -> GatewayControlDecision {
let (path, query) = path_and_query
.split_once('?')
.map_or((path_and_query, None), |(path, query)| (path, Some(query)));
let mut decision = GatewayControlDecision::synthetic(
path,
Some("ai_public".to_string()),
Some(route_family.to_string()),
Some("chat".to_string()),
Some(format!("{route_family}:chat")),
);
decision.public_query_string = query.map(str::to_string);
decision.auth_context = Some(GatewayControlAuthContext {
user_id: "user-1".to_string(),
api_key_id: "key-1".to_string(),
username: None,
api_key_name: None,
balance_remaining: None,
access_allowed: true,
user_rate_limit: None,
api_key_rate_limit: None,
api_key_is_standalone: false,
admin_bypass_limits: false,
local_rejection: None,
allowed_models: None,
ip_rules: None,
verified_api_key_hash: None,
});
decision
}
#[test]
fn authenticated_ai_routes_strip_query_api_keys_across_formats() {
for route_family in ["openai", "claude", "gemini"] {
let decision = authenticated_ai_decision(
route_family,
"/v1/chat/completions?key=client-secret&stream=true",
);
assert_eq!(
sanitize_upstream_path_and_query(
Some(&decision),
"/v1/chat/completions?key=client-secret&stream=true",
),
"/v1/chat/completions?stream=true"
);
}
}
#[test]
fn unauthenticated_routes_preserve_query_parameters() {
assert_eq!(
sanitize_upstream_path_and_query(None, "/v1/chat/completions?key=passthrough"),
"/v1/chat/completions?key=passthrough"
);
}
}
@@ -0,0 +1,130 @@
use crate::AppState;
use super::catalog::{
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_configured_key_fallbacks,
encrypt_catalog_secret_with_fallbacks,
};
const RUNTIME_SECRET_ENVELOPE_PREFIX: &str = "aether-runtime-secret-v1:";
pub(crate) fn seal_runtime_secret_payload(
state: &AppState,
purpose: &str,
plaintext: &str,
) -> Option<String> {
if purpose.is_empty() || plaintext.contains('\0') {
return None;
}
let protected = format!("{purpose}\0{plaintext}");
encrypt_catalog_secret_with_fallbacks(state, &protected)
.map(|ciphertext| format!("{RUNTIME_SECRET_ENVELOPE_PREFIX}{ciphertext}"))
}
pub(crate) fn seal_runtime_secret_payload_with_encryption_key(
encryption_key: Option<&str>,
purpose: &str,
plaintext: &str,
) -> Option<String> {
if purpose.is_empty() || plaintext.contains('\0') {
return None;
}
let protected = format!("{purpose}\0{plaintext}");
encrypt_catalog_secret_with_configured_key_fallbacks(encryption_key, &protected)
.map(|ciphertext| format!("{RUNTIME_SECRET_ENVELOPE_PREFIX}{ciphertext}"))
}
pub(crate) fn open_runtime_secret_payload(
state: &AppState,
purpose: &str,
stored: &str,
) -> Option<String> {
open_runtime_secret_payload_with_encryption_key(state.encryption_key(), purpose, stored)
}
pub(crate) fn open_runtime_secret_payload_with_encryption_key(
encryption_key: Option<&str>,
purpose: &str,
stored: &str,
) -> Option<String> {
if purpose.is_empty() {
return None;
}
let ciphertext = stored.strip_prefix(RUNTIME_SECRET_ENVELOPE_PREFIX)?;
let protected = decrypt_catalog_secret_with_fallbacks(encryption_key, ciphertext)?;
let plaintext = protected.strip_prefix(purpose)?.strip_prefix('\0')?;
(!plaintext.contains('\0')).then(|| plaintext.to_owned())
}
pub(crate) fn runtime_secret_payload_is_sealed(value: &str) -> bool {
value.starts_with(RUNTIME_SECRET_ENVELOPE_PREFIX)
}
#[cfg(test)]
mod tests {
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use super::{open_runtime_secret_payload, seal_runtime_secret_payload};
use crate::{data::GatewayDataState, AppState};
fn state_with_encryption_key() -> AppState {
AppState::new()
.expect("test state should build")
.with_data_state_for_tests(
GatewayDataState::disabled()
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
)
}
#[test]
fn runtime_secret_envelope_hides_payload_and_binds_purpose() {
let state = state_with_encryption_key();
let payload = r#"{"pkce_verifier":"pkce-runtime-secret-marker"}"#;
let sealed = seal_runtime_secret_payload(&state, "identity-oauth-state", payload)
.expect("runtime secret should encrypt");
assert!(sealed.starts_with("aether-runtime-secret-v1:"));
assert!(!sealed.contains("pkce-runtime-secret-marker"));
assert_eq!(
open_runtime_secret_payload(&state, "identity-oauth-state", &sealed).as_deref(),
Some(payload)
);
assert!(open_runtime_secret_payload(&state, "provider-oauth-state", &sealed).is_none());
}
#[test]
fn runtime_secret_reader_rejects_legacy_plaintext() {
let state = state_with_encryption_key();
let legacy = r#"{"pkce_verifier":"legacy-verifier"}"#;
assert!(open_runtime_secret_payload(&state, "identity-oauth-state", legacy).is_none());
}
#[test]
fn runtime_secret_envelope_requires_exact_purpose_boundary() {
let state = state_with_encryption_key();
assert!(seal_runtime_secret_payload(&state, "", "secret").is_none());
assert!(seal_runtime_secret_payload(&state, "purpose", "prefix\0secret").is_none());
// Structured, field-bound purposes intentionally contain NUL
// separators. A shorter prefix must not be accepted as that same
// purpose, while the complete structured purpose remains readable.
let structured_purpose = "purpose\0prefix";
let structured = seal_runtime_secret_payload(&state, structured_purpose, "secret")
.expect("structured purpose should encrypt");
assert_eq!(
open_runtime_secret_payload(&state, structured_purpose, &structured).as_deref(),
Some("secret")
);
assert!(open_runtime_secret_payload(&state, "purpose", &structured).is_none());
// A legacy payload with an extra NUL in the plaintext is rejected;
// the final NUL is not allowed to silently redefine the boundary.
let protected = "purpose\0prefix\0secret";
let ciphertext = super::encrypt_catalog_secret_with_fallbacks(&state, protected)
.expect("historical ambiguous payload should encrypt");
let stored = format!("{}{}", super::RUNTIME_SECRET_ENVELOPE_PREFIX, ciphertext);
assert!(open_runtime_secret_payload(&state, "purpose", &stored).is_none());
}
}
File diff suppressed because it is too large Load Diff