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 { 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::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 { 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 { 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 { 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("".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 { 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 { 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:password@payments.example.test", "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}" ); } } }