diff --git a/.cargo/README.md b/.cargo/README.md new file mode 100644 index 0000000000..a905f4fc6a --- /dev/null +++ b/.cargo/README.md @@ -0,0 +1,33 @@ +### Optional: Faster compilation + +You can add the following to your `.cargo/config.toml` to potentially speed up compilation on your local machine. + +> **Note:** These flags are intentionally not set by default. The GitHub runners used to build the KMS Docker image have unpredictable CPU architectures, which causes error code 132 when running containers built with `target_cpu=native`. These flags are however passed explicitly in CI for macOS, Windows, Linux, and CentOS 7 builds via the `RUSTFLAGS` environment variable in `cargo_build.yml`. + +```toml +[build] +# Speeds up Ristretto 25519 multiplication x 2 +rustflags = [ + "--cfg", + "curve25519_dalek_backend=\"simd\"", + "-C", + "target_cpu=native", +] + +# Can increase link speed on systems that support mold +[target.x86_64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=mold"] +``` + +### Optional: VS Code / rust-analyzer + +Add the following to your `.vscode/settings.json` to prevent rust-analyzer from interfering with regular `cargo` builds, which also reduces overall compilation time: + +```json +{ + "rust-analyzer.cargo.extraEnv": { + "CARGO_TARGET_DIR": "target/rust-analyzer" + } +} +``` diff --git a/.cargo/config.toml b/.cargo/config.toml index acf8be4d08..2389877c31 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -22,16 +22,3 @@ deps = "udeps --workspace --all-targets --all-features --backend depinfo" # Publish dry-run helpers (allow dirty tree during local checks) publish-dry = "publish --dry-run --workspace --allow-dirty" publish-dry-crate = "publish --dry-run --allow-dirty" - -### -# This options have been commented out because the Github runners responsible to build the KMS docker image have unpredictable CPU architectures, resulting in an error code 132 when running the generated container. -# However, those flags are specifically given by CI in other builds (macos, windows, linux, centos7) in cargo_build.yml via the RUSTFLAGS environment variable. -### -# [build] -# # Speeds up Ristretto 25519 multiplication x 2 -# rustflags = [ -# "--cfg", -# "curve25519_dalek_backend=\"simd\"", -# "-C", -# "target_cpu=native", -# ] diff --git a/crate/server/src/config/command_line/azure_ekm_config.rs b/crate/server/src/config/command_line/azure_ekm_config.rs new file mode 100644 index 0000000000..2ca3ef38a7 --- /dev/null +++ b/crate/server/src/config/command_line/azure_ekm_config.rs @@ -0,0 +1,61 @@ +use clap::Args; +use serde::{Deserialize, Serialize}; + +#[allow(clippy::trivially_copy_pass_by_ref)] // this is required by serde +fn is_false(b: &bool) -> bool { + !b +} +#[derive(Debug, Args, Deserialize, Serialize, Clone)] +#[serde(default)] +#[derive(Default)] +pub struct AzureEkmConfig { + /// This setting turns on/off the endpoints handling Azure EKM features + #[clap(long, env = "KMS_AZURE_EKM_ENABLE", default_value = "false")] + pub azure_ekm_enable: bool, + + /// Optional path prefix set within Managed HSM during EKM configuration. + /// + /// Enables multi-customer use or isolation of different MHSM pools using the same proxy. + /// Must be max 64 characters: letters (a-z, A-Z), numbers (0-9), slashes (/), dashes (-). + #[clap(long, env = "KMS_AZURE_EKM_PATH_PREFIX", verbatim_doc_comment)] + #[serde(skip_serializing_if = "Option::is_none")] + pub azure_ekm_path_prefix: Option, + + /// WARNING: This bypasses mTLS authentication entirely. Only use for testing! + #[clap( + long, + env = "KMS_AZURE_EKM_DISABLE_CLIENT_AUTH", + default_value = "false" + )] + // serde does not support skipping booleans out of the box so a custom function is used + #[serde(skip_serializing_if = "is_false")] + pub azure_ekm_disable_client_auth: bool, + + /// Proxy vendor name to report in /info endpoint. + #[clap(long, env = "KMS_AZURE_EKM_PROXY_VENDOR", default_value = "Cosmian")] + #[serde(skip_serializing_if = "String::is_empty")] + pub azure_ekm_proxy_vendor: String, + + /// Proxy name to report in /info endpoint. + #[clap( + long, + env = "KMS_AZURE_EKM_PROXY_NAME", + default_value_t = format!("EKM Proxy Service v{}", env!("CARGO_PKG_VERSION")) + )] + #[serde(skip_serializing_if = "String::is_empty")] + pub azure_ekm_proxy_name: String, + + /// EKMS vendor name report in the /info endpoint. + #[clap(long, env = "KMS_AZURE_EKM_VENDOR", default_value = "Cosmian")] + #[serde(skip_serializing_if = "String::is_empty")] + pub azure_ekm_ekm_vendor: String, // double "ekm" is intentional + + /// Product Name and Version of the EKMS to report in the /info endpoint. + #[clap( + long, + env = "KMS_AZURE_EKM_PRODUCT", + default_value_t = format!("Cosmian KMS v{}", env!("CARGO_PKG_VERSION")) + )] + #[serde(skip_serializing_if = "String::is_empty")] + pub azure_ekm_ekm_product: String, // again, double "ekm" is intentional +} diff --git a/crate/server/src/config/command_line/clap_config.rs b/crate/server/src/config/command_line/clap_config.rs index 5da8a37052..a3ad023594 100644 --- a/crate/server/src/config/command_line/clap_config.rs +++ b/crate/server/src/config/command_line/clap_config.rs @@ -11,7 +11,7 @@ use super::{ WorkspaceConfig, logging::LoggingConfig, ui_config::UiConfig, }; use crate::{ - config::{ProxyConfig, SocketServerConfig, TlsConfig}, + config::{AzureEkmConfig, ProxyConfig, SocketServerConfig, TlsConfig}, error::KmsError, result::KResult, }; @@ -60,6 +60,7 @@ impl Default for ClapConfig { non_revocable_key_id: None, privileged_users: None, kmip_policy: KmipPolicyConfig::default(), + azure_ekm_config: AzureEkmConfig::default(), } } } @@ -149,6 +150,9 @@ pub struct ClapConfig { #[clap(flatten)] pub google_cse_config: GoogleCseConfig, + #[clap(flatten)] + pub azure_ekm_config: AzureEkmConfig, + #[clap(flatten)] pub workspace: WorkspaceConfig, @@ -333,6 +337,35 @@ impl fmt::Debug for ClapConfig { &self.google_cse_config.google_cse_enable, ) }; + let x = if self.azure_ekm_config.azure_ekm_enable { + x.field("azure_ekm_enable", &self.azure_ekm_config.azure_ekm_enable) + .field( + "azure_ekm_path_prefix", + &self.azure_ekm_config.azure_ekm_path_prefix, + ) + .field( + "azure_ekm_disable_client_auth", + &self.azure_ekm_config.azure_ekm_disable_client_auth, + ) + .field( + "azure_ekm_proxy_vendor", + &self.azure_ekm_config.azure_ekm_proxy_vendor, + ) + .field( + "azure_ekm_proxy_name", + &self.azure_ekm_config.azure_ekm_proxy_name, + ) + .field( + "azure_ekm_ekm_vendor", + &self.azure_ekm_config.azure_ekm_ekm_vendor, + ) + .field( + "azure_ekm_ekm_product", + &self.azure_ekm_config.azure_ekm_ekm_product, + ) + } else { + x.field("azure_ekm_enable", &self.azure_ekm_config.azure_ekm_enable) + }; let x = x.field( "Microsoft Double Key Encryption URL", &self.ms_dke_service_url, diff --git a/crate/server/src/config/command_line/mod.rs b/crate/server/src/config/command_line/mod.rs index e95dee4085..89331b2a11 100644 --- a/crate/server/src/config/command_line/mod.rs +++ b/crate/server/src/config/command_line/mod.rs @@ -1,3 +1,4 @@ +mod azure_ekm_config; mod clap_config; mod db; mod google_cse_config; @@ -12,6 +13,7 @@ mod tls_config; mod ui_config; mod workspace; +pub use azure_ekm_config::AzureEkmConfig; pub use clap_config::ClapConfig; pub use db::{DEFAULT_SQLITE_PATH, MainDBConfig}; pub use google_cse_config::GoogleCseConfig; diff --git a/crate/server/src/config/params/server_params.rs b/crate/server/src/config/params/server_params.rs index 06af533b95..3e2dfe81c1 100644 --- a/crate/server/src/config/params/server_params.rs +++ b/crate/server/src/config/params/server_params.rs @@ -8,7 +8,7 @@ use cosmian_logger::{debug, warn}; use super::{KmipPolicyParams, TlsParams}; use crate::{ config::{ - ClapConfig, GoogleCseConfig, IdpConfig, OidcConfig, + AzureEkmConfig, ClapConfig, GoogleCseConfig, IdpConfig, OidcConfig, params::{ OpenTelemetryConfig, kmip_policy_params::KmipAllowlistsParams, proxy_params::ProxyParams, @@ -127,6 +127,8 @@ pub struct ServerParams { /// KMIP algorithm policy. pub kmip_policy: KmipPolicyParams, + + pub azure_ekm: AzureEkmConfig, } /// Represents the server parameters. @@ -336,6 +338,7 @@ impl ServerParams { aes_key_sizes: kmip_allowlists.aes_key_sizes, }, }, + azure_ekm: conf.azure_ekm_config, }; debug!("{res:#?}"); @@ -444,6 +447,32 @@ impl fmt::Debug for ServerParams { debug_struct.field("google_cse_enable", &self.google_cse.google_cse_enable); } + // Azure EKM configuration + if self.azure_ekm.azure_ekm_enable { + debug_struct + .field("azure_ekm_enable", &self.azure_ekm.azure_ekm_enable) + .field( + "azure_ekm_path_prefix", + &self.azure_ekm.azure_ekm_path_prefix, + ) + .field( + "azure_ekm_disable_client_auth", + &self.azure_ekm.azure_ekm_disable_client_auth, + ) + .field( + "azure_ekm_proxy_vendor", + &self.azure_ekm.azure_ekm_proxy_vendor, + ) + .field("azure_ekm_proxy_name", &self.azure_ekm.azure_ekm_proxy_name) + .field("azure_ekm_ekm_vendor", &self.azure_ekm.azure_ekm_ekm_vendor) + .field( + "azure_ekm_ekm_product", + &self.azure_ekm.azure_ekm_ekm_product, + ); + } else { + debug_struct.field("azure_ekm_enable", &self.azure_ekm.azure_ekm_enable); + } + if self.hsm_model.is_some() { debug_struct .field("hsm_admin", &self.hsm_admin) diff --git a/crate/server/src/core/operations/decrypt.rs b/crate/server/src/core/operations/decrypt.rs index 5a445493cc..eda32b28f9 100644 --- a/crate/server/src/core/operations/decrypt.rs +++ b/crate/server/src/core/operations/decrypt.rs @@ -387,7 +387,7 @@ fn decrypt_single( server_params: &crate::config::ServerParams, request: &Decrypt, ) -> KResult { - trace!("entering"); + trace!("Extracting key block for decryption to identify key format type..."); let key_block = owm.object().key_block()?; match &key_block.key_format_type { #[cfg(feature = "non-fips")] @@ -451,6 +451,11 @@ fn decrypt_single_with_symmetric_key( ) })?; let (key_bytes, aead) = get_aead_and_key(owm, request)?; + trace!( + "got key bytes of length: {}, aead: {:?}. Proceeding to get the nonce...", + key_bytes.len(), + aead + ); // For modes with nonce_size()==0 (e.g. ECB) we do not expect / require an IV. // For modes with nonce_size()>0 we require an IV. Some KMIP vectors supply an empty // IVCounterNonce element to indicate an all-zero IV (e.g. CBC test cases). Treat a diff --git a/crate/server/src/core/operations/encrypt.rs b/crate/server/src/core/operations/encrypt.rs index 308808ca98..b22d879262 100644 --- a/crate/server/src/core/operations/encrypt.rs +++ b/crate/server/src/core/operations/encrypt.rs @@ -593,7 +593,7 @@ fn get_key_and_cipher( request: &Encrypt, owm: &ObjectWithMetadata, ) -> KResult<(Zeroizing>, SymCipher)> { - trace!("entering"); + trace!("Entering get_key_and_cipher"); // Make sure that the key used to encrypt can be used to encrypt. if !owm .object() diff --git a/crate/server/src/main.rs b/crate/server/src/main.rs index 8caddd145e..a57aa177c5 100644 --- a/crate/server/src/main.rs +++ b/crate/server/src/main.rs @@ -144,13 +144,12 @@ async fn run() -> KResult<()> { #[cfg(test)] #[allow(clippy::unwrap_used, clippy::unwrap_in_result)] mod tests { - use std::path::PathBuf; - use cosmian_kms_server::config::{ - ClapConfig, GoogleCseConfig, HttpConfig, IdpAuthConfig, KmipPolicyConfig, LoggingConfig, - MainDBConfig, OidcConfig, ProxyConfig, SocketServerConfig, TlsConfig, UiConfig, - WorkspaceConfig, + AzureEkmConfig, ClapConfig, GoogleCseConfig, HttpConfig, IdpAuthConfig, KmipPolicyConfig, + LoggingConfig, MainDBConfig, OidcConfig, ProxyConfig, SocketServerConfig, TlsConfig, + UiConfig, WorkspaceConfig, }; + use std::path::PathBuf; #[cfg(feature = "non-fips")] #[test] @@ -216,6 +215,15 @@ mod tests { ]), google_cse_migration_key: None, }, + azure_ekm_config: AzureEkmConfig { + azure_ekm_enable: false, + azure_ekm_path_prefix: None, + azure_ekm_disable_client_auth: false, + azure_ekm_proxy_vendor: String::new(), + azure_ekm_proxy_name: String::new(), + azure_ekm_ekm_vendor: String::new(), + azure_ekm_ekm_product: String::new(), + }, kms_public_url: Some("[kms_public_url]".to_owned()), workspace: WorkspaceConfig { root_data_path: PathBuf::from("[root data path]"), @@ -308,6 +316,9 @@ google_cse_enable = false google_cse_disable_tokens_validation = false google_cse_incoming_url_whitelist = ["[kacls_url_1]", "[kacls_url_2]"] +[azure_ekm_config] +azure_ekm_enable = false + [workspace] root_data_path = "[root data path]" tmp_path = "[tmp path]" @@ -322,6 +333,8 @@ rolling_log_name = "kms_log" enable_metering = false environment = "development" ansi_colors = false + +[kmip.allowlists] "#; assert_eq!(toml_string.trim(), toml::to_string(&config).unwrap().trim()); diff --git a/crate/server/src/routes/azure_ekm/contributing.md b/crate/server/src/routes/azure_ekm/contributing.md new file mode 100644 index 0000000000..6670995638 --- /dev/null +++ b/crate/server/src/routes/azure_ekm/contributing.md @@ -0,0 +1,8 @@ +## About future versions + +- Add future-ly supported version numbers in `SUPPORTED_API_VERSIONS` in `crate/server/src/routes/azure_ekm/mod.rs` +- Take into account that each version *might* support error status codes that were not previously supported, refer to `error.rs`. + +## Development guidelines + +- Separate handlers to `handlers.rs` to ease out testing the API. diff --git a/crate/server/src/routes/azure_ekm/error.rs b/crate/server/src/routes/azure_ekm/error.rs new file mode 100644 index 0000000000..7e20ae6a4e --- /dev/null +++ b/crate/server/src/routes/azure_ekm/error.rs @@ -0,0 +1,151 @@ +use crate::error::KmsError; +use crate::routes::azure_ekm::SUPPORTED_API_VERSIONS; +use actix_web::HttpResponse; +use actix_web::ResponseError; +use cosmian_logger::debug; +use serde::Serialize; +use std::fmt; + +// If an error response is returned (with a non-200 HTTP status code), the proxy is required to +// include the following JSON body in its response. +#[derive(Serialize, Debug)] +pub(crate) struct AzureEkmErrorReply { + // for some reason, the spec wants the code to be a string, refer to page 9... + // due to likeliness of typos, proper constructors will be provided below + // please keep the `code` attribute private. + code: String, + message: String, +} + +impl fmt::Display for AzureEkmErrorReply { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[{}] {}", self.code, self.message) + } +} + +impl From for HttpResponse { + fn from(e: AzureEkmErrorReply) -> Self { + debug!("EKM Error: {:?}", e); + // as of version 0.1-preview, the spec only returns these exact error codes + match e.code.as_str() { + // 400 series errors + "InvalidRequest" | "UnsupportedApiVersion" | "UnsupportedAlgorithm" => { + Self::BadRequest().json(e) + } + "Unauthorized" => Self::Unauthorized().json(e), // 401 + "Forbidden" | "KeyDisabled" | "OperationNotAllowed" => Self::Forbidden().json(e), // 403 + "KeyNotFound" => Self::NotFound().json(e), // 404 + "TooManyRequests" => Self::TooManyRequests().json(e), // 429 + _ => Self::InternalServerError().json(e), // 5xx errors + } + } +} + +impl From for AzureEkmErrorReply { + fn from(e: KmsError) -> Self { + let status_code = e.status_code().as_u16(); + + // Mapping non-internal errors status numeric code to an error code string + let code = match status_code { + 400 => "InvalidRequest", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "KeyNotFound", + 429 => "TooManyRequests", + _ => "InternalError", + }; + + Self { + code: code.to_owned(), + message: "An Azure EKM request to the Cosmian KMS failed".to_owned(), + } + } +} + +// constructors for known error replies +impl AzureEkmErrorReply { + /// API version not supported + pub(crate) fn unsupported_api_version(version: &str) -> Self { + Self { + code: "UnsupportedApiVersion".to_owned(), + message: format!( + "API version '{version}' not supported. Supported: {SUPPORTED_API_VERSIONS:?}" + ), + } + } + + /// Invalid request - malformed JSON, missing fields, invalid parameters + pub(crate) fn invalid_request(message: impl Into) -> Self { + Self { + code: "InvalidRequest".to_owned(), + message: message.into(), + } + } + + /// Algorithm not supported for this key type + pub(crate) fn unsupported_algorithm(algorithm: &str, key_type: &str) -> Self { + Self { + code: "UnsupportedAlgorithm".to_owned(), + message: format!("Algorithm '{algorithm}' is not supported for key type '{key_type}'"), + } + } + + /// Key not found in the External Key Management System + pub(crate) fn key_not_found(key_name: &str) -> Self { + Self { + code: "KeyNotFound".to_owned(), + message: format!("Key '{key_name}' not found",), + } + } + + /// Authentication failed (invalid mTLS certificate, etc.) + pub(crate) fn unauthorized(message: impl Into) -> Self { + Self { + code: "Unauthorized".to_owned(), + message: message.into(), + } + } + + /// Access denied (authenticated but not authorized) + #[allow(dead_code)] // specified so it should figure in the code, might be used later + pub(crate) fn forbidden(message: impl Into) -> Self { + Self { + code: "Forbidden".to_owned(), + message: message.into(), + } + } + + /// Key is disabled + #[allow(dead_code)] // specified so it should figure in the code, might be used later + pub(crate) fn key_disabled(key_name: &str) -> Self { + Self { + code: "KeyDisabled".to_owned(), + message: format!("Key '{key_name}' is disabled"), + } + } + + /// Operation not allowed on this key + pub(crate) fn operation_not_allowed(operation: &str, key_name: &str) -> Self { + Self { + code: "OperationNotAllowed".to_owned(), + message: format!("Operation '{operation}' is not allowed on key '{key_name}'"), + } + } + + /// Rate limit exceeded + #[allow(dead_code)] + pub(crate) fn too_many_requests() -> Self { + Self { + code: "TooManyRequests".to_owned(), + message: "Too many requests. Please retry later.".to_owned(), + } + } + + /// Internal server error + pub(crate) fn internal_error(message: impl Into) -> Self { + Self { + code: "InternalError".to_owned(), + message: message.into(), + } + } +} diff --git a/crate/server/src/routes/azure_ekm/handlers.rs b/crate/server/src/routes/azure_ekm/handlers.rs new file mode 100644 index 0000000000..51507e5b0d --- /dev/null +++ b/crate/server/src/routes/azure_ekm/handlers.rs @@ -0,0 +1,468 @@ +use crate::{ + core::KMS, + error::KmsError, + result::KResult, + routes::{ + azure_ekm::{ + SUPPORTED_RSA_LENGTHS, + error::AzureEkmErrorReply, + models::{ + KeyMetadataResponse, UnwrapKeyRequest, UnwrapKeyResponse, WrapAlgorithm, + WrapKeyRequest, WrapKeyResponse, + }, + }, + utils::get_rsa_key_metadata_from_public_key, + }, +}; +use actix_web::{HttpResponse, web::Data}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::{BlockCipherMode, HashingAlgorithm, PaddingMethod}, + kmip_2_1::{ + kmip_data_structures::KeyMaterial, + kmip_objects::Object, + kmip_operations::{Decrypt, Encrypt, Get}, + kmip_types::{CryptographicAlgorithm, CryptographicParameters, UniqueIdentifier}, + }, +}; +use std::sync::Arc; +use zeroize::Zeroizing; + +const AZURE_EKM_REQUIRED_AES_KEY_LENGTH: i32 = 256; + +pub(crate) async fn get_key_metadata_handler( + key_name: String, + user: String, + kms: Data>, +) -> KResult { + let get_request = Get { + unique_identifier: Some(UniqueIdentifier::TextString(key_name.clone())), + ..Default::default() + }; + match kms.get(get_request, &user).await { + Ok(resp) => { + match resp.object { + Object::SymmetricKey(_) | Object::PublicKey(_) | Object::PrivateKey(_) => { + let object = resp.object; + + let key_block = object.key_block()?; + + let algorithm = key_block.cryptographic_algorithm().ok_or_else(|| { + KmsError::ServerError("Cryptographic algorithm not set.".to_owned()) + })?; + let key_length = key_block + .cryptographic_length + .ok_or_else(|| KmsError::ServerError("Key length not set.".to_owned()))?; + // Check algorithm and build response + match algorithm { + CryptographicAlgorithm::AES => { + if key_length == AZURE_EKM_REQUIRED_AES_KEY_LENGTH { + Ok(HttpResponse::Ok().json(KeyMetadataResponse::aes())) + } else { + // It's indeed uncommon to see an error wrapped in an Ok() - this was done in purpose to reduce useless conversions + // Returning an Err() will be interpreted as an internal server error by the caller, which is not what we want here + // since the key exists but its length is unsupported. The specs is not very clear on this particular case. + Ok(AzureEkmErrorReply::operation_not_allowed( + &format!( + "AES key has length {key_length}, only {AZURE_EKM_REQUIRED_AES_KEY_LENGTH} is supported for now." + ), + &key_name, + ) + .into()) + } + } + CryptographicAlgorithm::RSA => { + if !SUPPORTED_RSA_LENGTHS.contains(&key_length) { + return Ok(AzureEkmErrorReply::operation_not_allowed( + &format!( + "RSA key has length {key_length}. Only {SUPPORTED_RSA_LENGTHS:?} are supported for now.", + ), + &key_name, + ) + .into()); + } + let key_material = key_block.key_material()?; + + let (mod_bytes, exp_bytes) = + if let KeyMaterial::TransparentRSAPrivateKey { + modulus: m, + public_exponent: Some(pe), + .. + } = key_material + { + (m.to_bytes_be().1, pe.to_bytes_be().1) + } else { + let (m, e) = get_rsa_key_metadata_from_public_key( + &kms, &key_name, &user, + ) + .await?; + (m.to_bytes_be().1, e.to_bytes_be().1) + }; + + let n_base64url = URL_SAFE_NO_PAD.encode(&mod_bytes); + let e_base64url = URL_SAFE_NO_PAD.encode(&exp_bytes); + + Ok(HttpResponse::Ok().json(KeyMetadataResponse::rsa( + key_length, + n_base64url, + e_base64url, + ))) + } + _ => Err(KmsError::ServerError(format!( + "Unsupported key algorithm: {algorithm:?}. Only AES and RSA are supported" + ))), + } + } + _ => Ok(AzureEkmErrorReply::operation_not_allowed("metadata", &key_name).into()), + } + } + Err(e) => { + if (matches!(e, KmsError::ItemNotFound(_)) || e.to_string().contains("not found")) { + return Ok(AzureEkmErrorReply::key_not_found(&key_name).into()); // as required by Azure EKM specs + } + if matches!(e, KmsError::Unauthorized(_)) { + return Ok(AzureEkmErrorReply::unauthorized(&key_name).into()); + } + // Otherwise, it's an internal error + Ok(AzureEkmErrorReply::internal_error(format!("Failed to retrieve key: {e}")).into()) + } + } +} + +/// Retrieve and validate a wrapping/unwrapping key from KMS (the kek) +/// Simply refactored because we need it in both wrap and unwrap handlers +/// +/// Returns the cryptographic algorithm after validation +async fn get_and_validate_kek_algorithm( + kms: &KMS, + key_name: &str, + user: &str, + request_alg: &WrapAlgorithm, +) -> Result { + let key_object = kms + .get( + Get { + unique_identifier: Some(UniqueIdentifier::TextString(key_name.to_owned())), + ..Default::default() + }, + user, + ) + .await + .map_err(|e| match e { + KmsError::ItemNotFound(_) => AzureEkmErrorReply::key_not_found(key_name), + _ => e.into(), + })? + .object; + + let kek_algorithm = *key_object + .key_block() + .map_err(KmsError::from)? + .cryptographic_algorithm() + .ok_or_else(|| { + AzureEkmErrorReply::internal_error("Key has no cryptographic algorithm set".to_owned()) + })?; + + // According to KMS docs, if the algorithm is present the length is also present, so if we reach this line, there is no more error risk + match (&kek_algorithm, request_alg) { + (CryptographicAlgorithm::AES, WrapAlgorithm::A256KW | WrapAlgorithm::A256KWP) => { + let key_length = key_object + .key_block() + .map_err(KmsError::from)? + .cryptographic_length + .ok_or_else(|| { + AzureEkmErrorReply::internal_error("Key has no cryptographic length.") + })?; + if key_length != AZURE_EKM_REQUIRED_AES_KEY_LENGTH { + return Err(AzureEkmErrorReply::invalid_request(format!( + "AES KEK must be {AZURE_EKM_REQUIRED_AES_KEY_LENGTH} bits, found {key_length} bits" + ))); + } + Ok(kek_algorithm) + } + (CryptographicAlgorithm::RSA, WrapAlgorithm::RsaOaep256) => Ok(kek_algorithm), + (CryptographicAlgorithm::AES, _) => Err(AzureEkmErrorReply::unsupported_algorithm( + &format!("{request_alg:?}"), + "AES", + )), + (CryptographicAlgorithm::RSA, _) => Err(AzureEkmErrorReply::unsupported_algorithm( + &format!("{request_alg:?}"), + "RSA", + )), + _ => Err(AzureEkmErrorReply::internal_error(format!( + "Unsupported key algorithm: {kek_algorithm:?}", + ))), + } +} + +pub(crate) async fn wrap_key_handler( + kms: &KMS, + key_name: &str, + user: &str, + request: WrapKeyRequest, +) -> Result { + // Decode the input key from base64url + let dek_bytes = Zeroizing::new(URL_SAFE_NO_PAD.decode(&request.value).map_err(|e| { + AzureEkmErrorReply::invalid_request(format!( + "Invalid base64url encoding in 'value' field : {e}" + )) + })?); + + // Validate input length - this is critical because the KMS panics if handed non valid data ! + if dek_bytes.is_empty() { + return Err(AzureEkmErrorReply::invalid_request( + "Cannot wrap empty key data", + )); + } + match request.alg { + WrapAlgorithm::A256KW | WrapAlgorithm::A256KWP => { + // NIST Key Wrap requires at least 8 bytes (64 bits) + if dek_bytes.len() < 8 { + return Err(AzureEkmErrorReply::invalid_request(format!( + "Key data too short for AES Key Wrap: {} bytes (minimum 8 bytes required)", + dek_bytes.len() + ))); + } + } + WrapAlgorithm::RsaOaep256 => { + // We only check for reasonable bounds here + if dek_bytes.len() > 512 { + return Err(AzureEkmErrorReply::invalid_request(format!( + "Key data too large for RSA wrapping: {} bytes (maximum ~512 bytes)", + dek_bytes.len() + ))); + } + } + } + + let kek_algorithm = get_and_validate_kek_algorithm(kms, key_name, user, &request.alg).await?; + + // Perform the wrap operation based on key type + let wrapped_key_bytes = match kek_algorithm { + CryptographicAlgorithm::AES => { + // AES Key Wrap using KMIP Encrypt operation + wrap_with_aes( + kms, + key_name, + user, + dek_bytes, + &request.alg, + request.request_context.correlation_id, + ) + .await? + } + CryptographicAlgorithm::RSA => { + // RSA-OAEP-256 wrap using KMIP Encrypt operation + wrap_with_rsa( + kms, + key_name, + user, + dek_bytes, + request.request_context.correlation_id, + ) + .await? + } + _ => { + return Err(AzureEkmErrorReply::internal_error(format!( + "Unsupported key algorithm: {kek_algorithm:?}", + ))); + } + }; + + // Encode wrapped key as base64url + let wrapped_base64url = URL_SAFE_NO_PAD.encode(&wrapped_key_bytes); + + Ok(WrapKeyResponse { + value: wrapped_base64url, + }) +} + +async fn wrap_with_aes( + kms: &KMS, + key_name: &str, + user: &str, + dek_bytes: Zeroizing>, + alg: &WrapAlgorithm, + correlation_id: String, // for logging purposes +) -> Result, AzureEkmErrorReply> { + let block_cipher_mode = match alg { + WrapAlgorithm::A256KWP => BlockCipherMode::AESKeyWrapPadding, + WrapAlgorithm::A256KW => BlockCipherMode::NISTKeyWrap, + WrapAlgorithm::RsaOaep256 => { + return Err(AzureEkmErrorReply::invalid_request( + "Invalid AES wrap algorithm", + )); + } + }; + + let encrypt_request = Encrypt { + unique_identifier: Some(UniqueIdentifier::TextString(key_name.to_owned())), + cryptographic_parameters: Some(CryptographicParameters { + block_cipher_mode: Some(block_cipher_mode), + ..Default::default() + }), + data: Some(dek_bytes), + correlation_value: Some(correlation_id.into_bytes()), + ..Default::default() + }; + + let response = kms.encrypt(encrypt_request, user).await?; + + let wrapped_data = response + .data + .ok_or_else(|| AzureEkmErrorReply::internal_error("Encrypt response missing data."))?; + + Ok(wrapped_data) +} + +/// Wrap DEK with RSA public key using KMIP Encrypt (OAEP padding) +async fn wrap_with_rsa( + kms: &KMS, + key_name: &str, + user: &str, + dek_bytes: Zeroizing>, + correlation_id: String, // for logging purposes +) -> Result, AzureEkmErrorReply> { + let encrypt_request = Encrypt { + unique_identifier: Some(UniqueIdentifier::TextString(format!("{key_name}_pk"))), + cryptographic_parameters: Some(CryptographicParameters { + cryptographic_algorithm: Some(CryptographicAlgorithm::RSA), + padding_method: Some(PaddingMethod::OAEP), + hashing_algorithm: Some(HashingAlgorithm::SHA256), + ..Default::default() + }), + data: Some(dek_bytes), + correlation_value: Some(correlation_id.into_bytes()), + ..Default::default() + }; + + let response = kms.encrypt(encrypt_request, user).await?; + + let wrapped_data = response + .data + .ok_or_else(|| AzureEkmErrorReply::internal_error("Encrypt response missing data."))?; + + Ok(wrapped_data) +} + +pub(crate) async fn unwrap_key_handler( + kms: &KMS, + key_name: &str, + user: &str, + request: UnwrapKeyRequest, +) -> Result { + let wrapped_dek_bytes = URL_SAFE_NO_PAD.decode(&request.value).map_err(|e| { + AzureEkmErrorReply::invalid_request(format!( + "Invalid base64url encoding in 'value' field: {e}" + )) + })?; + + if wrapped_dek_bytes.is_empty() { + return Err(AzureEkmErrorReply::invalid_request( + "Cannot unwrap empty data", + )); + } + // No other length validation here: Invalid lengths produce clean crypto errors. + + let kek_algorithm = get_and_validate_kek_algorithm(kms, key_name, user, &request.alg).await?; + + let unwrapped_dek_bytes = match kek_algorithm { + CryptographicAlgorithm::AES => { + unwrap_with_aes( + kms, + key_name, + user, + wrapped_dek_bytes, + &request.alg, + request.request_context.correlation_id, + ) + .await? + } + CryptographicAlgorithm::RSA => { + unwrap_with_rsa( + kms, + key_name, + user, + wrapped_dek_bytes, + request.request_context.correlation_id, + ) + .await? + } + _ => { + return Err(AzureEkmErrorReply::internal_error(format!( + "Unsupported key algorithm: {kek_algorithm:?}", + ))); + } + }; + let unwrapped_base64url = URL_SAFE_NO_PAD.encode(&unwrapped_dek_bytes); + Ok(UnwrapKeyResponse { + value: unwrapped_base64url, + }) +} + +async fn unwrap_with_aes( + kms: &KMS, + key_name: &str, + user: &str, + wrapped_dek_bytes: Vec, + alg: &WrapAlgorithm, + correlation_id: String, // for logging purposes +) -> Result>, AzureEkmErrorReply> { + let block_cipher_mode = match alg { + WrapAlgorithm::A256KWP => BlockCipherMode::AESKeyWrapPadding, + WrapAlgorithm::A256KW => BlockCipherMode::NISTKeyWrap, + WrapAlgorithm::RsaOaep256 => { + return Err(AzureEkmErrorReply::invalid_request( + "Invalid AES wrap algorithm", + )); + } + }; + + let decrypt_request = Decrypt { + unique_identifier: Some(UniqueIdentifier::TextString(key_name.to_owned())), + cryptographic_parameters: Some(CryptographicParameters { + block_cipher_mode: Some(block_cipher_mode), + ..Default::default() + }), + data: Some(wrapped_dek_bytes), + correlation_value: Some(correlation_id.into_bytes()), + ..Default::default() + }; + + let response = kms.decrypt(decrypt_request, user).await?; + + let unwrapped_data = response + .data + .ok_or_else(|| AzureEkmErrorReply::internal_error("Decrypt response missing data."))?; + + Ok(unwrapped_data) +} + +/// Unwrap DEK with RSA private key using KMIP Decrypt (OAEP padding) +async fn unwrap_with_rsa( + kms: &KMS, + key_name: &str, + user: &str, + wrapped_dek_bytes: Vec, + correlation_id: String, // for logging purposes +) -> Result>, AzureEkmErrorReply> { + let decrypt_request = Decrypt { + unique_identifier: Some(UniqueIdentifier::TextString(key_name.to_owned())), + cryptographic_parameters: Some(CryptographicParameters { + cryptographic_algorithm: Some(CryptographicAlgorithm::RSA), + padding_method: Some(PaddingMethod::OAEP), + hashing_algorithm: Some(HashingAlgorithm::SHA256), + ..Default::default() + }), + data: Some(wrapped_dek_bytes), + correlation_value: Some(correlation_id.into_bytes()), + ..Default::default() + }; + + let response = kms.decrypt(decrypt_request, user).await?; + + let unwrapped_data = response + .data + .ok_or_else(|| AzureEkmErrorReply::internal_error("Decrypt response missing data."))?; + + Ok(unwrapped_data) +} diff --git a/crate/server/src/routes/azure_ekm/mod.rs b/crate/server/src/routes/azure_ekm/mod.rs new file mode 100644 index 0000000000..4f7cc81c36 --- /dev/null +++ b/crate/server/src/routes/azure_ekm/mod.rs @@ -0,0 +1,186 @@ +use actix_web::{ + HttpRequest, HttpResponse, post, + web::{Data, Json, Path, Query}, +}; +use cosmian_logger::{info, trace}; +use serde::Deserialize; +use std::sync::Arc; + +use crate::{ + core::KMS, + routes::azure_ekm::{ + error::AzureEkmErrorReply, + handlers::{get_key_metadata_handler, unwrap_key_handler, wrap_key_handler}, + models::{ + KeyMetadataRequest, ProxyInfoRequest, ProxyInfoResponse, UnwrapKeyRequest, + WrapKeyRequest, + }, + }, +}; + +pub(crate) mod error; +pub(crate) mod handlers; +pub(crate) mod models; + +/// List of API versions supported by this implementation +pub(crate) const SUPPORTED_API_VERSIONS: [&str; 1] = [ + "0.1-preview", + // Add future versions here, in order. +]; + +/// Validate API version for all requests +fn validate_api_version(version: &str) -> Result<(), AzureEkmErrorReply> { + if !SUPPORTED_API_VERSIONS.contains(&version) { + return Err(AzureEkmErrorReply::unsupported_api_version(version)); + } + Ok(()) +} + +fn validate_key_name(key_name: &str) -> Result<(), AzureEkmErrorReply> { + if key_name.is_empty() || key_name.len() > 127 { + return Err(AzureEkmErrorReply::invalid_request( + "Key name length must be between 1 and 127 characters", + )); + } + + // Only a-z, A-Z, 0-9, - allowed + if !key_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return Err(AzureEkmErrorReply::invalid_request( + "Key name contains illegal characters. Only a-z, A-Z, 0-9, and '-' are allowed.", + )); + } + + Ok(()) +} + +#[derive(Debug, Deserialize)] +struct AzureEkmQueryParams { + #[serde(rename = "api-version")] + pub(crate) api_version: String, +} + +// Post request handlers below. The request being trivial, it also directly handles its request. +#[post("/info")] +pub(crate) async fn get_proxy_info( + http_req: HttpRequest, + query: Query, + body: Json, + kms: Data>, +) -> HttpResponse { + info!( + "POST /ekm/info api-version={} user={}", + query.api_version, + kms.get_user(&http_req) + ); + trace!("Request: {:?}", body.into_inner()); + + if let Err(e) = validate_api_version(&query.api_version) { + return e.into(); + } + let conf = kms.params.azure_ekm.clone(); + + HttpResponse::Ok().json(ProxyInfoResponse { + api_version: query.api_version.clone(), + proxy_vendor: conf.azure_ekm_proxy_vendor, + proxy_name: conf.azure_ekm_proxy_name, + ekm_vendor: conf.azure_ekm_ekm_vendor, + ekm_product: conf.azure_ekm_ekm_product, + }) +} + +const SUPPORTED_RSA_LENGTHS: [i32; 3] = [2048, 3072, 4096]; // the KMS key lengths are i32 + +#[post("/{key_name}/metadata")] +pub(crate) async fn get_key_metadata( + http_req: HttpRequest, + key_name: Path, + query: Query, + body: Json, + kms: Data>, +) -> HttpResponse { + let key_name = key_name.into_inner(); + let user = kms.get_user(&http_req); + + info!( + "POST /ekm/{}/metadata api-version={} user={}", + key_name, query.api_version, user, + ); + trace!("Request: {:?}", body.0); + + if let Err(e) = validate_api_version(&query.api_version) { + return e.into(); + } + if let Err(e) = validate_key_name(&key_name) { + return e.into(); + } + + match get_key_metadata_handler(key_name, user, kms).await { + Ok(response) => response, + Err(e) => AzureEkmErrorReply::from(e).into(), + } +} + +#[post("/{key_name}/wrapkey")] +pub(crate) async fn wrap_key( + http_req: HttpRequest, + key_name: Path, + query: Query, + body: Json, + kms: Data>, +) -> HttpResponse { + let key_name = key_name.into_inner(); + let user = kms.get_user(&http_req); + + info!( + "POST /ekm/{}/wrapkey alg={:?} api-version={} user={}", + key_name, body.alg, query.api_version, user + ); + trace!("Request: {:?}", body.0); + + // Validate API version + if let Err(e) = validate_api_version(&query.api_version) { + return e.into(); + } + if let Err(e) = validate_key_name(&key_name) { + return e.into(); + } + + match wrap_key_handler(&kms, &key_name, &user, body.into_inner()).await { + Ok(response) => HttpResponse::Ok().json(response), + Err(e) => e.into(), + } +} + +#[post("/{key_name}/unwrapkey")] +pub(crate) async fn unwrap_key( + http_req: HttpRequest, + key_name: Path, + query: Query, + body: Json, + kms: Data>, +) -> HttpResponse { + let key_name = key_name.into_inner(); + let user = kms.get_user(&http_req); + + info!( + "POST /ekm/{}/unwrapkey alg={:?} api-version={} user={}", + key_name, body.alg, query.api_version, user + ); + trace!("Request: {:?}", body.0); + + // Validate API version + if let Err(e) = validate_api_version(&query.api_version) { + return e.into(); + } + if let Err(e) = validate_key_name(&key_name) { + return e.into(); + } + + match unwrap_key_handler(&kms, &key_name, &user, body.into_inner()).await { + Ok(response) => HttpResponse::Ok().json(response), + Err(e) => e.into(), + } +} diff --git a/crate/server/src/routes/azure_ekm/models.rs b/crate/server/src/routes/azure_ekm/models.rs new file mode 100644 index 0000000000..689446d7e9 --- /dev/null +++ b/crate/server/src/routes/azure_ekm/models.rs @@ -0,0 +1,98 @@ +//! this module contains the request and response struct formats for the Azure EKM routes +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct RequestContext { + #[serde(default)] + pub(crate) request_id: Option, // optional per spec + pub(crate) correlation_id: String, + pub(crate) pool_name: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct ProxyInfoRequest { + request_context: RequestContext, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct ProxyInfoResponse { + pub api_version: String, + pub proxy_vendor: String, + pub proxy_name: String, + pub ekm_vendor: String, + pub ekm_product: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct KeyMetadataRequest { + request_context: RequestContext, +} + +#[derive(Debug, Serialize)] +pub(crate) struct KeyMetadataResponse { + key_type: String, + key_size: i32, + key_ops: [&'static str; 2], + #[serde(skip_serializing_if = "Option::is_none")] + n: Option, // base64url encoded RSA modulus (only for RSA keys) + #[serde(skip_serializing_if = "Option::is_none")] + e: Option, // base64url encoded RSA public exponent (only for RSA keys) +} + +impl KeyMetadataResponse { + pub(crate) fn aes() -> Self { + Self { + key_type: "oct".to_owned(), + key_size: 256, + key_ops: ["wrapKey", "unwrapKey"], + n: None, + e: None, + } + } + + pub(crate) fn rsa( + key_size: i32, + modulus_base64url: String, + exponent_base64url: String, + ) -> Self { + Self { + key_type: "RSA".to_owned(), + key_size, + key_ops: ["wrapKey", "unwrapKey"], + n: Some(modulus_base64url), + e: Some(exponent_base64url), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub(crate) enum WrapAlgorithm { + A256KW, + A256KWP, + #[serde(rename = "RSA-OAEP-256")] + RsaOaep256, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct WrapKeyRequest { + pub(crate) request_context: RequestContext, + pub(crate) alg: WrapAlgorithm, + pub(crate) value: String, // base64url encoded key to wrap +} + +#[derive(Debug, Serialize)] +pub(crate) struct WrapKeyResponse { + pub(crate) value: String, // base64url encoded wrapped key +} + +#[derive(Debug, Deserialize)] +pub(crate) struct UnwrapKeyRequest { + pub(crate) request_context: RequestContext, + pub(crate) alg: WrapAlgorithm, + pub(crate) value: String, +} + +#[derive(Debug, Serialize)] +pub(crate) struct UnwrapKeyResponse { + pub(crate) value: String, // base64url encoded unwrapped DEK +} diff --git a/crate/server/src/routes/mod.rs b/crate/server/src/routes/mod.rs index c21cfb77dd..83e3d27418 100644 --- a/crate/server/src/routes/mod.rs +++ b/crate/server/src/routes/mod.rs @@ -18,12 +18,14 @@ const CLI_ARCHIVE_FOLDER: &str = "./resources"; const CLI_ARCHIVE_FILE_NAME: &str = "cli.zip"; pub mod access; +pub(crate) mod azure_ekm; pub mod google_cse; pub mod health; pub mod kmip; pub mod ms_dke; pub mod root_redirect; pub mod ui_auth; +mod utils; impl actix_web::error::ResponseError for KmsError { fn status_code(&self) -> StatusCode { diff --git a/crate/server/src/routes/ms_dke/mod.rs b/crate/server/src/routes/ms_dke/mod.rs index 95f9ee1335..927b17d7f3 100644 --- a/crate/server/src/routes/ms_dke/mod.rs +++ b/crate/server/src/routes/ms_dke/mod.rs @@ -8,14 +8,10 @@ use base64::{Engine, engine::general_purpose::STANDARD}; use chrono::{Duration, Utc}; use clap::crate_version; use cosmian_kms_server_database::reexport::cosmian_kmip::{ - kmip_0::kmip_types::{HashingAlgorithm, KeyWrapType, PaddingMethod}, + kmip_0::kmip_types::{HashingAlgorithm, PaddingMethod}, kmip_2_1::{ - kmip_data_structures::{KeyMaterial, KeyValue}, - kmip_objects::{Object, PublicKey}, - kmip_operations::{Decrypt, Get}, - kmip_types::{ - CryptographicAlgorithm, CryptographicParameters, KeyFormatType, UniqueIdentifier, - }, + kmip_operations::Decrypt, + kmip_types::{CryptographicAlgorithm, CryptographicParameters, UniqueIdentifier}, }, }; use cosmian_logger::{info, trace}; @@ -23,7 +19,9 @@ use num_bigint_dig::BigInt; use serde::{Deserialize, Serialize}; use url::Url; -use crate::{core::KMS, kms_bail, kms_error, result::KResult}; +use crate::{ + core::KMS, kms_error, result::KResult, routes::utils::get_rsa_key_metadata_from_public_key, +}; #[derive(Serialize, Debug)] pub enum KeyType { @@ -127,65 +125,42 @@ async fn internal_get_key( .ok_or_else(|| kms_error!("MS DKE: The MS DKE service URL is not configured"))?; let mut dke_service_url = Url::parse(dke_service_url) .map_err(|_e| kms_error!("MS DKE: Invalid MS DKE Service URL: {}", dke_service_url))?; - let op = Get { - unique_identifier: Some(UniqueIdentifier::TextString( - serde_json::to_string(&vec![key_tag, "_pk"]).map_err(|e| kms_error!(e))?, - )), - key_format_type: Some(KeyFormatType::TransparentRSAPublicKey), - key_wrap_type: Some(KeyWrapType::NotWrapped), - key_compression_type: None, - key_wrapping_specification: None, - }; - let resp = kms.get(op, &user).await?; - match resp.object { - Object::PublicKey(PublicKey { key_block, .. }) => { - let Some(KeyValue::Structure { key_material, .. }) = key_block.key_value.as_ref() - else { - kms_bail!("MS DKE: The public key block does not contain the key value") - }; - match key_material { - KeyMaterial::TransparentRSAPublicKey { - modulus, - public_exponent, - } => { - let key_id = resp.unique_identifier.as_str().ok_or_else(|| { - kms_error!( - "MS DKE: The RSA public key does not have a text unique identifier. \ + let unique_identifier = UniqueIdentifier::TextString( + serde_json::to_string(&vec![key_tag, "_pk"]).map_err(|e| kms_error!(e))?, + ); + let key_id = unique_identifier.as_str().ok_or_else(|| { + kms_error!( + "MS DKE: The RSA public key does not have a text unique identifier. \ This is not supported" - ) - })?; - let mut existing_path = dke_service_url.path().to_owned(); - // remove the trailing / if any - if existing_path.ends_with('/') { - existing_path.pop(); - } - dke_service_url.set_path(&format!("{existing_path}/{key_tag}/{key_id}")); - Ok(KeyData { - key: DkePublicKey { - key_type: KeyType::RSA, - modulus: STANDARD.encode(modulus.to_bytes_be().1), - exponent: big_int_to_u32(public_exponent), - algorithm: Algorithm::Rs256, - key_id: dke_service_url.to_string(), - }, - cache: DkePublicKeyCache { - expiration: { - // make the key valid for one day - let now = Utc::now(); - let later = now + Duration::days(1); + ) + })?; + let (modulus, public_exponent) = + get_rsa_key_metadata_from_public_key(kms, key_id, &user).await?; - later.format("%Y-%m-%dT%H:%M:%S").to_string() - }, - }, - }) - } - _ => { - kms_bail!("MS DKE: Invalid Key Material for a transparent RSA public key") - } - } - } - _ => kms_bail!("MS DKE: Invalid key type {}", resp.object_type), + let mut existing_path = dke_service_url.path().to_owned(); + // remove the trailing / if any + if existing_path.ends_with('/') { + existing_path.pop(); } + dke_service_url.set_path(&format!("{existing_path}/{key_tag}/{key_id}")); + Ok(KeyData { + key: DkePublicKey { + key_type: KeyType::RSA, + modulus: STANDARD.encode(modulus.to_bytes_be().1), + exponent: big_int_to_u32(&public_exponent), + algorithm: Algorithm::Rs256, + key_id: dke_service_url.to_string(), + }, + cache: DkePublicKeyCache { + expiration: { + // make the key valid for one day + let now = Utc::now(); + let later = now + Duration::days(1); + + later.format("%Y-%m-%dT%H:%M:%S").to_string() + }, + }, + }) } #[derive(Serialize, Debug)] diff --git a/crate/server/src/routes/utils.rs b/crate/server/src/routes/utils.rs new file mode 100644 index 0000000000..6f85ab8be7 --- /dev/null +++ b/crate/server/src/routes/utils.rs @@ -0,0 +1,53 @@ +//! Shared utility functions for route handlers +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::KeyWrapType, + kmip_2_1::{ + kmip_data_structures::KeyMaterial, + kmip_operations::Get, + kmip_types::{KeyFormatType, UniqueIdentifier}, + }, +}; +use cosmian_logger::trace; + +use crate::{core::KMS, error::KmsError, result::KResult}; + +/// Extract RSA public key metadata (modulus and exponent) +pub(crate) async fn get_rsa_key_metadata_from_public_key( + kms: &KMS, + key_name: &str, + user: &str, +) -> KResult<(Box, Box)> { + let public_key_name = format!("{key_name}_pk"); + trace!( + "Fetching public key: {public_key_name} and attempting to extract RSA modulus and public exponent from key material" + ); + let public_key_response = kms + .get( + Get { + unique_identifier: Some(UniqueIdentifier::TextString(public_key_name.clone())), + key_format_type: Some(KeyFormatType::TransparentRSAPublicKey), + key_wrap_type: Some(KeyWrapType::NotWrapped), + ..Default::default() + }, + user, + ) + .await + .map_err(|e| { + KmsError::ServerError(format!( + "Failed to retrieve public key {public_key_name}: {e}" + )) + })?; + + let pub_key_block = public_key_response.object.key_block()?; + let key_material = pub_key_block.key_material()?; + + match key_material { + KeyMaterial::TransparentRSAPublicKey { + modulus, + public_exponent, + } => Ok((modulus.clone(), public_exponent.clone())), // no escape from this clone if we want to refactor + _ => Err(KmsError::ServerError( + "Public key does not contain RSA public key material".to_owned(), + )), + } +} diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index d11f8d28de..af67c5a6e4 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -37,7 +37,7 @@ use cosmian_kms_server_database::reexport::{ openssl::kmip_private_key_to_openssl, }, }; -use cosmian_logger::{debug, error, info, trace}; +use cosmian_logger::{debug, error, info, trace, warn}; use openssl::{ hash::{Hasher, MessageDigest}, ssl::SslAcceptorBuilder, @@ -55,7 +55,7 @@ use crate::{ }, result::{KResult, KResultHelper}, routes::{ - access, cli_archive_download, cli_archive_exists, get_version, + access, azure_ekm, cli_archive_download, cli_archive_exists, get_version, google_cse::{self, GoogleCseConfig}, health, kmip::{self, handle_ttlv_bytes}, @@ -676,9 +676,46 @@ pub async fn prepare_kms_server(kms_server: Arc) -> KResult 64 { + return Err(KmsError::ServerError(format!( + "Azure EKM path prefix is too long ({} chars). Maximum allowed is 64 characters.", + prefix.len() + ))); + } + + if !prefix + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '/' || c == '-') + { + return Err(KmsError::ServerError(format!( + "Azure EKM path prefix contains illegal characters: '{prefix}'. Only a-z, A-Z, 0-9, '/', and '-' are allowed." + ))); + } + + // Check for leading or trailing slashes + if prefix.starts_with('/') || prefix.ends_with('/') { + return Err(KmsError::ServerError( + "Azure EKM path prefix cannot start or end with '/'".to_owned(), + )); + } + } + + if !kms_server.params.azure_ekm.azure_ekm_disable_client_auth && !use_cert_auth { + return Err(KmsError::ServerError( + "Azure EKM requires mTLS authentication but the KMS server is not configured with client certificate authentication.".to_owned() + )); + } + } + let privileged_users: Option> = kms_server.params.privileged_users.clone(); // Compute the public URL first so we can use it to derive the session key @@ -756,6 +793,47 @@ pub async fn prepare_kms_server(kms_server: Arc) -> KResult KResult<()> { + log_init(option_env!("RUST_LOG")); + + // INFO: I will take care of this one by adding the new code + let clap_config = https_clap_config(); + let kms = Arc::new(KMS::instantiate(Arc::new(ServerParams::try_from(clap_config)?)).await?); + let owner = "ekm_owner"; + + // Test 1: Invalid Base64 URL encoding + let req = symmetric_key_create_request( + None, + 256, + CryptographicAlgorithm::AES, + EMPTY_TAGS, + false, + None, + ) + .unwrap(); + let create_response = kms.create(req, owner, None).await.unwrap(); + let aes_kek_id = create_response.unique_identifier.to_string(); + + // Test invalid base64url - contains invalid characters + let invalid_wrap_request = WrapKeyRequest { + request_context: RequestContext { + request_id: Some("test-invalid-base64".to_owned()), + correlation_id: "test-invalid-corr".to_owned(), + pool_name: "test-pool".to_owned(), + }, + alg: WrapAlgorithm::A256KW, + value: "This!is@not#valid$base64url%".to_owned(), // Invalid characters + }; + + let wrap_result = wrap_key_handler(&kms, &aes_kek_id, owner, invalid_wrap_request).await; + assert!( + wrap_result.is_err(), + "Wrap operation should fail with invalid base64url input" + ); + + // Test 2: Algorithm mismatch - Use AES key with RSA algorithm + // we already created an AES KEK above + let plaintext = hex::decode("00112233445566778899AABBCCDDEEFF").expect("valid hex"); + let algorithm_mismatch_request = WrapKeyRequest { + request_context: RequestContext { + request_id: Some("test-algorithm-mismatch".to_owned()), + correlation_id: "test-mismatch-corr".to_owned(), + pool_name: "test-pool".to_owned(), + }, + alg: WrapAlgorithm::RsaOaep256, // AES algorithm + value: URL_SAFE_NO_PAD.encode(&plaintext), + }; + + let mismatch_result = + wrap_key_handler(&kms, &aes_kek_id, owner, algorithm_mismatch_request).await; + + assert!( + mismatch_result.is_err(), + "Wrap operation should fail when asking for a key that uses the wrong algorithm" + ); + if let Err(e) = mismatch_result { + // Check that error message indicates algorithm mismatch + let error_msg = format!("{e:?}"); + assert!( + error_msg.contains("algorithm") + || error_msg.contains("Algorithm") + || error_msg.contains("mismatch") + || error_msg.contains("incompatible"), + "Error should mention algorithm mismatch: {error_msg}" + ); + } + + // Test 3: Non-existent key ID + let nonexistent_key_id = "I-DO-NOT-EXIST-12345"; + let nonexistent_key_request = WrapKeyRequest { + request_context: RequestContext { + request_id: Some("test-nonexistent-key".to_owned()), + correlation_id: "test-nonexistent-corr".to_owned(), + pool_name: "test-pool".to_owned(), + }, + alg: WrapAlgorithm::A256KW, + value: URL_SAFE_NO_PAD.encode(&plaintext), + }; + + let nonexistent_result = + wrap_key_handler(&kms, nonexistent_key_id, owner, nonexistent_key_request).await; + + assert!( + nonexistent_result.is_err(), + "Wrap operation should fail with non-existent key ID" + ); + + // Test 4: Empty values + let empty_value_request = WrapKeyRequest { + request_context: RequestContext { + request_id: Some("test-empty-value".to_owned()), + correlation_id: "test-empty-corr".to_owned(), + pool_name: "test-pool".to_owned(), + }, + alg: WrapAlgorithm::A256KW, + value: String::new(), // Empty string + }; + + let empty_result = wrap_key_handler(&kms, &aes_kek_id, owner, empty_value_request).await; + assert!( + empty_result.is_err(), + "Wrap operation should fail with empty value" + ); + + // Test 5: Invalid AES key size (spec only mentions 256 bits for A256KW/P) + for al in [WrapAlgorithm::A256KW, WrapAlgorithm::A256KWP] { + let invalid_key_sizes = [128, 192]; // bits + for &size in &invalid_key_sizes { + let req = symmetric_key_create_request( + None, + size, + CryptographicAlgorithm::AES, + EMPTY_TAGS, + false, + None, + ) + .unwrap(); + let create_response = kms.create(req, owner, None).await.unwrap(); + let aes_kek_id = create_response.unique_identifier.to_string(); + + let invalid_size_request = WrapKeyRequest { + request_context: RequestContext { + request_id: Some(format!("test-invalid-key-size-{size:?}")), + correlation_id: format!("test-invalid-size-corr-{size:?}"), + pool_name: "test-pool".to_owned(), + }, + alg: al.clone(), + value: URL_SAFE_NO_PAD.encode(&plaintext), + }; + + let invalid_size_result = + wrap_key_handler(&kms, &aes_kek_id, owner, invalid_size_request).await; + + assert!( + invalid_size_result.is_err(), + "Wrap operation should fail with invalid key size: {size} bits" + ); + } + } + Ok(()) +} + +#[tokio::test] +async fn test_wrap_unwrap_roundtrip_aes256_kw() -> KResult<()> { + log_init(option_env!("RUST_LOG")); + + let clap_config = https_clap_config(); + let kms = Arc::new(KMS::instantiate(Arc::new(ServerParams::try_from(clap_config)?)).await?); + let owner = "ekm_owner"; + + // RFC 3394 Section 4.6: Wrap 256 bits of Key Data with a 256-bit KEK + let rfc_kek_bytes = + hex::decode("000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F") + .expect("valid hex"); + + let rfc_plaintext_unwrapped_input = + hex::decode("00112233445566778899AABBCCDDEEFF000102030405060708090A0B0C0D0E0F") + .expect("valid hex"); + + let rfc_expected_wrapped = hex::decode( + "28C9F404C4B810F4CBCCB35CFB87F8263F5786E2D80ED326CBC7F0E71A99F43BFB988B9B7A02DD21", + ) + .expect("valid hex"); + + let kek_attributes = Attributes { + cryptographic_algorithm: Some(CryptographicAlgorithm::AES), + cryptographic_usage_mask: Some( + CryptographicUsageMask::Encrypt | CryptographicUsageMask::Decrypt, + ), + ..Default::default() + }; + + let kek_object = create_symmetric_key_kmip_object(&rfc_kek_bytes, &kek_attributes)?; + + // Use the helper function to create import request + let import_request = import_object_request( + Some("rfc3394-test-kek".to_owned()), + kek_object, + Some(kek_attributes), + false, + true, + EMPTY_TAGS, + )?; + + let import_response = kms.import(import_request, owner, None).await?; + let kek_id = import_response.unique_identifier.to_string(); + + let wrap_request = WrapKeyRequest { + request_context: RequestContext { + request_id: Some("test-aes-wrap".to_owned()), + correlation_id: "test-aes-corr".to_owned(), + pool_name: "test-pool".to_owned(), + }, + alg: WrapAlgorithm::A256KW, + value: URL_SAFE_NO_PAD.encode(&rfc_plaintext_unwrapped_input), + }; + + let wrap_response = wrap_key_handler(&kms, &kek_id, owner, wrap_request) + .await + .unwrap(); + let decoded_wrapped_key = URL_SAFE_NO_PAD.decode(&wrap_response.value)?; + + assert_eq!( + decoded_wrapped_key, rfc_expected_wrapped, + "Wrapped output must match RFC 3394 Section 4.6 test vector" + ); + + // Test unwrap operation (round-trip verification) + let unwrap_request = UnwrapKeyRequest { + request_context: RequestContext { + request_id: Some("rfc3394-unwrap-test".to_owned()), + correlation_id: "rfc3394-unwrap".to_owned(), + pool_name: "test-pool".to_owned(), + }, + alg: WrapAlgorithm::A256KW, + value: wrap_response.value, // Use our wrapped result + }; + + let unwrap_response = unwrap_key_handler(&kms, &kek_id, owner, unwrap_request) + .await + .unwrap(); + let unwrapped = URL_SAFE_NO_PAD.decode(&unwrap_response.value)?; + + assert_eq!( + unwrapped, rfc_plaintext_unwrapped_input, + "Unwrapped key must match original RFC plaintext" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_wrap_unwrap_roundtrip_aes256_kwp() -> KResult<()> { + log_init(option_env!("RUST_LOG")); + + let clap_config = https_clap_config(); + let kms = Arc::new(KMS::instantiate(Arc::new(ServerParams::try_from(clap_config)?)).await?); + let owner = "ekm_owner"; + + // For info, the rfc document has no test vector with a 256-bit KEK, so we generate our own + for _ in 0..5 { + let mut rng = CsRng::from_entropy(); + let mut rfc_kek_bytes = vec![0_u8; 32]; + rng.fill_bytes(&mut rfc_kek_bytes); + + // aes256_kwp can handle any plaintext size (unlike KW which requires multiples of 8 bytes) + // we'll re-run multiple times to make sure sizes are handled correctly + let plaintext_len = 8 + (rng.next_u32() as usize % 256); + let mut rfc_plaintext_unwrapped_input = vec![0_u8; plaintext_len]; + rng.fill_bytes(&mut rfc_plaintext_unwrapped_input); + + let kek_attributes = Attributes { + cryptographic_algorithm: Some(CryptographicAlgorithm::AES), + cryptographic_usage_mask: Some( + CryptographicUsageMask::Encrypt | { CryptographicUsageMask::Decrypt }, + ), + ..Default::default() + }; + + let kek_object = create_symmetric_key_kmip_object(&rfc_kek_bytes, &kek_attributes)?; + + // Use the helper function to create import request + let import_request = import_object_request( + Some("test-kek".to_owned()), + kek_object, + Some(kek_attributes), + false, + true, + EMPTY_TAGS, + ) + .unwrap(); + + let import_response = kms.import(import_request, owner, None).await?; + let kek_id = import_response.unique_identifier.to_string(); + + let wrap_request = WrapKeyRequest { + request_context: RequestContext { + request_id: Some("test-rsa-wrap".to_owned()), + correlation_id: "test-rsa-corr".to_owned(), + pool_name: "test-pool".to_owned(), + }, + alg: WrapAlgorithm::A256KWP, + value: URL_SAFE_NO_PAD.encode(&rfc_plaintext_unwrapped_input), + }; + + let wrap_response = wrap_key_handler(&kms, &kek_id, owner, wrap_request) + .await + .unwrap(); + + // Test unwrap operation (round-trip verification) + let unwrap_request = UnwrapKeyRequest { + request_context: RequestContext { + request_id: Some("unwrap-test".to_owned()), + correlation_id: "unwrap".to_owned(), + pool_name: "test-pool".to_owned(), + }, + alg: WrapAlgorithm::A256KWP, + value: wrap_response.value, // Use our wrapped result + }; + + let unwrap_response = unwrap_key_handler(&kms, &kek_id, owner, unwrap_request) + .await + .unwrap(); + let unwrapped = URL_SAFE_NO_PAD.decode(&unwrap_response.value)?; + + assert_eq!( + unwrapped, rfc_plaintext_unwrapped_input, + "Unwrapped key must match original RFC plaintext" + ); + } + Ok(()) +} + +#[tokio::test] +async fn test_wrap_unwrap_roundtrip_rsa_oaep_256() -> KResult<()> { + log_init(option_env!("RUST_LOG")); + + let clap_config = https_clap_config(); + let kms = Arc::new(KMS::instantiate(Arc::new(ServerParams::try_from(clap_config)?)).await?); + let owner = "ekm_owner"; + + let create_keys = kms + .create_key_pair( + create_rsa_key_pair_request(None, Vec::::new(), 2048, false, None)?, + owner, + None, + ) + .await?; + let key_id_private = create_keys.private_key_unique_identifier.to_string(); + warn!( + "Created RSA key pair with Private Key ID: {}", + key_id_private + ); + + let mut rng = CsRng::from_entropy(); + let plaintext_len = 1 + (rng.next_u32() as usize % 190); // OAEP with SHA-256 and 2048-bit key max + let mut valid_random_plaintext = vec![0_u8; plaintext_len]; + rng.fill_bytes(&mut valid_random_plaintext); + + // Wrap with public key + let wrap_request = WrapKeyRequest { + request_context: RequestContext { + request_id: Some("test-rsa-wrap".to_owned()), + correlation_id: "test-rsa-corr".to_owned(), + pool_name: "test-pool".to_owned(), + }, + alg: WrapAlgorithm::RsaOaep256, + value: URL_SAFE_NO_PAD.encode(&valid_random_plaintext), + }; + + let wrap_response = wrap_key_handler(&kms, &key_id_private, owner, wrap_request) + .await + .unwrap(); + + assert!( + URL_SAFE_NO_PAD.decode(&wrap_response.value).is_ok(), + "Result should be base 64 encoded data" + ); + + // Test unwrap operation (round-trip verification) + let unwrap_request = UnwrapKeyRequest { + request_context: RequestContext { + request_id: Some("rfc3394-unwrap-test".to_owned()), + correlation_id: "rfc3394-unwrap".to_owned(), + pool_name: "test-pool".to_owned(), + }, + alg: WrapAlgorithm::RsaOaep256, + value: wrap_response.value, // Use our wrapped result + }; + + let unwrap_response = unwrap_key_handler(&kms, &key_id_private, owner, unwrap_request) + .await + .unwrap(); + let unwrapped = URL_SAFE_NO_PAD.decode(&unwrap_response.value)?; + + assert_eq!( + unwrapped, valid_random_plaintext, + "Unwrapped test must match original plaintext" + ); + + Ok(()) +} diff --git a/crate/server/src/tests/azure_ekm/mod.rs b/crate/server/src/tests/azure_ekm/mod.rs new file mode 100644 index 0000000000..07214813cb --- /dev/null +++ b/crate/server/src/tests/azure_ekm/mod.rs @@ -0,0 +1 @@ +mod integration_tests; diff --git a/crate/server/src/tests/mod.rs b/crate/server/src/tests/mod.rs index 6ba4f7a693..ee7ee506e9 100644 --- a/crate/server/src/tests/mod.rs +++ b/crate/server/src/tests/mod.rs @@ -1,3 +1,4 @@ +mod azure_ekm; mod bulk_encrypt_decrypt_tests; #[cfg(feature = "non-fips")] mod cover_crypt_tests; diff --git a/documentation/docs/azure/byok.md b/documentation/docs/azure/byok/byok.md similarity index 100% rename from documentation/docs/azure/byok.md rename to documentation/docs/azure/byok/byok.md diff --git a/documentation/docs/azure/byok_create_kek.png b/documentation/docs/azure/byok/byok_create_kek.png similarity index 100% rename from documentation/docs/azure/byok_create_kek.png rename to documentation/docs/azure/byok/byok_create_kek.png diff --git a/documentation/docs/azure/byok_download_kek_public_key.png b/documentation/docs/azure/byok/byok_download_kek_public_key.png similarity index 100% rename from documentation/docs/azure/byok_download_kek_public_key.png rename to documentation/docs/azure/byok/byok_download_kek_public_key.png diff --git a/documentation/docs/azure/byok_import_jwe.png b/documentation/docs/azure/byok/byok_import_jwe.png similarity index 100% rename from documentation/docs/azure/byok_import_jwe.png rename to documentation/docs/azure/byok/byok_import_jwe.png diff --git a/documentation/docs/azure/ekm/ekm.md b/documentation/docs/azure/ekm/ekm.md new file mode 100644 index 0000000000..1e6ef94c15 --- /dev/null +++ b/documentation/docs/azure/ekm/ekm.md @@ -0,0 +1,248 @@ + +Cosmian KMS implements the Azure External Key Manager (EKM) Proxy API, enabling it to serve as an external key management service for an Azure Managed HSM. + +This integration allows organizations to maintain complete physical control over their encryption keys outside of Azure infrastructure while seamlessly integrating with Azure services that support **Customer Managed Keys** (CMK). + +The Cosmian KMS implementation follows and implements the Microsoft EKM Proxy API Specification for v0.1-preview. + + +- [High level architecture](#high-level-architecture) +- [Api specification](#api-specification) + - [URL format](#url-format) + - [Endpoints](#endpoints) + - [Supported algorithms](#supported-algorithms) +- [Getting started](#getting-started) + - [Azure Managed HSM Setup](#azure-managed-hsm-setup) + - [Cosmian KMS setup](#cosmian-kms-setup) + - [mTLS Configuration](#mtls-configuration) + - [Azure EKM Configuration](#azure-ekm-configuration) +- [Testing the integration](#testing-the-integration) + + +## High level architecture + +![High level architecture](high_level_arch.png) + + +The customer's Azure services (configured with Customer Managed Keys) communicate with Azure Managed HSM to perform encryption/decryption operations. A full list of Azure services that support CMK [can be consulted on this page](https://learn.microsoft.com/en-us/azure/security/fundamentals/encryption-customer-managed-keys-support). + +With this integration, your protected secrets remain under your complete control while maintaining compatibility with Azure's managed services. + +The following diagram illustrates a possible use case where Cosmian KMS acts as the EKM Proxy : + + +![Sequence diagram: Using an Azure Service with keys saved on customer's infrastructure](sequence.svg) + + + +## Api specification + +### URL format + +All requests and responses for Azure EKM APIs are sent as JSON objects over HTTPS. Each request includes context information to associate Azure Managed HSM logs and audits with Cosmian KMS logs. + +The URI format for EKM Proxy API calls is: + +``` +https://{public-KMS-URI}/azureekm/[path-prefix]/{api-specific-paths}?api-version={client-api-version} +``` + +The parameters between brackets {} can be edited on the KMS configuration and must follow the following constraints : + +**Path Prefix:** +- Maximum 64 characters +- Allowed characters: letters (a-z, A-Z), numbers (0-9), slashes (/), and dashes (-) + +**External Key ID:** +- Referenced as `{key-name}` in the endpoints below +- Maximum 64 characters +- Allowed characters: letters (a-z, A-Z), numbers (0-9), and dashes (-) + +### Endpoints + +| Endpoint | Method | Path | Description | +| ---------------- | ------ | ------------------------------------------------ | ------------------------------------------------- | +| Get Proxy Info | POST | /azureekm/[path-prefix]/info | Health check and proxy details | +| Get Key Metadata | POST | /azureekm/[path-prefix]/{key-name}/metadata | Retrieve key type, size, and supported operations | +| Wrap Key | POST | /azureekm/[path-prefix]/{key-name}/wrapkey | Wrap (encrypt) a DEK with a KEK | +| Unwrap Key | POST | /azureekm/[path-prefix]/{key-name}/unwrapkey | Unwrap (decrypt) a previously wrapped DEK | + + +### Supported algorithms + +| Algorithm | Key Type | Description | +| ------------ | -------- | ------------------------------------ | +| A256KW | AES-256 | AES Key Wrap (RFC 3394) | +| A256KWP | AES-256 | AES Key Wrap with Padding (RFC 5649) | +| RSA-OAEP-256 | RSA | RSA-OAEP using SHA-256 and MGF1 | + +> **⚠️ Notice:** For RSA operations, always use the **private key reference** as `{key-name}` in **all** endpoints, including wrap operations. The KMS will automatically derive and use the associated public key for encryption (wrap) operations. Passing the public key ID might lead to errors. + +## Getting started + +### Azure Managed HSM Setup + +You must have an Azure Managed HSM Pool already created and activated in your Azure subscription. Refer to the [Azure Managed HSM documentation](https://learn.microsoft.com/en-us/azure/key-vault/managed-hsm/) for setup instructions. + +Once the configuration is done, you will need the root CA certificate that the Azure Managed HSM uses for client authentication. This certificate will be configured in Cosmian KMS to validate incoming mTLS connections. + +Let's save the root CA as **`mhsm-root-ca.pem`** - We will need it in the next step. + +### Cosmian KMS setup + +Follow the [Cosmian KMS installation guide](../../installation/installation_getting_started.md) to install the KMS server on your infrastructure. The KMS server typically uses the configuration file located at `/etc/cosmian/kms.toml` when installed manually with default parameters. + +Alternatively, you can deploy a pre-configured Cosmian Confidential VM [like explained in this guide.](../../installation/marketplace_guide.md). For confidential VMs, the KMS configuration file is located in the encrypted LUKS container at `/var/lib/cosmian_vm/data/app.conf`. + +Environment variables can also be used for all the configurations below. + +**The following guide will consider running Cosmian KMS on confidential VM in non-FIPS mode.** + +#### mTLS Configuration +Configure mutual TLS authentication to accept connections from Azure Managed HSM by adding of editing to following lines in your configuration file: +For detailed information about TLS client certificate authentication, see the [TLS Client Certificate configuration guide](../../configurations.md#tls-client-cert). + +```toml +[tls] +# Your server certificate and private key (PKCS#12 format) +tls_p12_file = "/etc/cosmian/server-cert.p12" +tls_p12_password = "your-secure-password" + +# The certificate downloaded in the previous section +# This validates the client certificate presented by Azure MHSM +clients_ca_cert_file = "/etc/cosmian/mhsm-root-ca.pem" +``` + +**Note** : If you have a server's key and certificate files, you can convert them to PKCS#12 format using [`openssl`](https://docs.openssl.org/master/man1/openssl/): + +```bash +openssl pkcs12 -export \ + -in server.crt \ + -inkey server.key \ + -out server-cert.p12 \ + -name "cosmian-kms-server" \ + -passout pass:your-secure-password +``` + +#### Azure EKM Configuration + +```toml +[azure_ekm_config] +# Enable Azure EKM endpoints +azure_ekm_enable = true + +# Optional: Path prefix for multi-tenant isolation (max 64 chars: a-z, A-Z, 0-9, /, -) +azure_ekm_path_prefix = "cosmian0" + +# The fields below will be reported in the /info endpoint, edit according to your needs +azure_ekm_proxy_vendor = "Cosmian" +azure_ekm_proxy_name = "EKM Proxy Service v0.1-preview" +azure_ekm_ekm_vendor = "Cosmian" +azure_ekm_ekm_product = "Cosmian KMS" + +# WARNING: Only set to true for testing! Never in production. +azure_ekm_disable_client_auth = false +``` + +**Configuration Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `azure_ekm_enable` | boolean | `false` | Enable/disable Azure EKM API endpoints. | +| `azure_ekm_path_prefix` | string | none | Optional path prefix for routing and multi-tenant isolation. Max 64 chars: `a-z`, `A-Z`, `0-9`, `/`, `-`. Example: `"cosmian0"`, `"customer-a/prod"` | +| `azure_ekm_proxy_vendor` | string | `"Cosmian"` | Proxy vendor name reported in `/info` endpoint. | +| `azure_ekm_proxy_name` | string | `"EKM Proxy Service v{version}"` | Proxy name and version reported in `/info` endpoint. Auto-inserts API version by default. | +| `azure_ekm_ekm_vendor` | string | `"Cosmian"` | EKMS vendor name reported in `/info` endpoint.| +| `azure_ekm_ekm_product` | string | `"Cosmian KMS v{CARGO_PKG_VERSION}"` | EKMS product name and version reported in `/info` endpoint. | +| `azure_ekm_disable_client_auth` | boolean | `false` | ⚠️ Bypasses mTLS authentication. Only use for testing. | + + +## Testing the integration + +For testing purposes or for debugging, you can temporarily disable client authentication by commenting out the following configuration fields: + +```toml +[tls] +# Comment this field to disable client auth and allow upcoming requests from anyone +# clients_ca_cert_file = "/etc/cosmian/mhsm-root-ca.pem" + +[azure_ekm_config] +# change to false +# azure_ekm_disable_client_auth = false +``` + +Restart the KMS server: + +```bash +sudo systemctl restart cosmian-kms +``` + +Test the `/info` endpoint: + +```bash +curl -X POST "https://ekm.yourdomain.com/azureekm/cosmian0/info?api-version=0.1-preview" \ + -H "Content-Type: application/json" \ + -d '{ + "request_context": { + "request_id": "test-request-123", + "correlation_id": "test-correlation-456", + "pool_name": "test-pool" + } + }' +``` + +Expected response (if you used the config above): + +```json +{ + "api_version": "0.1-preview", + "proxy_vendor": "Cosmian", + "proxy_name": "EKM Proxy Service v=0.1-preview", + "ekm_vendor": "Cosmian", + "ekm_product": "Cosmian KMS v5.15.0" +} +``` diff --git a/documentation/docs/azure/ekm/high_level_arch.png b/documentation/docs/azure/ekm/high_level_arch.png new file mode 100644 index 0000000000..98c2f5c6dc Binary files /dev/null and b/documentation/docs/azure/ekm/high_level_arch.png differ diff --git a/documentation/docs/azure/ekm/sequence.svg b/documentation/docs/azure/ekm/sequence.svg new file mode 100644 index 0000000000..f79699c818 --- /dev/null +++ b/documentation/docs/azure/ekm/sequence.svg @@ -0,0 +1,102 @@ +Cosmian KMSAzure Managed HSMAzure Service -(must support CMK)Cosmian KMSAzure Managed HSMAzure Service -(must support CMK)Has encrypted dataprotected by DEKDEK needs to bewrapped/unwrappedKEK NEVER leaves here!Wrapping happens locallyStores wrapped DEKEncrypt/Decrypt datausing External Key "mykey"POST /mykey/wrapkey{"value": "DEK_plaintext"}{"value": "DEK_wrapped"}Here's your wrapped DEK \ No newline at end of file diff --git a/documentation/docs/index.md b/documentation/docs/index.md index 170150ce7a..49394f1065 100644 --- a/documentation/docs/index.md +++ b/documentation/docs/index.md @@ -30,7 +30,7 @@ The **Cosmian KMS** is a high-performance, [**source available**](https://github ## Integrations - **Cloud integrations**: - - [Azure BYOK](./azure/byok.md) + - [Azure BYOK](./azure/byok/byok.md) - [GCP CSEK](./google_gcp/csek.md) and [Google CMEK](./google_gcp/cmek.md) - ... - **Workplace security**: diff --git a/documentation/includes.yml b/documentation/includes.yml index b938100422..40de9089bd 100644 --- a/documentation/includes.yml +++ b/documentation/includes.yml @@ -23,7 +23,7 @@ titlepage-logo: ./pandoc/cosmian.png logo-width: 200pt lang: en-US footer-left: Confidential -footer-center: © Copyright 2018-2024 Cosmian. All rights reserved +footer-center: © Copyright 2018-2026 Cosmian. All rights reserved header-left: ./pandoc/favicon.png header-includes: - \setcounter{page}{0} # So that the titlepage is the zeroth page diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 03c33d3a98..6876ca32ab 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -1,7 +1,7 @@ --- site_name: Key Management System site_url: https://docs.cosmian.com/ -copyright: © Copyright 2018-2024 Cosmian. All rights reserved +copyright: © Copyright 2018-2026 Cosmian. All rights reserved dev_addr: localhost:8003 theme: name: material @@ -76,7 +76,8 @@ nav: - API Endpoints: api.md - AWS ECS Fargate: aws_fargate.md - Azure: - - BYOK (Bring Your Own Key): azure/byok.md + - BYOK (Bring Your Own Key): azure/byok/byok.md + - EKM (External Key Management): azure/ekm/ekm.md - Google GCP: - CMEK (Customer Managed Encryption Keys): google_gcp/cmek.md - CSEK (Customer Supplied Encryption Keys): google_gcp/csek.md diff --git a/documentation/theme_overrides/assets/stylesheets/extra.css b/documentation/theme_overrides/assets/stylesheets/extra.css index c5fd51f7cf..f6d872873d 100644 --- a/documentation/theme_overrides/assets/stylesheets/extra.css +++ b/documentation/theme_overrides/assets/stylesheets/extra.css @@ -482,7 +482,7 @@ footer, } @bottom-center { - content: "© Copyright 2018-2021 Cosmian. All rights reserved"; + content: "© Copyright 2018-2026 Cosmian. All rights reserved"; } @bottom-right {