From 3d0276f08e974db6d0d2d00c823c986cdc855c24 Mon Sep 17 00:00:00 2001 From: HatemMn <19950216+HatemMn@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:17:39 +0100 Subject: [PATCH 01/18] feat: all commits squashed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: DONE rfc5649 fix: fix problematic test feat: finish up fix: major bugé feat: add a lot of things fis: add back provider for ci tests fix:clip fix: a lot more stuff fix: a lot more stuff2 fix: some fixes fix: finish up feat: reviews fixes + ui fixes + migrate fixes + a test feat: missing file fix: ui fix: review fixes fix: grammar fixes feat: add azure ekm configs feat: wip on errors feat: finish start file feat: big advance on metadata endpoint feat: finish metadata but the code is ugly refactor: HUGE refactoring of that huge nested code induced by the errors (I used handlers) feat: more advance feat: finish the api and fix compiler problems feat: multiple improvements for endpoints feat: more improvements feat: auth OK feat: auth seems ok ...? feat: first refactor fix: improve fix: add missing files fix: commit first files fix: add the rest fix: rfc algorithms Revert "fix: rfc algorithms" This reverts commit e5d97372aa122a3d6fa6a9a27169623f43dea392. fix: add rfc algos feat: rfc3394 algo postfixes feat: post review fixes and some new feats to AES habndlers feat: final commits that got lost before finish fix: test fixes --- .cargo/config.toml | 4 + .vscode/settings.json | 5 +- crate/crypto/src/crypto/symmetric/rfc5649.rs | 109 +++- .../config/command_line/azure_ekm_config.rs | 63 +++ .../src/config/command_line/clap_config.rs | 35 +- crate/server/src/config/command_line/mod.rs | 2 + .../server/src/config/params/server_params.rs | 31 +- crate/server/src/core/operations/decrypt.rs | 7 +- crate/server/src/core/operations/encrypt.rs | 2 +- crate/server/src/main.rs | 14 +- .../src/routes/azure_ekm/contributing.md | 9 + crate/server/src/routes/azure_ekm/error.rs | 144 +++++ crate/server/src/routes/azure_ekm/handlers.rs | 531 ++++++++++++++++++ crate/server/src/routes/azure_ekm/mod.rs | 185 ++++++ crate/server/src/routes/azure_ekm/models.rs | 98 ++++ crate/server/src/routes/google_cse/mod.rs | 1 - crate/server/src/routes/mod.rs | 1 + crate/server/src/start_kms_server.rs | 47 +- .../src/tests/azure_ekm/integration_tests.rs | 422 ++++++++++++++ crate/server/src/tests/azure_ekm/mod.rs | 1 + crate/server/src/tests/mod.rs | 1 + 21 files changed, 1697 insertions(+), 15 deletions(-) create mode 100644 crate/server/src/config/command_line/azure_ekm_config.rs create mode 100644 crate/server/src/routes/azure_ekm/contributing.md create mode 100644 crate/server/src/routes/azure_ekm/error.rs create mode 100644 crate/server/src/routes/azure_ekm/handlers.rs create mode 100644 crate/server/src/routes/azure_ekm/mod.rs create mode 100644 crate/server/src/routes/azure_ekm/models.rs create mode 100644 crate/server/src/tests/azure_ekm/integration_tests.rs create mode 100644 crate/server/src/tests/azure_ekm/mod.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index acf8be4d08..9027929ef4 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -35,3 +35,7 @@ publish-dry-crate = "publish --dry-run --allow-dirty" # "-C", # "target_cpu=native", # ] + +# [target.x86_64-unknown-linux-gnu] # TODO: uncomment this after dev is finished +# linker = "clang" +# rustflags = ["-C", "link-arg=-fuse-ld=mold"] diff --git a/.vscode/settings.json b/.vscode/settings.json index 5bd0649e26..d31407b141 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -108,5 +108,8 @@ "sqltools.useNodeRuntime": true, "rust-analyzer.cargo.features": [ "non-fips" - ] + ], + "rust-analyzer.cargo.extraEnv": { + "CARGO_TARGET_DIR": "target/rust-analyzer" // This prevents rust-analyzer from interfering with regular cargo run builds and reduces compilation time + } } diff --git a/crate/crypto/src/crypto/symmetric/rfc5649.rs b/crate/crypto/src/crypto/symmetric/rfc5649.rs index 795882de86..a3d28b580a 100644 --- a/crate/crypto/src/crypto/symmetric/rfc5649.rs +++ b/crate/crypto/src/crypto/symmetric/rfc5649.rs @@ -68,7 +68,7 @@ pub fn rfc5649_wrap(plaintext: &[u8], kek: &[u8]) -> CryptoResult> { }; // Allocate output buffer with extra space for cipher_final - let mut ciphertext = vec![0_u8; padded_len + (AES_BLOCK_SIZE * 2)]; + let mut ciphertext = vec![0_u8; padded_len + AES_BLOCK_SIZE]; // Perform the key wrap operation let mut written = ctx.cipher_update(plaintext, Some(&mut ciphertext))?; @@ -99,10 +99,7 @@ pub fn rfc5649_unwrap(ciphertext: &[u8], kek: &[u8]) -> CryptoResult CryptoResult Result<(u64, Zeroizing>), CryptoError> { +// let n = ciphertext.len(); + +// if !n.is_multiple_of(AES_WRAP_PAD_BLOCK_SIZE) || n < AES_BLOCK_SIZE { +// return Err(CryptoError::InvalidSize( +// "The ciphertext size should be >= 16 and a multiple of 8".to_owned(), +// )); +// } + +// // Number of 64-bit blocks minus 1 +// let n = n / 8 - 1; + +// let mut blocks = Zeroizing::from(Vec::with_capacity(n + 1)); +// for chunk in ciphertext.chunks(AES_WRAP_PAD_BLOCK_SIZE) { +// blocks.push(u64::from_be_bytes(chunk.try_into()?)); +// } + +// // ICR stands for Integrity Check Register initially containing the IV. +// #[expect(clippy::indexing_slicing)] +// let mut icr = blocks[0]; + +// // Encrypt block using AES with ECB mode i.e. raw AES as specified in +// // RFC5649. +// // Make use of OpenSSL Crypter interface to decrypt blocks incrementally +// // without padding since RFC5649 has special padding methods. +// let mut decrypt_cipher = match kek.len() { +// 16 => Crypter::new(Cipher::aes_128_ecb(), Mode::Decrypt, kek, None)?, +// 24 => Crypter::new(Cipher::aes_192_ecb(), Mode::Decrypt, kek, None)?, +// 32 => Crypter::new(Cipher::aes_256_ecb(), Mode::Decrypt, kek, None)?, +// _ => { +// return Err(CryptoError::InvalidSize( +// "The kek size should be 16, 24 or 32".to_owned(), +// )); +// } +// }; +// decrypt_cipher.pad(false); + +// #[expect(clippy::indexing_slicing)] +// for j in (0..6).rev() { +// for (i, block) in blocks[1..].iter_mut().rev().enumerate().take(n) { +// let t = u64::try_from((n * j) + (n - i))?; + +// // B = AES-1(K, (A ^ t) | R[i]) where t = n*j+i +// let big_i = ((u128::from(icr ^ t) << 64) | u128::from(*block)).to_be_bytes(); +// let big_b = big_i.as_slice(); + +// let mut plaintext = Zeroizing::from(vec![0; big_b.len() + AES_BLOCK_SIZE]); +// let mut dec_len = decrypt_cipher.update(big_b, &mut plaintext)?; +// dec_len += decrypt_cipher.finalize(&mut plaintext)?; +// plaintext.truncate(dec_len); + +// // A = MSB(64, B) +// icr = u64::from_be_bytes( +// plaintext +// .get(0..AES_WRAP_PAD_BLOCK_SIZE) +// .ok_or_else(|| { +// CryptoError::InvalidSize( +// "Decryption output too short for IV extraction".to_owned(), +// ) +// })? +// .try_into()?, +// ); + +// // R[i] = LSB(64, B) +// *block = u64::from_be_bytes( +// plaintext[AES_WRAP_PAD_BLOCK_SIZE..AES_WRAP_PAD_BLOCK_SIZE * 2].try_into()?, +// ); +// } +// } + +// let mut unwrapped_key = Zeroizing::from(Vec::with_capacity((blocks.len() - 1) * 8)); +// for block in blocks +// .get(1..) +// .ok_or_else(|| CryptoError::IndexingSlicing("Block index issue".to_owned()))? +// { +// unwrapped_key.extend(block.to_be_bytes()); +// } + +// Ok((icr, unwrapped_key)) +// } + #[expect(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] #[cfg(test)] mod tests { 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..129f2c6003 --- /dev/null +++ b/crate/server/src/config/command_line/azure_ekm_config.rs @@ -0,0 +1,63 @@ +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 (-). + /// TODO: validate allowed characters + #[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_VENDOR", + default_value = "EKM Proxy Service v{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. + /// TODO: refer to page 12 of the specs to assert this is the correct default value + #[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..0561775328 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..82797190c1 100644 --- a/crate/server/src/main.rs +++ b/crate/server/src/main.rs @@ -147,7 +147,7 @@ mod tests { use std::path::PathBuf; use cosmian_kms_server::config::{ - ClapConfig, GoogleCseConfig, HttpConfig, IdpAuthConfig, KmipPolicyConfig, LoggingConfig, + AzureEkmConfig,ClapConfig, GoogleCseConfig, HttpConfig, IdpAuthConfig, KmipPolicyConfig, LoggingConfig, MainDBConfig, OidcConfig, ProxyConfig, SocketServerConfig, TlsConfig, UiConfig, WorkspaceConfig, }; @@ -216,6 +216,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 +317,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]" 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..ef38d83684 --- /dev/null +++ b/crate/server/src/routes/azure_ekm/contributing.md @@ -0,0 +1,9 @@ +## 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 + +- For some reason, code editors might suggest to import the `cosmian_kmip` imports from the crate `cosmian_kms_client_utils`. Do not import from there, use that same crate `cosmian_kms_server_database` (mod.rs, line 9). +- 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..4cfe0512be --- /dev/null +++ b/crate/server/src/routes/azure_ekm/error.rs @@ -0,0 +1,144 @@ +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; + +// 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 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..f09245b864 --- /dev/null +++ b/crate/server/src/routes/azure_ekm/handlers.rs @@ -0,0 +1,531 @@ +#![allow(clippy::panic)] + +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::{KeyBlock, KeyMaterial}, + kmip_objects::Object, + kmip_operations::{Decrypt, Encrypt, Get}, + kmip_types::{ + CryptographicAlgorithm, CryptographicParameters, LinkType::PublicKeyLink, + UniqueIdentifier, + }, + }, +}; +use num_bigint_dig::BigInt; +use std::sync::Arc; +use zeroize::Zeroizing; + +use crate::{ + core::KMS, + error::KmsError, + result::KResult, + routes::azure_ekm::{ + SUPPORTED_RSA_LENGTHS, + error::AzureEkmErrorReply, + models::{ + KeyMetadataResponse, UnwrapKeyRequest, UnwrapKeyResponse, WrapAlgorithm, + WrapKeyRequest, WrapKeyResponse, + }, + }, +}; + +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, None).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 == 256 { + 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 256 is supported for now." + ), + &key_name, + ) + .into()) + } + } + CryptographicAlgorithm::RSA => Ok({ + 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 modulus; + let public_exponent: Box; // solves ownership issues - strongly typed on purpose + + match key_material { + KeyMaterial::TransparentRSAPublicKey { + modulus: m, + public_exponent: pe, + } => { + modulus = m; + public_exponent = pe.clone(); + } + KeyMaterial::TransparentRSAPrivateKey { + modulus: m, + public_exponent: pe, + .. + } => { + modulus = m; + let pub_exp = if let Some(exp) = pe { + exp.clone() + } else { + // Fetch and store in outer scope + // This function is not called in the other branches, which makes the cloning + // mandatory - I do not think there's a more efficient way to this + // TODO(review): This fallback mechanism is not explicitly mentioned in spec, and it's odd + // that the private key would not have the public exponent stored - double check this behavior... + get_public_exponent_from_linked_key(key_block, &user, &kms) + .await? + }; + public_exponent = pub_exp; + } + _ => { + return Err(KmsError::ServerError( + "RSA key has missing metadata parameters".to_owned(), + )); + } + } + + let modulus_bytes = modulus.to_bytes_be().1; // .1 to skip sign + let exponent_bytes = &public_exponent.to_bytes_be().1; + + let n_base64url = URL_SAFE_NO_PAD.encode(&modulus_bytes); + let e_base64url = URL_SAFE_NO_PAD.encode(exponent_bytes); + 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, + None, + ) + .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) => { + // Specs mention only the usage of 256 bits keys + 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 != 256 { + return Err(AzureEkmErrorReply::invalid_request(format!( + "AES KEK must be 256 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> { + // Determine block cipher mode and IV/nonce based on algorithm + 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, None).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(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(dek_bytes), + correlation_value: Some(correlation_id.into_bytes()), + ..Default::default() + }; + + // let rep = kms. + + let response = kms.encrypt(encrypt_request, user, None).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}" + )) + })?; + + 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, None).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, None).await?; + + let unwrapped_data = response + .data + .ok_or_else(|| AzureEkmErrorReply::internal_error("Decrypt response missing data."))?; + + Ok(unwrapped_data) +} + +/// If the public exponent is missing from the private key, fetch it from a linked RSA public key +async fn get_public_exponent_from_linked_key( + key_block: &KeyBlock, + user: &str, + kms: &KMS, +) -> KResult> { + let public_key_id = key_block + .get_linked_object_id(PublicKeyLink)? + .ok_or_else(|| { + KmsError::ServerError( + "RSA private key has no linked public key to get public exponent from.".to_owned(), + ) + })?; + + let public_key_response = kms + .get( + Get { + unique_identifier: Some(UniqueIdentifier::TextString(public_key_id)), + ..Default::default() + }, + user, + None, + ) + .await?; + + match public_key_response.object { + Object::PublicKey(pub_key) => match pub_key.key_block.key_material()? { + KeyMaterial::TransparentRSAPublicKey { + public_exponent, .. + } => Ok(public_exponent.clone()), + _ => Err(KmsError::ServerError( + "Failed to retrieve public exponent from linked public key".to_owned(), + )), + }, + _ => Err(KmsError::ServerError( + "Failed to retrieve public exponent from linked public key".to_owned(), + )), + } +} 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..b32d24edd6 --- /dev/null +++ b/crate/server/src/routes/azure_ekm/mod.rs @@ -0,0 +1,185 @@ +use actix_web::{ + HttpRequest, HttpResponse, post, + web::{Data, Json, Path, Query}, +}; +use cosmian_logger::{info, trace, warn}; +use serde::Deserialize; +use std::{sync::Arc, time::Duration}; +use tokio::time::timeout; + +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; + +/// The proxy is expected to respond to API calls within 250 milliseconds. If Managed HSM +/// does not receive a response within this period, it will time out. +/// This timeout is only set on the wrap/unwrap endpoints, since they can take time, in order to avoid +/// wasteful computing. +const AZURE_EKM_TIMEOUT_MS: u64 = 250; + +/// 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(()) +} + +#[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(); // it's an Arc, so cheap 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(); + } + + 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(); + } + + (timeout(Duration::from_millis(AZURE_EKM_TIMEOUT_MS), async { + match wrap_key_handler(&kms, &key_name, &user, body.into_inner()).await { + Ok(response) => HttpResponse::Ok().json(response), + Err(e) => e.into(), + } + }) + .await) + .unwrap_or_else(|_| { + warn!("Azure EKM /{}/wrapkey request timeout", key_name); + AzureEkmErrorReply::internal_error( + "Request timeout: operation exceeded HSM timeout delay, aborting.", + ) + .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(); + } + + (timeout(Duration::from_millis(AZURE_EKM_TIMEOUT_MS), async { + // Call implementation + match unwrap_key_handler(&kms, &key_name, &user, body.into_inner()).await { + Ok(response) => HttpResponse::Ok().json(response), + Err(e) => e.into(), + } + }) + .await) + .unwrap_or_else(|_| { + warn!("Azure EKM /{}/wrapkey request timeout", key_name); + AzureEkmErrorReply::internal_error( + "Request timeout: operation exceeded HSM timeout delay, aborting.", + ) + .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/google_cse/mod.rs b/crate/server/src/routes/google_cse/mod.rs index 95c9429f3f..1138066877 100644 --- a/crate/server/src/routes/google_cse/mod.rs +++ b/crate/server/src/routes/google_cse/mod.rs @@ -95,7 +95,6 @@ pub(crate) async fn get_status( info!("GET /google_cse/status {}", kms.get_user(&req)); let google_cse_kacls_url = build_google_cse_url(kms.params.kms_public_url.as_deref())?; - Ok(Json(operations::get_status(&google_cse_kacls_url))) } diff --git a/crate/server/src/routes/mod.rs b/crate/server/src/routes/mod.rs index c21cfb77dd..6431bd007d 100644 --- a/crate/server/src/routes/mod.rs +++ b/crate/server/src/routes/mod.rs @@ -18,6 +18,7 @@ const CLI_ARCHIVE_FOLDER: &str = "./resources"; const CLI_ARCHIVE_FILE_NAME: &str = "cli.zip"; pub mod access; +pub mod azure_ekm; pub mod google_cse; pub mod health; pub mod kmip; diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index d11f8d28de..ce8aaeed57 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,20 @@ pub async fn prepare_kms_server(kms_server: Arc) -> KResult> = kms_server.params.privileged_users.clone(); // Compute the public URL first so we can use it to derive the session key @@ -756,6 +767,36 @@ 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, 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, 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" + ); + } + } + // TODO(review): propose any other cases that might have been forgotten + 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, 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::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, 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, + None, + ) + .await?; + let key_id_public = create_keys.public_key_unique_identifier.to_string(); + let key_id_private = create_keys.private_key_unique_identifier.to_string(); + warn!( + "Created RSA key pair with Public Key ID: {} and Private Key ID: {}", + key_id_public, 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_public, 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; From a226d272c68a5b5491abb7f4dc7df18276e5bf91 Mon Sep 17 00:00:00 2001 From: HatemMn <19950216+HatemMn@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:12:12 +0100 Subject: [PATCH 02/18] fix: fixes --- crate/server/src/routes/azure_ekm/handlers.rs | 12 +++++------- .../server/src/tests/azure_ekm/integration_tests.rs | 9 ++++----- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/crate/server/src/routes/azure_ekm/handlers.rs b/crate/server/src/routes/azure_ekm/handlers.rs index f09245b864..133cb8edc2 100644 --- a/crate/server/src/routes/azure_ekm/handlers.rs +++ b/crate/server/src/routes/azure_ekm/handlers.rs @@ -41,7 +41,7 @@ pub(crate) async fn get_key_metadata_handler( unique_identifier: Some(UniqueIdentifier::TextString(key_name.clone())), ..Default::default() }; - match kms.get(get_request, &user, None).await { + match kms.get(get_request, &user).await { Ok(resp) => { match resp.object { Object::SymmetricKey(_) | Object::PublicKey(_) | Object::PrivateKey(_) => { @@ -171,7 +171,6 @@ async fn get_and_validate_kek_algorithm( ..Default::default() }, user, - None, ) .await .map_err(|e| match e { @@ -333,7 +332,7 @@ async fn wrap_with_aes( ..Default::default() }; - let response = kms.encrypt(encrypt_request, user, None).await?; + let response = kms.encrypt(encrypt_request, user).await?; let wrapped_data = response .data @@ -365,7 +364,7 @@ async fn wrap_with_rsa( // let rep = kms. - let response = kms.encrypt(encrypt_request, user, None).await?; + let response = kms.encrypt(encrypt_request, user).await?; let wrapped_data = response .data @@ -451,7 +450,7 @@ async fn unwrap_with_aes( ..Default::default() }; - let response = kms.decrypt(decrypt_request, user, None).await?; + let response = kms.decrypt(decrypt_request, user).await?; let unwrapped_data = response .data @@ -481,7 +480,7 @@ async fn unwrap_with_rsa( ..Default::default() }; - let response = kms.decrypt(decrypt_request, user, None).await?; + let response = kms.decrypt(decrypt_request, user).await?; let unwrapped_data = response .data @@ -511,7 +510,6 @@ async fn get_public_exponent_from_linked_key( ..Default::default() }, user, - None, ) .await?; diff --git a/crate/server/src/tests/azure_ekm/integration_tests.rs b/crate/server/src/tests/azure_ekm/integration_tests.rs index f0fdd3d0d8..7b64be49d8 100644 --- a/crate/server/src/tests/azure_ekm/integration_tests.rs +++ b/crate/server/src/tests/azure_ekm/integration_tests.rs @@ -50,7 +50,7 @@ async fn test_wrap_unwrap_error_cases() -> KResult<()> { None, ) .unwrap(); - let create_response = kms.create(req, owner, None, None).await.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 @@ -152,7 +152,7 @@ async fn test_wrap_unwrap_error_cases() -> KResult<()> { None, ) .unwrap(); - let create_response = kms.create(req, owner, None, None).await.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 { @@ -220,7 +220,7 @@ async fn test_wrap_unwrap_roundtrip_aes256_kw() -> KResult<()> { EMPTY_TAGS, )?; - let import_response = kms.import(import_request, owner, None, None).await?; + let import_response = kms.import(import_request, owner, None).await?; let kek_id = import_response.unique_identifier.to_string(); let wrap_request = WrapKeyRequest { @@ -308,7 +308,7 @@ async fn test_wrap_unwrap_roundtrip_aes256_kwp() -> KResult<()> { ) .unwrap(); - let import_response = kms.import(import_request, owner, None, None).await?; + let import_response = kms.import(import_request, owner, None).await?; let kek_id = import_response.unique_identifier.to_string(); let wrap_request = WrapKeyRequest { @@ -362,7 +362,6 @@ async fn test_wrap_unwrap_roundtrip_rsa_oaep_256() -> KResult<()> { create_rsa_key_pair_request(None, Vec::::new(), 2048, false, None)?, owner, None, - None, ) .await?; let key_id_public = create_keys.public_key_unique_identifier.to_string(); From 6419eb978fb21790e7d2658877f639047650d81b Mon Sep 17 00:00:00 2001 From: HatemMn <19950216+HatemMn@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:47:33 +0100 Subject: [PATCH 03/18] feat: ssl fix --- crate/server/src/routes/azure_ekm/mod.rs | 29 ++++++++++++ crate/server/src/start_kms_server.rs | 58 ++++++++++++++++++++---- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/crate/server/src/routes/azure_ekm/mod.rs b/crate/server/src/routes/azure_ekm/mod.rs index b32d24edd6..e35769c9ae 100644 --- a/crate/server/src/routes/azure_ekm/mod.rs +++ b/crate/server/src/routes/azure_ekm/mod.rs @@ -43,6 +43,26 @@ fn validate_api_version(version: &str) -> Result<(), AzureEkmErrorReply> { 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")] @@ -100,6 +120,9 @@ pub(crate) async fn get_key_metadata( 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, @@ -128,6 +151,9 @@ pub(crate) async fn wrap_key( 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(); + } (timeout(Duration::from_millis(AZURE_EKM_TIMEOUT_MS), async { match wrap_key_handler(&kms, &key_name, &user, body.into_inner()).await { @@ -166,6 +192,9 @@ pub(crate) async fn unwrap_key( 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(); + } (timeout(Duration::from_millis(AZURE_EKM_TIMEOUT_MS), async { // Call implementation diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index ce8aaeed57..aa712f3568 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -681,13 +681,40 @@ 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() + ))); + } + + // Check for illegal characters (only a-z, A-Z, 0-9, /, - are allowed) + 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(); @@ -774,21 +801,32 @@ pub async fn prepare_kms_server(kms_server: Arc) -> KResult Date: Fri, 6 Feb 2026 18:39:07 +0100 Subject: [PATCH 04/18] feat: fmt fix --- crate/server/src/start_kms_server.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index aa712f3568..1121feba12 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -692,7 +692,6 @@ pub async fn prepare_kms_server(kms_server: Arc) -> KResult Date: Fri, 6 Feb 2026 18:51:43 +0100 Subject: [PATCH 05/18] feat: stuff fix --- crate/server/src/start_kms_server.rs | 4 +--- documentation/docs/azure/{ => byok}/byok.md | 0 .../docs/azure/{ => byok}/byok_create_kek.png | Bin .../{ => byok}/byok_download_kek_public_key.png | Bin .../docs/azure/{ => byok}/byok_import_jwe.png | Bin documentation/docs/azure/ekm/ekm.md | 0 documentation/docs/index.md | 2 +- documentation/mkdocs.yml | 3 ++- 8 files changed, 4 insertions(+), 5 deletions(-) rename documentation/docs/azure/{ => byok}/byok.md (100%) rename documentation/docs/azure/{ => byok}/byok_create_kek.png (100%) rename documentation/docs/azure/{ => byok}/byok_download_kek_public_key.png (100%) rename documentation/docs/azure/{ => byok}/byok_import_jwe.png (100%) create mode 100644 documentation/docs/azure/ekm/ekm.md diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index 1121feba12..0b672166df 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -800,15 +800,13 @@ pub async fn prepare_kms_server(kms_server: Arc) -> KResult Date: Fri, 6 Feb 2026 20:24:37 +0100 Subject: [PATCH 06/18] feat: add docs --- documentation/docs/azure/ekm/ekm.md | 194 ++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/documentation/docs/azure/ekm/ekm.md b/documentation/docs/azure/ekm/ekm.md index e69de29bb2..9c9964f798 100644 --- a/documentation/docs/azure/ekm/ekm.md +++ b/documentation/docs/azure/ekm/ekm.md @@ -0,0 +1,194 @@ + +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. + +## Table of content + +- [Table of content](#table-of-content) +- [Architecture Overview](#architecture-overview) +- [Api specification](#api-specification) +- [Getting started](#getting-started) + - [Azure Managed HSM Setup](#azure-managed-hsm-setup) + - [Cosmian KMS setup](#cosmian-kms-setup) + - [TLS Configuration](#tls-configuration) + - [Azure EKM Configuration](#azure-ekm-configuration) +- [Testing the integration](#testing-the-integration) + + +## Architecture Overview + +![high level arch](high_level_arch.png) + +![Workflow](sequence.svg) + + + +## Api specification + +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://{server}/azureekm/[path-prefix]/{api-specific-paths}?api-version={client-api-version} +``` + +**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 (-) + + +| 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 | + +| 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 | + +## 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. + +Save this as **`mhsm-root-ca.pem`** - We will need it in the next step. + +// TODO more info here + +### Cosmian KMS setup + +Follow the [Cosmian KMS installation guide](../../installation/installation_getting_started.md) to install the KMS server on your infrastructure. Alternatively, you can deploy a pre-configured VM using the [this page](../../installation/marketplace_guide.md). + +The KMS server typically uses the configuration file located at `/etc/cosmian/kms.toml` when installed manually with default parameters. For confidential VMs, the KMS configuration file is located in the encrypted LUKS container at `/var/lib/cosmian_vm/data/app.conf`. + +**Important:** The Azure EKM feature requires running Cosmian KMS in non-FIPS mode. + +#### TLS Configuration +Configure mutual TLS authentication to accept connections from Azure Managed HSM: + +```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" +``` + +To convert PEM certificate and key files to PKCS#12 format using `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 +``` + +## Testing the integration + +For testing purposes or for debugging, you temporarily disable client authentication: + +```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" +} +``` From a98b99d00267f5c84b23fa6bdf0623082ee5a488 Mon Sep 17 00:00:00 2001 From: HatemMn <19950216+HatemMn@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:24:50 +0100 Subject: [PATCH 07/18] feat: add docs AND IMAGE --- .../docs/azure/ekm/high_level_arch.png | Bin 0 -> 54169 bytes documentation/docs/azure/ekm/sequence.svg | 102 ++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 documentation/docs/azure/ekm/high_level_arch.png create mode 100644 documentation/docs/azure/ekm/sequence.svg 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 0000000000000000000000000000000000000000..ff77087b27aa1a72c1cf2e63f2388408e00d99be GIT binary patch literal 54169 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 literal 0 HcmV?d00001 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 From 86a012054ea0842d62ce2259b5d0ff98893ea6cb Mon Sep 17 00:00:00 2001 From: HatemMn <19950216+HatemMn@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:14:16 +0100 Subject: [PATCH 08/18] feat: finish docs and fix some more todos feat: finish docs and fix some more todos2 --- .cargo/config.toml | 3 +- crate/crypto/src/crypto/symmetric/rfc5649.rs | 109 +----------------- .../config/command_line/azure_ekm_config.rs | 2 - .../src/routes/azure_ekm/contributing.md | 1 - crate/server/src/routes/google_cse/mod.rs | 1 + crate/server/src/start_kms_server.rs | 3 +- documentation/docs/azure/ekm/ekm.md | 96 +++++++++++---- .../docs/azure/ekm/high_level_arch.png | Bin 54169 -> 261239 bytes documentation/includes.yml | 2 +- documentation/mkdocs.yml | 2 +- .../assets/stylesheets/extra.css | 2 +- 11 files changed, 86 insertions(+), 135 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 9027929ef4..be28c9bfee 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -36,6 +36,7 @@ publish-dry-crate = "publish --dry-run --allow-dirty" # "target_cpu=native", # ] -# [target.x86_64-unknown-linux-gnu] # TODO: uncomment this after dev is finished +# can increase build time for system that support mold +# [target.x86_64-unknown-linux-gnu] # linker = "clang" # rustflags = ["-C", "link-arg=-fuse-ld=mold"] diff --git a/crate/crypto/src/crypto/symmetric/rfc5649.rs b/crate/crypto/src/crypto/symmetric/rfc5649.rs index a3d28b580a..795882de86 100644 --- a/crate/crypto/src/crypto/symmetric/rfc5649.rs +++ b/crate/crypto/src/crypto/symmetric/rfc5649.rs @@ -68,7 +68,7 @@ pub fn rfc5649_wrap(plaintext: &[u8], kek: &[u8]) -> CryptoResult> { }; // Allocate output buffer with extra space for cipher_final - let mut ciphertext = vec![0_u8; padded_len + AES_BLOCK_SIZE]; + let mut ciphertext = vec![0_u8; padded_len + (AES_BLOCK_SIZE * 2)]; // Perform the key wrap operation let mut written = ctx.cipher_update(plaintext, Some(&mut ciphertext))?; @@ -99,7 +99,10 @@ pub fn rfc5649_unwrap(ciphertext: &[u8], kek: &[u8]) -> CryptoResult CryptoResult Result<(u64, Zeroizing>), CryptoError> { -// let n = ciphertext.len(); - -// if !n.is_multiple_of(AES_WRAP_PAD_BLOCK_SIZE) || n < AES_BLOCK_SIZE { -// return Err(CryptoError::InvalidSize( -// "The ciphertext size should be >= 16 and a multiple of 8".to_owned(), -// )); -// } - -// // Number of 64-bit blocks minus 1 -// let n = n / 8 - 1; - -// let mut blocks = Zeroizing::from(Vec::with_capacity(n + 1)); -// for chunk in ciphertext.chunks(AES_WRAP_PAD_BLOCK_SIZE) { -// blocks.push(u64::from_be_bytes(chunk.try_into()?)); -// } - -// // ICR stands for Integrity Check Register initially containing the IV. -// #[expect(clippy::indexing_slicing)] -// let mut icr = blocks[0]; - -// // Encrypt block using AES with ECB mode i.e. raw AES as specified in -// // RFC5649. -// // Make use of OpenSSL Crypter interface to decrypt blocks incrementally -// // without padding since RFC5649 has special padding methods. -// let mut decrypt_cipher = match kek.len() { -// 16 => Crypter::new(Cipher::aes_128_ecb(), Mode::Decrypt, kek, None)?, -// 24 => Crypter::new(Cipher::aes_192_ecb(), Mode::Decrypt, kek, None)?, -// 32 => Crypter::new(Cipher::aes_256_ecb(), Mode::Decrypt, kek, None)?, -// _ => { -// return Err(CryptoError::InvalidSize( -// "The kek size should be 16, 24 or 32".to_owned(), -// )); -// } -// }; -// decrypt_cipher.pad(false); - -// #[expect(clippy::indexing_slicing)] -// for j in (0..6).rev() { -// for (i, block) in blocks[1..].iter_mut().rev().enumerate().take(n) { -// let t = u64::try_from((n * j) + (n - i))?; - -// // B = AES-1(K, (A ^ t) | R[i]) where t = n*j+i -// let big_i = ((u128::from(icr ^ t) << 64) | u128::from(*block)).to_be_bytes(); -// let big_b = big_i.as_slice(); - -// let mut plaintext = Zeroizing::from(vec![0; big_b.len() + AES_BLOCK_SIZE]); -// let mut dec_len = decrypt_cipher.update(big_b, &mut plaintext)?; -// dec_len += decrypt_cipher.finalize(&mut plaintext)?; -// plaintext.truncate(dec_len); - -// // A = MSB(64, B) -// icr = u64::from_be_bytes( -// plaintext -// .get(0..AES_WRAP_PAD_BLOCK_SIZE) -// .ok_or_else(|| { -// CryptoError::InvalidSize( -// "Decryption output too short for IV extraction".to_owned(), -// ) -// })? -// .try_into()?, -// ); - -// // R[i] = LSB(64, B) -// *block = u64::from_be_bytes( -// plaintext[AES_WRAP_PAD_BLOCK_SIZE..AES_WRAP_PAD_BLOCK_SIZE * 2].try_into()?, -// ); -// } -// } - -// let mut unwrapped_key = Zeroizing::from(Vec::with_capacity((blocks.len() - 1) * 8)); -// for block in blocks -// .get(1..) -// .ok_or_else(|| CryptoError::IndexingSlicing("Block index issue".to_owned()))? -// { -// unwrapped_key.extend(block.to_be_bytes()); -// } - -// Ok((icr, unwrapped_key)) -// } - #[expect(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] #[cfg(test)] mod tests { diff --git a/crate/server/src/config/command_line/azure_ekm_config.rs b/crate/server/src/config/command_line/azure_ekm_config.rs index 129f2c6003..d805e5a153 100644 --- a/crate/server/src/config/command_line/azure_ekm_config.rs +++ b/crate/server/src/config/command_line/azure_ekm_config.rs @@ -17,7 +17,6 @@ pub struct AzureEkmConfig { /// /// 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 (-). - /// TODO: validate allowed characters #[clap(long, env = "KMS_AZURE_EKM_PATH_PREFIX", verbatim_doc_comment)] #[serde(skip_serializing_if = "Option::is_none")] pub azure_ekm_path_prefix: Option, @@ -52,7 +51,6 @@ pub struct AzureEkmConfig { pub azure_ekm_ekm_vendor: String, // double "ekm" is intentional /// Product Name and Version of the EKMS to report in the /info endpoint. - /// TODO: refer to page 12 of the specs to assert this is the correct default value #[clap( long, env = "KMS_AZURE_EKM_PRODUCT", diff --git a/crate/server/src/routes/azure_ekm/contributing.md b/crate/server/src/routes/azure_ekm/contributing.md index ef38d83684..6670995638 100644 --- a/crate/server/src/routes/azure_ekm/contributing.md +++ b/crate/server/src/routes/azure_ekm/contributing.md @@ -5,5 +5,4 @@ ## Development guidelines -- For some reason, code editors might suggest to import the `cosmian_kmip` imports from the crate `cosmian_kms_client_utils`. Do not import from there, use that same crate `cosmian_kms_server_database` (mod.rs, line 9). - Separate handlers to `handlers.rs` to ease out testing the API. diff --git a/crate/server/src/routes/google_cse/mod.rs b/crate/server/src/routes/google_cse/mod.rs index 1138066877..95c9429f3f 100644 --- a/crate/server/src/routes/google_cse/mod.rs +++ b/crate/server/src/routes/google_cse/mod.rs @@ -95,6 +95,7 @@ pub(crate) async fn get_status( info!("GET /google_cse/status {}", kms.get_user(&req)); let google_cse_kacls_url = build_google_cse_url(kms.params.kms_public_url.as_deref())?; + Ok(Json(operations::get_status(&google_cse_kacls_url))) } diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index 0b672166df..9ecf3ad742 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -806,8 +806,7 @@ pub async fn prepare_kms_server(kms_server: Arc) -> KResult -- [Table of content](#table-of-content) -- [Architecture Overview](#architecture-overview) +- [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) @@ -18,14 +19,45 @@ The Cosmian KMS implementation follows and implements the Microsoft EKM Proxy AP - [Testing the integration](#testing-the-integration) -## Architecture Overview +## 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 : -![high level arch](high_level_arch.png) -![Workflow](sequence.svg) +![Sequence diagram: Using an Azure Service with keys saved on customer's infrastructure](sequence.svg) @@ -58,7 +58,7 @@ The following diagram illustrates a possible use case where Cosmian KMS acts as ![Sequence diagram: Using an Azure Service with keys saved on customer's infrastructure](sequence.svg)