diff --git a/.vscode/launch.json b/.vscode/launch.json index e69583e4a..f113b1d5a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -212,13 +212,17 @@ // "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key", // 4. kms vault transit backend test key + // "RUSTFS_KMS_ENABLE": "true", + // "RUSTFS_KMS_BACKEND": "vault-transit", + // "RUSTFS_KMS_VAULT_ADDRESS": "http://127.0.0.1:8200", + // "RUSTFS_KMS_VAULT_TOKEN": "Dev Token", + // "RUSTFS_KMS_VAULT_MOUNT_PATH": "transit", + // "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key", + + // 5、kms static backend test key "RUSTFS_KMS_ENABLE": "true", - "RUSTFS_KMS_BACKEND": "vault-transit", - "RUSTFS_KMS_VAULT_ADDRESS": "http://127.0.0.1:8200", - "RUSTFS_KMS_VAULT_TOKEN": "Dev Token", - "RUSTFS_KMS_VAULT_MOUNT_PATH": "transit", - "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key", - + "RUSTFS_KMS_BACKEND": "static", + "RUSTFS_KMS_STATIC_SECRET_KEY": "rustfs-master-key:2dfNXGHlsEflGVCxb+5DIdGEl1sIvtwX+QfmYasi5QM=" }, "sourceLanguages": [ "rust" diff --git a/crates/kms/src/api_types.rs b/crates/kms/src/api_types.rs index a0d04c814..dba7e5de7 100644 --- a/crates/kms/src/api_types.rs +++ b/crates/kms/src/api_types.rs @@ -16,7 +16,8 @@ use crate::config::{ BackendConfig, CacheConfig, DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX, DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT, KmsBackend, - KmsConfig, LocalConfig, TlsConfig, VaultAuthMethod, VaultConfig, VaultTransitConfig, redacted_secret_option, + KmsConfig, LocalConfig, StaticConfig, TlsConfig, VaultAuthMethod, VaultConfig, VaultTransitConfig, redacted_secret, + redacted_secret_option, }; use crate::service_manager::KmsServiceStatus; use crate::types::{KeyMetadata, KeyUsage}; @@ -136,6 +137,46 @@ pub struct ConfigureVaultTransitKmsRequest { pub allow_insecure_dev_defaults: Option, } +/// Request to configure KMS with Static single-key backend +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigureStaticKmsRequest { + /// Key identifier (name) for the single configured key + pub key_id: String, + /// Base64-encoded 32-byte AES-256 key material + pub secret_key: String, + /// Default master key ID for auto-encryption + pub default_key_id: Option, + /// Operation timeout in seconds + pub timeout_seconds: Option, + /// Number of retry attempts + pub retry_attempts: Option, + /// Enable caching + pub enable_cache: Option, + /// Maximum number of keys to cache + pub max_cached_keys: Option, + /// Cache TTL in seconds + pub cache_ttl_seconds: Option, + /// Allow development-only insecure defaults + pub allow_insecure_dev_defaults: Option, +} + +impl fmt::Debug for ConfigureStaticKmsRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ConfigureStaticKmsRequest") + .field("key_id", &self.key_id) + .field("secret_key", &redacted_secret(&self.secret_key)) + .field("default_key_id", &self.default_key_id) + .field("timeout_seconds", &self.timeout_seconds) + .field("retry_attempts", &self.retry_attempts) + .field("enable_cache", &self.enable_cache) + .field("max_cached_keys", &self.max_cached_keys) + .field("cache_ttl_seconds", &self.cache_ttl_seconds) + .field("allow_insecure_dev_defaults", &self.allow_insecure_dev_defaults) + .finish() + } +} + /// Generic KMS configuration request #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "backend_type")] @@ -155,6 +196,9 @@ pub enum ConfigureKmsRequest { /// Configure with Vault Transit backend #[serde(rename = "VaultTransit", alias = "vault-transit", alias = "vault_transit")] VaultTransit(ConfigureVaultTransitKmsRequest), + /// Configure with Static single-key backend + #[serde(rename = "Static", alias = "static")] + Static(ConfigureStaticKmsRequest), } /// KMS configuration response @@ -316,6 +360,11 @@ pub enum BackendSummary { /// Skip TLS verification skip_tls_verify: bool, }, + /// Static single-key backend summary + Static { + /// Configured key identifier + key_id: String, + }, } impl From<&KmsConfig> for KmsConfigSummary { @@ -360,6 +409,9 @@ impl From<&KmsConfig> for KmsConfigSummary { mount_path: vault_config.mount_path.clone(), skip_tls_verify: vault_config.tls.as_ref().is_some_and(|tls| tls.skip_verify), }, + BackendConfig::Static(static_config) => BackendSummary::Static { + key_id: static_config.key_id.clone(), + }, }; Self { @@ -474,6 +526,29 @@ impl ConfigureVaultTransitKmsRequest { } } +impl ConfigureStaticKmsRequest { + /// Convert to KmsConfig + pub fn to_kms_config(&self) -> KmsConfig { + KmsConfig { + backend: KmsBackend::Static, + default_key_id: self.default_key_id.clone(), + backend_config: BackendConfig::Static(StaticConfig { + key_id: self.key_id.clone(), + secret_key: self.secret_key.clone(), + }), + allow_insecure_dev_defaults: self.allow_insecure_dev_defaults.unwrap_or(false), + timeout: Duration::from_secs(self.timeout_seconds.unwrap_or(30)), + retry_attempts: self.retry_attempts.unwrap_or(3), + enable_cache: self.enable_cache.unwrap_or(true), + cache_config: CacheConfig { + max_keys: self.max_cached_keys.unwrap_or(1000), + ttl: Duration::from_secs(self.cache_ttl_seconds.unwrap_or(3600)), + enable_metrics: true, + }, + } + } +} + impl ConfigureKmsRequest { /// Convert to KmsConfig pub fn to_kms_config(&self) -> KmsConfig { @@ -481,6 +556,7 @@ impl ConfigureKmsRequest { ConfigureKmsRequest::Local(req) => req.to_kms_config(), ConfigureKmsRequest::VaultKv2(req) => req.to_kms_config(), ConfigureKmsRequest::VaultTransit(req) => req.to_kms_config(), + ConfigureKmsRequest::Static(req) => req.to_kms_config(), } } } diff --git a/crates/kms/src/backends/local.rs b/crates/kms/src/backends/local.rs index defcd2d4d..480b4e989 100644 --- a/crates/kms/src/backends/local.rs +++ b/crates/kms/src/backends/local.rs @@ -687,7 +687,9 @@ impl LocalKmsBackend { let local_config = match &config.backend_config { crate::config::BackendConfig::Local(local_config) => local_config.clone(), - crate::config::BackendConfig::VaultKv2(_) | crate::config::BackendConfig::VaultTransit(_) => { + crate::config::BackendConfig::VaultKv2(_) + | crate::config::BackendConfig::VaultTransit(_) + | crate::config::BackendConfig::Static(_) => { return Err(KmsError::configuration_error("Expected Local backend configuration")); } }; diff --git a/crates/kms/src/backends/mod.rs b/crates/kms/src/backends/mod.rs index 6c5eacfab..158bb4cc0 100644 --- a/crates/kms/src/backends/mod.rs +++ b/crates/kms/src/backends/mod.rs @@ -20,6 +20,7 @@ use async_trait::async_trait; use std::collections::HashMap; pub mod local; +pub mod static_kms; pub mod vault; pub mod vault_transit; diff --git a/crates/kms/src/backends/static_kms.rs b/crates/kms/src/backends/static_kms.rs new file mode 100644 index 000000000..0dc36f456 --- /dev/null +++ b/crates/kms/src/backends/static_kms.rs @@ -0,0 +1,655 @@ +// 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. + +//! Static single-key KMS backend implementation +//! +//! This backend holds one pre-configured AES-256 key and uses it directly +//! to encrypt/decrypt data encryption keys (DEKs) via AES-256-GCM. +//! +//! ## Ciphertext format +//! +//! encrypted_data(plaintext_len+16) || nonce (12 bytes) + +use crate::backends::{BackendInfo, KmsBackend, KmsClient}; +use crate::config::{BackendConfig, KmsConfig}; +use crate::error::{KmsError, Result}; +use crate::types::*; +use aes_gcm::{ + Aes256Gcm, Key, Nonce, + aead::{Aead, KeyInit}, +}; +use async_trait::async_trait; +use jiff::Zoned; +use rand::RngExt; +use std::collections::HashMap; +use tracing::debug; +use zeroize::Zeroizing; + +/// AES-GCM nonce size in bytes, appended to each ciphertext. +const NONCE_SIZE: usize = 12; +/// AES-256 key size in bytes. +const KEY_SIZE: usize = 32; + +/// Static single-key KMS backend. +/// +/// Uses a pre-configured AES-256 key to derive data encryption keys. This is a +/// read-only backend: it cannot create, delete, or rotate keys. +pub struct StaticKmsBackend { + /// The configured key identifier (name). + key_id: String, + /// The raw 32-byte AES-256 key material (zeroed on drop). + key: Zeroizing<[u8; KEY_SIZE]>, +} + +impl StaticKmsBackend { + /// Create a new static KMS backend from configuration. + pub async fn new(mut config: KmsConfig) -> Result { + let BackendConfig::Static(ref mut static_config) = config.backend_config else { + return Err(KmsError::configuration_error("Static KMS backend requires StaticConfig")); + }; + + let key = static_config.decode_key()?; + + // Zeroize the base64-encoded secret key in the config after extracting the raw key. + use zeroize::Zeroize; + static_config.secret_key.zeroize(); + + debug!( + key_id = %static_config.key_id, + "Static KMS backend initialized" + ); + + Ok(Self { + key_id: static_config.key_id.clone(), + key: key.into(), + }) + } + + /// Build the key metadata for the single configured key. + fn key_metadata(&self) -> KeyMetadata { + KeyMetadata { + key_id: self.key_id.clone(), + key_state: KeyState::Enabled, + key_usage: KeyUsage::EncryptDecrypt, + description: Some("Static single-key KMS backend".to_string()), + creation_date: Zoned::now(), + deletion_date: None, + origin: "EXTERNAL".to_string(), + key_manager: "STATIC".to_string(), + tags: HashMap::new(), + } + } +} + +#[async_trait] +impl KmsClient for StaticKmsBackend { + async fn generate_data_key(&self, request: &GenerateKeyRequest, _context: Option<&OperationContext>) -> Result { + if request.master_key_id != self.key_id { + return Err(KmsError::key_not_found(&request.master_key_id)); + } + + // Generate 12-byte random nonce + let mut nonce_bytes = [0u8; NONCE_SIZE]; + rand::rng().fill(&mut nonce_bytes[..]); + + // Generate 32 random bytes as plaintext DEK + let mut plaintext = [0u8; KEY_SIZE]; + rand::rng().fill(&mut plaintext[..]); + + // Encrypt DEK with AES-256-GCM using the static key directly + let key = Key::::from(*self.key); + let cipher = Aes256Gcm::new(&key); + let nonce = Nonce::from(nonce_bytes); + + let encrypted = cipher + .encrypt(&nonce, plaintext.as_ref()) + .map_err(|e| KmsError::cryptographic_error("AES-256-GCM encrypt", e.to_string()))?; + + // Ciphertext format: encrypted_dek || nonce + let mut ciphertext = encrypted; + ciphertext.extend_from_slice(&nonce_bytes); + + Ok(DataKeyInfo::new( + self.key_id.clone(), + 0, // version is always 0 for static KMS + Some(plaintext.to_vec()), + ciphertext, + "AES_256".to_string(), + )) + } + + async fn encrypt(&self, request: &EncryptRequest, _context: Option<&OperationContext>) -> Result { + if request.key_id != self.key_id { + return Err(KmsError::key_not_found(&request.key_id)); + } + + // Generate 12-byte random nonce + let mut nonce_bytes = [0u8; NONCE_SIZE]; + rand::rng().fill(&mut nonce_bytes[..]); + + let key = Key::::from(*self.key); + let cipher = Aes256Gcm::new(&key); + let nonce = Nonce::from(nonce_bytes); + + let encrypted = cipher + .encrypt(&nonce, request.plaintext.as_ref()) + .map_err(|e| KmsError::cryptographic_error("AES-256-GCM encrypt", e.to_string()))?; + + // Ciphertext format: encrypted_data || nonce + let mut ciphertext = encrypted; + ciphertext.extend_from_slice(&nonce_bytes); + + Ok(EncryptResponse { + ciphertext, + key_id: self.key_id.clone(), + key_version: 0, + algorithm: "AES-256-GCM".to_string(), + }) + } + + async fn decrypt(&self, request: &DecryptRequest, _context: Option<&OperationContext>) -> Result> { + if request.ciphertext.len() < NONCE_SIZE + 1 { + return Err(KmsError::cryptographic_error("decrypt", "Ciphertext too short for static KMS format")); + } + + // Split ciphertext: encrypted_data || nonce(12) + let split_at = request.ciphertext.len() - NONCE_SIZE; + let encrypted = &request.ciphertext[..split_at]; + let nonce_slice = &request.ciphertext[split_at..]; + + let key = Key::::from(*self.key); + let cipher = Aes256Gcm::new(&key); + let nonce = Nonce::try_from(nonce_slice).map_err(|_| KmsError::cryptographic_error("nonce", "invalid nonce length"))?; + + let plaintext = cipher + .decrypt(&nonce, encrypted) + .map_err(|e| KmsError::cryptographic_error("AES-256-GCM decrypt", e.to_string()))?; + + Ok(plaintext) + } + + async fn create_key(&self, key_id: &str, _algorithm: &str, _context: Option<&OperationContext>) -> Result { + if key_id == self.key_id { + return Err(KmsError::key_already_exists(key_id)); + } + Err(KmsError::invalid_operation("Static KMS is read-only: cannot create new keys")) + } + + async fn describe_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result { + if key_id != self.key_id { + return Err(KmsError::key_not_found(key_id)); + } + + let metadata = self.key_metadata(); + Ok(KeyInfo { + key_id: metadata.key_id, + description: metadata.description, + algorithm: "AES_256".to_string(), + usage: metadata.key_usage, + status: KeyStatus::Active, + version: 1, + metadata: HashMap::new(), + tags: HashMap::new(), + created_at: metadata.creation_date, + rotated_at: None, + created_by: None, + }) + } + + async fn list_keys(&self, request: &ListKeysRequest, _context: Option<&OperationContext>) -> Result { + let key_info = KeyInfo { + key_id: self.key_id.clone(), + description: Some("Static single-key KMS backend".to_string()), + algorithm: "AES_256".to_string(), + usage: KeyUsage::EncryptDecrypt, + status: KeyStatus::Active, + version: 1, + metadata: HashMap::new(), + tags: HashMap::new(), + created_at: Zoned::now(), + rotated_at: None, + created_by: None, + }; + + // Apply prefix filter if provided + if let Some(ref marker) = request.marker + && self.key_id <= *marker + { + return Ok(ListKeysResponse { + keys: vec![], + next_marker: None, + truncated: false, + }); + } + + Ok(ListKeysResponse { + keys: vec![key_info], + next_marker: None, + truncated: false, + }) + } + + async fn enable_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<()> { + if key_id != self.key_id { + return Err(KmsError::key_not_found(key_id)); + } + // Static KMS key is always enabled + Ok(()) + } + + async fn disable_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<()> { + if key_id != self.key_id { + return Err(KmsError::key_not_found(key_id)); + } + Err(KmsError::invalid_operation("Static KMS is read-only: cannot disable keys")) + } + + async fn schedule_key_deletion( + &self, + key_id: &str, + _pending_window_days: u32, + _context: Option<&OperationContext>, + ) -> Result<()> { + if key_id != self.key_id { + return Err(KmsError::key_not_found(key_id)); + } + Err(KmsError::invalid_operation("Static KMS is read-only: cannot schedule key deletion")) + } + + async fn cancel_key_deletion(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<()> { + if key_id != self.key_id { + return Err(KmsError::key_not_found(key_id)); + } + Err(KmsError::invalid_operation("Static KMS is read-only: cannot cancel key deletion")) + } + + async fn rotate_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result { + if key_id != self.key_id { + return Err(KmsError::key_not_found(key_id)); + } + Err(KmsError::invalid_operation("Static KMS is read-only: cannot rotate keys")) + } + + async fn health_check(&self) -> Result<()> { + // Static KMS is always healthy if it was successfully initialized + Ok(()) + } + + fn backend_info(&self) -> BackendInfo { + BackendInfo::new("static".to_string(), env!("CARGO_PKG_VERSION").to_string(), "local".to_string(), true) + .with_metadata("key_id".to_string(), self.key_id.clone()) + } +} + +#[async_trait] +impl KmsBackend for StaticKmsBackend { + async fn create_key(&self, request: CreateKeyRequest) -> Result { + let key_name = request.key_name.as_deref().unwrap_or(""); + if key_name == self.key_id { + return Err(KmsError::key_already_exists(&self.key_id)); + } + Err(KmsError::invalid_operation("Static KMS is read-only: cannot create new keys")) + } + + async fn encrypt(&self, request: EncryptRequest) -> Result { + ::encrypt(self, &request, None).await + } + + async fn decrypt(&self, request: DecryptRequest) -> Result { + let key_id = self.key_id.clone(); + let plaintext = ::decrypt(self, &request, None).await?; + Ok(DecryptResponse { + plaintext, + key_id, + encryption_algorithm: Some("AES-256-GCM".to_string()), + }) + } + + async fn generate_data_key(&self, request: GenerateDataKeyRequest) -> Result { + let gen_req = GenerateKeyRequest::new(request.key_id.clone(), request.key_spec.as_str().to_string()); + let data_key = ::generate_data_key(self, &gen_req, None).await?; + + let plaintext_key = data_key + .plaintext + .clone() + .ok_or_else(|| KmsError::internal_error("Generated data key is missing plaintext"))?; + + Ok(GenerateDataKeyResponse { + key_id: data_key.key_id.clone(), + plaintext_key, + ciphertext_blob: data_key.ciphertext.clone(), + }) + } + + async fn describe_key(&self, request: DescribeKeyRequest) -> Result { + let key_info = ::describe_key(self, &request.key_id, None).await?; + let key_metadata = KeyMetadata { + key_id: key_info.key_id.clone(), + key_state: if key_info.status == KeyStatus::Active { + KeyState::Enabled + } else { + KeyState::Disabled + }, + key_usage: key_info.usage, + description: key_info.description, + creation_date: key_info.created_at, + deletion_date: None, + origin: "EXTERNAL".to_string(), + key_manager: "STATIC".to_string(), + tags: key_info.tags, + }; + Ok(DescribeKeyResponse { key_metadata }) + } + + async fn list_keys(&self, request: ListKeysRequest) -> Result { + ::list_keys(self, &request, None).await + } + + async fn delete_key(&self, request: DeleteKeyRequest) -> Result { + if request.key_id != self.key_id { + return Err(KmsError::key_not_found(&request.key_id)); + } + Err(KmsError::invalid_operation("Static KMS is read-only: cannot delete keys")) + } + + async fn cancel_key_deletion(&self, request: CancelKeyDeletionRequest) -> Result { + if request.key_id != self.key_id { + return Err(KmsError::key_not_found(&request.key_id)); + } + Err(KmsError::invalid_operation("Static KMS is read-only: cannot cancel key deletion")) + } + + async fn health_check(&self) -> Result { + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backends::KmsClient; + use crate::config::{BackendConfig, KmsBackend, StaticConfig}; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as BASE64; + + /// Generate a random 32-byte key and return (key_id, raw_key). + fn random_static_key(key_id: &str) -> (String, [u8; 32]) { + let mut key = [0u8; 32]; + rand::rng().fill(&mut key[..]); + (key_id.to_string(), key) + } + + fn static_config(key_id: &str, raw_key: &[u8; 32]) -> StaticConfig { + StaticConfig { + key_id: key_id.to_string(), + secret_key: BASE64.encode(raw_key), + } + } + + fn kms_config(config: StaticConfig) -> KmsConfig { + KmsConfig { + backend: KmsBackend::Static, + default_key_id: Some(config.key_id.clone()), + backend_config: BackendConfig::Static(config), + ..Default::default() + } + } + + async fn create_test_backend() -> (StaticKmsBackend, String, [u8; 32]) { + let (key_id, key) = random_static_key("test-static-key"); + let config = static_config(&key_id, &key); + let backend = StaticKmsBackend::new(kms_config(config)) + .await + .expect("Failed to create backend"); + (backend, key_id, key) + } + + #[tokio::test] + async fn test_generate_and_decrypt_data_key() { + let (backend, key_id, _key) = create_test_backend().await; + + // Generate data key + let request = GenerateKeyRequest::new(key_id.clone(), "AES_256".to_string()) + .with_context("bucket".to_string(), "test-bucket".to_string()); + let data_key = KmsClient::generate_data_key(&backend, &request, None) + .await + .expect("Failed to generate data key"); + + assert_eq!(data_key.key_id, key_id); + assert_eq!(data_key.version, 0); + assert!(data_key.plaintext.is_some()); + assert_eq!(data_key.plaintext.as_ref().expect("plaintext should be set").len(), 32); + // Ciphertext should be: encrypted(32) + tag(16) + nonce(12) + assert_eq!(data_key.ciphertext.len(), 32 + 16 + NONCE_SIZE); + + // Decrypt the data key + let decrypt_request = + DecryptRequest::new(data_key.ciphertext.clone()).with_context("bucket".to_string(), "test-bucket".to_string()); + let decrypted = KmsClient::decrypt(&backend, &decrypt_request, None) + .await + .expect("Failed to decrypt"); + + assert_eq!(decrypted.as_slice(), data_key.plaintext.as_deref().expect("plaintext should exist")); + } + + #[tokio::test] + async fn test_generate_data_key_wrong_key_id() { + let (backend, _key_id, _key) = create_test_backend().await; + + let request = GenerateKeyRequest::new("wrong-key-id".to_string(), "AES_256".to_string()); + let result = KmsClient::generate_data_key(&backend, &request, None).await; + assert!(result.is_err()); + assert!(result.expect_err("should be Err").to_string().contains("wrong-key-id")); + } + + #[tokio::test] + async fn test_decrypt_invalid_ciphertext() { + let (backend, _key_id, _key) = create_test_backend().await; + + // Ciphertext too short + let short = vec![0u8; 10]; + let request = DecryptRequest::new(short); + let result = KmsClient::decrypt(&backend, &request, None).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_decrypt_tampered_ciphertext() { + let (backend, key_id, _key) = create_test_backend().await; + + // Generate a valid ciphertext first + let gen_request = GenerateKeyRequest::new(key_id, "AES_256".to_string()); + let data_key = KmsClient::generate_data_key(&backend, &gen_request, None) + .await + .expect("generate"); + + // Tamper with the ciphertext (flip a bit in the encrypted portion) + let mut tampered = data_key.ciphertext.clone(); + if !tampered.is_empty() { + tampered[0] ^= 0x01; + } + + let request = DecryptRequest::new(tampered); + let result = KmsClient::decrypt(&backend, &request, None).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_create_key_returns_exists_for_configured_key() { + let (backend, key_id, _key) = create_test_backend().await; + + // Creating the pre-configured key should return KeyAlreadyExists + let result = KmsClient::create_key(&backend, &key_id, "AES_256", None).await; + assert!(result.is_err()); + assert!(result.expect_err("should be Err").to_string().contains("already exists")); + } + + #[tokio::test] + async fn test_create_key_returns_error_for_other_keys() { + let (backend, _key_id, _key) = create_test_backend().await; + + // Creating any other key should return invalid operation (read-only) + let result = KmsClient::create_key(&backend, "other-key", "AES_256", None).await; + assert!(result.is_err()); + let err_msg = result.expect_err("should be Err").to_string(); + assert!(err_msg.contains("read-only") || err_msg.contains("cannot create")); + } + + #[tokio::test] + async fn test_describe_key() { + let (backend, key_id, _key) = create_test_backend().await; + + let key_info = KmsClient::describe_key(&backend, &key_id, None) + .await + .expect("describe_key should succeed"); + assert_eq!(key_info.key_id, key_id); + assert_eq!(key_info.status, KeyStatus::Active); + assert_eq!(key_info.algorithm, "AES_256"); + + // Wrong key ID + let result = KmsClient::describe_key(&backend, "nonexistent", None).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_list_keys() { + let (backend, key_id, _key) = create_test_backend().await; + + let response = KmsClient::list_keys(&backend, &ListKeysRequest::default(), None) + .await + .expect("list_keys should succeed"); + assert_eq!(response.keys.len(), 1); + assert_eq!(response.keys[0].key_id, key_id); + assert!(!response.truncated); + } + + #[tokio::test] + async fn test_disable_key_returns_error() { + let (backend, key_id, _key) = create_test_backend().await; + + let result = KmsClient::disable_key(&backend, &key_id, None).await; + assert!(result.is_err()); + assert!(result.expect_err("should be Err").to_string().contains("read-only")); + } + + #[tokio::test] + async fn test_enable_key_is_noop() { + let (backend, key_id, _key) = create_test_backend().await; + + // Enable should succeed (no-op for static KMS) + KmsClient::enable_key(&backend, &key_id, None) + .await + .expect("enable_key should be no-op"); + + // Wrong key should still fail + let result = KmsClient::enable_key(&backend, "wrong", None).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_delete_key_returns_error() { + let (backend, key_id, _key) = create_test_backend().await; + + let result = KmsClient::schedule_key_deletion(&backend, &key_id, 7, None).await; + assert!(result.is_err()); + assert!(result.expect_err("should be Err").to_string().contains("read-only")); + } + + #[tokio::test] + async fn test_rotate_key_returns_error() { + let (backend, key_id, _key) = create_test_backend().await; + + let result = KmsClient::rotate_key(&backend, &key_id, None).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_health_check() { + let (backend, _key_id, _key) = create_test_backend().await; + + KmsClient::health_check(&backend).await.expect("health_check should succeed"); + } + + #[tokio::test] + async fn test_backend_info() { + let (backend, key_id, _key) = create_test_backend().await; + + let info = KmsClient::backend_info(&backend); + assert_eq!(info.backend_type, "static"); + assert_eq!(info.endpoint, "local"); + assert!(info.healthy); + assert_eq!(info.metadata.get("key_id"), Some(&key_id)); + } + + #[tokio::test] + async fn test_encrypt_decrypt_direct() { + let (backend, key_id, _key) = create_test_backend().await; + + let plaintext = b"Hello, static KMS world!"; + let enc_request = EncryptRequest::new(key_id.clone(), plaintext.to_vec()); + let enc_response = KmsClient::encrypt(&backend, &enc_request, None) + .await + .expect("encrypt should succeed"); + + assert_eq!(enc_response.key_id, key_id); + assert!(!enc_response.ciphertext.is_empty()); + + let dec_request = DecryptRequest::new(enc_response.ciphertext); + let decrypted = KmsClient::decrypt(&backend, &dec_request, None) + .await + .expect("decrypt should succeed"); + + assert_eq!(decrypted, plaintext); + } + + #[tokio::test] + async fn test_kms_backend_trait_methods() { + use crate::backends::KmsBackend; + + let (backend, key_id, _key) = create_test_backend().await; + + // Test KmsBackend::generate_data_key + let gen_req = GenerateDataKeyRequest::new(key_id.clone(), KeySpec::Aes256); + let gen_resp = KmsBackend::generate_data_key(&backend, gen_req) + .await + .expect("KmsBackend::generate_data_key should succeed"); + assert_eq!(gen_resp.key_id, key_id); + assert_eq!(gen_resp.plaintext_key.len(), 32); + assert!(!gen_resp.ciphertext_blob.is_empty()); + + // Test KmsBackend::decrypt via the generated ciphertext + let dec_req = DecryptRequest::new(gen_resp.ciphertext_blob); + let dec_resp = KmsBackend::decrypt(&backend, dec_req) + .await + .expect("KmsBackend::decrypt should succeed"); + assert_eq!(dec_resp.plaintext, gen_resp.plaintext_key); + + // Test KmsBackend::describe_key + let desc_req = DescribeKeyRequest { key_id: key_id.clone() }; + let desc_resp = KmsBackend::describe_key(&backend, desc_req) + .await + .expect("describe_key should succeed"); + assert_eq!(desc_resp.key_metadata.key_id, key_id); + + // Test KmsBackend::create_key for the configured key (should return KeyAlreadyExists, same as KmsClient) + let create_req = CreateKeyRequest { + key_name: Some(key_id.clone()), + ..Default::default() + }; + let create_err = KmsBackend::create_key(&backend, create_req) + .await + .expect_err("create_key for configured key should return KeyAlreadyExists"); + assert!(create_err.to_string().contains("already exists")); + } +} diff --git a/crates/kms/src/backends/vault.rs b/crates/kms/src/backends/vault.rs index ebdfc2eb6..0ba44b23b 100644 --- a/crates/kms/src/backends/vault.rs +++ b/crates/kms/src/backends/vault.rs @@ -600,7 +600,9 @@ impl VaultKmsBackend { let vault_config = match &config.backend_config { crate::config::BackendConfig::VaultKv2(vault_config) => (**vault_config).clone(), - crate::config::BackendConfig::Local(_) | crate::config::BackendConfig::VaultTransit(_) => { + crate::config::BackendConfig::Local(_) + | crate::config::BackendConfig::VaultTransit(_) + | crate::config::BackendConfig::Static(_) => { return Err(KmsError::configuration_error("Expected Vault KV2 backend configuration")); } }; diff --git a/crates/kms/src/backends/vault_transit.rs b/crates/kms/src/backends/vault_transit.rs index cf42823d2..7be6487ba 100644 --- a/crates/kms/src/backends/vault_transit.rs +++ b/crates/kms/src/backends/vault_transit.rs @@ -602,7 +602,7 @@ impl VaultTransitKmsBackend { metadata_key_prefix: vault_config.key_path_prefix.clone(), tls: vault_config.tls.clone(), }, - crate::config::BackendConfig::Local(_) => { + crate::config::BackendConfig::Local(_) | crate::config::BackendConfig::Static(_) => { return Err(KmsError::configuration_error("Expected Vault Transit backend configuration")); } }; diff --git a/crates/kms/src/config.rs b/crates/kms/src/config.rs index a070bcb98..c3ac90813 100644 --- a/crates/kms/src/config.rs +++ b/crates/kms/src/config.rs @@ -27,6 +27,8 @@ pub const ENV_KMS_ALLOW_INSECURE_DEV_DEFAULTS: &str = "RUSTFS_KMS_ALLOW_INSECURE 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 DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT: &str = "secret"; pub const DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX: &str = "rustfs/kms/transit-metadata"; @@ -73,6 +75,7 @@ pub const KMS_CONFIG_REDACTION_RULES: &[RedactionRule] = &[ 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***"; @@ -97,6 +100,9 @@ pub enum KmsBackend { /// 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, } /// Main KMS configuration @@ -146,6 +152,8 @@ pub enum BackendConfig { VaultKv2(Box), /// Vault Transit backend configuration VaultTransit(Box), + /// Static single-key backend configuration + Static(StaticConfig), } impl Default for BackendConfig { @@ -160,6 +168,7 @@ impl fmt::Debug for BackendConfig { 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(), } } } @@ -196,6 +205,47 @@ impl Default for LocalConfig { } } +/// 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) + pub secret_key: String, +} + +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 + Transit backend configuration (metadata in KV, key wrapping via Transit) #[derive(Clone, Serialize, Deserialize)] pub struct VaultConfig { @@ -404,6 +454,23 @@ impl KmsConfig { } } + /// 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 { @@ -428,6 +495,14 @@ impl KmsConfig { } } + /// 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, + } + } + /// Set default key ID pub fn with_default_key(mut self, key_id: String) -> Self { self.default_key_id = Some(key_id); @@ -544,6 +619,16 @@ impl KmsConfig { 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()?; + } } // Validate cache configuration @@ -564,6 +649,7 @@ impl KmsConfig { "local" => KmsBackend::Local, "vault" | "vault-kv2" | "vault_kv2" => KmsBackend::VaultKv2, "vault-transit" | "vault_transit" => KmsBackend::VaultTransit, + "static" => KmsBackend::Static, _ => return Err(KmsError::configuration_error(format!("Unknown KMS backend: {backend_type}"))), }; } @@ -641,6 +727,47 @@ impl KmsConfig { 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: : + let colon_pos = secret_str.find(':').ok_or_else(|| { + KmsError::configuration_error("Static KMS secret key must be in format :") + })?; + 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); + } } config.validate()?; diff --git a/crates/kms/src/lib.rs b/crates/kms/src/lib.rs index 8d36dbbf6..de97e5349 100644 --- a/crates/kms/src/lib.rs +++ b/crates/kms/src/lib.rs @@ -78,9 +78,10 @@ pub mod types; // Re-export public API pub use api_types::{ - CacheSummary, ConfigureKmsRequest, ConfigureKmsResponse, ConfigureLocalKmsRequest, ConfigureVaultKmsRequest, - ConfigureVaultTransitKmsRequest, KmsConfigSummary, KmsStatusResponse, StartKmsRequest, StartKmsResponse, StopKmsResponse, - TagKeyRequest, TagKeyResponse, UntagKeyRequest, UntagKeyResponse, UpdateKeyDescriptionRequest, UpdateKeyDescriptionResponse, + CacheSummary, ConfigureKmsRequest, ConfigureKmsResponse, ConfigureLocalKmsRequest, ConfigureStaticKmsRequest, + ConfigureVaultKmsRequest, ConfigureVaultTransitKmsRequest, KmsConfigSummary, KmsStatusResponse, StartKmsRequest, + StartKmsResponse, StopKmsResponse, TagKeyRequest, TagKeyResponse, UntagKeyRequest, UntagKeyResponse, + UpdateKeyDescriptionRequest, UpdateKeyDescriptionResponse, }; pub use config::*; pub use encryption::is_data_key_envelope; diff --git a/crates/kms/src/service_manager.rs b/crates/kms/src/service_manager.rs index 214d9e33d..f81d799fb 100644 --- a/crates/kms/src/service_manager.rs +++ b/crates/kms/src/service_manager.rs @@ -384,6 +384,11 @@ impl KmsServiceManager { let backend = crate::backends::vault_transit::VaultTransitKmsBackend::new(config.clone()).await?; Arc::new(backend) as Arc } + BackendConfig::Static(_) => { + info!("Creating Static KMS backend for version {}", version); + let backend = crate::backends::static_kms::StaticKmsBackend::new(config.clone()).await?; + Arc::new(backend) as Arc + } }; // Create KMS manager diff --git a/rustfs/src/admin/handlers/kms_dynamic.rs b/rustfs/src/admin/handlers/kms_dynamic.rs index b78a4ea57..edcff88b1 100644 --- a/rustfs/src/admin/handlers/kms_dynamic.rs +++ b/rustfs/src/admin/handlers/kms_dynamic.rs @@ -65,6 +65,7 @@ fn existing_vault_auth(config: &KmsConfig) -> Option Some(vault.auth_method.clone()), rustfs_kms::config::BackendConfig::VaultTransit(vault) => Some(vault.auth_method.clone()), rustfs_kms::config::BackendConfig::Local(_) => None, + rustfs_kms::config::BackendConfig::Static(_) => None, } } @@ -84,6 +85,7 @@ fn normalize_configure_request_auth( ConfigureKmsRequest::VaultKv2(req) => token_is_blank(&req.auth_method), ConfigureKmsRequest::VaultTransit(req) => token_is_blank(&req.auth_method), ConfigureKmsRequest::Local(_) => false, + ConfigureKmsRequest::Static(_) => false, }; if !needs_existing_auth { @@ -98,6 +100,7 @@ fn normalize_configure_request_auth( ConfigureKmsRequest::VaultKv2(req) => req.auth_method = existing_auth, ConfigureKmsRequest::VaultTransit(req) => req.auth_method = existing_auth, ConfigureKmsRequest::Local(_) => {} + ConfigureKmsRequest::Static(_) => {} } Ok(()) diff --git a/rustfs/src/admin/handlers/kms_management.rs b/rustfs/src/admin/handlers/kms_management.rs index 0ff25a7dc..b958654ab 100644 --- a/rustfs/src/admin/handlers/kms_management.rs +++ b/rustfs/src/admin/handlers/kms_management.rs @@ -49,6 +49,7 @@ fn backend_name(backend: &KmsBackend) -> &'static str { KmsBackend::Local => "local", KmsBackend::VaultKv2 => "vault-kv2", KmsBackend::VaultTransit => "vault-transit", + KmsBackend::Static => "static", } } diff --git a/rustfs/src/init.rs b/rustfs/src/init.rs index dce73d056..86e2cbdf1 100644 --- a/rustfs/src/init.rs +++ b/rustfs/src/init.rs @@ -283,7 +283,7 @@ fn build_local_kms_config(cfg: &config::Config) -> std::io::Result std::io::Result std::io::Result std::io::Result std::io::Result std::io::Result std::io::Result { + use rustfs_kms::config::{ENV_KMS_STATIC_SECRET_KEY, ENV_KMS_STATIC_SECRET_KEY_FILE, StaticConfig}; + + // Read secret from file first, then fall back to env var + let secret_str = if let Some(file_path) = rustfs_utils::get_env_opt_str(ENV_KMS_STATIC_SECRET_KEY_FILE) { + std::fs::read_to_string(&file_path) + .map_err(|e| Error::other(format!("Failed to read static KMS secret key file {file_path}: {e}")))? + } else { + rustfs_utils::get_env_str(ENV_KMS_STATIC_SECRET_KEY, "") + }; + + let secret_str = secret_str.trim(); + if secret_str.is_empty() { + return Err(Error::other(format!( + "Static KMS requires {ENV_KMS_STATIC_SECRET_KEY} or {ENV_KMS_STATIC_SECRET_KEY_FILE} to be set" + ))); + } + + let colon_pos = secret_str.find(':').ok_or_else(|| { + Error::other(format!( + "Static KMS secret key must be in format :, got: {secret_str}" + )) + })?; + let key_id = secret_str[..colon_pos].to_string(); + let secret_key = secret_str[colon_pos + 1..].to_string(); + + if key_id.is_empty() || secret_key.is_empty() { + return Err(Error::other("Static KMS secret key must be in format :")); + } + + // Base64 decoding and 32-byte key length are validated by KmsConfig::validate() below + let static_config = StaticConfig { + key_id: key_id.clone(), + secret_key, + }; + + let kms_config = rustfs_kms::config::KmsConfig { + backend: rustfs_kms::config::KmsBackend::Static, + default_key_id: cfg.kms_default_key_id.clone().or(Some(key_id)), + backend_config: rustfs_kms::config::BackendConfig::Static(static_config), + allow_insecure_dev_defaults: cfg.kms_allow_insecure_dev_defaults, + ..Default::default() + }; + + kms_config + .validate() + .map_err(|e| Error::other(format!("Static KMS configuration validation failed: {e}")))?; + Ok(kms_config) } /// Configure and start KMS service @@ -423,6 +486,7 @@ pub async fn init_kms_system(config: &config::Config) -> std::io::Result<()> { "local" => build_local_kms_config(config)?, "vault" | "vault-kv2" | "vault_kv2" => build_vault_kms_config(config)?, "vault-transit" | "vault_transit" => build_vault_transit_kms_config(config)?, + "static" => build_static_kms_config(config)?, _ => return Err(Error::other(format!("Unsupported KMS backend: {}", config.kms_backend))), }; diff --git a/rustfs/src/storage/sse.rs b/rustfs/src/storage/sse.rs index 2fd067b3e..e24ee5c90 100644 --- a/rustfs/src/storage/sse.rs +++ b/rustfs/src/storage/sse.rs @@ -1601,12 +1601,21 @@ async fn apply_managed_encryption_material( // Try to get default key from KMS service (if available) if let Some(service) = runtime_sources::current_encryption_service().await { kms_key_candidate = service.get_default_key_id().cloned(); + tracing::debug!( + default_key_id = ?kms_key_candidate, + "SSE-S3: KMS service available, default_key_id from config" + ); + } else { + tracing::debug!("SSE-S3: KMS encryption service not available"); } } let kms_key_to_use = match (encryption_type, kms_key_candidate.clone()) { (SSEType::SseS3, Some(kms_key_id)) => kms_key_id, - (SSEType::SseS3, None) => "default".to_string(), + (SSEType::SseS3, None) => { + tracing::debug!("SSE-S3: no KMS key configured, falling back to \"default\" key ID"); + "default".to_string() + } (SSEType::SseKms, Some(kms_key_id)) => kms_key_id, (SSEType::SseKms, None) => { return Err(ApiError::from(StorageError::other( @@ -1635,7 +1644,7 @@ async fn apply_managed_encryption_material( Ok(EncryptionMaterial { sse_type: encryption_type, server_side_encryption, - kms_key_id: matches!(encryption_type, SSEType::SseKms).then_some(kms_key_to_use), + kms_key_id: Some(kms_key_to_use), algorithm, key_bytes, base_nonce, @@ -3523,7 +3532,7 @@ mod tests { let material = material.expect("managed sse-s3 encryption should return material"); let metadata = encryption_material_to_metadata(&material).expect("managed SSE-S3 metadata should serialize"); - assert_eq!(material.kms_key_id, None); + assert_eq!(material.kms_key_id.as_deref(), Some("default")); assert_eq!(metadata.get("x-amz-server-side-encryption").map(String::as_str), Some("AES256")); assert!(!metadata.contains_key("x-amz-server-side-encryption-aws-kms-key-id")); assert_eq!(metadata.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER).map(String::as_str), Some("default"));