Skip to main content

rc_core/admin/
kms.rs

1//! Typed contracts for RustFS KMS administration.
2
3use 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/// Runtime state of the RustFS KMS service.
12#[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/// Configured KMS backend family.
23#[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/// Non-secret KMS cache configuration.
33#[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/// Non-secret KMS configuration summary returned by RustFS.
42#[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/// KMS health and configuration state.
56#[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/// KMS key state.
66#[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/// KMS key usage.
80#[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/// A normalized KMS key returned by list or describe operations.
89#[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/// One page of KMS keys.
106#[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/// Metadata used to create a KMS key without exposing key material.
114#[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/// Result returned after creating a KMS key.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct KmsCreateKeyResult {
124    pub key_id: String,
125    pub key: Option<KmsKey>,
126}
127
128/// Request to schedule or immediately delete a KMS key.
129#[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/// Result returned after requesting KMS key deletion.
137#[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/// Result returned after cancelling scheduled KMS key deletion.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct KmsCancelKeyDeletionResult {
147    pub key_id: String,
148    pub key: Option<KmsKey>,
149}
150
151/// Strict Local backend configuration accepted by RustFS beta.10.
152#[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/// Vault authentication configuration. This type intentionally has no Debug implementation.
168#[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/// Strict Vault KV2 backend configuration accepted by RustFS beta.10.
176#[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/// Strict Vault Transit backend configuration accepted by RustFS beta.10.
196#[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/// Sensitive KMS configuration request. It intentionally cannot be formatted with Debug.
214#[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    /// Validate server request shape and security invariants without exposing values.
225    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/// RustFS KMS administration operations.
489#[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}