Files
rustfs/crates/kms/src/config.rs
T
唐小鸭 62cc19e937 fix(kms): repair unopenable ciphertext and cover the Vault backends (#5668)
* Add black-box behavior tests for KMS resilience and serialization

* fix(kms): repair unopenable ciphertext across backends

Black-box testing of the KMS crate surfaced several defects that make
encrypted data permanently unreadable.

Symmetric envelopes. The Local and Vault Transit backends returned raw
cipher output from `encrypt` while `decrypt` parsed a JSON envelope, so
anything sealed through the master-key path could never be opened again.
Local also discarded the AES-GCM nonce. Both now emit the same envelope
`decrypt` consumes, matching the Static backend.

Deterministic AAD. The object layer derived AEAD additional data by
serializing a `HashMap` directly. Iteration order differs per instance,
so a context rebuilt from storage produced different AAD bytes than the
one used to seal and the object stopped opening. Ordering by key removes
that dependency, matching the Static backend's existing `context_aad`.
Objects written with the default single-key context are unaffected,
since a one-entry map has only one serialization.

Cipher in the header projection. `metadata_to_headers` recorded the SSE
mode (`AES256` / `aws:kms`), which cannot represent ChaCha20-Poly1305,
so a ChaCha-sealed object came back claiming `aws:kms` and was opened
with the wrong cipher. The cipher now travels in
`x-rustfs-encryption-algorithm` — the header the storage layer already
reads but nothing ever wrote. Objects without it fall back as before.

Also: the Static backend ignored `key_spec` and always issued 256-bit
data keys; Local `list_keys` hardcoded `truncated: false`, ignored
`marker`, and paginated over unordered `read_dir`, so a paginating
client silently saw a partial key list; and Local and Vault KV2 reported
`key_id: "unknown"` from `decrypt` despite the envelope naming the
master key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(kms): cover both Vault backends and key rotation

The behavior suite ran only against Local and Static, and its own harness
documented the gap: the Vault backends had no business-capability
coverage at all. Setting `RUSTFS_KMS_VAULT_TOKEN` now adds Vault KV2 and
Vault Transit to every `for_each_backend` spec against a live server.
That lane is what surfaced the Transit envelope defect fixed in the
previous commit.

`rotate` and `versioning` are advertised only by the Vault backends, so
until now every capability-gated branch for them took the
`UnsupportedCapability` side and the working half was never asserted — a
rotation that dropped prior key versions would have gone green. The new
`behavior_rotation.rs` pins that half: material sealed before a rotation
still opens after it, repeated rotations accumulate versions rather than
overwriting a single spare, and the history survives a restart.

Two test defects fixed. `objects_round_trip_across_sizes_and_algorithms`
asserted a 1-byte object differs from its own ciphertext, which collides
once every 256 runs; the assertion now applies only where a collision is
not realistic, and small objects stay covered by the tag check and the
decrypt round-trip. `test_from_env_selects_token_file` depended on
`RUSTFS_KMS_VAULT_TOKEN` being absent from the caller's environment and
now clears it explicitly.

The snapshots directory was also removed from `.gitignore`: insta
snapshots are the assertions themselves, so leaving them untracked gives
CI nothing to compare against. Only `.snap.new` scratch files are
ignored now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(kms): adapt behavior suite to current key APIs

Rebasing onto main brought four API changes the suite predates.

`DeleteKeyRequest` gained `confirm_key_id`, and immediate deletion is now
gated on the server's `allow_immediate_deletion`. Scheduled deletions pass
`None`; the four specs that destroy a key outright echo the key id back
and opt the harness config in, which is what the gate asks of a real
caller.

`LocalBackupExportRequest` gained `sanitized_config`. These specs cover
the key-material path, so they seal no configuration and pass `None`.

`KmsCacheStats` became a named struct with real hit, miss, and eviction
counters. `cache_stats_returns_an_entry_count_and_no_hit_or_miss_data`
existed to pin the old placeholder behavior — that the second tuple
element was always zero — which main has since fixed, so it is now
`cache_stats_reports_hits_and_misses_separately` and asserts the counters
actually move.

Starting the service provisions the reserved probe key, so it shows up in
listings and backup bundles. Exact-set assertions filter it through a new
`without_probe_key` helper rather than naming it, keeping those specs
about the keys they seeded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(kms): bind the AAD to the stored context bytes

Review caught that canonicalizing the AAD on decrypt breaks objects sealed
before canonicalization existed, and it was right. The AAD is the
*serialization* of the encryption context, and `x-rustfs-encryption-context`
stores that exact byte sequence: `encrypt_object` fed one `HashMap` to the
AEAD and then moved the same map into the metadata the header is written
from, so the stored string is byte-identical to the AAD the object was
sealed under. Those objects are therefore recoverable — but only while
nothing round-trips the value through a `HashMap` and re-serializes it.

Recomputing sorted AAD on decrypt would have turned a readable object into
a permanently unreadable one. The previous behavior was worse than the
first analysis credited: it did not merely fail intermittently, it made
the failure deterministic.

`EncryptionMetadata` now carries `context_aad`, the bytes the object was
actually sealed with. Encryption records what it fed the AEAD, the header
projection stores those bytes verbatim (and preserves a legacy ordering
across a re-projection rather than rewriting it into sorted form), and
`headers_to_metadata` carries the stored string through untouched. Both
decrypt paths, SSE-KMS and SSE-C, prefer it and fall back to canonical
serialization only when no stored serialization exists. Canonicalization
still applies to everything newly sealed, so the original ordering bug
cannot recur.

Two tests pin this: a legacy record whose sealed bytes are non-canonical
must survive a full header round trip unchanged, and a context header
rewritten to an equivalent-but-reordered serialization must fail
authentication rather than silently re-deriving a working AAD. Both were
mutation-checked against the reinstated bug on each side.

Also from review: the lifecycle churn test asserted only that every
request was accounted for, which holds whether the state gate exists or
not, so both branches are now pinned deterministically after the churn
(asserting `refused > 0` on the concurrent phase would only trade the hole
for a scheduling flake). And the Local and Vault KV2 envelopes compare
`encryption_context` without authenticating it — `DekCrypto` seals only
the plaintext — which is now documented at both sites; closing it needs a
versioned envelope, since existing ciphertext was sealed without AAD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:33:08 +08:00

2101 lines
84 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
///
/// The configured 32-byte key is used directly as the AES-256-GCM key that
/// wraps data encryption keys — there is no HMAC-SHA256 derivation step — and
/// each wrapped DEK is serialized as a RustFS `DataKeyEnvelope` JSON blob.
///
/// This mirrors the *concept* of MinIO's builtin/static single-key KMS, but is
/// not wire-compatible with it: MinIO wraps DEKs in a different (`{"aead": ...}`)
/// blob that this backend neither produces nor accepts, so KMS ciphertext
/// written by MinIO cannot be opened here. Reading MinIO-written SSE objects is
/// tracked separately in rustfs/backlog#1638.
#[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")),
// Cleared explicitly: a static token in the ambient environment
// outranks the token file, so leaving it up to the caller's shell
// would make this assertion depend on who runs the test.
("RUSTFS_KMS_VAULT_TOKEN", None),
],
|| {
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);
},
);
}
}