From 5876c9ca1f7331be7de9f02554eee4f4b56ed015 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Wed, 27 May 2026 23:13:47 +0200 Subject: [PATCH 01/30] docs: add key auto-rotation specification Add comprehensive specification for scheduled key rotation covering: - 6 rotation scenarios (plain, wrapping, wrapped, asymmetric, CoverCrypt, KEK) - Rotation policy vendor attributes (x-rotate-interval, etc.) - Server-side cron scheduler - KMIP attribute tables (auto vs manual rotation semantics) - Implementation roadmap (5 stacked PRs) Ref: #900 --- CHANGELOG/docs_key-autorotation-spec.md | 6 + README.md | 2 +- .../docs/kmip_support/key_auto_rotation.md | 521 ++++++++++++++++++ documentation/mkdocs.yml | 1 + 4 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG/docs_key-autorotation-spec.md create mode 100644 documentation/docs/kmip_support/key_auto_rotation.md 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/README.md b/README.md index 1d493b5fde..c71c53feaa 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/documentation/docs/kmip_support/key_auto_rotation.md b/documentation/docs/kmip_support/key_auto_rotation.md new file mode 100644 index 0000000000..77c1b809db --- /dev/null +++ b/documentation/docs/kmip_support/key_auto_rotation.md @@ -0,0 +1,521 @@ +# 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. 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"] + PLAIN --> META["Update metadata
(generation++, date, links)"] + WRAP_K --> META + WRAP_D --> META + ASYM --> 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 five stacked pull requests, each +building on the previous one: + +```text +develop ← PR 1 ← PR 2 ← PR 3 ← PR 4 ← PR 5 +``` + +### PR 1 — Specification (this document) + +Publish the complete key auto-rotation specification so reviewers and +subsequent PRs have a stable reference. Standardise terminology: **Key +Rotation** for symmetric/asymmetric re-keying, **Certificate Renewal** for +certificate operations. + +### PR 2 — Manual rotation for all key types + test vectors + +Implement `Re-Key` and `Re-Key Key Pair` for all six scenarios described +in this document: + +1. Plain symmetric key +2. Wrapping key (rotate + re-wrap all dependants) +3. Wrapped key (unwrap → new material → re-wrap) +4. Asymmetric key pair (new private key + new public key UIDs) +5. Wrapped private key / CoverCrypt +6. Server-wide KEK (transparent — validated via test configuration variant) + +All test vectors green at merge time. No auto-rotation scheduler in this PR. + +### PR 3 — Auto-rotation scheduler + deadline detection + +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 4) +- OTel counter `kms.key.auto_rotation` on every successful rotation + +### PR 4 — Notification system (webhooks) + +First concrete `Notifier` implementation — POST JSON to configured URLs: + +- **Events**: `rotation_success`, `rotation_failure`, `approaching_deadline` +- Exponential-backoff retry; failures logged but never block rotation +- Configuration designed as an extensible enum for future sinks (email, + Slack, cloud pub/sub) +- Wizard step for notification endpoint setup + +### PR 5 — UI and CLI features + +Mirror rotation features in the Web UI and `ckms` CLI: + +- Wire existing `SetRotationPolicy` and `KeysReKey` UI components (routes + + menu entries) +- New `GetRotationPolicy` page (display policy + computed next rotation date) +- `ckms sym keys get-rotation-policy` CLI command +- Playwright E2E tests for all rotation UI flows diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 1d2dd43f5f..8bd8f02ea4 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -191,5 +191,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 \ No newline at end of file From 033183820366523d5a56d7a2fcaa9be5d131a9cc Mon Sep 17 00:00:00 2001 From: Manuthor <32013169+Manuthor@users.noreply.github.com> Date: Fri, 29 May 2026 14:56:56 +0200 Subject: [PATCH 02/30] [auto-rotation feature] feat(rekey): Manual rotation for all key types + test vectors (#969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(rekey): implement symmetric key ReKey with wrapping key re-wrap - Implement KMIP ReKey for symmetric keys with name transfer per §4.4 - Support re-wrapping dependent keys when a wrapping key is rekeyed - Add find_wrapped_by() to ObjectsStore trait (SQLite, PostgreSQL, MySQL) - Fix: transfer Name attribute from old to new key during ReKey - Fix: error on self-wrap when wrapping_key_id is user-supplied - Fix: bypass ownership check for server-configured KEK Tested with 37 vector tests (9 symmetric + 27 keypair + 1 security) * fix: consolidate rekey operations using trait * feat: consolidate Recertify operation --- CHANGELOG/feat_key-rotation-manual.md | 55 ++ crate/interfaces/src/stores/objects_store.rs | 15 + crate/kmip/src/kmip_1_4/kmip_operations.rs | 64 +- crate/kmip/src/kmip_2_1/kmip_messages.rs | 6 + crate/kmip/src/kmip_2_1/kmip_operations.rs | 66 ++ crate/server/src/core/kms/kmip.rs | 26 +- .../operations/certify/build_certificate.rs | 2 +- .../src/core/operations/certify/issuer.rs | 2 +- .../server/src/core/operations/certify/mod.rs | 3 + .../core/operations/certify/resolve_issuer.rs | 2 +- .../operations/certify/resolve_subject.rs | 2 +- .../src/core/operations/certify/subject.rs | 4 +- crate/server/src/core/operations/dispatch.rs | 7 +- .../server/src/core/operations/key_ops/mod.rs | 3 + crate/server/src/core/operations/message.rs | 6 + crate/server/src/core/operations/mod.rs | 7 +- crate/server/src/core/operations/recertify.rs | 347 +++++++ crate/server/src/core/operations/rekey.rs | 171 ---- .../src/core/operations/rekey/common.rs | 852 ++++++++++++++++++ .../src/core/operations/rekey/keypair.rs | 453 ++++++++++ crate/server/src/core/operations/rekey/mod.rs | 19 + .../src/core/operations/rekey/symmetric.rs | 194 ++++ .../src/core/operations/rekey_common.rs | 134 --- .../src/core/operations/rekey_keypair.rs | 427 --------- crate/server/src/core/wrapping/wrap.rs | 26 +- .../src/core/database_objects.rs | 18 + crate/server_database/src/stores/sql/mysql.rs | 51 ++ crate/server_database/src/stores/sql/pgsql.rs | 40 + .../server_database/src/stores/sql/sqlite.rs | 57 ++ crate/test_kms_server/README.md | 9 + crate/test_kms_server/src/vector_runner.rs | 108 ++- .../docs/kmip_support/key_auto_rotation.md | 108 ++- 32 files changed, 2492 insertions(+), 792 deletions(-) create mode 100644 CHANGELOG/feat_key-rotation-manual.md create mode 100644 crate/server/src/core/operations/recertify.rs delete mode 100644 crate/server/src/core/operations/rekey.rs create mode 100644 crate/server/src/core/operations/rekey/common.rs create mode 100644 crate/server/src/core/operations/rekey/keypair.rs create mode 100644 crate/server/src/core/operations/rekey/mod.rs create mode 100644 crate/server/src/core/operations/rekey/symmetric.rs delete mode 100644 crate/server/src/core/operations/rekey_common.rs delete mode 100644 crate/server/src/core/operations/rekey_keypair.rs 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/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 105f99ea72..66ad6a2bb6 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 723662c047..5c9613c10a 100644 --- a/crate/server_database/src/stores/sql/pgsql.rs +++ b/crate/server_database/src/stores/sql/pgsql.rs @@ -790,6 +790,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 e7d79b3c77..b18bcd1048 100644 --- a/crate/server_database/src/stores/sql/sqlite.rs +++ b/crate/server_database/src/stores/sql/sqlite.rs @@ -534,6 +534,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 735363af45..9e64dd0da5 100644 --- a/crate/test_kms_server/README.md +++ b/crate/test_kms_server/README.md @@ -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 | @@ -319,6 +325,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 217fbb206b..67ebfc075a 100644 --- a/crate/test_kms_server/src/vector_runner.rs +++ b/crate/test_kms_server/src/vector_runner.rs @@ -3342,34 +3342,6 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/batch_hash_query").await } - // ── KMIP operations: ReCertify ────────────────────────────────────── - - // #[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 - // } - - // #[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 - // } - - // #[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 - // } - - // #[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: ReKey with offset/state ───────────────────────── - #[cfg(feature = "non-fips")] #[tokio::test] async fn test_vec_rekey_keypair_with_offset_state() -> Result<(), KmsClientError> { @@ -3385,6 +3357,29 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/rekey_with_offset_state").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 @@ -3600,6 +3595,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] @@ -3975,11 +4017,11 @@ ObjectType = "SymmetricKey" .await } - // #[tokio::test] - // async fn test_vec_serial_rsa_sign_verify() -> Result<(), KmsClientError> { - // crate::init_test_logging(); - // run_test_vector("test_data/vectors/fips/serialization/rsa_sign_verify_roundtrip").await - // } + #[tokio::test] + async fn test_vec_serial_rsa_sign_verify() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/serialization/rsa_sign_verify_roundtrip").await + } #[tokio::test] async fn test_vec_serial_attributes_preservation() -> Result<(), KmsClientError> { diff --git a/documentation/docs/kmip_support/key_auto_rotation.md b/documentation/docs/kmip_support/key_auto_rotation.md index 77c1b809db..da8b801605 100644 --- a/documentation/docs/kmip_support/key_auto_rotation.md +++ b/documentation/docs/kmip_support/key_auto_rotation.md @@ -258,7 +258,111 @@ ckms sym keys set-rotation-policy \ --- -### 6. Server-wide key-encryption key (KEK) +### 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, @@ -310,10 +414,12 @@ flowchart TD 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 ``` From 732037470ae59786cf10af2fe7ac8c3b74bc9ddd Mon Sep 17 00:00:00 2001 From: Manuthor Date: Fri, 29 May 2026 15:27:08 +0200 Subject: [PATCH 03/30] docs(key-auto-rotation): update implementation roadmap from 5 PRs to 4 (merge spec+manual into PR1) --- .../docs/kmip_support/key_auto_rotation.md | 62 +++++++++---------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/documentation/docs/kmip_support/key_auto_rotation.md b/documentation/docs/kmip_support/key_auto_rotation.md index da8b801605..29953ca525 100644 --- a/documentation/docs/kmip_support/key_auto_rotation.md +++ b/documentation/docs/kmip_support/key_auto_rotation.md @@ -566,35 +566,29 @@ ckms sym keys set-rotation-policy \ ## Implementation roadmap -This feature is delivered as a cascade of five stacked pull requests, each +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 5 +develop ← PR 1 ← PR 2 ← PR 3 ← PR 4 ``` -### PR 1 — Specification (this document) +### PR 1 — Specification + manual rotation for all key types (#968) -Publish the complete key auto-rotation specification so reviewers and -subsequent PRs have a stable reference. Standardise terminology: **Key -Rotation** for symmetric/asymmetric re-keying, **Certificate Renewal** for -certificate operations. +Publish the complete key auto-rotation specification and implement all +manual-rotation flows: -### PR 2 — Manual rotation for all key types + test vectors +- 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) -Implement `Re-Key` and `Re-Key Key Pair` for all six scenarios described -in this document: - -1. Plain symmetric key -2. Wrapping key (rotate + re-wrap all dependants) -3. Wrapped key (unwrap → new material → re-wrap) -4. Asymmetric key pair (new private key + new public key UIDs) -5. Wrapped private key / CoverCrypt -6. Server-wide KEK (transparent — validated via test configuration variant) - -All test vectors green at merge time. No auto-rotation scheduler in this PR. - -### PR 3 — Auto-rotation scheduler + deadline detection +### PR 2 — Auto-rotation scheduler + deadline detection (#970) Background cron that finds due keys and rotates them automatically: @@ -603,25 +597,27 @@ Background cron that finds due keys and rotates them automatically: `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 4) + rotation) emitting events via a `Notifier` trait (no-op stub until PR 3) - OTel counter `kms.key.auto_rotation` on every successful rotation -### PR 4 — Notification system (webhooks) +### PR 3 — Notification system (SMTP email) (#971) -First concrete `Notifier` implementation — POST JSON to configured URLs: +First concrete `Notifier` implementation — sends HTML/plain-text emails +via SMTP (`lettre` 0.11): - **Events**: `rotation_success`, `rotation_failure`, `approaching_deadline` -- Exponential-backoff retry; failures logged but never block rotation -- Configuration designed as an extensible enum for future sinks (email, - Slack, cloud pub/sub) -- Wizard step for notification endpoint setup +- 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 5 — UI and CLI features +### PR 4 — UI and CLI features (#973) Mirror rotation features in the Web UI and `ckms` CLI: -- Wire existing `SetRotationPolicy` and `KeysReKey` UI components (routes + - menu entries) -- New `GetRotationPolicy` page (display policy + computed next rotation date) -- `ckms sym keys get-rotation-policy` CLI command +- `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 From dc9863b614e88cd351c9c67f54686b567de111b7 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 1 Jun 2026 17:29:47 +0200 Subject: [PATCH 04/30] fix: PR review --- CHANGELOG/docs_key-autorotation-spec.md | 21 ++++ crate/kmip/src/kmip_2_1/kmip_operations.rs | 2 +- crate/server/src/core/operations/recertify.rs | 10 +- .../src/core/operations/rekey/common.rs | 16 ++- .../src/stores/redis/redis_with_findex.rs | 52 ++++++++ crate/server_database/src/stores/sql/mysql.rs | 16 +-- crate/server_database/src/stores/sql/pgsql.rs | 16 +-- .../server_database/src/stores/sql/query.sql | 14 +++ .../src/stores/sql/query_mysql.sql | 14 +++ .../server_database/src/stores/sql/sqlite.rs | 1 + crate/test_kms_server/README.md | 6 + crate/test_kms_server/src/vector_runner.rs | 38 ++++++ .../docs/kmip_support/key_auto_rotation.md | 112 +++++++++--------- 13 files changed, 223 insertions(+), 95 deletions(-) diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md index aaf57503b9..0de6e802f9 100644 --- a/CHANGELOG/docs_key-autorotation-spec.md +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -1,3 +1,24 @@ +## Bug Fixes + +- Fix KMIP spec reference: `§4.7` → `§4.8` in `rekey/common.rs` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix KMIP spec reference: `§6.1.8` → `§6.1.45` for `ReCertify` operation ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add ownership check in `rewrap_dependants` to skip keys not owned by the caller ([#968](https://github.com/Cosmian/kms/pull/968)) +- Simplify `relink_keys_to_new_certificate` by passing `old_cert_uid` directly instead of extracting from attributes ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `rewrap_dependants` losing `activation_date` metadata on Redis-findex: use attributes from `retrieve_object` instead of `find_wrapped_by` which fails on wrapped keys ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix KMIP 1.4 XML test cleanup: use Revoke + Destroy(remove:true) to fully purge stale objects from Redis-findex ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Refactor + +- Extract `find-wrapped-by` SQL into `query.sql` and `query_mysql.sql` using `rawsql::Loader` macros ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `PublicKey` variant to SQLite `find_wrapped_by` inline query ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement `find_wrapped_by` for Redis-findex backend ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Testing + +- Add 6 non-regression test vectors for key rotation scenarios: + `rekey_wrapping_key`, `rekey_wrapped_key`, `rekey_wrapping_key_with_links`, + `rekey_wrapping_key_double_chain`, `kek_rekey_wrapped`, `rekey_wrapped_deactivated` ([#968](https://github.com/Cosmian/kms/pull/968)) + ## Documentation - Add key auto-rotation specification document covering all 6 rotation diff --git a/crate/kmip/src/kmip_2_1/kmip_operations.rs b/crate/kmip/src/kmip_2_1/kmip_operations.rs index 177bd5b119..c1bd685d70 100644 --- a/crate/kmip/src/kmip_2_1/kmip_operations.rs +++ b/crate/kmip/src/kmip_2_1/kmip_operations.rs @@ -1039,7 +1039,7 @@ impl_display!(CertifyResponse, "CertifyResponse", { req 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 +/// KMIP 2.1 §6.1.45 / KMIP 1.4 §4.8 #[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq, Debug)] #[serde(rename_all = "PascalCase")] pub struct ReCertify { diff --git a/crate/server/src/core/operations/recertify.rs b/crate/server/src/core/operations/recertify.rs index 364381305c..ced68e277a 100644 --- a/crate/server/src/core/operations/recertify.rs +++ b/crate/server/src/core/operations/recertify.rs @@ -264,6 +264,7 @@ impl RekeyOperation for CertificateRekey { relink_keys_to_new_certificate( kms, user, + &candidate.uid, candidate.owm.attributes(), &replacement.new_uid, &mut operations, @@ -290,16 +291,11 @@ impl RekeyOperation for CertificateRekey { async fn relink_keys_to_new_certificate( kms: &KMS, _user: &str, + old_cert_uid: &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() @@ -307,7 +303,7 @@ async fn relink_keys_to_new_certificate( .collect(); for key_uid in key_uids { - if let Some(op) = relink_single_key(kms, &key_uid, &old_cert_uid, new_cert_uid).await? { + if let Some(op) = relink_single_key(kms, &key_uid, old_cert_uid, new_cert_uid).await? { operations.push(op); } } diff --git a/crate/server/src/core/operations/rekey/common.rs b/crate/server/src/core/operations/rekey/common.rs index abffcd5bcd..97b5ab95b1 100644 --- a/crate/server/src/core/operations/rekey/common.rs +++ b/crate/server/src/core/operations/rekey/common.rs @@ -1,4 +1,4 @@ -//! Shared logic for KMIP `ReKey` (§4.4), `ReKeyKeyPair` (§4.5), and `ReCertify` (§4.7) operations. +//! Shared logic for KMIP `ReKey` (§4.4), `ReKeyKeyPair` (§4.5), and `ReCertify` (§4.8) operations. //! //! All rotation operations follow the same pattern via the [`RekeyOperation`] trait: //! - Validate inputs and resolve candidates for rotation. @@ -773,12 +773,24 @@ async fn rewrap_dependants( .await .unwrap_or_default(); - for (dep_uid, _dep_state, dep_attrs) in wrapped_dependants { + 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; }; + // Security: only re-wrap dependants owned by the caller + if dep_owm.owner() != owner { + warn!( + "skipping re-wrap of dependant {dep_uid}: owned by '{}', not by '{owner}'", + dep_owm.owner() + ); + continue; + } let mut dep_object = dep_owm.object().clone(); + // Use the full metadata attributes from retrieve_object (not from find_wrapped_by) + // because find_wrapped_by may return incomplete attributes for wrapped objects + // (Object::attributes() fails on wrapped keys, losing activation_date etc.) + let dep_attrs = dep_owm.attributes().clone(); if let Some(op) = rewrap_single_dependant(kms, owner, &dep_uid, &mut dep_object, dep_attrs, new_uid) diff --git a/crate/server_database/src/stores/redis/redis_with_findex.rs b/crate/server_database/src/stores/redis/redis_with_findex.rs index 5350fe45d5..661a8a1b8a 100644 --- a/crate/server_database/src/stores/redis/redis_with_findex.rs +++ b/crate/server_database/src/stores/redis/redis_with_findex.rs @@ -622,6 +622,58 @@ impl ObjectsStore for RedisWithFindex { }) .collect()) } + + async fn find_wrapped_by( + &self, + wrapping_key_uid: &str, + user: &str, + ) -> InterfaceResult> { + // Get UIDs owned by the user via Findex + let user_keyword = Keyword::from(user.as_bytes()); + let owned_uids = self + .findex + .search(&user_keyword) + .await + .map_err(|e| db_error!(format!("Error searching owned UIDs: {e:?}")))?; + let mut accessible_uids: HashSet = owned_uids + .iter() + .filter_map(|uid| String::from_utf8(uid.to_vec()).ok()) + .collect(); + + // Add UIDs the user has read access to + let permissions = self + .permission_db + .list_user_permissions(&UserId(user.to_owned())) + .await?; + accessible_uids.extend(permissions.keys().map(|k| k.0.clone())); + + if accessible_uids.is_empty() { + return Ok(vec![]); + } + + // Fetch all accessible objects and filter by wrapping key UID + let redis_db_objects = self.objects_db.objects_get(&accessible_uids).await?; + let mut out = Vec::new(); + for (uid, dbo) in redis_db_objects { + let is_wrapped_by = dbo + .object + .key_wrapping_data() + .and_then(|kwd| kwd.encryption_key_information.as_ref()) + .is_some_and(|eki| eki.unique_identifier.to_string() == wrapping_key_uid); + if is_wrapped_by { + let attrs = dbo + .object + .attributes() + .cloned() + .unwrap_or_else(|_| Attributes { + object_type: Some(dbo.object.object_type()), + ..Default::default() + }); + out.push((uid, dbo.state, attrs)); + } + } + Ok(out) + } } #[async_trait(?Send)] diff --git a/crate/server_database/src/stores/sql/mysql.rs b/crate/server_database/src/stores/sql/mysql.rs index 66ad6a2bb6..d27c160082 100644 --- a/crate/server_database/src/stores/sql/mysql.rs +++ b/crate/server_database/src/stores/sql/mysql.rs @@ -631,20 +631,7 @@ impl ObjectsStore for MySqlPool { 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 sql = get_mysql_query!("find-wrapped-by"); let mut conn = self .pool .get_conn() @@ -662,6 +649,7 @@ impl ObjectsStore for MySqlPool { wrapping_key_uid, wrapping_key_uid, wrapping_key_uid, + wrapping_key_uid, ), ) .await diff --git a/crate/server_database/src/stores/sql/pgsql.rs b/crate/server_database/src/stores/sql/pgsql.rs index 5c9613c10a..1a08c253d9 100644 --- a/crate/server_database/src/stores/sql/pgsql.rs +++ b/crate/server_database/src/stores/sql/pgsql.rs @@ -797,21 +797,7 @@ impl ObjectsStore for PgPool { 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 sql = get_pgsql_query!("find-wrapped-by"); let rows = client .query(sql, &[&wrapping_key_uid, &user]) .await diff --git a/crate/server_database/src/stores/sql/query.sql b/crate/server_database/src/stores/sql/query.sql index a3faf14737..2e0253c4b2 100644 --- a/crate/server_database/src/stores/sql/query.sql +++ b/crate/server_database/src/stores/sql/query.sql @@ -138,3 +138,17 @@ ON objects.id = matched_tags.id; -- name: select-uids-from-tags SELECT id FROM tags WHERE tag IN (@TAGS) GROUP BY id HAVING COUNT(DISTINCT tag) = @LEN; + +-- name: find-wrapped-by +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->'PublicKey'->'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 + ); diff --git a/crate/server_database/src/stores/sql/query_mysql.sql b/crate/server_database/src/stores/sql/query_mysql.sql index ff41c6ddbb..c17d5d3876 100644 --- a/crate/server_database/src/stores/sql/query_mysql.sql +++ b/crate/server_database/src/stores/sql/query_mysql.sql @@ -181,3 +181,17 @@ FROM tags WHERE tag IN (@TAGS) GROUP BY id HAVING COUNT(DISTINCT tag) = ?; + +-- name: find-wrapped-by +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->>'$.PublicKey.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' = ? + ); diff --git a/crate/server_database/src/stores/sql/sqlite.rs b/crate/server_database/src/stores/sql/sqlite.rs index b18bcd1048..669ba18997 100644 --- a/crate/server_database/src/stores/sql/sqlite.rs +++ b/crate/server_database/src/stores/sql/sqlite.rs @@ -551,6 +551,7 @@ impl ObjectsStore for SqlitePool { 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, '$.PublicKey.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 \ diff --git a/crate/test_kms_server/README.md b/crate/test_kms_server/README.md index 9e64dd0da5..006f348f43 100644 --- a/crate/test_kms_server/README.md +++ b/crate/test_kms_server/README.md @@ -172,6 +172,10 @@ replays the steps sequentially. | 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 | +| KMIP Operations | `rekey_wrapping_key` | Create wrapping key, Create wrapped dep, ReKey wrapping key (re-wraps dep), Encrypt dep, GetAttributes | 11 | +| KMIP Operations | `rekey_wrapped_key` | Create wrapping key, Create wrapped key, ReKey wrapped key (unwrap→new material→re-wrap), Encrypt, GetAttributes | 11 | +| KMIP Operations | `rekey_wrapping_key_with_links` | Create wrapping key + 2 deps, ReKey, verify links + both deps encrypt | 16 | +| KMIP Operations | `rekey_wrapping_key_double_chain` | Create wrapping key K0 + 2 deps, ReKey×2 (K0→K1→K2), verify full link chain + deps encrypt | 22 | | KMIP Operations | `rekey_keypair_ec` | CreateKeyPair (EC P-256), ReKeyKeyPair, Revoke+Destroy | 5 | | KMIP Operations | `rekey_keypair_rsa` | CreateKeyPair (RSA-2048), ReKeyKeyPair, Revoke+Destroy | 5 | | KMIP Operations | `rekey_keypair_rsa4096` | CreateKeyPair (RSA-4096), ReKeyKeyPair, Revoke+Destroy | 5 | @@ -223,6 +227,7 @@ replays the steps sequentially. | HSM / KEK Create | `hsm/kek_rsa2048_create_sign` | CreateKeyPair (RSA-2048, KEK-wrapped), Sign, Destroy ×2 | 3 | | HSM / KEK Create | `hsm/kek_ec_p256_create_sign` | CreateKeyPair (EC P-256, KEK-wrapped), Sign, Destroy ×2 | 3 | | HSM / KEK Create | `hsm/kek_ed25519_create_sign` | CreateKeyPair (Ed25519, KEK-wrapped), Sign, Destroy ×2 | 3 | +| HSM / KEK ReKey | `hsm/kek_rekey_wrapped` | Create (AES-256, KEK-wrapped), Encrypt, ReKey (unwrap from KEK, new material, re-wrap), Encrypt new, GetAttributes | 9 | | HSM / KEK Negative | `hsm/kek_rsa1024_rejected` | CreateKeyPair (RSA-1024, KEK-wrapped) → FIPS rejection | 1 | | HSM / Resident Create | `hsm/resident_aes128_create_encrypt` | Create (AES-128, HSM-resident), Encrypt, Decrypt, Destroy | 4 | | HSM / Resident Create | `hsm/resident_aes256_create_encrypt` | Create (AES-256, HSM-resident), Encrypt, Decrypt, Destroy | 4 | @@ -328,6 +333,7 @@ replays the steps sequentially. | 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 | +| Negative / ReKey | `negative/rekey_wrapped_deactivated` | Create wrapping key + wrapped key, Revoke wrapped, ReKey → fails (deactivated) | 7 | | **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 67ebfc075a..04525e0e37 100644 --- a/crate/test_kms_server/src/vector_runner.rs +++ b/crate/test_kms_server/src/vector_runner.rs @@ -1630,6 +1630,32 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/rekey_kmip14").await } + #[tokio::test] + async fn test_vec_rekey_wrapping_key() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_wrapping_key").await + } + + #[tokio::test] + async fn test_vec_rekey_wrapped_key() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_wrapped_key").await + } + + #[tokio::test] + async fn test_vec_rekey_wrapping_key_with_links() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_wrapping_key_with_links") + .await + } + + #[tokio::test] + async fn test_vec_rekey_wrapping_key_double_chain() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_wrapping_key_double_chain") + .await + } + #[cfg(feature = "non-fips")] #[tokio::test] async fn test_vec_rekey_keypair_kmip14() -> Result<(), KmsClientError> { @@ -3380,6 +3406,12 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/negative/recertify_not_a_certificate").await } + #[tokio::test] + async fn test_neg_rekey_wrapped_deactivated() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/rekey_wrapped_deactivated").await + } + // ── KMIP operations: ReKeyKeyPair (non-FIPS only) ──────────────────── // These vectors do not supply PrivateKeyAttributes/PublicKeyAttributes with // FIPS-compliant CryptographicUsageMask values, and some use PQC algorithms @@ -3778,6 +3810,12 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/hsm/kek_ed25519_create_sign").await } + #[tokio::test] + async fn test_vec_hsm_kek_rekey_wrapped() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/kek_rekey_wrapped").await + } + #[tokio::test] #[cfg(not(feature = "non-fips"))] async fn test_vec_hsm_kek_rsa1024_rejected() -> Result<(), KmsClientError> { diff --git a/documentation/docs/kmip_support/key_auto_rotation.md b/documentation/docs/kmip_support/key_auto_rotation.md index 29953ca525..e896de58c8 100644 --- a/documentation/docs/kmip_support/key_auto_rotation.md +++ b/documentation/docs/kmip_support/key_auto_rotation.md @@ -13,13 +13,13 @@ periodically which keys are overdue and rotates them automatically. 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. | +| 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. @@ -267,14 +267,14 @@ 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 | +| 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 @@ -318,29 +318,29 @@ sequenceDiagram #### 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 | +| 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) | +| 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 @@ -522,33 +522,33 @@ 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 | +| 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 | +| 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 From fb08e2cdf345227ab94e5d0a2a573fa2af83c1c1 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 1 Jun 2026 21:09:31 +0200 Subject: [PATCH 05/30] chore: update test_data repo From c1905213b6779daa81559f7adc49f4d855952d92 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 1 Jun 2026 21:40:34 +0200 Subject: [PATCH 06/30] chore: update test_data repo From 74b73f3ae42f33975139738c9c302385373d90b1 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Wed, 3 Jun 2026 20:35:08 +0200 Subject: [PATCH 07/30] fix: PR review --- crate/kmip/src/kmip_2_1/kmip_operations.rs | 2 +- documentation/docs/kmip_support/attributes.md | 4 +- .../docs/kmip_support/introduction/index.md | 2 +- .../docs/kmip_support/key_auto_rotation.md | 112 +++++++++--------- documentation/docs/kmip_support/operations.md | 2 +- 5 files changed, 61 insertions(+), 61 deletions(-) diff --git a/crate/kmip/src/kmip_2_1/kmip_operations.rs b/crate/kmip/src/kmip_2_1/kmip_operations.rs index c1bd685d70..e7f8d3135f 100644 --- a/crate/kmip/src/kmip_2_1/kmip_operations.rs +++ b/crate/kmip/src/kmip_2_1/kmip_operations.rs @@ -707,7 +707,7 @@ pub enum InteropFunction { /// `OperationUndone` in the response, potentially including only the Unique Identifier /// in the payload as per the KMIP profiles. /// -/// Reference: #_`Toc6497533L` +/// Reference: #[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)] #[serde(rename_all = "PascalCase")] pub struct Check { diff --git a/documentation/docs/kmip_support/attributes.md b/documentation/docs/kmip_support/attributes.md index 2b79bbd56c..37fa7bee51 100644 --- a/documentation/docs/kmip_support/attributes.md +++ b/documentation/docs/kmip_support/attributes.md @@ -1,4 +1,4 @@ -In [chapter 4](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/cs01/kmip-spec-v2.1-cs01.html#_Toc32239322), the KMIP 2.1 specification specifies a list of 63 Attributes, mostly made of enumerations and data structures, often nested in each other. Despite this impressive list, and as expected in such a large specification, KMIP allows for extensions to support new cryptographic schemes such as the ones enabled by Eviden. +In [chapter 4](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html#_Toc57115559), the KMIP 2.1 specification specifies a list of 63 Attributes, mostly made of enumerations and data structures, often nested in each other. Despite this impressive list, and as expected in such a large specification, KMIP allows for extensions to support new cryptographic schemes such as the ones enabled by Eviden. Extensions in KMIP consist mostly in augmenting enumerations with new values and attributing a specific prefix values, usually `0x8880` to the new variants. @@ -41,7 +41,7 @@ CoverCrypt = 0x8880_0004, #### Vendor Attributes -All keys managed by the Eviden KMS server are primarily a `KeyMaterial` made of bytes. Some keys, typically those of ABE, also carry information regarding the underlying access policies. This information is carried together with the keys using [VendorAttributes](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/cs01/kmip-spec-v2.1-cs01.html#_Toc32239382) +All keys managed by the Eviden KMS server are primarily a `KeyMaterial` made of bytes. Some keys, typically those of ABE, also carry information regarding the underlying access policies. This information is carried together with the keys using [VendorAttributes](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html#_Toc57115619) Typically a vendor attribute is made of 3 values: a `Vendor Identification` - set to the server's configured vendor ID (default: `cosmian`, configurable via `--vendor-identification` / `KMS_VENDOR_IDENTIFICATION`) - and a tuple `Attribute Name`, `Attribute Value`. diff --git a/documentation/docs/kmip_support/introduction/index.md b/documentation/docs/kmip_support/introduction/index.md index 4d7da90ea2..a6e76e574f 100644 --- a/documentation/docs/kmip_support/introduction/index.md +++ b/documentation/docs/kmip_support/introduction/index.md @@ -22,7 +22,7 @@ and encryption clients. Internally, all KMIP messages are translated to KMIP 2.1 specifications and converted back to KMIP 1.x when necessary. The Eviden KMS server implements a targeted subset of -the [KMIP 2.1 protocol](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/cs01/kmip-spec-v2.1-cs01.html). +the [KMIP 2.1 protocol](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html). ## Purpose of KMIP diff --git a/documentation/docs/kmip_support/key_auto_rotation.md b/documentation/docs/kmip_support/key_auto_rotation.md index e896de58c8..7ad4ec42ab 100644 --- a/documentation/docs/kmip_support/key_auto_rotation.md +++ b/documentation/docs/kmip_support/key_auto_rotation.md @@ -13,13 +13,13 @@ periodically which keys are overdue and rotates them automatically. 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. | +| Attribute | Type | Description | +|---|---|---| +| `x-rotate-interval` | `i32` (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` | `i32` (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. @@ -267,14 +267,14 @@ 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 | +| 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#_Toc57115677) | 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 @@ -318,29 +318,29 @@ sequenceDiagram #### 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 | +| 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) | +| 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 @@ -522,33 +522,33 @@ 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 | +| 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 | +| 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 diff --git a/documentation/docs/kmip_support/operations.md b/documentation/docs/kmip_support/operations.md index 957e9d3ad6..6d19944a4b 100644 --- a/documentation/docs/kmip_support/operations.md +++ b/documentation/docs/kmip_support/operations.md @@ -1,4 +1,4 @@ -In [chapter 6](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/cs01/kmip-spec-v2.1-cs01.html#_Toc32239394), the KMIP 2.1 +In [chapter 6](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html#_Toc57115631), the KMIP 2.1 specifications describe 57 potential operations that can be performed on a KMS. ### Supported Operations From 0df8407fc251c471a37a0de69da42add9a6394a2 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Thu, 4 Jun 2026 15:47:32 +0200 Subject: [PATCH 08/30] fix: doc reviewed --- .../docs/kmip_support/key_auto_rotation.md | 198 +++++++++++------- test_data | 2 +- 2 files changed, 121 insertions(+), 79 deletions(-) diff --git a/documentation/docs/kmip_support/key_auto_rotation.md b/documentation/docs/kmip_support/key_auto_rotation.md index 7ad4ec42ab..917b861542 100644 --- a/documentation/docs/kmip_support/key_auto_rotation.md +++ b/documentation/docs/kmip_support/key_auto_rotation.md @@ -2,7 +2,7 @@ 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 +`Re-Key`, `Re-Key Key Pair` or `ReCertify` 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. @@ -13,16 +13,39 @@ periodically which keys are overdue and rotates them automatically. 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` | `i32` (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` | `i32` (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. | +| Attribute | Type | Description | Mutable | +| --------------------- | --------------- | ----------------------------------------------------------------------------------------------------------- | ------- | +| `x-rotate-interval` | `i64` (seconds) | How often this key should be rotated. `0` disables auto-rotation. | Yes | +| `x-rotate-name` | `String` | Optional human-readable label for the policy (e.g. `"daily"`, `"annual"`). | Yes | +| `x-rotate-offset` | `i64` (seconds) | Shift the first rotation trigger by this many seconds after `Initial Date`. | Yes | +| `x-rotate-generation` | `u64` | Incremented on every rotation; `0` for never-rotated keys. **Server-managed, read-only.** | No | +| `x-rotate-date` | `datetime` | Timestamp of the last rotation; populated automatically after each rotation. **Server-managed, read-only.** | No | + +> **Read-only attributes:** `x-rotate-generation` and `x-rotate-date` are set +> exclusively by the server during the `Re-Key` operation. Any attempt to +> modify them via `AddAttribute`, `SetAttribute`, `ModifyAttribute`, or +> `DeleteAttribute` will be rejected with `Attribute_Read_Only`. +> +> These restrictions preserve two invariants that the scheduler and the +> rotation link-chain logic rely on: +> +> - **Monotonically increasing counter** — `x-rotate-generation` starts at `0` +> for a never-rotated key and is incremented by exactly `1` on each successful +> rotation. Within a key-set (keys linked via `ReplacedObjectLink` / +> `ReplacementObjectLink`), the generation number is therefore unique and +> strictly increasing, which lets the scheduler and client tooling identify +> the *current* key in a chain without inspecting every member. +> - **Authoritative last-rotation timestamp** — `x-rotate-date` is the only +> reliable source for "when was this key last rotated". The scheduler's +> `is_due_for_rotation` function computes the next trigger as +> `x-rotate-date + x-rotate-interval` (for previously-rotated keys) or as +> `initial_date + x-rotate-offset + x-rotate-interval` (for never-rotated +> keys with an initial date). Any external modification to `x-rotate-date` +> would cause the scheduler to fire too early, skip a scheduled rotation, or +> produce an inconsistent link chain. Use the `SetAttribute` KMIP operation (or the `ckms sym keys set-rotation-policy` -CLI command) to configure these attributes on an existing key. +CLI command) to configure the mutable attributes on an existing key. ```bash # Rotate the key every hour starting from its Initial Date @@ -34,21 +57,45 @@ ckms sym keys set-rotation-policy \ --- -> **⚠️ HSM-resident keys cannot be auto-rotated** +> **⚠️ HSM-resident keys cannot be auto-rotated via KMIP** +> +> Keys whose UID starts with `hsm::` (e.g. `hsm::softhsm2::0::my-kek`) are +> managed by a PKCS#11-capable Hardware Security Module. The KMS server *can* +> generate new HSM key material by calling `C_GenerateKey` / `C_GenerateKeyPair` +> on the HSM, but it cannot perform the full KMIP `Re-Key` / `Re-Key Key Pair` +> operation on them for two reasons: +> +> 1. **No KMIP attribute storage** — KMIP vendor attributes such as +> `x-rotate-interval`, `x-rotate-generation`, and `x-rotate-date` are stored +> in the KMS SQL metadata column. HSM objects live exclusively inside the +> HSM and have no corresponding SQL row, so rotation metadata cannot be +> attached to them. +> 2. **Non-extractable key material** — HSM keys are typically created with +> `CKA_SENSITIVE = true` and `CKA_EXTRACTABLE = false`, meaning the raw key +> bytes can never leave the hardware boundary. The KMIP re-key pipeline +> unwraps and re-wraps dependant keys in software, which is impossible for +> non-extractable HSM keys. > -> 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. +> - `find_due_for_rotation` **never** returns HSM UIDs — the HSM object store +> returns an empty list, so the scheduler skips them entirely. +> - Calling `Re-Key` or `Re-Key Key Pair` on an `hsm::` UID is explicitly +> **rejected** by the server with a `Not Supported` error. +> - Setting `x-rotate-interval` on an `hsm::` UID has no effect because there is +> no attribute row to update. > -> 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. +> **HSM key lifecycle management** should instead use PKCS#11-native mechanisms: +> +> | Mechanism | Description | +> |---|---| +> | `CKA_START_DATE` / `CKA_END_DATE` | Encode a validity period directly on the HSM object | +> | `pkcs11-tool --keygen` | Generate a new HSM key with `pkcs11-tool` | +> | `softhsm2-util`, Utimaco console, etc. | Vendor administration tools for slot / key lifecycle | +> +> After generating a new HSM key with vendor tools, register it with the KMS +> server by creating a KMIP `SymmetricKey` object whose UID uses the +> `hsm::::::` format. --- @@ -232,18 +279,13 @@ sequenceDiagram --- -### 5. Wrapped private key (CoverCrypt) +### 5. Wrapped private key (Covercrypt) -A **CoverCrypt** private key that has been wrapped follows the same flow as any +A **Covercrypt** master private key and user decryption key that have 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 +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. @@ -267,14 +309,14 @@ 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#_Toc57115677) | 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 | +| 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#_Toc57115677) | 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 @@ -318,29 +360,29 @@ sequenceDiagram #### 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 | +| 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) | +| 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 @@ -522,33 +564,33 @@ 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 | +| 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 | +| 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 @@ -582,7 +624,7 @@ manual-rotation flows: 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) + 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 diff --git a/test_data b/test_data index 47c9a06b99..d33e8e14f1 160000 --- a/test_data +++ b/test_data @@ -1 +1 @@ -Subproject commit 47c9a06b99f25439c70111d9bec7b67b2377527c +Subproject commit d33e8e14f10e10489a2d97d65dd350f931dc058c From 6aafb92b55fdaafd658de10be78cad0df55b9c23 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Thu, 4 Jun 2026 19:03:14 +0200 Subject: [PATCH 09/30] fix: ReCertify KMIP 1.4 bad signature, remove useless privileged_user, deny modify rotation attributes, update bad KMIP spec refs, offset becomes i64 --- CHANGELOG/docs_key-autorotation-spec.md | 59 +++++- CHANGELOG/feat_key-rotation-manual.md | 55 ------ Cargo.lock | 2 + .../symmetric/keys/get_rotation_policy.rs | 21 +++ .../clap/src/actions/symmetric/keys/mod.rs | 15 +- .../symmetric/keys/set_rotation_policy.rs | 29 +++ .../src/configurable_kem_utils.rs | 2 +- .../client_utils/src/cover_crypt_utils.rs | 1 + crate/clients/wasm/src/wasm.rs | 94 +++++++++- crate/interfaces/Cargo.toml | 1 + crate/interfaces/src/stores/objects_store.rs | 13 ++ crate/kmip/src/kmip_1_4/kmip_operations.rs | 51 ++--- crate/kmip/src/kmip_2_1/kmip_attributes.rs | 18 +- crate/kmip/src/kmip_2_1/kmip_objects.rs | 9 + crate/kmip/src/kmip_2_1/kmip_operations.rs | 10 +- .../src/kmip_2_1/requests/create_key_pair.rs | 4 +- .../src/config/command_line/clap_config.rs | 10 + .../server/src/config/params/server_params.rs | 9 + .../cover_crypt/create_user_decryption_key.rs | 5 +- .../server/src/core/cover_crypt/rekey_keys.rs | 174 +++++------------- crate/server/src/core/kms/kmip.rs | 67 ++----- .../server/src/core/kms/other_kms_methods.rs | 3 - crate/server/src/core/kms/permissions.rs | 19 +- .../src/core/operations/attributes/add.rs | 12 ++ .../src/core/operations/attributes/delete.rs | 26 ++- .../src/core/operations/attributes/modify.rs | 8 +- .../src/core/operations/attributes/set.rs | 26 ++- .../server/src/core/operations/auto_rotate.rs | 44 +++++ .../src/core/operations/certify/certify_op.rs | 9 +- .../operations/certify/resolve_subject.rs | 30 +-- crate/server/src/core/operations/create.rs | 46 +---- .../src/core/operations/create_key_pair.rs | 37 +--- crate/server/src/core/operations/dispatch.rs | 37 ++-- crate/server/src/core/operations/import.rs | 25 +-- .../server/src/core/operations/key_ops/mod.rs | 89 ++++++++- crate/server/src/core/operations/mac.rs | 1 - crate/server/src/core/operations/message.rs | 18 +- crate/server/src/core/operations/mod.rs | 2 + crate/server/src/core/operations/recertify.rs | 26 ++- crate/server/src/core/operations/register.rs | 32 +--- .../src/core/operations/rekey/common.rs | 94 +++++----- .../src/core/operations/rekey/keypair.rs | 51 ++--- crate/server/src/core/operations/rekey/mod.rs | 5 +- .../src/core/operations/rekey/symmetric.rs | 53 +++--- crate/server/src/core/wrapping/wrap.rs | 20 +- crate/server/src/cron.rs | 48 ++++- crate/server/src/main.rs | 1 + crate/server/src/routes/access.rs | 19 +- .../server/src/routes/aws_xks/key_metadata.rs | 3 +- crate/server/src/routes/crypto/keys.rs | 16 +- crate/server/src/routes/crypto/unwrap.rs | 2 +- crate/server/src/start_kms_server.rs | 27 ++- .../src/tests/azure_ekm/integration_tests.rs | 9 +- .../src/tests/bulk_encrypt_decrypt_tests.rs | 4 +- .../cover_crypt_tests/integration_tests.rs | 2 +- .../integration_tests_bulk.rs | 2 +- .../integration_tests_tags.rs | 4 +- .../src/tests/cover_crypt_tests/unit_tests.rs | 24 +-- crate/server/src/tests/curve_25519_tests.rs | 6 +- crate/server/src/tests/derive_key_tests.rs | 14 +- crate/server/src/tests/google_cse/mod.rs | 30 ++- crate/server/src/tests/health_endpoint.rs | 4 +- crate/server/src/tests/hsm/issues.rs | 2 +- crate/server/src/tests/hsm/mod.rs | 2 +- crate/server/src/tests/hsm/permissions.rs | 4 +- crate/server/src/tests/kmip_endpoints.rs | 4 +- crate/server/src/tests/kmip_messages.rs | 4 +- crate/server/src/tests/kmip_policy/basic.rs | 14 +- .../server/src/tests/kmip_policy/e2e_ecies.rs | 6 +- .../tests/kmip_policy/e2e_export_wrapping.rs | 14 +- .../src/tests/kmip_policy/e2e_signature.rs | 2 +- crate/server/src/tests/kmip_policy/helpers.rs | 2 +- .../server/src/tests/kmip_policy/overrides.rs | 2 +- crate/server/src/tests/kmip_server_tests.rs | 14 +- crate/server/src/tests/locate.rs | 4 +- crate/server/src/tests/ms_dke/mod.rs | 2 +- .../src/tests/rest_crypto/encrypt_decrypt.rs | 6 +- .../src/tests/rest_crypto/error_cases.rs | 14 +- .../src/tests/rest_crypto/jose_vectors.rs | 2 +- crate/server/src/tests/rest_crypto/mac.rs | 2 +- .../src/tests/rest_crypto/rfc_vectors.rs | 8 +- .../src/tests/rest_crypto/sign_verify.rs | 4 +- crate/server/src/tests/rest_crypto/unwrap.rs | 12 +- crate/server/src/tests/secret_data_tests.rs | 10 +- crate/server/src/tests/security_regression.rs | 24 +-- crate/server/src/tests/test_sign.rs | 4 +- crate/server/src/tests/test_utils.rs | 5 - crate/server/src/tests/test_validate.rs | 11 +- .../src/tests/ttlv_tests/integrations/vast.rs | 6 +- crate/server_database/Cargo.toml | 1 + .../src/core/database_objects.rs | 11 ++ crate/server_database/src/stores/mod.rs | 8 +- .../src/stores/redis/redis_with_findex.rs | 5 +- .../src/stores/sql/locate_query.rs | 48 +++++ crate/server_database/src/stores/sql/mysql.rs | 101 ++++++++-- crate/server_database/src/stores/sql/pgsql.rs | 116 ++++++++++-- .../server_database/src/stores/sql/query.sql | 29 +-- .../src/stores/sql/query_mysql.sql | 46 +++-- .../server_database/src/stores/sql/sqlite.rs | 169 +++++++++++++---- crate/test_kms_server/src/test_server.rs | 4 +- crate/test_kms_server/src/vector_runner.rs | 40 ---- scripts/generate_rekey_vectors.sh | 2 +- ui/src/App.tsx | 12 +- ui/src/menuItems.tsx | 5 +- 104 files changed, 1401 insertions(+), 957 deletions(-) delete mode 100644 CHANGELOG/feat_key-rotation-manual.md create mode 100644 crate/clients/clap/src/actions/symmetric/keys/get_rotation_policy.rs create mode 100644 crate/clients/clap/src/actions/symmetric/keys/set_rotation_policy.rs create mode 100644 crate/server/src/core/operations/auto_rotate.rs diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md index 0de6e802f9..556db24623 100644 --- a/CHANGELOG/docs_key-autorotation-spec.md +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -1,14 +1,57 @@ +## Features + +- Implement KMIP ReKey operation for symmetric keys with name transfer per §4.4 ([#968](https://github.com/Cosmian/kms/pull/968)) +- Support re-wrapping of dependent keys when a wrapping key is rekeyed ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `find_wrapped_by()` method to `ObjectsStore` trait (SQLite, PostgreSQL, MySQL implementations) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement KMIP `ReCertify` operation (§4.8) — certificate rotation with new UID and replacement links ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add proper `ReCertify` and `ReCertifyResponse` KMIP 2.1 types compliant with both KMIP 1.x and 2.x ([#968](https://github.com/Cosmian/kms/pull/968)) +- Introduce `RekeyOperation` trait to unify symmetric, keypair, and certificate rotation logic ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `offset` field to `ReCertify` struct per KMIP 2.1 §6.1.45 for date-based activation scheduling ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Security + +- Mark `x-rotate-generation` and `x-rotate-date` as server-managed read-only attributes: reject user modifications via AddAttribute, SetAttribute, ModifyAttribute, and DeleteAttribute ([#968](https://github.com/Cosmian/kms/pull/968)) +- Reject `Re-Key` and `Re-Key Key Pair` on HSM-managed keys (`hsm::` UID prefix) with an explicit `Not Supported` error instead of silently failing deep in the pipeline ([#968](https://github.com/Cosmian/kms/pull/968)) + ## Bug Fixes +- Fix KMIP lifecycle semantics: restore correct `setup_object_lifecycle` behavior — past `activation_date` → Active, `None` → PreActive ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add explicit `activation_date: Some(now)` to all request builders and test helpers requiring immediate Active state ([#968](https://github.com/Cosmian/kms/pull/968)) - Fix KMIP spec reference: `§4.7` → `§4.8` in `rekey/common.rs` ([#968](https://github.com/Cosmian/kms/pull/968)) - Fix KMIP spec reference: `§6.1.8` → `§6.1.45` for `ReCertify` operation ([#968](https://github.com/Cosmian/kms/pull/968)) - Add ownership check in `rewrap_dependants` to skip keys not owned by the caller ([#968](https://github.com/Cosmian/kms/pull/968)) - Simplify `relink_keys_to_new_certificate` by passing `old_cert_uid` directly instead of extracting from attributes ([#968](https://github.com/Cosmian/kms/pull/968)) - Fix `rewrap_dependants` losing `activation_date` metadata on Redis-findex: use attributes from `retrieve_object` instead of `find_wrapped_by` which fails on wrapped keys ([#968](https://github.com/Cosmian/kms/pull/968)) - Fix KMIP 1.4 XML test cleanup: use Revoke + Destroy(remove:true) to fully purge stale objects from Redis-findex ([#968](https://github.com/Cosmian/kms/pull/968)) +- Transfer `Name` attribute from old key to new key during ReKey per KMIP §4.4 ([#968](https://github.com/Cosmian/kms/pull/968)) +- Return error instead of silently skipping when a user-supplied wrapping key ID equals the key being wrapped ([#968](https://github.com/Cosmian/kms/pull/968)) +- Bypass ownership check for server-configured KEK during wrapping operations ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix symmetric ReKey missing server-wide KEK wrapping and unwrapped-cache insert ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix keypair rekey not preserving WrappingKeyLink on replacement keys ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix symmetric rekey hardcoding `State::Active` — now uses `setup_object_lifecycle` for date-based state computation ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `setup_object_lifecycle` not storing `activation_date` for `PreActive` keys — offset-based activation scheduling now works correctly ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `ReCertify` request/response deserialization to KMIP 2.1 message handler ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `ReCertify.generate_replacement` passing empty user to `get_subject`/`get_issuer` — use certificate owner instead ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `ReCertify` not computing lifecycle state from offset — certificates with future activation_date are now `PreActive` ([#968](https://github.com/Cosmian/kms/pull/968)) ## Refactor +- Reorganize ReKey modules into `rekey/` folder: `mod.rs`, `symmetric.rs`, `keypair.rs`, `common.rs`; move `ReCertify` handler to `operations/recertify.rs` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract `RekeyOperation` trait into `common.rs` with `execute_rekey()` orchestrator — shared 2-phase commit logic ([#968](https://github.com/Cosmian/kms/pull/968)) +- 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` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `KeyRetirement` struct + `finalize_rekey` function in `common.rs` — shared Phase 2 logic ([#968](https://github.com/Cosmian/kms/pull/968)) +- Move `compute_rotation_uid` and `rewrap_dependants` from `symmetric.rs` to `common.rs` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Convert `ReKeyKeyPair` to 2-phase commit (matching symmetric) to support dependant re-wrapping on public keys ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add default implementations to `RekeyOperation` trait for `detect_wrapping`, `persist_new_key`, `finalize_dependants`, and `rewrap_new_objects` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract `extract_rewrap_spec`, `extract_wrapping_key_uid`, and `retrieve_eligible_keys` into `common.rs` as shared helpers ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract shared `validate_no_crypto_param_change` into `common.rs` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Refactor `prepare_attributes` in `keypair.rs` — extract `finalize_replacement_key` helper ([#968](https://github.com/Cosmian/kms/pull/968)) +- Move `setup_new_key` and `finalize_replacement_key` from keypair.rs to common.rs ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract `preserve_wrapping_key_link` into common.rs ([#968](https://github.com/Cosmian/kms/pull/968)) +- Split `rewrap_dependants` (70→25 lines) by extracting `rewrap_single_dependant` helper ([#968](https://github.com/Cosmian/kms/pull/968)) +- Split `relink_keys_to_new_certificate` by extracting `relink_single_key` helper ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract `enforce_create_permission` and `reject_protection_storage_masks` shared helpers into `key_ops` module ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract `find-due-for-rotation` SQL into `query.sql` and `query_mysql.sql` using `rawsql::Loader` macros ([#968](https://github.com/Cosmian/kms/pull/968)) - Extract `find-wrapped-by` SQL into `query.sql` and `query_mysql.sql` using `rawsql::Loader` macros ([#968](https://github.com/Cosmian/kms/pull/968)) - Add `PublicKey` variant to SQLite `find_wrapped_by` inline query ([#968](https://github.com/Cosmian/kms/pull/968)) - Implement `find_wrapped_by` for Redis-findex backend ([#968](https://github.com/Cosmian/kms/pull/968)) @@ -18,10 +61,24 @@ - Add 6 non-regression test vectors for key rotation scenarios: `rekey_wrapping_key`, `rekey_wrapped_key`, `rekey_wrapping_key_with_links`, `rekey_wrapping_key_double_chain`, `kek_rekey_wrapped`, `rekey_wrapped_deactivated` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 9 symmetric ReKey test vectors (basic, wrapped, wrapping-key re-wrap, name transfer, offset, links) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 27 ReKeyKeyPair test vectors (RSA, EC, ML-KEM, ML-DSA, SLH-DSA, X25519, secp256k1) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add Covercrypt ReKeyKeyPair test vector (in-place attribute rekey with same UIDs) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add access privilege escalation test vector for ReKey ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 4 ReCertify test vectors (self-signed, chain, with-links, with-offset) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 3 negative ReCertify test vectors (missing UID, non-existent, not a certificate) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 2 offset state verification vectors (rekey + rekey-keypair: Offset=0 → Active, Offset=86400 → PreActive) ([#968](https://github.com/Cosmian/kms/pull/968)) ## 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. + server-side scheduler, KMIP attribute tables, and implementation roadmap ([#968](https://github.com/Cosmian/kms/pull/968)) +- Correct HSM key rotation section: the KMS cannot use KMIP `Re-Key` on + HSM-managed keys (no SQL attribute storage, non-extractable key material); + use PKCS#11 vendor tools instead ([#968](https://github.com/Cosmian/kms/pull/968)) +- Document `x-rotate-generation` and `x-rotate-date` invariants: monotonically + increasing counter unique within a key-set, authoritative last-rotation + timestamp relied on by `is_due_for_rotation` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add Certificate Renewal (ReCertify) section to key_auto_rotation.md with RFC references ([#968](https://github.com/Cosmian/kms/pull/968)) diff --git a/CHANGELOG/feat_key-rotation-manual.md b/CHANGELOG/feat_key-rotation-manual.md deleted file mode 100644 index 7df17878cb..0000000000 --- a/CHANGELOG/feat_key-rotation-manual.md +++ /dev/null @@ -1,55 +0,0 @@ -## 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/Cargo.lock b/Cargo.lock index d5e9fc4a1d..fe15670a0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1313,6 +1313,7 @@ dependencies = [ "num-bigint-dig", "serde_json", "thiserror 2.0.17", + "time", "zeroize", ] @@ -1403,6 +1404,7 @@ dependencies = [ "strum", "tempfile", "thiserror 2.0.17", + "time", "tokio", "tokio-postgres", "tokio-rusqlite", diff --git a/crate/clients/clap/src/actions/symmetric/keys/get_rotation_policy.rs b/crate/clients/clap/src/actions/symmetric/keys/get_rotation_policy.rs new file mode 100644 index 0000000000..b9bd9cab84 --- /dev/null +++ b/crate/clients/clap/src/actions/symmetric/keys/get_rotation_policy.rs @@ -0,0 +1,21 @@ +use clap::Parser; +use cosmian_kms_client::KmsClient; + +use crate::error::result::KmsCliResult; + +/// Get the automatic rotation policy for a symmetric key. +#[derive(Parser, Debug)] +#[clap(verbatim_doc_comment)] +pub struct GetRotationPolicyAction { + /// The unique identifier of the key to get the rotation policy from. + #[clap(long = "key-id", short = 'k')] + key_id: String, +} + +impl GetRotationPolicyAction { + #[allow(clippy::unused_async)] + pub async fn run(&self, _kms_rest_client: KmsClient) -> KmsCliResult<()> { + // TODO: implement KMIP Get Attributes call to retrieve rotation policy + Ok(()) + } +} diff --git a/crate/clients/clap/src/actions/symmetric/keys/mod.rs b/crate/clients/clap/src/actions/symmetric/keys/mod.rs index 131132379e..1f44c326ac 100644 --- a/crate/clients/clap/src/actions/symmetric/keys/mod.rs +++ b/crate/clients/clap/src/actions/symmetric/keys/mod.rs @@ -2,8 +2,9 @@ use clap::Subcommand; use cosmian_kms_client::KmsClient; use self::{ - create_key::CreateKeyAction, destroy_key::DestroyKeyAction, rekey::ReKeyAction, - revoke_key::RevokeKeyAction, + create_key::CreateKeyAction, destroy_key::DestroyKeyAction, + get_rotation_policy::GetRotationPolicyAction, rekey::ReKeyAction, revoke_key::RevokeKeyAction, + set_rotation_policy::SetRotationPolicyAction, }; use crate::{ actions::shared::{ @@ -15,8 +16,10 @@ use crate::{ pub mod create_key; pub mod destroy_key; +pub mod get_rotation_policy; pub mod rekey; pub mod revoke_key; +pub mod set_rotation_policy; /// Create, destroy, import, and export symmetric keys #[derive(Subcommand)] @@ -30,6 +33,8 @@ pub enum KeysCommands { Unwrap(UnwrapSecretDataOrKeyAction), Revoke(RevokeKeyAction), Destroy(DestroyKeyAction), + SetRotationPolicy(SetRotationPolicyAction), + GetRotationPolicy(GetRotationPolicyAction), } impl KeysCommands { @@ -62,6 +67,12 @@ impl KeysCommands { Self::Destroy(action) => { action.run(kms_rest_client).await?; } + Self::SetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } + Self::GetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } } Ok(()) diff --git a/crate/clients/clap/src/actions/symmetric/keys/set_rotation_policy.rs b/crate/clients/clap/src/actions/symmetric/keys/set_rotation_policy.rs new file mode 100644 index 0000000000..13d968371a --- /dev/null +++ b/crate/clients/clap/src/actions/symmetric/keys/set_rotation_policy.rs @@ -0,0 +1,29 @@ +use clap::Parser; +use cosmian_kms_client::KmsClient; + +use crate::error::result::KmsCliResult; + +/// Set the automatic rotation policy for a symmetric key. +#[derive(Parser, Debug)] +#[clap(verbatim_doc_comment)] +pub struct SetRotationPolicyAction { + /// The unique identifier of the key to set the rotation policy on. + #[clap(long = "key-id", short = 'k')] + key_id: String, + + /// Rotation interval in seconds. The key will be automatically re-keyed at this interval. + #[clap(long = "interval", short = 'i')] + interval_secs: i64, + + /// Offset in seconds from the initial date before the first rotation occurs. + #[clap(long = "offset", short = 'o')] + offset_secs: Option, +} + +impl SetRotationPolicyAction { + #[allow(clippy::unused_async)] + pub async fn run(&self, _kms_rest_client: KmsClient) -> KmsCliResult<()> { + // TODO: implement KMIP Modify Attribute call to set rotation policy + Ok(()) + } +} diff --git a/crate/clients/client_utils/src/configurable_kem_utils.rs b/crate/clients/client_utils/src/configurable_kem_utils.rs index f5ec7c58e2..f0543c73fd 100644 --- a/crate/clients/client_utils/src/configurable_kem_utils.rs +++ b/crate/clients/client_utils/src/configurable_kem_utils.rs @@ -175,9 +175,9 @@ pub fn build_create_configurable_kem_keypair_request Result { + let request = ReKey { + unique_identifier: Some(UniqueIdentifier::TextString(unique_identifier)), + ..ReKey::default() + }; + to_wasm_ttlv(&request) +} + +wasm_response_parser!(parse_rekey_ttlv_response, ReKeyResponse); + +// ── Rotation policy helpers ────────────────────────────────────────────────── + +/// Build a KMIP `SetAttribute` TTLV request to set `RotateInterval` on a key. +#[wasm_bindgen] +pub fn set_rotate_interval_ttlv_request( + unique_identifier: String, + interval_secs: i64, +) -> Result { + let request = SetAttribute { + unique_identifier: Some(UniqueIdentifier::TextString(unique_identifier)), + new_attribute: Attribute::RotateInterval(interval_secs), + }; + to_wasm_ttlv(&request) +} + +/// Build a KMIP `SetAttribute` TTLV request to set `RotateOffset` on a key. +#[wasm_bindgen] +pub fn set_rotate_offset_ttlv_request( + unique_identifier: String, + offset_secs: i64, +) -> Result { + let request = SetAttribute { + unique_identifier: Some(UniqueIdentifier::TextString(unique_identifier)), + new_attribute: Attribute::RotateOffset(offset_secs), + }; + to_wasm_ttlv(&request) +} + +/// Build a KMIP `SetAttribute` TTLV request to set `RotateName` on a key. +#[wasm_bindgen] +pub fn set_rotate_name_ttlv_request( + unique_identifier: String, + name: String, +) -> Result { + let request = SetAttribute { + unique_identifier: Some(UniqueIdentifier::TextString(unique_identifier)), + new_attribute: Attribute::RotateName(name), + }; + to_wasm_ttlv(&request) +} + +/// Rotation-policy fields extracted from a `GetAttributes` response. +#[derive(Serialize)] +struct RotationPolicyDto { + interval: i64, + offset: i64, + name: Option, + generation: i32, + date: Option, +} + +/// Parse a `GetAttributes` response and extract only the rotation-policy fields. +/// +/// Returns a JS object with keys: `interval`, `offset`, +/// `name`, `generation`, `date` (string or null). +#[wasm_bindgen] +pub fn parse_rotation_policy_response(response: &str) -> Result { + let ttlv: TTLV = serde_json::from_str(response).map_err(|e| JsValue::from(e.to_string()))?; + let GetAttributesResponse { + unique_identifier: _, + attributes, + } = from_ttlv(ttlv).map_err(|e| JsValue::from(e.to_string()))?; + + let policy = RotationPolicyDto { + interval: attributes.rotate_interval.unwrap_or(0), + offset: attributes.rotate_offset.unwrap_or(0), + name: attributes.rotate_name.clone(), + generation: attributes.rotate_generation.unwrap_or(0), + date: attributes.rotate_date.map(|d| d.to_string()), + }; + + Ok(serde_wasm_bindgen::to_value(&policy)?) +} diff --git a/crate/interfaces/Cargo.toml b/crate/interfaces/Cargo.toml index 2543324ce0..c15dc78379 100644 --- a/crate/interfaces/Cargo.toml +++ b/crate/interfaces/Cargo.toml @@ -23,4 +23,5 @@ cosmian_logger = { workspace = true } num-bigint-dig = { workspace = true, features = ["std", "rand", "serde", "zeroize"] } serde_json = { workspace = true } thiserror = { workspace = true } +time = { workspace = true } zeroize = { workspace = true, default-features = true } diff --git a/crate/interfaces/src/stores/objects_store.rs b/crate/interfaces/src/stores/objects_store.rs index aca1d07a7d..890d156389 100644 --- a/crate/interfaces/src/stores/objects_store.rs +++ b/crate/interfaces/src/stores/objects_store.rs @@ -5,6 +5,7 @@ use cosmian_kmip::{ kmip_0::kmip_types::State, kmip_2_1::{kmip_attributes::Attributes, kmip_objects::Object}, }; +use time::OffsetDateTime; use crate::{InterfaceResult, ObjectWithMetadata}; @@ -117,4 +118,16 @@ pub trait ObjectsStore { ) -> InterfaceResult> { Ok(vec![]) } + + /// Return UIDs of all Active objects that have a `rotate_interval > 0` and whose + /// next rotation instant is ≤ `now`. + /// + /// The next rotation instant is computed as: + /// - `rotate_date + rotate_interval` (if `rotate_date` is set), or + /// - `initial_date + rotate_interval + rotate_offset` (if `rotate_date` is None) + /// + /// The default implementation returns an empty list; backends should override. + async fn find_due_for_rotation(&self, _now: OffsetDateTime) -> 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 686907c31c..5d8dd385ba 100644 --- a/crate/kmip/src/kmip_1_4/kmip_operations.rs +++ b/crate/kmip/src/kmip_1_4/kmip_operations.rs @@ -274,7 +274,7 @@ pub struct ReKey { pub unique_identifier: String, /// Offset from the initialization date of the new key #[serde(skip_serializing_if = "Option::is_none")] - pub offset: Option, + pub offset: Option, /// Template attributes for the new key #[serde(skip_serializing_if = "Option::is_none")] pub template_attribute: Option, @@ -322,7 +322,7 @@ pub struct ReKeyKeyPair { pub private_key_unique_identifier: String, /// Offset from the initialization date of the new key pair #[serde(skip_serializing_if = "Option::is_none")] - pub offset: Option, + pub offset: Option, /// Common template attributes for both public and private key #[serde(skip_serializing_if = "Option::is_none")] pub common_template_attribute: Option, @@ -479,12 +479,19 @@ pub struct CertifyResponse { /// 4.8 Re-certify /// This operation requests the server to generate a new Certificate object for an existing public key. +/// Per KMIP 1.4 §4.8 Table 188, all fields are optional. #[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "PascalCase")] pub struct ReCertify { - pub unique_identifier: String, - pub certificate_request_type: CertificateRequestType, - pub certificate_request_value: Vec, + /// If omitted, then the ID Placeholder value is used by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub unique_identifier: Option, + /// REQUIRED if the Certificate Request 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>, #[serde(skip_serializing_if = "Option::is_none")] pub template_attribute: Option, } @@ -500,15 +507,15 @@ pub struct ReCertifyResponse { impl From for kmip_2_1::kmip_operations::ReCertify { fn from(recertify: ReCertify) -> Self { - let cert_req_type = match recertify.certificate_request_type { + let cert_req_type = recertify.certificate_request_type.map(|t| match t { 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), + unique_identifier: recertify.unique_identifier.map(Into::into), + certificate_request_type: cert_req_type, + certificate_request_value: recertify.certificate_request_value, offset: None, attributes: recertify.template_attribute.map(Into::into), protection_storage_masks: None, @@ -529,23 +536,17 @@ impl TryFrom for ReCertifyResponse 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 - } - }; + // Per KMIP 1.4 §4.8 Table 188, all fields are optional. + // Certificate Request Type is "REQUIRED if the Certificate Request is present". + let cert_req_type = recertify.certificate_request_type.map(|t| match t { + kmip_2_1::kmip_types::CertificateRequestType::CRMF => CertificateRequestType::CRMF, + kmip_2_1::kmip_types::CertificateRequestType::PKCS10 => CertificateRequestType::PKCS10, + kmip_2_1::kmip_types::CertificateRequestType::PEM => CertificateRequestType::PEM, + }); Self { - unique_identifier: recertify - .unique_identifier - .map_or_else(String::new, |u| u.to_string()), + unique_identifier: recertify.unique_identifier.map(|u| u.to_string()), certificate_request_type: cert_req_type, - certificate_request_value: recertify.certificate_request_value.unwrap_or_default(), + certificate_request_value: recertify.certificate_request_value, template_attribute: None, // KMIP 1.4 does not support offset; it is dropped during downgrade. } diff --git a/crate/kmip/src/kmip_2_1/kmip_attributes.rs b/crate/kmip/src/kmip_2_1/kmip_attributes.rs index ffcbc027f0..09c474fab7 100644 --- a/crate/kmip/src/kmip_2_1/kmip_attributes.rs +++ b/crate/kmip/src/kmip_2_1/kmip_attributes.rs @@ -381,7 +381,7 @@ pub struct Attributes { /// The Rotate Interval attribute specifies the interval between rotations of a /// Managed Cryptographic Object, measured in seconds. #[serde(skip_serializing_if = "Option::is_none")] - pub rotate_interval: Option, + pub rotate_interval: Option, /// The Rotate Latest attribute is a Boolean that indicates whether the latest /// rotation time should be recalculated based on the Rotation Interval and @@ -399,7 +399,13 @@ pub struct Attributes { /// Date and the Rotation Date of a Managed Cryptographic Object, measured in /// seconds. #[serde(skip_serializing_if = "Option::is_none")] - pub rotate_offset: Option, + pub rotate_offset: Option, + + /// Tracks the last warning threshold (in days) for which a renewal-warning + /// notification was already sent in the current rotation cycle. + /// Reset to `None` after each successful rotation so warnings restart fresh. + #[serde(skip_serializing_if = "Option::is_none")] + pub rotate_last_warning_days: Option, /// If True then the server SHALL prevent the object value being retrieved /// (via the Get operation) unless it is wrapped by another key. The server @@ -734,6 +740,7 @@ impl Attributes { merge_option_field!(rotate_latest); merge_option_field!(rotate_name); merge_option_field!(rotate_offset); + merge_option_field!(rotate_last_warning_days); merge_option_field!(sensitive); merge_option_field!(short_unique_identifier); merge_option_field!(state); @@ -964,6 +971,9 @@ impl Display for Attributes { if let Some(value) = &self.rotate_offset { writeln!(f, " Rotate Offset: {value}")?; } + if let Some(value) = &self.rotate_last_warning_days { + writeln!(f, " Rotate Last Warning Days: {value}")?; + } if let Some(value) = &self.sensitive { writeln!(f, " Sensitive: {value}")?; } @@ -1250,7 +1260,7 @@ pub enum Attribute { /// The Rotate Interval attribute specifies the interval between rotations of a /// Managed Cryptographic Object, measured in seconds. - RotateInterval(i32), + RotateInterval(i64), /// The Rotate Latest attribute is a Boolean that indicates whether the latest /// rotation time should be recalculated based on the Rotation Interval and @@ -1265,7 +1275,7 @@ pub enum Attribute { /// The Rotate Offset attribute specifies the time offset between the Creation /// Date and the Rotation Date of a Managed Cryptographic Object, measured in /// seconds. - RotateOffset(i32), + RotateOffset(i64), /// If True then the server SHALL prevent the object value being retrieved (via the Get operation) unless it is /// wrapped by another key. The server SHALL set the value to False if the value is not provided by the diff --git a/crate/kmip/src/kmip_2_1/kmip_objects.rs b/crate/kmip/src/kmip_2_1/kmip_objects.rs index 407637ab8e..65f54931e6 100644 --- a/crate/kmip/src/kmip_2_1/kmip_objects.rs +++ b/crate/kmip/src/kmip_2_1/kmip_objects.rs @@ -421,6 +421,15 @@ impl Object { self.key_block() .is_ok_and(|kb| kb.key_wrapping_data.is_some()) } + + /// Returns the UID of the wrapping (encryption) key embedded in this + /// object's `KeyWrappingData`, or `None` if the object is not wrapped. + #[must_use] + pub fn wrapping_key_uid(&self) -> Option { + self.key_wrapping_data() + .and_then(|kwd| kwd.encryption_key_information.as_ref()) + .map(|eki| eki.unique_identifier.to_string()) + } } impl TryFrom<&[u8]> for Object { diff --git a/crate/kmip/src/kmip_2_1/kmip_operations.rs b/crate/kmip/src/kmip_2_1/kmip_operations.rs index e7f8d3135f..287ba04ba1 100644 --- a/crate/kmip/src/kmip_2_1/kmip_operations.rs +++ b/crate/kmip/src/kmip_2_1/kmip_operations.rs @@ -1043,8 +1043,8 @@ impl_display!(CertifyResponse, "CertifyResponse", { req unique_identifier }); #[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. + /// The Unique Identifier of the Certificate being renewed. + /// If omitted, then the ID Placeholder value is used by the server as the Unique Identifier. #[serde(skip_serializing_if = "Option::is_none")] pub unique_identifier: Option, /// An Enumeration object specifying the type of certificate request. @@ -1058,7 +1058,7 @@ pub struct ReCertify { /// 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, + pub offset: Option, /// Specifies desired attributes to be associated with the new certificate. #[serde(skip_serializing_if = "Option::is_none")] pub attributes: Option, @@ -2543,7 +2543,7 @@ pub struct ReKey { // An Interval object indicating the difference between the Initial Date and the Activation Date of the replacement key to be created. #[serde(skip_serializing_if = "Option::is_none")] - pub offset: Option, + pub offset: Option, /// Specifies desired attributes to be associated with the new object. #[serde(skip_serializing_if = "Option::is_none")] @@ -2608,7 +2608,7 @@ pub struct ReKeyKeyPair { // An Interval object indicating the difference between the Initial Date and the Activation // Date of the replacement key pair to be created. #[serde(skip_serializing_if = "Option::is_none")] - pub offset: Option, + pub offset: Option, // Specifies desired attributes that apply to both the Private and Public Key Objects. #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crate/kmip/src/kmip_2_1/requests/create_key_pair.rs b/crate/kmip/src/kmip_2_1/requests/create_key_pair.rs index a3ddda49f3..0a6af83a80 100644 --- a/crate/kmip/src/kmip_2_1/requests/create_key_pair.rs +++ b/crate/kmip/src/kmip_2_1/requests/create_key_pair.rs @@ -74,6 +74,7 @@ pub fn create_rsa_key_pair_request>>( object_type: Some(ObjectType::PrivateKey), unique_identifier: private_key_id, sensitive: sensitive.then_some(true), + activation_date: Some(time_normalize()?), ..Attributes::default() }; @@ -85,6 +86,7 @@ pub fn create_rsa_key_pair_request>>( cryptographic_usage_mask: Some(public_key_mask), key_format_type: Some(KeyFormatType::TransparentRSAPrivateKey), object_type: Some(ObjectType::PrivateKey), + activation_date: Some(time_normalize()?), ..Attributes::default() }; @@ -217,7 +219,6 @@ pub fn create_ec_key_pair_request>>( unique_identifier: private_key_id, sensitive: sensitive.then_some(true), activation_date: Some(time_normalize()?), - ..Attributes::default() }; @@ -233,7 +234,6 @@ pub fn create_ec_key_pair_request>>( key_format_type: Some(KeyFormatType::TransparentECPublicKey), object_type: Some(ObjectType::PublicKey), activation_date: Some(time_normalize()?), - ..Attributes::default() }; diff --git a/crate/server/src/config/command_line/clap_config.rs b/crate/server/src/config/command_line/clap_config.rs index 9fd71faa45..1ec3e27bc1 100644 --- a/crate/server/src/config/command_line/clap_config.rs +++ b/crate/server/src/config/command_line/clap_config.rs @@ -70,6 +70,7 @@ impl Default for ClapConfig { aws_xks_config: AwsXksConfig::default(), kmip_policy: KmipPolicyConfig::default(), azure_ekm_config: AzureEkmConfig::default(), + auto_rotation_check_interval_secs: 0, } } } @@ -213,6 +214,11 @@ pub struct ClapConfig { #[clap(flatten)] #[serde(rename = "kmip")] pub kmip_policy: KmipPolicyConfig, + + /// Interval in seconds between background auto-rotation checks. + /// Set to 0 (default) to disable the auto-rotation background task. + #[clap(long, default_value = "0", verbatim_doc_comment)] + pub auto_rotation_check_interval_secs: u64, } impl ClapConfig { @@ -651,6 +657,10 @@ impl fmt::Debug for ClapConfig { x.field("aws_xks_enable", &self.aws_xks_config.aws_xks_enable) }; let x = x.field("kmip", &self.kmip_policy); + let x = x.field( + "auto_rotation_check_interval_secs", + &self.auto_rotation_check_interval_secs, + ); x.finish() } diff --git a/crate/server/src/config/params/server_params.rs b/crate/server/src/config/params/server_params.rs index eb2773cc9e..4a28999b6c 100644 --- a/crate/server/src/config/params/server_params.rs +++ b/crate/server/src/config/params/server_params.rs @@ -164,6 +164,10 @@ pub struct ServerParams { /// Client-supplied `MaximumItems` is clamped to this value; when absent the cap is /// applied automatically. Prevents unbounded DB queries and large response payloads. pub max_locate_items: u32, + + /// Interval in seconds between background auto-rotation checks. + /// 0 means disabled. + pub auto_rotation_check_interval_secs: u64, } /// Represents the server parameters. @@ -422,6 +426,7 @@ impl ServerParams { crate::config::default_cors_origins(cors_scheme, conf.http.port) }), max_locate_items: 1000, + auto_rotation_check_interval_secs: conf.auto_rotation_check_interval_secs, }; debug!("{res:#?}"); @@ -645,6 +650,10 @@ impl fmt::Debug for ServerParams { debug_struct.field("rate_limit_per_second", &self.rate_limit_per_second); debug_struct.field("cors_allowed_origins", &self.cors_allowed_origins); debug_struct.field("max_locate_items", &self.max_locate_items); + debug_struct.field( + "auto_rotation_check_interval_secs", + &self.auto_rotation_check_interval_secs, + ); debug_struct.finish() } diff --git a/crate/server/src/core/cover_crypt/create_user_decryption_key.rs b/crate/server/src/core/cover_crypt/create_user_decryption_key.rs index 2a57d05cad..666671fcc2 100644 --- a/crate/server/src/core/cover_crypt/create_user_decryption_key.rs +++ b/crate/server/src/core/cover_crypt/create_user_decryption_key.rs @@ -34,7 +34,6 @@ pub(crate) async fn create_user_decryption_key( create_request: &Create, owner: &str, sensitive: bool, - privileged_users: Option>, ) -> KResult { let msk_uid_or_tags = create_request .attributes @@ -94,9 +93,7 @@ pub(crate) async fn create_user_decryption_key( object: msk_obj, }; - kmip_server - .import(import_request, owner, privileged_users) - .await?; + kmip_server.import(import_request, owner).await?; return Ok(usk_obj); } diff --git a/crate/server/src/core/cover_crypt/rekey_keys.rs b/crate/server/src/core/cover_crypt/rekey_keys.rs index d2e57598fb..8209d4752d 100644 --- a/crate/server/src/core/cover_crypt/rekey_keys.rs +++ b/crate/server/src/core/cover_crypt/rekey_keys.rs @@ -44,133 +44,72 @@ pub(crate) async fn rekey_keypair_cover_crypt( owner: &str, action: RekeyEditAction, _sensitive: bool, - privileged_users: Option>, ) -> KResult { trace!("Internal rekey key pair Covercrypt"); let mpk_uid = match action { RekeyEditAction::RekeyAccessPolicy(access_policy) => { - update_master_keys( - kmip_server, - owner, - &msk_uid, - async |msk, mpk| { - let ap = AccessPolicy::parse(&access_policy)?; - *mpk = cover_crypt.rekey(msk, &ap)?; - update_all_active_usk( - kmip_server, - &cover_crypt, - &msk_uid, - msk, - owner, - &privileged_users, - ) - .await?; - Ok(()) - }, - &privileged_users, - ) + update_master_keys(kmip_server, owner, &msk_uid, async |msk, mpk| { + let ap = AccessPolicy::parse(&access_policy)?; + *mpk = cover_crypt.rekey(msk, &ap)?; + update_all_active_usk(kmip_server, &cover_crypt, &msk_uid, msk, owner).await?; + Ok(()) + }) .await? } RekeyEditAction::PruneAccessPolicy(access_policy) => { - update_master_keys( - kmip_server, - owner, - &msk_uid, - async |msk, _mpk| { - let ap = AccessPolicy::parse(&access_policy)?; - cover_crypt.prune_master_secret_key(msk, &ap)?; - update_all_active_usk( - kmip_server, - &cover_crypt, - &msk_uid, - msk, - owner, - &privileged_users, - ) - .await?; - Ok(()) - }, - &privileged_users, - ) + update_master_keys(kmip_server, owner, &msk_uid, async |msk, _mpk| { + let ap = AccessPolicy::parse(&access_policy)?; + cover_crypt.prune_master_secret_key(msk, &ap)?; + update_all_active_usk(kmip_server, &cover_crypt, &msk_uid, msk, owner).await?; + Ok(()) + }) .await? } RekeyEditAction::DeleteAttribute(attrs) => { - update_master_keys( - kmip_server, - owner, - &msk_uid, - async |msk, mpk| { - attrs - .iter() - .try_for_each(|attr| msk.access_structure.del_attribute(attr))?; - *mpk = cover_crypt.update_msk(msk)?; - update_all_active_usk( - kmip_server, - &cover_crypt, - &msk_uid, - msk, - owner, - &privileged_users, - ) - .await?; - Ok(()) - }, - &privileged_users, - ) + update_master_keys(kmip_server, owner, &msk_uid, async |msk, mpk| { + attrs + .iter() + .try_for_each(|attr| msk.access_structure.del_attribute(attr))?; + *mpk = cover_crypt.update_msk(msk)?; + update_all_active_usk(kmip_server, &cover_crypt, &msk_uid, msk, owner).await?; + Ok(()) + }) .await? } RekeyEditAction::DisableAttribute(attrs) => { - update_master_keys( - kmip_server, - owner, - &msk_uid, - async |msk, mpk| { - attrs - .iter() - .try_for_each(|attr| msk.access_structure.disable_attribute(attr))?; - *mpk = cover_crypt.update_msk(msk)?; - Ok(()) - }, - &privileged_users, - ) + update_master_keys(kmip_server, owner, &msk_uid, async |msk, mpk| { + attrs + .iter() + .try_for_each(|attr| msk.access_structure.disable_attribute(attr))?; + *mpk = cover_crypt.update_msk(msk)?; + Ok(()) + }) .await? } RekeyEditAction::RenameAttribute(pairs_attr_name) => { - update_master_keys( - kmip_server, - owner, - &msk_uid, - async |msk, mpk| { - pairs_attr_name - .iter() - .try_for_each(|(ap_attributes, new_name)| { - msk.access_structure - .rename_attribute(ap_attributes, new_name.clone()) - })?; - *mpk = cover_crypt.update_msk(msk)?; - Ok(()) - }, - &privileged_users, - ) + update_master_keys(kmip_server, owner, &msk_uid, async |msk, mpk| { + pairs_attr_name + .iter() + .try_for_each(|(ap_attributes, new_name)| { + msk.access_structure + .rename_attribute(ap_attributes, new_name.clone()) + })?; + *mpk = cover_crypt.update_msk(msk)?; + Ok(()) + }) .await? } RekeyEditAction::AddAttribute(attrs_properties) => { - update_master_keys( - kmip_server, - owner, - &msk_uid, - async |msk, mpk| { - attrs_properties - .iter() - .try_for_each(|(attr, encryption_hint, _after)| { - msk.access_structure - .add_attribute(attr.clone(), *encryption_hint, None) - })?; - *mpk = cover_crypt.update_msk(msk)?; - Ok(()) - }, - &privileged_users, - ) + update_master_keys(kmip_server, owner, &msk_uid, async |msk, mpk| { + attrs_properties + .iter() + .try_for_each(|(attr, encryption_hint, _after)| { + msk.access_structure + .add_attribute(attr.clone(), *encryption_hint, None) + })?; + *mpk = cover_crypt.update_msk(msk)?; + Ok(()) + }) .await? } }; @@ -189,7 +128,6 @@ pub(super) async fn update_master_keys( owner: &str, msk_uid: &String, mutator: impl AsyncFn(&mut MasterSecretKey, &mut MasterPublicKey) -> KResult<()>, - privileged_users: &Option>, ) -> KResult { let (msk_obj, (mpk_uid, mpk_obj)) = get_master_keys(server, msk_uid, owner).await?; @@ -204,7 +142,6 @@ pub(super) async fn update_master_keys( owner, (msk_uid.clone(), msk_obj), (mpk_uid.clone(), mpk_obj), - privileged_users, ) .await?; @@ -245,7 +182,6 @@ async fn import_rekeyed_master_keys( owner: &str, msk: KmipKeyUidObject, mpk: KmipKeyUidObject, - privileged_users: &Option>, ) -> KResult<()> { let import_request = Import { unique_identifier: UniqueIdentifier::TextString(msk.0), @@ -256,9 +192,7 @@ async fn import_rekeyed_master_keys( object: msk.1, }; - kmip_server - .import(import_request, owner, privileged_users.clone()) - .await?; + kmip_server.import(import_request, owner).await?; let import_request = Import { unique_identifier: UniqueIdentifier::TextString(mpk.0), @@ -269,9 +203,7 @@ async fn import_rekeyed_master_keys( object: mpk.1, }; - kmip_server - .import(import_request, owner, privileged_users.clone()) - .await?; + kmip_server.import(import_request, owner).await?; Ok(()) } @@ -283,14 +215,13 @@ async fn update_all_active_usk( msk_uid: &str, msk: &mut MasterSecretKey, owner: &str, - privileged_users: &Option>, ) -> KResult<()> { let res = locate_usk(kmip_server, msk_uid, None, Some(State::Active), owner).await?; if let Some(uids) = &res { let mut handler = UserDecryptionKeysHandler::instantiate(cover_crypt, msk); for usk_uid in uids { - update_usk(&mut handler, usk_uid, kmip_server, owner, privileged_users).await?; + update_usk(&mut handler, usk_uid, kmip_server, owner).await?; } } @@ -303,7 +234,6 @@ async fn update_usk( usk_uid: &str, kmip_server: &KMS, owner: &str, - privileged_users: &Option>, ) -> KResult<()> { let res = kmip_server.get(Get::from(usk_uid), owner).await?; @@ -321,9 +251,7 @@ async fn update_usk( object: usk_obj, }; - kmip_server - .import(req, owner, privileged_users.clone()) - .await?; + kmip_server.import(req, owner).await?; Ok(()) } diff --git a/crate/server/src/core/kms/kmip.rs b/crate/server/src/core/kms/kmip.rs index 40b46fe09e..6af00b0a4c 100644 --- a/crate/server/src/core/kms/kmip.rs +++ b/crate/server/src/core/kms/kmip.rs @@ -79,15 +79,10 @@ impl KMS { /// If the information in the Certificate Request conflicts with the /// attributes specified in the Attributes, then the information in the /// Certificate Request takes precedence. - pub(crate) async fn certify( - &self, - request: Certify, - user: &str, - privileged_users: Option>, - ) -> KResult { + pub(crate) async fn certify(&self, request: Certify, user: &str) -> KResult { let span = tracing::span!(tracing::Level::ERROR, "certify"); - Box::pin(operations::certify(self, request, user, privileged_users)) + Box::pin(operations::certify(self, request, user)) .instrument(span) .await } @@ -100,13 +95,8 @@ impl KMS { /// contains the Unique Identifier of the created object. The server SHALL /// copy the Unique Identifier returned by this operation into the ID /// Placeholder variable. - pub(crate) async fn create( - &self, - request: Create, - user: &str, - privileged_users: Option>, - ) -> KResult { - Box::pin(operations::create(self, request, user, privileged_users)).await + pub(crate) async fn create(&self, request: Create, user: &str) -> KResult { + Box::pin(operations::create(self, request, user)).await } /// This operation requests the server to generate a new public/private key @@ -128,18 +118,12 @@ impl KMS { &self, request: CreateKeyPair, user: &str, - privileged_users: Option>, ) -> KResult { let span = tracing::span!(tracing::Level::ERROR, "create_key_pair"); - Box::pin(operations::create_key_pair( - self, - request, - user, - privileged_users, - )) - .instrument(span) - .await + Box::pin(operations::create_key_pair(self, request, user)) + .instrument(span) + .await } /// This request is used by the client to determine a list of protocol versions @@ -412,16 +396,11 @@ impl KMS { /// for queries on tags. See tagging. /// For instance, a request for a unique identifier `[tag1]` will /// attempt to find a valid single object tagged with `tag1` - pub(crate) async fn import( - &self, - request: Import, - user: &str, - privileged_users: Option>, - ) -> KResult { + pub(crate) async fn import(&self, request: Import, user: &str) -> KResult { let span = tracing::span!(tracing::Level::ERROR, "import"); // Box::pin :: see https://rust-lang.github.io/rust-clippy/master/index.html#large_futures - Box::pin(operations::import(self, request, user, privileged_users)) + Box::pin(operations::import(self, request, user)) .instrument(span) .await } @@ -594,11 +573,10 @@ impl KMS { &self, request: Register, user: &str, - privileged_users: Option>, ) -> KResult { let span = tracing::span!(tracing::Level::ERROR, "register"); - Box::pin(operations::register(self, request, user, privileged_users)) + Box::pin(operations::register(self, request, user)) .instrument(span) .await } @@ -632,19 +610,12 @@ impl KMS { &self, request: ReKeyKeyPair, user: &str, - - privileged_users: Option>, ) -> KResult { let span = tracing::span!(tracing::Level::ERROR, "rekey_keypair"); - Box::pin(operations::rekey_keypair( - self, - request, - user, - privileged_users, - )) - .instrument(span) - .await + Box::pin(operations::rekey_keypair(self, request, user)) + .instrument(span) + .await } /// This request is used to generate a replacement key for an existing symmetric key. It is analogous to the Create operation, except that attributes of the replacement key are copied from the existing key, with the exception of the attributes listed in Re-key Attribute Requirements. @@ -656,15 +627,10 @@ impl KMS { /// For the existing key, the server SHALL create a Link attribute of Link Type Replacement Object pointing to the replacement key. For the replacement key, the server SHALL create a Link attribute of Link Type Replaced Key pointing to the existing key. /// /// An Offset MAY be used to indicate the difference between the Initial Date and the Activation Date of the replacement key. If no Offset is specified, the Activation Date, Process Start Date, Protect Stop Date and Deactivation Date values are copied from the existing key. - pub(crate) async fn rekey( - &self, - request: ReKey, - user: &str, - privileged_users: Option>, - ) -> KResult { + pub(crate) async fn rekey(&self, request: ReKey, user: &str) -> KResult { let span = tracing::span!(tracing::Level::ERROR, "rekey"); - Box::pin(operations::rekey(self, request, user, privileged_users)) + Box::pin(operations::rekey(self, request, user)) .instrument(span) .await } @@ -678,11 +644,10 @@ impl KMS { &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)) + Box::pin(operations::recertify(self, request, user)) .instrument(span) .await } diff --git a/crate/server/src/core/kms/other_kms_methods.rs b/crate/server/src/core/kms/other_kms_methods.rs index e8f27ddce5..4dd5b1d0b9 100644 --- a/crate/server/src/core/kms/other_kms_methods.rs +++ b/crate/server/src/core/kms/other_kms_methods.rs @@ -228,7 +228,6 @@ impl KMS { &self, create_request: &Create, _owner: &str, - _privileged_users: Option>, ) -> KResult<(Option, Object, HashSet)> { trace!("Internal create private key (FIPS build)"); let attributes = &create_request.attributes; @@ -342,7 +341,6 @@ impl KMS { &self, create_request: &Create, owner: &str, - privileged_users: Option>, ) -> KResult<(Option, Object, HashSet)> { trace!("Internal create private key"); let attributes = &create_request.attributes; @@ -362,7 +360,6 @@ impl KMS { create_request, owner, create_request.attributes.sensitive.unwrap_or(false), - privileged_users, ) .await?; // Update the attributes with state Active diff --git a/crate/server/src/core/kms/permissions.rs b/crate/server/src/core/kms/permissions.rs index e4cd79158d..2e7b6699e2 100644 --- a/crate/server/src/core/kms/permissions.rs +++ b/crate/server/src/core/kms/permissions.rs @@ -21,17 +21,12 @@ impl KMS { /// Grant access to a user (identified by `access.userid`) /// to an object (identified by `access.unique_identifier`) /// which is owned by `owner` (identified by `access.owner`) - pub(crate) async fn grant_access( - &self, - access: &Access, - owner: &str, - privileged_users: Option>, - ) -> KResult<()> { + pub(crate) async fn grant_access(&self, access: &Access, owner: &str) -> KResult<()> { // if create access right is set, grant access to Create for the * object let mut updated_operations_types = access.operation_types.clone(); if updated_operations_types.contains(&KmipOperation::Create) { updated_operations_types.retain(|op| op != &KmipOperation::Create); - if let Some(users) = privileged_users { + if let Some(ref users) = self.params.privileged_users { if !users.contains(&owner.to_owned()) { kms_bail!(KmsError::Unauthorized( "Only privileged users can grant/revoke create access right to a user." @@ -114,18 +109,12 @@ impl KMS { /// Remove an access authorization for a user (identified by `access.userid`) /// to an object (identified by `access.unique_identifier`) /// which is owned by `owner` (identified by `access.owner`) - pub(crate) async fn revoke_access( - &self, - access: &Access, - owner: &str, - - privileged_users: Option>, - ) -> KResult<()> { + pub(crate) async fn revoke_access(&self, access: &Access, owner: &str) -> KResult<()> { // if create access right is set, revoke access Create for * object let mut updated_operations_types = access.operation_types.clone(); if updated_operations_types.contains(&KmipOperation::Create) { updated_operations_types.retain(|op| op != &KmipOperation::Create); - if let Some(users) = privileged_users { + if let Some(ref users) = self.params.privileged_users { if !users.contains(&owner.to_owned()) { kms_bail!(KmsError::Unauthorized( "Only privileged users can grant/revoke create access right to a user." diff --git a/crate/server/src/core/operations/attributes/add.rs b/crate/server/src/core/operations/attributes/add.rs index 2159f0b28c..71b0017a85 100644 --- a/crate/server/src/core/operations/attributes/add.rs +++ b/crate/server/src/core/operations/attributes/add.rs @@ -33,6 +33,18 @@ pub(crate) async fn add_attribute( .as_str() .context("Add Attribute: the unique identifier must be a string")?; + // Read-only guard — these attributes are server-managed. + match &request.new_attribute { + Attribute::RotateGeneration(_) | Attribute::RotateDate(_) => { + return Err(KmsError::Kmip21Error( + ErrorReason::Attribute_Read_Only, + "DENIED: this attribute is server-managed and cannot be added by the user" + .to_owned(), + )); + } + _ => {} + } + let mut owm: ObjectWithMetadata = Box::pin(retrieve_object_for_operation( uid_or_tags, KmipOperation::AddAttribute, diff --git a/crate/server/src/core/operations/attributes/delete.rs b/crate/server/src/core/operations/attributes/delete.rs index ddfd6aac8a..8d9e1549cb 100644 --- a/crate/server/src/core/operations/attributes/delete.rs +++ b/crate/server/src/core/operations/attributes/delete.rs @@ -1,9 +1,12 @@ -use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::{ - KmipOperation, - kmip_attributes::Attribute, - kmip_objects::{Object, PrivateKey, PublicKey, SecretData, SymmetricKey}, - kmip_operations::{DeleteAttribute, DeleteAttributeResponse}, - kmip_types::{AttributeReference, Tag, UniqueIdentifier}, +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::ErrorReason, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attribute, + kmip_objects::{Object, PrivateKey, PublicKey, SecretData, SymmetricKey}, + kmip_operations::{DeleteAttribute, DeleteAttributeResponse}, + kmip_types::{AttributeReference, Tag, UniqueIdentifier}, + }, }; use cosmian_logger::trace; @@ -40,6 +43,17 @@ pub(crate) async fn delete_attribute( let mut attributes = owm.attributes().to_owned(); if let Some(attribute) = request.current_attribute { + // Read-only guard — these attributes are server-managed. + match &attribute { + Attribute::RotateGeneration(_) | Attribute::RotateDate(_) => { + return Err(KmsError::Kmip21Error( + ErrorReason::Attribute_Read_Only, + "DENIED: this attribute is server-managed and cannot be deleted by the user" + .to_owned(), + )); + } + _ => {} + } match_delete_attribute! { attribute, attributes, simple { diff --git a/crate/server/src/core/operations/attributes/modify.rs b/crate/server/src/core/operations/attributes/modify.rs index 8a09e1a37f..ebb85a4649 100644 --- a/crate/server/src/core/operations/attributes/modify.rs +++ b/crate/server/src/core/operations/attributes/modify.rs @@ -49,10 +49,14 @@ pub(crate) async fn modify_attribute( // Read-only guard — must be checked before the DB round-trip. match &request.new_attribute { - Attribute::State(_) | Attribute::CertificateLength(_) => { + Attribute::State(_) + | Attribute::CertificateLength(_) + | Attribute::RotateGeneration(_) + | Attribute::RotateDate(_) => { return Err(KmsError::Kmip21Error( ErrorReason::Attribute_Read_Only, - "DENIED".to_owned(), + "DENIED: this attribute is server-managed and cannot be modified by the user" + .to_owned(), )); } _ => {} diff --git a/crate/server/src/core/operations/attributes/set.rs b/crate/server/src/core/operations/attributes/set.rs index 3384e2eaaf..8e2045acbe 100644 --- a/crate/server/src/core/operations/attributes/set.rs +++ b/crate/server/src/core/operations/attributes/set.rs @@ -1,10 +1,13 @@ use cosmian_kms_server_database::reexport::{ - cosmian_kmip::kmip_2_1::{ - KmipOperation, - kmip_attributes::Attribute, - kmip_objects::ObjectType, - kmip_operations::{SetAttribute, SetAttributeResponse}, - kmip_types::UniqueIdentifier, + cosmian_kmip::{ + kmip_0::kmip_types::ErrorReason, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attribute, + kmip_objects::ObjectType, + kmip_operations::{SetAttribute, SetAttributeResponse}, + kmip_types::UniqueIdentifier, + }, }, cosmian_kms_interfaces::ObjectWithMetadata, }; @@ -31,6 +34,17 @@ pub(crate) async fn set_attribute( .as_str() .context("Set Attribute: the unique identifier must be a string")?; + // Read-only guard — must be checked before the DB round-trip. + match &request.new_attribute { + Attribute::State(_) | Attribute::RotateGeneration(_) | Attribute::RotateDate(_) => { + return Err(KmsError::Kmip21Error( + ErrorReason::Attribute_Read_Only, + "DENIED: this attribute is server-managed and cannot be set by the user".to_owned(), + )); + } + _ => {} + } + let mut owm: ObjectWithMetadata = Box::pin(retrieve_object_for_operation( uid_or_tags, KmipOperation::SetAttribute, diff --git a/crate/server/src/core/operations/auto_rotate.rs b/crate/server/src/core/operations/auto_rotate.rs new file mode 100644 index 0000000000..60da235cb0 --- /dev/null +++ b/crate/server/src/core/operations/auto_rotate.rs @@ -0,0 +1,44 @@ +//! Auto-rotation scheduler. +//! +//! This module provides: +//! - [`run_auto_rotation`] — iterates keys due for rotation and triggers re-key operations. +//! - [`dispatch_renewal_warnings`] — sends notifications when keys approach their rotation date. + +use cosmian_logger::debug; + +use crate::core::KMS; + +/// Rotate all keys that are past their scheduled rotation time. +/// +/// The function queries the database for active keys whose `rotate_interval` +/// has elapsed since their last rotation (or initial date + offset for first rotation), +/// then issues a Re-Key or Re-Key Key Pair operation for each. +pub(crate) async fn run_auto_rotation(kms: &KMS) { + let now = time::OffsetDateTime::now_utc(); + let due_keys = match kms.database.find_due_for_rotation(now).await { + Ok(keys) => keys, + Err(e) => { + debug!("[auto-rotate] Failed to query keys due for rotation: {e}"); + return; + } + }; + + if due_keys.is_empty() { + return; + } + debug!( + "[auto-rotate] Found {} key(s) due for rotation", + due_keys.len() + ); + + for uid in &due_keys { + debug!("[auto-rotate] Rotating key {uid}"); + // TODO: issue Re-Key / Re-Key Key Pair operation for the key + } +} + +/// Check keys approaching rotation and emit renewal-warning notifications. +pub(crate) async fn dispatch_renewal_warnings(_kms: &KMS) { + // TODO: implement renewal-warning notification dispatch + debug!("[auto-rotate] Renewal-warning dispatch complete (no-op stub)"); +} diff --git a/crate/server/src/core/operations/certify/certify_op.rs b/crate/server/src/core/operations/certify/certify_op.rs index 7e2ec88f2d..ff0a8d70cb 100644 --- a/crate/server/src/core/operations/certify/certify_op.rs +++ b/crate/server/src/core/operations/certify/certify_op.rs @@ -19,12 +19,7 @@ use crate::{core::KMS, error::KmsError, kms_bail, result::KResult}; /// Certify a certificate. /// This operation is used to issue a certificate based on a public key, a CSR or a key pair. /// The certificate can be self-signed or signed by another certificate. -pub(crate) async fn certify( - kms: &KMS, - request: Certify, - user: &str, - privileged_users: Option>, -) -> KResult { +pub(crate) async fn certify(kms: &KMS, request: Certify, user: &str) -> KResult { trace!("{}", serde_json::to_string(&request)?); if request.protection_storage_masks.is_some() { kms_bail!(KmsError::UnsupportedPlaceholder) @@ -34,7 +29,7 @@ pub(crate) async fn certify( // generate_x509(get_issuer(get_subject))) // The code below could be rewritten in a more functional way // but this would require manipulating some sort of Monad Transformer - let subject = Box::pin(get_subject(kms, &request, user, privileged_users)).await?; + let subject = Box::pin(get_subject(kms, &request, user)).await?; trace!("Subject name: {:?}", subject.subject_name()); let issuer = Box::pin(get_issuer(&subject, kms, &request, user)).await?; trace!("Issuer Subject name: {:?}", issuer.subject_name()); diff --git a/crate/server/src/core/operations/certify/resolve_subject.rs b/crate/server/src/core/operations/certify/resolve_subject.rs index a921951ef5..e85c881a54 100644 --- a/crate/server/src/core/operations/certify/resolve_subject.rs +++ b/crate/server/src/core/operations/certify/resolve_subject.rs @@ -15,11 +15,10 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ }, }; use cosmian_kms_server_database::reexport::{ - cosmian_kmip, cosmian_kmip::kmip_2_1::{ KmipOperation, kmip_objects::ObjectType, - kmip_operations::CreateKeyPair, + kmip_operations::{Certify, CreateKeyPair}, kmip_types::{CertificateRequestType, UniqueIdentifier}, }, cosmian_kms_crypto::openssl::{ @@ -33,8 +32,8 @@ use super::subject::{KeyPairData, Subject}; use crate::{ core::{ KMS, - operations::create_key_pair::generate_key_pair, - retrieve_object_utils::{retrieve_object_for_operation, user_has_permission}, + operations::{create_key_pair::generate_key_pair, key_ops::enforce_create_permission}, + retrieve_object_utils::retrieve_object_for_operation, }, error::KmsError, kms_bail, @@ -81,12 +80,7 @@ fn cryptographic_usage_mask_public_key( /// - a certificate /// - a key pair and a subject name /// - a CSR -pub(crate) async fn get_subject( - kms: &KMS, - request: &cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_operations::Certify, - user: &str, - privileged_users: Option>, -) -> KResult { +pub(crate) async fn get_subject(kms: &KMS, request: &Certify, user: &str) -> KResult { // Did the user provide a CSR? if let Some(pkcs10_bytes) = request.certificate_request_value.as_ref() { let x509_req = match &request @@ -180,21 +174,7 @@ pub(crate) async fn get_subject( // For creation of an object, check that user has create access-right // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users { - let has_permission = user_has_permission( - user, - None, - &cosmian_kmip::kmip_2_1::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() - )) - } - } + enforce_create_permission(kms, user).await?; let sk_uid = UniqueIdentifier::default(); let pk_uid = UniqueIdentifier::default(); diff --git a/crate/server/src/core/operations/create.rs b/crate/server/src/core/operations/create.rs index bf30cb7189..e27a6584b6 100644 --- a/crate/server/src/core/operations/create.rs +++ b/crate/server/src/core/operations/create.rs @@ -1,6 +1,6 @@ -use cosmian_kms_server_database::reexport::{ - cosmian_kmip, - cosmian_kmip::kmip_2_1::{ +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::ErrorReason, + kmip_2_1::{ kmip_objects::ObjectType, kmip_operations::{Create, CreateResponse}, kmip_types::UniqueIdentifier, @@ -9,48 +9,22 @@ use cosmian_kms_server_database::reexport::{ use cosmian_logger::{info, trace}; use uuid::Uuid; +use super::key_ops::{enforce_create_permission, reject_protection_storage_masks}; use crate::{ - core::{KMS, retrieve_object_utils::user_has_permission, wrapping::wrap_and_cache}, + core::{KMS, wrapping::wrap_and_cache}, error::KmsError, kms_bail, result::KResult, }; -pub(crate) async fn create( - kms: &KMS, - request: Create, - owner: &str, - privileged_users: Option>, -) -> KResult { +pub(crate) async fn create(kms: &KMS, request: Create, owner: &str) -> KResult { trace!("{request}"); - if request.protection_storage_masks.is_some() { - kms_bail!(KmsError::UnsupportedPlaceholder) - } - - // To create an object, check that the user has `Create` access right - // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users.clone() { - let has_permission = user_has_permission( - owner, - None, - &cosmian_kmip::kmip_2_1::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() - )) - } - } + reject_protection_storage_masks(request.protection_storage_masks.is_some())?; + enforce_create_permission(kms, owner).await?; let (unique_identifier, mut object, tags) = match &request.object_type { ObjectType::SymmetricKey => KMS::create_symmetric_key_and_tags(kms.vendor_id(), &request)?, - ObjectType::PrivateKey => { - kms.create_private_key_and_tags(&request, owner, privileged_users) - .await? - } + ObjectType::PrivateKey => kms.create_private_key_and_tags(&request, owner).await?, ObjectType::SecretData => KMS::create_secret_data_and_tags(kms.vendor_id(), &request)?, _ => { kms_bail!(KmsError::NotSupported(format!( @@ -74,7 +48,7 @@ pub(crate) async fn create( let protection_period_present = attrs.protection_period.is_some(); if qs && (protection_level_present || protection_period_present) { kms_bail!(KmsError::Kmip21Error( - cosmian_kmip::kmip_0::kmip_types::ErrorReason::General_Failure, + ErrorReason::General_Failure, "NOT_SAFE".to_owned(), )); } diff --git a/crate/server/src/core/operations/create_key_pair.rs b/crate/server/src/core/operations/create_key_pair.rs index 3d84a2a66f..980cbb2087 100644 --- a/crate/server/src/core/operations/create_key_pair.rs +++ b/crate/server/src/core/operations/create_key_pair.rs @@ -1,4 +1,3 @@ -use cosmian_kms_server_database::reexport::cosmian_kmip; #[cfg(feature = "non-fips")] use cosmian_kms_server_database::reexport::cosmian_kms_crypto::crypto::kem::kem_keygen; #[cfg(feature = "non-fips")] @@ -33,45 +32,27 @@ use cosmian_logger::warn; use cosmian_logger::{debug, info, trace}; use uuid::Uuid; use crate::{ - core::{KMS, retrieve_object_utils::user_has_permission, wrapping::wrap_and_cache}, + core::{KMS, wrapping::wrap_and_cache}, error::KmsError, kms_bail, result::KResult, }; +use super::key_ops::{enforce_create_permission, reject_protection_storage_masks}; + pub(crate) async fn create_key_pair( kms: &KMS, request: CreateKeyPair, owner: &str, - - privileged_users: Option>, ) -> KResult { debug!("Create key pair: {request}"); - // To create a key pair, check that the user has `Create` access right - // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users { - let has_permission = user_has_permission( - owner, - None, - &cosmian_kmip::kmip_2_1::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() - )) - } - } - - 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) - } + reject_protection_storage_masks( + request.common_protection_storage_masks.is_some() + || request.private_protection_storage_masks.is_some() + || request.public_protection_storage_masks.is_some(), + )?; + enforce_create_permission(kms, owner).await?; // generate uids and create the key pair and tags let sk_uid = request diff --git a/crate/server/src/core/operations/dispatch.rs b/crate/server/src/core/operations/dispatch.rs index 02caeeb906..00ceb666cc 100644 --- a/crate/server/src/core/operations/dispatch.rs +++ b/crate/server/src/core/operations/dispatch.rs @@ -30,13 +30,6 @@ macro_rules! op { let resp = $kms.$method(req, $user).await?; Operation::$Resp(resp) }}; - // Variant for operations that also need privileged_users - (priv $ttlv:expr, $kms:expr, $user:expr, $Req:ty, $method:ident, $Resp:ident) => {{ - let req = from_ttlv::<$Req>($ttlv)?; - let privileged_users = $kms.params.privileged_users.clone(); - let resp = $kms.$method(req, $user, privileged_users).await?; - Operation::$Resp(resp) - }}; // Variant for operations returning a boxed response (boxed $ttlv:expr, $kms:expr, $user:expr, $Req:ty, $method:ident, $Resp:ident) => {{ let req = from_ttlv::<$Req>($ttlv)?; @@ -117,13 +110,20 @@ async fn dispatch_inner( add_attribute, AddAttributeResponse ), - "Certify" => op!(priv ttlv, kms, user, Certify, certify, CertifyResponse), + "Certify" => op!(ttlv, kms, user, Certify, certify, CertifyResponse), "Check" => { op!(fn ttlv, kms, user, Check, check, CheckResponse) } - "Create" => op!(priv ttlv, kms, user, Create, create, CreateResponse), + "Create" => op!(ttlv, kms, user, Create, create, CreateResponse), "CreateKeyPair" => { - op!(priv ttlv, kms, user, CreateKeyPair, create_key_pair, CreateKeyPairResponse) + op!( + ttlv, + kms, + user, + CreateKeyPair, + create_key_pair, + CreateKeyPairResponse + ) } "Decrypt" => op!(ttlv, kms, user, Decrypt, decrypt, DecryptResponse), "DeleteAttribute" => { @@ -160,7 +160,7 @@ async fn dispatch_inner( RNGRetrieveResponse ), "RNGSeed" => op!(ttlv, kms, user, RNGSeed, rng_seed, RNGSeedResponse), - "Import" => op!(priv ttlv, kms, user, Import, import, ImportResponse), + "Import" => op!(ttlv, kms, user, Import, import, ImportResponse), "Locate" => op!(ttlv, kms, user, Locate, locate, LocateResponse), "Mac" | "MAC" => op!(ttlv, kms, user, MAC, mac, MACResponse), "MACVerify" => { @@ -177,14 +177,21 @@ async fn dispatch_inner( ModifyAttributeResponse ) } - "ReKey" => op!(priv ttlv, kms, user, ReKey, rekey, ReKeyResponse), + "ReKey" => op!(ttlv, kms, user, ReKey, rekey, ReKeyResponse), "ReKeyKeyPair" => { - op!(priv ttlv, kms, user, ReKeyKeyPair, rekey_keypair, ReKeyKeyPairResponse) + op!( + ttlv, + kms, + user, + ReKeyKeyPair, + rekey_keypair, + ReKeyKeyPairResponse + ) } "ReCertify" => { - op!(priv ttlv, kms, user, ReCertify, recertify, ReCertifyResponse) + op!(ttlv, kms, user, ReCertify, recertify, ReCertifyResponse) } - "Register" => op!(priv ttlv, kms, user, Register, register, RegisterResponse), + "Register" => op!(ttlv, kms, user, Register, register, RegisterResponse), "Revoke" => op!(ttlv, kms, user, Revoke, revoke, RevokeResponse), "SetAttribute" => op!( ttlv, diff --git a/crate/server/src/core/operations/import.rs b/crate/server/src/core/operations/import.rs index fe8acf72cd..26159afb35 100644 --- a/crate/server/src/core/operations/import.rs +++ b/crate/server/src/core/operations/import.rs @@ -33,11 +33,11 @@ use cosmian_logger::{debug, trace, warn}; use openssl::x509::X509; use uuid::Uuid; +use super::key_ops::enforce_create_permission; use crate::{ core::{ KMS, operations::validate::verify_crls, - retrieve_object_utils::user_has_permission, wrapping::{unwrap_object, wrap_and_cache}, }, error::KmsError, @@ -46,12 +46,7 @@ use crate::{ }; /// Import a new object -pub(crate) async fn import( - kms: &KMS, - request: Import, - user: &str, - privileged_users: Option>, -) -> KResult { +pub(crate) async fn import(kms: &KMS, request: Import, user: &str) -> KResult { trace!( "Entering import KMIP operation: uid={}, object_type={}", request.unique_identifier, request.object_type @@ -71,21 +66,7 @@ pub(crate) async fn import( // To import an object, ensure the user has the `Create` access right. // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users { - let has_permission = user_has_permission( - user, - None, - &cosmian_kmip::kmip_2_1::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() - )) - } - } + enforce_create_permission(kms, user).await?; // When replace_existing is requested with an explicit UID, verify the caller owns the // target object. Without this check, any user with Create rights could overwrite another diff --git a/crate/server/src/core/operations/key_ops/mod.rs b/crate/server/src/core/operations/key_ops/mod.rs index 502ff31901..10b77e3934 100644 --- a/crate/server/src/core/operations/key_ops/mod.rs +++ b/crate/server/src/core/operations/key_ops/mod.rs @@ -20,16 +20,59 @@ use time::OffsetDateTime; use super::digest::digest; use crate::{ - core::{KMS, uid_utils::has_prefix}, + core::{KMS, retrieve_object_utils::user_has_permission, uid_utils::has_prefix}, error::KmsError, + kms_bail, result::KResult, }; +/// Enforce that the caller has `Create` access-right. +/// +/// When `privileged_users` is configured, the user must either: +/// - have been explicitly granted the `Create` operation on any object, +/// - be listed in `privileged_users`, or +/// - be the `default_username` (unauthenticated / local access). +/// +/// This check applies uniformly to `Create`, `CreateKeyPair`, `Import`, and `Register`. +pub(crate) async fn enforce_create_permission(kms: &KMS, user: &str) -> KResult<()> { + if let Some(ref users) = kms.params.privileged_users { + let has_permission = user_has_permission(user, None, &KmipOperation::Create, kms).await?; + + if !has_permission + && !users.iter().any(|u| u == user) + && user != kms.params.default_username + { + kms_bail!(KmsError::Unauthorized( + "User does not have create access-right.".to_owned() + )) + } + } + Ok(()) +} + +/// Reject requests that specify `ProtectionStorageMasks`. +/// +/// KMIP defines this field but the server does not implement storage-level +/// masking. Fail early rather than silently ignoring the field. +#[allow(clippy::missing_const_for_fn)] // kms_bail! is not const-compatible +pub(crate) fn reject_protection_storage_masks(has_masks: bool) -> KResult<()> { + if has_masks { + kms_bail!(KmsError::UnsupportedPlaceholder) + } + Ok(()) +} + /// Initialize lifecycle attributes on a newly created or imported object. /// -/// Sets state (`PreActive` or `Active` based on `requested_activation_date`), digest, -/// `initial_date`, `original_creation_date`, `last_change_date`, `activation_date` (if `Active`), -/// and `object_type` on the object's attributes. Returns a clone of the final attributes. +/// - No `requested_activation_date` → state `PreActive` (requires explicit +/// Activate call or auto-activation via `get_effective_state`). +/// - `requested_activation_date` ≤ now → state `Active` immediately. +/// - `requested_activation_date` > now → state `PreActive`, date stored for +/// auto-transition by `get_effective_state`. +/// +/// Also sets `digest`, `initial_date`, `original_creation_date`, +/// `last_change_date`, and `object_type`. Returns a clone of the final +/// attributes. pub(crate) fn setup_object_lifecycle( object: &mut Object, object_type: ObjectType, @@ -39,6 +82,8 @@ pub(crate) fn setup_object_lifecycle( let digest = digest(object)?; let attributes = object.attributes_mut()?; + // KMIP semantics: activation_date present and ≤ now → Active, + // otherwise PreActive (absent or future date). let activation_allows_active = requested_activation_date.is_some_and(|d| d <= now); let state = if activation_allows_active { State::Active @@ -55,7 +100,7 @@ pub(crate) fn setup_object_lifecycle( 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 + // PreActive with future date: store it so auto-transition works attributes.activation_date = Some(future_date); } @@ -218,7 +263,7 @@ mod tests { kmip_0::kmip_types::State, kmip_2_1::{ kmip_attributes::Attributes, - kmip_data_structures::{KeyBlock, KeyValue}, + kmip_data_structures::{KeyBlock, KeyMaterial, KeyValue}, kmip_objects::{Object, SymmetricKey}, kmip_types::{CryptographicAlgorithm, KeyFormatType}, }, @@ -232,7 +277,10 @@ mod tests { Object::SymmetricKey(SymmetricKey { key_block: KeyBlock { key_format_type: KeyFormatType::Raw, - key_value: Some(KeyValue::ByteString(Zeroizing::new(vec![1, 2, 3, 4]))), + key_value: Some(KeyValue::Structure { + key_material: KeyMaterial::ByteString(Zeroizing::new(vec![1, 2, 3, 4])), + attributes: Some(Attributes::default()), + }), key_compression_type: None, cryptographic_algorithm: Some(CryptographicAlgorithm::AES), cryptographic_length: Some(256), @@ -300,6 +348,33 @@ mod tests { Ok(()) } + #[test] + fn test_setup_object_lifecycle_past_date_gives_active() -> KResult<()> { + let mut obj = test_object(); + let past = time_normalize()? - Duration::hours(1); + let attrs = setup_object_lifecycle(&mut obj, ObjectType::SymmetricKey, Some(past))?; + assert_eq!(attrs.state, Some(State::Active)); + Ok(()) + } + + #[test] + fn test_setup_object_lifecycle_no_date_gives_preactive() -> KResult<()> { + let mut obj = test_object(); + let attrs = setup_object_lifecycle(&mut obj, ObjectType::SymmetricKey, None)?; + assert_eq!(attrs.state, Some(State::PreActive)); + Ok(()) + } + + #[test] + fn test_setup_object_lifecycle_future_date_gives_preactive() -> KResult<()> { + let mut obj = test_object(); + let future = time_normalize()? + Duration::hours(1); + let attrs = setup_object_lifecycle(&mut obj, ObjectType::SymmetricKey, Some(future))?; + assert_eq!(attrs.state, Some(State::PreActive)); + assert_eq!(attrs.activation_date, Some(future)); + Ok(()) + } + #[test] fn test_effective_state_active_remains_active() -> KResult<()> { let attrs = Attributes { diff --git a/crate/server/src/core/operations/mac.rs b/crate/server/src/core/operations/mac.rs index f2df7cc3bc..5a3dbea23d 100644 --- a/crate/server/src/core/operations/mac.rs +++ b/crate/server/src/core/operations/mac.rs @@ -368,7 +368,6 @@ mod tests { None, )?, "user", - None, ) .await? .unique_identifier, diff --git a/crate/server/src/core/operations/message.rs b/crate/server/src/core/operations/message.rs index 2e9dc883ab..4117a53b18 100644 --- a/crate/server/src/core/operations/message.rs +++ b/crate/server/src/core/operations/message.rs @@ -363,8 +363,6 @@ async fn process_operation( let start_time = std::time::Instant::now(); - let privileged_users = kms.params.privileged_users.clone(); - // Process the operation and capture the result let result: Result = async { Ok(match request_operation { @@ -434,15 +432,15 @@ async fn process_operation( Operation::CheckResponse(check(kms, kmip_request, user).await?) } Operation::Certify(kmip_request) => Operation::CertifyResponse( - kms.certify(*kmip_request, user, privileged_users) + kms.certify(*kmip_request, user) .await?, ), Operation::Create(kmip_request) => Operation::CreateResponse( - kms.create(kmip_request, user, privileged_users) + kms.create(kmip_request, user) .await?, ), Operation::CreateKeyPair(kmip_request) => Operation::CreateKeyPairResponse( - kms.create_key_pair(*kmip_request, user, privileged_users) + kms.create_key_pair(*kmip_request, user) .await?, ), Operation::Decrypt(kmip_request) => { @@ -476,7 +474,7 @@ async fn process_operation( Operation::HashResponse(kms.hash(kmip_request, user).await?) } Operation::Import(kmip_request) => Operation::ImportResponse( - kms.import(*kmip_request, user, privileged_users) + kms.import(*kmip_request, user) .await?, ), Operation::Locate(kmip_request) => { @@ -492,18 +490,18 @@ async fn process_operation( Operation::QueryResponse(Box::new(kms.query(kmip_request).await?)) } Operation::Register(kmip_request) => Operation::RegisterResponse( - kms.register(*kmip_request, user, privileged_users) + kms.register(*kmip_request, user) .await?, ), Operation::ReCertify(kmip_request) => Operation::ReCertifyResponse( - kms.recertify(*kmip_request, user, privileged_users) + kms.recertify(*kmip_request, user) .await?, ), Operation::ReKey(kmip_request) => { - Operation::ReKeyResponse(kms.rekey(kmip_request, user, privileged_users).await?) + Operation::ReKeyResponse(kms.rekey(kmip_request, user).await?) } Operation::ReKeyKeyPair(kmip_request) => Operation::ReKeyKeyPairResponse( - kms.rekey_keypair(*kmip_request, user, privileged_users) + kms.rekey_keypair(*kmip_request, user) .await?, ), Operation::Revoke(kmip_request) => { diff --git a/crate/server/src/core/operations/mod.rs b/crate/server/src/core/operations/mod.rs index 4649a34852..520ed2c375 100644 --- a/crate/server/src/core/operations/mod.rs +++ b/crate/server/src/core/operations/mod.rs @@ -1,5 +1,6 @@ mod activate; mod attributes; +mod auto_rotate; mod certify; mod check; mod create; @@ -36,6 +37,7 @@ pub(crate) use activate::activate; pub(crate) use attributes::{ add_attribute, delete_attribute, get_attributes, modify_attribute, set_attribute, }; +pub(crate) use auto_rotate::{dispatch_renewal_warnings, run_auto_rotation}; pub(crate) use certify::certify; pub(crate) use check::check; pub(crate) use create::create; diff --git a/crate/server/src/core/operations/recertify.rs b/crate/server/src/core/operations/recertify.rs index ced68e277a..3327b05af7 100644 --- a/crate/server/src/core/operations/recertify.rs +++ b/crate/server/src/core/operations/recertify.rs @@ -34,7 +34,10 @@ use super::rekey::{ use crate::{ core::{ KMS, - operations::certify::{build_and_sign_certificate, get_issuer, get_subject}, + operations::{ + certify::{build_and_sign_certificate, get_issuer, get_subject}, + key_ops::reject_protection_storage_masks, + }, retrieve_object_utils::retrieve_object_for_operation, }, error::KmsError, @@ -44,8 +47,8 @@ use crate::{ /// 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, + /// The `offset` from the `ReCertify` request (date computation per KMIP §6.1.45). + offset: Option, } /// KMIP `ReCertify` operation — certificate rotation with new UID. @@ -57,17 +60,15 @@ 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( + Box::pin(execute_rekey( &CertificateRekey { offset }, kms, &request, owner, - &privileged_users, - ) + )) .await } @@ -80,13 +81,10 @@ impl RekeyOperation for CertificateRekey { kms: &KMS, request: &ReCertify, user: &str, - privileged: &Option>, ) -> KResult> { - if request.protection_storage_masks.is_some() { - kms_bail!(KmsError::UnsupportedPlaceholder) - } + reject_protection_storage_masks(request.protection_storage_masks.is_some())?; - enforce_privileged_user(kms, user, privileged).await?; + enforce_privileged_user(kms, user).await?; let uid = request .unique_identifier @@ -146,7 +144,7 @@ impl RekeyOperation for CertificateRekey { // 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?; + let subject = Box::pin(get_subject(kms, &certify_request, owner)).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 @@ -179,7 +177,7 @@ impl RekeyOperation for CertificateRekey { .first_mut() .ok_or_else(|| KmsError::InvalidRequest("no replacement object".to_owned()))?; - // Use shared date arithmetic for offset-based activation/deactivation + // Use shared date computation 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; diff --git a/crate/server/src/core/operations/register.rs b/crate/server/src/core/operations/register.rs index 363a9fe45f..776036c1bd 100644 --- a/crate/server/src/core/operations/register.rs +++ b/crate/server/src/core/operations/register.rs @@ -1,5 +1,4 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ - self, kmip_0::kmip_types::State, kmip_2_1::{ kmip_objects::ObjectType, @@ -10,7 +9,10 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ }; use cosmian_logger::{debug, trace}; -use super::import::process_opaque_object; +use super::{ + import::process_opaque_object, + key_ops::{enforce_create_permission, reject_protection_storage_masks}, +}; use crate::{ core::{ KMS, @@ -18,7 +20,6 @@ use crate::{ process_certificate, process_private_key, process_public_key, process_secret_data, process_symmetric_key, }, - retrieve_object_utils::user_has_permission, }, error::KmsError, kms_bail, @@ -29,31 +30,10 @@ pub(crate) async fn register( kms: &KMS, mut request: Register, owner: &str, - - privileged_users: Option>, ) -> KResult { trace!("{request}"); - if request.protection_storage_masks.is_some() { - kms_bail!(KmsError::UnsupportedPlaceholder) - } - - // To register an object, check that the user has `Create` access right - // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users.clone() { - let has_permission = user_has_permission( - owner, - None, - &cosmian_kmip::kmip_2_1::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 register objects.".to_owned() - )) - } - } + reject_protection_storage_masks(request.protection_storage_masks.is_some())?; + enforce_create_permission(kms, owner).await?; if request.object_type != request.object.object_type() { kms_bail!(KmsError::InconsistentOperation( diff --git a/crate/server/src/core/operations/rekey/common.rs b/crate/server/src/core/operations/rekey/common.rs index 97b5ab95b1..994381537b 100644 --- a/crate/server/src/core/operations/rekey/common.rs +++ b/crate/server/src/core/operations/rekey/common.rs @@ -1,4 +1,6 @@ -//! Shared logic for KMIP `ReKey` (§4.4), `ReKeyKeyPair` (§4.5), and `ReCertify` (§4.8) operations. +//! Shared logic for KMIP `ReKey` (§6.1.46), `ReKeyKeyPair` (§6.1.47), and `ReCertify` (§6.1.45) operations. +//! +//! All section references are to KMIP 2.1 (OASIS Standard). //! //! All rotation operations follow the same pattern via the [`RekeyOperation`] trait: //! - Validate inputs and resolve candidates for rotation. @@ -16,7 +18,6 @@ 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}, @@ -36,8 +37,7 @@ use uuid::Uuid; use crate::{ core::{ KMS, - operations::key_ops::setup_object_lifecycle, - retrieve_object_utils::user_has_permission, + operations::key_ops::{enforce_create_permission, setup_object_lifecycle}, wrapping::{unwrap_object, wrap_and_cache, wrap_object}, }, error::KmsError, @@ -65,15 +65,30 @@ pub(crate) fn extract_rewrap_spec(object: &Object) -> Option 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) +/// Returns: +/// - `Ok(Some(uid))` if the object is wrapped and has a valid wrapping key UID. +/// - `Ok(None)` if the object has no key block or no `KeyWrappingData` (i.e. not wrapped). +/// - `Err(...)` if the object has `KeyWrappingData` but the `EncryptionKeyInformation` +/// is missing or has no `UniqueIdentifier` — this is a structural invariant violation. +pub(crate) fn extract_wrapping_key_uid(object: &Object) -> KResult> { + let Some(kb) = object.key_block().ok() else { + return Ok(None); + }; + let Some(kwd) = &kb.key_wrapping_data else { + return Ok(None); + }; + // Object has wrapping data → the wrapping key UID MUST be present + let eki = kwd.encryption_key_information.as_ref().ok_or_else(|| { + KmsError::InvalidRequest( + "Object has KeyWrappingData but no EncryptionKeyInformation".to_owned(), + ) + })?; + let uid = eki.unique_identifier.as_str().ok_or_else(|| { + KmsError::InvalidRequest( + "EncryptionKeyInformation has no UniqueIdentifier for the wrapping key".to_owned(), + ) + })?; + Ok(Some(uid.to_owned())) } /// Copy the `WrappingKeyLink` from an old (wrapped) object to the new object's attributes. @@ -81,13 +96,17 @@ pub(crate) fn extract_wrapping_key_uid(object: &Object) -> Option { /// 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) { +pub(crate) fn preserve_wrapping_key_link( + old_object: &Object, + new_attrs: &mut Attributes, +) -> KResult<()> { + if let Some(wrapping_key_uid) = extract_wrapping_key_uid(old_object)? { new_attrs.set_link( LinkType::WrappingKeyLink, LinkedObjectIdentifier::TextString(wrapping_key_uid), ); } + Ok(()) } /// Retrieve all eligible objects matching the given identifier, filtered by state and type. @@ -164,7 +183,6 @@ pub(crate) trait RekeyOperation { kms: &KMS, request: &Self::Request, user: &str, - privileged: &Option>, ) -> impl std::future::Future>>; /// Step 2: Detect wrapping context on existing object(s). @@ -371,9 +389,8 @@ pub(crate) async fn execute_rekey( kms: &KMS, request: &T::Request, user: &str, - privileged: &Option>, ) -> KResult { - let candidates = op.validate(kms, request, user, privileged).await?; + let candidates = op.validate(kms, request, user).await?; let wrap_specs = op.detect_wrapping(&candidates); let mut replacements = op.generate_replacement(kms, &candidates).await?; op.prepare_attributes(kms, &candidates, &mut replacements)?; @@ -389,7 +406,7 @@ pub(crate) async fn execute_rekey( /// Dates computed for a replacement key based on the existing key's dates and an optional offset. /// -/// Per KMIP 1.4 Tables 172/176: +/// Per KMIP 1.4 §4.4 Table 172 / §4.5 Table 176 / §4.8 Table 186: /// - `activation = initialization + offset` (if offset provided) /// - `deactivation = old_deactivation + (new_activation - old_activation)` (if both exist) #[allow(clippy::struct_field_names)] @@ -401,18 +418,17 @@ pub(crate) struct ReplacementDates { /// 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: +/// KMIP 1.4 §4.4 Table 172 / §4.5 Table 176 / §4.8 Table 186: /// - 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, + offset: Option, ) -> KResult { let now = time_normalize()?; - let activation_date = - Some(offset.map_or(now, |secs| now + time::Duration::seconds(i64::from(secs)))); + let activation_date = Some(offset.map_or(now, |secs| now + time::Duration::seconds(secs))); let deactivation_date = match (old_attrs.deactivation_date, old_attrs.activation_date) { (Some(old_deactivation), Some(old_activation)) => { @@ -432,7 +448,7 @@ pub(crate) fn compute_replacement_dates( }) } -/// Prepare attributes for a replacement key, following KMIP 1.4 §4.4 Table 173 / §4.5 Table 177. +/// Prepare attributes for a replacement key, following KMIP 1.4 §4.4 Table 173 / §4.5 Table 177 / §4.8 Table 187. /// /// This function: /// - Copies attributes from the existing key @@ -440,12 +456,12 @@ pub(crate) fn compute_replacement_dates( /// - 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 +/// - Applies offset-based date computation /// - 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, + offset: Option, ) -> KResult { let dates = compute_replacement_dates(old_attrs, offset)?; @@ -480,7 +496,7 @@ pub(crate) fn prepare_replacement_attributes( /// Update the old key's attributes after a rekey operation. /// -/// Per KMIP 1.4 §4.4 Table 173 / §4.5 Table 177: +/// Per KMIP 1.4 §4.4 Table 173 / §4.5 Table 177 / §4.8 Table 187: /// - Sets `ReplacementObjectLink` → new key /// - Removes the Name attribute (transferred to the replacement) /// - Updates Last Change Date to now @@ -536,22 +552,10 @@ pub(crate) const fn clear_rotation_flags_on_old_key(old_attrs: &mut Attributes) /// 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(()) +/// must either have `Create` permission or be in the privileged users list +/// (configured in `kms.params.privileged_users`). +pub(crate) async fn enforce_privileged_user(kms: &KMS, user: &str) -> KResult<()> { + enforce_create_permission(kms, user).await } /// Validate that request attributes do not attempt to change cryptographic parameters. @@ -688,6 +692,10 @@ pub(crate) fn setup_new_key( } } + // Pass the activation_date directly to setup_object_lifecycle: + // - Past/present date → Active (replacement inherits active state) + // - Future date → PreActive (scheduled activation) + // - None → PreActive (requires explicit Activate) setup_object_lifecycle(key_object, object_type, replacement_attrs.activation_date) } diff --git a/crate/server/src/core/operations/rekey/keypair.rs b/crate/server/src/core/operations/rekey/keypair.rs index 2ae718a673..c214774244 100644 --- a/crate/server/src/core/operations/rekey/keypair.rs +++ b/crate/server/src/core/operations/rekey/keypair.rs @@ -30,17 +30,20 @@ use crate::core::cover_crypt::rekey_keypair_cover_crypt; use crate::{ core::{ KMS, - operations::{create_key_pair::generate_key_pair, key_ops::ObjectWithMetadataOps}, + operations::{ + create_key_pair::generate_key_pair, + key_ops::{ObjectWithMetadataOps, reject_protection_storage_masks}, + }, }, error::KmsError, kms_bail, result::{KResult, KResultHelper}, }; -/// Implementor of [`RekeyOperation`] for KMIP `ReKeyKeyPair` (§4.5) on asymmetric key pairs. +/// Implementor of [`RekeyOperation`] for KMIP `ReKeyKeyPair` (KMIP 1.4 §4.5 / KMIP 2.1 §6.1.47) on asymmetric key pairs. struct KeypairRekey { - /// The `offset` from the `ReKeyKeyPair` request (date arithmetic per KMIP Table 176). - offset: Option, + /// The `offset` from the `ReKeyKeyPair` request (date computation per KMIP 1.4 Table 176 / KMIP 2.1 Table 308). + offset: Option, } /// KMIP `ReKeyKeyPair` operation for asymmetric key pairs. @@ -51,7 +54,7 @@ struct KeypairRekey { /// - 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. +/// - If `offset` is provided, date computation 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 @@ -60,16 +63,13 @@ 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? - { + if let Some(response) = try_covercrypt_rekey(kms, &request, user).await? { return Ok(response); } @@ -80,7 +80,6 @@ pub(crate) async fn rekey_keypair( kms, &request, user, - &privileged_users, ) .await } @@ -91,7 +90,6 @@ async fn try_covercrypt_rekey( kms: &KMS, request: &ReKeyKeyPair, user: &str, - privileged_users: Option>, ) -> KResult> { let uid_or_tags = request .private_key_unique_identifier @@ -124,7 +122,6 @@ async fn try_covercrypt_rekey( user, action, owm.attributes().sensitive.unwrap_or(false), - privileged_users, )) .await .context("ReKeyKeyPair: Covercrypt rekey failed")?; @@ -144,16 +141,14 @@ impl RekeyOperation for KeypairRekey { 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) - } + reject_protection_storage_masks( + request.common_protection_storage_masks.is_some() + || request.private_protection_storage_masks.is_some() + || request.public_protection_storage_masks.is_some(), + )?; - enforce_privileged_user(kms, user, privileged).await?; + enforce_privileged_user(kms, user).await?; let uid_or_tags = request .private_key_unique_identifier @@ -162,6 +157,18 @@ impl RekeyOperation for KeypairRekey { .as_str() .context("ReKeyKeyPair: the private key unique identifier must be a string")?; + // HSM-managed keys cannot be re-keyed via KMIP: they have no KMIP attribute + // storage and are often non-extractable (CKA_EXTRACTABLE = false). + // Use PKCS#11 vendor tools for HSM key lifecycle management. + if uid_or_tags.starts_with("hsm::") { + return Err(KmsError::NotSupported( + "Re-Key Key Pair is not supported for HSM-managed keys. \ + Use PKCS#11 vendor tools or the HSM administration console \ + to manage HSM key lifecycle." + .to_owned(), + )); + } + for owm in retrieve_eligible_keys(kms, uid_or_tags, ObjectType::PrivateKey).await? { if !owm .user_can_perform_operation(user, &KmipOperation::Rekey, kms) @@ -375,7 +382,7 @@ fn prepare_sk_replacement( Some((pk_new_uid, LinkType::PublicKeyLink)), vendor_id, )?; - preserve_wrapping_key_link(candidate.owm.object(), &mut sk.attributes); + preserve_wrapping_key_link(candidate.owm.object(), &mut sk.attributes)?; Ok(()) } @@ -395,7 +402,7 @@ fn prepare_pk_replacement( Some((sk_new_uid, LinkType::PrivateKeyLink)), vendor_id, )?; - preserve_wrapping_key_link(candidate.owm.object(), &mut pk.attributes); + 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(()) diff --git a/crate/server/src/core/operations/rekey/mod.rs b/crate/server/src/core/operations/rekey/mod.rs index f76dc9d3d5..71ed1fff07 100644 --- a/crate/server/src/core/operations/rekey/mod.rs +++ b/crate/server/src/core/operations/rekey/mod.rs @@ -1,7 +1,8 @@ -//! KMIP key rotation operations: `ReKey` (§4.4), `ReKeyKeyPair` (§4.5). +//! KMIP key rotation operations: `ReKey` (KMIP 1.4 §4.4 / KMIP 2.1 §6.1.46), +//! `ReKeyKeyPair` (KMIP 1.4 §4.5 / KMIP 2.1 §6.1.47), `ReCertify` (KMIP 1.4 §4.8 / KMIP 2.1 §6.1.45). //! //! Submodules: -//! - [`common`] — Shared helpers for date arithmetic, attribute preparation, +//! - [`common`] — Shared helpers for date computation, 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). diff --git a/crate/server/src/core/operations/rekey/symmetric.rs b/crate/server/src/core/operations/rekey/symmetric.rs index df55a5f0bb..0a60a48dd1 100644 --- a/crate/server/src/core/operations/rekey/symmetric.rs +++ b/crate/server/src/core/operations/rekey/symmetric.rs @@ -14,42 +14,32 @@ use super::common::{ set_rotation_metadata_on_new_key, validate_no_crypto_param_change, }; use crate::{ - core::{KMS, operations::key_ops::ObjectWithMetadataOps}, + core::{ + KMS, + operations::key_ops::{ObjectWithMetadataOps, reject_protection_storage_masks}, + }, error::KmsError, - kms_bail, result::{KResult, KResultHelper}, }; -/// Implementor of [`RekeyOperation`] for KMIP `ReKey` (§4.4) on symmetric keys. +/// Implementor of [`RekeyOperation`] for KMIP `ReKey` (KMIP 2.1 §6.1.46) on symmetric keys. pub(crate) struct SymmetricRekey { - /// The `offset` from the `ReKey` request (date arithmetic per KMIP Table 172). - offset: Option, + /// The `Offset` from the `ReKey` request — an interval added to the new key's + /// `Initial Date` to compute its `Activation Date` (KMIP 2.1 §6.1.46 Table 305). + offset: Option, } -/// KMIP `ReKey` operation for symmetric keys. +/// KMIP `ReKey` operation for symmetric keys (KMIP 2.1 §6.1.46). /// -/// 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 { +pub(crate) async fn rekey(kms: &KMS, request: ReKey, owner: &str) -> KResult { trace!("ReKey: {}", serde_json::to_string(&request)?); let offset = request.offset; - execute_rekey( - &SymmetricRekey { offset }, - kms, - &request, - owner, - &privileged_users, - ) - .await + execute_rekey(&SymmetricRekey { offset }, kms, &request, owner).await } impl RekeyOperation for SymmetricRekey { @@ -61,13 +51,10 @@ impl RekeyOperation for SymmetricRekey { kms: &KMS, request: &ReKey, user: &str, - privileged: &Option>, ) -> KResult> { - if request.protection_storage_masks.is_some() { - kms_bail!(KmsError::UnsupportedPlaceholder) - } + reject_protection_storage_masks(request.protection_storage_masks.is_some())?; - enforce_privileged_user(kms, user, privileged).await?; + enforce_privileged_user(kms, user).await?; let uid_or_tags = request .unique_identifier @@ -76,6 +63,18 @@ impl RekeyOperation for SymmetricRekey { .as_str() .context("Rekey: the symmetric key unique identifier must be a string")?; + // HSM-managed keys cannot be re-keyed via KMIP: they have no KMIP attribute + // storage and are often non-extractable (CKA_EXTRACTABLE = false). + // Use PKCS#11 vendor tools for HSM key lifecycle management. + if uid_or_tags.starts_with("hsm::") { + return Err(KmsError::NotSupported( + "Re-Key is not supported for HSM-managed keys. \ + Use PKCS#11 vendor tools or the HSM administration console \ + to manage HSM key lifecycle." + .to_owned(), + )); + } + for owm in retrieve_eligible_keys(kms, uid_or_tags, ObjectType::SymmetricKey).await? { if !owm .user_can_perform_operation(user, &KmipOperation::Rekey, kms) @@ -171,7 +170,7 @@ impl RekeyOperation for SymmetricRekey { )?; // Preserve WrappingKeyLink if the old key was wrapped - preserve_wrapping_key_link(candidate.owm.object(), &mut replacement.attributes); + 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())?; diff --git a/crate/server/src/core/wrapping/wrap.rs b/crate/server/src/core/wrapping/wrap.rs index f22e56bc26..1683520714 100644 --- a/crate/server/src/core/wrapping/wrap.rs +++ b/crate/server/src/core/wrapping/wrap.rs @@ -14,7 +14,7 @@ use cosmian_kms_server_database::reexport::{ }, cosmian_kms_crypto::crypto::wrap::{key_data_to_wrap, wrap_object_with_key}, }; -use cosmian_logger::{debug, trace, warn}; +use cosmian_logger::{debug, trace}; use crate::{ core::{KMS, uid_utils::has_prefix, wrapping::unwrap_object}, @@ -71,20 +71,10 @@ pub(crate) async fn wrap_and_cache( // A key cannot be its own wrapping key. if wrapping_key_id == unique_identifier.to_string() { - // 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(()); + 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" + ))); } // This is useful to store a key on the default data store but wrapped by a key stored in an HSM diff --git a/crate/server/src/cron.rs b/crate/server/src/cron.rs index e6134814d0..a27c78ef7a 100644 --- a/crate/server/src/cron.rs +++ b/crate/server/src/cron.rs @@ -7,7 +7,53 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ use cosmian_logger::debug; use tokio::sync::oneshot; -use crate::core::KMS; +use crate::core::{ + KMS, + operations::{dispatch_renewal_warnings, run_auto_rotation}, +}; + +/// Spawn a background thread that periodically runs the key auto-rotation check. +/// The thread runs independently of the metrics cron and is spawned whenever +/// `auto_rotation_check_interval_secs > 0` in the server configuration. +/// +/// Returns a `oneshot::Sender<()>` that cleanly stops the thread when sent. +pub fn spawn_auto_rotation_cron(kms: Arc) -> oneshot::Sender<()> { + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let interval_secs = kms.params.auto_rotation_check_interval_secs; + + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + debug!("[auto-rotate-cron] Failed to build runtime: {}", e); + return; + } + }; + + rt.block_on(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); + let mut shutdown_rx = shutdown_rx; + loop { + tokio::select! { + _ = interval.tick() => { + debug!("[auto-rotate-cron] Running scheduled key auto-rotation check"); + Box::pin(run_auto_rotation(&kms)).await; + Box::pin(dispatch_renewal_warnings(&kms)).await; + } + _ = &mut shutdown_rx => { + debug!("[auto-rotate-cron] Shutdown signal received; stopping cron thread"); + break; + } + } + } + }); + }); + + shutdown_tx +} /// Spawn a background thread that periodically refreshes metrics. /// Returns a oneshot Sender that, when sent, cleanly stops the cron thread. diff --git a/crate/server/src/main.rs b/crate/server/src/main.rs index 6a02f9f7fe..9f42ea2d6d 100644 --- a/crate/server/src/main.rs +++ b/crate/server/src/main.rs @@ -347,6 +347,7 @@ mod tests { non_revocable_key_id: None, privileged_users: None, print_default_config: false, + auto_rotation_check_interval_secs: 0, }; let toml_string = r#" diff --git a/crate/server/src/routes/access.rs b/crate/server/src/routes/access.rs index ec51e27aa7..8789345b93 100644 --- a/crate/server/src/routes/access.rs +++ b/crate/server/src/routes/access.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use actix_web::{ HttpRequest, get, post, - web::{self, Data, Json, Path}, + web::{Data, Json, Path}, }; use cosmian_kms_access::access::{ Access, AccessRightsObtainedResponse, CreatePermissionResponse, ObjectOwnedResponse, @@ -98,7 +98,6 @@ pub(crate) async fn grant_access( req: HttpRequest, access: Json, kms: Data>, - privileged_users: web::Data>>, ) -> KResult> { let span = tracing::span!(tracing::Level::ERROR, "grant_access"); let _enter = span.enter(); @@ -111,8 +110,7 @@ pub(crate) async fn grant_access( "POST /access/grant" ); - kms.grant_access(&access, &user, privileged_users.as_ref().clone()) - .await?; + kms.grant_access(&access, &user).await?; debug!("Access granted on {}", access.user_id); Ok(Json(SuccessResponse { @@ -126,7 +124,6 @@ pub(crate) async fn revoke_access( req: HttpRequest, access: Json, kms: Data>, - privileged_users: Data>>, ) -> KResult> { let span = tracing::span!(tracing::Level::ERROR, "revoke_access"); let _enter = span.enter(); @@ -139,8 +136,7 @@ pub(crate) async fn revoke_access( "POST /access/revoke" ); - kms.revoke_access(&access, &user, privileged_users.as_ref().clone()) - .await?; + kms.revoke_access(&access, &user).await?; debug!("Access revoke for {}", access.user_id); Ok(Json(SuccessResponse { @@ -153,14 +149,13 @@ pub(crate) async fn revoke_access( pub(crate) async fn get_create_access( req: HttpRequest, kms: Data>, - privileged_users: web::Data>>, ) -> KResult> { let span = tracing::span!(tracing::Level::INFO, "get_create_access"); let _enter = span.enter(); let user = kms.get_user(&req); - let has_create_permission = match privileged_users.as_ref() { + let has_create_permission = match kms.params.privileged_users.as_ref() { Some(users) if users.contains(&user) => true, Some(_) => { user_has_permission( @@ -183,15 +178,15 @@ pub(crate) async fn get_create_access( pub(crate) async fn get_privileged_access( req: HttpRequest, kms: Data>, - privileged_users: web::Data>>, ) -> KResult> { let span = tracing::span!(tracing::Level::INFO, "get_create_access"); let _enter = span.enter(); let user = kms.get_user(&req); - let has_privileged_access = privileged_users - .as_ref() + let has_privileged_access = kms + .params + .privileged_users .as_ref() .is_some_and(|users| users.contains(&user)); Ok(Json(PrivilegedAccessResponse { diff --git a/crate/server/src/routes/aws_xks/key_metadata.rs b/crate/server/src/routes/aws_xks/key_metadata.rs index 4022be8d05..68f4c0610e 100644 --- a/crate/server/src/routes/aws_xks/key_metadata.rs +++ b/crate/server/src/routes/aws_xks/key_metadata.rs @@ -311,7 +311,7 @@ async fn create_key( protection_storage_masks: None, }; - if let Err(e) = kms.create(create, &kms.params.default_username, None).await { + if let Err(e) = kms.create(create, &kms.params.default_username).await { // If the key already exists, ignore the creation error (idempotent CreateKey). let get_att_response = kms .get_attributes( @@ -347,7 +347,6 @@ async fn create_key( ], }, &kms.params.default_username, - None, ) .await .map_err(|e| XksErrorReply { diff --git a/crate/server/src/routes/crypto/keys.rs b/crate/server/src/routes/crypto/keys.rs index a4461a1b55..2ef0d54295 100644 --- a/crate/server/src/routes/crypto/keys.rs +++ b/crate/server/src/routes/crypto/keys.rs @@ -161,7 +161,7 @@ async fn generate_symmetric_key( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .create(create_req, user, None) + .create(create_req, user) .await .map_err(CryptoApiError::from)?; @@ -210,7 +210,7 @@ async fn generate_ec_key_pair( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .create_key_pair(create_req, user, None) + .create_key_pair(create_req, user) .await .map_err(CryptoApiError::from)?; @@ -263,7 +263,7 @@ async fn generate_rsa_key_pair( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .create_key_pair(create_req, user, None) + .create_key_pair(create_req, user) .await .map_err(CryptoApiError::from)?; @@ -313,7 +313,7 @@ async fn generate_okp_key_pair( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .create_key_pair(create_req, user, None) + .create_key_pair(create_req, user) .await .map_err(CryptoApiError::from)?; @@ -437,7 +437,7 @@ async fn import_symmetric_key( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .import(import_req, user, None) + .import(import_req, user) .await .map_err(CryptoApiError::from)?; @@ -677,7 +677,7 @@ async fn import_ec_key( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .import(import_req, user, None) + .import(import_req, user) .await .map_err(CryptoApiError::from)?; @@ -840,7 +840,7 @@ async fn import_rsa_key( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .import(import_req, user, None) + .import(import_req, user) .await .map_err(CryptoApiError::from)?; @@ -943,7 +943,7 @@ async fn import_okp_key( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .import(import_req, user, None) + .import(import_req, user) .await .map_err(CryptoApiError::from)?; diff --git a/crate/server/src/routes/crypto/unwrap.rs b/crate/server/src/routes/crypto/unwrap.rs index 6a40810f11..39f94845ee 100644 --- a/crate/server/src/routes/crypto/unwrap.rs +++ b/crate/server/src/routes/crypto/unwrap.rs @@ -206,7 +206,7 @@ pub(crate) async fn unwrap_key( }; let import_response = kms - .import(import_request, &user, None) + .import(import_request, &user) .await .map_err(CryptoApiError::from)?; diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index dc1d67a3bd..fd276c438a 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -146,7 +146,7 @@ pub async fn handle_google_cse_rsa_keypair( None, )?; kms_server - .create_key_pair(create_request, &server_params.default_username, None) + .create_key_pair(create_request, &server_params.default_username) .await .map(|cr| { ( @@ -299,7 +299,7 @@ async fn import_cse_migration_key( false, vec![], )?; - kms_server.import(import_request_sk, &server_params.default_username, None) + kms_server.import(import_request_sk, &server_params.default_username) }; let import_pk_fut = { // Import PublicKey @@ -312,7 +312,7 @@ async fn import_cse_migration_key( false, vec![], )?; - kms_server.import(import_request_pk, &server_params.default_username, None) + kms_server.import(import_request_pk, &server_params.default_username) }; try_join!(import_sk_fut, import_pk_fut) @@ -362,6 +362,13 @@ pub async fn start_kms_server( None }; + // Spawn background auto-rotation cron thread and retain shutdown signal + let auto_rotation_shutdown_tx = if kms_server.params.auto_rotation_check_interval_secs > 0 { + Some(cron::spawn_auto_rotation_cron(kms_server.clone())) + } else { + None + }; + // Handle Google RSA Keypair for CSE Kacls migration if server_params.google_cse.google_cse_enable { handle_google_cse_rsa_keypair(&kms_server, &server_params) @@ -386,6 +393,10 @@ pub async fn start_kms_server( if let Some(tx) = metrics_shutdown_tx { let _ = tx.send(()); } + // Signal the auto-rotation cron thread to stop + if let Some(tx) = auto_rotation_shutdown_tx { + let _ = tx.send(()); + } if let Some(ss_command_tx) = ss_command_tx { // Send a shutdown command to the socket server ss_command_tx @@ -720,8 +731,6 @@ pub async fn prepare_kms_server(kms_server: Arc) -> KResult> = kms_server.params.privileged_users.clone(); - // Compute the public URL first so we can use it to derive the session key let kms_public_url = kms_server.params.kms_public_url.clone().unwrap_or_else(|| { format!( @@ -1071,7 +1080,6 @@ pub async fn prepare_kms_server(kms_server: Arc) -> KResult) -> KResult KResult<()> { None, ) .unwrap(); - let create_response = kms.create(req, owner, None).await.unwrap(); + let create_response = kms.create(req, owner).await.unwrap(); let aes_kek_id = create_response.unique_identifier.to_string(); // Test invalid base64url - contains invalid characters @@ -157,7 +157,7 @@ async fn test_wrap_unwrap_error_cases() -> KResult<()> { None, ) .unwrap(); - let create_response = kms.create(req, owner, None).await.unwrap(); + let create_response = kms.create(req, owner).await.unwrap(); let aes_kek_id = create_response.unique_identifier.to_string(); let invalid_size_request = WrapKeyRequest { @@ -226,7 +226,7 @@ async fn test_wrap_unwrap_roundtrip_aes256_kw() -> KResult<()> { EMPTY_TAGS, )?; - let import_response = kms.import(import_request, owner, None).await?; + let import_response = kms.import(import_request, owner).await?; let kek_id = import_response.unique_identifier.to_string(); let wrap_request = WrapKeyRequest { @@ -316,7 +316,7 @@ async fn test_wrap_unwrap_roundtrip_aes256_kwp() -> KResult<()> { ) .unwrap(); - let import_response = kms.import(import_request, owner, None).await?; + let import_response = kms.import(import_request, owner).await?; let kek_id = import_response.unique_identifier.to_string(); let wrap_request = WrapKeyRequest { @@ -376,7 +376,6 @@ async fn test_wrap_unwrap_roundtrip_rsa_oaep_256() -> KResult<()> { None, )?, owner, - None, ) .await?; let key_id_private = create_keys.private_key_unique_identifier.to_string(); diff --git a/crate/server/src/tests/bulk_encrypt_decrypt_tests.rs b/crate/server/src/tests/bulk_encrypt_decrypt_tests.rs index 16cca32ed6..7f012d36fe 100644 --- a/crate/server/src/tests/bulk_encrypt_decrypt_tests.rs +++ b/crate/server/src/tests/bulk_encrypt_decrypt_tests.rs @@ -33,7 +33,7 @@ const NUM_MESSAGES: usize = 1000; #[tokio::test] async fn bulk_encrypt_decrypt() -> KResult<()> { cosmian_logger::log_init(option_env!("RUST_LOG")); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let response: CreateResponse = test_utils::post_2_1( &app, @@ -94,7 +94,7 @@ async fn bulk_encrypt_decrypt() -> KResult<()> { #[tokio::test] async fn single_encrypt_decrypt_cbc_mode() -> KResult<()> { cosmian_logger::log_init(option_env!("RUST_LOG")); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let response: CreateResponse = test_utils::post_2_1( &app, diff --git a/crate/server/src/tests/cover_crypt_tests/integration_tests.rs b/crate/server/src/tests/cover_crypt_tests/integration_tests.rs index 8a9a8667f6..0adb1ffabe 100644 --- a/crate/server/src/tests/cover_crypt_tests/integration_tests.rs +++ b/crate/server/src/tests/cover_crypt_tests/integration_tests.rs @@ -34,7 +34,7 @@ async fn integration_tests_use_ids_no_tags() -> KResult<()> { // cosmian_logger::log_init(Some("debug")); cosmian_logger::log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let access_structure = r#"{"Security Level::<":["Protected","Confidential","Top Secret::+"],"Department":["RnD","HR","MKG","FIN"]}"#; // create Key Pair diff --git a/crate/server/src/tests/cover_crypt_tests/integration_tests_bulk.rs b/crate/server/src/tests/cover_crypt_tests/integration_tests_bulk.rs index bb077f3542..edd00df92f 100644 --- a/crate/server/src/tests/cover_crypt_tests/integration_tests_bulk.rs +++ b/crate/server/src/tests/cover_crypt_tests/integration_tests_bulk.rs @@ -22,7 +22,7 @@ use crate::{result::KResult, tests::test_utils}; #[tokio::test] async fn integration_tests_bulk() -> KResult<()> { // cosmian_logger::log_init("trace,hyper=info,reqwest=info"); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // Parse the json access_structure file let access_structure = r#"{"Security Level::<":["Protected","Confidential","Top Secret::+"],"Department":["RnD","HR","MKG","FIN"]}"#; diff --git a/crate/server/src/tests/cover_crypt_tests/integration_tests_tags.rs b/crate/server/src/tests/cover_crypt_tests/integration_tests_tags.rs index 084c1f0ba8..17de30756c 100644 --- a/crate/server/src/tests/cover_crypt_tests/integration_tests_tags.rs +++ b/crate/server/src/tests/cover_crypt_tests/integration_tests_tags.rs @@ -28,7 +28,7 @@ use crate::{ #[tokio::test] async fn test_re_key_with_tags() -> KResult<()> { - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // create Key Pair let mkp_tag = "mkp"; let mkp_json_tag = serde_json::to_string(&[mkp_tag.to_owned()])?; @@ -88,7 +88,7 @@ async fn test_re_key_with_tags() -> KResult<()> { async fn integration_tests_with_tags() -> KResult<()> { cosmian_logger::log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // create Key Pair let mkp_tag = "mkp"; let mkp_json_tag = serde_json::to_string(&[mkp_tag.to_owned()])?; diff --git a/crate/server/src/tests/cover_crypt_tests/unit_tests.rs b/crate/server/src/tests/cover_crypt_tests/unit_tests.rs index 1e67714978..fb88c72b85 100644 --- a/crate/server/src/tests/cover_crypt_tests/unit_tests.rs +++ b/crate/server/src/tests/cover_crypt_tests/unit_tests.rs @@ -51,7 +51,6 @@ async fn test_cover_crypt_keys() -> KResult<()> { None, )?, owner, - None, ) .await?; debug!(" -> response {}", cr); @@ -125,7 +124,7 @@ async fn test_cover_crypt_keys() -> KResult<()> { }, object: pk.clone(), }; - kms.import(request, owner, None).await.unwrap_err(); + kms.import(request, owner).await.unwrap_err(); // re-import public key - should succeed let request = Import { @@ -139,7 +138,7 @@ async fn test_cover_crypt_keys() -> KResult<()> { }, object: pk.clone(), }; - let _update_response = kms.import(request, owner, None).await?; + let _update_response = kms.import(request, owner).await?; // User decryption key let access_policy = "(Department::MKG || Department::FIN) && Security Level::Confidential"; @@ -154,7 +153,7 @@ async fn test_cover_crypt_keys() -> KResult<()> { false, None, )?; - let cr = kms.create(request, owner, None).await?; + let cr = kms.create(request, owner).await?; debug!("Create Response for User Decryption Key {}", cr); let usk_uid = cr.unique_identifier.to_string(); @@ -190,7 +189,7 @@ async fn test_cover_crypt_keys() -> KResult<()> { false, None, )?; - let cr = kms.create(request, owner, None).await?; + let cr = kms.create(request, owner).await?; debug!("Create Response for User Decryption Key {}", cr); let usk_uid = cr.unique_identifier.to_string(); @@ -250,7 +249,6 @@ async fn test_abe_encrypt_decrypt() -> KResult<()> { None, )?, owner, - None, ) .await?; let master_secret_key_id = ckr @@ -358,7 +356,6 @@ async fn test_abe_encrypt_decrypt() -> KResult<()> { None, )?, owner, - None, ) .await?; let secret_mkg_fin_user_key = &cr @@ -455,7 +452,7 @@ async fn test_abe_json_access() -> KResult<()> { )?; // create Key Pair - let ckr = kms.create_key_pair(master_keypair, owner, None).await?; + let ckr = kms.create_key_pair(master_keypair, owner).await?; let master_secret_key_uid = ckr.private_key_unique_identifier.to_string(); // define search criteria @@ -499,7 +496,6 @@ async fn test_abe_json_access() -> KResult<()> { None, )?, owner, - None, ) .await?; let secret_mkg_fin_user_key_id = &cr.unique_identifier; @@ -542,7 +538,6 @@ async fn test_import_decrypt() -> KResult<()> { None, )?, owner, - None, ) .await?; debug!(" -> response created"); @@ -592,7 +587,6 @@ async fn test_import_decrypt() -> KResult<()> { None, )?, owner, - None, ) .await?; let secret_mkg_fin_user_key = cr.unique_identifier.to_string(); @@ -626,9 +620,7 @@ async fn test_import_decrypt() -> KResult<()> { }, object: gr_sk.object.clone(), }; - kms.import(request, owner, None) - .await - .context(&custom_sk_uid)?; + kms.import(request, owner).await.context(&custom_sk_uid)?; // decrypt resource MKG + Confidential let dr = kms @@ -661,9 +653,7 @@ async fn test_import_decrypt() -> KResult<()> { attributes: gr_sk.object.attributes()?.clone(), object: gr_sk.object.clone(), }; - kms.import(request, owner, None) - .await - .context(&custom_sk_uid)?; + kms.import(request, owner).await.context(&custom_sk_uid)?; // Note: No activation needed here because the imported attributes include // activation_date from the original key, so it's imported as Active diff --git a/crate/server/src/tests/curve_25519_tests.rs b/crate/server/src/tests/curve_25519_tests.rs index 2010e1101b..1d73b728f0 100644 --- a/crate/server/src/tests/curve_25519_tests.rs +++ b/crate/server/src/tests/curve_25519_tests.rs @@ -59,7 +59,7 @@ async fn test_curve_25519() -> KResult<()> { false, None, )?; - let response = kms.create_key_pair(request, &owner, None).await?; + let response = kms.create_key_pair(request, &owner).await?; // check that the private and public keys exist // check secret key let sk_response = kms @@ -193,7 +193,7 @@ async fn test_curve_25519() -> KResult<()> { }, object: pk.clone(), }; - let new_uid = kms.import(request, &owner, None).await?.unique_identifier; + let new_uid = kms.import(request, &owner).await?.unique_identifier; // update let request = Import { unique_identifier: new_uid.clone(), @@ -206,7 +206,7 @@ async fn test_curve_25519() -> KResult<()> { }, object: pk, }; - let update_response = kms.import(request, &owner, None).await?; + let update_response = kms.import(request, &owner).await?; assert_eq!(new_uid, update_response.unique_identifier); Ok(()) } diff --git a/crate/server/src/tests/derive_key_tests.rs b/crate/server/src/tests/derive_key_tests.rs index 5fd1991a23..7ea2664aa1 100644 --- a/crate/server/src/tests/derive_key_tests.rs +++ b/crate/server/src/tests/derive_key_tests.rs @@ -42,7 +42,7 @@ async fn test_derive_key_pbkdf2_default() -> KResult<()> { // Create a base symmetric key let create_request = create_base_symmetric_key_request(); - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let base_key_id = create_response.unique_identifier; // Create DeriveKey request with PBKDF2 @@ -133,7 +133,7 @@ async fn test_derive_key_pbkdf2_different_hash_algorithms() -> KResult<()> { // Create a base symmetric key let create_request = create_base_symmetric_key_request(); - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let base_key_id = create_response.unique_identifier; let hash_algorithms = vec![ @@ -203,7 +203,7 @@ async fn test_derive_key_hkdf() -> KResult<()> { // Create a base symmetric key let create_request = create_base_symmetric_key_request(); - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let base_key_id = create_response.unique_identifier; // Create DeriveKey request with HKDF @@ -270,7 +270,7 @@ async fn test_derive_key_from_secret_data() -> KResult<()> { // Create a base secret data object let create_request = create_base_secret_data_request(); - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let base_secret_id = create_response.unique_identifier; // Create DeriveKey request using the secret data as base @@ -329,7 +329,7 @@ async fn test_derive_key_error_cases() -> KResult<()> { }, protection_storage_masks: None, }; - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let invalid_key_id = create_response.unique_identifier; // Test 1: Missing DeriveKey usage mask should fail @@ -377,7 +377,7 @@ async fn test_derive_key_pbkdf2_missing_salt() -> KResult<()> { // Create a base symmetric key let create_request = create_base_symmetric_key_request(); - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let base_key_id = create_response.unique_identifier; // Create DeriveKey request with PBKDF2 but missing salt @@ -471,7 +471,7 @@ async fn test_derive_key_missing_cryptographic_length() -> KResult<()> { // Create a base symmetric key let create_request = create_base_symmetric_key_request(); - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let base_key_id = create_response.unique_identifier; // Create DeriveKey request without cryptographic length diff --git a/crate/server/src/tests/google_cse/mod.rs b/crate/server/src/tests/google_cse/mod.rs index c4fe1cc87b..ca3a5b1c1b 100644 --- a/crate/server/src/tests/google_cse/mod.rs +++ b/crate/server/src/tests/google_cse/mod.rs @@ -249,7 +249,7 @@ async fn test_google_cse_resource_key_hash() -> KResult<()> { async fn test_google_cse_status() -> KResult<()> { log_init(option_env!("RUST_LOG")); - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; let response: StatusResponse = test_utils::get_json_with_uri(&app, "/google_cse/status").await?; @@ -270,7 +270,7 @@ async fn test_google_cse_private_key_sign() -> KResult<()> { }; log_init(None); - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; // Import google CSE key import_google_cse_symmetric_key_with_access(&app).await?; @@ -393,7 +393,6 @@ async fn test_google_cse_create_pair_encrypt_decrypt() -> KResult<()> { object: google_cse_object, }, owner, - None, ) .await?; @@ -409,7 +408,6 @@ async fn test_google_cse_create_pair_encrypt_decrypt() -> KResult<()> { None, )?, owner, - None, ) .await?; @@ -467,7 +465,7 @@ async fn test_google_cse_create_pair_encrypt_decrypt() -> KResult<()> { attributes, object: private_key, }; - let intermediate_cert = kms.import(import_request, owner, None).await?; + let intermediate_cert = kms.import(import_request, owner).await?; // Certify the public key: sign created public key with issuer private key let attributes = Attributes { @@ -495,10 +493,8 @@ async fn test_google_cse_create_pair_encrypt_decrypt() -> KResult<()> { ..Certify::default() }; - let certificate_unique_identifier = kms - .certify(certify_request, owner, None) - .await? - .unique_identifier; + let certificate_unique_identifier = + kms.certify(certify_request, owner).await?.unique_identifier; // Export the certificate and chain in PKCS7 format (just checking that it works) let pkcs7 = kms @@ -568,7 +564,7 @@ async fn test_cse_private_key_decrypt( std::env::set_var("KMS_GOOGLE_CSE_GMAIL_JWT_ISSUER", JWT_ISSUER_URI); }; - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; // Import google CSE key import_google_cse_symmetric_key_with_access(&app).await?; @@ -603,7 +599,7 @@ async fn test_google_cse_encrypt_and_private_key_decrypt() -> KResult<()> { std::env::set_var("KMS_GOOGLE_CSE_GMAIL_JWT_ISSUER", JWT_ISSUER_URI); }; - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; // Import google CSE key import_google_cse_symmetric_key_with_access(&app).await?; @@ -636,7 +632,7 @@ async fn test_google_cse_wrap_unwrap_key() -> KResult<()> { log_init(None); - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; // Import google CSE key import_google_cse_symmetric_key_with_access(&app).await?; @@ -688,7 +684,7 @@ async fn test_google_cse_privileged_wrap_unwrap_key() -> KResult<()> { log_init(None); - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; // Import google CSE key import_google_cse_symmetric_key_with_access(&app).await?; @@ -741,7 +737,7 @@ async fn test_google_cse_privileged_private_key_decrypt() -> KResult<()> { log_init(None); - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; let path = std::env::current_dir()?; println!("The current directory is {}", path.display()); @@ -822,7 +818,7 @@ async fn test_google_cse_custom_jwt() -> KResult<()> { log_init(None); - let app = test_utils::test_app(Some("https://127.0.0.1:9998".to_owned()), None).await; + let app = test_utils::test_app(Some("https://127.0.0.1:9998".to_owned())).await; let resource_name = "resource_name_test".to_owned(); let kacls_url = "https://127.0.0.1:9998/google_cse"; @@ -916,7 +912,7 @@ async fn test_google_cse_custom_jwt_multi_audience_match() -> KResult<()> { log_init(None); - let app = test_utils::test_app(Some("https://127.0.0.1:9998".to_owned()), None).await; + let app = test_utils::test_app(Some("https://127.0.0.1:9998".to_owned())).await; let resource_name = "resource_name_test".to_owned(); let kacls_url = "https://127.0.0.1:9998/google_cse"; @@ -1004,7 +1000,7 @@ async fn test_google_cse_custom_jwt_multi_audience_nomatch() -> KResult<()> { log_init(None); - let app = test_utils::test_app(Some("https://127.0.0.1:9998".to_owned()), None).await; + let app = test_utils::test_app(Some("https://127.0.0.1:9998".to_owned())).await; let resource_name = "resource_name_test".to_owned(); let kacls_url = "https://127.0.0.1:9998/google_cse"; diff --git a/crate/server/src/tests/health_endpoint.rs b/crate/server/src/tests/health_endpoint.rs index ce589ca5c6..69cf264771 100644 --- a/crate/server/src/tests/health_endpoint.rs +++ b/crate/server/src/tests/health_endpoint.rs @@ -6,7 +6,7 @@ use crate::tests::test_utils; async fn test_health_endpoint_ok() { log_init(option_env!("RUST_LOG")); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let response: serde_json::Value = test_utils::get_json_with_uri(&app, "/health") .await @@ -24,7 +24,7 @@ async fn test_health_endpoint_ok() { async fn test_root_redirects_to_ui() { log_init(option_env!("RUST_LOG")); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let response = actix_web::test::TestRequest::get() .uri("/") diff --git a/crate/server/src/tests/hsm/issues.rs b/crate/server/src/tests/hsm/issues.rs index e4efa420a5..1444025db2 100644 --- a/crate/server/src/tests/hsm/issues.rs +++ b/crate/server/src/tests/hsm/issues.rs @@ -159,7 +159,7 @@ pub(super) async fn test_server_side_unwrap() -> KResult<()> { attributes: Attributes::default(), object: wrapped_dek, }; - let import_response = kms.import(import_request, &admin, None).await?; + let import_response = kms.import(import_request, &admin).await?; assert_eq!( import_response.unique_identifier, UniqueIdentifier::TextString(tmp_uid.clone()) diff --git a/crate/server/src/tests/hsm/mod.rs b/crate/server/src/tests/hsm/mod.rs index 48971c1d60..b0c687fb98 100644 --- a/crate/server/src/tests/hsm/mod.rs +++ b/crate/server/src/tests/hsm/mod.rs @@ -255,7 +255,7 @@ async fn import_object( object: object.clone(), }; - let create_response = kms.import(import_request, owner, None).await?; + let create_response = kms.import(import_request, owner).await?; Ok(create_response.unique_identifier) } diff --git a/crate/server/src/tests/hsm/permissions.rs b/crate/server/src/tests/hsm/permissions.rs index 0f31f94bb8..afe88564a0 100644 --- a/crate/server/src/tests/hsm/permissions.rs +++ b/crate/server/src/tests/hsm/permissions.rs @@ -68,7 +68,7 @@ async fn grant_ops( user_id: user_id.to_owned(), operation_types: ops, }; - kms.grant_access(&access, owner, None).await + kms.grant_access(&access, owner).await } /// Helper: revoke operations on an HSM key @@ -84,7 +84,7 @@ async fn revoke_ops( user_id: user_id.to_owned(), operation_types: ops, }; - kms.revoke_access(&access, owner, None).await + kms.revoke_access(&access, owner).await } /// Helper: encrypt data using a key. diff --git a/crate/server/src/tests/kmip_endpoints.rs b/crate/server/src/tests/kmip_endpoints.rs index 39cb1bfe7f..e7becb27b7 100644 --- a/crate/server/src/tests/kmip_endpoints.rs +++ b/crate/server/src/tests/kmip_endpoints.rs @@ -54,7 +54,7 @@ async fn test_kmip_endpoints() -> KResult<()> { let request_message = build_query_request(2, 1); let fut = async { - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let _ttlv: TTLV = test_utils::post_json_with_uri(&app, to_ttlv(&request_message)?, "/kmip").await?; Ok::<(), KmsError>(()) @@ -74,7 +74,7 @@ async fn test_kmip_json_rejects_old_versions() -> KResult<()> { log_init(option_env!("RUST_LOG")); let fut = async { - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // These versions should be rejected by the JSON /kmip endpoint let rejected_versions = [(1, 0), (1, 1), (1, 2), (1, 3)]; diff --git a/crate/server/src/tests/kmip_messages.rs b/crate/server/src/tests/kmip_messages.rs index 2b8605d915..3a7043cd47 100644 --- a/crate/server/src/tests/kmip_messages.rs +++ b/crate/server/src/tests/kmip_messages.rs @@ -46,7 +46,7 @@ async fn test_kmip_mac_messages() -> KResult<()> { )?; let unique_identifier = Some( - kms.create(symmetric_key_request, owner, None) + kms.create(symmetric_key_request, owner) .await? .unique_identifier, ); @@ -126,7 +126,7 @@ async fn test_encrypt_kmip_messages() -> KResult<()> { )?; let unique_identifier = Some( - kms.create(symmetric_key_request, owner, None) + kms.create(symmetric_key_request, owner) .await? .unique_identifier, ); diff --git a/crate/server/src/tests/kmip_policy/basic.rs b/crate/server/src/tests/kmip_policy/basic.rs index 883ccec816..05569eea09 100644 --- a/crate/server/src/tests/kmip_policy/basic.rs +++ b/crate/server/src/tests/kmip_policy/basic.rs @@ -17,7 +17,6 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::U #[cfg(feature = "non-fips")] use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::requests::create_ec_key_pair_request; #[cfg(feature = "non-fips")] -use cosmian_kms_server_database::reexport::cosmian_kmip::time_normalize; use cosmian_kms_server_database::reexport::cosmian_kmip::{ kmip_0::kmip_types::{BlockCipherMode, HashingAlgorithm, PaddingMethod}, kmip_2_1::{ @@ -73,7 +72,7 @@ fn default_policy_allows_aes_gcm_encrypt_params() { async fn e2e_default_policy_allows_aes_gcm_encrypt_params() { let mut conf = https_clap_config_opts(None); conf.kmip_policy.policy_id = Some("DEFAULT".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let key_uid = create_aes_key_with_size(&app, "e2e-aes-gcm", 256) .await @@ -117,7 +116,7 @@ fn default_policy_denies_deprecated_algorithm_des() { async fn e2e_default_policy_denies_deprecated_algorithm_des() { let mut conf = https_clap_config_opts(None); conf.kmip_policy.policy_id = Some("DEFAULT".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let req = Operation::Create(Create { object_type: ObjectType::SymmetricKey, @@ -156,7 +155,7 @@ fn default_policy_denies_aes_invalid_key_size() { async fn e2e_default_policy_denies_aes_invalid_key_size() { let mut conf = https_clap_config_opts(None); conf.kmip_policy.policy_id = Some("DEFAULT".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let req = Operation::Create(Create { object_type: ObjectType::SymmetricKey, @@ -270,7 +269,7 @@ async fn e2e_default_policy_denies_disallowed_block_cipher_mode_ecb() { let mut conf = https_clap_config_opts(None); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); conf.kmip_policy.allowlists.block_cipher_modes = Some(vec![BlockCipherMode::GCM]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let key_uid = create_aes_key_with_size(&app, "e2e-aes-ecb", 256) .await @@ -348,7 +347,7 @@ async fn e2e_default_policy_allows_curve_p256() { CryptographicAlgorithm::EC, CryptographicAlgorithm::ECDH, ]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let create_kp = create_ec_key_pair_request( VENDOR_ID_COSMIAN, @@ -395,7 +394,7 @@ async fn e2e_default_policy_denies_padding_method_none_allowed_list() { let mut conf = https_clap_config_opts(None); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); conf.kmip_policy.allowlists.padding_methods = Some(vec![PaddingMethod::PKCS5]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let key_uid = create_aes_key_with_size(&app, "e2e-padding-deny", 256) .await @@ -486,7 +485,6 @@ fn _create_aes_key_request_for_export(tag: &str) -> Operation { cryptographic_usage_mask: Some( CryptographicUsageMask::WrapKey | CryptographicUsageMask::Encrypt, ), - activation_date: Some(time_normalize().expect("time_normalize should work")), alternative_name: Some(AlternativeName { alternative_name_type: AlternativeNameType::UninterpretedTextString, alternative_name_value: tag.to_owned(), diff --git a/crate/server/src/tests/kmip_policy/e2e_ecies.rs b/crate/server/src/tests/kmip_policy/e2e_ecies.rs index 35c0df44de..d8043a3b95 100644 --- a/crate/server/src/tests/kmip_policy/e2e_ecies.rs +++ b/crate/server/src/tests/kmip_policy/e2e_ecies.rs @@ -31,7 +31,7 @@ async fn e2e_ecies_roundtrip_with_policy( allowed_shake: CryptographicAlgorithm, ) -> Result<(), KmsError> { let conf = ecies_policy_conf(curve, allowed_shake); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let create_kp = create_ec_key_pair_request( VENDOR_ID_COSMIAN, @@ -172,7 +172,7 @@ async fn e2e_ecies_is_allowed_when_curves_allowlist_is_unset() { CryptographicAlgorithm::SHAKE256, ]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let create_kp = create_ec_key_pair_request( VENDOR_ID_COSMIAN, @@ -228,7 +228,7 @@ async fn e2e_ecies_is_denied_when_curves_allowlist_is_empty() { CryptographicAlgorithm::SHAKE256, ]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let create_kp = create_ec_key_pair_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs b/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs index 72e7072532..dae1e05248 100644 --- a/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs +++ b/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs @@ -55,11 +55,11 @@ where cryptographic_usage_mask: Some( CryptographicUsageMask::WrapKey | CryptographicUsageMask::Encrypt, ), - activation_date: Some(time_normalize().expect("time_normalize should work")), alternative_name: Some(AlternativeName { alternative_name_type: AlternativeNameType::UninterpretedTextString, alternative_name_value: tag.to_owned(), }), + activation_date: Some(time_normalize().expect("time_normalize")), ..Default::default() }, protection_storage_masks: None, @@ -93,7 +93,7 @@ async fn e2e_kmip_policy_key_wrapping_aes_kw_suite_requires_aes_and_nist_key_wra conf.kmip_policy.allowlists.block_cipher_modes = Some(vec![BlockCipherMode::NISTKeyWrap]); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let (kek_uid, target_uid) = create_kek_and_target_for_export(&app).await; let export = export_request( @@ -120,7 +120,7 @@ async fn e2e_kmip_policy_key_wrapping_aes_kwp_suite_requires_aes_and_kwp_mode() conf.kmip_policy.allowlists.block_cipher_modes = Some(vec![BlockCipherMode::AESKeyWrapPadding]); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let (kek_uid, target_uid) = create_kek_and_target_for_export(&app).await; let export = export_request( @@ -147,7 +147,7 @@ async fn e2e_kmip_policy_key_wrapping_aes_gcm_suite_requires_aes_and_gcm() { conf.kmip_policy.allowlists.block_cipher_modes = Some(vec![BlockCipherMode::GCM]); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let (kek_uid, target_uid) = create_kek_and_target_for_export(&app).await; let export = export_request( @@ -178,7 +178,7 @@ async fn e2e_kmip_policy_key_wrapping_rsa_oaep_sha256_suite_requires_rsa_oaep_an conf.kmip_policy.allowlists.hashes = Some(vec![HashingAlgorithm::SHA256]); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let (_kek_uid, target_uid) = create_kek_and_target_for_export(&app).await; let export = export_request( @@ -219,7 +219,7 @@ async fn e2e_kmip_policy_key_wrapping_rsa_aes_key_wrap_sha256_suite_requires_rsa conf.kmip_policy.allowlists.hashes = Some(vec![HashingAlgorithm::SHA256]); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let (_kek_uid, target_uid) = create_kek_and_target_for_export(&app).await; let export = export_request( @@ -255,7 +255,7 @@ async fn e2e_default_policy_allows_configurable_kem_roundtrip() { let mut conf = https_clap_config_opts(None); conf.kmip_policy.policy_id = Some("DEFAULT".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; // Use a pre-quantum KEM tag (P-256) so the request does not include a nested // post-quantum `CryptographicAlgorithm` in `CryptographicParameters`. diff --git a/crate/server/src/tests/kmip_policy/e2e_signature.rs b/crate/server/src/tests/kmip_policy/e2e_signature.rs index 01cf77b05b..83eac7564c 100644 --- a/crate/server/src/tests/kmip_policy/e2e_signature.rs +++ b/crate/server/src/tests/kmip_policy/e2e_signature.rs @@ -22,7 +22,7 @@ async fn e2e_signature_algorithm_allowlist_is_enforced_on_sign() { conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); conf.kmip_policy.allowlists.signature_algorithms = Some(vec![DigitalSignatureAlgorithm::SHA256WithRSAEncryption]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let create_kp = create_ec_key_pair_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/kmip_policy/helpers.rs b/crate/server/src/tests/kmip_policy/helpers.rs index 59e528a6ee..e5233d563f 100644 --- a/crate/server/src/tests/kmip_policy/helpers.rs +++ b/crate/server/src/tests/kmip_policy/helpers.rs @@ -86,11 +86,11 @@ where cryptographic_algorithm: Some(CryptographicAlgorithm::AES), cryptographic_length: Some(bits), cryptographic_usage_mask: Some(CryptographicUsageMask::Encrypt), - activation_date: Some(time_normalize().expect("time_normalize should work")), alternative_name: Some(AlternativeName { alternative_name_type: AlternativeNameType::UninterpretedTextString, alternative_name_value: tag.to_owned(), }), + activation_date: Some(time_normalize()?), ..Default::default() }, protection_storage_masks: None, diff --git a/crate/server/src/tests/kmip_policy/overrides.rs b/crate/server/src/tests/kmip_policy/overrides.rs index 65b44c7dba..e4be5f8558 100644 --- a/crate/server/src/tests/kmip_policy/overrides.rs +++ b/crate/server/src/tests/kmip_policy/overrides.rs @@ -95,7 +95,7 @@ async fn e2e_override_allowlists_can_tighten_policy() { conf.kmip_policy.allowlists.padding_methods = Some(vec![PaddingMethod::OAEP]); conf.kmip_policy.allowlists.mgf_hashes = Some(vec![HashingAlgorithm::SHA512]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let create_aes = Operation::Create(Create { object_type: ObjectType::SymmetricKey, diff --git a/crate/server/src/tests/kmip_server_tests.rs b/crate/server/src/tests/kmip_server_tests.rs index d0f43ee5a1..cfbd42ab31 100644 --- a/crate/server/src/tests/kmip_server_tests.rs +++ b/crate/server/src/tests/kmip_server_tests.rs @@ -55,7 +55,7 @@ async fn test_curve_25519_key_pair() -> KResult<()> { false, None, )?; - let response = kms.create_key_pair(request, owner, None).await?; + let response = kms.create_key_pair(request, owner).await?; // check that the private and public keys exist // check secret key let sk_response = kms @@ -186,7 +186,7 @@ async fn test_curve_25519_key_pair() -> KResult<()> { }, object: pk.clone(), }; - let new_uid = kms.import(request, owner, None).await?.unique_identifier; + let new_uid = kms.import(request, owner).await?.unique_identifier; // update let request = Import { @@ -200,7 +200,7 @@ async fn test_curve_25519_key_pair() -> KResult<()> { }, object: pk, }; - let update_response = kms.import(request, owner, None).await?; + let update_response = kms.import(request, owner).await?; assert_eq!(new_uid, update_response.unique_identifier); Ok(()) } @@ -252,7 +252,7 @@ async fn test_import_wrapped_symmetric_key() -> KResult<()> { }; trace!("request: {}", request); - let response = kms.import(request, owner, None).await?; + let response = kms.import(request, owner).await?; trace!("response: {}", response); Ok(()) @@ -278,7 +278,7 @@ async fn test_create_transparent_symmetric_key() -> KResult<()> { )?; trace!("request: {}", request); - let response = kms.create(request, owner, None).await?; + let response = kms.create(request, owner).await?; trace!("response: {}", response); // Get symmetric key without specifying key format type @@ -333,7 +333,7 @@ async fn test_database_user_tenant() -> KResult<()> { false, None, )?; - let response = kms.create_key_pair(request, owner, None).await?; + let response = kms.create_key_pair(request, owner).await?; // check that we can get the private and public key // check secret key @@ -435,7 +435,7 @@ async fn test_register_operation() -> KResult<()> { }; trace!("request: {}", request); - let register_response = kms.register(request, owner, None).await?; + let register_response = kms.register(request, owner).await?; trace!("response: {}", register_response); let uid = register_response.unique_identifier; diff --git a/crate/server/src/tests/locate.rs b/crate/server/src/tests/locate.rs index 47f3eca96d..b1753111be 100644 --- a/crate/server/src/tests/locate.rs +++ b/crate/server/src/tests/locate.rs @@ -105,7 +105,7 @@ async fn test_locate() -> KResult<()> { #[actix_rt::test] async fn test_locate_key_pair_and_sym_key() -> KResult<()> { // Use sqlite-backed test app - let app = test_app(None, None).await; + let app = test_app(None).await; // Create EC keypair (FIPS-approved curve and usage mask) let create = CreateKeyPair { @@ -193,7 +193,7 @@ async fn test_locate_key_pair_and_sym_key() -> KResult<()> { #[actix_rt::test] async fn test_locate_filters_by_object_type_and_and_semantics() -> KResult<()> { // Start test app (KMIP 2.1 endpoint) - let app = test_app(None, None).await; + let app = test_app(None).await; // Create an EC key pair let create = CreateKeyPair { diff --git a/crate/server/src/tests/ms_dke/mod.rs b/crate/server/src/tests/ms_dke/mod.rs index 83edab8b17..801ecc1e71 100644 --- a/crate/server/src/tests/ms_dke/mod.rs +++ b/crate/server/src/tests/ms_dke/mod.rs @@ -57,7 +57,7 @@ const ENCRYPTED_DATA: &str = r#"{ async fn decrypt_data_test() -> KResult<()> { cosmian_logger::log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let pem: pem::Pem = pem::parse(RSA_PRIVATE_KEY.as_bytes()) .map_err(|e| kms_error!(format!("cannot parse RSA private key: {}", e)))?; diff --git a/crate/server/src/tests/rest_crypto/encrypt_decrypt.rs b/crate/server/src/tests/rest_crypto/encrypt_decrypt.rs index 16a557677a..f6f63acbd4 100644 --- a/crate/server/src/tests/rest_crypto/encrypt_decrypt.rs +++ b/crate/server/src/tests/rest_crypto/encrypt_decrypt.rs @@ -16,14 +16,14 @@ use crate::{result::KResult, tests::test_utils}; #[tokio::test] async fn test_aes128gcm_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; super::common::aes_gcm_round_trip(&app, 128, "A128GCM").await } #[tokio::test] async fn test_aes256gcm_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; super::common::aes_gcm_round_trip(&app, 256, "A256GCM").await } @@ -31,7 +31,7 @@ async fn test_aes256gcm_round_trip() -> KResult<()> { #[tokio::test] async fn test_aad_binding() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/rest_crypto/error_cases.rs b/crate/server/src/tests/rest_crypto/error_cases.rs index 57a109d634..68576799e7 100644 --- a/crate/server/src/tests/rest_crypto/error_cases.rs +++ b/crate/server/src/tests/rest_crypto/error_cases.rs @@ -16,7 +16,7 @@ use crate::{result::KResult, tests::test_utils}; #[tokio::test] async fn test_unknown_encrypt_alg_returns_422() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, @@ -49,7 +49,7 @@ async fn test_unknown_encrypt_alg_returns_422() -> KResult<()> { #[tokio::test] async fn test_unknown_sign_alg_returns_422() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, @@ -82,7 +82,7 @@ async fn test_unknown_sign_alg_returns_422() -> KResult<()> { #[tokio::test] async fn test_nonexistent_key_id() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let req = test::TestRequest::post() .uri("/v1/crypto/encrypt") @@ -105,7 +105,7 @@ async fn test_nonexistent_key_id() -> KResult<()> { #[tokio::test] async fn test_wrong_key_type_for_sign() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, @@ -136,7 +136,7 @@ async fn test_wrong_key_type_for_sign() -> KResult<()> { #[tokio::test] async fn test_alg_none_returns_422() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // Build a protected header with alg=none and a valid kid let protected_json = r#"{"alg":"none","kid":"any-key"}"#; @@ -165,7 +165,7 @@ async fn test_alg_none_returns_422() -> KResult<()> { #[tokio::test] async fn test_decrypt_invalid_iv_length_returns_400() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, @@ -213,7 +213,7 @@ async fn test_decrypt_invalid_iv_length_returns_400() -> KResult<()> { #[tokio::test] async fn test_decrypt_short_tag_returns_400() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/rest_crypto/jose_vectors.rs b/crate/server/src/tests/rest_crypto/jose_vectors.rs index e3a696ecb5..00fdcfe1e9 100644 --- a/crate/server/src/tests/rest_crypto/jose_vectors.rs +++ b/crate/server/src/tests/rest_crypto/jose_vectors.rs @@ -924,7 +924,7 @@ fn inject_kid(body: &mut Value, kid: &str, kid_public: Option<&str>) { #[tokio::test] async fn test_jose_vectors() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let vectors = discover_vectors(); assert!( diff --git a/crate/server/src/tests/rest_crypto/mac.rs b/crate/server/src/tests/rest_crypto/mac.rs index a160d2b113..8f6f3630b0 100644 --- a/crate/server/src/tests/rest_crypto/mac.rs +++ b/crate/server/src/tests/rest_crypto/mac.rs @@ -17,7 +17,7 @@ use crate::{result::KResult, tests::test_utils}; #[tokio::test] async fn test_hs256_compute_verify() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/rest_crypto/rfc_vectors.rs b/crate/server/src/tests/rest_crypto/rfc_vectors.rs index 22febaaf38..0e8aa83f85 100644 --- a/crate/server/src/tests/rest_crypto/rfc_vectors.rs +++ b/crate/server/src/tests/rest_crypto/rfc_vectors.rs @@ -35,7 +35,7 @@ use crate::{result::KResult, tests::test_utils}; #[tokio::test] async fn test_rfc7515_a1_hs256_known_answer() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // RFC 7515 §A.1 — 512-bit key (base64url): // AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow @@ -100,7 +100,7 @@ async fn test_rfc7515_a1_hs256_known_answer() -> KResult<()> { #[tokio::test] async fn test_rfc7515_a2_rs256_known_key_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let kp_req = create_rsa_key_pair_request(VENDOR_ID_COSMIAN, None, EMPTY_TAGS, 2048, false, None)?; @@ -123,7 +123,7 @@ async fn test_rfc7515_a2_rs256_known_key_round_trip() -> KResult<()> { #[tokio::test] async fn test_rfc7515_a3_es256_known_key_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let kp_req = create_ec_key_pair_request( VENDOR_ID_COSMIAN, @@ -150,7 +150,7 @@ async fn test_rfc7515_a3_es256_known_key_round_trip() -> KResult<()> { #[tokio::test] async fn test_rfc7515_a4_es512_known_key_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let kp_req = create_ec_key_pair_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/rest_crypto/sign_verify.rs b/crate/server/src/tests/rest_crypto/sign_verify.rs index a06bb97af0..af86277d52 100644 --- a/crate/server/src/tests/rest_crypto/sign_verify.rs +++ b/crate/server/src/tests/rest_crypto/sign_verify.rs @@ -13,7 +13,7 @@ use crate::{result::KResult, tests::test_utils}; #[tokio::test] async fn test_rs256_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let kp_req = create_rsa_key_pair_request(VENDOR_ID_COSMIAN, None, EMPTY_TAGS, 2048, false, None)?; @@ -27,7 +27,7 @@ async fn test_rs256_round_trip() -> KResult<()> { #[tokio::test] async fn test_es256_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let kp_req = create_ec_key_pair_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/rest_crypto/unwrap.rs b/crate/server/src/tests/rest_crypto/unwrap.rs index 44df1e4e54..638e5d68ce 100644 --- a/crate/server/src/tests/rest_crypto/unwrap.rs +++ b/crate/server/src/tests/rest_crypto/unwrap.rs @@ -80,7 +80,7 @@ fn build_protected_header(alg: &str, enc: &str, kid: &str) -> String { #[tokio::test] async fn test_unwrap_key_then_decrypt() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // 1. Create RSA key pair via KMIP let (_kid_priv, kid_pub) = create_rsa_key_pair(&app).await?; @@ -145,7 +145,7 @@ async fn test_unwrap_key_then_decrypt() -> KResult<()> { #[tokio::test] async fn test_unwrap_key_rsa_oaep_256() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let (_kid_priv, kid_pub) = create_rsa_key_pair(&app).await?; @@ -200,7 +200,7 @@ async fn test_unwrap_key_rsa_oaep_256() -> KResult<()> { #[tokio::test] async fn test_unwrap_key_unsupported_alg() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let protected = build_protected_header("dir", "A256GCM", "some-kid"); let req = actix_test::TestRequest::post() @@ -220,7 +220,7 @@ async fn test_unwrap_key_unsupported_alg() -> KResult<()> { #[tokio::test] async fn test_unwrap_key_missing_enc() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let header = json!({"alg": "RSA-OAEP-256", "kid": "some-kid"}); let protected = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); @@ -241,7 +241,7 @@ async fn test_unwrap_key_missing_enc() -> KResult<()> { #[tokio::test] async fn test_unwrap_key_empty_encrypted_key() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let (kid_priv, _kid_pub) = create_rsa_key_pair(&app).await?; let protected = build_protected_header("RSA-OAEP-256", "A256GCM", &kid_priv); @@ -264,7 +264,7 @@ async fn test_unwrap_key_empty_encrypted_key() -> KResult<()> { #[tokio::test] async fn test_unwrap_key_size_mismatch() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let (kid_priv, kid_pub) = create_rsa_key_pair(&app).await?; diff --git a/crate/server/src/tests/secret_data_tests.rs b/crate/server/src/tests/secret_data_tests.rs index 0b68d0e214..f0be497925 100644 --- a/crate/server/src/tests/secret_data_tests.rs +++ b/crate/server/src/tests/secret_data_tests.rs @@ -50,7 +50,7 @@ async fn test_secret_data_create_basic() -> KResult<()> { None, )?; - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; assert!(create_response.unique_identifier.as_str().is_some()); // Test Get operation @@ -137,7 +137,7 @@ async fn test_secret_data_with_wrapping() -> KResult<()> { None, )?; - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; assert!(create_response.unique_identifier.as_str().is_some()); // create the wrapping key @@ -150,7 +150,7 @@ async fn test_secret_data_with_wrapping() -> KResult<()> { false, None, )?; - let create_wrapping_key_response = kms.create(create_wrapping_key_request, owner, None).await?; + let create_wrapping_key_response = kms.create(create_wrapping_key_request, owner).await?; assert!( create_wrapping_key_response .unique_identifier @@ -226,7 +226,7 @@ async fn test_secret_data_import_export_with_kek() -> KResult<()> { false, None, )?; - let create_wrapping_key_response = kms.create(create_wrapping_key_request, owner, None).await?; + let create_wrapping_key_response = kms.create(create_wrapping_key_request, owner).await?; assert!( create_wrapping_key_response .unique_identifier @@ -272,7 +272,7 @@ async fn test_secret_data_import_export_with_kek() -> KResult<()> { object: secret_data, }; - let import_response = kms.import(import_request, owner, None).await?; + let import_response = kms.import(import_request, owner).await?; assert_eq!(import_response.unique_identifier, secret_id); // Test Export operation with wrapping enabled diff --git a/crate/server/src/tests/security_regression.rs b/crate/server/src/tests/security_regression.rs index d11122efce..712dcb3679 100644 --- a/crate/server/src/tests/security_regression.rs +++ b/crate/server/src/tests/security_regression.rs @@ -40,7 +40,7 @@ async fn create_aes_key(kms: &KMS, user: &str) -> KResult { None, ) .map_err(|e| crate::error::KmsError::InvalidRequest(e.to_string()))?; - let response = kms.create(request, user, None).await?; + let response = kms.create(request, user).await?; Ok(response.unique_identifier) } @@ -195,10 +195,7 @@ async fn test_mac_no_hmac_value_in_traces() -> KResult<()> { None, ) .map_err(|e| crate::error::KmsError::InvalidRequest(e.to_string()))?; - let key_id = kms - .create(request, "test_user", None) - .await? - .unique_identifier; + let key_id = kms.create(request, "test_user").await?.unique_identifier; let message = b"MESSAGE_WHOSE_MAC_MUST_NOT_BE_LOGGED_IN_FULL"; @@ -251,10 +248,7 @@ async fn test_decrypt_preserves_kek_wrapping_with_usage_limits() -> KResult<()> None, ) .map_err(|e| crate::error::KmsError::InvalidRequest(e.to_string()))?; - let kek_id = kms - .create(kek_request, owner, None) - .await? - .unique_identifier; + let kek_id = kms.create(kek_request, owner).await?.unique_identifier; drop(kms); // Phase 2: re-instantiate KMS with KEK configured @@ -279,10 +273,7 @@ async fn test_decrypt_preserves_kek_wrapping_with_usage_limits() -> KResult<()> usage_limits_count: None, usage_limits_total: 100, }); - let dek_id = kms - .create(dek_request, owner, None) - .await? - .unique_identifier; + let dek_id = kms.create(dek_request, owner).await?.unique_identifier; // Verify the DEK is stored wrapped let raw_object_before = kms @@ -383,10 +374,7 @@ async fn test_sign_preserves_kek_wrapping_with_usage_limits() -> KResult<()> { None, ) .map_err(|e| crate::error::KmsError::InvalidRequest(e.to_string()))?; - let kek_id = kms - .create(kek_request, owner, None) - .await? - .unique_identifier; + let kek_id = kms.create(kek_request, owner).await?.unique_identifier; drop(kms); // Phase 2: re-instantiate KMS with KEK @@ -413,7 +401,7 @@ async fn test_sign_preserves_kek_wrapping_with_usage_limits() -> KResult<()> { usage_limits_total: 100, }); } - let create_response = kms.create_key_pair(create_request, owner, None).await?; + let create_response = kms.create_key_pair(create_request, owner).await?; let private_key_id = create_response.private_key_unique_identifier; // Verify the private key is stored wrapped diff --git a/crate/server/src/tests/test_sign.rs b/crate/server/src/tests/test_sign.rs index b4729a4609..e00c22eff9 100644 --- a/crate/server/src/tests/test_sign.rs +++ b/crate/server/src/tests/test_sign.rs @@ -184,7 +184,7 @@ async fn test_sign_rsa() -> KResult<()> { false, // sensitive None, // wrapping_key_id )?; - let response = kms.create_key_pair(request, owner, None).await?; + let response = kms.create_key_pair(request, owner).await?; // Test single-call signature test_single_signature( @@ -222,7 +222,7 @@ async fn test_sign_ec_curve(curve: RecommendedCurve, test_name: &str) -> KResult false, // sensitive None, // wrapping_key_id )?; - let response = kms.create_key_pair(request, owner, None).await?; + let response = kms.create_key_pair(request, owner).await?; // Test single-call signature test_single_signature( diff --git a/crate/server/src/tests/test_utils.rs b/crate/server/src/tests/test_utils.rs index f29c6a2776..128227c07c 100644 --- a/crate/server/src/tests/test_utils.rs +++ b/crate/server/src/tests/test_utils.rs @@ -196,7 +196,6 @@ pub(crate) fn get_tmp_sqlite_path() -> PathBuf { /// # Arguments /// /// * `kms_public_url` - Optional public URL for the KMS server -/// * `privileged_users` - Optional list of users with elevated permissions /// /// # Google CSE Support /// @@ -207,7 +206,6 @@ pub(crate) fn get_tmp_sqlite_path() -> PathBuf { /// - Public key stored as `google_cse_rsa_pk` and exposed via `/google_cse/certs` pub(crate) async fn test_app( kms_public_url: Option, - privileged_users: Option>, ) -> impl Service, Error = actix_web::Error> { let clap_config = https_clap_config_opts(kms_public_url); @@ -229,7 +227,6 @@ pub(crate) async fn test_app( let mut app = App::new() .app_data(Data::new(kms_server.clone())) - .app_data(Data::new(privileged_users)) .service(routes::root_redirect::root_redirect_to_ui) .service(routes::health::get_health) .service(routes::get_version) @@ -285,7 +282,6 @@ pub(crate) async fn test_app( /// and enforcement settings and then validate behavior through the HTTP stack. pub(crate) async fn test_app_with_clap_config( clap_config: ClapConfig, - privileged_users: Option>, ) -> impl Service, Error = actix_web::Error> { let server_params = Arc::new(ServerParams::try_from(clap_config).expect("cannot create server params")); @@ -304,7 +300,6 @@ pub(crate) async fn test_app_with_clap_config( let mut app = App::new() .app_data(Data::new(kms_server.clone())) - .app_data(Data::new(privileged_users)) .service(routes::root_redirect::root_redirect_to_ui) .service(routes::health::get_health) .service(routes::get_version) diff --git a/crate/server/src/tests/test_validate.rs b/crate/server/src/tests/test_validate.rs index 191c226d99..8c43d5c5b3 100644 --- a/crate/server/src/tests/test_validate.rs +++ b/crate/server/src/tests/test_validate.rs @@ -143,7 +143,7 @@ pub(crate) async fn test_validate_with_certificates_ids() -> Result<(), KmsError certificate_value: root_cert.clone(), }), }; - let res_root = kms.import(root_request, owner, None).await?; + let res_root = kms.import(root_request, owner).await?; // intermediate let intermediate_request = Import { unique_identifier: UniqueIdentifier::TextString(String::new()), @@ -159,7 +159,7 @@ pub(crate) async fn test_validate_with_certificates_ids() -> Result<(), KmsError certificate_value: intermediate_cert.clone(), }), }; - let res_intermediate = kms.import(intermediate_request, owner, None).await?; + let res_intermediate = kms.import(intermediate_request, owner).await?; // leaf1 let leaf1_request = Import { unique_identifier: UniqueIdentifier::TextString(String::new()), @@ -175,7 +175,7 @@ pub(crate) async fn test_validate_with_certificates_ids() -> Result<(), KmsError certificate_value: leaf1_cert.clone(), }), }; - let res_leaf1 = kms.import(leaf1_request, owner, None).await?; + let res_leaf1 = kms.import(leaf1_request, owner).await?; // Only the root, it is valid by default let request = Validate { certificate: None, @@ -443,7 +443,7 @@ authorityKeyIdentifier=keyid:always,issuer }; let cert_id = kms - .certify(certify_req, owner, None) + .certify(certify_req, owner) .await? .unique_identifier .to_string(); @@ -1032,7 +1032,7 @@ authorityKeyIdentifier=keyid:always,issuer ..Certify::default() }; - let result = kms.certify(certify_req, owner, None).await; + let result = kms.certify(certify_req, owner).await; assert!( result.is_err(), "ML-KEM self-signed certificate creation must be rejected, but it succeeded" @@ -1065,7 +1065,6 @@ authorityKeyIdentifier=keyid:always,issuer ..Certify::default() }, owner, - None, ) .await; assert!(result.is_err(), "ML-KEM-768 self-signed must be rejected"); diff --git a/crate/server/src/tests/ttlv_tests/integrations/vast.rs b/crate/server/src/tests/ttlv_tests/integrations/vast.rs index f05addcbf2..1a1dac8437 100644 --- a/crate/server/src/tests/ttlv_tests/integrations/vast.rs +++ b/crate/server/src/tests/ttlv_tests/integrations/vast.rs @@ -675,9 +675,9 @@ fn test_vast_recertify_request_parsed() { ephemeral: None, unique_batch_item_id: None, request_payload: Operation::ReCertify(ReCertify { - unique_identifier: "non-existent-cert-id".to_owned(), - certificate_request_type: CertificateRequestType::PEM, - certificate_request_value: vec![], + unique_identifier: Some("non-existent-cert-id".to_owned()), + certificate_request_type: Some(CertificateRequestType::PEM), + certificate_request_value: Some(vec![]), template_attribute: None, }), message_extension: None, diff --git a/crate/server_database/Cargo.toml b/crate/server_database/Cargo.toml index 4c9116d15d..c9c0370d86 100644 --- a/crate/server_database/Cargo.toml +++ b/crate/server_database/Cargo.toml @@ -55,6 +55,7 @@ serde = { workspace = true } serde_json = { workspace = true, features = ["preserve_order"] } strum = { workspace = true } thiserror = { workspace = true } +time = { workspace = true } tokio = { workspace = true, features = ["full"] } tokio-postgres = { version = "0.7.18", features = [ "with-uuid-1", diff --git a/crate/server_database/src/core/database_objects.rs b/crate/server_database/src/core/database_objects.rs index e774b1829d..78b655bfb0 100644 --- a/crate/server_database/src/core/database_objects.rs +++ b/crate/server_database/src/core/database_objects.rs @@ -343,6 +343,17 @@ impl Database { Ok(results) } + /// Find all Active objects that have a `rotate_interval > 0` and whose next + /// rotation instant is ≤ `now`. Returns a list of UIDs. + pub async fn find_due_for_rotation(&self, now: time::OffsetDateTime) -> DbResult> { + let map = self.objects.read().await; + let mut results: Vec = Vec::new(); + for db in map.values() { + results.extend(db.find_due_for_rotation(now).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/mod.rs b/crate/server_database/src/stores/mod.rs index 46807a2b66..cfa03ac4b7 100644 --- a/crate/server_database/src/stores/mod.rs +++ b/crate/server_database/src/stores/mod.rs @@ -16,9 +16,8 @@ pub(crate) use sql::{MySqlPool, PgPool, SqlitePool}; const PGSQL_FILE_QUERIES: &str = include_str!("sql/query.sql"); const MYSQL_FILE_QUERIES: &str = include_str!("sql/query_mysql.sql"); -const SQLITE_FILE_QUERIES: &str = include_str!("sql/query.sql"); -static PGSQL_QUERIES: LazyLock = LazyLock::new(|| { +pub(crate) static PGSQL_QUERIES: LazyLock = LazyLock::new(|| { // SAFETY: SQL files are included at compile time and should be valid #[expect(clippy::expect_used)] Loader::get_queries_from(PGSQL_FILE_QUERIES).expect("Can't parse the SQL file") @@ -28,8 +27,3 @@ static MYSQL_QUERIES: LazyLock = LazyLock::new(|| { #[expect(clippy::expect_used)] Loader::get_queries_from(MYSQL_FILE_QUERIES).expect("Can't parse the SQL file") }); -static SQLITE_QUERIES: LazyLock = LazyLock::new(|| { - // SAFETY: SQL files are included at compile time and should be valid - #[expect(clippy::expect_used)] - Loader::get_queries_from(SQLITE_FILE_QUERIES).expect("Can't parse the SQL file") -}); diff --git a/crate/server_database/src/stores/redis/redis_with_findex.rs b/crate/server_database/src/stores/redis/redis_with_findex.rs index 661a8a1b8a..1ce9214a51 100644 --- a/crate/server_database/src/stores/redis/redis_with_findex.rs +++ b/crate/server_database/src/stores/redis/redis_with_findex.rs @@ -657,9 +657,8 @@ impl ObjectsStore for RedisWithFindex { for (uid, dbo) in redis_db_objects { let is_wrapped_by = dbo .object - .key_wrapping_data() - .and_then(|kwd| kwd.encryption_key_information.as_ref()) - .is_some_and(|eki| eki.unique_identifier.to_string() == wrapping_key_uid); + .wrapping_key_uid() + .is_some_and(|wk| wk == wrapping_key_uid); if is_wrapped_by { let attrs = dbo .object diff --git a/crate/server_database/src/stores/sql/locate_query.rs b/crate/server_database/src/stores/sql/locate_query.rs index 36d5f3a3ea..176ab1e743 100644 --- a/crate/server_database/src/stores/sql/locate_query.rs +++ b/crate/server_database/src/stores/sql/locate_query.rs @@ -514,3 +514,51 @@ ON objects.id = matched_tags.id" qb.finish(query) } + +/// Build the SQL query to find objects that are candidates for rotation. +/// Selects active objects with a non-null `RotateInterval > 0`. +/// The actual "due" check (comparing timestamps) is done in Rust via `is_due_for_rotation`. +#[must_use] +pub(super) fn find_due_for_rotation_query() -> String { + let extract = P::extract_attribute_path(&["RotateInterval"]); + let cast_and_compare = if P::NEEDS_INTEGER_CAST { + format!("CAST({extract} AS {}) > 0", P::TYPE_INTEGER) + } else { + // MySQL: CAST with SIGNED for correct numeric comparison + format!("CAST({extract} AS SIGNED) > 0") + }; + format!( + "SELECT objects.id, objects.attributes FROM objects \ + WHERE objects.state = 'Active' \ + AND {extract} IS NOT NULL \ + AND {cast_and_compare}" + ) +} + +/// Determine whether a key object (already known to have `rotate_interval > 0`) +/// is past its scheduled rotation time. +/// +/// The next rotation time is computed as: +/// - `rotate_date + rotate_interval` if `rotate_date` is set (last rotation timestamp) +/// - `initial_date + rotate_offset + rotate_interval` otherwise (first rotation from creation) +/// +/// Returns `true` if `now >= next_rotation_time`. +pub(super) fn is_due_for_rotation(attrs: &Attributes, now: time::OffsetDateTime) -> bool { + let interval_secs = match attrs.rotate_interval { + Some(secs) if secs > 0 => secs, + _ => return false, + }; + let interval = time::Duration::seconds(interval_secs); + + let next_rotation = if let Some(last_rotate) = attrs.rotate_date { + last_rotate + interval + } else if let Some(initial) = attrs.initial_date { + let offset = time::Duration::seconds(attrs.rotate_offset.unwrap_or(0)); + initial + offset + interval + } else { + // No anchor date available — cannot determine schedule + return false; + }; + + now >= next_rotation +} diff --git a/crate/server_database/src/stores/sql/mysql.rs b/crate/server_database/src/stores/sql/mysql.rs index d27c160082..cfb0c77aaa 100644 --- a/crate/server_database/src/stores/sql/mysql.rs +++ b/crate/server_database/src/stores/sql/mysql.rs @@ -31,7 +31,7 @@ use crate::{ migrate::{DbState, Migrate}, sql::{ database::SqlDatabase, - locate_query::{MySqlPlaceholder, query_from_attributes}, + locate_query::{MySqlPlaceholder, find_due_for_rotation_query, query_from_attributes}, }, }, }; @@ -269,6 +269,50 @@ impl MySqlPool { } } + // Add wrapping_key_id column if not present, then backfill existing wrapped objects. + // MySQL 8.0 does not support `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, + // so we check with SHOW COLUMNS first. + let has_col_sql = MYSQL_QUERIES + .get("has-column-wrapping-key-id") + .ok_or_else(|| { + DbError::DatabaseError("Missing SQL query: has-column-wrapping-key-id".to_owned()) + })?; + let rows: Vec = conn.query(has_col_sql).await.map_err(DbError::from)?; + if rows.is_empty() { + let add_col = MYSQL_QUERIES + .get("add-column-wrapping-key-id") + .ok_or_else(|| { + DbError::DatabaseError( + "Missing SQL query: add-column-wrapping-key-id".to_owned(), + ) + })?; + conn.query_drop(add_col).await.map_err(DbError::from)?; + } + // Backfill: deserialize each object in Rust and extract wrapping key UID + let select_sql = MYSQL_QUERIES + .get("select-objects-null-wrapping-key") + .ok_or_else(|| { + DbError::DatabaseError( + "Missing SQL query: select-objects-null-wrapping-key".to_owned(), + ) + })?; + let update_sql = MYSQL_QUERIES.get("update-wrapping-key-id").ok_or_else(|| { + DbError::DatabaseError("Missing SQL query: update-wrapping-key-id".to_owned()) + })?; + let null_rows: Vec<(String, String)> = + conn.query(select_sql).await.map_err(DbError::from)?; + for (id, object_json) in &null_rows { + if let Ok(obj) = + serde_json::from_str::(object_json) + { + if let Some(wrapping_uid) = obj.wrapping_key_uid() { + conn.exec_drop(update_sql, (&wrapping_uid, id)) + .await + .map_err(DbError::from)?; + } + } + } + let this = Self { pool }; // On clear or first boot, update metadata (non-fips only) @@ -638,20 +682,7 @@ impl ObjectsStore for MySqlPool { .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, - wrapping_key_uid, - ), - ) + .exec(sql, (user, user, user, wrapping_key_uid)) .await .map_err(|e| InterfaceError::Db(format!("MySQL query error: {e}")))?; let mut out = Vec::new(); @@ -664,6 +695,30 @@ impl ObjectsStore for MySqlPool { } Ok(out) } + + async fn find_due_for_rotation( + &self, + now: time::OffsetDateTime, + ) -> InterfaceResult> { + let sql = find_due_for_rotation_query::(); + let mut conn = self + .pool + .get_conn() + .await + .map_err(|e| InterfaceError::Db(format!("MySQL connection error: {e}")))?; + let rows: Vec<(String, serde_json::Value)> = conn + .exec(&sql, ()) + .await + .map_err(|e| InterfaceError::Db(format!("MySQL query error: {e}")))?; + let mut due = Vec::new(); + for (uid, attrs_val) in rows { + let attrs: Attributes = serde_json::from_value(attrs_val).unwrap_or_default(); + if crate::stores::sql::locate_query::is_due_for_rotation(&attrs, now) { + due.push(uid); + } + } + Ok(due) + } } #[async_trait(?Send)] @@ -770,6 +825,7 @@ pub(super) async fn create_( DbError::ConversionError(format!("failed serializing the attributes to JSON: {e}").into()) })?; let uid = uid.unwrap_or_else(|| Uuid::new_v4().to_string()); + let wrapping_key_id = object.wrapping_key_uid(); tx.exec_drop( get_mysql_query!("insert-objects"), ( @@ -778,6 +834,7 @@ pub(super) async fn create_( attributes_json, attributes.state.unwrap_or(State::PreActive).to_string(), owner.to_owned(), + wrapping_key_id, ), ) .await @@ -828,9 +885,11 @@ pub(super) async fn update_object_( DbError::ConversionError(format!("failed serializing the attributes to JSON: {e}").into()) })?; + let wrapping_key_id = object.wrapping_key_uid(); + tx.exec_drop( get_mysql_query!("update-object-with-object"), - (object_json, attributes_json, uid), + (object_json, attributes_json, wrapping_key_id, uid), ) .await .map_err(DbError::from)?; @@ -903,9 +962,17 @@ pub(super) async fn upsert_( let attributes_json = serde_json::to_value(attributes).map_err(|e| { DbError::ConversionError(format!("failed serializing the attributes to JSON: {e}").into()) })?; + let wrapping_key_id = object.wrapping_key_uid(); tx.exec_drop( get_mysql_query!("upsert-object"), - (uid, object_json, attributes_json, state.to_string(), owner), + ( + uid, + object_json, + attributes_json, + state.to_string(), + owner, + wrapping_key_id, + ), ) .await .map_err(DbError::from)?; diff --git a/crate/server_database/src/stores/sql/pgsql.rs b/crate/server_database/src/stores/sql/pgsql.rs index 1a08c253d9..794d835933 100644 --- a/crate/server_database/src/stores/sql/pgsql.rs +++ b/crate/server_database/src/stores/sql/pgsql.rs @@ -337,6 +337,40 @@ impl PgPool { ) .await .map_err(DbError::from)?; + // Add wrapping_key_id column if not present, then backfill existing wrapped objects. + client + .batch_execute( + "ALTER TABLE objects ADD COLUMN IF NOT EXISTS wrapping_key_id VARCHAR(128);", + ) + .await + .map_err(DbError::from)?; + // Backfill: deserialize each object in Rust and extract wrapping key UID + let select_stmt = client + .prepare(get_pgsql_query!("select-objects-null-wrapping-key")) + .await + .map_err(DbError::from)?; + let update_stmt = client + .prepare(get_pgsql_query!("update-wrapping-key-id")) + .await + .map_err(DbError::from)?; + let null_rows = client + .query(&select_stmt, &[]) + .await + .map_err(DbError::from)?; + for row in &null_rows { + let id: String = row.get(0); + let object_json: String = row.get(1); + if let Ok(obj) = + serde_json::from_str::(&object_json) + { + if let Some(wrapping_uid) = obj.wrapping_key_uid() { + client + .execute(&update_stmt, &[&wrapping_uid, &id]) + .await + .map_err(DbError::from)?; + } + } + } // Optionally clear any existing data (useful for tests) if clear_database { @@ -398,14 +432,25 @@ impl ObjectsStore for PgPool { let object_json = serde_json::to_string(object).map_err(DbError::from)?; let attributes_json = serde_json::to_value(attributes).map_err(DbError::from)?; let state = attributes.state.unwrap_or(State::PreActive).to_string(); + let wrapping_key_id = object.wrapping_key_uid(); let stmt = tx .prepare(get_pgsql_query!("insert-objects")) .await .map_err(DbError::from)?; let attrs_param = Json(&attributes_json); - tx.execute(&stmt, &[&uid, &object_json, &attrs_param, &state, &owner]) - .await - .map_err(DbError::from)?; + tx.execute( + &stmt, + &[ + &uid, + &object_json, + &attrs_param, + &state, + &owner, + &wrapping_key_id, + ], + ) + .await + .map_err(DbError::from)?; if !tags.is_empty() { let transaction_stmt = tx .prepare(get_pgsql_query!("insert-tags")) @@ -488,12 +533,13 @@ impl ObjectsStore for PgPool { ) -> DbResult<()> { let object_json = serde_json::to_string(object).map_err(DbError::from)?; let attributes_json = serde_json::to_value(attributes).map_err(DbError::from)?; + let wrapping_key_id = object.wrapping_key_uid(); let stmt = tx .prepare(get_pgsql_query!("update-object-with-object")) .await .map_err(DbError::from)?; let attrs_param = Json(&attributes_json); - tx.execute(&stmt, &[&object_json, &attrs_param, &uid]) + tx.execute(&stmt, &[&object_json, &attrs_param, &wrapping_key_id, &uid]) .await .map_err(DbError::from)?; if let Some(tags) = tags { @@ -578,14 +624,25 @@ impl ObjectsStore for PgPool { let attributes_json = serde_json::to_value(attributes).map_err(DbError::from)?; let state = attributes.state.unwrap_or(State::PreActive).to_string(); + let wrapping_key_id = object.wrapping_key_uid(); let stmt = tx .prepare(get_pgsql_query!("insert-objects")) .await .map_err(DbError::from)?; let attrs_param = Json(&attributes_json); - tx.execute(&stmt, &[&uid, &object_json, &attrs_param, &state, &user]) - .await - .map_err(DbError::from)?; + tx.execute( + &stmt, + &[ + &uid, + &object_json, + &attrs_param, + &state, + &user, + &wrapping_key_id, + ], + ) + .await + .map_err(DbError::from)?; if !tags.is_empty() { let insert_stmt = tx .prepare(get_pgsql_query!("insert-tags")) @@ -603,12 +660,13 @@ impl ObjectsStore for PgPool { let object_json = serde_json::to_string(object).map_err(DbError::from)?; let attributes_json = serde_json::to_value(attributes).map_err(DbError::from)?; + let wrapping_key_id = object.wrapping_key_uid(); let stmt = tx .prepare(get_pgsql_query!("update-object-with-object")) .await .map_err(DbError::from)?; let attrs_param = Json(&attributes_json); - tx.execute(&stmt, &[&object_json, &attrs_param, &uid]) + tx.execute(&stmt, &[&object_json, &attrs_param, &wrapping_key_id, &uid]) .await .map_err(DbError::from)?; if let Some(tags) = tags { @@ -646,15 +704,26 @@ impl ObjectsStore for PgPool { let object_json = serde_json::to_string(object).map_err(DbError::from)?; let attributes_json = serde_json::to_value(attributes).map_err(DbError::from)?; + let wrapping_key_id = object.wrapping_key_uid(); let stmt = tx .prepare(get_pgsql_query!("upsert-object")) .await .map_err(DbError::from)?; let st = state.to_string(); let attrs_param = Json(&attributes_json); - tx.execute(&stmt, &[&uid, &object_json, &attrs_param, &st, &user]) - .await - .map_err(DbError::from)?; + tx.execute( + &stmt, + &[ + &uid, + &object_json, + &attrs_param, + &st, + &user, + &wrapping_key_id, + ], + ) + .await + .map_err(DbError::from)?; if let Some(tags) = tags { let delete_stmt = tx .prepare(get_pgsql_query!("delete-tags")) @@ -816,6 +885,31 @@ impl ObjectsStore for PgPool { Ok(out) }) } + + async fn find_due_for_rotation( + &self, + now: time::OffsetDateTime, + ) -> InterfaceResult> { + pg_retry!(self.pool, |client| { + let sql = crate::stores::sql::locate_query::find_due_for_rotation_query::< + crate::stores::sql::locate_query::PgSqlPlaceholder, + >(); + let rows = client + .query(&sql, &[]) + .await + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + let mut due = Vec::new(); + for row in rows { + let uid: String = row.get(0); + let attrs_val: Value = row.get(1); + let attrs: Attributes = serde_json::from_value(attrs_val).unwrap_or_default(); + if crate::stores::sql::locate_query::is_due_for_rotation(&attrs, now) { + due.push(uid); + } + } + Ok(due) + }) + } } #[async_trait(?Send)] diff --git a/crate/server_database/src/stores/sql/query.sql b/crate/server_database/src/stores/sql/query.sql index 2e0253c4b2..f4f1fc80be 100644 --- a/crate/server_database/src/stores/sql/query.sql +++ b/crate/server_database/src/stores/sql/query.sql @@ -24,13 +24,17 @@ CREATE TABLE IF NOT EXISTS objects ( object VARCHAR NOT NULL, attributes jsonb NOT NULL, state VARCHAR(32), - owner VARCHAR(255) + owner VARCHAR(255), + wrapping_key_id VARCHAR(128) ); -- name: add-column-attributes ALTER TABLE objects ADD COLUMN attributes json; -- name: has-column-attributes SELECT attributes from objects; +-- name: add-column-wrapping-key-id +ALTER TABLE objects ADD COLUMN IF NOT EXISTS wrapping_key_id VARCHAR(128); + -- name: create-table-read_access CREATE TABLE IF NOT EXISTS read_access ( id VARCHAR(128), @@ -56,7 +60,7 @@ DELETE FROM read_access; DELETE FROM tags; -- name: insert-objects -INSERT INTO objects (id, object, attributes, state, owner) VALUES ($1, $2, $3, $4, $5); +INSERT INTO objects (id, object, attributes, state, owner, wrapping_key_id) VALUES ($1, $2, $3, $4, $5, $6); -- name: select-object SELECT objects.id, objects.object, objects.attributes, objects.owner, objects.state @@ -64,7 +68,7 @@ SELECT objects.id, objects.object, objects.attributes, objects.owner, objects.st WHERE objects.id=$1; -- name: update-object-with-object -UPDATE objects SET object=$1, attributes=$2 WHERE id=$3; +UPDATE objects SET object=$1, attributes=$2, wrapping_key_id=$3 WHERE id=$4; -- name: update-object-with-state UPDATE objects SET state=$1 WHERE id=$2; @@ -73,9 +77,9 @@ UPDATE objects SET state=$1 WHERE id=$2; DELETE FROM objects WHERE id=$1; -- name: upsert-object -INSERT INTO objects (id, object, attributes, state, owner) VALUES ($1, $2, $3, $4, $5) +INSERT INTO objects (id, object, attributes, state, owner, wrapping_key_id) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT(id) - DO UPDATE SET object=$2, attributes=$3, state=$4, owner=$5 + DO UPDATE SET object=$2, attributes=$3, state=$4, owner=$5, wrapping_key_id=$6 WHERE objects.owner=$5; -- name: select-user-accesses-for-object @@ -144,11 +148,10 @@ 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->'PublicKey'->'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 - ); + AND objects.wrapping_key_id = $1; + +-- name: select-objects-null-wrapping-key +SELECT id, object FROM objects WHERE wrapping_key_id IS NULL; + +-- name: update-wrapping-key-id +UPDATE objects SET wrapping_key_id = $1 WHERE id = $2; diff --git a/crate/server_database/src/stores/sql/query_mysql.sql b/crate/server_database/src/stores/sql/query_mysql.sql index c17d5d3876..99654ae087 100644 --- a/crate/server_database/src/stores/sql/query_mysql.sql +++ b/crate/server_database/src/stores/sql/query_mysql.sql @@ -24,11 +24,12 @@ WHERE name = ?; -- name: create-table-objects CREATE TABLE IF NOT EXISTS objects ( - id VARCHAR(128) PRIMARY KEY, - object LONGTEXT NOT NULL, - attributes json NOT NULL, - state VARCHAR(32), - owner VARCHAR(255) + id VARCHAR(128) PRIMARY KEY, + object LONGTEXT NOT NULL, + attributes json NOT NULL, + state VARCHAR(32), + owner VARCHAR(255), + wrapping_key_id VARCHAR(128) ); -- name: add-column-attributes @@ -38,6 +39,12 @@ ALTER TABLE objects -- name: has-column-attributes SHOW COLUMNS FROM objects LIKE 'attributes'; +-- name: has-column-wrapping-key-id +SHOW COLUMNS FROM objects LIKE 'wrapping_key_id'; + +-- name: add-column-wrapping-key-id +ALTER TABLE objects ADD COLUMN wrapping_key_id VARCHAR(128); + -- name: create-table-read_access CREATE TABLE IF NOT EXISTS read_access ( @@ -69,8 +76,8 @@ FROM tags; -- name: insert-objects -INSERT INTO objects (id, object, attributes, state, owner) -VALUES (?, ?, ?, ?, ?); +INSERT INTO objects (id, object, attributes, state, owner, wrapping_key_id) +VALUES (?, ?, ?, ?, ?, ?); -- name: select-object SELECT objects.id, objects.object, objects.attributes, objects.owner, objects.state @@ -80,7 +87,8 @@ WHERE objects.id = ?; -- name: update-object-with-object UPDATE objects SET object=?, - attributes=? + attributes=?, + wrapping_key_id=? WHERE id = ?; -- name: update-object-with-state @@ -94,12 +102,13 @@ FROM objects WHERE id = ?; -- name: upsert-object -INSERT INTO objects (id, object, attributes, state, owner) -VALUES (?, ?, ?, ?, ?) +INSERT INTO objects (id, object, attributes, state, owner, wrapping_key_id) +VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE object=VALUES(object), attributes=VALUES(attributes), state=VALUES(state), - owner=VALUES(owner); + owner=VALUES(owner), + wrapping_key_id=VALUES(wrapping_key_id); -- name: select-user-accesses-for-object SELECT permissions @@ -187,11 +196,10 @@ 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->>'$.PublicKey.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' = ? - ); + AND objects.wrapping_key_id = ?; + +-- name: select-objects-null-wrapping-key +SELECT id, object FROM objects WHERE wrapping_key_id IS NULL; + +-- name: update-wrapping-key-id +UPDATE objects SET wrapping_key_id = ? WHERE id = ?; diff --git a/crate/server_database/src/stores/sql/sqlite.rs b/crate/server_database/src/stores/sql/sqlite.rs index 669ba18997..e8b5faf354 100644 --- a/crate/server_database/src/stores/sql/sqlite.rs +++ b/crate/server_database/src/stores/sql/sqlite.rs @@ -23,13 +23,13 @@ use serde_json::Value; use tokio_rusqlite::Connection; use uuid::Uuid; -use super::locate_query::{SqlitePlaceholder, query_from_attributes}; +use super::locate_query::{SqlitePlaceholder, find_due_for_rotation_query, query_from_attributes}; use crate::{ db_error, error::{DbError, DbResult}, migrate_block_cipher_mode_if_needed, stores::{ - SQLITE_QUERIES, + PGSQL_QUERIES, migrate::{DbState, Migrate}, sql::database::SqlDatabase, }, @@ -37,7 +37,7 @@ use crate::{ macro_rules! get_sqlite_query { ($name:literal) => { - SQLITE_QUERIES + PGSQL_QUERIES .get($name) .ok_or_else(|| db_error!("{} SQL query can't be found", $name))? }; @@ -134,6 +134,62 @@ impl SqlitePool { ) .await .map_err(DbError::from)?; + + // Add wrapping_key_id column if not present (migration for existing databases), + // then backfill from the embedded JSON for any pre-existing wrapped objects. + // Note: SQLite does not support `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, + // so we check PRAGMA table_info first. + pool.writer + .call( + move |c: &mut rusqlite::Connection| -> Result<(), rusqlite::Error> { + let has_column: bool = { + let mut stmt = c.prepare("PRAGMA table_info(objects)")?; + let mut rows = stmt.query([])?; + let mut found = false; + while let Some(row) = rows.next()? { + let col_name: String = row.get(1)?; + if col_name == "wrapping_key_id" { + found = true; + break; + } + } + found + }; + if !has_column { + c.execute_batch( + "ALTER TABLE objects ADD COLUMN wrapping_key_id VARCHAR(128);", + )?; + } + // Backfill: deserialize each object and extract wrapping key UID via Rust + let mut stmt = + c.prepare("SELECT id, object FROM objects WHERE wrapping_key_id IS NULL")?; + let pairs: Vec<(String, String)> = { + let mut rows = stmt.query([])?; + let mut out = Vec::new(); + while let Some(row) = rows.next()? { + out.push((row.get(0)?, row.get(1)?)); + } + out + }; + for (id, object_json) in &pairs { + if let Ok(obj) = serde_json::from_str::< + cosmian_kmip::kmip_2_1::kmip_objects::Object, + >(object_json) + { + if let Some(wrapping_uid) = obj.wrapping_key_uid() { + c.execute( + "UPDATE objects SET wrapping_key_id = ?1 WHERE id = ?2", + rusqlite::params![wrapping_uid, id], + )?; + } + } + } + Ok(()) + }, + ) + .await + .map_err(DbError::from)?; + if clear_database { pool.set_current_db_version(env!("CARGO_PKG_VERSION")) .await?; @@ -161,7 +217,7 @@ impl SqlitePool { impl SqlDatabase for SqlitePool { fn get_loader(&self) -> &Loader { - &SQLITE_QUERIES + &PGSQL_QUERIES } } @@ -248,6 +304,7 @@ impl ObjectsStore for SqlitePool { .map_err(|e| InterfaceError::Db(format!("failed serializing attributes: {e}")))?; let state_s = attributes.state.unwrap_or(State::PreActive).to_string(); let owner_s = owner.to_owned(); + let wrapping_key_id = object.wrapping_key_uid(); let insert_object = replace_dollars_with_qn(get_sqlite_query!("insert-objects")); let insert_tag = replace_dollars_with_qn(get_sqlite_query!("insert-tags")); @@ -261,13 +318,14 @@ impl ObjectsStore for SqlitePool { // Insert object tx.execute( &insert_object, - params_from_iter([ - &uid_clone, - &object_json, - &attributes_json, - &state_s, - &owner_s, - ]), + rusqlite::params![ + uid_clone, + object_json, + attributes_json, + state_s, + owner_s, + wrapping_key_id, + ], )?; // Insert tags for tag in &tags_owned { @@ -333,6 +391,7 @@ impl ObjectsStore for SqlitePool { .map_err(|e| InterfaceError::Db(format!("failed serializing object: {e}")))?; let attributes_json = serde_json::to_string(attributes) .map_err(|e| InterfaceError::Db(format!("failed serializing attributes: {e}")))?; + let wrapping_key_id = object.wrapping_key_uid(); let sql_update = replace_dollars_with_qn(get_sqlite_query!("update-object-with-object")); let sql_delete_tags = replace_dollars_with_qn(get_sqlite_query!("delete-tags")); @@ -346,7 +405,7 @@ impl ObjectsStore for SqlitePool { let tx = c.transaction()?; tx.execute( &sql_update, - params_from_iter([&object_json, &attributes_json, &uid_s]), + rusqlite::params![object_json, attributes_json, wrapping_key_id, uid_s], )?; if let Some(tags) = tags_owned.as_ref() { tx.execute(&sql_delete_tags, params_from_iter([&uid_s]))?; @@ -540,24 +599,7 @@ impl ObjectsStore for SqlitePool { 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, '$.PublicKey.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 sql = replace_dollars_with_qn(get_sqlite_query!("find-wrapped-by")); let uid_s = wrapping_key_uid.to_owned(); let user_s = user.to_owned(); let rows = self @@ -592,13 +634,49 @@ impl ObjectsStore for SqlitePool { .map_err(DbError::from)?; Ok(rows) } + + async fn find_due_for_rotation( + &self, + now: time::OffsetDateTime, + ) -> InterfaceResult> { + let sql = find_due_for_rotation_query::(); + let rows = self + .reader() + .call( + move |c: &mut rusqlite::Connection| -> Result< + Vec<(String, String)>, + rusqlite::Error, + > { + let mut stmt = c.prepare(&sql)?; + let mut q = stmt.query([])?; + let mut out = Vec::new(); + while let Some(r) = q.next()? { + let id: String = r.get(0)?; + let attrs_json: String = r.get(1)?; + out.push((id, attrs_json)); + } + Ok(out) + }, + ) + .await + .map_err(DbError::from)?; + + let mut due = Vec::new(); + for (uid, attrs_json) in rows { + let attrs: Attributes = serde_json::from_str(&attrs_json).unwrap_or_default(); + if crate::stores::sql::locate_query::is_due_for_rotation(&attrs, now) { + due.push(uid); + } + } + Ok(due) + } } #[async_trait(?Send)] impl Migrate for SqlitePool { async fn get_db_state(&self) -> DbResult> { let select_param = replace_dollars_with_qn( - SQLITE_QUERIES + PGSQL_QUERIES .get("select-parameter") .ok_or_else(|| db_error!("select-parameter SQL query can't be found"))?, ); @@ -625,7 +703,7 @@ impl Migrate for SqlitePool { async fn set_db_state(&self, state: DbState) -> DbResult<()> { let upsert_param = replace_dollars_with_qn( - SQLITE_QUERIES + PGSQL_QUERIES .get("upsert-parameter") .ok_or_else(|| db_error!("upsert-parameter SQL query can't be found"))?, ); @@ -649,7 +727,7 @@ impl Migrate for SqlitePool { async fn get_current_db_version(&self) -> DbResult> { let select_param = replace_dollars_with_qn( - SQLITE_QUERIES + PGSQL_QUERIES .get("select-parameter") .ok_or_else(|| db_error!("select-parameter SQL query can't be found"))?, ); @@ -673,7 +751,7 @@ impl Migrate for SqlitePool { async fn set_current_db_version(&self, version: &str) -> DbResult<()> { let upsert_param = replace_dollars_with_qn( - SQLITE_QUERIES + PGSQL_QUERIES .get("upsert-parameter") .ok_or_else(|| db_error!("upsert-parameter SQL query can't be found"))?, ); @@ -902,13 +980,21 @@ fn create_sqlite( DbError::DatabaseError(format!("failed serializing the attributes to JSON: {e}")) })?; let uid = uid.unwrap_or_else(|| Uuid::new_v4().to_string()); + let wrapping_key_id = object.wrapping_key_uid(); let sql = replace_dollars_with_qn(get_sqlite_query!("insert-objects")); let state_s = attributes.state.unwrap_or(State::PreActive).to_string(); let owner_s = owner.to_owned(); tx.execute( &sql, - params_from_iter([&uid, &object_json, &attributes_json, &state_s, &owner_s]), + rusqlite::params![ + uid, + object_json, + attributes_json, + state_s, + owner_s, + wrapping_key_id + ], )?; let sql = replace_dollars_with_qn(get_sqlite_query!("insert-tags")); @@ -931,11 +1017,12 @@ fn update_object_sqlite( let attributes_json = serde_json::to_string(attributes).map_err(|e| { DbError::DatabaseError(format!("failed serializing the attributes to JSON: {e}")) })?; + let wrapping_key_id = object.wrapping_key_uid(); let sql = replace_dollars_with_qn(get_sqlite_query!("update-object-with-object")); let uid_s = uid.to_owned(); tx.execute( &sql, - params_from_iter([&object_json, &attributes_json, &uid_s]), + rusqlite::params![object_json, attributes_json, wrapping_key_id, uid_s], )?; if let Some(tags) = tags { let del = replace_dollars_with_qn(get_sqlite_query!("delete-tags")); @@ -963,13 +1050,21 @@ fn upsert_sqlite( let attributes_json = serde_json::to_string(attributes).map_err(|e| { DbError::DatabaseError(format!("failed serializing the attributes to JSON: {e}")) })?; + let wrapping_key_id = object.wrapping_key_uid(); let sql = replace_dollars_with_qn(get_sqlite_query!("upsert-object")); let state_s = state.to_string(); let uid_s = uid.to_owned(); let owner_s = owner.to_owned(); tx.execute( &sql, - params_from_iter([&uid_s, &object_json, &attributes_json, &state_s, &owner_s]), + rusqlite::params![ + uid_s, + object_json, + attributes_json, + state_s, + owner_s, + wrapping_key_id + ], )?; if let Some(tags) = tags { let del = replace_dollars_with_qn(get_sqlite_query!("delete-tags")); diff --git a/crate/test_kms_server/src/test_server.rs b/crate/test_kms_server/src/test_server.rs index eb7c105bb5..bc9582c1df 100644 --- a/crate/test_kms_server/src/test_server.rs +++ b/crate/test_kms_server/src/test_server.rs @@ -19,7 +19,7 @@ static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); use actix_server::ServerHandle; use cosmian_kms_client::{ GmailApiConf, KmsClient, KmsClientConfig, KmsClientError, - cosmian_kmip::{KmipResultHelper, kmip_2_1::extra::tagging::VENDOR_ID_COSMIAN, time_normalize}, + cosmian_kmip::{KmipResultHelper, kmip_2_1::extra::tagging::VENDOR_ID_COSMIAN}, kmip_0::kmip_types::CryptographicUsageMask, kmip_2_1::{ kmip_attributes::Attributes, @@ -375,7 +375,6 @@ async fn create_kek_in_db() -> Result<(PathBuf, String), KmsClientError> { ), object_type: Some(ObjectType::SymmetricKey), unique_identifier: Some(UniqueIdentifier::TextString(kek_id.to_owned())), - activation_date: Some(time_normalize()?), ..Default::default() }, protection_storage_masks: None, @@ -496,7 +495,6 @@ async fn create_softhsm2_kek_in_db() -> Result<(PathBuf, String), KmsClientError ), object_type: Some(ObjectType::SymmetricKey), unique_identifier: Some(UniqueIdentifier::TextString(kek_id.clone())), - activation_date: Some(time_normalize()?), ..Default::default() }, protection_storage_masks: None, diff --git a/crate/test_kms_server/src/vector_runner.rs b/crate/test_kms_server/src/vector_runner.rs index 04525e0e37..61730e1419 100644 --- a/crate/test_kms_server/src/vector_runner.rs +++ b/crate/test_kms_server/src/vector_runner.rs @@ -3383,29 +3383,6 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/rekey_with_offset_state").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 - } - #[tokio::test] async fn test_neg_rekey_wrapped_deactivated() -> Result<(), KmsClientError> { crate::init_test_logging(); @@ -3657,23 +3634,6 @@ ObjectType = "SymmetricKey" 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/scripts/generate_rekey_vectors.sh b/scripts/generate_rekey_vectors.sh index 5e2eb1fb99..8d0c4545f1 100755 --- a/scripts/generate_rekey_vectors.sh +++ b/scripts/generate_rekey_vectors.sh @@ -1300,7 +1300,7 @@ cat >"$DIR/manifest.toml" <<'EOF' name = "ReKeyKeyPair With Offset" description = """ Verifies that ReKeyKeyPair with an Offset parameter correctly \ -applies date arithmetic on the replacement key pair. +applies date computation on the replacement key pair. """ [[steps]] diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0a01f6ceeb..fd1014191e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -33,8 +33,11 @@ import FpeEncryptForm from "./actions/FPE/FpeEncrypt"; import FpeKeyCreateForm from "./actions/FPE/FpeKeysCreate"; import CseInfo from "./actions/Keys/CseInfo"; import DeriveKeyForm from "./actions/Keys/DeriveKey"; +import GetRotationPolicyForm from "./actions/Keys/GetRotationPolicy"; import KeyExportForm from "./actions/Keys/KeysExport"; import KeyImportForm from "./actions/Keys/KeysImport"; +import KeysReKeyForm from "./actions/Keys/KeysReKey"; +import SetRotationPolicyForm from "./actions/Keys/SetRotationPolicy"; import SymKeyCreateForm from "./actions/Keys/SymKeysCreate"; import MacComputeForm from "./actions/MAC/MacCompute"; import MacVerifyForm from "./actions/MAC/MacVerify"; @@ -67,8 +70,7 @@ import TokenizeWordPatternMask from "./actions/Tokenize/TokenizeWordPatternMask" import TokenizeWordTokenize from "./actions/Tokenize/TokenizeWordTokenize"; import LocateForm from "./components/common/Locate"; import MainLayout from "./components/layout/MainLayout"; -import { AuthProvider } from "./contexts/AuthContext"; -import { useAuth } from "./contexts/useAuth"; +import { AuthProvider, useAuth } from "./contexts/AuthContext"; import { useBranding } from "./contexts/useBranding"; import LoginPage from "./pages/LoginPage"; import NotFoundPage from "./pages/NotFoundPage"; @@ -90,9 +92,8 @@ const isLoopbackHost = (host: string): boolean => LOOPBACK_HOSTS.has(host); const resolveServerUrl = (): string => { const configuredUrl = (import.meta.env.VITE_KMS_URL as string | undefined)?.trim(); - const isDevMode = import.meta.env.DEV || import.meta.env.VITE_DEV_MODE === "true"; const defaultDevUrl = `${window.location.protocol}//${window.location.hostname}:9998`; - const fallbackUrl = isDevMode ? defaultDevUrl : window.location.origin; + const fallbackUrl = import.meta.env.DEV ? defaultDevUrl : window.location.origin; const candidate = configuredUrl && configuredUrl.length > 0 ? configuredUrl : fallbackUrl; try { @@ -263,6 +264,9 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/menuItems.tsx b/ui/src/menuItems.tsx index 660f5f8b48..5ca9c3f46b 100644 --- a/ui/src/menuItems.tsx +++ b/ui/src/menuItems.tsx @@ -52,6 +52,9 @@ const baseMenu: MenuItem[] = [ { key: "sym/keys/create", label: "Create" }, { key: "sym/keys/export", label: "Export" }, { key: "sym/keys/import", label: "Import" }, + { key: "sym/keys/rekey", label: "Re-Key" }, + { key: "sym/keys/set-rotation-policy", label: "Set Rotation Policy" }, + { key: "sym/keys/get-rotation-policy", label: "Get Rotation Policy" }, { key: "sym/keys/revoke", label: "Revoke" }, { key: "sym/keys/destroy", label: "Destroy" }, ], @@ -64,7 +67,7 @@ const baseMenu: MenuItem[] = [ { key: "rsa", label: "RSA", - icon: , + icon: , collapsedlabel: "RSA", children: [ { From a04a759bf59ad7693b608256291af400604a3e75 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Thu, 4 Jun 2026 22:15:53 +0200 Subject: [PATCH 10/30] fix(redis-findex): index wrapped key under the wrapping-key ID --- .../src/stores/redis/objects_db.rs | 4 ++ .../src/stores/redis/redis_with_findex.rs | 56 +++++++++---------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/crate/server_database/src/stores/redis/objects_db.rs b/crate/server_database/src/stores/redis/objects_db.rs index 754da64f19..de4388ac71 100644 --- a/crate/server_database/src/stores/redis/objects_db.rs +++ b/crate/server_database/src/stores/redis/objects_db.rs @@ -123,6 +123,10 @@ impl RedisDbObject { } // index the owner keywords.insert(Keyword::from(self.owner.as_bytes())); + // index the wrapping key UID so find_wrapped_by can search by keyword + if let Some(wk_uid) = self.object.wrapping_key_uid() { + keywords.insert(Keyword::from(format!("wrapped_by::{wk_uid}").as_bytes())); + } keywords } } diff --git a/crate/server_database/src/stores/redis/redis_with_findex.rs b/crate/server_database/src/stores/redis/redis_with_findex.rs index 1ce9214a51..865eacdb5a 100644 --- a/crate/server_database/src/stores/redis/redis_with_findex.rs +++ b/crate/server_database/src/stores/redis/redis_with_findex.rs @@ -628,48 +628,46 @@ impl ObjectsStore for RedisWithFindex { wrapping_key_uid: &str, user: &str, ) -> InterfaceResult> { - // Get UIDs owned by the user via Findex - let user_keyword = Keyword::from(user.as_bytes()); - let owned_uids = self + // Search Findex for objects indexed under this wrapping key + let keyword = Keyword::from(format!("wrapped_by::{wrapping_key_uid}").as_bytes()); + let indexed_uids = self .findex - .search(&user_keyword) + .search(&keyword) .await - .map_err(|e| db_error!(format!("Error searching owned UIDs: {e:?}")))?; - let mut accessible_uids: HashSet = owned_uids + .map_err(|e| db_error!(format!("Error searching wrapped_by keyword: {e:?}")))?; + if indexed_uids.is_empty() { + return Ok(vec![]); + } + + let candidate_uids: HashSet = indexed_uids .iter() - .filter_map(|uid| String::from_utf8(uid.to_vec()).ok()) + .filter_map(|v| String::from_utf8(v.to_vec()).ok()) .collect(); - // Add UIDs the user has read access to + // Fetch only the candidate objects + let redis_db_objects = self.objects_db.objects_get(&candidate_uids).await?; + + // Filter by access: user must own the object or have permissions on it let permissions = self .permission_db .list_user_permissions(&UserId(user.to_owned())) .await?; - accessible_uids.extend(permissions.keys().map(|k| k.0.clone())); - if accessible_uids.is_empty() { - return Ok(vec![]); - } - - // Fetch all accessible objects and filter by wrapping key UID - let redis_db_objects = self.objects_db.objects_get(&accessible_uids).await?; let mut out = Vec::new(); for (uid, dbo) in redis_db_objects { - let is_wrapped_by = dbo - .object - .wrapping_key_uid() - .is_some_and(|wk| wk == wrapping_key_uid); - if is_wrapped_by { - let attrs = dbo - .object - .attributes() - .cloned() - .unwrap_or_else(|_| Attributes { - object_type: Some(dbo.object.object_type()), - ..Default::default() - }); - out.push((uid, dbo.state, attrs)); + let has_access = dbo.owner == user || permissions.contains_key(&ObjectUid(uid.clone())); + if !has_access { + continue; } + let attrs = dbo + .object + .attributes() + .cloned() + .unwrap_or_else(|_| Attributes { + object_type: Some(dbo.object.object_type()), + ..Default::default() + }); + out.push((uid, dbo.state, attrs)); } Ok(out) } From b77a466a7f28b9e62fc19ee2111def688c980ffa Mon Sep 17 00:00:00 2001 From: Manuthor Date: Fri, 5 Jun 2026 06:58:49 +0200 Subject: [PATCH 11/30] fix: state restriction - PreActive and Compromise cannot be rotated --- CHANGELOG/docs_key-autorotation-spec.md | 3 + .../server/src/core/operations/key_ops/mod.rs | 95 ++++++++++++++----- crate/server/src/core/operations/recertify.rs | 8 ++ .../src/core/operations/rekey/common.rs | 40 +++++--- .../server/src/core/retrieve_object_utils.rs | 81 +++++++++++++--- .../server/src/middlewares/jwt/jwt_config.rs | 7 +- crate/server/src/start_kms_server.rs | 9 +- crate/server/src/tests/kmip_policy/basic.rs | 1 - crate/test_kms_server/README.md | 8 +- crate/test_kms_server/src/vector_runner.rs | 25 +++-- .../docs/kmip_support/key_auto_rotation.md | 32 +++++++ 11 files changed, 251 insertions(+), 58 deletions(-) diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md index 556db24623..d0507b0ecf 100644 --- a/CHANGELOG/docs_key-autorotation-spec.md +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -7,11 +7,13 @@ - Add proper `ReCertify` and `ReCertifyResponse` KMIP 2.1 types compliant with both KMIP 1.x and 2.x ([#968](https://github.com/Cosmian/kms/pull/968)) - Introduce `RekeyOperation` trait to unify symmetric, keypair, and certificate rotation logic ([#968](https://github.com/Cosmian/kms/pull/968)) - Add `offset` field to `ReCertify` struct per KMIP 2.1 §6.1.45 for date-based activation scheduling ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement KMIP §4.57 transition 6 auto-deactivation: Active keys automatically transition to Deactivated when their DeactivationDate is reached ([#968](https://github.com/Cosmian/kms/pull/968)) ## Security - Mark `x-rotate-generation` and `x-rotate-date` as server-managed read-only attributes: reject user modifications via AddAttribute, SetAttribute, ModifyAttribute, and DeleteAttribute ([#968](https://github.com/Cosmian/kms/pull/968)) - Reject `Re-Key` and `Re-Key Key Pair` on HSM-managed keys (`hsm::` UID prefix) with an explicit `Not Supported` error instead of silently failing deep in the pipeline ([#968](https://github.com/Cosmian/kms/pull/968)) +- Restrict rotation to Active or Deactivated keys: `Re-Key`, `Re-Key Key Pair`, and `ReCertify` now reject PreActive, Compromised, Destroyed, and Destroyed_Compromised objects with an explicit error (KMIP §6.1.46 does not list `Wrong_Key_Lifecycle_State` for Re-Key) ([#968](https://github.com/Cosmian/kms/pull/968)) ## Bug Fixes @@ -68,6 +70,7 @@ - Add 4 ReCertify test vectors (self-signed, chain, with-links, with-offset) ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 3 negative ReCertify test vectors (missing UID, non-existent, not a certificate) ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 2 offset state verification vectors (rekey + rekey-keypair: Offset=0 → Active, Offset=86400 → PreActive) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 2 negative state restriction vectors: `rekey_preactive_fails`, `rekey_keypair_preactive_fails` ([#968](https://github.com/Cosmian/kms/pull/968)) ## Documentation diff --git a/crate/server/src/core/operations/key_ops/mod.rs b/crate/server/src/core/operations/key_ops/mod.rs index 10b77e3934..2651de0459 100644 --- a/crate/server/src/core/operations/key_ops/mod.rs +++ b/crate/server/src/core/operations/key_ops/mod.rs @@ -138,30 +138,41 @@ impl ObjectWithMetadataOps for ObjectWithMetadata { fn get_effective_state(&self) -> KResult { let stored_state = self.state(); - // Only PreActive objects can auto-transition to Active - if stored_state != State::PreActive { - return Ok(stored_state); - } - - // Check if there's an activation_date set - let activation_date = self.attributes().activation_date.or_else(|| { - // Fallback to object's attributes if not in metadata - self.object() - .attributes() - .ok() - .and_then(|attrs| attrs.activation_date) - }); - - if let Some(activation_date) = activation_date { - let now = time_normalize()?; - if activation_date <= now { - // The activation date has passed, treat as Active - return Ok(State::Active); + match stored_state { + State::PreActive => { + // KMIP §4.57 transition 4: PreActive → Active when ActivationDate is reached + let activation_date = self.attributes().activation_date.or_else(|| { + self.object() + .attributes() + .ok() + .and_then(|attrs| attrs.activation_date) + }); + if let Some(activation_date) = activation_date { + let now = time_normalize()?; + if activation_date <= now { + return Ok(State::Active); + } + } + Ok(State::PreActive) } + State::Active => { + // KMIP §4.57 transition 6: Active → Deactivated when DeactivationDate is reached + let deactivation_date = self.attributes().deactivation_date.or_else(|| { + self.object() + .attributes() + .ok() + .and_then(|attrs| attrs.deactivation_date) + }); + if let Some(deactivation_date) = deactivation_date { + let now = time_normalize()?; + if deactivation_date <= now { + return Ok(State::Deactivated); + } + } + Ok(State::Active) + } + _ => Ok(stored_state), } - - // No activation_date or it's in the future, remain PreActive - Ok(State::PreActive) } fn check_process_window(&self) -> KResult<()> { @@ -393,4 +404,44 @@ mod tests { assert_eq!(owm.get_effective_state()?, State::Active); Ok(()) } + + #[test] + fn test_effective_state_active_with_past_deactivation_date() -> KResult<()> { + let attrs = Attributes { + state: Some(State::Active), + deactivation_date: Some(time_normalize()? - Duration::hours(1)), + ..Default::default() + }; + + let owm = ObjectWithMetadata::new( + "test-id".to_owned(), + test_object(), + "owner".to_owned(), + State::Active, + attrs, + ); + + assert_eq!(owm.get_effective_state()?, State::Deactivated); + Ok(()) + } + + #[test] + fn test_effective_state_active_with_future_deactivation_date() -> KResult<()> { + let attrs = Attributes { + state: Some(State::Active), + deactivation_date: Some(time_normalize()? + Duration::hours(1)), + ..Default::default() + }; + + let owm = ObjectWithMetadata::new( + "test-id".to_owned(), + test_object(), + "owner".to_owned(), + State::Active, + attrs, + ); + + assert_eq!(owm.get_effective_state()?, State::Active); + Ok(()) + } } diff --git a/crate/server/src/core/operations/recertify.rs b/crate/server/src/core/operations/recertify.rs index 3327b05af7..b95f076649 100644 --- a/crate/server/src/core/operations/recertify.rs +++ b/crate/server/src/core/operations/recertify.rs @@ -110,6 +110,14 @@ impl RekeyOperation for CertificateRekey { ))); } + if owm.state() != State::Active && owm.state() != State::Deactivated { + kms_bail!(KmsError::InvalidRequest(format!( + "ReCertify: certificate '{uid}' is in state '{}' — only Active or Deactivated \ + certificates can be renewed", + owm.state() + ))); + } + Ok(vec![RotationCandidate { owm, uid: uid.to_owned(), diff --git a/crate/server/src/core/operations/rekey/common.rs b/crate/server/src/core/operations/rekey/common.rs index 994381537b..66cec47dd7 100644 --- a/crate/server/src/core/operations/rekey/common.rs +++ b/crate/server/src/core/operations/rekey/common.rs @@ -112,25 +112,41 @@ pub(crate) fn preserve_wrapping_key_link( /// Retrieve all eligible objects matching the given identifier, filtered by state and type. /// /// Filters by: -/// - State: `Active` or `PreActive` +/// - State: `Active` or `Deactivated` (per KMIP §6.1.46 — `Wrong_Key_Lifecycle_State` is NOT +/// listed in the Re-Key error table, meaning Deactivated keys are eligible for rotation). +/// `PreActive`, `Compromised`, `Destroyed`, and `Destroyed_Compromised` keys are rejected. /// - Object type: the specified `object_type` /// -/// Returns the list of matching [`ObjectWithMetadata`] entries. +/// When a specific UID resolves to a key of the correct type but in an +/// ineligible state, an explicit error is returned rather than silently +/// skipping. For tag-based queries, ineligible keys are filtered out. 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()) + let is_tag_query = uid_or_tags.starts_with('['); + let objects = kms.database.retrieve_objects(uid_or_tags).await?; + let mut eligible = Vec::new(); + + for owm in objects.into_values() { + if owm.object().object_type() != object_type { + continue; + } + if owm.state() != State::Active && owm.state() != State::Deactivated { + // For direct UID queries, give an explicit error instead of silently skipping + if !is_tag_query { + return Err(KmsError::InvalidRequest(format!( + "key '{}' is in state '{}' — only Active or Deactivated keys can be rotated", + owm.id(), + owm.state() + ))); + } + continue; + } + eligible.push(owm); + } + Ok(eligible) } // ─── Trait: RekeyOperation ─────────────────────────────────────────────────── diff --git a/crate/server/src/core/retrieve_object_utils.rs b/crate/server/src/core/retrieve_object_utils.rs index e87f695a98..55efa8b598 100644 --- a/crate/server/src/core/retrieve_object_utils.rs +++ b/crate/server/src/core/retrieve_object_utils.rs @@ -110,12 +110,9 @@ pub(crate) async fn retrieve_object_for_operation( attributes.state = Some(effective_state); } - // KMIP 2.1 Auto-activation: Automatically activate PreActive objects when activation_date has passed - // This ensures the database state stays synchronized with the object's actual lifecycle state + // KMIP 2.1 Auto-activation: PreActive → Active when ActivationDate has passed (§4.57 transition 4) if effective_state == State::PreActive { - // Check if activation_date is set and has passed let activation_date = owm.attributes().activation_date.or_else(|| { - // Fallback to object's attributes if not in metadata owm.object() .attributes() .ok() @@ -125,23 +122,16 @@ pub(crate) async fn retrieve_object_for_operation( if let Some(activation_date) = activation_date { let now = time_normalize()?; if activation_date <= now { - // Activation date has passed, automatically transition to Active trace!( "Auto-activating object {} (activation_date {} <= now {})", owm.id(), activation_date, now ); - - // Update state in both the object attributes and metadata owm.attributes_mut().state = Some(State::Active); if let Ok(ref mut attributes) = owm.object_mut().attributes_mut() { attributes.state = Some(State::Active); } - - // Persist the state change to database - // Note: We do this synchronously to ensure consistency, but log errors - // rather than failing the retrieval if the update fails if let Err(e) = kms.database.update_state(owm.id(), State::Active).await { warn!( "Failed to persist auto-activation of object {}: {}", @@ -149,6 +139,75 @@ pub(crate) async fn retrieve_object_for_operation( e ); } + // Re-check: the now-Active key may also need auto-deactivation + let deactivation_date = owm.attributes().deactivation_date.or_else(|| { + owm.object() + .attributes() + .ok() + .and_then(|attrs| attrs.deactivation_date) + }); + if let Some(deactivation_date) = deactivation_date { + if deactivation_date <= now { + trace!( + "Auto-deactivating object {} (deactivation_date {} <= now {})", + owm.id(), + deactivation_date, + now + ); + owm.attributes_mut().state = Some(State::Deactivated); + if let Ok(ref mut attributes) = owm.object_mut().attributes_mut() { + attributes.state = Some(State::Deactivated); + } + if let Err(e) = kms + .database + .update_state(owm.id(), State::Deactivated) + .await + { + warn!( + "Failed to persist auto-deactivation of object {}: {}", + owm.id(), + e + ); + } + } + } + } + } + } + + // KMIP 2.1 Auto-deactivation: Active → Deactivated when DeactivationDate has passed (§4.57 transition 6) + if owm.attributes().state == Some(State::Active) { + let deactivation_date = owm.attributes().deactivation_date.or_else(|| { + owm.object() + .attributes() + .ok() + .and_then(|attrs| attrs.deactivation_date) + }); + + if let Some(deactivation_date) = deactivation_date { + let now = time_normalize()?; + if deactivation_date <= now { + trace!( + "Auto-deactivating object {} (deactivation_date {} <= now {})", + owm.id(), + deactivation_date, + now + ); + owm.attributes_mut().state = Some(State::Deactivated); + if let Ok(ref mut attributes) = owm.object_mut().attributes_mut() { + attributes.state = Some(State::Deactivated); + } + if let Err(e) = kms + .database + .update_state(owm.id(), State::Deactivated) + .await + { + warn!( + "Failed to persist auto-deactivation of object {}: {}", + owm.id(), + e + ); + } } } } diff --git a/crate/server/src/middlewares/jwt/jwt_config.rs b/crate/server/src/middlewares/jwt/jwt_config.rs index 22125db2ac..f112cf6aa6 100644 --- a/crate/server/src/middlewares/jwt/jwt_config.rs +++ b/crate/server/src/middlewares/jwt/jwt_config.rs @@ -256,6 +256,11 @@ impl JwtConfig { #[cfg(test)] #[cfg(not(feature = "insecure"))] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::assertions_on_result_states +)] mod tests { use jsonwebtoken::Algorithm; @@ -332,7 +337,7 @@ mod tests { fn rejection_error_message_quality() { let result = check_alg(Algorithm::HS256); assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); + let msg = result.expect_err("HS256 must be rejected").to_string(); assert!( msg.contains("not permitted"), "error message should mention 'not permitted', got: {msg}" diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index fd276c438a..4be0ee41f2 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -1222,6 +1222,7 @@ fn validate_jwks_uris_are_https(uris: &[String]) -> KResult<()> { #[cfg(test)] #[expect(clippy::expect_used)] +#[allow(clippy::assertions_on_result_states)] mod tests { use super::*; @@ -1308,7 +1309,9 @@ mod tests { let uris = vec!["http://idp.example.com/.well-known/jwks.json".to_owned()]; let result = validate_jwks_uris_are_https(&uris); assert!(result.is_err(), "HTTP JWKS URI must be rejected"); - let msg = result.unwrap_err().to_string(); + let msg = result + .expect_err("HTTP JWKS URI must be rejected") + .to_string(); assert!( msg.contains("HTTPS") || msg.contains("https"), "Error message must mention HTTPS, got: {msg}" @@ -1346,7 +1349,9 @@ mod tests { result.is_err(), "List containing an HTTP URI must be rejected" ); - let msg = result.unwrap_err().to_string(); + let msg = result + .expect_err("List containing an HTTP URI must be rejected") + .to_string(); assert!( msg.contains("bad.example.com"), "Error message must identify the offending URI, got: {msg}" diff --git a/crate/server/src/tests/kmip_policy/basic.rs b/crate/server/src/tests/kmip_policy/basic.rs index 05569eea09..332eb0c4ba 100644 --- a/crate/server/src/tests/kmip_policy/basic.rs +++ b/crate/server/src/tests/kmip_policy/basic.rs @@ -16,7 +16,6 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_operatio use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::UniqueIdentifier; #[cfg(feature = "non-fips")] use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::requests::create_ec_key_pair_request; -#[cfg(feature = "non-fips")] use cosmian_kms_server_database::reexport::cosmian_kmip::{ kmip_0::kmip_types::{BlockCipherMode, HashingAlgorithm, PaddingMethod}, kmip_2_1::{ diff --git a/crate/test_kms_server/README.md b/crate/test_kms_server/README.md index 006f348f43..95670a412e 100644 --- a/crate/test_kms_server/README.md +++ b/crate/test_kms_server/README.md @@ -165,7 +165,7 @@ replays the steps sequentially. | KMIP Operations | `register_export` | Register, Get, Export, Destroy | 4 | | KMIP Operations | `rekey` | Create, ReKey, Encrypt | 3 | | KMIP Operations | `rekey_locate_by_name` | Create (named), Locate, ReKey, Locate (finds new key), GetAttributes (old=Active — ReKey does not deactivate the existing key) | 5 | -| KMIP Operations | `rekey_deactivated_fails` | Create, ReKey, Revoke (old → Deactivated), ReKey (old → fails) | 4 | +| KMIP Operations | `rekey_deactivated_succeeds` | Create, ReKey, Revoke (old → Deactivated), ReKey (old → succeeds per KMIP §6.1.46) | 9 | | 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 | @@ -187,7 +187,7 @@ replays the steps sequentially. | KMIP Operations | `rekey_keypair_ml_dsa_87` | CreateKeyPair (ML-DSA-87), ReKeyKeyPair, Revoke+Destroy | 5 | | KMIP Operations | `rekey_keypair_slh_dsa_sha2_128f` | CreateKeyPair (SLH-DSA-SHA2-128f), ReKeyKeyPair, Revoke+Destroy | 5 | | KMIP Operations | `rekey_keypair_double_chain` | CreateKeyPair (EC), ReKeyKeyPair ×2, verify link chain | 7 | -| KMIP Operations | `rekey_keypair_deactivated_fails` | CreateKeyPair (EC), Revoke SK, ReKeyKeyPair → fails | 4 | +| KMIP Operations | `rekey_keypair_deactivated_succeeds` | CreateKeyPair (EC), Revoke SK, ReKeyKeyPair → succeeds per KMIP §6.1.47 | 10 | | KMIP Operations | `rekey_keypair_change_algo_fails` | CreateKeyPair (EC), ReKeyKeyPair (different algo) → fails | 3 | | KMIP Operations | `rekey_keypair_ec_locate_by_name` | CreateKeyPair (named), ReKeyKeyPair, Locate (finds new key) | 5 | | KMIP Operations | `rekey_keypair_name_removed_from_old` | CreateKeyPair (named), ReKeyKeyPair, GetAttributes (old has no Name) | 5 | @@ -333,7 +333,9 @@ replays the steps sequentially. | 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 | -| Negative / ReKey | `negative/rekey_wrapped_deactivated` | Create wrapping key + wrapped key, Revoke wrapped, ReKey → fails (deactivated) | 7 | +| KMIP Operations | `rekey_wrapped_deactivated_succeeds` | Create wrapping key + wrapped key, Revoke wrapped, ReKey → succeeds (KMIP §6.1.46) | 10 | +| Negative / ReKey | `negative/rekey_preactive_fails` | Create (no ActivationDate → PreActive), ReKey → fails (not Active or Deactivated) | 4 | +| Negative / ReKeyKeyPair | `negative/rekey_keypair_preactive_fails` | CreateKeyPair (no ActivationDate → PreActive), ReKeyKeyPair → fails (not Active or Deactivated) | 5 | | **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 61730e1419..1f59a15ab9 100644 --- a/crate/test_kms_server/src/vector_runner.rs +++ b/crate/test_kms_server/src/vector_runner.rs @@ -1589,9 +1589,9 @@ ObjectType = "SymmetricKey" } #[tokio::test] - async fn test_vec_rekey_deactivated_fails() -> Result<(), KmsClientError> { + async fn test_vec_rekey_deactivated_succeeds() -> Result<(), KmsClientError> { crate::init_test_logging(); - run_test_vector("test_data/vectors/fips/kmip_operations/rekey_deactivated_fails").await + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_deactivated_succeeds").await } #[tokio::test] @@ -3384,9 +3384,22 @@ ObjectType = "SymmetricKey" } #[tokio::test] - async fn test_neg_rekey_wrapped_deactivated() -> Result<(), KmsClientError> { + async fn test_vec_rekey_wrapped_deactivated_succeeds() -> Result<(), KmsClientError> { crate::init_test_logging(); - run_test_vector("test_data/vectors/negative/rekey_wrapped_deactivated").await + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_wrapped_deactivated_succeeds") + .await + } + + #[tokio::test] + async fn test_neg_rekey_preactive_fails() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/rekey_preactive_fails").await + } + + #[tokio::test] + async fn test_neg_rekey_keypair_preactive_fails() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/rekey_keypair_preactive_fails").await } // ── KMIP operations: ReKeyKeyPair (non-FIPS only) ──────────────────── @@ -3518,9 +3531,9 @@ ObjectType = "SymmetricKey" #[cfg(feature = "non-fips")] #[tokio::test] - async fn test_vec_rekey_keypair_deactivated_fails() -> Result<(), KmsClientError> { + async fn test_vec_rekey_keypair_deactivated_succeeds() -> Result<(), KmsClientError> { crate::init_test_logging(); - run_test_vector("test_data/vectors/fips/kmip_operations/rekey_keypair_deactivated_fails") + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_keypair_deactivated_succeeds") .await } diff --git a/documentation/docs/kmip_support/key_auto_rotation.md b/documentation/docs/kmip_support/key_auto_rotation.md index 917b861542..9704a59a01 100644 --- a/documentation/docs/kmip_support/key_auto_rotation.md +++ b/documentation/docs/kmip_support/key_auto_rotation.md @@ -116,6 +116,38 @@ keys owned by any user whose `x-rotate-interval` has elapsed since either --- +## State restrictions + +Only keys (or certificates) in the **Active** or **Deactivated** state can be +rotated. Attempting to call `Re-Key`, `Re-Key Key Pair`, or `ReCertify` on an +object in any other state will produce an error: + +| State | Rotation allowed? | Rationale | +| ---------------------- | ----------------- | --------------------------------------------------------------------------- | +| **Active** | ✅ Yes | The primary valid source state for rotation. | +| **Deactivated** | ✅ Yes | KMIP §6.1.46 does not list `Wrong_Key_Lifecycle_State` — a deactivated key may produce a replacement. | +| **Pre-Active** | ❌ No | The key has never been activated — rotating unused material is premature. | +| **Compromised** | ❌ No | Rotating a compromised key would create confusion about trust lineage. | +| **Destroyed** | ❌ No | The object no longer exists. | +| **Destroyed_Compromised** | ❌ No | The object no longer exists. | + +> **Note:** This restriction applies to the **source** key only. The *output* +> of a rotation operation can still enter the `Pre-Active` state when an +> `Offset > 0` is supplied in the request (the new key's `Activation Date` is +> computed as `Initial Date + Offset`, scheduling future activation). + +--- + +## Auto-deactivation (KMIP §4.57 transition 6) + +Per KMIP §4.57 state transition 6, the server **automatically transitions** an +Active key to the Deactivated state when its `Deactivation Date` is reached. +This happens on retrieval (the same mechanism as PreActive → Active +auto-activation). There is no need for an explicit `Revoke` call — setting a +`Deactivation Date` in the future schedules the deactivation. + +--- + ## Key types and rotation flows The behaviour differs according to whether the key is plain, a wrapping key, From a387226fb3f0f32826fcc7187c4472c5dd68ac3a Mon Sep 17 00:00:00 2001 From: Manuthor Date: Fri, 5 Jun 2026 11:07:19 +0200 Subject: [PATCH 12/30] feat: Key-Set@Version + Server-Side Decrypt-With-Keyset --- CHANGELOG/docs_key-autorotation-spec.md | 21 +- Cargo.lock | 1 + crate/clients/ckms/src/tests/symmetric/mod.rs | 1 + .../src/tests/symmetric/rotation_policy.rs | 125 +++++++ .../symmetric/keys/get_rotation_policy.rs | 54 ++- .../symmetric/keys/set_rotation_policy.rs | 63 +++- crate/hsm/base_hsm/Cargo.toml | 1 + crate/hsm/base_hsm/src/hsm_lib.rs | 7 +- crate/hsm/base_hsm/src/kms_hsm.rs | 27 ++ .../hsm/base_hsm/src/session/session_impl.rs | 264 +++++++++++++- crate/interfaces/src/crypto_oracle.rs | 13 +- crate/interfaces/src/hsm/hsm_store.rs | 143 +++++++- crate/interfaces/src/hsm/interface.rs | 37 ++ crate/interfaces/src/lib.rs | 2 +- crate/interfaces/src/stores/objects_store.rs | 41 +++ .../src/config/command_line/clap_config.rs | 11 + .../server/src/config/params/server_params.rs | 9 + .../src/core/operations/attributes/add.rs | 6 + .../src/core/operations/attributes/get.rs | 11 + .../src/core/operations/attributes/modify.rs | 6 + .../src/core/operations/attributes/set.rs | 73 +++- crate/server/src/core/operations/decrypt.rs | 6 +- .../src/core/operations/key_ops/crypto_op.rs | 218 ++++++++++-- .../server/src/core/operations/key_ops/mod.rs | 2 +- crate/server/src/core/operations/mac.rs | 6 +- crate/server/src/core/operations/mod.rs | 2 +- .../src/core/operations/rekey/common.rs | 5 +- .../src/core/operations/rekey/keypair.rs | 12 + .../src/core/operations/rekey/symmetric.rs | 193 +++++++++- .../src/core/operations/signature_verify.rs | 6 +- crate/server/src/core/uid_utils.rs | 334 +++++++++++++++++- crate/server/src/main.rs | 6 + .../src/core/database_objects.rs | 47 +++ .../src/stores/redis/objects_db.rs | 6 + .../src/stores/redis/redis_with_findex.rs | 54 +++ .../src/stores/sql/locate_query.rs | 46 +++ crate/server_database/src/stores/sql/mysql.rs | 40 ++- crate/server_database/src/stores/sql/pgsql.rs | 45 +++ .../server_database/src/stores/sql/sqlite.rs | 56 ++- crate/test_kms_server/README.md | 16 +- crate/test_kms_server/src/vector_runner.rs | 86 +++++ .../docs/kmip_support/key_auto_rotation.md | 226 +++++++++--- ui/src/actions/Keys/GetRotationPolicy.tsx | 101 ++++++ ui/src/actions/Keys/KeysReKey.tsx | 73 ++++ ui/src/actions/Keys/SetRotationPolicy.tsx | 115 ++++++ ui/src/menuItems.tsx | 1 - 46 files changed, 2485 insertions(+), 133 deletions(-) create mode 100644 crate/clients/ckms/src/tests/symmetric/rotation_policy.rs create mode 100644 ui/src/actions/Keys/GetRotationPolicy.tsx create mode 100644 ui/src/actions/Keys/KeysReKey.tsx create mode 100644 ui/src/actions/Keys/SetRotationPolicy.tsx diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md index d0507b0ecf..bce1ffa0a6 100644 --- a/CHANGELOG/docs_key-autorotation-spec.md +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -8,12 +8,24 @@ - Introduce `RekeyOperation` trait to unify symmetric, keypair, and certificate rotation logic ([#968](https://github.com/Cosmian/kms/pull/968)) - Add `offset` field to `ReCertify` struct per KMIP 2.1 §6.1.45 for date-based activation scheduling ([#968](https://github.com/Cosmian/kms/pull/968)) - Implement KMIP §4.57 transition 6 auto-deactivation: Active keys automatically transition to Deactivated when their DeactivationDate is reached ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement keyset resolution: `name@latest`, `name@first`, `name@N` syntax to address specific key generations by `rotate_name` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement try-each-key decryption: Decrypt, SignatureVerify, and MACVerify operations with a bare keyset name walk the rotation chain (newest→oldest) until one key succeeds ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `--keyset-decrypt-max-attempts` server config flag (default: 100) to cap rotation chain traversal depth ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `find_by_rotate_name()` to `ObjectsStore` trait with SQLite, PostgreSQL, and MySQL implementations for keyset lookup ([#968](https://github.com/Cosmian/kms/pull/968)) +- Inherit `rotate_name` from old key to new key during ReKey so keyset resolution works across generations ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement `ckms sym keys set-rotation-policy` CLI command with `--interval`, `--offset`, `--rotation-name` flags ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement `ckms sym keys get-rotation-policy` CLI command to display interval, offset, keyset name, generation, and last rotation date ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add Web UI components for Set Rotation Policy, Get Rotation Policy, and Re-Key under Symmetric Keys ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add HSM keyset support: store keyset metadata in `CKA_LABEL` (`name::gen::base_id[::latest]`); `SetAttribute rotate_name` writes `CKA_LABEL`; `SetAttribute rotate_interval` writes `CKA_START_DATE`/`CKA_END_DATE`; `Re-Key` on HSM UIDs generates a new HSM key and updates both CKA_LABELs; keyset resolution via `find_by_rotate_name` enumerates PKCS#11 objects and sorts by generation ([#968](https://github.com/Cosmian/kms/pull/968)) +- Enrich `HsmStore::retrieve()` export path with CKA_LABEL keyset metadata (`rotate_name`, `rotate_generation`, `rotate_latest`) so the non-latest guard works for extractable HSM keys ([#968](https://github.com/Cosmian/kms/pull/968)) ## Security - Mark `x-rotate-generation` and `x-rotate-date` as server-managed read-only attributes: reject user modifications via AddAttribute, SetAttribute, ModifyAttribute, and DeleteAttribute ([#968](https://github.com/Cosmian/kms/pull/968)) -- Reject `Re-Key` and `Re-Key Key Pair` on HSM-managed keys (`hsm::` UID prefix) with an explicit `Not Supported` error instead of silently failing deep in the pipeline ([#968](https://github.com/Cosmian/kms/pull/968)) +- Guard `Re-Key` / `Re-Key Key Pair` to reject rotation of non-latest keyset members (`x-rotate-latest = false` and `x-rotate-name` set) with a clear error; keys without a keyset name are unaffected ([#968](https://github.com/Cosmian/kms/pull/968)) +- Reject `SetAttribute rotate_offset` on HSM keys with `NotSupported` — HSM rotation scheduling uses `CKA_START_DATE`/`CKA_END_DATE`, not SQL-managed offset windows ([#968](https://github.com/Cosmian/kms/pull/968)) - Restrict rotation to Active or Deactivated keys: `Re-Key`, `Re-Key Key Pair`, and `ReCertify` now reject PreActive, Compromised, Destroyed, and Destroyed_Compromised objects with an explicit error (KMIP §6.1.46 does not list `Wrong_Key_Lifecycle_State` for Re-Key) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Reject `@` character in `rotate_name` attribute values to prevent keyset versioning syntax injection ([#968](https://github.com/Cosmian/kms/pull/968)) ## Bug Fixes @@ -33,6 +45,9 @@ - Fix symmetric rekey hardcoding `State::Active` — now uses `setup_object_lifecycle` for date-based state computation ([#968](https://github.com/Cosmian/kms/pull/968)) - Fix `setup_object_lifecycle` not storing `activation_date` for `PreActive` keys — offset-based activation scheduling now works correctly ([#968](https://github.com/Cosmian/kms/pull/968)) - Add `ReCertify` request/response deserialization to KMIP 2.1 message handler ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `find_by_rotate_name` SQL queries using wrong JSON path (`$.rotate_name` → `$.RotateName`) matching PascalCase serde serialization ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `GetAttributes` not returning rotation policy fields (interval, offset, name, generation, date) because they lack `Tag` enum entries ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement `find_by_rotate_name()` on Redis-Findex backend and index `rotate_name` attribute in Findex keywords ([#968](https://github.com/Cosmian/kms/pull/968)) - Fix `ReCertify.generate_replacement` passing empty user to `get_subject`/`get_issuer` — use certificate owner instead ([#968](https://github.com/Cosmian/kms/pull/968)) - Fix `ReCertify` not computing lifecycle state from offset — certificates with future activation_date are now `PreActive` ([#968](https://github.com/Cosmian/kms/pull/968)) @@ -71,6 +86,9 @@ - Add 3 negative ReCertify test vectors (missing UID, non-existent, not a certificate) ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 2 offset state verification vectors (rekey + rekey-keypair: Offset=0 → Active, Offset=86400 → PreActive) ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 2 negative state restriction vectors: `rekey_preactive_fails`, `rekey_keypair_preactive_fails` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 7 keyset resolution test vectors: `keyset_encrypt_latest`, `keyset_encrypt_bare_name`, `keyset_encrypt_latest_after_rotation`, `keyset_decrypt_try_each`, `keyset_decrypt_double_rotation`, `keyset_decrypt_at_latest`, `keyset_rotate_name_at_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add HSM keyset support: `find_due_for_rotation` on HsmStore reads `CKA_START_DATE`/`CKA_END_DATE`; keyset metadata stored in `CKA_LABEL` (`name::gen::key_id[::latest]`); `SetAttribute rotate_name` writes `CKA_LABEL` via `C_SetAttributeValue`; `SetAttribute rotate_interval` writes `CKA_START_DATE`/`CKA_END_DATE`; `Re-Key` on HSM UIDs generates a new HSM key and updates both CKA_LABELs; `walk_keyset_chain` resolves HSM keysets by `CKA_LABEL` without `ReplacedObjectLink` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 3 HSM keyset test vectors: `resident_keyset_set_rotate_name`, `resident_keyset_rekey_and_decrypt`, `resident_keyset_double_rotation` + 1 negative: `hsm_rotate_offset_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) ## Documentation @@ -81,6 +99,7 @@ - Correct HSM key rotation section: the KMS cannot use KMIP `Re-Key` on HSM-managed keys (no SQL attribute storage, non-extractable key material); use PKCS#11 vendor tools instead ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add HSM keyset section to `key_auto_rotation.md`: CKA_LABEL convention, UID generation format, supported/unsupported attributes, example workflow, and keyset resolution description ([#968](https://github.com/Cosmian/kms/pull/968)) - Document `x-rotate-generation` and `x-rotate-date` invariants: monotonically increasing counter unique within a key-set, authoritative last-rotation timestamp relied on by `is_due_for_rotation` ([#968](https://github.com/Cosmian/kms/pull/968)) diff --git a/Cargo.lock b/Cargo.lock index fe15670a0e..c0dc341702 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,6 +1160,7 @@ dependencies = [ "pkcs11-sys", "rand 0.10.1", "thiserror 2.0.17", + "time", "uuid", "zeroize", ] diff --git a/crate/clients/ckms/src/tests/symmetric/mod.rs b/crate/clients/ckms/src/tests/symmetric/mod.rs index 25fa964bff..7d4a33781e 100644 --- a/crate/clients/ckms/src/tests/symmetric/mod.rs +++ b/crate/clients/ckms/src/tests/symmetric/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod create_key; pub(crate) mod encrypt_decrypt; pub(crate) mod rekey; +pub(crate) mod rotation_policy; pub(crate) const SUB_COMMAND: &str = "sym"; diff --git a/crate/clients/ckms/src/tests/symmetric/rotation_policy.rs b/crate/clients/ckms/src/tests/symmetric/rotation_policy.rs new file mode 100644 index 0000000000..e78f7b6f67 --- /dev/null +++ b/crate/clients/ckms/src/tests/symmetric/rotation_policy.rs @@ -0,0 +1,125 @@ +use test_kms_server::start_default_test_kms_server; + +use crate::{ + error::result::CosmianResult, + tests::{ + symmetric::create_key::create_symmetric_key, + utils::{owner_config, run_ckms, run_ckms_expect_error}, + }, +}; + +/// Set the rotation policy for a symmetric key via the CLI. +pub(crate) fn set_rotation_policy( + cli_conf_path: &str, + key_id: &str, + interval: i64, + offset: Option, + rotate_name: Option<&str>, +) -> CosmianResult { + let mut args = vec!["sym", "keys", "set-rotation-policy", "--key-id", key_id]; + let interval_str = interval.to_string(); + args.extend(["--interval", &interval_str]); + let offset_str; + if let Some(o) = offset { + offset_str = o.to_string(); + args.extend(["--offset", &offset_str]); + } + if let Some(name) = rotate_name { + args.extend(["--rotation-name", name]); + } + run_ckms(cli_conf_path, &args) +} + +/// Get the rotation policy for a symmetric key via the CLI. +pub(crate) fn get_rotation_policy(cli_conf_path: &str, key_id: &str) -> CosmianResult { + let args = vec!["sym", "keys", "get-rotation-policy", "--key-id", key_id]; + run_ckms(cli_conf_path, &args) +} + +#[tokio::test] +pub(crate) async fn test_set_and_get_rotation_policy() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a symmetric key + let key_id = create_symmetric_key(&owner_client_conf_path, &["--number-of-bits", "256"])?; + + // Set rotation policy with interval, offset, and name + let output = set_rotation_policy( + &owner_client_conf_path, + &key_id, + 86400, + Some(3600), + Some("test-keyset"), + )?; + assert!(output.contains("Rotation policy set successfully")); + + // Get rotation policy and verify + let output = get_rotation_policy(&owner_client_conf_path, &key_id)?; + assert!( + output.contains("86400"), + "expected interval=86400 in: {output}" + ); + assert!(output.contains("3600"), "expected offset=3600 in: {output}"); + assert!( + output.contains("test-keyset"), + "expected name=test-keyset in: {output}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_set_rotation_policy_name_rejects_at() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a symmetric key + let key_id = create_symmetric_key(&owner_client_conf_path, &["--number-of-bits", "256"])?; + + // Try to set rotation policy with a name containing '@' — should fail + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--interval", + "86400", + "--rotation-name", + "bad@name", + ]; + let stderr = run_ckms_expect_error(&owner_client_conf_path, &args)?; + assert!( + stderr.contains('@'), + "expected error mentioning '@' in: {stderr}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_set_rotation_policy_interval_only() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a symmetric key + let key_id = create_symmetric_key(&owner_client_conf_path, &["--number-of-bits", "256"])?; + + // Set rotation policy with interval only (no offset, no name) + let output = set_rotation_policy(&owner_client_conf_path, &key_id, 43200, None, None)?; + assert!(output.contains("Rotation policy set successfully")); + + // Get rotation policy and verify + let output = get_rotation_policy(&owner_client_conf_path, &key_id)?; + assert!( + output.contains("43200"), + "expected interval=43200 in: {output}" + ); + assert!( + output.contains("not set") || !output.contains("offset"), + "expected no offset set" + ); + + Ok(()) +} diff --git a/crate/clients/clap/src/actions/symmetric/keys/get_rotation_policy.rs b/crate/clients/clap/src/actions/symmetric/keys/get_rotation_policy.rs index b9bd9cab84..1bf7b0c204 100644 --- a/crate/clients/clap/src/actions/symmetric/keys/get_rotation_policy.rs +++ b/crate/clients/clap/src/actions/symmetric/keys/get_rotation_policy.rs @@ -1,9 +1,17 @@ use clap::Parser; -use cosmian_kms_client::KmsClient; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{kmip_operations::GetAttributes, kmip_types::UniqueIdentifier}, +}; -use crate::error::result::KmsCliResult; +use crate::{ + actions::console, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; /// Get the automatic rotation policy for a symmetric key. +/// +/// Displays: rotation interval, offset, keyset name, generation, and last rotation date. #[derive(Parser, Debug)] #[clap(verbatim_doc_comment)] pub struct GetRotationPolicyAction { @@ -13,9 +21,45 @@ pub struct GetRotationPolicyAction { } impl GetRotationPolicyAction { - #[allow(clippy::unused_async)] - pub async fn run(&self, _kms_rest_client: KmsClient) -> KmsCliResult<()> { - // TODO: implement KMIP Get Attributes call to retrieve rotation policy + pub async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult<()> { + let uid = UniqueIdentifier::TextString(self.key_id.clone()); + + let response = kms_rest_client + .get_attributes(GetAttributes { + unique_identifier: Some(uid), + attribute_reference: None, + }) + .await + .with_context(|| "failed retrieving attributes")?; + + let attrs = &response.attributes; + + let interval = attrs + .rotate_interval + .map_or_else(|| "not set".to_owned(), |v| v.to_string()); + let offset = attrs + .rotate_offset + .map_or_else(|| "not set".to_owned(), |v| v.to_string()); + let name = attrs.rotate_name.as_deref().unwrap_or("not set"); + let generation = attrs + .rotate_generation + .map_or_else(|| "not set".to_owned(), |v| v.to_string()); + let date = attrs + .rotate_date + .map_or_else(|| "never".to_owned(), |d| d.to_string()); + + let output = format!( + "Rotation policy for key: {}\n\ + \x20 Interval (seconds): {interval}\n\ + \x20 Offset (seconds): {offset}\n\ + \x20 Keyset name: {name}\n\ + \x20 Generation: {generation}\n\ + \x20 Last rotation date: {date}", + response.unique_identifier + ); + + console::Stdout::new(&output).write()?; + Ok(()) } } diff --git a/crate/clients/clap/src/actions/symmetric/keys/set_rotation_policy.rs b/crate/clients/clap/src/actions/symmetric/keys/set_rotation_policy.rs index 13d968371a..3816c7205a 100644 --- a/crate/clients/clap/src/actions/symmetric/keys/set_rotation_policy.rs +++ b/crate/clients/clap/src/actions/symmetric/keys/set_rotation_policy.rs @@ -1,9 +1,22 @@ use clap::Parser; -use cosmian_kms_client::KmsClient; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{ + kmip_attributes::Attribute, kmip_operations::SetAttribute, kmip_types::UniqueIdentifier, + }, +}; -use crate::error::result::KmsCliResult; +use crate::{ + actions::console, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; /// Set the automatic rotation policy for a symmetric key. +/// +/// This configures: +/// - The rotation interval (how often the key is automatically re-keyed) +/// - An optional offset (delay before first rotation) +/// - An optional keyset name (for addressing key generations via name@version syntax) #[derive(Parser, Debug)] #[clap(verbatim_doc_comment)] pub struct SetRotationPolicyAction { @@ -18,12 +31,52 @@ pub struct SetRotationPolicyAction { /// Offset in seconds from the initial date before the first rotation occurs. #[clap(long = "offset", short = 'o')] offset_secs: Option, + + /// A keyset name for addressing key generations via name@latest, name@first, name@N syntax. + /// Must not contain the '@' character. + #[clap(long = "rotation-name", short = 'n')] + rotate_name: Option, } impl SetRotationPolicyAction { - #[allow(clippy::unused_async)] - pub async fn run(&self, _kms_rest_client: KmsClient) -> KmsCliResult<()> { - // TODO: implement KMIP Modify Attribute call to set rotation policy + pub async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult<()> { + let uid = UniqueIdentifier::TextString(self.key_id.clone()); + + // Set the rotation interval + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateInterval(self.interval_secs), + }) + .await + .with_context(|| "failed setting RotateInterval attribute")?; + + // Set the rotation offset if provided + if let Some(offset) = self.offset_secs { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateOffset(offset), + }) + .await + .with_context(|| "failed setting RotateOffset attribute")?; + } + + // Set the rotation name if provided + if let Some(ref name) = self.rotate_name { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateName(name.clone()), + }) + .await + .with_context(|| "failed setting RotateName attribute")?; + } + + let mut stdout = console::Stdout::new("Rotation policy set successfully."); + stdout.set_unique_identifier(&uid); + stdout.write()?; + Ok(()) } } diff --git a/crate/hsm/base_hsm/Cargo.toml b/crate/hsm/base_hsm/Cargo.toml index 6694807b8c..17558e019a 100644 --- a/crate/hsm/base_hsm/Cargo.toml +++ b/crate/hsm/base_hsm/Cargo.toml @@ -26,5 +26,6 @@ lru = { workspace = true } pkcs11-sys = { workspace = true } rand = { workspace = true } thiserror = { workspace = true } +time = { workspace = true } uuid = { workspace = true, features = ["v4"] } zeroize = { workspace = true } diff --git a/crate/hsm/base_hsm/src/hsm_lib.rs b/crate/hsm/base_hsm/src/hsm_lib.rs index 207a0f1e58..3e310e752d 100644 --- a/crate/hsm/base_hsm/src/hsm_lib.rs +++ b/crate/hsm/base_hsm/src/hsm_lib.rs @@ -8,8 +8,9 @@ use pkcs11_sys::{ CK_C_Finalize, CK_C_FindObjects, CK_C_FindObjectsFinal, CK_C_FindObjectsInit, CK_C_GenerateKey, CK_C_GenerateKeyPair, CK_C_GenerateRandom, CK_C_GetAttributeValue, CK_C_GetInfo, CK_C_GetMechanismInfo, CK_C_GetMechanismList, CK_C_INITIALIZE_ARGS, CK_C_Initialize, - CK_C_Login, CK_C_Logout, CK_C_OpenSession, CK_C_SeedRandom, CK_C_Sign, CK_C_SignInit, - CK_C_UnwrapKey, CK_C_WrapKey, CKF_OS_LOCKING_OK, CKR_CRYPTOKI_ALREADY_INITIALIZED, CKR_OK, + CK_C_Login, CK_C_Logout, CK_C_OpenSession, CK_C_SeedRandom, CK_C_SetAttributeValue, CK_C_Sign, + CK_C_SignInit, CK_C_UnwrapKey, CK_C_WrapKey, CKF_OS_LOCKING_OK, + CKR_CRYPTOKI_ALREADY_INITIALIZED, CKR_OK, }; use crate::{HResult, hsm_call}; @@ -85,6 +86,7 @@ pub struct HsmLib { pub(crate) C_SeedRandom: CK_C_SeedRandom, pub(crate) C_GetAttributeValue: CK_C_GetAttributeValue, + pub(crate) C_SetAttributeValue: CK_C_SetAttributeValue, pub(crate) C_GetInfo: CK_C_GetInfo, pub(crate) C_GetMechanismList: CK_C_GetMechanismList, @@ -130,6 +132,7 @@ impl HsmLib { C_GenerateRandom: Some(*library.get(b"C_GenerateRandom")?), C_SeedRandom: Some(*library.get(b"C_SeedRandom")?), C_GetAttributeValue: Some(*library.get(b"C_GetAttributeValue")?), + C_SetAttributeValue: Some(*library.get(b"C_SetAttributeValue")?), C_GetInfo: Some(*library.get(b"C_GetInfo")?), C_GetMechanismList: Some(*library.get(b"C_GetMechanismList")?), C_GetMechanismInfo: Some(*library.get(b"C_GetMechanismInfo")?), diff --git a/crate/hsm/base_hsm/src/kms_hsm.rs b/crate/hsm/base_hsm/src/kms_hsm.rs index dc128a977b..dba90d1ebe 100644 --- a/crate/hsm/base_hsm/src/kms_hsm.rs +++ b/crate/hsm/base_hsm/src/kms_hsm.rs @@ -253,6 +253,33 @@ impl HSM for BaseHsm

{ Ok(()) } + async fn set_key_dates( + &self, + slot_id: usize, + key_id: &[u8], + start_date: Option, + end_date: Option, + ) -> InterfaceResult<()> { + let slot = self.get_slot(slot_id)?; + let session = slot.open_session(true)?; + let handle = session.get_object_handle(key_id)?; + session.set_key_dates(handle, start_date, end_date)?; + Ok(()) + } + + async fn set_key_label( + &self, + slot_id: usize, + key_id: &[u8], + label: &str, + ) -> InterfaceResult<()> { + let slot = self.get_slot(slot_id)?; + let session = slot.open_session(true)?; + let handle = session.get_object_handle(key_id)?; + session.set_label(handle, label)?; + Ok(()) + } + fn hsm_lib(&self) -> Option<&dyn std::any::Any> { Some(self.hsm_lib()) } diff --git a/crate/hsm/base_hsm/src/session/session_impl.rs b/crate/hsm/base_hsm/src/session/session_impl.rs index 29fa4e73d4..61ea8f2dd8 100644 --- a/crate/hsm/base_hsm/src/session/session_impl.rs +++ b/crate/hsm/base_hsm/src/session/session_impl.rs @@ -50,16 +50,16 @@ use cosmian_kms_interfaces::{ }; use cosmian_logger::{debug, trace}; use pkcs11_sys::{ - CK_AES_GCM_PARAMS, CK_ATTRIBUTE, CK_BBOOL, CK_FALSE, CK_KEY_TYPE, CK_MECHANISM, + CK_AES_GCM_PARAMS, CK_ATTRIBUTE, CK_BBOOL, CK_DATE, CK_FALSE, CK_KEY_TYPE, CK_MECHANISM, CK_MECHANISM_TYPE, CK_OBJECT_CLASS, CK_OBJECT_HANDLE, CK_RSA_PKCS_MGF_TYPE, CK_RSA_PKCS_OAEP_PARAMS, CK_SESSION_HANDLE, CK_TRUE, CK_ULONG, CKA_CLASS, CKA_COEFFICIENT, - CKA_EXPONENT_1, CKA_EXPONENT_2, CKA_ID, CKA_KEY_TYPE, CKA_LABEL, CKA_MODULUS, CKA_PRIME_1, - CKA_PRIME_2, CKA_PRIVATE_EXPONENT, CKA_PUBLIC_EXPONENT, CKA_SENSITIVE, CKA_VALUE, - CKA_VALUE_LEN, CKG_MGF1_SHA1, CKG_MGF1_SHA256, CKG_MGF1_SHA384, CKG_MGF1_SHA512, CKK_AES, - CKK_RSA, CKK_VENDOR_DEFINED, CKM_AES_CBC, CKM_AES_GCM, CKM_RSA_PKCS, CKM_RSA_PKCS_OAEP, - CKM_SHA_1, CKM_SHA1_RSA_PKCS, CKM_SHA256, CKM_SHA256_RSA_PKCS, CKM_SHA384, CKM_SHA384_RSA_PKCS, - CKM_SHA512, CKM_SHA512_RSA_PKCS, CKO_PRIVATE_KEY, CKO_PUBLIC_KEY, CKO_SECRET_KEY, - CKO_VENDOR_DEFINED, CKR_ATTRIBUTE_SENSITIVE, CKR_OBJECT_HANDLE_INVALID, CKR_OK, + CKA_END_DATE, CKA_EXPONENT_1, CKA_EXPONENT_2, CKA_ID, CKA_KEY_TYPE, CKA_LABEL, CKA_MODULUS, + CKA_PRIME_1, CKA_PRIME_2, CKA_PRIVATE_EXPONENT, CKA_PUBLIC_EXPONENT, CKA_SENSITIVE, + CKA_START_DATE, CKA_VALUE, CKA_VALUE_LEN, CKG_MGF1_SHA1, CKG_MGF1_SHA256, CKG_MGF1_SHA384, + CKG_MGF1_SHA512, CKK_AES, CKK_RSA, CKK_VENDOR_DEFINED, CKM_AES_CBC, CKM_AES_GCM, CKM_RSA_PKCS, + CKM_RSA_PKCS_OAEP, CKM_SHA_1, CKM_SHA1_RSA_PKCS, CKM_SHA256, CKM_SHA256_RSA_PKCS, CKM_SHA384, + CKM_SHA384_RSA_PKCS, CKM_SHA512, CKM_SHA512_RSA_PKCS, CKO_PRIVATE_KEY, CKO_PUBLIC_KEY, + CKO_SECRET_KEY, CKO_VENDOR_DEFINED, CKR_ATTRIBUTE_SENSITIVE, CKR_OBJECT_HANDLE_INVALID, CKR_OK, CKZ_DATA_SPECIFIED, }; use rand::{TryRng, rngs::SysRng}; @@ -1730,6 +1730,238 @@ impl Session { Ok(Some(())) } + /// Parse a `CK_DATE` (8-byte ASCII "YYYYMMDD") into a `time::Date`. + /// Returns `None` if the date is empty/zeroed. + fn parse_ck_date(date: CK_DATE) -> Option { + let year_str = std::str::from_utf8(&date.year).ok()?; + let month_str = std::str::from_utf8(&date.month).ok()?; + let day_str = std::str::from_utf8(&date.day).ok()?; + let year: i32 = year_str.trim().parse().ok()?; + let month: u8 = month_str.trim().parse().ok()?; + let day: u8 = day_str.trim().parse().ok()?; + if year == 0 && month == 0 && day == 0 { + return None; + } + let month = time::Month::try_from(month).ok()?; + time::Date::from_calendar_date(year, month, day).ok() + } + + /// Read `CKA_START_DATE` and `CKA_END_DATE` from a key handle. + /// Returns `(start_date, end_date)`. Attributes that are absent or empty + /// (zeroed) are returned as `None`. + fn get_key_dates( + &self, + key_handle: CK_OBJECT_HANDLE, + ) -> HResult<(Option, Option)> { + let mut start_date = CK_DATE { + year: [0; 4], + month: [0; 2], + day: [0; 2], + }; + let mut end_date = CK_DATE { + year: [0; 4], + month: [0; 2], + day: [0; 2], + }; + let mut template = vec![ + CK_ATTRIBUTE { + type_: CKA_START_DATE, + pValue: (&raw mut start_date).cast::(), + ulValueLen: CK_ULONG::try_from(size_of::())?, + }, + CK_ATTRIBUTE { + type_: CKA_END_DATE, + pValue: (&raw mut end_date).cast::(), + ulValueLen: CK_ULONG::try_from(size_of::())?, + }, + ]; + // If the HSM doesn't support these attributes, just return None for both + if self + .call_get_attributes(key_handle, &mut template)? + .is_none() + { + return Ok((None, None)); + } + // Check if the returned length is 0 (attribute present but empty) + let start = if template.first().is_none_or(|t| t.ulValueLen == 0) { + None + } else { + Self::parse_ck_date(start_date) + }; + let end = if template.get(1).is_none_or(|t| t.ulValueLen == 0) { + None + } else { + Self::parse_ck_date(end_date) + }; + Ok((start, end)) + } + + /// Format a `time::Date` into a `CK_DATE` (8-byte ASCII "YYYYMMDD"). + fn format_ck_date(date: time::Date) -> CK_DATE { + let year = date.year(); + let month: u8 = date.month().into(); + let day = date.day(); + // These format! calls always produce exactly the right number of bytes + let mut year_bytes = [b'0'; 4]; + let mut month_bytes = [b'0'; 2]; + let mut day_bytes = [b'0'; 2]; + let year_str = format!("{year:04}"); + let month_str = format!("{month:02}"); + let day_str = format!("{day:02}"); + year_bytes.copy_from_slice(year_str.as_bytes().get(..4).unwrap_or(&[b'0'; 4])); + month_bytes.copy_from_slice(month_str.as_bytes().get(..2).unwrap_or(&[b'0'; 2])); + day_bytes.copy_from_slice(day_str.as_bytes().get(..2).unwrap_or(&[b'0'; 2])); + CK_DATE { + year: year_bytes, + month: month_bytes, + day: day_bytes, + } + } + + /// Set `CKA_START_DATE` and/or `CKA_END_DATE` on a key object. + /// Passing `None` clears the attribute (sets to empty `CK_DATE`). + pub fn set_key_dates( + &self, + key_handle: CK_OBJECT_HANDLE, + start_date: Option, + end_date: Option, + ) -> HResult<()> { + let start_ck = start_date.map_or( + CK_DATE { + year: [0; 4], + month: [0; 2], + day: [0; 2], + }, + Self::format_ck_date, + ); + let end_ck = end_date.map_or( + CK_DATE { + year: [0; 4], + month: [0; 2], + day: [0; 2], + }, + Self::format_ck_date, + ); + + let mut template = vec![ + CK_ATTRIBUTE { + type_: CKA_START_DATE, + pValue: ptr::addr_of!(start_ck).cast_mut().cast(), + ulValueLen: CK_ULONG::try_from(std::mem::size_of::())?, + }, + CK_ATTRIBUTE { + type_: CKA_END_DATE, + pValue: ptr::addr_of!(end_ck).cast_mut().cast(), + ulValueLen: CK_ULONG::try_from(std::mem::size_of::())?, + }, + ]; + + #[expect(unsafe_code)] + let rv = match self.hsm.C_SetAttributeValue { + Some(func) => unsafe { + func( + self.handle, + key_handle, + template.as_mut_ptr(), + CK_ULONG::try_from(template.len())?, + ) + }, + None => { + return Err(HError::Default( + "C_SetAttributeValue not available on library".to_owned(), + )); + } + }; + if rv != CKR_OK { + return Err(HError::Default(format!( + "Failed to set key dates for key handle: {key_handle}. Return code: {rv}" + ))); + } + Ok(()) + } + + /// Parse keyset metadata from a `CKA_LABEL` value. + /// + /// Format: `rotate_name::generation::key_id[::latest]` + /// + /// Returns `(rotate_name, rotate_generation, rotate_latest)`. + /// Returns `(None, None, None)` if the label does not match the format + /// (e.g. plain keys whose label is just an identifier). + pub(crate) fn parse_label_metadata(label: &str) -> (Option, Option, Option) { + // Minimum viable format: "name::gen::keyid" (3 segments) + let parts: Vec<&str> = label.splitn(4, "::").collect(); + if parts.len() < 3 { + return (None, None, None); + } + let Some(rotate_name) = parts.first() else { + return (None, None, None); + }; + let Some(gen_str) = parts.get(1) else { + return (None, None, None); + }; + let Ok(generation) = gen_str.parse::() else { + return (None, None, None); + }; + // 4th segment (if present) must be "latest"; otherwise no latest flag + let latest = parts.get(3).is_some_and(|s| *s == "latest"); + ( + Some((*rotate_name).to_owned()), + Some(generation), + Some(latest), + ) + } + + /// Build the `CKA_LABEL` value for a keyset key. + /// + /// Format: `rotate_name::generation::key_id` (retired) or + /// `rotate_name::generation::key_id::latest` (current latest). + // Used by the HSM ReKey flow (Phase 3). + #[allow(dead_code)] + pub(crate) fn build_keyset_label( + rotate_name: &str, + generation: i32, + key_id: &str, + latest: bool, + ) -> String { + if latest { + format!("{rotate_name}::{generation}::{key_id}::latest") + } else { + format!("{rotate_name}::{generation}::{key_id}") + } + } + + /// Set `CKA_LABEL` on a key object via `C_SetAttributeValue`. + pub fn set_label(&self, key_handle: CK_OBJECT_HANDLE, label: &str) -> HResult<()> { + let label_bytes = label.as_bytes(); + let mut template = vec![CK_ATTRIBUTE { + type_: CKA_LABEL, + pValue: label_bytes.as_ptr().cast_mut().cast(), + ulValueLen: CK_ULONG::try_from(label_bytes.len())?, + }]; + #[expect(unsafe_code)] + let rv = match self.hsm.C_SetAttributeValue { + Some(func) => unsafe { + func( + self.handle, + key_handle, + template.as_mut_ptr(), + CK_ULONG::try_from(template.len())?, + ) + }, + None => { + return Err(HError::Default( + "C_SetAttributeValue not available on library".to_owned(), + )); + } + }; + if rv != CKR_OK { + return Err(HError::Default(format!( + "Failed to set label for key handle: {key_handle}. Return code: {rv}" + ))); + } + Ok(()) + } + /// Get the metadata for a key pub fn get_key_metadata(&self, key_handle: CK_OBJECT_HANDLE) -> HResult> { let Some(key_type) = self.get_key_type(key_handle)? else { @@ -1786,6 +2018,9 @@ impl Session { HError::Default(format!("Failed to convert label to string: {e}")) })? }; + let (start_date, end_date) = self.get_key_dates(key_handle).unwrap_or((None, None)); + let (rotate_name, rotate_generation, rotate_latest) = + Self::parse_label_metadata(&label); Ok(Some(KeyMetadata { key_type, key_length_in_bits: usize::try_from(key_size).map_err(|e| { @@ -1793,6 +2028,11 @@ impl Session { })? * 8, sensitive: sensitive == CK_TRUE, id: label, + start_date, + end_date, + rotate_name, + rotate_generation, + rotate_latest, })) } KeyType::RsaPrivateKey | KeyType::RsaPublicKey => { @@ -1856,11 +2096,19 @@ impl Session { label = label.trim().to_owned().add("_pk"); } let sensitive = sensitive == CK_TRUE; + let (start_date, end_date) = self.get_key_dates(key_handle).unwrap_or((None, None)); + let (rotate_name, rotate_generation, rotate_latest) = + Self::parse_label_metadata(&label); Ok(Some(KeyMetadata { key_type, key_length_in_bits, sensitive, id: label, + start_date, + end_date, + rotate_name, + rotate_generation, + rotate_latest, })) } } diff --git a/crate/interfaces/src/crypto_oracle.rs b/crate/interfaces/src/crypto_oracle.rs index d63cdadf92..b061a9368e 100644 --- a/crate/interfaces/src/crypto_oracle.rs +++ b/crate/interfaces/src/crypto_oracle.rs @@ -15,12 +15,23 @@ use zeroize::Zeroizing; use crate::{InterfaceError, KeyType, error::InterfaceResult}; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct KeyMetadata { pub key_type: KeyType, pub key_length_in_bits: usize, pub sensitive: bool, pub id: String, + /// PKCS#11 `CKA_START_DATE` — when the key became active. + pub start_date: Option, + /// PKCS#11 `CKA_END_DATE` — when the key is due for rotation. + pub end_date: Option, + /// Keyset name parsed from `CKA_LABEL` (`rotate_name::generation::key_id[::latest]`). + /// `None` means the key has no keyset membership. + pub rotate_name: Option, + /// Keyset generation counter parsed from `CKA_LABEL`. + pub rotate_generation: Option, + /// `true` if the key carries the `::latest` suffix in `CKA_LABEL`. + pub rotate_latest: Option, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crate/interfaces/src/hsm/hsm_store.rs b/crate/interfaces/src/hsm/hsm_store.rs index e03ffced4a..0fa4787273 100644 --- a/crate/interfaces/src/hsm/hsm_store.rs +++ b/crate/interfaces/src/hsm/hsm_store.rs @@ -141,8 +141,16 @@ impl ObjectsStore for HsmStore { let (slot_id, key_id) = parse_uid_with_prefix(uid, &self.prefix)?; match self.hsm.export(slot_id, key_id.as_bytes()).await { Ok(Some(hsm_object)) => { - let owm = + let mut owm = to_object_with_metadata(&hsm_object, uid, self.owner_name(), &self.vendor_id)?; + // Enrich attributes with keyset metadata from CKA_LABEL. + if let Ok(Some(meta)) = self.hsm.get_key_metadata(slot_id, key_id.as_bytes()).await + { + let attrs = owm.attributes_mut(); + attrs.rotate_name = meta.rotate_name; + attrs.rotate_generation = meta.rotate_generation; + attrs.rotate_latest = meta.rotate_latest; + } Ok(Some(owm)) } Ok(None) => Ok(None), @@ -377,6 +385,126 @@ impl ObjectsStore for HsmStore { Ok(uids) } + + async fn find_due_for_rotation( + &self, + now: time::OffsetDateTime, + ) -> InterfaceResult> { + let today = now.date(); + let slot_ids = self.hsm.get_available_slot_list().await?; + let mut due_uids = Vec::new(); + + for slot_id in slot_ids { + let found = self + .hsm + .find(slot_id, HsmObjectFilter::Any) + .await + .unwrap_or_default(); + for object_id in found { + let Some(meta) = self + .hsm + .get_key_metadata(slot_id, &object_id) + .await + .unwrap_or_default() + else { + continue; + }; + // A key is due for rotation when end_date is set and today >= end_date + let Some(end_date) = meta.end_date else { + continue; + }; + if today >= end_date { + let Ok(object_string) = std::str::from_utf8(&object_id) else { + continue; + }; + let uid = format!("{}::{slot_id}::{object_string}", self.prefix); + due_uids.push(uid); + } + } + } + + Ok(due_uids) + } + + /// Find HSM keys by keyset name, with optional generation and latest filters. + /// + /// The keyset name is parsed from `CKA_LABEL` which carries the format + /// `rotate_name::generation::key_id[::latest]`. This allows keys to be + /// addressed by their logical name rather than their physical UID. + async fn find_by_rotate_name( + &self, + name: &str, + generation: Option, + latest: Option, + _owner: &str, + ) -> InterfaceResult> { + let slot_ids = self.hsm.get_available_slot_list().await?; + let mut results = Vec::new(); + + for slot_id in slot_ids { + let found = self + .hsm + .find(slot_id, HsmObjectFilter::Any) + .await + .unwrap_or_default(); + for object_id in found { + let Some(meta) = self + .hsm + .get_key_metadata(slot_id, &object_id) + .await + .unwrap_or_default() + else { + continue; + }; + // Only consider keys that belong to this keyset + let Some(ref key_rotate_name) = meta.rotate_name else { + continue; + }; + if key_rotate_name != name { + continue; + } + // Optional generation filter + if let Some(gen_filter) = generation { + if meta.rotate_generation != Some(gen_filter) { + continue; + } + } + // Optional latest filter + if let Some(latest_filter) = latest { + if meta.rotate_latest != Some(latest_filter) { + continue; + } + } + let Ok(object_string) = std::str::from_utf8(&object_id) else { + continue; + }; + let uid = format!("{}::{slot_id}::{object_string}", self.prefix); + let attrs = build_keyset_attributes(&meta); + results.push((uid, attrs)); + } + } + + Ok(results) + } + + async fn set_key_label(&self, uid: &str, label: &str) -> InterfaceResult<()> { + let (slot_id, key_id) = parse_uid_with_prefix(uid, &self.prefix)?; + self.hsm + .set_key_label(slot_id, key_id.as_bytes(), label) + .await + } + + async fn set_key_rotation_dates( + &self, + uid: &str, + start_date: Option, + end_date: Option, + ) -> InterfaceResult<()> { + let (slot_id, key_id) = parse_uid_with_prefix(uid, &self.prefix)?; + self.hsm + .set_key_dates(slot_id, key_id.as_bytes(), start_date, end_date) + .await + } } // ────────────────────────────────────────────────────────────────────────────── @@ -611,6 +739,9 @@ fn build_sensitive_stub_attributes(meta: &KeyMetadata) -> Attributes { cryptographic_usage_mask: Some(usage_mask), key_format_type: Some(key_format_type), sensitive: Some(true), + rotate_name: meta.rotate_name.clone(), + rotate_generation: meta.rotate_generation, + rotate_latest: meta.rotate_latest, ..Attributes::default() } } @@ -684,6 +815,16 @@ fn build_sensitive_stub_object(meta: &KeyMetadata) -> Object { } } +/// Build an `Attributes` struct populated with keyset metadata from `KeyMetadata`. +/// Used by `find_by_rotate_name` to return `rotate_name`/`generation`/`latest` to callers. +fn build_keyset_attributes(meta: &KeyMetadata) -> Attributes { + let mut attrs = build_find_attributes(&Some(meta.clone()), &HsmObjectFilter::Any); + attrs.rotate_name.clone_from(&meta.rotate_name); + attrs.rotate_generation = meta.rotate_generation; + attrs.rotate_latest = meta.rotate_latest; + attrs +} + fn build_find_attributes(meta: &Option, filter: &HsmObjectFilter) -> Attributes { let mut attrs = Attributes::default(); if let Some(m) = meta { diff --git a/crate/interfaces/src/hsm/interface.rs b/crate/interfaces/src/hsm/interface.rs index 6dedcf5a43..509fe1ddfc 100644 --- a/crate/interfaces/src/hsm/interface.rs +++ b/crate/interfaces/src/hsm/interface.rs @@ -336,6 +336,43 @@ pub trait HSM: Send + Sync { /// can return an error; callers may choose to ignore such errors. async fn seed_random(&self, slot_id: usize, seed: &[u8]) -> InterfaceResult<()>; + /// Set `CKA_START_DATE` and `CKA_END_DATE` on a key object. + /// + /// These PKCS#11 attributes are used to track rotation scheduling: + /// - `start_date` — when the current rotation interval began. + /// - `end_date` — when the key is due for rotation. + /// + /// Passing `None` for either date clears that attribute (sets to empty `CK_DATE`). + /// + /// # Arguments + /// * `slot_id` - the slot ID of the HSM + /// * `key_id` - the ID of the key + /// * `start_date` - optional start date + /// * `end_date` - optional end date + async fn set_key_dates( + &self, + slot_id: usize, + key_id: &[u8], + start_date: Option, + end_date: Option, + ) -> InterfaceResult<()>; + + /// Set `CKA_LABEL` on a key object. + /// + /// The label encodes keyset metadata in the format + /// `rotate_name::generation::key_id[::latest]`. + /// + /// # Arguments + /// * `slot_id` - the slot ID of the HSM + /// * `key_id` - the `CKA_ID` bytes of the key to update + /// * `label` - the new label string to set + async fn set_key_label( + &self, + slot_id: usize, + key_id: &[u8], + label: &str, + ) -> InterfaceResult<()>; + /// Get a reference to the underlying PKCS#11 library for direct function calls. /// /// This method provides access to the raw PKCS#11 library (`HsmLib`) to enable diff --git a/crate/interfaces/src/lib.rs b/crate/interfaces/src/lib.rs index 5e75e0c5c7..1596dff0df 100644 --- a/crate/interfaces/src/lib.rs +++ b/crate/interfaces/src/lib.rs @@ -15,7 +15,7 @@ pub use stores::{AtomicOperation, ObjectWithMetadata, ObjectsStore, PermissionsS /// Supported cryptographic object types /// in plugins -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq)] pub enum KeyType { AesKey, RsaPrivateKey, diff --git a/crate/interfaces/src/stores/objects_store.rs b/crate/interfaces/src/stores/objects_store.rs index 890d156389..1d693b431c 100644 --- a/crate/interfaces/src/stores/objects_store.rs +++ b/crate/interfaces/src/stores/objects_store.rs @@ -130,4 +130,45 @@ pub trait ObjectsStore { async fn find_due_for_rotation(&self, _now: OffsetDateTime) -> InterfaceResult> { Ok(vec![]) } + + /// Find objects by their `x-rotate-name` vendor attribute. + /// + /// Optionally filter by: + /// - `generation`: match `x-rotate-generation` exactly + /// - `latest`: match `x-rotate-latest` flag + /// - `owner`: match the object owner + /// + /// Returns a list of `(uid, attributes)` pairs. + /// The default implementation returns an empty list; backends should override. + async fn find_by_rotate_name( + &self, + _name: &str, + _generation: Option, + _latest: Option, + _owner: &str, + ) -> InterfaceResult> { + Ok(vec![]) + } + + /// Set the human-readable label on a key object. + /// + /// For HSM backends this writes `CKA_LABEL` via `C_SetAttributeValue`. + /// The SQL backends ignore this call (labels are carried in the KMIP `Name` attribute + /// and managed separately). Default: no-op. + async fn set_key_label(&self, _uid: &str, _label: &str) -> InterfaceResult<()> { + Ok(()) + } + + /// Rewrite the PKCS#11 rotation dates on an HSM key identified by `uid`. + /// + /// `start_date` and `end_date` are stored as `CKA_START_DATE` / `CKA_END_DATE`. + /// SQL backends ignore this call. Default: no-op. + async fn set_key_rotation_dates( + &self, + _uid: &str, + _start_date: Option, + _end_date: Option, + ) -> InterfaceResult<()> { + Ok(()) + } } diff --git a/crate/server/src/config/command_line/clap_config.rs b/crate/server/src/config/command_line/clap_config.rs index 1ec3e27bc1..59345eff5e 100644 --- a/crate/server/src/config/command_line/clap_config.rs +++ b/crate/server/src/config/command_line/clap_config.rs @@ -71,6 +71,7 @@ impl Default for ClapConfig { kmip_policy: KmipPolicyConfig::default(), azure_ekm_config: AzureEkmConfig::default(), auto_rotation_check_interval_secs: 0, + keyset_decrypt_max_attempts: 100, } } } @@ -219,6 +220,12 @@ pub struct ClapConfig { /// Set to 0 (default) to disable the auto-rotation background task. #[clap(long, default_value = "0", verbatim_doc_comment)] pub auto_rotation_check_interval_secs: u64, + + /// Maximum number of keys the server will try when decrypting with a keyset + /// identifier (walking the rotation chain from newest to oldest). + /// Prevents unbounded chain traversal. + #[clap(long, default_value = "100", verbatim_doc_comment)] + pub keyset_decrypt_max_attempts: u32, } impl ClapConfig { @@ -661,6 +668,10 @@ impl fmt::Debug for ClapConfig { "auto_rotation_check_interval_secs", &self.auto_rotation_check_interval_secs, ); + let x = x.field( + "keyset_decrypt_max_attempts", + &self.keyset_decrypt_max_attempts, + ); x.finish() } diff --git a/crate/server/src/config/params/server_params.rs b/crate/server/src/config/params/server_params.rs index 4a28999b6c..2ba5362d85 100644 --- a/crate/server/src/config/params/server_params.rs +++ b/crate/server/src/config/params/server_params.rs @@ -168,6 +168,10 @@ pub struct ServerParams { /// Interval in seconds between background auto-rotation checks. /// 0 means disabled. pub auto_rotation_check_interval_secs: u64, + + /// Maximum number of keys the server will try when decrypting with a keyset + /// identifier (walking the rotation chain from newest to oldest). + pub keyset_decrypt_max_attempts: u32, } /// Represents the server parameters. @@ -427,6 +431,7 @@ impl ServerParams { }), max_locate_items: 1000, auto_rotation_check_interval_secs: conf.auto_rotation_check_interval_secs, + keyset_decrypt_max_attempts: conf.keyset_decrypt_max_attempts, }; debug!("{res:#?}"); @@ -654,6 +659,10 @@ impl fmt::Debug for ServerParams { "auto_rotation_check_interval_secs", &self.auto_rotation_check_interval_secs, ); + debug_struct.field( + "keyset_decrypt_max_attempts", + &self.keyset_decrypt_max_attempts, + ); debug_struct.finish() } diff --git a/crate/server/src/core/operations/attributes/add.rs b/crate/server/src/core/operations/attributes/add.rs index 71b0017a85..f16e2d86c7 100644 --- a/crate/server/src/core/operations/attributes/add.rs +++ b/crate/server/src/core/operations/attributes/add.rs @@ -42,6 +42,12 @@ pub(crate) async fn add_attribute( .to_owned(), )); } + Attribute::RotateName(name) if name.contains('@') => { + return Err(KmsError::InvalidRequest( + "AddAttribute: rotate_name must not contain '@' (reserved for keyset versioning)" + .to_owned(), + )); + } _ => {} } diff --git a/crate/server/src/core/operations/attributes/get.rs b/crate/server/src/core/operations/attributes/get.rs index 3003ddc29f..6c2d77a898 100644 --- a/crate/server/src/core/operations/attributes/get.rs +++ b/crate/server/src/core/operations/attributes/get.rs @@ -558,6 +558,17 @@ pub(crate) async fn get_attributes( owm.id(), res.get_tags(kms.vendor_id()) ); + + // Rotation attributes are not represented by Tag enum variants, so they are + // not included by the tag-based filtering loop above. Always propagate them + // from the source attributes when present. + res.rotate_date = attributes.rotate_date; + res.rotate_generation = attributes.rotate_generation; + res.rotate_interval = attributes.rotate_interval; + res.rotate_latest = attributes.rotate_latest; + res.rotate_name.clone_from(&attributes.rotate_name); + res.rotate_offset = attributes.rotate_offset; + trace!("Get Attributes: Response: {}", res); Ok(GetAttributesResponse { unique_identifier: UniqueIdentifier::TextString(owm.id().to_owned()), diff --git a/crate/server/src/core/operations/attributes/modify.rs b/crate/server/src/core/operations/attributes/modify.rs index ebb85a4649..7581166de8 100644 --- a/crate/server/src/core/operations/attributes/modify.rs +++ b/crate/server/src/core/operations/attributes/modify.rs @@ -59,6 +59,12 @@ pub(crate) async fn modify_attribute( .to_owned(), )); } + Attribute::RotateName(name) if name.contains('@') => { + return Err(KmsError::InvalidRequest( + "ModifyAttribute: rotate_name must not contain '@' (reserved for keyset versioning)" + .to_owned(), + )); + } _ => {} } diff --git a/crate/server/src/core/operations/attributes/set.rs b/crate/server/src/core/operations/attributes/set.rs index 8e2045acbe..3129a93a3c 100644 --- a/crate/server/src/core/operations/attributes/set.rs +++ b/crate/server/src/core/operations/attributes/set.rs @@ -12,13 +12,26 @@ use cosmian_kms_server_database::reexport::{ cosmian_kms_interfaces::ObjectWithMetadata, }; use cosmian_logger::{debug, trace}; +use time::OffsetDateTime; use crate::{ - core::{KMS, retrieve_object_utils::retrieve_object_for_operation}, + core::{KMS, retrieve_object_utils::retrieve_object_for_operation, uid_utils::has_prefix}, error::KmsError, result::{KResult, KResultHelper}, }; +/// Extract the PKCS#11 `key_id` from an HSM UID of the form +/// `hsm::::::`. +/// +/// Returns `None` if the UID cannot be parsed. +fn extract_hsm_key_id(uid: &str) -> Option<&str> { + let prefix = has_prefix(uid)?; + // Strip "hsm::::" to get "::" + let rest = uid.strip_prefix(&format!("{prefix}::"))?; + // Skip the slot_id segment + rest.split_once("::").map(|(_, key_id)| key_id) +} + pub(crate) async fn set_attribute( kms: &KMS, request: SetAttribute, @@ -42,6 +55,12 @@ pub(crate) async fn set_attribute( "DENIED: this attribute is server-managed and cannot be set by the user".to_owned(), )); } + Attribute::RotateName(name) if name.contains('@') => { + return Err(KmsError::InvalidRequest( + "SetAttribute: rotate_name must not contain '@' (reserved for keyset versioning)" + .to_owned(), + )); + } _ => {} } @@ -54,6 +73,24 @@ pub(crate) async fn set_attribute( .await?; trace!("Set Attribute: Retrieved target object"); + // Capture HSM-rotation values before the `match_set_attribute!` macro may + // partially move `request.new_attribute`. We do this here — after object + // retrieval — so we can inspect `owm.id()` to confirm it is an HSM key. + let (hsm_rotate_name, hsm_rotate_interval_secs) = if has_prefix(owm.id()).is_some() { + match &request.new_attribute { + Attribute::RotateOffset(_) => { + return Err(KmsError::NotSupported( + "SetAttribute: rotate_offset is not supported for HSM keys".to_owned(), + )); + } + Attribute::RotateName(n) => (Some(n.clone()), None::), + Attribute::RotateInterval(n) => (None::, Some(*n)), + _ => (None, None), + } + } else { + (None, None) + }; + let mut attributes = owm.attributes_mut().clone(); // Check if the attribute is allowed to be set @@ -154,6 +191,40 @@ pub(crate) async fn set_attribute( let tags = kms.database.retrieve_tags(owm.id()).await?; + // HSM-specific: propagate rotation attributes directly to PKCS#11 storage. + // `HsmStore::update_object` is a no-op for attributes (the HSM has no + // generic KV attribute storage), so we must explicitly write CKA_LABEL and + // CKA_START_DATE / CKA_END_DATE when the caller sets rotation metadata. + if let Some(rotate_name) = hsm_rotate_name { + // Register key in a keyset by writing CKA_LABEL at generation 0. + let key_id = extract_hsm_key_id(owm.id()).ok_or_else(|| { + KmsError::InvalidRequest(format!( + "SetAttribute: cannot parse key_id from HSM UID '{}'", + owm.id() + )) + })?; + let label = format!("{rotate_name}::0::{key_id}::latest"); + trace!( + "SetAttribute: writing CKA_LABEL '{}' on HSM key '{}'", + label, + owm.id() + ); + kms.database.set_key_label(owm.id(), &label).await?; + } else if let Some(interval_secs) = hsm_rotate_interval_secs { + let today = OffsetDateTime::now_utc().date(); + let days = interval_secs / 86400; + let end_date = today + time::Duration::days(days); + trace!( + "SetAttribute: writing CKA_START_DATE={} CKA_END_DATE={} on HSM key '{}'", + today, + end_date, + owm.id() + ); + kms.database + .set_key_rotation_dates(owm.id(), Some(today), Some(end_date)) + .await?; + } + match owm.object().object_type() { ObjectType::PublicKey | ObjectType::PrivateKey diff --git a/crate/server/src/core/operations/decrypt.rs b/crate/server/src/core/operations/decrypt.rs index d9f8a20d73..04fd4cb0d4 100644 --- a/crate/server/src/core/operations/decrypt.rs +++ b/crate/server/src/core/operations/decrypt.rs @@ -44,7 +44,7 @@ use crate::{ config::ServerParams, core::{ KMS, - operations::{CryptoOpSpec, has_usage_mask, perform_crypto_operation}, + operations::{CryptoOpSpec, KeysetMode, has_usage_mask, perform_crypto_operation}, }, error::KmsError, kms_bail, @@ -67,6 +67,10 @@ impl CryptoOpSpec for DecryptOp { request.unique_identifier.as_ref() } + fn keyset_mode() -> KeysetMode { + KeysetMode::TryEach + } + fn usage_data_len(request: &Self::Request) -> usize { request.data.as_ref().map_or(0, Vec::len) } diff --git a/crate/server/src/core/operations/key_ops/crypto_op.rs b/crate/server/src/core/operations/key_ops/crypto_op.rs index d3a85270b3..4bf4a012c3 100644 --- a/crate/server/src/core/operations/key_ops/crypto_op.rs +++ b/crate/server/src/core/operations/key_ops/crypto_op.rs @@ -10,17 +10,32 @@ use cosmian_kms_server_database::reexport::{ }, cosmian_kms_interfaces::ObjectWithMetadata, }; +use cosmian_logger::trace; use super::{DatabaseOps, ObjectWithMetadataOps}; use crate::{ core::{ KMS, - uid_utils::{has_prefix, uids_from_unique_identifier}, + uid_utils::{ + KeysetVersion, has_prefix, parse_keyset_identifier, resolve_keyset_to_single_uid, + uids_from_unique_identifier, walk_keyset_chain, + }, }, error::KmsError, result::{KResult, KResultHelper}, }; +// ─── Keyset mode ───────────────────────────────────────────────────────────── + +/// Determines how a keyset reference (bare name without `@version`) is handled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KeysetMode { + /// Use only the latest key in the keyset (for encrypt, sign, MAC). + SingleLatest, + /// Try each key in the chain from newest to oldest (for decrypt, verify). + TryEach, +} + // ─── Generic crypto operation key resolution ───────────────────────────────── /// Result of key resolution for a cryptographic operation. @@ -31,6 +46,9 @@ pub(crate) enum ResolvedKey { /// Key is in the local database: selected, Active, lifecycle-validated. /// NOT yet unwrapped — the caller handles unwrapping based on operation needs. Local(Box), + /// A keyset chain: ordered list of UIDs from newest to oldest. + /// The caller tries each key in order until one succeeds. + Keyset(Vec), } /// Check whether a managed object's usage mask permits the given operation. @@ -91,6 +109,14 @@ pub(crate) trait CryptoOpSpec { /// Extract the `UniqueIdentifier` from the typed request. fn unique_identifier(request: &Self::Request) -> Option<&UniqueIdentifier>; + /// How this operation handles a bare keyset name (no `@version` suffix). + /// + /// - `SingleLatest`: resolve to the latest key only (encrypt, sign, MAC). + /// - `TryEach`: walk the chain and try each key newest→oldest (decrypt, verify). + fn keyset_mode() -> KeysetMode { + KeysetMode::SingleLatest + } + /// Compute the data length for `UsageLimits` enforcement. /// /// The meaning varies per operation: @@ -165,32 +191,109 @@ pub(crate) async fn perform_crypto_operation( ResolvedKey::Oracle { uid, prefix } => { Op::execute_oracle(kms, &request, &uid, &prefix).await } - ResolvedKey::Local(owm) => { - let mut owm = *owm; - - // Clone before unwrap: preserve the wrapped key for DB persistence. - let mut unwrapped_owm = owm.clone(); - unwrap_and_enforce_policy(kms, &mut unwrapped_owm, Op::OP_NAME, user) - .await - .with_context(|| { - format!( - "{}: the key: {}, cannot be unwrapped.", - Op::OP_NAME, - owm.id() - ) - })?; - - let data_len = Op::usage_data_len(&request); - enforce_usage_limits(&owm, data_len)?; - - let res = Op::execute_local(kms, &unwrapped_owm, &request, user).await?; - - decrement_usage_limits(kms, &mut owm, Op::OP_NAME, data_len).await?; - Ok(res) + ResolvedKey::Local(owm) => execute_local_with_limits::(kms, *owm, &request, user).await, + ResolvedKey::Keyset(chain) => { + execute_keyset_try_each::(kms, &chain, &request, user).await } } } +/// Execute a local operation with unwrapping and usage-limit accounting. +async fn execute_local_with_limits( + kms: &KMS, + owm: ObjectWithMetadata, + request: &Op::Request, + user: &str, +) -> KResult { + let mut owm = owm; + + // Clone before unwrap: preserve the wrapped key for DB persistence. + let mut unwrapped_owm = owm.clone(); + unwrap_and_enforce_policy(kms, &mut unwrapped_owm, Op::OP_NAME, user) + .await + .with_context(|| { + format!( + "{}: the key: {}, cannot be unwrapped.", + Op::OP_NAME, + owm.id() + ) + })?; + + let data_len = Op::usage_data_len(request); + enforce_usage_limits(&owm, data_len)?; + + let res = Op::execute_local(kms, &unwrapped_owm, request, user).await?; + + decrement_usage_limits(kms, &mut owm, Op::OP_NAME, data_len).await?; + Ok(res) +} + +/// Try each key in a keyset chain (newest→oldest) until one succeeds. +/// +/// Used for decrypt/verify operations where the ciphertext may have been +/// encrypted with an older generation key. +async fn execute_keyset_try_each( + kms: &KMS, + chain: &[String], + request: &Op::Request, + user: &str, +) -> KResult { + let mut last_err: Option = None; + + for uid in chain { + let Some(owm) = kms.database.retrieve_object(uid).await? else { + continue; + }; + + // Must be Active + if owm.get_effective_state()? != State::Active { + continue; + } + + // Permission check + if !kms + .database + .is_user_authorized_for_operation(uid, user, Op::KMIP_OP) + .await? + { + continue; + } + + // Eligibility check + if !Op::is_key_eligible(&owm, kms.vendor_id()) { + continue; + } + + // Lifecycle check + if owm.check_process_window().is_err() { + continue; + } + + match execute_local_with_limits::(kms, owm, request, user).await { + Ok(response) => return Ok(response), + Err(e) => { + trace!( + "execute_keyset_try_each: key {} failed for {}: {}", + uid, + Op::OP_NAME, + e + ); + last_err = Some(e); + } + } + } + + Err(last_err.unwrap_or_else(|| { + KmsError::Kmip21Error( + ErrorReason::Item_Not_Found, + format!( + "{}: decryption failed — no key in the keyset could process the request", + Op::OP_NAME + ), + ) + })) +} + /// Collect the single eligible crypto-oracle UID for a cryptographic operation. /// /// Iterates over `candidate_uids`, retains those that carry a recognized prefix (oracle @@ -327,19 +430,76 @@ where /// Resolve the key for a cryptographic operation using the [`CryptoOpSpec`] trait. /// /// Performs the entire key selection pipeline generically: -/// 1. Resolves UIDs from the `unique_identifier`. -/// 2. Attempts oracle (HSM) routing. -/// 3. Selects the key from the database with `Op::is_key_eligible`. -/// 4. Applies error mapping via `Op::map_selection_error`. -/// 5. Enforces process window constraints (`ProcessStartDate` / `ProtectStopDate`). +/// 1. Checks if the identifier is a keyset reference (name or name@version). +/// 2. Resolves UIDs from the `unique_identifier`. +/// 3. Attempts oracle (HSM) routing. +/// 4. Selects the key from the database with `Op::is_key_eligible`. +/// 5. Applies error mapping via `Op::map_selection_error`. +/// 6. Enforces process window constraints (`ProcessStartDate` / `ProtectStopDate`). /// -/// Returns [`ResolvedKey::Oracle`] for HSM keys or [`ResolvedKey::Local`] for DB keys. +/// Returns [`ResolvedKey::Oracle`] for HSM keys, [`ResolvedKey::Local`] for DB keys, +/// or [`ResolvedKey::Keyset`] for try-each keyset chains. /// Local keys are NOT unwrapped — `perform_crypto_operation` handles unwrapping. pub(crate) async fn resolve_key_for_operation( unique_identifier: &UniqueIdentifier, kms: &KMS, user: &str, ) -> KResult { + let uid_str = unique_identifier + .as_str() + .context("The unique identifier must be a string")?; + + // ── Keyset detection ───────────────────────────────────────────────────── + if let Some(keyset_ref) = parse_keyset_identifier(uid_str) { + // Explicit @version → resolve to a single key + match &keyset_ref.version { + KeysetVersion::Latest | KeysetVersion::First | KeysetVersion::Generation(_) => { + if let Some(uid) = resolve_keyset_to_single_uid(&keyset_ref, kms, user).await? { + let owm = kms.database.retrieve_object(&uid).await?.ok_or_else(|| { + KmsError::ItemNotFound(format!( + "{}: keyset key not found: {uid}", + Op::OP_NAME + )) + })?; + owm.check_process_window()?; + return Ok(ResolvedKey::Local(Box::new(owm))); + } + // Keyset name not found in DB — fall through to normal UID resolution + } + KeysetVersion::Bare => { + // Bare keyset name: behavior depends on operation's keyset_mode + match Op::keyset_mode() { + KeysetMode::SingleLatest => { + if let Some(uid) = + resolve_keyset_to_single_uid(&keyset_ref, kms, user).await? + { + let owm = + kms.database.retrieve_object(&uid).await?.ok_or_else(|| { + KmsError::ItemNotFound(format!( + "{}: keyset key not found: {uid}", + Op::OP_NAME + )) + })?; + owm.check_process_window()?; + return Ok(ResolvedKey::Local(Box::new(owm))); + } + // Not a keyset → fall through to normal path + } + KeysetMode::TryEach => { + let max_depth = kms.params.keyset_decrypt_max_attempts; + let chain = + walk_keyset_chain(&keyset_ref.name, kms, user, max_depth).await?; + if !chain.is_empty() { + return Ok(ResolvedKey::Keyset(chain)); + } + // Not a keyset → fall through to normal path + } + } + } + } + } + + // ── Standard UID / tag resolution ──────────────────────────────────────── let uids = uids_from_unique_identifier(unique_identifier, kms) .await .context(Op::OP_NAME)?; diff --git a/crate/server/src/core/operations/key_ops/mod.rs b/crate/server/src/core/operations/key_ops/mod.rs index 2651de0459..8d62e9e075 100644 --- a/crate/server/src/core/operations/key_ops/mod.rs +++ b/crate/server/src/core/operations/key_ops/mod.rs @@ -15,7 +15,7 @@ use cosmian_kms_server_database::{ cosmian_kms_interfaces::ObjectWithMetadata, }, }; -pub(crate) use crypto_op::{CryptoOpSpec, has_usage_mask, perform_crypto_operation}; +pub(crate) use crypto_op::{CryptoOpSpec, KeysetMode, has_usage_mask, perform_crypto_operation}; use time::OffsetDateTime; use super::digest::digest; diff --git a/crate/server/src/core/operations/mac.rs b/crate/server/src/core/operations/mac.rs index 5a3dbea23d..eb0afe1ce8 100644 --- a/crate/server/src/core/operations/mac.rs +++ b/crate/server/src/core/operations/mac.rs @@ -18,7 +18,7 @@ use openssl::{md::Md, md_ctx::MdCtx, pkey::PKey}; use crate::{ core::{ KMS, - operations::{CryptoOpSpec, perform_crypto_operation}, + operations::{CryptoOpSpec, KeysetMode, perform_crypto_operation}, }, error::KmsError, kms_bail, @@ -123,6 +123,10 @@ impl CryptoOpSpec for MacVerifyOp { Some(&request.unique_identifier) } + fn keyset_mode() -> KeysetMode { + KeysetMode::TryEach + } + fn usage_data_len(request: &Self::Request) -> usize { request.data.len() } diff --git a/crate/server/src/core/operations/mod.rs b/crate/server/src/core/operations/mod.rs index 520ed2c375..a717f9e7ab 100644 --- a/crate/server/src/core/operations/mod.rs +++ b/crate/server/src/core/operations/mod.rs @@ -62,7 +62,7 @@ pub(crate) use pkcs11::pkcs11; 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 key_ops::{CryptoOpSpec, KeysetMode, has_usage_mask, perform_crypto_operation}; pub(crate) use recertify::recertify; pub(crate) use rekey::{rekey, rekey_keypair}; #[cfg(feature = "non-fips")] diff --git a/crate/server/src/core/operations/rekey/common.rs b/crate/server/src/core/operations/rekey/common.rs index 66cec47dd7..c632f910cb 100644 --- a/crate/server/src/core/operations/rekey/common.rs +++ b/crate/server/src/core/operations/rekey/common.rs @@ -540,7 +540,7 @@ pub(crate) fn update_old_key_after_rekey(old_attrs: &mut Attributes, new_uid: &s /// - `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_name` = inherited from old key (required for keyset resolution) /// - `rotate_offset` = None (cleared for manual rekey) pub(crate) fn set_rotation_metadata_on_new_key( new_attrs: &mut Attributes, @@ -551,7 +551,8 @@ pub(crate) fn set_rotation_metadata_on_new_key( // 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; + // Inherit rotate_name so keyset resolution (name@latest, bare name) can find the new key + new_attrs.rotate_name.clone_from(&old_attrs.rotate_name); new_attrs.rotate_offset = None; Ok(()) } diff --git a/crate/server/src/core/operations/rekey/keypair.rs b/crate/server/src/core/operations/rekey/keypair.rs index c214774244..23df67b3bc 100644 --- a/crate/server/src/core/operations/rekey/keypair.rs +++ b/crate/server/src/core/operations/rekey/keypair.rs @@ -188,6 +188,18 @@ impl RekeyOperation for KeypairRekey { continue; } + // Reject Re-Key on a retired (non-latest) member of a named keyset. + // Keys without a rotate_name are not keyset members and may be freely re-keyed. + if owm.attributes().rotate_name.is_some() + && owm.attributes().rotate_latest == Some(false) + { + return Err(KmsError::InvalidRequest(format!( + "ReKeyKeyPair: key '{}' is not the latest in its keyset — only the \ + latest generation can be rotated", + owm.id() + ))); + } + // Validate no crypto param changes validate_no_crypto_param_change( owm.attributes(), diff --git a/crate/server/src/core/operations/rekey/symmetric.rs b/crate/server/src/core/operations/rekey/symmetric.rs index 0a60a48dd1..fe351e71ee 100644 --- a/crate/server/src/core/operations/rekey/symmetric.rs +++ b/crate/server/src/core/operations/rekey/symmetric.rs @@ -6,6 +6,7 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::{ kmip_types::UniqueIdentifier, }; use cosmian_logger::trace; +use time::OffsetDateTime; use super::common::{ RekeyOperation, ReplacementObject, RotationCandidate, compute_rotation_uid, @@ -17,6 +18,7 @@ use crate::{ core::{ KMS, operations::key_ops::{ObjectWithMetadataOps, reject_protection_storage_masks}, + uid_utils::has_prefix, }, error::KmsError, result::{KResult, KResultHelper}, @@ -31,17 +33,174 @@ pub(crate) struct SymmetricRekey { /// KMIP `ReKey` operation for symmetric keys (KMIP 2.1 §6.1.46). /// -/// - 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. +/// - For regular (SQL) keys: generates fresh key material, handles wrapping, links generations. +/// - For HSM-resident keys (UID starts with `hsm::`): calls `C_GenerateKey` on the same HSM +/// slot, assigns a generation-suffix UID (`original::N+1`), and updates `CKA_LABEL` / +/// `CKA_START_DATE` / `CKA_END_DATE` on both the old and new keys. pub(crate) async fn rekey(kms: &KMS, request: ReKey, owner: &str) -> KResult { trace!("ReKey: {}", serde_json::to_string(&request)?); + let uid_or_tags = request + .unique_identifier + .as_ref() + .ok_or(KmsError::UnsupportedPlaceholder)? + .as_str() + .context("ReKey: the unique identifier must be a string")?; + + // Route HSM-resident keys through the dedicated PKCS#11 rotation path. + // The general RekeyOperation pipeline is designed for SQL-backed keys and + // is not applicable to non-extractable HSM key material. + if has_prefix(uid_or_tags).is_some() { + return rekey_hsm_symmetric(kms, uid_or_tags, owner).await; + } + let offset = request.offset; execute_rekey(&SymmetricRekey { offset }, kms, &request, owner).await } +/// Rotate an HSM-resident AES symmetric key. +/// +/// ## Rotation algorithm +/// +/// 1. Validate that the caller has `Rekey` permission. +/// 2. Retrieve the old key's metadata from the HSM (algorithm, length, sensitivity, +/// keyset info from `CKA_LABEL`, rotation interval from `CKA_START_DATE`/`CKA_END_DATE`). +/// 3. Compute the new generation number and new `key_id`/UID +/// (`base_key_id::new_gen`, `prefix::slot::base_key_id::new_gen`). +/// 4. Generate the new key on the same HSM slot via `C_GenerateKey` (`create_key`). +/// 5. Infer the rotation interval from the old key's dates; stamp new `CKA_START_DATE` / +/// `CKA_END_DATE` on the new key if an interval is known. +/// 6. Update `CKA_LABEL` on the old key (strip `::latest` suffix) and on the new key +/// (append `::latest`). +async fn rekey_hsm_symmetric(kms: &KMS, uid: &str, user: &str) -> KResult { + enforce_privileged_user(kms, user).await?; + + // Retrieve old key metadata from the HSM. + let old_owm = kms + .database + .retrieve_object(uid) + .await? + .ok_or_else(|| KmsError::InvalidRequest(format!("HSM key '{uid}' not found")))?; + + if old_owm.object().object_type() != ObjectType::SymmetricKey { + return Err(KmsError::NotSupported( + "HSM ReKey is currently supported for AES symmetric keys only".to_owned(), + )); + } + + let old_attrs = old_owm.attributes(); + + // Reject Re-Key on a retired (non-latest) member of a named keyset. + // Keys without a rotate_name are not keyset members and may be freely re-keyed. + if old_attrs.rotate_name.is_some() && old_attrs.rotate_latest == Some(false) { + return Err(KmsError::InvalidRequest(format!( + "ReKey: HSM key '{uid}' is not the latest in its keyset — only the latest \ + generation can be rotated" + ))); + } + + // Parse the UID to extract prefix, slot_id, and key_id. + // For `hsm::softhsm2::0::mykey` → prefix = `"hsm::softhsm2"`, rest = `"0::mykey"`. + let prefix = has_prefix(uid) + .ok_or_else(|| KmsError::InvalidRequest(format!("UID '{uid}' is not an HSM UID")))?; + let rest = uid + .strip_prefix(&format!("{prefix}::")) + .ok_or_else(|| KmsError::InvalidRequest("HSM UID has unexpected format".to_owned()))?; + let (slot_str, key_id) = rest.split_once("::").ok_or_else(|| { + KmsError::InvalidRequest(format!( + "HSM UID '{uid}' must have format '{prefix}::::'" + )) + })?; + let slot_id: usize = slot_str.parse().map_err(|e| { + KmsError::InvalidRequest(format!("HSM slot_id '{slot_str}' is not valid: {e}")) + })?; + + // Compute the new generation and new key_id. + // `key_id` may already contain a generation suffix: `"base_id::N"`. + // We split on the last `::` to find any existing numeric generation suffix. + let (base_id, old_gen) = key_id + .rsplit_once("::") + .map_or((key_id, 0), |(base, suffix)| { + suffix.parse::().map_or((key_id, 0), |n| (base, n)) + }); + + let new_gen = old_gen + 1; + let new_key_id = format!("{base_id}::{new_gen}"); + let new_uid = format!("{prefix}::{slot_id}::{new_key_id}"); + + // Retrieve old rotate metadata from the HSM (via stub attributes). + // Fall back gracefully if CKA_LABEL metadata is absent. + let (rotate_name, old_rotate_gen) = ( + old_attrs.rotate_name.clone(), + old_attrs.rotate_generation.unwrap_or(old_gen), + ); + + // Infer rotation interval from old CKA dates if available. + let interval_days: Option = old_attrs + .rotate_interval + .filter(|&i| i > 0) + .map(|secs| secs / 86400); + + // Generate the new key on the same HSM slot. + kms.database + .create( + Some(new_uid.clone()), + user, + &old_owm.object().clone(), + old_attrs, + &std::collections::HashSet::new(), + ) + .await + .map_err(|e| { + KmsError::InvalidRequest(format!("Failed to generate new HSM key '{new_uid}': {e}")) + })?; + + // Stamp rotation dates on the new key. + if let Some(days) = interval_days { + let today = OffsetDateTime::now_utc().date(); + let end = today + time::Duration::days(days); + kms.database + .set_key_rotation_dates(&new_uid, Some(today), Some(end)) + .await + .map_err(|e| { + KmsError::InvalidRequest(format!( + "Failed to set rotation dates on new HSM key '{new_uid}': {e}" + )) + })?; + } + + // Update CKA_LABEL on old key (remove ::latest) and new key (add ::latest). + // Use `base_id` (without generation suffix) in the label — the generation is + // already in its own field, and including a generation-suffixed key_id would + // introduce extra `::` delimiters that break `parse_label_metadata()`. + if let Some(ref name) = rotate_name { + let old_label_retired = format!("{name}::{old_rotate_gen}::{base_id}"); + let new_label_latest = format!("{name}::{new_gen}::{base_id}::latest"); + + kms.database + .set_key_label(uid, &old_label_retired) + .await + .map_err(|e| { + KmsError::InvalidRequest(format!( + "Failed to update CKA_LABEL on old HSM key '{uid}': {e}" + )) + })?; + kms.database + .set_key_label(&new_uid, &new_label_latest) + .await + .map_err(|e| { + KmsError::InvalidRequest(format!( + "Failed to set CKA_LABEL on new HSM key '{new_uid}': {e}" + )) + })?; + } + + trace!("HSM ReKey: old={uid} → new={new_uid} (slot={slot_id}, gen={new_gen}), user={user}"); + + Ok(ReKeyResponse { + unique_identifier: UniqueIdentifier::TextString(new_uid), + }) +} + impl RekeyOperation for SymmetricRekey { type Request = ReKey; type Response = ReKeyResponse; @@ -63,18 +222,6 @@ impl RekeyOperation for SymmetricRekey { .as_str() .context("Rekey: the symmetric key unique identifier must be a string")?; - // HSM-managed keys cannot be re-keyed via KMIP: they have no KMIP attribute - // storage and are often non-extractable (CKA_EXTRACTABLE = false). - // Use PKCS#11 vendor tools for HSM key lifecycle management. - if uid_or_tags.starts_with("hsm::") { - return Err(KmsError::NotSupported( - "Re-Key is not supported for HSM-managed keys. \ - Use PKCS#11 vendor tools or the HSM administration console \ - to manage HSM key lifecycle." - .to_owned(), - )); - } - for owm in retrieve_eligible_keys(kms, uid_or_tags, ObjectType::SymmetricKey).await? { if !owm .user_can_perform_operation(user, &KmipOperation::Rekey, kms) @@ -83,6 +230,18 @@ impl RekeyOperation for SymmetricRekey { continue; } + // Reject Re-Key on a retired (non-latest) member of a named keyset. + // Keys without a rotate_name are not keyset members and may be freely re-keyed. + if owm.attributes().rotate_name.is_some() + && owm.attributes().rotate_latest == Some(false) + { + return Err(KmsError::InvalidRequest(format!( + "ReKey: key '{}' is not the latest in its keyset — only the latest \ + generation can be rotated", + owm.id() + ))); + } + // Reject requests that attempt to change crypto parameters validate_no_crypto_param_change( owm.attributes(), diff --git a/crate/server/src/core/operations/signature_verify.rs b/crate/server/src/core/operations/signature_verify.rs index 77734896d2..5e92f854ae 100644 --- a/crate/server/src/core/operations/signature_verify.rs +++ b/crate/server/src/core/operations/signature_verify.rs @@ -23,7 +23,7 @@ use openssl::pkey::{Id, PKey, Public}; use crate::{ core::{ KMS, - operations::{CryptoOpSpec, has_usage_mask, perform_crypto_operation}, + operations::{CryptoOpSpec, KeysetMode, has_usage_mask, perform_crypto_operation}, }, error::KmsError, kms_bail, @@ -44,6 +44,10 @@ impl CryptoOpSpec for SignatureVerifyOp { request.unique_identifier.as_ref() } + fn keyset_mode() -> KeysetMode { + KeysetMode::TryEach + } + fn usage_data_len(request: &Self::Request) -> usize { request .data diff --git a/crate/server/src/core/uid_utils.rs b/crate/server/src/core/uid_utils.rs index 71c7ef9859..7cf5b6520a 100644 --- a/crate/server/src/core/uid_utils.rs +++ b/crate/server/src/core/uid_utils.rs @@ -1,6 +1,9 @@ use std::collections::HashSet; -use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::UniqueIdentifier; +use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::{ + LinkType, UniqueIdentifier, +}; +use cosmian_logger::trace; use crate::{ core::KMS, @@ -52,3 +55,332 @@ pub(super) async fn uids_from_unique_identifier( } Ok(HashSet::from([uid_or_tags.to_owned()])) } + +// ─── Keyset Resolution ─────────────────────────────────────────────────────── + +/// The result of parsing a keyset identifier (`name@version` syntax). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum KeysetVersion { + /// `name@latest` — resolve to the key with `rotate_latest=true` + Latest, + /// `name@first` or `name@0` — resolve to generation 0 + First, + /// `name@N` — resolve to a specific generation number + Generation(i32), + /// Bare `name` (no `@` suffix) — interpretation depends on operation mode + Bare, +} + +/// Parsed keyset reference. +#[derive(Debug, Clone)] +pub(crate) struct KeysetRef { + pub name: String, + pub version: KeysetVersion, +} + +/// Returns `true` if the string looks like a UUID (8-4-4-4-12 hex pattern). +fn looks_like_uuid(s: &str) -> bool { + // Quick length check: standard UUID is 36 chars + if s.len() != 36 { + return false; + } + s.chars().enumerate().all(|(i, c)| match i { + 8 | 13 | 18 | 23 => c == '-', + _ => c.is_ascii_hexdigit(), + }) +} + +/// Try to parse an identifier as a keyset reference. +/// +/// A keyset reference is recognized when: +/// - It is NOT a tag JSON (`[...]`) +/// - It is NOT an HSM prefix (`hsm::...`) +/// - It is NOT a UUID +/// - It contains `@` (explicit version) OR is a bare name (fallback resolution) +/// +/// Returns `None` if the identifier doesn't match keyset syntax. +pub(crate) fn parse_keyset_identifier(identifier: &str) -> Option { + // Skip tags and UUIDs. + // HSM UIDs with an `@` suffix ARE valid keyset references: the user addresses + // the keyset by its rotate_name, not by an hsm:: UID. + if identifier.starts_with('[') { + return None; + } + // A bare `hsm::...` UID (no `@`) is a direct key address, not a keyset ref. + if identifier.starts_with("hsm::") && !identifier.contains('@') { + return None; + } + + if let Some(at_pos) = identifier.rfind('@') { + let name = &identifier[..at_pos]; + let version_str = &identifier[at_pos + 1..]; + + // If the part before @ looks like a UUID, it's not a keyset reference + if looks_like_uuid(name) { + return None; + } + + // Empty name is not valid + if name.is_empty() { + return None; + } + + let version = match version_str { + "latest" => KeysetVersion::Latest, + "first" => KeysetVersion::First, + s => { + if let Ok(n) = s.parse::() { + if n == 0 { + KeysetVersion::First + } else { + KeysetVersion::Generation(n) + } + } else { + // Invalid version specifier — not a keyset reference + return None; + } + } + }; + + Some(KeysetRef { + name: name.to_owned(), + version, + }) + } else { + // No `@` — could be a bare keyset name if it's not a UUID + if looks_like_uuid(identifier) { + return None; + } + // It could be a plain UID that isn't a UUID (e.g. user-chosen IDs). + // We return a Bare keyset reference — the caller will attempt DB lookup + // and fall back to direct UID if the keyset name doesn't exist. + Some(KeysetRef { + name: identifier.to_owned(), + version: KeysetVersion::Bare, + }) + } +} + +/// Resolve a keyset identifier to a single UID (for encrypt/sign operations). +/// +/// For `@latest` or `Bare` mode, resolves to the key with `rotate_latest=true`. +/// If no key has `rotate_latest=true`, falls back to finding any key with that +/// `rotate_name` and picks the one with the highest generation (handles the case +/// where a key has `rotate_name` set but hasn't been rotated yet). +/// +/// For `@first` or `@N`, resolves to the key with the matching generation. +/// +/// Returns `None` if the keyset name doesn't match any object. +pub(crate) async fn resolve_keyset_to_single_uid( + keyset_ref: &KeysetRef, + kms: &KMS, + user: &str, +) -> KResult> { + let (generation, latest) = match &keyset_ref.version { + KeysetVersion::Latest | KeysetVersion::Bare => (None, Some(true)), + KeysetVersion::First => (Some(0), None), + KeysetVersion::Generation(n) => (Some(*n), None), + }; + + let mut results = kms + .database + .find_by_rotate_name(&keyset_ref.name, generation, latest, user) + .await?; + + // Fallback: if looking for @latest but no key has rotate_latest=true, + // search by name only and pick the highest generation. This handles keys + // that have rotate_name set but haven't been rotated yet. + if results.is_empty() && latest == Some(true) { + results = kms + .database + .find_by_rotate_name(&keyset_ref.name, None, None, user) + .await?; + } + + match results.len() { + 0 => Ok(None), + 1 => Ok(Some( + results + .into_iter() + .next() + .map(|(uid, _)| uid) + .unwrap_or_default(), + )), + _ => { + // Multiple matches — take the one with highest generation + let best = results + .into_iter() + .max_by_key(|(_, attrs)| attrs.rotate_generation.unwrap_or(0)); + Ok(best.map(|(uid, _)| uid)) + } + } +} + +/// Walk the keyset rotation chain from the latest key backward. +/// +/// Starts from the key with `rotate_latest=true` for the given `rotate_name`, +/// then follows `ReplacedObjectLink` backward, collecting UIDs in newest-to-oldest +/// order. +/// +/// Stops when: +/// - No more `ReplacedObjectLink` is found (reached the original key) +/// - The `max_depth` limit is reached +/// - A cycle is detected +/// +/// Returns the ordered list of UIDs to try for decryption. +pub(crate) async fn walk_keyset_chain( + keyset_name: &str, + kms: &KMS, + user: &str, + max_depth: u32, +) -> KResult> { + // Find the latest key in the chain (prefer rotate_latest=true) + let mut results = kms + .database + .find_by_rotate_name(keyset_name, None, Some(true), user) + .await?; + + // Fallback: if no key has rotate_latest=true, search by name only + if results.is_empty() { + results = kms + .database + .find_by_rotate_name(keyset_name, None, None, user) + .await?; + } + + let Some((latest_uid, _)) = results + .into_iter() + .max_by_key(|(_, attrs)| attrs.rotate_generation.unwrap_or(0)) + else { + return Ok(vec![]); + }; + + // HSM keys store all rotation metadata in PKCS#11 attributes — there are no + // ReplacedObjectLink back-pointers. Fetch every generation and sort newest-first. + if latest_uid.starts_with("hsm::") { + let all_results = kms + .database + .find_by_rotate_name(keyset_name, None, None, user) + .await?; + let mut all_pairs: Vec<(String, i32)> = all_results + .into_iter() + .map(|(uid, attrs)| (uid, attrs.rotate_generation.unwrap_or(0))) + .collect(); + all_pairs.sort_by(|a, b| b.1.cmp(&a.1)); + let chain: Vec = all_pairs.into_iter().map(|(uid, _)| uid).collect(); + trace!( + "walk_keyset_chain: HSM keyset '{}' has {} keys in chain", + keyset_name, + chain.len() + ); + return Ok(chain); + } + + let mut chain = vec![latest_uid.clone()]; + let mut current_uid = latest_uid; + let mut visited: HashSet = chain.iter().cloned().collect(); + + for _ in 1..max_depth { + // Retrieve the current object's attributes to find ReplacedObjectLink + let Some(owm) = kms.database.retrieve_object(¤t_uid).await? else { + break; + }; + + // Look for ReplacedObjectLink (points backward to the older key) + let prev_uid = owm + .attributes() + .get_link(LinkType::ReplacedObjectLink) + .map(|link| link.to_string()); + + let Some(prev_uid) = prev_uid else { + break; // End of chain + }; + + // Cycle detection + if !visited.insert(prev_uid.clone()) { + trace!( + "walk_keyset_chain: cycle detected at {} for keyset '{}'", + prev_uid, keyset_name + ); + break; + } + + chain.push(prev_uid.clone()); + current_uid = prev_uid; + } + + trace!( + "walk_keyset_chain: keyset '{}' has {} keys in chain", + keyset_name, + chain.len() + ); + + Ok(chain) +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[expect(clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn test_parse_keyset_latest() { + let r = parse_keyset_identifier("my-keys@latest").expect("should parse"); + assert_eq!(r.name, "my-keys"); + assert_eq!(r.version, KeysetVersion::Latest); + } + + #[test] + fn test_parse_keyset_first() { + let r = parse_keyset_identifier("my-keys@first").expect("should parse"); + assert_eq!(r.name, "my-keys"); + assert_eq!(r.version, KeysetVersion::First); + + let r = parse_keyset_identifier("my-keys@0").expect("should parse"); + assert_eq!(r.name, "my-keys"); + assert_eq!(r.version, KeysetVersion::First); + } + + #[test] + fn test_parse_keyset_generation() { + let r = parse_keyset_identifier("my-keys@3").expect("should parse"); + assert_eq!(r.name, "my-keys"); + assert_eq!(r.version, KeysetVersion::Generation(3)); + } + + #[test] + fn test_parse_keyset_bare() { + let r = parse_keyset_identifier("my-production-key").expect("should parse"); + assert_eq!(r.name, "my-production-key"); + assert_eq!(r.version, KeysetVersion::Bare); + } + + #[test] + fn test_parse_uuid_not_keyset() { + assert!(parse_keyset_identifier("550e8400-e29b-41d4-a716-446655440000").is_none()); + } + + #[test] + fn test_parse_tags_not_keyset() { + assert!(parse_keyset_identifier("[\"tag1\",\"tag2\"]").is_none()); + } + + #[test] + fn test_parse_hsm_not_keyset() { + assert!(parse_keyset_identifier("hsm::softhsm2::0::my-key").is_none()); + } + + #[test] + fn test_parse_invalid_version() { + // "abc" after @ is not a valid version specifier + assert!(parse_keyset_identifier("my-key@abc").is_none()); + } + + #[test] + fn test_parse_uuid_with_at() { + // A UUID followed by @latest should NOT be treated as keyset + assert!(parse_keyset_identifier("550e8400-e29b-41d4-a716-446655440000@latest").is_none()); + } +} diff --git a/crate/server/src/main.rs b/crate/server/src/main.rs index 9f42ea2d6d..caf003986d 100644 --- a/crate/server/src/main.rs +++ b/crate/server/src/main.rs @@ -348,6 +348,7 @@ mod tests { privileged_users: None, print_default_config: false, auto_rotation_check_interval_secs: 0, + keyset_decrypt_max_attempts: 100, }; let toml_string = r#" @@ -360,8 +361,11 @@ hsm_model = "" hsm_admin = [] hsm_slot = [] hsm_password = [] +hsm_instances = [] key_encryption_key = "key wrapping key" kms_public_url = "[kms_public_url]" +auto_rotation_check_interval_secs = 0 +keyset_decrypt_max_attempts = 100 [db] database_type = "[redis-findex, postgresql,...]" @@ -385,6 +389,7 @@ tls_cipher_suites = "TLS_AES_256_GCM_SHA384,TLS_AES_128_GCM_SHA256" [http] port = 443 hostname = "[hostname]" +rate_limit_per_second = 100 [proxy] proxy_url = "https://proxy.example.com:8080" @@ -420,6 +425,7 @@ tmp_path = "[tmp path]" [logging] rust_log = "info,cosmian_kms=debug" otlp = "http://localhost:4317" +otlp_allow_insecure = false quiet = false log_to_syslog = false rolling_log_dir = "[rolling log dir]" diff --git a/crate/server_database/src/core/database_objects.rs b/crate/server_database/src/core/database_objects.rs index 78b655bfb0..469765589f 100644 --- a/crate/server_database/src/core/database_objects.rs +++ b/crate/server_database/src/core/database_objects.rs @@ -8,6 +8,7 @@ use cosmian_kmip::{ kmip_2_1::{kmip_attributes::Attributes, kmip_objects::Object}, }; use cosmian_kms_interfaces::{AtomicOperation, ObjectWithMetadata, ObjectsStore}; +use time::Date; use crate::{ Database, @@ -354,6 +355,52 @@ impl Database { Ok(results) } + /// Find objects by their `x-rotate-name` vendor attribute. + /// + /// Queries all registered object stores and returns matching `(uid, attributes)` pairs. + pub async fn find_by_rotate_name( + &self, + name: &str, + generation: Option, + latest: Option, + owner: &str, + ) -> DbResult> { + let map = self.objects.read().await; + let mut results: Vec<(String, Attributes)> = Vec::new(); + for db in map.values() { + results.extend( + db.find_by_rotate_name(name, generation, latest, owner) + .await + .unwrap_or_default(), + ); + } + Ok(results) + } + + /// Set the `CKA_LABEL` (or equivalent) on a key identified by `uid`. + /// + /// Routes to the object store responsible for `uid`. SQL stores silently ignore this. + pub async fn set_key_label(&self, uid: &str, label: &str) -> DbResult<()> { + let store = self.get_object_store(uid).await?; + store.set_key_label(uid, label).await.map_err(Into::into) + } + + /// Rewrite the PKCS#11 rotation dates on an HSM key identified by `uid`. + /// + /// Routes to the object store responsible for `uid`. SQL stores silently ignore this. + pub async fn set_key_rotation_dates( + &self, + uid: &str, + start_date: Option, + end_date: Option, + ) -> DbResult<()> { + let store = self.get_object_store(uid).await?; + store + .set_key_rotation_dates(uid, start_date, end_date) + .await + .map_err(Into::into) + } + /// 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/redis/objects_db.rs b/crate/server_database/src/stores/redis/objects_db.rs index de4388ac71..5064a6af8d 100644 --- a/crate/server_database/src/stores/redis/objects_db.rs +++ b/crate/server_database/src/stores/redis/objects_db.rs @@ -63,6 +63,12 @@ pub(crate) fn keywords_from_attributes(attributes: &Attributes) -> HashSet, + latest: Option, + owner: &str, + ) -> InterfaceResult> { + // Search Findex for objects indexed under this rotate_name keyword + let keyword = Keyword::from(format!("rotate_name::{name}").as_bytes()); + let indexed_uids = self + .findex + .search(&keyword) + .await + .map_err(|e| db_error!(format!("Error searching rotate_name keyword: {e:?}")))?; + if indexed_uids.is_empty() { + return Ok(vec![]); + } + + let candidate_uids: HashSet = indexed_uids + .iter() + .filter_map(|v| String::from_utf8(v.to_vec()).ok()) + .collect(); + + // Fetch the candidate objects + let redis_db_objects = self.objects_db.objects_get(&candidate_uids).await?; + + // Filter by owner, generation, and latest flag + let mut results = Vec::new(); + for (uid, dbo) in redis_db_objects { + if dbo.owner != owner { + continue; + } + let attrs = dbo.attributes.unwrap_or_default(); + // Verify the rotate_name matches (double-check) + if attrs.rotate_name.as_deref() != Some(name) { + continue; + } + // Filter by generation if requested + if let Some(expected_gen) = generation { + if attrs.rotate_generation != Some(expected_gen) { + continue; + } + } + // Filter by latest flag if requested + if let Some(lat) = latest { + if attrs.rotate_latest != Some(lat) { + continue; + } + } + results.push((uid, attrs)); + } + Ok(results) + } + async fn find_wrapped_by( &self, wrapping_key_uid: &str, diff --git a/crate/server_database/src/stores/sql/locate_query.rs b/crate/server_database/src/stores/sql/locate_query.rs index 176ab1e743..1390c5ebe2 100644 --- a/crate/server_database/src/stores/sql/locate_query.rs +++ b/crate/server_database/src/stores/sql/locate_query.rs @@ -515,6 +515,52 @@ ON objects.id = matched_tags.id" qb.finish(query) } +/// Build the SQL query to find objects by their `RotateName` vendor attribute. +/// +/// Optionally filters by `RotateGeneration` (integer equality) and `RotateLatest` (text +/// `"true"`/`"false"`) directly in SQL, reducing the result set at the database level. +/// +/// Returns a `LocateQuery` with parameterized bindings suitable for all SQL backends. +pub(super) fn find_by_rotate_name_query( + name: &str, + generation: Option, + latest: Option, + owner: &str, +) -> LocateQuery { + let mut qb = LocateQueryBuilder::

::new(); + + let owner_bind = qb.bind_text(owner); + let name_bind = qb.bind_text(name); + let rotate_name_extract = P::extract_attribute_path(&["RotateName"]); + + let mut query = format!( + "SELECT objects.id, objects.attributes FROM objects \ + WHERE objects.owner = {owner_bind} \ + AND {rotate_name_extract} = {name_bind}" + ); + + if let Some(g) = generation { + let gen_extract = P::extract_attribute_path(&["RotateGeneration"]); + let gen_bind = qb.bind_i64(i64::from(g)); + if P::NEEDS_INTEGER_CAST { + query = format!( + "{query} AND CAST({gen_extract} AS {}) = {gen_bind}", + P::TYPE_INTEGER + ); + } else { + query = format!("{query} AND CAST({gen_extract} AS SIGNED) = {gen_bind}"); + } + } + + if let Some(lat) = latest { + let latest_extract = P::extract_attribute_path(&["RotateLatest"]); + let latest_bind = qb.bind_text(if lat { "true" } else { "false" }); + query = format!("{query} AND {latest_extract} = {latest_bind}"); + } + + qb.finish(query) +} + /// Build the SQL query to find objects that are candidates for rotation. /// Selects active objects with a non-null `RotateInterval > 0`. /// The actual "due" check (comparing timestamps) is done in Rust via `is_due_for_rotation`. diff --git a/crate/server_database/src/stores/sql/mysql.rs b/crate/server_database/src/stores/sql/mysql.rs index cfb0c77aaa..85095517d2 100644 --- a/crate/server_database/src/stores/sql/mysql.rs +++ b/crate/server_database/src/stores/sql/mysql.rs @@ -31,7 +31,10 @@ use crate::{ migrate::{DbState, Migrate}, sql::{ database::SqlDatabase, - locate_query::{MySqlPlaceholder, find_due_for_rotation_query, query_from_attributes}, + locate_query::{ + MySqlPlaceholder, find_by_rotate_name_query, find_due_for_rotation_query, + query_from_attributes, + }, }, }, }; @@ -719,6 +722,41 @@ impl ObjectsStore for MySqlPool { } Ok(due) } + + async fn find_by_rotate_name( + &self, + name: &str, + generation: Option, + latest: Option, + owner: &str, + ) -> InterfaceResult> { + let mut conn = self + .pool + .get_conn() + .await + .map_err(|e| InterfaceError::Db(format!("MySQL connection error: {e}")))?; + let locate = find_by_rotate_name_query::(name, generation, latest, owner); + let params: Vec = locate + .params + .into_iter() + .map(|p| match p { + crate::stores::sql::locate_query::LocateParam::Text(s) => { + mysql_async::Value::Bytes(s.into_bytes()) + } + crate::stores::sql::locate_query::LocateParam::I64(i) => mysql_async::Value::Int(i), + }) + .collect(); + let rows: Vec<(String, serde_json::Value)> = conn + .exec(locate.sql, params) + .await + .map_err(|e| InterfaceError::Db(format!("MySQL query error: {e}")))?; + let mut results = Vec::new(); + for (uid, attrs_val) in rows { + let attrs: Attributes = serde_json::from_value(attrs_val).unwrap_or_default(); + results.push((uid, attrs)); + } + Ok(results) + } } #[async_trait(?Send)] diff --git a/crate/server_database/src/stores/sql/pgsql.rs b/crate/server_database/src/stores/sql/pgsql.rs index 794d835933..b5c83bf6e5 100644 --- a/crate/server_database/src/stores/sql/pgsql.rs +++ b/crate/server_database/src/stores/sql/pgsql.rs @@ -910,6 +910,51 @@ impl ObjectsStore for PgPool { Ok(due) }) } + + async fn find_by_rotate_name( + &self, + name: &str, + generation: Option, + latest: Option, + owner: &str, + ) -> InterfaceResult> { + let name = name.to_owned(); + let owner = owner.to_owned(); + pg_retry!(self.pool, |client| { + let locate = crate::stores::sql::locate_query::find_by_rotate_name_query::< + crate::stores::sql::locate_query::PgSqlPlaceholder, + >(&name, generation, latest, &owner); + let stmt = client + .prepare(&locate.sql) + .await + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + let mut owned: Vec> = Vec::with_capacity(locate.params.len()); + for p in locate.params { + match p { + crate::stores::sql::locate_query::LocateParam::Text(s) => { + owned.push(Box::new(s)); + } + crate::stores::sql::locate_query::LocateParam::I64(i) => { + owned.push(Box::new(i)); + } + } + } + let params: Vec<&(dyn ToSql + Sync)> = + owned.iter().map(std::convert::AsRef::as_ref).collect(); + let rows = client + .query(&stmt, ¶ms) + .await + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + let mut results = Vec::new(); + for row in rows { + let uid: String = row.get(0); + let attrs_val: Value = row.get(1); + let attrs: Attributes = serde_json::from_value(attrs_val).unwrap_or_default(); + results.push((uid, attrs)); + } + Ok(results) + }) + } } #[async_trait(?Send)] diff --git a/crate/server_database/src/stores/sql/sqlite.rs b/crate/server_database/src/stores/sql/sqlite.rs index e8b5faf354..414c1e364f 100644 --- a/crate/server_database/src/stores/sql/sqlite.rs +++ b/crate/server_database/src/stores/sql/sqlite.rs @@ -23,7 +23,10 @@ use serde_json::Value; use tokio_rusqlite::Connection; use uuid::Uuid; -use super::locate_query::{SqlitePlaceholder, find_due_for_rotation_query, query_from_attributes}; +use super::locate_query::{ + SqlitePlaceholder, find_by_rotate_name_query, find_due_for_rotation_query, + query_from_attributes, +}; use crate::{ db_error, error::{DbError, DbResult}, @@ -670,6 +673,57 @@ impl ObjectsStore for SqlitePool { } Ok(due) } + + async fn find_by_rotate_name( + &self, + name: &str, + generation: Option, + latest: Option, + owner: &str, + ) -> InterfaceResult> { + let locate = + find_by_rotate_name_query::(name, generation, latest, owner); + let sql = replace_dollars_with_qn(&locate.sql); + let locate_params = locate.params; + let rows = self + .reader() + .call( + move |c: &mut rusqlite::Connection| -> Result< + Vec<(String, String)>, + rusqlite::Error, + > { + let mut stmt = c.prepare(&sql)?; + let values: Vec = locate_params + .into_iter() + .map(|p| match p { + crate::stores::sql::locate_query::LocateParam::Text(s) => { + rusqlite::types::Value::Text(s) + } + crate::stores::sql::locate_query::LocateParam::I64(i) => { + rusqlite::types::Value::Integer(i) + } + }) + .collect(); + let mut q = stmt.query(rusqlite::params_from_iter(values.iter()))?; + let mut out = Vec::new(); + while let Some(r) = q.next()? { + let id: String = r.get(0)?; + let attrs_json: String = r.get(1)?; + out.push((id, attrs_json)); + } + Ok(out) + }, + ) + .await + .map_err(DbError::from)?; + + let mut results = Vec::new(); + for (uid, attrs_json) in rows { + let attrs: Attributes = serde_json::from_str(&attrs_json).unwrap_or_default(); + results.push((uid, attrs)); + } + Ok(results) + } } #[async_trait(?Send)] diff --git a/crate/test_kms_server/README.md b/crate/test_kms_server/README.md index 95670a412e..7d52136d70 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. -**353 vectors** across 8 categories: +**361 vectors** across 8 categories: | Category | Vector Directory Name | KMIP Operations | Steps | |----------|-----------------------|-----------------|-------| @@ -228,6 +228,9 @@ replays the steps sequentially. | HSM / KEK Create | `hsm/kek_ec_p256_create_sign` | CreateKeyPair (EC P-256, KEK-wrapped), Sign, Destroy ×2 | 3 | | HSM / KEK Create | `hsm/kek_ed25519_create_sign` | CreateKeyPair (Ed25519, KEK-wrapped), Sign, Destroy ×2 | 3 | | HSM / KEK ReKey | `hsm/kek_rekey_wrapped` | Create (AES-256, KEK-wrapped), Encrypt, ReKey (unwrap from KEK, new material, re-wrap), Encrypt new, GetAttributes | 9 | +| HSM / Resident Keyset | `hsm/resident_keyset_set_rotate_name` | Create (AES-256, HSM), SetAttribute rotate_name (writes CKA_LABEL), Encrypt by keyset name, Decrypt by UID | 4 | +| HSM / Resident Keyset | `hsm/resident_keyset_rekey_and_decrypt` | Create, SetAttribute rotate_name, Encrypt (gen-0), ReKey, Decrypt by keyset name (chain: gen-1→gen-0) | 7 | +| HSM / Resident Keyset | `hsm/resident_keyset_double_rotation` | Create, SetAttribute rotate_name, Encrypt (gen-0), ReKey ×2, Decrypt by keyset name (chain: gen-2→gen-1→gen-0) | 9 | | HSM / KEK Negative | `hsm/kek_rsa1024_rejected` | CreateKeyPair (RSA-1024, KEK-wrapped) → FIPS rejection | 1 | | HSM / Resident Create | `hsm/resident_aes128_create_encrypt` | Create (AES-128, HSM-resident), Encrypt, Decrypt, Destroy | 4 | | HSM / Resident Create | `hsm/resident_aes256_create_encrypt` | Create (AES-256, HSM-resident), Encrypt, Decrypt, Destroy | 4 | @@ -345,6 +348,17 @@ replays the steps sequentially. | non-FIPS / ChaCha20 | `non-fips/chacha20_with_explicit_cryptographic_params` | Create, Encrypt (CryptographicParameters{ChaCha20} + 8-B nonce), Decrypt | 3 | | non-FIPS / Poly1305 | `non-fips/chacha20_poly1305_with_explicit_nonce` | Create, Encrypt (AEAD + client 12-B nonce), Decrypt | 3 | | non-FIPS / Poly1305 | `non-fips/chacha20_poly1305_with_aad` | Create, Encrypt (AEAD + AAD + server nonce), Decrypt | 3 | +| **Keyset Resolution** | | | | +| Keyset / Encrypt | `keyset_encrypt_latest` | Create, SetAttribute(RotateName), Encrypt(@latest), Decrypt | 6 | +| Keyset / Encrypt | `keyset_encrypt_bare_name` | Create, SetAttribute(RotateName), Encrypt(bare name), Decrypt | 6 | +| Keyset / Encrypt | `keyset_encrypt_latest_after_rotation` | Create, SetAttribute, ReKey, Encrypt(bare name→latest), Decrypt(new key) | 9 | +| Keyset / Decrypt | `keyset_decrypt_try_each` | Create, SetAttribute, Encrypt, ReKey, Decrypt(bare name→try-each) | 9 | +| Keyset / Decrypt | `keyset_decrypt_double_rotation` | Create, SetAttribute, Encrypt, ReKey×2, Decrypt(bare name→chain walk) | 12 | +| Keyset / Decrypt | `keyset_decrypt_at_latest` | Create, SetAttribute, ReKey, Encrypt(new key), Decrypt(@latest) | 9 | +| Negative / Keyset | `negative/keyset_rotate_name_at_rejected` | Create, SetAttribute(RotateName with @) → error | 4 | +| Negative / Keyset | `negative/rekey_non_latest_sql` | Create, SetAttribute(RotateName), ReKey (gen-0→gen-1), ReKey(gen-0 again) → "not the latest" error | 8 | +| Negative / HSM Keyset | `negative/rekey_non_latest_hsm` | Create (AES-256, HSM), SetAttribute(RotateName), ReKey (gen-0→gen-1), ReKey(gen-0 again) → "not the latest" error | 6 | +| Negative / HSM Keyset | `negative/set_attribute/hsm_rotate_offset_rejected` | Create (AES-256, HSM), SetAttribute(rotate_offset) → NotSupported | 3 | --- diff --git a/crate/test_kms_server/src/vector_runner.rs b/crate/test_kms_server/src/vector_runner.rs index 1f59a15ab9..cb03f48a97 100644 --- a/crate/test_kms_server/src/vector_runner.rs +++ b/crate/test_kms_server/src/vector_runner.rs @@ -3296,6 +3296,12 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/negative/set_attribute/read_only_attribute").await } + #[tokio::test] + async fn test_neg_hsm_rotate_offset_rejected() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/set_attribute/hsm_rotate_offset_rejected").await + } + #[tokio::test] async fn test_neg_spec_sign_invalid_message() -> Result<(), KmsClientError> { crate::init_test_logging(); @@ -3402,6 +3408,18 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/negative/rekey_keypair_preactive_fails").await } + #[tokio::test] + async fn test_neg_rekey_non_latest_sql() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/rekey_non_latest_sql").await + } + + #[tokio::test] + async fn test_neg_rekey_non_latest_hsm() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/rekey_non_latest_hsm").await + } + // ── KMIP operations: ReKeyKeyPair (non-FIPS only) ──────────────────── // These vectors do not supply PrivateKeyAttributes/PublicKeyAttributes with // FIPS-compliant CryptographicUsageMask values, and some use PQC algorithms @@ -3789,6 +3807,26 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/hsm/kek_rekey_wrapped").await } + // ── HSM Resident: Keyset (rotate_name / CKA_LABEL) ─────────────────── + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_set_rotate_name() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_set_rotate_name").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_rekey_and_decrypt() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_rekey_and_decrypt").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_double_rotation() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_double_rotation").await + } + #[tokio::test] #[cfg(not(feature = "non-fips"))] async fn test_vec_hsm_kek_rsa1024_rejected() -> Result<(), KmsClientError> { @@ -4045,4 +4083,52 @@ ObjectType = "SymmetricKey" crate::init_test_logging(); run_test_vector("test_data/vectors/fips/serialization/import_destroy_reimport").await } + + // ─── Keyset resolution & try-each-key vectors ──────────────────────────── + + #[tokio::test] + async fn test_vec_keyset_encrypt_latest() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_encrypt_latest").await + } + + #[tokio::test] + async fn test_vec_keyset_encrypt_bare_name() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_encrypt_bare_name").await + } + + #[tokio::test] + async fn test_vec_keyset_decrypt_try_each() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_decrypt_try_each").await + } + + #[tokio::test] + async fn test_vec_keyset_decrypt_double_rotation() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_decrypt_double_rotation") + .await + } + + #[tokio::test] + async fn test_vec_keyset_encrypt_latest_after_rotation() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector( + "test_data/vectors/fips/kmip_operations/keyset_encrypt_latest_after_rotation", + ) + .await + } + + #[tokio::test] + async fn test_vec_keyset_decrypt_at_latest() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_decrypt_at_latest").await + } + + #[tokio::test] + async fn test_vec_keyset_rotate_name_at_rejected() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/keyset_rotate_name_at_rejected").await + } } diff --git a/documentation/docs/kmip_support/key_auto_rotation.md b/documentation/docs/kmip_support/key_auto_rotation.md index 9704a59a01..00e5bfd975 100644 --- a/documentation/docs/kmip_support/key_auto_rotation.md +++ b/documentation/docs/kmip_support/key_auto_rotation.md @@ -15,11 +15,12 @@ the key object itself. The following attributes are available: | Attribute | Type | Description | Mutable | | --------------------- | --------------- | ----------------------------------------------------------------------------------------------------------- | ------- | -| `x-rotate-interval` | `i64` (seconds) | How often this key should be rotated. `0` disables auto-rotation. | Yes | -| `x-rotate-name` | `String` | Optional human-readable label for the policy (e.g. `"daily"`, `"annual"`). | Yes | +| `x-rotate-interval` | `i64` (seconds) | How often this key should be rotated. `0` disables auto-rotation. | Yes | +| `x-rotate-name` | `String` | Name of the keyset this key belongs to (see [Keysets](#keysets)). | Yes | | `x-rotate-offset` | `i64` (seconds) | Shift the first rotation trigger by this many seconds after `Initial Date`. | Yes | -| `x-rotate-generation` | `u64` | Incremented on every rotation; `0` for never-rotated keys. **Server-managed, read-only.** | No | +| `x-rotate-generation` | `i32` | Incremented on every rotation; `0` for never-rotated keys. **Server-managed, read-only.** | No | | `x-rotate-date` | `datetime` | Timestamp of the last rotation; populated automatically after each rotation. **Server-managed, read-only.** | No | +| `x-rotate-latest` | `bool` | `true` on the most-recent member of a keyset; `false` on all older (retired) members. **Server-managed, read-only.** | No | > **Read-only attributes:** `x-rotate-generation` and `x-rotate-date` are set > exclusively by the server during the `Re-Key` operation. Any attempt to @@ -57,45 +58,170 @@ ckms sym keys set-rotation-policy \ --- -> **⚠️ HSM-resident keys cannot be auto-rotated via KMIP** +> **ℹ️ HSM-resident keys support manual rotation but not auto-rotation** > -> Keys whose UID starts with `hsm::` (e.g. `hsm::softhsm2::0::my-kek`) are -> managed by a PKCS#11-capable Hardware Security Module. The KMS server *can* -> generate new HSM key material by calling `C_GenerateKey` / `C_GenerateKeyPair` -> on the HSM, but it cannot perform the full KMIP `Re-Key` / `Re-Key Key Pair` -> operation on them for two reasons: +> Keys whose UID starts with `hsm::` (e.g. `hsm::softhsm2::473094471::my-kek`) +> are managed by a PKCS#11-capable Hardware Security Module. > -> 1. **No KMIP attribute storage** — KMIP vendor attributes such as -> `x-rotate-interval`, `x-rotate-generation`, and `x-rotate-date` are stored -> in the KMS SQL metadata column. HSM objects live exclusively inside the -> HSM and have no corresponding SQL row, so rotation metadata cannot be -> attached to them. -> 2. **Non-extractable key material** — HSM keys are typically created with -> `CKA_SENSITIVE = true` and `CKA_EXTRACTABLE = false`, meaning the raw key -> bytes can never leave the hardware boundary. The KMIP re-key pipeline -> unwraps and re-wraps dependant keys in software, which is impossible for -> non-extractable HSM keys. -> -> As a result: -> -> - `find_due_for_rotation` **never** returns HSM UIDs — the HSM object store -> returns an empty list, so the scheduler skips them entirely. -> - Calling `Re-Key` or `Re-Key Key Pair` on an `hsm::` UID is explicitly -> **rejected** by the server with a `Not Supported` error. -> - Setting `x-rotate-interval` on an `hsm::` UID has no effect because there is -> no attribute row to update. -> -> **HSM key lifecycle management** should instead use PKCS#11-native mechanisms: -> -> | Mechanism | Description | -> |---|---| -> | `CKA_START_DATE` / `CKA_END_DATE` | Encode a validity period directly on the HSM object | -> | `pkcs11-tool --keygen` | Generate a new HSM key with `pkcs11-tool` | -> | `softhsm2-util`, Utimaco console, etc. | Vendor administration tools for slot / key lifecycle | -> -> After generating a new HSM key with vendor tools, register it with the KMS -> server by creating a KMIP `SymmetricKey` object whose UID uses the -> `hsm::::::` format. +> | Capability | Supported | Notes | +> |---|---|---| +> | Manual `Re-Key` via KMIP | ✅ Yes | Calls `C_GenerateKey` on the same HSM slot; see [HSM key rotation and keysets](#hsm-key-rotation-and-keysets) | +> | Keyset membership (`x-rotate-name`) | ✅ Yes | Stored in `CKA_LABEL`; supports bare-name and `name@version` addressing | +> | Auto-rotation scheduler | ❌ No | `find_due_for_rotation` never returns HSM UIDs; the scheduler skips them | +> | `x-rotate-interval` | ✅ Yes | Writes `CKA_START_DATE` / `CKA_END_DATE` for validity tracking | +> | `x-rotate-offset` | ❌ No | Not applicable to PKCS#11 scheduling; rejected with `NotSupported` | + +--- + +## Keysets + +A **keyset** is a named group of related key generations. Each generation is +a distinct cryptographic key (different key material, different UID for SQL +keys or different `key_id` suffix for HSM keys) produced by successive +`Re-Key` operations. Keysets are supported for both SQL-backed and +HSM-resident keys. + +### Assigning a key to a keyset + +Set `x-rotate-name` via `SetAttribute` (or the CLI): + +```bash +# SQL key +ckms sym keys set-rotation-policy --key-id --name "my-keyset" + +# HSM key (same command — writes CKA_LABEL on the PKCS#11 object) +ckms sym keys set-rotation-policy --key-id "hsm::softhsm2::473094471::my-key" --name "my-keyset" +``` + +The first `SetAttribute` marks the key as generation `0` and `x-rotate-latest = true`. +Every subsequent `Re-Key` increments the generation, sets `x-rotate-latest = true` +on the new key, and sets `x-rotate-latest = false` on the old key. + +### Keyset addressing syntax + +A keyset can be referenced by name instead of a specific UID: + +| Syntax | Resolves to | +|---|---| +| `my-keyset` (bare name) | Latest generation (`x-rotate-latest = true`) | +| `my-keyset@latest` | Latest generation (explicit) | +| `my-keyset@first` or `my-keyset@0` | Generation 0 (the original key) | +| `my-keyset@N` | Generation N | + +Keyset names are accepted wherever a `UniqueIdentifier` is expected: `Encrypt`, +`Decrypt`, `Sign`, `Verify`, `Get`, `GetAttributes`, `Re-Key`, etc. + +**Encrypt / Sign** resolves to the latest generation. +**Decrypt / Verify** walks the chain newest-to-oldest, trying each generation +until one succeeds. This lets in-flight ciphertexts produced with an older +key continue to decrypt after rotation. + +### Non-latest guard + +Only the **latest generation** of a keyset can be rotated via `Re-Key`. Attempting +to re-key a retired (non-latest) member is rejected: + +```text +Invalid Request: ReKey: key '' is not the latest in its keyset — +only the latest generation can be rotated +``` + +This prevents accidentally branching the rotation chain. Keys that do not +belong to any keyset (no `x-rotate-name`) are not subject to this restriction. + +### SQL keyset internals + +For SQL-backed keys the keyset state is stored as KMIP vendor attributes in the +database: + +- `x-rotate-name` — the keyset name (set once, inherited by each successive generation) +- `x-rotate-generation` — integer, starts at `0`, incremented per `Re-Key` +- `x-rotate-latest` — `true` on the current key; `false` on all older keys + +The rotation chain is also reflected in KMIP link attributes: +`ReplacementObjectLink` (old → new) and `ReplacedObjectLink` (new → old). +These back-pointers allow clients to traverse the full history. + +--- + +## HSM key rotation and keysets + +HSM-resident keys **fully support manual rotation via the `Re-Key` KMIP +operation** and the keyset feature. The background auto-rotation scheduler +does not apply to HSM keys (see note above). + +### CKA_LABEL convention + +HSM keyset metadata is stored entirely in the PKCS#11 `CKA_LABEL` attribute — +no SQL shadow rows are written. + +| CKA_LABEL value | Meaning | +| --------------------------------- | ---------------------------------- | +| `{name}::{gen}::{base_id}::latest` | Current (newest) key in the keyset | +| `{name}::{gen}::{base_id}` | Retired (older) key in the keyset | +| *(anything else)* | Key does not belong to a keyset | + +- `name` — the value set via `SetAttribute x-rotate-name` +- `gen` — integer starting at `0`, incremented on every `Re-Key` +- `base_id` — the original PKCS#11 `CKA_ID` of the gen-0 key + +### UID generation scheme + +```text +hsm:::: ← gen 0 (original key) +hsm::::::1 ← gen 1 (after first Re-Key) +hsm::::::2 ← gen 2 (after second Re-Key) +``` + +The numeric suffix is appended to the original `key_id`; the base name is +never changed. The full chain can therefore be discovered by inspecting +`CKA_LABEL` on the HSM slot. + +### Keyset resolution for HSM keys + +When a bare keyset name (e.g. `my-hsm-keyset`) or `name@version` syntax is +used, the server calls `find_by_rotate_name` which scans PKCS#11 objects in the +HSM slot and filters by `CKA_LABEL` prefix. Results are sorted by generation +(descending). For `Decrypt`, each generation is tried in order until one +succeeds. + +Unlike SQL-backed keys, HSM keysets do **not** use +`ReplacedObjectLink`/`ReplacementObjectLink` back-pointers; all state lives in +PKCS#11 attributes. + +### Example workflow + +```bash +# 1. Create an AES-256 key directly on the HSM (legacy UID format) +ckms sym keys create \ + --key-id "hsm::473094471::my-hsm-key" \ + --algorithm aes --length 256 + +# 2. Register the key in a keyset (writes CKA_LABEL = "my-keyset::0::my-hsm-key::latest") +ckms sym keys set-rotation-policy \ + --key-id "hsm::473094471::my-hsm-key" \ + --name "my-hsm-keyset" + +# 3. Encrypt using the keyset bare name (resolves to the latest key) +ckms sym encrypt --key-id "my-hsm-keyset" plaintext.bin + +# 4. Rotate: C_GenerateKey on the same HSM slot; updates CKA_LABEL on both keys +ckms sym keys rekey --key-id "hsm::473094471::my-hsm-key" +# → new UID: hsm::473094471::my-hsm-key::1 +# CKA_LABEL (gen-0): "my-keyset::0::my-hsm-key" (retired) +# CKA_LABEL (gen-1): "my-keyset::1::my-hsm-key::latest" (current) + +# 5. Decrypt old ciphertext: keyset tries gen-1 then gen-0 automatically +ckms sym decrypt --key-id "my-hsm-keyset" ciphertext.enc + +# 6. Second rotation +ckms sym keys rekey --key-id "hsm::473094471::my-hsm-key::1" +# → new UID: hsm::473094471::my-hsm-key::2 + +# Attempting to re-key a retired generation is rejected: +ckms sym keys rekey --key-id "hsm::473094471::my-hsm-key" # gen-0 — REJECTED +# Error: not the latest in its keyset +``` --- @@ -122,14 +248,14 @@ Only keys (or certificates) in the **Active** or **Deactivated** state can be rotated. Attempting to call `Re-Key`, `Re-Key Key Pair`, or `ReCertify` on an object in any other state will produce an error: -| State | Rotation allowed? | Rationale | -| ---------------------- | ----------------- | --------------------------------------------------------------------------- | -| **Active** | ✅ Yes | The primary valid source state for rotation. | -| **Deactivated** | ✅ Yes | KMIP §6.1.46 does not list `Wrong_Key_Lifecycle_State` — a deactivated key may produce a replacement. | -| **Pre-Active** | ❌ No | The key has never been activated — rotating unused material is premature. | -| **Compromised** | ❌ No | Rotating a compromised key would create confusion about trust lineage. | -| **Destroyed** | ❌ No | The object no longer exists. | -| **Destroyed_Compromised** | ❌ No | The object no longer exists. | +| State | Rotation allowed? | Rationale | +| ------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------- | +| **Active** | ✅ Yes | The primary valid source state for rotation. | +| **Deactivated** | ✅ Yes | KMIP §6.1.46 does not list `Wrong_Key_Lifecycle_State` — a deactivated key may produce a replacement. | +| **Pre-Active** | ❌ No | The key has never been activated — rotating unused material is premature. | +| **Compromised** | ❌ No | Rotating a compromised key would create confusion about trust lineage. | +| **Destroyed** | ❌ No | The object no longer exists. | +| **Destroyed_Compromised** | ❌ No | The object no longer exists. | > **Note:** This restriction applies to the **source** key only. The *output* > of a rotation operation can still enter the `Pre-Active` state when an @@ -604,7 +730,7 @@ when a key is rotated. | `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-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) | @@ -619,7 +745,7 @@ 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-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 | diff --git a/ui/src/actions/Keys/GetRotationPolicy.tsx b/ui/src/actions/Keys/GetRotationPolicy.tsx new file mode 100644 index 0000000000..d24d1fa239 --- /dev/null +++ b/ui/src/actions/Keys/GetRotationPolicy.tsx @@ -0,0 +1,101 @@ +import { Button, Card, Descriptions, Form, Input, Space } from "antd"; +import React, { useState } from "react"; +import { ActionResponse } from "../../components/common/ActionResponse"; +import { useActionState } from "../../hooks/useActionState"; +import { sendKmipRequest } from "../../utils/utils"; +import * as wasm from "../../wasm/pkg"; + +interface GetRotationPolicyFormData { + keyId: string; +} + +interface RotationPolicy { + interval?: number; + offset?: number; + name?: string; + generation?: number; + date?: string; +} + +const GetRotationPolicyForm: React.FC = () => { + const [form] = Form.useForm(); + const { res, isLoading, responseRef, idToken, serverUrl, execute } = useActionState(); + const [policy, setPolicy] = useState(null); + + const onFinish = async (values: GetRotationPolicyFormData) => { + setPolicy(null); + await execute(async () => { + const request = wasm.get_attributes_ttlv_request(values.keyId); + const result_str = await sendKmipRequest(request, idToken, serverUrl); + if (result_str) { + const result: RotationPolicy = await wasm.parse_rotation_policy_response(result_str); + setPolicy(result); + if (!result.interval && !result.name && !result.generation) { + return "No rotation policy configured for this key."; + } + return "Rotation policy retrieved successfully."; + } + }); + }; + + return ( +

+

Get Rotation Policy

+ +
+

Retrieve the current automatic rotation policy for a key.

+
+ +
+ + + + + + + + + + + + + + + {policy && (policy.interval || policy.name || policy.generation) && ( + + + + {policy.interval ?? "Not set"} + + + {policy.offset ?? "Not set"} + + + {policy.name ?? "Not set"} + + + {policy.generation ?? "Not set"} + + + {policy.date ?? "Never"} + + + + )} +
+ ); +}; + +export default GetRotationPolicyForm; diff --git a/ui/src/actions/Keys/KeysReKey.tsx b/ui/src/actions/Keys/KeysReKey.tsx new file mode 100644 index 0000000000..4b703d428c --- /dev/null +++ b/ui/src/actions/Keys/KeysReKey.tsx @@ -0,0 +1,73 @@ +import { Button, Card, Form, Input, Space } from "antd"; +import React from "react"; +import { ActionResponse } from "../../components/common/ActionResponse"; +import { useActionState } from "../../hooks/useActionState"; +import { sendKmipRequest } from "../../utils/utils"; +import * as wasm from "../../wasm/pkg"; + +interface ReKeyFormData { + keyId: string; +} + +type ReKeyResponse = { + UniqueIdentifier: string; +}; + +const KeysReKeyForm: React.FC = () => { + const [form] = Form.useForm(); + const { res, isLoading, responseRef, idToken, serverUrl, execute } = useActionState(); + + const onFinish = async (values: ReKeyFormData) => { + await execute(async () => { + const request = wasm.rekey_ttlv_request(values.keyId); + const result_str = await sendKmipRequest(request, idToken, serverUrl); + if (result_str) { + const result: ReKeyResponse = await wasm.parse_rekey_ttlv_response(result_str); + return `The symmetric key was successfully refreshed. New key: ${result.UniqueIdentifier}`; + } + }); + }; + + return ( +
+

Re-Key a symmetric key

+ +
+

Refresh an existing symmetric key, generating a new key value.

+
    +
  • The old key is deactivated and a new key is created as its replacement.
  • +
  • The rotation generation counter is incremented on the new key.
  • +
+
+ +
+ + + + + + + + + + + + + +
+ ); +}; + +export default KeysReKeyForm; diff --git a/ui/src/actions/Keys/SetRotationPolicy.tsx b/ui/src/actions/Keys/SetRotationPolicy.tsx new file mode 100644 index 0000000000..0c316bc4dd --- /dev/null +++ b/ui/src/actions/Keys/SetRotationPolicy.tsx @@ -0,0 +1,115 @@ +import { Button, Card, Form, Input, InputNumber, Space } from "antd"; +import React from "react"; +import { ActionResponse } from "../../components/common/ActionResponse"; +import { useActionState } from "../../hooks/useActionState"; +import { sendKmipRequest } from "../../utils/utils"; +import * as wasm from "../../wasm/pkg"; + +interface SetRotationPolicyFormData { + keyId: string; + interval: number; + offset?: number; + name?: string; +} + +const SetRotationPolicyForm: React.FC = () => { + const [form] = Form.useForm(); + const { res, isLoading, responseRef, idToken, serverUrl, execute } = useActionState(); + + const onFinish = async (values: SetRotationPolicyFormData) => { + await execute(async () => { + // Set the rotation interval (required) + const intervalRequest = wasm.set_rotate_interval_ttlv_request(values.keyId, BigInt(values.interval)); + const intervalResult = await sendKmipRequest(intervalRequest, idToken, serverUrl); + if (!intervalResult) return; + wasm.parse_set_attribute_ttlv_response(intervalResult); + + // Set the rotation offset (optional) + if (values.offset !== undefined && values.offset !== null) { + const offsetRequest = wasm.set_rotate_offset_ttlv_request(values.keyId, BigInt(values.offset)); + const offsetResult = await sendKmipRequest(offsetRequest, idToken, serverUrl); + if (!offsetResult) return; + wasm.parse_set_attribute_ttlv_response(offsetResult); + } + + // Set the rotation name (optional) + if (values.name) { + const nameRequest = wasm.set_rotate_name_ttlv_request(values.keyId, values.name); + const nameResult = await sendKmipRequest(nameRequest, idToken, serverUrl); + if (!nameResult) return; + wasm.parse_set_attribute_ttlv_response(nameResult); + } + + return "Rotation policy set successfully."; + }); + }; + + return ( +
+

Set Rotation Policy

+ +
+

Configure an automatic periodic rotation policy on a symmetric key.

+
    +
  • The interval defines how often (in seconds) the key is automatically rotated.
  • +
  • The offset defines the delay (in seconds) before activation of a newly rotated key.
  • +
  • The name assigns a keyset name for addressing key generations via name@version syntax.
  • +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +export default SetRotationPolicyForm; diff --git a/ui/src/menuItems.tsx b/ui/src/menuItems.tsx index 5ca9c3f46b..d43e284313 100644 --- a/ui/src/menuItems.tsx +++ b/ui/src/menuItems.tsx @@ -11,7 +11,6 @@ import { KeyOutlined, LockOutlined, SafetyCertificateOutlined, - SafetyOutlined, SearchOutlined, SolutionOutlined, TeamOutlined, From 0658072730208ad1a84450e7a613c6edf9aa6742 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sat, 6 Jun 2026 08:27:09 +0200 Subject: [PATCH 13/30] ci: fix rebase and tests --- CHANGELOG/docs_key-autorotation-spec.md | 1 + crate/server/src/routes/crypto/keys.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md index bce1ffa0a6..506a599b5c 100644 --- a/CHANGELOG/docs_key-autorotation-spec.md +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -86,6 +86,7 @@ - Add 3 negative ReCertify test vectors (missing UID, non-existent, not a certificate) ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 2 offset state verification vectors (rekey + rekey-keypair: Offset=0 → Active, Offset=86400 → PreActive) ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 2 negative state restriction vectors: `rekey_preactive_fails`, `rekey_keypair_preactive_fails` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix test vectors `rekey_keypair_preactive_fails` and `rsa_sign_verify_roundtrip`: `CryptographicUsageMask` must be in `PrivateKeyAttributes`/`PublicKeyAttributes`, not `CommonAttributes` — FIPS mode reads the mask only from per-key sections ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 7 keyset resolution test vectors: `keyset_encrypt_latest`, `keyset_encrypt_bare_name`, `keyset_encrypt_latest_after_rotation`, `keyset_decrypt_try_each`, `keyset_decrypt_double_rotation`, `keyset_decrypt_at_latest`, `keyset_rotate_name_at_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) - Add HSM keyset support: `find_due_for_rotation` on HsmStore reads `CKA_START_DATE`/`CKA_END_DATE`; keyset metadata stored in `CKA_LABEL` (`name::gen::key_id[::latest]`); `SetAttribute rotate_name` writes `CKA_LABEL` via `C_SetAttributeValue`; `SetAttribute rotate_interval` writes `CKA_START_DATE`/`CKA_END_DATE`; `Re-Key` on HSM UIDs generates a new HSM key and updates both CKA_LABELs; `walk_keyset_chain` resolves HSM keysets by `CKA_LABEL` without `ReplacedObjectLink` ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 3 HSM keyset test vectors: `resident_keyset_set_rotate_name`, `resident_keyset_rekey_and_decrypt`, `resident_keyset_double_rotation` + 1 negative: `hsm_rotate_offset_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) diff --git a/crate/server/src/routes/crypto/keys.rs b/crate/server/src/routes/crypto/keys.rs index 2ef0d54295..3d0a502396 100644 --- a/crate/server/src/routes/crypto/keys.rs +++ b/crate/server/src/routes/crypto/keys.rs @@ -513,7 +513,7 @@ async fn import_public_key_for_private( ) .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; - kms.import(import_req, user, None) + kms.import(import_req, user) .await .map_err(CryptoApiError::from)?; From 2a28bb15b1ce7cafa3738f3d1d11646a0b73da44 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sat, 6 Jun 2026 20:24:34 +0200 Subject: [PATCH 14/30] fix: wrap_and_cache must not attempt to wrap an HSM-resident key --- CHANGELOG/docs_key-autorotation-spec.md | 3 +- crate/server/src/core/wrapping/wrap.rs | 24 ++++++--- crate/test_kms_server/README.md | 1 + crate/test_kms_server/src/lib.rs | 3 ++ crate/test_kms_server/src/test_env.rs | 50 +++++++++++++++++++ crate/test_kms_server/src/test_server.rs | 46 +++++++++++++++++ crate/test_kms_server/src/vector_runner.rs | 58 ++++++++++++++++------ 7 files changed, 163 insertions(+), 22 deletions(-) create mode 100644 crate/test_kms_server/src/test_env.rs diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md index 506a599b5c..4e25490701 100644 --- a/CHANGELOG/docs_key-autorotation-spec.md +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -86,7 +86,8 @@ - Add 3 negative ReCertify test vectors (missing UID, non-existent, not a certificate) ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 2 offset state verification vectors (rekey + rekey-keypair: Offset=0 → Active, Offset=86400 → PreActive) ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 2 negative state restriction vectors: `rekey_preactive_fails`, `rekey_keypair_preactive_fails` ([#968](https://github.com/Cosmian/kms/pull/968)) -- Fix test vectors `rekey_keypair_preactive_fails` and `rsa_sign_verify_roundtrip`: `CryptographicUsageMask` must be in `PrivateKeyAttributes`/`PublicKeyAttributes`, not `CommonAttributes` — FIPS mode reads the mask only from per-key sections ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `wrap_and_cache`: skip server-wide KEK wrapping for HSM-resident keys (UID has `hsm::` prefix) — they are hardware-protected and the wrapping step caused a self-wrap error when the key being created IS the configured KEK ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add vector `test_data/vectors/hsm/kek_bootstrap_self_create` + `server_type = "hsm_kek_uncreated"` server type to reproduce and prevent regressions of the HSM self-wrap bug ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 7 keyset resolution test vectors: `keyset_encrypt_latest`, `keyset_encrypt_bare_name`, `keyset_encrypt_latest_after_rotation`, `keyset_decrypt_try_each`, `keyset_decrypt_double_rotation`, `keyset_decrypt_at_latest`, `keyset_rotate_name_at_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) - Add HSM keyset support: `find_due_for_rotation` on HsmStore reads `CKA_START_DATE`/`CKA_END_DATE`; keyset metadata stored in `CKA_LABEL` (`name::gen::key_id[::latest]`); `SetAttribute rotate_name` writes `CKA_LABEL` via `C_SetAttributeValue`; `SetAttribute rotate_interval` writes `CKA_START_DATE`/`CKA_END_DATE`; `Re-Key` on HSM UIDs generates a new HSM key and updates both CKA_LABELs; `walk_keyset_chain` resolves HSM keysets by `CKA_LABEL` without `ReplacedObjectLink` ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 3 HSM keyset test vectors: `resident_keyset_set_rotate_name`, `resident_keyset_rekey_and_decrypt`, `resident_keyset_double_rotation` + 1 negative: `hsm_rotate_offset_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) diff --git a/crate/server/src/core/wrapping/wrap.rs b/crate/server/src/core/wrapping/wrap.rs index 1683520714..ad48e02769 100644 --- a/crate/server/src/core/wrapping/wrap.rs +++ b/crate/server/src/core/wrapping/wrap.rs @@ -59,18 +59,28 @@ pub(crate) async fn wrap_and_cache( // or in the HSM. // Either the user has provided a wrapping key ID or a key wrapping key is // provided in the parameters. - let Some(wrapping_key_id) = object + let uid_str = unique_identifier.to_string(); + let explicit_wrapping_key_id = object .attributes_mut() .ok() - .and_then(|attrs| attrs.remove_wrapping_key_id(kms.vendor_id())) - .or_else(|| kms.params.key_wrapping_key.clone()) - else { - // no wrapping key provided - return Ok(()); + .and_then(|attrs| attrs.remove_wrapping_key_id(kms.vendor_id())); + let wrapping_key_id = if let Some(id) = explicit_wrapping_key_id { + id + } else { + let Some(kek) = kms.params.key_wrapping_key.clone() else { + // no wrapping key provided + return Ok(()); + }; + // HSM-resident keys are hardware-protected: skip the server-wide KEK wrapping + // to avoid creating a circular dependency where the KEK would wrap itself. + if has_prefix(&uid_str).is_some() { + return Ok(()); + } + kek }; // A key cannot be its own wrapping key. - if wrapping_key_id == unique_identifier.to_string() { + if wrapping_key_id == uid_str { 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" diff --git a/crate/test_kms_server/README.md b/crate/test_kms_server/README.md index 7d52136d70..c42d8d56f8 100644 --- a/crate/test_kms_server/README.md +++ b/crate/test_kms_server/README.md @@ -228,6 +228,7 @@ replays the steps sequentially. | HSM / KEK Create | `hsm/kek_ec_p256_create_sign` | CreateKeyPair (EC P-256, KEK-wrapped), Sign, Destroy ×2 | 3 | | HSM / KEK Create | `hsm/kek_ed25519_create_sign` | CreateKeyPair (Ed25519, KEK-wrapped), Sign, Destroy ×2 | 3 | | HSM / KEK ReKey | `hsm/kek_rekey_wrapped` | Create (AES-256, KEK-wrapped), Encrypt, ReKey (unwrap from KEK, new material, re-wrap), Encrypt new, GetAttributes | 9 | +| HSM / KEK Bootstrap | `hsm/kek_bootstrap_self_create` | **Regression**: Create KEK itself (self-wrap guard bypass), Create DEK, Encrypt, Decrypt, Revoke, Destroy DEK | 6 | | HSM / Resident Keyset | `hsm/resident_keyset_set_rotate_name` | Create (AES-256, HSM), SetAttribute rotate_name (writes CKA_LABEL), Encrypt by keyset name, Decrypt by UID | 4 | | HSM / Resident Keyset | `hsm/resident_keyset_rekey_and_decrypt` | Create, SetAttribute rotate_name, Encrypt (gen-0), ReKey, Decrypt by keyset name (chain: gen-1→gen-0) | 7 | | HSM / Resident Keyset | `hsm/resident_keyset_double_rotation` | Create, SetAttribute rotate_name, Encrypt (gen-0), ReKey ×2, Decrypt by keyset name (chain: gen-2→gen-1→gen-0) | 9 | diff --git a/crate/test_kms_server/src/lib.rs b/crate/test_kms_server/src/lib.rs index 1f2029a4e6..889cfc9dea 100644 --- a/crate/test_kms_server/src/lib.rs +++ b/crate/test_kms_server/src/lib.rs @@ -13,6 +13,7 @@ pub use test_server::{ start_default_test_kms_server_with_privileged_users, start_default_test_kms_server_with_softhsm2_and_kek, start_default_test_kms_server_with_softhsm2_and_kek_for_vectors, + start_default_test_kms_server_with_softhsm2_kek_uncreated_for_vectors, start_default_test_kms_server_with_three_softhsm2, start_default_test_kms_server_with_utimaco_and_kek, start_default_test_kms_server_with_utimaco_hsm, start_test_kms_server_with_config, @@ -24,6 +25,8 @@ mod test_server; mod test_jwt; +pub mod test_env; + pub mod vector_runner; use std::sync::Once; diff --git a/crate/test_kms_server/src/test_env.rs b/crate/test_kms_server/src/test_env.rs new file mode 100644 index 0000000000..a3216e95f7 --- /dev/null +++ b/crate/test_kms_server/src/test_env.rs @@ -0,0 +1,50 @@ +//! Safe in-process environment-variable overrides for test vectors. +//! +//! `std::env::set_var` is `unsafe` in Rust 1.87+ and is forbidden by this +//! crate's `deny(unsafe_code)` policy. This module provides a thread-safe +//! alternative that the vector runner consults **before** falling back to the +//! real process environment. +//! +//! Usage (server init): +//! ```rust,ignore +//! crate::test_env::set("HSM_BOOTSTRAP_KEK_ID", &kek_id); +//! ``` +//! +//! Usage (vector runner placeholder resolution): +//! ```rust,ignore +//! let value = crate::test_env::get("HSM_BOOTSTRAP_KEK_ID") +//! .or_else(|| std::env::var("HSM_BOOTSTRAP_KEK_ID").ok()); +//! ``` + +use std::{ + collections::HashMap, + sync::{OnceLock, PoisonError, RwLock}, +}; + +static OVERRIDES: OnceLock>> = OnceLock::new(); + +fn map() -> &'static RwLock> { + OVERRIDES.get_or_init(|| RwLock::new(HashMap::new())) +} + +/// Store an in-process env override under `key`. +/// +/// # Panics +/// Panics if the internal `RwLock` has been poisoned (should never happen in +/// a normal test run). +pub fn set(key: &str, value: &str) { + map() + .write() + .unwrap_or_else(PoisonError::into_inner) + .insert(key.to_owned(), value.to_owned()); +} + +/// Look up an in-process env override. Returns `None` if not set. +#[must_use] +pub fn get(key: &str) -> Option { + map() + .read() + .unwrap_or_else(PoisonError::into_inner) + .get(key) + .cloned() +} diff --git a/crate/test_kms_server/src/test_server.rs b/crate/test_kms_server/src/test_server.rs index bc9582c1df..b42c4dd0f4 100644 --- a/crate/test_kms_server/src/test_server.rs +++ b/crate/test_kms_server/src/test_server.rs @@ -589,6 +589,52 @@ pub async fn start_default_test_kms_server_with_softhsm2_and_kek_for_vectors() start_server_from_config(config, &config_path).await } +/// Start a `SoftHSM2` + KEK test server where the KEK has **not** been pre-created. +/// +/// This server type is used to reproduce the self-wrap regression (PR #968): +/// `wrap_and_cache` must not attempt to wrap an HSM-resident key with the +/// server-wide KEK, even when the key being created IS the KEK itself. +/// +/// Concretely, `key_encryption_key` is set to `"hsm::{slot}::kek_bootstrap"` +/// before the server starts. The first vector step creates that exact HSM key, +/// which would have triggered the self-wrap error prior to the fix. +/// +/// # Errors +/// Returns an error if the server fails to start. +/// +/// # Panics +/// Panics if `HSM_SLOT_ID` is not set or is not a valid `usize`. +pub async fn start_default_test_kms_server_with_softhsm2_kek_uncreated_for_vectors() +-> Result { + let slot = get_softhsm2_slot_id(); + let workspace_dir = std::env::temp_dir().join(format!( + "kms_test_softhsm2_kek_bootstrap_{}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let kek_id = format!("hsm::{slot}::kek_bootstrap_{}", std::process::id()); + // Export for {{$HSM_BOOTSTRAP_KEK_ID}} substitution in vector steps. + // Called once inside OnceCell initialisation before any vector steps run. + crate::test_env::set("HSM_BOOTSTRAP_KEK_ID", &kek_id); + + let config_path = hsm_config_path("hsm_softhsm2_kek.toml"); + let mut config = load_test_config_from_toml(&config_path)?; + config.hsm.hsm_slot = vec![slot]; + config.db.sqlite_path = workspace_dir.join("sqlite-data"); + config.workspace.root_data_path = workspace_dir.join("workspace"); + config.workspace.tmp_path = workspace_dir.join("tmp"); + config.key_encryption_key = Some(kek_id); + config.default_unwrap_type = Some(vec!["SecretData".to_owned(), "SymmetricKey".to_owned()]); + // Disable Google CSE: starting with an empty workspace means no Google CSE + // RSA keypair exists yet, and this test does not need that feature. + config.google_cse_config.google_cse_enable = false; + start_server_from_config(config, &config_path).await +} + /// Start a test KMS server with three `SoftHSM2` instances: /// /// - Slot 1 (`HSM_SLOT_ID_1`): legacy single-HSM config (`hsm:` top-level fields). diff --git a/crate/test_kms_server/src/vector_runner.rs b/crate/test_kms_server/src/vector_runner.rs index cb03f48a97..e4aca57984 100644 --- a/crate/test_kms_server/src/vector_runner.rs +++ b/crate/test_kms_server/src/vector_runner.rs @@ -30,6 +30,10 @@ static ONCE_VECTOR_CERT_AUTH: OnceCell = OnceCell::const_new(); static ONCE_VECTOR_AUTH_HTTPS: OnceCell = OnceCell::const_new(); /// Singleton server for vector tests requiring `SoftHSM2` + KEK. static ONCE_VECTOR_HSM_KEK: OnceCell = OnceCell::const_new(); +/// Singleton server for vector tests where the HSM KEK is configured but **not yet created**. +/// Used to verify that `wrap_and_cache` does not attempt to self-wrap when the first +/// operation creates the KEK itself (regression for PR #968 self-wrap bug). +static ONCE_VECTOR_HSM_KEK_UNCREATED: OnceCell = OnceCell::const_new(); /// A test vector manifest loaded from a TOML file. /// @@ -438,12 +442,14 @@ fn load_request_json( )) })?; let var_name = &rest[..end]; - let var_value = std::env::var(var_name).map_err(|_e| { - KmsClientError::UnexpectedError(format!( - "Environment variable '{var_name}' referenced in {} is not set", - path.display() - )) - })?; + let var_value = crate::test_env::get(var_name) + .or_else(|| std::env::var(var_name).ok()) + .ok_or_else(|| { + KmsClientError::UnexpectedError(format!( + "Environment variable '{var_name}' referenced in {} is not set", + path.display() + )) + })?; content = format!( "{}{var_value}{}", &content[..start], @@ -479,13 +485,15 @@ fn resolve_assertion_value( let rest = &result[start + 3..]; if let Some(end) = rest.find("}}") { let var_name = &rest[..end]; - let var_value = std::env::var(var_name).map_err(|_err| { - KmsClientError::UnexpectedError(format!( - "resolve_assertion_value: environment variable '{var_name}' \ - referenced in assertion template '{template}' is not set — \ - refusing to silently use an empty string" - )) - })?; + let var_value = crate::test_env::get(var_name) + .or_else(|| std::env::var(var_name).ok()) + .ok_or_else(|| { + KmsClientError::UnexpectedError(format!( + "resolve_assertion_value: environment variable '{var_name}' \ + referenced in assertion template '{template}' is not set — \ + refusing to silently use an empty string" + )) + })?; result = format!("{}{}{}", &result[..start], var_value, &rest[end + 2..]); } else { break; @@ -789,7 +797,7 @@ pub async fn run_test_vector(vector_dir: &str) -> Result<(), KmsClientError> { // Check required environment variables; skip gracefully if any is missing for env_var in &manifest.requires_env { - if std::env::var(env_var).is_err() { + if crate::test_env::get(env_var).is_none() && std::env::var(env_var).is_err() { eprintln!( "SKIP vector '{}': required env var '{env_var}' is not set", manifest.name @@ -814,6 +822,19 @@ pub async fn run_test_vector(vector_dir: &str) -> Result<(), KmsClientError> { ); return execute_steps(context, &manifest, &vector_path).await; } + "hsm_kek_uncreated" => { + let context = ONCE_VECTOR_HSM_KEK_UNCREATED + .get_or_try_init(|| async { + crate::start_default_test_kms_server_with_softhsm2_kek_uncreated_for_vectors() + .await + }) + .await?; + eprintln!( + "▶ Running vector '{}' on server_type 'hsm_kek_uncreated'", + manifest.name + ); + return execute_steps(context, &manifest, &vector_path).await; + } other => { return Err(KmsClientError::UnexpectedError(format!( "Unknown server_type '{other}' in manifest for vector '{}'", @@ -3807,6 +3828,15 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/hsm/kek_rekey_wrapped").await } + /// Regression test for the HSM self-wrap bug (PR #968): + /// `wrap_and_cache` must not attempt to wrap an HSM-resident key with the + /// server-wide KEK when the key being created IS the configured KEK UID. + #[tokio::test] + async fn test_vec_hsm_kek_bootstrap_self_create() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/kek_bootstrap_self_create").await + } + // ── HSM Resident: Keyset (rotate_name / CKA_LABEL) ─────────────────── #[tokio::test] From 2cceb3c1f6aeeede3371b2650f8b2cd789cff7f6 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 8 Jun 2026 12:59:40 +0200 Subject: [PATCH 15/30] test: add negative test on renewal of non latest key of a key-set --- CHANGELOG/docs_key-autorotation-spec.md | 3 ++ .../src/core/operations/rekey/keypair.rs | 39 ++++++++++++++++++- crate/test_kms_server/README.md | 1 + crate/test_kms_server/src/vector_runner.rs | 6 +++ .../server.vendor.static.sha256 | 2 +- 5 files changed, 48 insertions(+), 3 deletions(-) diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md index 4e25490701..a35b746873 100644 --- a/CHANGELOG/docs_key-autorotation-spec.md +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -29,6 +29,9 @@ ## Bug Fixes +- Fix `ReKeyKeyPair` not propagating `CryptographicUsageMask` from old key pair to the new `CreateKeyPair` request — causes FIPS-mode rejection (`got None but expected among 0x00103A01`) when rotating EC or RSA key pairs in a keyset +- Add non-regression test vector `negative/rekey_keypair_non_latest`: CreateKeyPair (EC P-256, FIPS masks), SetAttribute(RotateName), ReKeyKeyPair (gen-0→gen-1 succeeds), ReKeyKeyPair (gen-0 again) → "not the latest" error + - Fix KMIP lifecycle semantics: restore correct `setup_object_lifecycle` behavior — past `activation_date` → Active, `None` → PreActive ([#968](https://github.com/Cosmian/kms/pull/968)) - Add explicit `activation_date: Some(now)` to all request builders and test helpers requiring immediate Active state ([#968](https://github.com/Cosmian/kms/pull/968)) - Fix KMIP spec reference: `§4.7` → `§4.8` in `rekey/common.rs` ([#968](https://github.com/Cosmian/kms/pull/968)) diff --git a/crate/server/src/core/operations/rekey/keypair.rs b/crate/server/src/core/operations/rekey/keypair.rs index 23df67b3bc..bf07570c7e 100644 --- a/crate/server/src/core/operations/rekey/keypair.rs +++ b/crate/server/src/core/operations/rekey/keypair.rs @@ -253,10 +253,45 @@ impl RekeyOperation for KeypairRekey { let new_sk_uid = compute_rotation_uid(&sk_candidate.uid); let new_pk_uid = compute_rotation_uid(&pk_candidate.uid); + // Propagate the CryptographicUsageMask from the old keys so that + // FIPS-mode key-pair generators receive the required mask value. + let sk_mask = sk_candidate + .owm + .attributes() + .cryptographic_usage_mask + .or_else(|| { + sk_candidate + .owm + .object() + .attributes() + .ok() + .and_then(|a| a.cryptographic_usage_mask) + }); + let pk_mask = pk_candidate + .owm + .attributes() + .cryptographic_usage_mask + .or_else(|| { + pk_candidate + .owm + .object() + .attributes() + .ok() + .and_then(|a| a.cryptographic_usage_mask) + }); + let private_key_attributes = sk_mask.map(|m| Attributes { + cryptographic_usage_mask: Some(m), + ..Attributes::default() + }); + let public_key_attributes = pk_mask.map(|m| Attributes { + cryptographic_usage_mask: Some(m), + ..Attributes::default() + }); + let create_kp_request = CreateKeyPair { common_attributes: Some(common_attrs), - private_key_attributes: None, - public_key_attributes: None, + private_key_attributes, + public_key_attributes, common_protection_storage_masks: None, private_protection_storage_masks: None, public_protection_storage_masks: None, diff --git a/crate/test_kms_server/README.md b/crate/test_kms_server/README.md index c42d8d56f8..2b9527c298 100644 --- a/crate/test_kms_server/README.md +++ b/crate/test_kms_server/README.md @@ -340,6 +340,7 @@ replays the steps sequentially. | KMIP Operations | `rekey_wrapped_deactivated_succeeds` | Create wrapping key + wrapped key, Revoke wrapped, ReKey → succeeds (KMIP §6.1.46) | 10 | | Negative / ReKey | `negative/rekey_preactive_fails` | Create (no ActivationDate → PreActive), ReKey → fails (not Active or Deactivated) | 4 | | Negative / ReKeyKeyPair | `negative/rekey_keypair_preactive_fails` | CreateKeyPair (no ActivationDate → PreActive), ReKeyKeyPair → fails (not Active or Deactivated) | 5 | +| Negative / ReKeyKeyPair | `negative/rekey_keypair_non_latest` | CreateKeyPair (EC P-256), SetAttribute(RotateName), ReKeyKeyPair (gen-0→gen-1), ReKeyKeyPair(gen-0 again) → "not the latest" error | 8 | | **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 e4aca57984..f2092e0415 100644 --- a/crate/test_kms_server/src/vector_runner.rs +++ b/crate/test_kms_server/src/vector_runner.rs @@ -3441,6 +3441,12 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/negative/rekey_non_latest_hsm").await } + #[tokio::test] + async fn test_neg_rekey_keypair_non_latest() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/rekey_keypair_non_latest").await + } + // ── KMIP operations: ReKeyKeyPair (non-FIPS only) ──────────────────── // These vectors do not supply PrivateKeyAttributes/PublicKeyAttributes with // FIPS-compliant CryptographicUsageMask values, and some use PQC algorithms diff --git a/nix/expected-hashes/server.vendor.static.sha256 b/nix/expected-hashes/server.vendor.static.sha256 index 986451d83f..dae1d3abcd 100644 --- a/nix/expected-hashes/server.vendor.static.sha256 +++ b/nix/expected-hashes/server.vendor.static.sha256 @@ -1 +1 @@ -sha256-ZzuWLgecB3Hpz1TyDw0/ew6dihrrkdNP3iq+Ts8WEHw= +sha256-yYRnTMwn51PTfHQ0wSeOWEsNekRl7Ndde3oxTfhZlHE= From 1f221b326bda68332325f6f7ffea0d268e4597a5 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Tue, 9 Jun 2026 11:10:17 +0200 Subject: [PATCH 16/30] fix: test on CRL check --- CHANGELOG/docs_key-autorotation-spec.md | 6 ++++++ crate/test_kms_server/src/vector_runner.rs | 14 ++++++++++++++ nix/expected-hashes/server.vendor.static.sha256 | 2 +- nix/expected-hashes/ui.vendor.non-fips.sha256 | 2 +- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md index a35b746873..2764da6c83 100644 --- a/CHANGELOG/docs_key-autorotation-spec.md +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -1,3 +1,9 @@ +## Bug Fixes + +- Fix MySQL test race: vector MySQL tests reuse the shared `ONCE` server when `KMS_TEST_DB=mysql`, preventing two servers with `clear_database=true` from wiping each other's data ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `test_neg_recertify_nonexistent` vector: update `assert_error_contains` to match the actual "object not found" error now that `ReCertify` is fully implemented ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `test_certify_a_csr_with_extensions` and `test_certify_a_public_key_test_with_extensions`: update `[v3_ca]` CRL URL in `test_data/certificates/openssl/ext.cnf` to match what the test expects (`https://package.cosmian.com/kms/crl_tests/intermediate.crl.pem`) ([#968](https://github.com/Cosmian/kms/pull/968)) + ## Features - Implement KMIP ReKey operation for symmetric keys with name transfer per §4.4 ([#968](https://github.com/Cosmian/kms/pull/968)) diff --git a/crate/test_kms_server/src/vector_runner.rs b/crate/test_kms_server/src/vector_runner.rs index f2092e0415..9bc793bbcd 100644 --- a/crate/test_kms_server/src/vector_runner.rs +++ b/crate/test_kms_server/src/vector_runner.rs @@ -731,6 +731,20 @@ fn backend_available(backend: &str) -> bool { /// Get or initialize a singleton test server for the given backend. async fn get_or_init_vector_server(backend: &str) -> Result<&'static TestsContext, KmsClientError> { + // When `KMS_TEST_DB` names the same backend as the requested vector backend, + // reuse the shared default server (`ONCE`) rather than starting a second + // server against the same database. Two independent servers each configured + // with `clear_database = true` pointing at the same DB would race: whichever + // initialises second wipes out objects that the other has already written, + // causing non-deterministic "object not found" failures in the certify tests. + let effective_kms_db = std::env::var("KMS_TEST_DB").ok().map(|v| match v.as_str() { + "redis" => "redis-findex".to_owned(), + other => other.to_owned(), + }); + if effective_kms_db.as_deref() == Some(backend) { + return Ok(crate::start_default_test_kms_server().await); + } + let root = repo_root()?; let (cell, toml, env_var) = match backend { "postgresql" => (&ONCE_VECTOR_POSTGRESQL, "postgres.toml", "KMS_POSTGRES_URL"), diff --git a/nix/expected-hashes/server.vendor.static.sha256 b/nix/expected-hashes/server.vendor.static.sha256 index dae1d3abcd..1efd4c340b 100644 --- a/nix/expected-hashes/server.vendor.static.sha256 +++ b/nix/expected-hashes/server.vendor.static.sha256 @@ -1 +1 @@ -sha256-yYRnTMwn51PTfHQ0wSeOWEsNekRl7Ndde3oxTfhZlHE= +sha256-AvlJ+zb8blhGfSzDFgcbqRmt0Dmr6Qd9/BeoeW95Adk= diff --git a/nix/expected-hashes/ui.vendor.non-fips.sha256 b/nix/expected-hashes/ui.vendor.non-fips.sha256 index 81b379abb6..a6812d6d3e 100644 --- a/nix/expected-hashes/ui.vendor.non-fips.sha256 +++ b/nix/expected-hashes/ui.vendor.non-fips.sha256 @@ -1 +1 @@ -sha256-/C3v8txjB8P8mRbYkcT8hbBRapzB4cdbTBdmZFsphlo= +sha256-a86nwCScbRnuReokrCoKEUYrozxAwD+RdZJ3Dxc3i+Q= From 31ffeed6155d60d4a38f90cca0f51fbdb7a90748 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Tue, 9 Jun 2026 12:00:23 +0200 Subject: [PATCH 17/30] chore: update Nix expected hashes --- nix/expected-hashes/ui.vendor.fips.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/expected-hashes/ui.vendor.fips.sha256 b/nix/expected-hashes/ui.vendor.fips.sha256 index d1b16e798a..d917550964 100644 --- a/nix/expected-hashes/ui.vendor.fips.sha256 +++ b/nix/expected-hashes/ui.vendor.fips.sha256 @@ -1 +1 @@ -sha256-UUy49PEGv1Xe60YAxUD8rbPnIO0B08CpiHL9vClfXjY= +sha256-2ORxwjjjlNtQdYpgs6Q3n4JRN4E1eHkMfdw4OIgJSIM= From 3479b855610d533a8f19e91444c26e56f9890129 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Wed, 10 Jun 2026 15:52:18 +0200 Subject: [PATCH 18/30] docs: update doc --- .../docs/kmip_support/key_auto_rotation.md | 68 ++----------------- 1 file changed, 4 insertions(+), 64 deletions(-) diff --git a/documentation/docs/kmip_support/key_auto_rotation.md b/documentation/docs/kmip_support/key_auto_rotation.md index 00e5bfd975..4bb5e88c4c 100644 --- a/documentation/docs/kmip_support/key_auto_rotation.md +++ b/documentation/docs/kmip_support/key_auto_rotation.md @@ -53,7 +53,7 @@ CLI command) to configure the mutable attributes on an existing key. ckms sym keys set-rotation-policy \ --key-id \ --interval 3600 \ - --name "hourly" + --name "my-key-set" ``` --- @@ -594,7 +594,7 @@ Setting a rotation policy on a wrapped key is identical to a plain key: ckms sym keys set-rotation-policy \ --key-id \ --interval 3600 \ - --name "hourly" + --name "my-key-set" ``` The `SetAttribute` call succeeds even when the target key is wrapped (the @@ -636,7 +636,7 @@ ckms sym keys set-rotation-policy \ --key-id \ --interval 3600 \ --offset 60 \ - --name "hourly" + --name "my-key-set" ``` ### Step 2 — Enable the server scheduler @@ -759,65 +759,5 @@ rotation policy for the new key rather than blindly inheriting the old schedule. ckms sym keys set-rotation-policy \ --key-id \ --interval 3600 \ - --name "hourly" + --name "my-key-set" ``` - ---- - -## 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 From dcc5aa5f2230503140c5e2a59678508e1b2df5c3 Mon Sep 17 00:00:00 2001 From: Manuthor <32013169+Manuthor@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:22:25 +0200 Subject: [PATCH 19/30] [auto-rotation feature] PR 2 - ckms and Web UI updates (#988) * docs: add key auto-rotation specification Add comprehensive specification for scheduled key rotation covering: - 6 rotation scenarios (plain, wrapping, wrapped, asymmetric, CoverCrypt, KEK) - Rotation policy vendor attributes (x-rotate-interval, etc.) - Server-side cron scheduler - KMIP attribute tables (auto vs manual rotation semantics) - Implementation roadmap (5 stacked PRs) Ref: #900 * fix: build * chore: update Nix expected hashes --- .github/reusable_scripts | 2 +- CHANGELOG/docs_key-autorotation-spec.md | 6 - CHANGELOG/feat_key-rotation-ckms-ui.md | 42 +++ .../ckms/src/tests/elliptic_curve/mod.rs | 2 + .../tests/elliptic_curve/rotation_policy.rs | 79 ++++ crate/clients/ckms/src/tests/pqc/mod.rs | 2 + .../ckms/src/tests/pqc/rotation_policy.rs | 96 +++++ crate/clients/ckms/src/tests/rsa/mod.rs | 2 + .../ckms/src/tests/rsa/rotation_policy.rs | 92 +++++ .../src/tests/symmetric/rotation_policy.rs | 103 +++++- .../elliptic_curves/keys/create_key_pair.rs | 14 +- .../src/actions/elliptic_curves/keys/mod.rs | 15 +- .../src/actions/pqc/keys/create_key_pair.rs | 15 +- .../clients/clap/src/actions/pqc/keys/mod.rs | 15 +- .../src/actions/rsa/keys/create_key_pair.rs | 15 +- .../clients/clap/src/actions/rsa/keys/mod.rs | 15 +- .../src/actions/shared/get_rotation_policy.rs | 65 ++++ crate/clients/clap/src/actions/shared/mod.rs | 8 + .../clap/src/actions/shared/rekey_keypair.rs | 38 ++ .../actions/shared/rotation_policy_args.rs | 79 ++++ .../src/actions/shared/set_rotation_policy.rs | 87 +++++ .../src/actions/symmetric/keys/create_key.rs | 14 +- .../clap/src/actions/symmetric/keys/mod.rs | 10 +- crate/clients/client/src/http_client/login.rs | 37 +- .../client_utils/src/attributes_utils.rs | 53 ++- .../client_utils/src/certificate_utils.rs | 61 ++- crate/clients/wasm/src/wasm.rs | 58 ++- .../src/core/operations/rekey/symmetric.rs | 12 + crate/server/src/start_kms_server.rs | 1 + test_data | 2 +- ui/src/App.tsx | 30 +- .../Certificates/CertificateCertify.tsx | 24 +- ui/src/actions/EC/ECKeysCreate.tsx | 49 ++- ui/src/actions/EC/ECReKey.tsx | 75 ++++ ui/src/actions/Keys/KeysReKey.tsx | 6 +- ui/src/actions/Keys/SymKeysCreate.tsx | 49 ++- ui/src/actions/PQC/PqcKeysCreate.tsx | 49 ++- ui/src/actions/PQC/PqcReKey.tsx | 75 ++++ ui/src/actions/RSA/RsaKeysCreate.tsx | 50 ++- ui/src/actions/RSA/RsaReKey.tsx | 75 ++++ .../GetRotationPolicy.tsx | 26 +- .../SetRotationPolicy.tsx | 40 +- ui/src/components/common/Locate.tsx | 72 ++-- ui/src/menuItems.tsx | 71 +++- ui/tests/e2e/README.md | 40 ++ ui/tests/e2e/certificates-certify.spec.ts | 7 +- ui/tests/e2e/rotation-policy.spec.ts | 348 ++++++++++++++++++ 47 files changed, 1972 insertions(+), 154 deletions(-) create mode 100644 CHANGELOG/feat_key-rotation-ckms-ui.md create mode 100644 crate/clients/ckms/src/tests/elliptic_curve/rotation_policy.rs create mode 100644 crate/clients/ckms/src/tests/pqc/rotation_policy.rs create mode 100644 crate/clients/ckms/src/tests/rsa/rotation_policy.rs create mode 100644 crate/clients/clap/src/actions/shared/get_rotation_policy.rs create mode 100644 crate/clients/clap/src/actions/shared/rekey_keypair.rs create mode 100644 crate/clients/clap/src/actions/shared/rotation_policy_args.rs create mode 100644 crate/clients/clap/src/actions/shared/set_rotation_policy.rs create mode 100644 ui/src/actions/EC/ECReKey.tsx create mode 100644 ui/src/actions/PQC/PqcReKey.tsx create mode 100644 ui/src/actions/RSA/RsaReKey.tsx rename ui/src/actions/{Keys => RotationPolicy}/GetRotationPolicy.tsx (79%) rename ui/src/actions/{Keys => RotationPolicy}/SetRotationPolicy.tsx (71%) create mode 100644 ui/tests/e2e/rotation-policy.spec.ts diff --git a/.github/reusable_scripts b/.github/reusable_scripts index 27958a96a0..5216e05f11 160000 --- a/.github/reusable_scripts +++ b/.github/reusable_scripts @@ -1 +1 @@ -Subproject commit 27958a96a092ebb9d5340fddd5b5f72095a8e009 +Subproject commit 5216e05f11e37c472d75dac40818ea9e02c857dc diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md index 2764da6c83..a35b746873 100644 --- a/CHANGELOG/docs_key-autorotation-spec.md +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -1,9 +1,3 @@ -## Bug Fixes - -- Fix MySQL test race: vector MySQL tests reuse the shared `ONCE` server when `KMS_TEST_DB=mysql`, preventing two servers with `clear_database=true` from wiping each other's data ([#968](https://github.com/Cosmian/kms/pull/968)) -- Fix `test_neg_recertify_nonexistent` vector: update `assert_error_contains` to match the actual "object not found" error now that `ReCertify` is fully implemented ([#968](https://github.com/Cosmian/kms/pull/968)) -- Fix `test_certify_a_csr_with_extensions` and `test_certify_a_public_key_test_with_extensions`: update `[v3_ca]` CRL URL in `test_data/certificates/openssl/ext.cnf` to match what the test expects (`https://package.cosmian.com/kms/crl_tests/intermediate.crl.pem`) ([#968](https://github.com/Cosmian/kms/pull/968)) - ## Features - Implement KMIP ReKey operation for symmetric keys with name transfer per §4.4 ([#968](https://github.com/Cosmian/kms/pull/968)) diff --git a/CHANGELOG/feat_key-rotation-ckms-ui.md b/CHANGELOG/feat_key-rotation-ckms-ui.md new file mode 100644 index 0000000000..75c7ec88a2 --- /dev/null +++ b/CHANGELOG/feat_key-rotation-ckms-ui.md @@ -0,0 +1,42 @@ +# CHANGELOG — feat/key-rotation-ckms-ui + +## Bug Fixes (E2E) + +- Fix `rotation-policy.spec.ts`: AntD v5 `InputNumber` (rc-input-number 9.5) passes extra props including `data-testid` directly to the inner `` element; remove the incorrect `input` child combinator from locators ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Features + +- Add `--rotation-name`, `--rotation-interval`, `--rotation-offset` flags to `ckms sym keys create`, `ckms ec keys create`, `ckms rsa keys create`, and `ckms pqc keys create` — rotation policy is applied via `SetAttribute` immediately after key creation +- Add shared `RotationPolicyArgs` clap struct (`crate/clients/clap/src/actions/shared/rotation_policy_args.rs`) reused across all four create actions +- Add **Rotation Policy** section (Rotation Name / Interval / Offset fields) to `SymKeysCreate`, `ECKeysCreate`, `RsaKeysCreate`, `PqcKeysCreate` UI pages — policy is applied via WASM `set_rotate_*` calls after key creation + +- Add standalone **Rotation Policy** top-level menu item in the Web UI sidebar that regroups Set/Get Rotation Policy pages for all 4 key types (Symmetric, RSA, EC, PQC) under `/ui/rotation-policy/{sym,rsa,ec,pqc}/{set,get}`; remove the Set/Get Rotation Policy entries from each per-key-type Keys submenu ([#968](https://github.com/Cosmian/kms/pull/968)) +- In FIPS mode, the PQC child is automatically hidden from the Rotation Policy menu ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Testing + +- Add Playwright E2E tests for key rotation policy (sym, RSA, EC, PQC): set-rotation-policy, get-rotation-policy, re-key — `ui/tests/e2e/rotation-policy.spec.ts` +- Add HSM KEK self-wrap regression test vector `test_data/vectors/hsm/kek_bootstrap_self_create/` with 6 TTLV-JSON steps covering AES-256 KEK bootstrap, DEK lifecycle and AES-GCM roundtrip +- Add `crate/test_kms_server/src/test_env.rs` for safe in-process environment variable overrides (avoids `unsafe set_var` in Rust 1.87+) +- Register `hsm_kek_uncreated` server type and `ONCE_VECTOR_HSM_KEK_UNCREATED` OnceCell in vector runner + +## Bug Fixes + +- Fix RSA and EC `ReKeyKeyPair` in FIPS mode: `generate_replacement` now carries the `cryptographic_usage_mask` from the old private/public key into `private_key_attributes`/`public_key_attributes` of the `CreateKeyPair` request so that the FIPS compliance check (`got None but expected among 0x...`) no longer fails ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix HSM self-wrap: server-wide KEK wrapping now skips keys whose UID starts with `hsm::` prefix, preventing infinite recursion when the KEK itself is created on the HSM — `crate/server/src/core/wrapping/wrap.rs` +- Fix OAuth2 login redirect server not stopping after the first callback, causing TCP TIME_WAIT port conflicts when the test suite is run multiple times in quick succession — `crate/clients/client/src/http_client/login.rs` +- Fix CLI documentation: `--name` → `--rotation-name` in all examples in `key_auto_rotation.md` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Move `SetRotationPolicyAction` and `GetRotationPolicyAction` to `shared/` module and wire into RSA, EC, and PQC key subcommands ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add shared `ReKeyKeyPairAction` in CLI and wire `ckms rsa/ec/pqc keys re-key` subcommands ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix **Search Objects** Date column: `activation_date`, `initial_date`, `original_creation_date`, `rotate_date` were serialized as seconds; now serialized as milliseconds so `formatUnixDate` receives the correct epoch value ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix **Search Objects** missing rotate_* attributes: add `rotate_name`, `rotate_interval`, `rotate_offset`, `rotate_generation`, `rotate_latest` match arms in `parse_selected_attributes_flatten` and include all date/rotate keys in the `ENRICH_ATTRIBUTE_KEYS` constant used by all `enrichUids` calls in `Locate.tsx` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add WASM binding `rekey_keypair_ttlv_request` / `parse_rekey_keypair_ttlv_response` for asymmetric key rotation ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add Web UI pages for Re-Key, Set Rotation Policy, Get Rotation Policy under RSA, EC, and PQC key sections ([#968](https://github.com/Cosmian/kms/pull/968)) +- Consolidate 8 per-key-type rotation policy components (Set×4 + Get×4 for sym/rsa/ec/pqc) into 2 generic reusable components `ui/src/actions/RotationPolicy/SetRotationPolicy.tsx` and `GetRotationPolicy.tsx` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix Web UI Certificate Issuance page Option 3 (Certificate ID to Re-certify) to call the dedicated KMIP `ReCertify` operation (new UID + replacement links) instead of `Certify` (in-place upsert); add `build_re_certify_request` in `client_utils`, `re_certify_ttlv_request`/`parse_re_certify_ttlv_response` WASM bindings ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add E2E CLI tests for rotation policy on symmetric, RSA, EC key types (`test_keyset_workflow`, `test_rekey_non_latest_rejected`, `test_rsa_set_and_get_rotation_policy`, `test_ec_set_and_get_rotation_policy`) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add E2E CLI tests for `re-key` on RSA, EC, PQC key pairs (`test_rsa_rekey`, `test_ec_rekey`, `test_pqc_rekey`, `test_pqc_set_and_get_rotation_policy`) ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Documentation + +- Reorder implementation roadmap in `key_auto_rotation.md`: UI/CLI features become PR 2, notifications stay PR 3, auto-rotation scheduler becomes PR 4; close GitHub PR #973 (superseded), update PR #970 and #971 titles/descriptions diff --git a/crate/clients/ckms/src/tests/elliptic_curve/mod.rs b/crate/clients/ckms/src/tests/elliptic_curve/mod.rs index 819c5c4373..afe90fdb15 100644 --- a/crate/clients/ckms/src/tests/elliptic_curve/mod.rs +++ b/crate/clients/ckms/src/tests/elliptic_curve/mod.rs @@ -3,6 +3,8 @@ pub(crate) mod create_key_pair; #[cfg(feature = "non-fips")] pub(crate) mod encrypt_decrypt; #[cfg(feature = "non-fips")] +pub(crate) mod rotation_policy; +#[cfg(feature = "non-fips")] pub(crate) mod sign_verify; #[cfg(feature = "non-fips")] diff --git a/crate/clients/ckms/src/tests/elliptic_curve/rotation_policy.rs b/crate/clients/ckms/src/tests/elliptic_curve/rotation_policy.rs new file mode 100644 index 0000000000..ff021af2c9 --- /dev/null +++ b/crate/clients/ckms/src/tests/elliptic_curve/rotation_policy.rs @@ -0,0 +1,79 @@ +use test_kms_server::start_default_test_kms_server; + +use super::create_key_pair::create_ec_key_pair; +use crate::{ + error::result::CosmianResult, + tests::utils::{owner_config, run_ckms}, +}; + +#[tokio::test] +pub(crate) async fn test_ec_set_and_get_rotation_policy() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create an EC key pair (P-256) + let (private_key_id, _public_key_id) = + create_ec_key_pair(&owner_client_conf_path, "nist-p256", &[], false)?; + + // Set rotation policy on the private key with interval and name + let args = vec![ + "ec", + "keys", + "set-rotation-policy", + "--key-id", + &private_key_id, + "--interval", + "172800", + "--rotation-name", + "ec-keyset", + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("Rotation policy set successfully"), + "expected success message in: {output}" + ); + + // Get rotation policy and verify + let args = vec![ + "ec", + "keys", + "get-rotation-policy", + "--key-id", + &private_key_id, + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("172800"), + "expected interval=172800 in: {output}" + ); + assert!( + output.contains("ec-keyset"), + "expected name=ec-keyset in: {output}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_ec_rekey() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create an EC key pair (P-256) + let (private_key_id, _public_key_id) = + create_ec_key_pair(&owner_client_conf_path, "nist-p256", &[], false)?; + + // Re-Key the EC key pair + let args = vec!["ec", "keys", "re-key", "--key-id", &private_key_id]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("rotated"), + "expected 'rotated' in: {output}" + ); + assert!( + output.contains("Unique identifier"), + "expected new UID in: {output}" + ); + + Ok(()) +} diff --git a/crate/clients/ckms/src/tests/pqc/mod.rs b/crate/clients/ckms/src/tests/pqc/mod.rs index b580e00a15..35aead08db 100644 --- a/crate/clients/ckms/src/tests/pqc/mod.rs +++ b/crate/clients/ckms/src/tests/pqc/mod.rs @@ -1,3 +1,5 @@ +mod rotation_policy; + use std::{path::Path, process::Command}; use assert_cmd::prelude::*; diff --git a/crate/clients/ckms/src/tests/pqc/rotation_policy.rs b/crate/clients/ckms/src/tests/pqc/rotation_policy.rs new file mode 100644 index 0000000000..86faecf734 --- /dev/null +++ b/crate/clients/ckms/src/tests/pqc/rotation_policy.rs @@ -0,0 +1,96 @@ +use test_kms_server::start_default_test_kms_server; + +use crate::{ + error::result::CosmianResult, + tests::utils::{owner_config, run_ckms}, +}; + +/// Create a ML-KEM key pair using `run_ckms` and return (`private_key_id`, `public_key_id`) +fn create_ml_kem_key_pair(cli_conf_path: &str) -> CosmianResult<(String, String)> { + let args = vec!["pqc", "keys", "create", "--algorithm", "ml-kem-768"]; + let output = run_ckms(cli_conf_path, &args)?; + // Parse "Private key unique identifier: xxx" + let sk_id = output + .lines() + .find(|l| l.contains("Private key unique identifier")) + .and_then(|l| l.split(':').next_back()) + .map(|s| s.trim().to_owned()) + .unwrap_or_default(); + let pk_id = output + .lines() + .find(|l| l.contains("Public key unique identifier")) + .and_then(|l| l.split(':').next_back()) + .map(|s| s.trim().to_owned()) + .unwrap_or_default(); + Ok((sk_id, pk_id)) +} + +#[tokio::test] +pub(crate) async fn test_pqc_set_and_get_rotation_policy() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a ML-KEM key pair + let (private_key_id, _public_key_id) = create_ml_kem_key_pair(&owner_client_conf_path)?; + + // Set rotation policy + let args = vec![ + "pqc", + "keys", + "set-rotation-policy", + "--key-id", + &private_key_id, + "--interval", + "259200", + "--rotation-name", + "pqc-keyset", + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("Rotation policy set successfully"), + "expected success message in: {output}" + ); + + // Get rotation policy and verify + let args = vec![ + "pqc", + "keys", + "get-rotation-policy", + "--key-id", + &private_key_id, + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("259200"), + "expected interval=259200 in: {output}" + ); + assert!( + output.contains("pqc-keyset"), + "expected name=pqc-keyset in: {output}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_pqc_rekey() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a ML-KEM key pair + let (private_key_id, _public_key_id) = create_ml_kem_key_pair(&owner_client_conf_path)?; + + // Re-Key the PQC key pair + let args = vec!["pqc", "keys", "re-key", "--key-id", &private_key_id]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("rotated"), + "expected 'rotated' in: {output}" + ); + assert!( + output.contains("Unique identifier"), + "expected new UID in: {output}" + ); + + Ok(()) +} diff --git a/crate/clients/ckms/src/tests/rsa/mod.rs b/crate/clients/ckms/src/tests/rsa/mod.rs index 54b60cb321..459f37ed20 100644 --- a/crate/clients/ckms/src/tests/rsa/mod.rs +++ b/crate/clients/ckms/src/tests/rsa/mod.rs @@ -3,6 +3,8 @@ pub(crate) mod create_key_pair; #[cfg(feature = "non-fips")] pub(crate) mod encrypt_decrypt; #[cfg(feature = "non-fips")] +pub(crate) mod rotation_policy; +#[cfg(feature = "non-fips")] pub(crate) mod sign_verify; #[cfg(feature = "non-fips")] diff --git a/crate/clients/ckms/src/tests/rsa/rotation_policy.rs b/crate/clients/ckms/src/tests/rsa/rotation_policy.rs new file mode 100644 index 0000000000..349da47842 --- /dev/null +++ b/crate/clients/ckms/src/tests/rsa/rotation_policy.rs @@ -0,0 +1,92 @@ +use test_kms_server::start_default_test_kms_server; + +use super::create_key_pair::{RsaKeyPairOptions, create_rsa_key_pair}; +use crate::{ + error::result::CosmianResult, + tests::utils::{owner_config, run_ckms}, +}; + +#[tokio::test] +pub(crate) async fn test_rsa_set_and_get_rotation_policy() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create an RSA key pair + let (private_key_id, _public_key_id) = create_rsa_key_pair( + &owner_client_conf_path, + &RsaKeyPairOptions { + number_of_bits: Some(2048), + ..Default::default() + }, + )?; + + // Set rotation policy on the private key with interval, offset, and name + let args = vec![ + "rsa", + "keys", + "set-rotation-policy", + "--key-id", + &private_key_id, + "--interval", + "86400", + "--offset", + "7200", + "--rotation-name", + "rsa-keyset", + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("Rotation policy set successfully"), + "expected success message in: {output}" + ); + + // Get rotation policy and verify + let args = vec![ + "rsa", + "keys", + "get-rotation-policy", + "--key-id", + &private_key_id, + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("86400"), + "expected interval=86400 in: {output}" + ); + assert!(output.contains("7200"), "expected offset=7200 in: {output}"); + assert!( + output.contains("rsa-keyset"), + "expected name=rsa-keyset in: {output}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_rsa_rekey() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create an RSA key pair + let (private_key_id, _public_key_id) = create_rsa_key_pair( + &owner_client_conf_path, + &RsaKeyPairOptions { + number_of_bits: Some(2048), + ..Default::default() + }, + )?; + + // Re-Key the RSA key pair + let args = vec!["rsa", "keys", "re-key", "--key-id", &private_key_id]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("rotated"), + "expected 'rotated' in: {output}" + ); + assert!( + output.contains("Unique identifier"), + "expected new UID in: {output}" + ); + + Ok(()) +} diff --git a/crate/clients/ckms/src/tests/symmetric/rotation_policy.rs b/crate/clients/ckms/src/tests/symmetric/rotation_policy.rs index e78f7b6f67..47c6cf87e7 100644 --- a/crate/clients/ckms/src/tests/symmetric/rotation_policy.rs +++ b/crate/clients/ckms/src/tests/symmetric/rotation_policy.rs @@ -1,9 +1,17 @@ +use std::fs; + +use cosmian_kms_cli_actions::reexport::cosmian_kms_client::reexport::cosmian_kms_client_utils::symmetric_utils::DataEncryptionAlgorithm; +use tempfile::TempDir; use test_kms_server::start_default_test_kms_server; use crate::{ error::result::CosmianResult, tests::{ - symmetric::create_key::create_symmetric_key, + symmetric::{ + create_key::create_symmetric_key, + encrypt_decrypt::{decrypt, encrypt}, + rekey::rekey_symmetric_key, + }, utils::{owner_config, run_ckms, run_ckms_expect_error}, }, }; @@ -123,3 +131,96 @@ pub(crate) async fn test_set_rotation_policy_interval_only() -> CosmianResult<() Ok(()) } + +/// Full keyset workflow: create → set name → encrypt → rekey → decrypt via keyset name +#[tokio::test] +pub(crate) async fn test_keyset_workflow() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create AES-256 key + let key_id = create_symmetric_key(&owner_client_conf_path, &["--number-of-bits", "256"])?; + + // Set rotation name (keyset) without interval + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--rotation-name", + "e2e-keyset", + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!(output.contains("Rotation policy set successfully")); + + // Encrypt a file using the keyset bare name + let tmp_dir = TempDir::new()?; + let input_file = tmp_dir.path().join("plain.txt"); + fs::write(&input_file, b"hello keyset rotation")?; + let encrypted_file = tmp_dir.path().join("cipher.enc"); + + encrypt( + &owner_client_conf_path, + input_file.to_str().unwrap(), + "e2e-keyset", + DataEncryptionAlgorithm::AesGcm, + None, + Some(encrypted_file.to_str().unwrap()), + None, + )?; + + // ReKey: gen-0 → gen-1 + let _new_key_id = rekey_symmetric_key(&owner_client_conf_path, &key_id)?; + + // Decrypt via keyset bare name (should walk chain and find gen-0) + let decrypted_file = tmp_dir.path().join("decrypted.txt"); + decrypt( + &owner_client_conf_path, + encrypted_file.to_str().unwrap(), + "e2e-keyset", + DataEncryptionAlgorithm::AesGcm, + None, + Some(decrypted_file.to_str().unwrap()), + None, + )?; + let decrypted = fs::read(&decrypted_file)?; + assert_eq!(decrypted, b"hello keyset rotation"); + + Ok(()) +} + +/// Attempting to re-key a non-latest keyset member is rejected +#[tokio::test] +pub(crate) async fn test_rekey_non_latest_rejected() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create AES-256 key + let key_id = create_symmetric_key(&owner_client_conf_path, &["--number-of-bits", "256"])?; + + // Set rotation name (keyset) without interval + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--rotation-name", + "e2e-nlat", + ]; + run_ckms(&owner_client_conf_path, &args)?; + + // ReKey: gen-0 → gen-1 + let _new_key_id = rekey_symmetric_key(&owner_client_conf_path, &key_id)?; + + // Attempt to re-key the original (now non-latest) key — should fail + let args = vec!["sym", "keys", "re-key", "--key-id", &key_id]; + let stderr = run_ckms_expect_error(&owner_client_conf_path, &args)?; + assert!( + stderr.contains("not the latest"), + "expected 'not the latest' error, got: {stderr}" + ); + + Ok(()) +} diff --git a/crate/clients/clap/src/actions/elliptic_curves/keys/create_key_pair.rs b/crate/clients/clap/src/actions/elliptic_curves/keys/create_key_pair.rs index 6f0e66beaf..25b7204412 100644 --- a/crate/clients/clap/src/actions/elliptic_curves/keys/create_key_pair.rs +++ b/crate/clients/clap/src/actions/elliptic_curves/keys/create_key_pair.rs @@ -6,7 +6,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::console, + actions::{console, shared::RotationPolicyArgs}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -54,6 +54,10 @@ pub struct CreateKeyPairAction { verbatim_doc_comment )] pub(crate) wrapping_key_id: Option, + + /// Optional rotation policy to apply immediately after key pair creation. + #[clap(flatten)] + pub(crate) rotation_policy: RotationPolicyArgs, } impl CreateKeyPairAction { @@ -82,6 +86,14 @@ impl CreateKeyPairAction { let private_key_unique_identifier = &create_key_pair_response.private_key_unique_identifier; let public_key_unique_identifier = &create_key_pair_response.public_key_unique_identifier; + // Apply rotation policy on the private key (which is the keyset anchor) + if self.rotation_policy.is_set() { + let sk_id = private_key_unique_identifier + .as_str() + .with_context(|| "the server did not return a private key id as a string")?; + self.rotation_policy.apply(&kms_rest_client, sk_id).await?; + } + let mut stdout = console::Stdout::new("The EC key pair has been created."); stdout.set_tags(Some(&self.tags)); stdout.set_key_pair_unique_identifier( diff --git a/crate/clients/clap/src/actions/elliptic_curves/keys/mod.rs b/crate/clients/clap/src/actions/elliptic_curves/keys/mod.rs index 04e9bdabab..f2cf774a08 100644 --- a/crate/clients/clap/src/actions/elliptic_curves/keys/mod.rs +++ b/crate/clients/clap/src/actions/elliptic_curves/keys/mod.rs @@ -7,7 +7,8 @@ use self::{ }; use crate::{ actions::shared::{ - ActivateKeyAction, ExportSecretDataOrKeyAction, ImportSecretDataOrKeyAction, + ActivateKeyAction, ExportSecretDataOrKeyAction, GetRotationPolicyAction, + ImportSecretDataOrKeyAction, ReKeyKeyPairAction, SetRotationPolicyAction, UnwrapSecretDataOrKeyAction, WrapSecretDataOrKeyAction, }, error::result::KmsCliResult, @@ -28,6 +29,9 @@ pub enum KeysCommands { Unwrap(UnwrapSecretDataOrKeyAction), Revoke(RevokeKeyAction), Destroy(DestroyKeyAction), + ReKey(ReKeyKeyPairAction), + SetRotationPolicy(SetRotationPolicyAction), + GetRotationPolicy(GetRotationPolicyAction), } impl KeysCommands { @@ -55,6 +59,15 @@ impl KeysCommands { Self::Destroy(action) => { action.run(kms_rest_client).await?; } + Self::ReKey(action) => { + action.run(kms_rest_client).await?; + } + Self::SetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } + Self::GetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } } Ok(()) diff --git a/crate/clients/clap/src/actions/pqc/keys/create_key_pair.rs b/crate/clients/clap/src/actions/pqc/keys/create_key_pair.rs index abe38806f7..f0bb4ca4a7 100644 --- a/crate/clients/clap/src/actions/pqc/keys/create_key_pair.rs +++ b/crate/clients/clap/src/actions/pqc/keys/create_key_pair.rs @@ -11,7 +11,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::console, + actions::{console, shared::RotationPolicyArgs}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -152,6 +152,10 @@ pub struct CreatePqcKeyPairAction { /// Sensitive: if set, the private key will not be exportable #[clap(long = "sensitive", default_value = "false")] pub(crate) sensitive: bool, + + /// Optional rotation policy to apply immediately after key pair creation. + #[clap(flatten)] + pub(crate) rotation_policy: RotationPolicyArgs, } impl CreatePqcKeyPairAction { @@ -184,6 +188,15 @@ impl CreatePqcKeyPairAction { .await .with_context(|| "failed creating a PQC key pair")?; + // Apply rotation policy on the private key (which is the keyset anchor) + if self.rotation_policy.is_set() { + let sk_id = response + .private_key_unique_identifier + .as_str() + .with_context(|| "the server did not return a private key id as a string")?; + self.rotation_policy.apply(&kms_rest_client, sk_id).await?; + } + let mut stdout = console::Stdout::new("The PQC key pair has been properly generated."); stdout.set_tags(Some(&self.tags)); stdout.set_key_pair_unique_identifier( diff --git a/crate/clients/clap/src/actions/pqc/keys/mod.rs b/crate/clients/clap/src/actions/pqc/keys/mod.rs index 4ecd397c45..4ae065cf9a 100644 --- a/crate/clients/clap/src/actions/pqc/keys/mod.rs +++ b/crate/clients/clap/src/actions/pqc/keys/mod.rs @@ -7,7 +7,8 @@ use self::{ }; use crate::{ actions::shared::{ - ActivateKeyAction, ExportSecretDataOrKeyAction, ImportSecretDataOrKeyAction, + ActivateKeyAction, ExportSecretDataOrKeyAction, GetRotationPolicyAction, + ImportSecretDataOrKeyAction, ReKeyKeyPairAction, SetRotationPolicyAction, UnwrapSecretDataOrKeyAction, WrapSecretDataOrKeyAction, }, error::result::KmsCliResult, @@ -28,6 +29,9 @@ pub enum KeysCommands { Unwrap(UnwrapSecretDataOrKeyAction), Revoke(RevokeKeyAction), Destroy(DestroyKeyAction), + ReKey(ReKeyKeyPairAction), + SetRotationPolicy(SetRotationPolicyAction), + GetRotationPolicy(GetRotationPolicyAction), } impl KeysCommands { @@ -55,6 +59,15 @@ impl KeysCommands { Self::Destroy(action) => { action.run(kms_rest_client).await?; } + Self::ReKey(action) => { + action.run(kms_rest_client).await?; + } + Self::SetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } + Self::GetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } } Ok(()) } diff --git a/crate/clients/clap/src/actions/rsa/keys/create_key_pair.rs b/crate/clients/clap/src/actions/rsa/keys/create_key_pair.rs index bf146270e6..c49a638138 100644 --- a/crate/clients/clap/src/actions/rsa/keys/create_key_pair.rs +++ b/crate/clients/clap/src/actions/rsa/keys/create_key_pair.rs @@ -5,7 +5,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::console, + actions::{console, shared::RotationPolicyArgs}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -55,6 +55,10 @@ pub struct CreateKeyPairAction { verbatim_doc_comment )] pub wrapping_key_id: Option, + + /// Optional rotation policy to apply immediately after key pair creation. + #[clap(flatten)] + pub rotation_policy: RotationPolicyArgs, } impl Default for CreateKeyPairAction { @@ -65,6 +69,7 @@ impl Default for CreateKeyPairAction { private_key_id: None, sensitive: false, wrapping_key_id: None, + rotation_policy: RotationPolicyArgs::default(), } } } @@ -112,6 +117,14 @@ impl CreateKeyPairAction { let private_key_unique_identifier = &create_key_pair_response.private_key_unique_identifier; let public_key_unique_identifier = &create_key_pair_response.public_key_unique_identifier; + // Apply rotation policy on the private key (which is the keyset anchor) + if self.rotation_policy.is_set() { + let sk_id = private_key_unique_identifier + .as_str() + .with_context(|| "the server did not return a private key id as a string")?; + self.rotation_policy.apply(&kms_rest_client, sk_id).await?; + } + let mut stdout = console::Stdout::new("The RSA key pair has been created."); stdout.set_tags(Some(&self.tags)); stdout.set_key_pair_unique_identifier( diff --git a/crate/clients/clap/src/actions/rsa/keys/mod.rs b/crate/clients/clap/src/actions/rsa/keys/mod.rs index 77f38b0bc2..f2950edb0e 100644 --- a/crate/clients/clap/src/actions/rsa/keys/mod.rs +++ b/crate/clients/clap/src/actions/rsa/keys/mod.rs @@ -7,7 +7,8 @@ use self::{ }; use crate::{ actions::shared::{ - ActivateKeyAction, ExportSecretDataOrKeyAction, ImportSecretDataOrKeyAction, + ActivateKeyAction, ExportSecretDataOrKeyAction, GetRotationPolicyAction, + ImportSecretDataOrKeyAction, ReKeyKeyPairAction, SetRotationPolicyAction, UnwrapSecretDataOrKeyAction, WrapSecretDataOrKeyAction, }, error::result::KmsCliResult, @@ -28,6 +29,9 @@ pub enum KeysCommands { Unwrap(UnwrapSecretDataOrKeyAction), Revoke(RevokeKeyAction), Destroy(DestroyKeyAction), + ReKey(ReKeyKeyPairAction), + SetRotationPolicy(SetRotationPolicyAction), + GetRotationPolicy(GetRotationPolicyAction), } impl KeysCommands { @@ -72,6 +76,15 @@ impl KeysCommands { Self::Destroy(action) => { action.run(kms_rest_client).await?; } + Self::ReKey(action) => { + action.run(kms_rest_client).await?; + } + Self::SetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } + Self::GetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } } Ok(()) diff --git a/crate/clients/clap/src/actions/shared/get_rotation_policy.rs b/crate/clients/clap/src/actions/shared/get_rotation_policy.rs new file mode 100644 index 0000000000..413dac6f26 --- /dev/null +++ b/crate/clients/clap/src/actions/shared/get_rotation_policy.rs @@ -0,0 +1,65 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{kmip_operations::GetAttributes, kmip_types::UniqueIdentifier}, +}; + +use crate::{ + actions::console, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; + +/// Get the automatic rotation policy for a key or key pair. +/// +/// Displays: rotation interval, offset, keyset name, generation, and last rotation date. +#[derive(Parser, Debug)] +#[clap(verbatim_doc_comment)] +pub struct GetRotationPolicyAction { + /// The unique identifier of the key to get the rotation policy from. + #[clap(long = "key-id", short = 'k')] + key_id: String, +} + +impl GetRotationPolicyAction { + pub async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult<()> { + let uid = UniqueIdentifier::TextString(self.key_id.clone()); + + let response = kms_rest_client + .get_attributes(GetAttributes { + unique_identifier: Some(uid), + attribute_reference: None, + }) + .await + .with_context(|| "failed retrieving attributes")?; + + let attrs = &response.attributes; + + let interval = attrs + .rotate_interval + .map_or_else(|| "not set".to_owned(), |v| v.to_string()); + let offset = attrs + .rotate_offset + .map_or_else(|| "not set".to_owned(), |v| v.to_string()); + let name = attrs.rotate_name.as_deref().unwrap_or("not set"); + let generation = attrs + .rotate_generation + .map_or_else(|| "not set".to_owned(), |v| v.to_string()); + let date = attrs + .rotate_date + .map_or_else(|| "never".to_owned(), |d| d.to_string()); + + let output = format!( + "Rotation policy for key: {}\n\ + \x20 Interval (seconds): {interval}\n\ + \x20 Offset (seconds): {offset}\n\ + \x20 Keyset name: {name}\n\ + \x20 Generation: {generation}\n\ + \x20 Last rotation date: {date}", + response.unique_identifier + ); + + console::Stdout::new(&output).write()?; + + Ok(()) + } +} diff --git a/crate/clients/clap/src/actions/shared/mod.rs b/crate/clients/clap/src/actions/shared/mod.rs index b828f8ba3a..f1d3054c4f 100644 --- a/crate/clients/clap/src/actions/shared/mod.rs +++ b/crate/clients/clap/src/actions/shared/mod.rs @@ -1,9 +1,13 @@ mod activate; pub(crate) mod export_key; mod get_key_uid; +mod get_rotation_policy; pub(crate) mod import_key; mod locate; +mod rekey_keypair; pub(crate) mod resolve_key; +mod rotation_policy_args; +mod set_rotation_policy; pub(crate) mod sign; pub(crate) mod signature_verify; pub mod utils; @@ -15,8 +19,12 @@ mod unwrap_key; pub use activate::ActivateKeyAction; pub use export_key::ExportSecretDataOrKeyAction; pub(crate) use get_key_uid::get_key_uid; +pub use get_rotation_policy::GetRotationPolicyAction; pub use import_key::ImportSecretDataOrKeyAction; pub use locate::LocateObjectsAction; +pub use rekey_keypair::ReKeyKeyPairAction; +pub use rotation_policy_args::RotationPolicyArgs; +pub use set_rotation_policy::SetRotationPolicyAction; pub use unwrap_key::UnwrapSecretDataOrKeyAction; pub use wrap_key::WrapSecretDataOrKeyAction; diff --git a/crate/clients/clap/src/actions/shared/rekey_keypair.rs b/crate/clients/clap/src/actions/shared/rekey_keypair.rs new file mode 100644 index 0000000000..c4546b7d77 --- /dev/null +++ b/crate/clients/clap/src/actions/shared/rekey_keypair.rs @@ -0,0 +1,38 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{kmip_operations::ReKeyKeyPair, kmip_types::UniqueIdentifier}, +}; + +use crate::{ + actions::{console, labels::KEY_ID}, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; + +/// Rotate an existing asymmetric key pair, generating a new private/public key pair +#[derive(Parser)] +#[clap(verbatim_doc_comment)] +pub struct ReKeyKeyPairAction { + /// The unique identifier of the private key to re-key. + #[clap(long = KEY_ID, short = 'k')] + pub(crate) key_id: String, +} + +impl ReKeyKeyPairAction { + pub(crate) async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { + let rekey_keypair_request = ReKeyKeyPair { + private_key_unique_identifier: Some(UniqueIdentifier::TextString(self.key_id.clone())), + ..ReKeyKeyPair::default() + }; + let response = kms_rest_client + .rekey_keypair(rekey_keypair_request) + .await + .with_context(|| "failed rekeying the key pair")?; + + let mut stdout = console::Stdout::new("The key pair was successfully rotated."); + stdout.set_unique_identifier(&response.private_key_unique_identifier); + stdout.write()?; + + Ok(response.private_key_unique_identifier) + } +} diff --git a/crate/clients/clap/src/actions/shared/rotation_policy_args.rs b/crate/clients/clap/src/actions/shared/rotation_policy_args.rs new file mode 100644 index 0000000000..9bf234cd36 --- /dev/null +++ b/crate/clients/clap/src/actions/shared/rotation_policy_args.rs @@ -0,0 +1,79 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{ + kmip_attributes::Attribute, kmip_operations::SetAttribute, kmip_types::UniqueIdentifier, + }, +}; + +use crate::error::result::{KmsCliResult, KmsCliResultHelper}; + +/// Optional rotation policy arguments that can be added to key creation commands. +/// +/// When provided, these are applied as `SetAttribute` calls immediately after the key is created. +#[derive(Parser, Default, Debug, Clone)] +pub struct RotationPolicyArgs { + /// Assign a keyset name for addressing key generations via `name@latest`, `name@first`, `name@N` syntax. + /// Must not contain the `@` character. + #[clap( + long = "rotation-name", + short = 'n', + required = false, + verbatim_doc_comment + )] + pub rotate_name: Option, + + /// Rotation interval in seconds. The key will be automatically re-keyed at this interval. + /// Set to 0 to disable automatic rotation while preserving other policy fields. + #[clap(long = "rotation-interval", required = false)] + pub rotate_interval: Option, + + /// Offset in seconds from the initial date before the first rotation occurs. + #[clap(long = "rotation-offset", required = false)] + pub rotate_offset: Option, +} + +impl RotationPolicyArgs { + /// Returns `true` if at least one rotation policy field is set. + #[must_use] + pub const fn is_set(&self) -> bool { + self.rotate_name.is_some() || self.rotate_interval.is_some() || self.rotate_offset.is_some() + } + + /// Apply rotation policy attributes via `SetAttribute` calls on the given key ID. + pub async fn apply(&self, kms_rest_client: &KmsClient, key_id: &str) -> KmsCliResult<()> { + let uid = UniqueIdentifier::TextString(key_id.to_owned()); + + if let Some(interval) = self.rotate_interval { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateInterval(interval), + }) + .await + .with_context(|| "failed setting RotateInterval attribute")?; + } + + if let Some(offset) = self.rotate_offset { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateOffset(offset), + }) + .await + .with_context(|| "failed setting RotateOffset attribute")?; + } + + if let Some(ref name) = self.rotate_name { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateName(name.clone()), + }) + .await + .with_context(|| "failed setting RotateName attribute")?; + } + + Ok(()) + } +} diff --git a/crate/clients/clap/src/actions/shared/set_rotation_policy.rs b/crate/clients/clap/src/actions/shared/set_rotation_policy.rs new file mode 100644 index 0000000000..2a329935de --- /dev/null +++ b/crate/clients/clap/src/actions/shared/set_rotation_policy.rs @@ -0,0 +1,87 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{ + kmip_attributes::Attribute, kmip_operations::SetAttribute, kmip_types::UniqueIdentifier, + }, +}; + +use crate::{ + actions::console, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; + +/// Set the automatic rotation policy on a key or key pair. +/// +/// This configures: +/// - The rotation interval (how often the key is automatically re-keyed) +/// - An optional offset (delay before first rotation) +/// - An optional keyset name (for addressing key generations via name@version syntax) +/// +/// At least one of --interval or --rotation-name must be provided. +#[derive(Parser, Debug)] +#[clap(verbatim_doc_comment)] +pub struct SetRotationPolicyAction { + /// The unique identifier of the key to set the rotation policy on. + #[clap(long = "key-id", short = 'k')] + key_id: String, + + /// Rotation interval in seconds. The key will be automatically re-keyed at this interval. + /// Set to 0 to disable automatic rotation while preserving other policy fields. + #[clap(long = "interval", short = 'i')] + interval_secs: Option, + + /// Offset in seconds from the initial date before the first rotation occurs. + #[clap(long = "offset", short = 'o')] + offset_secs: Option, + + /// A keyset name for addressing key generations via name@latest, name@first, name@N syntax. + /// Must not contain the '@' character. + #[clap(long = "rotation-name", short = 'n')] + rotate_name: Option, +} + +impl SetRotationPolicyAction { + pub async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult<()> { + let uid = UniqueIdentifier::TextString(self.key_id.clone()); + + // Set the rotation interval if provided + if let Some(interval) = self.interval_secs { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateInterval(interval), + }) + .await + .with_context(|| "failed setting RotateInterval attribute")?; + } + + // Set the rotation offset if provided + if let Some(offset) = self.offset_secs { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateOffset(offset), + }) + .await + .with_context(|| "failed setting RotateOffset attribute")?; + } + + // Set the rotation name if provided + if let Some(ref name) = self.rotate_name { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateName(name.clone()), + }) + .await + .with_context(|| "failed setting RotateName attribute")?; + } + + let mut stdout = console::Stdout::new("Rotation policy set successfully."); + stdout.set_unique_identifier(&uid); + stdout.write()?; + + Ok(()) + } +} diff --git a/crate/clients/clap/src/actions/symmetric/keys/create_key.rs b/crate/clients/clap/src/actions/symmetric/keys/create_key.rs index 4681156632..330c11baa1 100644 --- a/crate/clients/clap/src/actions/symmetric/keys/create_key.rs +++ b/crate/clients/clap/src/actions/symmetric/keys/create_key.rs @@ -14,7 +14,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::console, + actions::{console, shared::RotationPolicyArgs}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -77,6 +77,10 @@ pub struct CreateKeyAction { verbatim_doc_comment )] pub wrapping_key_id: Option, + + /// Optional rotation policy to apply immediately after key creation. + #[clap(flatten)] + pub rotation_policy: RotationPolicyArgs, } impl CreateKeyAction { @@ -138,6 +142,14 @@ impl CreateKeyAction { .unique_identifier }; + // Apply rotation policy if any fields were provided + if self.rotation_policy.is_set() { + let key_id = unique_identifier + .as_str() + .with_context(|| "the server did not return a key id as a string")?; + self.rotation_policy.apply(&kms_rest_client, key_id).await?; + } + let mut stdout = console::Stdout::new("The symmetric key was successfully generated."); stdout.set_tags(Some(&self.tags)); stdout.set_unique_identifier(&unique_identifier); diff --git a/crate/clients/clap/src/actions/symmetric/keys/mod.rs b/crate/clients/clap/src/actions/symmetric/keys/mod.rs index 1f44c326ac..3f0f739608 100644 --- a/crate/clients/clap/src/actions/symmetric/keys/mod.rs +++ b/crate/clients/clap/src/actions/symmetric/keys/mod.rs @@ -2,14 +2,14 @@ use clap::Subcommand; use cosmian_kms_client::KmsClient; use self::{ - create_key::CreateKeyAction, destroy_key::DestroyKeyAction, - get_rotation_policy::GetRotationPolicyAction, rekey::ReKeyAction, revoke_key::RevokeKeyAction, - set_rotation_policy::SetRotationPolicyAction, + create_key::CreateKeyAction, destroy_key::DestroyKeyAction, rekey::ReKeyAction, + revoke_key::RevokeKeyAction, }; use crate::{ actions::shared::{ - ActivateKeyAction, ExportSecretDataOrKeyAction, ImportSecretDataOrKeyAction, - UnwrapSecretDataOrKeyAction, WrapSecretDataOrKeyAction, + ActivateKeyAction, ExportSecretDataOrKeyAction, GetRotationPolicyAction, + ImportSecretDataOrKeyAction, SetRotationPolicyAction, UnwrapSecretDataOrKeyAction, + WrapSecretDataOrKeyAction, }, error::result::KmsCliResult, }; diff --git a/crate/clients/client/src/http_client/login.rs b/crate/clients/client/src/http_client/login.rs index ec3f62d53a..f4e910a595 100644 --- a/crate/clients/client/src/http_client/login.rs +++ b/crate/clients/client/src/http_client/login.rs @@ -5,7 +5,9 @@ use std::{ }; use actix_web::{ - App, HttpResponse, HttpServer, get, + App, HttpResponse, HttpServer, + dev::ServerHandle, + get, web::{self, Data}, }; use oauth2::{ @@ -176,7 +178,11 @@ impl LoginState { // URL. Use the port that was actually embedded in the redirect URL at // construction time (respects OAUTH2_REDIRECT_URL_PORT). let port = self.redirect_url.port_or_known_default().unwrap_or(17_899); - let auth_parameters = Self::receive_authorization_parameters(port)?; + let (auth_parameters, server_handle) = Self::receive_authorization_parameters(port)?; + // Gracefully stop the redirect listener so the port is freed immediately + // rather than lingering in TIME_WAIT. This prevents port-binding + // failures when the test suite is run multiple times in quick succession. + server_handle.stop(true).await; // Once the user has been redirected to the redirect URL, you'll have access to // the authorization code. For security reasons, your code should verify @@ -213,18 +219,21 @@ impl LoginState { /// This function starts the server on the given `port` and waits for the /// authorization code to be received from the browser window. Once the - /// code is received, the server is closed and the authorization code is - /// returned. + /// code is received, the server handle is returned alongside the parameters + /// so the caller can gracefully stop the server and free the port. /// /// The port must match the one embedded in the redirect URL that was sent /// to the Identity Provider during the authorization request. #[allow(clippy::unwrap_used)] - fn receive_authorization_parameters(port: u16) -> HttpClientResult> { + fn receive_authorization_parameters( + port: u16, + ) -> HttpClientResult<(HashMap, ServerHandle)> { let (auth_params_tx, auth_params_rx) = mpsc::channel::>(); + let (server_handle_tx, server_handle_rx) = mpsc::sync_channel::(1); // Spawn the server into a runtime let tokio_handle = tokio::runtime::Handle::current(); let _task = thread::spawn(move || { - tokio_handle.block_on({ + tokio_handle.block_on(async move { // server.await #[get("/authorization")] async fn authorization_handler( @@ -238,18 +247,26 @@ impl LoginState { HttpResponse::Ok().body("You can now close this window.") } - HttpServer::new(move || { + let server = HttpServer::new(move || { App::new() .app_data(Data::new(auth_params_tx.clone())) .service(authorization_handler) }) .bind(("127.0.0.1", port))? - .run() + .run(); + // Send the handle before awaiting so the outer thread can stop + // the server once the first callback has been received. + drop(server_handle_tx.send(server.handle())); + server.await }) }); - auth_params_rx.recv().map_err(|e| { + let params = auth_params_rx.recv().map_err(|e| { HttpClientError::Default(format!("authorization code not received: {e:?}")) - }) + })?; + let handle = server_handle_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .map_err(|e| HttpClientError::Default(format!("server handle not received: {e:?}")))?; + Ok((params, handle)) } } diff --git a/crate/clients/client_utils/src/attributes_utils.rs b/crate/clients/client_utils/src/attributes_utils.rs index bacfe8ab14..75e6011cea 100644 --- a/crate/clients/client_utils/src/attributes_utils.rs +++ b/crate/clients/client_utils/src/attributes_utils.rs @@ -114,7 +114,7 @@ pub fn parse_selected_attributes( if let Some(v) = attributes.activation_date.as_ref() { results.insert( tag.to_string(), - serde_json::to_value(v.unix_timestamp()).unwrap_or_default(), + serde_json::to_value(v.unix_timestamp() * 1000_i64).unwrap_or_default(), ); } } @@ -377,10 +377,59 @@ pub fn parse_selected_attributes_flatten( if let Some(v) = attributes.activation_date.as_ref() { results.insert( selected_attribute_name.to_owned(), - serde_json::to_value(v.unix_timestamp()).unwrap_or_default(), + serde_json::to_value(v.unix_timestamp() * 1000_i64).unwrap_or_default(), ); } } + "initial_date" => { + if let Some(v) = attributes.initial_date.as_ref() { + results.insert( + selected_attribute_name.to_owned(), + serde_json::to_value(v.unix_timestamp() * 1000_i64).unwrap_or_default(), + ); + } + } + "original_creation_date" => { + if let Some(v) = attributes.original_creation_date.as_ref() { + results.insert( + selected_attribute_name.to_owned(), + serde_json::to_value(v.unix_timestamp() * 1000_i64).unwrap_or_default(), + ); + } + } + "rotate_date" => { + if let Some(v) = attributes.rotate_date.as_ref() { + results.insert( + selected_attribute_name.to_owned(), + serde_json::to_value(v.unix_timestamp() * 1000_i64).unwrap_or_default(), + ); + } + } + "rotate_generation" => insert_if_some!( + results, + selected_attribute_name, + attributes.rotate_generation.as_ref() + ), + "rotate_interval" => insert_if_some!( + results, + selected_attribute_name, + attributes.rotate_interval.as_ref() + ), + "rotate_latest" => insert_if_some!( + results, + selected_attribute_name, + attributes.rotate_latest.as_ref() + ), + "rotate_name" => insert_if_some!( + results, + selected_attribute_name, + attributes.rotate_name.as_ref() + ), + "rotate_offset" => insert_if_some!( + results, + selected_attribute_name, + attributes.rotate_offset.as_ref() + ), "cryptographic_algorithm" => insert_if_some!( results, selected_attribute_name, diff --git a/crate/clients/client_utils/src/certificate_utils.rs b/crate/clients/client_utils/src/certificate_utils.rs index f7d21522c0..bf60969896 100644 --- a/crate/clients/client_utils/src/certificate_utils.rs +++ b/crate/clients/client_utils/src/certificate_utils.rs @@ -5,7 +5,7 @@ use cosmian_kmip::{ kmip_2_1::{ kmip_attributes::Attributes, kmip_objects::ObjectType, - kmip_operations::Certify, + kmip_operations::{Certify, ReCertify}, kmip_types::{ CertificateAttributes, CertificateRequestType, CryptographicAlgorithm, CryptographicDomainParameters, KeyFormatType, LinkType, LinkedObjectIdentifier, @@ -425,6 +425,65 @@ pub fn build_certify_request( }) } +/// Build a KMIP `ReCertify` request — certificate rotation with a fresh UID. +/// +/// Unlike `Certify` with an existing cert UID (which upserts in-place), +/// `ReCertify` creates a **new certificate** and links old ↔ new via +/// `ReplacedObjectLink` / `ReplacementObjectLink`. +/// +/// # Parameters +/// - `vendor_id` — vendor identifier string for `VendorAttribute` operations +/// - `certificate_id_to_re_certify` — UID of the certificate to renew (required) +/// - `issuer_private_key_id` — optional UID of the issuer's private key +/// - `issuer_certificate_id` — optional UID of the issuer's certificate +/// - `number_of_days` — requested validity period for the new certificate +/// - `tags` — tags to associate with the new certificate +pub fn build_re_certify_request( + vendor_id: &str, + certificate_id_to_re_certify: &str, + issuer_private_key_id: &Option, + issuer_certificate_id: &Option, + number_of_days: usize, + tags: &[String], +) -> Result { + let mut attributes = Attributes { + object_type: Some(ObjectType::Certificate), + ..Attributes::default() + }; + + if let Some(issuer_certificate_id) = issuer_certificate_id { + attributes.set_link( + LinkType::CertificateLink, + LinkedObjectIdentifier::TextString(issuer_certificate_id.clone()), + ); + } + + if let Some(issuer_private_key_id) = issuer_private_key_id { + attributes.set_link( + LinkType::PrivateKeyLink, + LinkedObjectIdentifier::TextString(issuer_private_key_id.clone()), + ); + } + + attributes.set_requested_validity_days( + vendor_id, + i32::try_from(number_of_days).map_err(|_e| { + UtilsError::Default("number of days must be a positive integer".to_owned()) + })?, + ); + + attributes.activation_date = Some(time_normalize()?); + attributes.set_tags(vendor_id, tags)?; + + Ok(ReCertify { + unique_identifier: Some(UniqueIdentifier::TextString( + certificate_id_to_re_certify.to_owned(), + )), + attributes: Some(attributes), + ..ReCertify::default() + }) +} + fn ec_algorithm( attributes: &mut Attributes, cryptographic_algorithm: CryptographicAlgorithm, diff --git a/crate/clients/wasm/src/wasm.rs b/crate/clients/wasm/src/wasm.rs index a9c08011f5..47d23d0286 100644 --- a/crate/clients/wasm/src/wasm.rs +++ b/crate/clients/wasm/src/wasm.rs @@ -3,7 +3,7 @@ use std::{cell::RefCell, str::FromStr}; use base64::{Engine as _, engine::general_purpose}; use cosmian_kms_client_utils::{ attributes_utils::{build_selected_attribute, parse_selected_attributes_flatten}, - certificate_utils::{Algorithm, build_certify_request}, + certificate_utils::{Algorithm, build_certify_request, build_re_certify_request}, configurable_kem_utils::{KemAlgorithm, build_create_configurable_kem_keypair_request}, cover_crypt_utils::{ build_create_covercrypt_master_keypair_request, build_create_covercrypt_usk_request, @@ -41,8 +41,9 @@ use cosmian_kms_client_utils::{ DeriveKeyResponse, Destroy, DestroyResponse, EncryptResponse, ExportResponse, GetAttributes, GetAttributesResponse, Hash, HashResponse, ImportResponse, LocateResponse, ModifyAttribute, ModifyAttributeResponse, Query, QueryResponse, - ReKey, ReKeyResponse, RevokeResponse, SetAttribute, SetAttributeResponse, Sign, - SignResponse, SignatureVerify, SignatureVerifyResponse, Validate, ValidateResponse, + ReCertifyResponse, ReKey, ReKeyKeyPair, ReKeyKeyPairResponse, ReKeyResponse, + RevokeResponse, SetAttribute, SetAttributeResponse, Sign, SignResponse, + SignatureVerify, SignatureVerifyResponse, Validate, ValidateResponse, }, kmip_types::{ AttributeReference, CryptographicAlgorithm, CryptographicParameters, @@ -2145,6 +2146,39 @@ pub fn certify_ttlv_request( wasm_response_parser!(parse_certify_ttlv_response, CertifyResponse); +/// Build a KMIP `ReCertify` TTLV request. +/// +/// Unlike `certify_ttlv_request` with an existing certificate UID (which +/// replaces in-place), this sends the dedicated KMIP `ReCertify` operation +/// that creates a **new certificate** with a fresh UID and links the old and +/// new certificates via `ReplacedObjectLink` / `ReplacementObjectLink`. +#[allow(clippy::needless_pass_by_value)] +#[wasm_bindgen] +pub fn re_certify_ttlv_request( + certificate_id_to_re_certify: String, + issuer_private_key_id: Option, + issuer_certificate_id: Option, + number_of_days: usize, + tags: Vec, +) -> Result { + let vendor_id = get_vendor_id(); + let vendor_id = vendor_id.as_str(); + let issuer_private_key_id = none_if_empty(issuer_private_key_id); + let issuer_certificate_id = none_if_empty(issuer_certificate_id); + let request = build_re_certify_request( + vendor_id, + &certificate_id_to_re_certify, + &issuer_private_key_id, + &issuer_certificate_id, + number_of_days, + &tags, + ) + .map_err(|e| JsValue::from(e.to_string()))?; + to_wasm_ttlv(&request) +} + +wasm_response_parser!(parse_re_certify_ttlv_response, ReCertifyResponse); + // Attributes request #[wasm_bindgen] pub fn get_attributes_ttlv_request(unique_identifier: String) -> Result { @@ -2465,6 +2499,24 @@ pub fn rekey_ttlv_request(unique_identifier: String) -> Result wasm_response_parser!(parse_rekey_ttlv_response, ReKeyResponse); +// ── ReKey Key Pair (asymmetric key rotation) ───────────────────────────────── + +/// Build a KMIP `ReKeyKeyPair` TTLV request for an asymmetric key pair. +#[wasm_bindgen] +pub fn rekey_keypair_ttlv_request( + private_key_unique_identifier: String, +) -> Result { + let request = ReKeyKeyPair { + private_key_unique_identifier: Some(UniqueIdentifier::TextString( + private_key_unique_identifier, + )), + ..ReKeyKeyPair::default() + }; + to_wasm_ttlv(&request) +} + +wasm_response_parser!(parse_rekey_keypair_ttlv_response, ReKeyKeyPairResponse); + // ── Rotation policy helpers ────────────────────────────────────────────────── /// Build a KMIP `SetAttribute` TTLV request to set `RotateInterval` on a key. diff --git a/crate/server/src/core/operations/rekey/symmetric.rs b/crate/server/src/core/operations/rekey/symmetric.rs index fe351e71ee..6033bdf19f 100644 --- a/crate/server/src/core/operations/rekey/symmetric.rs +++ b/crate/server/src/core/operations/rekey/symmetric.rs @@ -222,6 +222,18 @@ impl RekeyOperation for SymmetricRekey { .as_str() .context("Rekey: the symmetric key unique identifier must be a string")?; + // HSM-managed keys cannot be re-keyed via KMIP: they have no KMIP attribute + // storage and are often non-extractable (CKA_EXTRACTABLE = false). + // Use PKCS#11 vendor tools for HSM key lifecycle management. + if uid_or_tags.starts_with("hsm::") { + return Err(KmsError::NotSupported( + "Re-Key is not supported for HSM-managed keys. \ + Use PKCS#11 vendor tools or the HSM administration console \ + to manage HSM key lifecycle." + .to_owned(), + )); + } + for owm in retrieve_eligible_keys(kms, uid_or_tags, ObjectType::SymmetricKey).await? { if !owm .user_can_perform_operation(user, &KmipOperation::Rekey, kms) diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index 4be0ee41f2..586a58f801 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -1005,6 +1005,7 @@ pub async fn prepare_kms_server(kms_server: Arc) -> KResult = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> - } /> - } /> } /> } /> } /> @@ -277,6 +278,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> + } /> } /> } /> } /> @@ -288,6 +290,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> + } /> } /> } /> } /> @@ -299,6 +302,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> + } /> } /> } /> } /> @@ -310,6 +314,24 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> + + + } /> + } /> + + + } /> + } /> + + + } /> + } /> + + + } /> + } /> + + } /> } /> diff --git a/ui/src/actions/Certificates/CertificateCertify.tsx b/ui/src/actions/Certificates/CertificateCertify.tsx index 4aea44acf7..d4000905a0 100644 --- a/ui/src/actions/Certificates/CertificateCertify.tsx +++ b/ui/src/actions/Certificates/CertificateCertify.tsx @@ -1,10 +1,10 @@ import { Button, Card, Checkbox, Form, Input, Radio, RadioChangeEvent, Select, Space } from "antd"; import React, { useEffect, useState } from "react"; +import { ActionResponse } from "../../components/common/ActionResponse"; import { FormUploadDragger } from "../../components/common/FormUpload"; +import { useActionState } from "../../hooks/useActionState"; import { sendKmipRequest } from "../../utils/utils"; import * as wasm from "../../wasm/pkg"; -import { useActionState } from "../../hooks/useActionState"; -import { ActionResponse } from "../../components/common/ActionResponse"; interface CertificateCertifyFormData { certificateId?: string; @@ -63,6 +63,26 @@ const CertificateCertifyForm: React.FC = () => { // does not attempt to look up a blank identifier on the server. const normalize = (v?: string) => (v?.trim() ? v.trim() : undefined); await execute(async () => { + // Option 3 uses the dedicated KMIP ReCertify operation which creates a + // new certificate with a fresh UID and links old ↔ new via replacement links. + if (certifyMethod === "reCertify") { + const certIdToRenew = normalize(values.certificateIdToReCertify); + if (!certIdToRenew) throw new Error("Certificate ID to re-certify is required"); + const request = wasm.re_certify_ttlv_request( + certIdToRenew, + normalize(values.issuerPrivateKeyId), + normalize(values.issuerCertificateId), + values.numberOfDays, + values.tags, + ); + const result_str = await sendKmipRequest(request, idToken, serverUrl); + if (result_str) { + const response = await wasm.parse_re_certify_ttlv_response(result_str); + return `Certificate successfully re-certified with new ID: ${response.UniqueIdentifier}`; + } + return; + } + const request = wasm.certify_ttlv_request( normalize(values.certificateId), values.csrFormat, diff --git a/ui/src/actions/EC/ECKeysCreate.tsx b/ui/src/actions/EC/ECKeysCreate.tsx index c5aed01e75..745b72475a 100644 --- a/ui/src/actions/EC/ECKeysCreate.tsx +++ b/ui/src/actions/EC/ECKeysCreate.tsx @@ -1,4 +1,4 @@ -import { Button, Card, Checkbox, Form, Input, Select, Space } from "antd"; +import { Button, Card, Checkbox, Divider, Form, Input, InputNumber, Select, Space } from "antd"; import React, { useEffect, useState } from "react"; import { sendKmipRequest } from "../../utils/utils"; import * as wasm from "../../wasm/pkg"; @@ -11,6 +11,9 @@ interface ECKeyCreateFormData { tags: string[]; sensitive: boolean; wrappingKeyId?: string; + rotateName?: string; + rotateInterval?: number; + rotateOffset?: number; } type CreateKeyPairResponse = { @@ -55,7 +58,25 @@ const ECKeyCreateForm: React.FC = () => { const result_str = await sendKmipRequest(request, idToken, serverUrl); if (result_str) { const result: CreateKeyPairResponse = await wasm.parse_create_keypair_ttlv_response(result_str); - return `Key pair has been created. Private key Id: ${result.PrivateKeyUniqueIdentifier} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; + const skId = result.PrivateKeyUniqueIdentifier; + + // Apply rotation policy on the private key (keyset anchor) + if (values.rotateName || values.rotateInterval !== undefined || values.rotateOffset !== undefined) { + if (values.rotateInterval !== undefined) { + const req = wasm.set_rotate_interval_ttlv_request(skId, BigInt(values.rotateInterval)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateOffset !== undefined) { + const req = wasm.set_rotate_offset_ttlv_request(skId, BigInt(values.rotateOffset)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateName) { + const req = wasm.set_rotate_name_ttlv_request(skId, values.rotateName); + await sendKmipRequest(req, idToken, serverUrl); + } + } + + return `Key pair has been created. Private key Id: ${skId} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; } }); }; @@ -116,6 +137,30 @@ const ECKeyCreateForm: React.FC = () => { Sensitive + + + Rotation Policy (optional) + + + + + + + + + + + + + diff --git a/ui/src/actions/EC/ECReKey.tsx b/ui/src/actions/EC/ECReKey.tsx new file mode 100644 index 0000000000..cf847676c0 --- /dev/null +++ b/ui/src/actions/EC/ECReKey.tsx @@ -0,0 +1,75 @@ +import { Button, Card, Form, Input, Space } from "antd"; +import React from "react"; +import { ActionResponse } from "../../components/common/ActionResponse"; +import { useActionState } from "../../hooks/useActionState"; +import { sendKmipRequest } from "../../utils/utils"; +import * as wasm from "../../wasm/pkg"; + +interface ReKeyFormData { + keyId: string; +} + +type ReKeyKeyPairResponse = { + PrivateKeyUniqueIdentifier: string; + PublicKeyUniqueIdentifier: string; +}; + +const ECReKeyForm: React.FC = () => { + const [form] = Form.useForm(); + const { res, isLoading, responseRef, idToken, serverUrl, execute } = useActionState(); + + const onFinish = async (values: ReKeyFormData) => { + await execute(async () => { + const request = wasm.rekey_keypair_ttlv_request(values.keyId); + const result_str = await sendKmipRequest(request, idToken, serverUrl); + if (result_str) { + const result: ReKeyKeyPairResponse = await wasm.parse_rekey_keypair_ttlv_response(result_str); + return `The EC key pair was successfully rotated.\nNew private key: ${result.PrivateKeyUniqueIdentifier}\nNew public key: ${result.PublicKeyUniqueIdentifier}`; + } + }); + }; + + return ( +
+

Re-Key an Elliptic Curve key pair

+ +
+

Rotate an existing EC key pair, generating new key material.

+
    +
  • A new private key and public key are created with the same curve.
  • +
  • The old key pair is linked to the new one via replacement links.
  • +
  • The rotation generation counter is incremented on the new key.
  • +
+
+ +
+ + + + + + + + + + + + + +
+ ); +}; + +export default ECReKeyForm; diff --git a/ui/src/actions/Keys/KeysReKey.tsx b/ui/src/actions/Keys/KeysReKey.tsx index 4b703d428c..3dd6e9d621 100644 --- a/ui/src/actions/Keys/KeysReKey.tsx +++ b/ui/src/actions/Keys/KeysReKey.tsx @@ -43,11 +43,7 @@ const KeysReKeyForm: React.FC = () => {
- + diff --git a/ui/src/actions/Keys/SymKeysCreate.tsx b/ui/src/actions/Keys/SymKeysCreate.tsx index 920aa15a12..344d645d3d 100644 --- a/ui/src/actions/Keys/SymKeysCreate.tsx +++ b/ui/src/actions/Keys/SymKeysCreate.tsx @@ -1,4 +1,4 @@ -import { Button, Card, Checkbox, Form, Input, InputNumber, Select, Space } from "antd"; +import { Button, Card, Checkbox, Divider, Form, Input, InputNumber, Select, Space } from "antd"; import React, { useEffect, useState } from "react"; import { sendKmipRequest } from "../../utils/utils"; import * as wasm from "../../wasm/pkg"; @@ -13,6 +13,9 @@ interface SymKeyCreateFormData { tags: string[]; sensitive: boolean; wrappingKeyId?: string; + rotateName?: string; + rotateInterval?: number; + rotateOffset?: number; } type CreateResponse = { @@ -49,7 +52,25 @@ const SymKeyCreateForm: React.FC = () => { const result_str = await sendKmipRequest(request, idToken, serverUrl); if (result_str) { const result: CreateResponse = await wasm.parse_create_ttlv_response(result_str); - return `${result.UniqueIdentifier} has been created.`; + const keyId = result.UniqueIdentifier; + + // Apply rotation policy if any fields were provided + if (values.rotateName || values.rotateInterval !== undefined || values.rotateOffset !== undefined) { + if (values.rotateInterval !== undefined) { + const req = wasm.set_rotate_interval_ttlv_request(keyId, BigInt(values.rotateInterval)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateOffset !== undefined) { + const req = wasm.set_rotate_offset_ttlv_request(keyId, BigInt(values.rotateOffset)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateName) { + const req = wasm.set_rotate_name_ttlv_request(keyId, values.rotateName); + await sendKmipRequest(req, idToken, serverUrl); + } + } + + return `${keyId} has been created.`; } }); }; @@ -111,6 +132,30 @@ const SymKeyCreateForm: React.FC = () => { Sensitive + + + Rotation Policy (optional) + + + + + + + + + + + + + diff --git a/ui/src/actions/PQC/PqcKeysCreate.tsx b/ui/src/actions/PQC/PqcKeysCreate.tsx index ba00685d8f..ffdf8aa65b 100644 --- a/ui/src/actions/PQC/PqcKeysCreate.tsx +++ b/ui/src/actions/PQC/PqcKeysCreate.tsx @@ -1,4 +1,4 @@ -import { Button, Card, Checkbox, Form, Select, Space } from "antd"; +import { Button, Card, Checkbox, Divider, Form, Input, InputNumber, Select, Space } from "antd"; import React, { useEffect, useState } from "react"; import { useBranding } from "../../contexts/useBranding"; import { sendKmipRequest } from "../../utils/utils"; @@ -10,6 +10,9 @@ interface PqcKeyCreateFormData { algorithm: string; tags: string[]; sensitive: boolean; + rotateName?: string; + rotateInterval?: number; + rotateOffset?: number; } type CreateKeyPairResponse = { @@ -49,7 +52,25 @@ const PqcKeysCreateForm: React.FC = () => { const result_str = await sendKmipRequest(request, idToken, serverUrl); if (result_str) { const result: CreateKeyPairResponse = await wasm.parse_create_keypair_ttlv_response(result_str); - return `Key pair has been created. Private key Id: ${result.PrivateKeyUniqueIdentifier} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; + const skId = result.PrivateKeyUniqueIdentifier; + + // Apply rotation policy on the private key (keyset anchor) + if (values.rotateName || values.rotateInterval !== undefined || values.rotateOffset !== undefined) { + if (values.rotateInterval !== undefined) { + const req = wasm.set_rotate_interval_ttlv_request(skId, BigInt(values.rotateInterval)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateOffset !== undefined) { + const req = wasm.set_rotate_offset_ttlv_request(skId, BigInt(values.rotateOffset)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateName) { + const req = wasm.set_rotate_name_ttlv_request(skId, values.rotateName); + await sendKmipRequest(req, idToken, serverUrl); + } + } + + return `Key pair has been created. Private key Id: ${skId} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; } }); }; @@ -107,6 +128,30 @@ const PqcKeysCreateForm: React.FC = () => { Sensitive + + + Rotation Policy (optional) + + + + + + + + + + + + + diff --git a/ui/src/actions/PQC/PqcReKey.tsx b/ui/src/actions/PQC/PqcReKey.tsx new file mode 100644 index 0000000000..ac33062f19 --- /dev/null +++ b/ui/src/actions/PQC/PqcReKey.tsx @@ -0,0 +1,75 @@ +import { Button, Card, Form, Input, Space } from "antd"; +import React from "react"; +import { ActionResponse } from "../../components/common/ActionResponse"; +import { useActionState } from "../../hooks/useActionState"; +import { sendKmipRequest } from "../../utils/utils"; +import * as wasm from "../../wasm/pkg"; + +interface ReKeyFormData { + keyId: string; +} + +type ReKeyKeyPairResponse = { + PrivateKeyUniqueIdentifier: string; + PublicKeyUniqueIdentifier: string; +}; + +const PqcReKeyForm: React.FC = () => { + const [form] = Form.useForm(); + const { res, isLoading, responseRef, idToken, serverUrl, execute } = useActionState(); + + const onFinish = async (values: ReKeyFormData) => { + await execute(async () => { + const request = wasm.rekey_keypair_ttlv_request(values.keyId); + const result_str = await sendKmipRequest(request, idToken, serverUrl); + if (result_str) { + const result: ReKeyKeyPairResponse = await wasm.parse_rekey_keypair_ttlv_response(result_str); + return `The post-quantum key pair was successfully rotated.\nNew private key: ${result.PrivateKeyUniqueIdentifier}\nNew public key: ${result.PublicKeyUniqueIdentifier}`; + } + }); + }; + + return ( +
+

Re-Key a Post-Quantum key pair

+ +
+

Rotate an existing post-quantum key pair (ML-KEM, ML-DSA), generating new key material.

+
    +
  • A new private key and public key are created with the same algorithm.
  • +
  • The old key pair is linked to the new one via replacement links.
  • +
  • The rotation generation counter is incremented on the new key.
  • +
+
+ + + + + + + + + + + + + + + +
+ ); +}; + +export default PqcReKeyForm; diff --git a/ui/src/actions/RSA/RsaKeysCreate.tsx b/ui/src/actions/RSA/RsaKeysCreate.tsx index 27cd1b8b3b..eebe2acc96 100644 --- a/ui/src/actions/RSA/RsaKeysCreate.tsx +++ b/ui/src/actions/RSA/RsaKeysCreate.tsx @@ -1,7 +1,8 @@ -import { Button, Card, Checkbox, Form, Input, InputNumber, Select, Space } from "antd"; +import { Button, Card, Checkbox, Divider, Form, Input, InputNumber, Select, Space } from "antd"; import React from "react"; import { sendKmipRequest } from "../../utils/utils"; import { create_rsa_key_pair_ttlv_request, parse_create_keypair_ttlv_response } from "../../wasm/pkg"; +import * as wasm from "../../wasm/pkg"; import { useActionState } from "../../hooks/useActionState"; import { ActionResponse } from "../../components/common/ActionResponse"; @@ -11,6 +12,9 @@ interface RsaKeyCreateFormData { tags: string[]; sensitive: boolean; wrappingKeyId?: string; + rotateName?: string; + rotateInterval?: number; + rotateOffset?: number; } type CreateKeyPairResponse = { @@ -34,7 +38,25 @@ const RsaKeyCreateForm: React.FC = () => { const result_str = await sendKmipRequest(request, idToken, serverUrl); if (result_str) { const result: CreateKeyPairResponse = await parse_create_keypair_ttlv_response(result_str); - return `Key pair has been created. Private key Id: ${result.PrivateKeyUniqueIdentifier} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; + const skId = result.PrivateKeyUniqueIdentifier; + + // Apply rotation policy on the private key (keyset anchor) + if (values.rotateName || values.rotateInterval !== undefined || values.rotateOffset !== undefined) { + if (values.rotateInterval !== undefined) { + const req = wasm.set_rotate_interval_ttlv_request(skId, BigInt(values.rotateInterval)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateOffset !== undefined) { + const req = wasm.set_rotate_offset_ttlv_request(skId, BigInt(values.rotateOffset)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateName) { + const req = wasm.set_rotate_name_ttlv_request(skId, values.rotateName); + await sendKmipRequest(req, idToken, serverUrl); + } + } + + return `Key pair has been created. Private key Id: ${skId} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; } }); }; @@ -96,6 +118,30 @@ const RsaKeyCreateForm: React.FC = () => { Sensitive + + + Rotation Policy (optional) + + + + + + + + + + + + + + +
+ + + + ); +}; + +export default RsaReKeyForm; diff --git a/ui/src/actions/Keys/GetRotationPolicy.tsx b/ui/src/actions/RotationPolicy/GetRotationPolicy.tsx similarity index 79% rename from ui/src/actions/Keys/GetRotationPolicy.tsx rename to ui/src/actions/RotationPolicy/GetRotationPolicy.tsx index d24d1fa239..79111fea8a 100644 --- a/ui/src/actions/Keys/GetRotationPolicy.tsx +++ b/ui/src/actions/RotationPolicy/GetRotationPolicy.tsx @@ -49,11 +49,7 @@ const GetRotationPolicyForm: React.FC = () => {
- + @@ -76,21 +72,11 @@ const GetRotationPolicyForm: React.FC = () => { {policy && (policy.interval || policy.name || policy.generation) && ( - - {policy.interval ?? "Not set"} - - - {policy.offset ?? "Not set"} - - - {policy.name ?? "Not set"} - - - {policy.generation ?? "Not set"} - - - {policy.date ?? "Never"} - + {policy.interval ?? "Not set"} + {policy.offset ?? "Not set"} + {policy.name ?? "Not set"} + {policy.generation ?? "Not set"} + {policy.date ?? "Never"} )} diff --git a/ui/src/actions/Keys/SetRotationPolicy.tsx b/ui/src/actions/RotationPolicy/SetRotationPolicy.tsx similarity index 71% rename from ui/src/actions/Keys/SetRotationPolicy.tsx rename to ui/src/actions/RotationPolicy/SetRotationPolicy.tsx index 0c316bc4dd..551e6e650c 100644 --- a/ui/src/actions/Keys/SetRotationPolicy.tsx +++ b/ui/src/actions/RotationPolicy/SetRotationPolicy.tsx @@ -7,7 +7,7 @@ import * as wasm from "../../wasm/pkg"; interface SetRotationPolicyFormData { keyId: string; - interval: number; + interval?: number; offset?: number; name?: string; } @@ -18,13 +18,13 @@ const SetRotationPolicyForm: React.FC = () => { const onFinish = async (values: SetRotationPolicyFormData) => { await execute(async () => { - // Set the rotation interval (required) - const intervalRequest = wasm.set_rotate_interval_ttlv_request(values.keyId, BigInt(values.interval)); - const intervalResult = await sendKmipRequest(intervalRequest, idToken, serverUrl); - if (!intervalResult) return; - wasm.parse_set_attribute_ttlv_response(intervalResult); + if (values.interval !== undefined && values.interval !== null) { + const intervalRequest = wasm.set_rotate_interval_ttlv_request(values.keyId, BigInt(values.interval)); + const intervalResult = await sendKmipRequest(intervalRequest, idToken, serverUrl); + if (!intervalResult) return; + wasm.parse_set_attribute_ttlv_response(intervalResult); + } - // Set the rotation offset (optional) if (values.offset !== undefined && values.offset !== null) { const offsetRequest = wasm.set_rotate_offset_ttlv_request(values.keyId, BigInt(values.offset)); const offsetResult = await sendKmipRequest(offsetRequest, idToken, serverUrl); @@ -32,7 +32,6 @@ const SetRotationPolicyForm: React.FC = () => { wasm.parse_set_attribute_ttlv_response(offsetResult); } - // Set the rotation name (optional) if (values.name) { const nameRequest = wasm.set_rotate_name_ttlv_request(values.keyId, values.name); const nameResult = await sendKmipRequest(nameRequest, idToken, serverUrl); @@ -49,7 +48,7 @@ const SetRotationPolicyForm: React.FC = () => {

Set Rotation Policy

-

Configure an automatic periodic rotation policy on a symmetric key.

+

Configure an automatic periodic rotation policy on a key.

  • The interval defines how often (in seconds) the key is automatically rotated.
  • The offset defines the delay (in seconds) before activation of a newly rotated key.
  • @@ -60,28 +59,15 @@ const SetRotationPolicyForm: React.FC = () => { - + - - + + - + @@ -90,7 +76,7 @@ const SetRotationPolicyForm: React.FC = () => { label="Keyset Name" help="Optional: a name for addressing key generations (name@latest, name@first, name@N)" > - + diff --git a/ui/src/components/common/Locate.tsx b/ui/src/components/common/Locate.tsx index c48320fc3f..4fde5dd958 100644 --- a/ui/src/components/common/Locate.tsx +++ b/ui/src/components/common/Locate.tsx @@ -11,6 +11,29 @@ const formatUnixDate = (unixMs: number): string => { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }; +/** Attribute keys fetched for every located row (used in all enrichment calls). */ +const ENRICH_ATTRIBUTE_KEYS = [ + "object_type", + "state", + "tags", + "user_tags", + "cryptographic_algorithm", + "cryptographic_length", + "key_format_type", + "public_key_id", + "private_key_id", + "certificate_id", + "initial_date", + "activation_date", + "original_creation_date", + "rotate_date", + "rotate_name", + "rotate_interval", + "rotate_offset", + "rotate_generation", + "rotate_latest", +]; + interface LocateObjectRow { object_id: string; state?: string; @@ -138,18 +161,7 @@ const LocateForm: React.FC = () => { const getReq = wasm.get_attributes_ttlv_request(uid); const getRespStr = await sendKmipRequest(getReq, idToken, serverUrl); if (getRespStr) { - const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, [ - "object_type", - "state", - "tags", - "user_tags", - "cryptographic_algorithm", - "cryptographic_length", - "key_format_type", - "public_key_id", - "private_key_id", - "certificate_id", - ]); + const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, ENRICH_ATTRIBUTE_KEYS); const m = extractMeta(parsed); // HSM keys are always Active; use that as default when state is missing const isHsm = /^hsm[0-9]*::/.test(uid); @@ -288,18 +300,7 @@ const LocateForm: React.FC = () => { const getReq = wasm.get_attributes_ttlv_request(uid); const getRespStr = await sendKmipRequest(getReq, idToken, serverUrl); if (getRespStr) { - const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, [ - "object_type", - "state", - "tags", - "user_tags", - "cryptographic_algorithm", - "cryptographic_length", - "key_format_type", - "public_key_id", - "private_key_id", - "certificate_id", - ]); + const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, ENRICH_ATTRIBUTE_KEYS); const m = extractMeta(parsed); return { object_id: uid, @@ -356,18 +357,7 @@ const LocateForm: React.FC = () => { const getReq = wasm.get_attributes_ttlv_request(uid); const getRespStr = await sendKmipRequest(getReq, idToken, serverUrl); if (getRespStr) { - const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, [ - "object_type", - "state", - "tags", - "user_tags", - "cryptographic_algorithm", - "cryptographic_length", - "key_format_type", - "public_key_id", - "private_key_id", - "certificate_id", - ]); + const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, ENRICH_ATTRIBUTE_KEYS); const m = extractMeta(parsed); return { object_id: uid, @@ -528,15 +518,7 @@ const LocateForm: React.FC = () => { const getReq = wasm.get_attributes_ttlv_request(uid); const getRespStr = await sendKmipRequest(getReq, idToken, serverUrl); if (getRespStr) { - const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, [ - "object_type", - "state", - "tags", - "user_tags", - "cryptographic_algorithm", - "cryptographic_length", - "key_format_type", - ]); + const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, ENRICH_ATTRIBUTE_KEYS); const m = extractMeta(parsed); return { object_id: uid, diff --git a/ui/src/menuItems.tsx b/ui/src/menuItems.tsx index d43e284313..39e1b32ade 100644 --- a/ui/src/menuItems.tsx +++ b/ui/src/menuItems.tsx @@ -13,6 +13,7 @@ import { SafetyCertificateOutlined, SearchOutlined, SolutionOutlined, + SyncOutlined, TeamOutlined, ToolOutlined, UsbOutlined, @@ -52,8 +53,6 @@ const baseMenu: MenuItem[] = [ { key: "sym/keys/export", label: "Export" }, { key: "sym/keys/import", label: "Import" }, { key: "sym/keys/rekey", label: "Re-Key" }, - { key: "sym/keys/set-rotation-policy", label: "Set Rotation Policy" }, - { key: "sym/keys/get-rotation-policy", label: "Get Rotation Policy" }, { key: "sym/keys/revoke", label: "Revoke" }, { key: "sym/keys/destroy", label: "Destroy" }, ], @@ -76,6 +75,7 @@ const baseMenu: MenuItem[] = [ { key: "rsa/keys/create", label: "Create" }, { key: "rsa/keys/export", label: "Export" }, { key: "rsa/keys/import", label: "Import" }, + { key: "rsa/keys/rekey", label: "Re-Key" }, { key: "rsa/keys/revoke", label: "Revoke" }, { key: "rsa/keys/destroy", label: "Destroy" }, ], @@ -99,6 +99,7 @@ const baseMenu: MenuItem[] = [ { key: "ec/keys/create", label: "Create" }, { key: "ec/keys/export", label: "Export" }, { key: "ec/keys/import", label: "Import" }, + { key: "ec/keys/rekey", label: "Re-Key" }, { key: "ec/keys/revoke", label: "Revoke" }, { key: "ec/keys/destroy", label: "Destroy" }, ], @@ -123,6 +124,7 @@ const baseMenu: MenuItem[] = [ { key: "pqc/keys/create", label: "Create" }, { key: "pqc/keys/export", label: "Export" }, { key: "pqc/keys/import", label: "Import" }, + { key: "pqc/keys/rekey", label: "Re-Key" }, { key: "pqc/keys/revoke", label: "Revoke" }, { key: "pqc/keys/destroy", label: "Destroy" }, ], @@ -133,6 +135,46 @@ const baseMenu: MenuItem[] = [ { key: "pqc/verify", label: "Verify" }, ], }, + { + key: "rotation-policy", + label: "Rotation Policy", + icon: , + collapsedlabel: "ROT", + children: [ + { + key: "rotation-policy/sym", + label: "Symmetric", + children: [ + { key: "rotation-policy/sym/set", label: "Set Policy" }, + { key: "rotation-policy/sym/get", label: "Get Policy" }, + ], + }, + { + key: "rotation-policy/rsa", + label: "RSA", + children: [ + { key: "rotation-policy/rsa/set", label: "Set Policy" }, + { key: "rotation-policy/rsa/get", label: "Get Policy" }, + ], + }, + { + key: "rotation-policy/ec", + label: "Elliptic Curve", + children: [ + { key: "rotation-policy/ec/set", label: "Set Policy" }, + { key: "rotation-policy/ec/get", label: "Get Policy" }, + ], + }, + { + key: "rotation-policy/pqc", + label: "__PQC_ROTATION_LABEL__", + children: [ + { key: "rotation-policy/pqc/set", label: "Set Policy" }, + { key: "rotation-policy/pqc/get", label: "Get Policy" }, + ], + }, + ], + }, { key: "mac", label: "MAC", @@ -326,11 +368,32 @@ export function getMenuItems(options?: { enableCovercrypt?: boolean; pqcLabel?: const pqcLabel = options?.pqcLabel ?? "PQC"; const isFips = options?.isFips ?? false; - let menu = baseMenu.map((item) => (item.key === "pqc" ? { ...item, label: pqcLabel } : item)); + let menu = baseMenu.map((item) => { + if (item.key === "pqc") return { ...item, label: pqcLabel }; + if (item.key === "rotation-policy") { + // Replace the PQC child label placeholder with the real pqcLabel + return { + ...item, + children: item.children?.map((child) => (child.key === "rotation-policy/pqc" ? { ...child, label: pqcLabel } : child)), + }; + } + return item; + }); // Hide PQC, MAC, FPE, and Tokenize/Anonymize in FIPS mode (not approved / not available in FIPS build) + // For rotation-policy, keep the menu but hide the PQC child. if (isFips) { - menu = menu.filter((item) => item.key !== "pqc" && item.key !== "mac" && item.key !== "fpe" && item.key !== "tokenize"); + menu = menu + .filter((item) => item.key !== "pqc" && item.key !== "mac" && item.key !== "fpe" && item.key !== "tokenize") + .map((item) => { + if (item.key === "rotation-policy") { + return { + ...item, + children: item.children?.filter((child) => child.key !== "rotation-policy/pqc"), + }; + } + return item; + }); } // Insert Covercrypt immediately after PQC so Hyperscalers stays last diff --git a/ui/tests/e2e/README.md b/ui/tests/e2e/README.md index 348d7b8a0c..9854534b98 100644 --- a/ui/tests/e2e/README.md +++ b/ui/tests/e2e/README.md @@ -123,6 +123,46 @@ graph LR Covers ECIES encryption and ECDSA signing on NIST P-256. +## Key Rotation Policy + +_PQC tests skipped in FIPS mode (`PLAYWRIGHT_FIPS_MODE=true`)._ + +### rotation-policy + +Covers set-rotation-policy, get-rotation-policy and rekey for all four key types: +symmetric (AES), RSA, EC (P-256), and PQC (ML-DSA-44). + +```mermaid +graph LR + subgraph Symmetric + A1[Create AES key] --> A2[Set rotation policy\ninterval=86400, name=sym-keyset] + A2 --> A3[Get rotation policy\nassert card + interval value] + A1 --> A4[Re-key → new UID ≠ old] + end + subgraph RSA + B1[Create RSA pair] --> B2[Set rotation policy\ninterval=604800] + B2 --> B3[Get rotation policy] + B1 --> B4[Re-key → new priv + pub UIDs] + end + subgraph EC + C1[Create EC P-256 pair] --> C2[Set rotation policy\ninterval=2592000] + C2 --> C3[Get rotation policy] + C1 --> C4[Re-key → new priv + pub UIDs] + end + subgraph "PQC (non-FIPS)" + D1[Create ML-DSA-44 pair] --> D2[Set rotation policy] + D2 --> D3[Get rotation policy] + D1 --> D4[Re-key → new priv + pub UIDs] + end +``` + +Tests: + +- **set rotation policy** — configure interval, optional offset and keyset name; assert "Rotation policy set successfully" +- **get rotation policy (with policy)** — assert details card is visible and contains the configured interval +- **get rotation policy (no policy)** — assert "No rotation policy configured" message for a fresh key (symmetric only) +- **re-key** — rotate key material and verify the returned UID is a valid UUID different from the original (keypair variants assert both private and public UIDs) + ## Certificates ### certificates-flow diff --git a/ui/tests/e2e/certificates-certify.spec.ts b/ui/tests/e2e/certificates-certify.spec.ts index c8339aa3ec..8e2c78f27c 100644 --- a/ui/tests/e2e/certificates-certify.spec.ts +++ b/ui/tests/e2e/certificates-certify.spec.ts @@ -184,14 +184,17 @@ test.describe("Certificate certify – re-certify", () => { // Create a base certificate first const originalId = await createCertificate(page, "NIST P-256"); - // Re-certify it + // Re-certify it — calls the dedicated KMIP ReCertify operation (Option 3), + // which issues a brand-new certificate with a fresh UID. await gotoAndWait(page, "/ui/certificates/certs/certify"); await page.getByText("3. Certificate ID to Re-certify").click(); await page.fill('input[placeholder="Enter certificate ID to re-certify"]', originalId); const text = await submitAndWaitForResponse(page); - expect(text).toMatch(/certificate successfully created/i); + expect(text).toMatch(/certificate successfully re-certified/i); const newId = extractUuid(text); expect(newId).not.toBeNull(); + // ReCertify must produce a new UID, not overwrite the original. + expect(newId).not.toBe(originalId); }); }); diff --git a/ui/tests/e2e/rotation-policy.spec.ts b/ui/tests/e2e/rotation-policy.spec.ts new file mode 100644 index 0000000000..025b953afa --- /dev/null +++ b/ui/tests/e2e/rotation-policy.spec.ts @@ -0,0 +1,348 @@ +/** + * Key rotation policy E2E tests. + * + * Covers per-key-type (symmetric, RSA, EC, PQC): + * • set-rotation-policy – configure interval, offset and keyset name + * • get-rotation-policy – retrieve and verify the configured values + * • get-rotation-policy – returns "no policy" for fresh (unconfigured) keys + * • rekey – rotate the key and verify a new UID is returned + * + * Also covers certificate renewal via the KMIP ReCertify operation (Option 3 + * on the Certificate Issuance page): + * • re-certify self-signed RSA certificate → new UID ≠ original UID + * • re-certify self-signed EC P-256 certificate → new UID ≠ original UID + * • re-certify PQC ML-DSA-44 certificate (skip FIPS) → new UID ≠ original UID + * + * PQC tests are skipped when running in FIPS mode because ML-DSA / ML-KEM are + * not FIPS-approved algorithms. + */ + +import { expect, test } from "@playwright/test"; +import { + UI_READY_TIMEOUT, + createCertificate, + createEcKeyPair, + createPqcKeyPair, + createRsaKeyPair, + createSymKey, + extractUuid, + extractUuidAfterLabel, + gotoAndWait, + submitAndWaitForResponse, +} from "./helpers"; + +const FIPS_MODE = process.env.PLAYWRIGHT_FIPS_MODE === "true"; + +// --------------------------------------------------------------------------- +// Symmetric key rotation +// --------------------------------------------------------------------------- + +test.describe("Symmetric key rotation policy", () => { + test("set rotation policy on AES key", async ({ page }) => { + const keyId = await createSymKey(page); + + await gotoAndWait(page, "/ui/rotation-policy/sym/set"); + await page.fill('[data-testid="rotation-key-id"]', keyId); + // AntD v5 InputNumber passes data-testid to the itself (via rc-input-number inputProps). + await page.locator('[data-testid="rotation-interval"]').fill("86400"); + await page.locator('[data-testid="rotation-offset"]').fill("3600"); + await page.fill('[data-testid="rotation-name"]', "sym-keyset"); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy set successfully/i); + }); + + test("get rotation policy shows configured values", async ({ page }) => { + const keyId = await createSymKey(page); + + // First set a policy. + await gotoAndWait(page, "/ui/rotation-policy/sym/set"); + await page.fill('[data-testid="rotation-key-id"]', keyId); + await page.locator('[data-testid="rotation-interval"]').fill("86400"); + await page.fill('[data-testid="rotation-name"]', "sym-get-test"); + await submitAndWaitForResponse(page); + + // Then retrieve it. + await gotoAndWait(page, "/ui/rotation-policy/sym/get"); + await page.fill('[data-testid="get-rotation-key-id"]', keyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy retrieved successfully/i); + + // The details card should appear because interval is set. + await expect(page.locator('[data-testid="rotation-policy-details"]')).toBeVisible({ + timeout: UI_READY_TIMEOUT, + }); + // The card must contain the interval value we set. + await expect(page.locator('[data-testid="rotation-policy-details"]')).toContainText("86400"); + }); + + test("get rotation policy returns no-policy message for fresh key", async ({ page }) => { + const keyId = await createSymKey(page); + + await gotoAndWait(page, "/ui/rotation-policy/sym/get"); + await page.fill('[data-testid="get-rotation-key-id"]', keyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/no rotation policy configured/i); + }); + + test("re-key symmetric key returns a different UID", async ({ page }) => { + const keyId = await createSymKey(page); + + await gotoAndWait(page, "/ui/sym/keys/rekey"); + await page.fill('[data-testid="rekey-key-id"]', keyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/successfully refreshed/i); + expect(text).toMatch(/New key:/i); + + // The response must contain a valid UUID different from the original. + const newId = extractUuid(text); + expect(newId).not.toBeNull(); + expect(newId).not.toBe(keyId); + }); +}); + +// --------------------------------------------------------------------------- +// RSA key rotation +// --------------------------------------------------------------------------- + +test.describe("RSA key rotation policy", () => { + test("set rotation policy on RSA key pair", async ({ page }) => { + const { privKeyId } = await createRsaKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/rsa/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("604800"); + await page.fill('[data-testid="rotation-name"]', "rsa-keyset"); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy set successfully/i); + }); + + test("get rotation policy shows configured values for RSA key", async ({ page }) => { + const { privKeyId } = await createRsaKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/rsa/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("604800"); + await page.fill('[data-testid="rotation-name"]', "rsa-get-test"); + await submitAndWaitForResponse(page); + + await gotoAndWait(page, "/ui/rotation-policy/rsa/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy retrieved successfully/i); + + await expect(page.locator('[data-testid="rotation-policy-details"]')).toBeVisible({ + timeout: UI_READY_TIMEOUT, + }); + await expect(page.locator('[data-testid="rotation-policy-details"]')).toContainText("604800"); + }); + + test("get rotation policy returns no-policy message for fresh RSA key", async ({ page }) => { + const { privKeyId } = await createRsaKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/rsa/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/no rotation policy configured/i); + }); + + test("re-key RSA key pair returns new private and public key UIDs", async ({ page }) => { + const { privKeyId } = await createRsaKeyPair(page); + + await gotoAndWait(page, "/ui/rsa/keys/rekey"); + await page.fill('[data-testid="rekey-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/RSA key pair was successfully rotated/i); + + const newPrivId = extractUuidAfterLabel(text, "New private key"); + const newPubId = extractUuidAfterLabel(text, "New public key"); + expect(newPrivId).not.toBeNull(); + expect(newPubId).not.toBeNull(); + expect(newPrivId).not.toBe(privKeyId); + }); +}); + +// --------------------------------------------------------------------------- +// EC key rotation +// --------------------------------------------------------------------------- + +test.describe("EC key rotation policy", () => { + test("set rotation policy on EC key pair", async ({ page }) => { + const { privKeyId } = await createEcKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/ec/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("2592000"); + await page.fill('[data-testid="rotation-name"]', "ec-keyset"); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy set successfully/i); + }); + + test("get rotation policy shows configured values for EC key", async ({ page }) => { + const { privKeyId } = await createEcKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/ec/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("2592000"); + await page.fill('[data-testid="rotation-name"]', "ec-get-test"); + await submitAndWaitForResponse(page); + + await gotoAndWait(page, "/ui/rotation-policy/ec/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy retrieved successfully/i); + + await expect(page.locator('[data-testid="rotation-policy-details"]')).toBeVisible({ + timeout: UI_READY_TIMEOUT, + }); + await expect(page.locator('[data-testid="rotation-policy-details"]')).toContainText("2592000"); + }); + + test("get rotation policy returns no-policy message for fresh EC key", async ({ page }) => { + const { privKeyId } = await createEcKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/ec/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/no rotation policy configured/i); + }); + + test("re-key EC key pair returns new private and public key UIDs", async ({ page }) => { + const { privKeyId } = await createEcKeyPair(page); + + await gotoAndWait(page, "/ui/ec/keys/rekey"); + await page.fill('[data-testid="rekey-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/EC key pair was successfully rotated/i); + + const newPrivId = extractUuidAfterLabel(text, "New private key"); + const newPubId = extractUuidAfterLabel(text, "New public key"); + expect(newPrivId).not.toBeNull(); + expect(newPubId).not.toBeNull(); + expect(newPrivId).not.toBe(privKeyId); + }); +}); + +// --------------------------------------------------------------------------- +// PQC key rotation (ML-DSA-44; skipped in FIPS mode) +// --------------------------------------------------------------------------- + +test.describe("PQC key rotation policy", () => { + test.skip(FIPS_MODE, "PQC algorithms are not available in FIPS mode"); + + test("set rotation policy on PQC key pair", async ({ page }) => { + const { privKeyId } = await createPqcKeyPair(page, "ML-DSA-44"); + + await gotoAndWait(page, "/ui/rotation-policy/pqc/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("86400"); + await page.fill('[data-testid="rotation-name"]', "pqc-keyset"); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy set successfully/i); + }); + + test("get rotation policy shows configured values for PQC key", async ({ page }) => { + const { privKeyId } = await createPqcKeyPair(page, "ML-DSA-44"); + + await gotoAndWait(page, "/ui/rotation-policy/pqc/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("86400"); + await page.fill('[data-testid="rotation-name"]', "pqc-get-test"); + await submitAndWaitForResponse(page); + + await gotoAndWait(page, "/ui/rotation-policy/pqc/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy retrieved successfully/i); + + await expect(page.locator('[data-testid="rotation-policy-details"]')).toBeVisible({ + timeout: UI_READY_TIMEOUT, + }); + await expect(page.locator('[data-testid="rotation-policy-details"]')).toContainText("86400"); + }); + + test("get rotation policy returns no-policy message for fresh PQC key", async ({ page }) => { + const { privKeyId } = await createPqcKeyPair(page, "ML-DSA-44"); + + await gotoAndWait(page, "/ui/rotation-policy/pqc/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/no rotation policy configured/i); + }); + + test("re-key PQC key pair returns new private and public key UIDs", async ({ page }) => { + const { privKeyId } = await createPqcKeyPair(page, "ML-DSA-44"); + + await gotoAndWait(page, "/ui/pqc/keys/rekey"); + await page.fill('[data-testid="rekey-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/post-quantum key pair was successfully rotated/i); + + const newPrivId = extractUuidAfterLabel(text, "New private key"); + const newPubId = extractUuidAfterLabel(text, "New public key"); + expect(newPrivId).not.toBeNull(); + expect(newPubId).not.toBeNull(); + expect(newPrivId).not.toBe(privKeyId); + }); +}); + +// --------------------------------------------------------------------------- +// Certificate renewal (KMIP ReCertify operation) +// --------------------------------------------------------------------------- + +test.describe("Certificate renewal (ReCertify)", () => { + test("re-certify self-signed RSA certificate returns a new distinct UID", async ({ page }) => { + // Create a base self-signed certificate (generates a key pair internally). + const originalId = await createCertificate(page, "RSA 2048"); + + // Re-certify it via Option 3 — this must call the dedicated KMIP ReCertify + // operation, which creates a brand-new certificate with a fresh UID and links + // the old and new certs via ReplacedObjectLink / ReplacementObjectLink. + await gotoAndWait(page, "/ui/certificates/certs/certify"); + await page.getByText("3. Certificate ID to Re-certify").click(); + await page.fill('input[placeholder="Enter certificate ID to re-certify"]', originalId); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/certificate successfully re-certified/i); + + // The new UID must be present and differ from the original. + const newId = extractUuid(text); + expect(newId).not.toBeNull(); + expect(newId).not.toBe(originalId); + }); + + test("re-certify self-signed EC P-256 certificate returns a new distinct UID", async ({ page }) => { + const originalId = await createCertificate(page, "NIST P-256"); + + await gotoAndWait(page, "/ui/certificates/certs/certify"); + await page.getByText("3. Certificate ID to Re-certify").click(); + await page.fill('input[placeholder="Enter certificate ID to re-certify"]', originalId); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/certificate successfully re-certified/i); + + const newId = extractUuid(text); + expect(newId).not.toBeNull(); + expect(newId).not.toBe(originalId); + }); + + test("re-certify PQC ML-DSA-44 certificate returns a new distinct UID", async ({ page }) => { + test.skip(FIPS_MODE, "PQC algorithms are not available in FIPS mode"); + + const originalId = await createCertificate(page, "ML-DSA-44 (PQC)"); + + await gotoAndWait(page, "/ui/certificates/certs/certify"); + await page.getByText("3. Certificate ID to Re-certify").click(); + await page.fill('input[placeholder="Enter certificate ID to re-certify"]', originalId); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/certificate successfully re-certified/i); + + const newId = extractUuid(text); + expect(newId).not.toBeNull(); + expect(newId).not.toBe(originalId); + }); +}); From 621e4550ee301fd8fc6a901c7765d639cb2b9cc6 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sun, 14 Jun 2026 23:03:45 +0200 Subject: [PATCH 20/30] fix: prevent user from configuring small values for auto_rotation_check_interval_secs --- .github/scripts/windows/windows_ui.ps1 | 5 ++++- CHANGELOG/docs_key-autorotation-spec.md | 5 +++++ crate/server/src/config/command_line/clap_config.rs | 1 + crate/server/src/config/params/server_params.rs | 13 ++++++++++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/scripts/windows/windows_ui.ps1 b/.github/scripts/windows/windows_ui.ps1 index 9ef65e1571..12d40b5afe 100644 --- a/.github/scripts/windows/windows_ui.ps1 +++ b/.github/scripts/windows/windows_ui.ps1 @@ -16,8 +16,11 @@ function Build-UI { rustup target add wasm32-unknown-unknown # Install wasm-bindgen-cli with matching version + # --locked pins the exact dependency versions shipped with the crate's Cargo.lock, + # preventing newly published transitive dependencies (e.g. brotli-decompressor v5) + # from breaking compilation on the current toolchain. Write-Host "Installing wasm-bindgen-cli 0.2.108..." - cargo install wasm-bindgen-cli --version 0.2.108 --force + cargo install wasm-bindgen-cli --version 0.2.108 --locked --force # Build WASM package Write-Host "Building WASM package..." diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md index a35b746873..11ea6c9ae8 100644 --- a/CHANGELOG/docs_key-autorotation-spec.md +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -11,6 +11,7 @@ - Implement keyset resolution: `name@latest`, `name@first`, `name@N` syntax to address specific key generations by `rotate_name` ([#968](https://github.com/Cosmian/kms/pull/968)) - Implement try-each-key decryption: Decrypt, SignatureVerify, and MACVerify operations with a bare keyset name walk the rotation chain (newest→oldest) until one key succeeds ([#968](https://github.com/Cosmian/kms/pull/968)) - Add `--keyset-decrypt-max-attempts` server config flag (default: 100) to cap rotation chain traversal depth ([#968](https://github.com/Cosmian/kms/pull/968)) +- Enforce a minimum of 60 seconds for `auto_rotation_check_interval_secs` when non-zero, to prevent high-frequency database scans from overloading the server ([#968](https://github.com/Cosmian/kms/pull/968)) - Add `find_by_rotate_name()` to `ObjectsStore` trait with SQLite, PostgreSQL, and MySQL implementations for keyset lookup ([#968](https://github.com/Cosmian/kms/pull/968)) - Inherit `rotate_name` from old key to new key during ReKey so keyset resolution works across generations ([#968](https://github.com/Cosmian/kms/pull/968)) - Implement `ckms sym keys set-rotation-policy` CLI command with `--interval`, `--offset`, `--rotation-name` flags ([#968](https://github.com/Cosmian/kms/pull/968)) @@ -19,6 +20,10 @@ - Add HSM keyset support: store keyset metadata in `CKA_LABEL` (`name::gen::base_id[::latest]`); `SetAttribute rotate_name` writes `CKA_LABEL`; `SetAttribute rotate_interval` writes `CKA_START_DATE`/`CKA_END_DATE`; `Re-Key` on HSM UIDs generates a new HSM key and updates both CKA_LABELs; keyset resolution via `find_by_rotate_name` enumerates PKCS#11 objects and sorts by generation ([#968](https://github.com/Cosmian/kms/pull/968)) - Enrich `HsmStore::retrieve()` export path with CKA_LABEL keyset metadata (`rotate_name`, `rotate_generation`, `rotate_latest`) so the non-latest guard works for extractable HSM keys ([#968](https://github.com/Cosmian/kms/pull/968)) +## Bug Fixes + +- Fix Windows CI: add `--locked` to `cargo install wasm-bindgen-cli` in `windows_ui.ps1` to prevent newly-published `brotli-decompressor v5` from breaking the build on Rust 1.91 ([#968](https://github.com/Cosmian/kms/pull/968)) + ## Security - Mark `x-rotate-generation` and `x-rotate-date` as server-managed read-only attributes: reject user modifications via AddAttribute, SetAttribute, ModifyAttribute, and DeleteAttribute ([#968](https://github.com/Cosmian/kms/pull/968)) diff --git a/crate/server/src/config/command_line/clap_config.rs b/crate/server/src/config/command_line/clap_config.rs index 59345eff5e..946ca4afd8 100644 --- a/crate/server/src/config/command_line/clap_config.rs +++ b/crate/server/src/config/command_line/clap_config.rs @@ -218,6 +218,7 @@ pub struct ClapConfig { /// Interval in seconds between background auto-rotation checks. /// Set to 0 (default) to disable the auto-rotation background task. + /// When enabled, must be at least 60 seconds to avoid excessive database churn. #[clap(long, default_value = "0", verbatim_doc_comment)] pub auto_rotation_check_interval_secs: u64, diff --git a/crate/server/src/config/params/server_params.rs b/crate/server/src/config/params/server_params.rs index 2ba5362d85..0ea09d8acf 100644 --- a/crate/server/src/config/params/server_params.rs +++ b/crate/server/src/config/params/server_params.rs @@ -430,7 +430,18 @@ impl ServerParams { crate::config::default_cors_origins(cors_scheme, conf.http.port) }), max_locate_items: 1000, - auto_rotation_check_interval_secs: conf.auto_rotation_check_interval_secs, + auto_rotation_check_interval_secs: { + let v = conf.auto_rotation_check_interval_secs; + // 0 means disabled; any non-zero value must be at least 60 seconds to avoid + // hammering the database with high-frequency key-rotation scans. + if v > 0 && v < 60 { + return Err(KmsError::ServerError(format!( + "auto_rotation_check_interval_secs must be 0 (disabled) or at least 60 \ + seconds; {v} is too small and would cause excessive database churn" + ))); + } + v + }, keyset_decrypt_max_attempts: conf.keyset_decrypt_max_attempts, }; From c4435ba2e3301511be1cf2b63414a394b0a3dab8 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sun, 14 Jun 2026 23:33:05 +0200 Subject: [PATCH 21/30] fix: implement cycle-detection instead or limiting the depth of the traversal --- CHANGELOG/docs_key-autorotation-spec.md | 2 +- crate/clients/client/src/kms_rest_client.rs | 15 ++++++- .../src/config/command_line/clap_config.rs | 19 ++++---- .../server/src/config/params/server_params.rs | 16 +++---- .../src/core/operations/key_ops/crypto_op.rs | 43 +++++++++++++++---- .../server/src/core/operations/key_ops/mod.rs | 2 +- crate/server/src/core/operations/mod.rs | 2 +- crate/server/src/core/uid_utils.rs | 10 +++-- crate/server/src/main.rs | 4 +- crate/server/src/routes/kmip.rs | 26 ++++++++--- 10 files changed, 96 insertions(+), 43 deletions(-) diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md index 11ea6c9ae8..13ff05f57a 100644 --- a/CHANGELOG/docs_key-autorotation-spec.md +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -10,7 +10,7 @@ - Implement KMIP §4.57 transition 6 auto-deactivation: Active keys automatically transition to Deactivated when their DeactivationDate is reached ([#968](https://github.com/Cosmian/kms/pull/968)) - Implement keyset resolution: `name@latest`, `name@first`, `name@N` syntax to address specific key generations by `rotate_name` ([#968](https://github.com/Cosmian/kms/pull/968)) - Implement try-each-key decryption: Decrypt, SignatureVerify, and MACVerify operations with a bare keyset name walk the rotation chain (newest→oldest) until one key succeeds ([#968](https://github.com/Cosmian/kms/pull/968)) -- Add `--keyset-decrypt-max-attempts` server config flag (default: 100) to cap rotation chain traversal depth ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `--keyset-warn-depth` server config flag (default: 5) to trigger a server warning and return `X-KMS-Keyset-Depth` response header when decryption succeeds at depth ≥ threshold; replaces the old `--keyset-decrypt-max-attempts` hard cap — traversal is now unbounded (cycle detection only) so all key generations remain reachable ([#968](https://github.com/Cosmian/kms/pull/968)) - Enforce a minimum of 60 seconds for `auto_rotation_check_interval_secs` when non-zero, to prevent high-frequency database scans from overloading the server ([#968](https://github.com/Cosmian/kms/pull/968)) - Add `find_by_rotate_name()` to `ObjectsStore` trait with SQLite, PostgreSQL, and MySQL implementations for keyset lookup ([#968](https://github.com/Cosmian/kms/pull/968)) - Inherit `rotate_name` from old key to new key during ReKey so keyset resolution works across generations ([#968](https://github.com/Cosmian/kms/pull/968)) diff --git a/crate/clients/client/src/kms_rest_client.rs b/crate/clients/client/src/kms_rest_client.rs index 95d6f57476..9b6855bad3 100644 --- a/crate/clients/client/src/kms_rest_client.rs +++ b/crate/clients/client/src/kms_rest_client.rs @@ -22,7 +22,7 @@ use cosmian_kms_client_utils::reexport::{ UserAccessResponse, }, }; -use cosmian_logger::{info, trace}; +use cosmian_logger::{info, trace, warn}; use serde::Serialize; use crate::{ @@ -811,7 +811,20 @@ impl KmsClient { let response = request.send().await?; let status_code = response.status(); if status_code.is_success() { + // Must read headers BEFORE consuming the response body with `.json()`. + let keyset_depth: Option = response + .headers() + .get("x-kms-keyset-depth") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse().ok()); let ttlv = response.json::().await?; + if let Some(depth) = keyset_depth { + warn!( + "Decryption succeeded at keyset chain depth {depth}. The ciphertext was \ + encrypted with an older key generation. Consider re-encrypting with the \ + current key." + ); + } if self.print_json { println!( "\nKMIP Response <==\n{}\n", diff --git a/crate/server/src/config/command_line/clap_config.rs b/crate/server/src/config/command_line/clap_config.rs index 946ca4afd8..b282625d13 100644 --- a/crate/server/src/config/command_line/clap_config.rs +++ b/crate/server/src/config/command_line/clap_config.rs @@ -71,7 +71,7 @@ impl Default for ClapConfig { kmip_policy: KmipPolicyConfig::default(), azure_ekm_config: AzureEkmConfig::default(), auto_rotation_check_interval_secs: 0, - keyset_decrypt_max_attempts: 100, + keyset_warn_depth: 5, } } } @@ -222,11 +222,13 @@ pub struct ClapConfig { #[clap(long, default_value = "0", verbatim_doc_comment)] pub auto_rotation_check_interval_secs: u64, - /// Maximum number of keys the server will try when decrypting with a keyset - /// identifier (walking the rotation chain from newest to oldest). - /// Prevents unbounded chain traversal. - #[clap(long, default_value = "100", verbatim_doc_comment)] - pub keyset_decrypt_max_attempts: u32, + /// Depth at which a successful keyset chain decryption triggers a warning. + /// Keyset chain traversal is unbounded (stopped only by cycle detection); + /// this threshold emits a server-side warning and returns the depth as the + /// `X-KMS-Keyset-Depth` HTTP header so clients can flag stale ciphertexts. + /// Default: 5. + #[clap(long, default_value = "5", verbatim_doc_comment)] + pub keyset_warn_depth: u32, } impl ClapConfig { @@ -669,10 +671,7 @@ impl fmt::Debug for ClapConfig { "auto_rotation_check_interval_secs", &self.auto_rotation_check_interval_secs, ); - let x = x.field( - "keyset_decrypt_max_attempts", - &self.keyset_decrypt_max_attempts, - ); + let x = x.field("keyset_warn_depth", &self.keyset_warn_depth); x.finish() } diff --git a/crate/server/src/config/params/server_params.rs b/crate/server/src/config/params/server_params.rs index 0ea09d8acf..71194bd828 100644 --- a/crate/server/src/config/params/server_params.rs +++ b/crate/server/src/config/params/server_params.rs @@ -169,9 +169,12 @@ pub struct ServerParams { /// 0 means disabled. pub auto_rotation_check_interval_secs: u64, - /// Maximum number of keys the server will try when decrypting with a keyset - /// identifier (walking the rotation chain from newest to oldest). - pub keyset_decrypt_max_attempts: u32, + /// Depth at which a successful keyset chain decryption triggers a warning. + /// Keyset chain traversal is unbounded (stopped only by cycle detection); this + /// threshold lets operators know when a ciphertext required walking many + /// generations to decrypt — a hint that re-encryption with the latest key may + /// be beneficial. + pub keyset_warn_depth: u32, } /// Represents the server parameters. @@ -442,7 +445,7 @@ impl ServerParams { } v }, - keyset_decrypt_max_attempts: conf.keyset_decrypt_max_attempts, + keyset_warn_depth: conf.keyset_warn_depth, }; debug!("{res:#?}"); @@ -670,10 +673,7 @@ impl fmt::Debug for ServerParams { "auto_rotation_check_interval_secs", &self.auto_rotation_check_interval_secs, ); - debug_struct.field( - "keyset_decrypt_max_attempts", - &self.keyset_decrypt_max_attempts, - ); + debug_struct.field("keyset_warn_depth", &self.keyset_warn_depth); debug_struct.finish() } diff --git a/crate/server/src/core/operations/key_ops/crypto_op.rs b/crate/server/src/core/operations/key_ops/crypto_op.rs index 4bf4a012c3..f11464a32d 100644 --- a/crate/server/src/core/operations/key_ops/crypto_op.rs +++ b/crate/server/src/core/operations/key_ops/crypto_op.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::{cell::Cell, collections::HashSet}; use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ @@ -10,7 +10,15 @@ use cosmian_kms_server_database::reexport::{ }, cosmian_kms_interfaces::ObjectWithMetadata, }; -use cosmian_logger::trace; +use cosmian_logger::{trace, warn}; + +// Task-local that carries the 0-based index (depth) at which a keyset chain +// walk successfully decrypted/verified. Set by `execute_keyset_try_each`; +// read by the HTTP route handler to inject `X-KMS-Keyset-Depth` header. +// Uses `Cell>` so it can be mutated from an immutable reference. +tokio::task_local! { + pub(crate) static KEYSET_CHAIN_DEPTH: Cell>; +} use super::{DatabaseOps, ObjectWithMetadataOps}; use crate::{ @@ -230,8 +238,11 @@ async fn execute_local_with_limits( /// Try each key in a keyset chain (newest→oldest) until one succeeds. /// -/// Used for decrypt/verify operations where the ciphertext may have been -/// encrypted with an older generation key. +/// The traversal is unbounded: `walk_keyset_chain` already guarantees termination +/// via cycle detection. The 0-based index of the successful key is stored in the +/// `KEYSET_CHAIN_DEPTH` task-local so the HTTP route handler can return it as the +/// `X-KMS-Keyset-Depth` response header. A server-side warning is emitted whenever +/// the depth is ≥ `params.keyset_warn_depth`. async fn execute_keyset_try_each( kms: &KMS, chain: &[String], @@ -240,7 +251,7 @@ async fn execute_keyset_try_each( ) -> KResult { let mut last_err: Option = None; - for uid in chain { + for (depth, uid) in chain.iter().enumerate() { let Some(owm) = kms.database.retrieve_object(uid).await? else { continue; }; @@ -270,7 +281,23 @@ async fn execute_keyset_try_each( } match execute_local_with_limits::(kms, owm, request, user).await { - Ok(response) => return Ok(response), + Ok(response) => { + let depth_u32 = u32::try_from(depth).unwrap_or(u32::MAX); + let warn_threshold = kms.params.keyset_warn_depth; + // Store depth for the HTTP layer to forward as a response header. + KEYSET_CHAIN_DEPTH.try_with(|c| c.set(Some(depth_u32))).ok(); + if depth_u32 >= warn_threshold { + warn!( + "{}: keyset chain depth {} ≥ warn threshold {} for uid {}; \ + consider re-encrypting with the latest key", + Op::OP_NAME, + depth_u32, + warn_threshold, + uid + ); + } + return Ok(response); + } Err(e) => { trace!( "execute_keyset_try_each: key {} failed for {}: {}", @@ -486,9 +513,7 @@ pub(crate) async fn resolve_key_for_operation( // Not a keyset → fall through to normal path } KeysetMode::TryEach => { - let max_depth = kms.params.keyset_decrypt_max_attempts; - let chain = - walk_keyset_chain(&keyset_ref.name, kms, user, max_depth).await?; + let chain = walk_keyset_chain(&keyset_ref.name, kms, user).await?; if !chain.is_empty() { return Ok(ResolvedKey::Keyset(chain)); } diff --git a/crate/server/src/core/operations/key_ops/mod.rs b/crate/server/src/core/operations/key_ops/mod.rs index 8d62e9e075..0a5499988a 100644 --- a/crate/server/src/core/operations/key_ops/mod.rs +++ b/crate/server/src/core/operations/key_ops/mod.rs @@ -1,4 +1,4 @@ -mod crypto_op; +pub(crate) mod crypto_op; use cosmian_kms_server_database::{ Database, diff --git a/crate/server/src/core/operations/mod.rs b/crate/server/src/core/operations/mod.rs index a717f9e7ab..81ed6c9140 100644 --- a/crate/server/src/core/operations/mod.rs +++ b/crate/server/src/core/operations/mod.rs @@ -17,7 +17,7 @@ mod export_get; mod get; mod hash; mod import; -mod key_ops; +pub(crate) mod key_ops; mod locate; mod mac; mod message; diff --git a/crate/server/src/core/uid_utils.rs b/crate/server/src/core/uid_utils.rs index 7cf5b6520a..aed3f8d59b 100644 --- a/crate/server/src/core/uid_utils.rs +++ b/crate/server/src/core/uid_utils.rs @@ -224,15 +224,17 @@ pub(crate) async fn resolve_keyset_to_single_uid( /// /// Stops when: /// - No more `ReplacedObjectLink` is found (reached the original key) -/// - The `max_depth` limit is reached -/// - A cycle is detected +/// - A cycle is detected (via a visited-set guard) +/// +/// The traversal is unbounded by design: since the KMS holds a finite number of +/// keys and cycle detection prevents infinite loops, all generations remain reachable +/// for decryption regardless of how many rotations have occurred. /// /// Returns the ordered list of UIDs to try for decryption. pub(crate) async fn walk_keyset_chain( keyset_name: &str, kms: &KMS, user: &str, - max_depth: u32, ) -> KResult> { // Find the latest key in the chain (prefer rotate_latest=true) let mut results = kms @@ -280,7 +282,7 @@ pub(crate) async fn walk_keyset_chain( let mut current_uid = latest_uid; let mut visited: HashSet = chain.iter().cloned().collect(); - for _ in 1..max_depth { + loop { // Retrieve the current object's attributes to find ReplacedObjectLink let Some(owm) = kms.database.retrieve_object(¤t_uid).await? else { break; diff --git a/crate/server/src/main.rs b/crate/server/src/main.rs index caf003986d..893fb82a5d 100644 --- a/crate/server/src/main.rs +++ b/crate/server/src/main.rs @@ -348,7 +348,7 @@ mod tests { privileged_users: None, print_default_config: false, auto_rotation_check_interval_secs: 0, - keyset_decrypt_max_attempts: 100, + keyset_warn_depth: 5, }; let toml_string = r#" @@ -365,7 +365,7 @@ hsm_instances = [] key_encryption_key = "key wrapping key" kms_public_url = "[kms_public_url]" auto_rotation_check_interval_secs = 0 -keyset_decrypt_max_attempts = 100 +keyset_warn_depth = 5 [db] database_type = "[redis-findex, postgresql,...]" diff --git a/crate/server/src/routes/kmip.rs b/crate/server/src/routes/kmip.rs index 44a98de45e..eb94b3330a 100644 --- a/crate/server/src/routes/kmip.rs +++ b/crate/server/src/routes/kmip.rs @@ -1,10 +1,10 @@ -use std::sync::Arc; +use std::{cell::Cell, sync::Arc}; use actix_web::{ HttpRequest, HttpResponse, http::header::CONTENT_TYPE, post, - web::{Bytes, Data, Json}, + web::{Bytes, Data}, }; use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ @@ -28,6 +28,7 @@ use tracing::Instrument; use crate::{ core::{ KMS, + operations::key_ops::crypto_op::KEYSET_CHAIN_DEPTH, operations::{dispatch, message}, }, error::KmsError, @@ -152,18 +153,31 @@ pub(crate) async fn kmip_2_1_json( req_http: HttpRequest, body: String, kms: Data>, -) -> KResult> { +) -> KResult { let ttlv = serde_json::from_str::(&body)?; let user = kms.get_user(&req_http); info!(target: "kmip", user=user, tag=ttlv.tag.as_str(), "POST /kmip/2_1. Request: {:?} {}", ttlv.tag.as_str(), user); let span = tracing::info_span!("kmip_2_1", user = user.as_str(), tag = ttlv.tag.as_str()); - let ttlv = Box::pin(handle_ttlv(&kms, ttlv, &user, 2, 1)) - .instrument(span) + + // Scope the task-local so `execute_keyset_try_each` can record the chain depth. + let (ttlv, depth) = KEYSET_CHAIN_DEPTH + .scope(std::cell::Cell::new(None), async { + let ttlv = Box::pin(handle_ttlv(&kms, ttlv, &user, 2, 1)) + .instrument(span) + .await?; + let depth = KEYSET_CHAIN_DEPTH.with(Cell::get); + Ok::<_, KmsError>((ttlv, depth)) + }) .await?; - Ok(Json(ttlv)) + let mut builder = HttpResponse::Ok(); + builder.content_type("application/json"); + if let Some(d) = depth { + builder.insert_header(("X-KMS-Keyset-Depth", d.to_string())); + } + Ok(builder.json(ttlv)) } /// Handle input TTLV requests From a76d76056b6e2bddbf5700cf863115758ae4e4bb Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 15 Jun 2026 07:30:14 +0200 Subject: [PATCH 22/30] fix: number of secs in a day --- crate/server/src/core/operations/attributes/set.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crate/server/src/core/operations/attributes/set.rs b/crate/server/src/core/operations/attributes/set.rs index 3129a93a3c..95b9fc94bc 100644 --- a/crate/server/src/core/operations/attributes/set.rs +++ b/crate/server/src/core/operations/attributes/set.rs @@ -212,7 +212,7 @@ pub(crate) async fn set_attribute( kms.database.set_key_label(owm.id(), &label).await?; } else if let Some(interval_secs) = hsm_rotate_interval_secs { let today = OffsetDateTime::now_utc().date(); - let days = interval_secs / 86400; + let days = interval_secs / (24 * 3600); let end_date = today + time::Duration::days(days); trace!( "SetAttribute: writing CKA_START_DATE={} CKA_END_DATE={} on HSM key '{}'", From 17c68bf587b0deebf38226befd09be746b3e38c2 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 15 Jun 2026 08:54:50 +0200 Subject: [PATCH 23/30] fix: HSM RotateInterval scheduling bugs: reject sub-day intervals --- CHANGELOG/docs_key-autorotation-spec.md | 2 + crate/interfaces/src/hsm/hsm_store.rs | 24 +++++++- crate/interfaces/src/lib.rs | 3 + .../src/core/operations/attributes/set.rs | 57 +++++++++++++++---- .../src/core/operations/rekey/symmetric.rs | 9 ++- 5 files changed, 80 insertions(+), 15 deletions(-) diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md index 13ff05f57a..aa27c005ab 100644 --- a/CHANGELOG/docs_key-autorotation-spec.md +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -99,6 +99,8 @@ - Add 7 keyset resolution test vectors: `keyset_encrypt_latest`, `keyset_encrypt_bare_name`, `keyset_encrypt_latest_after_rotation`, `keyset_decrypt_try_each`, `keyset_decrypt_double_rotation`, `keyset_decrypt_at_latest`, `keyset_rotate_name_at_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) - Add HSM keyset support: `find_due_for_rotation` on HsmStore reads `CKA_START_DATE`/`CKA_END_DATE`; keyset metadata stored in `CKA_LABEL` (`name::gen::key_id[::latest]`); `SetAttribute rotate_name` writes `CKA_LABEL` via `C_SetAttributeValue`; `SetAttribute rotate_interval` writes `CKA_START_DATE`/`CKA_END_DATE`; `Re-Key` on HSM UIDs generates a new HSM key and updates both CKA_LABELs; `walk_keyset_chain` resolves HSM keysets by `CKA_LABEL` without `ReplacedObjectLink` ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 3 HSM keyset test vectors: `resident_keyset_set_rotate_name`, `resident_keyset_rekey_and_decrypt`, `resident_keyset_double_rotation` + 1 negative: `hsm_rotate_offset_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix HSM `SetAttribute RotateInterval`: reject sub-day intervals (< 86400 s) that would silently produce `CKA_END_DATE = today` and cause immediate re-rotation on every scheduler tick; treat `RotateInterval = 0` as "disable" and clear the PKCS#11 dates instead of setting `end_date = today`; use ceiling division so non-multiple-of-86400 intervals do not fire one day early ([#968](https://github.com/Cosmian/kms/pull/968)) +- Reconstruct `rotate_interval` attribute from `CKA_START_DATE`/`CKA_END_DATE` in `HsmStore::retrieve` and `build_sensitive_stub_attributes` so that auto-rotation re-key can propagate the rotation schedule to the replacement key ([#968](https://github.com/Cosmian/kms/pull/968)) ## Documentation diff --git a/crate/interfaces/src/hsm/hsm_store.rs b/crate/interfaces/src/hsm/hsm_store.rs index 0fa4787273..61faecaea7 100644 --- a/crate/interfaces/src/hsm/hsm_store.rs +++ b/crate/interfaces/src/hsm/hsm_store.rs @@ -143,13 +143,24 @@ impl ObjectsStore for HsmStore { Ok(Some(hsm_object)) => { let mut owm = to_object_with_metadata(&hsm_object, uid, self.owner_name(), &self.vendor_id)?; - // Enrich attributes with keyset metadata from CKA_LABEL. + // Enrich attributes with keyset metadata from CKA_LABEL and CKA dates. if let Ok(Some(meta)) = self.hsm.get_key_metadata(slot_id, key_id.as_bytes()).await { let attrs = owm.attributes_mut(); attrs.rotate_name = meta.rotate_name; attrs.rotate_generation = meta.rotate_generation; attrs.rotate_latest = meta.rotate_latest; + // Reconstruct rotate_interval from CKA_START_DATE / CKA_END_DATE. + // HsmStore::update_object is a no-op for KMIP attributes so there is + // no persistent KMIP storage for rotate_interval on HSM keys; we + // recover it as (end_date − start_date) × 86400 s so that downstream + // operations (e.g. auto-rotation re-key) can propagate the schedule. + if let (Some(start), Some(end)) = (meta.start_date, meta.end_date) { + let days = (end - start).whole_days(); + if days > 0 { + attrs.rotate_interval = Some(days * crate::SECS_PER_DAY); + } + } } Ok(Some(owm)) } @@ -732,6 +743,16 @@ fn build_sensitive_stub_attributes(meta: &KeyMetadata) -> Attributes { KeyFormatType::PKCS1, ), }; + // Reconstruct rotate_interval from CKA_START_DATE / CKA_END_DATE. + // HsmStore::update_object is a no-op for KMIP attributes, so this is the only + // way to expose the scheduled interval to attribute-only callers (e.g. re-key). + let rotate_interval = match (meta.start_date, meta.end_date) { + (Some(start), Some(end)) => { + let days = (end - start).whole_days(); + if days > 0 { Some(days * crate::SECS_PER_DAY) } else { None } + } + _ => None, + }; Attributes { cryptographic_algorithm: Some(algorithm), cryptographic_length: Some(i32::try_from(meta.key_length_in_bits).unwrap_or_default()), @@ -742,6 +763,7 @@ fn build_sensitive_stub_attributes(meta: &KeyMetadata) -> Attributes { rotate_name: meta.rotate_name.clone(), rotate_generation: meta.rotate_generation, rotate_latest: meta.rotate_latest, + rotate_interval, ..Attributes::default() } } diff --git a/crate/interfaces/src/lib.rs b/crate/interfaces/src/lib.rs index 1596dff0df..e17bac19a3 100644 --- a/crate/interfaces/src/lib.rs +++ b/crate/interfaces/src/lib.rs @@ -13,6 +13,9 @@ pub use hsm::{ }; pub use stores::{AtomicOperation, ObjectWithMetadata, ObjectsStore, PermissionsStore}; +/// Number of seconds in one day — the finest granularity PKCS#11 `CK_DATE` can represent. +pub const SECS_PER_DAY: i64 = 24 * 3600; + /// Supported cryptographic object types /// in plugins #[derive(Debug, Clone, Eq, PartialEq)] diff --git a/crate/server/src/core/operations/attributes/set.rs b/crate/server/src/core/operations/attributes/set.rs index 95b9fc94bc..7f20b813f5 100644 --- a/crate/server/src/core/operations/attributes/set.rs +++ b/crate/server/src/core/operations/attributes/set.rs @@ -11,9 +11,13 @@ use cosmian_kms_server_database::reexport::{ }, cosmian_kms_interfaces::ObjectWithMetadata, }; +use cosmian_kms_server_database::reexport::cosmian_kms_interfaces::SECS_PER_DAY; use cosmian_logger::{debug, trace}; use time::OffsetDateTime; +/// `SECS_PER_DAY - 1`, used for ceiling integer division of seconds into whole days. +const SECS_PER_DAY_MINUS_ONE: i64 = SECS_PER_DAY - 1; + use crate::{ core::{KMS, retrieve_object_utils::retrieve_object_for_operation, uid_utils::has_prefix}, error::KmsError, @@ -211,18 +215,47 @@ pub(crate) async fn set_attribute( ); kms.database.set_key_label(owm.id(), &label).await?; } else if let Some(interval_secs) = hsm_rotate_interval_secs { - let today = OffsetDateTime::now_utc().date(); - let days = interval_secs / (24 * 3600); - let end_date = today + time::Duration::days(days); - trace!( - "SetAttribute: writing CKA_START_DATE={} CKA_END_DATE={} on HSM key '{}'", - today, - end_date, - owm.id() - ); - kms.database - .set_key_rotation_dates(owm.id(), Some(today), Some(end_date)) - .await?; + // CKA_START_DATE / CKA_END_DATE are PKCS#11 CK_DATE fields (year/month/day only — + // no sub-day precision). These dates ARE the scheduling signal used by + // HsmStore::find_due_for_rotation to determine when to auto-rotate the key; + // HsmStore::update_object is a no-op for KMIP attributes, so there is no other + // persistent store for rotate_interval on HSM keys. + if interval_secs == 0 { + // RotateInterval = 0 disables auto-rotation: clear the PKCS#11 dates so + // HsmStore::find_due_for_rotation no longer considers this key overdue. + trace!( + "SetAttribute: clearing CKA_START_DATE / CKA_END_DATE on HSM key '{}' (rotation disabled)", + owm.id() + ); + kms.database + .set_key_rotation_dates(owm.id(), None, None) + .await?; + } else if interval_secs < SECS_PER_DAY { + // PKCS#11 CK_DATE only stores year/month/day. A sub-day interval would map + // to end_date = today (0 whole days), causing the key to be immediately due + // for rotation on every scheduler tick. Reject it explicitly so callers get + // a clear error instead of unexpected behaviour. + return Err(KmsError::InvalidRequest(format!( + "SetAttribute: RotateInterval for HSM key '{}' must be at least 86400 seconds \ + (1 day) because PKCS#11 CK_DATE has day granularity only. Got {interval_secs} s.", + owm.id() + ))); + } else { + let today = OffsetDateTime::now_utc().date(); + // Ceiling-divide so that an interval that is not an exact multiple of 86400 + // does not map to end_date = today (which would trigger immediate rotation). + let days = (interval_secs + SECS_PER_DAY_MINUS_ONE) / SECS_PER_DAY; + let end_date = today + time::Duration::days(days); + trace!( + "SetAttribute: writing CKA_START_DATE={} CKA_END_DATE={} on HSM key '{}'", + today, + end_date, + owm.id() + ); + kms.database + .set_key_rotation_dates(owm.id(), Some(today), Some(end_date)) + .await?; + } } match owm.object().object_type() { diff --git a/crate/server/src/core/operations/rekey/symmetric.rs b/crate/server/src/core/operations/rekey/symmetric.rs index 6033bdf19f..de13eef7e8 100644 --- a/crate/server/src/core/operations/rekey/symmetric.rs +++ b/crate/server/src/core/operations/rekey/symmetric.rs @@ -134,11 +134,16 @@ async fn rekey_hsm_symmetric(kms: &KMS, uid: &str, user: &str) -> KResult = old_attrs .rotate_interval .filter(|&i| i > 0) - .map(|secs| secs / 86400); + .map(|secs| secs / cosmian_kms_server_database::reexport::cosmian_kms_interfaces::SECS_PER_DAY); // Generate the new key on the same HSM slot. kms.database From a573d6a182c6f52320fe3978345037e1249f5854 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 15 Jun 2026 09:04:30 +0200 Subject: [PATCH 24/30] fix: remove risky composing negation of bool values --- crate/server/src/core/operations/key_ops/mod.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crate/server/src/core/operations/key_ops/mod.rs b/crate/server/src/core/operations/key_ops/mod.rs index 0a5499988a..15c450d73f 100644 --- a/crate/server/src/core/operations/key_ops/mod.rs +++ b/crate/server/src/core/operations/key_ops/mod.rs @@ -36,17 +36,18 @@ use crate::{ /// This check applies uniformly to `Create`, `CreateKeyPair`, `Import`, and `Register`. pub(crate) async fn enforce_create_permission(kms: &KMS, user: &str) -> KResult<()> { if let Some(ref users) = kms.params.privileged_users { - let has_permission = user_has_permission(user, None, &KmipOperation::Create, kms).await?; - - if !has_permission - && !users.iter().any(|u| u == user) - && user != kms.params.default_username + if user == kms.params.default_username + || users.iter().any(|u| u == user) + || user_has_permission(user, None, &KmipOperation::Create, kms).await? { + return Ok(()); + } else { kms_bail!(KmsError::Unauthorized( "User does not have create access-right.".to_owned() )) } } + // If no privileged user was set, all users have the `Create` right. Ok(()) } From c3c240d9dc3775bde3f466102e3c1f5e8e504a07 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 15 Jun 2026 09:11:21 +0200 Subject: [PATCH 25/30] fix: do not forbid the rotation of compromised keys --- .../src/core/operations/rekey/common.rs | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crate/server/src/core/operations/rekey/common.rs b/crate/server/src/core/operations/rekey/common.rs index c632f910cb..d05f8c31fb 100644 --- a/crate/server/src/core/operations/rekey/common.rs +++ b/crate/server/src/core/operations/rekey/common.rs @@ -112,9 +112,14 @@ pub(crate) fn preserve_wrapping_key_link( /// Retrieve all eligible objects matching the given identifier, filtered by state and type. /// /// Filters by: -/// - State: `Active` or `Deactivated` (per KMIP §6.1.46 — `Wrong_Key_Lifecycle_State` is NOT -/// listed in the Re-Key error table, meaning Deactivated keys are eligible for rotation). -/// `PreActive`, `Compromised`, `Destroyed`, and `Destroyed_Compromised` keys are rejected. +/// - State: `Active`, `Deactivated`, or `Compromised`. +/// - `Active` / `Deactivated`: clearly eligible per KMIP §6.1.46 (`Wrong_Key_Lifecycle_State` +/// is not listed as a possible error, confirming Deactivated keys are rotatable). +/// - `Compromised`: explicitly allowed because the primary response to a key compromise is +/// to rotate it immediately — blocking rotation of compromised keys would be +/// counter-productive from a security standpoint. +/// - `PreActive`: rejected — key material has not yet entered service; rotation is meaningless. +/// - `Destroyed` / `Destroyed_Compromised`: rejected — key material is gone; nothing to rotate. /// - Object type: the specified `object_type` /// /// When a specific UID resolves to a key of the correct type but in an @@ -133,11 +138,16 @@ pub(crate) async fn retrieve_eligible_keys( if owm.object().object_type() != object_type { continue; } - if owm.state() != State::Active && owm.state() != State::Deactivated { + let is_eligible = matches!( + owm.state(), + State::Active | State::Deactivated | State::Compromised + ); + if !is_eligible { // For direct UID queries, give an explicit error instead of silently skipping if !is_tag_query { return Err(KmsError::InvalidRequest(format!( - "key '{}' is in state '{}' — only Active or Deactivated keys can be rotated", + "key '{}' is in state '{}' — only Active, Deactivated, or Compromised keys \ + can be rotated", owm.id(), owm.state() ))); From d1ff3d96007a6470314b643118f811e38aa39113 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 15 Jun 2026 09:14:57 +0200 Subject: [PATCH 26/30] fix: make persist new key as default impl in trait --- .../src/core/operations/rekey/common.rs | 39 +++++++------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/crate/server/src/core/operations/rekey/common.rs b/crate/server/src/core/operations/rekey/common.rs index d05f8c31fb..70d7662c32 100644 --- a/crate/server/src/core/operations/rekey/common.rs +++ b/crate/server/src/core/operations/rekey/common.rs @@ -270,7 +270,21 @@ pub(crate) trait RekeyOperation { user: &str, replacements: &[ReplacementObject], ) -> impl std::future::Future> { - default_persist_new_key(kms, user, replacements) + async move { + 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(()) + } } /// Step 7: Phase 2 — retire old objects + finalize dependants. @@ -295,29 +309,6 @@ pub(crate) trait RekeyOperation { 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, From 1d8d3486f3a98f62356105e7c7956edc76b332f2 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 15 Jun 2026 09:25:54 +0200 Subject: [PATCH 27/30] fix: add comment on rekey-concurrency issue --- .../src/core/operations/rekey/common.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crate/server/src/core/operations/rekey/common.rs b/crate/server/src/core/operations/rekey/common.rs index 70d7662c32..9716ddd576 100644 --- a/crate/server/src/core/operations/rekey/common.rs +++ b/crate/server/src/core/operations/rekey/common.rs @@ -651,6 +651,32 @@ pub(crate) struct KeyRetirement<'a> { /// 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. +/// +/// # Known concurrency limitation +/// +/// This function reads dependant objects (via `find_wrapped_by`) and then writes them back +/// in the same `atomic()` call, but there is **no optimistic lock** guarding those reads. +/// If two rotation requests for the same key execute concurrently — whether two manual +/// `Re-Key` requests or one manual and one auto-rotation tick — the following race is +/// possible: +/// +/// 1. Both callers read the old key and its dependants at the same snapshot. +/// 2. Both callers generate a new key (different UIDs, no collision on `Create`). +/// 3. Both callers commit Phase 2: the second writer silently overwrites the first writer's +/// `ReplacementObjectLink` on the old key, and re-wraps dependants a second time. +/// The first new key becomes a dangling orphan. +/// +/// The auto-rotation background task (`run_auto_rotation`) is currently a no-op stub +/// (TODO comment in `auto_rotate.rs`), so this race cannot be triggered in production +/// today. Before the scheduler is wired up, `ObjectsStore` must gain a conditional-update +/// primitive (optimistic locking via a `version` column / Lua CAS in Redis) so that Phase 2 +/// can abort with a `Conflict` error if the old key was modified between the read and the +/// commit. +/// +/// Tracking issue: +// TODO(concurrency): replace the unconditional `UpdateObject` for the old key with a +// `compare_and_swap` / `update_object_if_version` that aborts if the key was concurrently +// modified. See issue above for the full design. pub(crate) async fn finalize_rekey( kms: &KMS, owner: &str, From 9b3a0e39a37d3d4a332565caaa58a91f6a61374d Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 15 Jun 2026 14:13:15 +0200 Subject: [PATCH 28/30] fix: rely on associated type in traits --- crate/interfaces/src/hsm/hsm_store.rs | 6 +- .../src/core/operations/attributes/set.rs | 2 +- crate/server/src/core/operations/recertify.rs | 54 +++++-------- .../src/core/operations/rekey/common.rs | 46 +++++++---- .../src/core/operations/rekey/keypair.rs | 79 +++++-------------- .../src/core/operations/rekey/symmetric.rs | 44 +++++------ 6 files changed, 92 insertions(+), 139 deletions(-) diff --git a/crate/interfaces/src/hsm/hsm_store.rs b/crate/interfaces/src/hsm/hsm_store.rs index 61faecaea7..e166d65ba3 100644 --- a/crate/interfaces/src/hsm/hsm_store.rs +++ b/crate/interfaces/src/hsm/hsm_store.rs @@ -749,7 +749,11 @@ fn build_sensitive_stub_attributes(meta: &KeyMetadata) -> Attributes { let rotate_interval = match (meta.start_date, meta.end_date) { (Some(start), Some(end)) => { let days = (end - start).whole_days(); - if days > 0 { Some(days * crate::SECS_PER_DAY) } else { None } + if days > 0 { + Some(days * crate::SECS_PER_DAY) + } else { + None + } } _ => None, }; diff --git a/crate/server/src/core/operations/attributes/set.rs b/crate/server/src/core/operations/attributes/set.rs index 7f20b813f5..b5cba75dc2 100644 --- a/crate/server/src/core/operations/attributes/set.rs +++ b/crate/server/src/core/operations/attributes/set.rs @@ -1,3 +1,4 @@ +use cosmian_kms_server_database::reexport::cosmian_kms_interfaces::SECS_PER_DAY; use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ kmip_0::kmip_types::ErrorReason, @@ -11,7 +12,6 @@ use cosmian_kms_server_database::reexport::{ }, cosmian_kms_interfaces::ObjectWithMetadata, }; -use cosmian_kms_server_database::reexport::cosmian_kms_interfaces::SECS_PER_DAY; use cosmian_logger::{debug, trace}; use time::OffsetDateTime; diff --git a/crate/server/src/core/operations/recertify.rs b/crate/server/src/core/operations/recertify.rs index b95f076649..0e2c8a8fd6 100644 --- a/crate/server/src/core/operations/recertify.rs +++ b/crate/server/src/core/operations/recertify.rs @@ -75,13 +75,15 @@ pub(crate) async fn recertify( impl RekeyOperation for CertificateRekey { type Request = ReCertify; type Response = ReCertifyResponse; + type Candidates = [RotationCandidate; 1]; + type Replacements = [ReplacementObject; 1]; async fn validate( &self, kms: &KMS, request: &ReCertify, user: &str, - ) -> KResult> { + ) -> KResult<[RotationCandidate; 1]> { reject_protection_storage_masks(request.protection_storage_masks.is_some())?; enforce_privileged_user(kms, user).await?; @@ -118,7 +120,7 @@ impl RekeyOperation for CertificateRekey { ))); } - Ok(vec![RotationCandidate { + Ok([RotationCandidate { owm, uid: uid.to_owned(), object_type: ObjectType::Certificate, @@ -128,11 +130,9 @@ impl RekeyOperation for CertificateRekey { async fn generate_replacement( &self, kms: &KMS, - candidates: &[RotationCandidate], - ) -> KResult> { - let candidate = candidates - .first() - .ok_or_else(|| KmsError::InvalidRequest("no rotation candidate".to_owned()))?; + candidates: &[RotationCandidate; 1], + ) -> KResult<[ReplacementObject; 1]> { + let [candidate] = candidates; let new_uid = compute_rotation_uid(&candidate.uid); // Build a Certify request that references the existing certificate for renewal. @@ -159,7 +159,7 @@ impl RekeyOperation for CertificateRekey { let (certificate_object, tags, attributes) = build_and_sign_certificate(kms.vendor_id(), &issuer, &subject, certify_request)?; - Ok(vec![ReplacementObject { + Ok([ReplacementObject { new_uid, old_uid: candidate.uid.clone(), object: certificate_object, @@ -173,17 +173,12 @@ impl RekeyOperation for CertificateRekey { fn prepare_attributes( &self, kms: &KMS, - candidates: &[RotationCandidate], - replacements: &mut [ReplacementObject], + candidates: &[RotationCandidate; 1], + replacements: &mut [ReplacementObject; 1], ) -> 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()))?; + let [candidate] = candidates; + let old_attrs = candidate.owm.attributes(); + let [replacement] = replacements; // Use shared date computation for offset-based activation/deactivation let base_attrs = @@ -230,7 +225,7 @@ impl RekeyOperation for CertificateRekey { &self, _kms: &KMS, _user: &str, - _replacements: &mut [ReplacementObject], + _replacements: &mut [ReplacementObject; 1], _wrap_specs: &[Option], ) -> KResult<()> { // Certificates are never wrapped — no-op. @@ -241,15 +236,11 @@ impl RekeyOperation for CertificateRekey { &self, kms: &KMS, user: &str, - candidates: &[RotationCandidate], - replacements: &[ReplacementObject], + candidates: &[RotationCandidate; 1], + replacements: &[ReplacementObject; 1], ) -> 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()))?; + let [candidate] = candidates; + let [replacement] = replacements; // Phase 2: Update the old certificate with ReplacementObjectLink let mut old_object = candidate.owm.object().clone(); @@ -281,13 +272,10 @@ impl RekeyOperation for CertificateRekey { Ok(()) } - fn build_response(&self, replacements: &[ReplacementObject]) -> ReCertifyResponse { + fn build_response(&self, replacements: &[ReplacementObject; 1]) -> ReCertifyResponse { + let [replacement] = replacements; ReCertifyResponse { - unique_identifier: UniqueIdentifier::TextString( - replacements - .first() - .map_or_else(String::new, |r| r.new_uid.clone()), - ), + unique_identifier: UniqueIdentifier::TextString(replacement.new_uid.clone()), } } } diff --git a/crate/server/src/core/operations/rekey/common.rs b/crate/server/src/core/operations/rekey/common.rs index 9716ddd576..58777d50ae 100644 --- a/crate/server/src/core/operations/rekey/common.rs +++ b/crate/server/src/core/operations/rekey/common.rs @@ -194,22 +194,33 @@ pub(crate) struct ReplacementObject { /// /// Each implementor provides type-specific logic for the 8 steps of the rotation pipeline. /// The shared [`execute_rekey`] orchestrator drives the pipeline in order. +/// +/// The associated types `Candidates` and `Replacements` encode the expected cardinality +/// at compile time (e.g. `[RotationCandidate; 1]` for symmetric, `[RotationCandidate; 2]` +/// for key pairs), eliminating runtime indexing errors. 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; + /// The set of rotation candidates produced by [`Self::validate`]. + /// Use `[RotationCandidate; 1]` for single-object operations (symmetric, certificate) + /// or `[RotationCandidate; 2]` for key pairs (SK + PK). + type Candidates: AsRef<[RotationCandidate]>; + /// The set of replacement objects produced by [`Self::generate_replacement`]. + /// Use `[ReplacementObject; 1]` for single-object operations + /// or `[ReplacementObject; 2]` for key pairs. + type Replacements: AsRef<[ReplacementObject]> + AsMut<[ReplacementObject]>; /// 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. + /// Returns [`Self::Candidates`] — the existing objects eligible for rotation. fn validate( &self, kms: &KMS, request: &Self::Request, user: &str, - ) -> impl std::future::Future>>; + ) -> impl std::future::Future>; /// Step 2: Detect wrapping context on existing object(s). /// @@ -218,9 +229,10 @@ pub(crate) trait RekeyOperation { /// Certificates (which have no key block) naturally return `None`. fn detect_wrapping( &self, - candidates: &[RotationCandidate], + candidates: &Self::Candidates, ) -> Vec> { candidates + .as_ref() .iter() .map(|c| extract_rewrap_spec(c.owm.object())) .collect() @@ -228,20 +240,19 @@ pub(crate) trait RekeyOperation { /// 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. + /// Returns [`Self::Replacements`] — one replacement per candidate. fn generate_replacement( &self, kms: &KMS, - candidates: &[RotationCandidate], - ) -> impl std::future::Future>>; + candidates: &Self::Candidates, + ) -> impl std::future::Future>; /// Step 4: Prepare attributes — links, lifecycle dates, rotation metadata. fn prepare_attributes( &self, kms: &KMS, - candidates: &[RotationCandidate], - replacements: &mut [ReplacementObject], + candidates: &Self::Candidates, + replacements: &mut Self::Replacements, ) -> KResult<()>; /// Step 5: Re-wrap new objects if originals were wrapped. @@ -255,10 +266,10 @@ pub(crate) trait RekeyOperation { &self, kms: &KMS, user: &str, - replacements: &mut [ReplacementObject], + replacements: &mut Self::Replacements, wrap_specs: &[Option], ) -> impl std::future::Future> { - default_rewrap_new_objects(kms, user, replacements, wrap_specs) + default_rewrap_new_objects(kms, user, replacements.as_mut(), wrap_specs) } /// Step 6: Phase 1 — persist new objects atomically. @@ -268,10 +279,11 @@ pub(crate) trait RekeyOperation { &self, kms: &KMS, user: &str, - replacements: &[ReplacementObject], + replacements: &Self::Replacements, ) -> impl std::future::Future> { async move { let operations: Vec = replacements + .as_ref() .iter() .map(|r| { AtomicOperation::Create(( @@ -299,14 +311,14 @@ pub(crate) trait RekeyOperation { &self, kms: &KMS, user: &str, - candidates: &[RotationCandidate], - replacements: &[ReplacementObject], + candidates: &Self::Candidates, + replacements: &Self::Replacements, ) -> impl std::future::Future> { - default_finalize_dependants(kms, user, candidates, replacements) + default_finalize_dependants(kms, user, candidates.as_ref(), replacements.as_ref()) } /// Step 8: Build the KMIP response from the completed replacements. - fn build_response(&self, replacements: &[ReplacementObject]) -> Self::Response; + fn build_response(&self, replacements: &Self::Replacements) -> Self::Response; } /// Default implementation for [`RekeyOperation::finalize_dependants`]. diff --git a/crate/server/src/core/operations/rekey/keypair.rs b/crate/server/src/core/operations/rekey/keypair.rs index bf07570c7e..324106f4ab 100644 --- a/crate/server/src/core/operations/rekey/keypair.rs +++ b/crate/server/src/core/operations/rekey/keypair.rs @@ -36,7 +36,6 @@ use crate::{ }, }, error::KmsError, - kms_bail, result::{KResult, KResultHelper}, }; @@ -135,13 +134,15 @@ async fn try_covercrypt_rekey( impl RekeyOperation for KeypairRekey { type Request = ReKeyKeyPair; type Response = ReKeyKeyPairResponse; + type Candidates = [RotationCandidate; 2]; + type Replacements = [ReplacementObject; 2]; async fn validate( &self, kms: &KMS, request: &ReKeyKeyPair, user: &str, - ) -> KResult> { + ) -> KResult<[RotationCandidate; 2]> { reject_protection_storage_masks( request.common_protection_storage_masks.is_some() || request.private_protection_storage_masks.is_some() @@ -216,7 +217,7 @@ impl RekeyOperation for KeypairRekey { 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![ + return Ok([ RotationCandidate { uid: old_sk_uid, object_type: ObjectType::PrivateKey, @@ -239,14 +240,9 @@ impl RekeyOperation for KeypairRekey { 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()))?; + candidates: &[RotationCandidate; 2], + ) -> KResult<[ReplacementObject; 2]> { + let [sk_candidate, pk_candidate] = candidates; let common_attrs = build_generation_attributes(sk_candidate.owm.attributes(), kms.vendor_id()); @@ -300,7 +296,7 @@ impl RekeyOperation for KeypairRekey { let key_pair = generate_key_pair(kms.vendor_id(), create_kp_request, &new_sk_uid, &new_pk_uid)?; - Ok(vec![ + Ok([ ReplacementObject { new_uid: new_sk_uid, old_uid: sk_candidate.uid.clone(), @@ -323,10 +319,10 @@ impl RekeyOperation for KeypairRekey { fn prepare_attributes( &self, kms: &KMS, - candidates: &[RotationCandidate], - replacements: &mut [ReplacementObject], + candidates: &[RotationCandidate; 2], + replacements: &mut [ReplacementObject; 2], ) -> KResult<()> { - let (sk_candidate, pk_candidate) = extract_keypair_candidates(candidates)?; + let [sk_candidate, pk_candidate] = candidates; let new_sk_attributes = prepare_replacement_attributes( sk_candidate.owm.attributes(), @@ -339,26 +335,10 @@ impl RekeyOperation for KeypairRekey { self.offset, )?; - if replacements.len() < 2 { - kms_bail!(KmsError::InvalidRequest( - "expected 2 replacements for key pair".to_owned() - )); - } + let pk_new_uid = replacements[1].new_uid.clone(); + let sk_new_uid = replacements[0].new_uid.clone(); - 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()))?; + let [sk_rep, pk_rep] = replacements; prepare_sk_replacement( sk_rep, &new_sk_attributes, @@ -368,9 +348,6 @@ impl RekeyOperation for KeypairRekey { )?; 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, @@ -382,37 +359,17 @@ impl RekeyOperation for KeypairRekey { Ok(()) } - fn build_response(&self, replacements: &[ReplacementObject]) -> ReKeyKeyPairResponse { + fn build_response(&self, replacements: &[ReplacementObject; 2]) -> ReKeyKeyPairResponse { + let [sk_rep, pk_rep] = replacements; 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_key_unique_identifier: UniqueIdentifier::TextString(sk_rep.new_uid.clone()), + public_key_unique_identifier: UniqueIdentifier::TextString(pk_rep.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, diff --git a/crate/server/src/core/operations/rekey/symmetric.rs b/crate/server/src/core/operations/rekey/symmetric.rs index de13eef7e8..3800eab17c 100644 --- a/crate/server/src/core/operations/rekey/symmetric.rs +++ b/crate/server/src/core/operations/rekey/symmetric.rs @@ -140,10 +140,9 @@ async fn rekey_hsm_symmetric(kms: &KMS, uid: &str, user: &str) -> KResult = old_attrs - .rotate_interval - .filter(|&i| i > 0) - .map(|secs| secs / cosmian_kms_server_database::reexport::cosmian_kms_interfaces::SECS_PER_DAY); + let interval_days: Option = old_attrs.rotate_interval.filter(|&i| i > 0).map(|secs| { + secs / cosmian_kms_server_database::reexport::cosmian_kms_interfaces::SECS_PER_DAY + }); // Generate the new key on the same HSM slot. kms.database @@ -209,13 +208,15 @@ async fn rekey_hsm_symmetric(kms: &KMS, uid: &str, user: &str) -> KResult KResult> { + ) -> KResult<[RotationCandidate; 1]> { reject_protection_storage_masks(request.protection_storage_masks.is_some())?; enforce_privileged_user(kms, user).await?; @@ -267,7 +268,7 @@ impl RekeyOperation for SymmetricRekey { )?; let uid = owm.id().to_owned(); - return Ok(vec![RotationCandidate { + return Ok([RotationCandidate { owm, uid, object_type: ObjectType::SymmetricKey, @@ -282,11 +283,9 @@ impl RekeyOperation for SymmetricRekey { async fn generate_replacement( &self, kms: &KMS, - candidates: &[RotationCandidate], - ) -> KResult> { - let candidate = candidates - .first() - .ok_or_else(|| KmsError::InvalidRequest("no rotation candidate".to_owned()))?; + candidates: &[RotationCandidate; 1], + ) -> KResult<[ReplacementObject; 1]> { + let [candidate] = candidates; // Clean attributes for generation let mut gen_attrs = candidate.owm.attributes().to_owned(); @@ -307,7 +306,7 @@ impl RekeyOperation for SymmetricRekey { let new_uid = compute_rotation_uid(&candidate.uid); - Ok(vec![ReplacementObject { + Ok([ReplacementObject { new_uid, old_uid: candidate.uid.clone(), object: new_object, @@ -320,15 +319,11 @@ impl RekeyOperation for SymmetricRekey { fn prepare_attributes( &self, kms: &KMS, - candidates: &[RotationCandidate], - replacements: &mut [ReplacementObject], + candidates: &[RotationCandidate; 1], + replacements: &mut [ReplacementObject; 1], ) -> 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 [candidate] = candidates; + let [replacement] = replacements; let new_attrs = prepare_replacement_attributes( candidate.owm.attributes(), @@ -357,13 +352,10 @@ impl RekeyOperation for SymmetricRekey { Ok(()) } - fn build_response(&self, replacements: &[ReplacementObject]) -> ReKeyResponse { + fn build_response(&self, replacements: &[ReplacementObject; 1]) -> ReKeyResponse { + let [replacement] = replacements; ReKeyResponse { - unique_identifier: UniqueIdentifier::TextString( - replacements - .first() - .map_or_else(String::new, |r| r.new_uid.clone()), - ), + unique_identifier: UniqueIdentifier::TextString(replacement.new_uid.clone()), } } } From 2c5a62268f258ab6af84927d5a429eb149f0a8fe Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 15 Jun 2026 16:19:50 +0200 Subject: [PATCH 29/30] fix: declare new trait for unique key selection --- CHANGELOG/docs_key-autorotation-spec.md | 8 +- .../src/core/operations/attributes/set.rs | 3 +- crate/server/src/core/operations/decrypt.rs | 9 +- .../src/core/operations/key_ops/crypto_op.rs | 100 ++----- .../server/src/core/operations/key_ops/mod.rs | 7 +- .../src/core/operations/key_selection.rs | 163 +++++++++++ crate/server/src/core/operations/mac.rs | 7 +- crate/server/src/core/operations/mod.rs | 1 + crate/server/src/core/operations/recertify.rs | 4 +- .../src/core/operations/rekey/keypair.rs | 129 +++++---- .../src/core/operations/rekey/symmetric.rs | 86 +++--- .../src/core/operations/signature_verify.rs | 7 +- crate/server/src/routes/kmip.rs | 3 +- .../server/src/tests/rest_crypto/key_state.rs | 254 ++++++++++++++++++ crate/server/src/tests/rest_crypto/mod.rs | 1 + test_data | 2 +- 16 files changed, 603 insertions(+), 181 deletions(-) create mode 100644 crate/server/src/core/operations/key_selection.rs create mode 100644 crate/server/src/tests/rest_crypto/key_state.rs diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md index aa27c005ab..d697f919a6 100644 --- a/CHANGELOG/docs_key-autorotation-spec.md +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -10,6 +10,8 @@ - Implement KMIP §4.57 transition 6 auto-deactivation: Active keys automatically transition to Deactivated when their DeactivationDate is reached ([#968](https://github.com/Cosmian/kms/pull/968)) - Implement keyset resolution: `name@latest`, `name@first`, `name@N` syntax to address specific key generations by `rotate_name` ([#968](https://github.com/Cosmian/kms/pull/968)) - Implement try-each-key decryption: Decrypt, SignatureVerify, and MACVerify operations with a bare keyset name walk the rotation chain (newest→oldest) until one key succeeds ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement KMIP 2.1 §3.31 state-based key selection: processing operations (Decrypt, Verify, MACVerify) now accept Deactivated and Compromised keys; protection operations (Encrypt, Sign, MAC) remain Active-only ([#968](https://github.com/Cosmian/kms/pull/968)) +- Enforce uniqueness in ReKey/ReKeyKeyPair `validate()`: reject ambiguous identifiers that resolve to multiple eligible keys with a clear error message ([#968](https://github.com/Cosmian/kms/pull/968)) - Add `--keyset-warn-depth` server config flag (default: 5) to trigger a server warning and return `X-KMS-Keyset-Depth` response header when decryption succeeds at depth ≥ threshold; replaces the old `--keyset-decrypt-max-attempts` hard cap — traversal is now unbounded (cycle detection only) so all key generations remain reachable ([#968](https://github.com/Cosmian/kms/pull/968)) - Enforce a minimum of 60 seconds for `auto_rotation_check_interval_secs` when non-zero, to prevent high-frequency database scans from overloading the server ([#968](https://github.com/Cosmian/kms/pull/968)) - Add `find_by_rotate_name()` to `ObjectsStore` trait with SQLite, PostgreSQL, and MySQL implementations for keyset lookup ([#968](https://github.com/Cosmian/kms/pull/968)) @@ -20,10 +22,6 @@ - Add HSM keyset support: store keyset metadata in `CKA_LABEL` (`name::gen::base_id[::latest]`); `SetAttribute rotate_name` writes `CKA_LABEL`; `SetAttribute rotate_interval` writes `CKA_START_DATE`/`CKA_END_DATE`; `Re-Key` on HSM UIDs generates a new HSM key and updates both CKA_LABELs; keyset resolution via `find_by_rotate_name` enumerates PKCS#11 objects and sorts by generation ([#968](https://github.com/Cosmian/kms/pull/968)) - Enrich `HsmStore::retrieve()` export path with CKA_LABEL keyset metadata (`rotate_name`, `rotate_generation`, `rotate_latest`) so the non-latest guard works for extractable HSM keys ([#968](https://github.com/Cosmian/kms/pull/968)) -## Bug Fixes - -- Fix Windows CI: add `--locked` to `cargo install wasm-bindgen-cli` in `windows_ui.ps1` to prevent newly-published `brotli-decompressor v5` from breaking the build on Rust 1.91 ([#968](https://github.com/Cosmian/kms/pull/968)) - ## Security - Mark `x-rotate-generation` and `x-rotate-date` as server-managed read-only attributes: reject user modifications via AddAttribute, SetAttribute, ModifyAttribute, and DeleteAttribute ([#968](https://github.com/Cosmian/kms/pull/968)) @@ -58,6 +56,7 @@ - Implement `find_by_rotate_name()` on Redis-Findex backend and index `rotate_name` attribute in Findex keywords ([#968](https://github.com/Cosmian/kms/pull/968)) - Fix `ReCertify.generate_replacement` passing empty user to `get_subject`/`get_issuer` — use certificate owner instead ([#968](https://github.com/Cosmian/kms/pull/968)) - Fix `ReCertify` not computing lifecycle state from offset — certificates with future activation_date are now `PreActive` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix Windows CI: add `--locked` to `cargo install wasm-bindgen-cli` in `windows_ui.ps1` to prevent newly-published `brotli-decompressor v5` from breaking the build on Rust 1.91 ([#968](https://github.com/Cosmian/kms/pull/968)) ## Refactor @@ -98,6 +97,7 @@ - Add vector `test_data/vectors/hsm/kek_bootstrap_self_create` + `server_type = "hsm_kek_uncreated"` server type to reproduce and prevent regressions of the HSM self-wrap bug ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 7 keyset resolution test vectors: `keyset_encrypt_latest`, `keyset_encrypt_bare_name`, `keyset_encrypt_latest_after_rotation`, `keyset_decrypt_try_each`, `keyset_decrypt_double_rotation`, `keyset_decrypt_at_latest`, `keyset_rotate_name_at_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) - Add HSM keyset support: `find_due_for_rotation` on HsmStore reads `CKA_START_DATE`/`CKA_END_DATE`; keyset metadata stored in `CKA_LABEL` (`name::gen::key_id[::latest]`); `SetAttribute rotate_name` writes `CKA_LABEL` via `C_SetAttributeValue`; `SetAttribute rotate_interval` writes `CKA_START_DATE`/`CKA_END_DATE`; `Re-Key` on HSM UIDs generates a new HSM key and updates both CKA_LABELs; `walk_keyset_chain` resolves HSM keysets by `CKA_LABEL` without `ReplacedObjectLink` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 4 key-state compliance tests: `test_decrypt_deactivated_key_succeeds`, `test_decrypt_compromised_key_succeeds`, `test_encrypt_deactivated_key_rejected`, `test_encrypt_compromised_key_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) - Add 3 HSM keyset test vectors: `resident_keyset_set_rotate_name`, `resident_keyset_rekey_and_decrypt`, `resident_keyset_double_rotation` + 1 negative: `hsm_rotate_offset_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) - Fix HSM `SetAttribute RotateInterval`: reject sub-day intervals (< 86400 s) that would silently produce `CKA_END_DATE = today` and cause immediate re-rotation on every scheduler tick; treat `RotateInterval = 0` as "disable" and clear the PKCS#11 dates instead of setting `end_date = today`; use ceiling division so non-multiple-of-86400 intervals do not fire one day early ([#968](https://github.com/Cosmian/kms/pull/968)) - Reconstruct `rotate_interval` attribute from `CKA_START_DATE`/`CKA_END_DATE` in `HsmStore::retrieve` and `build_sensitive_stub_attributes` so that auto-rotation re-key can propagate the rotation schedule to the replacement key ([#968](https://github.com/Cosmian/kms/pull/968)) diff --git a/crate/server/src/core/operations/attributes/set.rs b/crate/server/src/core/operations/attributes/set.rs index b5cba75dc2..07b7042ee9 100644 --- a/crate/server/src/core/operations/attributes/set.rs +++ b/crate/server/src/core/operations/attributes/set.rs @@ -1,4 +1,3 @@ -use cosmian_kms_server_database::reexport::cosmian_kms_interfaces::SECS_PER_DAY; use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ kmip_0::kmip_types::ErrorReason, @@ -10,7 +9,7 @@ use cosmian_kms_server_database::reexport::{ kmip_types::UniqueIdentifier, }, }, - cosmian_kms_interfaces::ObjectWithMetadata, + cosmian_kms_interfaces::{ObjectWithMetadata, SECS_PER_DAY}, }; use cosmian_logger::{debug, trace}; use time::OffsetDateTime; diff --git a/crate/server/src/core/operations/decrypt.rs b/crate/server/src/core/operations/decrypt.rs index 04fd4cb0d4..12d20fc44b 100644 --- a/crate/server/src/core/operations/decrypt.rs +++ b/crate/server/src/core/operations/decrypt.rs @@ -11,7 +11,7 @@ use cosmian_kms_server_database::reexport::cosmian_kms_crypto::{ }; use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ - kmip_0::kmip_types::{CryptographicUsageMask, ErrorReason, PaddingMethod}, + kmip_0::kmip_types::{CryptographicUsageMask, ErrorReason, PaddingMethod, State}, kmip_2_1::{ KmipOperation, extra::BulkData, @@ -71,6 +71,13 @@ impl CryptoOpSpec for DecryptOp { KeysetMode::TryEach } + /// Decrypt accepts Active, Deactivated, and Compromised keys per KMIP 2.1 §3.31: + /// "The object SHALL NOT be used for applying cryptographic protection [...] + /// The object SHOULD only be used to process cryptographically-protected information." + fn accepted_states() -> &'static [State] { + &[State::Active, State::Deactivated, State::Compromised] + } + fn usage_data_len(request: &Self::Request) -> usize { request.data.as_ref().map_or(0, Vec::len) } diff --git a/crate/server/src/core/operations/key_ops/crypto_op.rs b/crate/server/src/core/operations/key_ops/crypto_op.rs index f11464a32d..a6daf6bd69 100644 --- a/crate/server/src/core/operations/key_ops/crypto_op.rs +++ b/crate/server/src/core/operations/key_ops/crypto_op.rs @@ -134,6 +134,19 @@ pub(crate) trait CryptoOpSpec { /// - MAC/MACVerify: data length fn usage_data_len(request: &Self::Request) -> usize; + /// Key states accepted by this operation. + /// + /// Per KMIP 2.1 §3.31: + /// - Protection operations (Encrypt, Sign, MAC) require `Active` only. + /// - Processing operations (Decrypt, Verify, `MACVerify`) accept `Active`, + /// `Deactivated`, and `Compromised` — because deactivated/compromised keys + /// must remain usable to process previously protected data. + /// + /// Default: `&[State::Active]` — override for processing operations. + fn accepted_states() -> &'static [State] { + &[State::Active] + } + /// Determine if the given managed object is eligible for this operation. /// /// Checks object type and `CryptographicUsageMask` as required by the operation. @@ -256,8 +269,9 @@ async fn execute_keyset_try_each( continue; }; - // Must be Active - if owm.get_effective_state()? != State::Active { + // State filter: per KMIP 2.1 §3.31, processing operations (Decrypt, Verify) + // accept Deactivated/Compromised keys; protection operations require Active only. + if !Op::accepted_states().contains(&owm.get_effective_state()?) { continue; } @@ -381,77 +395,27 @@ pub(crate) async fn select_eligible_oracle_uid( /// * `KmsError::Unauthorized` — candidates found but the user has no permission on any of them /// * `KmsError::ItemNotFound` — no candidate qualifies after all filters /// * `KmsError::InvalidRequest` — more than one eligible key matched -pub(crate) async fn select_unique_key_for_operation( - op_name: &str, +pub(crate) async fn select_unique_key_for_operation( candidate_uids: &HashSet, unique_identifier: &UniqueIdentifier, - operation: KmipOperation, kms: &KMS, user: &str, - is_eligible: F, -) -> KResult -where - F: Fn(&ObjectWithMetadata) -> KResult, -{ - let uid_display = unique_identifier.to_string(); - let mut eligible: Vec = Vec::new(); - let mut found_but_no_permission = false; +) -> KResult { + use super::super::key_selection::select_unique_key; + // Fetch objects from UIDs, skipping oracle (prefix) UIDs handled by the caller. + let mut candidates = Vec::new(); for uid in candidate_uids { - // Oracle (prefix) UIDs are handled by the caller — skip them here. if has_prefix(uid).is_some() { continue; } - - let Some(owm) = kms.database.retrieve_object(uid).await? else { - continue; - }; - - // Must be Active (respects auto-activation via activation_date). - if owm.get_effective_state()? != State::Active { - continue; - } - - // Permission check via the shared authorization function. - if !kms - .database - .is_user_authorized_for_operation(uid, user, operation) - .await? - { - found_but_no_permission = true; - continue; + if let Some(owm) = kms.database.retrieve_object(uid).await? { + candidates.push(owm); } - - // Object-type and usage-mask check supplied by the caller. - if !is_eligible(&owm)? { - continue; - } - - eligible.push(owm); } - match eligible.len() { - 1 => eligible - .into_iter() - .next() - .ok_or_else(|| KmsError::ItemNotFound("unreachable: len == 1".to_owned())), - 0 => Err(if found_but_no_permission { - KmsError::Unauthorized(format!( - "{op_name}: user {user} does not have permission to use key: {uid_display}" - )) - } else { - KmsError::ItemNotFound(format!( - "{op_name}: no valid key found for identifier: {uid_display}" - )) - }), - n => { - let ids: Vec<&str> = eligible.iter().map(ObjectWithMetadata::id).collect(); - Err(KmsError::InvalidRequest(format!( - "{op_name}: identifier {uid_display} resolves to {n} valid keys {ids:?}; \ - use a unique identifier" - ))) - } - } + let uid_display = unique_identifier.to_string(); + select_unique_key::(candidates, &uid_display, kms, user, |_| Ok(())).await } /// Resolve the key for a cryptographic operation using the [`CryptoOpSpec`] trait. @@ -544,17 +508,9 @@ pub(crate) async fn resolve_key_for_operation( } // Phase 2 — Standard database path. - let owm = select_unique_key_for_operation( - Op::OP_NAME, - &uids, - unique_identifier, - Op::KMIP_OP, - kms, - user, - |owm| Ok(Op::is_key_eligible(owm, kms.vendor_id())), - ) - .await - .map_err(|e| Op::map_selection_error(e, unique_identifier, user))?; + let owm = select_unique_key_for_operation::(&uids, unique_identifier, kms, user) + .await + .map_err(|e| Op::map_selection_error(e, unique_identifier, user))?; // Lifecycle enforcement: always check process window. owm.check_process_window()?; diff --git a/crate/server/src/core/operations/key_ops/mod.rs b/crate/server/src/core/operations/key_ops/mod.rs index 15c450d73f..5fe5aa48df 100644 --- a/crate/server/src/core/operations/key_ops/mod.rs +++ b/crate/server/src/core/operations/key_ops/mod.rs @@ -41,11 +41,10 @@ pub(crate) async fn enforce_create_permission(kms: &KMS, user: &str) -> KResult< || user_has_permission(user, None, &KmipOperation::Create, kms).await? { return Ok(()); - } else { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right.".to_owned() - )) } + kms_bail!(KmsError::Unauthorized( + "User does not have create access-right.".to_owned() + )) } // If no privileged user was set, all users have the `Create` right. Ok(()) diff --git a/crate/server/src/core/operations/key_selection.rs b/crate/server/src/core/operations/key_selection.rs new file mode 100644 index 0000000000..15ecb83454 --- /dev/null +++ b/crate/server/src/core/operations/key_selection.rs @@ -0,0 +1,163 @@ +//! Shared key-selection trait and generic function. +//! +//! Both cryptographic operations (`Encrypt`, `Decrypt`, `Sign`, …) and rotation +//! operations (`ReKey`, `ReKeyKeyPair`) need to select exactly one eligible key +//! from a set of candidates. This module factors the common pipeline: +//! +//! **state filter → permission check → eligibility → extra validation → uniqueness** +//! +//! into a single generic function [`select_unique_key`] parameterized by the +//! [`KeySelectionSpec`] trait. + +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::{kmip_0::kmip_types::State, kmip_2_1::KmipOperation}, + cosmian_kms_interfaces::ObjectWithMetadata, +}; + +use super::key_ops::{CryptoOpSpec, DatabaseOps, ObjectWithMetadataOps}; +use crate::{core::KMS, error::KmsError, result::KResult}; + +// ─── Trait ─────────────────────────────────────────────────────────────────── + +/// Declarative specification for key selection shared across all KMIP operations. +/// +/// Implemented by: +/// - Crypto operation marker structs (via the `CryptoOpSpec` supertrait relationship) +/// - Rekey operation structs (`SymmetricRekey`, `KeypairRekey`) +pub(crate) trait KeySelectionSpec { + /// Human-readable operation name for error messages (e.g. `"Encrypt"`, `"ReKey"`). + const OP_NAME: &'static str; + + /// The KMIP operation used for permission checks. + const KMIP_OP: KmipOperation; + + /// Key states accepted by this operation. + fn accepted_states() -> &'static [State]; + + /// Whether permission checks require an exact operation grant. + /// + /// - `false` (default): a `Get` grant also authorizes the operation (crypto ops). + /// - `true`: only an explicit grant of [`Self::KMIP_OP`] authorizes (rekey, destructive ops). + fn strict_permission_check() -> bool { + false + } + + /// Determine if the managed object is eligible (object type + usage mask). + fn is_key_eligible(owm: &ObjectWithMetadata, vendor_id: &str) -> bool; +} + +// ─── Blanket impl for all CryptoOpSpec types ───────────────────────────────── + +/// Every `CryptoOpSpec` implementor automatically satisfies `KeySelectionSpec`. +/// +/// This avoids duplicate trait implementations for `EncryptOp`, `DecryptOp`, etc. +impl KeySelectionSpec for T { + const KMIP_OP: KmipOperation = T::KMIP_OP; + const OP_NAME: &'static str = T::OP_NAME; + + fn accepted_states() -> &'static [State] { + T::accepted_states() + } + + fn is_key_eligible(owm: &ObjectWithMetadata, vendor_id: &str) -> bool { + ::is_key_eligible(owm, vendor_id) + } +} + +// ─── Generic selection function ────────────────────────────────────────────── + +/// Select exactly one key from pre-fetched candidates using the [`KeySelectionSpec`] pipeline. +/// +/// Applies the following filters in order: +/// 1. **State** — `Spec::accepted_states()` +/// 2. **Permission** — `is_user_authorized_for_operation` with `Spec::KMIP_OP` +/// 3. **Eligibility** — `Spec::is_eligible()` +/// 4. **Extra validation** — caller-supplied closure for operation-specific checks +/// (e.g. keyset-latest guard, crypto-param change rejection) +/// +/// Enforces uniqueness: +/// - 0 eligible → `KmsError::ItemNotFound` or `KmsError::Unauthorized` +/// - 1 eligible → `Ok(ObjectWithMetadata)` +/// - \>1 eligible → `KmsError::InvalidRequest` (ambiguous) +/// +/// # Parameters +/// +/// - `candidates`: Pre-fetched objects (from `retrieve_eligible_keys` or per-UID fetch). +/// - `uid_display`: Display string for the identifier (used in error messages). +/// - `kms`: Server state. +/// - `user`: Requesting user. +/// - `extra_validation`: Sync closure applied after eligibility; return `Ok(())` to accept, +/// `Err(...)` to reject with a hard error (propagated immediately, not silently skipped). +pub(crate) async fn select_unique_key( + candidates: Vec, + uid_display: &str, + kms: &KMS, + user: &str, + extra_validation: F, +) -> KResult +where + Spec: KeySelectionSpec, + F: Fn(&ObjectWithMetadata) -> KResult<()>, +{ + let mut eligible: Vec = Vec::new(); + let mut found_but_no_permission = false; + + for owm in candidates { + // 1. State filter + if !Spec::accepted_states().contains(&owm.get_effective_state()?) { + continue; + } + + // 2. Permission check + let authorized = if Spec::strict_permission_check() { + // Strict: only exact operation grant (no Get wildcard) + owm.user_can_perform_operation(user, &Spec::KMIP_OP, kms) + .await? + } else { + // Lenient: Get grant also authorizes (standard for crypto ops) + kms.database + .is_user_authorized_for_operation(owm.id(), user, Spec::KMIP_OP) + .await? + }; + if !authorized { + found_but_no_permission = true; + continue; + } + + // 3. Eligibility (object type + usage mask) + if !Spec::is_key_eligible(&owm, kms.vendor_id()) { + continue; + } + + // 4. Extra validation (hard error on failure — not skipped) + extra_validation(&owm)?; + + eligible.push(owm); + } + + match eligible.len() { + 1 => eligible + .into_iter() + .next() + .ok_or_else(|| KmsError::ItemNotFound("unreachable: len == 1".to_owned())), + 0 => Err(if found_but_no_permission { + KmsError::Unauthorized(format!( + "{}: user {user} does not have permission to use key: {uid_display}", + Spec::OP_NAME, + )) + } else { + KmsError::ItemNotFound(format!( + "{}: no valid key found for identifier: {uid_display}", + Spec::OP_NAME, + )) + }), + n => { + let ids: Vec<&str> = eligible.iter().map(ObjectWithMetadata::id).collect(); + Err(KmsError::InvalidRequest(format!( + "{}: identifier '{uid_display}' resolves to {n} valid keys {ids:?}; \ + use a unique identifier", + Spec::OP_NAME, + ))) + } + } +} diff --git a/crate/server/src/core/operations/mac.rs b/crate/server/src/core/operations/mac.rs index eb0afe1ce8..e6ed315785 100644 --- a/crate/server/src/core/operations/mac.rs +++ b/crate/server/src/core/operations/mac.rs @@ -1,6 +1,6 @@ use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ - kmip_0::kmip_types::HashingAlgorithm, + kmip_0::kmip_types::{HashingAlgorithm, State}, kmip_2_1::{ KmipOperation, kmip_attributes::Attributes, @@ -127,6 +127,11 @@ impl CryptoOpSpec for MacVerifyOp { KeysetMode::TryEach } + /// `MACVerify` accepts Active, Deactivated, and Compromised keys per KMIP 2.1 §3.31. + fn accepted_states() -> &'static [State] { + &[State::Active, State::Deactivated, State::Compromised] + } + fn usage_data_len(request: &Self::Request) -> usize { request.data.len() } diff --git a/crate/server/src/core/operations/mod.rs b/crate/server/src/core/operations/mod.rs index 81ed6c9140..741f428b6b 100644 --- a/crate/server/src/core/operations/mod.rs +++ b/crate/server/src/core/operations/mod.rs @@ -18,6 +18,7 @@ mod get; mod hash; mod import; pub(crate) mod key_ops; +pub(crate) mod key_selection; mod locate; mod mac; mod message; diff --git a/crate/server/src/core/operations/recertify.rs b/crate/server/src/core/operations/recertify.rs index 0e2c8a8fd6..ca2d32d1ab 100644 --- a/crate/server/src/core/operations/recertify.rs +++ b/crate/server/src/core/operations/recertify.rs @@ -73,10 +73,10 @@ pub(crate) async fn recertify( } impl RekeyOperation for CertificateRekey { - type Request = ReCertify; - type Response = ReCertifyResponse; type Candidates = [RotationCandidate; 1]; type Replacements = [ReplacementObject; 1]; + type Request = ReCertify; + type Response = ReCertifyResponse; async fn validate( &self, diff --git a/crate/server/src/core/operations/rekey/keypair.rs b/crate/server/src/core/operations/rekey/keypair.rs index 324106f4ab..ca2dde158c 100644 --- a/crate/server/src/core/operations/rekey/keypair.rs +++ b/crate/server/src/core/operations/rekey/keypair.rs @@ -2,21 +2,24 @@ 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_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::{ErrorReason, State}, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attributes, + kmip_objects::ObjectType, + kmip_operations::{CreateKeyPair, ReKeyKeyPair, ReKeyKeyPairResponse}, + kmip_types::{KeyFormatType, LinkType, UniqueIdentifier}, + }, + }, + cosmian_kms_interfaces::ObjectWithMetadata, +}; use cosmian_logger::trace; use super::common::{ @@ -31,8 +34,8 @@ use crate::{ core::{ KMS, operations::{ - create_key_pair::generate_key_pair, - key_ops::{ObjectWithMetadataOps, reject_protection_storage_masks}, + create_key_pair::generate_key_pair, key_ops::reject_protection_storage_masks, + key_selection::KeySelectionSpec, }, }, error::KmsError, @@ -45,6 +48,33 @@ struct KeypairRekey { offset: Option, } +impl KeySelectionSpec for KeypairRekey { + const KMIP_OP: KmipOperation = KmipOperation::Rekey; + const OP_NAME: &'static str = "ReKeyKeyPair"; + + fn accepted_states() -> &'static [State] { + &[State::Active, State::Deactivated, State::Compromised] + } + + fn strict_permission_check() -> bool { + true + } + + fn is_key_eligible(owm: &ObjectWithMetadata, _vendor_id: &str) -> bool { + if owm.object().object_type() != ObjectType::PrivateKey { + return false; + } + // 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) + }); + key_format_type != Some(KeyFormatType::CoverCryptSecretKey) + } +} + /// KMIP `ReKeyKeyPair` operation for asymmetric key pairs. /// /// Per KMIP 1.4 §4.5: @@ -72,14 +102,14 @@ pub(crate) async fn rekey_keypair( return Ok(response); } - execute_rekey( + Box::pin(execute_rekey( &KeypairRekey { offset: request.offset, }, kms, &request, user, - ) + )) .await } @@ -132,10 +162,10 @@ async fn try_covercrypt_rekey( } impl RekeyOperation for KeypairRekey { - type Request = ReKeyKeyPair; - type Response = ReKeyKeyPairResponse; type Candidates = [RotationCandidate; 2]; type Replacements = [ReplacementObject; 2]; + type Request = ReKeyKeyPair; + type Response = ReKeyKeyPairResponse; async fn validate( &self, @@ -143,6 +173,8 @@ impl RekeyOperation for KeypairRekey { request: &ReKeyKeyPair, user: &str, ) -> KResult<[RotationCandidate; 2]> { + use crate::core::operations::key_selection::select_unique_key; + reject_protection_storage_masks( request.common_protection_storage_masks.is_some() || request.private_protection_storage_masks.is_some() @@ -170,37 +202,19 @@ impl RekeyOperation for KeypairRekey { )); } - 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; - } + let candidates = retrieve_eligible_keys(kms, uid_or_tags, ObjectType::PrivateKey).await?; + let owm = select_unique_key::(candidates, uid_or_tags, kms, user, |owm| { // Reject Re-Key on a retired (non-latest) member of a named keyset. - // Keys without a rotate_name are not keyset members and may be freely re-keyed. if owm.attributes().rotate_name.is_some() && owm.attributes().rotate_latest == Some(false) { return Err(KmsError::InvalidRequest(format!( "ReKeyKeyPair: key '{}' is not the latest in its keyset — only the \ - latest generation can be rotated", + latest generation can be rotated", owm.id() ))); } - // Validate no crypto param changes validate_no_crypto_param_change( owm.attributes(), @@ -211,30 +225,27 @@ impl RekeyOperation for KeypairRekey { ], "ReKeyKeyPair", )?; + Ok(()) + }) + .await?; - // 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([ - RotationCandidate { - uid: old_sk_uid, - object_type: ObjectType::PrivateKey, - owm, - }, - RotationCandidate { - uid: old_pk_uid, - object_type: ObjectType::PublicKey, - owm: old_pk_owm, - }, - ]); - } + // Resolve paired public key (post-selection: only for the winning candidate) + 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?; - Err(KmsError::Kmip21Error( - ErrorReason::Item_Not_Found, - uid_or_tags.to_owned(), - )) + Ok([ + RotationCandidate { + uid: old_sk_uid, + object_type: ObjectType::PrivateKey, + owm, + }, + RotationCandidate { + uid: old_pk_uid, + object_type: ObjectType::PublicKey, + owm: old_pk_owm, + }, + ]) } async fn generate_replacement( diff --git a/crate/server/src/core/operations/rekey/symmetric.rs b/crate/server/src/core/operations/rekey/symmetric.rs index 3800eab17c..9c10f0c80f 100644 --- a/crate/server/src/core/operations/rekey/symmetric.rs +++ b/crate/server/src/core/operations/rekey/symmetric.rs @@ -1,9 +1,15 @@ -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_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::State, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attributes, + kmip_objects::ObjectType, + kmip_operations::{Create, ReKey, ReKeyResponse}, + kmip_types::UniqueIdentifier, + }, + }, + cosmian_kms_interfaces::ObjectWithMetadata, }; use cosmian_logger::trace; use time::OffsetDateTime; @@ -17,7 +23,7 @@ use super::common::{ use crate::{ core::{ KMS, - operations::key_ops::{ObjectWithMetadataOps, reject_protection_storage_masks}, + operations::{key_ops::reject_protection_storage_masks, key_selection::KeySelectionSpec}, uid_utils::has_prefix, }, error::KmsError, @@ -31,6 +37,23 @@ pub(crate) struct SymmetricRekey { offset: Option, } +impl KeySelectionSpec for SymmetricRekey { + const KMIP_OP: KmipOperation = KmipOperation::Rekey; + const OP_NAME: &'static str = "ReKey"; + + fn accepted_states() -> &'static [State] { + &[State::Active, State::Deactivated, State::Compromised] + } + + fn strict_permission_check() -> bool { + true + } + + fn is_key_eligible(owm: &ObjectWithMetadata, _vendor_id: &str) -> bool { + owm.object().object_type() == ObjectType::SymmetricKey + } +} + /// KMIP `ReKey` operation for symmetric keys (KMIP 2.1 §6.1.46). /// /// - For regular (SQL) keys: generates fresh key material, handles wrapping, links generations. @@ -54,7 +77,13 @@ pub(crate) async fn rekey(kms: &KMS, request: ReKey, owner: &str) -> KResult KResult KResult<[RotationCandidate; 1]> { + use crate::core::operations::key_selection::select_unique_key; + reject_protection_storage_masks(request.protection_storage_masks.is_some())?; enforce_privileged_user(kms, user).await?; @@ -240,44 +271,35 @@ impl RekeyOperation for SymmetricRekey { )); } - 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; - } + let candidates = retrieve_eligible_keys(kms, uid_or_tags, ObjectType::SymmetricKey).await?; + let owm = select_unique_key::(candidates, uid_or_tags, kms, user, |owm| { // Reject Re-Key on a retired (non-latest) member of a named keyset. - // Keys without a rotate_name are not keyset members and may be freely re-keyed. if owm.attributes().rotate_name.is_some() && owm.attributes().rotate_latest == Some(false) { return Err(KmsError::InvalidRequest(format!( "ReKey: key '{}' is not the latest in its keyset — only the latest \ - generation can be rotated", + generation can be rotated", owm.id() ))); } - // 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([RotationCandidate { - owm, - uid, - object_type: ObjectType::SymmetricKey, - }]); - } - - Err(KmsError::InvalidRequest(format!( - "rekey: no active symmetric key found for uid/tags: {uid_or_tags}", - ))) + Ok(()) + }) + .await?; + + let uid = owm.id().to_owned(); + Ok([RotationCandidate { + owm, + uid, + object_type: ObjectType::SymmetricKey, + }]) } async fn generate_replacement( diff --git a/crate/server/src/core/operations/signature_verify.rs b/crate/server/src/core/operations/signature_verify.rs index 5e92f854ae..beb516b230 100644 --- a/crate/server/src/core/operations/signature_verify.rs +++ b/crate/server/src/core/operations/signature_verify.rs @@ -1,6 +1,6 @@ use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ - kmip_0::kmip_types::CryptographicUsageMask, + kmip_0::kmip_types::{CryptographicUsageMask, State}, kmip_2_1::{ KmipOperation, kmip_objects::{Object, ObjectType}, @@ -48,6 +48,11 @@ impl CryptoOpSpec for SignatureVerifyOp { KeysetMode::TryEach } + /// `SignatureVerify` accepts Active, Deactivated, and Compromised keys per KMIP 2.1 §3.31. + fn accepted_states() -> &'static [State] { + &[State::Active, State::Deactivated, State::Compromised] + } + fn usage_data_len(request: &Self::Request) -> usize { request .data diff --git a/crate/server/src/routes/kmip.rs b/crate/server/src/routes/kmip.rs index eb94b3330a..0849f2fb7d 100644 --- a/crate/server/src/routes/kmip.rs +++ b/crate/server/src/routes/kmip.rs @@ -28,8 +28,7 @@ use tracing::Instrument; use crate::{ core::{ KMS, - operations::key_ops::crypto_op::KEYSET_CHAIN_DEPTH, - operations::{dispatch, message}, + operations::{dispatch, key_ops::crypto_op::KEYSET_CHAIN_DEPTH, message}, }, error::KmsError, result::KResult, diff --git a/crate/server/src/tests/rest_crypto/key_state.rs b/crate/server/src/tests/rest_crypto/key_state.rs new file mode 100644 index 0000000000..4d85ce85b1 --- /dev/null +++ b/crate/server/src/tests/rest_crypto/key_state.rs @@ -0,0 +1,254 @@ +//! Tests that cryptographic operations respect KMIP 2.1 §3.31 state rules: +//! +//! - **Protection operations** (Encrypt, Sign, MAC) require `Active` state. +//! - **Processing operations** (Decrypt, Verify, MACVerify) accept +//! `Active`, `Deactivated`, and `Compromised` states. + +use actix_web::{http::StatusCode, test}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::{RevocationReason, RevocationReasonCode}, + kmip_2_1::{ + extra::tagging::{EMPTY_TAGS, VENDOR_ID_COSMIAN}, + kmip_operations::{CreateResponse, Revoke, RevokeResponse}, + kmip_types::{CryptographicAlgorithm, UniqueIdentifier}, + requests::symmetric_key_create_request, + }, +}; +use cosmian_logger::log_init; +use serde_json::json; + +use crate::{result::KResult, tests::test_utils}; + +/// Create an AES-256 key and return its UID. +async fn create_aes_key(app: &S) -> KResult +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let create_req = symmetric_key_create_request( + VENDOR_ID_COSMIAN, + None, + 256, + CryptographicAlgorithm::AES, + EMPTY_TAGS, + false, + None, + )?; + let cr: CreateResponse = test_utils::post_2_1(app, create_req).await?; + Ok(cr.unique_identifier.to_string()) +} + +/// Encrypt plaintext with the given key, returning the JSON encrypt response. +async fn encrypt(app: &S, kid: &str, plaintext: &[u8]) -> KResult +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let data_b64 = URL_SAFE_NO_PAD.encode(plaintext); + test_utils::post_json_with_uri( + app, + json!({"kid": kid, "alg": "dir", "enc": "A256GCM", "data": data_b64}), + "/v1/crypto/encrypt", + ) + .await +} + +/// Attempt to decrypt the given ciphertext response; returns Ok(value) on success. +async fn try_decrypt( + app: &S, + enc_resp: &serde_json::Value, +) -> Result +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let req = test::TestRequest::post() + .uri("/v1/crypto/decrypt") + .set_json(&json!({ + "protected": enc_resp["protected"], + "iv": enc_resp["iv"], + "ciphertext": enc_resp["ciphertext"], + "tag": enc_resp["tag"], + })) + .to_request(); + let resp = test::call_service(app, req).await; + if resp.status() == StatusCode::OK { + let body = test::read_body(resp).await; + Ok(serde_json::from_slice(&body).unwrap_or_default()) + } else { + Err(resp.status()) + } +} + +/// Attempt to encrypt; returns Ok on 200, Err(status) otherwise. +async fn try_encrypt( + app: &S, + kid: &str, + plaintext: &[u8], +) -> Result +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let data_b64 = URL_SAFE_NO_PAD.encode(plaintext); + let req = test::TestRequest::post() + .uri("/v1/crypto/encrypt") + .set_json(&json!({"kid": kid, "alg": "dir", "enc": "A256GCM", "data": data_b64})) + .to_request(); + let resp = test::call_service(app, req).await; + if resp.status() == StatusCode::OK { + let body = test::read_body(resp).await; + Ok(serde_json::from_slice(&body).unwrap_or_default()) + } else { + Err(resp.status()) + } +} + +/// Deactivate a key via KMIP `Revoke` with `CessationOfOperation` reason. +async fn deactivate_key(app: &S, kid: &str) -> KResult<()> +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let revoke = Revoke { + unique_identifier: Some(UniqueIdentifier::TextString(kid.to_owned())), + revocation_reason: RevocationReason { + revocation_reason_code: RevocationReasonCode::CessationOfOperation, + revocation_message: Some("test deactivation".to_owned()), + }, + compromise_occurrence_date: None, + cascade: false, + }; + let _resp: RevokeResponse = test_utils::post_2_1(app, revoke).await?; + Ok(()) +} + +/// Compromise a key via KMIP `Revoke` with `KeyCompromise` reason. +async fn compromise_key(app: &S, kid: &str) -> KResult<()> +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let revoke = Revoke { + unique_identifier: Some(UniqueIdentifier::TextString(kid.to_owned())), + revocation_reason: RevocationReason { + revocation_reason_code: RevocationReasonCode::KeyCompromise, + revocation_message: Some("test compromise".to_owned()), + }, + compromise_occurrence_date: None, + cascade: false, + }; + let _resp: RevokeResponse = test_utils::post_2_1(app, revoke).await?; + Ok(()) +} + +/// KMIP 2.1 §3.31: Decrypt MUST work on a Deactivated key. +#[tokio::test] +async fn test_decrypt_deactivated_key_succeeds() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + let enc_resp = encrypt(&app, &kid, b"secret data").await?; + + // Deactivate the key + deactivate_key(&app, &kid).await?; + + // Decrypt must still work per KMIP 2.1 §3.31 + let dec_result = try_decrypt(&app, &enc_resp).await; + assert!( + dec_result.is_ok(), + "Decrypt with deactivated key should succeed per KMIP 2.1 §3.31, got: {dec_result:?}" + ); + + Ok(()) +} + +/// KMIP 2.1 §3.31: Decrypt SHOULD work on a Compromised key. +#[tokio::test] +async fn test_decrypt_compromised_key_succeeds() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + let enc_resp = encrypt(&app, &kid, b"secret data").await?; + + // Compromise the key + compromise_key(&app, &kid).await?; + + // Decrypt must still work per KMIP 2.1 §3.31 + let dec_result = try_decrypt(&app, &enc_resp).await; + assert!( + dec_result.is_ok(), + "Decrypt with compromised key should succeed per KMIP 2.1 §3.31, got: {dec_result:?}" + ); + + Ok(()) +} + +/// KMIP 2.1 §3.31: Encrypt MUST NOT work on a Deactivated key. +#[tokio::test] +async fn test_encrypt_deactivated_key_rejected() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + + // Deactivate the key + deactivate_key(&app, &kid).await?; + + // Encrypt must be rejected (protection operation on non-Active key) + let enc_result = try_encrypt(&app, &kid, b"will not encrypt").await; + assert!( + enc_result.is_err(), + "Encrypt with deactivated key should fail per KMIP 2.1 §3.31" + ); + + Ok(()) +} + +/// KMIP 2.1 §3.31: Encrypt MUST NOT work on a Compromised key. +#[tokio::test] +async fn test_encrypt_compromised_key_rejected() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + + // Compromise the key + compromise_key(&app, &kid).await?; + + // Encrypt must be rejected (protection operation on non-Active key) + let enc_result = try_encrypt(&app, &kid, b"will not encrypt").await; + assert!( + enc_result.is_err(), + "Encrypt with compromised key should fail per KMIP 2.1 §3.31" + ); + + Ok(()) +} diff --git a/crate/server/src/tests/rest_crypto/mod.rs b/crate/server/src/tests/rest_crypto/mod.rs index c4c986d1ba..f6e7ad1475 100644 --- a/crate/server/src/tests/rest_crypto/mod.rs +++ b/crate/server/src/tests/rest_crypto/mod.rs @@ -16,6 +16,7 @@ mod common; mod encrypt_decrypt; mod error_cases; mod jose_vectors; +mod key_state; mod mac; mod rfc_vectors; mod sign_verify; diff --git a/test_data b/test_data index 41871788ac..bfe6b7c92d 160000 --- a/test_data +++ b/test_data @@ -1 +1 @@ -Subproject commit 41871788ac8b8ebbb990747828f25960863538c4 +Subproject commit bfe6b7c92d7982453884da1f75625e3f064a5c71 From 05751a173e7fa343a791c7e82f45c359dc9205de Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 15 Jun 2026 19:04:06 +0200 Subject: [PATCH 30/30] chore: update Nix expected hashes --- nix/expected-hashes/server.vendor.static.sha256 | 2 +- nix/expected-hashes/ui.vendor.fips.sha256 | 2 +- nix/expected-hashes/ui.vendor.non-fips.sha256 | 2 +- test_data | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/expected-hashes/server.vendor.static.sha256 b/nix/expected-hashes/server.vendor.static.sha256 index 1efd4c340b..485d99dab3 100644 --- a/nix/expected-hashes/server.vendor.static.sha256 +++ b/nix/expected-hashes/server.vendor.static.sha256 @@ -1 +1 @@ -sha256-AvlJ+zb8blhGfSzDFgcbqRmt0Dmr6Qd9/BeoeW95Adk= +sha256-dUwwUVT+gHGWezkfQKKuH/6keBREvPsGpsbwYHRoG98= diff --git a/nix/expected-hashes/ui.vendor.fips.sha256 b/nix/expected-hashes/ui.vendor.fips.sha256 index d917550964..b024745362 100644 --- a/nix/expected-hashes/ui.vendor.fips.sha256 +++ b/nix/expected-hashes/ui.vendor.fips.sha256 @@ -1 +1 @@ -sha256-2ORxwjjjlNtQdYpgs6Q3n4JRN4E1eHkMfdw4OIgJSIM= +sha256-svfCrtu7g3Nvvv7bkHNFyJIPV2Bn1QG9AZKYwoPDAFk= diff --git a/nix/expected-hashes/ui.vendor.non-fips.sha256 b/nix/expected-hashes/ui.vendor.non-fips.sha256 index a6812d6d3e..fca852a917 100644 --- a/nix/expected-hashes/ui.vendor.non-fips.sha256 +++ b/nix/expected-hashes/ui.vendor.non-fips.sha256 @@ -1 +1 @@ -sha256-a86nwCScbRnuReokrCoKEUYrozxAwD+RdZJ3Dxc3i+Q= +sha256-+dDmWnyHegTUHh0x0Hf3Yqjx+FnV1hZxd8MdK1jZVVc= diff --git a/test_data b/test_data index bfe6b7c92d..177ee01179 160000 --- a/test_data +++ b/test_data @@ -1 +1 @@ -Subproject commit bfe6b7c92d7982453884da1f75625e3f064a5c71 +Subproject commit 177ee0117935eb893ed585d4efcd736d62f81a04