diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f3f9bf417b..834f4f184d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -64,6 +64,7 @@ No external OpenSSL needed — `crate/crypto/build.rs` downloads and builds Open | `/standards-review` | Verify code against exact text of applicable standards | | `/kmip-compliance` | When adding/modifying a KMIP operation | | `/rust-patterns` | Rust design patterns for this codebase | +| `/rust-simplify` | Find simplification opportunities in Rust code | | `/react-ant-patterns` | UI coding conventions | | `/kms-changelog` | Writing the branch CHANGELOG entry | | `/threat-model` | STRIDE-A threat model | diff --git a/.github/reusable_scripts b/.github/reusable_scripts index 27958a96a0..5216e05f11 160000 --- a/.github/reusable_scripts +++ b/.github/reusable_scripts @@ -1 +1 @@ -Subproject commit 27958a96a092ebb9d5340fddd5b5f72095a8e009 +Subproject commit 5216e05f11e37c472d75dac40818ea9e02c857dc diff --git a/.github/skills/README.md b/.github/skills/README.md index 4d82e2d84d..adafb9e666 100644 --- a/.github/skills/README.md +++ b/.github/skills/README.md @@ -56,6 +56,7 @@ Team-wide GitHub Copilot skills for the KMS repository. | **Code Quality** | `/code-quality [path]` | **Orchestrates** `/rust-refactor`, `/rust-patterns`, Clippy hygiene, and `/ci-efficiency`. Produces a ranked report of blocking items and high-impact improvements. | | Refactor Plan | `/refactor-plan` | Investigate a refactor, produce a phased plan with cargo verification steps. Wait for confirmation before implementing. | | Rust Refactor | `/rust-refactor` | Find duplication in Rust code and consolidate with Traits, Generics, macros. Ranked impact/risk plan before touching code. | +| Rust Simplify | `/rust-simplify [path]` | Find simplification opportunities: nested control flow, long functions, dead code, bool param traps, iterator anti-patterns, and Clippy-flagged complexity. Ranked list before touching code. | | Rust Patterns | `/rust-patterns` | KMS-specific Rust design patterns: newtype, builder, command, trait abstraction, key lifecycle state machine. | | CI Efficiency | `/ci-efficiency` | Audit GitHub Actions workflows for waste (missing caches, over-broad triggers, no concurrency cancellation). | diff --git a/.github/skills/rust-simplify/SKILL.md b/.github/skills/rust-simplify/SKILL.md new file mode 100644 index 0000000000..210a7bf222 --- /dev/null +++ b/.github/skills/rust-simplify/SKILL.md @@ -0,0 +1,129 @@ +--- +name: rust-simplify +description: Scan Rust code for simplification opportunities — nested control flow, long functions, dead code, boolean param traps, redundant iterator chains, and Clippy-flagged complexity. Use when asked to simplify code, reduce cognitive complexity, clean up code, or find over-engineering in Rust. +--- + +# Rust Simplify + +Find and eliminate unnecessary complexity in Rust code. Complements `/rust-refactor` (duplication) +and `/code-quality` (full audit) by targeting **complexity**, not duplication. + +**Usage**: `/rust-simplify` (current file / recent diff) or `/rust-simplify crate/server/src/core/` + +--- + +## Phase 1 — Scan + +Run against the target path (default: files from `git diff --name-only`). + +### 1a Clippy complexity lints + +```bash +cargo clippy-all 2>&1 | grep -E "cognitive_complexity|too_many_arguments|too_many_lines|needless_|dead_code|unused_" +``` + +### 1b Long functions (> 50 lines) + +```bash +rg -c "^\s+(pub |pub\(crate\) )?fn " --type rust # files with many fns +# Then inspect flagged files for individual functions exceeding 50 lines +``` + +### 1c Deep nesting (≥ 5 levels) + +```bash +rg -n "^ {20,}" --type rust | head -30 +``` + +### 1d Boolean param traps + +```bash +rg -n "fn [a-z_]+\([^)]*bool[^)]*bool" --type rust +``` + +### 1e Iterator anti-patterns + +```bash +rg -n "for .+ in .+\.(iter|iter_mut)\(\)" --type rust | head -20 +``` + +### 1f Redundant unwraps / sentinel values + +```bash +rg -n '\.unwrap\(\)|\.expect\(' --type rust +``` + +--- + +## Phase 2 — Classify + +| Smell | Pattern | +|-------|---------| +| Nested `if`/`match` (depth ≥ 3) | Early return, `let-else`, `?` | +| Function > 50 lines | Extract named private helper | +| Multiple `bool` params per fn | Replace with `pub(crate) enum` | +| Dead / unused item | Delete; cross-crate check first | +| Manual `for` → collect | Iterator chain (`map`, `filter`, `fold`) | +| Sentinel value (–1, `""`, 0) | `Option` or `Result` | +| `#[cfg(feature)]` inside fn body | Hoist to function / module level | +| `Arc>` with immutable `T` | `Arc` | + +--- + +## Phase 3 — Prioritize + +Present this ranked list to the user **before touching any code**: + +```text +[BLOCKING] unwrap_used / expect_used — cardinal rule violation +[HIGH] Function > 100 lines — extract helper(s) +[HIGH] Nesting depth ≥ 5 — invert guard clause +[MED] Bool param trap — define enum +[MED] Dead code — delete after cross-crate check +[LOW] Manual loop → iterator — cosmetic, improves readability +``` + +--- + +## Phase 4 — Implement (one finding at a time) + +**Early return / `let-else`** + +```rust +// Before +if let Some(x) = opt { use(x) } else { return Err(e) } +// After +let Some(x) = opt else { return Err(e) }; +``` + +**Extract helper** — name it by *what it does*, not by what calls it. Add a `#[cfg(test)]` unit test. + +**Bool trap → enum** — replace `bool` parameter with a named enum: + +1. Define `pub(crate) enum FlagName { Yes, No }` near the call site. +2. Update every call site before removing the `bool` param. + +**Dead code removal** — always run `rg "item_name" --type rust` across all crates before deleting. + +After each file: `cargo clippy-all && cargo fmt --all` +After each crate: `cargo test -p ` + +--- + +## Phase 5 — Verify + +```bash +cargo clippy-all # zero warnings +cargo fmt --all # no drift +cargo test -p # narrowest scope covering the change +git diff --stat # every hunk explainable by the task +``` + +--- + +## Quick Rules + +- Keep every function ≤ 50 lines after simplification. +- Never remove a `pub` item without `rg "item_name" --type rust` across all crates. +- One logical simplification per commit; never mix with feature work. +- Add a `//` comment when a non-obvious simplification changes observable behaviour. diff --git a/.github/workflows/main_base.yml b/.github/workflows/main_base.yml index 4926de8d42..323b6d749d 100644 --- a/.github/workflows/main_base.yml +++ b/.github/workflows/main_base.yml @@ -31,7 +31,7 @@ jobs: with: toolchain: ${{ inputs.toolchain }} - log-index-check: + log-reference: name: Log index — log-reference.md in sync with source runs-on: ubuntu-latest steps: @@ -41,7 +41,7 @@ jobs: - name: Check log-reference.md is up to date id: check - run: python3 scripts/update_log_index.py --check --no-color + run: python3 .mise/scripts/docs/update_log_index.py --check --no-color - name: How to fix if: failure() @@ -52,7 +52,7 @@ jobs: echo "" echo " Fix it locally by running:" echo "" - echo " python3 scripts/update_log_index.py --non-interactive --no-color" + echo " python3 .mise/scripts/docs/update_log_index.py --non-interactive --no-color" echo "" echo " Then review the diff, stage, and commit:" echo "" diff --git a/.mise/scripts/docs/gen_vector_readme.py b/.mise/scripts/docs/gen_vector_readme.py new file mode 100755 index 0000000000..2c56395744 --- /dev/null +++ b/.mise/scripts/docs/gen_vector_readme.py @@ -0,0 +1,684 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Regenerate crate/test_kms_server/README.md from all test_data/vectors/ manifest.toml files. + +Usage: + python3 .mise/scripts/docs/gen_vector_readme.py + +Outputs: crate/test_kms_server/README.md (overwritten in place) +""" +import os +import sys +import tomllib +from collections import OrderedDict +from pathlib import Path + +# Resolve repo root (script lives at .mise/scripts/docs/) +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent +BASE = REPO_ROOT / 'test_data' / 'vectors' +OUTPUT = REPO_ROOT / 'crate' / 'test_kms_server' / 'README.md' + +if not BASE.exists(): + sys.exit(f"ERROR: vectors directory not found: {BASE}") + + +# ─── Read all vectors ──────────────────────────────────────────────────────── +vectors = [] +for root, _dirs, files in os.walk(BASE): + if 'manifest.toml' in files: + rel = os.path.relpath(root, BASE) + manifest_path = os.path.join(root, 'manifest.toml') + with open(manifest_path, 'rb') as f: + manifest = tomllib.load(f) + name = manifest.get('name', os.path.basename(rel)) + desc = manifest.get('description', '').strip().split('\n')[0] # First line only + steps = len(manifest.get('steps', [])) + vectors.append( + { + 'path': rel, + 'name': name, + 'description': desc, + 'steps': steps, + 'manifest': manifest, + } + ) + +vectors.sort(key=lambda v: v['path']) +total = len(vectors) + + +# ─── Classification helpers ────────────────────────────────────────────────── +PQC_PREFIXES = ('ml_dsa_', 'ml_kem_', 'slh_dsa_') + + +def is_pqc(path): + basename = os.path.basename(path) + return any(basename.startswith(p) for p in PQC_PREFIXES) + + +# ─── Categorize: main table vs KAT (separate section) ─────────────────────── +main_vectors = [v for v in vectors if not v['path'].startswith('kat/')] +kat_vectors = [v for v in vectors if v['path'].startswith('kat/')] + +categories = OrderedDict() +categories['Symmetric'] = [] +categories['Asymmetric'] = [] +categories['PQC'] = [] +categories['KMIP Operations'] = [] +categories['Serialization'] = [] +categories['K8s Plugin'] = [] +categories['Access Control'] = [] +categories['HSM'] = [] +categories['Integrations'] = [] +categories['TLS'] = [] +categories['OPA'] = [] +categories['Negative'] = [] +categories['non-FIPS CryptographicParameters'] = [] +categories['Keyset Resolution'] = [] + +for v in main_vectors: + path = v['path'] + parts = path.split('/') + basename = os.path.basename(path) + + if parts[0] == 'fips': + if parts[1] == 'symmetric': + categories['Symmetric'].append(v) + elif parts[1] == 'asymmetric': + if is_pqc(path) and 'export' not in basename: + categories['PQC'].append(v) + else: + categories['Asymmetric'].append(v) + elif parts[1] == 'kmip_operations': + if 'keyset' in basename and not basename.startswith('rekey'): + categories['Keyset Resolution'].append(v) + else: + categories['KMIP Operations'].append(v) + elif parts[1] == 'integrations': + categories['Integrations'].append(v) + elif parts[1] == 'k8s_plugin': + categories['K8s Plugin'].append(v) + elif parts[1] == 'serialization': + categories['Serialization'].append(v) + elif parts[0] == 'access_control': + categories['Access Control'].append(v) + elif parts[0] == 'hsm': + categories['HSM'].append(v) + elif parts[0] == 'negative': + if 'keyset' in basename or 'rekey_non_latest' in basename: + categories['Keyset Resolution'].append(v) + else: + categories['Negative'].append(v) + elif parts[0] == 'non-fips': + if len(parts) > 1 and parts[1] == 'integrations': + categories['Integrations'].append(v) + elif 'rekey_keypair' in basename: + categories['KMIP Operations'].append(v) + else: + categories['non-FIPS CryptographicParameters'].append(v) + elif parts[0] == 'tls': + categories['TLS'].append(v) + elif parts[0] == 'opa': + categories['OPA'].append(v) + + +# ─── Display path formatter ───────────────────────────────────────────────── +def display_path(v): + path = v['path'] + parts = path.split('/') + # Short names for fips subcategories and access_control + if parts[0] == 'fips' and parts[1] in ( + 'symmetric', + 'asymmetric', + 'pqc', + 'kmip_operations', + 'k8s_plugin', + 'serialization', + ): + return os.path.basename(path) + if parts[0] == 'access_control': + return os.path.basename(path) + return path + + +# ─── HSM subcategory label ────────────────────────────────────────────────── +def hsm_sub(v): + path = v['path'] + parts = path.split('/') + if 'permissions' in parts: + return 'HSM / Permissions' + if len(parts) > 1: + name = parts[1] + if name.startswith('kek'): + if 'bootstrap' in name: + return 'HSM / KEK Bootstrap' + if 'rekey' in name: + return 'HSM / KEK ReKey' + if 'rsa1024' in name: + return 'HSM / KEK Negative' + if 'create' in name or 'sign' in name: + return 'HSM / KEK Create' + return 'HSM / KEK' + if name.startswith('resident'): + if 'rejected' in name: + return 'HSM / Resident Negative' + if 'keyset' in name: + return 'HSM / Resident Keyset' + if 'sign' in name: + return 'HSM / Resident Sign' + if 'encrypt' in name: + return 'HSM / Resident Encrypt' + return 'HSM / Resident Create' + if name.startswith('hsm_resident'): + return 'HSM / KEK Baseline' + if name in ('wrong_prefix', 'no_kek_baseline'): + return 'HSM / Negative' + return 'HSM' + + +# ─── Negative subcategory label ───────────────────────────────────────────── +_NEG_NAME_MAP = { + 'crypto_params': 'CryptoParams', + 'decrypt': 'Decrypt', + 'rsa': 'RSA', + 'sign_verify': 'Sign', + 'sign': 'Sign', + 'mac': 'MAC', + 'mac_verify': 'MAC', + 'hash': 'Hash', + 'derive_key': 'DeriveKey', + 'lifecycle': 'Lifecycle', + 'type_mismatch': 'TypeMismatch', + 'activate': 'Activate', + 'add_attribute': 'AddAttribute', + 'certify': 'Certify', + 'check': 'Check', + 'create': 'Create', + 'create_key_pair': 'CreateKeyPair', + 'delete_attribute': 'DeleteAttribute', + 'destroy': 'Destroy', + 'encrypt': 'Encrypt', + 'export': 'Export', + 'get': 'Get', + 'get_attribute_list': 'GetAttributeList', + 'get_attributes': 'GetAttributes', + 'import': 'Import', + 'modify_attribute': 'ModifyAttribute', + 'register': 'Register', + 'revoke': 'Revoke', + 'set_attribute': 'SetAttribute', + 'signature_verify': 'SignatureVerify', + 'validate': 'Validate', +} + + +def neg_sub(v): + parts = v['path'].split('/') + if len(parts) == 2: + return 'Negative / Protocol' + return ( + f"Negative / {_NEG_NAME_MAP.get(parts[1], parts[1].replace('_', ' ').title())}" + ) + + +# ─── KAT helpers ──────────────────────────────────────────────────────────── +def kat_info(v): + """Extract operations summary and assert fields from manifest.""" + manifest = v['manifest'] + steps = manifest.get('steps', []) + ops = [s.get('operation', '') for s in steps] + ops_str = ', '.join(ops) + assert_fields = [] + for s in steps: + for k in s.get('assert_fields', {}): + if k not in assert_fields: + assert_fields.append(k) + assert_str = ', '.join(f"`{f}`" for f in assert_fields) if assert_fields else '' + return ops_str, assert_str + + +def kat_reference(basename): + """Determine the standard reference for a KAT vector by its directory name.""" + if 'hkdf' in basename: + return 'RFC 5869 §A.1' + if 'pbkdf2' in basename: + return 'RFC 8018 §5.2' + if 'ed25519' in basename: + return 'RFC 8032 §7.1' + if 'ed448' in basename: + return 'RFC 8032 §7.4' + if 'rsa2048' in basename: + return 'NIST PKCS#1 v2.2' + if 'secp256k1' in basename: + return 'RFC 6979 §A.2.5' + if 'covercrypt' in basename: + return 'Self-generated USK' + if 'hmac_sha1' in basename: + return 'RFC 2202 §3' + if 'hmac_sha3_' in basename: + return 'NIST HMAC-SHA3' + if 'hmac_sha' in basename: + return 'RFC 4231 §4.2' + if 'gcm_siv' in basename: + return 'RFC 8452 §C.1' + if 'rfc3394' in basename: + return 'RFC 3394 §2.2.3' + if 'rfc5649' in basename: + return 'RFC 5649 §6' + if 'chacha20_poly1305' in basename: + return 'RFC 8439 §2.8' + if 'chacha20' in basename: + return 'RFC 7539 §2.1' + if 'xts' in basename: + return 'IEEE 1619-2007' + if 'gcm' in basename: + return 'SP 800-38D TC7' + if 'ecb' in basename: + return 'SP 800-38A' + if 'cbc' in basename: + return 'SP 800-38A' + if 'sha3' in basename: + return 'FIPS 202' + if any(x in basename for x in ('sha256', 'sha384', 'sha512')): + return 'FIPS 180-4' + return '' + + +# ═══════════════════════════════════════════════════════════════════════════════ +# GENERATE README +# ═══════════════════════════════════════════════════════════════════════════════ +out = [] + +# ─── Header ───────────────────────────────────────────────────────────────── +out.append( + """# test_kms_server — Vector Runner & Test Infrastructure + +This crate provides the **vector runner** for TTLV-JSON regression tests and +utilities for starting isolated KMS server instances in tests. + +## Running Vectors + +```bash +# All vectors (non-FIPS mode includes both FIPS and non-FIPS vectors) +cargo test -p test_kms_server --features non-fips --lib vector_runner + +# Single vector +cargo test -p test_kms_server --features non-fips --lib -- test_vec_aes_create_get + +# Record responses (writes step*_response.json files) +RECORD_VECTORS=1 cargo test -p test_kms_server --features non-fips --lib vector_runner + +# PostgreSQL backend (requires docker compose up -d) +KMS_TEST_DB=postgresql cargo test -p test_kms_server --features non-fips --lib vector_runner + +# Multiple backends at once +KMS_TEST_BACKENDS=sqlite,postgresql cargo test -p test_kms_server --features non-fips --lib vector_runner +``` + +## Multi-Backend Testing + +The vector runner supports testing against multiple database backends. + +### How it works + +1. Each vector runs against **all four backends** by default (`sqlite`, + `postgresql`, `mysql`, `redis-findex`) — no per-manifest `backends` field needed. +2. The runner reads `KMS_TEST_BACKENDS` (comma-separated) or `KMS_TEST_DB` (single + value, used by CI) to select which backends to test. +3. Backends without their required connection env var are **skipped gracefully**. +4. A **singleton server per backend** (`OnceCell`) is shared across all vectors in + a test run — no per-test server start/stop overhead. +5. Vectors with a custom `server_config` (e.g. cert_auth, TLS) start a dedicated + server instance instead of using the singleton. + +### Backend → config mapping + +| Backend | Config TOML | Required env var | +| -------------- | ------------------- | ------------------------------- | +| `sqlite` | `auth_plain.toml` | — (always available) | +| `postgresql` | `postgres.toml` | `KMS_POSTGRES_URL` | +| `mysql` | `mysql.toml` | `KMS_MYSQL_URL` | +| `redis-findex` | `redis_findex.toml` | `KMS_REDIS_URL` or `REDIS_HOST` | + +### CI integration + +CI scripts set `KMS_TEST_DB` to select a single backend: + +- `test_sqlite.sh` → (default, no env var) +- `test_psql.sh` → `KMS_TEST_DB=postgresql` +- `test_mysql.sh` → `KMS_TEST_DB=mysql` +- `test_redis.sh` → `KMS_TEST_DB=redis` + +--- + +## Regression Test Vectors (TTLV-JSON) + +All regression vectors use a uniform **TTLV-JSON** format. Each vector is a directory +under `test_data/vectors/` containing a `manifest.toml` and one JSON step file +per KMIP operation. The vector runner uses singleton shared servers and +replays the steps sequentially. +""" +) + +# ─── Main table ───────────────────────────────────────────────────────────── +cat_count = sum(1 for v in categories.values() if v) +out.append(f"**{total} vectors** across {cat_count + 1} categories (including KAT):\n") +out.append('| Category | Vector Directory Name | KMIP Operations | Steps |') +out.append('|----------|-----------------------|-----------------|-------|') + +# Symmetric +out.append('| **Symmetric** | | | |') +for v in categories['Symmetric']: + out.append( + f"| Symmetric | `{display_path(v)}` | {v['description']} | {v['steps']} |" + ) + +# Asymmetric +out.append('| **Asymmetric** | | | |') +for v in categories['Asymmetric']: + out.append( + f"| Asymmetric | `{display_path(v)}` | {v['description']} | {v['steps']} |" + ) + +# PQC +out.append('| **PQC** | | | |') +for v in categories['PQC']: + out.append(f"| PQC | `{display_path(v)}` | {v['description']} | {v['steps']} |") + +# KMIP Operations +out.append('| **KMIP Operations** | | | |') +for v in categories['KMIP Operations']: + is_non_fips = v['path'].startswith('non-fips/') + cat_label = 'KMIP Operations (non-FIPS)' if is_non_fips else 'KMIP Operations' + out.append( + f"| {cat_label} | `{display_path(v)}` | {v['description']} | {v['steps']} |" + ) + +# Serialization +out.append('| **Serialization** | | | |') +for v in categories['Serialization']: + out.append( + f"| Serialization | `{display_path(v)}` | {v['description']} | {v['steps']} |" + ) + +# K8s Plugin +out.append('| **K8s Plugin** | | | |') +for v in categories['K8s Plugin']: + out.append( + f"| K8s Plugin | `{display_path(v)}` | {v['description']} | {v['steps']} |" + ) + +# Access Control +out.append('| **Access Control** | | | |') +for v in categories['Access Control']: + out.append( + f"| Access Control | `{display_path(v)}` | {v['description']} | {v['steps']} |" + ) + +# HSM +out.append('| **HSM (requires SoftHSM2 + `HSM_SLOT_ID`)** | | | |') +for v in sorted(categories['HSM'], key=lambda x: x['path']): + out.append(f"| {hsm_sub(v)} | `{v['path']}` | {v['description']} | {v['steps']} |") + +# Integrations +out.append('| **Integrations** | | | |') +for v in sorted(categories['Integrations'], key=lambda x: x['path']): + out.append(f"| Integrations | `{v['path']}` | {v['description']} | {v['steps']} |") + +# TLS +out.append('| **TLS Transport** | | | |') +for v in categories['TLS']: + out.append(f"| TLS | `{v['path']}` | {v['description']} | {v['steps']} |") + +# OPA +out.append('| **OPA Policy Engine** | | | |') +for v in sorted(categories['OPA'], key=lambda x: x['path']): + out.append(f"| OPA | `{v['path']}` | {v['description']} | {v['steps']} |") + +# Negative +out.append('| **Negative** | | | |') +for v in sorted(categories['Negative'], key=lambda x: x['path']): + out.append(f"| {neg_sub(v)} | `{v['path']}` | {v['description']} | {v['steps']} |") + +# non-FIPS CryptographicParameters +out.append('| **non-FIPS CryptographicParameters** | | | |') +for v in categories['non-FIPS CryptographicParameters']: + basename = os.path.basename(v['path']) + if 'gcm_siv' in basename: + sub = 'non-FIPS / GCM-SIV' + elif 'chacha20_poly1305' in basename: + sub = 'non-FIPS / Poly1305' + elif 'chacha20' in basename: + sub = 'non-FIPS / ChaCha20' + else: + sub = 'non-FIPS' + out.append(f"| {sub} | `{v['path']}` | {v['description']} | {v['steps']} |") + +# Keyset Resolution +out.append('| **Keyset Resolution** | | | |') +for v in sorted(categories['Keyset Resolution'], key=lambda x: x['path']): + path = v['path'] + if path.startswith('negative/'): + sub = 'Negative / Keyset' + else: + basename = os.path.basename(path) + if 'encrypt' in basename: + sub = 'Keyset / Encrypt' + elif 'decrypt' in basename: + sub = 'Keyset / Decrypt' + else: + sub = 'Keyset' + out.append(f"| {sub} | `{display_path(v)}` | {v['description']} | {v['steps']} |") + +out.append('') +out.append('---') +out.append('') + +# ─── KAT Section ──────────────────────────────────────────────────────────── +out.append( + """## Known-Answer Test (KAT) Vectors (`test_data/vectors/kat/`) + +KAT vectors use **published reference values** from NIST FIPS and RFC specifications to +verify bit-exact outputs. Each vector imports a known key and asserts exact ciphertext, +MAC, or derived-key values. + +| Category | Vector Directory | Reference | Operations | Assert Field | +|----------|-----------------|-----------|------------|--------------|""" +) + +# Group KAT by subcategory +kat_groups = OrderedDict() +for v in kat_vectors: + parts = v['path'].split('/') + subcat = parts[1] if len(parts) >= 2 else 'other' + if subcat not in kat_groups: + kat_groups[subcat] = [] + kat_groups[subcat].append(v) + +kat_headers = { + 'hash': ('**Hash**', 'NIST FIPS 180-4 / FIPS 202'), + 'mac': ('**MAC**', 'RFC 4231 / RFC 2202 / NIST HMAC-SHA3'), + 'symmetric': ( + '**Symmetric**', + 'NIST SP 800-38A / SP 800-38D / RFC 8439 / RFC 7539 / RFC 3394 / RFC 5649', + ), + 'derive_key': ('**Derive Key**', 'RFC 5869 / RFC 8018'), + 'asymmetric': ('**Asymmetric**', 'RFC 8032 / NIST PKCS#1 / RFC 6979'), + 'covercrypt_decrypt': ('**Covercrypt**', 'Cosmian Covercrypt v16'), +} + +for subcat, vecs in kat_groups.items(): + header_name, ref_group = kat_headers.get(subcat, (f"**{subcat.title()}**", '')) + out.append(f"| {header_name} | | {ref_group} | | |") + for v in sorted(vecs, key=lambda x: x['path']): + ops_str, assert_str = kat_info(v) + basename = os.path.basename(v['path']) + ref = kat_reference(basename) + is_non_fips = any( + x in basename + for x in ('gcm_siv', 'chacha20', 'ed448', 'secp256k1', 'covercrypt', 'xts') + ) + cat_prefix = ( + f"{subcat.replace('_', ' ').title()} (non-FIPS)" + if is_non_fips + else subcat.replace('_', ' ').title() + ) + out.append( + f"| {cat_prefix} | `{v['path']}` | {ref} | {ops_str} | {assert_str} |" + ) + +out.append('') +out.append('---') +out.append('') + +# ─── Manifest Schema ──────────────────────────────────────────────────────── +out.append( + """## Manifest Schema (`manifest.toml`) + +```toml +# Required metadata +name = "AES-256 Create and Get" +description = "Creates an AES-256 symmetric key and retrieves it via Get" + +# Optional: override default server config (defaults to auth_plain.toml) +# Vectors with server_config start a dedicated server instance instead of +# using the shared singleton. +# server_config = "test_data/configs/server/test/cert_auth.toml" + +# Optional: wire format — "json" (default) or "binary" +# "json" sends TTLV-JSON to /kmip/2_1 +# "binary" serializes to binary TTLV and POSTed to /kmip (application/octet-stream) +# wire_format = "binary" + +# Optional: KMIP protocol version (default [2, 1]) +# Used to set the RequestHeader version and select KMIP 1.x / 2.x / 3.x serialization +# kmip_version = [3, 0] + +# Optional: named identities for multi-user (access control) tests. +# [identities.owner] +# client_cert = "test_data/certificates/client_server/owner/owner.client.acme.com.crt" +# client_key = "test_data/certificates/client_server/owner/owner.client.acme.com.key" +# client_pkcs12 = "test_data/certificates/client_server/owner/owner.client.acme.com.p12" +# client_pkcs12_password = "password" + +# Steps executed sequentially against the KMS server +[[steps]] +operation = "Create" +request = "step1_request.json" +assert_success = true # HTTP 200 + ResultStatus check + +[steps.capture] +key_id = "UniqueIdentifier" # capture tag value for use in later steps + +[[steps]] +operation = "Get" +request = "step2_request.json" # contains {{key_id}} placeholder +assert_success = true + +[steps.assert_fields] +ObjectType = "SymmetricKey" # assert specific TTLV tags in response + +# Batch requests: raw_request = true sends a complete RequestMessage as-is +[[steps]] +operation = "Batch Create+Query" +request = "step_batch.json" # must be a full RequestMessage JSON +raw_request = true +assert_success = true # asserts ALL BatchItem ResultStatus == Success + +# Error testing: assert failure and inspect reason +[[steps]] +operation = "Encrypt" +request = "step_encrypt_after_revoke.json" +assert_success = false +assert_error_reason = "PermissionDenied" # match ResultReason tag +# assert_error_contains = "partial message match" # alternative: substring in ResultMessage + +# Negative assertions: verify fields are absent from response +[steps.assert_fields_absent] +fields = ["SensitiveField"] + +# Assert that a captured value appears among results (for multi-result Locate) +[steps.assert_any_field] +UniqueIdentifier = "{{key_id}}" +``` + +--- + +## Request Payloads (TTLV-JSON) + +Request files are TTLV-JSON payloads. By default (`wire_format = "json"`), they +are sent directly to the `/kmip/2_1` endpoint. When `wire_format = "binary"`, the +JSON is wrapped in a `RequestMessage` envelope, serialized to binary TTLV, and +POSTed to `/kmip` with `Content-Type: application/octet-stream`. + +When `raw_request = true`, the file IS the complete `RequestMessage` (used for +batch requests and integration vectors requiring custom headers). + +Binary-mode integration vectors use KMIP 1.x `TemplateAttribute` format: + +```json +{ + "tag": "Create", + "value": [ + { "tag": "ObjectType", "type": "Enumeration", "value": "SymmetricKey" }, + { "tag": "TemplateAttribute", "value": [ + { "tag": "Attribute", "value": [ + { "tag": "AttributeName", "type": "TextString", "value": "Cryptographic Algorithm" }, + { "tag": "AttributeValue", "type": "Enumeration", "value": "AES" } + ]}, + { "tag": "Attribute", "value": [ + { "tag": "AttributeName", "type": "TextString", "value": "Cryptographic Length" }, + { "tag": "AttributeValue", "type": "Integer", "value": 256 } + ]} + ]} + ] +} +``` + +JSON-mode vectors use KMIP 2.1 `Attributes` format: + +```json +{ + "tag": "Create", + "value": [ + { "tag": "ObjectType", "type": "Enumeration", "value": "SymmetricKey" }, + { "tag": "Attributes", "value": [ + { "tag": "CryptographicAlgorithm", "type": "Enumeration", "value": "AES" }, + { "tag": "CryptographicLength", "type": "Integer", "value": 256 } + ]} + ] +} +``` + +Placeholders use `{{variable_name}}` syntax and are substituted from captured values: + +```json +{ + "tag": "Get", + "value": [ + { "tag": "UniqueIdentifier", "type": "TextString", "value": "{{key_id}}" } + ] +} +```""" +) + +# ─── Write output ─────────────────────────────────────────────────────────── +content = '\n'.join(out) + '\n' +existing = OUTPUT.read_text(encoding='utf-8') if OUTPUT.exists() else '' +changed = content != existing +if changed: + with open(OUTPUT, 'w', encoding='utf-8') as f: + f.write(content) + +# ─── Verify ───────────────────────────────────────────────────────────────── +accounted = sum(len(v) for v in categories.values()) + len(kat_vectors) +if accounted != total: + sys.exit(f"ERROR: {accounted} categorized != {total} on disk") + +print( + f"✓ {OUTPUT.relative_to(REPO_ROOT)}: {total} vectors documented ({len(out)} lines)" +) +# Exit 1 when the file was updated so pre-commit blocks the commit and the +# developer re-stages the regenerated README before continuing. +if changed: + sys.exit(1) diff --git a/.mise/scripts/docs/generate_docs.sh b/.mise/scripts/docs/generate_docs.sh index 8a0499faa4..4bbb5f0aac 100755 --- a/.mise/scripts/docs/generate_docs.sh +++ b/.mise/scripts/docs/generate_docs.sh @@ -176,7 +176,7 @@ fi # ─── Step 6: Log call-site index ──────────────────────────────────────────────────── if [[ "$SKIP_LOG_INDEX" == false ]]; then banner "6/6 — Log call-site index (log-reference.md)" - if python3 "${REPO_ROOT}/scripts/update_log_index.py" --non-interactive --no-color; then + if python3 "${REPO_ROOT}/.mise/scripts/docs/update_log_index.py" --non-interactive --no-color; then ok "log-reference.md synced" else fail "update_log_index.py failed" diff --git a/.mise/scripts/git/loc_diff.py b/.mise/scripts/git/loc_diff.py new file mode 100644 index 0000000000..fc21f63f7a --- /dev/null +++ b/.mise/scripts/git/loc_diff.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +loc_diff.py — Show lines added / deleted / net vs a base branch, +grouped by root folder and then by crate/ subfolders. + +Usage: + python3 loc_diff.py [BASE_REF] + + BASE_REF defaults to "origin/develop". +""" +import subprocess +import sys +from collections import defaultdict + + +def main() -> None: + base = sys.argv[1] if len(sys.argv) > 1 else 'origin/develop' + + result = subprocess.run( + ['git', 'diff', f"{base}...HEAD", '--numstat'], + capture_output=True, + text=True, + check=True, + ) + + root_stats: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + sub_stats: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + + for line in result.stdout.splitlines(): + parts = line.split('\t') + if len(parts) != 3: + continue + added_s, deleted_s, path = parts + if added_s == '-': # binary file + continue + added, deleted = int(added_s), int(deleted_s) + segs = path.split('/') + root = segs[0] if len(segs) > 1 else '.' + root_stats[root][0] += added + root_stats[root][1] += deleted + if root == 'crate' and len(segs) > 2: + sub = segs[1] + sub_stats[sub][0] += added + sub_stats[sub][1] += deleted + + def _row(label: str, added: int, deleted: int) -> str: + net = added - deleted + sign = '+' if net >= 0 else '' + return f" {label:<38} {added:>7} {deleted:>7} {sign}{net:>7}" + + header = f" {'Folder':<38} {'Added':>7} {'Deleted':>7} {'Net':>8}" + sep = f" {'-' * 38} {'-' * 7} {'-' * 7} {'-' * 8}" + + print(f"\n=== By root folder (vs {base}) ===") + print(header) + print(sep) + for r, (a, d) in sorted(root_stats.items(), key=lambda x: -(x[1][0] - x[1][1])): + print(_row(r, a, d)) + + print(f"\n=== crate/ subfolders ===") + print(header.replace('Folder', 'Subfolder')) + print(sep) + for s, (a, d) in sorted(sub_stats.items(), key=lambda x: -(x[1][0] - x[1][1])): + print(_row(f"crate/{s}", a, d)) + + total_added = sum(v[0] for v in root_stats.values()) + total_deleted = sum(v[1] for v in root_stats.values()) + print(f"\n Lines changed: {total_added:,} additions & {total_deleted:,} deletions") + + +if __name__ == '__main__': + main() diff --git a/.mise/scripts/test/find_unused_vector_json.py b/.mise/scripts/test/find_unused_vector_json.py new file mode 100644 index 0000000000..eee3d830ca --- /dev/null +++ b/.mise/scripts/test/find_unused_vector_json.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Find and remove JSON files in test_data/vectors/ that are not referenced by any +non-regression test vector manifest. + +Rules: + - test_data/vectors/jose/ : all JSON files are used (auto-discovered at runtime). + - Every other sub-directory that contains a manifest.toml: + Used JSON files = those listed as `request = "..."` in the manifest steps. + Unused JSON files = any *.json file in the directory NOT in that set. + - Sub-directories that contain NO manifest.toml are entirely unused. +""" + +import os +import sys +import re +from pathlib import Path + +VECTORS_DIR = Path(__file__).resolve().parent.parent / 'test_data' / 'vectors' +# jose/ uses runtime discovery — skip it entirely +SKIP_DIRS = {'jose'} + + +def collect_manifest_requests(manifest_path: Path) -> set[str]: + """Return the set of JSON filenames referenced via `request = "..."` in a manifest.""" + text = manifest_path.read_text() + # Match request = "filename.json" (with any surrounding whitespace) + return set(re.findall(r'request\s*=\s*"([^"]+\.json)"', text)) + + +def main(dry_run: bool = False) -> None: + unused: list[Path] = [] + no_manifest_dirs: list[Path] = [] + + for dirpath, dirnames, filenames in os.walk(VECTORS_DIR): + dir_ = Path(dirpath) + + # Skip the root vectors/ directory itself + if dir_ == VECTORS_DIR: + continue + + # Skip explicitly excluded dirs (jose/) at any depth + rel = dir_.relative_to(VECTORS_DIR) + if any(part in SKIP_DIRS for part in rel.parts): + dirnames.clear() # don't recurse + continue + + # We only care about leaf directories that contain JSON files + json_files = {f for f in filenames if f.endswith('.json')} + if not json_files: + continue + + manifest = dir_ / 'manifest.toml' + if not manifest.exists(): + # The whole directory is unreferenced + no_manifest_dirs.append(dir_) + for f in sorted(json_files): + unused.append(dir_ / f) + else: + referenced = collect_manifest_requests(manifest) + for f in sorted(json_files): + if f not in referenced: + unused.append(dir_ / f) + + # ── Report ──────────────────────────────────────────────────────────────── + if no_manifest_dirs: + print(f"\n{'=' * 60}") + print('Directories with JSON files but NO manifest.toml:') + for d in sorted(no_manifest_dirs): + print(f" {d.relative_to(VECTORS_DIR)}/") + + if unused: + print(f"\n{'=' * 60}") + print(f"Unused JSON files ({len(unused)} total):") + for f in unused: + print(f" {f.relative_to(VECTORS_DIR)}") + else: + print('\nNo unused JSON files found.') + return + + if dry_run: + print( + f"\n[DRY RUN] Would remove {len(unused)} file(s). Re-run without --dry-run to delete." + ) + return + + print(f"\nRemoving {len(unused)} file(s)...") + removed = 0 + for f in unused: + try: + f.unlink() + print(f" REMOVED {f.relative_to(VECTORS_DIR)}") + removed += 1 + except OSError as exc: + print(f" ERROR {f.relative_to(VECTORS_DIR)}: {exc}", file=sys.stderr) + + print(f"\nDone. {removed}/{len(unused)} file(s) removed.") + + +if __name__ == '__main__': + dry_run = '--dry-run' in sys.argv or '-n' in sys.argv + if dry_run: + print('Running in DRY-RUN mode (no files will be deleted).') + main(dry_run=dry_run) diff --git a/.mise/scripts/test/generate_rekey_vectors.sh b/.mise/scripts/test/generate_rekey_vectors.sh index 05d7867d72..be48c959e7 100755 --- a/.mise/scripts/test/generate_rekey_vectors.sh +++ b/.mise/scripts/test/generate_rekey_vectors.sh @@ -1302,7 +1302,7 @@ cat >"$DIR/manifest.toml" <<'EOF' name = "ReKeyKeyPair With Offset" description = """ Verifies that ReKeyKeyPair with an Offset parameter correctly \ -applies date arithmetic on the replacement key pair. +applies date computation on the replacement key pair. """ [[steps]] diff --git a/.mise/scripts/test/test_ui.sh b/.mise/scripts/test/test_ui.sh index 6298b249cf..300f3d0550 100755 --- a/.mise/scripts/test/test_ui.sh +++ b/.mise/scripts/test/test_ui.sh @@ -176,7 +176,7 @@ echo "==> Using dynamic ports: KMS=${KMS_PORT}, Vite=${VITE_PORT}" ensure_pnpm echo "==> Installing UI dependencies …" -rm -rf "${UI_DIR}/node_modules" +rm -rf "${UI_DIR}/node_modules" 2>/dev/null || true run_ui run_pnpm install --frozen-lockfile echo "==> Building UI (VITE_KMS_URL=https://127.0.0.1:${KMS_PORT}, VITE_DEV_MODE=true) …" diff --git a/.mise/scripts/windows/windows_ui.ps1 b/.mise/scripts/windows/windows_ui.ps1 index 83feaf6319..d5e52f4bd5 100644 --- a/.mise/scripts/windows/windows_ui.ps1 +++ b/.mise/scripts/windows/windows_ui.ps1 @@ -16,8 +16,11 @@ function Build-UI { rustup target add wasm32-unknown-unknown # Install wasm-bindgen-cli with matching version + # --locked pins the exact dependency versions shipped with the crate's Cargo.lock, + # preventing newly published transitive dependencies (e.g. brotli-decompressor v5) + # from breaking compilation on the current toolchain. Write-Host "Installing wasm-bindgen-cli 0.2.108..." - cargo install wasm-bindgen-cli --version 0.2.108 --force + cargo install wasm-bindgen-cli --version 0.2.108 --locked --force # Build WASM package Write-Host "Building WASM package..." diff --git a/.mise/tasks/docs/log-index-check b/.mise/tasks/docs/log-index-check index 89d7e71afd..944c73188e 100755 --- a/.mise/tasks/docs/log-index-check +++ b/.mise/tasks/docs/log-index-check @@ -6,14 +6,14 @@ source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" print_header "Checking log-reference.md" REPO_ROOT="$(get_repo_root)" -if python3 "${REPO_ROOT}/scripts/update_log_index.py" --check --no-color; then +if python3 "${REPO_ROOT}/.mise/scripts/docs/update_log_index.py" --check --no-color; then print_success "log-reference.md is up to date" else print_error "log-reference.md is out of sync with the source code." echo "" echo " Fix it by running one of:" echo "" - echo " python3 scripts/update_log_index.py --non-interactive --no-color" + echo " python3 .mise/scripts/docs/update_log_index.py --non-interactive --no-color" echo " mise run docs:generate" echo "" echo " Then review, stage, and commit:" diff --git a/.mise/tasks/docs/vector-readme b/.mise/tasks/docs/vector-readme new file mode 100755 index 0000000000..8b9ce1faf0 --- /dev/null +++ b/.mise/tasks/docs/vector-readme @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +#MISE description="Regenerate crate/test_kms_server/README.md from all test vector manifests" +set -euo pipefail +source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" + +print_header "Regenerating test_kms_server vector README" + +REPO_ROOT="$(get_repo_root)" +python3 "${REPO_ROOT}/.mise/scripts/docs/gen_vector_readme.py" || { + rc=$? + if [ "$rc" -eq 1 ]; then + print_status "README.md updated — stage the file and re-commit" + exit 1 + else + print_error "gen_vector_readme.py failed with exit code $rc" + exit "$rc" + fi +} + +print_success "Vector README is already up-to-date" diff --git a/.mise/tasks/git/loc-diff b/.mise/tasks/git/loc-diff new file mode 100755 index 0000000000..4883c96687 --- /dev/null +++ b/.mise/tasks/git/loc-diff @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +#MISE description="Show lines added/deleted/net vs a base branch, by root folder and crate/ subfolder" +#USAGE arg "[BASE_REF]" help="Base branch or commit to diff against (default: origin/develop)" +set -euo pipefail +source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" + +BASE="${1:-origin/develop}" +REPO_ROOT="$(get_repo_root)" + +print_header "LOC diff vs ${BASE}" +python3 "${REPO_ROOT}/.mise/scripts/git/loc_diff.py" "${BASE}" diff --git a/.mise/tasks/test/ui b/.mise/tasks/test/ui index 98b0468098..945dd77c8d 100755 --- a/.mise/tasks/test/ui +++ b/.mise/tasks/test/ui @@ -19,14 +19,15 @@ export WITH_WASM=1 WITH_HSM=1 WITH_CURL=1 # The Nix-built Node.js links against OpenSSL with compiled-in OPENSSLDIR=/usr/local/cosmian/lib/ssl. # Create symlinks so node can initialize without error. +# Use sudo -n (non-interactive) to avoid hanging in CI or pre-commit hooks. if [ -n "${OPENSSL_MODULES:-}" ] && [ -d "$OPENSSL_MODULES" ]; then - sudo mkdir -p /usr/local/cosmian/lib/ossl-modules + sudo -n mkdir -p /usr/local/cosmian/lib/ossl-modules 2>/dev/null || true for f in "$OPENSSL_MODULES"/*.so "$OPENSSL_MODULES"/*.dylib; do - [ -f "$f" ] && sudo ln -sf "$f" /usr/local/cosmian/lib/ossl-modules/ 2>/dev/null || true + [ -f "$f" ] && sudo -n ln -sf "$f" /usr/local/cosmian/lib/ossl-modules/ 2>/dev/null || true done if [ -f "${OPENSSL_CONF:-}" ]; then - sudo mkdir -p /usr/local/cosmian/lib/ssl - sudo ln -sf "$OPENSSL_CONF" /usr/local/cosmian/lib/ssl/openssl.cnf 2>/dev/null || true + sudo -n mkdir -p /usr/local/cosmian/lib/ssl 2>/dev/null || true + sudo -n ln -sf "$OPENSSL_CONF" /usr/local/cosmian/lib/ssl/openssl.cnf 2>/dev/null || true fi fi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ac4466c7f7..904161f22e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -199,7 +199,7 @@ repos: Flags stale entries with [REMOVED], appends new entries, and updates ×N multiplicity counts. Exits 1 when any change is made so the commit is blocked until the updated file is reviewed and re-staged. - entry: python3 scripts/update_log_index.py --non-interactive --no-color + entry: mise docs:log-index language: system pass_filenames: false files: \.(rs|ts|tsx)$ @@ -252,6 +252,17 @@ repos: pass_filenames: false types_or: [javascript, jsx, ts, tsx] + - id: gen-vector-readme + name: Regenerate test_kms_server vector README + description: | + Syncs crate/test_kms_server/README.md with vector_runner.rs and + test_data/vectors manifests. Exits 1 when the README is updated so + the developer must re-stage the file before committing. + entry: mise docs:vector-readme + language: system + pass_filenames: false + files: crate/test_kms_server/src/vector_runner\.rs|\.mise/scripts/docs/gen_vector_readme\.py|\.mise/tasks/docs/vector-readme + - id: pnpm-ui-lint name: pnpm ui check:lint entry: pnpm -C ui check:lint diff --git a/AGENTS.md b/AGENTS.md index e2d0e9533a..242b2219a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -330,6 +330,7 @@ All team-wide skills are in `.github/skills/`. See `.github/prompts/README.md` f | `/code-quality [path]` | Full code quality audit — duplication, patterns, Clippy, CI | | `/refactor-plan` | Before any multi-file refactor | | `/rust-refactor` | To find and consolidate Rust code duplication | +| `/rust-simplify [path]` | Find simplification opportunities: nesting, long functions, dead code, bool traps, iterator anti-patterns | | `/rust-patterns` | KMS-specific Rust design patterns reference | | `/docs-writer` | For documentation pages (Diátaxis framework) | | `/adr` | For architectural decisions | diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md new file mode 100644 index 0000000000..306dd0190e --- /dev/null +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -0,0 +1,128 @@ +## Features + +- Implement KMIP ReKey operation for symmetric keys with name transfer per §4.4 ([#968](https://github.com/Cosmian/kms/pull/968)) +- Support re-wrapping of dependent keys when a wrapping key is rekeyed ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `find_wrapped_by()` method to `ObjectsStore` trait (SQLite, PostgreSQL, MySQL implementations) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement KMIP `ReCertify` operation (§4.8) — certificate rotation with new UID and replacement links ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add proper `ReCertify` and `ReCertifyResponse` KMIP 2.1 types compliant with both KMIP 1.x and 2.x ([#968](https://github.com/Cosmian/kms/pull/968)) +- Introduce `RekeyOperation` trait to unify symmetric, keypair, and certificate rotation logic ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `offset` field to `ReCertify` struct per KMIP 2.1 §6.1.45 for date-based activation scheduling ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement KMIP §4.57 transition 6 auto-deactivation: Active keys automatically transition to Deactivated when their DeactivationDate is reached ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement keyset resolution: `name@latest`, `name@first`, `name@N` syntax to address specific key generations by `rotate_name` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement try-each-key decryption: Decrypt, SignatureVerify, and MACVerify operations with a bare keyset name walk the rotation chain (newest→oldest) until one key succeeds ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement KMIP 2.1 §3.31 state-based key selection: processing operations (Decrypt, Verify, MACVerify) now accept Deactivated and Compromised keys; protection operations (Encrypt, Sign, MAC) remain Active-only ([#968](https://github.com/Cosmian/kms/pull/968)) +- Enforce uniqueness in ReKey/ReKeyKeyPair `validate()`: reject ambiguous identifiers that resolve to multiple eligible keys with a clear error message ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `--keyset-warn-depth` server config flag (default: 5) to trigger a server warning and return `X-KMS-Keyset-Depth` response header when decryption succeeds at depth ≥ threshold; replaces the old `--keyset-decrypt-max-attempts` hard cap — traversal is now unbounded (cycle detection only) so all key generations remain reachable ([#968](https://github.com/Cosmian/kms/pull/968)) +- Enforce a minimum of 60 seconds for `auto_rotation_check_interval_secs` when non-zero, to prevent high-frequency database scans from overloading the server ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `find_by_rotate_name()` to `ObjectsStore` trait with SQLite, PostgreSQL, and MySQL implementations for keyset lookup ([#968](https://github.com/Cosmian/kms/pull/968)) +- Inherit `rotate_name` from old key to new key during ReKey so keyset resolution works across generations ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement `ckms sym keys set-rotation-policy` CLI command with `--interval`, `--offset`, `--rotation-name` flags ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement `ckms sym keys get-rotation-policy` CLI command to display interval, offset, keyset name, generation, and last rotation date ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add Web UI components for Set Rotation Policy, Get Rotation Policy, and Re-Key under Symmetric Keys ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add HSM keyset support: store keyset metadata in `CKA_LABEL` (`name::gen::base_id[::latest]`); `SetAttribute rotate_name` writes `CKA_LABEL`; `SetAttribute rotate_interval` writes `CKA_START_DATE`/`CKA_END_DATE`; `Re-Key` on HSM UIDs generates a new HSM key and updates both CKA_LABELs; keyset resolution via `find_by_rotate_name` enumerates PKCS#11 objects and sorts by generation ([#968](https://github.com/Cosmian/kms/pull/968)) +- Enrich `HsmStore::retrieve()` export path with CKA_LABEL keyset metadata (`rotate_name`, `rotate_generation`, `rotate_latest`) so the non-latest guard works for extractable HSM keys ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Security + +- Mark `x-rotate-generation` and `x-rotate-date` as server-managed read-only attributes: reject user modifications via AddAttribute, SetAttribute, ModifyAttribute, and DeleteAttribute ([#968](https://github.com/Cosmian/kms/pull/968)) +- Guard `Re-Key` / `Re-Key Key Pair` to reject rotation of non-latest keyset members (`x-rotate-latest = false` and `x-rotate-name` set) with a clear error; keys without a keyset name are unaffected ([#968](https://github.com/Cosmian/kms/pull/968)) +- Reject `SetAttribute rotate_offset` on HSM keys with `NotSupported` — HSM rotation scheduling uses `CKA_START_DATE`/`CKA_END_DATE`, not SQL-managed offset windows ([#968](https://github.com/Cosmian/kms/pull/968)) +- Restrict rotation to Active or Deactivated keys: `Re-Key`, `Re-Key Key Pair`, and `ReCertify` now reject PreActive, Compromised, Destroyed, and Destroyed_Compromised objects with an explicit error (KMIP §6.1.46 does not list `Wrong_Key_Lifecycle_State` for Re-Key) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Reject `@` character in `rotate_name` attribute values to prevent keyset versioning syntax injection ([#968](https://github.com/Cosmian/kms/pull/968)) + +### Server internals + +- Consolidate `key_ops/` sub-modules: move `enforce_create_permission`, `reject_protection_storage_masks`, and `user_can_perform_operation` to `impl KMS` in `kms/permissions.rs`; move `record_cascading_metrics` to `impl KMS` in `kms/other_kms_methods.rs`; merge `authorization.rs`, `key_resolution.rs`, `lifecycle.rs`, and `usage_limits.rs` into `crypto_op.rs` — callers updated throughout `operations/` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `is_within_process_window()` to honour `ProcessStartDate`/`ProtectStopDate` stored in external DB attributes (previously only key-block embedded attributes were checked) ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Bug Fixes + +- Fix `GetAttributes` not returning `RotateLatest` and `RotateAutomatic` vendor attributes — these fields were missing from the rotation-attribute propagation block, making rotation policy status completely unqueryable ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `ReKeyKeyPair` not calling `set_rotation_metadata_from()` on the new public key — only the new private key received `rotate_latest=true` and `rotate_generation=N+1`; the new public key's rotation metadata stayed `None` after every keypair rotation ([#968](https://github.com/Cosmian/kms/pull/968)) + +- Fix `ReKeyKeyPair` not propagating `CryptographicUsageMask` from old key pair to the new `CreateKeyPair` request — causes FIPS-mode rejection (`got None but expected among 0x00103A01`) when rotating EC or RSA key pairs in a keyset +- Add non-regression test vector `negative/rekey_keypair_non_latest`: CreateKeyPair (EC P-256, FIPS masks), SetAttribute(RotateName), ReKeyKeyPair (gen-0→gen-1 succeeds), ReKeyKeyPair (gen-0 again) → "not the latest" error + +- Fix KMIP lifecycle semantics: restore correct `setup_object_lifecycle` behavior — past `activation_date` → Active, `None` → PreActive ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add explicit `activation_date: Some(now)` to all request builders and test helpers requiring immediate Active state ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix KMIP spec reference: `§4.7` → `§4.8` in `rekey/common.rs` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix KMIP spec reference: `§6.1.8` → `§6.1.45` for `ReCertify` operation ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add ownership check in `rewrap_dependants` to skip keys not owned by the caller ([#968](https://github.com/Cosmian/kms/pull/968)) +- Simplify `relink_keys_to_new_certificate` by passing `old_cert_uid` directly instead of extracting from attributes ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `rewrap_dependants` losing `activation_date` metadata on Redis-findex: use attributes from `retrieve_object` instead of `find_wrapped_by` which fails on wrapped keys ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix KMIP 1.4 XML test cleanup: use Revoke + Destroy(remove:true) to fully purge stale objects from Redis-findex ([#968](https://github.com/Cosmian/kms/pull/968)) +- Transfer `Name` attribute from old key to new key during ReKey per KMIP §4.4 ([#968](https://github.com/Cosmian/kms/pull/968)) +- Return error instead of silently skipping when a user-supplied wrapping key ID equals the key being wrapped ([#968](https://github.com/Cosmian/kms/pull/968)) +- Bypass ownership check for server-configured KEK during wrapping operations ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix symmetric ReKey missing server-wide KEK wrapping and unwrapped-cache insert ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix keypair rekey not preserving WrappingKeyLink on replacement keys ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix symmetric rekey hardcoding `State::Active` — now uses `setup_object_lifecycle` for date-based state computation ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `setup_object_lifecycle` not storing `activation_date` for `PreActive` keys — offset-based activation scheduling now works correctly ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `ReCertify` request/response deserialization to KMIP 2.1 message handler ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `find_by_rotate_name` SQL queries using wrong JSON path (`$.rotate_name` → `$.RotateName`) matching PascalCase serde serialization ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `GetAttributes` not returning rotation policy fields (interval, offset, name, generation, date) because they lack `Tag` enum entries ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement `find_by_rotate_name()` on Redis-Findex backend and index `rotate_name` attribute in Findex keywords ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `ReCertify.generate_replacement` passing empty user to `get_subject`/`get_issuer` — use certificate owner instead ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `ReCertify` not computing lifecycle state from offset — certificates with future activation_date are now `PreActive` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix Windows CI: add `--locked` to `cargo install wasm-bindgen-cli` in `windows_ui.ps1` to prevent newly-published `brotli-decompressor v5` from breaking the build on Rust 1.91 ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Refactor + +- Reorganize ReKey modules into `rekey/` folder: `mod.rs`, `symmetric.rs`, `keypair.rs`, `common.rs`; move `ReCertify` handler to `operations/recertify.rs` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract `RekeyOperation` trait into `common.rs` with `execute_rekey()` orchestrator — shared 2-phase commit logic ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract 6 shared helpers into `common.rs`: `compute_replacement_dates`, `prepare_replacement_attributes`, `update_old_key_after_rekey`, `set_rotation_metadata_on_new_key`, `clear_rotation_flags_on_old_key`, `enforce_privileged_user` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `KeyRetirement` struct + `finalize_rekey` function in `common.rs` — shared Phase 2 logic ([#968](https://github.com/Cosmian/kms/pull/968)) +- Move `compute_rotation_uid` and `rewrap_dependants` from `symmetric.rs` to `common.rs` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Convert `ReKeyKeyPair` to 2-phase commit (matching symmetric) to support dependant re-wrapping on public keys ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add default implementations to `RekeyOperation` trait for `detect_wrapping`, `persist_new_key`, `finalize_dependants`, and `rewrap_new_objects` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract `extract_rewrap_spec`, `extract_wrapping_key_uid`, and `retrieve_eligible_keys` into `common.rs` as shared helpers ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract shared `validate_no_crypto_param_change` into `common.rs` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Refactor `prepare_attributes` in `keypair.rs` — extract `finalize_replacement_key` helper ([#968](https://github.com/Cosmian/kms/pull/968)) +- Move `setup_new_key` and `finalize_replacement_key` from keypair.rs to common.rs ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract `preserve_wrapping_key_link` into common.rs ([#968](https://github.com/Cosmian/kms/pull/968)) +- Split `rewrap_dependants` (70→25 lines) by extracting `rewrap_single_dependant` helper ([#968](https://github.com/Cosmian/kms/pull/968)) +- Split `relink_keys_to_new_certificate` by extracting `relink_single_key` helper ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract `enforce_create_permission` and `reject_protection_storage_masks` shared helpers into `key_ops` module ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract `find-due-for-rotation` SQL into `query.sql` and `query_mysql.sql` using `rawsql::Loader` macros ([#968](https://github.com/Cosmian/kms/pull/968)) +- Extract `find-wrapped-by` SQL into `query.sql` and `query_mysql.sql` using `rawsql::Loader` macros ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add `PublicKey` variant to SQLite `find_wrapped_by` inline query ([#968](https://github.com/Cosmian/kms/pull/968)) +- Implement `find_wrapped_by` for Redis-findex backend ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Testing + +- Add 18 exhaustive KAT (Known-Answer Test) vectors for key rotation operations, covering `ReKey` (8 vectors), `ReKeyKeyPair` (6 vectors), and `ReCertify` (4 vectors): state transitions, `RotateGeneration` counter accuracy, `RotateLatest` flag propagation, `RotateInterval` clearance, keyset UID resolution, replacement/replaced link integrity, deactivated-key rejection (Encrypt/Sign), and deactivated-key acceptance (Decrypt/Verify) ([#968](https://github.com/Cosmian/kms/pull/968)) + +- Add 6 non-regression test vectors for key rotation scenarios: + `rekey_wrapping_key`, `rekey_wrapped_key`, `rekey_wrapping_key_with_links`, + `rekey_wrapping_key_double_chain`, `kek_rekey_wrapped`, `rekey_wrapped_deactivated` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 9 symmetric ReKey test vectors (basic, wrapped, wrapping-key re-wrap, name transfer, offset, links) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 27 ReKeyKeyPair test vectors (RSA, EC, ML-KEM, ML-DSA, SLH-DSA, X25519, secp256k1) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add Covercrypt ReKeyKeyPair test vector (in-place attribute rekey with same UIDs) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add access privilege escalation test vector for ReKey ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 4 ReCertify test vectors (self-signed, chain, with-links, with-offset) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 3 negative ReCertify test vectors (missing UID, non-existent, not a certificate) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 2 offset state verification vectors (rekey + rekey-keypair: Offset=0 → Active, Offset=86400 → PreActive) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 2 negative state restriction vectors: `rekey_preactive_fails`, `rekey_keypair_preactive_fails` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix `wrap_and_cache`: skip server-wide KEK wrapping for HSM-resident keys (UID has `hsm::` prefix) — they are hardware-protected and the wrapping step caused a self-wrap error when the key being created IS the configured KEK ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add vector `test_data/vectors/hsm/kek_bootstrap_self_create` + `server_type = "hsm_kek_uncreated"` server type to reproduce and prevent regressions of the HSM self-wrap bug ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 7 keyset resolution test vectors: `keyset_encrypt_latest`, `keyset_encrypt_bare_name`, `keyset_encrypt_latest_after_rotation`, `keyset_decrypt_try_each`, `keyset_decrypt_double_rotation`, `keyset_decrypt_at_latest`, `keyset_rotate_name_at_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add HSM keyset support: `find_due_for_rotation` on HsmStore reads `CKA_START_DATE`/`CKA_END_DATE`; keyset metadata stored in `CKA_LABEL` (`name::gen::key_id[::latest]`); `SetAttribute rotate_name` writes `CKA_LABEL` via `C_SetAttributeValue`; `SetAttribute rotate_interval` writes `CKA_START_DATE`/`CKA_END_DATE`; `Re-Key` on HSM UIDs generates a new HSM key and updates both CKA_LABELs; `walk_keyset_chain` resolves HSM keysets by `CKA_LABEL` without `ReplacedObjectLink` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 4 key-state compliance tests: `test_decrypt_deactivated_key_succeeds`, `test_decrypt_compromised_key_succeeds`, `test_encrypt_deactivated_key_rejected`, `test_encrypt_compromised_key_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add 3 HSM keyset test vectors: `resident_keyset_set_rotate_name`, `resident_keyset_rekey_and_decrypt`, `resident_keyset_double_rotation` + 1 negative: `hsm_rotate_offset_rejected` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix HSM `SetAttribute RotateInterval`: reject sub-day intervals (< 86400 s) that would silently produce `CKA_END_DATE = today` and cause immediate re-rotation on every scheduler tick; treat `RotateInterval = 0` as "disable" and clear the PKCS#11 dates instead of setting `end_date = today`; use ceiling division so non-multiple-of-86400 intervals do not fire one day early ([#968](https://github.com/Cosmian/kms/pull/968)) +- Reconstruct `rotate_interval` attribute from `CKA_START_DATE`/`CKA_END_DATE` in `HsmStore::retrieve` and `build_sensitive_stub_attributes` so that auto-rotation re-key can propagate the rotation schedule to the replacement key ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Documentation + +- Add key auto-rotation specification document covering all 6 rotation + scenarios (plain symmetric, wrapping key, wrapped key, asymmetric pair, + wrapped private key, server-wide KEK), rotation policy attributes, + server-side scheduler, KMIP attribute tables, and implementation roadmap ([#968](https://github.com/Cosmian/kms/pull/968)) +- Correct HSM key rotation section: the KMS cannot use KMIP `Re-Key` on + HSM-managed keys (no SQL attribute storage, non-extractable key material); + use PKCS#11 vendor tools instead ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add HSM keyset section to `key_auto_rotation.md`: CKA_LABEL convention, UID generation format, supported/unsupported attributes, example workflow, and keyset resolution description ([#968](https://github.com/Cosmian/kms/pull/968)) +- Document `x-rotate-generation` and `x-rotate-date` invariants: monotonically + increasing counter unique within a key-set, authoritative last-rotation + timestamp relied on by `is_due_for_rotation` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add Certificate Renewal (ReCertify) section to key_auto_rotation.md with RFC references ([#968](https://github.com/Cosmian/kms/pull/968)) diff --git a/CHANGELOG/feat_key-rotation-ckms-ui.md b/CHANGELOG/feat_key-rotation-ckms-ui.md new file mode 100644 index 0000000000..75c7ec88a2 --- /dev/null +++ b/CHANGELOG/feat_key-rotation-ckms-ui.md @@ -0,0 +1,42 @@ +# CHANGELOG — feat/key-rotation-ckms-ui + +## Bug Fixes (E2E) + +- Fix `rotation-policy.spec.ts`: AntD v5 `InputNumber` (rc-input-number 9.5) passes extra props including `data-testid` directly to the inner `` element; remove the incorrect `input` child combinator from locators ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Features + +- Add `--rotation-name`, `--rotation-interval`, `--rotation-offset` flags to `ckms sym keys create`, `ckms ec keys create`, `ckms rsa keys create`, and `ckms pqc keys create` — rotation policy is applied via `SetAttribute` immediately after key creation +- Add shared `RotationPolicyArgs` clap struct (`crate/clients/clap/src/actions/shared/rotation_policy_args.rs`) reused across all four create actions +- Add **Rotation Policy** section (Rotation Name / Interval / Offset fields) to `SymKeysCreate`, `ECKeysCreate`, `RsaKeysCreate`, `PqcKeysCreate` UI pages — policy is applied via WASM `set_rotate_*` calls after key creation + +- Add standalone **Rotation Policy** top-level menu item in the Web UI sidebar that regroups Set/Get Rotation Policy pages for all 4 key types (Symmetric, RSA, EC, PQC) under `/ui/rotation-policy/{sym,rsa,ec,pqc}/{set,get}`; remove the Set/Get Rotation Policy entries from each per-key-type Keys submenu ([#968](https://github.com/Cosmian/kms/pull/968)) +- In FIPS mode, the PQC child is automatically hidden from the Rotation Policy menu ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Testing + +- Add Playwright E2E tests for key rotation policy (sym, RSA, EC, PQC): set-rotation-policy, get-rotation-policy, re-key — `ui/tests/e2e/rotation-policy.spec.ts` +- Add HSM KEK self-wrap regression test vector `test_data/vectors/hsm/kek_bootstrap_self_create/` with 6 TTLV-JSON steps covering AES-256 KEK bootstrap, DEK lifecycle and AES-GCM roundtrip +- Add `crate/test_kms_server/src/test_env.rs` for safe in-process environment variable overrides (avoids `unsafe set_var` in Rust 1.87+) +- Register `hsm_kek_uncreated` server type and `ONCE_VECTOR_HSM_KEK_UNCREATED` OnceCell in vector runner + +## Bug Fixes + +- Fix RSA and EC `ReKeyKeyPair` in FIPS mode: `generate_replacement` now carries the `cryptographic_usage_mask` from the old private/public key into `private_key_attributes`/`public_key_attributes` of the `CreateKeyPair` request so that the FIPS compliance check (`got None but expected among 0x...`) no longer fails ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix HSM self-wrap: server-wide KEK wrapping now skips keys whose UID starts with `hsm::` prefix, preventing infinite recursion when the KEK itself is created on the HSM — `crate/server/src/core/wrapping/wrap.rs` +- Fix OAuth2 login redirect server not stopping after the first callback, causing TCP TIME_WAIT port conflicts when the test suite is run multiple times in quick succession — `crate/clients/client/src/http_client/login.rs` +- Fix CLI documentation: `--name` → `--rotation-name` in all examples in `key_auto_rotation.md` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Move `SetRotationPolicyAction` and `GetRotationPolicyAction` to `shared/` module and wire into RSA, EC, and PQC key subcommands ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add shared `ReKeyKeyPairAction` in CLI and wire `ckms rsa/ec/pqc keys re-key` subcommands ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix **Search Objects** Date column: `activation_date`, `initial_date`, `original_creation_date`, `rotate_date` were serialized as seconds; now serialized as milliseconds so `formatUnixDate` receives the correct epoch value ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix **Search Objects** missing rotate_* attributes: add `rotate_name`, `rotate_interval`, `rotate_offset`, `rotate_generation`, `rotate_latest` match arms in `parse_selected_attributes_flatten` and include all date/rotate keys in the `ENRICH_ATTRIBUTE_KEYS` constant used by all `enrichUids` calls in `Locate.tsx` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add WASM binding `rekey_keypair_ttlv_request` / `parse_rekey_keypair_ttlv_response` for asymmetric key rotation ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add Web UI pages for Re-Key, Set Rotation Policy, Get Rotation Policy under RSA, EC, and PQC key sections ([#968](https://github.com/Cosmian/kms/pull/968)) +- Consolidate 8 per-key-type rotation policy components (Set×4 + Get×4 for sym/rsa/ec/pqc) into 2 generic reusable components `ui/src/actions/RotationPolicy/SetRotationPolicy.tsx` and `GetRotationPolicy.tsx` ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix Web UI Certificate Issuance page Option 3 (Certificate ID to Re-certify) to call the dedicated KMIP `ReCertify` operation (new UID + replacement links) instead of `Certify` (in-place upsert); add `build_re_certify_request` in `client_utils`, `re_certify_ttlv_request`/`parse_re_certify_ttlv_response` WASM bindings ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add E2E CLI tests for rotation policy on symmetric, RSA, EC key types (`test_keyset_workflow`, `test_rekey_non_latest_rejected`, `test_rsa_set_and_get_rotation_policy`, `test_ec_set_and_get_rotation_policy`) ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add E2E CLI tests for `re-key` on RSA, EC, PQC key pairs (`test_rsa_rekey`, `test_ec_rekey`, `test_pqc_rekey`, `test_pqc_set_and_get_rotation_policy`) ([#968](https://github.com/Cosmian/kms/pull/968)) + +## Documentation + +- Reorder implementation roadmap in `key_auto_rotation.md`: UI/CLI features become PR 2, notifications stay PR 3, auto-rotation scheduler becomes PR 4; close GitHub PR #973 (superseded), update PR #970 and #971 titles/descriptions diff --git a/Cargo.lock b/Cargo.lock index fe787fbae9..373d78ef89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1188,6 +1188,7 @@ dependencies = [ "pkcs11-sys", "rand 0.10.1", "thiserror 2.0.18", + "time", "uuid", "zeroize", ] @@ -1351,6 +1352,7 @@ dependencies = [ "num-bigint-dig", "serde_json", "thiserror 2.0.18", + "time", "tokio", "zeroize", ] @@ -1442,6 +1444,7 @@ dependencies = [ "strum 0.27.2", "tempfile", "thiserror 2.0.18", + "time", "tokio", "tokio-postgres", "tokio-rusqlite", diff --git a/README.md b/README.md index ba02b0b596..7d5c1286ef 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The **Cosmian KMS** presents some unique features, such as: - **Other integrations**: [OpenSSH](./documentation/docs/integrations/openssh.md), [S/MIME email encryption](./documentation/docs/integrations/smime.md), and [FortiGate / FortiOS](./documentation/docs/integrations/fortigate.md). - **Security and standards**: [FIPS 140-3](./documentation/docs/certifications_and_compliance/fips.md), [KMIP 1.0-2.1 binary and JSON TTLV support](./documentation/docs/kmip_support/introduction/index.md), [state-of-the-art authentication mechanisms](./documentation/docs/configuration/authentication.md), and native compatibility with network appliances such as [FortiGate / FortiOS](./documentation/docs/integrations/fortigate.md). - **HSM support**: [Utimaco, SmartCard-HSM/Nitrokey HSM 2, Proteccio, Crypt2pay, and others](./documentation/docs/hsm_support/introduction/index.md), with KMS keys wrapped by HSMs. -- **Operations**: full-featured [CLI and graphical clients](https://docs.cosmian.com/kms_clients/), [high-availability mode](./documentation/docs/installation/high_availability_mode.md), [confidential cloud deployment](./documentation/docs/installation/marketplace_guide.md), [OpenTelemetry integration](./documentation/docs/configuration/logging.md), and [OpenAPI 3.1 spec with Swagger UI](./documentation/docs/kmip_support/openapi.md) for interactive API exploration. +- **Operations**: full-featured [CLI and graphical clients](https://docs.cosmian.com/kms_clients/), [high-availability mode](./documentation/docs/installation/high_availability_mode.md), [confidential cloud deployment](./documentation/docs/installation/marketplace_guide.md), [OpenTelemetry integration](./documentation/docs/configuration/logging.md), [OpenAPI 3.1 spec with Swagger UI](./documentation/docs/kmip_support/openapi.md) for interactive API exploration, and [scheduled key auto-rotation](./documentation/docs/kmip_support/key_auto_rotation.md). The **Cosmian KMS** is both a Key Management System and a Public Key Infrastructure. As a KMS, it is designed to manage the lifecycle of keys and provide scalable cryptographic services such as on-the-fly key generation, encryption, and decryption operations. diff --git a/cli_documentation/docs/cli/main_commands.md b/cli_documentation/docs/cli/main_commands.md index 2c3d042562..69df6c4343 100644 --- a/cli_documentation/docs/cli/main_commands.md +++ b/cli_documentation/docs/cli/main_commands.md @@ -1466,6 +1466,12 @@ Manage post-quantum keys (ML-KEM, ML-DSA) **`destroy`** [[8.1.8]](#818-ckms-pqc-keys-destroy) Destroy a PQC public or private key +**`re-key`** [[8.1.9]](#819-ckms-pqc-keys-re-key) Rotate an existing asymmetric key pair, generating a new private/public key pair + +**`set-rotation-policy`** [[8.1.10]](#8110-ckms-pqc-keys-set-rotation-policy) Set the automatic rotation policy on a key or key pair. + +**`get-rotation-policy`** [[8.1.11]](#8111-ckms-pqc-keys-get-rotation-policy) Get the automatic rotation policy for a key or key pair. + --- ## 8.1.1 ckms pqc keys activate @@ -1500,6 +1506,13 @@ Possible values: `"ml-kem-512", "ml-kem-768", "ml-kem-1024", "ml-dsa-44", "ml-d Possible values: `"true", "false"` [default: `"false"`] +`--rotation-name [-n] ` Assign a keyset name for addressing key generations via `name@latest`, `name@first`, `name@N` syntax. +Must not contain the `@` character. + +`--rotation-interval ` Rotation interval in seconds. The key will be automatically re-keyed at this interval. Set to 0 to disable automatic rotation while preserving other policy fields + +`--rotation-offset ` Offset in seconds from the initial date before the first rotation occurs + --- @@ -1694,6 +1707,51 @@ Possible values: `"true", "false"` [default: `"false"`] +--- + +## 8.1.9 ckms pqc keys re-key + +Rotate an existing asymmetric key pair, generating a new private/public key pair + +### Usage +`ckms pqc keys re-key [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the private key to re-key + + + +--- + +## 8.1.10 ckms pqc keys set-rotation-policy + +Set the automatic rotation policy on a key or key pair. + +### Usage +`ckms pqc keys set-rotation-policy [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the key to set the rotation policy on + +`--interval [-i] ` Rotation interval in seconds. The key will be automatically re-keyed at this interval. Set to 0 to disable automatic rotation while preserving other policy fields + +`--offset [-o] ` Offset in seconds from the initial date before the first rotation occurs + +`--rotation-name [-n] ` A keyset name for addressing key generations via name@latest, name@first, name@N syntax. Must not contain the '@' character + + + +--- + +## 8.1.11 ckms pqc keys get-rotation-policy + +Get the automatic rotation policy for a key or key pair. + +### Usage +`ckms pqc keys get-rotation-policy [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the key to get the rotation policy from + + + --- @@ -2391,6 +2449,12 @@ Create, destroy, import, and export elliptic curve key pairs **`destroy`** [[13.1.8]](#1318-ckms-ec-keys-destroy) Destroy a public or private key +**`re-key`** [[13.1.9]](#1319-ckms-ec-keys-re-key) Rotate an existing asymmetric key pair, generating a new private/public key pair + +**`set-rotation-policy`** [[13.1.10]](#13110-ckms-ec-keys-set-rotation-policy) Set the automatic rotation policy on a key or key pair. + +**`get-rotation-policy`** [[13.1.11]](#13111-ckms-ec-keys-get-rotation-policy) Get the automatic rotation policy for a key or key pair. + --- ## 13.1.1 ckms ec keys activate @@ -2435,6 +2499,13 @@ If the wrapping key is: - a RSA key, RSA-OAEP will be used - a EC key, ECIES will be used (salsa20poly1305 for X25519) +`--rotation-name [-n] ` Assign a keyset name for addressing key generations via `name@latest`, `name@first`, `name@N` syntax. +Must not contain the `@` character. + +`--rotation-interval ` Rotation interval in seconds. The key will be automatically re-keyed at this interval. Set to 0 to disable automatic rotation while preserving other policy fields + +`--rotation-offset ` Offset in seconds from the initial date before the first rotation occurs + --- @@ -2632,6 +2703,51 @@ Possible values: `"true", "false"` [default: `"false"`] +--- + +## 13.1.9 ckms ec keys re-key + +Rotate an existing asymmetric key pair, generating a new private/public key pair + +### Usage +`ckms ec keys re-key [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the private key to re-key + + + +--- + +## 13.1.10 ckms ec keys set-rotation-policy + +Set the automatic rotation policy on a key or key pair. + +### Usage +`ckms ec keys set-rotation-policy [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the key to set the rotation policy on + +`--interval [-i] ` Rotation interval in seconds. The key will be automatically re-keyed at this interval. Set to 0 to disable automatic rotation while preserving other policy fields + +`--offset [-o] ` Offset in seconds from the initial date before the first rotation occurs + +`--rotation-name [-n] ` A keyset name for addressing key generations via name@latest, name@first, name@N syntax. Must not contain the '@' character + + + +--- + +## 13.1.11 ckms ec keys get-rotation-policy + +Get the automatic rotation policy for a key or key pair. + +### Usage +`ckms ec keys get-rotation-policy [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the key to get the rotation policy from + + + --- @@ -3321,6 +3437,12 @@ Create, destroy, import, and export RSA key pairs **`destroy`** [[22.1.8]](#2218-ckms-rsa-keys-destroy) Destroy a public or private key +**`re-key`** [[22.1.9]](#2219-ckms-rsa-keys-re-key) Rotate an existing asymmetric key pair, generating a new private/public key pair + +**`set-rotation-policy`** [[22.1.10]](#22110-ckms-rsa-keys-set-rotation-policy) Set the automatic rotation policy on a key or key pair. + +**`get-rotation-policy`** [[22.1.11]](#22111-ckms-rsa-keys-get-rotation-policy) Get the automatic rotation policy for a key or key pair. + --- ## 22.1.1 ckms rsa keys activate @@ -3363,6 +3485,13 @@ If the wrapping key is: - a RSA key, RSA-OAEP will be used - a EC key, ECIES will be used (salsa20poly1305 for X25519) +`--rotation-name [-n] ` Assign a keyset name for addressing key generations via `name@latest`, `name@first`, `name@N` syntax. +Must not contain the `@` character. + +`--rotation-interval ` Rotation interval in seconds. The key will be automatically re-keyed at this interval. Set to 0 to disable automatic rotation while preserving other policy fields + +`--rotation-offset ` Offset in seconds from the initial date before the first rotation occurs + --- @@ -3560,6 +3689,51 @@ Possible values: `"true", "false"` [default: `"false"`] +--- + +## 22.1.9 ckms rsa keys re-key + +Rotate an existing asymmetric key pair, generating a new private/public key pair + +### Usage +`ckms rsa keys re-key [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the private key to re-key + + + +--- + +## 22.1.10 ckms rsa keys set-rotation-policy + +Set the automatic rotation policy on a key or key pair. + +### Usage +`ckms rsa keys set-rotation-policy [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the key to set the rotation policy on + +`--interval [-i] ` Rotation interval in seconds. The key will be automatically re-keyed at this interval. Set to 0 to disable automatic rotation while preserving other policy fields + +`--offset [-o] ` Offset in seconds from the initial date before the first rotation occurs + +`--rotation-name [-n] ` A keyset name for addressing key generations via name@latest, name@first, name@N syntax. Must not contain the '@' character + + + +--- + +## 22.1.11 ckms rsa keys get-rotation-policy + +Get the automatic rotation policy for a key or key pair. + +### Usage +`ckms rsa keys get-rotation-policy [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the key to get the rotation policy from + + + --- @@ -4230,6 +4404,10 @@ Create, destroy, import, and export symmetric keys **`destroy`** [[26.1.9]](#2619-ckms-sym-keys-destroy) Destroy a symmetric key +**`set-rotation-policy`** [[26.1.10]](#26110-ckms-sym-keys-set-rotation-policy) Set the automatic rotation policy on a key or key pair. + +**`get-rotation-policy`** [[26.1.11]](#26111-ckms-sym-keys-get-rotation-policy) Get the automatic rotation policy for a key or key pair. + --- ## 26.1.1 ckms sym keys activate @@ -4278,6 +4456,13 @@ If the wrapping key is: - a RSA key, RSA-OAEP will be used - a EC key, ECIES will be used (salsa20poly1305 for X25519) +`--rotation-name [-n] ` Assign a keyset name for addressing key generations via `name@latest`, `name@first`, `name@N` syntax. +Must not contain the `@` character. + +`--rotation-interval ` Rotation interval in seconds. The key will be automatically re-keyed at this interval. Set to 0 to disable automatic rotation while preserving other policy fields + +`--rotation-offset ` Offset in seconds from the initial date before the first rotation occurs + --- @@ -4488,6 +4673,38 @@ Possible values: `"true", "false"` [default: `"false"`] +--- + +## 26.1.10 ckms sym keys set-rotation-policy + +Set the automatic rotation policy on a key or key pair. + +### Usage +`ckms sym keys set-rotation-policy [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the key to set the rotation policy on + +`--interval [-i] ` Rotation interval in seconds. The key will be automatically re-keyed at this interval. Set to 0 to disable automatic rotation while preserving other policy fields + +`--offset [-o] ` Offset in seconds from the initial date before the first rotation occurs + +`--rotation-name [-n] ` A keyset name for addressing key generations via name@latest, name@first, name@N syntax. Must not contain the '@' character + + + +--- + +## 26.1.11 ckms sym keys get-rotation-policy + +Get the automatic rotation policy for a key or key pair. + +### Usage +`ckms sym keys get-rotation-policy [options]` +### Arguments +`--key-id [-k] ` The unique identifier of the key to get the rotation policy from + + + --- diff --git a/crate/clients/ckms/src/tests/elliptic_curve/mod.rs b/crate/clients/ckms/src/tests/elliptic_curve/mod.rs index 819c5c4373..afe90fdb15 100644 --- a/crate/clients/ckms/src/tests/elliptic_curve/mod.rs +++ b/crate/clients/ckms/src/tests/elliptic_curve/mod.rs @@ -3,6 +3,8 @@ pub(crate) mod create_key_pair; #[cfg(feature = "non-fips")] pub(crate) mod encrypt_decrypt; #[cfg(feature = "non-fips")] +pub(crate) mod rotation_policy; +#[cfg(feature = "non-fips")] pub(crate) mod sign_verify; #[cfg(feature = "non-fips")] diff --git a/crate/clients/ckms/src/tests/elliptic_curve/rotation_policy.rs b/crate/clients/ckms/src/tests/elliptic_curve/rotation_policy.rs new file mode 100644 index 0000000000..94384a72f1 --- /dev/null +++ b/crate/clients/ckms/src/tests/elliptic_curve/rotation_policy.rs @@ -0,0 +1,334 @@ +use std::fs; + +use tempfile::TempDir; +use test_kms_server::start_default_test_kms_server; + +use super::SUB_COMMAND; +use crate::{ + config::CKMS_CONF_ENV, + error::{CosmianError, result::CosmianResult}, + tests::utils::{ + ckms_bin, + extract_uids::{extract_private_key, extract_public_key, extract_unique_identifier}, + owner_config, recover_cmd_logs, run_ckms, run_ckms_expect_error, + }, +}; + +/// Create an EC P-256 keypair whose private key UID equals `key_id`. +/// Returns `(private_key_id, public_key_id)`. +fn create_ec_keypair_with_id(cli_conf_path: &str, key_id: &str) -> CosmianResult<(String, String)> { + let mut cmd = ckms_bin(); + cmd.env(CKMS_CONF_ENV, cli_conf_path); + cmd.arg(SUB_COMMAND) + .args(["keys", "create", "--curve", "nist-p256", key_id]); + let output = recover_cmd_logs(&mut cmd); + if output.status.success() { + let stdout = std::str::from_utf8(&output.stdout)?; + let sk = extract_private_key(stdout) + .ok_or_else(|| CosmianError::Default("failed extracting private key".to_owned()))? + .to_owned(); + let pk = extract_public_key(stdout) + .ok_or_else(|| CosmianError::Default("failed extracting public key".to_owned()))? + .to_owned(); + return Ok((sk, pk)); + } + Err(CosmianError::Default( + std::str::from_utf8(&output.stderr)?.to_owned(), + )) +} + +/// Rekey the EC keypair identified by `private_key_id`. +/// Returns the new private key UID. +fn rekey_ec_keypair(cli_conf_path: &str, private_key_id: &str) -> CosmianResult { + let mut cmd = ckms_bin(); + cmd.env(CKMS_CONF_ENV, cli_conf_path); + cmd.arg(SUB_COMMAND) + .args(["keys", "re-key", "--key-id", private_key_id]); + let output = recover_cmd_logs(&mut cmd); + if output.status.success() { + let stdout = std::str::from_utf8(&output.stdout)?; + let uid = extract_unique_identifier(stdout) + .ok_or_else(|| CosmianError::Default("failed extracting new key UID".to_owned()))? + .to_owned(); + return Ok(uid); + } + Err(CosmianError::Default( + std::str::from_utf8(&output.stderr)?.to_owned(), + )) +} + +/// Sign `input_file` with `key_id` (private key). +/// Returns an error string on failure. +fn ec_sign( + cli_conf_path: &str, + input_file: &str, + key_id: &str, + output_file: &str, +) -> CosmianResult<()> { + let mut cmd = ckms_bin(); + cmd.env(CKMS_CONF_ENV, cli_conf_path); + cmd.arg(SUB_COMMAND) + .args(["sign", input_file, "--key-id", key_id, "-o", output_file]); + let output = recover_cmd_logs(&mut cmd); + if output.status.success() { + return Ok(()); + } + Err(CosmianError::Default( + std::str::from_utf8(&output.stderr)?.to_owned(), + )) +} + +/// Verify `sig_file` over `data_file` using `key_id` (public key). +/// Returns an error string on failure. +fn ec_verify( + cli_conf_path: &str, + data_file: &str, + sig_file: &str, + key_id: &str, +) -> CosmianResult<()> { + let mut cmd = ckms_bin(); + cmd.env(CKMS_CONF_ENV, cli_conf_path); + cmd.arg(SUB_COMMAND) + .args(["sign-verify", data_file, sig_file, "--key-id", key_id]); + let output = recover_cmd_logs(&mut cmd); + if output.status.success() { + let stdout = std::str::from_utf8(&output.stdout)?; + assert!(stdout.contains("Signature verification is Valid")); + return Ok(()); + } + Err(CosmianError::Default( + std::str::from_utf8(&output.stderr)?.to_owned(), + )) +} + +#[tokio::test] +pub(crate) async fn test_ec_set_and_get_rotation_policy() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create an EC keypair whose UID equals the keyset name + let (private_key_id, _public_key_id) = + create_ec_keypair_with_id(&owner_client_conf_path, "ec-keyset")?; + assert_eq!(private_key_id, "ec-keyset"); + + // Set rotation policy — rotation-name must equal private key UID + let args = vec![ + "ec", + "keys", + "set-rotation-policy", + "--key-id", + &private_key_id, + "--interval", + "172800", + "--rotation-name", + "ec-keyset", + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("Rotation policy set successfully"), + "expected success message in: {output}" + ); + + // Get rotation policy and verify + let args = vec![ + "ec", + "keys", + "get-rotation-policy", + "--key-id", + &private_key_id, + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("172800"), + "expected interval=172800 in: {output}" + ); + assert!( + output.contains("ec-keyset"), + "expected name=ec-keyset in: {output}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_ec_rekey() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create an EC keypair whose UID equals the keyset name + let (private_key_id, _public_key_id) = + create_ec_keypair_with_id(&owner_client_conf_path, "ec-rekey-test")?; + + // Set rotation policy — rotation-name must equal private key UID + run_ckms( + &owner_client_conf_path, + &[ + "ec", + "keys", + "set-rotation-policy", + "--key-id", + &private_key_id, + "--rotation-name", + "ec-rekey-test", + ], + )?; + + // Re-Key the EC key pair + let gen1_id = rekey_ec_keypair(&owner_client_conf_path, &private_key_id)?; + assert!( + gen1_id.contains("ec-rekey-test"), + "new key UID should contain keyset name, got: {gen1_id}" + ); + + Ok(()) +} + +/// Full EC keyset lifecycle test — KMIP §4.57 state enforcement across all +/// addressing forms. +/// +/// Sign (protection op — Active only) and Verify (processing op — Active, +/// Deactivated, and Compromised). +#[tokio::test] +pub(crate) async fn test_ec_keyset_full_lifecycle() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + let tmp_dir = TempDir::new()?; + let data_file = tmp_dir.path().join("data.txt"); + let plaintext = b"ec-lifecycle-test-data"; + fs::write(&data_file, plaintext)?; + + // ── Step 1: Create EC keypair with UID = keyset name ──────────────── + let (gen0_sk_id, gen0_pk_id) = + create_ec_keypair_with_id(&owner_client_conf_path, "ec-lc-keyset")?; + assert_eq!(gen0_sk_id, "ec-lc-keyset"); + assert_eq!(gen0_pk_id, "ec-lc-keyset_pk"); + + run_ckms( + &owner_client_conf_path, + &[ + "ec", + "keys", + "set-rotation-policy", + "--key-id", + &gen0_sk_id, + "--rotation-name", + "ec-lc-keyset", + ], + )?; + + // ── Step 2: Sign with gen-0 (Active) → OK ─────────────────────────── + let sig_gen0 = tmp_dir.path().join("sig_gen0.sig"); + ec_sign( + &owner_client_conf_path, + data_file.to_str().unwrap(), + "ec-lc-keyset", // bare name → gen-0 private key + sig_gen0.to_str().unwrap(), + )?; + + // ── Step 3: Re-Key gen-0 → gen-1 (gen-0 private key Deactivated) ──── + let gen1_sk_id = rekey_ec_keypair(&owner_client_conf_path, &gen0_sk_id)?; + let gen1_pk_id = format!("{gen1_sk_id}_pk"); + + // ── Step 4: Sign with @0 (Deactivated private key) → FAIL ────────── + let sig_fail = tmp_dir.path().join("sig_fail.sig"); + let result = ec_sign( + &owner_client_conf_path, + data_file.to_str().unwrap(), + "ec-lc-keyset@0", + sig_fail.to_str().unwrap(), + ); + assert!( + result.is_err(), + "Sign with gen-0 private key must fail (Deactivated)" + ); + + // ── Step 5: Verify with gen-0 public key (Deactivated) → OK ───────── + // The old gen-0 public key keeps its original UID (ec-lc-keyset_pk) after + // rekey — only the new generation gets an @N suffix. + // Verify is a processing op; Deactivated keys are allowed. + ec_verify( + &owner_client_conf_path, + data_file.to_str().unwrap(), + sig_gen0.to_str().unwrap(), + &gen0_pk_id, // "ec-lc-keyset_pk" + )?; + + // ── Step 6: Sign with bare name (→ gen-1, Active) → OK ────────────── + let sig_gen1 = tmp_dir.path().join("sig_gen1.sig"); + ec_sign( + &owner_client_conf_path, + data_file.to_str().unwrap(), + "ec-lc-keyset", // bare name → latest = gen-1 + sig_gen1.to_str().unwrap(), + )?; + + // ── Step 7: Verify gen-1 signature with gen-1 public key → OK ─────── + ec_verify( + &owner_client_conf_path, + data_file.to_str().unwrap(), + sig_gen1.to_str().unwrap(), + &gen1_pk_id, + )?; + + // ── Step 8: Revoke gen-1 private key with KeyCompromise → Compromised + run_ckms( + &owner_client_conf_path, + &[ + "ec", + "keys", + "revoke", + "--key-id", + &gen1_sk_id, + "--reason-code", + "key-compromise", + "compromised for lifecycle testing", + ], + )?; + + // ── Step 9: Sign with gen-1 (Compromised) → FAIL ──────────────────── + let sig_comp = tmp_dir.path().join("sig_comp.sig"); + let result = ec_sign( + &owner_client_conf_path, + data_file.to_str().unwrap(), + &gen1_sk_id, + sig_comp.to_str().unwrap(), + ); + assert!( + result.is_err(), + "Sign with Compromised gen-1 private key must fail" + ); + + // Sign with bare name also fails (latest = gen-1, Compromised) + let result = ec_sign( + &owner_client_conf_path, + data_file.to_str().unwrap(), + "ec-lc-keyset", + sig_comp.to_str().unwrap(), + ); + assert!( + result.is_err(), + "Sign with bare name must fail (gen-1 Compromised)" + ); + + // ── Step 10: Verify gen-1 signature with Compromised public key → OK ─ + // Verify is a processing op; Compromised keys are allowed. + ec_verify( + &owner_client_conf_path, + data_file.to_str().unwrap(), + sig_gen1.to_str().unwrap(), + &gen1_pk_id, + )?; + + // ── Step 11: Attempting to re-key non-latest (gen-0 via @first) → FAIL + let stderr = run_ckms_expect_error( + &owner_client_conf_path, + &["ec", "keys", "re-key", "--key-id", "ec-lc-keyset@first"], + )?; + assert!( + stderr.contains("not the latest"), + "expected 'not the latest' error, got: {stderr}" + ); + + Ok(()) +} diff --git a/crate/clients/ckms/src/tests/hsm/mod.rs b/crate/clients/ckms/src/tests/hsm/mod.rs index 4d0833dde9..06f910f1a3 100644 --- a/crate/clients/ckms/src/tests/hsm/mod.rs +++ b/crate/clients/ckms/src/tests/hsm/mod.rs @@ -19,6 +19,7 @@ use crate::tests::hsm::encrypt_decrypt::{test_rsa_pkcs_oaep, test_rsa_pkcs_v15}; mod encrypt_decrypt; mod multi_softhsm2; +mod rekey; mod revoke_destroy; mod wrap_with_hsm_key; diff --git a/crate/clients/ckms/src/tests/hsm/rekey.rs b/crate/clients/ckms/src/tests/hsm/rekey.rs new file mode 100644 index 0000000000..7535b6ef6c --- /dev/null +++ b/crate/clients/ckms/src/tests/hsm/rekey.rs @@ -0,0 +1,133 @@ +//! HSM symmetric key rotation tests using bare keyset name. +//! +//! Tests that `ckms sym keys re-key --key-id ` works for +//! 3 consecutive rotations without the `hsm::slot::` prefix. + +use cosmian_logger::log_init; +use test_kms_server::{ + TestClientOptions, TestsContext, hsm_config_path, start_test_server_with_patch, +}; +use uuid::Uuid; + +use crate::{ + error::result::CosmianResult, + tests::{ + shared::destroy, + symmetric::{create_key::create_symmetric_key, rekey::rekey_symmetric_key}, + utils::{owner_config, run_ckms}, + }, +}; + +/// Read the `HSM_SLOT_ID` env var (same value used by the test server). +/// Returns `None` when the variable is absent so callers can skip the test. +fn hsm_slot_id() -> Option { + std::env::var("HSM_SLOT_ID").ok()?.parse().ok() +} + +/// Test: 3 consecutive re-keys using the HSM base UID as the stable keyset handle. +/// +/// For HSM keys, `rotate_name` must be the full base UID (`hsm::::`), +/// not just the bare key name. This ensures uniqueness across HSM slots when +/// multiple slots host keys with the same local name. +/// +/// Steps: +/// 1. Create AES-256 key on HSM with explicit UID `hsm::::` +/// 2. Set rotation-name = `hsm::::` (the key's full base UID) +/// 3. Re-key 3× using the base UID `hsm::::` (no `@N` suffix) +/// The dispatcher routes to `rekey_hsm_symmetric` which selectively redirects +/// stable-handle requests (no @N) to the latest generation. +/// 4. Assert each re-key returns a distinct UID +/// 5. Cleanup all 4 generations +pub(crate) fn test_hsm_rekey_by_bare_keyset_name(ctx: &TestsContext) -> CosmianResult<()> { + log_init(None); + let owner_client_conf_path = owner_config(ctx); + let Some(slot) = hsm_slot_id() else { + println!(" HSM_SLOT_ID not set — skipping test"); + return Ok(()); + }; + + // Use a unique keyset name per test run to avoid collisions with stale state + let keyset_name = format!("ckms_rk_{}", Uuid::new_v4().as_simple()); + let hsm_uid = format!("hsm::{slot}::{keyset_name}"); + + // ── Step 1: Create gen-0 on HSM ───────────────────────────────────── + let gen0_id = create_symmetric_key( + &owner_client_conf_path, + &["--algorithm", "aes", "--number-of-bits", "256", &hsm_uid], + )?; + assert_eq!( + gen0_id, hsm_uid, + "server should echo back the requested UID" + ); + println!(" gen-0: {gen0_id}"); + + // ── Step 2: Set rotation name = full base UID ─────────────────────── + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &gen0_id, + "--rotation-name", + &hsm_uid, + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("Rotation policy set successfully"), + "set-rotation-policy failed: {output}" + ); + + // ── Step 3: First re-key using base UID (stable handle) ───────────── + let gen1_id = rekey_symmetric_key(&owner_client_conf_path, &hsm_uid)?; + println!(" gen-1: {gen1_id}"); + assert_ne!(gen1_id, gen0_id, "gen-1 must differ from gen-0"); + assert!( + gen1_id.contains("@1"), + "gen-1 UID should contain @1, got: {gen1_id}" + ); + + // ── Step 4: Second re-key using base UID (stable handle) ──────────── + let gen2_id = rekey_symmetric_key(&owner_client_conf_path, &hsm_uid)?; + println!(" gen-2: {gen2_id}"); + assert_ne!(gen2_id, gen1_id, "gen-2 must differ from gen-1"); + assert!( + gen2_id.contains("@2"), + "gen-2 UID should contain @2, got: {gen2_id}" + ); + + // ── Step 5: Third re-key using base UID (stable handle) ───────────── + let gen3_id = rekey_symmetric_key(&owner_client_conf_path, &hsm_uid)?; + println!(" gen-3: {gen3_id}"); + assert_ne!(gen3_id, gen2_id, "gen-3 must differ from gen-2"); + assert!( + gen3_id.contains("@3"), + "gen-3 UID should contain @3, got: {gen3_id}" + ); + + // ── Cleanup ───────────────────────────────────────────────────────── + for uid in [&gen0_id, &gen1_id, &gen2_id, &gen3_id] { + destroy(&owner_client_conf_path, "sym", uid, true).ok(); + } + + println!(" ✓ 3 consecutive bare-name HSM re-keys succeeded"); + Ok(()) +} + +#[tokio::test] +async fn test_hsm_rekey_bare_keyset_name() -> CosmianResult<()> { + let Some(slot) = hsm_slot_id() else { + // HSM_SLOT_ID not set in this environment — skip gracefully. + return Ok(()); + }; + let config_path = hsm_config_path("three_softhsm2.toml"); + let ctx = start_test_server_with_patch( + &config_path, + move |config| { + config.hsm.hsm_slot = vec![slot]; + }, + TestClientOptions::default(), + ) + .await + .map_err(|e| crate::error::CosmianError::Default(e.to_string()))?; + test_hsm_rekey_by_bare_keyset_name(&ctx) +} diff --git a/crate/clients/ckms/src/tests/pqc/mod.rs b/crate/clients/ckms/src/tests/pqc/mod.rs index b580e00a15..35aead08db 100644 --- a/crate/clients/ckms/src/tests/pqc/mod.rs +++ b/crate/clients/ckms/src/tests/pqc/mod.rs @@ -1,3 +1,5 @@ +mod rotation_policy; + use std::{path::Path, process::Command}; use assert_cmd::prelude::*; diff --git a/crate/clients/ckms/src/tests/pqc/rotation_policy.rs b/crate/clients/ckms/src/tests/pqc/rotation_policy.rs new file mode 100644 index 0000000000..0dec913ab0 --- /dev/null +++ b/crate/clients/ckms/src/tests/pqc/rotation_policy.rs @@ -0,0 +1,96 @@ +use test_kms_server::start_default_test_kms_server; + +use crate::{ + error::result::CosmianResult, + tests::utils::{owner_config, run_ckms}, +}; + +/// Create a ML-KEM key pair using `run_ckms` and return (`private_key_id`, `public_key_id`) +fn create_ml_kem_key_pair(cli_conf_path: &str) -> CosmianResult<(String, String)> { + let args = vec!["pqc", "keys", "create", "--algorithm", "ml-kem-768"]; + let output = run_ckms(cli_conf_path, &args)?; + // Parse "Private key unique identifier: xxx" + let sk_id = output + .lines() + .find(|l| l.contains("Private key unique identifier")) + .and_then(|l| l.split(':').next_back()) + .map(|s| s.trim().to_owned()) + .unwrap_or_default(); + let pk_id = output + .lines() + .find(|l| l.contains("Public key unique identifier")) + .and_then(|l| l.split(':').next_back()) + .map(|s| s.trim().to_owned()) + .unwrap_or_default(); + Ok((sk_id, pk_id)) +} + +#[tokio::test] +pub(crate) async fn test_pqc_set_and_get_rotation_policy() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a ML-KEM key pair + let (private_key_id, _public_key_id) = create_ml_kem_key_pair(&owner_client_conf_path)?; + + // Set rotation policy + let args = vec![ + "pqc", + "keys", + "set-rotation-policy", + "--key-id", + &private_key_id, + "--interval", + "259200", + "--rotation-name", + &private_key_id, + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("Rotation policy set successfully"), + "expected success message in: {output}" + ); + + // Get rotation policy and verify + let args = vec![ + "pqc", + "keys", + "get-rotation-policy", + "--key-id", + &private_key_id, + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("259200"), + "expected interval=259200 in: {output}" + ); + assert!( + output.contains(&private_key_id), + "expected name={private_key_id} in: {output}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_pqc_rekey() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a ML-KEM key pair + let (private_key_id, _public_key_id) = create_ml_kem_key_pair(&owner_client_conf_path)?; + + // Re-Key the PQC key pair + let args = vec!["pqc", "keys", "re-key", "--key-id", &private_key_id]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("rotated"), + "expected 'rotated' in: {output}" + ); + assert!( + output.contains("Unique identifier"), + "expected new UID in: {output}" + ); + + Ok(()) +} diff --git a/crate/clients/ckms/src/tests/rsa/mod.rs b/crate/clients/ckms/src/tests/rsa/mod.rs index 54b60cb321..459f37ed20 100644 --- a/crate/clients/ckms/src/tests/rsa/mod.rs +++ b/crate/clients/ckms/src/tests/rsa/mod.rs @@ -3,6 +3,8 @@ pub(crate) mod create_key_pair; #[cfg(feature = "non-fips")] pub(crate) mod encrypt_decrypt; #[cfg(feature = "non-fips")] +pub(crate) mod rotation_policy; +#[cfg(feature = "non-fips")] pub(crate) mod sign_verify; #[cfg(feature = "non-fips")] diff --git a/crate/clients/ckms/src/tests/rsa/rotation_policy.rs b/crate/clients/ckms/src/tests/rsa/rotation_policy.rs new file mode 100644 index 0000000000..4d6dfcdaf2 --- /dev/null +++ b/crate/clients/ckms/src/tests/rsa/rotation_policy.rs @@ -0,0 +1,336 @@ +use std::fs; + +use tempfile::TempDir; +use test_kms_server::start_default_test_kms_server; + +use super::SUB_COMMAND; +use crate::{ + config::CKMS_CONF_ENV, + error::{CosmianError, result::CosmianResult}, + tests::utils::{ + ckms_bin, + extract_uids::{extract_private_key, extract_public_key, extract_unique_identifier}, + owner_config, recover_cmd_logs, run_ckms, run_ckms_expect_error, + }, +}; + +/// Create a 2048-bit RSA keypair whose private key UID equals `key_id`. +/// Returns `(private_key_id, public_key_id)`. +fn create_rsa_keypair_with_id( + cli_conf_path: &str, + key_id: &str, +) -> CosmianResult<(String, String)> { + let mut cmd = ckms_bin(); + cmd.env(CKMS_CONF_ENV, cli_conf_path); + cmd.arg(SUB_COMMAND) + .args(["keys", "create", "--size_in_bits", "2048", key_id]); + let output = recover_cmd_logs(&mut cmd); + if output.status.success() { + let stdout = std::str::from_utf8(&output.stdout)?; + let sk = extract_private_key(stdout) + .ok_or_else(|| CosmianError::Default("failed extracting private key".to_owned()))? + .to_owned(); + let pk = extract_public_key(stdout) + .ok_or_else(|| CosmianError::Default("failed extracting public key".to_owned()))? + .to_owned(); + return Ok((sk, pk)); + } + Err(CosmianError::Default( + std::str::from_utf8(&output.stderr)?.to_owned(), + )) +} + +/// Rekey the RSA keypair identified by `private_key_id`. +/// Returns the new private key UID. +fn rekey_rsa_keypair(cli_conf_path: &str, private_key_id: &str) -> CosmianResult { + let mut cmd = ckms_bin(); + cmd.env(CKMS_CONF_ENV, cli_conf_path); + cmd.arg(SUB_COMMAND) + .args(["keys", "re-key", "--key-id", private_key_id]); + let output = recover_cmd_logs(&mut cmd); + if output.status.success() { + let stdout = std::str::from_utf8(&output.stdout)?; + let uid = extract_unique_identifier(stdout) + .ok_or_else(|| CosmianError::Default("failed extracting new key UID".to_owned()))? + .to_owned(); + return Ok(uid); + } + Err(CosmianError::Default( + std::str::from_utf8(&output.stderr)?.to_owned(), + )) +} + +/// Sign `input_file` with `key_id` (private key). +fn rsa_sign( + cli_conf_path: &str, + input_file: &str, + key_id: &str, + output_file: &str, +) -> CosmianResult<()> { + let mut cmd = ckms_bin(); + cmd.env(CKMS_CONF_ENV, cli_conf_path); + cmd.arg(SUB_COMMAND) + .args(["sign", input_file, "--key-id", key_id, "-o", output_file]); + let output = recover_cmd_logs(&mut cmd); + if output.status.success() { + return Ok(()); + } + Err(CosmianError::Default( + std::str::from_utf8(&output.stderr)?.to_owned(), + )) +} + +/// Verify `sig_file` over `data_file` using `key_id` (public key). +fn rsa_verify( + cli_conf_path: &str, + data_file: &str, + sig_file: &str, + key_id: &str, +) -> CosmianResult<()> { + let mut cmd = ckms_bin(); + cmd.env(CKMS_CONF_ENV, cli_conf_path); + cmd.arg(SUB_COMMAND) + .args(["sign-verify", data_file, sig_file, "--key-id", key_id]); + let output = recover_cmd_logs(&mut cmd); + if output.status.success() { + let stdout = std::str::from_utf8(&output.stdout)?; + assert!(stdout.contains("Signature verification is Valid")); + return Ok(()); + } + Err(CosmianError::Default( + std::str::from_utf8(&output.stderr)?.to_owned(), + )) +} + +#[tokio::test] +pub(crate) async fn test_rsa_set_and_get_rotation_policy() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create an RSA keypair whose UID equals the keyset name + let (private_key_id, _public_key_id) = + create_rsa_keypair_with_id(&owner_client_conf_path, "rsa-keyset")?; + assert_eq!(private_key_id, "rsa-keyset"); + + // Set rotation policy — rotation-name must equal private key UID + let args = vec![ + "rsa", + "keys", + "set-rotation-policy", + "--key-id", + &private_key_id, + "--interval", + "86400", + "--offset", + "7200", + "--rotation-name", + "rsa-keyset", + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("Rotation policy set successfully"), + "expected success message in: {output}" + ); + + // Get rotation policy and verify + let args = vec![ + "rsa", + "keys", + "get-rotation-policy", + "--key-id", + &private_key_id, + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("86400"), + "expected interval=86400 in: {output}" + ); + assert!(output.contains("7200"), "expected offset=7200 in: {output}"); + assert!( + output.contains("rsa-keyset"), + "expected name=rsa-keyset in: {output}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_rsa_rekey() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create an RSA keypair whose UID equals the keyset name + let (private_key_id, _public_key_id) = + create_rsa_keypair_with_id(&owner_client_conf_path, "rsa-rekey-test")?; + + // Set rotation policy — rotation-name must equal private key UID + run_ckms( + &owner_client_conf_path, + &[ + "rsa", + "keys", + "set-rotation-policy", + "--key-id", + &private_key_id, + "--rotation-name", + "rsa-rekey-test", + ], + )?; + + // Re-Key the RSA key pair + let gen1_id = rekey_rsa_keypair(&owner_client_conf_path, &private_key_id)?; + assert!( + gen1_id.contains("rsa-rekey-test"), + "new key UID should contain keyset name, got: {gen1_id}" + ); + + Ok(()) +} + +/// Full RSA keyset lifecycle test — KMIP §4.57 state enforcement across all +/// addressing forms. +/// +/// Sign (protection op — Active only) and Verify (processing op — Active, +/// Deactivated, and Compromised). +#[tokio::test] +pub(crate) async fn test_rsa_keyset_full_lifecycle() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + let tmp_dir = TempDir::new()?; + let data_file = tmp_dir.path().join("data.txt"); + fs::write(&data_file, b"rsa-lifecycle-test-data")?; + + // ── Step 1: Create RSA keypair with UID = keyset name ──────────────── + let (gen0_sk_id, gen0_pk_id) = + create_rsa_keypair_with_id(&owner_client_conf_path, "rsa-lc-keyset")?; + assert_eq!(gen0_sk_id, "rsa-lc-keyset"); + assert_eq!(gen0_pk_id, "rsa-lc-keyset_pk"); + + run_ckms( + &owner_client_conf_path, + &[ + "rsa", + "keys", + "set-rotation-policy", + "--key-id", + &gen0_sk_id, + "--rotation-name", + "rsa-lc-keyset", + ], + )?; + + // ── Step 2: Sign with gen-0 (Active) → OK ─────────────────────────── + let sig_gen0 = tmp_dir.path().join("sig_gen0.sig"); + rsa_sign( + &owner_client_conf_path, + data_file.to_str().unwrap(), + "rsa-lc-keyset", // bare name → gen-0 private key + sig_gen0.to_str().unwrap(), + )?; + + // ── Step 3: Re-Key gen-0 → gen-1 (gen-0 private key Deactivated) ──── + let gen1_sk_id = rekey_rsa_keypair(&owner_client_conf_path, &gen0_sk_id)?; + let gen1_pk_id = format!("{gen1_sk_id}_pk"); + + // ── Step 4: Sign with @0 (Deactivated private key) → FAIL ─────────── + let sig_fail = tmp_dir.path().join("sig_fail.sig"); + let result = rsa_sign( + &owner_client_conf_path, + data_file.to_str().unwrap(), + "rsa-lc-keyset@0", + sig_fail.to_str().unwrap(), + ); + assert!( + result.is_err(), + "Sign with gen-0 private key must fail (Deactivated)" + ); + + // ── Step 5: Verify with gen-0 public key (Deactivated) → OK ───────── + // The old gen-0 public key keeps its original UID (rsa-lc-keyset_pk) after + // rekey — only the new generation gets an @N suffix. + // Verify is a processing op; Deactivated keys are allowed. + rsa_verify( + &owner_client_conf_path, + data_file.to_str().unwrap(), + sig_gen0.to_str().unwrap(), + &gen0_pk_id, // "rsa-lc-keyset_pk" + )?; + + // ── Step 6: Sign with bare name (→ gen-1, Active) → OK ────────────── + let sig_gen1 = tmp_dir.path().join("sig_gen1.sig"); + rsa_sign( + &owner_client_conf_path, + data_file.to_str().unwrap(), + "rsa-lc-keyset", // bare name → latest = gen-1 + sig_gen1.to_str().unwrap(), + )?; + + // ── Step 7: Verify gen-1 signature with gen-1 public key → OK ─────── + rsa_verify( + &owner_client_conf_path, + data_file.to_str().unwrap(), + sig_gen1.to_str().unwrap(), + &gen1_pk_id, + )?; + + // ── Step 8: Revoke gen-1 private key with KeyCompromise → Compromised + run_ckms( + &owner_client_conf_path, + &[ + "rsa", + "keys", + "revoke", + "--key-id", + &gen1_sk_id, + "--reason-code", + "key-compromise", + "compromised for lifecycle testing", + ], + )?; + + // ── Step 9: Sign with gen-1 (Compromised) → FAIL ──────────────────── + let sig_comp = tmp_dir.path().join("sig_comp.sig"); + let result = rsa_sign( + &owner_client_conf_path, + data_file.to_str().unwrap(), + &gen1_sk_id, + sig_comp.to_str().unwrap(), + ); + assert!( + result.is_err(), + "Sign with Compromised gen-1 private key must fail" + ); + + // Sign with bare name also fails (latest = gen-1, Compromised) + let result = rsa_sign( + &owner_client_conf_path, + data_file.to_str().unwrap(), + "rsa-lc-keyset", + sig_comp.to_str().unwrap(), + ); + assert!( + result.is_err(), + "Sign with bare name must fail (gen-1 Compromised)" + ); + + // ── Step 10: Verify gen-1 signature with Compromised public key → OK ─ + rsa_verify( + &owner_client_conf_path, + data_file.to_str().unwrap(), + sig_gen1.to_str().unwrap(), + &gen1_pk_id, + )?; + + // ── Step 11: Attempting to re-key non-latest (gen-0 via @first) → FAIL + let stderr = run_ckms_expect_error( + &owner_client_conf_path, + &["rsa", "keys", "re-key", "--key-id", "rsa-lc-keyset@first"], + )?; + assert!( + stderr.contains("not the latest"), + "expected 'not the latest' error, got: {stderr}" + ); + + Ok(()) +} diff --git a/crate/clients/ckms/src/tests/symmetric/mod.rs b/crate/clients/ckms/src/tests/symmetric/mod.rs index 25fa964bff..7d4a33781e 100644 --- a/crate/clients/ckms/src/tests/symmetric/mod.rs +++ b/crate/clients/ckms/src/tests/symmetric/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod create_key; pub(crate) mod encrypt_decrypt; pub(crate) mod rekey; +pub(crate) mod rotation_policy; pub(crate) const SUB_COMMAND: &str = "sym"; diff --git a/crate/clients/ckms/src/tests/symmetric/rotation_policy.rs b/crate/clients/ckms/src/tests/symmetric/rotation_policy.rs new file mode 100644 index 0000000000..aec58c4dbe --- /dev/null +++ b/crate/clients/ckms/src/tests/symmetric/rotation_policy.rs @@ -0,0 +1,878 @@ +use std::fs; + +use cosmian_kms_cli_actions::reexport::cosmian_kms_client::reexport::cosmian_kms_client_utils::symmetric_utils::DataEncryptionAlgorithm; +use tempfile::TempDir; +use test_kms_server::start_default_test_kms_server; + +use crate::{ + error::result::CosmianResult, + tests::{ + symmetric::{ + create_key::create_symmetric_key, + encrypt_decrypt::{decrypt, encrypt}, + rekey::rekey_symmetric_key, + }, + utils::{owner_config, run_ckms, run_ckms_expect_error}, + }, +}; + +/// Set the rotation policy for a symmetric key via the CLI. +pub(crate) fn set_rotation_policy( + cli_conf_path: &str, + key_id: &str, + interval: i64, + offset: Option, + rotate_name: Option<&str>, +) -> CosmianResult { + let mut args = vec!["sym", "keys", "set-rotation-policy", "--key-id", key_id]; + let interval_str = interval.to_string(); + args.extend(["--interval", &interval_str]); + let offset_str; + if let Some(o) = offset { + offset_str = o.to_string(); + args.extend(["--offset", &offset_str]); + } + if let Some(name) = rotate_name { + args.extend(["--rotation-name", name]); + } + run_ckms(cli_conf_path, &args) +} + +/// Get the rotation policy for a symmetric key via the CLI. +pub(crate) fn get_rotation_policy(cli_conf_path: &str, key_id: &str) -> CosmianResult { + let args = vec!["sym", "keys", "get-rotation-policy", "--key-id", key_id]; + run_ckms(cli_conf_path, &args) +} + +#[tokio::test] +pub(crate) async fn test_set_and_get_rotation_policy() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a symmetric key with the keyset name as UID + let key_id = create_symmetric_key( + &owner_client_conf_path, + &["--number-of-bits", "256", "test-keyset"], + )?; + + // Set rotation policy with interval, offset, and name + let output = set_rotation_policy( + &owner_client_conf_path, + &key_id, + 86400, + Some(3600), + Some("test-keyset"), + )?; + assert!(output.contains("Rotation policy set successfully")); + + // Get rotation policy and verify + let output = get_rotation_policy(&owner_client_conf_path, &key_id)?; + assert!( + output.contains("86400"), + "expected interval=86400 in: {output}" + ); + assert!(output.contains("3600"), "expected offset=3600 in: {output}"); + assert!( + output.contains("test-keyset"), + "expected name=test-keyset in: {output}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_set_rotation_policy_name_rejects_at() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a symmetric key + let key_id = create_symmetric_key(&owner_client_conf_path, &["--number-of-bits", "256"])?; + + // Try to set rotation policy with a name containing '@' — should fail + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--interval", + "86400", + "--rotation-name", + "bad@name", + ]; + let stderr = run_ckms_expect_error(&owner_client_conf_path, &args)?; + assert!( + stderr.contains('@'), + "expected error mentioning '@' in: {stderr}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_set_rotation_policy_interval_only() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a symmetric key + let key_id = create_symmetric_key(&owner_client_conf_path, &["--number-of-bits", "256"])?; + + // Set rotation policy with interval only (no offset, no name) + let output = set_rotation_policy(&owner_client_conf_path, &key_id, 43200, None, None)?; + assert!(output.contains("Rotation policy set successfully")); + + // Get rotation policy and verify + let output = get_rotation_policy(&owner_client_conf_path, &key_id)?; + assert!( + output.contains("43200"), + "expected interval=43200 in: {output}" + ); + assert!( + output.contains("not set") || !output.contains("offset"), + "expected no offset set" + ); + + Ok(()) +} + +/// Full keyset workflow: create → set name → encrypt → rekey → decrypt via keyset name +#[tokio::test] +pub(crate) async fn test_keyset_workflow() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create AES-256 key with keyset name as UID + let key_id = create_symmetric_key( + &owner_client_conf_path, + &["--number-of-bits", "256", "e2e-keyset"], + )?; + + // Set rotation name (keyset) without interval + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--rotation-name", + "e2e-keyset", + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!(output.contains("Rotation policy set successfully")); + + // Encrypt a file using the keyset bare name + let tmp_dir = TempDir::new()?; + let input_file = tmp_dir.path().join("plain.txt"); + fs::write(&input_file, b"hello keyset rotation")?; + let encrypted_file = tmp_dir.path().join("cipher.enc"); + + encrypt( + &owner_client_conf_path, + input_file.to_str().unwrap(), + "e2e-keyset", + DataEncryptionAlgorithm::AesGcm, + None, + Some(encrypted_file.to_str().unwrap()), + None, + )?; + + // ReKey: gen-0 → gen-1 + let _new_key_id = rekey_symmetric_key(&owner_client_conf_path, &key_id)?; + + // Decrypt via keyset bare name (should walk chain and find gen-0) + let decrypted_file = tmp_dir.path().join("decrypted.txt"); + decrypt( + &owner_client_conf_path, + encrypted_file.to_str().unwrap(), + "e2e-keyset", + DataEncryptionAlgorithm::AesGcm, + None, + Some(decrypted_file.to_str().unwrap()), + None, + )?; + let decrypted = fs::read(&decrypted_file)?; + assert_eq!(decrypted, b"hello keyset rotation"); + + Ok(()) +} + +/// Encrypt with `name@first` after rotation: gen-0 is Deactivated per KMIP §4.57, +/// so encrypt MUST fail, but decrypt MUST still succeed. +#[tokio::test] +pub(crate) async fn test_keyset_encrypt_at_first() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + let key_id = create_symmetric_key( + &owner_client_conf_path, + &["--number-of-bits", "256", "kst-enc-first"], + )?; + + // Set rotation name + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--rotation-name", + "kst-enc-first", + ]; + run_ckms(&owner_client_conf_path, &args)?; + + // Encrypt with gen-0 BEFORE re-key (still Active) + let tmp_dir = TempDir::new()?; + let input_file = tmp_dir.path().join("plain.txt"); + fs::write(&input_file, b"first-gen-test")?; + let encrypted_file = tmp_dir.path().join("cipher.enc"); + + encrypt( + &owner_client_conf_path, + input_file.to_str().unwrap(), + "kst-enc-first@first", + DataEncryptionAlgorithm::AesGcm, + None, + Some(encrypted_file.to_str().unwrap()), + None, + )?; + + // ReKey: gen-0 → gen-1 (gen-0 becomes Deactivated) + let _new_key_id = rekey_symmetric_key(&owner_client_conf_path, &key_id)?; + + // Encrypt using @first AFTER re-key — must FAIL (gen-0 Deactivated) + let input_file2 = tmp_dir.path().join("plain2.txt"); + fs::write(&input_file2, b"should-fail")?; + let encrypted_file2 = tmp_dir.path().join("cipher2.enc"); + + let result = encrypt( + &owner_client_conf_path, + input_file2.to_str().unwrap(), + "kst-enc-first@first", + DataEncryptionAlgorithm::AesGcm, + None, + Some(encrypted_file2.to_str().unwrap()), + None, + ); + assert!( + result.is_err(), + "encrypt with @first after re-key must fail (gen-0 Deactivated)" + ); + + // Decrypt using @first AFTER re-key — must SUCCEED (Deactivated allows decrypt) + let decrypted_file = tmp_dir.path().join("decrypted.txt"); + decrypt( + &owner_client_conf_path, + encrypted_file.to_str().unwrap(), + "kst-enc-first@first", + DataEncryptionAlgorithm::AesGcm, + None, + Some(decrypted_file.to_str().unwrap()), + None, + )?; + let decrypted = fs::read(&decrypted_file)?; + assert_eq!(decrypted, b"first-gen-test"); + + Ok(()) +} + +/// Encrypt with `name@0` after rotation: gen-0 is Deactivated per KMIP §4.57, +/// so encrypt MUST fail, but decrypt MUST still succeed. +#[tokio::test] +pub(crate) async fn test_keyset_encrypt_at_zero() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + let key_id = create_symmetric_key( + &owner_client_conf_path, + &["--number-of-bits", "256", "kst-enc-zero"], + )?; + + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--rotation-name", + "kst-enc-zero", + ]; + run_ckms(&owner_client_conf_path, &args)?; + + // Encrypt with @0 BEFORE re-key (gen-0 still Active) + let tmp_dir = TempDir::new()?; + let input_file = tmp_dir.path().join("plain.txt"); + fs::write(&input_file, b"zero-gen-test")?; + let encrypted_file = tmp_dir.path().join("cipher.enc"); + + encrypt( + &owner_client_conf_path, + input_file.to_str().unwrap(), + "kst-enc-zero@0", + DataEncryptionAlgorithm::AesGcm, + None, + Some(encrypted_file.to_str().unwrap()), + None, + )?; + + // ReKey: gen-0 → gen-1 (gen-0 becomes Deactivated) + let _new_key_id = rekey_symmetric_key(&owner_client_conf_path, &key_id)?; + + // Encrypt using @0 AFTER re-key — must FAIL (gen-0 Deactivated) + let input_file2 = tmp_dir.path().join("plain2.txt"); + fs::write(&input_file2, b"should-fail")?; + let encrypted_file2 = tmp_dir.path().join("cipher2.enc"); + + let result = encrypt( + &owner_client_conf_path, + input_file2.to_str().unwrap(), + "kst-enc-zero@0", + DataEncryptionAlgorithm::AesGcm, + None, + Some(encrypted_file2.to_str().unwrap()), + None, + ); + assert!( + result.is_err(), + "encrypt with @0 after re-key must fail (gen-0 Deactivated)" + ); + + // Decrypt using @0 AFTER re-key — must SUCCEED (Deactivated allows decrypt) + let decrypted_file = tmp_dir.path().join("decrypted.txt"); + decrypt( + &owner_client_conf_path, + encrypted_file.to_str().unwrap(), + "kst-enc-zero@0", + DataEncryptionAlgorithm::AesGcm, + None, + Some(decrypted_file.to_str().unwrap()), + None, + )?; + let decrypted = fs::read(&decrypted_file)?; + assert_eq!(decrypted, b"zero-gen-test"); + + Ok(()) +} + +/// Encrypt with `name@1` after double rotation: gen-1 is Deactivated per KMIP §4.57, +/// so encrypt MUST fail, but decrypt MUST still succeed. +#[tokio::test] +pub(crate) async fn test_keyset_encrypt_at_generation_n() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + let key_id = create_symmetric_key( + &owner_client_conf_path, + &["--number-of-bits", "256", "kst-enc-gen-n"], + )?; + + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--rotation-name", + "kst-enc-gen-n", + ]; + run_ckms(&owner_client_conf_path, &args)?; + + // ReKey: gen-0 → gen-1 + let gen1_id = rekey_symmetric_key(&owner_client_conf_path, &key_id)?; + + // Encrypt using @1 BEFORE second re-key (gen-1 is Active/latest) + let tmp_dir = TempDir::new()?; + let input_file = tmp_dir.path().join("plain.txt"); + fs::write(&input_file, b"gen-1-test")?; + let encrypted_file = tmp_dir.path().join("cipher.enc"); + + encrypt( + &owner_client_conf_path, + input_file.to_str().unwrap(), + "kst-enc-gen-n@1", + DataEncryptionAlgorithm::AesGcm, + None, + Some(encrypted_file.to_str().unwrap()), + None, + )?; + + // ReKey again: gen-1 → gen-2 (gen-1 becomes Deactivated) + let _gen2_id = rekey_symmetric_key(&owner_client_conf_path, &gen1_id)?; + + // Encrypt using @1 AFTER second re-key — must FAIL (gen-1 Deactivated) + let input_file2 = tmp_dir.path().join("plain2.txt"); + fs::write(&input_file2, b"should-fail")?; + let encrypted_file2 = tmp_dir.path().join("cipher2.enc"); + + let result = encrypt( + &owner_client_conf_path, + input_file2.to_str().unwrap(), + "kst-enc-gen-n@1", + DataEncryptionAlgorithm::AesGcm, + None, + Some(encrypted_file2.to_str().unwrap()), + None, + ); + assert!( + result.is_err(), + "encrypt with @1 after second re-key must fail (gen-1 Deactivated)" + ); + + // Decrypt using @1 AFTER second re-key — must SUCCEED (Deactivated allows decrypt) + let decrypted_file = tmp_dir.path().join("decrypted.txt"); + decrypt( + &owner_client_conf_path, + encrypted_file.to_str().unwrap(), + "kst-enc-gen-n@1", + DataEncryptionAlgorithm::AesGcm, + None, + Some(decrypted_file.to_str().unwrap()), + None, + )?; + let decrypted = fs::read(&decrypted_file)?; + assert_eq!(decrypted, b"gen-1-test"); + + Ok(()) +} + +/// Decrypt with `name@first` after rotation: resolves to gen-0 +#[tokio::test] +pub(crate) async fn test_keyset_decrypt_at_first() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + let key_id = create_symmetric_key( + &owner_client_conf_path, + &["--number-of-bits", "256", "kst-dec-first"], + )?; + + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--rotation-name", + "kst-dec-first", + ]; + run_ckms(&owner_client_conf_path, &args)?; + + // Encrypt with gen-0 UID + let tmp_dir = TempDir::new()?; + let input_file = tmp_dir.path().join("plain.txt"); + fs::write(&input_file, b"decrypt-first-test")?; + let encrypted_file = tmp_dir.path().join("cipher.enc"); + + encrypt( + &owner_client_conf_path, + input_file.to_str().unwrap(), + &key_id, + DataEncryptionAlgorithm::AesGcm, + None, + Some(encrypted_file.to_str().unwrap()), + None, + )?; + + // ReKey: gen-0 → gen-1 + let _new_key_id = rekey_symmetric_key(&owner_client_conf_path, &key_id)?; + + // Decrypt using @first — should resolve to gen-0 + let decrypted_file = tmp_dir.path().join("decrypted.txt"); + decrypt( + &owner_client_conf_path, + encrypted_file.to_str().unwrap(), + "kst-dec-first@first", + DataEncryptionAlgorithm::AesGcm, + None, + Some(decrypted_file.to_str().unwrap()), + None, + )?; + let decrypted = fs::read(&decrypted_file)?; + assert_eq!(decrypted, b"decrypt-first-test"); + + Ok(()) +} + +/// Decrypt with `name@0` after double rotation: resolves to gen-0 +#[tokio::test] +pub(crate) async fn test_keyset_decrypt_at_generation_n() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + let key_id = create_symmetric_key( + &owner_client_conf_path, + &["--number-of-bits", "256", "kst-dec-gen-n"], + )?; + + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--rotation-name", + "kst-dec-gen-n", + ]; + run_ckms(&owner_client_conf_path, &args)?; + + // Encrypt with gen-0 UID + let tmp_dir = TempDir::new()?; + let input_file = tmp_dir.path().join("plain.txt"); + fs::write(&input_file, b"decrypt-gen0-test")?; + let encrypted_file = tmp_dir.path().join("cipher.enc"); + + encrypt( + &owner_client_conf_path, + input_file.to_str().unwrap(), + &key_id, + DataEncryptionAlgorithm::AesGcm, + None, + Some(encrypted_file.to_str().unwrap()), + None, + )?; + + // ReKey twice: gen-0 → gen-1 → gen-2 + let gen1_id = rekey_symmetric_key(&owner_client_conf_path, &key_id)?; + let _gen2_id = rekey_symmetric_key(&owner_client_conf_path, &gen1_id)?; + + // Decrypt using @0 — should resolve to gen-0 + let decrypted_file = tmp_dir.path().join("decrypted.txt"); + decrypt( + &owner_client_conf_path, + encrypted_file.to_str().unwrap(), + "kst-dec-gen-n@0", + DataEncryptionAlgorithm::AesGcm, + None, + Some(decrypted_file.to_str().unwrap()), + None, + )?; + let decrypted = fs::read(&decrypted_file)?; + assert_eq!(decrypted, b"decrypt-gen0-test"); + + Ok(()) +} + +/// Encrypt with `name@99` — nonexistent generation — must fail +#[tokio::test] +pub(crate) async fn test_keyset_encrypt_at_invalid_generation() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + let key_id = create_symmetric_key( + &owner_client_conf_path, + &["--number-of-bits", "256", "kst-invalid-gen"], + )?; + + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--rotation-name", + "kst-invalid-gen", + ]; + run_ckms(&owner_client_conf_path, &args)?; + + // Encrypt using @99 — generation doesn't exist + let tmp_dir = TempDir::new()?; + let input_file = tmp_dir.path().join("plain.txt"); + fs::write(&input_file, b"should-fail")?; + let encrypted_file = tmp_dir.path().join("cipher.enc"); + + let result = encrypt( + &owner_client_conf_path, + input_file.to_str().unwrap(), + "kst-invalid-gen@99", + DataEncryptionAlgorithm::AesGcm, + None, + Some(encrypted_file.to_str().unwrap()), + None, + ); + assert!(result.is_err(), "expected encrypt with @99 to fail"); + + Ok(()) +} + +/// Attempting to re-key a non-latest keyset member is rejected +#[tokio::test] +pub(crate) async fn test_rekey_non_latest_rejected() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create AES-256 key with keyset name as UID + let key_id = create_symmetric_key( + &owner_client_conf_path, + &["--number-of-bits", "256", "e2e-nlat"], + )?; + + // Set rotation name (keyset) without interval + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--rotation-name", + "e2e-nlat", + ]; + run_ckms(&owner_client_conf_path, &args)?; + + // ReKey: gen-0 → gen-1 + let _new_key_id = rekey_symmetric_key(&owner_client_conf_path, &key_id)?; + + // Attempt to re-key gen-0 (now non-latest, Deactivated) via @first — should fail + let args = vec!["sym", "keys", "re-key", "--key-id", "e2e-nlat@first"]; + let stderr = run_ckms_expect_error(&owner_client_conf_path, &args)?; + assert!( + stderr.contains("not the latest"), + "expected 'not the latest' error, got: {stderr}" + ); + + Ok(()) +} + +/// Full keyset lifecycle exercising KMIP §4.57 state enforcement across all +/// addressing forms: bare name, `@first`, `@0`, `@latest`, `@N`. +/// +/// States tested: Active, Deactivated (via Re-Key), Compromised (via Revoke `KeyCompromise`). +/// Operations tested: Encrypt (protection op — Active only), Decrypt (processing op — +/// Active, Deactivated, Compromised). +#[tokio::test] +pub(crate) async fn test_keyset_full_lifecycle() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + let tmp_dir = TempDir::new()?; + let plaintext = b"lifecycle-test-data"; + + // ── Step 1: Create AES-256 key with keyset name as UID ───────────── + let gen0_id = create_symmetric_key( + &owner_client_conf_path, + &["--number-of-bits", "256", "lc-keyset"], + )?; + + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &gen0_id, + "--rotation-name", + "lc-keyset", + ]; + run_ckms(&owner_client_conf_path, &args)?; + + // ── Step 2: Encrypt with gen-0 (Active) → OK ──────────────────────── + let input_file = tmp_dir.path().join("plain.txt"); + fs::write(&input_file, plaintext)?; + let enc_gen0 = tmp_dir.path().join("enc_gen0.enc"); + + encrypt( + &owner_client_conf_path, + input_file.to_str().unwrap(), + "lc-keyset", // bare name → latest = gen-0 + DataEncryptionAlgorithm::AesGcm, + None, + Some(enc_gen0.to_str().unwrap()), + None, + )?; + + // ── Step 3: Re-Key gen-0 → gen-1 (gen-0 becomes Deactivated) ──────── + let gen1_id = rekey_symmetric_key(&owner_client_conf_path, &gen0_id)?; + + // ── Step 4: Encrypt with @0 (Deactivated) → FAIL ──────────────────── + let input_fail = tmp_dir.path().join("fail.txt"); + fs::write(&input_fail, b"should-fail")?; + let enc_fail = tmp_dir.path().join("enc_fail.enc"); + + let result = encrypt( + &owner_client_conf_path, + input_fail.to_str().unwrap(), + "lc-keyset@0", + DataEncryptionAlgorithm::AesGcm, + None, + Some(enc_fail.to_str().unwrap()), + None, + ); + assert!( + result.is_err(), + "encrypt with @0 must fail (gen-0 Deactivated)" + ); + + // @first — same result + let result = encrypt( + &owner_client_conf_path, + input_fail.to_str().unwrap(), + "lc-keyset@first", + DataEncryptionAlgorithm::AesGcm, + None, + Some(enc_fail.to_str().unwrap()), + None, + ); + assert!( + result.is_err(), + "encrypt with @first must fail (gen-0 Deactivated)" + ); + + // ── Step 5: Decrypt with @0 (Deactivated) → OK ────────────────────── + let dec_gen0 = tmp_dir.path().join("dec_gen0.txt"); + decrypt( + &owner_client_conf_path, + enc_gen0.to_str().unwrap(), + "lc-keyset@0", + DataEncryptionAlgorithm::AesGcm, + None, + Some(dec_gen0.to_str().unwrap()), + None, + )?; + assert_eq!(fs::read(&dec_gen0)?, plaintext); + + // @first — same result + let dec_gen0_first = tmp_dir.path().join("dec_gen0_first.txt"); + decrypt( + &owner_client_conf_path, + enc_gen0.to_str().unwrap(), + "lc-keyset@first", + DataEncryptionAlgorithm::AesGcm, + None, + Some(dec_gen0_first.to_str().unwrap()), + None, + )?; + assert_eq!(fs::read(&dec_gen0_first)?, plaintext); + + // ── Step 6: Encrypt with gen-1 (Active) via multiple addressing forms ─ + let enc_gen1 = tmp_dir.path().join("enc_gen1.enc"); + encrypt( + &owner_client_conf_path, + input_file.to_str().unwrap(), + "lc-keyset", // bare name → latest = gen-1 + DataEncryptionAlgorithm::AesGcm, + None, + Some(enc_gen1.to_str().unwrap()), + None, + )?; + + let enc_gen1_latest = tmp_dir.path().join("enc_gen1_latest.enc"); + encrypt( + &owner_client_conf_path, + input_file.to_str().unwrap(), + "lc-keyset@latest", + DataEncryptionAlgorithm::AesGcm, + None, + Some(enc_gen1_latest.to_str().unwrap()), + None, + )?; + + let enc_gen1_at1 = tmp_dir.path().join("enc_gen1_at1.enc"); + encrypt( + &owner_client_conf_path, + input_file.to_str().unwrap(), + "lc-keyset@1", + DataEncryptionAlgorithm::AesGcm, + None, + Some(enc_gen1_at1.to_str().unwrap()), + None, + )?; + + // Verify all gen-1 ciphertexts decrypt correctly + let dec_gen1 = tmp_dir.path().join("dec_gen1.txt"); + decrypt( + &owner_client_conf_path, + enc_gen1.to_str().unwrap(), + &gen1_id, + DataEncryptionAlgorithm::AesGcm, + None, + Some(dec_gen1.to_str().unwrap()), + None, + )?; + assert_eq!(fs::read(&dec_gen1)?, plaintext); + + // ── Step 7: Revoke gen-1 with KeyCompromise → Compromised ─────────── + run_ckms( + &owner_client_conf_path, + &[ + "sym", + "keys", + "revoke", + "--key-id", + &gen1_id, + "--reason-code", + "key-compromise", + "compromised for lifecycle testing", + ], + )?; + + // ── Step 8: Encrypt with @1 (Compromised) → FAIL ──────────────────── + let result = encrypt( + &owner_client_conf_path, + input_fail.to_str().unwrap(), + "lc-keyset@1", + DataEncryptionAlgorithm::AesGcm, + None, + Some(enc_fail.to_str().unwrap()), + None, + ); + assert!( + result.is_err(), + "encrypt with @1 must fail (gen-1 Compromised)" + ); + + // bare name (latest = gen-1, Compromised) → also FAIL + let result = encrypt( + &owner_client_conf_path, + input_fail.to_str().unwrap(), + "lc-keyset", + DataEncryptionAlgorithm::AesGcm, + None, + Some(enc_fail.to_str().unwrap()), + None, + ); + assert!( + result.is_err(), + "encrypt with bare name must fail (latest gen-1 Compromised)" + ); + + // ── Step 9: Decrypt with @1 (Compromised) → OK ────────────────────── + let dec_gen1_comp = tmp_dir.path().join("dec_gen1_comp.txt"); + decrypt( + &owner_client_conf_path, + enc_gen1.to_str().unwrap(), + "lc-keyset@1", + DataEncryptionAlgorithm::AesGcm, + None, + Some(dec_gen1_comp.to_str().unwrap()), + None, + )?; + assert_eq!(fs::read(&dec_gen1_comp)?, plaintext); + + // ── Step 10: Decrypt wrong key → crypto error ─────────────────────── + let dec_wrong = tmp_dir.path().join("dec_wrong.txt"); + let result = decrypt( + &owner_client_conf_path, + enc_gen0.to_str().unwrap(), + "lc-keyset@1", // gen-0 ciphertext, gen-1 key + DataEncryptionAlgorithm::AesGcm, + None, + Some(dec_wrong.to_str().unwrap()), + None, + ); + assert!( + result.is_err(), + "decrypt with wrong generation key must fail" + ); + + // ── Step 11: Decrypt via bare name (TryEach) finds correct key ────── + let dec_bare = tmp_dir.path().join("dec_bare.txt"); + decrypt( + &owner_client_conf_path, + enc_gen0.to_str().unwrap(), + "lc-keyset", // bare name walks chain → finds gen-0 + DataEncryptionAlgorithm::AesGcm, + None, + Some(dec_bare.to_str().unwrap()), + None, + )?; + assert_eq!(fs::read(&dec_bare)?, plaintext); + + Ok(()) +} diff --git a/crate/clients/clap/src/actions/certificates/revoke_certificate.rs b/crate/clients/clap/src/actions/certificates/revoke_certificate.rs index 87475cd4f5..50df816e78 100644 --- a/crate/clients/clap/src/actions/certificates/revoke_certificate.rs +++ b/crate/clients/clap/src/actions/certificates/revoke_certificate.rs @@ -1,10 +1,16 @@ use clap::Parser; -use cosmian_kms_client::{KmsClient, kmip_2_1::kmip_types::UniqueIdentifier}; +use cosmian_kms_client::{ + KmsClient, cosmian_kmip::kmip_0::kmip_types::RevocationReasonCode, + kmip_2_1::kmip_types::UniqueIdentifier, +}; use crate::{ actions::{ labels::{CERTIFICATE_ID, TAG}, - shared::{get_key_uid, utils::revoke}, + shared::{ + get_key_uid, + utils::{parse_revocation_reason_code, revoke}, + }, }, error::result::KmsCliResult, }; @@ -19,6 +25,15 @@ pub struct RevokeCertificateAction { #[clap(required = true)] pub(crate) revocation_reason: String, + /// The revocation reason code [default: unspecified] + /// + /// Valid values: unspecified, key-compromise, ca-compromise, + /// affiliation-changed, superseded, cessation-of-operation, + /// privilege-withdrawn + #[clap(long = "reason-code", short = 'r', default_value = "unspecified", + value_parser = parse_revocation_reason_code)] + pub(crate) reason_code: RevocationReasonCode, + /// The certificate unique identifier of the certificate to revoke. /// If not specified, tags should be specified #[clap(long = CERTIFICATE_ID, short = 'c', group = "certificate-tags")] @@ -42,6 +57,12 @@ impl RevokeCertificateAction { self.tags.as_ref(), CERTIFICATE_ID, )?; - revoke(kms_rest_client, &id, &self.revocation_reason).await + revoke( + kms_rest_client, + &id, + &self.revocation_reason, + self.reason_code, + ) + .await } } diff --git a/crate/clients/clap/src/actions/cover_crypt/keys/revoke_key.rs b/crate/clients/clap/src/actions/cover_crypt/keys/revoke_key.rs index a367608dfd..c46216c5af 100644 --- a/crate/clients/clap/src/actions/cover_crypt/keys/revoke_key.rs +++ b/crate/clients/clap/src/actions/cover_crypt/keys/revoke_key.rs @@ -1,10 +1,16 @@ use clap::Parser; -use cosmian_kms_client::{KmsClient, kmip_2_1::kmip_types::UniqueIdentifier}; +use cosmian_kms_client::{ + KmsClient, cosmian_kmip::kmip_0::kmip_types::RevocationReasonCode, + kmip_2_1::kmip_types::UniqueIdentifier, +}; use crate::{ actions::{ labels::KEY_ID, - shared::{get_key_uid, utils::revoke}, + shared::{ + get_key_uid, + utils::{parse_revocation_reason_code, revoke}, + }, }, error::result::KmsCliResult, }; @@ -28,6 +34,15 @@ pub struct RevokeKeyAction { #[clap(required = true)] revocation_reason: String, + /// The revocation reason code [default: unspecified] + /// + /// Valid values: unspecified, key-compromise, ca-compromise, + /// affiliation-changed, superseded, cessation-of-operation, + /// privilege-withdrawn + #[clap(long = "reason-code", short = 'r', default_value = "unspecified", + value_parser = parse_revocation_reason_code)] + reason_code: RevocationReasonCode, + /// The key unique identifier of the key to revoke. /// If not specified, tags should be specified #[clap(long = KEY_ID, short = 'k', group = "key-tags")] @@ -42,6 +57,12 @@ pub struct RevokeKeyAction { impl RevokeKeyAction { pub async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { let id = get_key_uid(self.key_id.as_ref(), self.tags.as_ref(), KEY_ID)?; - revoke(kms_rest_client, &id, &self.revocation_reason).await + revoke( + kms_rest_client, + &id, + &self.revocation_reason, + self.reason_code, + ) + .await } } diff --git a/crate/clients/clap/src/actions/elliptic_curves/keys/create_key_pair.rs b/crate/clients/clap/src/actions/elliptic_curves/keys/create_key_pair.rs index 6f0e66beaf..25b7204412 100644 --- a/crate/clients/clap/src/actions/elliptic_curves/keys/create_key_pair.rs +++ b/crate/clients/clap/src/actions/elliptic_curves/keys/create_key_pair.rs @@ -6,7 +6,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::console, + actions::{console, shared::RotationPolicyArgs}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -54,6 +54,10 @@ pub struct CreateKeyPairAction { verbatim_doc_comment )] pub(crate) wrapping_key_id: Option, + + /// Optional rotation policy to apply immediately after key pair creation. + #[clap(flatten)] + pub(crate) rotation_policy: RotationPolicyArgs, } impl CreateKeyPairAction { @@ -82,6 +86,14 @@ impl CreateKeyPairAction { let private_key_unique_identifier = &create_key_pair_response.private_key_unique_identifier; let public_key_unique_identifier = &create_key_pair_response.public_key_unique_identifier; + // Apply rotation policy on the private key (which is the keyset anchor) + if self.rotation_policy.is_set() { + let sk_id = private_key_unique_identifier + .as_str() + .with_context(|| "the server did not return a private key id as a string")?; + self.rotation_policy.apply(&kms_rest_client, sk_id).await?; + } + let mut stdout = console::Stdout::new("The EC key pair has been created."); stdout.set_tags(Some(&self.tags)); stdout.set_key_pair_unique_identifier( diff --git a/crate/clients/clap/src/actions/elliptic_curves/keys/mod.rs b/crate/clients/clap/src/actions/elliptic_curves/keys/mod.rs index 04e9bdabab..f2cf774a08 100644 --- a/crate/clients/clap/src/actions/elliptic_curves/keys/mod.rs +++ b/crate/clients/clap/src/actions/elliptic_curves/keys/mod.rs @@ -7,7 +7,8 @@ use self::{ }; use crate::{ actions::shared::{ - ActivateKeyAction, ExportSecretDataOrKeyAction, ImportSecretDataOrKeyAction, + ActivateKeyAction, ExportSecretDataOrKeyAction, GetRotationPolicyAction, + ImportSecretDataOrKeyAction, ReKeyKeyPairAction, SetRotationPolicyAction, UnwrapSecretDataOrKeyAction, WrapSecretDataOrKeyAction, }, error::result::KmsCliResult, @@ -28,6 +29,9 @@ pub enum KeysCommands { Unwrap(UnwrapSecretDataOrKeyAction), Revoke(RevokeKeyAction), Destroy(DestroyKeyAction), + ReKey(ReKeyKeyPairAction), + SetRotationPolicy(SetRotationPolicyAction), + GetRotationPolicy(GetRotationPolicyAction), } impl KeysCommands { @@ -55,6 +59,15 @@ impl KeysCommands { Self::Destroy(action) => { action.run(kms_rest_client).await?; } + Self::ReKey(action) => { + action.run(kms_rest_client).await?; + } + Self::SetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } + Self::GetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } } Ok(()) diff --git a/crate/clients/clap/src/actions/elliptic_curves/keys/revoke_key.rs b/crate/clients/clap/src/actions/elliptic_curves/keys/revoke_key.rs index d52c0e472b..b337dbb34e 100644 --- a/crate/clients/clap/src/actions/elliptic_curves/keys/revoke_key.rs +++ b/crate/clients/clap/src/actions/elliptic_curves/keys/revoke_key.rs @@ -1,10 +1,16 @@ use clap::Parser; -use cosmian_kms_client::{KmsClient, kmip_2_1::kmip_types::UniqueIdentifier}; +use cosmian_kms_client::{ + KmsClient, cosmian_kmip::kmip_0::kmip_types::RevocationReasonCode, + kmip_2_1::kmip_types::UniqueIdentifier, +}; use crate::{ actions::{ labels::KEY_ID, - shared::{get_key_uid, utils::revoke}, + shared::{ + get_key_uid, + utils::{parse_revocation_reason_code, revoke}, + }, }, error::result::KmsCliResult, }; @@ -22,6 +28,15 @@ pub struct RevokeKeyAction { #[clap(required = true)] revocation_reason: String, + /// The revocation reason code [default: unspecified] + /// + /// Valid values: unspecified, key-compromise, ca-compromise, + /// affiliation-changed, superseded, cessation-of-operation, + /// privilege-withdrawn + #[clap(long = "reason-code", short = 'r', default_value = "unspecified", + value_parser = parse_revocation_reason_code)] + reason_code: RevocationReasonCode, + /// The key unique identifier of the key to revoke. /// If not specified, tags should be specified #[clap(long = KEY_ID, short = 'k', group = "key-tags")] @@ -36,6 +51,12 @@ pub struct RevokeKeyAction { impl RevokeKeyAction { pub async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { let id = get_key_uid(self.key_id.as_ref(), self.tags.as_ref(), KEY_ID)?; - revoke(kms_rest_client, &id, &self.revocation_reason).await + revoke( + kms_rest_client, + &id, + &self.revocation_reason, + self.reason_code, + ) + .await } } diff --git a/crate/clients/clap/src/actions/fpe/keys/revoke_key.rs b/crate/clients/clap/src/actions/fpe/keys/revoke_key.rs index b27672c115..fb61028fa3 100644 --- a/crate/clients/clap/src/actions/fpe/keys/revoke_key.rs +++ b/crate/clients/clap/src/actions/fpe/keys/revoke_key.rs @@ -1,10 +1,16 @@ use clap::Parser; -use cosmian_kms_client::{KmsClient, kmip_2_1::kmip_types::UniqueIdentifier}; +use cosmian_kms_client::{ + KmsClient, cosmian_kmip::kmip_0::kmip_types::RevocationReasonCode, + kmip_2_1::kmip_types::UniqueIdentifier, +}; use crate::{ actions::{ labels::KEY_ID, - shared::{get_key_uid, utils::revoke}, + shared::{ + get_key_uid, + utils::{parse_revocation_reason_code, revoke}, + }, }, error::result::KmsCliResult, }; @@ -13,12 +19,21 @@ use crate::{ /// /// When a key is revoked, it can only be exported by the owner of the key, /// using the --allow-revoked flag on the export function. -#[derive(Parser, Default, Debug)] +#[derive(Parser, Debug)] pub struct RevokeKeyAction { /// The reason for the revocation as a string #[clap(required = true)] pub(crate) revocation_reason: String, + /// The revocation reason code [default: unspecified] + /// + /// Valid values: unspecified, key-compromise, ca-compromise, + /// affiliation-changed, superseded, cessation-of-operation, + /// privilege-withdrawn + #[clap(long = "reason-code", short = 'r', default_value = "unspecified", + value_parser = parse_revocation_reason_code)] + pub(crate) reason_code: RevocationReasonCode, + /// The key unique identifier of the key to revoke. /// If not specified, tags should be specified #[clap(long = KEY_ID, short = 'k', group = "key-tags")] @@ -33,6 +48,12 @@ pub struct RevokeKeyAction { impl RevokeKeyAction { pub(crate) async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { let id = get_key_uid(self.key_id.as_ref(), self.tags.as_ref(), KEY_ID)?; - revoke(kms_rest_client, &id, &self.revocation_reason).await + revoke( + kms_rest_client, + &id, + &self.revocation_reason, + self.reason_code, + ) + .await } } diff --git a/crate/clients/clap/src/actions/opaque_object/revoke.rs b/crate/clients/clap/src/actions/opaque_object/revoke.rs index 3c54dd4898..b9f8717093 100644 --- a/crate/clients/clap/src/actions/opaque_object/revoke.rs +++ b/crate/clients/clap/src/actions/opaque_object/revoke.rs @@ -1,10 +1,16 @@ use clap::Parser; -use cosmian_kms_client::{KmsClient, kmip_2_1::kmip_types::UniqueIdentifier}; +use cosmian_kms_client::{ + KmsClient, cosmian_kmip::kmip_0::kmip_types::RevocationReasonCode, + kmip_2_1::kmip_types::UniqueIdentifier, +}; use crate::{ actions::{ labels::KEY_ID, - shared::{get_key_uid, utils::revoke}, + shared::{ + get_key_uid, + utils::{parse_revocation_reason_code, revoke}, + }, }, error::result::KmsCliResult, }; @@ -12,12 +18,21 @@ use crate::{ /// Revoke an `OpaqueObject`. /// /// Once revoked, the object can typically only be exported by the owner when explicitly allowed. -#[derive(Parser, Default, Debug)] +#[derive(Parser, Debug)] pub struct RevokeOpaqueObjectAction { /// The reason for the revocation as a string #[clap(required = true)] pub(crate) revocation_reason: String, + /// The revocation reason code [default: unspecified] + /// + /// Valid values: unspecified, key-compromise, ca-compromise, + /// affiliation-changed, superseded, cessation-of-operation, + /// privilege-withdrawn + #[clap(long = "reason-code", short = 'r', default_value = "unspecified", + value_parser = parse_revocation_reason_code)] + pub(crate) reason_code: RevocationReasonCode, + /// The opaque object unique identifier to revoke. If not specified, tags should be specified #[clap(long = KEY_ID, short = 'k', group = "key-tags")] pub(crate) object_id: Option, @@ -30,6 +45,12 @@ pub struct RevokeOpaqueObjectAction { impl RevokeOpaqueObjectAction { pub(crate) async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { let id = get_key_uid(self.object_id.as_ref(), self.tags.as_ref(), KEY_ID)?; - revoke(kms_rest_client, &id, &self.revocation_reason).await + revoke( + kms_rest_client, + &id, + &self.revocation_reason, + self.reason_code, + ) + .await } } diff --git a/crate/clients/clap/src/actions/pqc/keys/create_key_pair.rs b/crate/clients/clap/src/actions/pqc/keys/create_key_pair.rs index abe38806f7..f0bb4ca4a7 100644 --- a/crate/clients/clap/src/actions/pqc/keys/create_key_pair.rs +++ b/crate/clients/clap/src/actions/pqc/keys/create_key_pair.rs @@ -11,7 +11,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::console, + actions::{console, shared::RotationPolicyArgs}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -152,6 +152,10 @@ pub struct CreatePqcKeyPairAction { /// Sensitive: if set, the private key will not be exportable #[clap(long = "sensitive", default_value = "false")] pub(crate) sensitive: bool, + + /// Optional rotation policy to apply immediately after key pair creation. + #[clap(flatten)] + pub(crate) rotation_policy: RotationPolicyArgs, } impl CreatePqcKeyPairAction { @@ -184,6 +188,15 @@ impl CreatePqcKeyPairAction { .await .with_context(|| "failed creating a PQC key pair")?; + // Apply rotation policy on the private key (which is the keyset anchor) + if self.rotation_policy.is_set() { + let sk_id = response + .private_key_unique_identifier + .as_str() + .with_context(|| "the server did not return a private key id as a string")?; + self.rotation_policy.apply(&kms_rest_client, sk_id).await?; + } + let mut stdout = console::Stdout::new("The PQC key pair has been properly generated."); stdout.set_tags(Some(&self.tags)); stdout.set_key_pair_unique_identifier( diff --git a/crate/clients/clap/src/actions/pqc/keys/mod.rs b/crate/clients/clap/src/actions/pqc/keys/mod.rs index 4ecd397c45..4ae065cf9a 100644 --- a/crate/clients/clap/src/actions/pqc/keys/mod.rs +++ b/crate/clients/clap/src/actions/pqc/keys/mod.rs @@ -7,7 +7,8 @@ use self::{ }; use crate::{ actions::shared::{ - ActivateKeyAction, ExportSecretDataOrKeyAction, ImportSecretDataOrKeyAction, + ActivateKeyAction, ExportSecretDataOrKeyAction, GetRotationPolicyAction, + ImportSecretDataOrKeyAction, ReKeyKeyPairAction, SetRotationPolicyAction, UnwrapSecretDataOrKeyAction, WrapSecretDataOrKeyAction, }, error::result::KmsCliResult, @@ -28,6 +29,9 @@ pub enum KeysCommands { Unwrap(UnwrapSecretDataOrKeyAction), Revoke(RevokeKeyAction), Destroy(DestroyKeyAction), + ReKey(ReKeyKeyPairAction), + SetRotationPolicy(SetRotationPolicyAction), + GetRotationPolicy(GetRotationPolicyAction), } impl KeysCommands { @@ -55,6 +59,15 @@ impl KeysCommands { Self::Destroy(action) => { action.run(kms_rest_client).await?; } + Self::ReKey(action) => { + action.run(kms_rest_client).await?; + } + Self::SetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } + Self::GetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } } Ok(()) } diff --git a/crate/clients/clap/src/actions/pqc/keys/revoke_key.rs b/crate/clients/clap/src/actions/pqc/keys/revoke_key.rs index 363e72819a..fedda3eaa8 100644 --- a/crate/clients/clap/src/actions/pqc/keys/revoke_key.rs +++ b/crate/clients/clap/src/actions/pqc/keys/revoke_key.rs @@ -1,10 +1,16 @@ use clap::Parser; -use cosmian_kms_client::{KmsClient, kmip_2_1::kmip_types::UniqueIdentifier}; +use cosmian_kms_client::{ + KmsClient, cosmian_kmip::kmip_0::kmip_types::RevocationReasonCode, + kmip_2_1::kmip_types::UniqueIdentifier, +}; use crate::{ actions::{ labels::KEY_ID, - shared::{get_key_uid, utils::revoke}, + shared::{ + get_key_uid, + utils::{parse_revocation_reason_code, revoke}, + }, }, error::result::KmsCliResult, }; @@ -16,6 +22,15 @@ pub struct RevokeKeyAction { #[clap(required = true)] revocation_reason: String, + /// The revocation reason code [default: unspecified] + /// + /// Valid values: unspecified, key-compromise, ca-compromise, + /// affiliation-changed, superseded, cessation-of-operation, + /// privilege-withdrawn + #[clap(long = "reason-code", short = 'r', default_value = "unspecified", + value_parser = parse_revocation_reason_code)] + reason_code: RevocationReasonCode, + /// The key unique identifier of the key to revoke #[clap(long = KEY_ID, short = 'k', group = "key-tags")] key_id: Option, @@ -28,6 +43,12 @@ pub struct RevokeKeyAction { impl RevokeKeyAction { pub async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { let id = get_key_uid(self.key_id.as_ref(), self.tags.as_ref(), KEY_ID)?; - revoke(kms_rest_client, &id, &self.revocation_reason).await + revoke( + kms_rest_client, + &id, + &self.revocation_reason, + self.reason_code, + ) + .await } } diff --git a/crate/clients/clap/src/actions/rsa/keys/create_key_pair.rs b/crate/clients/clap/src/actions/rsa/keys/create_key_pair.rs index bf146270e6..c49a638138 100644 --- a/crate/clients/clap/src/actions/rsa/keys/create_key_pair.rs +++ b/crate/clients/clap/src/actions/rsa/keys/create_key_pair.rs @@ -5,7 +5,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::console, + actions::{console, shared::RotationPolicyArgs}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -55,6 +55,10 @@ pub struct CreateKeyPairAction { verbatim_doc_comment )] pub wrapping_key_id: Option, + + /// Optional rotation policy to apply immediately after key pair creation. + #[clap(flatten)] + pub rotation_policy: RotationPolicyArgs, } impl Default for CreateKeyPairAction { @@ -65,6 +69,7 @@ impl Default for CreateKeyPairAction { private_key_id: None, sensitive: false, wrapping_key_id: None, + rotation_policy: RotationPolicyArgs::default(), } } } @@ -112,6 +117,14 @@ impl CreateKeyPairAction { let private_key_unique_identifier = &create_key_pair_response.private_key_unique_identifier; let public_key_unique_identifier = &create_key_pair_response.public_key_unique_identifier; + // Apply rotation policy on the private key (which is the keyset anchor) + if self.rotation_policy.is_set() { + let sk_id = private_key_unique_identifier + .as_str() + .with_context(|| "the server did not return a private key id as a string")?; + self.rotation_policy.apply(&kms_rest_client, sk_id).await?; + } + let mut stdout = console::Stdout::new("The RSA key pair has been created."); stdout.set_tags(Some(&self.tags)); stdout.set_key_pair_unique_identifier( diff --git a/crate/clients/clap/src/actions/rsa/keys/mod.rs b/crate/clients/clap/src/actions/rsa/keys/mod.rs index 77f38b0bc2..f2950edb0e 100644 --- a/crate/clients/clap/src/actions/rsa/keys/mod.rs +++ b/crate/clients/clap/src/actions/rsa/keys/mod.rs @@ -7,7 +7,8 @@ use self::{ }; use crate::{ actions::shared::{ - ActivateKeyAction, ExportSecretDataOrKeyAction, ImportSecretDataOrKeyAction, + ActivateKeyAction, ExportSecretDataOrKeyAction, GetRotationPolicyAction, + ImportSecretDataOrKeyAction, ReKeyKeyPairAction, SetRotationPolicyAction, UnwrapSecretDataOrKeyAction, WrapSecretDataOrKeyAction, }, error::result::KmsCliResult, @@ -28,6 +29,9 @@ pub enum KeysCommands { Unwrap(UnwrapSecretDataOrKeyAction), Revoke(RevokeKeyAction), Destroy(DestroyKeyAction), + ReKey(ReKeyKeyPairAction), + SetRotationPolicy(SetRotationPolicyAction), + GetRotationPolicy(GetRotationPolicyAction), } impl KeysCommands { @@ -72,6 +76,15 @@ impl KeysCommands { Self::Destroy(action) => { action.run(kms_rest_client).await?; } + Self::ReKey(action) => { + action.run(kms_rest_client).await?; + } + Self::SetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } + Self::GetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } } Ok(()) diff --git a/crate/clients/clap/src/actions/rsa/keys/revoke_key.rs b/crate/clients/clap/src/actions/rsa/keys/revoke_key.rs index b3c6781ae7..c48441a18b 100644 --- a/crate/clients/clap/src/actions/rsa/keys/revoke_key.rs +++ b/crate/clients/clap/src/actions/rsa/keys/revoke_key.rs @@ -1,10 +1,16 @@ use clap::Parser; -use cosmian_kms_client::{KmsClient, kmip_2_1::kmip_types::UniqueIdentifier}; +use cosmian_kms_client::{ + KmsClient, cosmian_kmip::kmip_0::kmip_types::RevocationReasonCode, + kmip_2_1::kmip_types::UniqueIdentifier, +}; use crate::{ actions::{ labels::KEY_ID, - shared::{get_key_uid, utils::revoke}, + shared::{ + get_key_uid, + utils::{parse_revocation_reason_code, revoke}, + }, }, error::result::KmsCliResult, }; @@ -22,6 +28,15 @@ pub struct RevokeKeyAction { #[clap(required = true)] pub(crate) revocation_reason: String, + /// The revocation reason code [default: unspecified] + /// + /// Valid values: unspecified, key-compromise, ca-compromise, + /// affiliation-changed, superseded, cessation-of-operation, + /// privilege-withdrawn + #[clap(long = "reason-code", short = 'r', default_value = "unspecified", + value_parser = parse_revocation_reason_code)] + pub(crate) reason_code: RevocationReasonCode, + /// The key unique identifier of the key to revoke. /// If not specified, tags should be specified #[clap(long = KEY_ID, short = 'k', group = "key-tags")] @@ -55,6 +70,12 @@ impl RevokeKeyAction { /// * The revocation request fails. pub async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { let id = get_key_uid(self.key_id.as_ref(), self.tags.as_ref(), KEY_ID)?; - revoke(kms_rest_client, &id, &self.revocation_reason).await + revoke( + kms_rest_client, + &id, + &self.revocation_reason, + self.reason_code, + ) + .await } } diff --git a/crate/clients/clap/src/actions/secret_data/revoke_secret.rs b/crate/clients/clap/src/actions/secret_data/revoke_secret.rs index 5054e17913..f4baf0f7a1 100644 --- a/crate/clients/clap/src/actions/secret_data/revoke_secret.rs +++ b/crate/clients/clap/src/actions/secret_data/revoke_secret.rs @@ -1,10 +1,16 @@ use clap::Parser; -use cosmian_kms_client::{KmsClient, kmip_2_1::kmip_types::UniqueIdentifier}; +use cosmian_kms_client::{ + KmsClient, cosmian_kmip::kmip_0::kmip_types::RevocationReasonCode, + kmip_2_1::kmip_types::UniqueIdentifier, +}; use crate::{ actions::{ labels::SECRET_DATA_ID, - shared::{get_key_uid, utils::revoke}, + shared::{ + get_key_uid, + utils::{parse_revocation_reason_code, revoke}, + }, }, error::result::KmsCliResult, }; @@ -13,12 +19,21 @@ use crate::{ /// /// When a secret data is revoked, it can only be exported by the owner of the secret data. /// using the --allow-revoked flag on the export function. -#[derive(Parser, Default, Debug)] +#[derive(Parser, Debug)] pub struct RevokeSecretDataAction { /// The reason for the revocation as a string #[clap(required = true)] pub(crate) revocation_reason: String, + /// The revocation reason code [default: unspecified] + /// + /// Valid values: unspecified, key-compromise, ca-compromise, + /// affiliation-changed, superseded, cessation-of-operation, + /// privilege-withdrawn + #[clap(long = "reason-code", short = 'r', default_value = "unspecified", + value_parser = parse_revocation_reason_code)] + pub(crate) reason_code: RevocationReasonCode, + /// The secret unique identifier of the secret to revoke. /// If not specified, tags should be specified #[clap(long = SECRET_DATA_ID, short = 's', group = "key-tags")] @@ -33,6 +48,12 @@ pub struct RevokeSecretDataAction { impl RevokeSecretDataAction { pub(crate) async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { let id = get_key_uid(self.secret_id.as_ref(), self.tags.as_ref(), SECRET_DATA_ID)?; - revoke(kms_rest_client, &id, &self.revocation_reason).await + revoke( + kms_rest_client, + &id, + &self.revocation_reason, + self.reason_code, + ) + .await } } diff --git a/crate/clients/clap/src/actions/shared/get_rotation_policy.rs b/crate/clients/clap/src/actions/shared/get_rotation_policy.rs new file mode 100644 index 0000000000..413dac6f26 --- /dev/null +++ b/crate/clients/clap/src/actions/shared/get_rotation_policy.rs @@ -0,0 +1,65 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{kmip_operations::GetAttributes, kmip_types::UniqueIdentifier}, +}; + +use crate::{ + actions::console, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; + +/// Get the automatic rotation policy for a key or key pair. +/// +/// Displays: rotation interval, offset, keyset name, generation, and last rotation date. +#[derive(Parser, Debug)] +#[clap(verbatim_doc_comment)] +pub struct GetRotationPolicyAction { + /// The unique identifier of the key to get the rotation policy from. + #[clap(long = "key-id", short = 'k')] + key_id: String, +} + +impl GetRotationPolicyAction { + pub async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult<()> { + let uid = UniqueIdentifier::TextString(self.key_id.clone()); + + let response = kms_rest_client + .get_attributes(GetAttributes { + unique_identifier: Some(uid), + attribute_reference: None, + }) + .await + .with_context(|| "failed retrieving attributes")?; + + let attrs = &response.attributes; + + let interval = attrs + .rotate_interval + .map_or_else(|| "not set".to_owned(), |v| v.to_string()); + let offset = attrs + .rotate_offset + .map_or_else(|| "not set".to_owned(), |v| v.to_string()); + let name = attrs.rotate_name.as_deref().unwrap_or("not set"); + let generation = attrs + .rotate_generation + .map_or_else(|| "not set".to_owned(), |v| v.to_string()); + let date = attrs + .rotate_date + .map_or_else(|| "never".to_owned(), |d| d.to_string()); + + let output = format!( + "Rotation policy for key: {}\n\ + \x20 Interval (seconds): {interval}\n\ + \x20 Offset (seconds): {offset}\n\ + \x20 Keyset name: {name}\n\ + \x20 Generation: {generation}\n\ + \x20 Last rotation date: {date}", + response.unique_identifier + ); + + console::Stdout::new(&output).write()?; + + Ok(()) + } +} diff --git a/crate/clients/clap/src/actions/shared/mod.rs b/crate/clients/clap/src/actions/shared/mod.rs index b828f8ba3a..f1d3054c4f 100644 --- a/crate/clients/clap/src/actions/shared/mod.rs +++ b/crate/clients/clap/src/actions/shared/mod.rs @@ -1,9 +1,13 @@ mod activate; pub(crate) mod export_key; mod get_key_uid; +mod get_rotation_policy; pub(crate) mod import_key; mod locate; +mod rekey_keypair; pub(crate) mod resolve_key; +mod rotation_policy_args; +mod set_rotation_policy; pub(crate) mod sign; pub(crate) mod signature_verify; pub mod utils; @@ -15,8 +19,12 @@ mod unwrap_key; pub use activate::ActivateKeyAction; pub use export_key::ExportSecretDataOrKeyAction; pub(crate) use get_key_uid::get_key_uid; +pub use get_rotation_policy::GetRotationPolicyAction; pub use import_key::ImportSecretDataOrKeyAction; pub use locate::LocateObjectsAction; +pub use rekey_keypair::ReKeyKeyPairAction; +pub use rotation_policy_args::RotationPolicyArgs; +pub use set_rotation_policy::SetRotationPolicyAction; pub use unwrap_key::UnwrapSecretDataOrKeyAction; pub use wrap_key::WrapSecretDataOrKeyAction; diff --git a/crate/clients/clap/src/actions/shared/rekey_keypair.rs b/crate/clients/clap/src/actions/shared/rekey_keypair.rs new file mode 100644 index 0000000000..c4546b7d77 --- /dev/null +++ b/crate/clients/clap/src/actions/shared/rekey_keypair.rs @@ -0,0 +1,38 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{kmip_operations::ReKeyKeyPair, kmip_types::UniqueIdentifier}, +}; + +use crate::{ + actions::{console, labels::KEY_ID}, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; + +/// Rotate an existing asymmetric key pair, generating a new private/public key pair +#[derive(Parser)] +#[clap(verbatim_doc_comment)] +pub struct ReKeyKeyPairAction { + /// The unique identifier of the private key to re-key. + #[clap(long = KEY_ID, short = 'k')] + pub(crate) key_id: String, +} + +impl ReKeyKeyPairAction { + pub(crate) async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { + let rekey_keypair_request = ReKeyKeyPair { + private_key_unique_identifier: Some(UniqueIdentifier::TextString(self.key_id.clone())), + ..ReKeyKeyPair::default() + }; + let response = kms_rest_client + .rekey_keypair(rekey_keypair_request) + .await + .with_context(|| "failed rekeying the key pair")?; + + let mut stdout = console::Stdout::new("The key pair was successfully rotated."); + stdout.set_unique_identifier(&response.private_key_unique_identifier); + stdout.write()?; + + Ok(response.private_key_unique_identifier) + } +} diff --git a/crate/clients/clap/src/actions/shared/rotation_policy_args.rs b/crate/clients/clap/src/actions/shared/rotation_policy_args.rs new file mode 100644 index 0000000000..5f0d7ceedd --- /dev/null +++ b/crate/clients/clap/src/actions/shared/rotation_policy_args.rs @@ -0,0 +1,86 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{ + kmip_attributes::Attribute, kmip_operations::SetAttribute, kmip_types::UniqueIdentifier, + }, +}; + +use crate::error::result::{KmsCliResult, KmsCliResultHelper}; + +/// Optional rotation policy arguments that can be added to key creation commands. +/// +/// When provided, these are applied as `SetAttribute` calls immediately after the key is created. +#[derive(Parser, Default, Debug, Clone)] +pub struct RotationPolicyArgs { + /// Enroll this key in a keyset so it can be addressed via `name@latest`, + /// `name@first`, `name@N` syntax. The keyset name is set automatically to + /// the key's own ID returned by the server. + #[clap( + long = "enroll-keyset", + short = 'n', + default_value = "false", + verbatim_doc_comment + )] + pub enroll_keyset: bool, + + /// Rotation interval in seconds. The key will be automatically re-keyed at this interval. + /// Set to 0 to disable automatic rotation while preserving other policy fields. + #[clap(long = "rotation-interval", required = false)] + pub rotate_interval: Option, + + /// Offset in seconds from the initial date before the first rotation occurs. + #[clap(long = "rotation-offset", required = false)] + pub rotate_offset: Option, +} + +impl RotationPolicyArgs { + /// Returns `true` if at least one rotation policy field is set. + #[must_use] + pub const fn is_set(&self) -> bool { + self.enroll_keyset || self.rotate_interval.is_some() || self.rotate_offset.is_some() + } + + /// Apply rotation policy attributes via `SetAttribute` calls on the given key ID. + /// + /// When `--enroll-keyset` is set, the keyset name is automatically set to `key_id` + /// (the ID returned by the server), so the key can be addressed via `key_id@latest`, + /// `key_id@first`, `key_id@N` syntax. + pub async fn apply(&self, kms_rest_client: &KmsClient, key_id: &str) -> KmsCliResult<()> { + let uid = UniqueIdentifier::TextString(key_id.to_owned()); + + if let Some(interval) = self.rotate_interval { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateInterval(interval), + }) + .await + .with_context(|| "failed setting RotateInterval attribute")?; + } + + if let Some(offset) = self.rotate_offset { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateOffset(offset), + }) + .await + .with_context(|| "failed setting RotateOffset attribute")?; + } + + if self.enroll_keyset { + // Use the server-assigned key ID as the keyset name so the keyset + // invariant (key_id == rotate_name) is always satisfied automatically. + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateName(key_id.to_owned()), + }) + .await + .with_context(|| "failed setting RotateName attribute")?; + } + + Ok(()) + } +} diff --git a/crate/clients/clap/src/actions/shared/set_rotation_policy.rs b/crate/clients/clap/src/actions/shared/set_rotation_policy.rs new file mode 100644 index 0000000000..2a329935de --- /dev/null +++ b/crate/clients/clap/src/actions/shared/set_rotation_policy.rs @@ -0,0 +1,87 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{ + kmip_attributes::Attribute, kmip_operations::SetAttribute, kmip_types::UniqueIdentifier, + }, +}; + +use crate::{ + actions::console, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; + +/// Set the automatic rotation policy on a key or key pair. +/// +/// This configures: +/// - The rotation interval (how often the key is automatically re-keyed) +/// - An optional offset (delay before first rotation) +/// - An optional keyset name (for addressing key generations via name@version syntax) +/// +/// At least one of --interval or --rotation-name must be provided. +#[derive(Parser, Debug)] +#[clap(verbatim_doc_comment)] +pub struct SetRotationPolicyAction { + /// The unique identifier of the key to set the rotation policy on. + #[clap(long = "key-id", short = 'k')] + key_id: String, + + /// Rotation interval in seconds. The key will be automatically re-keyed at this interval. + /// Set to 0 to disable automatic rotation while preserving other policy fields. + #[clap(long = "interval", short = 'i')] + interval_secs: Option, + + /// Offset in seconds from the initial date before the first rotation occurs. + #[clap(long = "offset", short = 'o')] + offset_secs: Option, + + /// A keyset name for addressing key generations via name@latest, name@first, name@N syntax. + /// Must not contain the '@' character. + #[clap(long = "rotation-name", short = 'n')] + rotate_name: Option, +} + +impl SetRotationPolicyAction { + pub async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult<()> { + let uid = UniqueIdentifier::TextString(self.key_id.clone()); + + // Set the rotation interval if provided + if let Some(interval) = self.interval_secs { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateInterval(interval), + }) + .await + .with_context(|| "failed setting RotateInterval attribute")?; + } + + // Set the rotation offset if provided + if let Some(offset) = self.offset_secs { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateOffset(offset), + }) + .await + .with_context(|| "failed setting RotateOffset attribute")?; + } + + // Set the rotation name if provided + if let Some(ref name) = self.rotate_name { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateName(name.clone()), + }) + .await + .with_context(|| "failed setting RotateName attribute")?; + } + + let mut stdout = console::Stdout::new("Rotation policy set successfully."); + stdout.set_unique_identifier(&uid); + stdout.write()?; + + Ok(()) + } +} diff --git a/crate/clients/clap/src/actions/shared/utils/mod.rs b/crate/clients/clap/src/actions/shared/utils/mod.rs index 06da4a600c..7ecb306253 100644 --- a/crate/clients/clap/src/actions/shared/utils/mod.rs +++ b/crate/clients/clap/src/actions/shared/utils/mod.rs @@ -1,6 +1,6 @@ pub(crate) use activate_utils::activate; pub(crate) use destroy_utils::destroy; -pub(crate) use revoke_utils::revoke; +pub(crate) use revoke_utils::{parse_revocation_reason_code, revoke}; mod activate_utils; mod destroy_utils; diff --git a/crate/clients/clap/src/actions/shared/utils/revoke_utils.rs b/crate/clients/clap/src/actions/shared/utils/revoke_utils.rs index 73308861ff..e1914eaafd 100644 --- a/crate/clients/clap/src/actions/shared/utils/revoke_utils.rs +++ b/crate/clients/clap/src/actions/shared/utils/revoke_utils.rs @@ -10,16 +10,37 @@ use crate::{ error::result::{KmsCliResult, KmsCliResultHelper}, }; +/// Parse a CLI string into a `RevocationReasonCode`. +/// +/// Accepts `kebab-case`, `snake_case`, or `PascalCase` (case-insensitive). +pub(crate) fn parse_revocation_reason_code(s: &str) -> Result { + match s.to_lowercase().replace(['-', '_'], "").as_str() { + "unspecified" => Ok(RevocationReasonCode::Unspecified), + "keycompromise" => Ok(RevocationReasonCode::KeyCompromise), + "cacompromise" => Ok(RevocationReasonCode::CACompromise), + "affiliationchanged" => Ok(RevocationReasonCode::AffiliationChanged), + "superseded" => Ok(RevocationReasonCode::Superseded), + "cessationofoperation" => Ok(RevocationReasonCode::CessationOfOperation), + "privilegewithdrawn" => Ok(RevocationReasonCode::PrivilegeWithdrawn), + other => Err(format!( + "unknown revocation reason code: '{other}'. Valid values: unspecified, \ + key-compromise, ca-compromise, affiliation-changed, superseded, \ + cessation-of-operation, privilege-withdrawn" + )), + } +} + pub(crate) async fn revoke( kms_rest_client: KmsClient, key_id: &str, revocation_reason: &str, + reason_code: RevocationReasonCode, ) -> KmsCliResult { // Create the kmip query let revoke_query = build_revoke_key_request( key_id, RevocationReason { - revocation_reason_code: RevocationReasonCode::Unspecified, + revocation_reason_code: reason_code, revocation_message: Some(revocation_reason.to_owned()), }, )?; diff --git a/crate/clients/clap/src/actions/symmetric/keys/create_key.rs b/crate/clients/clap/src/actions/symmetric/keys/create_key.rs index 4681156632..330c11baa1 100644 --- a/crate/clients/clap/src/actions/symmetric/keys/create_key.rs +++ b/crate/clients/clap/src/actions/symmetric/keys/create_key.rs @@ -14,7 +14,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::console, + actions::{console, shared::RotationPolicyArgs}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -77,6 +77,10 @@ pub struct CreateKeyAction { verbatim_doc_comment )] pub wrapping_key_id: Option, + + /// Optional rotation policy to apply immediately after key creation. + #[clap(flatten)] + pub rotation_policy: RotationPolicyArgs, } impl CreateKeyAction { @@ -138,6 +142,14 @@ impl CreateKeyAction { .unique_identifier }; + // Apply rotation policy if any fields were provided + if self.rotation_policy.is_set() { + let key_id = unique_identifier + .as_str() + .with_context(|| "the server did not return a key id as a string")?; + self.rotation_policy.apply(&kms_rest_client, key_id).await?; + } + let mut stdout = console::Stdout::new("The symmetric key was successfully generated."); stdout.set_tags(Some(&self.tags)); stdout.set_unique_identifier(&unique_identifier); diff --git a/crate/clients/clap/src/actions/symmetric/keys/mod.rs b/crate/clients/clap/src/actions/symmetric/keys/mod.rs index 131132379e..742c9d1802 100644 --- a/crate/clients/clap/src/actions/symmetric/keys/mod.rs +++ b/crate/clients/clap/src/actions/symmetric/keys/mod.rs @@ -7,8 +7,9 @@ use self::{ }; use crate::{ actions::shared::{ - ActivateKeyAction, ExportSecretDataOrKeyAction, ImportSecretDataOrKeyAction, - UnwrapSecretDataOrKeyAction, WrapSecretDataOrKeyAction, + ActivateKeyAction, ExportSecretDataOrKeyAction, GetRotationPolicyAction, + ImportSecretDataOrKeyAction, SetRotationPolicyAction, UnwrapSecretDataOrKeyAction, + WrapSecretDataOrKeyAction, }, error::result::KmsCliResult, }; @@ -30,6 +31,8 @@ pub enum KeysCommands { Unwrap(UnwrapSecretDataOrKeyAction), Revoke(RevokeKeyAction), Destroy(DestroyKeyAction), + SetRotationPolicy(SetRotationPolicyAction), + GetRotationPolicy(GetRotationPolicyAction), } impl KeysCommands { @@ -62,6 +65,12 @@ impl KeysCommands { Self::Destroy(action) => { action.run(kms_rest_client).await?; } + Self::SetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } + Self::GetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } } Ok(()) diff --git a/crate/clients/clap/src/actions/symmetric/keys/revoke_key.rs b/crate/clients/clap/src/actions/symmetric/keys/revoke_key.rs index e9a437af76..23555f4bba 100644 --- a/crate/clients/clap/src/actions/symmetric/keys/revoke_key.rs +++ b/crate/clients/clap/src/actions/symmetric/keys/revoke_key.rs @@ -1,10 +1,16 @@ use clap::Parser; -use cosmian_kms_client::{KmsClient, kmip_2_1::kmip_types::UniqueIdentifier}; +use cosmian_kms_client::{ + KmsClient, cosmian_kmip::kmip_0::kmip_types::RevocationReasonCode, + kmip_2_1::kmip_types::UniqueIdentifier, +}; use crate::{ actions::{ labels::KEY_ID, - shared::{get_key_uid, utils::revoke}, + shared::{ + get_key_uid, + utils::{parse_revocation_reason_code, revoke}, + }, }, error::result::KmsCliResult, }; @@ -13,12 +19,21 @@ use crate::{ /// /// When a key is revoked, it can only be exported by the owner of the key, /// using the --allow-revoked flag on the export function. -#[derive(Parser, Default, Debug)] +#[derive(Parser, Debug)] pub struct RevokeKeyAction { /// The reason for the revocation as a string #[clap(required = true)] pub(crate) revocation_reason: String, + /// The revocation reason code [default: unspecified] + /// + /// Valid values: unspecified, key-compromise, ca-compromise, + /// affiliation-changed, superseded, cessation-of-operation, + /// privilege-withdrawn + #[clap(long = "reason-code", short = 'r', default_value = "unspecified", + value_parser = parse_revocation_reason_code)] + pub(crate) reason_code: RevocationReasonCode, + /// The key unique identifier of the key to revoke. /// If not specified, tags should be specified #[clap(long = KEY_ID, short = 'k', group = "key-tags")] @@ -33,6 +48,12 @@ pub struct RevokeKeyAction { impl RevokeKeyAction { pub(crate) async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { let id = get_key_uid(self.key_id.as_ref(), self.tags.as_ref(), KEY_ID)?; - revoke(kms_rest_client, &id, &self.revocation_reason).await + revoke( + kms_rest_client, + &id, + &self.revocation_reason, + self.reason_code, + ) + .await } } diff --git a/crate/clients/clap/src/tests/metrics.rs b/crate/clients/clap/src/tests/metrics.rs index 28faa3e40c..fa6ebd6436 100644 --- a/crate/clients/clap/src/tests/metrics.rs +++ b/crate/clients/clap/src/tests/metrics.rs @@ -1,6 +1,6 @@ use cosmian_kmip::kmip_0::kmip_types::State; use cosmian_kms_client::{ - cosmian_kmip::kmip_2_1::kmip_operations::Get, + cosmian_kmip::{kmip_0::kmip_types::RevocationReasonCode, kmip_2_1::kmip_operations::Get}, reexport::cosmian_kms_client_utils::create_utils::SymmetricAlgorithm, }; use test_kms_server::start_default_test_kms_server; @@ -38,6 +38,7 @@ async fn test_count_active_keys() -> KmsCliResult<()> { // Revoke then destroy the first key RevokeKeyAction { revocation_reason: "test revoke".to_string(), + reason_code: RevocationReasonCode::Unspecified, key_id: Some(first_uid.clone()), tags: None, } @@ -110,6 +111,7 @@ async fn test_revoked_keys_not_active() -> KmsCliResult<()> { // Revoke all keys with the tag in a single operation RevokeKeyAction { revocation_reason: "test revoke all".to_string(), + reason_code: RevocationReasonCode::Unspecified, key_id: None, tags: Some(vec![test_tag]), } diff --git a/crate/clients/clap/src/tests/oom/large_payloads.rs b/crate/clients/clap/src/tests/oom/large_payloads.rs index 8ddab083a8..c1ae8c690a 100644 --- a/crate/clients/clap/src/tests/oom/large_payloads.rs +++ b/crate/clients/clap/src/tests/oom/large_payloads.rs @@ -8,6 +8,7 @@ //! M4 - HTTP payload that exceeds the 64 MB server limit → 413 //! M5 - Encrypt 4 MB plaintext (well within limit) completes without OOM +use cosmian_kms_client::cosmian_kmip::kmip_0::kmip_types::RevocationReasonCode; use test_kms_server::start_default_test_kms_server; use crate::{ @@ -90,6 +91,7 @@ pub(crate) async fn m03_revoke_destroy_lifecycle_clean() -> KmsCliResult<()> { // Revoke RevokeKeyAction { revocation_reason: "test".to_owned(), + reason_code: RevocationReasonCode::Unspecified, key_id: Some(key_id.clone()), tags: None, } @@ -160,6 +162,7 @@ pub(crate) async fn m05_repeated_create_destroy_no_leak() -> KmsCliResult<()> { RevokeKeyAction { revocation_reason: "gc test".to_owned(), + reason_code: RevocationReasonCode::Unspecified, key_id: Some(key_id.clone()), tags: None, } diff --git a/crate/clients/clap/src/tests/xml/compare.rs b/crate/clients/clap/src/tests/xml/compare.rs index bc016971c6..a5c539e845 100644 --- a/crate/clients/clap/src/tests/xml/compare.rs +++ b/crate/clients/clap/src/tests/xml/compare.rs @@ -655,6 +655,7 @@ pub(crate) fn compare_attributes( cmp_opt!(quantum_safe); cmp_opt!(random_number_generator); cmp_opt!(revocation_reason); + cmp_opt!(rotate_automatic); cmp_opt!(rotate_date); cmp_opt!(rotate_generation); cmp_opt!(rotate_interval); diff --git a/crate/clients/client/src/http_client/client.rs b/crate/clients/client/src/http_client/client.rs index f207342ab8..06084933a5 100644 --- a/crate/clients/client/src/http_client/client.rs +++ b/crate/clients/client/src/http_client/client.rs @@ -210,11 +210,19 @@ impl<'de> Deserialize<'de> for HttpClientConfig { pub struct HttpResponse { /// HTTP status code. pub status: http::StatusCode, + /// Response headers. + headers: HeaderMap, /// Response body as raw bytes. body: Bytes, } impl HttpResponse { + /// Return the response headers. + #[must_use] + pub const fn headers(&self) -> &HeaderMap { + &self.headers + } + /// Deserialize the response body as JSON. /// /// # Errors @@ -527,6 +535,7 @@ impl HttpClient { .map_err(|e| HttpClientError::Default(format!("HTTP request failed: {e}")))?; let status = response.status(); + let headers = response.headers().clone(); let body = response .into_body() .collect() @@ -534,6 +543,10 @@ impl HttpClient { .map_err(|e| HttpClientError::Default(format!("Failed to read response body: {e}")))? .to_bytes(); - Ok(HttpResponse { status, body }) + Ok(HttpResponse { + status, + headers, + body, + }) } } diff --git a/crate/clients/client/src/http_client/login.rs b/crate/clients/client/src/http_client/login.rs index 6ac3d2e040..dfdcebf857 100644 --- a/crate/clients/client/src/http_client/login.rs +++ b/crate/clients/client/src/http_client/login.rs @@ -5,7 +5,9 @@ use std::{ }; use actix_web::{ - App, HttpResponse, HttpServer, get, + App, HttpResponse, HttpServer, + dev::ServerHandle, + get, web::{self, Data}, }; use http::{ @@ -175,7 +177,11 @@ impl LoginState { // URL. Use the port that was actually embedded in the redirect URL at // construction time (respects OAUTH2_REDIRECT_URL_PORT). let port = self.redirect_url.port_or_known_default().unwrap_or(17_899); - let auth_parameters = Self::receive_authorization_parameters(port)?; + let (auth_parameters, server_handle) = Self::receive_authorization_parameters(port)?; + // Gracefully stop the redirect listener so the port is freed immediately + // rather than lingering in TIME_WAIT. This prevents port-binding + // failures when the test suite is run multiple times in quick succession. + server_handle.stop(true).await; // Once the user has been redirected to the redirect URL, you'll have access to // the authorization code. For security reasons, your code should verify @@ -212,18 +218,21 @@ impl LoginState { /// This function starts the server on the given `port` and waits for the /// authorization code to be received from the browser window. Once the - /// code is received, the server is closed and the authorization code is - /// returned. + /// code is received, the server handle is returned alongside the parameters + /// so the caller can gracefully stop the server and free the port. /// /// The port must match the one embedded in the redirect URL that was sent /// to the Identity Provider during the authorization request. #[allow(clippy::unwrap_used)] - fn receive_authorization_parameters(port: u16) -> HttpClientResult> { + fn receive_authorization_parameters( + port: u16, + ) -> HttpClientResult<(HashMap, ServerHandle)> { let (auth_params_tx, auth_params_rx) = mpsc::channel::>(); + let (server_handle_tx, server_handle_rx) = mpsc::sync_channel::(1); // Spawn the server into a runtime let tokio_handle = tokio::runtime::Handle::current(); let _task = thread::spawn(move || { - tokio_handle.block_on({ + tokio_handle.block_on(async move { // server.await #[get("/authorization")] async fn authorization_handler( @@ -237,18 +246,26 @@ impl LoginState { HttpResponse::Ok().body("You can now close this window.") } - HttpServer::new(move || { + let server = HttpServer::new(move || { App::new() .app_data(Data::new(auth_params_tx.clone())) .service(authorization_handler) }) .bind(("127.0.0.1", port))? - .run() + .run(); + // Send the handle before awaiting so the outer thread can stop + // the server once the first callback has been received. + drop(server_handle_tx.send(server.handle())); + server.await }) }); - auth_params_rx.recv().map_err(|e| { + let params = auth_params_rx.recv().map_err(|e| { HttpClientError::Default(format!("authorization code not received: {e:?}")) - }) + })?; + let handle = server_handle_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .map_err(|e| HttpClientError::Default(format!("server handle not received: {e:?}")))?; + Ok((params, handle)) } } diff --git a/crate/clients/client_utils/src/attributes_utils.rs b/crate/clients/client_utils/src/attributes_utils.rs index c5b5a7aca9..63f90531de 100644 --- a/crate/clients/client_utils/src/attributes_utils.rs +++ b/crate/clients/client_utils/src/attributes_utils.rs @@ -114,7 +114,7 @@ pub fn parse_selected_attributes( if let Some(v) = attributes.activation_date.as_ref() { results.insert( tag.to_string(), - serde_json::to_value(v.unix_timestamp()).unwrap_or_default(), + serde_json::to_value(v.unix_timestamp() * 1000_i64).unwrap_or_default(), ); } } @@ -346,6 +346,33 @@ pub fn parse_selected_attributes( Ok(results) } +/// Attribute key names recognised by [`parse_selected_attributes_flatten`]. +/// +/// This is the canonical list used by the WASM layer to enrich KMIP Locate +/// results. Add an entry here whenever a new match arm is added to +/// `parse_selected_attributes_flatten` and the attribute should be surfaced in +/// the UI. +pub const LOCATE_ENRICH_ATTRIBUTE_KEYS: &[&str] = &[ + "object_type", + "state", + "tags", + "user_tags", + "cryptographic_algorithm", + "cryptographic_length", + "key_format_type", + "public_key_id", + "private_key_id", + "certificate_id", + "initial_date", + "activation_date", + "original_creation_date", + "rotate_date", + "rotate_name", + "rotate_interval", + "rotate_offset", + "rotate_generation", +]; + pub fn parse_selected_attributes_flatten( attributes: &Attributes, selected_attributes: &[&str], @@ -377,10 +404,64 @@ pub fn parse_selected_attributes_flatten( if let Some(v) = attributes.activation_date.as_ref() { results.insert( selected_attribute_name.to_owned(), - serde_json::to_value(v.unix_timestamp()).unwrap_or_default(), + serde_json::to_value(v.unix_timestamp() * 1000_i64).unwrap_or_default(), + ); + } + } + "initial_date" => { + if let Some(v) = attributes.initial_date.as_ref() { + results.insert( + selected_attribute_name.to_owned(), + serde_json::to_value(v.unix_timestamp() * 1000_i64).unwrap_or_default(), + ); + } + } + "original_creation_date" => { + if let Some(v) = attributes.original_creation_date.as_ref() { + results.insert( + selected_attribute_name.to_owned(), + serde_json::to_value(v.unix_timestamp() * 1000_i64).unwrap_or_default(), + ); + } + } + "rotate_automatic" => insert_if_some!( + results, + selected_attribute_name, + attributes.rotate_automatic.as_ref() + ), + "rotate_date" => { + if let Some(v) = attributes.rotate_date.as_ref() { + results.insert( + selected_attribute_name.to_owned(), + serde_json::to_value(v.unix_timestamp() * 1000_i64).unwrap_or_default(), ); } } + "rotate_generation" => insert_if_some!( + results, + selected_attribute_name, + attributes.rotate_generation.as_ref() + ), + "rotate_interval" => insert_if_some!( + results, + selected_attribute_name, + attributes.rotate_interval.as_ref() + ), + "rotate_latest" => insert_if_some!( + results, + selected_attribute_name, + attributes.rotate_latest.as_ref() + ), + "rotate_name" => insert_if_some!( + results, + selected_attribute_name, + attributes.rotate_name.as_ref() + ), + "rotate_offset" => insert_if_some!( + results, + selected_attribute_name, + attributes.rotate_offset.as_ref() + ), "cryptographic_algorithm" => insert_if_some!( results, selected_attribute_name, diff --git a/crate/clients/client_utils/src/certificate_utils.rs b/crate/clients/client_utils/src/certificate_utils.rs index f7d21522c0..bf60969896 100644 --- a/crate/clients/client_utils/src/certificate_utils.rs +++ b/crate/clients/client_utils/src/certificate_utils.rs @@ -5,7 +5,7 @@ use cosmian_kmip::{ kmip_2_1::{ kmip_attributes::Attributes, kmip_objects::ObjectType, - kmip_operations::Certify, + kmip_operations::{Certify, ReCertify}, kmip_types::{ CertificateAttributes, CertificateRequestType, CryptographicAlgorithm, CryptographicDomainParameters, KeyFormatType, LinkType, LinkedObjectIdentifier, @@ -425,6 +425,65 @@ pub fn build_certify_request( }) } +/// Build a KMIP `ReCertify` request — certificate rotation with a fresh UID. +/// +/// Unlike `Certify` with an existing cert UID (which upserts in-place), +/// `ReCertify` creates a **new certificate** and links old ↔ new via +/// `ReplacedObjectLink` / `ReplacementObjectLink`. +/// +/// # Parameters +/// - `vendor_id` — vendor identifier string for `VendorAttribute` operations +/// - `certificate_id_to_re_certify` — UID of the certificate to renew (required) +/// - `issuer_private_key_id` — optional UID of the issuer's private key +/// - `issuer_certificate_id` — optional UID of the issuer's certificate +/// - `number_of_days` — requested validity period for the new certificate +/// - `tags` — tags to associate with the new certificate +pub fn build_re_certify_request( + vendor_id: &str, + certificate_id_to_re_certify: &str, + issuer_private_key_id: &Option, + issuer_certificate_id: &Option, + number_of_days: usize, + tags: &[String], +) -> Result { + let mut attributes = Attributes { + object_type: Some(ObjectType::Certificate), + ..Attributes::default() + }; + + if let Some(issuer_certificate_id) = issuer_certificate_id { + attributes.set_link( + LinkType::CertificateLink, + LinkedObjectIdentifier::TextString(issuer_certificate_id.clone()), + ); + } + + if let Some(issuer_private_key_id) = issuer_private_key_id { + attributes.set_link( + LinkType::PrivateKeyLink, + LinkedObjectIdentifier::TextString(issuer_private_key_id.clone()), + ); + } + + attributes.set_requested_validity_days( + vendor_id, + i32::try_from(number_of_days).map_err(|_e| { + UtilsError::Default("number of days must be a positive integer".to_owned()) + })?, + ); + + attributes.activation_date = Some(time_normalize()?); + attributes.set_tags(vendor_id, tags)?; + + Ok(ReCertify { + unique_identifier: Some(UniqueIdentifier::TextString( + certificate_id_to_re_certify.to_owned(), + )), + attributes: Some(attributes), + ..ReCertify::default() + }) +} + fn ec_algorithm( attributes: &mut Attributes, cryptographic_algorithm: CryptographicAlgorithm, diff --git a/crate/clients/client_utils/src/configurable_kem_utils.rs b/crate/clients/client_utils/src/configurable_kem_utils.rs index f5ec7c58e2..f0543c73fd 100644 --- a/crate/clients/client_utils/src/configurable_kem_utils.rs +++ b/crate/clients/client_utils/src/configurable_kem_utils.rs @@ -175,9 +175,9 @@ pub fn build_create_configurable_kem_keypair_request, + issuer_certificate_id: Option, + number_of_days: usize, + tags: Vec, +) -> Result { + let vendor_id = get_vendor_id(); + let vendor_id = vendor_id.as_str(); + let issuer_private_key_id = none_if_empty(issuer_private_key_id); + let issuer_certificate_id = none_if_empty(issuer_certificate_id); + let request = build_re_certify_request( + vendor_id, + &certificate_id_to_re_certify, + &issuer_private_key_id, + &issuer_certificate_id, + number_of_days, + &tags, + ) + .map_err(|e| JsValue::from(e.to_string()))?; + to_wasm_ttlv(&request) +} + +wasm_response_parser!(parse_re_certify_ttlv_response, ReCertifyResponse); + // Attributes request + +/// Returns the canonical list of attribute key strings used to enrich KMIP Locate results. +/// Sourced from [`cosmian_kms_client_utils::attributes_utils::LOCATE_ENRICH_ATTRIBUTE_KEYS`] — +/// single source of truth defined next to `parse_selected_attributes_flatten`. +#[wasm_bindgen] +pub fn get_locate_enrich_attribute_keys() -> Result { + serde_wasm_bindgen::to_value(LOCATE_ENRICH_ATTRIBUTE_KEYS) + .map_err(|e| JsValue::from(e.to_string())) +} + #[wasm_bindgen] pub fn get_attributes_ttlv_request(unique_identifier: String) -> Result { let unique_identifier = UniqueIdentifier::TextString(unique_identifier); @@ -2450,3 +2496,109 @@ pub fn derive_key_ttlv_request( } wasm_response_parser!(parse_derive_key_ttlv_response, DeriveKeyResponse); + +// ── ReKey (symmetric key rotation) ─────────────────────────────────────────── + +/// Build a KMIP `ReKey` TTLV request for a symmetric key. +#[wasm_bindgen] +pub fn rekey_ttlv_request(unique_identifier: String) -> Result { + let request = ReKey { + unique_identifier: Some(UniqueIdentifier::TextString(unique_identifier)), + ..ReKey::default() + }; + to_wasm_ttlv(&request) +} + +wasm_response_parser!(parse_rekey_ttlv_response, ReKeyResponse); + +// ── ReKey Key Pair (asymmetric key rotation) ───────────────────────────────── + +/// Build a KMIP `ReKeyKeyPair` TTLV request for an asymmetric key pair. +#[wasm_bindgen] +pub fn rekey_keypair_ttlv_request( + private_key_unique_identifier: String, +) -> Result { + let request = ReKeyKeyPair { + private_key_unique_identifier: Some(UniqueIdentifier::TextString( + private_key_unique_identifier, + )), + ..ReKeyKeyPair::default() + }; + to_wasm_ttlv(&request) +} + +wasm_response_parser!(parse_rekey_keypair_ttlv_response, ReKeyKeyPairResponse); + +// ── Rotation policy helpers ────────────────────────────────────────────────── + +/// Build a KMIP `SetAttribute` TTLV request to set `RotateInterval` on a key. +#[wasm_bindgen] +pub fn set_rotate_interval_ttlv_request( + unique_identifier: String, + interval_secs: i64, +) -> Result { + let request = SetAttribute { + unique_identifier: Some(UniqueIdentifier::TextString(unique_identifier)), + new_attribute: Attribute::RotateInterval(interval_secs), + }; + to_wasm_ttlv(&request) +} + +/// Build a KMIP `SetAttribute` TTLV request to set `RotateOffset` on a key. +#[wasm_bindgen] +pub fn set_rotate_offset_ttlv_request( + unique_identifier: String, + offset_secs: i64, +) -> Result { + let request = SetAttribute { + unique_identifier: Some(UniqueIdentifier::TextString(unique_identifier)), + new_attribute: Attribute::RotateOffset(offset_secs), + }; + to_wasm_ttlv(&request) +} + +/// Build a KMIP `SetAttribute` TTLV request to set `RotateName` on a key. +#[wasm_bindgen] +pub fn set_rotate_name_ttlv_request( + unique_identifier: String, + name: String, +) -> Result { + let request = SetAttribute { + unique_identifier: Some(UniqueIdentifier::TextString(unique_identifier)), + new_attribute: Attribute::RotateName(name), + }; + to_wasm_ttlv(&request) +} + +/// Rotation-policy fields extracted from a `GetAttributes` response. +#[derive(Serialize)] +struct RotationPolicyDto { + interval: i64, + offset: i64, + name: Option, + generation: i32, + date: Option, +} + +/// Parse a `GetAttributes` response and extract only the rotation-policy fields. +/// +/// Returns a JS object with keys: `interval`, `offset`, +/// `name`, `generation`, `date` (string or null). +#[wasm_bindgen] +pub fn parse_rotation_policy_response(response: &str) -> Result { + let ttlv: TTLV = serde_json::from_str(response).map_err(|e| JsValue::from(e.to_string()))?; + let GetAttributesResponse { + unique_identifier: _, + attributes, + } = from_ttlv(ttlv).map_err(|e| JsValue::from(e.to_string()))?; + + let policy = RotationPolicyDto { + interval: attributes.rotate_interval.unwrap_or(0), + offset: attributes.rotate_offset.unwrap_or(0), + name: attributes.rotate_name.clone(), + generation: attributes.rotate_generation.unwrap_or(0), + date: attributes.rotate_date.map(|d| d.to_string()), + }; + + Ok(serde_wasm_bindgen::to_value(&policy)?) +} diff --git a/crate/hsm/base_hsm/Cargo.toml b/crate/hsm/base_hsm/Cargo.toml index c2b9ab66db..133489ba23 100644 --- a/crate/hsm/base_hsm/Cargo.toml +++ b/crate/hsm/base_hsm/Cargo.toml @@ -26,5 +26,6 @@ lru = { workspace = true } pkcs11-sys = { workspace = true } rand = { workspace = true } thiserror = { workspace = true } +time = { workspace = true } uuid = { workspace = true, features = ["v4"] } zeroize = { workspace = true } diff --git a/crate/hsm/base_hsm/src/hsm_lib.rs b/crate/hsm/base_hsm/src/hsm_lib.rs index 207a0f1e58..3e310e752d 100644 --- a/crate/hsm/base_hsm/src/hsm_lib.rs +++ b/crate/hsm/base_hsm/src/hsm_lib.rs @@ -8,8 +8,9 @@ use pkcs11_sys::{ CK_C_Finalize, CK_C_FindObjects, CK_C_FindObjectsFinal, CK_C_FindObjectsInit, CK_C_GenerateKey, CK_C_GenerateKeyPair, CK_C_GenerateRandom, CK_C_GetAttributeValue, CK_C_GetInfo, CK_C_GetMechanismInfo, CK_C_GetMechanismList, CK_C_INITIALIZE_ARGS, CK_C_Initialize, - CK_C_Login, CK_C_Logout, CK_C_OpenSession, CK_C_SeedRandom, CK_C_Sign, CK_C_SignInit, - CK_C_UnwrapKey, CK_C_WrapKey, CKF_OS_LOCKING_OK, CKR_CRYPTOKI_ALREADY_INITIALIZED, CKR_OK, + CK_C_Login, CK_C_Logout, CK_C_OpenSession, CK_C_SeedRandom, CK_C_SetAttributeValue, CK_C_Sign, + CK_C_SignInit, CK_C_UnwrapKey, CK_C_WrapKey, CKF_OS_LOCKING_OK, + CKR_CRYPTOKI_ALREADY_INITIALIZED, CKR_OK, }; use crate::{HResult, hsm_call}; @@ -85,6 +86,7 @@ pub struct HsmLib { pub(crate) C_SeedRandom: CK_C_SeedRandom, pub(crate) C_GetAttributeValue: CK_C_GetAttributeValue, + pub(crate) C_SetAttributeValue: CK_C_SetAttributeValue, pub(crate) C_GetInfo: CK_C_GetInfo, pub(crate) C_GetMechanismList: CK_C_GetMechanismList, @@ -130,6 +132,7 @@ impl HsmLib { C_GenerateRandom: Some(*library.get(b"C_GenerateRandom")?), C_SeedRandom: Some(*library.get(b"C_SeedRandom")?), C_GetAttributeValue: Some(*library.get(b"C_GetAttributeValue")?), + C_SetAttributeValue: Some(*library.get(b"C_SetAttributeValue")?), C_GetInfo: Some(*library.get(b"C_GetInfo")?), C_GetMechanismList: Some(*library.get(b"C_GetMechanismList")?), C_GetMechanismInfo: Some(*library.get(b"C_GetMechanismInfo")?), diff --git a/crate/hsm/base_hsm/src/kms_hsm.rs b/crate/hsm/base_hsm/src/kms_hsm.rs index dc128a977b..cc75c4e915 100644 --- a/crate/hsm/base_hsm/src/kms_hsm.rs +++ b/crate/hsm/base_hsm/src/kms_hsm.rs @@ -253,6 +253,35 @@ impl HSM for BaseHsm

{ Ok(()) } + /// Sets `CKA_START_DATE` and `CKA_END_DATE` on the key identified by `key_id`. Pass `None` to clear a date. + async fn set_key_dates( + &self, + slot_id: usize, + key_id: &[u8], + start_date: Option, + end_date: Option, + ) -> InterfaceResult<()> { + let slot = self.get_slot(slot_id)?; + let session = slot.open_session(true)?; + let handle = session.get_object_handle(key_id)?; + session.set_key_dates(handle, start_date, end_date)?; + Ok(()) + } + + /// Sets `CKA_LABEL` on the key identified by `key_id`. + async fn set_key_label( + &self, + slot_id: usize, + key_id: &[u8], + label: &str, + ) -> InterfaceResult<()> { + let slot = self.get_slot(slot_id)?; + let session = slot.open_session(true)?; + let handle = session.get_object_handle(key_id)?; + session.set_label(handle, label)?; + Ok(()) + } + fn hsm_lib(&self) -> Option<&dyn std::any::Any> { Some(self.hsm_lib()) } diff --git a/crate/hsm/base_hsm/src/session/session_impl.rs b/crate/hsm/base_hsm/src/session/session_impl.rs index 29fa4e73d4..8b8a045544 100644 --- a/crate/hsm/base_hsm/src/session/session_impl.rs +++ b/crate/hsm/base_hsm/src/session/session_impl.rs @@ -50,16 +50,16 @@ use cosmian_kms_interfaces::{ }; use cosmian_logger::{debug, trace}; use pkcs11_sys::{ - CK_AES_GCM_PARAMS, CK_ATTRIBUTE, CK_BBOOL, CK_FALSE, CK_KEY_TYPE, CK_MECHANISM, + CK_AES_GCM_PARAMS, CK_ATTRIBUTE, CK_BBOOL, CK_DATE, CK_FALSE, CK_KEY_TYPE, CK_MECHANISM, CK_MECHANISM_TYPE, CK_OBJECT_CLASS, CK_OBJECT_HANDLE, CK_RSA_PKCS_MGF_TYPE, CK_RSA_PKCS_OAEP_PARAMS, CK_SESSION_HANDLE, CK_TRUE, CK_ULONG, CKA_CLASS, CKA_COEFFICIENT, - CKA_EXPONENT_1, CKA_EXPONENT_2, CKA_ID, CKA_KEY_TYPE, CKA_LABEL, CKA_MODULUS, CKA_PRIME_1, - CKA_PRIME_2, CKA_PRIVATE_EXPONENT, CKA_PUBLIC_EXPONENT, CKA_SENSITIVE, CKA_VALUE, - CKA_VALUE_LEN, CKG_MGF1_SHA1, CKG_MGF1_SHA256, CKG_MGF1_SHA384, CKG_MGF1_SHA512, CKK_AES, - CKK_RSA, CKK_VENDOR_DEFINED, CKM_AES_CBC, CKM_AES_GCM, CKM_RSA_PKCS, CKM_RSA_PKCS_OAEP, - CKM_SHA_1, CKM_SHA1_RSA_PKCS, CKM_SHA256, CKM_SHA256_RSA_PKCS, CKM_SHA384, CKM_SHA384_RSA_PKCS, - CKM_SHA512, CKM_SHA512_RSA_PKCS, CKO_PRIVATE_KEY, CKO_PUBLIC_KEY, CKO_SECRET_KEY, - CKO_VENDOR_DEFINED, CKR_ATTRIBUTE_SENSITIVE, CKR_OBJECT_HANDLE_INVALID, CKR_OK, + CKA_END_DATE, CKA_EXPONENT_1, CKA_EXPONENT_2, CKA_ID, CKA_KEY_TYPE, CKA_LABEL, CKA_MODULUS, + CKA_PRIME_1, CKA_PRIME_2, CKA_PRIVATE_EXPONENT, CKA_PUBLIC_EXPONENT, CKA_SENSITIVE, + CKA_START_DATE, CKA_VALUE, CKA_VALUE_LEN, CKG_MGF1_SHA1, CKG_MGF1_SHA256, CKG_MGF1_SHA384, + CKG_MGF1_SHA512, CKK_AES, CKK_RSA, CKK_VENDOR_DEFINED, CKM_AES_CBC, CKM_AES_GCM, CKM_RSA_PKCS, + CKM_RSA_PKCS_OAEP, CKM_SHA_1, CKM_SHA1_RSA_PKCS, CKM_SHA256, CKM_SHA256_RSA_PKCS, CKM_SHA384, + CKM_SHA384_RSA_PKCS, CKM_SHA512, CKM_SHA512_RSA_PKCS, CKO_PRIVATE_KEY, CKO_PUBLIC_KEY, + CKO_SECRET_KEY, CKO_VENDOR_DEFINED, CKR_ATTRIBUTE_SENSITIVE, CKR_OBJECT_HANDLE_INVALID, CKR_OK, CKZ_DATA_SPECIFIED, }; use rand::{TryRng, rngs::SysRng}; @@ -311,6 +311,13 @@ impl Session { let mut supported = Vec::new(); + // Probe each hash by performing a real (dummy) single-part encryption. + // Using encrypt_with_mechanism (C_EncryptInit + C_Encrypt) guarantees the + // session returns to a clean state after each probe: per PKCS#11, C_Encrypt + // terminates the active encryption operation on completion. Stopping at + // C_EncryptInit would leave `self.handle` in ENCRYPT state, causing + // CKR_OPERATION_ACTIVE (130) for the next hash and for any later operations + // (including C_DestroyObject on the temp key and keys created by other tests). for (hash, mgf) in candidates { let mut params = CK_RSA_PKCS_OAEP_PARAMS { hashAlg: *hash, @@ -326,20 +333,11 @@ impl Session { ulParameterLen: CK_ULONG::try_from(size_of::())?, }; - // We don't actually encrypt, just see if init succeeds - #[expect(unsafe_code)] - let rv = unsafe { - self.hsm.C_EncryptInit.ok_or_else(|| { - drop(self.destroy_object(sk_handle)); - drop(self.destroy_object(pk_handle)); - HError::Default("C_EncryptInit not available on library".to_owned()) - })?(self.handle, &raw mut mechanism, pk_handle) - }; - - if rv == CKR_OK { - supported.push(*hash); - } else { - debug!("Failed to encrypt data with hash {hash}: {rv}"); + // A 1-byte plaintext is minimal but valid for RSA-1024 OAEP. + let dummy_plaintext = [0_u8; 1]; + match self.encrypt_with_mechanism(pk_handle, &mut mechanism, &dummy_plaintext) { + Ok(_) => supported.push(*hash), + Err(e) => debug!("OAEP hash {hash} not supported: {e}"), } } self.destroy_object(sk_handle)?; @@ -1730,6 +1728,246 @@ impl Session { Ok(Some(())) } + /// Parse a `CK_DATE` (8-byte ASCII "YYYYMMDD") into a `time::Date`. + /// Returns `None` if the date is empty/zeroed. + fn parse_ck_date(date: CK_DATE) -> Option { + let year_str = std::str::from_utf8(&date.year).ok()?; + let month_str = std::str::from_utf8(&date.month).ok()?; + let day_str = std::str::from_utf8(&date.day).ok()?; + let year: i32 = year_str.trim().parse().ok()?; + let month: u8 = month_str.trim().parse().ok()?; + let day: u8 = day_str.trim().parse().ok()?; + if year == 0 && month == 0 && day == 0 { + return None; + } + let month = time::Month::try_from(month).ok()?; + time::Date::from_calendar_date(year, month, day).ok() + } + + /// Read `CKA_START_DATE` and `CKA_END_DATE` from a key handle. + /// Returns `(start_date, end_date)`. Attributes that are absent or empty + /// (zeroed) are returned as `None`. + fn get_key_dates( + &self, + key_handle: CK_OBJECT_HANDLE, + ) -> HResult<(Option, Option)> { + let mut start_date = CK_DATE { + year: [0; 4], + month: [0; 2], + day: [0; 2], + }; + let mut end_date = CK_DATE { + year: [0; 4], + month: [0; 2], + day: [0; 2], + }; + let mut template = vec![ + CK_ATTRIBUTE { + type_: CKA_START_DATE, + pValue: (&raw mut start_date).cast::(), + ulValueLen: CK_ULONG::try_from(size_of::())?, + }, + CK_ATTRIBUTE { + type_: CKA_END_DATE, + pValue: (&raw mut end_date).cast::(), + ulValueLen: CK_ULONG::try_from(size_of::())?, + }, + ]; + // If the HSM doesn't support these attributes, just return None for both + if self + .call_get_attributes(key_handle, &mut template)? + .is_none() + { + return Ok((None, None)); + } + // Check if the returned length is 0 (attribute present but empty) + let start = if template.first().is_none_or(|t| t.ulValueLen == 0) { + None + } else { + Self::parse_ck_date(start_date) + }; + let end = if template.get(1).is_none_or(|t| t.ulValueLen == 0) { + None + } else { + Self::parse_ck_date(end_date) + }; + Ok((start, end)) + } + + /// Format a `time::Date` into a `CK_DATE` (8-byte ASCII "YYYYMMDD"). + fn format_ck_date(date: time::Date) -> CK_DATE { + let year = date.year(); + let month: u8 = date.month().into(); + let day = date.day(); + // These format! calls always produce exactly the right number of bytes + let mut year_bytes = [b'0'; 4]; + let mut month_bytes = [b'0'; 2]; + let mut day_bytes = [b'0'; 2]; + let year_str = format!("{year:04}"); + let month_str = format!("{month:02}"); + let day_str = format!("{day:02}"); + year_bytes.copy_from_slice(year_str.as_bytes().get(..4).unwrap_or(&[b'0'; 4])); + month_bytes.copy_from_slice(month_str.as_bytes().get(..2).unwrap_or(&[b'0'; 2])); + day_bytes.copy_from_slice(day_str.as_bytes().get(..2).unwrap_or(&[b'0'; 2])); + CK_DATE { + year: year_bytes, + month: month_bytes, + day: day_bytes, + } + } + + /// Set `CKA_START_DATE` and/or `CKA_END_DATE` on a key object. + /// Passing `None` clears the attribute (sets to empty `CK_DATE`). + pub fn set_key_dates( + &self, + key_handle: CK_OBJECT_HANDLE, + start_date: Option, + end_date: Option, + ) -> HResult<()> { + let start_ck = start_date.map_or( + CK_DATE { + year: [0; 4], + month: [0; 2], + day: [0; 2], + }, + Self::format_ck_date, + ); + let end_ck = end_date.map_or( + CK_DATE { + year: [0; 4], + month: [0; 2], + day: [0; 2], + }, + Self::format_ck_date, + ); + + let mut template = vec![ + CK_ATTRIBUTE { + type_: CKA_START_DATE, + pValue: ptr::addr_of!(start_ck).cast_mut().cast(), + ulValueLen: CK_ULONG::try_from(std::mem::size_of::())?, + }, + CK_ATTRIBUTE { + type_: CKA_END_DATE, + pValue: ptr::addr_of!(end_ck).cast_mut().cast(), + ulValueLen: CK_ULONG::try_from(std::mem::size_of::())?, + }, + ]; + + #[expect(unsafe_code)] + let rv = match self.hsm.C_SetAttributeValue { + Some(func) => unsafe { + func( + self.handle, + key_handle, + template.as_mut_ptr(), + CK_ULONG::try_from(template.len())?, + ) + }, + None => { + return Err(HError::Default( + "C_SetAttributeValue not available on library".to_owned(), + )); + } + }; + if rv != CKR_OK { + return Err(HError::Default(format!( + "Failed to set key dates for key handle: {key_handle}. Return code: {rv}" + ))); + } + Ok(()) + } + + /// Parse keyset metadata from a `CKA_LABEL` value. + /// + /// Format: `rotate_name::generation::key_id[@latest]` + /// The optional `@latest` suffix is accepted for backward compatibility with + /// existing HSM keys (older format used `::latest`) but is not used for + /// determining the latest generation; callers compare `rotate_generation` values. + /// + /// Returns `(rotate_name, rotate_generation)`. + /// Returns `(None, None)` if the label does not match the format + /// (e.g. plain keys whose label is just an identifier). + pub(crate) fn parse_label_metadata(label: &str) -> (Option, Option) { + // Format: "rotate_name::generation::key_id[@latest]" + // + // `rotate_name` may itself contain "::" — for HSM-resident keys the convention is + // rotate_name = "hsm::::::" (the full base UID, including the + // model segment), which is unique across slots. Split from the RIGHT so the + // variable-length rotate_name is always the residual left segment, regardless of + // how many "::" it contains. + // + // rsplitn(3, "::") yields (from right to left): + // index 0 → key_id[@latest] + // index 1 → generation (must parse as i32) + // index 2 → rotate_name (may contain "::") + let mut rparts = label.rsplitn(3, "::"); + let Some(_key_id) = rparts.next() else { + return (None, None); + }; + let Some(gen_str) = rparts.next() else { + return (None, None); + }; + let Some(rotate_name) = rparts.next() else { + return (None, None); + }; + let Ok(generation) = gen_str.parse::() else { + return (None, None); + }; + (Some(rotate_name.to_owned()), Some(generation)) + } + + /// Build the `CKA_LABEL` value for a keyset key. + /// + /// Format: `rotate_name::generation::key_id` (retired) or + /// `rotate_name::generation::key_id@latest` (current latest). + // Used by the HSM ReKey flow (Phase 3). + #[allow(dead_code)] + pub(crate) fn build_keyset_label( + rotate_name: &str, + generation: i32, + key_id: &str, + latest: bool, + ) -> String { + if latest { + format!("{rotate_name}::{generation}::{key_id}@latest") + } else { + format!("{rotate_name}::{generation}::{key_id}") + } + } + + /// Set `CKA_LABEL` on a key object via `C_SetAttributeValue`. + pub fn set_label(&self, key_handle: CK_OBJECT_HANDLE, label: &str) -> HResult<()> { + let label_bytes = label.as_bytes(); + let mut template = vec![CK_ATTRIBUTE { + type_: CKA_LABEL, + pValue: label_bytes.as_ptr().cast_mut().cast(), + ulValueLen: CK_ULONG::try_from(label_bytes.len())?, + }]; + #[expect(unsafe_code)] + let rv = match self.hsm.C_SetAttributeValue { + Some(func) => unsafe { + func( + self.handle, + key_handle, + template.as_mut_ptr(), + CK_ULONG::try_from(template.len())?, + ) + }, + None => { + return Err(HError::Default( + "C_SetAttributeValue not available on library".to_owned(), + )); + } + }; + if rv != CKR_OK { + return Err(HError::Default(format!( + "Failed to set label for key handle: {key_handle}. Return code: {rv}" + ))); + } + Ok(()) + } + /// Get the metadata for a key pub fn get_key_metadata(&self, key_handle: CK_OBJECT_HANDLE) -> HResult> { let Some(key_type) = self.get_key_type(key_handle)? else { @@ -1786,6 +2024,8 @@ impl Session { HError::Default(format!("Failed to convert label to string: {e}")) })? }; + let (start_date, end_date) = self.get_key_dates(key_handle).unwrap_or((None, None)); + let (rotate_name, rotate_generation) = Self::parse_label_metadata(&label); Ok(Some(KeyMetadata { key_type, key_length_in_bits: usize::try_from(key_size).map_err(|e| { @@ -1793,6 +2033,10 @@ impl Session { })? * 8, sensitive: sensitive == CK_TRUE, id: label, + start_date, + end_date, + rotate_name, + rotate_generation, })) } KeyType::RsaPrivateKey | KeyType::RsaPublicKey => { @@ -1856,11 +2100,17 @@ impl Session { label = label.trim().to_owned().add("_pk"); } let sensitive = sensitive == CK_TRUE; + let (start_date, end_date) = self.get_key_dates(key_handle).unwrap_or((None, None)); + let (rotate_name, rotate_generation) = Self::parse_label_metadata(&label); Ok(Some(KeyMetadata { key_type, key_length_in_bits, sensitive, id: label, + start_date, + end_date, + rotate_name, + rotate_generation, })) } } diff --git a/crate/interfaces/Cargo.toml b/crate/interfaces/Cargo.toml index 437a1f28d2..3bfa71098f 100644 --- a/crate/interfaces/Cargo.toml +++ b/crate/interfaces/Cargo.toml @@ -23,6 +23,7 @@ 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 } [dev-dependencies] diff --git a/crate/interfaces/src/crypto_oracle.rs b/crate/interfaces/src/crypto_oracle.rs index d63cdadf92..2306b9b4ae 100644 --- a/crate/interfaces/src/crypto_oracle.rs +++ b/crate/interfaces/src/crypto_oracle.rs @@ -15,12 +15,21 @@ use zeroize::Zeroizing; use crate::{InterfaceError, KeyType, error::InterfaceResult}; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct KeyMetadata { pub key_type: KeyType, pub key_length_in_bits: usize, pub sensitive: bool, pub id: String, + /// PKCS#11 `CKA_START_DATE` — when the key became active. + pub start_date: Option, + /// PKCS#11 `CKA_END_DATE` — when the key is due for rotation. + pub end_date: Option, + /// Keyset name parsed from `CKA_LABEL` (`rotate_name::generation::key_id[@latest]`). + /// `None` means the key has no keyset membership. + pub rotate_name: Option, + /// Keyset generation counter parsed from `CKA_LABEL`. + pub rotate_generation: Option, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crate/interfaces/src/hsm/hsm_store.rs b/crate/interfaces/src/hsm/hsm_store.rs index 03c9cbfde0..8f5eedb2f1 100644 --- a/crate/interfaces/src/hsm/hsm_store.rs +++ b/crate/interfaces/src/hsm/hsm_store.rs @@ -141,8 +141,26 @@ impl ObjectsStore for HsmStore { let (slot_id, key_id) = parse_uid_with_prefix(uid, &self.prefix)?; match self.hsm.export(slot_id, key_id.as_bytes()).await { Ok(Some(hsm_object)) => { - let owm = + let mut owm = to_object_with_metadata(&hsm_object, uid, self.owner_name(), &self.vendor_id)?; + // Enrich attributes with keyset metadata from CKA_LABEL and CKA dates. + if let Ok(Some(meta)) = self.hsm.get_key_metadata(slot_id, key_id.as_bytes()).await + { + let attrs = owm.attributes_mut(); + attrs.rotate_name = meta.rotate_name; + attrs.rotate_generation = meta.rotate_generation; + // Reconstruct rotate_interval from CKA_START_DATE / CKA_END_DATE. + // HsmStore::update_object is a no-op for KMIP attributes so there is + // no persistent KMIP storage for rotate_interval on HSM keys; we + // recover it as (end_date − start_date) × 86400 s so that downstream + // operations (e.g. auto-rotation re-key) can propagate the schedule. + if let (Some(start), Some(end)) = (meta.start_date, meta.end_date) { + let days = (end - start).whole_days(); + if days > 0 { + attrs.rotate_interval = Some(days * crate::SECS_PER_DAY); + } + } + } Ok(Some(owm)) } Ok(None) => Ok(None), @@ -378,6 +396,128 @@ impl ObjectsStore for HsmStore { Ok(uids) } + async fn find_due_for_rotation( + &self, + now: time::OffsetDateTime, + ) -> InterfaceResult> { + let today = now.date(); + let slot_ids = self.hsm.get_available_slot_list().await?; + let mut due_uids = Vec::new(); + + for slot_id in slot_ids { + let found = self + .hsm + .find(slot_id, HsmObjectFilter::Any) + .await + .unwrap_or_default(); + for object_id in found { + let Some(meta) = self + .hsm + .get_key_metadata(slot_id, &object_id) + .await + .unwrap_or_default() + else { + continue; + }; + // A key is due for rotation when end_date is set and today >= end_date + let Some(end_date) = meta.end_date else { + continue; + }; + if today >= end_date { + let Ok(object_string) = std::str::from_utf8(&object_id) else { + continue; + }; + let uid = format!("{}::{slot_id}::{object_string}", self.prefix); + // HSM objects have no KMIP "owner" — the HSM instance is single-tenant. + // Return an empty owner string; the scheduler must resolve ownership + // from the KMS system metadata if multi-tenancy is needed in future. + due_uids.push((uid, String::new())); + } + } + } + + Ok(due_uids) + } + + /// Find HSM keys by keyset name, with optional generation and latest filters. + /// + /// The keyset name is parsed from `CKA_LABEL` which carries the format + /// `rotate_name::generation::key_id[@latest]`. This allows keys to be + /// addressed by their logical name rather than their physical UID. + async fn find_by_rotate_name( + &self, + name: &str, + generation: Option, + // PKCS#11 objects have no KMIP "owner" field — ownership cannot be filtered + // at the HSM layer. The KMS server is assumed to be single-tenant with respect + // to a given HSM instance (each deployment's HSM is dedicated to one server). + // The `owner` parameter is intentionally unused here; the SQL implementation + // (which is multi-tenant) does filter by owner. + _owner: &str, + ) -> InterfaceResult> { + let slot_ids = self.hsm.get_available_slot_list().await?; + let mut results = Vec::new(); + + for slot_id in slot_ids { + let found = self + .hsm + .find(slot_id, HsmObjectFilter::Any) + .await + .unwrap_or_default(); + for object_id in found { + let Some(meta) = self + .hsm + .get_key_metadata(slot_id, &object_id) + .await + .unwrap_or_default() + else { + continue; + }; + // Only consider keys that belong to this keyset + let Some(ref key_rotate_name) = meta.rotate_name else { + continue; + }; + if key_rotate_name != name { + continue; + } + // Optional generation filter + if let Some(gen_filter) = generation { + if meta.rotate_generation != Some(gen_filter) { + continue; + } + } + // Optional latest filter — removed: caller selects max generation instead + let Ok(object_string) = std::str::from_utf8(&object_id) else { + continue; + }; + let uid = format!("{}::{slot_id}::{object_string}", self.prefix); + let attrs = build_keyset_attributes(&meta); + results.push((uid, attrs)); + } + } + + Ok(results) + } + + async fn set_key_label(&self, uid: &str, label: &str) -> InterfaceResult<()> { + let (slot_id, key_id) = parse_uid_with_prefix(uid, &self.prefix)?; + self.hsm + .set_key_label(slot_id, key_id.as_bytes(), label) + .await + } + + async fn set_key_rotation_dates( + &self, + uid: &str, + start_date: Option, + end_date: Option, + ) -> InterfaceResult<()> { + let (slot_id, key_id) = parse_uid_with_prefix(uid, &self.prefix)?; + self.hsm + .set_key_dates(slot_id, key_id.as_bytes(), start_date, end_date) + .await + } + /// Count all non-destroyed objects on this HSM. /// /// On an HSM every object present in a slot is by definition non-destroyed: @@ -645,6 +785,20 @@ fn build_sensitive_stub_attributes(meta: &KeyMetadata) -> Attributes { KeyFormatType::PKCS1, ), }; + // Reconstruct rotate_interval from CKA_START_DATE / CKA_END_DATE. + // HsmStore::update_object is a no-op for KMIP attributes, so this is the only + // way to expose the scheduled interval to attribute-only callers (e.g. re-key). + let rotate_interval = match (meta.start_date, meta.end_date) { + (Some(start), Some(end)) => { + let days = (end - start).whole_days(); + if days > 0 { + Some(days * crate::SECS_PER_DAY) + } else { + None + } + } + _ => None, + }; Attributes { cryptographic_algorithm: Some(algorithm), cryptographic_length: Some(i32::try_from(meta.key_length_in_bits).unwrap_or_default()), @@ -652,6 +806,9 @@ fn build_sensitive_stub_attributes(meta: &KeyMetadata) -> Attributes { cryptographic_usage_mask: Some(usage_mask), key_format_type: Some(key_format_type), sensitive: Some(true), + rotate_name: meta.rotate_name.clone(), + rotate_generation: meta.rotate_generation, + rotate_interval, ..Attributes::default() } } @@ -725,6 +882,15 @@ fn build_sensitive_stub_object(meta: &KeyMetadata) -> Object { } } +/// Build an `Attributes` struct populated with keyset metadata from `KeyMetadata`. +/// Used by `find_by_rotate_name` to return `rotate_name`/`generation`/`latest` to callers. +fn build_keyset_attributes(meta: &KeyMetadata) -> Attributes { + let mut attrs = build_find_attributes(&Some(meta.clone()), &HsmObjectFilter::Any); + attrs.rotate_name.clone_from(&meta.rotate_name); + attrs.rotate_generation = meta.rotate_generation; + attrs +} + fn build_find_attributes(meta: &Option, filter: &HsmObjectFilter) -> Attributes { let mut attrs = Attributes::default(); if let Some(m) = meta { @@ -1236,6 +1402,19 @@ mod tests { len: usize, ) -> InterfaceResult>; async fn seed_random(&self, slot_id: usize, seed: &[u8]) -> InterfaceResult<()>; + async fn set_key_dates( + &self, + slot_id: usize, + key_id: &[u8], + start_date: Option, + end_date: Option, + ) -> InterfaceResult<()>; + async fn set_key_label( + &self, + slot_id: usize, + key_id: &[u8], + label: &str, + ) -> InterfaceResult<()>; fn hsm_lib(&self) -> Option<&'static dyn std::any::Any> { None } } } diff --git a/crate/interfaces/src/hsm/interface.rs b/crate/interfaces/src/hsm/interface.rs index 6dedcf5a43..3820308971 100644 --- a/crate/interfaces/src/hsm/interface.rs +++ b/crate/interfaces/src/hsm/interface.rs @@ -336,6 +336,43 @@ pub trait HSM: Send + Sync { /// can return an error; callers may choose to ignore such errors. async fn seed_random(&self, slot_id: usize, seed: &[u8]) -> InterfaceResult<()>; + /// Set `CKA_START_DATE` and `CKA_END_DATE` on a key object. + /// + /// These PKCS#11 attributes are used to track rotation scheduling: + /// - `start_date` — when the current rotation interval began. + /// - `end_date` — when the key is due for rotation. + /// + /// Passing `None` for either date clears that attribute (sets to empty `CK_DATE`). + /// + /// # Arguments + /// * `slot_id` - the slot ID of the HSM + /// * `key_id` - the ID of the key + /// * `start_date` - optional start date + /// * `end_date` - optional end date + async fn set_key_dates( + &self, + slot_id: usize, + key_id: &[u8], + start_date: Option, + end_date: Option, + ) -> InterfaceResult<()>; + + /// Set `CKA_LABEL` on a key object. + /// + /// The label encodes keyset metadata in the format + /// `rotate_name::generation::key_id[@latest]`. + /// + /// # Arguments + /// * `slot_id` - the slot ID of the HSM + /// * `key_id` - the `CKA_ID` bytes of the key to update + /// * `label` - the new label string to set + async fn set_key_label( + &self, + slot_id: usize, + key_id: &[u8], + label: &str, + ) -> InterfaceResult<()>; + /// Get a reference to the underlying PKCS#11 library for direct function calls. /// /// This method provides access to the raw PKCS#11 library (`HsmLib`) to enable diff --git a/crate/interfaces/src/lib.rs b/crate/interfaces/src/lib.rs index 5e75e0c5c7..e17bac19a3 100644 --- a/crate/interfaces/src/lib.rs +++ b/crate/interfaces/src/lib.rs @@ -13,9 +13,12 @@ pub use hsm::{ }; pub use stores::{AtomicOperation, ObjectWithMetadata, ObjectsStore, PermissionsStore}; +/// Number of seconds in one day — the finest granularity PKCS#11 `CK_DATE` can represent. +pub const SECS_PER_DAY: i64 = 24 * 3600; + /// Supported cryptographic object types /// in plugins -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq)] pub enum KeyType { AesKey, RsaPrivateKey, diff --git a/crate/interfaces/src/stores/object_with_metadata.rs b/crate/interfaces/src/stores/object_with_metadata.rs index 97c3f50cc9..d331d83ac5 100644 --- a/crate/interfaces/src/stores/object_with_metadata.rs +++ b/crate/interfaces/src/stores/object_with_metadata.rs @@ -1,10 +1,14 @@ use std::fmt::{self, Display, Formatter}; use cosmian_kmip::{ - kmip_0::kmip_types::State, + KmipError, + kmip_0::kmip_types::{CryptographicUsageMask, ErrorReason, State}, kmip_2_1::{ - kmip_attributes::Attributes, kmip_objects::Object, kmip_types::CryptographicAlgorithm, + kmip_attributes::Attributes, + kmip_objects::Object, + kmip_types::{CryptographicAlgorithm, UsageLimitsUnit}, }, + time_normalize, }; /// An object with its metadata such as owner, permissions and state @@ -93,6 +97,157 @@ impl ObjectWithMetadata { .and_then(|kb| kb.cryptographic_algorithm().copied()) .or(self.attributes.cryptographic_algorithm) } + + // ─── Lifecycle predicates ──────────────────────────────────────────────── + + /// Determine the effective KMIP state based on stored state and time-based + /// transitions (activation / deactivation). + /// + /// - `PreActive` → `Active` when `activation_date` ≤ now. + /// - `Active` → `Deactivated` when `deactivation_date` ≤ now. + /// + /// Falls back to the stored state if the system clock cannot be read. + #[must_use] + pub fn effective_state(&self) -> State { + let Ok(now) = time_normalize() else { + return self.state; + }; + match self.state { + State::PreActive => { + let activation_date = self.attributes.activation_date.or_else(|| { + self.object + .attributes() + .ok() + .and_then(|attrs| attrs.activation_date) + }); + if activation_date.is_some_and(|d| d <= now) { + State::Active + } else { + State::PreActive + } + } + State::Active => { + let deactivation_date = self.attributes.deactivation_date.or_else(|| { + self.object + .attributes() + .ok() + .and_then(|attrs| attrs.deactivation_date) + }); + if deactivation_date.is_some_and(|d| d <= now) { + State::Deactivated + } else { + State::Active + } + } + other => other, + } + } + + /// Check whether the current time falls within the KMIP process window + /// (`ProcessStartDate`..`ProtectStopDate`). + /// + /// Returns `true` when usage is allowed (window is open or no window is set). + /// Returns `false` when the key is outside its process window. + /// Falls back to `true` if the system clock cannot be read. + /// + /// # Attribute precedence + /// + /// The external (database) attributes stored in `self.attributes` are checked + /// first, with the embedded key-block attributes as fallback. This mirrors + /// `effective_state()` and ensures that `SetAttribute ProcessStartDate / ProtectStopDate` + /// calls are honoured even when the key block itself was not modified. + #[must_use] + pub fn is_within_process_window(&self) -> bool { + if self.effective_state() != State::Active { + return true; // window only applies to Active keys + } + let Ok(now) = time_normalize() else { + return true; + }; + // Prefer external (database) attributes; fall back to embedded key-block attributes. + let kb_attrs = self.object.attributes().ok(); + let process_start = self + .attributes + .process_start_date + .or_else(|| kb_attrs.as_ref().and_then(|a| a.process_start_date)); + let protect_stop = self + .attributes + .protect_stop_date + .or_else(|| kb_attrs.as_ref().and_then(|a| a.protect_stop_date)); + let too_early = process_start.is_some_and(|d| now < d); + let too_late = protect_stop.is_some_and(|d| now > d); + !(too_early || too_late) + } + + // ─── Usage predicates ──────────────────────────────────────────────────── + + /// Check whether the object's usage mask permits the given operation. + /// + /// In **lenient** mode a missing mask (`None`) is treated as "allowed", + /// which supports legacy Certificates/Public Keys imported without masks. + #[must_use] + pub fn has_usage_mask(&self, required: CryptographicUsageMask, lenient: bool) -> bool { + let attributes = self + .object + .attributes() + .unwrap_or_else(|_| self.attributes()); + if lenient && attributes.cryptographic_usage_mask.is_none() { + return true; + } + attributes + .is_usage_authorized_for(required) + .unwrap_or(false) + } + + /// Check whether the key's remaining usage budget is sufficient for + /// `data_len` bytes of payload. + /// + /// Returns `true` when no `UsageLimits` are set or the budget is sufficient. + #[must_use] + pub fn has_usage_budget(&self, data_len: usize) -> bool { + let Some(ul) = self.attributes.usage_limits.as_ref() else { + return true; + }; + match ul.usage_limits_unit { + UsageLimitsUnit::Byte => { + let needed = i64::try_from(data_len).unwrap_or(i64::MAX); + ul.usage_limits_total >= needed + } + UsageLimitsUnit::Object | UsageLimitsUnit::Block | UsageLimitsUnit::Operation => { + ul.usage_limits_total > 0 + } + } + } + + // ─── Enforcement (error-returning) ─────────────────────────────────────── + + /// Enforce the KMIP process-window constraints. + /// + /// An Active key whose current time is before `ProcessStartDate` or after + /// `ProtectStopDate` is rejected with `Wrong_Key_Lifecycle_State`. + pub fn check_process_window(&self) -> Result<(), KmipError> { + if !self.is_within_process_window() { + return Err(KmipError::Kmip21( + ErrorReason::Wrong_Key_Lifecycle_State, + "DENIED".to_owned(), + )); + } + Ok(()) + } + + /// Enforce `UsageLimits` before a cryptographic operation. + /// + /// Returns `Err(Permission_Denied)` when the key's remaining usage budget + /// is insufficient for the requested `data_len` bytes. + pub fn enforce_usage_limits(&self, data_len: usize) -> Result<(), KmipError> { + if !self.has_usage_budget(data_len) { + return Err(KmipError::Kmip21( + ErrorReason::Permission_Denied, + "DENIED".to_owned(), + )); + } + Ok(()) + } } impl Display for ObjectWithMetadata { @@ -104,3 +259,184 @@ impl Display for ObjectWithMetadata { ) } } + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn)] +mod tests { + use cosmian_kmip::{ + kmip_0::kmip_types::State, + kmip_2_1::{ + kmip_attributes::Attributes, + kmip_data_structures::{KeyBlock, KeyMaterial, KeyValue}, + kmip_objects::{Object, SymmetricKey}, + kmip_types::{CryptographicAlgorithm, KeyFormatType}, + }, + time_normalize, + }; + use time::Duration; + use zeroize::Zeroizing; + + use super::ObjectWithMetadata; + + /// Build a minimal `Object::SymmetricKey` with empty embedded attributes. + fn test_object() -> Object { + Object::SymmetricKey(SymmetricKey { + key_block: KeyBlock { + key_format_type: KeyFormatType::Raw, + key_value: Some(KeyValue::Structure { + key_material: KeyMaterial::ByteString(Zeroizing::new(vec![0_u8; 32])), + attributes: Some(Attributes::default()), + }), + key_compression_type: None, + cryptographic_algorithm: Some(CryptographicAlgorithm::AES), + cryptographic_length: Some(256), + key_wrapping_data: None, + }, + }) + } + + fn active_owm(ext_attrs: Attributes) -> ObjectWithMetadata { + ObjectWithMetadata::new( + "test-key".to_owned(), + test_object(), + "owner".to_owned(), + State::Active, + ext_attrs, + ) + } + + // ── is_within_process_window ────────────────────────────────────────────── + + #[test] + fn test_process_window_no_dates_is_open() { + let owm = active_owm(Attributes::default()); + assert!(owm.is_within_process_window()); + } + + /// `ProtectStopDate` set via `SetAttribute` (external DB attrs) in the past + /// must be honoured. This verifies the bug-fix: the old code only checked the + /// key-block embedded attributes and would have returned `true` here. + #[test] + fn test_process_window_protect_stop_past_in_external_attrs_is_closed() + -> Result<(), Box> { + let now = time_normalize()?; + let owm = active_owm(Attributes { + protect_stop_date: Some(now - Duration::hours(1)), + ..Default::default() + }); + assert!(!owm.is_within_process_window()); + Ok(()) + } + + #[test] + fn test_process_window_protect_stop_future_is_open() -> Result<(), Box> { + let now = time_normalize()?; + let owm = active_owm(Attributes { + protect_stop_date: Some(now + Duration::hours(1)), + ..Default::default() + }); + assert!(owm.is_within_process_window()); + Ok(()) + } + + /// `ProcessStartDate` set via `SetAttribute` (external DB attrs) in the future + /// must be honoured. Same fix as the `ProtectStopDate` case above. + #[test] + fn test_process_window_process_start_future_in_external_attrs_is_closed() + -> Result<(), Box> { + let now = time_normalize()?; + let owm = active_owm(Attributes { + process_start_date: Some(now + Duration::hours(1)), + ..Default::default() + }); + assert!(!owm.is_within_process_window()); + Ok(()) + } + + #[test] + fn test_process_window_process_start_past_is_open() -> Result<(), Box> { + let now = time_normalize()?; + let owm = active_owm(Attributes { + process_start_date: Some(now - Duration::hours(1)), + ..Default::default() + }); + assert!(owm.is_within_process_window()); + Ok(()) + } + + #[test] + fn test_process_window_both_dates_valid_is_open() -> Result<(), Box> { + let now = time_normalize()?; + let owm = active_owm(Attributes { + process_start_date: Some(now - Duration::hours(1)), + protect_stop_date: Some(now + Duration::hours(1)), + ..Default::default() + }); + assert!(owm.is_within_process_window()); + Ok(()) + } + + /// `ProtectStopDate` embedded inside the key block (not via `SetAttribute`) + /// must still be honoured — the fallback path remains correct. + #[test] + fn test_process_window_protect_stop_past_in_key_block_is_closed() + -> Result<(), Box> { + let now = time_normalize()?; + // Build an object with ProtectStopDate inside the key block's embedded attrs. + let kb_attrs = Attributes { + protect_stop_date: Some(now - Duration::hours(1)), + ..Default::default() + }; + let object = Object::SymmetricKey(SymmetricKey { + key_block: KeyBlock { + key_format_type: KeyFormatType::Raw, + key_value: Some(KeyValue::Structure { + key_material: KeyMaterial::ByteString(Zeroizing::new(vec![0_u8; 32])), + attributes: Some(kb_attrs), + }), + key_compression_type: None, + cryptographic_algorithm: Some(CryptographicAlgorithm::AES), + cryptographic_length: Some(256), + key_wrapping_data: None, + }, + }); + let owm = ObjectWithMetadata::new( + "test-key".to_owned(), + object, + "owner".to_owned(), + State::Active, + Attributes::default(), // no external attrs + ); + assert!(!owm.is_within_process_window()); + Ok(()) + } + + /// Non-Active keys bypass the process-window check (always open). + #[test] + fn test_process_window_non_active_state_always_open() -> Result<(), Box> + { + let now = time_normalize()?; + for state in [ + State::Compromised, + State::Deactivated, + State::Destroyed, + State::PreActive, + ] { + let owm = ObjectWithMetadata::new( + "test-key".to_owned(), + test_object(), + "owner".to_owned(), + state, + Attributes { + protect_stop_date: Some(now - Duration::hours(1)), + ..Default::default() + }, + ); + assert!( + owm.is_within_process_window(), + "expected window open for state {state:?}" + ); + } + Ok(()) + } +} diff --git a/crate/interfaces/src/stores/objects_store.rs b/crate/interfaces/src/stores/objects_store.rs index f06cb0c69f..0bb6762eca 100644 --- a/crate/interfaces/src/stores/objects_store.rs +++ b/crate/interfaces/src/stores/objects_store.rs @@ -6,6 +6,7 @@ use cosmian_kmip::{ kmip_2_1::{kmip_attributes::Attributes, kmip_objects::Object}, }; use cosmian_logger::warn; +use time::OffsetDateTime; use crate::{InterfaceResult, ObjectWithMetadata}; @@ -104,6 +105,79 @@ pub trait ObjectsStore { 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_interval + rotate_offset` (if `rotate_date` is None) + /// + /// The default implementation returns an empty list; backends should override. + /// + /// Each entry is `(uid, owner)` so the auto-rotation scheduler can issue a + /// Re-Key on behalf of the correct owner without an additional DB round-trip. + async fn find_due_for_rotation( + &self, + _now: OffsetDateTime, + ) -> InterfaceResult> { + Ok(vec![]) + } + + /// Find objects by their `x-rotate-name` vendor attribute. + /// + /// Optionally filter by: + /// - `generation`: match `x-rotate-generation` exactly + /// - `latest`: match `x-rotate-latest` flag + /// - `owner`: match the object owner + /// + /// Returns a list of `(uid, attributes)` pairs. + /// The default implementation returns an empty list; backends should override. + async fn find_by_rotate_name( + &self, + _name: &str, + _generation: Option, + _owner: &str, + ) -> InterfaceResult> { + Ok(vec![]) + } + + /// Set the human-readable label on a key object. + /// + /// For HSM backends this writes `CKA_LABEL` via `C_SetAttributeValue`. + /// The SQL backends ignore this call (labels are carried in the KMIP `Name` attribute + /// and managed separately). Default: no-op. + async fn set_key_label(&self, _uid: &str, _label: &str) -> InterfaceResult<()> { + Ok(()) + } + + /// Rewrite the PKCS#11 rotation dates on an HSM key identified by `uid`. + /// + /// `start_date` and `end_date` are stored as `CKA_START_DATE` / `CKA_END_DATE`. + /// SQL backends ignore this call. Default: no-op. + async fn set_key_rotation_dates( + &self, + _uid: &str, + _start_date: Option, + _end_date: Option, + ) -> InterfaceResult<()> { + Ok(()) + } + /// Count all objects that are **not** in a terminal (destroyed) state. /// /// # Purpose — metrics only diff --git a/crate/kmip/src/kmip_1_4/kmip_attributes.rs b/crate/kmip/src/kmip_1_4/kmip_attributes.rs index 8f7022d388..d5d82fe70a 100644 --- a/crate/kmip/src/kmip_1_4/kmip_attributes.rs +++ b/crate/kmip/src/kmip_1_4/kmip_attributes.rs @@ -961,6 +961,7 @@ impl TryFrom for Attribute { | kmip_2_1::kmip_attributes::Attribute::ProtectionPeriod(_) | kmip_2_1::kmip_attributes::Attribute::ProtectionStorageMasks(_) | kmip_2_1::kmip_attributes::Attribute::QuantumSafe(_) + | kmip_2_1::kmip_attributes::Attribute::RotateAutomatic(_) | kmip_2_1::kmip_attributes::Attribute::RotateDate(_) | kmip_2_1::kmip_attributes::Attribute::RotateGeneration(_) | kmip_2_1::kmip_attributes::Attribute::RotateInterval(_) diff --git a/crate/kmip/src/kmip_1_4/kmip_operations.rs b/crate/kmip/src/kmip_1_4/kmip_operations.rs index 6078ea81bb..5d8dd385ba 100644 --- a/crate/kmip/src/kmip_1_4/kmip_operations.rs +++ b/crate/kmip/src/kmip_1_4/kmip_operations.rs @@ -274,7 +274,7 @@ pub struct ReKey { pub unique_identifier: String, /// Offset from the initialization date of the new key #[serde(skip_serializing_if = "Option::is_none")] - pub offset: Option, + pub offset: Option, /// Template attributes for the new key #[serde(skip_serializing_if = "Option::is_none")] pub template_attribute: Option, @@ -322,7 +322,7 @@ pub struct ReKeyKeyPair { pub private_key_unique_identifier: String, /// Offset from the initialization date of the new key pair #[serde(skip_serializing_if = "Option::is_none")] - pub offset: Option, + pub offset: Option, /// Common template attributes for both public and private key #[serde(skip_serializing_if = "Option::is_none")] pub common_template_attribute: Option, @@ -479,12 +479,19 @@ pub struct CertifyResponse { /// 4.8 Re-certify /// This operation requests the server to generate a new Certificate object for an existing public key. +/// Per KMIP 1.4 §4.8 Table 188, all fields are optional. #[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "PascalCase")] pub struct ReCertify { - pub unique_identifier: String, - pub certificate_request_type: CertificateRequestType, - pub certificate_request_value: Vec, + /// If omitted, then the ID Placeholder value is used by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub unique_identifier: Option, + /// REQUIRED if the Certificate Request is present. + #[serde(skip_serializing_if = "Option::is_none")] + pub certificate_request_type: Option, + /// A Byte String object with the certificate request. + #[serde(skip_serializing_if = "Option::is_none")] + pub certificate_request_value: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub template_attribute: Option, } @@ -498,6 +505,54 @@ pub struct ReCertifyResponse { pub template_attribute: Option, } +impl From for kmip_2_1::kmip_operations::ReCertify { + fn from(recertify: ReCertify) -> Self { + let cert_req_type = recertify.certificate_request_type.map(|t| match t { + CertificateRequestType::CRMF => kmip_2_1::kmip_types::CertificateRequestType::CRMF, + CertificateRequestType::PKCS10 => kmip_2_1::kmip_types::CertificateRequestType::PKCS10, + CertificateRequestType::PEM => kmip_2_1::kmip_types::CertificateRequestType::PEM, + }); + Self { + unique_identifier: recertify.unique_identifier.map(Into::into), + certificate_request_type: cert_req_type, + certificate_request_value: recertify.certificate_request_value, + offset: None, + attributes: recertify.template_attribute.map(Into::into), + protection_storage_masks: None, + } + } +} + +impl TryFrom for ReCertifyResponse { + type Error = KmipError; + + fn try_from(value: kmip_2_1::kmip_operations::ReCertifyResponse) -> Result { + Ok(Self { + unique_identifier: value.unique_identifier.to_string(), + template_attribute: None, + }) + } +} + +impl From for ReCertify { + fn from(recertify: kmip_2_1::kmip_operations::ReCertify) -> Self { + // Per KMIP 1.4 §4.8 Table 188, all fields are optional. + // Certificate Request Type is "REQUIRED if the Certificate Request is present". + let cert_req_type = recertify.certificate_request_type.map(|t| match t { + kmip_2_1::kmip_types::CertificateRequestType::CRMF => CertificateRequestType::CRMF, + kmip_2_1::kmip_types::CertificateRequestType::PKCS10 => CertificateRequestType::PKCS10, + kmip_2_1::kmip_types::CertificateRequestType::PEM => CertificateRequestType::PEM, + }); + Self { + unique_identifier: recertify.unique_identifier.map(|u| u.to_string()), + certificate_request_type: cert_req_type, + certificate_request_value: recertify.certificate_request_value, + template_attribute: None, + // KMIP 1.4 does not support offset; it is dropped during downgrade. + } + } +} + /// 4.9 Locate /// This operation requests that the server search for one or more Managed Objects. #[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] @@ -2647,9 +2702,7 @@ impl TryFrom for kmip_2_1::kmip_operations::Operation { // } // Operation::Poll(poll) => Self::Poll(poll.into()), Operation::Query(query) => Self::Query(query.into()), - // Operation::ReCertify(recertify) => { - // Self::ReCertify(recertify.into()) - // } + Operation::ReCertify(recertify) => Self::ReCertify(Box::new(recertify.into())), // Operation::Recover(recover) => { // Self::Recover(recover.into()) // } @@ -2803,9 +2856,9 @@ impl TryFrom for Operation { (*query_response).try_into().context("QueryResponse")?, )) } - // Operation::ReCertifyResponse(recertify_response) => { - // Self::ReCertifyResponse(recertify_response.into()) - // } + kmip_2_1::kmip_operations::Operation::ReCertifyResponse(recertify_response) => { + Self::ReCertifyResponse(recertify_response.try_into().context("ReCertifyResponse")?) + } // Operation::RecoverResponse(recover_response) => { // Self::RecoverResponse(recover_response.into()) // } diff --git a/crate/kmip/src/kmip_2_1/kmip_attributes.rs b/crate/kmip/src/kmip_2_1/kmip_attributes.rs index ffcbc027f0..26777e8fbf 100644 --- a/crate/kmip/src/kmip_2_1/kmip_attributes.rs +++ b/crate/kmip/src/kmip_2_1/kmip_attributes.rs @@ -3,7 +3,7 @@ use std::fmt::{self, Display, Formatter}; use cosmian_logger::trace; use serde::{Deserialize, Serialize}; use strum::Display; -use time::OffsetDateTime; +use time::{Duration, OffsetDateTime}; use super::kmip_types::{Digest, UsageLimits, VendorAttributeValue}; use crate::{ @@ -22,6 +22,7 @@ use crate::{ VENDOR_ATTR_AAD, VendorAttribute, }, }, + time_utils::time_normalize, }; /// The following subsections describe the attributes that are associated with @@ -366,6 +367,12 @@ pub struct Attributes { #[serde(skip_serializing_if = "Option::is_none")] pub revocation_reason: Option, + /// If set to True, specifies the Managed Object will be automatically rotated by the server + /// using the Rotate Interval via the equivalent of the `ReKey`, `ReKeyKeyPair` or `ReCertify` + /// operation performed by the server (KMIP 2.1 §4.48). + #[serde(skip_serializing_if = "Option::is_none")] + pub rotate_automatic: Option, + /// The Rotate Date attribute specifies the date and time for the last rotation /// of a Managed Cryptographic Object. The Rotate Date attribute SHALL be set by /// the server when the Rotate operation successfully completes. @@ -381,11 +388,10 @@ pub struct Attributes { /// The Rotate Interval attribute specifies the interval between rotations of a /// Managed Cryptographic Object, measured in seconds. #[serde(skip_serializing_if = "Option::is_none")] - pub rotate_interval: Option, + pub rotate_interval: Option, - /// The Rotate Latest attribute is a Boolean that indicates whether the latest - /// rotation time should be recalculated based on the Rotation Interval and - /// the Initial Date. + /// If set to True, specifies the Managed Object is the most recent object of the set of + /// rotated Managed Objects. Set by the server when the object is rotated (KMIP 2.1 §4.52). #[serde(skip_serializing_if = "Option::is_none")] pub rotate_latest: Option, @@ -399,7 +405,7 @@ pub struct Attributes { /// Date and the Rotation Date of a Managed Cryptographic Object, measured in /// seconds. #[serde(skip_serializing_if = "Option::is_none")] - pub rotate_offset: Option, + pub rotate_offset: Option, /// If True then the server SHALL prevent the object value being retrieved /// (via the Get operation) unless it is wrapped by another key. The server @@ -728,6 +734,7 @@ impl Attributes { merge_option_field!(quantum_safe); merge_option_field!(random_number_generator); merge_option_field!(revocation_reason); + merge_option_field!(rotate_automatic); merge_option_field!(rotate_date); merge_option_field!(rotate_generation); merge_option_field!(rotate_interval); @@ -946,6 +953,9 @@ impl Display for Attributes { if let Some(value) = &self.revocation_reason { writeln!(f, " Revocation Reason: {value}")?; } + if let Some(value) = &self.rotate_automatic { + writeln!(f, " Rotate Automatic: {value}")?; + } if let Some(value) = &self.rotate_date { writeln!(f, " Rotate Date: {value}")?; } @@ -1238,6 +1248,10 @@ pub enum Attribute { /// Managed Object was revoked. RevocationReason(RevocationReason), + /// If set to True, specifies the Managed Object will be automatically rotated by the server + /// using the Rotate Interval (KMIP 2.1 §4.48). + RotateAutomatic(bool), + /// The Rotate Date attribute specifies the date and time for the last rotation /// of a Managed Cryptographic Object. The Rotate Date attribute SHALL be set by /// the server when the Rotate operation successfully completes. @@ -1250,11 +1264,10 @@ pub enum Attribute { /// The Rotate Interval attribute specifies the interval between rotations of a /// Managed Cryptographic Object, measured in seconds. - RotateInterval(i32), + RotateInterval(i64), - /// The Rotate Latest attribute is a Boolean that indicates whether the latest - /// rotation time should be recalculated based on the Rotation Interval and - /// the Initial Date. + /// If set to True, specifies the Managed Object is the most recent object of the set of + /// rotated Managed Objects (KMIP 2.1 §4.52). Set by the server; not modifiable by client. RotateLatest(bool), /// The Rotate Name attribute specifies the name of the rotation. This attribute @@ -1265,7 +1278,7 @@ pub enum Attribute { /// The Rotate Offset attribute specifies the time offset between the Creation /// Date and the Rotation Date of a Managed Cryptographic Object, measured in /// seconds. - RotateOffset(i32), + RotateOffset(i64), /// If True then the server SHALL prevent the object value being retrieved (via the Get operation) unless it is /// wrapped by another key. The server SHALL set the value to False if the value is not provided by the @@ -1471,6 +1484,9 @@ impl From for Vec { if let Some(revocation_reason) = attributes.revocation_reason { vec.push(Attribute::RevocationReason(revocation_reason)); } + if let Some(rotate_automatic) = attributes.rotate_automatic { + vec.push(Attribute::RotateAutomatic(rotate_automatic)); + } if let Some(rotate_date) = attributes.rotate_date { vec.push(Attribute::RotateDate(rotate_date)); } @@ -1604,6 +1620,7 @@ impl From> for Attributes { attrs.random_number_generator = Some(value); } Attribute::RevocationReason(value) => attrs.revocation_reason = Some(value), + Attribute::RotateAutomatic(value) => attrs.rotate_automatic = Some(value), Attribute::RotateDate(value) => attrs.rotate_date = Some(value), Attribute::RotateGeneration(value) => attrs.rotate_generation = Some(value), Attribute::RotateInterval(value) => attrs.rotate_interval = Some(value), @@ -1641,3 +1658,203 @@ impl From> for Attributes { attrs } } + +impl Attributes { + // ─── Key rotation helpers ────────────────────────────────────────────────── + + /// Return a clean copy of these attributes suitable for use as input to + /// `Create` / `CreateKeyPair` when generating a rotation replacement. + /// + /// Strips identity fields, lifecycle dates, rotation metadata, and vendor + /// tags that must not leak from the old key into the new key's generation + /// request. Cryptographic parameters (algorithm, length, domain parameters) + /// are preserved so the replacement key has identical cryptographic properties. + #[must_use] + pub fn clean_for_generation(&self, vendor_id: &str) -> Self { + let mut attrs = self.clone(); + // Identity — the new key gets its own UID and links + attrs.unique_identifier = None; + attrs.link = None; + attrs.name = None; + // Lifecycle dates — must not leak from old key + attrs.initial_date = None; + attrs.last_change_date = None; + attrs.activation_date = None; + attrs.deactivation_date = None; + attrs.destroy_date = None; + attrs.compromise_date = None; + attrs.compromise_occurrence_date = None; + // Generation format — let Create/CreateKeyPair choose + attrs.key_format_type = None; + // Rotation metadata — new key starts fresh; server re-stamps these + attrs.rotate_interval = None; + attrs.rotate_name = None; + attrs.rotate_offset = None; + // rotate_generation / rotate_latest / rotate_date are server-managed: + // clear them so the outer-metadata values set by set_rotation_metadata_from + // are not blocked by the merge(overwrite=false) in GetAttributes. + attrs.rotate_generation = None; + attrs.rotate_latest = None; + attrs.rotate_date = None; + // Vendor tags — assigned fresh by Create + drop(attrs.remove_tags(vendor_id)); + attrs + } + + /// Update these (old) attributes to record that they have been replaced. + /// + /// Per KMIP 1.4 §4.4 Table 173 / §4.5 Table 177 / §4.8 Table 187: + /// - Sets `ReplacementObjectLink` → new key UID + /// - Removes the Name attribute (transferred to the replacement) + /// - Updates Last Change Date to now + pub fn retire_for_replacement(&mut self, new_uid: &str) -> Result<(), KmipError> { + let now = time_normalize()?; + self.set_link( + LinkType::ReplacementObjectLink, + LinkedObjectIdentifier::TextString(new_uid.to_owned()), + ); + self.name = None; + self.deactivation_date = Some(now); + self.last_change_date = Some(now); + Ok(()) + } + + /// Set rotation metadata on these (new) attributes based on the old key's attributes. + /// + /// Per the auto-rotation spec (Manual rekey table): + /// - `rotate_generation` = old value + 1 + /// - `rotate_date` = now + /// - `rotate_interval` = 0 (manual rekey does not inherit the policy) + /// - `rotate_name` = inherited from old key (required for keyset resolution) + /// - `rotate_offset` = None (cleared for manual rekey) + /// - `rotate_latest` = true (new key is the latest in the keyset) + pub fn set_rotation_metadata_from(&mut self, old: &Self) -> Result<(), KmipError> { + self.rotate_generation = Some(old.rotate_generation.unwrap_or(0) + 1); + self.rotate_date = Some(time_normalize()?); + // Manual rekey: do not inherit the rotation policy — user must re-arm explicitly + self.rotate_interval = Some(0); + // Inherit rotate_name so keyset resolution can find the new key + self.rotate_name.clone_from(&old.rotate_name); + self.rotate_offset = None; + // Mark the new key as the latest in the keyset + self.rotate_latest = Some(true); + Ok(()) + } + + /// Clear rotation flags after this key is retired as part of a rekey. + /// + /// - `rotate_interval` = 0 (prevent the scheduler from picking it up again) + /// - `rotate_latest` = false (the old key is no longer the latest in the keyset) + /// - `rotate_generation` = 0 if unset (ensure gen-0 is queryable) + pub const fn clear_rotation_flags(&mut self) { + self.rotate_interval = Some(0); + self.rotate_latest = Some(false); + if self.rotate_generation.is_none() { + self.rotate_generation = Some(0); + } + } + + /// Validate that the request attributes do not attempt to change cryptographic parameters. + /// + /// Per KMIP §4.4 / §4.5, a rekey operation must preserve the algorithm, curve, + /// and key length of the original key. Changing these requires a new `Create` or + /// `CreateKeyPair` operation instead. + /// + /// The `attrs_iter` yields each `Option<&Attributes>` from the request (one for + /// symmetric `ReKey`, up to three for `ReKeyKeyPair`). + pub fn validate_no_crypto_param_change<'a>( + &self, + attrs_iter: impl IntoIterator>, + operation_name: &str, + ) -> Result<(), KmipError> { + for req_attrs in attrs_iter.into_iter().flatten() { + if let Some(algo) = req_attrs.cryptographic_algorithm { + if self.cryptographic_algorithm != Some(algo) { + return Err(KmipError::InvalidKmip21Value( + ErrorReason::Constraint_Violation, + format!( + "{operation_name}: changing the cryptographic algorithm is not \ + allowed. Use Create/CreateKeyPair for a different algorithm." + ), + )); + } + } + if let Some(ref cdp) = req_attrs.cryptographic_domain_parameters { + if let Some(ref existing_cdp) = self.cryptographic_domain_parameters { + if cdp.recommended_curve.is_some() + && cdp.recommended_curve != existing_cdp.recommended_curve + { + return Err(KmipError::InvalidKmip21Value( + ErrorReason::Constraint_Violation, + format!( + "{operation_name}: changing the recommended curve is not allowed. \ + Use Create/CreateKeyPair for a different curve." + ), + )); + } + } + } + if let Some(len) = req_attrs.cryptographic_length { + if self.cryptographic_length.is_some() && self.cryptographic_length != Some(len) { + return Err(KmipError::InvalidKmip21Value( + ErrorReason::Constraint_Violation, + format!( + "{operation_name}: changing the cryptographic length is not allowed. \ + Use Create/CreateKeyPair for a different key size." + ), + )); + } + } + } + Ok(()) + } + + /// Create replacement attributes for a new key based on these (old) attributes. + /// + /// Per KMIP 1.4 §4.4 Table 173 / §4.5 Table 177 / §4.8 Table 187: + /// - Copies attributes from the existing key + /// - Removes stale unique identifier and links + /// - Sets `ReplacedObjectLink` → old key UID + /// - Applies offset-based date computation + pub fn for_replacement(&self, old_uid: &str, offset: Option) -> Result { + let now = time_normalize()?; + let activation_date = Some(offset.map_or(now, |secs| now + Duration::seconds(secs))); + let deactivation_date = match (self.deactivation_date, self.activation_date) { + (Some(old_deactivation), Some(old_activation)) => { + activation_date.map(|new_activation| { + let shift = new_activation - old_activation; + old_deactivation + shift + }) + } + _ => None, + }; + + let mut new_attrs = self.clone(); + + // Clear fields that must not be set on the replacement key + new_attrs.unique_identifier = None; + new_attrs.destroy_date = None; + new_attrs.compromise_date = None; + new_attrs.compromise_occurrence_date = None; + + // Remove any existing replacement/replaced links (from a previous rekey) + new_attrs.remove_link(LinkType::ReplacementObjectLink); + new_attrs.remove_link(LinkType::ReplacedObjectLink); + + // Set the ReplacedObjectLink on the new key pointing to the old key + new_attrs.set_link( + LinkType::ReplacedObjectLink, + LinkedObjectIdentifier::TextString(old_uid.to_owned()), + ); + + // Set dates per spec + new_attrs.initial_date = Some(now); + new_attrs.last_change_date = Some(now); + new_attrs.activation_date = activation_date; + if deactivation_date.is_some() { + new_attrs.deactivation_date = deactivation_date; + } + + Ok(new_attrs) + } +} diff --git a/crate/kmip/src/kmip_2_1/kmip_messages.rs b/crate/kmip/src/kmip_2_1/kmip_messages.rs index 75d0e73646..0703673963 100644 --- a/crate/kmip/src/kmip_2_1/kmip_messages.rs +++ b/crate/kmip/src/kmip_2_1/kmip_messages.rs @@ -354,6 +354,9 @@ impl<'de> Deserialize<'de> for RequestMessageBatchItem { OperationEnumeration::ReKeyKeyPair => { Operation::ReKeyKeyPair(map.next_value()?) } + OperationEnumeration::ReCertify => { + Operation::ReCertify(map.next_value()?) + } x => { return Err(de::Error::custom(format!( "Request Message Batch Item: unsupported operation: {x:?}" @@ -792,6 +795,9 @@ impl<'de> Deserialize<'de> for ResponseMessageBatchItem { OperationEnumeration::ReKeyKeyPair => { Operation::ReKeyKeyPairResponse(map.next_value()?) } + OperationEnumeration::ReCertify => { + Operation::ReCertifyResponse(map.next_value()?) + } x => { return Err(de::Error::custom(format!( "KMIP 2 response message payload: unsupported operation: \ diff --git a/crate/kmip/src/kmip_2_1/kmip_objects.rs b/crate/kmip/src/kmip_2_1/kmip_objects.rs index 407637ab8e..702143b83a 100644 --- a/crate/kmip/src/kmip_2_1/kmip_objects.rs +++ b/crate/kmip/src/kmip_2_1/kmip_objects.rs @@ -16,12 +16,16 @@ use strum::{Display, VariantNames}; use super::kmip_operations::Base64Display; use super::{ kmip_attributes::Attributes, - kmip_data_structures::{KeyBlock, KeyWrappingData}, - kmip_types::{CertificateRequestType, OpaqueDataType, SplitKeyMethod}, + kmip_data_structures::{KeyBlock, KeyWrappingData, KeyWrappingSpecification}, + kmip_types::{ + CertificateRequestType, Digest, LinkType, LinkedObjectIdentifier, OpaqueDataType, + SplitKeyMethod, + }, }; use crate::{ error::KmipError, - kmip_0::kmip_types::{CertificateType, ErrorReason, SecretDataType}, + kmip_0::kmip_types::{CertificateType, ErrorReason, SecretDataType, State}, + time_normalize, }; /// A Managed Cryptographic Object that is a digital certificate. @@ -421,6 +425,92 @@ impl Object { self.key_block() .is_ok_and(|kb| kb.key_wrapping_data.is_some()) } + + /// Returns the UID of the wrapping (encryption) key embedded in this + /// object's `KeyWrappingData`, or `None` if the object is not wrapped. + #[must_use] + pub fn wrapping_key_uid(&self) -> Option { + self.key_wrapping_data() + .and_then(|kwd| kwd.encryption_key_information.as_ref()) + .map(|eki| eki.unique_identifier.to_string()) + } + + /// Build a [`KeyWrappingSpecification`] from this object's `KeyWrappingData`. + /// + /// Returns `None` if the object has no key block or is not wrapped. + #[must_use] + pub fn rewrap_spec(&self) -> Option { + let kwd = self.key_wrapping_data()?; + Some(KeyWrappingSpecification { + wrapping_method: kwd.wrapping_method, + encryption_key_information: kwd.encryption_key_information.clone(), + mac_or_signature_key_information: kwd.mac_signature_key_information.clone(), + attribute_name: None, + encoding_option: kwd.encoding_option, + }) + } + + /// Copy the wrapping key link from this object to `new_attrs`. + /// + /// If this object was wrapped, its wrapping key UID is preserved as a + /// `LinkType::WrappingKeyLink` on `new_attrs` so that dependant re-wrapping + /// and attribute queries work correctly on the replacement object. + pub fn copy_wrapping_key_link_to(&self, new_attrs: &mut Attributes) { + if let Some(wrapping_key_uid) = self.wrapping_key_uid() { + new_attrs.set_link( + LinkType::WrappingKeyLink, + LinkedObjectIdentifier::TextString(wrapping_key_uid), + ); + } + } + + /// Initialize lifecycle attributes on a newly created or imported object. + /// + /// - No `requested_activation_date` → state `PreActive` (requires explicit + /// Activate call or auto-activation via `effective_state()`). + /// - `requested_activation_date` ≤ now → state `Active` immediately. + /// - `requested_activation_date` > now → state `PreActive`, date stored for + /// auto-transition by `effective_state()`. + /// + /// Sets `digest`, `initial_date`, `original_creation_date`, + /// `last_change_date`, and `object_type` on the object's embedded + /// attributes. Returns a clone of the final attributes. + /// + /// The caller must compute the `digest` externally (e.g. via + /// `openssl::sha::sha256`) and pass it in. + pub fn setup_lifecycle( + &mut self, + object_type: ObjectType, + requested_activation_date: Option, + digest: Option, + ) -> Result { + let now = time_normalize()?; + let attributes = self.attributes_mut()?; + + // KMIP semantics: activation_date present and ≤ now → Active, + // otherwise PreActive (absent or future date). + let activation_allows_active = requested_activation_date.is_some_and(|d| d <= now); + let state = if activation_allows_active { + State::Active + } else { + State::PreActive + }; + + attributes.state = Some(state); + attributes.digest = digest; + attributes.object_type = Some(object_type); + attributes.initial_date = Some(now); + attributes.original_creation_date = Some(now); + attributes.last_change_date = Some(now); + if state == State::Active { + attributes.activation_date = Some(now); + } else if let Some(future_date) = requested_activation_date { + // PreActive with future date: store it so auto-transition works + attributes.activation_date = Some(future_date); + } + + Ok(attributes.clone()) + } } impl TryFrom<&[u8]> for Object { diff --git a/crate/kmip/src/kmip_2_1/kmip_operations.rs b/crate/kmip/src/kmip_2_1/kmip_operations.rs index 924711bc8f..287ba04ba1 100644 --- a/crate/kmip/src/kmip_2_1/kmip_operations.rs +++ b/crate/kmip/src/kmip_2_1/kmip_operations.rs @@ -193,6 +193,8 @@ pub enum Operation { PKCS11Response(PKCS11Response), Query(Query), QueryResponse(Box), + ReCertify(Box), + ReCertifyResponse(ReCertifyResponse), ReKey(ReKey), ReKeyKeyPair(Box), ReKeyKeyPairResponse(ReKeyKeyPairResponse), @@ -277,6 +279,8 @@ impl Display for Operation { Self::PKCS11Response(op) => write!(f, "{op}")?, Self::Query(op) => write!(f, "{op}")?, Self::QueryResponse(op) => write!(f, "{op}")?, + Self::ReCertify(op) => write!(f, "{op}")?, + Self::ReCertifyResponse(op) => write!(f, "{op}")?, Self::ReKey(op) => write!(f, "{op}")?, Self::ReKeyKeyPair(op) => write!(f, "{op}")?, Self::ReKeyKeyPairResponse(op) => write!(f, "{op}")?, @@ -333,6 +337,7 @@ impl Operation { | Self::ModifyAttributeResponse(_) | Self::PKCS11Response(_) | Self::QueryResponse(_) + | Self::ReCertifyResponse(_) | Self::ReKeyKeyPairResponse(_) | Self::ReKeyResponse(_) | Self::RegisterResponse(_) @@ -393,6 +398,7 @@ impl Operation { } Self::PKCS11(_) | Self::PKCS11Response(_) => OperationEnumeration::PKCS11, Self::Query(_) | Self::QueryResponse(_) => OperationEnumeration::Query, + Self::ReCertify(_) | Self::ReCertifyResponse(_) => OperationEnumeration::ReCertify, Self::Register(_) | Self::RegisterResponse(_) => OperationEnumeration::Register, Self::ReKey(_) | Self::ReKeyResponse(_) => OperationEnumeration::ReKey, Self::ReKeyKeyPair(_) | Self::ReKeyKeyPairResponse(_) => { @@ -701,7 +707,7 @@ pub enum InteropFunction { /// `OperationUndone` in the response, potentially including only the Unique Identifier /// in the payload as per the KMIP profiles. /// -/// Reference: #_`Toc6497533L` +/// Reference: #[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)] #[serde(rename_all = "PascalCase")] pub struct Check { @@ -1021,6 +1027,66 @@ pub struct CertifyResponse { impl_display!(CertifyResponse, "CertifyResponse", { req unique_identifier }); +/// `ReCertify` +/// +/// This operation requests the server to generate a new certificate for an +/// existing public key whose certificate has expired or is about to expire. +/// The request contains the Unique Identifier of the existing certificate to be +/// renewed, an optional certificate request, and optional attributes for the new +/// certificate. +/// +/// The server creates a new Certificate object with a fresh Unique Identifier, +/// sets a `ReplacedObjectLink` on the new certificate pointing to the old one, +/// and sets a `ReplacementObjectLink` on the old certificate pointing to the new one. +/// +/// KMIP 2.1 §6.1.45 / KMIP 1.4 §4.8 +#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "PascalCase")] +pub struct ReCertify { + /// The Unique Identifier of the Certificate being renewed. + /// If omitted, then the ID Placeholder value is used by the server as the Unique Identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub unique_identifier: Option, + /// An Enumeration object specifying the type of certificate request. + /// Required if Certificate Request Value is present. + #[serde(skip_serializing_if = "Option::is_none")] + pub certificate_request_type: Option, + /// A Byte String object with the certificate request. + #[serde(skip_serializing_if = "Option::is_none")] + pub certificate_request_value: Option>, + /// An Offset MAY be used to indicate the difference between the Initial Date + /// and the Activation Date of the new certificate. Per KMIP 2.1 §6.1.45, + /// the new certificate's Activation Date = Initial Date + Offset. + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Specifies desired attributes to be associated with the new certificate. + #[serde(skip_serializing_if = "Option::is_none")] + pub attributes: Option, + /// Specifies all permissible Protection Storage Mask selections for the new + /// object. + #[serde(skip_serializing_if = "Option::is_none")] + pub protection_storage_masks: Option, +} + +impl_display!(ReCertify, "ReCertify", { + opt unique_identifier, + opt certificate_request_type, + opt_b64 certificate_request_value, + opt offset, + opt attributes, + opt protection_storage_masks, +}); + +/// Response to a `ReCertify` request. +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)] +#[serde(rename_all = "PascalCase")] +pub struct ReCertifyResponse { + /// The Unique Identifier of the newly created replacement certificate. + pub unique_identifier: UniqueIdentifier, +} + +impl_display!(ReCertifyResponse, "ReCertifyResponse", { req unique_identifier }); + /// Create /// /// This operation requests the server to generate a new symmetric key or @@ -2477,7 +2543,7 @@ pub struct ReKey { // An Interval object indicating the difference between the Initial Date and the Activation Date of the replacement key to be created. #[serde(skip_serializing_if = "Option::is_none")] - pub offset: Option, + pub offset: Option, /// Specifies desired attributes to be associated with the new object. #[serde(skip_serializing_if = "Option::is_none")] @@ -2542,7 +2608,7 @@ pub struct ReKeyKeyPair { // An Interval object indicating the difference between the Initial Date and the Activation // Date of the replacement key pair to be created. #[serde(skip_serializing_if = "Option::is_none")] - pub offset: Option, + pub offset: Option, // Specifies desired attributes that apply to both the Private and Public Key Objects. #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crate/kmip/src/kmip_2_1/kmip_types.rs b/crate/kmip/src/kmip_2_1/kmip_types.rs index cf96c6d088..0022d0777d 100644 --- a/crate/kmip/src/kmip_2_1/kmip_types.rs +++ b/crate/kmip/src/kmip_2_1/kmip_types.rs @@ -1751,6 +1751,22 @@ impl UniqueIdentifier { _ => None, } } + + /// Compute a fresh UID for a rotation replacement key. + /// + /// For keyset keys (`rotate_name` is `Some`): returns `"{name}@{gen+1}"`, + /// e.g. `"my-keyset@1"` for the first rotation of `"my-keyset"`. + /// For standalone keys (no `rotate_name`): returns a fresh UUID. + /// + /// The `prefix_uuid` pattern (`"name_"`) is intentionally dropped; + /// keyset membership is the only path to deterministic successor UIDs. + #[must_use] + pub fn rotation_successor(rotate_name: Option<&str>, rotate_generation: Option) -> String { + rotate_name.map_or_else( + || Uuid::new_v4().to_string(), + |name| format!("{name}@{}", rotate_generation.unwrap_or(0) + 1), + ) + } } impl TryFrom for UniqueIdentifier { diff --git a/crate/kmip/src/kmip_2_1/requests/create_key_pair.rs b/crate/kmip/src/kmip_2_1/requests/create_key_pair.rs index a3ddda49f3..0a6af83a80 100644 --- a/crate/kmip/src/kmip_2_1/requests/create_key_pair.rs +++ b/crate/kmip/src/kmip_2_1/requests/create_key_pair.rs @@ -74,6 +74,7 @@ pub fn create_rsa_key_pair_request>>( object_type: Some(ObjectType::PrivateKey), unique_identifier: private_key_id, sensitive: sensitive.then_some(true), + activation_date: Some(time_normalize()?), ..Attributes::default() }; @@ -85,6 +86,7 @@ pub fn create_rsa_key_pair_request>>( cryptographic_usage_mask: Some(public_key_mask), key_format_type: Some(KeyFormatType::TransparentRSAPrivateKey), object_type: Some(ObjectType::PrivateKey), + activation_date: Some(time_normalize()?), ..Attributes::default() }; @@ -217,7 +219,6 @@ pub fn create_ec_key_pair_request>>( unique_identifier: private_key_id, sensitive: sensitive.then_some(true), activation_date: Some(time_normalize()?), - ..Attributes::default() }; @@ -233,7 +234,6 @@ pub fn create_ec_key_pair_request>>( key_format_type: Some(KeyFormatType::TransparentECPublicKey), object_type: Some(ObjectType::PublicKey), activation_date: Some(time_normalize()?), - ..Attributes::default() }; diff --git a/crate/server/src/config/command_line/clap_config.rs b/crate/server/src/config/command_line/clap_config.rs index 91cb97339c..5ff1ce75ba 100644 --- a/crate/server/src/config/command_line/clap_config.rs +++ b/crate/server/src/config/command_line/clap_config.rs @@ -71,6 +71,8 @@ impl Default for ClapConfig { aws_xks_config: AwsXksConfig::default(), kmip_policy: KmipPolicyConfig::default(), azure_ekm_config: AzureEkmConfig::default(), + auto_rotation_check_interval_secs: 0, + keyset_warn_depth: 5, secret_backends: SecretBackendConfig::default(), } } @@ -216,6 +218,19 @@ pub struct ClapConfig { #[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. + /// When enabled, must be at least 60 seconds to avoid excessive database churn. + #[clap(long, default_value = "0", verbatim_doc_comment)] + pub auto_rotation_check_interval_secs: u64, + + /// Depth at which a successful keyset chain decryption triggers a server-side warning. + /// Keyset chain traversal is unbounded (stopped only by cycle detection); + /// this threshold emits a warning log so operators can flag stale ciphertexts. + /// Default: 5. + #[clap(long, default_value = "5", verbatim_doc_comment)] + pub keyset_warn_depth: u32, + /// Authentication credentials for secret URI resolution backends. /// /// These are provided via CLI flags or environment variables only — @@ -681,6 +696,11 @@ 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, + ); + let x = x.field("keyset_warn_depth", &self.keyset_warn_depth); x.finish() } diff --git a/crate/server/src/config/params/server_params.rs b/crate/server/src/config/params/server_params.rs index 9e5e8a9c89..3d9e7fb112 100644 --- a/crate/server/src/config/params/server_params.rs +++ b/crate/server/src/config/params/server_params.rs @@ -171,6 +171,17 @@ pub struct ServerParams { /// Client-supplied `MaximumItems` is clamped to this value; when absent the cap is /// applied automatically. Prevents unbounded DB queries and large response payloads. pub max_locate_items: u32, + + /// Interval in seconds between background auto-rotation checks. + /// 0 means disabled. + pub auto_rotation_check_interval_secs: u64, + + /// Depth at which a successful keyset chain decryption triggers a warning. + /// Keyset chain traversal is unbounded (stopped only by cycle detection); this + /// threshold lets operators know when a ciphertext required walking many + /// generations to decrypt — a hint that re-encryption with the latest key may + /// be beneficial. + pub keyset_warn_depth: u32, } /// Represents the server parameters. @@ -437,6 +448,19 @@ impl ServerParams { crate::config::default_cors_origins(cors_scheme, conf.http.port) }), max_locate_items: 1000, + auto_rotation_check_interval_secs: { + let v = conf.auto_rotation_check_interval_secs; + // 0 means disabled; any non-zero value must be at least 60 seconds to avoid + // hammering the database with high-frequency key-rotation scans. + if v > 0 && v < 60 { + return Err(KmsError::ServerError(format!( + "auto_rotation_check_interval_secs must be 0 (disabled) or at least 60 \ + seconds; {v} is too small and would cause excessive database churn" + ))); + } + v + }, + keyset_warn_depth: conf.keyset_warn_depth, }; debug!("{res:#?}"); @@ -662,6 +686,11 @@ impl fmt::Debug for ServerParams { debug_struct.field("server_workers", &self.server_workers); debug_struct.field("cors_allowed_origins", &self.cors_allowed_origins); debug_struct.field("max_locate_items", &self.max_locate_items); + debug_struct.field( + "auto_rotation_check_interval_secs", + &self.auto_rotation_check_interval_secs, + ); + debug_struct.field("keyset_warn_depth", &self.keyset_warn_depth); debug_struct.finish() } diff --git a/crate/server/src/core/cover_crypt/create_user_decryption_key.rs b/crate/server/src/core/cover_crypt/create_user_decryption_key.rs index 2a57d05cad..666671fcc2 100644 --- a/crate/server/src/core/cover_crypt/create_user_decryption_key.rs +++ b/crate/server/src/core/cover_crypt/create_user_decryption_key.rs @@ -34,7 +34,6 @@ pub(crate) async fn create_user_decryption_key( create_request: &Create, owner: &str, sensitive: bool, - privileged_users: Option>, ) -> KResult { let msk_uid_or_tags = create_request .attributes @@ -94,9 +93,7 @@ pub(crate) async fn create_user_decryption_key( object: msk_obj, }; - kmip_server - .import(import_request, owner, privileged_users) - .await?; + kmip_server.import(import_request, owner).await?; return Ok(usk_obj); } diff --git a/crate/server/src/core/cover_crypt/rekey_keys.rs b/crate/server/src/core/cover_crypt/rekey_keys.rs index d2e57598fb..8209d4752d 100644 --- a/crate/server/src/core/cover_crypt/rekey_keys.rs +++ b/crate/server/src/core/cover_crypt/rekey_keys.rs @@ -44,133 +44,72 @@ pub(crate) async fn rekey_keypair_cover_crypt( owner: &str, action: RekeyEditAction, _sensitive: bool, - privileged_users: Option>, ) -> KResult { trace!("Internal rekey key pair Covercrypt"); let mpk_uid = match action { RekeyEditAction::RekeyAccessPolicy(access_policy) => { - update_master_keys( - kmip_server, - owner, - &msk_uid, - async |msk, mpk| { - let ap = AccessPolicy::parse(&access_policy)?; - *mpk = cover_crypt.rekey(msk, &ap)?; - update_all_active_usk( - kmip_server, - &cover_crypt, - &msk_uid, - msk, - owner, - &privileged_users, - ) - .await?; - Ok(()) - }, - &privileged_users, - ) + update_master_keys(kmip_server, owner, &msk_uid, async |msk, mpk| { + let ap = AccessPolicy::parse(&access_policy)?; + *mpk = cover_crypt.rekey(msk, &ap)?; + update_all_active_usk(kmip_server, &cover_crypt, &msk_uid, msk, owner).await?; + Ok(()) + }) .await? } RekeyEditAction::PruneAccessPolicy(access_policy) => { - update_master_keys( - kmip_server, - owner, - &msk_uid, - async |msk, _mpk| { - let ap = AccessPolicy::parse(&access_policy)?; - cover_crypt.prune_master_secret_key(msk, &ap)?; - update_all_active_usk( - kmip_server, - &cover_crypt, - &msk_uid, - msk, - owner, - &privileged_users, - ) - .await?; - Ok(()) - }, - &privileged_users, - ) + update_master_keys(kmip_server, owner, &msk_uid, async |msk, _mpk| { + let ap = AccessPolicy::parse(&access_policy)?; + cover_crypt.prune_master_secret_key(msk, &ap)?; + update_all_active_usk(kmip_server, &cover_crypt, &msk_uid, msk, owner).await?; + Ok(()) + }) .await? } RekeyEditAction::DeleteAttribute(attrs) => { - update_master_keys( - kmip_server, - owner, - &msk_uid, - async |msk, mpk| { - attrs - .iter() - .try_for_each(|attr| msk.access_structure.del_attribute(attr))?; - *mpk = cover_crypt.update_msk(msk)?; - update_all_active_usk( - kmip_server, - &cover_crypt, - &msk_uid, - msk, - owner, - &privileged_users, - ) - .await?; - Ok(()) - }, - &privileged_users, - ) + update_master_keys(kmip_server, owner, &msk_uid, async |msk, mpk| { + attrs + .iter() + .try_for_each(|attr| msk.access_structure.del_attribute(attr))?; + *mpk = cover_crypt.update_msk(msk)?; + update_all_active_usk(kmip_server, &cover_crypt, &msk_uid, msk, owner).await?; + Ok(()) + }) .await? } RekeyEditAction::DisableAttribute(attrs) => { - update_master_keys( - kmip_server, - owner, - &msk_uid, - async |msk, mpk| { - attrs - .iter() - .try_for_each(|attr| msk.access_structure.disable_attribute(attr))?; - *mpk = cover_crypt.update_msk(msk)?; - Ok(()) - }, - &privileged_users, - ) + update_master_keys(kmip_server, owner, &msk_uid, async |msk, mpk| { + attrs + .iter() + .try_for_each(|attr| msk.access_structure.disable_attribute(attr))?; + *mpk = cover_crypt.update_msk(msk)?; + Ok(()) + }) .await? } RekeyEditAction::RenameAttribute(pairs_attr_name) => { - update_master_keys( - kmip_server, - owner, - &msk_uid, - async |msk, mpk| { - pairs_attr_name - .iter() - .try_for_each(|(ap_attributes, new_name)| { - msk.access_structure - .rename_attribute(ap_attributes, new_name.clone()) - })?; - *mpk = cover_crypt.update_msk(msk)?; - Ok(()) - }, - &privileged_users, - ) + update_master_keys(kmip_server, owner, &msk_uid, async |msk, mpk| { + pairs_attr_name + .iter() + .try_for_each(|(ap_attributes, new_name)| { + msk.access_structure + .rename_attribute(ap_attributes, new_name.clone()) + })?; + *mpk = cover_crypt.update_msk(msk)?; + Ok(()) + }) .await? } RekeyEditAction::AddAttribute(attrs_properties) => { - update_master_keys( - kmip_server, - owner, - &msk_uid, - async |msk, mpk| { - attrs_properties - .iter() - .try_for_each(|(attr, encryption_hint, _after)| { - msk.access_structure - .add_attribute(attr.clone(), *encryption_hint, None) - })?; - *mpk = cover_crypt.update_msk(msk)?; - Ok(()) - }, - &privileged_users, - ) + update_master_keys(kmip_server, owner, &msk_uid, async |msk, mpk| { + attrs_properties + .iter() + .try_for_each(|(attr, encryption_hint, _after)| { + msk.access_structure + .add_attribute(attr.clone(), *encryption_hint, None) + })?; + *mpk = cover_crypt.update_msk(msk)?; + Ok(()) + }) .await? } }; @@ -189,7 +128,6 @@ pub(super) async fn update_master_keys( owner: &str, msk_uid: &String, mutator: impl AsyncFn(&mut MasterSecretKey, &mut MasterPublicKey) -> KResult<()>, - privileged_users: &Option>, ) -> KResult { let (msk_obj, (mpk_uid, mpk_obj)) = get_master_keys(server, msk_uid, owner).await?; @@ -204,7 +142,6 @@ pub(super) async fn update_master_keys( owner, (msk_uid.clone(), msk_obj), (mpk_uid.clone(), mpk_obj), - privileged_users, ) .await?; @@ -245,7 +182,6 @@ async fn import_rekeyed_master_keys( owner: &str, msk: KmipKeyUidObject, mpk: KmipKeyUidObject, - privileged_users: &Option>, ) -> KResult<()> { let import_request = Import { unique_identifier: UniqueIdentifier::TextString(msk.0), @@ -256,9 +192,7 @@ async fn import_rekeyed_master_keys( object: msk.1, }; - kmip_server - .import(import_request, owner, privileged_users.clone()) - .await?; + kmip_server.import(import_request, owner).await?; let import_request = Import { unique_identifier: UniqueIdentifier::TextString(mpk.0), @@ -269,9 +203,7 @@ async fn import_rekeyed_master_keys( object: mpk.1, }; - kmip_server - .import(import_request, owner, privileged_users.clone()) - .await?; + kmip_server.import(import_request, owner).await?; Ok(()) } @@ -283,14 +215,13 @@ async fn update_all_active_usk( msk_uid: &str, msk: &mut MasterSecretKey, owner: &str, - privileged_users: &Option>, ) -> KResult<()> { let res = locate_usk(kmip_server, msk_uid, None, Some(State::Active), owner).await?; if let Some(uids) = &res { let mut handler = UserDecryptionKeysHandler::instantiate(cover_crypt, msk); for usk_uid in uids { - update_usk(&mut handler, usk_uid, kmip_server, owner, privileged_users).await?; + update_usk(&mut handler, usk_uid, kmip_server, owner).await?; } } @@ -303,7 +234,6 @@ async fn update_usk( usk_uid: &str, kmip_server: &KMS, owner: &str, - privileged_users: &Option>, ) -> KResult<()> { let res = kmip_server.get(Get::from(usk_uid), owner).await?; @@ -321,9 +251,7 @@ async fn update_usk( object: usk_obj, }; - kmip_server - .import(req, owner, privileged_users.clone()) - .await?; + kmip_server.import(req, owner).await?; Ok(()) } diff --git a/crate/server/src/core/kms/kmip.rs b/crate/server/src/core/kms/kmip.rs index c498988799..3e33bfe1ac 100644 --- a/crate/server/src/core/kms/kmip.rs +++ b/crate/server/src/core/kms/kmip.rs @@ -1,8 +1,5 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ - kmip_0::{ - kmip_messages::{RequestMessage, ResponseMessage}, - kmip_operations::{DiscoverVersions, DiscoverVersionsResponse}, - }, + kmip_0::kmip_operations::{DiscoverVersions, DiscoverVersionsResponse}, kmip_2_1::kmip_operations::{ Activate, ActivateResponse, AddAttribute, AddAttributeResponse, Certify, CertifyResponse, Create, CreateKeyPair, CreateKeyPairResponse, CreateResponse, Decrypt, DecryptResponse, @@ -11,10 +8,10 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ GetAttributesResponse, GetResponse, Hash, HashResponse, Import, ImportResponse, Locate, LocateResponse, MAC, MACResponse, MACVerify, MACVerifyResponse, ModifyAttribute, ModifyAttributeResponse, PKCS11, PKCS11Response, Query, QueryResponse, RNGRetrieve, - RNGRetrieveResponse, RNGSeed, RNGSeedResponse, ReKey, ReKeyKeyPair, ReKeyKeyPairResponse, - ReKeyResponse, Register, RegisterResponse, Revoke, RevokeResponse, SetAttribute, - SetAttributeResponse, Sign, SignResponse, SignatureVerify, SignatureVerifyResponse, - Validate, ValidateResponse, + RNGRetrieveResponse, RNGSeed, RNGSeedResponse, ReCertify, ReCertifyResponse, ReKey, + ReKeyKeyPair, ReKeyKeyPairResponse, ReKeyResponse, Register, RegisterResponse, Revoke, + RevokeResponse, SetAttribute, SetAttributeResponse, Sign, SignResponse, SignatureVerify, + SignatureVerifyResponse, Validate, ValidateResponse, }, }; use tracing::Instrument; @@ -79,15 +76,10 @@ impl KMS { /// If the information in the Certificate Request conflicts with the /// attributes specified in the Attributes, then the information in the /// Certificate Request takes precedence. - pub(crate) async fn certify( - &self, - request: Certify, - user: &str, - privileged_users: Option>, - ) -> KResult { + pub(crate) async fn certify(&self, request: Certify, user: &str) -> KResult { let span = tracing::span!(tracing::Level::ERROR, "certify"); - Box::pin(operations::certify(self, request, user, privileged_users)) + Box::pin(operations::certify(self, request, user)) .instrument(span) .await } @@ -100,13 +92,8 @@ impl KMS { /// contains the Unique Identifier of the created object. The server SHALL /// copy the Unique Identifier returned by this operation into the ID /// Placeholder variable. - pub(crate) async fn create( - &self, - request: Create, - user: &str, - privileged_users: Option>, - ) -> KResult { - Box::pin(operations::create(self, request, user, privileged_users)).await + pub(crate) async fn create(&self, request: Create, user: &str) -> KResult { + Box::pin(operations::create(self, request, user)).await } /// This operation requests the server to generate a new public/private key @@ -128,18 +115,12 @@ impl KMS { &self, request: CreateKeyPair, user: &str, - privileged_users: Option>, ) -> KResult { let span = tracing::span!(tracing::Level::ERROR, "create_key_pair"); - Box::pin(operations::create_key_pair( - self, - request, - user, - privileged_users, - )) - .instrument(span) - .await + Box::pin(operations::create_key_pair(self, request, user)) + .instrument(span) + .await } /// This request is used by the client to determine a list of protocol versions @@ -412,16 +393,11 @@ impl KMS { /// for queries on tags. See tagging. /// For instance, a request for a unique identifier `[tag1]` will /// attempt to find a valid single object tagged with `tag1` - pub(crate) async fn import( - &self, - request: Import, - user: &str, - privileged_users: Option>, - ) -> KResult { + pub(crate) async fn import(&self, request: Import, user: &str) -> KResult { let span = tracing::span!(tracing::Level::ERROR, "import"); // Box::pin :: see https://rust-lang.github.io/rust-clippy/master/index.html#large_futures - Box::pin(operations::import(self, request, user, privileged_users)) + Box::pin(operations::import(self, request, user)) .instrument(span) .await } @@ -572,16 +548,20 @@ impl KMS { user: &str, ) -> KResult { let span = tracing::span!(tracing::Level::ERROR, "mac_verify"); - let _enter = span.enter(); - Box::pin(operations::mac_verify(self, request, user)).await + Box::pin(operations::mac_verify(self, request, user)) + .instrument(span) + .await } + #[cfg(test)] pub(crate) async fn message( &self, - request: RequestMessage, + request: cosmian_kms_server_database::reexport::cosmian_kmip::kmip_0::kmip_messages::RequestMessage, user: &str, - ) -> KResult { + ) -> KResult< + cosmian_kms_server_database::reexport::cosmian_kmip::kmip_0::kmip_messages::ResponseMessage, + > { let span = tracing::span!(tracing::Level::ERROR, "message"); // This is a large future, hence pinning @@ -594,11 +574,10 @@ impl KMS { &self, request: Register, user: &str, - privileged_users: Option>, ) -> KResult { let span = tracing::span!(tracing::Level::ERROR, "register"); - Box::pin(operations::register(self, request, user, privileged_users)) + Box::pin(operations::register(self, request, user)) .instrument(span) .await } @@ -632,19 +611,12 @@ impl KMS { &self, request: ReKeyKeyPair, user: &str, - - privileged_users: Option>, ) -> KResult { let span = tracing::span!(tracing::Level::ERROR, "rekey_keypair"); - Box::pin(operations::rekey_keypair( - self, - request, - user, - privileged_users, - )) - .instrument(span) - .await + Box::pin(operations::rekey_keypair(self, request, user)) + .instrument(span) + .await } /// This request is used to generate a replacement key for an existing symmetric key. It is analogous to the Create operation, except that attributes of the replacement key are copied from the existing key, with the exception of the attributes listed in Re-key Attribute Requirements. @@ -656,15 +628,27 @@ impl KMS { /// For the existing key, the server SHALL create a Link attribute of Link Type Replacement Object pointing to the replacement key. For the replacement key, the server SHALL create a Link attribute of Link Type Replaced Key pointing to the existing key. /// /// An Offset MAY be used to indicate the difference between the Initial Date and the Activation Date of the replacement key. If no Offset is specified, the Activation Date, Process Start Date, Protect Stop Date and Deactivation Date values are copied from the existing key. - pub(crate) async fn rekey( + pub(crate) async fn rekey(&self, request: ReKey, user: &str) -> KResult { + let span = tracing::span!(tracing::Level::ERROR, "rekey"); + + Box::pin(operations::rekey(self, request, user)) + .instrument(span) + .await + } + + /// `ReCertify` — certificate rotation with a new UID. + /// + /// Creates a fresh certificate for the same subject/issuer and links old → new + /// via `ReplacementObjectLink`. Keys referencing the old certificate are updated + /// to point to the new one. + pub(crate) async fn recertify( &self, - request: ReKey, + request: ReCertify, user: &str, - privileged_users: Option>, - ) -> KResult { - let span = tracing::span!(tracing::Level::ERROR, "rekey"); + ) -> KResult { + let span = tracing::span!(tracing::Level::ERROR, "recertify"); - Box::pin(operations::rekey(self, request, user, privileged_users)) + Box::pin(operations::recertify(self, request, user)) .instrument(span) .await } diff --git a/crate/server/src/core/kms/other_kms_methods.rs b/crate/server/src/core/kms/other_kms_methods.rs index fcd33da41d..24fd7a2355 100644 --- a/crate/server/src/core/kms/other_kms_methods.rs +++ b/crate/server/src/core/kms/other_kms_methods.rs @@ -237,7 +237,6 @@ impl KMS { &self, create_request: &Create, _owner: &str, - _privileged_users: Option>, ) -> KResult<(Option, Object, HashSet)> { trace!("Internal create private key (FIPS build)"); let attributes = &create_request.attributes; @@ -351,7 +350,6 @@ impl KMS { &self, create_request: &Create, owner: &str, - privileged_users: Option>, ) -> KResult<(Option, Object, HashSet)> { trace!("Internal create private key"); let attributes = &create_request.attributes; @@ -371,7 +369,6 @@ impl KMS { create_request, owner, create_request.attributes.sensitive.unwrap_or(false), - privileged_users, ) .await?; // Update the attributes with state Active @@ -518,4 +515,19 @@ impl KMS { let mut oracles = self.crypto_oracles.write().await; oracles.insert(prefix.to_owned(), oracle); } + + /// Record metrics for a cascading (linked-object) operation. + /// + /// Used by `destroy` and `revoke` when they cascade to related keys. + pub(crate) fn record_cascading_metrics( + &self, + op_name: &str, + op_start: std::time::Instant, + user: &str, + ) { + if let Some(metrics) = &self.metrics { + metrics.record_kmip_operation(op_name, user); + metrics.record_kmip_operation_duration(op_name, op_start.elapsed().as_secs_f64()); + } + } } diff --git a/crate/server/src/core/kms/permissions.rs b/crate/server/src/core/kms/permissions.rs index e4cd79158d..dbc7915876 100644 --- a/crate/server/src/core/kms/permissions.rs +++ b/crate/server/src/core/kms/permissions.rs @@ -4,13 +4,14 @@ use actix_web::{HttpMessage, HttpRequest}; use cosmian_kms_access::access::{ Access, AccessRightsObtainedResponse, ObjectOwnedResponse, UserAccessResponse, }; -use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::{ - KmipOperation, kmip_types::UniqueIdentifier, +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::kmip_2_1::{KmipOperation, kmip_types::UniqueIdentifier}, + cosmian_kms_interfaces::ObjectWithMetadata, }; use cosmian_logger::debug; use crate::{ - core::{KMS, uid_utils::has_prefix}, + core::{KMS, retrieve_object_utils::user_has_permission, uid_utils::has_prefix}, error::KmsError, kms_bail, middlewares::AuthenticatedUser, @@ -21,17 +22,12 @@ impl KMS { /// Grant access to a user (identified by `access.userid`) /// to an object (identified by `access.unique_identifier`) /// which is owned by `owner` (identified by `access.owner`) - pub(crate) async fn grant_access( - &self, - access: &Access, - owner: &str, - privileged_users: Option>, - ) -> KResult<()> { + pub(crate) async fn grant_access(&self, access: &Access, owner: &str) -> KResult<()> { // if create access right is set, grant access to Create for the * object let mut updated_operations_types = access.operation_types.clone(); if updated_operations_types.contains(&KmipOperation::Create) { updated_operations_types.retain(|op| op != &KmipOperation::Create); - if let Some(users) = privileged_users { + if let Some(ref users) = self.params.privileged_users { if !users.contains(&owner.to_owned()) { kms_bail!(KmsError::Unauthorized( "Only privileged users can grant/revoke create access right to a user." @@ -114,18 +110,12 @@ impl KMS { /// Remove an access authorization for a user (identified by `access.userid`) /// to an object (identified by `access.unique_identifier`) /// which is owned by `owner` (identified by `access.owner`) - pub(crate) async fn revoke_access( - &self, - access: &Access, - owner: &str, - - privileged_users: Option>, - ) -> KResult<()> { + pub(crate) async fn revoke_access(&self, access: &Access, owner: &str) -> KResult<()> { // if create access right is set, revoke access Create for * object let mut updated_operations_types = access.operation_types.clone(); if updated_operations_types.contains(&KmipOperation::Create) { updated_operations_types.retain(|op| op != &KmipOperation::Create); - if let Some(users) = privileged_users { + if let Some(ref users) = self.params.privileged_users { if !users.contains(&owner.to_owned()) { kms_bail!(KmsError::Unauthorized( "Only privileged users can grant/revoke create access right to a user." @@ -249,6 +239,64 @@ impl KMS { Ok(ids) } + // ─── Operation-level authorization ───────────────────────────────────── + + /// Enforce that the caller has `Create` access-right. + /// + /// When `privileged_users` is configured, the user must either: + /// - have been explicitly granted the `Create` operation on any object, + /// - be listed in `privileged_users`, or + /// - be the `default_username` (unauthenticated / local access). + /// + /// This check applies uniformly to `Create`, `CreateKeyPair`, `Import`, and `Register`. + pub(crate) async fn enforce_create_permission(&self, user: &str) -> KResult<()> { + if let Some(ref users) = self.params.privileged_users { + if user == self.params.default_username + || users.iter().any(|u| u == user) + || user_has_permission(user, None, &KmipOperation::Create, self).await? + { + return Ok(()); + } + kms_bail!(KmsError::Unauthorized( + "User does not have create access-right.".to_owned() + )) + } + // If no privileged user was set, all users have the `Create` right. + Ok(()) + } + + /// Reject requests that specify `ProtectionStorageMasks`. + /// + /// KMIP defines this field but the server does not implement storage-level + /// masking. Fail early rather than silently ignoring the field. + #[allow(clippy::missing_const_for_fn)] // kms_bail! is not const-compatible + pub(crate) fn reject_protection_storage_masks(has_masks: bool) -> KResult<()> { + if has_masks { + kms_bail!(KmsError::UnsupportedPlaceholder) + } + Ok(()) + } + + /// Check whether `user` is allowed to perform `operation` on this object. + /// + /// Returns `true` if the user is the owner or has been explicitly granted + /// the requested operation. + pub(crate) async fn user_can_perform_operation( + &self, + owm: &ObjectWithMetadata, + user: &str, + operation: &KmipOperation, + ) -> KResult { + if user == owm.owner() { + return Ok(true); + } + let permissions = self + .database + .list_user_operations_on_object(owm.id(), user, false) + .await?; + Ok(permissions.contains(operation)) + } + /// Get the user from the request depending on the authentication method. pub(crate) fn get_user(&self, req_http: &HttpRequest) -> String { if self.params.force_default_username { diff --git a/crate/server/src/core/operations/attributes/add.rs b/crate/server/src/core/operations/attributes/add.rs index 07ba9122ee..6aca175436 100644 --- a/crate/server/src/core/operations/attributes/add.rs +++ b/crate/server/src/core/operations/attributes/add.rs @@ -15,7 +15,7 @@ use cosmian_kms_server_database::reexport::{ use cosmian_logger::{debug, trace}; use crate::{ - core::{KMS, retrieve_object_utils::retrieve_object_for_operation}, + core::{KMS, retrieve_object_utils::retrieve_object_for_operation, uid_utils::has_prefix}, error::KmsError, result::{KResult, KResultHelper}, }; @@ -33,6 +33,27 @@ pub(crate) async fn add_attribute( .as_str() .context("Add Attribute: the unique identifier must be a string")?; + // Read-only guard — these attributes are server-managed. + match &request.new_attribute { + Attribute::RotateAutomatic(_) + | Attribute::RotateGeneration(_) + | Attribute::RotateDate(_) + | Attribute::RotateLatest(_) => { + return Err(KmsError::Kmip21Error( + ErrorReason::Attribute_Read_Only, + "DENIED: this attribute is server-managed and cannot be added by the user" + .to_owned(), + )); + } + Attribute::RotateName(name) if name.contains('@') => { + return Err(KmsError::InvalidRequest( + "AddAttribute: rotate_name must not contain '@' (reserved for keyset versioning)" + .to_owned(), + )); + } + _ => {} + } + let mut owm: ObjectWithMetadata = Box::pin(retrieve_object_for_operation( uid_or_tags, KmipOperation::AddAttribute, @@ -42,8 +63,26 @@ pub(crate) async fn add_attribute( .await?; trace!("Retrieved object for: {}", owm.object()); + // For SQL keys (non-HSM): rotate_name must equal the key's UID. + // This enforces the gen-0 UID = keyset name invariant for deterministic @N addressing. + if has_prefix(owm.id()).is_none() { + if let Attribute::RotateName(name) = &request.new_attribute { + let key_uid = owm.id(); + if name.as_str() != key_uid { + return Err(KmsError::InvalidRequest(format!( + "AddAttribute: rotate_name ('{name}') must equal the key's UID \ + ('{key_uid}') — create the key with the keyset name as its ID" + ))); + } + } + } + let mut attributes = owm.attributes_mut().clone(); + // Capture before the macro runs (which may partially move request.new_attribute). + let is_adding_rotate_name_on_sql = matches!(&request.new_attribute, Attribute::RotateName(_)) + && has_prefix(owm.id()).is_none(); + // Check if the attribute is allowed to be set match_add_attribute! { request.new_attribute, attributes, @@ -98,6 +137,7 @@ pub(crate) async fn add_attribute( QuantumSafe => quantum_safe, RandomNumberGenerator => random_number_generator, RevocationReason => revocation_reason, + RotateAutomatic => rotate_automatic, RotateDate => rotate_date, RotateGeneration => rotate_generation, RotateInterval => rotate_interval, @@ -182,6 +222,16 @@ pub(crate) async fn add_attribute( } } + // Initialise keyset metadata when rotate_name is first added to an SQL key. + if is_adding_rotate_name_on_sql { + if attributes.rotate_generation.is_none() { + attributes.rotate_generation = Some(0); + } + if attributes.rotate_latest.is_none() { + attributes.rotate_latest = Some(true); + } + } + // update the last change date attributes.last_change_date = Some(time_normalize()?); diff --git a/crate/server/src/core/operations/attributes/delete.rs b/crate/server/src/core/operations/attributes/delete.rs index ddfd6aac8a..0c10a44357 100644 --- a/crate/server/src/core/operations/attributes/delete.rs +++ b/crate/server/src/core/operations/attributes/delete.rs @@ -1,9 +1,12 @@ -use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::{ - KmipOperation, - kmip_attributes::Attribute, - kmip_objects::{Object, PrivateKey, PublicKey, SecretData, SymmetricKey}, - kmip_operations::{DeleteAttribute, DeleteAttributeResponse}, - kmip_types::{AttributeReference, Tag, UniqueIdentifier}, +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::ErrorReason, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attribute, + kmip_objects::{Object, PrivateKey, PublicKey, SecretData, SymmetricKey}, + kmip_operations::{DeleteAttribute, DeleteAttributeResponse}, + kmip_types::{AttributeReference, Tag, UniqueIdentifier}, + }, }; use cosmian_logger::trace; @@ -40,6 +43,20 @@ pub(crate) async fn delete_attribute( let mut attributes = owm.attributes().to_owned(); if let Some(attribute) = request.current_attribute { + // Read-only guard — these attributes are server-managed. + match &attribute { + Attribute::RotateAutomatic(_) + | Attribute::RotateGeneration(_) + | Attribute::RotateDate(_) + | Attribute::RotateLatest(_) => { + return Err(KmsError::Kmip21Error( + ErrorReason::Attribute_Read_Only, + "DENIED: this attribute is server-managed and cannot be deleted by the user" + .to_owned(), + )); + } + _ => {} + } match_delete_attribute! { attribute, attributes, simple { @@ -90,6 +107,7 @@ pub(crate) async fn delete_attribute( QuantumSafe => quantum_safe, RandomNumberGenerator => random_number_generator, RevocationReason => revocation_reason, + RotateAutomatic => rotate_automatic, RotateDate => rotate_date, RotateGeneration => rotate_generation, RotateInterval => rotate_interval, diff --git a/crate/server/src/core/operations/attributes/get.rs b/crate/server/src/core/operations/attributes/get.rs index 3003ddc29f..734b92287d 100644 --- a/crate/server/src/core/operations/attributes/get.rs +++ b/crate/server/src/core/operations/attributes/get.rs @@ -510,9 +510,11 @@ pub(crate) async fn get_attributes( res.state = attributes.state; } Tag::UniqueIdentifier => { - attributes - .unique_identifier - .clone_into(&mut res.unique_identifier); + // Always authoritative: use the stored object UID from the database row + // rather than the embedded attribute value. The embedded value may be a + // stale random UUID for re-keyed objects created before the `finalize` + // fix stamped the correct new UID into the key material attributes. + res.unique_identifier = Some(UniqueIdentifier::TextString(owm.id().to_owned())); } Tag::ShortUniqueIdentifier => { // Ensure presence: if absent, return an empty string @@ -558,6 +560,18 @@ pub(crate) async fn get_attributes( owm.id(), res.get_tags(kms.vendor_id()) ); + + // Rotation attributes are not represented by Tag enum variants, so they are + // not included by the tag-based filtering loop above. Always propagate them + // from the source attributes when present. + res.rotate_automatic = attributes.rotate_automatic; + res.rotate_date = attributes.rotate_date; + res.rotate_generation = attributes.rotate_generation; + res.rotate_interval = attributes.rotate_interval; + res.rotate_latest = attributes.rotate_latest; + res.rotate_name.clone_from(&attributes.rotate_name); + res.rotate_offset = attributes.rotate_offset; + trace!("Get Attributes: Response: {}", res); Ok(GetAttributesResponse { unique_identifier: UniqueIdentifier::TextString(owm.id().to_owned()), diff --git a/crate/server/src/core/operations/attributes/modify.rs b/crate/server/src/core/operations/attributes/modify.rs index 8a09e1a37f..2928cc7ac1 100644 --- a/crate/server/src/core/operations/attributes/modify.rs +++ b/crate/server/src/core/operations/attributes/modify.rs @@ -49,10 +49,21 @@ pub(crate) async fn modify_attribute( // Read-only guard — must be checked before the DB round-trip. match &request.new_attribute { - Attribute::State(_) | Attribute::CertificateLength(_) => { + Attribute::State(_) + | Attribute::CertificateLength(_) + | Attribute::RotateGeneration(_) + | Attribute::RotateDate(_) + | Attribute::RotateLatest(_) => { return Err(KmsError::Kmip21Error( ErrorReason::Attribute_Read_Only, - "DENIED".to_owned(), + "DENIED: this attribute is server-managed and cannot be modified by the user" + .to_owned(), + )); + } + Attribute::RotateName(name) if name.contains('@') => { + return Err(KmsError::InvalidRequest( + "ModifyAttribute: rotate_name must not contain '@' (reserved for keyset versioning)" + .to_owned(), )); } _ => {} @@ -139,6 +150,7 @@ pub(crate) async fn modify_attribute( QuantumSafe => quantum_safe, RandomNumberGenerator => random_number_generator, RevocationReason => revocation_reason, + RotateAutomatic => rotate_automatic, RotateDate => rotate_date, RotateGeneration => rotate_generation, RotateInterval => rotate_interval, diff --git a/crate/server/src/core/operations/attributes/set.rs b/crate/server/src/core/operations/attributes/set.rs index 3384e2eaaf..2d096d7c5d 100644 --- a/crate/server/src/core/operations/attributes/set.rs +++ b/crate/server/src/core/operations/attributes/set.rs @@ -1,21 +1,56 @@ use cosmian_kms_server_database::reexport::{ - cosmian_kmip::kmip_2_1::{ - KmipOperation, - kmip_attributes::Attribute, - kmip_objects::ObjectType, - kmip_operations::{SetAttribute, SetAttributeResponse}, - kmip_types::UniqueIdentifier, + cosmian_kmip::{ + kmip_0::kmip_types::ErrorReason, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attribute, + kmip_objects::ObjectType, + kmip_operations::{SetAttribute, SetAttributeResponse}, + kmip_types::UniqueIdentifier, + }, }, - cosmian_kms_interfaces::ObjectWithMetadata, + cosmian_kms_interfaces::{ObjectWithMetadata, SECS_PER_DAY}, }; use cosmian_logger::{debug, trace}; +use time::OffsetDateTime; + +/// `SECS_PER_DAY - 1`, used for ceiling integer division of seconds into whole days. +const SECS_PER_DAY_MINUS_ONE: i64 = SECS_PER_DAY - 1; use crate::{ - core::{KMS, retrieve_object_utils::retrieve_object_for_operation}, + core::{KMS, retrieve_object_utils::retrieve_object_for_operation, uid_utils::has_prefix}, error::KmsError, result::{KResult, KResultHelper}, }; +/// Extract the PKCS#11 `key_id` from an HSM UID of the form +/// `hsm::::::`. +/// +/// Returns `None` if the UID cannot be parsed. +fn extract_hsm_key_id(uid: &str) -> Option<&str> { + let prefix = has_prefix(uid)?; + // Strip "hsm::::" to get "::" + let rest = uid.strip_prefix(&format!("{prefix}::"))?; + // Skip the slot_id segment + rest.split_once("::").map(|(_, key_id)| key_id) +} + +/// Compute the full base UID for an HSM key: `hsm::::::`. +/// +/// This is the canonical `rotate_name` for HSM-resident keys — it embeds the slot ID +/// and is therefore unique across HSM slots. +fn hsm_base_uid(uid: &str) -> Option { + let prefix = has_prefix(uid)?; + let rest = uid.strip_prefix(&format!("{prefix}::"))?; + let (slot_str, key_id) = rest.split_once("::")?; + // Strip any @N generation suffix to get the stable base key_id. + let base_key_id = key_id + .rsplit_once('@') + .and_then(|(base, suffix)| suffix.parse::().ok().map(|_| base)) + .unwrap_or(key_id); + Some(format!("{prefix}::{slot_str}::{base_key_id}")) +} + pub(crate) async fn set_attribute( kms: &KMS, request: SetAttribute, @@ -31,6 +66,26 @@ pub(crate) async fn set_attribute( .as_str() .context("Set Attribute: the unique identifier must be a string")?; + // Read-only guard — must be checked before the DB round-trip. + match &request.new_attribute { + Attribute::State(_) + | Attribute::RotateGeneration(_) + | Attribute::RotateDate(_) + | Attribute::RotateLatest(_) => { + return Err(KmsError::Kmip21Error( + ErrorReason::Attribute_Read_Only, + "DENIED: this attribute is server-managed and cannot be set by the user".to_owned(), + )); + } + Attribute::RotateName(name) if name.contains('@') => { + return Err(KmsError::InvalidRequest( + "SetAttribute: rotate_name must not contain '@' (reserved for keyset versioning)" + .to_owned(), + )); + } + _ => {} + } + let mut owm: ObjectWithMetadata = Box::pin(retrieve_object_for_operation( uid_or_tags, KmipOperation::SetAttribute, @@ -40,8 +95,64 @@ pub(crate) async fn set_attribute( .await?; trace!("Set Attribute: Retrieved target object"); + // For SQL keys (non-HSM): rotate_name must equal the key's UID. + // This enforces the gen-0 UID = keyset name invariant for deterministic @N addressing. + if has_prefix(owm.id()).is_none() { + if let Attribute::RotateName(name) = &request.new_attribute { + let key_uid = owm.id(); + if name.as_str() != key_uid { + return Err(KmsError::InvalidRequest(format!( + "SetAttribute: rotate_name ('{name}') must equal the key's UID \ + ('{key_uid}') — create the key with the keyset name as its ID" + ))); + } + } + } + + // For HSM keys: rotate_name must be the key's full base UID (hsm::slot::key_id without + // any @N suffix). The slot ID in the UID guarantees uniqueness across HSM slots, + // preventing keyset name collisions when multiple slots host keys with the same name. + if has_prefix(owm.id()).is_some() { + if let Attribute::RotateName(name) = &request.new_attribute { + let expected = hsm_base_uid(owm.id()).ok_or_else(|| { + KmsError::InvalidRequest(format!( + "SetAttribute: cannot derive base UID from HSM key '{}'", + owm.id() + )) + })?; + if name.as_str() != expected { + return Err(KmsError::InvalidRequest(format!( + "SetAttribute: for HSM keys, rotate_name must be the key's base UID \ + ('{expected}'), not '{name}'" + ))); + } + } + } + + // Capture HSM-rotation values before the `match_set_attribute!` macro may + // partially move `request.new_attribute`. We do this here — after object + // retrieval — so we can inspect `owm.id()` to confirm it is an HSM key. + let (hsm_rotate_name, hsm_rotate_interval_secs) = if has_prefix(owm.id()).is_some() { + match &request.new_attribute { + Attribute::RotateOffset(_) => { + return Err(KmsError::NotSupported( + "SetAttribute: rotate_offset is not supported for HSM keys".to_owned(), + )); + } + Attribute::RotateName(n) => (Some(n.clone()), None::), + Attribute::RotateInterval(n) => (None::, Some(*n)), + _ => (None, None), + } + } else { + (None, None) + }; + let mut attributes = owm.attributes_mut().clone(); + // Capture before the macro runs (which may partially move request.new_attribute). + let is_setting_rotate_name_on_sql = matches!(&request.new_attribute, Attribute::RotateName(_)) + && has_prefix(owm.id()).is_none(); + // Check if the attribute is allowed to be set match_set_attribute! { "SetAttribute", request.new_attribute, attributes, @@ -97,6 +208,7 @@ pub(crate) async fn set_attribute( QuantumSafe => quantum_safe, RandomNumberGenerator => random_number_generator, RevocationReason => revocation_reason, + RotateAutomatic => rotate_automatic, RotateDate => rotate_date, RotateGeneration => rotate_generation, RotateInterval => rotate_interval, @@ -140,6 +252,80 @@ pub(crate) async fn set_attribute( let tags = kms.database.retrieve_tags(owm.id()).await?; + // For SQL keys: initialise keyset metadata when rotate_name is first set. + // rotate_generation and rotate_latest are server-managed (read-only guard above), + // so they can only be initialised here, not by the user directly. + if is_setting_rotate_name_on_sql { + if attributes.rotate_generation.is_none() { + attributes.rotate_generation = Some(0); + } + if attributes.rotate_latest.is_none() { + attributes.rotate_latest = Some(true); + } + } + // `HsmStore::update_object` is a no-op for attributes (the HSM has no + // generic KV attribute storage), so we must explicitly write CKA_LABEL and + // CKA_START_DATE / CKA_END_DATE when the caller sets rotation metadata. + if let Some(rotate_name) = hsm_rotate_name { + // Register key in a keyset by writing CKA_LABEL at generation 0. + let key_id = extract_hsm_key_id(owm.id()).ok_or_else(|| { + KmsError::InvalidRequest(format!( + "SetAttribute: cannot parse key_id from HSM UID '{}'", + owm.id() + )) + })?; + let label = format!("{rotate_name}::0::{key_id}@latest"); + trace!( + "SetAttribute: writing CKA_LABEL '{}' on HSM key '{}'", + label, + owm.id() + ); + kms.database.set_key_label(owm.id(), &label).await?; + } else if let Some(interval_secs) = hsm_rotate_interval_secs { + // CKA_START_DATE / CKA_END_DATE are PKCS#11 CK_DATE fields (year/month/day only — + // no sub-day precision). These dates ARE the scheduling signal used by + // HsmStore::find_due_for_rotation to determine when to auto-rotate the key; + // HsmStore::update_object is a no-op for KMIP attributes, so there is no other + // persistent store for rotate_interval on HSM keys. + if interval_secs == 0 { + // RotateInterval = 0 disables auto-rotation: clear the PKCS#11 dates so + // HsmStore::find_due_for_rotation no longer considers this key overdue. + trace!( + "SetAttribute: clearing CKA_START_DATE / CKA_END_DATE on HSM key '{}' (rotation disabled)", + owm.id() + ); + kms.database + .set_key_rotation_dates(owm.id(), None, None) + .await?; + } else if interval_secs < SECS_PER_DAY { + // PKCS#11 CK_DATE only stores year/month/day. A sub-day interval would map + // to end_date = today (0 whole days), causing the key to be immediately due + // for rotation on every scheduler tick. Reject it explicitly so callers get + // a clear error instead of unexpected behaviour. + return Err(KmsError::InvalidRequest(format!( + "SetAttribute: RotateInterval for HSM key '{}' must be at least 86400 seconds \ + (1 day) because PKCS#11 CK_DATE has day granularity only. Got {interval_secs} s.", + owm.id() + ))); + } else { + let today = OffsetDateTime::now_utc().date(); + // Ceiling-divide so that an interval that is not an exact multiple of + // SECS_PER_DAY (86 400) does not map to end_date = today (which would + // trigger immediate rotation). + let days = (interval_secs + SECS_PER_DAY_MINUS_ONE) / SECS_PER_DAY; + let end_date = today + time::Duration::days(days); + trace!( + "SetAttribute: writing CKA_START_DATE={} CKA_END_DATE={} on HSM key '{}'", + today, + end_date, + owm.id() + ); + kms.database + .set_key_rotation_dates(owm.id(), Some(today), Some(end_date)) + .await?; + } + } + match owm.object().object_type() { ObjectType::PublicKey | ObjectType::PrivateKey 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..af30d909d5 --- /dev/null +++ b/crate/server/src/core/operations/auto_rotate.rs @@ -0,0 +1,44 @@ +//! Auto-rotation scheduler. +//! +//! This module provides: +//! - [`run_auto_rotation`] — iterates keys due for rotation and triggers re-key operations. +//! - [`dispatch_renewal_warnings`] — sends notifications when keys approach their rotation date. + +use cosmian_logger::debug; + +use crate::core::KMS; + +/// Rotate all keys that are past their scheduled rotation time. +/// +/// The function queries the database for active keys whose `rotate_interval` +/// has elapsed since their last rotation (or initial date + offset for first rotation), +/// then issues a Re-Key or Re-Key Key Pair operation for each. +pub(crate) async fn run_auto_rotation(kms: &KMS) { + let now = time::OffsetDateTime::now_utc(); + let due_keys = match kms.database.find_due_for_rotation(now).await { + Ok(keys) => keys, + Err(e) => { + debug!("[auto-rotate] Failed to query keys due for rotation: {e}"); + return; + } + }; + + if due_keys.is_empty() { + return; + } + debug!( + "[auto-rotate] Found {} key(s) due for rotation", + due_keys.len() + ); + + for (uid, owner) in &due_keys { + debug!("[auto-rotate] Rotating key {uid} (owner={owner})"); + // TODO: issue Re-Key / Re-Key Key Pair operation for the key + } +} + +/// Check keys approaching rotation and emit renewal-warning notifications. +pub(crate) async fn dispatch_renewal_warnings(_kms: &KMS) { + // TODO: implement renewal-warning notification dispatch + debug!("[auto-rotate] Renewal-warning dispatch complete (no-op stub)"); +} diff --git a/crate/server/src/core/operations/certify/build_certificate.rs b/crate/server/src/core/operations/certify/build_certificate.rs index 7188a51191..85bba03128 100644 --- a/crate/server/src/core/operations/certify/build_certificate.rs +++ b/crate/server/src/core/operations/certify/build_certificate.rs @@ -37,7 +37,7 @@ use crate::{ const X509_VERSION3: i32 = 2; -pub(super) fn build_and_sign_certificate( +pub(crate) fn build_and_sign_certificate( vendor_id: &str, issuer: &Issuer, subject: &Subject, diff --git a/crate/server/src/core/operations/certify/certify_op.rs b/crate/server/src/core/operations/certify/certify_op.rs index 7e2ec88f2d..ff0a8d70cb 100644 --- a/crate/server/src/core/operations/certify/certify_op.rs +++ b/crate/server/src/core/operations/certify/certify_op.rs @@ -19,12 +19,7 @@ use crate::{core::KMS, error::KmsError, kms_bail, result::KResult}; /// Certify a certificate. /// This operation is used to issue a certificate based on a public key, a CSR or a key pair. /// The certificate can be self-signed or signed by another certificate. -pub(crate) async fn certify( - kms: &KMS, - request: Certify, - user: &str, - privileged_users: Option>, -) -> KResult { +pub(crate) async fn certify(kms: &KMS, request: Certify, user: &str) -> KResult { trace!("{}", serde_json::to_string(&request)?); if request.protection_storage_masks.is_some() { kms_bail!(KmsError::UnsupportedPlaceholder) @@ -34,7 +29,7 @@ pub(crate) async fn certify( // generate_x509(get_issuer(get_subject))) // The code below could be rewritten in a more functional way // but this would require manipulating some sort of Monad Transformer - let subject = Box::pin(get_subject(kms, &request, user, privileged_users)).await?; + let subject = Box::pin(get_subject(kms, &request, user)).await?; trace!("Subject name: {:?}", subject.subject_name()); let issuer = Box::pin(get_issuer(&subject, kms, &request, user)).await?; trace!("Issuer Subject name: {:?}", issuer.subject_name()); diff --git a/crate/server/src/core/operations/certify/issuer.rs b/crate/server/src/core/operations/certify/issuer.rs index 8cc57e24a1..c998c316cb 100644 --- a/crate/server/src/core/operations/certify/issuer.rs +++ b/crate/server/src/core/operations/certify/issuer.rs @@ -8,7 +8,7 @@ use openssl::{ /// A certificate Issuer is constructed from a unique identifier and /// - either a private key and a certificate. /// - or a private key, a subject name and a certificate. -pub(super) enum Issuer<'a> { +pub(crate) enum Issuer<'a> { PrivateKeyAndCertificate( UniqueIdentifier, /// Private key diff --git a/crate/server/src/core/operations/certify/mod.rs b/crate/server/src/core/operations/certify/mod.rs index e3418aab3a..c27edc4c62 100644 --- a/crate/server/src/core/operations/certify/mod.rs +++ b/crate/server/src/core/operations/certify/mod.rs @@ -17,7 +17,10 @@ mod tests; // Re-export the public API of this module. // Re-export helpers used by sibling RFC submodules via `super::`. +pub(crate) use build_certificate::build_and_sign_certificate; use build_certificate::extension_config_is_ca; #[cfg(feature = "non-fips")] use build_certificate::pqc_signing_key_usage; pub(crate) use certify_op::certify; +pub(crate) use resolve_issuer::get_issuer; +pub(crate) use resolve_subject::get_subject; diff --git a/crate/server/src/core/operations/certify/resolve_issuer.rs b/crate/server/src/core/operations/certify/resolve_issuer.rs index e54452fa27..9560d86423 100644 --- a/crate/server/src/core/operations/certify/resolve_issuer.rs +++ b/crate/server/src/core/operations/certify/resolve_issuer.rs @@ -27,7 +27,7 @@ use crate::{ /// Determine the issuer of the issued certificate. /// The issuer can be recovered from different sources or be self-signed. -pub(super) async fn get_issuer<'a>( +pub(crate) async fn get_issuer<'a>( subject: &'a Subject, kms: &KMS, request: &Certify, diff --git a/crate/server/src/core/operations/certify/resolve_subject.rs b/crate/server/src/core/operations/certify/resolve_subject.rs index 011f0e2ad5..b0d14b4421 100644 --- a/crate/server/src/core/operations/certify/resolve_subject.rs +++ b/crate/server/src/core/operations/certify/resolve_subject.rs @@ -15,11 +15,10 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ }, }; use cosmian_kms_server_database::reexport::{ - cosmian_kmip, cosmian_kmip::kmip_2_1::{ KmipOperation, kmip_objects::ObjectType, - kmip_operations::CreateKeyPair, + kmip_operations::{Certify, CreateKeyPair}, kmip_types::{CertificateRequestType, UniqueIdentifier}, }, cosmian_kms_crypto::openssl::{ @@ -32,9 +31,8 @@ use openssl::x509::X509Req; use super::subject::{KeyPairData, Subject}; use crate::{ core::{ - KMS, - operations::create_key_pair::generate_key_pair, - retrieve_object_utils::{retrieve_object_for_operation, user_has_permission}, + KMS, operations::create_key_pair::generate_key_pair, + retrieve_object_utils::retrieve_object_for_operation, }, error::KmsError, kms_bail, @@ -81,12 +79,7 @@ fn cryptographic_usage_mask_public_key( /// - a certificate /// - a key pair and a subject name /// - a CSR -pub(super) async fn get_subject( - kms: &KMS, - request: &cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_operations::Certify, - user: &str, - privileged_users: Option>, -) -> KResult { +pub(crate) async fn get_subject(kms: &KMS, request: &Certify, user: &str) -> KResult { // Did the user provide a CSR? if let Some(pkcs10_bytes) = request.certificate_request_value.as_ref() { let x509_req = match &request @@ -180,21 +173,7 @@ pub(super) async fn get_subject( // For creation of an object, check that user has create access-right // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users { - let has_permission = user_has_permission( - user, - None, - &cosmian_kmip::kmip_2_1::KmipOperation::Create, - kms, - ) - .await?; - - if !has_permission && !users.iter().any(|u| u == user) { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right.".to_owned() - )) - } - } + kms.enforce_create_permission(user).await?; let sk_uid = UniqueIdentifier::default(); let pk_uid = UniqueIdentifier::default(); diff --git a/crate/server/src/core/operations/certify/subject.rs b/crate/server/src/core/operations/certify/subject.rs index ff1b382ee7..43f1ebb9a3 100644 --- a/crate/server/src/core/operations/certify/subject.rs +++ b/crate/server/src/core/operations/certify/subject.rs @@ -18,7 +18,7 @@ use openssl::{ use crate::{kms_error, result::KResult}; /// This holds `KeyPair` information when one is created for the subject -pub(super) struct KeyPairData { +pub(crate) struct KeyPairData { pub(crate) private_key_id: UniqueIdentifier, pub(crate) private_key_object: Object, pub(crate) private_key_tags: HashSet, @@ -45,7 +45,7 @@ impl Display for KeyPairData { /// The party that gets signed by the issuer and gets the certificate #[expect(clippy::large_enum_variant)] -pub(super) enum Subject { +pub(crate) enum Subject { X509Req( /// Unique identifier of the certificate to create UniqueIdentifier, diff --git a/crate/server/src/core/operations/create.rs b/crate/server/src/core/operations/create.rs index bf30cb7189..34db3147b0 100644 --- a/crate/server/src/core/operations/create.rs +++ b/crate/server/src/core/operations/create.rs @@ -1,6 +1,6 @@ -use cosmian_kms_server_database::reexport::{ - cosmian_kmip, - cosmian_kmip::kmip_2_1::{ +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::ErrorReason, + kmip_2_1::{ kmip_objects::ObjectType, kmip_operations::{Create, CreateResponse}, kmip_types::UniqueIdentifier, @@ -9,48 +9,22 @@ use cosmian_kms_server_database::reexport::{ use cosmian_logger::{info, trace}; use uuid::Uuid; +use super::key_ops::ObjectLifecycleExt; use crate::{ - core::{KMS, retrieve_object_utils::user_has_permission, wrapping::wrap_and_cache}, + core::{KMS, uid_utils::has_prefix, wrapping::wrap_and_cache}, error::KmsError, kms_bail, result::KResult, }; -pub(crate) async fn create( - kms: &KMS, - request: Create, - owner: &str, - privileged_users: Option>, -) -> KResult { +pub(crate) async fn create(kms: &KMS, request: Create, owner: &str) -> KResult { trace!("{request}"); - if request.protection_storage_masks.is_some() { - kms_bail!(KmsError::UnsupportedPlaceholder) - } - - // To create an object, check that the user has `Create` access right - // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users.clone() { - let has_permission = user_has_permission( - owner, - None, - &cosmian_kmip::kmip_2_1::KmipOperation::Create, - kms, - ) - .await?; - - if !has_permission && !users.iter().any(|u| u == owner) { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right.".to_owned() - )) - } - } + KMS::reject_protection_storage_masks(request.protection_storage_masks.is_some())?; + kms.enforce_create_permission(owner).await?; let (unique_identifier, mut object, tags) = match &request.object_type { ObjectType::SymmetricKey => KMS::create_symmetric_key_and_tags(kms.vendor_id(), &request)?, - ObjectType::PrivateKey => { - kms.create_private_key_and_tags(&request, owner, privileged_users) - .await? - } + ObjectType::PrivateKey => kms.create_private_key_and_tags(&request, owner).await?, ObjectType::SecretData => KMS::create_secret_data_and_tags(kms.vendor_id(), &request)?, _ => { kms_bail!(KmsError::NotSupported(format!( @@ -74,7 +48,7 @@ pub(crate) async fn create( let protection_period_present = attrs.protection_period.is_some(); if qs && (protection_level_present || protection_period_present) { kms_bail!(KmsError::Kmip21Error( - cosmian_kmip::kmip_0::kmip_types::ErrorReason::General_Failure, + ErrorReason::General_Failure, "NOT_SAFE".to_owned(), )); } @@ -86,11 +60,29 @@ pub(crate) async fn create( ); // Set lifecycle attributes and copy them before the key gets wrapped - let attributes = super::key_ops::setup_object_lifecycle( - &mut object, - request.object_type, - request.attributes.activation_date, - )?; + let attributes = + object.setup_with_lifecycle(request.object_type, request.attributes.activation_date)?; + let mut attributes = attributes; + + // Keyset validation (SQL keys only): if rotate_name is present, the UID must equal it. + // HSM keys (those with a prefix such as "hsm::") manage keyset membership differently — + // the UID is an opaque PKCS#11 handle; rotate_name is set independently via SetAttribute. + if let Some(rotate_name) = &request.attributes.rotate_name { + let uid_str = unique_identifier.as_str().ok_or_else(|| { + KmsError::InvalidRequest("Create: unique_identifier must be a TextString".to_owned()) + })?; + if has_prefix(uid_str).is_none() && rotate_name.as_str() != uid_str { + return Err(KmsError::InvalidRequest(format!( + "Create: rotate_name ('{rotate_name}') must equal the key's unique_identifier \ + ('{uid_str}') — set the key ID to the keyset name at creation time" + ))); + } + // Initialise keyset metadata for SQL keys: generation 0, the current (only) member. + if has_prefix(uid_str).is_none() { + attributes.rotate_generation = Some(0); + attributes.rotate_latest = Some(true); + } + } trace!( "Creating object of type {:?} with UID {} and attributes {}", @@ -100,6 +92,9 @@ pub(crate) async fn create( ); // Wrap the object if requested by the user or on the server params Box::pin(wrap_and_cache(kms, owner, &unique_identifier, &mut object)).await?; + // If the object was wrapped, record the WrappingKeyLink in the stored attributes + // so KMIP GetAttributes returns it correctly (KMIP 2.1 §4.31 Link). + object.copy_wrapping_key_link_to(&mut attributes); // create the object in the database let uid = kms diff --git a/crate/server/src/core/operations/create_key_pair.rs b/crate/server/src/core/operations/create_key_pair.rs index 3d84a2a66f..abb6b4e5ec 100644 --- a/crate/server/src/core/operations/create_key_pair.rs +++ b/crate/server/src/core/operations/create_key_pair.rs @@ -1,4 +1,3 @@ -use cosmian_kms_server_database::reexport::cosmian_kmip; #[cfg(feature = "non-fips")] use cosmian_kms_server_database::reexport::cosmian_kms_crypto::crypto::kem::kem_keygen; #[cfg(feature = "non-fips")] @@ -33,45 +32,26 @@ use cosmian_logger::warn; use cosmian_logger::{debug, info, trace}; use uuid::Uuid; use crate::{ - core::{KMS, retrieve_object_utils::user_has_permission, wrapping::wrap_and_cache}, + core::{KMS, uid_utils::has_prefix, wrapping::wrap_and_cache}, error::KmsError, kms_bail, result::KResult, }; +use super::key_ops::ObjectLifecycleExt; pub(crate) async fn create_key_pair( kms: &KMS, request: CreateKeyPair, owner: &str, - - privileged_users: Option>, ) -> KResult { debug!("Create key pair: {request}"); - // To create a key pair, check that the user has `Create` access right - // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users { - let has_permission = user_has_permission( - owner, - None, - &cosmian_kmip::kmip_2_1::KmipOperation::Create, - kms, - ) - .await?; - - if !has_permission && !users.iter().any(|u| u == owner) { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right.".to_owned() - )) - } - } - - if request.common_protection_storage_masks.is_some() - || request.private_protection_storage_masks.is_some() - || request.public_protection_storage_masks.is_some() - { - kms_bail!(KmsError::UnsupportedPlaceholder) - } + KMS::reject_protection_storage_masks( + request.common_protection_storage_masks.is_some() + || request.private_protection_storage_masks.is_some() + || request.public_protection_storage_masks.is_some(), + )?; + kms.enforce_create_permission(owner).await?; // generate uids and create the key pair and tags let sk_uid = request @@ -83,6 +63,23 @@ pub(crate) async fn create_key_pair( std::string::ToString::to_string, ); let pk_uid = sk_uid.clone() + SYSTEM_TAG_PUBLIC_KEY; + + // Extract rotate_name before `request` is consumed by `generate_key_pair`. + // Keyset validation (SQL keys only): if rotate_name is set, it must equal the private key's UID. + // HSM key pairs have opaque PKCS#11-prefixed UIDs — the UID-match constraint does not apply. + let sk_rotate_name = request + .private_key_attributes + .as_ref() + .or(request.common_attributes.as_ref()) + .and_then(|attrs| attrs.rotate_name.clone()); + if let Some(ref rotate_name) = sk_rotate_name { + if has_prefix(&sk_uid).is_none() && rotate_name.as_str() != sk_uid { + return Err(KmsError::InvalidRequest(format!( + "CreateKeyPair: rotate_name ('{rotate_name}') must equal the private key's UID \ + ('{sk_uid}') — set the private key ID to the keyset name at creation time" + ))); + } + } // Capture requested ActivationDate values BEFORE moving the request into key generation // Private key: prefer private_key_attributes.activation_date then fallback to common_attributes.activation_date let requested_sk_activation_date = request @@ -112,11 +109,14 @@ pub(crate) async fn create_key_pair( trace!("sk_uid: {sk_uid}, pk_uid: {pk_uid}"); let mut private_key = key_pair.private_key().to_owned(); - let private_key_attributes = super::key_ops::setup_object_lifecycle( - &mut private_key, - ObjectType::PrivateKey, - requested_sk_activation_date, - )?; + let private_key_attributes = + private_key.setup_with_lifecycle(ObjectType::PrivateKey, requested_sk_activation_date)?; + let mut private_key_attributes = private_key_attributes; + // Initialise keyset metadata for gen-0 on SQL key pairs only. + if sk_rotate_name.is_some() && has_prefix(&sk_uid).is_none() { + private_key_attributes.rotate_generation = Some(0); + private_key_attributes.rotate_latest = Some(true); + } trace!( "Private key attributes after lifecycle update: {}", private_key_attributes @@ -131,13 +131,13 @@ pub(crate) async fn create_key_pair( &mut private_key, )) .await?; + // If the private key was wrapped, record the WrappingKeyLink in stored attributes + // so KMIP GetAttributes returns it correctly (KMIP 2.1 §4.31 Link). + private_key.copy_wrapping_key_link_to(&mut private_key_attributes); let mut public_key = key_pair.public_key().to_owned(); - let public_key_attributes = super::key_ops::setup_object_lifecycle( - &mut public_key, - ObjectType::PublicKey, - requested_pk_activation_date, - )?; + let mut public_key_attributes = + public_key.setup_with_lifecycle(ObjectType::PublicKey, requested_pk_activation_date)?; trace!( "Public key attributes after lifecycle update: {}", public_key_attributes @@ -150,6 +150,8 @@ pub(crate) async fn create_key_pair( &mut public_key, )) .await?; + // If the public key was wrapped, record the WrappingKeyLink in stored attributes. + public_key.copy_wrapping_key_link_to(&mut public_key_attributes); let operations = vec![ AtomicOperation::Create(( diff --git a/crate/server/src/core/operations/decrypt.rs b/crate/server/src/core/operations/decrypt.rs index d9f8a20d73..fe4e5333c0 100644 --- a/crate/server/src/core/operations/decrypt.rs +++ b/crate/server/src/core/operations/decrypt.rs @@ -11,7 +11,7 @@ use cosmian_kms_server_database::reexport::cosmian_kms_crypto::{ }; use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ - kmip_0::kmip_types::{CryptographicUsageMask, ErrorReason, PaddingMethod}, + kmip_0::kmip_types::{CryptographicUsageMask, ErrorReason, PaddingMethod, State}, kmip_2_1::{ KmipOperation, extra::BulkData, @@ -44,7 +44,7 @@ use crate::{ config::ServerParams, core::{ KMS, - operations::{CryptoOpSpec, has_usage_mask, perform_crypto_operation}, + operations::{CryptoOpSpec, KeysetMode}, }, error::KmsError, kms_bail, @@ -67,6 +67,17 @@ impl CryptoOpSpec for DecryptOp { request.unique_identifier.as_ref() } + fn keyset_mode() -> KeysetMode { + KeysetMode::TryEach + } + + /// Decrypt accepts Active, Deactivated, and Compromised keys per KMIP 2.1 §3.31: + /// "The object SHALL NOT be used for applying cryptographic protection [...] + /// The object SHOULD only be used to process cryptographically-protected information." + fn accepted_states() -> &'static [State] { + &[State::Active, State::Deactivated, State::Compromised] + } + fn usage_data_len(request: &Self::Request) -> usize { request.data.as_ref().map_or(0, Vec::len) } @@ -75,10 +86,10 @@ impl CryptoOpSpec for DecryptOp { #[cfg(not(feature = "non-fips"))] let _ = vendor_id; if let Object::SymmetricKey { .. } = owm.object() { - return has_usage_mask(owm, CryptographicUsageMask::Decrypt, false); + return owm.has_usage_mask(CryptographicUsageMask::Decrypt, false); } if let Object::PrivateKey { .. } = owm.object() { - if !has_usage_mask(owm, CryptographicUsageMask::Decrypt, false) { + if !owm.has_usage_mask(CryptographicUsageMask::Decrypt, false) { return false; } #[cfg(feature = "non-fips")] @@ -196,7 +207,7 @@ pub(crate) async fn decrypt(kms: &KMS, request: Decrypt, user: &str) -> KResult< request.unique_identifier, request.data.as_ref().map_or(0, Vec::len) ); - Box::pin(perform_crypto_operation::(kms, request, user)).await + Box::pin(kms.perform_crypto_operation::(request, user)).await } fn decrypt_bulk( diff --git a/crate/server/src/core/operations/destroy.rs b/crate/server/src/core/operations/destroy.rs index 9d8985a0f6..70408301b3 100644 --- a/crate/server/src/core/operations/destroy.rs +++ b/crate/server/src/core/operations/destroy.rs @@ -23,7 +23,6 @@ use crate::core::cover_crypt::destroy_user_decryption_keys; use crate::{ core::{ KMS, - operations::key_ops::{ObjectWithMetadataOps, record_cascading_metrics}, uid_utils::{has_prefix, uids_from_unique_identifier}, }, error::KmsError, @@ -119,8 +118,8 @@ pub(crate) async fn recursively_destroy_object( // Check if the object is owned by the user // If the object is not owned by the user, check if the user has destroy permissions - if !owm - .user_can_perform_operation(user, &KmipOperation::Destroy, kms) + if !kms + .user_can_perform_operation(&owm, user, &KmipOperation::Destroy) .await? { continue; @@ -263,7 +262,7 @@ pub(crate) async fn recursively_destroy_object( ids_to_skip.clone(), ) .await?; - record_cascading_metrics("Destroy", op_start, kms, user); + kms.record_cascading_metrics("Destroy", op_start, user); } } } @@ -322,7 +321,7 @@ pub(crate) async fn recursively_destroy_object( private_key_id_clone, e ); } - record_cascading_metrics("Destroy", op_start, kms, user); + kms.record_cascading_metrics("Destroy", op_start, user); } } } diff --git a/crate/server/src/core/operations/dispatch.rs b/crate/server/src/core/operations/dispatch.rs index 37a3e52994..00ceb666cc 100644 --- a/crate/server/src/core/operations/dispatch.rs +++ b/crate/server/src/core/operations/dispatch.rs @@ -3,8 +3,8 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ kmip_2_1::kmip_operations::{ Activate, AddAttribute, Certify, Check, Create, CreateKeyPair, Decrypt, DeleteAttribute, DeriveKey, Destroy, Encrypt, Export, Get, GetAttributeList, GetAttributes, Hash, Import, - Locate, MAC, MACVerify, ModifyAttribute, Operation, Query, RNGRetrieve, RNGSeed, ReKey, - ReKeyKeyPair, Register, Revoke, SetAttribute, Sign, SignatureVerify, Validate, + Locate, MAC, MACVerify, ModifyAttribute, Operation, Query, RNGRetrieve, RNGSeed, ReCertify, + ReKey, ReKeyKeyPair, Register, Revoke, SetAttribute, Sign, SignatureVerify, Validate, }, ttlv::{TTLV, from_ttlv}, }; @@ -30,13 +30,6 @@ macro_rules! op { let resp = $kms.$method(req, $user).await?; Operation::$Resp(resp) }}; - // Variant for operations that also need privileged_users - (priv $ttlv:expr, $kms:expr, $user:expr, $Req:ty, $method:ident, $Resp:ident) => {{ - let req = from_ttlv::<$Req>($ttlv)?; - let privileged_users = $kms.params.privileged_users.clone(); - let resp = $kms.$method(req, $user, privileged_users).await?; - Operation::$Resp(resp) - }}; // Variant for operations returning a boxed response (boxed $ttlv:expr, $kms:expr, $user:expr, $Req:ty, $method:ident, $Resp:ident) => {{ let req = from_ttlv::<$Req>($ttlv)?; @@ -117,13 +110,20 @@ async fn dispatch_inner( add_attribute, AddAttributeResponse ), - "Certify" => op!(priv ttlv, kms, user, Certify, certify, CertifyResponse), + "Certify" => op!(ttlv, kms, user, Certify, certify, CertifyResponse), "Check" => { op!(fn ttlv, kms, user, Check, check, CheckResponse) } - "Create" => op!(priv ttlv, kms, user, Create, create, CreateResponse), + "Create" => op!(ttlv, kms, user, Create, create, CreateResponse), "CreateKeyPair" => { - op!(priv ttlv, kms, user, CreateKeyPair, create_key_pair, CreateKeyPairResponse) + op!( + ttlv, + kms, + user, + CreateKeyPair, + create_key_pair, + CreateKeyPairResponse + ) } "Decrypt" => op!(ttlv, kms, user, Decrypt, decrypt, DecryptResponse), "DeleteAttribute" => { @@ -160,7 +160,7 @@ async fn dispatch_inner( RNGRetrieveResponse ), "RNGSeed" => op!(ttlv, kms, user, RNGSeed, rng_seed, RNGSeedResponse), - "Import" => op!(priv ttlv, kms, user, Import, import, ImportResponse), + "Import" => op!(ttlv, kms, user, Import, import, ImportResponse), "Locate" => op!(ttlv, kms, user, Locate, locate, LocateResponse), "Mac" | "MAC" => op!(ttlv, kms, user, MAC, mac, MACResponse), "MACVerify" => { @@ -177,11 +177,21 @@ async fn dispatch_inner( ModifyAttributeResponse ) } - "ReKey" => op!(priv ttlv, kms, user, ReKey, rekey, ReKeyResponse), + "ReKey" => op!(ttlv, kms, user, ReKey, rekey, ReKeyResponse), "ReKeyKeyPair" => { - op!(priv ttlv, kms, user, ReKeyKeyPair, rekey_keypair, ReKeyKeyPairResponse) + op!( + ttlv, + kms, + user, + ReKeyKeyPair, + rekey_keypair, + ReKeyKeyPairResponse + ) + } + "ReCertify" => { + op!(ttlv, kms, user, ReCertify, recertify, ReCertifyResponse) } - "Register" => op!(priv ttlv, kms, user, Register, register, RegisterResponse), + "Register" => op!(ttlv, kms, user, Register, register, RegisterResponse), "Revoke" => op!(ttlv, kms, user, Revoke, revoke, RevokeResponse), "SetAttribute" => op!( ttlv, diff --git a/crate/server/src/core/operations/encrypt.rs b/crate/server/src/core/operations/encrypt.rs index d15c23e409..99eb7916ea 100644 --- a/crate/server/src/core/operations/encrypt.rs +++ b/crate/server/src/core/operations/encrypt.rs @@ -51,10 +51,7 @@ use crate::core::operations::algorithm_policy::{ }; use crate::{ config::ServerParams, - core::{ - KMS, - operations::{CryptoOpSpec, has_usage_mask, perform_crypto_operation}, - }, + core::{KMS, operations::CryptoOpSpec}, error::KmsError, kms_bail, result::KResult, @@ -82,10 +79,10 @@ impl CryptoOpSpec for EncryptOp { fn is_key_eligible(owm: &ObjectWithMetadata, _vendor_id: &str) -> bool { if let Object::Certificate { .. } = owm.object() { - return has_usage_mask(owm, CryptographicUsageMask::Encrypt, true); + return owm.has_usage_mask(CryptographicUsageMask::Encrypt, true); } if let Object::SymmetricKey { .. } | Object::PublicKey { .. } = owm.object() { - return has_usage_mask(owm, CryptographicUsageMask::Encrypt, false); + return owm.has_usage_mask(CryptographicUsageMask::Encrypt, false); } false } @@ -168,7 +165,7 @@ pub(crate) async fn encrypt(kms: &KMS, request: Encrypt, user: &str) -> KResult< request.unique_identifier, request.data.as_ref().map_or(0, |d| d.len()) ); - Box::pin(perform_crypto_operation::(kms, request, user)).await + Box::pin(kms.perform_crypto_operation::(request, user)).await } /// Encrypt a single plaintext with the key diff --git a/crate/server/src/core/operations/export_get.rs b/crate/server/src/core/operations/export_get.rs index 15d5770d11..da104d4ff5 100644 --- a/crate/server/src/core/operations/export_get.rs +++ b/crate/server/src/core/operations/export_get.rs @@ -564,6 +564,11 @@ async fn post_process_active_private_key( owm_attributes } }; + // Strip WrappingKeyLink: it belongs to the server-side metadata (tracking the stored + // wrapping relationship), not to the exported key material. Without this, a key that + // was imported-as-wrapped and later exported-with-unwrap would carry a stale + // WrappingKeyLink in its embedded key_value.attributes. + attributes.remove_link(LinkType::WrappingKeyLink); // Special-case TransparentDSAPrivateKey: we do not need (nor want) an OpenSSL round-trip // if the caller either requested no specific format OR explicitly requested the same @@ -858,6 +863,9 @@ async fn process_public_key( owm_attributes } }; + // Strip WrappingKeyLink from the exported public key attributes (same rationale as + // post_process_active_private_key: server-side metadata, not part of exported material). + attributes.remove_link(LinkType::WrappingKeyLink); // parse the key to an openssl object let openssl_key = kmip_public_key_to_openssl(object_with_metadata.object()) diff --git a/crate/server/src/core/operations/import.rs b/crate/server/src/core/operations/import.rs index fe8acf72cd..bef0749bc6 100644 --- a/crate/server/src/core/operations/import.rs +++ b/crate/server/src/core/operations/import.rs @@ -37,7 +37,6 @@ use crate::{ core::{ KMS, operations::validate::verify_crls, - retrieve_object_utils::user_has_permission, wrapping::{unwrap_object, wrap_and_cache}, }, error::KmsError, @@ -46,12 +45,7 @@ use crate::{ }; /// Import a new object -pub(crate) async fn import( - kms: &KMS, - request: Import, - user: &str, - privileged_users: Option>, -) -> KResult { +pub(crate) async fn import(kms: &KMS, request: Import, user: &str) -> KResult { trace!( "Entering import KMIP operation: uid={}, object_type={}", request.unique_identifier, request.object_type @@ -71,21 +65,7 @@ pub(crate) async fn import( // To import an object, ensure the user has the `Create` access right. // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users { - let has_permission = user_has_permission( - user, - None, - &cosmian_kmip::kmip_2_1::KmipOperation::Create, - kms, - ) - .await?; - - if !has_permission && !users.iter().any(|u| u == user) { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right.".to_owned() - )) - } - } + kms.enforce_create_permission(user).await?; // When replace_existing is requested with an explicit UID, verify the caller owns the // target object. Without this check, any user with Create rights could overwrite another @@ -294,6 +274,9 @@ pub(super) async fn process_symmetric_key( &mut object, )) .await?; + // If the object was wrapped, record the WrappingKeyLink in the stored attributes + // so KMIP GetAttributes returns it correctly (KMIP 2.1 §4.31 Link). + object.copy_wrapping_key_link_to(&mut attributes); Ok(( uid.clone(), @@ -483,6 +466,9 @@ pub(super) async fn process_public_key( &mut object, )) .await?; + // If the object was wrapped, record the WrappingKeyLink in the stored attributes + // so KMIP GetAttributes returns it correctly (KMIP 2.1 §4.31 Link). + object.copy_wrapping_key_link_to(&mut attributes); Ok(( uid.clone(), @@ -615,6 +601,9 @@ pub(super) async fn process_private_key( &mut object, )) .await?; + // If the object was wrapped, record the WrappingKeyLink in the stored attributes + // so KMIP GetAttributes returns it correctly (KMIP 2.1 §4.31 Link). + object.copy_wrapping_key_link_to(&mut attributes); Ok(( uid.clone(), @@ -836,7 +825,7 @@ async fn process_pkcs12( trace!("Private key linked to leaf certificate"); // Keep private key attributes before wrapping/inserting in DB - let private_key_attributes = private_key.attributes()?.clone(); + let mut private_key_attributes = private_key.attributes()?.clone(); // Wrap the private key if requested by the user or on the server params Box::pin(wrap_and_cache( @@ -847,6 +836,9 @@ async fn process_pkcs12( )) .await?; trace!("Private key wrapped and cached"); + // If the private key was wrapped, record the WrappingKeyLink in stored attributes + // so KMIP GetAttributes returns it correctly (KMIP 2.1 §4.31 Link). + private_key.copy_wrapping_key_link_to(&mut private_key_attributes); // Create an operation to set the private key operations.push(single_operation( @@ -1047,6 +1039,9 @@ pub(super) async fn process_secret_data( &mut object, )) .await?; + // If the object was wrapped, record the WrappingKeyLink in the stored attributes + // so KMIP GetAttributes returns it correctly (KMIP 2.1 §4.31 Link). + object.copy_wrapping_key_link_to(&mut attributes); Ok(( uid.clone(), diff --git a/crate/server/src/core/operations/key_ops/crypto_op.rs b/crate/server/src/core/operations/key_ops/crypto_op.rs index ae68c48234..e115b7eae9 100644 --- a/crate/server/src/core/operations/key_ops/crypto_op.rs +++ b/crate/server/src/core/operations/key_ops/crypto_op.rs @@ -1,68 +1,135 @@ +//! KMIP cryptographic operation dispatch: key resolution, execution, +//! lifecycle enforcement, and usage-limit accounting. +//! +//! This module owns: +//! - [`CryptoOpSpec`] — operation-specific trait for crypto operations. + +//! - [`KeySelectionSpec`] — generic key-selection spec shared with rekey operations. +//! - [`KeysetMode`] — how a bare keyset name is handled. +//! - [`perform_crypto_operation`] — generic entry point for all crypto ops. +//! - [`select_unique_key`] — single-candidate selection helper (also used by rekey). +//! - [`setup_object_lifecycle`] — lifecycle initialization for newly created objects. +//! +//! ## Key resolution pipeline +//! +//! ```text +//! UniqueIdentifier +//! │ +//! ├─ Keyset detection (name / name@version) +//! │ ├─ Explicit @version → resolve to single UID +//! │ └─ Bare name: +//! │ ├─ SingleLatest → resolve latest key +//! │ └─ TryEach → walk keyset chain +//! │ +//! ├─ Standard UID / tag resolution +//! │ +//! ├─ Phase 1: Oracle (HSM) routing +//! │ +//! └─ Phase 2: Database selection + uniqueness enforcement +//! ``` + use std::collections::HashSet; use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ - kmip_0::kmip_types::{CryptographicUsageMask, ErrorReason, State}, + kmip_0::kmip_types::{ErrorReason, State}, kmip_2_1::{ KmipOperation, + kmip_attributes::Attributes, + kmip_objects::{Object, ObjectType}, kmip_types::{UniqueIdentifier, UsageLimitsUnit}, }, }, cosmian_kms_interfaces::ObjectWithMetadata, }; +use cosmian_logger::{trace, warn}; +use time::OffsetDateTime; -use super::{DatabaseOps, ObjectWithMetadataOps}; use crate::{ core::{ KMS, - uid_utils::{has_prefix, uids_from_unique_identifier}, + operations::digest::digest, + uid_utils::{ + KeysetVersion, has_prefix, parse_keyset_identifier, resolve_keyset_to_single_uid, + uids_from_unique_identifier, walk_keyset_chain, + }, }, error::KmsError, result::{KResult, KResultHelper}, }; -// ─── Generic crypto operation key resolution ───────────────────────────────── +// ─── Key lifecycle initialization ───────────────────────────────────────────── -/// Result of key resolution for a cryptographic operation. -pub(crate) enum ResolvedKey { - /// Key lives on an external crypto oracle (HSM / external key store). - /// The caller dispatches to the oracle using the `uid` and `prefix`. - Oracle { uid: String, prefix: String }, - /// Key is in the local database: selected, Active, lifecycle-validated. - /// NOT yet unwrapped — the caller handles unwrapping based on operation needs. - Local(Box), +/// Extension trait on [`Object`] for server-side lifecycle initialization. +/// +/// This trait bridges the KMIP crate's [`Object::setup_lifecycle`] with the +/// server-crate-specific [`digest`] function (which requires OpenSSL). The +/// kmip crate has no OpenSSL dependency so the digest computation lives here. +pub(crate) trait ObjectLifecycleExt { + /// Initialize lifecycle attributes on this newly created or imported object. + /// + /// Computes the KMIP digest (SHA-256 via OpenSSL), then delegates to + /// [`Object::setup_lifecycle`] for the state-machine logic. + fn setup_with_lifecycle( + &mut self, + object_type: ObjectType, + requested_activation_date: Option, + ) -> KResult; } -/// Check whether a managed object's usage mask permits the given operation. -/// -/// Resolves the object's effective attributes (prefers the object's own key-block -/// attributes, falls back to externally-stored metadata) and tests against the -/// required `CryptographicUsageMask` flag. -/// -/// # `lenient` mode -/// -/// When `true`, a **missing** usage mask (`None`) is treated as "allowed". -/// This backward-compatibility mode is used for Certificates and Public Keys that -/// were imported without a usage mask. +impl ObjectLifecycleExt for Object { + fn setup_with_lifecycle( + &mut self, + object_type: ObjectType, + requested_activation_date: Option, + ) -> KResult { + let computed_digest = digest(self)?; + Ok(self.setup_lifecycle(object_type, requested_activation_date, computed_digest)?) + } +} + +// ─── Keyset mode ────────────────────────────────────────────────────────────── + +/// Determines how a keyset reference (bare name without `@version`) is handled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KeysetMode { + /// Use only the latest key in the keyset (for encrypt, sign, MAC). + SingleLatest, + /// Try each key in the chain from newest to oldest (for decrypt, verify). + TryEach, +} + +// ─── Key selection trait ────────────────────────────────────────────────────── + +/// Declarative specification for key selection shared across all KMIP operations. /// -/// When `false`, a missing mask means the key is **rejected**. -pub(crate) fn has_usage_mask( - owm: &ObjectWithMetadata, - required: CryptographicUsageMask, - lenient: bool, -) -> bool { - let attributes = owm - .object() - .attributes() - .unwrap_or_else(|_| owm.attributes()); - if lenient && attributes.cryptographic_usage_mask.is_none() { - return true; +/// Implemented by: +/// - Crypto operation marker structs (via the `CryptoOpSpec` supertrait relationship) +/// - Rekey operation structs (`SymmetricRekey`, `KeypairRekey`) +pub(crate) trait KeySelectionSpec { + /// Human-readable operation name for error messages (e.g. `"Encrypt"`, `"ReKey"`). + const OP_NAME: &'static str; + + /// The KMIP operation used for permission checks. + const KMIP_OP: KmipOperation; + + /// Key states accepted by this operation. + fn accepted_states() -> &'static [State]; + + /// Whether permission checks require an exact operation grant. + /// + /// - `false` (default): a `Get` grant also authorizes the operation (crypto ops). + /// - `true`: only an explicit grant of [`Self::KMIP_OP`] authorizes (rekey, destructive ops). + fn strict_permission_check() -> bool { + false } - attributes - .is_usage_authorized_for(required) - .unwrap_or(false) + + /// Determine if the managed object is eligible (object type + usage mask). + fn is_key_eligible(owm: &ObjectWithMetadata, vendor_id: &str) -> bool; } +// ─── Crypto operation specification ─────────────────────────────────────────── + /// Declarative specification for KMIP cryptographic operations. /// /// Implemented by zero-sized marker types (one per operation). A generic @@ -91,6 +158,14 @@ pub(crate) trait CryptoOpSpec { /// Extract the `UniqueIdentifier` from the typed request. fn unique_identifier(request: &Self::Request) -> Option<&UniqueIdentifier>; + /// How this operation handles a bare keyset name (no `@version` suffix). + /// + /// - `SingleLatest`: resolve to the latest key only (encrypt, sign, MAC). + /// - `TryEach`: walk the chain and try each key newest→oldest (decrypt, verify). + fn keyset_mode() -> KeysetMode { + KeysetMode::SingleLatest + } + /// Compute the data length for `UsageLimits` enforcement. /// /// The meaning varies per operation: @@ -100,6 +175,19 @@ pub(crate) trait CryptoOpSpec { /// - MAC/MACVerify: data length fn usage_data_len(request: &Self::Request) -> usize; + /// Key states accepted by this operation. + /// + /// Per KMIP 2.1 §3.31: + /// - Protection operations (Encrypt, Sign, MAC) require `Active` only. + /// - Processing operations (Decrypt, Verify, `MACVerify`) accept `Active`, + /// `Deactivated`, and `Compromised` — because deactivated/compromised keys + /// must remain usable to process previously protected data. + /// + /// Default: `&[State::Active]` — override for processing operations. + fn accepted_states() -> &'static [State] { + &[State::Active] + } + /// Determine if the given managed object is eligible for this operation. /// /// Checks object type and `CryptographicUsageMask` as required by the operation. @@ -145,350 +233,736 @@ pub(crate) trait CryptoOpSpec { ) -> impl std::future::Future> + Send; } -/// Generic entry point for all cryptographic operations. +/// Every `CryptoOpSpec` implementor automatically satisfies `KeySelectionSpec`. /// -/// Resolves the key (oracle or local), enforces algorithm policy, -/// enforces and decrements usage limits, and dispatches to the -/// operation-specific execution logic. -/// -/// The clone-before-unwrap pattern ensures wrapped key material is -/// never persisted in plaintext (COSMIAN-2026-015). -pub(crate) async fn perform_crypto_operation( - kms: &KMS, - request: Op::Request, - user: &str, -) -> KResult { - let unique_identifier = - Op::unique_identifier(&request).ok_or(KmsError::UnsupportedPlaceholder)?; - - match resolve_key_for_operation::(unique_identifier, kms, user).await? { - ResolvedKey::Oracle { uid, prefix } => { - let result = Op::execute_oracle(kms, &request, &uid, &prefix).await; - if let Some(ref metrics) = kms.metrics { - let model = crate::core::uid_utils::hsm_model_from_prefix( - &kms.params.hsm_instances, - &prefix, - ); - metrics.record_hsm_operation(Op::OP_NAME, model); +/// This avoids duplicate trait implementations for `EncryptOp`, `DecryptOp`, etc. +impl KeySelectionSpec for T { + const KMIP_OP: KmipOperation = T::KMIP_OP; + const OP_NAME: &'static str = T::OP_NAME; + + fn accepted_states() -> &'static [State] { + T::accepted_states() + } + + fn is_key_eligible(owm: &ObjectWithMetadata, vendor_id: &str) -> bool { + ::is_key_eligible(owm, vendor_id) + } +} + +// ─── Generic key selection ──────────────────────────────────────────────────── + +/// Result of key resolution for a cryptographic operation. +enum ResolvedKey { + /// Key lives on an external crypto oracle (HSM / external key store). + Oracle { uid: String, prefix: String }, + /// Key is in the local database: selected, Active, lifecycle-validated. + /// NOT yet unwrapped — `perform_crypto_operation` handles unwrapping. + Local(Box), + /// A keyset chain: ordered list of UIDs from newest to oldest. + Keyset(Vec), +} + +impl KMS { + /// Select exactly one key from pre-fetched candidates using the [`KeySelectionSpec`] pipeline. + /// + /// Applies the following filters in order: + /// 1. **State** — `Spec::accepted_states()` + /// 2. **Permission** — ownership or explicit grant, optionally with `Get` wildcard + /// 3. **Eligibility** — `Spec::is_key_eligible()` + /// 4. **Extra validation** — caller-supplied closure for operation-specific checks + /// (e.g. keyset-latest guard, crypto-param change rejection) + /// + /// Enforces uniqueness: + /// - 0 eligible → `KmsError::ItemNotFound` or `KmsError::Unauthorized` + /// - 1 eligible → `Ok(ObjectWithMetadata)` + /// - \>1 eligible → `KmsError::InvalidRequest` (ambiguous) + pub(crate) async fn select_unique_key( + &self, + candidates: Vec, + uid_display: &str, + user: &str, + extra_validation: F, + ) -> KResult + where + Spec: KeySelectionSpec, + F: Fn(&ObjectWithMetadata) -> KResult<()>, + { + let mut eligible: Vec = Vec::new(); + let mut found_but_no_permission = false; + // Track state mismatches so we can surface a useful error instead of "not found". + let mut wrong_state: Option<(String, State)> = None; + + for owm in candidates { + // 1. State filter + let effective = owm.effective_state(); + if !Spec::accepted_states().contains(&effective) { + // Track live-but-wrong-state for a useful error message. + // Skip Destroyed/DestroyedCompromised: those behave as "not found". + if effective != State::Destroyed && effective != State::Destroyed_Compromised { + wrong_state.get_or_insert_with(|| (owm.id().to_owned(), effective)); + } + continue; } - result + + // 2. Permission check + let authorized = if Spec::strict_permission_check() { + // Strict: only exact operation grant (no Get wildcard) + self.user_can_perform_operation(&owm, user, &Spec::KMIP_OP) + .await? + } else { + // Lenient: Get grant also authorizes (standard for crypto ops) + self.is_user_authorized_with_get_wildcard(owm.id(), user, Spec::KMIP_OP) + .await? + }; + if !authorized { + found_but_no_permission = true; + continue; + } + + // 3. Eligibility (object type + usage mask) + if !Spec::is_key_eligible(&owm, self.vendor_id()) { + continue; + } + + // 4. Extra validation (hard error on failure — not skipped) + extra_validation(&owm)?; + + eligible.push(owm); } - ResolvedKey::Local(owm) => { - let mut owm = *owm; - // Clone before unwrap: preserve the wrapped key for DB persistence. - let mut unwrapped_owm = owm.clone(); - unwrap_and_enforce_policy(kms, &mut unwrapped_owm, Op::OP_NAME, user) - .await - .with_context(|| { + match eligible.len() { + 1 => eligible + .into_iter() + .next() + .ok_or_else(|| KmsError::ItemNotFound("unreachable: len == 1".to_owned())), + 0 => Err(if found_but_no_permission { + KmsError::Unauthorized(format!( + "{}: user {user} does not have permission to use key: {uid_display}", + Spec::OP_NAME, + )) + } else if let Some((uid, state)) = wrong_state { + KmsError::Kmip21Error( + ErrorReason::Permission_Denied, format!( - "{}: the key: {}, cannot be unwrapped.", - Op::OP_NAME, - owm.id() - ) - })?; - - let data_len = Op::usage_data_len(&request); - enforce_usage_limits(&owm, data_len)?; + "{}: key {uid} is in state {state:?} but operation requires one of {:?}", + Spec::OP_NAME, + Spec::accepted_states() + ), + ) + } else { + KmsError::ItemNotFound(format!( + "{}: no valid key found for identifier: {uid_display}", + Spec::OP_NAME, + )) + }), + n => { + let ids: Vec<&str> = eligible.iter().map(ObjectWithMetadata::id).collect(); + Err(KmsError::InvalidRequest(format!( + "{}: identifier '{uid_display}' resolves to {n} valid keys {ids:?}; \ + use a unique identifier", + Spec::OP_NAME, + ))) + } + } + } - let res = Op::execute_local(kms, &unwrapped_owm, &request, user).await?; + /// Generic entry point for all cryptographic operations. + /// + /// Resolves the key (oracle or local), enforces algorithm policy, + /// enforces and decrements usage limits, and dispatches to the + /// operation-specific execution logic. + /// + /// The clone-before-unwrap pattern ensures wrapped key material is + /// never persisted in plaintext (COSMIAN-2026-015). + pub(crate) async fn perform_crypto_operation( + &self, + request: Op::Request, + user: &str, + ) -> KResult { + let unique_identifier = + Op::unique_identifier(&request).ok_or(KmsError::UnsupportedPlaceholder)?; - decrement_usage_limits(kms, &mut owm, Op::OP_NAME, data_len).await?; - Ok(res) + match self + .resolve_key_for_operation::(unique_identifier, user) + .await? + { + ResolvedKey::Oracle { uid, prefix } => { + let result = Op::execute_oracle(self, &request, &uid, &prefix).await?; + if let Some(ref metrics) = self.metrics { + let model = crate::core::uid_utils::hsm_model_from_prefix( + &self.params.hsm_instances, + &prefix, + ); + metrics.record_hsm_operation(Op::OP_NAME, model); + } + Ok(result) + } + ResolvedKey::Local(owm) => { + self.execute_local_with_limits::(*owm, &request, user) + .await + } + ResolvedKey::Keyset(chain) => { + self.execute_keyset_try_each::(&chain, &request, user) + .await + } } } -} -/// Collect the single eligible crypto-oracle UID for a cryptographic operation. -/// -/// Iterates over `candidate_uids`, retains those that carry a recognized prefix (oracle -/// keys), and filters out any for which the current `user` lacks authorization. -/// -/// Returns: -/// * `Ok(None)` — no oracle UID is eligible; the caller should fall through to the standard -/// database path. -/// * `Ok(Some((uid, prefix)))` — exactly one oracle UID is eligible; use it. -/// * `Err(KmsError::InvalidRequest)` — more than one oracle UID is eligible (ambiguous). -pub(crate) async fn select_eligible_oracle_uid( - operation: KmipOperation, - op_name: &str, - candidate_uids: &HashSet, - unique_identifier: &UniqueIdentifier, - kms: &KMS, - user: &str, -) -> KResult> { - let mut eligible: Vec<(String, String)> = Vec::new(); - for uid in candidate_uids { - if let Some(prefix) = has_prefix(uid) { - if !kms - .database - .is_user_authorized_for_operation(uid, user, operation) + /// Execute a local operation with unwrapping and usage-limit accounting. + async fn execute_local_with_limits( + &self, + owm: ObjectWithMetadata, + request: &Op::Request, + user: &str, + ) -> KResult { + let mut owm = owm; + + // Clone before unwrap: preserve the wrapped key for DB persistence. + let mut unwrapped_owm = owm.clone(); + self.unwrap_and_enforce_policy(&mut unwrapped_owm, Op::OP_NAME, user) + .await + .with_context(|| { + format!( + "{}: the key: {}, cannot be unwrapped.", + Op::OP_NAME, + owm.id() + ) + })?; + + let data_len = Op::usage_data_len(request); + owm.enforce_usage_limits(data_len)?; + + let res = Op::execute_local(self, &unwrapped_owm, request, user).await?; + + self.decrement_usage_limits(&mut owm, Op::OP_NAME, data_len) + .await?; + Ok(res) + } + + /// Try each key in a keyset chain (newest→oldest) until one succeeds. + /// + /// The traversal is unbounded: `walk_keyset_chain` already guarantees termination + /// via cycle detection. A server-side warning is emitted whenever the depth is + /// ≥ `params.keyset_warn_depth`. + async fn execute_keyset_try_each( + &self, + chain: &[String], + request: &Op::Request, + user: &str, + ) -> KResult { + let mut last_err: Option = None; + + for (depth, uid) in chain.iter().enumerate() { + let Some(owm) = self.database.retrieve_object(uid).await? else { + continue; + }; + + // State filter: per KMIP 2.1 §3.31, processing operations (Decrypt, Verify) + // accept Deactivated/Compromised keys; protection operations require Active only. + if !Op::accepted_states().contains(&owm.effective_state()) { + continue; + } + + // Permission check + if !self + .is_user_authorized_with_get_wildcard(uid, user, Op::KMIP_OP) .await? { continue; } - eligible.push((uid.clone(), prefix.to_owned())); + + // Eligibility check + if !Op::is_key_eligible(&owm, self.vendor_id()) { + continue; + } + + // Lifecycle check + if owm.check_process_window().is_err() { + continue; + } + + match self + .execute_local_with_limits::(owm, request, user) + .await + { + Ok(response) => { + let depth_u32 = u32::try_from(depth).unwrap_or(u32::MAX); + let warn_threshold = self.params.keyset_warn_depth; + if depth_u32 >= warn_threshold { + warn!( + "{}: keyset chain depth {} ≥ warn threshold {} for uid {}; \ + consider re-encrypting with the latest key", + Op::OP_NAME, + depth_u32, + warn_threshold, + uid + ); + } + return Ok(response); + } + Err(e) => { + trace!( + "execute_keyset_try_each: key {} failed for {}: {}", + uid, + Op::OP_NAME, + e + ); + last_err = Some(e); + } + } } + + Err(last_err.unwrap_or_else(|| { + KmsError::Kmip21Error( + ErrorReason::Item_Not_Found, + format!( + "{}: decryption failed — no key in the keyset could process the request", + Op::OP_NAME + ), + ) + })) } - match eligible.len() { - 0 => Ok(None), - 1 => Ok(eligible.into_iter().next()), - n => { - let ids: Vec<&str> = eligible.iter().map(|(uid, _)| uid.as_str()).collect(); - Err(KmsError::InvalidRequest(format!( - "{op_name}: identifier {unique_identifier} resolves to {n} valid oracle keys \ - {ids:?}; use a unique identifier", - ))) + + /// Unwrap a key (if wrapped) and enforce the KMIP algorithm policy. + /// + /// # Security + /// + /// The operation is performed **in-place** on `owm`, replacing the wrapped key material + /// with plaintext. Callers that later persist `owm` (e.g. via `decrement_usage_limits`) + /// **MUST clone** before calling this function and pass the original (still-wrapped) + /// `owm` to the persistence path. Failing to do so silently stores the plaintext key + /// in the database, defeating KEK encryption at rest. + pub(crate) async fn unwrap_and_enforce_policy( + &self, + owm: &mut ObjectWithMetadata, + op_name: &str, + user: &str, + ) -> KResult<()> { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_objects::Object; + if !matches!(owm.object(), Object::Certificate { .. }) { + owm.set_object(self.get_unwrapped(owm.id(), owm.object(), user).await?); } + crate::core::operations::algorithm_policy::enforce_kmip_algorithm_policy_for_retrieved_key( + &self.params, + op_name, + owm.id(), + owm, + ) } -} -/// Select exactly one key from a set of candidate UIDs for a cryptographic operation. -/// -/// `candidate_uids` is a `HashSet` as returned by `uid_utils::uids_from_unique_identifier`. -/// The function: -/// -/// 1. Skips prefix-based (oracle) UIDs — those are handled by the caller before this call. -/// 2. Fetches each object from the database and checks it is `Active` via `get_effective_state`. -/// 3. Verifies the user is authorized via `is_user_authorized_for_operation`. -/// 4. Applies `is_eligible` — a caller-supplied predicate that checks object type / usage mask. -/// 5. Enforces uniqueness: the operation **fails** when more than one eligible key is found. -/// This prevents an attacker from silently substituting a key by tagging a second one. -/// -/// # Errors -/// * `KmsError::Unauthorized` — candidates found but the user has no permission on any of them -/// * `KmsError::ItemNotFound` — no candidate qualifies after all filters -/// * `KmsError::InvalidRequest` — more than one eligible key matched -pub(crate) async fn select_unique_key_for_operation( - op_name: &str, - candidate_uids: &HashSet, - unique_identifier: &UniqueIdentifier, - operation: KmipOperation, - kms: &KMS, - user: &str, - is_eligible: F, -) -> KResult -where - F: Fn(&ObjectWithMetadata) -> KResult, -{ - let uid_display = unique_identifier.to_string(); - let mut eligible: Vec = Vec::new(); - let mut found_but_no_permission = false; - - for uid in candidate_uids { - // Oracle (prefix) UIDs are handled by the caller — skip them here. + /// Check whether a user is authorized to perform `operation` on the object + /// identified by `uid`, with `Get` wildcard for non-HSM keys. + /// + /// The user is authorized if they own the object, or have been granted the + /// specific `operation` **or** `Get` (which implies read-level access). + /// For HSM keys (prefix-based UIDs), the `Get` wildcard is **not** applied. + pub(crate) async fn is_user_authorized_with_get_wildcard( + &self, + uid: &str, + user: &str, + operation: KmipOperation, + ) -> KResult { + if self.database.is_object_owned_by(uid, user).await? { + return Ok(true); + } + let ops = self + .database + .list_user_operations_on_object(uid, user, false) + .await?; + + // HSM keys: each operation must be explicitly granted — no Get wildcard if has_prefix(uid).is_some() { - continue; + return Ok(ops.iter().any(|p| *p == operation)); } - let Some(owm) = kms.database.retrieve_object(uid).await? else { - continue; - }; + Ok(ops + .iter() + .any(|p| *p == operation || *p == KmipOperation::Get)) + } - // Must be Active (respects auto-activation via activation_date). - if owm.get_effective_state()? != State::Active { - continue; + /// Decrement and persist `UsageLimits` after a successful cryptographic operation. + /// + /// For `Byte`-based limits, `data_len` bytes are subtracted from the remaining total. + /// For `Object`, `Block`, and `Operation` units, one unit is consumed. + /// + /// Persistence is skipped when no usage limits are set on the key, + /// avoiding unnecessary row-level lock contention on the hot path. + async fn decrement_usage_limits( + &self, + owm: &mut ObjectWithMetadata, + op_name: &str, + data_len: usize, + ) -> KResult<()> { + let mut decremented = false; + if let Some(ref mut ul) = owm.attributes_mut().usage_limits { + match ul.usage_limits_unit { + UsageLimitsUnit::Byte => { + let consumed = i64::try_from(data_len).unwrap_or(i64::MAX); + ul.usage_limits_total = (ul.usage_limits_total - consumed).max(0); + decremented = true; + } + UsageLimitsUnit::Object | UsageLimitsUnit::Block | UsageLimitsUnit::Operation => { + ul.usage_limits_total = (ul.usage_limits_total - 1).max(0); + decremented = true; + } + } } + if decremented { + let attributes = owm.attributes().clone(); + self.database + .update_object(owm.id(), owm.object(), &attributes, None) + .await + .map_err(|e| { + KmsError::ServerError(format!( + "{op_name}: failed to persist updated usage limits: {e}" + )) + })?; + } + Ok(()) + } - // Permission check via the shared authorization function. - if !kms - .database - .is_user_authorized_for_operation(uid, user, operation) + /// Collect the single eligible crypto-oracle UID for a cryptographic operation. + /// + /// Returns `Ok(None)` if no oracle UID is eligible, `Ok(Some((uid, prefix)))` for + /// exactly one, or `Err(InvalidRequest)` when multiple are ambiguously eligible. + async fn select_eligible_oracle_uid( + &self, + operation: KmipOperation, + op_name: &str, + candidate_uids: &HashSet, + unique_identifier: &UniqueIdentifier, + user: &str, + ) -> KResult> { + let mut eligible: Vec<(String, String)> = Vec::new(); + for uid in candidate_uids { + if let Some(prefix) = has_prefix(uid) { + if !self + .is_user_authorized_with_get_wildcard(uid, user, operation) + .await? + { + continue; + } + eligible.push((uid.clone(), prefix.to_owned())); + } + } + match eligible.len() { + 0 => Ok(None), + 1 => Ok(eligible.into_iter().next()), + n => { + let ids: Vec<&str> = eligible.iter().map(|(uid, _)| uid.as_str()).collect(); + Err(KmsError::InvalidRequest(format!( + "{op_name}: identifier {unique_identifier} resolves to {n} valid oracle keys \ + {ids:?}; use a unique identifier", + ))) + } + } + } + + /// Resolve the key for a cryptographic operation using the [`CryptoOpSpec`] trait. + /// + /// Performs the entire key selection pipeline: + /// 1. Keyset detection (name or name@version). + /// 2. Standard UID / tag resolution. + /// 3. Oracle (HSM) routing. + /// 4. Database selection with `Op::is_key_eligible` + uniqueness enforcement. + /// 5. Error mapping via `Op::map_selection_error`. + /// 6. Process window enforcement (`ProcessStartDate` / `ProtectStopDate`). + async fn resolve_key_for_operation( + &self, + unique_identifier: &UniqueIdentifier, + user: &str, + ) -> KResult { + let uid_str = unique_identifier + .as_str() + .context("The unique identifier must be a string")?; + + // ── Keyset detection ───────────────────────────────────────────────────── + if let Some(keyset_ref) = parse_keyset_identifier(uid_str) { + match &keyset_ref.version { + KeysetVersion::Latest | KeysetVersion::First | KeysetVersion::Generation(_) => { + if let Some(uid) = resolve_keyset_to_single_uid(&keyset_ref, self, user).await? + { + let owm = self.database.retrieve_object(&uid).await?.ok_or_else(|| { + KmsError::ItemNotFound(format!( + "{}: keyset key not found: {uid}", + Op::OP_NAME + )) + })?; + owm.check_process_window()?; + // KMIP §4.57: enforce state requirements for keyset-addressed keys + let effective = owm.effective_state(); + if !Op::accepted_states().contains(&effective) { + return Err(KmsError::Kmip21Error( + ErrorReason::Permission_Denied, + format!( + "{}: key {uid} is in state {effective:?} but operation \ + requires one of {:?}", + Op::OP_NAME, + Op::accepted_states() + ), + )); + } + return Ok(ResolvedKey::Local(Box::new(owm))); + } + // Not a keyset → fall through to normal UID resolution + } + KeysetVersion::Bare => match Op::keyset_mode() { + KeysetMode::SingleLatest => { + if let Some(uid) = + resolve_keyset_to_single_uid(&keyset_ref, self, user).await? + { + let owm = + self.database.retrieve_object(&uid).await?.ok_or_else(|| { + KmsError::ItemNotFound(format!( + "{}: keyset key not found: {uid}", + Op::OP_NAME + )) + })?; + owm.check_process_window()?; + // KMIP §4.57: enforce state requirements for keyset-addressed keys + let effective = owm.effective_state(); + if !Op::accepted_states().contains(&effective) { + return Err(KmsError::Kmip21Error( + ErrorReason::Permission_Denied, + format!( + "{}: key {uid} is in state {effective:?} but operation \ + requires one of {:?}", + Op::OP_NAME, + Op::accepted_states() + ), + )); + } + return Ok(ResolvedKey::Local(Box::new(owm))); + } + // Not a keyset → fall through to normal path + } + KeysetMode::TryEach => { + let chain = walk_keyset_chain(&keyset_ref.name, self, user).await?; + if !chain.is_empty() { + return Ok(ResolvedKey::Keyset(chain)); + } + // Not a keyset → fall through to normal path + } + }, + } + } + + // ── Standard UID / tag resolution ──────────────────────────────────────── + let uids = uids_from_unique_identifier(unique_identifier, self) + .await + .context(Op::OP_NAME)?; + + // Phase 1 — Oracle (HSM / prefix) routing. + if let Some((uid, prefix)) = self + .select_eligible_oracle_uid(Op::KMIP_OP, Op::OP_NAME, &uids, unique_identifier, user) .await? { - found_but_no_permission = true; - continue; + return Ok(ResolvedKey::Oracle { uid, prefix }); } - // Object-type and usage-mask check supplied by the caller. - if !is_eligible(&owm)? { - continue; + // Phase 2 — Standard database path: fetch candidates, filter, enforce uniqueness. + let mut candidates = Vec::new(); + for uid in &uids { + if has_prefix(uid).is_some() { + continue; + } + if let Some(owm) = self.database.retrieve_object(uid).await? { + candidates.push(owm); + } } + let uid_display = unique_identifier.to_string(); + let owm = self + .select_unique_key::(candidates, &uid_display, user, |_| Ok(())) + .await + .map_err(|e| Op::map_selection_error(e, unique_identifier, user))?; - eligible.push(owm); - } + // Lifecycle enforcement: always check process window. + owm.check_process_window()?; - match eligible.len() { - 1 => eligible - .into_iter() - .next() - .ok_or_else(|| KmsError::ItemNotFound("unreachable: len == 1".to_owned())), - 0 => Err(if found_but_no_permission { - KmsError::Unauthorized(format!( - "{op_name}: user {user} does not have permission to use key: {uid_display}" - )) - } else { - KmsError::ItemNotFound(format!( - "{op_name}: no valid key found for identifier: {uid_display}" - )) - }), - n => { - let ids: Vec<&str> = eligible.iter().map(ObjectWithMetadata::id).collect(); - Err(KmsError::InvalidRequest(format!( - "{op_name}: identifier {uid_display} resolves to {n} valid keys {ids:?}; \ - use a unique identifier" - ))) - } + Ok(ResolvedKey::Local(Box::new(owm))) } } -/// Resolve the key for a cryptographic operation using the [`CryptoOpSpec`] trait. -/// -/// Performs the entire key selection pipeline generically: -/// 1. Resolves UIDs from the `unique_identifier`. -/// 2. Attempts oracle (HSM) routing. -/// 3. Selects the key from the database with `Op::is_key_eligible`. -/// 4. Applies error mapping via `Op::map_selection_error`. -/// 5. Enforces process window constraints (`ProcessStartDate` / `ProtectStopDate`). -/// -/// Returns [`ResolvedKey::Oracle`] for HSM keys or [`ResolvedKey::Local`] for DB keys. -/// Local keys are NOT unwrapped — `perform_crypto_operation` handles unwrapping. -pub(crate) async fn resolve_key_for_operation( - unique_identifier: &UniqueIdentifier, - kms: &KMS, - user: &str, -) -> KResult { - let uids = uids_from_unique_identifier(unique_identifier, kms) - .await - .context(Op::OP_NAME)?; - - // Phase 1 — Oracle (HSM / prefix) routing. - if let Some((uid, prefix)) = select_eligible_oracle_uid( - Op::KMIP_OP, - Op::OP_NAME, - &uids, - unique_identifier, - kms, - user, - ) - .await? - { - return Ok(ResolvedKey::Oracle { uid, prefix }); +// ─── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn)] +mod tests { + use cosmian_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::State, + kmip_2_1::{ + kmip_attributes::Attributes, + kmip_data_structures::{KeyBlock, KeyMaterial, KeyValue}, + kmip_objects::{Object, ObjectType, SymmetricKey}, + kmip_types::{CryptographicAlgorithm, KeyFormatType}, + }, + time_normalize, + }, + cosmian_kms_interfaces::ObjectWithMetadata, + }; + use time::Duration; + use zeroize::Zeroizing; + + use super::ObjectLifecycleExt; + use crate::result::KResult; + + fn test_object() -> Object { + Object::SymmetricKey(SymmetricKey { + key_block: KeyBlock { + key_format_type: KeyFormatType::Raw, + key_value: Some(KeyValue::Structure { + key_material: KeyMaterial::ByteString(Zeroizing::new(vec![1, 2, 3, 4])), + attributes: Some(Attributes::default()), + }), + key_compression_type: None, + cryptographic_algorithm: Some(CryptographicAlgorithm::AES), + cryptographic_length: Some(256), + key_wrapping_data: None, + }, + }) } - // Phase 2 — Standard database path. - let owm = select_unique_key_for_operation( - Op::OP_NAME, - &uids, - unique_identifier, - Op::KMIP_OP, - kms, - user, - |owm| Ok(Op::is_key_eligible(owm, kms.vendor_id())), - ) - .await - .map_err(|e| Op::map_selection_error(e, unique_identifier, user))?; - - // Lifecycle enforcement: always check process window. - owm.check_process_window()?; - - Ok(ResolvedKey::Local(Box::new(owm))) -} + #[test] + fn test_effective_state_preactive_with_past_activation_date() -> KResult<()> { + let attrs = Attributes { + state: Some(State::PreActive), + activation_date: Some(time_normalize()? - Duration::hours(1)), + ..Default::default() + }; -/// Unwrap a key (if wrapped) and enforce the KMIP algorithm policy. -/// -/// This is the generic second-stage enforcement step shared by all cryptographic -/// operations after key resolution. It: -/// 1. Unwraps the key material (Certificates are never unwrapped). -/// 2. Validates the unwrapped key against the server's configured algorithm policy. -/// -/// # Security -/// -/// The operation is performed **in-place** on `owm`, replacing the wrapped key material -/// with plaintext. Callers that later persist `owm` (e.g. via `decrement_usage_limits`) -/// **MUST clone** before calling this function and pass the original (still-wrapped) -/// `owm` to the persistence path. Failing to do so silently stores the plaintext key -/// in the database, defeating KEK encryption at rest. -pub(crate) async fn unwrap_and_enforce_policy( - kms: &KMS, - owm: &mut ObjectWithMetadata, - op_name: &str, - user: &str, -) -> KResult<()> { - use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_objects::Object; - if !matches!(owm.object(), Object::Certificate { .. }) { - owm.set_object(kms.get_unwrapped(owm.id(), owm.object(), user).await?); + let owm = ObjectWithMetadata::new( + "test-id".to_owned(), + test_object(), + "owner".to_owned(), + State::PreActive, + attrs, + ); + + assert_eq!(owm.effective_state(), State::Active); + Ok(()) } - crate::core::operations::algorithm_policy::enforce_kmip_algorithm_policy_for_retrieved_key( - &kms.params, - op_name, - owm.id(), - owm, - ) -} -// ─── UsageLimits helpers ───────────────────────────────────────────────────── + #[test] + fn test_effective_state_preactive_with_future_activation_date() -> KResult<()> { + let attrs = Attributes { + state: Some(State::PreActive), + activation_date: Some(time_normalize()? + Duration::hours(1)), + ..Default::default() + }; -/// Enforce `UsageLimits` before a cryptographic operation. -/// -/// Returns `Err(Permission_Denied / "DENIED")` when the key's remaining usage -/// budget is insufficient for the requested `data_len` bytes. -/// -/// For `Byte`-based limits the check is data-length-aware; for `Object`, `Block`, -/// and `Operation` units the limit simply cannot be zero. -pub(crate) fn enforce_usage_limits(owm: &ObjectWithMetadata, data_len: usize) -> KResult<()> { - let Some(ul) = owm.attributes().usage_limits.as_ref() else { - return Ok(()); - }; - match ul.usage_limits_unit { - UsageLimitsUnit::Byte => { - let needed = i64::try_from(data_len).unwrap_or(i64::MAX); - if ul.usage_limits_total < needed { - return Err(KmsError::Kmip21Error( - ErrorReason::Permission_Denied, - "DENIED".to_owned(), - )); - } - } - UsageLimitsUnit::Object | UsageLimitsUnit::Block | UsageLimitsUnit::Operation => { - if ul.usage_limits_total <= 0 { - return Err(KmsError::Kmip21Error( - ErrorReason::Permission_Denied, - "DENIED".to_owned(), - )); - } - } + let owm = ObjectWithMetadata::new( + "test-id".to_owned(), + test_object(), + "owner".to_owned(), + State::PreActive, + attrs, + ); + + assert_eq!(owm.effective_state(), State::PreActive); + Ok(()) } - Ok(()) -} -/// Decrement and persist `UsageLimits` after a successful cryptographic operation. -/// -/// For `Byte`-based limits, `data_len` bytes are subtracted from the remaining total. -/// For `Object`, `Block`, and `Operation` units, one unit is consumed. -/// -/// Persistence (database UPDATE) is skipped when no usage limits are set on the key, -/// avoiding unnecessary row-level lock contention on the hot path. -pub(crate) async fn decrement_usage_limits( - kms: &KMS, - owm: &mut ObjectWithMetadata, - op_name: &str, - data_len: usize, -) -> KResult<()> { - let mut decremented = false; - if let Some(ref mut ul) = owm.attributes_mut().usage_limits { - match ul.usage_limits_unit { - UsageLimitsUnit::Byte => { - let consumed = i64::try_from(data_len).unwrap_or(i64::MAX); - ul.usage_limits_total = (ul.usage_limits_total - consumed).max(0); - decremented = true; - } - UsageLimitsUnit::Object | UsageLimitsUnit::Block | UsageLimitsUnit::Operation => { - ul.usage_limits_total = (ul.usage_limits_total - 1).max(0); - decremented = true; - } - } + #[test] + fn test_effective_state_preactive_without_activation_date() { + let attrs = Attributes { + state: Some(State::PreActive), + ..Default::default() + }; + + let owm = ObjectWithMetadata::new( + "test-id".to_owned(), + test_object(), + "owner".to_owned(), + State::PreActive, + attrs, + ); + + assert_eq!(owm.effective_state(), State::PreActive); } - if decremented { - let attributes = owm.attributes().clone(); - kms.database - .update_object(owm.id(), owm.object(), &attributes, None) - .await - .map_err(|e| { - KmsError::ServerError(format!( - "{op_name}: failed to persist updated usage limits: {e}" - )) - })?; + + #[test] + fn test_setup_object_lifecycle_past_date_gives_active() -> KResult<()> { + let mut obj = test_object(); + let past = time_normalize()? - Duration::hours(1); + let attrs = obj.setup_with_lifecycle(ObjectType::SymmetricKey, Some(past))?; + assert_eq!(attrs.state, Some(State::Active)); + Ok(()) + } + + #[test] + fn test_setup_object_lifecycle_no_date_gives_preactive() -> KResult<()> { + let mut obj = test_object(); + let attrs = obj.setup_with_lifecycle(ObjectType::SymmetricKey, None)?; + assert_eq!(attrs.state, Some(State::PreActive)); + Ok(()) + } + + #[test] + fn test_setup_object_lifecycle_future_date_gives_preactive() -> KResult<()> { + let mut obj = test_object(); + let future = time_normalize()? + Duration::hours(1); + let attrs = obj.setup_with_lifecycle(ObjectType::SymmetricKey, Some(future))?; + assert_eq!(attrs.state, Some(State::PreActive)); + assert_eq!(attrs.activation_date, Some(future)); + Ok(()) + } + + #[test] + fn test_effective_state_active_remains_active() { + let attrs = Attributes { + state: Some(State::Active), + ..Default::default() + }; + + let owm = ObjectWithMetadata::new( + "test-id".to_owned(), + test_object(), + "owner".to_owned(), + State::Active, + attrs, + ); + + assert_eq!(owm.effective_state(), State::Active); + } + + #[test] + fn test_effective_state_active_with_past_deactivation_date() -> KResult<()> { + let attrs = Attributes { + state: Some(State::Active), + deactivation_date: Some(time_normalize()? - Duration::hours(1)), + ..Default::default() + }; + + let owm = ObjectWithMetadata::new( + "test-id".to_owned(), + test_object(), + "owner".to_owned(), + State::Active, + attrs, + ); + + assert_eq!(owm.effective_state(), State::Deactivated); + Ok(()) + } + + #[test] + fn test_effective_state_active_with_future_deactivation_date() -> KResult<()> { + let attrs = Attributes { + state: Some(State::Active), + deactivation_date: Some(time_normalize()? + Duration::hours(1)), + ..Default::default() + }; + + let owm = ObjectWithMetadata::new( + "test-id".to_owned(), + test_object(), + "owner".to_owned(), + State::Active, + attrs, + ); + + assert_eq!(owm.effective_state(), State::Active); + Ok(()) } - Ok(()) } diff --git a/crate/server/src/core/operations/key_ops/mod.rs b/crate/server/src/core/operations/key_ops/mod.rs index 18c8236e80..9d27e2529e 100644 --- a/crate/server/src/core/operations/key_ops/mod.rs +++ b/crate/server/src/core/operations/key_ops/mod.rs @@ -1,318 +1,10 @@ -mod crypto_op; +//! Key operations: lifecycle, authorization, resolution, crypto dispatch, and usage limits. +//! +//! Crypto dispatch and key selection are now methods on [`KMS`](crate::core::KMS). +//! This module re-exports the traits, enums, and lifecycle utility. -use cosmian_kms_server_database::{ - Database, - reexport::{ - cosmian_kmip::{ - kmip_0::kmip_types::{ErrorReason, State}, - kmip_2_1::{ - KmipOperation, - kmip_attributes::Attributes, - kmip_objects::{Object, ObjectType}, - }, - time_normalize, - }, - cosmian_kms_interfaces::ObjectWithMetadata, - }, -}; -pub(crate) use crypto_op::{CryptoOpSpec, has_usage_mask, perform_crypto_operation}; -use time::OffsetDateTime; +pub(crate) mod crypto_op; -use super::digest::digest; -use crate::{ - core::{KMS, uid_utils::has_prefix}, - error::KmsError, - result::KResult, -}; +// ─── Re-exports (stable external API) ─────────────────────────────────────── -/// Initialize lifecycle attributes on a newly created or imported object. -/// -/// Sets state (`PreActive` or `Active` based on `requested_activation_date`), digest, -/// `initial_date`, `original_creation_date`, `last_change_date`, `activation_date` (if `Active`), -/// and `object_type` on the object's attributes. Returns a clone of the final attributes. -pub(crate) fn setup_object_lifecycle( - object: &mut Object, - object_type: ObjectType, - requested_activation_date: Option, -) -> KResult { - let now = time_normalize()?; - let digest = digest(object)?; - let attributes = object.attributes_mut()?; - - let activation_allows_active = requested_activation_date.is_some_and(|d| d <= now); - let state = if activation_allows_active { - State::Active - } else { - State::PreActive - }; - - attributes.state = Some(state); - attributes.digest = digest; - attributes.object_type = Some(object_type); - attributes.initial_date = Some(now); - attributes.original_creation_date = Some(now); - attributes.last_change_date = Some(now); - if state == State::Active { - attributes.activation_date = Some(now); - } - - Ok(attributes.clone()) -} - -// ─── Extension trait: ObjectWithMetadata ───────────────────────────────────── - -/// Server-side operations on [`ObjectWithMetadata`] that depend on KMS error types. -pub(crate) trait ObjectWithMetadataOps { - /// Determine the effective state based on stored state and `activation_date`. - /// - /// A `PreActive` object whose `activation_date` has passed is treated as `Active`. - fn get_effective_state(&self) -> KResult; - - /// Enforce the KMIP process-window constraints. - /// - /// An Active key whose current time is before `ProcessStartDate` or after - /// `ProtectStopDate` is rejected with `Wrong_Key_Lifecycle_State`. - fn check_process_window(&self) -> KResult<()>; - - /// Check whether `user` is allowed to perform `operation` on this object. - /// - /// Returns `true` if the user is the owner or has been explicitly granted - /// the requested operation. - async fn user_can_perform_operation( - &self, - user: &str, - operation: &KmipOperation, - kms: &KMS, - ) -> KResult; -} - -impl ObjectWithMetadataOps for ObjectWithMetadata { - fn get_effective_state(&self) -> KResult { - let stored_state = self.state(); - - // Only PreActive objects can auto-transition to Active - if stored_state != State::PreActive { - return Ok(stored_state); - } - - // Check if there's an activation_date set - let activation_date = self.attributes().activation_date.or_else(|| { - // Fallback to object's attributes if not in metadata - self.object() - .attributes() - .ok() - .and_then(|attrs| attrs.activation_date) - }); - - if let Some(activation_date) = activation_date { - let now = time_normalize()?; - if activation_date <= now { - // The activation date has passed, treat as Active - return Ok(State::Active); - } - } - - // No activation_date or it's in the future, remain PreActive - Ok(State::PreActive) - } - - fn check_process_window(&self) -> KResult<()> { - if self.get_effective_state()? == State::Active { - if let Ok(attrs) = self.object().attributes() { - let now = time_normalize()?; - let too_early = attrs.process_start_date.is_some_and(|d| now < d); - let too_late = attrs.protect_stop_date.is_some_and(|d| now > d); - if too_early || too_late { - return Err(KmsError::Kmip21Error( - ErrorReason::Wrong_Key_Lifecycle_State, - "DENIED".to_owned(), - )); - } - } - } - Ok(()) - } - - async fn user_can_perform_operation( - &self, - user: &str, - operation: &KmipOperation, - kms: &KMS, - ) -> KResult { - if user == self.owner() { - return Ok(true); - } - let permissions = kms - .database - .list_user_operations_on_object(self.id(), user, false) - .await?; - Ok(permissions.contains(operation)) - } -} - -// ─── Extension trait: Database ─────────────────────────────────────────────── - -/// Server-side authorization check on [`Database`]. -pub(crate) trait DatabaseOps { - /// Check whether a user is authorized to perform `operation` on the object - /// identified by `uid`. - /// - /// The user is authorized if they own the object, or have been granted the - /// specific `operation` **or** `Get` (which implies read-level access). - /// For HSM keys (prefix-based UIDs), the `Get` wildcard is **not** applied. - async fn is_user_authorized_for_operation( - &self, - uid: &str, - user: &str, - operation: KmipOperation, - ) -> KResult; -} - -impl DatabaseOps for Database { - async fn is_user_authorized_for_operation( - &self, - uid: &str, - user: &str, - operation: KmipOperation, - ) -> KResult { - if self.is_object_owned_by(uid, user).await? { - return Ok(true); - } - let ops = self - .list_user_operations_on_object(uid, user, false) - .await?; - - // HSM keys: each operation must be explicitly granted — no Get wildcard - if has_prefix(uid).is_some() { - return Ok(ops.iter().any(|p| *p == operation)); - } - - Ok(ops - .iter() - .any(|p| *p == operation || *p == KmipOperation::Get)) - } -} - -/// Record metrics for a cascading (linked-object) operation. -/// -/// Used by `destroy` and `revoke` when they cascade to related keys. -pub(crate) fn record_cascading_metrics( - op_name: &str, - op_start: std::time::Instant, - kms: &KMS, - user: &str, -) { - if let Some(metrics) = &kms.metrics { - metrics.record_kmip_operation(op_name, user); - metrics.record_kmip_operation_duration(op_name, op_start.elapsed().as_secs_f64()); - } -} - -#[cfg(test)] -#[allow(clippy::panic_in_result_fn)] -mod tests { - use cosmian_kms_server_database::reexport::cosmian_kmip::{ - kmip_0::kmip_types::State, - kmip_2_1::{ - kmip_attributes::Attributes, - kmip_data_structures::{KeyBlock, KeyValue}, - kmip_objects::{Object, SymmetricKey}, - kmip_types::{CryptographicAlgorithm, KeyFormatType}, - }, - }; - use time::Duration; - use zeroize::Zeroizing; - - use super::*; - - fn test_object() -> Object { - Object::SymmetricKey(SymmetricKey { - key_block: KeyBlock { - key_format_type: KeyFormatType::Raw, - key_value: Some(KeyValue::ByteString(Zeroizing::new(vec![1, 2, 3, 4]))), - key_compression_type: None, - cryptographic_algorithm: Some(CryptographicAlgorithm::AES), - cryptographic_length: Some(256), - key_wrapping_data: None, - }, - }) - } - - #[test] - fn test_effective_state_preactive_with_past_activation_date() -> KResult<()> { - let attrs = Attributes { - state: Some(State::PreActive), - activation_date: Some(time_normalize()? - Duration::hours(1)), - ..Default::default() - }; - - let owm = ObjectWithMetadata::new( - "test-id".to_owned(), - test_object(), - "owner".to_owned(), - State::PreActive, - attrs, - ); - - assert_eq!(owm.get_effective_state()?, State::Active); - Ok(()) - } - - #[test] - fn test_effective_state_preactive_with_future_activation_date() -> KResult<()> { - let attrs = Attributes { - state: Some(State::PreActive), - activation_date: Some(time_normalize()? + Duration::hours(1)), - ..Default::default() - }; - - let owm = ObjectWithMetadata::new( - "test-id".to_owned(), - test_object(), - "owner".to_owned(), - State::PreActive, - attrs, - ); - - assert_eq!(owm.get_effective_state()?, State::PreActive); - Ok(()) - } - - #[test] - fn test_effective_state_preactive_without_activation_date() -> KResult<()> { - let attrs = Attributes { - state: Some(State::PreActive), - ..Default::default() - }; - - let owm = ObjectWithMetadata::new( - "test-id".to_owned(), - test_object(), - "owner".to_owned(), - State::PreActive, - attrs, - ); - - assert_eq!(owm.get_effective_state()?, State::PreActive); - Ok(()) - } - - #[test] - fn test_effective_state_active_remains_active() -> KResult<()> { - let attrs = Attributes { - state: Some(State::Active), - ..Default::default() - }; - - let owm = ObjectWithMetadata::new( - "test-id".to_owned(), - test_object(), - "owner".to_owned(), - State::Active, - attrs, - ); - - assert_eq!(owm.get_effective_state()?, State::Active); - Ok(()) - } -} +pub(crate) use crypto_op::{CryptoOpSpec, KeySelectionSpec, KeysetMode, ObjectLifecycleExt}; diff --git a/crate/server/src/core/operations/mac.rs b/crate/server/src/core/operations/mac.rs index f2df7cc3bc..873e4d170e 100644 --- a/crate/server/src/core/operations/mac.rs +++ b/crate/server/src/core/operations/mac.rs @@ -1,6 +1,6 @@ use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ - kmip_0::kmip_types::HashingAlgorithm, + kmip_0::kmip_types::{HashingAlgorithm, State}, kmip_2_1::{ KmipOperation, kmip_attributes::Attributes, @@ -18,7 +18,7 @@ use openssl::{md::Md, md_ctx::MdCtx, pkey::PKey}; use crate::{ core::{ KMS, - operations::{CryptoOpSpec, perform_crypto_operation}, + operations::{CryptoOpSpec, KeysetMode}, }, error::KmsError, kms_bail, @@ -123,6 +123,15 @@ impl CryptoOpSpec for MacVerifyOp { Some(&request.unique_identifier) } + fn keyset_mode() -> KeysetMode { + KeysetMode::TryEach + } + + /// `MACVerify` accepts Active, Deactivated, and Compromised keys per KMIP 2.1 §3.31. + fn accepted_states() -> &'static [State] { + &[State::Active, State::Deactivated, State::Compromised] + } + fn usage_data_len(request: &Self::Request) -> usize { request.data.len() } @@ -196,7 +205,7 @@ impl CryptoOpSpec for MacVerifyOp { pub(crate) async fn mac(kms: &KMS, request: MAC, user: &str) -> KResult { trace!("uid={:?}", request.unique_identifier); - Box::pin(perform_crypto_operation::(kms, request, user)).await + Box::pin(kms.perform_crypto_operation::(request, user)).await } pub(crate) async fn mac_verify( @@ -205,7 +214,7 @@ pub(crate) async fn mac_verify( user: &str, ) -> KResult { trace!("uid={}", request.unique_identifier); - Box::pin(perform_crypto_operation::(kms, request, user)).await + Box::pin(kms.perform_crypto_operation::(request, user)).await } // ─── Helper functions ──────────────────────────────────────────────────────── @@ -368,7 +377,6 @@ mod tests { None, )?, "user", - None, ) .await? .unique_identifier, diff --git a/crate/server/src/core/operations/message.rs b/crate/server/src/core/operations/message.rs index 855a7c0b97..b120554612 100644 --- a/crate/server/src/core/operations/message.rs +++ b/crate/server/src/core/operations/message.rs @@ -106,6 +106,7 @@ pub(crate) async fn message( Some(request.request_header.protocol_version), )) .await; + // 3) Optionally enforce MaximumResponseSize for Query let forced_size_error = enforce_max_response_size_for_query(&response_operation, remaining_max_response_size)?; @@ -338,6 +339,7 @@ fn get_operation_name(operation: &Operation) -> &'static str { Operation::MAC(_) => "MAC", Operation::Query(_) => "Query", Operation::Register(_) => "Register", + Operation::ReCertify(_) => "ReCertify", Operation::ReKey(_) => "ReKey", Operation::ReKeyKeyPair(_) => "ReKeyKeyPair", Operation::Revoke(_) => "Revoke", @@ -362,8 +364,6 @@ async fn process_operation( let start_time = std::time::Instant::now(); - let privileged_users = kms.params.privileged_users.clone(); - // Process the operation and capture the result let result: Result = async { Ok(match request_operation { @@ -433,19 +433,21 @@ async fn process_operation( Operation::CheckResponse(check(kms, kmip_request, user).await?) } Operation::Certify(kmip_request) => Operation::CertifyResponse( - kms.certify(*kmip_request, user, privileged_users) + kms.certify(*kmip_request, user) .await?, ), Operation::Create(kmip_request) => Operation::CreateResponse( - kms.create(kmip_request, user, privileged_users) + kms.create(kmip_request, user) .await?, ), Operation::CreateKeyPair(kmip_request) => Operation::CreateKeyPairResponse( - kms.create_key_pair(*kmip_request, user, privileged_users) + kms.create_key_pair(*kmip_request, user) .await?, ), Operation::Decrypt(kmip_request) => { - Operation::DecryptResponse(kms.decrypt(*kmip_request, user).await?) + Operation::DecryptResponse( + crate::core::operations::decrypt(kms, *kmip_request, user).await?, + ) } Operation::DeleteAttribute(kmip_request) => Operation::DeleteAttributeResponse( kms.delete_attribute(kmip_request, user).await?, @@ -460,7 +462,9 @@ async fn process_operation( kms.discover_versions(kmip_request, user).await, ), Operation::Encrypt(kmip_request) => { - Operation::EncryptResponse(kms.encrypt(*kmip_request, user).await?) + Operation::EncryptResponse( + crate::core::operations::encrypt(kms, *kmip_request, user).await?, + ) } Operation::Export(kmip_request) => { Operation::ExportResponse(Box::new(kms.export(kmip_request, user).await?)) @@ -475,30 +479,38 @@ async fn process_operation( Operation::HashResponse(kms.hash(kmip_request, user).await?) } Operation::Import(kmip_request) => Operation::ImportResponse( - kms.import(*kmip_request, user, privileged_users) + kms.import(*kmip_request, user) .await?, ), Operation::Locate(kmip_request) => { Operation::LocateResponse(kms.locate(*kmip_request, user).await?) } Operation::MAC(kmip_request) => { - Operation::MACResponse(kms.mac(kmip_request, user).await?) + Operation::MACResponse( + crate::core::operations::mac(kms, kmip_request, user).await?, + ) } - Operation::MACVerify(kmip_request) => Operation::MACVerifyResponse( - crate::core::operations::mac::mac_verify(kms, kmip_request, user).await?, - ), + Operation::MACVerify(kmip_request) => { + Operation::MACVerifyResponse( + crate::core::operations::mac_verify(kms, kmip_request, user).await?, + ) + } Operation::Query(kmip_request) => { Operation::QueryResponse(Box::new(kms.query(kmip_request).await?)) } Operation::Register(kmip_request) => Operation::RegisterResponse( - kms.register(*kmip_request, user, privileged_users) + kms.register(*kmip_request, user) + .await?, + ), + Operation::ReCertify(kmip_request) => Operation::ReCertifyResponse( + kms.recertify(*kmip_request, user) .await?, ), Operation::ReKey(kmip_request) => { - Operation::ReKeyResponse(kms.rekey(kmip_request, user, privileged_users).await?) + Operation::ReKeyResponse(kms.rekey(kmip_request, user).await?) } Operation::ReKeyKeyPair(kmip_request) => Operation::ReKeyKeyPairResponse( - kms.rekey_keypair(*kmip_request, user, privileged_users) + kms.rekey_keypair(*kmip_request, user) .await?, ), Operation::Revoke(kmip_request) => { @@ -508,11 +520,15 @@ async fn process_operation( kms.set_attribute(kmip_request, user).await?, ), Operation::Sign(kmip_request) => { - Operation::SignResponse(kms.sign(kmip_request, user).await?) + Operation::SignResponse( + crate::core::operations::sign(kms, kmip_request, user).await?, + ) + } + Operation::SignatureVerify(kmip_request) => { + Operation::SignatureVerifyResponse( + crate::core::operations::signature_verify(kms, kmip_request, user).await?, + ) } - Operation::SignatureVerify(kmip_request) => Operation::SignatureVerifyResponse( - kms.signature_verify(kmip_request, user).await?, - ), Operation::Validate(kmip_request) => { Operation::ValidateResponse(kms.validate(kmip_request, user).await?) } @@ -537,6 +553,7 @@ async fn process_operation( | Operation::MACResponse(_) | Operation::MACVerifyResponse(_) | Operation::QueryResponse(_) + | Operation::ReCertifyResponse(_) | Operation::RegisterResponse(_) | Operation::ReKeyKeyPairResponse(_) | Operation::ReKeyResponse(_) diff --git a/crate/server/src/core/operations/mod.rs b/crate/server/src/core/operations/mod.rs index 4c129efaeb..12eb987e64 100644 --- a/crate/server/src/core/operations/mod.rs +++ b/crate/server/src/core/operations/mod.rs @@ -1,5 +1,6 @@ mod activate; mod attributes; +mod auto_rotate; mod certify; mod check; mod create; @@ -7,7 +8,7 @@ mod create_key_pair; mod decrypt; pub(crate) mod derive_key; mod destroy; -mod digest; +pub(crate) mod digest; mod discover_versions; mod dispatch; mod encrypt; @@ -16,16 +17,15 @@ mod export_get; mod get; mod hash; mod import; -mod key_ops; +pub(crate) mod key_ops; mod locate; mod mac; mod message; mod pkcs11; mod query; +mod recertify; mod register; mod rekey; -mod rekey_common; -mod rekey_keypair; mod revoke; mod rng_retrieve; mod rng_seed; @@ -37,6 +37,7 @@ pub(crate) use activate::activate; pub(crate) use attributes::{ add_attribute, delete_attribute, get_attributes, modify_attribute, set_attribute, }; +pub(crate) use auto_rotate::{dispatch_renewal_warnings, run_auto_rotation}; pub(crate) use certify::certify; pub(crate) use check::check; pub(crate) use create::create; @@ -61,9 +62,9 @@ pub(crate) use pkcs11::pkcs11; pub(crate) use query::query; pub(crate) use register::register; pub(crate) mod algorithm_policy; -pub(crate) use key_ops::{CryptoOpSpec, has_usage_mask, perform_crypto_operation}; -pub(crate) use rekey::rekey; -pub(crate) use rekey_keypair::rekey_keypair; +pub(crate) use key_ops::{CryptoOpSpec, KeysetMode}; +pub(crate) use recertify::recertify; +pub(crate) use rekey::{rekey, rekey_keypair}; #[cfg(feature = "non-fips")] pub(crate) use revoke::recursively_revoke_key; pub(crate) use revoke::revoke_operation; diff --git a/crate/server/src/core/operations/recertify.rs b/crate/server/src/core/operations/recertify.rs new file mode 100644 index 0000000000..bc199bc8b1 --- /dev/null +++ b/crate/server/src/core/operations/recertify.rs @@ -0,0 +1,338 @@ +//! KMIP `ReCertify` — certificate rotation with new UID and replacement links. +//! +//! This implements the [`RekeyOperation`] trait for certificate renewal/rotation. +//! Unlike the standard `Certify` operation (which replaces in-place via Upsert), +//! `ReCertify` creates a **new certificate with a fresh UID** and links it to the +//! old certificate via `ReplacedObject` / `ReplacementObject` links. +//! +//! The old certificate remains Active but is marked with a `ReplacementObjectLink` +//! pointing to the new certificate. Keys linked to the old certificate are updated +//! to point to the new certificate via their `CertificateLink`. + +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::State, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attributes, + kmip_data_structures::KeyWrappingSpecification, + kmip_objects::ObjectType, + kmip_operations::{Certify, ReCertify, ReCertifyResponse}, + kmip_types::{LinkType, LinkedObjectIdentifier, UniqueIdentifier}, + }, + time_normalize, + }, + cosmian_kms_interfaces::AtomicOperation, +}; +use cosmian_logger::trace; + +use super::rekey::{RekeyOperation, ReplacementObject, RotationCandidate, execute_rekey}; +use crate::{ + core::{ + KMS, + operations::certify::{build_and_sign_certificate, get_issuer, get_subject}, + retrieve_object_utils::retrieve_object_for_operation, + }, + error::KmsError, + kms_bail, + result::KResult, +}; + +/// Implementor of [`RekeyOperation`] for certificate rotation (`ReCertify`). +pub(crate) struct CertificateRekey { + /// The `offset` from the `ReCertify` request (date computation per KMIP §6.1.45). + offset: Option, +} + +/// KMIP `ReCertify` operation — certificate rotation with new UID. +/// +/// Creates a new certificate for the same subject/issuer, assigns a fresh UID, +/// and links old → new via `ReplacementObjectLink`. Keys referencing the old +/// certificate are updated to point to the new one. +pub(crate) async fn recertify( + kms: &KMS, + request: ReCertify, + owner: &str, +) -> KResult { + trace!("ReCertify: {}", serde_json::to_string(&request)?); + Box::pin(execute_rekey( + &CertificateRekey { + offset: request.offset, + }, + kms, + &request, + owner, + )) + .await +} + +impl RekeyOperation for CertificateRekey { + type Candidates = [RotationCandidate; 1]; + type Replacements = [ReplacementObject; 1]; + type Request = ReCertify; + type Response = ReCertifyResponse; + + async fn validate( + &self, + kms: &KMS, + request: &ReCertify, + user: &str, + ) -> KResult<[RotationCandidate; 1]> { + KMS::reject_protection_storage_masks(request.protection_storage_masks.is_some())?; + + kms.enforce_create_permission(user).await?; + + let uid = request + .unique_identifier + .as_ref() + .ok_or_else(|| { + KmsError::InvalidRequest( + "ReCertify: unique_identifier of the certificate to rotate is required" + .to_owned(), + ) + })? + .as_str() + .ok_or_else(|| { + KmsError::InvalidRequest( + "ReCertify: unique_identifier must be a text string".to_owned(), + ) + })?; + + let owm = Box::pin(retrieve_object_for_operation( + uid, + KmipOperation::Certify, + kms, + user, + )) + .await?; + + if owm.object().object_type() != ObjectType::Certificate { + kms_bail!(KmsError::InvalidRequest(format!( + "ReCertify: object {uid} is not a Certificate" + ))); + } + + if owm.state() != State::Active && owm.state() != State::Deactivated { + kms_bail!(KmsError::InvalidRequest(format!( + "ReCertify: certificate '{uid}' is in state '{}' — only Active or Deactivated \ + certificates can be renewed", + owm.state() + ))); + } + + Ok([RotationCandidate { + owm, + uid: uid.to_owned(), + }]) + } + + async fn generate_replacement( + &self, + kms: &KMS, + candidates: &[RotationCandidate; 1], + ) -> KResult<[ReplacementObject; 1]> { + let [candidate] = candidates; + // Certificates are never members of a named keyset — always use a fresh UUID. + let new_uid = UniqueIdentifier::rotation_successor(None, None); + + // Build a Certify request that references the existing certificate for renewal. + // We pass the old certificate's UID so `get_subject` produces a `Subject::Certificate`. + let certify_request = Certify { + unique_identifier: Some(UniqueIdentifier::TextString(candidate.uid.clone())), + certificate_request_type: None, + certificate_request_value: None, + attributes: Some(Attributes { + // The new certificate UID is set in the attributes so `get_subject` uses it. + unique_identifier: Some(UniqueIdentifier::TextString(new_uid.clone())), + // Preserve issuer links from the old certificate's attributes + ..candidate.owm.attributes().clone() + }), + protection_storage_masks: None, + }; + + // Resolve subject (will produce Subject::Certificate from existing cert) + let owner = candidate.owm.owner(); + let subject = Box::pin(get_subject(kms, &certify_request, owner)).await?; + // Resolve issuer from the old certificate's attributes + let issuer = Box::pin(get_issuer(&subject, kms, &certify_request, owner)).await?; + // Build and sign the new certificate + let (certificate_object, tags, attributes) = + build_and_sign_certificate(kms.vendor_id(), &issuer, &subject, certify_request)?; + + Ok([ReplacementObject { + new_uid, + old_uid: candidate.uid.clone(), + object: certificate_object, + attributes, + tags, + // Certificates don't wrap anything, no dependant re-wrapping needed. + rewrap_to: None, + }]) + } + + fn prepare_attributes( + &self, + kms: &KMS, + candidates: &[RotationCandidate; 1], + replacements: &mut [ReplacementObject; 1], + ) -> KResult<()> { + let [candidate] = candidates; + let old_attrs = candidate.owm.attributes(); + let [replacement] = replacements; + + // Use shared date computation for offset-based activation/deactivation + let base_attrs = old_attrs.for_replacement(&replacement.old_uid, self.offset)?; + replacement.attributes.activation_date = base_attrs.activation_date; + replacement.attributes.deactivation_date = base_attrs.deactivation_date; + replacement.attributes.initial_date = base_attrs.initial_date; + replacement.attributes.last_change_date = base_attrs.last_change_date; + + // Compute state based on activation_date (certificates bypass setup_object_lifecycle) + let now = time_normalize()?; + let state = if replacement + .attributes + .activation_date + .is_some_and(|d| d <= now) + { + State::Active + } else { + State::PreActive + }; + replacement.attributes.state = Some(state); + + // Set ReplacedObjectLink on the new certificate pointing to the old one + replacement.attributes.set_link( + LinkType::ReplacedObjectLink, + LinkedObjectIdentifier::TextString(replacement.old_uid.clone()), + ); + + // Preserve links to associated keys from the old certificate + for link_type in [LinkType::PublicKeyLink, LinkType::PrivateKeyLink] { + if let Some(link) = old_attrs.get_link(link_type) { + replacement.attributes.set_link(link_type, link); + } + } + + // Set rotation metadata + vendor tags + replacement + .attributes + .set_rotation_metadata_from(old_attrs)?; + replacement.tags.extend(old_attrs.get_tags(kms.vendor_id())); + + Ok(()) + } + + async fn rewrap_new_objects( + &self, + _kms: &KMS, + _user: &str, + _replacements: &mut [ReplacementObject; 1], + _wrap_specs: &[Option], + ) -> KResult<()> { + // Certificates are never wrapped — no-op. + Ok(()) + } + + async fn finalize_dependants( + &self, + kms: &KMS, + user: &str, + candidates: &[RotationCandidate; 1], + replacements: &[ReplacementObject; 1], + ) -> KResult<()> { + let [candidate] = candidates; + let [replacement] = replacements; + + // Phase 2: Update the old certificate with ReplacementObjectLink + let mut old_object = candidate.owm.object().clone(); + let mut old_attributes = candidate.owm.attributes().clone(); + old_attributes.retire_for_replacement(&replacement.new_uid)?; + if let Ok(obj_attrs) = old_object.attributes_mut() { + obj_attrs.retire_for_replacement(&replacement.new_uid)?; + } + + let mut operations = vec![AtomicOperation::UpdateObject(( + candidate.uid.clone(), + old_object, + old_attributes, + None, + ))]; + + // Relink keys: update CertificateLink on linked PK/SK to point to new cert UID + relink_keys_to_new_certificate( + kms, + user, + &candidate.uid, + candidate.owm.attributes(), + &replacement.new_uid, + &mut operations, + ) + .await?; + + kms.database.atomic(user, &operations).await?; + Ok(()) + } + + fn build_response(&self, replacements: &[ReplacementObject; 1]) -> ReCertifyResponse { + let [replacement] = replacements; + ReCertifyResponse { + unique_identifier: UniqueIdentifier::TextString(replacement.new_uid.clone()), + } + } +} + +/// Update `CertificateLink` on any keys that reference the old certificate +/// to point to the new certificate UID. +async fn relink_keys_to_new_certificate( + kms: &KMS, + _user: &str, + old_cert_uid: &str, + old_cert_attrs: &Attributes, + new_cert_uid: &str, + operations: &mut Vec, +) -> KResult<()> { + // Collect key UIDs linked from the old certificate + let key_uids: Vec = [LinkType::PublicKeyLink, LinkType::PrivateKeyLink] + .iter() + .filter_map(|lt| old_cert_attrs.get_link(*lt).map(|l| l.to_string())) + .collect(); + + for key_uid in key_uids { + if let Some(op) = relink_single_key(kms, &key_uid, old_cert_uid, new_cert_uid).await? { + operations.push(op); + } + } + Ok(()) +} + +/// Update a single key's `CertificateLink` if it points to the old certificate. +async fn relink_single_key( + kms: &KMS, + key_uid: &str, + old_cert_uid: &str, + new_cert_uid: &str, +) -> KResult> { + let Some(key_owm) = kms.database.retrieve_object(key_uid).await? else { + return Ok(None); + }; + let Some(cert_link) = key_owm.attributes().get_link(LinkType::CertificateLink) else { + return Ok(None); + }; + if cert_link.to_string() != old_cert_uid { + return Ok(None); + } + + let mut key_object = key_owm.object().clone(); + let mut key_attrs = key_owm.attributes().clone(); + let new_link = LinkedObjectIdentifier::TextString(new_cert_uid.to_owned()); + key_attrs.set_link(LinkType::CertificateLink, new_link.clone()); + if let Ok(obj_attrs) = key_object.attributes_mut() { + obj_attrs.set_link(LinkType::CertificateLink, new_link); + } + Ok(Some(AtomicOperation::UpdateObject(( + key_uid.to_owned(), + key_object, + key_attrs, + None, + )))) +} diff --git a/crate/server/src/core/operations/register.rs b/crate/server/src/core/operations/register.rs index 363a9fe45f..bbc95c8905 100644 --- a/crate/server/src/core/operations/register.rs +++ b/crate/server/src/core/operations/register.rs @@ -1,5 +1,4 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ - self, kmip_0::kmip_types::State, kmip_2_1::{ kmip_objects::ObjectType, @@ -18,7 +17,6 @@ use crate::{ process_certificate, process_private_key, process_public_key, process_secret_data, process_symmetric_key, }, - retrieve_object_utils::user_has_permission, }, error::KmsError, kms_bail, @@ -29,31 +27,10 @@ pub(crate) async fn register( kms: &KMS, mut request: Register, owner: &str, - - privileged_users: Option>, ) -> KResult { trace!("{request}"); - if request.protection_storage_masks.is_some() { - kms_bail!(KmsError::UnsupportedPlaceholder) - } - - // To register an object, check that the user has `Create` access right - // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users.clone() { - let has_permission = user_has_permission( - owner, - None, - &cosmian_kmip::kmip_2_1::KmipOperation::Create, - kms, - ) - .await?; - - if !has_permission && !users.iter().any(|u| u == owner) { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right to register objects.".to_owned() - )) - } - } + KMS::reject_protection_storage_masks(request.protection_storage_masks.is_some())?; + kms.enforce_create_permission(owner).await?; if request.object_type != request.object.object_type() { kms_bail!(KmsError::InconsistentOperation( diff --git a/crate/server/src/core/operations/rekey.rs b/crate/server/src/core/operations/rekey.rs deleted file mode 100644 index 005766ef7a..0000000000 --- a/crate/server/src/core/operations/rekey.rs +++ /dev/null @@ -1,171 +0,0 @@ -use cosmian_kms_server_database::reexport::{ - cosmian_kmip::{ - kmip_0::kmip_types::State, - kmip_2_1::{ - KmipOperation, - kmip_objects::ObjectType, - kmip_operations::{Create, ReKey, ReKeyResponse}, - kmip_types::UniqueIdentifier, - }, - }, - cosmian_kms_interfaces::AtomicOperation, -}; -use cosmian_logger::{info, trace}; -use uuid::Uuid; - -use super::rekey_common::{prepare_replacement_attributes, update_old_key_after_rekey}; -use crate::{ - core::{ - KMS, - operations::key_ops::{ObjectWithMetadataOps, setup_object_lifecycle}, - retrieve_object_utils::user_has_permission, - wrapping::wrap_and_cache, - }, - error::KmsError, - kms_bail, - result::{KResult, KResultHelper}, -}; - -/// KMIP `ReKey` operation for symmetric keys. -/// -/// Per KMIP 1.4 §4.4 / KMIP 2.1 §6.1.46: -/// - Creates a new replacement key with a new Unique Identifier. -/// - Sets a Link of type `ReplacementObjectLink` on the existing key pointing to the new key. -/// - Sets a Link of type `ReplacedObjectLink` on the new key pointing to the existing key. -/// - The replacement key takes over the Name attribute of the existing key. -/// - The existing key's **State is NOT changed** — the spec does not deactivate it. -/// - If `offset` is provided, date arithmetic per Table 172 is applied. -pub(crate) async fn rekey( - kms: &KMS, - request: ReKey, - owner: &str, - privileged_users: Option>, -) -> KResult { - trace!("ReKey: {}", serde_json::to_string(&request)?); - - if request.protection_storage_masks.is_some() { - kms_bail!(KmsError::UnsupportedPlaceholder) - } - - // ReKey creates a new replacement key — enforce privileged-user restriction - if let Some(ref users) = privileged_users { - let has_permission = user_has_permission(owner, None, &KmipOperation::Create, kms).await?; - - if !has_permission && !users.iter().any(|u| u == owner) { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right.".to_owned() - )) - } - } - - // there must be an identifier - let uid_or_tags = request - .unique_identifier - .as_ref() - .ok_or(KmsError::UnsupportedPlaceholder)? - .as_str() - .context("Rekey: the symmetric key unique identifier must be a string")?; - - let offset = request.offset; - - // retrieve the symmetric key associated with the uid - for owm in kms - .database - .retrieve_objects(uid_or_tags) - .await? - .into_values() - { - // only active objects - if owm.state() != State::Active { - continue; - } - // only symmetric keys - if owm.object().object_type() != ObjectType::SymmetricKey { - continue; - } - - // Reject wrapped keys — the server cannot safely rekey a wrapped object - if owm.object().key_wrapping_data().is_some() { - kms_bail!(KmsError::InconsistentOperation( - "The server cannot rekey: the key is wrapped. Unwrap it first.".to_owned() - )) - } - - let old_uid = owm.id().to_owned(); - - // Verify the caller is allowed to rekey this object - if !owm - .user_can_perform_operation(owner, &KmipOperation::Rekey, kms) - .await? - { - continue; - } - - // Prepare replacement attributes using shared logic (links, name, dates) - let new_attributes = prepare_replacement_attributes(owm.attributes(), &old_uid, offset)?; - - // Compute the activation date for lifecycle setup - let activation_date = new_attributes.activation_date; - - // Create a new symmetric key with fresh key material - let create_request = Create { - object_type: ObjectType::SymmetricKey, - attributes: new_attributes, - protection_storage_masks: None, - }; - let (_uid, mut new_object, tags) = - KMS::create_symmetric_key_and_tags(kms.vendor_id(), &create_request)?; - - // Generate a new UID for the replacement key - let new_uid = Uuid::new_v4().to_string(); - - // Set up lifecycle attributes (state based on activation date) - let new_obj_attributes = - setup_object_lifecycle(&mut new_object, ObjectType::SymmetricKey, activation_date)?; - - // Wrap the new object if requested - Box::pin(wrap_and_cache( - kms, - owner, - &UniqueIdentifier::TextString(new_uid.clone()), - &mut new_object, - )) - .await?; - - // Update the old key using shared logic (ReplacementObjectLink, remove name, last change) - let mut old_object = owm.object().clone(); - let mut old_attributes = owm.attributes().clone(); - - update_old_key_after_rekey(&mut old_attributes, &new_uid)?; - - // Update internal object attributes too - if let Ok(obj_attrs) = old_object.attributes_mut() { - update_old_key_after_rekey(obj_attrs, &new_uid)?; - } - - // Execute all operations atomically: - // 1. Create the new replacement key - // 2. Update the old key (add link, remove name, update last change date) - let operations = vec![ - AtomicOperation::Create((new_uid.clone(), new_object, new_obj_attributes, tags)), - AtomicOperation::UpdateObject((old_uid.clone(), old_object, old_attributes, None)), - ]; - - kms.database.atomic(owner, &operations).await?; - - info!( - old_uid = old_uid, - new_uid = new_uid, - user = owner, - "Re-keyed symmetric key: new replacement key created, old key remains Active", - ); - - return Ok(ReKeyResponse { - unique_identifier: UniqueIdentifier::TextString(new_uid), - }); - } - - Err(KmsError::InvalidRequest(format!( - "rekey: no active symmetric key found for uid/tags: {uid_or_tags}", - ))) -} diff --git a/crate/server/src/core/operations/rekey/common.rs b/crate/server/src/core/operations/rekey/common.rs new file mode 100644 index 0000000000..d3f782fa38 --- /dev/null +++ b/crate/server/src/core/operations/rekey/common.rs @@ -0,0 +1,588 @@ +//! Shared logic for KMIP `ReKey` (§6.1.46), `ReKeyKeyPair` (§6.1.47), and `ReCertify` (§6.1.45) operations. +//! +//! All section references are to KMIP 2.1 (OASIS Standard). +//! +//! All rotation operations follow the same pattern via the [`RekeyOperation`] trait: +//! - Validate inputs and resolve candidates for rotation. +//! - Detect wrapping context on existing objects. +//! - Generate replacement material (new key/cert) with fresh UIDs. +//! - Prepare attributes: links, lifecycle dates, rotation metadata. +//! - Re-wrap new objects if the originals were wrapped. +//! - Phase 1: persist new objects atomically. +//! - Phase 2: retire old objects, finalize dependants (rewrap keys / relink certs). +//! - Build and return the KMIP response. + +use std::collections::HashSet; + +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::State, + kmip_2_1::{ + kmip_attributes::Attributes, + kmip_data_structures::KeyWrappingSpecification, + kmip_objects::{Object, ObjectType}, + kmip_types::{ + EncodingOption, EncryptionKeyInformation, LinkType, LinkedObjectIdentifier, + MacSignatureKeyInformation, UniqueIdentifier, + }, + }, + }, + cosmian_kms_interfaces::{AtomicOperation, ObjectWithMetadata}, +}; +use cosmian_logger::{info, warn}; + +use crate::{ + core::{ + KMS, + operations::key_ops::ObjectLifecycleExt, + wrapping::{unwrap_object, wrap_and_cache, wrap_object}, + }, + error::KmsError, + result::KResult, +}; + +impl KMS { + /// Retrieve all eligible objects matching the given identifier, filtered by state and type. + /// + /// Filters by: + /// - State: `Active`, `Deactivated`, or `Compromised`. + /// - Object type: the specified `object_type` + /// + /// When a specific UID resolves to a key of the correct type but in an + /// ineligible state, an explicit error is returned rather than silently + /// skipping. For tag-based queries, ineligible keys are filtered out. + pub(crate) async fn retrieve_eligible_keys( + &self, + uid_or_tags: &str, + object_type: ObjectType, + ) -> KResult> { + let is_tag_query = uid_or_tags.starts_with('['); + let objects = self.database.retrieve_objects(uid_or_tags).await?; + let mut eligible = Vec::new(); + + for owm in objects.into_values() { + if owm.object().object_type() != object_type { + continue; + } + let is_eligible = matches!( + owm.state(), + State::Active | State::Deactivated | State::Compromised + ); + if !is_eligible { + if !is_tag_query { + return Err(KmsError::InvalidRequest(format!( + "key '{}' is in state '{}' — only Active, Deactivated, or Compromised \ + keys can be rotated", + owm.id(), + owm.state() + ))); + } + continue; + } + eligible.push(owm); + } + Ok(eligible) + } + + /// Returns `true` if `attrs` represents the latest generation in its named keyset. + pub(crate) async fn is_keyset_latest( + &self, + uid: &str, + attrs: &Attributes, + user: &str, + ) -> KResult { + let Some(name) = attrs.rotate_name.as_deref() else { + return Ok(true); + }; + let current_gen = attrs.rotate_generation.unwrap_or(0); + let all = self.database.find_by_rotate_name(name, None, user).await?; + Ok(!all.iter().any(|(other_uid, other_attrs)| { + other_uid != uid && other_attrs.rotate_generation.unwrap_or(0) > current_gen + })) + } + + /// Reject a Re-Key / Re-KeyKeyPair request when the selected key is not the latest + /// generation in its named keyset. + /// + /// No-ops for keys that are not part of a named keyset (no `rotate_name`). + pub(crate) async fn enforce_keyset_latest( + &self, + uid: &str, + attrs: &Attributes, + user: &str, + op_name: &str, + ) -> KResult<()> { + if !self.is_keyset_latest(uid, attrs, user).await? { + return Err(KmsError::InvalidRequest(format!( + "{op_name}: key '{uid}' is not the latest in its keyset — only the latest \ + generation can be rotated" + ))); + } + Ok(()) + } + + /// Default implementation for [`RekeyOperation::finalize_dependants`]. + pub(crate) async fn default_finalize_dependants( + &self, + user: &str, + candidates: &[RotationCandidate], + replacements: &[ReplacementObject], + ) -> KResult<()> { + let mut operations: Vec = Vec::new(); + + for (c, r) in candidates.iter().zip(replacements.iter()) { + let (old_object, old_attributes) = retire_old_key(&c.owm, &r.new_uid)?; + operations.push(AtomicOperation::UpdateObject(( + c.owm.id().to_owned(), + old_object, + old_attributes, + None, + ))); + // KMIP §4.57 transition 6: old key becomes Deactivated after Re-Key + operations.push(AtomicOperation::UpdateState(( + c.owm.id().to_owned(), + State::Deactivated, + ))); + if let Some(ref new_wrapping_uid) = r.rewrap_to { + Box::pin(self.rewrap_dependants( + user, + c.owm.id(), + new_wrapping_uid, + &mut operations, + )) + .await?; + } + } + + self.database.atomic(user, &operations).await?; + + for (c, r) in candidates.iter().zip(replacements.iter()) { + info!( + "Rekey finalized: old={} → new={}, user={user}", + c.uid, r.new_uid + ); + } + Ok(()) + } + + /// Default implementation for [`RekeyOperation::rewrap_new_objects`]. + pub(crate) async fn default_rewrap_new_objects( + &self, + user: &str, + replacements: &mut [ReplacementObject], + wrap_specs: &[Option], + ) -> KResult<()> { + for (replacement, spec) in replacements.iter_mut().zip(wrap_specs.iter()) { + Box::pin(wrap_and_cache( + self, + user, + &UniqueIdentifier::TextString(replacement.new_uid.clone()), + &mut replacement.object, + )) + .await?; + + let Some(mut rewrap_spec) = spec.clone() else { + continue; + }; + if replacement.object.is_wrapped() { + continue; + } + if replacement + .object + .key_block() + .is_ok_and(|kb| kb.key_bytes().is_ok()) + { + rewrap_spec.encoding_option = Some(EncodingOption::NoEncoding); + } + + let unwrapped_object = replacement.object.clone(); + Box::pin(wrap_object( + &mut replacement.object, + &rewrap_spec, + self, + user, + )) + .await?; + self.database + .unwrapped_cache() + .insert( + replacement.new_uid.clone(), + &replacement.object, + unwrapped_object, + ) + .await?; + } + Ok(()) + } + + /// Re-wrap all keys that were wrapped by the old wrapping key. + pub(crate) async fn rewrap_dependants( + &self, + owner: &str, + old_uid: &str, + new_uid: &str, + operations: &mut Vec, + ) -> KResult<()> { + let wrapped_dependants = match self.database.find_wrapped_by(old_uid, owner).await { + Ok(deps) => deps, + Err(e) => { + warn!("find_wrapped_by({old_uid}) failed — skipping re-wrap of dependants: {e}"); + vec![] + } + }; + + for (dep_uid, _dep_state, _dep_attrs) in wrapped_dependants { + let Some(dep_owm) = self.database.retrieve_object(&dep_uid).await? else { + warn!("wrapped dependant {dep_uid} not found, skipping"); + continue; + }; + if dep_owm.owner() != owner { + warn!( + "skipping re-wrap of dependant {dep_uid}: owned by '{}', not by '{owner}'", + dep_owm.owner() + ); + continue; + } + let mut dep_object = dep_owm.object().clone(); + let dep_attrs = dep_owm.attributes().clone(); + + if let Some(op) = self + .rewrap_single_dependant(owner, &dep_uid, &mut dep_object, dep_attrs, new_uid) + .await? + { + operations.push(op); + } + } + Ok(()) + } + + /// Unwrap and re-wrap a single dependant object with the new wrapping key. + async fn rewrap_single_dependant( + &self, + owner: &str, + dep_uid: &str, + dep_object: &mut Object, + mut dep_attrs: Attributes, + new_uid: &str, + ) -> KResult> { + let dep_wrap_spec = dep_object + .key_block() + .ok() + .and_then(|kb| kb.key_wrapping_data.as_ref()) + .map(|kwd| KeyWrappingSpecification { + wrapping_method: kwd.wrapping_method, + encryption_key_information: Some(EncryptionKeyInformation { + unique_identifier: UniqueIdentifier::TextString(new_uid.to_owned()), + cryptographic_parameters: kwd + .encryption_key_information + .as_ref() + .and_then(|e| e.cryptographic_parameters.clone()), + }), + mac_or_signature_key_information: kwd.mac_signature_key_information.clone().map( + |m| MacSignatureKeyInformation { + unique_identifier: UniqueIdentifier::TextString(new_uid.to_owned()), + cryptographic_parameters: m.cryptographic_parameters, + }, + ), + attribute_name: None, + encoding_option: kwd.encoding_option, + }); + + let Some(spec) = dep_wrap_spec else { + return Ok(None); + }; + + if let Err(e) = unwrap_object(dep_object, self, owner).await { + warn!("failed to unwrap dependant {dep_uid}: {e}, skipping"); + return Ok(None); + } + if let Err(e) = crate::core::wrapping::wrap_object(dep_object, &spec, self, owner).await { + warn!("failed to re-wrap dependant {dep_uid} with new key: {e}, skipping"); + return Ok(None); + } + + dep_attrs.set_link( + LinkType::WrappingKeyLink, + LinkedObjectIdentifier::TextString(new_uid.to_owned()), + ); + dep_attrs.set_wrapping_key_id(self.vendor_id(), new_uid); + + Ok(Some(AtomicOperation::UpdateObject(( + dep_uid.to_owned(), + dep_object.clone(), + dep_attrs, + None, + )))) + } +} + +// ─── Trait: RekeyOperation ─────────────────────────────────────────────────── + +/// An existing object that is a candidate for rotation. +pub(crate) struct RotationCandidate { + /// The object-with-metadata from the database. + pub owm: ObjectWithMetadata, + /// The UID of this object. + pub uid: String, +} + +impl RotationCandidate { + /// Follow `PublicKeyLink` on this candidate's private key to resolve the paired public key UID. + pub(crate) fn public_key_uid(&self) -> KResult { + self.owm + .attributes() + .get_link(LinkType::PublicKeyLink) + .map(|l| l.to_string()) + .ok_or_else(|| { + KmsError::InvalidRequest( + "ReKeyKeyPair: the private key has no PublicKeyLink. Cannot determine the \ + paired public key." + .to_owned(), + ) + }) + } +} + +/// A newly generated replacement object ready for Phase 1 commit. +pub(crate) struct ReplacementObject { + /// The fresh UID for the replacement. + pub new_uid: String, + /// The UID of the old object being replaced. + pub old_uid: String, + /// The new KMIP object (key or certificate). + pub object: Object, + /// Attributes for the new object. + pub attributes: Attributes, + /// Tags for the new object (used in `AtomicOperation::Create`). + pub tags: HashSet, + /// If `Some`, dependants of the old object will be re-wrapped/re-linked + /// to this UID during Phase 2. `None` means no dependant processing for this slot. + pub rewrap_to: Option, +} + +impl ReplacementObject { + /// Apply replacement attributes, lifecycle setup, and tag extraction to this key slot. + /// + /// Combines [`setup_new_key`] + attribute/tag extraction into a single call to + /// avoid repetition when processing both the SK and PK in `prepare_attributes`. + pub(crate) fn finalize( + &mut self, + new_attrs: &Attributes, + object_type: ObjectType, + old_uid: &str, + paired_key: Option<(&str, LinkType)>, + vendor_id: &str, + ) -> KResult<()> { + setup_new_key( + &mut self.object, + new_attrs, + object_type, + old_uid, + paired_key, + )?; + // Stamp the embedded attributes with the correct UID. + // `create_symmetric_key_kmip_object` always assigns a random UUID to + // `attributes.unique_identifier`; replace it with `new_uid` so that + // GetAttributes always returns a `unique_identifier` that matches the + // object's actual stored UID. + if let Ok(embedded_attrs) = self.object.attributes_mut() { + embedded_attrs.unique_identifier = + Some(UniqueIdentifier::TextString(self.new_uid.clone())); + } + let attrs = self.object.attributes().cloned().unwrap_or_default(); + self.tags = attrs.get_tags(vendor_id); + self.attributes = attrs; + Ok(()) + } +} + +/// Unified trait for all rotation operations: `ReKey`, `ReKeyKeyPair`, and `ReCertify`. +/// +/// Each implementor provides type-specific logic for the rotation pipeline; +/// the shared [`execute_rekey`] orchestrator drives all steps in order. +/// Wrapping detection and atomic persist are handled directly in [`execute_rekey`]; +/// the remaining 6 steps below are overridable. +/// +/// The associated types `Candidates` and `Replacements` encode the expected cardinality +/// at compile time (e.g. `[RotationCandidate; 1]` for symmetric, `[RotationCandidate; 2]` +/// for key pairs), eliminating runtime indexing errors. +pub(crate) trait RekeyOperation { + /// The KMIP request type (e.g. `ReKey`, `ReKeyKeyPair`, `Certify`). + type Request; + /// The KMIP response type (e.g. `ReKeyResponse`, `ReKeyKeyPairResponse`). + type Response; + /// The set of rotation candidates produced by [`Self::validate`]. + /// Use `[RotationCandidate; 1]` for single-object operations (symmetric, certificate) + /// or `[RotationCandidate; 2]` for key pairs (SK + PK). + type Candidates: AsRef<[RotationCandidate]>; + /// The set of replacement objects produced by [`Self::generate_replacement`]. + /// Use `[ReplacementObject; 1]` for single-object operations + /// or `[ReplacementObject; 2]` for key pairs. + type Replacements: AsRef<[ReplacementObject]> + AsMut<[ReplacementObject]>; + + /// Step 1: Parse request, validate inputs, check permissions. + /// + /// Returns [`Self::Candidates`] — the existing objects eligible for rotation. + fn validate( + &self, + kms: &KMS, + request: &Self::Request, + user: &str, + ) -> impl std::future::Future>; + + /// Step 3: Generate replacement material (new key/cert + fresh UIDs). + /// + /// Returns [`Self::Replacements`] — one replacement per candidate. + fn generate_replacement( + &self, + kms: &KMS, + candidates: &Self::Candidates, + ) -> impl std::future::Future>; + + /// Step 4: Prepare attributes — links, lifecycle dates, rotation metadata. + fn prepare_attributes( + &self, + kms: &KMS, + candidates: &Self::Candidates, + replacements: &mut Self::Replacements, + ) -> KResult<()>; + + /// Step 5: Re-wrap new objects if originals were wrapped. + /// + /// The default implementation handles both: + /// 1. Server-wide KEK wrapping (via `wrap_and_cache` — no-op if no KEK configured) + /// 2. Re-wrapping with the same spec as the old object (if it was wrapped) + /// + /// Certificates should override with a no-op since they are never wrapped. + fn rewrap_new_objects( + &self, + kms: &KMS, + user: &str, + replacements: &mut Self::Replacements, + wrap_specs: &[Option], + ) -> impl std::future::Future> { + kms.default_rewrap_new_objects(user, replacements.as_mut(), wrap_specs) + } + + /// Step 6: Phase 2 — retire old objects + finalize dependants. + /// + /// For keys: rewrap all dependants with the new wrapping key. + /// For certificates: relink keys' `CertificateLink` to the new cert UID. + /// + /// The default implementation retires old objects and re-wraps dependants atomically. + /// Override this for certificate-specific logic. + fn finalize_dependants( + &self, + kms: &KMS, + user: &str, + candidates: &Self::Candidates, + replacements: &Self::Replacements, + ) -> impl std::future::Future> { + kms.default_finalize_dependants(user, candidates.as_ref(), replacements.as_ref()) + } + + /// Step 8: Build the KMIP response from the completed replacements. + fn build_response(&self, replacements: &Self::Replacements) -> Self::Response; +} + +/// Execute the full rotation pipeline using a [`RekeyOperation`] implementor. +/// +/// This orchestrator drives the 8-step rotation flow in order: +/// validate → detect wrapping → generate → prepare attributes → rewrap → commit → finalize → respond. +pub(crate) async fn execute_rekey( + op: &T, + kms: &KMS, + request: &T::Request, + user: &str, +) -> KResult { + let candidates = op.validate(kms, request, user).await?; + let wrap_specs: Vec<_> = candidates + .as_ref() + .iter() + .map(|c| c.owm.object().rewrap_spec()) + .collect(); + let mut replacements = op.generate_replacement(kms, &candidates).await?; + op.prepare_attributes(kms, &candidates, &mut replacements)?; + op.rewrap_new_objects(kms, user, &mut replacements, &wrap_specs) + .await?; + let persist_ops: Vec = replacements + .as_ref() + .iter() + .map(|r| { + AtomicOperation::Create(( + r.new_uid.clone(), + r.object.clone(), + r.attributes.clone(), + r.tags.clone(), + )) + }) + .collect(); + kms.database.atomic(user, &persist_ops).await?; + op.finalize_dependants(kms, user, &candidates, &replacements) + .await?; + Ok(op.build_response(&replacements)) +} + +// ─── Phase 2: Finalize rekey (retire old keys + rewrap dependants) ─────────── + +/// Set up a newly generated key with replacement attributes and links. +/// +/// Applies the `ReplacedObjectLink` pointing to the old UID, an optional +/// paired-key cross-link, and the Name from the replacement attributes. +/// Then calls [`ObjectLifecycleExt::setup_with_lifecycle`] to set state / dates / digest. +pub(crate) fn setup_new_key( + key_object: &mut Object, + replacement_attrs: &Attributes, + object_type: ObjectType, + old_uid: &str, + paired_key: Option<(&str, LinkType)>, +) -> KResult<()> { + if let Ok(key_attrs) = key_object.attributes_mut() { + key_attrs.name.clone_from(&replacement_attrs.name); + key_attrs.set_link( + LinkType::ReplacedObjectLink, + LinkedObjectIdentifier::TextString(old_uid.to_owned()), + ); + if let Some((paired_uid, link_type)) = paired_key { + key_attrs.set_link( + link_type, + LinkedObjectIdentifier::TextString(paired_uid.to_owned()), + ); + } + } + + key_object.setup_with_lifecycle(object_type, replacement_attrs.activation_date)?; + Ok(()) +} + +// ─── Private helpers ───────────────────────────────────────────────────────── + +/// Reject a Re-Key request when the UID refers to an HSM-managed key. +/// +/// HSM-managed keys have no KMIP attribute storage and are often non-extractable +/// (`CKA_EXTRACTABLE = false`) — they must be managed via the HSM's own tools. +pub(in crate::core::operations::rekey) fn reject_hsm_uid(uid: &str, op_name: &str) -> KResult<()> { + if uid.starts_with("hsm::") { + return Err(KmsError::NotSupported(format!( + "{op_name} is not supported for HSM-managed keys. \ + Use PKCS#11 vendor tools or the HSM administration console \ + to manage HSM key lifecycle." + ))); + } + Ok(()) +} + +fn retire_old_key(owm: &ObjectWithMetadata, new_uid: &str) -> KResult<(Object, Attributes)> { + let mut old_object = owm.object().clone(); + let mut old_attributes = owm.attributes().clone(); + old_attributes.retire_for_replacement(new_uid)?; + old_attributes.clear_rotation_flags(); + // KMIP §4.57 transition 6: persist Deactivated in all attribute layers so that + // destroy.rs effective-state logic reads Deactivated (not the stale Active value). + old_attributes.state = Some(State::Deactivated); + if let Ok(obj_attrs) = old_object.attributes_mut() { + obj_attrs.retire_for_replacement(new_uid)?; + obj_attrs.state = Some(State::Deactivated); + } + Ok((old_object, old_attributes)) +} diff --git a/crate/server/src/core/operations/rekey/keypair/covercrypt.rs b/crate/server/src/core/operations/rekey/keypair/covercrypt.rs new file mode 100644 index 0000000000..853b02cccc --- /dev/null +++ b/crate/server/src/core/operations/rekey/keypair/covercrypt.rs @@ -0,0 +1,73 @@ +//! Covercrypt in-place attribute rekey (non-FIPS only). +//! +//! Covercrypt uses a fundamentally different rotation model: key material is mutated +//! in-place via attribute-level policy changes rather than generating a new UID. +//! This module isolates that logic from the standard key pair rotation pipeline. + +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::kmip_2_1::{ + kmip_objects::ObjectType, + kmip_operations::{ReKeyKeyPair, ReKeyKeyPairResponse}, + kmip_types::{CryptographicAlgorithm, KeyFormatType}, + }, + cosmian_kms_crypto::{ + crypto::cover_crypt::attributes::rekey_edit_action_from_attributes, + reexport::cosmian_cover_crypt::api::Covercrypt, + }, +}; + +use crate::{ + core::{KMS, cover_crypt::rekey_keypair_cover_crypt}, + error::KmsError, + result::{KResult, KResultHelper}, +}; + +/// Attempt Covercrypt-specific rekey. Returns `Some(response)` if handled, `None` otherwise. +pub(super) async fn try_covercrypt_rekey( + kms: &KMS, + request: &ReKeyKeyPair, + user: &str, +) -> KResult> { + let uid_or_tags = request + .private_key_unique_identifier + .as_ref() + .ok_or(KmsError::UnsupportedPlaceholder)? + .as_str() + .context("ReKeyKeyPair: the private key unique identifier must be a string")?; + + for owm in kms + .retrieve_eligible_keys(uid_or_tags, ObjectType::PrivateKey) + .await? + { + let key_format_type = owm.attributes().key_format_type.or_else(|| { + owm.object() + .attributes() + .ok() + .and_then(|a| a.key_format_type) + }); + + if key_format_type == Some(KeyFormatType::CoverCryptSecretKey) { + let attributes = request.private_key_attributes.as_ref().ok_or_else(|| { + KmsError::InvalidRequest( + "ReKeyKeyPair: the private key attributes must be supplied for Covercrypt" + .to_owned(), + ) + })?; + if Some(CryptographicAlgorithm::CoverCrypt) == attributes.cryptographic_algorithm { + let action = rekey_edit_action_from_attributes(kms.vendor_id(), attributes)?; + let response = Box::pin(rekey_keypair_cover_crypt( + kms, + Covercrypt::default(), + owm.id().to_owned(), + user, + action, + owm.attributes().sensitive.unwrap_or(false), + )) + .await + .context("ReKeyKeyPair: Covercrypt rekey failed")?; + return Ok(Some(response)); + } + } + } + Ok(None) +} diff --git a/crate/server/src/core/operations/rekey/keypair/mod.rs b/crate/server/src/core/operations/rekey/keypair/mod.rs new file mode 100644 index 0000000000..baa6f0537e --- /dev/null +++ b/crate/server/src/core/operations/rekey/keypair/mod.rs @@ -0,0 +1,108 @@ +//! KMIP `ReKeyKeyPair` for asymmetric key pairs — dispatcher. +//! +//! Routes the request to either: +//! - The Covercrypt in-place attribute rekey (non-FIPS only, [`covercrypt`] module). +//! - The SQL-backed key pair rotation pipeline ([`sql::SqlKeypairRekeyer`]). + +#[cfg(feature = "non-fips")] +mod covercrypt; +mod sql; + +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::ErrorReason, + kmip_2_1::{ + kmip_operations::{ReKeyKeyPair, ReKeyKeyPairResponse}, + kmip_types::UniqueIdentifier, + }, +}; +use cosmian_logger::trace; + +use self::sql::SqlKeypairRekeyer; +use super::common::execute_rekey; +use crate::{ + core::{KMS, uid_utils::resolve_uid_or_keyset}, + error::KmsError, + result::KResult, +}; + +/// KMIP `ReKeyKeyPair` operation for asymmetric key pairs. +/// +/// Per KMIP 1.4 §4.5: +/// - Creates a replacement key pair with new Unique Identifiers. +/// - Sets `ReplacementObjectLink` on both old private and public keys. +/// - Sets `ReplacedObjectLink` on both new private and public keys. +/// - The replacement keys take over the Name attributes of the existing keys. +/// - The existing keys' State is NOT changed. +/// - If `offset` is provided, date computation per Table 176 is applied. +/// - Rotation metadata is set on both old and new keys. +/// +/// For Covercrypt keys (non-FIPS only), delegates to the existing in-place +/// attribute-level rekey which mutates the key material without creating new UIDs. +pub(crate) async fn rekey_keypair( + kms: &KMS, + request: ReKeyKeyPair, + user: &str, +) -> KResult { + trace!("ReKeyKeyPair: {}", serde_json::to_string(&request)?); + + // Covercrypt early-return: uses a completely different code path (in-place attribute rekey) + // that doesn't fit the rotation trait pattern. + #[cfg(feature = "non-fips")] + if let Some(response) = covercrypt::try_covercrypt_rekey(kms, &request, user).await? { + return Ok(response); + } + + // Resolve keyset references (`name@latest`, `name@first`, `name@N`, bare name) to a concrete + // private-key UID before routing, so that `re-key --key-id my-kp@latest` works transparently. + let request = if let Some(uid_str) = request + .private_key_unique_identifier + .as_ref() + .and_then(|u| u.as_str()) + { + if let Some(resolved) = resolve_uid_or_keyset(uid_str, "ReKeyKeyPair", kms, user).await? { + trace!( + "ReKeyKeyPair: resolved keyset ref '{}' → '{}'", + uid_str, resolved + ); + ReKeyKeyPair { + private_key_unique_identifier: Some(UniqueIdentifier::TextString(resolved)), + ..request + } + } else { + request + } + } else { + request + }; + + Box::pin(execute_rekey( + &SqlKeypairRekeyer { + offset: request.offset, + }, + kms, + &request, + user, + )) + .await +} + +impl KMS { + /// Retrieve the linked public key from the database. + pub(super) async fn retrieve_linked_public_key( + &self, + pk_uid: &str, + ) -> KResult + { + self.database + .retrieve_objects(pk_uid) + .await? + .into_values() + .next() + .ok_or_else(|| { + KmsError::Kmip21Error( + ErrorReason::Item_Not_Found, + format!("ReKeyKeyPair: linked public key '{pk_uid}' not found in database"), + ) + }) + } +} diff --git a/crate/server/src/core/operations/rekey/keypair/sql.rs b/crate/server/src/core/operations/rekey/keypair/sql.rs new file mode 100644 index 0000000000..1629eb8250 --- /dev/null +++ b/crate/server/src/core/operations/rekey/keypair/sql.rs @@ -0,0 +1,275 @@ +//! SQL-backed asymmetric key pair rotation (KMIP `ReKeyKeyPair` §6.1.47). +//! +//! This module handles `ReKeyKeyPair` for key pairs stored in the SQL database — generates +//! a fresh key pair, manages wrapping/unwrapping, links generations, and retires old keys. + +use std::collections::HashSet; + +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::State, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attributes, + kmip_objects::ObjectType, + kmip_operations::{CreateKeyPair, ReKeyKeyPair, ReKeyKeyPairResponse}, + kmip_types::{KeyFormatType, LinkType, UniqueIdentifier}, + }, + }, + cosmian_kms_interfaces::ObjectWithMetadata, +}; + +use super::super::common::{RekeyOperation, ReplacementObject, RotationCandidate, reject_hsm_uid}; +use crate::{ + core::{ + KMS, + operations::{create_key_pair::generate_key_pair, key_ops::KeySelectionSpec}, + }, + error::KmsError, + result::{KResult, KResultHelper}, +}; + +/// Implementor of [`RekeyOperation`] for KMIP `ReKeyKeyPair` (KMIP 1.4 §4.5 / KMIP 2.1 +/// §6.1.47) on SQL-backed asymmetric key pairs. +pub(in crate::core::operations::rekey) struct SqlKeypairRekeyer { + /// The `offset` from the `ReKeyKeyPair` request (date computation per KMIP 1.4 Table + /// 176 / KMIP 2.1 Table 308). + pub offset: Option, +} + +impl KeySelectionSpec for SqlKeypairRekeyer { + const KMIP_OP: KmipOperation = KmipOperation::Rekey; + const OP_NAME: &'static str = "ReKeyKeyPair"; + + fn accepted_states() -> &'static [State] { + &[State::Active, State::Deactivated, State::Compromised] + } + + fn strict_permission_check() -> bool { + true + } + + fn is_key_eligible(owm: &ObjectWithMetadata, _vendor_id: &str) -> bool { + if owm.object().object_type() != ObjectType::PrivateKey { + return false; + } + // Skip Covercrypt keys (handled separately before trait dispatch) + let key_format_type = owm.attributes().key_format_type.or_else(|| { + owm.object() + .attributes() + .ok() + .and_then(|a| a.key_format_type) + }); + key_format_type != Some(KeyFormatType::CoverCryptSecretKey) + } +} + +impl RekeyOperation for SqlKeypairRekeyer { + type Candidates = [RotationCandidate; 2]; + type Replacements = [ReplacementObject; 2]; + type Request = ReKeyKeyPair; + type Response = ReKeyKeyPairResponse; + + async fn validate( + &self, + kms: &KMS, + request: &ReKeyKeyPair, + user: &str, + ) -> KResult<[RotationCandidate; 2]> { + KMS::reject_protection_storage_masks( + request.common_protection_storage_masks.is_some() + || request.private_protection_storage_masks.is_some() + || request.public_protection_storage_masks.is_some(), + )?; + + kms.enforce_create_permission(user).await?; + + let uid_or_tags = request + .private_key_unique_identifier + .as_ref() + .ok_or(KmsError::UnsupportedPlaceholder)? + .as_str() + .context("ReKeyKeyPair: the private key unique identifier must be a string")?; + + // HSM-managed keys cannot be re-keyed via the SQL pipeline: they have no KMIP + // attribute storage and are often non-extractable (CKA_EXTRACTABLE = false). + reject_hsm_uid(uid_or_tags, "Re-Key Key Pair")?; + + let candidates = kms + .retrieve_eligible_keys(uid_or_tags, ObjectType::PrivateKey) + .await?; + + let owm = kms + .select_unique_key::(candidates, uid_or_tags, user, |owm| { + // Validate no crypto param changes + owm.attributes().validate_no_crypto_param_change( + [ + request.common_attributes.as_ref(), + request.private_key_attributes.as_ref(), + request.public_key_attributes.as_ref(), + ], + "ReKeyKeyPair", + )?; + Ok(()) + }) + .await?; + + // Reject Re-Key on a retired (non-latest) member of a named keyset. + kms.enforce_keyset_latest(owm.id(), owm.attributes(), user, "ReKeyKeyPair") + .await?; + + // Resolve paired public key (post-selection: only for the winning candidate) + let old_sk_uid = owm.id().to_owned(); + let sk_candidate = RotationCandidate { + uid: old_sk_uid, + owm, + }; + let old_pk_uid = sk_candidate.public_key_uid()?; + let old_pk_owm = kms.retrieve_linked_public_key(&old_pk_uid).await?; + + Ok([ + sk_candidate, + RotationCandidate { + uid: old_pk_uid, + owm: old_pk_owm, + }, + ]) + } + + async fn generate_replacement( + &self, + kms: &KMS, + candidates: &[RotationCandidate; 2], + ) -> KResult<[ReplacementObject; 2]> { + let [sk_candidate, pk_candidate] = candidates; + + let common_attrs = sk_candidate + .owm + .attributes() + .clean_for_generation(kms.vendor_id()); + let new_sk_uid = UniqueIdentifier::rotation_successor( + sk_candidate.owm.attributes().rotate_name.as_deref(), + sk_candidate.owm.attributes().rotate_generation, + ); + // The public key UID always mirrors the private key UID with the "_pk" suffix. + let new_pk_uid = format!("{new_sk_uid}_pk"); + + // Propagate the CryptographicUsageMask from the old keys so that + // FIPS-mode key-pair generators receive the required mask value. + let resolve_mask = |owm: &ObjectWithMetadata| { + owm.attributes().cryptographic_usage_mask.or_else(|| { + owm.object() + .attributes() + .ok() + .and_then(|a| a.cryptographic_usage_mask) + }) + }; + let sk_mask = resolve_mask(&sk_candidate.owm); + let pk_mask = resolve_mask(&pk_candidate.owm); + let private_key_attributes = sk_mask.map(|m| Attributes { + cryptographic_usage_mask: Some(m), + ..Attributes::default() + }); + let public_key_attributes = pk_mask.map(|m| Attributes { + cryptographic_usage_mask: Some(m), + ..Attributes::default() + }); + + let create_kp_request = CreateKeyPair { + common_attributes: Some(common_attrs), + private_key_attributes, + public_key_attributes, + common_protection_storage_masks: None, + private_protection_storage_masks: None, + public_protection_storage_masks: None, + }; + + let key_pair = + generate_key_pair(kms.vendor_id(), create_kp_request, &new_sk_uid, &new_pk_uid)?; + + Ok([ + ReplacementObject { + new_uid: new_sk_uid, + old_uid: sk_candidate.uid.clone(), + object: key_pair.private_key().to_owned(), + attributes: Attributes::default(), // filled in prepare_attributes + tags: HashSet::new(), // filled in prepare_attributes + rewrap_to: None, // private keys are not wrapping keys + }, + ReplacementObject { + new_uid: new_pk_uid, + old_uid: pk_candidate.uid.clone(), + object: key_pair.public_key().to_owned(), + attributes: Attributes::default(), // filled in prepare_attributes + tags: HashSet::new(), // filled in prepare_attributes + rewrap_to: None, // set in prepare_attributes + }, + ]) + } + + fn prepare_attributes( + &self, + kms: &KMS, + candidates: &[RotationCandidate; 2], + replacements: &mut [ReplacementObject; 2], + ) -> KResult<()> { + let [sk_candidate, pk_candidate] = candidates; + + let new_sk_attributes = sk_candidate + .owm + .attributes() + .for_replacement(&sk_candidate.uid, self.offset)?; + let new_pk_attributes = pk_candidate + .owm + .attributes() + .for_replacement(&pk_candidate.uid, self.offset)?; + + let pk_new_uid = replacements[1].new_uid.clone(); + let sk_new_uid = replacements[0].new_uid.clone(); + + let [sk_rep, pk_rep] = replacements; + + // Private key slot + sk_rep.finalize( + &new_sk_attributes, + ObjectType::PrivateKey, + &sk_candidate.uid, + Some((&pk_new_uid, LinkType::PublicKeyLink)), + kms.vendor_id(), + )?; + sk_candidate + .owm + .object() + .copy_wrapping_key_link_to(&mut sk_rep.attributes); + sk_rep + .attributes + .set_rotation_metadata_from(sk_candidate.owm.attributes())?; + + // Public key slot — rewrap target: dependants wrapped by old PK get re-wrapped to new PK + pk_rep.finalize( + &new_pk_attributes, + ObjectType::PublicKey, + &pk_candidate.uid, + Some((&sk_new_uid, LinkType::PrivateKeyLink)), + kms.vendor_id(), + )?; + pk_candidate + .owm + .object() + .copy_wrapping_key_link_to(&mut pk_rep.attributes); + pk_rep + .attributes + .set_rotation_metadata_from(pk_candidate.owm.attributes())?; + pk_rep.rewrap_to = Some(pk_rep.new_uid.clone()); + + Ok(()) + } + + fn build_response(&self, replacements: &[ReplacementObject; 2]) -> ReKeyKeyPairResponse { + let [sk_rep, pk_rep] = replacements; + ReKeyKeyPairResponse { + private_key_unique_identifier: UniqueIdentifier::TextString(sk_rep.new_uid.clone()), + public_key_unique_identifier: UniqueIdentifier::TextString(pk_rep.new_uid.clone()), + } + } +} diff --git a/crate/server/src/core/operations/rekey/mod.rs b/crate/server/src/core/operations/rekey/mod.rs new file mode 100644 index 0000000000..4ebd9290ac --- /dev/null +++ b/crate/server/src/core/operations/rekey/mod.rs @@ -0,0 +1,16 @@ +//! KMIP key rotation operations: `ReKey` (KMIP 1.4 §4.4 / KMIP 2.1 §6.1.46), +//! `ReKeyKeyPair` (KMIP 1.4 §4.5 / KMIP 2.1 §6.1.47), `ReCertify` (KMIP 1.4 §4.8 / KMIP 2.1 §6.1.45). +//! +//! Submodules: +//! - [`common`] — Shared helpers for date computation, attribute preparation, +//! rotation metadata, and privileged-user enforcement. +//! - [`symmetric`] — `ReKey` for symmetric keys (plain, wrapped, wrapping keys). +//! - [`keypair`] — `ReKeyKeyPair` for asymmetric key pairs (RSA, EC, PQC, Covercrypt). + +mod common; +mod keypair; +mod symmetric; + +pub(crate) use common::{RekeyOperation, ReplacementObject, RotationCandidate, execute_rekey}; +pub(crate) use keypair::rekey_keypair; +pub(crate) use symmetric::rekey; diff --git a/crate/server/src/core/operations/rekey/symmetric/hsm.rs b/crate/server/src/core/operations/rekey/symmetric/hsm.rs new file mode 100644 index 0000000000..d94e091356 --- /dev/null +++ b/crate/server/src/core/operations/rekey/symmetric/hsm.rs @@ -0,0 +1,336 @@ +//! HSM-resident symmetric key rotation via PKCS#11. +//! +//! This module handles `ReKey` for keys whose UID starts with `hsm::` — non-extractable +//! key material managed by a hardware token. The rotation algorithm generates a new key +//! on the same HSM slot (via `C_GenerateKey`), assigns a generation-suffixed UID, and +//! updates `CKA_LABEL` / `CKA_START_DATE` / `CKA_END_DATE` on both old and new keys. + +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::kmip_2_1::{ + KmipOperation, kmip_objects::ObjectType, kmip_operations::ReKeyResponse, + kmip_types::UniqueIdentifier, + }, + cosmian_kms_interfaces::AtomicOperation, +}; +use cosmian_logger::trace; +use time::OffsetDateTime; + +use crate::{ + core::{KMS, uid_utils::has_prefix}, + error::KmsError, + result::KResult, +}; + +/// Components extracted from an HSM key UID (`{prefix}::{slot}::{base_id}[@N]`). +struct ParsedHsmUid { + prefix: String, + slot_id: usize, + base_id: String, + old_gen: i32, + has_explicit_gen: bool, +} + +impl ParsedHsmUid { + /// Returns the stable base UID (no generation suffix). + fn full_base_uid(&self) -> String { + format!("{}::{}::{}", self.prefix, self.slot_id, self.base_id) + } +} + +/// Parse an HSM key UID into its components. +/// +/// Expected format: `{prefix}::{slot}::{base_id}` or `{prefix}::{slot}::{base_id}@{gen}`. +fn parse_hsm_uid(uid: &str) -> KResult { + let prefix = has_prefix(uid) + .ok_or_else(|| KmsError::InvalidRequest(format!("UID '{uid}' is not an HSM UID")))? + .to_owned(); + let rest = uid + .strip_prefix(&format!("{prefix}::")) + .ok_or_else(|| KmsError::InvalidRequest("HSM UID has unexpected format".to_owned()))?; + let (slot_str, key_id) = rest.split_once("::").ok_or_else(|| { + KmsError::InvalidRequest(format!( + "HSM UID '{uid}' must have format '{prefix}::::'" + )) + })?; + let slot_id: usize = slot_str.parse().map_err(|e| { + KmsError::InvalidRequest(format!("HSM slot_id '{slot_str}' is not valid: {e}")) + })?; + let (base_id, old_gen, has_explicit_gen) = key_id + .rsplit_once('@') + .and_then(|(base, suffix)| suffix.parse::().ok().map(|n| (base, n, true))) + .unwrap_or((key_id, 0, false)); + Ok(ParsedHsmUid { + prefix, + slot_id, + base_id: base_id.to_owned(), + old_gen, + has_explicit_gen, + }) +} + +impl KMS { + /// Find the latest generation UID in a keyset identified by `rotate_name`. + pub(super) async fn latest_hsm_keyset_uid( + &self, + rotate_name: &str, + user: &str, + ) -> Option { + self.database + .find_by_rotate_name(rotate_name, None, user) + .await + .ok() + .and_then(|keys| { + keys.into_iter() + .max_by_key(|(_, attrs)| attrs.rotate_generation.unwrap_or(0)) + .map(|(uid, _)| uid) + }) + } + + /// Resolve a missing HSM key by falling back to keyset-name resolution. + /// + /// Looks up the latest-generation key for `full_base_uid` as a keyset name and + /// delegates to [`Self::rekey_hsm_symmetric`]. Returns an `InvalidRequest` error + /// if no keyset exists under that name. + async fn hsm_keyset_fallback( + &self, + uid: &str, + full_base_uid: &str, + user: &str, + ) -> KResult { + if let Some(latest) = self.latest_hsm_keyset_uid(full_base_uid, user).await { + Box::pin(self.rekey_hsm_symmetric(&latest, user)).await + } else { + Err(KmsError::InvalidRequest(format!( + "HSM key '{uid}' not found and no keyset named '{full_base_uid}' exists on \ + this HSM slot" + ))) + } + } + + /// Rotate an HSM-resident AES symmetric key. + /// + /// ## Rotation algorithm + /// + /// 1. Validate that the caller has `Rekey` permission. + /// 2. Retrieve the old key's metadata from the HSM (algorithm, length, sensitivity, + /// keyset info from `CKA_LABEL`, rotation interval from `CKA_START_DATE`/`CKA_END_DATE`). + /// 3. Compute the new generation number and new `key_id`/UID + /// (`base_key_id::new_gen`, `prefix::slot::base_key_id::new_gen`). + /// 4. Generate the new key on the same HSM slot via `C_GenerateKey` (`create_key`). + /// 5. Infer the rotation interval from the old key's dates; stamp new `CKA_START_DATE` / + /// `CKA_END_DATE` on the new key if an interval is known. + /// 6. Update `CKA_LABEL` on the old key (strip `@latest` suffix) and on the new key + /// (append `@latest`). + /// + /// ## Non-latest generation rejection + /// + /// If the key belongs to a named keyset but is not the latest generation, the request + /// is rejected with an error. Callers should use the bare keyset name (e.g. + /// `re-key --key-id my-key`) or `my-key@latest` — the dispatcher resolves those to + /// the latest UID before this function is called. + pub(super) async fn rekey_hsm_symmetric( + &self, + uid: &str, + user: &str, + ) -> KResult { + self.enforce_create_permission(user).await?; + + // Parse the UID early — prefix, slot_id, and key_id are needed for the + // keyset-name fallback resolution that follows. + let parsed = parse_hsm_uid(uid)?; + let full_base_uid = parsed.full_base_uid(); + let (prefix, slot_id, base_id, old_gen, has_explicit_gen) = ( + &parsed.prefix, + parsed.slot_id, + &parsed.base_id, + parsed.old_gen, + parsed.has_explicit_gen, + ); + + // Retrieve old key metadata from the HSM. + // When the PKCS#11 slot does not have a key with this exact UID — returned + // either as `Ok(None)` or as an `Err` containing "not found" — fall back to + // keyset-name resolution using the full base UID as the rotate_name. + let old_owm = match self.database.retrieve_object(uid).await { + Ok(Some(owm)) => owm, + Ok(None) => { + return Box::pin(self.hsm_keyset_fallback(uid, &full_base_uid, user)).await; + } + Err(e) => { + // PKCS#11 returns an error (not just empty results) for missing objects; + // treat any "not found" message as an absent key and try keyset resolution. + if e.to_string().to_lowercase().contains("not found") { + return Box::pin(self.hsm_keyset_fallback(uid, &full_base_uid, user)).await; + } + return Err(KmsError::Database(e)); + } + }; + + if old_owm.object().object_type() != ObjectType::SymmetricKey { + return Err(KmsError::NotSupported( + "HSM ReKey is currently supported for AES symmetric keys only".to_owned(), + )); + } + + // Per-object authorization: the caller must own the key or hold an explicit + // Rekey grant. `enforce_create_permission` above only checks the server-level + // "can create" right; it does not verify ownership of this specific object. + if !self + .user_can_perform_operation(&old_owm, user, &KmipOperation::Rekey) + .await? + { + return Err(KmsError::Unauthorized(format!( + "User '{user}' does not have Rekey permission on HSM key '{uid}'" + ))); + } + + let old_attrs = old_owm.attributes(); + + // Decide how to handle a non-latest key: + // - Explicit generation (@N suffix): the caller targeted a specific old generation + // — this is an error. Use the base UID (no suffix) to always rotate the head. + // - Bare base UID (no @N): treat as a stable keyset handle and redirect silently + // to the actual latest. For HSM keys rotate_name is the full base UID, which + // is unique across slots (embed slot ID). + if !self.is_keyset_latest(uid, old_attrs, user).await? { + if has_explicit_gen { + return Err(KmsError::InvalidRequest(format!( + "ReKey: HSM key '{uid}' is not the latest in its keyset — only the latest \ + generation can be rotated. Use '{full_base_uid}' to always rotate the \ + current head." + ))); + } + // Stable handle: redirect to the latest generation. + // rotate_name is the full base UID (unique across slots); fall back to + // full_base_uid for keys whose rotation policy has not been initialised. + let keyset_id = old_attrs.rotate_name.as_deref().unwrap_or(&full_base_uid); + if let Some(latest_uid) = self.latest_hsm_keyset_uid(keyset_id, user).await { + if latest_uid != uid { + return Box::pin(self.rekey_hsm_symmetric(&latest_uid, user)).await; + } + } + } + + let new_gen = old_gen + 1; + let new_uid = format!("{prefix}::{slot_id}::{base_id}@{new_gen}"); + + // Retrieve old rotate metadata from the HSM (via stub attributes). + // Fall back gracefully if CKA_LABEL metadata is absent. + let (rotate_name, old_rotate_gen) = ( + old_attrs.rotate_name.clone(), + old_attrs.rotate_generation.unwrap_or(old_gen), + ); + + // Read rotation interval in days from the old key's attributes. + // For HSM keys, rotate_interval is not stored in PKCS#11 as a KMIP attribute + // (HsmStore::update_object is a no-op); instead it is reconstructed at + // retrieve-time from CKA_START_DATE / CKA_END_DATE as (end − start) × 86400 s. + // If unavailable (key was never armed with SetAttribute RotateInterval), + // interval_days is None and the new key will not be auto-scheduled. + let interval_days: Option = old_attrs.rotate_interval.filter(|&i| i > 0).map(|secs| { + secs / cosmian_kms_server_database::reexport::cosmian_kms_interfaces::SECS_PER_DAY + }); + + // Generate the new key on the same HSM slot. + if let Err(e) = self + .database + .create( + Some(new_uid.clone()), + user, + &old_owm.object().clone(), + old_attrs, + &std::collections::HashSet::new(), + ) + .await + { + if e.to_string().contains("already exists") { + let latest_uid = if let Some(ref name) = rotate_name { + self.latest_hsm_keyset_uid(name, user).await + } else { + None + }; + let retry_hint = latest_uid.map_or_else( + || { + format!( + " Locate the highest-generation key on this slot \ + (UIDs matching `{prefix}::{slot_id}::{base_id}@N`) and re-key that \ + one." + ) + }, + |uid| { + format!( + " Re-key the current latest key instead (use the KMS client of your \ + choice with key-id `{uid}`)." + ) + }, + ); + return Err(KmsError::InvalidRequest(format!( + "HSM key '{new_uid}' already exists — generation {new_gen} was already \ + created on this HSM slot. A previous rotation may have \ + completed.{retry_hint}" + ))); + } + return Err(KmsError::InvalidRequest(format!( + "Failed to generate new HSM key '{new_uid}': {e}" + ))); + } + + // Stamp rotation dates on the new key. + if let Some(days) = interval_days { + let today = OffsetDateTime::now_utc().date(); + let end = today + time::Duration::days(days); + self.database + .set_key_rotation_dates(&new_uid, Some(today), Some(end)) + .await + .map_err(|e| { + KmsError::InvalidRequest(format!( + "Failed to set rotation dates on new HSM key '{new_uid}': {e}" + )) + })?; + } + + // Update CKA_LABEL on old key (remove @latest) and new key (add @latest). + // Use `base_id` (without generation suffix) in the label — the generation is + // already in its own field, and including a generation-suffixed key_id would + // introduce extra `::` delimiters that break `parse_label_metadata()`. + if let Some(ref name) = rotate_name { + let old_label_retired = format!("{name}::{old_rotate_gen}::{base_id}"); + let new_label_latest = format!("{name}::{new_gen}::{base_id}@latest"); + + self.database + .set_key_label(uid, &old_label_retired) + .await + .map_err(|e| { + KmsError::InvalidRequest(format!( + "Failed to update CKA_LABEL on old HSM key '{uid}': {e}" + )) + })?; + self.database + .set_key_label(&new_uid, &new_label_latest) + .await + .map_err(|e| { + KmsError::InvalidRequest(format!( + "Failed to set CKA_LABEL on new HSM key '{new_uid}': {e}" + )) + })?; + } + + trace!("HSM ReKey: old={uid} → new={new_uid} (slot={slot_id}, gen={new_gen}), user={user}"); + + // Re-wrap any DB keys that were wrapped by the old HSM key, so that + // they remain accessible under the new generation without requiring + // the caller to keep the old HSM key alive. + let mut operations: Vec = Vec::new(); + Box::pin(self.rewrap_dependants(user, uid, &new_uid, &mut operations)).await?; + if !operations.is_empty() { + self.database + .atomic(user, &operations) + .await + .map_err(KmsError::Database)?; + } + + Ok(ReKeyResponse { + unique_identifier: UniqueIdentifier::TextString(new_uid), + }) + } +} diff --git a/crate/server/src/core/operations/rekey/symmetric/mod.rs b/crate/server/src/core/operations/rekey/symmetric/mod.rs new file mode 100644 index 0000000000..785580ce7d --- /dev/null +++ b/crate/server/src/core/operations/rekey/symmetric/mod.rs @@ -0,0 +1,72 @@ +//! KMIP `ReKey` for symmetric keys — dispatcher. +//! +//! Routes the request to either the SQL-backed pipeline ([`sql::SqlSymmetricRekeyer`]) or +//! the HSM PKCS#11 rotation path ([`hsm`] module) based on the UID prefix. + +mod hsm; +mod sql; + +use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::{ + kmip_operations::{ReKey, ReKeyResponse}, + kmip_types::UniqueIdentifier, +}; +use cosmian_logger::trace; + +use self::sql::SqlSymmetricRekeyer; +use super::common::execute_rekey; +use crate::{ + core::{ + KMS, + uid_utils::{has_prefix, resolve_uid_or_keyset}, + }, + error::KmsError, + result::{KResult, KResultHelper}, +}; + +/// KMIP `ReKey` operation for symmetric keys (KMIP 2.1 §6.1.46). +/// +/// - For regular (SQL) keys: generates fresh key material, handles wrapping, links generations. +/// - For HSM-resident keys (UID starts with `hsm::`): calls `C_GenerateKey` on the same HSM +/// slot, assigns a generation-suffix UID (`original::N+1`), and updates `CKA_LABEL` / +/// `CKA_START_DATE` / `CKA_END_DATE` on both the old and new keys. +pub(crate) async fn rekey(kms: &KMS, request: ReKey, owner: &str) -> KResult { + trace!("ReKey: {}", serde_json::to_string(&request)?); + let uid = request + .unique_identifier + .as_ref() + .ok_or(KmsError::UnsupportedPlaceholder)? + .as_str() + .context("ReKey: the unique identifier must be a string")? + .to_owned(); + + // Resolve keyset references (`name@latest`, `name@first`, `name@N`, bare name) to a + // concrete UID before routing. This allows `re-key --key-id my-keyset@latest` to work + // transparently for both SQL and HSM-backed keysets. + let uid = if let Some(resolved) = resolve_uid_or_keyset(&uid, "ReKey", kms, owner).await? { + trace!("ReKey: resolved keyset ref '{}' → '{}'", uid, resolved); + resolved + } else { + uid + }; + + // Route HSM-resident keys through the dedicated PKCS#11 rotation path. + // The general RekeyOperation pipeline is designed for SQL-backed keys and + // is not applicable to non-extractable HSM key material. + if has_prefix(&uid).is_some() { + return Box::pin(kms.rekey_hsm_symmetric(&uid, owner)).await; + } + + let request = ReKey { + unique_identifier: Some(UniqueIdentifier::TextString(uid)), + ..request + }; + Box::pin(execute_rekey( + &SqlSymmetricRekeyer { + offset: request.offset, + }, + kms, + &request, + owner, + )) + .await +} diff --git a/crate/server/src/core/operations/rekey/symmetric/sql.rs b/crate/server/src/core/operations/rekey/symmetric/sql.rs new file mode 100644 index 0000000000..7f153567ca --- /dev/null +++ b/crate/server/src/core/operations/rekey/symmetric/sql.rs @@ -0,0 +1,181 @@ +//! SQL-backed symmetric key rotation (KMIP `ReKey` §6.1.46). +//! +//! This module handles `ReKey` for keys stored in the SQL database — generates fresh +//! key material, manages wrapping/unwrapping, links generations, and retires old keys. + +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::State, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attributes, + kmip_objects::ObjectType, + kmip_operations::{Create, ReKey, ReKeyResponse}, + kmip_types::UniqueIdentifier, + }, + }, + cosmian_kms_interfaces::ObjectWithMetadata, +}; + +use super::super::common::{RekeyOperation, ReplacementObject, RotationCandidate, reject_hsm_uid}; +use crate::{ + core::{KMS, operations::key_ops::KeySelectionSpec}, + error::KmsError, + result::{KResult, KResultHelper}, +}; + +/// Implementor of [`RekeyOperation`] for KMIP `ReKey` (KMIP 2.1 §6.1.46) on SQL-backed +/// symmetric keys. +pub(in crate::core::operations::rekey) struct SqlSymmetricRekeyer { + /// The `Offset` from the `ReKey` request — an interval added to the new key's + /// `Initial Date` to compute its `Activation Date` (KMIP 2.1 §6.1.46 Table 305). + pub offset: Option, +} + +impl KeySelectionSpec for SqlSymmetricRekeyer { + const KMIP_OP: KmipOperation = KmipOperation::Rekey; + const OP_NAME: &'static str = "ReKey"; + + fn accepted_states() -> &'static [State] { + &[State::Active, State::Deactivated, State::Compromised] + } + + fn strict_permission_check() -> bool { + true + } + + fn is_key_eligible(owm: &ObjectWithMetadata, _vendor_id: &str) -> bool { + owm.object().object_type() == ObjectType::SymmetricKey + } +} + +impl RekeyOperation for SqlSymmetricRekeyer { + type Candidates = [RotationCandidate; 1]; + type Replacements = [ReplacementObject; 1]; + type Request = ReKey; + type Response = ReKeyResponse; + + async fn validate( + &self, + kms: &KMS, + request: &ReKey, + user: &str, + ) -> KResult<[RotationCandidate; 1]> { + KMS::reject_protection_storage_masks(request.protection_storage_masks.is_some())?; + + kms.enforce_create_permission(user).await?; + + let uid_or_tags = request + .unique_identifier + .as_ref() + .ok_or(KmsError::UnsupportedPlaceholder)? + .as_str() + .context("Rekey: the symmetric key unique identifier must be a string")?; + + // HSM-managed keys cannot be re-keyed via the SQL pipeline: they have no KMIP + // attribute storage and are often non-extractable (CKA_EXTRACTABLE = false). + reject_hsm_uid(uid_or_tags, "Re-Key")?; + + let candidates = kms + .retrieve_eligible_keys(uid_or_tags, ObjectType::SymmetricKey) + .await?; + + let owm = kms + .select_unique_key::(candidates, uid_or_tags, user, |owm| { + // Reject requests that attempt to change crypto parameters + owm.attributes() + .validate_no_crypto_param_change([request.attributes.as_ref()], "ReKey")?; + Ok(()) + }) + .await?; + + // Reject Re-Key on a retired (non-latest) member of a named keyset. + kms.enforce_keyset_latest(owm.id(), owm.attributes(), user, "ReKey") + .await?; + + let uid = owm.id().to_owned(); + Ok([RotationCandidate { owm, uid }]) + } + + async fn generate_replacement( + &self, + kms: &KMS, + candidates: &[RotationCandidate; 1], + ) -> KResult<[ReplacementObject; 1]> { + let [candidate] = candidates; + + // Clean attributes for generation (removes identity, lifecycle dates, rotation metadata) + let gen_attrs = candidate + .owm + .attributes() + .clean_for_generation(kms.vendor_id()); + + let create_request = Create { + object_type: ObjectType::SymmetricKey, + attributes: gen_attrs, + protection_storage_masks: None, + }; + let (_, new_object, new_tags) = + KMS::create_symmetric_key_and_tags(kms.vendor_id(), &create_request)?; + + let new_uid = UniqueIdentifier::rotation_successor( + candidate.owm.attributes().rotate_name.as_deref(), + candidate.owm.attributes().rotate_generation, + ); + + Ok([ReplacementObject { + new_uid, + old_uid: candidate.uid.clone(), + object: new_object, + attributes: Attributes::default(), // filled in prepare_attributes + tags: new_tags, + rewrap_to: Some(candidate.uid.clone()), // placeholder, replaced in prepare_attributes + }]) + } + + fn prepare_attributes( + &self, + kms: &KMS, + candidates: &[RotationCandidate; 1], + replacements: &mut [ReplacementObject; 1], + ) -> KResult<()> { + let [candidate] = candidates; + let [replacement] = replacements; + + let new_attrs = candidate + .owm + .attributes() + .for_replacement(&candidate.uid, self.offset)?; + + replacement.finalize( + &new_attrs, + ObjectType::SymmetricKey, + &candidate.uid, + None, + kms.vendor_id(), + )?; + + // Preserve WrappingKeyLink if the old key was wrapped + candidate + .owm + .object() + .copy_wrapping_key_link_to(&mut replacement.attributes); + + // Set rotation metadata + replacement + .attributes + .set_rotation_metadata_from(candidate.owm.attributes())?; + + // Rewrap dependants to the NEW key + replacement.rewrap_to = Some(replacement.new_uid.clone()); + + Ok(()) + } + + fn build_response(&self, replacements: &[ReplacementObject; 1]) -> ReKeyResponse { + let [replacement] = replacements; + ReKeyResponse { + unique_identifier: UniqueIdentifier::TextString(replacement.new_uid.clone()), + } + } +} diff --git a/crate/server/src/core/operations/rekey_common.rs b/crate/server/src/core/operations/rekey_common.rs deleted file mode 100644 index 5192b4f28b..0000000000 --- a/crate/server/src/core/operations/rekey_common.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! Shared logic for KMIP `ReKey` (§4.4) and `ReKeyKeyPair` (§4.5) operations. -//! -//! Both operations follow the same pattern: -//! - The replacement key inherits the Name attribute from the existing key. -//! - Bidirectional links are established (`ReplacementObjectLink` / `ReplacedObjectLink`). -//! - Date arithmetic is applied when an `offset` is provided. -//! - Initial Date and Last Change Date are set to the current time. - -use cosmian_kms_server_database::reexport::cosmian_kmip::{ - kmip_2_1::{ - kmip_attributes::Attributes, - kmip_types::{LinkType, LinkedObjectIdentifier}, - }, - time_normalize, -}; -use time::OffsetDateTime; - -use crate::result::KResult; - -/// Dates computed for a replacement key based on the existing key's dates and an optional offset. -/// -/// Per KMIP 1.4 Tables 172/176: -/// - `activation = initialization + offset` (if offset provided) -/// - `deactivation = old_deactivation + (new_activation - old_activation)` (if both exist) -#[allow(clippy::struct_field_names)] -pub(crate) struct ReplacementDates { - pub initialization_date: OffsetDateTime, - pub activation_date: Option, - pub deactivation_date: Option, -} - -/// Compute the replacement key's dates from the existing key's attributes and an optional offset. -/// -/// KMIP 1.4 §4.4 Table 172 / §4.5 Table 176: -/// - Initialization Date (IT₂) = now (always > IT₁) -/// - Activation Date (AT₂) = IT₂ + Offset (if offset provided), else IT₂ (immediate activation) -/// - Deactivation Date = DT₁ + (AT₂ - AT₁) (if both DT₁ and AT₁ exist) -pub(crate) fn compute_replacement_dates( - old_attrs: &Attributes, - offset: Option, -) -> KResult { - let now = time_normalize()?; - - let activation_date = - Some(offset.map_or(now, |secs| now + time::Duration::seconds(i64::from(secs)))); - - let deactivation_date = match (old_attrs.deactivation_date, old_attrs.activation_date) { - (Some(old_deactivation), Some(old_activation)) => { - // DT₂ = DT₁ + (AT₂ - AT₁) - activation_date.map(|new_activation| { - let shift = new_activation - old_activation; - old_deactivation + shift - }) - } - _ => None, - }; - - Ok(ReplacementDates { - initialization_date: now, - activation_date, - deactivation_date, - }) -} - -/// Prepare attributes for a replacement key, following KMIP 1.4 §4.4 Table 173 / §4.5 Table 177. -/// -/// This function: -/// - Copies attributes from the existing key -/// - Removes stale unique identifier and links -/// - Sets `ReplacedObjectLink` → old key -/// - Transfers the Name from old key (already in the cloned attributes) -/// - Sets Initial Date, Last Change Date to now -/// - Applies offset-based date arithmetic -/// - Clears fields that must not be carried over (`destroy_date`, compromise dates, revocation) -pub(crate) fn prepare_replacement_attributes( - old_attrs: &Attributes, - old_uid: &str, - offset: Option, -) -> KResult { - let dates = compute_replacement_dates(old_attrs, offset)?; - - let mut new_attrs = old_attrs.clone(); - - // Clear fields that must not be set on the replacement key - new_attrs.unique_identifier = None; - new_attrs.destroy_date = None; - new_attrs.compromise_date = None; - new_attrs.compromise_occurrence_date = None; - // Revocation reason is stored in state, not attributes directly - - // Remove any existing replacement/replaced links (from a previous rekey) - new_attrs.remove_link(LinkType::ReplacementObjectLink); - new_attrs.remove_link(LinkType::ReplacedObjectLink); - - // Set the ReplacedObjectLink on the new key pointing to the old key - new_attrs.set_link( - LinkType::ReplacedObjectLink, - LinkedObjectIdentifier::TextString(old_uid.to_owned()), - ); - - // Set dates per spec - new_attrs.initial_date = Some(dates.initialization_date); - new_attrs.last_change_date = Some(dates.initialization_date); - new_attrs.activation_date = dates.activation_date; - if dates.deactivation_date.is_some() { - new_attrs.deactivation_date = dates.deactivation_date; - } - - Ok(new_attrs) -} - -/// Update the old key's attributes after a rekey operation. -/// -/// Per KMIP 1.4 §4.4 Table 173 / §4.5 Table 177: -/// - Sets `ReplacementObjectLink` → new key -/// - Removes the Name attribute (transferred to the replacement) -/// - Updates Last Change Date to now -pub(crate) fn update_old_key_after_rekey(old_attrs: &mut Attributes, new_uid: &str) -> KResult<()> { - let now = time_normalize()?; - - // Set the ReplacementObjectLink on the old key pointing to the new key - old_attrs.set_link( - LinkType::ReplacementObjectLink, - LinkedObjectIdentifier::TextString(new_uid.to_owned()), - ); - - // Remove the Name from the old key (it's taken over by the new key) - old_attrs.name = None; - - // Update Last Change Date - old_attrs.last_change_date = Some(now); - - Ok(()) -} diff --git a/crate/server/src/core/operations/rekey_keypair.rs b/crate/server/src/core/operations/rekey_keypair.rs deleted file mode 100644 index 558f470f73..0000000000 --- a/crate/server/src/core/operations/rekey_keypair.rs +++ /dev/null @@ -1,427 +0,0 @@ -#[cfg(feature = "non-fips")] -use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::CryptographicAlgorithm; -#[cfg(feature = "non-fips")] -use cosmian_kms_server_database::reexport::cosmian_kms_crypto::{ - crypto::cover_crypt::attributes::rekey_edit_action_from_attributes, - reexport::cosmian_cover_crypt::api::Covercrypt, -}; -use cosmian_kms_server_database::reexport::{ - cosmian_kmip::{ - kmip_0::kmip_types::{ErrorReason, State}, - kmip_2_1::{ - KmipOperation, - kmip_objects::ObjectType, - kmip_operations::{CreateKeyPair, ReKeyKeyPair, ReKeyKeyPairResponse}, - kmip_types::{KeyFormatType, LinkType, LinkedObjectIdentifier, UniqueIdentifier}, - }, - }, - cosmian_kms_interfaces::AtomicOperation, -}; -use cosmian_logger::{info, trace}; -use uuid::Uuid; - -#[cfg(feature = "non-fips")] -use crate::core::cover_crypt::rekey_keypair_cover_crypt; -use crate::{ - core::{ - KMS, - operations::{ - create_key_pair::generate_key_pair, - key_ops::{ObjectWithMetadataOps, setup_object_lifecycle}, - rekey_common::{prepare_replacement_attributes, update_old_key_after_rekey}, - }, - retrieve_object_utils::user_has_permission, - wrapping::wrap_and_cache, - }, - error::KmsError, - kms_bail, - result::{KResult, KResultHelper}, -}; - -/// KMIP `ReKeyKeyPair` operation for asymmetric key pairs. -/// -/// Per KMIP 1.4 §4.5: -/// - Creates a replacement key pair with new Unique Identifiers. -/// - Sets `ReplacementObjectLink` on both old private and public keys. -/// - Sets `ReplacedObjectLink` on both new private and public keys. -/// - The replacement keys take over the Name attributes of the existing keys. -/// - The existing keys' State is NOT changed. -/// - If `offset` is provided, date arithmetic per Table 176 is applied. -/// -/// For Covercrypt keys (non-FIPS only), delegates to the existing in-place -/// attribute-level rekey which mutates the key material without creating new UIDs. -pub(crate) async fn rekey_keypair( - kms: &KMS, - request: ReKeyKeyPair, - user: &str, - - privileged_users: Option>, -) -> KResult { - trace!("ReKeyKeyPair: {}", serde_json::to_string(&request)?); - - if request.common_protection_storage_masks.is_some() - || request.private_protection_storage_masks.is_some() - || request.public_protection_storage_masks.is_some() - { - kms_bail!(KmsError::UnsupportedPlaceholder) - } - - // ReKeyKeyPair creates a replacement key pair — enforce privileged-user restriction - if let Some(ref users) = privileged_users { - let has_permission = user_has_permission(user, None, &KmipOperation::Create, kms).await?; - - if !has_permission && !users.iter().any(|u| u == user) { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right.".to_owned() - )) - } - } - - // there must be an identifier - let uid_or_tags = request - .private_key_unique_identifier - .as_ref() - .ok_or(KmsError::UnsupportedPlaceholder)? - .as_str() - .context("ReKeyKeyPair: the private key unique identifier must be a string")?; - - let offset = request.offset; - - // retrieve from tags or use passed identifier - let owm_s = kms - .database - .retrieve_objects(uid_or_tags) - .await? - .into_values(); - - for owm in owm_s { - // Only Active or PreActive objects are eligible for rekey - if owm.state() != State::Active && owm.state() != State::PreActive { - continue; - } - - if owm.object().object_type() != ObjectType::PrivateKey { - continue; - } - - // Verify the caller is allowed to rekey this key pair - if !owm - .user_can_perform_operation(user, &KmipOperation::Rekey, kms) - .await? - { - continue; - } - - // Dispatch based on the existing key's format type - let key_format_type = owm.attributes().key_format_type.or_else(|| { - owm.object() - .attributes() - .ok() - .and_then(|a| a.key_format_type) - }); - - // Covercrypt special case (non-FIPS only) - #[cfg(feature = "non-fips")] - if key_format_type == Some(KeyFormatType::CoverCryptSecretKey) { - let attributes = request.private_key_attributes.as_ref().ok_or_else(|| { - KmsError::InvalidRequest( - "ReKeyKeyPair: the private key attributes must be supplied for Covercrypt" - .to_owned(), - ) - })?; - if Some(CryptographicAlgorithm::CoverCrypt) == attributes.cryptographic_algorithm { - let action = rekey_edit_action_from_attributes(kms.vendor_id(), attributes)?; - return Box::pin(rekey_keypair_cover_crypt( - kms, - Covercrypt::default(), - owm.id().to_owned(), - user, - action, - owm.attributes().sensitive.unwrap_or(false), - privileged_users, - )) - .await - .context("ReKeyKeyPair: Covercrypt rekey failed"); - } - } - - // Skip Covercrypt keys in FIPS mode - #[cfg(not(feature = "non-fips"))] - if key_format_type == Some(KeyFormatType::CoverCryptSecretKey) { - continue; - } - - // ── General asymmetric key pair rekey (RSA, EC, PQC) ── - - // Reject wrapped keys - if owm.object().key_wrapping_data().is_some() { - kms_bail!(KmsError::InconsistentOperation( - "The server cannot rekey: the private key is wrapped. Unwrap it first.".to_owned() - )) - } - - let old_sk_uid = owm.id().to_owned(); - - // Follow PublicKeyLink to find the paired public key - let old_pk_uid = owm - .attributes() - .get_link(LinkType::PublicKeyLink) - .ok_or_else(|| { - KmsError::InvalidRequest( - "ReKeyKeyPair: the private key has no PublicKeyLink. Cannot determine the \ - paired public key." - .to_owned(), - ) - })? - .to_string(); - - // Retrieve the old public key - let old_pk_owm = kms - .database - .retrieve_objects(&old_pk_uid) - .await? - .into_values() - .next() - .ok_or_else(|| { - KmsError::Kmip21Error( - ErrorReason::Item_Not_Found, - format!("ReKeyKeyPair: linked public key '{old_pk_uid}' not found in database"), - ) - })?; - - // Reject wrapped public keys too - if old_pk_owm.object().key_wrapping_data().is_some() { - kms_bail!(KmsError::InconsistentOperation( - "The server cannot rekey: the public key is wrapped. Unwrap it first.".to_owned() - )) - } - - // Validate that the request doesn't try to change cryptographic parameters - validate_no_crypto_param_change(owm.attributes(), &request)?; - - // Build a CreateKeyPair request from the existing key's attributes - let mut common_attrs = owm.attributes().clone(); - // Clear fields that shouldn't be passed to key generation - common_attrs.unique_identifier = None; - common_attrs.link = None; - common_attrs.name = None; - common_attrs.initial_date = None; - common_attrs.last_change_date = None; - common_attrs.activation_date = None; - common_attrs.deactivation_date = None; - common_attrs.destroy_date = None; - common_attrs.compromise_date = None; - common_attrs.compromise_occurrence_date = None; - // Remove vendor tag attribute (contains system tags like _sk/_pk) - common_attrs.remove_vendor_attribute(kms.vendor_id(), "tag"); - - let new_sk_uid = Uuid::new_v4().to_string(); - let new_pk_uid = Uuid::new_v4().to_string(); - - let create_kp_request = CreateKeyPair { - common_attributes: Some(common_attrs), - private_key_attributes: None, - public_key_attributes: None, - common_protection_storage_masks: None, - private_protection_storage_masks: None, - public_protection_storage_masks: None, - }; - - let key_pair = - generate_key_pair(kms.vendor_id(), create_kp_request, &new_sk_uid, &new_pk_uid)?; - - // Prepare replacement attributes for both private and public keys - let new_sk_attributes = - prepare_replacement_attributes(owm.attributes(), &old_sk_uid, offset)?; - let new_pk_attributes = - prepare_replacement_attributes(old_pk_owm.attributes(), &old_pk_uid, offset)?; - - let sk_activation_date = new_sk_attributes.activation_date; - let pk_activation_date = new_pk_attributes.activation_date; - - // Set up private key lifecycle - let mut new_private_key = key_pair.private_key().to_owned(); - - // Set the replacement attributes on the new private key's internal attributes - if let Ok(sk_attrs) = new_private_key.attributes_mut() { - sk_attrs.name.clone_from(&new_sk_attributes.name); - sk_attrs.set_link( - LinkType::ReplacedObjectLink, - LinkedObjectIdentifier::TextString(old_sk_uid.clone()), - ); - sk_attrs.set_link( - LinkType::PublicKeyLink, - LinkedObjectIdentifier::TextString(new_pk_uid.clone()), - ); - } - - let new_sk_obj_attributes = setup_object_lifecycle( - &mut new_private_key, - ObjectType::PrivateKey, - sk_activation_date, - )?; - let sk_tags = new_sk_obj_attributes.get_tags(kms.vendor_id()); - - Box::pin(wrap_and_cache( - kms, - user, - &UniqueIdentifier::TextString(new_sk_uid.clone()), - &mut new_private_key, - )) - .await?; - - // Set up public key lifecycle - let mut new_public_key = key_pair.public_key().to_owned(); - - // Set the replacement attributes on the new public key's internal attributes - if let Ok(pk_attrs) = new_public_key.attributes_mut() { - pk_attrs.name.clone_from(&new_pk_attributes.name); - pk_attrs.set_link( - LinkType::ReplacedObjectLink, - LinkedObjectIdentifier::TextString(old_pk_uid.clone()), - ); - pk_attrs.set_link( - LinkType::PrivateKeyLink, - LinkedObjectIdentifier::TextString(new_sk_uid.clone()), - ); - } - - let new_pk_obj_attributes = setup_object_lifecycle( - &mut new_public_key, - ObjectType::PublicKey, - pk_activation_date, - )?; - let pk_tags = new_pk_obj_attributes.get_tags(kms.vendor_id()); - - Box::pin(wrap_and_cache( - kms, - user, - &UniqueIdentifier::TextString(new_pk_uid.clone()), - &mut new_public_key, - )) - .await?; - - // Update old private key - let mut old_sk_object = owm.object().clone(); - let mut old_sk_attributes = owm.attributes().clone(); - update_old_key_after_rekey(&mut old_sk_attributes, &new_sk_uid)?; - if let Ok(obj_attrs) = old_sk_object.attributes_mut() { - update_old_key_after_rekey(obj_attrs, &new_sk_uid)?; - } - - // Update old public key - let mut old_pk_object = old_pk_owm.object().clone(); - let mut old_pk_attributes = old_pk_owm.attributes().clone(); - update_old_key_after_rekey(&mut old_pk_attributes, &new_pk_uid)?; - if let Ok(obj_attrs) = old_pk_object.attributes_mut() { - update_old_key_after_rekey(obj_attrs, &new_pk_uid)?; - } - - // Execute all operations atomically: - // 1. Create new private key - // 2. Create new public key - // 3. Update old private key - // 4. Update old public key - let operations = vec![ - AtomicOperation::Create(( - new_sk_uid.clone(), - new_private_key, - new_sk_obj_attributes, - sk_tags, - )), - AtomicOperation::Create(( - new_pk_uid.clone(), - new_public_key, - new_pk_obj_attributes, - pk_tags, - )), - AtomicOperation::UpdateObject(( - old_sk_uid.clone(), - old_sk_object, - old_sk_attributes, - None, - )), - AtomicOperation::UpdateObject(( - old_pk_uid.clone(), - old_pk_object, - old_pk_attributes, - None, - )), - ]; - - kms.database.atomic(user, &operations).await?; - - info!( - old_sk_uid = old_sk_uid, - old_pk_uid = old_pk_uid, - new_sk_uid = new_sk_uid, - new_pk_uid = new_pk_uid, - user = user, - "Re-keyed key pair: new replacement keys created, old keys remain Active", - ); - - return Ok(ReKeyKeyPairResponse { - private_key_unique_identifier: UniqueIdentifier::TextString(new_sk_uid), - public_key_unique_identifier: UniqueIdentifier::TextString(new_pk_uid), - }); - } - - Err(KmsError::Kmip21Error( - ErrorReason::Item_Not_Found, - uid_or_tags.to_owned(), - )) -} - -/// Validate that the `ReKeyKeyPair` request does not attempt to change cryptographic parameters. -/// -/// Per KMIP 1.4 §4.5: "Attributes of the replacement key pair are copied from the existing -/// key pair." Changing algorithm, curve, or key length requires a new `CreateKeyPair` instead. -fn validate_no_crypto_param_change( - existing_attrs: &cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_attributes::Attributes, - request: &ReKeyKeyPair, -) -> KResult<()> { - // Check all attribute sources in the request - for req_attrs in [ - request.common_attributes.as_ref(), - request.private_key_attributes.as_ref(), - request.public_key_attributes.as_ref(), - ] - .into_iter() - .flatten() - { - if let Some(algo) = req_attrs.cryptographic_algorithm { - if existing_attrs.cryptographic_algorithm != Some(algo) { - kms_bail!(KmsError::InvalidRequest( - "ReKeyKeyPair: changing the cryptographic algorithm is not allowed. \ - Use CreateKeyPair for a different algorithm." - .to_owned() - )) - } - } - if let Some(ref cdp) = req_attrs.cryptographic_domain_parameters { - if let Some(ref existing_cdp) = existing_attrs.cryptographic_domain_parameters { - if cdp.recommended_curve.is_some() - && cdp.recommended_curve != existing_cdp.recommended_curve - { - kms_bail!(KmsError::InvalidRequest( - "ReKeyKeyPair: changing the recommended curve is not allowed. \ - Use CreateKeyPair for a different curve." - .to_owned() - )) - } - } - } - if let Some(len) = req_attrs.cryptographic_length { - if existing_attrs.cryptographic_length.is_some() - && existing_attrs.cryptographic_length != Some(len) - { - kms_bail!(KmsError::InvalidRequest( - "ReKeyKeyPair: changing the cryptographic length is not allowed. \ - Use CreateKeyPair for a different key size." - .to_owned() - )) - } - } - } - Ok(()) -} diff --git a/crate/server/src/core/operations/revoke.rs b/crate/server/src/core/operations/revoke.rs index f0b72d4c3d..37560cbaf3 100644 --- a/crate/server/src/core/operations/revoke.rs +++ b/crate/server/src/core/operations/revoke.rs @@ -24,7 +24,6 @@ use crate::core::cover_crypt::revoke_user_decryption_keys; use crate::{ core::{ KMS, - operations::key_ops::{ObjectWithMetadataOps, record_cascading_metrics}, uid_utils::{has_prefix, uids_from_unique_identifier}, }, error::KmsError, @@ -142,6 +141,12 @@ pub(crate) async fn recursively_revoke_key( "[revoke] proceed-destroyed uid={uid} state={:?}", owm.state() ); + } else if owm.state() == State::Deactivated { + // KMIP §4.45 / §4.57: revoking an already-Deactivated key is a no-op success. + // After Re-Key the old key is Deactivated; cleanup sequences that call + // Revoke → Destroy must still succeed. + count += 1; + continue; } else { trace!( "[revoke] skip uid={uid} reason=state-not-revocable state={:?}", @@ -160,8 +165,8 @@ pub(crate) async fn recursively_revoke_key( continue; } // if the user is not the owner, we need to check if the user has the right to revoke - if !owm - .user_can_perform_operation(user, &KmipOperation::Revoke, kms) + if !kms + .user_can_perform_operation(&owm, user, &KmipOperation::Revoke) .await? { continue; @@ -175,12 +180,12 @@ pub(crate) async fn recursively_revoke_key( | ObjectType::SecretData | ObjectType::OpaqueObject => { // revoke the key - revoke_key_core( + Box::pin(revoke_key_core( owm, revocation_reason.clone(), compromise_occurrence_date, kms, - ) + )) .await?; } ObjectType::PrivateKey => { @@ -219,16 +224,16 @@ pub(crate) async fn recursively_revoke_key( ids_to_skip.clone(), ) .await?; - record_cascading_metrics("Revoke", op_start, kms, user); + kms.record_cascading_metrics("Revoke", op_start, user); } } } - revoke_key_core( + Box::pin(revoke_key_core( owm, revocation_reason.clone(), compromise_occurrence_date, kms, - ) + )) .await?; } ObjectType::PublicKey => { @@ -253,16 +258,16 @@ pub(crate) async fn recursively_revoke_key( ids_to_skip.clone(), ) .await?; - record_cascading_metrics("Revoke", op_start, kms, user); + kms.record_cascading_metrics("Revoke", op_start, user); } } } - revoke_key_core( + Box::pin(revoke_key_core( owm, revocation_reason.clone(), compromise_occurrence_date, kms, - ) + )) .await?; } x => kms_bail!(KmsError::NotSupported(format!( diff --git a/crate/server/src/core/operations/sign.rs b/crate/server/src/core/operations/sign.rs index e93ed5b3ba..c60829b13c 100644 --- a/crate/server/src/core/operations/sign.rs +++ b/crate/server/src/core/operations/sign.rs @@ -21,10 +21,7 @@ use cosmian_logger::{debug, trace}; use openssl::pkey::{Id, PKey, Private}; use crate::{ - core::{ - KMS, - operations::{CryptoOpSpec, has_usage_mask, perform_crypto_operation}, - }, + core::{KMS, operations::CryptoOpSpec}, error::KmsError, kms_bail, result::KResult, @@ -54,7 +51,7 @@ impl CryptoOpSpec for SignOp { fn is_key_eligible(owm: &ObjectWithMetadata, _vendor_id: &str) -> bool { if let Object::PrivateKey { .. } = owm.object() { - return has_usage_mask(owm, CryptographicUsageMask::Sign, false); + return owm.has_usage_mask(CryptographicUsageMask::Sign, false); } false } @@ -117,7 +114,7 @@ pub(crate) async fn sign(kms: &KMS, request: Sign, user: &str) -> KResult(kms, request, user)).await + Box::pin(kms.perform_crypto_operation::(request, user)).await } fn sign_with_private_key(request: &Sign, owm: &ObjectWithMetadata) -> KResult { diff --git a/crate/server/src/core/operations/signature_verify.rs b/crate/server/src/core/operations/signature_verify.rs index 77734896d2..1b47f4b9ee 100644 --- a/crate/server/src/core/operations/signature_verify.rs +++ b/crate/server/src/core/operations/signature_verify.rs @@ -1,6 +1,6 @@ use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ - kmip_0::kmip_types::CryptographicUsageMask, + kmip_0::kmip_types::{CryptographicUsageMask, State}, kmip_2_1::{ KmipOperation, kmip_objects::{Object, ObjectType}, @@ -23,7 +23,7 @@ use openssl::pkey::{Id, PKey, Public}; use crate::{ core::{ KMS, - operations::{CryptoOpSpec, has_usage_mask, perform_crypto_operation}, + operations::{CryptoOpSpec, KeysetMode}, }, error::KmsError, kms_bail, @@ -44,6 +44,15 @@ impl CryptoOpSpec for SignatureVerifyOp { request.unique_identifier.as_ref() } + fn keyset_mode() -> KeysetMode { + KeysetMode::TryEach + } + + /// `SignatureVerify` accepts Active, Deactivated, and Compromised keys per KMIP 2.1 §3.31. + fn accepted_states() -> &'static [State] { + &[State::Active, State::Deactivated, State::Compromised] + } + fn usage_data_len(request: &Self::Request) -> usize { request .data @@ -60,7 +69,7 @@ impl CryptoOpSpec for SignatureVerifyOp { // Use Verify mask with lenient=true so imported keys without an explicit mask // still work. Object::PublicKey { .. } | Object::PrivateKey { .. } => { - has_usage_mask(owm, CryptographicUsageMask::Verify, true) + owm.has_usage_mask(CryptographicUsageMask::Verify, true) } _ => false, } @@ -218,10 +227,7 @@ pub(crate) async fn signature_verify( )); } - Box::pin(perform_crypto_operation::( - kms, request, user, - )) - .await + Box::pin(kms.perform_crypto_operation::(request, user)).await } /// Extract the verification key from a managed object. diff --git a/crate/server/src/core/retrieve_object_utils.rs b/crate/server/src/core/retrieve_object_utils.rs index e87f695a98..55efa8b598 100644 --- a/crate/server/src/core/retrieve_object_utils.rs +++ b/crate/server/src/core/retrieve_object_utils.rs @@ -110,12 +110,9 @@ pub(crate) async fn retrieve_object_for_operation( attributes.state = Some(effective_state); } - // KMIP 2.1 Auto-activation: Automatically activate PreActive objects when activation_date has passed - // This ensures the database state stays synchronized with the object's actual lifecycle state + // KMIP 2.1 Auto-activation: PreActive → Active when ActivationDate has passed (§4.57 transition 4) if effective_state == State::PreActive { - // Check if activation_date is set and has passed let activation_date = owm.attributes().activation_date.or_else(|| { - // Fallback to object's attributes if not in metadata owm.object() .attributes() .ok() @@ -125,23 +122,16 @@ pub(crate) async fn retrieve_object_for_operation( if let Some(activation_date) = activation_date { let now = time_normalize()?; if activation_date <= now { - // Activation date has passed, automatically transition to Active trace!( "Auto-activating object {} (activation_date {} <= now {})", owm.id(), activation_date, now ); - - // Update state in both the object attributes and metadata owm.attributes_mut().state = Some(State::Active); if let Ok(ref mut attributes) = owm.object_mut().attributes_mut() { attributes.state = Some(State::Active); } - - // Persist the state change to database - // Note: We do this synchronously to ensure consistency, but log errors - // rather than failing the retrieval if the update fails if let Err(e) = kms.database.update_state(owm.id(), State::Active).await { warn!( "Failed to persist auto-activation of object {}: {}", @@ -149,6 +139,75 @@ pub(crate) async fn retrieve_object_for_operation( e ); } + // Re-check: the now-Active key may also need auto-deactivation + let deactivation_date = owm.attributes().deactivation_date.or_else(|| { + owm.object() + .attributes() + .ok() + .and_then(|attrs| attrs.deactivation_date) + }); + if let Some(deactivation_date) = deactivation_date { + if deactivation_date <= now { + trace!( + "Auto-deactivating object {} (deactivation_date {} <= now {})", + owm.id(), + deactivation_date, + now + ); + owm.attributes_mut().state = Some(State::Deactivated); + if let Ok(ref mut attributes) = owm.object_mut().attributes_mut() { + attributes.state = Some(State::Deactivated); + } + if let Err(e) = kms + .database + .update_state(owm.id(), State::Deactivated) + .await + { + warn!( + "Failed to persist auto-deactivation of object {}: {}", + owm.id(), + e + ); + } + } + } + } + } + } + + // KMIP 2.1 Auto-deactivation: Active → Deactivated when DeactivationDate has passed (§4.57 transition 6) + if owm.attributes().state == Some(State::Active) { + let deactivation_date = owm.attributes().deactivation_date.or_else(|| { + owm.object() + .attributes() + .ok() + .and_then(|attrs| attrs.deactivation_date) + }); + + if let Some(deactivation_date) = deactivation_date { + let now = time_normalize()?; + if deactivation_date <= now { + trace!( + "Auto-deactivating object {} (deactivation_date {} <= now {})", + owm.id(), + deactivation_date, + now + ); + owm.attributes_mut().state = Some(State::Deactivated); + if let Ok(ref mut attributes) = owm.object_mut().attributes_mut() { + attributes.state = Some(State::Deactivated); + } + if let Err(e) = kms + .database + .update_state(owm.id(), State::Deactivated) + .await + { + warn!( + "Failed to persist auto-deactivation of object {}: {}", + owm.id(), + e + ); + } } } } diff --git a/crate/server/src/core/uid_utils.rs b/crate/server/src/core/uid_utils.rs index 8ad5bd5b32..2e0f8ddefa 100644 --- a/crate/server/src/core/uid_utils.rs +++ b/crate/server/src/core/uid_utils.rs @@ -1,10 +1,12 @@ use std::collections::HashSet; use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::UniqueIdentifier; +use cosmian_logger::trace; use crate::{ config::HsmInstanceParams, core::KMS, + error::KmsError, result::{KResult, KResultHelper}, }; @@ -74,8 +76,332 @@ pub(super) async fn uids_from_unique_identifier( Ok(HashSet::from([uid_or_tags.to_owned()])) } +// ─── Keyset Resolution ─────────────────────────────────────────────────────── + +/// The result of parsing a keyset identifier (`name@version` syntax). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum KeysetVersion { + /// `name@latest` — resolve to the key with `rotate_latest=true` + Latest, + /// `name@first` or `name@0` — resolve to generation 0 + First, + /// `name@N` — resolve to a specific generation number + Generation(i32), + /// Bare `name` (no `@` suffix) — interpretation depends on operation mode + Bare, +} + +/// Parsed keyset reference. +#[derive(Debug, Clone)] +pub(crate) struct KeysetRef { + pub name: String, + pub version: KeysetVersion, +} + +/// Returns `true` if the string looks like a UUID (8-4-4-4-12 hex pattern). +fn looks_like_uuid(s: &str) -> bool { + // Quick length check: standard UUID is 36 chars + if s.len() != 36 { + return false; + } + s.chars().enumerate().all(|(i, c)| match i { + 8 | 13 | 18 | 23 => c == '-', + _ => c.is_ascii_hexdigit(), + }) +} + +/// Try to parse an identifier as a keyset reference. +/// +/// A keyset reference is recognized when: +/// - It is NOT a tag JSON (`[...]`) +/// - It is NOT an HSM prefix (`hsm::...`) +/// - It is NOT a UUID +/// - It contains `@` (explicit version) OR is a bare name (fallback resolution) +/// +/// Returns `None` if the identifier doesn't match keyset syntax. +pub(crate) fn parse_keyset_identifier(identifier: &str) -> Option { + // Skip tags and UUIDs. + if identifier.starts_with('[') { + return None; + } + // HSM UID handling: + // - With an `@N` generation suffix (e.g. `hsm::softhsm2::0::my-key@1`) → direct PKCS#11 + // key handle for a specific generation; never a keyset reference. + // - Without `@N` (plain base UID, e.g. `hsm::softhsm2::0::my-key`) → this IS the keyset + // name (= rotate_name = full base UID). Treat it as a bare keyset reference so that: + // · Encrypt / Sign (`SingleLatest`) resolves to the current latest generation. + // · Decrypt / Verify (`TryEach`) chain-walks through all generations newest-to-oldest. + if identifier.starts_with("hsm::") { + if identifier.contains('@') { + // Explicit `@N` → direct generation handle, not a keyset ref. + return None; + } + // Plain HSM base UID → bare keyset reference. + return Some(KeysetRef { + name: identifier.to_owned(), + version: KeysetVersion::Bare, + }); + } + + if let Some(at_pos) = identifier.rfind('@') { + let name = &identifier[..at_pos]; + let version_str = &identifier[at_pos + 1..]; + + // If the part before @ looks like a UUID, it's not a keyset reference + if looks_like_uuid(name) { + return None; + } + + // Empty name is not valid + if name.is_empty() { + return None; + } + + let version = match version_str { + "latest" => KeysetVersion::Latest, + "first" => KeysetVersion::First, + s => { + if let Ok(n) = s.parse::() { + if n == 0 { + KeysetVersion::First + } else { + KeysetVersion::Generation(n) + } + } else { + // Invalid version specifier — not a keyset reference + return None; + } + } + }; + + Some(KeysetRef { + name: name.to_owned(), + version, + }) + } else { + // No `@` — could be a bare keyset name if it's not a UUID + if looks_like_uuid(identifier) { + return None; + } + // It could be a plain UID that isn't a UUID (e.g. user-chosen IDs). + // We return a Bare keyset reference — the caller will attempt DB lookup + // and fall back to direct UID if the keyset name doesn't exist. + Some(KeysetRef { + name: identifier.to_owned(), + version: KeysetVersion::Bare, + }) + } +} + +/// Resolve a keyset identifier to a single UID (for encrypt/sign operations). +/// +/// For `@latest` or `Bare` mode, resolves to the key with the highest `rotate_generation`. +/// For `@first` or `@N`, resolves to the key with the matching generation. +/// +/// Returns `None` if the keyset name doesn't match any object. +pub(crate) async fn resolve_keyset_to_single_uid( + keyset_ref: &KeysetRef, + kms: &KMS, + user: &str, +) -> KResult> { + let generation = match &keyset_ref.version { + KeysetVersion::Latest | KeysetVersion::Bare => None, + KeysetVersion::First => Some(0), + KeysetVersion::Generation(n) => Some(*n), + }; + + let results = kms + .database + .find_by_rotate_name(&keyset_ref.name, generation, user) + .await?; + + match results.len() { + 0 => Ok(None), + 1 => Ok(Some( + results + .into_iter() + .next() + .map(|(uid, _)| uid) + .unwrap_or_default(), + )), + _ => { + // Multiple matches — take the one with highest generation + let best = results + .into_iter() + .max_by_key(|(_, attrs)| attrs.rotate_generation.unwrap_or(0)); + Ok(best.map(|(uid, _)| uid)) + } + } +} + +/// Resolve a keyset reference in a rekey operation to a concrete UID. +/// +/// This combines [`parse_keyset_identifier`] + [`resolve_keyset_to_single_uid`] into a +/// single call with uniform error handling, suitable for use in rekey dispatchers. +/// +/// # Return value +/// - `Ok(None)` — `uid` is not a keyset reference, or is a bare keyset name that has no +/// members (key exists but has no `rotate_name` set, or keyset lookup returns empty). +/// The caller should treat `uid` as a direct object identifier. +/// - `Ok(Some(resolved_uid))` — `uid` was a keyset reference and resolved successfully. +/// - `Err(...)` — `uid` used an explicit versioned keyset syntax (`@latest`, `@N`, `@first`) +/// but the keyset could not be found; this is always a user error. +pub(crate) async fn resolve_uid_or_keyset( + uid: &str, + op_name: &str, + kms: &KMS, + user: &str, +) -> KResult> { + let Some(keyset_ref) = parse_keyset_identifier(uid) else { + return Ok(None); + }; + let resolved = resolve_keyset_to_single_uid(&keyset_ref, kms, user).await?; + match resolved { + Some(resolved_uid) => Ok(Some(resolved_uid)), + None => match &keyset_ref.version { + // Bare name (or plain HSM base UID): no keyset members found → fall through + // to direct object lookup. The key may simply have no rotate_name set yet. + KeysetVersion::Bare => Ok(None), + // Explicit versioned ref (`@latest`, `@N`, `@first`): not finding the keyset + // is always a user error. + _ => Err(KmsError::InvalidRequest(format!( + "{op_name}: keyset '{uid}' not found or has no resolvable latest key" + ))), + }, + } +} + +/// Walk the keyset rotation chain from the latest key backward. +/// +/// Fetches every member of the keyset (all generations) via `find_by_rotate_name`, +/// then sorts them newest-to-oldest by `rotate_generation`. +/// +/// This works identically for SQL and HSM keys: +/// - SQL keys store generation in the `RotateGeneration` JSON attribute. +/// - HSM keys store generation in `CKA_LABEL` and expose it through the same +/// `rotate_generation` field. +/// +/// `ReplacedObjectLink` / `ReplacementObjectLink` back-pointers are still +/// written on each rotation for KMIP protocol compliance, but are not used +/// for chain traversal. +/// +/// Returns the ordered list of UIDs to try for decryption (newest first). +pub(crate) async fn walk_keyset_chain( + keyset_name: &str, + kms: &KMS, + user: &str, +) -> KResult> { + let results = kms + .database + .find_by_rotate_name(keyset_name, None, user) + .await?; + + if results.is_empty() { + return Ok(vec![]); + } + + // Sort all generations newest-first. + let mut all_pairs: Vec<(String, i32)> = results + .into_iter() + .map(|(uid, attrs)| (uid, attrs.rotate_generation.unwrap_or(0))) + .collect(); + all_pairs.sort_by(|a, b| b.1.cmp(&a.1)); + let chain: Vec = all_pairs.into_iter().map(|(uid, _)| uid).collect(); + + trace!( + "walk_keyset_chain: keyset '{}' has {} keys in chain", + keyset_name, + chain.len() + ); + + Ok(chain) +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + #[cfg(test)] +#[expect(clippy::expect_used)] mod tests { + use super::*; + + #[test] + fn test_parse_keyset_latest() { + let r = parse_keyset_identifier("my-keys@latest").expect("should parse"); + assert_eq!(r.name, "my-keys"); + assert_eq!(r.version, KeysetVersion::Latest); + } + + #[test] + fn test_parse_keyset_first() { + let r = parse_keyset_identifier("my-keys@first").expect("should parse"); + assert_eq!(r.name, "my-keys"); + assert_eq!(r.version, KeysetVersion::First); + + let r = parse_keyset_identifier("my-keys@0").expect("should parse"); + assert_eq!(r.name, "my-keys"); + assert_eq!(r.version, KeysetVersion::First); + } + + #[test] + fn test_parse_keyset_generation() { + let r = parse_keyset_identifier("my-keys@3").expect("should parse"); + assert_eq!(r.name, "my-keys"); + assert_eq!(r.version, KeysetVersion::Generation(3)); + } + + #[test] + fn test_parse_keyset_bare() { + let r = parse_keyset_identifier("my-production-key").expect("should parse"); + assert_eq!(r.name, "my-production-key"); + assert_eq!(r.version, KeysetVersion::Bare); + } + + #[test] + fn test_parse_uuid_not_keyset() { + assert!(parse_keyset_identifier("550e8400-e29b-41d4-a716-446655440000").is_none()); + } + + #[test] + fn test_parse_tags_not_keyset() { + assert!(parse_keyset_identifier("[\"tag1\",\"tag2\"]").is_none()); + } + + #[test] + fn test_parse_hsm_not_keyset() { + // HSM UID with @N suffix → explicit generation handle, NOT a keyset ref. + assert!(parse_keyset_identifier("hsm::softhsm2::0::my-key@1").is_none()); + assert!(parse_keyset_identifier("hsm::softhsm2::0::my-key@2").is_none()); + assert!(parse_keyset_identifier("hsm::1667223158::vec_rk_fl@1").is_none()); + } + + #[test] + fn test_parse_hsm_plain_base_uid_is_keyset() { + // Plain HSM base UID (no @N) → bare keyset reference (= rotate_name). + let r = parse_keyset_identifier("hsm::softhsm2::0::my-key").expect("should parse"); + assert_eq!(r.name, "hsm::softhsm2::0::my-key"); + assert_eq!(r.version, KeysetVersion::Bare); + + // Also works for the legacy single-segment slot format. + let r2 = parse_keyset_identifier("hsm::1667223158::vec_rk_fl").expect("should parse"); + assert_eq!(r2.name, "hsm::1667223158::vec_rk_fl"); + assert_eq!(r2.version, KeysetVersion::Bare); + } + + #[test] + fn test_parse_invalid_version() { + // "abc" after @ is not a valid version specifier + assert!(parse_keyset_identifier("my-key@abc").is_none()); + } + + #[test] + fn test_parse_uuid_with_at() { + // A UUID followed by @latest should NOT be treated as keyset + assert!(parse_keyset_identifier("550e8400-e29b-41d4-a716-446655440000@latest").is_none()); + } +} + +#[cfg(test)] +mod hsm_tests { use std::collections::HashMap; use super::*; diff --git a/crate/server/src/core/wrapping/wrap.rs b/crate/server/src/core/wrapping/wrap.rs index d8be34a717..5af21d35fc 100644 --- a/crate/server/src/core/wrapping/wrap.rs +++ b/crate/server/src/core/wrapping/wrap.rs @@ -14,7 +14,7 @@ use cosmian_kms_server_database::reexport::{ }, cosmian_kms_crypto::crypto::wrap::{key_data_to_wrap, wrap_object_with_key}, }; -use cosmian_logger::{debug, trace, warn}; +use cosmian_logger::{debug, trace}; use crate::{ core::{KMS, uid_utils::has_prefix, wrapping::unwrap_object}, @@ -59,22 +59,32 @@ pub(crate) async fn wrap_and_cache( // or in the HSM. // Either the user has provided a wrapping key ID or a key wrapping key is // provided in the parameters. - let Some(wrapping_key_id) = object + let uid_str = unique_identifier.to_string(); + let explicit_wrapping_key_id = object .attributes_mut() .ok() - .and_then(|attrs| attrs.remove_wrapping_key_id(kms.vendor_id())) - .or_else(|| kms.params.key_wrapping_key.clone()) - else { - // no wrapping key provided - return Ok(()); + .and_then(|attrs| attrs.remove_wrapping_key_id(kms.vendor_id())); + let wrapping_key_id = if let Some(id) = explicit_wrapping_key_id { + id + } else { + let Some(kek) = kms.params.key_wrapping_key.clone() else { + // no wrapping key provided + return Ok(()); + }; + // HSM-resident keys are hardware-protected: skip the server-wide KEK wrapping + // to avoid creating a circular dependency where the KEK would wrap itself. + if has_prefix(&uid_str).is_some() { + return Ok(()); + } + kek }; - // Cannot wrap yourself - if wrapping_key_id == unique_identifier.to_string() { - if kms.params.key_wrapping_key.is_none() { - warn!("Key {wrapping_key_id} attempted to wrap itself"); - } - return Ok(()); + // A key cannot be its own wrapping key. + if wrapping_key_id == uid_str { + return Err(KmsError::InvalidRequest(format!( + "Key '{wrapping_key_id}' cannot be used as its own wrapping key: \ + the wrapping key ID must differ from the key ID being created" + ))); } // This is useful to store a key on the default data store but wrapped by a key stored in an HSM @@ -245,7 +255,15 @@ async fn wrap_using_kms( "The wrapping key {wrapping_key_uid} is not active" ))); } - if wrapping_key.owner() != user { + // The server-configured key_encryption_key is a shared server resource accessible + // to all users, so skip the ownership check for it (mirrors the bypass in + // `wrap_using_crypto_oracle` — issue #761). + let is_server_kek = kms + .params + .key_wrapping_key + .as_deref() + .is_some_and(|kek| kek == wrapping_key_uid); + if !is_server_kek && wrapping_key.owner() != user { let ops = kms .database .list_user_operations_on_object(wrapping_key.id(), user, false) diff --git a/crate/server/src/cron.rs b/crate/server/src/cron.rs index e416e37ffa..8bc05efa78 100644 --- a/crate/server/src/cron.rs +++ b/crate/server/src/cron.rs @@ -3,7 +3,53 @@ use std::sync::Arc; use cosmian_logger::debug; use tokio::sync::oneshot; -use crate::core::KMS; +use crate::core::{ + KMS, + operations::{dispatch_renewal_warnings, run_auto_rotation}, +}; + +/// Spawn a background thread that periodically runs the key auto-rotation check. +/// The thread runs independently of the metrics cron and is spawned whenever +/// `auto_rotation_check_interval_secs > 0` in the server configuration. +/// +/// Returns a `oneshot::Sender<()>` that cleanly stops the thread when sent. +pub fn spawn_auto_rotation_cron(kms: Arc) -> oneshot::Sender<()> { + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let interval_secs = kms.params.auto_rotation_check_interval_secs; + + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + debug!("[auto-rotate-cron] Failed to build runtime: {}", e); + return; + } + }; + + rt.block_on(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); + let mut shutdown_rx = shutdown_rx; + loop { + tokio::select! { + _ = interval.tick() => { + debug!("[auto-rotate-cron] Running scheduled key auto-rotation check"); + Box::pin(run_auto_rotation(&kms)).await; + Box::pin(dispatch_renewal_warnings(&kms)).await; + } + _ = &mut shutdown_rx => { + debug!("[auto-rotate-cron] Shutdown signal received; stopping cron thread"); + break; + } + } + } + }); + }); + + shutdown_tx +} /// Spawn a background thread that periodically refreshes metrics. /// Returns a oneshot Sender that, when sent, cleanly stops the cron thread. diff --git a/crate/server/src/main.rs b/crate/server/src/main.rs index 009d1f4b77..838ed493b3 100644 --- a/crate/server/src/main.rs +++ b/crate/server/src/main.rs @@ -350,6 +350,8 @@ mod tests { privileged_users: None, secret_backends: cosmian_kms_server::config::SecretBackendConfig::default(), print_default_config: false, + auto_rotation_check_interval_secs: 0, + keyset_warn_depth: 5, }; let toml_string = r#" @@ -365,6 +367,8 @@ hsm_password = [] hsm_instances = [] key_encryption_key = "key wrapping key" kms_public_url = "[kms_public_url]" +auto_rotation_check_interval_secs = 0 +keyset_warn_depth = 5 [db] database_type = "[redis-findex, postgresql,...]" diff --git a/crate/server/src/middlewares/jwt/jwt_config.rs b/crate/server/src/middlewares/jwt/jwt_config.rs index 22125db2ac..f112cf6aa6 100644 --- a/crate/server/src/middlewares/jwt/jwt_config.rs +++ b/crate/server/src/middlewares/jwt/jwt_config.rs @@ -256,6 +256,11 @@ impl JwtConfig { #[cfg(test)] #[cfg(not(feature = "insecure"))] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::assertions_on_result_states +)] mod tests { use jsonwebtoken::Algorithm; @@ -332,7 +337,7 @@ mod tests { fn rejection_error_message_quality() { let result = check_alg(Algorithm::HS256); assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); + let msg = result.expect_err("HS256 must be rejected").to_string(); assert!( msg.contains("not permitted"), "error message should mention 'not permitted', got: {msg}" diff --git a/crate/server/src/routes/access.rs b/crate/server/src/routes/access.rs index ec51e27aa7..8789345b93 100644 --- a/crate/server/src/routes/access.rs +++ b/crate/server/src/routes/access.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use actix_web::{ HttpRequest, get, post, - web::{self, Data, Json, Path}, + web::{Data, Json, Path}, }; use cosmian_kms_access::access::{ Access, AccessRightsObtainedResponse, CreatePermissionResponse, ObjectOwnedResponse, @@ -98,7 +98,6 @@ pub(crate) async fn grant_access( req: HttpRequest, access: Json, kms: Data>, - privileged_users: web::Data>>, ) -> KResult> { let span = tracing::span!(tracing::Level::ERROR, "grant_access"); let _enter = span.enter(); @@ -111,8 +110,7 @@ pub(crate) async fn grant_access( "POST /access/grant" ); - kms.grant_access(&access, &user, privileged_users.as_ref().clone()) - .await?; + kms.grant_access(&access, &user).await?; debug!("Access granted on {}", access.user_id); Ok(Json(SuccessResponse { @@ -126,7 +124,6 @@ pub(crate) async fn revoke_access( req: HttpRequest, access: Json, kms: Data>, - privileged_users: Data>>, ) -> KResult> { let span = tracing::span!(tracing::Level::ERROR, "revoke_access"); let _enter = span.enter(); @@ -139,8 +136,7 @@ pub(crate) async fn revoke_access( "POST /access/revoke" ); - kms.revoke_access(&access, &user, privileged_users.as_ref().clone()) - .await?; + kms.revoke_access(&access, &user).await?; debug!("Access revoke for {}", access.user_id); Ok(Json(SuccessResponse { @@ -153,14 +149,13 @@ pub(crate) async fn revoke_access( pub(crate) async fn get_create_access( req: HttpRequest, kms: Data>, - privileged_users: web::Data>>, ) -> KResult> { let span = tracing::span!(tracing::Level::INFO, "get_create_access"); let _enter = span.enter(); let user = kms.get_user(&req); - let has_create_permission = match privileged_users.as_ref() { + let has_create_permission = match kms.params.privileged_users.as_ref() { Some(users) if users.contains(&user) => true, Some(_) => { user_has_permission( @@ -183,15 +178,15 @@ pub(crate) async fn get_create_access( pub(crate) async fn get_privileged_access( req: HttpRequest, kms: Data>, - privileged_users: web::Data>>, ) -> KResult> { let span = tracing::span!(tracing::Level::INFO, "get_create_access"); let _enter = span.enter(); let user = kms.get_user(&req); - let has_privileged_access = privileged_users - .as_ref() + let has_privileged_access = kms + .params + .privileged_users .as_ref() .is_some_and(|users| users.contains(&user)); Ok(Json(PrivilegedAccessResponse { diff --git a/crate/server/src/routes/aws_xks/key_metadata.rs b/crate/server/src/routes/aws_xks/key_metadata.rs index 4022be8d05..68f4c0610e 100644 --- a/crate/server/src/routes/aws_xks/key_metadata.rs +++ b/crate/server/src/routes/aws_xks/key_metadata.rs @@ -311,7 +311,7 @@ async fn create_key( protection_storage_masks: None, }; - if let Err(e) = kms.create(create, &kms.params.default_username, None).await { + if let Err(e) = kms.create(create, &kms.params.default_username).await { // If the key already exists, ignore the creation error (idempotent CreateKey). let get_att_response = kms .get_attributes( @@ -347,7 +347,6 @@ async fn create_key( ], }, &kms.params.default_username, - None, ) .await .map_err(|e| XksErrorReply { diff --git a/crate/server/src/routes/crypto/decrypt.rs b/crate/server/src/routes/crypto/decrypt.rs index 49541e8b66..4c053c1647 100644 --- a/crate/server/src/routes/crypto/decrypt.rs +++ b/crate/server/src/routes/crypto/decrypt.rs @@ -186,9 +186,14 @@ async fn decrypt_rsa_oaep( let tag_bytes = b64_decode("tag", &body.tag)?; // Resolve the private key — accept either private or public key UID - let owm = retrieve_object_for_operation(&kid, KmipOperation::Decrypt, kms, user) - .await - .map_err(CryptoApiError::from)?; + let owm = Box::pin(retrieve_object_for_operation( + &kid, + KmipOperation::Decrypt, + kms, + user, + )) + .await + .map_err(CryptoApiError::from)?; // Determine the private key object let private_key_owm = match owm.object() { @@ -203,12 +208,12 @@ async fn decrypt_rsa_oaep( "RSA-OAEP decrypt: public key has no linked private key".to_owned(), ) })?; - retrieve_object_for_operation( + Box::pin(retrieve_object_for_operation( &priv_key_uid.to_string(), KmipOperation::Decrypt, kms, user, - ) + )) .await .map_err(CryptoApiError::from)? } diff --git a/crate/server/src/routes/crypto/encrypt.rs b/crate/server/src/routes/crypto/encrypt.rs index cb079d263a..76b923e81a 100644 --- a/crate/server/src/routes/crypto/encrypt.rs +++ b/crate/server/src/routes/crypto/encrypt.rs @@ -140,9 +140,14 @@ async fn encrypt_rsa_oaep( aad: Option, ) -> CryptoResult { // Resolve the key — accept either private or public key UID - let owm = retrieve_object_for_operation(&kid, KmipOperation::Encrypt, kms, user) - .await - .map_err(CryptoApiError::from)?; + let owm = Box::pin(retrieve_object_for_operation( + &kid, + KmipOperation::Encrypt, + kms, + user, + )) + .await + .map_err(CryptoApiError::from)?; // Determine if this is a private key (resolve to linked public key) or already a public key let (public_key, private_key_uid) = match owm.object() { @@ -151,12 +156,12 @@ async fn encrypt_rsa_oaep( let pkey = if let Some(pub_key_uid) = owm.attributes().get_link(LinkType::PublicKeyLink) { - let pub_owm = retrieve_object_for_operation( + let pub_owm = Box::pin(retrieve_object_for_operation( &pub_key_uid.to_string(), KmipOperation::Encrypt, kms, user, - ) + )) .await .map_err(CryptoApiError::from)?; kmip_public_key_to_openssl(pub_owm.object()).map_err(|e| { diff --git a/crate/server/src/routes/crypto/keys.rs b/crate/server/src/routes/crypto/keys.rs index a4461a1b55..3d0a502396 100644 --- a/crate/server/src/routes/crypto/keys.rs +++ b/crate/server/src/routes/crypto/keys.rs @@ -161,7 +161,7 @@ async fn generate_symmetric_key( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .create(create_req, user, None) + .create(create_req, user) .await .map_err(CryptoApiError::from)?; @@ -210,7 +210,7 @@ async fn generate_ec_key_pair( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .create_key_pair(create_req, user, None) + .create_key_pair(create_req, user) .await .map_err(CryptoApiError::from)?; @@ -263,7 +263,7 @@ async fn generate_rsa_key_pair( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .create_key_pair(create_req, user, None) + .create_key_pair(create_req, user) .await .map_err(CryptoApiError::from)?; @@ -313,7 +313,7 @@ async fn generate_okp_key_pair( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .create_key_pair(create_req, user, None) + .create_key_pair(create_req, user) .await .map_err(CryptoApiError::from)?; @@ -437,7 +437,7 @@ async fn import_symmetric_key( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .import(import_req, user, None) + .import(import_req, user) .await .map_err(CryptoApiError::from)?; @@ -513,7 +513,7 @@ async fn import_public_key_for_private( ) .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; - kms.import(import_req, user, None) + kms.import(import_req, user) .await .map_err(CryptoApiError::from)?; @@ -677,7 +677,7 @@ async fn import_ec_key( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .import(import_req, user, None) + .import(import_req, user) .await .map_err(CryptoApiError::from)?; @@ -840,7 +840,7 @@ async fn import_rsa_key( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .import(import_req, user, None) + .import(import_req, user) .await .map_err(CryptoApiError::from)?; @@ -943,7 +943,7 @@ async fn import_okp_key( .map_err(|e| CryptoApiError::InternalError(e.to_string()))?; let resp = kms - .import(import_req, user, None) + .import(import_req, user) .await .map_err(CryptoApiError::from)?; diff --git a/crate/server/src/routes/crypto/sign.rs b/crate/server/src/routes/crypto/sign.rs index dc91022c85..568184e6ab 100644 --- a/crate/server/src/routes/crypto/sign.rs +++ b/crate/server/src/routes/crypto/sign.rs @@ -40,10 +40,14 @@ pub(crate) async fn sign( // Look up the private key's linked public key; fall back to body.kid for // symmetric keys and standalone keys that have no PublicKeyLink. let signing_kid = { - let owm = - retrieve_object_for_operation(&body.kid, KmipOperation::GetAttributes, &kms, &user) - .await - .map_err(CryptoApiError::from)?; + let owm = Box::pin(retrieve_object_for_operation( + &body.kid, + KmipOperation::GetAttributes, + &kms, + &user, + )) + .await + .map_err(CryptoApiError::from)?; owm.attributes() .get_link(LinkType::PublicKeyLink) .map_or_else(|| body.kid.clone(), |l| l.to_string()) diff --git a/crate/server/src/routes/crypto/unwrap.rs b/crate/server/src/routes/crypto/unwrap.rs index 6a40810f11..4d8b1f0ff9 100644 --- a/crate/server/src/routes/crypto/unwrap.rs +++ b/crate/server/src/routes/crypto/unwrap.rs @@ -104,9 +104,14 @@ pub(crate) async fn unwrap_key( } // Resolve the private key - let owm = retrieve_object_for_operation(&kid, KmipOperation::Decrypt, kms.as_ref(), &user) - .await - .map_err(CryptoApiError::from)?; + let owm = Box::pin(retrieve_object_for_operation( + &kid, + KmipOperation::Decrypt, + kms.as_ref(), + &user, + )) + .await + .map_err(CryptoApiError::from)?; let private_key_owm = match owm.object() { Object::PrivateKey { .. } => owm, @@ -119,12 +124,12 @@ pub(crate) async fn unwrap_key( "Key unwrap: public key has no linked private key".to_owned(), ) })?; - retrieve_object_for_operation( + Box::pin(retrieve_object_for_operation( &priv_key_uid.to_string(), KmipOperation::Decrypt, kms.as_ref(), &user, - ) + )) .await .map_err(CryptoApiError::from)? } @@ -206,7 +211,7 @@ pub(crate) async fn unwrap_key( }; let import_response = kms - .import(import_request, &user, None) + .import(import_request, &user) .await .map_err(CryptoApiError::from)?; diff --git a/crate/server/src/routes/kmip.rs b/crate/server/src/routes/kmip.rs index 44a98de45e..b60f1b93c5 100644 --- a/crate/server/src/routes/kmip.rs +++ b/crate/server/src/routes/kmip.rs @@ -4,7 +4,7 @@ use actix_web::{ HttpRequest, HttpResponse, http::header::CONTENT_TYPE, post, - web::{Bytes, Data, Json}, + web::{Bytes, Data}, }; use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ @@ -152,18 +152,21 @@ pub(crate) async fn kmip_2_1_json( req_http: HttpRequest, body: String, kms: Data>, -) -> KResult> { +) -> KResult { let ttlv = serde_json::from_str::(&body)?; let user = kms.get_user(&req_http); info!(target: "kmip", user=user, tag=ttlv.tag.as_str(), "POST /kmip/2_1. Request: {:?} {}", ttlv.tag.as_str(), user); let span = tracing::info_span!("kmip_2_1", user = user.as_str(), tag = ttlv.tag.as_str()); + let ttlv = Box::pin(handle_ttlv(&kms, ttlv, &user, 2, 1)) .instrument(span) .await?; - Ok(Json(ttlv)) + let mut builder = HttpResponse::Ok(); + builder.content_type("application/json"); + Ok(builder.json(ttlv)) } /// Handle input TTLV requests @@ -182,14 +185,19 @@ async fn handle_ttlv(kms: &KMS, ttlv: TTLV, user: &str, major: i32, minor: i32) return Ok(error_response_ttlv(major, minor, &e.to_string())); } }; - let resp = kms.message(req, user).await.unwrap_or_else(|e| { - error!(target: "kmip", "Failed to process request: {}", e); - invalid_response_message(major, minor, e.to_string()) - }); - Ok(to_ttlv(&resp).unwrap_or_else(|e| { + let span = tracing::span!(tracing::Level::ERROR, "message"); + let resp = Box::pin(message(kms, req, user)) + .instrument(span) + .await + .unwrap_or_else(|e| { + error!(target: "kmip", "Failed to process request: {}", e); + invalid_response_message(major, minor, e.to_string()) + }); + let ttlv = to_ttlv(&resp).unwrap_or_else(|e| { error!(target: "kmip", "Failed to convert response message to TTLV: {}", e); error_response_ttlv(major, minor, e.to_string().as_str()) - })) + }); + Ok(ttlv) } else { let operation = Box::pin(dispatch(kms, ttlv, user)).await?; Ok(to_ttlv(&operation)?) @@ -230,9 +238,9 @@ pub(crate) async fn kmip_json( error!(target: "kmip", "Failed to process request: {}", e); error_response_ttlv(2, 1, &e.to_string()) }); - HttpResponse::Ok() - .content_type("application/json") - .json(json) + let mut builder = HttpResponse::Ok(); + builder.content_type("application/json"); + builder.json(json) } /// Handle KMIP requests with JSON content type diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index defc00b07b..9818d63a0d 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -146,7 +146,7 @@ pub async fn handle_google_cse_rsa_keypair( None, )?; kms_server - .create_key_pair(create_request, &server_params.default_username, None) + .create_key_pair(create_request, &server_params.default_username) .await .map(|cr| { ( @@ -299,7 +299,7 @@ async fn import_cse_migration_key( false, vec![], )?; - kms_server.import(import_request_sk, &server_params.default_username, None) + kms_server.import(import_request_sk, &server_params.default_username) }; let import_pk_fut = { // Import PublicKey @@ -312,7 +312,7 @@ async fn import_cse_migration_key( false, vec![], )?; - kms_server.import(import_request_pk, &server_params.default_username, None) + kms_server.import(import_request_pk, &server_params.default_username) }; try_join!(import_sk_fut, import_pk_fut) @@ -362,6 +362,13 @@ pub async fn start_kms_server( None }; + // Spawn background auto-rotation cron thread and retain shutdown signal + let auto_rotation_shutdown_tx = if kms_server.params.auto_rotation_check_interval_secs > 0 { + Some(cron::spawn_auto_rotation_cron(kms_server.clone())) + } else { + None + }; + // Handle Google RSA Keypair for CSE Kacls migration if server_params.google_cse.google_cse_enable { handle_google_cse_rsa_keypair(&kms_server, &server_params) @@ -386,6 +393,10 @@ pub async fn start_kms_server( if let Some(tx) = metrics_shutdown_tx { let _ = tx.send(()); } + // Signal the auto-rotation cron thread to stop + if let Some(tx) = auto_rotation_shutdown_tx { + let _ = tx.send(()); + } if let Some(ss_command_tx) = ss_command_tx { // Send a shutdown command to the socket server ss_command_tx @@ -720,8 +731,6 @@ pub async fn prepare_kms_server(kms_server: Arc) -> KResult> = kms_server.params.privileged_users.clone(); - // Compute the public URL first so we can use it to derive the session key let kms_public_url = kms_server.params.kms_public_url.clone().unwrap_or_else(|| { format!( @@ -1000,6 +1009,7 @@ pub async fn prepare_kms_server(kms_server: Arc) -> KResult) -> KResult) -> KResult KResult<()> { #[cfg(test)] #[expect(clippy::expect_used)] +#[allow(clippy::assertions_on_result_states)] mod tests { use super::*; @@ -1318,7 +1327,9 @@ mod tests { let uris = vec!["http://idp.example.com/.well-known/jwks.json".to_owned()]; let result = validate_jwks_uris_are_https(&uris); assert!(result.is_err(), "HTTP JWKS URI must be rejected"); - let msg = result.unwrap_err().to_string(); + let msg = result + .expect_err("HTTP JWKS URI must be rejected") + .to_string(); assert!( msg.contains("HTTPS") || msg.contains("https"), "Error message must mention HTTPS, got: {msg}" @@ -1356,7 +1367,9 @@ mod tests { result.is_err(), "List containing an HTTP URI must be rejected" ); - let msg = result.unwrap_err().to_string(); + let msg = result + .expect_err("List containing an HTTP URI must be rejected") + .to_string(); assert!( msg.contains("bad.example.com"), "Error message must identify the offending URI, got: {msg}" diff --git a/crate/server/src/tests/azure_ekm/integration_tests.rs b/crate/server/src/tests/azure_ekm/integration_tests.rs index 10a9496e2f..2ab1a930bc 100644 --- a/crate/server/src/tests/azure_ekm/integration_tests.rs +++ b/crate/server/src/tests/azure_ekm/integration_tests.rs @@ -54,7 +54,7 @@ async fn test_wrap_unwrap_error_cases() -> KResult<()> { None, ) .unwrap(); - let create_response = kms.create(req, owner, None).await.unwrap(); + let create_response = kms.create(req, owner).await.unwrap(); let aes_kek_id = create_response.unique_identifier.to_string(); // Test invalid base64url - contains invalid characters @@ -157,7 +157,7 @@ async fn test_wrap_unwrap_error_cases() -> KResult<()> { None, ) .unwrap(); - let create_response = kms.create(req, owner, None).await.unwrap(); + let create_response = kms.create(req, owner).await.unwrap(); let aes_kek_id = create_response.unique_identifier.to_string(); let invalid_size_request = WrapKeyRequest { @@ -226,7 +226,7 @@ async fn test_wrap_unwrap_roundtrip_aes256_kw() -> KResult<()> { EMPTY_TAGS, )?; - let import_response = kms.import(import_request, owner, None).await?; + let import_response = kms.import(import_request, owner).await?; let kek_id = import_response.unique_identifier.to_string(); let wrap_request = WrapKeyRequest { @@ -316,7 +316,7 @@ async fn test_wrap_unwrap_roundtrip_aes256_kwp() -> KResult<()> { ) .unwrap(); - let import_response = kms.import(import_request, owner, None).await?; + let import_response = kms.import(import_request, owner).await?; let kek_id = import_response.unique_identifier.to_string(); let wrap_request = WrapKeyRequest { @@ -376,7 +376,6 @@ async fn test_wrap_unwrap_roundtrip_rsa_oaep_256() -> KResult<()> { None, )?, owner, - None, ) .await?; let key_id_private = create_keys.private_key_unique_identifier.to_string(); diff --git a/crate/server/src/tests/bulk_encrypt_decrypt_tests.rs b/crate/server/src/tests/bulk_encrypt_decrypt_tests.rs index 16cca32ed6..7f012d36fe 100644 --- a/crate/server/src/tests/bulk_encrypt_decrypt_tests.rs +++ b/crate/server/src/tests/bulk_encrypt_decrypt_tests.rs @@ -33,7 +33,7 @@ const NUM_MESSAGES: usize = 1000; #[tokio::test] async fn bulk_encrypt_decrypt() -> KResult<()> { cosmian_logger::log_init(option_env!("RUST_LOG")); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let response: CreateResponse = test_utils::post_2_1( &app, @@ -94,7 +94,7 @@ async fn bulk_encrypt_decrypt() -> KResult<()> { #[tokio::test] async fn single_encrypt_decrypt_cbc_mode() -> KResult<()> { cosmian_logger::log_init(option_env!("RUST_LOG")); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let response: CreateResponse = test_utils::post_2_1( &app, diff --git a/crate/server/src/tests/cover_crypt_tests/integration_tests.rs b/crate/server/src/tests/cover_crypt_tests/integration_tests.rs index 8a9a8667f6..0adb1ffabe 100644 --- a/crate/server/src/tests/cover_crypt_tests/integration_tests.rs +++ b/crate/server/src/tests/cover_crypt_tests/integration_tests.rs @@ -34,7 +34,7 @@ async fn integration_tests_use_ids_no_tags() -> KResult<()> { // cosmian_logger::log_init(Some("debug")); cosmian_logger::log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let access_structure = r#"{"Security Level::<":["Protected","Confidential","Top Secret::+"],"Department":["RnD","HR","MKG","FIN"]}"#; // create Key Pair diff --git a/crate/server/src/tests/cover_crypt_tests/integration_tests_bulk.rs b/crate/server/src/tests/cover_crypt_tests/integration_tests_bulk.rs index bb077f3542..edd00df92f 100644 --- a/crate/server/src/tests/cover_crypt_tests/integration_tests_bulk.rs +++ b/crate/server/src/tests/cover_crypt_tests/integration_tests_bulk.rs @@ -22,7 +22,7 @@ use crate::{result::KResult, tests::test_utils}; #[tokio::test] async fn integration_tests_bulk() -> KResult<()> { // cosmian_logger::log_init("trace,hyper=info,reqwest=info"); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // Parse the json access_structure file let access_structure = r#"{"Security Level::<":["Protected","Confidential","Top Secret::+"],"Department":["RnD","HR","MKG","FIN"]}"#; diff --git a/crate/server/src/tests/cover_crypt_tests/integration_tests_tags.rs b/crate/server/src/tests/cover_crypt_tests/integration_tests_tags.rs index 084c1f0ba8..17de30756c 100644 --- a/crate/server/src/tests/cover_crypt_tests/integration_tests_tags.rs +++ b/crate/server/src/tests/cover_crypt_tests/integration_tests_tags.rs @@ -28,7 +28,7 @@ use crate::{ #[tokio::test] async fn test_re_key_with_tags() -> KResult<()> { - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // create Key Pair let mkp_tag = "mkp"; let mkp_json_tag = serde_json::to_string(&[mkp_tag.to_owned()])?; @@ -88,7 +88,7 @@ async fn test_re_key_with_tags() -> KResult<()> { async fn integration_tests_with_tags() -> KResult<()> { cosmian_logger::log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // create Key Pair let mkp_tag = "mkp"; let mkp_json_tag = serde_json::to_string(&[mkp_tag.to_owned()])?; diff --git a/crate/server/src/tests/cover_crypt_tests/unit_tests.rs b/crate/server/src/tests/cover_crypt_tests/unit_tests.rs index 1e67714978..fb88c72b85 100644 --- a/crate/server/src/tests/cover_crypt_tests/unit_tests.rs +++ b/crate/server/src/tests/cover_crypt_tests/unit_tests.rs @@ -51,7 +51,6 @@ async fn test_cover_crypt_keys() -> KResult<()> { None, )?, owner, - None, ) .await?; debug!(" -> response {}", cr); @@ -125,7 +124,7 @@ async fn test_cover_crypt_keys() -> KResult<()> { }, object: pk.clone(), }; - kms.import(request, owner, None).await.unwrap_err(); + kms.import(request, owner).await.unwrap_err(); // re-import public key - should succeed let request = Import { @@ -139,7 +138,7 @@ async fn test_cover_crypt_keys() -> KResult<()> { }, object: pk.clone(), }; - let _update_response = kms.import(request, owner, None).await?; + let _update_response = kms.import(request, owner).await?; // User decryption key let access_policy = "(Department::MKG || Department::FIN) && Security Level::Confidential"; @@ -154,7 +153,7 @@ async fn test_cover_crypt_keys() -> KResult<()> { false, None, )?; - let cr = kms.create(request, owner, None).await?; + let cr = kms.create(request, owner).await?; debug!("Create Response for User Decryption Key {}", cr); let usk_uid = cr.unique_identifier.to_string(); @@ -190,7 +189,7 @@ async fn test_cover_crypt_keys() -> KResult<()> { false, None, )?; - let cr = kms.create(request, owner, None).await?; + let cr = kms.create(request, owner).await?; debug!("Create Response for User Decryption Key {}", cr); let usk_uid = cr.unique_identifier.to_string(); @@ -250,7 +249,6 @@ async fn test_abe_encrypt_decrypt() -> KResult<()> { None, )?, owner, - None, ) .await?; let master_secret_key_id = ckr @@ -358,7 +356,6 @@ async fn test_abe_encrypt_decrypt() -> KResult<()> { None, )?, owner, - None, ) .await?; let secret_mkg_fin_user_key = &cr @@ -455,7 +452,7 @@ async fn test_abe_json_access() -> KResult<()> { )?; // create Key Pair - let ckr = kms.create_key_pair(master_keypair, owner, None).await?; + let ckr = kms.create_key_pair(master_keypair, owner).await?; let master_secret_key_uid = ckr.private_key_unique_identifier.to_string(); // define search criteria @@ -499,7 +496,6 @@ async fn test_abe_json_access() -> KResult<()> { None, )?, owner, - None, ) .await?; let secret_mkg_fin_user_key_id = &cr.unique_identifier; @@ -542,7 +538,6 @@ async fn test_import_decrypt() -> KResult<()> { None, )?, owner, - None, ) .await?; debug!(" -> response created"); @@ -592,7 +587,6 @@ async fn test_import_decrypt() -> KResult<()> { None, )?, owner, - None, ) .await?; let secret_mkg_fin_user_key = cr.unique_identifier.to_string(); @@ -626,9 +620,7 @@ async fn test_import_decrypt() -> KResult<()> { }, object: gr_sk.object.clone(), }; - kms.import(request, owner, None) - .await - .context(&custom_sk_uid)?; + kms.import(request, owner).await.context(&custom_sk_uid)?; // decrypt resource MKG + Confidential let dr = kms @@ -661,9 +653,7 @@ async fn test_import_decrypt() -> KResult<()> { attributes: gr_sk.object.attributes()?.clone(), object: gr_sk.object.clone(), }; - kms.import(request, owner, None) - .await - .context(&custom_sk_uid)?; + kms.import(request, owner).await.context(&custom_sk_uid)?; // Note: No activation needed here because the imported attributes include // activation_date from the original key, so it's imported as Active diff --git a/crate/server/src/tests/curve_25519_tests.rs b/crate/server/src/tests/curve_25519_tests.rs index 2010e1101b..1d73b728f0 100644 --- a/crate/server/src/tests/curve_25519_tests.rs +++ b/crate/server/src/tests/curve_25519_tests.rs @@ -59,7 +59,7 @@ async fn test_curve_25519() -> KResult<()> { false, None, )?; - let response = kms.create_key_pair(request, &owner, None).await?; + let response = kms.create_key_pair(request, &owner).await?; // check that the private and public keys exist // check secret key let sk_response = kms @@ -193,7 +193,7 @@ async fn test_curve_25519() -> KResult<()> { }, object: pk.clone(), }; - let new_uid = kms.import(request, &owner, None).await?.unique_identifier; + let new_uid = kms.import(request, &owner).await?.unique_identifier; // update let request = Import { unique_identifier: new_uid.clone(), @@ -206,7 +206,7 @@ async fn test_curve_25519() -> KResult<()> { }, object: pk, }; - let update_response = kms.import(request, &owner, None).await?; + let update_response = kms.import(request, &owner).await?; assert_eq!(new_uid, update_response.unique_identifier); Ok(()) } diff --git a/crate/server/src/tests/derive_key_tests.rs b/crate/server/src/tests/derive_key_tests.rs index 5fd1991a23..7ea2664aa1 100644 --- a/crate/server/src/tests/derive_key_tests.rs +++ b/crate/server/src/tests/derive_key_tests.rs @@ -42,7 +42,7 @@ async fn test_derive_key_pbkdf2_default() -> KResult<()> { // Create a base symmetric key let create_request = create_base_symmetric_key_request(); - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let base_key_id = create_response.unique_identifier; // Create DeriveKey request with PBKDF2 @@ -133,7 +133,7 @@ async fn test_derive_key_pbkdf2_different_hash_algorithms() -> KResult<()> { // Create a base symmetric key let create_request = create_base_symmetric_key_request(); - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let base_key_id = create_response.unique_identifier; let hash_algorithms = vec![ @@ -203,7 +203,7 @@ async fn test_derive_key_hkdf() -> KResult<()> { // Create a base symmetric key let create_request = create_base_symmetric_key_request(); - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let base_key_id = create_response.unique_identifier; // Create DeriveKey request with HKDF @@ -270,7 +270,7 @@ async fn test_derive_key_from_secret_data() -> KResult<()> { // Create a base secret data object let create_request = create_base_secret_data_request(); - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let base_secret_id = create_response.unique_identifier; // Create DeriveKey request using the secret data as base @@ -329,7 +329,7 @@ async fn test_derive_key_error_cases() -> KResult<()> { }, protection_storage_masks: None, }; - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let invalid_key_id = create_response.unique_identifier; // Test 1: Missing DeriveKey usage mask should fail @@ -377,7 +377,7 @@ async fn test_derive_key_pbkdf2_missing_salt() -> KResult<()> { // Create a base symmetric key let create_request = create_base_symmetric_key_request(); - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let base_key_id = create_response.unique_identifier; // Create DeriveKey request with PBKDF2 but missing salt @@ -471,7 +471,7 @@ async fn test_derive_key_missing_cryptographic_length() -> KResult<()> { // Create a base symmetric key let create_request = create_base_symmetric_key_request(); - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; let base_key_id = create_response.unique_identifier; // Create DeriveKey request without cryptographic length diff --git a/crate/server/src/tests/google_cse/mod.rs b/crate/server/src/tests/google_cse/mod.rs index c4fe1cc87b..ca3a5b1c1b 100644 --- a/crate/server/src/tests/google_cse/mod.rs +++ b/crate/server/src/tests/google_cse/mod.rs @@ -249,7 +249,7 @@ async fn test_google_cse_resource_key_hash() -> KResult<()> { async fn test_google_cse_status() -> KResult<()> { log_init(option_env!("RUST_LOG")); - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; let response: StatusResponse = test_utils::get_json_with_uri(&app, "/google_cse/status").await?; @@ -270,7 +270,7 @@ async fn test_google_cse_private_key_sign() -> KResult<()> { }; log_init(None); - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; // Import google CSE key import_google_cse_symmetric_key_with_access(&app).await?; @@ -393,7 +393,6 @@ async fn test_google_cse_create_pair_encrypt_decrypt() -> KResult<()> { object: google_cse_object, }, owner, - None, ) .await?; @@ -409,7 +408,6 @@ async fn test_google_cse_create_pair_encrypt_decrypt() -> KResult<()> { None, )?, owner, - None, ) .await?; @@ -467,7 +465,7 @@ async fn test_google_cse_create_pair_encrypt_decrypt() -> KResult<()> { attributes, object: private_key, }; - let intermediate_cert = kms.import(import_request, owner, None).await?; + let intermediate_cert = kms.import(import_request, owner).await?; // Certify the public key: sign created public key with issuer private key let attributes = Attributes { @@ -495,10 +493,8 @@ async fn test_google_cse_create_pair_encrypt_decrypt() -> KResult<()> { ..Certify::default() }; - let certificate_unique_identifier = kms - .certify(certify_request, owner, None) - .await? - .unique_identifier; + let certificate_unique_identifier = + kms.certify(certify_request, owner).await?.unique_identifier; // Export the certificate and chain in PKCS7 format (just checking that it works) let pkcs7 = kms @@ -568,7 +564,7 @@ async fn test_cse_private_key_decrypt( std::env::set_var("KMS_GOOGLE_CSE_GMAIL_JWT_ISSUER", JWT_ISSUER_URI); }; - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; // Import google CSE key import_google_cse_symmetric_key_with_access(&app).await?; @@ -603,7 +599,7 @@ async fn test_google_cse_encrypt_and_private_key_decrypt() -> KResult<()> { std::env::set_var("KMS_GOOGLE_CSE_GMAIL_JWT_ISSUER", JWT_ISSUER_URI); }; - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; // Import google CSE key import_google_cse_symmetric_key_with_access(&app).await?; @@ -636,7 +632,7 @@ async fn test_google_cse_wrap_unwrap_key() -> KResult<()> { log_init(None); - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; // Import google CSE key import_google_cse_symmetric_key_with_access(&app).await?; @@ -688,7 +684,7 @@ async fn test_google_cse_privileged_wrap_unwrap_key() -> KResult<()> { log_init(None); - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; // Import google CSE key import_google_cse_symmetric_key_with_access(&app).await?; @@ -741,7 +737,7 @@ async fn test_google_cse_privileged_private_key_decrypt() -> KResult<()> { log_init(None); - let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned()), None).await; + let app = test_utils::test_app(Some("http://127.0.0.1/".to_owned())).await; let path = std::env::current_dir()?; println!("The current directory is {}", path.display()); @@ -822,7 +818,7 @@ async fn test_google_cse_custom_jwt() -> KResult<()> { log_init(None); - let app = test_utils::test_app(Some("https://127.0.0.1:9998".to_owned()), None).await; + let app = test_utils::test_app(Some("https://127.0.0.1:9998".to_owned())).await; let resource_name = "resource_name_test".to_owned(); let kacls_url = "https://127.0.0.1:9998/google_cse"; @@ -916,7 +912,7 @@ async fn test_google_cse_custom_jwt_multi_audience_match() -> KResult<()> { log_init(None); - let app = test_utils::test_app(Some("https://127.0.0.1:9998".to_owned()), None).await; + let app = test_utils::test_app(Some("https://127.0.0.1:9998".to_owned())).await; let resource_name = "resource_name_test".to_owned(); let kacls_url = "https://127.0.0.1:9998/google_cse"; @@ -1004,7 +1000,7 @@ async fn test_google_cse_custom_jwt_multi_audience_nomatch() -> KResult<()> { log_init(None); - let app = test_utils::test_app(Some("https://127.0.0.1:9998".to_owned()), None).await; + let app = test_utils::test_app(Some("https://127.0.0.1:9998".to_owned())).await; let resource_name = "resource_name_test".to_owned(); let kacls_url = "https://127.0.0.1:9998/google_cse"; diff --git a/crate/server/src/tests/health_endpoint.rs b/crate/server/src/tests/health_endpoint.rs index ce589ca5c6..69cf264771 100644 --- a/crate/server/src/tests/health_endpoint.rs +++ b/crate/server/src/tests/health_endpoint.rs @@ -6,7 +6,7 @@ use crate::tests::test_utils; async fn test_health_endpoint_ok() { log_init(option_env!("RUST_LOG")); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let response: serde_json::Value = test_utils::get_json_with_uri(&app, "/health") .await @@ -24,7 +24,7 @@ async fn test_health_endpoint_ok() { async fn test_root_redirects_to_ui() { log_init(option_env!("RUST_LOG")); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let response = actix_web::test::TestRequest::get() .uri("/") diff --git a/crate/server/src/tests/hsm/issues.rs b/crate/server/src/tests/hsm/issues.rs index e4efa420a5..1444025db2 100644 --- a/crate/server/src/tests/hsm/issues.rs +++ b/crate/server/src/tests/hsm/issues.rs @@ -159,7 +159,7 @@ pub(super) async fn test_server_side_unwrap() -> KResult<()> { attributes: Attributes::default(), object: wrapped_dek, }; - let import_response = kms.import(import_request, &admin, None).await?; + let import_response = kms.import(import_request, &admin).await?; assert_eq!( import_response.unique_identifier, UniqueIdentifier::TextString(tmp_uid.clone()) diff --git a/crate/server/src/tests/hsm/mod.rs b/crate/server/src/tests/hsm/mod.rs index 48971c1d60..b0c687fb98 100644 --- a/crate/server/src/tests/hsm/mod.rs +++ b/crate/server/src/tests/hsm/mod.rs @@ -255,7 +255,7 @@ async fn import_object( object: object.clone(), }; - let create_response = kms.import(import_request, owner, None).await?; + let create_response = kms.import(import_request, owner).await?; Ok(create_response.unique_identifier) } diff --git a/crate/server/src/tests/hsm/permissions.rs b/crate/server/src/tests/hsm/permissions.rs index 0f31f94bb8..afe88564a0 100644 --- a/crate/server/src/tests/hsm/permissions.rs +++ b/crate/server/src/tests/hsm/permissions.rs @@ -68,7 +68,7 @@ async fn grant_ops( user_id: user_id.to_owned(), operation_types: ops, }; - kms.grant_access(&access, owner, None).await + kms.grant_access(&access, owner).await } /// Helper: revoke operations on an HSM key @@ -84,7 +84,7 @@ async fn revoke_ops( user_id: user_id.to_owned(), operation_types: ops, }; - kms.revoke_access(&access, owner, None).await + kms.revoke_access(&access, owner).await } /// Helper: encrypt data using a key. diff --git a/crate/server/src/tests/kmip_endpoints.rs b/crate/server/src/tests/kmip_endpoints.rs index 39cb1bfe7f..e7becb27b7 100644 --- a/crate/server/src/tests/kmip_endpoints.rs +++ b/crate/server/src/tests/kmip_endpoints.rs @@ -54,7 +54,7 @@ async fn test_kmip_endpoints() -> KResult<()> { let request_message = build_query_request(2, 1); let fut = async { - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let _ttlv: TTLV = test_utils::post_json_with_uri(&app, to_ttlv(&request_message)?, "/kmip").await?; Ok::<(), KmsError>(()) @@ -74,7 +74,7 @@ async fn test_kmip_json_rejects_old_versions() -> KResult<()> { log_init(option_env!("RUST_LOG")); let fut = async { - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // These versions should be rejected by the JSON /kmip endpoint let rejected_versions = [(1, 0), (1, 1), (1, 2), (1, 3)]; diff --git a/crate/server/src/tests/kmip_messages.rs b/crate/server/src/tests/kmip_messages.rs index 2b8605d915..3a7043cd47 100644 --- a/crate/server/src/tests/kmip_messages.rs +++ b/crate/server/src/tests/kmip_messages.rs @@ -46,7 +46,7 @@ async fn test_kmip_mac_messages() -> KResult<()> { )?; let unique_identifier = Some( - kms.create(symmetric_key_request, owner, None) + kms.create(symmetric_key_request, owner) .await? .unique_identifier, ); @@ -126,7 +126,7 @@ async fn test_encrypt_kmip_messages() -> KResult<()> { )?; let unique_identifier = Some( - kms.create(symmetric_key_request, owner, None) + kms.create(symmetric_key_request, owner) .await? .unique_identifier, ); diff --git a/crate/server/src/tests/kmip_policy/basic.rs b/crate/server/src/tests/kmip_policy/basic.rs index 883ccec816..332eb0c4ba 100644 --- a/crate/server/src/tests/kmip_policy/basic.rs +++ b/crate/server/src/tests/kmip_policy/basic.rs @@ -16,8 +16,6 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_operatio use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::UniqueIdentifier; #[cfg(feature = "non-fips")] use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::requests::create_ec_key_pair_request; -#[cfg(feature = "non-fips")] -use cosmian_kms_server_database::reexport::cosmian_kmip::time_normalize; use cosmian_kms_server_database::reexport::cosmian_kmip::{ kmip_0::kmip_types::{BlockCipherMode, HashingAlgorithm, PaddingMethod}, kmip_2_1::{ @@ -73,7 +71,7 @@ fn default_policy_allows_aes_gcm_encrypt_params() { async fn e2e_default_policy_allows_aes_gcm_encrypt_params() { let mut conf = https_clap_config_opts(None); conf.kmip_policy.policy_id = Some("DEFAULT".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let key_uid = create_aes_key_with_size(&app, "e2e-aes-gcm", 256) .await @@ -117,7 +115,7 @@ fn default_policy_denies_deprecated_algorithm_des() { async fn e2e_default_policy_denies_deprecated_algorithm_des() { let mut conf = https_clap_config_opts(None); conf.kmip_policy.policy_id = Some("DEFAULT".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let req = Operation::Create(Create { object_type: ObjectType::SymmetricKey, @@ -156,7 +154,7 @@ fn default_policy_denies_aes_invalid_key_size() { async fn e2e_default_policy_denies_aes_invalid_key_size() { let mut conf = https_clap_config_opts(None); conf.kmip_policy.policy_id = Some("DEFAULT".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let req = Operation::Create(Create { object_type: ObjectType::SymmetricKey, @@ -270,7 +268,7 @@ async fn e2e_default_policy_denies_disallowed_block_cipher_mode_ecb() { let mut conf = https_clap_config_opts(None); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); conf.kmip_policy.allowlists.block_cipher_modes = Some(vec![BlockCipherMode::GCM]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let key_uid = create_aes_key_with_size(&app, "e2e-aes-ecb", 256) .await @@ -348,7 +346,7 @@ async fn e2e_default_policy_allows_curve_p256() { CryptographicAlgorithm::EC, CryptographicAlgorithm::ECDH, ]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let create_kp = create_ec_key_pair_request( VENDOR_ID_COSMIAN, @@ -395,7 +393,7 @@ async fn e2e_default_policy_denies_padding_method_none_allowed_list() { let mut conf = https_clap_config_opts(None); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); conf.kmip_policy.allowlists.padding_methods = Some(vec![PaddingMethod::PKCS5]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let key_uid = create_aes_key_with_size(&app, "e2e-padding-deny", 256) .await @@ -486,7 +484,6 @@ fn _create_aes_key_request_for_export(tag: &str) -> Operation { cryptographic_usage_mask: Some( CryptographicUsageMask::WrapKey | CryptographicUsageMask::Encrypt, ), - activation_date: Some(time_normalize().expect("time_normalize should work")), alternative_name: Some(AlternativeName { alternative_name_type: AlternativeNameType::UninterpretedTextString, alternative_name_value: tag.to_owned(), diff --git a/crate/server/src/tests/kmip_policy/e2e_ecies.rs b/crate/server/src/tests/kmip_policy/e2e_ecies.rs index 35c0df44de..d8043a3b95 100644 --- a/crate/server/src/tests/kmip_policy/e2e_ecies.rs +++ b/crate/server/src/tests/kmip_policy/e2e_ecies.rs @@ -31,7 +31,7 @@ async fn e2e_ecies_roundtrip_with_policy( allowed_shake: CryptographicAlgorithm, ) -> Result<(), KmsError> { let conf = ecies_policy_conf(curve, allowed_shake); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let create_kp = create_ec_key_pair_request( VENDOR_ID_COSMIAN, @@ -172,7 +172,7 @@ async fn e2e_ecies_is_allowed_when_curves_allowlist_is_unset() { CryptographicAlgorithm::SHAKE256, ]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let create_kp = create_ec_key_pair_request( VENDOR_ID_COSMIAN, @@ -228,7 +228,7 @@ async fn e2e_ecies_is_denied_when_curves_allowlist_is_empty() { CryptographicAlgorithm::SHAKE256, ]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let create_kp = create_ec_key_pair_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs b/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs index 72e7072532..dae1e05248 100644 --- a/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs +++ b/crate/server/src/tests/kmip_policy/e2e_export_wrapping.rs @@ -55,11 +55,11 @@ where cryptographic_usage_mask: Some( CryptographicUsageMask::WrapKey | CryptographicUsageMask::Encrypt, ), - activation_date: Some(time_normalize().expect("time_normalize should work")), alternative_name: Some(AlternativeName { alternative_name_type: AlternativeNameType::UninterpretedTextString, alternative_name_value: tag.to_owned(), }), + activation_date: Some(time_normalize().expect("time_normalize")), ..Default::default() }, protection_storage_masks: None, @@ -93,7 +93,7 @@ async fn e2e_kmip_policy_key_wrapping_aes_kw_suite_requires_aes_and_nist_key_wra conf.kmip_policy.allowlists.block_cipher_modes = Some(vec![BlockCipherMode::NISTKeyWrap]); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let (kek_uid, target_uid) = create_kek_and_target_for_export(&app).await; let export = export_request( @@ -120,7 +120,7 @@ async fn e2e_kmip_policy_key_wrapping_aes_kwp_suite_requires_aes_and_kwp_mode() conf.kmip_policy.allowlists.block_cipher_modes = Some(vec![BlockCipherMode::AESKeyWrapPadding]); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let (kek_uid, target_uid) = create_kek_and_target_for_export(&app).await; let export = export_request( @@ -147,7 +147,7 @@ async fn e2e_kmip_policy_key_wrapping_aes_gcm_suite_requires_aes_and_gcm() { conf.kmip_policy.allowlists.block_cipher_modes = Some(vec![BlockCipherMode::GCM]); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let (kek_uid, target_uid) = create_kek_and_target_for_export(&app).await; let export = export_request( @@ -178,7 +178,7 @@ async fn e2e_kmip_policy_key_wrapping_rsa_oaep_sha256_suite_requires_rsa_oaep_an conf.kmip_policy.allowlists.hashes = Some(vec![HashingAlgorithm::SHA256]); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let (_kek_uid, target_uid) = create_kek_and_target_for_export(&app).await; let export = export_request( @@ -219,7 +219,7 @@ async fn e2e_kmip_policy_key_wrapping_rsa_aes_key_wrap_sha256_suite_requires_rsa conf.kmip_policy.allowlists.hashes = Some(vec![HashingAlgorithm::SHA256]); conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let (_kek_uid, target_uid) = create_kek_and_target_for_export(&app).await; let export = export_request( @@ -255,7 +255,7 @@ async fn e2e_default_policy_allows_configurable_kem_roundtrip() { let mut conf = https_clap_config_opts(None); conf.kmip_policy.policy_id = Some("DEFAULT".to_owned()); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; // Use a pre-quantum KEM tag (P-256) so the request does not include a nested // post-quantum `CryptographicAlgorithm` in `CryptographicParameters`. diff --git a/crate/server/src/tests/kmip_policy/e2e_signature.rs b/crate/server/src/tests/kmip_policy/e2e_signature.rs index 01cf77b05b..83eac7564c 100644 --- a/crate/server/src/tests/kmip_policy/e2e_signature.rs +++ b/crate/server/src/tests/kmip_policy/e2e_signature.rs @@ -22,7 +22,7 @@ async fn e2e_signature_algorithm_allowlist_is_enforced_on_sign() { conf.kmip_policy.policy_id = Some("CUSTOM".to_owned()); conf.kmip_policy.allowlists.signature_algorithms = Some(vec![DigitalSignatureAlgorithm::SHA256WithRSAEncryption]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let create_kp = create_ec_key_pair_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/kmip_policy/helpers.rs b/crate/server/src/tests/kmip_policy/helpers.rs index 59e528a6ee..e5233d563f 100644 --- a/crate/server/src/tests/kmip_policy/helpers.rs +++ b/crate/server/src/tests/kmip_policy/helpers.rs @@ -86,11 +86,11 @@ where cryptographic_algorithm: Some(CryptographicAlgorithm::AES), cryptographic_length: Some(bits), cryptographic_usage_mask: Some(CryptographicUsageMask::Encrypt), - activation_date: Some(time_normalize().expect("time_normalize should work")), alternative_name: Some(AlternativeName { alternative_name_type: AlternativeNameType::UninterpretedTextString, alternative_name_value: tag.to_owned(), }), + activation_date: Some(time_normalize()?), ..Default::default() }, protection_storage_masks: None, diff --git a/crate/server/src/tests/kmip_policy/overrides.rs b/crate/server/src/tests/kmip_policy/overrides.rs index 65b44c7dba..e4be5f8558 100644 --- a/crate/server/src/tests/kmip_policy/overrides.rs +++ b/crate/server/src/tests/kmip_policy/overrides.rs @@ -95,7 +95,7 @@ async fn e2e_override_allowlists_can_tighten_policy() { conf.kmip_policy.allowlists.padding_methods = Some(vec![PaddingMethod::OAEP]); conf.kmip_policy.allowlists.mgf_hashes = Some(vec![HashingAlgorithm::SHA512]); - let app = Box::pin(test_app_with_clap_config(conf, None)).await; + let app = Box::pin(test_app_with_clap_config(conf)).await; let create_aes = Operation::Create(Create { object_type: ObjectType::SymmetricKey, diff --git a/crate/server/src/tests/kmip_server_tests.rs b/crate/server/src/tests/kmip_server_tests.rs index d0f43ee5a1..cfbd42ab31 100644 --- a/crate/server/src/tests/kmip_server_tests.rs +++ b/crate/server/src/tests/kmip_server_tests.rs @@ -55,7 +55,7 @@ async fn test_curve_25519_key_pair() -> KResult<()> { false, None, )?; - let response = kms.create_key_pair(request, owner, None).await?; + let response = kms.create_key_pair(request, owner).await?; // check that the private and public keys exist // check secret key let sk_response = kms @@ -186,7 +186,7 @@ async fn test_curve_25519_key_pair() -> KResult<()> { }, object: pk.clone(), }; - let new_uid = kms.import(request, owner, None).await?.unique_identifier; + let new_uid = kms.import(request, owner).await?.unique_identifier; // update let request = Import { @@ -200,7 +200,7 @@ async fn test_curve_25519_key_pair() -> KResult<()> { }, object: pk, }; - let update_response = kms.import(request, owner, None).await?; + let update_response = kms.import(request, owner).await?; assert_eq!(new_uid, update_response.unique_identifier); Ok(()) } @@ -252,7 +252,7 @@ async fn test_import_wrapped_symmetric_key() -> KResult<()> { }; trace!("request: {}", request); - let response = kms.import(request, owner, None).await?; + let response = kms.import(request, owner).await?; trace!("response: {}", response); Ok(()) @@ -278,7 +278,7 @@ async fn test_create_transparent_symmetric_key() -> KResult<()> { )?; trace!("request: {}", request); - let response = kms.create(request, owner, None).await?; + let response = kms.create(request, owner).await?; trace!("response: {}", response); // Get symmetric key without specifying key format type @@ -333,7 +333,7 @@ async fn test_database_user_tenant() -> KResult<()> { false, None, )?; - let response = kms.create_key_pair(request, owner, None).await?; + let response = kms.create_key_pair(request, owner).await?; // check that we can get the private and public key // check secret key @@ -435,7 +435,7 @@ async fn test_register_operation() -> KResult<()> { }; trace!("request: {}", request); - let register_response = kms.register(request, owner, None).await?; + let register_response = kms.register(request, owner).await?; trace!("response: {}", register_response); let uid = register_response.unique_identifier; diff --git a/crate/server/src/tests/locate.rs b/crate/server/src/tests/locate.rs index 47f3eca96d..b1753111be 100644 --- a/crate/server/src/tests/locate.rs +++ b/crate/server/src/tests/locate.rs @@ -105,7 +105,7 @@ async fn test_locate() -> KResult<()> { #[actix_rt::test] async fn test_locate_key_pair_and_sym_key() -> KResult<()> { // Use sqlite-backed test app - let app = test_app(None, None).await; + let app = test_app(None).await; // Create EC keypair (FIPS-approved curve and usage mask) let create = CreateKeyPair { @@ -193,7 +193,7 @@ async fn test_locate_key_pair_and_sym_key() -> KResult<()> { #[actix_rt::test] async fn test_locate_filters_by_object_type_and_and_semantics() -> KResult<()> { // Start test app (KMIP 2.1 endpoint) - let app = test_app(None, None).await; + let app = test_app(None).await; // Create an EC key pair let create = CreateKeyPair { diff --git a/crate/server/src/tests/ms_dke/mod.rs b/crate/server/src/tests/ms_dke/mod.rs index 83edab8b17..801ecc1e71 100644 --- a/crate/server/src/tests/ms_dke/mod.rs +++ b/crate/server/src/tests/ms_dke/mod.rs @@ -57,7 +57,7 @@ const ENCRYPTED_DATA: &str = r#"{ async fn decrypt_data_test() -> KResult<()> { cosmian_logger::log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let pem: pem::Pem = pem::parse(RSA_PRIVATE_KEY.as_bytes()) .map_err(|e| kms_error!(format!("cannot parse RSA private key: {}", e)))?; diff --git a/crate/server/src/tests/rest_crypto/encrypt_decrypt.rs b/crate/server/src/tests/rest_crypto/encrypt_decrypt.rs index 16a557677a..f6f63acbd4 100644 --- a/crate/server/src/tests/rest_crypto/encrypt_decrypt.rs +++ b/crate/server/src/tests/rest_crypto/encrypt_decrypt.rs @@ -16,14 +16,14 @@ use crate::{result::KResult, tests::test_utils}; #[tokio::test] async fn test_aes128gcm_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; super::common::aes_gcm_round_trip(&app, 128, "A128GCM").await } #[tokio::test] async fn test_aes256gcm_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; super::common::aes_gcm_round_trip(&app, 256, "A256GCM").await } @@ -31,7 +31,7 @@ async fn test_aes256gcm_round_trip() -> KResult<()> { #[tokio::test] async fn test_aad_binding() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/rest_crypto/error_cases.rs b/crate/server/src/tests/rest_crypto/error_cases.rs index 57a109d634..68576799e7 100644 --- a/crate/server/src/tests/rest_crypto/error_cases.rs +++ b/crate/server/src/tests/rest_crypto/error_cases.rs @@ -16,7 +16,7 @@ use crate::{result::KResult, tests::test_utils}; #[tokio::test] async fn test_unknown_encrypt_alg_returns_422() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, @@ -49,7 +49,7 @@ async fn test_unknown_encrypt_alg_returns_422() -> KResult<()> { #[tokio::test] async fn test_unknown_sign_alg_returns_422() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, @@ -82,7 +82,7 @@ async fn test_unknown_sign_alg_returns_422() -> KResult<()> { #[tokio::test] async fn test_nonexistent_key_id() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let req = test::TestRequest::post() .uri("/v1/crypto/encrypt") @@ -105,7 +105,7 @@ async fn test_nonexistent_key_id() -> KResult<()> { #[tokio::test] async fn test_wrong_key_type_for_sign() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, @@ -136,7 +136,7 @@ async fn test_wrong_key_type_for_sign() -> KResult<()> { #[tokio::test] async fn test_alg_none_returns_422() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // Build a protected header with alg=none and a valid kid let protected_json = r#"{"alg":"none","kid":"any-key"}"#; @@ -165,7 +165,7 @@ async fn test_alg_none_returns_422() -> KResult<()> { #[tokio::test] async fn test_decrypt_invalid_iv_length_returns_400() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, @@ -213,7 +213,7 @@ async fn test_decrypt_invalid_iv_length_returns_400() -> KResult<()> { #[tokio::test] async fn test_decrypt_short_tag_returns_400() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/rest_crypto/jose_vectors.rs b/crate/server/src/tests/rest_crypto/jose_vectors.rs index e3a696ecb5..00fdcfe1e9 100644 --- a/crate/server/src/tests/rest_crypto/jose_vectors.rs +++ b/crate/server/src/tests/rest_crypto/jose_vectors.rs @@ -924,7 +924,7 @@ fn inject_kid(body: &mut Value, kid: &str, kid_public: Option<&str>) { #[tokio::test] async fn test_jose_vectors() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let vectors = discover_vectors(); assert!( diff --git a/crate/server/src/tests/rest_crypto/key_state.rs b/crate/server/src/tests/rest_crypto/key_state.rs new file mode 100644 index 0000000000..aeeff76361 --- /dev/null +++ b/crate/server/src/tests/rest_crypto/key_state.rs @@ -0,0 +1,844 @@ +//! Tests that cryptographic operations respect KMIP 2.1 §3.31 state rules: +//! +//! - **Protection operations** (Encrypt, Sign, MAC) require `Active` state. +//! - **Processing operations** (Decrypt, Verify, MACVerify) accept +//! `Active`, `Deactivated`, and `Compromised` states. +//! +//! Also tests KMIP §4.57 auto-deactivation (transition 6): +//! +//! - An Active key whose `DeactivationDate` has passed is automatically +//! transitioned to Deactivated on retrieval, mirroring the PreActive → Active +//! auto-activation mechanism. + +use actix_web::{http::StatusCode, test}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::{HashingAlgorithm, RevocationReason, RevocationReasonCode, State}, + kmip_2_1::{ + extra::tagging::{EMPTY_TAGS, VENDOR_ID_COSMIAN}, + kmip_attributes::Attribute, + kmip_operations::{ + CreateKeyPairResponse, CreateResponse, GetAttributes, GetAttributesResponse, MAC, + MACResponse, MACVerify, MACVerifyResponse, Revoke, RevokeResponse, SetAttribute, + SetAttributeResponse, Sign, SignResponse, SignatureVerify, SignatureVerifyResponse, + }, + kmip_types::{ + AttributeReference, CryptographicAlgorithm, CryptographicParameters, + DigitalSignatureAlgorithm, RecommendedCurve, Tag, UniqueIdentifier, ValidityIndicator, + }, + requests::{create_ec_key_pair_request, symmetric_key_create_request}, + }, +}; +use cosmian_logger::log_init; +use serde_json::json; +use time::{Duration, OffsetDateTime}; +use zeroize::Zeroizing; + +use crate::{result::KResult, tests::test_utils}; + +/// Create an AES-256 key and return its UID. +async fn create_aes_key(app: &S) -> KResult +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let create_req = symmetric_key_create_request( + VENDOR_ID_COSMIAN, + None, + 256, + CryptographicAlgorithm::AES, + EMPTY_TAGS, + false, + None, + )?; + let cr: CreateResponse = test_utils::post_2_1(app, create_req).await?; + Ok(cr.unique_identifier.to_string()) +} + +/// Encrypt plaintext with the given key, returning the JSON encrypt response. +async fn encrypt(app: &S, kid: &str, plaintext: &[u8]) -> KResult +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let data_b64 = URL_SAFE_NO_PAD.encode(plaintext); + test_utils::post_json_with_uri( + app, + json!({"kid": kid, "alg": "dir", "enc": "A256GCM", "data": data_b64}), + "/v1/crypto/encrypt", + ) + .await +} + +/// Attempt to decrypt the given ciphertext response; returns Ok(value) on success. +async fn try_decrypt( + app: &S, + enc_resp: &serde_json::Value, +) -> Result +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let req = test::TestRequest::post() + .uri("/v1/crypto/decrypt") + .set_json(&json!({ + "protected": enc_resp["protected"], + "iv": enc_resp["iv"], + "ciphertext": enc_resp["ciphertext"], + "tag": enc_resp["tag"], + })) + .to_request(); + let resp = test::call_service(app, req).await; + if resp.status() == StatusCode::OK { + let body = test::read_body(resp).await; + Ok(serde_json::from_slice(&body).unwrap_or_default()) + } else { + Err(resp.status()) + } +} + +/// Attempt to encrypt; returns Ok on 200, Err(status) otherwise. +async fn try_encrypt( + app: &S, + kid: &str, + plaintext: &[u8], +) -> Result +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let data_b64 = URL_SAFE_NO_PAD.encode(plaintext); + let req = test::TestRequest::post() + .uri("/v1/crypto/encrypt") + .set_json(&json!({"kid": kid, "alg": "dir", "enc": "A256GCM", "data": data_b64})) + .to_request(); + let resp = test::call_service(app, req).await; + if resp.status() == StatusCode::OK { + let body = test::read_body(resp).await; + Ok(serde_json::from_slice(&body).unwrap_or_default()) + } else { + Err(resp.status()) + } +} + +/// Deactivate a key via KMIP `Revoke` with `CessationOfOperation` reason. +async fn deactivate_key(app: &S, kid: &str) -> KResult<()> +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let revoke = Revoke { + unique_identifier: Some(UniqueIdentifier::TextString(kid.to_owned())), + revocation_reason: RevocationReason { + revocation_reason_code: RevocationReasonCode::CessationOfOperation, + revocation_message: Some("test deactivation".to_owned()), + }, + compromise_occurrence_date: None, + cascade: false, + }; + let _resp: RevokeResponse = test_utils::post_2_1(app, revoke).await?; + Ok(()) +} + +/// Compromise a key via KMIP `Revoke` with `KeyCompromise` reason. +async fn compromise_key(app: &S, kid: &str) -> KResult<()> +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let revoke = Revoke { + unique_identifier: Some(UniqueIdentifier::TextString(kid.to_owned())), + revocation_reason: RevocationReason { + revocation_reason_code: RevocationReasonCode::KeyCompromise, + revocation_message: Some("test compromise".to_owned()), + }, + compromise_occurrence_date: None, + cascade: false, + }; + let _resp: RevokeResponse = test_utils::post_2_1(app, revoke).await?; + Ok(()) +} + +/// KMIP 2.1 §3.31: Decrypt MUST work on a Deactivated key. +#[tokio::test] +async fn test_decrypt_deactivated_key_succeeds() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + let enc_resp = encrypt(&app, &kid, b"secret data").await?; + + // Deactivate the key + deactivate_key(&app, &kid).await?; + + // Decrypt must still work per KMIP 2.1 §3.31 + let dec_result = try_decrypt(&app, &enc_resp).await; + assert!( + dec_result.is_ok(), + "Decrypt with deactivated key should succeed per KMIP 2.1 §3.31, got: {dec_result:?}" + ); + + Ok(()) +} + +/// KMIP 2.1 §3.31: Decrypt SHOULD work on a Compromised key. +#[tokio::test] +async fn test_decrypt_compromised_key_succeeds() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + let enc_resp = encrypt(&app, &kid, b"secret data").await?; + + // Compromise the key + compromise_key(&app, &kid).await?; + + // Decrypt must still work per KMIP 2.1 §3.31 + let dec_result = try_decrypt(&app, &enc_resp).await; + assert!( + dec_result.is_ok(), + "Decrypt with compromised key should succeed per KMIP 2.1 §3.31, got: {dec_result:?}" + ); + + Ok(()) +} + +/// KMIP 2.1 §3.31: Encrypt MUST NOT work on a Deactivated key. +#[tokio::test] +async fn test_encrypt_deactivated_key_rejected() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + + // Deactivate the key + deactivate_key(&app, &kid).await?; + + // Encrypt must be rejected (protection operation on non-Active key) + let enc_result = try_encrypt(&app, &kid, b"will not encrypt").await; + assert!( + enc_result.is_err(), + "Encrypt with deactivated key should fail per KMIP 2.1 §3.31" + ); + + Ok(()) +} + +/// KMIP 2.1 §3.31: Encrypt MUST NOT work on a Compromised key. +#[tokio::test] +async fn test_encrypt_compromised_key_rejected() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + + // Compromise the key + compromise_key(&app, &kid).await?; + + // Encrypt must be rejected (protection operation on non-Active key) + let enc_result = try_encrypt(&app, &kid, b"will not encrypt").await; + assert!( + enc_result.is_err(), + "Encrypt with compromised key should fail per KMIP 2.1 §3.31" + ); + + Ok(()) +} + +// ── MAC / MACVerify state tests ─────────────────────────────────────────────── + +const MAC_DATA: &[u8] = b"data-to-mac"; + +/// Compute an HMAC-SHA256 tag on [`MAC_DATA`] with an Active key. +async fn compute_hmac(app: &S, kid: &str) -> KResult> +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let resp: MACResponse = test_utils::post_2_1( + app, + MAC { + unique_identifier: Some(UniqueIdentifier::TextString(kid.to_owned())), + cryptographic_parameters: Some(CryptographicParameters { + hashing_algorithm: Some(HashingAlgorithm::SHA256), + ..Default::default() + }), + data: Some(MAC_DATA.to_vec()), + ..Default::default() + }, + ) + .await?; + Ok(resp.mac_data.unwrap_or_default()) +} + +/// KMIP 2.1 §3.31: MAC (protection op) MUST NOT work on a Deactivated key. +#[tokio::test] +async fn test_mac_deactivated_key_rejected() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + // MAC works while key is Active + compute_hmac(&app, &kid).await?; + + deactivate_key(&app, &kid).await?; + + // MAC must be rejected on a Deactivated key (protection operation) + let result = test_utils::post_2_1::<_, _, MACResponse, _>( + &app, + MAC { + unique_identifier: Some(UniqueIdentifier::TextString(kid.clone())), + cryptographic_parameters: Some(CryptographicParameters { + hashing_algorithm: Some(HashingAlgorithm::SHA256), + ..Default::default() + }), + data: Some(MAC_DATA.to_vec()), + ..Default::default() + }, + ) + .await; + assert!( + result.is_err(), + "MAC with deactivated key should fail per KMIP 2.1 §3.31" + ); + Ok(()) +} + +/// KMIP 2.1 §3.31: MAC (protection op) MUST NOT work on a Compromised key. +#[tokio::test] +async fn test_mac_compromised_key_rejected() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + compromise_key(&app, &kid).await?; + + let result = test_utils::post_2_1::<_, _, MACResponse, _>( + &app, + MAC { + unique_identifier: Some(UniqueIdentifier::TextString(kid.clone())), + cryptographic_parameters: Some(CryptographicParameters { + hashing_algorithm: Some(HashingAlgorithm::SHA256), + ..Default::default() + }), + data: Some(MAC_DATA.to_vec()), + ..Default::default() + }, + ) + .await; + assert!( + result.is_err(), + "MAC with compromised key should fail per KMIP 2.1 §3.31" + ); + Ok(()) +} + +/// KMIP 2.1 §3.31: MACVerify (processing op) MUST work on a Deactivated key. +#[tokio::test] +async fn test_mac_verify_deactivated_key_succeeds() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + let tag = compute_hmac(&app, &kid).await?; + + deactivate_key(&app, &kid).await?; + + // MACVerify must succeed on a Deactivated key (processing operation) + let resp: MACVerifyResponse = test_utils::post_2_1( + &app, + MACVerify { + unique_identifier: UniqueIdentifier::TextString(kid.clone()), + cryptographic_parameters: Some(CryptographicParameters { + hashing_algorithm: Some(HashingAlgorithm::SHA256), + ..Default::default() + }), + data: MAC_DATA.to_vec(), + mac_data: tag, + }, + ) + .await + .map_err(|e| { + crate::error::KmsError::InvalidRequest(format!( + "MACVerify with deactivated key should succeed per KMIP 2.1 §3.31, got: {e}" + )) + })?; + assert_eq!( + resp.validity_indicator, + cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::ValidityIndicator::Valid, + "MACVerify should report Valid" + ); + Ok(()) +} + +/// KMIP 2.1 §3.31: MACVerify (processing op) MUST work on a Compromised key. +#[tokio::test] +async fn test_mac_verify_compromised_key_succeeds() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + let tag = compute_hmac(&app, &kid).await?; + + compromise_key(&app, &kid).await?; + + let resp: MACVerifyResponse = test_utils::post_2_1( + &app, + MACVerify { + unique_identifier: UniqueIdentifier::TextString(kid.clone()), + cryptographic_parameters: Some(CryptographicParameters { + hashing_algorithm: Some(HashingAlgorithm::SHA256), + ..Default::default() + }), + data: MAC_DATA.to_vec(), + mac_data: tag, + }, + ) + .await + .map_err(|e| { + crate::error::KmsError::InvalidRequest(format!( + "MACVerify with compromised key should succeed per KMIP 2.1 §3.31, got: {e}" + )) + })?; + assert_eq!( + resp.validity_indicator, + ValidityIndicator::Valid, + "MACVerify should report Valid" + ); + Ok(()) +} + +// ── Sign / SignatureVerify state tests ──────────────────────────────────────── + +const SIGN_DATA: &[u8] = b"data-to-sign"; + +/// Create an EC P-256 keypair and return `(private_uid, public_uid)`. +async fn create_ec_keypair(app: &S) -> KResult<(String, String)> +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let req = create_ec_key_pair_request( + VENDOR_ID_COSMIAN, + None, + EMPTY_TAGS, + RecommendedCurve::P256, + false, + None, + )?; + let resp: CreateKeyPairResponse = test_utils::post_2_1(app, req).await?; + Ok(( + resp.private_key_unique_identifier.to_string(), + resp.public_key_unique_identifier.to_string(), + )) +} + +/// Sign [`SIGN_DATA`] with the private key and return the raw signature bytes. +async fn ec_sign(app: &S, priv_uid: &str) -> KResult> +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let resp: SignResponse = test_utils::post_2_1( + app, + Sign { + unique_identifier: Some(UniqueIdentifier::TextString(priv_uid.to_owned())), + cryptographic_parameters: Some(CryptographicParameters { + digital_signature_algorithm: Some(DigitalSignatureAlgorithm::ECDSAWithSHA256), + ..Default::default() + }), + data: Some(Zeroizing::new(SIGN_DATA.to_vec())), + ..Default::default() + }, + ) + .await?; + Ok(resp.signature_data.unwrap_or_default()) +} + +/// KMIP 2.1 §3.31: Sign (protection op) MUST NOT work on a Deactivated key. +#[tokio::test] +async fn test_sign_deactivated_key_rejected() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let (priv_uid, _pub_uid) = create_ec_keypair(&app).await?; + // Sign works while key is Active + ec_sign(&app, &priv_uid).await?; + + deactivate_key(&app, &priv_uid).await?; + + // Sign must be rejected on a Deactivated key (protection operation) + let result = test_utils::post_2_1::<_, _, SignResponse, _>( + &app, + Sign { + unique_identifier: Some(UniqueIdentifier::TextString(priv_uid.clone())), + cryptographic_parameters: Some(CryptographicParameters { + digital_signature_algorithm: Some(DigitalSignatureAlgorithm::ECDSAWithSHA256), + ..Default::default() + }), + data: Some(Zeroizing::new(SIGN_DATA.to_vec())), + ..Default::default() + }, + ) + .await; + assert!( + result.is_err(), + "Sign with deactivated key should fail per KMIP 2.1 §3.31" + ); + Ok(()) +} + +/// KMIP 2.1 §3.31: Sign (protection op) MUST NOT work on a Compromised key. +#[tokio::test] +async fn test_sign_compromised_key_rejected() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let (priv_uid, _pub_uid) = create_ec_keypair(&app).await?; + compromise_key(&app, &priv_uid).await?; + + let result = test_utils::post_2_1::<_, _, SignResponse, _>( + &app, + Sign { + unique_identifier: Some(UniqueIdentifier::TextString(priv_uid.clone())), + cryptographic_parameters: Some(CryptographicParameters { + digital_signature_algorithm: Some(DigitalSignatureAlgorithm::ECDSAWithSHA256), + ..Default::default() + }), + data: Some(Zeroizing::new(SIGN_DATA.to_vec())), + ..Default::default() + }, + ) + .await; + assert!( + result.is_err(), + "Sign with compromised key should fail per KMIP 2.1 §3.31" + ); + Ok(()) +} + +/// KMIP 2.1 §3.31: SignatureVerify (processing op) MUST work on a Deactivated key. +#[tokio::test] +async fn test_signature_verify_deactivated_key_succeeds() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let (priv_uid, pub_uid) = create_ec_keypair(&app).await?; + let sig = ec_sign(&app, &priv_uid).await?; + + // Deactivate the *public* key (used for verification) + deactivate_key(&app, &pub_uid).await?; + + // SignatureVerify must succeed on a Deactivated key (processing operation) + let resp: SignatureVerifyResponse = test_utils::post_2_1( + &app, + SignatureVerify { + unique_identifier: Some(UniqueIdentifier::TextString(pub_uid.clone())), + cryptographic_parameters: Some(CryptographicParameters { + digital_signature_algorithm: Some(DigitalSignatureAlgorithm::ECDSAWithSHA256), + ..Default::default() + }), + data: Some(SIGN_DATA.to_vec()), + signature_data: Some(sig), + ..Default::default() + }, + ) + .await + .map_err(|e| { + crate::error::KmsError::InvalidRequest(format!( + "SignatureVerify with deactivated key should succeed per KMIP 2.1 §3.31, got: {e}" + )) + })?; + assert_eq!( + resp.validity_indicator, + Some(ValidityIndicator::Valid), + "SignatureVerify should report Valid" + ); + Ok(()) +} + +/// KMIP 2.1 §3.31: SignatureVerify (processing op) MUST work on a Compromised key. +#[tokio::test] +async fn test_signature_verify_compromised_key_succeeds() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let (priv_uid, pub_uid) = create_ec_keypair(&app).await?; + let sig = ec_sign(&app, &priv_uid).await?; + + // Compromise the *public* key (used for verification) + compromise_key(&app, &pub_uid).await?; + + let resp: SignatureVerifyResponse = test_utils::post_2_1( + &app, + SignatureVerify { + unique_identifier: Some(UniqueIdentifier::TextString(pub_uid.clone())), + cryptographic_parameters: Some(CryptographicParameters { + digital_signature_algorithm: Some(DigitalSignatureAlgorithm::ECDSAWithSHA256), + ..Default::default() + }), + data: Some(SIGN_DATA.to_vec()), + signature_data: Some(sig), + ..Default::default() + }, + ) + .await + .map_err(|e| { + crate::error::KmsError::InvalidRequest(format!( + "SignatureVerify with compromised key should succeed per KMIP 2.1 §3.31, got: {e}" + )) + })?; + assert_eq!( + resp.validity_indicator, + Some(ValidityIndicator::Valid), + "SignatureVerify should report Valid" + ); + Ok(()) +} + +// ── Auto-deactivation tests (KMIP §4.57 transition 6) ──────────────────────── + +/// Set the `DeactivationDate` attribute on a key via KMIP `SetAttribute`. +async fn set_deactivation_date(app: &S, kid: &str, date: OffsetDateTime) -> KResult<()> +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let _resp: SetAttributeResponse = test_utils::post_2_1( + app, + SetAttribute { + unique_identifier: Some(UniqueIdentifier::TextString(kid.to_owned())), + new_attribute: Attribute::DeactivationDate(date), + }, + ) + .await?; + Ok(()) +} + +/// Set the `ActivationDate` attribute on a key via KMIP `SetAttribute`. +async fn set_activation_date(app: &S, kid: &str, date: OffsetDateTime) -> KResult<()> +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let _resp: SetAttributeResponse = test_utils::post_2_1( + app, + SetAttribute { + unique_identifier: Some(UniqueIdentifier::TextString(kid.to_owned())), + new_attribute: Attribute::ActivationDate(date), + }, + ) + .await?; + Ok(()) +} + +/// Query the `State` attribute of a key via KMIP `GetAttributes`. +async fn get_state(app: &S, kid: &str) -> KResult> +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let resp: GetAttributesResponse = test_utils::post_2_1( + app, + GetAttributes { + unique_identifier: Some(UniqueIdentifier::TextString(kid.to_owned())), + attribute_reference: Some(vec![AttributeReference::Standard(Tag::State)]), + }, + ) + .await?; + Ok(resp.attributes.state) +} + +/// KMIP §4.57 transition 6: An Active key with a `DeactivationDate` in the past +/// is automatically transitioned to Deactivated on retrieval. +/// +/// - Encrypt (protection op) MUST fail. +/// - Decrypt (processing op) MUST succeed. +/// - Reported state MUST be `Deactivated`. +#[tokio::test] +async fn test_auto_deactivation_past_date_blocks_encrypt() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + // Create an Active AES key and encrypt while it is Active. + let kid = create_aes_key(&app).await?; + let enc_resp = encrypt(&app, &kid, b"auto-deact test").await?; + + // Set DeactivationDate to 1 hour in the past → triggers auto-deactivation. + let past = OffsetDateTime::now_utc() - Duration::hours(1); + set_deactivation_date(&app, &kid, past).await?; + + // Encrypt must be rejected (key is now effectively Deactivated). + let enc_result = try_encrypt(&app, &kid, b"should fail").await; + assert!( + enc_result.is_err(), + "Encrypt must fail on an auto-deactivated key (§4.57 transition 6)" + ); + + // Decrypt must still succeed (processing op on Deactivated key). + let dec_result = try_decrypt(&app, &enc_resp).await; + assert!( + dec_result.is_ok(), + "Decrypt must succeed on an auto-deactivated key, got: {dec_result:?}" + ); + + // State attribute must report Deactivated. + let state = get_state(&app, &kid).await?; + assert_eq!( + state, + Some(State::Deactivated), + "State must be Deactivated after auto-deactivation" + ); + + Ok(()) +} + +/// A key with `DeactivationDate` in the future remains Active. +/// +/// Encrypt (protection op) MUST succeed. +#[tokio::test] +async fn test_future_deactivation_date_keeps_key_active() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + + // Set DeactivationDate 1 hour in the future — key should stay Active. + let future = OffsetDateTime::now_utc() + Duration::hours(1); + set_deactivation_date(&app, &kid, future).await?; + + // Encrypt must still work (key is Active until DeactivationDate). + let enc_result = try_encrypt(&app, &kid, b"still active").await; + assert!( + enc_result.is_ok(), + "Encrypt must succeed when DeactivationDate is in the future" + ); + + // State must still be Active. + let state = get_state(&app, &kid).await?; + assert_eq!( + state, + Some(State::Active), + "State must remain Active when DeactivationDate is in the future" + ); + + Ok(()) +} + +/// Auto-deactivation persists: once the server transitions a key to Deactivated +/// via auto-deactivation, subsequent retrievals reflect the persisted state +/// without re-checking the date. +#[tokio::test] +async fn test_auto_deactivation_persists_across_retrievals() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + let enc_resp = encrypt(&app, &kid, b"persistence test").await?; + + // Trigger auto-deactivation. + let past = OffsetDateTime::now_utc() - Duration::hours(1); + set_deactivation_date(&app, &kid, past).await?; + + // First retrieval: triggers auto-deactivation and persists Deactivated. + let state1 = get_state(&app, &kid).await?; + assert_eq!(state1, Some(State::Deactivated)); + + // Second retrieval: state is already persisted, no date check needed. + let state2 = get_state(&app, &kid).await?; + assert_eq!(state2, Some(State::Deactivated)); + + // Decrypt still works on both retrievals. + let dec_result = try_decrypt(&app, &enc_resp).await; + assert!( + dec_result.is_ok(), + "Decrypt must succeed on persisted auto-deactivated key" + ); + + Ok(()) +} + +/// KMIP §4.57 combined transitions: a PreActive key with past `ActivationDate` +/// AND past `DeactivationDate` transitions through PreActive → Active → +/// Deactivated in a single retrieval. +#[tokio::test] +async fn test_preactive_to_deactivated_combined_transition() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + // Create a key with a future activation date to make it PreActive. + let kid = create_aes_key(&app).await?; + + // Set ActivationDate in the past → server auto-activates. + let past_activation = OffsetDateTime::now_utc() - Duration::hours(2); + set_activation_date(&app, &kid, past_activation).await?; + + // Set DeactivationDate in the past → server auto-deactivates. + let past_deactivation = OffsetDateTime::now_utc() - Duration::hours(1); + set_deactivation_date(&app, &kid, past_deactivation).await?; + + // Encrypt must fail (key is effectively Deactivated). + let enc_result = try_encrypt(&app, &kid, b"should fail").await; + assert!( + enc_result.is_err(), + "Encrypt must fail on a key that passed through PreActive→Active→Deactivated" + ); + + // State must report Deactivated. + let state = get_state(&app, &kid).await?; + assert_eq!( + state, + Some(State::Deactivated), + "State must be Deactivated after combined PreActive→Active→Deactivated transitions" + ); + + Ok(()) +} diff --git a/crate/server/src/tests/rest_crypto/mac.rs b/crate/server/src/tests/rest_crypto/mac.rs index a160d2b113..8f6f3630b0 100644 --- a/crate/server/src/tests/rest_crypto/mac.rs +++ b/crate/server/src/tests/rest_crypto/mac.rs @@ -17,7 +17,7 @@ use crate::{result::KResult, tests::test_utils}; #[tokio::test] async fn test_hs256_compute_verify() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let create_req = symmetric_key_create_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/rest_crypto/mod.rs b/crate/server/src/tests/rest_crypto/mod.rs index c4c986d1ba..f6e7ad1475 100644 --- a/crate/server/src/tests/rest_crypto/mod.rs +++ b/crate/server/src/tests/rest_crypto/mod.rs @@ -16,6 +16,7 @@ mod common; mod encrypt_decrypt; mod error_cases; mod jose_vectors; +mod key_state; mod mac; mod rfc_vectors; mod sign_verify; diff --git a/crate/server/src/tests/rest_crypto/rfc_vectors.rs b/crate/server/src/tests/rest_crypto/rfc_vectors.rs index 22febaaf38..0e8aa83f85 100644 --- a/crate/server/src/tests/rest_crypto/rfc_vectors.rs +++ b/crate/server/src/tests/rest_crypto/rfc_vectors.rs @@ -35,7 +35,7 @@ use crate::{result::KResult, tests::test_utils}; #[tokio::test] async fn test_rfc7515_a1_hs256_known_answer() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // RFC 7515 §A.1 — 512-bit key (base64url): // AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow @@ -100,7 +100,7 @@ async fn test_rfc7515_a1_hs256_known_answer() -> KResult<()> { #[tokio::test] async fn test_rfc7515_a2_rs256_known_key_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let kp_req = create_rsa_key_pair_request(VENDOR_ID_COSMIAN, None, EMPTY_TAGS, 2048, false, None)?; @@ -123,7 +123,7 @@ async fn test_rfc7515_a2_rs256_known_key_round_trip() -> KResult<()> { #[tokio::test] async fn test_rfc7515_a3_es256_known_key_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let kp_req = create_ec_key_pair_request( VENDOR_ID_COSMIAN, @@ -150,7 +150,7 @@ async fn test_rfc7515_a3_es256_known_key_round_trip() -> KResult<()> { #[tokio::test] async fn test_rfc7515_a4_es512_known_key_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let kp_req = create_ec_key_pair_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/rest_crypto/sign_verify.rs b/crate/server/src/tests/rest_crypto/sign_verify.rs index a06bb97af0..af86277d52 100644 --- a/crate/server/src/tests/rest_crypto/sign_verify.rs +++ b/crate/server/src/tests/rest_crypto/sign_verify.rs @@ -13,7 +13,7 @@ use crate::{result::KResult, tests::test_utils}; #[tokio::test] async fn test_rs256_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let kp_req = create_rsa_key_pair_request(VENDOR_ID_COSMIAN, None, EMPTY_TAGS, 2048, false, None)?; @@ -27,7 +27,7 @@ async fn test_rs256_round_trip() -> KResult<()> { #[tokio::test] async fn test_es256_round_trip() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let kp_req = create_ec_key_pair_request( VENDOR_ID_COSMIAN, diff --git a/crate/server/src/tests/rest_crypto/unwrap.rs b/crate/server/src/tests/rest_crypto/unwrap.rs index 44df1e4e54..638e5d68ce 100644 --- a/crate/server/src/tests/rest_crypto/unwrap.rs +++ b/crate/server/src/tests/rest_crypto/unwrap.rs @@ -80,7 +80,7 @@ fn build_protected_header(alg: &str, enc: &str, kid: &str) -> String { #[tokio::test] async fn test_unwrap_key_then_decrypt() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; // 1. Create RSA key pair via KMIP let (_kid_priv, kid_pub) = create_rsa_key_pair(&app).await?; @@ -145,7 +145,7 @@ async fn test_unwrap_key_then_decrypt() -> KResult<()> { #[tokio::test] async fn test_unwrap_key_rsa_oaep_256() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let (_kid_priv, kid_pub) = create_rsa_key_pair(&app).await?; @@ -200,7 +200,7 @@ async fn test_unwrap_key_rsa_oaep_256() -> KResult<()> { #[tokio::test] async fn test_unwrap_key_unsupported_alg() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let protected = build_protected_header("dir", "A256GCM", "some-kid"); let req = actix_test::TestRequest::post() @@ -220,7 +220,7 @@ async fn test_unwrap_key_unsupported_alg() -> KResult<()> { #[tokio::test] async fn test_unwrap_key_missing_enc() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let header = json!({"alg": "RSA-OAEP-256", "kid": "some-kid"}); let protected = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); @@ -241,7 +241,7 @@ async fn test_unwrap_key_missing_enc() -> KResult<()> { #[tokio::test] async fn test_unwrap_key_empty_encrypted_key() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let (kid_priv, _kid_pub) = create_rsa_key_pair(&app).await?; let protected = build_protected_header("RSA-OAEP-256", "A256GCM", &kid_priv); @@ -264,7 +264,7 @@ async fn test_unwrap_key_empty_encrypted_key() -> KResult<()> { #[tokio::test] async fn test_unwrap_key_size_mismatch() -> KResult<()> { log_init(None); - let app = test_utils::test_app(None, None).await; + let app = test_utils::test_app(None).await; let (kid_priv, kid_pub) = create_rsa_key_pair(&app).await?; diff --git a/crate/server/src/tests/secret_data_tests.rs b/crate/server/src/tests/secret_data_tests.rs index 0b68d0e214..f0be497925 100644 --- a/crate/server/src/tests/secret_data_tests.rs +++ b/crate/server/src/tests/secret_data_tests.rs @@ -50,7 +50,7 @@ async fn test_secret_data_create_basic() -> KResult<()> { None, )?; - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; assert!(create_response.unique_identifier.as_str().is_some()); // Test Get operation @@ -137,7 +137,7 @@ async fn test_secret_data_with_wrapping() -> KResult<()> { None, )?; - let create_response = kms.create(create_request, owner, None).await?; + let create_response = kms.create(create_request, owner).await?; assert!(create_response.unique_identifier.as_str().is_some()); // create the wrapping key @@ -150,7 +150,7 @@ async fn test_secret_data_with_wrapping() -> KResult<()> { false, None, )?; - let create_wrapping_key_response = kms.create(create_wrapping_key_request, owner, None).await?; + let create_wrapping_key_response = kms.create(create_wrapping_key_request, owner).await?; assert!( create_wrapping_key_response .unique_identifier @@ -226,7 +226,7 @@ async fn test_secret_data_import_export_with_kek() -> KResult<()> { false, None, )?; - let create_wrapping_key_response = kms.create(create_wrapping_key_request, owner, None).await?; + let create_wrapping_key_response = kms.create(create_wrapping_key_request, owner).await?; assert!( create_wrapping_key_response .unique_identifier @@ -272,7 +272,7 @@ async fn test_secret_data_import_export_with_kek() -> KResult<()> { object: secret_data, }; - let import_response = kms.import(import_request, owner, None).await?; + let import_response = kms.import(import_request, owner).await?; assert_eq!(import_response.unique_identifier, secret_id); // Test Export operation with wrapping enabled diff --git a/crate/server/src/tests/security_regression.rs b/crate/server/src/tests/security_regression.rs index d11122efce..5058346d19 100644 --- a/crate/server/src/tests/security_regression.rs +++ b/crate/server/src/tests/security_regression.rs @@ -40,7 +40,7 @@ async fn create_aes_key(kms: &KMS, user: &str) -> KResult { None, ) .map_err(|e| crate::error::KmsError::InvalidRequest(e.to_string()))?; - let response = kms.create(request, user, None).await?; + let response = kms.create(request, user).await?; Ok(response.unique_identifier) } @@ -195,10 +195,7 @@ async fn test_mac_no_hmac_value_in_traces() -> KResult<()> { None, ) .map_err(|e| crate::error::KmsError::InvalidRequest(e.to_string()))?; - let key_id = kms - .create(request, "test_user", None) - .await? - .unique_identifier; + let key_id = kms.create(request, "test_user").await?.unique_identifier; let message = b"MESSAGE_WHOSE_MAC_MUST_NOT_BE_LOGGED_IN_FULL"; @@ -251,10 +248,7 @@ async fn test_decrypt_preserves_kek_wrapping_with_usage_limits() -> KResult<()> None, ) .map_err(|e| crate::error::KmsError::InvalidRequest(e.to_string()))?; - let kek_id = kms - .create(kek_request, owner, None) - .await? - .unique_identifier; + let kek_id = kms.create(kek_request, owner).await?.unique_identifier; drop(kms); // Phase 2: re-instantiate KMS with KEK configured @@ -279,10 +273,7 @@ async fn test_decrypt_preserves_kek_wrapping_with_usage_limits() -> KResult<()> usage_limits_count: None, usage_limits_total: 100, }); - let dek_id = kms - .create(dek_request, owner, None) - .await? - .unique_identifier; + let dek_id = kms.create(dek_request, owner).await?.unique_identifier; // Verify the DEK is stored wrapped let raw_object_before = kms @@ -295,6 +286,26 @@ async fn test_decrypt_preserves_kek_wrapping_with_usage_limits() -> KResult<()> "DEK must be KEK-wrapped after creation" ); + // Verify WrappingKeyLink attribute is populated in stored metadata (KMIP 2.1 §4.31 Link). + // Guards: bug where wrap_and_cache set key_block.key_wrapping_data but never + // propagated the wrapping key UID to the separate Attributes struct used by + // GetAttributes for wrapped (ByteString) key values. + { + use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::LinkType; + let wrapping_link = raw_object_before + .attributes() + .get_link(LinkType::WrappingKeyLink); + assert!( + wrapping_link.is_some(), + "WrappingKeyLink must be set in stored attributes after KEK-wrapped creation" + ); + assert_eq!( + wrapping_link.unwrap().to_string(), + kek_id.to_string(), + "WrappingKeyLink must point to the KEK" + ); + } + // Phase 3: Encrypt → Decrypt cycle (Decrypt triggers decrement_usage_limits) let plaintext = b"Regression test: KEK wrapping must survive decrypt"; let encrypt_response = kms @@ -383,10 +394,7 @@ async fn test_sign_preserves_kek_wrapping_with_usage_limits() -> KResult<()> { None, ) .map_err(|e| crate::error::KmsError::InvalidRequest(e.to_string()))?; - let kek_id = kms - .create(kek_request, owner, None) - .await? - .unique_identifier; + let kek_id = kms.create(kek_request, owner).await?.unique_identifier; drop(kms); // Phase 2: re-instantiate KMS with KEK @@ -413,7 +421,7 @@ async fn test_sign_preserves_kek_wrapping_with_usage_limits() -> KResult<()> { usage_limits_total: 100, }); } - let create_response = kms.create_key_pair(create_request, owner, None).await?; + let create_response = kms.create_key_pair(create_request, owner).await?; let private_key_id = create_response.private_key_unique_identifier; // Verify the private key is stored wrapped diff --git a/crate/server/src/tests/test_sign.rs b/crate/server/src/tests/test_sign.rs index b4729a4609..e00c22eff9 100644 --- a/crate/server/src/tests/test_sign.rs +++ b/crate/server/src/tests/test_sign.rs @@ -184,7 +184,7 @@ async fn test_sign_rsa() -> KResult<()> { false, // sensitive None, // wrapping_key_id )?; - let response = kms.create_key_pair(request, owner, None).await?; + let response = kms.create_key_pair(request, owner).await?; // Test single-call signature test_single_signature( @@ -222,7 +222,7 @@ async fn test_sign_ec_curve(curve: RecommendedCurve, test_name: &str) -> KResult false, // sensitive None, // wrapping_key_id )?; - let response = kms.create_key_pair(request, owner, None).await?; + let response = kms.create_key_pair(request, owner).await?; // Test single-call signature test_single_signature( diff --git a/crate/server/src/tests/test_utils.rs b/crate/server/src/tests/test_utils.rs index f29c6a2776..128227c07c 100644 --- a/crate/server/src/tests/test_utils.rs +++ b/crate/server/src/tests/test_utils.rs @@ -196,7 +196,6 @@ pub(crate) fn get_tmp_sqlite_path() -> PathBuf { /// # Arguments /// /// * `kms_public_url` - Optional public URL for the KMS server -/// * `privileged_users` - Optional list of users with elevated permissions /// /// # Google CSE Support /// @@ -207,7 +206,6 @@ pub(crate) fn get_tmp_sqlite_path() -> PathBuf { /// - Public key stored as `google_cse_rsa_pk` and exposed via `/google_cse/certs` pub(crate) async fn test_app( kms_public_url: Option, - privileged_users: Option>, ) -> impl Service, Error = actix_web::Error> { let clap_config = https_clap_config_opts(kms_public_url); @@ -229,7 +227,6 @@ pub(crate) async fn test_app( let mut app = App::new() .app_data(Data::new(kms_server.clone())) - .app_data(Data::new(privileged_users)) .service(routes::root_redirect::root_redirect_to_ui) .service(routes::health::get_health) .service(routes::get_version) @@ -285,7 +282,6 @@ pub(crate) async fn test_app( /// and enforcement settings and then validate behavior through the HTTP stack. pub(crate) async fn test_app_with_clap_config( clap_config: ClapConfig, - privileged_users: Option>, ) -> impl Service, Error = actix_web::Error> { let server_params = Arc::new(ServerParams::try_from(clap_config).expect("cannot create server params")); @@ -304,7 +300,6 @@ pub(crate) async fn test_app_with_clap_config( let mut app = App::new() .app_data(Data::new(kms_server.clone())) - .app_data(Data::new(privileged_users)) .service(routes::root_redirect::root_redirect_to_ui) .service(routes::health::get_health) .service(routes::get_version) diff --git a/crate/server/src/tests/test_validate.rs b/crate/server/src/tests/test_validate.rs index 191c226d99..8c43d5c5b3 100644 --- a/crate/server/src/tests/test_validate.rs +++ b/crate/server/src/tests/test_validate.rs @@ -143,7 +143,7 @@ pub(crate) async fn test_validate_with_certificates_ids() -> Result<(), KmsError certificate_value: root_cert.clone(), }), }; - let res_root = kms.import(root_request, owner, None).await?; + let res_root = kms.import(root_request, owner).await?; // intermediate let intermediate_request = Import { unique_identifier: UniqueIdentifier::TextString(String::new()), @@ -159,7 +159,7 @@ pub(crate) async fn test_validate_with_certificates_ids() -> Result<(), KmsError certificate_value: intermediate_cert.clone(), }), }; - let res_intermediate = kms.import(intermediate_request, owner, None).await?; + let res_intermediate = kms.import(intermediate_request, owner).await?; // leaf1 let leaf1_request = Import { unique_identifier: UniqueIdentifier::TextString(String::new()), @@ -175,7 +175,7 @@ pub(crate) async fn test_validate_with_certificates_ids() -> Result<(), KmsError certificate_value: leaf1_cert.clone(), }), }; - let res_leaf1 = kms.import(leaf1_request, owner, None).await?; + let res_leaf1 = kms.import(leaf1_request, owner).await?; // Only the root, it is valid by default let request = Validate { certificate: None, @@ -443,7 +443,7 @@ authorityKeyIdentifier=keyid:always,issuer }; let cert_id = kms - .certify(certify_req, owner, None) + .certify(certify_req, owner) .await? .unique_identifier .to_string(); @@ -1032,7 +1032,7 @@ authorityKeyIdentifier=keyid:always,issuer ..Certify::default() }; - let result = kms.certify(certify_req, owner, None).await; + let result = kms.certify(certify_req, owner).await; assert!( result.is_err(), "ML-KEM self-signed certificate creation must be rejected, but it succeeded" @@ -1065,7 +1065,6 @@ authorityKeyIdentifier=keyid:always,issuer ..Certify::default() }, owner, - None, ) .await; assert!(result.is_err(), "ML-KEM-768 self-signed must be rejected"); diff --git a/crate/server/src/tests/ttlv_tests/integrations/vast.rs b/crate/server/src/tests/ttlv_tests/integrations/vast.rs index 7dfb6d7499..f07e86884a 100644 --- a/crate/server/src/tests/ttlv_tests/integrations/vast.rs +++ b/crate/server/src/tests/ttlv_tests/integrations/vast.rs @@ -440,21 +440,23 @@ fn test_vast_rekey_aes_key() { !rekey_response.unique_identifier.is_empty(), "ReKey: UniqueIdentifier must not be empty" ); - // Per KMIP spec, ReKey creates a new replacement key with a new UID. - // The old key remains Active and is linked to the new key. + // Per KMIP spec §4.57, ReKey creates a new replacement key with a new UID. + // The server automatically Deactivates the old key (KMIP §4.57 transition 6). assert_ne!( rekey_response.unique_identifier, original_uid, "ReKey: new key must have a different UID than the original" ); info!( - "ReKey succeeded: new key uid={}, old key uid={} (old key remains Active)", + "ReKey succeeded: new key uid={}, old key uid={} (old key is now Deactivated per §4.57)", rekey_response.unique_identifier, original_uid ); - // Cleanup: destroy both the replacement key and the original key + // Cleanup: the new key is Active and must be Revoked before Destroy. + // Per KMIP §4.57 (transition 6) the server automatically Deactivates the old key + // during ReKey, so revoking the old key again would return Item_Not_Found. revoke_key(&client, &rekey_response.unique_identifier); destroy_key(&client, &rekey_response.unique_identifier); - revoke_key(&client, &original_uid); + // old key is already Deactivated by the server — skip Revoke, go straight to Destroy destroy_key(&client, &original_uid); } @@ -675,9 +677,9 @@ fn test_vast_recertify_request_parsed() { ephemeral: None, unique_batch_item_id: None, request_payload: Operation::ReCertify(ReCertify { - unique_identifier: "non-existent-cert-id".to_owned(), - certificate_request_type: CertificateRequestType::PEM, - certificate_request_value: vec![], + unique_identifier: Some("non-existent-cert-id".to_owned()), + certificate_request_type: Some(CertificateRequestType::PEM), + certificate_request_value: Some(vec![]), template_attribute: None, }), message_extension: None, @@ -1514,10 +1516,12 @@ fn test_vast_opn_survives_rekey() { info!("OPN survives ReKey: new key {new_key_uid} has OPN='default' and Name='{vast_key_name}'"); - // Cleanup + // Cleanup: new key is Active — revoke before destroy. + // Per KMIP §4.57 (transition 6) the server automatically Deactivates the old key + // during ReKey, so revoking it again would return Item_Not_Found. revoke_key(&client, new_key_uid); destroy_key(&client, new_key_uid); - revoke_key(&client, &key_uid); + // old key is already Deactivated — skip Revoke, go straight to Destroy destroy_key(&client, &key_uid); } @@ -1689,10 +1693,15 @@ fn test_vast_multi_key_locate_after_rekey() { info!("Locate _{i}: found {expected_uid} ✓"); } - // Cleanup + // Cleanup: new key is Active — revoke before destroy. + // Per KMIP §4.57 (transition 6) the server auto-Deactivates key_uids[0] (the rekeyed + // key) during ReKey, so revoking it again would return Item_Not_Found. revoke_key(&client, &new_uid_0); destroy_key(&client, &new_uid_0); - for uid in &key_uids { + // key_uids[0] is already Deactivated — skip Revoke, go straight to Destroy + destroy_key(&client, &key_uids[0]); + // key_uids[1..] were not rekeyed and are still Active — revoke them first + for uid in &key_uids[1..] { revoke_key(&client, uid); destroy_key(&client, uid); } diff --git a/crate/server_database/Cargo.toml b/crate/server_database/Cargo.toml index 896462b902..9a651cf36c 100644 --- a/crate/server_database/Cargo.toml +++ b/crate/server_database/Cargo.toml @@ -58,6 +58,7 @@ serde = { workspace = true } serde_json = { workspace = true, features = ["preserve_order"] } strum = { workspace = true } thiserror = { workspace = true } +time = { workspace = true } tokio = { workspace = true, features = ["full"] } tokio-postgres = { version = "0.7.18", features = [ "with-uuid-1", diff --git a/crate/server_database/src/core/database_objects.rs b/crate/server_database/src/core/database_objects.rs index 0909cfc6ca..cbd36a81c7 100644 --- a/crate/server_database/src/core/database_objects.rs +++ b/crate/server_database/src/core/database_objects.rs @@ -10,6 +10,7 @@ use cosmian_kmip::{ kmip_2_1::{kmip_attributes::Attributes, kmip_objects::Object}, }; use cosmian_kms_interfaces::{AtomicOperation, ObjectWithMetadata, ObjectsStore}; +use time::Date; use crate::{ Database, @@ -384,6 +385,83 @@ impl Database { .await } + /// Return (uid, state, attributes) for every object wrapped by the given wrapping key. + pub async fn find_wrapped_by( + &self, + wrapping_key_uid: &str, + user: &str, + ) -> DbResult> { + let map = self.objects.read().await; + let mut results: Vec<(String, State, Attributes)> = Vec::new(); + for db in map.values() { + results.extend( + db.find_wrapped_by(wrapping_key_uid, user) + .await + .unwrap_or_default(), + ); + } + Ok(results) + } + + /// Find all Active objects that have a `rotate_interval > 0` and whose next + /// rotation instant is ≤ `now`. Returns `(uid, owner)` pairs. + pub async fn find_due_for_rotation( + &self, + now: time::OffsetDateTime, + ) -> DbResult> { + let map = self.objects.read().await; + let mut results: Vec<(String, String)> = Vec::new(); + for db in map.values() { + results.extend(db.find_due_for_rotation(now).await.unwrap_or_default()); + } + Ok(results) + } + + /// Find objects by their `x-rotate-name` vendor attribute. + /// + /// Queries all registered object stores and returns matching `(uid, attributes)` pairs. + pub async fn find_by_rotate_name( + &self, + name: &str, + generation: Option, + owner: &str, + ) -> DbResult> { + let map = self.objects.read().await; + let mut results: Vec<(String, Attributes)> = Vec::new(); + for db in map.values() { + results.extend( + db.find_by_rotate_name(name, generation, owner) + .await + .unwrap_or_default(), + ); + } + Ok(results) + } + + /// Set the `CKA_LABEL` (or equivalent) on a key identified by `uid`. + /// + /// Routes to the object store responsible for `uid`. SQL stores silently ignore this. + pub async fn set_key_label(&self, uid: &str, label: &str) -> DbResult<()> { + let store = self.get_object_store(uid).await?; + store.set_key_label(uid, label).await.map_err(Into::into) + } + + /// Rewrite the PKCS#11 rotation dates on an HSM key identified by `uid`. + /// + /// Routes to the object store responsible for `uid`. SQL stores silently ignore this. + pub async fn set_key_rotation_dates( + &self, + uid: &str, + start_date: Option, + end_date: Option, + ) -> DbResult<()> { + let store = self.get_object_store(uid).await?; + store + .set_key_rotation_dates(uid, start_date, end_date) + .await + .map_err(Into::into) + } + /// Perform an atomic set of operations on the database. /// /// This function executes a series of operations (typically in a transaction) atomically. diff --git a/crate/server_database/src/stores/mod.rs b/crate/server_database/src/stores/mod.rs index 46807a2b66..cfa03ac4b7 100644 --- a/crate/server_database/src/stores/mod.rs +++ b/crate/server_database/src/stores/mod.rs @@ -16,9 +16,8 @@ pub(crate) use sql::{MySqlPool, PgPool, SqlitePool}; const PGSQL_FILE_QUERIES: &str = include_str!("sql/query.sql"); const MYSQL_FILE_QUERIES: &str = include_str!("sql/query_mysql.sql"); -const SQLITE_FILE_QUERIES: &str = include_str!("sql/query.sql"); -static PGSQL_QUERIES: LazyLock = LazyLock::new(|| { +pub(crate) static PGSQL_QUERIES: LazyLock = LazyLock::new(|| { // SAFETY: SQL files are included at compile time and should be valid #[expect(clippy::expect_used)] Loader::get_queries_from(PGSQL_FILE_QUERIES).expect("Can't parse the SQL file") @@ -28,8 +27,3 @@ static MYSQL_QUERIES: LazyLock = LazyLock::new(|| { #[expect(clippy::expect_used)] Loader::get_queries_from(MYSQL_FILE_QUERIES).expect("Can't parse the SQL file") }); -static SQLITE_QUERIES: LazyLock = LazyLock::new(|| { - // SAFETY: SQL files are included at compile time and should be valid - #[expect(clippy::expect_used)] - Loader::get_queries_from(SQLITE_FILE_QUERIES).expect("Can't parse the SQL file") -}); diff --git a/crate/server_database/src/stores/redis/objects_db.rs b/crate/server_database/src/stores/redis/objects_db.rs index d68ad87edf..6885eca550 100644 --- a/crate/server_database/src/stores/redis/objects_db.rs +++ b/crate/server_database/src/stores/redis/objects_db.rs @@ -64,6 +64,12 @@ pub(crate) fn keywords_from_attributes(attributes: &Attributes) -> HashSet, + owner: &str, + ) -> InterfaceResult> { + // Search Findex for objects indexed under this rotate_name keyword + let keyword = Keyword::from(format!("rotate_name::{name}").as_bytes()); + let indexed_uids = self + .findex + .search(&keyword) + .await + .map_err(|e| db_error!(format!("Error searching rotate_name keyword: {e:?}")))?; + if indexed_uids.is_empty() { + return Ok(vec![]); + } + + let candidate_uids: HashSet = indexed_uids + .iter() + .filter_map(|v| String::from_utf8(v.to_vec()).ok()) + .collect(); + + // Fetch the candidate objects + let redis_db_objects = self.objects_db.objects_get(&candidate_uids).await?; + + // Filter by owner, generation, and latest flag + let mut results = Vec::new(); + for (uid, dbo) in redis_db_objects { + if dbo.owner != owner { + continue; + } + let attrs = dbo.attributes.unwrap_or_default(); + // Verify the rotate_name matches (double-check) + if attrs.rotate_name.as_deref() != Some(name) { + continue; + } + // Filter by generation if requested + if let Some(expected_gen) = generation { + if attrs.rotate_generation != Some(expected_gen) { + continue; + } + } + results.push((uid, attrs)); + } + Ok(results) + } + + async fn find_wrapped_by( + &self, + wrapping_key_uid: &str, + user: &str, + ) -> InterfaceResult> { + // Search Findex for objects indexed under this wrapping key + let keyword = Keyword::from(format!("wrapped_by::{wrapping_key_uid}").as_bytes()); + let indexed_uids = self + .findex + .search(&keyword) + .await + .map_err(|e| db_error!(format!("Error searching wrapped_by keyword: {e:?}")))?; + if indexed_uids.is_empty() { + return Ok(vec![]); + } + + let candidate_uids: HashSet = indexed_uids + .iter() + .filter_map(|v| String::from_utf8(v.to_vec()).ok()) + .collect(); + + // Fetch only the candidate objects + let redis_db_objects = self.objects_db.objects_get(&candidate_uids).await?; + + // Filter by access: user must own the object or have permissions on it + let permissions = self + .permission_db + .list_user_permissions(&UserId(user.to_owned())) + .await?; + + let mut out = Vec::new(); + for (uid, dbo) in redis_db_objects { + let has_access = dbo.owner == user || permissions.contains_key(&ObjectUid(uid.clone())); + if !has_access { + continue; + } + let attrs = dbo + .object + .attributes() + .cloned() + .unwrap_or_else(|_| Attributes { + object_type: Some(dbo.object.object_type()), + ..Default::default() + }); + out.push((uid, dbo.state, attrs)); + } + Ok(out) + } + /// Return the count of live (non-destroyed) objects. /// /// # Fast path (steady state) diff --git a/crate/server_database/src/stores/sql/locate_query.rs b/crate/server_database/src/stores/sql/locate_query.rs index 36d5f3a3ea..39937e18c6 100644 --- a/crate/server_database/src/stores/sql/locate_query.rs +++ b/crate/server_database/src/stores/sql/locate_query.rs @@ -514,3 +514,95 @@ ON objects.id = matched_tags.id" qb.finish(query) } + +/// Build the SQL query to find objects by their `RotateName` vendor attribute. +/// +/// Optionally filters by `RotateGeneration` (integer equality) directly in SQL. +/// +/// Returns a `LocateQuery` with parameterized bindings suitable for all SQL backends. +pub(super) fn find_by_rotate_name_query( + name: &str, + generation: Option, + owner: &str, +) -> LocateQuery { + let mut qb = LocateQueryBuilder::

::new(); + + let owner_bind = qb.bind_text(owner); + let name_bind = qb.bind_text(name); + let rotate_name_extract = P::extract_attribute_path(&["RotateName"]); + + let mut query = format!( + "SELECT objects.id, objects.attributes FROM objects \ + WHERE objects.owner = {owner_bind} \ + AND {rotate_name_extract} = {name_bind}" + ); + + if let Some(g) = generation { + let gen_extract = P::extract_attribute_path(&["RotateGeneration"]); + let gen_bind = qb.bind_i64(i64::from(g)); + if P::NEEDS_INTEGER_CAST { + query = format!( + "{query} AND CAST({gen_extract} AS {}) = {gen_bind}", + P::TYPE_INTEGER + ); + } else { + query = format!("{query} AND CAST({gen_extract} AS SIGNED) = {gen_bind}"); + } + } + + qb.finish(query) +} + +/// Build the SQL query to find objects that are candidates for rotation. +/// Selects active objects where `RotateAutomatic = true` and `RotateInterval > 0`. +/// Per KMIP 2.1 §4.48, automatic rotation only occurs when explicitly enabled by the client. +/// The actual "due" check (comparing timestamps) is done in Rust via `is_due_for_rotation`. +/// +/// Returns `(id, owner, attributes)` rows so the auto-rotation scheduler can issue a +/// Re-Key on behalf of the correct owner without needing an additional DB round-trip. +#[must_use] +pub(super) fn find_due_for_rotation_query() -> String { + let interval_extract = P::extract_attribute_path(&["RotateInterval"]); + let auto_extract = P::extract_attribute_path(&["RotateAutomatic"]); + let cast_and_compare = if P::NEEDS_INTEGER_CAST { + format!("CAST({interval_extract} AS {}) > 0", P::TYPE_INTEGER) + } else { + // MySQL: CAST with SIGNED for correct numeric comparison + format!("CAST({interval_extract} AS SIGNED) > 0") + }; + format!( + "SELECT objects.id, objects.owner, objects.attributes FROM objects \ + WHERE objects.state = 'Active' \ + AND {auto_extract} = 'true' \ + AND {interval_extract} IS NOT NULL \ + AND {cast_and_compare}" + ) +} + +/// Determine whether a key object (already known to have `rotate_interval > 0`) +/// is past its scheduled rotation time. +/// +/// The next rotation time is computed as: +/// - `rotate_date + rotate_interval` if `rotate_date` is set (last rotation timestamp) +/// - `initial_date + rotate_offset + rotate_interval` otherwise (first rotation from creation) +/// +/// Returns `true` if `now >= next_rotation_time`. +pub(super) fn is_due_for_rotation(attrs: &Attributes, now: time::OffsetDateTime) -> bool { + let interval_secs = match attrs.rotate_interval { + Some(secs) if secs > 0 => secs, + _ => return false, + }; + let interval = time::Duration::seconds(interval_secs); + + let next_rotation = if let Some(last_rotate) = attrs.rotate_date { + last_rotate + interval + } else if let Some(initial) = attrs.initial_date { + let offset = time::Duration::seconds(attrs.rotate_offset.unwrap_or(0)); + initial + offset + interval + } else { + // No anchor date available — cannot determine schedule + return false; + }; + + now >= next_rotation +} diff --git a/crate/server_database/src/stores/sql/mysql.rs b/crate/server_database/src/stores/sql/mysql.rs index 9cea9cfc84..2e025ac7d0 100644 --- a/crate/server_database/src/stores/sql/mysql.rs +++ b/crate/server_database/src/stores/sql/mysql.rs @@ -31,7 +31,10 @@ use crate::{ migrate::{DbState, Migrate}, sql::{ database::SqlDatabase, - locate_query::{MySqlPlaceholder, query_from_attributes}, + locate_query::{ + MySqlPlaceholder, find_by_rotate_name_query, find_due_for_rotation_query, + query_from_attributes, + }, }, }, }; @@ -269,6 +272,50 @@ impl MySqlPool { } } + // Add wrapping_key_id column if not present, then backfill existing wrapped objects. + // MySQL 8.0 does not support `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, + // so we check with SHOW COLUMNS first. + let has_col_sql = MYSQL_QUERIES + .get("has-column-wrapping-key-id") + .ok_or_else(|| { + DbError::DatabaseError("Missing SQL query: has-column-wrapping-key-id".to_owned()) + })?; + let rows: Vec = conn.query(has_col_sql).await.map_err(DbError::from)?; + if rows.is_empty() { + let add_col = MYSQL_QUERIES + .get("add-column-wrapping-key-id") + .ok_or_else(|| { + DbError::DatabaseError( + "Missing SQL query: add-column-wrapping-key-id".to_owned(), + ) + })?; + conn.query_drop(add_col).await.map_err(DbError::from)?; + } + // Backfill: deserialize each object in Rust and extract wrapping key UID + let select_sql = MYSQL_QUERIES + .get("select-objects-null-wrapping-key") + .ok_or_else(|| { + DbError::DatabaseError( + "Missing SQL query: select-objects-null-wrapping-key".to_owned(), + ) + })?; + let update_sql = MYSQL_QUERIES.get("update-wrapping-key-id").ok_or_else(|| { + DbError::DatabaseError("Missing SQL query: update-wrapping-key-id".to_owned()) + })?; + let null_rows: Vec<(String, String)> = + conn.query(select_sql).await.map_err(DbError::from)?; + for (id, object_json) in &null_rows { + if let Ok(obj) = + serde_json::from_str::(object_json) + { + if let Some(wrapping_uid) = obj.wrapping_key_uid() { + conn.exec_drop(update_sql, (&wrapping_uid, id)) + .await + .map_err(DbError::from)?; + } + } + } + let this = Self { pool }; // On clear or first boot, update metadata (non-fips only) @@ -626,6 +673,90 @@ impl ObjectsStore for MySqlPool { .await?) } + async fn find_wrapped_by( + &self, + wrapping_key_uid: &str, + user: &str, + ) -> InterfaceResult> { + let sql = get_mysql_query!("find-wrapped-by"); + let mut conn = self + .pool + .get_conn() + .await + .map_err(|e| InterfaceError::Db(format!("MySQL connection error: {e}")))?; + let rows: Vec<(String, String, Value)> = conn + .exec(sql, (user, user, user, wrapping_key_uid)) + .await + .map_err(|e| InterfaceError::Db(format!("MySQL query error: {e}")))?; + let mut out = Vec::new(); + for (uid, state_str, attrs_val) in rows { + let state = State::try_from(state_str.as_str()) + .map_err(|e| InterfaceError::Db(format!("invalid state: {e}")))?; + let attrs: Attributes = serde_json::from_value(attrs_val) + .map_err(|e| InterfaceError::Db(format!("invalid attributes: {e}")))?; + out.push((uid, state, attrs)); + } + Ok(out) + } + + async fn find_due_for_rotation( + &self, + now: time::OffsetDateTime, + ) -> InterfaceResult> { + let sql = find_due_for_rotation_query::(); + let mut conn = self + .pool + .get_conn() + .await + .map_err(|e| InterfaceError::Db(format!("MySQL connection error: {e}")))?; + let rows: Vec<(String, String, serde_json::Value)> = conn + .exec(&sql, ()) + .await + .map_err(|e| InterfaceError::Db(format!("MySQL query error: {e}")))?; + let mut due = Vec::new(); + for (uid, owner, attrs_val) in rows { + let attrs: Attributes = serde_json::from_value(attrs_val).unwrap_or_default(); + if crate::stores::sql::locate_query::is_due_for_rotation(&attrs, now) { + due.push((uid, owner)); + } + } + Ok(due) + } + + async fn find_by_rotate_name( + &self, + name: &str, + generation: Option, + owner: &str, + ) -> InterfaceResult> { + let mut conn = self + .pool + .get_conn() + .await + .map_err(|e| InterfaceError::Db(format!("MySQL connection error: {e}")))?; + let locate = find_by_rotate_name_query::(name, generation, owner); + let params: Vec = locate + .params + .into_iter() + .map(|p| match p { + crate::stores::sql::locate_query::LocateParam::Text(s) => { + mysql_async::Value::Bytes(s.into_bytes()) + } + crate::stores::sql::locate_query::LocateParam::I64(i) => mysql_async::Value::Int(i), + }) + .collect(); + let rows: Vec<(String, serde_json::Value)> = conn + .exec(locate.sql, params) + .await + .map_err(|e| InterfaceError::Db(format!("MySQL query error: {e}")))?; + let mut results = Vec::new(); + for (uid, attrs_val) in rows { + let attrs: Attributes = serde_json::from_value(attrs_val).unwrap_or_default(); + results.push((uid, attrs)); + } + Ok(results) + } + /// Returns the total count of live (non-destroyed) objects in this `MySQL` store. /// /// This is a **metrics-only** privileged query: it scans the full `objects` table @@ -770,6 +901,7 @@ pub(super) async fn create_( DbError::ConversionError(format!("failed serializing the attributes to JSON: {e}").into()) })?; let uid = uid.unwrap_or_else(|| Uuid::new_v4().to_string()); + let wrapping_key_id = object.wrapping_key_uid(); tx.exec_drop( get_mysql_query!("insert-objects"), ( @@ -778,6 +910,7 @@ pub(super) async fn create_( attributes_json, attributes.state.unwrap_or(State::PreActive).to_string(), owner.to_owned(), + wrapping_key_id, ), ) .await @@ -828,9 +961,11 @@ pub(super) async fn update_object_( DbError::ConversionError(format!("failed serializing the attributes to JSON: {e}").into()) })?; + let wrapping_key_id = object.wrapping_key_uid(); + tx.exec_drop( get_mysql_query!("update-object-with-object"), - (object_json, attributes_json, uid), + (object_json, attributes_json, wrapping_key_id, uid), ) .await .map_err(DbError::from)?; @@ -903,9 +1038,17 @@ pub(super) async fn upsert_( let attributes_json = serde_json::to_value(attributes).map_err(|e| { DbError::ConversionError(format!("failed serializing the attributes to JSON: {e}").into()) })?; + let wrapping_key_id = object.wrapping_key_uid(); tx.exec_drop( get_mysql_query!("upsert-object"), - (uid, object_json, attributes_json, state.to_string(), owner), + ( + uid, + object_json, + attributes_json, + state.to_string(), + owner, + wrapping_key_id, + ), ) .await .map_err(DbError::from)?; diff --git a/crate/server_database/src/stores/sql/pgsql.rs b/crate/server_database/src/stores/sql/pgsql.rs index 7742105cd3..aa809345f9 100644 --- a/crate/server_database/src/stores/sql/pgsql.rs +++ b/crate/server_database/src/stores/sql/pgsql.rs @@ -337,6 +337,40 @@ impl PgPool { ) .await .map_err(DbError::from)?; + // Add wrapping_key_id column if not present, then backfill existing wrapped objects. + client + .batch_execute( + "ALTER TABLE objects ADD COLUMN IF NOT EXISTS wrapping_key_id VARCHAR(128);", + ) + .await + .map_err(DbError::from)?; + // Backfill: deserialize each object in Rust and extract wrapping key UID + let select_stmt = client + .prepare(get_pgsql_query!("select-objects-null-wrapping-key")) + .await + .map_err(DbError::from)?; + let update_stmt = client + .prepare(get_pgsql_query!("update-wrapping-key-id")) + .await + .map_err(DbError::from)?; + let null_rows = client + .query(&select_stmt, &[]) + .await + .map_err(DbError::from)?; + for row in &null_rows { + let id: String = row.get(0); + let object_json: String = row.get(1); + if let Ok(obj) = + serde_json::from_str::(&object_json) + { + if let Some(wrapping_uid) = obj.wrapping_key_uid() { + client + .execute(&update_stmt, &[&wrapping_uid, &id]) + .await + .map_err(DbError::from)?; + } + } + } // Optionally clear any existing data (useful for tests) if clear_database { @@ -398,14 +432,25 @@ impl ObjectsStore for PgPool { let object_json = serde_json::to_string(object).map_err(DbError::from)?; let attributes_json = serde_json::to_value(attributes).map_err(DbError::from)?; let state = attributes.state.unwrap_or(State::PreActive).to_string(); + let wrapping_key_id = object.wrapping_key_uid(); let stmt = tx .prepare_cached(get_pgsql_query!("insert-objects")) .await .map_err(DbError::from)?; let attrs_param = Json(&attributes_json); - tx.execute(&stmt, &[&uid, &object_json, &attrs_param, &state, &owner]) - .await - .map_err(DbError::from)?; + tx.execute( + &stmt, + &[ + &uid, + &object_json, + &attrs_param, + &state, + &owner, + &wrapping_key_id, + ], + ) + .await + .map_err(DbError::from)?; if !tags.is_empty() { let transaction_stmt = tx .prepare_cached(get_pgsql_query!("insert-tags")) @@ -488,12 +533,13 @@ impl ObjectsStore for PgPool { ) -> DbResult<()> { let object_json = serde_json::to_string(object).map_err(DbError::from)?; let attributes_json = serde_json::to_value(attributes).map_err(DbError::from)?; + let wrapping_key_id = object.wrapping_key_uid(); let stmt = tx .prepare_cached(get_pgsql_query!("update-object-with-object")) .await .map_err(DbError::from)?; let attrs_param = Json(&attributes_json); - tx.execute(&stmt, &[&object_json, &attrs_param, &uid]) + tx.execute(&stmt, &[&object_json, &attrs_param, &wrapping_key_id, &uid]) .await .map_err(DbError::from)?; if let Some(tags) = tags { @@ -578,14 +624,25 @@ impl ObjectsStore for PgPool { let attributes_json = serde_json::to_value(attributes).map_err(DbError::from)?; let state = attributes.state.unwrap_or(State::PreActive).to_string(); + let wrapping_key_id = object.wrapping_key_uid(); let stmt = tx .prepare_cached(get_pgsql_query!("insert-objects")) .await .map_err(DbError::from)?; let attrs_param = Json(&attributes_json); - tx.execute(&stmt, &[&uid, &object_json, &attrs_param, &state, &user]) - .await - .map_err(DbError::from)?; + tx.execute( + &stmt, + &[ + &uid, + &object_json, + &attrs_param, + &state, + &user, + &wrapping_key_id, + ], + ) + .await + .map_err(DbError::from)?; if !tags.is_empty() { let insert_stmt = tx .prepare_cached(get_pgsql_query!("insert-tags")) @@ -603,12 +660,13 @@ impl ObjectsStore for PgPool { let object_json = serde_json::to_string(object).map_err(DbError::from)?; let attributes_json = serde_json::to_value(attributes).map_err(DbError::from)?; + let wrapping_key_id = object.wrapping_key_uid(); let stmt = tx .prepare_cached(get_pgsql_query!("update-object-with-object")) .await .map_err(DbError::from)?; let attrs_param = Json(&attributes_json); - tx.execute(&stmt, &[&object_json, &attrs_param, &uid]) + tx.execute(&stmt, &[&object_json, &attrs_param, &wrapping_key_id, &uid]) .await .map_err(DbError::from)?; if let Some(tags) = tags { @@ -646,15 +704,26 @@ impl ObjectsStore for PgPool { let object_json = serde_json::to_string(object).map_err(DbError::from)?; let attributes_json = serde_json::to_value(attributes).map_err(DbError::from)?; + let wrapping_key_id = object.wrapping_key_uid(); let stmt = tx .prepare_cached(get_pgsql_query!("upsert-object")) .await .map_err(DbError::from)?; let st = state.to_string(); let attrs_param = Json(&attributes_json); - tx.execute(&stmt, &[&uid, &object_json, &attrs_param, &st, &user]) - .await - .map_err(DbError::from)?; + tx.execute( + &stmt, + &[ + &uid, + &object_json, + &attrs_param, + &st, + &user, + &wrapping_key_id, + ], + ) + .await + .map_err(DbError::from)?; if let Some(tags) = tags { let delete_stmt = tx .prepare_cached(get_pgsql_query!("delete-tags")) @@ -793,6 +862,102 @@ impl ObjectsStore for PgPool { }) } + async fn find_wrapped_by( + &self, + wrapping_key_uid: &str, + user: &str, + ) -> InterfaceResult> { + pg_retry!(self.pool, |client| { + let sql = get_pgsql_query!("find-wrapped-by"); + let rows = client + .query(sql, &[&wrapping_key_uid, &user]) + .await + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + let mut out = Vec::new(); + for row in rows { + let uid: String = row.get(0); + let state_str: String = row.get(1); + let state = State::try_from(state_str.as_str()) + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + let attrs_val: Value = row.get(2); + let attrs: Attributes = serde_json::from_value(attrs_val) + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + out.push((uid, state, attrs)); + } + Ok(out) + }) + } + + async fn find_due_for_rotation( + &self, + now: time::OffsetDateTime, + ) -> InterfaceResult> { + pg_retry!(self.pool, |client| { + let sql = crate::stores::sql::locate_query::find_due_for_rotation_query::< + crate::stores::sql::locate_query::PgSqlPlaceholder, + >(); + let rows = client + .query(&sql, &[]) + .await + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + let mut due = Vec::new(); + for row in rows { + let uid: String = row.get(0); + let owner: String = row.get(1); + let attrs_val: Value = row.get(2); + 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, owner)); + } + } + Ok(due) + }) + } + + async fn find_by_rotate_name( + &self, + name: &str, + generation: Option, + owner: &str, + ) -> InterfaceResult> { + let name = name.to_owned(); + let owner = owner.to_owned(); + pg_retry!(self.pool, |client| { + let locate = crate::stores::sql::locate_query::find_by_rotate_name_query::< + crate::stores::sql::locate_query::PgSqlPlaceholder, + >(&name, generation, &owner); + let stmt = client + .prepare(&locate.sql) + .await + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + let mut owned: Vec> = Vec::with_capacity(locate.params.len()); + for p in locate.params { + match p { + crate::stores::sql::locate_query::LocateParam::Text(s) => { + owned.push(Box::new(s)); + } + crate::stores::sql::locate_query::LocateParam::I64(i) => { + owned.push(Box::new(i)); + } + } + } + let params: Vec<&(dyn ToSql + Sync)> = + owned.iter().map(std::convert::AsRef::as_ref).collect(); + let rows = client + .query(&stmt, ¶ms) + .await + .map_err(|e| InterfaceError::from(DbError::from(e)))?; + let mut results = Vec::new(); + for row in rows { + let uid: String = row.get(0); + let attrs_val: Value = row.get(1); + let attrs: Attributes = serde_json::from_value(attrs_val).unwrap_or_default(); + results.push((uid, attrs)); + } + Ok(results) + }) + } + /// Returns the total count of live (non-destroyed) objects in this `PostgreSQL` store. /// /// This is a **metrics-only** privileged query: it scans the full `objects` table diff --git a/crate/server_database/src/stores/sql/query.sql b/crate/server_database/src/stores/sql/query.sql index 97b3166904..11e9b670d1 100644 --- a/crate/server_database/src/stores/sql/query.sql +++ b/crate/server_database/src/stores/sql/query.sql @@ -24,13 +24,17 @@ CREATE TABLE IF NOT EXISTS objects ( object VARCHAR NOT NULL, attributes jsonb NOT NULL, state VARCHAR(32), - owner VARCHAR(255) + owner VARCHAR(255), + wrapping_key_id VARCHAR(128) ); -- name: add-column-attributes ALTER TABLE objects ADD COLUMN attributes json; -- name: has-column-attributes SELECT attributes from objects; +-- name: add-column-wrapping-key-id +ALTER TABLE objects ADD COLUMN IF NOT EXISTS wrapping_key_id VARCHAR(128); + -- name: create-table-read_access CREATE TABLE IF NOT EXISTS read_access ( id VARCHAR(128), @@ -56,7 +60,7 @@ DELETE FROM read_access; DELETE FROM tags; -- name: insert-objects -INSERT INTO objects (id, object, attributes, state, owner) VALUES ($1, $2, $3, $4, $5); +INSERT INTO objects (id, object, attributes, state, owner, wrapping_key_id) VALUES ($1, $2, $3, $4, $5, $6); -- name: select-object SELECT objects.id, objects.object, objects.attributes, objects.owner, objects.state @@ -64,7 +68,7 @@ SELECT objects.id, objects.object, objects.attributes, objects.owner, objects.st WHERE objects.id=$1; -- name: update-object-with-object -UPDATE objects SET object=$1, attributes=$2 WHERE id=$3; +UPDATE objects SET object=$1, attributes=$2, wrapping_key_id=$3 WHERE id=$4; -- name: update-object-with-state UPDATE objects SET state=$1 WHERE id=$2; @@ -73,9 +77,9 @@ UPDATE objects SET state=$1 WHERE id=$2; DELETE FROM objects WHERE id=$1; -- name: upsert-object -INSERT INTO objects (id, object, attributes, state, owner) VALUES ($1, $2, $3, $4, $5) +INSERT INTO objects (id, object, attributes, state, owner, wrapping_key_id) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT(id) - DO UPDATE SET object=$2, attributes=$3, state=$4, owner=$5 + DO UPDATE SET object=$2, attributes=$3, state=$4, owner=$5, wrapping_key_id=$6 WHERE objects.owner=$5; -- name: count-non-destroyed-objects @@ -155,3 +159,16 @@ SELECT id FROM tags WHERE tag IN (@TAGS) GROUP BY id HAVING COUNT(DISTINCT tag) -- name: list-uids-for-tags SELECT id FROM tags WHERE tag = ANY($1::text[]) GROUP BY id HAVING COUNT(DISTINCT tag) = $2::int; + +-- name: find-wrapped-by +SELECT DISTINCT objects.id, objects.state, objects.attributes +FROM objects +LEFT JOIN read_access ON objects.id = read_access.id AND read_access.userid = $2 +WHERE (objects.owner = $2 OR read_access.userid = $2) + AND objects.wrapping_key_id = $1; + +-- name: select-objects-null-wrapping-key +SELECT id, object FROM objects WHERE wrapping_key_id IS NULL; + +-- name: update-wrapping-key-id +UPDATE objects SET wrapping_key_id = $1 WHERE id = $2; diff --git a/crate/server_database/src/stores/sql/query_mysql.sql b/crate/server_database/src/stores/sql/query_mysql.sql index f0e1f18bf9..7e62b60f11 100644 --- a/crate/server_database/src/stores/sql/query_mysql.sql +++ b/crate/server_database/src/stores/sql/query_mysql.sql @@ -24,11 +24,12 @@ WHERE name = ?; -- name: create-table-objects CREATE TABLE IF NOT EXISTS objects ( - id VARCHAR(128) PRIMARY KEY, - object LONGTEXT NOT NULL, - attributes json NOT NULL, - state VARCHAR(32), - owner VARCHAR(255) + id VARCHAR(128) PRIMARY KEY, + object LONGTEXT NOT NULL, + attributes json NOT NULL, + state VARCHAR(32), + owner VARCHAR(255), + wrapping_key_id VARCHAR(128) ); -- name: add-column-attributes @@ -38,6 +39,12 @@ ALTER TABLE objects -- name: has-column-attributes SHOW COLUMNS FROM objects LIKE 'attributes'; +-- name: has-column-wrapping-key-id +SHOW COLUMNS FROM objects LIKE 'wrapping_key_id'; + +-- name: add-column-wrapping-key-id +ALTER TABLE objects ADD COLUMN wrapping_key_id VARCHAR(128); + -- name: create-table-read_access CREATE TABLE IF NOT EXISTS read_access ( @@ -89,8 +96,8 @@ WHERE state NOT IN ('Destroyed', 'Destroyed_Compromised') AND JSON_UNQUOTE(JSON_EXTRACT(attributes, '$.ObjectType')) IN ('SymmetricKey', 'PrivateKey', 'PublicKey', 'SplitKey'); -- name: insert-objects -INSERT INTO objects (id, object, attributes, state, owner) -VALUES (?, ?, ?, ?, ?); +INSERT INTO objects (id, object, attributes, state, owner, wrapping_key_id) +VALUES (?, ?, ?, ?, ?, ?); -- name: select-object SELECT objects.id, objects.object, objects.attributes, objects.owner, objects.state @@ -100,7 +107,8 @@ WHERE objects.id = ?; -- name: update-object-with-object UPDATE objects SET object=?, - attributes=? + attributes=?, + wrapping_key_id=? WHERE id = ?; -- name: update-object-with-state @@ -114,12 +122,13 @@ FROM objects WHERE id = ?; -- name: upsert-object -INSERT INTO objects (id, object, attributes, state, owner) -VALUES (?, ?, ?, ?, ?) +INSERT INTO objects (id, object, attributes, state, owner, wrapping_key_id) +VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE object=VALUES(object), attributes=VALUES(attributes), state=VALUES(state), - owner=VALUES(owner); + owner=VALUES(owner), + wrapping_key_id=VALUES(wrapping_key_id); -- name: select-user-accesses-for-object SELECT permissions @@ -201,3 +210,16 @@ FROM tags WHERE tag IN (@TAGS) GROUP BY id HAVING COUNT(DISTINCT tag) = ?; + +-- name: find-wrapped-by +SELECT DISTINCT objects.id, objects.state, objects.attributes +FROM objects +LEFT JOIN read_access ON objects.id = read_access.id AND read_access.userid = ? +WHERE (objects.owner = ? OR read_access.userid = ?) + AND objects.wrapping_key_id = ?; + +-- name: select-objects-null-wrapping-key +SELECT id, object FROM objects WHERE wrapping_key_id IS NULL; + +-- name: update-wrapping-key-id +UPDATE objects SET wrapping_key_id = ? WHERE id = ?; diff --git a/crate/server_database/src/stores/sql/sqlite.rs b/crate/server_database/src/stores/sql/sqlite.rs index 8205c7e88d..80bc680613 100644 --- a/crate/server_database/src/stores/sql/sqlite.rs +++ b/crate/server_database/src/stores/sql/sqlite.rs @@ -23,13 +23,16 @@ use serde_json::Value; use tokio_rusqlite::Connection; use uuid::Uuid; -use super::locate_query::{SqlitePlaceholder, query_from_attributes}; +use super::locate_query::{ + SqlitePlaceholder, find_by_rotate_name_query, find_due_for_rotation_query, + query_from_attributes, +}; use crate::{ db_error, error::{DbError, DbResult}, migrate_block_cipher_mode_if_needed, stores::{ - SQLITE_QUERIES, + PGSQL_QUERIES, migrate::{DbState, Migrate}, sql::database::SqlDatabase, }, @@ -37,7 +40,7 @@ use crate::{ macro_rules! get_sqlite_query { ($name:literal) => { - SQLITE_QUERIES + PGSQL_QUERIES .get($name) .ok_or_else(|| db_error!("{} SQL query can't be found", $name))? }; @@ -143,6 +146,62 @@ impl SqlitePool { ) .await .map_err(DbError::from)?; + + // Add wrapping_key_id column if not present (migration for existing databases), + // then backfill from the embedded JSON for any pre-existing wrapped objects. + // Note: SQLite does not support `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, + // so we check PRAGMA table_info first. + pool.writer + .call( + move |c: &mut rusqlite::Connection| -> Result<(), rusqlite::Error> { + let has_column: bool = { + let mut stmt = c.prepare("PRAGMA table_info(objects)")?; + let mut rows = stmt.query([])?; + let mut found = false; + while let Some(row) = rows.next()? { + let col_name: String = row.get(1)?; + if col_name == "wrapping_key_id" { + found = true; + break; + } + } + found + }; + if !has_column { + c.execute_batch( + "ALTER TABLE objects ADD COLUMN wrapping_key_id VARCHAR(128);", + )?; + } + // Backfill: deserialize each object and extract wrapping key UID via Rust + let mut stmt = + c.prepare("SELECT id, object FROM objects WHERE wrapping_key_id IS NULL")?; + let pairs: Vec<(String, String)> = { + let mut rows = stmt.query([])?; + let mut out = Vec::new(); + while let Some(row) = rows.next()? { + out.push((row.get(0)?, row.get(1)?)); + } + out + }; + for (id, object_json) in &pairs { + if let Ok(obj) = serde_json::from_str::< + cosmian_kmip::kmip_2_1::kmip_objects::Object, + >(object_json) + { + if let Some(wrapping_uid) = obj.wrapping_key_uid() { + c.execute( + "UPDATE objects SET wrapping_key_id = ?1 WHERE id = ?2", + rusqlite::params![wrapping_uid, id], + )?; + } + } + } + Ok(()) + }, + ) + .await + .map_err(DbError::from)?; + if clear_database { pool.set_current_db_version(env!("CARGO_PKG_VERSION")) .await?; @@ -170,7 +229,7 @@ impl SqlitePool { impl SqlDatabase for SqlitePool { fn get_loader(&self) -> &Loader { - &SQLITE_QUERIES + &PGSQL_QUERIES } } @@ -257,6 +316,7 @@ impl ObjectsStore for SqlitePool { .map_err(|e| InterfaceError::Db(format!("failed serializing attributes: {e}")))?; let state_s = attributes.state.unwrap_or(State::PreActive).to_string(); let owner_s = owner.to_owned(); + let wrapping_key_id = object.wrapping_key_uid(); let insert_object = replace_dollars_with_qn(get_sqlite_query!("insert-objects")); let insert_tag = replace_dollars_with_qn(get_sqlite_query!("insert-tags")); @@ -270,13 +330,14 @@ impl ObjectsStore for SqlitePool { // Insert object tx.execute( &insert_object, - params_from_iter([ - &uid_clone, - &object_json, - &attributes_json, - &state_s, - &owner_s, - ]), + rusqlite::params![ + uid_clone, + object_json, + attributes_json, + state_s, + owner_s, + wrapping_key_id, + ], )?; // Insert tags for tag in &tags_owned { @@ -342,6 +403,7 @@ impl ObjectsStore for SqlitePool { .map_err(|e| InterfaceError::Db(format!("failed serializing object: {e}")))?; let attributes_json = serde_json::to_string(attributes) .map_err(|e| InterfaceError::Db(format!("failed serializing attributes: {e}")))?; + let wrapping_key_id = object.wrapping_key_uid(); let sql_update = replace_dollars_with_qn(get_sqlite_query!("update-object-with-object")); let sql_delete_tags = replace_dollars_with_qn(get_sqlite_query!("delete-tags")); @@ -355,7 +417,7 @@ impl ObjectsStore for SqlitePool { let tx = c.transaction()?; tx.execute( &sql_update, - params_from_iter([&object_json, &attributes_json, &uid_s]), + rusqlite::params![object_json, attributes_json, wrapping_key_id, uid_s], )?; if let Some(tags) = tags_owned.as_ref() { tx.execute(&sql_delete_tags, params_from_iter([&uid_s]))?; @@ -544,6 +606,133 @@ impl ObjectsStore for SqlitePool { Ok(rows) } + async fn find_wrapped_by( + &self, + wrapping_key_uid: &str, + user: &str, + ) -> InterfaceResult> { + let sql = replace_dollars_with_qn(get_sqlite_query!("find-wrapped-by")); + let uid_s = wrapping_key_uid.to_owned(); + let user_s = user.to_owned(); + let rows = self + .reader() + .call( + move |c: &mut rusqlite::Connection| -> Result< + Vec<(String, State, Attributes)>, + rusqlite::Error, + > { + let mut stmt = c.prepare(&sql)?; + let mut q = + stmt.query(params_from_iter([uid_s.as_str(), user_s.as_str()]))?; + let mut out = Vec::new(); + while let Some(r) = q.next()? { + let id: String = r.get(0)?; + let state_str: String = r.get(1)?; + let state = State::try_from(state_str.as_str()) + .map_err(|_e| rusqlite::Error::InvalidQuery)?; + let raw: String = r.get(2)?; + let attrs = if raw.is_empty() { + Attributes::default() + } else { + serde_json::from_str::(&raw) + .map_err(|_e| rusqlite::Error::InvalidQuery)? + }; + out.push((id, state, attrs)); + } + Ok(out) + }, + ) + .await + .map_err(DbError::from)?; + Ok(rows) + } + + async fn find_due_for_rotation( + &self, + now: time::OffsetDateTime, + ) -> InterfaceResult> { + let sql = find_due_for_rotation_query::(); + let rows = self + .reader() + .call( + move |c: &mut rusqlite::Connection| -> Result< + Vec<(String, String, String)>, + rusqlite::Error, + > { + let mut stmt = c.prepare(&sql)?; + let mut q = stmt.query([])?; + let mut out = Vec::new(); + while let Some(r) = q.next()? { + let id: String = r.get(0)?; + let owner: String = r.get(1)?; + let attrs_json: String = r.get(2)?; + out.push((id, owner, attrs_json)); + } + Ok(out) + }, + ) + .await + .map_err(DbError::from)?; + + let mut due = Vec::new(); + for (uid, owner, 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, owner)); + } + } + Ok(due) + } + + async fn find_by_rotate_name( + &self, + name: &str, + generation: Option, + owner: &str, + ) -> InterfaceResult> { + let locate = find_by_rotate_name_query::(name, generation, owner); + let sql = replace_dollars_with_qn(&locate.sql); + let locate_params = locate.params; + let rows = self + .reader() + .call( + move |c: &mut rusqlite::Connection| -> Result< + Vec<(String, String)>, + rusqlite::Error, + > { + let mut stmt = c.prepare(&sql)?; + let values: Vec = locate_params + .into_iter() + .map(|p| match p { + crate::stores::sql::locate_query::LocateParam::Text(s) => { + rusqlite::types::Value::Text(s) + } + crate::stores::sql::locate_query::LocateParam::I64(i) => { + rusqlite::types::Value::Integer(i) + } + }) + .collect(); + let mut q = stmt.query(rusqlite::params_from_iter(values.iter()))?; + let mut out = Vec::new(); + while let Some(r) = q.next()? { + let id: String = r.get(0)?; + let attrs_json: String = r.get(1)?; + out.push((id, attrs_json)); + } + Ok(out) + }, + ) + .await + .map_err(DbError::from)?; + + let mut results = Vec::new(); + for (uid, attrs_json) in rows { + let attrs: Attributes = serde_json::from_str(&attrs_json).unwrap_or_default(); + results.push((uid, attrs)); + } + Ok(results) + } + /// Returns the total count of live (non-destroyed) objects in this `SQLite` store. /// /// This is a **metrics-only** privileged query: it scans the full `objects` table @@ -585,7 +774,7 @@ impl ObjectsStore for SqlitePool { impl Migrate for SqlitePool { async fn get_db_state(&self) -> DbResult> { let select_param = replace_dollars_with_qn( - SQLITE_QUERIES + PGSQL_QUERIES .get("select-parameter") .ok_or_else(|| db_error!("select-parameter SQL query can't be found"))?, ); @@ -612,7 +801,7 @@ impl Migrate for SqlitePool { async fn set_db_state(&self, state: DbState) -> DbResult<()> { let upsert_param = replace_dollars_with_qn( - SQLITE_QUERIES + PGSQL_QUERIES .get("upsert-parameter") .ok_or_else(|| db_error!("upsert-parameter SQL query can't be found"))?, ); @@ -636,7 +825,7 @@ impl Migrate for SqlitePool { async fn get_current_db_version(&self) -> DbResult> { let select_param = replace_dollars_with_qn( - SQLITE_QUERIES + PGSQL_QUERIES .get("select-parameter") .ok_or_else(|| db_error!("select-parameter SQL query can't be found"))?, ); @@ -660,7 +849,7 @@ impl Migrate for SqlitePool { async fn set_current_db_version(&self, version: &str) -> DbResult<()> { let upsert_param = replace_dollars_with_qn( - SQLITE_QUERIES + PGSQL_QUERIES .get("upsert-parameter") .ok_or_else(|| db_error!("upsert-parameter SQL query can't be found"))?, ); @@ -889,13 +1078,21 @@ fn create_sqlite( DbError::DatabaseError(format!("failed serializing the attributes to JSON: {e}")) })?; let uid = uid.unwrap_or_else(|| Uuid::new_v4().to_string()); + let wrapping_key_id = object.wrapping_key_uid(); let sql = replace_dollars_with_qn(get_sqlite_query!("insert-objects")); let state_s = attributes.state.unwrap_or(State::PreActive).to_string(); let owner_s = owner.to_owned(); tx.execute( &sql, - params_from_iter([&uid, &object_json, &attributes_json, &state_s, &owner_s]), + rusqlite::params![ + uid, + object_json, + attributes_json, + state_s, + owner_s, + wrapping_key_id + ], )?; let sql = replace_dollars_with_qn(get_sqlite_query!("insert-tags")); @@ -918,11 +1115,12 @@ fn update_object_sqlite( let attributes_json = serde_json::to_string(attributes).map_err(|e| { DbError::DatabaseError(format!("failed serializing the attributes to JSON: {e}")) })?; + let wrapping_key_id = object.wrapping_key_uid(); let sql = replace_dollars_with_qn(get_sqlite_query!("update-object-with-object")); let uid_s = uid.to_owned(); tx.execute( &sql, - params_from_iter([&object_json, &attributes_json, &uid_s]), + rusqlite::params![object_json, attributes_json, wrapping_key_id, uid_s], )?; if let Some(tags) = tags { let del = replace_dollars_with_qn(get_sqlite_query!("delete-tags")); @@ -950,13 +1148,21 @@ fn upsert_sqlite( let attributes_json = serde_json::to_string(attributes).map_err(|e| { DbError::DatabaseError(format!("failed serializing the attributes to JSON: {e}")) })?; + let wrapping_key_id = object.wrapping_key_uid(); let sql = replace_dollars_with_qn(get_sqlite_query!("upsert-object")); let state_s = state.to_string(); let uid_s = uid.to_owned(); let owner_s = owner.to_owned(); tx.execute( &sql, - params_from_iter([&uid_s, &object_json, &attributes_json, &state_s, &owner_s]), + rusqlite::params![ + uid_s, + object_json, + attributes_json, + state_s, + owner_s, + wrapping_key_id + ], )?; if let Some(tags) = tags { let del = replace_dollars_with_qn(get_sqlite_query!("delete-tags")); @@ -1061,11 +1267,11 @@ mod tests { #[test] fn test_count_query_keys_present_in_loader() { assert!( - SQLITE_QUERIES.get("count-non-destroyed-objects").is_some(), + PGSQL_QUERIES.get("count-non-destroyed-objects").is_some(), "count-non-destroyed-objects not found – rawsql comment stripping bug recurred" ); assert!( - SQLITE_QUERIES + PGSQL_QUERIES .get("count-non-destroyed-keys-sqlite") .is_some(), "count-non-destroyed-keys-sqlite not found – rawsql comment stripping bug recurred" diff --git a/crate/test_kms_server/README.md b/crate/test_kms_server/README.md index 22c977be6d..efdb0647ed 100644 --- a/crate/test_kms_server/README.md +++ b/crate/test_kms_server/README.md @@ -65,269 +65,440 @@ under `test_data/vectors/` containing a `manifest.toml` and one JSON step file per KMIP operation. The vector runner uses singleton shared servers and replays the steps sequentially. -**353 vectors** across 8 categories: +**479 vectors** across 15 categories (including KAT): | Category | Vector Directory Name | KMIP Operations | Steps | |----------|-----------------------|-----------------|-------| | **Symmetric** | | | | -| Symmetric | `aes_create_get` | Create, Get | 2 | -| Symmetric | `aes_encrypt_decrypt` | Create, Encrypt, Decrypt, Revoke, Destroy | 5 | -| Symmetric | `aes128_encrypt_decrypt` | Create, Encrypt (AES-128-GCM), Decrypt | 3 | -| Symmetric | `aes256_cbc_encrypt_decrypt` | Create, Encrypt (AES-256-CBC), Decrypt | 3 | -| Symmetric | `aes128_cbc_encrypt_decrypt` | Create, Encrypt (AES-128-CBC), Decrypt | 3 | -| Symmetric | `aes192_gcm_encrypt_decrypt` | Create, Encrypt (AES-192-GCM), Decrypt | 3 | -| Symmetric | `aes192_cbc_encrypt_decrypt` | Create, Encrypt (AES-192-CBC), Decrypt | 3 | -| Symmetric | `aes128_ecb_encrypt_decrypt` | Create, Encrypt (AES-128-ECB, no padding, no nonce), Decrypt | 3 | -| Symmetric | `aes256_ecb_encrypt_decrypt` | Create, Encrypt (AES-256-ECB, no padding, no nonce), Decrypt | 3 | -| Symmetric | `aes256_gcm_aad_encrypt_decrypt` | Create, Encrypt (AES-256-GCM + AAD), Decrypt | 3 | -| Symmetric | `aes256_gcm_siv_encrypt_decrypt` | Create, Encrypt (AES-256-GCM-SIV), Decrypt | 3 | -| Symmetric | `aes128_gcm_siv_encrypt_decrypt` | Create, Encrypt (AES-128-GCM-SIV), Decrypt | 3 | -| Symmetric | `aes192_ecb_encrypt_decrypt` | Create, Encrypt (AES-192-ECB, no padding), Decrypt | 3 | -| Symmetric | `aes256_cbc_no_padding_encrypt_decrypt` | Create, Encrypt (AES-256-CBC, no padding), Decrypt | 3 | -| Symmetric | `aes128_xts_encrypt_decrypt` | Create, Encrypt (AES-128-XTS), Decrypt | 3 | -| Symmetric | `aes256_xts_encrypt_decrypt` | Create, Encrypt (AES-256-XTS), Decrypt | 3 | -| Symmetric | `chacha20_encrypt_decrypt` | Create, Encrypt (ChaCha20 pure stream), Decrypt | 3 | -| Symmetric | `chacha20_poly1305_encrypt_decrypt` | Create, Encrypt (ChaCha20-Poly1305 AEAD), Decrypt | 3 | +| Symmetric | `aes128_cbc_encrypt_decrypt` | Creates an AES-128 symmetric key, encrypts data with AES-CBC (PKCS5 padding), then decrypts and verifies | 3 | +| Symmetric | `aes128_ecb_encrypt_decrypt` | Creates an AES-128 symmetric key, encrypts block-aligned data with AES-ECB (no padding, no nonce), then decrypts and verifies | 3 | +| Symmetric | `aes128_encrypt_decrypt` | Creates an AES-128 key, encrypts data with AES-GCM, then decrypts | 5 | +| Symmetric | `aes128_gcm_siv_encrypt_decrypt` | Creates an AES-128 symmetric key, encrypts data with AES-GCM-SIV (nonce-misuse resistant AEAD), then decrypts and verifies | 3 | +| Symmetric | `aes128_xts_encrypt_decrypt` | Creates an AES-128-XTS key (32-byte, non-FIPS), encrypts a sector with a fixed tweak, then decrypts and verifies | 3 | +| Symmetric | `aes192_cbc_encrypt_decrypt` | Creates an AES-192 symmetric key, encrypts data with AES-CBC (PKCS5 padding), then decrypts and verifies | 3 | +| Symmetric | `aes192_ecb_encrypt_decrypt` | Creates an AES-192 symmetric key, encrypts block-aligned data with AES-ECB (no padding, no nonce), then decrypts and verifies | 3 | +| Symmetric | `aes192_gcm_encrypt_decrypt` | Creates an AES-192 symmetric key, encrypts data with AES-GCM (AEAD), then decrypts and verifies | 3 | +| Symmetric | `aes256_cbc_encrypt_decrypt` | Creates an AES-256 symmetric key, encrypts data with AES-CBC (PKCS5 padding), then decrypts and verifies | 3 | +| Symmetric | `aes256_cbc_no_padding_encrypt_decrypt` | Creates an AES-256 key, encrypts block-aligned data with CBC and no padding, then decrypts and verifies | 3 | +| Symmetric | `aes256_ecb_encrypt_decrypt` | Creates an AES-256 symmetric key, encrypts block-aligned data with AES-ECB (no padding, no nonce), then decrypts and verifies | 3 | +| Symmetric | `aes256_gcm_aad_encrypt_decrypt` | Creates an AES-256 symmetric key, encrypts data with AES-GCM and Additional Authenticated Data (AAD), then decrypts and verifies that AAD is authenticated | 3 | +| Symmetric | `aes256_gcm_siv_encrypt_decrypt` | Creates an AES-256 symmetric key, encrypts data with AES-GCM-SIV (nonce-misuse resistant AEAD), then decrypts and verifies | 3 | +| Symmetric | `aes256_xts_encrypt_decrypt` | Creates an AES-256-XTS key (64-byte, non-FIPS), encrypts a sector with a fixed tweak, then decrypts and verifies | 3 | +| Symmetric | `aes_create_get` | Creates an AES-256 symmetric key and retrieves it via Get | 2 | +| Symmetric | `aes_encrypt_decrypt` | Creates an AES-256 key, encrypts data with AES-GCM, then decrypts and verifies | 5 | +| Symmetric | `chacha20_encrypt_decrypt` | Creates a ChaCha20 key (non-FIPS), encrypts data with an 8-byte nonce, then decrypts and verifies | 3 | +| Symmetric | `chacha20_poly1305_encrypt_decrypt` | Creates a ChaCha20-Poly1305 key, encrypts data with AEAD mode, then decrypts and verifies | 3 | | **Asymmetric** | | | | -| Asymmetric | `rsa_create_encrypt_decrypt` | CreateKeyPair (RSA-2048), Encrypt (OAEP/SHA-256), Decrypt | 3 | -| Asymmetric | `rsa4096_encrypt_decrypt` | CreateKeyPair (RSA-4096), Encrypt (OAEP/SHA-256), Decrypt | 3 | -| Asymmetric | `rsa2048_oaep_sha384_encrypt_decrypt` | CreateKeyPair (RSA-2048), Encrypt (OAEP/SHA-384), Decrypt | 3 | -| Asymmetric | `rsa2048_oaep_sha512_encrypt_decrypt` | CreateKeyPair (RSA-2048), Encrypt (OAEP/SHA-512), Decrypt | 3 | -| Asymmetric | `rsa2048_pkcs1v15_encrypt_decrypt` | CreateKeyPair (RSA-2048), Encrypt (PKCS#1 v1.5), Decrypt | 3 | -| Asymmetric | `ec_p256_sign_verify` | CreateKeyPair (P-256), Sign (ECDSA), SignatureVerify | 3 | -| Asymmetric | `ec_p384_sign_verify` | CreateKeyPair (P-384), Sign (ECDSA), SignatureVerify | 3 | -| Asymmetric | `ec_p521_sign_verify` | CreateKeyPair (P-521), Sign (ECDSA), SignatureVerify | 3 | -| Asymmetric | `rsa2048_pkcs1v15_sha256_sign` | CreateKeyPair (RSA-2048), Sign (PKCS#1 v1.5 SHA-256), SignatureVerify | 3 | -| Asymmetric | `rsa2048_pss_sha256_sign` | CreateKeyPair (RSA-2048), Sign (PSS-SHA256), SignatureVerify | 3 | -| Asymmetric | `rsa2048_pss_sha384_sign` | CreateKeyPair (RSA-2048), Sign (PSS-SHA384), SignatureVerify | 3 | -| Asymmetric | `rsa2048_pss_sha512_sign` | CreateKeyPair (RSA-2048), Sign (PSS-SHA512), SignatureVerify | 3 | -| Asymmetric | `eddsa_ed25519_sign` | CreateKeyPair (Ed25519), Sign (EdDSA), SignatureVerify | 3 | -| Asymmetric | `eddsa_ed448_sign` | CreateKeyPair (Ed448), Sign (EdDSA), SignatureVerify | 3 | -| Asymmetric | `ec_k256_sign_verify` | CreateKeyPair (secp256k1), Sign (ECDSA), SignatureVerify | 3 | -| Asymmetric | `rsa4096_pss_sha256_sign` | CreateKeyPair (RSA-4096), Sign (PSS-SHA256), SignatureVerify | 3 | -| Asymmetric | `rsa2048_pss_sha1_sign` | CreateKeyPair (RSA-2048), Sign (PSS-SHA1), SignatureVerify | 3 | -| Asymmetric | `ec_p256_ecies_encrypt_decrypt` | CreateKeyPair (P-256), Encrypt (ECIES), Decrypt | 3 | -| Asymmetric | `rsa2048_aes_key_wrap` | CreateKeyPair (RSA-2048), Encrypt (RSA-AES key wrap), Decrypt | 3 | +| Asymmetric | `ec_k256_sign_verify` | Creates a secp256k1 ECDSA key pair (non-FIPS), signs data with SHA-256, verifies the signature | 3 | +| Asymmetric | `ec_p256_ecies_encrypt_decrypt` | Creates an ECDH P-256 key pair (non-FIPS), encrypts with ECIES using the public key, decrypts with the private key | 3 | +| Asymmetric | `ec_p256_sign_verify` | Creates a NIST P-256 key pair, signs data, verifies the signature | 3 | +| Asymmetric | `ec_p384_sign_verify` | Creates a NIST P-384 key pair, signs data, verifies the signature | 3 | +| Asymmetric | `ec_p521_sign_verify` | Creates a NIST P-521 key pair, signs data with ECDSA, verifies the signature | 3 | +| Asymmetric | `eddsa_ed25519_sign` | Creates an Ed25519 key pair, signs data with EdDSA, verifies the signature | 3 | +| Asymmetric | `eddsa_ed448_sign` | Creates an Ed448 key pair (non-FIPS), signs data, verifies the signature | 3 | +| Asymmetric | `ml_dsa_44_export_raw` | Creates a ML-DSA-44 key pair (PKCS8 format), exports both private and public keys as Raw format | 3 | +| Asymmetric | `ml_kem_768_export_raw` | Creates a ML-KEM-768 key pair (PKCS8 format), exports both private and public keys as Raw format | 3 | +| Asymmetric | `rsa2048_aes_key_wrap` | Creates an RSA-2048 key pair, wraps 32-byte AES key material with RSA-AES (PaddingMethod=None), then unwraps and verifies | 3 | +| Asymmetric | `rsa2048_oaep_sha384_encrypt_decrypt` | Creates an RSA-2048 key pair, encrypts data with OAEP/SHA-384, decrypts with the private key and verifies | 3 | +| Asymmetric | `rsa2048_oaep_sha512_encrypt_decrypt` | Creates an RSA-2048 key pair, encrypts data with OAEP/SHA-512, decrypts with the private key and verifies | 3 | +| Asymmetric | `rsa2048_pkcs1v15_encrypt_decrypt` | Creates an RSA-2048 key pair, encrypts data with PKCS#1 v1.5 padding (non-FIPS), decrypts with the private key and verifies | 3 | +| Asymmetric | `rsa2048_pkcs1v15_sha256_sign` | Creates an RSA-2048 key pair, signs data with PKCS#1 v1.5 (SHA-256WithRSAEncryption), verifies the signature | 3 | +| Asymmetric | `rsa2048_pss_sha1_sign` | Creates an RSA-2048 key pair, signs data with PSS-SHA1 (non-FIPS: SHA-1 signing disallowed in FIPS mode), verifies the signature | 3 | +| Asymmetric | `rsa2048_pss_sha256_sign` | Creates an RSA-2048 key pair, signs data with RSASSA-PSS (SHA-256), verifies the signature | 3 | +| Asymmetric | `rsa2048_pss_sha384_sign` | Creates an RSA-2048 key pair, signs data with RSASSA-PSS (SHA-384), verifies the signature | 3 | +| Asymmetric | `rsa2048_pss_sha512_sign` | Creates an RSA-2048 key pair, signs data with RSASSA-PSS (SHA-512), verifies the signature | 3 | +| Asymmetric | `rsa4096_encrypt_decrypt` | Creates an RSA-4096 key pair, encrypts data with the public key, decrypts with the private key | 3 | +| Asymmetric | `rsa4096_pss_sha256_sign` | Creates an RSA-4096 key pair, signs data with PSS-SHA256, verifies the signature | 3 | +| Asymmetric | `rsa_create_encrypt_decrypt` | Creates an RSA-2048 key pair, encrypts data with the public key, decrypts with the private key | 3 | | **PQC** | | | | -| PQC | `ml_dsa_44_sign_verify` | CreateKeyPair (ML-DSA-44), Sign, SignatureVerify | 3 | -| PQC | `ml_dsa_65_sign_verify` | CreateKeyPair (ML-DSA-65), Sign, SignatureVerify | 3 | -| PQC | `ml_dsa_87_sign_verify` | CreateKeyPair (ML-DSA-87), Sign, SignatureVerify | 3 | -| PQC | `ml_kem_512_encap_decap` | CreateKeyPair (ML-KEM-512), Encrypt (encapsulate), Decrypt (decapsulate) | 3 | -| PQC | `ml_kem_768_encap_decap` | CreateKeyPair (ML-KEM-768), Encrypt (encapsulate), Decrypt (decapsulate) | 3 | -| PQC | `ml_kem_1024_encap_decap` | CreateKeyPair (ML-KEM-1024), Encrypt (encapsulate), Decrypt (decapsulate) | 3 | -| PQC | `slh_dsa_sha2_128s_sign_verify` | CreateKeyPair (SLH-DSA-SHA2-128s), Sign, SignatureVerify | 3 | -| PQC | `slh_dsa_sha2_128f_sign_verify` | CreateKeyPair (SLH-DSA-SHA2-128f), Sign, SignatureVerify | 3 | -| PQC | `slh_dsa_sha2_192s_sign_verify` | CreateKeyPair (SLH-DSA-SHA2-192s), Sign, SignatureVerify | 3 | -| PQC | `slh_dsa_sha2_192f_sign_verify` | CreateKeyPair (SLH-DSA-SHA2-192f), Sign, SignatureVerify | 3 | -| PQC | `slh_dsa_sha2_256s_sign_verify` | CreateKeyPair (SLH-DSA-SHA2-256s), Sign, SignatureVerify | 3 | -| PQC | `slh_dsa_sha2_256f_sign_verify` | CreateKeyPair (SLH-DSA-SHA2-256f), Sign, SignatureVerify | 3 | -| PQC | `slh_dsa_shake_128s_sign_verify` | CreateKeyPair (SLH-DSA-SHAKE-128s), Sign, SignatureVerify | 3 | -| PQC | `slh_dsa_shake_128f_sign_verify` | CreateKeyPair (SLH-DSA-SHAKE-128f), Sign, SignatureVerify | 3 | -| PQC | `slh_dsa_shake_192s_sign_verify` | CreateKeyPair (SLH-DSA-SHAKE-192s), Sign, SignatureVerify | 3 | -| PQC | `slh_dsa_shake_192f_sign_verify` | CreateKeyPair (SLH-DSA-SHAKE-192f), Sign, SignatureVerify | 3 | -| PQC | `slh_dsa_shake_256s_sign_verify` | CreateKeyPair (SLH-DSA-SHAKE-256s), Sign, SignatureVerify | 3 | -| PQC | `slh_dsa_shake_256f_sign_verify` | CreateKeyPair (SLH-DSA-SHAKE-256f), Sign, SignatureVerify | 3 | +| PQC | `ml_dsa_44_sign_verify` | Creates a ML-DSA-44 key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `ml_dsa_65_sign_verify` | Creates a ML-DSA-65 key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `ml_dsa_87_sign_verify` | Creates a ML-DSA-87 key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `ml_kem_1024_encap_decap` | Creates a ML-KEM-1024 key pair, encapsulates to get ciphertext + shared secret, decapsulates and verifies shared secrets match | 3 | +| PQC | `ml_kem_512_encap_decap` | Creates a ML-KEM-512 key pair, encapsulates to get ciphertext + shared secret, decapsulates and verifies shared secrets match | 3 | +| PQC | `ml_kem_768_encap_decap` | Creates a ML-KEM-768 key pair, encapsulates to get ciphertext + shared secret, decapsulates and verifies shared secrets match | 3 | +| PQC | `slh_dsa_sha2_128f_sign_verify` | Creates a SLH-DSA-SHA2-128f key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `slh_dsa_sha2_128s_sign_verify` | Creates a SLH-DSA-SHA2-128s key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `slh_dsa_sha2_192f_sign_verify` | Creates a SLH-DSA-SHA2-192f key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `slh_dsa_sha2_192s_sign_verify` | Creates a SLH-DSA-SHA2-192s key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `slh_dsa_sha2_256f_sign_verify` | Creates a SLH-DSA-SHA2-256f key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `slh_dsa_sha2_256s_sign_verify` | Creates a SLH-DSA-SHA2-256s key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `slh_dsa_shake_128f_sign_verify` | Creates a SLH-DSA-SHAKE-128f key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `slh_dsa_shake_128s_sign_verify` | Creates a SLH-DSA-SHAKE-128s key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `slh_dsa_shake_192f_sign_verify` | Creates a SLH-DSA-SHAKE-192f key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `slh_dsa_shake_192s_sign_verify` | Creates a SLH-DSA-SHAKE-192s key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `slh_dsa_shake_256f_sign_verify` | Creates a SLH-DSA-SHAKE-256f key pair (non-FIPS), signs data, verifies the signature | 3 | +| PQC | `slh_dsa_shake_256s_sign_verify` | Creates a SLH-DSA-SHAKE-256s key pair (non-FIPS), signs data, verifies the signature | 3 | | **KMIP Operations** | | | | -| KMIP Operations | `activate` | Create, Check, Activate, Check, Encrypt, Destroy | 6 | -| KMIP Operations | `attribute_management` | Create, GetAttributes, SetAttribute, AddAttribute, DeleteAttribute, ModifyAttribute, GetAttributeList | 7 | -| KMIP Operations | `certify_validate` | CreateKeyPair, Certify, Validate, Destroy ×3 | 6 | -| KMIP Operations | `certify_revoke_validate` | CreateKeyPair, Certify, Validate, Revoke, Validate (invalid) | 8 | -| KMIP Operations | `certify_chain` | CreateKeyPair, Certify (root→intermediate→leaf), Validate chain | 17 | -| KMIP Operations | `check` | Create, Check, Activate, Check | 4 | -| KMIP Operations | `derive_key_pbkdf2` | Create, DeriveKey (PBKDF2-SHA256), Get | 3 | -| KMIP Operations | `derive_key_pbkdf2_sha512` | Create, DeriveKey (PBKDF2-SHA512), Get | 3 | -| KMIP Operations | `derive_key_hkdf` | Create, DeriveKey (HKDF-SHA256), Get | 3 | -| KMIP Operations | `destroy` | Create, Revoke, Destroy, Get (fail) | 4 | -| KMIP Operations | `discover_versions` | DiscoverVersions | 1 | -| KMIP Operations | `get_attribute_list` | Create, GetAttributeList, Revoke, Destroy | 4 | -| KMIP Operations | `get_attributes` | Create, GetAttributes, Revoke, Destroy | 4 | -| KMIP Operations | `hash_sha256` | Hash (SHA-256) | 2 | -| KMIP Operations | `hash_sha384` | Hash (SHA-384) | 2 | -| KMIP Operations | `hash_sha512` | Hash (SHA-512) | 2 | -| KMIP Operations | `hash_sha3_256` | Hash (SHA3-256) | 2 | -| KMIP Operations | `hash_sha3_384` | Hash (SHA3-384) | 2 | -| KMIP Operations | `hash_sha3_512` | Hash (SHA3-512) | 2 | -| KMIP Operations | `import_key` | Import, Get, Revoke, Destroy | 4 | -| KMIP Operations | `locate` | Create ×2, Locate | 3 | -| KMIP Operations | `locate_by_state` | Create ×2, Activate, Locate (active only) | 4 | -| KMIP Operations | `locate_by_tag` | Create (with vendor tag), Locate (by tag), Destroy | 3 | -| KMIP Operations | `locate_by_usage_mask` | Create (encrypt-only + sign-only), Locate (by usage mask) | 3 | -| KMIP Operations | `mac_and_verify` | Create, MAC, MACVerify, MACVerify (fail) | 4 | -| KMIP Operations | `mac_hmac_sha384` | Create, MAC (HMAC-SHA384) | 2 | -| KMIP Operations | `mac_hmac_sha512` | Create, MAC (HMAC-SHA512) | 2 | -| KMIP Operations | `mac_hmac_sha3_256` | Import, MAC (HMAC-SHA3-256) | 2 | -| KMIP Operations | `opaque_data` | Import, Get, Revoke, Destroy | 4 | -| KMIP Operations | `query` | Query | 1 | -| KMIP Operations | `register_export` | Register, Get, Export, Destroy | 4 | -| KMIP Operations | `rekey` | Create, ReKey, Encrypt | 3 | -| KMIP Operations | `rekey_locate_by_name` | Create (named), Locate, ReKey, Locate (finds new key), GetAttributes (old=Active — ReKey does not deactivate the existing key) | 5 | -| KMIP Operations | `rekey_deactivated_fails` | Create, ReKey, Revoke (old → Deactivated), ReKey (old → fails) | 4 | -| KMIP Operations | `rekey_with_links` | Create, ReKey, GetAttributes (old has ReplacementObjectLink), GetAttributes (new has ReplacedObjectLink) | 4 | -| KMIP Operations | `rekey_with_offset` | Create, ReKey (Offset=3600s), GetAttributes (ActivationDate = now+3600) | 4 | -| KMIP Operations | `rekey_name_removed_from_old` | Create (named), ReKey, GetAttributes (old has no Name) | 4 | -| KMIP Operations | `rekey_double_chain` | Create, ReKey, ReKey, GetAttributes (chain of ReplacementObjectLinks) | 5 | -| KMIP Operations | `rekey_old_key_still_decrypts` | Create, ReKey, Encrypt (old key still works) | 3 | -| KMIP Operations | `rekey_keypair_ec` | CreateKeyPair (EC P-256), ReKeyKeyPair, Revoke+Destroy | 5 | -| KMIP Operations | `rekey_keypair_rsa` | CreateKeyPair (RSA-2048), ReKeyKeyPair, Revoke+Destroy | 5 | -| KMIP Operations | `rekey_keypair_rsa4096` | CreateKeyPair (RSA-4096), ReKeyKeyPair, Revoke+Destroy | 5 | -| KMIP Operations | `rekey_keypair_p384` | CreateKeyPair (EC P-384), ReKeyKeyPair, Revoke+Destroy | 5 | -| KMIP Operations | `rekey_keypair_p521` | CreateKeyPair (EC P-521), ReKeyKeyPair, Revoke+Destroy | 5 | -| KMIP Operations | `rekey_keypair_ml_kem_768` | CreateKeyPair (ML-KEM-768), ReKeyKeyPair, Revoke+Destroy | 5 | -| KMIP Operations | `rekey_keypair_ml_kem_1024` | CreateKeyPair (ML-KEM-1024), ReKeyKeyPair, Revoke+Destroy | 5 | -| KMIP Operations | `rekey_keypair_ml_dsa_65` | CreateKeyPair (ML-DSA-65), ReKeyKeyPair, Revoke+Destroy | 5 | -| KMIP Operations | `rekey_keypair_ml_dsa_87` | CreateKeyPair (ML-DSA-87), ReKeyKeyPair, Revoke+Destroy | 5 | -| KMIP Operations | `rekey_keypair_slh_dsa_sha2_128f` | CreateKeyPair (SLH-DSA-SHA2-128f), ReKeyKeyPair, Revoke+Destroy | 5 | -| KMIP Operations | `rekey_keypair_double_chain` | CreateKeyPair (EC), ReKeyKeyPair ×2, verify link chain | 7 | -| KMIP Operations | `rekey_keypair_deactivated_fails` | CreateKeyPair (EC), Revoke SK, ReKeyKeyPair → fails | 4 | -| KMIP Operations | `rekey_keypair_change_algo_fails` | CreateKeyPair (EC), ReKeyKeyPair (different algo) → fails | 3 | -| KMIP Operations | `rekey_keypair_ec_locate_by_name` | CreateKeyPair (named), ReKeyKeyPair, Locate (finds new key) | 5 | -| KMIP Operations | `rekey_keypair_name_removed_from_old` | CreateKeyPair (named), ReKeyKeyPair, GetAttributes (old has no Name) | 5 | -| KMIP Operations | `rekey_keypair_old_key_still_active` | CreateKeyPair (EC), ReKeyKeyPair, GetAttributes (old SK State=Active) | 5 | -| KMIP Operations | `rekey_keypair_no_public_link_fails` | CreateKeyPair (EC), Delete PublicKeyLink, ReKeyKeyPair → fails | 4 | -| KMIP Operations | `rekey_keypair_with_offset` | CreateKeyPair (EC), ReKeyKeyPair (Offset=3600s), verify ActivationDate | 5 | -| KMIP Operations | `rekey_keypair_ec_with_links` | CreateKeyPair (EC), ReKeyKeyPair, GetAttributes (verify links) | 5 | -| KMIP Operations | `rekey_keypair_rsa_with_links` | CreateKeyPair (RSA), ReKeyKeyPair, GetAttributes (verify links) | 5 | -| KMIP Operations | `rekey_keypair_rsa_encrypt_decrypt` | CreateKeyPair (RSA), ReKeyKeyPair, Encrypt+Decrypt with new key | 7 | -| KMIP Operations | `rekey_keypair_ec_sign_verify` | CreateKeyPair (ECDSA P-256), ReKeyKeyPair, Sign+Verify with new key | 7 | -| KMIP Operations (non-FIPS) | `rekey_keypair_ed25519` | CreateKeyPair (Ed25519), ReKeyKeyPair, Revoke+Destroy | 5 | -| KMIP Operations (non-FIPS) | `rekey_keypair_x25519` | CreateKeyPair (X25519), ReKeyKeyPair, Revoke+Destroy | 5 | -| KMIP Operations (non-FIPS) | `rekey_keypair_secp256k1` | CreateKeyPair (secp256k1), ReKeyKeyPair, Revoke+Destroy | 5 | -| KMIP Operations | `rekey_kmip14` | Create (KMIP 1.4 JSON), ReKey (KMIP 1.4), Encrypt, Destroy ×2 | 7 | -| KMIP Operations | `rekey_keypair_kmip14` | CreateKeyPair (KMIP 1.4 JSON), ReKeyKeyPair (KMIP 1.4), Destroy ×4 | 6 | -| KMIP Operations | `rekey_keypair_kmip14_binary` | CreateKeyPair (KMIP 1.4 binary), ReKeyKeyPair (KMIP 1.4 binary), Destroy ×4 | 6 | -| KMIP Operations | `rng_retrieve` | RNGRetrieve | 1 | -| KMIP Operations | `rng_seed` | RNGSeed | 1 | -| KMIP Operations | `secret_data` | Register, Get, Activate, Revoke, Destroy | 5 | +| KMIP Operations | `activate` | Creates a pre-active key, verifies encrypt fails, activates it, encrypts successfully | 6 | +| KMIP Operations | `attribute_management` | Tests GetAttributes, SetAttribute, AddAttribute, DeleteAttribute, ModifyAttribute, GetAttributeList | 9 | +| KMIP Operations | `batch_create_get` | Sends a single JSON RequestMessage with BatchCount=2: first BatchItem creates an AES-256 key, second BatchItem gets it back. Both items must succeed. Exercises the JSON batch endpoint. | 2 | +| KMIP Operations | `batch_hash_query` | Sends a single binary KMIP RequestMessage with BatchCount=3: Hash(SHA-256), Hash(SHA-384), and Query in one binary request. All three BatchItems must succeed. Exercises the binary batch endpoint with BatchOrderOption=true and stateless independent operations. | 1 | +| KMIP Operations | `certify_chain` | Creates a 3-level X.509 chain: | 18 | +| KMIP Operations | `certify_revoke_validate` | Creates a self-signed certificate, validates it (valid), revokes it, then re-validates (invalid) | 8 | +| KMIP Operations | `certify_validate` | Creates an EC key pair, self-signs a certificate, validates it, then cleans up | 6 | +| KMIP Operations | `check` | Creates a key, checks its usage mask, activates it, checks again | 4 | +| KMIP Operations | `crl_validation_lifecycle` | Full CRL validation: create CA+EE cert chain, generate empty CRL (valid), revoke EE cert, regenerate CRL, validate again (invalid due to revocation in CRL) | 16 | +| KMIP Operations | `derive_key_hkdf` | Creates a base symmetric key, derives a new AES-128 key using HKDF-SHA256 | 3 | +| KMIP Operations | `derive_key_pbkdf2` | Creates a base symmetric key, derives a new AES-128 key using PBKDF2-SHA256, retrieves the derived key | 3 | +| KMIP Operations | `derive_key_pbkdf2_sha512` | Creates a base symmetric key, derives a new AES-128 key using PBKDF2-SHA512 | 3 | +| KMIP Operations | `destroy` | Creates a symmetric key, destroys it, then verifies Get fails | 4 | +| KMIP Operations | `discover_versions` | Queries supported KMIP protocol versions from the server | 1 | +| KMIP Operations | `get_attribute_list` | Creates a key and retrieves the list of its attribute names | 4 | +| KMIP Operations | `get_attributes` | Creates a symmetric key, retrieves its attributes, then cleans up | 4 | +| KMIP Operations | `hash_sha256` | Computes a SHA-256 hash of data | 1 | +| KMIP Operations | `hash_sha384` | Computes a SHA-384 hash of data | 1 | +| KMIP Operations | `hash_sha3_256` | Computes a SHA3_256 hash of data | 1 | +| KMIP Operations | `hash_sha3_384` | Computes a SHA3_384 hash of data | 1 | +| KMIP Operations | `hash_sha3_512` | Computes a SHA3_512 hash of data | 1 | +| KMIP Operations | `hash_sha512` | Computes a SHA512 hash of data | 1 | +| KMIP Operations | `import_key` | Imports an AES-256 key with explicit UID, gets it, revokes, and destroys | 4 | +| KMIP Operations | `locate` | Creates two AES keys with distinct names, then locates them by ObjectType and Name | 3 | +| KMIP Operations | `locate_by_state` | Creates two keys (one activated, one pre-active), locates only the active one by state filter | 4 | +| KMIP Operations | `locate_by_tag` | Creates a key with a Cosmian vendor tag, then locates it using the tag filter | 3 | +| KMIP Operations | `locate_by_usage_mask` | Creates keys with different usage masks (encrypt-only vs sign-only), locates by CryptographicUsageMask | 3 | +| KMIP Operations | `mac_and_verify` | Creates an HMAC key, computes a MAC, verifies it, then verifies an invalid MAC fails | 6 | +| KMIP Operations | `mac_hmac_sha384` | Creates an HMACSHA384 HMAC key, computes a MAC over data | 2 | +| KMIP Operations | `mac_hmac_sha3_256` | Imports an HMACSHA3256 HMAC key, computes a MAC over data | 2 | +| KMIP Operations | `mac_hmac_sha512` | Creates an HMACSHA512 HMAC key, computes a MAC over data | 2 | +| KMIP Operations | `opaque_data` | Imports opaque data, retrieves it, then destroys | 4 | +| KMIP Operations | `process_window_encrypt_expired_fails` | Creates an Active symmetric key, then sets its ProtectStopDate to a date in the past via SetAttribute. An Encrypt attempt must be rejected with Wrong_Key_Lifecycle_State even though the key state is still Active. | 5 | +| KMIP Operations | `process_window_encrypt_not_yet_active_fails` | Creates an Active symmetric key, then sets its ProcessStartDate to a date far in the future via SetAttribute. An Encrypt attempt must be rejected with Wrong_Key_Lifecycle_State even though the key state is still Active. | 5 | +| KMIP Operations | `query` | Queries server information, supported operations, and supported object types | 1 | +| KMIP Operations | `recertify_chain` | Creates a root CA (self-signed) and a leaf certificate signed by the root, then performs ReCertify on the leaf certificate. Verifies the new leaf cert has proper replacement links and Active state. | 16 | +| KMIP Operations | `recertify_old_cert_stays_active` | After ReCertify, the old certificate transitions to Deactivated state per KMIP §4.57 transition 6 (same path as ReKey). The new certificate is Active. | 11 | +| KMIP Operations | `recertify_self_signed` | Creates a self-signed certificate, performs ReCertify to rotate it, and verifies the new certificate has a fresh UID and Active state. | 10 | +| KMIP Operations | `recertify_with_links` | Verifies that ReCertify properly sets ReplacementObjectLink on the old certificate and ReplacedObjectLink on the new certificate, forming a bidirectional chain. Also verifies that the new certificate is in Active state. | 11 | +| KMIP Operations | `recertify_with_offset` | Verifies that ReCertify with Offset=0 produces an Active certificate, and ReCertify with Offset=86400 (24h future) produces a PreActive certificate. | 19 | +| KMIP Operations | `register_export` | Registers a pre-existing AES key, retrieves it with Get, exports it, then destroys | 5 | +| KMIP Operations | `rekey` | Creates an AES key, re-keys it, and verifies the new key works for encryption | 5 | +| KMIP Operations | `rekey_compromised_succeeds` | Verifies that ReKey on a Compromised key succeeds. Per the spec, Active, Deactivated, and Compromised keys can be rotated. Only PreActive and Destroyed states are rejected. | 8 | +| KMIP Operations | `rekey_deactivated_fails` | Verifies that ReKey on a Destroyed symmetric key fails with Wrong_Key_Lifecycle_State. | 4 | +| KMIP Operations | `rekey_deactivated_succeeds` | Verifies that ReKey on a Deactivated key succeeds. Per KMIP §6.1.46, Wrong_Key_Lifecycle_State is NOT listed in the Re-Key error table, meaning Deactivated keys are eligible for rotation. | 9 | +| KMIP Operations | `rekey_double_chain` | Verifies that re-keying twice creates a proper chain: K1→K2→K3. K1.ReplacementObjectLink=K2, K2.ReplacedObjectLink=K1, K2.ReplacementObjectLink=K3, K3.ReplacedObjectLink=K2. | 12 | +| KMIP Operations | `rekey_keypair_change_algo_fails` | Verifies that ReKeyKeyPair rejects a request that tries to change the cryptographic algorithm (from EC to RSA). | 4 | +| KMIP Operations | `rekey_keypair_deactivated_fails` | Verifies that ReKeyKeyPair on a Destroyed private key fails. | 6 | +| KMIP Operations | `rekey_keypair_deactivated_succeeds` | Verifies that ReKeyKeyPair on a revoked/deactivated private key succeeds. Per KMIP §6.1.47, Wrong_Key_Lifecycle_State is NOT listed in the Re-Key Key Pair error table, meaning Deactivated keys are eligible for rotation. | 10 | +| KMIP Operations | `rekey_keypair_double_chain` | Verifies that re-keying a key pair twice creates a proper chain. KP1 -> KP2 -> KP3 with correct link attributes. | 12 | +| KMIP Operations | `rekey_keypair_ec` | Verifies that ReKeyKeyPair succeeds for an EC P-256 key pair, returning new private and public key UIDs. | 7 | +| KMIP Operations | `rekey_keypair_ec_locate_by_name` | Verifies that after ReKeyKeyPair, the replacement private key inherits the Name attribute and can be found via Locate by name. | 8 | +| KMIP Operations | `rekey_keypair_ec_sign_verify` | Verifies that after ReKeyKeyPair, the new private key can sign and the new public key can verify the signature. | 8 | +| KMIP Operations | `rekey_keypair_ec_with_links` | Verifies that ReKeyKeyPair on an EC P-256 key pair properly sets ReplacementObjectLink on both old keys and ReplacedObjectLink on both new keys. | 10 | +| KMIP Operations | `rekey_keypair_kmip14` | Exercises the ReKeyKeyPair operation through the KMIP 1.4 protocol path, verifying that the V14→V21 request conversion (PrivateKeyUniqueIdentifier as required String, CommonTemplateAttribute→CommonAttributes) and V21→V14 response conversion (UniqueIdentifier→String) work correctly. This test was previously impossible because the ReKeyKeyPair V14↔V21 conversion was not implemented. | 6 | +| KMIP Operations | `rekey_keypair_kmip14_binary` | Exercises ReKeyKeyPair through the binary TTLV wire format with KMIP 1.4 protocol version. This mimics how real clients (VAST Data, Synology, FortiGate, etc.) communicate with the KMS — sending binary TTLV over HTTP. Verifies the full path: JSON→TTLV binary serialization→server parse→V14→V21 conversion→operation→V21→V14 response conversion→binary serialization→JSON assertion. | 6 | +| KMIP Operations | `rekey_keypair_ml_dsa_44` | Verifies that ReKeyKeyPair succeeds for ML-DSA-44, completing the ML-DSA trilogy (44/65/87). | 10 | +| KMIP Operations | `rekey_keypair_ml_dsa_65` | Verifies that ReKeyKeyPair succeeds for ML-DSA-65. | 6 | +| KMIP Operations | `rekey_keypair_ml_dsa_87` | Verifies that ReKeyKeyPair succeeds for ML-DSA-87. | 6 | +| KMIP Operations | `rekey_keypair_ml_kem_1024` | Verifies that ReKeyKeyPair succeeds for ML-KEM-1024. | 6 | +| KMIP Operations | `rekey_keypair_ml_kem_512` | Verifies that ReKeyKeyPair succeeds for ML-KEM-512, completing the ML-KEM trilogy (512/768/1024). | 10 | +| KMIP Operations | `rekey_keypair_ml_kem_768` | Verifies that ReKeyKeyPair succeeds for ML-KEM-768. | 6 | +| KMIP Operations | `rekey_keypair_name_removed_from_old` | Verifies that after ReKeyKeyPair, the old private key no longer has the Name attribute. | 7 | +| KMIP Operations | `rekey_keypair_no_public_link_fails` | Verifies that ReKeyKeyPair fails when the private key has no PublicKeyLink. | 5 | +| KMIP Operations | `rekey_keypair_old_key_still_active` | Verifies that after ReKeyKeyPair, the old private key transitions to Deactivated | 7 | +| KMIP Operations | `rekey_keypair_p384` | Verifies that ReKeyKeyPair succeeds for this key type. | 6 | +| KMIP Operations | `rekey_keypair_p521` | Verifies that ReKeyKeyPair succeeds for this key type. | 6 | +| KMIP Operations | `rekey_keypair_rsa` | Verifies that ReKeyKeyPair succeeds for an RSA-2048 key pair, returning new private and public key UIDs. | 7 | +| KMIP Operations | `rekey_keypair_rsa4096` | Verifies that ReKeyKeyPair succeeds for this key type. | 6 | +| KMIP Operations | `rekey_keypair_rsa_encrypt_decrypt` | Verifies that after ReKeyKeyPair, the new public key can encrypt and the new private key can decrypt. | 8 | +| KMIP Operations | `rekey_keypair_rsa_old_decrypts` | After ReKeyKeyPair, the old private key is Deactivated but can still decrypt ciphertext encrypted with the old public key. Processing operations (Decrypt) accept Deactivated state per KMIP §3.31. | 8 | +| KMIP Operations | `rekey_keypair_rsa_sign_verify` | Verifies that after ReKeyKeyPair on an RSA-2048 key pair, the new private key can sign and the new public key can verify the signature (RSA-PSS SHA-256). | 12 | +| KMIP Operations | `rekey_keypair_rsa_with_links` | Verifies that ReKeyKeyPair on an RSA-2048 key pair properly sets ReplacementObjectLink and ReplacedObjectLink. | 8 | +| KMIP Operations | `rekey_keypair_slh_dsa_sha2_128f` | Verifies that ReKeyKeyPair succeeds for SLH-DSA-SHA2-128F. | 6 | +| KMIP Operations | `rekey_keypair_with_offset` | Verifies that ReKeyKeyPair with an Offset parameter correctly applies date computation on the replacement key pair. | 7 | +| KMIP Operations | `rekey_keypair_with_offset_state` | Verifies that ReKeyKeyPair with Offset=0 produces Active keys, and ReKeyKeyPair with Offset=86400 (24h future) produces PreActive keys. | 20 | +| KMIP Operations | `rekey_kmip14` | Exercises the ReKey operation through the KMIP 1.4 protocol path, verifying that the V14→V21 request conversion and V21→V14 response conversion work correctly. KMIP 1.4 uses TemplateAttribute containers and a required (non-optional) UniqueIdentifier. The ReKey response must return a new UniqueIdentifier as a plain String (not wrapped in UniqueIdentifier enum). | 7 | +| KMIP Operations | `rekey_locate_by_name` | Verifies that after ReKey, the replacement key inherits the Name attribute and can be found via Locate by name. This is the critical behavior for VAST Data and similar EKM integrations that poll by name after rotation. | 9 | +| KMIP Operations | `rekey_mac_keyset` | Complex MAC key rotation test: | 10 | +| KMIP Operations | `rekey_manual_clears_interval` | After a manual ReKey, x-rotate-interval must be set to 0 on the new key. This forces the operator to re-arm the rotation policy explicitly, preventing accidental automatic rotation of the new key. | 8 | +| KMIP Operations | `rekey_manual_clears_offset` | After a manual ReKey, x-rotate-offset must NOT be inherited by the new key. The spec states the value is 'None (not inherited for manual rekey)'. | 8 | +| KMIP Operations | `rekey_name_removed_from_old` | Verifies that after ReKey, the old key no longer has the Name attribute (it was transferred to the replacement key). | 7 | +| KMIP Operations | `rekey_old_key_decrypt_succeeds` | After ReKey, the old key is Deactivated. Per KMIP §3.31 the old key can still be used for Decrypt (processing operations accept Deactivated state). This test encrypts before rotation, then decrypts with the OLD key UID after rotation. | 8 | +| KMIP Operations | `rekey_old_key_still_decrypts` | Verifies that after ReKey, the old key is Deactivated (KMIP §4.57 transition 6) and can no longer be used for encryption. | 7 | +| KMIP Operations | `rekey_with_links` | Verifies that ReKey properly sets ReplacementObjectLink on the old key and ReplacedObjectLink on the new key, forming a bidirectional chain. | 8 | +| KMIP Operations | `rekey_with_offset` | Verifies that ReKey with an Offset parameter correctly computes the replacement key's Activation Date as InitializationDate + Offset. | 7 | +| KMIP Operations | `rekey_with_offset_state` | Verifies that ReKey with Offset=0 produces an Active key, and ReKey with Offset=86400 (24h future) produces a PreActive key. | 13 | +| KMIP Operations | `rekey_wrapped_deactivated_succeeds` | Creates a wrapping key and a wrapped dependent key, verifies wrapping, revokes the dependent, then verifies that ReKey on the deactivated wrapped key succeeds per KMIP §6.1.46. | 12 | +| KMIP Operations | `rekey_wrapped_key` | Creates a wrapping key and a wrapped dependent key, then re-keys the wrapped key. Verifies the new key has fresh material, is still wrapped, and works for encryption. | 13 | +| KMIP Operations | `rekey_wrapping_key` | Creates a wrapping key, creates a dependent key wrapped by it, then re-keys the wrapping key and verifies the dependent key was automatically re-wrapped and still works for encryption. | 13 | +| KMIP Operations | `rekey_wrapping_key_double_chain` | Creates a wrapping key K0 with two wrapped dependants. Rotates K0 → K1, then K1 → K2. Verifies the full link chain (K0 → K1 → K2) and that both dependants are re-wrapped each time and still work for encryption. | 24 | +| KMIP Operations | `rekey_wrapping_key_with_links` | Creates a wrapping key and two dependent wrapped keys. Re-keys the wrapping key and verifies: (1) dependants are actually wrapped, (2) bidirectional replacement links on the wrapping keys, (3) both dependants are re-wrapped and still work for encryption. | 18 | +| KMIP Operations | `rng_retrieve` | Retrieves 32 random bytes from the server RNG | 1 | +| KMIP Operations | `rng_seed` | Seeds the server RNG with entropy and verifies the response | 1 | +| KMIP Operations | `secret_data` | Registers a password as SecretData, retrieves it with Get, then destroys | 5 | +| KMIP Operations (non-FIPS) | `non-fips/rekey_keypair_covercrypt` | Verifies that ReKeyKeyPair on a Covercrypt master secret key with a RekeyAccessPolicy action performs an in-place attribute-level rekey, returning the same UIDs (no new key pair is created). | 6 | +| KMIP Operations (non-FIPS) | `non-fips/rekey_keypair_ed25519` | Verifies that ReKeyKeyPair succeeds for ed25519. | 6 | +| KMIP Operations (non-FIPS) | `non-fips/rekey_keypair_secp256k1` | Verifies that ReKeyKeyPair succeeds for secp256k1. | 6 | +| KMIP Operations (non-FIPS) | `non-fips/rekey_keypair_x25519` | Verifies that ReKeyKeyPair succeeds for x25519. | 6 | +| **Serialization** | | | | +| Serialization | `attributes_preservation` | Creates an AES key with multiple attributes (name, algorithm, length, usage mask), retrieves it with Get, and verifies all attributes are preserved through DB serialization | 3 | +| Serialization | `create_encrypt_decrypt_roundtrip` | Creates an AES-256 key, encrypts data, then decrypts — verifies key material survives DB serialization through KMIP3: prefixed object storage | 3 | +| Serialization | `create_locate_roundtrip` | Creates an AES key with a unique name, then Locates it by name — verifies attributes survive DB serialization (kmip_3_0 JSON format) and json_extract queries work | 2 | +| Serialization | `import_destroy_reimport` | Imports a key with explicit UID, destroys it, then re-imports with the same UID — verifies lifecycle state transitions work correctly with the new serialization format | 6 | +| Serialization | `rsa_sign_verify_roundtrip` | Creates an RSA-2048 key pair, signs data with private key, verifies with public key — verifies asymmetric key material and attributes survive DB serialization | 3 | +| **K8s Plugin** | | | | +| K8s Plugin | `dek_wrap_unwrap` | Simulates the exact sequence performed by cosmian-kms-plugin when kube-apiserver | 5 | | **Access Control** | | | | -| Access Control | `revoke_key_lifecycle` | Create, Revoke, Encrypt (fail — revoked) | 3 | -| Access Control | `grant_access_aes` | Create, GrantAccess, Get (user), Encrypt (user), Decrypt (user) | 5 | -| Access Control | `revoke_access` | Create, GrantAccess, Get (user ok), RevokeAccess, Get (user fail) | 5 | -| Access Control | `unauthorized_access` | Create, Get (user fail — no grant) | 2 | -| Access Control | `owner_full_access` | Create, Get (owner), Encrypt (owner), Decrypt (owner) | 4 | -| Access Control | `grant_partial_permissions` | Create, GrantAccess (Get only), Get (user ok), Encrypt (user ok — Get is wildcard for crypto) | 4 | -| Access Control | `privilege_escalation_self_grant` | Create, GrantAccess (owner → self) → denied | 2 | -| Access Control | `privilege_escalation_non_owner_grant` | Create, GrantAccess by user (not owner) → denied ×2 | 3 | -| Access Control | `privilege_escalation_destroy_without_permission` | Create, GrantAccess (Get only), Get (ok), Destroy (denied — Get not wildcard for lifecycle ops), Get (still exists) | 5 | -| Access Control | `privilege_escalation_rekey_without_permission` | Create, GrantAccess (Get only), Get (ok), ReKey (denied — Get not wildcard for ReKey), Get (still exists) | 5 | -| Access Control | `privilege_escalation_activate_without_permission` | Create (PreActive), GrantAccess (Encrypt only), Activate (denied — Encrypt does not imply Activate), Activate (owner ok) | 4 | +| Access Control | `grant_access_aes` | Owner creates AES key, grants user access, user can Get/Encrypt/Decrypt, owner destroys key | 7 | +| Access Control | `grant_partial_permissions` | Owner grants only Get; user Get succeeds and Encrypt is denied | 6 | +| Access Control | `owner_full_permissions` | Owner performs Get/Encrypt/Decrypt/Revoke/Destroy without grants | 6 | +| Access Control | `privilege_escalation_activate_without_permission` | Owner creates a PreActive AES key, grants user only Encrypt. User's Activate attempt is denied because Encrypt grant does not imply Activate permission. | 6 | +| Access Control | `privilege_escalation_destroy_without_permission` | Owner creates AES key, grants user only Get. Get acts as wildcard for crypto ops but NOT for Destroy — user's Destroy attempt is denied. | 7 | +| Access Control | `privilege_escalation_non_owner_grant` | Owner creates AES key, user (non-owner) attempts to grant themselves access — must be denied because user does not own the key | 5 | +| Access Control | `privilege_escalation_rekey_without_permission` | Owner creates AES key, grants user only Get. User's ReKey attempt is denied because Get wildcard does NOT apply to lifecycle-mutating operations like ReKey. | 7 | +| Access Control | `privilege_escalation_self_grant` | Owner creates AES key, then attempts to grant themselves additional permissions — which must be denied | 4 | +| Access Control | `revoke_access` | Owner grants user Get, revokes it, user can no longer Get | 7 | +| Access Control | `revoke_key_lifecycle` | Creates a symmetric key, revokes it, then verifies it cannot be used for encryption | 3 | +| Access Control | `unauthorized_access` | Owner creates AES key and ungranted user cannot Get it | 4 | | **HSM (requires SoftHSM2 + `HSM_SLOT_ID`)** | | | | -| HSM / KEK | `hsm/kek_encrypt_decrypt` | Create (HSM+KEK), Encrypt, Decrypt, Destroy | 4 | -| HSM / KEK | `hsm/kek_sign_verify` | CreateKeyPair (HSM+KEK RSA), Sign, SignatureVerify, Destroy ×2 | 5 | -| HSM / KEK Create | `hsm/kek_aes256_create_encrypt` | Create (AES-256, KEK-wrapped), Encrypt, Decrypt, Destroy | 3 | -| HSM / KEK Create | `hsm/kek_rsa2048_create_sign` | CreateKeyPair (RSA-2048, KEK-wrapped), Sign, Destroy ×2 | 3 | -| HSM / KEK Create | `hsm/kek_ec_p256_create_sign` | CreateKeyPair (EC P-256, KEK-wrapped), Sign, Destroy ×2 | 3 | -| HSM / KEK Create | `hsm/kek_ed25519_create_sign` | CreateKeyPair (Ed25519, KEK-wrapped), Sign, Destroy ×2 | 3 | -| HSM / KEK Negative | `hsm/kek_rsa1024_rejected` | CreateKeyPair (RSA-1024, KEK-wrapped) → FIPS rejection | 1 | -| HSM / Resident Create | `hsm/resident_aes128_create_encrypt` | Create (AES-128, HSM-resident), Encrypt, Decrypt, Destroy | 4 | -| HSM / Resident Create | `hsm/resident_aes256_create_encrypt` | Create (AES-256, HSM-resident), Encrypt, Decrypt, Destroy | 4 | -| HSM / Resident Create | `hsm/resident_rsa4096_create_sign` | CreateKeyPair (RSA-4096, HSM-resident), Sign, Destroy ×2 | 4 | -| HSM / Resident Encrypt | `hsm/resident_aes256_encrypt_cbc` | Create (AES-256, HSM), Encrypt (AES-CBC), Decrypt, Destroy | 4 | -| HSM / Resident Encrypt | `hsm/resident_rsa2048_encrypt_oaep_sha256` | CreateKeyPair (RSA-2048, HSM), Encrypt (OAEP-SHA256), Decrypt, Destroy ×2 | 5 | -| HSM / Resident Encrypt | `hsm/resident_rsa2048_encrypt_oaep_sha1` | CreateKeyPair (RSA-2048, HSM), Encrypt (OAEP-SHA1), Decrypt, Destroy ×2 | 5 | -| HSM / Resident Encrypt | `hsm/resident_rsa2048_encrypt_pkcs1v15` | CreateKeyPair (RSA-2048, HSM), Encrypt (PKCS#1 v1.5), Decrypt, Destroy ×2 | 5 | -| HSM / Resident Sign | `hsm/resident_rsa2048_sign_pkcs1v15` | CreateKeyPair (RSA-2048, HSM), Sign (raw PKCS#1 v1.5), Destroy ×2 | 4 | -| HSM / Resident Sign | `hsm/resident_rsa2048_sign_sha1` | CreateKeyPair (RSA-2048, HSM), Sign (SHA1WithRSA), Destroy ×2 | 4 | -| HSM / Resident Sign | `hsm/resident_rsa2048_sign_sha256` | CreateKeyPair (RSA-2048, HSM), Sign (SHA256WithRSA), Destroy ×2 | 4 | -| HSM / Resident Sign | `hsm/resident_rsa2048_sign_sha384` | CreateKeyPair (RSA-2048, HSM), Sign (SHA384WithRSA), Destroy ×2 | 4 | -| HSM / Resident Sign | `hsm/resident_rsa2048_sign_sha512` | CreateKeyPair (RSA-2048, HSM), Sign (SHA512WithRSA), Destroy ×2 | 4 | -| HSM / Resident Negative | `hsm/resident_rsa1024_rejected` | CreateKeyPair (RSA-1024, HSM-resident) → FIPS rejection | 1 | -| HSM / Resident Negative | `hsm/resident_ec_p256_rejected` | CreateKeyPair (EC P-256, HSM-resident) → unsupported key type | 1 | -| HSM / Resident Negative | `hsm/resident_ec_p384_rejected` | CreateKeyPair (EC P-384, HSM-resident) → unsupported key type | 1 | -| HSM / Resident Negative | `hsm/resident_ed25519_rejected` | CreateKeyPair (Ed25519, HSM-resident) → unsupported key type | 1 | -| HSM / Resident Negative | `hsm/resident_non_aes_rejected` | Create (3DES, HSM-resident) → only AES allowed | 1 | -| HSM / Resident Negative | `hsm/resident_aes256_encrypt_ecb_rejected` | Create (AES-256, HSM), Encrypt (ECB) → unsupported mode | 3 | -| HSM / Resident Negative | `hsm/resident_rsa2048_sign_ecdsa_rejected` | CreateKeyPair (RSA-2048, HSM), Sign (ECDSAWithSHA256) → unsupported algorithm | 2 | -| HSM / Resident Negative | `hsm/resident_rsa2048_sign_dsa_rejected` | CreateKeyPair (RSA-2048, HSM), Sign (DSAWithSHA256) → unsupported algorithm | 2 | -| HSM / Negative | `hsm/wrong_prefix` | Create (bad prefix) → error | 1 | -| HSM / Negative | `hsm/no_kek_baseline` | Create (AES, no HSM prefix), Encrypt, Decrypt, Destroy | 4 | -| HSM / Permissions | `hsm/permissions/admin_create_encrypt_destroy` | Create (admin), Encrypt, Decrypt, Destroy | 4 | -| HSM / Permissions | `hsm/permissions/admin_grant_encrypt_decrypt` | Create, GrantAccess (Encrypt+Decrypt), user Encrypt, user Decrypt, Destroy | 5 | -| HSM / Permissions | `hsm/permissions/get_not_wildcard` | Create, GrantAccess (Get only), user Get (ok), user Encrypt (fail), Destroy | 5 | -| HSM / Permissions | `hsm/permissions/admin_grant_revoke` | Create, Grant Encrypt, user Encrypt (ok), Revoke, user Encrypt (fail), Destroy | 6 | -| HSM / Permissions | `hsm/permissions/user_cannot_create` | user Create → error (non-admin denied) | 1 | -| HSM / Permissions | `hsm/permissions/user_cannot_destroy` | Create (admin), user Destroy → error, admin Destroy | 3 | -| HSM / Permissions | `hsm/permissions/user_cannot_encrypt` | Create (admin), user Encrypt → error (not found), Destroy | 3 | -| HSM / Permissions | `hsm/permissions/user_cannot_grant` | Create (admin), user GrantAccess → error (not owner), Destroy | 3 | -| HSM / Permissions | `hsm/permissions/cannot_grant_destroy` | Create (admin), admin GrantAccess (Destroy) → error (reserved), Destroy | 3 | -| HSM / Permissions | `hsm/permissions/locate_visibility` | Create ×2, Grant user key1, admin Locate (sees both), user Locate (sees only key1), Destroy ×2 | 7 | +| HSM / KEK Baseline | `hsm/hsm_resident_encrypt` | Creates a new AES-256 key on a KMS server with SoftHSM2 KEK enabled. | 3 | +| HSM / KEK Baseline | `hsm/hsm_resident_sign` | Creates an EC P-256 key pair on a KMS server with SoftHSM2 KEK enabled. | 2 | +| HSM / KEK Create | `hsm/kek_aes256_create_encrypt` | Creates a new AES-256 key on a KMS server with SoftHSM2 KEK enabled. | 3 | +| HSM / KEK Bootstrap | `hsm/kek_bootstrap_self_create` | Regression test for the self-wrap bug introduced by PR #968. | 6 | +| HSM / KEK Create | `hsm/kek_ec_p256_create_sign` | Creates an EC P-256 keypair on a KMS server with SoftHSM2 KEK enabled. | 2 | +| HSM / KEK Create | `hsm/kek_ed25519_create_sign` | Creates an Ed25519 keypair on a KMS server with SoftHSM2 KEK enabled. | 2 | +| HSM / KEK | `hsm/kek_encrypt_decrypt` | Imports an AES-256 key into a KMS server backed by a SoftHSM2 KEK. | 3 | +| HSM / KEK ReKey | `hsm/kek_rekey_kek` | Creates a dedicated HSM KEK ('hsm::::vec_kek_rekey') and a DB AES DEK that is explicitly wrapped at rest by that KEK. Re-keys the KEK itself — the HSM rotation generates new key material and a new UID, then automatically re-wraps all dependent DB keys (rewrap_dependants). Verifies via GetAttributes that the DEK's WrappingKeyLink now points to the new KEK UID, and confirms encrypt still works after the rotation. Fully cleans up (DEK + both KEK generations) for idempotent reruns. | 12 | +| HSM / KEK ReKey | `hsm/kek_rekey_wrapped` | Creates an AES-256 key in a KMS server backed by a SoftHSM2 KEK. The key is auto-wrapped by the HSM-resident KEK at rest. Re-keys the wrapped key (unwrap from KEK, generate new material, re-wrap). Verifies the new key works for encryption. | 9 | +| HSM / KEK Negative | `hsm/kek_rsa1024_rejected` | Attempts to create an RSA-1024 keypair on a server with KEK enabled. | 1 | +| HSM / KEK Create | `hsm/kek_rsa2048_create_sign` | Creates an RSA-2048 keypair on a KMS server with SoftHSM2 KEK enabled. | 2 | +| HSM / KEK Create | `hsm/kek_sign_verify` | Imports an Ed25519 private key into a KMS server backed by a SoftHSM2 KEK. | 2 | +| HSM / Negative | `hsm/no_kek_baseline` | Imports the same AES-256 key as kek_encrypt_decrypt scenario but on a plain SQLite | 3 | +| HSM / Permissions | `hsm/permissions/admin_create_encrypt_destroy` | HSM admin () creates an AES-256 key directly in the HSM, | 5 | +| HSM / Permissions | `hsm/permissions/admin_grant_encrypt_decrypt` | HSM admin creates an AES-256 key in the HSM, grants Encrypt and Decrypt | 6 | +| HSM / Permissions | `hsm/permissions/admin_grant_revoke` | HSM admin creates an AES key, grants Encrypt to user, user can encrypt, | 7 | +| HSM / Permissions | `hsm/permissions/cannot_grant_destroy` | HSM admin creates an AES key in the HSM, then attempts to grant Destroy to | 4 | +| HSM / Permissions | `hsm/permissions/get_not_wildcard` | HSM admin creates an AES-256 key in the HSM, grants only Get to | 6 | +| HSM / Permissions | `hsm/permissions/locate_visibility` | HSM admin creates two AES keys in the HSM. Grants user Encrypt on only the | 10 | +| HSM / Permissions | `hsm/permissions/user_cannot_create` | Non-admin user () attempts to create an AES key directly | 1 | +| HSM / Permissions | `hsm/permissions/user_cannot_destroy` | HSM admin creates an AES key, then non-admin user () | 4 | +| HSM / Permissions | `hsm/permissions/user_cannot_encrypt` | HSM admin creates an AES key in the HSM. Non-admin user () | 4 | +| HSM / Permissions | `hsm/permissions/user_cannot_grant` | HSM admin creates an AES key in the HSM. Non-admin user () | 4 | +| HSM / Resident Encrypt | `hsm/resident_aes128_create_encrypt` | Creates an AES-128 key directly on the HSM (key material lives in the HSM token). | 5 | +| HSM / Resident Encrypt | `hsm/resident_aes256_create_encrypt` | Creates an AES-256 key directly on the HSM (key material lives in the HSM token). | 5 | +| HSM / Resident Encrypt | `hsm/resident_aes256_encrypt_cbc` | Creates an AES-256 key on the HSM, then encrypts and decrypts with AES-CBC mode. | 5 | +| HSM / Resident Negative | `hsm/resident_aes256_encrypt_ecb_rejected` | Creates an AES-256 key on the HSM, then attempts to encrypt with ECB mode. | 4 | +| HSM / Resident Negative | `hsm/resident_ec_p256_rejected` | Attempts to create an EC P-256 keypair with an HSM-resident UID. | 1 | +| HSM / Resident Negative | `hsm/resident_ec_p384_rejected` | Attempts to create an EC P-384 keypair with an HSM-resident UID. | 1 | +| HSM / Resident Negative | `hsm/resident_ed25519_rejected` | Attempts to create an Ed25519 keypair with an HSM-resident UID. | 1 | +| HSM / Resident Keyset | `hsm/resident_keyset_double_rotation` | Tests HSM keyset traversal across a 3-generation chain: | 12 | +| HSM / Resident Keyset | `hsm/resident_keyset_full_lifecycle` | Full HSM keyset lifecycle test covering all three decrypt-addressing variants: | 12 | +| HSM / Resident Keyset | `hsm/resident_keyset_no_kek_addressing` | Creates an HSM-resident key, assigns a keyset name (rotate_name = "ks-addr"), | 15 | +| HSM / Resident Keyset | `hsm/resident_keyset_no_kek_basic` | Creates an HSM-resident AES-256 key without a keyset (no rotate_name), encrypts | 9 | +| HSM / Resident Keyset | `hsm/resident_keyset_no_kek_consecutive` | Creates gen-0 key, rekeys twice (gen-1, gen-2), encrypts once per generation, | 17 | +| HSM / Resident Keyset | `hsm/resident_keyset_no_kek_duplicate_rekey` | Creates an HSM-resident key WITHOUT a keyset name (no rotate_name). Re-keys it | 10 | +| HSM / Resident Keyset | `hsm/resident_keyset_no_kek_encrypt_gen_select` | Encrypts with the keyset base UID before rotation (targets gen-0, the only/latest key) | 15 | +| HSM / Resident Keyset | `hsm/resident_keyset_no_kek_rekey_by_hsm_uid` | Verifies that an HSM-resident keyset can be rotated 3 consecutive times using the | 13 | +| HSM / Resident Keyset | `hsm/resident_keyset_no_kek_rekey_by_keyset_name` | Verifies that an HSM-resident keyset can be rotated 3 consecutive times using the | 13 | +| HSM / Resident Keyset | `hsm/resident_keyset_no_kek_rekey_by_name` | Verifies that ReKey can be addressed via the keyset name (not just the direct UID). | 10 | +| HSM / Resident Keyset | `hsm/resident_keyset_no_kek_rekey_non_latest` | Verifies that re-keying a non-latest keyset member is transparently redirected to | 13 | +| HSM / Resident Keyset | `hsm/resident_keyset_no_kek_uid_lifecycle` | Creates a resident AES-256 HSM key, assigns a rotate_name, rekeys the latest | 18 | +| HSM / Resident Keyset | `hsm/resident_keyset_rekey_and_decrypt` | Full HSM keyset rotation test: | 9 | +| HSM / Resident Negative | `hsm/resident_keyset_rotate_name_bare_rejected` | For HSM keys, the keyset name (rotate_name) must be the key's full base UID (hsm::::::). A bare name without the hsm:: prefix is rejected because it would be ambiguous across HSM slots. | 4 | +| HSM / Resident Negative | `hsm/resident_keyset_rotate_name_gen_suffix_rejected` | For HSM keys, the keyset name must be the base UID without any @N generation suffix. Setting rotate_name to 'hsm::slot::key@1' must be rejected. | 4 | +| HSM / Resident Keyset | `hsm/resident_keyset_set_rotate_name` | Creates an AES-256 key directly on the HSM, assigns a rotate_name via SetAttribute | 6 | +| HSM / Resident Negative | `hsm/resident_non_aes_rejected` | Attempts to create a 3DES symmetric key directly on the HSM. | 1 | +| HSM / Resident Negative | `hsm/resident_rsa1024_rejected` | Attempts to create an RSA-1024 keypair with an HSM-resident UID. | 1 | +| HSM / Resident Encrypt | `hsm/resident_rsa2048_encrypt_oaep_sha1` | Creates an RSA-2048 keypair on the HSM, then encrypts with RSA-OAEP-SHA1 | 7 | +| HSM / Resident Encrypt | `hsm/resident_rsa2048_encrypt_oaep_sha256` | Creates an RSA-2048 keypair on the HSM, then attempts to encrypt with RSA-OAEP-SHA256. | 6 | +| HSM / Resident Encrypt | `hsm/resident_rsa2048_encrypt_pkcs1v15` | Creates an RSA-2048 keypair on the HSM, then encrypts with RSA-PKCS#1v1.5 | 7 | +| HSM / Resident Negative | `hsm/resident_rsa2048_sign_dsa_rejected` | Creates an RSA-2048 keypair on the HSM then attempts to sign using DSAWithSHA256. | 6 | +| HSM / Resident Negative | `hsm/resident_rsa2048_sign_ecdsa_rejected` | Creates an RSA-2048 keypair on the HSM then attempts to sign using ECDSAWithSHA256. | 6 | +| HSM / Resident Sign | `hsm/resident_rsa2048_sign_pkcs1v15` | Creates an RSA-2048 keypair on the HSM and signs with raw PKCS#1 v1.5 | 6 | +| HSM / Resident Sign | `hsm/resident_rsa2048_sign_sha1` | Creates an RSA-2048 keypair on the HSM and signs with SHA1WithRSA (CKM_SHA1_RSA_PKCS). | 6 | +| HSM / Resident Sign | `hsm/resident_rsa2048_sign_sha256` | Creates an RSA-2048 keypair on the HSM and signs with SHA256WithRSA (CKM_SHA256_RSA_PKCS). | 6 | +| HSM / Resident Sign | `hsm/resident_rsa2048_sign_sha384` | Creates an RSA-2048 keypair on the HSM and signs with SHA384WithRSA (CKM_SHA384_RSA_PKCS). | 6 | +| HSM / Resident Sign | `hsm/resident_rsa2048_sign_sha512` | Creates an RSA-2048 keypair on the HSM and signs with SHA512WithRSA (CKM_SHA512_RSA_PKCS). | 6 | +| HSM / Resident Sign | `hsm/resident_rsa4096_create_sign` | Creates an RSA-4096 keypair directly on the HSM via CreateKeyPair. | 6 | +| HSM / Negative | `hsm/wrong_prefix` | Attempts to encrypt using a key ID with an invalid HSM slot prefix (hsm::99::nonexistent). | 1 | | **Integrations** | | | | -| Integrations | `fips/integrations/synology_dsm` | Query ×4, Locate, Register, ModifyAttribute, Locate, Activate, Revoke, Destroy (binary TTLV / KMIP 1.2) | 11 | -| Integrations | `fips/integrations/veeam` | CreateKeyPair, Get ×2, Destroy ×2 (binary TTLV / KMIP 1.4) | 5 | -| Integrations | `fips/integrations/vmware_vcenter` | DiscoverVersions, Query, Create, GetAttributes, AddAttribute ×3, GetAttributes, Get (binary TTLV / KMIP 1.1) | 9 | -| Integrations | `fips/integrations/mysql` | Create, Activate, Get, Revoke, Destroy (binary TTLV / KMIP 1.1) | 5 | -| Integrations | `fips/integrations/percona` | Register, Locate, Get, Revoke, Destroy (binary TTLV / KMIP 1.4) | 5 | -| Integrations | `fips/integrations/fortigate` | Create, Locate, Get, Activate, Revoke, Destroy (binary TTLV / KMIP 1.0) | 6 | -| Integrations | `fips/integrations/fortigate_credential_type` | Create, Activate, Locate with numeric CredentialType enum, Revoke, Destroy (binary TTLV / KMIP 1.0) | 5 | -| Integrations | `fips/integrations/fortigate_locate_filter` | Register ×2, Activate ×2, Locate by name ×2 (assert distinct IDs), Revoke ×2, Destroy ×2 (binary TTLV / KMIP 1.0) | 10 | -| Integrations | `fips/integrations/fortigate_locate_get` | Register ×2, Activate ×2, Batch Locate ×2, Batch Get ×2 (full IPsec key retrieval), Revoke ×2, Destroy ×2 (binary TTLV / KMIP 1.0) | 10 | -| Integrations | `fips/integrations/vast_data` | DiscoverVersions, Create (with OPN), AddAttribute ×3 (Name, ObjectGroup, OPN), Activate, Locate, Get, GetAttributes, ReKey, Locate, Get, GetAttributes (with OPN), Revoke, Destroy, Revoke, Destroy (JSON TTLV / KMIP 1.4) | 17 | -| Integrations | `fips/integrations/kmip_1_3_symmetric` | Create, Activate, Get, Locate, Revoke, Destroy (binary TTLV / KMIP 1.3) | 6 | -| Integrations | `fips/integrations/kmip_1_3_asymmetric` | CreateKeyPair, Get ×2, Destroy ×2 (binary TTLV / KMIP 1.3) | 5 | -| Integrations | `non-fips/integrations/mongodb` | Create, Locate, Get, Destroy (binary TTLV / KMIP 1.0) | 4 | -| Integrations | `non-fips/integrations/pykmip` | DiscoverVersions, Create, CreateKeyPair, GetAttributes, Locate, Activate, Revoke, Destroy ×3 (binary TTLV / KMIP 1.2) | 11 | -| Integrations | `non-fips/integrations/edb_tde_pykmip_variant` | Create, Activate, Encrypt (DEK wrap), Decrypt (DEK unwrap), Revoke, Destroy — EDB TDE pykmip variant | 6 | -| Integrations | `non-fips/integrations/edb_tde_thales_variant` | Create, Activate, Locate, Get (key export), Revoke, Destroy — EDB TDE thales variant | 6 | -| Integrations | `non-fips/integrations/edb_tde_key_rotation` | Create ×2, Activate ×2, Encrypt, Decrypt, Encrypt (re-wrap), Decrypt (verify), Revoke ×2, Destroy ×2 — EDB TDE key rotation | 12 | +| Integrations | `fips/integrations/fortigate` | Simulates FortiOS KMIP 1.0 batched key lookup: Register named AES-128 and HMAC-SHA1 keys → Activate → Batched Locate×4 with UsernamePassword Authentication → Revoke → Destroy. Matches real FortiOS TRACES_40F_1.txt traces (BatchCount=4, BatchOrderOption=true, MaximumItems=1 per Locate, KMIP 1.0 TemplateAttribute). | 17 | +| Integrations | `fips/integrations/fortigate_credential_type` | Non-regression for GitHub issue #824 (FortiOS 7.6.0 / FortiGate 40F support). FortiGate sends CredentialType as a raw numeric enumeration (0x00000001) rather than the symbolic name "UsernameAndPassword". The server previously failed with "missing field `CredentialType`" because the Authentication/Credential structure was not being deserialized correctly. This test sends a KMIP 1.0 Locate request with Authentication containing the numeric CredentialType value and verifies the server processes it successfully. | 5 | +| Integrations | `fips/integrations/fortigate_locate_filter` | Non-regression for GitHub issue #824 comment (FortiOS 7.6 / FortiGate 40F). FortiGate sends KMIP 1.0 Locate requests filtered on the Name attribute to resolve IPsec keys (ENC and AUTH, both directions). The bug caused the server to return the same UniqueIdentifier for all Locate requests regardless of the requested NameValue. This test creates two keys with different names matching the FortiGate naming pattern, locates each by name using KMIP 1.0 TemplateAttribute, and verifies each Locate returns the correct (distinct) key. | 10 | +| Integrations | `fips/integrations/fortigate_locate_get` | Simulates the real FortiOS KMIP 1.0 IPsec key retrieval flow observed in production traces (assii4.txt): Register named AES-128 (ENC) and HMAC-SHA1-160 (AUTH) symmetric keys from the same tunnel pair (FORTIGATE1-FORTIGATE2) → Activate → Batched Locate×2 with UsernamePassword Authentication → Batched Get×2 to retrieve full key material → Revoke → Destroy. Covers the two-phase lookup pattern: first Locate by name to resolve UIDs, then Get by UID to retrieve raw key blocks (FortiOS 7.6 / FortiGate 40F). Key material extracted from assii4.txt production traces. | 10 | +| Integrations | `fips/integrations/fortigate_locate_many_similar_names` | Non-regression for strict name filtering under FortiGate IPsec key patterns. Creates 8 keys with names that share a very long common prefix and differ only in the last few characters (ENC/AUTH, algorithm, key length, tunnel direction). Locates each individually with MaximumItems=1 and verifies that ONLY the exact-match key is returned — proving no aggregation or truncation occurs. assert_count=1 combined with assert_any_field proves exactly one result was returned and it matches the expected key. | 40 | +| Integrations | `fips/integrations/fortigate_locate_multi_tunnel` | Non-regression verifying that keys from different IPsec tunnels are strictly isolated during Locate. Creates 6 keys across 3 tunnel configurations (alpha forward, beta forward, alpha reverse) and verifies each Locate returns only the key for its specific tunnel — no cross-tunnel contamination. Tests that: (1) different tunnel names (alpha vs beta) don't interfere, (2) same tunnel name in different direction (fw1-fw2 vs fw2-fw1) stays isolated, (3) same direction but different type (ENC vs AUTH) returns the correct type. | 30 | +| Integrations | `fips/integrations/fortigate_locate_no_match` | Non-regression proving the server uses EXACT name matching (not substring, prefix, or LIKE). Registers two keys with FortiGate naming patterns, then attempts Locate with: (1) a substring of an existing name, (2) a superstring of an existing name, (3) a completely non-existent name. All three must return success with zero UniqueIdentifiers — proving no partial/fuzzy matching occurs. | 11 | +| Integrations | `fips/integrations/kmip_1_3_asymmetric` | Tests KMIP 1.3 binary wire format with an RSA key pair lifecycle: CreateKeyPair (RSA-2048, Sign/Verify) → Get (public) → Get (private) → Destroy private → Destroy public. KMIP 1.3 is processed identically to 1.4 (no special tweaks unlike 1.0/1.1/1.2). | 5 | +| Integrations | `fips/integrations/kmip_1_3_symmetric` | Tests KMIP 1.3 binary wire format with a full symmetric key lifecycle: Create AES-256 key → Activate → Get → Locate (by name) → Revoke → Destroy. KMIP 1.3 is processed identically to 1.4 (no special tweaks unlike 1.0/1.1/1.2). | 6 | +| Integrations | `fips/integrations/kmip_3_0_asymmetric` | Creates EC P-256 key pair, signs, and verifies using KMIP 3.0 binary wire format | 7 | +| Integrations | `fips/integrations/kmip_3_0_discover_versions` | Queries supported KMIP protocol versions using KMIP 3.0 binary wire format | 1 | +| Integrations | `fips/integrations/kmip_3_0_hash` | Computes SHA-256 hash using KMIP 3.0 binary wire format | 1 | +| Integrations | `fips/integrations/kmip_3_0_locate_get` | Creates a symmetric key, locates it by attributes, and retrieves it using KMIP 3.0 JSON wire format | 4 | +| Integrations | `fips/integrations/kmip_3_0_mac` | Creates HMAC-SHA256 key, computes MAC, and verifies it using KMIP 3.0 binary wire format | 6 | +| Integrations | `fips/integrations/kmip_3_0_query` | Queries server operations and objects using KMIP 3.0 JSON wire format | 1 | +| Integrations | `fips/integrations/kmip_3_0_symmetric` | Creates AES-256 key, encrypts, decrypts, and destroys using KMIP 3.0 binary wire format | 6 | +| Integrations | `fips/integrations/mysql` | Simulates MySQL Enterprise Transparent Data Encryption (TDE) KMIP 1.1 protocol: Create AES-256 key → Activate → Get → Revoke → Destroy. | 5 | +| Integrations | `fips/integrations/percona` | Simulates the Percona PostgreSQL TDE KMIP 1.4 protocol: Register (AES-128 symmetric key) → Locate (by ObjectType + Name) → Get. Mirrors crate/server/src/tests/ttlv_tests/integrations/postgres.rs exactly. | 5 | +| Integrations | `fips/integrations/synology_dsm` | Replays the exact KMIP 1.2 operation sequence observed from Synology DSM 7.x during encrypted volume creation: Query ×4 → Locate (empty) → Register (SecretData/Password with OperationPolicyName) → ModifyAttribute (rename to volume UUID) → Locate (find) → Activate → GetAttributeList → GetAttributes → Get → Revoke → Destroy. Mirrors crate/server/src/tests/ttlv_tests/integrations/synology_dsm.rs exactly. | 14 | +| Integrations | `fips/integrations/vast_data` | Replays the exact KMIP 1.4 operation sequence observed in VAST Data production logs (June 2026): DiscoverVersions → Create AES-256 (with OperationPolicyName) → AddAttribute (Name) → AddAttribute (ObjectGroup) → AddAttribute (OperationPolicyName) → Activate → Locate by name → Get (plaintext) → GetAttributes (State + ActivationDate) → ReKey → Locate (find rotated key) → Get (new key material) → GetAttributes (verify Active + OperationPolicyName preserved after rotation) → Revoke old → Destroy old → Revoke new → Destroy new. VAST uses HTTP POST to /kmip with KMIP 1.4 binary TTLV and mTLS authentication. Covers the ReKey bug fix (issue #845): VAST sends ReKey and expects a new UUID returned. Covers the OperationPolicyName persistence fix: OPN must survive AddAttribute and ReKey. | 17 | +| Integrations | `fips/integrations/veeam` | Replays the KMIP 1.4 operation sequence from Veeam Backup & Replication: CreateKeyPair (RSA-2048, Sign/Verify) → Get (public key) → Get (private key) → Destroy private → Destroy public. Mirrors crate/server/src/tests/ttlv_tests/integrations/veeam.rs exactly. | 5 | +| Integrations | `fips/integrations/vmware_vcenter` | Simulates the VMware vCenter KMIP 1.1 protocol for VM encryption key management: DiscoverVersions → Query → Create (AES-256) → GetAttributes → AddAttribute (x-Product_Version, x-Vendor, x-Product) → GetAttributes → Get. Mirrors crate/server/src/tests/ttlv_tests/integrations/vmware.rs exactly. | 9 | +| Integrations | `non-fips/integrations/edb_tde_key_rotation` | Simulates EDB Postgres TDE master key rotation: | 12 | +| Integrations | `non-fips/integrations/edb_tde_pykmip_variant` | Simulates the EDB Postgres TDE workflow using the pykmip variant: | 6 | +| Integrations | `non-fips/integrations/edb_tde_thales_variant` | Simulates the EDB Postgres TDE workflow using AES-256-CBC KMIP Encrypt/Decrypt: | 6 | +| Integrations | `non-fips/integrations/mongodb` | Simulates MongoDB Queryable Encryption KMIP 1.0 key management: Create AES-256 → Locate → Get → Destroy. Mirrors crate/server/src/tests/ttlv_tests/get_1_0.rs (Percona Server for MongoDB KMIP 1.0). | 4 | +| Integrations | `non-fips/integrations/pykmip` | Simulates the PyKMIP client KMIP 1.2 protocol sequence: DiscoverVersions → Create (AES-256) → CreateKeyPair (RSA-2048) → GetAttributes → Locate → Activate → Revoke → Destroy (symmetric + RSA pair). | 11 | | **TLS Transport** | | | | -| TLS | `tls/server_tls` | Create, Revoke, Destroy (HTTPS server TLS) | 3 | -| TLS | `tls/mtls` | Create, Revoke, Destroy (mTLS client certificate auth) | 3 | +| TLS | `tls/mtls` | Verifies the KMS can be reached over HTTPS with mutual TLS (client certificate required) | 3 | +| TLS | `tls/server_tls` | Verifies the KMS can be reached over HTTPS with server-TLS only (self-signed cert, no mTLS) | 3 | +| **OPA Policy Engine** | | | | +| OPA | `opa/mode_disabled` | OPA not configured; KMS legacy permission logic applies. Creates an AES key, retrieves it, and destroys it. | 3 | +| OPA | `opa/mode_enforcing_allowed` | OPA enforcing mode; JWT with CryptoOfficer role from auth server; Create then Get allowed by is_owner=true (OPA + KMS both pass). | 3 | +| OPA | `opa/mode_enforcing_denied` | OPA enforcing mode; owner (mTLS cert) creates AES key; ungranted user (different cert, no roles) is denied Get. | 3 | +| OPA | `opa/mode_exclusive_allowed` | OPA exclusive mode; JWT with CryptoOfficer role from auth server; Create then Get allowed by is_owner=true. | 3 | +| OPA | `opa/mode_exclusive_auditor_destroy_denied` | OPA exclusive mode. The CryptoOfficer (default JWT client, owner) creates an AES key. | 3 | +| OPA | `opa/mode_exclusive_auditor_get_attributes_allowed` | OPA exclusive mode. The CryptoOfficer (default JWT client, owner) creates an AES key. | 3 | +| OPA | `opa/mode_exclusive_denied` | OPA exclusive mode; owner (mTLS cert) creates AES key; ungranted user (different cert, no roles) is denied Get. | 3 | +| OPA | `opa/mode_exclusive_domain_admin_wrong_domain` | OPA exclusive mode. The CryptoOfficer from realm `kms-opa-test` (default JWT client, | 3 | +| OPA | `opa/mode_exclusive_user_role_denied` | OPA exclusive mode. The CryptoOfficer (default JWT client, owner) creates an AES key. | 3 | +| OPA | `opa/mode_exclusive_wrong_domain` | OPA exclusive mode. The CryptoOfficer from realm `kms-opa-test` (default JWT client, | 3 | | **Negative** | | | | -| Negative / Protocol | `negative/empty_request` | Empty body → error | 1 | -| Negative / Protocol | `negative/missing_data_encrypt` | Encrypt without Data → error | 2 | -| Negative / Protocol | `negative/missing_data_decrypt` | Decrypt without Data → error | 2 | -| Negative / Protocol | `negative/missing_uid_encrypt` | Encrypt without UniqueIdentifier → error | 1 | -| Negative / Protocol | `negative/nonexistent_key_encrypt` | Encrypt with unknown key ID → error | 1 | -| Negative / Protocol | `negative/nonexistent_key_decrypt` | Decrypt with unknown key ID → error | 1 | -| Negative / Protocol | `negative/wrong_key_type_encrypt` | Encrypt with RSA key for AES cipher → error | 2 | -| Negative / Protocol | `negative/destroy_then_encrypt` | Destroy key then encrypt → error | 3 | -| Negative / Protocol | `negative/empty_data_encrypt` | Encrypt with empty plaintext → success | 2 | -| Negative / Protocol | `negative/invalid_iv_length` | Encrypt with wrong-length IV → error | 2 | -| Negative / Protocol | `negative/sign_with_encrypt_key` | Sign with Encrypt-mask-only key → error | 2 | -| Negative / Protocol | `negative/duplicate_tags_encrypt` | Encrypt with tag resolving to 2 keys → error | 7 | -| Negative / CryptoParams | `negative/crypto_params/encrypt_unsupported_mode` | Unsupported BlockCipherMode → success | 2 | -| Negative / CryptoParams | `negative/crypto_params/encrypt_unsupported_padding` | Unsupported PaddingMethod with GCM → success | 2 | -| Negative / CryptoParams | `negative/crypto_params/encrypt_mode_algo_mismatch` | ChaCha20 key + AES CryptographicParameters → success | 2 | -| Negative / CryptoParams | `negative/crypto_params/encrypt_gcm_invalid_tag_length` | Invalid TagLength for GCM → error | 2 | -| Negative / CryptoParams | `negative/crypto_params/sign_invalid_hash` | RSA-PSS with MD5 hash → success in non-FIPS | 2 | -| Negative / CryptoParams | `negative/crypto_params/sign_rsa_with_ecdsa_algo` | RSA key + ECDSA algorithm → error | 2 | -| Negative / CryptoParams | `negative/crypto_params/decrypt_wrong_mode` | Encrypt GCM then Decrypt CBC → error | 3 | -| Negative / CryptoParams | `negative/crypto_params/encrypt_chacha20_with_gcm_mode` | ChaCha20 key + GCM mode → success | 2 | -| Negative / CryptoParams | `negative/crypto_params/hash_unsupported_algo` | Hash with MD5 → success in non-FIPS | 1 | -| Negative / CryptoParams | `negative/crypto_params/mac_unsupported_algo` | MAC with MD5 → success in non-FIPS | 2 | -| Negative / Decrypt | `negative/decrypt/decrypt_missing_iv_cbc` | AES-CBC decrypt without IV → error | 2 | -| Negative / Decrypt | `negative/decrypt/decrypt_empty_tag_gcm` | AES-GCM decrypt with empty auth tag → error | 2 | -| Negative / Decrypt | `negative/decrypt/decrypt_truncated_ciphertext` | AES-GCM decrypt truncated ciphertext → error | 2 | -| Negative / Decrypt | `negative/decrypt/decrypt_wrong_key` | Decrypt with wrong key → error | 3 | -| Negative / Decrypt | `negative/decrypt/decrypt_corrupted_ciphertext` | AES-GCM decrypt with corrupted ciphertext+tag → error | 3 | -| Negative / RSA | `negative/rsa/rsa_encrypt_oversized_data` | RSA-OAEP encrypt data too large → error | 2 | -| Negative / RSA | `negative/rsa/rsa_decrypt_with_public_key` | RSA decrypt using public key → error | 2 | -| Negative / RSA | `negative/rsa/rsa_decrypt_garbage` | RSA decrypt random bytes → error | 2 | -| Negative / Sign | `negative/sign_verify/verify_corrupted_signature` | Verify with bit-flipped signature → error | 3 | -| Negative / Sign | `negative/sign_verify/verify_wrong_key` | Verify with wrong keypair → error | 4 | -| Negative / Sign | `negative/sign_verify/sign_with_public_key` | Sign with public key → error | 2 | -| Negative / MAC | `negative/mac/mac_with_non_hmac_key` | MAC with AES key (not HMAC) → error | 2 | -| Negative / MAC | `negative/mac/mac_verify_wrong_data` | MACVerify with tampered data → error | 3 | -| Negative / Hash | `negative/hash/hash_missing_algorithm` | Hash without HashingAlgorithm → error | 1 | -| Negative / Hash | `negative/hash/hash_init_and_final_both_true` | Hash with InitIndicator=true AND FinalIndicator=true → error | 1 | -| Negative / DeriveKey | `negative/derive_key/derive_key_pbkdf2_no_salt` | PBKDF2 without Salt → error | 2 | -| Negative / DeriveKey | `negative/derive_key/derive_key_negative_iterations` | PBKDF2 with negative iteration count → error | 2 | -| Negative / Lifecycle | `negative/lifecycle/encrypt_pre_active_key` | Encrypt with pre-active key → error | 2 | -| Negative / Lifecycle | `negative/lifecycle/create_invalid_algorithm` | Create with unknown algorithm → error | 1 | -| Negative / Lifecycle | `negative/lifecycle/create_zero_length_key` | Create with CryptographicLength=0 → error | 1 | -| Negative / Lifecycle | `negative/lifecycle/double_activate` | Activate already-active key → error | 3 | -| Negative / Lifecycle | `negative/lifecycle/deactivate_pre_active` | Activate a destroyed key → error | 5 | -| Negative / TypeMismatch | `negative/type_mismatch/import_malformed_key` | Import TransparentSymmetricKey with raw bytes → error | 1 | -| Negative / TypeMismatch | `negative/type_mismatch/encrypt_with_secret_data` | Encrypt using SecretData object → error | 2 | -| Negative / TypeMismatch | `negative/type_mismatch/revoke_already_destroyed` | Revoke a destroyed key → success | 3 | +| Negative / Activate | `negative/activate/item_not_found` | Tests that Activate returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / Activate | `negative/activate/wrong_key_lifecycle_state` | Tests that Activate returns Wrong_Key_Lifecycle_State error as per KMIP spec | 3 | +| Negative / AddAttribute | `negative/add_attribute/item_not_found` | Tests that Add Attribute returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / AddAttribute | `negative/add_attribute/read_only_attribute` | Tests that Add Attribute returns Read_Only_Attribute error as per KMIP spec | 2 | +| Negative / Certify | `negative/certify/invalid_object_type` | Tests that Certify returns Invalid_Object_Type error as per KMIP spec | 2 | +| Negative / Certify | `negative/certify/item_not_found` | Tests that Certify returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / Check | `negative/check/item_not_found` | Tests that Check returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / Create | `negative/create/invalid_attribute` | Tests that Create returns Invalid_Attribute error as per KMIP spec | 1 | +| Negative / Create | `negative/create/invalid_attribute_value` | Tests that Create returns Invalid_Attribute_Value error as per KMIP spec | 1 | +| Negative / Create | `negative/create/invalid_field` | Tests that Create returns Invalid_Field error as per KMIP spec | 1 | +| Negative / Create | `negative/create/invalid_message` | Tests that Create returns Invalid_Message error as per KMIP spec | 1 | +| Negative / Create | `negative/create/read_only_attribute` | Tests that Create returns Read_Only_Attribute error as per KMIP spec | 2 | +| Negative / CreateKeyPair | `negative/create_key_pair/invalid_attribute` | Tests that Create Key Pair returns Invalid_Attribute error as per KMIP spec | 1 | +| Negative / CreateKeyPair | `negative/create_key_pair/invalid_attribute_value` | Tests that Create Key Pair returns Invalid_Attribute_Value error as per KMIP spec | 1 | +| Negative / CreateKeyPair | `negative/create_key_pair/invalid_message` | Tests that Create Key Pair returns Invalid_Message error as per KMIP spec | 1 | +| Negative / CryptoParams | `negative/crypto_params/decrypt_wrong_mode` | Tests that decryption fails when using CBC mode to decrypt data that was encrypted with GCM | 3 | +| Negative / CryptoParams | `negative/crypto_params/encrypt_chacha20_with_gcm_mode` | Documents that ChaCha20Poly1305 key with BlockCipherMode GCM succeeds — server routes to AES-256-GCM since GCM mode overrides the key's algorithm | 2 | +| Negative / CryptoParams | `negative/crypto_params/encrypt_gcm_invalid_tag_length` | Tests that AES-GCM encryption fails with an invalid authentication tag length | 2 | +| Negative / CryptoParams | `negative/crypto_params/encrypt_mode_algo_mismatch` | Documents that encryption succeeds when CryptographicParameters algorithm differs from key algorithm — server uses the key's actual algorithm, ignoring algorithm field in CryptographicParameters | 2 | +| Negative / CryptoParams | `negative/crypto_params/encrypt_unsupported_mode` | Tests that AES encryption fails with an unsupported BlockCipherMode | 2 | +| Negative / CryptoParams | `negative/crypto_params/encrypt_unsupported_padding` | Documents that AES-GCM encryption ignores unsupported PaddingMethod (GCM handles padding internally) | 2 | +| Negative / CryptoParams | `negative/crypto_params/hash_unsupported_algo` | Tests that hashing fails when using an unsupported algorithm (MD5) | 1 | +| Negative / CryptoParams | `negative/crypto_params/mac_unsupported_algo` | Tests that MAC computation fails when using an unsupported hashing algorithm (MD5) | 2 | +| Negative / CryptoParams | `negative/crypto_params/sign_invalid_hash` | Documents that RSA-PSS signing with MD5 succeeds in non-FIPS mode (OpenSSL allows MD5 in legacy mode) | 2 | +| Negative / CryptoParams | `negative/crypto_params/sign_rsa_with_ecdsa_algo` | Tests that signing an RSA key with ECDSA digital signature algorithm fails | 2 | +| Negative / Decrypt | `negative/decrypt/decrypt_corrupted_ciphertext` | Tests that AES-GCM decryption fails when ciphertext and tag are fabricated | 2 | +| Negative / Decrypt | `negative/decrypt/decrypt_empty_tag_gcm` | Tests that AES-GCM decryption fails when AuthenticatedEncryptionTag is missing | 3 | +| Negative / Decrypt | `negative/decrypt/decrypt_missing_iv_cbc` | When IVCounterNonce is absent from a CBC Decrypt request the KMS must return | 2 | +| Negative / Decrypt | `negative/decrypt/decrypt_truncated_ciphertext` | Tests that AES-GCM decryption fails when ciphertext is too short (1 byte) | 2 | +| Negative / Decrypt | `negative/decrypt/decrypt_wrong_key` | Tests that AES-GCM decryption fails when using a different key than the one used for encryption | 4 | +| Negative / Decrypt | `negative/decrypt/invalid_message` | Tests that Decrypt returns Invalid_Message error as per KMIP spec | 1 | +| Negative / Decrypt | `negative/decrypt/wrong_key_lifecycle_state` | Tests that Decrypt returns Wrong_Key_Lifecycle_State error as per KMIP spec | 2 | +| Negative / DeleteAttribute | `negative/delete_attribute/item_not_found` | Tests that Delete Attribute returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / DeriveKey | `negative/derive_key/derive_key_negative_iterations` | Tests that PBKDF2 key derivation fails when IterationCount is negative | 2 | +| Negative / DeriveKey | `negative/derive_key/derive_key_pbkdf2_no_salt` | Tests that PBKDF2 key derivation fails when Salt is not provided | 2 | +| Negative / Destroy | `negative/destroy/item_not_found` | Tests that Destroy returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / Destroy | `negative/destroy/wrong_key_lifecycle_state` | Tests that Destroy returns Wrong_Key_Lifecycle_State error as per KMIP spec | 3 | +| Negative / Protocol | `negative/destroy_then_encrypt` | Tests that encrypt fails when the key has been destroyed | 5 | +| Negative / Protocol | `negative/duplicate_tags_encrypt` | Creates two AES-256 keys with the same tag ["dup-test-enc"], then attempts | 7 | +| Negative / Protocol | `negative/empty_data_encrypt` | Tests that GCM encryption with empty data succeeds (GCM allows empty plaintext) | 2 | +| Negative / Protocol | `negative/empty_request` | Tests that the server handles an empty JSON body gracefully | 1 | +| Negative / Encrypt | `negative/encrypt/bad_cryptographic_parameters` | Tests that Encrypt returns Bad_Cryptographic_Parameters error as per KMIP spec | 3 | +| Negative / Encrypt | `negative/encrypt/incompatible_cryptographic_usage_mask` | Tests that Encrypt returns Incompatible_Cryptographic_Usage_Mask error as per KMIP spec | 3 | +| Negative / Encrypt | `negative/encrypt/invalid_field` | Tests that Encrypt returns Invalid_Field error as per KMIP spec | 3 | +| Negative / Encrypt | `negative/encrypt/invalid_message` | Tests that Encrypt returns Invalid_Message error as per KMIP spec | 1 | +| Negative / Encrypt | `negative/encrypt/invalid_object_type` | Tests that Encrypt returns Invalid_Object_Type error as per KMIP spec | 3 | +| Negative / Encrypt | `negative/encrypt/unsupported_cryptographic_parameters` | Tests that Encrypt returns Unsupported_Cryptographic_Parameters error as per KMIP spec | 3 | +| Negative / Encrypt | `negative/encrypt/wrong_key_lifecycle_state` | Tests that Encrypt returns Wrong_Key_Lifecycle_State error as per KMIP spec | 2 | +| Negative / Export | `negative/export/item_not_found` | Tests that Export returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / Export | `negative/export/key_format_type_not_supported` | Tests that Export returns Key_Format_Type_Not_Supported error as per KMIP spec | 2 | +| Negative / Get | `negative/get/item_not_found` | Tests that Get returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / Get | `negative/get/key_format_type_not_supported` | Tests that Get returns Key_Format_Type_Not_Supported error as per KMIP spec | 2 | +| Negative / GetAttributeList | `negative/get_attribute_list/item_not_found` | Tests that Get Attribute List returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / GetAttributes | `negative/get_attributes/item_not_found` | Tests that Get Attributes returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / Hash | `negative/hash/hash_init_and_final_both_true` | Tests that Hash operation fails when both InitIndicator and FinalIndicator are set to true | 1 | +| Negative / Hash | `negative/hash/hash_missing_algorithm` | Tests that Hash operation fails when CryptographicParameters has no HashingAlgorithm | 1 | +| Negative / Import | `negative/import/invalid_message` | Tests that Import returns Invalid_Message error as per KMIP spec | 1 | +| Negative / Protocol | `negative/invalid_iv_length` | Tests that AES-CBC encryption fails when an IV with incorrect length (8 bytes instead of 16) is provided | 2 | +| Negative / Lifecycle | `negative/lifecycle/create_hsm_key_without_hsm` | Attempts to create an AES-256 key with an HSM-prefixed UID (hsm::0::no_hsm_key) | 1 | +| Negative / Lifecycle | `negative/lifecycle/create_invalid_algorithm` | Tests that key creation fails when specifying an unsupported cryptographic algorithm | 1 | +| Negative / Lifecycle | `negative/lifecycle/create_zero_length_key` | Tests that key creation fails when CryptographicLength is set to zero | 1 | +| Negative / Lifecycle | `negative/lifecycle/deactivate_pre_active` | Tests that activating a destroyed key fails | 5 | +| Negative / Lifecycle | `negative/lifecycle/double_activate` | Tests that activating an already-active key fails | 3 | +| Negative / Lifecycle | `negative/lifecycle/encrypt_pre_active_key` | Tests that encryption fails when using a key that has not been activated (no ActivationDate) | 2 | +| Negative / Lifecycle | `negative/lifecycle/reactivate_deactivated` | Tests that activating a deactivated (revoked) key fails | 4 | +| Negative / MAC | `negative/mac/item_not_found` | Tests that MAC returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / MAC | `negative/mac/mac_verify_wrong_data` | Tests that MAC verification returns Invalid when data does not match the MAC | 3 | +| Negative / MAC | `negative/mac/mac_with_non_hmac_key` | Tests that MAC operation fails when using an AES key without proper HMAC algorithm parameters | 2 | +| Negative / MAC | `negative/mac/wrong_key_lifecycle_state` | Tests that MAC returns Wrong_Key_Lifecycle_State error as per KMIP spec | 2 | +| Negative / MAC | `negative/mac_verify/item_not_found` | Tests that MAC Verify returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / MAC | `negative/mac_verify/wrong_key_lifecycle_state` | Tests that MAC Verify returns Wrong_Key_Lifecycle_State error as per KMIP spec | 2 | +| Negative / Protocol | `negative/missing_data_decrypt` | Tests that decrypt fails when no Data field is provided | 2 | +| Negative / Protocol | `negative/missing_data_encrypt` | Tests that encrypt fails when no Data field is provided | 2 | +| Negative / Protocol | `negative/missing_uid_encrypt` | Tests that encrypt fails when no UniqueIdentifier is provided | 1 | +| Negative / ModifyAttribute | `negative/modify_attribute/item_not_found` | Tests that Modify Attribute returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / ModifyAttribute | `negative/modify_attribute/read_only_attribute` | Tests that Modify Attribute returns Read_Only_Attribute error as per KMIP spec | 2 | +| Negative / Protocol | `negative/nonexistent_key_decrypt` | Tests that decrypt fails when referencing a key ID that does not exist | 1 | +| Negative / Protocol | `negative/nonexistent_key_encrypt` | Tests that encrypt fails when referencing a key ID that does not exist | 1 | +| Negative / Protocol | `negative/recertify_missing_uid` | Tests that ReCertify fails — ReCertify is a KMIP 1.4 operation not supported in KMIP 2.1 | 1 | +| Negative / Protocol | `negative/recertify_nonexistent` | Tests that ReCertify fails when the certificate UID does not exist in the database | 1 | +| Negative / Protocol | `negative/recertify_not_a_certificate` | Tests that ReCertify fails when given a symmetric key instead of a certificate | 4 | +| Negative / Register | `negative/register/invalid_attribute` | Tests that Register returns Invalid_Attribute error as per KMIP spec | 1 | +| Negative / Register | `negative/register/invalid_attribute_value` | Tests that Register returns Invalid_Attribute_Value error as per KMIP spec | 1 | +| Negative / Register | `negative/register/invalid_message` | Tests that Register returns Invalid_Message error as per KMIP spec | 1 | +| Negative / Protocol | `negative/rekey_keypair_non_latest` | Tests that ReKeyKeyPair on a retired (non-latest) keyset member is rejected. | 7 | +| Negative / Protocol | `negative/rekey_keypair_preactive_fails` | Creates an EC P-256 key pair without ActivationDate (enters PreActive state), then verifies that ReKeyKeyPair on a PreActive private key is rejected. Only Active keys can be rotated. | 5 | +| Negative / Protocol | `negative/rekey_offset_preactive_cannot_encrypt` | When ReKey uses Offset=86400, the new key enters PreActive state. A PreActive key must not be usable for Encrypt operations. | 6 | +| Negative / Protocol | `negative/rekey_preactive_fails` | Creates a symmetric key without ActivationDate (enters PreActive state), then verifies that ReKey on a PreActive key is rejected. Only Active keys can be rotated. | 4 | +| Negative / Revoke | `negative/revoke/item_not_found` | Tests that Revoke returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / RSA | `negative/rsa/rsa_decrypt_garbage` | Tests that RSA-OAEP decryption fails when ciphertext is random garbage data | 2 | +| Negative / RSA | `negative/rsa/rsa_decrypt_with_public_key` | Tests that RSA decryption fails when attempting to use a public key for decryption | 2 | +| Negative / RSA | `negative/rsa/rsa_encrypt_oversized_data` | Tests that RSA-OAEP encryption fails when plaintext exceeds modulus size limit | 2 | +| Negative / SetAttribute | `negative/set_attribute/hsm_rotate_offset_rejected` | Tests that SetAttribute rotate_offset on an HSM-resident key is rejected | 4 | +| Negative / SetAttribute | `negative/set_attribute/item_not_found` | Tests that Set Attribute returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / SetAttribute | `negative/set_attribute/read_only_attribute` | Tests that Set Attribute returns Read_Only_Attribute error as per KMIP spec | 2 | +| Negative / SetAttribute | `negative/set_attribute/readonly_rotate_date` | x-rotate-date is a server-managed read-only attribute. Any attempt to set it via SetAttribute must be rejected with Attribute_Read_Only. | 4 | +| Negative / SetAttribute | `negative/set_attribute/readonly_rotate_generation` | x-rotate-generation is a server-managed read-only attribute. Any attempt to set it via SetAttribute must be rejected with Attribute_Read_Only. | 4 | +| Negative / Sign | `negative/sign/invalid_message` | Tests that Sign returns Invalid_Message error as per KMIP spec | 1 | +| Negative / Sign | `negative/sign/item_not_found` | Tests that Sign returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / Sign | `negative/sign/wrong_key_lifecycle_state` | Tests that Sign returns Wrong_Key_Lifecycle_State error as per KMIP spec | 2 | +| Negative / Sign | `negative/sign_verify/sign_with_public_key` | Tests that signing fails when attempting to use a public key instead of private key | 2 | +| Negative / Sign | `negative/sign_verify/verify_corrupted_signature` | Tests that signature verification returns Invalid when the signature is fabricated | 3 | +| Negative / Sign | `negative/sign_verify/verify_wrong_key` | Tests that signature verification returns Invalid when verifying with a different key pair | 4 | +| Negative / Protocol | `negative/sign_with_encrypt_key` | Tests that signing fails when the private key only has Decrypt usage mask (no Sign permission) | 2 | +| Negative / SignatureVerify | `negative/signature_verify/item_not_found` | Tests that Signature Verify returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / SignatureVerify | `negative/signature_verify/wrong_key_lifecycle_state` | Tests that Signature Verify returns Wrong_Key_Lifecycle_State error as per KMIP spec | 2 | +| Negative / TypeMismatch | `negative/type_mismatch/encrypt_with_secret_data` | Tests that encryption fails when attempting to use a Secret Data object instead of a cryptographic key | 2 | +| Negative / TypeMismatch | `negative/type_mismatch/import_malformed_key` | Tests that importing a key with mismatched key material size and declared CryptographicLength fails | 1 | +| Negative / TypeMismatch | `negative/type_mismatch/revoke_already_destroyed` | Documents that the server allows revoking a key that has already been destroyed (surprising but accepted behavior) | 4 | +| Negative / Validate | `negative/validate/item_not_found` | Tests that Validate returns Item_Not_Found error as per KMIP spec | 1 | +| Negative / Protocol | `negative/wrong_key_type_encrypt` | Tests that encrypt fails when using a private key (wrong key type for encryption) | 2 | | **non-FIPS CryptographicParameters** | | | | -| non-FIPS / GCM-SIV | `non-fips/aes128_gcm_siv_with_explicit_nonce` | Create (AES-128), Encrypt (client 12-B nonce), Decrypt | 3 | -| non-FIPS / GCM-SIV | `non-fips/aes256_gcm_siv_with_explicit_nonce` | Create (AES-256), Encrypt (client 12-B nonce), Decrypt | 3 | -| non-FIPS / GCM-SIV | `non-fips/aes128_gcm_siv_with_aad` | Create (AES-128), Encrypt (AAD + server nonce), Decrypt | 3 | -| non-FIPS / GCM-SIV | `non-fips/aes256_gcm_siv_with_aad` | Create (AES-256), Encrypt (AAD + server nonce), Decrypt | 3 | -| non-FIPS / ChaCha20 | `non-fips/chacha20_server_generated_nonce` | Create, Encrypt (server generates 8-B nonce), Decrypt | 3 | -| non-FIPS / ChaCha20 | `non-fips/chacha20_with_explicit_cryptographic_params` | Create, Encrypt (CryptographicParameters{ChaCha20} + 8-B nonce), Decrypt | 3 | -| non-FIPS / Poly1305 | `non-fips/chacha20_poly1305_with_explicit_nonce` | Create, Encrypt (AEAD + client 12-B nonce), Decrypt | 3 | -| non-FIPS / Poly1305 | `non-fips/chacha20_poly1305_with_aad` | Create, Encrypt (AEAD + AAD + server nonce), Decrypt | 3 | +| non-FIPS / GCM-SIV | `non-fips/aes128_gcm_siv_with_aad` | Creates an AES-128 key, encrypts with additional authenticated data (AAD) using AES-GCM-SIV (server-generated nonce), then decrypts with the same AAD and verifies the plaintext | 3 | +| non-FIPS / GCM-SIV | `non-fips/aes128_gcm_siv_with_explicit_nonce` | Creates an AES-128 key, encrypts with a client-provided 12-byte nonce using AES-GCM-SIV, then decrypts and verifies the plaintext | 3 | +| non-FIPS / GCM-SIV | `non-fips/aes256_gcm_siv_with_aad` | Creates an AES-256 key, encrypts with additional authenticated data (AAD) using AES-GCM-SIV (server-generated nonce), then decrypts with the same AAD and verifies the plaintext | 3 | +| non-FIPS / GCM-SIV | `non-fips/aes256_gcm_siv_with_explicit_nonce` | Creates an AES-256 key, encrypts with a client-provided 12-byte nonce using AES-GCM-SIV, then decrypts and verifies the plaintext | 3 | +| non-FIPS / Poly1305 | `non-fips/chacha20_poly1305_with_aad` | Creates a ChaCha20-Poly1305 key, encrypts with additional authenticated data (AAD) using AEAD mode (server-generated nonce), then decrypts with the same AAD and verifies the plaintext | 3 | +| non-FIPS / Poly1305 | `non-fips/chacha20_poly1305_with_explicit_nonce` | Creates a ChaCha20-Poly1305 key, encrypts with a client-provided 12-byte nonce using AEAD mode, then decrypts and verifies the plaintext | 3 | +| non-FIPS / ChaCha20 | `non-fips/chacha20_server_generated_nonce` | Creates a ChaCha20 key, encrypts without specifying a nonce (server generates an 8-byte nonce), captures the nonce from the response, then decrypts and verifies the plaintext | 3 | +| non-FIPS / ChaCha20 | `non-fips/chacha20_with_explicit_cryptographic_params` | Creates a ChaCha20 key, encrypts with an explicit CryptographicParameters block specifying the ChaCha20 algorithm and a client-provided 8-byte nonce, then decrypts and verifies the plaintext | 3 | +| **Keyset Resolution** | | | | +| Keyset | `keyset_chain_skips_expired_window` | Creates a symmetric key (gen-0), sets a rotate_name, encrypts with the gen-0 UID, then performs a ReKey to create gen-1. Sets ProtectStopDate in the past on gen-1 (the newest key in the chain). Decrypts with the bare keyset name. | 9 | +| Keyset / Decrypt | `keyset_decrypt_at_first` | Creates a symmetric key, assigns a rotate_name, encrypts with the original key UID, performs a ReKey, then decrypts using name@first. Verifies that @first resolves to gen-0 for decryption. | 8 | +| Keyset / Decrypt | `keyset_decrypt_at_generation_n` | Creates a symmetric key, assigns a rotate_name, encrypts with gen-0 UID, performs two ReKey operations, then decrypts using name@0. Verifies that @0 resolves to gen-0 for decryption even after multiple rotations. | 11 | +| Keyset / Decrypt | `keyset_decrypt_at_latest` | Decrypt using name@latest resolves to the single latest key rather than walking the chain. After rotation, encrypts with the new key, then decrypts using name@latest which should find the new key directly. | 8 | +| Keyset / Decrypt | `keyset_decrypt_double_rotation` | Tests try-each-key across a 3-generation chain: | 11 | +| Keyset / Decrypt | `keyset_decrypt_try_each` | The primary keyset try-each-key test: | 8 | +| Keyset | `keyset_ec_sign_verify_chain` | Tests keyset-based Sign resolution with EC key pairs: | 10 | +| Keyset / Encrypt | `keyset_encrypt_at_first` | Creates a symmetric key with a rotate_name, encrypts using name@first while gen-0 is Active, then performs ReKey (gen-0 → Deactivated per §4.57). Verifies @first resolved to gen-0 by decrypting with the original key UID after rotation (Decrypt accepts Deactivated keys per KMIP §3.31). | 8 | +| Keyset / Encrypt | `keyset_encrypt_at_generation_n` | Creates a symmetric key, assigns a rotate_name, performs two ReKey operations (gen-0→gen-1→gen-2). Encrypts with name@1 while gen-1 is still Active (between rotations), then verifies by decrypting with the gen-1 UID after the second rotation (Decrypt accepts Deactivated keys). | 11 | +| Keyset / Encrypt | `keyset_encrypt_bare_name` | Creates a symmetric key with rotate_name set, then encrypts using only the bare keyset name (no @version suffix). For Encrypt operations, bare keyset names resolve to the latest key (SingleLatest mode). | 5 | +| Keyset / Encrypt | `keyset_encrypt_expired_window_fails` | Creates a symmetric key, assigns a rotate_name, then sets ProtectStopDate in the past on the key (the only/latest key in the chain). An Encrypt with the bare keyset name must fail. | 5 | +| Keyset / Encrypt | `keyset_encrypt_latest` | Creates a symmetric key, assigns a rotate_name via SetAttribute, then encrypts data using the keyset name@latest syntax. Verifies that keyset resolution correctly finds the latest key. | 5 | +| Keyset / Encrypt | `keyset_encrypt_latest_after_rotation` | After rotation, encrypting by the bare keyset name must use the new key: | 8 | +| Keyset | `keyset_gen0_via_address` | After creating a keyset and rotating it once, verifies that the gen-0 key | 9 | +| Keyset | `keyset_getattributes_resolution` | Verifies that after ReKey, RotateGeneration is correctly set: | 7 | +| Keyset | `keyset_mac_verify_chain` | Tests the TryEach chain-walk for MACVerify via keyset name: | 7 | +| Keyset | `keyset_uid_scheme` | Verifies the deterministic UID scheme for SQL keysets: | 9 | +| Negative / Keyset | `negative/keyset_addattribute_uid_mismatch_fails` | Verifies that adding rotate_name via AddAttribute is rejected when the attribute value does not equal the key's UID. Mirrors the SetAttribute enforcement for the same keyset-name invariant. | 4 | +| Negative / Keyset | `negative/keyset_create_no_uid_with_rotate_name_fails` | Verifies that a Create request that specifies rotate_name but omits UniqueIdentifier is rejected. Without an explicit UID equal to the keyset name, the server would assign a random UUID, violating the gen-0 UID invariant. | 1 | +| Negative / Keyset | `negative/keyset_create_uid_mismatch_fails` | Verifies that a Create request where rotate_name does not equal the supplied UniqueIdentifier is rejected. The invariant is: gen-0 UID must equal the keyset name. | 1 | +| Negative / Keyset | `negative/keyset_invalid_generation` | Creates a symmetric key with a rotate_name, then attempts to encrypt using name@99 which references a nonexistent generation. The operation must fail. | 4 | +| Negative / Keyset | `negative/keyset_rotate_name_at_rejected` | Verifies that setting a rotate_name containing '@' is rejected with an InvalidRequest error, since '@' is reserved for keyset versioning syntax. | 4 | +| Negative / Keyset | `negative/keyset_setattribute_uid_mismatch_fails` | Verifies that setting rotate_name via SetAttribute is rejected when the attribute value does not equal the key's UID. SQL keys require gen-0 UID to equal the keyset name — this invariant must be enforced at SetAttribute time. | 4 | +| Negative / Keyset | `negative/rekey_non_latest_hsm` | Tests that Re-Key on a retired (non-latest) HSM key is transparently redirected | 10 | +| Negative / Keyset | `negative/rekey_non_latest_sql` | Tests that Re-Key on a retired (non-latest) SQL-backed key is rejected. | 7 | --- @@ -339,59 +510,78 @@ MAC, or derived-key values. | Category | Vector Directory | Reference | Operations | Assert Field | |----------|-----------------|-----------|------------|--------------| -| **Hash** | | NIST FIPS 180-4 / FIPS 202 ("abc") | | | -| Hash | `kat/hash/sha256` | FIPS 180-4 | Hash (SHA-256) | `Data` | -| Hash | `kat/hash/sha384` | FIPS 180-4 | Hash (SHA-384) | `Data` | -| Hash | `kat/hash/sha512` | FIPS 180-4 | Hash (SHA-512) | `Data` | -| Hash | `kat/hash/sha3_256` | FIPS 202 | Hash (SHA3-256) | `Data` | -| Hash | `kat/hash/sha3_384` | FIPS 202 | Hash (SHA3-384) | `Data` | -| Hash | `kat/hash/sha3_512` | FIPS 202 | Hash (SHA3-512) | `Data` | -| **MAC** | | RFC 4231 §4.2 ("Hi There", key=0x0B×32) | | | -| MAC | `kat/mac/hmac_sha256` | RFC 4231 §4.2 | Import, MAC (HMAC-SHA256) | `MACData` | -| MAC | `kat/mac/hmac_sha384` | RFC 4231 §4.2 | Import, MAC (HMAC-SHA384) | `MACData` | -| MAC | `kat/mac/hmac_sha512` | RFC 4231 §4.2 | Import, MAC (HMAC-SHA512) | `MACData` | -| MAC | `kat/mac/hmac_sha3_256` | NIST HMAC-SHA3 | Import, MAC (HMAC-SHA3-256) | `MACData` | -| MAC | `kat/mac/hmac_sha3_384` | NIST HMAC-SHA3 | Import, MAC (HMAC-SHA3-384) | `MACData` | -| MAC | `kat/mac/hmac_sha3_512` | NIST HMAC-SHA3 | Import, MAC (HMAC-SHA3-512) | `MACData` | -| MAC | `kat/mac/hmac_sha1` | RFC 2202 §3 | Import, MAC (HMAC-SHA1) | `MACData` | -| **Symmetric** | | NIST SP 800-38A / SP 800-38D | | | -| Symmetric | `kat/symmetric/aes128_ecb` | SP 800-38A F.1.1 | Import, Encrypt (AES-128-ECB) | `Data` | -| Symmetric | `kat/symmetric/aes192_ecb` | SP 800-38A F.1.3 | Import, Encrypt (AES-192-ECB) | `Data` | -| Symmetric | `kat/symmetric/aes256_ecb` | SP 800-38A F.1.5 | Import, Encrypt (AES-256-ECB) | `Data` | -| Symmetric | `kat/symmetric/aes128_cbc` | SP 800-38A F.2.1 | Import, Encrypt (AES-128-CBC, no padding) | `Data` | -| Symmetric | `kat/symmetric/aes192_cbc` | SP 800-38A F.2.3 | Import, Encrypt (AES-192-CBC, no padding) | `Data` | -| Symmetric | `kat/symmetric/aes256_cbc` | SP 800-38A F.2.5 | Import, Encrypt (AES-256-CBC, no padding) | `Data` | -| Symmetric | `kat/symmetric/aes128_gcm` | SP 800-38D TC7 | Import, Encrypt (AES-128-GCM + AAD) | `Data`, `AuthenticatedEncryptionTag` | -| Symmetric | `kat/symmetric/aes192_gcm` | SP 800-38D TC7 | Import, Encrypt (AES-192-GCM + AAD) | `Data`, `AuthenticatedEncryptionTag` | -| Symmetric | `kat/symmetric/aes256_gcm` | SP 800-38D TC7 | Import, Encrypt (AES-256-GCM + AAD) | `Data`, `AuthenticatedEncryptionTag` | -| Symmetric | `kat/symmetric/chacha20_poly1305` | RFC 8439 §2.8 | Import, Encrypt (ChaCha20-Poly1305) | `Data`, `AuthenticatedEncryptionTag` | -| Symmetric | `kat/symmetric/chacha20_pure` | RFC 7539 §2.1 | Import, Encrypt (ChaCha20 pure stream) | `Data` | -| Symmetric | `kat/symmetric/aes128_xts` | IEEE 1619-2007 | Import, Encrypt (AES-128-XTS) | `Data` | -| Symmetric | `kat/symmetric/aes256_xts` | IEEE 1619-2007 | Import, Encrypt (AES-256-XTS) | `Data` | -| Symmetric | `kat/symmetric/rfc3394_aes128_kek` | RFC 3394 §2.2.3 | Import KEK, Import key, Encrypt (AES-128 key wrap), Decrypt | `Data` | -| Symmetric | `kat/symmetric/rfc3394_aes192_kek` | RFC 3394 §2.2.3 | Import KEK, Import key, Encrypt (AES-192 key wrap), Decrypt | `Data` | -| Symmetric | `kat/symmetric/rfc3394_aes256_kek` | RFC 3394 §2.2.3 | Import KEK, Import key, Encrypt (AES-256 key wrap), Decrypt | `Data` | -| Symmetric | `kat/symmetric/rfc5649_aes128_kek` | RFC 5649 §6 | Import KEK, Encrypt (AES-128 key wrap with padding), Decrypt | `Data` | -| Symmetric | `kat/symmetric/rfc5649_aes192_kek` | RFC 5649 §6 | Import KEK, Encrypt (AES-192 key wrap with padding), Decrypt | `Data` | -| Symmetric | `kat/symmetric/rfc5649_aes256_kek` | RFC 5649 §6 | Import KEK, Encrypt (AES-256 key wrap with padding), Decrypt | `Data` | +| **Asymmetric** | | RFC 8032 / NIST PKCS#1 / RFC 6979 | | | +| Asymmetric | `kat/asymmetric/ed25519_eddsa_sign` | RFC 8032 §7.1 | Import, Sign | `SignatureData` | +| Asymmetric (non-FIPS) | `kat/asymmetric/ed448_eddsa_sign` | RFC 8032 §7.4 | Import, Sign | `SignatureData` | +| Asymmetric | `kat/asymmetric/rsa2048_oaep_sha256_decrypt` | NIST PKCS#1 v2.2 | Import, Decrypt | `Data` | +| Asymmetric (non-FIPS) | `kat/asymmetric/secp256k1_ecdsa_sign` | RFC 6979 §A.2.5 | Import, Sign | `SignatureData` | +| **Covercrypt** | | Cosmian Covercrypt v16 | | | +| Covercrypt Decrypt (non-FIPS) | `kat/covercrypt_decrypt` | Self-generated USK | Import, Decrypt | `Data` | | **Derive Key** | | RFC 5869 / RFC 8018 | | | -| Derive Key | `kat/derive_key/hkdf_sha256` | RFC 5869 §A.1 | Import, DeriveKey (HKDF-SHA256), Get | `KeyMaterial` | -| Derive Key | `kat/derive_key/hkdf_sha384` | RFC 5869 §A.1 | Import, DeriveKey (HKDF-SHA384), Get | `KeyMaterial` | -| Derive Key | `kat/derive_key/hkdf_sha512` | RFC 5869 §A.1 | Import, DeriveKey (HKDF-SHA512), Get | `KeyMaterial` | -| Derive Key | `kat/derive_key/pbkdf2_sha256` | RFC 8018 §5.2 | Import, DeriveKey (PBKDF2-SHA256), Get | `KeyMaterial` | -| Derive Key | `kat/derive_key/pbkdf2_sha384` | RFC 8018 §5.2 | Import, DeriveKey (PBKDF2-SHA384), Get | `KeyMaterial` | -| Derive Key | `kat/derive_key/pbkdf2_sha512` | RFC 8018 §5.2 | Import, DeriveKey (PBKDF2-SHA512), Get | `KeyMaterial` | -| **Asymmetric** | | | | | -| Asymmetric | `kat/asymmetric/ed25519_eddsa_sign` | RFC 8032 §7.1 Test 2 | Import Ed25519 private key, Sign (EdDSA) | `SignatureData` | -| Asymmetric | `kat/asymmetric/rsa2048_oaep_sha256_decrypt` | NIST PKCS#1 v2.2 | Import RSA-2048 private key, Decrypt (OAEP-SHA256) | `Data` | -| **Non-FIPS Symmetric** | | RFC 8452 (AES-GCM-SIV) | | | -| Symmetric (non-FIPS) | `kat/symmetric/aes128_gcm_siv` | RFC 8452 §C.1 | Import, Encrypt (AES-128-GCM-SIV) | `Data`, `AuthenticatedEncryptionTag` | -| Symmetric (non-FIPS) | `kat/symmetric/aes256_gcm_siv` | RFC 8452 §C.1 | Import, Encrypt (AES-256-GCM-SIV) | `Data`, `AuthenticatedEncryptionTag` | -| **Non-FIPS Asymmetric** | | RFC 8032 / RFC 6979 | | | -| Asymmetric (non-FIPS) | `kat/asymmetric/ed448_eddsa_sign` | RFC 8032 §7.4 Test 1 | Import Ed448 private key, Sign (EdDSA) | `SignatureData` | -| Asymmetric (non-FIPS) | `kat/asymmetric/secp256k1_ecdsa_sign` | RFC 6979 §A.2.5 | Import secp256k1 private key, Sign (ECDSA-SHA256) | `SignatureData` | -| **Non-FIPS Covercrypt** | | Cosmian Covercrypt v16 | | | -| Covercrypt (non-FIPS) | `kat/covercrypt_decrypt` | Self-generated USK | Import USK, Decrypt (Covercrypt single-decrypt) | `Data` | +| Derive Key | `kat/derive_key/hkdf_sha256` | RFC 5869 §A.1 | Import, DeriveKey, Get | `KeyMaterial` | +| Derive Key | `kat/derive_key/hkdf_sha384` | RFC 5869 §A.1 | Import, DeriveKey, Get | `KeyMaterial` | +| Derive Key | `kat/derive_key/hkdf_sha512` | RFC 5869 §A.1 | Import, DeriveKey, Get | `KeyMaterial` | +| Derive Key | `kat/derive_key/pbkdf2_sha256` | RFC 8018 §5.2 | Import, DeriveKey, Get | `KeyMaterial` | +| Derive Key | `kat/derive_key/pbkdf2_sha384` | RFC 8018 §5.2 | Import, DeriveKey, Get | `KeyMaterial` | +| Derive Key | `kat/derive_key/pbkdf2_sha512` | RFC 8018 §5.2 | Import, DeriveKey, Get | `KeyMaterial` | +| **Hash** | | NIST FIPS 180-4 / FIPS 202 | | | +| Hash | `kat/hash/sha256` | FIPS 180-4 | Hash | `Data` | +| Hash | `kat/hash/sha384` | FIPS 202 | Hash | `Data` | +| Hash | `kat/hash/sha3_256` | FIPS 202 | Hash | `Data` | +| Hash | `kat/hash/sha3_384` | FIPS 202 | Hash | `Data` | +| Hash | `kat/hash/sha3_512` | FIPS 202 | Hash | `Data` | +| Hash | `kat/hash/sha512` | FIPS 180-4 | Hash | `Data` | +| **MAC** | | RFC 4231 / RFC 2202 / NIST HMAC-SHA3 | | | +| Mac | `kat/mac/hmac_sha1` | RFC 2202 §3 | Import, Mac | `MACData` | +| Mac | `kat/mac/hmac_sha256` | RFC 4231 §4.2 | Import, Mac | `MACData` | +| Mac | `kat/mac/hmac_sha384` | RFC 4231 §4.2 | Import, Mac | `MACData` | +| Mac | `kat/mac/hmac_sha3_256` | NIST HMAC-SHA3 | Import, Mac | `MACData` | +| Mac | `kat/mac/hmac_sha3_384` | NIST HMAC-SHA3 | Import, Mac | `MACData` | +| Mac | `kat/mac/hmac_sha3_512` | NIST HMAC-SHA3 | Import, Mac | `MACData` | +| Mac | `kat/mac/hmac_sha512` | RFC 4231 §4.2 | Import, Mac | `MACData` | +| **Recertify** | | | | | +| Recertify | `kat/recertify/replacement_and_replaced_links` | | CreateKeyPair, Certify, ReCertify, GetAttributes, GetAttributes, Destroy, Revoke, Destroy, Destroy, Revoke, Destroy | | +| Recertify | `kat/recertify/rotate_generation_counter` | | CreateKeyPair, Certify, ReCertify, GetAttributes, GetAttributes, Destroy, Revoke, Destroy, Destroy, Revoke, Destroy | `RotateGeneration` | +| Recertify | `kat/recertify/rotate_latest_flag` | | CreateKeyPair, Certify, ReCertify, GetAttributes, GetAttributes, Destroy, Revoke, Destroy, Destroy, Revoke, Destroy | `RotateLatest` | +| Recertify | `kat/recertify/state_transitions` | | CreateKeyPair, Certify, ReCertify, GetAttributes, GetAttributes, Destroy, Revoke, Destroy, Destroy, Revoke, Destroy | `State` | +| **Rekey** | | | | | +| Rekey | `kat/rekey/deactivated_accepts_decrypt` | | Create, Encrypt, ReKey, Decrypt, Destroy, Revoke, Destroy | `Data` | +| Rekey | `kat/rekey/deactivated_rejects_encrypt` | | Create, Encrypt, ReKey, Encrypt, Destroy, Revoke, Destroy | | +| Rekey | `kat/rekey/keyset_uid` | | Create, ReKey, ReKey, Destroy, Destroy, Revoke, Destroy | `UniqueIdentifier` | +| Rekey | `kat/rekey/replacement_and_replaced_links` | | Create, ReKey, GetAttributes, GetAttributes, Destroy, Revoke, Destroy | `LinkedObjectIdentifier` | +| Rekey | `kat/rekey/rotate_generation_counter` | | Create, ReKey, ReKey, GetAttributes, GetAttributes, GetAttributes, Destroy, Destroy, Revoke, Destroy | `RotateGeneration` | +| Rekey | `kat/rekey/rotate_interval_cleared` | | Create, SetAttribute, GetAttributes, ReKey, GetAttributes, Destroy, Revoke, Destroy | `RotateInterval` | +| Rekey | `kat/rekey/rotate_latest_flag` | | Create, ReKey, GetAttributes, GetAttributes, Destroy, Revoke, Destroy | `RotateLatest` | +| Rekey | `kat/rekey/state_transitions` | | Create, ReKey, GetAttributes, GetAttributes, Destroy, Revoke, Destroy | `State` | +| **Rekey_Keypair** | | | | | +| Rekey Keypair | `kat/rekey_keypair/old_pk_deactivated_accepts_verify` | | CreateKeyPair, ReKeyKeyPair, Sign, SignatureVerify, Destroy, Destroy, Revoke, Destroy, Revoke, Destroy | | +| Rekey Keypair | `kat/rekey_keypair/old_sk_deactivated_rejects_sign` | | CreateKeyPair, Sign, ReKeyKeyPair, Sign, Destroy, Destroy, Revoke, Destroy, Revoke, Destroy | | +| Rekey Keypair | `kat/rekey_keypair/replacement_links` | | CreateKeyPair, ReKeyKeyPair, GetAttributes, GetAttributes, GetAttributes, GetAttributes, Destroy, Destroy, Revoke, Destroy, Revoke, Destroy | | +| Rekey Keypair | `kat/rekey_keypair/rotate_generation_counter` | | CreateKeyPair, ReKeyKeyPair, GetAttributes, GetAttributes, GetAttributes, GetAttributes, Destroy, Destroy, Revoke, Destroy, Revoke, Destroy | `RotateGeneration` | +| Rekey Keypair | `kat/rekey_keypair/rotate_latest_flag` | | CreateKeyPair, ReKeyKeyPair, GetAttributes, GetAttributes, GetAttributes, GetAttributes, Destroy, Destroy, Revoke, Destroy, Revoke, Destroy | `RotateLatest` | +| Rekey Keypair | `kat/rekey_keypair/state_transitions` | | CreateKeyPair, ReKeyKeyPair, GetAttributes, GetAttributes, GetAttributes, GetAttributes, Destroy, Destroy, Revoke, Destroy, Revoke, Destroy | `State` | +| **Symmetric** | | NIST SP 800-38A / SP 800-38D / RFC 8439 / RFC 7539 / RFC 3394 / RFC 5649 | | | +| Symmetric | `kat/symmetric/aes128_cbc` | SP 800-38A | Import, Encrypt, Decrypt | `Data` | +| Symmetric | `kat/symmetric/aes128_ecb` | SP 800-38A | Import, Encrypt, Decrypt | `Data` | +| Symmetric | `kat/symmetric/aes128_gcm` | SP 800-38D TC7 | Import, Encrypt, Decrypt | `Data`, `AuthenticatedEncryptionTag` | +| Symmetric (non-FIPS) | `kat/symmetric/aes128_gcm_siv` | RFC 8452 §C.1 | Import, Encrypt, Decrypt | `Data`, `AuthenticatedEncryptionTag` | +| Symmetric (non-FIPS) | `kat/symmetric/aes128_xts` | IEEE 1619-2007 | Import, Encrypt, Decrypt | `Data` | +| Symmetric | `kat/symmetric/aes192_cbc` | SP 800-38A | Import, Encrypt, Decrypt | `Data` | +| Symmetric | `kat/symmetric/aes192_ecb` | SP 800-38A | Import, Encrypt, Decrypt | `Data` | +| Symmetric | `kat/symmetric/aes192_gcm` | SP 800-38D TC7 | Import, Encrypt, Decrypt | `Data`, `AuthenticatedEncryptionTag` | +| Symmetric | `kat/symmetric/aes256_cbc` | SP 800-38A | Import, Encrypt, Decrypt | `Data` | +| Symmetric | `kat/symmetric/aes256_ecb` | SP 800-38A | Import, Encrypt, Decrypt | `Data` | +| Symmetric | `kat/symmetric/aes256_gcm` | SP 800-38D TC7 | Import, Encrypt, Decrypt | `Data`, `AuthenticatedEncryptionTag` | +| Symmetric (non-FIPS) | `kat/symmetric/aes256_gcm_siv` | RFC 8452 §C.1 | Import, Encrypt, Decrypt | `Data`, `AuthenticatedEncryptionTag` | +| Symmetric (non-FIPS) | `kat/symmetric/aes256_xts` | IEEE 1619-2007 | Import, Encrypt, Decrypt | `Data` | +| Symmetric (non-FIPS) | `kat/symmetric/chacha20_poly1305` | RFC 8439 §2.8 | Import, Encrypt, Decrypt | `Data`, `AuthenticatedEncryptionTag` | +| Symmetric (non-FIPS) | `kat/symmetric/chacha20_pure` | RFC 7539 §2.1 | Import, Encrypt, Decrypt | `Data` | +| Symmetric | `kat/symmetric/rfc3394_aes128_kek` | RFC 3394 §2.2.3 | Import, Encrypt, Decrypt | `Data` | +| Symmetric | `kat/symmetric/rfc3394_aes192_kek` | RFC 3394 §2.2.3 | Import, Encrypt, Decrypt | `Data` | +| Symmetric | `kat/symmetric/rfc3394_aes256_kek` | RFC 3394 §2.2.3 | Import, Encrypt, Decrypt | `Data` | +| Symmetric | `kat/symmetric/rfc5649_aes128_kek` | RFC 5649 §6 | Import, Encrypt, Decrypt | `Data` | +| Symmetric | `kat/symmetric/rfc5649_aes192_kek` | RFC 5649 §6 | Import, Encrypt, Decrypt | `Data` | +| Symmetric | `kat/symmetric/rfc5649_aes256_kek` | RFC 5649 §6 | Import, Encrypt, Decrypt | `Data` | --- diff --git a/crate/test_kms_server/benches/http_throughput.rs b/crate/test_kms_server/benches/http_throughput.rs index abb152df36..4fd5758d25 100644 --- a/crate/test_kms_server/benches/http_throughput.rs +++ b/crate/test_kms_server/benches/http_throughput.rs @@ -26,13 +26,6 @@ use std::time::Instant; -use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; -use futures::future::join_all; -use test_kms_server::{ - TestClientOptions, init_test_logging, start_test_server_with_patch, test_config_path, -}; -use zeroize::Zeroizing; - use cosmian_kms_client::{ KmsClient, KmsClientError, kmip_0::kmip_types::{CryptographicUsageMask, HashingAlgorithm, PaddingMethod}, @@ -51,6 +44,12 @@ use cosmian_kms_client::{ }, }, }; +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use futures::future::join_all; +use test_kms_server::{ + TestClientOptions, init_test_logging, start_test_server_with_patch, test_config_path, +}; +use zeroize::Zeroizing; /// Number of concurrent HTTP tasks dispatched per Criterion iteration. /// High enough to saturate the server workers; low enough to avoid OS thread exhaustion. diff --git a/crate/test_kms_server/src/lib.rs b/crate/test_kms_server/src/lib.rs index 1f2029a4e6..cf64fc5576 100644 --- a/crate/test_kms_server/src/lib.rs +++ b/crate/test_kms_server/src/lib.rs @@ -13,6 +13,8 @@ pub use test_server::{ start_default_test_kms_server_with_privileged_users, start_default_test_kms_server_with_softhsm2_and_kek, start_default_test_kms_server_with_softhsm2_and_kek_for_vectors, + start_default_test_kms_server_with_softhsm2_for_vectors, + start_default_test_kms_server_with_softhsm2_kek_uncreated_for_vectors, start_default_test_kms_server_with_three_softhsm2, start_default_test_kms_server_with_utimaco_and_kek, start_default_test_kms_server_with_utimaco_hsm, start_test_kms_server_with_config, @@ -24,6 +26,8 @@ mod test_server; mod test_jwt; +pub mod test_env; + pub mod vector_runner; use std::sync::Once; diff --git a/crate/test_kms_server/src/test_env.rs b/crate/test_kms_server/src/test_env.rs new file mode 100644 index 0000000000..a3216e95f7 --- /dev/null +++ b/crate/test_kms_server/src/test_env.rs @@ -0,0 +1,50 @@ +//! Safe in-process environment-variable overrides for test vectors. +//! +//! `std::env::set_var` is `unsafe` in Rust 1.87+ and is forbidden by this +//! crate's `deny(unsafe_code)` policy. This module provides a thread-safe +//! alternative that the vector runner consults **before** falling back to the +//! real process environment. +//! +//! Usage (server init): +//! ```rust,ignore +//! crate::test_env::set("HSM_BOOTSTRAP_KEK_ID", &kek_id); +//! ``` +//! +//! Usage (vector runner placeholder resolution): +//! ```rust,ignore +//! let value = crate::test_env::get("HSM_BOOTSTRAP_KEK_ID") +//! .or_else(|| std::env::var("HSM_BOOTSTRAP_KEK_ID").ok()); +//! ``` + +use std::{ + collections::HashMap, + sync::{OnceLock, PoisonError, RwLock}, +}; + +static OVERRIDES: OnceLock>> = OnceLock::new(); + +fn map() -> &'static RwLock> { + OVERRIDES.get_or_init(|| RwLock::new(HashMap::new())) +} + +/// Store an in-process env override under `key`. +/// +/// # Panics +/// Panics if the internal `RwLock` has been poisoned (should never happen in +/// a normal test run). +pub fn set(key: &str, value: &str) { + map() + .write() + .unwrap_or_else(PoisonError::into_inner) + .insert(key.to_owned(), value.to_owned()); +} + +/// Look up an in-process env override. Returns `None` if not set. +#[must_use] +pub fn get(key: &str) -> Option { + map() + .read() + .unwrap_or_else(PoisonError::into_inner) + .get(key) + .cloned() +} diff --git a/crate/test_kms_server/src/test_server.rs b/crate/test_kms_server/src/test_server.rs index a3c1eae595..0cdab8d045 100644 --- a/crate/test_kms_server/src/test_server.rs +++ b/crate/test_kms_server/src/test_server.rs @@ -19,12 +19,12 @@ static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); use actix_server::ServerHandle; use cosmian_kms_client::{ GmailApiConf, KmsClient, KmsClientConfig, KmsClientError, - cosmian_kmip::{KmipResultHelper, kmip_2_1::extra::tagging::VENDOR_ID_COSMIAN, time_normalize}, + cosmian_kmip::{KmipResultHelper, kmip_2_1::extra::tagging::VENDOR_ID_COSMIAN}, kmip_0::kmip_types::CryptographicUsageMask, kmip_2_1::{ kmip_attributes::Attributes, kmip_objects::ObjectType, - kmip_operations::Create, + kmip_operations::{Create, Destroy, Locate}, kmip_types::{CryptographicAlgorithm, UniqueIdentifier}, }, kms_client_bail, kms_client_error, @@ -375,7 +375,6 @@ async fn create_kek_in_db() -> Result<(PathBuf, String), KmsClientError> { ), object_type: Some(ObjectType::SymmetricKey), unique_identifier: Some(UniqueIdentifier::TextString(kek_id.to_owned())), - activation_date: Some(time_normalize()?), ..Default::default() }, protection_storage_masks: None, @@ -496,7 +495,6 @@ async fn create_softhsm2_kek_in_db() -> Result<(PathBuf, String), KmsClientError ), object_type: Some(ObjectType::SymmetricKey), unique_identifier: Some(UniqueIdentifier::TextString(kek_id.clone())), - activation_date: Some(time_normalize()?), ..Default::default() }, protection_storage_masks: None, @@ -591,6 +589,163 @@ pub async fn start_default_test_kms_server_with_softhsm2_and_kek_for_vectors() start_server_from_config(config, &config_path).await } +/// Remove all test-vector objects (`vec_…` keys) from the active HSM slot +/// before test steps execute. +/// +/// Orphaned keys from a previous test run accumulate in the `SoftHSM2` token +/// across `cargo test` invocations. When `find(slot, Any)` is called (e.g. +/// from `is_keyset_latest`), it pre-populates the +/// [`ObjectHandlesCache`][cosmian_kms_base_hsm::ObjectHandlesCache] with a +/// handle for every object in the slot. If a handle belonging to an orphaned +/// object gets associated with a newly created key ID, the subsequent +/// `C_SetAttributeValue` call receives a stale handle and fails with +/// `CKR_OBJECT_HANDLE_INVALID` (return code 130). +/// +/// Only keys whose KMIP UID contains a `::vec_` segment are destroyed — +/// this avoids touching KEKs or other non-test objects that share the slot. +/// +/// Errors are silently swallowed: if an object is already absent or requires +/// revocation first, the deletion attempt is simply skipped. +/// +/// **Must be called while the slot-level mutex is held** (see +/// `HSM_SLOT_MUTEX` in `vector_runner.rs`) to prevent deleting keys that a +/// concurrently-running `hsm_kek` test has just created on the same slot. +pub(crate) async fn cleanup_hsm_slot_objects(client: &KmsClient) { + let locate = Locate { + attributes: Attributes::default(), + ..Default::default() + }; + let ids = match client.locate(locate).await { + Ok(resp) => resp.unique_identifier.unwrap_or_default(), + Err(e) => { + trace!("HSM slot pre-cleanup: locate failed: {e}"); + return; + } + }; + // Only target test-vector keys (key_id starts with "vec_") to avoid + // destroying KEKs or other non-test objects on the shared slot. + let test_ids: Vec<_> = ids + .into_iter() + .filter(|uid| { + let s = uid.to_string(); + // KMIP UID format: "hsm::::" — keep only vec_ keys. + s.split("::") + .nth(2) + .is_some_and(|key_id| key_id.starts_with("vec_")) + }) + .collect(); + trace!( + "HSM slot pre-cleanup: found {} test-vector object(s) to destroy", + test_ids.len() + ); + for uid in test_ids { + let req = Destroy { + unique_identifier: Some(uid.clone()), + remove: true, + ..Default::default() + }; + if let Err(e) = client.destroy(req).await { + trace!("HSM slot pre-cleanup: could not destroy {uid}: {e}"); + } + } +} + +/// Start a `SoftHSM2` test server **without** a Key Encryption Key. +/// +/// Used for test vectors that exercise HSM-resident key operations (keyset +/// addressing, re-key guards, chain-walk semantics) without any KEK wrapping +/// layer. The server otherwise has identical TLS and auth configuration to +/// [`start_default_test_kms_server_with_softhsm2_and_kek_for_vectors`]. +/// +/// The vector runner owns the singleton; this function returns a fresh +/// `TestsContext` each call. +/// +/// # Errors +/// Returns an error if the server fails to start. +/// +/// # Panics +/// Panics if `HSM_SLOT_ID` is not set or is not a valid `usize`. +pub async fn start_default_test_kms_server_with_softhsm2_for_vectors() +-> Result { + let slot = get_softhsm2_slot_id(); + // Use a unique directory for workspace/tmp (certs, temp files) but a + // **stable** path for the SQLite database. A stable DB path means that + // records for keys created by a previous (possibly failed) test run are + // still present on the next invocation. `cleanup_hsm_slot_objects` can + // then call `locate()` + `destroy()` to remove both the DB record and the + // still-resident HSM object — fixing the "A secret key with this id already + // exists" error that arises when the HSM outlives the ephemeral DB. + let workspace_dir = std::env::temp_dir().join(format!( + "kms_test_softhsm2_no_kek_{}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + // Stable across runs — cleaned up by `cleanup_hsm_slot_objects` at startup. + let stable_db_path = std::env::temp_dir().join("kms_test_hsm_vec_no_kek_sqlite"); + + let config_path = hsm_config_path("hsm_softhsm2_kek.toml"); + let mut config = load_test_config_from_toml(&config_path)?; + config.hsm.hsm_slot = vec![slot]; + config.db.sqlite_path = stable_db_path; + config.db.clear_database = false; + config.workspace.root_data_path = workspace_dir.join("workspace"); + config.workspace.tmp_path = workspace_dir.join("tmp"); + // No key_encryption_key — this is the plain HSM server (no KEK wrapping). + config.google_cse_config.google_cse_enable = false; + let ctx = start_server_from_config(config, &config_path).await?; + Ok(ctx) +} + +/// Start a `SoftHSM2` + KEK test server where the KEK has **not** been pre-created. +/// +/// This server type is used to reproduce the self-wrap regression (PR #968): +/// `wrap_and_cache` must not attempt to wrap an HSM-resident key with the +/// server-wide KEK, even when the key being created IS the KEK itself. +/// +/// Concretely, `key_encryption_key` is set to `"hsm::{slot}::kek_bootstrap"` +/// before the server starts. The first vector step creates that exact HSM key, +/// which would have triggered the self-wrap error prior to the fix. +/// +/// # Errors +/// Returns an error if the server fails to start. +/// +/// # Panics +/// Panics if `HSM_SLOT_ID` is not set or is not a valid `usize`. +pub async fn start_default_test_kms_server_with_softhsm2_kek_uncreated_for_vectors() +-> Result { + let slot = get_softhsm2_slot_id(); + let workspace_dir = std::env::temp_dir().join(format!( + "kms_test_softhsm2_kek_bootstrap_{}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let kek_id = format!("hsm::{slot}::kek_bootstrap_{}", std::process::id()); + // Export for {{$HSM_BOOTSTRAP_KEK_ID}} substitution in vector steps. + // Called once inside OnceCell initialisation before any vector steps run. + crate::test_env::set("HSM_BOOTSTRAP_KEK_ID", &kek_id); + + let config_path = hsm_config_path("hsm_softhsm2_kek.toml"); + let mut config = load_test_config_from_toml(&config_path)?; + config.hsm.hsm_slot = vec![slot]; + config.db.sqlite_path = workspace_dir.join("sqlite-data"); + config.workspace.root_data_path = workspace_dir.join("workspace"); + config.workspace.tmp_path = workspace_dir.join("tmp"); + config.key_encryption_key = Some(kek_id); + config.default_unwrap_type = Some(vec!["SecretData".to_owned(), "SymmetricKey".to_owned()]); + // Disable Google CSE: starting with an empty workspace means no Google CSE + // RSA keypair exists yet, and this test does not need that feature. + config.google_cse_config.google_cse_enable = false; + start_server_from_config(config, &config_path).await +} + /// Start a test KMS server with three `SoftHSM2` instances: /// /// - Slot 1 (`HSM_SLOT_ID_1`): legacy single-HSM config (`hsm:` top-level fields). diff --git a/crate/test_kms_server/src/vector_runner.rs b/crate/test_kms_server/src/vector_runner.rs index 5c1d511bab..1413a3bae0 100644 --- a/crate/test_kms_server/src/vector_runner.rs +++ b/crate/test_kms_server/src/vector_runner.rs @@ -12,7 +12,7 @@ use cosmian_kms_client::{ reexport::cosmian_kms_access::access::Access, }; use serde::Deserialize; -use tokio::sync::OnceCell; +use tokio::sync::{Mutex, OnceCell}; use crate::TestsContext; @@ -30,6 +30,27 @@ static ONCE_VECTOR_CERT_AUTH: OnceCell = OnceCell::const_new(); static ONCE_VECTOR_AUTH_HTTPS: OnceCell = OnceCell::const_new(); /// Singleton server for vector tests requiring `SoftHSM2` + KEK. static ONCE_VECTOR_HSM_KEK: OnceCell = OnceCell::const_new(); +/// Singleton server for vector tests where the HSM KEK is configured but **not yet created**. +/// Used to verify that `wrap_and_cache` does not attempt to self-wrap when the first +/// operation creates the KEK itself (regression for PR #968 self-wrap bug). +static ONCE_VECTOR_HSM_KEK_UNCREATED: OnceCell = OnceCell::const_new(); +/// Singleton server for vector tests requiring `SoftHSM2` **without** a KEK. +static ONCE_VECTOR_HSM: OnceCell = OnceCell::const_new(); +/// Serialises **all** HSM test vectors that target the same `SoftHSM2` slot +/// (`hsm_kek`, `hsm_kek_uncreated`, and `hsm` server types all use +/// `HSM_SLOT_ID`). +/// +/// A single slot-level mutex is required because the two KMS server instances +/// (`hsm` and `hsm_kek`) share PKCS#11 object handles across separate +/// `BaseHsm` caches. When both run concurrently, one instance can close a +/// session whose handles the other still holds in cache, producing +/// `CKR_OBJECT_HANDLE_INVALID` (130) failures in the losing thread. +static HSM_SLOT_MUTEX: Mutex<()> = Mutex::const_new(()); +/// Guards the one-time cleanup of stale `vec_*` HSM objects that accumulate +/// across `cargo test` invocations. The cleanup must run while `HSM_SLOT_MUTEX` +/// is held so that it cannot delete keys being used by a concurrently-running +/// `hsm_kek` test. +static HSM_CLEANUP_DONE: OnceCell<()> = OnceCell::const_new(); /// A test vector manifest loaded from a TOML file. /// @@ -75,6 +96,8 @@ pub struct TestManifest { /// /// Controls which singleton server is started: /// - `"hsm_kek"` — `SoftHSM2` with a Key Encryption Key (uses `ONCE_VECTOR_HSM_KEK`) + /// - `"hsm_kek_uncreated"` — `SoftHSM2` + KEK UID configured but key not yet created + /// - `"hsm"` — `SoftHSM2` without any KEK (uses `ONCE_VECTOR_HSM`) /// - anything else or omitted — standard backend-driven servers pub server_type: Option, /// Environment variables required to run this vector. @@ -430,12 +453,14 @@ fn load_request_json( )) })?; let var_name = &rest[..end]; - let var_value = std::env::var(var_name).map_err(|_e| { - KmsClientError::UnexpectedError(format!( - "Environment variable '{var_name}' referenced in {} is not set", - path.display() - )) - })?; + let var_value = crate::test_env::get(var_name) + .or_else(|| std::env::var(var_name).ok()) + .ok_or_else(|| { + KmsClientError::UnexpectedError(format!( + "Environment variable '{var_name}' referenced in {} is not set", + path.display() + )) + })?; content = format!( "{}{var_value}{}", &content[..start], @@ -471,13 +496,15 @@ fn resolve_assertion_value( let rest = &result[start + 3..]; if let Some(end) = rest.find("}}") { let var_name = &rest[..end]; - let var_value = std::env::var(var_name).map_err(|_err| { - KmsClientError::UnexpectedError(format!( - "resolve_assertion_value: environment variable '{var_name}' \ - referenced in assertion template '{template}' is not set — \ - refusing to silently use an empty string" - )) - })?; + let var_value = crate::test_env::get(var_name) + .or_else(|| std::env::var(var_name).ok()) + .ok_or_else(|| { + KmsClientError::UnexpectedError(format!( + "resolve_assertion_value: environment variable '{var_name}' \ + referenced in assertion template '{template}' is not set — \ + refusing to silently use an empty string" + )) + })?; result = format!("{}{}{}", &result[..start], var_value, &rest[end + 2..]); } else { break; @@ -715,6 +742,20 @@ fn backend_available(backend: &str) -> bool { /// Get or initialize a singleton test server for the given backend. async fn get_or_init_vector_server(backend: &str) -> Result<&'static TestsContext, KmsClientError> { + // When `KMS_TEST_DB` names the same backend as the requested vector backend, + // reuse the shared default server (`ONCE`) rather than starting a second + // server against the same database. Two independent servers each configured + // with `clear_database = true` pointing at the same DB would race: whichever + // initialises second wipes out objects that the other has already written, + // causing non-deterministic "object not found" failures in the certify tests. + let effective_kms_db = std::env::var("KMS_TEST_DB").ok().map(|v| match v.as_str() { + "redis" => "redis-findex".to_owned(), + other => other.to_owned(), + }); + if effective_kms_db.as_deref() == Some(backend) { + return Ok(crate::start_default_test_kms_server().await); + } + let root = repo_root()?; let (cell, toml, env_var) = match backend { "postgresql" => (&ONCE_VECTOR_POSTGRESQL, "postgres.toml", "KMS_POSTGRES_URL"), @@ -781,7 +822,7 @@ pub async fn run_test_vector(vector_dir: &str) -> Result<(), KmsClientError> { // Check required environment variables; skip gracefully if any is missing for env_var in &manifest.requires_env { - if std::env::var(env_var).is_err() { + if crate::test_env::get(env_var).is_none() && std::env::var(env_var).is_err() { eprintln!( "SKIP vector '{}': required env var '{env_var}' is not set", manifest.name @@ -800,12 +841,50 @@ pub async fn run_test_vector(vector_dir: &str) -> Result<(), KmsClientError> { .await }) .await?; + // Serialise PKCS#11 access: SoftHSM2 state is not safe under concurrent + // access on the same slot (CKR_OBJECT_HANDLE_INVALID races). + let _hsm_guard = HSM_SLOT_MUTEX.lock().await; eprintln!( "▶ Running vector '{}' on server_type 'hsm_kek'", manifest.name ); return execute_steps(context, &manifest, &vector_path).await; } + "hsm_kek_uncreated" => { + let context = ONCE_VECTOR_HSM_KEK_UNCREATED + .get_or_try_init(|| async { + crate::start_default_test_kms_server_with_softhsm2_kek_uncreated_for_vectors() + .await + }) + .await?; + let _hsm_guard = HSM_SLOT_MUTEX.lock().await; + eprintln!( + "▶ Running vector '{}' on server_type 'hsm_kek_uncreated'", + manifest.name + ); + return execute_steps(context, &manifest, &vector_path).await; + } + "hsm" => { + let context = ONCE_VECTOR_HSM + .get_or_try_init(|| async { + crate::start_default_test_kms_server_with_softhsm2_for_vectors().await + }) + .await?; + // Serialise PKCS#11 access: all HSM server types share the same slot. + let _hsm_guard = HSM_SLOT_MUTEX.lock().await; + // Purge stale `vec_*` objects from previous test runs exactly once, + // while the slot mutex is held (prevents deleting keys that an + // `hsm_kek` test just created on the shared slot). + HSM_CLEANUP_DONE + .get_or_try_init(|| async { + crate::test_server::cleanup_hsm_slot_objects(&context.get_owner_client()) + .await; + Ok::<(), cosmian_kms_client::KmsClientError>(()) + }) + .await?; + eprintln!("▶ Running vector '{}' on server_type 'hsm'", manifest.name); + return execute_steps(context, &manifest, &vector_path).await; + } other => { return Err(KmsClientError::UnexpectedError(format!( "Unknown server_type '{other}' in manifest for vector '{}'", @@ -1574,6 +1653,18 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/rekey_locate_by_name").await } + #[tokio::test] + async fn test_vec_rekey_deactivated_succeeds() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_deactivated_succeeds").await + } + + #[tokio::test] + async fn test_vec_rekey_compromised_succeeds() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_compromised_succeeds").await + } + #[tokio::test] async fn test_vec_rekey_deactivated_fails() -> Result<(), KmsClientError> { crate::init_test_logging(); @@ -1610,12 +1701,70 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/rekey_old_key_still_decrypts").await } + #[tokio::test] + async fn test_vec_rekey_old_key_decrypt_succeeds() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_old_key_decrypt_succeeds") + .await + } + + #[tokio::test] + async fn test_vec_rekey_manual_clears_interval() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_manual_clears_interval").await + } + + #[tokio::test] + async fn test_vec_rekey_manual_clears_offset() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_manual_clears_offset").await + } + + #[tokio::test] + async fn test_vec_rekey_keypair_rsa_old_decrypts() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_keypair_rsa_old_decrypts") + .await + } + + #[tokio::test] + async fn test_vec_rekey_mac_keyset() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_mac_keyset").await + } + #[tokio::test] async fn test_vec_rekey_kmip14() -> Result<(), KmsClientError> { crate::init_test_logging(); run_test_vector("test_data/vectors/fips/kmip_operations/rekey_kmip14").await } + #[tokio::test] + async fn test_vec_rekey_wrapping_key() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_wrapping_key").await + } + + #[tokio::test] + async fn test_vec_rekey_wrapped_key() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_wrapped_key").await + } + + #[tokio::test] + async fn test_vec_rekey_wrapping_key_with_links() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_wrapping_key_with_links") + .await + } + + #[tokio::test] + async fn test_vec_rekey_wrapping_key_double_chain() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_wrapping_key_double_chain") + .await + } + #[cfg(feature = "non-fips")] #[tokio::test] async fn test_vec_rekey_keypair_kmip14() -> Result<(), KmsClientError> { @@ -2586,6 +2735,123 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/kat/covercrypt_decrypt").await } + // ── KAT: ReKey (symmetric) lifecycle ───────────────────────────────── + + #[tokio::test] + async fn test_kat_rekey_state_transitions() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey/state_transitions").await + } + + #[tokio::test] + async fn test_kat_rekey_rotate_generation_counter() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey/rotate_generation_counter").await + } + + #[tokio::test] + async fn test_kat_rekey_rotate_latest_flag() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey/rotate_latest_flag").await + } + + #[tokio::test] + async fn test_kat_rekey_rotate_interval_cleared() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey/rotate_interval_cleared").await + } + + #[tokio::test] + async fn test_kat_rekey_keyset_uid() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey/keyset_uid").await + } + + #[tokio::test] + async fn test_kat_rekey_replacement_and_replaced_links() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey/replacement_and_replaced_links").await + } + + #[tokio::test] + async fn test_kat_rekey_deactivated_rejects_encrypt() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey/deactivated_rejects_encrypt").await + } + + #[tokio::test] + async fn test_kat_rekey_deactivated_accepts_decrypt() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey/deactivated_accepts_decrypt").await + } + + // ── KAT: ReKeyKeyPair (asymmetric) lifecycle ────────────────────────── + + #[tokio::test] + async fn test_kat_rekey_keypair_state_transitions() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey_keypair/state_transitions").await + } + + #[tokio::test] + async fn test_kat_rekey_keypair_rotate_generation_counter() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey_keypair/rotate_generation_counter").await + } + + #[tokio::test] + async fn test_kat_rekey_keypair_rotate_latest_flag() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey_keypair/rotate_latest_flag").await + } + + #[tokio::test] + async fn test_kat_rekey_keypair_replacement_links() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey_keypair/replacement_links").await + } + + #[tokio::test] + async fn test_kat_rekey_keypair_old_sk_deactivated_rejects_sign() -> Result<(), KmsClientError> + { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey_keypair/old_sk_deactivated_rejects_sign").await + } + + #[tokio::test] + async fn test_kat_rekey_keypair_old_pk_deactivated_accepts_verify() -> Result<(), KmsClientError> + { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/rekey_keypair/old_pk_deactivated_accepts_verify") + .await + } + + // ── KAT: ReCertify lifecycle ────────────────────────────────────────── + + #[tokio::test] + async fn test_kat_recertify_state_transitions() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/recertify/state_transitions").await + } + + #[tokio::test] + async fn test_kat_recertify_rotate_generation_counter() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/recertify/rotate_generation_counter").await + } + + #[tokio::test] + async fn test_kat_recertify_rotate_latest_flag() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/recertify/rotate_latest_flag").await + } + + #[tokio::test] + async fn test_kat_recertify_replacement_and_replaced_links() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/kat/recertify/replacement_and_replaced_links").await + } + // ── non-FIPS: CryptographicParameters coverage ─────────────────────── #[cfg(feature = "non-fips")] @@ -3256,6 +3522,24 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/negative/set_attribute/read_only_attribute").await } + #[tokio::test] + async fn test_neg_hsm_rotate_offset_rejected() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/set_attribute/hsm_rotate_offset_rejected").await + } + + #[tokio::test] + async fn test_neg_set_attribute_readonly_rotate_generation() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/set_attribute/readonly_rotate_generation").await + } + + #[tokio::test] + async fn test_neg_set_attribute_readonly_rotate_date() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/set_attribute/readonly_rotate_date").await + } + #[tokio::test] async fn test_neg_spec_sign_invalid_message() -> Result<(), KmsClientError> { crate::init_test_logging(); @@ -3331,34 +3615,6 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/batch_hash_query").await } - // ── KMIP operations: ReCertify ────────────────────────────────────── - - // #[tokio::test] - // async fn test_vec_recertify_chain() -> Result<(), KmsClientError> { - // crate::init_test_logging(); - // run_test_vector("test_data/vectors/fips/kmip_operations/recertify_chain").await - // } - - // #[tokio::test] - // async fn test_vec_recertify_self_signed() -> Result<(), KmsClientError> { - // crate::init_test_logging(); - // run_test_vector("test_data/vectors/fips/kmip_operations/recertify_self_signed").await - // } - - // #[tokio::test] - // async fn test_vec_recertify_with_links() -> Result<(), KmsClientError> { - // crate::init_test_logging(); - // run_test_vector("test_data/vectors/fips/kmip_operations/recertify_with_links").await - // } - - // #[tokio::test] - // async fn test_vec_recertify_with_offset() -> Result<(), KmsClientError> { - // crate::init_test_logging(); - // run_test_vector("test_data/vectors/fips/kmip_operations/recertify_with_offset").await - // } - - // ── KMIP operations: ReKey with offset/state ───────────────────────── - #[cfg(feature = "non-fips")] #[tokio::test] async fn test_vec_rekey_keypair_with_offset_state() -> Result<(), KmsClientError> { @@ -3374,6 +3630,49 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/rekey_with_offset_state").await } + #[tokio::test] + async fn test_vec_rekey_wrapped_deactivated_succeeds() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_wrapped_deactivated_succeeds") + .await + } + + #[tokio::test] + async fn test_neg_rekey_preactive_fails() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/rekey_preactive_fails").await + } + + #[tokio::test] + async fn test_neg_rekey_offset_preactive_cannot_encrypt() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/rekey_offset_preactive_cannot_encrypt").await + } + + #[tokio::test] + async fn test_neg_rekey_keypair_preactive_fails() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/rekey_keypair_preactive_fails").await + } + + #[tokio::test] + async fn test_neg_rekey_non_latest_sql() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/rekey_non_latest_sql").await + } + + #[tokio::test] + async fn test_neg_rekey_non_latest_hsm() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/rekey_non_latest_hsm").await + } + + #[tokio::test] + async fn test_neg_rekey_keypair_non_latest() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/rekey_keypair_non_latest").await + } + // ── KMIP operations: ReKeyKeyPair (non-FIPS only) ──────────────────── // These vectors do not supply PrivateKeyAttributes/PublicKeyAttributes with // FIPS-compliant CryptographicUsageMask values, and some use PQC algorithms @@ -3430,6 +3729,14 @@ ObjectType = "SymmetricKey" .await } + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_rekey_keypair_rsa_sign_verify() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_keypair_rsa_sign_verify") + .await + } + #[cfg(feature = "non-fips")] #[tokio::test] async fn test_vec_rekey_keypair_p384() -> Result<(), KmsClientError> { @@ -3451,6 +3758,13 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/rekey_keypair_rsa4096").await } + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_rekey_keypair_ml_kem_512() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_keypair_ml_kem_512").await + } + #[cfg(feature = "non-fips")] #[tokio::test] async fn test_vec_rekey_keypair_ml_kem_768() -> Result<(), KmsClientError> { @@ -3465,6 +3779,13 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/rekey_keypair_ml_kem_1024").await } + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_rekey_keypair_ml_dsa_44() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_keypair_ml_dsa_44").await + } + #[cfg(feature = "non-fips")] #[tokio::test] async fn test_vec_rekey_keypair_ml_dsa_65() -> Result<(), KmsClientError> { @@ -3501,6 +3822,14 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/rekey_keypair_double_chain").await } + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_rekey_keypair_deactivated_succeeds() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/rekey_keypair_deactivated_succeeds") + .await + } + #[cfg(feature = "non-fips")] #[tokio::test] async fn test_vec_rekey_keypair_deactivated_fails() -> Result<(), KmsClientError> { @@ -3589,6 +3918,36 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/certify_revoke_validate").await } + // ── KMIP operations: ReCertify ────────────────────────────────────── + + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_recertify_self_signed() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/recertify_self_signed").await + } + + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_recertify_chain() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/recertify_chain").await + } + + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_recertify_with_links() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/recertify_with_links").await + } + + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_recertify_with_offset() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/recertify_with_offset").await + } + // ── KMIP operations: Locate filters ───────────────────────────────── #[tokio::test] @@ -3725,6 +4084,134 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/hsm/kek_ed25519_create_sign").await } + #[tokio::test] + async fn test_vec_hsm_kek_rekey_wrapped() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/kek_rekey_wrapped").await + } + + #[tokio::test] + async fn test_vec_hsm_kek_rekey_kek() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/kek_rekey_kek").await + } + + /// Regression test for the HSM self-wrap bug (PR #968): + /// `wrap_and_cache` must not attempt to wrap an HSM-resident key with the + /// server-wide KEK when the key being created IS the configured KEK UID. + #[tokio::test] + async fn test_vec_hsm_kek_bootstrap_self_create() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/kek_bootstrap_self_create").await + } + + // ── HSM Resident: Keyset (rotate_name / CKA_LABEL) ─────────────────── + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_set_rotate_name() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_set_rotate_name").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_rekey_and_decrypt() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_rekey_and_decrypt").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_double_rotation() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_double_rotation").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_full_lifecycle() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_full_lifecycle").await + } + + // ── HSM No-KEK: Keyset addressing, re-key guards, chain-walk semantics ── + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_no_kek_basic() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_no_kek_basic").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_no_kek_addressing() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_no_kek_addressing").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_no_kek_consecutive() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_no_kek_consecutive").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_no_kek_uid_lifecycle() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_no_kek_uid_lifecycle").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_no_kek_rekey_non_latest() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_no_kek_rekey_non_latest").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_no_kek_rekey_by_name() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_no_kek_rekey_by_name").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_no_kek_rekey_by_hsm_uid() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_no_kek_rekey_by_hsm_uid").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_no_kek_rekey_by_keyset_name() -> Result<(), KmsClientError> + { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_no_kek_rekey_by_keyset_name").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_no_kek_duplicate_rekey() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_no_kek_duplicate_rekey").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_no_kek_encrypt_gen_select() -> Result<(), KmsClientError> + { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_no_kek_encrypt_gen_select").await + } + + // ── HSM Negative: Keyset name constraints ───────────────────────────── + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_rotate_name_bare_rejected() -> Result<(), KmsClientError> + { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_rotate_name_bare_rejected").await + } + + #[tokio::test] + async fn test_vec_hsm_resident_keyset_rotate_name_gen_suffix_rejected() + -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/hsm/resident_keyset_rotate_name_gen_suffix_rejected") + .await + } + #[tokio::test] #[cfg(not(feature = "non-fips"))] async fn test_vec_hsm_kek_rsa1024_rejected() -> Result<(), KmsClientError> { @@ -3964,11 +4451,11 @@ ObjectType = "SymmetricKey" .await } - // #[tokio::test] - // async fn test_vec_serial_rsa_sign_verify() -> Result<(), KmsClientError> { - // crate::init_test_logging(); - // run_test_vector("test_data/vectors/fips/serialization/rsa_sign_verify_roundtrip").await - // } + #[tokio::test] + async fn test_vec_serial_rsa_sign_verify() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/serialization/rsa_sign_verify_roundtrip").await + } #[tokio::test] async fn test_vec_serial_attributes_preservation() -> Result<(), KmsClientError> { @@ -3981,4 +4468,194 @@ ObjectType = "SymmetricKey" crate::init_test_logging(); run_test_vector("test_data/vectors/fips/serialization/import_destroy_reimport").await } + + // ─── Keyset resolution & try-each-key vectors ──────────────────────────── + + #[tokio::test] + async fn test_vec_keyset_encrypt_latest() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_encrypt_latest").await + } + + #[tokio::test] + async fn test_vec_keyset_encrypt_bare_name() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_encrypt_bare_name").await + } + + #[tokio::test] + async fn test_vec_keyset_decrypt_try_each() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_decrypt_try_each").await + } + + #[tokio::test] + async fn test_vec_keyset_decrypt_double_rotation() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_decrypt_double_rotation") + .await + } + + #[tokio::test] + async fn test_vec_keyset_encrypt_latest_after_rotation() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector( + "test_data/vectors/fips/kmip_operations/keyset_encrypt_latest_after_rotation", + ) + .await + } + + #[tokio::test] + async fn test_vec_keyset_decrypt_at_latest() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_decrypt_at_latest").await + } + + #[tokio::test] + async fn test_vec_keyset_encrypt_at_first() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_encrypt_at_first").await + } + + #[tokio::test] + async fn test_vec_keyset_encrypt_at_generation_n() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_encrypt_at_generation_n") + .await + } + + #[tokio::test] + async fn test_vec_keyset_decrypt_at_first() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_decrypt_at_first").await + } + + #[tokio::test] + async fn test_vec_keyset_decrypt_at_generation_n() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_decrypt_at_generation_n") + .await + } + + #[tokio::test] + async fn test_vec_keyset_rotate_name_at_rejected() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/keyset_rotate_name_at_rejected").await + } + + #[tokio::test] + async fn test_vec_keyset_invalid_generation() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/keyset_invalid_generation").await + } + + // ── Process-window (ProtectStopDate / ProcessStartDate) ─────────────────── + + #[tokio::test] + async fn test_vec_process_window_encrypt_expired_fails() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector( + "test_data/vectors/fips/kmip_operations/process_window_encrypt_expired_fails", + ) + .await + } + + #[tokio::test] + async fn test_vec_process_window_encrypt_not_yet_active_fails() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector( + "test_data/vectors/fips/kmip_operations/process_window_encrypt_not_yet_active_fails", + ) + .await + } + + #[tokio::test] + async fn test_vec_keyset_chain_skips_expired_window() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_chain_skips_expired_window") + .await + } + + #[tokio::test] + async fn test_vec_keyset_encrypt_expired_window_fails() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector( + "test_data/vectors/fips/kmip_operations/keyset_encrypt_expired_window_fails", + ) + .await + } + + #[tokio::test] + async fn test_vec_keyset_uid_scheme() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_uid_scheme").await + } + + #[tokio::test] + async fn test_vec_keyset_gen0_via_address() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_gen0_via_address").await + } + + #[tokio::test] + async fn test_vec_keyset_create_uid_mismatch_fails() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/keyset_create_uid_mismatch_fails").await + } + + #[tokio::test] + async fn test_vec_keyset_create_no_uid_with_rotate_name_fails() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/keyset_create_no_uid_with_rotate_name_fails") + .await + } + + #[tokio::test] + async fn test_vec_keyset_setattribute_uid_mismatch_fails() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/keyset_setattribute_uid_mismatch_fails").await + } + + #[tokio::test] + async fn test_vec_keyset_addattribute_uid_mismatch_fails() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/keyset_addattribute_uid_mismatch_fails").await + } + + // ─── Keyset sign/verify chain walk ──────────────────────────────────── + + #[tokio::test] + async fn test_vec_keyset_ec_sign_verify_chain() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_ec_sign_verify_chain").await + } + + #[tokio::test] + async fn test_vec_keyset_mac_verify_chain() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_mac_verify_chain").await + } + + // ─── Keyset GetAttributes resolution ───────────────────────────────── + + #[tokio::test] + async fn test_vec_keyset_getattributes_resolution() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/keyset_getattributes_resolution") + .await + } + + // ─── ReCertify gaps ────────────────────────────────────────────────── + + // The CreateKeyPair step uses ECDH + mask in CommonAttributes, consistent + // with all other ReCertify test vectors (which are also #[cfg(feature = "non-fips")]). + // In FIPS mode the private_key_mask must be explicit in PrivateKeyAttributes; + // a CommonAttributes-only mask gives None and fails the FIPS check. + #[cfg(feature = "non-fips")] + #[tokio::test] + async fn test_vec_recertify_old_cert_stays_active() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/fips/kmip_operations/recertify_old_cert_stays_active") + .await + } } diff --git a/documentation/docs/adr/0002-key-auto-rotation-keyset-chain-design.md b/documentation/docs/adr/0002-key-auto-rotation-keyset-chain-design.md new file mode 100644 index 0000000000..4c8933fd13 --- /dev/null +++ b/documentation/docs/adr/0002-key-auto-rotation-keyset-chain-design.md @@ -0,0 +1,270 @@ +--- +title: "ADR-0002: KMIP-Compliant Key Auto-Rotation with Keyset Chain Design" +status: "Accepted" +date: "2026-06-21" +authors: "contributors, security architects, HSM operators" +tags: ["architecture", "decision", "cryptography", "kmip", "key-management"] +supersedes: "" +superseded_by: "" +--- + +# ADR-0002: KMIP-Compliant Key Auto-Rotation with Keyset Chain Design + +## Status + +Accepted + +## Context + +The Cosmian KMS must support systematic cryptographic key rotation — replacing old key material with +new material on a schedule or on demand — while satisfying several hard constraints: + +1. **KMIP 2.1 compliance**: rotation must be exposed as standard `Re-Key` (§6.1.46), + `Re-Key Key Pair` (§6.1.47), and `ReCertify` (§4.8) operations, not as KMS-proprietary APIs. +2. **Backward decryption compatibility**: ciphertexts encrypted under generation N must remain + decryptable after the key is rotated to generation N+1. The rotation chain must be walkable. +3. **Wrapping-key cascades**: rotating a wrapping key must atomically re-wrap all objects currently + protected by it; partial re-wrap leaves objects unreadable. +4. **HSM-resident keys**: PKCS#11 keys are non-extractable; rotation must happen inside the HSM via + `C_GenerateKey`, not via software key material manipulation. +5. **Automated scheduling**: operators must be able to set a rotation interval (seconds) and an + optional offset so the server auto-rotates keys on a background cron task. +6. **Multi-backend portability**: the design must work across SQLite, PostgreSQL, MySQL, and + Redis-Findex without database-specific rotation code in the operation handlers. +7. **Security invariants**: only the latest generation in a chain may be re-keyed; rotation + attributes such as `rotate_generation` and `rotate_date` must be server-managed (read-only from + the client's perspective). + +Prior to this change the KMS had no rotation primitives at all; every key was a standalone object +with no chain membership concept. + +## Decision + +### 1 — Keyset identity via KMIP attributes + +A **keyset** is a named sequence of key generations identified by six KMIP attributes stored +directly on every key object: + +| Attribute (`Attribute` enum variant) | Rust struct field | Type | Semantics | +|---|---|---|---| +| `RotateName` | `rotate_name` | `TextString` | Keyset name shared by all generations | +| `RotateGeneration` | `rotate_generation` | `Integer` | Monotonically increasing counter (0 = initial) | +| `RotateLatest` | `rotate_latest` | `Boolean` | `true` only on the newest generation | +| `RotateDate` | `rotate_date` | `DateTime` | Server-set timestamp of last successful rotation | +| `RotateInterval` | `rotate_interval` | `Integer` | Auto-rotation period in seconds (0 = disabled) | +| `RotateOffset` | `rotate_offset` | `Integer` | Offset added to `initial_date` for first activation | + +These are native KMIP `Attribute` enum variants (not vendor attributes); they are serialised as +standard KMIP TTLV attributes. There is no separate "keyset" object in the database. + +Keyset members are addressed via an extended `UniqueIdentifier` syntax: +`@latest`, `@first`, `@` (resolved server-side before the operation +executes). `@latest` and bare `` both resolve to the key with the highest +`RotateGeneration` value. + +### 2 — `RekeyOperation` trait and two-phase commit + +A `RekeyOperation` trait unifies the rotation logic for symmetric keys, key pairs, and +certificates: + +```text +Phase 1 — Prepare new key: + validate() → verify permissions, lifecycle, no crypto-param change + generate_replacement() → create new key material (or new certificate) + detect_wrapping() → determine if old key was wrapped + persist_new_key() → insert new object with rotate_generation+1, rotate_latest=true + +Phase 2 — Commit: + retire_old_key() → rotate_latest=false, set deactivation date on old key + rewrap_dependants() → find all keys wrapped by old key, re-wrap with new key + finalize_dependants() → update wrapping-key links on dependant objects +``` + +The `execute_rekey()` orchestrator in `common.rs` runs these phases in order. +If Phase 2 fails the new key has already been persisted; the old key remains `Active` so no +ciphertext is unreadable. A future cleanup pass can detect and complete the commit +(see IMP-001). + +### 3 — `ObjectsStore` trait extensions for keyset queries + +Two new methods are added to the `ObjectsStore` trait with default no-op implementations +(enabling gradual rollout and backward-compatible trait evolution): + +- `find_wrapped_by(wrapping_key_uid, owner)` — returns all keys wrapped by a given key. + SQL backends use a JSON path query on the serialised object column; + Redis-Findex uses a Findex keyword index `wrapped_by::`. +- `find_by_rotate_name(name, generation, owner)` — returns all generations of a keyset. + SQL backends use a JSON path query on the attributes column (`$.RotateName`); + HSM backends enumerate PKCS#11 objects by `CKA_LABEL` and call `parse_label_metadata()`; + Redis-Findex uses a Findex keyword index `rotate_name::`. +- `find_due_for_rotation(now)` — returns UIDs of Active keys with `rotate_interval > 0` + whose next rotation instant (`rotate_date + rotate_interval` or + `initial_date + rotate_interval + rotate_offset`) is ≤ `now`. + +### 4 — HSM keyset via `CKA_LABEL` + +HSM-resident keys cannot carry arbitrary KMIP attributes; PKCS#11 offers only a fixed +attribute set. Keyset metadata is encoded in `CKA_LABEL` using the convention: + +```text +::::[::latest] +``` + +- `SetAttribute(RotateName)` on an HSM key writes `CKA_LABEL = "::0::::latest"` + (generation 0, `::latest` suffix present on initial creation). +- `SetAttribute(RotateInterval)` writes `CKA_START_DATE`/`CKA_END_DATE` (ceiling-day conversion; + intervals below 86400 s are rejected; `0` clears the dates). +- `Re-Key` on an HSM UID calls `C_GenerateKey`, computes `new_gen = old_gen + 1`, then writes: + - old key: `CKA_LABEL = "::::"` (no `::latest` suffix) + - new key: `CKA_LABEL = "::::"` (no `::latest` suffix) + +The `::latest` suffix written on initial creation is accepted by `parse_label_metadata()` for +backward compatibility but is **not written by Re-Key**; the latest generation is determined by +comparing `RotateGeneration` values, not by the suffix. + +`find_by_rotate_name` enumerates PKCS#11 objects, parses their `CKA_LABEL` via +`parse_label_metadata()`, filters by name prefix, and sorts by `rotate_generation`. + +### 5 — Try-each keyset decryption + +`Decrypt`, `SignatureVerify`, and `MACVerify` support a bare keyset name as the +`UniqueIdentifier`. The server resolves it via `walk_keyset_chain()`, which orders generations +newest→oldest and tries each key in turn until one succeeds. A configurable +`--keyset-warn-depth` threshold (default: 5) causes a server-side `warn!` log entry when the +successful key is at depth ≥ the threshold, prompting operators to re-encrypt old ciphertexts. +No response header is added to the client response. + +### 6 — Background rotation scheduler + +`cron.rs` (`spawn_auto_rotation_cron`) spawns a dedicated native thread that owns a single- +threaded Tokio runtime. On each tick (driven by `tokio::time::interval`) it calls +`run_auto_rotation(kms)`, which queries `find_due_for_rotation(now)` and dispatches a +`Re-Key` or `Re-Key Key Pair` for each due UID. The thread is started only when +`auto_rotation_check_interval_secs > 0` (disabled by default); the minimum allowed interval +is 60 s. + +The cron wiring and scheduler infrastructure are fully implemented. The rotation dispatch +inside `run_auto_rotation` (i.e. triggering the actual `Re-Key` operation per UID) is +marked as a TODO stub and is not yet implemented. + +### 7 — Security guardrails enforced server-side + +- `RotateGeneration`, `RotateDate`, and `RotateLatest` are rejected in AddAttribute, + SetAttribute, ModifyAttribute, and DeleteAttribute — they are written exclusively by + rotation operations. +- Only the key with the highest `RotateGeneration` and `RotateLatest = true` may be the + subject of a `Re-Key` request; attempting to rotate a retired generation returns an + explicit error. +- `Re-Key` and `ReCertify` accept only `Active` or `Deactivated` source objects; `PreActive`, + `Compromised`, `Destroyed`, and `Destroyed_Compromised` are rejected per KMIP §6.1.46. +- `RotateName` values containing `@` are rejected to prevent keyset versioning syntax injection. + +## Consequences + +### Positive + +- **POS-001**: Full KMIP 2.1 compliance — `Re-Key`, `Re-Key Key Pair`, and `ReCertify` are + standard operations; any KMIP-conformant client can trigger rotation without KMS-proprietary + extensions. +- **POS-002**: Backward decryption compatibility is maintained transparently; existing ciphertexts + remain decryptable through the rotation chain without client-side key-management changes. +- **POS-003**: Wrapping-key cascades are atomic at the application level — no orphaned wrapped keys + after rotation. +- **POS-004**: HSM keyset support is achieved without modifying PKCS#11 key storage; keyset + metadata is carried entirely in `CKA_LABEL`. +- **POS-005**: The `RekeyOperation` trait makes it straightforward to add new key-type rotation + (e.g. Covercrypt, JOSE) by implementing only the type-specific `validate` and + `generate_replacement` methods. +- **POS-006**: Automated scheduling is fully server-side; operators set a policy once and rotation + happens without external cron jobs or CA intervention. + +### Negative + +- **NEG-001**: The two-phase commit is not truly atomic at the database level. A process crash + between Phase 1 (new key persisted) and Phase 2 (old key retired) leaves the database in a + state where both generations have `rotate_latest = true`. A recovery sweep is not yet + implemented. +- **NEG-002**: `find_by_rotate_name` and `find_wrapped_by` use JSON path queries on serialised + object columns (SQL backends). These queries are not indexed and will degrade on very large + object counts without adding a materialised index column for `rotate_name`. +- **NEG-003**: The `@version` keyset resolution syntax is applied at the KMIP + `UniqueIdentifier` layer as a KMS extension. KMIP-conformant clients that validate the + `UniqueIdentifier` format strictly may reject these identifiers before sending them to the + server. +- **NEG-004**: HSM `rotate_offset` is not supported (HSM rotation scheduling uses + `CKA_START_DATE`/`CKA_END_DATE`); attempting to set it returns `NotSupported`. + +## Alternatives Considered + +### Re-import pattern (external rotation) + +- **ALT-001 Description**: Revoke the old key; generate new key material outside the KMS; + import the new key; update all client references to the new UID. +- **ALT-002 Rejection Reason**: Breaks backward decryption compatibility (old ciphertexts + become unreadable). Requires clients to track UID changes. Not compatible with non-extractable + HSM keys. No KMIP-standard way to express the replacement relationship. + +### Generation suffix in UID + +- **ALT-003 Description**: Embed the generation counter directly in the object UUID + (e.g. `base-uuid-gen-2`), making the UID opaque-but-structured. +- **ALT-004 Rejection Reason**: Violates KMIP §3.1 ("Unique Identifier is opaque to clients"). + Requires all clients to understand the suffix convention. Breaks existing object references + stored in external systems. + +### KMIP `ReplacedObjectLink` chain + +- **ALT-005 Description**: Use the standard KMIP `ReplacedObjectLink` / `ReplacementObjectLink` + link types to form a singly-linked list of generations; walk the chain for decryption. +- **ALT-006 Rejection Reason**: Requires O(N) sequential DB lookups to walk a chain of N + generations. No efficient batch query for "all members of keyset X". Does not cleanly address + HSM objects where KMIP links cannot be persisted. The `rotate_name` attribute approach allows + O(1) lookup of the latest generation and efficient batch retrieval. + +### Separate rotation microservice + +- **ALT-007 Description**: Implement rotation as a sidecar or external service that polls the + KMS and issues standard KMIP `Re-Key` requests. +- **ALT-008 Rejection Reason**: Adds operational complexity (separate deployment unit, secret + management for the sidecar's KMS credentials). Does not address the wrapping-key cascade + problem (the sidecar would need full read/write access to re-wrap dependants). Latency + between the sidecar and KMS creates a window where old and new keys coexist without the new + key being committed. + +## Implementation Notes + +- **IMP-001**: The `execute_rekey()` two-phase commit should be hardened with a `PendingRotation` + table or a monotonic `rotate_commit_token` attribute so that crashed Phase-1-only rotations can + be detected and completed or rolled back by the scheduler. +- **IMP-002**: For SQL backends, consider adding a materialised `rotate_name` column to the + `objects` table and a composite index `(rotate_name, rotate_generation)` to avoid full-table + JSON scans as keyset sizes grow. +- **IMP-003**: The server-side `warn!` log emitted at `keyset_warn_depth` should be forwarded + to an OTEL log exporter and monitored to track re-encryption debt across deployments. + A future improvement is to expose this as an OTEL counter `kms.keyset_decrypt_depth`. +- **IMP-004**: HSM keyset recovery after a `Re-Key` crash must update `CKA_LABEL` on both the + old key (strip `::latest` suffix if present) and the new key; key ordering relies on the + parsed `rotate_generation` integer in the label, not the `::latest` suffix. + +## References + +- **REF-001**: ADR-0001 — Unwrapped-cache configurable max size + (`documentation/docs/adr/0001-unwrapped-cache-configurable-max-size.md`) +- **REF-002**: KMIP 2.1 specification §6.1.46 (Re-Key), §6.1.47 (Re-Key Key Pair), §4.8 + (ReCertify), §3.31 (Key state lifecycle), §3.1 (Unique Identifier) +- **REF-003**: Key auto-rotation design document + (`documentation/docs/kmip_support/key_rotation/index.md`) +- **REF-004**: `RekeyOperation` trait and orchestrator + (`crate/server/src/core/operations/rekey/common.rs`) +- **REF-005**: `ObjectsStore` trait extensions + (`crate/interfaces/src/stores/objects_store.rs`) +- **REF-006**: Background rotation scheduler + (`crate/server/src/cron.rs`) +- **REF-007**: HSM keyset implementation + (`crate/interfaces/src/hsm/hsm_store.rs`) +- **REF-008**: SQL keyset queries + (`crate/server_database/src/stores/sql/query.sql`, + `crate/server_database/src/stores/sql/query_mysql.sql`) +- **REF-009**: PKCS#11 specification v2.40 — `CKA_LABEL`, `CKA_START_DATE`, `CKA_END_DATE` +- **REF-010**: PR #968 — auto-rotation feature implementation + (`https://github.com/Cosmian/kms/pull/968`) diff --git a/documentation/docs/configuration/log-reference.md b/documentation/docs/configuration/log-reference.md index a15dfc2576..7bfe1fb8e4 100644 --- a/documentation/docs/configuration/log-reference.md +++ b/documentation/docs/configuration/log-reference.md @@ -3,12 +3,13 @@ This page lists every production log call-site across all Cosmian KMS components, grouped by domain and crate. +It is not listed in the navigation menu but is accessible via It is not listed in the navigation menu but is accessible via [Logging and telemetry](./logging.md). ## How to read this page -The index is organised by usage-domains. Withing each domain, each table covers one crate (the UI section is an exception). All tables, ui inluded, have 5 columns: +The index is organised by usage-domains. Within each domain, each table covers one crate (the UI section is an exception). All tables, ui included, have 5 columns: | Column | Meaning | |---|---| @@ -30,7 +31,7 @@ alphabetically by message. Test files are excluded. ### `cosmian_kms_server` -Crate path: `crate/server` +Crate path: `crate/server` `RUST_LOG` target: `cosmian_kms_server` | Level | Message | File | Variables | Notes | @@ -54,7 +55,6 @@ Crate path: `crate/server` | `warn` | `Could not insert: certificate: AKI: {}, SKI: {}` | `src/core/operations/validate.rs` | - | - | | `warn` | `Failed to persist auto-activation of object {}: {}` | `src/core/retrieve_object_utils.rs` | - | - | | `warn` | `Fetch JWKS: {e}` | `src/middlewares/jwt/jwks.rs` | `e`: caught error | - | -| `warn` | `Key {wrapping_key_id} attempted to wrap itself` | `src/core/wrapping/wrap.rs` | `wrapping_key_id`: UID of the wrapping key | - | | `warn` | `SigV4 failure: {signature_error}` | `src/routes/aws_xks/sigv4_middleware.rs` | `signature_error`: SigV4 signature validation error | - | | `warn` | `Socket server: connection failed: {e}` | `src/socket_server.rs` | `e`: caught error | - | | `warn` | `UI folder invalid or Linux default detected, falling back to: {fallback:#?}` | `src/config/params/server_params.rs` | `fallback`: fallback UI folder path | - | @@ -110,8 +110,6 @@ Crate path: `crate/server` | `info` | `POST /kms/xks/v1/keys/{key_id}/decrypt - operation: {} - id: {} - user: {}` | `src/routes/aws_xks/encrypt_decrypt/decrypt_.rs` | `key_id`: XKS key identifier | - | | `info` | `POST /kms/xks/v1/keys/{key_id}/encrypt - operation: {} - id: {} - user: {}` | `src/routes/aws_xks/encrypt_decrypt/encrypt_.rs` | `key_id`: XKS key identifier | - | | `info` | `POST /kms/xks/v1/keys/{key_id}/metadata - operation: {} - id: {} - user: {}` | `src/routes/aws_xks/key_metadata.rs` | `key_id`: XKS key identifier | - | -| `info` | `Re-keyed key pair: new replacement keys created, old keys remain Active` | `src/core/operations/rekey_keypair.rs` | - | - | -| `info` | `Re-keyed symmetric key: new replacement key created, old key remains Active` | `src/core/operations/rekey.rs` | - | - | | `info` | `Refreshing JWKS` | `src/middlewares/jwt/jwks.rs` | - | - | | `info` | `Response TTLV: {ttlv:?}` | `src/routes/kmip.rs` | `ttlv`: TTLV-encoded response | - | | `info` | `Revoked object type: {}` | `src/core/operations/revoke.rs` | - | - | @@ -409,8 +407,8 @@ Crate path: `crate/server` | `trace` | `Processing PKCS12 import` | `src/core/operations/import.rs` | — | — | | `trace` | `Public key attributes after lifecycle update: {}` | `src/core/operations/create_key_pair.rs` | — | — | | `trace` | `Public key extracted from PKCS12` | `src/core/operations/import.rs` | — | — | -| `trace` | `ReKey: {}` | `src/core/operations/rekey.rs` | — | — | -| `trace` | `ReKeyKeyPair: {}` | `src/core/operations/rekey_keypair.rs` | — | — | +| `trace` | `ReKey: {}` | `src/core/operations/rekey/symmetric/mod.rs` | — | — | +| `trace` | `ReKeyKeyPair: {}` | `src/core/operations/rekey/keypair/mod.rs` | — | — | | `trace` | `Request: {:?}` | `src/routes/azure_ekm/mod.rs` | — | ×4 in this file | | `trace` | `request: {username} {}` | `src/start_kms_server.rs` | `username` — … | — | | `trace` | `Response message: {response_message}` | `src/core/operations/message.rs` | `response_message` — … | — | @@ -490,14 +488,10 @@ Crate path: `crate/server` | `warn` | `An Edwards Keypair on curve 25519 should not be requested to perform ECDH. Creating anyway.` | `src/core/operations/create_key_pair.rs` | — | — | | `warn` | `An Edwards Keypair on curve 448 should not be requested to perform ECDH. Creating anyway.` | `src/core/operations/create_key_pair.rs` | — | — | | `warn` | `CRL signature could not be verified against chain issuers; issuer: {:?}. Continuing with status checks.` | `src/core/operations/validate.rs` | — | — | -| `warn` | `Failed to process request: -{response_message}` | `src/routes/kmip.rs` | `response_message` | — | | `warn` | `Import: CRL check could not be completed ({e}), proceeding with {desired_state:?} state` | `src/core/operations/import.rs` | `e`, `desired_state` | — | | `warn` | `The UI index HTML folder does not contain an index.html file: {ui_index_html_folder:#?}` | `src/config/params/server_params.rs` | `ui_index_html_folder` | — | | `warn` | `Unsupported Block Cipher Mode for AES: {x:?}. The Authenticated Encryption Tag will NOT be extracted.` | `src/routes/kmip.rs` | `x` | — | | `warn` | `User-supplied keyUsage in extension config overrides the RFC-mandated PQC keyUsage extension (RFC 9881/9909/9935)` | `src/core/operations/certify/build_certificate.rs` | — | — | -| `info` | ` -{:?}` | `src/routes/kmip.rs` | — | — | | `info` | `POST /kmip {}.{} Binary. Request: {:?} {}` | `src/routes/kmip.rs` | — | — | | `info` | `POST /kmip {}.{} JSON. Request: {:?} {}` | `src/routes/kmip.rs` | — | — | | `info` | `POST /kmip/2_1. Request: {:?} {}` | `src/routes/kmip.rs` | — | — | @@ -527,10 +521,6 @@ Crate path: `crate/server` | `trace` | `ciphertext: {ciphertext:?}, nonce: {nonce:?}, aad: {aad:?}, tag: {tag:?}, padding_method: {padding_method:?}` | `src/core/operations/decrypt.rs` | `ciphertext`, `nonce`, `aad`, `tag`, `padding_method` | — | | `trace` | `enter export_get op={:?} req={}` | `src/core/operations/export_get.rs` | — | ×2 in this file | | `trace` | `get_attribute_list uid={} refs=[{}]` | `src/core/operations/attributes/get_list.rs` | — | — | -| `trace` | `JWK has been found: -{jwk:?}` | `src/middlewares/jwt/jwt_config.rs` | `jwk` | — | -| `trace` | `JWK has been found: -{jwk:?}` | `src/routes/google_cse/jwt.rs` | `jwk` | — | | `trace` | `post-process symmetric key uid={} final_format={:?}` | `src/core/operations/export_get.rs` | — | — | | `trace` | `process_symmetric_key enter uid={} requested_format={:?} wrap_type={:?}` | `src/core/operations/export_get.rs` | — | — | | `trace` | `process_symmetric_key exit uid={} final_format={:?}` | `src/core/operations/export_get.rs` | — | — | @@ -548,7 +538,28 @@ Crate path: `crate/server` | `info` | `\n{:?}` | `src/routes/kmip.rs` | — | — | | `trace` | `JWK has been found:\n{jwk:?}` | `src/middlewares/jwt/jwt_config.rs` | `jwk` | — | | `trace` | `JWK has been found:\n{jwk:?}` | `src/routes/google_cse/jwt.rs` | `jwk` | — | -| `info` | `KMS HTTP server configured with {n} worker thread(s)` | `src/start_kms_server.rs` | `n` | - | +| `warn` | `Failed to persist auto-deactivation of object {}: {}` | `src/core/retrieve_object_utils.rs` | - | ×2 in this file | +| `warn` | `failed to re-wrap dependant {dep_uid} with new key: {e}, skipping` | `src/core/operations/rekey/common.rs` | `dep_uid`, `e` | - | +| `warn` | `failed to unwrap dependant {dep_uid}: {e}, skipping` | `src/core/operations/rekey/common.rs` | `dep_uid`, `e` | - | +| `warn` | `skipping re-wrap of dependant {dep_uid}: owned by '{}', not by '{owner}'` | `src/core/operations/rekey/common.rs` | `dep_uid`, `owner` | - | +| `warn` | `wrapped dependant {dep_uid} not found, skipping` | `src/core/operations/rekey/common.rs` | `dep_uid` | - | +| `warn` | `{}: keyset chain depth {} ≥ warn threshold {} for uid {}; consider re-encrypting with the latest key` | `src/core/operations/key_ops/crypto_op.rs` | - | - | +| `info` | `Rekey finalized: old={} → new={}, user={user}` | `src/core/operations/rekey/common.rs` | `user` | - | +| `debug` | `[auto-rotate-cron] Failed to build runtime: {}` | `src/cron.rs` | - | - | +| `debug` | `[auto-rotate-cron] Running scheduled key auto-rotation check` | `src/cron.rs` | - | - | +| `debug` | `[auto-rotate-cron] Shutdown signal received; stopping cron thread` | `src/cron.rs` | - | - | +| `debug` | `[auto-rotate] Failed to query keys due for rotation: {e}` | `src/core/operations/auto_rotate.rs` | `e` | - | +| `debug` | `[auto-rotate] Found {} key(s) due for rotation` | `src/core/operations/auto_rotate.rs` | - | - | +| `debug` | `[auto-rotate] Renewal-warning dispatch complete (no-op stub)` | `src/core/operations/auto_rotate.rs` | - | - | +| `trace` | `Auto-deactivating object {} (deactivation_date {} <= now {})` | `src/core/retrieve_object_utils.rs` | - | ×2 in this file | +| `trace` | `execute_keyset_try_each: key {} failed for {}: {}` | `src/core/operations/key_ops/crypto_op.rs` | - | - | +| `trace` | `HSM ReKey: old={uid} → new={new_uid} (slot={slot_id}, gen={new_gen}), user={user}` | `src/core/operations/rekey/symmetric/hsm.rs` | `uid`, `new_uid`, `slot_id`, `new_gen`, `user` | - | +| `trace` | `ReCertify: {}` | `src/core/operations/recertify.rs` | - | - | +| `trace` | `ReKey: resolved keyset ref '{}' → '{}'` | `src/core/operations/rekey/symmetric/mod.rs` | - | - | +| `trace` | `SetAttribute: clearing CKA_START_DATE / CKA_END_DATE on HSM key '{}' (rotation disabled)` | `src/core/operations/attributes/set.rs` | - | - | +| `trace` | `SetAttribute: writing CKA_LABEL '{}' on HSM key '{}'` | `src/core/operations/attributes/set.rs` | - | - | +| `trace` | `SetAttribute: writing CKA_START_DATE={} CKA_END_DATE={} on HSM key '{}'` | `src/core/operations/attributes/set.rs` | - | - | +| `trace` | `walk_keyset_chain: keyset '{}' has {} keys in chain` | `src/core/uid_utils.rs` | - | - | | `warn` | `` ui_session_salt is not configured — using a randomly generated ephemeral session key. Sessions will be invalidated on server restart and are not portable across instances. For persistent sessions and load-balanced deployments, set `ui_session_salt` (or KMS_UI_SESSION_SALT) to a strong random secret value. `` | `src/start_kms_server.rs` | - | - | | `trace` | `` Found valid JWK in JWKS at `{jwks_uri}`: {jwk:#?} `` | `src/middlewares/jwt/jwks.rs` | `jwks_uri`, `jwk` | - | | `trace` | `` Ignoring invalid JWK in JWKS at `{jwks_uri}`: {e}: {v:#?} `` | `src/middlewares/jwt/jwks.rs` | `jwks_uri`, `e`, `v` | - | @@ -581,10 +592,14 @@ Crate path: `crate/server` | `trace` | `` PKCS#11 `C_SeedRandom` not yet implemented `` | `src/core/operations/pkcs11.rs` | - | - | | `trace` | `` PKCS#11 `C_UnwrapKey` not yet implemented `` | `src/core/operations/pkcs11.rs` | - | - | | `trace` | `` PKCS#11 `C_WrapKey` not yet implemented `` | `src/core/operations/pkcs11.rs` | - | - | +| `trace` | `ReKeyKeyPair: resolved keyset ref '{}' → '{}'` | `src/core/operations/rekey/keypair/mod.rs` | - | - | +| `info` | `KMS HTTP server configured with {n} worker thread(s)` | `src/start_kms_server.rs` | `n` | - | +| `debug` | `[auto-rotate] Rotating key {uid} (owner={owner})` | `src/core/operations/auto_rotate.rs` | `uid`, `owner` | - | +| `warn` | `find_wrapped_by({old_uid}) failed — skipping re-wrap of dependants: {e}` | `src/core/operations/rekey/common.rs` | `old_uid`, `e` | - | ### `cosmian_kms_server_database` -Crate path: `crate/server_database` +Crate path: `crate/server_database` `RUST_LOG` target: `cosmian_kms_server_database` | Level | Message | File | Variables | Notes | @@ -622,7 +637,7 @@ Crate path: `crate/server_database` ### `cosmian_kms_crypto` -Crate path: `crate/crypto` +Crate path: `crate/crypto` `RUST_LOG` target: `cosmian_kms_crypto` | Level | Message | File | Variables | Notes | @@ -689,7 +704,7 @@ Crate path: `crate/crypto` ### `cosmian_kmip` -Crate path: `crate/kmip` +Crate path: `crate/kmip` `RUST_LOG` target: `cosmian_kmip` | Level | Message | File | Variables | Notes | @@ -836,7 +851,7 @@ Crate path: `crate/kmip` ### `cosmian_kms_interfaces` -Crate path: `crate/interfaces` +Crate path: `crate/interfaces` `RUST_LOG` target: `cosmian_kms_interfaces` | Level | Message | File | Variables | Notes | @@ -864,14 +879,14 @@ Crate path: `crate/interfaces` ### `cosmian_kms_access` -Crate path: `crate/access` +Crate path: `crate/access` `RUST_LOG` target: `cosmian_kms_access` _No production log call-sites in this crate._ ### `cosmian_kms_base_hsm` -Crate path: `crate/hsm/base_hsm` +Crate path: `crate/hsm/base_hsm` `RUST_LOG` target: `cosmian_kms_base_hsm` | Level | Message | File | Variables | Notes | @@ -879,7 +894,6 @@ Crate path: `crate/hsm/base_hsm` | `warn` | `HSM library already initialized (CKR_CRYPTOKI_ALREADY_INITIALIZED); continuing` | `src/hsm_lib.rs` | - | - | | `warn` | `user already logged in, ignoring logging` | `src/slots.rs` | - | - | | `debug` | `Creating new session: {session_handle}. Logging in? {logging_in}` | `src/session/session_impl.rs` | `session_handle`: session handle
`logging_in`: logging in | - | -| `debug` | `Failed to encrypt data with hash {hash}: {rv}` | `src/session/session_impl.rs` | `hash`: hash
`rv`: PKCS#11 return code | - | | `debug` | `Found {} possible handles` | `src/session/session_impl.rs` | - | - | | `debug` | `Invalid object, skipping` | `src/kms_hsm.rs` | - | - | | `debug` | `Logging in session {session_handle} with password` | `src/slots.rs` | `session_handle`: session handle | - | @@ -892,6 +906,7 @@ Crate path: `crate/hsm/base_hsm` | `debug` | `Using PKCS#11 library with {:?}` | `src/base_hsm.rs` | - | - | | `trace` | `Doing round with {round_length} bytes. {processed_length} of {total_length} done` | `src/session/session_impl.rs` | `round_length` — …
`processed_length` — …
`total_length` — … | ×2 in this file | | `trace` | `Found {object_count} objects` | `src/session/session_impl.rs` | `object_count` — … | — | +| `debug` | `OAEP hash {hash} not supported: {e}` | `src/session/session_impl.rs` | `hash`, `e` | - | --- @@ -899,7 +914,7 @@ Crate path: `crate/hsm/base_hsm` ### `cosmian_kms_cli_actions` -Crate path: `crate/clients/clap` +Crate path: `crate/clients/clap` `RUST_LOG` target: `cosmian_kms_cli_actions` | Level | Message | File | Variables | Notes | @@ -944,7 +959,7 @@ Crate path: `crate/clients/clap` ### `ckms` -Crate path: `crate/clients/ckms` +Crate path: `crate/clients/ckms` `RUST_LOG` target: `ckms` | Level | Message | File | Variables | Notes | @@ -959,7 +974,7 @@ Crate path: `crate/clients/ckms` ### `cosmian_kms_client` -Crate path: `crate/clients/client` +Crate path: `crate/clients/client` `RUST_LOG` target: `cosmian_kms_client` | Level | Message | File | Variables | Notes | @@ -978,16 +993,12 @@ Crate path: `crate/clients/client` | `debug` | `CONNECT tunnel: {proxy_addr} → {target_host}:{target_port}` | `src/http_client/proxy.rs` | `proxy_addr`, `target_host`, `target_port` | — | | `trace` | `Error response on {endpoint}: status={status}, body={text}` | `src/kms_rest_client.rs` | `endpoint`, `status`, `text` | — | | `warn` | `` ckms config: `{}` is deprecated — rename it to `{}` in your ckms.toml to silence this warning. `` | `src/http_client/client.rs` | - | - | -| `trace` | `<== -{}` | `src/kms_rest_client.rs` | — | ×3 in this file | -| `trace` | `==> -{}` | `src/kms_rest_client.rs` | — | ×3 in this file | --- ### `cosmian_kms_client_utils` -Crate path: `crate/clients/client_utils` +Crate path: `crate/clients/client_utils` `RUST_LOG` target: `cosmian_kms_client_utils` | Level | Message | File | Variables | Notes | @@ -1000,7 +1011,7 @@ Crate path: `crate/clients/client_utils` ### `cosmian_pkcs11` -Crate path: `crate/clients/pkcs11/provider` +Crate path: `crate/clients/pkcs11/provider` `RUST_LOG` target: `cosmian_pkcs11` | Level | Message | File | Variables | Notes | @@ -1067,7 +1078,7 @@ Crate path: `crate/clients/pkcs11/provider` ### `cosmian_pkcs11_module` -Crate path: `crate/clients/pkcs11/module` +Crate path: `crate/clients/pkcs11/module` `RUST_LOG` target: `cosmian_pkcs11_module` | Level | Message | File | Variables | Notes | @@ -1142,7 +1153,7 @@ Crate path: `crate/clients/pkcs11/module` ### `cosmian_cng` -Crate path: `crate/clients/cng` +Crate path: `crate/clients/cng` `RUST_LOG` target: `cosmian_cng` | Level | Message | File | Variables | Notes | diff --git a/documentation/docs/configuration/server_configuration_file.md b/documentation/docs/configuration/server_configuration_file.md index 8591b5c606..b1bd79bd61 100644 --- a/documentation/docs/configuration/server_configuration_file.md +++ b/documentation/docs/configuration/server_configuration_file.md @@ -175,6 +175,19 @@ info = false # and grant access rights for Create Kmip Operation. # privileged_users = ["", ""] +# Interval in seconds between background auto-rotation checks. +# Set to 0 (default) to disable the auto-rotation background task. +# When enabled, must be at least 60 seconds to avoid excessive database churn. +# See: https://docs.cosmian.com/kmip_support/key_rotation/auto_rotation_policy/ +# auto_rotation_check_interval_secs = 0 + +# Depth at which a successful keyset-chain decryption triggers a server warning. +# Keyset chain traversal is unbounded (stopped only by cycle detection). +# When a Decrypt / Verify call succeeds at depth >= this value, the server logs +# a warning so operators can identify stale ciphertexts that should be +# re-encrypted with the latest key. Default: 5. +# keyset_warn_depth = 5 + # Check the database configuration documentation pages for more information [db] # The main database of the KMS server that holds default cryptographic objects and permissions. diff --git a/documentation/docs/kmip_support/attributes.md b/documentation/docs/kmip_support/attributes.md index 2b79bbd56c..37fa7bee51 100644 --- a/documentation/docs/kmip_support/attributes.md +++ b/documentation/docs/kmip_support/attributes.md @@ -1,4 +1,4 @@ -In [chapter 4](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/cs01/kmip-spec-v2.1-cs01.html#_Toc32239322), the KMIP 2.1 specification specifies a list of 63 Attributes, mostly made of enumerations and data structures, often nested in each other. Despite this impressive list, and as expected in such a large specification, KMIP allows for extensions to support new cryptographic schemes such as the ones enabled by Eviden. +In [chapter 4](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html#_Toc57115559), the KMIP 2.1 specification specifies a list of 63 Attributes, mostly made of enumerations and data structures, often nested in each other. Despite this impressive list, and as expected in such a large specification, KMIP allows for extensions to support new cryptographic schemes such as the ones enabled by Eviden. Extensions in KMIP consist mostly in augmenting enumerations with new values and attributing a specific prefix values, usually `0x8880` to the new variants. @@ -41,7 +41,7 @@ CoverCrypt = 0x8880_0004, #### Vendor Attributes -All keys managed by the Eviden KMS server are primarily a `KeyMaterial` made of bytes. Some keys, typically those of ABE, also carry information regarding the underlying access policies. This information is carried together with the keys using [VendorAttributes](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/cs01/kmip-spec-v2.1-cs01.html#_Toc32239382) +All keys managed by the Eviden KMS server are primarily a `KeyMaterial` made of bytes. Some keys, typically those of ABE, also carry information regarding the underlying access policies. This information is carried together with the keys using [VendorAttributes](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html#_Toc57115619) Typically a vendor attribute is made of 3 values: a `Vendor Identification` - set to the server's configured vendor ID (default: `cosmian`, configurable via `--vendor-identification` / `KMS_VENDOR_IDENTIFICATION`) - and a tuple `Attribute Name`, `Attribute Value`. diff --git a/documentation/docs/kmip_support/introduction/index.md b/documentation/docs/kmip_support/introduction/index.md index 4d7da90ea2..a6e76e574f 100644 --- a/documentation/docs/kmip_support/introduction/index.md +++ b/documentation/docs/kmip_support/introduction/index.md @@ -22,7 +22,7 @@ and encryption clients. Internally, all KMIP messages are translated to KMIP 2.1 specifications and converted back to KMIP 1.x when necessary. The Eviden KMS server implements a targeted subset of -the [KMIP 2.1 protocol](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/cs01/kmip-spec-v2.1-cs01.html). +the [KMIP 2.1 protocol](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html). ## Purpose of KMIP diff --git a/documentation/docs/kmip_support/key_rotation/_re-certify.md b/documentation/docs/kmip_support/key_rotation/_re-certify.md new file mode 100644 index 0000000000..a61b1c6ce8 --- /dev/null +++ b/documentation/docs/kmip_support/key_rotation/_re-certify.md @@ -0,0 +1,166 @@ +#### Specification + +This request is used to generate a **replacement certificate** for an existing +X.509 certificate. It is analogous to the Certify operation, except that the +source is an already-issued certificate rather than a public key or CSR. + +The server creates a new certificate with a **fresh Unique Identifier**, copies +the subject public key and the requested validity period from the existing +certificate, and re-signs it using the specified (or inherited) issuer key. + +> **[KMIP 2.1], §6.1.6, "Certify"** — "This request supports the certification +> of a new public key, as well as the certification of a public key that has +> already been certified (i.e., certificate update)." +> +> The dedicated `ReCertify` operation tag (distinct from `Certify`) creates the +> replacement certificate as a **new managed object with a new UID**, and wires +> bidirectional `ReplacementObjectLink` / `ReplacedObjectLink` attributes +> between old and new certificates. + +**Server behaviour:** + +1. A new Certificate object is created with a fresh Unique Identifier. +2. The new certificate is issued with the requested `Offset` (activation delay) + and `NumberOfDays` validity, or the issuer's defaults if omitted. +3. `ReplacementObjectLink` is added to the **old** certificate pointing to the + new UID. +4. `ReplacedObjectLink` is added to the **new** certificate pointing back to + the old UID. +5. The new certificate enters **Active** state (or **Pre-Active** if + `Offset > 0`). +6. The old certificate is **not** automatically deactivated — its state remains + unchanged. Call `Revoke` explicitly to deactivate it when ready. + +#### Request Payload + +| Item | Required | Description | +| ------------------------ | :------: | ------------------------------------------------------------------------------------------------- | +| `UniqueIdentifier` | No | UID of the existing certificate to re-certify. Defaults to the ID Placeholder if omitted. | +| `CertificateRequestType` | No | Type of an optional accompanying certificate request (e.g., PKCS10). | +| `CertificateRequestValue`| No | Raw certificate request bytes (PEM or DER). Overrides the subject information from the old cert. | +| `Offset` | No | Seconds between `Initial Date` and the new certificate's `Activation Date`. `0` → Active immediately; `> 0` → Pre-Active. | +| `Attributes` | No | Desired attributes for the new certificate (e.g., issuer private key ID, issuer certificate ID, number of days). | +| `ProtectionStorageMasks` | No | Permissible protection storage mask selections for the new object. | + +#### Response Payload + +| Item | Description | +| ------------------ | ------------------------------------------------------- | +| `UniqueIdentifier` | The Unique Identifier of the newly created replacement certificate. | + +#### Implementation + +The `ReCertify` operation rotates an X.509 certificate. It is the certificate +equivalent of `Re-Key` for symmetric keys and `Re-Key Key Pair` for asymmetric +key pairs. + +**Key differences vs. `Certify` with `certificate-id-to-re-certify`:** + +| Aspect | `ReCertify` operation | `Certify` with existing cert UID | +| --------------------- | ------------------------------------------ | ------------------------------------------ | +| New UID | Always fresh | Optionally same as old | +| Object links | Bidirectional (old ↔ new) | Not set automatically | +| Old cert deactivation | Manual (call `Revoke`) | Manual (call `Revoke`) | +| KMIP operation tag | `ReCertify` | `Certify` | + +**CLI command:** + +```bash +ckms certificates certify \ + --certificate-id-to-re-certify \ + --issuer-private-key-id \ + --issuer-certificate-id \ + --number-of-days 365 +``` + +### Example — Re-certify a self-signed certificate + +=== "Request" + + ```json + { + "tag": "ReCertify", + "value": [ + { + "tag": "UniqueIdentifier", + "type": "TextString", + "value": "c3d4e5f6-0000-0000-0000-aabbccddeeff" + } + ] + } + ``` + +=== "Response" + + ```json + { + "tag": "ReCertifyResponse", + "type": "Structure", + "value": [ + { + "tag": "UniqueIdentifier", + "type": "TextString", + "value": "a1b2c3d4-1111-2222-3333-445566778899" + } + ] + } + ``` + +### Example — Re-certify with a future activation date (Offset) + +An `Offset` of `86400` (24 h) produces a **Pre-Active** certificate whose +`Activation Date` is set to `Initial Date + 86400 s`, allowing a zero-downtime +switchover by pre-staging the replacement before the old one expires. + +=== "Request" + + ```json + { + "tag": "ReCertify", + "value": [ + { + "tag": "UniqueIdentifier", + "type": "TextString", + "value": "c3d4e5f6-0000-0000-0000-aabbccddeeff" + }, + { + "tag": "Offset", + "type": "Interval", + "value": 86400 + } + ] + } + ``` + +=== "Response" + + ```json + { + "tag": "ReCertifyResponse", + "type": "Structure", + "value": [ + { + "tag": "UniqueIdentifier", + "type": "TextString", + "value": "b2c3d4e5-aaaa-bbbb-cccc-ddeeff001122" + } + ] + } + ``` + +### KMIP link chain after re-certification + +```mermaid +flowchart LR + C0["Cert₀ (old)
Active"] -->|ReplacementObjectLink| C1["Cert₁ (new)
Active"] + C1 -->|ReplacedObjectLink| C0 +``` + +The old certificate remains `Active` until explicitly revoked. To complete the +rotation: + +```bash +# Revoke the old certificate when no longer needed +ckms certificates revoke --certificate-id \ + --revocation-reason "Superseded" +``` diff --git a/documentation/docs/kmip_support/_re-key.md b/documentation/docs/kmip_support/key_rotation/_re-key.md similarity index 96% rename from documentation/docs/kmip_support/_re-key.md rename to documentation/docs/kmip_support/key_rotation/_re-key.md index 88634b6e0e..7a1919686b 100644 --- a/documentation/docs/kmip_support/_re-key.md +++ b/documentation/docs/kmip_support/key_rotation/_re-key.md @@ -16,7 +16,7 @@ The `Re-Key` Operation refreshes Symmetric keys. ### Example - Refresh a Symmetric Key -Corresponding [KMS CLI](../../kms_clients/index.md) command: +Corresponding [KMS CLI](../../../kms_clients/index.md) command: ```bash ckms sym keys re-key -k 64c60363-6660-4fd4-9f30-c965a0f72fc3 diff --git a/documentation/docs/kmip_support/_re-key_key_pair.md b/documentation/docs/kmip_support/key_rotation/_re-key_key_pair.md similarity index 98% rename from documentation/docs/kmip_support/_re-key_key_pair.md rename to documentation/docs/kmip_support/key_rotation/_re-key_key_pair.md index a73ab1708e..d8d3393900 100644 --- a/documentation/docs/kmip_support/_re-key_key_pair.md +++ b/documentation/docs/kmip_support/key_rotation/_re-key_key_pair.md @@ -34,7 +34,7 @@ The operation has currently no other usages on the Eviden server. ### Example - Rotate the `Security Level::Confidential` attribute -Corresponding [KMS CLI](../../kms_clients/index.md) command: +Corresponding [KMS CLI](../../../kms_clients/index.md) command: ```bash ckms cc keys rekey -k b652a48a-a48c-4dc1-bd7e-cf0e5126b7b9 "Security Level::Confidential" diff --git a/documentation/docs/kmip_support/key_rotation/auto_rotation_policy.md b/documentation/docs/kmip_support/key_rotation/auto_rotation_policy.md new file mode 100644 index 0000000000..86bde9ef77 --- /dev/null +++ b/documentation/docs/kmip_support/key_rotation/auto_rotation_policy.md @@ -0,0 +1,250 @@ +# Auto-Rotation Policy + +Cosmian KMS supports **scheduled, policy-driven key rotation** for SQL-backed +symmetric keys and asymmetric key pairs. A per-key *rotation policy* is +attached to a key object; a background scheduler then rotates any key whose +interval has elapsed — without any operator action. + +> **HSM keys** support manual rotation only; the auto-rotation scheduler never +> picks up HSM UIDs. See [HSM Key Rotation](hsm.md). +> +> For the rotation flows and sequence diagrams for each key type, see +> [Key Rotation](index.md). + +--- + +## Rotation policy attributes + +All rotation-policy state is stored as vendor-extension KMIP attributes on +the key object itself: + +| Attribute | Type | Mutable | Description | +| --------------------- | --------------- | :-----: | ------------------------------------------------------------------------------------ | +| `x-rotate-interval` | `i64` (seconds) | ✅ | How often to rotate. `0` disables auto-rotation. | +| `x-rotate-name` | `string` | ✅ | Keyset name this key belongs to (see [Key Rotation — Keysets](index.md#keysets)). | +| `x-rotate-offset` | `i64` (seconds) | ✅ | Shift the first rotation trigger by this many seconds after `Initial Date`. | +| `x-rotate-generation` | `i32` | ❌ | Incremented on every rotation; `0` for never-rotated keys. **Server-managed.** | +| `x-rotate-date` | `datetime` | ❌ | Timestamp of the last rotation. **Server-managed.** | +| `x-rotate-latest` | `bool` | ❌ | `true` on the most-recent keyset member; `false` on all older keys. **Server-managed.** | + +### Read-only attributes + +`x-rotate-generation`, `x-rotate-date`, and `x-rotate-latest` are set +exclusively by the server during the `Re-Key` operation. Any attempt to +modify them via `AddAttribute`, `SetAttribute`, `ModifyAttribute`, or +`DeleteAttribute` is rejected with `Attribute_Read_Only`. + +These restrictions maintain two invariants relied on by the scheduler and the +keyset resolution logic: + +- **Monotonic generation counter** — `x-rotate-generation` starts at `0` and + increments by exactly `1` per rotation. Within a keyset the generation is + unique and strictly increasing, which lets the scheduler and client tooling + identify the current key without inspecting every member. +- **Authoritative rotation timestamp** — `x-rotate-date` is the only reliable + source for "when was this key last rotated". The scheduler computes the next + trigger as `x-rotate-date + x-rotate-interval` (previously rotated) or + `initial_date + x-rotate-offset + x-rotate-interval` (never rotated). + External modifications to `x-rotate-date` would cause missed or premature + rotations. + +--- + +## Assigning a rotation policy + +Use `ckms sym keys set-rotation-policy` (or `SetAttribute` for key pairs) to +configure the mutable attributes on an existing key: + +```bash +# Rotate every hour; first rotation is 60 seconds after Initial Date +ckms sym keys set-rotation-policy \ + --key-id my-keyset \ + --interval 3600 \ + --offset 60 \ + --name my-keyset +``` + +`SetAttribute` initialises `x-rotate-generation = 0` and +`x-rotate-latest = true` on the key. Every subsequent `Re-Key` increments +the generation, marks the new key as `latest`, and marks the old key as +non-latest. + +For **asymmetric key pairs**, use `SetAttribute` directly: + +```bash +ckms objects set-attribute \ + --id \ + x-rotate-interval 86400 +``` + +--- + +## Server configuration + +### Enable the scheduler + +The background scheduler is **disabled by default**. Enable it in `kms.toml` +or via the command-line flag: + +```toml +# kms.toml +auto_rotation_check_interval_secs = 300 # check every 5 minutes +``` + +```bash +# Command-line equivalent +cosmian_kms --auto-rotation-check-interval-secs 300 +``` + +Set the interval to a value **smaller than the shortest `x-rotate-interval`** +on any key — otherwise some rotations will be delayed by up to one scheduler +period. + +### HSM backend + KEK + +The scheduler works with any database backend. If the server is started with +a `--key-encryption-key`, KEK-wrapped keys are rotated the same as plain keys +(the server unwraps in memory, generates fresh material, re-wraps): + +```bash +cosmian_kms \ + --database-type sqlite \ + --hsm-model softhsm2 \ + --hsm-slot 0 \ + --hsm-password 12345678 \ + --key-encryption-key "hsm::softhsm2::0::my-kek" \ + --auto-rotation-check-interval-secs 300 +``` + +--- + +## How the scheduler works + +On each tick, the scheduler: + +1. Queries all **Active** symmetric keys and private keys whose + `x-rotate-interval > 0`. +2. For each candidate, computes the next rotation deadline: + - **Previously rotated key:** `x-rotate-date + x-rotate-interval` + - **Never-rotated key with Initial Date:** `initial_date + x-rotate-offset + x-rotate-interval` +3. Rotates every key whose deadline is in the past. +4. Emits an OpenTelemetry counter `kms.key.auto_rotation` labelled with `uid` + and `algorithm` for each successful rotation. + +The scheduler **never returns HSM UIDs** — auto-rotation is SQL-only. + +### Attribute changes on auto-rotation + +| Attribute | Old key | New key | +| ----------------------------- | ------------------------------------ | ----------------------------------- | +| `Unique Identifier` | unchanged | fresh UUID (or `name@N` for keysets) | +| `State` | **Deactivated** (§4.57) | Active | +| `Link[ReplacementObjectLink]` | → new key UID | — | +| `Link[ReplacedObjectLink]` | — | → old key UID | +| `x-rotate-generation` | unchanged | old value + 1 | +| `x-rotate-date` | unchanged | timestamp of rotation | +| `x-rotate-interval` | **set to `0`** (cron skips old key) | **inherited** from old 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 next deadline) | +| `Cryptographic Algorithm` | unchanged | copied from old key | +| `Cryptographic Length` | unchanged | copied from old key | + +> **Note:** `x-rotate-interval` is inherited on auto-rotation (policy +> continues on the new key automatically). On **manual** `Re-Key` it is set +> to `0` on the new key — the operator must re-arm the policy explicitly. + +--- + +## End-to-end setup + +### Step 1 — Create a key in a keyset + +```bash +# SQL key: UID must equal the keyset name +ckms sym keys create --key-id my-keyset --algorithm aes --length 256 +``` + +### Step 2 — Attach a rotation policy + +```bash +ckms sym keys set-rotation-policy \ + --key-id my-keyset \ + --name my-keyset \ + --interval 3600 \ + --offset 60 +``` + +### Step 3 — Enable the server scheduler + +In `kms.toml`: + +```toml +auto_rotation_check_interval_secs = 300 +``` + +The scheduler will rotate `my-keyset` for the first time roughly `60 + 3600` +seconds after its `Initial Date`, and every `3600` seconds thereafter. + +--- + +## Disabling auto-rotation on a key + +Set `x-rotate-interval` to `0`: + +```bash +ckms sym keys set-rotation-policy --key-id my-keyset --interval 0 +``` + +This prevents the scheduler from selecting the key without removing any other +keyset metadata. + +--- + +## Keyset chain depth warning + +When a `Decrypt` or `Verify` call resolves a keyset name (e.g. `my-keyset`) +rather than an explicit key UID, the server walks the generation chain +newest-to-oldest until one generation succeeds. A very deep chain indicates +that many ciphertexts are still bound to old key generations — a signal that +client-side re-encryption may be overdue. + +To surface this, configure `keyset_warn_depth` in `kms.toml`: + +```toml +# Emit a server warning when decryption succeeds at chain depth >= this value. +# Default: 5. 0 disables the warning. +keyset_warn_depth = 5 +``` + +Or via the CLI flag: + +```bash +cosmian_kms --keyset-warn-depth 5 +``` + +When the threshold is reached, the server emits a `WARN` log line: + +```text +Decrypt: keyset chain depth 5 >= warn threshold 5 for uid my-keyset@0; +consider re-encrypting with the latest key +``` + +!!! note "Traversal is unbounded" + The chain walk has no hard limit. All key generations remain reachable + (subject to KMIP state rules). `keyset_warn_depth` is purely a + monitoring hint — it never prevents a decryption from succeeding. + +--- + +## Observability + +The server increments the OpenTelemetry counter `kms.key.auto_rotation` with +labels `uid` and `algorithm` on every successful auto-rotation. Use your +OTel-compatible backend (Prometheus + Grafana, Datadog, …) to: + +- Alert on unexpected gaps in rotation activity. +- Audit which keys were rotated and when. +- Track rotation throughput across the fleet. + +See [Monitoring](../../configuration/monitoring-setup.md) for the full OTel setup guide. diff --git a/documentation/docs/kmip_support/key_rotation/hsm.md b/documentation/docs/kmip_support/key_rotation/hsm.md new file mode 100644 index 0000000000..b922983ebd --- /dev/null +++ b/documentation/docs/kmip_support/key_rotation/hsm.md @@ -0,0 +1,267 @@ +# HSM Key Rotation + +Cosmian KMS supports manual `Re-Key` for keys that reside on a +PKCS#11-capable Hardware Security Module (SoftHSM2, Utimaco, Proteccio, …). +The flow mirrors SQL-backed rotation but all keyset metadata lives in PKCS#11 +attributes rather than the KMS database. + +## Capabilities + +| Capability | Supported | Notes | +| ------------------------------ | :-------: | ---------------------------------------------------------------- | +| Manual `Re-Key` via KMIP | ✅ | Calls `C_GenerateKey` on the same HSM slot. | +| Keyset membership (`x-rotate-name`) | ✅ | Stored in `CKA_LABEL`; keyset name **must be the full base UID** (`hsm::model::slot::key_id`). Supports `@latest`, `@first`, `@N` generation addressing. | +| `x-rotate-interval` attribute | ✅ | Writes `CKA_START_DATE` / `CKA_END_DATE` for validity tracking. | +| Auto-rotation scheduler | ❌ | `find_due_for_rotation` never returns HSM UIDs; scheduler skips them. | +| `x-rotate-offset` | ❌ | Not applicable to PKCS#11 scheduling; rejected with `NotSupported`. | + +--- + +## CKA_LABEL convention + +HSM keyset metadata is stored entirely in the PKCS#11 `CKA_LABEL` attribute — +no SQL shadow rows are written. + +| `CKA_LABEL` value | Meaning | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `{key_id}` *(plain, before enrollment)* | Initial state after `Create` — key has not yet been added to a keyset | +| `{base_uid}::0::{key_id}@latest` | Gen-0 key immediately after `SetAttribute` enrollment | +| `{base_uid}::{gen}::{base_id}@latest` | Current latest key after any `Re-Key` | +| `{base_uid}::{gen}::{base_id}` | Retired key (any generation that is no longer the latest) | +| *(anything else)* | Key does not belong to a keyset | + +Where: + +- `base_uid` — the **full base UID** of the gen-0 key: `hsm::::::`. + Because it embeds the slot ID, the keyset name is **unique across all HSM slots**. + A key on slot 0 and a key on slot 1 with the same local name will have different + keyset names (`hsm::softhsm2::0::my-key` vs `hsm::softhsm2::1::my-key`). +- `gen` — integer starting at `0`, incremented on every `Re-Key`. +- `base_id` / `key_id` — the PKCS#11 `CKA_ID` of the gen-0 key. + +!!! note "Why does `my-hsm-key` appear twice in the label?" + The label format is `{rotate_name}::{gen}::{key_id}`. For HSM keys, + `rotate_name` **is** the full base UID (`hsm::softhsm2::0::my-hsm-key`), which + already embeds the local key name. The same name then appears again as the + separate `key_id` field required by `parse_label_metadata` to reconstruct the + per-key PKCS#11 `CKA_ID`. + + ``` + hsm::softhsm2::0::my-hsm-key :: 0 :: my-hsm-key@latest + ├─ rotate_name (full base UID) gen key_id + ``` + + This duplication is intentional and handled by the right-split parser. + +!!! note "CKA_LABEL parsing uses right-split" + Because `rotate_name` itself contains `::`, the label is parsed **from the right** + (`rsplitn(3, "::")`): + + ``` + hsm::softhsm2::0::my-key::1::my-key@latest + ├─ rotate_name = "hsm::softhsm2::0::my-key" (everything left of the last two ::) + ├─ generation = 1 + └─ key_id = "my-key@latest" + ``` + + This is backward-compatible: labels written before the full-UID convention + (e.g. `"my-keyset::0::my-key@latest"`) still parse correctly. + +--- + +## UID scheme + +```text +hsm:::::: ← gen 0 (original key) +hsm::::::@1 ← gen 1 (after first Re-Key) +hsm::::::@2 ← gen 2 (after second Re-Key) +``` + +Where `` is the HSM model name (e.g. `softhsm2`, `utimaco`, `proteccio`). +The `@N` generation suffix is appended to the original `base_key_id`, using the +same convention as SQL keyset keys (`my-key@1`, `my-key@2`). Unlike +SQL keys, the base portion of the UID never changes, so the full chain can be +discovered by scanning `CKA_LABEL` on the HSM slot. + +!!! note "Backward-compatible two-segment UIDs" + The older format `hsm::::` (without model segment) is still + accepted for backward compatibility with keys created before the model segment + was introduced. + +--- + +## Keyset name constraint + +For HSM keys the keyset name (`x-rotate-name`) **must be the key's full base UID** +(`hsm::::::` without any `@N` suffix). + +| Value | Accepted? | Reason | +| ------------------------------------ | :-------: | --------------------------------------------------- | +| `hsm::softhsm2::0::my-hsm-key` | ✅ | Full base UID — unique across slots. | +| `my-hsm-keyset` | ❌ | Bare name rejected — no slot disambiguation. | +| `hsm::softhsm2::0::my-hsm-key@1` | ❌ | Generation suffix rejected — base UID only. | + +```text +Invalid Request: SetAttribute: for HSM keys, rotate_name must be the key's base UID +('hsm::softhsm2::0::my-hsm-key'), not 'my-hsm-keyset' +``` + +> **Why this constraint?** A bare name like `"my-hsm-key"` is ambiguous when +> multiple HSM slots are in use — two keys on different slots could have the +> same local name. Embedding the slot ID in the keyset name prevents cross-slot +> collisions without requiring a central name registry. + +--- + +## Keyset resolution + +When a base UID (e.g. `hsm::softhsm2::0::my-hsm-key`) or generation-scoped syntax +(`hsm::softhsm2::0::my-hsm-key@latest`, `hsm::softhsm2::0::my-hsm-key@0`, +`hsm::softhsm2::0::my-hsm-key@N`) is used, the server calls `find_by_rotate_name`, +which scans PKCS#11 objects in the HSM slot and parses `CKA_LABEL` to extract +the `rotate_name` and `rotate_generation`. Results are sorted by generation +descending: + +- **Encrypt / Sign** → uses the key with the **highest `rotate_generation`** + (current latest). The `@latest` suffix in `CKA_LABEL` is *not* used for + this determination — generation sorting is the authoritative source. +- **Decrypt / Verify** → tries each generation newest-to-oldest until one + succeeds, allowing old ciphertexts to continue to decrypt after rotation. + +Using a **direct generation UID** with an explicit `@N` suffix +(e.g. `hsm::softhsm2::0::my-hsm-key@0`) bypasses keyset resolution entirely: +only that specific generation is used, with no chain walk. + +Using the **base UID without `@N`** (e.g. `hsm::softhsm2::0::my-hsm-key`) +always resolves to the **latest generation** (see [Second rotation and non-latest guard](#second-rotation-and-non-latest-guard) +below for the `Re-Key`-specific stable-handle behaviour). + +HSM keysets do **not** use `ReplacedObjectLink` / `ReplacementObjectLink` +back-pointers; all state lives in PKCS#11 attributes. + +--- + +## Full rotation workflow + +### Setup + +```bash +# 1. Start the KMS server with HSM backend +cosmian_kms \ + --database-type sqlite \ + --hsm-model softhsm2 \ + --hsm-slot 0 \ + --hsm-password 12345678 +``` + +### Create and enrol + +```bash +# 2. Create an AES-256 key directly on the HSM +ckms sym keys create \ + --key-id "hsm::softhsm2::0::my-hsm-key" \ + --algorithm aes --length 256 + +# 3. Enrol the key in a keyset. +# The keyset name MUST equal the key's full base UID. +# Before: CKA_LABEL = "my-hsm-key" (plain key_id set at creation) +# After: CKA_LABEL = "hsm::softhsm2::0::my-hsm-key::0::my-hsm-key@latest" +# └─ rotate_name ──────────────────────┘ gen └─ key_id ──┘ +ckms sym keys set-rotation-policy \ + --key-id "hsm::softhsm2::0::my-hsm-key" \ + --name "hsm::softhsm2::0::my-hsm-key" +``` + +### Encrypt using the base UID as the keyset address + +```bash +# 4. Encrypt — base UID resolves to the latest key (gen-0 initially) +ckms sym encrypt --key-id "hsm::softhsm2::0::my-hsm-key" plaintext.bin -o ciphertext.enc +``` + +### First rotation + +```bash +# 5. Rotate using the base UID as a stable keyset handle. +# No @N suffix → server redirects to the latest generation (gen-0 here). +ckms sym keys rekey --key-id "hsm::softhsm2::0::my-hsm-key" +# Response: new UID = hsm::softhsm2::0::my-hsm-key@1 +# +# CKA_LABEL (gen-0): "hsm::softhsm2::0::my-hsm-key::0::my-hsm-key" (retired) +# CKA_LABEL (gen-1): "hsm::softhsm2::0::my-hsm-key::1::my-hsm-key@latest" (current) +``` + +```mermaid +sequenceDiagram + participant Client + participant KMS + participant HSM + + Client->>KMS: Re-Key("hsm::softhsm2::0::my-hsm-key") + KMS->>HSM: C_FindObjects (CKA_ID = "my-hsm-key") + HSM-->>KMS: {gen-0 object handle} + KMS->>KMS: build new key attributes
(algorithm, length, gen=1) + KMS->>HSM: C_GenerateKey → gen-1 object + KMS->>HSM: C_SetAttributeValue gen-0
CKA_LABEL = "hsm::softhsm2::0::my-hsm-key::0::my-hsm-key" + KMS->>HSM: C_SetAttributeValue gen-1
CKA_LABEL = "hsm::softhsm2::0::my-hsm-key::1::my-hsm-key@latest" + KMS-->>Client: Re-KeyResponse("hsm::softhsm2::0::my-hsm-key@1") +``` + +### Decrypt old ciphertext after rotation + +```bash +# 6. Decrypt using the base UID — server tries gen-1 then gen-0 +ckms sym decrypt --key-id "hsm::softhsm2::0::my-hsm-key" ciphertext.enc -o plaintext.bin +``` + +```mermaid +sequenceDiagram + participant Client + participant KMS + participant HSM + + Client->>KMS: Decrypt("hsm::softhsm2::0::my-hsm-key", ciphertext) + KMS->>HSM: find_by_rotate_name("hsm::softhsm2::0::my-hsm-key")
scan CKA_LABEL right-split, sort by gen desc + HSM-->>KMS: [gen-1, gen-0] + KMS->>HSM: C_DecryptInit / C_Decrypt with gen-1 + HSM-->>KMS: DecryptFailed (wrong key material) + KMS->>HSM: C_DecryptInit / C_Decrypt with gen-0 + HSM-->>KMS: plaintext + KMS-->>Client: DecryptResponse(plaintext) +``` + +### Second rotation and non-latest guard + +The `Re-Key` operation distinguishes two addressing styles for HSM keys: + +| Addressing style | Example | Behaviour when not latest | +| ------------------------- | ------------------------------------------ | ---------------------------- | +| **Stable handle** (base UID, no `@N`) | `hsm::softhsm2::0::my-hsm-key` | Silently redirects to the latest generation | +| **Explicit generation** (`@N`) | `hsm::softhsm2::0::my-hsm-key@0` | Returns an error | + +```bash +# 7. Second rotation using the stable handle (always rotates the latest) +ckms sym keys rekey --key-id "hsm::softhsm2::0::my-hsm-key" +# → redirected to gen-1 (current latest) +# Response: new UID = hsm::softhsm2::0::my-hsm-key@2 + +# Attempting to re-key an explicit retired generation is rejected: +ckms sym keys rekey --key-id "hsm::softhsm2::0::my-hsm-key@0" # explicit @0 — REJECTED +# Error: ReKey: HSM key 'hsm::softhsm2::0::my-hsm-key@0' is not the latest in its keyset +# — only the latest generation can be rotated. +# Use 'hsm::softhsm2::0::my-hsm-key' to always rotate the current head. +``` + +--- + +## Differences from SQL rotation + +| Aspect | SQL-backed keys | HSM-resident keys | +| ----------------------- | ------------------------------------------------ | -------------------------------------------------- | +| Keyset metadata storage | KMIP vendor attributes (`x-rotate-*`) in SQL DB | `CKA_LABEL` on PKCS#11 objects | +| UID scheme | Plain identifier (keyset name is a `rotate_name` attribute, not in the UID) | `hsm::::::[@N]` (generation suffix appended) | +| Auto-rotation scheduler | ✅ Supported | ❌ Not supported | +| `x-rotate-offset` | ✅ Supported | ❌ Not supported | +| KMIP link back-pointers | ✅ `ReplacedObjectLink` / `ReplacementObjectLink` | ❌ Not used; chain lives in `CKA_LABEL` | +| Chain discovery | SQL query on `x-rotate-name` + `x-rotate-generation` | PKCS#11 `C_FindObjects` on `CKA_LABEL` prefix | +| Old key state after rotation | Deactivated (§4.57) | Retired label set in `CKA_LABEL` (no KMIP state) | diff --git a/documentation/docs/kmip_support/key_rotation/index.md b/documentation/docs/kmip_support/key_rotation/index.md new file mode 100644 index 0000000000..5a1d3e6946 --- /dev/null +++ b/documentation/docs/kmip_support/key_rotation/index.md @@ -0,0 +1,521 @@ +# Key Rotation + +Cosmian KMS supports **manual key rotation** for all key types through the +standard KMIP operations: + +| KMIP operation | Applies to | CLI command | +| ---------------- | --------------------------------------- | -------------------------------- | +| `Re-Key` | Symmetric keys, secret data | `ckms sym keys rekey` | +| `Re-Key Key Pair`| Asymmetric key pairs (RSA, EC, PQC, …) | `ckms {rsa,ec,pqc} keys rekey` | +| `ReCertify` | X.509 certificates | `ckms certificates certify --certificate-id-to-re-certify` | + +On every rotation the server: + +1. Generates a new cryptographic object (or new certificate) under a **fresh UID**. +2. Sets a `ReplacementObjectLink` on the old object pointing to the new UID. +3. Sets a `ReplacedObjectLink` on the new object pointing back to the old UID. +4. Transitions the old key to **Deactivated** (KMIP §4.57, transition 6 — see [below](#old-key-deactivated-after-rotation)). +5. Increments `x-rotate-generation` and records `x-rotate-date` on the new object. + +> **Auto-rotation** (scheduler-driven, policy-based) is covered separately in +> [Auto-Rotation Policy](auto_rotation_policy.md). +> **HSM key rotation** is covered in [HSM Key Rotation](hsm.md). + +--- + +## State restrictions + +Only objects in the following states can be the **source** of a rotation: + +| State | Rotation allowed? | Rationale | +| ------------------- | :---------------: | ------------------------------------------------------------------- | +| **Active** | ✅ | Primary valid source state. | +| **Deactivated** | ✅ | KMIP §6.1.46 permits it; a replacement key should still be issued. | +| **Compromised** | ✅ | Rotating a compromised key is the recommended incident response. | +| **Pre-Active** | ❌ | Key material was never activated — rotating unused material is premature. | +| **Destroyed** | ❌ | Object no longer exists. | +| **Destroyed_Compromised** | ❌ | Object no longer exists. | + +> This restriction applies to the **source** key only. The *output* of a +> rotation can enter `Pre-Active` if the request includes an `Offset > 0` +> (the new key's Activation Date is set to `Initial Date + Offset`). + +--- + +## Old key Deactivated after rotation + +### Why deactivation is enforced + +A key in the **Deactivated** state SHALL NOT be used for applying cryptographic +protection (encryption, signing, wrapping, MACing, deriving). It MAY still be +used to *process* already-protected data (decryption, signature verification, +unwrapping). This asymmetry is the key principle behind post-rotation key +handling: + +- **Limit blast radius.** If the old key material is compromised after rotation, + no new data was ever encrypted with it — the damage is bounded. +- **Enforce the cryptoperiod.** NIST SP800-57-1 defines a *cryptoperiod* as the + span during which a key is authorised to protect data. Key rotation ends that + period; the Deactivated state makes the boundary enforceable by the server. +- **Preserve backward compatibility.** Consumers holding ciphertexts produced + before rotation must still be able to decrypt. Deactivated keeps the key + accessible for decryption without allowing it to encrypt new data. + +### How KMIP §4.57 mandates it + +KMIP §4.57 defines the **State** attribute and lists the only legal state +transitions. **Transition 6** governs `Active → Deactivated` and states that it +SHALL occur in one of three ways: + +1. The object's **Deactivation Date is reached**. +2. A client issues a **Revoke** operation with a non-Compromised reason. +3. A client **Modify Attribute** sets the Deactivation Date to now or the past. + +`Re-Key` / `Re-Key Key Pair` are *not* listed as a direct cause of transition 6. +The spec mandates deactivation indirectly through path 1: the KMIP §4.33 +**Deactivation Date** attribute rules list `Re-key` and `Re-key Key Pair` under +*"When implicitly set"*, meaning the server **MUST** set the Deactivation Date on +the old key as part of the rotation operation. Because that date is set to +*now* (or already passed), the condition "Deactivation Date is reached" +immediately triggers transition 6. + +In short: the spec does not have a direct Re-Key → Deactivated transition, but +it mandates deactivation by requiring the server to set the Deactivation Date +during `Re-Key`, which then fires transition 6 automatically. + +### Consequences + +- The old key can no longer be used for **Encrypt** or **Sign** operations + (those require `Active` state). +- The old key remains available for **Decrypt** and **Verify** (processing + operations accept `Active`, `Deactivated`, and `Compromised` states per + KMIP §3.31), so in-flight ciphertexts continue to decrypt. +- You can call `Revoke` on an already-Deactivated key; the call succeeds as + a no-op (the state is already revoked). +- You can call `Destroy` on a Deactivated key directly — no prior `Revoke` + is required. + +--- + +## Keysets + +A **keyset** is a named group of related key generations. Each generation is +a distinct cryptographic key (different material, different UID) produced by +successive `Re-Key` operations. + +### Creating a SQL key in a keyset + +For SQL-backed keys the key's UID **must equal the keyset name** from the +start. Supply both `UniqueIdentifier` and `x-rotate-name` in the same `Create` +request: + +```bash +# Create a key whose UID is the keyset name +ckms sym keys create --key-id my-keyset --algorithm aes --length 256 +ckms sym keys set-rotation-policy --key-id my-keyset --name my-keyset +``` + +Attempting `set-rotation-policy --name X` on a SQL key whose UID is not `X` +is rejected: + +```text +Invalid Request: SetAttribute: rotate_name ('X') must equal the key's UID — create the key with the keyset name as its ID +``` + +### SQL keyset UID scheme + +UIDs are assigned deterministically at each generation: + +```text +my-keyset ← gen 0 (UID equals the keyset name) +my-keyset@1 ← gen 1 (after first Re-Key) +my-keyset@2 ← gen 2 (after second Re-Key) +``` + +The `Re-Key` response always returns the new key's real UID (e.g. `my-keyset@1`). + +### Addressing syntax + +A keyset can be referenced by name wherever a `UniqueIdentifier` is accepted +(`Encrypt`, `Decrypt`, `Get`, `Re-Key`, …): + +| Syntax | Resolves to | +| -------------------- | ------------------------------------------------ | +| `my-keyset` | Latest generation (bare name = `@latest`) | +| `my-keyset@latest` | Latest generation (explicit alias) | +| `my-keyset@first` | Generation 0 | +| `my-keyset@0` | Generation 0 (numeric alias for `@first`) | +| `my-keyset@N` | Generation N | + +**Encrypt / Sign** always resolves to the **latest** generation. + +**Decrypt / Verify** walks the chain newest-to-oldest until one generation +succeeds, allowing ciphertexts encrypted with an older (now Deactivated) key +to continue to decrypt after rotation. + +> **`@latest` is a virtual alias.** It is never stored in the database and +> is never returned in a response. `my-keyset` (bare name) also resolves to +> the latest generation even though `my-keyset` is the literal UID of gen-0. +> To access gen-0 explicitly, use `my-keyset@0` or `my-keyset@first`. + +### Non-latest guard + +Only the **latest generation** of a keyset can be rotated via `Re-Key`. +Attempting to rotate a retired member is rejected: + +```text +Invalid Request: ReKey: key '' is not the latest in its keyset — +only the latest generation can be rotated +``` + +Use `my-keyset@0` (explicit generation) rather than `my-keyset` (bare name) +to target an older generation — and expect that call to fail. + +### Keyset internals (SQL) + +Keyset state is stored as KMIP vendor attributes in the database: + +| Attribute | Type | Meaning | +| --------------------- | ---------- | ---------------------------------------------------- | +| `x-rotate-name` | `string` | Keyset name; equals the key UID for gen-0. | +| `x-rotate-generation` | `i32` | `0` for gen-0, incremented on each `Re-Key`. | +| `x-rotate-latest` | `bool` | `true` on the current key; `false` on older keys. | + +KMIP link attributes mirror the chain for protocol compliance: +`ReplacementObjectLink` (old → new) and `ReplacedObjectLink` (new → old). +Keyset chain traversal for `Decrypt` / `Verify` uses +`x-rotate-generation` (sorted descending), not the link back-pointers. + +--- + +## Rotation flows by key type + +### 1. Plain symmetric key + +A plain symmetric key carries only its own policy. + +**What happens:** + +1. Fresh key material is generated (same algorithm and length). +2. The new key gets a new UID (`my-keyset@N` for keyset keys, fresh UUID otherwise). +3. `ReplacedObjectLink` on the new key → old key. +4. `ReplacementObjectLink` on the old key → new key. +5. Old key → **Deactivated**. + +**KMIP link chain after two rotations:** + +```mermaid +flowchart LR + K0["Key₀ (gen 0)
Deactivated"] -->|ReplacementObjectLink| K1["Key₁ (gen 1)
Deactivated"] + K1 -->|ReplacementObjectLink| K2["Key₂ (gen 2)
Active"] + K2 -->|ReplacedObjectLink| K1 + K1 -->|ReplacedObjectLink| K0 +``` + +**CLI:** + +```bash +# Manual rotation of a SQL keyset key +ckms sym keys rekey --key-id my-keyset +# Response: new UID = my-keyset@1 + +# Manual rotation of a plain UUID key +ckms sym keys rekey --key-id +``` + +--- + +### 2. Wrapping key + +A *wrapping key* is referenced by one or more *wrapped* keys via a +`WrappingKeyLink` attribute. Rotating a wrapping key re-wraps all its +dependants atomically. + +**What happens:** + +1. A new wrapping key is created and committed to the database (Phase 1). +2. Every `Active` key whose `WrappingKeyLink` points to the old wrapping key + is fetched, unwrapped in memory (plaintext never stored), and re-wrapped + with the new wrapping key (Phase 2). +3. Each wrapped key's `WrappingKeyLink` is updated to the new wrapping key UID. +4. Standard rotation metadata is applied; old wrapping key → Deactivated. + +```mermaid +sequenceDiagram + participant Client + participant KMS + participant DB + + Client->>KMS: Re-Key(wrapping_key_uid) + KMS->>DB: Phase 1 — create new wrapping key (committed) + loop For each wrapped dependant + KMS->>DB: retrieve wrapped key + KMS->>KMS: unwrap with old wrapping key + KMS->>KMS: re-wrap with new wrapping key + KMS->>DB: update WrappingKeyLink → new wrapping key UID + end + KMS->>DB: Phase 2 — retire old wrapping key (Deactivated, links set) + KMS-->>Client: Re-KeyResponse(new_wrapping_key_uid) +``` + +--- + +### 3. Wrapped key + +A *wrapped key* stores its key material encrypted under a wrapping key. + +**What happens:** + +1. The wrapped key is exported and unwrapped in memory using the current + wrapping key (plaintext never stored). +2. Fresh plaintext key material is generated from the unwrapped attributes. +3. The new material is re-wrapped with the same wrapping key. +4. The new ciphertext is stored under a new UID with an active `WrappingKeyLink`. +5. Standard rotation metadata is applied; old wrapped key → Deactivated. + +```mermaid +sequenceDiagram + participant Client + participant KMS + participant DB + + Client->>KMS: Re-Key(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: retire old key (Deactivated, ReplacementObjectLink → new key) + KMS-->>Client: Re-KeyResponse(new_key_uid) +``` + +--- + +### 4. Asymmetric key pair + +`Re-Key Key Pair` targets the **private key** UID. The server resolves the +associated public key via the `PublicKeyLink` attribute. + +**What happens:** + +1. A new private key + public key pair is generated (same algorithm). +2. Both receive new UIDs; the new private key carries a `PublicKeyLink` to + the new public key. +3. Standard `ReplacementObjectLink` / `ReplacedObjectLink` links are set on + both pairs. +4. Old private key and old public key → **Deactivated**. + +```mermaid +sequenceDiagram + participant Client + participant KMS + participant DB + + Client->>KMS: Re-Key Key Pair(private_key_uid) + KMS->>DB: retrieve private key + KMS->>DB: retrieve linked public key (PublicKeyLink) + KMS->>KMS: generate new key pair (same algorithm) + KMS->>DB: Phase 1 — store new private key + new public key + KMS->>DB: Phase 2 — retire old private key + public key (Deactivated, links) + KMS-->>Client: Re-Key Key PairResponse(new_sk_uid, new_pk_uid) +``` + +**CLI:** + +```bash +# EC key pair +ckms ec keys rekey --key-id + +# RSA key pair +ckms rsa keys rekey --key-id + +# Post-quantum (ML-KEM, ML-DSA, SLH-DSA) +ckms pqc keys rekey --key-id +``` + +--- + +### 5. Wrapped private key (Covercrypt) + +A Covercrypt master private key follows the same `Re-Key Key Pair` flow. The +wrapped key is unwrapped in memory, the Covercrypt partition attributes are +re-keyed, and the new wrapped private key is stored under a fresh UID. + +> Setting a rotation policy on a wrapped private key always works: the +> `x-rotate-*` attributes are stored in the metadata column (not inside the +> ciphertext block) and do not require the key to be unwrapped first. + +--- + +### 6. Certificate renewal (`ReCertify`) + +Certificate renewal creates a **new certificate for the same key pair** — no +new key material is generated. + +**What happens:** + +1. The existing certificate is retrieved and its issuer / subject are resolved. +2. A new certificate is built and signed (same key pair, same issuer). +3. The new certificate receives a fresh UID. +4. `ReplacedObjectLink` on the new cert → old cert. +5. `ReplacementObjectLink` on the old cert → new cert. +6. All keys linked to the old certificate have their `CertificateLink` updated. +7. `x-rotate-generation` and `x-rotate-date` are updated. + +```mermaid +sequenceDiagram + participant Client + participant KMS + participant DB + + Client->>KMS: ReCertify(old_cert_uid) + KMS->>DB: retrieve old certificate + KMS->>KMS: resolve issuer + subject from old cert + KMS->>KMS: build & sign new certificate (same key pair) + KMS->>DB: Phase 1 — store new cert (fresh UID) + KMS->>DB: Phase 2 — update old cert (ReplacementObjectLink) + KMS->>DB: Phase 2 — relink keys (CertificateLink → new cert) + KMS-->>Client: ReCertifyResponse(new_cert_uid) +``` + +**Attribute changes (KMIP 2.1 §6.1.45):** + +| Attribute | New certificate | Old certificate | +| ----------------------------- | ------------------- | ----------------------- | +| `Unique Identifier` | Fresh UUID | Unchanged | +| `Initial Date` | Now | Unchanged | +| `Link[ReplacedObjectLink]` | → old cert UID | — | +| `Link[ReplacementObjectLink]` | — | → new cert UID | +| `Link[PublicKeyLink]` | Copied from old | Unchanged | +| `Link[PrivateKeyLink]` | Copied from old | Unchanged | +| `Name` | Inherited from old | Removed (per KMIP spec) | +| `State` | Active | Active (not Deactivated — certificates are exempt from §4.57) | +| `x-rotate-generation` | old + 1 | Unchanged | +| `x-rotate-date` | Now | Unchanged | + +**CLI:** + +```bash +# Renew a CA-signed certificate (same key pair, new validity period) +ckms certificates certify \ + --certificate-id-to-re-certify \ + --issuer-private-key-id \ + --days 365 + +# Self-signed certificate renewal +ckms certificates certify \ + --certificate-id-to-re-certify \ + --days 3650 +``` + +**Standards:** + +| Standard | Relevance | +| -------- | --------- | +| [KMIP 2.1 §6.1.45](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html#_Toc57115677) | Normative definition of `ReCertify` | +| [RFC 4210](https://www.rfc-editor.org/rfc/rfc4210.html) §5.3.5–5.3.6 | CMP Key Update Request / Response (`kur`/`kup`) — the wire-protocol equivalent | +| [RFC 5280](https://www.rfc-editor.org/rfc/rfc5280.html) | X.509v3 certificate structure and validity periods | + +--- + +### 7. KEK-protected key (server-wide key-encryption key) + +When the KMS server is started with `--key-encryption-key `, every +object stored in the database is transparently wrapped by the KEK. Rotation +works identically to the wrapped-key flow above — the server unwraps in memory, +generates fresh material, re-wraps, and stores the result. + +```bash +# Example server startup with SoftHSM2 KEK +cosmian_kms \ + --database-type sqlite \ + --hsm-model softhsm2 \ + --hsm-slot 0 \ + --hsm-password 12345678 \ + --key-encryption-key "hsm::softhsm2::0::my-kek" +``` + +No special handling is required for rotation policy — `SetAttribute` on a +KEK-wrapped key writes the `x-rotate-*` attributes to the metadata column, not +into the ciphertext, so no unwrap is needed. + +--- + +## Rotation dispatch overview + +```mermaid +flowchart TD + subgraph "Re-Key dispatch" + REQ["Re-Key / Re-Key Key Pair / ReCertify"] --> DISPATCH{"Object type?"} + DISPATCH -->|SymmetricKey, no dependants| PLAIN["Plain rekey
(new material, new UID)"] + DISPATCH -->|SymmetricKey, has WrappingKeyLink dependants| WRAP_K["Wrapping-key rotation
(Phase 1 commit → Phase 2 re-wrap)"] + DISPATCH -->|SymmetricKey, is wrapped| WRAP_D["Wrapped-key rotation
(unwrap → new material → re-wrap)"] + DISPATCH -->|PrivateKey| ASYM["Re-Key Key Pair"] + DISPATCH -->|Certificate| CERT["ReCertify
(same key pair, new cert UID)"] + PLAIN & WRAP_K & WRAP_D & ASYM & CERT --> META["Update metadata
(generation++, date, links,
old key → Deactivated)"] + end +``` + +--- + +## KMIP attribute changes on manual rotation + +When the user explicitly calls `Re-Key` (e.g. `ckms sym keys rekey`), the +following attributes are set on the old and new key: + +| Attribute | Old key | New key | +| ----------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------- | +| `Unique Identifier` | unchanged | fresh UUID (or `name@N` for keyset keys) | +| `State` | **Deactivated** (§4.57) | Active | +| `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) | **`0`** — must be re-armed explicitly on the new key | +| `x-rotate-name` | unchanged | inherited from old key | +| `x-rotate-offset` | unchanged | `None` (not inherited for manual rekey) | +| `Name` | removed | inherited from old key | + +> **Manual vs auto-rotation difference:** `x-rotate-interval` is intentionally +> set to `0` (not inherited) on the new key after a manual rotation. This +> forces the operator to re-evaluate the rotation policy for the new key rather +> than blindly continuing 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 "my-keyset" +> ``` + +--- + +## Revoking superseded keys + +After rotation the old key is **Deactivated** (not Destroyed). Its material +persists so that in-flight Decrypt / Verify operations against old ciphertexts +continue to work. Once all consumers have migrated, destroy the old key: + +```bash +# Find the old key UID from the new key's ReplacedObjectLink attribute +ckms objects get-attributes --key-id + +# Destroy the old key directly (Deactivated keys do not need a prior Revoke) +ckms sym keys destroy --key-id +``` + +If you need to place the old key into `Compromised` state (e.g. for audit +records), call `Revoke` first with a compromise reason: + +```bash +# Symmetric key +ckms sym keys revoke -k "Superseded" + +# Asymmetric key pair (revokes both private and linked public key) +ckms ec keys revoke -k "Superseded" + +# Certificate +ckms certificates revoke -c "Superseded" +``` diff --git a/documentation/docs/kmip_support/operations.md b/documentation/docs/kmip_support/operations.md index 957e9d3ad6..6d19944a4b 100644 --- a/documentation/docs/kmip_support/operations.md +++ b/documentation/docs/kmip_support/operations.md @@ -1,4 +1,4 @@ -In [chapter 6](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/cs01/kmip-spec-v2.1-cs01.html#_Toc32239394), the KMIP 2.1 +In [chapter 6](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html#_Toc57115631), the KMIP 2.1 specifications describe 57 potential operations that can be performed on a KMS. ### Supported Operations diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 32d7d153fd..4bd36f7ed1 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -53,9 +53,9 @@ extra_javascript: #see this for Katex: https://squidfunk.github.io/mkdocs-materi - javascripts/macros.js - javascripts/log_filter.js extra_css: - - https://unpkg.com/katex@0/dist/katex.min.css + - https://unpkg.com/katex@0/dist/katex.min.css hooks: - - hooks.py + - hooks.py plugins: - search - kroki @@ -198,7 +198,12 @@ nav: - Import: kmip_support/_import.md - Locate: kmip_support/_locate.md - Mac: kmip_support/_mac.md - - Re-Key: kmip_support/_re-key.md - - Re-Key Key Pair: kmip_support/_re-key_key_pair.md + - Key rotation: + - Re-Key: kmip_support/key_rotation/_re-key.md + - Re-Key Key Pair: kmip_support/key_rotation/_re-key_key_pair.md + - Re-Certify: kmip_support/key_rotation/_re-certify.md + - Key Rotation: kmip_support/key_rotation/index.md + - HSM Key Rotation: kmip_support/key_rotation/hsm.md + - Auto-Rotation Policy: kmip_support/key_rotation/auto_rotation_policy.md - Revoke: kmip_support/_revoke.md - Sign: kmip_support/_signature.md diff --git a/pkg/kms.toml b/pkg/kms.toml index 13c02fd337..4311dff1c6 100644 --- a/pkg/kms.toml +++ b/pkg/kms.toml @@ -89,6 +89,18 @@ info = false # and grant access rights for Create Kmip Operation. # privileged_users = ["", ""] +# ── Key Rotation & Keyset Management ──────────────────────────────────────── +# Background auto-rotation check interval (seconds). +# Set to 0 (default) to disable the auto-rotation background task. +# When enabled, must be at least 60 seconds to avoid excessive database churn. +# auto_rotation_check_interval_secs = 0 + +# Depth at which a keyset chain decryption triggers a server-side warning. +# Keyset chain traversal is unbounded (stopped only by cycle detection); +# this threshold emits a warning log so operators can flag stale ciphertexts. +# Default: 5. +# keyset_warn_depth = 5 + # Check the database configuration documentation pages for more information [db] # The main database of the KMS server that holds default cryptographic objects and permissions. diff --git a/resources/kms.toml b/resources/kms.toml index 13ee03179e..cbe3c14ba6 100644 --- a/resources/kms.toml +++ b/resources/kms.toml @@ -38,6 +38,18 @@ default_username = "admin" # # UIDs for the above: hsm::utimaco::0::, hsm::utimaco::1:: +# ── Key Rotation & Keyset Management ──────────────────────────────────────── +# Background auto-rotation check interval (seconds). +# Set to 0 (default) to disable the auto-rotation background task. +# When enabled, must be at least 60 seconds to avoid excessive database churn. +# auto_rotation_check_interval_secs = 0 + +# Depth at which a keyset chain decryption triggers a server-side warning. +# Keyset chain traversal is unbounded (stopped only by cycle detection); +# this threshold emits a warning log so operators can flag stale ciphertexts. +# Default: 5. +# keyset_warn_depth = 5 + [http] port = 9998 hostname = "0.0.0.0" diff --git a/scripts/update_log_index.py b/scripts/update_log_index.py deleted file mode 100644 index 53495ec69b..0000000000 --- a/scripts/update_log_index.py +++ /dev/null @@ -1,1044 +0,0 @@ -#!/usr/bin/env python3 -""" -update_log_index.py — Smart updater for log-reference.md - -Compares documented log call-sites against actual source, then merges: - • Removes (or flags) stale entries no longer in source - • Updates the File column when a log call moved to a different file - • Appends new entries with auto-extracted variable names - -Usage: - python3 scripts/update_log_index.py -""" - -import argparse -import re -import sys -from dataclasses import dataclass, field -from pathlib import Path -from typing import Optional - -# Path resolution - -SCRIPT_DIR = Path(__file__).resolve().parent -AGENT_DIR = SCRIPT_DIR.parent -DOC_FILE = AGENT_DIR / "documentation" / "docs" / "configuration" / "log-reference.md" - - -def _find_repo_root() -> Path: - """Walk up from script dir looking for a directory with Cargo.toml + crate/.""" - p = SCRIPT_DIR - for _ in range(12): - if (p / "Cargo.toml").exists() and (p / "crate").is_dir(): - return p - p = p.parent - raise RuntimeError(f"Could not find repo root from {SCRIPT_DIR}") - - -REPO_ROOT = _find_repo_root() -CARGO_TOML = REPO_ROOT / "Cargo.toml" - -# Crate list - -RUST_CRATES = [ - "crate/server", - "crate/server_database", - "crate/crypto", - "crate/kmip", - "crate/interfaces", - "crate/access", - "crate/hsm/base_hsm", - "crate/clients/clap", - "crate/clients/ckms", - "crate/clients/client", - "crate/clients/client_utils", - "crate/clients/pkcs11/provider", - "crate/clients/pkcs11/module", - "crate/clients/cng", -] - -UI_CRATE = "ui/src" # normalized (no trailing slash) - -# Crates that only exist on specific platforms. -# When the crate directory is absent on the current OS (e.g. Windows-only crates on Linux CI), -# their doc entries are never flagged as stale to avoid false positives. -PLATFORM_SPECIFIC_CRATES: frozenset[str] = frozenset({"crate/clients/cng"}) # Windows-only - -# ── ANSI colours ─────────────────────────────────────────────────────────── - -RED = "\033[91m" -ORANGE = "\033[93m" -GREEN = "\033[92m" -BOLD = "\033[1m" -DIM = "\033[2m" -RESET = "\033[0m" - -_USE_COLOR = True # set to False via --no-color - -def _red(s): return f"{RED}{s}{RESET}" if _USE_COLOR else s -def _orange(s): return f"{ORANGE}{s}{RESET}" if _USE_COLOR else s -def _green(s): return f"{GREEN}{s}{RESET}" if _USE_COLOR else s -def _bold(s): return f"{BOLD}{s}{RESET}" if _USE_COLOR else s -def _dim(s): return f"{DIM}{s}{RESET}" if _USE_COLOR else s - -# ── Severity ordering ────────────────────────────────────────────────────── - -SEVERITY = {"error": 0, "warn": 1, "info": 2, "debug": 3, "trace": 4, "log": 5} - -# ── DocEntry ─────────────────────────────────────────────────────────────── - -@dataclass -class DocEntry: - crate_path: str - file_rel: str - level: str - message: str # raw string, backticks stripped - variables: str # raw Variables cell content - notes: str # raw Notes cell content - line_idx: int # 0-based index in the original lines list - - @property - def norm_message(self) -> str: - return _normalize_msg(self.message) - - def quad_key(self) -> tuple: - return (self.crate_path, self.file_rel, self.level, self.norm_message) - - def triple_key(self) -> tuple: - return (self.crate_path, self.level, self.norm_message) - - def short(self) -> str: - msg = self.message[:55] + "…" if len(self.message) > 55 else self.message - return f"{self.crate_path:<35} {self.level:<5} {self.file_rel}" - - -def _normalize_msg(msg: str) -> str: - """Normalise for comparison: {e:?} → {e}, ${uid} → {uid}.""" - s = re.sub(r'\{(\w+):[^}]*\}', r'{\1}', msg.strip()) # Rust format spec - s = re.sub(r'\$\{([^}]+)\}', r'{\1}', s) # JS template literal - return s - -# ── MergeResult ──────────────────────────────────────────────────────────── - -@dataclass -class MergeResult: - deleted: list = field(default_factory=list) # DocEntry - flagged: list = field(default_factory=list) # DocEntry (→ [REMOVED]) - moved: list = field(default_factory=list) # (DocEntry, new_file: str) - mult_upd: list = field(default_factory=list) # (DocEntry, old_mult: int, new_mult: int) - added: list = field(default_factory=list) # (crate, file, level, msg, mult) - -# ── Version helpers ──────────────────────────────────────────────────────── - -def _read_version() -> str: - if not CARGO_TOML.exists(): - return "develop" - text = CARGO_TOML.read_text(encoding="utf-8") - m = re.search( - r'\[workspace\.package\].*?^version\s*=\s*"([^"]+)"', - text, re.DOTALL | re.MULTILINE, - ) - if m: - return m.group(1) - m2 = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) - return m2.group(1) if m2 else "develop" - - -# ── Interactive prompts ──────────────────────────────────────────────────── - -def _prompt(question: str, default: str = "") -> str: - try: - ans = input(question).strip() - except (EOFError, KeyboardInterrupt): - print() - sys.exit(0) - return ans if ans else default - - -def _print_disclaimer() -> None: - """Always-visible accuracy warning printed before the interactive prompts.""" - print() - print(_orange("┌─────────────────────────────────────────────────────────┐")) - print(_orange("│ ⚠ ACCURACY DISCLAIMER │")) - print(_orange("│ │")) - print(_orange("│ This script uses heuristic regex extraction — it may │")) - print(_orange("│ produce FALSE POSITIVES (flag a valid entry as stale) │")) - print(_orange("│ or FALSE NEGATIVES (miss a call-site entirely). │")) - print(_orange("│ │")) - print(_orange("│ Known limitations: │")) - print(_orange("│ • Complex macro bodies (nested calls, cfg-gated blocks) │")) - print(_orange("│ • Messages built at runtime (format! + concat) │")) - print(_orange("│ • UI console calls with non-identifier second args │")) - print(_orange("│ │")) - print(_orange("│ Always review the report before accepting deletions. │")) - print(_orange("│ Flag instead of delete when unsure — safer. │")) - print(_orange("└─────────────────────────────────────────────────────────┘")) - - -def _phase0_prompts() -> bool: - """Returns silent_delete: bool.""" - _print_disclaimer() - - print() - print(_bold("── Stale entries ────────────────────────────────────────")) - print(" Entries in the doc that no longer exist in source:") - print(" y Delete them silently (default)") - print(" n Flag with [REMOVED] in the Notes column — safer, reversible") - ans = _prompt(" Delete stale entries? [Y/n]: ", "y").strip().lower() - silent_delete = (ans != "n") - - print() - return silent_delete - -# ── Markdown parser ──────────────────────────────────────────────────────── - -_LEVEL_ROW_RE = re.compile(r'^\|\s*`(error|warn|info|debug|trace|log)`\s*\|') -_CRATE_RE = re.compile(r'Crate path:\s*`([^`]+)`') -_LINK_RE = re.compile(r'^\[([^\]]+)\]\([^)]+\)$') -_MULT_RE = re.compile(r'×(\d+) in this file') -_TABLE_SEP_RE = re.compile(r'^\|[\s\-:]+\|[\s\-:]+\|[\s\-:]+\|') # |---|---|...| - - -def _split_table_row(line: str) -> list[str]: - """ - Split a markdown table row on | characters that are NOT inside backtick spans. - - Handles single-backtick `…` and double-backtick `` … `` (and any N-backtick) - spans correctly, so log messages containing | are not split mid-cell. - """ - parts: list[str] = [] - current: list[str] = [] - i = 0 - n = len(line) - while i < n: - ch = line[i] - if ch == '`': - # Count the run of backticks that opens (or closes) a span. - j = i + 1 - while j < n and line[j] == '`': - j += 1 - tick_str = line[i:j] - # Find the matching closing run of the same length. - close = line.find(tick_str, j) - if close >= 0: - # Consume the entire span (no | splitting inside). - current.append(line[i : close + len(tick_str)]) - i = close + len(tick_str) - else: - # Unmatched backtick run — emit literally. - current.append(tick_str) - i = j - elif ch == '|': - parts.append(''.join(current)) - current = [] - i += 1 - else: - current.append(ch) - i += 1 - parts.append(''.join(current)) - return parts - - -def _strip_cell(raw: str) -> str: - """Strip backticks or Markdown link wrapper from a table cell.""" - s = raw.strip() - lm = _LINK_RE.match(s) - if lm: - return lm.group(1) - # Handle `` content `` wrapping (used when message itself contains a backtick). - if s.startswith('`` ') and s.endswith(' ``'): - return s[3:-3] - # Strip outermost backtick pair only; inner backticks are part of the message. - if len(s) >= 2 and s[0] == '`' and s[-1] == '`': - return s[1:-1] - return s - - -def parse_doc(path: Path) -> tuple[list[str], list[DocEntry], dict[str, int]]: - """Return (original_lines, doc_entries, crate_sep_idx). - - crate_sep_idx maps crate_path → line index of its table separator (|---|...|). - Used to insert new rows even when a crate section has no existing data rows. - """ - lines = path.read_text(encoding="utf-8").splitlines() - entries: list[DocEntry] = [] - crate_sep_idx: dict[str, int] = {} - current_crate: Optional[str] = None - - for i, line in enumerate(lines): - cm = _CRATE_RE.search(line) - if cm: - current_crate = cm.group(1).rstrip("/") - continue - - if current_crate and _TABLE_SEP_RE.match(line) and current_crate not in crate_sep_idx: - crate_sep_idx[current_crate] = i - continue - - if current_crate and _LEVEL_ROW_RE.match(line): - parts = _split_table_row(line) - if len(parts) < 7: - continue - level = _strip_cell(parts[1]) - message = _strip_cell(parts[2]) - file_raw = _strip_cell(parts[3]) - variables = parts[4].strip() - notes = parts[5].strip() - entries.append(DocEntry( - crate_path=current_crate, - file_rel=file_raw, - level=level, - message=message, - variables=variables, - notes=notes, - line_idx=i, - )) - - return lines, entries, crate_sep_idx - -# ── Test-file detection ──────────────────────────────────────────────────── - -def _is_test_file(rel: str) -> bool: - p = Path(rel) - if any(part in ("tests", "test") for part in p.parts[:-1]): - return True - name = p.name - return any([ - name.endswith("_tests.rs"), - name.startswith("tests_"), - name.startswith("test_"), - name in ("tests_shared.rs", "test_helpers.rs"), - "additional_redis_findex_tests" in name, - ]) - -# ── Rust string + paren-depth extraction ────────────────────────────────── - -def _skip_rust_string(text: str, i: int) -> int: - """Advance past the double-quoted Rust string starting at position i.""" - i += 1 - while i < len(text): - if text[i] == "\\": - i += 2 - continue - if text[i] == '"': - return i + 1 - i += 1 - return i - - -def _extract_body(text: str, start: int) -> str: - """ - Extract macro call body from start (after opening paren) to closing paren. - - Handles: nested parens, double-quoted strings, raw strings r#\"…\"#, - line comments // …, and block comments /* … */. - """ - depth = 1 - i = start - while i < len(text) and depth > 0: - c = text[i] - # Line comment — skip to end of line. - if c == '/' and i + 1 < len(text) and text[i + 1] == '/': - while i < len(text) and text[i] != '\n': - i += 1 - continue - # Block comment — skip to */. - if c == '/' and i + 1 < len(text) and text[i + 1] == '*': - i += 2 - while i + 1 < len(text) and not (text[i] == '*' and text[i + 1] == '/'): - i += 1 - i += 2 - continue - # Raw string r#\"…\"# (any number of hashes). - if c == 'r' and i + 1 < len(text) and text[i + 1] == '#': - j = i + 1 - hashes = 0 - while j < len(text) and text[j] == '#': - hashes += 1 - j += 1 - if j < len(text) and text[j] == '"': - j += 1 # skip opening " - end_marker = '"' + '#' * hashes - end = text.find(end_marker, j) - if end >= 0: - i = end + len(end_marker) - continue - if c == '(': - depth += 1 - elif c == ')': - depth -= 1 - elif c == '"': - i = _skip_rust_string(text, i) - continue - i += 1 - return text[start : i - 1] - - -def _first_string_from_body(body: str) -> Optional[str]: - """ - Extract the log message string from a macro call body. - Handles: target: "...", "message" syntax by skipping the target string. - """ - stripped = body.strip() - skip_first = stripped.startswith("target:") or stripped.startswith("module_path!") - - skipped = 0 - i = 0 - while i < len(body): - if body[i] != '"': - i += 1 - continue - # Found a string literal - j = i + 1 - chars: list[str] = [] - while j < len(body): - if body[j] == "\\": - nxt = body[j + 1] if j + 1 < len(body) else "" - if nxt == "\n": pass # actual newline = line continuation, discard - elif nxt == "n": chars.append("\\"); chars.append("n") # \n → keep as literal \n - elif nxt == "t": chars.append("\\"); chars.append("t") # \t → keep as literal \t - elif nxt == '"': chars.append('"') # \" → " (unescape) - elif nxt == "\\": chars.append("\\") # \\ → \ (unescape) - else: chars.append(body[j : j + 2]) # keep raw - j += 2 - continue - if body[j] == '"': - break - chars.append(body[j]) - j += 1 - content = "".join(chars) - if skipped == 0 and skip_first: - skipped += 1 - i = j + 1 - continue - return content - return None - - -_MACRO_RE = re.compile(r'\b(trace|debug|info|warn|error)!\s*\(') - - -def extract_rust_logs(crate_path: str) -> list[tuple[str, str, str]]: - """Return [(file_rel, level, message)] from Rust source (test files excluded).""" - crate_dir = REPO_ROOT / crate_path - if not crate_dir.exists(): - return [] - - results: list[tuple[str, str, str]] = [] - for rs_file in sorted(crate_dir.rglob("*.rs")): - try: - rel = str(rs_file.relative_to(crate_dir)) - except ValueError: - continue - if _is_test_file(rel): - continue - - text = rs_file.read_text(encoding="utf-8", errors="replace") - for match in _MACRO_RE.finditer(text): - level = match.group(1) - body = _extract_body(text, match.end()) - msg = _first_string_from_body(body) - if msg is not None: - msg = re.sub(r"\\\n\s*", "", msg) # collapse line continuations - results.append((rel, level, msg)) - - return results - -# ── Web UI extractor ─────────────────────────────────────────────────────── - -_CONSOLE_RE = re.compile(r'\bconsole\.(error|warn|info|debug|log)\s*\(') - - -def _extract_ui_message(body: str) -> Optional[str]: - """ - First string argument from a console.* call body. - - Synthesizes a {var} suffix for a single simple-identifier second argument: - console.error("prefix:", e) → "prefix: {e}" - console.error("prefix:", err.msg) → "prefix:" (no synthesis) - console.error(`template ${uid}`) → "template ${uid}" (raw; _normalize_msg handles $) - """ - body = body.strip() - first_msg: Optional[str] = None - rest = "" - - if body.startswith("`"): - m = re.match(r'`((?:[^`\\]|\\.)*)`', body) - if m: - first_msg = m.group(1) # keep raw ${...}; _normalize_msg handles it - rest = body[m.end():] - elif body.startswith('"'): - m = re.match(r'"((?:[^"\\]|\\.)*)"', body) - if m: - first_msg = m.group(1) - rest = body[m.end():] - elif body.startswith("'"): - m = re.match(r"'((?:[^'\\]|\\.)*)'", body) - if m: - first_msg = m.group(1) - rest = body[m.end():] - - if first_msg is None: - return None - - # Synthesize {identifier} for simple-identifier second arg only. - # Stops at `.`, `?`, `[`, `(` etc. — those are expressions, not plain vars. - rest_stripped = rest.lstrip(" ,") - id_m = re.match(r'^([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[,)]', rest_stripped) - if id_m: - first_msg = first_msg.rstrip() + " {" + id_m.group(1) + "}" - - return first_msg - - -def _skip_js_string(text: str, i: int, quote: str) -> int: - i += 1 - while i < len(text): - if text[i] == "\\": - i += 2 - continue - if text[i] == quote: - return i + 1 - i += 1 - return i - - -def extract_ui_logs() -> list[tuple[str, str, str]]: - """Return [(file_rel_to_ui_src, level, message)] from ui/src/.""" - ui_dir = REPO_ROOT / "ui" / "src" - if not ui_dir.exists(): - return [] - - results: list[tuple[str, str, str]] = [] - all_ts = sorted(list(ui_dir.rglob("*.ts")) + list(ui_dir.rglob("*.tsx"))) - for ts_file in all_ts: - rel = str(ts_file.relative_to(ui_dir)) - if any(x in rel.lower() for x in ("test", "spec", "__mocks__")): - continue - if "node_modules" in rel: - continue - - text = ts_file.read_text(encoding="utf-8", errors="replace") - for match in _CONSOLE_RE.finditer(text): - level = match.group(1) - depth = 1 - i = match.end() - while i < len(text) and depth > 0: - c = text[i] - if c == "(": - depth += 1 - elif c == ")": - depth -= 1 - elif c in ('"', "'"): - i = _skip_js_string(text, i, c) - continue - elif c == "`": - i += 1 - while i < len(text) and text[i] != "`": - if text[i] == "\\": - i += 2 - continue - i += 1 - i += 1 - continue - i += 1 - body = text[match.end() : i - 1] - msg = _extract_ui_message(body) - if msg is not None: - results.append((rel, level, msg)) - - return results - -# ── Source aggregation ───────────────────────────────────────────────────── - -def _extract_all_sources() -> dict[str, list[tuple[str, str, str]]]: - print(_dim(" Extracting source logs..."), end="", flush=True) - result: dict[str, list[tuple[str, str, str]]] = {} - for cp in RUST_CRATES: - result[cp] = extract_rust_logs(cp) - result[UI_CRATE] = extract_ui_logs() - total = sum(len(v) for v in result.values()) - print(_dim(f" {total} call-sites found.")) - return result - - -def _build_source_index( - source: dict[str, list[tuple[str, str, str]]] -) -> tuple[set, set, dict, dict]: - """ - Returns: - quad_set – {(crate, file, level, norm_msg)} - triple_set – {(crate, level, norm_msg)} - move_map – {(crate, level, norm_msg): latest_file_seen} - mult_map – {(crate, file, level, norm_msg): occurrence_count} - """ - quad_set: set = set() - triple_set: set = set() - move_map: dict = {} - mult_map: dict = {} - - for crate_path, entries in source.items(): - for (file_rel, level, msg) in entries: - nm = _normalize_msg(msg) - quad = (crate_path, file_rel, level, nm) - tri = (crate_path, level, nm) - quad_set.add(quad) - triple_set.add(tri) - move_map[tri] = file_rel - mult_map[quad] = mult_map.get(quad, 0) + 1 - - return quad_set, triple_set, move_map, mult_map - -# ── Three-way merge ──────────────────────────────────────────────────────── - -def _stored_mult(notes: str) -> int: - """Read the ×N multiplicity stored in the Notes cell (default 1).""" - m = _MULT_RE.search(notes) - return int(m.group(1)) if m else 1 - - -def merge( - doc_entries: list[DocEntry], - source: dict[str, list[tuple[str, str, str]]], - silent_delete: bool, -) -> MergeResult: - quad_set, triple_set, move_map, mult_map = _build_source_index(source) - result = MergeResult() - - doc_quad_set: set = set() - for e in doc_entries: - doc_quad_set.add(e.quad_key()) - - # ── Check each existing doc entry ────────────────────────────────────── - doc_quad_seen: set = set() # for deduplication of doc entries - for e in doc_entries: - qk = e.quad_key() - # Deduplicate: if we've seen the same (crate, file, level, msg) before, - # treat the later occurrence as stale so it gets removed. - if qk in doc_quad_seen: - if silent_delete: - result.deleted.append(e) - else: - result.flagged.append(e) - continue - doc_quad_seen.add(qk) - tk = e.triple_key() - - if qk in quad_set: - # Present in source at same location — check multiplicity - src_mult = mult_map.get(qk, 1) - doc_mult = _stored_mult(e.notes) - if src_mult != doc_mult: - result.mult_upd.append((e, doc_mult, src_mult)) - elif tk in triple_set and move_map.get(tk) != e.file_rel: - # Same (crate, level, message) but different file → MOVED - # Only flag as moved if the OLD location is truly gone from source - if qk not in quad_set: - result.moved.append((e, move_map[tk])) - else: - # Not found anywhere → STALE - # Platform-specific crates absent on this OS are never stale. - if e.crate_path in PLATFORM_SPECIFIC_CRATES and not (REPO_ROOT / e.crate_path).exists(): - continue - if silent_delete: - result.deleted.append(e) - else: - result.flagged.append(e) - - # Build set of "new files" for moved entries so we don't double-count them - moved_new_quads: set = { - (e.crate_path, new_file, e.level, e.norm_message) - for (e, new_file) in result.moved - } - - # ── Find new source entries not covered by any doc entry ─────────────── - for crate_path, entries in source.items(): - # Deduplicate within this crate: (file, level, norm_msg) → (raw_msg, count) - seen: dict[tuple, tuple] = {} - for (file_rel, level, msg) in entries: - nm = _normalize_msg(msg) - k = (file_rel, level, nm) - if k not in seen: - seen[k] = (msg, 0) - seen[k] = (seen[k][0], seen[k][1] + 1) - - for (file_rel, level, nm), (raw_msg, mult) in seen.items(): - qk = (crate_path, file_rel, level, nm) - if qk in doc_quad_set: - continue # already documented - if qk in moved_new_quads: - continue # handled as a "move" of an existing entry - result.added.append((crate_path, file_rel, level, raw_msg, mult)) - - return result - -# ── Variable name extraction ─────────────────────────────────────────────── - -def _extract_var_names(message: str) -> str: - """Extract {name} placeholders → '`name1`, `name2`' or '—'""" - seen: list[str] = [] - used: set[str] = set() - for m in re.finditer(r'\{(\w+)', message): - n = m.group(1) - if n not in used: - used.add(n) - seen.append(f"`{n}`") - return ", ".join(seen) if seen else "-" - -# ── Document rewriter ────────────────────────────────────────────────────── - -def _wrap_in_backticks(s: str) -> str: - """Wrap s for a markdown table cell; use double backticks if s contains one.""" - if '`' not in s: - return f"`{s}`" - if '``' not in s: - return f"`` {s} ``" - # Last resort: strip backticks to avoid corrupting the table structure. - return f"`{s.replace('`', '')}`" - - -def _fmt_row(level: str, msg: str, file_rel: str, variables: str, notes: str) -> str: - return f"| {_wrap_in_backticks(level)} | {_wrap_in_backticks(msg)} | {_wrap_in_backticks(file_rel)} | {variables} | {notes} |" - - -def _build_new_row(crate_path: str, file_rel: str, level: str, msg: str, mult: int) -> str: - variables = _extract_var_names(msg) - notes = f"×{mult} in this file" if mult > 1 else "-" - return _fmt_row(level, msg, file_rel, variables, notes) - - -def _update_mult_in_notes(notes: str, new_mult: int) -> str: - """Update the ×N multiplicity marker in a Notes cell.""" - m = _MULT_RE.search(notes) - if new_mult == 1: - # Count dropped back to 1 — remove the multiplicity note entirely. - if m: - stripped = (notes[:m.start()] + notes[m.end():]).strip(" ;") - return stripped if stripped else "-" - return notes # already no marker - if m: - return notes[:m.start()] + f"×{new_mult} in this file" + notes[m.end():] - # No ×N marker yet (doc defaulted to 1, source now has more) — insert it. - clean = notes.strip() - if clean in ("—", "-", ""): - return f"×{new_mult} in this file" - return clean + f"; ×{new_mult} in this file" - - -def rewrite_doc( - lines: list[str], - doc_entries: list[DocEntry], - result: MergeResult, - crate_sep_idx: dict[str, int], -) -> list[str]: - """Apply merge result to original lines and return the new line list.""" - - deleted_idx = {e.line_idx for e in result.deleted} - flagged_map = {e.line_idx: e for e in result.flagged} - moved_map = {e.line_idx: new_file for (e, new_file) in result.moved} - mult_upd_map = {e.line_idx: new_mult for (e, _, new_mult) in result.mult_upd} - - # Find last table-row line per crate for new-entry insertion - last_row_idx: dict[str, int] = {} - for e in doc_entries: - cp = e.crate_path - if cp not in last_row_idx or e.line_idx > last_row_idx[cp]: - last_row_idx[cp] = e.line_idx - - # Group and sort new entries by crate - new_by_crate: dict[str, list[tuple]] = {} - for (cp, file_rel, level, msg, mult) in result.added: - new_by_crate.setdefault(cp, []).append((level, msg, file_rel, mult)) - for cp in new_by_crate: - new_by_crate[cp].sort(key=lambda x: (SEVERITY.get(x[0], 99), x[1].lower())) - - # Build line-idx → [rows_to_insert_after] map - insertions: dict[int, list[str]] = {} - missing_crates: list[str] = [] - for cp, rows in new_by_crate.items(): - insert_after = last_row_idx.get(cp) - if insert_after is not None: - new_rows = [_build_new_row(cp, f, lv, msg, mult) for (lv, msg, f, mult) in rows] - insertions.setdefault(insert_after, []).extend(new_rows) - else: - # Crate section exists with a table header but no data rows yet. - sep_idx = crate_sep_idx.get(cp) - if sep_idx is not None: - new_rows = [_build_new_row(cp, f, lv, msg, mult) for (lv, msg, f, mult) in rows] - insertions.setdefault(sep_idx, []).extend(new_rows) - else: - missing_crates.append(cp) - - if missing_crates: - print(f"\n ⚠ New entries for unknown crates (add sections manually): {missing_crates}") - - # Reconstruct line by line - output: list[str] = [] - for i, line in enumerate(lines): - if i in deleted_idx: - # Still emit any new entries queued for insertion after this (now-deleted) anchor. - if i in insertions: - output.extend(insertions[i]) - continue # stale — drop - - if i in flagged_map: - # Prepend [REMOVED] to the Notes column - parts = _split_table_row(line) - if len(parts) >= 7: - notes = parts[5].strip() - if "[REMOVED]" not in notes: - parts[5] = f" [REMOVED] {notes} " if notes not in ("-", "—", "") else " [REMOVED] " - line = "|".join(parts) - - if i in moved_map: - # Update File column with new path - parts = _split_table_row(line) - if len(parts) >= 7: - parts[3] = f" `{moved_map[i]}` " - line = "|".join(parts) - - if i in mult_upd_map: - # Update ×N count in Notes column - parts = _split_table_row(line) - if len(parts) >= 7: - parts[5] = " " + _update_mult_in_notes(parts[5].strip(), mult_upd_map[i]) + " " - line = "|".join(parts) - - output.append(line) - - if i in insertions: - output.extend(insertions[i]) - - return output - -# ── Colorized report ─────────────────────────────────────────────────────── - -def _print_report(result: MergeResult, version: str, silent_delete: bool) -> None: - print() - print(_bold("══════════════════════════════════════════════════════")) - print(_bold(f" Log Index Update Report (v{version})")) - print(_bold("══════════════════════════════════════════════════════")) - - # ── Red: stale ───────────────────────────────────────────────────────── - stale_all = result.deleted + result.flagged - if stale_all: - action = "deleted" if silent_delete else "flagged [REMOVED]" - print() - print(_red(f"❌ STALE — {action}: {len(stale_all)} entries")) - for e in stale_all: - tag = "DEL" if silent_delete else "FLG" - msg_short = e.message[:58] + "…" if len(e.message) > 58 else e.message - print(_red(f" [{tag}] {e.crate_path:<35} {e.level:<5} {e.file_rel}")) - print(_red(f" \"{msg_short}\"")) - else: - print(_green(" ✓ No stale entries.")) - - # ── Orange: moved ────────────────────────────────────────────────────── - if result.moved: - print() - print(_orange(f"🚚 MOVED: {len(result.moved)} entries")) - for (e, new_file) in result.moved: - msg_short = e.message[:55] + "…" if len(e.message) > 55 else e.message - print(_orange(f" {e.crate_path:<35} {e.level:<5}")) - print(_orange(f" {e.file_rel} → {new_file}")) - print(_orange(f" \"{msg_short}\"")) - else: - print(_green(" ✓ No moved entries.")) - - # ── Orange: multiplicity updates ─────────────────────────────────────── - if result.mult_upd: - print() - print(_orange(f"🔢 MULTIPLICITY UPDATED: {len(result.mult_upd)} entries")) - for (e, old_m, new_m) in result.mult_upd: - print(_orange(f" {e.crate_path:<35} {e.level:<5} {e.file_rel}")) - print(_orange(f" ×{old_m} → ×{new_m} \"{e.message[:55]}\"")) - - # ── Green: new ───────────────────────────────────────────────────────── - if result.added: - print() - print(_green(f"✅ NEW (added): {len(result.added)} entries")) - for (cp, file_rel, level, msg, mult) in result.added: - mult_tag = _dim(f" (×{mult} in file)") if mult > 1 else "" - msg_short = msg[:60] + "…" if len(msg) > 60 else msg - print(_green(f" {cp:<35} {level:<5} {file_rel}{mult_tag}")) - print(_green(f" \"{msg_short}\"")) - else: - print(_green(" ✓ No new entries.")) - - # ── Summary ──────────────────────────────────────────────────────────── - print() - print(_bold("──────────────────────────────────────────────────────")) - parts: list[str] = [] - if stale_all: parts.append(_red(f"{len(stale_all)} stale")) - if result.moved: parts.append(_orange(f"{len(result.moved)} moved")) - if result.mult_upd: parts.append(_orange(f"{len(result.mult_upd)} ×N updated")) - if result.added: parts.append(_green(f"{len(result.added)} new")) - if parts: - print(" " + " · ".join(parts)) - else: - print(_green(" Everything is up to date. No changes needed.")) - - print() - -# ── Post-run action box ─────────────────────────────────────────────────── - -def _print_action_box(result: MergeResult, doc_file: Path) -> None: - """ - Print a hard-to-miss bordered reminder when new log entries were added. - New entries only carry auto-extracted variable names — a human must add - meaningful descriptions and operator notes before the information fades. - """ - if not result.added: - return - - n = len(result.added) - tag = "entry" if n == 1 else "entries" - - # List the new entries (capped at 8 to keep the box readable) - entry_lines: list[str] = [] - for (cp, file_rel, level, msg, _mult) in result.added[:8]: - short_msg = msg[:52] + "\u2026" if len(msg) > 52 else msg - entry_lines.append(f" {level.upper():<5} {file_rel}") - entry_lines.append(f" \"{short_msg}\"") - if len(result.added) > 8: - entry_lines.append(f" \u2026 and {len(result.added) - 8} more") - - width = 62 - dash = "\u2500" * width - - def _box(text: str = "") -> None: - """Print one line inside the box, padded to width.""" - # Strip ANSI codes for length calculation - plain = re.sub(r'\x1b\[[0-9;]*m', '', text) - pad = width - 2 - len(plain) - print(f"\u2502 {text}{' ' * max(pad, 0)}\u2502") - - print() - print(_bold(f"\u250c{dash}\u2510")) - _box() - _box(_bold("ACTION REQUIRED \u2014 NEW LOG ENTRIES ADDED")) - _box() - _box(f"{_green(str(n) + ' new ' + tag)} appended to {doc_file.name}") - _box() - _box("They currently only have auto-extracted variable names.") - _box("This context is freshest RIGHT NOW \u2014 once the dev moves on,") - _box("it is very hard to recover. Please open the file and add:") - _box() - _box(f" \u2022 Variables column \u2014 what each {{name}} actually means") - _box(f" \u2022 Notes column \u2014 security flags, operator guidance") - _box() - _box(_bold("New entries:")) - for el in entry_lines: - _box(el) - _box() - _box(_bold(f"Open: {doc_file}")) - _box() - print(_bold(f"\u2514{dash}\u2518")) - print() - -# ── Main ─────────────────────────────────────────────────────────────────── - -def _parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser( - description="Update log-reference.md from source call-sites.", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=( - "Examples:\n" - " # interactive (default)\n" - " python3 scripts/update_log_index.py\n\n" - " # CI / bot: safe defaults, no prompts, no colour\n" - " python3 scripts/update_log_index.py --non-interactive --no-color\n\n" - " # bot: also delete stale entries\n" - " python3 scripts/update_log_index.py --non-interactive --delete-stale --no-color\n" - ), - ) - p.add_argument( - "--non-interactive", action="store_true", - help="Skip all prompts; use flag values directly (for CI/bots).", - ) - p.add_argument( - "--delete-stale", action="store_true", default=False, - help="Delete stale entries instead of flagging with [REMOVED]. Only meaningful with --non-interactive.", - ) - p.add_argument( - "--no-color", action="store_true", - help="Suppress ANSI colour codes (useful for CI logs).", - ) - p.add_argument( - "--check", action="store_true", - help=( - "Report changes without writing the file. " - "Exits 1 if any changes are detected. " - "Implies --non-interactive." - ), - ) - return p.parse_args() - - -def main() -> None: - global _USE_COLOR - args = _parse_args() - if args.no_color: - _USE_COLOR = False - check_only = args.check - if check_only: - args.non_interactive = True # --check implies --non-interactive - - print(_bold(f"\n Log Index Updater{' [check-only]' if check_only else ''}")) - print(_dim(f" Doc : {DOC_FILE}")) - print(_dim(f" Repo: {REPO_ROOT}")) - - if not DOC_FILE.exists(): - print(_red(f"\nERROR: {DOC_FILE} not found.")) - sys.exit(1) - - # Phase 0: prompts or CLI args - version = _read_version() - if args.non_interactive: - silent_delete = args.delete_stale - else: - if args.delete_stale: - print(_orange(" ⚠ --delete-stale is only used with --non-interactive; ignoring.")) - silent_delete = _phase0_prompts() - print(_dim(f" Version: {version} | Stale strategy: {'delete' if silent_delete else 'flag [REMOVED]'}")) - print() - - # Phase 1: parse doc - print(_dim(" Parsing log-reference.md..."), end="", flush=True) - lines, doc_entries, crate_sep_idx = parse_doc(DOC_FILE) - print(_dim(f" {len(doc_entries)} entries found.")) - - # Phase 2: extract source - source = _extract_all_sources() - - # Phase 3: merge - print(_dim(" Merging..."), end="", flush=True) - result = merge(doc_entries, source, silent_delete) - n_changes = len(result.deleted) + len(result.flagged) + len(result.moved) + len(result.added) + len(result.mult_upd) - print(_dim(f" {n_changes} change(s) detected.")) - - # Phase 4: rewrite - if n_changes > 0: - if check_only: - print(_dim(" Check mode — file not modified.")) - else: - print(_dim(" Writing log-reference.md..."), end="", flush=True) - new_lines = rewrite_doc(lines, doc_entries, result, crate_sep_idx) - DOC_FILE.write_text("\n".join(new_lines) + "\n", encoding="utf-8") - print(_dim(" Done.")) - else: - print(_dim(" No changes — file not modified.")) - - # Phase 5: report + action box - _print_report(result, version, silent_delete) - _print_action_box(result, DOC_FILE) - - # Exit 1 when changes exist so pre-commit (and CI) can detect drift. - # In write mode: the file was already updated — user should re-stage it. - # In check mode: nothing was written — indicates the index is out of date. - if n_changes > 0: - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/test_data b/test_data index 4e7b95b038..0f7efb2ae6 160000 --- a/test_data +++ b/test_data @@ -1 +1 @@ -Subproject commit 4e7b95b0389c50f08a0718ae4df46c3cb8e5cfe6 +Subproject commit 0f7efb2ae6468e6202fcef0619c2d8a4ae60ada8 diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f68ba2783d..5239376a75 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -14,6 +14,7 @@ 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 CertificateReCertifyForm from "./actions/Certificates/CertificateReCertify"; import CertificateValidateForm from "./actions/Certificates/CertificateValidate"; import AwsExportKeyMaterialForm from "./actions/CloudProviders/AwsExportKeyMaterial"; import ImportAwsKekForm from "./actions/CloudProviders/AwsImportKek"; @@ -26,6 +27,7 @@ import CovercryptUserKeyForm from "./actions/Covercrypt/CovercryptUserKey"; import ECDecryptForm from "./actions/EC/ECDecrypt"; import ECEncryptForm from "./actions/EC/ECEncrypt"; import ECKeyCreateForm from "./actions/EC/ECKeysCreate"; +import ECReKeyForm from "./actions/EC/ECReKey"; import ECSignForm from "./actions/EC/ECSign"; import ECVerifyForm from "./actions/EC/ECVerify"; import FpeDecryptForm from "./actions/FPE/FpeDecrypt"; @@ -35,6 +37,7 @@ 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 SymKeyCreateForm from "./actions/Keys/SymKeysCreate"; import MacComputeForm from "./actions/MAC/MacCompute"; import MacVerifyForm from "./actions/MAC/MacVerify"; @@ -47,11 +50,15 @@ import SecretDataCreateForm from "./actions/Objects/SecretDataCreate"; import PqcDecapsulateForm from "./actions/PQC/PqcDecapsulate"; import PqcEncapsulateForm from "./actions/PQC/PqcEncapsulate"; import PqcKeysCreateForm from "./actions/PQC/PqcKeysCreate"; +import PqcReKeyForm from "./actions/PQC/PqcReKey"; import PqcSignForm from "./actions/PQC/PqcSign"; import PqcVerifyForm from "./actions/PQC/PqcVerify"; +import GetRotationPolicyForm from "./actions/RotationPolicy/GetRotationPolicy"; +import SetRotationPolicyForm from "./actions/RotationPolicy/SetRotationPolicy"; import RsaDecryptForm from "./actions/RSA/RsaDecrypt"; import RsaEncryptForm from "./actions/RSA/RsaEncrypt"; import RsaKeyCreateForm from "./actions/RSA/RsaKeysCreate"; +import RsaReKeyForm from "./actions/RSA/RsaReKey"; import RsaSignForm from "./actions/RSA/RsaSign"; import RsaVerifyForm from "./actions/RSA/RsaVerify"; import SymmetricDecryptForm from "./actions/Symmetric/SymmetricDecrypt"; @@ -67,8 +74,7 @@ import TokenizeWordPatternMask from "./actions/Tokenize/TokenizeWordPatternMask" import TokenizeWordTokenize from "./actions/Tokenize/TokenizeWordTokenize"; import LocateForm from "./components/common/Locate"; import MainLayout from "./components/layout/MainLayout"; -import { AuthProvider } from "./contexts/AuthContext"; -import { useAuth } from "./contexts/useAuth"; +import { AuthProvider, useAuth } from "./contexts/AuthContext"; import { useBranding } from "./contexts/useBranding"; import LoginPage from "./pages/LoginPage"; import NotFoundPage from "./pages/NotFoundPage"; @@ -90,9 +96,8 @@ const isLoopbackHost = (host: string): boolean => LOOPBACK_HOSTS.has(host); const resolveServerUrl = (): string => { const configuredUrl = (import.meta.env.VITE_KMS_URL as string | undefined)?.trim(); - const isDevMode = import.meta.env.DEV || import.meta.env.VITE_DEV_MODE === "true"; const defaultDevUrl = `${window.location.protocol}//${window.location.hostname}:9998`; - const fallbackUrl = isDevMode ? defaultDevUrl : window.location.origin; + const fallbackUrl = import.meta.env.DEV ? defaultDevUrl : window.location.origin; const candidate = configuredUrl && configuredUrl.length > 0 ? configuredUrl : fallbackUrl; try { @@ -263,6 +268,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> + } /> } /> } /> } /> @@ -273,6 +279,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> + } /> } /> } /> } /> @@ -284,6 +291,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> + } /> } /> } /> } /> @@ -295,6 +303,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> + } /> } /> } /> } /> @@ -306,6 +315,24 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> + + + } /> + } /> + + + } /> + } /> + + + } /> + } /> + + + } /> + } /> + + } /> } /> @@ -359,6 +386,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> + } /> } /> diff --git a/ui/src/actions/Access/AccessObtained.tsx b/ui/src/actions/Access/AccessObtained.tsx index 6fa20f2a27..4fea030981 100644 --- a/ui/src/actions/Access/AccessObtained.tsx +++ b/ui/src/actions/Access/AccessObtained.tsx @@ -128,9 +128,9 @@ const AccessObtainedList: React.FC = () => { rowKey="objectUid" loading={isLoading} pagination={{ - defaultPageSize: 10, + defaultPageSize: 50, showSizeChanger: true, - pageSizeOptions: [10, 20, 50, 100], + pageSizeOptions: [50, 100, 500, 1000], }} className="border rounded" /> diff --git a/ui/src/actions/Certificates/CertificateCertify.tsx b/ui/src/actions/Certificates/CertificateCertify.tsx index 4aea44acf7..d4000905a0 100644 --- a/ui/src/actions/Certificates/CertificateCertify.tsx +++ b/ui/src/actions/Certificates/CertificateCertify.tsx @@ -1,10 +1,10 @@ import { Button, Card, Checkbox, Form, Input, Radio, RadioChangeEvent, Select, Space } from "antd"; import React, { useEffect, useState } from "react"; +import { ActionResponse } from "../../components/common/ActionResponse"; import { FormUploadDragger } from "../../components/common/FormUpload"; +import { useActionState } from "../../hooks/useActionState"; import { sendKmipRequest } from "../../utils/utils"; import * as wasm from "../../wasm/pkg"; -import { useActionState } from "../../hooks/useActionState"; -import { ActionResponse } from "../../components/common/ActionResponse"; interface CertificateCertifyFormData { certificateId?: string; @@ -63,6 +63,26 @@ const CertificateCertifyForm: React.FC = () => { // does not attempt to look up a blank identifier on the server. const normalize = (v?: string) => (v?.trim() ? v.trim() : undefined); await execute(async () => { + // Option 3 uses the dedicated KMIP ReCertify operation which creates a + // new certificate with a fresh UID and links old ↔ new via replacement links. + if (certifyMethod === "reCertify") { + const certIdToRenew = normalize(values.certificateIdToReCertify); + if (!certIdToRenew) throw new Error("Certificate ID to re-certify is required"); + const request = wasm.re_certify_ttlv_request( + certIdToRenew, + normalize(values.issuerPrivateKeyId), + normalize(values.issuerCertificateId), + values.numberOfDays, + values.tags, + ); + const result_str = await sendKmipRequest(request, idToken, serverUrl); + if (result_str) { + const response = await wasm.parse_re_certify_ttlv_response(result_str); + return `Certificate successfully re-certified with new ID: ${response.UniqueIdentifier}`; + } + return; + } + const request = wasm.certify_ttlv_request( normalize(values.certificateId), values.csrFormat, diff --git a/ui/src/actions/Certificates/CertificateReCertify.tsx b/ui/src/actions/Certificates/CertificateReCertify.tsx new file mode 100644 index 0000000000..eaa94659db --- /dev/null +++ b/ui/src/actions/Certificates/CertificateReCertify.tsx @@ -0,0 +1,127 @@ +import { Button, Card, Form, Input, Select, Space } from "antd"; +import React from "react"; +import { ActionResponse } from "../../components/common/ActionResponse"; +import { useActionState } from "../../hooks/useActionState"; +import { sendKmipRequest } from "../../utils/utils"; +import * as wasm from "../../wasm/pkg"; + +interface CertificateReCertifyFormData { + certificateIdToReCertify: string; + issuerPrivateKeyId?: string; + issuerCertificateId?: string; + numberOfDays: number; + tags: string[]; +} + +const CertificateReCertifyForm: React.FC = () => { + const [form] = Form.useForm(); + const { res, isLoading, responseRef, idToken, serverUrl, execute } = useActionState(); + + const onFinish = async (values: CertificateReCertifyFormData) => { + const normalize = (v?: string) => (v?.trim() ? v.trim() : undefined); + await execute(async () => { + const request = wasm.re_certify_ttlv_request( + values.certificateIdToReCertify.trim(), + normalize(values.issuerPrivateKeyId), + normalize(values.issuerCertificateId), + values.numberOfDays, + values.tags, + ); + const result_str = await sendKmipRequest(request, idToken, serverUrl); + if (result_str) { + const response = await wasm.parse_re_certify_ttlv_response(result_str); + return `Certificate successfully re-certified with new ID: ${response.UniqueIdentifier}`; + } + }); + }; + + return ( +

+

Re-certify a Certificate

+ +
+

+ Creates a new certificate from an existing one using the KMIP ReCertify operation. The old + and new certificates are linked via ReplacedObjectLink / ReplacementObjectLink attributes. The + original certificate is preserved and the new one is returned with a fresh unique identifier. +

+
+ +
+ + +

Certificate to Re-certify

+ + + +
+ + +

Issuer Information

+

If no issuer is provided, the certificate will be self-signed.

+ + + + + + + + +
+ + +

Certificate Options

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

Re-Key a symmetric key

+ +
+

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

+
    +
  • The old key is deactivated and a new key is created as its replacement.
  • +
  • The rotation generation counter is incremented on the new key.
  • +
+
+ +
+ + + + + + + + + + + + + +
+ ); +}; + +export default KeysReKeyForm; diff --git a/ui/src/actions/Keys/SymKeysCreate.tsx b/ui/src/actions/Keys/SymKeysCreate.tsx index 920aa15a12..171d12e224 100644 --- a/ui/src/actions/Keys/SymKeysCreate.tsx +++ b/ui/src/actions/Keys/SymKeysCreate.tsx @@ -1,4 +1,4 @@ -import { Button, Card, Checkbox, Form, Input, InputNumber, Select, Space } from "antd"; +import { Button, Card, Checkbox, Divider, Form, Input, InputNumber, Select, Space } from "antd"; import React, { useEffect, useState } from "react"; import { sendKmipRequest } from "../../utils/utils"; import * as wasm from "../../wasm/pkg"; @@ -13,6 +13,9 @@ interface SymKeyCreateFormData { tags: string[]; sensitive: boolean; wrappingKeyId?: string; + enrollKeyset: boolean; + rotateInterval?: number; + rotateOffset?: number; } type CreateResponse = { @@ -49,7 +52,26 @@ const SymKeyCreateForm: React.FC = () => { const result_str = await sendKmipRequest(request, idToken, serverUrl); if (result_str) { const result: CreateResponse = await wasm.parse_create_ttlv_response(result_str); - return `${result.UniqueIdentifier} has been created.`; + const keyId = result.UniqueIdentifier; + + // Apply rotation policy if any fields were provided + if (values.enrollKeyset || values.rotateInterval !== undefined || values.rotateOffset !== undefined) { + if (values.rotateInterval !== undefined) { + const req = wasm.set_rotate_interval_ttlv_request(keyId, BigInt(values.rotateInterval)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateOffset !== undefined) { + const req = wasm.set_rotate_offset_ttlv_request(keyId, BigInt(values.rotateOffset)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.enrollKeyset) { + // rotation name must equal the key ID for SQL-backed keys + const req = wasm.set_rotate_name_ttlv_request(keyId, keyId); + await sendKmipRequest(req, idToken, serverUrl); + } + } + + return `${keyId} has been created.`; } }); }; @@ -76,6 +98,7 @@ const SymKeyCreateForm: React.FC = () => { numberOfBits: 256, tags: [], sensitive: false, + enrollKeyset: false, }} > @@ -111,6 +134,52 @@ const SymKeyCreateForm: React.FC = () => { Sensitive + + + Rotation Policy (optional) + + + + Enroll in keyset (rotation name = key ID) + + + prev.enrollKeyset !== curr.enrollKeyset}> + {({ getFieldValue }) => + getFieldValue("enrollKeyset") ? ( + <> + + + + + + + + + ) : null + } + diff --git a/ui/src/actions/Objects/ObjectsOwned.tsx b/ui/src/actions/Objects/ObjectsOwned.tsx index d5cbe5bf9c..75083dc711 100644 --- a/ui/src/actions/Objects/ObjectsOwned.tsx +++ b/ui/src/actions/Objects/ObjectsOwned.tsx @@ -80,9 +80,9 @@ const ObjectsOwnedList: React.FC = () => { rowKey="object_id" loading={isLoading} pagination={{ - defaultPageSize: 10, + defaultPageSize: 50, showSizeChanger: true, - pageSizeOptions: [10, 20, 50, 100], + pageSizeOptions: [50, 100, 500, 1000], }} className="border rounded" /> diff --git a/ui/src/actions/PQC/PqcKeysCreate.tsx b/ui/src/actions/PQC/PqcKeysCreate.tsx index ba00685d8f..a5f64c19e3 100644 --- a/ui/src/actions/PQC/PqcKeysCreate.tsx +++ b/ui/src/actions/PQC/PqcKeysCreate.tsx @@ -1,4 +1,4 @@ -import { Button, Card, Checkbox, Form, Select, Space } from "antd"; +import { Button, Card, Checkbox, Divider, Form, Input, InputNumber, Select, Space } from "antd"; import React, { useEffect, useState } from "react"; import { useBranding } from "../../contexts/useBranding"; import { sendKmipRequest } from "../../utils/utils"; @@ -10,6 +10,9 @@ interface PqcKeyCreateFormData { algorithm: string; tags: string[]; sensitive: boolean; + enrollKeyset: boolean; + rotateInterval?: number; + rotateOffset?: number; } type CreateKeyPairResponse = { @@ -49,7 +52,26 @@ const PqcKeysCreateForm: React.FC = () => { const result_str = await sendKmipRequest(request, idToken, serverUrl); if (result_str) { const result: CreateKeyPairResponse = await wasm.parse_create_keypair_ttlv_response(result_str); - return `Key pair has been created. Private key Id: ${result.PrivateKeyUniqueIdentifier} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; + const skId = result.PrivateKeyUniqueIdentifier; + + // Apply rotation policy on the private key (keyset anchor) + if (values.enrollKeyset || values.rotateInterval !== undefined || values.rotateOffset !== undefined) { + if (values.rotateInterval !== undefined) { + const req = wasm.set_rotate_interval_ttlv_request(skId, BigInt(values.rotateInterval)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateOffset !== undefined) { + const req = wasm.set_rotate_offset_ttlv_request(skId, BigInt(values.rotateOffset)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.enrollKeyset) { + // rotation name is set to the private key ID (server-generated for PQC) + const req = wasm.set_rotate_name_ttlv_request(skId, skId); + await sendKmipRequest(req, idToken, serverUrl); + } + } + + return `Key pair has been created. Private key Id: ${skId} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; } }); }; @@ -87,6 +109,7 @@ const PqcKeysCreateForm: React.FC = () => { initialValues={{ tags: [], sensitive: false, + enrollKeyset: false, }} > @@ -107,6 +130,30 @@ const PqcKeysCreateForm: React.FC = () => { Sensitive + + + Rotation Policy (optional) + + + + + + + + + + + + + diff --git a/ui/src/actions/PQC/PqcReKey.tsx b/ui/src/actions/PQC/PqcReKey.tsx new file mode 100644 index 0000000000..ac33062f19 --- /dev/null +++ b/ui/src/actions/PQC/PqcReKey.tsx @@ -0,0 +1,75 @@ +import { Button, Card, Form, Input, Space } from "antd"; +import React from "react"; +import { ActionResponse } from "../../components/common/ActionResponse"; +import { useActionState } from "../../hooks/useActionState"; +import { sendKmipRequest } from "../../utils/utils"; +import * as wasm from "../../wasm/pkg"; + +interface ReKeyFormData { + keyId: string; +} + +type ReKeyKeyPairResponse = { + PrivateKeyUniqueIdentifier: string; + PublicKeyUniqueIdentifier: string; +}; + +const PqcReKeyForm: React.FC = () => { + const [form] = Form.useForm(); + const { res, isLoading, responseRef, idToken, serverUrl, execute } = useActionState(); + + const onFinish = async (values: ReKeyFormData) => { + await execute(async () => { + const request = wasm.rekey_keypair_ttlv_request(values.keyId); + const result_str = await sendKmipRequest(request, idToken, serverUrl); + if (result_str) { + const result: ReKeyKeyPairResponse = await wasm.parse_rekey_keypair_ttlv_response(result_str); + return `The post-quantum key pair was successfully rotated.\nNew private key: ${result.PrivateKeyUniqueIdentifier}\nNew public key: ${result.PublicKeyUniqueIdentifier}`; + } + }); + }; + + return ( +
+

Re-Key a Post-Quantum key pair

+ +
+

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

+
    +
  • A new private key and public key are created with the same algorithm.
  • +
  • The old key pair is linked to the new one via replacement links.
  • +
  • The rotation generation counter is incremented on the new key.
  • +
+
+ +
+ + + + + + + + + + + + + +
+ ); +}; + +export default PqcReKeyForm; diff --git a/ui/src/actions/RSA/RsaKeysCreate.tsx b/ui/src/actions/RSA/RsaKeysCreate.tsx index 27cd1b8b3b..021fb0f52b 100644 --- a/ui/src/actions/RSA/RsaKeysCreate.tsx +++ b/ui/src/actions/RSA/RsaKeysCreate.tsx @@ -1,7 +1,8 @@ -import { Button, Card, Checkbox, Form, Input, InputNumber, Select, Space } from "antd"; +import { Button, Card, Checkbox, Divider, Form, Input, InputNumber, Select, Space } from "antd"; import React from "react"; import { sendKmipRequest } from "../../utils/utils"; import { create_rsa_key_pair_ttlv_request, parse_create_keypair_ttlv_response } from "../../wasm/pkg"; +import * as wasm from "../../wasm/pkg"; import { useActionState } from "../../hooks/useActionState"; import { ActionResponse } from "../../components/common/ActionResponse"; @@ -11,6 +12,9 @@ interface RsaKeyCreateFormData { tags: string[]; sensitive: boolean; wrappingKeyId?: string; + enrollKeyset: boolean; + rotateInterval?: number; + rotateOffset?: number; } type CreateKeyPairResponse = { @@ -34,7 +38,26 @@ const RsaKeyCreateForm: React.FC = () => { const result_str = await sendKmipRequest(request, idToken, serverUrl); if (result_str) { const result: CreateKeyPairResponse = await parse_create_keypair_ttlv_response(result_str); - return `Key pair has been created. Private key Id: ${result.PrivateKeyUniqueIdentifier} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; + const skId = result.PrivateKeyUniqueIdentifier; + + // Apply rotation policy on the private key (keyset anchor) + if (values.enrollKeyset || values.rotateInterval !== undefined || values.rotateOffset !== undefined) { + if (values.rotateInterval !== undefined) { + const req = wasm.set_rotate_interval_ttlv_request(skId, BigInt(values.rotateInterval)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateOffset !== undefined) { + const req = wasm.set_rotate_offset_ttlv_request(skId, BigInt(values.rotateOffset)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.enrollKeyset) { + // rotation name must equal the private key ID + const req = wasm.set_rotate_name_ttlv_request(skId, skId); + await sendKmipRequest(req, idToken, serverUrl); + } + } + + return `Key pair has been created. Private key Id: ${skId} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; } }); }; @@ -60,6 +83,7 @@ const RsaKeyCreateForm: React.FC = () => { sizeInBits: 4096, tags: [], sensitive: false, + enrollKeyset: false, }} > @@ -96,6 +120,52 @@ const RsaKeyCreateForm: React.FC = () => { Sensitive + + + Rotation Policy (optional) + + + + Enroll in keyset (rotation name = private key ID) + + + prev.enrollKeyset !== curr.enrollKeyset}> + {({ getFieldValue }) => + getFieldValue("enrollKeyset") ? ( + <> + + + + + + + + + ) : null + } + + + + + + + ); +}; + +export default RsaReKeyForm; diff --git a/ui/src/actions/RotationPolicy/GetRotationPolicy.tsx b/ui/src/actions/RotationPolicy/GetRotationPolicy.tsx new file mode 100644 index 0000000000..79111fea8a --- /dev/null +++ b/ui/src/actions/RotationPolicy/GetRotationPolicy.tsx @@ -0,0 +1,87 @@ +import { Button, Card, Descriptions, Form, Input, Space } from "antd"; +import React, { useState } from "react"; +import { ActionResponse } from "../../components/common/ActionResponse"; +import { useActionState } from "../../hooks/useActionState"; +import { sendKmipRequest } from "../../utils/utils"; +import * as wasm from "../../wasm/pkg"; + +interface GetRotationPolicyFormData { + keyId: string; +} + +interface RotationPolicy { + interval?: number; + offset?: number; + name?: string; + generation?: number; + date?: string; +} + +const GetRotationPolicyForm: React.FC = () => { + const [form] = Form.useForm(); + const { res, isLoading, responseRef, idToken, serverUrl, execute } = useActionState(); + const [policy, setPolicy] = useState(null); + + const onFinish = async (values: GetRotationPolicyFormData) => { + setPolicy(null); + await execute(async () => { + const request = wasm.get_attributes_ttlv_request(values.keyId); + const result_str = await sendKmipRequest(request, idToken, serverUrl); + if (result_str) { + const result: RotationPolicy = await wasm.parse_rotation_policy_response(result_str); + setPolicy(result); + if (!result.interval && !result.name && !result.generation) { + return "No rotation policy configured for this key."; + } + return "Rotation policy retrieved successfully."; + } + }); + }; + + return ( +
+

Get Rotation Policy

+ +
+

Retrieve the current automatic rotation policy for a key.

+
+ +
+ + + + + + + + + + + + + + + {policy && (policy.interval || policy.name || policy.generation) && ( + + + {policy.interval ?? "Not set"} + {policy.offset ?? "Not set"} + {policy.name ?? "Not set"} + {policy.generation ?? "Not set"} + {policy.date ?? "Never"} + + + )} +
+ ); +}; + +export default GetRotationPolicyForm; diff --git a/ui/src/actions/RotationPolicy/SetRotationPolicy.tsx b/ui/src/actions/RotationPolicy/SetRotationPolicy.tsx new file mode 100644 index 0000000000..551e6e650c --- /dev/null +++ b/ui/src/actions/RotationPolicy/SetRotationPolicy.tsx @@ -0,0 +1,101 @@ +import { Button, Card, Form, Input, InputNumber, Space } from "antd"; +import React from "react"; +import { ActionResponse } from "../../components/common/ActionResponse"; +import { useActionState } from "../../hooks/useActionState"; +import { sendKmipRequest } from "../../utils/utils"; +import * as wasm from "../../wasm/pkg"; + +interface SetRotationPolicyFormData { + keyId: string; + interval?: number; + offset?: number; + name?: string; +} + +const SetRotationPolicyForm: React.FC = () => { + const [form] = Form.useForm(); + const { res, isLoading, responseRef, idToken, serverUrl, execute } = useActionState(); + + const onFinish = async (values: SetRotationPolicyFormData) => { + await execute(async () => { + if (values.interval !== undefined && values.interval !== null) { + const intervalRequest = wasm.set_rotate_interval_ttlv_request(values.keyId, BigInt(values.interval)); + const intervalResult = await sendKmipRequest(intervalRequest, idToken, serverUrl); + if (!intervalResult) return; + wasm.parse_set_attribute_ttlv_response(intervalResult); + } + + if (values.offset !== undefined && values.offset !== null) { + const offsetRequest = wasm.set_rotate_offset_ttlv_request(values.keyId, BigInt(values.offset)); + const offsetResult = await sendKmipRequest(offsetRequest, idToken, serverUrl); + if (!offsetResult) return; + wasm.parse_set_attribute_ttlv_response(offsetResult); + } + + if (values.name) { + const nameRequest = wasm.set_rotate_name_ttlv_request(values.keyId, values.name); + const nameResult = await sendKmipRequest(nameRequest, idToken, serverUrl); + if (!nameResult) return; + wasm.parse_set_attribute_ttlv_response(nameResult); + } + + return "Rotation policy set successfully."; + }); + }; + + return ( +
+

Set Rotation Policy

+ +
+

Configure an automatic periodic rotation policy on a key.

+
    +
  • The interval defines how often (in seconds) the key is automatically rotated.
  • +
  • The offset defines the delay (in seconds) before activation of a newly rotated key.
  • +
  • The name assigns a keyset name for addressing key generations via name@version syntax.
  • +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +export default SetRotationPolicyForm; diff --git a/ui/src/components/common/Locate.tsx b/ui/src/components/common/Locate.tsx index c48320fc3f..f52cdda308 100644 --- a/ui/src/components/common/Locate.tsx +++ b/ui/src/components/common/Locate.tsx @@ -1,16 +1,34 @@ -import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Table, Tag, Tooltip } from "antd"; import type { TableColumnsType } from "antd"; +import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Table, Tag, Tooltip } from "antd"; import React, { useEffect, useRef, useState } from "react"; import { useAuth } from "../../contexts/useAuth"; -import HashMapDisplay from "./HashMapDisplay"; import { AuthMethod, fetchAuthMethod, getNoTTLVRequest, sendKmipRequest } from "../../utils/utils"; import * as wasm from "../../wasm/pkg"; +import HashMapDisplay from "./HashMapDisplay"; const formatUnixDate = (unixMs: number): string => { const d = new Date(unixMs); return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }; +/** Attribute keys fetched for every located row — sourced from WASM (single source of truth). + * Lazily initialised on first access so the WASM module is guaranteed to be + * ready (eager module-level evaluation can race with async WASM loading). */ +let _enrichAttributeKeysCache: string[] | null = null; +function getEnrichAttributeKeys(): string[] { + if (_enrichAttributeKeysCache === null || _enrichAttributeKeysCache.length === 0) { + try { + const keys = wasm.get_locate_enrich_attribute_keys(); + if (Array.isArray(keys) && keys.length > 0) { + _enrichAttributeKeysCache = keys as string[]; + } + } catch { + // WASM not ready yet; will retry on next call + } + } + return _enrichAttributeKeysCache ?? []; +} + interface LocateObjectRow { object_id: string; state?: string; @@ -138,18 +156,7 @@ const LocateForm: React.FC = () => { const getReq = wasm.get_attributes_ttlv_request(uid); const getRespStr = await sendKmipRequest(getReq, idToken, serverUrl); if (getRespStr) { - const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, [ - "object_type", - "state", - "tags", - "user_tags", - "cryptographic_algorithm", - "cryptographic_length", - "key_format_type", - "public_key_id", - "private_key_id", - "certificate_id", - ]); + const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, getEnrichAttributeKeys()); const m = extractMeta(parsed); // HSM keys are always Active; use that as default when state is missing const isHsm = /^hsm[0-9]*::/.test(uid); @@ -288,18 +295,7 @@ const LocateForm: React.FC = () => { const getReq = wasm.get_attributes_ttlv_request(uid); const getRespStr = await sendKmipRequest(getReq, idToken, serverUrl); if (getRespStr) { - const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, [ - "object_type", - "state", - "tags", - "user_tags", - "cryptographic_algorithm", - "cryptographic_length", - "key_format_type", - "public_key_id", - "private_key_id", - "certificate_id", - ]); + const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, getEnrichAttributeKeys()); const m = extractMeta(parsed); return { object_id: uid, @@ -356,18 +352,7 @@ const LocateForm: React.FC = () => { const getReq = wasm.get_attributes_ttlv_request(uid); const getRespStr = await sendKmipRequest(getReq, idToken, serverUrl); if (getRespStr) { - const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, [ - "object_type", - "state", - "tags", - "user_tags", - "cryptographic_algorithm", - "cryptographic_length", - "key_format_type", - "public_key_id", - "private_key_id", - "certificate_id", - ]); + const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, getEnrichAttributeKeys()); const m = extractMeta(parsed); return { object_id: uid, @@ -528,15 +513,10 @@ const LocateForm: React.FC = () => { const getReq = wasm.get_attributes_ttlv_request(uid); const getRespStr = await sendKmipRequest(getReq, idToken, serverUrl); if (getRespStr) { - const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, [ - "object_type", - "state", - "tags", - "user_tags", - "cryptographic_algorithm", - "cryptographic_length", - "key_format_type", - ]); + const parsed = await wasm.parse_get_attributes_ttlv_response( + getRespStr, + getEnrichAttributeKeys(), + ); const m = extractMeta(parsed); return { object_id: uid, @@ -611,15 +591,15 @@ const LocateForm: React.FC = () => { const getRespStr = await sendKmipRequest(getReq, idToken, serverUrl); if (getRespStr) { const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, []); + let m: Map; if (parsed instanceof Map) { - setDetailsData(parsed); + m = new Map(parsed as Map); } else if (parsed && typeof parsed === "object") { - // Convert record to Map - const m = new Map(Object.entries(parsed as Record)); - setDetailsData(m); + m = new Map(Object.entries(parsed as Record)); } else { - setDetailsData(new Map()); + m = new Map(); } + setDetailsData(m); setDetailsForId(uid); setDetailsVisible(true); } @@ -804,9 +784,9 @@ const LocateForm: React.FC = () => { dataSource={objects || []} rowKey="object_id" pagination={{ - defaultPageSize: 10, + defaultPageSize: 50, showSizeChanger: true, - pageSizeOptions: [10, 20, 50, 100], + pageSizeOptions: [50, 100, 500, 1000], }} className="border rounded" columns={ diff --git a/ui/src/menuItems.tsx b/ui/src/menuItems.tsx index 660f5f8b48..cbc986f7b0 100644 --- a/ui/src/menuItems.tsx +++ b/ui/src/menuItems.tsx @@ -11,7 +11,6 @@ import { KeyOutlined, LockOutlined, SafetyCertificateOutlined, - SafetyOutlined, SearchOutlined, SolutionOutlined, TeamOutlined, @@ -52,10 +51,19 @@ const baseMenu: MenuItem[] = [ { key: "sym/keys/create", label: "Create" }, { key: "sym/keys/export", label: "Export" }, { key: "sym/keys/import", label: "Import" }, + { key: "sym/keys/rekey", label: "Re-Key" }, { key: "sym/keys/revoke", label: "Revoke" }, { key: "sym/keys/destroy", label: "Destroy" }, ], }, + { + key: "rotation-policy/sym", + label: "Rotation Policy", + children: [ + { key: "rotation-policy/sym/set", label: "Set Policy" }, + { key: "rotation-policy/sym/get", label: "Get Policy" }, + ], + }, { key: "sym/encrypt", label: "Encrypt" }, { key: "sym/decrypt", label: "Decrypt" }, { key: "sym/hash", label: "Hash" }, @@ -64,7 +72,7 @@ const baseMenu: MenuItem[] = [ { key: "rsa", label: "RSA", - icon: , + icon: , collapsedlabel: "RSA", children: [ { @@ -74,10 +82,19 @@ const baseMenu: MenuItem[] = [ { key: "rsa/keys/create", label: "Create" }, { key: "rsa/keys/export", label: "Export" }, { key: "rsa/keys/import", label: "Import" }, + { key: "rsa/keys/rekey", label: "Re-Key" }, { key: "rsa/keys/revoke", label: "Revoke" }, { key: "rsa/keys/destroy", label: "Destroy" }, ], }, + { + key: "rotation-policy/rsa", + label: "Rotation Policy", + children: [ + { key: "rotation-policy/rsa/set", label: "Set Policy" }, + { key: "rotation-policy/rsa/get", label: "Get Policy" }, + ], + }, { key: "rsa/encrypt", label: "Encrypt" }, { key: "rsa/decrypt", label: "Decrypt" }, { key: "rsa/sign", label: "Sign" }, @@ -97,10 +114,19 @@ const baseMenu: MenuItem[] = [ { key: "ec/keys/create", label: "Create" }, { key: "ec/keys/export", label: "Export" }, { key: "ec/keys/import", label: "Import" }, + { key: "ec/keys/rekey", label: "Re-Key" }, { key: "ec/keys/revoke", label: "Revoke" }, { key: "ec/keys/destroy", label: "Destroy" }, ], }, + { + key: "rotation-policy/ec", + label: "Rotation Policy", + children: [ + { key: "rotation-policy/ec/set", label: "Set Policy" }, + { key: "rotation-policy/ec/get", label: "Get Policy" }, + ], + }, { key: "ec/encrypt", label: "Encrypt" }, { key: "ec/decrypt", label: "Decrypt" }, { key: "ec/sign", label: "Sign" }, @@ -121,10 +147,19 @@ const baseMenu: MenuItem[] = [ { key: "pqc/keys/create", label: "Create" }, { key: "pqc/keys/export", label: "Export" }, { key: "pqc/keys/import", label: "Import" }, + { key: "pqc/keys/rekey", label: "Re-Key" }, { key: "pqc/keys/revoke", label: "Revoke" }, { key: "pqc/keys/destroy", label: "Destroy" }, ], }, + { + key: "rotation-policy/pqc", + label: "Rotation Policy", + children: [ + { key: "rotation-policy/pqc/set", label: "Set Policy" }, + { key: "rotation-policy/pqc/get", label: "Get Policy" }, + ], + }, { key: "pqc/encapsulate", label: "Encapsulate" }, { key: "pqc/decapsulate", label: "Decapsulate" }, { key: "pqc/sign", label: "Sign" }, @@ -220,6 +255,7 @@ const baseMenu: MenuItem[] = [ label: "Certs", children: [ { key: "certificates/certs/certify", label: "Certify" }, + { key: "certificates/certs/recertify", label: "ReCertify" }, { key: "certificates/certs/export", label: "Export" }, { key: "certificates/certs/import", label: "Import" }, { key: "certificates/certs/revoke", label: "Revoke" }, @@ -324,9 +360,13 @@ export function getMenuItems(options?: { enableCovercrypt?: boolean; pqcLabel?: const pqcLabel = options?.pqcLabel ?? "PQC"; const isFips = options?.isFips ?? false; - let menu = baseMenu.map((item) => (item.key === "pqc" ? { ...item, label: pqcLabel } : item)); + let menu = baseMenu.map((item) => { + if (item.key === "pqc") return { ...item, label: pqcLabel }; + return item; + }); // Hide PQC, MAC, FPE, and Tokenize/Anonymize in FIPS mode (not approved / not available in FIPS build) + // Rotation Policy for PQC is removed automatically since it lives inside the PQC item. if (isFips) { menu = menu.filter((item) => item.key !== "pqc" && item.key !== "mac" && item.key !== "fpe" && item.key !== "tokenize"); } diff --git a/ui/tests/e2e/README.md b/ui/tests/e2e/README.md index 5c7d78daec..125fe159ca 100644 --- a/ui/tests/e2e/README.md +++ b/ui/tests/e2e/README.md @@ -123,6 +123,72 @@ graph LR Covers ECIES encryption and ECDSA signing on NIST P-256. +## Key Rotation Policy + +_PQC tests skipped in FIPS mode (`PLAYWRIGHT_FIPS_MODE=true`)._ + +### rotation-policy + +Covers set-rotation-policy, get-rotation-policy and rekey for all four key types: +symmetric (AES), RSA, EC (P-256), and PQC (ML-DSA-44). + +```mermaid +graph LR + subgraph Symmetric + A1[Create AES key] --> A2[Set rotation policy\ninterval=86400, name=sym-keyset] + A2 --> A3[Get rotation policy\nassert card + interval value] + A1 --> A4[Re-key → new UID ≠ old] + end + subgraph RSA + B1[Create RSA pair] --> B2[Set rotation policy\ninterval=604800] + B2 --> B3[Get rotation policy] + B1 --> B4[Re-key → new priv + pub UIDs] + end + subgraph EC + C1[Create EC P-256 pair] --> C2[Set rotation policy\ninterval=2592000] + C2 --> C3[Get rotation policy] + C1 --> C4[Re-key → new priv + pub UIDs] + end + subgraph "PQC (non-FIPS)" + D1[Create ML-DSA-44 pair] --> D2[Set rotation policy] + D2 --> D3[Get rotation policy] + D1 --> D4[Re-key → new priv + pub UIDs] + end +``` + +Tests: + +- **set rotation policy** — configure interval, optional offset and keyset name; assert "Rotation policy set successfully" +- **get rotation policy (with policy)** — assert details card is visible and contains the configured interval +- **get rotation policy (no policy)** — assert "No rotation policy configured" message for a fresh key (symmetric only) +- **re-key** — rotate key material and verify the returned UID is a valid UUID different from the original (keypair variants assert both private and public UIDs) + +### keyset-addressing + +Covers all 4 keyset addressing syntax forms (`name`, `name@latest`, `name@first`/`name@0`, `name@N`) +in symmetric encrypt/decrypt operations. + +```mermaid +graph LR + A[Create AES key] --> B[Set rotation name] + B --> C[ReKey 0-2 times] + C --> D["Encrypt with keyset syntax
bare / @latest / @first / @0 / @N"] + D --> E[Decrypt with UUID to prove key selection] + E --> F{Roundtrip match} + F -->|Match| G[Pass] +``` + +Tests (8): + +- **bare keyset name** — encrypt with bare name resolves to latest key +- **name@latest** — explicit `@latest` resolves to latest key +- **name@first** — after rekey, `@first` resolves to gen-0 +- **name@0** — alias for `@first` +- **name@1** — after double rekey, `@1` resolves to gen-1 +- **bare name decrypt** — try-each chain walk after rotation +- **name@99** — nonexistent generation returns error +- **generation after rekey** — get-rotation-policy shows incremented generation + ## Certificates ### certificates-flow diff --git a/ui/tests/e2e/certificates-certify.spec.ts b/ui/tests/e2e/certificates-certify.spec.ts index c8339aa3ec..8e2c78f27c 100644 --- a/ui/tests/e2e/certificates-certify.spec.ts +++ b/ui/tests/e2e/certificates-certify.spec.ts @@ -184,14 +184,17 @@ test.describe("Certificate certify – re-certify", () => { // Create a base certificate first const originalId = await createCertificate(page, "NIST P-256"); - // Re-certify it + // Re-certify it — calls the dedicated KMIP ReCertify operation (Option 3), + // which issues a brand-new certificate with a fresh UID. await gotoAndWait(page, "/ui/certificates/certs/certify"); await page.getByText("3. Certificate ID to Re-certify").click(); await page.fill('input[placeholder="Enter certificate ID to re-certify"]', originalId); const text = await submitAndWaitForResponse(page); - expect(text).toMatch(/certificate successfully created/i); + expect(text).toMatch(/certificate successfully re-certified/i); const newId = extractUuid(text); expect(newId).not.toBeNull(); + // ReCertify must produce a new UID, not overwrite the original. + expect(newId).not.toBe(originalId); }); }); diff --git a/ui/tests/e2e/helpers.ts b/ui/tests/e2e/helpers.ts index 0720a2be35..5e7539ea2b 100644 --- a/ui/tests/e2e/helpers.ts +++ b/ui/tests/e2e/helpers.ts @@ -312,6 +312,21 @@ export async function createSymKey(page: Page): Promise { return id!; } +/** + * Create a fresh AES-256 symmetric key with a specific UID and return that UID. + * + * The server requires rotate_name == key UID; tests that address a key by a + * human-readable keyset name must create the key with that name as its UID. + */ +export async function createSymKeyWithId(page: Page, id: string): Promise { + await gotoAndWait(page, "/ui/sym/keys/create"); + await expect(page.locator(".ant-select-selection-item").first()).not.toHaveText("", { timeout: UI_READY_TIMEOUT }); + await page.fill('input[placeholder="Enter key ID"]', id); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/has been created/i); + return id; +} + /** * Create a fresh 4096-bit RSA key pair and return both key IDs. */ diff --git a/ui/tests/e2e/keyset-addressing.spec.ts b/ui/tests/e2e/keyset-addressing.spec.ts new file mode 100644 index 0000000000..1c4e05556b --- /dev/null +++ b/ui/tests/e2e/keyset-addressing.spec.ts @@ -0,0 +1,200 @@ +/** + * Keyset addressing syntax E2E tests. + * + * Covers all 4 keyset addressing forms in crypto operations: + * • bare keyset name → resolves to latest (encrypt) + * • name@latest → explicit latest resolution + * • name@first / name@0 → resolves to generation 0 + * • name@N → resolves to specific generation N + * • bare name in decrypt → try-each chain walk + * • name@99 → nonexistent generation → error + * • get-rotation-policy after rekey → generation incremented + * + * All tests use AES-GCM (FIPS-approved) — no FIPS skip needed. + */ +import { expect, test } from "@playwright/test"; +import * as fs from "fs"; +import { + UI_READY_TIMEOUT, + createSymKeyWithId, + gotoAndWait, + submitAndWaitForDownload, + submitAndWaitForResponse, + uploadFile, + writeTempFile, +} from "./helpers"; + +const PLAINTEXT = "keyset-addressing-e2e-test-data"; + +// Unique suffix per test-run so keys from prior runs don't block creation. +const RUN_ID = Date.now().toString(36).slice(-6); + +// ── Local helpers ────────────────────────────────────────────────────────── + +async function setRotationPolicy(page: import("@playwright/test").Page, keyId: string, name: string, interval?: number): Promise { + await gotoAndWait(page, "/ui/rotation-policy/sym/set"); + await page.fill('[data-testid="rotation-key-id"]', keyId); + await page.fill('[data-testid="rotation-name"]', name); + if (interval !== undefined) { + await page.locator('[data-testid="rotation-interval"]').fill(String(interval)); + } + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy set successfully/i); +} + +async function rekeyKey(page: import("@playwright/test").Page, keyId: string): Promise { + await gotoAndWait(page, "/ui/sym/keys/rekey"); + await page.fill('[data-testid="rekey-key-id"]', keyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/successfully refreshed/i); + // New key ID may be a keyset address like "name@1", not a plain UUID. + const m = text.match(/New key:\s*(\S+)/i); + expect(m).not.toBeNull(); + const newId = m![1]; + expect(newId).not.toBe(keyId); + return newId; +} + +async function symEncrypt(page: import("@playwright/test").Page, keyIdOrName: string, plainFile: string): Promise { + await gotoAndWait(page, "/ui/sym/encrypt"); + await uploadFile(page, plainFile); + await page.fill('input[placeholder="Enter key ID"]', keyIdOrName); + const { download } = await submitAndWaitForDownload(page); + const encPath = await download.path(); + expect(encPath).not.toBeNull(); + return encPath!; +} + +async function symDecrypt(page: import("@playwright/test").Page, keyIdOrName: string, encFile: string): Promise { + await gotoAndWait(page, "/ui/sym/decrypt"); + await uploadFile(page, encFile); + await page.fill('input[placeholder="Enter key ID"]', keyIdOrName); + const { download } = await submitAndWaitForDownload(page); + const decPath = await download.path(); + expect(decPath).not.toBeNull(); + return decPath!; +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +test.describe("Keyset addressing syntax", () => { + test("encrypt with bare keyset name resolves to latest key", async ({ page }) => { + // Server requires rotate_name == key UID — create with keyset name as UID + const keyId = await createSymKeyWithId(page, `e2e-ks-bare-${RUN_ID}`); + await setRotationPolicy(page, keyId, keyId, 86400); + + const plainFile = writeTempFile("ks-bare.txt", PLAINTEXT); + const encPath = await symEncrypt(page, keyId, plainFile); + + // Decrypt with UUID proves the correct key was used + const decPath = await symDecrypt(page, keyId, encPath); + expect(fs.readFileSync(decPath, "utf-8")).toBe(PLAINTEXT); + }); + + test("encrypt with name@latest resolves to latest key", async ({ page }) => { + const keyId = await createSymKeyWithId(page, `e2e-ks-latest-${RUN_ID}`); + await setRotationPolicy(page, keyId, keyId, 86400); + + const plainFile = writeTempFile("ks-latest.txt", PLAINTEXT); + const encPath = await symEncrypt(page, `${keyId}@latest`, plainFile); + + const decPath = await symDecrypt(page, keyId, encPath); + expect(fs.readFileSync(decPath, "utf-8")).toBe(PLAINTEXT); + }); + + test("decrypt with name@first after rekey resolves to gen-0", async ({ page }) => { + const keyId = await createSymKeyWithId(page, `e2e-ks-first-${RUN_ID}`); + await setRotationPolicy(page, keyId, keyId); + + // Encrypt while gen-0 is still Active + const plainFile = writeTempFile("ks-first.txt", PLAINTEXT); + const encPath = await symEncrypt(page, keyId, plainFile); + + // Rotate: gen-0 → Deactivated, gen-1 → Active + await rekeyKey(page, keyId); + + // Decrypt with @first — Deactivated keys allow decrypt operations + const decPath = await symDecrypt(page, `${keyId}@first`, encPath); + expect(fs.readFileSync(decPath, "utf-8")).toBe(PLAINTEXT); + }); + + test("decrypt with name@0 after rekey resolves to gen-0", async ({ page }) => { + const keyId = await createSymKeyWithId(page, `e2e-ks-zero-${RUN_ID}`); + await setRotationPolicy(page, keyId, keyId); + + // Encrypt while gen-0 is still Active + const plainFile = writeTempFile("ks-zero.txt", PLAINTEXT); + const encPath = await symEncrypt(page, keyId, plainFile); + + // Rotate: gen-0 → Deactivated, gen-1 → Active + await rekeyKey(page, keyId); + + // Decrypt with @0 — Deactivated keys allow decrypt operations + const decPath = await symDecrypt(page, `${keyId}@0`, encPath); + expect(fs.readFileSync(decPath, "utf-8")).toBe(PLAINTEXT); + }); + + test("decrypt with name@1 after double rekey resolves to gen-1", async ({ page }) => { + const keyId = await createSymKeyWithId(page, `e2e-ks-gen1-${RUN_ID}`); + await setRotationPolicy(page, keyId, keyId); + + // First rekey: gen-0 → Deactivated, gen-1 → Active + const gen1Id = await rekeyKey(page, keyId); + + // Encrypt while gen-1 is still Active + const plainFile = writeTempFile("ks-gen1.txt", PLAINTEXT); + const encPath = await symEncrypt(page, gen1Id, plainFile); + + // Second rekey: gen-1 → Deactivated, gen-2 → Active + await rekeyKey(page, gen1Id); + + // Decrypt with @1 — Deactivated keys allow decrypt operations + const decPath = await symDecrypt(page, `${keyId}@1`, encPath); + expect(fs.readFileSync(decPath, "utf-8")).toBe(PLAINTEXT); + }); + + test("decrypt with bare keyset name walks chain after rotation", async ({ page }) => { + const keyId = await createSymKeyWithId(page, `e2e-ks-chain-${RUN_ID}`); + await setRotationPolicy(page, keyId, keyId); + + // Encrypt with gen-0 UUID + const plainFile = writeTempFile("ks-chain.txt", PLAINTEXT); + const encPath = await symEncrypt(page, keyId, plainFile); + + // Rotate: gen-0 → gen-1 + await rekeyKey(page, keyId); + + // Decrypt with bare keyset name — try-each walks gen-1→gen-0 + const decPath = await symDecrypt(page, keyId, encPath); + expect(fs.readFileSync(decPath, "utf-8")).toBe(PLAINTEXT); + }); + + test("encrypt with name@99 fails for nonexistent generation", async ({ page }) => { + const keyId = await createSymKeyWithId(page, `e2e-ks-bad-${RUN_ID}`); + await setRotationPolicy(page, keyId, keyId); + + const plainFile = writeTempFile("ks-bad.txt", PLAINTEXT); + await gotoAndWait(page, "/ui/sym/encrypt"); + await uploadFile(page, plainFile); + await page.fill('input[placeholder="Enter key ID"]', `${keyId}@99`); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/error/i); + }); + + test("get rotation policy after rekey shows incremented generation", async ({ page }) => { + const keyId = await createSymKeyWithId(page, `e2e-ks-gen-${RUN_ID}`); + await setRotationPolicy(page, keyId, keyId, 86400); + + const newKeyId = await rekeyKey(page, keyId); + + await gotoAndWait(page, "/ui/rotation-policy/sym/get"); + await page.fill('[data-testid="get-rotation-key-id"]', newKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy retrieved successfully/i); + await expect(page.locator('[data-testid="rotation-policy-details"]')).toBeVisible({ + timeout: UI_READY_TIMEOUT, + }); + // Generation should be 1 after one rekey + await expect(page.locator('[data-testid="rotation-policy-details"]')).toContainText("1"); + }); +}); diff --git a/ui/tests/e2e/rotation-policy.spec.ts b/ui/tests/e2e/rotation-policy.spec.ts new file mode 100644 index 0000000000..b6b01fe29d --- /dev/null +++ b/ui/tests/e2e/rotation-policy.spec.ts @@ -0,0 +1,352 @@ +/** + * Key rotation policy E2E tests. + * + * Covers per-key-type (symmetric, RSA, EC, PQC): + * • set-rotation-policy – configure interval, offset and keyset name + * • get-rotation-policy – retrieve and verify the configured values + * • get-rotation-policy – returns "no policy" for fresh (unconfigured) keys + * • rekey – rotate the key and verify a new UID is returned + * + * Also covers certificate renewal via the KMIP ReCertify operation (Option 3 + * on the Certificate Issuance page): + * • re-certify self-signed RSA certificate → new UID ≠ original UID + * • re-certify self-signed EC P-256 certificate → new UID ≠ original UID + * • re-certify PQC ML-DSA-44 certificate (skip FIPS) → new UID ≠ original UID + * + * PQC tests are skipped when running in FIPS mode because ML-DSA / ML-KEM are + * not FIPS-approved algorithms. + */ + +import { expect, test } from "@playwright/test"; +import { + UI_READY_TIMEOUT, + createCertificate, + createEcKeyPair, + createPqcKeyPair, + createRsaKeyPair, + createSymKey, + extractUuid, + extractUuidAfterLabel, + gotoAndWait, + submitAndWaitForResponse, +} from "./helpers"; + +const FIPS_MODE = process.env.PLAYWRIGHT_FIPS_MODE === "true"; + +// --------------------------------------------------------------------------- +// Symmetric key rotation +// --------------------------------------------------------------------------- + +test.describe("Symmetric key rotation policy", () => { + test("set rotation policy on AES key", async ({ page }) => { + const keyId = await createSymKey(page); + + await gotoAndWait(page, "/ui/rotation-policy/sym/set"); + await page.fill('[data-testid="rotation-key-id"]', keyId); + // AntD v5 InputNumber passes data-testid to the itself (via rc-input-number inputProps). + await page.locator('[data-testid="rotation-interval"]').fill("86400"); + await page.locator('[data-testid="rotation-offset"]').fill("3600"); + // Server requires rotate_name == key UID; use the key's own UID as the keyset name. + await page.fill('[data-testid="rotation-name"]', keyId); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy set successfully/i); + }); + + test("get rotation policy shows configured values", async ({ page }) => { + const keyId = await createSymKey(page); + + // First set a policy. + await gotoAndWait(page, "/ui/rotation-policy/sym/set"); + await page.fill('[data-testid="rotation-key-id"]', keyId); + await page.locator('[data-testid="rotation-interval"]').fill("86400"); + await page.fill('[data-testid="rotation-name"]', "sym-get-test"); + await submitAndWaitForResponse(page); + + // Then retrieve it. + await gotoAndWait(page, "/ui/rotation-policy/sym/get"); + await page.fill('[data-testid="get-rotation-key-id"]', keyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy retrieved successfully/i); + + // The details card should appear because interval is set. + await expect(page.locator('[data-testid="rotation-policy-details"]')).toBeVisible({ + timeout: UI_READY_TIMEOUT, + }); + // The card must contain the interval value we set. + await expect(page.locator('[data-testid="rotation-policy-details"]')).toContainText("86400"); + }); + + test("get rotation policy returns no-policy message for fresh key", async ({ page }) => { + const keyId = await createSymKey(page); + + await gotoAndWait(page, "/ui/rotation-policy/sym/get"); + await page.fill('[data-testid="get-rotation-key-id"]', keyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/no rotation policy configured/i); + }); + + test("re-key symmetric key returns a different UID", async ({ page }) => { + const keyId = await createSymKey(page); + + await gotoAndWait(page, "/ui/sym/keys/rekey"); + await page.fill('[data-testid="rekey-key-id"]', keyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/successfully refreshed/i); + expect(text).toMatch(/New key:/i); + + // The response must contain a valid UUID different from the original. + const newId = extractUuid(text); + expect(newId).not.toBeNull(); + expect(newId).not.toBe(keyId); + }); +}); + +// --------------------------------------------------------------------------- +// RSA key rotation +// --------------------------------------------------------------------------- + +test.describe("RSA key rotation policy", () => { + test("set rotation policy on RSA key pair", async ({ page }) => { + const { privKeyId } = await createRsaKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/rsa/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("604800"); + // Server requires rotate_name == key UID; use the key's own UID as the keyset name. + await page.fill('[data-testid="rotation-name"]', privKeyId); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy set successfully/i); + }); + + test("get rotation policy shows configured values for RSA key", async ({ page }) => { + const { privKeyId } = await createRsaKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/rsa/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("604800"); + await page.fill('[data-testid="rotation-name"]', "rsa-get-test"); + await submitAndWaitForResponse(page); + + await gotoAndWait(page, "/ui/rotation-policy/rsa/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy retrieved successfully/i); + + await expect(page.locator('[data-testid="rotation-policy-details"]')).toBeVisible({ + timeout: UI_READY_TIMEOUT, + }); + await expect(page.locator('[data-testid="rotation-policy-details"]')).toContainText("604800"); + }); + + test("get rotation policy returns no-policy message for fresh RSA key", async ({ page }) => { + const { privKeyId } = await createRsaKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/rsa/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/no rotation policy configured/i); + }); + + test("re-key RSA key pair returns new private and public key UIDs", async ({ page }) => { + const { privKeyId } = await createRsaKeyPair(page); + + await gotoAndWait(page, "/ui/rsa/keys/rekey"); + await page.fill('[data-testid="rekey-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/RSA key pair was successfully rotated/i); + + const newPrivId = extractUuidAfterLabel(text, "New private key"); + const newPubId = extractUuidAfterLabel(text, "New public key"); + expect(newPrivId).not.toBeNull(); + expect(newPubId).not.toBeNull(); + expect(newPrivId).not.toBe(privKeyId); + }); +}); + +// --------------------------------------------------------------------------- +// EC key rotation +// --------------------------------------------------------------------------- + +test.describe("EC key rotation policy", () => { + test("set rotation policy on EC key pair", async ({ page }) => { + const { privKeyId } = await createEcKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/ec/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("2592000"); + // Server requires rotate_name == key UID; use the key's own UID as the keyset name. + await page.fill('[data-testid="rotation-name"]', privKeyId); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy set successfully/i); + }); + + test("get rotation policy shows configured values for EC key", async ({ page }) => { + const { privKeyId } = await createEcKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/ec/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("2592000"); + await page.fill('[data-testid="rotation-name"]', "ec-get-test"); + await submitAndWaitForResponse(page); + + await gotoAndWait(page, "/ui/rotation-policy/ec/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy retrieved successfully/i); + + await expect(page.locator('[data-testid="rotation-policy-details"]')).toBeVisible({ + timeout: UI_READY_TIMEOUT, + }); + await expect(page.locator('[data-testid="rotation-policy-details"]')).toContainText("2592000"); + }); + + test("get rotation policy returns no-policy message for fresh EC key", async ({ page }) => { + const { privKeyId } = await createEcKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/ec/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/no rotation policy configured/i); + }); + + test("re-key EC key pair returns new private and public key UIDs", async ({ page }) => { + const { privKeyId } = await createEcKeyPair(page); + + await gotoAndWait(page, "/ui/ec/keys/rekey"); + await page.fill('[data-testid="rekey-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/EC key pair was successfully rotated/i); + + const newPrivId = extractUuidAfterLabel(text, "New private key"); + const newPubId = extractUuidAfterLabel(text, "New public key"); + expect(newPrivId).not.toBeNull(); + expect(newPubId).not.toBeNull(); + expect(newPrivId).not.toBe(privKeyId); + }); +}); + +// --------------------------------------------------------------------------- +// PQC key rotation (ML-DSA-44; skipped in FIPS mode) +// --------------------------------------------------------------------------- + +test.describe("PQC key rotation policy", () => { + test.skip(FIPS_MODE, "PQC algorithms are not available in FIPS mode"); + + test("set rotation policy on PQC key pair", async ({ page }) => { + const { privKeyId } = await createPqcKeyPair(page, "ML-DSA-44"); + + await gotoAndWait(page, "/ui/rotation-policy/pqc/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("86400"); + // Server requires rotate_name == key UID; use the key's own UID as the keyset name. + await page.fill('[data-testid="rotation-name"]', privKeyId); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy set successfully/i); + }); + + test("get rotation policy shows configured values for PQC key", async ({ page }) => { + const { privKeyId } = await createPqcKeyPair(page, "ML-DSA-44"); + + await gotoAndWait(page, "/ui/rotation-policy/pqc/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("86400"); + await page.fill('[data-testid="rotation-name"]', "pqc-get-test"); + await submitAndWaitForResponse(page); + + await gotoAndWait(page, "/ui/rotation-policy/pqc/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy retrieved successfully/i); + + await expect(page.locator('[data-testid="rotation-policy-details"]')).toBeVisible({ + timeout: UI_READY_TIMEOUT, + }); + await expect(page.locator('[data-testid="rotation-policy-details"]')).toContainText("86400"); + }); + + test("get rotation policy returns no-policy message for fresh PQC key", async ({ page }) => { + const { privKeyId } = await createPqcKeyPair(page, "ML-DSA-44"); + + await gotoAndWait(page, "/ui/rotation-policy/pqc/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/no rotation policy configured/i); + }); + + test("re-key PQC key pair returns new private and public key UIDs", async ({ page }) => { + const { privKeyId } = await createPqcKeyPair(page, "ML-DSA-44"); + + await gotoAndWait(page, "/ui/pqc/keys/rekey"); + await page.fill('[data-testid="rekey-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/post-quantum key pair was successfully rotated/i); + + const newPrivId = extractUuidAfterLabel(text, "New private key"); + const newPubId = extractUuidAfterLabel(text, "New public key"); + expect(newPrivId).not.toBeNull(); + expect(newPubId).not.toBeNull(); + expect(newPrivId).not.toBe(privKeyId); + }); +}); + +// --------------------------------------------------------------------------- +// Certificate renewal (KMIP ReCertify operation) +// --------------------------------------------------------------------------- + +test.describe("Certificate renewal (ReCertify)", () => { + test("re-certify self-signed RSA certificate returns a new distinct UID", async ({ page }) => { + // Create a base self-signed certificate (generates a key pair internally). + const originalId = await createCertificate(page, "RSA 2048"); + + // Re-certify it via Option 3 — this must call the dedicated KMIP ReCertify + // operation, which creates a brand-new certificate with a fresh UID and links + // the old and new certs via ReplacedObjectLink / ReplacementObjectLink. + await gotoAndWait(page, "/ui/certificates/certs/certify"); + await page.getByText("3. Certificate ID to Re-certify").click(); + await page.fill('input[placeholder="Enter certificate ID to re-certify"]', originalId); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/certificate successfully re-certified/i); + + // The new UID must be present and differ from the original. + const newId = extractUuid(text); + expect(newId).not.toBeNull(); + expect(newId).not.toBe(originalId); + }); + + test("re-certify self-signed EC P-256 certificate returns a new distinct UID", async ({ page }) => { + const originalId = await createCertificate(page, "NIST P-256"); + + await gotoAndWait(page, "/ui/certificates/certs/certify"); + await page.getByText("3. Certificate ID to Re-certify").click(); + await page.fill('input[placeholder="Enter certificate ID to re-certify"]', originalId); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/certificate successfully re-certified/i); + + const newId = extractUuid(text); + expect(newId).not.toBeNull(); + expect(newId).not.toBe(originalId); + }); + + test("re-certify PQC ML-DSA-44 certificate returns a new distinct UID", async ({ page }) => { + test.skip(FIPS_MODE, "PQC algorithms are not available in FIPS mode"); + + const originalId = await createCertificate(page, "ML-DSA-44 (PQC)"); + + await gotoAndWait(page, "/ui/certificates/certs/certify"); + await page.getByText("3. Certificate ID to Re-certify").click(); + await page.fill('input[placeholder="Enter certificate ID to re-certify"]', originalId); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/certificate successfully re-certified/i); + + const newId = extractUuid(text); + expect(newId).not.toBeNull(); + expect(newId).not.toBe(originalId); + }); +});