mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-13 16:46:55 +00:00
36b1723cec
Add BackendCapabilities and a KmsBackend::capabilities() method with a conservative default so callers can discover which lifecycle operations the active backend supports instead of probing them. Each backend declares its real matrix (Vault Transit is the only one advertising version-retaining rotation). Introduce the typed KmsError::UnsupportedCapability variant for later use by lifecycle endpoints. Refs rustfs/backlog#1571 (part of rustfs/backlog#1562)
989 lines
40 KiB
Rust
989 lines
40 KiB
Rust
// 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.
|
|
|
|
//! Vault Transit-based KMS backend.
|
|
|
|
use crate::backends::vault_credentials::{VaultClientHandle, VaultConnectionSettings, VaultCredentialProvider, token_source_for};
|
|
use crate::backends::{BackendCapabilities, BackendInfo, KmsBackend, KmsClient};
|
|
use crate::config::{KmsConfig, VaultTransitConfig};
|
|
use crate::encryption::{DataKeyEnvelope, generate_key_material};
|
|
use crate::error::{KmsError, Result};
|
|
use crate::types::*;
|
|
use async_trait::async_trait;
|
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
|
use jiff::Zoned;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tokio::sync::RwLock;
|
|
use vaultrs::{
|
|
api::transit::{
|
|
KeyType,
|
|
requests::{
|
|
CreateKeyRequestBuilder, DecryptDataRequestBuilder, EncryptDataRequestBuilder, UpdateKeyConfigurationRequestBuilder,
|
|
},
|
|
},
|
|
kv2,
|
|
transit::{data, key},
|
|
};
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct TransitKeyMetadata {
|
|
key_usage: KeyUsage,
|
|
description: Option<String>,
|
|
tags: HashMap<String, String>,
|
|
key_state: KeyState,
|
|
created_at: Zoned,
|
|
deletion_date: Option<Zoned>,
|
|
origin: String,
|
|
created_by: Option<String>,
|
|
current_version: u32,
|
|
}
|
|
|
|
/// Serializable version of TransitKeyMetadata for KV v2 persistence.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct TransitKeyMetadataPersisted {
|
|
key_usage: KeyUsage,
|
|
description: Option<String>,
|
|
tags: HashMap<String, String>,
|
|
key_state: KeyState,
|
|
created_at: Zoned,
|
|
deletion_date: Option<Zoned>,
|
|
origin: String,
|
|
created_by: Option<String>,
|
|
current_version: u32,
|
|
}
|
|
|
|
impl TransitKeyMetadata {
|
|
fn from_create_request(request: &CreateKeyRequest) -> Self {
|
|
Self {
|
|
key_usage: request.key_usage.clone(),
|
|
description: request.description.clone(),
|
|
tags: request.tags.clone(),
|
|
key_state: KeyState::Enabled,
|
|
created_at: Zoned::now(),
|
|
deletion_date: None,
|
|
origin: request.origin.clone().unwrap_or_else(|| "VAULT_TRANSIT".to_string()),
|
|
created_by: None,
|
|
current_version: 1,
|
|
}
|
|
}
|
|
|
|
fn synthesized() -> Self {
|
|
Self {
|
|
key_usage: KeyUsage::EncryptDecrypt,
|
|
description: None,
|
|
tags: HashMap::new(),
|
|
key_state: KeyState::Enabled,
|
|
created_at: Zoned::now(),
|
|
deletion_date: None,
|
|
origin: "VAULT_TRANSIT".to_string(),
|
|
created_by: None,
|
|
current_version: 1,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<TransitKeyMetadata> for TransitKeyMetadataPersisted {
|
|
fn from(m: TransitKeyMetadata) -> Self {
|
|
Self {
|
|
key_usage: m.key_usage,
|
|
description: m.description,
|
|
tags: m.tags,
|
|
key_state: m.key_state,
|
|
created_at: m.created_at,
|
|
deletion_date: m.deletion_date,
|
|
origin: m.origin,
|
|
created_by: m.created_by,
|
|
current_version: m.current_version,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<TransitKeyMetadataPersisted> for TransitKeyMetadata {
|
|
fn from(m: TransitKeyMetadataPersisted) -> Self {
|
|
Self {
|
|
key_usage: m.key_usage,
|
|
description: m.description,
|
|
tags: m.tags,
|
|
key_state: m.key_state,
|
|
created_at: m.created_at,
|
|
deletion_date: m.deletion_date,
|
|
origin: m.origin,
|
|
created_by: m.created_by,
|
|
current_version: m.current_version,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct VaultTransitKmsClient {
|
|
credentials: VaultCredentialProvider,
|
|
config: VaultTransitConfig,
|
|
/// KV v2 mount path for persisting transit key metadata
|
|
metadata_kv_mount: String,
|
|
/// Path prefix under metadata_kv_mount for storing transit key metadata records
|
|
metadata_key_prefix: String,
|
|
metadata_cache: RwLock<HashMap<String, TransitKeyMetadata>>,
|
|
}
|
|
|
|
impl VaultTransitKmsClient {
|
|
/// Create a new Vault Transit KMS client
|
|
///
|
|
/// `attempt_timeout` caps every HTTP request issued through this client.
|
|
pub async fn new(config: VaultTransitConfig, attempt_timeout: Duration) -> Result<Self> {
|
|
let source = token_source_for(&config.auth_method)?;
|
|
let settings = VaultConnectionSettings {
|
|
address: config.address.clone(),
|
|
namespace: config.namespace.clone(),
|
|
attempt_timeout,
|
|
};
|
|
let credentials = VaultCredentialProvider::new(settings, source).await?;
|
|
|
|
Ok(Self {
|
|
credentials,
|
|
metadata_kv_mount: config.metadata_kv_mount.clone(),
|
|
metadata_key_prefix: config.metadata_key_prefix.clone(),
|
|
config,
|
|
metadata_cache: RwLock::new(HashMap::new()),
|
|
})
|
|
}
|
|
|
|
/// Snapshot the authenticated Vault client for a single request.
|
|
///
|
|
/// Every Vault call takes its own snapshot so a credential rotation
|
|
/// applies to subsequent calls without interrupting in-flight ones.
|
|
fn vault(&self) -> Arc<VaultClientHandle> {
|
|
self.credentials.current()
|
|
}
|
|
|
|
fn canonicalize_context(encryption_context: &HashMap<String, String>) -> Result<Option<String>> {
|
|
if encryption_context.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let ordered: BTreeMap<_, _> = encryption_context
|
|
.iter()
|
|
.map(|(key, value)| (key.clone(), value.clone()))
|
|
.collect();
|
|
let serialized = serde_json::to_vec(&ordered)?;
|
|
Ok(Some(BASE64.encode(serialized)))
|
|
}
|
|
|
|
fn map_vault_error<T>(key_id: &str, error: vaultrs::error::ClientError, operation: &str) -> Result<T> {
|
|
match error {
|
|
vaultrs::error::ClientError::ResponseWrapError => Err(KmsError::key_not_found(key_id)),
|
|
vaultrs::error::ClientError::APIError { code: 404, .. } => Err(KmsError::key_not_found(key_id)),
|
|
other => Err(KmsError::backend_error(format!(
|
|
"Vault Transit {operation} failed for key {key_id}: {other}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
async fn read_transit_key(&self, key_id: &str) -> Result<vaultrs::api::transit::responses::ReadKeyResponse> {
|
|
key::read(&self.vault().client, &self.config.mount_path, key_id)
|
|
.await
|
|
.or_else(|e| Self::map_vault_error(key_id, e, "read"))
|
|
}
|
|
|
|
async fn create_transit_key(&self, key_id: &str) -> Result<()> {
|
|
let mut builder = CreateKeyRequestBuilder::default();
|
|
builder.key_type(KeyType::Aes256Gcm96);
|
|
key::create(&self.vault().client, &self.config.mount_path, key_id, Some(&mut builder))
|
|
.await
|
|
.map_err(|e| KmsError::backend_error(format!("Failed to create Vault Transit key {key_id}: {e}")))
|
|
}
|
|
|
|
async fn transit_encrypt(
|
|
&self,
|
|
key_id: &str,
|
|
plaintext: &[u8],
|
|
encryption_context: &HashMap<String, String>,
|
|
) -> Result<String> {
|
|
let plaintext_b64 = BASE64.encode(plaintext);
|
|
let mut builder = EncryptDataRequestBuilder::default();
|
|
if let Some(aad) = Self::canonicalize_context(encryption_context)? {
|
|
builder.associated_data(aad);
|
|
}
|
|
|
|
let response = data::encrypt(&self.vault().client, &self.config.mount_path, key_id, &plaintext_b64, Some(&mut builder))
|
|
.await
|
|
.map_err(|e| KmsError::backend_error(format!("Failed to encrypt data with Vault Transit key {key_id}: {e}")))?;
|
|
|
|
Ok(response.ciphertext)
|
|
}
|
|
|
|
async fn transit_decrypt(
|
|
&self,
|
|
key_id: &str,
|
|
ciphertext: &str,
|
|
encryption_context: &HashMap<String, String>,
|
|
) -> Result<Vec<u8>> {
|
|
let mut builder = DecryptDataRequestBuilder::default();
|
|
if let Some(aad) = Self::canonicalize_context(encryption_context)? {
|
|
builder.associated_data(aad);
|
|
}
|
|
|
|
let response = data::decrypt(&self.vault().client, &self.config.mount_path, key_id, ciphertext, Some(&mut builder))
|
|
.await
|
|
.map_err(|e| KmsError::backend_error(format!("Failed to decrypt data with Vault Transit key {key_id}: {e}")))?;
|
|
|
|
BASE64
|
|
.decode(response.plaintext)
|
|
.map_err(|e| KmsError::cryptographic_error("base64_decode", e.to_string()))
|
|
}
|
|
|
|
fn metadata_key_path(&self, key_id: &str) -> String {
|
|
format!("{}/{}", self.metadata_key_prefix, key_id)
|
|
}
|
|
|
|
async fn read_metadata_from_kv(&self, key_id: &str) -> Result<Option<TransitKeyMetadata>> {
|
|
let path = self.metadata_key_path(key_id);
|
|
match kv2::read::<TransitKeyMetadataPersisted>(&self.vault().client, &self.metadata_kv_mount, &path).await {
|
|
Ok(persisted) => Ok(Some(persisted.into())),
|
|
Err(vaultrs::error::ClientError::ResponseWrapError)
|
|
| Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(None),
|
|
Err(e) => Err(KmsError::backend_error(format!("Failed to read transit key metadata from Vault KV: {e}"))),
|
|
}
|
|
}
|
|
|
|
async fn write_metadata_to_kv(&self, key_id: &str, metadata: &TransitKeyMetadata) -> Result<()> {
|
|
let path = self.metadata_key_path(key_id);
|
|
let persisted: TransitKeyMetadataPersisted = metadata.clone().into();
|
|
kv2::set(&self.vault().client, &self.metadata_kv_mount, &path, &persisted)
|
|
.await
|
|
.map(|_| ())
|
|
.map_err(|e| KmsError::backend_error(format!("Failed to write transit key metadata to Vault KV: {e}")))
|
|
}
|
|
|
|
async fn delete_metadata_from_kv(&self, key_id: &str) -> Result<()> {
|
|
let path = self.metadata_key_path(key_id);
|
|
match kv2::delete_metadata(&self.vault().client, &self.metadata_kv_mount, &path).await {
|
|
Ok(_) => Ok(()),
|
|
Err(vaultrs::error::ClientError::ResponseWrapError)
|
|
| Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(()),
|
|
Err(e) => Err(KmsError::backend_error(format!(
|
|
"Failed to delete transit key metadata from Vault KV: {e}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
async fn get_key_metadata(&self, key_id: &str) -> Result<TransitKeyMetadata> {
|
|
// Check in-memory cache first.
|
|
if let Some(metadata) = self.metadata_cache.read().await.get(key_id).cloned() {
|
|
return Ok(metadata);
|
|
}
|
|
|
|
// On cache miss, try reading from the persistent KV store.
|
|
if let Some(persisted) = self.read_metadata_from_kv(key_id).await? {
|
|
self.metadata_cache
|
|
.write()
|
|
.await
|
|
.insert(key_id.to_string(), persisted.clone());
|
|
return Ok(persisted);
|
|
}
|
|
|
|
// Deliberate exemption from the "read paths never write" rule (rustfs#4256 /
|
|
// rustfs#4262): transit keys created before metadata persistence existed have no
|
|
// KV record at all, so failing closed here would brick every pre-existing transit
|
|
// key. The synthesised record only describes metadata — key material lives solely
|
|
// inside Vault's transit engine and is never generated or written by this path.
|
|
//
|
|
// Verify the transit key actually exists in Vault before synthesising.
|
|
self.read_transit_key(key_id).await?;
|
|
let metadata = TransitKeyMetadata::synthesized();
|
|
// Persist the synthesised metadata so future cache misses pick it up (best
|
|
// effort: the KV write failing must not fail the read).
|
|
let _ = self.write_metadata_to_kv(key_id, &metadata).await;
|
|
self.metadata_cache.write().await.insert(key_id.to_string(), metadata.clone());
|
|
Ok(metadata)
|
|
}
|
|
|
|
async fn store_key_metadata(&self, key_id: &str, metadata: &TransitKeyMetadata) -> Result<()> {
|
|
self.write_metadata_to_kv(key_id, metadata).await?;
|
|
self.metadata_cache.write().await.insert(key_id.to_string(), metadata.clone());
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete_key_metadata(&self, key_id: &str) -> Result<()> {
|
|
self.delete_metadata_from_kv(key_id).await?;
|
|
self.metadata_cache.write().await.remove(key_id);
|
|
Ok(())
|
|
}
|
|
|
|
async fn key_info(&self, key_id: &str) -> Result<KeyInfo> {
|
|
self.read_transit_key(key_id).await?;
|
|
let metadata = self.get_key_metadata(key_id).await?;
|
|
|
|
Ok(KeyInfo {
|
|
key_id: key_id.to_string(),
|
|
description: metadata.description.clone(),
|
|
algorithm: "AES_256".to_string(),
|
|
usage: metadata.key_usage.clone(),
|
|
status: match metadata.key_state {
|
|
KeyState::Enabled => KeyStatus::Active,
|
|
KeyState::Disabled => KeyStatus::Disabled,
|
|
KeyState::PendingDeletion => KeyStatus::PendingDeletion,
|
|
KeyState::PendingImport | KeyState::Unavailable => KeyStatus::Deleted,
|
|
},
|
|
version: metadata.current_version,
|
|
metadata: metadata.tags.clone(),
|
|
tags: metadata.tags,
|
|
created_at: metadata.created_at,
|
|
rotated_at: None,
|
|
created_by: metadata.created_by,
|
|
})
|
|
}
|
|
|
|
async fn key_metadata_response(&self, key_id: &str) -> Result<KeyMetadata> {
|
|
self.read_transit_key(key_id).await?;
|
|
let metadata = self.get_key_metadata(key_id).await?;
|
|
|
|
Ok(KeyMetadata {
|
|
key_id: key_id.to_string(),
|
|
key_state: metadata.key_state,
|
|
key_usage: metadata.key_usage,
|
|
description: metadata.description,
|
|
creation_date: metadata.created_at,
|
|
deletion_date: metadata.deletion_date,
|
|
origin: metadata.origin,
|
|
key_manager: "VAULT_TRANSIT".to_string(),
|
|
tags: metadata.tags,
|
|
})
|
|
}
|
|
|
|
async fn ensure_key_active(&self, key_id: &str) -> Result<TransitKeyMetadata> {
|
|
let metadata = self.get_key_metadata(key_id).await?;
|
|
if metadata.key_state != KeyState::Enabled {
|
|
return Err(KmsError::invalid_operation(format!(
|
|
"Key {key_id} is not active (state: {:?})",
|
|
metadata.key_state
|
|
)));
|
|
}
|
|
Ok(metadata)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl KmsClient for VaultTransitKmsClient {
|
|
async fn generate_data_key(&self, request: &GenerateKeyRequest, _context: Option<&OperationContext>) -> Result<DataKeyInfo> {
|
|
self.ensure_key_active(&request.master_key_id).await?;
|
|
|
|
let plaintext_key = generate_key_material(&request.key_spec)?;
|
|
let encrypted_key = self
|
|
.transit_encrypt(&request.master_key_id, &plaintext_key, &request.encryption_context)
|
|
.await?;
|
|
|
|
let envelope = DataKeyEnvelope {
|
|
key_id: uuid::Uuid::new_v4().to_string(),
|
|
master_key_id: request.master_key_id.clone(),
|
|
key_spec: request.key_spec.clone(),
|
|
encrypted_key: encrypted_key.into_bytes(),
|
|
nonce: Vec::new(),
|
|
encryption_context: request.encryption_context.clone(),
|
|
created_at: Zoned::now(),
|
|
// Transit ciphertext already self-describes its key version
|
|
// ("vault:vN:..."), so the envelope never carries one.
|
|
master_key_version: None,
|
|
};
|
|
|
|
let ciphertext = serde_json::to_vec(&envelope)?;
|
|
Ok(DataKeyInfo::new(
|
|
envelope.key_id,
|
|
1,
|
|
Some(plaintext_key),
|
|
ciphertext,
|
|
request.key_spec.clone(),
|
|
))
|
|
}
|
|
|
|
async fn encrypt(&self, request: &EncryptRequest, _context: Option<&OperationContext>) -> Result<EncryptResponse> {
|
|
let metadata = self.ensure_key_active(&request.key_id).await?;
|
|
let ciphertext = self
|
|
.transit_encrypt(&request.key_id, &request.plaintext, &request.encryption_context)
|
|
.await?;
|
|
|
|
Ok(EncryptResponse {
|
|
ciphertext: ciphertext.into_bytes(),
|
|
key_id: request.key_id.clone(),
|
|
key_version: metadata.current_version,
|
|
algorithm: "vault-transit".to_string(),
|
|
})
|
|
}
|
|
|
|
async fn decrypt(&self, request: &DecryptRequest, _context: Option<&OperationContext>) -> Result<Vec<u8>> {
|
|
let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext)
|
|
.map_err(|e| KmsError::cryptographic_error("parse", format!("Failed to parse data key envelope: {e}")))?;
|
|
|
|
for (key, expected_value) in &envelope.encryption_context {
|
|
if let Some(actual_value) = request.encryption_context.get(key) {
|
|
if actual_value != expected_value {
|
|
return Err(KmsError::context_mismatch(format!(
|
|
"Context mismatch for key '{key}': expected '{expected_value}', got '{actual_value}'"
|
|
)));
|
|
}
|
|
} else if !request.encryption_context.is_empty() {
|
|
return Err(KmsError::context_mismatch(format!("Missing context key '{key}'")));
|
|
}
|
|
}
|
|
|
|
let encrypted_key = std::str::from_utf8(&envelope.encrypted_key)
|
|
.map_err(|e| KmsError::cryptographic_error("utf8", format!("Invalid Transit ciphertext: {e}")))?;
|
|
self.transit_decrypt(&envelope.master_key_id, encrypted_key, &envelope.encryption_context)
|
|
.await
|
|
}
|
|
|
|
async fn create_key(&self, key_id: &str, algorithm: &str, _context: Option<&OperationContext>) -> Result<MasterKeyInfo> {
|
|
if algorithm != "AES_256" {
|
|
return Err(KmsError::unsupported_algorithm(algorithm));
|
|
}
|
|
|
|
if self.read_transit_key(key_id).await.is_ok() {
|
|
return Err(KmsError::key_already_exists(key_id));
|
|
}
|
|
|
|
self.create_transit_key(key_id).await?;
|
|
|
|
let metadata = TransitKeyMetadata {
|
|
created_by: Some("vault-transit".to_string()),
|
|
..TransitKeyMetadata::from_create_request(&CreateKeyRequest {
|
|
key_name: Some(key_id.to_string()),
|
|
..Default::default()
|
|
})
|
|
};
|
|
self.store_key_metadata(key_id, &metadata).await?;
|
|
|
|
Ok(MasterKeyInfo {
|
|
key_id: key_id.to_string(),
|
|
version: metadata.current_version,
|
|
algorithm: algorithm.to_string(),
|
|
usage: metadata.key_usage,
|
|
status: KeyStatus::Active,
|
|
description: metadata.description,
|
|
metadata: metadata.tags,
|
|
created_at: metadata.created_at,
|
|
rotated_at: None,
|
|
created_by: metadata.created_by,
|
|
})
|
|
}
|
|
|
|
async fn describe_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<KeyInfo> {
|
|
self.key_info(key_id).await
|
|
}
|
|
|
|
async fn list_keys(&self, request: &ListKeysRequest, _context: Option<&OperationContext>) -> Result<ListKeysResponse> {
|
|
let all_keys = key::list(&self.vault().client, &self.config.mount_path)
|
|
.await
|
|
.map_err(|e| KmsError::backend_error(format!("Failed to list Vault Transit keys: {e}")))?
|
|
.keys;
|
|
|
|
let mut filtered = Vec::new();
|
|
for key_id in all_keys {
|
|
let key_info = self.key_info(&key_id).await?;
|
|
let usage_matches = request.usage_filter.as_ref().is_none_or(|usage| usage == &key_info.usage);
|
|
let status_matches = request.status_filter.as_ref().is_none_or(|status| status == &key_info.status);
|
|
if usage_matches && status_matches {
|
|
filtered.push(key_info);
|
|
}
|
|
}
|
|
|
|
let start_idx = request
|
|
.marker
|
|
.as_ref()
|
|
.and_then(|marker| filtered.iter().position(|info| &info.key_id == marker))
|
|
.map(|idx| idx + 1)
|
|
.unwrap_or(0);
|
|
let limit = request.limit.unwrap_or(100) as usize;
|
|
let end_idx = std::cmp::min(start_idx + limit, filtered.len());
|
|
let keys = filtered[start_idx..end_idx].to_vec();
|
|
let next_marker = if end_idx < filtered.len() {
|
|
Some(filtered[end_idx - 1].key_id.clone())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(ListKeysResponse {
|
|
keys,
|
|
next_marker,
|
|
truncated: end_idx < filtered.len(),
|
|
})
|
|
}
|
|
|
|
async fn enable_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<()> {
|
|
let mut metadata = self.get_key_metadata(key_id).await?;
|
|
metadata.key_state = KeyState::Enabled;
|
|
metadata.deletion_date = None;
|
|
self.store_key_metadata(key_id, &metadata).await
|
|
}
|
|
|
|
async fn disable_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<()> {
|
|
let mut metadata = self.get_key_metadata(key_id).await?;
|
|
metadata.key_state = KeyState::Disabled;
|
|
self.store_key_metadata(key_id, &metadata).await
|
|
}
|
|
|
|
async fn schedule_key_deletion(
|
|
&self,
|
|
key_id: &str,
|
|
pending_window_days: u32,
|
|
_context: Option<&OperationContext>,
|
|
) -> Result<()> {
|
|
let mut metadata = self.get_key_metadata(key_id).await?;
|
|
metadata.key_state = KeyState::PendingDeletion;
|
|
metadata.deletion_date = Some(Zoned::now() + Duration::from_secs(pending_window_days as u64 * 86400));
|
|
self.store_key_metadata(key_id, &metadata).await
|
|
}
|
|
|
|
async fn cancel_key_deletion(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<()> {
|
|
let mut metadata = self.get_key_metadata(key_id).await?;
|
|
metadata.key_state = KeyState::Enabled;
|
|
metadata.deletion_date = None;
|
|
self.store_key_metadata(key_id, &metadata).await
|
|
}
|
|
|
|
async fn rotate_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<MasterKeyInfo> {
|
|
key::rotate(&self.vault().client, &self.config.mount_path, key_id)
|
|
.await
|
|
.map_err(|e| KmsError::backend_error(format!("Failed to rotate Vault Transit key {key_id}: {e}")))?;
|
|
|
|
let mut metadata = self.get_key_metadata(key_id).await?;
|
|
metadata.current_version += 1;
|
|
self.store_key_metadata(key_id, &metadata).await?;
|
|
|
|
Ok(MasterKeyInfo {
|
|
key_id: key_id.to_string(),
|
|
version: metadata.current_version,
|
|
algorithm: "AES_256".to_string(),
|
|
usage: metadata.key_usage,
|
|
status: KeyStatus::Active,
|
|
description: metadata.description,
|
|
metadata: metadata.tags,
|
|
created_at: metadata.created_at,
|
|
rotated_at: Some(Zoned::now()),
|
|
created_by: metadata.created_by,
|
|
})
|
|
}
|
|
|
|
async fn health_check(&self) -> Result<()> {
|
|
key::list(&self.vault().client, &self.config.mount_path)
|
|
.await
|
|
.map(|_| ())
|
|
.map_err(|e| KmsError::backend_error(format!("Vault Transit health check failed: {e}")))
|
|
}
|
|
|
|
fn backend_info(&self) -> BackendInfo {
|
|
BackendInfo::new("vault-transit".to_string(), "0.1.0".to_string(), self.config.address.clone(), true)
|
|
.with_metadata("mount_path".to_string(), self.config.mount_path.clone())
|
|
}
|
|
}
|
|
|
|
pub struct VaultTransitKmsBackend {
|
|
client: VaultTransitKmsClient,
|
|
}
|
|
|
|
impl VaultTransitKmsBackend {
|
|
pub async fn new(config: KmsConfig) -> Result<Self> {
|
|
config.validate()?;
|
|
|
|
let vault_config = match &config.backend_config {
|
|
crate::config::BackendConfig::VaultTransit(vault_config) => (**vault_config).clone(),
|
|
crate::config::BackendConfig::VaultKv2(vault_config) => VaultTransitConfig {
|
|
address: vault_config.address.clone(),
|
|
auth_method: vault_config.auth_method.clone(),
|
|
namespace: vault_config.namespace.clone(),
|
|
mount_path: vault_config.mount_path.clone(),
|
|
metadata_kv_mount: vault_config.kv_mount.clone(),
|
|
metadata_key_prefix: vault_config.key_path_prefix.clone(),
|
|
tls: vault_config.tls.clone(),
|
|
},
|
|
crate::config::BackendConfig::Local(_) | crate::config::BackendConfig::Static(_) => {
|
|
return Err(KmsError::configuration_error("Expected Vault Transit backend configuration"));
|
|
}
|
|
};
|
|
|
|
let client = VaultTransitKmsClient::new(vault_config, config.effective_timeout()).await?;
|
|
Ok(Self { client })
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl KmsBackend for VaultTransitKmsBackend {
|
|
async fn create_key(&self, request: CreateKeyRequest) -> Result<CreateKeyResponse> {
|
|
let key_id = request.key_name.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
|
if self.client.read_transit_key(&key_id).await.is_ok() {
|
|
return Err(KmsError::key_already_exists(&key_id));
|
|
}
|
|
|
|
self.client.create_transit_key(&key_id).await?;
|
|
let metadata = TransitKeyMetadata::from_create_request(&request);
|
|
self.client.store_key_metadata(&key_id, &metadata).await?;
|
|
|
|
Ok(CreateKeyResponse {
|
|
key_id: key_id.clone(),
|
|
key_metadata: KeyMetadata {
|
|
key_id,
|
|
key_state: metadata.key_state,
|
|
key_usage: metadata.key_usage,
|
|
description: metadata.description,
|
|
creation_date: metadata.created_at,
|
|
deletion_date: metadata.deletion_date,
|
|
origin: metadata.origin,
|
|
key_manager: "VAULT_TRANSIT".to_string(),
|
|
tags: metadata.tags,
|
|
},
|
|
})
|
|
}
|
|
|
|
async fn encrypt(&self, request: EncryptRequest) -> Result<EncryptResponse> {
|
|
self.client.encrypt(&request, None).await
|
|
}
|
|
|
|
async fn decrypt(&self, request: DecryptRequest) -> Result<DecryptResponse> {
|
|
let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext)?;
|
|
let plaintext = self.client.decrypt(&request, None).await?;
|
|
Ok(DecryptResponse {
|
|
plaintext,
|
|
key_id: envelope.master_key_id,
|
|
encryption_algorithm: Some("vault-transit".to_string()),
|
|
})
|
|
}
|
|
|
|
async fn generate_data_key(&self, request: GenerateDataKeyRequest) -> Result<GenerateDataKeyResponse> {
|
|
let generate_request = GenerateKeyRequest {
|
|
master_key_id: request.key_id.clone(),
|
|
key_spec: request.key_spec.as_str().to_string(),
|
|
key_length: Some(request.key_spec.key_size() as u32),
|
|
encryption_context: request.encryption_context,
|
|
grant_tokens: Vec::new(),
|
|
};
|
|
|
|
let data_key = self.client.generate_data_key(&generate_request, None).await?;
|
|
let plaintext_key = data_key.plaintext.clone().unwrap_or_default();
|
|
let ciphertext_blob = data_key.ciphertext.clone();
|
|
Ok(GenerateDataKeyResponse {
|
|
key_id: request.key_id,
|
|
plaintext_key,
|
|
ciphertext_blob,
|
|
})
|
|
}
|
|
|
|
async fn describe_key(&self, request: DescribeKeyRequest) -> Result<DescribeKeyResponse> {
|
|
Ok(DescribeKeyResponse {
|
|
key_metadata: self.client.key_metadata_response(&request.key_id).await?,
|
|
})
|
|
}
|
|
|
|
async fn list_keys(&self, request: ListKeysRequest) -> Result<ListKeysResponse> {
|
|
self.client.list_keys(&request, None).await
|
|
}
|
|
|
|
async fn delete_key(&self, request: DeleteKeyRequest) -> Result<DeleteKeyResponse> {
|
|
let key_id = request.key_id;
|
|
let mut key_metadata = self.client.key_metadata_response(&key_id).await?;
|
|
|
|
let deletion_date = if request.force_immediate.unwrap_or(false) {
|
|
if key_metadata.key_state == KeyState::PendingDeletion {
|
|
if !self.client.read_transit_key(&key_id).await?.deletion_allowed {
|
|
let mut update_builder = UpdateKeyConfigurationRequestBuilder::default();
|
|
update_builder.deletion_allowed(true);
|
|
key::update(
|
|
&self.client.vault().client,
|
|
&self.client.config.mount_path,
|
|
&key_id,
|
|
Some(&mut update_builder),
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
KmsError::backend_error(format!("Failed to allow deletion of Vault Transit key {key_id}: {e}"))
|
|
})?;
|
|
}
|
|
key::delete(&self.client.vault().client, &self.client.config.mount_path, &key_id)
|
|
.await
|
|
.map_err(|e| KmsError::backend_error(format!("Failed to delete Vault Transit key {key_id}: {e}")))?;
|
|
self.client.delete_key_metadata(&key_id).await?;
|
|
None
|
|
} else {
|
|
let mut metadata = self.client.get_key_metadata(&key_id).await?;
|
|
metadata.key_state = KeyState::PendingDeletion;
|
|
metadata.deletion_date = Some(Zoned::now());
|
|
self.client.store_key_metadata(&key_id, &metadata).await?;
|
|
key_metadata = self.client.key_metadata_response(&key_id).await?;
|
|
None
|
|
}
|
|
} else {
|
|
let days = request.pending_window_in_days.unwrap_or(30);
|
|
if !(7..=30).contains(&days) {
|
|
return Err(KmsError::invalid_parameter("pending_window_in_days must be between 7 and 30"));
|
|
}
|
|
|
|
let mut metadata = self.client.get_key_metadata(&key_id).await?;
|
|
let scheduled = Zoned::now() + Duration::from_secs(days as u64 * 86400);
|
|
metadata.key_state = KeyState::PendingDeletion;
|
|
metadata.deletion_date = Some(scheduled.clone());
|
|
self.client.store_key_metadata(&key_id, &metadata).await?;
|
|
key_metadata = self.client.key_metadata_response(&key_id).await?;
|
|
Some(scheduled.to_string())
|
|
};
|
|
|
|
Ok(DeleteKeyResponse {
|
|
key_id,
|
|
deletion_date,
|
|
key_metadata,
|
|
})
|
|
}
|
|
|
|
async fn cancel_key_deletion(&self, request: CancelKeyDeletionRequest) -> Result<CancelKeyDeletionResponse> {
|
|
let mut metadata = self.client.get_key_metadata(&request.key_id).await?;
|
|
if metadata.key_state != KeyState::PendingDeletion {
|
|
return Err(KmsError::invalid_key_state(format!("Key {} is not pending deletion", request.key_id)));
|
|
}
|
|
|
|
metadata.key_state = KeyState::Enabled;
|
|
metadata.deletion_date = None;
|
|
self.client.store_key_metadata(&request.key_id, &metadata).await?;
|
|
|
|
Ok(CancelKeyDeletionResponse {
|
|
key_id: request.key_id.clone(),
|
|
key_metadata: self.client.key_metadata_response(&request.key_id).await?,
|
|
})
|
|
}
|
|
|
|
async fn health_check(&self) -> Result<bool> {
|
|
self.client.health_check().await.map(|_| true)
|
|
}
|
|
|
|
fn capabilities(&self) -> BackendCapabilities {
|
|
// Vault Transit natively supports version-retaining rotation, keeps
|
|
// prior versions addressable for decryption, and allows physical
|
|
// deletion once a key is pending deletion.
|
|
BackendCapabilities::minimal()
|
|
.with_rotate(true)
|
|
.with_enable_disable(true)
|
|
.with_schedule_deletion(true)
|
|
.with_versioning(true)
|
|
.with_physical_delete(true)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::{
|
|
DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX, DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT, VaultAuthMethod, VaultTransitConfig,
|
|
};
|
|
use crate::types::KeyStatus;
|
|
|
|
fn test_vault_transit_config() -> VaultTransitConfig {
|
|
VaultTransitConfig {
|
|
address: "http://127.0.0.1:8200".to_string(),
|
|
auth_method: VaultAuthMethod::Token {
|
|
token: std::env::var("RUSTFS_KMS_VAULT_TOKEN").unwrap_or_else(|_| "dev-token".to_string()),
|
|
},
|
|
namespace: None,
|
|
mount_path: "transit".to_string(),
|
|
metadata_kv_mount: DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT.to_string(),
|
|
metadata_key_prefix: DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX.to_string(),
|
|
tls: None,
|
|
}
|
|
}
|
|
|
|
/// Regression test for rustfs/backlog#808.
|
|
///
|
|
/// VaultTransit stores key metadata (state, tags, etc.) ONLY in an in-memory
|
|
/// `metadata_cache`. On a cache miss — including after any server restart —
|
|
/// `get_key_metadata()` synthesises a fresh record with `key_state: Enabled`.
|
|
/// This means a disabled/deleted key silently revives as Enabled after restart.
|
|
#[tokio::test]
|
|
#[ignore] // Requires a running Vault instance with transit engine enabled
|
|
async fn test_transit_key_state_lost_after_restart_simulation() {
|
|
let config = test_vault_transit_config();
|
|
|
|
// --- First "process": create a key and disable it ---
|
|
let client1 = VaultTransitKmsClient::new(config.clone(), Duration::from_secs(30))
|
|
.await
|
|
.expect("Failed to create VaultTransit client");
|
|
|
|
let key_id = format!("regression-808-{}", uuid::Uuid::new_v4());
|
|
|
|
// Create key → Enabled
|
|
let created = client1.create_key(&key_id, "AES_256", None).await.expect("create_key");
|
|
assert_eq!(created.status, KeyStatus::Active, "newly created key must be Active");
|
|
|
|
let info = client1
|
|
.describe_key(&key_id, None)
|
|
.await
|
|
.expect("describe_key before disable");
|
|
assert_eq!(info.status, KeyStatus::Active, "key must be Active before disable");
|
|
|
|
// Disable the key
|
|
client1.disable_key(&key_id, None).await.expect("disable_key");
|
|
|
|
let info_after_disable = client1.describe_key(&key_id, None).await.expect("describe_key after disable");
|
|
assert_eq!(info_after_disable.status, KeyStatus::Disabled, "key must be Disabled after disable_key");
|
|
|
|
// --- Simulate restart: create a brand new client with empty cache ---
|
|
let client2 = VaultTransitKmsClient::new(config, Duration::from_secs(30))
|
|
.await
|
|
.expect("Failed to create second VaultTransit client (restart simulation)");
|
|
|
|
// After "restart", the key must remain Disabled because KV-persisted metadata
|
|
// survives across client recreation.
|
|
let info_after_restart = client2
|
|
.describe_key(&key_id, None)
|
|
.await
|
|
.expect("describe_key after restart simulation");
|
|
|
|
assert_eq!(
|
|
info_after_restart.status,
|
|
KeyStatus::Disabled,
|
|
"after restart, a disabled key must remain Disabled"
|
|
);
|
|
|
|
// Cleanup: schedule the key for deletion so Vault state is clean for the next run.
|
|
let _ = client2.schedule_key_deletion(&key_id, 7, None).await;
|
|
}
|
|
|
|
/// Regression test for rustfs/backlog#808.
|
|
///
|
|
/// PendingDeletion must be persisted outside the process-local metadata cache.
|
|
/// Otherwise, a restart would synthesize Enabled metadata and allow new key use.
|
|
#[tokio::test]
|
|
#[ignore] // Requires a running Vault instance with transit engine enabled
|
|
async fn test_transit_pending_deletion_survives_restart_simulation() {
|
|
let config = test_vault_transit_config();
|
|
|
|
let client1 = VaultTransitKmsClient::new(config.clone(), Duration::from_secs(30))
|
|
.await
|
|
.expect("Failed to create VaultTransit client");
|
|
|
|
let key_id = format!("regression-808-pending-{}", uuid::Uuid::new_v4());
|
|
|
|
let created = client1.create_key(&key_id, "AES_256", None).await.expect("create_key");
|
|
assert_eq!(created.status, KeyStatus::Active, "newly created key must be Active");
|
|
|
|
client1
|
|
.schedule_key_deletion(&key_id, 7, None)
|
|
.await
|
|
.expect("schedule_key_deletion");
|
|
|
|
let info_after_schedule = client1
|
|
.describe_key(&key_id, None)
|
|
.await
|
|
.expect("describe_key after schedule_key_deletion");
|
|
assert_eq!(
|
|
info_after_schedule.status,
|
|
KeyStatus::PendingDeletion,
|
|
"key must be PendingDeletion after schedule_key_deletion"
|
|
);
|
|
|
|
let client2 = VaultTransitKmsClient::new(config, Duration::from_secs(30))
|
|
.await
|
|
.expect("Failed to create second VaultTransit client (restart simulation)");
|
|
|
|
let info_after_restart = client2
|
|
.describe_key(&key_id, None)
|
|
.await
|
|
.expect("describe_key after restart simulation");
|
|
|
|
assert_eq!(
|
|
info_after_restart.status,
|
|
KeyStatus::PendingDeletion,
|
|
"after restart, a pending-deletion key must remain PendingDeletion"
|
|
);
|
|
|
|
let generate_result = client2
|
|
.generate_data_key(
|
|
&GenerateKeyRequest {
|
|
master_key_id: key_id,
|
|
key_spec: "AES_256".to_string(),
|
|
key_length: Some(32),
|
|
encryption_context: HashMap::new(),
|
|
grant_tokens: Vec::new(),
|
|
},
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
generate_result.is_err(),
|
|
"after restart, a pending-deletion key must not be usable for new data keys"
|
|
);
|
|
}
|
|
|
|
/// Contract regression for rustfs/backlog#1565.
|
|
///
|
|
/// Transit rotation is delegated entirely to Vault's own key versioning: the
|
|
/// ciphertext self-describes the wrapping version ("vault:vN:..."), so historical
|
|
/// ciphertext must keep decrypting after rotation without any RustFS-side
|
|
/// version bookkeeping in the envelope.
|
|
#[tokio::test]
|
|
#[ignore] // Requires a running Vault instance with transit engine enabled
|
|
async fn test_transit_old_ciphertext_decrypts_after_rotate() {
|
|
let client = VaultTransitKmsClient::new(test_vault_transit_config(), Duration::from_secs(30))
|
|
.await
|
|
.expect("Failed to create VaultTransit client");
|
|
|
|
let key_id = format!("regression-1565-rotate-{}", uuid::Uuid::new_v4());
|
|
client.create_key(&key_id, "AES_256", None).await.expect("create_key");
|
|
|
|
let request = GenerateKeyRequest {
|
|
master_key_id: key_id.clone(),
|
|
key_spec: "AES_256".to_string(),
|
|
key_length: Some(32),
|
|
encryption_context: HashMap::new(),
|
|
grant_tokens: Vec::new(),
|
|
};
|
|
|
|
let dk_v1 = client.generate_data_key(&request, None).await.expect("generate under v1");
|
|
let env_v1: DataKeyEnvelope = serde_json::from_slice(&dk_v1.ciphertext).expect("parse v1 envelope");
|
|
assert!(
|
|
env_v1.encrypted_key.starts_with(b"vault:v1:"),
|
|
"first-version Transit ciphertext must carry the vault:v1: prefix"
|
|
);
|
|
assert_eq!(
|
|
env_v1.master_key_version, None,
|
|
"Transit envelopes must not carry a RustFS-side master key version"
|
|
);
|
|
|
|
let rotated = client.rotate_key(&key_id, None).await.expect("rotate_key");
|
|
assert_eq!(rotated.version, 2, "rotation must advance the Transit key version");
|
|
|
|
let dk_v2 = client.generate_data_key(&request, None).await.expect("generate under v2");
|
|
let env_v2: DataKeyEnvelope = serde_json::from_slice(&dk_v2.ciphertext).expect("parse v2 envelope");
|
|
assert!(
|
|
env_v2.encrypted_key.starts_with(b"vault:v2:"),
|
|
"post-rotation Transit ciphertext must carry the vault:v2: prefix"
|
|
);
|
|
|
|
// Historical ciphertext keeps decrypting per Vault's version semantics,
|
|
// interleaved with post-rotation ciphertext.
|
|
for (data_key, label) in [(&dk_v1, "v1"), (&dk_v2, "v2"), (&dk_v1, "v1 again")] {
|
|
let plaintext = client
|
|
.decrypt(
|
|
&DecryptRequest {
|
|
ciphertext: data_key.ciphertext.clone(),
|
|
encryption_context: HashMap::new(),
|
|
grant_tokens: Vec::new(),
|
|
},
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_or_else(|error| panic!("{label} ciphertext must stay decryptable after rotation: {error}"));
|
|
assert_eq!(Some(plaintext), data_key.plaintext, "{label} plaintext must round-trip");
|
|
}
|
|
|
|
// Cleanup so repeated runs against the same Vault do not accumulate keys.
|
|
let _ = client.schedule_key_deletion(&key_id, 7, None).await;
|
|
}
|
|
}
|