mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
1433 lines
55 KiB
Rust
1433 lines
55 KiB
Rust
use super::catalog::decrypt_catalog_secret_with_fallbacks;
|
|
use super::runtime_secret::{open_runtime_secret_payload, seal_runtime_secret_payload};
|
|
use crate::{AppState, GatewayError};
|
|
use aether_crypto::looks_like_python_fernet_ciphertext;
|
|
use std::future::Future;
|
|
use url::Url;
|
|
|
|
const SYSTEM_CONFIG_SECRET_MIGRATION_RETRIES: usize = 8;
|
|
const LDAP_BIND_PASSWORD_MIGRATION_RETRIES: usize = 8;
|
|
const BARK_DEVICE_KEY_MIGRATION_RETRIES: usize = 8;
|
|
const SYSTEM_CONFIG_SECRET_ENVELOPE_FAMILY_PREFIX: &str = "aether-system-config-secret-";
|
|
const SYSTEM_CONFIG_SECRET_V2_PREFIX: &str = "aether-system-config-secret-v2:";
|
|
const SYSTEM_CONFIG_SECRET_BOUND_PURPOSE_VERSION: &str = "system-config-secret-bound-v2";
|
|
const SMTP_PASSWORD_V3_PREFIX: &str = "aether-smtp-password-v3:";
|
|
const SMTP_PASSWORD_BOUND_PURPOSE_V3: &str = "smtp-password-bound-v3";
|
|
const LDAP_BIND_PASSWORD_ENVELOPE_FAMILY_PREFIX: &str = "aether-ldap-bind-password-";
|
|
const LDAP_BIND_PASSWORD_V2_PREFIX: &str = "aether-ldap-bind-password-v2:";
|
|
const LDAP_BIND_PASSWORD_V3_PREFIX: &str = "aether-ldap-bind-password-v3:";
|
|
const LDAP_BIND_PASSWORD_BOUND_PURPOSE: &str = "ldap-bind-password-bound-v2";
|
|
const LDAP_BIND_PASSWORD_BOUND_PURPOSE_V3: &str = "ldap-bind-password-bound-v3";
|
|
const BARK_DEVICE_KEY_V2_PREFIX: &str = "aether-bark-device-key-v2:";
|
|
const BARK_DEVICE_KEY_BOUND_PURPOSE_V2: &str = "bark-device-key-bound-v2";
|
|
const BARK_DEVICE_KEY_CONFIG_KEY: &str = "module.bark_push.device_key";
|
|
const RUNTIME_SECRET_ENVELOPE_FAMILY_PREFIX: &str = "aether-runtime-secret-";
|
|
|
|
fn system_config_secret_purpose(key: &str) -> String {
|
|
let key = aether_admin::system::normalize_admin_system_config_key(key);
|
|
format!(
|
|
"{SYSTEM_CONFIG_SECRET_BOUND_PURPOSE_VERSION}\0key-bytes={}\0{key}",
|
|
key.len()
|
|
)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct SmtpPasswordBinding {
|
|
pub(crate) host: String,
|
|
pub(crate) port: u16,
|
|
pub(crate) user: String,
|
|
pub(crate) use_tls: bool,
|
|
pub(crate) use_ssl: bool,
|
|
}
|
|
|
|
pub(crate) fn smtp_password_binding(
|
|
host: &str,
|
|
port: u16,
|
|
user: Option<&str>,
|
|
use_tls: bool,
|
|
use_ssl: bool,
|
|
) -> Option<SmtpPasswordBinding> {
|
|
let host = host.trim();
|
|
let user = user.unwrap_or("").trim();
|
|
if host.is_empty()
|
|
|| user.is_empty()
|
|
|| host.contains('\0')
|
|
|| user.contains('\0')
|
|
|| host.bytes().any(|byte| matches!(byte, b'\r' | b'\n'))
|
|
|| user.bytes().any(|byte| matches!(byte, b'\r' | b'\n'))
|
|
{
|
|
return None;
|
|
}
|
|
Some(SmtpPasswordBinding {
|
|
host: host.to_ascii_lowercase(),
|
|
port,
|
|
user: user.to_string(),
|
|
use_tls,
|
|
use_ssl,
|
|
})
|
|
}
|
|
|
|
fn smtp_password_purpose(binding: &SmtpPasswordBinding) -> String {
|
|
format!(
|
|
"{SMTP_PASSWORD_BOUND_PURPOSE_V3}\0host-bytes={}\0{}\0port={}\0user-bytes={}\0{}\0tls={}\0ssl={}\0field-bytes={}\0smtp_password",
|
|
binding.host.len(),
|
|
binding.host,
|
|
binding.port,
|
|
binding.user.len(),
|
|
binding.user,
|
|
if binding.use_tls { 1 } else { 0 },
|
|
if binding.use_ssl { 1 } else { 0 },
|
|
"smtp_password".len(),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn encrypt_smtp_password(
|
|
state: &AppState,
|
|
binding: &SmtpPasswordBinding,
|
|
plaintext: &str,
|
|
) -> Option<String> {
|
|
if plaintext.contains('\0') {
|
|
return None;
|
|
}
|
|
seal_runtime_secret_payload(state, &smtp_password_purpose(binding), plaintext)
|
|
.map(|sealed| format!("{SMTP_PASSWORD_V3_PREFIX}{sealed}"))
|
|
}
|
|
|
|
fn decrypt_smtp_password_v3(
|
|
state: &AppState,
|
|
binding: &SmtpPasswordBinding,
|
|
stored: &str,
|
|
) -> Option<String> {
|
|
let sealed = stored.strip_prefix(SMTP_PASSWORD_V3_PREFIX)?;
|
|
open_runtime_secret_payload(state, &smtp_password_purpose(binding), sealed)
|
|
.filter(|plaintext| !plaintext.contains('\0'))
|
|
}
|
|
|
|
pub(crate) async fn decrypt_or_migrate_smtp_password(
|
|
state: &AppState,
|
|
binding: &SmtpPasswordBinding,
|
|
stored: String,
|
|
) -> Result<String, GatewayError> {
|
|
if let Some(plaintext) = decrypt_smtp_password_v3(state, binding, stored.trim()) {
|
|
return Ok(plaintext);
|
|
}
|
|
if stored.trim().starts_with(SMTP_PASSWORD_V3_PREFIX)
|
|
|| stored.trim().starts_with("aether-smtp-password-")
|
|
{
|
|
return Err(system_config_secret_error(
|
|
"stored SMTP password cannot be decrypted",
|
|
));
|
|
}
|
|
let plaintext = decrypt_system_config_secret(state, "smtp_password", stored.trim())
|
|
.or_else(|| {
|
|
if stored_secret_uses_known_envelope_family(stored.trim()) {
|
|
return None;
|
|
}
|
|
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), stored.trim()).or_else(
|
|
|| {
|
|
(!stored.trim().is_empty()
|
|
&& !looks_like_python_fernet_ciphertext(stored.trim()))
|
|
.then(|| stored.trim().to_string())
|
|
},
|
|
)
|
|
})
|
|
.ok_or_else(|| system_config_secret_error("stored SMTP password cannot be decrypted"))?;
|
|
if plaintext.contains('\0') {
|
|
return Err(system_config_secret_error(
|
|
"stored SMTP password contains reserved secret framing",
|
|
));
|
|
}
|
|
let replacement = encrypt_smtp_password(state, binding, &plaintext)
|
|
.ok_or_else(|| system_config_secret_error("SMTP password migration is unavailable"))?;
|
|
if state
|
|
.compare_and_set_system_config_string_value("smtp_password", stored.trim(), &replacement)
|
|
.await?
|
|
{
|
|
return Ok(plaintext);
|
|
}
|
|
let current = state
|
|
.read_system_config_json_value_strong("smtp_password")
|
|
.await?
|
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
|
.ok_or_else(|| system_config_secret_error("stored SMTP password is unavailable"))?;
|
|
decrypt_smtp_password_v3(state, binding, current.trim())
|
|
.ok_or_else(|| system_config_secret_error("stored SMTP password changed during migration"))
|
|
}
|
|
|
|
pub(crate) fn encrypt_system_config_secret(
|
|
state: &AppState,
|
|
key: &str,
|
|
plaintext: &str,
|
|
) -> Option<String> {
|
|
if plaintext.contains('\0') {
|
|
return None;
|
|
}
|
|
let purpose = system_config_secret_purpose(key);
|
|
seal_runtime_secret_payload(state, &purpose, plaintext)
|
|
.map(|sealed| format!("{SYSTEM_CONFIG_SECRET_V2_PREFIX}{sealed}"))
|
|
}
|
|
|
|
pub(crate) fn decrypt_system_config_secret(
|
|
state: &AppState,
|
|
key: &str,
|
|
stored: &str,
|
|
) -> Option<String> {
|
|
let sealed = stored.strip_prefix(SYSTEM_CONFIG_SECRET_V2_PREFIX)?;
|
|
let purpose = system_config_secret_purpose(key);
|
|
open_runtime_secret_payload(state, &purpose, sealed)
|
|
.filter(|plaintext| !plaintext.contains('\0'))
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct LdapBindPasswordBinding {
|
|
server_url: String,
|
|
bind_dn: String,
|
|
base_dn: String,
|
|
use_starttls: bool,
|
|
}
|
|
|
|
/// Parse and canonicalize an LDAP transport endpoint.
|
|
///
|
|
/// LDAP simple binds carry credentials, so plaintext `ldap://` is only valid
|
|
/// when StartTLS is explicitly requested. The parser deliberately does not
|
|
/// reject private or loopback hosts: LDAP deployments commonly run on an
|
|
/// internal network and this function is a transport-integrity check, not an
|
|
/// outbound SSRF policy. The `mockldap` scheme exists only in test builds.
|
|
pub(crate) fn normalize_ldap_transport_server_url(raw: &str, use_starttls: bool) -> Option<String> {
|
|
#[cfg(test)]
|
|
{
|
|
// Gateway unit/integration fixtures use an in-process mock endpoint. Keep
|
|
// this exception behind the gateway test configuration; production code
|
|
// always uses the strict parser without custom schemes.
|
|
aether_admin::system::normalize_ldap_transport_server_url_for_tests(raw, use_starttls)
|
|
}
|
|
#[cfg(not(test))]
|
|
{
|
|
aether_admin::system::normalize_ldap_transport_server_url(raw, use_starttls)
|
|
}
|
|
}
|
|
|
|
/// Return whether an LDAP user search filter is safe to use with the escaped
|
|
/// `{username}` substitution performed by the login path. LDAP filters are
|
|
/// always parenthesized; keeping the same bounded shape at every config
|
|
/// ingress prevents malformed/imported values from reaching the query layer.
|
|
pub(crate) fn ldap_search_filter_is_valid(value: &str) -> bool {
|
|
aether_admin::system::ldap_search_filter_is_valid(value)
|
|
}
|
|
|
|
pub(crate) fn ldap_distinguished_name_is_valid(value: &str) -> bool {
|
|
aether_admin::system::ldap_distinguished_name_is_valid(value)
|
|
}
|
|
|
|
pub(crate) fn ldap_attribute_description_is_valid(value: &str) -> bool {
|
|
aether_admin::system::ldap_attribute_description_is_valid(value)
|
|
}
|
|
|
|
pub(crate) fn ldap_module_config_is_valid(
|
|
config: Option<&aether_data::repository::auth_modules::StoredLdapModuleConfig>,
|
|
) -> bool {
|
|
config.is_some_and(|config| {
|
|
normalize_ldap_transport_server_url(&config.server_url, config.use_starttls).is_some()
|
|
&& aether_admin::system::ldap_module_config_fields_are_valid(config)
|
|
})
|
|
}
|
|
|
|
fn canonical_ldap_server_url(raw: &str, use_starttls: bool) -> Option<String> {
|
|
normalize_ldap_transport_server_url(raw, use_starttls)
|
|
}
|
|
|
|
fn ldap_bind_password_binding(
|
|
config: &aether_data::repository::auth_modules::StoredLdapModuleConfig,
|
|
) -> Option<LdapBindPasswordBinding> {
|
|
let server_url = canonical_ldap_server_url(&config.server_url, config.use_starttls)?;
|
|
let bind_dn = config.bind_dn.trim();
|
|
let base_dn = config.base_dn.trim();
|
|
if !ldap_distinguished_name_is_valid(&config.bind_dn)
|
|
|| !ldap_distinguished_name_is_valid(&config.base_dn)
|
|
{
|
|
return None;
|
|
}
|
|
Some(LdapBindPasswordBinding {
|
|
server_url,
|
|
bind_dn: bind_dn.to_string(),
|
|
base_dn: base_dn.to_string(),
|
|
use_starttls: config.use_starttls,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn ldap_bind_password_binding_matches(
|
|
stored: &aether_data::repository::auth_modules::StoredLdapModuleConfig,
|
|
replacement: &aether_data::repository::auth_modules::StoredLdapModuleConfig,
|
|
) -> Result<bool, &'static str> {
|
|
let stored =
|
|
ldap_bind_password_binding(stored).ok_or("stored LDAP bind password binding is invalid")?;
|
|
let replacement = ldap_bind_password_binding(replacement)
|
|
.ok_or("replacement LDAP bind password binding is invalid")?;
|
|
Ok(stored == replacement)
|
|
}
|
|
|
|
fn ldap_bind_password_purpose_v3(binding: &LdapBindPasswordBinding) -> String {
|
|
format!(
|
|
"{LDAP_BIND_PASSWORD_BOUND_PURPOSE_V3}\0server-url-bytes={}\0{}\0bind-dn-bytes={}\0{}\0base-dn-bytes={}\0{}\0starttls={}\0field-bytes={}\0bind_password_encrypted",
|
|
binding.server_url.len(),
|
|
binding.server_url,
|
|
binding.bind_dn.len(),
|
|
binding.bind_dn,
|
|
binding.base_dn.len(),
|
|
binding.base_dn,
|
|
if binding.use_starttls { 1 } else { 0 },
|
|
"bind_password_encrypted".len(),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn encrypt_ldap_bind_password(
|
|
state: &AppState,
|
|
config: &aether_data::repository::auth_modules::StoredLdapModuleConfig,
|
|
plaintext: &str,
|
|
) -> Option<String> {
|
|
if plaintext.contains('\0') {
|
|
return None;
|
|
}
|
|
let binding = ldap_bind_password_binding(config)?;
|
|
seal_runtime_secret_payload(state, &ldap_bind_password_purpose_v3(&binding), plaintext)
|
|
.map(|sealed| format!("{LDAP_BIND_PASSWORD_V3_PREFIX}{sealed}"))
|
|
}
|
|
|
|
fn decrypt_ldap_bind_password_v2(state: &AppState, stored: &str) -> Option<String> {
|
|
let sealed = stored.strip_prefix(LDAP_BIND_PASSWORD_V2_PREFIX)?;
|
|
open_runtime_secret_payload(state, LDAP_BIND_PASSWORD_BOUND_PURPOSE, sealed)
|
|
.filter(|plaintext| !plaintext.contains('\0'))
|
|
}
|
|
|
|
fn decrypt_ldap_bind_password_v3(
|
|
state: &AppState,
|
|
config: &aether_data::repository::auth_modules::StoredLdapModuleConfig,
|
|
stored: &str,
|
|
) -> Option<String> {
|
|
let sealed = stored.strip_prefix(LDAP_BIND_PASSWORD_V3_PREFIX)?;
|
|
let binding = ldap_bind_password_binding(config)?;
|
|
open_runtime_secret_payload(state, &ldap_bind_password_purpose_v3(&binding), sealed)
|
|
.filter(|plaintext| !plaintext.contains('\0'))
|
|
}
|
|
|
|
fn stored_secret_uses_known_envelope_family(value: &str) -> bool {
|
|
value.starts_with(SYSTEM_CONFIG_SECRET_ENVELOPE_FAMILY_PREFIX)
|
|
|| value.starts_with(LDAP_BIND_PASSWORD_ENVELOPE_FAMILY_PREFIX)
|
|
|| value.starts_with(RUNTIME_SECRET_ENVELOPE_FAMILY_PREFIX)
|
|
|| value.starts_with("aether-")
|
|
}
|
|
|
|
pub(crate) fn module_available_from_env(env_key: &str, default_available: bool) -> bool {
|
|
match std::env::var(env_key) {
|
|
Ok(value) => matches!(
|
|
value.trim().to_ascii_lowercase().as_str(),
|
|
"true" | "1" | "yes"
|
|
),
|
|
Err(_) => default_available,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn system_config_bool(value: Option<&serde_json::Value>, default: bool) -> bool {
|
|
match value {
|
|
Some(serde_json::Value::Bool(value)) => *value,
|
|
Some(serde_json::Value::Number(value)) => {
|
|
value.as_i64().map(|value| value != 0).unwrap_or(default)
|
|
}
|
|
Some(serde_json::Value::String(value)) => {
|
|
match value.trim().to_ascii_lowercase().as_str() {
|
|
"true" | "1" | "yes" | "on" => true,
|
|
"false" | "0" | "no" | "off" => false,
|
|
_ => default,
|
|
}
|
|
}
|
|
_ => default,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn system_config_string(value: Option<&serde_json::Value>) -> Option<String> {
|
|
match value {
|
|
Some(serde_json::Value::String(value)) => {
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
None
|
|
} else {
|
|
Some(value.to_string())
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct BarkDeviceKeyBinding {
|
|
pub(crate) server_url: String,
|
|
}
|
|
|
|
/// Canonicalize the Bark base URL before it participates in a secret binding.
|
|
/// Host spelling and default ports are normalized so equivalent destinations
|
|
/// use the same binding, while credentials and URL-controlled request data are
|
|
/// rejected entirely.
|
|
pub(crate) fn canonical_bark_server_url(raw: &str) -> Option<String> {
|
|
if raw.contains('\0') {
|
|
return None;
|
|
}
|
|
let raw = raw.trim().trim_end_matches('/');
|
|
if raw.is_empty() || raw.contains('@') {
|
|
return None;
|
|
}
|
|
let mut parsed = Url::parse(raw).ok()?;
|
|
if !matches!(parsed.scheme(), "https" | "http")
|
|
|| parsed.host_str().is_none()
|
|
|| !parsed.username().is_empty()
|
|
|| parsed.password().is_some()
|
|
|| parsed.query().is_some()
|
|
|| parsed.fragment().is_some()
|
|
{
|
|
return None;
|
|
}
|
|
let host = parsed
|
|
.host_str()?
|
|
.trim_end_matches('.')
|
|
.to_ascii_lowercase();
|
|
if host.is_empty() {
|
|
return None;
|
|
}
|
|
parsed.set_host(Some(&host)).ok()?;
|
|
let default_port = if parsed.scheme() == "https" { 443 } else { 80 };
|
|
if parsed.port() == Some(default_port) {
|
|
parsed.set_port(None).ok()?;
|
|
}
|
|
Some(parsed.as_str().trim_end_matches('/').to_string())
|
|
}
|
|
|
|
pub(crate) fn bark_device_key_binding(server_url: &str) -> Option<BarkDeviceKeyBinding> {
|
|
Some(BarkDeviceKeyBinding {
|
|
server_url: canonical_bark_server_url(server_url)?,
|
|
})
|
|
}
|
|
|
|
fn bark_device_key_purpose(binding: &BarkDeviceKeyBinding) -> String {
|
|
format!(
|
|
"{BARK_DEVICE_KEY_BOUND_PURPOSE_V2}\0server-url-bytes={}\0{}\0field-bytes={}\0{}",
|
|
binding.server_url.len(),
|
|
binding.server_url,
|
|
BARK_DEVICE_KEY_CONFIG_KEY.len(),
|
|
BARK_DEVICE_KEY_CONFIG_KEY,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn encrypt_bark_device_key(
|
|
state: &AppState,
|
|
binding: &BarkDeviceKeyBinding,
|
|
plaintext: &str,
|
|
) -> Option<String> {
|
|
if plaintext.contains('\0') {
|
|
return None;
|
|
}
|
|
seal_runtime_secret_payload(state, &bark_device_key_purpose(binding), plaintext)
|
|
.map(|sealed| format!("{BARK_DEVICE_KEY_V2_PREFIX}{sealed}"))
|
|
}
|
|
|
|
fn decrypt_bark_device_key_v2(
|
|
state: &AppState,
|
|
binding: &BarkDeviceKeyBinding,
|
|
stored: &str,
|
|
) -> Option<String> {
|
|
let sealed = stored.strip_prefix(BARK_DEVICE_KEY_V2_PREFIX)?;
|
|
open_runtime_secret_payload(state, &bark_device_key_purpose(binding), sealed)
|
|
.filter(|plaintext| !plaintext.contains('\0'))
|
|
}
|
|
|
|
pub(crate) async fn decrypt_or_migrate_bark_device_key(
|
|
state: &AppState,
|
|
binding: &BarkDeviceKeyBinding,
|
|
stored_value: String,
|
|
) -> Result<String, GatewayError> {
|
|
let mut observed_raw = stored_value;
|
|
for _ in 0..BARK_DEVICE_KEY_MIGRATION_RETRIES {
|
|
let current_raw =
|
|
read_strong_system_config_secret(state, BARK_DEVICE_KEY_CONFIG_KEY).await?;
|
|
if current_raw != observed_raw {
|
|
observed_raw = current_raw;
|
|
continue;
|
|
}
|
|
let observed = observed_raw.trim();
|
|
if observed.starts_with(BARK_DEVICE_KEY_V2_PREFIX) {
|
|
return decrypt_bark_device_key_v2(state, binding, observed).ok_or_else(|| {
|
|
system_config_secret_error("stored Bark device key cannot be decrypted")
|
|
});
|
|
}
|
|
|
|
// Older Bark entries were written by the generic system-config secret
|
|
// path. They may migrate once, but all new writes are destination-bound.
|
|
let plaintext = if let Some(plaintext) =
|
|
decrypt_system_config_secret(state, BARK_DEVICE_KEY_CONFIG_KEY, observed)
|
|
{
|
|
plaintext
|
|
} else {
|
|
if stored_secret_uses_known_envelope_family(observed) {
|
|
return Err(system_config_secret_error(
|
|
"stored Bark device key cannot be decrypted",
|
|
));
|
|
}
|
|
match decrypt_catalog_secret_with_fallbacks(state.encryption_key(), observed) {
|
|
Some(plaintext) => plaintext,
|
|
None if looks_like_python_fernet_ciphertext(observed) => {
|
|
return Err(system_config_secret_error(
|
|
"stored Bark device key cannot be decrypted",
|
|
));
|
|
}
|
|
None => observed.to_string(),
|
|
}
|
|
};
|
|
if plaintext.contains('\0') {
|
|
return Err(system_config_secret_error(
|
|
"stored Bark device key contains reserved secret framing",
|
|
));
|
|
}
|
|
let encrypted = encrypt_bark_device_key(state, binding, &plaintext).ok_or_else(|| {
|
|
system_config_secret_error("Bark device key migration is unavailable")
|
|
})?;
|
|
if state
|
|
.compare_and_set_system_config_string_value(
|
|
BARK_DEVICE_KEY_CONFIG_KEY,
|
|
&observed_raw,
|
|
&encrypted,
|
|
)
|
|
.await?
|
|
{
|
|
return Ok(plaintext);
|
|
}
|
|
observed_raw = read_strong_system_config_secret(state, BARK_DEVICE_KEY_CONFIG_KEY).await?;
|
|
}
|
|
|
|
Err(system_config_secret_error(
|
|
"Bark device key migration did not stabilize",
|
|
))
|
|
}
|
|
|
|
pub(crate) async fn decrypt_or_migrate_system_config_secret(
|
|
state: &AppState,
|
|
key: &str,
|
|
stored_value: String,
|
|
) -> Result<String, GatewayError> {
|
|
decrypt_or_migrate_system_config_secret_with_before_compare(
|
|
state,
|
|
key,
|
|
stored_value,
|
|
|| async {},
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn decrypt_or_migrate_ldap_bind_password(
|
|
state: &AppState,
|
|
config: &aether_data::repository::auth_modules::StoredLdapModuleConfig,
|
|
) -> Result<Option<String>, GatewayError> {
|
|
let mut current = config.clone();
|
|
for _ in 0..LDAP_BIND_PASSWORD_MIGRATION_RETRIES {
|
|
let Some(observed_raw) = current
|
|
.bind_password_encrypted
|
|
.as_deref()
|
|
.filter(|value| !value.trim().is_empty())
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let observed = observed_raw.trim();
|
|
if observed.starts_with(LDAP_BIND_PASSWORD_V3_PREFIX) {
|
|
let plaintext =
|
|
decrypt_ldap_bind_password_v3(state, ¤t, observed).ok_or_else(|| {
|
|
ldap_bind_password_error("stored LDAP bind password cannot be decrypted")
|
|
})?;
|
|
return Ok((!plaintext.trim().is_empty()).then_some(plaintext));
|
|
}
|
|
if observed.starts_with(LDAP_BIND_PASSWORD_V2_PREFIX) {
|
|
let plaintext = decrypt_ldap_bind_password_v2(state, observed).ok_or_else(|| {
|
|
ldap_bind_password_error("stored LDAP bind password cannot be decrypted")
|
|
})?;
|
|
if plaintext.trim().is_empty() {
|
|
return Ok(None);
|
|
}
|
|
// v2 was bound only to the LDAP field name. Re-seal it with the
|
|
// complete current LDAP destination before returning it so a
|
|
// legacy ciphertext cannot remain portable across configurations.
|
|
let encrypted =
|
|
encrypt_ldap_bind_password(state, ¤t, &plaintext).ok_or_else(|| {
|
|
ldap_bind_password_error("LDAP bind password migration is unavailable")
|
|
})?;
|
|
if state
|
|
.compare_and_swap_ldap_bind_password(observed_raw, &encrypted)
|
|
.await?
|
|
{
|
|
return Ok(Some(plaintext));
|
|
}
|
|
current = state.get_ldap_module_config().await?.ok_or_else(|| {
|
|
ldap_bind_password_error("stored LDAP configuration is unavailable")
|
|
})?;
|
|
continue;
|
|
}
|
|
if stored_secret_uses_known_envelope_family(observed) {
|
|
return Err(ldap_bind_password_error(
|
|
"stored LDAP bind password cannot be decrypted",
|
|
));
|
|
}
|
|
let plaintext =
|
|
match decrypt_catalog_secret_with_fallbacks(state.encryption_key(), observed) {
|
|
Some(plaintext) => plaintext,
|
|
None if looks_like_python_fernet_ciphertext(observed) => {
|
|
return Err(ldap_bind_password_error(
|
|
"stored LDAP bind password cannot be decrypted",
|
|
));
|
|
}
|
|
None => observed.to_string(),
|
|
};
|
|
if plaintext.contains('\0') {
|
|
return Err(ldap_bind_password_error(
|
|
"stored LDAP bind password contains reserved secret framing",
|
|
));
|
|
}
|
|
let encrypted =
|
|
encrypt_ldap_bind_password(state, ¤t, &plaintext).ok_or_else(|| {
|
|
ldap_bind_password_error("LDAP bind password migration is unavailable")
|
|
})?;
|
|
if state
|
|
.compare_and_swap_ldap_bind_password(observed_raw, &encrypted)
|
|
.await?
|
|
{
|
|
return Ok((!plaintext.trim().is_empty()).then_some(plaintext));
|
|
}
|
|
current = state
|
|
.get_ldap_module_config()
|
|
.await?
|
|
.ok_or_else(|| ldap_bind_password_error("stored LDAP configuration is unavailable"))?;
|
|
}
|
|
|
|
Err(ldap_bind_password_error(
|
|
"LDAP bind password migration did not stabilize",
|
|
))
|
|
}
|
|
|
|
async fn decrypt_or_migrate_system_config_secret_with_before_compare<BeforeCompare, CompareFuture>(
|
|
state: &AppState,
|
|
key: &str,
|
|
stored_value: String,
|
|
before_compare: BeforeCompare,
|
|
) -> Result<String, GatewayError>
|
|
where
|
|
BeforeCompare: Fn() -> CompareFuture,
|
|
CompareFuture: Future<Output = ()>,
|
|
{
|
|
let mut observed_raw = stored_value;
|
|
for _ in 0..SYSTEM_CONFIG_SECRET_MIGRATION_RETRIES {
|
|
let current_raw = read_strong_system_config_secret(state, key).await?;
|
|
if current_raw != observed_raw {
|
|
observed_raw = current_raw;
|
|
continue;
|
|
}
|
|
let observed = observed_raw.trim();
|
|
|
|
if observed.starts_with(SYSTEM_CONFIG_SECRET_V2_PREFIX) {
|
|
let plaintext =
|
|
decrypt_system_config_secret(state, key, observed).ok_or_else(|| {
|
|
system_config_secret_error(
|
|
"stored system configuration secret cannot be decrypted",
|
|
)
|
|
})?;
|
|
return Ok(plaintext);
|
|
}
|
|
if stored_secret_uses_known_envelope_family(observed) {
|
|
return Err(system_config_secret_error(
|
|
"stored system configuration secret cannot be decrypted",
|
|
));
|
|
}
|
|
let plaintext =
|
|
match decrypt_catalog_secret_with_fallbacks(state.encryption_key(), observed) {
|
|
Some(plaintext) => plaintext,
|
|
None if looks_like_python_fernet_ciphertext(observed) => {
|
|
return Err(system_config_secret_error(
|
|
"stored system configuration secret cannot be decrypted",
|
|
));
|
|
}
|
|
None => observed.to_string(),
|
|
};
|
|
if plaintext.contains('\0') {
|
|
return Err(system_config_secret_error(
|
|
"stored system configuration secret contains reserved secret framing",
|
|
));
|
|
}
|
|
let encrypted = encrypt_system_config_secret(state, key, &plaintext).ok_or_else(|| {
|
|
system_config_secret_error("system configuration secret migration is unavailable")
|
|
})?;
|
|
|
|
before_compare().await;
|
|
if state
|
|
.compare_and_set_system_config_string_value(key, &observed_raw, &encrypted)
|
|
.await?
|
|
{
|
|
return Ok(plaintext);
|
|
}
|
|
observed_raw = read_strong_system_config_secret(state, key).await?;
|
|
}
|
|
|
|
Err(system_config_secret_error(
|
|
"system configuration secret migration did not stabilize",
|
|
))
|
|
}
|
|
|
|
async fn read_strong_system_config_secret(
|
|
state: &AppState,
|
|
key: &str,
|
|
) -> Result<String, GatewayError> {
|
|
state
|
|
.read_system_config_json_value_strong(key)
|
|
.await?
|
|
.and_then(|value| {
|
|
value
|
|
.as_str()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(ToOwned::to_owned)
|
|
})
|
|
.ok_or_else(|| {
|
|
system_config_secret_error("stored system configuration secret is unavailable")
|
|
})
|
|
}
|
|
|
|
fn system_config_secret_error(message: &str) -> GatewayError {
|
|
GatewayError::Internal(message.to_string())
|
|
}
|
|
|
|
fn ldap_bind_password_error(message: &str) -> GatewayError {
|
|
GatewayError::Internal(message.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
bark_device_key_binding, decrypt_bark_device_key_v2, decrypt_ldap_bind_password_v2,
|
|
decrypt_ldap_bind_password_v3, decrypt_or_migrate_bark_device_key,
|
|
decrypt_or_migrate_ldap_bind_password, decrypt_or_migrate_smtp_password,
|
|
decrypt_or_migrate_system_config_secret,
|
|
decrypt_or_migrate_system_config_secret_with_before_compare, decrypt_system_config_secret,
|
|
encrypt_bark_device_key, encrypt_ldap_bind_password, encrypt_smtp_password,
|
|
encrypt_system_config_secret, ldap_module_config_is_valid,
|
|
normalize_ldap_transport_server_url, smtp_password_binding, LDAP_BIND_PASSWORD_V2_PREFIX,
|
|
LDAP_BIND_PASSWORD_V3_PREFIX, SMTP_PASSWORD_V3_PREFIX, SYSTEM_CONFIG_SECRET_V2_PREFIX,
|
|
};
|
|
use crate::data::GatewayDataState;
|
|
use crate::AppState;
|
|
use aether_crypto::{
|
|
encrypt_python_fernet_plaintext, looks_like_python_fernet_ciphertext,
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
};
|
|
use aether_data::repository::auth_modules::{
|
|
AuthModuleReadRepository, InMemoryAuthModuleReadRepository, StoredLdapModuleConfig,
|
|
};
|
|
use futures_util::future::join_all;
|
|
use serde_json::json;
|
|
use std::sync::{
|
|
atomic::{AtomicBool, Ordering},
|
|
Arc,
|
|
};
|
|
use tokio::sync::Barrier;
|
|
|
|
const TEST_KEY: &str = "smtp_password";
|
|
const TEST_SECRET: &str = "legacy-plaintext-password";
|
|
const BARK_DEVICE_KEY: &str = "module.bark_push.device_key";
|
|
|
|
fn state_with_stored_secret(value: &str) -> AppState {
|
|
state_with_named_stored_secret(TEST_KEY, value)
|
|
}
|
|
|
|
fn state_with_named_stored_secret(key: &str, value: &str) -> AppState {
|
|
let data = GatewayDataState::disabled()
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY)
|
|
.with_system_config_values_for_tests([(key.to_string(), json!(value))]);
|
|
let mut state = AppState::new().expect("gateway state should build");
|
|
state.replace_data_state(Arc::new(data));
|
|
state
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn smtp_password_migrates_legacy_formats_to_bound_v3() {
|
|
let binding = smtp_password_binding(
|
|
"smtp.example.com",
|
|
587,
|
|
Some("[email protected]"),
|
|
true,
|
|
false,
|
|
)
|
|
.expect("SMTP binding should build");
|
|
let fixture_state = state_with_stored_secret(TEST_SECRET);
|
|
let legacy_values = [
|
|
TEST_SECRET.to_string(),
|
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, TEST_SECRET)
|
|
.expect("legacy SMTP password should encrypt"),
|
|
encrypt_system_config_secret(&fixture_state, TEST_KEY, TEST_SECRET)
|
|
.expect("v2 SMTP password should encrypt"),
|
|
];
|
|
|
|
for legacy in legacy_values {
|
|
let state = state_with_stored_secret(&legacy);
|
|
let plaintext = decrypt_or_migrate_smtp_password(&state, &binding, legacy.clone())
|
|
.await
|
|
.expect("legacy SMTP password should migrate");
|
|
assert_eq!(plaintext, TEST_SECRET);
|
|
let migrated = state
|
|
.read_system_config_json_value_strong(TEST_KEY)
|
|
.await
|
|
.expect("SMTP password should read")
|
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
|
.expect("SMTP password should be a string");
|
|
assert!(migrated.starts_with(SMTP_PASSWORD_V3_PREFIX));
|
|
assert_ne!(migrated, legacy);
|
|
assert_eq!(
|
|
decrypt_or_migrate_smtp_password(&state, &binding, migrated.clone())
|
|
.await
|
|
.expect("migrated SMTP password should decrypt"),
|
|
TEST_SECRET
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.read_system_config_json_value_strong(TEST_KEY)
|
|
.await
|
|
.unwrap(),
|
|
Some(json!(migrated))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn smtp_password_rejects_invalid_ciphertext_without_rewriting() {
|
|
let binding = smtp_password_binding(
|
|
"smtp.example.com",
|
|
587,
|
|
Some("[email protected]"),
|
|
true,
|
|
false,
|
|
)
|
|
.expect("SMTP binding should build");
|
|
let fixture_state = state_with_stored_secret(TEST_SECRET);
|
|
let mut tampered = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, TEST_SECRET)
|
|
.expect("legacy SMTP password should encrypt");
|
|
tampered.replace_range(tampered.len() - 2.., "AA");
|
|
let invalid_values = [
|
|
tampered,
|
|
encrypt_python_fernet_plaintext("unavailable-historical-key", TEST_SECRET)
|
|
.expect("wrong-key SMTP password should encrypt"),
|
|
encrypt_system_config_secret(&fixture_state, "other_secret", TEST_SECRET)
|
|
.expect("wrong-purpose secret should encrypt"),
|
|
"aether-system-config-secret-v2:invalid".to_string(),
|
|
"aether-smtp-password-v3:invalid".to_string(),
|
|
"aether-runtime-secret-v1:invalid".to_string(),
|
|
"aether-unknown-secret-v4:invalid".to_string(),
|
|
];
|
|
|
|
for stored in invalid_values {
|
|
let state = state_with_stored_secret(&stored);
|
|
let error = decrypt_or_migrate_smtp_password(&state, &binding, stored.clone())
|
|
.await
|
|
.expect_err("invalid ciphertext must not become an SMTP password");
|
|
assert_eq!(
|
|
error.into_message(),
|
|
"stored SMTP password cannot be decrypted"
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.read_system_config_json_value_strong(TEST_KEY)
|
|
.await
|
|
.unwrap(),
|
|
Some(json!(stored))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn smtp_password_v3_rejects_changed_transport_binding() {
|
|
let binding = smtp_password_binding(
|
|
"smtp.example.com",
|
|
587,
|
|
Some("[email protected]"),
|
|
true,
|
|
false,
|
|
)
|
|
.expect("SMTP binding should build");
|
|
let stored = encrypt_smtp_password(
|
|
&state_with_stored_secret(TEST_SECRET),
|
|
&binding,
|
|
TEST_SECRET,
|
|
)
|
|
.expect("SMTP password should encrypt");
|
|
let state = state_with_stored_secret(&stored);
|
|
for changed_binding in [
|
|
smtp_password_binding(
|
|
"other.example.com",
|
|
587,
|
|
Some("[email protected]"),
|
|
true,
|
|
false,
|
|
),
|
|
smtp_password_binding(
|
|
"smtp.example.com",
|
|
465,
|
|
Some("[email protected]"),
|
|
true,
|
|
false,
|
|
),
|
|
smtp_password_binding(
|
|
"smtp.example.com",
|
|
587,
|
|
Some("[email protected]"),
|
|
true,
|
|
false,
|
|
),
|
|
smtp_password_binding(
|
|
"smtp.example.com",
|
|
587,
|
|
Some("[email protected]"),
|
|
false,
|
|
false,
|
|
),
|
|
smtp_password_binding("smtp.example.com", 587, Some("[email protected]"), true, true),
|
|
] {
|
|
assert!(decrypt_or_migrate_smtp_password(
|
|
&state,
|
|
&changed_binding.expect("changed binding should build"),
|
|
stored.clone(),
|
|
)
|
|
.await
|
|
.is_err());
|
|
}
|
|
assert_eq!(
|
|
state
|
|
.read_system_config_json_value_strong(TEST_KEY)
|
|
.await
|
|
.unwrap(),
|
|
Some(json!(stored))
|
|
);
|
|
}
|
|
|
|
fn ldap_config(bind_password: &str) -> StoredLdapModuleConfig {
|
|
StoredLdapModuleConfig {
|
|
server_url: "ldaps://ldap.example.com".to_string(),
|
|
bind_dn: "cn=admin,dc=example,dc=com".to_string(),
|
|
bind_password_encrypted: Some(bind_password.to_string()),
|
|
base_dn: "dc=example,dc=com".to_string(),
|
|
user_search_filter: Some("(uid={username})".to_string()),
|
|
username_attr: Some("uid".to_string()),
|
|
email_attr: Some("mail".to_string()),
|
|
display_name_attr: Some("displayName".to_string()),
|
|
is_enabled: true,
|
|
is_exclusive: false,
|
|
use_starttls: false,
|
|
connect_timeout: Some(10),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ldap_transport_url_requires_tls_and_rejects_url_control_data() {
|
|
assert_eq!(
|
|
normalize_ldap_transport_server_url(" ldaps://LDAP.Example.COM:636/ ", false)
|
|
.as_deref(),
|
|
Some("ldaps://ldap.example.com")
|
|
);
|
|
assert_eq!(
|
|
normalize_ldap_transport_server_url("ldap://10.20.30.40:389", true).as_deref(),
|
|
Some("ldap://10.20.30.40")
|
|
);
|
|
assert!(normalize_ldap_transport_server_url("ldap://10.20.30.40", false).is_none());
|
|
assert!(
|
|
normalize_ldap_transport_server_url("ldap://user:[email protected]", true)
|
|
.is_none()
|
|
);
|
|
assert!(normalize_ldap_transport_server_url("ldaps://@ldap.example.com", false).is_none());
|
|
assert!(
|
|
normalize_ldap_transport_server_url("ldaps://ldap.example.com?x=1", false).is_none()
|
|
);
|
|
assert!(
|
|
normalize_ldap_transport_server_url("ldaps://ldap.example.com#fragment", false)
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
normalize_ldap_transport_server_url("ldaps://ldap.example.com/dc=example", false)
|
|
.is_none()
|
|
);
|
|
assert!(normalize_ldap_transport_server_url("https://ldap.example.com", false).is_none());
|
|
assert!(normalize_ldap_transport_server_url("ldaps://ldap.example.com\n", false).is_none());
|
|
// The in-process mock is intentionally available only to test builds.
|
|
assert!(
|
|
normalize_ldap_transport_server_url("mockldap://ldap.example.com", false).is_some()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_ldap_validation_reuses_shared_field_rules_with_test_transport() {
|
|
let mut config = ldap_config("sealed-password");
|
|
config.server_url = "mockldap://ldap.example.com".to_string();
|
|
config.use_starttls = false;
|
|
assert!(ldap_module_config_is_valid(Some(&config)));
|
|
|
|
config.user_search_filter = Some("(uid={username})(objectClass=*)".to_string());
|
|
assert!(!ldap_module_config_is_valid(Some(&config)));
|
|
config.user_search_filter = Some("(uid={username})".to_string());
|
|
config.username_attr = Some("uid)(|(objectClass=*)".to_string());
|
|
assert!(!ldap_module_config_is_valid(Some(&config)));
|
|
config.username_attr = Some("uid".to_string());
|
|
config.bind_dn = "cn=admin,dc=example,dc=com\n".to_string();
|
|
assert!(!ldap_module_config_is_valid(Some(&config)));
|
|
}
|
|
|
|
fn state_with_ldap_bind_password(
|
|
value: &str,
|
|
) -> (AppState, Arc<InMemoryAuthModuleReadRepository>) {
|
|
let repository = Arc::new(InMemoryAuthModuleReadRepository::seed(
|
|
Vec::new(),
|
|
Some(ldap_config(value)),
|
|
));
|
|
let data = GatewayDataState::with_auth_module_repository_for_tests(repository.clone())
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
|
|
let mut state = AppState::new().expect("gateway state should build");
|
|
state.replace_data_state(Arc::new(data));
|
|
(state, repository)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_ldap_bind_password_is_lazily_migrated() {
|
|
let (state, repository) = state_with_ldap_bind_password(TEST_SECRET);
|
|
let config = repository
|
|
.get_ldap_config()
|
|
.await
|
|
.expect("LDAP config should read")
|
|
.expect("LDAP config should exist");
|
|
|
|
let plaintext = decrypt_or_migrate_ldap_bind_password(&state, &config)
|
|
.await
|
|
.expect("legacy LDAP bind password should migrate");
|
|
assert_eq!(plaintext.as_deref(), Some(TEST_SECRET));
|
|
|
|
let stored = repository
|
|
.get_ldap_config()
|
|
.await
|
|
.expect("LDAP config should read")
|
|
.and_then(|config| config.bind_password_encrypted)
|
|
.expect("LDAP bind password should exist");
|
|
assert!(stored.starts_with(LDAP_BIND_PASSWORD_V3_PREFIX));
|
|
assert_eq!(
|
|
decrypt_ldap_bind_password_v3(&state, &config, &stored)
|
|
.expect("migrated LDAP password should decrypt"),
|
|
TEST_SECRET
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tampered_ldap_bind_password_ciphertext_fails_closed() {
|
|
let mut tampered = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, TEST_SECRET)
|
|
.expect("LDAP password should encrypt");
|
|
tampered.replace_range(tampered.len() - 2.., "AA");
|
|
assert!(looks_like_python_fernet_ciphertext(&tampered));
|
|
let (state, repository) = state_with_ldap_bind_password(&tampered);
|
|
let config = repository
|
|
.get_ldap_config()
|
|
.await
|
|
.expect("LDAP config should read")
|
|
.expect("LDAP config should exist");
|
|
|
|
let error = decrypt_or_migrate_ldap_bind_password(&state, &config)
|
|
.await
|
|
.expect_err("tampered LDAP ciphertext must not be used as plaintext");
|
|
assert!(format!("{error:?}").contains("cannot be decrypted"));
|
|
assert_eq!(
|
|
repository
|
|
.get_ldap_config()
|
|
.await
|
|
.expect("LDAP config should read")
|
|
.and_then(|config| config.bind_password_encrypted),
|
|
Some(tampered)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bound_secret_envelopes_cannot_move_between_ldap_and_system_config() {
|
|
let (ldap_state, _) = state_with_ldap_bind_password(TEST_SECRET);
|
|
let ldap_sealed =
|
|
encrypt_ldap_bind_password(&ldap_state, &ldap_config("legacy"), TEST_SECRET)
|
|
.expect("LDAP bind password should seal");
|
|
let system_sealed = encrypt_system_config_secret(&ldap_state, TEST_KEY, TEST_SECRET)
|
|
.expect("system config secret should seal");
|
|
|
|
let (ldap_with_system_secret, repository) = state_with_ldap_bind_password(&system_sealed);
|
|
let config = repository
|
|
.get_ldap_config()
|
|
.await
|
|
.expect("LDAP config should read")
|
|
.expect("LDAP config should exist");
|
|
let ldap_error = decrypt_or_migrate_ldap_bind_password(&ldap_with_system_secret, &config)
|
|
.await
|
|
.expect_err("system config ciphertext must not become an LDAP password");
|
|
assert!(ldap_error.into_message().contains("cannot be decrypted"));
|
|
|
|
let system_with_ldap_secret = state_with_stored_secret(&ldap_sealed);
|
|
let system_error = decrypt_or_migrate_system_config_secret(
|
|
&system_with_ldap_secret,
|
|
TEST_KEY,
|
|
ldap_sealed.clone(),
|
|
)
|
|
.await
|
|
.expect_err("LDAP ciphertext must not become a system config secret");
|
|
assert!(system_error.into_message().contains("cannot be decrypted"));
|
|
|
|
let stripped_system = system_sealed
|
|
.strip_prefix(SYSTEM_CONFIG_SECRET_V2_PREFIX)
|
|
.and_then(|value| value.strip_prefix("aether-runtime-secret-v1:"))
|
|
.expect("system secret should contain a nested runtime envelope");
|
|
let (ldap_with_stripped_system, repository) =
|
|
state_with_ldap_bind_password(stripped_system);
|
|
let config = repository
|
|
.get_ldap_config()
|
|
.await
|
|
.expect("LDAP config should read")
|
|
.expect("LDAP config should exist");
|
|
let error = decrypt_or_migrate_ldap_bind_password(&ldap_with_stripped_system, &config)
|
|
.await
|
|
.expect_err("stripped system framing must not become an LDAP password");
|
|
assert!(error.into_message().contains("reserved secret framing"));
|
|
|
|
let stripped_ldap = ldap_sealed
|
|
.strip_prefix(LDAP_BIND_PASSWORD_V3_PREFIX)
|
|
.and_then(|value| value.strip_prefix("aether-runtime-secret-v1:"))
|
|
.expect("LDAP secret should contain a nested runtime envelope");
|
|
let system_with_stripped_ldap = state_with_stored_secret(stripped_ldap);
|
|
let error = decrypt_or_migrate_system_config_secret(
|
|
&system_with_stripped_ldap,
|
|
TEST_KEY,
|
|
stripped_ldap.to_string(),
|
|
)
|
|
.await
|
|
.expect_err("stripped LDAP framing must not become a system secret");
|
|
assert!(error.into_message().contains("reserved secret framing"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_plaintext_secret_is_lazily_migrated() {
|
|
let state = state_with_stored_secret(TEST_SECRET);
|
|
|
|
let plaintext =
|
|
decrypt_or_migrate_system_config_secret(&state, TEST_KEY, TEST_SECRET.to_string())
|
|
.await
|
|
.expect("legacy secret should migrate");
|
|
assert_eq!(plaintext, TEST_SECRET);
|
|
|
|
let stored = state
|
|
.read_system_config_json_value_strong(TEST_KEY)
|
|
.await
|
|
.expect("stored secret should read")
|
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
|
.expect("stored secret should be a string");
|
|
assert_ne!(stored, TEST_SECRET);
|
|
assert!(stored.starts_with(SYSTEM_CONFIG_SECRET_V2_PREFIX));
|
|
assert_eq!(
|
|
decrypt_system_config_secret(&state, TEST_KEY, &stored)
|
|
.expect("migrated secret should decrypt"),
|
|
TEST_SECRET
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_secret_migration_compares_the_untrimmed_stored_value() {
|
|
let stored_raw = format!(" {TEST_SECRET} ");
|
|
let state = state_with_stored_secret(&stored_raw);
|
|
|
|
let plaintext =
|
|
decrypt_or_migrate_system_config_secret(&state, TEST_KEY, TEST_SECRET.to_string())
|
|
.await
|
|
.expect("whitespace-wrapped legacy secret should migrate");
|
|
assert_eq!(plaintext, TEST_SECRET);
|
|
|
|
let stored = state
|
|
.read_system_config_json_value_strong(TEST_KEY)
|
|
.await
|
|
.expect("stored secret should read")
|
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
|
.expect("stored secret should be a string");
|
|
assert_eq!(
|
|
decrypt_system_config_secret(&state, TEST_KEY, &stored).as_deref(),
|
|
Some(TEST_SECRET)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_legacy_reads_do_not_double_encrypt() {
|
|
let state = state_with_stored_secret(TEST_SECRET);
|
|
let reads = (0..16).map(|_| {
|
|
decrypt_or_migrate_system_config_secret(&state, TEST_KEY, TEST_SECRET.to_string())
|
|
});
|
|
for result in join_all(reads).await {
|
|
assert_eq!(
|
|
result.expect("concurrent migration should succeed"),
|
|
TEST_SECRET
|
|
);
|
|
}
|
|
|
|
let stored = state
|
|
.read_system_config_json_value_strong(TEST_KEY)
|
|
.await
|
|
.expect("stored secret should read")
|
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
|
.expect("stored secret should be a string");
|
|
assert_eq!(
|
|
decrypt_system_config_secret(&state, TEST_KEY, &stored)
|
|
.expect("migrated secret should decrypt exactly once"),
|
|
TEST_SECRET
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stale_cached_ciphertext_does_not_bypass_strong_read() {
|
|
let stale = encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"credential-before-rotation",
|
|
)
|
|
.expect("stale fixture should encrypt");
|
|
let current = encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"credential-after-rotation",
|
|
)
|
|
.expect("current fixture should encrypt");
|
|
let state = state_with_stored_secret(¤t);
|
|
|
|
let plaintext = decrypt_or_migrate_system_config_secret(&state, TEST_KEY, stale)
|
|
.await
|
|
.expect("strong read should use the rotated credential");
|
|
|
|
assert_eq!(plaintext, "credential-after-rotation");
|
|
let stored = state
|
|
.read_system_config_json_value_strong(TEST_KEY)
|
|
.await
|
|
.expect("stored secret should read")
|
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
|
.expect("stored secret should be a string");
|
|
assert_ne!(stored, current);
|
|
assert_eq!(
|
|
decrypt_system_config_secret(&state, TEST_KEY, &stored).as_deref(),
|
|
Some("credential-after-rotation")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn administrator_rotation_wins_race_with_plaintext_migration() {
|
|
let state = state_with_stored_secret(TEST_SECRET);
|
|
let rotated_plaintext = "credential-after-administrator-rotation";
|
|
let rotated_ciphertext =
|
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, rotated_plaintext)
|
|
.expect("rotated fixture should encrypt");
|
|
let reached_compare = Arc::new(Barrier::new(2));
|
|
let resume_compare = Arc::new(Barrier::new(2));
|
|
let first_compare = Arc::new(AtomicBool::new(true));
|
|
let migration_reached_compare = Arc::clone(&reached_compare);
|
|
let migration_resume_compare = Arc::clone(&resume_compare);
|
|
let migration_first_compare = Arc::clone(&first_compare);
|
|
|
|
let migration = decrypt_or_migrate_system_config_secret_with_before_compare(
|
|
&state,
|
|
TEST_KEY,
|
|
TEST_SECRET.to_string(),
|
|
move || {
|
|
let reached_compare = Arc::clone(&migration_reached_compare);
|
|
let resume_compare = Arc::clone(&migration_resume_compare);
|
|
let first_compare = Arc::clone(&migration_first_compare);
|
|
async move {
|
|
if first_compare.swap(false, Ordering::SeqCst) {
|
|
reached_compare.wait().await;
|
|
resume_compare.wait().await;
|
|
}
|
|
}
|
|
},
|
|
);
|
|
let rotation = async {
|
|
reached_compare.wait().await;
|
|
state
|
|
.upsert_system_config_json_value(TEST_KEY, &json!(rotated_ciphertext.clone()), None)
|
|
.await
|
|
.expect("administrator rotation should persist");
|
|
resume_compare.wait().await;
|
|
};
|
|
|
|
let (migration_result, ()) = tokio::join!(migration, rotation);
|
|
assert_eq!(
|
|
migration_result.expect("migration should retry against the rotated value"),
|
|
rotated_plaintext
|
|
);
|
|
let stored = state
|
|
.read_system_config_json_value_strong(TEST_KEY)
|
|
.await
|
|
.expect("stored secret should read")
|
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
|
.expect("stored secret should be a string");
|
|
assert_ne!(stored, rotated_ciphertext);
|
|
assert_eq!(
|
|
decrypt_system_config_secret(&state, TEST_KEY, &stored).as_deref(),
|
|
Some(rotated_plaintext)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_compare_and_set_invalidates_cached_system_config_value() {
|
|
let data = Arc::new(
|
|
GatewayDataState::disabled()
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY)
|
|
.with_system_config_values_for_tests([(
|
|
TEST_KEY.to_string(),
|
|
json!("value-cached-on-this-node"),
|
|
)]),
|
|
);
|
|
let mut state = AppState::new().expect("gateway state should build");
|
|
state.replace_data_state(Arc::clone(&data));
|
|
assert_eq!(
|
|
state
|
|
.read_system_config_json_value(TEST_KEY)
|
|
.await
|
|
.expect("initial config should cache"),
|
|
Some(json!("value-cached-on-this-node"))
|
|
);
|
|
|
|
data.upsert_system_config_value(TEST_KEY, &json!("value-rotated-by-another-node"), None)
|
|
.await
|
|
.expect("simulated remote rotation should persist");
|
|
assert!(!state
|
|
.compare_and_set_system_config_string_value(
|
|
TEST_KEY,
|
|
"value-cached-on-this-node",
|
|
"stale-migration-replacement",
|
|
)
|
|
.await
|
|
.expect("stale compare-and-set should complete"));
|
|
|
|
assert_eq!(
|
|
state
|
|
.read_system_config_json_value(TEST_KEY)
|
|
.await
|
|
.expect("config should reload after failed compare-and-set"),
|
|
Some(json!("value-rotated-by-another-node"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn undecryptable_fernet_secret_fails_without_plaintext_fallback() {
|
|
let ciphertext = encrypt_python_fernet_plaintext("unavailable-historical-key", TEST_SECRET)
|
|
.expect("fixture should encrypt");
|
|
let state = state_with_stored_secret(&ciphertext);
|
|
|
|
let error = decrypt_or_migrate_system_config_secret(&state, TEST_KEY, ciphertext.clone())
|
|
.await
|
|
.expect_err("unknown Fernet ciphertext must fail closed");
|
|
let error_text = error.into_message();
|
|
assert!(!error_text.contains(TEST_SECRET));
|
|
assert!(!error_text.contains(&ciphertext));
|
|
assert_eq!(
|
|
state
|
|
.read_system_config_json_value_strong(TEST_KEY)
|
|
.await
|
|
.expect("stored secret should read"),
|
|
Some(json!(ciphertext))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn system_config_secret_ciphertext_is_bound_to_its_config_key() {
|
|
let sealed = encrypt_system_config_secret(
|
|
&state_with_stored_secret(TEST_SECRET),
|
|
TEST_KEY,
|
|
TEST_SECRET,
|
|
)
|
|
.expect("system config secret should seal");
|
|
let state = state_with_stored_secret(&sealed);
|
|
|
|
assert_eq!(
|
|
decrypt_or_migrate_system_config_secret(&state, TEST_KEY, sealed.clone(),)
|
|
.await
|
|
.expect("matching system config secret should open"),
|
|
TEST_SECRET
|
|
);
|
|
let wrong_key = "backup_s3_secret_access_key";
|
|
let wrong_state = state_with_named_stored_secret(wrong_key, &sealed);
|
|
let wrong_key_error =
|
|
decrypt_or_migrate_system_config_secret(&wrong_state, wrong_key, sealed.clone())
|
|
.await
|
|
.expect_err("copied system config secret must fail closed");
|
|
assert!(wrong_key_error
|
|
.into_message()
|
|
.contains("cannot be decrypted"));
|
|
assert_eq!(
|
|
decrypt_system_config_secret(&state, "SMTP_PASSWORD", &sealed).as_deref(),
|
|
Some(TEST_SECRET)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bark_device_key_ciphertext_is_bound_to_canonical_server_url() {
|
|
let state = state_with_stored_secret(TEST_SECRET);
|
|
let binding = bark_device_key_binding(" HTTPS://Example.COM:443/api/// ")
|
|
.expect("Bark server URL should canonicalize");
|
|
assert_eq!(binding.server_url, "https://example.com/api");
|
|
let equivalent = bark_device_key_binding("https://example.com:443/api")
|
|
.expect("equivalent Bark server URL should canonicalize");
|
|
let sealed = encrypt_bark_device_key(&state, &binding, TEST_SECRET)
|
|
.expect("Bark device key should seal");
|
|
|
|
assert_eq!(
|
|
decrypt_bark_device_key_v2(&state, &equivalent, &sealed).as_deref(),
|
|
Some(TEST_SECRET)
|
|
);
|
|
let changed = bark_device_key_binding("https://example.com/other")
|
|
.expect("changed Bark server URL should parse");
|
|
assert!(decrypt_bark_device_key_v2(&state, &changed, &sealed).is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_bark_device_key_migrates_to_destination_bound_envelope() {
|
|
let state = state_with_named_stored_secret(BARK_DEVICE_KEY, TEST_SECRET);
|
|
let binding =
|
|
bark_device_key_binding("https://api.day.app").expect("Bark URL should parse");
|
|
let plaintext =
|
|
decrypt_or_migrate_bark_device_key(&state, &binding, TEST_SECRET.to_string())
|
|
.await
|
|
.expect("legacy Bark device key should migrate");
|
|
assert_eq!(plaintext, TEST_SECRET);
|
|
|
|
let stored = state
|
|
.read_system_config_json_value_strong(BARK_DEVICE_KEY)
|
|
.await
|
|
.expect("Bark device key should read")
|
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
|
.expect("Bark device key should remain a string");
|
|
assert!(stored.starts_with("aether-bark-device-key-v2:"));
|
|
assert_eq!(
|
|
decrypt_bark_device_key_v2(&state, &binding, &stored).as_deref(),
|
|
Some(TEST_SECRET)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bark_device_key_rejects_a_ciphertext_bound_to_another_server_url() {
|
|
let state = state_with_stored_secret(TEST_SECRET);
|
|
let original =
|
|
bark_device_key_binding("https://api.day.app").expect("Bark URL should parse");
|
|
let sealed = encrypt_bark_device_key(&state, &original, TEST_SECRET)
|
|
.expect("Bark device key should seal");
|
|
let state = state_with_named_stored_secret(BARK_DEVICE_KEY, &sealed);
|
|
let changed = bark_device_key_binding("https://bark.example.test/api")
|
|
.expect("changed Bark URL should parse");
|
|
|
|
let error = decrypt_or_migrate_bark_device_key(&state, &changed, sealed.clone())
|
|
.await
|
|
.expect_err("Bark device key must not move between destinations");
|
|
assert!(error.into_message().contains("cannot be decrypted"));
|
|
assert_eq!(
|
|
state
|
|
.read_system_config_json_value_strong(BARK_DEVICE_KEY)
|
|
.await
|
|
.expect("Bark device key should remain readable"),
|
|
Some(json!(sealed))
|
|
);
|
|
}
|
|
}
|