Files
rustfs/crates/kms/src/config.rs
T
Zhengchao An ad721bff42 fix(kms): honour the configured metadata cache TTL and metrics switch (#5569)
* fix(kms): honour the configured metadata cache TTL and metrics switch

KmsManager::new built the KmsCache from cache_config.max_keys alone, so
cache_config.ttl was dead configuration: every deployment ran the
hardcoded 300s window whatever the admin configure API was given, while
CacheSummary and the KMS config endpoint reported the configured value
back. cache_config.enable_metrics was never read anywhere.

Build the cache from the whole CacheConfig. The documented default is
reconciled down to the 300s the cache has always used rather than up to
the advertised 3600s, and now lives in one place (DEFAULT_CACHE_TTL)
instead of being duplicated across the four configure-request
converters, so the default path behaves exactly as before.

Behaviour change: a deployment configured through the admin API already
has ttl 3600 persisted, because the old converters wrote that default
into the stored config, so its describe_key staleness window widens from
an effective 300s to the 3600s it asked for. No cryptographic or
authorization path widens - encrypt, decrypt and generate_data_key go
straight to the backend and never read this cache. The Vault Transit
backend's own metadata cache, which does gate crypto through
ensure_key_state_allows, stays fixed at 300s and is now documented as
deliberately not operator-tunable.

The configured duration now reaches moka's builder, which panics above
1000 years, so CacheConfig::effective_ttl clamps to a 24h maximum the
way effective_timeout already clamps its own, and validate rejects a
zero TTL beside the existing max_keys check. Both config summaries and
the KMS config endpoint report the effective value, so the admin API
cannot advertise a lifetime the cache does not honour.

enable_metrics gates publication of the rustfs_kms_metadata_cache_*
families only; the counters behind the admin status API keep running
either way. No configure-request field sets it yet.

Refs rustfs/backlog#1584

* docs(kms): state why the Transit metadata TTL is not bound to the default

The comment claimed the constant matches config::DEFAULT_CACHE_TTL, which
reads as an invariant the code does not enforce. Say plainly that the
equality is a coincidence rather than a contract, and why binding the two
would be wrong: this cache gates crypto through ensure_key_state_allows,
so a later change to the operator-facing describe-cache default must not
be able to widen its staleness window.
2026-08-01 13:27:33 +00:00

2090 lines
83 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! KMS configuration management
use crate::error::{KmsError, Result};
use rustfs_security_governance::{RedactionLevel, RedactionRule};
use rustfs_utils::{get_env_bool, get_env_opt_str, get_env_str};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use url::Url;
pub const ENV_KMS_ALLOW_INSECURE_DEV_DEFAULTS: &str = "RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS";
pub const ENV_KMS_ALLOW_IMMEDIATE_DELETION: &str = "RUSTFS_KMS_ALLOW_IMMEDIATE_DELETION";
pub const ENV_KMS_VAULT_SKIP_TLS_VERIFY: &str = "RUSTFS_KMS_VAULT_SKIP_TLS_VERIFY";
pub const ENV_KMS_VAULT_TRANSIT_METADATA_KV_MOUNT: &str = "RUSTFS_KMS_VAULT_TRANSIT_METADATA_KV_MOUNT";
pub const ENV_KMS_VAULT_TRANSIT_METADATA_PREFIX: &str = "RUSTFS_KMS_VAULT_TRANSIT_METADATA_PREFIX";
pub const ENV_KMS_STATIC_SECRET_KEY: &str = "RUSTFS_KMS_STATIC_SECRET_KEY";
pub const ENV_KMS_STATIC_SECRET_KEY_FILE: &str = "RUSTFS_KMS_STATIC_SECRET_KEY_FILE";
pub const ENV_KMS_VAULT_APPROLE_ROLE_ID: &str = "RUSTFS_KMS_VAULT_APPROLE_ROLE_ID";
pub const ENV_KMS_VAULT_APPROLE_SECRET_ID: &str = "RUSTFS_KMS_VAULT_APPROLE_SECRET_ID";
pub const ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE: &str = "RUSTFS_KMS_VAULT_APPROLE_SECRET_ID_FILE";
pub const ENV_KMS_VAULT_APPROLE_MOUNT: &str = "RUSTFS_KMS_VAULT_APPROLE_MOUNT";
pub const ENV_KMS_VAULT_TOKEN_FILE: &str = "RUSTFS_KMS_VAULT_TOKEN_FILE";
pub const ENV_KMS_AWS_REGION: &str = "RUSTFS_KMS_AWS_REGION";
pub const ENV_KMS_AWS_ENDPOINT_URL: &str = "RUSTFS_KMS_AWS_ENDPOINT_URL";
pub const DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT: &str = "secret";
pub const DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX: &str = "rustfs/kms/transit-metadata";
pub const DEFAULT_VAULT_APPROLE_MOUNT: &str = "approle";
/// Upper bound applied to `KmsConfig::timeout` when deriving backend behavior.
///
/// Out-of-range values are clamped at use rather than rejected so existing
/// deployments with oversized settings keep starting after an upgrade.
pub(crate) const MAX_OPERATION_TIMEOUT: Duration = Duration::from_secs(300);
/// Upper bound applied to `KmsConfig::retry_attempts` when deriving backend behavior.
pub(crate) const MAX_RETRY_ATTEMPTS: u32 = 10;
/// Default number of key metadata entries the cache holds.
pub const DEFAULT_MAX_CACHED_KEYS: usize = 1000;
/// Default lifetime of a cached key metadata entry.
pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(300);
/// Upper bound applied to `CacheConfig::ttl` when building the metadata cache.
///
/// Out-of-range values are clamped at use rather than rejected, matching
/// `MAX_OPERATION_TIMEOUT`, so existing deployments with an oversized setting
/// keep starting after an upgrade.
pub(crate) const MAX_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
fn default_vault_transit_metadata_kv_mount() -> String {
DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT.to_string()
}
fn default_vault_transit_metadata_key_prefix() -> String {
DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX.to_string()
}
fn default_vault_kv2_mount_path() -> String {
"transit".to_string()
}
fn default_vault_approle_mount() -> String {
DEFAULT_VAULT_APPROLE_MOUNT.to_string()
}
pub const KMS_CONFIG_REDACTION_RULES: &[RedactionRule] = &[
RedactionRule::new("kms.local.master_key", RedactionLevel::Secret, "local backend key encryption material"),
RedactionRule::new("kms.vault.token", RedactionLevel::Secret, "vault authentication token"),
RedactionRule::new("kms.vault.approle.secret_id", RedactionLevel::Secret, "vault approle secret"),
RedactionRule::new("kms.vault_transit.token", RedactionLevel::Secret, "vault transit authentication token"),
RedactionRule::new(
"kms.vault_transit.approle.secret_id",
RedactionLevel::Secret,
"vault transit approle secret",
),
RedactionRule::new(
"kms.configure.local.master_key",
RedactionLevel::Secret,
"admin configure request local master key",
),
RedactionRule::new(
"kms.configure.vault.token",
RedactionLevel::Secret,
"admin configure request vault authentication token",
),
RedactionRule::new(
"kms.configure.vault.approle.secret_id",
RedactionLevel::Secret,
"admin configure request vault approle secret",
),
RedactionRule::new(
"kms.configure.vault_transit.token",
RedactionLevel::Secret,
"admin configure request vault transit authentication token",
),
RedactionRule::new(
"kms.configure.vault_transit.approle.secret_id",
RedactionLevel::Secret,
"admin configure request vault transit approle secret",
),
RedactionRule::new("kms.static.secret_key", RedactionLevel::Secret, "static backend secret key material"),
];
pub(crate) const REDACTED_SECRET: &str = "***redacted***";
pub(crate) fn redacted_secret(value: &str) -> &'static str {
if value.is_empty() { "" } else { REDACTED_SECRET }
}
pub(crate) fn redacted_secret_option(value: Option<&str>) -> Option<&'static str> {
value.map(redacted_secret)
}
/// KMS backend types
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum KmsBackend {
/// Vault KV v2 storage backend: master key material is stored directly in KV v2.
/// Confidentiality relies on Vault ACLs, KV v2 at-rest encryption, and TLS; the
/// backend performs no Transit wrapping of key material.
#[serde(rename = "VaultKV2", alias = "Vault")]
VaultKv2,
/// Vault Transit backend using Vault as the cryptographic source of truth
#[serde(rename = "VaultTransit")]
VaultTransit,
/// Local file-based backend for development and testing only
#[default]
Local,
/// Static single-key backend that derives DEKs from a pre-configured key
#[serde(rename = "Static")]
Static,
/// AWS KMS backend: AWS is the cryptographic source of truth and owns key
/// state, versioning, and the deletion window.
#[serde(rename = "AWS", alias = "AwsKms")]
Aws,
}
impl KmsBackend {
/// Stable identifier for logs, metrics, and audit records.
///
/// External consumers key off these values, so treat them as a wire
/// contract rather than a rendering detail.
pub fn as_str(&self) -> &'static str {
match self {
KmsBackend::VaultKv2 => "vault-kv2",
KmsBackend::VaultTransit => "vault-transit",
KmsBackend::Local => "local",
KmsBackend::Static => "static",
KmsBackend::Aws => "aws",
}
}
}
/// Main KMS configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KmsConfig {
/// Backend type
pub backend: KmsBackend,
/// Default master key ID for auto-encryption
pub default_key_id: Option<String>,
/// Backend-specific configuration
pub backend_config: BackendConfig,
/// Allow development-only insecure defaults such as plaintext local keys or HTTP Vault.
#[serde(default)]
pub allow_insecure_dev_defaults: bool,
/// Allow `DeleteKey` requests to skip the pending-deletion waiting window and
/// destroy key material right away.
///
/// Off by default: an immediate deletion is unrecoverable and takes every
/// object encrypted under the key with it, so the waiting window (plus
/// `CancelKeyDeletion`) is the only recovery path. Operators who genuinely
/// need immediate deletion — throwaway test clusters, key material that was
/// never used — must turn it on through server configuration
/// ([`ENV_KMS_ALLOW_IMMEDIATE_DELETION`]); the request must still echo the
/// key id back for confirmation.
///
/// Not part of the serialized configuration, and not settable through the
/// admin configure API. It is per-server operator state that has to be
/// re-stated to survive a restart: persisting it would carry one operator's
/// one-time enablement into the cluster-wide config that every node reloads,
/// long after the deletion it was turned on for.
#[serde(skip)]
pub allow_immediate_deletion: bool,
/// Timeout for a single backend attempt.
///
/// This bounds one outbound request, not the whole operation: the operation
/// policy owns the total deadline across retries. Values above 300 seconds
/// are clamped at use (see `KmsConfig::effective_timeout`).
pub timeout: Duration,
/// Number of retry attempts.
///
/// Values above 10 are clamped at use (see `KmsConfig::effective_retry_attempts`).
pub retry_attempts: u32,
/// Enable caching
pub enable_cache: bool,
/// Cache configuration
pub cache_config: CacheConfig,
}
impl Default for KmsConfig {
fn default() -> Self {
Self {
backend: KmsBackend::default(),
default_key_id: None,
backend_config: BackendConfig::default(),
allow_insecure_dev_defaults: false,
allow_immediate_deletion: false,
timeout: Duration::from_secs(30),
retry_attempts: 3,
enable_cache: true,
cache_config: CacheConfig::default(),
}
}
}
/// Backend-specific configuration
#[derive(Clone, Serialize, Deserialize)]
pub enum BackendConfig {
/// Local backend configuration
Local(LocalConfig),
/// Vault KV v2 storage backend configuration
#[serde(rename = "VaultKV2", alias = "Vault")]
VaultKv2(Box<VaultConfig>),
/// Vault Transit backend configuration
VaultTransit(Box<VaultTransitConfig>),
/// Static single-key backend configuration
Static(StaticConfig),
/// AWS KMS backend configuration
#[serde(rename = "AWS", alias = "AwsKms")]
Aws(Box<AwsKmsConfig>),
}
impl Default for BackendConfig {
fn default() -> Self {
Self::Local(LocalConfig::default())
}
}
impl fmt::Debug for BackendConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Local(config) => f.debug_tuple("Local").field(config).finish(),
Self::VaultKv2(config) => f.debug_tuple("VaultKv2").field(config).finish(),
Self::VaultTransit(config) => f.debug_tuple("VaultTransit").field(config).finish(),
Self::Static(config) => f.debug_tuple("Static").field(config).finish(),
Self::Aws(config) => f.debug_tuple("Aws").field(config).finish(),
}
}
}
/// Local KMS backend configuration
#[derive(Clone, Serialize, Deserialize)]
pub struct LocalConfig {
/// Directory to store key files
pub key_dir: PathBuf,
/// Master key for encrypting stored keys (if None, only explicit development-mode plaintext storage is allowed)
pub master_key: Option<String>,
/// File permissions for key files (octal)
pub file_permissions: Option<u32>,
}
impl fmt::Debug for LocalConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let master_key = redacted_secret_option(self.master_key.as_deref());
f.debug_struct("LocalConfig")
.field("key_dir", &self.key_dir)
.field("master_key", &master_key)
.field("file_permissions", &self.file_permissions)
.finish()
}
}
impl Default for LocalConfig {
fn default() -> Self {
Self {
key_dir: std::env::temp_dir().join("rustfs_kms_keys"),
master_key: None,
file_permissions: Some(0o600), // Owner read/write only
}
}
}
/// Static single-key KMS backend configuration
///
/// Uses a pre-configured AES-256 key to derive data encryption keys via
/// HMAC-SHA256 + AES-256-GCM, matching the MinIO builtin/static KMS wire format.
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct StaticConfig {
/// Key identifier (name) for the single configured key
pub key_id: String,
/// Base64-encoded 32-byte AES-256 key material (zeroed on drop)
#[serde(skip_serializing, default)]
pub secret_key: String,
}
impl Drop for StaticConfig {
fn drop(&mut self) {
use zeroize::Zeroize;
self.secret_key.zeroize();
}
}
impl fmt::Debug for StaticConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StaticConfig")
.field("key_id", &self.key_id)
.field("secret_key", &redacted_secret(&self.secret_key))
.finish()
}
}
impl StaticConfig {
/// Decode the base64-encoded secret key into raw bytes.
/// Returns an error if the key is not valid base64 or is not exactly 32 bytes.
pub fn decode_key(&self) -> Result<[u8; 32]> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&self.secret_key)
.map_err(|e| KmsError::configuration_error(format!("Static KMS secret key is not valid base64: {e}")))?;
if bytes.len() != 32 {
return Err(KmsError::configuration_error(format!(
"Static KMS secret key must be exactly 32 bytes after base64 decoding, got {} bytes",
bytes.len()
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(key)
}
}
/// Vault KV v2 backend configuration.
///
/// Key material and metadata are stored directly in KV v2; any identity with KV read
/// access to the key path can recover plaintext master key material. Use the Vault
/// Transit backend when cryptographic isolation of key material is required.
#[derive(Clone, Serialize, Deserialize)]
pub struct VaultConfig {
/// Vault server URL
pub address: String,
/// Authentication method
pub auth_method: VaultAuthMethod,
/// Vault namespace (Vault Enterprise)
pub namespace: Option<String>,
/// Deprecated: legacy Transit engine mount path. The Vault KV2 backend never calls
/// the Transit engine, so this value is unused; the field is retained (and
/// defaulted) only so previously persisted configurations keep deserializing.
#[serde(default = "default_vault_kv2_mount_path")]
pub mount_path: String,
/// KV engine mount path for storing keys
pub kv_mount: String,
/// Path prefix for keys in KV store
pub key_path_prefix: String,
/// TLS configuration
pub tls: Option<TlsConfig>,
}
impl fmt::Debug for VaultConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VaultConfig")
.field("address", &self.address)
.field("auth_method", &self.auth_method)
.field("namespace", &self.namespace)
.field("mount_path", &self.mount_path)
.field("kv_mount", &self.kv_mount)
.field("key_path_prefix", &self.key_path_prefix)
.field("tls", &self.tls)
.finish()
}
}
impl Default for VaultConfig {
fn default() -> Self {
Self {
address: "http://localhost:8200".to_string(),
auth_method: VaultAuthMethod::Token {
token: "dev-token".to_string(),
},
namespace: None,
mount_path: "transit".to_string(),
kv_mount: "secret".to_string(),
key_path_prefix: "rustfs/kms/keys".to_string(),
tls: None,
}
}
}
/// Vault Transit backend configuration
#[derive(Clone, Serialize, Deserialize)]
pub struct VaultTransitConfig {
/// Vault server URL
pub address: String,
/// Authentication method
pub auth_method: VaultAuthMethod,
/// Vault namespace (Vault Enterprise)
pub namespace: Option<String>,
/// Transit engine mount path
pub mount_path: String,
/// KV v2 mount path for persisting transit key metadata
#[serde(default = "default_vault_transit_metadata_kv_mount")]
pub metadata_kv_mount: String,
/// Key path prefix under metadata_kv_mount for transit key metadata storage
#[serde(default = "default_vault_transit_metadata_key_prefix")]
pub metadata_key_prefix: String,
/// TLS configuration
pub tls: Option<TlsConfig>,
}
impl fmt::Debug for VaultTransitConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VaultTransitConfig")
.field("address", &self.address)
.field("auth_method", &self.auth_method)
.field("namespace", &self.namespace)
.field("mount_path", &self.mount_path)
.field("metadata_kv_mount", &self.metadata_kv_mount)
.field("metadata_key_prefix", &self.metadata_key_prefix)
.field("tls", &self.tls)
.finish()
}
}
impl Default for VaultTransitConfig {
fn default() -> Self {
Self {
address: "http://localhost:8200".to_string(),
auth_method: VaultAuthMethod::Token {
token: "dev-token".to_string(),
},
namespace: None,
mount_path: "transit".to_string(),
metadata_kv_mount: default_vault_transit_metadata_kv_mount(),
metadata_key_prefix: default_vault_transit_metadata_key_prefix(),
tls: None,
}
}
}
/// Vault authentication methods
#[derive(Clone, Serialize, Deserialize)]
pub enum VaultAuthMethod {
/// Token authentication
Token { token: String },
/// AppRole authentication: login with `role_id` + `secret_id` for a
/// lease-bound token that is renewed in the background.
AppRole {
role_id: String,
/// Inline secret_id; used only when `secret_id_file` is unset.
secret_id: String,
/// Path to a file holding the secret_id. Re-read on every login so an
/// externally rotated secret_id is picked up; takes precedence over the
/// inline value.
#[serde(default)]
secret_id_file: Option<PathBuf>,
/// AppRole auth engine mount path.
#[serde(default = "default_vault_approle_mount")]
mount: String,
/// Fail-closed margin in seconds: once the current token is within this
/// window of expiry without a successful refresh, requests are refused
/// instead of sent with a token that may lapse mid-flight. Defaults to
/// the per-attempt timeout.
#[serde(default)]
refresh_safety_window_secs: Option<u64>,
},
/// Agent-managed token file (for example a Vault Agent auto-auth sink):
/// the token is read from `path` and re-read periodically so a token
/// rotated by the agent is picked up without a restart.
TokenFile {
path: PathBuf,
/// Seconds between token file re-reads. Each successful read also
/// extends the token's observed validity to twice this value, so a
/// file that stops being readable eventually trips the fail-closed
/// window. Defaults to 30 seconds.
#[serde(default)]
poll_interval_secs: Option<u64>,
/// Fail-closed margin in seconds, as on `AppRole`. Defaults to the
/// per-attempt timeout.
#[serde(default)]
refresh_safety_window_secs: Option<u64>,
},
}
impl VaultAuthMethod {
/// AppRole authentication with the default mount and no secret-id file.
pub fn approle(role_id: String, secret_id: String) -> Self {
Self::AppRole {
role_id,
secret_id,
secret_id_file: None,
mount: default_vault_approle_mount(),
refresh_safety_window_secs: None,
}
}
/// Agent-managed token file with the default poll interval.
pub fn token_file(path: PathBuf) -> Self {
Self::TokenFile {
path,
poll_interval_secs: None,
refresh_safety_window_secs: None,
}
}
}
impl fmt::Debug for VaultAuthMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Token { token } => f.debug_struct("Token").field("token", &redacted_secret(token)).finish(),
Self::AppRole {
role_id,
secret_id,
secret_id_file,
mount,
refresh_safety_window_secs,
} => f
.debug_struct("AppRole")
.field("role_id", role_id)
.field("secret_id", &redacted_secret(secret_id))
.field("secret_id_file", secret_id_file)
.field("mount", mount)
.field("refresh_safety_window_secs", refresh_safety_window_secs)
.finish(),
Self::TokenFile {
path,
poll_interval_secs,
refresh_safety_window_secs,
} => f
.debug_struct("TokenFile")
.field("path", path)
.field("poll_interval_secs", poll_interval_secs)
.field("refresh_safety_window_secs", refresh_safety_window_secs)
.finish(),
}
}
}
/// TLS configuration for Vault
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsConfig {
/// Path to CA certificate file
pub ca_cert_path: Option<PathBuf>,
/// Path to client certificate file
pub client_cert_path: Option<PathBuf>,
/// Path to client private key file
pub client_key_path: Option<PathBuf>,
/// Skip TLS verification (insecure, for development only)
pub skip_verify: bool,
}
/// Cache configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
/// Maximum number of keys to cache
pub max_keys: usize,
/// Lifetime of a cached key metadata entry.
///
/// This bounds how long a describe can answer from metadata that another
/// node has since changed (disable, schedule-deletion); encrypt, decrypt
/// and data key generation never read the cache. Values above 24 hours are
/// clamped at use (see [`CacheConfig::effective_ttl`]).
pub ttl: Duration,
/// Publish the `rustfs_kms_metadata_cache_*` metrics.
///
/// Only metrics-recorder output is gated: the counters behind the admin
/// status API are maintained either way.
pub enable_metrics: bool,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
max_keys: DEFAULT_MAX_CACHED_KEYS,
ttl: DEFAULT_CACHE_TTL,
enable_metrics: true,
}
}
}
impl CacheConfig {
/// Metadata lifetime with the configured value clamped to the supported maximum.
///
/// This is the value the cache is built with and the value reported back to
/// operators, so what the admin API advertises is what the cache does.
pub fn effective_ttl(&self) -> Duration {
self.ttl.min(MAX_CACHE_TTL)
}
}
/// AWS KMS backend configuration.
///
/// Deliberately holds no credential material: the backend resolves credentials
/// through the standard `aws-config` provider chain (environment, shared
/// profile, container/IMDS role), so RustFS never stores, persists, or redacts
/// AWS secrets of its own.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct AwsKmsConfig {
/// AWS region hosting the KMS keys. When unset, the region is resolved by
/// the standard chain (`AWS_REGION`, profile, IMDS).
#[serde(default)]
pub region: Option<String>,
/// Override for the KMS endpoint, for local emulators and private
/// endpoints. Unset in production, where the SDK derives the regional
/// endpoint.
#[serde(default)]
pub endpoint_url: Option<String>,
}
impl KmsConfig {
/// Create a new KMS configuration for local backend (for development and testing only)
pub fn local(key_dir: PathBuf) -> Self {
Self {
backend: KmsBackend::Local,
backend_config: BackendConfig::Local(LocalConfig {
key_dir,
..Default::default()
}),
..Default::default()
}
}
/// Create a new KMS configuration for the Vault KV v2 backend with token authentication.
///
/// Master key material is stored directly in Vault KV v2: confidentiality relies on
/// Vault ACLs, KV v2 at-rest encryption, and TLS. KV read access to the key path is
/// equivalent to holding the plaintext master keys. Use [`KmsConfig::vault_transit`]
/// when key material must never be readable through Vault storage APIs.
pub fn vault(address: Url, token: String) -> Self {
Self {
backend: KmsBackend::VaultKv2,
backend_config: BackendConfig::VaultKv2(Box::new(VaultConfig {
address: address.to_string(),
auth_method: VaultAuthMethod::Token { token },
..Default::default()
})),
..Default::default()
}
}
/// Create a new KMS configuration for the Vault KV v2 backend with AppRole authentication.
///
/// Shares the security boundary described on [`KmsConfig::vault`]: key material lives
/// in KV v2 and is protected only by Vault ACLs and KV v2 at-rest encryption.
pub fn vault_approle(address: Url, role_id: String, secret_id: String) -> Self {
Self {
backend: KmsBackend::VaultKv2,
backend_config: BackendConfig::VaultKv2(Box::new(VaultConfig {
address: address.to_string(),
auth_method: VaultAuthMethod::approle(role_id, secret_id),
..Default::default()
})),
..Default::default()
}
}
/// Create a new KMS configuration for Vault Transit backend with token authentication
pub fn vault_transit(address: Url, token: String) -> Self {
Self {
backend: KmsBackend::VaultTransit,
backend_config: BackendConfig::VaultTransit(Box::new(VaultTransitConfig {
address: address.to_string(),
auth_method: VaultAuthMethod::Token { token },
..Default::default()
})),
..Default::default()
}
}
/// Create a new KMS configuration for static single-key backend
///
/// # Arguments
/// * `key_id` - The key identifier (name) for the configured key
/// * `secret_key` - Base64-encoded 32-byte AES-256 key material
pub fn static_kms(key_id: String, secret_key: String) -> Self {
Self {
backend: KmsBackend::Static,
backend_config: BackendConfig::Static(StaticConfig {
key_id: key_id.clone(),
secret_key,
}),
default_key_id: Some(key_id),
..Default::default()
}
}
/// Get the local configuration if backend is Local
pub fn local_config(&self) -> Option<&LocalConfig> {
match &self.backend_config {
BackendConfig::Local(config) => Some(config),
_ => None,
}
}
/// Get the Vault KV2 configuration if backend is VaultKv2
pub fn vault_config(&self) -> Option<&VaultConfig> {
match &self.backend_config {
BackendConfig::VaultKv2(config) => Some(config),
_ => None,
}
}
/// Get the Vault Transit configuration if backend is VaultTransit
pub fn vault_transit_config(&self) -> Option<&VaultTransitConfig> {
match &self.backend_config {
BackendConfig::VaultTransit(config) => Some(config),
_ => None,
}
}
/// Get the static configuration if backend is Static
pub fn static_config(&self) -> Option<&StaticConfig> {
match &self.backend_config {
BackendConfig::Static(config) => Some(config),
_ => None,
}
}
/// Create a new KMS configuration for the AWS KMS backend.
///
/// Credentials are resolved by the standard `aws-config` provider chain;
/// only the region is configured here.
pub fn aws(region: Option<String>) -> Self {
Self {
backend: KmsBackend::Aws,
backend_config: BackendConfig::Aws(Box::new(AwsKmsConfig {
region,
endpoint_url: None,
})),
..Default::default()
}
}
/// Get the AWS configuration if backend is AWS KMS
pub fn aws_kms_config(&self) -> Option<&AwsKmsConfig> {
match &self.backend_config {
BackendConfig::Aws(config) => Some(config),
_ => None,
}
}
/// Set default key ID
pub fn with_default_key(mut self, key_id: String) -> Self {
self.default_key_id = Some(key_id);
self
}
/// Explicitly allow development-only KMS defaults.
pub fn with_insecure_development_defaults(mut self) -> Self {
self.allow_insecure_dev_defaults = true;
self
}
/// Explicitly allow deletions that bypass the pending-deletion waiting window.
pub fn with_immediate_deletion_allowed(mut self) -> Self {
self.allow_immediate_deletion = true;
self
}
/// Set operation timeout
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Enable or disable caching
pub fn with_cache(mut self, enable: bool) -> Self {
self.enable_cache = enable;
self
}
/// Per-attempt timeout with the configured value clamped to the supported maximum.
pub(crate) fn effective_timeout(&self) -> Duration {
self.timeout.min(MAX_OPERATION_TIMEOUT)
}
/// Retry attempts with the configured value clamped to the supported maximum.
pub(crate) fn effective_retry_attempts(&self) -> u32 {
self.retry_attempts.min(MAX_RETRY_ATTEMPTS)
}
/// Validate the configuration
pub fn validate(&self) -> Result<()> {
// Validate timeout
if self.timeout.is_zero() {
return Err(KmsError::configuration_error("Timeout must be greater than 0"));
}
// Validate retry attempts
if self.retry_attempts == 0 {
return Err(KmsError::configuration_error("Retry attempts must be greater than 0"));
}
// Oversized values are clamped at use (not rejected) so pre-existing
// configurations cannot keep the service from starting after upgrade.
if self.timeout > MAX_OPERATION_TIMEOUT {
tracing::warn!(
configured_secs = self.timeout.as_secs(),
max_secs = MAX_OPERATION_TIMEOUT.as_secs(),
"KMS timeout exceeds the supported maximum; backend operations clamp it to the maximum"
);
}
if self.retry_attempts > MAX_RETRY_ATTEMPTS {
tracing::warn!(
configured = self.retry_attempts,
max = MAX_RETRY_ATTEMPTS,
"KMS retry_attempts exceeds the supported maximum; backend operations clamp it to the maximum"
);
}
// Validate backend-specific configuration
match &self.backend_config {
BackendConfig::Local(config) => {
if !config.key_dir.is_absolute() {
return Err(KmsError::configuration_error("Local key directory must be an absolute path"));
}
if !self.allow_insecure_dev_defaults {
if config.master_key.as_deref().is_none_or(str::is_empty) {
return Err(development_default_error(
"Local KMS requires a master key outside explicit development mode",
));
}
if is_under_temp_dir(&config.key_dir) {
return Err(development_default_error(
"Local KMS key directory must not be under the process temp directory outside explicit development mode",
));
}
}
}
BackendConfig::VaultKv2(config) => {
if !config.address.starts_with("http://") && !config.address.starts_with("https://") {
return Err(KmsError::configuration_error("Vault KV2 address must use http or https scheme"));
}
validate_vault_auth_method("Vault KV2", &config.auth_method)?;
if !self.allow_insecure_dev_defaults {
validate_vault_development_defaults("Vault KV2", &config.address, &config.auth_method, config.tls.as_ref())?;
}
// `mount_path` is deprecated and unused by this backend, so an empty value
// is deliberately not an error.
// Validate TLS configuration if using HTTPS
if config.address.starts_with("https://")
&& let Some(ref tls) = config.tls
&& !tls.skip_verify
{
// In production, we should have proper TLS configuration
if tls.ca_cert_path.is_none() && tls.client_cert_path.is_none() {
tracing::warn!("Using HTTPS without custom TLS configuration - relying on system CA");
}
}
}
BackendConfig::VaultTransit(config) => {
if !config.address.starts_with("http://") && !config.address.starts_with("https://") {
return Err(KmsError::configuration_error("Vault Transit address must use http or https scheme"));
}
validate_vault_auth_method("Vault Transit", &config.auth_method)?;
if !self.allow_insecure_dev_defaults {
validate_vault_development_defaults(
"Vault Transit",
&config.address,
&config.auth_method,
config.tls.as_ref(),
)?;
}
if config.mount_path.is_empty() {
return Err(KmsError::configuration_error("Vault Transit mount path cannot be empty"));
}
if config.metadata_kv_mount.is_empty() {
return Err(KmsError::configuration_error("Vault Transit metadata KV mount cannot be empty"));
}
if config.metadata_key_prefix.is_empty() {
return Err(KmsError::configuration_error("Vault Transit metadata key prefix cannot be empty"));
}
if config.address.starts_with("https://")
&& let Some(ref tls) = config.tls
&& !tls.skip_verify
&& tls.ca_cert_path.is_none()
&& tls.client_cert_path.is_none()
{
tracing::warn!("Using HTTPS without custom TLS configuration - relying on system CA");
}
}
BackendConfig::Static(config) => {
if config.key_id.is_empty() {
return Err(KmsError::configuration_error("Static KMS key_id cannot be empty"));
}
if config.secret_key.is_empty() {
return Err(KmsError::configuration_error("Static KMS secret_key cannot be empty"));
}
// Validate that the key can be decoded (right length, valid base64)
config.decode_key()?;
}
BackendConfig::Aws(config) => {
if let Some(region) = &config.region
&& region.is_empty()
{
return Err(KmsError::configuration_error("AWS KMS region cannot be empty when set"));
}
if let Some(endpoint) = &config.endpoint_url {
if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
return Err(KmsError::configuration_error("AWS KMS endpoint URL must use http or https scheme"));
}
// A plaintext endpoint override exposes every KMS request,
// including plaintext data keys, so it stays gated on the
// explicit development opt-in.
if endpoint.starts_with("http://") && !self.allow_insecure_dev_defaults {
return Err(development_default_error("AWS KMS endpoint URL must use https"));
}
}
}
}
// Validate cache configuration
if self.enable_cache {
if self.cache_config.max_keys == 0 {
return Err(KmsError::configuration_error("Cache max_keys must be greater than 0"));
}
// A zero TTL expires every entry on insert, which is a cache that
// cannot serve anything; reject it instead of silently disabling
// the cache the operator asked for.
if self.cache_config.ttl.is_zero() {
return Err(KmsError::configuration_error("Cache ttl must be greater than 0"));
}
}
Ok(())
}
/// Load configuration from environment variables
pub fn from_env() -> Result<Self> {
let mut config = Self::default();
// Backend type
if let Some(backend_type) = get_env_opt_str("RUSTFS_KMS_BACKEND") {
config.backend = match backend_type.to_lowercase().as_str() {
"local" => KmsBackend::Local,
"vault" | "vault-kv2" | "vault_kv2" => KmsBackend::VaultKv2,
"vault-transit" | "vault_transit" => KmsBackend::VaultTransit,
"static" => KmsBackend::Static,
"aws" | "aws-kms" | "aws_kms" => KmsBackend::Aws,
_ => return Err(KmsError::configuration_error(format!("Unknown KMS backend: {backend_type}"))),
};
}
// Default key ID
if let Some(key_id) = get_env_opt_str("RUSTFS_KMS_DEFAULT_KEY_ID") {
config.default_key_id = Some(key_id);
}
// Timeout
if let Some(timeout_str) = get_env_opt_str("RUSTFS_KMS_TIMEOUT_SECS") {
let timeout_secs = timeout_str
.parse::<u64>()
.map_err(|_| KmsError::configuration_error("Invalid timeout value"))?;
config.timeout = Duration::from_secs(timeout_secs);
}
// Retry attempts
if let Some(retries_str) = get_env_opt_str("RUSTFS_KMS_RETRY_ATTEMPTS") {
config.retry_attempts = retries_str
.parse()
.map_err(|_| KmsError::configuration_error("Invalid retry attempts value"))?;
}
// Enable cache
config.enable_cache = get_env_bool("RUSTFS_KMS_ENABLE_CACHE", config.enable_cache);
config.allow_insecure_dev_defaults =
get_env_bool(ENV_KMS_ALLOW_INSECURE_DEV_DEFAULTS, config.allow_insecure_dev_defaults);
config.allow_immediate_deletion = get_env_bool(ENV_KMS_ALLOW_IMMEDIATE_DELETION, config.allow_immediate_deletion);
// Backend-specific configuration
match config.backend {
KmsBackend::Local => {
let key_dir = get_env_str("RUSTFS_KMS_LOCAL_KEY_DIR", "./kms_keys");
let master_key = get_env_opt_str("RUSTFS_KMS_LOCAL_MASTER_KEY");
config.backend_config = BackendConfig::Local(LocalConfig {
key_dir: PathBuf::from(key_dir),
master_key,
file_permissions: Some(0o600),
});
}
KmsBackend::VaultKv2 => {
let address = get_env_str("RUSTFS_KMS_VAULT_ADDRESS", "http://localhost:8200");
let auth_method = vault_auth_method_from_env()?;
let skip_tls_verify = get_env_bool(ENV_KMS_VAULT_SKIP_TLS_VERIFY, false);
let mount_path = match get_env_opt_str("RUSTFS_KMS_VAULT_MOUNT_PATH") {
Some(path) => {
tracing::warn!(
"RUSTFS_KMS_VAULT_MOUNT_PATH is deprecated for the Vault KV2 backend: it never calls the Transit engine and the value is stored but unused"
);
path
}
None => default_vault_kv2_mount_path(),
};
config.backend_config = BackendConfig::VaultKv2(Box::new(VaultConfig {
address,
auth_method,
namespace: get_env_opt_str("RUSTFS_KMS_VAULT_NAMESPACE"),
mount_path,
kv_mount: get_env_str("RUSTFS_KMS_VAULT_KV_MOUNT", "secret"),
key_path_prefix: get_env_str("RUSTFS_KMS_VAULT_KEY_PREFIX", "rustfs/kms/keys"),
tls: vault_tls_config(skip_tls_verify),
}));
}
KmsBackend::VaultTransit => {
let address = get_env_str("RUSTFS_KMS_VAULT_ADDRESS", "http://localhost:8200");
let auth_method = vault_auth_method_from_env()?;
let skip_tls_verify = get_env_bool(ENV_KMS_VAULT_SKIP_TLS_VERIFY, false);
config.backend_config = BackendConfig::VaultTransit(Box::new(VaultTransitConfig {
address,
auth_method,
namespace: get_env_opt_str("RUSTFS_KMS_VAULT_NAMESPACE"),
mount_path: get_env_str("RUSTFS_KMS_VAULT_MOUNT_PATH", "transit"),
metadata_kv_mount: get_env_str(
ENV_KMS_VAULT_TRANSIT_METADATA_KV_MOUNT,
DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT,
),
metadata_key_prefix: get_env_str(
ENV_KMS_VAULT_TRANSIT_METADATA_PREFIX,
DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX,
),
tls: vault_tls_config(skip_tls_verify),
}));
}
KmsBackend::Static => {
// Read from file first, then fall back to direct env var
let secret_str = if let Some(file_path) = get_env_opt_str(ENV_KMS_STATIC_SECRET_KEY_FILE) {
std::fs::read_to_string(&file_path).map_err(|e| {
KmsError::configuration_error(format!("Failed to read static KMS secret key file {file_path}: {e}"))
})?
} else {
get_env_str(ENV_KMS_STATIC_SECRET_KEY, "")
};
let secret_str = secret_str.trim().to_string();
if secret_str.is_empty() {
return Err(KmsError::configuration_error(format!(
"Static KMS requires {ENV_KMS_STATIC_SECRET_KEY} or {ENV_KMS_STATIC_SECRET_KEY_FILE} to be set"
)));
}
// Parse format: <key-id>:<base64-key>
let colon_pos = secret_str.find(':').ok_or_else(|| {
KmsError::configuration_error("Static KMS secret key must be in format <key-name>:<base64-key>")
})?;
let key_id = secret_str[..colon_pos].to_string();
let secret_key = secret_str[colon_pos + 1..].to_string();
if key_id.is_empty() {
return Err(KmsError::configuration_error(
"Static KMS key name must not be empty in secret key string",
));
}
if secret_key.is_empty() {
return Err(KmsError::configuration_error(
"Static KMS base64 key must not be empty in secret key string",
));
}
config.backend_config = BackendConfig::Static(StaticConfig {
key_id: key_id.clone(),
secret_key,
});
config.default_key_id = Some(key_id);
}
KmsBackend::Aws => {
// Only non-credential settings are read here; access keys,
// profiles, and role assumption stay with the aws-config chain.
config.backend_config = BackendConfig::Aws(Box::new(AwsKmsConfig {
region: get_env_opt_str(ENV_KMS_AWS_REGION),
endpoint_url: get_env_opt_str(ENV_KMS_AWS_ENDPOINT_URL),
}));
}
}
config.validate()?;
Ok(config)
}
}
/// Read the immediate-deletion gate from the environment.
///
/// Callers that assemble a [`KmsConfig`] field by field instead of going
/// through [`KmsConfig::from_env`] use this, so the gate keeps one name, one
/// default, and one place to look it up.
pub fn allow_immediate_deletion_from_env() -> bool {
get_env_bool(ENV_KMS_ALLOW_IMMEDIATE_DELETION, false)
}
fn vault_tls_config(skip_tls_verify: bool) -> Option<TlsConfig> {
skip_tls_verify.then_some(TlsConfig {
ca_cert_path: None,
client_cert_path: None,
client_key_path: None,
skip_verify: true,
})
}
fn development_default_error(reason: &str) -> KmsError {
KmsError::configuration_error(format!("{reason}; set {ENV_KMS_ALLOW_INSECURE_DEV_DEFAULTS}=true only for development"))
}
fn is_under_temp_dir(path: &Path) -> bool {
path.starts_with(std::env::temp_dir())
}
/// Resolve the Vault auth method from environment variables.
///
/// Setting `RUSTFS_KMS_VAULT_APPROLE_ROLE_ID` selects AppRole authentication;
/// the secret_id then comes from `RUSTFS_KMS_VAULT_APPROLE_SECRET_ID_FILE`
/// (re-read on every login, mirroring the `RUSTFS_KMS_STATIC_SECRET_KEY_FILE`
/// precedent) or inline from `RUSTFS_KMS_VAULT_APPROLE_SECRET_ID`, with the
/// file taking precedence. Without a role id the legacy token flow applies.
fn vault_auth_method_from_env() -> Result<VaultAuthMethod> {
if let Some(token_file) = get_env_opt_str(ENV_KMS_VAULT_TOKEN_FILE) {
// A token file names one authoritative credential source; combining it
// with another one would leave the effective identity ambiguous, so
// that is a configuration error rather than a precedence rule.
if get_env_opt_str(ENV_KMS_VAULT_APPROLE_ROLE_ID).is_some() {
return Err(KmsError::configuration_error(format!(
"{ENV_KMS_VAULT_TOKEN_FILE} cannot be combined with {ENV_KMS_VAULT_APPROLE_ROLE_ID}; configure exactly one Vault auth method"
)));
}
if get_env_opt_str("RUSTFS_KMS_VAULT_TOKEN").is_some() {
return Err(KmsError::configuration_error(format!(
"{ENV_KMS_VAULT_TOKEN_FILE} cannot be combined with RUSTFS_KMS_VAULT_TOKEN; configure exactly one Vault auth method"
)));
}
return Ok(VaultAuthMethod::token_file(PathBuf::from(token_file)));
}
let Some(role_id) = get_env_opt_str(ENV_KMS_VAULT_APPROLE_ROLE_ID) else {
return Ok(VaultAuthMethod::Token {
token: get_env_str("RUSTFS_KMS_VAULT_TOKEN", "dev-token"),
});
};
let secret_id_file = get_env_opt_str(ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE).map(PathBuf::from);
let secret_id = get_env_opt_str(ENV_KMS_VAULT_APPROLE_SECRET_ID).unwrap_or_default();
if secret_id.is_empty() && secret_id_file.is_none() {
return Err(KmsError::configuration_error(format!(
"Vault AppRole requires {ENV_KMS_VAULT_APPROLE_SECRET_ID} or {ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE} to be set"
)));
}
Ok(VaultAuthMethod::AppRole {
role_id,
secret_id,
secret_id_file,
mount: get_env_str(ENV_KMS_VAULT_APPROLE_MOUNT, DEFAULT_VAULT_APPROLE_MOUNT),
refresh_safety_window_secs: None,
})
}
fn validate_vault_auth_method(backend_name: &str, auth_method: &VaultAuthMethod) -> Result<()> {
match auth_method {
VaultAuthMethod::Token { .. } => Ok(()),
VaultAuthMethod::AppRole {
role_id,
secret_id,
secret_id_file,
mount,
..
} => {
if role_id.is_empty() {
return Err(KmsError::configuration_error(format!("{backend_name} AppRole role_id cannot be empty")));
}
if secret_id.is_empty() && secret_id_file.is_none() {
return Err(KmsError::configuration_error(format!(
"{backend_name} AppRole requires a secret_id or a secret_id_file"
)));
}
if mount.is_empty() {
return Err(KmsError::configuration_error(format!("{backend_name} AppRole mount cannot be empty")));
}
Ok(())
}
VaultAuthMethod::TokenFile {
path,
poll_interval_secs,
..
} => {
if path.as_os_str().is_empty() {
return Err(KmsError::configuration_error(format!("{backend_name} token file path cannot be empty")));
}
if poll_interval_secs == &Some(0) {
return Err(KmsError::configuration_error(format!(
"{backend_name} token file poll interval must be greater than 0"
)));
}
Ok(())
}
}
}
fn validate_vault_development_defaults(
backend_name: &str,
address: &str,
auth_method: &VaultAuthMethod,
tls: Option<&TlsConfig>,
) -> Result<()> {
if address.starts_with("http://") {
return Err(development_default_error(&format!(
"{backend_name} requires HTTPS outside explicit development mode"
)));
}
if matches!(auth_method, VaultAuthMethod::Token { token } if token == "dev-token") {
return Err(development_default_error(&format!(
"{backend_name} default dev-token is not allowed outside explicit development mode"
)));
}
if tls.is_some_and(|tls| tls.skip_verify) {
return Err(development_default_error(&format!(
"{backend_name} skip TLS verification is not allowed outside explicit development mode"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rustfs_security_governance::validate_redaction_rules;
use temp_env::with_vars;
use tempfile::TempDir;
#[test]
fn test_default_config() {
let config = KmsConfig::default();
assert_eq!(config.backend, KmsBackend::Local);
assert!(config.validate().is_err());
assert!(config.with_insecure_development_defaults().validate().is_ok());
}
#[test]
fn test_local_config() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let config = KmsConfig::local(temp_dir.path().to_path_buf()).with_insecure_development_defaults();
assert_eq!(config.backend, KmsBackend::Local);
assert!(config.validate().is_ok());
let local_config = config.local_config().expect("Should have local config");
assert_eq!(local_config.key_dir, temp_dir.path());
}
#[test]
fn oversized_cache_ttl_is_clamped_not_rejected() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let config = KmsConfig::local(temp_dir.path().to_path_buf()).with_insecure_development_defaults();
assert_eq!(config.cache_config.ttl, DEFAULT_CACHE_TTL);
assert_eq!(config.cache_config.effective_ttl(), DEFAULT_CACHE_TTL);
// An oversized lifetime must not keep the service from starting, and
// must not reach moka, whose builder panics beyond 1000 years.
let config = KmsConfig {
cache_config: CacheConfig {
ttl: Duration::from_secs(u64::MAX),
..Default::default()
},
..config
};
assert!(config.validate().is_ok());
assert_eq!(config.cache_config.effective_ttl(), MAX_CACHE_TTL);
// In-range values pass through unchanged.
let config = KmsConfig {
cache_config: CacheConfig {
ttl: Duration::from_secs(600),
..Default::default()
},
..config
};
assert!(config.validate().is_ok());
assert_eq!(config.cache_config.effective_ttl(), Duration::from_secs(600));
}
#[test]
fn zero_cache_ttl_is_rejected_only_while_caching_is_enabled() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let config = KmsConfig {
cache_config: CacheConfig {
ttl: Duration::ZERO,
..Default::default()
},
..KmsConfig::local(temp_dir.path().to_path_buf()).with_insecure_development_defaults()
};
// A cache that expires every entry on insert is a misconfiguration, not
// a way to turn caching off.
assert!(config.validate().is_err());
let config = KmsConfig {
enable_cache: false,
..config
};
assert!(config.validate().is_ok());
}
#[test]
fn test_oversized_timeout_and_retries_clamped_not_rejected() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let config = KmsConfig {
timeout: Duration::from_secs(3_600),
retry_attempts: 50,
..KmsConfig::local(temp_dir.path().to_path_buf()).with_insecure_development_defaults()
};
// Out-of-range values must not keep the service from starting.
assert!(config.validate().is_ok());
assert_eq!(config.effective_timeout(), MAX_OPERATION_TIMEOUT);
assert_eq!(config.effective_retry_attempts(), MAX_RETRY_ATTEMPTS);
// In-range values pass through unchanged.
let config = KmsConfig {
timeout: Duration::from_secs(45),
retry_attempts: 5,
..config
};
assert!(config.validate().is_ok());
assert_eq!(config.effective_timeout(), Duration::from_secs(45));
assert_eq!(config.effective_retry_attempts(), 5);
}
#[test]
fn test_local_development_defaults_require_opt_in() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let config = KmsConfig::local(temp_dir.path().to_path_buf());
assert!(config.validate().is_err());
assert!(config.with_insecure_development_defaults().validate().is_ok());
let production_config = KmsConfig {
backend: KmsBackend::Local,
backend_config: BackendConfig::Local(LocalConfig {
key_dir: PathBuf::from("/var/lib/rustfs/kms"),
master_key: Some("production-master-key".to_string()),
file_permissions: Some(0o600),
}),
..Default::default()
};
assert!(production_config.validate().is_ok());
}
#[test]
fn test_vault_config() {
let address = Url::parse("https://vault.example.com:8200").expect("Valid URL");
let config = KmsConfig::vault(address.clone(), "test-token".to_string());
assert_eq!(config.backend, KmsBackend::VaultKv2);
assert!(config.validate().is_ok());
let vault_config = config.vault_config().expect("Should have vault config");
assert_eq!(vault_config.address, address.as_str());
}
#[test]
fn test_vault_transit_config() {
let address = Url::parse("https://vault.example.com:8200").expect("Valid URL");
let config = KmsConfig::vault_transit(address.clone(), "test-token".to_string());
assert_eq!(config.backend, KmsBackend::VaultTransit);
assert!(config.validate().is_ok());
let vault_config = config.vault_transit_config().expect("Should have vault transit config");
assert_eq!(vault_config.address, address.as_str());
assert_eq!(vault_config.mount_path, "transit");
}
#[test]
fn test_vault_development_defaults_require_opt_in() {
let http_address = Url::parse("http://127.0.0.1:8200").expect("Valid URL");
let https_address = Url::parse("https://vault.example.com:8200").expect("Valid URL");
let http_config = KmsConfig::vault(http_address, "vault-token".to_string());
assert!(http_config.validate().is_err());
assert!(http_config.with_insecure_development_defaults().validate().is_ok());
let dev_token_config = KmsConfig::vault(https_address.clone(), "dev-token".to_string());
assert!(dev_token_config.validate().is_err());
assert!(dev_token_config.with_insecure_development_defaults().validate().is_ok());
let skip_tls_config = KmsConfig {
backend: KmsBackend::VaultTransit,
backend_config: BackendConfig::VaultTransit(Box::new(VaultTransitConfig {
address: https_address.to_string(),
auth_method: VaultAuthMethod::Token {
token: "vault-token".to_string(),
},
namespace: None,
mount_path: "transit".to_string(),
metadata_kv_mount: DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT.to_string(),
metadata_key_prefix: DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX.to_string(),
tls: Some(TlsConfig {
ca_cert_path: None,
client_cert_path: None,
client_key_path: None,
skip_verify: true,
}),
})),
..Default::default()
};
assert!(skip_tls_config.validate().is_err());
assert!(skip_tls_config.with_insecure_development_defaults().validate().is_ok());
}
#[test]
fn test_vault_kv2_backend_serialization_uses_pascal_case() {
let serialized = serde_json::to_string(&KmsBackend::VaultKv2).expect("backend should serialize");
assert_eq!(serialized, "\"VaultKV2\"");
let legacy: KmsBackend = serde_json::from_str("\"Vault\"").expect("legacy Vault label should deserialize");
assert_eq!(legacy, KmsBackend::VaultKv2);
}
#[test]
fn test_legacy_persisted_backend_config_vault_key_deserializes() {
let raw = r#"{
"backend": "Vault",
"backend_config": {
"Vault": {
"address": "http://127.0.0.1:8200",
"auth_method": { "Token": { "token": "t" } },
"namespace": null,
"mount_path": "transit",
"kv_mount": "secret",
"key_path_prefix": "rustfs/kms/keys",
"tls": null
}
},
"default_key_id": null,
"timeout": {"secs": 30, "nanos": 0},
"retry_attempts": 3,
"enable_cache": true,
"cache_config": {
"max_keys": 1000,
"ttl": {"secs": 3600, "nanos": 0},
"enable_metrics": true
}
}"#;
let config: KmsConfig = serde_json::from_str(raw).expect("legacy persisted kms config");
assert_eq!(config.backend, KmsBackend::VaultKv2);
assert!(config.vault_config().is_some());
}
#[test]
fn test_persisted_vault_kv2_config_without_mount_path_deserializes() {
// Configurations persisted after mount_path was deprecated may omit the field;
// it must default instead of failing deserialization.
let raw = r#"{
"backend": "VaultKV2",
"backend_config": {
"VaultKV2": {
"address": "http://127.0.0.1:8200",
"auth_method": { "Token": { "token": "t" } },
"namespace": null,
"kv_mount": "secret",
"key_path_prefix": "rustfs/kms/keys",
"tls": null
}
},
"default_key_id": null,
"timeout": {"secs": 30, "nanos": 0},
"retry_attempts": 3,
"enable_cache": true,
"cache_config": {
"max_keys": 1000,
"ttl": {"secs": 3600, "nanos": 0},
"enable_metrics": true
}
}"#;
let config: KmsConfig = serde_json::from_str(raw).expect("persisted kms config without mount_path");
assert_eq!(config.backend, KmsBackend::VaultKv2);
let vault = config.vault_config().expect("vault-kv2 config");
assert_eq!(vault.mount_path, "transit");
}
#[test]
fn test_vault_kv2_empty_mount_path_passes_validation() {
let address = Url::parse("https://vault.example.com:8200").expect("Valid URL");
let mut config = KmsConfig::vault(address, "test-token".to_string());
if let BackendConfig::VaultKv2(vault) = &mut config.backend_config {
vault.mount_path = String::new();
}
assert!(config.validate().is_ok(), "deprecated mount_path must not be required");
}
#[test]
fn test_vault_kv2_sources_do_not_claim_transit_wrapping() {
let sources = [
("config.rs", include_str!("config.rs")),
("api_types.rs", include_str!("api_types.rs")),
("backends/vault.rs", include_str!("backends/vault.rs")),
("lib.rs", include_str!("lib.rs")),
];
// Assemble the needles at runtime so this guard does not match its own source.
let needles = [
format!("wrapping via {}", "Transit"),
format!("KV v2 + {}", "Transit"),
format!("KV2+{}", "Transit"),
format!("you would use Vault's {} engine", "transit"),
];
for (name, source) in sources {
for needle in &needles {
assert!(
!source.contains(needle.as_str()),
"{name} still describes the Vault KV2 backend with `{needle}`"
);
}
}
}
#[test]
fn test_legacy_persisted_vault_transit_config_uses_metadata_defaults() {
let raw = r#"{
"backend": "VaultTransit",
"backend_config": {
"VaultTransit": {
"address": "http://127.0.0.1:8200",
"auth_method": { "Token": { "token": "t" } },
"namespace": null,
"mount_path": "transit",
"tls": null
}
},
"default_key_id": null,
"timeout": {"secs": 30, "nanos": 0},
"retry_attempts": 3,
"enable_cache": true,
"cache_config": {
"max_keys": 1000,
"ttl": {"secs": 3600, "nanos": 0},
"enable_metrics": true
}
}"#;
let config: KmsConfig = serde_json::from_str(raw).expect("legacy persisted vault-transit config");
assert_eq!(config.backend, KmsBackend::VaultTransit);
let vault = config
.vault_transit_config()
.expect("vault transit config should deserialize");
assert_eq!(vault.metadata_kv_mount, DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT);
assert_eq!(vault.metadata_key_prefix, DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX);
}
#[test]
fn test_vault_transit_backend_serialization_uses_pascal_case() {
let serialized = serde_json::to_string(&KmsBackend::VaultTransit).expect("backend should serialize");
assert_eq!(serialized, "\"VaultTransit\"");
}
#[test]
fn test_kms_redaction_rules_are_valid() {
assert!(validate_redaction_rules(KMS_CONFIG_REDACTION_RULES).is_ok());
}
#[test]
fn test_kms_config_debug_redacts_secret_fields() {
let local = KmsConfig {
backend: KmsBackend::Local,
backend_config: BackendConfig::Local(LocalConfig {
key_dir: PathBuf::from("/tmp/kms"),
master_key: Some("local-master-secret".to_string()),
file_permissions: Some(0o600),
}),
..Default::default()
};
let vault = KmsConfig::vault(
Url::parse("https://vault.example.com:8200").expect("vault URL"),
"vault-token-secret".to_string(),
);
let approle = KmsConfig::vault_approle(
Url::parse("https://vault.example.com:8200").expect("vault URL"),
"role-id-visible".to_string(),
"approle-secret-id".to_string(),
);
let rendered = format!("{local:?}\n{vault:?}\n{approle:?}");
assert!(!rendered.contains("local-master-secret"));
assert!(!rendered.contains("vault-token-secret"));
assert!(!rendered.contains("approle-secret-id"));
assert!(rendered.contains("role-id-visible"));
assert!(rendered.contains(REDACTED_SECRET));
}
#[test]
fn test_kms_config_serialization_preserves_secret_fields_for_persistence() {
let config = KmsConfig::vault(
Url::parse("https://vault.example.com:8200").expect("vault URL"),
"persisted-token-secret".to_string(),
);
let serialized = serde_json::to_string(&config).expect("kms config should serialize for persistence");
assert!(serialized.contains("persisted-token-secret"));
}
#[test]
fn static_kms_config_serialization_does_not_expose_key_material() {
use base64::Engine as _;
let encoded_key = base64::engine::general_purpose::STANDARD.encode([0x5au8; 32]);
let config = KmsConfig::static_kms("static-key".to_string(), encoded_key.clone());
let serialized = serde_json::to_string(&config).expect("static KMS config should serialize");
assert!(
!serialized.contains(&encoded_key),
"persisted static KMS configuration must not contain plaintext key material"
);
}
#[test]
fn test_config_validation() {
let mut config = KmsConfig {
allow_insecure_dev_defaults: true,
..Default::default()
};
// Valid config
assert!(config.validate().is_ok());
// Invalid timeout
config.timeout = Duration::from_secs(0);
assert!(config.validate().is_err());
// Reset timeout and test invalid retry attempts
config.timeout = Duration::from_secs(30);
config.retry_attempts = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_from_env_reads_vault_settings() {
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("vault")),
("RUSTFS_KMS_DEFAULT_KEY_ID", Some("tenant-key")),
("RUSTFS_KMS_TIMEOUT_SECS", Some("42")),
("RUSTFS_KMS_RETRY_ATTEMPTS", Some("7")),
("RUSTFS_KMS_ENABLE_CACHE", Some("false")),
("RUSTFS_KMS_VAULT_ADDRESS", Some("https://vault.example.com")),
("RUSTFS_KMS_VAULT_TOKEN", Some("vault-token")),
("RUSTFS_KMS_VAULT_NAMESPACE", Some("tenant-a")),
("RUSTFS_KMS_VAULT_MOUNT_PATH", Some("transit-alt")),
("RUSTFS_KMS_VAULT_KV_MOUNT", Some("secret-alt")),
("RUSTFS_KMS_VAULT_KEY_PREFIX", Some("tenant/keys")),
],
|| {
let config = KmsConfig::from_env().expect("kms config should load from env");
assert_eq!(config.backend, KmsBackend::VaultKv2);
assert_eq!(config.default_key_id.as_deref(), Some("tenant-key"));
assert_eq!(config.timeout, Duration::from_secs(42));
assert_eq!(config.retry_attempts, 7);
assert!(!config.enable_cache);
let vault = config.vault_config().expect("vault backend config");
assert_eq!(vault.address, "https://vault.example.com");
assert_eq!(vault.namespace.as_deref(), Some("tenant-a"));
assert_eq!(vault.mount_path, "transit-alt");
assert_eq!(vault.kv_mount, "secret-alt");
assert_eq!(vault.key_path_prefix, "tenant/keys");
},
);
}
#[test]
fn test_from_env_selects_approle_when_role_id_is_set() {
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("vault")),
("RUSTFS_KMS_VAULT_ADDRESS", Some("https://vault.example.com")),
(ENV_KMS_VAULT_APPROLE_ROLE_ID, Some("env-role-id")),
(ENV_KMS_VAULT_APPROLE_SECRET_ID, Some("env-approle-secret-id")),
(ENV_KMS_VAULT_APPROLE_MOUNT, Some("approle-alt")),
// A stale token env var must not override the AppRole selection.
("RUSTFS_KMS_VAULT_TOKEN", Some("vault-token")),
],
|| {
let config = KmsConfig::from_env().expect("kms config should load from env");
let vault = config.vault_config().expect("vault backend config");
let VaultAuthMethod::AppRole {
role_id,
secret_id,
secret_id_file,
mount,
refresh_safety_window_secs,
} = &vault.auth_method
else {
panic!("role id in the environment must select AppRole auth, got {:?}", vault.auth_method);
};
assert_eq!(role_id, "env-role-id");
assert_eq!(secret_id, "env-approle-secret-id");
assert_eq!(secret_id_file, &None);
assert_eq!(mount, "approle-alt");
assert_eq!(refresh_safety_window_secs, &None);
},
);
}
#[test]
fn test_from_env_approle_secret_id_file_is_stored_as_path() {
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("vault-transit")),
("RUSTFS_KMS_VAULT_ADDRESS", Some("https://vault.example.com")),
(ENV_KMS_VAULT_APPROLE_ROLE_ID, Some("env-role-id")),
(ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE, Some("/etc/rustfs/approle-secret-id")),
],
|| {
let config = KmsConfig::from_env().expect("kms config should load from env");
let vault = config.vault_transit_config().expect("vault transit backend config");
let VaultAuthMethod::AppRole {
secret_id,
secret_id_file,
mount,
..
} = &vault.auth_method
else {
panic!("role id in the environment must select AppRole auth");
};
// The path is stored, not read: the secret_id file is re-read on
// every login so external rotation is picked up.
assert_eq!(secret_id_file.as_deref(), Some(std::path::Path::new("/etc/rustfs/approle-secret-id")));
assert!(secret_id.is_empty());
assert_eq!(mount, DEFAULT_VAULT_APPROLE_MOUNT);
},
);
}
/// The AWS backend reads only non-credential settings from the
/// environment; access keys, profiles, and role assumption stay with the
/// aws-config provider chain.
#[test]
fn test_from_env_selects_aws_backend_without_credentials() {
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("aws")),
(ENV_KMS_AWS_REGION, Some("eu-central-1")),
(ENV_KMS_AWS_ENDPOINT_URL, None::<&str>),
],
|| {
let config = KmsConfig::from_env().expect("kms config should load from env");
assert_eq!(config.backend, KmsBackend::Aws);
let aws = config.aws_kms_config().expect("aws backend config");
assert_eq!(aws.region.as_deref(), Some("eu-central-1"));
assert_eq!(aws.endpoint_url, None);
},
);
}
/// A plaintext endpoint override exposes every KMS request, including the
/// plaintext data keys, so it stays behind the explicit development opt-in.
#[test]
fn test_from_env_rejects_plaintext_aws_endpoint() {
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("aws")),
(ENV_KMS_AWS_REGION, Some("us-east-1")),
(ENV_KMS_AWS_ENDPOINT_URL, Some("http://localhost:4566")),
],
|| {
KmsConfig::from_env().expect_err("a plaintext AWS endpoint must be rejected by default");
},
);
}
#[test]
fn test_from_env_approle_requires_secret_id_or_file() {
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("vault")),
(ENV_KMS_VAULT_APPROLE_ROLE_ID, Some("env-role-id")),
(ENV_KMS_VAULT_APPROLE_SECRET_ID, None::<&str>),
(ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE, None::<&str>),
],
|| {
let error = KmsConfig::from_env().expect_err("approle without a secret_id source must be rejected");
assert!(error.to_string().contains(ENV_KMS_VAULT_APPROLE_SECRET_ID));
},
);
}
#[test]
fn test_from_env_selects_token_file() {
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("vault")),
("RUSTFS_KMS_VAULT_ADDRESS", Some("https://vault.example.com")),
(ENV_KMS_VAULT_TOKEN_FILE, Some("/run/vault-agent/token")),
],
|| {
let config = KmsConfig::from_env().expect("kms config should load from env");
let vault = config.vault_config().expect("vault backend config");
let VaultAuthMethod::TokenFile {
path,
poll_interval_secs,
refresh_safety_window_secs,
} = &vault.auth_method
else {
panic!("token file in the environment must select TokenFile auth, got {:?}", vault.auth_method);
};
assert_eq!(path, std::path::Path::new("/run/vault-agent/token"));
assert_eq!(poll_interval_secs, &None);
assert_eq!(refresh_safety_window_secs, &None);
},
);
}
#[test]
fn test_from_env_token_file_is_mutually_exclusive_with_other_auth() {
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("vault")),
(ENV_KMS_VAULT_TOKEN_FILE, Some("/run/vault-agent/token")),
(ENV_KMS_VAULT_APPROLE_ROLE_ID, Some("env-role-id")),
],
|| {
let error = KmsConfig::from_env().expect_err("token file combined with approle must be rejected");
assert!(error.to_string().contains(ENV_KMS_VAULT_TOKEN_FILE));
assert!(error.to_string().contains(ENV_KMS_VAULT_APPROLE_ROLE_ID));
},
);
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("vault-transit")),
(ENV_KMS_VAULT_TOKEN_FILE, Some("/run/vault-agent/token")),
("RUSTFS_KMS_VAULT_TOKEN", Some("vault-token")),
],
|| {
let error = KmsConfig::from_env().expect_err("token file combined with a static token must be rejected");
assert!(error.to_string().contains(ENV_KMS_VAULT_TOKEN_FILE));
assert!(error.to_string().contains("RUSTFS_KMS_VAULT_TOKEN"));
},
);
}
#[test]
fn test_validate_rejects_bad_token_file_settings() {
let vault_config = |auth_method: VaultAuthMethod| KmsConfig {
backend: KmsBackend::VaultKv2,
backend_config: BackendConfig::VaultKv2(Box::new(VaultConfig {
address: "https://vault.example.com:8200".to_string(),
auth_method,
..Default::default()
})),
..Default::default()
};
let error = vault_config(VaultAuthMethod::token_file(PathBuf::new()))
.validate()
.expect_err("empty token file path must be rejected");
assert!(error.to_string().contains("path"));
let error = vault_config(VaultAuthMethod::TokenFile {
path: PathBuf::from("/run/vault-agent/token"),
poll_interval_secs: Some(0),
refresh_safety_window_secs: None,
})
.validate()
.expect_err("zero poll interval must be rejected");
assert!(error.to_string().contains("poll interval"));
vault_config(VaultAuthMethod::token_file(PathBuf::from("/run/vault-agent/token")))
.validate()
.expect("well-formed token file auth must validate");
}
#[test]
fn test_approle_config_deserializes_legacy_shape_with_defaults() {
// Persisted configurations from before the AppRole implementation only
// carry role_id and secret_id; the new fields must fill with defaults.
let legacy = serde_json::json!({
"AppRole": {
"role_id": "legacy-role",
"secret_id": "legacy-secret-id",
}
});
let auth: VaultAuthMethod = serde_json::from_value(legacy).expect("legacy AppRole config must keep deserializing");
let VaultAuthMethod::AppRole {
role_id,
secret_id_file,
mount,
refresh_safety_window_secs,
..
} = auth
else {
panic!("expected AppRole");
};
assert_eq!(role_id, "legacy-role");
assert_eq!(secret_id_file, None);
assert_eq!(mount, DEFAULT_VAULT_APPROLE_MOUNT);
assert_eq!(refresh_safety_window_secs, None);
}
/// The gate lives in server configuration only: it never rides along in a
/// serialized config, and a stored config that claims it must not be
/// believed. Otherwise one operator's one-time enablement would reach every
/// node that later reloads that config.
#[test]
fn immediate_deletion_gate_is_server_local_and_never_persisted() {
let persisted =
serde_json::to_value(KmsConfig::default().with_immediate_deletion_allowed()).expect("kms config should serialize");
assert!(
persisted.get("allow_immediate_deletion").is_none(),
"the gate must not be written into a persisted config: {persisted}"
);
let mut forged = persisted;
forged
.as_object_mut()
.expect("a persisted config must be a JSON object")
.insert("allow_immediate_deletion".to_string(), serde_json::json!(true));
let restored: KmsConfig = serde_json::from_value(forged).expect("an unknown gate field must not break loading");
assert!(!restored.allow_immediate_deletion, "a stored gate must fail closed");
with_vars(vec![(ENV_KMS_ALLOW_IMMEDIATE_DELETION, Some("true"))], || {
assert!(
allow_immediate_deletion_from_env(),
"the gate must be reachable from server configuration"
);
});
with_vars(vec![(ENV_KMS_ALLOW_IMMEDIATE_DELETION, None::<&str>)], || {
assert!(!allow_immediate_deletion_from_env());
});
}
#[test]
fn test_validate_rejects_incomplete_approle() {
let mut config = KmsConfig::vault_approle(
Url::parse("https://vault.example.com:8200").expect("vault URL"),
String::new(),
"secret-id".to_string(),
);
let error = config.validate().expect_err("empty role_id must be rejected");
assert!(error.to_string().contains("role_id"));
config = KmsConfig::vault_approle(
Url::parse("https://vault.example.com:8200").expect("vault URL"),
"role-id".to_string(),
String::new(),
);
let error = config
.validate()
.expect_err("approle without secret_id or secret_id_file must be rejected");
assert!(error.to_string().contains("secret_id"));
}
#[test]
fn test_from_env_requires_vault_development_opt_in() {
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("vault")),
("RUSTFS_KMS_VAULT_ADDRESS", Some("http://127.0.0.1:8200")),
("RUSTFS_KMS_VAULT_TOKEN", Some("dev-token")),
],
|| {
let error = KmsConfig::from_env().expect_err("vault dev defaults should fail closed");
assert!(error.to_string().contains(ENV_KMS_ALLOW_INSECURE_DEV_DEFAULTS));
},
);
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("vault")),
("RUSTFS_KMS_VAULT_ADDRESS", Some("http://127.0.0.1:8200")),
("RUSTFS_KMS_VAULT_TOKEN", Some("dev-token")),
(ENV_KMS_ALLOW_INSECURE_DEV_DEFAULTS, Some("true")),
],
|| {
let config = KmsConfig::from_env().expect("explicit development opt-in should allow vault dev defaults");
assert!(config.allow_insecure_dev_defaults);
},
);
}
#[test]
fn test_from_env_rejects_vault_skip_tls_verify_without_opt_in() {
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("vault-transit")),
("RUSTFS_KMS_VAULT_ADDRESS", Some("https://vault.example.com")),
("RUSTFS_KMS_VAULT_TOKEN", Some("vault-token")),
(ENV_KMS_VAULT_SKIP_TLS_VERIFY, Some("true")),
],
|| {
let error = KmsConfig::from_env().expect_err("skip TLS verify should fail closed");
assert!(error.to_string().contains(ENV_KMS_ALLOW_INSECURE_DEV_DEFAULTS));
},
);
}
#[test]
fn test_from_env_reads_vault_transit_settings() {
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("vault-transit")),
("RUSTFS_KMS_DEFAULT_KEY_ID", Some("tenant-key")),
("RUSTFS_KMS_VAULT_ADDRESS", Some("https://vault.example.com")),
("RUSTFS_KMS_VAULT_TOKEN", Some("vault-token")),
("RUSTFS_KMS_VAULT_NAMESPACE", Some("tenant-a")),
("RUSTFS_KMS_VAULT_MOUNT_PATH", Some("transit-alt")),
],
|| {
let config = KmsConfig::from_env().expect("kms config should load from env");
assert_eq!(config.backend, KmsBackend::VaultTransit);
assert_eq!(config.default_key_id.as_deref(), Some("tenant-key"));
let vault = config.vault_transit_config().expect("vault transit backend config");
assert_eq!(vault.address, "https://vault.example.com");
assert_eq!(vault.namespace.as_deref(), Some("tenant-a"));
assert_eq!(vault.mount_path, "transit-alt");
},
);
}
#[test]
fn test_from_env_reads_static_secret_file_and_sets_default_key() {
use base64::Engine as _;
let temp_dir = TempDir::new().expect("create temp dir for static KMS secret");
let secret_path = temp_dir.path().join("static-kms-secret");
// Named `*_key_b64` (not `*_secret`) so the logging-guardrails check does not
// flag these fixture interpolations as secrets leaking into log strings.
let file_key_b64 = base64::engine::general_purpose::STANDARD.encode([7u8; 32]);
let env_key_b64 = base64::engine::general_purpose::STANDARD.encode([9u8; 32]);
std::fs::write(&secret_path, format!("file-key:{file_key_b64}\n")).expect("write static KMS secret file");
with_vars(
vec![
("RUSTFS_KMS_BACKEND", Some("static")),
(
ENV_KMS_STATIC_SECRET_KEY_FILE,
Some(secret_path.to_str().expect("secret path should be utf-8")),
),
(ENV_KMS_STATIC_SECRET_KEY, Some(&format!("env-key:{env_key_b64}"))),
],
|| {
let config = KmsConfig::from_env().expect("static KMS config should load from secret file");
assert_eq!(config.backend, KmsBackend::Static);
assert_eq!(config.default_key_id.as_deref(), Some("file-key"));
let static_config = config.static_config().expect("static backend config");
assert_eq!(static_config.key_id, "file-key");
assert_eq!(static_config.secret_key, file_key_b64);
},
);
}
}