fraiseql_core/security/kms/
vault.rs1use std::{collections::HashMap, time::Duration};
4
5pub(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#[derive(Clone)]
30pub struct VaultConfig {
31 pub vault_addr: String,
33 pub token: String,
35 pub mount_path: String,
37 pub namespace: Option<String>,
39 pub verify_tls: bool,
41 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 #[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 #[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 #[must_use]
81 pub fn with_namespace(mut self, namespace: String) -> Self {
82 self.namespace = Some(namespace);
83 self
84 }
85
86 #[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 #[must_use]
95 pub const fn with_timeout(mut self, timeout: u64) -> Self {
96 self.timeout = timeout;
97 self
98 }
99
100 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
107pub struct VaultKmsProvider {
114 config: VaultConfig,
115 client: reqwest::Client,
116}
117
118impl VaultKmsProvider {
119 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 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#[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 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 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, });
288
289 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 Ok(RotationPolicyInfo {
443 enabled: false,
444 rotation_period_days: 0,
445 last_rotation: None,
446 next_rotation: None,
447 })
448 }
449}
450
451pub(crate) fn base64_encode(data: &[u8]) -> String {
453 #[allow(clippy::wildcard_imports)]
454 use base64::prelude::*;
457 BASE64_STANDARD.encode(data)
458}
459
460pub(crate) fn base64_decode(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
462 #[allow(clippy::wildcard_imports)]
463 use base64::prelude::*;
466 BASE64_STANDARD.decode(s)
467}