diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a28e8d5311..546a92d827 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -37,23 +37,23 @@ curl -s -X POST -H "Content-Type: application/json" -d '{}' http://localhost:999 ### Cargo aliases (`.cargo/config.toml`) -| Alias | Expands to | -|---|---| -| `format` | `fmt --all -- --check` | -| `build-all` | `build --workspace --all-targets --all-features --bins` | -| `test-fips` | `test --lib --workspace` | -| `test-non-fips` | `test --lib --workspace --features non-fips` | -| `clippy-all` | `clippy --workspace --all-targets --all-features -- -D warnings` | +| Alias | Expands to | +| --------------- | ---------------------------------------------------------------- | +| `format` | `fmt --all -- --check` | +| `build-all` | `build --workspace --all-targets --all-features --bins` | +| `test-fips` | `test --lib --workspace` | +| `test-non-fips` | `test --lib --workspace --features non-fips` | +| `clippy-all` | `clippy --workspace --all-targets --all-features -- -D warnings` | ### Database test environment Start backends with `docker compose up -d`, then set: -| Variable | Value | -|---|---| +| Variable | Value | +| ------------------ | ----------------------------------------- | | `KMS_POSTGRES_URL` | `postgresql://kms:kms@127.0.0.1:5432/kms` | -| `KMS_MYSQL_URL` | `mysql://kms:kms@localhost:3306/kms` | -| `KMS_SQLITE_PATH` | `data/shared` | +| `KMS_MYSQL_URL` | `mysql://kms:kms@localhost:3306/kms` | +| `KMS_SQLITE_PATH` | `data/shared` | > MySQL tests are currently disabled in CI. > Redis-findex tests are skipped in FIPS mode. @@ -143,10 +143,10 @@ crate/server/src/core/kms/mod.rs — KMS struct (params, database, Enterprise routes: -- `crate/server/src/routes/aws_xks/` — AWS XKS +- `crate/server/src/routes/aws_xks/` — AWS XKS - `crate/server/src/routes/azure_ekm/` — Azure EKM - `crate/server/src/routes/google_cse/` — Google CSE -- `crate/server/src/routes/ms_dke/` — Microsoft DKE +- `crate/server/src/routes/ms_dke/` — Microsoft DKE You must always verify that changes related to KMIP protocol are compliant with KMIP specifications (HTML files found in crate/kmip/src) @@ -156,37 +156,37 @@ You must always verify that changes related to KMIP protocol are compliant with When you need to change something, start here: -| Intent | File(s) | -|---|---| +| Intent | File(s) | +| --------------------------- | ------------------------------------------------- | | Add/change a KMIP operation | `crate/server/src/core/operations/.rs` | -| KMIP operation dispatcher | `crate/server/src/core/operations/dispatch.rs` | -| KMS struct definition | `crate/server/src/core/kms/mod.rs` | -| Server config & CLI flags | `crate/server/src/config/` | -| Server startup | `crate/server/src/start_kms_server.rs` | -| OpenSSL provider init | `crate/server/src/openssl_providers.rs` | -| HTTP routes | `crate/server/src/routes/` | -| Middlewares (auth, logging) | `crate/server/src/middlewares/` | -| KMIP protocol types | `crate/kmip/src/` | -| Crypto primitives | `crate/crypto/src/` | -| OpenSSL build script | `crate/crypto/build.rs` | -| DB backend implementations | `crate/server_database/src/` | -| CLI commands | `crate/clients/ckms/src/` | -| WASM bindings | `crate/wasm/src/` | -| Web UI source | `ui/src/` | -| E2E tests (Playwright) | `ui/tests/e2e/` | -| E2E test helpers | `ui/tests/e2e/helpers.ts` | +| KMIP operation dispatcher | `crate/server/src/core/operations/dispatch.rs` | +| KMS struct definition | `crate/server/src/core/kms/mod.rs` | +| Server config & CLI flags | `crate/server/src/config/` | +| Server startup | `crate/server/src/start_kms_server.rs` | +| OpenSSL provider init | `crate/server/src/openssl_providers.rs` | +| HTTP routes | `crate/server/src/routes/` | +| Middlewares (auth, logging) | `crate/server/src/middlewares/` | +| KMIP protocol types | `crate/kmip/src/` | +| Crypto primitives | `crate/crypto/src/` | +| OpenSSL build script | `crate/crypto/build.rs` | +| DB backend implementations | `crate/server_database/src/` | +| CLI commands | `crate/clients/ckms/src/` | +| WASM bindings | `crate/wasm/src/` | +| Web UI source | `ui/src/` | +| E2E tests (Playwright) | `ui/tests/e2e/` | +| E2E test helpers | `ui/tests/e2e/helpers.ts` | --- ## 5. Feature flags -| Flag | Default | Effect | -|---|---|---| -| *(none / fips)* | **on** | FIPS-140-3 mode; only NIST-approved algorithms; loads FIPS provider | -| `non-fips` | off | Legacy OpenSSL provider, Covercrypt, Redis-findex, PQC CLI module, AES-XTS | -| `interop` | **on** | Enables extra KMIP interoperability test operations (on by default; do not disable in tests) | -| `insecure` | off | Skips OAuth token expiration check and allows self-signed TLS — **dev/test only** | -| `timeout` | off | Makes the server binary expire at a compile-time-chosen date | +| Flag | Default | Effect | +| --------------- | ------- | -------------------------------------------------------------------------------------------- | +| _(none / fips)_ | **on** | FIPS-140-3 mode; only NIST-approved algorithms; loads FIPS provider | +| `non-fips` | off | Legacy OpenSSL provider, Covercrypt, Redis-findex, PQC CLI module, AES-XTS | +| `interop` | **on** | Enables extra KMIP interoperability test operations (on by default; do not disable in tests) | +| `insecure` | off | Skips OAuth token expiration check and allows self-signed TLS — **dev/test only** | +| `timeout` | off | Makes the server binary expire at a compile-time-chosen date | Use `--features non-fips` to enable all non-approved algorithms. @@ -223,23 +223,23 @@ bash .github/scripts/nix.sh [--variant fips|non-fips] [--link static|dynamic] CO ### Test types (`nix.sh test `) -| Type | FIPS? | Script | Notes | -|---|---|---|---| -| `sqlite` | yes | `test_sqlite.sh` | Default DB backend | -| `psql` | yes | `test_psql.sh` | Requires PostgreSQL | -| `mysql` | yes | `test_mysql.sh` | Disabled in CI | -| `percona` | yes | `test_percona.sh` | Percona XtraDB | -| `mariadb` | yes | `test_maria.sh` | MariaDB | -| `wasm` | yes | `test_wasm.sh` | WASM package build + tests | -| `google_cse` | yes | `test_google_cse.sh` | Requires OAuth creds | -| `gcp_cmek` | yes | `test_gcp_cmek.sh` | GCP CMEK wrapping | -| `otel_export` | yes | `test_otel_export.sh` | OpenTelemetry metrics | -| `hsm [backend]` | yes | `test_hsm_*.sh` | softhsm2 / utimaco / proteccio / all | -| `redis` | **no** | `test_redis.sh` | Redis-findex (non-FIPS only) | -| `pykmip` | **no** | `test_pykmip.sh` | PyKMIP + Synology DSM | -| `aws_xks` | **no** | `aws_xks_test.sh` | AWS XKS | -| `azure_ekm` | **no** | `azure_ekm_test.sh` | Azure EKM | -| `ui` | **no** | `test_ui.sh` | Playwright E2E (see §8) | +| Type | FIPS? | Script | Notes | +| --------------- | ------ | --------------------- | ------------------------------------ | +| `sqlite` | yes | `test_sqlite.sh` | Default DB backend | +| `psql` | yes | `test_psql.sh` | Requires PostgreSQL | +| `mysql` | yes | `test_mysql.sh` | Disabled in CI | +| `percona` | yes | `test_percona.sh` | Percona XtraDB | +| `mariadb` | yes | `test_maria.sh` | MariaDB | +| `wasm` | yes | `test_wasm.sh` | WASM package build + tests | +| `google_cse` | yes | `test_google_cse.sh` | Requires OAuth creds | +| `gcp_cmek` | yes | `test_gcp_cmek.sh` | GCP CMEK wrapping | +| `otel_export` | yes | `test_otel_export.sh` | OpenTelemetry metrics | +| `hsm [backend]` | yes | `test_hsm_*.sh` | softhsm2 / utimaco / proteccio / all | +| `redis` | **no** | `test_redis.sh` | Redis-findex (non-FIPS only) | +| `pykmip` | **no** | `test_pykmip.sh` | PyKMIP + Synology DSM | +| `aws_xks` | **no** | `aws_xks_test.sh` | AWS XKS | +| `azure_ekm` | **no** | `azure_ekm_test.sh` | Azure EKM | +| `ui` | **no** | `test_ui.sh` | Playwright E2E (see §8) | ### Package types (`nix.sh package [type]`) @@ -253,14 +253,14 @@ bash .github/scripts/nix.sh docker --variant non-fips --load --test ### Workflow files -| Workflow | Purpose | -|---|---| +| Workflow | Purpose | +| ---------------------------- | -------------------------------------------------------------------- | | `main.yml` → `main_base.yml` | Push/PR trigger; runs clippy, cargo-deny, cargo-test, test_all, docs | -| `test_all.yml` | Nix-based test matrix: 15 types × 2 variants + HSM matrix | -| `packaging.yml` | Multi-platform packaging (Linux/ARM/macOS), GPG-signed | -| `packaging-docker.yml` | Docker image builds (fips + non-fips) | -| `test_windows.yml` | Windows-only build + test | -| `build_windows.yml` | Windows server + UI builder | +| `test_all.yml` | Nix-based test matrix: 15 types × 2 variants + HSM matrix | +| `packaging.yml` | Multi-platform packaging (Linux/ARM/macOS), GPG-signed | +| `packaging-docker.yml` | Docker image builds (fips + non-fips) | +| `test_windows.yml` | Windows-only build + test | +| `build_windows.yml` | Windows server + UI builder | --- @@ -270,6 +270,20 @@ bash .github/scripts/nix.sh docker --variant non-fips --load --test The UI must be seen as a mirror of the `ckms` CLI tool. All features added to the `ckms` CLI tool or development must be synced on the Web UI. +### Mandatory UI mirroring checklist + +Whenever a new feature or operation is added to `ckms` (the CLI tool under `crate/clients/ckms/`), **the same feature must be implemented in the Web UI** before the work is considered complete. The required steps are: + +1. **WASM bindings** (`crate/wasm/src/wasm.rs`): add a `#[wasm_bindgen]` function that builds the KMIP TTLV request and a corresponding `parse_*_ttlv_response` function. Use the existing `rekey_ttlv_request` / `parse_rekey_ttlv_response` functions as a model. +2. **WASM TypeScript declarations** (`ui/src/wasm/pkg/cosmian_kms_client_wasm.d.ts`): add the matching TypeScript function signatures next to the new Rust functions. The `.d.ts` is a pre-built artefact and must be updated manually whenever new WASM functions are added. +3. **React component** (`ui/src/`): create a new `.tsx` file that builds the form, calls the WASM function, and displays the response. Follow the pattern of existing components (e.g. `SymKeysReKey.tsx`, `SymKeysCreate.tsx`). +4. **Menu entry** (`ui/src/menuItems.tsx`): add a `{ key: "
/...", label: "..." }` item under the correct parent. +5. **Route** (`ui/src/App.tsx`): import the new component and add the matching `` inside the correct `` block. +6. **E2E Playwright test** (`ui/tests/e2e/`): create or extend a `*.spec.ts` file that navigates to the page, fills the form, and asserts the response. Use `data-testid="submit-btn"` and `data-testid="response-output"` for targeting. +7. **E2E README** (`ui/tests/e2e/README.md`): update the test coverage table. + +Failing to mirror a CLI feature in the UI is treated as an incomplete implementation. + ### Running UI tests ```bash @@ -306,11 +320,11 @@ Update ui/tests/e2e/README.md according to ui/tests/e2e/ tests. The UI has three test layers — all must pass before merging: -| Layer | Runner | Location | Config | -|---|---|---|---| -| E2E | Playwright | `ui/tests/e2e/` | `ui/playwright.config.ts` | -| Integration | Vitest | `ui/tests/integration/` | `ui/tests/vitest.int.config.ts` | -| Unit | Vitest | `ui/tests/unit/` | `ui/tests/vitest.unit.config.ts` | +| Layer | Runner | Location | Config | +| ----------- | ---------- | ----------------------- | -------------------------------- | +| E2E | Playwright | `ui/tests/e2e/` | `ui/playwright.config.ts` | +| Integration | Vitest | `ui/tests/integration/` | `ui/tests/vitest.int.config.ts` | +| Unit | Vitest | `ui/tests/unit/` | `ui/tests/vitest.unit.config.ts` | ### UI test conventions @@ -438,14 +452,14 @@ Repeat for all four combinations (`fips`/`non-fips` × `dynamic`/`static`). ## 14. Common issues -| Symptom | Cause | Fix | -|---|---|---| -| Usage mask errors (`Encrypt`, `Sign` denied) | Key missing required `CryptographicUsageMask` | Check the object's attributes | -| `legacy.so` / `fips.so` not found | `OPENSSL_MODULES` not set | Ensure `apply_openssl_dir_env_if_needed()` in `openssl_providers.rs` is called before `Provider::try_load()` | -| Stale Nix vendor hashes | `Cargo.lock` or version changed | Regenerate all four hash files (see §13) | -| `gh` command hangs | Interactive pager opened | Use `GH_PAGER=cat gh ...` | -| Playwright `toHaveText` type error with `exact` | Unsupported option in Playwright | Use anchored regex instead: `toHaveText(/^\s*Label\s*$/)` | -| TypeScript unused-variable error in UI tests | `noUnusedLocals: true` in tsconfig | Remove the variable or prefix with `_` | +| Symptom | Cause | Fix | +| ----------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Usage mask errors (`Encrypt`, `Sign` denied) | Key missing required `CryptographicUsageMask` | Check the object's attributes | +| `legacy.so` / `fips.so` not found | `OPENSSL_MODULES` not set | Ensure `apply_openssl_dir_env_if_needed()` in `openssl_providers.rs` is called before `Provider::try_load()` | +| Stale Nix vendor hashes | `Cargo.lock` or version changed | Regenerate all four hash files (see §13) | +| `gh` command hangs | Interactive pager opened | Use `GH_PAGER=cat gh ...` | +| Playwright `toHaveText` type error with `exact` | Unsupported option in Playwright | Use anchored regex instead: `toHaveText(/^\s*Label\s*$/)` | +| TypeScript unused-variable error in UI tests | `noUnusedLocals: true` in tsconfig | Remove the variable or prefix with `_` | --- @@ -474,29 +488,29 @@ The integrations section is the most commonly extended area. Keep these four vie **Canonical integration file paths**: - Cloud providers: `documentation/docs/integrations/cloud_providers//` - - AWS: `cloud_providers/aws/` (xks.md, byok.md, fargate.md) - - Azure: `cloud_providers/azure/` (ekm.md, byok.md) - - GCP: `cloud_providers/google_gcp/` (cmek.md, csek.md) - - Google Workspace CSE: `cloud_providers/google_workspace_client_side_encryption_cse/` - - Microsoft 365 DKE: `cloud_providers/microsoft_365_double_key_encryption_dke/` + - AWS: `cloud_providers/aws/` (xks.md, byok.md, fargate.md) + - Azure: `cloud_providers/azure/` (ekm.md, byok.md) + - GCP: `cloud_providers/google_gcp/` (cmek.md, csek.md) + - Google Workspace CSE: `cloud_providers/google_workspace_client_side_encryption_cse/` + - Microsoft 365 DKE: `cloud_providers/microsoft_365_double_key_encryption_dke/` - Databases: `documentation/docs/integrations/databases/` - - mongodb.md, mysql.md, percona.md, ms_sql_server.md, oracle_tde.md, snowflake_native_app/ + - mongodb.md, mysql.md, percona.md, ms_sql_server.md, oracle_tde.md, snowflake_native_app/ - Storage: `documentation/docs/integrations/storage/` - - vcenter.md, synology_dsm.md, veeam.md, user_defined_function_for_pyspark_databricks_in_python/ - - Disk encryption: `documentation/docs/integrations/disk_encryption/` - - veracrypt.md, luks.md, cryhod.md + - vcenter.md, synology_dsm.md, veeam.md, user_defined_function_for_pyspark_databricks_in_python/ + - Disk encryption: `documentation/docs/integrations/disk_encryption/` + - veracrypt.md, luks.md, cryhod.md - Other: `documentation/docs/integrations/` - - openssh.md, pykmip.md, smime.md + - openssh.md, pykmip.md, smime.md **README.md `## 🔗 Integrations` section categories must mirror mkdocs.yml exactly:** -| README section | mkdocs.yml grouping | Files location | -|---|---|---| -| ☁️ Cloud Provider — External Key Management | `Cloud providers:` | `integrations/cloud_providers/` | -| 🗄️ Database Integrations | `Databases:` | `integrations/databases/` | -| 💿 Disk Encryption | `Disk encryption:` (under `Storage:`) | `integrations/disk_encryption/` | -| 💾 Storage Integrations | `Storage:` | `integrations/storage/` | -| 🔗 Other Integrations | `Other:` | `integrations/` root | +| README section | mkdocs.yml grouping | Files location | +| ------------------------------------------- | ------------------------------------- | ------------------------------- | +| ☁️ Cloud Provider — External Key Management | `Cloud providers:` | `integrations/cloud_providers/` | +| 🗄️ Database Integrations | `Databases:` | `integrations/databases/` | +| 💿 Disk Encryption | `Disk encryption:` (under `Storage:`) | `integrations/disk_encryption/` | +| 💾 Storage Integrations | `Storage:` | `integrations/storage/` | +| 🔗 Other Integrations | `Other:` | `integrations/` root | **When adding a new integration**: diff --git a/.github/scripts/test/test_ui.sh b/.github/scripts/test/test_ui.sh index 114f409c74..10191d51a3 100755 --- a/.github/scripts/test/test_ui.sh +++ b/.github/scripts/test/test_ui.sh @@ -150,7 +150,7 @@ echo "==> Building UI (VITE_KMS_URL=http://127.0.0.1:9998) …" chmod -R u+w dist >/dev/null 2>&1 || true rm -rf dist >/dev/null 2>&1 || true }) -(cd "${UI_DIR}" && env -u LD_PRELOAD -u OPENSSL_CONF -u OPENSSL_MODULES VITE_KMS_URL="http://127.0.0.1:9998" pnpm run build) +(cd "${UI_DIR}" && env -u LD_PRELOAD -u OPENSSL_CONF -u OPENSSL_MODULES VITE_KMS_URL="http://127.0.0.1:9998" pnpm run build:react) # ── 4. Install Playwright's Chromium browser ───────────────────────────────── echo "==> Installing Playwright Chromium browser …" diff --git a/.github/scripts/windows/test_ui.ps1 b/.github/scripts/windows/test_ui.ps1 index 9b65bdf688..6a40d56d10 100644 --- a/.github/scripts/windows/test_ui.ps1 +++ b/.github/scripts/windows/test_ui.ps1 @@ -222,6 +222,10 @@ try { # SoftHSM2 is not available on Windows; signal to the specs that no HSM # keys are pre-created so the HSM-specific tests are skipped. $env:PLAYWRIGHT_HSM_KEY_COUNT = "0" + # Use fewer parallel workers on Windows: the CI runner is slower than + # Linux, and 10 concurrent workers saturate the single KMS server causing + # 408 Request Timeout errors in unrelated tests. + $env:PLAYWRIGHT_WORKERS = "5" try { Invoke-Checked $pnpmCmd @("run", "test:e2e") } @@ -229,6 +233,7 @@ try { Remove-Item Env:CI -ErrorAction SilentlyContinue Remove-Item Env:PLAYWRIGHT_BASE_URL -ErrorAction SilentlyContinue Remove-Item Env:PLAYWRIGHT_HSM_KEY_COUNT -ErrorAction SilentlyContinue + Remove-Item Env:PLAYWRIGHT_WORKERS -ErrorAction SilentlyContinue Pop-Location } } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6d4c0c9050..1ac87fdea9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -301,5 +301,5 @@ repos: rev: v3.13.7 hooks: - id: markdown-link-check - args: [-q, --config, ./.markdown-link-check.json] + args: [-q, --config, .github/.markdown-link-check.json] exclude: .github/copilot-instructions.md diff --git a/CHANGELOG/feat_key_rotation_policy.md b/CHANGELOG/feat_key_rotation_policy.md new file mode 100644 index 0000000000..f9303ff93d --- /dev/null +++ b/CHANGELOG/feat_key_rotation_policy.md @@ -0,0 +1,92 @@ +## [Unreleased] + +### 🚀 Features + +#### Key auto-rotation (scheduled / policy-driven) + +- **KMIP link chain**: `ReKey` now creates a fresh key UUID and links old and new keys via `ReplacementObjectLink` / `ReplacedObjectLink` attributes, enabling full rotation lineage tracking ([#859](https://github.com/Cosmian/kms/issues/859)). +- **Wrapping-key rotation**: when a wrapping key is rotated, all objects it protects are automatically re-wrapped with the new key and their `WrappingKeyLink` attribute is updated. +- **Wrapped-key rotation**: symmetric keys that are themselves wrapped are transparently unwrapped, re-keyed, then re-wrapped with the same wrapping key during rotation. +- **Database query `find_due_for_rotation`**: all DB backends (SQLite, PostgreSQL, MySQL, Redis-Findex) can now query for keys whose `RotateInterval` has elapsed since the last `RotateDate`. +- **Auto-rotation background task**: a new `run_auto_rotation()` operation scans all owned objects due for rotation and rotates them automatically; each rotation is counted in the `kms.key.auto_rotation` OTel metric. Supported object types: `SymmetricKey` (via `ReKey`), `Certificate` (via `Certify` upsert/re-sign), `PrivateKey` / `PublicKey` (via `ReKeyKeyPair` — currently CoverCrypt only; RSA/EC key pair rotation is gracefully skipped with a warning until `ReKeyKeyPair` is extended). +- **Cron wiring**: the background cron thread now schedules auto-rotation checks at a configurable interval (default: disabled, set `--auto-rotation-check-interval-secs` > 0 to enable). +- **CLI `set-rotation-policy`**: new `sym keys set-rotation-policy` sub-command to configure `--interval`, `--name`, and `--offset` rotation attributes on any symmetric key. +- **CLI `--rotate-interval/--rotate-name/--rotate-offset` on create**: all create commands (`sym keys create`, `rsa keys create-key-pair`, `ec keys create-key-pair`, `pqc keys create-key-pair`, `cc keys create-master-key-pair`) now accept optional `--rotate-interval`, `--rotate-name`, and `--rotate-offset` flags to set the rotation policy at key creation time. +- **CLI `re-key` subcommand for RSA, EC, PQC**: `rsa keys re-key`, `ec keys re-key`, and `pqc keys re-key` subcommands added (symmetric re-key was already present); they accept `--key-id` and an optional `--offset` parameter. Server-side support for asymmetric `ReKey` will follow in a subsequent PR. +- **Web UI — Re-Key**: new `sym/keys/re-key` page allowing users to re-key a symmetric key from the browser; backed by new `rekey_ttlv_request` / `parse_rekey_ttlv_response` WASM bindings. Re-Key is also added to RSA, EC, PQC, and Covercrypt menus. +- **Web UI — Rotation Policy at creation**: all Create forms (Symmetric, RSA, EC, PQC, Covercrypt master key) and the **Certificate Certify/Renew** form now include a collapsible *Auto Rotation Policy* panel with interval, name, and offset fields applied via `SetAttribute` after creation. +- **Web UI — Set Rotation Policy removed**: the standalone *Set Rotation Policy* menu item has been removed from all key type menus; rotation policy is now set inline in the Create form. +- **Web UI — Covercrypt Re-Key**: new `CovercryptReKey` component allowing re-keying a Covercrypt master key pair by access policy; backed by new `rekey_cc_keypair_ttlv_request` / `parse_rekey_cc_keypair_ttlv_response` WASM bindings (non-FIPS only). +- **Web UI menus unified**: all key type menus (Symmetric, RSA, EC, PQC, Covercrypt) now share the same structure: Create → Re-Key → Export → Import → Revoke → Destroy. +- **Server flag `--auto-rotation-check-interval-secs`**: configures (or disables) the server-side background rotation check interval. + +### 🐛 Bug Fixes + +#### Key rotation policy semantics + +- **Auto-rotation: old key's `rotate_interval` now set to `0` after rotation**: previously the old key's `rotate_interval` was left unchanged after `run_auto_rotation`, causing the cron to re-pick the same object on the next check. After rotation, `rekey.rs` now explicitly writes `rotate_interval = Some(0)` to the old key's metadata, preventing the cron from rotating it a second time. +- **Auto-rotation: new key now inherits rotation policy from old key**: after `run_auto_rotation` completes an auto-triggered rekey, `auto_rotate.rs` now captures the old key's `rotate_interval`, `rotate_name`, and `rotate_offset` before calling `rekey`, then writes them to the new key along with a fresh `initial_date`. This ensures the auto-rotation cadence is preserved seamlessly across generations without any manual operator intervention. +- **Manual rekey: new key does NOT inherit rotation policy**: when a user explicitly calls `Re-Key`, the new key starts with `rotate_interval = Some(0)` (disabled). This is intentional — a manual rekey is an out-of-cycle operator action; the operator should explicitly re-arm the rotation policy on the new key if desired. The old key also gets `rotate_interval = Some(0)`. + +#### UI — Locate page + +- **Locate: fix Type and Key Format Type showing N/A for all keys**: after enriching locate results with `GetAttributes` per object, a redundant inner try/catch block was calling `supplementStateFromOwned(mapped, ...)` using the un-enriched `mapped` array (with `ObjectType: undefined`) and overwriting the correctly populated `merged` data. The inner block is removed — `supplementStateFromOwned(enriched, ...)` already produces the correct final rows. +- **Locate: fix pagination not working when changing page size**: the `pageSize` prop on the `Table` pagination was a controlled value that reset to 10 on every render, ignoring user selection. Changed to `defaultPageSize` (uncontrolled) so Ant Design's internal state handles changes correctly. +- **Locate: add Date column**: a sortable *Date* column is added to the results table showing the best available date for each object (priority: last rotation date → creation/initial date → activation date). The column header indicates the date source via a tooltip. +- **Locate: add inline Auto-Rotate button**: each row in the results table now has a compact *Auto-Rotate* button in the Actions column. When auto-rotation is already configured the button shows a blue tag with the current interval (e.g. `↻ 30d`); clicking it opens a Popover to update the interval (in days) or disable it. Disabling sets `rotate_interval = 0` via `SetAttribute`, which causes `is_due_for_rotation` to return `false`. The `initial_date` attribute is also now supported in `parse_selected_attributes_flatten` so it can be fetched and displayed. +- **`parse_selected_attributes_flatten`: add `initial_date` support**: the Rust attribute parser in `crate/client_utils/src/attributes_utils.rs` now handles the `"initial_date"` key, returning its Unix timestamp so the UI can display it as a date source. + +#### Key rotation policy attributes not visible after creation + +- **Server `GetAttributes`**: rotation policy fields (`rotate_interval`, `rotate_name`, `rotate_offset`, `rotate_date`, `rotate_generation`, `rotate_latest`) are Cosmian-proprietary extensions that have no standard KMIP `Tag` enum values. They were never included in `GetAttributes` responses because the Tag-based iteration loop had no matching arms for them. Fixed by copying these fields from the stored `Attributes` directly into the response struct after the Tag loop. +- **WASM `parse_selected_attributes_flatten`**: the `match` in `parse_selected_attributes_flatten` fell through to `_x => {}` when any rotate_* attribute was specifically requested by name. Added explicit match arms for all six rotation fields. +- **UI Attribute Get page**: added `rotate_interval`, `rotate_name`, `rotate_offset`, `rotate_date`, `rotate_generation`, `rotate_latest` to the `ATTRIBUTE_NAMES` selector so users can retrieve them individually. + +#### Configuration + +- **`auto_rotation_check_interval_secs` ignored in TOML config**: the field was placed under the `[ui_config]` section in the test config, but it is a top-level `ClapConfig` field and must appear before any section header to be parsed correctly. Moved to the top level in `test_data/configs/server/ui.toml`. + +#### Key rotation correctness, `RotateLatest=false` on old key**: the flag was previously copied verbatim from the old key to the new one, making it impossible to locate the most recently rotated key via `Locate` with `rotate_latest=true` (KMIP §4.51). Now exactly one key in a lineage carries `rotate_latest=true` at any point in time + +#### Certificate auto-renewal reliability + +- **Certificate renewal now creates new objects instead of overwriting**: auto-rotation for certificates previously upserted a re-signed cert in place (same UID). The certificate, private key, and public key are now left unchanged; a completely new set of objects (new cert UID + new key pair UIDs) is created on each renewal. The old objects receive a `ReplacementObjectLink` pointing to their successors; the new objects carry a `ReplacedObjectLink` back to their predecessors (KMIP 2.1 §4.48 semantics). The old cert's rotation policy (`rotate_interval`, `rotate_date`, `initial_date`) is cleared after renewal so the cron does not pick it up again. The new cert inherits the rotation policy so subsequent renewals continue at the configured cadence. +- **Certificate renewal produces bitwise-identical DER bytes (renewals appear silent)**: the serial number was computed as `SHA1(SPKI)`, which is deterministic and unchanged across renewals of the same key pair. RSA PKCS#1 v1.5 signing is also deterministic, so the entire X.509 DER was identical before and after renewal. Users saw *"certificate xxx re-certified"* in logs but the certificate file appeared unchanged. Fixed by mixing the current nanosecond timestamp into the SHA1 hash (`sha1.update(&OffsetDateTime::now_utc().unix_timestamp_nanos().to_le_bytes())`) so consecutive renewals produce unique serial numbers per RFC 5280 §4.1.2.2. +- **`PrivateKeyLink`/`PublicKeyLink` lost after renewal**: `build_and_sign_certificate` stripped `PrivateKeyLink` and `PublicKeyLink` from the certificate attributes (to prevent issuer-key leakage) but never restored them for `Subject::Certificate` (renewal). After the first auto-renewal the cert had no key links, causing all subsequent cron renewals to fail with *"No private or public key link found for the certificate"*. Fixed by splitting the `Subject::X509Req | Subject::Certificate` match arm and restoring the subject certificate's key links from the stored attributes after signing. +- **`rotate_date` not updated after certificate renewal**: `auto_rotate.rs` contained a wrong comment claiming *"rotate_date is set by the certify path"*, but `build_and_sign_certificate` never updates it. This made `is_due_for_rotation` return `true` on every cron tick (since the old past date was reused), causing the cert to be renewed indefinitely. Fixed by explicitly setting `certify_attrs.rotate_date = Some(OffsetDateTime::now_utc())` before the `Certify` call in `auto_rotate_key`. +- **`initial_date` never set on newly issued certificates**: `is_due_for_rotation` computes the first rotation deadline as `initial_date + rotate_interval` when no `rotate_date` exists. Because `certify/mod.rs` never stamped `initial_date` on new certificates, any cert created with only `rotate_interval` set (the typical production flow) was silently skipped on every cron tick — users saw *"Running scheduled key auto-rotation check"* in logs with no renewal. Fixed by setting `attributes.initial_date = Some(OffsetDateTime::now_utc())` in `build_and_sign_certificate` when it is not already present (preserves the original date across renewals). + +- **`rekey`: clear stale `link` from new-key attrs before `create_symmetric_key_and_tags`**: links from the source key were embedded in the new key's block and shadowed the correct links stored in the metadata column, causing chained-rekey assertions to fail. +- **`rekey`: clear `key_format_type` from new-key attrs**: the `Raw` presentation format passed to the key generator caused "unable to generate a symmetric key for format: Raw" when rekeying keys with `GetAttributes`-normalised format type. +- **`rekey`: commit new wrapping key before re-wrapping dependants**: the new wrapping key is now persisted in a Phase-1 atomic commit before any wrapped-key re-wrapping occurs, fixing "wrapping key not found" errors during wrapping-key rotation. + +### 🧪 Testing + +- **Server**: 25 unit tests covering basic rekey, KMIP link chain, rotation metadata propagation, policy preservation, `rotate_latest` flag, wrapped/wrapping-key rotation, chained rotations, unknown-uid errors, all `run_auto_rotation` edge cases, certificate renewal (new-object semantics with `ReplacementObjectLink`/`ReplacedObjectLink`), public-key graceful-skip (RSA key pair rekey not yet implemented), two **end-to-end cron tests** that verify the background thread fires autonomously, a **production-scenario test** (`test_cron_renews_cert_with_only_rotate_interval_set`) that guards against the `initial_date`-missing regression, and `test_cert_auto_rotation_updates_der_bytes` which asserts the new cert's DER bytes differ from the old cert's, the old cert is preserved unchanged, and all cross-links are correct. +- **CLI (ckms + cosmian_kms_cli)**: updated `test_rekey_symmetric_key` to assert `id != id_2`; added 4 `set-rotation-policy` CLI tests validating `--interval`, `--name`, `--offset`, and disable-by-zero semantics; added `test_rekey_sets_link_chain_after_rotation_policy` CLI test verifying that after setting a rotation policy and manually rekeying, old key carries `ReplacementObjectLink` → new key and new key carries `ReplacedObjectLink` → old key. +- **Server**: added `test_auto_rotation_transfers_policy_to_new_key` unit test verifying that auto-rotation (cron-triggered) sets `rotate_interval=0` on the old key and transfers the full rotation policy (`rotate_interval`, `rotate_name`) to the new key. +- **Web UI E2E**: added `set rotation policy then re-key` Playwright test confirming that arming a key with a rotation policy, rekeying via the UI, and then fetching attributes on the old key all succeed — the old key's `ReplacementObjectLink` points to the new key UID. + +### 🔨 Build / Refactor + +#### Internalize `cosmian_logger` + +- **`crate/logger`**: `cosmian_logger` is now a first-class workspace member at `crate/logger/` instead of being pulled from crates.io. The source is identical to `cosmian_logger 0.5.4` with the following adaptations for the workspace: + - Removed `std::env::set_var` calls (unsafe in edition 2024) — the `rust_log` string from `TracingConfig` is now fed directly to `EnvFilter::try_new()`, removing the need to mutate the process environment. + - Fixed all clippy lints enforced by the workspace (`struct_excessive_bools`, `items_after_statements`, `manual_inspect`, `unnecessary_debug_formatting`, `str_to_string`, `let_underscore_drop`). + - `opentelemetry 0.29.x` packages are pinned directly in the crate manifest (workspace uses 0.27 for the server metrics layer; both versions coexist in the dependency tree). + +### 📚 Documentation + +#### Key auto-rotation policy + +- Added [`documentation/docs/kmip_support/key_auto_rotation.md`](../documentation/docs/kmip_support/key_auto_rotation.md): comprehensive reference for the scheduled key rotation feature covering all key types (plain, wrapping, wrapped, asymmetric), lifecycle diagrams (Mermaid stateDiagram + sequenceDiagram + flowchart), KMIP attribute table, and configuration examples. +- **Documentation: auto-rotation vs manual-rekey semantics**: updated the `key_auto_rotation.md` attribute table to clearly distinguish auto-rotation (old key gets `rotate_interval=0`; new key **inherits** the policy) from manual rekey (old key gets `rotate_interval=0`; new key also gets `rotate_interval=0` — user must explicitly re-arm the new key). +- Added entry in `documentation/mkdocs.yml` under *KMIP Support → Key Auto-Rotation Policy*. +- Updated `README.md` *Why Cosmian KMS* section with a one-line summary and link. + +#### Authentication — Break-Glass / Local Authentication + +- Added *Break-Glass / Local Authentication* section to [`documentation/docs/configuration/authentication.md`](../documentation/docs/configuration/authentication.md): documents the operational best practice of configuring TLS client certificate authentication alongside OIDC/JWT so that administrators retain a local, out-of-band recovery path when the identity provider is unreachable. Includes step-by-step certificate issuance, server configuration, ckms CLI usage, and emergency recovery procedures. + +Closes #859 diff --git a/Cargo.lock b/Cargo.lock index 689c43bc72..2c0d481a4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1238,6 +1238,7 @@ dependencies = [ "num-bigint-dig", "serde_json", "thiserror 2.0.17", + "time", "zeroize", ] @@ -1325,6 +1326,7 @@ dependencies = [ "strum", "tempfile", "thiserror 2.0.17", + "time", "tokio", "tokio-postgres", "tokio-rusqlite", @@ -1334,9 +1336,7 @@ dependencies = [ [[package]] name = "cosmian_logger" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e3c9c7a09cb59a839d7df9cba9fe3f781f2c46fa5b56545092acbbfd3e7aa3" +version = "5.20.0" dependencies = [ "opentelemetry 0.29.1", "opentelemetry-otlp 0.29.0", diff --git a/Cargo.toml b/Cargo.toml index 47c6bc1a1e..8bfc95fef0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ default-members = [ members = [ # Common crates "crate/kmip", + "crate/logger", # Client crates "crate/client_utils", "crate/kms_client", @@ -110,18 +111,18 @@ categories = ["security"] [profile.release] # Deterministic build configuration for reproducible binaries # These settings ensure the same source produces identical binaries across builds -lto = "fat" # Fat LTO: maximum cross-crate optimization for smallest binaries -strip = "symbols" # Strip symbol tables for smaller binaries -opt-level = "z" # Optimize for size while maintaining performance -codegen-units = 1 # Single codegen unit: best optimization and determinism -panic = "abort" # Smaller binaries, no unwinding tables +lto = "fat" # Fat LTO: maximum cross-crate optimization for smallest binaries +strip = "symbols" # Strip symbol tables for smaller binaries +opt-level = "z" # Optimize for size while maintaining performance +codegen-units = 1 # Single codegen unit: best optimization and determinism +panic = "abort" # Smaller binaries, no unwinding tables incremental = false # Disable incremental compilation for determinism -debug = 0 # No debug info (timestamps/paths) +debug = 0 # No debug info (timestamps/paths) [profile.dev] strip = "debuginfo" incremental = false -panic = "abort" # prevent UB from unwinding panics across extern "C" boundary +panic = "abort" # prevent UB from unwinding panics across extern "C" boundary # proc-macros and build-scripts [profile.dev.build-override] @@ -148,12 +149,23 @@ base64 = "0.22" bitflags = "2.9" chrono = "0.4" clap = { version = "4.5", default-features = false } -criterion = { version = "0.6", default-features = false, features = ["html_reports", "async_tokio"] } +criterion = { version = "0.6", default-features = false, features = [ + "html_reports", + "async_tokio", +] } cosmian_config_utils = "0.3.1" -cosmian_crypto_core = { version = "11.0", default-features = false, features = ["ser"] } -cosmian_logger = "0.5" +cosmian_crypto_core = { version = "11.0", default-features = false, features = [ + "ser", +] } +cosmian_logger = { path = "crate/logger", version = "5.20.0" } der = { version = "0.7", default-features = false } -dialoguer = { version = "0.11", default-features = false, features = ["editor", "fuzzy-select", "history", "password", "completion"] } +dialoguer = { version = "0.11", default-features = false, features = [ + "editor", + "fuzzy-select", + "history", + "password", + "completion", +] } dotenvy = "0.15" futures = "0.3" hex = { version = "0.4", default-features = false } diff --git a/README.md b/README.md index f85e311fd0..a097593bdd 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ See the [documentation](https://docs.cosmian.com/key_management_system/) for mor - HSM-first: optional HSM key-wrapping and vendor modules (Utimaco, SmartCard-HSM, Proteccio, Crypt2pay…). - Cloud-native: official Docker image, simple horizontal scaling, and OpenTelemetry observability. - End-to-end: server, CLI, and web UI for a complete developer and operator experience. +- **Key auto-rotation**: policy-driven background rotation for plain, wrapped, and wrapping keys — see [Key Auto-Rotation Policy](./documentation/docs/kmip_support/key_auto_rotation.md). ## 🎯 Top Use Cases diff --git a/cli_documentation/docs/cli/main_commands.md b/cli_documentation/docs/cli/main_commands.md index a4cf78f701..052bd646ab 100644 --- a/cli_documentation/docs/cli/main_commands.md +++ b/cli_documentation/docs/cli/main_commands.md @@ -649,6 +649,12 @@ If the wrapping key is: - a RSA key, RSA-OAEP will be used - a EC key, ECIES will be used (salsa20poly1305 for X25519) +`--rotate-interval [-i] ` Auto-rotation interval in seconds. Set to 0 to disable. Example: 86400 for daily rotation, 604800 for weekly rotation + +`--rotate-name ` Optional name to identify the rotation policy lineage + +`--rotate-offset ` Delay in seconds before the first automatic rotation is triggered. Defaults to the rotation interval if not set + --- @@ -1116,17 +1122,19 @@ Manage post-quantum keys (ML-KEM, ML-DSA) **`create`** [[7.1.1]](#711-ckms-pqc-keys-create) Create a new post-quantum key pair (ML-KEM or ML-DSA). -**`export`** [[7.1.2]](#712-ckms-pqc-keys-export) Export a key or secret data from the KMS +**`re-key`** [[7.1.2]](#712-ckms-pqc-keys-re-key) Refresh an existing post-quantum private key (key rotation) + +**`export`** [[7.1.3]](#713-ckms-pqc-keys-export) Export a key or secret data from the KMS -**`import`** [[7.1.3]](#713-ckms-pqc-keys-import) Import a secret data or a key in the KMS. +**`import`** [[7.1.4]](#714-ckms-pqc-keys-import) Import a secret data or a key in the KMS. -**`wrap`** [[7.1.4]](#714-ckms-pqc-keys-wrap) Locally wrap a secret data or key in KMIP JSON TTLV format. +**`wrap`** [[7.1.5]](#715-ckms-pqc-keys-wrap) Locally wrap a secret data or key in KMIP JSON TTLV format. -**`unwrap`** [[7.1.5]](#715-ckms-pqc-keys-unwrap) Locally unwrap a secret data or key in KMIP JSON TTLV format. +**`unwrap`** [[7.1.6]](#716-ckms-pqc-keys-unwrap) Locally unwrap a secret data or key in KMIP JSON TTLV format. -**`revoke`** [[7.1.6]](#716-ckms-pqc-keys-revoke) Revoke a PQC public or private key +**`revoke`** [[7.1.7]](#717-ckms-pqc-keys-revoke) Revoke a PQC public or private key -**`destroy`** [[7.1.7]](#717-ckms-pqc-keys-destroy) Destroy a PQC public or private key +**`destroy`** [[7.1.8]](#718-ckms-pqc-keys-destroy) Destroy a PQC public or private key --- @@ -1147,11 +1155,30 @@ Possible values: `"ml-kem-512", "ml-kem-768", "ml-kem-1024", "ml-dsa-44", "ml-d Possible values: `"true", "false"` [default: `"false"`] +`--rotate-interval [-i] ` Auto-rotation interval in seconds. Set to 0 to disable. Example: 86400 for daily rotation, 604800 for weekly rotation + +`--rotate-name ` Optional name to identify the rotation policy lineage + +`--rotate-offset ` Delay in seconds before the first automatic rotation is triggered. Defaults to the rotation interval if not set + --- -## 7.1.2 ckms pqc keys export +## 7.1.2 ckms pqc keys re-key + +Refresh an existing post-quantum private key (key rotation) + +### Usage +`ckms pqc keys re-key [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the PQC private key to rotate + + + +--- + +## 7.1.3 ckms pqc keys export Export a key or secret data from the KMS @@ -1210,7 +1237,7 @@ Possible values: `"aes-key-wrap-padding", "nist-key-wrap", "aes-gcm", "rsa-pkcs --- -## 7.1.3 ckms pqc keys import +## 7.1.4 ckms pqc keys import Import a secret data or a key in the KMS. @@ -1258,7 +1285,7 @@ If the wrapping key is: --- -## 7.1.4 ckms pqc keys wrap +## 7.1.5 ckms pqc keys wrap Locally wrap a secret data or key in KMIP JSON TTLV format. @@ -1283,7 +1310,7 @@ Locally wrap a secret data or key in KMIP JSON TTLV format. --- -## 7.1.5 ckms pqc keys unwrap +## 7.1.6 ckms pqc keys unwrap Locally unwrap a secret data or key in KMIP JSON TTLV format. @@ -1306,7 +1333,7 @@ Locally unwrap a secret data or key in KMIP JSON TTLV format. --- -## 7.1.6 ckms pqc keys revoke +## 7.1.7 ckms pqc keys revoke Revoke a PQC public or private key @@ -1324,7 +1351,7 @@ Revoke a PQC public or private key --- -## 7.1.7 ckms pqc keys destroy +## 7.1.8 ckms pqc keys destroy Destroy a PQC public or private key @@ -1757,17 +1784,19 @@ Create, destroy, import, and export elliptic curve key pairs **`create`** [[10.1.1]](#1011-ckms-ec-keys-create) Create an elliptic curve key pair -**`export`** [[10.1.2]](#1012-ckms-ec-keys-export) Export a key or secret data from the KMS +**`re-key`** [[10.1.2]](#1012-ckms-ec-keys-re-key) Refresh an existing Elliptic Curve private key (key rotation) + +**`export`** [[10.1.3]](#1013-ckms-ec-keys-export) Export a key or secret data from the KMS -**`import`** [[10.1.3]](#1013-ckms-ec-keys-import) Import a secret data or a key in the KMS. +**`import`** [[10.1.4]](#1014-ckms-ec-keys-import) Import a secret data or a key in the KMS. -**`wrap`** [[10.1.4]](#1014-ckms-ec-keys-wrap) Locally wrap a secret data or key in KMIP JSON TTLV format. +**`wrap`** [[10.1.5]](#1015-ckms-ec-keys-wrap) Locally wrap a secret data or key in KMIP JSON TTLV format. -**`unwrap`** [[10.1.5]](#1015-ckms-ec-keys-unwrap) Locally unwrap a secret data or key in KMIP JSON TTLV format. +**`unwrap`** [[10.1.6]](#1016-ckms-ec-keys-unwrap) Locally unwrap a secret data or key in KMIP JSON TTLV format. -**`revoke`** [[10.1.6]](#1016-ckms-ec-keys-revoke) Revoke a public or private key +**`revoke`** [[10.1.7]](#1017-ckms-ec-keys-revoke) Revoke a public or private key -**`destroy`** [[10.1.7]](#1017-ckms-ec-keys-destroy) Destroy a public or private key +**`destroy`** [[10.1.8]](#1018-ckms-ec-keys-destroy) Destroy a public or private key --- @@ -1798,11 +1827,30 @@ If the wrapping key is: - a RSA key, RSA-OAEP will be used - a EC key, ECIES will be used (salsa20poly1305 for X25519) +`--rotate-interval [-i] ` Auto-rotation interval in seconds. Set to 0 to disable. Example: 86400 for daily rotation, 604800 for weekly rotation + +`--rotate-name ` Optional name to identify the rotation policy lineage + +`--rotate-offset ` Delay in seconds before the first automatic rotation is triggered. Defaults to the rotation interval if not set + + + +--- + +## 10.1.2 ckms ec keys re-key + +Refresh an existing Elliptic Curve private key (key rotation) + +### Usage +`ckms ec keys re-key [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the EC private key to rotate + --- -## 10.1.2 ckms ec keys export +## 10.1.3 ckms ec keys export Export a key or secret data from the KMS @@ -1861,7 +1909,7 @@ Possible values: `"aes-key-wrap-padding", "nist-key-wrap", "aes-gcm", "rsa-pkcs --- -## 10.1.3 ckms ec keys import +## 10.1.4 ckms ec keys import Import a secret data or a key in the KMS. @@ -1909,7 +1957,7 @@ If the wrapping key is: --- -## 10.1.4 ckms ec keys wrap +## 10.1.5 ckms ec keys wrap Locally wrap a secret data or key in KMIP JSON TTLV format. @@ -1934,7 +1982,7 @@ Locally wrap a secret data or key in KMIP JSON TTLV format. --- -## 10.1.5 ckms ec keys unwrap +## 10.1.6 ckms ec keys unwrap Locally unwrap a secret data or key in KMIP JSON TTLV format. @@ -1957,7 +2005,7 @@ Locally unwrap a secret data or key in KMIP JSON TTLV format. --- -## 10.1.6 ckms ec keys revoke +## 10.1.7 ckms ec keys revoke Revoke a public or private key @@ -1975,7 +2023,7 @@ Revoke a public or private key --- -## 10.1.7 ckms ec keys destroy +## 10.1.8 ckms ec keys destroy Destroy a public or private key @@ -2670,17 +2718,19 @@ Create, destroy, import, and export RSA key pairs **`create`** [[19.1.1]](#1911-ckms-rsa-keys-create) Create a new RSA key pair -**`export`** [[19.1.2]](#1912-ckms-rsa-keys-export) Export a key or secret data from the KMS +**`re-key`** [[19.1.2]](#1912-ckms-rsa-keys-re-key) Refresh an existing RSA private key (key rotation) -**`import`** [[19.1.3]](#1913-ckms-rsa-keys-import) Import a secret data or a key in the KMS. +**`export`** [[19.1.3]](#1913-ckms-rsa-keys-export) Export a key or secret data from the KMS -**`wrap`** [[19.1.4]](#1914-ckms-rsa-keys-wrap) Locally wrap a secret data or key in KMIP JSON TTLV format. +**`import`** [[19.1.4]](#1914-ckms-rsa-keys-import) Import a secret data or a key in the KMS. -**`unwrap`** [[19.1.5]](#1915-ckms-rsa-keys-unwrap) Locally unwrap a secret data or key in KMIP JSON TTLV format. +**`wrap`** [[19.1.5]](#1915-ckms-rsa-keys-wrap) Locally wrap a secret data or key in KMIP JSON TTLV format. -**`revoke`** [[19.1.6]](#1916-ckms-rsa-keys-revoke) Revoke a public or private key +**`unwrap`** [[19.1.6]](#1916-ckms-rsa-keys-unwrap) Locally unwrap a secret data or key in KMIP JSON TTLV format. -**`destroy`** [[19.1.7]](#1917-ckms-rsa-keys-destroy) Destroy a public or private key +**`revoke`** [[19.1.7]](#1917-ckms-rsa-keys-revoke) Revoke a public or private key + +**`destroy`** [[19.1.8]](#1918-ckms-rsa-keys-destroy) Destroy a public or private key --- @@ -2709,11 +2759,30 @@ If the wrapping key is: - a RSA key, RSA-OAEP will be used - a EC key, ECIES will be used (salsa20poly1305 for X25519) +`--rotate-interval [-i] ` Auto-rotation interval in seconds. Set to 0 to disable. Example: 86400 for daily rotation, 604800 for weekly rotation + +`--rotate-name ` Optional name to identify the rotation policy lineage + +`--rotate-offset ` Delay in seconds before the first automatic rotation is triggered. Defaults to the rotation interval if not set + --- -## 19.1.2 ckms rsa keys export +## 19.1.2 ckms rsa keys re-key + +Refresh an existing RSA private key (key rotation) + +### Usage +`ckms rsa keys re-key [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the RSA private key to rotate + + + +--- + +## 19.1.3 ckms rsa keys export Export a key or secret data from the KMS @@ -2772,7 +2841,7 @@ Possible values: `"aes-key-wrap-padding", "nist-key-wrap", "aes-gcm", "rsa-pkcs --- -## 19.1.3 ckms rsa keys import +## 19.1.4 ckms rsa keys import Import a secret data or a key in the KMS. @@ -2820,7 +2889,7 @@ If the wrapping key is: --- -## 19.1.4 ckms rsa keys wrap +## 19.1.5 ckms rsa keys wrap Locally wrap a secret data or key in KMIP JSON TTLV format. @@ -2845,7 +2914,7 @@ Locally wrap a secret data or key in KMIP JSON TTLV format. --- -## 19.1.5 ckms rsa keys unwrap +## 19.1.6 ckms rsa keys unwrap Locally unwrap a secret data or key in KMIP JSON TTLV format. @@ -2868,7 +2937,7 @@ Locally unwrap a secret data or key in KMIP JSON TTLV format. --- -## 19.1.6 ckms rsa keys revoke +## 19.1.7 ckms rsa keys revoke Revoke a public or private key @@ -2886,7 +2955,7 @@ Revoke a public or private key --- -## 19.1.7 ckms rsa keys destroy +## 19.1.8 ckms rsa keys destroy Destroy a public or private key @@ -3497,17 +3566,19 @@ Create, destroy, import, and export symmetric keys **`re-key`** [[22.1.2]](#2212-ckms-sym-keys-re-key) Refresh an existing symmetric key -**`export`** [[22.1.3]](#2213-ckms-sym-keys-export) Export a key or secret data from the KMS +**`set-rotation-policy`** [[22.1.3]](#2213-ckms-sym-keys-set-rotation-policy) Set the rotation policy for a symmetric key. + +**`export`** [[22.1.4]](#2214-ckms-sym-keys-export) Export a key or secret data from the KMS -**`import`** [[22.1.4]](#2214-ckms-sym-keys-import) Import a secret data or a key in the KMS. +**`import`** [[22.1.5]](#2215-ckms-sym-keys-import) Import a secret data or a key in the KMS. -**`wrap`** [[22.1.5]](#2215-ckms-sym-keys-wrap) Locally wrap a secret data or key in KMIP JSON TTLV format. +**`wrap`** [[22.1.6]](#2216-ckms-sym-keys-wrap) Locally wrap a secret data or key in KMIP JSON TTLV format. -**`unwrap`** [[22.1.6]](#2216-ckms-sym-keys-unwrap) Locally unwrap a secret data or key in KMIP JSON TTLV format. +**`unwrap`** [[22.1.7]](#2217-ckms-sym-keys-unwrap) Locally unwrap a secret data or key in KMIP JSON TTLV format. -**`revoke`** [[22.1.7]](#2217-ckms-sym-keys-revoke) Revoke a symmetric key +**`revoke`** [[22.1.8]](#2218-ckms-sym-keys-revoke) Revoke a symmetric key -**`destroy`** [[22.1.8]](#2218-ckms-sym-keys-destroy) Destroy a symmetric key +**`destroy`** [[22.1.9]](#2219-ckms-sym-keys-destroy) Destroy a symmetric key --- @@ -3542,6 +3613,12 @@ If the wrapping key is: - a RSA key, RSA-OAEP will be used - a EC key, ECIES will be used (salsa20poly1305 for X25519) +`--rotate-interval [-i] ` Auto-rotation interval in seconds. Set to 0 to disable. Example: 86400 for daily rotation, 604800 for weekly rotation + +`--rotate-name ` Optional name to identify the rotation policy lineage + +`--rotate-offset ` Delay in seconds before the first automatic rotation is triggered. Defaults to the rotation interval if not set + --- @@ -3559,7 +3636,26 @@ Refresh an existing symmetric key --- -## 22.1.3 ckms sym keys export +## 22.1.3 ckms sym keys set-rotation-policy + +Set the rotation policy for a symmetric key. + +### Usage +`ckms sym keys set-rotation-policy [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the key to configure + +`--interval [-i] ` Rotation interval in seconds. Set to 0 to disable auto-rotation. Example: 86400 for daily rotation, 604800 for weekly + +`--name [-n] ` The name used to track the rotation lineage (optional) + +`--offset ` Time offset in seconds from the creation date before the first rotation is triggered (optional). Defaults to the interval if not set + + + +--- + +## 22.1.4 ckms sym keys export Export a key or secret data from the KMS @@ -3618,7 +3714,7 @@ Possible values: `"aes-key-wrap-padding", "nist-key-wrap", "aes-gcm", "rsa-pkcs --- -## 22.1.4 ckms sym keys import +## 22.1.5 ckms sym keys import Import a secret data or a key in the KMS. @@ -3666,7 +3762,7 @@ If the wrapping key is: --- -## 22.1.5 ckms sym keys wrap +## 22.1.6 ckms sym keys wrap Locally wrap a secret data or key in KMIP JSON TTLV format. @@ -3691,7 +3787,7 @@ Locally wrap a secret data or key in KMIP JSON TTLV format. --- -## 22.1.6 ckms sym keys unwrap +## 22.1.7 ckms sym keys unwrap Locally unwrap a secret data or key in KMIP JSON TTLV format. @@ -3714,7 +3810,7 @@ Locally unwrap a secret data or key in KMIP JSON TTLV format. --- -## 22.1.7 ckms sym keys revoke +## 22.1.8 ckms sym keys revoke Revoke a symmetric key @@ -3732,7 +3828,7 @@ Revoke a symmetric key --- -## 22.1.8 ckms sym keys destroy +## 22.1.9 ckms sym keys destroy Destroy a symmetric key diff --git a/crate/cli/src/actions/kms/cover_crypt/keys/create_key_pair.rs b/crate/cli/src/actions/kms/cover_crypt/keys/create_key_pair.rs index d1fc647c37..12676b4312 100644 --- a/crate/cli/src/actions/kms/cover_crypt/keys/create_key_pair.rs +++ b/crate/cli/src/actions/kms/cover_crypt/keys/create_key_pair.rs @@ -8,7 +8,7 @@ use cosmian_kms_client::{ use cosmian_logger::debug; use crate::{ - actions::kms::console, + actions::kms::{console, shared::utils::apply_rotation_policy_if_set}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -72,6 +72,20 @@ pub struct CreateMasterKeyPairAction { verbatim_doc_comment )] pub(crate) wrapping_key_id: Option, + + /// Auto-rotation interval in seconds. Set to 0 to disable. + /// Example: 86400 for daily rotation, 604800 for weekly rotation. + #[clap(long = "rotate-interval", short = 'i', required = false)] + pub(crate) rotate_interval: Option, + + /// Optional name to identify the rotation policy lineage. + #[clap(long = "rotate-name", required = false)] + pub(crate) rotate_name: Option, + + /// Delay in seconds before the first automatic rotation is triggered. + /// Defaults to the rotation interval if not set. + #[clap(long = "rotate-offset", required = false)] + pub(crate) rotate_offset: Option, } impl CreateMasterKeyPairAction { @@ -101,6 +115,17 @@ impl CreateMasterKeyPairAction { &res.public_key_unique_identifier, ); stdout.write()?; + + // Apply rotation policy on the private key if any fields are set + apply_rotation_policy_if_set( + &kms_rest_client, + &res.private_key_unique_identifier.to_string(), + self.rotate_interval, + self.rotate_name.as_deref(), + self.rotate_offset, + ) + .await?; + Ok(( res.private_key_unique_identifier, res.public_key_unique_identifier, diff --git a/crate/cli/src/actions/kms/elliptic_curves/keys/create_key_pair.rs b/crate/cli/src/actions/kms/elliptic_curves/keys/create_key_pair.rs index b0056983b9..126a3073f7 100644 --- a/crate/cli/src/actions/kms/elliptic_curves/keys/create_key_pair.rs +++ b/crate/cli/src/actions/kms/elliptic_curves/keys/create_key_pair.rs @@ -6,7 +6,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::kms::console, + actions::kms::{console, shared::utils::apply_rotation_policy_if_set}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -54,6 +54,20 @@ pub struct CreateKeyPairAction { verbatim_doc_comment )] pub(crate) wrapping_key_id: Option, + + /// Auto-rotation interval in seconds. Set to 0 to disable. + /// Example: 86400 for daily rotation, 604800 for weekly rotation. + #[clap(long = "rotate-interval", short = 'i', required = false)] + pub(crate) rotate_interval: Option, + + /// Optional name to identify the rotation policy lineage. + #[clap(long = "rotate-name", required = false)] + pub(crate) rotate_name: Option, + + /// Delay in seconds before the first automatic rotation is triggered. + /// Defaults to the rotation interval if not set. + #[clap(long = "rotate-offset", required = false)] + pub(crate) rotate_offset: Option, } impl CreateKeyPairAction { @@ -90,6 +104,16 @@ impl CreateKeyPairAction { ); stdout.write()?; + // Apply rotation policy on the private key if any fields are set + apply_rotation_policy_if_set( + &kms_rest_client, + &private_key_unique_identifier.to_string(), + self.rotate_interval, + self.rotate_name.as_deref(), + self.rotate_offset, + ) + .await?; + Ok(( private_key_unique_identifier.clone(), public_key_unique_identifier.clone(), diff --git a/crate/cli/src/actions/kms/elliptic_curves/keys/mod.rs b/crate/cli/src/actions/kms/elliptic_curves/keys/mod.rs index 525be6f595..eef3283a72 100644 --- a/crate/cli/src/actions/kms/elliptic_curves/keys/mod.rs +++ b/crate/cli/src/actions/kms/elliptic_curves/keys/mod.rs @@ -2,7 +2,7 @@ use clap::Subcommand; use cosmian_kms_client::KmsClient; use self::{ - create_key_pair::CreateKeyPairAction, destroy_key::DestroyKeyAction, + create_key_pair::CreateKeyPairAction, destroy_key::DestroyKeyAction, rekey::ReKeyAction, revoke_key::RevokeKeyAction, }; use crate::{ @@ -15,12 +15,14 @@ use crate::{ pub(crate) mod create_key_pair; pub(crate) mod destroy_key; +pub(crate) mod rekey; pub(crate) mod revoke_key; /// Create, destroy, import, and export elliptic curve key pairs #[derive(Subcommand)] pub enum KeysCommands { Create(CreateKeyPairAction), + ReKey(ReKeyAction), Export(ExportSecretDataOrKeyAction), Import(ImportSecretDataOrKeyAction), Wrap(WrapSecretDataOrKeyAction), @@ -35,6 +37,9 @@ impl KeysCommands { Self::Create(action) => { action.run(kms_rest_client).await?; } + Self::ReKey(action) => { + action.run(kms_rest_client).await?; + } Self::Export(action) => { action.run(kms_rest_client).await?; } diff --git a/crate/cli/src/actions/kms/elliptic_curves/keys/rekey.rs b/crate/cli/src/actions/kms/elliptic_curves/keys/rekey.rs new file mode 100644 index 0000000000..098af01a2a --- /dev/null +++ b/crate/cli/src/actions/kms/elliptic_curves/keys/rekey.rs @@ -0,0 +1,42 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{kmip_operations::ReKey, kmip_types::UniqueIdentifier}, +}; + +use crate::{ + actions::kms::{console, labels::KEY_ID}, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; + +/// Refresh an existing Elliptic Curve private key (key rotation) +/// +/// Creates a new EC key pair with a fresh UUID, preserving the rotation +/// policy and linking the old key via `ReplacedObjectLink`. +#[derive(Parser)] +#[clap(verbatim_doc_comment)] +pub struct ReKeyAction { + /// The unique identifier of the EC private key to rotate. + #[clap(long = KEY_ID, short = 'k')] + pub(crate) key_id: String, +} + +impl ReKeyAction { + pub(crate) async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { + let rekey_key_request = ReKey { + unique_identifier: Some(UniqueIdentifier::TextString(self.key_id.clone())), + ..ReKey::default() + }; + let unique_identifier = kms_rest_client + .rekey(rekey_key_request) + .await + .with_context(|| "failed rekeying the EC key pair")? + .unique_identifier; + + let mut stdout = console::Stdout::new("The EC key pair was successfully refreshed."); + stdout.set_unique_identifier(&unique_identifier); + stdout.write()?; + + Ok(unique_identifier) + } +} diff --git a/crate/cli/src/actions/kms/pqc/keys/create_key_pair.rs b/crate/cli/src/actions/kms/pqc/keys/create_key_pair.rs index 4192be526a..b37bdac635 100644 --- a/crate/cli/src/actions/kms/pqc/keys/create_key_pair.rs +++ b/crate/cli/src/actions/kms/pqc/keys/create_key_pair.rs @@ -11,7 +11,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::kms::console, + actions::kms::{console, shared::utils::apply_rotation_policy_if_set}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -152,6 +152,20 @@ pub struct CreatePqcKeyPairAction { /// Sensitive: if set, the private key will not be exportable #[clap(long = "sensitive", default_value = "false")] pub(crate) sensitive: bool, + + /// Auto-rotation interval in seconds. Set to 0 to disable. + /// Example: 86400 for daily rotation, 604800 for weekly rotation. + #[clap(long = "rotate-interval", short = 'i', required = false)] + pub(crate) rotate_interval: Option, + + /// Optional name to identify the rotation policy lineage. + #[clap(long = "rotate-name", required = false)] + pub(crate) rotate_name: Option, + + /// Delay in seconds before the first automatic rotation is triggered. + /// Defaults to the rotation interval if not set. + #[clap(long = "rotate-offset", required = false)] + pub(crate) rotate_offset: Option, } impl CreatePqcKeyPairAction { @@ -192,6 +206,16 @@ impl CreatePqcKeyPairAction { ); stdout.write()?; + // Apply rotation policy on the private key if any fields are set + apply_rotation_policy_if_set( + &kms_rest_client, + &response.private_key_unique_identifier.to_string(), + self.rotate_interval, + self.rotate_name.as_deref(), + self.rotate_offset, + ) + .await?; + Ok(( response.private_key_unique_identifier, response.public_key_unique_identifier, diff --git a/crate/cli/src/actions/kms/pqc/keys/mod.rs b/crate/cli/src/actions/kms/pqc/keys/mod.rs index 92f7a96bec..60b4e83a70 100644 --- a/crate/cli/src/actions/kms/pqc/keys/mod.rs +++ b/crate/cli/src/actions/kms/pqc/keys/mod.rs @@ -2,7 +2,7 @@ use clap::Subcommand; use cosmian_kms_client::KmsClient; use self::{ - create_key_pair::CreatePqcKeyPairAction, destroy_key::DestroyKeyAction, + create_key_pair::CreatePqcKeyPairAction, destroy_key::DestroyKeyAction, rekey::ReKeyAction, revoke_key::RevokeKeyAction, }; use crate::{ @@ -15,12 +15,14 @@ use crate::{ pub(crate) mod create_key_pair; pub(crate) mod destroy_key; +pub(crate) mod rekey; pub(crate) mod revoke_key; /// Manage post-quantum keys (ML-KEM, ML-DSA) #[derive(Subcommand)] pub enum KeysCommands { Create(CreatePqcKeyPairAction), + ReKey(ReKeyAction), Export(ExportSecretDataOrKeyAction), Import(ImportSecretDataOrKeyAction), Wrap(WrapSecretDataOrKeyAction), @@ -35,6 +37,9 @@ impl KeysCommands { Self::Create(action) => { action.run(kms_rest_client).await?; } + Self::ReKey(action) => { + action.run(kms_rest_client).await?; + } Self::Export(action) => { action.run(kms_rest_client).await?; } diff --git a/crate/cli/src/actions/kms/pqc/keys/rekey.rs b/crate/cli/src/actions/kms/pqc/keys/rekey.rs new file mode 100644 index 0000000000..5a714f5ba6 --- /dev/null +++ b/crate/cli/src/actions/kms/pqc/keys/rekey.rs @@ -0,0 +1,42 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{kmip_operations::ReKey, kmip_types::UniqueIdentifier}, +}; + +use crate::{ + actions::kms::{console, labels::KEY_ID}, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; + +/// Refresh an existing post-quantum private key (key rotation) +/// +/// Creates a new PQC key pair with a fresh UUID, preserving the rotation +/// policy and linking the old key via `ReplacedObjectLink`. +#[derive(Parser)] +#[clap(verbatim_doc_comment)] +pub struct ReKeyAction { + /// The unique identifier of the PQC private key to rotate. + #[clap(long = KEY_ID, short = 'k')] + pub(crate) key_id: String, +} + +impl ReKeyAction { + pub(crate) async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { + let rekey_key_request = ReKey { + unique_identifier: Some(UniqueIdentifier::TextString(self.key_id.clone())), + ..ReKey::default() + }; + let unique_identifier = kms_rest_client + .rekey(rekey_key_request) + .await + .with_context(|| "failed rekeying the PQC key pair")? + .unique_identifier; + + let mut stdout = console::Stdout::new("The PQC key pair was successfully refreshed."); + stdout.set_unique_identifier(&unique_identifier); + stdout.write()?; + + Ok(unique_identifier) + } +} diff --git a/crate/cli/src/actions/kms/rsa/keys/create_key_pair.rs b/crate/cli/src/actions/kms/rsa/keys/create_key_pair.rs index 9f0d69919f..3fc614c6d0 100644 --- a/crate/cli/src/actions/kms/rsa/keys/create_key_pair.rs +++ b/crate/cli/src/actions/kms/rsa/keys/create_key_pair.rs @@ -5,7 +5,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::kms::console, + actions::kms::{console, shared::utils::apply_rotation_policy_if_set}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -55,6 +55,20 @@ pub struct CreateKeyPairAction { verbatim_doc_comment )] pub wrapping_key_id: Option, + + /// Auto-rotation interval in seconds. Set to 0 to disable. + /// Example: 86400 for daily rotation, 604800 for weekly rotation. + #[clap(long = "rotate-interval", short = 'i', required = false)] + pub rotate_interval: Option, + + /// Optional name to identify the rotation policy lineage. + #[clap(long = "rotate-name", required = false)] + pub rotate_name: Option, + + /// Delay in seconds before the first automatic rotation is triggered. + /// Defaults to the rotation interval if not set. + #[clap(long = "rotate-offset", required = false)] + pub rotate_offset: Option, } impl Default for CreateKeyPairAction { @@ -65,6 +79,9 @@ impl Default for CreateKeyPairAction { private_key_id: None, sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, } } } @@ -120,6 +137,16 @@ impl CreateKeyPairAction { ); stdout.write()?; + // Apply rotation policy on the private key if any fields are set + apply_rotation_policy_if_set( + &kms_rest_client, + &private_key_unique_identifier.to_string(), + self.rotate_interval, + self.rotate_name.as_deref(), + self.rotate_offset, + ) + .await?; + Ok(( private_key_unique_identifier.to_owned(), public_key_unique_identifier.to_owned(), diff --git a/crate/cli/src/actions/kms/rsa/keys/mod.rs b/crate/cli/src/actions/kms/rsa/keys/mod.rs index 626458704a..b0940063b8 100644 --- a/crate/cli/src/actions/kms/rsa/keys/mod.rs +++ b/crate/cli/src/actions/kms/rsa/keys/mod.rs @@ -2,7 +2,7 @@ use clap::Subcommand; use cosmian_kms_client::KmsClient; use self::{ - create_key_pair::CreateKeyPairAction, destroy_key::DestroyKeyAction, + create_key_pair::CreateKeyPairAction, destroy_key::DestroyKeyAction, rekey::ReKeyAction, revoke_key::RevokeKeyAction, }; use crate::{ @@ -15,12 +15,14 @@ use crate::{ pub mod create_key_pair; pub mod destroy_key; +pub mod rekey; pub mod revoke_key; /// Create, destroy, import, and export RSA key pairs #[derive(Subcommand)] pub enum KeysCommands { Create(CreateKeyPairAction), + ReKey(ReKeyAction), Export(ExportSecretDataOrKeyAction), Import(ImportSecretDataOrKeyAction), Wrap(WrapSecretDataOrKeyAction), @@ -50,6 +52,9 @@ impl KeysCommands { Self::Create(action) => { action.run(kms_rest_client).await?; } + Self::ReKey(action) => { + action.run(kms_rest_client).await?; + } Self::Export(action) => { action.run(kms_rest_client).await?; } diff --git a/crate/cli/src/actions/kms/rsa/keys/rekey.rs b/crate/cli/src/actions/kms/rsa/keys/rekey.rs new file mode 100644 index 0000000000..10c3f98aa6 --- /dev/null +++ b/crate/cli/src/actions/kms/rsa/keys/rekey.rs @@ -0,0 +1,42 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{kmip_operations::ReKey, kmip_types::UniqueIdentifier}, +}; + +use crate::{ + actions::kms::{console, labels::KEY_ID}, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; + +/// Refresh an existing RSA private key (key rotation) +/// +/// Creates a new RSA key pair with a fresh UUID, preserving the rotation +/// policy and linking the old key via `ReplacedObjectLink`. +#[derive(Parser)] +#[clap(verbatim_doc_comment)] +pub struct ReKeyAction { + /// The unique identifier of the RSA private key to rotate. + #[clap(long = KEY_ID, short = 'k')] + pub(crate) key_id: String, +} + +impl ReKeyAction { + pub(crate) async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { + let rekey_key_request = ReKey { + unique_identifier: Some(UniqueIdentifier::TextString(self.key_id.clone())), + ..ReKey::default() + }; + let unique_identifier = kms_rest_client + .rekey(rekey_key_request) + .await + .with_context(|| "failed rekeying the RSA key pair")? + .unique_identifier; + + let mut stdout = console::Stdout::new("The RSA key pair was successfully refreshed."); + stdout.set_unique_identifier(&unique_identifier); + stdout.write()?; + + Ok(unique_identifier) + } +} diff --git a/crate/cli/src/actions/kms/shared/utils/mod.rs b/crate/cli/src/actions/kms/shared/utils/mod.rs index 1c4d532a7f..ef2b2f98d5 100644 --- a/crate/cli/src/actions/kms/shared/utils/mod.rs +++ b/crate/cli/src/actions/kms/shared/utils/mod.rs @@ -1,5 +1,7 @@ pub(crate) use destroy_utils::destroy; pub(crate) use revoke_utils::revoke; +pub(crate) use rotation_policy_utils::apply_rotation_policy_if_set; mod destroy_utils; mod revoke_utils; +mod rotation_policy_utils; diff --git a/crate/cli/src/actions/kms/shared/utils/rotation_policy_utils.rs b/crate/cli/src/actions/kms/shared/utils/rotation_policy_utils.rs new file mode 100644 index 0000000000..5a213cccf1 --- /dev/null +++ b/crate/cli/src/actions/kms/shared/utils/rotation_policy_utils.rs @@ -0,0 +1,58 @@ +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{ + kmip_attributes::Attribute, kmip_operations::SetAttribute, kmip_types::UniqueIdentifier, + }, +}; + +use crate::error::result::{KmsCliResult, KmsCliResultHelper}; + +/// Apply rotation policy attributes to an existing object via `SetAttribute`. +/// +/// Calls the KMS server with up to three sequential `SetAttribute` requests for +/// `RotateInterval`, `RotateName`, and `RotateOffset`. Only attributes with a +/// `Some` value are sent. +pub(crate) async fn apply_rotation_policy_if_set( + kms_rest_client: &KmsClient, + uid: &str, + rotate_interval: Option, + rotate_name: Option<&str>, + rotate_offset: Option, +) -> KmsCliResult<()> { + let unique_identifier = UniqueIdentifier::TextString(uid.to_owned()); + + if let Some(interval) = rotate_interval { + let req = SetAttribute { + unique_identifier: Some(unique_identifier.clone()), + new_attribute: Attribute::RotateInterval(interval), + }; + kms_rest_client + .set_attribute(req) + .await + .with_context(|| "failed to set RotateInterval")?; + } + + if let Some(name) = rotate_name { + let req = SetAttribute { + unique_identifier: Some(unique_identifier.clone()), + new_attribute: Attribute::RotateName(name.to_owned()), + }; + kms_rest_client + .set_attribute(req) + .await + .with_context(|| "failed to set RotateName")?; + } + + if let Some(offset) = rotate_offset { + let req = SetAttribute { + unique_identifier: Some(unique_identifier.clone()), + new_attribute: Attribute::RotateOffset(offset), + }; + kms_rest_client + .set_attribute(req) + .await + .with_context(|| "failed to set RotateOffset")?; + } + + Ok(()) +} diff --git a/crate/cli/src/actions/kms/symmetric/keys/create_key.rs b/crate/cli/src/actions/kms/symmetric/keys/create_key.rs index 9d31b575cd..7b520969d9 100644 --- a/crate/cli/src/actions/kms/symmetric/keys/create_key.rs +++ b/crate/cli/src/actions/kms/symmetric/keys/create_key.rs @@ -14,7 +14,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::kms::console, + actions::kms::{console, shared::utils::apply_rotation_policy_if_set}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -77,6 +77,20 @@ pub struct CreateKeyAction { verbatim_doc_comment )] pub wrapping_key_id: Option, + + /// Auto-rotation interval in seconds. Set to 0 to disable. + /// Example: 86400 for daily rotation, 604800 for weekly rotation. + #[clap(long = "rotate-interval", short = 'i', required = false)] + pub rotate_interval: Option, + + /// Optional name to identify the rotation policy lineage. + #[clap(long = "rotate-name", required = false)] + pub rotate_name: Option, + + /// Delay in seconds before the first automatic rotation is triggered. + /// Defaults to the rotation interval if not set. + #[clap(long = "rotate-offset", required = false)] + pub rotate_offset: Option, } impl CreateKeyAction { @@ -143,6 +157,16 @@ impl CreateKeyAction { stdout.set_unique_identifier(&unique_identifier); stdout.write()?; + // Apply rotation policy if any fields are set + apply_rotation_policy_if_set( + &kms_rest_client, + &unique_identifier.to_string(), + self.rotate_interval, + self.rotate_name.as_deref(), + self.rotate_offset, + ) + .await?; + Ok(unique_identifier) } } diff --git a/crate/cli/src/actions/kms/symmetric/keys/mod.rs b/crate/cli/src/actions/kms/symmetric/keys/mod.rs index 8924d6a5fa..f2844b391e 100644 --- a/crate/cli/src/actions/kms/symmetric/keys/mod.rs +++ b/crate/cli/src/actions/kms/symmetric/keys/mod.rs @@ -3,7 +3,7 @@ use cosmian_kms_client::KmsClient; use self::{ create_key::CreateKeyAction, destroy_key::DestroyKeyAction, rekey::ReKeyAction, - revoke_key::RevokeKeyAction, + revoke_key::RevokeKeyAction, set_rotation_policy::SetRotationPolicyAction, }; use crate::{ actions::kms::shared::{ @@ -17,12 +17,14 @@ pub mod create_key; pub mod destroy_key; pub mod rekey; pub mod revoke_key; +pub mod set_rotation_policy; /// Create, destroy, import, and export symmetric keys #[derive(Subcommand)] pub enum KeysCommands { Create(CreateKeyAction), ReKey(ReKeyAction), + SetRotationPolicy(SetRotationPolicyAction), Export(ExportSecretDataOrKeyAction), Import(ImportSecretDataOrKeyAction), Wrap(WrapSecretDataOrKeyAction), @@ -40,6 +42,9 @@ impl KeysCommands { Self::ReKey(action) => { action.run(kms_rest_client).await?; } + Self::SetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } Self::Export(action) => { action.run(kms_rest_client).await?; } diff --git a/crate/cli/src/actions/kms/symmetric/keys/set_rotation_policy.rs b/crate/cli/src/actions/kms/symmetric/keys/set_rotation_policy.rs new file mode 100644 index 0000000000..bb5c7a4a9d --- /dev/null +++ b/crate/cli/src/actions/kms/symmetric/keys/set_rotation_policy.rs @@ -0,0 +1,101 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{ + kmip_attributes::Attribute, kmip_operations::SetAttribute, kmip_types::UniqueIdentifier, + }, +}; + +use crate::{ + actions::kms::{console, labels::KEY_ID}, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; + +/// Set the rotation policy for a symmetric key. +/// +/// Use this command to configure automated key rotation by setting the +/// rotation interval. Once the interval is set, the KMS background task +/// will automatically rotate the key when it is due. +/// +/// Setting `--interval` to 0 disables automatic rotation for the key. +#[derive(Parser)] +#[clap(verbatim_doc_comment)] +pub struct SetRotationPolicyAction { + /// The unique identifier of the key to configure. + #[clap(long = KEY_ID, short = 'k')] + pub(crate) key_id: String, + + /// Rotation interval in seconds. Set to 0 to disable auto-rotation. + /// Example: 86400 for daily rotation, 604800 for weekly. + #[clap(long, short = 'i')] + pub(crate) interval: Option, + + /// The name used to track the rotation lineage (optional). + #[clap(long, short = 'n')] + pub(crate) name: Option, + + /// Time offset in seconds from the creation date before the first rotation + /// is triggered (optional). Defaults to the interval if not set. + #[clap(long)] + pub(crate) offset: Option, +} + +impl SetRotationPolicyAction { + pub(crate) async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult<()> { + let uid = UniqueIdentifier::TextString(self.key_id.clone()); + let mut updated = vec![]; + + if let Some(interval) = self.interval { + let req = SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateInterval(interval), + }; + kms_rest_client + .set_attribute(req) + .await + .with_context(|| "failed to set RotateInterval")?; + updated.push(format!("interval={interval}s")); + } + + if let Some(ref name) = self.name { + let req = SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateName(name.clone()), + }; + kms_rest_client + .set_attribute(req) + .await + .with_context(|| "failed to set RotateName")?; + updated.push(format!("name={name}")); + } + + if let Some(offset) = self.offset { + let req = SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateOffset(offset), + }; + kms_rest_client + .set_attribute(req) + .await + .with_context(|| "failed to set RotateOffset")?; + updated.push(format!("offset={offset}s")); + } + + if updated.is_empty() { + let stdout = console::Stdout::new( + "No rotation policy attributes specified. Use --interval, --name, or --offset.", + ); + stdout.write()?; + } else { + let msg = format!( + "Rotation policy updated for key {}: {}", + self.key_id, + updated.join(", ") + ); + let stdout = console::Stdout::new(&msg); + stdout.write()?; + } + + Ok(()) + } +} diff --git a/crate/cli/src/tests/kms/cover_crypt/access_structure.rs b/crate/cli/src/tests/kms/cover_crypt/access_structure.rs index d66ce7c0fe..3fcd1bd807 100644 --- a/crate/cli/src/tests/kms/cover_crypt/access_structure.rs +++ b/crate/cli/src/tests/kms/cover_crypt/access_structure.rs @@ -38,6 +38,9 @@ async fn test_view_access_structure() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, } .run(ctx.get_owner_client()), ) @@ -94,6 +97,9 @@ async fn test_edit_access_structure() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, } .run(ctx.get_owner_client()), ) diff --git a/crate/cli/src/tests/kms/cover_crypt/encrypt_decrypt.rs b/crate/cli/src/tests/kms/cover_crypt/encrypt_decrypt.rs index 3422f35186..ab05c75e9d 100644 --- a/crate/cli/src/tests/kms/cover_crypt/encrypt_decrypt.rs +++ b/crate/cli/src/tests/kms/cover_crypt/encrypt_decrypt.rs @@ -33,6 +33,9 @@ async fn test_encrypt_decrypt_using_object_ids() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) @@ -137,6 +140,9 @@ async fn test_encrypt_decrypt_bulk_using_object_ids() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) @@ -277,6 +283,9 @@ async fn test_encrypt_decrypt_using_tags() -> KmsCliResult<()> { tags: vec![base_tag.clone()], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) @@ -422,6 +431,9 @@ async fn test_encrypt_decrypt_bulk_using_tags() -> KmsCliResult<()> { tags: vec![base_tag.clone()], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) diff --git a/crate/cli/src/tests/kms/cover_crypt/master_key_pair.rs b/crate/cli/src/tests/kms/cover_crypt/master_key_pair.rs index 502d604f1c..3a1bcf6216 100644 --- a/crate/cli/src/tests/kms/cover_crypt/master_key_pair.rs +++ b/crate/cli/src/tests/kms/cover_crypt/master_key_pair.rs @@ -16,6 +16,9 @@ pub(crate) async fn test_create_master_key_pair() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, } .run(ctx.get_owner_client()), ) diff --git a/crate/cli/src/tests/kms/cover_crypt/rekey.rs b/crate/cli/src/tests/kms/cover_crypt/rekey.rs index ed36e4abd9..c0b4e91c89 100644 --- a/crate/cli/src/tests/kms/cover_crypt/rekey.rs +++ b/crate/cli/src/tests/kms/cover_crypt/rekey.rs @@ -44,6 +44,9 @@ async fn test_rekey_error() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) @@ -187,6 +190,9 @@ async fn test_enc_dec_rekey() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) @@ -264,6 +270,9 @@ async fn test_rekey_prune() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) diff --git a/crate/cli/src/tests/kms/cover_crypt/user_decryption_keys.rs b/crate/cli/src/tests/kms/cover_crypt/user_decryption_keys.rs index 7b0c457476..272a1d2c58 100644 --- a/crate/cli/src/tests/kms/cover_crypt/user_decryption_keys.rs +++ b/crate/cli/src/tests/kms/cover_crypt/user_decryption_keys.rs @@ -20,6 +20,9 @@ pub(crate) async fn test_user_decryption_key() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) @@ -53,6 +56,9 @@ pub(crate) async fn test_user_decryption_key_error() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) diff --git a/crate/cli/src/tests/kms/elliptic_curve/create_key_pair.rs b/crate/cli/src/tests/kms/elliptic_curve/create_key_pair.rs index bcb6955e30..f547b8637a 100644 --- a/crate/cli/src/tests/kms/elliptic_curve/create_key_pair.rs +++ b/crate/cli/src/tests/kms/elliptic_curve/create_key_pair.rs @@ -16,6 +16,9 @@ pub(crate) async fn test_create_key_pair() -> KmsCliResult<()> { private_key_id: None, sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, } .run(ctx.get_owner_client()) .await?; diff --git a/crate/cli/src/tests/kms/pqc/mod.rs b/crate/cli/src/tests/kms/pqc/mod.rs index 4baecd8be8..6a5337aff3 100644 --- a/crate/cli/src/tests/kms/pqc/mod.rs +++ b/crate/cli/src/tests/kms/pqc/mod.rs @@ -19,6 +19,9 @@ async fn test_kem(ctx: &TestsContext, name: &str, algorithm: PqcAlgorithm) -> Km algorithm, tags: vec![name.to_owned()], sensitive: false, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, } .run(ctx.get_owner_client()), ) @@ -70,6 +73,9 @@ async fn test_sign_verify( algorithm, tags: vec![name.to_owned()], sensitive: false, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, } .run(ctx.get_owner_client()), ) diff --git a/crate/cli/src/tests/kms/shared/destroy.rs b/crate/cli/src/tests/kms/shared/destroy.rs index 801526c2ed..22720fa8eb 100644 --- a/crate/cli/src/tests/kms/shared/destroy.rs +++ b/crate/cli/src/tests/kms/shared/destroy.rs @@ -351,6 +351,9 @@ async fn test_destroy_cover_crypt() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(Box::pin(action.run(ctx.get_owner_client()))).await?; (key_ids.0.to_string(), key_ids.1.to_string()) @@ -432,6 +435,9 @@ async fn test_destroy_cover_crypt() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) @@ -513,6 +519,9 @@ async fn test_destroy_cover_crypt() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) diff --git a/crate/cli/src/tests/kms/shared/export.rs b/crate/cli/src/tests/kms/shared/export.rs index ccf3d3bf5c..dbcb90d18c 100644 --- a/crate/cli/src/tests/kms/shared/export.rs +++ b/crate/cli/src/tests/kms/shared/export.rs @@ -290,6 +290,9 @@ pub(crate) async fn test_export_covercrypt() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) @@ -359,6 +362,9 @@ pub(crate) async fn test_export_error_cover_crypt() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) @@ -633,6 +639,9 @@ pub(crate) async fn test_sensitive_covercrypt_key() -> KmsCliResult<()> { tags: vec![], sensitive: true, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) diff --git a/crate/cli/src/tests/kms/shared/import.rs b/crate/cli/src/tests/kms/shared/import.rs index 0dd9bc24d1..3950c8e6d8 100644 --- a/crate/cli/src/tests/kms/shared/import.rs +++ b/crate/cli/src/tests/kms/shared/import.rs @@ -37,6 +37,9 @@ pub(crate) async fn test_import_cover_crypt() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0.to_string(), key_ids.1.to_string()) @@ -95,6 +98,9 @@ pub(crate) async fn test_generate_export_import() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0, key_ids.1) diff --git a/crate/cli/src/tests/kms/shared/import_export_wrapping.rs b/crate/cli/src/tests/kms/shared/import_export_wrapping.rs index 5cf3e43ea3..4f7dc310d9 100644 --- a/crate/cli/src/tests/kms/shared/import_export_wrapping.rs +++ b/crate/cli/src/tests/kms/shared/import_export_wrapping.rs @@ -75,6 +75,9 @@ pub(crate) async fn test_import_export_wrap_rfc_5649() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0, key_ids.1) @@ -163,6 +166,9 @@ pub(crate) async fn test_import_export_wrap_ecies() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0, key_ids.1) diff --git a/crate/cli/src/tests/kms/shared/locate.rs b/crate/cli/src/tests/kms/shared/locate.rs index d1eea7ada9..e7078e09cf 100644 --- a/crate/cli/src/tests/kms/shared/locate.rs +++ b/crate/cli/src/tests/kms/shared/locate.rs @@ -48,6 +48,9 @@ pub(crate) async fn test_locate_cover_crypt() -> KmsCliResult<()> { tags: vec![base_tag.clone()], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0, key_ids.1) @@ -454,6 +457,9 @@ pub(crate) async fn test_locate_grant() -> KmsCliResult<()> { tags: vec![base_tag.clone()], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0, key_ids.1) diff --git a/crate/cli/src/tests/kms/shared/wrap_unwrap.rs b/crate/cli/src/tests/kms/shared/wrap_unwrap.rs index 492f9ec392..4b4cc8b38e 100644 --- a/crate/cli/src/tests/kms/shared/wrap_unwrap.rs +++ b/crate/cli/src/tests/kms/shared/wrap_unwrap.rs @@ -38,6 +38,9 @@ pub(crate) async fn test_password_wrap_import() -> KmsCliResult<()> { tags: vec![], sensitive: false, wrapping_key_id: None, + rotate_interval: None, + rotate_name: None, + rotate_offset: None, }; let key_ids = Box::pin(action.run(ctx.get_owner_client())).await?; (key_ids.0, key_ids.1) diff --git a/crate/cli/src/tests/kms/symmetric/rekey.rs b/crate/cli/src/tests/kms/symmetric/rekey.rs index f1a24861c7..39de21f316 100644 --- a/crate/cli/src/tests/kms/symmetric/rekey.rs +++ b/crate/cli/src/tests/kms/symmetric/rekey.rs @@ -1,15 +1,39 @@ -use cosmian_kms_client::read_object_from_json_ttlv_file; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{ + kmip_operations::GetAttributes, + kmip_types::{LinkType, UniqueIdentifier}, + }, + read_object_from_json_ttlv_file, +}; use tempfile::TempDir; use test_kms_server::start_default_test_kms_server; use crate::{ actions::kms::{ shared::ExportSecretDataOrKeyAction, - symmetric::keys::{create_key::CreateKeyAction, rekey::ReKeyAction}, + symmetric::keys::{ + create_key::CreateKeyAction, rekey::ReKeyAction, + set_rotation_policy::SetRotationPolicyAction, + }, }, error::result::KmsCliResult, }; +/// Fetch all attributes for `uid` via the KMIP `GetAttributes` HTTP endpoint. +async fn get_all_attrs_cli( + client: &KmsClient, + uid: &str, +) -> KmsCliResult { + Ok(client + .get_attributes(GetAttributes { + unique_identifier: Some(UniqueIdentifier::TextString(uid.to_owned())), + attribute_reference: None, + }) + .await? + .attributes) +} + const AES_KEY_SIZE: usize = 256; #[tokio::test] @@ -37,19 +61,19 @@ pub(crate) async fn test_rekey_symmetric_key() -> KmsCliResult<()> { .run(ctx.get_owner_client()) .await?; - // and refresh it + // and rekey it — must produce a NEW unique identifier let id_2 = ReKeyAction { key_id: id.to_string(), } .run(ctx.get_owner_client()) .await?; - assert_eq!(id, id_2); + assert_ne!(id, id_2, "rekey must produce a new unique identifier"); - // Export as default (JsonTTLV with Raw Key Format Type) + // Export the NEW key (id_2) to compare material ExportSecretDataOrKeyAction { key_file: tmp_path.join("aes_sym_2"), - key_id: Some(id.to_string()), + key_id: Some(id_2.to_string()), ..Default::default() } .run(ctx.get_owner_client()) @@ -63,8 +87,7 @@ pub(crate) async fn test_rekey_symmetric_key() -> KmsCliResult<()> { new_object.key_block()?.key_bytes()? ); - // Compare the attributes - assert!(old_object.attributes()? == new_object.attributes()?); + // Cryptographic parameters must be preserved assert_eq!( new_object.attributes()?.cryptographic_length.unwrap(), i32::try_from(AES_KEY_SIZE).unwrap() @@ -72,3 +95,68 @@ pub(crate) async fn test_rekey_symmetric_key() -> KmsCliResult<()> { Ok(()) } + +/// Test that after a manual rekey (`re-key`) the KMIP link chain is set up correctly: +/// - old key carries `ReplacementObjectLink` → new key UID +/// - new key carries `ReplacedObjectLink` → old key UID +/// +/// We first configure a rotation policy on the key to confirm that manual rekey +/// behaves correctly regardless of any pre-existing rotation policy. +#[tokio::test] +async fn test_rekey_sets_link_chain_after_rotation_policy() -> KmsCliResult<()> { + let ctx = start_default_test_kms_server().await; + let client = ctx.get_owner_client(); + + // Create AES-256 key. + let old_id = CreateKeyAction { + number_of_bits: Some(AES_KEY_SIZE), + ..Default::default() + } + .run(client.clone()) + .await? + .to_string(); + + // Arm the key with a rotation policy so the rekey happens with a policy in place. + SetRotationPolicyAction { + key_id: old_id.clone(), + interval: Some(3600), + name: Some("hourly".to_owned()), + offset: None, + } + .run(client.clone()) + .await?; + + // Perform a manual rekey. + let new_id = ReKeyAction { + key_id: old_id.clone(), + } + .run(client.clone()) + .await? + .to_string(); + + assert_ne!(old_id, new_id, "rekey must produce a new unique identifier"); + + // Old key must have a ReplacementObjectLink pointing to the new key. + let old_attrs = get_all_attrs_cli(&client, &old_id).await?; + let replacement_link = old_attrs + .get_link(LinkType::ReplacementObjectLink) + .expect("old key must carry ReplacementObjectLink after manual rekey"); + assert_eq!( + replacement_link.to_string(), + new_id, + "ReplacementObjectLink on old key must point to the new key UID" + ); + + // New key must have a ReplacedObjectLink pointing back to the old key. + let new_attrs = get_all_attrs_cli(&client, &new_id).await?; + let replaced_link = new_attrs + .get_link(LinkType::ReplacedObjectLink) + .expect("new key must carry ReplacedObjectLink after manual rekey"); + assert_eq!( + replaced_link.to_string(), + old_id, + "ReplacedObjectLink on new key must point to the old key UID" + ); + + Ok(()) +} diff --git a/crate/client_utils/src/attributes_utils.rs b/crate/client_utils/src/attributes_utils.rs index 9ecbf8f148..9349b1747f 100644 --- a/crate/client_utils/src/attributes_utils.rs +++ b/crate/client_utils/src/attributes_utils.rs @@ -372,6 +372,22 @@ pub fn parse_selected_attributes_flatten( ); } } + "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()).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()).unwrap_or_default(), + ); + } + } "cryptographic_algorithm" => { if let Some(v) = attributes.cryptographic_algorithm.as_ref() { results.insert( @@ -479,6 +495,54 @@ pub fn parse_selected_attributes_flatten( ); } } + "rotate_interval" => { + if let Some(v) = attributes.rotate_interval.as_ref() { + results.insert( + selected_attribute_name.to_owned(), + serde_json::to_value(v).unwrap_or_default(), + ); + } + } + "rotate_name" => { + if let Some(v) = attributes.rotate_name.as_ref() { + results.insert( + selected_attribute_name.to_owned(), + serde_json::to_value(v).unwrap_or_default(), + ); + } + } + "rotate_offset" => { + if let Some(v) = attributes.rotate_offset.as_ref() { + results.insert( + selected_attribute_name.to_owned(), + serde_json::to_value(v).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()).unwrap_or_default(), + ); + } + } + "rotate_generation" => { + if let Some(v) = attributes.rotate_generation.as_ref() { + results.insert( + selected_attribute_name.to_owned(), + serde_json::to_value(v).unwrap_or_default(), + ); + } + } + "rotate_latest" => { + if let Some(v) = attributes.rotate_latest.as_ref() { + results.insert( + selected_attribute_name.to_owned(), + serde_json::to_value(v).unwrap_or_default(), + ); + } + } _x => {} } } @@ -554,6 +618,20 @@ pub fn build_selected_attribute( name_type: NameType::UninterpretedTextString, }), + "rotate_interval" => { + let interval = attribute_value + .parse::() + .map_err(|e| UtilsError::Default(e.to_string()))?; + Attribute::RotateInterval(interval) + } + "rotate_name" => Attribute::RotateName(attribute_value), + "rotate_offset" => { + let offset = attribute_value + .parse::() + .map_err(|e| UtilsError::Default(e.to_string()))?; + Attribute::RotateOffset(offset) + } + _ => { return Err(UtilsError::Default(format!( "Unknown attribute name: {attribute_name}" diff --git a/crate/client_utils/src/cover_crypt_utils.rs b/crate/client_utils/src/cover_crypt_utils.rs index a2d5e69197..ef48699365 100644 --- a/crate/client_utils/src/cover_crypt_utils.rs +++ b/crate/client_utils/src/cover_crypt_utils.rs @@ -3,10 +3,10 @@ use cosmian_kmip::{ kmip_2_1::{ kmip_attributes::Attributes, kmip_objects::ObjectType, - kmip_operations::{Create, CreateKeyPair}, + kmip_operations::{Create, CreateKeyPair, ReKeyKeyPair}, kmip_types::{ CryptographicAlgorithm, KeyFormatType, Link, LinkType, LinkedObjectIdentifier, - VendorAttribute, VendorAttributeValue, + UniqueIdentifier, VendorAttribute, VendorAttributeValue, }, }, time_normalize, @@ -99,3 +99,48 @@ pub fn build_create_covercrypt_usk_request Result { + // Serialize the action as JSON bytes, mirroring `RekeyEditAction::RekeyAccessPolicy` + // from `cosmian_kms_crypto::crypto::cover_crypt::attributes`. + let action_json = format!( + r#"{{"RekeyAccessPolicy":{}}}"#, + serde_json::json!(access_policy) + ); + let action_bytes = action_json.into_bytes(); + + let vendor_attribute = VendorAttribute { + vendor_identification: vendor_id.to_owned(), + attribute_name: VENDOR_ATTR_COVER_CRYPT_REKEY_ACTION.to_owned(), + attribute_value: VendorAttributeValue::ByteString(action_bytes), + }; + + let attributes = Attributes { + object_type: Some(ObjectType::PrivateKey), + cryptographic_algorithm: Some(CryptographicAlgorithm::CoverCrypt), + key_format_type: Some(KeyFormatType::CoverCryptSecretKey), + vendor_attributes: Some(vec![vendor_attribute]), + ..Attributes::default() + }; + + Ok(ReKeyKeyPair { + private_key_unique_identifier: Some(UniqueIdentifier::TextString(msk_uid.to_owned())), + private_key_attributes: Some(attributes), + ..ReKeyKeyPair::default() + }) +} diff --git a/crate/clients/ckms/src/tests/kms/symmetric/mod.rs b/crate/clients/ckms/src/tests/kms/symmetric/mod.rs index 25fa964bff..d228b37669 100644 --- a/crate/clients/ckms/src/tests/kms/symmetric/mod.rs +++ b/crate/clients/ckms/src/tests/kms/symmetric/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod create_key; pub(crate) mod encrypt_decrypt; pub(crate) mod rekey; +pub(crate) mod set_rotation_policy; pub(crate) const SUB_COMMAND: &str = "sym"; diff --git a/crate/clients/ckms/src/tests/kms/symmetric/rekey.rs b/crate/clients/ckms/src/tests/kms/symmetric/rekey.rs index eeba5e21f5..a04334c5e8 100644 --- a/crate/clients/ckms/src/tests/kms/symmetric/rekey.rs +++ b/crate/clients/ckms/src/tests/kms/symmetric/rekey.rs @@ -80,21 +80,21 @@ pub(crate) async fn test_rekey_symmetric_key() -> CosmianResult<()> { ..Default::default() })?; - // and refresh it + // and rekey it — must produce a NEW unique identifier let id_2 = rekey_symmetric_key(&owner_client_conf_path, &id)?; - assert_eq!(id, id_2); + assert_ne!(id, id_2, "rekey must produce a new unique identifier"); - // Export as default (JsonTTLV with Raw Key Format Type) + // Export new key (id_2) to compare material export_key(ExportKeyParams { cli_conf_path: owner_client_conf_path, sub_command: "sym".to_owned(), - key_id: id, + key_id: id_2, key_file: tmp_path.join("aes_sym_2").to_str().unwrap().to_owned(), ..Default::default() })?; - // Compare the symmetric key bytes + // Rekey must produce fresh key material let old_object = read_object_from_json_ttlv_file(&tmp_path.join("aes_sym"))?; let new_object = read_object_from_json_ttlv_file(&tmp_path.join("aes_sym_2"))?; assert_ne!( @@ -102,8 +102,7 @@ pub(crate) async fn test_rekey_symmetric_key() -> CosmianResult<()> { new_object.key_block()?.key_bytes()? ); - // Compare the attributes - assert!(old_object.attributes()? == new_object.attributes()?); + // Cryptographic parameters must be preserved assert_eq!( new_object.attributes()?.cryptographic_length.unwrap(), i32::try_from(AES_KEY_SIZE).unwrap() diff --git a/crate/clients/ckms/src/tests/kms/symmetric/set_rotation_policy.rs b/crate/clients/ckms/src/tests/kms/symmetric/set_rotation_policy.rs new file mode 100644 index 0000000000..e106b14b52 --- /dev/null +++ b/crate/clients/ckms/src/tests/kms/symmetric/set_rotation_policy.rs @@ -0,0 +1,147 @@ +use std::process::Command; + +use assert_cmd::prelude::*; +use cosmian_kms_cli::actions::kms::symmetric::keys::create_key::CreateKeyAction; +use test_kms_server::start_default_test_kms_server; + +use super::SUB_COMMAND; +use crate::{ + config::CKMS_CONF_ENV, + error::{CosmianError, result::CosmianResult}, + tests::{PROG_NAME, kms::symmetric::create_key::create_symmetric_key, save_kms_cli_config}, +}; + +/// Invoke `ckms sym keys set-rotation-policy` and return the stdout if +/// the command succeeds, or an error containing stderr. +fn set_rotation_policy_cmd( + cli_conf_path: &str, + key_id: &str, + extra_args: &[&str], +) -> CosmianResult { + let mut cmd = Command::cargo_bin(PROG_NAME)?; + cmd.env(CKMS_CONF_ENV, cli_conf_path); + cmd.env("RUST_MIN_STACK", "16777216"); + let mut args = vec!["keys", "set-rotation-policy", "--key-id", key_id]; + args.extend_from_slice(extra_args); + cmd.arg(SUB_COMMAND).args(args); + let output = cmd.output()?; + if output.status.success() { + return Ok(std::str::from_utf8(&output.stdout)?.to_owned()); + } + Err(CosmianError::Default( + std::str::from_utf8(&output.stderr)?.to_owned(), + )) +} + +#[tokio::test] +async fn test_set_rotation_policy_interval_and_name() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let (owner_client_conf_path, _) = save_kms_cli_config(ctx); + + let id = create_symmetric_key( + &owner_client_conf_path, + CreateKeyAction { + number_of_bits: Some(256), + ..Default::default() + }, + )?; + + let stdout = set_rotation_policy_cmd( + &owner_client_conf_path, + &id, + &["--interval", "3600", "--name", "daily"], + )?; + assert!( + stdout.contains("Rotation policy updated"), + "unexpected stdout: {stdout}" + ); + assert!( + stdout.contains("interval=3600s"), + "stdout must mention interval: {stdout}" + ); + assert!( + stdout.contains("name=daily"), + "stdout must mention name: {stdout}" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_set_rotation_policy_disable_with_zero() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let (owner_client_conf_path, _) = save_kms_cli_config(ctx); + + let id = create_symmetric_key( + &owner_client_conf_path, + CreateKeyAction { + number_of_bits: Some(256), + ..Default::default() + }, + )?; + + // Enable rotation first + let stdout = set_rotation_policy_cmd(&owner_client_conf_path, &id, &["--interval", "3600"])?; + assert!(stdout.contains("interval=3600s")); + + // Disable by setting interval to 0 + let stdout = set_rotation_policy_cmd(&owner_client_conf_path, &id, &["--interval", "0"])?; + assert!( + stdout.contains("interval=0s"), + "stdout must confirm interval=0: {stdout}" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_set_rotation_policy_no_args_prints_message() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let (owner_client_conf_path, _) = save_kms_cli_config(ctx); + + let id = create_symmetric_key( + &owner_client_conf_path, + CreateKeyAction { + number_of_bits: Some(256), + ..Default::default() + }, + )?; + + let stdout = set_rotation_policy_cmd(&owner_client_conf_path, &id, &[])?; + assert!( + stdout.contains("No rotation policy attributes specified"), + "expected hint message, got: {stdout}" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_set_rotation_policy_offset() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let (owner_client_conf_path, _) = save_kms_cli_config(ctx); + + let id = create_symmetric_key( + &owner_client_conf_path, + CreateKeyAction { + number_of_bits: Some(256), + ..Default::default() + }, + )?; + + let stdout = set_rotation_policy_cmd( + &owner_client_conf_path, + &id, + &["--interval", "7200", "--offset", "120"], + )?; + assert!( + stdout.contains("interval=7200s"), + "stdout must mention interval: {stdout}" + ); + assert!( + stdout.contains("offset=120s"), + "stdout must mention offset: {stdout}" + ); + + Ok(()) +} diff --git a/crate/interfaces/Cargo.toml b/crate/interfaces/Cargo.toml index 35fd0f3916..66593da4f3 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 7f27d27353..d0966f97f3 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}; @@ -102,4 +103,31 @@ 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![]) + } + + /// 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_offset` (if `rotate_date` is None and `rotate_offset` is set) + /// + /// 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/logger/Cargo.toml b/crate/logger/Cargo.toml new file mode 100644 index 0000000000..2fb9bbaff2 --- /dev/null +++ b/crate/logger/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "cosmian_logger" +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +description = "Logger helper — tracing, file rolling, OpenTelemetry, syslog" +readme = "README.md" +keywords = ["logging", "tracing", "opentelemetry", "syslog"] +categories = ["development-tools::debugging"] + +[features] +default = [] +full = [ + "dep:opentelemetry-otlp", + "dep:opentelemetry-semantic-conventions", + "dep:opentelemetry-stdout", + "dep:opentelemetry_sdk", + "dep:tracing-opentelemetry", + "dep:syslog-tracing", +] + +[lib] +name = "cosmian_logger" +path = "src/lib.rs" + +[dependencies] +# opentelemetry 0.29.x — pinned directly (workspace uses 0.27 for the server metrics layer) +opentelemetry = { version = "0.29", features = ["trace", "metrics"] } +opentelemetry-otlp = { version = "0.29", features = [ + "trace", + "metrics", + "grpc-tonic", +], optional = true } +opentelemetry-semantic-conventions = { version = "0.29", features = [ + "semconv_experimental", +], optional = true } +opentelemetry-stdout = { version = "0.29", features = [ + "trace", + "metrics", +], optional = true } +opentelemetry_sdk = { version = "0.29", features = ["trace"], optional = true } +syslog-tracing = { version = "0.3", optional = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tracing-appender = { version = "0.2" } +tracing-opentelemetry = { version = "0.30", optional = true } +tracing-subscriber = { workspace = true, features = [ + "env-filter", + "fmt", + "ansi", +] } + +[lints] +workspace = true diff --git a/crate/logger/README.md b/crate/logger/README.md new file mode 100644 index 0000000000..23eec44f14 --- /dev/null +++ b/crate/logger/README.md @@ -0,0 +1,223 @@ +# Cosmian Logger + +A flexible logging crate that supports both synchronous and asynchronous environments. + +## Features + +- `full`: Enables complete functionality including OpenTelemetry integration, syslog support, and advanced tracing features +- Without `full`: Provides basic tracing functionality for synchronous applications + +⚠️ **Important**: If you need `TelemetryConfig` or OpenTelemetry functionality, you must enable the `full` feature: + +```toml +[dependencies] +cosmian_logger = { version = "0.5.4", features = ["full"] } +``` + +## Usage + +### With Full Features + +For applications that need OpenTelemetry and advanced features: + +```toml +[dependencies] +cosmian_logger = { version = "0.5.4", features = ["full"] } +``` + +```rust +use cosmian_logger::{tracing_init, TelemetryConfig, TracingConfig}; + +#[tokio::main] +async fn main() { + let config = TracingConfig { + service_name: "my-service".to_string(), + otlp: Some(TelemetryConfig { + version: Some("1.0.0".to_string()), + environment: Some("production".to_string()), + otlp_url: "http://localhost:4317".to_string(), + enable_metering: true, + }), + no_log_to_stdout: false, + with_ansi_colors: true, + ..Default::default() + }; + + let _guard = tracing_init(&config); + + tracing::info!("Application started"); +} +``` + +### Without Full Features (Basic Mode) + +For synchronous applications that only need basic logging: + +```toml +[dependencies] +cosmian_logger = "0.5.4" +``` + +```rust +use cosmian_logger::{tracing_init, TracingConfig}; + +fn main() { + let config = TracingConfig { + service_name: "my-sync-service".to_string(), + no_log_to_stdout: false, + with_ansi_colors: true, + // Note: otlp field is not available without full feature + ..Default::default() + }; + + let _guard = tracing_init(&config); + + tracing::info!("Synchronous application started"); +} +``` + +## Logging Macros + +The crate provides logging macros that work with or without the full feature: + +```rust +use cosmian_logger::{info, debug, warn, error, trace}; + +// Function name is automatically prefixed to log messages +info!("Application initialized"); +debug!(user_id = 123, "Processing user request"); +warn!("Low memory warning"); +error!(error = %err, "Operation failed"); +``` + +## Features Summary + +- **Basic logging**: stdout, file, and structured logging support +- **OpenTelemetry** (requires full feature): OTLP tracing and metrics +- **Syslog support** (requires full feature): System log integration +- **Structured logging**: Multiple message patterns supported +- **ANSI colors**: Configurable for interactive vs persistent outputs + +A versatile logging and tracing utility for Rust applications that provides: + +- Structured logging to stdout +- Syslog integration +- OpenTelemetry support for distributed tracing +- Runtime configuration options + +## Installation + +Add the dependency to your `Cargo.toml`: + +```toml +[dependencies] +cosmian_logger = { path = "../path/to/crate/logger" } +``` + +## Basic Usage + +For simple applications, use the `log_init` function to set up logging: + +```rust +use cosmian_logger::log_init; +use tracing::{debug, info}; + +fn main() { + // Initialize with custom log level + log_init(Some("debug")); + + info!("This is an info message"); + debug!("This is a debug message"); +} +``` + +The `log_init` function accepts an optional log level string parameter: + +- When `None` is provided, it falls back to the `RUST_LOG` environment variable +- Log levels follow Rust's standard: trace, debug, info, warn, error + +## Advanced Configuration with OpenTelemetry + +For more advanced use cases with OpenTelemetry integration, enable the `full` feature: + +```toml +[dependencies] +cosmian_logger = { version = "0.5.4", features = ["full"] } +``` + +```rust +use cosmian_logger::{tracing_init, TelemetryConfig, TracingConfig}; +use tracing::span; +use tracing_core::Level; + +#[tokio::main] +async fn main() { + let config = TracingConfig { + service_name: "my_service".to_string(), + otlp: Some(TelemetryConfig { + version: Some("1.0.0".to_string()), + environment: Some("development".to_string()), + otlp_url: "http://localhost:4317".to_string(), + enable_metering: true, + }), + no_log_to_stdout: false, + #[cfg(not(target_os = "windows"))] + log_to_syslog: true, + rust_log: Some("debug".to_string()), + ..Default::default() + }; + + let _otel_guard = tracing_init(&config); + + // Create and enter a span for better tracing context + let span = span!(Level::TRACE, "application"); + let _span_guard = span.enter(); + + // Your application code here + tracing::info!("Application started"); +} +``` + +## OpenTelemetry Setup + +To use OpenTelemetry, start a collector like Jaeger: + +```bash +docker run -p16686:16686 -p4317:4317 -p4318:4318 \ +-e COLLECTOR_OTLP_ENABLED=true -e LOG_LEVEL=debug \ +jaegertracing/jaeger:2.5.0 +``` + +Then access the Jaeger UI at `http://localhost:16686` + +## Configuration Options + +The `TracingConfig` struct supports: + +- `service_name`: Name of your service for tracing +- `otlp`: OpenTelemetry configuration (only available with `full` feature) +- `no_log_to_stdout`: Disable logging to stdout +- `log_to_syslog`: Enable logging to system log (only available with `full` feature) +- `rust_log`: Log level configuration +- `with_ansi_colors`: Enable ANSI colors in output +- `log_to_file`: Optional file logging configuration + +## In Tests + +The `log_init` function is safe to use in tests: + +```rust +#[test] +fn test_something() { + cosmian_logger::log_init(Some("debug")); + // Your test code +} +``` + +## Re-exports + +The logger crate re-exports common tracing utilities: + +```rust +use cosmian_logger::reexport::{tracing, tracing_subscriber}; +``` diff --git a/crate/logger/src/error.rs b/crate/logger/src/error.rs new file mode 100644 index 0000000000..8e8a31437c --- /dev/null +++ b/crate/logger/src/error.rs @@ -0,0 +1,41 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum LoggerError { + #[error("OTLP error: {0}")] + Otlp(String), + + #[error("Parsing error: {0}")] + Parsing(String), + + #[error("Tracing subscriber error: {0}")] + TracingSubscriber(String), + + #[error("IO error: {0}")] + IOError(String), +} + +#[cfg(feature = "full")] +impl From for LoggerError { + fn from(e: opentelemetry_otlp::ExporterBuildError) -> Self { + Self::Otlp(e.to_string()) + } +} + +impl From for LoggerError { + fn from(e: tracing_subscriber::filter::ParseError) -> Self { + Self::Parsing(e.to_string()) + } +} + +impl From for LoggerError { + fn from(value: tracing_subscriber::util::TryInitError) -> Self { + Self::TracingSubscriber(value.to_string()) + } +} + +impl From for LoggerError { + fn from(e: std::ffi::NulError) -> Self { + Self::Parsing(e.to_string()) + } +} diff --git a/crate/logger/src/lib.rs b/crate/logger/src/lib.rs new file mode 100644 index 0000000000..434fb21294 --- /dev/null +++ b/crate/logger/src/lib.rs @@ -0,0 +1,46 @@ +//! # Cosmian Logger +//! +//! A flexible logging crate that supports both synchronous and asynchronous +//! environments. +//! +//! ## Features +//! +//! - `full`: Enables complete functionality including tokio/async support, +//! OpenTelemetry integration, and syslog support +//! - Without `full`: Provides basic tracing functionality for synchronous +//! applications +//! +//! ## Important Note +//! +//! If you need `TelemetryConfig` or full OpenTelemetry functionality, you must +//! enable the `full` feature: +//! +//! ```toml +//! [dependencies] +//! cosmian_logger = { version = "0.5.4", features = ["full"] } +//! ``` +//! +//! If you get an error like "no `TelemetryConfig` in the root", it means you +//! need to enable the full feature in your Cargo.toml dependency declaration. +mod error; +mod log_utils; +mod macros; +#[cfg(feature = "full")] +mod otlp; +mod tracing; + +pub use error::LoggerError; +pub use log_utils::log_init; +#[cfg(feature = "full")] +pub use tracing::TelemetryConfig; +pub use tracing::{LoggingGuards, TracingConfig, tracing_init}; + +/// Re-exported dependencies for use with the logging macros +/// +/// The logging macros (info!, debug!, warn!, error!, trace!) use these +/// re-exported tracing modules internally, so external crates don't need to add +/// tracing as a direct dependency. +pub mod reexport { + pub use tracing; + pub use tracing_subscriber; +} diff --git a/crate/logger/src/log_utils.rs b/crate/logger/src/log_utils.rs new file mode 100644 index 0000000000..178b367e49 --- /dev/null +++ b/crate/logger/src/log_utils.rs @@ -0,0 +1,46 @@ +use crate::{TracingConfig, tracing_init}; + +/// Initializing the stdout logger only. +/// (no open telemetry nor syslog) +/// +/// # Arguments +/// * `rust_log` - The log string to set for `RUST_LOG` +/// +/// # Notes +/// - calling `log_init(None`) is equivalent to calling +/// `log_init(option_env!("RUST_LOG"))` +/// - this function can be called from a `[tokio::test]` function, in contrast +/// to `tracing_init` +pub fn log_init(rust_log: Option<&str>) { + let config = TracingConfig { + #[cfg(feature = "full")] + otlp: None, + service_name: String::new(), + no_log_to_stdout: false, + log_to_file: None, + #[cfg(not(target_os = "windows"))] + log_to_syslog: false, + rust_log: rust_log + .or(option_env!("RUST_LOG")) + .map(std::borrow::ToOwned::to_owned), + with_ansi_colors: false, + }; + tracing_init(&config); +} + +#[cfg(test)] +mod tests { + use tracing::{debug, info, trace}; + + use super::*; + + #[test] + fn test_log_init() { + log_init(Some("debug")); + info!("This is an INFO test log message"); + debug!("This is a DEBUG test log message"); + debug!("RUST_LOG: {:?}", std::env::var("RUST_LOG")); + // The next message is a TRACING level and should be ignored + trace!("This is a TRACE test log message"); + } +} diff --git a/crate/logger/src/macros.rs b/crate/logger/src/macros.rs new file mode 100644 index 0000000000..ef12a480c4 --- /dev/null +++ b/crate/logger/src/macros.rs @@ -0,0 +1,253 @@ +/// Helper macro to extract function name from the call site +#[macro_export] +macro_rules! __get_fn_name { + () => {{ + let type_name = std::any::type_name_of_val(&|| {}); + let parts: Vec<&str> = type_name.split("::").collect(); + // Find the last element that is not "{{closure}}" + parts + .iter() + .rev() + .find(|&&part| part != "{{closure}}") + .unwrap_or(&"unknown") + .to_string() + }}; +} + +/// Macro to automatically add function name as prefix to info logs +/// Supports both simple format strings and structured logging with key-value +/// pairs +#[macro_export] +macro_rules! info { + (target: $target:expr, $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::info!(target: $target, "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + (target: $target:expr, $($field:ident = $value:expr,)+ $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::info!(target: $target, $($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + ($($field:ident = $value:expr),+ $(,)?; $($rest:tt)*) => { + $crate::reexport::tracing::info!($($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($($rest)*)) + }; + ($($field:ident = $value:expr,)+ $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::info!($($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + ($($arg:tt)*) => { + $crate::reexport::tracing::info!("[{}] {}", $crate::__get_fn_name!(), format!($($arg)*)) + }; +} + +/// Macro to automatically add function name as prefix to debug logs +/// Supports both simple format strings and structured logging with key-value +/// pairs +#[macro_export] +macro_rules! debug { + (target: $target:expr, $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::debug!(target: $target, "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + (target: $target:expr, $($field:ident = $value:expr,)+ $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::debug!(target: $target, $($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + ($($field:ident = $value:expr),+ $(,)?; $($rest:tt)*) => { + $crate::reexport::tracing::debug!($($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($($rest)*)) + }; + ($($field:ident = $value:expr,)+ $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::debug!($($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + ($($arg:tt)*) => { + $crate::reexport::tracing::debug!("[{}] {}", $crate::__get_fn_name!(), format!($($arg)*)) + }; +} + +/// Macro to automatically add function name as prefix to warn logs +/// Supports both simple format strings and structured logging with key-value +/// pairs +#[macro_export] +macro_rules! warn { + (target: $target:expr, $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::warn!(target: $target, "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + (target: $target:expr, $($field:ident = $value:expr,)+ $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::warn!(target: $target, $($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + ($($field:ident = $value:expr),+ $(,)?; $($rest:tt)*) => { + $crate::reexport::tracing::warn!($($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($($rest)*)) + }; + ($($field:ident = $value:expr,)+ $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::warn!($($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + ($($arg:tt)*) => { + $crate::reexport::tracing::warn!("[{}] {}", $crate::__get_fn_name!(), format!($($arg)*)) + }; +} + +/// Macro to automatically add function name as prefix to error logs +/// Supports both simple format strings and structured logging with key-value +/// pairs +#[macro_export] +macro_rules! error { + (target: $target:expr, $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::error!(target: $target, "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + (target: $target:expr, $($field:ident = $value:expr,)+ $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::error!(target: $target, $($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + ($($field:ident = $value:expr),+ $(,)?; $($rest:tt)*) => { + $crate::reexport::tracing::error!($($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($($rest)*)) + }; + ($($field:ident = $value:expr,)+ $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::error!($($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + ($($arg:tt)*) => { + $crate::reexport::tracing::error!("[{}] {}", $crate::__get_fn_name!(), format!($($arg)*)) + }; +} + +/// Macro to automatically add function name as prefix to trace logs +/// Supports both simple format strings and structured logging with key-value +/// pairs +#[macro_export] +macro_rules! trace { + (target: $target:expr, $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::trace!(target: $target, "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + (target: $target:expr, $($field:ident = $value:expr,)+ $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::trace!(target: $target, $($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + ($($field:ident = $value:expr),+ $(,)?; $($rest:tt)*) => { + $crate::reexport::tracing::trace!($($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($($rest)*)) + }; + ($($field:ident = $value:expr,)+ $fmt:literal $(, $($args:tt)*)?) => { + $crate::reexport::tracing::trace!($($field = $value,)+ "[{}] {}", $crate::__get_fn_name!(), format!($fmt $(, $($args)*)?)) + }; + ($($arg:tt)*) => { + $crate::reexport::tracing::trace!("[{}] {}", $crate::__get_fn_name!(), format!($($arg)*)) + }; +} + +#[cfg(test)] +#[allow(clippy::items_after_statements)] +mod macro_tests { + use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt}; + + #[derive(Debug)] + enum ObjectType { + Document, + } + + struct TestObject { + obj_type: ObjectType, + } + + impl TestObject { + fn object_type(&self) -> &ObjectType { + &self.obj_type + } + } + + fn init_test_logging() { + let _guard = tracing_subscriber::registry().with(fmt::layer()).try_init(); + } + + #[test] + fn test_structured_logging_macros() { + init_test_logging(); + + let uid = "test_uid"; + let owner = "test_owner"; + let object = TestObject { + obj_type: ObjectType::Document, + }; + + // Test the exact pattern from the user's request + info!( + uid = uid, + user = owner, + "Created Object of type {:?}", + &object.object_type() + ); + + // Test variations + debug!( + uid = uid, + user = owner, + "Debug message for object type {:?}", + &object.object_type() + ); + warn!( + user = owner, + "Warning about object type {:?}", + &object.object_type() + ); + error!( + uid = uid, + user = owner, + "Error related to object type {:?}", + &object.object_type() + ); + } + + #[test] + fn test_simple_logging_macros() { + init_test_logging(); + + info!("Simple info message"); + debug!("Simple debug message with arg: {}", 42); + warn!("Simple warning: {}", "test"); + } + + #[test] + fn test_target_with_structured_logging() { + init_test_logging(); + + let user = "test_user"; + + // Mock TTLV structure for testing + struct TestTag { + tag: String, + } + + impl TestTag { + fn as_str(&self) -> &str { + &self.tag + } + } + + struct TestTtlv { + tag: TestTag, + } + + let ttlv = TestTtlv { + tag: TestTag { + tag: "Create".to_owned(), + }, + }; + + // Test the exact pattern from the user's request + info!(target: "kmip", user = user, tag = ttlv.tag.as_str(), "POST /kmip/2_1. Request: {:?} {}", ttlv.tag.as_str(), user); + } + + #[test] + fn test_all_macros_with_target() { + init_test_logging(); + + let user = "admin"; + let action = "test_action"; + + // Test all macros with target support + info!(target: "auth", user = user, action = action, "Info: User {} performed {}", user, action); + debug!(target: "auth", user = user, action = action, "Debug: User {} performed {}", user, action); + warn!(target: "auth", user = user, action = action, "Warning: User {} performed {}", user, action); + error!(target: "auth", user = user, action = action, "Error: User {} performed {}", user, action); + trace!(target: "auth", user = user, action = action, "Trace: User {} performed {}", user, action); + } + + #[test] + fn test_target_with_simple_message() { + init_test_logging(); + + let e = "parse error: invalid format"; + + // Test the exact pattern from the user's request + error!(target: "kmip", "Failed to parse RequestMessage: {}", e); + } +} diff --git a/crate/logger/src/otlp.rs b/crate/logger/src/otlp.rs new file mode 100644 index 0000000000..b3c9492483 --- /dev/null +++ b/crate/logger/src/otlp.rs @@ -0,0 +1,101 @@ +use std::time::Duration; + +use opentelemetry::{KeyValue, global}; +use opentelemetry_otlp::WithExportConfig; +use opentelemetry_sdk::{ + Resource, + metrics::{MeterProviderBuilder, PeriodicReader, SdkMeterProvider}, + trace::{RandomIdGenerator, Sampler, SdkTracerProvider}, +}; +use opentelemetry_semantic_conventions::{ + SCHEMA_URL, + attribute::{DEPLOYMENT_ENVIRONMENT_NAME, SERVICE_NAME, SERVICE_VERSION}, +}; + +use crate::LoggerError; + +fn resource(service_name: &str, version: Option, environment: Option) -> Resource { + let mut attributes = vec![KeyValue::new(SERVICE_NAME, service_name.to_owned())]; + if let Some(version) = version { + attributes.push(KeyValue::new(SERVICE_VERSION, version)); + } + if let Some(environment) = environment { + attributes.push(KeyValue::new(DEPLOYMENT_ENVIRONMENT_NAME, environment)); + } + Resource::builder() + .with_service_name(service_name.to_owned()) + .with_schema_url(attributes, SCHEMA_URL) + .build() +} + +/// Internal function to initialize the OTLP tracer +/// that returns a Result with the `SdkTracerProvider` +pub(crate) fn init_tracer_provider( + service_name: &str, + url: &str, + version: Option, + environment: Option, +) -> Result { + let otlp_exporter = opentelemetry_otlp::SpanExporter::builder() + .with_tonic() + .with_endpoint(url.to_owned()) + .with_timeout(Duration::from_secs(3)) + .build() + .map_err(|e| { + LoggerError::Otlp(format!( + "Failed to create OTLP provider exporter. Make sure the endpoint is correct and \ + the server is running: {e}" + )) + })?; + + let tracer_provider = SdkTracerProvider::builder() + .with_batch_exporter(otlp_exporter) + .with_id_generator(RandomIdGenerator::default()) + .with_sampler(Sampler::AlwaysOn) + .with_resource(resource(service_name, version, environment)) + .with_max_events_per_span(64) + .with_max_attributes_per_span(16) + .build(); + + global::set_tracer_provider(tracer_provider.clone()); + + Ok(tracer_provider) +} + +// Construct MeterProvider for MetricsLayer +pub(crate) fn init_meter_provider( + service_name: &str, + url: &str, + version: Option, + environment: Option, +) -> Result { + let exporter = opentelemetry_otlp::MetricExporter::builder() + .with_tonic() + .with_temporality(opentelemetry_sdk::metrics::Temporality::default()) + .with_endpoint(url.to_owned()) + .build() + .map_err(|e| { + LoggerError::Otlp(format!( + "Failed to create OTLP meter exporter. Make sure the endpoint is correct and the \ + server is running: {e}" + )) + })?; + + let reader = PeriodicReader::builder(exporter) + .with_interval(Duration::from_secs(30)) + .build(); + + // For debugging in development + let stdout_reader = + PeriodicReader::builder(opentelemetry_stdout::MetricExporter::default()).build(); + + let meter_provider = MeterProviderBuilder::default() + .with_resource(resource(service_name, version, environment)) + .with_reader(reader) + .with_reader(stdout_reader) + .build(); + + global::set_meter_provider(meter_provider.clone()); + + Ok(meter_provider) +} diff --git a/crate/logger/src/tracing.rs b/crate/logger/src/tracing.rs new file mode 100644 index 0000000000..df3b43769b --- /dev/null +++ b/crate/logger/src/tracing.rs @@ -0,0 +1,410 @@ +use std::{ + path::PathBuf, + sync::atomic::{AtomicBool, Ordering}, +}; + +#[cfg(feature = "full")] +use opentelemetry::trace::TracerProvider; +#[cfg(feature = "full")] +use opentelemetry_sdk::{metrics::SdkMeterProvider, trace::SdkTracerProvider}; +#[cfg(feature = "full")] +use tracing::debug; +use tracing::{info, span, warn}; +#[cfg(feature = "full")] +use tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer}; +use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, reload, util::SubscriberInitExt}; + +use crate::LoggerError; +#[cfg(feature = "full")] +use crate::otlp; + +static TRACING_SET: AtomicBool = AtomicBool::new(false); + +// ============================================================================ +// Configuration Types +// ============================================================================ + +#[derive(Debug, Default, Clone)] +pub struct TracingConfig { + /// The Name of the service using this config + /// Only used by the OTLP collector and syslog + pub service_name: String, + + /// Use the OpenTelemetry provider + #[cfg(feature = "full")] + pub otlp: Option, + + /// Do not log to stdout + pub no_log_to_stdout: bool, + + #[cfg(not(target_os = "windows"))] + /// log to syslog + pub log_to_syslog: bool, + + /// If set, logs will be written to the specified directory (first argument) + /// using the specified file name (second argument): .YYYY-MM-DD. + /// It is a rolling file appender that creates a new log file every day. + pub log_to_file: Option<(PathBuf, String)>, + + /// Default `RUST_LOG` configuration. + /// If it is not set, the value of the environment variable `RUST_LOG` will + /// be used. + pub rust_log: Option, + + /// If true, the logs to stdout will be written with ANSI colors. + pub with_ansi_colors: bool, +} + +#[cfg(feature = "full")] +#[derive(Debug, Default, Clone)] +pub struct TelemetryConfig { + /// The version of the service using this config + pub version: Option, + + /// The name of the environment + /// (for instance, "production", "staging", "development") + pub environment: Option, + + /// The OTLP collector URL + /// (for instance, ) + pub otlp_url: String, + + /// Tracing is enabled by default. + /// This controls whether metering should also be enabled. + pub enable_metering: bool, +} + +// ============================================================================ +// Logging Guards and Cleanup +// ============================================================================ + +#[derive(Default)] +pub struct LoggingGuards { + #[cfg(feature = "full")] + tracer_provider: Option, + #[cfg(feature = "full")] + meter_provider: Option, + rolling_appender_guard: Option, +} + +impl Drop for LoggingGuards { + fn drop(&mut self) { + #[cfg(feature = "full")] + { + if let Some(tracer_provider) = &mut self.tracer_provider { + debug!("dropping OTLP tracer"); + if let Err(err) = tracer_provider.shutdown() { + eprintln!("Trace provider shutdown error: {err:?}"); + } + } + if let Some(meter_provider) = &mut self.meter_provider { + debug!("dropping OTLP meter"); + if let Err(_err) = meter_provider.shutdown() { + // ignore the error + } + } + } + } +} + +// ============================================================================ +// Public Interface +// ============================================================================ + +/// Initialize the telemetry system +/// +/// # Usage +/// +/// ```rust-ignore +/// use cosmian_logger::{telemetry_init, TelemetryConfig}; +/// use tracing::span; +/// use tracing_core::Level; +/// +/// #[tokio::main] +/// async fn main() { +/// +/// let tracing = TracingConfig { +/// service_name: "test".to_string(), +/// otlp: Some(TelemetryConfig { +/// version: Some( +/// option_env!("CARGO_PKG_VERSION") +/// .unwrap_or("1.0.0") +/// .to_string(), +/// ), +/// environment: Some("test".to_string()), +/// otlp_url: "http://localhost:4317".to_string(), +/// enable_metering: true, +/// }), +/// no_log_to_stdout: false, +/// #[cfg(not(target_os = "windows"))] +/// log_to_syslog: true, +/// rust_log: Some("trace".to_string()), +/// with_ansi_colors: false, +/// }; +/// let _otel_guard = tracing_init(&tracing); +/// +/// let span = span!(Level::TRACE, "application"); +/// let _span_guard = span.enter(); +/// +/// tracing::info!( +/// monotonic_counter.foo = 1_u64, +/// key_1 = "bar", +/// key_2 = 10, +/// "handle foo", +/// ); +/// +/// tracing::info!(histogram.baz = 10, "histogram example",); +/// +/// } +/// ``` +/// +/// # Note +/// The OTLP gRPC provider fails when the telemetry is initialized from a test +/// started with `#[tokio::test]`. The reason is currently unknown. Use +/// `log_init()` instead. +/// +/// # Arguments +/// * `telemetry` - The `TelemetryConfig` object containing the telemetry +/// configuration +/// +/// # Errors +/// Returns an error if there is an issue initializing the telemetry system. +pub fn tracing_init(tracing_config: &TracingConfig) -> LoggingGuards { + if TRACING_SET.swap(true, Ordering::Acquire) { + let span = span!(tracing::Level::INFO, "tracing_init"); + let _guard = span.enter(); + warn!("Tracing already initialized or crashed"); + return LoggingGuards::default(); + } + + match tracing_init_(tracing_config) { + Ok(otel_guard) => { + let span = span!(tracing::Level::INFO, "tracing_init"); + let _guard = span.enter(); + info!("Tracing initialized with config {tracing_config:#?}",); + otel_guard + } + Err(err) => { + TRACING_SET.store(false, Ordering::Release); + // If we cannot initialize the tracing system, we should not panic + eprintln!("Failed to initialize tracing: {err:?}"); + LoggingGuards::default() + } + } +} + +// ============================================================================ +// Internal Implementation +// ============================================================================ + +/// Configuration for fmt layer formatting options +#[derive(Clone, Copy)] +#[allow(clippy::struct_excessive_bools)] +struct FmtConfig { + with_level: bool, + with_target: bool, + with_thread_ids: bool, + with_line_number: bool, + with_file: bool, + with_ansi: bool, +} + +impl FmtConfig { + /// Standard fmt layer configuration with customizable ANSI colors + const fn standard(with_ansi: bool) -> Self { + Self { + with_level: true, + with_target: true, + with_thread_ids: true, + with_line_number: true, + with_file: true, + with_ansi, + } + } +} + +/// Macro to apply standard fmt layer configuration +macro_rules! configure_fmt_layer { + ($layer:expr, $config:expr) => {{ + $layer + .with_level($config.with_level) + .with_target($config.with_target) + .with_thread_ids($config.with_thread_ids) + .with_line_number($config.with_line_number) + .with_file($config.with_file) + .with_ansi($config.with_ansi) + }}; +} + +fn make_env_filter(rust_log: Option<&str>) -> EnvFilter { + rust_log.map_or_else(EnvFilter::from_default_env, |s| { + EnvFilter::try_new(s).unwrap_or_else(|_| EnvFilter::from_default_env()) + }) +} + +fn tracing_init_(config: &TracingConfig) -> Result { + let mut otel_guard = LoggingGuards::default(); + let mut layers = vec![]; + + // ======================================== + // Filter Configuration + // ======================================== + let filter = { + #[cfg(feature = "full")] + { + if config.otlp.is_some() { + // ======================================== + // OTLP Filter Configuration + // ======================================== + // To prevent a telemetry-induced-telemetry loop, OpenTelemetry's own internal + // logging is properly suppressed. However, logs emitted by external components + // (such as reqwest, tonic, etc.) are not suppressed as they do not propagate + // OpenTelemetry context. Until this issue is addressed + // (https://github.com/open-telemetry/opentelemetry-rust/issues/2877), + // filtering like this is the best way to suppress such logs. + // + // The filter levels are set as follows: + // - Allow `info` level and above by default. + // - Completely restrict logs from `hyper`, `tonic`, `h2`, and `reqwest`. + // + // Note: This filtering will also drop logs from these components even when + // they are used outside of the OTLP Exporter. + let (filter, _reload_handle) = reload::Layer::new( + make_env_filter(config.rust_log.as_deref()) + .add_directive("hyper=error".parse()?) + .add_directive("tonic=error".parse()?) + .add_directive("tower::buffer=off".parse()?) + .add_directive("opentelemetry-otlp=off".parse()?) + .add_directive("opentelemetry_sdk=error".parse()?) + // .add_directive("reqwest=off".parse()?) + .add_directive("h2=off".parse()?), + ); + filter + } else { + // If no OTLP URL is provided, we can use the default filter + let (filter, _reload_handle) = + reload::Layer::new(make_env_filter(config.rust_log.as_deref())); + filter + } + } + + #[cfg(not(feature = "full"))] + { + // ======================================== + // Standard Filter Configuration + // ======================================== + // If no OTLP URL is provided, we can use the default filter + let (filter, _reload_handle) = + reload::Layer::new(make_env_filter(config.rust_log.as_deref())); + filter + } + }; + + // ======================================== + // Stdout Logging Layer + // ======================================== + // Logging to stdout + if !config.no_log_to_stdout { + let fmt_layer = configure_fmt_layer!( + tracing_subscriber::fmt::layer(), + FmtConfig::standard(config.with_ansi_colors) + ) + .compact(); + layers.push(fmt_layer.boxed()); + } + + // ======================================== + // File Logging Layer + // ======================================== + // Logging the rolling file appender + if let Some((dir, name)) = &config.log_to_file { + // create the logs directory if it does not exist + if !dir.exists() { + std::fs::create_dir_all(dir).map_err(|err| { + LoggerError::IOError(format!( + "Failed to create logs directory: {}: {err:?}", + dir.display() + )) + })?; + } + + // Configure a daily rolling file appender + // Log files will be created in the "logs" directory + // with names like ".YYYY-MM-DD" + let file_appender = tracing_appender::rolling::daily(dir, name); + let (non_blocking_writer, guard) = tracing_appender::non_blocking(file_appender); + otel_guard.rolling_appender_guard = Some(guard); + + let fmt_layer = configure_fmt_layer!( + tracing_subscriber::fmt::layer().with_writer(non_blocking_writer), + FmtConfig::standard(false) // No ANSI colors for file logs + ) + .compact(); + layers.push(fmt_layer.boxed()); + } + + // ======================================== + // Syslog Logging Layer (Unix only) + // ======================================== + // Logging to syslog + #[cfg(all(not(target_os = "windows"), feature = "full"))] + if config.log_to_syslog { + let identity = + std::borrow::Cow::Owned(std::ffi::CString::new(config.service_name.clone())?); + let (options, facility) = Default::default(); + if let Some(syslog) = syslog_tracing::Syslog::new(identity, options, facility) { + let syslog_layer = configure_fmt_layer!( + tracing_subscriber::fmt::layer().with_writer(syslog), + FmtConfig::standard(false) // No ANSI colors for syslog + ); + layers.push(syslog_layer.boxed()); + } + } + + // ======================================== + // OpenTelemetry Logging Layer + // ======================================== + // Logging to the OpenTelemetry collector + #[cfg(feature = "full")] + if let Some(otlp_config) = &config.otlp { + // The OpenTelemetry tracing provider + let otlp_provider = otlp::init_tracer_provider( + &config.service_name, + &otlp_config.otlp_url, + otlp_config.version.clone(), + otlp_config.environment.clone(), + )?; + layers.push( + OpenTelemetryLayer::new(otlp_provider.tracer(config.service_name.clone())).boxed(), + ); + + let meter_provider = otlp_config + .enable_metering + .then(|| { + otlp::init_meter_provider( + &config.service_name, + &otlp_config.otlp_url, + otlp_config.version.clone(), + otlp_config.environment.clone(), + ) + .inspect(|meter_provider| { + layers.push(MetricsLayer::new(meter_provider.clone()).boxed()); + }) + }) + .transpose()?; + + otel_guard.tracer_provider = Some(otlp_provider); + otel_guard.meter_provider = meter_provider; + } + + // ======================================== + // Initialize Tracing Subscriber + // ======================================== + // Initialize the global tracing subscriber + tracing_subscriber::registry() + .with(filter) + .with(layers) + .try_init()?; + + Ok(otel_guard) +} diff --git a/crate/server/src/config/command_line/clap_config.rs b/crate/server/src/config/command_line/clap_config.rs index 06bcb6abed..625bd00866 100644 --- a/crate/server/src/config/command_line/clap_config.rs +++ b/crate/server/src/config/command_line/clap_config.rs @@ -69,6 +69,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, } } } @@ -202,6 +203,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 { @@ -641,6 +647,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 c0c756c056..741c3c95fa 100644 --- a/crate/server/src/config/params/server_params.rs +++ b/crate/server/src/config/params/server_params.rs @@ -140,6 +140,10 @@ pub struct ServerParams { pub kmip_policy: KmipPolicyParams, pub azure_ekm: AzureEkmConfig, + + /// Interval in seconds between background auto-rotation checks. + /// Set to 0 to disable auto-rotation. + pub auto_rotation_check_interval_secs: u64, } /// Represents the server parameters. @@ -357,6 +361,7 @@ impl ServerParams { }, }, azure_ekm: conf.azure_ekm_config, + auto_rotation_check_interval_secs: conf.auto_rotation_check_interval_secs, }; debug!("{res:#?}"); @@ -572,6 +577,10 @@ impl fmt::Debug for ServerParams { debug_struct.field("ui_index_html_folder", &self.ui_index_html_folder); debug_struct.field("ui_enable", &self.ui_enable); + debug_struct.field( + "auto_rotation_check_interval_secs", + &self.auto_rotation_check_interval_secs, + ); debug_struct.finish() } 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..57432ff8bf --- /dev/null +++ b/crate/server/src/core/operations/auto_rotate.rs @@ -0,0 +1,327 @@ +use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::{ + kmip_attributes::Attributes, + kmip_objects::ObjectType, + kmip_operations::{Certify, ReKey, ReKeyKeyPair}, + kmip_types::{LinkType, LinkedObjectIdentifier, UniqueIdentifier}, +}; +use cosmian_logger::{debug, warn}; +use time::OffsetDateTime; + +use crate::{ + core::{ + KMS, + operations::{certify::certify, rekey, rekey_keypair}, + }, + result::KResult, +}; + +/// Rotate a single managed object identified by `uid` on behalf of its owner. +/// +/// Dispatches based on object type: +/// - `SymmetricKey` → `ReKey` (new UID, KMIP §6.1.46) +/// - `PrivateKey` → `ReKeyKeyPair` (new UIDs for both private and public, KMIP §6.1.47) +/// - `PublicKey` → follows `PrivateKeyLink` to the private key and delegates to `ReKeyKeyPair` +/// - `Certificate` → `Certify` with the existing cert UID (upsert / re-sign, KMIP §4.48) +/// - All other types → skipped with a warning (not rotatable per KMIP spec) +/// +/// After a successful rotation this function emits an OpenTelemetry counter +/// `kms.key.auto_rotation` labelled with the detected algorithm. +pub(crate) async fn auto_rotate_key(kms: &KMS, uid: &str, owner: &str) -> KResult<()> { + // Retrieve the object to determine its type + let Some(owm) = kms.database.retrieve_object(uid).await? else { + warn!("object {uid} not found, skipping"); + return Ok(()); + }; + + let object_type = owm.object().object_type(); + let algorithm = owm + .attributes() + .cryptographic_algorithm + .map_or_else(|| "Unknown".to_owned(), |a| a.to_string()); + + debug!("rotating {object_type:?} uid={uid} algorithm={algorithm}"); + + match object_type { + ObjectType::SymmetricKey => { + // Capture the rotation policy BEFORE rekey (which sets rotate_interval=0 on the old key). + let old_rotate_interval = owm.attributes().rotate_interval; + let old_rotate_name = owm.attributes().rotate_name.clone(); + let old_rotate_offset = owm.attributes().rotate_offset; + + let request = ReKey { + unique_identifier: Some(UniqueIdentifier::TextString(uid.to_owned())), + ..ReKey::default() + }; + let response = Box::pin(rekey(kms, request, owner)).await?; + let new_uid = response.unique_identifier.to_string(); + debug!("symmetric key {uid} rotated → new uid={new_uid}"); + + // Transfer the rotation policy to the new key so it continues to + // auto-rotate at the same cadence. The old key already has + // rotate_interval = Some(0) written by rekey.rs Phase-2. + if let Some(new_owm) = kms.database.retrieve_object(&new_uid).await? { + let mut new_attrs = new_owm.attributes().clone(); + new_attrs.rotate_interval = old_rotate_interval; + new_attrs.rotate_name = old_rotate_name; + new_attrs.rotate_offset = old_rotate_offset; + new_attrs.initial_date = Some(OffsetDateTime::now_utc()); + let new_tags = kms.database.retrieve_tags(&new_uid).await?; + kms.database + .update_object(&new_uid, new_owm.object(), &new_attrs, Some(&new_tags)) + .await?; + } + } + + ObjectType::PrivateKey => { + // Capture the rotation policy BEFORE rekey (which sets rotate_interval=0 on the old key). + let old_rotate_interval = owm.attributes().rotate_interval; + let old_rotate_name = owm.attributes().rotate_name.clone(); + let old_rotate_offset = owm.attributes().rotate_offset; + + let request = ReKeyKeyPair { + private_key_unique_identifier: Some(UniqueIdentifier::TextString(uid.to_owned())), + private_key_attributes: Some(owm.attributes().to_owned()), + ..ReKeyKeyPair::default() + }; + let response = Box::pin(rekey_keypair(kms, request, owner, None)).await?; + let new_private_uid = response.private_key_unique_identifier.to_string(); + debug!("private key {uid} rotated → new uid={new_private_uid}"); + + // Transfer the rotation policy to the new private key. + if let Some(new_owm) = kms.database.retrieve_object(&new_private_uid).await? { + let mut new_attrs = new_owm.attributes().clone(); + new_attrs.rotate_interval = old_rotate_interval; + new_attrs.rotate_name = old_rotate_name; + new_attrs.rotate_offset = old_rotate_offset; + new_attrs.initial_date = Some(OffsetDateTime::now_utc()); + let new_tags = kms.database.retrieve_tags(&new_private_uid).await?; + kms.database + .update_object( + &new_private_uid, + new_owm.object(), + &new_attrs, + Some(&new_tags), + ) + .await?; + } + } + + ObjectType::PublicKey => { + // The rotation policy lives on the public key but the actual rotation + // operation requires the private key. Follow PrivateKeyLink to find it. + let Some(private_key_id) = owm.attributes().get_link(LinkType::PrivateKeyLink) else { + warn!( + "public key {uid} has no PrivateKeyLink — cannot rotate \ + without the associated private key, skipping" + ); + return Ok(()); + }; + let private_key_uid = private_key_id.to_string(); + + // Load the private key to pass its attributes to ReKeyKeyPair + let Some(private_owm) = kms.database.retrieve_object(&private_key_uid).await? else { + warn!( + "linked private key {private_key_uid} for public key {uid} \ + not found, skipping" + ); + return Ok(()); + }; + + let request = ReKeyKeyPair { + private_key_unique_identifier: Some(UniqueIdentifier::TextString( + private_key_uid.clone(), + )), + private_key_attributes: Some(private_owm.attributes().to_owned()), + ..ReKeyKeyPair::default() + }; + let response = Box::pin(rekey_keypair(kms, request, owner, None)).await?; + let new_private_uid = response.private_key_unique_identifier.to_string(); + debug!( + "key pair (public={uid} / private={private_key_uid}) rotated \ + → new private uid={new_private_uid}" + ); + } + + ObjectType::Certificate => { + // Renewal creates a completely NEW set of objects (new private key, public key, + // and certificate). The old objects are preserved as-is, with a + // ReplacementObjectLink pointing to their successors (KMIP 2.1 §4.48 semantics). + let existing_attrs = owm.attributes().clone(); + let new_generation = existing_attrs.rotate_generation.unwrap_or(0) + 1; + + // Collect the old private key UID — needed to look up the crypto algorithm. + let old_private_key_id = existing_attrs + .get_link(LinkType::PrivateKeyLink) + .map(|l| l.to_string()); + + // build_and_sign_certificate strips cryptographic_algorithm from cert attributes, + // so we must fetch it (plus key-size / curve) from the linked private key. + let Some(ref old_private_key_uid) = old_private_key_id else { + warn!( + "certificate {uid} has no PrivateKeyLink — cannot auto-renew \ + without the linked private key, skipping" + ); + return Ok(()); + }; + let Some(old_pk_owm) = kms.database.retrieve_object(old_private_key_uid).await? else { + warn!( + "certificate {uid}: linked private key {old_private_key_uid} not found, \ + cannot auto-renew, skipping" + ); + return Ok(()); + }; + let Some(cryptographic_algorithm) = old_pk_owm.attributes().cryptographic_algorithm + else { + warn!( + "certificate {uid}: linked private key {old_private_key_uid} has no \ + cryptographic_algorithm; cannot generate replacement key pair, skipping" + ); + return Ok(()); + }; + let cryptographic_length = old_pk_owm.attributes().cryptographic_length; + let cryptographic_parameters = old_pk_owm.attributes().cryptographic_parameters.clone(); + + // Build a Certify request that creates entirely new objects. + // Omitting `unique_identifier` causes get_subject to take the + // Subject::KeypairAndSubjectName path: fresh private key, public key, and + // certificate UIDs are all auto-assigned. + // The new cert inherits the rotation policy so it continues to auto-rotate + // at the same cadence; the old cert's interval is set to 0 below. + let certify_attrs = Attributes { + certificate_attributes: existing_attrs.certificate_attributes.clone(), + cryptographic_algorithm: Some(cryptographic_algorithm), + cryptographic_length, + cryptographic_parameters, + rotate_generation: Some(new_generation), + ..Attributes::default() + }; + let new_certify_request = Certify { + unique_identifier: None, + attributes: Some(certify_attrs), + ..Certify::default() + }; + let response = Box::pin(certify(kms, new_certify_request, owner, None)).await?; + let new_cert_uid = response.unique_identifier.to_string(); + + // ------------------------------------------------------------------ + // Add cross-links and stop auto-rotation on the old certificate. + // The old private/public keys are NOT given ReplacementObjectLink + // because only the certificate is the subject of the auto-rotate + // policy; the key pair is renewed as a side-effect of cert renewal + // and the old keys should not track the new ones via links. + // ------------------------------------------------------------------ + + // 1. Old cert: add ReplacementObjectLink, clear rotation policy. + let old_cert_tags = kms.database.retrieve_tags(uid).await?; + let mut old_cert_attrs = existing_attrs.clone(); + old_cert_attrs.set_link( + LinkType::ReplacementObjectLink, + LinkedObjectIdentifier::TextString(new_cert_uid.clone()), + ); + old_cert_attrs.rotate_interval = Some(0); + old_cert_attrs.rotate_date = None; + old_cert_attrs.initial_date = None; + kms.database + .update_object(uid, owm.object(), &old_cert_attrs, Some(&old_cert_tags)) + .await?; + + // 2. New cert: add ReplacedObjectLink back to the old cert and + // transfer the rotation policy so it continues auto-rotating at + // the same cadence. + if let Some(new_cert_owm) = kms.database.retrieve_object(&new_cert_uid).await? { + let mut new_cert_attrs = new_cert_owm.attributes().clone(); + new_cert_attrs.set_link( + LinkType::ReplacedObjectLink, + LinkedObjectIdentifier::TextString(uid.to_owned()), + ); + // Inherit rotation policy from the old cert. + new_cert_attrs.rotate_interval = existing_attrs.rotate_interval; + new_cert_attrs + .rotate_name + .clone_from(&existing_attrs.rotate_name); + new_cert_attrs.rotate_offset = existing_attrs.rotate_offset; + new_cert_attrs.initial_date = Some(OffsetDateTime::now_utc()); + let new_cert_tags = kms.database.retrieve_tags(&new_cert_uid).await?; + kms.database + .update_object( + &new_cert_uid, + new_cert_owm.object(), + &new_cert_attrs, + Some(&new_cert_tags), + ) + .await?; + } + + // 3. Old private/public keys: no ReplacementObjectLink is written. + // Only the certificate carries the auto-rotate policy; the key pair + // is renewed as a side-effect and the old keys should not track the + // new ones via links. + + debug!( + "certificate {uid} renewed → new cert uid={new_cert_uid} \ + (generation={new_generation})" + ); + } + + other => { + warn!( + "object {uid} has unsupported type {other:?} for \ + auto-rotation (KMIP defines rotation only for SymmetricKey, PrivateKey, \ + PublicKey, and Certificate), skipping" + ); + return Ok(()); + } + } + + // Emit OTel counter if metrics are configured + if let Some(ref metrics) = kms.metrics { + metrics.increment_auto_rotation_counter(&algorithm); + } + + Ok(()) +} + +/// Find all objects due for rotation and rotate them. +/// +/// Called periodically by the scheduler (see `crate::cron`). +/// Uses [`Database::find_due_for_rotation`] to find candidates, then calls +/// [`auto_rotate_key`] for each one. +pub(crate) async fn run_auto_rotation(kms: &KMS) { + let now = OffsetDateTime::now_utc(); + + let due_uids = match kms.database.find_due_for_rotation(now).await { + Ok(uids) => uids, + Err(e) => { + warn!("auto-rotation: failed to query due keys: {e}"); + return; + } + }; + + if due_uids.is_empty() { + return; + } + + debug!( + "auto-rotation: {} object(s) due for rotation", + due_uids.len() + ); + + for uid in &due_uids { + // Retrieve the owner of the object so we can rotate it on their behalf + let owner = match kms.database.retrieve_object(uid).await { + Ok(Some(owm)) => owm.owner().to_owned(), + Ok(None) => { + warn!("auto-rotation: object {uid} not found, skipping"); + continue; + } + Err(e) => { + warn!("auto-rotation: failed to retrieve object {uid}: {e}, skipping"); + continue; + } + }; + + if let Err(e) = Box::pin(auto_rotate_key(kms, uid, &owner)).await { + warn!("auto-rotation: failed to rotate object {uid}: {e}"); + } + } +} diff --git a/crate/server/src/core/operations/certify/mod.rs b/crate/server/src/core/operations/certify/mod.rs index ffb658381c..9fffa12eb7 100644 --- a/crate/server/src/core/operations/certify/mod.rs +++ b/crate/server/src/core/operations/certify/mod.rs @@ -43,6 +43,7 @@ use openssl::{ sha::Sha1, x509::{X509, X509Req}, }; +use time::OffsetDateTime; use crate::{ core::{ @@ -92,9 +93,22 @@ pub(crate) async fn certify( let (certificate, tags, attributes) = build_and_sign_certificate(kms.vendor_id(), &issuer, &subject, request)?; + // For certificate renewal (Subject::Certificate), build_and_sign_certificate strips + // PrivateKeyLink and PublicKeyLink from the attributes (to prevent issuer-key leakage). + // These links belong to the *subject* certificate and must be preserved across renewals; + // without them the next auto-rotation cycle fails with "No private or public key link found". + let cert_renewal_key_links = if let Subject::Certificate(_, _, stored_attrs) = &subject { + ( + stored_attrs.get_link(LinkType::PrivateKeyLink), + stored_attrs.get_link(LinkType::PublicKeyLink), + ) + } else { + (None, None) + }; + let (operations, unique_identifier) = match subject { - Subject::X509Req(unique_identifier, _) | Subject::Certificate(unique_identifier, _, _) => { - trace!("Certify X509Req or Certificate:{unique_identifier}"); + Subject::X509Req(unique_identifier, _) => { + trace!("Certify X509Req:{unique_identifier}"); ( vec![ // upsert the certificate @@ -109,6 +123,31 @@ pub(crate) async fn certify( unique_identifier, ) } + Subject::Certificate(unique_identifier, _, _) => { + trace!("Certify Certificate:{unique_identifier}"); + // Restore the subject certificate's key links that were stripped by + // build_and_sign_certificate. These are needed for future renewals. + let mut cert_attributes = attributes; + if let Some(link) = cert_renewal_key_links.0 { + cert_attributes.set_link(LinkType::PrivateKeyLink, link); + } + if let Some(link) = cert_renewal_key_links.1 { + cert_attributes.set_link(LinkType::PublicKeyLink, link); + } + ( + vec![ + // upsert the certificate + AtomicOperation::Upsert(( + unique_identifier.to_string(), + certificate, + cert_attributes, + Some(tags), + State::Active, + )), + ], + unique_identifier, + ) + } Subject::PublicKeyAndSubjectName(unique_identifier, from_public_key, _) => { trace!( "Certify PublicKeyAndSubjectName:{unique_identifier}: public key: \ @@ -199,7 +238,20 @@ pub(crate) async fn certify( AtomicOperation::Upsert(( keypair_data.private_key_id.to_string(), keypair_data.private_key_object.clone(), - keypair_data.private_key_object.attributes()?.clone(), + { + let mut a = keypair_data.private_key_object.attributes()?.clone(); + let now = OffsetDateTime::now_utc(); + if a.activation_date.is_none() { + a.activation_date = Some(now); + } + if a.initial_date.is_none() { + a.initial_date = Some(now); + } + if a.original_creation_date.is_none() { + a.original_creation_date = Some(now); + } + a + }, Some(keypair_data.private_key_tags), State::Active, )), @@ -207,7 +259,20 @@ pub(crate) async fn certify( AtomicOperation::Upsert(( keypair_data.public_key_id.to_string(), keypair_data.public_key_object.clone(), - keypair_data.public_key_object.attributes()?.clone(), + { + let mut a = keypair_data.public_key_object.attributes()?.clone(); + let now = OffsetDateTime::now_utc(); + if a.activation_date.is_none() { + a.activation_date = Some(now); + } + if a.initial_date.is_none() { + a.initial_date = Some(now); + } + if a.original_creation_date.is_none() { + a.original_creation_date = Some(now); + } + a + }, Some(keypair_data.public_key_tags), State::Active, )), @@ -527,8 +592,8 @@ async fn issuer_for_self_signed_certificate<'a>( ) } Subject::Certificate(unique_identifier, certificate, certificate_attributes) => { - // the user is renewing a self-signed certificate. See if we can find - // a linked private key + // The user is renewing a self-signed certificate. See if we can find + // a linked private key to re-sign with. let private_key = fetch_object_from_attributes( LinkType::PrivateKeyLink, kms, @@ -542,10 +607,19 @@ async fn issuer_for_self_signed_certificate<'a>( .to_owned(), ) })?; - Ok(Issuer::PrivateKeyAndCertificate( + // Use PrivateKeyAndSubjectName (not PrivateKeyAndCertificate) so that + // `issuer.not_after()` returns None. Using the old cert as the issuer would + // cap the renewed cert's NotAfter to the OLD cert's expiry, meaning each + // renewal only shrinks the validity window rather than extending it — the cert + // would approach its original expiry date and eventually fail to renew at all. + // With PrivateKeyAndSubjectName there is no cap, and build_and_sign_certificate + // issues a fresh N-day cert from the current date, as intended. + // The issuer name is set to the old cert's subject name so the renewed cert + // remains a valid self-signed cert (issuer == subject). + Ok(Issuer::PrivateKeyAndSubjectName( unique_identifier.clone(), kmip_private_key_to_openssl(private_key.object())?, - certificate.clone(), + certificate.subject_name(), )) } Subject::PublicKeyAndSubjectName(unique_identifier, public_key, subject_name) => { @@ -602,6 +676,15 @@ fn create_subject_key_identifier_value(subject: &Subject) -> KResult KResult { - trace!("ReKey: {}", serde_json::to_string(&request)?); + trace!("{}", serde_json::to_string(&request)?); if request.protection_storage_masks.is_some() { kms_bail!(KmsError::UnsupportedPlaceholder) @@ -31,7 +39,6 @@ pub(crate) async fn rekey(kms: &KMS, request: ReKey, owner: &str) -> KResult KResult = if key_for_material.is_wrapped() { + // Extract the wrapping specification from the current wrapping data so we can + // re-wrap the new material with the same wrapping key. + let wrap_spec = key_for_material + .key_block() + .ok() + .and_then(|kb| kb.key_wrapping_data.as_ref()) + .map(|kwd| 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, + }); + // Unwrap the object so we can generate fresh key material from the plaintext + unwrap_object(&mut key_for_material, kms, owner).await?; + wrap_spec + } else { + None + }; + + // Generate fresh symmetric key material with the same attributes + let mut new_key_attrs = owm.attributes().to_owned(); + // Clear fields that must not be inherited by the new key: + // - unique_identifier: let create_symmetric_key_and_tags assign a fresh one + // - key_format_type: `Raw` is a presentation format; the generator only accepts + // None or TransparentSymmetricKey. Clearing it lets the generator pick its default. + // - link: stale links from the source key would be embedded in new_object's key_block + // and would shadow the correct links stored in the metadata column at get_attributes time. + new_key_attrs.unique_identifier = None; + new_key_attrs.key_format_type = None; + new_key_attrs.link = None; + // Clear rotate_interval on the new-key template so the generator does not + // embed a stale interval in the key block; we set the final value explicitly + // on new_key_attrs after Create (see phase-1 attrs block below). + new_key_attrs.rotate_interval = None; + new_key_attrs.rotate_name = None; + new_key_attrs.rotate_offset = None; let create_request = Create { object_type: ObjectType::SymmetricKey, - attributes: owm.attributes().to_owned(), + attributes: new_key_attrs, protection_storage_masks: None, }; - let (_uid, new_object, _tags) = + let (_, mut new_object, new_tags) = KMS::create_symmetric_key_and_tags(kms.vendor_id(), &create_request)?; - // import new KMIP object into the database (but overwrite the existing one) - let import_request = Import { - unique_identifier: UniqueIdentifier::TextString(owm.id().to_owned()), - object_type: ObjectType::SymmetricKey, - replace_existing: Some(true), - key_wrap_type: None, - attributes: new_object.attributes()?.clone(), - object: new_object, - }; - let (uid, operations) = Box::pin(process_symmetric_key(kms, import_request, owner)).await?; + // Assign a fresh UID to the new key + let new_uid = Uuid::new_v4().to_string(); + + // §3 — Re-wrap the new key with the same wrapping key if the old key was wrapped + if let Some(spec) = rewrap_spec { + crate::core::wrapping::wrap_object(&mut new_object, &spec, kms, owner).await?; + } + + // Build attributes for the new key: add ReplacedObjectLink → old_uid, update rotation metadata + let mut new_key_attrs = new_object.attributes().cloned().unwrap_or_default(); + new_key_attrs.set_link( + LinkType::ReplacedObjectLink, + LinkedObjectIdentifier::TextString(old_uid.clone()), + ); + new_key_attrs.rotate_generation = Some(owm.attributes().rotate_generation.unwrap_or(0) + 1); + new_key_attrs.rotate_date = Some(time_normalize()?); + // Do not inherit the rotation policy (rotate_interval/name/offset) from + // the old key — the new key starts with auto-rotate disabled (interval=0). + // The user must explicitly re-arm auto-rotation on the new key if desired. + new_key_attrs.rotate_interval = Some(0); + new_key_attrs.rotate_name = None; + new_key_attrs.rotate_offset = None; + // Mark the new key as the latest in the rotation lineage (KMIP §4.51). + // The old key's rotate_latest will be cleared in phase 2 below. + new_key_attrs.rotate_latest = Some(true); + // Set unique identifier on the new key + new_key_attrs.unique_identifier = Some(UniqueIdentifier::TextString(new_uid.clone())); + new_key_attrs.object_type = Some(ObjectType::SymmetricKey); + // Propagate the state: the new key is Active + new_key_attrs.state = Some(State::Active); + + // Commit phase 1: persist the new key so it is accessible for re-wrapping dependants. + // We can't fold this into the final atomic batch because `wrap_object` looks up the new + // wrapping key by UID *from the live database* — if it isn't committed yet, the re-wrap + // would fail with "key not found". + kms.database + .atomic( + owner, + &[AtomicOperation::Create(( + new_uid.clone(), + new_object.clone(), + new_key_attrs.clone(), + new_tags, + ))], + ) + .await?; + + // Build attributes for the old key: add ReplacementObjectLink → new_uid, + // clear rotate_latest so only the newest key carries the flag, + // and set rotate_interval = 0 so the old key is not picked up by auto-rotation again. + let mut old_key_attrs = owm.attributes().to_owned(); + old_key_attrs.set_link( + LinkType::ReplacementObjectLink, + LinkedObjectIdentifier::TextString(new_uid.clone()), + ); + old_key_attrs.rotate_latest = Some(false); + old_key_attrs.rotate_interval = Some(0); + + // Phase 2: update the old key and re-wrap any dependants. + let mut operations: Vec = vec![AtomicOperation::UpdateObject(( + old_uid.clone(), + owm.object().clone(), + old_key_attrs, + None, // tags: None means don't change tags + ))]; + + // §2 — Rewrap all keys that were wrapped by the old key + 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 { + // Retrieve the full wrapped object + 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(); + + // Extract the current wrapping specification before unwrapping + 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 { + // Point to the NEW wrapping key + unique_identifier: UniqueIdentifier::TextString(new_uid.clone()), + 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.clone()), + cryptographic_parameters: m.cryptographic_parameters, + } + }), + attribute_name: None, + encoding_option: kwd.encoding_option, + }); + + if let Some(spec) = dep_wrap_spec { + // Unwrap using the OLD key (still accessible at old_uid at this point) + if let Err(e) = unwrap_object(&mut dep_object, kms, owner).await { + warn!("failed to unwrap dependant {dep_uid}: {e}, skipping"); + continue; + } + // Re-wrap with the NEW key + if let Err(e) = + crate::core::wrapping::wrap_object(&mut dep_object, &spec, kms, owner).await + { + warn!("failed to re-wrap dependant {dep_uid} with new key: {e}, skipping"); + continue; + } + // Update WrappingKeyLink on the dependant attributes + let mut updated_dep_attrs = dep_attrs; + updated_dep_attrs.set_link( + LinkType::WrappingKeyLink, + LinkedObjectIdentifier::TextString(new_uid.clone()), + ); + operations.push(AtomicOperation::UpdateObject(( + dep_uid.clone(), + dep_object, + updated_dep_attrs, + None, + ))); + } + } - // execute the operations + // Execute all operations atomically kms.database.atomic(owner, &operations).await?; - // return the uid - debug!("Re-key symmetric key with uid: {uid}"); + debug!("Re-keyed symmetric key: old uid={old_uid} → new uid={new_uid}"); return Ok(ReKeyResponse { - unique_identifier: UniqueIdentifier::TextString(uid), + unique_identifier: UniqueIdentifier::TextString(new_uid), }); } Err(KmsError::InvalidRequest(format!( - "rekey: get: too many symmetric keys for uid/tags: {uid_or_tags}", + "rekey: no active symmetric key found for uid/tags: {uid_or_tags}", ))) } diff --git a/crate/server/src/core/otel_metrics.rs b/crate/server/src/core/otel_metrics.rs index 19c2f36f19..3ae4b995f3 100644 --- a/crate/server/src/core/otel_metrics.rs +++ b/crate/server/src/core/otel_metrics.rs @@ -91,6 +91,9 @@ pub struct OtelMetrics { /// HSM operation counts (if HSM is enabled) pub hsm_operations_total: Counter, + + /// Auto-rotation events per algorithm + pub key_auto_rotation_total: Counter, } impl OtelMetrics { @@ -255,6 +258,13 @@ impl OtelMetrics { .with_unit("{operation}") .build(); + // Auto-rotation events + let key_auto_rotation_total = meter + .u64_counter("kms.key.auto_rotation") + .with_description("Total number of automatic key rotation events") + .with_unit("{rotation}") + .build(); + Ok(Self { meter, _meter_provider: meter_provider, @@ -278,6 +288,7 @@ impl OtelMetrics { active_keys_count_value: Arc::new(RwLock::new(0)), cache_operations_total, hsm_operations_total, + key_auto_rotation_total, }) } @@ -462,6 +473,11 @@ impl OtelMetrics { self.server_uptime_seconds.add(1, &[]); } + pub fn increment_auto_rotation_counter(&self, algorithm: &str) { + self.key_auto_rotation_total + .add(1, &[KeyValue::new("algorithm", algorithm.to_owned())]); + } + /// Get a reference to the meter for custom metrics #[must_use] pub const fn meter(&self) -> &Meter { diff --git a/crate/server/src/cron.rs b/crate/server/src/cron.rs index 017bdbf5e1..6b3260b993 100644 --- a/crate/server/src/cron.rs +++ b/crate/server/src/cron.rs @@ -7,7 +7,49 @@ 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::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; + } + _ = &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. @@ -34,6 +76,7 @@ pub fn spawn_metrics_cron(kms: Arc) -> oneshot::Sender<()> { rt.block_on(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); let mut uptime_interval = tokio::time::interval(std::time::Duration::from_secs(1)); + let mut shutdown_rx = shutdown_rx; loop { tokio::select! { diff --git a/crate/server/src/main.rs b/crate/server/src/main.rs index de53210ddb..fa224839a7 100644 --- a/crate/server/src/main.rs +++ b/crate/server/src/main.rs @@ -279,6 +279,7 @@ mod tests { default_unwrap_type: None, non_revocable_key_id: None, privileged_users: None, + auto_rotation_check_interval_secs: 0, print_default_config: false, }; diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index 15cdd83806..3159ddddcf 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -359,6 +359,14 @@ pub async fn start_kms_server( None }; + // Spawn background auto-rotation cron thread, independent of metrics. + // Only started when auto_rotation_check_interval_secs > 0. + 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) @@ -383,6 +391,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 diff --git a/crate/server/src/tests/mod.rs b/crate/server/src/tests/mod.rs index ff5de5865e..e262312ef0 100644 --- a/crate/server/src/tests/mod.rs +++ b/crate/server/src/tests/mod.rs @@ -19,6 +19,7 @@ mod locate; mod migrate; mod ms_dke; mod mtls_db; +mod rekey_rotation_tests; mod secret_data_tests; mod test_modify_attribute; pub(crate) mod test_set_attribute; diff --git a/crate/server/src/tests/rekey_rotation_tests.rs b/crate/server/src/tests/rekey_rotation_tests.rs new file mode 100644 index 0000000000..f8bbd3212b --- /dev/null +++ b/crate/server/src/tests/rekey_rotation_tests.rs @@ -0,0 +1,1217 @@ +//! Comprehensive tests for the KMIP symmetric key rotation / `ReKey` feature. +//! +//! Covered scenarios +//! ----------------- +//! 1. Basic rekey — fresh UID, new key material, old key unchanged. +//! 2. KMIP Link chain — `ReplacedObjectLink` on new key, `ReplacementObjectLink` on old key. +//! 3. Rotation metadata — `rotate_generation` increments, `rotate_date` is set. +//! 4. Rotation policy propagation — `rotate_interval`, `rotate_name`, `rotate_offset` copied. +//! 5. `rotate_latest` flag — new key gets true, old key gets false. +//! 6. Rekey a wrapped key — new key is re-wrapped with same wrapping key. +//! 7. Rekey a wrapping key — wrapped dependants are re-wrapped with the new key. +//! 8. Chained rekey — rotate twice, link chain grows correctly. +//! 9. Rekey non-existent key returns error. +//! 10. Auto-rotation `is_due_for_rotation` logic — due / not-due / disabled edge cases. +//! 11. `run_auto_rotation` rotates keys past their interval. +//! 12. `run_auto_rotation` does NOT rotate keys ahead of schedule. +//! 13. Set + Get rotation policy attributes via KMIP `SetAttribute`. +//! 14. Auto-rotation of a Certificate — creates new cert + key pair, links old → new via +//! `ReplacementObjectLink`/`ReplacedObjectLink`. Old objects are preserved unchanged. +//! 15. Auto-rotation of a `PublicKey` — follows `PrivateKeyLink`, graceful skip (RSA not yet supported). +//! 16. End-to-end cron: symmetric key is automatically rotated by the background cron thread. +//! 17. End-to-end cron: certificate auto-renewal creates new objects; old cert gains a +//! `ReplacementObjectLink` pointing to the new cert. +//! 18. Production scenario: cert has only `rotate_interval` set (no explicit `rotate_date`). +//! The cron must still auto-renew it using `initial_date`, which the `Certify` operation +//! must set so that `is_due_for_rotation` can compute the first rotation deadline. +//! 19. New cert DER bytes differ from old cert DER bytes; old cert is preserved unchanged; +//! `ReplacementObjectLink`/`ReplacedObjectLink` cross-links are correct. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stdout, + clippy::large_futures +)] + +use std::sync::Arc; + +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::KeyWrapType, + kmip_2_1::{ + extra::tagging::VENDOR_ID_COSMIAN, + kmip_attributes::Attributes, + kmip_data_structures::KeyWrappingSpecification, + kmip_objects::ObjectType, + kmip_operations::{Certify, Export, GetAttributes, ReKey, SetAttribute}, + kmip_types::{ + CertificateAttributes, CryptographicAlgorithm, EncryptionKeyInformation, LinkType, + UniqueIdentifier, WrappingMethod, + }, + requests::{create_rsa_key_pair_request, symmetric_key_create_request}, + }, +}; +use cosmian_logger::log_init; + +use crate::{ + config::ServerParams, + core::{KMS, operations::run_auto_rotation}, + result::KResult, + tests::test_utils::https_clap_config, +}; + +const USER: &str = "rotation_test_user@example.com"; + +/// Build a fresh KMS instance backed by an in-memory `SQLite` database. +async fn kms() -> Arc { + log_init(option_env!("RUST_LOG")); + let clap_config = https_clap_config(); + Arc::new( + KMS::instantiate(Arc::new( + ServerParams::try_from(clap_config).expect("ServerParams"), + )) + .await + .expect("KMS::instantiate"), + ) +} + +// ─── helpers ───────────────────────────────────────────────────────────────── + +async fn create_aes_256(kms: &Arc) -> KResult { + let request = symmetric_key_create_request( + kms.vendor_id(), + None, + 256, + CryptographicAlgorithm::AES, + Vec::::new(), + false, + None, + )?; + Ok(kms + .create(request, USER, None) + .await? + .unique_identifier + .to_string()) +} + +async fn do_rekey(kms: &Arc, uid: &str) -> KResult<(String, String)> { + let resp = kms + .rekey( + ReKey { + unique_identifier: Some(UniqueIdentifier::TextString(uid.to_owned())), + ..Default::default() + }, + USER, + ) + .await?; + Ok((uid.to_owned(), resp.unique_identifier.to_string())) +} + +async fn get_all_attrs( + kms: &Arc, + uid: &str, +) -> KResult< + cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attributes, +> { + Ok(kms + .get_attributes( + GetAttributes { + unique_identifier: Some(UniqueIdentifier::TextString(uid.to_owned())), + attribute_reference: None, + }, + USER, + ) + .await? + .attributes) +} + +/// Read attributes directly from the DB (bypasses `GetAttributes` filtering). +/// Use this helper to inspect rotation-specific fields (`rotate_generation`, +/// `rotate_interval`, etc.) that are stored as metadata but not mapped by +/// the KMIP `GetAttributes` operation. +async fn get_db_attrs( + kms: &Arc, + uid: &str, +) -> KResult< + cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attributes, +> { + Ok(kms + .database + .retrieve_object(uid) + .await? + .expect("object must exist in DB") + .attributes() + .to_owned()) +} + +async fn set_attr( + kms: &Arc, + uid: &str, + attribute: cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute, +) -> KResult<()> { + kms.set_attribute( + SetAttribute { + unique_identifier: Some(UniqueIdentifier::TextString(uid.to_owned())), + new_attribute: attribute, + }, + USER, + ) + .await?; + Ok(()) +} + +/// Export `data_uid` wrapped with `wrapping_uid` and store the blob under `dest_uid`. +async fn store_wrapped_copy( + kms: &Arc, + data_uid: &str, + wrapping_uid: &str, + dest_uid: &str, +) -> KResult<()> { + let resp = kms + .export( + Export { + unique_identifier: Some(UniqueIdentifier::TextString(data_uid.to_owned())), + key_format_type: None, + key_wrap_type: Some(KeyWrapType::AsRegistered), + key_compression_type: None, + key_wrapping_specification: Some(KeyWrappingSpecification { + wrapping_method: WrappingMethod::Encrypt, + encryption_key_information: Some(EncryptionKeyInformation { + unique_identifier: UniqueIdentifier::TextString(wrapping_uid.to_owned()), + cryptographic_parameters: None, + }), + mac_or_signature_key_information: None, + attribute_name: None, + encoding_option: None, + }), + }, + USER, + ) + .await?; + let obj = resp.object; + // Prefer embedded attributes; fall back to fetching the original key's DB attributes so + // the stored copy always has CryptographicAlgorithm / length (needed by rekey). + // Use the DB source (not GetAttributes) to avoid the `Raw` key_format_type normalization + // that GetAttributes applies, which would break create_symmetric_key_and_tags. + let mut attrs = match obj.attributes() { + Ok(a) => a.clone(), + Err(_) => get_db_attrs(kms, data_uid).await?, + }; + // The stored copy must be Active so the rekey operation can discover it. + attrs.state = Some( + cosmian_kms_server_database::reexport::cosmian_kmip::kmip_0::kmip_types::State::Active, + ); + kms.database + .create( + Some(dest_uid.to_owned()), + USER, + &obj, + &attrs, + &std::collections::HashSet::new(), + ) + .await?; + Ok(()) +} + +// ─── tests ──────────────────────────────────────────────────────────────────── + +// 1. Basic rekey +#[tokio::test] +async fn test_rekey_produces_new_uid_and_material() -> KResult<()> { + let kms = kms().await; + let old_uid = create_aes_256(&kms).await?; + let (_, new_uid) = do_rekey(&kms, &old_uid).await?; + assert_ne!(old_uid, new_uid); + let old_owm = kms + .database + .retrieve_object(&old_uid) + .await? + .expect("old key must still exist"); + let new_owm = kms + .database + .retrieve_object(&new_uid) + .await? + .expect("new key must exist"); + assert_ne!( + old_owm.object().key_block()?.key_bytes()?, + new_owm.object().key_block()?.key_bytes()? + ); + Ok(()) +} + +// 2. KMIP Link chain +#[tokio::test] +async fn test_rekey_link_chain() -> KResult<()> { + let kms = kms().await; + let old_uid = create_aes_256(&kms).await?; + let (_, new_uid) = do_rekey(&kms, &old_uid).await?; + let old_attrs = get_all_attrs(&kms, &old_uid).await?; + let new_attrs = get_all_attrs(&kms, &new_uid).await?; + assert_eq!( + old_attrs + .get_link(LinkType::ReplacementObjectLink) + .expect("must have ReplacementObjectLink") + .to_string(), + new_uid + ); + assert_eq!( + new_attrs + .get_link(LinkType::ReplacedObjectLink) + .expect("must have ReplacedObjectLink") + .to_string(), + old_uid + ); + Ok(()) +} + +// 3. Rotation metadata +#[tokio::test] +async fn test_rekey_rotation_metadata() -> KResult<()> { + let kms = kms().await; + let uid0 = create_aes_256(&kms).await?; + // time_normalize() truncates milliseconds; align `before` to the same granularity + let before = time::OffsetDateTime::now_utc() + .replace_millisecond(0) + .expect("replace_millisecond(0)"); + let (_, uid1) = do_rekey(&kms, &uid0).await?; + let attrs1 = get_db_attrs(&kms, &uid1).await?; + assert_eq!(attrs1.rotate_generation, Some(1)); + let d1 = attrs1.rotate_date.expect("rotate_date must be set"); + assert!(d1 >= before, "d1={d1} must be >= before={before}"); + let (_, uid2) = do_rekey(&kms, &uid1).await?; + let attrs2 = get_db_attrs(&kms, &uid2).await?; + assert_eq!(attrs2.rotate_generation, Some(2)); + assert!( + attrs2.rotate_date.expect("rotate_date must be set") >= d1, + "second rotate_date must be >= first" + ); + Ok(()) +} + +// 4. Rotation policy propagation on manual rekey: +// - old key gets rotate_interval = 0 (disabled) +// - new key does NOT inherit the policy (user must re-arm explicitly) +#[tokio::test] +async fn test_rekey_clears_old_and_does_not_propagate_policy() -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + let kms = kms().await; + let uid = create_aes_256(&kms).await?; + set_attr(&kms, &uid, Attribute::RotateInterval(3600)).await?; + set_attr(&kms, &uid, Attribute::RotateName("weekly".to_owned())).await?; + set_attr(&kms, &uid, Attribute::RotateOffset(60)).await?; + let (_, new_uid) = do_rekey(&kms, &uid).await?; + + // Old key: rotate_interval must be 0 so the cron does not pick it up again. + // Use get_all_attrs (KMIP GetAttributes): after the fix to get_attributes.rs, rotation + // policy fields are read from the metadata column, so this now returns the correct value. + let old_attrs = get_all_attrs(&kms, &uid).await?; + assert_eq!( + old_attrs.rotate_interval, + Some(0), + "old key rotate_interval must be 0 after manual rekey" + ); + + // New key: manual rekey does NOT inherit the rotation policy. + let new_attrs = get_db_attrs(&kms, &new_uid).await?; + assert_eq!( + new_attrs.rotate_interval, + Some(0), + "new key rotate_interval must be 0 after manual rekey (no auto-re-arm)" + ); + assert_eq!(new_attrs.rotate_name, None); + // rotate_offset is left as None on the new key (rekey.rs does not set it to Some(0)) + assert_eq!(new_attrs.rotate_offset, None); + Ok(()) +} + +// 5. rotate_latest flag: new key gets true, old key gets false +#[tokio::test] +async fn test_rekey_rotate_latest_flag() -> KResult<()> { + let kms = kms().await; + let uid0 = create_aes_256(&kms).await?; + let (_, uid1) = do_rekey(&kms, &uid0).await?; + + // After first rotation: uid1 is latest, uid0 is not. + assert_eq!(get_db_attrs(&kms, &uid1).await?.rotate_latest, Some(true)); + assert_eq!(get_db_attrs(&kms, &uid0).await?.rotate_latest, Some(false)); + + // After second rotation: uid2 is latest, uid1 is no longer. + let (_, uid2) = do_rekey(&kms, &uid1).await?; + assert_eq!(get_db_attrs(&kms, &uid2).await?.rotate_latest, Some(true)); + assert_eq!(get_db_attrs(&kms, &uid1).await?.rotate_latest, Some(false)); + // uid0 is unaffected (still false from the first rotation) + assert_eq!(get_db_attrs(&kms, &uid0).await?.rotate_latest, Some(false)); + Ok(()) +} + +// 6. Rekey a wrapped key stays wrapped +#[tokio::test] +async fn test_rekey_wrapped_key_stays_wrapped() -> KResult<()> { + let kms = kms().await; + let wrapping_uid = create_aes_256(&kms).await?; + let data_uid = create_aes_256(&kms).await?; + let wrapped_uid = uuid::Uuid::new_v4().to_string(); + store_wrapped_copy(&kms, &data_uid, &wrapping_uid, &wrapped_uid).await?; + let (_, new_uid) = do_rekey(&kms, &wrapped_uid).await?; + assert!( + kms.database + .retrieve_object(&new_uid) + .await? + .expect("new key must exist") + .object() + .is_wrapped() + ); + Ok(()) +} + +// 6. Rekey a wrapping key re-wraps dependants +#[tokio::test] +async fn test_rekey_wrapping_key_rewraps_dependants() -> KResult<()> { + let kms = kms().await; + let wrapping_uid = create_aes_256(&kms).await?; + let data_uid = create_aes_256(&kms).await?; + let wrapped_data_uid = uuid::Uuid::new_v4().to_string(); + store_wrapped_copy(&kms, &data_uid, &wrapping_uid, &wrapped_data_uid).await?; + let (_, new_wrapping_uid) = do_rekey(&kms, &wrapping_uid).await?; + let dep_attrs = get_all_attrs(&kms, &wrapped_data_uid).await?; + assert_eq!( + dep_attrs + .get_link(LinkType::WrappingKeyLink) + .expect("must have WrappingKeyLink") + .to_string(), + new_wrapping_uid + ); + assert!( + kms.database + .retrieve_object(&wrapped_data_uid) + .await? + .expect("dep must still exist") + .object() + .is_wrapped() + ); + Ok(()) +} + +// 7. Two successive rotations chain +#[tokio::test] +async fn test_rekey_two_successive_rotations() -> KResult<()> { + let kms = kms().await; + let uid0 = create_aes_256(&kms).await?; + let (_, uid1) = do_rekey(&kms, &uid0).await?; + let (_, uid2) = do_rekey(&kms, &uid1).await?; + assert_ne!(uid0, uid1); + assert_ne!(uid1, uid2); + let attrs1 = get_all_attrs(&kms, &uid1).await?; + assert_eq!( + attrs1 + .get_link(LinkType::ReplacedObjectLink) + .unwrap() + .to_string(), + uid0 + ); + assert_eq!( + attrs1 + .get_link(LinkType::ReplacementObjectLink) + .unwrap() + .to_string(), + uid2 + ); + let attrs2 = get_all_attrs(&kms, &uid2).await?; + assert_eq!( + attrs2 + .get_link(LinkType::ReplacedObjectLink) + .unwrap() + .to_string(), + uid1 + ); + assert!(attrs2.get_link(LinkType::ReplacementObjectLink).is_none()); + assert_eq!(get_db_attrs(&kms, &uid1).await?.rotate_generation, Some(1)); + assert_eq!(get_db_attrs(&kms, &uid2).await?.rotate_generation, Some(2)); + Ok(()) +} + +// 8. Rekey non-existent key +#[tokio::test] +async fn test_rekey_unknown_uid_returns_error() -> KResult<()> { + let kms = kms().await; + do_rekey(&kms, "nonexistent-uid").await.unwrap_err(); + Ok(()) +} + +// 9. Auto-rotation: not yet due +#[tokio::test] +async fn test_auto_rotation_not_due_is_skipped() -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + let kms = kms().await; + let uid = create_aes_256(&kms).await?; + set_attr(&kms, &uid, Attribute::RotateInterval(3600)).await?; + set_attr( + &kms, + &uid, + Attribute::InitialDate(time::OffsetDateTime::now_utc()), + ) + .await?; + run_auto_rotation(&kms).await; + assert!( + get_all_attrs(&kms, &uid) + .await? + .get_link(LinkType::ReplacementObjectLink) + .is_none() + ); + Ok(()) +} + +// 10. Auto-rotation: interval=0 means disabled +#[tokio::test] +async fn test_auto_rotation_interval_zero_disabled() -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + let kms = kms().await; + let uid = create_aes_256(&kms).await?; + set_attr(&kms, &uid, Attribute::RotateInterval(0)).await?; + set_attr( + &kms, + &uid, + Attribute::RotateDate(time::OffsetDateTime::now_utc() - time::Duration::seconds(7200)), + ) + .await?; + run_auto_rotation(&kms).await; + assert!( + get_all_attrs(&kms, &uid) + .await? + .get_link(LinkType::ReplacementObjectLink) + .is_none() + ); + Ok(()) +} + +// 11. Auto-rotation: past due +#[tokio::test] +async fn test_auto_rotation_past_due_is_rotated() -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + let kms = kms().await; + let uid = create_aes_256(&kms).await?; + set_attr(&kms, &uid, Attribute::RotateInterval(60)).await?; + set_attr( + &kms, + &uid, + Attribute::RotateDate(time::OffsetDateTime::now_utc() - time::Duration::seconds(120)), + ) + .await?; + run_auto_rotation(&kms).await; + assert!( + get_all_attrs(&kms, &uid) + .await? + .get_link(LinkType::ReplacementObjectLink) + .is_some() + ); + Ok(()) +} + +// 12. Auto-rotation: exactly at boundary triggers rotation +#[tokio::test] +async fn test_auto_rotation_exactly_at_boundary() -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + let kms = kms().await; + let uid = create_aes_256(&kms).await?; + set_attr(&kms, &uid, Attribute::RotateInterval(60)).await?; + set_attr( + &kms, + &uid, + Attribute::RotateDate(time::OffsetDateTime::now_utc() - time::Duration::seconds(60)), + ) + .await?; + run_auto_rotation(&kms).await; + assert!( + get_all_attrs(&kms, &uid) + .await? + .get_link(LinkType::ReplacementObjectLink) + .is_some() + ); + Ok(()) +} + +// 13. Auto-rotation: first-ever rotation triggered by InitialDate +#[tokio::test] +async fn test_auto_rotation_first_rotation_via_initial_date() -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + let kms = kms().await; + let uid = create_aes_256(&kms).await?; + set_attr(&kms, &uid, Attribute::RotateInterval(100)).await?; + set_attr( + &kms, + &uid, + Attribute::InitialDate(time::OffsetDateTime::now_utc() - time::Duration::seconds(200)), + ) + .await?; + run_auto_rotation(&kms).await; + assert!( + get_all_attrs(&kms, &uid) + .await? + .get_link(LinkType::ReplacementObjectLink) + .is_some() + ); + Ok(()) +} + +// 14. Auto-rotation: multiple keys at once +#[tokio::test] +async fn test_auto_rotation_multiple_keys_in_one_pass() -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + let kms = kms().await; + let uid1 = create_aes_256(&kms).await?; + let uid2 = create_aes_256(&kms).await?; + for uid in [&uid1, &uid2] { + set_attr(&kms, uid, Attribute::RotateInterval(60)).await?; + set_attr( + &kms, + uid, + Attribute::RotateDate(time::OffsetDateTime::now_utc() - time::Duration::seconds(120)), + ) + .await?; + } + run_auto_rotation(&kms).await; + for uid in [&uid1, &uid2] { + assert!( + get_all_attrs(&kms, uid) + .await? + .get_link(LinkType::ReplacementObjectLink) + .is_some(), + "{uid} must be rotated" + ); + } + Ok(()) +} + +// 15. Auto-rotation: mixed due / not-due +#[tokio::test] +async fn test_auto_rotation_mixed_batch() -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + let kms = kms().await; + let uid_due = create_aes_256(&kms).await?; + let uid_not_due = create_aes_256(&kms).await?; + set_attr(&kms, &uid_due, Attribute::RotateInterval(60)).await?; + set_attr( + &kms, + &uid_due, + Attribute::RotateDate(time::OffsetDateTime::now_utc() - time::Duration::seconds(120)), + ) + .await?; + set_attr(&kms, &uid_not_due, Attribute::RotateInterval(3600)).await?; + set_attr( + &kms, + &uid_not_due, + Attribute::InitialDate(time::OffsetDateTime::now_utc()), + ) + .await?; + run_auto_rotation(&kms).await; + assert!( + get_all_attrs(&kms, &uid_due) + .await? + .get_link(LinkType::ReplacementObjectLink) + .is_some() + ); + assert!( + get_all_attrs(&kms, &uid_not_due) + .await? + .get_link(LinkType::ReplacementObjectLink) + .is_none() + ); + Ok(()) +} + +// 16. Disable rotation after enabling it +#[tokio::test] +async fn test_disable_rotation_after_enabling() -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + let kms = kms().await; + let uid = create_aes_256(&kms).await?; + set_attr(&kms, &uid, Attribute::RotateInterval(3600)).await?; + set_attr(&kms, &uid, Attribute::RotateInterval(0)).await?; + set_attr( + &kms, + &uid, + Attribute::RotateDate(time::OffsetDateTime::now_utc() - time::Duration::seconds(7200)), + ) + .await?; + run_auto_rotation(&kms).await; + assert!( + get_all_attrs(&kms, &uid) + .await? + .get_link(LinkType::ReplacementObjectLink) + .is_none() + ); + Ok(()) +} + +// 17. Key without interval is never auto-rotated +#[tokio::test] +async fn test_key_without_interval_never_auto_rotated() -> KResult<()> { + let kms = kms().await; + let uid = create_aes_256(&kms).await?; + run_auto_rotation(&kms).await; + assert!( + get_all_attrs(&kms, &uid) + .await? + .get_link(LinkType::ReplacementObjectLink) + .is_none() + ); + Ok(()) +} + +// 17b. Auto-rotation policy transfer: old key gets interval=0, new key inherits interval. +#[tokio::test] +async fn test_auto_rotation_transfers_policy_to_new_key() -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + let kms = kms().await; + let uid = create_aes_256(&kms).await?; + set_attr(&kms, &uid, Attribute::RotateInterval(3600)).await?; + set_attr(&kms, &uid, Attribute::RotateName("hourly".to_owned())).await?; + set_attr( + &kms, + &uid, + Attribute::RotateDate(time::OffsetDateTime::now_utc() - time::Duration::seconds(7200)), + ) + .await?; + + run_auto_rotation(&kms).await; + + // Use get_all_attrs (KMIP GetAttributes) for both checks — after the fix to + // get_attributes.rs, rotation policy fields are always read from the metadata + // column (not the key block), so GetAttributes now returns the correct values. + let old_attrs = get_all_attrs(&kms, &uid).await?; + let new_uid = old_attrs + .get_link(LinkType::ReplacementObjectLink) + .expect("old key must have ReplacementObjectLink after auto-rotation") + .to_string(); + + assert_eq!( + old_attrs.rotate_interval, + Some(0), + "old key rotate_interval must be 0 after auto-rotation" + ); + + // New key: must inherit the rotation policy. + let new_attrs = get_db_attrs(&kms, &new_uid).await?; + assert_eq!( + new_attrs.rotate_interval, + Some(3600), + "new key must inherit rotate_interval from the old key" + ); + assert_eq!( + new_attrs.rotate_name.as_deref(), + Some("hourly"), + "new key must inherit rotate_name from the old key" + ); + Ok(()) +} + +// ─── RSA key pair + self-signed certificate helper ─────────────────────────── + +/// Create an RSA-2048 key pair and self-sign the public key as a certificate. +/// +/// Returns `(private_key_uid, public_key_uid, cert_uid)`. +async fn create_rsa_key_pair_and_cert(kms: &Arc) -> KResult<(String, String, String)> { + let kp_req = create_rsa_key_pair_request( + VENDOR_ID_COSMIAN, + None, + Vec::::new(), + 2048, + false, + None, + )?; + let kp_resp = kms.create_key_pair(kp_req, USER, None).await?; + let private_key_uid = kp_resp.private_key_unique_identifier.to_string(); + let public_key_uid = kp_resp.public_key_unique_identifier.to_string(); + + // Self-sign: certify the public key without specifying an issuer in the request. + // The certify path reads PrivateKeyLink from the stored public key attributes to find + // the signing key — passing it in the request attributes would trigger the non-self-signed + // path, which requires an existing issuer certificate. + let certify_attrs = Attributes { + object_type: Some(ObjectType::Certificate), + certificate_attributes: Some(CertificateAttributes::parse_subject_line( + "CN=AutoRotateTest", + )?), + ..Attributes::default() + }; + let cert_resp = kms + .certify( + Certify { + unique_identifier: Some(UniqueIdentifier::TextString(public_key_uid.clone())), + attributes: Some(certify_attrs), + ..Certify::default() + }, + USER, + None, + ) + .await?; + let cert_uid = cert_resp.unique_identifier.to_string(); + + Ok((private_key_uid, public_key_uid, cert_uid)) +} + +// 18. Re-keyed key carries a `ReplacedObjectLink` KMIP attribute pointing to the old key ID. +// +// This is the canonical proof that the KMIP link set by `rekey` is readable back through +// `GetAttributes` and that it contains exactly the original key UID — independent of the +// broader link-chain test above. +#[tokio::test] +async fn test_rekeyed_key_has_replaced_object_link_to_old_key() -> KResult<()> { + let kms = kms().await; + + // Create the original key. + let old_uid = create_aes_256(&kms).await?; + + // Rotate it. + let (_, new_uid) = do_rekey(&kms, &old_uid).await?; + + // Verify: the *new* key must carry a `ReplacedObjectLink` pointing to the *old* key. + let new_attrs = get_all_attrs(&kms, &new_uid).await?; + let replaced_link = new_attrs + .get_link(LinkType::ReplacedObjectLink) + .expect("new key must carry a ReplacedObjectLink KMIP attribute after rotation"); + assert_eq!( + replaced_link.to_string(), + old_uid, + "ReplacedObjectLink on the new key must equal the old key UID" + ); + + // Cross-check: the *old* key must carry a `ReplacementObjectLink` pointing to the *new* key. + let old_attrs = get_all_attrs(&kms, &old_uid).await?; + let replacement_link = old_attrs + .get_link(LinkType::ReplacementObjectLink) + .expect("old key must carry a ReplacementObjectLink KMIP attribute after rotation"); + assert_eq!( + replacement_link.to_string(), + new_uid, + "ReplacementObjectLink on the old key must equal the new key UID" + ); + + Ok(()) +} + +// 19. Auto-rotation of a Certificate: creates a new cert + key pair, links old → new. +#[tokio::test] +async fn test_auto_rotation_certificate() -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + let kms = kms().await; + + let (_, _, cert_uid) = create_rsa_key_pair_and_cert(&kms).await?; + + // Mark the certificate as overdue for rotation. + set_attr(&kms, &cert_uid, Attribute::RotateInterval(60)).await?; + set_attr( + &kms, + &cert_uid, + Attribute::RotateDate(time::OffsetDateTime::now_utc() - time::Duration::seconds(120)), + ) + .await?; + + let gen_before = get_db_attrs(&kms, &cert_uid) + .await? + .rotate_generation + .unwrap_or(0); + + run_auto_rotation(&kms).await; + + // Old cert must have a ReplacementObjectLink pointing to the new cert. + let old_attrs = get_db_attrs(&kms, &cert_uid).await?; + let new_cert_uid = old_attrs + .get_link(LinkType::ReplacementObjectLink) + .expect("old cert must carry ReplacementObjectLink after auto-renewal") + .to_string(); + + // New cert must have incremented rotate_generation. + let new_attrs = get_db_attrs(&kms, &new_cert_uid).await?; + assert_eq!( + new_attrs.rotate_generation, + Some(gen_before + 1), + "new certificate rotate_generation must be gen_before+1 after auto-renewal" + ); + + // Old cert's rotation policy must be disabled (interval = 0). + assert_eq!( + old_attrs.rotate_interval, + Some(0), + "old cert rotate_interval must be 0 after renewal so it is not picked up again" + ); + + // New cert must have inherited the rotation policy from the old cert. + assert_eq!( + new_attrs.rotate_interval, + Some(60), + "new cert must inherit rotate_interval from the old cert" + ); + + Ok(()) +} + +// 20. Auto-rotation: PublicKey arm follows PrivateKeyLink and delegates to ReKeyKeyPair. +// `rekey_keypair` currently only supports CoverCrypt; RSA key pair rotation is not yet +// implemented. This test verifies the graceful-skip path: `run_auto_rotation` must not +// panic, and the key must be left unchanged (no ReplacementObjectLink). +#[tokio::test] +async fn test_auto_rotation_public_key_graceful_skip() -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + let kms = kms().await; + + let (private_key_uid, public_key_uid, _) = create_rsa_key_pair_and_cert(&kms).await?; + + // Set rotation policy on the public key — the auto-rotation engine will follow PrivateKeyLink, + // attempt ReKeyKeyPair on the RSA private key, and gracefully log a warning because RSA key + // pair rekey is not yet implemented in rekey_keypair. + set_attr(&kms, &public_key_uid, Attribute::RotateInterval(60)).await?; + set_attr( + &kms, + &public_key_uid, + Attribute::RotateDate(time::OffsetDateTime::now_utc() - time::Duration::seconds(120)), + ) + .await?; + + // Must not panic — run_auto_rotation catches errors with a warning. + run_auto_rotation(&kms).await; + + // RSA ReKeyKeyPair is not yet implemented: the private key must be left unchanged. + let private_attrs = get_all_attrs(&kms, &private_key_uid).await?; + assert!( + private_attrs + .get_link(LinkType::ReplacementObjectLink) + .is_none(), + "RSA key pair rekey is not yet supported; ReplacementObjectLink must not be set" + ); + + Ok(()) +} + +// 21. End-to-end cron: symmetric key created with a 2-second rotation interval is automatically +// rotated by the background cron thread without any explicit `run_auto_rotation` call. +// +// The test verifies that: +// - after waiting ~6 s (≥ 2 cron ticks at 2 s interval), the original key carries a +// `ReplacementObjectLink` attribute. +// - `rotate_generation` on the original key is 0 (it was never manually rekeyed), +// and on the replacement it is 1. +#[tokio::test] +async fn test_cron_automatically_rotates_symmetric_key() -> KResult<()> { + use std::sync::Arc; + + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + + use crate::cron::spawn_auto_rotation_cron; + + // Build a KMS with a 2-second auto-rotation check interval so the cron fires quickly. + log_init(option_env!("RUST_LOG")); + let mut clap_config = https_clap_config(); + clap_config.auto_rotation_check_interval_secs = 2; + let kms = Arc::new( + KMS::instantiate(Arc::new( + ServerParams::try_from(clap_config).expect("ServerParams"), + )) + .await + .expect("KMS::instantiate"), + ); + + // Create an AES-256 key and mark it as already overdue (rotate_date = 10 s in the past). + let uid = create_aes_256(&kms).await?; + set_attr(&kms, &uid, Attribute::RotateInterval(1)).await?; // interval = 1 s → always due + set_attr( + &kms, + &uid, + Attribute::RotateDate(time::OffsetDateTime::now_utc() - time::Duration::seconds(10)), + ) + .await?; + + // Spawn the auto-rotation cron thread. + let shutdown_tx = spawn_auto_rotation_cron(kms.clone()); + + // Wait long enough for at least two cron ticks (2 × 2 s + 2 s margin). + tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; + + // Stop the cron thread. + let _ = shutdown_tx.send(()); + + // The original key must now have a ReplacementObjectLink (= rotation occurred). + let attrs = get_all_attrs(&kms, &uid).await?; + let replacement_uid = attrs + .get_link(LinkType::ReplacementObjectLink) + .expect("cron must have rotated the symmetric key: ReplacementObjectLink is missing") + .to_string(); + + // The replacement key must have rotate_generation = 1. + let new_attrs = get_db_attrs(&kms, &replacement_uid).await?; + assert_eq!( + new_attrs.rotate_generation, + Some(1), + "replacement key must have rotate_generation = 1" + ); + + Ok(()) +} + +// 22. End-to-end cron: certificate created with a 2-second rotation interval is automatically +// renewed by the background cron thread without any explicit `run_auto_rotation` call. +// +// Certificate auto-rotation creates new objects (new key pair + new certificate) and links +// the old cert to the new one via ReplacementObjectLink. The test verifies: +// - the old cert gains a ReplacementObjectLink after at least one cron tick. +// - the replacement certificate has rotate_generation >= 1. +#[tokio::test] +async fn test_cron_automatically_renews_certificate() -> KResult<()> { + use std::sync::Arc; + + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + + use crate::cron::spawn_auto_rotation_cron; + + log_init(option_env!("RUST_LOG")); + let mut clap_config = https_clap_config(); + clap_config.auto_rotation_check_interval_secs = 2; + let kms = Arc::new( + KMS::instantiate(Arc::new( + ServerParams::try_from(clap_config).expect("ServerParams"), + )) + .await + .expect("KMS::instantiate"), + ); + + // Create an RSA key pair and self-sign a certificate. + let (_private_key_uid, _public_key_uid, cert_uid) = create_rsa_key_pair_and_cert(&kms).await?; + + // Mark the certificate as overdue for rotation (interval = 1 s, last rotate_date = 10 s ago). + set_attr(&kms, &cert_uid, Attribute::RotateInterval(1)).await?; + set_attr( + &kms, + &cert_uid, + Attribute::RotateDate(time::OffsetDateTime::now_utc() - time::Duration::seconds(10)), + ) + .await?; + + // Spawn the auto-rotation cron thread. + let shutdown_tx = spawn_auto_rotation_cron(kms.clone()); + + // Wait long enough for at least two cron ticks (2 × 2 s + 2 s margin). + tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; + + // Stop the cron thread. + let _ = shutdown_tx.send(()); + + // The old cert must now have a ReplacementObjectLink (= at least one renewal occurred). + let old_attrs = get_db_attrs(&kms, &cert_uid).await?; + let new_cert_uid = old_attrs + .get_link(LinkType::ReplacementObjectLink) + .expect( + "cron must have renewed the certificate: \ + ReplacementObjectLink is missing on old cert", + ) + .to_string(); + + // The replacement certificate must have rotate_generation >= 1. + let new_attrs = get_db_attrs(&kms, &new_cert_uid).await?; + assert!( + new_attrs.rotate_generation.unwrap_or(0) >= 1, + "replacement certificate must have rotate_generation >= 1 \ + (got {:?})", + new_attrs.rotate_generation + ); + + Ok(()) +} + +// 23. Production scenario: a certificate is created with only rotate_interval set (no explicit +// rotate_date). The `Certify` operation must stamp initial_date on the certificate so +// that `is_due_for_rotation` can compute the first rotation deadline without the user +// needing to call SetAttribute(RotateDate) beforehand. +// +// Without initial_date being set, is_due_for_rotation returns false for every cron tick no +// matter how large the interval, and the user sees "Running scheduled key auto-rotation check" +// in the logs with no certificate ever renewed. +#[tokio::test] +async fn test_cron_renews_cert_with_only_rotate_interval_set() -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attribute; + + use crate::cron::spawn_auto_rotation_cron; + + log_init(option_env!("RUST_LOG")); + let mut clap_config = https_clap_config(); + clap_config.auto_rotation_check_interval_secs = 2; + let kms = Arc::new( + KMS::instantiate(Arc::new( + ServerParams::try_from(clap_config).expect("ServerParams"), + )) + .await + .expect("KMS::instantiate"), + ); + + // Create the cert and set ONLY rotate_interval — no rotate_date, no rotate_offset. + // This is the typical production flow where the user configures a rotation policy + // at creation time without providing a starting date. + let (_private_key_uid, _public_key_uid, cert_uid) = create_rsa_key_pair_and_cert(&kms).await?; + set_attr(&kms, &cert_uid, Attribute::RotateInterval(1)).await?; + // Deliberately do NOT call set_attr(RotateDate). The cert relies entirely on + // initial_date (set by Certify) for the first-rotation-deadline computation. + + // Confirm initial_date was stamped by Certify. + let attrs_created = get_db_attrs(&kms, &cert_uid).await?; + assert!( + attrs_created.initial_date.is_some(), + "Certify must set initial_date on the certificate so that \ + is_due_for_rotation works without an explicit rotate_date" + ); + + // Spawn the auto-rotation cron and wait long enough for it to fire. + let shutdown_tx = spawn_auto_rotation_cron(kms.clone()); + tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; + let _ = shutdown_tx.send(()); + + // The old cert must have a ReplacementObjectLink (= renewal occurred). + let old_attrs = get_db_attrs(&kms, &cert_uid).await?; + let new_cert_uid = old_attrs + .get_link(LinkType::ReplacementObjectLink) + .expect( + "old cert must carry ReplacementObjectLink even without an explicit rotate_date \ + — initial_date must have been used to trigger the first renewal", + ) + .to_string(); + + // New cert must have rotate_generation >= 1. + let new_attrs = get_db_attrs(&kms, &new_cert_uid).await?; + assert!( + new_attrs.rotate_generation.unwrap_or(0) >= 1, + "replacement certificate rotate_generation must be >= 1 (got {:?})", + new_attrs.rotate_generation + ); + + Ok(()) +} + +// 25. After auto-rotation the new certificate has different DER bytes and a fresh validity. +// +// The test verifies the "create new objects" renewal semantics: +// 1. The old cert's DER bytes are UNCHANGED after rotation (it is not overwritten). +// 2. The old cert gains a ReplacementObjectLink → new cert UID. +// 3. The new cert's DER bytes differ from the old cert's DER bytes. +// 4. The new cert's NotAfter is at or after the old cert's NotAfter (fresh validity). +// 5. The new cert carries rotate_generation = gen_before + 1. +// 6. The old cert's rotation policy (rotate_interval) is cleared. +// 7. The new cert carries a ReplacedObjectLink → old cert UID. +#[tokio::test] +async fn test_cert_auto_rotation_updates_der_bytes() -> KResult<()> { + use cosmian_kms_server_database::reexport::{ + cosmian_kmip::kmip_2_1::{kmip_attributes::Attribute, kmip_objects::Object}, + cosmian_kms_crypto::openssl::kmip_certificate_to_openssl, + }; + + log_init(option_env!("RUST_LOG")); + let kms = kms().await; + + let (_, _, cert_uid) = create_rsa_key_pair_and_cert(&kms).await?; + + // Mark the cert as overdue so run_auto_rotation picks it up immediately. + set_attr(&kms, &cert_uid, Attribute::RotateInterval(1)).await?; + set_attr( + &kms, + &cert_uid, + Attribute::RotateDate(time::OffsetDateTime::now_utc() - time::Duration::seconds(10)), + ) + .await?; + + // Capture the DER bytes and NotAfter of the OLD cert BEFORE rotation. + let (der_before, not_after_before) = { + let owm = kms + .database + .retrieve_object(&cert_uid) + .await? + .expect("cert must exist"); + let x509 = kmip_certificate_to_openssl(owm.object())?; + let not_after = x509.not_after().to_string(); + let der = match owm.object() { + Object::Certificate(c) => c.certificate_value.clone(), + other => panic!("expected Certificate, got {other:?}"), + }; + (der, not_after) + }; + let gen_before = get_db_attrs(&kms, &cert_uid) + .await? + .rotate_generation + .unwrap_or(0); + + // Wait 1 second so the new NotAfter (computed as `now + 365 days`) is guaranteed to + // be strictly at or after the old NotAfter. + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + // Trigger a single auto-rotation pass. + run_auto_rotation(&kms).await; + + // 1. Old cert DER bytes must be UNCHANGED (cert is preserved, not overwritten). + let old_owm_after = kms + .database + .retrieve_object(&cert_uid) + .await? + .expect("old cert must still exist"); + let der_old_after = match old_owm_after.object() { + Object::Certificate(c) => c.certificate_value.clone(), + other => panic!("expected Certificate, got {other:?}"), + }; + assert_eq!( + der_before, der_old_after, + "old cert DER bytes must be UNCHANGED after renewal (it is not overwritten)" + ); + + // 2. Old cert must have a ReplacementObjectLink. + let old_attrs_after = get_db_attrs(&kms, &cert_uid).await?; + let new_cert_uid = old_attrs_after + .get_link(LinkType::ReplacementObjectLink) + .expect("old cert must carry ReplacementObjectLink after auto-renewal") + .to_string(); + + // 3. New cert DER bytes must differ from old cert DER bytes. + let new_owm = kms + .database + .retrieve_object(&new_cert_uid) + .await? + .expect("new cert must exist"); + let x509_new = kmip_certificate_to_openssl(new_owm.object())?; + let not_after_new = x509_new.not_after().to_string(); + let der_new = match new_owm.object() { + Object::Certificate(c) => c.certificate_value.clone(), + other => panic!("expected Certificate, got {other:?}"), + }; + assert_ne!( + der_before, der_new, + "new cert DER bytes must differ from old cert DER bytes" + ); + + // 4. New cert NotAfter must be at or after the old cert's NotAfter (fresh validity window). + assert!( + not_after_new >= not_after_before, + "new cert NotAfter must be >= old cert NotAfter: \ + renewal must issue a fresh cert (before={not_after_before}, new={not_after_new})" + ); + + // 5. New cert rotate_generation = gen_before + 1. + let new_attrs = get_db_attrs(&kms, &new_cert_uid).await?; + assert_eq!( + new_attrs.rotate_generation, + Some(gen_before + 1), + "new cert rotate_generation must be gen_before+1 after auto-renewal" + ); + + // 6. Old cert rotation policy must be disabled (interval = 0). + assert_eq!( + old_attrs_after.rotate_interval, + Some(0), + "old cert rotate_interval must be 0 after renewal" + ); + + // 7. New cert must carry ReplacedObjectLink → old cert. + let replaced_link = new_attrs + .get_link(LinkType::ReplacedObjectLink) + .expect("new cert must carry ReplacedObjectLink after auto-renewal"); + assert_eq!( + replaced_link.to_string(), + cert_uid, + "new cert ReplacedObjectLink must point to the old cert UID" + ); + + Ok(()) +} diff --git a/crate/server_database/Cargo.toml b/crate/server_database/Cargo.toml index ad6b8e78b0..3e6346e147 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", 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 a5b30b2615..7709e9be3b 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::OffsetDateTime; use crate::{ Database, @@ -314,6 +315,37 @@ impl Database { Ok(results) } + /// Find all Active objects whose `key_wrapping_data` wrapping key UID matches + /// `wrapping_key_uid`, accessible to `user`. Used during key rotation to + /// find dependant keys that must be re-wrapped. + 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 (_prefix, db) in map.iter() { + results.extend( + db.find_wrapped_by(wrapping_key_uid, user) + .await + .unwrap_or_default(), + ); + } + 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: OffsetDateTime) -> DbResult> { + let map = self.objects.read().await; + let mut results: Vec = Vec::new(); + for (_prefix, db) in map.iter() { + 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/redis/objects_db.rs b/crate/server_database/src/stores/redis/objects_db.rs index 0f61f0719b..0b0df421ab 100644 --- a/crate/server_database/src/stores/redis/objects_db.rs +++ b/crate/server_database/src/stores/redis/objects_db.rs @@ -312,6 +312,16 @@ impl ObjectsDB { pipeline.query_async::<()>(&mut self.mgr.clone()).await?; Ok(res) } + + /// Scan all Redis keys matching a pattern (e.g. `do::*`). + /// Returns the matching key strings (including the prefix). + pub(crate) async fn scan_keys(&self, pattern: &str) -> DbResult> { + let keys: Vec = redis::cmd("KEYS") + .arg(pattern) + .query_async(&mut self.mgr.clone()) + .await?; + Ok(keys) + } } pub(crate) enum RedisOperation { 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 2ef3b483af..a6ab2d8b6b 100644 --- a/crate/server_database/src/stores/redis/redis_with_findex.rs +++ b/crate/server_database/src/stores/redis/redis_with_findex.rs @@ -19,6 +19,7 @@ use cosmian_kms_interfaces::{ use cosmian_logger::{debug, trace}; use cosmian_sse_memories::{ADDRESS_LENGTH, Address, RedisMemory}; use redis::aio::ConnectionManager; +use time::OffsetDateTime; use uuid::Uuid; use super::{ @@ -552,6 +553,81 @@ impl ObjectsStore for RedisWithFindex { }) .collect()) } + + async fn find_wrapped_by( + &self, + wrapping_key_uid: &str, + user: &str, + ) -> InterfaceResult> { + // For Redis-Findex: scan all keys with prefix do::, decrypt and filter in memory. + // This is O(n) in the number of stored objects. + let pattern = "do::*"; + let keys: Vec = self + .objects_db + .scan_keys(pattern) + .await + .map_err(|e| db_error!(format!("Redis scan_keys error: {e:?}")))?; + + let permissions: std::collections::HashMap = self + .permission_db + .list_user_permissions(&UserId(user.to_owned())) + .await? + .into_iter() + .map(|(k, v)| (k.0, v)) + .collect(); + + let uids: std::collections::HashSet = keys + .into_iter() + .map(|k| k.trim_start_matches("do::").to_owned()) + .collect(); + + let all_objects = self.objects_db.objects_get(&uids).await?; + let mut out = Vec::new(); + for (uid, redis_obj) in all_objects { + if redis_obj.owner != user && !permissions.contains_key(&uid) { + continue; + } + let wrapped_by = redis_obj + .object + .key_block() + .ok() + .and_then(|kb| kb.key_wrapping_data.as_ref()) + .and_then(|kwd| kwd.encryption_key_information.as_ref()) + .map(|eki| eki.unique_identifier.to_string()); + if wrapped_by.as_deref() == Some(wrapping_key_uid) { + let attrs = redis_obj.object.attributes().cloned().unwrap_or_default(); + out.push((uid, redis_obj.state, attrs)); + } + } + Ok(out) + } + + async fn find_due_for_rotation(&self, now: OffsetDateTime) -> InterfaceResult> { + let pattern = "do::*"; + let keys: Vec = self + .objects_db + .scan_keys(pattern) + .await + .map_err(|e| db_error!(format!("Redis scan_keys error: {e:?}")))?; + + let uids: std::collections::HashSet = keys + .into_iter() + .map(|k| k.trim_start_matches("do::").to_owned()) + .collect(); + + let all_objects = self.objects_db.objects_get(&uids).await?; + let mut due = Vec::new(); + for (uid, redis_obj) in all_objects { + if redis_obj.state != State::Active { + continue; + } + let attrs = redis_obj.object.attributes().cloned().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/locate_query.rs b/crate/server_database/src/stores/sql/locate_query.rs index 36d5f3a3ea..bfd91c0533 100644 --- a/crate/server_database/src/stores/sql/locate_query.rs +++ b/crate/server_database/src/stores/sql/locate_query.rs @@ -7,6 +7,7 @@ use cosmian_kmip::{ kmip_types::{LinkedObjectIdentifier::TextString, NameType, UniqueIdentifier}, }, }; +use time::OffsetDateTime; /// Handle different placeholders naming (bind parameter or /// function) in SQL databases. @@ -514,3 +515,35 @@ ON objects.id = matched_tags.id" qb.finish(query) } + +/// Determine whether a key is due for rotation based on its `Attributes`. +/// +/// A key is due when: +/// - `rotate_interval > 0` is set, AND +/// - `rotate_date + rotate_interval <= now` (if `rotate_date` is Some), OR +/// - `initial_date + rotate_offset <= now` (if `rotate_date` is None and `rotate_offset` is Some) +#[must_use] +pub(crate) fn is_due_for_rotation( + attrs: &cosmian_kmip::kmip_2_1::kmip_attributes::Attributes, + now: OffsetDateTime, +) -> bool { + let interval = match attrs.rotate_interval { + Some(i) if i > 0 => i64::from(i), + _ => return false, + }; + + if let Some(last_rotate) = attrs.rotate_date { + // Has been rotated before: next rotation = rotate_date + interval + let next = last_rotate + time::Duration::seconds(interval); + return next <= now; + } + + // Never been rotated: use initial_date + rotate_offset as the first rotation instant + if let Some(initial) = attrs.initial_date { + let offset = attrs.rotate_offset.map_or(0, i64::from); + let first_rotation = initial + time::Duration::seconds(interval + offset); + return first_rotation <= now; + } + + false +} diff --git a/crate/server_database/src/stores/sql/mod.rs b/crate/server_database/src/stores/sql/mod.rs index e14f8edb08..3276749e6f 100644 --- a/crate/server_database/src/stores/sql/mod.rs +++ b/crate/server_database/src/stores/sql/mod.rs @@ -1,4 +1,4 @@ -mod locate_query; +pub(crate) mod locate_query; mod mysql; pub(crate) use mysql::MySqlPool; mod pgsql; diff --git a/crate/server_database/src/stores/sql/mysql.rs b/crate/server_database/src/stores/sql/mysql.rs index 7cd58f4f1d..4c9b2c7bc4 100644 --- a/crate/server_database/src/stores/sql/mysql.rs +++ b/crate/server_database/src/stores/sql/mysql.rs @@ -19,6 +19,7 @@ use mysql_async::ClientIdentity; use mysql_async::{Pool, SslOpts, Transaction, prelude::*}; use rawsql::Loader; use serde_json::Value; +use time::OffsetDateTime; use url::Url; use uuid::Uuid; @@ -625,6 +626,96 @@ impl ObjectsStore for MySqlPool { ) .await?) } + + async fn find_wrapped_by( + &self, + wrapping_key_uid: &str, + user: &str, + ) -> InterfaceResult> { + // MySQL/MariaDB: search in the stored `object` JSON column using JSON_EXTRACT + 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 ( \ + JSON_UNQUOTE(JSON_EXTRACT(objects.object, '$.SymmetricKey.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier')) = ? \ + OR JSON_UNQUOTE(JSON_EXTRACT(objects.object, '$.PrivateKey.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier')) = ? \ + OR JSON_UNQUOTE(JSON_EXTRACT(objects.object, '$.SecretData.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier')) = ? \ + OR JSON_UNQUOTE(JSON_EXTRACT(objects.object, '$.SplitKey.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier')) = ? \ + OR JSON_UNQUOTE(JSON_EXTRACT(objects.object, '$.PGPKey.KeyBlock.KeyWrappingData.EncryptionKeyInformation.UniqueIdentifier')) = ? \ + )"; + let uid_s = wrapping_key_uid.to_owned(); + let user_s = user.to_owned(); + let mut conn = self + .get_configured_conn() + .await + .map_err(InterfaceError::from)?; + let rows: Vec = conn + .exec( + sql, + ( + user_s.clone(), + user_s.clone(), + user_s.clone(), + uid_s.clone(), + uid_s.clone(), + uid_s.clone(), + uid_s.clone(), + uid_s.clone(), + ), + ) + .await + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + let mut out = Vec::new(); + for row in rows { + let id: String = row + .get(0) + .ok_or_else(|| InterfaceError::Db("missing id".to_owned()))?; + let state_str: String = row + .get(1) + .ok_or_else(|| InterfaceError::Db("missing state".to_owned()))?; + let state = State::try_from(state_str.as_str()) + .map_err(|e| InterfaceError::Db(e.to_string()))?; + let attrs_val: mysql_async::Value = row + .get(2) + .ok_or_else(|| InterfaceError::Db("missing attributes".to_owned()))?; + let attrs_json = match &attrs_val { + mysql_async::Value::Bytes(b) => { + String::from_utf8(b.clone()).map_err(|e| InterfaceError::Db(e.to_string()))? + } + _ => return Err(InterfaceError::Db("unexpected attribute type".to_owned())), + }; + let attrs: Attributes = + serde_json::from_str(&attrs_json).map_err(|e| InterfaceError::Db(e.to_string()))?; + out.push((id, state, attrs)); + } + Ok(out) + } + + async fn find_due_for_rotation(&self, now: OffsetDateTime) -> InterfaceResult> { + let sql = "SELECT objects.id, objects.attributes \ + FROM objects \ + WHERE objects.state = 'Active' \ + AND JSON_EXTRACT(objects.attributes, '$.RotateInterval') IS NOT NULL \ + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(objects.attributes, '$.RotateInterval')) AS UNSIGNED) > 0"; + let mut conn = self + .get_configured_conn() + .await + .map_err(InterfaceError::from)?; + let rows: Vec<(String, String)> = conn + .query(sql) + .await + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + 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)] diff --git a/crate/server_database/src/stores/sql/pgsql.rs b/crate/server_database/src/stores/sql/pgsql.rs index 17be0fee65..5ad8f9f56a 100644 --- a/crate/server_database/src/stores/sql/pgsql.rs +++ b/crate/server_database/src/stores/sql/pgsql.rs @@ -14,6 +14,7 @@ use openssl::ssl::{SslConnector, SslFiletype, SslMethod, SslVerifyMode}; use postgres_openssl::MakeTlsConnector; use rawsql::Loader; use serde_json::Value; +use time::OffsetDateTime; use tokio_postgres::{ NoTls, types::{Json, ToSql}, @@ -780,6 +781,75 @@ impl ObjectsStore for PgPool { Ok(out) }) } + + async fn find_wrapped_by( + &self, + wrapping_key_uid: &str, + user: &str, + ) -> InterfaceResult> { + 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::jsonb) -> 'SymmetricKey' -> 'KeyBlock' -> 'KeyWrappingData' -> 'EncryptionKeyInformation' ->> 'UniqueIdentifier' = $1 \ + OR (objects.object::jsonb) -> 'PrivateKey' -> 'KeyBlock' -> 'KeyWrappingData' -> 'EncryptionKeyInformation' ->> 'UniqueIdentifier' = $1 \ + OR (objects.object::jsonb) -> 'SecretData' -> 'KeyBlock' -> 'KeyWrappingData' -> 'EncryptionKeyInformation' ->> 'UniqueIdentifier' = $1 \ + OR (objects.object::jsonb) -> 'SplitKey' -> 'KeyBlock' -> 'KeyWrappingData' -> 'EncryptionKeyInformation' ->> 'UniqueIdentifier' = $1 \ + OR (objects.object::jsonb) -> 'PGPKey' -> 'KeyBlock' -> 'KeyWrappingData' -> 'EncryptionKeyInformation' ->> 'UniqueIdentifier' = $1 \ + )"; + pg_retry!(self.pool, |client| { + let stmt = client + .prepare(sql) + .await + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + let rows = client + .query(&stmt, &[&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 fn find_due_for_rotation(&self, now: OffsetDateTime) -> InterfaceResult> { + let sql = "SELECT objects.id, objects.attributes \ + FROM objects \ + WHERE objects.state = 'Active' \ + AND (objects.attributes::jsonb ->> 'RotateInterval') IS NOT NULL \ + AND CAST((objects.attributes::jsonb ->> 'RotateInterval') AS BIGINT) > 0"; + pg_retry!(self.pool, |client| { + let stmt = client + .prepare(sql) + .await + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + let rows = client + .query(&stmt, &[]) + .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/sqlite.rs b/crate/server_database/src/stores/sql/sqlite.rs index 03c39b24c2..c0ac6894f8 100644 --- a/crate/server_database/src/stores/sql/sqlite.rs +++ b/crate/server_database/src/stores/sql/sqlite.rs @@ -16,10 +16,11 @@ use cosmian_kms_interfaces::{ use rawsql::Loader; use rusqlite::{OptionalExtension, Row, params_from_iter}; use serde_json::Value; +use time::OffsetDateTime; use tokio_rusqlite::Connection; use uuid::Uuid; -use super::locate_query::{SqlitePlaceholder, query_from_attributes}; +use super::locate_query::{SqlitePlaceholder, is_due_for_rotation, query_from_attributes}; use crate::{ db_error, error::{DbError, DbResult}, @@ -489,6 +490,101 @@ 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 + .conn + .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 fn find_due_for_rotation(&self, now: OffsetDateTime) -> InterfaceResult> { + // Find all Active objects with rotate_interval > 0. + // The actual due-date check is done in Rust after fetching. + let sql = replace_dollars_with_qn( + "SELECT objects.id, objects.attributes \ + FROM objects \ + WHERE objects.state = 'Active' \ + AND json_extract(objects.attributes, '$.RotateInterval') IS NOT NULL \ + AND CAST(json_extract(objects.attributes, '$.RotateInterval') AS INTEGER) > 0", + ); + let rows = self + .conn + .call( + move |c: &mut rusqlite::Connection| -> Result, 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 is_due_for_rotation(&attrs, now) { + due.push(uid); + } + } + Ok(due) + } } #[async_trait(?Send)] diff --git a/crate/test_kms_server/src/test_server.rs b/crate/test_kms_server/src/test_server.rs index e1e633812a..0cce19d98f 100644 --- a/crate/test_kms_server/src/test_server.rs +++ b/crate/test_kms_server/src/test_server.rs @@ -950,6 +950,7 @@ pub fn build_server_params_full( } else { None }, + auto_rotation_check_interval_secs: 10, ..ClapConfig::default() }; diff --git a/crate/wasm/src/wasm.rs b/crate/wasm/src/wasm.rs index e89b8693ae..a4c8cd30ad 100644 --- a/crate/wasm/src/wasm.rs +++ b/crate/wasm/src/wasm.rs @@ -40,8 +40,8 @@ use cosmian_kms_client_utils::{ DeriveKeyResponse, Destroy, DestroyResponse, EncryptResponse, ExportResponse, GetAttributes, GetAttributesResponse, Hash, HashResponse, ImportResponse, LocateResponse, ModifyAttribute, ModifyAttributeResponse, Query, QueryResponse, - RevokeResponse, SetAttribute, SetAttributeResponse, Sign, SignResponse, - SignatureVerify, SignatureVerifyResponse, Validate, ValidateResponse, + ReKey, ReKeyResponse, RevokeResponse, SetAttribute, SetAttributeResponse, Sign, + SignResponse, SignatureVerify, SignatureVerifyResponse, Validate, ValidateResponse, }, kmip_types::{ AttributeReference, CryptographicAlgorithm, CryptographicParameters, @@ -2037,6 +2037,56 @@ pub fn parse_set_attribute_ttlv_response(response: &str) -> Result(response) } +/// Build a TTLV `ReKey` request for the given symmetric key UID. +#[wasm_bindgen] +pub fn rekey_ttlv_request(unique_identifier: String) -> Result { + let request = ReKey { + unique_identifier: Some(UniqueIdentifier::TextString(unique_identifier)), + ..ReKey::default() + }; + let objects = to_ttlv(&request).map_err(|e| JsValue::from(e.to_string()))?; + serde_wasm_bindgen::to_value(&objects).map_err(|e| JsValue::from(e.to_string())) +} + +/// Parse a TTLV `ReKey` response and return `{ UniqueIdentifier: string }`. +#[wasm_bindgen] +pub fn parse_rekey_ttlv_response(response: &str) -> Result { + parse_ttlv_response::(response) +} + +/// Build a TTLV `ReKeyKeyPair` request to re-key a Covercrypt access policy. +/// +/// `msk_id` is the unique identifier of the Covercrypt Master Secret Key. +/// `access_policy` is a boolean policy expression, e.g. +/// `"Department::HR && Security Level::Confidential"`. +/// +/// This is non-FIPS only because Covercrypt is a non-FIPS algorithm. +#[wasm_bindgen] +#[cfg(feature = "non-fips")] +#[allow(clippy::needless_pass_by_value)] +pub fn rekey_cc_keypair_ttlv_request( + msk_id: String, + access_policy: String, +) -> Result { + use cosmian_kms_client_utils::{ + cover_crypt_utils::build_rekey_cc_keypair_request, + reexport::cosmian_kmip::kmip_2_1::kmip_operations::ReKeyKeyPair, + }; + let vendor_id = get_vendor_id(); + let request: ReKeyKeyPair = build_rekey_cc_keypair_request(&vendor_id, &msk_id, &access_policy) + .map_err(|e| JsValue::from_str(&format!("Covercrypt rekey request failed: {e}")))?; + let objects = to_ttlv(&request).map_err(|e| JsValue::from(e.to_string()))?; + serde_wasm_bindgen::to_value(&objects).map_err(|e| JsValue::from(e.to_string())) +} + +/// Parse a TTLV `ReKeyKeyPairResponse` and return `{ PrivateKeyUniqueIdentifier, PublicKeyUniqueIdentifier }`. +#[wasm_bindgen] +#[cfg(feature = "non-fips")] +pub fn parse_rekey_cc_keypair_ttlv_response(response: &str) -> Result { + use cosmian_kms_client_utils::reexport::cosmian_kmip::kmip_2_1::kmip_operations::ReKeyKeyPairResponse; + parse_ttlv_response::(response) +} + #[wasm_bindgen] pub fn modify_attribute_ttlv_request( unique_identifier: String, diff --git a/documentation/docs/configuration/authentication.md b/documentation/docs/configuration/authentication.md index 272c8e16a2..f70fb80bf9 100644 --- a/documentation/docs/configuration/authentication.md +++ b/documentation/docs/configuration/authentication.md @@ -320,3 +320,100 @@ Note: the trailing `/` is required in the issuer URI. The JWKS URI will default ```sh --jwt-auth-provider="https://.com,https://.com/oauth2/v1/keys," ``` + +--- + +## Break-Glass / Local Authentication + +> **Operational best practice**: always configure TLS client certificate authentication +> in addition to any OIDC/JWT-based method. The certificate acts as a *local, +> out-of-band* authentication path that remains available even when the identity +> provider is unreachable or misconfigured. + +### Why you need a break-glass path + +OIDC-based authentication depends on an external identity provider (Google, Entra, +Auth0, Okta…). If that provider is: + +- temporarily unavailable (outage, maintenance window, DNS failure), +- misconfigured (wrong tenant, expired signing key in JWKS), +- not reachable from the KMS host (network segmentation, firewall rule change), + +then **no OIDC client can authenticate**, including the administrators who would +normally fix the misconfiguration. A locally-issued client certificate is entirely +self-contained and does not contact any external service at validation time, so it +provides an independent recovery path — the *break-glass* account. + +### Recommended setup + +1. **Issue a break-glass administrator client certificate** from a local CA that you + control. Keep the private key and certificate in a hardware token or an offline + vault rather than on the KMS host itself. + + ```bash + # Create a local CA (done once; store securely) + openssl genrsa -out local_ca.key 4096 + openssl req -x509 -new -nodes -key local_ca.key -sha256 \ + -days 3650 -out local_ca.crt \ + -subj "/CN=KMS Break-Glass CA" + + # Issue an administrator client certificate + openssl genrsa -out admin_breakglass.key 2048 + openssl req -new -key admin_breakglass.key \ + -out admin_breakglass.csr \ + -subj "/CN=kms-admin@yourcompany.com" + openssl x509 -req -in admin_breakglass.csr \ + -CA local_ca.crt -CAkey local_ca.key -CAcreateserial \ + -out admin_breakglass.crt -days 730 -sha256 + ``` + +2. **Start the KMS server with both authentication methods** enabled: + + ```toml + [tls] + tls_cert_file = "server.crt" + tls_key_file = "server.key" + clients_ca_cert_file = "local_ca.crt" # break-glass CA + + [idp_auth] + jwt_auth_provider = ["https://accounts.google.com,https://www.googleapis.com/oauth2/v3/certs,my-audience"] + ``` + + Most day-to-day logins will proceed via JWT; the client certificate path acts + as a silent fallback and is only used when a certificate is actually presented. + +3. **Use the break-glass certificate** from the `ckms` CLI when OIDC is unavailable: + + ```bash + ckms --url https://kms.example.com:9998 \ + --cert admin_breakglass.crt \ + --key admin_breakglass.key \ + sym keys list + ``` + +4. **Protect the break-glass material** as you would any privileged secret: + - Store the private key (`admin_breakglass.key`) in an HSM, air-gapped vault, or a + hardware token such as a YubiKey. + - Rotate the certificate before its expiry date. + - Audit its use via server logs — every request authenticated by certificate will + show the CN (`kms-admin@yourcompany.com` in the example) in the audit trail. + +### Emergency recovery steps + +If the primary OIDC authentication path fails: + +1. Present the break-glass certificate to verify connectivity: + + ```bash + curl --cert admin_breakglass.crt --key admin_breakglass.key \ + --cacert server_ca.crt \ + -X POST -H "Content-Type: application/json" -d '{}' \ + https://kms.example.com:9998/kmip/2_1 + # Expect HTTP 422 (not 401) — you are authenticated. + ``` + +2. Diagnose the OIDC configuration with `ckms` or direct API calls. +3. Fix the OIDC misconfiguration and restart or reload the server configuration. +4. Once OIDC is restored, log the break-glass access in your incident management + system and rotate the break-glass certificate if it was used in a potentially + compromised environment. diff --git a/documentation/docs/configuration/server_cli.md b/documentation/docs/configuration/server_cli.md index be282ae332..0a40276dfa 100644 --- a/documentation/docs/configuration/server_cli.md +++ b/documentation/docs/configuration/server_cli.md @@ -498,6 +498,12 @@ Options: [env: KMS_POLICY_ID=] + --auto-rotation-check-interval-secs + Interval in seconds between background auto-rotation checks. + Set to 0 (default) to disable the auto-rotation background task. + + [default: 0] + -h, --help Print help (see a summary with '-h') 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..0698018a40 --- /dev/null +++ b/documentation/docs/kmip_support/key_auto_rotation.md @@ -0,0 +1,324 @@ +# 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" +``` + +--- + +## 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\n(new UID, new material) + Active --> Deactivated : Revoke + Deactivated --> Destroyed : Destroy + Destroyed --> [*] + + note right of Active + Each arrow inside Active represents + one rotation cycle. Old key retains + a ReplacementObjectLink → new key. + New key carries ReplacedObjectLink → old key. + end note +``` + +**KMIP link chain after two successive rotations:** + +```mermaid +flowchart LR + K0["Key₀\n(original)"] -->|ReplacementObjectLink| K1["Key₁\n(1st rotation)"] + K1 -->|ReplacementObjectLink| K2["Key₂\n(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\n(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\n(unwrap → new material → re-wrap\nnew UID, same wrapping key) + Wrapped_Active --> Deactivated : Revoke + Deactivated --> Destroyed : Destroy + Destroyed --> [*] +``` + +--- + +### 4. Asymmetric key pair (private key) + +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\nNew PublicKey UID\n(linked to new private key) +``` + +--- + +## 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\n(new material, new UID)"] + DISPATCH -->|SymmetricKey + has dependants| WRAP_K["Wrapping-key rotation\n(Phase 1 → Phase 2 re-wrap)"] + DISPATCH -->|SymmetricKey + wrapped| WRAP_D["Wrapped-key rotation\n(unwrap → new material → re-wrap)"] + DISPATCH -->|PrivateKey| ASYM["ReKeyKeyPair"] + PLAIN --> META["Update metadata\n(generation++, date, links)"] + WRAP_K --> META + WRAP_D --> META + ASYM --> META + META --> OTEL["Increment\nkms.key.auto_rotation\nOTel 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 +``` + +--- + +## 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" +``` diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index cdd877a31e..731db691dd 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -171,5 +171,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 Policy: kmip_support/key_auto_rotation.md - Revoke: kmip_support/_revoke.md - Sign: kmip_support/_signature.md diff --git a/test_data b/test_data index bcfd2663db..5cc89a799f 160000 --- a/test_data +++ b/test_data @@ -1 +1 @@ -Subproject commit bcfd2663db67b7820d21575df887de94d68ce6b8 +Subproject commit 5cc89a799fa928c18262602991754c6491957492 diff --git a/ui/package.json b/ui/package.json index 6bdc85ba0c..356a850cf9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build:wasm": "cd .. && wasm-pack build crate/wasm --target web --release --features non-fips && node ui/scripts/sync-wasm.mjs", - "build": "tsc -b && vite build", + "build:react": "tsc -b && vite build", + "build": "pnpm run build:wasm && pnpm run build:react", "preview": "vite preview", "lint": "eslint . --fix", "format": "prettier . --write", @@ -45,7 +46,7 @@ "prettier": "^3.8.1", "typescript": "~5.8.3", "typescript-eslint": "^8.56.0", - "vite": "^7.3.1", + "vite": "^7.3.2", "vitest": "^3.2.4" }, "pnpm": { diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index a749b148aa..ef55e14ce9 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -23,7 +23,7 @@ importers: version: 6.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tailwindcss/vite': specifier: ^4.2.0 - version: 4.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3)) + version: 4.2.0(vite@7.3.2(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3)) antd: specifier: ^5.29.3 version: 5.29.3(moment@2.30.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -66,7 +66,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react-swc': specifier: ^3.11.0 - version: 3.11.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3)) + version: 3.11.0(vite@7.3.2(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3)) eslint: specifier: ^9.39.3 version: 9.39.3(jiti@2.6.1) @@ -95,8 +95,8 @@ importers: specifier: ^8.56.0 version: 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.8.3) vite: - specifier: ^7.3.1 - version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3) + specifier: ^7.3.2 + version: 7.3.2(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3) vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@25.3.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(yaml@2.8.3) @@ -511,56 +511,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.121.0': resolution: {integrity: sha512-qT663J/W8yQFw3dtscbEi9LKJevr20V7uWs2MPGTnvNZ3rm8anhhE16gXGpxDOHeg9raySaSHKhd4IGa3YZvuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': resolution: {integrity: sha512-mYNe4NhVvDBbPkAP8JaVS8lC1dsoJZWH5WCjpw5E+sjhk1R08wt3NnXYUzum7tIiWPfgQxbCMcoxgeemFASbRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': resolution: {integrity: sha512-+QiFoGxhAbaI/amqX567784cDyyuZIpinBrJNxUzb+/L2aBRX67mN6Jv40pqduHf15yYByI+K5gUEygCuv0z9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.121.0': resolution: {integrity: sha512-9ykEgyTa5JD/Uhv2sttbKnCfl2PieUfOjyxJC/oDL2UO0qtXOtjPLl7H8Kaj5G7p3hIvFgu3YWvAxvE0sqY+hQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.121.0': resolution: {integrity: sha512-DB1EW5VHZdc1lIRjOI3bW/wV6R6y0xlfvdVrqj6kKi7Ayu2U3UqUBdq9KviVkcUGd5Oq+dROqvUEEFRXGAM7EQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.121.0': resolution: {integrity: sha512-s4lfobX9p4kPTclvMiH3gcQUd88VlnkMTF6n2MTMDAyX5FPNRhhRSFZK05Ykhf8Zy5NibV4PbGR6DnK7FGNN6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.121.0': resolution: {integrity: sha512-P9KlyTpuBuMi3NRGpJO8MicuGZfOoqZVRP1WjOecwx8yk4L/+mrCRNc5egSi0byhuReblBF2oVoDSMgV9Bj4Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.121.0': resolution: {integrity: sha512-R+4jrWOfF2OAPPhj3Eb3U5CaKNAH9/btMveMULIrcNW/hjfysFQlF8wE0GaVBr81dWz8JLgQlsxwctoL78JwXw==} @@ -633,49 +625,41 @@ packages: resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==} cpu: [arm64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-arm64-musl@11.19.1': resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==} cpu: [arm64] os: [linux] - libc: [musl] '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==} cpu: [riscv64] os: [linux] - libc: [musl] '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==} cpu: [s390x] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-x64-gnu@11.19.1': resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==} cpu: [x64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.19.1': resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==} cpu: [x64] os: [linux] - libc: [musl] '@oxc-resolver/binding-openharmony-arm64@11.19.1': resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==} @@ -805,79 +789,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -932,28 +903,24 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.15.11': resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-x64-gnu@1.15.11': resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.15.11': resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.15.11': resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} @@ -1026,28 +993,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.0': resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.0': resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.0': resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.0': resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} @@ -1697,28 +1660,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -2332,8 +2291,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3173,12 +3132,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.0 '@tailwindcss/oxide-win32-x64-msvc': 4.2.0 - '@tailwindcss/vite@4.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3))': + '@tailwindcss/vite@4.2.0(vite@7.3.2(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3))': dependencies: '@tailwindcss/node': 4.2.0 '@tailwindcss/oxide': 4.2.0 tailwindcss: 4.2.0 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3) '@testing-library/dom@10.4.1': dependencies: @@ -3331,11 +3290,11 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.0 - '@vitejs/plugin-react-swc@3.11.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3))': + '@vitejs/plugin-react-swc@3.11.0(vite@7.3.2(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.15.11 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3) transitivePeerDependencies: - '@swc/helpers' @@ -3347,13 +3306,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3))': + '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3) '@vitest/pretty-format@3.2.4': dependencies: @@ -4622,7 +4581,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -4637,7 +4596,7 @@ snapshots: - tsx - yaml - vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3): + vite@7.3.2(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -4656,7 +4615,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -4674,7 +4633,7 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3) vite-node: 3.2.4(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 32fd4ce3ef..8b2a42529d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -9,37 +9,40 @@ import AttributeDeleteForm from "./actions/Attributes/AttributeDelete"; import AttributeGetForm from "./actions/Attributes/AttributeGet"; import AttributeModifyForm from "./actions/Attributes/AttributeModify"; import AttributeSetForm from "./actions/Attributes/AttributeSet"; -import { AuthProvider, useAuth } from "./contexts/AuthContext"; -import ExportAzureBYOKForm from "./actions/CloudProviders/AzureExportByok"; -import ImportAzureKekForm from "./actions/CloudProviders/AzureImportKek"; import CertificateCertifyForm from "./actions/Certificates/CertificateCertify"; import CertificateDecryptForm from "./actions/Certificates/CertificateDecrypt"; import CertificateEncryptForm from "./actions/Certificates/CertificateEncrypt"; import CertificateExportForm from "./actions/Certificates/CertificateExport"; import CertificateImportForm from "./actions/Certificates/CertificateImport"; import CertificateValidateForm from "./actions/Certificates/CertificateValidate"; +import AwsExportKeyMaterialForm from "./actions/CloudProviders/AwsExportKeyMaterial"; +import ImportAwsKekForm from "./actions/CloudProviders/AwsImportKek"; +import ExportAzureBYOKForm from "./actions/CloudProviders/AzureExportByok"; +import ImportAzureKekForm from "./actions/CloudProviders/AzureImportKek"; import CCDecryptForm from "./actions/Covercrypt/CovercryptDecrypt"; import CCEncryptForm from "./actions/Covercrypt/CovercryptEncrypt"; import CovercryptMasterKeyForm from "./actions/Covercrypt/CovercryptMasterKey"; +import CovercryptReKeyForm from "./actions/Covercrypt/CovercryptReKey"; import CovercryptUserKeyForm from "./actions/Covercrypt/CovercryptUserKey"; -import CseInfo from "./actions/Keys/CseInfo"; import ECDecryptForm from "./actions/EC/ECDecrypt"; import ECEncryptForm from "./actions/EC/ECEncrypt"; import ECKeyCreateForm from "./actions/EC/ECKeysCreate"; import ECSignForm from "./actions/EC/ECSign"; import ECVerifyForm from "./actions/EC/ECVerify"; +import CseInfo from "./actions/Keys/CseInfo"; +import DeriveKeyForm from "./actions/Keys/DeriveKey"; 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"; -import LocateForm from "./components/common/Locate"; -import LoginPage from "./pages/LoginPage"; -import MainLayout from "./components/layout/MainLayout"; -import NotFoundPage from "./pages/NotFoundPage"; import DestroyForm from "./actions/Objects/ObjectsDestroy"; import ObjectsOwnedList from "./actions/Objects/ObjectsOwned"; import RevokeForm from "./actions/Objects/ObjectsRevoke"; import OpaqueObjectForm from "./actions/Objects/OpaqueObject"; +import SecretDataCreateForm from "./actions/Objects/SecretDataCreate"; import PqcDecapsulateForm from "./actions/PQC/PqcDecapsulate"; import PqcEncapsulateForm from "./actions/PQC/PqcEncapsulate"; import PqcKeysCreateForm from "./actions/PQC/PqcKeysCreate"; @@ -50,17 +53,18 @@ import RsaEncryptForm from "./actions/RSA/RsaEncrypt"; import RsaKeyCreateForm from "./actions/RSA/RsaKeysCreate"; import RsaSignForm from "./actions/RSA/RsaSign"; import RsaVerifyForm from "./actions/RSA/RsaVerify"; -import SecretDataCreateForm from "./actions/Objects/SecretDataCreate"; -import SymKeyCreateForm from "./actions/Keys/SymKeysCreate"; import SymmetricDecryptForm from "./actions/Symmetric/SymmetricDecrypt"; import SymmetricEncryptForm from "./actions/Symmetric/SymmetricEncrypt"; import SymmetricHashForm from "./actions/Symmetric/SymmetricHash"; +import LocateForm from "./components/common/Locate"; +import MainLayout from "./components/layout/MainLayout"; +import { AuthProvider, useAuth } from "./contexts/AuthContext"; import { useBranding } from "./contexts/useBranding"; +import LoginPage from "./pages/LoginPage"; +import NotFoundPage from "./pages/NotFoundPage"; +import SymKeysReKeyForm from "./SymKeysReKey"; import { AuthMethod, fetchAuthMethod, fetchIdToken, getNoTTLVRequest } from "./utils/utils"; import init, * as wasmModule from "./wasm/pkg"; -import ImportAwsKekForm from "./actions/CloudProviders/AwsImportKek"; -import AwsExportKeyMaterialForm from "./actions/CloudProviders/AwsExportKeyMaterial"; -import DeriveKeyForm from "./actions/Keys/DeriveKey"; type AppContentProps = { isDarkMode: boolean; @@ -196,6 +200,8 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> + } /> + } /> } /> } /> } /> @@ -206,6 +212,8 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> + } /> + } /> } /> } /> } /> @@ -217,6 +225,8 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> + } /> + } /> } /> } /> } /> @@ -228,6 +238,8 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> + } /> + } /> } /> } /> } /> @@ -245,6 +257,8 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/SymKeysReKey.tsx b/ui/src/SymKeysReKey.tsx new file mode 100644 index 0000000000..74872d35e9 --- /dev/null +++ b/ui/src/SymKeysReKey.tsx @@ -0,0 +1,8 @@ +// This file is kept for backward compatibility. +// The implementation has been moved to ./actions/Keys/KeysReKey.tsx. +import React from "react"; +import KeysReKeyForm from "./actions/Keys/KeysReKey"; + +const SymKeysReKeyForm: React.FC = () => ; + +export default SymKeysReKeyForm; diff --git a/ui/src/actions/Attributes/AttributeGet.tsx b/ui/src/actions/Attributes/AttributeGet.tsx index d5bc393f3d..8e8c974f10 100644 --- a/ui/src/actions/Attributes/AttributeGet.tsx +++ b/ui/src/actions/Attributes/AttributeGet.tsx @@ -1,7 +1,7 @@ import { Button, Card, Form, Input, Select, Space, Typography } from "antd"; import React, { useEffect, useRef, useState } from "react"; -import { useAuth } from "../../contexts/AuthContext"; import HashMapDisplay from "../../components/common/HashMapDisplay"; +import { useAuth } from "../../contexts/AuthContext"; import { sendKmipRequest } from "../../utils/utils"; import { get_attributes_ttlv_request, parse_get_attributes_ttlv_response } from "../../wasm/pkg/cosmian_kms_client_wasm"; @@ -65,6 +65,30 @@ const ATTRIBUTE_NAMES = [ value: "child_id", label: "Child ID", }, + { + value: "rotate_interval", + label: "Rotate Interval", + }, + { + value: "rotate_name", + label: "Rotate Name", + }, + { + value: "rotate_offset", + label: "Rotate Offset", + }, + { + value: "rotate_date", + label: "Rotate Date", + }, + { + value: "rotate_generation", + label: "Rotate Generation", + }, + { + value: "rotate_latest", + label: "Rotate Latest", + }, ]; interface AttributeGetFormData { diff --git a/ui/src/actions/Certificates/CertificateCertify.tsx b/ui/src/actions/Certificates/CertificateCertify.tsx index c02b1f6a94..0683f13d51 100644 --- a/ui/src/actions/Certificates/CertificateCertify.tsx +++ b/ui/src/actions/Certificates/CertificateCertify.tsx @@ -1,11 +1,13 @@ import { Button, Card, Checkbox, Form, Input, Radio, RadioChangeEvent, Select, Space } from "antd"; import React, { useEffect, useRef, useState } from "react"; -import { useAuth } from "../../contexts/AuthContext"; import { FormUploadDragger } from "../../components/common/FormUpload"; +import { useAuth } from "../../contexts/AuthContext"; import { sendKmipRequest } from "../../utils/utils"; import * as wasm from "../../wasm/pkg"; +import { parse_set_attribute_ttlv_response, set_attribute_ttlv_request } from "../../wasm/pkg/cosmian_kms_client_wasm"; +import RotationPolicyFields, { type RotationPolicyFormValues, applyRotationPolicy } from "../Keys/RotationPolicyFields"; -interface CertificateCertifyFormData { +interface CertificateCertifyFormData extends RotationPolicyFormValues { certificateId?: string; certificateSigningRequest?: Uint8Array; csrFormat: "pem" | "der"; @@ -88,7 +90,19 @@ const CertificateCertifyForm: React.FC = () => { const result_str = await sendKmipRequest(request, idToken, serverUrl); if (result_str) { const response = await wasm.parse_certify_ttlv_response(result_str); - setRes(`Certificate successfully created with ID: ${response.UniqueIdentifier}`); + const certId = response.UniqueIdentifier as string; + await applyRotationPolicy( + certId, + values.rotateInterval, + values.rotateName, + values.rotateOffset, + sendKmipRequest, + parse_set_attribute_ttlv_response, + set_attribute_ttlv_request, + idToken, + serverUrl, + ); + setRes(`Certificate successfully created with ID: ${certId}`); } } catch (e) { setRes(`Error certifying certificate: ${e}`); @@ -301,6 +315,10 @@ const CertificateCertifyForm: React.FC = () => { + + + + + + + + {res && ( +
+ {res} +
+ )} + + ); +}; + +export default CovercryptReKeyForm; diff --git a/ui/src/actions/EC/ECKeysCreate.tsx b/ui/src/actions/EC/ECKeysCreate.tsx index 84263d279a..11c190fce9 100644 --- a/ui/src/actions/EC/ECKeysCreate.tsx +++ b/ui/src/actions/EC/ECKeysCreate.tsx @@ -3,8 +3,10 @@ import React, { useEffect, useRef, useState } from "react"; import { useAuth } from "../../contexts/AuthContext"; import { sendKmipRequest } from "../../utils/utils"; import * as wasm from "../../wasm/pkg"; +import { parse_set_attribute_ttlv_response, set_attribute_ttlv_request } from "../../wasm/pkg/cosmian_kms_client_wasm"; +import RotationPolicyFields, { type RotationPolicyFormValues, applyRotationPolicy } from "../Keys/RotationPolicyFields"; -interface ECKeyCreateFormData { +interface ECKeyCreateFormData extends RotationPolicyFormValues { privateKeyId?: string; curve: string; tags: string[]; @@ -65,6 +67,18 @@ 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); + // Apply rotation policy on the private key if specified + await applyRotationPolicy( + result.PrivateKeyUniqueIdentifier, + values.rotateInterval, + values.rotateName, + values.rotateOffset, + sendKmipRequest, + parse_set_attribute_ttlv_response, + set_attribute_ttlv_request, + idToken, + serverUrl, + ); setRes( `Key pair has been created. Private key Id: ${result.PrivateKeyUniqueIdentifier} - Public key Id: ${result.PublicKeyUniqueIdentifier}`, ); @@ -135,6 +149,10 @@ const ECKeyCreateForm: React.FC = () => {
+ + + + + + + + {res && ( +
+ {res} +
+ )} + + ); +}; + +export default KeysReKeyForm; diff --git a/ui/src/actions/Keys/RotationPolicyFields.tsx b/ui/src/actions/Keys/RotationPolicyFields.tsx new file mode 100644 index 0000000000..6beea33c9e --- /dev/null +++ b/ui/src/actions/Keys/RotationPolicyFields.tsx @@ -0,0 +1,85 @@ +import { Collapse, Form, Input, InputNumber } from "antd"; +import React from "react"; + +const { Panel } = Collapse; + +/** + * A collapsible form section that adds optional rotation policy fields. + * + * When the user expands the panel, they can set: + * - Rotation Interval (seconds) — triggers automatic re-keying when due + * - Rotation Name — optional label to identify the lineage + * - Rotation Offset (seconds) — delay before the first rotation + * + * These fields map to the `rotate_interval`, `rotate_name`, and `rotate_offset` + * KMIP attributes that are applied via `SetAttribute` after the object is created. + */ +const RotationPolicyFields: React.FC = () => ( + + + + + + + + + + + + + + + +); + +export default RotationPolicyFields; + +/** Fields contributed by the rotation policy panel. */ +export interface RotationPolicyFormValues { + rotateInterval?: number; + rotateName?: string; + rotateOffset?: number; +} + +/** + * Apply rotation policy attributes to an already-created object via sequential + * `set_attribute_ttlv_request` calls. Only attributes that are set are sent. + */ +export async function applyRotationPolicy( + keyId: string, + rotateInterval: number | undefined, + rotateName: string | undefined, + rotateOffset: number | undefined, + sendRequest: (request: object, idToken: string | null, serverUrl: string) => Promise, + parseSetResponse: (response: string) => unknown, + setAttributeRequest: (objectId: string, attrName: string, attrValue: string) => object, + idToken: string | null, + serverUrl: string, +): Promise { + const attrs: Array<[string, string]> = []; + if (rotateInterval != null && rotateInterval > 0) { + attrs.push(["rotate_interval", String(Math.round(rotateInterval))]); + } + if (rotateName) { + attrs.push(["rotate_name", rotateName]); + } + if (rotateOffset != null && rotateOffset > 0) { + attrs.push(["rotate_offset", String(Math.round(rotateOffset))]); + } + + for (const [attrName, attrValue] of attrs) { + const req = setAttributeRequest(keyId, attrName, attrValue); + const result = await sendRequest(req, idToken, serverUrl); + if (result) { + parseSetResponse(result); + } + } +} diff --git a/ui/src/actions/Keys/SetRotationPolicy.tsx b/ui/src/actions/Keys/SetRotationPolicy.tsx new file mode 100644 index 0000000000..d79957ebaa --- /dev/null +++ b/ui/src/actions/Keys/SetRotationPolicy.tsx @@ -0,0 +1,134 @@ +import { Button, Card, Form, Input, InputNumber, Space } from "antd"; +import React, { useEffect, useRef, useState } from "react"; +import { useAuth } from "../../contexts/AuthContext"; +import { ObjectType, getObjectLabel, sendKmipRequest } from "../../utils/utils"; +import { parse_set_attribute_ttlv_response, set_attribute_ttlv_request } from "../../wasm/pkg/cosmian_kms_client_wasm"; + +interface SetRotationPolicyFormData { + objectId: string; + rotateInterval?: number; + rotateName?: string; + rotateOffset?: number; +} + +interface SetRotationPolicyProps { + objectType: ObjectType; +} + +const SetRotationPolicyForm: React.FC = ({ objectType }) => { + const [form] = Form.useForm(); + const [res, setRes] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const { idToken, serverUrl } = useAuth(); + const responseRef = useRef(null); + const label = getObjectLabel(objectType); + + useEffect(() => { + if (res && responseRef.current) { + responseRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [res]); + + const onFinish = async (values: SetRotationPolicyFormData) => { + setIsLoading(true); + setRes(undefined); + + const attrsToSet: Array<[string, string]> = []; + if (values.rotateInterval != null) { + attrsToSet.push(["rotate_interval", String(Math.round(values.rotateInterval))]); + } + if (values.rotateName) { + attrsToSet.push(["rotate_name", values.rotateName]); + } + if (values.rotateOffset != null) { + attrsToSet.push(["rotate_offset", String(Math.round(values.rotateOffset))]); + } + + if (attrsToSet.length === 0) { + setRes("No rotation policy attributes specified. Please fill in at least one field."); + setIsLoading(false); + return; + } + + try { + const updates: string[] = []; + for (const [attrName, attrValue] of attrsToSet) { + const request = set_attribute_ttlv_request(values.objectId, attrName, attrValue); + const result_str = await sendKmipRequest(request, idToken, serverUrl); + if (result_str) { + const response = parse_set_attribute_ttlv_response(result_str); + updates.push(`${attrName}=${attrValue} set on ${response.UniqueIdentifier}`); + } + } + setRes(`Rotation policy updated for ${label} ${values.objectId}: ${updates.join(", ")}`); + } catch (e) { + setRes(`Error setting rotation policy: ${e}`); + console.error("Error setting rotation policy:", e); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Set {label} rotation policy

+ +
+

Configure automatic rotation for a {label}:

+
    +
  • + Interval: time in seconds between automatic re-keys (set to 0 to disable rotation). +
  • +
  • + Name: optional label to identify this rotation policy. +
  • +
  • + Offset: delay in seconds before the first rotation. +
  • +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + {res && ( +
+ {res} +
+ )} +
+ ); +}; + +export default SetRotationPolicyForm; diff --git a/ui/src/actions/Keys/SymKeysCreate.tsx b/ui/src/actions/Keys/SymKeysCreate.tsx index b09698524e..146542b71c 100644 --- a/ui/src/actions/Keys/SymKeysCreate.tsx +++ b/ui/src/actions/Keys/SymKeysCreate.tsx @@ -3,8 +3,10 @@ import React, { useEffect, useRef, useState } from "react"; import { useAuth } from "../../contexts/AuthContext"; import { sendKmipRequest } from "../../utils/utils"; import * as wasm from "../../wasm/pkg"; +import { parse_set_attribute_ttlv_response, set_attribute_ttlv_request } from "../../wasm/pkg/cosmian_kms_client_wasm"; +import RotationPolicyFields, { type RotationPolicyFormValues, applyRotationPolicy } from "./RotationPolicyFields"; -interface SymKeyCreateFormData { +interface SymKeyCreateFormData extends RotationPolicyFormValues { keyId?: string; algorithm: string; // options provided by WASM get_symmetric_algorithms() numberOfBits?: number; @@ -59,7 +61,20 @@ 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); - setRes(`${result.UniqueIdentifier} has been created.`); + const keyId = result.UniqueIdentifier; + // Apply rotation policy attributes if specified + await applyRotationPolicy( + keyId, + values.rotateInterval, + values.rotateName, + values.rotateOffset, + sendKmipRequest, + parse_set_attribute_ttlv_response, + set_attribute_ttlv_request, + idToken, + serverUrl, + ); + setRes(`${keyId} has been created.`); } } catch (e) { setRes(`Error creating key: ${e}`); @@ -128,6 +143,10 @@ const SymKeyCreateForm: React.FC = () => {
+ + + +