Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 192 additions & 19 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ rust-version = "1.78"
[features]
default = ["caching"]
release = ["kms-aws", "middleware", "key_custodian", "limit", "kms-hashicorp-vault", "caching", "external_key_manager_mtls"]
dev = ["kms-aws", "middleware", "key_custodian", "limit", "kms-hashicorp-vault", "caching"]
dev = ["kms-aws", "middleware", "key_custodian", "limit", "kms-hashicorp-vault", "kms-gcp", "caching"]
kms-aws = ["dep:aws-config", "dep:aws-sdk-kms"]
kms-gcp = ["dep:google-cloud-kms", "dep:rustls"]
kms-hashicorp-vault = ["dep:vaultrs"]
limit = []
middleware = []
Expand All @@ -32,6 +33,8 @@ gethostname = "0.5.0"
rustc-hash = "2.0"
once_cell = "1.19.0"
vaultrs = { version = "0.7.2", optional = true }
google-cloud-kms = { version = "0.6.0", optional = true }
rustls = { version = "0.23", features = ["aws_lc_rs"], optional = true }

# Tokio Dependencies
tokio = { version = "1.39.3", features = ["macros", "rt-multi-thread"] }
Expand Down
7 changes: 6 additions & 1 deletion src/bin/locker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ use tartarus::{logger, tenant::GlobalAppState};
#[allow(clippy::expect_used)]
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize rustls crypto provider for GCP KMS
#[cfg(feature = "kms-gcp")]
{
use rustls::crypto::{aws_lc_rs, CryptoProvider};
let _ = CryptoProvider::install_default(aws_lc_rs::default_provider());
}

if cfg!(feature = "dev") {
eprintln!("This is a dev build, not for production use");
Expand All @@ -28,7 +34,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

let global_app_state = GlobalAppState::new(global_config).await;


tartarus::app::server_builder(global_app_state)
.await
.expect("Failed while building the server");
Expand Down
72 changes: 72 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,4 +424,76 @@ mod tests {
_ => assert!(false),
}
}

#[cfg(feature = "kms-gcp")]
#[test]
fn test_gcp_kms_case() {
let data = r#"
[secrets_management]
secrets_manager = "gcp_kms"

[secrets_management.gcp_kms]
project_id = "test-project"
location = "us-central1"
key_ring = "test-keyring"
key_name = "test-key"
"#;
let parsed: TestDeser = serde_path_to_error::deserialize(
config::Config::builder()
.add_source(config::File::from_str(data, config::FileFormat::Toml))
.build()
.unwrap(),
)
.unwrap();

match parsed.secrets_management {
SecretsManagementConfig::GcpKms { gcp_kms } => {
assert!(
gcp_kms.project_id == "test-project"
&& gcp_kms.location == "us-central1"
&& gcp_kms.key_ring == "test-keyring"
&& gcp_kms.key_name == "test-key"
&& gcp_kms.service_account_key_path.is_none()
)
}
_ => assert!(false),
}
}

#[cfg(feature = "kms-gcp")]
#[test]
fn test_gcp_kms_case_with_service_account() {
let data = r#"
[secrets_management]
secrets_manager = "gcp_kms"

[secrets_management.gcp_kms]
project_id = "test-project"
location = "europe-west4"
key_ring = "test-keyring"
key_name = "test-key"
service_account_key_path = "/path/to/service-account.json"
"#;
let parsed: TestDeser = serde_path_to_error::deserialize(
config::Config::builder()
.add_source(config::File::from_str(data, config::FileFormat::Toml))
.build()
.unwrap(),
)
.unwrap();

match parsed.secrets_management {
SecretsManagementConfig::GcpKms { gcp_kms } => {
assert!(
gcp_kms.project_id == "test-project"
&& gcp_kms.location == "europe-west4"
&& gcp_kms.key_ring == "test-keyring"
&& gcp_kms.key_name == "test-key"
&& gcp_kms.service_account_key_path
== Some("/path/to/service-account.json".to_string())
)
}
_ => assert!(false),
}
}
}
6 changes: 5 additions & 1 deletion src/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ pub mod keymanager;
pub mod secrets_manager;

pub mod consts {
#[cfg(any(feature = "external_key_manager", feature = "kms-aws"))]
#[cfg(any(
feature = "external_key_manager",
feature = "kms-aws",
feature = "kms-gcp"
))]
/// General purpose base64 engine
pub(crate) const BASE64_ENGINE: base64::engine::GeneralPurpose =
base64::engine::general_purpose::STANDARD;
Expand Down
2 changes: 2 additions & 0 deletions src/crypto/secrets_manager/managers.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#[cfg(feature = "kms-aws")]
pub mod aws_kms;
#[cfg(feature = "kms-gcp")]
pub mod gcp_kms;
#[cfg(feature = "kms-hashicorp-vault")]
pub mod hcvault;
pub mod hollow;
6 changes: 6 additions & 0 deletions src/crypto/secrets_manager/managers/gcp_kms.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//! Google Cloud Platform Key Management Service integration for secrets management

pub mod core;
pub mod implementers;

pub use self::core::GcpKmsClient;
184 changes: 184 additions & 0 deletions src/crypto/secrets_manager/managers/gcp_kms/core.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//! Core GCP KMS client implementation

use base64::Engine;
use error_stack::ResultExt;
use google_cloud_kms::client::{Client, ClientConfig};

use crate::{crypto::consts::BASE64_ENGINE, error::ConfigurationError, logger};

/// Configuration parameters required for constructing a [`GcpKmsClient`].
#[derive(Clone, Debug, Default, serde::Deserialize, Eq, PartialEq)]
#[serde(default)]
pub struct GcpKmsConfig {
/// The GCP project ID where the KMS key is located.
pub project_id: String,

/// The GCP region/location where the key ring is located.
pub location: String,

/// The key ring name containing the KMS key.
pub key_ring: String,

/// The KMS key name used to encrypt or decrypt data.
pub key_name: String,

/// Optional service account key file path for authentication.
/// If not provided, will use default GCP authentication (ADC).
pub service_account_key_path: Option<String>,
}

/// Client for GCP KMS operations.
#[derive(Debug, Clone)]
pub struct GcpKmsClient {
config: GcpKmsConfig,
key_resource_name: String,
}

impl GcpKmsClient {
/// Constructs a new GCP KMS client.
pub async fn new(config: &GcpKmsConfig) -> Self {
let key_resource_name = format!(
"projects/{}/locations/{}/keyRings/{}/cryptoKeys/{}",
config.project_id, config.location, config.key_ring, config.key_name
);

Self {
config: config.clone(),
key_resource_name,
}
}

/// Decrypts the provided base64-encoded encrypted data using the GCP KMS SDK.
pub async fn decrypt(
&self,
data: impl AsRef<[u8]>,
) -> error_stack::Result<String, GcpKmsError> {
let ciphertext = BASE64_ENGINE
.decode(data)
.change_context(GcpKmsError::Base64DecodingFailed)?;

// Create KMS client
let client = self.get_kms_client().await?;

// Create decrypt request using the correct path
let request = google_cloud_kms::grpc::kms::v1::DecryptRequest {
name: self.key_resource_name.clone(),
ciphertext,
additional_authenticated_data: vec![],
ciphertext_crc32c: None,
additional_authenticated_data_crc32c: None,
};

// Perform decryption
let response = client
.decrypt(request, None)
.await
.map_err(|error| {
logger::error!(gcp_kms_decrypt_error=?error, "Failed to decrypt with GCP KMS");
error
})
.change_context(GcpKmsError::DecryptionFailed)?;

let plaintext = String::from_utf8(response.plaintext)
.change_context(GcpKmsError::Utf8DecodingFailed)?;

Ok(plaintext)
}

/// Creates a GCP KMS client with proper authentication
async fn get_kms_client(&self) -> error_stack::Result<Client, GcpKmsError> {
let config = if let Some(_key_path) = &self.config.service_account_key_path {
// For service account key file authentication, we would need to implement
// custom credential loading. For now, fall back to default auth.
ClientConfig::default()
.with_auth()
.await
.change_context(GcpKmsError::AuthenticationFailed)?
} else {
// Use Application Default Credentials (ADC)
ClientConfig::default()
.with_auth()
.await
.change_context(GcpKmsError::AuthenticationFailed)?
};

Client::new(config)
.await
.change_context(GcpKmsError::ClientCreationFailed)
}
}

/// Errors that could occur during GCP KMS operations.
#[derive(Debug, thiserror::Error)]
pub enum GcpKmsError {
/// An error occurred when base64 encoding input data.
#[error("Failed to base64 encode input data")]
Base64EncodingFailed,

/// An error occurred when base64 decoding input data.
#[error("Failed to base64 decode input data")]
Base64DecodingFailed,

/// An error occurred when GCP KMS decrypting input data.
#[error("Failed to GCP KMS decrypt input data")]
DecryptionFailed,

/// An error occurred when GCP KMS encrypting input data.
#[error("Failed to GCP KMS encrypt input data")]
EncryptionFailed,

/// The GCP KMS decrypted output does not include a plaintext output.
#[error("Missing plaintext GCP KMS decryption output")]
MissingPlaintextDecryptionOutput,

/// The GCP KMS encrypted output does not include a ciphertext output.
#[error("Missing ciphertext GCP KMS encryption output")]
MissingCiphertextEncryptionOutput,

/// An error occurred UTF-8 decoding GCP KMS decrypted output.
#[error("Failed to UTF-8 decode decryption output")]
Utf8DecodingFailed,

/// The GCP KMS client has not been initialized.
#[error("The GCP KMS client has not been initialized")]
GcpKmsClientNotInitialized,

/// Authentication with GCP failed.
#[error("Failed to authenticate with GCP")]
AuthenticationFailed,

/// Failed to create HTTP client.
#[error("Failed to create HTTP client")]
ClientCreationFailed,
}

impl GcpKmsConfig {
/// Verifies that the [`GcpKmsClient`] configuration is usable.
pub fn validate(&self) -> Result<(), ConfigurationError> {
if self.project_id.trim().is_empty() {
return Err(ConfigurationError::InvalidConfigurationValueError(
"GCP KMS project ID must not be empty".into(),
));
}

if self.location.trim().is_empty() {
return Err(ConfigurationError::InvalidConfigurationValueError(
"GCP KMS location must not be empty".into(),
));
}

if self.key_ring.trim().is_empty() {
return Err(ConfigurationError::InvalidConfigurationValueError(
"GCP KMS key ring must not be empty".into(),
));
}

if self.key_name.trim().is_empty() {
return Err(ConfigurationError::InvalidConfigurationValueError(
"GCP KMS key name must not be empty".into(),
));
}

Ok(())
}
}
20 changes: 20 additions & 0 deletions src/crypto/secrets_manager/managers/gcp_kms/implementers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
use error_stack::ResultExt;
use masking::{PeekInterface, Secret};

use crate::crypto::secrets_manager::{
managers::gcp_kms::core::GcpKmsClient,
secrets_interface::{SecretManager, SecretsManagementError},
};

#[async_trait::async_trait]
impl SecretManager for GcpKmsClient {
async fn get_secret(
&self,
input: Secret<String>,
) -> error_stack::Result<Secret<String>, SecretsManagementError> {
self.decrypt(input.peek())
.await
.change_context(SecretsManagementError::FetchSecretFailed)
.map(Into::into)
}
}
Loading