1use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6use std::path::PathBuf;
7use zeroize::Zeroize;
8
9use crate::error::Result;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "kebab-case")]
14pub enum KmsServiceState {
15 NotConfigured,
16 Configured,
17 Running,
18 Error,
19 Unknown,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum KmsBackendKind {
26 Local,
27 VaultKv2,
28 VaultTransit,
29 Unknown,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct KmsCacheSummary {
35 pub enabled: bool,
36 pub max_keys: Option<u64>,
37 pub ttl_seconds: Option<u64>,
38 pub metrics_enabled: Option<bool>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct KmsConfigSummary {
44 pub backend: KmsBackendKind,
45 pub default_key_id: Option<String>,
46 pub timeout_seconds: Option<u64>,
47 pub retry_attempts: Option<u32>,
48 pub cache: KmsCacheSummary,
49 pub endpoint: Option<String>,
50 pub auth_method: Option<String>,
51 pub credentials_configured: Option<bool>,
52 pub tls_verification_disabled: Option<bool>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct KmsStatus {
58 pub state: KmsServiceState,
59 pub backend: Option<KmsBackendKind>,
60 pub healthy: Option<bool>,
61 pub error_message: Option<String>,
62 pub config: Option<KmsConfigSummary>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "kebab-case")]
68pub enum KmsKeyState {
69 Enabled,
70 Active,
71 Disabled,
72 PendingDeletion,
73 PendingImport,
74 Unavailable,
75 Deleted,
76 Unknown,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "kebab-case")]
82pub enum KmsKeyUsage {
83 EncryptDecrypt,
84 SignVerify,
85 Unknown,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct KmsKey {
91 pub key_id: String,
92 pub state: KmsKeyState,
93 pub usage: KmsKeyUsage,
94 pub description: Option<String>,
95 pub algorithm: Option<String>,
96 pub version: Option<u32>,
97 pub created_at: Option<String>,
98 pub deletion_date: Option<String>,
99 pub rotated_at: Option<String>,
100 pub origin: Option<String>,
101 pub manager: Option<String>,
102 pub tags: BTreeMap<String, String>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct KmsKeyPage {
108 pub keys: Vec<KmsKey>,
109 pub truncated: bool,
110 pub next_marker: Option<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct KmsCreateKeyRequest {
116 pub name: Option<String>,
117 pub description: Option<String>,
118 pub tags: BTreeMap<String, String>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct KmsCreateKeyResult {
124 pub key_id: String,
125 pub key: Option<KmsKey>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct KmsDeleteKeyRequest {
131 pub key_id: String,
132 pub pending_window_in_days: Option<u32>,
133 pub force_immediate: bool,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct KmsDeleteKeyResult {
139 pub key_id: String,
140 pub deletion_date: Option<String>,
141 pub immediate: bool,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct KmsCancelKeyDeletionResult {
147 pub key_id: String,
148 pub key: Option<KmsKey>,
149}
150
151#[derive(Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct KmsLocalConfigureRequest {
155 pub key_dir: PathBuf,
156 pub master_key: Option<String>,
157 pub file_permissions: Option<u32>,
158 pub default_key_id: Option<String>,
159 pub timeout_seconds: Option<u64>,
160 pub retry_attempts: Option<u32>,
161 pub enable_cache: Option<bool>,
162 pub max_cached_keys: Option<usize>,
163 pub cache_ttl_seconds: Option<u64>,
164 pub allow_insecure_dev_defaults: Option<bool>,
165}
166
167#[derive(Serialize, Deserialize)]
169#[serde(deny_unknown_fields)]
170pub enum KmsVaultAuthMethod {
171 Token { token: String },
172 AppRole { role_id: String, secret_id: String },
173}
174
175#[derive(Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct KmsVaultKv2ConfigureRequest {
179 pub address: String,
180 pub auth_method: KmsVaultAuthMethod,
181 pub namespace: Option<String>,
182 pub mount_path: Option<String>,
183 pub kv_mount: Option<String>,
184 pub key_path_prefix: Option<String>,
185 pub skip_tls_verify: Option<bool>,
186 pub default_key_id: Option<String>,
187 pub timeout_seconds: Option<u64>,
188 pub retry_attempts: Option<u32>,
189 pub enable_cache: Option<bool>,
190 pub max_cached_keys: Option<usize>,
191 pub cache_ttl_seconds: Option<u64>,
192 pub allow_insecure_dev_defaults: Option<bool>,
193}
194
195#[derive(Serialize, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct KmsVaultTransitConfigureRequest {
199 pub address: String,
200 pub auth_method: KmsVaultAuthMethod,
201 pub namespace: Option<String>,
202 pub mount_path: Option<String>,
203 pub skip_tls_verify: Option<bool>,
204 pub default_key_id: Option<String>,
205 pub timeout_seconds: Option<u64>,
206 pub retry_attempts: Option<u32>,
207 pub enable_cache: Option<bool>,
208 pub max_cached_keys: Option<usize>,
209 pub cache_ttl_seconds: Option<u64>,
210 pub allow_insecure_dev_defaults: Option<bool>,
211}
212
213#[derive(Serialize, Deserialize)]
215#[serde(tag = "backend_type")]
216pub enum KmsConfigureRequest {
217 Local(KmsLocalConfigureRequest),
218 #[serde(rename = "VaultKV2")]
219 VaultKv2(KmsVaultKv2ConfigureRequest),
220 VaultTransit(KmsVaultTransitConfigureRequest),
221}
222
223impl KmsConfigureRequest {
224 pub fn validate(&self, allow_existing_credentials: bool) -> Result<()> {
226 match self {
227 Self::Local(request) => validate_local_configuration(request),
228 Self::VaultKv2(request) => {
229 validate_vault_kv2_configuration(request, allow_existing_credentials)
230 }
231 Self::VaultTransit(request) => {
232 validate_vault_transit_configuration(request, allow_existing_credentials)
233 }
234 }
235 }
236
237 fn zeroize_sensitive(&mut self) {
238 match self {
239 Self::Local(request) => request.master_key.zeroize(),
240 Self::VaultKv2(request) => request.auth_method.zeroize_sensitive(),
241 Self::VaultTransit(request) => request.auth_method.zeroize_sensitive(),
242 }
243 }
244}
245
246impl Drop for KmsConfigureRequest {
247 fn drop(&mut self) {
248 self.zeroize_sensitive();
249 }
250}
251
252impl KmsVaultAuthMethod {
253 fn zeroize_sensitive(&mut self) {
254 match self {
255 Self::Token { token } => token.zeroize(),
256 Self::AppRole { role_id, secret_id } => {
257 role_id.zeroize();
258 secret_id.zeroize();
259 }
260 }
261 }
262}
263
264fn validate_local_configuration(request: &KmsLocalConfigureRequest) -> Result<()> {
265 validate_common_configuration(
266 request.timeout_seconds,
267 request.retry_attempts,
268 request.enable_cache,
269 request.max_cached_keys,
270 request.cache_ttl_seconds,
271 )?;
272 validate_optional_text("Default KMS key id", request.default_key_id.as_deref())?;
273 if !request.key_dir.is_absolute() {
274 return Err(crate::Error::InvalidPath(
275 "Local KMS key directory must be an absolute path".to_string(),
276 ));
277 }
278 let allow_insecure = request.allow_insecure_dev_defaults.unwrap_or(false);
279 if !allow_insecure && request.master_key.as_deref().is_none_or(str::is_empty) {
280 return Err(crate::Error::InvalidPath(
281 "Local KMS requires a master key outside explicit development mode".to_string(),
282 ));
283 }
284 if request
285 .master_key
286 .as_deref()
287 .is_some_and(|value| value.chars().any(char::is_control))
288 {
289 return Err(crate::Error::InvalidPath(
290 "Local KMS master key contains invalid characters".to_string(),
291 ));
292 }
293 if request.file_permissions.is_some_and(|mode| mode > 0o777) {
294 return Err(crate::Error::InvalidPath(
295 "Local KMS file permissions must be an octal mode between 000 and 777".to_string(),
296 ));
297 }
298 if !allow_insecure
299 && request
300 .file_permissions
301 .is_some_and(|mode| mode & 0o077 != 0)
302 {
303 return Err(crate::Error::InvalidPath(
304 "Local KMS key files cannot grant group or other permissions".to_string(),
305 ));
306 }
307 Ok(())
308}
309
310fn validate_vault_kv2_configuration(
311 request: &KmsVaultKv2ConfigureRequest,
312 allow_existing_credentials: bool,
313) -> Result<()> {
314 validate_vault_configuration(
315 "Vault KV2",
316 &request.address,
317 &request.auth_method,
318 request.mount_path.as_deref(),
319 request.skip_tls_verify,
320 request.allow_insecure_dev_defaults,
321 request.timeout_seconds,
322 request.retry_attempts,
323 request.enable_cache,
324 request.max_cached_keys,
325 request.cache_ttl_seconds,
326 allow_existing_credentials,
327 )?;
328 validate_optional_text("Vault KV mount", request.kv_mount.as_deref())?;
329 validate_optional_text("Vault key path prefix", request.key_path_prefix.as_deref())?;
330 validate_optional_text("Default KMS key id", request.default_key_id.as_deref())
331}
332
333fn validate_vault_transit_configuration(
334 request: &KmsVaultTransitConfigureRequest,
335 allow_existing_credentials: bool,
336) -> Result<()> {
337 validate_vault_configuration(
338 "Vault Transit",
339 &request.address,
340 &request.auth_method,
341 request.mount_path.as_deref(),
342 request.skip_tls_verify,
343 request.allow_insecure_dev_defaults,
344 request.timeout_seconds,
345 request.retry_attempts,
346 request.enable_cache,
347 request.max_cached_keys,
348 request.cache_ttl_seconds,
349 allow_existing_credentials,
350 )?;
351 validate_optional_text("Default KMS key id", request.default_key_id.as_deref())
352}
353
354#[allow(clippy::too_many_arguments)]
355fn validate_vault_configuration(
356 backend: &str,
357 address: &str,
358 auth_method: &KmsVaultAuthMethod,
359 mount_path: Option<&str>,
360 skip_tls_verify: Option<bool>,
361 allow_insecure_dev_defaults: Option<bool>,
362 timeout_seconds: Option<u64>,
363 retry_attempts: Option<u32>,
364 enable_cache: Option<bool>,
365 max_cached_keys: Option<usize>,
366 cache_ttl_seconds: Option<u64>,
367 allow_existing_credentials: bool,
368) -> Result<()> {
369 validate_common_configuration(
370 timeout_seconds,
371 retry_attempts,
372 enable_cache,
373 max_cached_keys,
374 cache_ttl_seconds,
375 )?;
376 validate_optional_text("Vault mount path", mount_path)?;
377 let parsed = url::Url::parse(address).map_err(|_| {
378 crate::Error::InvalidPath(format!(
379 "{backend} address must be a valid HTTP or HTTPS URL"
380 ))
381 })?;
382 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
383 return Err(crate::Error::InvalidPath(format!(
384 "{backend} address must be a valid HTTP or HTTPS URL"
385 )));
386 }
387 if !parsed.username().is_empty()
388 || parsed.password().is_some()
389 || parsed.query().is_some()
390 || parsed.fragment().is_some()
391 {
392 return Err(crate::Error::InvalidPath(format!(
393 "{backend} address cannot contain credentials, a query, or a fragment"
394 )));
395 }
396 let allow_insecure = allow_insecure_dev_defaults.unwrap_or(false);
397 if !allow_insecure && parsed.scheme() != "https" {
398 return Err(crate::Error::InvalidPath(format!(
399 "{backend} requires HTTPS outside explicit development mode"
400 )));
401 }
402 if !allow_insecure && skip_tls_verify.unwrap_or(false) {
403 return Err(crate::Error::InvalidPath(format!(
404 "{backend} cannot skip TLS verification outside explicit development mode"
405 )));
406 }
407 validate_vault_auth(auth_method, allow_existing_credentials, allow_insecure)
408}
409
410fn validate_vault_auth(
411 auth_method: &KmsVaultAuthMethod,
412 allow_existing_credentials: bool,
413 allow_insecure: bool,
414) -> Result<()> {
415 match auth_method {
416 KmsVaultAuthMethod::Token { token } if token.is_empty() && allow_existing_credentials => {
417 Ok(())
418 }
419 KmsVaultAuthMethod::Token { token } if token.is_empty() => Err(crate::Error::InvalidPath(
420 "Vault token cannot be empty for initial configuration".to_string(),
421 )),
422 KmsVaultAuthMethod::Token { token } if token.chars().any(char::is_control) => Err(
423 crate::Error::InvalidPath("Vault token contains invalid characters".to_string()),
424 ),
425 KmsVaultAuthMethod::Token { token } if token == "dev-token" && !allow_insecure => {
426 Err(crate::Error::InvalidPath(
427 "Vault development token requires explicit development mode".to_string(),
428 ))
429 }
430 KmsVaultAuthMethod::Token { .. } => Ok(()),
431 KmsVaultAuthMethod::AppRole { role_id, secret_id }
432 if role_id.is_empty() || secret_id.is_empty() =>
433 {
434 Err(crate::Error::InvalidPath(
435 "Vault AppRole id and secret cannot be empty".to_string(),
436 ))
437 }
438 KmsVaultAuthMethod::AppRole { role_id, secret_id }
439 if role_id.chars().any(char::is_control) || secret_id.chars().any(char::is_control) =>
440 {
441 Err(crate::Error::InvalidPath(
442 "Vault AppRole credentials contain invalid characters".to_string(),
443 ))
444 }
445 KmsVaultAuthMethod::AppRole { .. } => Ok(()),
446 }
447}
448
449fn validate_common_configuration(
450 timeout_seconds: Option<u64>,
451 retry_attempts: Option<u32>,
452 enable_cache: Option<bool>,
453 max_cached_keys: Option<usize>,
454 cache_ttl_seconds: Option<u64>,
455) -> Result<()> {
456 if timeout_seconds == Some(0) {
457 return Err(crate::Error::InvalidPath(
458 "KMS timeout must be greater than zero".to_string(),
459 ));
460 }
461 if retry_attempts == Some(0) {
462 return Err(crate::Error::InvalidPath(
463 "KMS retry attempts must be greater than zero".to_string(),
464 ));
465 }
466 if enable_cache.unwrap_or(true) && max_cached_keys == Some(0) {
467 return Err(crate::Error::InvalidPath(
468 "KMS cache size must be greater than zero when caching is enabled".to_string(),
469 ));
470 }
471 if cache_ttl_seconds == Some(0) {
472 return Err(crate::Error::InvalidPath(
473 "KMS cache TTL must be greater than zero".to_string(),
474 ));
475 }
476 Ok(())
477}
478
479fn validate_optional_text(label: &str, value: Option<&str>) -> Result<()> {
480 if value.is_some_and(|value| value.is_empty() || value.chars().any(char::is_control)) {
481 return Err(crate::Error::InvalidPath(format!(
482 "{label} cannot be empty or contain control characters"
483 )));
484 }
485 Ok(())
486}
487
488#[async_trait]
490pub trait KmsApi: Send + Sync {
491 async fn kms_status(&self) -> Result<KmsStatus>;
492 async fn kms_list_keys(&self, limit: u32, marker: Option<&str>) -> Result<KmsKeyPage>;
493 async fn kms_describe_key(&self, key_id: &str) -> Result<KmsKey>;
494 async fn kms_create_key(&self, request: &KmsCreateKeyRequest) -> Result<KmsCreateKeyResult>;
495 async fn kms_delete_key(&self, request: &KmsDeleteKeyRequest) -> Result<KmsDeleteKeyResult>;
496 async fn kms_cancel_key_deletion(&self, key_id: &str) -> Result<KmsCancelKeyDeletionResult>;
497 async fn kms_configure(&self, request: &KmsConfigureRequest) -> Result<KmsServiceState>;
498 async fn kms_reconfigure(&self, request: &KmsConfigureRequest) -> Result<KmsServiceState>;
499 async fn kms_start(&self, force: bool) -> Result<KmsServiceState>;
500 async fn kms_stop(&self) -> Result<KmsServiceState>;
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn machine_readable_states_are_stable() {
509 assert_eq!(
510 serde_json::to_string(&KmsServiceState::NotConfigured)
511 .expect("service state should serialize"),
512 "\"not-configured\""
513 );
514 assert_eq!(
515 serde_json::to_string(&KmsKeyState::PendingDeletion)
516 .expect("key state should serialize"),
517 "\"pending-deletion\""
518 );
519 }
520
521 #[test]
522 fn configure_requests_validate_all_native_backend_shapes() {
523 let local: KmsConfigureRequest = serde_json::from_value(serde_json::json!({
524 "backend_type": "Local",
525 "key_dir": std::env::temp_dir(),
526 "master_key": "local-secret",
527 "file_permissions": 384
528 }))
529 .expect("valid Local KMS configuration shape");
530 local
531 .validate(false)
532 .expect("secure Local KMS configuration");
533
534 for raw in [
535 r#"{"backend_type":"VaultKV2","address":"https://vault.example","auth_method":{"Token":{"token":"vault-secret"}},"mount_path":"transit","kv_mount":"secret","key_path_prefix":"rustfs/kms/keys"}"#,
536 r#"{"backend_type":"VaultTransit","address":"https://vault.example","auth_method":{"AppRole":{"role_id":"role","secret_id":"secret"}},"mount_path":"transit"}"#,
537 ] {
538 let request: KmsConfigureRequest =
539 serde_json::from_str(raw).expect("valid KMS configuration shape");
540 request.validate(false).expect("secure KMS configuration");
541 }
542 }
543
544 #[test]
545 fn configure_requests_reject_unknown_insecure_and_missing_fields() {
546 for raw in [
547 r#"{"backend_type":"Local","key_dir":"relative","master_key":"secret"}"#,
548 r#"{"backend_type":"VaultKV2","address":"http://vault.example","auth_method":{"Token":{"token":"secret"}}}"#,
549 r#"{"backend_type":"VaultTransit","address":"https://vault.example","auth_method":{"Token":{"token":""}}}"#,
550 r#"{"backend_type":"VaultTransit","address":"https://vault.example","auth_method":{"Token":{"token":"dev-token"}}}"#,
551 ] {
552 let request: KmsConfigureRequest =
553 serde_json::from_str(raw).expect("request shape should deserialize");
554 request
555 .validate(false)
556 .expect_err("request should be rejected");
557 }
558 let unknown = r#"{"backend_type":"Local","key_dir":"/var/lib/kms","master_key":"secret","unknown":"secret-value"}"#;
559 assert!(
560 serde_json::from_str::<KmsConfigureRequest>(unknown).is_err(),
561 "unknown configuration fields should fail"
562 );
563 }
564
565 #[test]
566 fn configure_requests_reject_vault_address_credential_channels() {
567 for address in [
568 "https://user@vault.example",
569 "https://user:password@vault.example",
570 "https://vault.example?token=hidden",
571 "https://vault.example#hidden",
572 ] {
573 let raw = serde_json::json!({
574 "backend_type": "VaultTransit",
575 "address": address,
576 "auth_method": {"Token": {"token": "vault-secret"}}
577 });
578 let request: KmsConfigureRequest =
579 serde_json::from_value(raw).expect("request shape should deserialize");
580 let error = request
581 .validate(false)
582 .expect_err("address credential channel should be rejected");
583 let message = error.to_string();
584 assert!(!message.contains("user"));
585 assert!(!message.contains("password"));
586 assert!(!message.contains("hidden"));
587 }
588 }
589
590 #[test]
591 fn reconfigure_only_allows_an_empty_token_to_reuse_credentials() {
592 let empty_token: KmsConfigureRequest = serde_json::from_str(
593 r#"{"backend_type":"VaultTransit","address":"https://vault.example","auth_method":{"Token":{"token":""}}}"#,
594 )
595 .expect("empty token request should deserialize");
596 empty_token
597 .validate(true)
598 .expect("reconfigure may reuse the stored token");
599
600 for raw in [
601 r#"{"backend_type":"VaultTransit","address":"https://vault.example","auth_method":{"AppRole":{"role_id":"","secret_id":"secret"}}}"#,
602 r#"{"backend_type":"VaultTransit","address":"https://vault.example","auth_method":{"AppRole":{"role_id":"role","secret_id":""}}}"#,
603 ] {
604 let request: KmsConfigureRequest =
605 serde_json::from_str(raw).expect("partial AppRole request should deserialize");
606 request
607 .validate(true)
608 .expect_err("partial AppRole credentials cannot be reused");
609 }
610 }
611
612 #[test]
613 fn configure_request_zeroizes_owned_secret_fields() {
614 let mut request: KmsConfigureRequest = serde_json::from_str(
615 r#"{"backend_type":"VaultTransit","address":"https://vault.example","auth_method":{"AppRole":{"role_id":"role-id","secret_id":"secret-id"}}}"#,
616 )
617 .expect("request should deserialize");
618 request.zeroize_sensitive();
619 let serialized = serde_json::to_string(&request).expect("request should serialize");
620 assert!(!serialized.contains("role-id"));
621 assert!(!serialized.contains("secret-id"));
622 }
623}