diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md new file mode 100644 index 0000000000..aaf57503b9 --- /dev/null +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -0,0 +1,6 @@ +## Documentation + +- Add key auto-rotation specification document covering all 6 rotation + scenarios (plain symmetric, wrapping key, wrapped key, asymmetric pair, + wrapped private key, server-wide KEK), rotation policy attributes, + server-side scheduler, KMIP attribute tables, and implementation roadmap. diff --git a/CHANGELOG/feat_key-rotation-manual.md b/CHANGELOG/feat_key-rotation-manual.md new file mode 100644 index 0000000000..7df17878cb --- /dev/null +++ b/CHANGELOG/feat_key-rotation-manual.md @@ -0,0 +1,55 @@ +## Features + +- Implement KMIP ReKey operation for symmetric keys with name transfer per §4.4 +- Support re-wrapping of dependent keys when a wrapping key is rekeyed +- Add `find_wrapped_by()` method to `ObjectsStore` trait (SQLite, PostgreSQL, MySQL implementations) +- Implement KMIP `ReCertify` operation (§4.7) — certificate rotation with new UID and replacement links +- Add proper `ReCertify` and `ReCertifyResponse` KMIP 2.1 types compliant with both KMIP 1.x and 2.x +- Introduce `RekeyOperation` trait to unify symmetric, keypair, and certificate rotation logic +- Add `offset` field to `ReCertify` struct per KMIP 2.1 §6.1.45 for date-based activation scheduling + +## Refactor + +- Reorganize ReKey modules into `rekey/` folder: `mod.rs`, `symmetric.rs`, `keypair.rs`, `common.rs`; move `ReCertify` handler to `operations/recertify.rs` (top-level, parallel to `certify.rs`) +- Extract `RekeyOperation` trait into `common.rs` with `execute_rekey()` orchestrator — shared 2-phase commit logic +- Extract 6 shared helpers into `common.rs`: `compute_replacement_dates`, `prepare_replacement_attributes`, `update_old_key_after_rekey`, `set_rotation_metadata_on_new_key`, `clear_rotation_flags_on_old_key`, `enforce_privileged_user` +- Add `KeyRetirement` struct + `finalize_rekey` function in `common.rs` — shared Phase 2 logic (retire old keys + rewrap dependants + atomic commit) used by both symmetric and keypair rekey +- Move `compute_rotation_uid` and `rewrap_dependants` from `symmetric.rs` to `common.rs`; keypair rekey now uses name-preserving UIDs +- Convert `ReKeyKeyPair` to 2-phase commit (matching symmetric) to support dependant re-wrapping on public keys +- Set rotation metadata (`rotate_generation`, `rotate_date`, `rotate_latest`, `rotate_interval`) on new keys during `ReKeyKeyPair` +- Clear rotation flags on old keys during `ReKeyKeyPair` to prevent scheduler re-triggering +- Add default implementations to `RekeyOperation` trait for `detect_wrapping`, `persist_new_key`, `finalize_dependants`, and `rewrap_new_objects` — eliminates duplicate code across symmetric.rs, keypair.rs, and recertify.rs +- Extract `extract_rewrap_spec`, `extract_wrapping_key_uid`, and `retrieve_eligible_keys` into `common.rs` as shared helpers — removes 40+ lines of duplicated logic +- Extract shared `validate_no_crypto_param_change` into `common.rs` — validates that ReKey/ReKeyKeyPair requests do not alter algorithm, curve, or key length; now applies to both symmetric and keypair rekey +- Refactor `prepare_attributes` in `keypair.rs` — extract `finalize_replacement_key` helper to eliminate SK/PK code duplication +- Move `setup_new_key` and `finalize_replacement_key` from keypair.rs to common.rs as shared helpers +- Extract `preserve_wrapping_key_link` into common.rs — copies WrappingKeyLink from old to new key +- Split `rewrap_dependants` (70→25 lines) by extracting `rewrap_single_dependant` helper +- Split `relink_keys_to_new_certificate` by extracting `relink_single_key` helper + +## Bug Fixes + +- Transfer `Name` attribute from old key to new key during ReKey per KMIP §4.4 +- Return error instead of silently skipping when a user-supplied wrapping key ID equals the key being wrapped +- Bypass ownership check for server-configured KEK during wrapping operations +- Fix symmetric ReKey missing server-wide KEK wrapping and unwrapped-cache insert (now consistent with keypair rekey via shared default) +- Fix keypair rekey not preserving WrappingKeyLink on replacement keys +- Fix symmetric rekey hardcoding `State::Active` — now uses `setup_object_lifecycle` for date-based state computation +- Fix `setup_object_lifecycle` not storing `activation_date` for `PreActive` keys — offset-based activation scheduling now works correctly +- Add `ReCertify` request/response deserialization to KMIP 2.1 message handler +- Fix `ReCertify.generate_replacement` passing empty user to `get_subject`/`get_issuer` — use certificate owner instead +- Fix `ReCertify` not computing lifecycle state from offset — certificates with future activation_date are now `PreActive` + +## Documentation + +- Add Certificate Renewal (ReCertify) section to key_auto_rotation.md with RFC references (RFC 4210, 4211, 5280, 2986, 5272), KMIP 2.1 §6.1.45 attribute table, and CMP relationship explanation + +## Testing + +- Add 9 symmetric ReKey test vectors (basic, wrapped, wrapping-key re-wrap, name transfer, offset, links) +- Add 27 ReKeyKeyPair test vectors (RSA, EC, ML-KEM, ML-DSA, SLH-DSA, X25519, secp256k1) +- Add Covercrypt ReKeyKeyPair test vector (in-place attribute rekey with same UIDs) +- Add access privilege escalation test vector for ReKey +- Add 4 ReCertify test vectors (self-signed, chain, with-links, with-offset) +- Add 3 negative ReCertify test vectors (missing UID, non-existent, not a certificate) +- Add 2 offset state verification vectors (rekey + rekey-keypair: Offset=0 → Active, Offset=86400 → PreActive) diff --git a/README.md b/README.md index c7b9deee59..8e81006369 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The **Cosmian KMS** presents some unique features, such as: - **Other integrations**: [OpenSSH](./documentation/docs/integrations/openssh.md), [S/MIME email encryption](./documentation/docs/integrations/smime.md), and [FortiGate / FortiOS](./documentation/docs/integrations/fortigate.md). - **Security and standards**: [FIPS 140-3](./documentation/docs/certifications_and_compliance/fips.md), [KMIP 1.0-2.1 binary and JSON TTLV support](./documentation/docs/kmip_support/introduction/index.md), [state-of-the-art authentication mechanisms](./documentation/docs/configuration/authentication.md), and native compatibility with network appliances such as [FortiGate / FortiOS](./documentation/docs/integrations/fortigate.md). - **HSM support**: [Utimaco, SmartCard-HSM/Nitrokey HSM 2, Proteccio, Crypt2pay, and others](./documentation/docs/hsm_support/introduction/index.md), with KMS keys wrapped by HSMs. -- **Operations**: full-featured [CLI and graphical clients](https://docs.cosmian.com/kms_clients/), [high-availability mode](./documentation/docs/installation/high_availability_mode.md), [confidential cloud deployment](./documentation/docs/installation/marketplace_guide.md), [OpenTelemetry integration](./documentation/docs/configuration/logging.md), and [OpenAPI 3.1 spec with Swagger UI](./documentation/docs/kmip_support/openapi.md) for interactive API exploration. +- **Operations**: full-featured [CLI and graphical clients](https://docs.cosmian.com/kms_clients/), [high-availability mode](./documentation/docs/installation/high_availability_mode.md), [confidential cloud deployment](./documentation/docs/installation/marketplace_guide.md), [OpenTelemetry integration](./documentation/docs/configuration/logging.md), [OpenAPI 3.1 spec with Swagger UI](./documentation/docs/kmip_support/openapi.md) for interactive API exploration, and [scheduled key auto-rotation](./documentation/docs/kmip_support/key_auto_rotation.md). The **Cosmian KMS** is both a Key Management System and a Public Key Infrastructure. As a KMS, it is designed to manage the lifecycle of keys and provide scalable cryptographic services such as on-the-fly key generation, encryption, and decryption operations. diff --git a/crate/interfaces/src/stores/objects_store.rs b/crate/interfaces/src/stores/objects_store.rs index 7f27d27353..aca1d07a7d 100644 --- a/crate/interfaces/src/stores/objects_store.rs +++ b/crate/interfaces/src/stores/objects_store.rs @@ -102,4 +102,19 @@ pub trait ObjectsStore { user_must_be_owner: bool, vendor_id: &str, ) -> InterfaceResult>; + + /// Return (uid, state, attributes) for every object whose + /// `key_wrapping_data.encryption_key_information.unique_identifier` equals + /// `wrapping_key_uid`. Used by key rotation to re-wrap all objects protected by + /// the rotated key. + /// + /// The default implementation returns an empty list; backends that support + /// JSON-based object storage should override this with an efficient query. + async fn find_wrapped_by( + &self, + _wrapping_key_uid: &str, + _user: &str, + ) -> InterfaceResult> { + Ok(vec![]) + } } diff --git a/crate/kmip/src/kmip_1_4/kmip_operations.rs b/crate/kmip/src/kmip_1_4/kmip_operations.rs index 6078ea81bb..686907c31c 100644 --- a/crate/kmip/src/kmip_1_4/kmip_operations.rs +++ b/crate/kmip/src/kmip_1_4/kmip_operations.rs @@ -498,6 +498,60 @@ pub struct ReCertifyResponse { pub template_attribute: Option, } +impl From for kmip_2_1::kmip_operations::ReCertify { + fn from(recertify: ReCertify) -> Self { + let cert_req_type = match recertify.certificate_request_type { + CertificateRequestType::CRMF => kmip_2_1::kmip_types::CertificateRequestType::CRMF, + CertificateRequestType::PKCS10 => kmip_2_1::kmip_types::CertificateRequestType::PKCS10, + CertificateRequestType::PEM => kmip_2_1::kmip_types::CertificateRequestType::PEM, + }; + Self { + unique_identifier: Some(recertify.unique_identifier.into()), + certificate_request_type: Some(cert_req_type), + certificate_request_value: Some(recertify.certificate_request_value), + offset: None, + attributes: recertify.template_attribute.map(Into::into), + protection_storage_masks: None, + } + } +} + +impl TryFrom for ReCertifyResponse { + type Error = KmipError; + + fn try_from(value: kmip_2_1::kmip_operations::ReCertifyResponse) -> Result { + Ok(Self { + unique_identifier: value.unique_identifier.to_string(), + template_attribute: None, + }) + } +} + +impl From for ReCertify { + fn from(recertify: kmip_2_1::kmip_operations::ReCertify) -> Self { + let cert_req_type = match recertify.certificate_request_type { + Some(kmip_2_1::kmip_types::CertificateRequestType::CRMF) => { + CertificateRequestType::CRMF + } + Some(kmip_2_1::kmip_types::CertificateRequestType::PKCS10) => { + CertificateRequestType::PKCS10 + } + Some(kmip_2_1::kmip_types::CertificateRequestType::PEM) | None => { + CertificateRequestType::PEM + } + }; + Self { + unique_identifier: recertify + .unique_identifier + .map_or_else(String::new, |u| u.to_string()), + certificate_request_type: cert_req_type, + certificate_request_value: recertify.certificate_request_value.unwrap_or_default(), + template_attribute: None, + // KMIP 1.4 does not support offset; it is dropped during downgrade. + } + } +} + /// 4.9 Locate /// This operation requests that the server search for one or more Managed Objects. #[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] @@ -2647,9 +2701,7 @@ impl TryFrom for kmip_2_1::kmip_operations::Operation { // } // Operation::Poll(poll) => Self::Poll(poll.into()), Operation::Query(query) => Self::Query(query.into()), - // Operation::ReCertify(recertify) => { - // Self::ReCertify(recertify.into()) - // } + Operation::ReCertify(recertify) => Self::ReCertify(Box::new(recertify.into())), // Operation::Recover(recover) => { // Self::Recover(recover.into()) // } @@ -2803,9 +2855,9 @@ impl TryFrom for Operation { (*query_response).try_into().context("QueryResponse")?, )) } - // Operation::ReCertifyResponse(recertify_response) => { - // Self::ReCertifyResponse(recertify_response.into()) - // } + kmip_2_1::kmip_operations::Operation::ReCertifyResponse(recertify_response) => { + Self::ReCertifyResponse(recertify_response.try_into().context("ReCertifyResponse")?) + } // Operation::RecoverResponse(recover_response) => { // Self::RecoverResponse(recover_response.into()) // } diff --git a/crate/kmip/src/kmip_2_1/kmip_messages.rs b/crate/kmip/src/kmip_2_1/kmip_messages.rs index 75d0e73646..0703673963 100644 --- a/crate/kmip/src/kmip_2_1/kmip_messages.rs +++ b/crate/kmip/src/kmip_2_1/kmip_messages.rs @@ -354,6 +354,9 @@ impl<'de> Deserialize<'de> for RequestMessageBatchItem { OperationEnumeration::ReKeyKeyPair => { Operation::ReKeyKeyPair(map.next_value()?) } + OperationEnumeration::ReCertify => { + Operation::ReCertify(map.next_value()?) + } x => { return Err(de::Error::custom(format!( "Request Message Batch Item: unsupported operation: {x:?}" @@ -792,6 +795,9 @@ impl<'de> Deserialize<'de> for ResponseMessageBatchItem { OperationEnumeration::ReKeyKeyPair => { Operation::ReKeyKeyPairResponse(map.next_value()?) } + OperationEnumeration::ReCertify => { + Operation::ReCertifyResponse(map.next_value()?) + } x => { return Err(de::Error::custom(format!( "KMIP 2 response message payload: unsupported operation: \ diff --git a/crate/kmip/src/kmip_2_1/kmip_operations.rs b/crate/kmip/src/kmip_2_1/kmip_operations.rs index 924711bc8f..177bd5b119 100644 --- a/crate/kmip/src/kmip_2_1/kmip_operations.rs +++ b/crate/kmip/src/kmip_2_1/kmip_operations.rs @@ -193,6 +193,8 @@ pub enum Operation { PKCS11Response(PKCS11Response), Query(Query), QueryResponse(Box), + ReCertify(Box), + ReCertifyResponse(ReCertifyResponse), ReKey(ReKey), ReKeyKeyPair(Box), ReKeyKeyPairResponse(ReKeyKeyPairResponse), @@ -277,6 +279,8 @@ impl Display for Operation { Self::PKCS11Response(op) => write!(f, "{op}")?, Self::Query(op) => write!(f, "{op}")?, Self::QueryResponse(op) => write!(f, "{op}")?, + Self::ReCertify(op) => write!(f, "{op}")?, + Self::ReCertifyResponse(op) => write!(f, "{op}")?, Self::ReKey(op) => write!(f, "{op}")?, Self::ReKeyKeyPair(op) => write!(f, "{op}")?, Self::ReKeyKeyPairResponse(op) => write!(f, "{op}")?, @@ -333,6 +337,7 @@ impl Operation { | Self::ModifyAttributeResponse(_) | Self::PKCS11Response(_) | Self::QueryResponse(_) + | Self::ReCertifyResponse(_) | Self::ReKeyKeyPairResponse(_) | Self::ReKeyResponse(_) | Self::RegisterResponse(_) @@ -393,6 +398,7 @@ impl Operation { } Self::PKCS11(_) | Self::PKCS11Response(_) => OperationEnumeration::PKCS11, Self::Query(_) | Self::QueryResponse(_) => OperationEnumeration::Query, + Self::ReCertify(_) | Self::ReCertifyResponse(_) => OperationEnumeration::ReCertify, Self::Register(_) | Self::RegisterResponse(_) => OperationEnumeration::Register, Self::ReKey(_) | Self::ReKeyResponse(_) => OperationEnumeration::ReKey, Self::ReKeyKeyPair(_) | Self::ReKeyKeyPairResponse(_) => { @@ -1021,6 +1027,66 @@ pub struct CertifyResponse { impl_display!(CertifyResponse, "CertifyResponse", { req unique_identifier }); +/// `ReCertify` +/// +/// This operation requests the server to generate a new certificate for an +/// existing public key whose certificate has expired or is about to expire. +/// The request contains the Unique Identifier of the existing certificate to be +/// renewed, an optional certificate request, and optional attributes for the new +/// certificate. +/// +/// The server creates a new Certificate object with a fresh Unique Identifier, +/// sets a `ReplacedObjectLink` on the new certificate pointing to the old one, +/// and sets a `ReplacementObjectLink` on the old certificate pointing to the new one. +/// +/// KMIP 2.1 §6.1.8 / KMIP 1.4 §4.8 +#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "PascalCase")] +pub struct ReCertify { + /// The Unique Identifier of the existing Certificate to be re-certified. + /// If omitted, the ID Placeholder value is used. + #[serde(skip_serializing_if = "Option::is_none")] + pub unique_identifier: Option, + /// An Enumeration object specifying the type of certificate request. + /// Required if Certificate Request Value is present. + #[serde(skip_serializing_if = "Option::is_none")] + pub certificate_request_type: Option, + /// A Byte String object with the certificate request. + #[serde(skip_serializing_if = "Option::is_none")] + pub certificate_request_value: Option>, + /// An Offset MAY be used to indicate the difference between the Initial Date + /// and the Activation Date of the new certificate. Per KMIP 2.1 §6.1.45, + /// the new certificate's Activation Date = Initial Date + Offset. + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Specifies desired attributes to be associated with the new certificate. + #[serde(skip_serializing_if = "Option::is_none")] + pub attributes: Option, + /// Specifies all permissible Protection Storage Mask selections for the new + /// object. + #[serde(skip_serializing_if = "Option::is_none")] + pub protection_storage_masks: Option, +} + +impl_display!(ReCertify, "ReCertify", { + opt unique_identifier, + opt certificate_request_type, + opt_b64 certificate_request_value, + opt offset, + opt attributes, + opt protection_storage_masks, +}); + +/// Response to a `ReCertify` request. +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)] +#[serde(rename_all = "PascalCase")] +pub struct ReCertifyResponse { + /// The Unique Identifier of the newly created replacement certificate. + pub unique_identifier: UniqueIdentifier, +} + +impl_display!(ReCertifyResponse, "ReCertifyResponse", { req unique_identifier }); + /// Create /// /// This operation requests the server to generate a new symmetric key or diff --git a/crate/server/src/core/kms/kmip.rs b/crate/server/src/core/kms/kmip.rs index c498988799..40b46fe09e 100644 --- a/crate/server/src/core/kms/kmip.rs +++ b/crate/server/src/core/kms/kmip.rs @@ -11,10 +11,10 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ GetAttributesResponse, GetResponse, Hash, HashResponse, Import, ImportResponse, Locate, LocateResponse, MAC, MACResponse, MACVerify, MACVerifyResponse, ModifyAttribute, ModifyAttributeResponse, PKCS11, PKCS11Response, Query, QueryResponse, RNGRetrieve, - RNGRetrieveResponse, RNGSeed, RNGSeedResponse, ReKey, ReKeyKeyPair, ReKeyKeyPairResponse, - ReKeyResponse, Register, RegisterResponse, Revoke, RevokeResponse, SetAttribute, - SetAttributeResponse, Sign, SignResponse, SignatureVerify, SignatureVerifyResponse, - Validate, ValidateResponse, + RNGRetrieveResponse, RNGSeed, RNGSeedResponse, ReCertify, ReCertifyResponse, ReKey, + ReKeyKeyPair, ReKeyKeyPairResponse, ReKeyResponse, Register, RegisterResponse, Revoke, + RevokeResponse, SetAttribute, SetAttributeResponse, Sign, SignResponse, SignatureVerify, + SignatureVerifyResponse, Validate, ValidateResponse, }, }; use tracing::Instrument; @@ -669,6 +669,24 @@ impl KMS { .await } + /// `ReCertify` — certificate rotation with a new UID. + /// + /// Creates a fresh certificate for the same subject/issuer and links old → new + /// via `ReplacementObjectLink`. Keys referencing the old certificate are updated + /// to point to the new one. + pub(crate) async fn recertify( + &self, + request: ReCertify, + user: &str, + privileged_users: Option>, + ) -> KResult { + let span = tracing::span!(tracing::Level::ERROR, "recertify"); + + Box::pin(operations::recertify(self, request, user, privileged_users)) + .instrument(span) + .await + } + /// This operation requests the server to modify a single attribute on an existing Managed Object. /// Per KMIP spec §3.22, modifying `ActivationDate` on a Pre-Active object to a date in the past /// or the present triggers an automatic transition to the Active state. diff --git a/crate/server/src/core/operations/certify/build_certificate.rs b/crate/server/src/core/operations/certify/build_certificate.rs index 7188a51191..85bba03128 100644 --- a/crate/server/src/core/operations/certify/build_certificate.rs +++ b/crate/server/src/core/operations/certify/build_certificate.rs @@ -37,7 +37,7 @@ use crate::{ const X509_VERSION3: i32 = 2; -pub(super) fn build_and_sign_certificate( +pub(crate) fn build_and_sign_certificate( vendor_id: &str, issuer: &Issuer, subject: &Subject, diff --git a/crate/server/src/core/operations/certify/issuer.rs b/crate/server/src/core/operations/certify/issuer.rs index 8cc57e24a1..c998c316cb 100644 --- a/crate/server/src/core/operations/certify/issuer.rs +++ b/crate/server/src/core/operations/certify/issuer.rs @@ -8,7 +8,7 @@ use openssl::{ /// A certificate Issuer is constructed from a unique identifier and /// - either a private key and a certificate. /// - or a private key, a subject name and a certificate. -pub(super) enum Issuer<'a> { +pub(crate) enum Issuer<'a> { PrivateKeyAndCertificate( UniqueIdentifier, /// Private key diff --git a/crate/server/src/core/operations/certify/mod.rs b/crate/server/src/core/operations/certify/mod.rs index e3418aab3a..c27edc4c62 100644 --- a/crate/server/src/core/operations/certify/mod.rs +++ b/crate/server/src/core/operations/certify/mod.rs @@ -17,7 +17,10 @@ mod tests; // Re-export the public API of this module. // Re-export helpers used by sibling RFC submodules via `super::`. +pub(crate) use build_certificate::build_and_sign_certificate; use build_certificate::extension_config_is_ca; #[cfg(feature = "non-fips")] use build_certificate::pqc_signing_key_usage; pub(crate) use certify_op::certify; +pub(crate) use resolve_issuer::get_issuer; +pub(crate) use resolve_subject::get_subject; diff --git a/crate/server/src/core/operations/certify/resolve_issuer.rs b/crate/server/src/core/operations/certify/resolve_issuer.rs index e54452fa27..9560d86423 100644 --- a/crate/server/src/core/operations/certify/resolve_issuer.rs +++ b/crate/server/src/core/operations/certify/resolve_issuer.rs @@ -27,7 +27,7 @@ use crate::{ /// Determine the issuer of the issued certificate. /// The issuer can be recovered from different sources or be self-signed. -pub(super) async fn get_issuer<'a>( +pub(crate) async fn get_issuer<'a>( subject: &'a Subject, kms: &KMS, request: &Certify, diff --git a/crate/server/src/core/operations/certify/resolve_subject.rs b/crate/server/src/core/operations/certify/resolve_subject.rs index 011f0e2ad5..a921951ef5 100644 --- a/crate/server/src/core/operations/certify/resolve_subject.rs +++ b/crate/server/src/core/operations/certify/resolve_subject.rs @@ -81,7 +81,7 @@ fn cryptographic_usage_mask_public_key( /// - a certificate /// - a key pair and a subject name /// - a CSR -pub(super) async fn get_subject( +pub(crate) async fn get_subject( kms: &KMS, request: &cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_operations::Certify, user: &str, diff --git a/crate/server/src/core/operations/certify/subject.rs b/crate/server/src/core/operations/certify/subject.rs index ff1b382ee7..43f1ebb9a3 100644 --- a/crate/server/src/core/operations/certify/subject.rs +++ b/crate/server/src/core/operations/certify/subject.rs @@ -18,7 +18,7 @@ use openssl::{ use crate::{kms_error, result::KResult}; /// This holds `KeyPair` information when one is created for the subject -pub(super) struct KeyPairData { +pub(crate) struct KeyPairData { pub(crate) private_key_id: UniqueIdentifier, pub(crate) private_key_object: Object, pub(crate) private_key_tags: HashSet, @@ -45,7 +45,7 @@ impl Display for KeyPairData { /// The party that gets signed by the issuer and gets the certificate #[expect(clippy::large_enum_variant)] -pub(super) enum Subject { +pub(crate) enum Subject { X509Req( /// Unique identifier of the certificate to create UniqueIdentifier, diff --git a/crate/server/src/core/operations/dispatch.rs b/crate/server/src/core/operations/dispatch.rs index 37a3e52994..02caeeb906 100644 --- a/crate/server/src/core/operations/dispatch.rs +++ b/crate/server/src/core/operations/dispatch.rs @@ -3,8 +3,8 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ kmip_2_1::kmip_operations::{ Activate, AddAttribute, Certify, Check, Create, CreateKeyPair, Decrypt, DeleteAttribute, DeriveKey, Destroy, Encrypt, Export, Get, GetAttributeList, GetAttributes, Hash, Import, - Locate, MAC, MACVerify, ModifyAttribute, Operation, Query, RNGRetrieve, RNGSeed, ReKey, - ReKeyKeyPair, Register, Revoke, SetAttribute, Sign, SignatureVerify, Validate, + Locate, MAC, MACVerify, ModifyAttribute, Operation, Query, RNGRetrieve, RNGSeed, ReCertify, + ReKey, ReKeyKeyPair, Register, Revoke, SetAttribute, Sign, SignatureVerify, Validate, }, ttlv::{TTLV, from_ttlv}, }; @@ -181,6 +181,9 @@ async fn dispatch_inner( "ReKeyKeyPair" => { op!(priv ttlv, kms, user, ReKeyKeyPair, rekey_keypair, ReKeyKeyPairResponse) } + "ReCertify" => { + op!(priv ttlv, kms, user, ReCertify, recertify, ReCertifyResponse) + } "Register" => op!(priv ttlv, kms, user, Register, register, RegisterResponse), "Revoke" => op!(ttlv, kms, user, Revoke, revoke, RevokeResponse), "SetAttribute" => op!( diff --git a/crate/server/src/core/operations/key_ops/mod.rs b/crate/server/src/core/operations/key_ops/mod.rs index 18c8236e80..502ff31901 100644 --- a/crate/server/src/core/operations/key_ops/mod.rs +++ b/crate/server/src/core/operations/key_ops/mod.rs @@ -54,6 +54,9 @@ pub(crate) fn setup_object_lifecycle( attributes.last_change_date = Some(now); if state == State::Active { attributes.activation_date = Some(now); + } else if let Some(future_date) = requested_activation_date { + // PreActive: store the future activation date so auto-transition works + attributes.activation_date = Some(future_date); } Ok(attributes.clone()) diff --git a/crate/server/src/core/operations/message.rs b/crate/server/src/core/operations/message.rs index bb02ffae67..2e9dc883ab 100644 --- a/crate/server/src/core/operations/message.rs +++ b/crate/server/src/core/operations/message.rs @@ -338,6 +338,7 @@ fn get_operation_name(operation: &Operation) -> &'static str { Operation::MAC(_) => "MAC", Operation::Query(_) => "Query", Operation::Register(_) => "Register", + Operation::ReCertify(_) => "ReCertify", Operation::ReKey(_) => "ReKey", Operation::ReKeyKeyPair(_) => "ReKeyKeyPair", Operation::Revoke(_) => "Revoke", @@ -494,6 +495,10 @@ async fn process_operation( kms.register(*kmip_request, user, privileged_users) .await?, ), + Operation::ReCertify(kmip_request) => Operation::ReCertifyResponse( + kms.recertify(*kmip_request, user, privileged_users) + .await?, + ), Operation::ReKey(kmip_request) => { Operation::ReKeyResponse(kms.rekey(kmip_request, user, privileged_users).await?) } @@ -537,6 +542,7 @@ async fn process_operation( | Operation::MACResponse(_) | Operation::MACVerifyResponse(_) | Operation::QueryResponse(_) + | Operation::ReCertifyResponse(_) | Operation::RegisterResponse(_) | Operation::ReKeyKeyPairResponse(_) | Operation::ReKeyResponse(_) diff --git a/crate/server/src/core/operations/mod.rs b/crate/server/src/core/operations/mod.rs index 4c129efaeb..4649a34852 100644 --- a/crate/server/src/core/operations/mod.rs +++ b/crate/server/src/core/operations/mod.rs @@ -22,10 +22,9 @@ mod mac; mod message; mod pkcs11; mod query; +mod recertify; mod register; mod rekey; -mod rekey_common; -mod rekey_keypair; mod revoke; mod rng_retrieve; mod rng_seed; @@ -62,8 +61,8 @@ pub(crate) use query::query; pub(crate) use register::register; pub(crate) mod algorithm_policy; pub(crate) use key_ops::{CryptoOpSpec, has_usage_mask, perform_crypto_operation}; -pub(crate) use rekey::rekey; -pub(crate) use rekey_keypair::rekey_keypair; +pub(crate) use recertify::recertify; +pub(crate) use rekey::{rekey, rekey_keypair}; #[cfg(feature = "non-fips")] pub(crate) use revoke::recursively_revoke_key; pub(crate) use revoke::revoke_operation; diff --git a/crate/server/src/core/operations/recertify.rs b/crate/server/src/core/operations/recertify.rs new file mode 100644 index 0000000000..364381305c --- /dev/null +++ b/crate/server/src/core/operations/recertify.rs @@ -0,0 +1,347 @@ +//! KMIP `ReCertify` — certificate rotation with new UID and replacement links. +//! +//! This implements the [`RekeyOperation`] trait for certificate renewal/rotation. +//! Unlike the standard `Certify` operation (which replaces in-place via Upsert), +//! `ReCertify` creates a **new certificate with a fresh UID** and links it to the +//! old certificate via `ReplacedObject` / `ReplacementObject` links. +//! +//! The old certificate remains Active but is marked with a `ReplacementObjectLink` +//! pointing to the new certificate. Keys linked to the old certificate are updated +//! to point to the new certificate via their `CertificateLink`. + +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::State, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attributes, + kmip_data_structures::KeyWrappingSpecification, + kmip_objects::ObjectType, + kmip_operations::{Certify, ReCertify, ReCertifyResponse}, + kmip_types::{LinkType, LinkedObjectIdentifier, UniqueIdentifier}, + }, + time_normalize, + }, + cosmian_kms_interfaces::AtomicOperation, +}; +use cosmian_logger::trace; + +use super::rekey::{ + RekeyOperation, ReplacementObject, RotationCandidate, compute_rotation_uid, + enforce_privileged_user, execute_rekey, prepare_replacement_attributes, + set_rotation_metadata_on_new_key, update_old_key_after_rekey, +}; +use crate::{ + core::{ + KMS, + operations::certify::{build_and_sign_certificate, get_issuer, get_subject}, + retrieve_object_utils::retrieve_object_for_operation, + }, + error::KmsError, + kms_bail, + result::KResult, +}; + +/// Implementor of [`RekeyOperation`] for certificate rotation (`ReCertify`). +pub(crate) struct CertificateRekey { + /// The `offset` from the `ReCertify` request (date arithmetic per KMIP §6.1.45). + offset: Option, +} + +/// KMIP `ReCertify` operation — certificate rotation with new UID. +/// +/// Creates a new certificate for the same subject/issuer, assigns a fresh UID, +/// and links old → new via `ReplacementObjectLink`. Keys referencing the old +/// certificate are updated to point to the new one. +pub(crate) async fn recertify( + kms: &KMS, + request: ReCertify, + owner: &str, + privileged_users: Option>, +) -> KResult { + trace!("ReCertify: {}", serde_json::to_string(&request)?); + let offset = request.offset; + execute_rekey( + &CertificateRekey { offset }, + kms, + &request, + owner, + &privileged_users, + ) + .await +} + +impl RekeyOperation for CertificateRekey { + type Request = ReCertify; + type Response = ReCertifyResponse; + + async fn validate( + &self, + kms: &KMS, + request: &ReCertify, + user: &str, + privileged: &Option>, + ) -> KResult> { + if request.protection_storage_masks.is_some() { + kms_bail!(KmsError::UnsupportedPlaceholder) + } + + enforce_privileged_user(kms, user, privileged).await?; + + let uid = request + .unique_identifier + .as_ref() + .ok_or_else(|| { + KmsError::InvalidRequest( + "ReCertify: unique_identifier of the certificate to rotate is required" + .to_owned(), + ) + })? + .as_str() + .ok_or_else(|| { + KmsError::InvalidRequest( + "ReCertify: unique_identifier must be a text string".to_owned(), + ) + })?; + + let owm = retrieve_object_for_operation(uid, KmipOperation::Certify, kms, user).await?; + + if owm.object().object_type() != ObjectType::Certificate { + kms_bail!(KmsError::InvalidRequest(format!( + "ReCertify: object {uid} is not a Certificate" + ))); + } + + Ok(vec![RotationCandidate { + owm, + uid: uid.to_owned(), + object_type: ObjectType::Certificate, + }]) + } + + async fn generate_replacement( + &self, + kms: &KMS, + candidates: &[RotationCandidate], + ) -> KResult> { + let candidate = candidates + .first() + .ok_or_else(|| KmsError::InvalidRequest("no rotation candidate".to_owned()))?; + let new_uid = compute_rotation_uid(&candidate.uid); + + // Build a Certify request that references the existing certificate for renewal. + // We pass the old certificate's UID so `get_subject` produces a `Subject::Certificate`. + let certify_request = Certify { + unique_identifier: Some(UniqueIdentifier::TextString(candidate.uid.clone())), + certificate_request_type: None, + certificate_request_value: None, + attributes: Some(Attributes { + // The new certificate UID is set in the attributes so `get_subject` uses it. + unique_identifier: Some(UniqueIdentifier::TextString(new_uid.clone())), + // Preserve issuer links from the old certificate's attributes + ..candidate.owm.attributes().clone() + }), + protection_storage_masks: None, + }; + + // Resolve subject (will produce Subject::Certificate from existing cert) + let owner = candidate.owm.owner(); + let subject = Box::pin(get_subject(kms, &certify_request, owner, None)).await?; + // Resolve issuer from the old certificate's attributes + let issuer = Box::pin(get_issuer(&subject, kms, &certify_request, owner)).await?; + // Build and sign the new certificate + let (certificate_object, tags, attributes) = + build_and_sign_certificate(kms.vendor_id(), &issuer, &subject, certify_request)?; + + Ok(vec![ReplacementObject { + new_uid, + old_uid: candidate.uid.clone(), + object: certificate_object, + attributes, + tags, + // Certificates don't wrap anything, no dependant re-wrapping needed. + rewrap_to: None, + }]) + } + + fn prepare_attributes( + &self, + kms: &KMS, + candidates: &[RotationCandidate], + replacements: &mut [ReplacementObject], + ) -> KResult<()> { + let old_attrs = candidates + .first() + .ok_or_else(|| KmsError::InvalidRequest("no rotation candidate".to_owned()))? + .owm + .attributes(); + let replacement = replacements + .first_mut() + .ok_or_else(|| KmsError::InvalidRequest("no replacement object".to_owned()))?; + + // Use shared date arithmetic for offset-based activation/deactivation + let base_attrs = + prepare_replacement_attributes(old_attrs, &replacement.old_uid, self.offset)?; + replacement.attributes.activation_date = base_attrs.activation_date; + replacement.attributes.deactivation_date = base_attrs.deactivation_date; + replacement.attributes.initial_date = base_attrs.initial_date; + replacement.attributes.last_change_date = base_attrs.last_change_date; + + // Compute state based on activation_date (certificates bypass setup_object_lifecycle) + let now = time_normalize()?; + let state = if replacement + .attributes + .activation_date + .is_some_and(|d| d <= now) + { + State::Active + } else { + State::PreActive + }; + replacement.attributes.state = Some(state); + + // Set ReplacedObjectLink on the new certificate pointing to the old one + replacement.attributes.set_link( + LinkType::ReplacedObjectLink, + LinkedObjectIdentifier::TextString(replacement.old_uid.clone()), + ); + + // Preserve links to associated keys from the old certificate + for link_type in [LinkType::PublicKeyLink, LinkType::PrivateKeyLink] { + if let Some(link) = old_attrs.get_link(link_type) { + replacement.attributes.set_link(link_type, link); + } + } + + // Set rotation metadata + vendor tags + set_rotation_metadata_on_new_key(&mut replacement.attributes, old_attrs)?; + replacement.tags.extend(old_attrs.get_tags(kms.vendor_id())); + + Ok(()) + } + + async fn rewrap_new_objects( + &self, + _kms: &KMS, + _user: &str, + _replacements: &mut [ReplacementObject], + _wrap_specs: &[Option], + ) -> KResult<()> { + // Certificates are never wrapped — no-op. + Ok(()) + } + + async fn finalize_dependants( + &self, + kms: &KMS, + user: &str, + candidates: &[RotationCandidate], + replacements: &[ReplacementObject], + ) -> KResult<()> { + let candidate = candidates + .first() + .ok_or_else(|| KmsError::InvalidRequest("no rotation candidate".to_owned()))?; + let replacement = replacements + .first() + .ok_or_else(|| KmsError::InvalidRequest("no replacement object".to_owned()))?; + + // Phase 2: Update the old certificate with ReplacementObjectLink + let mut old_object = candidate.owm.object().clone(); + let mut old_attributes = candidate.owm.attributes().clone(); + update_old_key_after_rekey(&mut old_attributes, &replacement.new_uid)?; + if let Ok(obj_attrs) = old_object.attributes_mut() { + update_old_key_after_rekey(obj_attrs, &replacement.new_uid)?; + } + + let mut operations = vec![AtomicOperation::UpdateObject(( + candidate.uid.clone(), + old_object, + old_attributes, + None, + ))]; + + // Relink keys: update CertificateLink on linked PK/SK to point to new cert UID + relink_keys_to_new_certificate( + kms, + user, + candidate.owm.attributes(), + &replacement.new_uid, + &mut operations, + ) + .await?; + + kms.database.atomic(user, &operations).await?; + Ok(()) + } + + fn build_response(&self, replacements: &[ReplacementObject]) -> ReCertifyResponse { + ReCertifyResponse { + unique_identifier: UniqueIdentifier::TextString( + replacements + .first() + .map_or_else(String::new, |r| r.new_uid.clone()), + ), + } + } +} + +/// Update `CertificateLink` on any keys that reference the old certificate +/// to point to the new certificate UID. +async fn relink_keys_to_new_certificate( + kms: &KMS, + _user: &str, + old_cert_attrs: &Attributes, + new_cert_uid: &str, + operations: &mut Vec, +) -> KResult<()> { + let old_cert_uid = old_cert_attrs + .unique_identifier + .as_ref() + .map(std::string::ToString::to_string) + .unwrap_or_default(); + + // Collect key UIDs linked from the old certificate + let key_uids: Vec = [LinkType::PublicKeyLink, LinkType::PrivateKeyLink] + .iter() + .filter_map(|lt| old_cert_attrs.get_link(*lt).map(|l| l.to_string())) + .collect(); + + for key_uid in key_uids { + if let Some(op) = relink_single_key(kms, &key_uid, &old_cert_uid, new_cert_uid).await? { + operations.push(op); + } + } + Ok(()) +} + +/// Update a single key's `CertificateLink` if it points to the old certificate. +async fn relink_single_key( + kms: &KMS, + key_uid: &str, + old_cert_uid: &str, + new_cert_uid: &str, +) -> KResult> { + let Some(key_owm) = kms.database.retrieve_object(key_uid).await? else { + return Ok(None); + }; + let Some(cert_link) = key_owm.attributes().get_link(LinkType::CertificateLink) else { + return Ok(None); + }; + if cert_link.to_string() != old_cert_uid { + return Ok(None); + } + + let mut key_object = key_owm.object().clone(); + let mut key_attrs = key_owm.attributes().clone(); + let new_link = LinkedObjectIdentifier::TextString(new_cert_uid.to_owned()); + key_attrs.set_link(LinkType::CertificateLink, new_link.clone()); + if let Ok(obj_attrs) = key_object.attributes_mut() { + obj_attrs.set_link(LinkType::CertificateLink, new_link); + } + Ok(Some(AtomicOperation::UpdateObject(( + key_uid.to_owned(), + key_object, + key_attrs, + None, + )))) +} diff --git a/crate/server/src/core/operations/rekey.rs b/crate/server/src/core/operations/rekey.rs deleted file mode 100644 index 005766ef7a..0000000000 --- a/crate/server/src/core/operations/rekey.rs +++ /dev/null @@ -1,171 +0,0 @@ -use cosmian_kms_server_database::reexport::{ - cosmian_kmip::{ - kmip_0::kmip_types::State, - kmip_2_1::{ - KmipOperation, - kmip_objects::ObjectType, - kmip_operations::{Create, ReKey, ReKeyResponse}, - kmip_types::UniqueIdentifier, - }, - }, - cosmian_kms_interfaces::AtomicOperation, -}; -use cosmian_logger::{info, trace}; -use uuid::Uuid; - -use super::rekey_common::{prepare_replacement_attributes, update_old_key_after_rekey}; -use crate::{ - core::{ - KMS, - operations::key_ops::{ObjectWithMetadataOps, setup_object_lifecycle}, - retrieve_object_utils::user_has_permission, - wrapping::wrap_and_cache, - }, - error::KmsError, - kms_bail, - result::{KResult, KResultHelper}, -}; - -/// KMIP `ReKey` operation for symmetric keys. -/// -/// Per KMIP 1.4 §4.4 / KMIP 2.1 §6.1.46: -/// - Creates a new replacement key with a new Unique Identifier. -/// - Sets a Link of type `ReplacementObjectLink` on the existing key pointing to the new key. -/// - Sets a Link of type `ReplacedObjectLink` on the new key pointing to the existing key. -/// - The replacement key takes over the Name attribute of the existing key. -/// - The existing key's **State is NOT changed** — the spec does not deactivate it. -/// - If `offset` is provided, date arithmetic per Table 172 is applied. -pub(crate) async fn rekey( - kms: &KMS, - request: ReKey, - owner: &str, - privileged_users: Option>, -) -> KResult { - trace!("ReKey: {}", serde_json::to_string(&request)?); - - if request.protection_storage_masks.is_some() { - kms_bail!(KmsError::UnsupportedPlaceholder) - } - - // ReKey creates a new replacement key — enforce privileged-user restriction - if let Some(ref users) = privileged_users { - let has_permission = user_has_permission(owner, None, &KmipOperation::Create, kms).await?; - - if !has_permission && !users.iter().any(|u| u == owner) { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right.".to_owned() - )) - } - } - - // there must be an identifier - let uid_or_tags = request - .unique_identifier - .as_ref() - .ok_or(KmsError::UnsupportedPlaceholder)? - .as_str() - .context("Rekey: the symmetric key unique identifier must be a string")?; - - let offset = request.offset; - - // retrieve the symmetric key associated with the uid - for owm in kms - .database - .retrieve_objects(uid_or_tags) - .await? - .into_values() - { - // only active objects - if owm.state() != State::Active { - continue; - } - // only symmetric keys - if owm.object().object_type() != ObjectType::SymmetricKey { - continue; - } - - // Reject wrapped keys — the server cannot safely rekey a wrapped object - if owm.object().key_wrapping_data().is_some() { - kms_bail!(KmsError::InconsistentOperation( - "The server cannot rekey: the key is wrapped. Unwrap it first.".to_owned() - )) - } - - let old_uid = owm.id().to_owned(); - - // Verify the caller is allowed to rekey this object - if !owm - .user_can_perform_operation(owner, &KmipOperation::Rekey, kms) - .await? - { - continue; - } - - // Prepare replacement attributes using shared logic (links, name, dates) - let new_attributes = prepare_replacement_attributes(owm.attributes(), &old_uid, offset)?; - - // Compute the activation date for lifecycle setup - let activation_date = new_attributes.activation_date; - - // Create a new symmetric key with fresh key material - let create_request = Create { - object_type: ObjectType::SymmetricKey, - attributes: new_attributes, - protection_storage_masks: None, - }; - let (_uid, mut new_object, tags) = - KMS::create_symmetric_key_and_tags(kms.vendor_id(), &create_request)?; - - // Generate a new UID for the replacement key - let new_uid = Uuid::new_v4().to_string(); - - // Set up lifecycle attributes (state based on activation date) - let new_obj_attributes = - setup_object_lifecycle(&mut new_object, ObjectType::SymmetricKey, activation_date)?; - - // Wrap the new object if requested - Box::pin(wrap_and_cache( - kms, - owner, - &UniqueIdentifier::TextString(new_uid.clone()), - &mut new_object, - )) - .await?; - - // Update the old key using shared logic (ReplacementObjectLink, remove name, last change) - let mut old_object = owm.object().clone(); - let mut old_attributes = owm.attributes().clone(); - - update_old_key_after_rekey(&mut old_attributes, &new_uid)?; - - // Update internal object attributes too - if let Ok(obj_attrs) = old_object.attributes_mut() { - update_old_key_after_rekey(obj_attrs, &new_uid)?; - } - - // Execute all operations atomically: - // 1. Create the new replacement key - // 2. Update the old key (add link, remove name, update last change date) - let operations = vec![ - AtomicOperation::Create((new_uid.clone(), new_object, new_obj_attributes, tags)), - AtomicOperation::UpdateObject((old_uid.clone(), old_object, old_attributes, None)), - ]; - - kms.database.atomic(owner, &operations).await?; - - info!( - old_uid = old_uid, - new_uid = new_uid, - user = owner, - "Re-keyed symmetric key: new replacement key created, old key remains Active", - ); - - return Ok(ReKeyResponse { - unique_identifier: UniqueIdentifier::TextString(new_uid), - }); - } - - Err(KmsError::InvalidRequest(format!( - "rekey: no active symmetric key found for uid/tags: {uid_or_tags}", - ))) -} diff --git a/crate/server/src/core/operations/rekey/common.rs b/crate/server/src/core/operations/rekey/common.rs new file mode 100644 index 0000000000..abffcd5bcd --- /dev/null +++ b/crate/server/src/core/operations/rekey/common.rs @@ -0,0 +1,852 @@ +//! Shared logic for KMIP `ReKey` (§4.4), `ReKeyKeyPair` (§4.5), and `ReCertify` (§4.7) operations. +//! +//! All rotation operations follow the same pattern via the [`RekeyOperation`] trait: +//! - Validate inputs and resolve candidates for rotation. +//! - Detect wrapping context on existing objects. +//! - Generate replacement material (new key/cert) with fresh UIDs. +//! - Prepare attributes: links, lifecycle dates, rotation metadata. +//! - Re-wrap new objects if the originals were wrapped. +//! - Phase 1: persist new objects atomically. +//! - Phase 2: retire old objects, finalize dependants (rewrap keys / relink certs). +//! - Build and return the KMIP response. + +use std::collections::HashSet; + +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::State, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attributes, + kmip_data_structures::KeyWrappingSpecification, + kmip_objects::{Object, ObjectType}, + kmip_types::{ + EncodingOption, EncryptionKeyInformation, LinkType, LinkedObjectIdentifier, + UniqueIdentifier, + }, + }, + time_normalize, + }, + cosmian_kms_interfaces::{AtomicOperation, ObjectWithMetadata}, +}; +use cosmian_logger::{info, warn}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + core::{ + KMS, + operations::key_ops::setup_object_lifecycle, + retrieve_object_utils::user_has_permission, + wrapping::{unwrap_object, wrap_and_cache, wrap_object}, + }, + error::KmsError, + kms_bail, + result::KResult, +}; + +// ─── Shared helpers (used by all rotation trait implementors) ──────────────── + +/// Extract the full wrapping specification from an object's `KeyWrappingData`. +/// +/// Returns `None` if the object has no key block or is not wrapped. +/// Used by the default [`RekeyOperation::detect_wrapping`] implementation. +pub(crate) fn extract_rewrap_spec(object: &Object) -> Option { + let kb = object.key_block().ok()?; + let kwd = kb.key_wrapping_data.as_ref()?; + Some(KeyWrappingSpecification { + wrapping_method: kwd.wrapping_method, + encryption_key_information: kwd.encryption_key_information.clone(), + mac_or_signature_key_information: kwd.mac_signature_key_information.clone(), + attribute_name: None, + encoding_option: kwd.encoding_option, + }) +} + +/// Extract the wrapping key UID from a wrapped object's encryption key information. +/// +/// Returns `None` if the object is not wrapped or has no `EncryptionKeyInformation`. +pub(crate) fn extract_wrapping_key_uid(object: &Object) -> Option { + object + .key_block() + .ok() + .and_then(|kb| kb.key_wrapping_data.as_ref()) + .and_then(|kwd| kwd.encryption_key_information.as_ref()) + .and_then(|eki| eki.unique_identifier.as_str()) + .map(str::to_owned) +} + +/// Copy the `WrappingKeyLink` from an old (wrapped) object to the new object's attributes. +/// +/// If the old object was wrapped, the wrapping key UID is preserved as a +/// `LinkType::WrappingKeyLink` on the replacement's attributes so that +/// dependant re-wrapping and attribute queries work correctly. +pub(crate) fn preserve_wrapping_key_link(old_object: &Object, new_attrs: &mut Attributes) { + if let Some(wrapping_key_uid) = extract_wrapping_key_uid(old_object) { + new_attrs.set_link( + LinkType::WrappingKeyLink, + LinkedObjectIdentifier::TextString(wrapping_key_uid), + ); + } +} + +/// Retrieve all eligible objects matching the given identifier, filtered by state and type. +/// +/// Filters by: +/// - State: `Active` or `PreActive` +/// - Object type: the specified `object_type` +/// +/// Returns the list of matching [`ObjectWithMetadata`] entries. +pub(crate) async fn retrieve_eligible_keys( + kms: &KMS, + uid_or_tags: &str, + object_type: ObjectType, +) -> KResult> { + Ok(kms + .database + .retrieve_objects(uid_or_tags) + .await? + .into_values() + .filter(|owm| { + (owm.state() == State::Active || owm.state() == State::PreActive) + && owm.object().object_type() == object_type + }) + .collect()) +} + +// ─── Trait: RekeyOperation ─────────────────────────────────────────────────── + +/// An existing object that is a candidate for rotation. +#[allow(dead_code)] +pub(crate) struct RotationCandidate { + /// The object-with-metadata from the database. + pub owm: ObjectWithMetadata, + /// The UID of this object. + pub uid: String, + /// The KMIP object type. + pub object_type: ObjectType, +} + +/// A newly generated replacement object ready for Phase 1 commit. +#[allow(dead_code)] +pub(crate) struct ReplacementObject { + /// The fresh UID for the replacement. + pub new_uid: String, + /// The UID of the old object being replaced. + pub old_uid: String, + /// The new KMIP object (key or certificate). + pub object: Object, + /// Attributes for the new object. + pub attributes: Attributes, + /// Tags for the new object (used in `AtomicOperation::Create`). + pub tags: HashSet, + /// If `Some`, dependants of the old object will be re-wrapped/re-linked + /// to this UID during Phase 2. `None` means no dependant processing for this slot. + pub rewrap_to: Option, +} + +/// Unified trait for all rotation operations: `ReKey`, `ReKeyKeyPair`, and `ReCertify`. +/// +/// Each implementor provides type-specific logic for the 8 steps of the rotation pipeline. +/// The shared [`execute_rekey`] orchestrator drives the pipeline in order. +pub(crate) trait RekeyOperation { + /// The KMIP request type (e.g. `ReKey`, `ReKeyKeyPair`, `Certify`). + type Request; + /// The KMIP response type (e.g. `ReKeyResponse`, `ReKeyKeyPairResponse`). + type Response; + + /// Step 1: Parse request, validate inputs, check permissions. + /// + /// Returns one or more [`RotationCandidate`]s (existing objects eligible for rotation). + /// For symmetric keys this is 1 candidate; for key pairs, 2 (SK + PK); for certs, 1. + fn validate( + &self, + kms: &KMS, + request: &Self::Request, + user: &str, + privileged: &Option>, + ) -> impl std::future::Future>>; + + /// Step 2: Detect wrapping context on existing object(s). + /// + /// Returns one `Option` per candidate. + /// The default implementation extracts wrapping data from each candidate's key block. + /// Certificates (which have no key block) naturally return `None`. + fn detect_wrapping( + &self, + candidates: &[RotationCandidate], + ) -> Vec> { + candidates + .iter() + .map(|c| extract_rewrap_spec(c.owm.object())) + .collect() + } + + /// Step 3: Generate replacement material (new key/cert + fresh UIDs). + /// + /// Returns one [`ReplacementObject`] per new object to create. + /// For key pairs this may return 2 objects from 2 candidates. + fn generate_replacement( + &self, + kms: &KMS, + candidates: &[RotationCandidate], + ) -> impl std::future::Future>>; + + /// Step 4: Prepare attributes — links, lifecycle dates, rotation metadata. + fn prepare_attributes( + &self, + kms: &KMS, + candidates: &[RotationCandidate], + replacements: &mut [ReplacementObject], + ) -> KResult<()>; + + /// Step 5: Re-wrap new objects if originals were wrapped. + /// + /// The default implementation handles both: + /// 1. Server-wide KEK wrapping (via `wrap_and_cache` — no-op if no KEK configured) + /// 2. Re-wrapping with the same spec as the old object (if it was wrapped) + /// + /// Certificates should override with a no-op since they are never wrapped. + fn rewrap_new_objects( + &self, + kms: &KMS, + user: &str, + replacements: &mut [ReplacementObject], + wrap_specs: &[Option], + ) -> impl std::future::Future> { + default_rewrap_new_objects(kms, user, replacements, wrap_specs) + } + + /// Step 6: Phase 1 — persist new objects atomically. + /// + /// The default implementation creates all replacement objects in a single atomic transaction. + fn persist_new_key( + &self, + kms: &KMS, + user: &str, + replacements: &[ReplacementObject], + ) -> impl std::future::Future> { + default_persist_new_key(kms, user, replacements) + } + + /// Step 7: Phase 2 — retire old objects + finalize dependants. + /// + /// For keys: rewrap all dependants with the new wrapping key. + /// For certificates: relink keys' `CertificateLink` to the new cert UID. + /// + /// The default implementation builds [`KeyRetirement`] entries from each + /// candidate/replacement pair and delegates to [`finalize_rekey`]. + /// Override this for certificate-specific logic. + fn finalize_dependants( + &self, + kms: &KMS, + user: &str, + candidates: &[RotationCandidate], + replacements: &[ReplacementObject], + ) -> impl std::future::Future> { + default_finalize_dependants(kms, user, candidates, replacements) + } + + /// Step 8: Build the KMIP response from the completed replacements. + fn build_response(&self, replacements: &[ReplacementObject]) -> Self::Response; +} + +/// Default implementation for [`RekeyOperation::persist_new_key`]. +/// +/// Creates all replacement objects in a single atomic database transaction. +async fn default_persist_new_key( + kms: &KMS, + user: &str, + replacements: &[ReplacementObject], +) -> KResult<()> { + let operations: Vec = replacements + .iter() + .map(|r| { + AtomicOperation::Create(( + r.new_uid.clone(), + r.object.clone(), + r.attributes.clone(), + r.tags.clone(), + )) + }) + .collect(); + kms.database.atomic(user, &operations).await?; + Ok(()) +} + +/// Default implementation for [`RekeyOperation::finalize_dependants`]. +/// +/// Builds [`KeyRetirement`] entries from each candidate/replacement pair, +/// delegates to [`finalize_rekey`], and logs the result. +async fn default_finalize_dependants( + kms: &KMS, + user: &str, + candidates: &[RotationCandidate], + replacements: &[ReplacementObject], +) -> KResult<()> { + let retirements: Vec> = candidates + .iter() + .zip(replacements.iter()) + .map(|(c, r)| KeyRetirement { + old_owm: &c.owm, + new_uid: &r.new_uid, + rewrap_to: r.rewrap_to.as_deref(), + }) + .collect(); + + Box::pin(finalize_rekey(kms, user, &retirements)).await?; + + for (c, r) in candidates.iter().zip(replacements.iter()) { + info!( + "Rekey finalized: old={} → new={}, user={user}", + c.uid, r.new_uid + ); + } + Ok(()) +} + +/// Default implementation for [`RekeyOperation::rewrap_new_objects`]. +/// +/// For each replacement object: +/// 1. Applies server-wide KEK wrapping via `wrap_and_cache` (no-op if none configured). +/// 2. If the old object was wrapped (spec present) and the new object is still unwrapped, +/// applies the same wrapping specification and caches the unwrapped copy. +async fn default_rewrap_new_objects( + kms: &KMS, + user: &str, + replacements: &mut [ReplacementObject], + wrap_specs: &[Option], +) -> KResult<()> { + for (replacement, spec) in replacements.iter_mut().zip(wrap_specs.iter()) { + // Step 1: server-wide KEK wrapping (no-op if no KEK configured or already wrapped) + Box::pin(wrap_and_cache( + kms, + user, + &UniqueIdentifier::TextString(replacement.new_uid.clone()), + &mut replacement.object, + )) + .await?; + + // Step 2: re-wrap with original spec if old key was wrapped and new key is still unwrapped + let Some(mut rewrap_spec) = spec.clone() else { + continue; + }; + if replacement.object.is_wrapped() { + continue; + } + if replacement + .object + .key_block() + .is_ok_and(|kb| kb.key_bytes().is_ok()) + { + rewrap_spec.encoding_option = Some(EncodingOption::NoEncoding); + } + + let unwrapped_object = replacement.object.clone(); + Box::pin(wrap_object( + &mut replacement.object, + &rewrap_spec, + kms, + user, + )) + .await?; + kms.database + .unwrapped_cache() + .insert( + replacement.new_uid.clone(), + &replacement.object, + unwrapped_object, + ) + .await?; + } + Ok(()) +} + +/// Execute the full rotation pipeline using a [`RekeyOperation`] implementor. +/// +/// This orchestrator drives the 8-step rotation flow in order: +/// validate → detect wrapping → generate → prepare attributes → rewrap → commit → finalize → respond. +pub(crate) async fn execute_rekey( + op: &T, + kms: &KMS, + request: &T::Request, + user: &str, + privileged: &Option>, +) -> KResult { + let candidates = op.validate(kms, request, user, privileged).await?; + let wrap_specs = op.detect_wrapping(&candidates); + let mut replacements = op.generate_replacement(kms, &candidates).await?; + op.prepare_attributes(kms, &candidates, &mut replacements)?; + op.rewrap_new_objects(kms, user, &mut replacements, &wrap_specs) + .await?; + op.persist_new_key(kms, user, &replacements).await?; + op.finalize_dependants(kms, user, &candidates, &replacements) + .await?; + Ok(op.build_response(&replacements)) +} + +// ─── Shared helpers (used by trait implementors) ───────────────────────────── + +/// Dates computed for a replacement key based on the existing key's dates and an optional offset. +/// +/// Per KMIP 1.4 Tables 172/176: +/// - `activation = initialization + offset` (if offset provided) +/// - `deactivation = old_deactivation + (new_activation - old_activation)` (if both exist) +#[allow(clippy::struct_field_names)] +pub(crate) struct ReplacementDates { + pub initialization_date: OffsetDateTime, + pub activation_date: Option, + pub deactivation_date: Option, +} + +/// Compute the replacement key's dates from the existing key's attributes and an optional offset. +/// +/// KMIP 1.4 §4.4 Table 172 / §4.5 Table 176: +/// - Initialization Date (IT₂) = now (always > IT₁) +/// - Activation Date (AT₂) = IT₂ + Offset (if offset provided), else IT₂ (immediate activation) +/// - Deactivation Date = DT₁ + (AT₂ - AT₁) (if both DT₁ and AT₁ exist) +pub(crate) fn compute_replacement_dates( + old_attrs: &Attributes, + offset: Option, +) -> KResult { + let now = time_normalize()?; + + let activation_date = + Some(offset.map_or(now, |secs| now + time::Duration::seconds(i64::from(secs)))); + + let deactivation_date = match (old_attrs.deactivation_date, old_attrs.activation_date) { + (Some(old_deactivation), Some(old_activation)) => { + // DT₂ = DT₁ + (AT₂ - AT₁) + activation_date.map(|new_activation| { + let shift = new_activation - old_activation; + old_deactivation + shift + }) + } + _ => None, + }; + + Ok(ReplacementDates { + initialization_date: now, + activation_date, + deactivation_date, + }) +} + +/// Prepare attributes for a replacement key, following KMIP 1.4 §4.4 Table 173 / §4.5 Table 177. +/// +/// This function: +/// - Copies attributes from the existing key +/// - Removes stale unique identifier and links +/// - Sets `ReplacedObjectLink` → old key +/// - Transfers the Name from old key (already in the cloned attributes) +/// - Sets Initial Date, Last Change Date to now +/// - Applies offset-based date arithmetic +/// - Clears fields that must not be carried over (`destroy_date`, compromise dates, revocation) +pub(crate) fn prepare_replacement_attributes( + old_attrs: &Attributes, + old_uid: &str, + offset: Option, +) -> KResult { + let dates = compute_replacement_dates(old_attrs, offset)?; + + let mut new_attrs = old_attrs.clone(); + + // Clear fields that must not be set on the replacement key + new_attrs.unique_identifier = None; + new_attrs.destroy_date = None; + new_attrs.compromise_date = None; + new_attrs.compromise_occurrence_date = None; + + // Remove any existing replacement/replaced links (from a previous rekey) + new_attrs.remove_link(LinkType::ReplacementObjectLink); + new_attrs.remove_link(LinkType::ReplacedObjectLink); + + // Set the ReplacedObjectLink on the new key pointing to the old key + new_attrs.set_link( + LinkType::ReplacedObjectLink, + LinkedObjectIdentifier::TextString(old_uid.to_owned()), + ); + + // Set dates per spec + new_attrs.initial_date = Some(dates.initialization_date); + new_attrs.last_change_date = Some(dates.initialization_date); + new_attrs.activation_date = dates.activation_date; + if dates.deactivation_date.is_some() { + new_attrs.deactivation_date = dates.deactivation_date; + } + + Ok(new_attrs) +} + +/// Update the old key's attributes after a rekey operation. +/// +/// Per KMIP 1.4 §4.4 Table 173 / §4.5 Table 177: +/// - Sets `ReplacementObjectLink` → new key +/// - Removes the Name attribute (transferred to the replacement) +/// - Updates Last Change Date to now +pub(crate) fn update_old_key_after_rekey(old_attrs: &mut Attributes, new_uid: &str) -> KResult<()> { + let now = time_normalize()?; + + old_attrs.set_link( + LinkType::ReplacementObjectLink, + LinkedObjectIdentifier::TextString(new_uid.to_owned()), + ); + + // Remove the Name from the old key (it's taken over by the new key) + old_attrs.name = None; + + // Update Last Change Date + old_attrs.last_change_date = Some(now); + + Ok(()) +} + +/// Set rotation metadata on the **new** key after a manual rekey. +/// +/// Per the auto-rotation spec (Manual rekey table): +/// - `rotate_generation` = old value + 1 +/// - `rotate_date` = now +/// - `rotate_interval` = 0 (manual rekey does not inherit the policy) +/// - `rotate_latest` = true (this is the newest key in the chain) +/// - `rotate_name` = None (cleared for manual rekey) +/// - `rotate_offset` = None (cleared for manual rekey) +pub(crate) fn set_rotation_metadata_on_new_key( + new_attrs: &mut Attributes, + old_attrs: &Attributes, +) -> KResult<()> { + new_attrs.rotate_generation = Some(old_attrs.rotate_generation.unwrap_or(0) + 1); + new_attrs.rotate_date = Some(time_normalize()?); + // Manual rekey: do not inherit the rotation policy — user must re-arm explicitly + new_attrs.rotate_interval = Some(0); + new_attrs.rotate_latest = Some(true); + new_attrs.rotate_name = None; + new_attrs.rotate_offset = None; + Ok(()) +} + +/// Clear rotation flags on the **old** key after a rekey. +/// +/// - `rotate_latest` = false (no longer the newest) +/// - `rotate_interval` = 0 (prevent the scheduler from picking it up again) +pub(crate) const fn clear_rotation_flags_on_old_key(old_attrs: &mut Attributes) { + old_attrs.rotate_latest = Some(false); + old_attrs.rotate_interval = Some(0); +} + +/// Enforce privileged-user restriction for rekey operations that create new keys. +/// +/// Both `ReKey` and `ReKeyKeyPair` create replacement keys, so the caller +/// must either have `Create` permission or be in the privileged users list. +pub(crate) async fn enforce_privileged_user( + kms: &KMS, + user: &str, + privileged_users: &Option>, +) -> KResult<()> { + if let Some(users) = privileged_users { + let has_permission = user_has_permission(user, None, &KmipOperation::Create, kms).await?; + + if !has_permission && !users.iter().any(|u| u == user) { + kms_bail!(KmsError::Unauthorized( + "User does not have create access-right.".to_owned() + )) + } + } + Ok(()) +} + +/// Validate that request attributes do not attempt to change cryptographic parameters. +/// +/// Per KMIP §4.4 / §4.5, a rekey operation must preserve the algorithm, curve, +/// and key length of the original key. Changing these requires a new `Create` or +/// `CreateKeyPair` operation instead. +/// +/// The `attrs_iter` yields each `Option<&Attributes>` from the request (one for +/// symmetric `ReKey`, up to three for `ReKeyKeyPair`). +pub(crate) fn validate_no_crypto_param_change<'a>( + existing_attrs: &Attributes, + attrs_iter: impl IntoIterator>, + operation_name: &str, +) -> KResult<()> { + for req_attrs in attrs_iter.into_iter().flatten() { + if let Some(algo) = req_attrs.cryptographic_algorithm { + if existing_attrs.cryptographic_algorithm != Some(algo) { + kms_bail!(KmsError::InvalidRequest(format!( + "{operation_name}: changing the cryptographic algorithm is not allowed. \ + Use Create/CreateKeyPair for a different algorithm." + ))) + } + } + if let Some(ref cdp) = req_attrs.cryptographic_domain_parameters { + if let Some(ref existing_cdp) = existing_attrs.cryptographic_domain_parameters { + if cdp.recommended_curve.is_some() + && cdp.recommended_curve != existing_cdp.recommended_curve + { + kms_bail!(KmsError::InvalidRequest(format!( + "{operation_name}: changing the recommended curve is not allowed. \ + Use Create/CreateKeyPair for a different curve." + ))) + } + } + } + if let Some(len) = req_attrs.cryptographic_length { + if existing_attrs.cryptographic_length.is_some() + && existing_attrs.cryptographic_length != Some(len) + { + kms_bail!(KmsError::InvalidRequest(format!( + "{operation_name}: changing the cryptographic length is not allowed. \ + Use Create/CreateKeyPair for a different key size." + ))) + } + } + } + Ok(()) +} + +// ─── Phase 2: Finalize rekey (retire old keys + rewrap dependants) ─────────── + +/// Describes one old key being retired as part of a rekey operation. +/// +/// Used by [`finalize_rekey`] to batch-retire multiple keys (e.g., both the +/// private key and public key in a key pair rekey) in a single atomic commit. +pub(crate) struct KeyRetirement<'a> { + /// The old key's metadata (object + attributes). + pub old_owm: &'a ObjectWithMetadata, + /// The UID of the new replacement key. + pub new_uid: &'a str, + /// If `Some`, all keys that were wrapped by this old key will be re-wrapped + /// using the key at this UID. Typically this is the same as `new_uid` for + /// symmetric keys and the new public key UID for key pairs. + /// `None` means no dependant re-wrapping for this slot (e.g., private keys + /// are never used as wrapping keys). + pub rewrap_to: Option<&'a str>, +} + +/// Phase 2 of a rekey operation: retire old keys, re-wrap dependants, and commit atomically. +/// +/// This function: +/// 1. For each [`KeyRetirement`] slot, retires the old key (sets `ReplacementObjectLink`, +/// clears rotation flags, updates the embedded attributes). +/// 2. For each slot with `rewrap_to = Some(new_wrapping_uid)`, finds all keys wrapped +/// by the old key and re-wraps them with the new wrapping key. +/// 3. Commits all resulting updates in a single atomic database transaction. +pub(crate) async fn finalize_rekey( + kms: &KMS, + owner: &str, + retirements: &[KeyRetirement<'_>], +) -> KResult<()> { + let mut operations: Vec = Vec::new(); + + for retirement in retirements { + let (old_object, old_attributes) = retire_old_key(retirement.old_owm, retirement.new_uid)?; + + operations.push(AtomicOperation::UpdateObject(( + retirement.old_owm.id().to_owned(), + old_object, + old_attributes, + None, + ))); + + if let Some(new_wrapping_uid) = retirement.rewrap_to { + Box::pin(rewrap_dependants( + kms, + owner, + retirement.old_owm.id(), + new_wrapping_uid, + &mut operations, + )) + .await?; + } + } + + kms.database.atomic(owner, &operations).await?; + Ok(()) +} + +/// Set up a newly generated key with replacement attributes and links. +/// +/// Applies the `ReplacedObjectLink` pointing to the old UID, an optional +/// paired-key cross-link, and the Name from the replacement attributes. +/// Then runs [`setup_object_lifecycle`] to set state / dates / digest. +pub(crate) fn setup_new_key( + key_object: &mut Object, + replacement_attrs: &Attributes, + object_type: ObjectType, + old_uid: &str, + paired_key: Option<(&str, LinkType)>, +) -> KResult { + if let Ok(key_attrs) = key_object.attributes_mut() { + key_attrs.name.clone_from(&replacement_attrs.name); + key_attrs.set_link( + LinkType::ReplacedObjectLink, + LinkedObjectIdentifier::TextString(old_uid.to_owned()), + ); + if let Some((paired_uid, link_type)) = paired_key { + key_attrs.set_link( + link_type, + LinkedObjectIdentifier::TextString(paired_uid.to_owned()), + ); + } + } + + setup_object_lifecycle(key_object, object_type, replacement_attrs.activation_date) +} + +/// Apply replacement attributes, lifecycle setup, and tag extraction to one key slot. +/// +/// Combines [`setup_new_key`] + attribute/tag extraction into a single call to +/// avoid repetition when processing both the SK and PK in `prepare_attributes`. +pub(crate) fn finalize_replacement_key( + replacement: &mut ReplacementObject, + new_attrs: &Attributes, + object_type: ObjectType, + old_uid: &str, + paired_key: Option<(&str, LinkType)>, + vendor_id: &str, +) -> KResult<()> { + setup_new_key( + &mut replacement.object, + new_attrs, + object_type, + old_uid, + paired_key, + )?; + let attrs = replacement.object.attributes().cloned().unwrap_or_default(); + replacement.tags = attrs.get_tags(vendor_id); + replacement.attributes = attrs; + Ok(()) +} + +/// Compute a fresh UID for a rotation replacement key. +/// +/// - Pure UUID → fresh UUID (e.g. `"abc-…"` → `"def-…"`) +/// - User name → `"_"` (e.g. `"toto"` → `"toto_def-…"`) +/// - Already-prefixed → strip old UUID suffix, re-use prefix +/// (e.g. `"toto_abc-…"` → `"toto_def-…"`) +pub(crate) fn compute_rotation_uid(old_uid: &str) -> String { + if Uuid::parse_str(old_uid).is_ok() { + Uuid::new_v4().to_string() + } else { + let prefix = old_uid + .rsplit_once('_') + .filter(|(_, suffix)| Uuid::parse_str(suffix).is_ok()) + .map_or(old_uid, |(prefix, _)| prefix); + format!("{prefix}_{}", Uuid::new_v4()) + } +} + +// ─── Private helpers ───────────────────────────────────────────────────────── + +/// Prepare an old key (private, public, or symmetric) for replacement. +/// +/// Clones the object and attributes from the OWM, sets `ReplacementObjectLink` +/// pointing to the new key, and clears rotation flags so the scheduler won't +/// pick it up again. +fn retire_old_key( + owm: &ObjectWithMetadata, + new_uid: &str, +) -> KResult<( + cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_objects::Object, + Attributes, +)> { + let mut old_object = owm.object().clone(); + let mut old_attributes = owm.attributes().clone(); + update_old_key_after_rekey(&mut old_attributes, new_uid)?; + clear_rotation_flags_on_old_key(&mut old_attributes); + if let Ok(obj_attrs) = old_object.attributes_mut() { + update_old_key_after_rekey(obj_attrs, new_uid)?; + } + Ok((old_object, old_attributes)) +} + +/// Re-wrap all keys that were wrapped by the old wrapping key, pointing them +/// to the new wrapping key UID. +async fn rewrap_dependants( + kms: &KMS, + owner: &str, + old_uid: &str, + new_uid: &str, + operations: &mut Vec, +) -> KResult<()> { + let wrapped_dependants = kms + .database + .find_wrapped_by(old_uid, owner) + .await + .unwrap_or_default(); + + for (dep_uid, _dep_state, dep_attrs) in wrapped_dependants { + let Some(dep_owm) = kms.database.retrieve_object(&dep_uid).await? else { + warn!("wrapped dependant {dep_uid} not found, skipping"); + continue; + }; + let mut dep_object = dep_owm.object().clone(); + + if let Some(op) = + rewrap_single_dependant(kms, owner, &dep_uid, &mut dep_object, dep_attrs, new_uid) + .await? + { + operations.push(op); + } + } + Ok(()) +} + +/// Unwrap and re-wrap a single dependant object with the new wrapping key. +/// +/// Returns `Some(AtomicOperation)` if the re-wrap succeeded, `None` if skipped. +async fn rewrap_single_dependant( + kms: &KMS, + owner: &str, + dep_uid: &str, + dep_object: &mut Object, + mut dep_attrs: Attributes, + new_uid: &str, +) -> KResult> { + let dep_wrap_spec = dep_object + .key_block() + .ok() + .and_then(|kb| kb.key_wrapping_data.as_ref()) + .map(|kwd| KeyWrappingSpecification { + wrapping_method: kwd.wrapping_method, + encryption_key_information: Some(EncryptionKeyInformation { + unique_identifier: UniqueIdentifier::TextString(new_uid.to_owned()), + cryptographic_parameters: kwd + .encryption_key_information + .as_ref() + .and_then(|e| e.cryptographic_parameters.clone()), + }), + mac_or_signature_key_information: kwd.mac_signature_key_information.clone().map(|m| { + cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::MacSignatureKeyInformation { + unique_identifier: UniqueIdentifier::TextString(new_uid.to_owned()), + cryptographic_parameters: m.cryptographic_parameters, + } + }), + attribute_name: None, + encoding_option: kwd.encoding_option, + }); + + let Some(spec) = dep_wrap_spec else { + return Ok(None); + }; + + if let Err(e) = unwrap_object(dep_object, kms, owner).await { + warn!("failed to unwrap dependant {dep_uid}: {e}, skipping"); + return Ok(None); + } + if let Err(e) = crate::core::wrapping::wrap_object(dep_object, &spec, kms, owner).await { + warn!("failed to re-wrap dependant {dep_uid} with new key: {e}, skipping"); + return Ok(None); + } + + dep_attrs.set_link( + LinkType::WrappingKeyLink, + LinkedObjectIdentifier::TextString(new_uid.to_owned()), + ); + dep_attrs.set_wrapping_key_id(kms.vendor_id(), new_uid); + + Ok(Some(AtomicOperation::UpdateObject(( + dep_uid.to_owned(), + dep_object.clone(), + dep_attrs, + None, + )))) +} diff --git a/crate/server/src/core/operations/rekey/keypair.rs b/crate/server/src/core/operations/rekey/keypair.rs new file mode 100644 index 0000000000..2ae718a673 --- /dev/null +++ b/crate/server/src/core/operations/rekey/keypair.rs @@ -0,0 +1,453 @@ +use std::collections::HashSet; + +#[cfg(feature = "non-fips")] +use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::CryptographicAlgorithm; +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::ErrorReason, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attributes, + kmip_objects::ObjectType, + kmip_operations::{CreateKeyPair, ReKeyKeyPair, ReKeyKeyPairResponse}, + kmip_types::{KeyFormatType, LinkType, UniqueIdentifier}, + }, +}; +#[cfg(feature = "non-fips")] +use cosmian_kms_server_database::reexport::cosmian_kms_crypto::{ + crypto::cover_crypt::attributes::rekey_edit_action_from_attributes, + reexport::cosmian_cover_crypt::api::Covercrypt, +}; +use cosmian_logger::trace; + +use super::common::{ + RekeyOperation, ReplacementObject, RotationCandidate, compute_rotation_uid, + enforce_privileged_user, execute_rekey, finalize_replacement_key, + prepare_replacement_attributes, preserve_wrapping_key_link, retrieve_eligible_keys, + set_rotation_metadata_on_new_key, validate_no_crypto_param_change, +}; +#[cfg(feature = "non-fips")] +use crate::core::cover_crypt::rekey_keypair_cover_crypt; +use crate::{ + core::{ + KMS, + operations::{create_key_pair::generate_key_pair, key_ops::ObjectWithMetadataOps}, + }, + error::KmsError, + kms_bail, + result::{KResult, KResultHelper}, +}; + +/// Implementor of [`RekeyOperation`] for KMIP `ReKeyKeyPair` (§4.5) on asymmetric key pairs. +struct KeypairRekey { + /// The `offset` from the `ReKeyKeyPair` request (date arithmetic per KMIP Table 176). + offset: Option, +} + +/// KMIP `ReKeyKeyPair` operation for asymmetric key pairs. +/// +/// Per KMIP 1.4 §4.5: +/// - Creates a replacement key pair with new Unique Identifiers. +/// - Sets `ReplacementObjectLink` on both old private and public keys. +/// - Sets `ReplacedObjectLink` on both new private and public keys. +/// - The replacement keys take over the Name attributes of the existing keys. +/// - The existing keys' State is NOT changed. +/// - If `offset` is provided, date arithmetic per Table 176 is applied. +/// - Rotation metadata is set on both old and new keys. +/// +/// For Covercrypt keys (non-FIPS only), delegates to the existing in-place +/// attribute-level rekey which mutates the key material without creating new UIDs. +pub(crate) async fn rekey_keypair( + kms: &KMS, + request: ReKeyKeyPair, + user: &str, + privileged_users: Option>, +) -> KResult { + trace!("ReKeyKeyPair: {}", serde_json::to_string(&request)?); + + // Covercrypt early-return: uses a completely different code path (in-place attribute rekey) + // that doesn't fit the rotation trait pattern. + #[cfg(feature = "non-fips")] + if let Some(response) = + try_covercrypt_rekey(kms, &request, user, privileged_users.clone()).await? + { + return Ok(response); + } + + execute_rekey( + &KeypairRekey { + offset: request.offset, + }, + kms, + &request, + user, + &privileged_users, + ) + .await +} + +/// Attempt Covercrypt-specific rekey. Returns `Some(response)` if handled, `None` otherwise. +#[cfg(feature = "non-fips")] +async fn try_covercrypt_rekey( + kms: &KMS, + request: &ReKeyKeyPair, + user: &str, + privileged_users: Option>, +) -> KResult> { + let uid_or_tags = request + .private_key_unique_identifier + .as_ref() + .ok_or(KmsError::UnsupportedPlaceholder)? + .as_str() + .context("ReKeyKeyPair: the private key unique identifier must be a string")?; + + for owm in retrieve_eligible_keys(kms, uid_or_tags, ObjectType::PrivateKey).await? { + let key_format_type = owm.attributes().key_format_type.or_else(|| { + owm.object() + .attributes() + .ok() + .and_then(|a| a.key_format_type) + }); + + if key_format_type == Some(KeyFormatType::CoverCryptSecretKey) { + let attributes = request.private_key_attributes.as_ref().ok_or_else(|| { + KmsError::InvalidRequest( + "ReKeyKeyPair: the private key attributes must be supplied for Covercrypt" + .to_owned(), + ) + })?; + if Some(CryptographicAlgorithm::CoverCrypt) == attributes.cryptographic_algorithm { + let action = rekey_edit_action_from_attributes(kms.vendor_id(), attributes)?; + let response = Box::pin(rekey_keypair_cover_crypt( + kms, + Covercrypt::default(), + owm.id().to_owned(), + user, + action, + owm.attributes().sensitive.unwrap_or(false), + privileged_users, + )) + .await + .context("ReKeyKeyPair: Covercrypt rekey failed")?; + return Ok(Some(response)); + } + } + } + Ok(None) +} + +impl RekeyOperation for KeypairRekey { + type Request = ReKeyKeyPair; + type Response = ReKeyKeyPairResponse; + + async fn validate( + &self, + kms: &KMS, + request: &ReKeyKeyPair, + user: &str, + privileged: &Option>, + ) -> KResult> { + if request.common_protection_storage_masks.is_some() + || request.private_protection_storage_masks.is_some() + || request.public_protection_storage_masks.is_some() + { + kms_bail!(KmsError::UnsupportedPlaceholder) + } + + enforce_privileged_user(kms, user, privileged).await?; + + let uid_or_tags = request + .private_key_unique_identifier + .as_ref() + .ok_or(KmsError::UnsupportedPlaceholder)? + .as_str() + .context("ReKeyKeyPair: the private key unique identifier must be a string")?; + + for owm in retrieve_eligible_keys(kms, uid_or_tags, ObjectType::PrivateKey).await? { + if !owm + .user_can_perform_operation(user, &KmipOperation::Rekey, kms) + .await? + { + continue; + } + + // Skip Covercrypt keys (handled separately before trait dispatch) + let key_format_type = owm.attributes().key_format_type.or_else(|| { + owm.object() + .attributes() + .ok() + .and_then(|a| a.key_format_type) + }); + if key_format_type == Some(KeyFormatType::CoverCryptSecretKey) { + continue; + } + + // Validate no crypto param changes + validate_no_crypto_param_change( + owm.attributes(), + [ + request.common_attributes.as_ref(), + request.private_key_attributes.as_ref(), + request.public_key_attributes.as_ref(), + ], + "ReKeyKeyPair", + )?; + + // Resolve paired public key + let old_sk_uid = owm.id().to_owned(); + let old_pk_uid = resolve_public_key_uid(&owm)?; + let old_pk_owm = retrieve_linked_public_key(kms, &old_pk_uid).await?; + + return Ok(vec![ + RotationCandidate { + uid: old_sk_uid, + object_type: ObjectType::PrivateKey, + owm, + }, + RotationCandidate { + uid: old_pk_uid, + object_type: ObjectType::PublicKey, + owm: old_pk_owm, + }, + ]); + } + + Err(KmsError::Kmip21Error( + ErrorReason::Item_Not_Found, + uid_or_tags.to_owned(), + )) + } + + async fn generate_replacement( + &self, + kms: &KMS, + candidates: &[RotationCandidate], + ) -> KResult> { + let sk_candidate = candidates + .first() + .ok_or_else(|| KmsError::InvalidRequest("missing private key candidate".to_owned()))?; + let pk_candidate = candidates + .get(1) + .ok_or_else(|| KmsError::InvalidRequest("missing public key candidate".to_owned()))?; + + let common_attrs = + build_generation_attributes(sk_candidate.owm.attributes(), kms.vendor_id()); + let new_sk_uid = compute_rotation_uid(&sk_candidate.uid); + let new_pk_uid = compute_rotation_uid(&pk_candidate.uid); + + let create_kp_request = CreateKeyPair { + common_attributes: Some(common_attrs), + private_key_attributes: None, + public_key_attributes: None, + common_protection_storage_masks: None, + private_protection_storage_masks: None, + public_protection_storage_masks: None, + }; + + let key_pair = + generate_key_pair(kms.vendor_id(), create_kp_request, &new_sk_uid, &new_pk_uid)?; + + Ok(vec![ + ReplacementObject { + new_uid: new_sk_uid, + old_uid: sk_candidate.uid.clone(), + object: key_pair.private_key().to_owned(), + attributes: Attributes::default(), // filled in prepare_attributes + tags: HashSet::new(), // filled in prepare_attributes + rewrap_to: None, // private keys are not wrapping keys + }, + ReplacementObject { + new_uid: new_pk_uid, + old_uid: pk_candidate.uid.clone(), + object: key_pair.public_key().to_owned(), + attributes: Attributes::default(), // filled in prepare_attributes + tags: HashSet::new(), // filled in prepare_attributes + rewrap_to: None, // set in prepare_attributes + }, + ]) + } + + fn prepare_attributes( + &self, + kms: &KMS, + candidates: &[RotationCandidate], + replacements: &mut [ReplacementObject], + ) -> KResult<()> { + let (sk_candidate, pk_candidate) = extract_keypair_candidates(candidates)?; + + let new_sk_attributes = prepare_replacement_attributes( + sk_candidate.owm.attributes(), + &sk_candidate.uid, + self.offset, + )?; + let new_pk_attributes = prepare_replacement_attributes( + pk_candidate.owm.attributes(), + &pk_candidate.uid, + self.offset, + )?; + + if replacements.len() < 2 { + kms_bail!(KmsError::InvalidRequest( + "expected 2 replacements for key pair".to_owned() + )); + } + + let pk_new_uid = replacements + .get(1) + .ok_or_else(|| KmsError::InvalidRequest("missing PK replacement".to_owned()))? + .new_uid + .clone(); + let sk_new_uid = replacements + .first() + .ok_or_else(|| KmsError::InvalidRequest("missing SK replacement".to_owned()))? + .new_uid + .clone(); + + let sk_rep = replacements + .first_mut() + .ok_or_else(|| KmsError::InvalidRequest("missing SK replacement".to_owned()))?; + prepare_sk_replacement( + sk_rep, + &new_sk_attributes, + sk_candidate, + &pk_new_uid, + kms.vendor_id(), + )?; + set_rotation_metadata_on_new_key(&mut sk_rep.attributes, sk_candidate.owm.attributes())?; + + let pk_rep = replacements + .get_mut(1) + .ok_or_else(|| KmsError::InvalidRequest("missing PK replacement".to_owned()))?; + prepare_pk_replacement( + pk_rep, + &new_pk_attributes, + pk_candidate, + &sk_new_uid, + kms.vendor_id(), + )?; + + Ok(()) + } + + fn build_response(&self, replacements: &[ReplacementObject]) -> ReKeyKeyPairResponse { + ReKeyKeyPairResponse { + private_key_unique_identifier: UniqueIdentifier::TextString( + replacements + .first() + .map_or_else(String::new, |r| r.new_uid.clone()), + ), + public_key_unique_identifier: UniqueIdentifier::TextString( + replacements + .get(1) + .map_or_else(String::new, |r| r.new_uid.clone()), + ), + } + } +} + +// ─── Private helpers ───────────────────────────────────────────────────────── + +/// Extract the SK and PK candidates from the candidates slice, validating length. +fn extract_keypair_candidates( + candidates: &[RotationCandidate], +) -> KResult<(&RotationCandidate, &RotationCandidate)> { + let sk = candidates + .first() + .ok_or_else(|| KmsError::InvalidRequest("missing private key candidate".to_owned()))?; + let pk = candidates + .get(1) + .ok_or_else(|| KmsError::InvalidRequest("missing public key candidate".to_owned()))?; + Ok((sk, pk)) +} + +/// Finalize the private key replacement: lifecycle setup, cross-link, and wrapping key. +fn prepare_sk_replacement( + sk: &mut ReplacementObject, + new_attrs: &Attributes, + candidate: &RotationCandidate, + pk_new_uid: &str, + vendor_id: &str, +) -> KResult<()> { + finalize_replacement_key( + sk, + new_attrs, + ObjectType::PrivateKey, + &candidate.uid, + Some((pk_new_uid, LinkType::PublicKeyLink)), + vendor_id, + )?; + preserve_wrapping_key_link(candidate.owm.object(), &mut sk.attributes); + Ok(()) +} + +/// Finalize the public key replacement: lifecycle setup, cross-link, wrapping key, and `rewrap_to`. +fn prepare_pk_replacement( + pk: &mut ReplacementObject, + new_attrs: &Attributes, + candidate: &RotationCandidate, + sk_new_uid: &str, + vendor_id: &str, +) -> KResult<()> { + finalize_replacement_key( + pk, + new_attrs, + ObjectType::PublicKey, + &candidate.uid, + Some((sk_new_uid, LinkType::PrivateKeyLink)), + vendor_id, + )?; + preserve_wrapping_key_link(candidate.owm.object(), &mut pk.attributes); + // Public key IS a wrapping key — dependants get re-wrapped to it + pk.rewrap_to = Some(pk.new_uid.clone()); + Ok(()) +} + +/// Follow `PublicKeyLink` on the private key to resolve the paired public key UID. +fn resolve_public_key_uid( + owm: &cosmian_kms_server_database::reexport::cosmian_kms_interfaces::ObjectWithMetadata, +) -> KResult { + owm.attributes() + .get_link(LinkType::PublicKeyLink) + .map(|l| l.to_string()) + .ok_or_else(|| { + KmsError::InvalidRequest( + "ReKeyKeyPair: the private key has no PublicKeyLink. Cannot determine the \ + paired public key." + .to_owned(), + ) + }) +} + +/// Retrieve the linked public key from the database. +async fn retrieve_linked_public_key( + kms: &KMS, + pk_uid: &str, +) -> KResult { + kms.database + .retrieve_objects(pk_uid) + .await? + .into_values() + .next() + .ok_or_else(|| { + KmsError::Kmip21Error( + ErrorReason::Item_Not_Found, + format!("ReKeyKeyPair: linked public key '{pk_uid}' not found in database"), + ) + }) +} + +/// Build clean attributes for key pair generation. +fn build_generation_attributes(old_attrs: &Attributes, vendor_id: &str) -> Attributes { + let mut attrs = old_attrs.clone(); + attrs.unique_identifier = None; + attrs.link = None; + attrs.name = None; + attrs.initial_date = None; + attrs.last_change_date = None; + attrs.activation_date = None; + attrs.deactivation_date = None; + attrs.destroy_date = None; + attrs.compromise_date = None; + attrs.compromise_occurrence_date = None; + attrs.remove_vendor_attribute(vendor_id, "tag"); + attrs +} diff --git a/crate/server/src/core/operations/rekey/mod.rs b/crate/server/src/core/operations/rekey/mod.rs new file mode 100644 index 0000000000..f76dc9d3d5 --- /dev/null +++ b/crate/server/src/core/operations/rekey/mod.rs @@ -0,0 +1,19 @@ +//! KMIP key rotation operations: `ReKey` (§4.4), `ReKeyKeyPair` (§4.5). +//! +//! Submodules: +//! - [`common`] — Shared helpers for date arithmetic, attribute preparation, +//! rotation metadata, and privileged-user enforcement. +//! - [`symmetric`] — `ReKey` for symmetric keys (plain, wrapped, wrapping keys). +//! - [`keypair`] — `ReKeyKeyPair` for asymmetric key pairs (RSA, EC, PQC, Covercrypt). + +mod common; +mod keypair; +mod symmetric; + +pub(crate) use common::{ + RekeyOperation, ReplacementObject, RotationCandidate, compute_rotation_uid, + enforce_privileged_user, execute_rekey, prepare_replacement_attributes, + set_rotation_metadata_on_new_key, update_old_key_after_rekey, +}; +pub(crate) use keypair::rekey_keypair; +pub(crate) use symmetric::rekey; diff --git a/crate/server/src/core/operations/rekey/symmetric.rs b/crate/server/src/core/operations/rekey/symmetric.rs new file mode 100644 index 0000000000..df55a5f0bb --- /dev/null +++ b/crate/server/src/core/operations/rekey/symmetric.rs @@ -0,0 +1,194 @@ +use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::{ + KmipOperation, + kmip_attributes::Attributes, + kmip_objects::ObjectType, + kmip_operations::{Create, ReKey, ReKeyResponse}, + kmip_types::UniqueIdentifier, +}; +use cosmian_logger::trace; + +use super::common::{ + RekeyOperation, ReplacementObject, RotationCandidate, compute_rotation_uid, + enforce_privileged_user, execute_rekey, finalize_replacement_key, + prepare_replacement_attributes, preserve_wrapping_key_link, retrieve_eligible_keys, + set_rotation_metadata_on_new_key, validate_no_crypto_param_change, +}; +use crate::{ + core::{KMS, operations::key_ops::ObjectWithMetadataOps}, + error::KmsError, + kms_bail, + result::{KResult, KResultHelper}, +}; + +/// Implementor of [`RekeyOperation`] for KMIP `ReKey` (§4.4) on symmetric keys. +pub(crate) struct SymmetricRekey { + /// The `offset` from the `ReKey` request (date arithmetic per KMIP Table 172). + offset: Option, +} + +/// KMIP `ReKey` operation for symmetric keys. +/// +/// Per KMIP 1.4 §4.4: +/// - Generates fresh key material with the same algorithm and length. +/// - Assigns a new UID (preserving user-facing name prefixes across rotations). +/// - Handles wrapped keys: unwraps → generates → re-wraps with same wrapping key. +/// - Phase-1/Phase-2 commit for wrapping keys: re-wraps all dependant keys. +/// - Sets rotation metadata on both old and new keys. +pub(crate) async fn rekey( + kms: &KMS, + request: ReKey, + owner: &str, + privileged_users: Option>, +) -> KResult { + trace!("ReKey: {}", serde_json::to_string(&request)?); + let offset = request.offset; + execute_rekey( + &SymmetricRekey { offset }, + kms, + &request, + owner, + &privileged_users, + ) + .await +} + +impl RekeyOperation for SymmetricRekey { + type Request = ReKey; + type Response = ReKeyResponse; + + async fn validate( + &self, + kms: &KMS, + request: &ReKey, + user: &str, + privileged: &Option>, + ) -> KResult> { + if request.protection_storage_masks.is_some() { + kms_bail!(KmsError::UnsupportedPlaceholder) + } + + enforce_privileged_user(kms, user, privileged).await?; + + let uid_or_tags = request + .unique_identifier + .as_ref() + .ok_or(KmsError::UnsupportedPlaceholder)? + .as_str() + .context("Rekey: the symmetric key unique identifier must be a string")?; + + for owm in retrieve_eligible_keys(kms, uid_or_tags, ObjectType::SymmetricKey).await? { + if !owm + .user_can_perform_operation(user, &KmipOperation::Rekey, kms) + .await? + { + continue; + } + + // Reject requests that attempt to change crypto parameters + validate_no_crypto_param_change( + owm.attributes(), + [request.attributes.as_ref()], + "ReKey", + )?; + + let uid = owm.id().to_owned(); + return Ok(vec![RotationCandidate { + owm, + uid, + object_type: ObjectType::SymmetricKey, + }]); + } + + Err(KmsError::InvalidRequest(format!( + "rekey: no active symmetric key found for uid/tags: {uid_or_tags}", + ))) + } + + async fn generate_replacement( + &self, + kms: &KMS, + candidates: &[RotationCandidate], + ) -> KResult> { + let candidate = candidates + .first() + .ok_or_else(|| KmsError::InvalidRequest("no rotation candidate".to_owned()))?; + + // Clean attributes for generation + let mut gen_attrs = candidate.owm.attributes().to_owned(); + gen_attrs.unique_identifier = None; + gen_attrs.key_format_type = None; + gen_attrs.link = None; + gen_attrs.rotate_interval = None; + gen_attrs.rotate_name = None; + gen_attrs.rotate_offset = None; + + let create_request = Create { + object_type: ObjectType::SymmetricKey, + attributes: gen_attrs, + protection_storage_masks: None, + }; + let (_, new_object, new_tags) = + KMS::create_symmetric_key_and_tags(kms.vendor_id(), &create_request)?; + + let new_uid = compute_rotation_uid(&candidate.uid); + + Ok(vec![ReplacementObject { + new_uid, + old_uid: candidate.uid.clone(), + object: new_object, + attributes: Attributes::default(), // filled in prepare_attributes + tags: new_tags, + rewrap_to: Some(candidate.uid.clone()), // placeholder, replaced in prepare_attributes + }]) + } + + fn prepare_attributes( + &self, + kms: &KMS, + candidates: &[RotationCandidate], + replacements: &mut [ReplacementObject], + ) -> KResult<()> { + let candidate = candidates + .first() + .ok_or_else(|| KmsError::InvalidRequest("no rotation candidate".to_owned()))?; + let replacement = replacements + .first_mut() + .ok_or_else(|| KmsError::InvalidRequest("no replacement object".to_owned()))?; + + let new_attrs = prepare_replacement_attributes( + candidate.owm.attributes(), + &candidate.uid, + self.offset, + )?; + + finalize_replacement_key( + replacement, + &new_attrs, + ObjectType::SymmetricKey, + &candidate.uid, + None, + kms.vendor_id(), + )?; + + // Preserve WrappingKeyLink if the old key was wrapped + preserve_wrapping_key_link(candidate.owm.object(), &mut replacement.attributes); + + // Set rotation metadata + set_rotation_metadata_on_new_key(&mut replacement.attributes, candidate.owm.attributes())?; + + // Rewrap dependants to the NEW key + replacement.rewrap_to = Some(replacement.new_uid.clone()); + + Ok(()) + } + + fn build_response(&self, replacements: &[ReplacementObject]) -> ReKeyResponse { + ReKeyResponse { + unique_identifier: UniqueIdentifier::TextString( + replacements + .first() + .map_or_else(String::new, |r| r.new_uid.clone()), + ), + } + } +} diff --git a/crate/server/src/core/operations/rekey_common.rs b/crate/server/src/core/operations/rekey_common.rs deleted file mode 100644 index 5192b4f28b..0000000000 --- a/crate/server/src/core/operations/rekey_common.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! Shared logic for KMIP `ReKey` (§4.4) and `ReKeyKeyPair` (§4.5) operations. -//! -//! Both operations follow the same pattern: -//! - The replacement key inherits the Name attribute from the existing key. -//! - Bidirectional links are established (`ReplacementObjectLink` / `ReplacedObjectLink`). -//! - Date arithmetic is applied when an `offset` is provided. -//! - Initial Date and Last Change Date are set to the current time. - -use cosmian_kms_server_database::reexport::cosmian_kmip::{ - kmip_2_1::{ - kmip_attributes::Attributes, - kmip_types::{LinkType, LinkedObjectIdentifier}, - }, - time_normalize, -}; -use time::OffsetDateTime; - -use crate::result::KResult; - -/// Dates computed for a replacement key based on the existing key's dates and an optional offset. -/// -/// Per KMIP 1.4 Tables 172/176: -/// - `activation = initialization + offset` (if offset provided) -/// - `deactivation = old_deactivation + (new_activation - old_activation)` (if both exist) -#[allow(clippy::struct_field_names)] -pub(crate) struct ReplacementDates { - pub initialization_date: OffsetDateTime, - pub activation_date: Option, - pub deactivation_date: Option, -} - -/// Compute the replacement key's dates from the existing key's attributes and an optional offset. -/// -/// KMIP 1.4 §4.4 Table 172 / §4.5 Table 176: -/// - Initialization Date (IT₂) = now (always > IT₁) -/// - Activation Date (AT₂) = IT₂ + Offset (if offset provided), else IT₂ (immediate activation) -/// - Deactivation Date = DT₁ + (AT₂ - AT₁) (if both DT₁ and AT₁ exist) -pub(crate) fn compute_replacement_dates( - old_attrs: &Attributes, - offset: Option, -) -> KResult { - let now = time_normalize()?; - - let activation_date = - Some(offset.map_or(now, |secs| now + time::Duration::seconds(i64::from(secs)))); - - let deactivation_date = match (old_attrs.deactivation_date, old_attrs.activation_date) { - (Some(old_deactivation), Some(old_activation)) => { - // DT₂ = DT₁ + (AT₂ - AT₁) - activation_date.map(|new_activation| { - let shift = new_activation - old_activation; - old_deactivation + shift - }) - } - _ => None, - }; - - Ok(ReplacementDates { - initialization_date: now, - activation_date, - deactivation_date, - }) -} - -/// Prepare attributes for a replacement key, following KMIP 1.4 §4.4 Table 173 / §4.5 Table 177. -/// -/// This function: -/// - Copies attributes from the existing key -/// - Removes stale unique identifier and links -/// - Sets `ReplacedObjectLink` → old key -/// - Transfers the Name from old key (already in the cloned attributes) -/// - Sets Initial Date, Last Change Date to now -/// - Applies offset-based date arithmetic -/// - Clears fields that must not be carried over (`destroy_date`, compromise dates, revocation) -pub(crate) fn prepare_replacement_attributes( - old_attrs: &Attributes, - old_uid: &str, - offset: Option, -) -> KResult { - let dates = compute_replacement_dates(old_attrs, offset)?; - - let mut new_attrs = old_attrs.clone(); - - // Clear fields that must not be set on the replacement key - new_attrs.unique_identifier = None; - new_attrs.destroy_date = None; - new_attrs.compromise_date = None; - new_attrs.compromise_occurrence_date = None; - // Revocation reason is stored in state, not attributes directly - - // Remove any existing replacement/replaced links (from a previous rekey) - new_attrs.remove_link(LinkType::ReplacementObjectLink); - new_attrs.remove_link(LinkType::ReplacedObjectLink); - - // Set the ReplacedObjectLink on the new key pointing to the old key - new_attrs.set_link( - LinkType::ReplacedObjectLink, - LinkedObjectIdentifier::TextString(old_uid.to_owned()), - ); - - // Set dates per spec - new_attrs.initial_date = Some(dates.initialization_date); - new_attrs.last_change_date = Some(dates.initialization_date); - new_attrs.activation_date = dates.activation_date; - if dates.deactivation_date.is_some() { - new_attrs.deactivation_date = dates.deactivation_date; - } - - Ok(new_attrs) -} - -/// Update the old key's attributes after a rekey operation. -/// -/// Per KMIP 1.4 §4.4 Table 173 / §4.5 Table 177: -/// - Sets `ReplacementObjectLink` → new key -/// - Removes the Name attribute (transferred to the replacement) -/// - Updates Last Change Date to now -pub(crate) fn update_old_key_after_rekey(old_attrs: &mut Attributes, new_uid: &str) -> KResult<()> { - let now = time_normalize()?; - - // Set the ReplacementObjectLink on the old key pointing to the new key - old_attrs.set_link( - LinkType::ReplacementObjectLink, - LinkedObjectIdentifier::TextString(new_uid.to_owned()), - ); - - // Remove the Name from the old key (it's taken over by the new key) - old_attrs.name = None; - - // Update Last Change Date - old_attrs.last_change_date = Some(now); - - Ok(()) -} diff --git a/crate/server/src/core/operations/rekey_keypair.rs b/crate/server/src/core/operations/rekey_keypair.rs deleted file mode 100644 index 558f470f73..0000000000 --- a/crate/server/src/core/operations/rekey_keypair.rs +++ /dev/null @@ -1,427 +0,0 @@ -#[cfg(feature = "non-fips")] -use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::CryptographicAlgorithm; -#[cfg(feature = "non-fips")] -use cosmian_kms_server_database::reexport::cosmian_kms_crypto::{ - crypto::cover_crypt::attributes::rekey_edit_action_from_attributes, - reexport::cosmian_cover_crypt::api::Covercrypt, -}; -use cosmian_kms_server_database::reexport::{ - cosmian_kmip::{ - kmip_0::kmip_types::{ErrorReason, State}, - kmip_2_1::{ - KmipOperation, - kmip_objects::ObjectType, - kmip_operations::{CreateKeyPair, ReKeyKeyPair, ReKeyKeyPairResponse}, - kmip_types::{KeyFormatType, LinkType, LinkedObjectIdentifier, UniqueIdentifier}, - }, - }, - cosmian_kms_interfaces::AtomicOperation, -}; -use cosmian_logger::{info, trace}; -use uuid::Uuid; - -#[cfg(feature = "non-fips")] -use crate::core::cover_crypt::rekey_keypair_cover_crypt; -use crate::{ - core::{ - KMS, - operations::{ - create_key_pair::generate_key_pair, - key_ops::{ObjectWithMetadataOps, setup_object_lifecycle}, - rekey_common::{prepare_replacement_attributes, update_old_key_after_rekey}, - }, - retrieve_object_utils::user_has_permission, - wrapping::wrap_and_cache, - }, - error::KmsError, - kms_bail, - result::{KResult, KResultHelper}, -}; - -/// KMIP `ReKeyKeyPair` operation for asymmetric key pairs. -/// -/// Per KMIP 1.4 §4.5: -/// - Creates a replacement key pair with new Unique Identifiers. -/// - Sets `ReplacementObjectLink` on both old private and public keys. -/// - Sets `ReplacedObjectLink` on both new private and public keys. -/// - The replacement keys take over the Name attributes of the existing keys. -/// - The existing keys' State is NOT changed. -/// - If `offset` is provided, date arithmetic per Table 176 is applied. -/// -/// For Covercrypt keys (non-FIPS only), delegates to the existing in-place -/// attribute-level rekey which mutates the key material without creating new UIDs. -pub(crate) async fn rekey_keypair( - kms: &KMS, - request: ReKeyKeyPair, - user: &str, - - privileged_users: Option>, -) -> KResult { - trace!("ReKeyKeyPair: {}", serde_json::to_string(&request)?); - - if request.common_protection_storage_masks.is_some() - || request.private_protection_storage_masks.is_some() - || request.public_protection_storage_masks.is_some() - { - kms_bail!(KmsError::UnsupportedPlaceholder) - } - - // ReKeyKeyPair creates a replacement key pair — enforce privileged-user restriction - if let Some(ref users) = privileged_users { - let has_permission = user_has_permission(user, None, &KmipOperation::Create, kms).await?; - - if !has_permission && !users.iter().any(|u| u == user) { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right.".to_owned() - )) - } - } - - // there must be an identifier - let uid_or_tags = request - .private_key_unique_identifier - .as_ref() - .ok_or(KmsError::UnsupportedPlaceholder)? - .as_str() - .context("ReKeyKeyPair: the private key unique identifier must be a string")?; - - let offset = request.offset; - - // retrieve from tags or use passed identifier - let owm_s = kms - .database - .retrieve_objects(uid_or_tags) - .await? - .into_values(); - - for owm in owm_s { - // Only Active or PreActive objects are eligible for rekey - if owm.state() != State::Active && owm.state() != State::PreActive { - continue; - } - - if owm.object().object_type() != ObjectType::PrivateKey { - continue; - } - - // Verify the caller is allowed to rekey this key pair - if !owm - .user_can_perform_operation(user, &KmipOperation::Rekey, kms) - .await? - { - continue; - } - - // Dispatch based on the existing key's format type - let key_format_type = owm.attributes().key_format_type.or_else(|| { - owm.object() - .attributes() - .ok() - .and_then(|a| a.key_format_type) - }); - - // Covercrypt special case (non-FIPS only) - #[cfg(feature = "non-fips")] - if key_format_type == Some(KeyFormatType::CoverCryptSecretKey) { - let attributes = request.private_key_attributes.as_ref().ok_or_else(|| { - KmsError::InvalidRequest( - "ReKeyKeyPair: the private key attributes must be supplied for Covercrypt" - .to_owned(), - ) - })?; - if Some(CryptographicAlgorithm::CoverCrypt) == attributes.cryptographic_algorithm { - let action = rekey_edit_action_from_attributes(kms.vendor_id(), attributes)?; - return Box::pin(rekey_keypair_cover_crypt( - kms, - Covercrypt::default(), - owm.id().to_owned(), - user, - action, - owm.attributes().sensitive.unwrap_or(false), - privileged_users, - )) - .await - .context("ReKeyKeyPair: Covercrypt rekey failed"); - } - } - - // Skip Covercrypt keys in FIPS mode - #[cfg(not(feature = "non-fips"))] - if key_format_type == Some(KeyFormatType::CoverCryptSecretKey) { - continue; - } - - // ── General asymmetric key pair rekey (RSA, EC, PQC) ── - - // Reject wrapped keys - if owm.object().key_wrapping_data().is_some() { - kms_bail!(KmsError::InconsistentOperation( - "The server cannot rekey: the private key is wrapped. Unwrap it first.".to_owned() - )) - } - - let old_sk_uid = owm.id().to_owned(); - - // Follow PublicKeyLink to find the paired public key - let old_pk_uid = owm - .attributes() - .get_link(LinkType::PublicKeyLink) - .ok_or_else(|| { - KmsError::InvalidRequest( - "ReKeyKeyPair: the private key has no PublicKeyLink. Cannot determine the \ - paired public key." - .to_owned(), - ) - })? - .to_string(); - - // Retrieve the old public key - let old_pk_owm = kms - .database - .retrieve_objects(&old_pk_uid) - .await? - .into_values() - .next() - .ok_or_else(|| { - KmsError::Kmip21Error( - ErrorReason::Item_Not_Found, - format!("ReKeyKeyPair: linked public key '{old_pk_uid}' not found in database"), - ) - })?; - - // Reject wrapped public keys too - if old_pk_owm.object().key_wrapping_data().is_some() { - kms_bail!(KmsError::InconsistentOperation( - "The server cannot rekey: the public key is wrapped. Unwrap it first.".to_owned() - )) - } - - // Validate that the request doesn't try to change cryptographic parameters - validate_no_crypto_param_change(owm.attributes(), &request)?; - - // Build a CreateKeyPair request from the existing key's attributes - let mut common_attrs = owm.attributes().clone(); - // Clear fields that shouldn't be passed to key generation - common_attrs.unique_identifier = None; - common_attrs.link = None; - common_attrs.name = None; - common_attrs.initial_date = None; - common_attrs.last_change_date = None; - common_attrs.activation_date = None; - common_attrs.deactivation_date = None; - common_attrs.destroy_date = None; - common_attrs.compromise_date = None; - common_attrs.compromise_occurrence_date = None; - // Remove vendor tag attribute (contains system tags like _sk/_pk) - common_attrs.remove_vendor_attribute(kms.vendor_id(), "tag"); - - let new_sk_uid = Uuid::new_v4().to_string(); - let new_pk_uid = Uuid::new_v4().to_string(); - - let create_kp_request = CreateKeyPair { - common_attributes: Some(common_attrs), - private_key_attributes: None, - public_key_attributes: None, - common_protection_storage_masks: None, - private_protection_storage_masks: None, - public_protection_storage_masks: None, - }; - - let key_pair = - generate_key_pair(kms.vendor_id(), create_kp_request, &new_sk_uid, &new_pk_uid)?; - - // Prepare replacement attributes for both private and public keys - let new_sk_attributes = - prepare_replacement_attributes(owm.attributes(), &old_sk_uid, offset)?; - let new_pk_attributes = - prepare_replacement_attributes(old_pk_owm.attributes(), &old_pk_uid, offset)?; - - let sk_activation_date = new_sk_attributes.activation_date; - let pk_activation_date = new_pk_attributes.activation_date; - - // Set up private key lifecycle - let mut new_private_key = key_pair.private_key().to_owned(); - - // Set the replacement attributes on the new private key's internal attributes - if let Ok(sk_attrs) = new_private_key.attributes_mut() { - sk_attrs.name.clone_from(&new_sk_attributes.name); - sk_attrs.set_link( - LinkType::ReplacedObjectLink, - LinkedObjectIdentifier::TextString(old_sk_uid.clone()), - ); - sk_attrs.set_link( - LinkType::PublicKeyLink, - LinkedObjectIdentifier::TextString(new_pk_uid.clone()), - ); - } - - let new_sk_obj_attributes = setup_object_lifecycle( - &mut new_private_key, - ObjectType::PrivateKey, - sk_activation_date, - )?; - let sk_tags = new_sk_obj_attributes.get_tags(kms.vendor_id()); - - Box::pin(wrap_and_cache( - kms, - user, - &UniqueIdentifier::TextString(new_sk_uid.clone()), - &mut new_private_key, - )) - .await?; - - // Set up public key lifecycle - let mut new_public_key = key_pair.public_key().to_owned(); - - // Set the replacement attributes on the new public key's internal attributes - if let Ok(pk_attrs) = new_public_key.attributes_mut() { - pk_attrs.name.clone_from(&new_pk_attributes.name); - pk_attrs.set_link( - LinkType::ReplacedObjectLink, - LinkedObjectIdentifier::TextString(old_pk_uid.clone()), - ); - pk_attrs.set_link( - LinkType::PrivateKeyLink, - LinkedObjectIdentifier::TextString(new_sk_uid.clone()), - ); - } - - let new_pk_obj_attributes = setup_object_lifecycle( - &mut new_public_key, - ObjectType::PublicKey, - pk_activation_date, - )?; - let pk_tags = new_pk_obj_attributes.get_tags(kms.vendor_id()); - - Box::pin(wrap_and_cache( - kms, - user, - &UniqueIdentifier::TextString(new_pk_uid.clone()), - &mut new_public_key, - )) - .await?; - - // Update old private key - let mut old_sk_object = owm.object().clone(); - let mut old_sk_attributes = owm.attributes().clone(); - update_old_key_after_rekey(&mut old_sk_attributes, &new_sk_uid)?; - if let Ok(obj_attrs) = old_sk_object.attributes_mut() { - update_old_key_after_rekey(obj_attrs, &new_sk_uid)?; - } - - // Update old public key - let mut old_pk_object = old_pk_owm.object().clone(); - let mut old_pk_attributes = old_pk_owm.attributes().clone(); - update_old_key_after_rekey(&mut old_pk_attributes, &new_pk_uid)?; - if let Ok(obj_attrs) = old_pk_object.attributes_mut() { - update_old_key_after_rekey(obj_attrs, &new_pk_uid)?; - } - - // Execute all operations atomically: - // 1. Create new private key - // 2. Create new public key - // 3. Update old private key - // 4. Update old public key - let operations = vec![ - AtomicOperation::Create(( - new_sk_uid.clone(), - new_private_key, - new_sk_obj_attributes, - sk_tags, - )), - AtomicOperation::Create(( - new_pk_uid.clone(), - new_public_key, - new_pk_obj_attributes, - pk_tags, - )), - AtomicOperation::UpdateObject(( - old_sk_uid.clone(), - old_sk_object, - old_sk_attributes, - None, - )), - AtomicOperation::UpdateObject(( - old_pk_uid.clone(), - old_pk_object, - old_pk_attributes, - None, - )), - ]; - - kms.database.atomic(user, &operations).await?; - - info!( - old_sk_uid = old_sk_uid, - old_pk_uid = old_pk_uid, - new_sk_uid = new_sk_uid, - new_pk_uid = new_pk_uid, - user = user, - "Re-keyed key pair: new replacement keys created, old keys remain Active", - ); - - return Ok(ReKeyKeyPairResponse { - private_key_unique_identifier: UniqueIdentifier::TextString(new_sk_uid), - public_key_unique_identifier: UniqueIdentifier::TextString(new_pk_uid), - }); - } - - Err(KmsError::Kmip21Error( - ErrorReason::Item_Not_Found, - uid_or_tags.to_owned(), - )) -} - -/// Validate that the `ReKeyKeyPair` request does not attempt to change cryptographic parameters. -/// -/// Per KMIP 1.4 §4.5: "Attributes of the replacement key pair are copied from the existing -/// key pair." Changing algorithm, curve, or key length requires a new `CreateKeyPair` instead. -fn validate_no_crypto_param_change( - existing_attrs: &cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attributes, - request: &ReKeyKeyPair, -) -> KResult<()> { - // Check all attribute sources in the request - for req_attrs in [ - request.common_attributes.as_ref(), - request.private_key_attributes.as_ref(), - request.public_key_attributes.as_ref(), - ] - .into_iter() - .flatten() - { - if let Some(algo) = req_attrs.cryptographic_algorithm { - if existing_attrs.cryptographic_algorithm != Some(algo) { - kms_bail!(KmsError::InvalidRequest( - "ReKeyKeyPair: changing the cryptographic algorithm is not allowed. \ - Use CreateKeyPair for a different algorithm." - .to_owned() - )) - } - } - if let Some(ref cdp) = req_attrs.cryptographic_domain_parameters { - if let Some(ref existing_cdp) = existing_attrs.cryptographic_domain_parameters { - if cdp.recommended_curve.is_some() - && cdp.recommended_curve != existing_cdp.recommended_curve - { - kms_bail!(KmsError::InvalidRequest( - "ReKeyKeyPair: changing the recommended curve is not allowed. \ - Use CreateKeyPair for a different curve." - .to_owned() - )) - } - } - } - if let Some(len) = req_attrs.cryptographic_length { - if existing_attrs.cryptographic_length.is_some() - && existing_attrs.cryptographic_length != Some(len) - { - kms_bail!(KmsError::InvalidRequest( - "ReKeyKeyPair: changing the cryptographic length is not allowed. \ - Use CreateKeyPair for a different key size." - .to_owned() - )) - } - } - } - Ok(()) -} diff --git a/crate/server/src/core/wrapping/wrap.rs b/crate/server/src/core/wrapping/wrap.rs index 04de4b4870..f22e56bc26 100644 --- a/crate/server/src/core/wrapping/wrap.rs +++ b/crate/server/src/core/wrapping/wrap.rs @@ -69,11 +69,21 @@ pub(crate) async fn wrap_and_cache( return Ok(()); }; - // Cannot wrap yourself + // A key cannot be its own wrapping key. if wrapping_key_id == unique_identifier.to_string() { - if kms.params.key_wrapping_key.is_none() { - warn!("Key {wrapping_key_id} attempted to wrap itself"); + // The wrapping_key_id came from the request attributes (user-supplied), + // not from the server-wide KEK. Reject this as an explicit self-wrap. + if kms.params.key_wrapping_key.as_deref() != Some(&wrapping_key_id) { + return Err(KmsError::InvalidRequest(format!( + "Key '{wrapping_key_id}' cannot be used as its own wrapping key: \ + the wrapping key ID must differ from the key ID being created" + ))); } + // The server-wide KEK coincidentally matches the new key's UID — skip silently. + // This should not happen in practice (KEKs use prefixed UIDs like "hsm::softhsm2::0::kek"). + warn!( + "Server KEK '{wrapping_key_id}' matches the UID of the key being created; skipping self-wrap" + ); return Ok(()); } @@ -245,7 +255,15 @@ async fn wrap_using_kms( "The wrapping key {wrapping_key_uid} is not active" ))); } - if wrapping_key.owner() != user { + // The server-configured key_encryption_key is a shared server resource accessible + // to all users, so skip the ownership check for it (mirrors the bypass in + // `wrap_using_crypto_oracle` — issue #761). + let is_server_kek = kms + .params + .key_wrapping_key + .as_deref() + .is_some_and(|kek| kek == wrapping_key_uid); + if !is_server_kek && wrapping_key.owner() != user { let ops = kms .database .list_user_operations_on_object(wrapping_key.id(), user, false) diff --git a/crate/server_database/src/core/database_objects.rs b/crate/server_database/src/core/database_objects.rs index 1f9c82e0f1..e774b1829d 100644 --- a/crate/server_database/src/core/database_objects.rs +++ b/crate/server_database/src/core/database_objects.rs @@ -325,6 +325,24 @@ impl Database { Ok(results) } + /// Return (uid, state, attributes) for every object wrapped by the given wrapping key. + pub async fn find_wrapped_by( + &self, + wrapping_key_uid: &str, + user: &str, + ) -> DbResult> { + let map = self.objects.read().await; + let mut results: Vec<(String, State, Attributes)> = Vec::new(); + for db in map.values() { + results.extend( + db.find_wrapped_by(wrapping_key_uid, user) + .await + .unwrap_or_default(), + ); + } + Ok(results) + } + /// Perform an atomic set of operations on the database. /// /// This function executes a series of operations (typically in a transaction) atomically. diff --git a/crate/server_database/src/stores/sql/mysql.rs b/crate/server_database/src/stores/sql/mysql.rs index 7cd58f4f1d..89d992910e 100644 --- a/crate/server_database/src/stores/sql/mysql.rs +++ b/crate/server_database/src/stores/sql/mysql.rs @@ -625,6 +625,57 @@ impl ObjectsStore for MySqlPool { ) .await?) } + + async fn find_wrapped_by( + &self, + wrapping_key_uid: &str, + user: &str, + ) -> InterfaceResult> { + // MySQL uses JSON_EXTRACT with unquoting via JSON_UNQUOTE or ->> operator (MySQL 8+). + let sql = "\ + SELECT DISTINCT objects.id, objects.state, objects.attributes \ + FROM objects \ + LEFT JOIN read_access ON objects.id = read_access.id \ + AND read_access.userid = ? \ + WHERE (objects.owner = ? OR read_access.userid = ?) \ + AND ( \ + objects.object->>'$.SymmetricKey.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier' = ? \ + OR objects.object->>'$.PrivateKey.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier' = ? \ + OR objects.object->>'$.SecretData.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier' = ? \ + OR objects.object->>'$.SplitKey.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier' = ? \ + OR objects.object->>'$.PGPKey.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier' = ? \ + )"; + let mut conn = self + .pool + .get_conn() + .await + .map_err(|e| InterfaceError::Db(format!("MySQL connection error: {e}")))?; + let rows: Vec<(String, String, Value)> = conn + .exec( + sql, + ( + user, + user, + user, + wrapping_key_uid, + wrapping_key_uid, + wrapping_key_uid, + wrapping_key_uid, + wrapping_key_uid, + ), + ) + .await + .map_err(|e| InterfaceError::Db(format!("MySQL query error: {e}")))?; + let mut out = Vec::new(); + for (uid, state_str, attrs_val) in rows { + let state = State::try_from(state_str.as_str()) + .map_err(|e| InterfaceError::Db(format!("invalid state: {e}")))?; + let attrs: Attributes = serde_json::from_value(attrs_val) + .map_err(|e| InterfaceError::Db(format!("invalid attributes: {e}")))?; + out.push((uid, state, attrs)); + } + Ok(out) + } } #[async_trait(?Send)] diff --git a/crate/server_database/src/stores/sql/pgsql.rs b/crate/server_database/src/stores/sql/pgsql.rs index 17be0fee65..a542f36684 100644 --- a/crate/server_database/src/stores/sql/pgsql.rs +++ b/crate/server_database/src/stores/sql/pgsql.rs @@ -780,6 +780,46 @@ impl ObjectsStore for PgPool { Ok(out) }) } + + async fn find_wrapped_by( + &self, + wrapping_key_uid: &str, + user: &str, + ) -> InterfaceResult> { + pg_retry!(self.pool, |client| { + // PostgreSQL uses ->> for JSON text extraction. + // We check the 5 object variants that can hold a KeyBlock with wrapping data. + let sql = "\ + SELECT DISTINCT objects.id, objects.state, objects.attributes \ + FROM objects \ + LEFT JOIN read_access ON objects.id = read_access.id \ + AND read_access.userid = $2 \ + WHERE (objects.owner = $2 OR read_access.userid = $2) \ + AND ( \ + objects.object->'SymmetricKey'->'KeyBlock'->'KeyWrappingData'->'EncryptionKeyInformation'->>'UniqueIdentifier' = $1 \ + OR objects.object->'PrivateKey'->'KeyBlock'->'KeyWrappingData'->'EncryptionKeyInformation'->>'UniqueIdentifier' = $1 \ + OR objects.object->'SecretData'->'KeyBlock'->'KeyWrappingData'->'EncryptionKeyInformation'->>'UniqueIdentifier' = $1 \ + OR objects.object->'SplitKey'->'KeyBlock'->'KeyWrappingData'->'EncryptionKeyInformation'->>'UniqueIdentifier' = $1 \ + OR objects.object->'PGPKey'->'KeyBlock'->'KeyWrappingData'->'EncryptionKeyInformation'->>'UniqueIdentifier' = $1 \ + )"; + let rows = client + .query(sql, &[&wrapping_key_uid, &user]) + .await + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + let mut out = Vec::new(); + for row in rows { + let uid: String = row.get(0); + let state_str: String = row.get(1); + let state = State::try_from(state_str.as_str()) + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + let attrs_val: Value = row.get(2); + let attrs: Attributes = serde_json::from_value(attrs_val) + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + out.push((uid, state, attrs)); + } + Ok(out) + }) + } } #[async_trait(?Send)] diff --git a/crate/server_database/src/stores/sql/sqlite.rs b/crate/server_database/src/stores/sql/sqlite.rs index dc06786c76..c33b3e8999 100644 --- a/crate/server_database/src/stores/sql/sqlite.rs +++ b/crate/server_database/src/stores/sql/sqlite.rs @@ -531,6 +531,63 @@ impl ObjectsStore for SqlitePool { .map_err(DbError::from)?; Ok(rows) } + + async fn find_wrapped_by( + &self, + wrapping_key_uid: &str, + user: &str, + ) -> InterfaceResult> { + // Search in the stored `object` JSON column for objects whose KeyWrappingData + // EncryptionKeyInformation UniqueIdentifier matches the given wrapping key UID. + // We check all the object variant prefixes that can hold a KeyBlock. + let sql = replace_dollars_with_qn( + "SELECT DISTINCT objects.id, objects.state, objects.attributes \ + FROM objects \ + LEFT JOIN read_access ON objects.id = read_access.id \ + AND read_access.userid = $2 \ + WHERE (objects.owner = $2 OR read_access.userid = $2) \ + AND ( \ + json_extract(objects.object, '$.SymmetricKey.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier') = $1 \ + OR json_extract(objects.object, '$.PrivateKey.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier') = $1 \ + OR json_extract(objects.object, '$.SecretData.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier') = $1 \ + OR json_extract(objects.object, '$.SplitKey.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier') = $1 \ + OR json_extract(objects.object, '$.PGPKey.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier') = $1 \ + )", + ); + let uid_s = wrapping_key_uid.to_owned(); + let user_s = user.to_owned(); + let rows = self + .reader() + .call( + move |c: &mut rusqlite::Connection| -> Result< + Vec<(String, State, Attributes)>, + rusqlite::Error, + > { + let mut stmt = c.prepare(&sql)?; + let mut q = + stmt.query(params_from_iter([uid_s.as_str(), user_s.as_str()]))?; + let mut out = Vec::new(); + while let Some(r) = q.next()? { + let id: String = r.get(0)?; + let state_str: String = r.get(1)?; + let state = State::try_from(state_str.as_str()) + .map_err(|_e| rusqlite::Error::InvalidQuery)?; + let raw: String = r.get(2)?; + let attrs = if raw.is_empty() { + Attributes::default() + } else { + serde_json::from_str::(&raw) + .map_err(|_e| rusqlite::Error::InvalidQuery)? + }; + out.push((id, state, attrs)); + } + Ok(out) + }, + ) + .await + .map_err(DbError::from)?; + Ok(rows) + } } #[async_trait(?Send)] diff --git a/crate/test_kms_server/README.md b/crate/test_kms_server/README.md index 1f5efbd7ae..c155cce250 100644 --- a/crate/test_kms_server/README.md +++ b/crate/test_kms_server/README.md @@ -65,7 +65,7 @@ under `test_data/vectors/` containing a `manifest.toml` and one JSON step file per KMIP operation. The vector runner uses singleton shared servers and replays the steps sequentially. -**329 vectors** across 8 categories: +**344 vectors** across 8 categories: | Category | Vector Directory Name | KMIP Operations | Steps | |----------|-----------------------|-----------------|-------| @@ -133,6 +133,10 @@ replays the steps sequentially. | KMIP Operations | `certify_validate` | CreateKeyPair, Certify, Validate, Destroy ×3 | 6 | | KMIP Operations | `certify_revoke_validate` | CreateKeyPair, Certify, Validate, Revoke, Validate (invalid) | 8 | | KMIP Operations | `certify_chain` | CreateKeyPair, Certify (root→intermediate→leaf), Validate chain | 17 | +| KMIP Operations | `recertify_self_signed` | CreateKeyPair, Certify (self-signed), ReCertify, GetAttributes (state), Revoke ×2, Destroy ×4 | 10 | +| KMIP Operations | `recertify_chain` | CreateKeyPair ×2, Certify (CA + leaf), ReCertify (leaf), GetAttributes (links), Revoke ×3, Destroy ×7 | 16 | +| KMIP Operations | `recertify_with_links` | CreateKeyPair, Certify, ReCertify, GetAttributes (old→ReplacementObjectLink, new→ReplacedObjectLink), Revoke ×2, Destroy ×4 | 11 | +| KMIP Operations | `recertify_with_offset` | CreateKeyPair, Certify, ReCertify (Offset=0 → Active), CreateKeyPair, Certify, ReCertify (Offset=86400 → PreActive), Revoke ×3, Destroy ×8 | 19 | | KMIP Operations | `check` | Create, Check, Activate, Check | 4 | | KMIP Operations | `derive_key_pbkdf2` | Create, DeriveKey (PBKDF2-SHA256), Get | 3 | | KMIP Operations | `derive_key_pbkdf2_sha512` | Create, DeriveKey (PBKDF2-SHA512), Get | 3 | @@ -164,6 +168,7 @@ replays the steps sequentially. | KMIP Operations | `rekey_deactivated_fails` | Create, ReKey, Revoke (old → Deactivated), ReKey (old → fails) | 4 | | KMIP Operations | `rekey_with_links` | Create, ReKey, GetAttributes (old has ReplacementObjectLink), GetAttributes (new has ReplacedObjectLink) | 4 | | KMIP Operations | `rekey_with_offset` | Create, ReKey (Offset=3600s), GetAttributes (ActivationDate = now+3600) | 4 | +| KMIP Operations | `rekey_with_offset_state` | Create, ReKey (Offset=0 → Active), Create, ReKey (Offset=86400 → PreActive), cleanup | 13 | | KMIP Operations | `rekey_name_removed_from_old` | Create (named), ReKey, GetAttributes (old has no Name) | 4 | | KMIP Operations | `rekey_double_chain` | Create, ReKey, ReKey, GetAttributes (chain of ReplacementObjectLinks) | 5 | | KMIP Operations | `rekey_old_key_still_decrypts` | Create, ReKey, Encrypt (old key still works) | 3 | @@ -185,6 +190,7 @@ replays the steps sequentially. | KMIP Operations | `rekey_keypair_old_key_still_active` | CreateKeyPair (EC), ReKeyKeyPair, GetAttributes (old SK State=Active) | 5 | | KMIP Operations | `rekey_keypair_no_public_link_fails` | CreateKeyPair (EC), Delete PublicKeyLink, ReKeyKeyPair → fails | 4 | | KMIP Operations | `rekey_keypair_with_offset` | CreateKeyPair (EC), ReKeyKeyPair (Offset=3600s), verify ActivationDate | 5 | +| KMIP Operations | `rekey_keypair_with_offset_state` | CreateKeyPair (EC), ReKeyKeyPair (Offset=0 → Active), CreateKeyPair, ReKeyKeyPair (Offset=86400 → PreActive), cleanup | 20 | | KMIP Operations | `rekey_keypair_ec_with_links` | CreateKeyPair (EC), ReKeyKeyPair, GetAttributes (verify links) | 5 | | KMIP Operations | `rekey_keypair_rsa_with_links` | CreateKeyPair (RSA), ReKeyKeyPair, GetAttributes (verify links) | 5 | | KMIP Operations | `rekey_keypair_rsa_encrypt_decrypt` | CreateKeyPair (RSA), ReKeyKeyPair, Encrypt+Decrypt with new key | 7 | @@ -316,6 +322,9 @@ replays the steps sequentially. | Negative / TypeMismatch | `negative/type_mismatch/import_malformed_key` | Import TransparentSymmetricKey with raw bytes → error | 1 | | Negative / TypeMismatch | `negative/type_mismatch/encrypt_with_secret_data` | Encrypt using SecretData object → error | 2 | | Negative / TypeMismatch | `negative/type_mismatch/revoke_already_destroyed` | Revoke a destroyed key → success | 3 | +| Negative / ReCertify | `negative/recertify_missing_uid` | ReCertify without UniqueIdentifier → error | 1 | +| Negative / ReCertify | `negative/recertify_nonexistent` | ReCertify non-existent certificate → error | 1 | +| Negative / ReCertify | `negative/recertify_not_a_certificate` | ReCertify a symmetric key → error | 2 | | **non-FIPS CryptographicParameters** | | | | | non-FIPS / GCM-SIV | `non-fips/aes128_gcm_siv_with_explicit_nonce` | Create (AES-128), Encrypt (client 12-B nonce), Decrypt | 3 | | non-FIPS / GCM-SIV | `non-fips/aes256_gcm_siv_with_explicit_nonce` | Create (AES-256), Encrypt (client 12-B nonce), Decrypt | 3 | diff --git a/crate/test_kms_server/src/vector_runner.rs b/crate/test_kms_server/src/vector_runner.rs index 80f206b060..5d5ad0a8c7 100644 --- a/crate/test_kms_server/src/vector_runner.rs +++ b/crate/test_kms_server/src/vector_runner.rs @@ -2925,6 +2925,29 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/negative/duplicate_tags_encrypt").await } + // ── Negative tests: ReCertify ────────────────────────────────────── + + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_neg_recertify_missing_uid() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/recertify_missing_uid").await + } + + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_neg_recertify_nonexistent() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/recertify_nonexistent").await + } + + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_neg_recertify_not_a_certificate() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/recertify_not_a_certificate").await + } + // ── KMIP operations: ReKeyKeyPair (non-FIPS only) ──────────────────── // These vectors do not supply PrivateKeyAttributes/PublicKeyAttributes with // FIPS-compliant CryptographicUsageMask values, and some use PQC algorithms @@ -3117,6 +3140,13 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/non-fips/rekey_keypair_secp256k1").await } + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_rekey_keypair_covercrypt() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/non-fips/rekey_keypair_covercrypt").await + } + // ── KMIP operations: certificate chain and revoke ─────────────────── #[cfg(feature = "non-fips")] @@ -3133,6 +3163,53 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/certify_revoke_validate").await } + // ── KMIP operations: ReCertify ────────────────────────────────────── + + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_recertify_self_signed() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/recertify_self_signed").await + } + + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_recertify_chain() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/recertify_chain").await + } + + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_recertify_with_links() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/recertify_with_links").await + } + + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_recertify_with_offset() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/recertify_with_offset").await + } + + // ── KMIP operations: Offset state verification ────────────────────── + + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_rekey_with_offset_state() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_with_offset_state").await + } + + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_rekey_keypair_with_offset_state() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_keypair_with_offset_state") + .await + } + // ── KMIP operations: Locate filters ───────────────────────────────── #[tokio::test] diff --git a/documentation/docs/kmip_support/key_auto_rotation.md b/documentation/docs/kmip_support/key_auto_rotation.md new file mode 100644 index 0000000000..29953ca525 --- /dev/null +++ b/documentation/docs/kmip_support/key_auto_rotation.md @@ -0,0 +1,623 @@ +# Key Auto-Rotation Policy + +Cosmian KMS supports **scheduled, policy-driven key rotation** for symmetric +keys and asymmetric key pairs. Instead of requiring an operator to call the +`Re-Key` or `Re-Key Key Pair` KMIP operations manually, a per-key *rotation +policy* can be attached to any key object. A background task then checks +periodically which keys are overdue and rotates them automatically. + +--- + +## Rotation policy attributes + +All rotation-policy state is stored as vendor-extension KMIP attributes on +the key object itself. The following attributes are available: + +| Attribute | Type | Description | +|---|---|---| +| `x-rotate-interval` | `u32` (seconds) | How often this key should be rotated. `0` disables auto-rotation. | +| `x-rotate-name` | `String` | Optional human-readable label for the policy (e.g. `"daily"`, `"annual"`). | +| `x-rotate-offset` | `u32` (seconds) | Shift the first rotation trigger by this many seconds after `Initial Date`. | +| `x-rotate-generation` | `u64` | Incremented on every rotation; `0` for never-rotated keys. | +| `x-rotate-date` | `datetime` | Timestamp of the last rotation; populated automatically after each rotation. | + +Use the `SetAttribute` KMIP operation (or the `ckms sym keys set-rotation-policy` +CLI command) to configure these attributes on an existing key. + +```bash +# Rotate the key every hour starting from its Initial Date +ckms sym keys set-rotation-policy \ + --key-id \ + --interval 3600 \ + --name "hourly" +``` + +--- + +> **⚠️ HSM-resident keys cannot be auto-rotated** +> +> Keys whose UID starts with `hsm::` are stored entirely inside the Hardware +> Security Module. The KMS has no ability to generate new key material inside +> the HSM, replace an existing HSM key, or migrate key material to a new UID. +> As a result: +> +> - `find_due_for_rotation` never returns HSM UIDs (they are not in the KMS +> database), so the scheduler will never attempt to rotate them. +> - Calling `Re-Key` manually on an `hsm::` UID will fail. +> - Setting `x-rotate-interval` on an HSM key is unsupported and has no effect. +> +> To rotate an HSM key, use the vendor's own key-management tools +> (e.g. `softhsm2-util`, the Utimaco administration console, `pkcs11-tool`, +> etc.) and re-register the new key with the KMS server if needed. + +--- + +## Server-side scheduler + +The server's background cron thread runs an auto-rotation check at the +interval configured by the `--auto-rotation-check-interval-secs` server flag +(default: `0`, meaning disabled). + +```bash +cosmian_kms --auto-rotation-check-interval-secs 300 # check every 5 minutes +``` + +On each check, the server queries all **Active** symmetric keys and private +keys owned by any user whose `x-rotate-interval` has elapsed since either +`x-rotate-date` (for previously-rotated keys) or `Initial Date + x-rotate-offset` +(for never-rotated keys with an initial date). + +--- + +## Key types and rotation flows + +The behaviour differs according to whether the key is plain, a wrapping key, +or a wrapped key. Each case is described below with a lifecycle diagram. + +--- + +### 1. Plain symmetric key (no wrapping) + +A plain symmetric key carries only its own policy. On rotation: + +1. Fresh key material is generated (same algorithm and length). +2. The new key is assigned a new UUID. +3. A `ReplacedObjectLink` on the new key points back to the old key. +4. A `ReplacementObjectLink` on the old key points forward to the new key. +5. `x-rotate-generation` is incremented; `x-rotate-date` is set. + +```mermaid +stateDiagram-v2 + direction LR + [*] --> Active : Create + Active --> Active : Auto-rotation (new UID, new material) + Active --> Deactivated : Revoke + Deactivated --> Destroyed : Destroy + Destroyed --> [*] + + note right of Active + Each arrow = one rotation cycle. + Old key: ReplacementObjectLink → new key. + New key: ReplacedObjectLink → old key. + end note +``` + +**KMIP link chain after two successive rotations:** + +```mermaid +flowchart LR + K0["Key₀ (original)"] -->|ReplacementObjectLink| K1["Key₁ (1st rotation)"] + K1 -->|ReplacementObjectLink| K2["Key₂ (2nd rotation)"] + K2 -->|ReplacedObjectLink| K1 + K1 -->|ReplacedObjectLink| K0 +``` + +--- + +### 2. Wrapping key + +A *wrapping key* is a symmetric key (or asymmetric public key) whose +`WrappingKeyLink` points to it from one or more *wrapped* keys. + +When the wrapping key is rotated: + +1. A new wrapping key is created (Phase 1 — committed immediately so it is + available in the database). +2. Every **Active** key that references the old wrapping key via a + `WrappingKeyLink` is re-wrapped with the new wrapping key (Phase 2). +3. Each wrapped key's `WrappingKeyLink` is updated to the new wrapping key + UUID. +4. All standard rotation metadata (`ReplacementObjectLink`, generation counter, + date) are applied to both the old and new wrapping key. + +```mermaid +sequenceDiagram + participant Scheduler + participant KMS + participant DB + + Scheduler->>KMS: run_auto_rotation() + KMS->>DB: find_due_for_rotation() + DB-->>KMS: [wrapping_key_uid, ...] + KMS->>DB: Phase 1 — upsert new wrapping key (committed) + loop For each wrapped dependant + KMS->>DB: retrieve wrapped key + KMS->>KMS: unwrap with old wrapping key + KMS->>KMS: wrap with new wrapping key + KMS->>DB: update WrappingKeyLink → new wrapping key UID + end + KMS->>DB: Phase 2 — update old wrapping key links + metadata +``` + +**State view:** + +```mermaid +stateDiagram-v2 + direction LR + [*] --> WK_Active : Create wrapping key + WK_Active --> WK_Active : Auto-rotation (new UID, re-wraps all dependants) + WK_Active --> Deactivated : Revoke + Deactivated --> Destroyed : Destroy + Destroyed --> [*] +``` + +--- + +### 3. Wrapped key + +A *wrapped key* is any key whose key block contains `KeyWrappingData`. It +cannot simply be re-keyed in place because the new plaintext bytes must be +re-wrapped before storage. + +Rotation flow: + +1. The wrapped key is exported from the database and **unwrapped** in + memory using the current wrapping key. +2. Fresh plaintext key material is generated from the unwrapped attributes. +3. The new key material is **re-wrapped** with the same wrapping key. +4. The resulting ciphertext is stored under a new UUID; the new key entry + carries an active `WrappingKeyLink` pointing to the original wrapping key. +5. Standard rotation metadata is applied. + +```mermaid +sequenceDiagram + participant Scheduler + participant KMS + participant DB + + Scheduler->>KMS: run_auto_rotation() + KMS->>DB: find_due_for_rotation() + DB-->>KMS: [wrapped_key_uid, ...] + KMS->>DB: retrieve wrapped key + wrapping key + + Note over KMS: unwrap in-memory (plaintext never stored) + KMS->>KMS: generate new key material + KMS->>KMS: re-wrap with same wrapping key + + KMS->>DB: store new wrapped key (new UID, same WrappingKeyLink) + KMS->>DB: update old key: ReplacementObjectLink → new key + Note over DB: new key has ReplacedObjectLink → old key +``` + +**State view:** + +```mermaid +stateDiagram-v2 + direction LR + [*] --> Wrapped_Active : Create + wrap + Wrapped_Active --> Wrapped_Active : Auto-rotation (unwrap, new material, re-wrap) + Wrapped_Active --> Deactivated : Revoke + Deactivated --> Destroyed : Destroy + Destroyed --> [*] +``` + +--- + +### 4. Asymmetric key pair (private key — plain) + +For asymmetric keys managed via `Re-Key Key Pair`, the rotation target is the +**private key**. The associated public key UID is carried in the private key's +`PublicKeyLink` attribute and is preserved in the new private key. + +```mermaid +sequenceDiagram + participant Scheduler + participant KMS + + Scheduler->>KMS: run_auto_rotation() + KMS->>KMS: detect PrivateKey type + KMS->>KMS: ReKeyKeyPair (new private key + new public key) + note right of KMS: New PrivateKey UID
New PublicKey UID
(linked to new private key) +``` + +--- + +### 5. Wrapped private key (CoverCrypt) + +A **CoverCrypt** private key that has been wrapped follows the same flow as any +other `PrivateKey` rotation: the `ReKeyKeyPair` (`rekey_keypair`) operation +unwraps the key in memory, rekeys the CoverCrypt partition, and stores a new +wrapped private key under a fresh UID. + +> **Note on RSA / EC private keys**: auto-rotation of RSA and EC private keys +> via `ReKeyKeyPair` is not yet supported. If a rotation policy is set on an +> RSA or EC private key, the scheduler will attempt rotation and log a warning +> instead of failing. + +Setting a rotation policy attribute on a wrapped private key works in all +cases: the attribute is stored in the metadata column (not in the ciphertext +key block) and does not require the key to be unwrapped first. + +```bash +# Works even when the private key is stored wrapped +ckms sym keys set-rotation-policy \ + --key-id \ + --interval 86400 \ + --name "nightly" +``` + +--- + +### 6. Certificate renewal (`ReCertify`) + +Certificate renewal creates a **new certificate for the same key pair** — no +new key material is generated. The KMIP `ReCertify` operation (§6.1.45) +assigns a fresh UID to the renewed certificate and links old → new via the +standard `ReplacementObjectLink` / `ReplacedObjectLink` pair. + +#### Standards and RFCs + +| Standard | Title | Relevance | +|----------|-------|-----------| +| [KMIP 2.1 §6.1.45](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html) | Re-certify operation | Normative definition: request/response payload, attribute handling, link semantics | +| [RFC 4210](https://www.rfc-editor.org/rfc/rfc4210.html) | Internet X.509 PKI — Certificate Management Protocol (CMP) | Defines `kur` (Key Update Request, §5.3.5) / `kup` (Key Update Response, §5.3.6) for certificate renewal over the wire. KMIP `ReCertify` is the KMS-internal equivalent. | +| [RFC 4211](https://www.rfc-editor.org/rfc/rfc4211.html) | Internet X.509 CRMF (Certificate Request Message Format) | §6.5 "OldCert ID Control" — identifies the certificate being renewed in a CMP request | +| [RFC 5280](https://www.rfc-editor.org/rfc/rfc5280.html) | Internet X.509 PKI — Certificate and CRL Profile | Defines X.509v3 certificate structure, extensions, validity periods | +| [RFC 2986](https://www.rfc-editor.org/rfc/rfc2986.html) | PKCS#10: Certification Request Syntax | CSR format supported by KMIP `CertificateRequestType` | +| [RFC 5272](https://www.rfc-editor.org/rfc/rfc5272.html) | Certificate Management over CMS (CMC) | Alternative certificate lifecycle protocol | + +#### Relationship between CMP and KMIP ReCertify + +In the CMP protocol (RFC 4210), a client sends a **Key Update Request** (`kur`, +body tag [7]) to a CA to obtain a renewed certificate for an existing key pair. +The CA responds with a **Key Update Response** (`kup`, body tag [8]) containing +the new certificate. + +In Cosmian KMS, the server acts as both the CA and the key/certificate store. +The `ReCertify` KMIP operation performs the equivalent of a CMP `kur`/`kup` +exchange locally: it re-signs the certificate for the same subject and key pair, +assigns a fresh UID, and manages replacement links — all in a single atomic +database transaction. + +#### Rotation flow + +1. The existing certificate is retrieved and its issuer/subject are resolved. +2. A new certificate is built and signed (same key pair, same issuer). +3. The new certificate is assigned a fresh UID. +4. `ReplacedObjectLink` on the new certificate → old certificate. +5. `ReplacementObjectLink` on the old certificate → new certificate. +6. Keys linked to the old certificate have their `CertificateLink` updated + to point to the new certificate. +7. Rotation metadata (`x-rotate-generation`, `x-rotate-date`) is set. + +```mermaid +sequenceDiagram + participant Client + participant KMS + participant DB + + Client->>KMS: ReCertify(old_cert_uid) + KMS->>DB: retrieve old certificate + KMS->>KMS: resolve issuer + subject from old cert + KMS->>KMS: build & sign new certificate (same key pair) + KMS->>DB: Phase 1 — store new cert (fresh UID) + KMS->>DB: Phase 2 — update old cert (ReplacementObjectLink) + KMS->>DB: Phase 2 — relink keys (CertificateLink → new cert) + KMS-->>Client: ReCertifyResponse(new_cert_uid) +``` + +#### Attribute handling (KMIP 2.1 §6.1.45 Table 299) + +| Attribute | New certificate | Old certificate | +|-----------|-----------------|-----------------| +| `Unique Identifier` | Fresh UUID | Unchanged | +| `Initial Date` | Set to current time | Unchanged | +| `Link[ReplacedObjectLink]` | → old cert UID | — | +| `Link[ReplacementObjectLink]` | — | → new cert UID | +| `Link[PublicKeyLink]` | Preserved from old cert | Unchanged | +| `Link[PrivateKeyLink]` | Preserved from old cert | Unchanged | +| `Name` | Inherited from old cert | Removed (per KMIP spec) | +| `State` | Active | Active | +| `x-rotate-generation` | old value + 1 | Unchanged | +| `x-rotate-date` | Current timestamp | Unchanged | +| `Destroy Date` | Not set | Unchanged | +| `Revocation Reason` | Not set | Unchanged | + +#### Key differences from key rotation + +| Aspect | Key rotation (`ReKey` / `ReKeyKeyPair`) | Certificate renewal (`ReCertify`) | +|--------|------------------------------------------|-----------------------------------| +| New material generated? | Yes (new key bytes) | No (same key pair) | +| Wrapping involved? | Yes (if key was wrapped) | Never | +| Dependants re-wrapped? | Yes (for wrapping keys) | No — keys are *relinked* instead | +| KMIP operation | `Re-Key` (0x0A) / `Re-Key Key Pair` (0x0B) | `Re-Certify` (0x07) | + +#### CLI usage + +Certificate renewal is invoked via the `ckms certificates certify` command with +the `--certificate-id-to-re-certify` flag: + +```bash +# Renew an existing certificate (same key pair, new validity period) +ckms certificates certify \ + --certificate-id-to-re-certify \ + --issuer-private-key-id \ + --days 365 + +# Self-signed certificate renewal (issuer = subject) +ckms certificates certify \ + --certificate-id-to-re-certify \ + --days 3650 +``` + +--- + +### 7. Server-wide key-encryption key (KEK) + +The KMS server can be configured with a **key-encryption key** (`--key-encryption-key` +CLI flag or `key_encryption_key` in `kms.toml`). When this option is set, +**every object stored in the KMS database is transparently wrapped** by the KEK +before being persisted. The KEK is typically held in an HSM (SoftHSM2, +Utimaco, Proteccio, …). + +Auto-rotation works exactly the same as for plain or wrapped keys: the scheduler +detects objects whose `x-rotate-interval` has elapsed, unwraps them using the +server KEK, generates fresh key material, re-wraps the new key, and stores it. +The operator **does not need to do anything special** to rotate a key stored in +a KEK-protected server. + +Example server startup with SoftHSM2 and a KEK: + +```bash +cosmian_kms \ + --database-type sqlite \ + --hsm-model softhsm2 \ + --hsm-slot 0 \ + --hsm-password 12345678 \ + --key-encryption-key "hsm::softhsm2::0::my-kek" \ + --auto-rotation-check-interval-secs 300 +``` + +Setting a rotation policy on a wrapped key is identical to a plain key: + +```bash +ckms sym keys set-rotation-policy \ + --key-id \ + --interval 3600 \ + --name "hourly" +``` + +The `SetAttribute` call succeeds even when the target key is wrapped (the +attribute is stored separately in the metadata column, not inside the +ciphertext). + +--- + +## Interaction between key types during rotation + +```mermaid +flowchart TD + subgraph "Auto-rotation cycle" + direction TB + DUE["find_due_for_rotation()"] --> DISPATCH{"Object type?"} + DISPATCH -->|SymmetricKey| PLAIN["Plain rekey
(new material, new UID)"] + DISPATCH -->|SymmetricKey + has dependants| WRAP_K["Wrapping-key rotation
(Phase 1 → Phase 2 re-wrap)"] + DISPATCH -->|SymmetricKey + wrapped| WRAP_D["Wrapped-key rotation
(unwrap → new material → re-wrap)"] + DISPATCH -->|PrivateKey| ASYM["ReKeyKeyPair"] + DISPATCH -->|Certificate| CERT["ReCertify
(same key pair, new cert UID)"] + PLAIN --> META["Update metadata
(generation++, date, links)"] + WRAP_K --> META + WRAP_D --> META + ASYM --> META + CERT --> META + META --> OTEL["Increment
kms.key.auto_rotation
OTel counter"] + end +``` + +--- + +## Configuring auto-rotation end-to-end + +### Step 1 — Set the rotation policy on a key + +```bash +# Enable hourly rotation with a 60-second initial offset +ckms sym keys set-rotation-policy \ + --key-id \ + --interval 3600 \ + --offset 60 \ + --name "hourly" +``` + +### Step 2 — Enable the server scheduler + +In `kms.toml` (or on the command line): + +```toml +auto_rotation_check_interval_secs = 300 # check every 5 minutes +``` + +### Step 3 — Observe rotations + +The server emits an OpenTelemetry counter `kms.key.auto_rotation` labelled +with the `uid` and `algorithm` on every successful rotation. Use your +OTel-compatible backend (Prometheus + Grafana, Datadog, …) to alert on +unexpected gaps in rotation activity. + +--- + +## Disabling auto-rotation on a key + +Set `x-rotate-interval` to `0`: + +```bash +ckms sym keys set-rotation-policy --key-id --interval 0 +``` + +--- + +## Revoking superseded (old) keys + +After a rotation — whether triggered automatically by the scheduler or manually +via `Re-Key` — **the old key is not revoked automatically**. Its state remains +`Active` so that any in-flight operations that still reference the old UID can +complete gracefully. However, once all consumers have migrated to the new key, +the old key should be revoked to prevent further use and to accurately reflect +its lifecycle state. + +> **How to find the old key UID**: the new key always carries a +> `ReplacedObjectLink` attribute pointing back to the old key UID. Use +> `ckms objects get-attributes --key-id ` or the *Attributes → Get* +> page in the Web UI to read that link. + +### Using the CLI + +The revoke sub-command lives under each key-type group and takes a free-text +revocation reason as its first positional argument: + +```bash +# Symmetric key (old key superseded by rotation) +ckms sym keys revoke -k "Superseded" + +# RSA or EC key pair (revokes both the private key and its linked public key) +ckms rsa keys revoke -k "Superseded" +ckms ec keys revoke -k "Superseded" + +# Post-quantum key pair +ckms pqc keys revoke -k "Superseded" + +# Certificate +ckms certificates revoke -c "Superseded" +``` + +Once a key is in the `Deactivated` state it can still be exported by its owner +(with `--allow-revoked`), but it will be refused for all cryptographic +operations by any other user. + +### Using the Web UI + +1. Navigate to **Objects → Revoke** in the left-hand menu. +2. Enter the old key UID in the *Object ID* field. +3. Type a reason (e.g. `Superseded`) in the *Revocation Reason* field. +4. Click **Revoke**. + +The object's state will change to `Deactivated` immediately. + +--- + +## Interaction with KMIP attributes + +The table below summarises which KMIP attributes are **added** or **updated** +when a key is rotated. + +### Auto-rotation (cron-triggered) + +| Attribute | Old key | New key | +|---|---|---| +| `Unique Identifier` | unchanged | fresh UUID | +| `Link[ReplacementObjectLink]` | → new key UID | — | +| `Link[ReplacedObjectLink]` | — | → old key UID | +| `Link[WrappingKeyLink]` | unchanged | copied from old key | +| `x-rotate-generation` | unchanged | old value + 1 | +| `x-rotate-date` | unchanged | timestamp of rotation | +| `x-rotate-interval` | **set to `0`** (disabled, so cron skips the old key in future runs) | **inherited** from old key (policy continues on the new key) | +| `x-rotate-name` | unchanged | inherited from old key | +| `x-rotate-offset` | unchanged | inherited from old key | +| `x-initial-date` | cleared | set to now (resets the baseline for the next rotation deadline) | +| `State` | Active | Active | +| `Cryptographic Algorithm` | unchanged | copied from old key | +| `Cryptographic Length` | unchanged | copied from old key | + +### Manual rekey (user-triggered via `Re-Key` / `re-key` CLI) + +When a user explicitly calls `Re-Key` (e.g. `ckms sym keys re-key --key-id `), +the semantics deliberately differ from auto-rotation: + +| Attribute | Old key | New key | +|---|---|---| +| `x-rotate-interval` | **set to `0`** (disabled) | **`0`** (not inherited — user must re-arm the new key explicitly) | +| `x-rotate-generation` | unchanged | old value + 1 | +| `Link[ReplacementObjectLink]` | → new key UID | — | +| `Link[ReplacedObjectLink]` | — | → old key UID | + +This asymmetry is intentional: a manual rekey is an out-of-cycle operator action +(e.g. for incident response), so the operator is expected to re-evaluate the +rotation policy for the new key rather than blindly inheriting the old schedule. + +```bash +# After a manual rekey, re-arm the rotation policy on the new key: +ckms sym keys set-rotation-policy \ + --key-id \ + --interval 3600 \ + --name "hourly" +``` + +--- + +## Implementation roadmap + +This feature is delivered as a cascade of four stacked pull requests, each +building on the previous one: + +```text +develop ← PR 1 ← PR 2 ← PR 3 ← PR 4 +``` + +### PR 1 — Specification + manual rotation for all key types (#968) + +Publish the complete key auto-rotation specification and implement all +manual-rotation flows: + +- Standardise terminology: **Key Rotation** for symmetric/asymmetric + re-keying, **Certificate Renewal** for certificate operations +- `Re-Key` implementation for all six symmetric/asymmetric scenarios +- `Re-Key Key Pair` for all curve types (RSA, EC, ML-KEM, ML-DSA, SLH-DSA, + X25519, secp256k1, CoverCrypt) +- `ReCertify` (KMIP §6.1.45) for self-signed and CA-signed certificate renewal +- Offset-based `PreActive` state for keys/certificates with future activation + dates +- 344 test vectors (non-regression coverage for all flows) + +### PR 2 — Auto-rotation scheduler + deadline detection (#970) + +Background cron that finds due keys and rotates them automatically: + +- `find_due_for_rotation()` DB query → dispatch to the appropriate flow +- Rotation-policy inheritance (interval, name, offset → new key; + `x-rotate-interval = 0` on old key) +- `--auto-rotation-check-interval-secs` server config flag + wizard step +- Approaching-deadline detection (30 / 7 / 1 days before next scheduled + rotation) emitting events via a `Notifier` trait (no-op stub until PR 3) +- OTel counter `kms.key.auto_rotation` on every successful rotation + +### PR 3 — Notification system (SMTP email) (#971) + +First concrete `Notifier` implementation — sends HTML/plain-text emails +via SMTP (`lettre` 0.11): + +- **Events**: `rotation_success`, `rotation_failure`, `approaching_deadline` +- Threshold-based dedup: warning emitted once per threshold per key +- Failures are logged at `warn!` level and never block rotation +- `NotificationsStore` trait backed by SQLite, PostgreSQL, and MySQL +- HTTP API for reading notifications from the UI +- `SmtpConfig` wizard step for notification endpoint setup + +### PR 4 — UI and CLI features (#973) + +Mirror rotation features in the Web UI and `ckms` CLI: + +- `set-rotation-policy` and `get-rotation-policy` subcommands under + `ckms sym keys` +- Re-Key, Set/Get Rotation Policy pages in the Web UI (Symmetric Keys section) +- `NotificationsBell` component with unread count badge and drawer +- Playwright E2E tests for all rotation UI flows diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 784bc75170..a23f8b5317 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -185,5 +185,6 @@ nav: - Mac: kmip_support/_mac.md - Re-Key: kmip_support/_re-key.md - Re-Key Key Pair: kmip_support/_re-key_key_pair.md + - Key Auto-Rotation: kmip_support/key_auto_rotation.md - Revoke: kmip_support/_revoke.md - Sign: kmip_support/_signature.md