mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-27 23:47:28 +00:00
refactor(kms): route Vault clients through a rotatable credential provider (#5481)
* fix(kms): repair vault test call sites missed by the timeout refactor Three offline tests still constructed VaultKmsClient with the pre-#5472 single-argument signature, leaving cargo test -p rustfs-kms unable to compile. Pass the same 30s attempt timeout the neighbouring tests use. * refactor(kms): route Vault clients through a rotatable credential provider Both Vault backends previously built a VaultClient in their constructor and held it for the lifetime of the backend, which leaves no seam for re-authentication: rotating credentials would require tearing down the whole backend. Introduce backends/vault_credentials with a TokenSource trait (only StaticToken for now; AppRole login and agent token files land in follow-ups) and a VaultCredentialProvider that owns the authenticated client behind an ArcSwap. Request paths take a per-call snapshot via current(), so a future rotation swaps in a new client generation without interrupting calls already in flight. Tokens held by this crate are zeroized on drop, and Debug output of every credential-carrying type is redacted (covered by a leak regression test). Behavior is unchanged: static token, namespace, and per-attempt timeout feed the same VaultClientSettings as before, and AppRole configurations are still rejected at construction with the same message.
This commit is contained in:
@@ -22,6 +22,7 @@ use std::collections::HashMap;
|
|||||||
pub mod local;
|
pub mod local;
|
||||||
pub mod static_kms;
|
pub mod static_kms;
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
|
pub(crate) mod vault_credentials;
|
||||||
pub mod vault_transit;
|
pub mod vault_transit;
|
||||||
|
|
||||||
/// Abstract KMS client interface that all backends must implement
|
/// Abstract KMS client interface that all backends must implement
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
//! Vault-based KMS backend implementation using vaultrs
|
//! Vault-based KMS backend implementation using vaultrs
|
||||||
|
|
||||||
|
use crate::backends::vault_credentials::{VaultClientHandle, VaultConnectionSettings, VaultCredentialProvider, token_source_for};
|
||||||
use crate::backends::{BackendInfo, KmsBackend, KmsClient};
|
use crate::backends::{BackendInfo, KmsBackend, KmsClient};
|
||||||
use crate::config::{KmsConfig, VaultConfig};
|
use crate::config::{KmsConfig, VaultConfig};
|
||||||
use crate::encryption::{AesDekCrypto, DataKeyEnvelope, DekCrypto, generate_key_material};
|
use crate::encryption::{AesDekCrypto, DataKeyEnvelope, DekCrypto, generate_key_material};
|
||||||
@@ -24,16 +25,14 @@ use base64::{Engine as _, engine::general_purpose};
|
|||||||
use jiff::Zoned;
|
use jiff::Zoned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
use vaultrs::{
|
use vaultrs::kv2;
|
||||||
client::{VaultClient, VaultClientSettingsBuilder},
|
|
||||||
kv2,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Vault KMS client implementation
|
/// Vault KMS client implementation
|
||||||
pub struct VaultKmsClient {
|
pub struct VaultKmsClient {
|
||||||
client: VaultClient,
|
credentials: VaultCredentialProvider,
|
||||||
config: VaultConfig,
|
config: VaultConfig,
|
||||||
/// Mount path for the KV engine (typically "kv" or "secret")
|
/// Mount path for the KV engine (typically "kv" or "secret")
|
||||||
kv_mount: String,
|
kv_mount: String,
|
||||||
@@ -100,44 +99,18 @@ impl VaultKmsClient {
|
|||||||
///
|
///
|
||||||
/// `attempt_timeout` caps every HTTP request issued through this client.
|
/// `attempt_timeout` caps every HTTP request issued through this client.
|
||||||
pub async fn new(config: VaultConfig, attempt_timeout: Duration) -> Result<Self> {
|
pub async fn new(config: VaultConfig, attempt_timeout: Duration) -> Result<Self> {
|
||||||
// Create client settings
|
let source = token_source_for(&config.auth_method)?;
|
||||||
let mut settings_builder = VaultClientSettingsBuilder::default();
|
let settings = VaultConnectionSettings {
|
||||||
settings_builder.address(&config.address);
|
address: config.address.clone(),
|
||||||
// Defense in depth against stalled connections: vaultrs leaves the
|
namespace: config.namespace.clone(),
|
||||||
// underlying reqwest client without any timeout by default, so a hung
|
attempt_timeout,
|
||||||
// request would otherwise wait forever regardless of the
|
|
||||||
// operation-level retry policy.
|
|
||||||
settings_builder.timeout(Some(attempt_timeout));
|
|
||||||
|
|
||||||
// Set authentication token based on method
|
|
||||||
let token = match &config.auth_method {
|
|
||||||
crate::config::VaultAuthMethod::Token { token } => token.clone(),
|
|
||||||
crate::config::VaultAuthMethod::AppRole { .. } => {
|
|
||||||
// For AppRole authentication, we would need to first authenticate
|
|
||||||
// and get a token. For simplicity, we'll require a token for now.
|
|
||||||
return Err(KmsError::backend_error(
|
|
||||||
"AppRole authentication not yet implemented. Please use token authentication.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
let credentials = VaultCredentialProvider::new(settings, source).await?;
|
||||||
settings_builder.token(&token);
|
|
||||||
|
|
||||||
if let Some(namespace) = &config.namespace {
|
|
||||||
settings_builder.namespace(Some(namespace.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = settings_builder
|
|
||||||
.build()
|
|
||||||
.map_err(|e| KmsError::backend_error(format!("Failed to build Vault client settings: {e}")))?;
|
|
||||||
|
|
||||||
let client =
|
|
||||||
VaultClient::new(settings).map_err(|e| KmsError::backend_error(format!("Failed to create Vault client: {e}")))?;
|
|
||||||
|
|
||||||
info!(address = %config.address, "Vault KMS backend connected");
|
info!(address = %config.address, "Vault KMS backend connected");
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client,
|
credentials,
|
||||||
kv_mount: config.kv_mount.clone(),
|
kv_mount: config.kv_mount.clone(),
|
||||||
key_path_prefix: config.key_path_prefix.clone(),
|
key_path_prefix: config.key_path_prefix.clone(),
|
||||||
config,
|
config,
|
||||||
@@ -145,6 +118,14 @@ impl VaultKmsClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the full path for a key in Vault
|
/// Get the full path for a key in Vault
|
||||||
fn key_path(&self, key_id: &str) -> String {
|
fn key_path(&self, key_id: &str) -> String {
|
||||||
format!("{}/{}", self.key_path_prefix, key_id)
|
format!("{}/{}", self.key_path_prefix, key_id)
|
||||||
@@ -193,7 +174,7 @@ impl VaultKmsClient {
|
|||||||
async fn store_key_data(&self, key_id: &str, key_data: &VaultKeyData) -> Result<()> {
|
async fn store_key_data(&self, key_id: &str, key_data: &VaultKeyData) -> Result<()> {
|
||||||
let path = self.key_path(key_id);
|
let path = self.key_path(key_id);
|
||||||
|
|
||||||
kv2::set(&self.client, &self.kv_mount, &path, key_data)
|
kv2::set(&self.vault().client, &self.kv_mount, &path, key_data)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| KmsError::backend_error(format!("Failed to store key in Vault: {e}")))?;
|
.map_err(|e| KmsError::backend_error(format!("Failed to store key in Vault: {e}")))?;
|
||||||
|
|
||||||
@@ -242,11 +223,13 @@ impl VaultKmsClient {
|
|||||||
async fn get_key_data(&self, key_id: &str) -> Result<VaultKeyData> {
|
async fn get_key_data(&self, key_id: &str) -> Result<VaultKeyData> {
|
||||||
let path = self.key_path(key_id);
|
let path = self.key_path(key_id);
|
||||||
|
|
||||||
let secret: VaultKeyData = kv2::read(&self.client, &self.kv_mount, &path).await.map_err(|e| match e {
|
let secret: VaultKeyData = kv2::read(&self.vault().client, &self.kv_mount, &path)
|
||||||
vaultrs::error::ClientError::ResponseWrapError => KmsError::key_not_found(key_id),
|
.await
|
||||||
vaultrs::error::ClientError::APIError { code: 404, .. } => KmsError::key_not_found(key_id),
|
.map_err(|e| match e {
|
||||||
_ => KmsError::backend_error(format!("Failed to read key from Vault: {e}")),
|
vaultrs::error::ClientError::ResponseWrapError => KmsError::key_not_found(key_id),
|
||||||
})?;
|
vaultrs::error::ClientError::APIError { code: 404, .. } => KmsError::key_not_found(key_id),
|
||||||
|
_ => KmsError::backend_error(format!("Failed to read key from Vault: {e}")),
|
||||||
|
})?;
|
||||||
|
|
||||||
debug!("Retrieved key {} from Vault, tags: {:?}", key_id, secret.tags);
|
debug!("Retrieved key {} from Vault, tags: {:?}", key_id, secret.tags);
|
||||||
Ok(secret)
|
Ok(secret)
|
||||||
@@ -255,7 +238,7 @@ impl VaultKmsClient {
|
|||||||
/// List all keys stored in Vault
|
/// List all keys stored in Vault
|
||||||
async fn list_vault_keys(&self) -> Result<Vec<String>> {
|
async fn list_vault_keys(&self) -> Result<Vec<String>> {
|
||||||
// List keys under the prefix
|
// List keys under the prefix
|
||||||
match kv2::list(&self.client, &self.kv_mount, &self.key_path_prefix).await {
|
match kv2::list(&self.vault().client, &self.kv_mount, &self.key_path_prefix).await {
|
||||||
Ok(keys) => {
|
Ok(keys) => {
|
||||||
debug!("Found {} keys in Vault", keys.len());
|
debug!("Found {} keys in Vault", keys.len());
|
||||||
Ok(keys)
|
Ok(keys)
|
||||||
@@ -279,7 +262,7 @@ impl VaultKmsClient {
|
|||||||
|
|
||||||
// For this specific key path, we can safely delete the metadata
|
// For this specific key path, we can safely delete the metadata
|
||||||
// since each key has its own unique path under the prefix
|
// since each key has its own unique path under the prefix
|
||||||
kv2::delete_metadata(&self.client, &self.kv_mount, &path)
|
kv2::delete_metadata(&self.vault().client, &self.kv_mount, &path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
vaultrs::error::ClientError::APIError { code: 404, .. } => KmsError::key_not_found(key_id),
|
vaultrs::error::ClientError::APIError { code: 404, .. } => KmsError::key_not_found(key_id),
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! Credential plumbing shared by the Vault KV2 and Transit backends.
|
||||||
|
//!
|
||||||
|
//! [`VaultCredentialProvider`] owns the authenticated [`VaultClient`] and hands
|
||||||
|
//! out per-request snapshots. Backends take a fresh snapshot via
|
||||||
|
//! [`VaultCredentialProvider::current`] for every Vault call instead of holding
|
||||||
|
//! a client for their own lifetime: a future credential rotation then applies
|
||||||
|
//! to the next call, while calls already in flight finish on the generation
|
||||||
|
//! they captured (their `Arc` keeps it alive).
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use vaultrs::client::{VaultClient, VaultClientSettingsBuilder};
|
||||||
|
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||||
|
|
||||||
|
use crate::config::{VaultAuthMethod, redacted_secret};
|
||||||
|
use crate::error::{KmsError, Result};
|
||||||
|
|
||||||
|
/// A Vault token handed out by a [`TokenSource`].
|
||||||
|
///
|
||||||
|
/// The crate's copy of the token is zeroized on drop. This cannot cover the
|
||||||
|
/// copy `vaultrs` keeps inside its client settings (or the HTTP headers built
|
||||||
|
/// from it); it bounds how long the token lingers in memory owned by this
|
||||||
|
/// module.
|
||||||
|
///
|
||||||
|
/// AppRole login (PR-2) will extend this with the lease metadata returned by
|
||||||
|
/// the login endpoint (`lease_duration`, `renewable`, accessor).
|
||||||
|
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
|
||||||
|
pub(crate) struct TokenLease {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenLease {
|
||||||
|
pub(crate) fn new(token: String) -> Self {
|
||||||
|
Self { token }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expose the raw token for handing to the Vault client builder.
|
||||||
|
pub(crate) fn expose(&self) -> &str {
|
||||||
|
&self.token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for TokenLease {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("TokenLease")
|
||||||
|
.field("token", &redacted_secret(&self.token))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source of Vault authentication tokens.
|
||||||
|
///
|
||||||
|
/// Only [`StaticToken`] exists today. The trait is async and fallible so
|
||||||
|
/// future sources can perform I/O when acquiring a token without changing the
|
||||||
|
/// provider:
|
||||||
|
/// - `AppRoleLogin` (PR-2): performs an `auth/approle/login` round trip and
|
||||||
|
/// returns the issued token with its lease metadata;
|
||||||
|
/// - `TokenFile` (PR-3): re-reads an agent-managed token file.
|
||||||
|
#[async_trait]
|
||||||
|
pub(crate) trait TokenSource: fmt::Debug + Send + Sync {
|
||||||
|
/// Acquire a token for a new client generation.
|
||||||
|
///
|
||||||
|
/// Called once at provider construction today; rotation (PR-2) will call
|
||||||
|
/// it again for every re-authentication.
|
||||||
|
async fn acquire(&self) -> Result<TokenLease>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token source for [`VaultAuthMethod::Token`]: always yields the token fixed
|
||||||
|
/// at configuration time.
|
||||||
|
pub(crate) struct StaticToken {
|
||||||
|
token: TokenLease,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StaticToken {
|
||||||
|
pub(crate) fn new(token: String) -> Self {
|
||||||
|
Self {
|
||||||
|
token: TokenLease::new(token),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TokenSource for StaticToken {
|
||||||
|
async fn acquire(&self) -> Result<TokenLease> {
|
||||||
|
Ok(self.token.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for StaticToken {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
// TokenLease::fmt already redacts the token value.
|
||||||
|
f.debug_struct("StaticToken").field("token", &self.token).finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map the configured auth method onto a token source.
|
||||||
|
///
|
||||||
|
/// AppRole is still rejected at construction time; PR-2 replaces this arm with
|
||||||
|
/// an `AppRoleLogin` source.
|
||||||
|
pub(crate) fn token_source_for(auth_method: &VaultAuthMethod) -> Result<Box<dyn TokenSource>> {
|
||||||
|
match auth_method {
|
||||||
|
VaultAuthMethod::Token { token } => Ok(Box::new(StaticToken::new(token.clone()))),
|
||||||
|
VaultAuthMethod::AppRole { .. } => Err(KmsError::backend_error(
|
||||||
|
"AppRole authentication not yet implemented. Please use token authentication.",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connection parameters shared by every client generation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct VaultConnectionSettings {
|
||||||
|
pub(crate) address: String,
|
||||||
|
pub(crate) namespace: Option<String>,
|
||||||
|
/// Per-attempt HTTP timeout applied to the underlying reqwest client.
|
||||||
|
pub(crate) attempt_timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VaultConnectionSettings {
|
||||||
|
/// Build an authenticated client for one generation.
|
||||||
|
fn build_client(&self, token: &TokenLease) -> Result<VaultClient> {
|
||||||
|
let mut settings_builder = VaultClientSettingsBuilder::default();
|
||||||
|
settings_builder.address(&self.address);
|
||||||
|
// Defense in depth against stalled connections: vaultrs leaves the
|
||||||
|
// underlying reqwest client without any timeout by default, so a hung
|
||||||
|
// request would otherwise wait forever regardless of the
|
||||||
|
// operation-level retry policy.
|
||||||
|
settings_builder.timeout(Some(self.attempt_timeout));
|
||||||
|
settings_builder.token(token.expose());
|
||||||
|
|
||||||
|
if let Some(namespace) = &self.namespace {
|
||||||
|
settings_builder.namespace(Some(namespace.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = settings_builder
|
||||||
|
.build()
|
||||||
|
.map_err(|e| KmsError::backend_error(format!("Failed to build Vault client settings: {e}")))?;
|
||||||
|
|
||||||
|
VaultClient::new(settings).map_err(|e| KmsError::backend_error(format!("Failed to create Vault client: {e}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One authenticated client generation.
|
||||||
|
///
|
||||||
|
/// Request paths hold the handle (via `Arc`) for the duration of a single
|
||||||
|
/// Vault call, so a rotation that swaps in a newer generation never tears the
|
||||||
|
/// client out from under an in-flight request.
|
||||||
|
pub(crate) struct VaultClientHandle {
|
||||||
|
/// Monotonic counter identifying the credential generation this client was
|
||||||
|
/// built from. Static tokens never rotate, so only generation 0 exists
|
||||||
|
/// today; rotation (PR-2) bumps it on every re-authentication.
|
||||||
|
pub(crate) generation: u64,
|
||||||
|
pub(crate) client: VaultClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for VaultClientHandle {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
// `VaultClient` embeds its settings, including the token, so it must
|
||||||
|
// never appear in Debug output.
|
||||||
|
f.debug_struct("VaultClientHandle")
|
||||||
|
.field("generation", &self.generation)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the authenticated Vault client for a backend and hands out
|
||||||
|
/// per-request snapshots.
|
||||||
|
///
|
||||||
|
/// The provider keeps neither the settings nor the source after construction
|
||||||
|
/// because a static token can never be refreshed. Rotation (PR-2) will retain
|
||||||
|
/// both and add a refresh path that acquires a fresh lease, rebuilds the
|
||||||
|
/// client, and stores it under a bumped generation.
|
||||||
|
pub(crate) struct VaultCredentialProvider {
|
||||||
|
current: ArcSwap<VaultClientHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VaultCredentialProvider {
|
||||||
|
/// Authenticate with `source` and build the initial client generation.
|
||||||
|
pub(crate) async fn new(settings: VaultConnectionSettings, source: Box<dyn TokenSource>) -> Result<Self> {
|
||||||
|
let lease = source.acquire().await?;
|
||||||
|
let client = settings.build_client(&lease)?;
|
||||||
|
Ok(Self {
|
||||||
|
current: ArcSwap::from_pointee(VaultClientHandle { generation: 0, client }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the current client generation.
|
||||||
|
///
|
||||||
|
/// Take one snapshot per Vault call: the returned `Arc` pins the
|
||||||
|
/// generation for exactly that call, so a concurrent rotation applies to
|
||||||
|
/// the next call without interrupting this one.
|
||||||
|
pub(crate) fn current(&self) -> Arc<VaultClientHandle> {
|
||||||
|
self.current.load_full()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for VaultCredentialProvider {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("VaultCredentialProvider")
|
||||||
|
.field("current", &self.current.load())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::REDACTED_SECRET;
|
||||||
|
|
||||||
|
const TEST_TOKEN: &str = "vault-token-debug-leak-canary";
|
||||||
|
|
||||||
|
fn test_settings() -> VaultConnectionSettings {
|
||||||
|
VaultConnectionSettings {
|
||||||
|
address: "http://127.0.0.1:8200".to_string(),
|
||||||
|
namespace: Some("team-namespace".to_string()),
|
||||||
|
attempt_timeout: Duration::from_secs(30),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Building a provider never contacts Vault, so these tests run offline.
|
||||||
|
async fn test_provider() -> VaultCredentialProvider {
|
||||||
|
VaultCredentialProvider::new(test_settings(), Box::new(StaticToken::new(TEST_TOKEN.to_string())))
|
||||||
|
.await
|
||||||
|
.expect("provider construction must not require a live Vault")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_static_token_snapshots_pin_one_generation() {
|
||||||
|
let provider = test_provider().await;
|
||||||
|
|
||||||
|
let first = provider.current();
|
||||||
|
let second = provider.current();
|
||||||
|
|
||||||
|
assert_eq!(first.generation, 0);
|
||||||
|
assert!(
|
||||||
|
Arc::ptr_eq(&first, &second),
|
||||||
|
"without rotation every snapshot must return the same client generation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_static_token_source_yields_configured_token() {
|
||||||
|
let source = token_source_for(&VaultAuthMethod::Token {
|
||||||
|
token: TEST_TOKEN.to_string(),
|
||||||
|
})
|
||||||
|
.expect("token auth must map to a source");
|
||||||
|
|
||||||
|
let lease = source.acquire().await.expect("static acquire cannot fail");
|
||||||
|
assert_eq!(lease.expose(), TEST_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Behavior pin: AppRole keeps failing at construction with the same
|
||||||
|
/// user-visible message until the login source lands (PR-2).
|
||||||
|
#[test]
|
||||||
|
fn test_approle_auth_method_still_rejected() {
|
||||||
|
let error = token_source_for(&VaultAuthMethod::AppRole {
|
||||||
|
role_id: "role".to_string(),
|
||||||
|
secret_id: "approle-secret-canary".to_string(),
|
||||||
|
})
|
||||||
|
.expect_err("approle must stay rejected until the login source lands");
|
||||||
|
|
||||||
|
let rendered = error.to_string();
|
||||||
|
assert!(rendered.contains("AppRole authentication not yet implemented"), "got: {rendered}");
|
||||||
|
assert!(!rendered.contains("approle-secret-canary"), "error must not echo the secret id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leak regression: the Debug output of every credential-carrying type
|
||||||
|
/// must stay free of the token literal.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_credential_types_debug_redacts_token() {
|
||||||
|
let provider = test_provider().await;
|
||||||
|
let handle = provider.current();
|
||||||
|
let lease = TokenLease::new(TEST_TOKEN.to_string());
|
||||||
|
let source = StaticToken::new(TEST_TOKEN.to_string());
|
||||||
|
|
||||||
|
for rendered in [
|
||||||
|
format!("{provider:?}"),
|
||||||
|
format!("{handle:?}"),
|
||||||
|
format!("{lease:?}"),
|
||||||
|
format!("{source:?}"),
|
||||||
|
] {
|
||||||
|
assert!(!rendered.contains(TEST_TOKEN), "debug output must not leak the vault token: {rendered}");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(format!("{lease:?}").contains(REDACTED_SECRET));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
//! Vault Transit-based KMS backend.
|
//! Vault Transit-based KMS backend.
|
||||||
|
|
||||||
|
use crate::backends::vault_credentials::{VaultClientHandle, VaultConnectionSettings, VaultCredentialProvider, token_source_for};
|
||||||
use crate::backends::{BackendInfo, KmsBackend, KmsClient};
|
use crate::backends::{BackendInfo, KmsBackend, KmsClient};
|
||||||
use crate::config::{KmsConfig, VaultTransitConfig};
|
use crate::config::{KmsConfig, VaultTransitConfig};
|
||||||
use crate::encryption::{DataKeyEnvelope, generate_key_material};
|
use crate::encryption::{DataKeyEnvelope, generate_key_material};
|
||||||
@@ -24,6 +25,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
|||||||
use jiff::Zoned;
|
use jiff::Zoned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use vaultrs::{
|
use vaultrs::{
|
||||||
@@ -33,7 +35,6 @@ use vaultrs::{
|
|||||||
CreateKeyRequestBuilder, DecryptDataRequestBuilder, EncryptDataRequestBuilder, UpdateKeyConfigurationRequestBuilder,
|
CreateKeyRequestBuilder, DecryptDataRequestBuilder, EncryptDataRequestBuilder, UpdateKeyConfigurationRequestBuilder,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
client::{VaultClient, VaultClientSettingsBuilder},
|
|
||||||
kv2,
|
kv2,
|
||||||
transit::{data, key},
|
transit::{data, key},
|
||||||
};
|
};
|
||||||
@@ -128,7 +129,7 @@ impl From<TransitKeyMetadataPersisted> for TransitKeyMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct VaultTransitKmsClient {
|
pub struct VaultTransitKmsClient {
|
||||||
client: VaultClient,
|
credentials: VaultCredentialProvider,
|
||||||
config: VaultTransitConfig,
|
config: VaultTransitConfig,
|
||||||
/// KV v2 mount path for persisting transit key metadata
|
/// KV v2 mount path for persisting transit key metadata
|
||||||
metadata_kv_mount: String,
|
metadata_kv_mount: String,
|
||||||
@@ -142,38 +143,16 @@ impl VaultTransitKmsClient {
|
|||||||
///
|
///
|
||||||
/// `attempt_timeout` caps every HTTP request issued through this client.
|
/// `attempt_timeout` caps every HTTP request issued through this client.
|
||||||
pub async fn new(config: VaultTransitConfig, attempt_timeout: Duration) -> Result<Self> {
|
pub async fn new(config: VaultTransitConfig, attempt_timeout: Duration) -> Result<Self> {
|
||||||
let mut settings_builder = VaultClientSettingsBuilder::default();
|
let source = token_source_for(&config.auth_method)?;
|
||||||
settings_builder.address(&config.address);
|
let settings = VaultConnectionSettings {
|
||||||
// Defense in depth against stalled connections: vaultrs leaves the
|
address: config.address.clone(),
|
||||||
// underlying reqwest client without any timeout by default, so a hung
|
namespace: config.namespace.clone(),
|
||||||
// request would otherwise wait forever regardless of the
|
attempt_timeout,
|
||||||
// operation-level retry policy.
|
|
||||||
settings_builder.timeout(Some(attempt_timeout));
|
|
||||||
|
|
||||||
let token = match &config.auth_method {
|
|
||||||
crate::config::VaultAuthMethod::Token { token } => token.clone(),
|
|
||||||
crate::config::VaultAuthMethod::AppRole { .. } => {
|
|
||||||
return Err(KmsError::backend_error(
|
|
||||||
"AppRole authentication not yet implemented. Please use token authentication.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
let credentials = VaultCredentialProvider::new(settings, source).await?;
|
||||||
settings_builder.token(&token);
|
|
||||||
|
|
||||||
if let Some(namespace) = &config.namespace {
|
|
||||||
settings_builder.namespace(Some(namespace.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = settings_builder
|
|
||||||
.build()
|
|
||||||
.map_err(|e| KmsError::backend_error(format!("Failed to build Vault client settings: {e}")))?;
|
|
||||||
|
|
||||||
let client =
|
|
||||||
VaultClient::new(settings).map_err(|e| KmsError::backend_error(format!("Failed to create Vault client: {e}")))?;
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client,
|
credentials,
|
||||||
metadata_kv_mount: config.metadata_kv_mount.clone(),
|
metadata_kv_mount: config.metadata_kv_mount.clone(),
|
||||||
metadata_key_prefix: config.metadata_key_prefix.clone(),
|
metadata_key_prefix: config.metadata_key_prefix.clone(),
|
||||||
config,
|
config,
|
||||||
@@ -181,6 +160,14 @@ impl VaultTransitKmsClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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>> {
|
fn canonicalize_context(encryption_context: &HashMap<String, String>) -> Result<Option<String>> {
|
||||||
if encryption_context.is_empty() {
|
if encryption_context.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -205,7 +192,7 @@ impl VaultTransitKmsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn read_transit_key(&self, key_id: &str) -> Result<vaultrs::api::transit::responses::ReadKeyResponse> {
|
async fn read_transit_key(&self, key_id: &str) -> Result<vaultrs::api::transit::responses::ReadKeyResponse> {
|
||||||
key::read(&self.client, &self.config.mount_path, key_id)
|
key::read(&self.vault().client, &self.config.mount_path, key_id)
|
||||||
.await
|
.await
|
||||||
.or_else(|e| Self::map_vault_error(key_id, e, "read"))
|
.or_else(|e| Self::map_vault_error(key_id, e, "read"))
|
||||||
}
|
}
|
||||||
@@ -213,7 +200,7 @@ impl VaultTransitKmsClient {
|
|||||||
async fn create_transit_key(&self, key_id: &str) -> Result<()> {
|
async fn create_transit_key(&self, key_id: &str) -> Result<()> {
|
||||||
let mut builder = CreateKeyRequestBuilder::default();
|
let mut builder = CreateKeyRequestBuilder::default();
|
||||||
builder.key_type(KeyType::Aes256Gcm96);
|
builder.key_type(KeyType::Aes256Gcm96);
|
||||||
key::create(&self.client, &self.config.mount_path, key_id, Some(&mut builder))
|
key::create(&self.vault().client, &self.config.mount_path, key_id, Some(&mut builder))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| KmsError::backend_error(format!("Failed to create Vault Transit key {key_id}: {e}")))
|
.map_err(|e| KmsError::backend_error(format!("Failed to create Vault Transit key {key_id}: {e}")))
|
||||||
}
|
}
|
||||||
@@ -230,7 +217,7 @@ impl VaultTransitKmsClient {
|
|||||||
builder.associated_data(aad);
|
builder.associated_data(aad);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = data::encrypt(&self.client, &self.config.mount_path, key_id, &plaintext_b64, Some(&mut builder))
|
let response = data::encrypt(&self.vault().client, &self.config.mount_path, key_id, &plaintext_b64, Some(&mut builder))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| KmsError::backend_error(format!("Failed to encrypt data with Vault Transit key {key_id}: {e}")))?;
|
.map_err(|e| KmsError::backend_error(format!("Failed to encrypt data with Vault Transit key {key_id}: {e}")))?;
|
||||||
|
|
||||||
@@ -248,7 +235,7 @@ impl VaultTransitKmsClient {
|
|||||||
builder.associated_data(aad);
|
builder.associated_data(aad);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = data::decrypt(&self.client, &self.config.mount_path, key_id, ciphertext, Some(&mut builder))
|
let response = data::decrypt(&self.vault().client, &self.config.mount_path, key_id, ciphertext, Some(&mut builder))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| KmsError::backend_error(format!("Failed to decrypt data with Vault Transit key {key_id}: {e}")))?;
|
.map_err(|e| KmsError::backend_error(format!("Failed to decrypt data with Vault Transit key {key_id}: {e}")))?;
|
||||||
|
|
||||||
@@ -263,7 +250,7 @@ impl VaultTransitKmsClient {
|
|||||||
|
|
||||||
async fn read_metadata_from_kv(&self, key_id: &str) -> Result<Option<TransitKeyMetadata>> {
|
async fn read_metadata_from_kv(&self, key_id: &str) -> Result<Option<TransitKeyMetadata>> {
|
||||||
let path = self.metadata_key_path(key_id);
|
let path = self.metadata_key_path(key_id);
|
||||||
match kv2::read::<TransitKeyMetadataPersisted>(&self.client, &self.metadata_kv_mount, &path).await {
|
match kv2::read::<TransitKeyMetadataPersisted>(&self.vault().client, &self.metadata_kv_mount, &path).await {
|
||||||
Ok(persisted) => Ok(Some(persisted.into())),
|
Ok(persisted) => Ok(Some(persisted.into())),
|
||||||
Err(vaultrs::error::ClientError::ResponseWrapError)
|
Err(vaultrs::error::ClientError::ResponseWrapError)
|
||||||
| Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(None),
|
| Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(None),
|
||||||
@@ -274,7 +261,7 @@ impl VaultTransitKmsClient {
|
|||||||
async fn write_metadata_to_kv(&self, key_id: &str, metadata: &TransitKeyMetadata) -> Result<()> {
|
async fn write_metadata_to_kv(&self, key_id: &str, metadata: &TransitKeyMetadata) -> Result<()> {
|
||||||
let path = self.metadata_key_path(key_id);
|
let path = self.metadata_key_path(key_id);
|
||||||
let persisted: TransitKeyMetadataPersisted = metadata.clone().into();
|
let persisted: TransitKeyMetadataPersisted = metadata.clone().into();
|
||||||
kv2::set(&self.client, &self.metadata_kv_mount, &path, &persisted)
|
kv2::set(&self.vault().client, &self.metadata_kv_mount, &path, &persisted)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|e| KmsError::backend_error(format!("Failed to write transit key metadata to Vault KV: {e}")))
|
.map_err(|e| KmsError::backend_error(format!("Failed to write transit key metadata to Vault KV: {e}")))
|
||||||
@@ -282,7 +269,7 @@ impl VaultTransitKmsClient {
|
|||||||
|
|
||||||
async fn delete_metadata_from_kv(&self, key_id: &str) -> Result<()> {
|
async fn delete_metadata_from_kv(&self, key_id: &str) -> Result<()> {
|
||||||
let path = self.metadata_key_path(key_id);
|
let path = self.metadata_key_path(key_id);
|
||||||
match kv2::delete_metadata(&self.client, &self.metadata_kv_mount, &path).await {
|
match kv2::delete_metadata(&self.vault().client, &self.metadata_kv_mount, &path).await {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(vaultrs::error::ClientError::ResponseWrapError)
|
Err(vaultrs::error::ClientError::ResponseWrapError)
|
||||||
| Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(()),
|
| Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(()),
|
||||||
@@ -496,7 +483,7 @@ impl KmsClient for VaultTransitKmsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn list_keys(&self, request: &ListKeysRequest, _context: Option<&OperationContext>) -> Result<ListKeysResponse> {
|
async fn list_keys(&self, request: &ListKeysRequest, _context: Option<&OperationContext>) -> Result<ListKeysResponse> {
|
||||||
let all_keys = key::list(&self.client, &self.config.mount_path)
|
let all_keys = key::list(&self.vault().client, &self.config.mount_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| KmsError::backend_error(format!("Failed to list Vault Transit keys: {e}")))?
|
.map_err(|e| KmsError::backend_error(format!("Failed to list Vault Transit keys: {e}")))?
|
||||||
.keys;
|
.keys;
|
||||||
@@ -566,7 +553,7 @@ impl KmsClient for VaultTransitKmsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn rotate_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<MasterKeyInfo> {
|
async fn rotate_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<MasterKeyInfo> {
|
||||||
key::rotate(&self.client, &self.config.mount_path, key_id)
|
key::rotate(&self.vault().client, &self.config.mount_path, key_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| KmsError::backend_error(format!("Failed to rotate Vault Transit key {key_id}: {e}")))?;
|
.map_err(|e| KmsError::backend_error(format!("Failed to rotate Vault Transit key {key_id}: {e}")))?;
|
||||||
|
|
||||||
@@ -589,7 +576,7 @@ impl KmsClient for VaultTransitKmsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn health_check(&self) -> Result<()> {
|
async fn health_check(&self) -> Result<()> {
|
||||||
key::list(&self.client, &self.config.mount_path)
|
key::list(&self.vault().client, &self.config.mount_path)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|e| KmsError::backend_error(format!("Vault Transit health check failed: {e}")))
|
.map_err(|e| KmsError::backend_error(format!("Vault Transit health check failed: {e}")))
|
||||||
@@ -710,13 +697,18 @@ impl KmsBackend for VaultTransitKmsBackend {
|
|||||||
if !self.client.read_transit_key(&key_id).await?.deletion_allowed {
|
if !self.client.read_transit_key(&key_id).await?.deletion_allowed {
|
||||||
let mut update_builder = UpdateKeyConfigurationRequestBuilder::default();
|
let mut update_builder = UpdateKeyConfigurationRequestBuilder::default();
|
||||||
update_builder.deletion_allowed(true);
|
update_builder.deletion_allowed(true);
|
||||||
key::update(&self.client.client, &self.client.config.mount_path, &key_id, Some(&mut update_builder))
|
key::update(
|
||||||
.await
|
&self.client.vault().client,
|
||||||
.map_err(|e| {
|
&self.client.config.mount_path,
|
||||||
KmsError::backend_error(format!("Failed to allow deletion of Vault Transit key {key_id}: {e}"))
|
&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.client, &self.client.config.mount_path, &key_id)
|
key::delete(&self.client.vault().client, &self.client.config.mount_path, &key_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| KmsError::backend_error(format!("Failed to delete Vault Transit key {key_id}: {e}")))?;
|
.map_err(|e| KmsError::backend_error(format!("Failed to delete Vault Transit key {key_id}: {e}")))?;
|
||||||
self.client.delete_key_metadata(&key_id).await?;
|
self.client.delete_key_metadata(&key_id).await?;
|
||||||
|
|||||||
Reference in New Issue
Block a user