From 28c3b212f449ab92604f89ebce9e1ef6695bdc57 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Fri, 23 Oct 2026 11:16:58 +0200 Subject: [PATCH 01/20] fix(server): support hybrid KEM keys wrapped by an HSM key encryption key --- CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md | 12 + crate/crypto/src/crypto/wrap/tests.rs | 89 +++- crate/crypto/src/crypto/wrap/unwrap_key.rs | 4 +- crate/crypto/src/crypto/wrap/wrap_key.rs | 16 + .../server/src/core/operations/export_get.rs | 22 +- crate/server/src/tests/hsm/hybrid_kem_dek.rs | 403 ++++++++++++++++++ crate/server/src/tests/hsm/mod.rs | 6 + 7 files changed, 531 insertions(+), 21 deletions(-) create mode 100644 CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md create mode 100644 crate/server/src/tests/hsm/hybrid_kem_dek.rs diff --git a/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md new file mode 100644 index 0000000000..d63da2a59a --- /dev/null +++ b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md @@ -0,0 +1,12 @@ +# HSM-wrapped hybrid KEM key lifecycle + +## Bug Fixes + +- Hybrid KEM keys (`ConfigurableKEM` and native `X25519MLKEM768` / `X448MLKEM1024`) now work when the server `key_encryption_key` is stored in an HSM: `ckms pqc keys create -a ml-kem-768-p256` no longer fails with `Unsupported key format type: ConfigurableKEMSecretKey`. + - Wrap: `ByteString`-only `Raw`, `ConfigurableKEM*` and `CoverCrypt*` asymmetric keys are wrapped as raw bytes instead of going through OpenSSL (Create, Import, ReKeyKeyPair, Export with a `KeyWrappingSpecification`). + - Unwrap: `ConfigurableKEM*` keys are decoded on unwrap (Encrypt/Decrypt after a restart, `default_unwrap_type`, Export/Import with `NotWrapped`). + - Export: a PQC/hybrid KEM key can be exported with its own stored `KeyFormatType`. + +## Testing + +- Add a SoftHSM2-backed hybrid KEM lifecycle test (`tests::hsm::hybrid_kem_dek`) and a wrap/unwrap unit test for `ByteString`-only KEM keys. diff --git a/crate/crypto/src/crypto/wrap/tests.rs b/crate/crypto/src/crypto/wrap/tests.rs index 1c7f398aea..1140c24099 100644 --- a/crate/crypto/src/crypto/wrap/tests.rs +++ b/crate/crypto/src/crypto/wrap/tests.rs @@ -2,7 +2,11 @@ use cosmian_kmip::kmip_2_1::extra::fips::FIPS_PUBLIC_RSA_MASK; #[cfg(feature = "non-fips")] use cosmian_kmip::kmip_2_1::{ - kmip_data_structures::KeyWrappingData, kmip_objects::Object, kmip_types::EncodingOption, + kmip_data_structures::KeyWrappingData, + kmip_objects::Object, + kmip_types::{ + CryptographicDomainParameters, CryptographicParameters, EncodingOption, RecommendedCurve, + }, }; use cosmian_kmip::{ kmip_0::kmip_types::CryptographicUsageMask, @@ -27,9 +31,11 @@ use openssl::{pkey::PKey, rsa::Rsa}; #[cfg(feature = "non-fips")] use crate::crypto::elliptic_curves::operation::create_x25519_key_pair; +#[cfg(feature = "non-fips")] +use crate::crypto::{kem::kem_keygen, pqc::hybrid_kem::create_hybrid_kem_key_pair}; use crate::{ crypto::{ - KmsRng, + KeyPairIdentity, KmsRng, wrap::{unwrap_key_block, wrap_object_with_key}, }, crypto_bail, @@ -464,3 +470,82 @@ fn test_encrypt_decrypt_ec_p384() -> CryptoResult<()> { assert_eq!(data_encryption_key.key_block()?, &original_key_block); Ok(()) } + +/// ByteString-only asymmetric keys (`ConfigurableKEM`, native hybrid KEM) have no OpenSSL +/// representation: they must wrap/unwrap as raw bytes. +#[cfg(feature = "non-fips")] +#[test] +fn test_wrap_unwrap_byte_string_kem_keys() -> CryptoResult<()> { + let mut sym_wrapping_key_bytes = vec![0; 32]; + KmsRng::new().fill_bytes(&mut sym_wrapping_key_bytes)?; + let sym_key = create_symmetric_key_kmip_object( + VENDOR_ID_COSMIAN, + sym_wrapping_key_bytes.as_slice(), + &Attributes { + cryptographic_algorithm: Some(CryptographicAlgorithm::AES), + ..Default::default() + }, + )?; + + let common = Attributes { + cryptographic_domain_parameters: Some(CryptographicDomainParameters { + qlength: Some(256), + recommended_curve: Some(RecommendedCurve::P256), + }), + cryptographic_parameters: Some(CryptographicParameters { + cryptographic_algorithm: Some(CryptographicAlgorithm::MLKEM_768), + ..Default::default() + }), + ..Default::default() + }; + let mut configurable = kem_keygen( + VENDOR_ID_COSMIAN, + "dk".to_owned(), + None, + "ek".to_owned(), + None, + common, + )?; + let mut native = create_hybrid_kem_key_pair( + CryptographicAlgorithm::X25519MLKEM768, + KeyPairIdentity { + vendor_id: VENDOR_ID_COSMIAN, + private_key_uid: "sk", + public_key_uid: "pk", + common_attributes: Attributes::default(), + private_key_attributes: None, + public_key_attributes: None, + }, + None, + )?; + + for i in 0..4 { + let pair = if i < 2 { + &mut configurable + } else { + &mut native + }; + let obj = if i % 2 == 0 { + pair.private_key_mut() + } else { + pair.public_key_mut() + }; + let bytes = obj.key_block()?.key_bytes()?; + let format = obj.key_block()?.key_format_type; + wrap_object_with_key( + obj, + &sym_key, + &KeyWrappingSpecification { + encoding_option: Some(EncodingOption::NoEncoding), + ..Default::default() + }, + &KmsRng::new(), + )?; + assert!(obj.is_wrapped()); + unwrap_key_block(obj.key_block_mut()?, &sym_key)?; + assert_eq!(obj.key_block()?.key_bytes()?, bytes); + assert_eq!(obj.key_block()?.key_format_type, format); + assert!(obj.key_block()?.key_wrapping_data.is_none()); + } + Ok(()) +} diff --git a/crate/crypto/src/crypto/wrap/unwrap_key.rs b/crate/crypto/src/crypto/wrap/unwrap_key.rs index 90f2000fc2..4d93e14376 100644 --- a/crate/crypto/src/crypto/wrap/unwrap_key.rs +++ b/crate/crypto/src/crypto/wrap/unwrap_key.rs @@ -226,7 +226,9 @@ pub fn decode_unwrapped_key( | KeyFormatType::PKCS8 | KeyFormatType::X509 | KeyFormatType::CoverCryptSecretKey - | KeyFormatType::CoverCryptPublicKey => { + | KeyFormatType::CoverCryptPublicKey + | KeyFormatType::ConfigurableKEMSecretKey + | KeyFormatType::ConfigurableKEMPublicKey => { // For no encoding, create a structure with the plaintext as bytes let key_material = KeyMaterial::ByteString(plaintext); Ok(KeyValue::Structure { diff --git a/crate/crypto/src/crypto/wrap/wrap_key.rs b/crate/crypto/src/crypto/wrap/wrap_key.rs index 447af6e8ae..68b9f43928 100644 --- a/crate/crypto/src/crypto/wrap/wrap_key.rs +++ b/crate/crypto/src/crypto/wrap/wrap_key.rs @@ -210,6 +210,22 @@ pub fn key_data_to_wrap( Object::SecretData(SecretData { key_block, .. }) => key_block .key_bytes() .context("cannot recover secret data bytes ")?, + Object::PrivateKey(PrivateKey { key_block }) + | Object::PublicKey(PublicKey { key_block }) + if matches!( + key_block.key_format_type, + KeyFormatType::Raw + | KeyFormatType::ConfigurableKEMSecretKey + | KeyFormatType::ConfigurableKEMPublicKey + | KeyFormatType::CoverCryptSecretKey + | KeyFormatType::CoverCryptPublicKey + ) => + { + // No OpenSSL representation for these formats: wrap the stored bytes as-is + key_block + .key_bytes() + .context("cannot recover asymmetric key bytes ")? + } Object::PrivateKey(..) => { let pkey = kmip_private_key_to_openssl(object)?; Zeroizing::new(pkey.private_key_to_pkcs8()?) diff --git a/crate/server/src/core/operations/export_get.rs b/crate/server/src/core/operations/export_get.rs index 3c53ffe690..dd5921ad65 100644 --- a/crate/server/src/core/operations/export_get.rs +++ b/crate/server/src/core/operations/export_get.rs @@ -520,15 +520,8 @@ async fn post_process_active_private_key( #[cfg(feature = "non-fips")] if is_pqc_algorithm(key_block.cryptographic_algorithm) { let stored_fmt = key_block.key_format_type; - if key_format_type.is_some() - && !matches!( - key_format_type, - Some(KeyFormatType::PKCS8 | KeyFormatType::Raw) - ) - { - kms_bail!("export: PQC keys only support PKCS#8 or Raw format") - } - // Convert PKCS#8 → Raw when requested + // Convert PKCS#8 → Raw when requested; any other format must be the stored one + // (these keys have no OpenSSL round-trip) if let Some(requested) = key_format_type { if *requested == KeyFormatType::Raw && stored_fmt == KeyFormatType::PKCS8 { let key_bytes = key_block.key_bytes()?; @@ -836,15 +829,8 @@ async fn process_public_key( #[cfg(feature = "non-fips")] if is_pqc_algorithm(key_block.cryptographic_algorithm) { let stored_fmt = key_block.key_format_type; - if key_format_type.is_some() - && !matches!( - key_format_type, - Some(KeyFormatType::PKCS8 | KeyFormatType::Raw) - ) - { - kms_bail!("export: PQC keys only support PKCS#8 or Raw format") - } - // Convert SPKI (stored as PKCS8 format type) → Raw when requested + // Convert SPKI (stored as PKCS8 format type) → Raw when requested; any other format + // must be the stored one (these keys have no OpenSSL round-trip) if let Some(requested) = key_format_type { if *requested == KeyFormatType::Raw && stored_fmt == KeyFormatType::PKCS8 { let key_bytes = key_block.key_bytes()?; diff --git a/crate/server/src/tests/hsm/hybrid_kem_dek.rs b/crate/server/src/tests/hsm/hybrid_kem_dek.rs new file mode 100644 index 0000000000..60b9510001 --- /dev/null +++ b/crate/server/src/tests/hsm/hybrid_kem_dek.rs @@ -0,0 +1,403 @@ +//! Lifecycle of hybrid KEM keys (`ConfigurableKEM` and native `X25519MLKEM768`) under an HSM KEK. +//! These keys are `ByteString`-only: they have no OpenSSL representation. +use std::{path::Path, sync::Arc}; + +use cosmian_kms_client_utils::{ + configurable_kem_utils::{KemAlgorithm, build_create_configurable_kem_keypair_request}, + reexport::cosmian_kmip::kmip_2_1::kmip_objects::{Object, ObjectType}, +}; +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::{KeyWrapType, RevocationReason, RevocationReasonCode}, + kmip_2_1::{ + extra::tagging::VENDOR_ID_COSMIAN, + kmip_attributes::Attributes, + kmip_operations::{ + Activate, CreateKeyPair, Destroy, Export, GetAttributes, Operation, ReKeyKeyPair, + Revoke, + }, + kmip_types::{CryptographicAlgorithm, KeyFormatType, UniqueIdentifier}, + requests::{create_pqc_key_pair_request, decrypt_request, encrypt_request}, + }, + }, + cosmian_kms_crypto::reexport::cosmian_crypto_core::bytes_ser_de::Serializable, +}; +use uuid::Uuid; +use zeroize::Zeroizing; + +use crate::{ + config::ServerParams, + core::KMS, + error::KmsError, + middlewares::UserId, + result::KResult, + tests::{ + hsm::{ + create_kek, delete_key, export_object, hsm_clap_config, import_object, locate_keys, + send_message, + }, + test_utils::get_tmp_sqlite_path, + }, +}; + +#[derive(Clone, Copy)] +enum Alg { + MlKem512P256, + MlKem768P256, + X25519MlKem768, +} + +impl Alg { + /// `ConfigurableKEM` private keys default to sensitive, which denies exporting them + /// without a user wrapping key: ask for non-sensitive keys. + fn create_request(self, tag: &str) -> KResult { + let mut request = self.base_request(tag)?; + request.common_attributes.get_or_insert_default().sensitive = Some(false); + Ok(request) + } + + fn base_request(self, tag: &str) -> KResult { + Ok(match self { + Self::MlKem512P256 => build_create_configurable_kem_keypair_request( + VENDOR_ID_COSMIAN, + None, + [tag], + KemAlgorithm::MlKem512P256, + false, + None, + ) + .map_err(|e| KmsError::Default(e.to_string()))?, + Self::MlKem768P256 => build_create_configurable_kem_keypair_request( + VENDOR_ID_COSMIAN, + None, + [tag], + KemAlgorithm::MlKem768P256, + false, + None, + ) + .map_err(|e| KmsError::Default(e.to_string()))?, + Self::X25519MlKem768 => create_pqc_key_pair_request( + VENDOR_ID_COSMIAN, + [tag], + CryptographicAlgorithm::X25519MLKEM768, + false, + )?, + }) + } + + /// Stored algorithm, private key format, public key format + const fn stored(self) -> (CryptographicAlgorithm, KeyFormatType, KeyFormatType) { + match self { + Self::MlKem512P256 | Self::MlKem768P256 => ( + CryptographicAlgorithm::ConfigurableKEM, + KeyFormatType::ConfigurableKEMSecretKey, + KeyFormatType::ConfigurableKEMPublicKey, + ), + Self::X25519MlKem768 => ( + CryptographicAlgorithm::X25519MLKEM768, + KeyFormatType::Raw, + KeyFormatType::Raw, + ), + } + } +} + +pub(super) async fn test_hybrid_kem_lifecycle() -> KResult<()> { + for alg in [Alg::MlKem512P256, Alg::MlKem768P256, Alg::X25519MlKem768] { + Box::pin(lifecycle(alg)).await?; + } + Ok(()) +} + +async fn restart( + owner: &str, + kek_uuid: Uuid, + sqlite_path: &Path, + default_unwrap: bool, +) -> KResult> { + let mut clap_config = hsm_clap_config(owner, Some(kek_uuid))?; + clap_config.db.sqlite_path = sqlite_path.to_path_buf(); + if default_unwrap { + clap_config.default_unwrap_type = + Some(vec!["PrivateKey".to_owned(), "PublicKey".to_owned()]); + } + Ok(Arc::new( + KMS::instantiate(Arc::new(ServerParams::try_from(clap_config)?)).await?, + )) +} + +/// Returns (shared secret, encapsulation) +async fn encapsulate(kms: &Arc, owner: &str, pk_uid: &str) -> KResult<(Vec, Vec)> { + let request = encrypt_request(pk_uid, None, Vec::new(), None, None, None)?; + let response = send_message( + kms.clone(), + owner, + vec![Operation::Encrypt(Box::new(request))], + ) + .await?; + let Operation::EncryptResponse(response) = &response[0] else { + return Err(KmsError::ServerError("invalid response".to_owned())); + }; + let data = response + .data + .clone() + .ok_or_else(|| KmsError::ServerError("empty encapsulation data".to_owned()))?; + if let Some(enc) = response.i_v_counter_nonce.clone() { + // native hybrid KEM + return Ok((data, enc)); + } + // ConfigurableKEM: serialized (shared secret, encapsulation) + let (secret, enc) = <(Zeroizing>, Zeroizing>)>::deserialize(&data) + .map_err(|e| KmsError::Default(e.to_string()))?; + Ok((secret.to_vec(), enc.to_vec())) +} + +async fn decapsulate(kms: &Arc, owner: &str, sk_uid: &str, enc: &[u8]) -> KResult> { + let request = decrypt_request(sk_uid, None, enc.to_vec(), None, None, None); + let response = send_message( + kms.clone(), + owner, + vec![Operation::Decrypt(Box::new(request))], + ) + .await?; + let Operation::DecryptResponse(response) = &response[0] else { + return Err(KmsError::ServerError("invalid response".to_owned())); + }; + Ok(response + .data + .as_ref() + .ok_or_else(|| KmsError::ServerError("empty decapsulated secret".to_owned()))? + .to_vec()) +} + +async fn assert_roundtrip(kms: &Arc, owner: &str, pk: &str, sk: &str) -> KResult<()> { + let (secret, enc) = encapsulate(kms, owner, pk).await?; + assert!(!secret.is_empty()); + assert_eq!(secret, decapsulate(kms, owner, sk, &enc).await?); + Ok(()) +} + +/// Imported objects are `PreActive` +async fn activate(kms: &Arc, owner: &str, uid: &str) -> KResult<()> { + send_message( + kms.clone(), + owner, + vec![Operation::Activate(Activate { + unique_identifier: UniqueIdentifier::TextString(uid.to_owned()), + })], + ) + .await?; + Ok(()) +} + +async fn get_attributes( + kms: &Arc, + owner: &str, + uid: &str, + algorithm: CryptographicAlgorithm, +) -> KResult<()> { + let response = send_message( + kms.clone(), + owner, + vec![Operation::GetAttributes(GetAttributes { + unique_identifier: Some(UniqueIdentifier::TextString(uid.to_owned())), + attribute_reference: None, + })], + ) + .await?; + let Operation::GetAttributesResponse(response) = &response[0] else { + return Err(KmsError::ServerError("invalid response".to_owned())); + }; + assert_eq!(response.attributes.cryptographic_algorithm, Some(algorithm)); + Ok(()) +} + +async fn export_with( + kms: &Arc, + owner: &str, + uid: &str, + key_wrap_type: Option, + key_format_type: Option, +) -> KResult { + Ok(kms + .export( + Export { + unique_identifier: Some(UniqueIdentifier::TextString(uid.to_owned())), + key_format_type, + key_compression_type: None, + key_wrap_type, + key_wrapping_specification: None, + }, + &UserId::from(owner), + ) + .await? + .object) +} + +fn uid_string(uid: &UniqueIdentifier) -> KResult { + uid.as_str() + .map(ToOwned::to_owned) + .ok_or_else(|| KmsError::ServerError("unexpected unique identifier".to_owned())) +} + +fn assert_wrapped_by(object: &Object, kek_uid: &str) -> KResult<()> { + assert!(object.is_wrapped()); + let kek = object + .key_block()? + .key_wrapping_data + .as_ref() + .and_then(|d| d.encryption_key_information.as_ref()) + .map(|i| i.unique_identifier.clone()); + assert_eq!(kek, Some(UniqueIdentifier::TextString(kek_uid.to_owned()))); + Ok(()) +} + +/// Revoke then destroy `uid` alone (no cascade along the key links) +async fn revoke_and_destroy(uid: &str, owner: &str, kms: &Arc) -> KResult<()> { + let uid = UniqueIdentifier::TextString(uid.to_owned()); + send_message( + kms.clone(), + owner, + vec![Operation::Revoke(Revoke { + unique_identifier: Some(uid.clone()), + revocation_reason: RevocationReason { + revocation_reason_code: RevocationReasonCode::Unspecified, + revocation_message: None, + }, + compromise_occurrence_date: None, + cascade: false, + })], + ) + .await?; + send_message( + kms.clone(), + owner, + vec![Operation::Destroy(Destroy { + unique_identifier: Some(uid), + remove: true, + cascade: false, + expected_object_type: None, + })], + ) + .await?; + Ok(()) +} + +async fn lifecycle(alg: Alg) -> KResult<()> { + let kek_uuid = Uuid::new_v4(); + let owner = Uuid::new_v4().to_string(); + let tag = Uuid::new_v4().to_string(); + let sqlite_path = get_tmp_sqlite_path(); + let (stored_algorithm, sk_format, pk_format) = alg.stored(); + + // 1. Create: the server KEK wraps the new pair + let kms = restart(&owner, kek_uuid, &sqlite_path, false).await?; + let kek_uid = hsm_clap_config(&owner, Some(kek_uuid))? + .key_encryption_key + .ok_or_else(|| KmsError::Default("Missing KEK".to_owned()))?; + create_kek(&kek_uid, &owner, &kms).await?; + let response = send_message( + kms.clone(), + &owner, + vec![Operation::CreateKeyPair(Box::new( + alg.create_request(&tag)?, + ))], + ) + .await?; + let Operation::CreateKeyPairResponse(response) = &response[0] else { + return Err(KmsError::ServerError("invalid response".to_owned())); + }; + let sk = uid_string(&response.private_key_unique_identifier)?; + let pk = uid_string(&response.public_key_unique_identifier)?; + assert_wrapped_by(&export_object(&kms, &owner, &sk).await?, &kek_uid)?; + assert_wrapped_by(&export_object(&kms, &owner, &pk).await?, &kek_uid)?; + assert_roundtrip(&kms, &owner, &pk, &sk).await?; + + // 2. Restart: cold cache, the HSM unwraps + drop(kms); + let kms = restart(&owner, kek_uuid, &sqlite_path, false).await?; + assert_roundtrip(&kms, &owner, &pk, &sk).await?; + get_attributes(&kms, &owner, &sk, stored_algorithm).await?; + let mut attributes = Attributes::default(); + attributes.set_tags(VENDOR_ID_COSMIAN, [tag.as_str()])?; + let located = locate_keys(&owner, &kms, Some(attributes)).await?; + assert!(located.contains(&UniqueIdentifier::TextString(sk.clone()))); + assert!(located.contains(&UniqueIdentifier::TextString(pk.clone()))); + + // 3. Export: only the stored format can be returned + let not_wrapped = Some(KeyWrapType::NotWrapped); + let plain_sk = export_with(&kms, &owner, &sk, not_wrapped, None).await?; + assert!(!plain_sk.is_wrapped()); + assert_eq!(plain_sk.key_block()?.key_format_type, sk_format); + let plain_pk = export_with(&kms, &owner, &pk, not_wrapped, None).await?; + assert!(!plain_pk.is_wrapped()); + assert_eq!(plain_pk.key_block()?.key_format_type, pk_format); + export_with(&kms, &owner, &sk, not_wrapped, Some(sk_format)).await?; + export_with(&kms, &owner, &pk, not_wrapped, Some(pk_format)).await?; + export_with( + &kms, + &owner, + &sk, + not_wrapped, + Some(KeyFormatType::TransparentECPrivateKey), + ) + .await + .unwrap_err(); + let wrapped_sk = export_object(&kms, &owner, &sk).await?; + assert!(wrapped_sk.is_wrapped()); + + // 4. Import: plain objects are wrapped by the server KEK, wrapped ones are stored as-is + let sk_imp = format!("{sk}-imp"); + let pk_imp = format!("{pk}-imp"); + let sk_impw = format!("{sk}-impw"); + import_object(&kms, &owner, &sk_imp, &plain_sk, ObjectType::PrivateKey).await?; + import_object(&kms, &owner, &pk_imp, &plain_pk, ObjectType::PublicKey).await?; + import_object(&kms, &owner, &sk_impw, &wrapped_sk, ObjectType::PrivateKey).await?; + for uid in [&sk_imp, &pk_imp, &sk_impw] { + activate(&kms, &owner, uid).await?; + assert!(export_object(&kms, &owner, uid).await?.is_wrapped()); + } + + // 5. Restart with default_unwrap_type: reads return plaintext keys + drop(kms); + let kms = restart(&owner, kek_uuid, &sqlite_path, true).await?; + assert!(!export_object(&kms, &owner, &sk).await?.is_wrapped()); + get_attributes(&kms, &owner, &sk, stored_algorithm).await?; + assert_roundtrip(&kms, &owner, &pk_imp, &sk_imp).await?; + let (secret, enc) = encapsulate(&kms, &owner, &pk).await?; + assert_eq!(secret, decapsulate(&kms, &owner, &sk_impw, &enc).await?); + + // 6. ReKeyKeyPair: the replacement pair is wrapped by the server KEK + let response = send_message( + kms.clone(), + &owner, + vec![Operation::ReKeyKeyPair(Box::new(ReKeyKeyPair { + private_key_unique_identifier: Some(UniqueIdentifier::TextString(sk.clone())), + ..Default::default() + }))], + ) + .await?; + let Operation::ReKeyKeyPairResponse(response) = &response[0] else { + return Err(KmsError::ServerError("invalid response".to_owned())); + }; + let sk2 = uid_string(&response.private_key_unique_identifier)?; + let pk2 = uid_string(&response.public_key_unique_identifier)?; + drop(kms); + let kms = restart(&owner, kek_uuid, &sqlite_path, false).await?; + assert_wrapped_by(&export_object(&kms, &owner, &sk2).await?, &kek_uid)?; + assert_roundtrip(&kms, &owner, &pk2, &sk2).await?; + + // 7. Revoke + Destroy each key on its own: a cascade follows the key links, and the + // imported keys link to the originals. + let all = [&sk_impw, &sk_imp, &pk_imp, &sk, &pk, &sk2, &pk2]; + for uid in all { + revoke_and_destroy(uid, &owner, &kms).await?; + } + let remaining = locate_keys(&owner, &kms, None).await?; + for uid in all { + assert!(!remaining.contains(&UniqueIdentifier::TextString(uid.clone()))); + } + delete_key(&kek_uid, &owner, &kms).await?; + export_object(&kms, &owner, &sk).await.unwrap_err(); + Ok(()) +} diff --git a/crate/server/src/tests/hsm/mod.rs b/crate/server/src/tests/hsm/mod.rs index dea51ff517..3854a409f2 100644 --- a/crate/server/src/tests/hsm/mod.rs +++ b/crate/server/src/tests/hsm/mod.rs @@ -48,6 +48,8 @@ use crate::{ mod ec_dek; #[cfg(feature = "non-fips")] mod eddsa_sign; +#[cfg(feature = "non-fips")] +mod hybrid_kem_dek; mod issues; mod multi_hsm; mod permissions; @@ -83,6 +85,10 @@ async fn test_hsm_all() { Box::pin(ec_dek::test_wrapped_ec_dek()).await.unwrap(); info!("HSM: eddsa_sign"); Box::pin(eddsa_sign::test_hsm_eddsa_sign()).await.unwrap(); + info!("HSM: hybrid_kem_lifecycle"); + Box::pin(hybrid_kem_dek::test_hybrid_kem_lifecycle()) + .await + .unwrap(); } info!("HSM: non_admin_kek_wrapping (issue #761)"); From a27a3a4d9e3a4ee6dbcd4cff4a9535a3e35d03db Mon Sep 17 00:00:00 2001 From: Manuthor Date: Fri, 23 Oct 2026 11:38:05 +0200 Subject: [PATCH 02/20] fix(crypto): gate KeyPairIdentity test import under non-fips --- crate/crypto/src/crypto/wrap/tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crate/crypto/src/crypto/wrap/tests.rs b/crate/crypto/src/crypto/wrap/tests.rs index 1140c24099..fae2cc4d2e 100644 --- a/crate/crypto/src/crypto/wrap/tests.rs +++ b/crate/crypto/src/crypto/wrap/tests.rs @@ -32,10 +32,12 @@ use openssl::{pkey::PKey, rsa::Rsa}; #[cfg(feature = "non-fips")] use crate::crypto::elliptic_curves::operation::create_x25519_key_pair; #[cfg(feature = "non-fips")] -use crate::crypto::{kem::kem_keygen, pqc::hybrid_kem::create_hybrid_kem_key_pair}; +use crate::crypto::{ + KeyPairIdentity, kem::kem_keygen, pqc::hybrid_kem::create_hybrid_kem_key_pair, +}; use crate::{ crypto::{ - KeyPairIdentity, KmsRng, + KmsRng, wrap::{unwrap_key_block, wrap_object_with_key}, }, crypto_bail, From 02149d151f6ca6c62e0d092658481553e9fe64b5 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Fri, 23 Oct 2026 12:51:18 +0200 Subject: [PATCH 03/20] fix(deps): bump ctutils to 0.4.4 (RUSTSEC-2026-0385) --- Cargo.lock | 4 ++-- supply-chain/config.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 82eb43dea7..5e057fb6a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2337,9 +2337,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865" dependencies = [ "cmov", "subtle", diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 77b988653c..92407a5aee 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -504,7 +504,7 @@ version = "0.9.2" criteria = "safe-to-deploy" [[exemptions.ctutils]] -version = "0.4.2" +version = "0.4.4" criteria = "safe-to-deploy" [[exemptions.curve25519-dalek]] From 885b414345484a739424064a057a759dd67d41ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 12:05:10 +0000 Subject: [PATCH 04/20] build: update Nix vendor hashes (macOS) [skip ci] --- nix/expected-hashes/cli.vendor.dynamic.darwin.sha256 | 2 +- nix/expected-hashes/cli.vendor.static.darwin.sha256 | 2 +- nix/expected-hashes/server.vendor.dynamic.sha256 | 2 +- nix/expected-hashes/server.vendor.static.sha256 | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/expected-hashes/cli.vendor.dynamic.darwin.sha256 b/nix/expected-hashes/cli.vendor.dynamic.darwin.sha256 index 91dee19e2a..fe0dd508e2 100644 --- a/nix/expected-hashes/cli.vendor.dynamic.darwin.sha256 +++ b/nix/expected-hashes/cli.vendor.dynamic.darwin.sha256 @@ -1 +1 @@ -sha256-XDAPdTRcOx+8NROC0nC2x7pSn0XWep7YiP3sL4Ambao= +sha256-+5rLtI6cKpL0ihqDWuOH807oKh68BI7D0CexPz+1ByY= diff --git a/nix/expected-hashes/cli.vendor.static.darwin.sha256 b/nix/expected-hashes/cli.vendor.static.darwin.sha256 index 97181b484f..7fa09ebed4 100644 --- a/nix/expected-hashes/cli.vendor.static.darwin.sha256 +++ b/nix/expected-hashes/cli.vendor.static.darwin.sha256 @@ -1 +1 @@ -sha256-1Wnvz3AnfkwMYmq0pJQ79zE4DC8isGUFb+FL11jg5sw= +sha256-9CBR0sVW0Q+R3Sgg0idwqYf8zBxgKsZvGlnb2Mgfa/A= diff --git a/nix/expected-hashes/server.vendor.dynamic.sha256 b/nix/expected-hashes/server.vendor.dynamic.sha256 index 1f664b7698..4e6acc78c4 100644 --- a/nix/expected-hashes/server.vendor.dynamic.sha256 +++ b/nix/expected-hashes/server.vendor.dynamic.sha256 @@ -1 +1 @@ -sha256-ct5euifPfGAZXMjAGiiAOcHkgJnDc+sKRRX9ItleyFk= +sha256-seRoGjKPE1rwAAoyzHthOosHDejTrYiEMsHyUSydFLk= diff --git a/nix/expected-hashes/server.vendor.static.sha256 b/nix/expected-hashes/server.vendor.static.sha256 index 74f3cd9f1d..1294342d29 100644 --- a/nix/expected-hashes/server.vendor.static.sha256 +++ b/nix/expected-hashes/server.vendor.static.sha256 @@ -1 +1 @@ -sha256-8voylxCsnMfnirRcA7AMHSVMhh3uvGNnu6b0jtfrhrI= +sha256-Fl3a5j8kOYaSte3lh7HIA0Jr5SmeL3utR7Reu/8e97E= From e5a55dc09463c0c774b72131db4fc6219c9e07ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 13:10:25 +0000 Subject: [PATCH 05/20] build: update Nix vendor hashes (Linux) [skip ci] --- nix/expected-hashes/cli.vendor.linux.sha256 | 2 +- nix/expected-hashes/cli.vendor.musl-fips.sha256 | 2 +- nix/expected-hashes/cli.vendor.musl-non-fips.sha256 | 2 +- nix/expected-hashes/k8s-csi-provider.vendor.linux.sha256 | 2 +- nix/expected-hashes/k8s-operator.vendor.linux.sha256 | 2 +- nix/expected-hashes/k8s-plugin.vendor.linux.sha256 | 2 +- nix/expected-hashes/server.vendor.musl-fips.sha256 | 2 +- nix/expected-hashes/server.vendor.musl-non-fips.sha256 | 2 +- nix/expected-hashes/ui.vendor.fips.sha256 | 2 +- nix/expected-hashes/ui.vendor.non-fips.sha256 | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/nix/expected-hashes/cli.vendor.linux.sha256 b/nix/expected-hashes/cli.vendor.linux.sha256 index c5b6988074..4716f2f5ba 100644 --- a/nix/expected-hashes/cli.vendor.linux.sha256 +++ b/nix/expected-hashes/cli.vendor.linux.sha256 @@ -1 +1 @@ -sha256-SxXbx8p8Gl9mmHDPYIjdpd55xXUx4zUwlXpj42OQCt0= +sha256-26MDHMBPqWG2nmwZnQ7j5V8pYqu+BxNnOAprcUlVPrU= diff --git a/nix/expected-hashes/cli.vendor.musl-fips.sha256 b/nix/expected-hashes/cli.vendor.musl-fips.sha256 index d195bcb076..93201626db 100644 --- a/nix/expected-hashes/cli.vendor.musl-fips.sha256 +++ b/nix/expected-hashes/cli.vendor.musl-fips.sha256 @@ -1 +1 @@ -sha256-5KgphSrXiH/BDItXI0bkVVEYCPfOIYVShUQb6IHp0NA= +sha256-vh/qRIv3c2qnjByB9vSaH3Ph0uxq7Qk/ObKmA/zYZFY= diff --git a/nix/expected-hashes/cli.vendor.musl-non-fips.sha256 b/nix/expected-hashes/cli.vendor.musl-non-fips.sha256 index 3e5150a2ed..2fed3af870 100644 --- a/nix/expected-hashes/cli.vendor.musl-non-fips.sha256 +++ b/nix/expected-hashes/cli.vendor.musl-non-fips.sha256 @@ -1 +1 @@ -sha256-O1EzS5uXMfSWRMGy0AJZHC6IkruOn9S7ytNOa+U0L5U= +sha256-ZEpCp4nyiDWEFrUET4lsKSpD8WPNytvcAXSQUbGZQlo= diff --git a/nix/expected-hashes/k8s-csi-provider.vendor.linux.sha256 b/nix/expected-hashes/k8s-csi-provider.vendor.linux.sha256 index c5b6988074..4716f2f5ba 100644 --- a/nix/expected-hashes/k8s-csi-provider.vendor.linux.sha256 +++ b/nix/expected-hashes/k8s-csi-provider.vendor.linux.sha256 @@ -1 +1 @@ -sha256-SxXbx8p8Gl9mmHDPYIjdpd55xXUx4zUwlXpj42OQCt0= +sha256-26MDHMBPqWG2nmwZnQ7j5V8pYqu+BxNnOAprcUlVPrU= diff --git a/nix/expected-hashes/k8s-operator.vendor.linux.sha256 b/nix/expected-hashes/k8s-operator.vendor.linux.sha256 index c5b6988074..4716f2f5ba 100644 --- a/nix/expected-hashes/k8s-operator.vendor.linux.sha256 +++ b/nix/expected-hashes/k8s-operator.vendor.linux.sha256 @@ -1 +1 @@ -sha256-SxXbx8p8Gl9mmHDPYIjdpd55xXUx4zUwlXpj42OQCt0= +sha256-26MDHMBPqWG2nmwZnQ7j5V8pYqu+BxNnOAprcUlVPrU= diff --git a/nix/expected-hashes/k8s-plugin.vendor.linux.sha256 b/nix/expected-hashes/k8s-plugin.vendor.linux.sha256 index c5b6988074..4716f2f5ba 100644 --- a/nix/expected-hashes/k8s-plugin.vendor.linux.sha256 +++ b/nix/expected-hashes/k8s-plugin.vendor.linux.sha256 @@ -1 +1 @@ -sha256-SxXbx8p8Gl9mmHDPYIjdpd55xXUx4zUwlXpj42OQCt0= +sha256-26MDHMBPqWG2nmwZnQ7j5V8pYqu+BxNnOAprcUlVPrU= diff --git a/nix/expected-hashes/server.vendor.musl-fips.sha256 b/nix/expected-hashes/server.vendor.musl-fips.sha256 index d957a836cc..e2a1c5d68b 100644 --- a/nix/expected-hashes/server.vendor.musl-fips.sha256 +++ b/nix/expected-hashes/server.vendor.musl-fips.sha256 @@ -1 +1 @@ -sha256-z3fgyAQW/gFaO2wu7a7ZHd7T9/iNG0BmP469xNX33VU= +sha256-aj6hImX+n0n/xUz8iZ9FXCUXA61bQrvjEH6X+iy+xM0= diff --git a/nix/expected-hashes/server.vendor.musl-non-fips.sha256 b/nix/expected-hashes/server.vendor.musl-non-fips.sha256 index 8a5bebeb05..238141c955 100644 --- a/nix/expected-hashes/server.vendor.musl-non-fips.sha256 +++ b/nix/expected-hashes/server.vendor.musl-non-fips.sha256 @@ -1 +1 @@ -sha256-j9RpGWnAk0J1NrnR+LHuIduu8Qu5LgsTv70TMRSsDHM= +sha256-aSjato2A95J5gibETH4aTaEwDEzMgKNudQO5hMdwjLQ= diff --git a/nix/expected-hashes/ui.vendor.fips.sha256 b/nix/expected-hashes/ui.vendor.fips.sha256 index cbe99c8d8b..07b2113354 100644 --- a/nix/expected-hashes/ui.vendor.fips.sha256 +++ b/nix/expected-hashes/ui.vendor.fips.sha256 @@ -1 +1 @@ -sha256-TlPlThCRab82OAyLShRpvW/M5IzvyiZEXAgsy9PxPp4= +sha256-+OEy/BrxfK89/DruDV7u22Z0YGFcd+U4ku1DrytmsqA= diff --git a/nix/expected-hashes/ui.vendor.non-fips.sha256 b/nix/expected-hashes/ui.vendor.non-fips.sha256 index 92e10d2f5e..76b0177365 100644 --- a/nix/expected-hashes/ui.vendor.non-fips.sha256 +++ b/nix/expected-hashes/ui.vendor.non-fips.sha256 @@ -1 +1 @@ -sha256-a0JeDK966/KtSiIq6AXmjwOuvdyIUa4MPKmZrbPl3as= +sha256-rvrPE7MizcuJJsohCYLJ7m/eXrxm3dCtkjGv/UFMNYU= From 99de7189d798a619a8020c32fb0f25df10b787bb Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sat, 24 Oct 2026 10:22:41 +0200 Subject: [PATCH 06/20] fix(deps): bump crypto-bigint to 0.7.6, justify RUSTSEC-2026-0387 for 0.5.5, update Nix vendor hashes --- .cargo/audit.toml | 2 + Cargo.lock | 14 +- deny.toml | 1 + .../cli.vendor.dynamic.darwin.sha256 | 2 +- .../cli.vendor.static.darwin.sha256 | 2 +- .../server.vendor.dynamic.sha256 | 2 +- .../server.vendor.static.sha256 | 2 +- supply-chain/audits.toml | 12 ++ supply-chain/config.toml | 53 ------ supply-chain/imports.lock | 159 ++++++++++++++++++ 10 files changed, 185 insertions(+), 64 deletions(-) diff --git a/.cargo/audit.toml b/.cargo/audit.toml index d45d46c12e..e83b4b98ca 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -5,4 +5,6 @@ # List of advisory IDs to ignore (extracted from deny.toml) ignore = [ "RUSTSEC-2023-0071", + # crypto-bigint 0.5.5 via elliptic-curve 0.13 (jsonwebtoken, pkcs11 p256, optional pgp); no 0.5.x patch, see deny.toml + "RUSTSEC-2026-0387", ] diff --git a/Cargo.lock b/Cargo.lock index 5e057fb6a1..7d47678aff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2229,9 +2229,9 @@ dependencies = [ [[package]] name = "crypto-bigint" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0" dependencies = [ "cpubits", "ctutils", @@ -2337,9 +2337,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", "subtle", @@ -2950,7 +2950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ "base16ct 1.0.0", - "crypto-bigint 0.7.5", + "crypto-bigint 0.7.6", "crypto-common 0.2.2", "digest 0.11.3", "ff 0.14.0", @@ -5664,7 +5664,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "crypto-bigint 0.7.5", + "crypto-bigint 0.7.6", "crypto-common 0.2.2", "ff 0.14.0", "rand_core 0.10.1", @@ -6293,7 +6293,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "crypto-bigint 0.7.5", + "crypto-bigint 0.7.6", "hmac 0.13.0", ] diff --git a/deny.toml b/deny.toml index 7f63078c41..bd5a18a450 100644 --- a/deny.toml +++ b/deny.toml @@ -70,6 +70,7 @@ ignore = [ # "a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish # { crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, { id = "RUSTSEC-2023-0071", reason = "Marvin Attack: potential key recovery through timing sidechannels. KMS depends now on jsonwebtoken that has 2 crypto backends (rust_crypto and aws-lc). aws-lc not being Windows compatible, we must choose rust_crypto" }, + { id = "RUSTSEC-2026-0387", reason = "crypto-bigint 0.5.5 timing variability; no 0.5.x patch exists (fixed in >=0.7.6, which the workspace uses for 0.7.x). 0.5.5 remains via elliptic-curve 0.13 (p256/p384 0.13) from jsonwebtoken 10.4 (rust_crypto, JWT ES256/ES384 verification in server and CLI), from the direct p256 0.13 dependency of cosmian_pkcs11 and cosmian_pkcs11_module, and from the optional pgp 0.21.0 -> cx448 0.1.1 (non-fips). Upstream jsonwebtoken 11.1 and pgp 0.21 still require elliptic-curve 0.13. Remove when they move to 0.14" }, ] # The path where the advisory databases are cloned/fetched into # db-path = "$CARGO_HOME/advisory-dbs" # The url(s) of the advisory databases to use diff --git a/nix/expected-hashes/cli.vendor.dynamic.darwin.sha256 b/nix/expected-hashes/cli.vendor.dynamic.darwin.sha256 index fe0dd508e2..8b69dd5970 100644 --- a/nix/expected-hashes/cli.vendor.dynamic.darwin.sha256 +++ b/nix/expected-hashes/cli.vendor.dynamic.darwin.sha256 @@ -1 +1 @@ -sha256-+5rLtI6cKpL0ihqDWuOH807oKh68BI7D0CexPz+1ByY= +sha256-TBTrk7PmV/5M8JgbWYxS7uc1f7TrgDzj2WwMsBCcjkM= diff --git a/nix/expected-hashes/cli.vendor.static.darwin.sha256 b/nix/expected-hashes/cli.vendor.static.darwin.sha256 index 7fa09ebed4..44986ef7c6 100644 --- a/nix/expected-hashes/cli.vendor.static.darwin.sha256 +++ b/nix/expected-hashes/cli.vendor.static.darwin.sha256 @@ -1 +1 @@ -sha256-9CBR0sVW0Q+R3Sgg0idwqYf8zBxgKsZvGlnb2Mgfa/A= +sha256-4IMhXftooZ8rxfpifmSgPqGlPHlxkFOokyTIZcvwoQQ= diff --git a/nix/expected-hashes/server.vendor.dynamic.sha256 b/nix/expected-hashes/server.vendor.dynamic.sha256 index 4e6acc78c4..e78924e94a 100644 --- a/nix/expected-hashes/server.vendor.dynamic.sha256 +++ b/nix/expected-hashes/server.vendor.dynamic.sha256 @@ -1 +1 @@ -sha256-seRoGjKPE1rwAAoyzHthOosHDejTrYiEMsHyUSydFLk= +sha256-ugKgP+Z+nO3YMndHbZFdtF/utabnzPvkeSB8vFqjvrE= diff --git a/nix/expected-hashes/server.vendor.static.sha256 b/nix/expected-hashes/server.vendor.static.sha256 index 1294342d29..c9d33cf0fe 100644 --- a/nix/expected-hashes/server.vendor.static.sha256 +++ b/nix/expected-hashes/server.vendor.static.sha256 @@ -1 +1 @@ -sha256-Fl3a5j8kOYaSte3lh7HIA0Jr5SmeL3utR7Reu/8e97E= +sha256-JCu/i1G2q65S3jR97DOiZgK+xbrNOAPQiC/wu9Kj2A0= diff --git a/supply-chain/audits.toml b/supply-chain/audits.toml index 4bf7875081..17b2692f2b 100644 --- a/supply-chain/audits.toml +++ b/supply-chain/audits.toml @@ -339,6 +339,12 @@ start = "2025-08-06" end = "2027-10-15" notes = "Cosmian-maintained crate (own organisation)" +[[trusted.crypto-bigint]] +criteria = "safe-to-deploy" +trusted-publisher = "github:RustCrypto/crypto-bigint" +start = "2026-02-12" +end = "2027-10-24" + [[trusted.csv]] criteria = "safe-to-deploy" user-id = 189 # Andrew Gallant (BurntSushi) @@ -353,6 +359,12 @@ start = "2019-06-26" end = "2027-10-15" notes = "Well-known Rust maintainer, also trusted by bytecode-alliance, mozilla audit sets" +[[trusted.ctutils]] +criteria = "safe-to-deploy" +trusted-publisher = "github:RustCrypto/utils" +start = "2026-04-02" +end = "2027-10-24" + [[trusted.derive_more]] criteria = "safe-to-deploy" user-id = 3797 # Jelte Fennema-Nio (JelteF) diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 92407a5aee..d836f30109 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -349,10 +349,6 @@ criteria = "safe-to-deploy" version = "0.9.1" criteria = "safe-to-deploy" -[[exemptions.chacha20]] -version = "0.10.2" -criteria = "safe-to-deploy" - [[exemptions.chacha20poly1305]] version = "0.10.1" criteria = "safe-to-deploy" @@ -478,11 +474,6 @@ notes = "priority: build-time code (sys/proc-macro/build.rs) — needs manual au version = "0.5.5" criteria = "safe-to-deploy" -[[exemptions.crypto-bigint]] -version = "0.7.5" -criteria = "safe-to-deploy" -notes = "transitive or direct dependency of the workspace (dependency bump to 0.7.5) — unaudited, pending review" - [[exemptions.crypto-common]] version = "0.1.7" criteria = "safe-to-deploy" @@ -503,10 +494,6 @@ criteria = "safe-to-deploy" version = "0.9.2" criteria = "safe-to-deploy" -[[exemptions.ctutils]] -version = "0.4.4" -criteria = "safe-to-deploy" - [[exemptions.curve25519-dalek]] version = "4.1.3" criteria = "safe-to-deploy" @@ -824,11 +811,6 @@ version = "0.3.4" criteria = "safe-to-deploy" notes = "priority: build-time code (sys/proc-macro/build.rs) — needs manual audit, tracked as backlog per issue #1265" -[[exemptions.getrandom]] -version = "0.4.3" -criteria = "safe-to-deploy" -notes = "priority: build-time code (sys/proc-macro/build.rs) — needs manual audit, tracked as backlog per issue #1265" - [[exemptions.ghash]] version = "0.5.1" criteria = "safe-to-deploy" @@ -1419,23 +1401,10 @@ criteria = "safe-to-deploy" version = "0.9.5" criteria = "safe-to-deploy" -[[exemptions.rand]] -version = "0.10.3" -criteria = "safe-to-deploy" -notes = "transitive or direct dependency of the workspace (dependency bump to 0.10.3) — unaudited, pending review" - [[exemptions.rand_chacha]] version = "0.10.0" criteria = "safe-to-deploy" -[[exemptions.rand_core]] -version = "0.10.1" -criteria = "safe-to-deploy" - -[[exemptions.rand_pcg]] -version = "0.10.2" -criteria = "safe-to-deploy" - [[exemptions.rand_xorshift]] version = "0.5.0" criteria = "safe-to-run" @@ -1480,11 +1449,6 @@ version = "0.6.0" criteria = "safe-to-deploy" notes = "transitive or direct dependency of the workspace (dependency bump to 0.6.0) — unaudited, pending review" -[[exemptions.ring]] -version = "0.17.14" -criteria = "safe-to-deploy" -notes = "priority: build-time code (sys/proc-macro/build.rs) — needs manual audit, tracked as backlog per issue #1265" - [[exemptions.ripemd]] version = "0.1.3" criteria = "safe-to-deploy" @@ -1722,10 +1686,6 @@ notes = "priority: build-time code (sys/proc-macro/build.rs) — needs manual au version = "0.1.0" criteria = "safe-to-deploy" -[[exemptions.sync_wrapper]] -version = "1.0.2" -criteria = "safe-to-deploy" - [[exemptions.syslog-tracing]] version = "0.3.1" criteria = "safe-to-deploy" @@ -1851,10 +1811,6 @@ version = "0.9.10" criteria = "safe-to-deploy" notes = "priority: build-time code (sys/proc-macro/build.rs) — needs manual audit, tracked as backlog per issue #1265" -[[exemptions.untrusted]] -version = "0.9.0" -criteria = "safe-to-deploy" - [[exemptions.uuid]] version = "1.28.0" criteria = "safe-to-deploy" @@ -1914,10 +1870,6 @@ version = "0.3.85" criteria = "safe-to-deploy" notes = "priority: build-time code (sys/proc-macro/build.rs) — needs manual audit, tracked as backlog per issue #1265" -[[exemptions.web-time]] -version = "1.1.0" -criteria = "safe-to-deploy" - [[exemptions.whoami]] version = "2.1.2" criteria = "safe-to-deploy" @@ -2003,11 +1955,6 @@ notes = "priority: build-time code (sys/proc-macro/build.rs) — needs manual au version = "0.1.8" criteria = "safe-to-deploy" -[[exemptions.zeroize]] -version = "1.9.1" -criteria = "safe-to-deploy" -notes = "transitive or direct dependency of the workspace (dependency bump to 1.9.1) — unaudited, pending review" - [[exemptions.zeroize_derive]] version = "1.5.0" criteria = "safe-to-deploy" diff --git a/supply-chain/imports.lock b/supply-chain/imports.lock index 8999c2638a..8c961619a0 100644 --- a/supply-chain/imports.lock +++ b/supply-chain/imports.lock @@ -278,6 +278,11 @@ when = "2026-02-13" user-id = 138394 user-login = "Manuthor" +[[publisher.crypto-bigint]] +version = "0.7.6" +when = "2026-10-23" +trusted-publisher = "github:RustCrypto/crypto-bigint" + [[publisher.csv]] version = "1.4.0" when = "2025-10-17" @@ -292,6 +297,11 @@ user-id = 189 user-login = "BurntSushi" user-name = "Andrew Gallant" +[[publisher.ctutils]] +version = "0.4.5" +when = "2026-10-23" +trusted-publisher = "github:RustCrypto/utils" + [[publisher.derive_more]] version = "0.99.20" when = "2025-04-23" @@ -791,6 +801,13 @@ user-id = 359 user-login = "seanmonstar" user-name = "Sean McArthur" +[[publisher.ring]] +version = "0.17.11" +when = "2025-02-22" +user-id = 2751 +user-login = "ctz" +user-name = "Joe Birr-Pixton" + [[publisher.rustix]] version = "1.1.4" when = "2026-02-22" @@ -1606,6 +1623,12 @@ who = "Pat Hickey " criteria = "safe-to-deploy" delta = "0.3.28 -> 0.3.31" +[[audits.bytecode-alliance.audits.getrandom]] +who = "Alex Crichton " +criteria = "safe-to-deploy" +delta = "0.4.1 -> 0.4.2" +notes = "Nothing awry in this update, standard updates for some platforms and other misc things." + [[audits.bytecode-alliance.audits.heck]] who = "Alex Crichton " criteria = "safe-to-deploy" @@ -1745,6 +1768,12 @@ a few `unsafe` blocks related to utf-8 validation which are locally verifiable as correct and otherwise this crate is good to go. """ +[[audits.bytecode-alliance.audits.rand]] +who = "Alex Crichton " +criteria = "safe-to-deploy" +delta = "0.10.0 -> 0.10.1" +notes = "Minor logging-based updated fixing a recent advisory for the crate." + [[audits.bytecode-alliance.audits.rustc_version]] who = "Alex Crichton " criteria = "safe-to-deploy" @@ -1853,6 +1882,11 @@ criteria = "safe-to-deploy" version = "0.2.15" notes = "no build.rs, no macros, no unsafe. It reads the filesystem and makes copies of DLLs into OUT_DIR." +[[audits.bytecode-alliance.audits.zeroize]] +who = "Pat Hickey " +criteria = "safe-to-deploy" +delta = "1.8.1 -> 1.8.2" + [[audits.embark-studios.audits.cfg_aliases]] who = "Johan Andersson " criteria = "safe-to-deploy" @@ -2141,6 +2175,15 @@ Still no `unsafe` anywhere. """ aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" +[[audits.google.audits.rand]] +who = "Lukasz Anforowicz " +criteria = "safe-to-deploy" +version = "0.8.5" +notes = """ +For more detailed unsafe review notes please see https://crrev.com/c/6362797 +""" +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" + [[audits.google.audits.rand_chacha]] who = "Lukasz Anforowicz " criteria = "safe-to-deploy" @@ -2251,6 +2294,11 @@ who = "David Cook " criteria = "safe-to-deploy" version = "0.9.0" +[[audits.isrg.audits.chacha20]] +who = "David Cook " +criteria = "safe-to-deploy" +version = "0.10.0" + [[audits.isrg.audits.cmac]] who = "David Cook " criteria = "safe-to-deploy" @@ -2351,6 +2399,16 @@ criteria = "safe-to-deploy" delta = "0.2.8 -> 0.2.9" notes = "No changes to Rust code between 0.2.8 and 0.2.9" +[[audits.isrg.audits.getrandom]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "0.3.4 -> 0.4.0" + +[[audits.isrg.audits.getrandom]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "0.4.0 -> 0.4.1" + [[audits.isrg.audits.hmac]] who = "David Cook " criteria = "safe-to-deploy" @@ -2381,6 +2439,21 @@ who = "David Cook " criteria = "safe-to-deploy" version = "0.3.0" +[[audits.isrg.audits.rand]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "0.8.5 -> 0.9.1" + +[[audits.isrg.audits.rand]] +who = "Tim Geoghegan " +criteria = "safe-to-deploy" +delta = "0.9.1 -> 0.9.2" + +[[audits.isrg.audits.rand]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "0.9.2 -> 0.10.0" + [[audits.isrg.audits.rand_chacha]] who = "David Cook " criteria = "safe-to-deploy" @@ -2396,6 +2469,11 @@ who = "J.C. Jones " criteria = "safe-to-deploy" delta = "0.9.3 -> 0.9.5" +[[audits.isrg.audits.rand_core]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "0.9.5 -> 0.10.0" + [[audits.isrg.audits.rand_distr]] who = "David Cook " criteria = "safe-to-deploy" @@ -2431,6 +2509,11 @@ who = "David Cook " criteria = "safe-to-deploy" delta = "0.5.0 -> 0.5.1" +[[audits.isrg.audits.untrusted]] +who = "David Cook " +criteria = "safe-to-deploy" +version = "0.7.1" + [[audits.mozilla.wildcard-audits.encoding_rs]] who = "Henri Sivonen " criteria = "safe-to-deploy" @@ -2589,6 +2672,12 @@ criteria = "safe-to-deploy" delta = "0.2.1 -> 0.2.2" aggregated-from = "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/supply-chain/audits.toml" +[[audits.mozilla.audits.chacha20]] +who = "Alex Franchuk " +criteria = "safe-to-deploy" +delta = "0.10.0 -> 0.10.2" +aggregated-from = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml" + [[audits.mozilla.audits.core_detect]] who = "Henri Sivonen " criteria = "safe-to-deploy" @@ -2724,6 +2813,12 @@ criteria = "safe-to-deploy" delta = "0.3.31 -> 0.3.34" aggregated-from = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml" +[[audits.mozilla.audits.getrandom]] +who = "Alex Franchuk " +criteria = "safe-to-deploy" +delta = "0.4.2 -> 0.4.3" +aggregated-from = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml" + [[audits.mozilla.audits.hashbrown]] who = "Mike Hommey " criteria = "safe-to-deploy" @@ -2909,6 +3004,18 @@ criteria = "safe-to-deploy" delta = "1.0.45 -> 1.0.47" aggregated-from = "https://raw.githubusercontent.com/mozilla/glean/main/supply-chain/audits.toml" +[[audits.mozilla.audits.rand]] +who = "Alex Franchuk " +criteria = "safe-to-deploy" +delta = "0.10.1 -> 0.10.3" +aggregated-from = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml" + +[[audits.mozilla.audits.rand_core]] +who = "Alex Franchuk " +criteria = "safe-to-deploy" +delta = "0.10.0 -> 0.10.1" +aggregated-from = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml" + [[audits.mozilla.audits.rand_distr]] who = "Ben Dean-Kawamura " criteria = "safe-to-deploy" @@ -2919,6 +3026,20 @@ As far as I can tell, it does not have any file IO or network access. """ aggregated-from = "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/supply-chain/audits.toml" +[[audits.mozilla.audits.rand_pcg]] +who = "Alex Franchuk " +criteria = "safe-to-deploy" +version = "0.10.2" +notes = "no_std crate with unsafe code forbidden." +aggregated-from = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml" + +[[audits.mozilla.audits.ring]] +who = "Alex Franchuk " +criteria = "safe-to-deploy" +delta = "0.17.11 -> 0.17.14" +notes = "Generated code changes based on Perl scripts taken from BoringSSL. Portions are superficial in nature: deprecating/renaming modules and functions. New unsafe code is necessary to call out to assembly." +aggregated-from = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml" + [[audits.mozilla.audits.rustc_version]] who = "Nika Layzell " criteria = "safe-to-deploy" @@ -3057,6 +3178,13 @@ version = "2.5.0" notes = "The goal is to provide some constant-time correctness for cryptographic implementations. The approach is reasonable, it is known to be insufficient but this is pointed out in the documentation." aggregated-from = "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/supply-chain/audits.toml" +[[audits.mozilla.audits.sync_wrapper]] +who = "Alex Franchuk " +criteria = "safe-to-deploy" +version = "1.0.2" +notes = "Simple crate, nothing out of the ordinary." +aggregated-from = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml" + [[audits.mozilla.audits.synstructure]] who = "Nika Layzell " criteria = "safe-to-deploy" @@ -3150,6 +3278,13 @@ criteria = "safe-to-deploy" delta = "0.1.34 -> 0.1.36" aggregated-from = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml" +[[audits.mozilla.audits.untrusted]] +who = "Alex Franchuk " +criteria = "safe-to-deploy" +delta = "0.7.1 -> 0.9.0" +notes = "Mostly a refactor of existing code. No use of std, no unsafe, no dependencies." +aggregated-from = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml" + [[audits.mozilla.audits.utf8parse]] who = "Nika Layzell " criteria = "safe-to-deploy" @@ -3162,6 +3297,30 @@ criteria = "safe-to-run" delta = "0.2.0 -> 0.2.1" aggregated-from = "https://raw.githubusercontent.com/mozilla/glean/main/supply-chain/audits.toml" +[[audits.mozilla.audits.web-time]] +who = "Alex Franchuk " +criteria = "safe-to-deploy" +version = "1.1.0" +notes = "Reasonable dependencies, unsafe code denied. The only use of std is to forward std::time when used on non-wasm targets and to reuse a few std::time things that are still applicable." +aggregated-from = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml" + +[[audits.mozilla.audits.zeroize]] +who = "Benjamin Beurdouche " +criteria = "safe-to-deploy" +version = "1.8.1" +notes = """ +This code DOES contain unsafe code required to internally call volatiles +for deleting data. This is expected and documented behavior. +""" +aggregated-from = "https://raw.githubusercontent.com/mozilla-firefox/firefox/main/supply-chain/audits.toml" + +[[audits.mozilla.audits.zeroize]] +who = "Alex Franchuk " +criteria = "safe-to-deploy" +delta = "1.8.2 -> 1.9.1" +notes = "Minor stylistic changes to unsafe blocks, many tests added/updated." +aggregated-from = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml" + [[audits.zcash.audits.base64]] who = "Jack Grigg " criteria = "safe-to-deploy" From 6f3c70b72bf5ef4070ecf743c323369eda76b13b Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sat, 24 Oct 2026 10:26:42 +0200 Subject: [PATCH 07/20] fix(server): support hybrid KEM keys wrapped by an HSM KEK --- CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md | 6 +- crate/crypto/src/crypto/wrap/tests.rs | 58 ++-- .../kmip/src/kmip_2_1/kmip_data_structures.rs | 2 + crate/kmip/src/ttlv/tests/kmip_2_1_tests.rs | 22 ++ crate/server/src/core/wrapping/unwrap.rs | 33 ++- crate/server/src/tests/hsm/hybrid_kem_dek.rs | 278 +++++++++++++++++- crate/server/src/tests/hsm/mod.rs | 8 + 7 files changed, 364 insertions(+), 43 deletions(-) diff --git a/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md index d63da2a59a..778ea27bed 100644 --- a/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md +++ b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md @@ -3,10 +3,12 @@ ## Bug Fixes - Hybrid KEM keys (`ConfigurableKEM` and native `X25519MLKEM768` / `X448MLKEM1024`) now work when the server `key_encryption_key` is stored in an HSM: `ckms pqc keys create -a ml-kem-768-p256` no longer fails with `Unsupported key format type: ConfigurableKEMSecretKey`. - - Wrap: `ByteString`-only `Raw`, `ConfigurableKEM*` and `CoverCrypt*` asymmetric keys are wrapped as raw bytes instead of going through OpenSSL (Create, Import, ReKeyKeyPair, Export with a `KeyWrappingSpecification`). + - Wrap: `ByteString`-only `Raw`, `ConfigurableKEM*` and `CoverCrypt*` asymmetric keys are wrapped as raw bytes instead of going through OpenSSL (Create, Import, ReKeyKeyPair, Export with a `KeyWrappingSpecification` using `NoEncoding`). - Unwrap: `ConfigurableKEM*` keys are decoded on unwrap (Encrypt/Decrypt after a restart, `default_unwrap_type`, Export/Import with `NotWrapped`). - Export: a PQC/hybrid KEM key can be exported with its own stored `KeyFormatType`. + - TTLV: `ConfigurableKEM*` key material can be deserialized from TTLV, so TTLV-wrapped keys (`ckms keys export --wrap-key-id ... -f json-ttlv`, then import) can be unwrapped and used. +- Unwrapping with a KMS wrapping key now works when that wrapping key is itself wrapped (e.g. by the HSM KEK): it is unwrapped first and its stored usage mask is checked, as already done when wrapping. ## Testing -- Add a SoftHSM2-backed hybrid KEM lifecycle test (`tests::hsm::hybrid_kem_dek`) and a wrap/unwrap unit test for `ByteString`-only KEM keys. +- Add a SoftHSM2-backed hybrid KEM lifecycle test (`tests::hsm::hybrid_kem_dek`), TTLV-wrapped import tests (export wrapped by a software AES key, and client-side wrap by the HSM KEK), a wrap/unwrap unit test (`NoEncoding` and TTLV) for `ByteString`-only KEM keys, and a TTLV round-trip test for `ConfigurableKEM*` key values. diff --git a/crate/crypto/src/crypto/wrap/tests.rs b/crate/crypto/src/crypto/wrap/tests.rs index fae2cc4d2e..1ef56c0733 100644 --- a/crate/crypto/src/crypto/wrap/tests.rs +++ b/crate/crypto/src/crypto/wrap/tests.rs @@ -474,7 +474,7 @@ fn test_encrypt_decrypt_ec_p384() -> CryptoResult<()> { } /// ByteString-only asymmetric keys (`ConfigurableKEM`, native hybrid KEM) have no OpenSSL -/// representation: they must wrap/unwrap as raw bytes. +/// representation: they must wrap/unwrap as raw bytes (`NoEncoding`) and as TTLV. #[cfg(feature = "non-fips")] #[test] fn test_wrap_unwrap_byte_string_kem_keys() -> CryptoResult<()> { @@ -521,33 +521,35 @@ fn test_wrap_unwrap_byte_string_kem_keys() -> CryptoResult<()> { None, )?; - for i in 0..4 { - let pair = if i < 2 { - &mut configurable - } else { - &mut native - }; - let obj = if i % 2 == 0 { - pair.private_key_mut() - } else { - pair.public_key_mut() - }; - let bytes = obj.key_block()?.key_bytes()?; - let format = obj.key_block()?.key_format_type; - wrap_object_with_key( - obj, - &sym_key, - &KeyWrappingSpecification { - encoding_option: Some(EncodingOption::NoEncoding), - ..Default::default() - }, - &KmsRng::new(), - )?; - assert!(obj.is_wrapped()); - unwrap_key_block(obj.key_block_mut()?, &sym_key)?; - assert_eq!(obj.key_block()?.key_bytes()?, bytes); - assert_eq!(obj.key_block()?.key_format_type, format); - assert!(obj.key_block()?.key_wrapping_data.is_none()); + for encoding in [EncodingOption::NoEncoding, EncodingOption::TTLVEncoding] { + for i in 0..4 { + let pair = if i < 2 { + &mut configurable + } else { + &mut native + }; + let obj = if i % 2 == 0 { + pair.private_key_mut() + } else { + pair.public_key_mut() + }; + let bytes = obj.key_block()?.key_bytes()?; + let format = obj.key_block()?.key_format_type; + wrap_object_with_key( + obj, + &sym_key, + &KeyWrappingSpecification { + encoding_option: Some(encoding), + ..Default::default() + }, + &KmsRng::new(), + )?; + assert!(obj.is_wrapped()); + unwrap_key_block(obj.key_block_mut()?, &sym_key)?; + assert_eq!(obj.key_block()?.key_bytes()?, bytes, "{encoding:?} #{i}"); + assert_eq!(obj.key_block()?.key_format_type, format); + assert!(obj.key_block()?.key_wrapping_data.is_none()); + } } Ok(()) } diff --git a/crate/kmip/src/kmip_2_1/kmip_data_structures.rs b/crate/kmip/src/kmip_2_1/kmip_data_structures.rs index 234d3c0666..73dde2ef65 100644 --- a/crate/kmip/src/kmip_2_1/kmip_data_structures.rs +++ b/crate/kmip/src/kmip_2_1/kmip_data_structures.rs @@ -1661,6 +1661,8 @@ impl<'de> DeserializeSeed<'de> for KeyMaterialDeserializer { | KeyFormatType::PKCS7 | KeyFormatType::PKCS8 | KeyFormatType::X509 + | KeyFormatType::ConfigurableKEMSecretKey + | KeyFormatType::ConfigurableKEMPublicKey | KeyFormatType::CoverCryptPublicKey | KeyFormatType::CoverCryptSecretKey | KeyFormatType::OpenPgpSecretKey diff --git a/crate/kmip/src/ttlv/tests/kmip_2_1_tests.rs b/crate/kmip/src/ttlv/tests/kmip_2_1_tests.rs index e1b0401512..c241bbb2ef 100644 --- a/crate/kmip/src/ttlv/tests/kmip_2_1_tests.rs +++ b/crate/kmip/src/ttlv/tests/kmip_2_1_tests.rs @@ -1843,6 +1843,28 @@ fn test_key_value_ttlv() { ); } +#[test] +fn test_key_value_ttlv_round_trip_configurable_kem() { + for key_format_type in [ + KeyFormatType::ConfigurableKEMSecretKey, + KeyFormatType::ConfigurableKEMPublicKey, + ] { + let kv = KeyValue::Structure { + key_material: KeyMaterial::ByteString(Zeroizing::new(vec![3, 1, 2, 193, 9])), + attributes: Some(Attributes { + cryptographic_algorithm: Some(CryptographicAlgorithm::ConfigurableKEM), + ..Default::default() + }), + }; + assert_eq!( + kv, + KeyValue::from_ttlv_bytes(&kv.to_ttlv_bytes(key_format_type).unwrap(), key_format_type) + .unwrap(), + "TTLV round trip failed for {key_format_type:?}" + ); + } +} + #[test] fn test_set_attribute() { // log_init(Some("trace")); diff --git a/crate/server/src/core/wrapping/unwrap.rs b/crate/server/src/core/wrapping/unwrap.rs index 11ae313e23..b84c54dc20 100644 --- a/crate/server/src/core/wrapping/unwrap.rs +++ b/crate/server/src/core/wrapping/unwrap.rs @@ -149,13 +149,12 @@ async fn unwrap_using_kms( "The unwrapping key {unwrapping_key_uid} is not active" ))); } - // check authorized usage - let attributes = unwrapping_key - .object() + // check authorized usage: the stored attributes are authoritative, the ones embedded in the + // key value are absent when the unwrapping key is itself wrapped (mirrors `wrap_using_kms`) + if !unwrapping_key .attributes() - .cloned() - .unwrap_or_default(); - if !attributes.is_usage_authorized_for(CryptographicUsageMask::UnwrapKey)? { + .is_usage_authorized_for(CryptographicUsageMask::UnwrapKey)? + { return Err(KmsError::NotSupported(format!( "The key: {unwrapping_key_uid}, is not meant to unwrap keys" ))); @@ -176,8 +175,28 @@ async fn unwrap_using_kms( ))); } } + // If the unwrapping key is itself wrapped (e.g. by the server KEK), unwrap it first + let unwrapping_key_object = if unwrapping_key.object().is_wrapped() { + debug!("The unwrapping key {unwrapping_key_uid} is itself wrapped, unwrapping it first"); + let mut object = unwrapping_key.object().clone(); + Box::pin(unwrap_object(&mut object, kms, user)).await?; + // the unwrapped key value carries no attributes: reattach the stored ones, which the + // crypto layer reads to check the usage mask + if let Some(KeyValue::Structure { attributes, .. }) = object + .key_block_mut() + .ok() + .and_then(|key_block| key_block.key_value.as_mut()) + { + if attributes.is_none() { + *attributes = Some(unwrapping_key.attributes().clone()); + } + } + object + } else { + unwrapping_key.object().clone() + }; // ok => unwrap - unwrap_key_block(object_key_block, unwrapping_key.object())?; + unwrap_key_block(object_key_block, &unwrapping_key_object)?; Ok(()) } diff --git a/crate/server/src/tests/hsm/hybrid_kem_dek.rs b/crate/server/src/tests/hsm/hybrid_kem_dek.rs index 60b9510001..3c89be2869 100644 --- a/crate/server/src/tests/hsm/hybrid_kem_dek.rs +++ b/crate/server/src/tests/hsm/hybrid_kem_dek.rs @@ -8,19 +8,35 @@ use cosmian_kms_client_utils::{ }; use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ - kmip_0::kmip_types::{KeyWrapType, RevocationReason, RevocationReasonCode}, + kmip_0::kmip_types::{ + CryptographicUsageMask, KeyWrapType, RevocationReason, RevocationReasonCode, + }, kmip_2_1::{ extra::tagging::VENDOR_ID_COSMIAN, kmip_attributes::Attributes, + kmip_data_structures::{KeyValue, KeyWrappingData, KeyWrappingSpecification}, kmip_operations::{ - Activate, CreateKeyPair, Destroy, Export, GetAttributes, Operation, ReKeyKeyPair, - Revoke, + Activate, CreateKeyPair, Destroy, Export, GetAttributes, Import, Operation, + ReKeyKeyPair, Revoke, + }, + kmip_types::{ + CryptographicAlgorithm, CryptographicDomainParameters, CryptographicParameters, + EncodingOption, EncryptionKeyInformation, KeyFormatType, RecommendedCurve, + UniqueIdentifier, WrappingMethod, + }, + requests::{ + create_pqc_key_pair_request, create_symmetric_key_kmip_object, decrypt_request, + encrypt_request, }, - kmip_types::{CryptographicAlgorithm, KeyFormatType, UniqueIdentifier}, - requests::{create_pqc_key_pair_request, decrypt_request, encrypt_request}, }, + time_normalize, + }, + cosmian_kms_crypto::{ + crypto::{ + KeyPair, KeyPairIdentity, kem::kem_keygen, pqc::hybrid_kem::create_hybrid_kem_key_pair, + }, + reexport::cosmian_crypto_core::bytes_ser_de::Serializable, }, - cosmian_kms_crypto::reexport::cosmian_crypto_core::bytes_ser_de::Serializable, }; use uuid::Uuid; use zeroize::Zeroizing; @@ -45,6 +61,8 @@ enum Alg { MlKem512P256, MlKem768P256, X25519MlKem768, + /// Pure (non hybrid) ML-KEM, stored as PKCS#8 + MlKem768, } impl Alg { @@ -82,6 +100,12 @@ impl Alg { CryptographicAlgorithm::X25519MLKEM768, false, )?, + Self::MlKem768 => create_pqc_key_pair_request( + VENDOR_ID_COSMIAN, + [tag], + CryptographicAlgorithm::MLKEM_768, + false, + )?, }) } @@ -98,6 +122,11 @@ impl Alg { KeyFormatType::Raw, KeyFormatType::Raw, ), + Self::MlKem768 => ( + CryptographicAlgorithm::MLKEM_768, + KeyFormatType::PKCS8, + KeyFormatType::PKCS8, + ), } } } @@ -401,3 +430,240 @@ async fn lifecycle(alg: Alg) -> KResult<()> { export_object(&kms, &owner, &sk).await.unwrap_err(); Ok(()) } + +/// Same import attributes as `ckms keys import`: activated now, usage mask from `--key-usage`. +async fn import_activated( + kms: &Arc, + owner: &str, + uid: &str, + object: Object, + object_type: ObjectType, +) -> KResult<()> { + kms.import( + Import { + unique_identifier: UniqueIdentifier::TextString(uid.to_owned()), + object_type, + attributes: Attributes { + object_type: Some(object_type), + activation_date: Some(time_normalize()?), + cryptographic_usage_mask: Some(CryptographicUsageMask::Unrestricted), + ..Default::default() + }, + replace_existing: None, + key_wrap_type: None, + object, + }, + &UserId::from(owner), + ) + .await?; + Ok(()) +} + +/// Keys created on a server without KEK, exported TTLV-wrapped by a software AES key, then +/// imported (still wrapped) in a server whose KEK is in the HSM. The software key is itself +/// wrapped by the HSM KEK on that server. +pub(super) async fn test_ttlv_wrapped_kem_import() -> KResult<()> { + for alg in [Alg::MlKem768P256, Alg::X25519MlKem768, Alg::MlKem768] { + Box::pin(ttlv_wrapped_import(alg)).await?; + } + Ok(()) +} + +async fn ttlv_wrapped_import(alg: Alg) -> KResult<()> { + let kek_uuid = Uuid::new_v4(); + let owner = Uuid::new_v4().to_string(); + let tag = Uuid::new_v4().to_string(); + let sqlite_path = get_tmp_sqlite_path(); + + let prod = restart(&owner, kek_uuid, &sqlite_path, false).await?; + let kek_uid = hsm_clap_config(&owner, Some(kek_uuid))? + .key_encryption_key + .ok_or_else(|| KmsError::Default("Missing KEK".to_owned()))?; + create_kek(&kek_uid, &owner, &prod).await?; + // the source server has no key encryption key + let mut source_config = hsm_clap_config(&owner, None)?; + source_config.db.sqlite_path = get_tmp_sqlite_path(); + let source = + Arc::new(KMS::instantiate(Arc::new(ServerParams::try_from(source_config)?)).await?); + + // the same software AES wrapping key on both servers + let aes = create_symmetric_key_kmip_object( + VENDOR_ID_COSMIAN, + &[7_u8; 32], + &Attributes { + cryptographic_algorithm: Some(CryptographicAlgorithm::AES), + ..Default::default() + }, + )?; + for kms in [&prod, &source] { + import_activated(kms, &owner, "kemkek", aes.clone(), ObjectType::SymmetricKey).await?; + } + + let response = send_message( + source.clone(), + &owner, + vec![Operation::CreateKeyPair(Box::new( + alg.create_request(&tag)?, + ))], + ) + .await?; + let Operation::CreateKeyPairResponse(response) = &response[0] else { + return Err(KmsError::ServerError("invalid response".to_owned())); + }; + let sk = uid_string(&response.private_key_unique_identifier)?; + let pk = uid_string(&response.public_key_unique_identifier)?; + assert_roundtrip(&source, &owner, &pk, &sk).await?; + + for (uid, object_type) in [(&sk, ObjectType::PrivateKey), (&pk, ObjectType::PublicKey)] { + // what `ckms keys export --wrap-key-id kemkek -f json-ttlv` asks for + let exported = source + .export( + Export { + unique_identifier: Some(UniqueIdentifier::TextString(uid.clone())), + key_format_type: None, + key_compression_type: None, + key_wrap_type: None, + key_wrapping_specification: Some(KeyWrappingSpecification { + wrapping_method: WrappingMethod::Encrypt, + encryption_key_information: Some(EncryptionKeyInformation { + unique_identifier: UniqueIdentifier::TextString("kemkek".to_owned()), + cryptographic_parameters: None, + }), + encoding_option: Some(EncodingOption::TTLVEncoding), + ..Default::default() + }), + }, + &UserId::from(owner.as_str()), + ) + .await? + .object; + assert!(exported.is_wrapped()); + import_activated(&prod, &owner, uid, exported, object_type).await?; + } + assert_roundtrip(&prod, &owner, &pk, &sk).await?; + + // cold cache: unwrap again from the database + drop(prod); + let prod = restart(&owner, kek_uuid, &sqlite_path, false).await?; + assert_roundtrip(&prod, &owner, &pk, &sk).await?; + Ok(()) +} + +/// `ConfigurableKEM` and native hybrid keys generated client side, wrapped (TTLV) by the HSM KEK +/// with a KMIP Encrypt, and imported as already wrapped. +pub(super) async fn test_client_wrapped_kem_import() -> KResult<()> { + let kek_uuid = Uuid::new_v4(); + let owner = Uuid::new_v4().to_string(); + let sqlite_path = get_tmp_sqlite_path(); + let kms = restart(&owner, kek_uuid, &sqlite_path, false).await?; + let kek_uid = hsm_clap_config(&owner, Some(kek_uuid))? + .key_encryption_key + .ok_or_else(|| KmsError::Default("Missing KEK".to_owned()))?; + create_kek(&kek_uid, &owner, &kms).await?; + + for configurable in [true, false] { + let sk = format!("cw-{}", Uuid::new_v4()); + let pk = format!("{sk}_pk"); + let pair = client_key_pair(configurable, &sk, &pk)?; + for (uid, object, object_type) in [ + (&sk, pair.private_key(), ObjectType::PrivateKey), + (&pk, pair.public_key(), ObjectType::PublicKey), + ] { + let block = object.key_block()?; + let plaintext = block + .key_value + .as_ref() + .ok_or_else(|| KmsError::Default("no key value".to_owned()))? + .to_ttlv_bytes(block.key_format_type)?; + let response = send_message( + kms.clone(), + &owner, + vec![Operation::Encrypt(Box::new(encrypt_request( + &kek_uid, None, plaintext, None, None, None, + )?))], + ) + .await?; + let Operation::EncryptResponse(response) = &response[0] else { + return Err(KmsError::ServerError("invalid response".to_owned())); + }; + // the same layout as the server wrapping: IV || ciphertext || tag + let wrapped = [ + response.i_v_counter_nonce.clone().unwrap_or_default(), + response.data.clone().unwrap_or_default(), + response + .authenticated_encryption_tag + .clone() + .unwrap_or_default(), + ] + .concat(); + let mut wrapped_object = object.clone(); + let wrapped_block = wrapped_object.key_block_mut()?; + wrapped_block.key_value = Some(KeyValue::ByteString(Zeroizing::new(wrapped))); + wrapped_block.key_wrapping_data = Some(KeyWrappingData { + wrapping_method: WrappingMethod::Encrypt, + encryption_key_information: Some(EncryptionKeyInformation { + unique_identifier: UniqueIdentifier::TextString(kek_uid.clone()), + cryptographic_parameters: None, + }), + mac_signature_key_information: None, + mac_signature: None, + iv_counter_nonce: None, + encoding_option: Some(EncodingOption::TTLVEncoding), + }); + import_activated(&kms, &owner, uid, wrapped_object, object_type).await?; + } + assert_roundtrip(&kms, &owner, &pk, &sk).await?; + } + + // cold cache: the HSM unwraps both pairs again + drop(kms); + let kms = restart(&owner, kek_uuid, &sqlite_path, false).await?; + for uid in locate_keys(&owner, &kms, None).await? { + let uid = uid_string(&uid)?; + if let Some(sk) = uid.strip_suffix("_pk").filter(|s| s.starts_with("cw-")) { + assert_roundtrip(&kms, &owner, &uid, sk).await?; + } + } + Ok(()) +} + +fn client_key_pair(configurable: bool, sk: &str, pk: &str) -> KResult { + let common = Attributes { + activation_date: Some(time_normalize()?), + ..Default::default() + }; + if configurable { + kem_keygen( + VENDOR_ID_COSMIAN, + sk.to_owned(), + None, + pk.to_owned(), + None, + Attributes { + cryptographic_domain_parameters: Some(CryptographicDomainParameters { + qlength: Some(256), + recommended_curve: Some(RecommendedCurve::P256), + }), + cryptographic_parameters: Some(CryptographicParameters { + cryptographic_algorithm: Some(CryptographicAlgorithm::MLKEM_768), + ..Default::default() + }), + ..common + }, + ) + } else { + create_hybrid_kem_key_pair( + CryptographicAlgorithm::X25519MLKEM768, + KeyPairIdentity { + vendor_id: VENDOR_ID_COSMIAN, + private_key_uid: sk, + public_key_uid: pk, + common_attributes: common, + private_key_attributes: None, + public_key_attributes: None, + }, + None, + ) + } + .map_err(|e| KmsError::Default(e.to_string())) +} diff --git a/crate/server/src/tests/hsm/mod.rs b/crate/server/src/tests/hsm/mod.rs index 3854a409f2..c85d278aa5 100644 --- a/crate/server/src/tests/hsm/mod.rs +++ b/crate/server/src/tests/hsm/mod.rs @@ -89,6 +89,14 @@ async fn test_hsm_all() { Box::pin(hybrid_kem_dek::test_hybrid_kem_lifecycle()) .await .unwrap(); + info!("HSM: hybrid_kem_ttlv_wrapped_import"); + Box::pin(hybrid_kem_dek::test_ttlv_wrapped_kem_import()) + .await + .unwrap(); + info!("HSM: hybrid_kem_client_wrapped_import"); + Box::pin(hybrid_kem_dek::test_client_wrapped_kem_import()) + .await + .unwrap(); } info!("HSM: non_admin_kek_wrapping (issue #761)"); From 48fd45de1bf7285a638b08aa6c036ecada41f162 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sat, 24 Oct 2026 16:16:04 +0200 Subject: [PATCH 08/20] fix(server): least-privilege ConfigurableKEM masks, uniform KEM Encrypt response, policy checks on hybrid KEM use --- CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md | 8 + .../src/configurable_kem_utils.rs | 3 +- crate/crypto/src/crypto/kem.rs | 29 +- crate/crypto/src/crypto/wrap/tests.rs | 2 +- .../config/command_line/kmip_policy_config.rs | 3 + .../src/core/operations/algorithm_policy.rs | 22 +- .../src/core/operations/create_key_pair.rs | 2 +- crate/server/src/core/operations/encrypt.rs | 39 ++- .../src/tests/configurable_kem_tests.rs | 319 ++++++++++++++++++ crate/server/src/tests/hsm/hybrid_kem_dek.rs | 26 +- .../tests/kmip_policy/e2e_export_wrapping.rs | 23 +- crate/server/src/tests/mod.rs | 2 + .../cryptographic_algorithms/kmip_policy.md | 4 +- 13 files changed, 416 insertions(+), 66 deletions(-) create mode 100644 crate/server/src/tests/configurable_kem_tests.rs diff --git a/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md index 778ea27bed..9493f76fbc 100644 --- a/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md +++ b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md @@ -8,7 +8,15 @@ - Export: a PQC/hybrid KEM key can be exported with its own stored `KeyFormatType`. - TTLV: `ConfigurableKEM*` key material can be deserialized from TTLV, so TTLV-wrapped keys (`ckms keys export --wrap-key-id ... -f json-ttlv`, then import) can be unwrapped and used. - Unwrapping with a KMS wrapping key now works when that wrapping key is itself wrapped (e.g. by the HSM KEK): it is unwrapped first and its stored usage mask is checked, as already done when wrapping. +- `ConfigurableKEM` hybrid keys (ML-KEM + P-256/X25519) follow least privilege and the KMIP usage-mask rules: + - A requested `CryptographicUsageMask` is kept as is instead of being OR-ed with `Unrestricted`. Without one, the private key gets `Decrypt` and the public key `Encrypt`; `ckms pqc keys create` no longer requests `Unrestricted`. Existing keys keep their mask. + - Per-key `PrivateKeyAttributes`/`PublicKeyAttributes` inherit the `CommonAttributes` (tags, curve, ML-KEM variant) instead of replacing them, which also applies to `ReKeyKeyPair`. +- KMIP `Encrypt` with a `ConfigurableKEM` public key returns the shared secret in `Data` and the encapsulation in `IVCounterNonce`, like ML-KEM and native hybrid KEMs; it used to pack both into `Data`, so the Web UI encapsulation page downloaded an unusable file. KMIP clients parsing the old packed format must be updated (`ckms` and the Web UI already read the new one). +- KEM `Encrypt` (ML-KEM, native hybrid, `ConfigurableKEM`) rejects non-empty `Data` instead of silently ignoring it. +- Algorithm policy: a `ConfigurableKEM` key is checked on every use, not only at creation, against both its ML-KEM variant and its curve; `X25519MLKEM768` and `X448MLKEM1024` are in the policy scope and in the `DEFAULT` allowlist (non-FIPS), where they were previously always denied. +- `ckms bench`: the configurable KEM decapsulation benchmark now decapsulates the actual encapsulation. ## Testing - Add a SoftHSM2-backed hybrid KEM lifecycle test (`tests::hsm::hybrid_kem_dek`), TTLV-wrapped import tests (export wrapped by a software AES key, and client-side wrap by the HSM KEK), a wrap/unwrap unit test (`NoEncoding` and TTLV) for `ByteString`-only KEM keys, and a TTLV round-trip test for `ConfigurableKEM*` key values. +- Add `tests::configurable_kem_tests` (usage masks, encapsulation round trip, plaintext and `Sign` refusal, `Locate`, `ReKeyKeyPair`, revocation, policy on use) and make `ckms pqc` KEM tests compare the decapsulated secret with the encapsulated one. diff --git a/crate/clients/client_utils/src/configurable_kem_utils.rs b/crate/clients/client_utils/src/configurable_kem_utils.rs index f0543c73fd..0e61e64f64 100644 --- a/crate/clients/client_utils/src/configurable_kem_utils.rs +++ b/crate/clients/client_utils/src/configurable_kem_utils.rs @@ -2,7 +2,6 @@ use std::fmt; use clap::ValueEnum; use cosmian_kmip::{ - kmip_0::kmip_types::CryptographicUsageMask, kmip_2_1::{ kmip_attributes::Attributes, kmip_objects::ObjectType, @@ -173,7 +172,7 @@ pub fn build_create_configurable_kem_keypair_request, ek_uid: String, ek_attributes: Option, - common_attributes: Attributes, + common_attributes: &Attributes, ) -> Result { let tag = match ( common_attributes @@ -89,30 +89,33 @@ pub fn kem_keygen( ), }; - let access_structure = access_structure_from_attributes(vendor_id, &common_attributes).ok(); + let access_structure = access_structure_from_attributes(vendor_id, common_attributes).ok(); let (dk, ek) = ConfigurableKEM::keygen(tag, access_structure)?; + // Per-key attributes inherit the common ones, as for every other key pair type. + let with_common = |attributes: Option| { + let mut attributes = attributes.unwrap_or_default(); + attributes.merge(common_attributes, false); + attributes + }; Ok(KeyPair::new( create_dk_object( vendor_id, dk.serialize()?, - dk_attributes.unwrap_or_else(|| common_attributes.clone()), + with_common(dk_attributes), ek_uid, )?, create_ek_object( vendor_id, ek.serialize()?, - ek_attributes.unwrap_or(common_attributes), + with_common(ek_attributes), dk_uid, )?, )) } #[allow(clippy::type_complexity)] -pub fn kem_encaps( - ek: &[u8], - _data: Option<&Zeroizing>>, -) -> Result<(Zeroizing>, Zeroizing>), CryptoError> { +pub fn kem_encaps(ek: &[u8]) -> Result<(Zeroizing>, Zeroizing>), CryptoError> { let ek = ConfigurableKemEk::deserialize(ek).map_err(|e| { CryptoError::ConversionError(format!( "failed deserializing the configurable-KEM encapsulation: {e}" @@ -168,7 +171,10 @@ fn create_dk_object( attributes.object_type = Some(ObjectType::PrivateKey); attributes.key_format_type = Some(KeyFormatType::ConfigurableKEMSecretKey); - attributes.set_cryptographic_usage_mask_bits(CryptographicUsageMask::Unrestricted); + // A requested mask is kept as is; decapsulation is a KMIP Decrypt. + attributes.cryptographic_usage_mask = attributes + .cryptographic_usage_mask + .or(Some(CryptographicUsageMask::Decrypt)); attributes.link = Some(vec![Link { link_type: LinkType::PublicKeyLink, linked_object_identifier: LinkedObjectIdentifier::TextString(ek_uid), @@ -204,7 +210,10 @@ fn create_ek_object( attributes.sensitive = None; attributes.object_type = Some(ObjectType::PublicKey); attributes.key_format_type = Some(KeyFormatType::ConfigurableKEMPublicKey); - attributes.set_cryptographic_usage_mask_bits(CryptographicUsageMask::Encrypt); + // A requested mask is kept as is; encapsulation is a KMIP Encrypt. + attributes.cryptographic_usage_mask = attributes + .cryptographic_usage_mask + .or(Some(CryptographicUsageMask::Encrypt)); attributes.link = Some(vec![Link { link_type: LinkType::PrivateKeyLink, linked_object_identifier: LinkedObjectIdentifier::TextString(dk_uid), diff --git a/crate/crypto/src/crypto/wrap/tests.rs b/crate/crypto/src/crypto/wrap/tests.rs index 1ef56c0733..7cd857c5f0 100644 --- a/crate/crypto/src/crypto/wrap/tests.rs +++ b/crate/crypto/src/crypto/wrap/tests.rs @@ -506,7 +506,7 @@ fn test_wrap_unwrap_byte_string_kem_keys() -> CryptoResult<()> { None, "ek".to_owned(), None, - common, + &common, )?; let mut native = create_hybrid_kem_key_pair( CryptographicAlgorithm::X25519MLKEM768, diff --git a/crate/server/src/config/command_line/kmip_policy_config.rs b/crate/server/src/config/command_line/kmip_policy_config.rs index 1735265e47..b1eaf2639f 100644 --- a/crate/server/src/config/command_line/kmip_policy_config.rs +++ b/crate/server/src/config/command_line/kmip_policy_config.rs @@ -240,6 +240,9 @@ impl KmipAllowlistsConfig { CryptographicAlgorithm::MLKEM_512, CryptographicAlgorithm::MLKEM_768, CryptographicAlgorithm::MLKEM_1024, + // Native hybrid KEM groups (classical ECDH + ML-KEM). + CryptographicAlgorithm::X25519MLKEM768, + CryptographicAlgorithm::X448MLKEM1024, // ML-DSA (post-quantum lattice-based signature) variants. CryptographicAlgorithm::MLDSA_44, CryptographicAlgorithm::MLDSA_65, diff --git a/crate/server/src/core/operations/algorithm_policy.rs b/crate/server/src/core/operations/algorithm_policy.rs index b4a393a7ce..cdcf06b8d9 100644 --- a/crate/server/src/core/operations/algorithm_policy.rs +++ b/crate/server/src/core/operations/algorithm_policy.rs @@ -424,7 +424,16 @@ pub(crate) fn enforce_kmip_algorithm_policy_for_retrieved_key( .attributes() .unwrap_or_else(|_| owm.attributes()); - if let Some(alg) = attrs.cryptographic_algorithm { + // A ConfigurableKEM key also carries its post-quantum component in its parameters. + let inner_kem_algorithm = attrs + .cryptographic_parameters + .as_ref() + .and_then(|p| p.cryptographic_algorithm) + .filter(|_| attrs.cryptographic_algorithm == Some(CryptographicAlgorithm::ConfigurableKEM)); + for alg in [attrs.cryptographic_algorithm, inner_kem_algorithm] + .into_iter() + .flatten() + { validate_algorithm(alg, wl.algorithms.as_ref()).map_err(|e| match e { KmsError::Kmip21Error(_, _) | KmsError::InvalidRequest(_) => e, other => KmsError::Kmip21Error( @@ -446,10 +455,13 @@ pub(crate) fn enforce_kmip_algorithm_policy_for_retrieved_key( })?; } - // Enforce curves for EC keys when the curve is known. + // Enforce curves for EC keys, and for the pre-quantum part of hybrid KEM keys, when known. if let Ok(kb) = owm.object().key_block() { match kb.key_format_type { - KeyFormatType::TransparentECPublicKey | KeyFormatType::TransparentECPrivateKey => { + KeyFormatType::TransparentECPublicKey + | KeyFormatType::TransparentECPrivateKey + | KeyFormatType::ConfigurableKEMPublicKey + | KeyFormatType::ConfigurableKEMSecretKey => { if let Some(domain) = attrs.cryptographic_domain_parameters.as_ref() { if let Some(curve) = domain.recommended_curve { validate_curve(curve, wl.curves.as_ref()).map_err(|e| match e { @@ -914,10 +926,12 @@ fn validate_algorithm( | CryptographicAlgorithm::MLKEM_512 | CryptographicAlgorithm::MLKEM_768 | CryptographicAlgorithm::MLKEM_1024 + | CryptographicAlgorithm::X25519MLKEM768 + | CryptographicAlgorithm::X448MLKEM1024 | CryptographicAlgorithm::MLDSA_44 | CryptographicAlgorithm::MLDSA_65 | CryptographicAlgorithm::MLDSA_87 => { - // Configurable KEM, ML-KEM, and ML-DSA variants are in-scope in non-FIPS mode. + // Configurable KEM, ML-KEM, hybrid KEM and ML-DSA variants are in-scope in non-FIPS mode. // If an allowlist is configured, they must still be explicitly allowed. } _ => { diff --git a/crate/server/src/core/operations/create_key_pair.rs b/crate/server/src/core/operations/create_key_pair.rs index 23288be964..8386ee561e 100644 --- a/crate/server/src/core/operations/create_key_pair.rs +++ b/crate/server/src/core/operations/create_key_pair.rs @@ -523,7 +523,7 @@ pub(super) fn generate_key_pair( request.private_key_attributes, public_key_uid.to_owned(), request.public_key_attributes, - common_attributes, + &common_attributes, ), #[cfg(feature = "non-fips")] CryptographicAlgorithm::CoverCrypt => { diff --git a/crate/server/src/core/operations/encrypt.rs b/crate/server/src/core/operations/encrypt.rs index 82d3aceb19..3912168842 100644 --- a/crate/server/src/core/operations/encrypt.rs +++ b/crate/server/src/core/operations/encrypt.rs @@ -605,27 +605,16 @@ fn encrypt_with_public_key( match &key_block.key_format_type { #[cfg(feature = "non-fips")] KeyFormatType::ConfigurableKEMPublicKey => { - use cosmian_kms_server_database::reexport::cosmian_kms_crypto::{ - crypto::kem::kem_encaps, reexport::cosmian_crypto_core::bytes_ser_de::Serializable, - }; + use cosmian_kms_server_database::reexport::cosmian_kms_crypto::crypto::kem::kem_encaps; + reject_kem_plaintext(request)?; let (ek_bytes, _) = owm.object().key_block()?.key_bytes_and_attributes()?; - let (key, enc) = kem_encaps(&ek_bytes, request.data.as_ref())?; + let (shared_secret, encapsulation) = kem_encaps(&ek_bytes)?; Ok(EncryptResponse { unique_identifier: UniqueIdentifier::TextString(owm.id().to_owned()), - data: Some( - (key, enc) - .serialize() - .map_err(|e| { - KmsError::ConversionError(format!( - "failed serializing the configurable-KEM encapsulation \ - results: {e}" - )) - })? - .to_vec(), - ), - i_v_counter_nonce: None, - correlation_value: None, + data: Some(shared_secret.to_vec()), + i_v_counter_nonce: Some(encapsulation.to_vec()), + correlation_value: request.correlation_value.clone(), authenticated_encryption_tag: None, }) } @@ -657,6 +646,7 @@ fn encrypt_with_public_key( ) ) { use cosmian_kms_server_database::reexport::cosmian_kms_crypto::crypto::pqc::ml_kem::ml_kem_encapsulate; + reject_kem_plaintext(request)?; let (pub_bytes, _) = key_block.key_bytes_and_attributes()?; let (shared_secret, ciphertext) = ml_kem_encapsulate(&pub_bytes)?; return Ok(EncryptResponse { @@ -673,6 +663,7 @@ fn encrypt_with_public_key( ) = key_algo { use cosmian_kms_server_database::reexport::cosmian_kms_crypto::crypto::pqc::hybrid_kem::hybrid_kem_encapsulate; + reject_kem_plaintext(request)?; let (pub_bytes, _) = key_block.key_bytes_and_attributes()?; let (shared_secret, ciphertext) = hybrid_kem_encapsulate(algo, &pub_bytes)?; return Ok(EncryptResponse { @@ -710,6 +701,20 @@ fn encrypt_with_public_key( } } +/// A KEM encapsulation generates its own shared secret: refuse plaintext rather than +/// silently ignoring it. +#[cfg(feature = "non-fips")] +fn reject_kem_plaintext(request: &Encrypt) -> KResult<()> { + if request.data.as_ref().is_some_and(|data| !data.is_empty()) { + return Err(KmsError::InvalidRequest( + "Encrypt: a KEM public key encapsulates a fresh shared secret; data to encrypt must \ + be empty" + .to_owned(), + )); + } + Ok(()) +} + fn encrypt_with_pkey( request: &Encrypt, #[cfg(feature = "non-fips")] server_params: &ServerParams, diff --git a/crate/server/src/tests/configurable_kem_tests.rs b/crate/server/src/tests/configurable_kem_tests.rs new file mode 100644 index 0000000000..242096ac94 --- /dev/null +++ b/crate/server/src/tests/configurable_kem_tests.rs @@ -0,0 +1,319 @@ +//! Lifecycle and usage rules of `ConfigurableKEM` hybrid keys (e.g. ML-KEM-768/P-256): +//! least-privilege usage masks, encapsulation round trip, refusal of non-KEM uses, +//! re-key, revocation and algorithm-policy enforcement on use. +use std::sync::Arc; + +use cosmian_kms_client_utils::configurable_kem_utils::{ + KemAlgorithm, build_create_configurable_kem_keypair_request, +}; +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::{CryptographicUsageMask, RevocationReason, RevocationReasonCode}, + kmip_2_1::{ + extra::tagging::VENDOR_ID_COSMIAN, + kmip_attributes::Attributes, + kmip_operations::{ + CreateKeyPair, GetAttributes, Locate, Operation, ReKeyKeyPair, Revoke, Sign, + }, + kmip_types::{CryptographicAlgorithm, RecommendedCurve, UniqueIdentifier}, + requests::{create_pqc_key_pair_request, decrypt_request, encrypt_request}, + }, +}; +use cosmian_logger::log_init; +use uuid::Uuid; + +use crate::{ + config::{ClapConfig, ServerParams}, + core::{KMS, operations::algorithm_policy::enforce_kmip_algorithm_policy_for_request}, + error::KmsError, + middlewares::UserId, + result::KResult, + tests::test_utils::https_clap_config, +}; + +async fn kms_from(conf: ClapConfig) -> KResult> { + Ok(Arc::new( + KMS::instantiate(Arc::new(ServerParams::try_from(conf)?)).await?, + )) +} + +fn hybrid_request(tag: &str) -> KResult { + build_create_configurable_kem_keypair_request( + VENDOR_ID_COSMIAN, + None, + [tag], + KemAlgorithm::MlKem768P256, + false, + None, + ) + .map_err(|e| KmsError::InvalidRequest(e.to_string())) +} + +/// Returns the (private, public) key identifiers +async fn create(kms: &KMS, owner: &UserId, request: CreateKeyPair) -> KResult<(String, String)> { + let response = kms.create_key_pair(request, owner).await?; + Ok(( + response.private_key_unique_identifier.to_string(), + response.public_key_unique_identifier.to_string(), + )) +} + +async fn attributes(kms: &KMS, owner: &UserId, uid: &str) -> KResult { + Ok(kms + .get_attributes( + GetAttributes { + unique_identifier: Some(UniqueIdentifier::TextString(uid.to_owned())), + attribute_reference: None, + }, + owner, + ) + .await? + .attributes) +} + +/// Returns (shared secret, encapsulation) +async fn encapsulate(kms: &KMS, owner: &UserId, pk: &str) -> KResult<(Vec, Vec)> { + let response = kms + .encrypt(encrypt_request(pk, None, vec![], None, None, None)?, owner) + .await?; + Ok(( + response + .data + .ok_or_else(|| KmsError::ServerError("no shared secret".to_owned()))?, + response + .i_v_counter_nonce + .ok_or_else(|| KmsError::ServerError("no encapsulation".to_owned()))?, + )) +} + +async fn decapsulate(kms: &KMS, owner: &UserId, sk: &str, enc: Vec) -> KResult> { + let response = kms + .decrypt(decrypt_request(sk, None, enc, None, None, None), owner) + .await?; + Ok(response + .data + .ok_or_else(|| KmsError::ServerError("no decapsulated secret".to_owned()))? + .to_vec()) +} + +async fn assert_round_trip(kms: &KMS, owner: &UserId, sk: &str, pk: &str) -> KResult<()> { + let (secret, enc) = encapsulate(kms, owner, pk).await?; + assert!(!secret.is_empty()); + assert_eq!(decapsulate(kms, owner, sk, enc).await?, secret); + Ok(()) +} + +#[tokio::test] +async fn test_configurable_kem_lifecycle() -> KResult<()> { + log_init(option_env!("RUST_LOG")); + let kms = kms_from(https_clap_config()).await?; + let owner = UserId::from("configurable_kem_lifecycle"); + let tag = format!("hybrid-kem-{}", Uuid::new_v4()); + + let (sk, pk) = create(&kms, &owner, hybrid_request(&tag)?).await?; + + // least privilege: decapsulation and encapsulation only + let sk_attributes = attributes(&kms, &owner, &sk).await?; + assert_eq!( + sk_attributes.cryptographic_usage_mask, + Some(CryptographicUsageMask::Decrypt) + ); + assert_eq!( + attributes(&kms, &owner, &pk) + .await? + .cryptographic_usage_mask, + Some(CryptographicUsageMask::Encrypt) + ); + + assert_round_trip(&kms, &owner, &sk, &pk).await?; + + // plaintext is refused instead of being silently ignored + let encrypt_with_data = kms + .encrypt( + encrypt_request(&pk, None, b"plaintext".to_vec(), None, None, None)?, + &owner, + ) + .await; + assert!(encrypt_with_data.is_err(), "KEM Encrypt accepted plaintext"); + + // a KEM key cannot sign + let sign = kms + .sign( + Sign { + unique_identifier: Some(UniqueIdentifier::TextString(sk.clone())), + data: Some(b"message".to_vec().into()), + ..Sign::default() + }, + &owner, + ) + .await; + assert!(sign.is_err(), "a KEM private key signed data"); + + // both halves are found by their tag + let located = locate_by_tag(&kms, &owner, &tag).await?; + for uid in [&sk, &pk] { + assert!(located.contains(uid), "Locate by tag missed {uid}"); + } + + // re-key: the new pair keeps the masks and works + let rekeyed = kms + .rekey_keypair( + ReKeyKeyPair { + private_key_unique_identifier: Some(UniqueIdentifier::TextString(sk.clone())), + ..ReKeyKeyPair::default() + }, + &owner, + ) + .await?; + let sk2 = rekeyed.private_key_unique_identifier.to_string(); + let pk2 = rekeyed.public_key_unique_identifier.to_string(); + assert_ne!(sk2, sk); + assert_round_trip(&kms, &owner, &sk2, &pk2).await?; + assert_eq!( + attributes(&kms, &owner, &sk2) + .await? + .cryptographic_usage_mask, + Some(CryptographicUsageMask::Decrypt) + ); + + // revocation: the public key no longer encapsulates; KMIP still lets a compromised + // private key decapsulate (Decrypt accepts Compromised keys) to recover existing data + let (secret, enc) = encapsulate(&kms, &owner, &pk2).await?; + kms.revoke( + Revoke { + unique_identifier: Some(UniqueIdentifier::TextString(sk2.clone())), + revocation_reason: RevocationReason { + revocation_reason_code: RevocationReasonCode::KeyCompromise, + revocation_message: None, + }, + compromise_occurrence_date: None, + cascade: true, + }, + &owner, + ) + .await?; + assert!( + encapsulate(&kms, &owner, &pk2).await.is_err(), + "a revoked KEM public key encapsulated" + ); + assert_eq!(decapsulate(&kms, &owner, &sk2, enc).await?, secret); + Ok(()) +} + +async fn locate_by_tag(kms: &KMS, owner: &UserId, tag: &str) -> KResult> { + let mut search = Attributes::default(); + search.set_tags(VENDOR_ID_COSMIAN, [tag])?; + Ok(kms + .locate( + Locate { + attributes: search, + ..Locate::default() + }, + owner, + ) + .await? + .unique_identifier + .unwrap_or_default() + .iter() + .map(ToString::to_string) + .collect()) +} + +/// A per-key usage mask is honoured, not OR-ed with `Unrestricted`, and the per-key +/// attributes still inherit the common ones (tags). +#[tokio::test] +async fn test_configurable_kem_requested_mask_is_honoured() -> KResult<()> { + log_init(option_env!("RUST_LOG")); + let kms = kms_from(https_clap_config()).await?; + let owner = UserId::from("configurable_kem_mask"); + let tag = format!("hybrid-kem-mask-{}", Uuid::new_v4()); + + let mut request = hybrid_request(&tag)?; + if let Some(common) = request.common_attributes.as_mut() { + common.cryptographic_usage_mask = Some(CryptographicUsageMask::Unrestricted); + } + request.private_key_attributes = Some(Attributes { + cryptographic_usage_mask: Some(CryptographicUsageMask::Decrypt), + ..Attributes::default() + }); + let (sk, pk) = create(&kms, &owner, request).await?; + + let sk_attributes = attributes(&kms, &owner, &sk).await?; + assert_eq!( + sk_attributes.cryptographic_usage_mask, + Some(CryptographicUsageMask::Decrypt) + ); + assert!(locate_by_tag(&kms, &owner, &tag).await?.contains(&sk)); + assert_round_trip(&kms, &owner, &sk, &pk).await +} + +/// The algorithm policy checks both components of a hybrid key when it is used, so +/// tightening the policy after creation takes effect on existing keys. +#[tokio::test] +async fn test_configurable_kem_policy_on_use() -> KResult<()> { + log_init(option_env!("RUST_LOG")); + let conf = https_clap_config(); + let sqlite_path = conf.db.sqlite_path.clone(); + let owner = UserId::from("configurable_kem_policy"); + let (sk, pk) = { + let kms = kms_from(conf).await?; + create(&kms, &owner, hybrid_request("hybrid-kem-policy")?).await? + }; + + // a server restarted on the same database with a tighter policy + let with_policy = |algorithms: Vec, curve: RecommendedCurve| { + let mut conf = https_clap_config(); + conf.db.sqlite_path.clone_from(&sqlite_path); + conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); + conf.kmip_policy.allowlists.algorithms = Some(algorithms); + conf.kmip_policy.allowlists.curves = Some(vec![curve]); + conf + }; + let ml_kem_768 = vec![ + CryptographicAlgorithm::ConfigurableKEM, + CryptographicAlgorithm::MLKEM_768, + ]; + + let allowed = kms_from(with_policy(ml_kem_768.clone(), RecommendedCurve::P256)).await?; + assert_round_trip(&allowed, &owner, &sk, &pk).await?; + drop(allowed); + + let curve_denied = kms_from(with_policy(ml_kem_768, RecommendedCurve::P384)).await?; + assert!( + encapsulate(&curve_denied, &owner, &pk).await.is_err(), + "P-256 component used while only P-384 is allowed" + ); + drop(curve_denied); + + let ml_kem_denied = kms_from(with_policy( + vec![ + CryptographicAlgorithm::ConfigurableKEM, + CryptographicAlgorithm::MLKEM_512, + ], + RecommendedCurve::P256, + )) + .await?; + assert!( + encapsulate(&ml_kem_denied, &owner, &pk).await.is_err(), + "ML-KEM-768 component used while only ML-KEM-512 is allowed" + ); + Ok(()) +} + +/// Native hybrid KEM groups are in the `DEFAULT` policy scope. +#[test] +fn test_default_policy_allows_native_hybrid_kem() -> KResult<()> { + let mut conf = ClapConfig::default(); + conf.kmip_policy.policy_id = Some("DEFAULT".to_owned()); + let params = ServerParams::try_from(conf)?; + for algorithm in [ + CryptographicAlgorithm::X25519MLKEM768, + CryptographicAlgorithm::X448MLKEM1024, + ] { + let request = create_pqc_key_pair_request(VENDOR_ID_COSMIAN, ["t"], algorithm, false)?; + enforce_kmip_algorithm_policy_for_request( + ¶ms, + &Operation::CreateKeyPair(Box::new(request)), + )?; + } + Ok(()) +} diff --git a/crate/server/src/tests/hsm/hybrid_kem_dek.rs b/crate/server/src/tests/hsm/hybrid_kem_dek.rs index 3c89be2869..7bdb23e45b 100644 --- a/crate/server/src/tests/hsm/hybrid_kem_dek.rs +++ b/crate/server/src/tests/hsm/hybrid_kem_dek.rs @@ -31,11 +31,8 @@ use cosmian_kms_server_database::reexport::{ }, time_normalize, }, - cosmian_kms_crypto::{ - crypto::{ - KeyPair, KeyPairIdentity, kem::kem_keygen, pqc::hybrid_kem::create_hybrid_kem_key_pair, - }, - reexport::cosmian_crypto_core::bytes_ser_de::Serializable, + cosmian_kms_crypto::crypto::{ + KeyPair, KeyPairIdentity, kem::kem_keygen, pqc::hybrid_kem::create_hybrid_kem_key_pair, }, }; use uuid::Uuid; @@ -167,18 +164,15 @@ async fn encapsulate(kms: &Arc, owner: &str, pk_uid: &str) -> KResult<(Vec< let Operation::EncryptResponse(response) = &response[0] else { return Err(KmsError::ServerError("invalid response".to_owned())); }; - let data = response + let secret = response .data .clone() - .ok_or_else(|| KmsError::ServerError("empty encapsulation data".to_owned()))?; - if let Some(enc) = response.i_v_counter_nonce.clone() { - // native hybrid KEM - return Ok((data, enc)); - } - // ConfigurableKEM: serialized (shared secret, encapsulation) - let (secret, enc) = <(Zeroizing>, Zeroizing>)>::deserialize(&data) - .map_err(|e| KmsError::Default(e.to_string()))?; - Ok((secret.to_vec(), enc.to_vec())) + .ok_or_else(|| KmsError::ServerError("empty shared secret".to_owned()))?; + let enc = response + .i_v_counter_nonce + .clone() + .ok_or_else(|| KmsError::ServerError("empty encapsulation".to_owned()))?; + Ok((secret, enc)) } async fn decapsulate(kms: &Arc, owner: &str, sk_uid: &str, enc: &[u8]) -> KResult> { @@ -639,7 +633,7 @@ fn client_key_pair(configurable: bool, sk: &str, pk: &str) -> KResult { None, pk.to_owned(), None, - Attributes { + &Attributes { cryptographic_domain_parameters: Some(CryptographicDomainParameters { qlength: Some(256), recommended_curve: Some(RecommendedCurve::P256), diff --git a/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs b/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs index dae1e05248..6c6b322d46 100644 --- a/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs +++ b/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs @@ -9,7 +9,7 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ HashingAlgorithm, PaddingMethod, }, kmip_2_1::{ - extra::tagging::SYSTEM_TAG_PUBLIC_KEY, + extra::tagging::{SYSTEM_TAG_PUBLIC_KEY, VENDOR_ID_COSMIAN}, kmip_attributes::Attributes, kmip_objects::ObjectType, kmip_operations::{ @@ -22,16 +22,10 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ time_normalize, }; -#[cfg(feature = "non-fips")] -use cosmian_kms_server_database::reexport::cosmian_kms_crypto::reexport::cosmian_crypto_core::bytes_ser_de::Serializable; -#[cfg(feature = "non-fips")] -use zeroize::Zeroizing; - use crate::{ error::KmsError, tests::test_utils::{https_clap_config_opts, post_2_1, test_app_with_clap_config}, }; -use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::extra::tagging::VENDOR_ID_COSMIAN; async fn create_kek_and_target_for_export(app: &S) -> (String, String) where @@ -299,16 +293,17 @@ async fn e2e_default_policy_allows_configurable_kem_roundtrip() { ) .expect("encrypt_request should build"); let enc_resp: EncryptResponse = post_2_1(&app, &enc_req).await.unwrap(); - let enc_data = enc_resp.data.expect("encrypt response should include data"); - - let (expected_key, encapsulation) = - <(Zeroizing>, Zeroizing>)>::deserialize(&enc_data) - .expect("configurable-kem encrypt response should deserialize"); + let expected_key = enc_resp + .data + .expect("encrypt response should include the secret"); + let encapsulation = enc_resp + .i_v_counter_nonce + .expect("encrypt response should include the encapsulation"); let dec_req = decrypt_request( &sk_uid, None, - encapsulation.to_vec(), + encapsulation, None, None, Some(CryptographicParameters { @@ -319,5 +314,5 @@ async fn e2e_default_policy_allows_configurable_kem_roundtrip() { let dec_resp: DecryptResponse = post_2_1(&app, &dec_req).await.unwrap(); let recovered_key = dec_resp.data.expect("decrypt response should include data"); - assert_eq!(recovered_key.to_vec(), expected_key.to_vec()); + assert_eq!(recovered_key.to_vec(), expected_key); } diff --git a/crate/server/src/tests/mod.rs b/crate/server/src/tests/mod.rs index 2fbdd2f3eb..654cf19748 100644 --- a/crate/server/src/tests/mod.rs +++ b/crate/server/src/tests/mod.rs @@ -2,6 +2,8 @@ mod audit_middleware; mod azure_ekm; mod bulk_encrypt_decrypt_tests; #[cfg(feature = "non-fips")] +mod configurable_kem_tests; +#[cfg(feature = "non-fips")] mod cover_crypt_tests; mod crl_tests; #[cfg(feature = "non-fips")] diff --git a/documentation/docs/certifications_and_compliance/cryptographic_algorithms/kmip_policy.md b/documentation/docs/certifications_and_compliance/cryptographic_algorithms/kmip_policy.md index b025352c32..3c1828ea69 100644 --- a/documentation/docs/certifications_and_compliance/cryptographic_algorithms/kmip_policy.md +++ b/documentation/docs/certifications_and_compliance/cryptographic_algorithms/kmip_policy.md @@ -54,7 +54,7 @@ It constrains KMIP requests by validating their declared cryptographic parameter In particular, it allowlists: - Cryptographic algorithms (all builds): `AES`, `RSA`, `ECDSA`, `ECDH`, `EC`, `HMACSHA256`, `HMACSHA384`, `HMACSHA512` -- Cryptographic algorithms (non-FIPS builds only): `ChaCha20Poly1305`, `Ed25519`, `SHAKE128`, `SHAKE256`, `ConfigurableKEM`, `MLKEM_512`, `MLKEM_768`, `MLKEM_1024` +- Cryptographic algorithms (non-FIPS builds only): `ChaCha20Poly1305`, `Ed25519`, `SHAKE128`, `SHAKE256`, `ConfigurableKEM`, `MLKEM_512`, `MLKEM_768`, `MLKEM_1024`, `X25519MLKEM768`, `X448MLKEM1024`, `MLDSA_44`, `MLDSA_65`, `MLDSA_87` - Hash functions: `SHA256`, `SHA384`, `SHA512`, `SHA3256`, `SHA3384`, `SHA3512` - Signature algorithms: `SHA256WithRSAEncryption`, `SHA384WithRSAEncryption`, `SHA512WithRSAEncryption`, `RSASSAPSS`, `ECDSAWithSHA256`, `ECDSAWithSHA384`, `ECDSAWithSHA512` - Curves: `P256`, `P384`, `P521`, `CURVE25519`, `CURVE448` @@ -63,6 +63,8 @@ In particular, it allowlists: - MGF hashes: `SHA256`, `SHA384`, `SHA512` - Mask generators: `MGF1` +A `ConfigurableKEM` hybrid key (for example ML-KEM-768/P-256) is checked component by component, at creation and on every use: its ML-KEM variant against the algorithm allowlist and its curve against the curve allowlist. + It also enforces key-size constraints: - RSA key sizes: `3072`, `4096` (in addition to baseline structural constraints) From 714a71eaf14b6b382addb2ac689f4d3edc7a8ed2 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sat, 24 Oct 2026 16:17:45 +0200 Subject: [PATCH 09/20] fix(cli): read the uniform KEM encapsulation response and check decapsulated secrets --- crate/clients/ckms/src/tests/pqc/mod.rs | 8 ++++-- crate/clients/clap/src/actions/bench/kmip.rs | 2 +- .../clap/src/actions/pqc/encapsulate.rs | 27 +++++-------------- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/crate/clients/ckms/src/tests/pqc/mod.rs b/crate/clients/ckms/src/tests/pqc/mod.rs index 35aead08db..e4c9282adb 100644 --- a/crate/clients/ckms/src/tests/pqc/mod.rs +++ b/crate/clients/ckms/src/tests/pqc/mod.rs @@ -178,10 +178,14 @@ fn test_kem(cli_conf_path: &str, name: &str, algorithm: &str) -> CosmianResult<( &encapsulation_file, &session_key_file, )?; - assert!(session_key_file.exists()); - + // `pqc encrypt` writes the shared secret next to the encapsulation, as `.key` + let shared_secret = std::fs::read(encapsulation_file.with_extension("key"))?; let session_key = std::fs::read(&session_key_file)?; assert!(!session_key.is_empty()); + assert_eq!( + session_key, shared_secret, + "{name}: decapsulated secret differs" + ); Ok(()) } diff --git a/crate/clients/clap/src/actions/bench/kmip.rs b/crate/clients/clap/src/actions/bench/kmip.rs index 8c7a99fdaf..caa876f752 100644 --- a/crate/clients/clap/src/actions/bench/kmip.rs +++ b/crate/clients/clap/src/actions/bench/kmip.rs @@ -755,7 +755,7 @@ fn bench_kem(c: &mut Criterion, client: &KmsClient, rt: &Runtime, transport: Tra let enc_resp = rt .block_on(client.encrypt(enc_req)) .expect("pre-encapsulate for decapsulate"); - let ct = enc_resp.data.map_or_else(Vec::new, |z| z.to_vec()); + let ct = enc_resp.i_v_counter_nonce.unwrap_or_default(); let dec_req = decrypt_request( &priv_id.to_string(), None, diff --git a/crate/clients/clap/src/actions/pqc/encapsulate.rs b/crate/clients/clap/src/actions/pqc/encapsulate.rs index 0217e43bc7..87a833033f 100644 --- a/crate/clients/clap/src/actions/pqc/encapsulate.rs +++ b/crate/clients/clap/src/actions/pqc/encapsulate.rs @@ -1,15 +1,11 @@ use std::{fs::File, io::Write, path::PathBuf}; use clap::Parser; -use cosmian_crypto_core::bytes_ser_de::Serializable; use cosmian_kms_client::{KmsClient, kmip_2_1::requests::encrypt_request}; use crate::{ actions::{console, labels::KEY_ID, shared::get_key_uid}, - error::{ - KmsCliError, - result::{KmsCliResult, KmsCliResultHelper}, - }, + error::result::{KmsCliResult, KmsCliResultHelper}, }; /// Encapsulate using a PQC public key (ML-KEM-512/768/1024, X25519MLKEM768, @@ -48,22 +44,11 @@ impl EncapsulateAction { .await .with_context(|| "KEM encapsulation failed")?; - let (shared_secret, ciphertext) = if let Some(ciphertext) = response.i_v_counter_nonce { - // Standard PQC KEM: shared_secret in data, ciphertext in iv_counter_nonce - let shared_secret = response.data.context("shared secret is empty")?; - (shared_secret, ciphertext) - } else { - // ConfigurableKEM: both packed as a serialized tuple in data - let data = response.data.context("encrypted data is empty")?; - let (session_key, encapsulation) = - <(zeroize::Zeroizing>, zeroize::Zeroizing>)>::deserialize(&data) - .map_err(|e| { - KmsCliError::Conversion(format!( - "failed deserializing KEM encapsulation from response data: {e}" - )) - })?; - (session_key.to_vec(), encapsulation.to_vec()) - }; + // Every KEM returns the shared secret in data and the ciphertext in iv_counter_nonce + let shared_secret = response.data.context("shared secret is empty")?; + let ciphertext = response + .i_v_counter_nonce + .context("encapsulation is empty")?; let output_file = self .output_file From 2fcec87ddbf1aa5f35fa37784463f719c4099f97 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sat, 24 Oct 2026 16:32:33 +0200 Subject: [PATCH 10/20] fix(server): enforce the algorithm policy on ConfigurableKEM components read from the key bytes --- CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md | 4 +- crate/crypto/src/crypto/kem.rs | 28 +++- .../src/core/operations/algorithm_policy.rs | 50 +++++-- crate/server/src/core/operations/decrypt.rs | 12 +- crate/server/src/core/operations/encrypt.rs | 12 +- .../src/tests/configurable_kem_tests.rs | 138 ++++++++++++++---- 6 files changed, 193 insertions(+), 51 deletions(-) diff --git a/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md index 9493f76fbc..c8ade11bdc 100644 --- a/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md +++ b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md @@ -13,10 +13,10 @@ - Per-key `PrivateKeyAttributes`/`PublicKeyAttributes` inherit the `CommonAttributes` (tags, curve, ML-KEM variant) instead of replacing them, which also applies to `ReKeyKeyPair`. - KMIP `Encrypt` with a `ConfigurableKEM` public key returns the shared secret in `Data` and the encapsulation in `IVCounterNonce`, like ML-KEM and native hybrid KEMs; it used to pack both into `Data`, so the Web UI encapsulation page downloaded an unusable file. KMIP clients parsing the old packed format must be updated (`ckms` and the Web UI already read the new one). - KEM `Encrypt` (ML-KEM, native hybrid, `ConfigurableKEM`) rejects non-empty `Data` instead of silently ignoring it. -- Algorithm policy: a `ConfigurableKEM` key is checked on every use, not only at creation, against both its ML-KEM variant and its curve; `X25519MLKEM768` and `X448MLKEM1024` are in the policy scope and in the `DEFAULT` allowlist (non-FIPS), where they were previously always denied. +- Algorithm policy: a `ConfigurableKEM` key is checked on every use, not only at creation, against both its ML-KEM variant and its curve, read from the serialized key rather than from its KMIP attributes, so an imported key with relabelled or stripped attributes cannot bypass the policy; `X25519MLKEM768` and `X448MLKEM1024` are in the policy scope and in the `DEFAULT` allowlist (non-FIPS), where they were previously always denied. - `ckms bench`: the configurable KEM decapsulation benchmark now decapsulates the actual encapsulation. ## Testing - Add a SoftHSM2-backed hybrid KEM lifecycle test (`tests::hsm::hybrid_kem_dek`), TTLV-wrapped import tests (export wrapped by a software AES key, and client-side wrap by the HSM KEK), a wrap/unwrap unit test (`NoEncoding` and TTLV) for `ByteString`-only KEM keys, and a TTLV round-trip test for `ConfigurableKEM*` key values. -- Add `tests::configurable_kem_tests` (usage masks, encapsulation round trip, plaintext and `Sign` refusal, `Locate`, `ReKeyKeyPair`, revocation, policy on use) and make `ckms pqc` KEM tests compare the decapsulated secret with the encapsulated one. +- Add `tests::configurable_kem_tests` (usage masks, encapsulation round trip, plaintext and `Sign` refusal, `Locate`, `ReKeyKeyPair`, revocation, policy on use including relabelled and stripped imports) and make `ckms pqc` KEM tests compare the decapsulated secret with the encapsulated one. diff --git a/crate/crypto/src/crypto/kem.rs b/crate/crypto/src/crypto/kem.rs index a6d69d6030..5ff29d1435 100644 --- a/crate/crypto/src/crypto/kem.rs +++ b/crate/crypto/src/crypto/kem.rs @@ -7,7 +7,7 @@ use cosmian_cover_crypt::{ ConfigurableKEM, ConfigurableKemDk, ConfigurableKemEk, ConfigurableKemEnc, KemTag, PostQuantumKemTag, PreQuantumKemTag, }; -use cosmian_crypto_core::bytes_ser_de::Serializable; +use cosmian_crypto_core::bytes_ser_de::{Deserializer, Serializable}; use cosmian_kmip::{ kmip_0::kmip_types::CryptographicUsageMask, kmip_2_1::{ @@ -144,6 +144,32 @@ pub fn kem_encaps(ek: &[u8]) -> Result<(Zeroizing>, Zeroizing>), )) } +/// Return the pre-quantum curve and the post-quantum algorithm of a serialized +/// `ConfigurableKEM` key (public or private), as encoded in the key itself, whatever its +/// KMIP attributes claim. Policy checks must rely on this, since attributes can be relabelled. +pub fn kem_key_components( + key: &[u8], +) -> Result<(Option, Option), CryptoError> { + // Both key types serialize their `KemTag` first: read only that. + let tag: KemTag = Deserializer::new(key).read().map_err(|e| { + CryptoError::ConversionError(format!("failed reading the configurable-KEM key tag: {e}")) + })?; + let curve = |c| match c { + PreQuantumKemTag::P256 => RecommendedCurve::P256, + PreQuantumKemTag::R25519 => RecommendedCurve::CURVE25519, + }; + let algorithm = |a| match a { + PostQuantumKemTag::MlKem512 => CryptographicAlgorithm::MLKEM_512, + PostQuantumKemTag::MlKem768 => CryptographicAlgorithm::MLKEM_768, + }; + Ok(match tag { + KemTag::PreQuantum(c) => (Some(curve(c)), None), + KemTag::PostQuantum(a) => (None, Some(algorithm(a))), + KemTag::Hybridized(c, a) => (Some(curve(c)), Some(algorithm(a))), + KemTag::Abe => (None, Some(CryptographicAlgorithm::CoverCrypt)), + }) +} + pub fn kem_decaps(dk: &[u8], enc: &[u8]) -> Result>, CryptoError> { let dk = ConfigurableKemDk::deserialize(dk)?; diff --git a/crate/server/src/core/operations/algorithm_policy.rs b/crate/server/src/core/operations/algorithm_policy.rs index cdcf06b8d9..c5a5b727df 100644 --- a/crate/server/src/core/operations/algorithm_policy.rs +++ b/crate/server/src/core/operations/algorithm_policy.rs @@ -424,16 +424,7 @@ pub(crate) fn enforce_kmip_algorithm_policy_for_retrieved_key( .attributes() .unwrap_or_else(|_| owm.attributes()); - // A ConfigurableKEM key also carries its post-quantum component in its parameters. - let inner_kem_algorithm = attrs - .cryptographic_parameters - .as_ref() - .and_then(|p| p.cryptographic_algorithm) - .filter(|_| attrs.cryptographic_algorithm == Some(CryptographicAlgorithm::ConfigurableKEM)); - for alg in [attrs.cryptographic_algorithm, inner_kem_algorithm] - .into_iter() - .flatten() - { + if let Some(alg) = attrs.cryptographic_algorithm { validate_algorithm(alg, wl.algorithms.as_ref()).map_err(|e| match e { KmsError::Kmip21Error(_, _) | KmsError::InvalidRequest(_) => e, other => KmsError::Kmip21Error( @@ -455,13 +446,12 @@ pub(crate) fn enforce_kmip_algorithm_policy_for_retrieved_key( })?; } - // Enforce curves for EC keys, and for the pre-quantum part of hybrid KEM keys, when known. + // Enforce curves for EC keys when the curve is known. + // `ConfigurableKEM` components are enforced from the key bytes, see + // `enforce_kmip_algorithm_policy_for_kem_components`. if let Ok(kb) = owm.object().key_block() { match kb.key_format_type { - KeyFormatType::TransparentECPublicKey - | KeyFormatType::TransparentECPrivateKey - | KeyFormatType::ConfigurableKEMPublicKey - | KeyFormatType::ConfigurableKEMSecretKey => { + KeyFormatType::TransparentECPublicKey | KeyFormatType::TransparentECPrivateKey => { if let Some(domain) = attrs.cryptographic_domain_parameters.as_ref() { if let Some(curve) = domain.recommended_curve { validate_curve(curve, wl.curves.as_ref()).map_err(|e| match e { @@ -495,6 +485,36 @@ pub(crate) fn enforce_kmip_algorithm_policy_for_retrieved_key( Ok(()) } +/// Enforce the policy on the components of a `ConfigurableKEM` key as read from the key +/// bytes (see `kem_key_components`), not from its KMIP attributes, which an importer +/// controls: a key relabelled or stripped of its component attributes cannot bypass it. +#[cfg(feature = "non-fips")] +pub(crate) fn enforce_kmip_algorithm_policy_for_kem_components( + params: &ServerParams, + operation_tag: &str, + uid_for_error: &str, + (curve, algorithm): (Option, Option), +) -> KResult<()> { + if params.kmip_policy.policy_id.is_none() { + return Ok(()); + } + let wl = KmipWhitelists::from_params(¶ms.kmip_policy); + [Some(CryptographicAlgorithm::ConfigurableKEM), algorithm] + .into_iter() + .flatten() + .try_for_each(|alg| validate_algorithm(alg, wl.algorithms.as_ref())) + .and_then(|()| curve.map_or(Ok(()), |c| validate_curve(c, wl.curves.as_ref()))) + .map_err(|e| match e { + KmsError::Kmip21Error(_, _) | KmsError::InvalidRequest(_) => e, + other => KmsError::Kmip21Error( + ErrorReason::Cryptographic_Failure, + format!( + "{operation_tag} denied by algorithm policy (uid={uid_for_error}): {other}" + ), + ), + }) +} + fn allow(wl: Option<&HashSet>, token: &T) -> bool { // Semantics: // - `None`: no restriction for this parameter (allow all) diff --git a/crate/server/src/core/operations/decrypt.rs b/crate/server/src/core/operations/decrypt.rs index d19753829a..f906725c28 100644 --- a/crate/server/src/core/operations/decrypt.rs +++ b/crate/server/src/core/operations/decrypt.rs @@ -340,9 +340,19 @@ fn decrypt_single( match &key_block.key_format_type { #[cfg(feature = "non-fips")] KeyFormatType::ConfigurableKEMSecretKey => { - use cosmian_kms_server_database::reexport::cosmian_kms_crypto::crypto::kem::kem_decaps; + use cosmian_kms_server_database::reexport::cosmian_kms_crypto::crypto::kem::{ + kem_decaps, kem_key_components, + }; + + use crate::core::operations::algorithm_policy::enforce_kmip_algorithm_policy_for_kem_components; let (dk_bytes, _) = owm.object().key_block()?.key_bytes_and_attributes()?; + enforce_kmip_algorithm_policy_for_kem_components( + server_params, + "Decrypt", + owm.id(), + kem_key_components(&dk_bytes)?, + )?; let enc = request .data .as_ref() diff --git a/crate/server/src/core/operations/encrypt.rs b/crate/server/src/core/operations/encrypt.rs index 3912168842..785793c847 100644 --- a/crate/server/src/core/operations/encrypt.rs +++ b/crate/server/src/core/operations/encrypt.rs @@ -605,10 +605,20 @@ fn encrypt_with_public_key( match &key_block.key_format_type { #[cfg(feature = "non-fips")] KeyFormatType::ConfigurableKEMPublicKey => { - use cosmian_kms_server_database::reexport::cosmian_kms_crypto::crypto::kem::kem_encaps; + use cosmian_kms_server_database::reexport::cosmian_kms_crypto::crypto::kem::{ + kem_encaps, kem_key_components, + }; + + use crate::core::operations::algorithm_policy::enforce_kmip_algorithm_policy_for_kem_components; reject_kem_plaintext(request)?; let (ek_bytes, _) = owm.object().key_block()?.key_bytes_and_attributes()?; + enforce_kmip_algorithm_policy_for_kem_components( + server_params, + "Encrypt", + owm.id(), + kem_key_components(&ek_bytes)?, + )?; let (shared_secret, encapsulation) = kem_encaps(&ek_bytes)?; Ok(EncryptResponse { unique_identifier: UniqueIdentifier::TextString(owm.id().to_owned()), diff --git a/crate/server/src/tests/configurable_kem_tests.rs b/crate/server/src/tests/configurable_kem_tests.rs index 242096ac94..9958c7fc4d 100644 --- a/crate/server/src/tests/configurable_kem_tests.rs +++ b/crate/server/src/tests/configurable_kem_tests.rs @@ -11,10 +11,15 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ kmip_2_1::{ extra::tagging::VENDOR_ID_COSMIAN, kmip_attributes::Attributes, + kmip_objects::ObjectType, kmip_operations::{ - CreateKeyPair, GetAttributes, Locate, Operation, ReKeyKeyPair, Revoke, Sign, + CreateKeyPair, Get, GetAttributes, Import, Locate, Operation, ReKeyKeyPair, Revoke, + Sign, + }, + kmip_types::{ + CryptographicAlgorithm, CryptographicDomainParameters, CryptographicParameters, + RecommendedCurve, UniqueIdentifier, }, - kmip_types::{CryptographicAlgorithm, RecommendedCurve, UniqueIdentifier}, requests::{create_pqc_key_pair_request, decrypt_request, encrypt_request}, }, }; @@ -246,56 +251,127 @@ async fn test_configurable_kem_requested_mask_is_honoured() -> KResult<()> { assert_round_trip(&kms, &owner, &sk, &pk).await } +/// Re-import the public key `pk` under a fresh identifier, with its component attributes +/// replaced by `curve` and `algorithm` (`None` strips them) in both the request and the object. +async fn import_relabelled( + kms: &KMS, + owner: &UserId, + pk: &str, + curve: Option, + algorithm: Option, +) -> KResult { + let relabel = |attributes: &mut Attributes| { + attributes.cryptographic_domain_parameters = curve.map(|c| CryptographicDomainParameters { + recommended_curve: Some(c), + ..CryptographicDomainParameters::default() + }); + attributes.cryptographic_parameters = algorithm.map(|a| CryptographicParameters { + cryptographic_algorithm: Some(a), + ..CryptographicParameters::default() + }); + }; + let mut object = kms.get(Get::from(pk), owner).await?.object; + relabel(object.attributes_mut()?); + let mut attributes = Attributes { + object_type: Some(ObjectType::PublicKey), + ..Attributes::default() + }; + relabel(&mut attributes); + let uid = Uuid::new_v4().to_string(); + kms.import( + Import { + unique_identifier: UniqueIdentifier::TextString(uid.clone()), + object_type: ObjectType::PublicKey, + replace_existing: Some(false), + key_wrap_type: None, + attributes, + object, + }, + owner, + ) + .await?; + Ok(uid) +} + /// The algorithm policy checks both components of a hybrid key when it is used, so -/// tightening the policy after creation takes effect on existing keys. +/// tightening the policy after creation takes effect on existing keys. The components are +/// read from the serialized key, so stripped or relabelled KMIP attributes cannot bypass it. #[tokio::test] async fn test_configurable_kem_policy_on_use() -> KResult<()> { log_init(option_env!("RUST_LOG")); let conf = https_clap_config(); let sqlite_path = conf.db.sqlite_path.clone(); let owner = UserId::from("configurable_kem_policy"); - let (sk, pk) = { + // an ML-KEM-768/P-256 key pair, and two imports of its public key: one with the + // components stripped, one relabelled as ML-KEM-512/P-384 + let (sk, pk, stripped, relabelled) = { let kms = kms_from(conf).await?; - create(&kms, &owner, hybrid_request("hybrid-kem-policy")?).await? + let (sk, pk) = create(&kms, &owner, hybrid_request("hybrid-kem-policy")?).await?; + let stripped = import_relabelled(&kms, &owner, &pk, None, None).await?; + let relabelled = import_relabelled( + &kms, + &owner, + &pk, + Some(RecommendedCurve::P384), + Some(CryptographicAlgorithm::MLKEM_512), + ) + .await?; + (sk, pk, stripped, relabelled) }; // a server restarted on the same database with a tighter policy - let with_policy = |algorithms: Vec, curve: RecommendedCurve| { + let with_policy = |algorithm: CryptographicAlgorithm, curve: RecommendedCurve| { let mut conf = https_clap_config(); conf.db.sqlite_path.clone_from(&sqlite_path); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); - conf.kmip_policy.allowlists.algorithms = Some(algorithms); + conf.kmip_policy.allowlists.algorithms = + Some(vec![CryptographicAlgorithm::ConfigurableKEM, algorithm]); conf.kmip_policy.allowlists.curves = Some(vec![curve]); conf }; - let ml_kem_768 = vec![ - CryptographicAlgorithm::ConfigurableKEM, - CryptographicAlgorithm::MLKEM_768, - ]; - - let allowed = kms_from(with_policy(ml_kem_768.clone(), RecommendedCurve::P256)).await?; - assert_round_trip(&allowed, &owner, &sk, &pk).await?; - drop(allowed); - let curve_denied = kms_from(with_policy(ml_kem_768, RecommendedCurve::P384)).await?; - assert!( - encapsulate(&curve_denied, &owner, &pk).await.is_err(), - "P-256 component used while only P-384 is allowed" - ); - drop(curve_denied); - - let ml_kem_denied = kms_from(with_policy( - vec![ - CryptographicAlgorithm::ConfigurableKEM, - CryptographicAlgorithm::MLKEM_512, - ], + let allowed = kms_from(with_policy( + CryptographicAlgorithm::MLKEM_768, RecommendedCurve::P256, )) .await?; - assert!( - encapsulate(&ml_kem_denied, &owner, &pk).await.is_err(), - "ML-KEM-768 component used while only ML-KEM-512 is allowed" - ); + // all three are the same key: usable when the policy allows its real components + for uid in [&pk, &stripped, &relabelled] { + assert_round_trip(&allowed, &owner, &sk, uid).await?; + } + let (_, enc) = encapsulate(&allowed, &owner, &pk).await?; + drop(allowed); + + for (algorithm, curve, denied) in [ + ( + CryptographicAlgorithm::MLKEM_768, + RecommendedCurve::P384, + "P-256", + ), + ( + CryptographicAlgorithm::MLKEM_512, + RecommendedCurve::P256, + "ML-KEM-768", + ), + // the policy matches the relabelled attributes, not the key + ( + CryptographicAlgorithm::MLKEM_512, + RecommendedCurve::P384, + "ML-KEM-768/P-256", + ), + ] { + let kms = kms_from(with_policy(algorithm, curve)).await?; + for uid in [&pk, &stripped, &relabelled] { + assert!( + encapsulate(&kms, &owner, uid).await.is_err(), + "{uid}: {denied} component used while only {algorithm}/{curve:?} is allowed" + ); + } + assert!( + decapsulate(&kms, &owner, &sk, enc.clone()).await.is_err(), + "{denied} private key used while only {algorithm}/{curve:?} is allowed" + ); + } Ok(()) } From aba6d9e7f390337b01744fa5e5a51710090472ce Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sun, 25 Oct 2026 04:39:20 +0100 Subject: [PATCH 11/20] test(server): round-trip KEM Encrypt/Decrypt for every KEM type, with and without a KEK or HSM --- .../src/tests/configurable_kem_tests.rs | 122 +++++++++++++++++- crate/server/src/tests/hsm/hybrid_kem_dek.rs | 33 ++++- 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/crate/server/src/tests/configurable_kem_tests.rs b/crate/server/src/tests/configurable_kem_tests.rs index 9958c7fc4d..822ed80fce 100644 --- a/crate/server/src/tests/configurable_kem_tests.rs +++ b/crate/server/src/tests/configurable_kem_tests.rs @@ -9,7 +9,7 @@ use cosmian_kms_client_utils::configurable_kem_utils::{ use cosmian_kms_server_database::reexport::cosmian_kmip::{ kmip_0::kmip_types::{CryptographicUsageMask, RevocationReason, RevocationReasonCode}, kmip_2_1::{ - extra::tagging::VENDOR_ID_COSMIAN, + extra::tagging::{EMPTY_TAGS, VENDOR_ID_COSMIAN}, kmip_attributes::Attributes, kmip_objects::ObjectType, kmip_operations::{ @@ -20,7 +20,10 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ CryptographicAlgorithm, CryptographicDomainParameters, CryptographicParameters, RecommendedCurve, UniqueIdentifier, }, - requests::{create_pqc_key_pair_request, decrypt_request, encrypt_request}, + requests::{ + create_pqc_key_pair_request, decrypt_request, encrypt_request, + symmetric_key_create_request, + }, }, }; use cosmian_logger::log_init; @@ -223,6 +226,121 @@ async fn locate_by_tag(kms: &KMS, owner: &UserId, tag: &str) -> KResult KResult<()> { + log_init(option_env!("RUST_LOG")); + let requests = || -> KResult> { + let mut requests = Vec::new(); + for (name, algorithm) in [ + ("ML-KEM-512/P-256", KemAlgorithm::MlKem512P256), + ("ML-KEM-768/P-256", KemAlgorithm::MlKem768P256), + ("ML-KEM-512/X25519", KemAlgorithm::MlKem512Curve25519), + ("ML-KEM-768/X25519", KemAlgorithm::MlKem768Curve25519), + ] { + requests.push(( + name, + build_create_configurable_kem_keypair_request( + VENDOR_ID_COSMIAN, + None, + [name], + algorithm, + false, + None, + ) + .map_err(|e| KmsError::InvalidRequest(e.to_string()))?, + )); + } + for (name, algorithm) in [ + ("ML-KEM-512", CryptographicAlgorithm::MLKEM_512), + ("ML-KEM-768", CryptographicAlgorithm::MLKEM_768), + ("ML-KEM-1024", CryptographicAlgorithm::MLKEM_1024), + ("X25519MLKEM768", CryptographicAlgorithm::X25519MLKEM768), + ("X448MLKEM1024", CryptographicAlgorithm::X448MLKEM1024), + ] { + requests.push(( + name, + create_pqc_key_pair_request(VENDOR_ID_COSMIAN, [name], algorithm, false)?, + )); + } + Ok(requests) + }; + + for with_kek in [false, true] { + let owner = UserId::from("kem_round_trip"); + let sqlite_path = https_clap_config().db.sqlite_path; + let conf_with = |kek: Option| { + let mut conf = https_clap_config(); + conf.db.sqlite_path.clone_from(&sqlite_path); + conf.key_encryption_key = kek; + conf + }; + let kms = kms_from(conf_with(None)).await?; + let kek = if with_kek { + Some( + kms.create( + symmetric_key_create_request( + VENDOR_ID_COSMIAN, + None, + 256, + CryptographicAlgorithm::AES, + EMPTY_TAGS, + false, + None, + )?, + &owner, + ) + .await? + .unique_identifier + .to_string(), + ) + } else { + None + }; + // keys created by a server that wraps them with the KEK, if any + drop(kms); + let kms = kms_from(conf_with(kek.clone())).await?; + let mut pairs = Vec::new(); + for (name, request) in requests()? { + let (sk, pk) = create(&kms, &owner, request).await?; + assert_round_trip(&kms, &owner, &sk, &pk) + .await + .map_err(|e| KmsError::Default(format!("{name} (kek: {with_kek}): {e}")))?; + pairs.push((name, sk, pk)); + } + // restart: the keys are read back from the database, and unwrapped if wrapped + drop(kms); + let kms = kms_from(conf_with(kek)).await?; + for (name, sk, pk) in &pairs { + for uid in [sk, pk] { + let stored = + kms.database.retrieve_object(uid).await?.ok_or_else(|| { + KmsError::ItemNotFound(format!("{name}: {uid} not stored")) + })?; + assert_eq!( + stored.object().is_wrapped(), + with_kek, + "{name}: {uid} wrapped at rest" + ); + } + let (secret, enc) = encapsulate(&kms, &owner, pk).await?; + assert_eq!( + decapsulate(&kms, &owner, sk, enc.clone()).await?, + secret, + "{name} (kek: {with_kek}) after restart" + ); + // a fresh encapsulation is not a replay of the previous one + let (secret2, enc2) = encapsulate(&kms, &owner, pk).await?; + assert_ne!(enc, enc2, "{name}: deterministic encapsulation"); + assert_ne!(secret, secret2, "{name}: repeated shared secret"); + } + } + Ok(()) +} + /// A per-key usage mask is honoured, not OR-ed with `Unrestricted`, and the per-key /// attributes still inherit the common ones (tags). #[tokio::test] diff --git a/crate/server/src/tests/hsm/hybrid_kem_dek.rs b/crate/server/src/tests/hsm/hybrid_kem_dek.rs index 7bdb23e45b..cf7840a947 100644 --- a/crate/server/src/tests/hsm/hybrid_kem_dek.rs +++ b/crate/server/src/tests/hsm/hybrid_kem_dek.rs @@ -58,6 +58,8 @@ enum Alg { MlKem512P256, MlKem768P256, X25519MlKem768, + MlKem768X25519, + X448MlKem1024, /// Pure (non hybrid) ML-KEM, stored as PKCS#8 MlKem768, } @@ -91,12 +93,27 @@ impl Alg { None, ) .map_err(|e| KmsError::Default(e.to_string()))?, + Self::MlKem768X25519 => build_create_configurable_kem_keypair_request( + VENDOR_ID_COSMIAN, + None, + [tag], + KemAlgorithm::MlKem768Curve25519, + false, + None, + ) + .map_err(|e| KmsError::Default(e.to_string()))?, Self::X25519MlKem768 => create_pqc_key_pair_request( VENDOR_ID_COSMIAN, [tag], CryptographicAlgorithm::X25519MLKEM768, false, )?, + Self::X448MlKem1024 => create_pqc_key_pair_request( + VENDOR_ID_COSMIAN, + [tag], + CryptographicAlgorithm::X448MLKEM1024, + false, + )?, Self::MlKem768 => create_pqc_key_pair_request( VENDOR_ID_COSMIAN, [tag], @@ -109,7 +126,7 @@ impl Alg { /// Stored algorithm, private key format, public key format const fn stored(self) -> (CryptographicAlgorithm, KeyFormatType, KeyFormatType) { match self { - Self::MlKem512P256 | Self::MlKem768P256 => ( + Self::MlKem512P256 | Self::MlKem768P256 | Self::MlKem768X25519 => ( CryptographicAlgorithm::ConfigurableKEM, KeyFormatType::ConfigurableKEMSecretKey, KeyFormatType::ConfigurableKEMPublicKey, @@ -119,6 +136,11 @@ impl Alg { KeyFormatType::Raw, KeyFormatType::Raw, ), + Self::X448MlKem1024 => ( + CryptographicAlgorithm::X448MLKEM1024, + KeyFormatType::Raw, + KeyFormatType::Raw, + ), Self::MlKem768 => ( CryptographicAlgorithm::MLKEM_768, KeyFormatType::PKCS8, @@ -129,7 +151,14 @@ impl Alg { } pub(super) async fn test_hybrid_kem_lifecycle() -> KResult<()> { - for alg in [Alg::MlKem512P256, Alg::MlKem768P256, Alg::X25519MlKem768] { + for alg in [ + Alg::MlKem512P256, + Alg::MlKem768P256, + Alg::MlKem768X25519, + Alg::X25519MlKem768, + Alg::X448MlKem1024, + Alg::MlKem768, + ] { Box::pin(lifecycle(alg)).await?; } Ok(()) From 664c703cba5d1f8ebc7fa6dc609d84ab7104a2a4 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sun, 25 Oct 2026 05:07:14 +0100 Subject: [PATCH 12/20] fix(server): keep the ConfigurableKEM Encrypt response contract (packed tuple in Data, no IVCounterNonce) --- CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md | 4 +- crate/server/src/core/operations/encrypt.rs | 26 ++++- .../src/tests/configurable_kem_tests.rs | 107 +++++++++++++----- crate/server/src/tests/hsm/hybrid_kem_dek.rs | 83 +++++++++++--- .../tests/kmip_policy/e2e_export_wrapping.rs | 23 ++-- 5 files changed, 183 insertions(+), 60 deletions(-) diff --git a/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md index c8ade11bdc..7045642d41 100644 --- a/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md +++ b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md @@ -11,10 +11,10 @@ - `ConfigurableKEM` hybrid keys (ML-KEM + P-256/X25519) follow least privilege and the KMIP usage-mask rules: - A requested `CryptographicUsageMask` is kept as is instead of being OR-ed with `Unrestricted`. Without one, the private key gets `Decrypt` and the public key `Encrypt`; `ckms pqc keys create` no longer requests `Unrestricted`. Existing keys keep their mask. - Per-key `PrivateKeyAttributes`/`PublicKeyAttributes` inherit the `CommonAttributes` (tags, curve, ML-KEM variant) instead of replacing them, which also applies to `ReKeyKeyPair`. -- KMIP `Encrypt` with a `ConfigurableKEM` public key returns the shared secret in `Data` and the encapsulation in `IVCounterNonce`, like ML-KEM and native hybrid KEMs; it used to pack both into `Data`, so the Web UI encapsulation page downloaded an unusable file. KMIP clients parsing the old packed format must be updated (`ckms` and the Web UI already read the new one). +- KMIP `Encrypt` with a `ConfigurableKEM` public key keeps its response contract: `Data` is the serialized `(shared secret, encapsulation)` tuple and `IVCounterNonce` is empty; only the encapsulation component is decapsulated. This is now covered by regression tests, in software and with an HSM KEK. ML-KEM and native hybrid KEMs keep returning the secret in `Data` and the encapsulation in `IVCounterNonce`. - KEM `Encrypt` (ML-KEM, native hybrid, `ConfigurableKEM`) rejects non-empty `Data` instead of silently ignoring it. - Algorithm policy: a `ConfigurableKEM` key is checked on every use, not only at creation, against both its ML-KEM variant and its curve, read from the serialized key rather than from its KMIP attributes, so an imported key with relabelled or stripped attributes cannot bypass the policy; `X25519MLKEM768` and `X448MLKEM1024` are in the policy scope and in the `DEFAULT` allowlist (non-FIPS), where they were previously always denied. -- `ckms bench`: the configurable KEM decapsulation benchmark now decapsulates the actual encapsulation. +- `ckms bench`: the configurable KEM decapsulation benchmark now decapsulates the encapsulation component of the packed result instead of the whole tuple. ## Testing diff --git a/crate/server/src/core/operations/encrypt.rs b/crate/server/src/core/operations/encrypt.rs index 785793c847..0009d0653b 100644 --- a/crate/server/src/core/operations/encrypt.rs +++ b/crate/server/src/core/operations/encrypt.rs @@ -605,8 +605,9 @@ fn encrypt_with_public_key( match &key_block.key_format_type { #[cfg(feature = "non-fips")] KeyFormatType::ConfigurableKEMPublicKey => { - use cosmian_kms_server_database::reexport::cosmian_kms_crypto::crypto::kem::{ - kem_encaps, kem_key_components, + use cosmian_kms_server_database::reexport::cosmian_kms_crypto::{ + crypto::kem::{kem_encaps, kem_key_components}, + reexport::cosmian_crypto_core::bytes_ser_de::Serializable, }; use crate::core::operations::algorithm_policy::enforce_kmip_algorithm_policy_for_kem_components; @@ -619,12 +620,25 @@ fn encrypt_with_public_key( owm.id(), kem_key_components(&ek_bytes)?, )?; - let (shared_secret, encapsulation) = kem_encaps(&ek_bytes)?; + let (key, enc) = kem_encaps(&ek_bytes)?; + // Published `ConfigurableKEM` contract: `Data` holds the serialized + // `(shared secret, encapsulation)` tuple and `IVCounterNonce` is empty. + // Do not align it with the ML-KEM layout. Ok(EncryptResponse { unique_identifier: UniqueIdentifier::TextString(owm.id().to_owned()), - data: Some(shared_secret.to_vec()), - i_v_counter_nonce: Some(encapsulation.to_vec()), - correlation_value: request.correlation_value.clone(), + data: Some( + (key, enc) + .serialize() + .map_err(|e| { + KmsError::ConversionError(format!( + "failed serializing the configurable-KEM encapsulation \ + results: {e}" + )) + })? + .to_vec(), + ), + i_v_counter_nonce: None, + correlation_value: None, authenticated_encryption_tag: None, }) } diff --git a/crate/server/src/tests/configurable_kem_tests.rs b/crate/server/src/tests/configurable_kem_tests.rs index 822ed80fce..783c8dff0b 100644 --- a/crate/server/src/tests/configurable_kem_tests.rs +++ b/crate/server/src/tests/configurable_kem_tests.rs @@ -6,28 +6,32 @@ use std::sync::Arc; use cosmian_kms_client_utils::configurable_kem_utils::{ KemAlgorithm, build_create_configurable_kem_keypair_request, }; -use cosmian_kms_server_database::reexport::cosmian_kmip::{ - kmip_0::kmip_types::{CryptographicUsageMask, RevocationReason, RevocationReasonCode}, - kmip_2_1::{ - extra::tagging::{EMPTY_TAGS, VENDOR_ID_COSMIAN}, - kmip_attributes::Attributes, - kmip_objects::ObjectType, - kmip_operations::{ - CreateKeyPair, Get, GetAttributes, Import, Locate, Operation, ReKeyKeyPair, Revoke, - Sign, - }, - kmip_types::{ - CryptographicAlgorithm, CryptographicDomainParameters, CryptographicParameters, - RecommendedCurve, UniqueIdentifier, - }, - requests::{ - create_pqc_key_pair_request, decrypt_request, encrypt_request, - symmetric_key_create_request, +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::{CryptographicUsageMask, RevocationReason, RevocationReasonCode}, + kmip_2_1::{ + extra::tagging::{EMPTY_TAGS, VENDOR_ID_COSMIAN}, + kmip_attributes::Attributes, + kmip_objects::ObjectType, + kmip_operations::{ + CreateKeyPair, EncryptResponse, Get, GetAttributes, Import, Locate, Operation, + ReKeyKeyPair, Revoke, Sign, + }, + kmip_types::{ + CryptographicAlgorithm, CryptographicDomainParameters, CryptographicParameters, + RecommendedCurve, UniqueIdentifier, + }, + requests::{ + create_pqc_key_pair_request, decrypt_request, encrypt_request, + symmetric_key_create_request, + }, }, }, + cosmian_kms_crypto::reexport::cosmian_crypto_core::bytes_ser_de::Serializable, }; use cosmian_logger::log_init; use uuid::Uuid; +use zeroize::Zeroizing; use crate::{ config::{ClapConfig, ServerParams}, @@ -78,19 +82,56 @@ async fn attributes(kms: &KMS, owner: &UserId, uid: &str) -> KResult .attributes) } -/// Returns (shared secret, encapsulation) +async fn encrypt_raw(kms: &KMS, owner: &UserId, pk: &str) -> KResult { + kms.encrypt(encrypt_request(pk, None, vec![], None, None, None)?, owner) + .await +} + +fn unpack(data: &[u8]) -> KResult<(Vec, Vec)> { + let (secret, enc) = + <(Zeroizing>, Zeroizing>)>::deserialize(data).map_err(|e| { + KmsError::ServerError(format!("data is not a (secret, encapsulation): {e}")) + })?; + Ok((secret.to_vec(), enc.to_vec())) +} + +/// Returns (shared secret, encapsulation). `ConfigurableKEM` packs both in `Data` as a +/// serialized tuple; the other KEMs return the secret in `Data` and the encapsulation in +/// `IVCounterNonce`. async fn encapsulate(kms: &KMS, owner: &UserId, pk: &str) -> KResult<(Vec, Vec)> { - let response = kms - .encrypt(encrypt_request(pk, None, vec![], None, None, None)?, owner) - .await?; - Ok(( - response - .data - .ok_or_else(|| KmsError::ServerError("no shared secret".to_owned()))?, - response - .i_v_counter_nonce - .ok_or_else(|| KmsError::ServerError("no encapsulation".to_owned()))?, - )) + let response = encrypt_raw(kms, owner, pk).await?; + let data = response + .data + .ok_or_else(|| KmsError::ServerError("no data".to_owned()))?; + match response.i_v_counter_nonce { + Some(enc) => Ok((data, enc)), + None => unpack(&data), + } +} + +/// The `ConfigurableKEM` `Encrypt` contract, which must not be aligned with ML-KEM's: +/// `IVCounterNonce` is empty and `Data` is the serialized `(shared secret, encapsulation)` +/// tuple. Only its encapsulation component can be decapsulated, to the packed secret. +async fn assert_configurable_kem_contract( + kms: &KMS, + owner: &UserId, + sk: &str, + pk: &str, +) -> KResult<()> { + let response = encrypt_raw(kms, owner, pk).await?; + assert_eq!(response.i_v_counter_nonce, None); + assert_eq!(response.authenticated_encryption_tag, None); + let data = response + .data + .ok_or_else(|| KmsError::ServerError("no data".to_owned()))?; + let (secret, enc) = unpack(&data)?; + assert!(!secret.is_empty() && !enc.is_empty()); + assert_eq!(decapsulate(kms, owner, sk, enc).await?, secret); + assert!( + decapsulate(kms, owner, sk, data).await.is_err(), + "the packed tuple is not itself an encapsulation" + ); + Ok(()) } async fn decapsulate(kms: &KMS, owner: &UserId, sk: &str, enc: Vec) -> KResult> { @@ -133,6 +174,7 @@ async fn test_configurable_kem_lifecycle() -> KResult<()> { ); assert_round_trip(&kms, &owner, &sk, &pk).await?; + assert_configurable_kem_contract(&kms, &owner, &sk, &pk).await?; // plaintext is refused instead of being silently ignored let encrypt_with_data = kms @@ -309,6 +351,13 @@ async fn test_kem_encrypt_decrypt_round_trip_all_algorithms() -> KResult<()> { assert_round_trip(&kms, &owner, &sk, &pk) .await .map_err(|e| KmsError::Default(format!("{name} (kek: {with_kek}): {e}")))?; + // only the `ConfigurableKEM` names contain a `/`: they keep the packed layout + if name.contains('/') { + assert_configurable_kem_contract(&kms, &owner, &sk, &pk).await?; + } else { + let response = encrypt_raw(&kms, &owner, &pk).await?; + assert!(response.i_v_counter_nonce.is_some(), "{name}: no IV"); + } pairs.push((name, sk, pk)); } // restart: the keys are read back from the database, and unwrapped if wrapped diff --git a/crate/server/src/tests/hsm/hybrid_kem_dek.rs b/crate/server/src/tests/hsm/hybrid_kem_dek.rs index cf7840a947..be480b6f19 100644 --- a/crate/server/src/tests/hsm/hybrid_kem_dek.rs +++ b/crate/server/src/tests/hsm/hybrid_kem_dek.rs @@ -16,8 +16,8 @@ use cosmian_kms_server_database::reexport::{ kmip_attributes::Attributes, kmip_data_structures::{KeyValue, KeyWrappingData, KeyWrappingSpecification}, kmip_operations::{ - Activate, CreateKeyPair, Destroy, Export, GetAttributes, Import, Operation, - ReKeyKeyPair, Revoke, + Activate, CreateKeyPair, Destroy, EncryptResponse, Export, GetAttributes, Import, + Operation, ReKeyKeyPair, Revoke, }, kmip_types::{ CryptographicAlgorithm, CryptographicDomainParameters, CryptographicParameters, @@ -31,8 +31,11 @@ use cosmian_kms_server_database::reexport::{ }, time_normalize, }, - cosmian_kms_crypto::crypto::{ - KeyPair, KeyPairIdentity, kem::kem_keygen, pqc::hybrid_kem::create_hybrid_kem_key_pair, + cosmian_kms_crypto::{ + crypto::{ + KeyPair, KeyPairIdentity, kem::kem_keygen, pqc::hybrid_kem::create_hybrid_kem_key_pair, + }, + reexport::cosmian_crypto_core::bytes_ser_de::Serializable, }, }; use uuid::Uuid; @@ -181,8 +184,8 @@ async fn restart( )) } -/// Returns (shared secret, encapsulation) -async fn encapsulate(kms: &Arc, owner: &str, pk_uid: &str) -> KResult<(Vec, Vec)> { +/// The raw KMIP `Encrypt` response of a KEM public key +async fn encrypt_response(kms: &Arc, owner: &str, pk_uid: &str) -> KResult { let request = encrypt_request(pk_uid, None, Vec::new(), None, None, None)?; let response = send_message( kms.clone(), @@ -193,15 +196,59 @@ async fn encapsulate(kms: &Arc, owner: &str, pk_uid: &str) -> KResult<(Vec< let Operation::EncryptResponse(response) = &response[0] else { return Err(KmsError::ServerError("invalid response".to_owned())); }; - let secret = response + Ok(response.clone()) +} + +/// Returns (shared secret, encapsulation). `ConfigurableKEM` packs both in `Data` as a +/// serialized tuple; the other KEMs return the secret in `Data` and the encapsulation in +/// `IVCounterNonce`. +async fn encapsulate(kms: &Arc, owner: &str, pk_uid: &str) -> KResult<(Vec, Vec)> { + let response = encrypt_response(kms, owner, pk_uid).await?; + let data = response .data - .clone() - .ok_or_else(|| KmsError::ServerError("empty shared secret".to_owned()))?; - let enc = response - .i_v_counter_nonce - .clone() - .ok_or_else(|| KmsError::ServerError("empty encapsulation".to_owned()))?; - Ok((secret, enc)) + .ok_or_else(|| KmsError::ServerError("empty encrypt data".to_owned()))?; + if let Some(enc) = response.i_v_counter_nonce { + return Ok((data, enc)); + } + let (secret, enc) = <(Zeroizing>, Zeroizing>)>::deserialize(&data) + .map_err(|e| KmsError::Default(e.to_string()))?; + Ok((secret.to_vec(), enc.to_vec())) +} + +/// The `ConfigurableKEM` `Encrypt` contract: no `IVCounterNonce`, and `Data` is the serialized +/// `(shared secret, encapsulation)` tuple, of which only the encapsulation can be decapsulated. +/// Every other KEM returns the secret in `Data` and the encapsulation in `IVCounterNonce`. +async fn assert_encrypt_contract( + kms: &Arc, + owner: &str, + pk: &str, + sk: &str, + configurable: bool, +) -> KResult<()> { + let response = encrypt_response(kms, owner, pk).await?; + let data = response + .data + .ok_or_else(|| KmsError::ServerError("empty encrypt data".to_owned()))?; + if !configurable { + let enc = response + .i_v_counter_nonce + .ok_or_else(|| KmsError::ServerError("missing IVCounterNonce".to_owned()))?; + assert_eq!(data, decapsulate(kms, owner, sk, &enc).await?); + return Ok(()); + } + assert!(response.i_v_counter_nonce.is_none()); + let (secret, enc) = <(Zeroizing>, Zeroizing>)>::deserialize(&data) + .map_err(|e| KmsError::Default(e.to_string()))?; + assert_eq!( + secret.to_vec(), + decapsulate(kms, owner, sk, &enc).await?, + "decapsulating the encapsulation component must return the packed secret" + ); + assert!( + decapsulate(kms, owner, sk, &data).await.is_err(), + "the packed tuple is not itself an encapsulation" + ); + Ok(()) } async fn decapsulate(kms: &Arc, owner: &str, sk_uid: &str, enc: &[u8]) -> KResult> { @@ -364,6 +411,14 @@ async fn lifecycle(alg: Alg) -> KResult<()> { assert_wrapped_by(&export_object(&kms, &owner, &sk).await?, &kek_uid)?; assert_wrapped_by(&export_object(&kms, &owner, &pk).await?, &kek_uid)?; assert_roundtrip(&kms, &owner, &pk, &sk).await?; + assert_encrypt_contract( + &kms, + &owner, + &pk, + &sk, + stored_algorithm == CryptographicAlgorithm::ConfigurableKEM, + ) + .await?; // 2. Restart: cold cache, the HSM unwraps drop(kms); diff --git a/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs b/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs index 6c6b322d46..dae1e05248 100644 --- a/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs +++ b/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs @@ -9,7 +9,7 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ HashingAlgorithm, PaddingMethod, }, kmip_2_1::{ - extra::tagging::{SYSTEM_TAG_PUBLIC_KEY, VENDOR_ID_COSMIAN}, + extra::tagging::SYSTEM_TAG_PUBLIC_KEY, kmip_attributes::Attributes, kmip_objects::ObjectType, kmip_operations::{ @@ -22,10 +22,16 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ time_normalize, }; +#[cfg(feature = "non-fips")] +use cosmian_kms_server_database::reexport::cosmian_kms_crypto::reexport::cosmian_crypto_core::bytes_ser_de::Serializable; +#[cfg(feature = "non-fips")] +use zeroize::Zeroizing; + use crate::{ error::KmsError, tests::test_utils::{https_clap_config_opts, post_2_1, test_app_with_clap_config}, }; +use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::extra::tagging::VENDOR_ID_COSMIAN; async fn create_kek_and_target_for_export(app: &S) -> (String, String) where @@ -293,17 +299,16 @@ async fn e2e_default_policy_allows_configurable_kem_roundtrip() { ) .expect("encrypt_request should build"); let enc_resp: EncryptResponse = post_2_1(&app, &enc_req).await.unwrap(); - let expected_key = enc_resp - .data - .expect("encrypt response should include the secret"); - let encapsulation = enc_resp - .i_v_counter_nonce - .expect("encrypt response should include the encapsulation"); + let enc_data = enc_resp.data.expect("encrypt response should include data"); + + let (expected_key, encapsulation) = + <(Zeroizing>, Zeroizing>)>::deserialize(&enc_data) + .expect("configurable-kem encrypt response should deserialize"); let dec_req = decrypt_request( &sk_uid, None, - encapsulation, + encapsulation.to_vec(), None, None, Some(CryptographicParameters { @@ -314,5 +319,5 @@ async fn e2e_default_policy_allows_configurable_kem_roundtrip() { let dec_resp: DecryptResponse = post_2_1(&app, &dec_req).await.unwrap(); let recovered_key = dec_resp.data.expect("decrypt response should include data"); - assert_eq!(recovered_key.to_vec(), expected_key); + assert_eq!(recovered_key.to_vec(), expected_key.to_vec()); } From f6e531ef377dc40d10388105270f5ed3e1af7134 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sun, 25 Oct 2026 05:07:55 +0100 Subject: [PATCH 13/20] fix(cli): read the packed ConfigurableKEM encapsulation again and decapsulate its encapsulation component in ckms bench --- crate/clients/clap/src/actions/bench/kmip.rs | 8 +++++- .../clap/src/actions/pqc/encapsulate.rs | 27 ++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/crate/clients/clap/src/actions/bench/kmip.rs b/crate/clients/clap/src/actions/bench/kmip.rs index caa876f752..622a168e05 100644 --- a/crate/clients/clap/src/actions/bench/kmip.rs +++ b/crate/clients/clap/src/actions/bench/kmip.rs @@ -1,4 +1,6 @@ #[cfg(feature = "non-fips")] +use cosmian_crypto_core::bytes_ser_de::Serializable as _; +#[cfg(feature = "non-fips")] use cosmian_kms_client::kmip_2_1::requests::create_pqc_key_pair_request; #[cfg(feature = "non-fips")] use cosmian_kms_client::reexport::cosmian_kms_client_utils::configurable_kem_utils::{ @@ -755,7 +757,11 @@ fn bench_kem(c: &mut Criterion, client: &KmsClient, rt: &Runtime, transport: Tra let enc_resp = rt .block_on(client.encrypt(enc_req)) .expect("pre-encapsulate for decapsulate"); - let ct = enc_resp.i_v_counter_nonce.unwrap_or_default(); + // ConfigurableKEM packs `(shared secret, encapsulation)` in `data`: decapsulate the latter + let packed = enc_resp.data.unwrap_or_default(); + let (_, ct) = <(Zeroizing>, Zeroizing>)>::deserialize(&packed) + .expect("ConfigurableKEM encapsulation tuple"); + let ct = ct.to_vec(); let dec_req = decrypt_request( &priv_id.to_string(), None, diff --git a/crate/clients/clap/src/actions/pqc/encapsulate.rs b/crate/clients/clap/src/actions/pqc/encapsulate.rs index 87a833033f..0217e43bc7 100644 --- a/crate/clients/clap/src/actions/pqc/encapsulate.rs +++ b/crate/clients/clap/src/actions/pqc/encapsulate.rs @@ -1,11 +1,15 @@ use std::{fs::File, io::Write, path::PathBuf}; use clap::Parser; +use cosmian_crypto_core::bytes_ser_de::Serializable; use cosmian_kms_client::{KmsClient, kmip_2_1::requests::encrypt_request}; use crate::{ actions::{console, labels::KEY_ID, shared::get_key_uid}, - error::result::{KmsCliResult, KmsCliResultHelper}, + error::{ + KmsCliError, + result::{KmsCliResult, KmsCliResultHelper}, + }, }; /// Encapsulate using a PQC public key (ML-KEM-512/768/1024, X25519MLKEM768, @@ -44,11 +48,22 @@ impl EncapsulateAction { .await .with_context(|| "KEM encapsulation failed")?; - // Every KEM returns the shared secret in data and the ciphertext in iv_counter_nonce - let shared_secret = response.data.context("shared secret is empty")?; - let ciphertext = response - .i_v_counter_nonce - .context("encapsulation is empty")?; + let (shared_secret, ciphertext) = if let Some(ciphertext) = response.i_v_counter_nonce { + // Standard PQC KEM: shared_secret in data, ciphertext in iv_counter_nonce + let shared_secret = response.data.context("shared secret is empty")?; + (shared_secret, ciphertext) + } else { + // ConfigurableKEM: both packed as a serialized tuple in data + let data = response.data.context("encrypted data is empty")?; + let (session_key, encapsulation) = + <(zeroize::Zeroizing>, zeroize::Zeroizing>)>::deserialize(&data) + .map_err(|e| { + KmsCliError::Conversion(format!( + "failed deserializing KEM encapsulation from response data: {e}" + )) + })?; + (session_key.to_vec(), encapsulation.to_vec()) + }; let output_file = self .output_file From 24a26bc330b71c07a5f0f916f69f7fa548d543f8 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sun, 25 Oct 2026 05:48:44 +0100 Subject: [PATCH 14/20] fix(server): return the KEM encapsulation in Data for every KEM, never in IVCounterNonce --- CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md | 4 +- crate/crypto/src/crypto/kem.rs | 46 ++++++++++++ crate/server/src/core/operations/encrypt.rs | 73 +++++++++---------- .../src/tests/configurable_kem_tests.rs | 47 +++++------- crate/server/src/tests/hsm/hybrid_kem_dek.rs | 38 ++-------- crate/server/src/tests/pqc_import_tests.rs | 32 ++++---- 6 files changed, 125 insertions(+), 115 deletions(-) diff --git a/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md index 7045642d41..c6766688de 100644 --- a/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md +++ b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md @@ -11,10 +11,10 @@ - `ConfigurableKEM` hybrid keys (ML-KEM + P-256/X25519) follow least privilege and the KMIP usage-mask rules: - A requested `CryptographicUsageMask` is kept as is instead of being OR-ed with `Unrestricted`. Without one, the private key gets `Decrypt` and the public key `Encrypt`; `ckms pqc keys create` no longer requests `Unrestricted`. Existing keys keep their mask. - Per-key `PrivateKeyAttributes`/`PublicKeyAttributes` inherit the `CommonAttributes` (tags, curve, ML-KEM variant) instead of replacing them, which also applies to `ReKeyKeyPair`. -- KMIP `Encrypt` with a `ConfigurableKEM` public key keeps its response contract: `Data` is the serialized `(shared secret, encapsulation)` tuple and `IVCounterNonce` is empty; only the encapsulation component is decapsulated. This is now covered by regression tests, in software and with an HSM KEK. ML-KEM and native hybrid KEMs keep returning the secret in `Data` and the encapsulation in `IVCounterNonce`. +- KMIP `Encrypt` with any KEM public key (ML-KEM, `X25519MLKEM768`, `X448MLKEM1024`, `ConfigurableKEM`) returns one response layout: `Data` is the serialized `(shared secret, encapsulation)` tuple (LEB128 length + bytes, twice) and `IVCounterNonce` is empty. ML-KEM and the native hybrid KEMs used to return the encapsulation, a ciphertext, in `IVCounterNonce`, a field meant for IVs, counters and nonces. **Breaking for KMIP clients of ML-KEM and native hybrid KEMs**: unpack `Data` instead of reading `IVCounterNonce`; `ckms pqc encapsulate`, `ckms bench` and the Web UI encapsulation page already do. Only the encapsulation component is decapsulated. Covered by regression tests, in software and with an HSM KEK. - KEM `Encrypt` (ML-KEM, native hybrid, `ConfigurableKEM`) rejects non-empty `Data` instead of silently ignoring it. - Algorithm policy: a `ConfigurableKEM` key is checked on every use, not only at creation, against both its ML-KEM variant and its curve, read from the serialized key rather than from its KMIP attributes, so an imported key with relabelled or stripped attributes cannot bypass the policy; `X25519MLKEM768` and `X448MLKEM1024` are in the policy scope and in the `DEFAULT` allowlist (non-FIPS), where they were previously always denied. -- `ckms bench`: the configurable KEM decapsulation benchmark now decapsulates the encapsulation component of the packed result instead of the whole tuple. +- `ckms bench`: the KEM decapsulation benchmarks decapsulate the encapsulation component of the packed result instead of the whole tuple or the IV field. ## Testing diff --git a/crate/crypto/src/crypto/kem.rs b/crate/crypto/src/crypto/kem.rs index 5ff29d1435..6bbb45513c 100644 --- a/crate/crypto/src/crypto/kem.rs +++ b/crate/crypto/src/crypto/kem.rs @@ -144,6 +144,30 @@ pub fn kem_encaps(ek: &[u8]) -> Result<(Zeroizing>, Zeroizing>), )) } +/// Pack a KEM result as the `Data` of a KMIP `Encrypt` response: the serialized +/// `(shared secret, encapsulation)` tuple. The encapsulation is a KEM ciphertext, not an +/// IV/counter/nonce, so `IVCounterNonce` stays empty for every KEM. +pub fn pack_kem_encapsulation( + shared_secret: Zeroizing>, + encapsulation: Zeroizing>, +) -> Result>, CryptoError> { + (shared_secret, encapsulation).serialize().map_err(|e| { + CryptoError::ConversionError(format!("failed serializing the KEM encapsulation: {e}")) + }) +} + +/// Inverse of [`pack_kem_encapsulation`]: returns `(shared secret, encapsulation)`. +#[allow(clippy::type_complexity)] +pub fn unpack_kem_encapsulation( + data: &[u8], +) -> Result<(Zeroizing>, Zeroizing>), CryptoError> { + <(Zeroizing>, Zeroizing>)>::deserialize(data).map_err(|e| { + CryptoError::ConversionError(format!( + "the KEM response data is not a (shared secret, encapsulation) tuple: {e}" + )) + }) +} + /// Return the pre-quantum curve and the post-quantum algorithm of a serialized /// `ConfigurableKEM` key (public or private), as encoded in the key itself, whatever its /// KMIP attributes claim. Policy checks must rely on this, since attributes can be relabelled. @@ -266,3 +290,25 @@ fn create_ek_object( }, })) } + +#[expect(clippy::unwrap_used)] +#[cfg(test)] +mod tests { + use zeroize::Zeroizing; + + use super::{pack_kem_encapsulation, unpack_kem_encapsulation}; + + #[test] + fn kem_encapsulation_pack_round_trip() { + // a ciphertext longer than 127 bytes has a multi-byte length prefix + let secret = Zeroizing::new(vec![7_u8; 32]); + let encapsulation = Zeroizing::new(vec![9_u8; 1088]); + let packed = pack_kem_encapsulation(secret.clone(), encapsulation.clone()).unwrap(); + let (s, e) = unpack_kem_encapsulation(&packed).unwrap(); + assert_eq!((s, e), (secret, encapsulation)); + // truncated data is refused, not silently shortened + let (_, truncated) = packed.split_last().unwrap(); + unpack_kem_encapsulation(truncated).unwrap_err(); + unpack_kem_encapsulation(&[]).unwrap_err(); + } +} diff --git a/crate/server/src/core/operations/encrypt.rs b/crate/server/src/core/operations/encrypt.rs index 0009d0653b..88932c038a 100644 --- a/crate/server/src/core/operations/encrypt.rs +++ b/crate/server/src/core/operations/encrypt.rs @@ -605,9 +605,8 @@ fn encrypt_with_public_key( match &key_block.key_format_type { #[cfg(feature = "non-fips")] KeyFormatType::ConfigurableKEMPublicKey => { - use cosmian_kms_server_database::reexport::cosmian_kms_crypto::{ - crypto::kem::{kem_encaps, kem_key_components}, - reexport::cosmian_crypto_core::bytes_ser_de::Serializable, + use cosmian_kms_server_database::reexport::cosmian_kms_crypto::crypto::kem::{ + kem_encaps, kem_key_components, }; use crate::core::operations::algorithm_policy::enforce_kmip_algorithm_policy_for_kem_components; @@ -621,26 +620,7 @@ fn encrypt_with_public_key( kem_key_components(&ek_bytes)?, )?; let (key, enc) = kem_encaps(&ek_bytes)?; - // Published `ConfigurableKEM` contract: `Data` holds the serialized - // `(shared secret, encapsulation)` tuple and `IVCounterNonce` is empty. - // Do not align it with the ML-KEM layout. - Ok(EncryptResponse { - unique_identifier: UniqueIdentifier::TextString(owm.id().to_owned()), - data: Some( - (key, enc) - .serialize() - .map_err(|e| { - KmsError::ConversionError(format!( - "failed serializing the configurable-KEM encapsulation \ - results: {e}" - )) - })? - .to_vec(), - ), - i_v_counter_nonce: None, - correlation_value: None, - authenticated_encryption_tag: None, - }) + kem_encrypt_response(owm.id(), request, key, enc) } #[cfg(feature = "non-fips")] KeyFormatType::CoverCryptPublicKey => { @@ -673,13 +653,12 @@ fn encrypt_with_public_key( reject_kem_plaintext(request)?; let (pub_bytes, _) = key_block.key_bytes_and_attributes()?; let (shared_secret, ciphertext) = ml_kem_encapsulate(&pub_bytes)?; - return Ok(EncryptResponse { - unique_identifier: UniqueIdentifier::TextString(owm.id().to_owned()), - data: Some(shared_secret), - i_v_counter_nonce: Some(ciphertext), - correlation_value: request.correlation_value.clone(), - authenticated_encryption_tag: None, - }); + return kem_encrypt_response( + owm.id(), + request, + Zeroizing::new(shared_secret), + Zeroizing::new(ciphertext), + ); } if let Some( algo @ (CryptographicAlgorithm::X25519MLKEM768 @@ -690,13 +669,12 @@ fn encrypt_with_public_key( reject_kem_plaintext(request)?; let (pub_bytes, _) = key_block.key_bytes_and_attributes()?; let (shared_secret, ciphertext) = hybrid_kem_encapsulate(algo, &pub_bytes)?; - return Ok(EncryptResponse { - unique_identifier: UniqueIdentifier::TextString(owm.id().to_owned()), - data: Some(shared_secret), - i_v_counter_nonce: Some(ciphertext), - correlation_value: request.correlation_value.clone(), - authenticated_encryption_tag: None, - }); + return kem_encrypt_response( + owm.id(), + request, + Zeroizing::new(shared_secret), + Zeroizing::new(ciphertext), + ); } } @@ -739,6 +717,27 @@ fn reject_kem_plaintext(request: &Encrypt) -> KResult<()> { Ok(()) } +/// The `Encrypt` response of every KEM public key: the shared secret and the encapsulation +/// packed as a serialized tuple in `Data`. The encapsulation is a ciphertext, so it must not +/// travel in `IVCounterNonce`, which carries public cipher metadata (an IV/counter/nonce). +#[cfg(feature = "non-fips")] +fn kem_encrypt_response( + uid: &str, + request: &Encrypt, + shared_secret: Zeroizing>, + encapsulation: Zeroizing>, +) -> KResult { + use cosmian_kms_server_database::reexport::cosmian_kms_crypto::crypto::kem::pack_kem_encapsulation; + + Ok(EncryptResponse { + unique_identifier: UniqueIdentifier::TextString(uid.to_owned()), + data: Some(pack_kem_encapsulation(shared_secret, encapsulation)?.to_vec()), + i_v_counter_nonce: None, + correlation_value: request.correlation_value.clone(), + authenticated_encryption_tag: None, + }) +} + fn encrypt_with_pkey( request: &Encrypt, #[cfg(feature = "non-fips")] server_params: &ServerParams, diff --git a/crate/server/src/tests/configurable_kem_tests.rs b/crate/server/src/tests/configurable_kem_tests.rs index 783c8dff0b..7232d39b0f 100644 --- a/crate/server/src/tests/configurable_kem_tests.rs +++ b/crate/server/src/tests/configurable_kem_tests.rs @@ -95,29 +95,22 @@ fn unpack(data: &[u8]) -> KResult<(Vec, Vec)> { Ok((secret.to_vec(), enc.to_vec())) } -/// Returns (shared secret, encapsulation). `ConfigurableKEM` packs both in `Data` as a -/// serialized tuple; the other KEMs return the secret in `Data` and the encapsulation in -/// `IVCounterNonce`. +/// Returns (shared secret, encapsulation). Every KEM packs both in `Data` as a serialized +/// tuple: an encapsulation is a ciphertext and never travels in `IVCounterNonce`. async fn encapsulate(kms: &KMS, owner: &UserId, pk: &str) -> KResult<(Vec, Vec)> { let response = encrypt_raw(kms, owner, pk).await?; - let data = response - .data - .ok_or_else(|| KmsError::ServerError("no data".to_owned()))?; - match response.i_v_counter_nonce { - Some(enc) => Ok((data, enc)), - None => unpack(&data), - } + assert_eq!(response.i_v_counter_nonce, None); + unpack( + &response + .data + .ok_or_else(|| KmsError::ServerError("no data".to_owned()))?, + ) } -/// The `ConfigurableKEM` `Encrypt` contract, which must not be aligned with ML-KEM's: -/// `IVCounterNonce` is empty and `Data` is the serialized `(shared secret, encapsulation)` -/// tuple. Only its encapsulation component can be decapsulated, to the packed secret. -async fn assert_configurable_kem_contract( - kms: &KMS, - owner: &UserId, - sk: &str, - pk: &str, -) -> KResult<()> { +/// The KEM `Encrypt` contract: `IVCounterNonce` is empty and `Data` is the serialized +/// `(shared secret, encapsulation)` tuple. Only its encapsulation component can be +/// decapsulated, to the packed secret. +async fn assert_kem_encrypt_contract(kms: &KMS, owner: &UserId, sk: &str, pk: &str) -> KResult<()> { let response = encrypt_raw(kms, owner, pk).await?; assert_eq!(response.i_v_counter_nonce, None); assert_eq!(response.authenticated_encryption_tag, None); @@ -174,7 +167,7 @@ async fn test_configurable_kem_lifecycle() -> KResult<()> { ); assert_round_trip(&kms, &owner, &sk, &pk).await?; - assert_configurable_kem_contract(&kms, &owner, &sk, &pk).await?; + assert_kem_encrypt_contract(&kms, &owner, &sk, &pk).await?; // plaintext is refused instead of being silently ignored let encrypt_with_data = kms @@ -268,8 +261,8 @@ async fn locate_by_tag(kms: &KMS, owner: &UserId, tag: &str) -> KResult KResult<()> { assert_round_trip(&kms, &owner, &sk, &pk) .await .map_err(|e| KmsError::Default(format!("{name} (kek: {with_kek}): {e}")))?; - // only the `ConfigurableKEM` names contain a `/`: they keep the packed layout - if name.contains('/') { - assert_configurable_kem_contract(&kms, &owner, &sk, &pk).await?; - } else { - let response = encrypt_raw(&kms, &owner, &pk).await?; - assert!(response.i_v_counter_nonce.is_some(), "{name}: no IV"); - } + assert_kem_encrypt_contract(&kms, &owner, &sk, &pk) + .await + .map_err(|e| KmsError::Default(format!("{name} (kek: {with_kek}): {e}")))?; pairs.push((name, sk, pk)); } // restart: the keys are read back from the database, and unwrapped if wrapped diff --git a/crate/server/src/tests/hsm/hybrid_kem_dek.rs b/crate/server/src/tests/hsm/hybrid_kem_dek.rs index be480b6f19..39bcfffc24 100644 --- a/crate/server/src/tests/hsm/hybrid_kem_dek.rs +++ b/crate/server/src/tests/hsm/hybrid_kem_dek.rs @@ -199,44 +199,27 @@ async fn encrypt_response(kms: &Arc, owner: &str, pk_uid: &str) -> KResult< Ok(response.clone()) } -/// Returns (shared secret, encapsulation). `ConfigurableKEM` packs both in `Data` as a -/// serialized tuple; the other KEMs return the secret in `Data` and the encapsulation in -/// `IVCounterNonce`. +/// Returns (shared secret, encapsulation). Every KEM packs both in `Data` as a serialized +/// tuple: an encapsulation is a ciphertext and never travels in `IVCounterNonce`. async fn encapsulate(kms: &Arc, owner: &str, pk_uid: &str) -> KResult<(Vec, Vec)> { let response = encrypt_response(kms, owner, pk_uid).await?; + assert!(response.i_v_counter_nonce.is_none()); let data = response .data .ok_or_else(|| KmsError::ServerError("empty encrypt data".to_owned()))?; - if let Some(enc) = response.i_v_counter_nonce { - return Ok((data, enc)); - } let (secret, enc) = <(Zeroizing>, Zeroizing>)>::deserialize(&data) .map_err(|e| KmsError::Default(e.to_string()))?; Ok((secret.to_vec(), enc.to_vec())) } -/// The `ConfigurableKEM` `Encrypt` contract: no `IVCounterNonce`, and `Data` is the serialized +/// The KEM `Encrypt` contract: no `IVCounterNonce`, and `Data` is the serialized /// `(shared secret, encapsulation)` tuple, of which only the encapsulation can be decapsulated. -/// Every other KEM returns the secret in `Data` and the encapsulation in `IVCounterNonce`. -async fn assert_encrypt_contract( - kms: &Arc, - owner: &str, - pk: &str, - sk: &str, - configurable: bool, -) -> KResult<()> { +async fn assert_encrypt_contract(kms: &Arc, owner: &str, pk: &str, sk: &str) -> KResult<()> { let response = encrypt_response(kms, owner, pk).await?; + assert!(response.i_v_counter_nonce.is_none()); let data = response .data .ok_or_else(|| KmsError::ServerError("empty encrypt data".to_owned()))?; - if !configurable { - let enc = response - .i_v_counter_nonce - .ok_or_else(|| KmsError::ServerError("missing IVCounterNonce".to_owned()))?; - assert_eq!(data, decapsulate(kms, owner, sk, &enc).await?); - return Ok(()); - } - assert!(response.i_v_counter_nonce.is_none()); let (secret, enc) = <(Zeroizing>, Zeroizing>)>::deserialize(&data) .map_err(|e| KmsError::Default(e.to_string()))?; assert_eq!( @@ -411,14 +394,7 @@ async fn lifecycle(alg: Alg) -> KResult<()> { assert_wrapped_by(&export_object(&kms, &owner, &sk).await?, &kek_uid)?; assert_wrapped_by(&export_object(&kms, &owner, &pk).await?, &kek_uid)?; assert_roundtrip(&kms, &owner, &pk, &sk).await?; - assert_encrypt_contract( - &kms, - &owner, - &pk, - &sk, - stored_algorithm == CryptographicAlgorithm::ConfigurableKEM, - ) - .await?; + assert_encrypt_contract(&kms, &owner, &pk, &sk).await?; // 2. Restart: cold cache, the HSM unwraps drop(kms); diff --git a/crate/server/src/tests/pqc_import_tests.rs b/crate/server/src/tests/pqc_import_tests.rs index f9c6219e6f..a4095b3bac 100644 --- a/crate/server/src/tests/pqc_import_tests.rs +++ b/crate/server/src/tests/pqc_import_tests.rs @@ -7,13 +7,16 @@ use std::sync::Arc; use cosmian_kms_client_utils::configurable_kem_utils::{ KemAlgorithm, build_create_configurable_kem_keypair_request, }; -use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::{ - extra::tagging::{EMPTY_TAGS, VENDOR_ID_COSMIAN}, - kmip_attributes::Attributes, - kmip_objects::{Object, ObjectType}, - kmip_operations::{Get, Import}, - kmip_types::{CryptographicAlgorithm, KeyFormatType, UniqueIdentifier}, - requests::{create_pqc_key_pair_request, decrypt_request, encrypt_request}, +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::kmip_2_1::{ + extra::tagging::{EMPTY_TAGS, VENDOR_ID_COSMIAN}, + kmip_attributes::Attributes, + kmip_objects::{Object, ObjectType}, + kmip_operations::{Get, Import}, + kmip_types::{CryptographicAlgorithm, KeyFormatType, UniqueIdentifier}, + requests::{create_pqc_key_pair_request, decrypt_request, encrypt_request}, + }, + cosmian_kms_crypto::crypto::kem::unpack_kem_encapsulation, }; use cosmian_logger::log_init; use uuid::Uuid; @@ -81,21 +84,18 @@ async fn test_import_hybrid_kem_public_key_cross_instance() -> KResult<()> { &owner, ) .await?; - let shared_secret = er - .data - .clone() - .ok_or_else(|| KmsError::InvalidRequest("no shared secret".to_owned()))?; - let ciphertext = er - .i_v_counter_nonce - .clone() - .ok_or_else(|| KmsError::InvalidRequest("no KEM ciphertext".to_owned()))?; + assert_eq!(er.i_v_counter_nonce, None); + let (shared_secret, ciphertext) = unpack_kem_encapsulation( + &er.data + .ok_or_else(|| KmsError::InvalidRequest("no KEM data".to_owned()))?, + )?; assert!(!shared_secret.is_empty()); assert!(!ciphertext.is_empty()); // decapsulate with the original private key (never re-imported) let dr = kms .decrypt( - decrypt_request(&priv_uid, None, ciphertext, None, None, None), + decrypt_request(&priv_uid, None, ciphertext.to_vec(), None, None, None), &owner, ) .await?; From a9f36ffe9f37a960cdc5bc5f206bb7942961e57d Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sun, 25 Oct 2026 05:50:20 +0100 Subject: [PATCH 15/20] fix(cli): read the packed KEM encapsulation from Data in pqc encapsulate and ckms bench --- crate/clients/clap/src/actions/bench/kmip.rs | 19 +++++++------- .../clap/src/actions/pqc/encapsulate.rs | 26 ++++--------------- 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/crate/clients/clap/src/actions/bench/kmip.rs b/crate/clients/clap/src/actions/bench/kmip.rs index 622a168e05..ee7d7461e0 100644 --- a/crate/clients/clap/src/actions/bench/kmip.rs +++ b/crate/clients/clap/src/actions/bench/kmip.rs @@ -1,6 +1,4 @@ #[cfg(feature = "non-fips")] -use cosmian_crypto_core::bytes_ser_de::Serializable as _; -#[cfg(feature = "non-fips")] use cosmian_kms_client::kmip_2_1::requests::create_pqc_key_pair_request; #[cfg(feature = "non-fips")] use cosmian_kms_client::reexport::cosmian_kms_client_utils::configurable_kem_utils::{ @@ -30,6 +28,8 @@ use cosmian_kms_client::{ }, }, }; +#[cfg(feature = "non-fips")] +use cosmian_kms_crypto::crypto::kem::unpack_kem_encapsulation; use criterion::{BenchmarkId, Criterion, Throughput}; use sha2::{Digest as _, Sha256}; use tokio::runtime::Runtime; @@ -757,10 +757,9 @@ fn bench_kem(c: &mut Criterion, client: &KmsClient, rt: &Runtime, transport: Tra let enc_resp = rt .block_on(client.encrypt(enc_req)) .expect("pre-encapsulate for decapsulate"); - // ConfigurableKEM packs `(shared secret, encapsulation)` in `data`: decapsulate the latter - let packed = enc_resp.data.unwrap_or_default(); - let (_, ct) = <(Zeroizing>, Zeroizing>)>::deserialize(&packed) - .expect("ConfigurableKEM encapsulation tuple"); + // Every KEM packs `(shared secret, encapsulation)` in `data`: decapsulate the latter + let (_, ct) = unpack_kem_encapsulation(&enc_resp.data.unwrap_or_default()) + .expect("KEM encapsulation tuple"); let ct = ct.to_vec(); let dec_req = decrypt_request( &priv_id.to_string(), @@ -824,10 +823,10 @@ fn bench_pqc_kem(c: &mut Criterion, client: &KmsClient, rt: &Runtime, transport: Operation::Encrypt(Box::new(enc_req)), ); - // Standard PQC KEM: ciphertext is in i_v_counter_nonce - let ct = enc_resp - .i_v_counter_nonce - .unwrap_or_else(|| enc_resp.data.map_or_else(Vec::new, |z| z.to_vec())); + // Every KEM packs `(shared secret, encapsulation)` in `data`: decapsulate the latter + let (_, ct) = unpack_kem_encapsulation(&enc_resp.data.unwrap_or_default()) + .expect("KEM encapsulation tuple"); + let ct = ct.to_vec(); let dec_req = decrypt_request(&priv_id.to_string(), None, ct, None, None, None); bench_op_id( &mut group, diff --git a/crate/clients/clap/src/actions/pqc/encapsulate.rs b/crate/clients/clap/src/actions/pqc/encapsulate.rs index 0217e43bc7..c84833ea0c 100644 --- a/crate/clients/clap/src/actions/pqc/encapsulate.rs +++ b/crate/clients/clap/src/actions/pqc/encapsulate.rs @@ -1,15 +1,12 @@ use std::{fs::File, io::Write, path::PathBuf}; use clap::Parser; -use cosmian_crypto_core::bytes_ser_de::Serializable; use cosmian_kms_client::{KmsClient, kmip_2_1::requests::encrypt_request}; +use cosmian_kms_crypto::crypto::kem::unpack_kem_encapsulation; use crate::{ actions::{console, labels::KEY_ID, shared::get_key_uid}, - error::{ - KmsCliError, - result::{KmsCliResult, KmsCliResultHelper}, - }, + error::result::{KmsCliResult, KmsCliResultHelper}, }; /// Encapsulate using a PQC public key (ML-KEM-512/768/1024, X25519MLKEM768, @@ -48,22 +45,9 @@ impl EncapsulateAction { .await .with_context(|| "KEM encapsulation failed")?; - let (shared_secret, ciphertext) = if let Some(ciphertext) = response.i_v_counter_nonce { - // Standard PQC KEM: shared_secret in data, ciphertext in iv_counter_nonce - let shared_secret = response.data.context("shared secret is empty")?; - (shared_secret, ciphertext) - } else { - // ConfigurableKEM: both packed as a serialized tuple in data - let data = response.data.context("encrypted data is empty")?; - let (session_key, encapsulation) = - <(zeroize::Zeroizing>, zeroize::Zeroizing>)>::deserialize(&data) - .map_err(|e| { - KmsCliError::Conversion(format!( - "failed deserializing KEM encapsulation from response data: {e}" - )) - })?; - (session_key.to_vec(), encapsulation.to_vec()) - }; + // Every KEM packs `(shared secret, encapsulation)` in `data`; `iv_counter_nonce` is empty + let data = response.data.context("encrypted data is empty")?; + let (shared_secret, ciphertext) = unpack_kem_encapsulation(&data)?; let output_file = self .output_file From a23e8623f541d410b56aa0df130422ed408418c4 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sun, 25 Oct 2026 05:52:10 +0100 Subject: [PATCH 16/20] fix(ui): read the packed KEM encapsulation from Data on the encapsulation page --- ui/src/actions/PQC/PqcEncapsulate.tsx | 20 +++++++---------- ui/src/utils/kem.ts | 32 +++++++++++++++++++++++++++ ui/src/utils/utils.ts | 3 ++- ui/tests/unit/kem.test.ts | 24 ++++++++++++++++++++ 4 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 ui/src/utils/kem.ts create mode 100644 ui/tests/unit/kem.test.ts diff --git a/ui/src/actions/PQC/PqcEncapsulate.tsx b/ui/src/actions/PQC/PqcEncapsulate.tsx index 4ce84c7c0f..0522cf1623 100644 --- a/ui/src/actions/PQC/PqcEncapsulate.tsx +++ b/ui/src/actions/PQC/PqcEncapsulate.tsx @@ -2,6 +2,7 @@ import { Button, Card, Form, Select, Space } from "antd"; import React from "react"; import { Trans, useTranslation } from "react-i18next"; import { downloadFile, sendKmipRequest } from "../../utils/utils"; +import { unpackKemEncapsulation } from "../../utils/kem"; import { encrypt_ec_ttlv_request, parse_encrypt_ttlv_response } from "../../wasm/pkg"; import { useActionState } from "../../hooks/useActionState"; import { ActionResponse } from "../../components/common/ActionResponse"; @@ -30,19 +31,14 @@ const PqcEncapsulateForm: React.FC = () => { const response = await parse_encrypt_ttlv_response(result_str); const respObj = response as unknown as Record; - // shared_secret is in Data, ciphertext is in IVCounterNonce - const sharedSecret = respObj.Data as Uint8Array | number[] | undefined; - const ciphertext = respObj.IVCounterNonce as Uint8Array | number[] | undefined; - - if (ciphertext) { - const ctBytes = ciphertext instanceof Uint8Array ? ciphertext : new Uint8Array(ciphertext); - downloadFile(ctBytes, "encapsulation.bin", "application/octet-stream"); - } - - if (sharedSecret) { - const ssBytes = sharedSecret instanceof Uint8Array ? sharedSecret : new Uint8Array(sharedSecret); - downloadFile(ssBytes, "shared_secret.key", "application/octet-stream"); + // Data packs (shared secret, encapsulation); IVCounterNonce is empty for every KEM + const data = respObj.Data as Uint8Array | number[] | undefined; + if (!data) { + throw new Error("Invalid KEM response: no data"); } + const { sharedSecret, encapsulation } = unpackKemEncapsulation(data); + downloadFile(encapsulation, "encapsulation.bin", "application/octet-stream"); + downloadFile(sharedSecret, "shared_secret.key", "application/octet-stream"); return t("pqcEncapsulate.success"); } diff --git a/ui/src/utils/kem.ts b/ui/src/utils/kem.ts new file mode 100644 index 0000000000..eb87f919e4 --- /dev/null +++ b/ui/src/utils/kem.ts @@ -0,0 +1,32 @@ +/** + * Split the `Data` of a KEM `Encrypt` response. Every KEM (ML-KEM, X25519MLKEM768, + * X448MLKEM1024, ConfigurableKEM) packs `(shared secret, encapsulation)` as two + * length-prefixed byte strings: LEB128 length, then the bytes. `IVCounterNonce` is empty. + */ +export const unpackKemEncapsulation = (data: Uint8Array | number[]): { sharedSecret: Uint8Array; encapsulation: Uint8Array } => { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + let offset = 0; + const readBytes = (): Uint8Array => { + let length = 0; + for (let shift = 0; ; shift += 7) { + const byte = bytes[offset++]; + if (byte === undefined || shift > 28) { + throw new Error("Invalid KEM response: bad length prefix"); + } + length += (byte & 0x7f) * 2 ** shift; + if ((byte & 0x80) === 0) break; + } + if (offset + length > bytes.length) { + throw new Error("Invalid KEM response: truncated data"); + } + const out = bytes.slice(offset, offset + length); + offset += length; + return out; + }; + const sharedSecret = readBytes(); + const encapsulation = readBytes(); + if (offset !== bytes.length) { + throw new Error("Invalid KEM response: trailing data"); + } + return { sharedSecret, encapsulation }; +}; diff --git a/ui/src/utils/utils.ts b/ui/src/utils/utils.ts index 0cb14b3513..e757317564 100644 --- a/ui/src/utils/utils.ts +++ b/ui/src/utils/utils.ts @@ -7,7 +7,8 @@ export const DOCS_BASE_URL = "https://docs.cosmian.com"; * Build a link into the KMS mdBook docs site from a `documentation/docs/` file path * (no `.md`, no leading/trailing slash — e.g. `configuration/authorization/key_ceremony`). */ -export const docsUrl = (path: string, hash?: string): string => `${DOCS_BASE_URL}/key_management_system/${path}.html${hash ? `#${hash}` : ""}`; +export const docsUrl = (path: string, hash?: string): string => + `${DOCS_BASE_URL}/key_management_system/${path}.html${hash ? `#${hash}` : ""}`; /** Strip HTML tags from error responses (server may return HTML error pages). */ const stripHtml = (text: string): string => diff --git a/ui/tests/unit/kem.test.ts b/ui/tests/unit/kem.test.ts new file mode 100644 index 0000000000..06d1e45984 --- /dev/null +++ b/ui/tests/unit/kem.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "vitest"; + +import { unpackKemEncapsulation } from "../../src/utils/kem"; + +describe("unpackKemEncapsulation", () => { + test("splits the secret and an encapsulation with a multi-byte length prefix", () => { + const secret = new Uint8Array(32).fill(7); + const encapsulation = new Uint8Array(1088).fill(9); + // 1088 = 0xC0 0x08 in LEB128 + const packed = new Uint8Array([32, ...secret, 0xc0, 0x08, ...encapsulation]); + const out = unpackKemEncapsulation(packed); + expect(out.sharedSecret).toEqual(secret); + expect(out.encapsulation).toEqual(encapsulation); + expect(unpackKemEncapsulation(Array.from(packed)).encapsulation).toEqual(encapsulation); + }); + + test("rejects truncated, trailing and empty data", () => { + const packed = new Uint8Array([2, 1, 2, 1, 3]); + expect(unpackKemEncapsulation(packed).encapsulation).toEqual(new Uint8Array([3])); + expect(() => unpackKemEncapsulation(packed.slice(0, 4))).toThrow(); + expect(() => unpackKemEncapsulation(new Uint8Array([...packed, 0]))).toThrow(); + expect(() => unpackKemEncapsulation(new Uint8Array())).toThrow(); + }); +}); From 43471aa03d111e41619ba13b23a8256b3ee27dc2 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sun, 25 Oct 2026 07:00:25 +0100 Subject: [PATCH 17/20] test(server): ML-KEM vectors read the packed KEM Encrypt Data via a runner capture transform --- CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md | 1 + crate/test_kms_server/README.md | 2 +- crate/test_kms_server/src/vector_runner.rs | 84 +++++++++++++++++++++- test_data | 2 +- 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md index c6766688de..4b107ef388 100644 --- a/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md +++ b/CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md @@ -20,3 +20,4 @@ - Add a SoftHSM2-backed hybrid KEM lifecycle test (`tests::hsm::hybrid_kem_dek`), TTLV-wrapped import tests (export wrapped by a software AES key, and client-side wrap by the HSM KEK), a wrap/unwrap unit test (`NoEncoding` and TTLV) for `ByteString`-only KEM keys, and a TTLV round-trip test for `ConfigurableKEM*` key values. - Add `tests::configurable_kem_tests` (usage masks, encapsulation round trip, plaintext and `Sign` refusal, `Locate`, `ReKeyKeyPair`, revocation, policy on use including relabelled and stripped imports) and make `ckms pqc` KEM tests compare the decapsulated secret with the encapsulated one. +- Test vectors: the vector runner can capture one part of a packed KEM `Encrypt` response (`Data:kem_shared_secret`, `Data:kem_encapsulation`); the ML-KEM encapsulate/decapsulate vectors in `test_data` use it instead of `IVCounterNonce`. diff --git a/crate/test_kms_server/README.md b/crate/test_kms_server/README.md index e9a3ca45b1..fdc26d1c0e 100644 --- a/crate/test_kms_server/README.md +++ b/crate/test_kms_server/README.md @@ -468,7 +468,7 @@ replays the steps sequentially. | Negative / SetAttribute | `negative/set_attribute/hsm_rotate_offset_rejected` | Tests that SetAttribute rotate_offset on an HSM-resident key is rejected | 4 | | Negative / SetAttribute | `negative/set_attribute/item_not_found` | Tests that Set Attribute returns Item_Not_Found error as per KMIP spec | 1 | | Negative / SetAttribute | `negative/set_attribute/read_only_attribute` | Tests that Set Attribute returns Read_Only_Attribute error as per KMIP spec | 2 | -| Negative / SetAttribute | `negative/set_attribute/readonly_initial_date` | Rejects SetAttribute of InitialDate and verifies the original date is preserved | 6 | +| Negative / SetAttribute | `negative/set_attribute/readonly_initial_date` | Checks that InitialDate is read-only and remains unchanged after a rejected SetAttribute request. | 6 | | Negative / SetAttribute | `negative/set_attribute/readonly_rotate_date` | x-rotate-date is a server-managed read-only attribute. Any attempt to set it via SetAttribute must be rejected with Attribute_Read_Only. | 4 | | Negative / SetAttribute | `negative/set_attribute/readonly_rotate_generation` | x-rotate-generation is a server-managed read-only attribute. Any attempt to set it via SetAttribute must be rejected with Attribute_Read_Only. | 4 | | Negative / Sign | `negative/sign/invalid_message` | Tests that Sign returns Invalid_Message error as per KMIP spec | 1 | diff --git a/crate/test_kms_server/src/vector_runner.rs b/crate/test_kms_server/src/vector_runner.rs index 3d70839c7f..f69451f1d1 100644 --- a/crate/test_kms_server/src/vector_runner.rs +++ b/crate/test_kms_server/src/vector_runner.rs @@ -249,7 +249,10 @@ pub struct TestStep { pub assert_error_contains: Option, /// Values to capture from the response for use in subsequent steps. /// Keys are capture variable names (used as `{{name}}` in later request files); - /// values are the TTLV tag name whose leaf value should be captured. + /// values are the TTLV tag name whose leaf value should be captured. A tag may be + /// suffixed with `:kem_shared_secret` or `:kem_encapsulation` to capture one part of the + /// packed `(shared secret, encapsulation)` `Data` of a KEM `Encrypt` response, e.g. + /// `ciphertext = "Data:kem_encapsulation"`. #[serde(default)] pub capture: HashMap, /// When `true`, the request JSON file contains a complete `RequestMessage` envelope @@ -717,18 +720,67 @@ fn capture_values( captures: &mut HashMap, step_operation: &str, ) -> Result<(), KmsClientError> { - for (var_name, tag) in capture_rules { - let value = find_field_in_json(response, tag).ok_or_else(|| { + for (var_name, rule) in capture_rules { + let (tag, kem_part) = rule + .split_once(':') + .map_or((rule.as_str(), None), |(tag, part)| (tag, Some(part))); + let mut value = find_field_in_json(response, tag).ok_or_else(|| { KmsClientError::UnexpectedError(format!( "Step '{step_operation}': cannot capture '{var_name}': \ tag '{tag}' not found in response" )) })?; + if let Some(part) = kem_part { + value = kem_packed_part(&value, part).ok_or_else(|| { + KmsClientError::UnexpectedError(format!( + "Step '{step_operation}': cannot capture '{var_name}': '{tag}' is not a \ + packed (shared secret, encapsulation) tuple, or '{part}' is not \ + kem_shared_secret/kem_encapsulation" + )) + })?; + } captures.insert(var_name.clone(), value); } Ok(()) } +/// Read one LEB128 length-prefixed byte string; returns it and the remaining bytes. +fn read_length_prefixed(data: &[u8]) -> Option<(&[u8], &[u8])> { + let mut len = 0_usize; + let mut used = 0_usize; + for shift in (0..35).step_by(7) { + let byte = *data.get(used)?; + used += 1; + len |= usize::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + let end = used.checked_add(len)?; + return Some((data.get(used..end)?, data.get(end..)?)); + } + } + None +} + +/// Split the hex `Data` of a KEM `Encrypt` response, a serialized `(shared secret, +/// encapsulation)` tuple, and return the hex of the requested part in the same letter case. +fn kem_packed_part(hex_data: &str, part: &str) -> Option { + let bytes = hex::decode(hex_data).ok()?; + let (shared_secret, rest) = read_length_prefixed(&bytes)?; + let (encapsulation, rest) = read_length_prefixed(rest)?; + if !rest.is_empty() { + return None; + } + let chosen = match part { + "kem_shared_secret" => shared_secret, + "kem_encapsulation" => encapsulation, + _ => return None, + }; + Some(if hex_data.bytes().any(|b| b.is_ascii_lowercase()) { + hex::encode(chosen) + } else { + hex::encode_upper(chosen) + }) +} + /// Resolve a path relative to the repository root (two levels up from `CARGO_MANIFEST_DIR`). fn repo_root() -> Result { PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -1667,6 +1719,32 @@ async fn execute_steps( mod tests { use super::*; + #[test] + fn test_kem_packed_part() { + // 32-byte secret, then a 300-byte encapsulation with a two-byte LEB128 length (0xAC 0x02) + let mut packed = vec![32_u8]; + packed.extend([0xAA; 32]); + packed.extend([0xAC, 0x02]); + packed.extend([0xBB; 300]); + let hex_data = hex::encode_upper(&packed); + assert_eq!( + kem_packed_part(&hex_data, "kem_shared_secret").unwrap(), + "AA".repeat(32) + ); + assert_eq!( + kem_packed_part(&hex_data, "kem_encapsulation").unwrap(), + "BB".repeat(300) + ); + assert_eq!( + kem_packed_part(&hex::encode(&packed), "kem_shared_secret").unwrap(), + "aa".repeat(32) + ); + // unknown part, truncated and trailing data are refused + assert!(kem_packed_part(&hex_data, "other").is_none()); + assert!(kem_packed_part(&hex_data[..hex_data.len() - 2], "kem_encapsulation").is_none()); + assert!(kem_packed_part(&format!("{hex_data}00"), "kem_encapsulation").is_none()); + } + #[test] fn test_find_field_in_json() { let json = serde_json::json!({ diff --git a/test_data b/test_data index c98026b26b..b2fe95043f 160000 --- a/test_data +++ b/test_data @@ -1 +1 @@ -Subproject commit c98026b26b6b215dec4c544b795c92f0b281ea7b +Subproject commit b2fe95043f32a2dfb9ea8ba3080659986da1af72 From 0b15df0bed127c18c4aa00782fa883bff75348ef Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sun, 25 Oct 2026 10:25:08 +0100 Subject: [PATCH 18/20] refactor(server): name the KEM result type, pin the packed layout in a golden test, document the vector capture suffix --- crate/crypto/src/crypto/kem.rs | 23 +++++++++++++++-------- crate/test_kms_server/README.md | 2 ++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/crate/crypto/src/crypto/kem.rs b/crate/crypto/src/crypto/kem.rs index 6bbb45513c..4a558e4f48 100644 --- a/crate/crypto/src/crypto/kem.rs +++ b/crate/crypto/src/crypto/kem.rs @@ -114,8 +114,10 @@ pub fn kem_keygen( )) } -#[allow(clippy::type_complexity)] -pub fn kem_encaps(ek: &[u8]) -> Result<(Zeroizing>, Zeroizing>), CryptoError> { +/// A KEM result: `(shared secret, encapsulation)`. +pub type KemEncapsulation = (Zeroizing>, Zeroizing>); + +pub fn kem_encaps(ek: &[u8]) -> Result { let ek = ConfigurableKemEk::deserialize(ek).map_err(|e| { CryptoError::ConversionError(format!( "failed deserializing the configurable-KEM encapsulation: {e}" @@ -157,11 +159,8 @@ pub fn pack_kem_encapsulation( } /// Inverse of [`pack_kem_encapsulation`]: returns `(shared secret, encapsulation)`. -#[allow(clippy::type_complexity)] -pub fn unpack_kem_encapsulation( - data: &[u8], -) -> Result<(Zeroizing>, Zeroizing>), CryptoError> { - <(Zeroizing>, Zeroizing>)>::deserialize(data).map_err(|e| { +pub fn unpack_kem_encapsulation(data: &[u8]) -> Result { + KemEncapsulation::deserialize(data).map_err(|e| { CryptoError::ConversionError(format!( "the KEM response data is not a (shared secret, encapsulation) tuple: {e}" )) @@ -306,9 +305,17 @@ mod tests { let packed = pack_kem_encapsulation(secret.clone(), encapsulation.clone()).unwrap(); let (s, e) = unpack_kem_encapsulation(&packed).unwrap(); assert_eq!((s, e), (secret, encapsulation)); - // truncated data is refused, not silently shortened + // golden layout, shared with the Web UI parser and the vector runner: + // LEB128 length + secret, LEB128 length (1088 = 0xC0 0x08) + encapsulation + assert_eq!(packed.len(), 1 + 32 + 2 + 1088); + assert_eq!(packed.get(..1).unwrap(), &[32]); + assert_eq!(packed.get(33..35).unwrap(), &[0xC0, 0x08]); + // truncated and trailing data are refused, not silently shortened or ignored let (_, truncated) = packed.split_last().unwrap(); unpack_kem_encapsulation(truncated).unwrap_err(); unpack_kem_encapsulation(&[]).unwrap_err(); + let mut trailing = packed.to_vec(); + trailing.push(0); + unpack_kem_encapsulation(&trailing).unwrap_err(); } } diff --git a/crate/test_kms_server/README.md b/crate/test_kms_server/README.md index fdc26d1c0e..482ed6a60b 100644 --- a/crate/test_kms_server/README.md +++ b/crate/test_kms_server/README.md @@ -643,6 +643,8 @@ assert_success = true # HTTP 200 + ResultStatus check [steps.capture] key_id = "UniqueIdentifier" # capture tag value for use in later steps +# ciphertext = "Data:kem_encapsulation" # one part of a packed KEM Encrypt `Data` +# shared_secret = "Data:kem_shared_secret" [[steps]] operation = "Get" From 0d5bae2f4092f952087eac30d86670a811d83954 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sun, 25 Oct 2026 10:26:25 +0100 Subject: [PATCH 19/20] fix(ui): translate the KEM encapsulation response error and name the unpacked type --- ui/src/actions/PQC/PqcEncapsulate.tsx | 17 +++++++++++------ ui/src/i18n/locales/en/actions.json | 1 + ui/src/i18n/locales/fr/actions.json | 1 + ui/src/i18n/locales/zh-CN/actions.json | 1 + ui/src/utils/kem.ts | 8 +++++++- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/ui/src/actions/PQC/PqcEncapsulate.tsx b/ui/src/actions/PQC/PqcEncapsulate.tsx index 0522cf1623..bde8fb7e95 100644 --- a/ui/src/actions/PQC/PqcEncapsulate.tsx +++ b/ui/src/actions/PQC/PqcEncapsulate.tsx @@ -2,7 +2,7 @@ import { Button, Card, Form, Select, Space } from "antd"; import React from "react"; import { Trans, useTranslation } from "react-i18next"; import { downloadFile, sendKmipRequest } from "../../utils/utils"; -import { unpackKemEncapsulation } from "../../utils/kem"; +import { type KemEncapsulation, unpackKemEncapsulation } from "../../utils/kem"; import { encrypt_ec_ttlv_request, parse_encrypt_ttlv_response } from "../../wasm/pkg"; import { useActionState } from "../../hooks/useActionState"; import { ActionResponse } from "../../components/common/ActionResponse"; @@ -33,12 +33,17 @@ const PqcEncapsulateForm: React.FC = () => { // Data packs (shared secret, encapsulation); IVCounterNonce is empty for every KEM const data = respObj.Data as Uint8Array | number[] | undefined; - if (!data) { - throw new Error("Invalid KEM response: no data"); + let unpacked: KemEncapsulation; + try { + if (!data) { + throw new Error("no data"); + } + unpacked = unpackKemEncapsulation(data); + } catch { + throw new Error(t("pqcEncapsulate.invalidResponse")); } - const { sharedSecret, encapsulation } = unpackKemEncapsulation(data); - downloadFile(encapsulation, "encapsulation.bin", "application/octet-stream"); - downloadFile(sharedSecret, "shared_secret.key", "application/octet-stream"); + downloadFile(unpacked.encapsulation, "encapsulation.bin", "application/octet-stream"); + downloadFile(unpacked.sharedSecret, "shared_secret.key", "application/octet-stream"); return t("pqcEncapsulate.success"); } diff --git a/ui/src/i18n/locales/en/actions.json b/ui/src/i18n/locales/en/actions.json index d713ee6472..6a00fc7b36 100644 --- a/ui/src/i18n/locales/en/actions.json +++ b/ui/src/i18n/locales/en/actions.json @@ -1550,6 +1550,7 @@ "tagsHelp": "Alternative to Key ID: specify tags to identify the key", "submit": "Encapsulate", "missingKeyId": "Missing key identifier.", + "invalidResponse": "Invalid KEM response: expected the shared secret and the encapsulation.", "success": "Encapsulation successful. Ciphertext and shared secret downloaded.", "responseTitle": "PQC KEM encapsulate response" }, diff --git a/ui/src/i18n/locales/fr/actions.json b/ui/src/i18n/locales/fr/actions.json index a562afe9fd..d7a9e7c94a 100644 --- a/ui/src/i18n/locales/fr/actions.json +++ b/ui/src/i18n/locales/fr/actions.json @@ -1550,6 +1550,7 @@ "tagsHelp": "Alternative à l'ID de clé : spécifiez des tags pour identifier la clé", "submit": "Encapsuler", "missingKeyId": "Identifiant de clé manquant.", + "invalidResponse": "Réponse KEM invalide : le secret partagé et l'encapsulation étaient attendus.", "success": "Encapsulation réussie. Texte chiffré et secret partagé téléchargés.", "responseTitle": "Réponse d'encapsulation KEM PQC" }, diff --git a/ui/src/i18n/locales/zh-CN/actions.json b/ui/src/i18n/locales/zh-CN/actions.json index 27d6bf0302..9d34473725 100644 --- a/ui/src/i18n/locales/zh-CN/actions.json +++ b/ui/src/i18n/locales/zh-CN/actions.json @@ -1550,6 +1550,7 @@ "tagsHelp": "Key ID 的替代方式:指定标签以标识密钥", "submit": "封装", "missingKeyId": "缺少密钥标识符。", + "invalidResponse": "无效的 KEM 响应:应包含共享秘密和封装。", "success": "封装成功。密文和共享秘密已下载。", "responseTitle": "PQC KEM 封装响应" }, diff --git a/ui/src/utils/kem.ts b/ui/src/utils/kem.ts index eb87f919e4..7f18f55797 100644 --- a/ui/src/utils/kem.ts +++ b/ui/src/utils/kem.ts @@ -1,9 +1,15 @@ +/** A KEM `Encrypt` result: the shared secret and the encapsulation (KEM ciphertext). */ +export interface KemEncapsulation { + sharedSecret: Uint8Array; + encapsulation: Uint8Array; +} + /** * Split the `Data` of a KEM `Encrypt` response. Every KEM (ML-KEM, X25519MLKEM768, * X448MLKEM1024, ConfigurableKEM) packs `(shared secret, encapsulation)` as two * length-prefixed byte strings: LEB128 length, then the bytes. `IVCounterNonce` is empty. */ -export const unpackKemEncapsulation = (data: Uint8Array | number[]): { sharedSecret: Uint8Array; encapsulation: Uint8Array } => { +export const unpackKemEncapsulation = (data: Uint8Array | number[]): KemEncapsulation => { const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); let offset = 0; const readBytes = (): Uint8Array => { From 30c899e397efb858dc0ef6e02a792f07a5cbc003 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sun, 25 Oct 2026 12:00:41 +0100 Subject: [PATCH 20/20] chore: update git submodule test_data --- test_data | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_data b/test_data index b2fe95043f..a3d4f43b78 160000 --- a/test_data +++ b/test_data @@ -1 +1 @@ -Subproject commit b2fe95043f32a2dfb9ea8ba3080659986da1af72 +Subproject commit a3d4f43b789b1083acabc3c40ad9a5bbb344e2a7