Skip to main content

fraiseql_core/security/kms/
vault.rs

1//! `HashiCorp` Vault Transit secrets engine provider.
2
3use std::{collections::HashMap, time::Duration};
4
5/// Timeout for all outbound Vault API requests.
6pub(crate) const VAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
7
8use async_trait::async_trait;
9use serde_json::json;
10
11use crate::{
12    http::build_ssrf_safe_client,
13    security::kms::{
14        base::{BaseKmsProvider, KeyInfo, RotationPolicyInfo},
15        error::{KmsError, KmsResult},
16    },
17};
18
19/// Configuration for Vault KMS provider.
20///
21/// # Security Considerations
22/// Token Handling:
23/// - The Vault token is stored in memory for the provider's lifetime
24/// - For production deployments, consider:
25///   1. Using short-lived tokens with automatic renewal
26///   2. Vault Agent with auto-auth for token management
27///   3. `AppRole` authentication with response wrapping
28///   4. Kubernetes auth method in K8s environments
29#[derive(Clone)]
30pub struct VaultConfig {
31    /// Vault server address (e.g., `https://vault.example.com`)
32    pub vault_addr: String,
33    /// Vault authentication token
34    pub token:      String,
35    /// Transit mount path (default: "transit")
36    pub mount_path: String,
37    /// Optional Vault namespace
38    pub namespace:  Option<String>,
39    /// Verify TLS certificates (default: true)
40    pub verify_tls: bool,
41    /// Request timeout in seconds (default: 30)
42    pub timeout:    u64,
43}
44
45impl std::fmt::Debug for VaultConfig {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("VaultConfig")
48            .field("vault_addr", &self.vault_addr)
49            .field("token", &"[REDACTED]")
50            .field("mount_path", &self.mount_path)
51            .field("namespace", &self.namespace)
52            .field("verify_tls", &self.verify_tls)
53            .field("timeout", &self.timeout)
54            .finish()
55    }
56}
57
58impl VaultConfig {
59    /// Create a new Vault configuration.
60    #[must_use]
61    pub fn new(vault_addr: String, token: String) -> Self {
62        Self {
63            vault_addr,
64            token,
65            mount_path: "transit".to_string(),
66            namespace: None,
67            verify_tls: true,
68            timeout: 30,
69        }
70    }
71
72    /// Set the transit mount path.
73    #[must_use]
74    pub fn with_mount_path(mut self, mount_path: String) -> Self {
75        self.mount_path = mount_path;
76        self
77    }
78
79    /// Set the Vault namespace.
80    #[must_use]
81    pub fn with_namespace(mut self, namespace: String) -> Self {
82        self.namespace = Some(namespace);
83        self
84    }
85
86    /// Set TLS verification.
87    #[must_use]
88    pub const fn with_verify_tls(mut self, verify_tls: bool) -> Self {
89        self.verify_tls = verify_tls;
90        self
91    }
92
93    /// Set request timeout in seconds.
94    #[must_use]
95    pub const fn with_timeout(mut self, timeout: u64) -> Self {
96        self.timeout = timeout;
97        self
98    }
99
100    /// Build full API URL for a path.
101    pub(crate) fn api_url(&self, path: &str) -> String {
102        let addr = self.vault_addr.trim_end_matches('/');
103        format!("{}/v1/{}/{}", addr, self.mount_path, path)
104    }
105}
106
107/// `HashiCorp` Vault Transit secrets engine provider.
108///
109/// Uses Vault's Transit secrets engine for encryption/decryption operations.
110/// Supports envelope encryption via data key generation.
111///
112/// All operations use authenticated encryption (AES-256-GCM).
113pub struct VaultKmsProvider {
114    config: VaultConfig,
115    client: reqwest::Client,
116}
117
118impl VaultKmsProvider {
119    /// Create a new Vault KMS provider.
120    ///
121    /// # Errors
122    ///
123    /// Returns `KmsError::InvalidConfiguration` if the HTTP client fails to build.
124    pub fn new(config: VaultConfig) -> KmsResult<Self> {
125        let client = build_ssrf_safe_client(VAULT_REQUEST_TIMEOUT).map_err(|e| {
126            KmsError::InvalidConfiguration {
127                message: format!("Failed to build HTTP client: {e}"),
128            }
129        })?;
130        Ok(Self { config, client })
131    }
132
133    /// Build a request with Vault headers.
134    fn build_headers(&self) -> reqwest::header::HeaderMap {
135        let mut headers = reqwest::header::HeaderMap::new();
136
137        headers.insert(
138            "X-Vault-Token",
139            reqwest::header::HeaderValue::from_str(&self.config.token)
140                .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("")),
141        );
142
143        if let Some(namespace) = &self.config.namespace {
144            headers.insert(
145                "X-Vault-Namespace",
146                reqwest::header::HeaderValue::from_str(namespace)
147                    .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("")),
148            );
149        }
150
151        headers
152    }
153}
154
155// Reason: BaseKmsProvider is defined with #[async_trait]; all implementations must match
156// its transformed method signatures to satisfy the trait contract
157// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
158#[async_trait]
159impl BaseKmsProvider for VaultKmsProvider {
160    fn provider_name(&self) -> &'static str {
161        "vault"
162    }
163
164    async fn do_encrypt(
165        &self,
166        plaintext: &[u8],
167        key_id: &str,
168        context: &HashMap<String, String>,
169    ) -> KmsResult<(String, String)> {
170        let url = self.config.api_url(&format!("encrypt/{}", key_id));
171
172        let plaintext_b64 = base64_encode(plaintext);
173
174        let mut payload = json!({
175            "plaintext": plaintext_b64,
176        });
177
178        // Add context if provided (used for key derivation)
179        if !context.is_empty() {
180            let context_json =
181                serde_json::to_string(context).map_err(|e| KmsError::SerializationError {
182                    message: e.to_string(),
183                })?;
184            let context_b64 = base64_encode(context_json.as_bytes());
185            payload["context"] = json!(context_b64);
186        }
187
188        let response = self
189            .client
190            .post(&url)
191            .headers(self.build_headers())
192            .json(&payload)
193            .timeout(std::time::Duration::from_secs(self.config.timeout))
194            .send()
195            .await
196            .map_err(|e| KmsError::ProviderConnectionError {
197                message: e.to_string(),
198            })?;
199
200        if !response.status().is_success() {
201            return Err(KmsError::EncryptionFailed {
202                message: format!("Vault returned status {}", response.status()),
203            });
204        }
205
206        let data = response.json::<serde_json::Value>().await.map_err(|e| {
207            KmsError::SerializationError {
208                message: e.to_string(),
209            }
210        })?;
211
212        let ciphertext = data["data"]["ciphertext"]
213            .as_str()
214            .ok_or_else(|| KmsError::EncryptionFailed {
215                message: "No ciphertext in Vault response".to_string(),
216            })?
217            .to_string();
218
219        Ok((ciphertext, "aes256-gcm96".to_string()))
220    }
221
222    async fn do_decrypt(
223        &self,
224        ciphertext: &str,
225        key_id: &str,
226        context: &HashMap<String, String>,
227    ) -> KmsResult<Vec<u8>> {
228        let url = self.config.api_url(&format!("decrypt/{}", key_id));
229
230        let mut payload = json!({
231            "ciphertext": ciphertext,
232        });
233
234        // Add context if provided
235        if !context.is_empty() {
236            let context_json =
237                serde_json::to_string(context).map_err(|e| KmsError::SerializationError {
238                    message: e.to_string(),
239                })?;
240            let context_b64 = base64_encode(context_json.as_bytes());
241            payload["context"] = json!(context_b64);
242        }
243
244        let response = self
245            .client
246            .post(&url)
247            .headers(self.build_headers())
248            .json(&payload)
249            .timeout(std::time::Duration::from_secs(self.config.timeout))
250            .send()
251            .await
252            .map_err(|e| KmsError::ProviderConnectionError {
253                message: e.to_string(),
254            })?;
255
256        if !response.status().is_success() {
257            return Err(KmsError::DecryptionFailed {
258                message: format!("Vault returned status {}", response.status()),
259            });
260        }
261
262        let data = response.json::<serde_json::Value>().await.map_err(|e| {
263            KmsError::SerializationError {
264                message: e.to_string(),
265            }
266        })?;
267
268        let plaintext_b64 =
269            data["data"]["plaintext"].as_str().ok_or_else(|| KmsError::DecryptionFailed {
270                message: "No plaintext in Vault response".to_string(),
271            })?;
272
273        base64_decode(plaintext_b64).map_err(|_| KmsError::DecryptionFailed {
274            message: "Failed to decode plaintext from Vault".to_string(),
275        })
276    }
277
278    async fn do_generate_data_key(
279        &self,
280        key_id: &str,
281        context: &HashMap<String, String>,
282    ) -> KmsResult<(Vec<u8>, String)> {
283        let url = self.config.api_url(&format!("datakey/plaintext/{}", key_id));
284
285        let mut payload = json!({
286            "bits": 256,  // AES-256
287        });
288
289        // Add context if provided
290        if !context.is_empty() {
291            let context_json =
292                serde_json::to_string(context).map_err(|e| KmsError::SerializationError {
293                    message: e.to_string(),
294                })?;
295            let context_b64 = base64_encode(context_json.as_bytes());
296            payload["context"] = json!(context_b64);
297        }
298
299        let response = self
300            .client
301            .post(&url)
302            .headers(self.build_headers())
303            .json(&payload)
304            .timeout(std::time::Duration::from_secs(self.config.timeout))
305            .send()
306            .await
307            .map_err(|e| KmsError::ProviderConnectionError {
308                message: e.to_string(),
309            })?;
310
311        if !response.status().is_success() {
312            return Err(KmsError::EncryptionFailed {
313                message: format!("Vault returned status {}", response.status()),
314            });
315        }
316
317        let data = response.json::<serde_json::Value>().await.map_err(|e| {
318            KmsError::SerializationError {
319                message: e.to_string(),
320            }
321        })?;
322
323        let plaintext_b64 =
324            data["data"]["plaintext"].as_str().ok_or_else(|| KmsError::EncryptionFailed {
325                message: "No plaintext key in Vault response".to_string(),
326            })?;
327
328        let plaintext_key =
329            base64_decode(plaintext_b64).map_err(|_| KmsError::EncryptionFailed {
330                message: "Failed to decode plaintext key from Vault".to_string(),
331            })?;
332
333        let ciphertext = data["data"]["ciphertext"]
334            .as_str()
335            .ok_or_else(|| KmsError::EncryptionFailed {
336                message: "No encrypted key in Vault response".to_string(),
337            })?
338            .to_string();
339
340        Ok((plaintext_key, ciphertext))
341    }
342
343    async fn do_rotate_key(&self, key_id: &str) -> KmsResult<()> {
344        let url = self.config.api_url(&format!("keys/{}/rotate", key_id));
345
346        let response = self
347            .client
348            .post(&url)
349            .headers(self.build_headers())
350            .json(&json!({}))
351            .timeout(std::time::Duration::from_secs(self.config.timeout))
352            .send()
353            .await
354            .map_err(|e| KmsError::ProviderConnectionError {
355                message: e.to_string(),
356            })?;
357
358        if !response.status().is_success() {
359            return Err(KmsError::RotationFailed {
360                message: format!("Vault returned status {}", response.status()),
361            });
362        }
363
364        Ok(())
365    }
366
367    async fn do_get_key_info(&self, key_id: &str) -> KmsResult<KeyInfo> {
368        let url = self.config.api_url(&format!("keys/{}", key_id));
369
370        let response = self
371            .client
372            .get(&url)
373            .headers(self.build_headers())
374            .timeout(std::time::Duration::from_secs(self.config.timeout))
375            .send()
376            .await
377            .map_err(|e| KmsError::ProviderConnectionError {
378                message: e.to_string(),
379            })?;
380
381        if response.status() == 404 {
382            return Err(KmsError::KeyNotFound {
383                key_id: key_id.to_string(),
384            });
385        }
386
387        if !response.status().is_success() {
388            return Err(KmsError::ProviderConnectionError {
389                message: format!("Vault returned status {}", response.status()),
390            });
391        }
392
393        let data = response.json::<serde_json::Value>().await.map_err(|e| {
394            KmsError::SerializationError {
395                message: e.to_string(),
396            }
397        })?;
398
399        let key_data = &data["data"];
400        let alias = key_data["name"].as_str().map(|s| s.to_string());
401        let created_at = key_data["creation_time"]
402            .as_i64()
403            .unwrap_or_else(|| chrono::Utc::now().timestamp());
404
405        Ok(KeyInfo { alias, created_at })
406    }
407
408    async fn do_get_rotation_policy(&self, key_id: &str) -> KmsResult<RotationPolicyInfo> {
409        let url = self.config.api_url(&format!("keys/{}", key_id));
410
411        let response = self
412            .client
413            .get(&url)
414            .headers(self.build_headers())
415            .timeout(std::time::Duration::from_secs(self.config.timeout))
416            .send()
417            .await
418            .map_err(|e| KmsError::ProviderConnectionError {
419                message: e.to_string(),
420            })?;
421
422        if response.status() == 404 {
423            return Err(KmsError::KeyNotFound {
424                key_id: key_id.to_string(),
425            });
426        }
427
428        if !response.status().is_success() {
429            return Err(KmsError::ProviderConnectionError {
430                message: format!("Vault returned status {}", response.status()),
431            });
432        }
433
434        let _data = response.json::<serde_json::Value>().await.map_err(|e| {
435            KmsError::SerializationError {
436                message: e.to_string(),
437            }
438        })?;
439
440        // Vault doesn't have explicit rotation policies in transit engine
441        // Return disabled by default
442        Ok(RotationPolicyInfo {
443            enabled:              false,
444            rotation_period_days: 0,
445            last_rotation:        None,
446            next_rotation:        None,
447        })
448    }
449}
450
451/// Encode bytes as base64.
452pub(crate) fn base64_encode(data: &[u8]) -> String {
453    #[allow(clippy::wildcard_imports)]
454    // Reason: base64::prelude::* is the canonical usage pattern recommended by the base64
455    // crate
456    use base64::prelude::*;
457    BASE64_STANDARD.encode(data)
458}
459
460/// Decode base64 to bytes.
461pub(crate) fn base64_decode(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
462    #[allow(clippy::wildcard_imports)]
463    // Reason: base64::prelude::* is the canonical usage pattern recommended by the base64
464    // crate
465    use base64::prelude::*;
466    BASE64_STANDARD.decode(s)
467}