Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
28c3b21
fix(server): support hybrid KEM keys wrapped by an HSM key encryption…
Manuthor Oct 23, 2026
a27a3a4
fix(crypto): gate KeyPairIdentity test import under non-fips
Manuthor Oct 23, 2026
02149d1
fix(deps): bump ctutils to 0.4.4 (RUSTSEC-2026-0385)
Manuthor Oct 23, 2026
885b414
build: update Nix vendor hashes (macOS) [skip ci]
github-actions[bot] Oct 23, 2026
e5a55dc
build: update Nix vendor hashes (Linux) [skip ci]
github-actions[bot] Oct 23, 2026
99de718
fix(deps): bump crypto-bigint to 0.7.6, justify RUSTSEC-2026-0387 for…
Manuthor Oct 24, 2026
6f3c70b
fix(server): support hybrid KEM keys wrapped by an HSM KEK
Manuthor Oct 24, 2026
48fd45d
fix(server): least-privilege ConfigurableKEM masks, uniform KEM Encry…
Manuthor Oct 24, 2026
714a71e
fix(cli): read the uniform KEM encapsulation response and check decap…
Manuthor Oct 24, 2026
2fcec87
fix(server): enforce the algorithm policy on ConfigurableKEM componen…
Manuthor Oct 24, 2026
aba6d9e
test(server): round-trip KEM Encrypt/Decrypt for every KEM type, with…
Manuthor Oct 25, 2026
664c703
fix(server): keep the ConfigurableKEM Encrypt response contract (pack…
Manuthor Oct 25, 2026
f6e531e
fix(cli): read the packed ConfigurableKEM encapsulation again and dec…
Manuthor Oct 25, 2026
24a26bc
fix(server): return the KEM encapsulation in Data for every KEM, neve…
Manuthor Oct 25, 2026
a9f36ff
fix(cli): read the packed KEM encapsulation from Data in pqc encapsul…
Manuthor Oct 25, 2026
a23e862
fix(ui): read the packed KEM encapsulation from Data on the encapsula…
Manuthor Oct 25, 2026
43471aa
test(server): ML-KEM vectors read the packed KEM Encrypt Data via a r…
Manuthor Oct 25, 2026
0b15df0
refactor(server): name the KEM result type, pin the packed layout in …
Manuthor Oct 25, 2026
0d5bae2
fix(ui): translate the KEM encapsulation response error and name the …
Manuthor Oct 25, 2026
30c899e
chore: update git submodule test_data
Manuthor Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cargo/audit.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
23 changes: 23 additions & 0 deletions CHANGELOG/fix_hybrid_kem_hsm_lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 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` 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.
- `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 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 KEM decapsulation benchmarks decapsulate the encapsulation component of the packed result instead of the whole tuple or the IV field.

## 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 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`.
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions crate/clients/ckms/src/tests/pqc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<output>.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(())
}

Expand Down
15 changes: 10 additions & 5 deletions crate/clients/clap/src/actions/bench/kmip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,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;
Expand Down Expand Up @@ -755,7 +757,10 @@ 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());
// 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,
Expand Down Expand Up @@ -818,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,
Expand Down
26 changes: 5 additions & 21 deletions crate/clients/clap/src/actions/pqc/encapsulate.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Vec<u8>>, zeroize::Zeroizing<Vec<u8>>)>::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
Expand Down
3 changes: 1 addition & 2 deletions crate/clients/client_utils/src/configurable_kem_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -173,7 +172,7 @@ pub fn build_create_configurable_kem_keypair_request<T: IntoIterator<Item = impl
cryptographic_algorithm: Some(CryptographicAlgorithm::ConfigurableKEM),
key_format_type: Some(KeyFormatType::ConfigurableKEMSecretKey),
vendor_attributes,
cryptographic_usage_mask: Some(CryptographicUsageMask::Unrestricted),
// no usage mask: the server defaults to Decrypt (private) and Encrypt (public)
sensitive: sensitive.then_some(true),
cryptographic_domain_parameters,
cryptographic_parameters,
Expand Down
112 changes: 100 additions & 12 deletions crate/crypto/src/crypto/kem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -60,7 +60,7 @@ pub fn kem_keygen(
dk_attributes: Option<Attributes>,
ek_uid: String,
ek_attributes: Option<Attributes>,
common_attributes: Attributes,
common_attributes: &Attributes,
) -> Result<KeyPair, CryptoError> {
let tag = match (
common_attributes
Expand Down Expand Up @@ -89,30 +89,35 @@ 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<Attributes>| {
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<Vec<u8>>>,
) -> Result<(Zeroizing<Vec<u8>>, Zeroizing<Vec<u8>>), CryptoError> {
/// A KEM result: `(shared secret, encapsulation)`.
pub type KemEncapsulation = (Zeroizing<Vec<u8>>, Zeroizing<Vec<u8>>);

pub fn kem_encaps(ek: &[u8]) -> Result<KemEncapsulation, CryptoError> {
let ek = ConfigurableKemEk::deserialize(ek).map_err(|e| {
CryptoError::ConversionError(format!(
"failed deserializing the configurable-KEM encapsulation: {e}"
Expand Down Expand Up @@ -141,6 +146,53 @@ pub fn kem_encaps(
))
}

/// 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<Vec<u8>>,
encapsulation: Zeroizing<Vec<u8>>,
) -> Result<Zeroizing<Vec<u8>>, 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)`.
pub fn unpack_kem_encapsulation(data: &[u8]) -> Result<KemEncapsulation, CryptoError> {
KemEncapsulation::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.
pub fn kem_key_components(
key: &[u8],
) -> Result<(Option<RecommendedCurve>, Option<CryptographicAlgorithm>), 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<Zeroizing<Vec<u8>>, CryptoError> {
let dk = ConfigurableKemDk::deserialize(dk)?;

Expand Down Expand Up @@ -168,7 +220,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),
Expand Down Expand Up @@ -204,7 +259,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),
Expand All @@ -231,3 +289,33 @@ 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));
// 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();
}
}
Loading
Loading