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/.gitmodules b/.gitmodules index a8b036530c..392018345c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "kmip"] path = kmip url = https://github.com/Cosmian/kmip.git +[submodule "standards"] + path = standards + url = https://github.com/Cosmian/standards.git diff --git a/.mise/scripts/docs/gen_vector_readme.py b/.mise/scripts/docs/gen_vector_readme.py new file mode 100755 index 0000000000..6892b5e8fd --- /dev/null +++ b/.mise/scripts/docs/gen_vector_readme.py @@ -0,0 +1,677 @@ +#!/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' +with open(OUTPUT, 'w') 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)" +) 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/vector-readme b/.mise/tasks/docs/vector-readme new file mode 100755 index 0000000000..96aa35b4ae --- /dev/null +++ b/.mise/tasks/docs/vector-readme @@ -0,0 +1,11 @@ +#!/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" + +print_success "Vector README regenerated" 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 c0e1c45bd7..ecfcd9cadb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -239,6 +239,14 @@ repos: pass_filenames: false types_or: [javascript, jsx, ts, tsx] + - id: gen-vector-readme + name: Regenerate test_kms_server vector README + entry: python3 .mise/scripts/docs/gen_vector_readme.py + language: system + pass_filenames: false + files: test_data/vectors/.*/manifest\.toml|\.mise/scripts/docs/gen_vector_readme\.py + stages: [manual] + - id: pnpm-ui-lint name: pnpm ui check:lint entry: pnpm -C ui check:lint diff --git a/CHANGELOG/docs_key-autorotation-spec.md b/CHANGELOG/docs_key-autorotation-spec.md new file mode 100644 index 0000000000..d697f919a6 --- /dev/null +++ b/CHANGELOG/docs_key-autorotation-spec.md @@ -0,0 +1,118 @@ +## 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)) + +## Bug Fixes + +- Fix `ReKeyKeyPair` not propagating `CryptographicUsageMask` from old key pair to the new `CreateKeyPair` request — causes FIPS-mode rejection (`got None but expected among 0x00103A01`) when rotating EC or RSA key pairs in a keyset +- Add non-regression test vector `negative/rekey_keypair_non_latest`: CreateKeyPair (EC P-256, FIPS masks), SetAttribute(RotateName), ReKeyKeyPair (gen-0→gen-1 succeeds), ReKeyKeyPair (gen-0 again) → "not the latest" error + +- Fix KMIP lifecycle semantics: restore correct `setup_object_lifecycle` behavior — past `activation_date` → Active, `None` → PreActive ([#968](https://github.com/Cosmian/kms/pull/968)) +- Add explicit `activation_date: Some(now)` to all request builders and test helpers requiring immediate Active state ([#968](https://github.com/Cosmian/kms/pull/968)) +- Fix KMIP spec reference: `§4.7` → `§4.8` in `rekey/common.rs` ([#968](https://github.com/Cosmian/kms/pull/968)) +- 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 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 5e63b51b34..2b9bf9bd51 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..ff021af2c9 --- /dev/null +++ b/crate/clients/ckms/src/tests/elliptic_curve/rotation_policy.rs @@ -0,0 +1,79 @@ +use test_kms_server::start_default_test_kms_server; + +use super::create_key_pair::create_ec_key_pair; +use crate::{ + error::result::CosmianResult, + tests::utils::{owner_config, run_ckms}, +}; + +#[tokio::test] +pub(crate) async fn test_ec_set_and_get_rotation_policy() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create an EC key pair (P-256) + let (private_key_id, _public_key_id) = + create_ec_key_pair(&owner_client_conf_path, "nist-p256", &[], false)?; + + // Set rotation policy on the private key with interval and name + let args = vec![ + "ec", + "keys", + "set-rotation-policy", + "--key-id", + &private_key_id, + "--interval", + "172800", + "--rotation-name", + "ec-keyset", + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("Rotation policy set successfully"), + "expected success message in: {output}" + ); + + // Get rotation policy and verify + let args = vec![ + "ec", + "keys", + "get-rotation-policy", + "--key-id", + &private_key_id, + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("172800"), + "expected interval=172800 in: {output}" + ); + assert!( + output.contains("ec-keyset"), + "expected name=ec-keyset in: {output}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_ec_rekey() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create an EC key pair (P-256) + let (private_key_id, _public_key_id) = + create_ec_key_pair(&owner_client_conf_path, "nist-p256", &[], false)?; + + // Re-Key the EC key pair + let args = vec!["ec", "keys", "re-key", "--key-id", &private_key_id]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("rotated"), + "expected 'rotated' in: {output}" + ); + assert!( + output.contains("Unique identifier"), + "expected new UID in: {output}" + ); + + Ok(()) +} diff --git a/crate/clients/ckms/src/tests/pqc/mod.rs b/crate/clients/ckms/src/tests/pqc/mod.rs index b580e00a15..35aead08db 100644 --- a/crate/clients/ckms/src/tests/pqc/mod.rs +++ b/crate/clients/ckms/src/tests/pqc/mod.rs @@ -1,3 +1,5 @@ +mod rotation_policy; + use std::{path::Path, process::Command}; use assert_cmd::prelude::*; diff --git a/crate/clients/ckms/src/tests/pqc/rotation_policy.rs b/crate/clients/ckms/src/tests/pqc/rotation_policy.rs new file mode 100644 index 0000000000..86faecf734 --- /dev/null +++ b/crate/clients/ckms/src/tests/pqc/rotation_policy.rs @@ -0,0 +1,96 @@ +use test_kms_server::start_default_test_kms_server; + +use crate::{ + error::result::CosmianResult, + tests::utils::{owner_config, run_ckms}, +}; + +/// Create a ML-KEM key pair using `run_ckms` and return (`private_key_id`, `public_key_id`) +fn create_ml_kem_key_pair(cli_conf_path: &str) -> CosmianResult<(String, String)> { + let args = vec!["pqc", "keys", "create", "--algorithm", "ml-kem-768"]; + let output = run_ckms(cli_conf_path, &args)?; + // Parse "Private key unique identifier: xxx" + let sk_id = output + .lines() + .find(|l| l.contains("Private key unique identifier")) + .and_then(|l| l.split(':').next_back()) + .map(|s| s.trim().to_owned()) + .unwrap_or_default(); + let pk_id = output + .lines() + .find(|l| l.contains("Public key unique identifier")) + .and_then(|l| l.split(':').next_back()) + .map(|s| s.trim().to_owned()) + .unwrap_or_default(); + Ok((sk_id, pk_id)) +} + +#[tokio::test] +pub(crate) async fn test_pqc_set_and_get_rotation_policy() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a ML-KEM key pair + let (private_key_id, _public_key_id) = create_ml_kem_key_pair(&owner_client_conf_path)?; + + // Set rotation policy + let args = vec![ + "pqc", + "keys", + "set-rotation-policy", + "--key-id", + &private_key_id, + "--interval", + "259200", + "--rotation-name", + "pqc-keyset", + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("Rotation policy set successfully"), + "expected success message in: {output}" + ); + + // Get rotation policy and verify + let args = vec![ + "pqc", + "keys", + "get-rotation-policy", + "--key-id", + &private_key_id, + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("259200"), + "expected interval=259200 in: {output}" + ); + assert!( + output.contains("pqc-keyset"), + "expected name=pqc-keyset in: {output}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_pqc_rekey() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a ML-KEM key pair + let (private_key_id, _public_key_id) = create_ml_kem_key_pair(&owner_client_conf_path)?; + + // Re-Key the PQC key pair + let args = vec!["pqc", "keys", "re-key", "--key-id", &private_key_id]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("rotated"), + "expected 'rotated' in: {output}" + ); + assert!( + output.contains("Unique identifier"), + "expected new UID in: {output}" + ); + + Ok(()) +} diff --git a/crate/clients/ckms/src/tests/rsa/mod.rs b/crate/clients/ckms/src/tests/rsa/mod.rs index 54b60cb321..459f37ed20 100644 --- a/crate/clients/ckms/src/tests/rsa/mod.rs +++ b/crate/clients/ckms/src/tests/rsa/mod.rs @@ -3,6 +3,8 @@ pub(crate) mod create_key_pair; #[cfg(feature = "non-fips")] pub(crate) mod encrypt_decrypt; #[cfg(feature = "non-fips")] +pub(crate) mod rotation_policy; +#[cfg(feature = "non-fips")] pub(crate) mod sign_verify; #[cfg(feature = "non-fips")] diff --git a/crate/clients/ckms/src/tests/rsa/rotation_policy.rs b/crate/clients/ckms/src/tests/rsa/rotation_policy.rs new file mode 100644 index 0000000000..349da47842 --- /dev/null +++ b/crate/clients/ckms/src/tests/rsa/rotation_policy.rs @@ -0,0 +1,92 @@ +use test_kms_server::start_default_test_kms_server; + +use super::create_key_pair::{RsaKeyPairOptions, create_rsa_key_pair}; +use crate::{ + error::result::CosmianResult, + tests::utils::{owner_config, run_ckms}, +}; + +#[tokio::test] +pub(crate) async fn test_rsa_set_and_get_rotation_policy() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create an RSA key pair + let (private_key_id, _public_key_id) = create_rsa_key_pair( + &owner_client_conf_path, + &RsaKeyPairOptions { + number_of_bits: Some(2048), + ..Default::default() + }, + )?; + + // Set rotation policy on the private key with interval, offset, and name + let args = vec![ + "rsa", + "keys", + "set-rotation-policy", + "--key-id", + &private_key_id, + "--interval", + "86400", + "--offset", + "7200", + "--rotation-name", + "rsa-keyset", + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("Rotation policy set successfully"), + "expected success message in: {output}" + ); + + // Get rotation policy and verify + let args = vec![ + "rsa", + "keys", + "get-rotation-policy", + "--key-id", + &private_key_id, + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("86400"), + "expected interval=86400 in: {output}" + ); + assert!(output.contains("7200"), "expected offset=7200 in: {output}"); + assert!( + output.contains("rsa-keyset"), + "expected name=rsa-keyset in: {output}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_rsa_rekey() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create an RSA key pair + let (private_key_id, _public_key_id) = create_rsa_key_pair( + &owner_client_conf_path, + &RsaKeyPairOptions { + number_of_bits: Some(2048), + ..Default::default() + }, + )?; + + // Re-Key the RSA key pair + let args = vec!["rsa", "keys", "re-key", "--key-id", &private_key_id]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!( + output.contains("rotated"), + "expected 'rotated' in: {output}" + ); + assert!( + output.contains("Unique identifier"), + "expected new UID in: {output}" + ); + + Ok(()) +} diff --git a/crate/clients/ckms/src/tests/symmetric/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..2fb6631d41 --- /dev/null +++ b/crate/clients/ckms/src/tests/symmetric/rotation_policy.rs @@ -0,0 +1,543 @@ +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 + let key_id = create_symmetric_key(&owner_client_conf_path, &["--number-of-bits", "256"])?; + + // Set rotation policy with interval, offset, and name + let output = set_rotation_policy( + &owner_client_conf_path, + &key_id, + 86400, + Some(3600), + Some("test-keyset"), + )?; + assert!(output.contains("Rotation policy set successfully")); + + // Get rotation policy and verify + let output = get_rotation_policy(&owner_client_conf_path, &key_id)?; + assert!( + output.contains("86400"), + "expected interval=86400 in: {output}" + ); + assert!(output.contains("3600"), "expected offset=3600 in: {output}"); + assert!( + output.contains("test-keyset"), + "expected name=test-keyset in: {output}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_set_rotation_policy_name_rejects_at() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a symmetric key + let key_id = create_symmetric_key(&owner_client_conf_path, &["--number-of-bits", "256"])?; + + // Try to set rotation policy with a name containing '@' — should fail + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--interval", + "86400", + "--rotation-name", + "bad@name", + ]; + let stderr = run_ckms_expect_error(&owner_client_conf_path, &args)?; + assert!( + stderr.contains('@'), + "expected error mentioning '@' in: {stderr}" + ); + + Ok(()) +} + +#[tokio::test] +pub(crate) async fn test_set_rotation_policy_interval_only() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create a symmetric key + let key_id = create_symmetric_key(&owner_client_conf_path, &["--number-of-bits", "256"])?; + + // Set rotation policy with interval only (no offset, no name) + let output = set_rotation_policy(&owner_client_conf_path, &key_id, 43200, None, None)?; + assert!(output.contains("Rotation policy set successfully")); + + // Get rotation policy and verify + let output = get_rotation_policy(&owner_client_conf_path, &key_id)?; + assert!( + output.contains("43200"), + "expected interval=43200 in: {output}" + ); + assert!( + output.contains("not set") || !output.contains("offset"), + "expected no offset set" + ); + + Ok(()) +} + +/// Full keyset workflow: create → set name → encrypt → rekey → decrypt via keyset name +#[tokio::test] +pub(crate) async fn test_keyset_workflow() -> CosmianResult<()> { + let ctx = start_default_test_kms_server().await; + let owner_client_conf_path = owner_config(ctx); + + // Create AES-256 key + let key_id = create_symmetric_key(&owner_client_conf_path, &["--number-of-bits", "256"])?; + + // Set rotation name (keyset) without interval + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--rotation-name", + "e2e-keyset", + ]; + let output = run_ckms(&owner_client_conf_path, &args)?; + assert!(output.contains("Rotation policy set successfully")); + + // Encrypt a file using the keyset bare name + let tmp_dir = TempDir::new()?; + let input_file = tmp_dir.path().join("plain.txt"); + fs::write(&input_file, b"hello keyset rotation")?; + let encrypted_file = tmp_dir.path().join("cipher.enc"); + + encrypt( + &owner_client_conf_path, + input_file.to_str().unwrap(), + "e2e-keyset", + DataEncryptionAlgorithm::AesGcm, + None, + Some(encrypted_file.to_str().unwrap()), + None, + )?; + + // ReKey: gen-0 → gen-1 + let _new_key_id = rekey_symmetric_key(&owner_client_conf_path, &key_id)?; + + // Decrypt via keyset bare name (should walk chain and find gen-0) + let decrypted_file = tmp_dir.path().join("decrypted.txt"); + decrypt( + &owner_client_conf_path, + encrypted_file.to_str().unwrap(), + "e2e-keyset", + DataEncryptionAlgorithm::AesGcm, + None, + Some(decrypted_file.to_str().unwrap()), + None, + )?; + let decrypted = fs::read(&decrypted_file)?; + assert_eq!(decrypted, b"hello keyset rotation"); + + Ok(()) +} + +/// Encrypt with `name@first` after rotation: must use gen-0 key +#[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"])?; + + // 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)?; + + // ReKey: gen-0 → gen-1 + let _new_key_id = rekey_symmetric_key(&owner_client_conf_path, &key_id)?; + + // Encrypt using @first — should resolve to gen-0 + 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, + )?; + + // Decrypt using gen-0 UID — proves gen-0 key was used + let decrypted_file = tmp_dir.path().join("decrypted.txt"); + decrypt( + &owner_client_conf_path, + encrypted_file.to_str().unwrap(), + &key_id, + 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: alias for `@first` +#[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"])?; + + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--rotation-name", + "kst-enc-zero", + ]; + 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)?; + + // Encrypt using @0 + 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, + )?; + + // Decrypt using gen-0 UID + let decrypted_file = tmp_dir.path().join("decrypted.txt"); + decrypt( + &owner_client_conf_path, + encrypted_file.to_str().unwrap(), + &key_id, + 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: must use gen-1 key +#[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"])?; + + 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 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)?; + + // Encrypt using @1 — should resolve to gen-1 + 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, + )?; + + // Decrypt using gen-1 UID — proves gen-1 key was used + let decrypted_file = tmp_dir.path().join("decrypted.txt"); + decrypt( + &owner_client_conf_path, + encrypted_file.to_str().unwrap(), + &gen1_id, + 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"])?; + + 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"])?; + + 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"])?; + + 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 + let key_id = create_symmetric_key(&owner_client_conf_path, &["--number-of-bits", "256"])?; + + // Set rotation name (keyset) without interval + let args = vec![ + "sym", + "keys", + "set-rotation-policy", + "--key-id", + &key_id, + "--rotation-name", + "e2e-nlat", + ]; + run_ckms(&owner_client_conf_path, &args)?; + + // ReKey: gen-0 → gen-1 + let _new_key_id = rekey_symmetric_key(&owner_client_conf_path, &key_id)?; + + // Attempt to re-key the original (now non-latest) key — should fail + let args = vec!["sym", "keys", "re-key", "--key-id", &key_id]; + let stderr = run_ckms_expect_error(&owner_client_conf_path, &args)?; + assert!( + stderr.contains("not the latest"), + "expected 'not the latest' error, got: {stderr}" + ); + + Ok(()) +} diff --git a/crate/clients/clap/src/actions/elliptic_curves/keys/create_key_pair.rs b/crate/clients/clap/src/actions/elliptic_curves/keys/create_key_pair.rs index 6f0e66beaf..25b7204412 100644 --- a/crate/clients/clap/src/actions/elliptic_curves/keys/create_key_pair.rs +++ b/crate/clients/clap/src/actions/elliptic_curves/keys/create_key_pair.rs @@ -6,7 +6,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::console, + actions::{console, shared::RotationPolicyArgs}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -54,6 +54,10 @@ pub struct CreateKeyPairAction { verbatim_doc_comment )] pub(crate) wrapping_key_id: Option, + + /// Optional rotation policy to apply immediately after key pair creation. + #[clap(flatten)] + pub(crate) rotation_policy: RotationPolicyArgs, } impl CreateKeyPairAction { @@ -82,6 +86,14 @@ impl CreateKeyPairAction { let private_key_unique_identifier = &create_key_pair_response.private_key_unique_identifier; let public_key_unique_identifier = &create_key_pair_response.public_key_unique_identifier; + // Apply rotation policy on the private key (which is the keyset anchor) + if self.rotation_policy.is_set() { + let sk_id = private_key_unique_identifier + .as_str() + .with_context(|| "the server did not return a private key id as a string")?; + self.rotation_policy.apply(&kms_rest_client, sk_id).await?; + } + let mut stdout = console::Stdout::new("The EC key pair has been created."); stdout.set_tags(Some(&self.tags)); stdout.set_key_pair_unique_identifier( diff --git a/crate/clients/clap/src/actions/elliptic_curves/keys/mod.rs b/crate/clients/clap/src/actions/elliptic_curves/keys/mod.rs index 04e9bdabab..f2cf774a08 100644 --- a/crate/clients/clap/src/actions/elliptic_curves/keys/mod.rs +++ b/crate/clients/clap/src/actions/elliptic_curves/keys/mod.rs @@ -7,7 +7,8 @@ use self::{ }; use crate::{ actions::shared::{ - ActivateKeyAction, ExportSecretDataOrKeyAction, ImportSecretDataOrKeyAction, + ActivateKeyAction, ExportSecretDataOrKeyAction, GetRotationPolicyAction, + ImportSecretDataOrKeyAction, ReKeyKeyPairAction, SetRotationPolicyAction, UnwrapSecretDataOrKeyAction, WrapSecretDataOrKeyAction, }, error::result::KmsCliResult, @@ -28,6 +29,9 @@ pub enum KeysCommands { Unwrap(UnwrapSecretDataOrKeyAction), Revoke(RevokeKeyAction), Destroy(DestroyKeyAction), + ReKey(ReKeyKeyPairAction), + SetRotationPolicy(SetRotationPolicyAction), + GetRotationPolicy(GetRotationPolicyAction), } impl KeysCommands { @@ -55,6 +59,15 @@ impl KeysCommands { Self::Destroy(action) => { action.run(kms_rest_client).await?; } + Self::ReKey(action) => { + action.run(kms_rest_client).await?; + } + Self::SetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } + Self::GetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } } Ok(()) diff --git a/crate/clients/clap/src/actions/pqc/keys/create_key_pair.rs b/crate/clients/clap/src/actions/pqc/keys/create_key_pair.rs index abe38806f7..f0bb4ca4a7 100644 --- a/crate/clients/clap/src/actions/pqc/keys/create_key_pair.rs +++ b/crate/clients/clap/src/actions/pqc/keys/create_key_pair.rs @@ -11,7 +11,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::console, + actions::{console, shared::RotationPolicyArgs}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -152,6 +152,10 @@ pub struct CreatePqcKeyPairAction { /// Sensitive: if set, the private key will not be exportable #[clap(long = "sensitive", default_value = "false")] pub(crate) sensitive: bool, + + /// Optional rotation policy to apply immediately after key pair creation. + #[clap(flatten)] + pub(crate) rotation_policy: RotationPolicyArgs, } impl CreatePqcKeyPairAction { @@ -184,6 +188,15 @@ impl CreatePqcKeyPairAction { .await .with_context(|| "failed creating a PQC key pair")?; + // Apply rotation policy on the private key (which is the keyset anchor) + if self.rotation_policy.is_set() { + let sk_id = response + .private_key_unique_identifier + .as_str() + .with_context(|| "the server did not return a private key id as a string")?; + self.rotation_policy.apply(&kms_rest_client, sk_id).await?; + } + let mut stdout = console::Stdout::new("The PQC key pair has been properly generated."); stdout.set_tags(Some(&self.tags)); stdout.set_key_pair_unique_identifier( diff --git a/crate/clients/clap/src/actions/pqc/keys/mod.rs b/crate/clients/clap/src/actions/pqc/keys/mod.rs index 4ecd397c45..4ae065cf9a 100644 --- a/crate/clients/clap/src/actions/pqc/keys/mod.rs +++ b/crate/clients/clap/src/actions/pqc/keys/mod.rs @@ -7,7 +7,8 @@ use self::{ }; use crate::{ actions::shared::{ - ActivateKeyAction, ExportSecretDataOrKeyAction, ImportSecretDataOrKeyAction, + ActivateKeyAction, ExportSecretDataOrKeyAction, GetRotationPolicyAction, + ImportSecretDataOrKeyAction, ReKeyKeyPairAction, SetRotationPolicyAction, UnwrapSecretDataOrKeyAction, WrapSecretDataOrKeyAction, }, error::result::KmsCliResult, @@ -28,6 +29,9 @@ pub enum KeysCommands { Unwrap(UnwrapSecretDataOrKeyAction), Revoke(RevokeKeyAction), Destroy(DestroyKeyAction), + ReKey(ReKeyKeyPairAction), + SetRotationPolicy(SetRotationPolicyAction), + GetRotationPolicy(GetRotationPolicyAction), } impl KeysCommands { @@ -55,6 +59,15 @@ impl KeysCommands { Self::Destroy(action) => { action.run(kms_rest_client).await?; } + Self::ReKey(action) => { + action.run(kms_rest_client).await?; + } + Self::SetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } + Self::GetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } } Ok(()) } diff --git a/crate/clients/clap/src/actions/rsa/keys/create_key_pair.rs b/crate/clients/clap/src/actions/rsa/keys/create_key_pair.rs index bf146270e6..c49a638138 100644 --- a/crate/clients/clap/src/actions/rsa/keys/create_key_pair.rs +++ b/crate/clients/clap/src/actions/rsa/keys/create_key_pair.rs @@ -5,7 +5,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::console, + actions::{console, shared::RotationPolicyArgs}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -55,6 +55,10 @@ pub struct CreateKeyPairAction { verbatim_doc_comment )] pub wrapping_key_id: Option, + + /// Optional rotation policy to apply immediately after key pair creation. + #[clap(flatten)] + pub rotation_policy: RotationPolicyArgs, } impl Default for CreateKeyPairAction { @@ -65,6 +69,7 @@ impl Default for CreateKeyPairAction { private_key_id: None, sensitive: false, wrapping_key_id: None, + rotation_policy: RotationPolicyArgs::default(), } } } @@ -112,6 +117,14 @@ impl CreateKeyPairAction { let private_key_unique_identifier = &create_key_pair_response.private_key_unique_identifier; let public_key_unique_identifier = &create_key_pair_response.public_key_unique_identifier; + // Apply rotation policy on the private key (which is the keyset anchor) + if self.rotation_policy.is_set() { + let sk_id = private_key_unique_identifier + .as_str() + .with_context(|| "the server did not return a private key id as a string")?; + self.rotation_policy.apply(&kms_rest_client, sk_id).await?; + } + let mut stdout = console::Stdout::new("The RSA key pair has been created."); stdout.set_tags(Some(&self.tags)); stdout.set_key_pair_unique_identifier( diff --git a/crate/clients/clap/src/actions/rsa/keys/mod.rs b/crate/clients/clap/src/actions/rsa/keys/mod.rs index 77f38b0bc2..f2950edb0e 100644 --- a/crate/clients/clap/src/actions/rsa/keys/mod.rs +++ b/crate/clients/clap/src/actions/rsa/keys/mod.rs @@ -7,7 +7,8 @@ use self::{ }; use crate::{ actions::shared::{ - ActivateKeyAction, ExportSecretDataOrKeyAction, ImportSecretDataOrKeyAction, + ActivateKeyAction, ExportSecretDataOrKeyAction, GetRotationPolicyAction, + ImportSecretDataOrKeyAction, ReKeyKeyPairAction, SetRotationPolicyAction, UnwrapSecretDataOrKeyAction, WrapSecretDataOrKeyAction, }, error::result::KmsCliResult, @@ -28,6 +29,9 @@ pub enum KeysCommands { Unwrap(UnwrapSecretDataOrKeyAction), Revoke(RevokeKeyAction), Destroy(DestroyKeyAction), + ReKey(ReKeyKeyPairAction), + SetRotationPolicy(SetRotationPolicyAction), + GetRotationPolicy(GetRotationPolicyAction), } impl KeysCommands { @@ -72,6 +76,15 @@ impl KeysCommands { Self::Destroy(action) => { action.run(kms_rest_client).await?; } + Self::ReKey(action) => { + action.run(kms_rest_client).await?; + } + Self::SetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } + Self::GetRotationPolicy(action) => { + action.run(kms_rest_client).await?; + } } Ok(()) diff --git a/crate/clients/clap/src/actions/shared/get_rotation_policy.rs b/crate/clients/clap/src/actions/shared/get_rotation_policy.rs new file mode 100644 index 0000000000..413dac6f26 --- /dev/null +++ b/crate/clients/clap/src/actions/shared/get_rotation_policy.rs @@ -0,0 +1,65 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{kmip_operations::GetAttributes, kmip_types::UniqueIdentifier}, +}; + +use crate::{ + actions::console, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; + +/// Get the automatic rotation policy for a key or key pair. +/// +/// Displays: rotation interval, offset, keyset name, generation, and last rotation date. +#[derive(Parser, Debug)] +#[clap(verbatim_doc_comment)] +pub struct GetRotationPolicyAction { + /// The unique identifier of the key to get the rotation policy from. + #[clap(long = "key-id", short = 'k')] + key_id: String, +} + +impl GetRotationPolicyAction { + pub async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult<()> { + let uid = UniqueIdentifier::TextString(self.key_id.clone()); + + let response = kms_rest_client + .get_attributes(GetAttributes { + unique_identifier: Some(uid), + attribute_reference: None, + }) + .await + .with_context(|| "failed retrieving attributes")?; + + let attrs = &response.attributes; + + let interval = attrs + .rotate_interval + .map_or_else(|| "not set".to_owned(), |v| v.to_string()); + let offset = attrs + .rotate_offset + .map_or_else(|| "not set".to_owned(), |v| v.to_string()); + let name = attrs.rotate_name.as_deref().unwrap_or("not set"); + let generation = attrs + .rotate_generation + .map_or_else(|| "not set".to_owned(), |v| v.to_string()); + let date = attrs + .rotate_date + .map_or_else(|| "never".to_owned(), |d| d.to_string()); + + let output = format!( + "Rotation policy for key: {}\n\ + \x20 Interval (seconds): {interval}\n\ + \x20 Offset (seconds): {offset}\n\ + \x20 Keyset name: {name}\n\ + \x20 Generation: {generation}\n\ + \x20 Last rotation date: {date}", + response.unique_identifier + ); + + console::Stdout::new(&output).write()?; + + Ok(()) + } +} diff --git a/crate/clients/clap/src/actions/shared/mod.rs b/crate/clients/clap/src/actions/shared/mod.rs index b828f8ba3a..f1d3054c4f 100644 --- a/crate/clients/clap/src/actions/shared/mod.rs +++ b/crate/clients/clap/src/actions/shared/mod.rs @@ -1,9 +1,13 @@ mod activate; pub(crate) mod export_key; mod get_key_uid; +mod get_rotation_policy; pub(crate) mod import_key; mod locate; +mod rekey_keypair; pub(crate) mod resolve_key; +mod rotation_policy_args; +mod set_rotation_policy; pub(crate) mod sign; pub(crate) mod signature_verify; pub mod utils; @@ -15,8 +19,12 @@ mod unwrap_key; pub use activate::ActivateKeyAction; pub use export_key::ExportSecretDataOrKeyAction; pub(crate) use get_key_uid::get_key_uid; +pub use get_rotation_policy::GetRotationPolicyAction; pub use import_key::ImportSecretDataOrKeyAction; pub use locate::LocateObjectsAction; +pub use rekey_keypair::ReKeyKeyPairAction; +pub use rotation_policy_args::RotationPolicyArgs; +pub use set_rotation_policy::SetRotationPolicyAction; pub use unwrap_key::UnwrapSecretDataOrKeyAction; pub use wrap_key::WrapSecretDataOrKeyAction; diff --git a/crate/clients/clap/src/actions/shared/rekey_keypair.rs b/crate/clients/clap/src/actions/shared/rekey_keypair.rs new file mode 100644 index 0000000000..c4546b7d77 --- /dev/null +++ b/crate/clients/clap/src/actions/shared/rekey_keypair.rs @@ -0,0 +1,38 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{kmip_operations::ReKeyKeyPair, kmip_types::UniqueIdentifier}, +}; + +use crate::{ + actions::{console, labels::KEY_ID}, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; + +/// Rotate an existing asymmetric key pair, generating a new private/public key pair +#[derive(Parser)] +#[clap(verbatim_doc_comment)] +pub struct ReKeyKeyPairAction { + /// The unique identifier of the private key to re-key. + #[clap(long = KEY_ID, short = 'k')] + pub(crate) key_id: String, +} + +impl ReKeyKeyPairAction { + pub(crate) async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult { + let rekey_keypair_request = ReKeyKeyPair { + private_key_unique_identifier: Some(UniqueIdentifier::TextString(self.key_id.clone())), + ..ReKeyKeyPair::default() + }; + let response = kms_rest_client + .rekey_keypair(rekey_keypair_request) + .await + .with_context(|| "failed rekeying the key pair")?; + + let mut stdout = console::Stdout::new("The key pair was successfully rotated."); + stdout.set_unique_identifier(&response.private_key_unique_identifier); + stdout.write()?; + + Ok(response.private_key_unique_identifier) + } +} diff --git a/crate/clients/clap/src/actions/shared/rotation_policy_args.rs b/crate/clients/clap/src/actions/shared/rotation_policy_args.rs new file mode 100644 index 0000000000..9bf234cd36 --- /dev/null +++ b/crate/clients/clap/src/actions/shared/rotation_policy_args.rs @@ -0,0 +1,79 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{ + kmip_attributes::Attribute, kmip_operations::SetAttribute, kmip_types::UniqueIdentifier, + }, +}; + +use crate::error::result::{KmsCliResult, KmsCliResultHelper}; + +/// Optional rotation policy arguments that can be added to key creation commands. +/// +/// When provided, these are applied as `SetAttribute` calls immediately after the key is created. +#[derive(Parser, Default, Debug, Clone)] +pub struct RotationPolicyArgs { + /// Assign a keyset name for addressing key generations via `name@latest`, `name@first`, `name@N` syntax. + /// Must not contain the `@` character. + #[clap( + long = "rotation-name", + short = 'n', + required = false, + verbatim_doc_comment + )] + pub rotate_name: Option, + + /// Rotation interval in seconds. The key will be automatically re-keyed at this interval. + /// Set to 0 to disable automatic rotation while preserving other policy fields. + #[clap(long = "rotation-interval", required = false)] + pub rotate_interval: Option, + + /// Offset in seconds from the initial date before the first rotation occurs. + #[clap(long = "rotation-offset", required = false)] + pub rotate_offset: Option, +} + +impl RotationPolicyArgs { + /// Returns `true` if at least one rotation policy field is set. + #[must_use] + pub const fn is_set(&self) -> bool { + self.rotate_name.is_some() || self.rotate_interval.is_some() || self.rotate_offset.is_some() + } + + /// Apply rotation policy attributes via `SetAttribute` calls on the given key ID. + pub async fn apply(&self, kms_rest_client: &KmsClient, key_id: &str) -> KmsCliResult<()> { + let uid = UniqueIdentifier::TextString(key_id.to_owned()); + + if let Some(interval) = self.rotate_interval { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateInterval(interval), + }) + .await + .with_context(|| "failed setting RotateInterval attribute")?; + } + + if let Some(offset) = self.rotate_offset { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateOffset(offset), + }) + .await + .with_context(|| "failed setting RotateOffset attribute")?; + } + + if let Some(ref name) = self.rotate_name { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateName(name.clone()), + }) + .await + .with_context(|| "failed setting RotateName attribute")?; + } + + Ok(()) + } +} diff --git a/crate/clients/clap/src/actions/shared/set_rotation_policy.rs b/crate/clients/clap/src/actions/shared/set_rotation_policy.rs new file mode 100644 index 0000000000..2a329935de --- /dev/null +++ b/crate/clients/clap/src/actions/shared/set_rotation_policy.rs @@ -0,0 +1,87 @@ +use clap::Parser; +use cosmian_kms_client::{ + KmsClient, + kmip_2_1::{ + kmip_attributes::Attribute, kmip_operations::SetAttribute, kmip_types::UniqueIdentifier, + }, +}; + +use crate::{ + actions::console, + error::result::{KmsCliResult, KmsCliResultHelper}, +}; + +/// Set the automatic rotation policy on a key or key pair. +/// +/// This configures: +/// - The rotation interval (how often the key is automatically re-keyed) +/// - An optional offset (delay before first rotation) +/// - An optional keyset name (for addressing key generations via name@version syntax) +/// +/// At least one of --interval or --rotation-name must be provided. +#[derive(Parser, Debug)] +#[clap(verbatim_doc_comment)] +pub struct SetRotationPolicyAction { + /// The unique identifier of the key to set the rotation policy on. + #[clap(long = "key-id", short = 'k')] + key_id: String, + + /// Rotation interval in seconds. The key will be automatically re-keyed at this interval. + /// Set to 0 to disable automatic rotation while preserving other policy fields. + #[clap(long = "interval", short = 'i')] + interval_secs: Option, + + /// Offset in seconds from the initial date before the first rotation occurs. + #[clap(long = "offset", short = 'o')] + offset_secs: Option, + + /// A keyset name for addressing key generations via name@latest, name@first, name@N syntax. + /// Must not contain the '@' character. + #[clap(long = "rotation-name", short = 'n')] + rotate_name: Option, +} + +impl SetRotationPolicyAction { + pub async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult<()> { + let uid = UniqueIdentifier::TextString(self.key_id.clone()); + + // Set the rotation interval if provided + if let Some(interval) = self.interval_secs { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateInterval(interval), + }) + .await + .with_context(|| "failed setting RotateInterval attribute")?; + } + + // Set the rotation offset if provided + if let Some(offset) = self.offset_secs { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateOffset(offset), + }) + .await + .with_context(|| "failed setting RotateOffset attribute")?; + } + + // Set the rotation name if provided + if let Some(ref name) = self.rotate_name { + kms_rest_client + .set_attribute(SetAttribute { + unique_identifier: Some(uid.clone()), + new_attribute: Attribute::RotateName(name.clone()), + }) + .await + .with_context(|| "failed setting RotateName attribute")?; + } + + let mut stdout = console::Stdout::new("Rotation policy set successfully."); + stdout.set_unique_identifier(&uid); + stdout.write()?; + + Ok(()) + } +} diff --git a/crate/clients/clap/src/actions/symmetric/keys/create_key.rs b/crate/clients/clap/src/actions/symmetric/keys/create_key.rs index 4681156632..330c11baa1 100644 --- a/crate/clients/clap/src/actions/symmetric/keys/create_key.rs +++ b/crate/clients/clap/src/actions/symmetric/keys/create_key.rs @@ -14,7 +14,7 @@ use cosmian_kms_client::{ }; use crate::{ - actions::console, + actions::{console, shared::RotationPolicyArgs}, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -77,6 +77,10 @@ pub struct CreateKeyAction { verbatim_doc_comment )] pub wrapping_key_id: Option, + + /// Optional rotation policy to apply immediately after key creation. + #[clap(flatten)] + pub rotation_policy: RotationPolicyArgs, } impl CreateKeyAction { @@ -138,6 +142,14 @@ impl CreateKeyAction { .unique_identifier }; + // Apply rotation policy if any fields were provided + if self.rotation_policy.is_set() { + let key_id = unique_identifier + .as_str() + .with_context(|| "the server did not return a key id as a string")?; + self.rotation_policy.apply(&kms_rest_client, key_id).await?; + } + let mut stdout = console::Stdout::new("The symmetric key was successfully generated."); stdout.set_tags(Some(&self.tags)); stdout.set_unique_identifier(&unique_identifier); diff --git a/crate/clients/clap/src/actions/symmetric/keys/mod.rs b/crate/clients/clap/src/actions/symmetric/keys/mod.rs index 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/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..a6660d3401 100644 --- a/crate/hsm/base_hsm/src/session/session_impl.rs +++ b/crate/hsm/base_hsm/src/session/session_impl.rs @@ -50,16 +50,16 @@ use cosmian_kms_interfaces::{ }; use cosmian_logger::{debug, trace}; use pkcs11_sys::{ - CK_AES_GCM_PARAMS, CK_ATTRIBUTE, CK_BBOOL, CK_FALSE, CK_KEY_TYPE, CK_MECHANISM, + CK_AES_GCM_PARAMS, CK_ATTRIBUTE, CK_BBOOL, CK_DATE, CK_FALSE, CK_KEY_TYPE, CK_MECHANISM, CK_MECHANISM_TYPE, CK_OBJECT_CLASS, CK_OBJECT_HANDLE, CK_RSA_PKCS_MGF_TYPE, CK_RSA_PKCS_OAEP_PARAMS, CK_SESSION_HANDLE, CK_TRUE, CK_ULONG, CKA_CLASS, CKA_COEFFICIENT, - CKA_EXPONENT_1, CKA_EXPONENT_2, CKA_ID, CKA_KEY_TYPE, CKA_LABEL, CKA_MODULUS, CKA_PRIME_1, - CKA_PRIME_2, CKA_PRIVATE_EXPONENT, CKA_PUBLIC_EXPONENT, CKA_SENSITIVE, CKA_VALUE, - CKA_VALUE_LEN, CKG_MGF1_SHA1, CKG_MGF1_SHA256, CKG_MGF1_SHA384, CKG_MGF1_SHA512, CKK_AES, - CKK_RSA, CKK_VENDOR_DEFINED, CKM_AES_CBC, CKM_AES_GCM, CKM_RSA_PKCS, CKM_RSA_PKCS_OAEP, - CKM_SHA_1, CKM_SHA1_RSA_PKCS, CKM_SHA256, CKM_SHA256_RSA_PKCS, CKM_SHA384, CKM_SHA384_RSA_PKCS, - CKM_SHA512, CKM_SHA512_RSA_PKCS, CKO_PRIVATE_KEY, CKO_PUBLIC_KEY, CKO_SECRET_KEY, - CKO_VENDOR_DEFINED, CKR_ATTRIBUTE_SENSITIVE, CKR_OBJECT_HANDLE_INVALID, CKR_OK, + CKA_END_DATE, CKA_EXPONENT_1, CKA_EXPONENT_2, CKA_ID, CKA_KEY_TYPE, CKA_LABEL, CKA_MODULUS, + CKA_PRIME_1, CKA_PRIME_2, CKA_PRIVATE_EXPONENT, CKA_PUBLIC_EXPONENT, CKA_SENSITIVE, + CKA_START_DATE, CKA_VALUE, CKA_VALUE_LEN, CKG_MGF1_SHA1, CKG_MGF1_SHA256, CKG_MGF1_SHA384, + CKG_MGF1_SHA512, CKK_AES, CKK_RSA, CKK_VENDOR_DEFINED, CKM_AES_CBC, CKM_AES_GCM, CKM_RSA_PKCS, + CKM_RSA_PKCS_OAEP, CKM_SHA_1, CKM_SHA1_RSA_PKCS, CKM_SHA256, CKM_SHA256_RSA_PKCS, CKM_SHA384, + CKM_SHA384_RSA_PKCS, CKM_SHA512, CKM_SHA512_RSA_PKCS, CKO_PRIVATE_KEY, CKO_PUBLIC_KEY, + CKO_SECRET_KEY, CKO_VENDOR_DEFINED, CKR_ATTRIBUTE_SENSITIVE, CKR_OBJECT_HANDLE_INVALID, CKR_OK, CKZ_DATA_SPECIFIED, }; use rand::{TryRng, rngs::SysRng}; @@ -1730,6 +1730,235 @@ 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 but is no longer used; callers determine the latest + /// generation by comparing `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) { + // Minimum viable format: "name::gen::keyid" (3 segments) + let parts: Vec<&str> = label.splitn(4, "::").collect(); + if parts.len() < 3 { + return (None, None); + } + let Some(rotate_name) = parts.first() else { + return (None, None); + }; + let Some(gen_str) = parts.get(1) 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 +2015,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 +2024,10 @@ impl Session { })? * 8, sensitive: sensitive == CK_TRUE, id: label, + start_date, + end_date, + rotate_name, + rotate_generation, })) } KeyType::RsaPrivateKey | KeyType::RsaPublicKey => { @@ -1856,11 +2091,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..08aeab3af5 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..658bbaf2e5 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,120 @@ impl ObjectsStore for HsmStore { Ok(uids) } + async fn find_due_for_rotation( + &self, + now: time::OffsetDateTime, + ) -> InterfaceResult> { + let today = now.date(); + let slot_ids = self.hsm.get_available_slot_list().await?; + let mut due_uids = Vec::new(); + + for slot_id in slot_ids { + let found = self + .hsm + .find(slot_id, HsmObjectFilter::Any) + .await + .unwrap_or_default(); + for object_id in found { + let Some(meta) = self + .hsm + .get_key_metadata(slot_id, &object_id) + .await + .unwrap_or_default() + else { + continue; + }; + // A key is due for rotation when end_date is set and today >= end_date + let Some(end_date) = meta.end_date else { + continue; + }; + if today >= end_date { + let Ok(object_string) = std::str::from_utf8(&object_id) else { + continue; + }; + let uid = format!("{}::{slot_id}::{object_string}", self.prefix); + due_uids.push(uid); + } + } + } + + Ok(due_uids) + } + + /// Find HSM keys by keyset name, with optional generation and latest filters. + /// + /// The keyset name is parsed from `CKA_LABEL` which carries the format + /// `rotate_name::generation::key_id[::latest]`. This allows keys to be + /// addressed by their logical name rather than their physical UID. + async fn find_by_rotate_name( + &self, + name: &str, + generation: Option, + _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 +777,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 +798,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 +874,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 +1394,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..509fe1ddfc 100644 --- a/crate/interfaces/src/hsm/interface.rs +++ b/crate/interfaces/src/hsm/interface.rs @@ -336,6 +336,43 @@ pub trait HSM: Send + Sync { /// can return an error; callers may choose to ignore such errors. async fn seed_random(&self, slot_id: usize, seed: &[u8]) -> InterfaceResult<()>; + /// Set `CKA_START_DATE` and `CKA_END_DATE` on a key object. + /// + /// These PKCS#11 attributes are used to track rotation scheduling: + /// - `start_date` — when the current rotation interval began. + /// - `end_date` — when the key is due for rotation. + /// + /// Passing `None` for either date clears that attribute (sets to empty `CK_DATE`). + /// + /// # Arguments + /// * `slot_id` - the slot ID of the HSM + /// * `key_id` - the ID of the key + /// * `start_date` - optional start date + /// * `end_date` - optional end date + async fn set_key_dates( + &self, + slot_id: usize, + key_id: &[u8], + start_date: Option, + end_date: Option, + ) -> InterfaceResult<()>; + + /// Set `CKA_LABEL` on a key object. + /// + /// The label encodes keyset metadata in the format + /// `rotate_name::generation::key_id[::latest]`. + /// + /// # Arguments + /// * `slot_id` - the slot ID of the HSM + /// * `key_id` - the `CKA_ID` bytes of the key to update + /// * `label` - the new label string to set + async fn set_key_label( + &self, + slot_id: usize, + key_id: &[u8], + label: &str, + ) -> InterfaceResult<()>; + /// Get a reference to the underlying PKCS#11 library for direct function calls. /// /// This method provides access to the raw PKCS#11 library (`HsmLib`) to enable diff --git a/crate/interfaces/src/lib.rs b/crate/interfaces/src/lib.rs index 5e75e0c5c7..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..6d30249c0d 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,143 @@ 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. + #[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(attrs) = self.object.attributes() else { + return true; + }; + let Ok(now) = time_normalize() else { + return true; + }; + 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); + !(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 { diff --git a/crate/interfaces/src/stores/objects_store.rs b/crate/interfaces/src/stores/objects_store.rs index f06cb0c69f..e73784f3ea 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,73 @@ 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. + 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..972b348c60 100644 --- a/crate/kmip/src/kmip_2_1/kmip_attributes.rs +++ b/crate/kmip/src/kmip_2_1/kmip_attributes.rs @@ -366,6 +366,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 +387,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 +404,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 +733,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 +952,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 +1247,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 +1263,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 +1277,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 +1483,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 +1619,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), 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..e0b6d49ffd 100644 --- a/crate/kmip/src/kmip_2_1/kmip_objects.rs +++ b/crate/kmip/src/kmip_2_1/kmip_objects.rs @@ -16,12 +16,13 @@ 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, 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 +422,78 @@ 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, + }) + } + + /// 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/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 4acc2f59a4..1e0535c22d 100644 --- a/crate/server/src/config/params/server_params.rs +++ b/crate/server/src/config/params/server_params.rs @@ -167,6 +167,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. @@ -432,6 +443,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:#?}"); @@ -656,6 +680,11 @@ impl fmt::Debug for ServerParams { debug_struct.field("rate_limit_per_second", &self.rate_limit_per_second); debug_struct.field("cors_allowed_origins", &self.cors_allowed_origins); debug_struct.field("max_locate_items", &self.max_locate_items); + debug_struct.field( + "auto_rotation_check_interval_secs", + &self.auto_rotation_check_interval_secs, + ); + debug_struct.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..5819b039d1 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 diff --git a/crate/server/src/core/kms/permissions.rs b/crate/server/src/core/kms/permissions.rs index e4cd79158d..2e7b6699e2 100644 --- a/crate/server/src/core/kms/permissions.rs +++ b/crate/server/src/core/kms/permissions.rs @@ -21,17 +21,12 @@ impl KMS { /// Grant access to a user (identified by `access.userid`) /// to an object (identified by `access.unique_identifier`) /// which is owned by `owner` (identified by `access.owner`) - pub(crate) async fn grant_access( - &self, - access: &Access, - owner: &str, - privileged_users: Option>, - ) -> KResult<()> { + pub(crate) async fn grant_access(&self, access: &Access, owner: &str) -> KResult<()> { // if create access right is set, grant access to Create for the * object let mut updated_operations_types = access.operation_types.clone(); if updated_operations_types.contains(&KmipOperation::Create) { updated_operations_types.retain(|op| op != &KmipOperation::Create); - if let Some(users) = privileged_users { + if let Some(ref users) = self.params.privileged_users { if !users.contains(&owner.to_owned()) { kms_bail!(KmsError::Unauthorized( "Only privileged users can grant/revoke create access right to a user." @@ -114,18 +109,12 @@ impl KMS { /// Remove an access authorization for a user (identified by `access.userid`) /// to an object (identified by `access.unique_identifier`) /// which is owned by `owner` (identified by `access.owner`) - pub(crate) async fn revoke_access( - &self, - access: &Access, - owner: &str, - - privileged_users: Option>, - ) -> KResult<()> { + pub(crate) async fn revoke_access(&self, access: &Access, owner: &str) -> KResult<()> { // if create access right is set, revoke access Create for * object let mut updated_operations_types = access.operation_types.clone(); if updated_operations_types.contains(&KmipOperation::Create) { updated_operations_types.retain(|op| op != &KmipOperation::Create); - if let Some(users) = privileged_users { + if let Some(ref users) = self.params.privileged_users { if !users.contains(&owner.to_owned()) { kms_bail!(KmsError::Unauthorized( "Only privileged users can grant/revoke create access right to a user." diff --git a/crate/server/src/core/operations/attributes/add.rs b/crate/server/src/core/operations/attributes/add.rs index 07ba9122ee..931438c6e2 100644 --- a/crate/server/src/core/operations/attributes/add.rs +++ b/crate/server/src/core/operations/attributes/add.rs @@ -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, @@ -98,6 +119,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, 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..fda8af9668 100644 --- a/crate/server/src/core/operations/attributes/get.rs +++ b/crate/server/src/core/operations/attributes/get.rs @@ -558,6 +558,16 @@ pub(crate) async fn get_attributes( owm.id(), res.get_tags(kms.vendor_id()) ); + + // Rotation attributes are not represented by Tag enum variants, so they are + // not included by the tag-based filtering loop above. Always propagate them + // from the source attributes when present. + res.rotate_date = attributes.rotate_date; + res.rotate_generation = attributes.rotate_generation; + res.rotate_interval = attributes.rotate_interval; + res.rotate_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..8aabed4800 100644 --- a/crate/server/src/core/operations/attributes/set.rs +++ b/crate/server/src/core/operations/attributes/set.rs @@ -1,21 +1,40 @@ 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) +} + pub(crate) async fn set_attribute( kms: &KMS, request: SetAttribute, @@ -31,6 +50,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,6 +79,24 @@ pub(crate) async fn set_attribute( .await?; trace!("Set Attribute: Retrieved target object"); + // Capture HSM-rotation values before the `match_set_attribute!` macro may + // partially move `request.new_attribute`. We do this here — after object + // retrieval — so we can inspect `owm.id()` to confirm it is an HSM key. + let (hsm_rotate_name, hsm_rotate_interval_secs) = if has_prefix(owm.id()).is_some() { + match &request.new_attribute { + Attribute::RotateOffset(_) => { + return Err(KmsError::NotSupported( + "SetAttribute: rotate_offset is not supported for HSM keys".to_owned(), + )); + } + Attribute::RotateName(n) => (Some(n.clone()), None::), + Attribute::RotateInterval(n) => (None::, Some(*n)), + _ => (None, None), + } + } else { + (None, None) + }; + let mut attributes = owm.attributes_mut().clone(); // Check if the attribute is allowed to be set @@ -97,6 +154,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 +198,70 @@ pub(crate) async fn set_attribute( let tags = kms.database.retrieve_tags(owm.id()).await?; + // HSM-specific: propagate rotation attributes directly to PKCS#11 storage. + // `HsmStore::update_object` is a no-op for attributes (the HSM has no + // generic KV attribute storage), so we must explicitly write CKA_LABEL and + // CKA_START_DATE / CKA_END_DATE when the caller sets rotation metadata. + if let Some(rotate_name) = hsm_rotate_name { + // Register key in a keyset by writing CKA_LABEL at generation 0. + let key_id = extract_hsm_key_id(owm.id()).ok_or_else(|| { + KmsError::InvalidRequest(format!( + "SetAttribute: cannot parse key_id from HSM UID '{}'", + owm.id() + )) + })?; + let label = format!("{rotate_name}::0::{key_id}::latest"); + trace!( + "SetAttribute: writing CKA_LABEL '{}' on HSM key '{}'", + label, + owm.id() + ); + kms.database.set_key_label(owm.id(), &label).await?; + } else if let Some(interval_secs) = hsm_rotate_interval_secs { + // 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..60da235cb0 --- /dev/null +++ b/crate/server/src/core/operations/auto_rotate.rs @@ -0,0 +1,44 @@ +//! Auto-rotation scheduler. +//! +//! This module provides: +//! - [`run_auto_rotation`] — iterates keys due for rotation and triggers re-key operations. +//! - [`dispatch_renewal_warnings`] — sends notifications when keys approach their rotation date. + +use cosmian_logger::debug; + +use crate::core::KMS; + +/// Rotate all keys that are past their scheduled rotation time. +/// +/// The function queries the database for active keys whose `rotate_interval` +/// has elapsed since their last rotation (or initial date + offset for first rotation), +/// then issues a Re-Key or Re-Key Key Pair operation for each. +pub(crate) async fn run_auto_rotation(kms: &KMS) { + let now = time::OffsetDateTime::now_utc(); + let due_keys = match kms.database.find_due_for_rotation(now).await { + Ok(keys) => keys, + Err(e) => { + debug!("[auto-rotate] Failed to query keys due for rotation: {e}"); + return; + } + }; + + if due_keys.is_empty() { + return; + } + debug!( + "[auto-rotate] Found {} key(s) due for rotation", + due_keys.len() + ); + + for uid in &due_keys { + debug!("[auto-rotate] Rotating key {uid}"); + // TODO: issue Re-Key / Re-Key Key Pair operation for the key + } +} + +/// Check keys approaching rotation and emit renewal-warning notifications. +pub(crate) async fn dispatch_renewal_warnings(_kms: &KMS) { + // TODO: implement renewal-warning notification dispatch + debug!("[auto-rotate] Renewal-warning dispatch complete (no-op stub)"); +} diff --git a/crate/server/src/core/operations/certify/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..e85c881a54 100644 --- a/crate/server/src/core/operations/certify/resolve_subject.rs +++ b/crate/server/src/core/operations/certify/resolve_subject.rs @@ -15,11 +15,10 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ }, }; use cosmian_kms_server_database::reexport::{ - cosmian_kmip, cosmian_kmip::kmip_2_1::{ KmipOperation, kmip_objects::ObjectType, - kmip_operations::CreateKeyPair, + kmip_operations::{Certify, CreateKeyPair}, kmip_types::{CertificateRequestType, UniqueIdentifier}, }, cosmian_kms_crypto::openssl::{ @@ -33,8 +32,8 @@ use super::subject::{KeyPairData, Subject}; use crate::{ core::{ KMS, - operations::create_key_pair::generate_key_pair, - retrieve_object_utils::{retrieve_object_for_operation, user_has_permission}, + operations::{create_key_pair::generate_key_pair, key_ops::enforce_create_permission}, + retrieve_object_utils::retrieve_object_for_operation, }, error::KmsError, kms_bail, @@ -81,12 +80,7 @@ fn cryptographic_usage_mask_public_key( /// - a certificate /// - a key pair and a subject name /// - a CSR -pub(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 +174,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() - )) - } - } + enforce_create_permission(kms, 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..e27a6584b6 100644 --- a/crate/server/src/core/operations/create.rs +++ b/crate/server/src/core/operations/create.rs @@ -1,6 +1,6 @@ -use cosmian_kms_server_database::reexport::{ - cosmian_kmip, - cosmian_kmip::kmip_2_1::{ +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::ErrorReason, + kmip_2_1::{ kmip_objects::ObjectType, kmip_operations::{Create, CreateResponse}, kmip_types::UniqueIdentifier, @@ -9,48 +9,22 @@ use cosmian_kms_server_database::reexport::{ use cosmian_logger::{info, trace}; use uuid::Uuid; +use super::key_ops::{enforce_create_permission, reject_protection_storage_masks}; use crate::{ - core::{KMS, retrieve_object_utils::user_has_permission, wrapping::wrap_and_cache}, + core::{KMS, wrapping::wrap_and_cache}, error::KmsError, kms_bail, result::KResult, }; -pub(crate) async fn create( - kms: &KMS, - request: Create, - owner: &str, - privileged_users: Option>, -) -> KResult { +pub(crate) async fn create(kms: &KMS, request: Create, owner: &str) -> KResult { trace!("{request}"); - if request.protection_storage_masks.is_some() { - kms_bail!(KmsError::UnsupportedPlaceholder) - } - - // To create an object, check that the user has `Create` access right - // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users.clone() { - let has_permission = user_has_permission( - owner, - None, - &cosmian_kmip::kmip_2_1::KmipOperation::Create, - kms, - ) - .await?; - - if !has_permission && !users.iter().any(|u| u == owner) { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right.".to_owned() - )) - } - } + reject_protection_storage_masks(request.protection_storage_masks.is_some())?; + enforce_create_permission(kms, owner).await?; let (unique_identifier, mut object, tags) = match &request.object_type { ObjectType::SymmetricKey => KMS::create_symmetric_key_and_tags(kms.vendor_id(), &request)?, - ObjectType::PrivateKey => { - kms.create_private_key_and_tags(&request, owner, privileged_users) - .await? - } + ObjectType::PrivateKey => kms.create_private_key_and_tags(&request, owner).await?, ObjectType::SecretData => KMS::create_secret_data_and_tags(kms.vendor_id(), &request)?, _ => { kms_bail!(KmsError::NotSupported(format!( @@ -74,7 +48,7 @@ pub(crate) async fn create( let protection_period_present = attrs.protection_period.is_some(); if qs && (protection_level_present || protection_period_present) { kms_bail!(KmsError::Kmip21Error( - cosmian_kmip::kmip_0::kmip_types::ErrorReason::General_Failure, + ErrorReason::General_Failure, "NOT_SAFE".to_owned(), )); } diff --git a/crate/server/src/core/operations/create_key_pair.rs b/crate/server/src/core/operations/create_key_pair.rs index 3d84a2a66f..980cbb2087 100644 --- a/crate/server/src/core/operations/create_key_pair.rs +++ b/crate/server/src/core/operations/create_key_pair.rs @@ -1,4 +1,3 @@ -use cosmian_kms_server_database::reexport::cosmian_kmip; #[cfg(feature = "non-fips")] use cosmian_kms_server_database::reexport::cosmian_kms_crypto::crypto::kem::kem_keygen; #[cfg(feature = "non-fips")] @@ -33,45 +32,27 @@ use cosmian_logger::warn; use cosmian_logger::{debug, info, trace}; use uuid::Uuid; use crate::{ - core::{KMS, retrieve_object_utils::user_has_permission, wrapping::wrap_and_cache}, + core::{KMS, wrapping::wrap_and_cache}, error::KmsError, kms_bail, result::KResult, }; +use super::key_ops::{enforce_create_permission, reject_protection_storage_masks}; + pub(crate) async fn create_key_pair( kms: &KMS, request: CreateKeyPair, owner: &str, - - privileged_users: Option>, ) -> KResult { debug!("Create key pair: {request}"); - // To create a key pair, check that the user has `Create` access right - // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users { - let has_permission = user_has_permission( - owner, - None, - &cosmian_kmip::kmip_2_1::KmipOperation::Create, - kms, - ) - .await?; - - if !has_permission && !users.iter().any(|u| u == owner) { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right.".to_owned() - )) - } - } - - if request.common_protection_storage_masks.is_some() - || request.private_protection_storage_masks.is_some() - || request.public_protection_storage_masks.is_some() - { - kms_bail!(KmsError::UnsupportedPlaceholder) - } + reject_protection_storage_masks( + request.common_protection_storage_masks.is_some() + || request.private_protection_storage_masks.is_some() + || request.public_protection_storage_masks.is_some(), + )?; + enforce_create_permission(kms, owner).await?; // generate uids and create the key pair and tags let sk_uid = request diff --git a/crate/server/src/core/operations/decrypt.rs b/crate/server/src/core/operations/decrypt.rs index d9f8a20d73..bb32b29f9f 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, perform_crypto_operation}, }, 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")] diff --git a/crate/server/src/core/operations/destroy.rs b/crate/server/src/core/operations/destroy.rs index 9d8985a0f6..276649aee2 100644 --- a/crate/server/src/core/operations/destroy.rs +++ b/crate/server/src/core/operations/destroy.rs @@ -23,7 +23,7 @@ use crate::core::cover_crypt::destroy_user_decryption_keys; use crate::{ core::{ KMS, - operations::key_ops::{ObjectWithMetadataOps, record_cascading_metrics}, + operations::key_ops::{lifecycle::user_can_perform_operation, record_cascading_metrics}, uid_utils::{has_prefix, uids_from_unique_identifier}, }, error::KmsError, @@ -119,10 +119,7 @@ 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) - .await? - { + if !user_can_perform_operation(&owm, user, &KmipOperation::Destroy, kms).await? { continue; } 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..736f13e3d5 100644 --- a/crate/server/src/core/operations/encrypt.rs +++ b/crate/server/src/core/operations/encrypt.rs @@ -53,7 +53,7 @@ use crate::{ config::ServerParams, core::{ KMS, - operations::{CryptoOpSpec, has_usage_mask, perform_crypto_operation}, + operations::{CryptoOpSpec, perform_crypto_operation}, }, error::KmsError, kms_bail, @@ -82,10 +82,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 } diff --git a/crate/server/src/core/operations/import.rs b/crate/server/src/core/operations/import.rs index fe8acf72cd..26159afb35 100644 --- a/crate/server/src/core/operations/import.rs +++ b/crate/server/src/core/operations/import.rs @@ -33,11 +33,11 @@ use cosmian_logger::{debug, trace, warn}; use openssl::x509::X509; use uuid::Uuid; +use super::key_ops::enforce_create_permission; use crate::{ core::{ KMS, operations::validate::verify_crls, - retrieve_object_utils::user_has_permission, wrapping::{unwrap_object, wrap_and_cache}, }, error::KmsError, @@ -46,12 +46,7 @@ use crate::{ }; /// Import a new object -pub(crate) async fn import( - kms: &KMS, - request: Import, - user: &str, - privileged_users: Option>, -) -> KResult { +pub(crate) async fn import(kms: &KMS, request: Import, user: &str) -> KResult { trace!( "Entering import KMIP operation: uid={}, object_type={}", request.unique_identifier, request.object_type @@ -71,21 +66,7 @@ pub(crate) async fn import( // To import an object, ensure the user has the `Create` access right. // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users { - let has_permission = user_has_permission( - user, - None, - &cosmian_kmip::kmip_2_1::KmipOperation::Create, - kms, - ) - .await?; - - if !has_permission && !users.iter().any(|u| u == user) { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right.".to_owned() - )) - } - } + enforce_create_permission(kms, user).await?; // When replace_existing is requested with an explicit UID, verify the caller owns the // target object. Without this check, any user with Create rights could overwrite another diff --git a/crate/server/src/core/operations/key_ops/authorization.rs b/crate/server/src/core/operations/key_ops/authorization.rs new file mode 100644 index 0000000000..1e826d403d --- /dev/null +++ b/crate/server/src/core/operations/key_ops/authorization.rs @@ -0,0 +1,78 @@ +//! Authorization and permission checks for KMIP operations. +//! +//! Contains standalone guards for operation-level authorization, +//! `Create` permission, and `ProtectionStorageMasks` rejection. + +use cosmian_kms_server_database::{Database, reexport::cosmian_kmip::kmip_2_1::KmipOperation}; + +use crate::{ + core::{KMS, retrieve_object_utils::user_has_permission, uid_utils::has_prefix}, + error::KmsError, + kms_bail, + result::KResult, +}; + +/// Enforce that the caller has `Create` access-right. +/// +/// When `privileged_users` is configured, the user must either: +/// - have been explicitly granted the `Create` operation on any object, +/// - be listed in `privileged_users`, or +/// - be the `default_username` (unauthenticated / local access). +/// +/// This check applies uniformly to `Create`, `CreateKeyPair`, `Import`, and `Register`. +pub(crate) async fn enforce_create_permission(kms: &KMS, user: &str) -> KResult<()> { + if let Some(ref users) = kms.params.privileged_users { + if user == kms.params.default_username + || users.iter().any(|u| u == user) + || user_has_permission(user, None, &KmipOperation::Create, kms).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(()) +} + +// ─── Operation-level authorization ─────────────────────────────────────────── + +/// 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. +pub(super) async fn is_user_authorized( + db: &Database, + uid: &str, + user: &str, + operation: KmipOperation, +) -> KResult { + if db.is_object_owned_by(uid, user).await? { + return Ok(true); + } + let ops = db.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)) +} 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..41682eb180 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,23 @@ -use std::collections::HashSet; - use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ - kmip_0::kmip_types::{CryptographicUsageMask, ErrorReason, State}, - kmip_2_1::{ - KmipOperation, - kmip_types::{UniqueIdentifier, UsageLimitsUnit}, - }, + kmip_0::kmip_types::{ErrorReason, State}, + kmip_2_1::{KmipOperation, kmip_types::UniqueIdentifier}, }, cosmian_kms_interfaces::ObjectWithMetadata, }; +use cosmian_logger::{trace, warn}; -use super::{DatabaseOps, ObjectWithMetadataOps}; +use super::{ + authorization::is_user_authorized, + key_resolution::{KeysetMode, ResolvedKey, resolve_key_for_operation}, + usage_limits::decrement_usage_limits, +}; use crate::{ - core::{ - KMS, - uid_utils::{has_prefix, uids_from_unique_identifier}, - }, + core::KMS, error::KmsError, result::{KResult, KResultHelper}, }; -// ─── Generic crypto operation key resolution ───────────────────────────────── - -/// 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), -} - -/// 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. -/// -/// 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; - } - attributes - .is_usage_authorized_for(required) - .unwrap_or(false) -} - /// Declarative specification for KMIP cryptographic operations. /// /// Implemented by zero-sized marker types (one per operation). A generic @@ -91,6 +46,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 +63,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. @@ -163,7 +139,7 @@ pub(crate) async fn perform_crypto_operation( match resolve_key_for_operation::(unique_identifier, kms, user).await? { ResolvedKey::Oracle { uid, prefix } => { - let result = Op::execute_oracle(kms, &request, &uid, &prefix).await; + 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, @@ -171,218 +147,121 @@ pub(crate) async fn perform_crypto_operation( ); metrics.record_hsm_operation(Op::OP_NAME, model); } - result + Ok(result) } - ResolvedKey::Local(owm) => { - let mut owm = *owm; - - // Clone before unwrap: preserve the wrapped key for DB persistence. - let mut unwrapped_owm = owm.clone(); - unwrap_and_enforce_policy(kms, &mut unwrapped_owm, Op::OP_NAME, user) - .await - .with_context(|| { - format!( - "{}: the key: {}, cannot be unwrapped.", - Op::OP_NAME, - owm.id() - ) - })?; - - let data_len = Op::usage_data_len(&request); - enforce_usage_limits(&owm, data_len)?; - - let res = Op::execute_local(kms, &unwrapped_owm, &request, user).await?; - - decrement_usage_limits(kms, &mut owm, Op::OP_NAME, data_len).await?; - Ok(res) + ResolvedKey::Local(owm) => execute_local_with_limits::(kms, *owm, &request, user).await, + ResolvedKey::Keyset(chain) => { + execute_keyset_try_each::(kms, &chain, &request, user).await } } } -/// 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, +/// Execute a local operation with unwrapping and usage-limit accounting. +async fn execute_local_with_limits( kms: &KMS, + owm: ObjectWithMetadata, + request: &Op::Request, user: &str, -) -> KResult> { - let mut 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) - .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", - ))) - } - } +) -> KResult { + let mut owm = owm; + + // Clone before unwrap: preserve the wrapped key for DB persistence. + let mut unwrapped_owm = owm.clone(); + unwrap_and_enforce_policy(kms, &mut unwrapped_owm, Op::OP_NAME, user) + .await + .with_context(|| { + format!( + "{}: the key: {}, cannot be unwrapped.", + Op::OP_NAME, + owm.id() + ) + })?; + + let data_len = Op::usage_data_len(request); + owm.enforce_usage_limits(data_len)?; + + let res = Op::execute_local(kms, &unwrapped_owm, request, user).await?; + + decrement_usage_limits(kms, &mut owm, Op::OP_NAME, data_len).await?; + Ok(res) } -/// 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: +/// Try each key in a keyset chain (newest→oldest) until one succeeds. /// -/// 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, +/// 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( kms: &KMS, + chain: &[String], + request: &Op::Request, 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. - if has_prefix(uid).is_some() { - continue; - } +) -> KResult { + let mut last_err: Option = None; + for (depth, uid) in chain.iter().enumerate() { let Some(owm) = kms.database.retrieve_object(uid).await? else { continue; }; - // Must be Active (respects auto-activation via activation_date). - if owm.get_effective_state()? != State::Active { + // 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 via the shared authorization function. - if !kms - .database - .is_user_authorized_for_operation(uid, user, operation) - .await? - { - found_but_no_permission = true; + // Permission check + if !is_user_authorized(&kms.database, uid, user, Op::KMIP_OP).await? { continue; } - // Object-type and usage-mask check supplied by the caller. - if !is_eligible(&owm)? { + // Eligibility check + if !Op::is_key_eligible(&owm, kms.vendor_id()) { continue; } - eligible.push(owm); - } - - match eligible.len() { - 1 => eligible - .into_iter() - .next() - .ok_or_else(|| KmsError::ItemNotFound("unreachable: len == 1".to_owned())), - 0 => Err(if found_but_no_permission { - KmsError::Unauthorized(format!( - "{op_name}: user {user} does not have permission to use key: {uid_display}" - )) - } else { - KmsError::ItemNotFound(format!( - "{op_name}: no valid key found for identifier: {uid_display}" - )) - }), - n => { - let ids: Vec<&str> = eligible.iter().map(ObjectWithMetadata::id).collect(); - Err(KmsError::InvalidRequest(format!( - "{op_name}: identifier {uid_display} resolves to {n} valid keys {ids:?}; \ - use a unique identifier" - ))) + // Lifecycle check + if owm.check_process_window().is_err() { + continue; } - } -} - -/// 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 }); + match execute_local_with_limits::(kms, owm, request, user).await { + Ok(response) => { + let depth_u32 = u32::try_from(depth).unwrap_or(u32::MAX); + let warn_threshold = kms.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); + } + } } - // 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))) + 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 + ), + ) + })) } /// Unwrap a key (if wrapped) and enforce the KMIP algorithm policy. @@ -399,7 +278,7 @@ pub(crate) async fn resolve_key_for_operation( /// **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( +async fn unwrap_and_enforce_policy( kms: &KMS, owm: &mut ObjectWithMetadata, op_name: &str, @@ -416,79 +295,3 @@ pub(crate) async fn unwrap_and_enforce_policy( owm, ) } - -// ─── UsageLimits helpers ───────────────────────────────────────────────────── - -/// 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(), - )); - } - } - } - 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; - } - } - } - 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}" - )) - })?; - } - Ok(()) -} diff --git a/crate/server/src/core/operations/key_ops/key_resolution.rs b/crate/server/src/core/operations/key_ops/key_resolution.rs new file mode 100644 index 0000000000..626cd71314 --- /dev/null +++ b/crate/server/src/core/operations/key_ops/key_resolution.rs @@ -0,0 +1,373 @@ +//! Centralized key resolution pipeline for KMIP operations. +//! +//! This module owns the logic for resolving a `UniqueIdentifier` into a usable key: +//! keyset parsing, oracle (HSM) routing, UID/tag lookup, uniqueness enforcement, +//! and lifecycle validation. +//! +//! The [`resolve_key_for_operation`] function is the main entry point. It is generic +//! over [`CryptoOpSpec`] so that each operation declares its own keyset-mode, +//! state-acceptance, and error-mapping behaviour. +//! +//! ## Architecture +//! +//! ```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::State, + kmip_2_1::{KmipOperation, kmip_types::UniqueIdentifier}, + }, + cosmian_kms_interfaces::ObjectWithMetadata, +}; + +use super::{ + authorization::is_user_authorized, crypto_op::CryptoOpSpec, + lifecycle::user_can_perform_operation, +}; +use crate::{ + core::{ + KMS, + 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}, +}; + +// ─── Key selection trait ───────────────────────────────────────────────────── + +/// Declarative specification for key selection shared across all KMIP operations. +/// +/// Implemented by: +/// - Crypto operation marker structs (via the `CryptoOpSpec` supertrait relationship) +/// - Rekey operation structs (`SymmetricRekey`, `KeypairRekey`) +pub(crate) trait KeySelectionSpec { + /// Human-readable operation name for error messages (e.g. `"Encrypt"`, `"ReKey"`). + const OP_NAME: &'static str; + + /// The KMIP operation used for permission checks. + const KMIP_OP: KmipOperation; + + /// Key states accepted by this operation. + fn accepted_states() -> &'static [State]; + + /// Whether permission checks require an exact operation grant. + /// + /// - `false` (default): a `Get` grant also authorizes the operation (crypto ops). + /// - `true`: only an explicit grant of [`Self::KMIP_OP`] authorizes (rekey, destructive ops). + fn strict_permission_check() -> bool { + false + } + + /// Determine if the managed object is eligible (object type + usage mask). + fn is_key_eligible(owm: &ObjectWithMetadata, vendor_id: &str) -> bool; +} + +/// Every `CryptoOpSpec` implementor automatically satisfies `KeySelectionSpec`. +/// +/// This avoids duplicate trait implementations for `EncryptOp`, `DecryptOp`, etc. +impl KeySelectionSpec for T { + const KMIP_OP: KmipOperation = T::KMIP_OP; + const OP_NAME: &'static str = T::OP_NAME; + + fn accepted_states() -> &'static [State] { + T::accepted_states() + } + + fn is_key_eligible(owm: &ObjectWithMetadata, vendor_id: &str) -> bool { + ::is_key_eligible(owm, vendor_id) + } +} + +// ─── Generic selection function ────────────────────────────────────────────── + +/// Select exactly one key from pre-fetched candidates using the [`KeySelectionSpec`] pipeline. +/// +/// Applies the following filters in order: +/// 1. **State** — `Spec::accepted_states()` +/// 2. **Permission** — `is_user_authorized_for_operation` with `Spec::KMIP_OP` +/// 3. **Eligibility** — `Spec::is_eligible()` +/// 4. **Extra validation** — caller-supplied closure for operation-specific checks +/// (e.g. keyset-latest guard, crypto-param change rejection) +/// +/// Enforces uniqueness: +/// - 0 eligible → `KmsError::ItemNotFound` or `KmsError::Unauthorized` +/// - 1 eligible → `Ok(ObjectWithMetadata)` +/// - \>1 eligible → `KmsError::InvalidRequest` (ambiguous) +/// +/// # Parameters +/// +/// - `candidates`: Pre-fetched objects (from `retrieve_eligible_keys` or per-UID fetch). +/// - `uid_display`: Display string for the identifier (used in error messages). +/// - `kms`: Server state. +/// - `user`: Requesting user. +/// - `extra_validation`: Sync closure applied after eligibility; return `Ok(())` to accept, +/// `Err(...)` to reject with a hard error (propagated immediately, not silently skipped). +pub(crate) async fn select_unique_key( + candidates: Vec, + uid_display: &str, + kms: &KMS, + user: &str, + extra_validation: F, +) -> KResult +where + Spec: KeySelectionSpec, + F: Fn(&ObjectWithMetadata) -> KResult<()>, +{ + let mut eligible: Vec = Vec::new(); + let mut found_but_no_permission = false; + + for owm in candidates { + // 1. State filter + if !Spec::accepted_states().contains(&owm.effective_state()) { + continue; + } + + // 2. Permission check + let authorized = if Spec::strict_permission_check() { + // Strict: only exact operation grant (no Get wildcard) + user_can_perform_operation(&owm, user, &Spec::KMIP_OP, kms).await? + } else { + // Lenient: Get grant also authorizes (standard for crypto ops) + is_user_authorized(&kms.database, owm.id(), user, Spec::KMIP_OP).await? + }; + if !authorized { + found_but_no_permission = true; + continue; + } + + // 3. Eligibility (object type + usage mask) + if !Spec::is_key_eligible(&owm, kms.vendor_id()) { + continue; + } + + // 4. Extra validation (hard error on failure — not skipped) + extra_validation(&owm)?; + + eligible.push(owm); + } + + match eligible.len() { + 1 => eligible + .into_iter() + .next() + .ok_or_else(|| KmsError::ItemNotFound("unreachable: len == 1".to_owned())), + 0 => Err(if found_but_no_permission { + KmsError::Unauthorized(format!( + "{}: user {user} does not have permission to use key: {uid_display}", + Spec::OP_NAME, + )) + } else { + KmsError::ItemNotFound(format!( + "{}: no valid key found for identifier: {uid_display}", + Spec::OP_NAME, + )) + }), + n => { + let ids: Vec<&str> = eligible.iter().map(ObjectWithMetadata::id).collect(); + Err(KmsError::InvalidRequest(format!( + "{}: identifier '{uid_display}' resolves to {n} valid keys {ids:?}; \ + use a unique identifier", + Spec::OP_NAME, + ))) + } + } +} + +// ─── 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, +} + +// ─── Resolution result ─────────────────────────────────────────────────────── + +/// Result of key resolution for a cryptographic operation. +pub(super) 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), + /// A keyset chain: ordered list of UIDs from newest to oldest. + /// The caller tries each key in order until one succeeds. + Keyset(Vec), +} + +// ─── Oracle selection ──────────────────────────────────────────────────────── + +/// 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). +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 !is_user_authorized(&kms.database, 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", + ))) + } + } +} + +// ─── Main resolution pipeline ──────────────────────────────────────────────── + +/// Resolve the key for a cryptographic operation using the [`CryptoOpSpec`] trait. +/// +/// Performs the entire key selection pipeline generically: +/// 1. Checks if the identifier is a keyset reference (name or name@version). +/// 2. Resolves UIDs from the `unique_identifier`. +/// 3. Attempts oracle (HSM) routing. +/// 4. Selects the key from the database with `Op::is_key_eligible`. +/// 5. Applies error mapping via `Op::map_selection_error`. +/// 6. Enforces process window constraints (`ProcessStartDate` / `ProtectStopDate`). +/// +/// Returns [`ResolvedKey::Oracle`] for HSM keys, [`ResolvedKey::Local`] for DB keys, +/// or [`ResolvedKey::Keyset`] for try-each keyset chains. +/// Local keys are NOT unwrapped — `perform_crypto_operation` handles unwrapping. +pub(super) async fn resolve_key_for_operation( + unique_identifier: &UniqueIdentifier, + kms: &KMS, + user: &str, +) -> KResult { + let uid_str = unique_identifier + .as_str() + .context("The unique identifier must be a string")?; + + // ── Keyset detection ───────────────────────────────────────────────────── + if let Some(keyset_ref) = parse_keyset_identifier(uid_str) { + // Explicit @version → resolve to a single key + match &keyset_ref.version { + KeysetVersion::Latest | KeysetVersion::First | KeysetVersion::Generation(_) => { + if let Some(uid) = resolve_keyset_to_single_uid(&keyset_ref, kms, user).await? { + let owm = kms.database.retrieve_object(&uid).await?.ok_or_else(|| { + KmsError::ItemNotFound(format!( + "{}: keyset key not found: {uid}", + Op::OP_NAME + )) + })?; + owm.check_process_window()?; + return Ok(ResolvedKey::Local(Box::new(owm))); + } + // Keyset name not found in DB — fall through to normal UID resolution + } + KeysetVersion::Bare => { + // Bare keyset name: behavior depends on operation's keyset_mode + match Op::keyset_mode() { + KeysetMode::SingleLatest => { + if let Some(uid) = + resolve_keyset_to_single_uid(&keyset_ref, kms, user).await? + { + let owm = + kms.database.retrieve_object(&uid).await?.ok_or_else(|| { + KmsError::ItemNotFound(format!( + "{}: keyset key not found: {uid}", + Op::OP_NAME + )) + })?; + owm.check_process_window()?; + return Ok(ResolvedKey::Local(Box::new(owm))); + } + // Not a keyset → fall through to normal path + } + KeysetMode::TryEach => { + let chain = walk_keyset_chain(&keyset_ref.name, kms, 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, 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 }); + } + + // 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) = kms.database.retrieve_object(uid).await? { + candidates.push(owm); + } + } + let uid_display = unique_identifier.to_string(); + let owm = select_unique_key::(candidates, &uid_display, kms, user, |_| Ok(())) + .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))) +} diff --git a/crate/server/src/core/operations/key_ops/lifecycle.rs b/crate/server/src/core/operations/key_ops/lifecycle.rs new file mode 100644 index 0000000000..55d6f7e10f --- /dev/null +++ b/crate/server/src/core/operations/key_ops/lifecycle.rs @@ -0,0 +1,251 @@ +//! Key lifecycle management: authorization helpers, digest-aware lifecycle +//! initialization, and cascading-operation metrics. + +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::kmip_2_1::{ + KmipOperation, + kmip_attributes::Attributes, + kmip_objects::{Object, ObjectType}, + }, + cosmian_kms_interfaces::ObjectWithMetadata, +}; +use time::OffsetDateTime; + +use crate::{ + core::{KMS, operations::digest::digest}, + result::KResult, +}; + +// ─── Lifecycle helpers ─────────────────────────────────────────────────────── + +/// 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( + owm: &ObjectWithMetadata, + user: &str, + operation: &KmipOperation, + kms: &KMS, +) -> KResult { + if user == owm.owner() { + return Ok(true); + } + let permissions = kms + .database + .list_user_operations_on_object(owm.id(), user, false) + .await?; + Ok(permissions.contains(operation)) +} + +// ─── Object lifecycle initialization ───────────────────────────────────────── + +/// Initialize lifecycle attributes on a newly created or imported object. +/// +/// Computes the KMIP digest (SHA-256 via OpenSSL), then delegates to +/// [`Object::setup_lifecycle`] for the state-machine logic. +pub(crate) fn setup_object_lifecycle( + object: &mut Object, + object_type: ObjectType, + requested_activation_date: Option, +) -> KResult { + let computed_digest = digest(object)?; + Ok(object.setup_lifecycle(object_type, requested_activation_date, computed_digest)?) +} + +// ─── Metrics ───────────────────────────────────────────────────────────────── + +/// 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()); + } +} + +// ─── 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, + }; + 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::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, + }, + }) + } + + #[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.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.effective_state(), State::PreActive); + Ok(()) + } + + #[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); + } + + #[test] + fn test_setup_object_lifecycle_past_date_gives_active() -> KResult<()> { + let mut obj = test_object(); + let past = time_normalize()? - Duration::hours(1); + let attrs = setup_object_lifecycle(&mut obj, ObjectType::SymmetricKey, Some(past))?; + assert_eq!(attrs.state, Some(State::Active)); + Ok(()) + } + + #[test] + fn test_setup_object_lifecycle_no_date_gives_preactive() -> KResult<()> { + let mut obj = test_object(); + let attrs = setup_object_lifecycle(&mut obj, ObjectType::SymmetricKey, None)?; + assert_eq!(attrs.state, Some(State::PreActive)); + Ok(()) + } + + #[test] + fn test_setup_object_lifecycle_future_date_gives_preactive() -> KResult<()> { + let mut obj = test_object(); + let future = time_normalize()? + Duration::hours(1); + let attrs = setup_object_lifecycle(&mut obj, ObjectType::SymmetricKey, Some(future))?; + assert_eq!(attrs.state, Some(State::PreActive)); + assert_eq!(attrs.activation_date, Some(future)); + Ok(()) + } + + #[test] + fn test_effective_state_active_remains_active() { + 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(()) + } +} diff --git a/crate/server/src/core/operations/key_ops/mod.rs b/crate/server/src/core/operations/key_ops/mod.rs index 18c8236e80..517fd9a8da 100644 --- a/crate/server/src/core/operations/key_ops/mod.rs +++ b/crate/server/src/core/operations/key_ops/mod.rs @@ -1,318 +1,18 @@ -mod crypto_op; - -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; - -use super::digest::digest; -use crate::{ - core::{KMS, uid_utils::has_prefix}, - error::KmsError, - result::KResult, -}; - -/// 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(()) - } -} +//! Key operations: lifecycle, authorization, resolution, crypto dispatch, and usage limits. +//! +//! This module is the hub for all key-related operation logic. Each concern +//! lives in its own submodule; this file re-exports the public API so that +//! external consumers can continue importing from `key_ops::`. + +pub(crate) mod authorization; +pub(crate) mod crypto_op; +pub(crate) mod key_resolution; +pub(crate) mod lifecycle; +pub(crate) mod usage_limits; + +// ─── Re-exports (stable external API) ─────────────────────────────────────── + +pub(crate) use authorization::{enforce_create_permission, reject_protection_storage_masks}; +pub(crate) use crypto_op::{CryptoOpSpec, perform_crypto_operation}; +pub(crate) use key_resolution::KeysetMode; +pub(crate) use lifecycle::{record_cascading_metrics, setup_object_lifecycle}; diff --git a/crate/server/src/core/operations/key_ops/usage_limits.rs b/crate/server/src/core/operations/key_ops/usage_limits.rs new file mode 100644 index 0000000000..e41dcfb59b --- /dev/null +++ b/crate/server/src/core/operations/key_ops/usage_limits.rs @@ -0,0 +1,56 @@ +//! Usage-limit accounting for KMIP cryptographic operations. +//! +//! Keys may carry `UsageLimits` attributes that cap how many bytes / objects / +//! operations they can protect. This module provides: +//! +//! - [`decrement_usage_limits`] — post-operation persistence. +//! +//! Pre-operation checks (`has_usage_mask`, `enforce_usage_limits`) live on +//! [`ObjectWithMetadata`] directly. + +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::kmip_2_1::kmip_types::UsageLimitsUnit, cosmian_kms_interfaces::ObjectWithMetadata, +}; + +use crate::{core::KMS, error::KmsError, result::KResult}; + +/// 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(super) 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; + } + } + } + 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}" + )) + })?; + } + Ok(()) +} diff --git a/crate/server/src/core/operations/mac.rs b/crate/server/src/core/operations/mac.rs index f2df7cc3bc..e6ed315785 100644 --- a/crate/server/src/core/operations/mac.rs +++ b/crate/server/src/core/operations/mac.rs @@ -1,6 +1,6 @@ use cosmian_kms_server_database::reexport::{ cosmian_kmip::{ - kmip_0::kmip_types::HashingAlgorithm, + kmip_0::kmip_types::{HashingAlgorithm, State}, kmip_2_1::{ KmipOperation, kmip_attributes::Attributes, @@ -18,7 +18,7 @@ use openssl::{md::Md, md_ctx::MdCtx, pkey::PKey}; use crate::{ core::{ KMS, - operations::{CryptoOpSpec, perform_crypto_operation}, + operations::{CryptoOpSpec, KeysetMode, perform_crypto_operation}, }, error::KmsError, kms_bail, @@ -123,6 +123,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() } @@ -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..7153223492 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, perform_crypto_operation}; +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..d19dfe4cbb --- /dev/null +++ b/crate/server/src/core/operations/recertify.rs @@ -0,0 +1,344 @@ +//! KMIP `ReCertify` — certificate rotation with new UID and replacement links. +//! +//! This implements the [`RekeyOperation`] trait for certificate renewal/rotation. +//! Unlike the standard `Certify` operation (which replaces in-place via Upsert), +//! `ReCertify` creates a **new certificate with a fresh UID** and links it to the +//! old certificate via `ReplacedObject` / `ReplacementObject` links. +//! +//! The old certificate remains Active but is marked with a `ReplacementObjectLink` +//! pointing to the new certificate. Keys linked to the old certificate are updated +//! to point to the new certificate via their `CertificateLink`. + +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::State, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attributes, + kmip_data_structures::KeyWrappingSpecification, + kmip_objects::ObjectType, + kmip_operations::{Certify, ReCertify, ReCertifyResponse}, + kmip_types::{LinkType, LinkedObjectIdentifier, UniqueIdentifier}, + }, + time_normalize, + }, + cosmian_kms_interfaces::AtomicOperation, +}; +use cosmian_logger::trace; + +use super::rekey::{ + RekeyOperation, ReplacementObject, RotationCandidate, compute_rotation_uid, + enforce_privileged_user, execute_rekey, prepare_replacement_attributes, + set_rotation_metadata_on_new_key, update_old_key_after_rekey, +}; +use crate::{ + core::{ + KMS, + operations::{ + certify::{build_and_sign_certificate, get_issuer, get_subject}, + key_ops::reject_protection_storage_masks, + }, + 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]> { + reject_protection_storage_masks(request.protection_storage_masks.is_some())?; + + enforce_privileged_user(kms, 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(), + object_type: ObjectType::Certificate, + }]) + } + + async fn generate_replacement( + &self, + kms: &KMS, + candidates: &[RotationCandidate; 1], + ) -> KResult<[ReplacementObject; 1]> { + let [candidate] = candidates; + let new_uid = compute_rotation_uid(&candidate.uid); + + // Build a Certify request that references the existing certificate for renewal. + // 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 = + prepare_replacement_attributes(old_attrs, &replacement.old_uid, self.offset)?; + replacement.attributes.activation_date = base_attrs.activation_date; + replacement.attributes.deactivation_date = base_attrs.deactivation_date; + replacement.attributes.initial_date = base_attrs.initial_date; + replacement.attributes.last_change_date = base_attrs.last_change_date; + + // Compute state based on activation_date (certificates bypass setup_object_lifecycle) + let now = time_normalize()?; + let state = if replacement + .attributes + .activation_date + .is_some_and(|d| d <= now) + { + State::Active + } else { + State::PreActive + }; + replacement.attributes.state = Some(state); + + // Set ReplacedObjectLink on the new certificate pointing to the old one + replacement.attributes.set_link( + LinkType::ReplacedObjectLink, + LinkedObjectIdentifier::TextString(replacement.old_uid.clone()), + ); + + // Preserve links to associated keys from the old certificate + for link_type in [LinkType::PublicKeyLink, LinkType::PrivateKeyLink] { + if let Some(link) = old_attrs.get_link(link_type) { + replacement.attributes.set_link(link_type, link); + } + } + + // Set rotation metadata + vendor tags + set_rotation_metadata_on_new_key(&mut replacement.attributes, old_attrs)?; + replacement.tags.extend(old_attrs.get_tags(kms.vendor_id())); + + Ok(()) + } + + async fn rewrap_new_objects( + &self, + _kms: &KMS, + _user: &str, + _replacements: &mut [ReplacementObject; 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(); + update_old_key_after_rekey(&mut old_attributes, &replacement.new_uid)?; + if let Ok(obj_attrs) = old_object.attributes_mut() { + update_old_key_after_rekey(obj_attrs, &replacement.new_uid)?; + } + + let mut operations = vec![AtomicOperation::UpdateObject(( + candidate.uid.clone(), + old_object, + old_attributes, + None, + ))]; + + // Relink keys: update CertificateLink on linked PK/SK to point to new cert UID + relink_keys_to_new_certificate( + kms, + user, + &candidate.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..776036c1bd 100644 --- a/crate/server/src/core/operations/register.rs +++ b/crate/server/src/core/operations/register.rs @@ -1,5 +1,4 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ - self, kmip_0::kmip_types::State, kmip_2_1::{ kmip_objects::ObjectType, @@ -10,7 +9,10 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ }; use cosmian_logger::{debug, trace}; -use super::import::process_opaque_object; +use super::{ + import::process_opaque_object, + key_ops::{enforce_create_permission, reject_protection_storage_masks}, +}; use crate::{ core::{ KMS, @@ -18,7 +20,6 @@ use crate::{ process_certificate, process_private_key, process_public_key, process_secret_data, process_symmetric_key, }, - retrieve_object_utils::user_has_permission, }, error::KmsError, kms_bail, @@ -29,31 +30,10 @@ pub(crate) async fn register( kms: &KMS, mut request: Register, owner: &str, - - privileged_users: Option>, ) -> KResult { trace!("{request}"); - if request.protection_storage_masks.is_some() { - kms_bail!(KmsError::UnsupportedPlaceholder) - } - - // To register an object, check that the user has `Create` access right - // The `Create` right implicitly grants permission for Create, Import, and Register operations. - if let Some(users) = privileged_users.clone() { - let has_permission = user_has_permission( - owner, - None, - &cosmian_kmip::kmip_2_1::KmipOperation::Create, - kms, - ) - .await?; - - if !has_permission && !users.iter().any(|u| u == owner) { - kms_bail!(KmsError::Unauthorized( - "User does not have create access-right to register objects.".to_owned() - )) - } - } + reject_protection_storage_masks(request.protection_storage_masks.is_some())?; + enforce_create_permission(kms, owner).await?; if request.object_type != request.object.object_type() { kms_bail!(KmsError::InconsistentOperation( diff --git a/crate/server/src/core/operations/rekey.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..23d18e70e9 --- /dev/null +++ b/crate/server/src/core/operations/rekey/common.rs @@ -0,0 +1,942 @@ +//! 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, + UniqueIdentifier, + }, + }, + time_normalize, + }, + cosmian_kms_interfaces::{AtomicOperation, ObjectWithMetadata}, +}; +use cosmian_logger::{info, warn}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + core::{ + KMS, + operations::key_ops::{enforce_create_permission, setup_object_lifecycle}, + wrapping::{unwrap_object, wrap_and_cache, wrap_object}, + }, + error::KmsError, + kms_bail, + result::KResult, +}; + +// ─── Shared helpers (used by all rotation trait implementors) ──────────────── + +/// Copy the `WrappingKeyLink` from an old (wrapped) object to the new object's attributes. +/// +/// If the old object was wrapped, the wrapping key UID is preserved as a +/// `LinkType::WrappingKeyLink` on the replacement's attributes so that +/// dependant re-wrapping and attribute queries work correctly. +pub(crate) fn preserve_wrapping_key_link(old_object: &Object, new_attrs: &mut Attributes) { + if let Some(wrapping_key_uid) = old_object.wrapping_key_uid() { + new_attrs.set_link( + LinkType::WrappingKeyLink, + LinkedObjectIdentifier::TextString(wrapping_key_uid), + ); + } +} + +/// Retrieve all eligible objects matching the given identifier, filtered by state and type. +/// +/// Filters by: +/// - State: `Active`, `Deactivated`, or `Compromised`. +/// - `Active` / `Deactivated`: clearly eligible per KMIP §6.1.46 (`Wrong_Key_Lifecycle_State` +/// is not listed as a possible error, confirming Deactivated keys are rotatable). +/// - `Compromised`: explicitly allowed because the primary response to a key compromise is +/// to rotate it immediately — blocking rotation of compromised keys would be +/// counter-productive from a security standpoint. +/// - `PreActive`: rejected — key material has not yet entered service; rotation is meaningless. +/// - `Destroyed` / `Destroyed_Compromised`: rejected — key material is gone; nothing to rotate. +/// - Object type: the specified `object_type` +/// +/// When a specific UID resolves to a key of the correct type but in an +/// ineligible state, an explicit error is returned rather than silently +/// skipping. For tag-based queries, ineligible keys are filtered out. +pub(crate) async fn retrieve_eligible_keys( + kms: &KMS, + uid_or_tags: &str, + object_type: ObjectType, +) -> KResult> { + let is_tag_query = uid_or_tags.starts_with('['); + let objects = kms.database.retrieve_objects(uid_or_tags).await?; + let mut eligible = Vec::new(); + + for owm in objects.into_values() { + if owm.object().object_type() != object_type { + continue; + } + let is_eligible = matches!( + owm.state(), + State::Active | State::Deactivated | State::Compromised + ); + if !is_eligible { + // For direct UID queries, give an explicit error instead of silently skipping + if !is_tag_query { + return Err(KmsError::InvalidRequest(format!( + "key '{}' is in state '{}' — only Active, Deactivated, or Compromised keys \ + can be rotated", + owm.id(), + owm.state() + ))); + } + continue; + } + eligible.push(owm); + } + Ok(eligible) +} + +// ─── Trait: RekeyOperation ─────────────────────────────────────────────────── + +/// An existing object that is a candidate for rotation. +#[allow(dead_code)] +pub(crate) struct RotationCandidate { + /// The object-with-metadata from the database. + pub owm: ObjectWithMetadata, + /// The UID of this object. + pub uid: String, + /// The KMIP object type. + pub object_type: ObjectType, +} + +/// A newly generated replacement object ready for Phase 1 commit. +#[allow(dead_code)] +pub(crate) struct ReplacementObject { + /// The fresh UID for the replacement. + pub new_uid: String, + /// The UID of the old object being replaced. + pub old_uid: String, + /// The new KMIP object (key or certificate). + pub object: Object, + /// Attributes for the new object. + pub attributes: Attributes, + /// Tags for the new object (used in `AtomicOperation::Create`). + pub tags: HashSet, + /// If `Some`, dependants of the old object will be re-wrapped/re-linked + /// to this UID during Phase 2. `None` means no dependant processing for this slot. + pub rewrap_to: Option, +} + +/// Unified trait for all rotation operations: `ReKey`, `ReKeyKeyPair`, and `ReCertify`. +/// +/// Each implementor provides type-specific logic for the 8 steps of the rotation pipeline. +/// The shared [`execute_rekey`] orchestrator drives the pipeline in order. +/// +/// 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 2: Detect wrapping context on existing object(s). + /// + /// Returns one `Option` per candidate. + /// The default implementation extracts wrapping data from each candidate's key block. + /// Certificates (which have no key block) naturally return `None`. + fn detect_wrapping( + &self, + candidates: &Self::Candidates, + ) -> Vec> { + candidates + .as_ref() + .iter() + .map(|c| c.owm.object().rewrap_spec()) + .collect() + } + + /// 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> { + default_rewrap_new_objects(kms, user, replacements.as_mut(), wrap_specs) + } + + /// Step 6: Phase 1 — persist new objects atomically. + /// + /// The default implementation creates all replacement objects in a single atomic transaction. + fn persist_new_key( + &self, + kms: &KMS, + user: &str, + replacements: &Self::Replacements, + ) -> impl std::future::Future> { + async move { + let operations: 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, &operations).await?; + Ok(()) + } + } + + /// Step 7: Phase 2 — retire old objects + finalize dependants. + /// + /// For keys: rewrap all dependants with the new wrapping key. + /// For certificates: relink keys' `CertificateLink` to the new cert UID. + /// + /// The default implementation builds [`KeyRetirement`] entries from each + /// candidate/replacement pair and delegates to [`finalize_rekey`]. + /// Override this for certificate-specific logic. + fn finalize_dependants( + &self, + kms: &KMS, + user: &str, + candidates: &Self::Candidates, + replacements: &Self::Replacements, + ) -> impl std::future::Future> { + default_finalize_dependants(kms, user, candidates.as_ref(), replacements.as_ref()) + } + + /// Step 8: Build the KMIP response from the completed replacements. + fn build_response(&self, replacements: &Self::Replacements) -> Self::Response; +} + +/// Default implementation for [`RekeyOperation::finalize_dependants`]. +/// +/// Builds [`KeyRetirement`] entries from each candidate/replacement pair, +/// delegates to [`finalize_rekey`], and logs the result. +async fn default_finalize_dependants( + kms: &KMS, + user: &str, + candidates: &[RotationCandidate], + replacements: &[ReplacementObject], +) -> KResult<()> { + let retirements: Vec> = candidates + .iter() + .zip(replacements.iter()) + .map(|(c, r)| KeyRetirement { + old_owm: &c.owm, + new_uid: &r.new_uid, + rewrap_to: r.rewrap_to.as_deref(), + }) + .collect(); + + Box::pin(finalize_rekey(kms, user, &retirements)).await?; + + for (c, r) in candidates.iter().zip(replacements.iter()) { + info!( + "Rekey finalized: old={} → new={}, user={user}", + c.uid, r.new_uid + ); + } + Ok(()) +} + +/// Default implementation for [`RekeyOperation::rewrap_new_objects`]. +/// +/// For each replacement object: +/// 1. Applies server-wide KEK wrapping via `wrap_and_cache` (no-op if none configured). +/// 2. If the old object was wrapped (spec present) and the new object is still unwrapped, +/// applies the same wrapping specification and caches the unwrapped copy. +async fn default_rewrap_new_objects( + kms: &KMS, + user: &str, + replacements: &mut [ReplacementObject], + wrap_specs: &[Option], +) -> KResult<()> { + for (replacement, spec) in replacements.iter_mut().zip(wrap_specs.iter()) { + // Step 1: server-wide KEK wrapping (no-op if no KEK configured or already wrapped) + Box::pin(wrap_and_cache( + kms, + user, + &UniqueIdentifier::TextString(replacement.new_uid.clone()), + &mut replacement.object, + )) + .await?; + + // Step 2: re-wrap with original spec if old key was wrapped and new key is still unwrapped + let Some(mut rewrap_spec) = spec.clone() else { + continue; + }; + if replacement.object.is_wrapped() { + continue; + } + if replacement + .object + .key_block() + .is_ok_and(|kb| kb.key_bytes().is_ok()) + { + rewrap_spec.encoding_option = Some(EncodingOption::NoEncoding); + } + + let unwrapped_object = replacement.object.clone(); + Box::pin(wrap_object( + &mut replacement.object, + &rewrap_spec, + kms, + user, + )) + .await?; + kms.database + .unwrapped_cache() + .insert( + replacement.new_uid.clone(), + &replacement.object, + unwrapped_object, + ) + .await?; + } + Ok(()) +} + +/// Execute the full rotation pipeline using a [`RekeyOperation`] implementor. +/// +/// This orchestrator drives the 8-step rotation flow in order: +/// validate → detect wrapping → generate → prepare attributes → rewrap → commit → finalize → respond. +pub(crate) async fn execute_rekey( + op: &T, + kms: &KMS, + request: &T::Request, + user: &str, +) -> KResult { + let candidates = op.validate(kms, request, user).await?; + let wrap_specs = op.detect_wrapping(&candidates); + let mut replacements = op.generate_replacement(kms, &candidates).await?; + op.prepare_attributes(kms, &candidates, &mut replacements)?; + op.rewrap_new_objects(kms, user, &mut replacements, &wrap_specs) + .await?; + op.persist_new_key(kms, user, &replacements).await?; + op.finalize_dependants(kms, user, &candidates, &replacements) + .await?; + Ok(op.build_response(&replacements)) +} + +// ─── Shared helpers (used by trait implementors) ───────────────────────────── + +/// Dates computed for a replacement key based on the existing key's dates and an optional offset. +/// +/// Per KMIP 1.4 §4.4 Table 172 / §4.5 Table 176 / §4.8 Table 186: +/// - `activation = initialization + offset` (if offset provided) +/// - `deactivation = old_deactivation + (new_activation - old_activation)` (if both exist) +#[allow(clippy::struct_field_names)] +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 / §4.8 Table 186: +/// - Initialization Date (IT₂) = now (always > IT₁) +/// - Activation Date (AT₂) = IT₂ + Offset (if offset provided), else IT₂ (immediate activation) +/// - Deactivation Date = DT₁ + (AT₂ - AT₁) (if both DT₁ and AT₁ exist) +pub(crate) fn compute_replacement_dates( + old_attrs: &Attributes, + offset: Option, +) -> KResult { + let now = time_normalize()?; + + let activation_date = Some(offset.map_or(now, |secs| now + time::Duration::seconds(secs))); + + let deactivation_date = match (old_attrs.deactivation_date, old_attrs.activation_date) { + (Some(old_deactivation), Some(old_activation)) => { + // 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 / §4.8 Table 187. +/// +/// 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 computation +/// - Clears fields that must not be carried over (`destroy_date`, compromise dates, revocation) +pub(crate) fn prepare_replacement_attributes( + old_attrs: &Attributes, + old_uid: &str, + offset: Option, +) -> KResult { + let dates = compute_replacement_dates(old_attrs, offset)?; + + let mut new_attrs = old_attrs.clone(); + + // Clear fields that must not be set on the replacement key + new_attrs.unique_identifier = None; + new_attrs.destroy_date = None; + new_attrs.compromise_date = None; + new_attrs.compromise_occurrence_date = None; + + // Remove any existing replacement/replaced links (from a previous rekey) + new_attrs.remove_link(LinkType::ReplacementObjectLink); + new_attrs.remove_link(LinkType::ReplacedObjectLink); + + // Set the ReplacedObjectLink on the new key pointing to the old key + new_attrs.set_link( + LinkType::ReplacedObjectLink, + LinkedObjectIdentifier::TextString(old_uid.to_owned()), + ); + + // Set dates per spec + new_attrs.initial_date = Some(dates.initialization_date); + new_attrs.last_change_date = Some(dates.initialization_date); + new_attrs.activation_date = dates.activation_date; + if dates.deactivation_date.is_some() { + new_attrs.deactivation_date = dates.deactivation_date; + } + + Ok(new_attrs) +} + +/// Clean attributes from an existing key to use as input to `Create` / `CreateKeyPair`. +/// +/// Removes identity, lifecycle dates, rotation metadata, and vendor tags that must not +/// leak from the old key into the generation request. The cryptographic parameters +/// (algorithm, length, domain parameters) are preserved so the replacement key has +/// identical cryptographic properties. +/// +/// Used by both `symmetric.rs` and `keypair.rs` in their `generate_replacement` step. +pub(crate) fn clean_attributes_for_generation( + old_attrs: &Attributes, + vendor_id: &str, +) -> Attributes { + let mut attrs = old_attrs.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 (set_rotation_metadata_on_new_key applies later) + attrs.rotate_interval = None; + attrs.rotate_name = None; + attrs.rotate_offset = None; + // Vendor tags — assigned fresh by Create + attrs.remove_vendor_attribute(vendor_id, "tag"); + attrs +} + +/// Update the old key's attributes after a rekey operation. +/// +/// Per KMIP 1.4 §4.4 Table 173 / §4.5 Table 177 / §4.8 Table 187: +/// - Sets `ReplacementObjectLink` → new key +/// - Removes the Name attribute (transferred to the replacement) +/// - Updates Last Change Date to now +pub(crate) fn update_old_key_after_rekey(old_attrs: &mut Attributes, new_uid: &str) -> KResult<()> { + let now = time_normalize()?; + + old_attrs.set_link( + LinkType::ReplacementObjectLink, + LinkedObjectIdentifier::TextString(new_uid.to_owned()), + ); + + // Remove the Name from the old key (it's taken over by the new key) + old_attrs.name = None; + + // Update Last Change Date + old_attrs.last_change_date = Some(now); + + Ok(()) +} + +/// Set rotation metadata on the **new** key after a manual rekey. +/// +/// Per the auto-rotation spec (Manual rekey table): +/// - `rotate_generation` = old value + 1 +/// - `rotate_date` = now +/// - `rotate_interval` = 0 (manual rekey does not inherit the policy) +/// - `rotate_name` = inherited from old key (required for keyset resolution) +/// - `rotate_offset` = None (cleared for manual rekey) +pub(crate) fn set_rotation_metadata_on_new_key( + new_attrs: &mut Attributes, + old_attrs: &Attributes, +) -> KResult<()> { + new_attrs.rotate_generation = Some(old_attrs.rotate_generation.unwrap_or(0) + 1); + new_attrs.rotate_date = Some(time_normalize()?); + // Manual rekey: do not inherit the rotation policy — user must re-arm explicitly + new_attrs.rotate_interval = Some(0); + // Inherit rotate_name so keyset resolution (name@latest, bare name) can find the new key + new_attrs.rotate_name.clone_from(&old_attrs.rotate_name); + new_attrs.rotate_offset = None; + // Mark the new key as the latest in the keyset + new_attrs.rotate_latest = Some(true); + Ok(()) +} + +/// Clear rotation flags on the **old** key after 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 via `@first`/`@0`) +pub(crate) const fn clear_rotation_flags_on_old_key(old_attrs: &mut Attributes) { + old_attrs.rotate_interval = Some(0); + old_attrs.rotate_latest = Some(false); + // Ensure the original key has an explicit generation so that keyset + // addressing with @first / @0 can find it via find_by_rotate_name. + if old_attrs.rotate_generation.is_none() { + old_attrs.rotate_generation = Some(0); + } +} + +/// Returns `true` if `attrs` represents the latest generation in its named keyset. +/// +/// The latest key is the one with the highest `rotate_generation` value for the +/// given `rotate_name`. If the key has no `rotate_name` it is trivially the latest. +pub(crate) async fn is_keyset_latest( + kms: &KMS, + 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 = kms.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 + })) +} + +/// Enforce privileged-user restriction for rekey operations that create new keys. +/// +/// Both `ReKey` and `ReKeyKeyPair` create replacement keys, so the caller +/// must either have `Create` permission or be in the privileged users list +/// (configured in `kms.params.privileged_users`). +pub(crate) async fn enforce_privileged_user(kms: &KMS, user: &str) -> KResult<()> { + enforce_create_permission(kms, user).await +} + +/// Validate that request attributes do not attempt to change cryptographic parameters. +/// +/// Per KMIP §4.4 / §4.5, a rekey operation must preserve the algorithm, curve, +/// and key length of the original key. Changing these requires a new `Create` or +/// `CreateKeyPair` operation instead. +/// +/// The `attrs_iter` yields each `Option<&Attributes>` from the request (one for +/// symmetric `ReKey`, up to three for `ReKeyKeyPair`). +pub(crate) fn validate_no_crypto_param_change<'a>( + existing_attrs: &Attributes, + attrs_iter: impl IntoIterator>, + operation_name: &str, +) -> KResult<()> { + for req_attrs in attrs_iter.into_iter().flatten() { + if let Some(algo) = req_attrs.cryptographic_algorithm { + if existing_attrs.cryptographic_algorithm != Some(algo) { + kms_bail!(KmsError::InvalidRequest(format!( + "{operation_name}: changing the cryptographic algorithm is not allowed. \ + Use Create/CreateKeyPair for a different algorithm." + ))) + } + } + if let Some(ref cdp) = req_attrs.cryptographic_domain_parameters { + if let Some(ref existing_cdp) = existing_attrs.cryptographic_domain_parameters { + if cdp.recommended_curve.is_some() + && cdp.recommended_curve != existing_cdp.recommended_curve + { + kms_bail!(KmsError::InvalidRequest(format!( + "{operation_name}: changing the recommended curve is not allowed. \ + Use Create/CreateKeyPair for a different curve." + ))) + } + } + } + if let Some(len) = req_attrs.cryptographic_length { + if existing_attrs.cryptographic_length.is_some() + && existing_attrs.cryptographic_length != Some(len) + { + kms_bail!(KmsError::InvalidRequest(format!( + "{operation_name}: changing the cryptographic length is not allowed. \ + Use Create/CreateKeyPair for a different key size." + ))) + } + } + } + Ok(()) +} + +// ─── Phase 2: Finalize rekey (retire old keys + rewrap dependants) ─────────── + +/// Describes one old key being retired as part of a rekey operation. +/// +/// Used by [`finalize_rekey`] to batch-retire multiple keys (e.g., both the +/// private key and public key in a key pair rekey) in a single atomic commit. +pub(crate) struct KeyRetirement<'a> { + /// The old key's metadata (object + attributes). + pub old_owm: &'a ObjectWithMetadata, + /// The UID of the new replacement key. + pub new_uid: &'a str, + /// If `Some`, all keys that were wrapped by this old key will be re-wrapped + /// using the key at this UID. Typically this is the same as `new_uid` for + /// symmetric keys and the new public key UID for key pairs. + /// `None` means no dependant re-wrapping for this slot (e.g., private keys + /// are never used as wrapping keys). + pub rewrap_to: Option<&'a str>, +} + +/// Phase 2 of a rekey operation: retire old keys, re-wrap dependants, and commit atomically. +/// +/// This function: +/// 1. For each [`KeyRetirement`] slot, retires the old key (sets `ReplacementObjectLink`, +/// clears rotation flags, updates the embedded attributes). +/// 2. For each slot with `rewrap_to = Some(new_wrapping_uid)`, finds all keys wrapped +/// by the old key and re-wraps them with the new wrapping key. +/// 3. Commits all resulting updates in a single atomic database transaction. +/// +/// # Known concurrency limitation +/// +/// This function reads dependant objects (via `find_wrapped_by`) and then writes them back +/// in the same `atomic()` call, but there is **no optimistic lock** guarding those reads. +/// If two rotation requests for the same key execute concurrently — whether two manual +/// `Re-Key` requests or one manual and one auto-rotation tick — the following race is +/// possible: +/// +/// 1. Both callers read the old key and its dependants at the same snapshot. +/// 2. Both callers generate a new key (different UIDs, no collision on `Create`). +/// 3. Both callers commit Phase 2: the second writer silently overwrites the first writer's +/// `ReplacementObjectLink` on the old key, and re-wraps dependants a second time. +/// The first new key becomes a dangling orphan. +/// +/// The auto-rotation background task (`run_auto_rotation`) is currently a no-op stub +/// (TODO comment in `auto_rotate.rs`), so this race cannot be triggered in production +/// today. Before the scheduler is wired up, `ObjectsStore` must gain a conditional-update +/// primitive (optimistic locking via a `version` column / Lua CAS in Redis) so that Phase 2 +/// can abort with a `Conflict` error if the old key was modified between the read and the +/// commit. +/// +/// Tracking issue: +// TODO(concurrency): replace the unconditional `UpdateObject` for the old key with a +// `compare_and_swap` / `update_object_if_version` that aborts if the key was concurrently +// modified. See issue above for the full design. +pub(crate) async fn finalize_rekey( + kms: &KMS, + owner: &str, + retirements: &[KeyRetirement<'_>], +) -> KResult<()> { + let mut operations: Vec = Vec::new(); + + for retirement in retirements { + let (old_object, old_attributes) = retire_old_key(retirement.old_owm, retirement.new_uid)?; + + operations.push(AtomicOperation::UpdateObject(( + retirement.old_owm.id().to_owned(), + old_object, + old_attributes, + None, + ))); + + if let Some(new_wrapping_uid) = retirement.rewrap_to { + Box::pin(rewrap_dependants( + kms, + owner, + retirement.old_owm.id(), + new_wrapping_uid, + &mut operations, + )) + .await?; + } + } + + kms.database.atomic(owner, &operations).await?; + Ok(()) +} + +/// Set up a newly generated key with replacement attributes and links. +/// +/// Applies the `ReplacedObjectLink` pointing to the old UID, an optional +/// paired-key cross-link, and the Name from the replacement attributes. +/// Then runs [`setup_object_lifecycle`] to set state / dates / digest. +pub(crate) fn setup_new_key( + key_object: &mut Object, + replacement_attrs: &Attributes, + object_type: ObjectType, + old_uid: &str, + paired_key: Option<(&str, LinkType)>, +) -> KResult { + if let Ok(key_attrs) = key_object.attributes_mut() { + key_attrs.name.clone_from(&replacement_attrs.name); + key_attrs.set_link( + LinkType::ReplacedObjectLink, + LinkedObjectIdentifier::TextString(old_uid.to_owned()), + ); + if let Some((paired_uid, link_type)) = paired_key { + key_attrs.set_link( + link_type, + LinkedObjectIdentifier::TextString(paired_uid.to_owned()), + ); + } + } + + // Pass the activation_date directly to setup_object_lifecycle: + // - Past/present date → Active (replacement inherits active state) + // - Future date → PreActive (scheduled activation) + // - None → PreActive (requires explicit Activate) + setup_object_lifecycle(key_object, object_type, replacement_attrs.activation_date) +} + +/// Apply replacement attributes, lifecycle setup, and tag extraction to one key slot. +/// +/// Combines [`setup_new_key`] + attribute/tag extraction into a single call to +/// avoid repetition when processing both the SK and PK in `prepare_attributes`. +pub(crate) fn finalize_replacement_key( + replacement: &mut ReplacementObject, + new_attrs: &Attributes, + object_type: ObjectType, + old_uid: &str, + paired_key: Option<(&str, LinkType)>, + vendor_id: &str, +) -> KResult<()> { + setup_new_key( + &mut replacement.object, + new_attrs, + object_type, + old_uid, + paired_key, + )?; + let attrs = replacement.object.attributes().cloned().unwrap_or_default(); + replacement.tags = attrs.get_tags(vendor_id); + replacement.attributes = attrs; + Ok(()) +} + +/// Compute a fresh UID for a rotation replacement key. +/// +/// - Pure UUID → fresh UUID (e.g. `"abc-…"` → `"def-…"`) +/// - User name → `"_"` (e.g. `"toto"` → `"toto_def-…"`) +/// - Already-prefixed → strip old UUID suffix, re-use prefix +/// (e.g. `"toto_abc-…"` → `"toto_def-…"`) +pub(crate) fn compute_rotation_uid(old_uid: &str) -> String { + if Uuid::parse_str(old_uid).is_ok() { + Uuid::new_v4().to_string() + } else { + let prefix = old_uid + .rsplit_once('_') + .filter(|(_, suffix)| Uuid::parse_str(suffix).is_ok()) + .map_or(old_uid, |(prefix, _)| prefix); + format!("{prefix}_{}", Uuid::new_v4()) + } +} + +// ─── Private helpers ───────────────────────────────────────────────────────── + +/// Prepare an old key (private, public, or symmetric) for replacement. +/// +/// Clones the object and attributes from the OWM, sets `ReplacementObjectLink` +/// pointing to the new key, and clears rotation flags so the scheduler won't +/// pick it up again. +fn retire_old_key( + owm: &ObjectWithMetadata, + new_uid: &str, +) -> KResult<( + cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_objects::Object, + Attributes, +)> { + let mut old_object = owm.object().clone(); + let mut old_attributes = owm.attributes().clone(); + update_old_key_after_rekey(&mut old_attributes, new_uid)?; + clear_rotation_flags_on_old_key(&mut old_attributes); + if let Ok(obj_attrs) = old_object.attributes_mut() { + update_old_key_after_rekey(obj_attrs, new_uid)?; + } + Ok((old_object, old_attributes)) +} + +/// Re-wrap all keys that were wrapped by the old wrapping key, pointing them +/// to the new wrapping key UID. +async fn rewrap_dependants( + kms: &KMS, + owner: &str, + old_uid: &str, + new_uid: &str, + operations: &mut Vec, +) -> KResult<()> { + let wrapped_dependants = kms + .database + .find_wrapped_by(old_uid, owner) + .await + .unwrap_or_default(); + + for (dep_uid, _dep_state, _dep_attrs) in wrapped_dependants { + let Some(dep_owm) = kms.database.retrieve_object(&dep_uid).await? else { + warn!("wrapped dependant {dep_uid} not found, skipping"); + continue; + }; + // Security: only re-wrap dependants owned by the caller + if dep_owm.owner() != owner { + warn!( + "skipping re-wrap of dependant {dep_uid}: owned by '{}', not by '{owner}'", + dep_owm.owner() + ); + continue; + } + let mut dep_object = dep_owm.object().clone(); + // Use the full metadata attributes from retrieve_object (not from find_wrapped_by) + // because find_wrapped_by may return incomplete attributes for wrapped objects + // (Object::attributes() fails on wrapped keys, losing activation_date etc.) + let dep_attrs = dep_owm.attributes().clone(); + + if let Some(op) = + rewrap_single_dependant(kms, owner, &dep_uid, &mut dep_object, dep_attrs, new_uid) + .await? + { + operations.push(op); + } + } + Ok(()) +} + +/// Unwrap and re-wrap a single dependant object with the new wrapping key. +/// +/// Returns `Some(AtomicOperation)` if the re-wrap succeeded, `None` if skipped. +async fn rewrap_single_dependant( + kms: &KMS, + owner: &str, + dep_uid: &str, + dep_object: &mut Object, + mut dep_attrs: Attributes, + new_uid: &str, +) -> KResult> { + let dep_wrap_spec = dep_object + .key_block() + .ok() + .and_then(|kb| kb.key_wrapping_data.as_ref()) + .map(|kwd| KeyWrappingSpecification { + wrapping_method: kwd.wrapping_method, + encryption_key_information: Some(EncryptionKeyInformation { + unique_identifier: UniqueIdentifier::TextString(new_uid.to_owned()), + cryptographic_parameters: kwd + .encryption_key_information + .as_ref() + .and_then(|e| e.cryptographic_parameters.clone()), + }), + mac_or_signature_key_information: kwd.mac_signature_key_information.clone().map(|m| { + cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::MacSignatureKeyInformation { + unique_identifier: UniqueIdentifier::TextString(new_uid.to_owned()), + cryptographic_parameters: m.cryptographic_parameters, + } + }), + attribute_name: None, + encoding_option: kwd.encoding_option, + }); + + let Some(spec) = dep_wrap_spec else { + return Ok(None); + }; + + if let Err(e) = unwrap_object(dep_object, kms, owner).await { + warn!("failed to unwrap dependant {dep_uid}: {e}, skipping"); + return Ok(None); + } + if let Err(e) = crate::core::wrapping::wrap_object(dep_object, &spec, kms, owner).await { + warn!("failed to re-wrap dependant {dep_uid} with new key: {e}, skipping"); + return Ok(None); + } + + dep_attrs.set_link( + LinkType::WrappingKeyLink, + LinkedObjectIdentifier::TextString(new_uid.to_owned()), + ); + dep_attrs.set_wrapping_key_id(kms.vendor_id(), new_uid); + + Ok(Some(AtomicOperation::UpdateObject(( + dep_uid.to_owned(), + dep_object.clone(), + dep_attrs, + None, + )))) +} diff --git a/crate/server/src/core/operations/rekey/keypair.rs b/crate/server/src/core/operations/rekey/keypair.rs new file mode 100644 index 0000000000..ce927aeefc --- /dev/null +++ b/crate/server/src/core/operations/rekey/keypair.rs @@ -0,0 +1,457 @@ +use std::collections::HashSet; + +#[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_attributes::Attributes, + kmip_objects::ObjectType, + kmip_operations::{CreateKeyPair, ReKeyKeyPair, ReKeyKeyPairResponse}, + kmip_types::{KeyFormatType, LinkType, UniqueIdentifier}, + }, + }, + cosmian_kms_interfaces::ObjectWithMetadata, +}; +use cosmian_logger::trace; + +use super::common::{ + RekeyOperation, ReplacementObject, RotationCandidate, clean_attributes_for_generation, + compute_rotation_uid, enforce_privileged_user, execute_rekey, finalize_replacement_key, + is_keyset_latest, prepare_replacement_attributes, preserve_wrapping_key_link, + retrieve_eligible_keys, set_rotation_metadata_on_new_key, validate_no_crypto_param_change, +}; +#[cfg(feature = "non-fips")] +use crate::core::cover_crypt::rekey_keypair_cover_crypt; +use crate::{ + core::{ + KMS, + operations::{ + create_key_pair::generate_key_pair, + key_ops::{key_resolution::KeySelectionSpec, reject_protection_storage_masks}, + }, + }, + error::KmsError, + result::{KResult, KResultHelper}, +}; + +/// Implementor of [`RekeyOperation`] for KMIP `ReKeyKeyPair` (KMIP 1.4 §4.5 / KMIP 2.1 §6.1.47) on asymmetric key pairs. +struct KeypairRekey { + /// The `offset` from the `ReKeyKeyPair` request (date computation per KMIP 1.4 Table 176 / KMIP 2.1 Table 308). + offset: Option, +} + +impl KeySelectionSpec for KeypairRekey { + const KMIP_OP: KmipOperation = KmipOperation::Rekey; + const OP_NAME: &'static str = "ReKeyKeyPair"; + + fn accepted_states() -> &'static [State] { + &[State::Active, State::Deactivated, State::Compromised] + } + + fn strict_permission_check() -> bool { + true + } + + fn is_key_eligible(owm: &ObjectWithMetadata, _vendor_id: &str) -> bool { + if owm.object().object_type() != ObjectType::PrivateKey { + return false; + } + // Skip Covercrypt keys (handled separately before trait dispatch) + let key_format_type = owm.attributes().key_format_type.or_else(|| { + owm.object() + .attributes() + .ok() + .and_then(|a| a.key_format_type) + }); + key_format_type != Some(KeyFormatType::CoverCryptSecretKey) + } +} + +/// KMIP `ReKeyKeyPair` operation for asymmetric key pairs. +/// +/// Per KMIP 1.4 §4.5: +/// - 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) = try_covercrypt_rekey(kms, &request, user).await? { + return Ok(response); + } + + Box::pin(execute_rekey( + &KeypairRekey { + offset: request.offset, + }, + kms, + &request, + user, + )) + .await +} + +/// Attempt Covercrypt-specific rekey. Returns `Some(response)` if handled, `None` otherwise. +#[cfg(feature = "non-fips")] +async fn try_covercrypt_rekey( + kms: &KMS, + request: &ReKeyKeyPair, + user: &str, +) -> KResult> { + let uid_or_tags = request + .private_key_unique_identifier + .as_ref() + .ok_or(KmsError::UnsupportedPlaceholder)? + .as_str() + .context("ReKeyKeyPair: the private key unique identifier must be a string")?; + + for owm in retrieve_eligible_keys(kms, uid_or_tags, ObjectType::PrivateKey).await? { + let key_format_type = owm.attributes().key_format_type.or_else(|| { + owm.object() + .attributes() + .ok() + .and_then(|a| a.key_format_type) + }); + + if key_format_type == Some(KeyFormatType::CoverCryptSecretKey) { + let attributes = request.private_key_attributes.as_ref().ok_or_else(|| { + KmsError::InvalidRequest( + "ReKeyKeyPair: the private key attributes must be supplied for Covercrypt" + .to_owned(), + ) + })?; + if Some(CryptographicAlgorithm::CoverCrypt) == attributes.cryptographic_algorithm { + let action = rekey_edit_action_from_attributes(kms.vendor_id(), attributes)?; + let response = Box::pin(rekey_keypair_cover_crypt( + kms, + Covercrypt::default(), + owm.id().to_owned(), + user, + action, + owm.attributes().sensitive.unwrap_or(false), + )) + .await + .context("ReKeyKeyPair: Covercrypt rekey failed")?; + return Ok(Some(response)); + } + } + } + Ok(None) +} + +impl RekeyOperation for KeypairRekey { + 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]> { + use crate::core::operations::key_ops::key_resolution::select_unique_key; + + reject_protection_storage_masks( + request.common_protection_storage_masks.is_some() + || request.private_protection_storage_masks.is_some() + || request.public_protection_storage_masks.is_some(), + )?; + + enforce_privileged_user(kms, user).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 KMIP: they have no KMIP attribute + // storage and are often non-extractable (CKA_EXTRACTABLE = false). + // Use PKCS#11 vendor tools for HSM key lifecycle management. + if uid_or_tags.starts_with("hsm::") { + return Err(KmsError::NotSupported( + "Re-Key Key Pair is not supported for HSM-managed keys. \ + Use PKCS#11 vendor tools or the HSM administration console \ + to manage HSM key lifecycle." + .to_owned(), + )); + } + + let candidates = retrieve_eligible_keys(kms, uid_or_tags, ObjectType::PrivateKey).await?; + + let owm = select_unique_key::(candidates, uid_or_tags, kms, user, |owm| { + // Validate no crypto param changes + validate_no_crypto_param_change( + owm.attributes(), + [ + request.common_attributes.as_ref(), + request.private_key_attributes.as_ref(), + request.public_key_attributes.as_ref(), + ], + "ReKeyKeyPair", + )?; + Ok(()) + }) + .await?; + + // Reject Re-Key on a retired (non-latest) member of a named keyset. + if !is_keyset_latest(kms, owm.id(), owm.attributes(), user).await? { + return Err(KmsError::InvalidRequest(format!( + "ReKeyKeyPair: key '{}' is not the latest in its keyset — only the \ + latest generation can be rotated", + owm.id() + ))); + } + + // Resolve paired public key (post-selection: only for the winning candidate) + let old_sk_uid = owm.id().to_owned(); + let old_pk_uid = resolve_public_key_uid(&owm)?; + let old_pk_owm = retrieve_linked_public_key(kms, &old_pk_uid).await?; + + Ok([ + RotationCandidate { + uid: old_sk_uid, + object_type: ObjectType::PrivateKey, + owm, + }, + RotationCandidate { + uid: old_pk_uid, + object_type: ObjectType::PublicKey, + owm: old_pk_owm, + }, + ]) + } + + async fn generate_replacement( + &self, + kms: &KMS, + candidates: &[RotationCandidate; 2], + ) -> KResult<[ReplacementObject; 2]> { + let [sk_candidate, pk_candidate] = candidates; + + let common_attrs = + clean_attributes_for_generation(sk_candidate.owm.attributes(), kms.vendor_id()); + let new_sk_uid = compute_rotation_uid(&sk_candidate.uid); + let new_pk_uid = compute_rotation_uid(&pk_candidate.uid); + + // Propagate the CryptographicUsageMask from the old keys so that + // FIPS-mode key-pair generators receive the required mask value. + let sk_mask = sk_candidate + .owm + .attributes() + .cryptographic_usage_mask + .or_else(|| { + sk_candidate + .owm + .object() + .attributes() + .ok() + .and_then(|a| a.cryptographic_usage_mask) + }); + let pk_mask = pk_candidate + .owm + .attributes() + .cryptographic_usage_mask + .or_else(|| { + pk_candidate + .owm + .object() + .attributes() + .ok() + .and_then(|a| a.cryptographic_usage_mask) + }); + let private_key_attributes = sk_mask.map(|m| Attributes { + cryptographic_usage_mask: Some(m), + ..Attributes::default() + }); + let public_key_attributes = pk_mask.map(|m| Attributes { + cryptographic_usage_mask: Some(m), + ..Attributes::default() + }); + + let create_kp_request = CreateKeyPair { + common_attributes: Some(common_attrs), + private_key_attributes, + 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 = prepare_replacement_attributes( + sk_candidate.owm.attributes(), + &sk_candidate.uid, + self.offset, + )?; + let new_pk_attributes = prepare_replacement_attributes( + pk_candidate.owm.attributes(), + &pk_candidate.uid, + self.offset, + )?; + + let pk_new_uid = replacements[1].new_uid.clone(); + let sk_new_uid = replacements[0].new_uid.clone(); + + let [sk_rep, pk_rep] = replacements; + prepare_sk_replacement( + sk_rep, + &new_sk_attributes, + sk_candidate, + &pk_new_uid, + kms.vendor_id(), + )?; + set_rotation_metadata_on_new_key(&mut sk_rep.attributes, sk_candidate.owm.attributes())?; + + prepare_pk_replacement( + pk_rep, + &new_pk_attributes, + pk_candidate, + &sk_new_uid, + kms.vendor_id(), + )?; + + 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()), + } + } +} + +// ─── Private helpers ───────────────────────────────────────────────────────── + +/// Finalize the private key replacement: lifecycle setup, cross-link, and wrapping key. +fn prepare_sk_replacement( + sk: &mut ReplacementObject, + new_attrs: &Attributes, + candidate: &RotationCandidate, + pk_new_uid: &str, + vendor_id: &str, +) -> KResult<()> { + finalize_replacement_key( + sk, + new_attrs, + ObjectType::PrivateKey, + &candidate.uid, + Some((pk_new_uid, LinkType::PublicKeyLink)), + vendor_id, + )?; + preserve_wrapping_key_link(candidate.owm.object(), &mut sk.attributes); + Ok(()) +} + +/// Finalize the public key replacement: lifecycle setup, cross-link, wrapping key, and `rewrap_to`. +fn prepare_pk_replacement( + pk: &mut ReplacementObject, + new_attrs: &Attributes, + candidate: &RotationCandidate, + sk_new_uid: &str, + vendor_id: &str, +) -> KResult<()> { + finalize_replacement_key( + pk, + new_attrs, + ObjectType::PublicKey, + &candidate.uid, + Some((sk_new_uid, LinkType::PrivateKeyLink)), + vendor_id, + )?; + preserve_wrapping_key_link(candidate.owm.object(), &mut pk.attributes); + // Public key IS a wrapping key — dependants get re-wrapped to it + pk.rewrap_to = Some(pk.new_uid.clone()); + Ok(()) +} + +/// Follow `PublicKeyLink` on the private key to resolve the paired public key UID. +fn resolve_public_key_uid( + owm: &cosmian_kms_server_database::reexport::cosmian_kms_interfaces::ObjectWithMetadata, +) -> KResult { + owm.attributes() + .get_link(LinkType::PublicKeyLink) + .map(|l| l.to_string()) + .ok_or_else(|| { + KmsError::InvalidRequest( + "ReKeyKeyPair: the private key has no PublicKeyLink. Cannot determine the \ + paired public key." + .to_owned(), + ) + }) +} + +/// Retrieve the linked public key from the database. +async fn retrieve_linked_public_key( + kms: &KMS, + pk_uid: &str, +) -> KResult { + kms.database + .retrieve_objects(pk_uid) + .await? + .into_values() + .next() + .ok_or_else(|| { + KmsError::Kmip21Error( + ErrorReason::Item_Not_Found, + format!("ReKeyKeyPair: linked public key '{pk_uid}' not found in database"), + ) + }) +} 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..71ed1fff07 --- /dev/null +++ b/crate/server/src/core/operations/rekey/mod.rs @@ -0,0 +1,20 @@ +//! 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, compute_rotation_uid, + enforce_privileged_user, execute_rekey, prepare_replacement_attributes, + set_rotation_metadata_on_new_key, update_old_key_after_rekey, +}; +pub(crate) use keypair::rekey_keypair; +pub(crate) use symmetric::rekey; diff --git a/crate/server/src/core/operations/rekey/symmetric.rs b/crate/server/src/core/operations/rekey/symmetric.rs new file mode 100644 index 0000000000..ae1c3fab20 --- /dev/null +++ b/crate/server/src/core/operations/rekey/symmetric.rs @@ -0,0 +1,378 @@ +use cosmian_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::State, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attributes, + kmip_objects::ObjectType, + kmip_operations::{Create, ReKey, ReKeyResponse}, + kmip_types::UniqueIdentifier, + }, + }, + cosmian_kms_interfaces::ObjectWithMetadata, +}; +use cosmian_logger::trace; +use time::OffsetDateTime; + +use super::common::{ + RekeyOperation, ReplacementObject, RotationCandidate, clean_attributes_for_generation, + compute_rotation_uid, enforce_privileged_user, execute_rekey, finalize_replacement_key, + is_keyset_latest, prepare_replacement_attributes, preserve_wrapping_key_link, + retrieve_eligible_keys, set_rotation_metadata_on_new_key, validate_no_crypto_param_change, +}; +use crate::{ + core::{ + KMS, + operations::key_ops::{key_resolution::KeySelectionSpec, reject_protection_storage_masks}, + uid_utils::has_prefix, + }, + error::KmsError, + result::{KResult, KResultHelper}, +}; + +/// Implementor of [`RekeyOperation`] for KMIP `ReKey` (KMIP 2.1 §6.1.46) on symmetric keys. +pub(crate) struct SymmetricRekey { + /// The `Offset` from the `ReKey` request — an interval added to the new key's + /// `Initial Date` to compute its `Activation Date` (KMIP 2.1 §6.1.46 Table 305). + offset: Option, +} + +impl KeySelectionSpec for SymmetricRekey { + const KMIP_OP: KmipOperation = KmipOperation::Rekey; + const OP_NAME: &'static str = "ReKey"; + + fn accepted_states() -> &'static [State] { + &[State::Active, State::Deactivated, State::Compromised] + } + + fn strict_permission_check() -> bool { + true + } + + fn is_key_eligible(owm: &ObjectWithMetadata, _vendor_id: &str) -> bool { + owm.object().object_type() == ObjectType::SymmetricKey + } +} + +/// KMIP `ReKey` operation for symmetric keys (KMIP 2.1 §6.1.46). +/// +/// - For regular (SQL) keys: generates fresh key material, handles wrapping, links generations. +/// - For HSM-resident keys (UID starts with `hsm::`): calls `C_GenerateKey` on the same HSM +/// slot, assigns a generation-suffix UID (`original::N+1`), and updates `CKA_LABEL` / +/// `CKA_START_DATE` / `CKA_END_DATE` on both the old and new keys. +pub(crate) async fn rekey(kms: &KMS, request: ReKey, owner: &str) -> KResult { + trace!("ReKey: {}", serde_json::to_string(&request)?); + let uid_or_tags = request + .unique_identifier + .as_ref() + .ok_or(KmsError::UnsupportedPlaceholder)? + .as_str() + .context("ReKey: the unique identifier must be a string")?; + + // Route HSM-resident keys through the dedicated PKCS#11 rotation path. + // The general RekeyOperation pipeline is designed for SQL-backed keys and + // is not applicable to non-extractable HSM key material. + if has_prefix(uid_or_tags).is_some() { + return rekey_hsm_symmetric(kms, uid_or_tags, owner).await; + } + + Box::pin(execute_rekey( + &SymmetricRekey { + offset: request.offset, + }, + kms, + &request, + owner, + )) + .await +} + +/// Rotate an HSM-resident AES symmetric key. +/// +/// ## Rotation algorithm +/// +/// 1. Validate that the caller has `Rekey` permission. +/// 2. Retrieve the old key's metadata from the HSM (algorithm, length, sensitivity, +/// keyset info from `CKA_LABEL`, rotation interval from `CKA_START_DATE`/`CKA_END_DATE`). +/// 3. Compute the new generation number and new `key_id`/UID +/// (`base_key_id::new_gen`, `prefix::slot::base_key_id::new_gen`). +/// 4. Generate the new key on the same HSM slot via `C_GenerateKey` (`create_key`). +/// 5. Infer the rotation interval from the old key's dates; stamp new `CKA_START_DATE` / +/// `CKA_END_DATE` on the new key if an interval is known. +/// 6. Update `CKA_LABEL` on the old key (strip `::latest` suffix) and on the new key +/// (append `::latest`). +async fn rekey_hsm_symmetric(kms: &KMS, uid: &str, user: &str) -> KResult { + enforce_privileged_user(kms, user).await?; + + // Retrieve old key metadata from the HSM. + let old_owm = kms + .database + .retrieve_object(uid) + .await? + .ok_or_else(|| KmsError::InvalidRequest(format!("HSM key '{uid}' not found")))?; + + if old_owm.object().object_type() != ObjectType::SymmetricKey { + return Err(KmsError::NotSupported( + "HSM ReKey is currently supported for AES symmetric keys only".to_owned(), + )); + } + + let old_attrs = old_owm.attributes(); + + // Reject Re-Key on a retired (non-latest) member of a named keyset. + // Keys without a rotate_name are not keyset members and may be freely re-keyed. + if old_attrs.rotate_name.is_some() && !is_keyset_latest(kms, uid, old_attrs, user).await? { + return Err(KmsError::InvalidRequest(format!( + "ReKey: HSM key '{uid}' is not the latest in its keyset — only the latest \ + generation can be rotated" + ))); + } + + // Parse the UID to extract prefix, slot_id, and key_id. + // For `hsm::softhsm2::0::mykey` → prefix = `"hsm::softhsm2"`, rest = `"0::mykey"`. + let prefix = has_prefix(uid) + .ok_or_else(|| KmsError::InvalidRequest(format!("UID '{uid}' is not an HSM UID")))?; + let rest = uid + .strip_prefix(&format!("{prefix}::")) + .ok_or_else(|| KmsError::InvalidRequest("HSM UID has unexpected format".to_owned()))?; + let (slot_str, key_id) = rest.split_once("::").ok_or_else(|| { + KmsError::InvalidRequest(format!( + "HSM UID '{uid}' must have format '{prefix}::::'" + )) + })?; + let slot_id: usize = slot_str.parse().map_err(|e| { + KmsError::InvalidRequest(format!("HSM slot_id '{slot_str}' is not valid: {e}")) + })?; + + // Compute the new generation and new key_id. + // `key_id` may already contain a generation suffix: `"base_id::N"`. + // We split on the last `::` to find any existing numeric generation suffix. + let (base_id, old_gen) = key_id + .rsplit_once("::") + .map_or((key_id, 0), |(base, suffix)| { + suffix.parse::().map_or((key_id, 0), |n| (base, n)) + }); + + let new_gen = old_gen + 1; + let new_key_id = format!("{base_id}::{new_gen}"); + let new_uid = format!("{prefix}::{slot_id}::{new_key_id}"); + + // Retrieve old rotate metadata from the HSM (via stub attributes). + // Fall back gracefully if CKA_LABEL metadata is absent. + let (rotate_name, old_rotate_gen) = ( + old_attrs.rotate_name.clone(), + old_attrs.rotate_generation.unwrap_or(old_gen), + ); + + // 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. + kms.database + .create( + Some(new_uid.clone()), + user, + &old_owm.object().clone(), + old_attrs, + &std::collections::HashSet::new(), + ) + .await + .map_err(|e| { + KmsError::InvalidRequest(format!("Failed to generate new HSM key '{new_uid}': {e}")) + })?; + + // Stamp rotation dates on the new key. + if let Some(days) = interval_days { + let today = OffsetDateTime::now_utc().date(); + let end = today + time::Duration::days(days); + kms.database + .set_key_rotation_dates(&new_uid, Some(today), Some(end)) + .await + .map_err(|e| { + KmsError::InvalidRequest(format!( + "Failed to set rotation dates on new HSM key '{new_uid}': {e}" + )) + })?; + } + + // Update CKA_LABEL on old key (remove ::latest) and new key (add ::latest). + // Use `base_id` (without generation suffix) in the label — the generation is + // already in its own field, and including a generation-suffixed key_id would + // introduce extra `::` delimiters that break `parse_label_metadata()`. + if let Some(ref name) = rotate_name { + let old_label_retired = format!("{name}::{old_rotate_gen}::{base_id}"); + let new_label_latest = format!("{name}::{new_gen}::{base_id}"); + + kms.database + .set_key_label(uid, &old_label_retired) + .await + .map_err(|e| { + KmsError::InvalidRequest(format!( + "Failed to update CKA_LABEL on old HSM key '{uid}': {e}" + )) + })?; + kms.database + .set_key_label(&new_uid, &new_label_latest) + .await + .map_err(|e| { + KmsError::InvalidRequest(format!( + "Failed to set CKA_LABEL on new HSM key '{new_uid}': {e}" + )) + })?; + } + + trace!("HSM ReKey: old={uid} → new={new_uid} (slot={slot_id}, gen={new_gen}), user={user}"); + + Ok(ReKeyResponse { + unique_identifier: UniqueIdentifier::TextString(new_uid), + }) +} + +impl RekeyOperation for SymmetricRekey { + type 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]> { + use crate::core::operations::key_ops::key_resolution::select_unique_key; + + reject_protection_storage_masks(request.protection_storage_masks.is_some())?; + + enforce_privileged_user(kms, 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 KMIP: they have no KMIP attribute + // storage and are often non-extractable (CKA_EXTRACTABLE = false). + // Use PKCS#11 vendor tools for HSM key lifecycle management. + if uid_or_tags.starts_with("hsm::") { + return Err(KmsError::NotSupported( + "Re-Key is not supported for HSM-managed keys. \ + Use PKCS#11 vendor tools or the HSM administration console \ + to manage HSM key lifecycle." + .to_owned(), + )); + } + + let candidates = retrieve_eligible_keys(kms, uid_or_tags, ObjectType::SymmetricKey).await?; + + let owm = select_unique_key::(candidates, uid_or_tags, kms, user, |owm| { + // Reject requests that attempt to change crypto parameters + validate_no_crypto_param_change( + owm.attributes(), + [request.attributes.as_ref()], + "ReKey", + )?; + Ok(()) + }) + .await?; + + // Reject Re-Key on a retired (non-latest) member of a named keyset. + if !is_keyset_latest(kms, owm.id(), owm.attributes(), user).await? { + return Err(KmsError::InvalidRequest(format!( + "ReKey: key '{}' is not the latest in its keyset — only the latest \ + generation can be rotated", + owm.id() + ))); + } + + let uid = owm.id().to_owned(); + Ok([RotationCandidate { + owm, + uid, + object_type: ObjectType::SymmetricKey, + }]) + } + + 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 = + clean_attributes_for_generation(candidate.owm.attributes(), 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 = compute_rotation_uid(&candidate.uid); + + 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 = prepare_replacement_attributes( + candidate.owm.attributes(), + &candidate.uid, + self.offset, + )?; + + finalize_replacement_key( + replacement, + &new_attrs, + ObjectType::SymmetricKey, + &candidate.uid, + None, + kms.vendor_id(), + )?; + + // Preserve WrappingKeyLink if the old key was wrapped + preserve_wrapping_key_link(candidate.owm.object(), &mut replacement.attributes); + + // Set rotation metadata + set_rotation_metadata_on_new_key(&mut replacement.attributes, candidate.owm.attributes())?; + + // Rewrap dependants to the NEW key + replacement.rewrap_to = Some(replacement.new_uid.clone()); + + Ok(()) + } + + fn build_response(&self, replacements: &[ReplacementObject; 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..d69b86c19e 100644 --- a/crate/server/src/core/operations/revoke.rs +++ b/crate/server/src/core/operations/revoke.rs @@ -24,7 +24,7 @@ use crate::core::cover_crypt::revoke_user_decryption_keys; use crate::{ core::{ KMS, - operations::key_ops::{ObjectWithMetadataOps, record_cascading_metrics}, + operations::key_ops::{lifecycle::user_can_perform_operation, record_cascading_metrics}, uid_utils::{has_prefix, uids_from_unique_identifier}, }, error::KmsError, @@ -160,10 +160,7 @@ 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) - .await? - { + if !user_can_perform_operation(&owm, user, &KmipOperation::Revoke, kms).await? { continue; } count += 1; @@ -175,12 +172,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 => { @@ -223,12 +220,12 @@ pub(crate) async fn recursively_revoke_key( } } } - revoke_key_core( + Box::pin(revoke_key_core( owm, revocation_reason.clone(), compromise_occurrence_date, kms, - ) + )) .await?; } ObjectType::PublicKey => { @@ -257,12 +254,12 @@ pub(crate) async fn recursively_revoke_key( } } } - 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..d0e0564d63 100644 --- a/crate/server/src/core/operations/sign.rs +++ b/crate/server/src/core/operations/sign.rs @@ -23,7 +23,7 @@ use openssl::pkey::{Id, PKey, Private}; use crate::{ core::{ KMS, - operations::{CryptoOpSpec, has_usage_mask, perform_crypto_operation}, + operations::{CryptoOpSpec, perform_crypto_operation}, }, error::KmsError, kms_bail, @@ -54,7 +54,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 } diff --git a/crate/server/src/core/operations/signature_verify.rs b/crate/server/src/core/operations/signature_verify.rs index 77734896d2..ce616a162d 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, perform_crypto_operation}, }, 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, } 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..1b0634a7b0 100644 --- a/crate/server/src/core/uid_utils.rs +++ b/crate/server/src/core/uid_utils.rs @@ -1,6 +1,9 @@ use std::collections::HashSet; -use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::UniqueIdentifier; +use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::kmip_types::{ + LinkType, UniqueIdentifier, +}; +use cosmian_logger::trace; use crate::{ config::HsmInstanceParams, @@ -74,8 +77,317 @@ pub(super) async fn uids_from_unique_identifier( Ok(HashSet::from([uid_or_tags.to_owned()])) } +// ─── Keyset Resolution ─────────────────────────────────────────────────────── + +/// The result of parsing a keyset identifier (`name@version` syntax). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum KeysetVersion { + /// `name@latest` — resolve to the key with `rotate_latest=true` + Latest, + /// `name@first` or `name@0` — resolve to generation 0 + First, + /// `name@N` — resolve to a specific generation number + Generation(i32), + /// Bare `name` (no `@` suffix) — interpretation depends on operation mode + Bare, +} + +/// Parsed keyset reference. +#[derive(Debug, Clone)] +pub(crate) struct KeysetRef { + pub name: String, + pub version: KeysetVersion, +} + +/// Returns `true` if the string looks like a UUID (8-4-4-4-12 hex pattern). +fn looks_like_uuid(s: &str) -> bool { + // Quick length check: standard UUID is 36 chars + if s.len() != 36 { + return false; + } + s.chars().enumerate().all(|(i, c)| match i { + 8 | 13 | 18 | 23 => c == '-', + _ => c.is_ascii_hexdigit(), + }) +} + +/// Try to parse an identifier as a keyset reference. +/// +/// A keyset reference is recognized when: +/// - It is NOT a tag JSON (`[...]`) +/// - It is NOT an HSM prefix (`hsm::...`) +/// - It is NOT a UUID +/// - It contains `@` (explicit version) OR is a bare name (fallback resolution) +/// +/// Returns `None` if the identifier doesn't match keyset syntax. +pub(crate) fn parse_keyset_identifier(identifier: &str) -> Option { + // Skip tags and UUIDs. + // HSM UIDs with an `@` suffix ARE valid keyset references: the user addresses + // the keyset by its rotate_name, not by an hsm:: UID. + if identifier.starts_with('[') { + return None; + } + // A bare `hsm::...` UID (no `@`) is a direct key address, not a keyset ref. + if identifier.starts_with("hsm::") && !identifier.contains('@') { + return None; + } + + if let Some(at_pos) = identifier.rfind('@') { + let name = &identifier[..at_pos]; + let version_str = &identifier[at_pos + 1..]; + + // If the part before @ looks like a UUID, it's not a keyset reference + if looks_like_uuid(name) { + return None; + } + + // Empty name is not valid + if name.is_empty() { + return None; + } + + let version = match version_str { + "latest" => KeysetVersion::Latest, + "first" => KeysetVersion::First, + s => { + if let Ok(n) = s.parse::() { + if n == 0 { + KeysetVersion::First + } else { + KeysetVersion::Generation(n) + } + } else { + // Invalid version specifier — not a keyset reference + return None; + } + } + }; + + Some(KeysetRef { + name: name.to_owned(), + version, + }) + } else { + // No `@` — could be a bare keyset name if it's not a UUID + if looks_like_uuid(identifier) { + return None; + } + // It could be a plain UID that isn't a UUID (e.g. user-chosen IDs). + // We return a Bare keyset reference — the caller will attempt DB lookup + // and fall back to direct UID if the keyset name doesn't exist. + Some(KeysetRef { + name: identifier.to_owned(), + version: KeysetVersion::Bare, + }) + } +} + +/// Resolve a keyset identifier to a single UID (for encrypt/sign operations). +/// +/// For `@latest` or `Bare` mode, resolves to the key with 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)) + } + } +} + +/// Walk the keyset rotation chain from the latest key backward. +/// +/// Resolves the key with the highest `rotate_generation` for the given +/// `rotate_name`, then follows `ReplacedObjectLink` backward, collecting UIDs +/// in newest-to-oldest order. +/// +/// Stops when: +/// - No more `ReplacedObjectLink` is found (reached the original key) +/// - A cycle is detected (via a visited-set guard) +/// +/// The traversal is unbounded by design: since the KMS holds a finite number of +/// keys and cycle detection prevents infinite loops, all generations remain reachable +/// for decryption regardless of how many rotations have occurred. +/// +/// Returns the ordered list of UIDs to try for decryption. +pub(crate) async fn walk_keyset_chain( + keyset_name: &str, + kms: &KMS, + user: &str, +) -> KResult> { + // Find all members of the keyset and pick the one with the highest generation. + let results = kms + .database + .find_by_rotate_name(keyset_name, None, user) + .await?; + + let Some((latest_uid, _)) = results + .into_iter() + .max_by_key(|(_, attrs)| attrs.rotate_generation.unwrap_or(0)) + else { + return Ok(vec![]); + }; + + // HSM keys store all rotation metadata in PKCS#11 attributes — there are no + // ReplacedObjectLink back-pointers. Fetch every generation and sort newest-first. + if latest_uid.starts_with("hsm::") { + let all_results = kms + .database + .find_by_rotate_name(keyset_name, None, user) + .await?; + let mut all_pairs: Vec<(String, i32)> = all_results + .into_iter() + .map(|(uid, attrs)| (uid, attrs.rotate_generation.unwrap_or(0))) + .collect(); + all_pairs.sort_by(|a, b| b.1.cmp(&a.1)); + let chain: Vec = all_pairs.into_iter().map(|(uid, _)| uid).collect(); + trace!( + "walk_keyset_chain: HSM keyset '{}' has {} keys in chain", + keyset_name, + chain.len() + ); + return Ok(chain); + } + + let mut chain = vec![latest_uid.clone()]; + let mut current_uid = latest_uid; + let mut visited: HashSet = chain.iter().cloned().collect(); + + loop { + // Retrieve the current object's attributes to find ReplacedObjectLink + let Some(owm) = kms.database.retrieve_object(¤t_uid).await? else { + break; + }; + + // Look for ReplacedObjectLink (points backward to the older key) + let prev_uid = owm + .attributes() + .get_link(LinkType::ReplacedObjectLink) + .map(|link| link.to_string()); + + let Some(prev_uid) = prev_uid else { + break; // End of chain + }; + + // Cycle detection + if !visited.insert(prev_uid.clone()) { + trace!( + "walk_keyset_chain: cycle detected at {} for keyset '{}'", + prev_uid, keyset_name + ); + break; + } + + chain.push(prev_uid.clone()); + current_uid = prev_uid; + } + + trace!( + "walk_keyset_chain: keyset '{}' has {} keys in chain", + keyset_name, + chain.len() + ); + + Ok(chain) +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + #[cfg(test)] +#[expect(clippy::expect_used)] mod tests { + use super::*; + + #[test] + fn test_parse_keyset_latest() { + let r = parse_keyset_identifier("my-keys@latest").expect("should parse"); + assert_eq!(r.name, "my-keys"); + assert_eq!(r.version, KeysetVersion::Latest); + } + + #[test] + fn test_parse_keyset_first() { + let r = parse_keyset_identifier("my-keys@first").expect("should parse"); + assert_eq!(r.name, "my-keys"); + assert_eq!(r.version, KeysetVersion::First); + + let r = parse_keyset_identifier("my-keys@0").expect("should parse"); + assert_eq!(r.name, "my-keys"); + assert_eq!(r.version, KeysetVersion::First); + } + + #[test] + fn test_parse_keyset_generation() { + let r = parse_keyset_identifier("my-keys@3").expect("should parse"); + assert_eq!(r.name, "my-keys"); + assert_eq!(r.version, KeysetVersion::Generation(3)); + } + + #[test] + fn test_parse_keyset_bare() { + let r = parse_keyset_identifier("my-production-key").expect("should parse"); + assert_eq!(r.name, "my-production-key"); + assert_eq!(r.version, KeysetVersion::Bare); + } + + #[test] + fn test_parse_uuid_not_keyset() { + assert!(parse_keyset_identifier("550e8400-e29b-41d4-a716-446655440000").is_none()); + } + + #[test] + fn test_parse_tags_not_keyset() { + assert!(parse_keyset_identifier("[\"tag1\",\"tag2\"]").is_none()); + } + + #[test] + fn test_parse_hsm_not_keyset() { + assert!(parse_keyset_identifier("hsm::softhsm2::0::my-key").is_none()); + } + + #[test] + fn test_parse_invalid_version() { + // "abc" after @ is not a valid version specifier + assert!(parse_keyset_identifier("my-key@abc").is_none()); + } + + #[test] + fn test_parse_uuid_with_at() { + // A UUID followed by @latest should NOT be treated as keyset + assert!(parse_keyset_identifier("550e8400-e29b-41d4-a716-446655440000@latest").is_none()); + } +} + +#[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 411f16a927..569d809677 100644 --- a/crate/server/src/main.rs +++ b/crate/server/src/main.rs @@ -349,6 +349,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#" @@ -364,6 +366,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 1c5db1ce1c..3583e6e3d2 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!( @@ -997,6 +1006,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::*; @@ -1302,7 +1311,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}" @@ -1340,7 +1351,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..4d85ce85b1 --- /dev/null +++ b/crate/server/src/tests/rest_crypto/key_state.rs @@ -0,0 +1,254 @@ +//! Tests that cryptographic operations respect KMIP 2.1 §3.31 state rules: +//! +//! - **Protection operations** (Encrypt, Sign, MAC) require `Active` state. +//! - **Processing operations** (Decrypt, Verify, MACVerify) accept +//! `Active`, `Deactivated`, and `Compromised` states. + +use actix_web::{http::StatusCode, test}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use cosmian_kms_server_database::reexport::cosmian_kmip::{ + kmip_0::kmip_types::{RevocationReason, RevocationReasonCode}, + kmip_2_1::{ + extra::tagging::{EMPTY_TAGS, VENDOR_ID_COSMIAN}, + kmip_operations::{CreateResponse, Revoke, RevokeResponse}, + kmip_types::{CryptographicAlgorithm, UniqueIdentifier}, + requests::symmetric_key_create_request, + }, +}; +use cosmian_logger::log_init; +use serde_json::json; + +use crate::{result::KResult, tests::test_utils}; + +/// Create an AES-256 key and return its UID. +async fn create_aes_key(app: &S) -> KResult +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let create_req = symmetric_key_create_request( + VENDOR_ID_COSMIAN, + None, + 256, + CryptographicAlgorithm::AES, + EMPTY_TAGS, + false, + None, + )?; + let cr: CreateResponse = test_utils::post_2_1(app, create_req).await?; + Ok(cr.unique_identifier.to_string()) +} + +/// Encrypt plaintext with the given key, returning the JSON encrypt response. +async fn encrypt(app: &S, kid: &str, plaintext: &[u8]) -> KResult +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let data_b64 = URL_SAFE_NO_PAD.encode(plaintext); + test_utils::post_json_with_uri( + app, + json!({"kid": kid, "alg": "dir", "enc": "A256GCM", "data": data_b64}), + "/v1/crypto/encrypt", + ) + .await +} + +/// Attempt to decrypt the given ciphertext response; returns Ok(value) on success. +async fn try_decrypt( + app: &S, + enc_resp: &serde_json::Value, +) -> Result +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let req = test::TestRequest::post() + .uri("/v1/crypto/decrypt") + .set_json(&json!({ + "protected": enc_resp["protected"], + "iv": enc_resp["iv"], + "ciphertext": enc_resp["ciphertext"], + "tag": enc_resp["tag"], + })) + .to_request(); + let resp = test::call_service(app, req).await; + if resp.status() == StatusCode::OK { + let body = test::read_body(resp).await; + Ok(serde_json::from_slice(&body).unwrap_or_default()) + } else { + Err(resp.status()) + } +} + +/// Attempt to encrypt; returns Ok on 200, Err(status) otherwise. +async fn try_encrypt( + app: &S, + kid: &str, + plaintext: &[u8], +) -> Result +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let data_b64 = URL_SAFE_NO_PAD.encode(plaintext); + let req = test::TestRequest::post() + .uri("/v1/crypto/encrypt") + .set_json(&json!({"kid": kid, "alg": "dir", "enc": "A256GCM", "data": data_b64})) + .to_request(); + let resp = test::call_service(app, req).await; + if resp.status() == StatusCode::OK { + let body = test::read_body(resp).await; + Ok(serde_json::from_slice(&body).unwrap_or_default()) + } else { + Err(resp.status()) + } +} + +/// Deactivate a key via KMIP `Revoke` with `CessationOfOperation` reason. +async fn deactivate_key(app: &S, kid: &str) -> KResult<()> +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let revoke = Revoke { + unique_identifier: Some(UniqueIdentifier::TextString(kid.to_owned())), + revocation_reason: RevocationReason { + revocation_reason_code: RevocationReasonCode::CessationOfOperation, + revocation_message: Some("test deactivation".to_owned()), + }, + compromise_occurrence_date: None, + cascade: false, + }; + let _resp: RevokeResponse = test_utils::post_2_1(app, revoke).await?; + Ok(()) +} + +/// Compromise a key via KMIP `Revoke` with `KeyCompromise` reason. +async fn compromise_key(app: &S, kid: &str) -> KResult<()> +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, + B: actix_web::body::MessageBody, +{ + let revoke = Revoke { + unique_identifier: Some(UniqueIdentifier::TextString(kid.to_owned())), + revocation_reason: RevocationReason { + revocation_reason_code: RevocationReasonCode::KeyCompromise, + revocation_message: Some("test compromise".to_owned()), + }, + compromise_occurrence_date: None, + cascade: false, + }; + let _resp: RevokeResponse = test_utils::post_2_1(app, revoke).await?; + Ok(()) +} + +/// KMIP 2.1 §3.31: Decrypt MUST work on a Deactivated key. +#[tokio::test] +async fn test_decrypt_deactivated_key_succeeds() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + let enc_resp = encrypt(&app, &kid, b"secret data").await?; + + // Deactivate the key + deactivate_key(&app, &kid).await?; + + // Decrypt must still work per KMIP 2.1 §3.31 + let dec_result = try_decrypt(&app, &enc_resp).await; + assert!( + dec_result.is_ok(), + "Decrypt with deactivated key should succeed per KMIP 2.1 §3.31, got: {dec_result:?}" + ); + + Ok(()) +} + +/// KMIP 2.1 §3.31: Decrypt SHOULD work on a Compromised key. +#[tokio::test] +async fn test_decrypt_compromised_key_succeeds() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + let enc_resp = encrypt(&app, &kid, b"secret data").await?; + + // Compromise the key + compromise_key(&app, &kid).await?; + + // Decrypt must still work per KMIP 2.1 §3.31 + let dec_result = try_decrypt(&app, &enc_resp).await; + assert!( + dec_result.is_ok(), + "Decrypt with compromised key should succeed per KMIP 2.1 §3.31, got: {dec_result:?}" + ); + + Ok(()) +} + +/// KMIP 2.1 §3.31: Encrypt MUST NOT work on a Deactivated key. +#[tokio::test] +async fn test_encrypt_deactivated_key_rejected() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + + // Deactivate the key + deactivate_key(&app, &kid).await?; + + // Encrypt must be rejected (protection operation on non-Active key) + let enc_result = try_encrypt(&app, &kid, b"will not encrypt").await; + assert!( + enc_result.is_err(), + "Encrypt with deactivated key should fail per KMIP 2.1 §3.31" + ); + + Ok(()) +} + +/// KMIP 2.1 §3.31: Encrypt MUST NOT work on a Compromised key. +#[tokio::test] +async fn test_encrypt_compromised_key_rejected() -> KResult<()> { + log_init(None); + let app = test_utils::test_app(None).await; + + let kid = create_aes_key(&app).await?; + + // Compromise the key + compromise_key(&app, &kid).await?; + + // Encrypt must be rejected (protection operation on non-Active key) + let enc_result = try_encrypt(&app, &kid, b"will not encrypt").await; + assert!( + enc_result.is_err(), + "Encrypt with compromised key should fail per KMIP 2.1 §3.31" + ); + + Ok(()) +} diff --git a/crate/server/src/tests/rest_crypto/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..712dcb3679 100644 --- a/crate/server/src/tests/security_regression.rs +++ b/crate/server/src/tests/security_regression.rs @@ -40,7 +40,7 @@ async fn create_aes_key(kms: &KMS, user: &str) -> KResult { None, ) .map_err(|e| crate::error::KmsError::InvalidRequest(e.to_string()))?; - let response = kms.create(request, user, None).await?; + let response = kms.create(request, user).await?; Ok(response.unique_identifier) } @@ -195,10 +195,7 @@ async fn test_mac_no_hmac_value_in_traces() -> KResult<()> { None, ) .map_err(|e| crate::error::KmsError::InvalidRequest(e.to_string()))?; - let key_id = kms - .create(request, "test_user", None) - .await? - .unique_identifier; + let key_id = kms.create(request, "test_user").await?.unique_identifier; let message = b"MESSAGE_WHOSE_MAC_MUST_NOT_BE_LOGGED_IN_FULL"; @@ -251,10 +248,7 @@ async fn test_decrypt_preserves_kek_wrapping_with_usage_limits() -> KResult<()> None, ) .map_err(|e| crate::error::KmsError::InvalidRequest(e.to_string()))?; - let kek_id = kms - .create(kek_request, owner, None) - .await? - .unique_identifier; + let kek_id = kms.create(kek_request, owner).await?.unique_identifier; drop(kms); // Phase 2: re-instantiate KMS with KEK configured @@ -279,10 +273,7 @@ async fn test_decrypt_preserves_kek_wrapping_with_usage_limits() -> KResult<()> usage_limits_count: None, usage_limits_total: 100, }); - let dek_id = kms - .create(dek_request, owner, None) - .await? - .unique_identifier; + let dek_id = kms.create(dek_request, owner).await?.unique_identifier; // Verify the DEK is stored wrapped let raw_object_before = kms @@ -383,10 +374,7 @@ async fn test_sign_preserves_kek_wrapping_with_usage_limits() -> KResult<()> { None, ) .map_err(|e| crate::error::KmsError::InvalidRequest(e.to_string()))?; - let kek_id = kms - .create(kek_request, owner, None) - .await? - .unique_identifier; + let kek_id = kms.create(kek_request, owner).await?.unique_identifier; drop(kms); // Phase 2: re-instantiate KMS with KEK @@ -413,7 +401,7 @@ async fn test_sign_preserves_kek_wrapping_with_usage_limits() -> KResult<()> { usage_limits_total: 100, }); } - let create_response = kms.create_key_pair(create_request, owner, None).await?; + let create_response = kms.create_key_pair(create_request, owner).await?; let private_key_id = create_response.private_key_unique_identifier; // Verify the private key is stored wrapped diff --git a/crate/server/src/tests/test_sign.rs b/crate/server/src/tests/test_sign.rs index b4729a4609..e00c22eff9 100644 --- a/crate/server/src/tests/test_sign.rs +++ b/crate/server/src/tests/test_sign.rs @@ -184,7 +184,7 @@ async fn test_sign_rsa() -> KResult<()> { false, // sensitive None, // wrapping_key_id )?; - let response = kms.create_key_pair(request, owner, None).await?; + let response = kms.create_key_pair(request, owner).await?; // Test single-call signature test_single_signature( @@ -222,7 +222,7 @@ async fn test_sign_ec_curve(curve: RecommendedCurve, test_name: &str) -> KResult false, // sensitive None, // wrapping_key_id )?; - let response = kms.create_key_pair(request, owner, None).await?; + let response = kms.create_key_pair(request, owner).await?; // Test single-call signature test_single_signature( diff --git a/crate/server/src/tests/test_utils.rs b/crate/server/src/tests/test_utils.rs index f29c6a2776..128227c07c 100644 --- a/crate/server/src/tests/test_utils.rs +++ b/crate/server/src/tests/test_utils.rs @@ -196,7 +196,6 @@ pub(crate) fn get_tmp_sqlite_path() -> PathBuf { /// # Arguments /// /// * `kms_public_url` - Optional public URL for the KMS server -/// * `privileged_users` - Optional list of users with elevated permissions /// /// # Google CSE Support /// @@ -207,7 +206,6 @@ pub(crate) fn get_tmp_sqlite_path() -> PathBuf { /// - Public key stored as `google_cse_rsa_pk` and exposed via `/google_cse/certs` pub(crate) async fn test_app( kms_public_url: Option, - privileged_users: Option>, ) -> impl Service, Error = actix_web::Error> { let clap_config = https_clap_config_opts(kms_public_url); @@ -229,7 +227,6 @@ pub(crate) async fn test_app( let mut app = App::new() .app_data(Data::new(kms_server.clone())) - .app_data(Data::new(privileged_users)) .service(routes::root_redirect::root_redirect_to_ui) .service(routes::health::get_health) .service(routes::get_version) @@ -285,7 +282,6 @@ pub(crate) async fn test_app( /// and enforcement settings and then validate behavior through the HTTP stack. pub(crate) async fn test_app_with_clap_config( clap_config: ClapConfig, - privileged_users: Option>, ) -> impl Service, Error = actix_web::Error> { let server_params = Arc::new(ServerParams::try_from(clap_config).expect("cannot create server params")); @@ -304,7 +300,6 @@ pub(crate) async fn test_app_with_clap_config( let mut app = App::new() .app_data(Data::new(kms_server.clone())) - .app_data(Data::new(privileged_users)) .service(routes::root_redirect::root_redirect_to_ui) .service(routes::health::get_health) .service(routes::get_version) diff --git a/crate/server/src/tests/test_validate.rs b/crate/server/src/tests/test_validate.rs index 191c226d99..8c43d5c5b3 100644 --- a/crate/server/src/tests/test_validate.rs +++ b/crate/server/src/tests/test_validate.rs @@ -143,7 +143,7 @@ pub(crate) async fn test_validate_with_certificates_ids() -> Result<(), KmsError certificate_value: root_cert.clone(), }), }; - let res_root = kms.import(root_request, owner, None).await?; + let res_root = kms.import(root_request, owner).await?; // intermediate let intermediate_request = Import { unique_identifier: UniqueIdentifier::TextString(String::new()), @@ -159,7 +159,7 @@ pub(crate) async fn test_validate_with_certificates_ids() -> Result<(), KmsError certificate_value: intermediate_cert.clone(), }), }; - let res_intermediate = kms.import(intermediate_request, owner, None).await?; + let res_intermediate = kms.import(intermediate_request, owner).await?; // leaf1 let leaf1_request = Import { unique_identifier: UniqueIdentifier::TextString(String::new()), @@ -175,7 +175,7 @@ pub(crate) async fn test_validate_with_certificates_ids() -> Result<(), KmsError certificate_value: leaf1_cert.clone(), }), }; - let res_leaf1 = kms.import(leaf1_request, owner, None).await?; + let res_leaf1 = kms.import(leaf1_request, owner).await?; // Only the root, it is valid by default let request = Validate { certificate: None, @@ -443,7 +443,7 @@ authorityKeyIdentifier=keyid:always,issuer }; let cert_id = kms - .certify(certify_req, owner, None) + .certify(certify_req, owner) .await? .unique_identifier .to_string(); @@ -1032,7 +1032,7 @@ authorityKeyIdentifier=keyid:always,issuer ..Certify::default() }; - let result = kms.certify(certify_req, owner, None).await; + let result = kms.certify(certify_req, owner).await; assert!( result.is_err(), "ML-KEM self-signed certificate creation must be rejected, but it succeeded" @@ -1065,7 +1065,6 @@ authorityKeyIdentifier=keyid:always,issuer ..Certify::default() }, owner, - None, ) .await; assert!(result.is_err(), "ML-KEM-768 self-signed must be rejected"); diff --git a/crate/server/src/tests/ttlv_tests/integrations/vast.rs b/crate/server/src/tests/ttlv_tests/integrations/vast.rs index 7dfb6d7499..50748aa768 100644 --- a/crate/server/src/tests/ttlv_tests/integrations/vast.rs +++ b/crate/server/src/tests/ttlv_tests/integrations/vast.rs @@ -675,9 +675,9 @@ fn test_vast_recertify_request_parsed() { ephemeral: None, unique_batch_item_id: None, request_payload: Operation::ReCertify(ReCertify { - unique_identifier: "non-existent-cert-id".to_owned(), - certificate_request_type: CertificateRequestType::PEM, - certificate_request_value: vec![], + unique_identifier: Some("non-existent-cert-id".to_owned()), + certificate_request_type: Some(CertificateRequestType::PEM), + certificate_request_value: Some(vec![]), template_attribute: None, }), message_extension: None, diff --git a/crate/server_database/Cargo.toml b/crate/server_database/Cargo.toml index 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..bdc5755b09 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,80 @@ 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 a list of UIDs. + pub async fn find_due_for_rotation(&self, now: time::OffsetDateTime) -> DbResult> { + let map = self.objects.read().await; + let mut results: Vec = Vec::new(); + for db in map.values() { + results.extend(db.find_due_for_rotation(now).await.unwrap_or_default()); + } + Ok(results) + } + + /// 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..4c0338964b 100644 --- a/crate/server_database/src/stores/sql/locate_query.rs +++ b/crate/server_database/src/stores/sql/locate_query.rs @@ -514,3 +514,92 @@ 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`. +#[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.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..5ec563eaec 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, serde_json::Value)> = conn + .exec(&sql, ()) + .await + .map_err(|e| InterfaceError::Db(format!("MySQL query error: {e}")))?; + let mut due = Vec::new(); + for (uid, attrs_val) in rows { + let attrs: Attributes = serde_json::from_value(attrs_val).unwrap_or_default(); + if crate::stores::sql::locate_query::is_due_for_rotation(&attrs, now) { + due.push(uid); + } + } + Ok(due) + } + + async 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..7a6b409eb5 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,101 @@ 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 attrs_val: Value = row.get(1); + let attrs: Attributes = serde_json::from_value(attrs_val).unwrap_or_default(); + if crate::stores::sql::locate_query::is_due_for_rotation(&attrs, now) { + due.push(uid); + } + } + Ok(due) + }) + } + + async 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 a4ff69252b..16f8deb0cc 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))? }; @@ -134,6 +137,62 @@ impl SqlitePool { ) .await .map_err(DbError::from)?; + + // Add wrapping_key_id column if not present (migration for existing databases), + // then backfill from the embedded JSON for any pre-existing wrapped objects. + // Note: SQLite does not support `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, + // so we check PRAGMA table_info first. + pool.writer + .call( + move |c: &mut rusqlite::Connection| -> Result<(), rusqlite::Error> { + let has_column: bool = { + let mut stmt = c.prepare("PRAGMA table_info(objects)")?; + let mut rows = stmt.query([])?; + let mut found = false; + while let Some(row) = rows.next()? { + let col_name: String = row.get(1)?; + if col_name == "wrapping_key_id" { + found = true; + break; + } + } + found + }; + if !has_column { + c.execute_batch( + "ALTER TABLE objects ADD COLUMN wrapping_key_id VARCHAR(128);", + )?; + } + // Backfill: deserialize each object and extract wrapping key UID via Rust + let mut stmt = + c.prepare("SELECT id, object FROM objects WHERE wrapping_key_id IS NULL")?; + let pairs: Vec<(String, String)> = { + let mut rows = stmt.query([])?; + let mut out = Vec::new(); + while let Some(row) = rows.next()? { + out.push((row.get(0)?, row.get(1)?)); + } + out + }; + for (id, object_json) in &pairs { + if let Ok(obj) = serde_json::from_str::< + cosmian_kmip::kmip_2_1::kmip_objects::Object, + >(object_json) + { + if let Some(wrapping_uid) = obj.wrapping_key_uid() { + c.execute( + "UPDATE objects SET wrapping_key_id = ?1 WHERE id = ?2", + rusqlite::params![wrapping_uid, id], + )?; + } + } + } + Ok(()) + }, + ) + .await + .map_err(DbError::from)?; + if clear_database { pool.set_current_db_version(env!("CARGO_PKG_VERSION")) .await?; @@ -161,7 +220,7 @@ impl SqlitePool { impl SqlDatabase for SqlitePool { fn get_loader(&self) -> &Loader { - &SQLITE_QUERIES + &PGSQL_QUERIES } } @@ -248,6 +307,7 @@ impl ObjectsStore for SqlitePool { .map_err(|e| InterfaceError::Db(format!("failed serializing attributes: {e}")))?; let state_s = attributes.state.unwrap_or(State::PreActive).to_string(); let owner_s = owner.to_owned(); + let wrapping_key_id = object.wrapping_key_uid(); let insert_object = replace_dollars_with_qn(get_sqlite_query!("insert-objects")); let insert_tag = replace_dollars_with_qn(get_sqlite_query!("insert-tags")); @@ -261,13 +321,14 @@ impl ObjectsStore for SqlitePool { // Insert object tx.execute( &insert_object, - params_from_iter([ - &uid_clone, - &object_json, - &attributes_json, - &state_s, - &owner_s, - ]), + rusqlite::params![ + uid_clone, + object_json, + attributes_json, + state_s, + owner_s, + wrapping_key_id, + ], )?; // Insert tags for tag in &tags_owned { @@ -333,6 +394,7 @@ impl ObjectsStore for SqlitePool { .map_err(|e| InterfaceError::Db(format!("failed serializing object: {e}")))?; let attributes_json = serde_json::to_string(attributes) .map_err(|e| InterfaceError::Db(format!("failed serializing attributes: {e}")))?; + let wrapping_key_id = object.wrapping_key_uid(); let sql_update = replace_dollars_with_qn(get_sqlite_query!("update-object-with-object")); let sql_delete_tags = replace_dollars_with_qn(get_sqlite_query!("delete-tags")); @@ -346,7 +408,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]))?; @@ -535,6 +597,132 @@ 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)>, + rusqlite::Error, + > { + let mut stmt = c.prepare(&sql)?; + let mut q = stmt.query([])?; + let mut out = Vec::new(); + while let Some(r) = q.next()? { + let id: String = r.get(0)?; + let attrs_json: String = r.get(1)?; + out.push((id, attrs_json)); + } + Ok(out) + }, + ) + .await + .map_err(DbError::from)?; + + let mut due = Vec::new(); + for (uid, attrs_json) in rows { + let attrs: Attributes = serde_json::from_str(&attrs_json).unwrap_or_default(); + if crate::stores::sql::locate_query::is_due_for_rotation(&attrs, now) { + due.push(uid); + } + } + Ok(due) + } + + async 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 @@ -576,7 +764,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"))?, ); @@ -603,7 +791,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"))?, ); @@ -627,7 +815,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"))?, ); @@ -651,7 +839,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"))?, ); @@ -880,13 +1068,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")); @@ -909,11 +1105,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")); @@ -941,13 +1138,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")); @@ -1052,11 +1257,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..5f67471fe8 100644 --- a/crate/test_kms_server/README.md +++ b/crate/test_kms_server/README.md @@ -65,269 +65,399 @@ 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: +**420 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 | `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_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_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 remains in Active state. | 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_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: | 11 | +| 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_still_decrypts` | Verifies that after ReKey, the old key remains Active and can still be used for encryption (proving it is not deactivated by ReKey). | 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_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_rekey_and_decrypt` | Full HSM keyset rotation test: | 9 | +| 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. | 8 | +| 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_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 / 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 / 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. | 9 | +| Keyset / Decrypt | `keyset_decrypt_double_rotation` | Tests try-each-key across a 3-generation chain: | 12 | +| Keyset / Decrypt | `keyset_decrypt_try_each` | The primary keyset try-each-key test: | 9 | +| 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). | 6 | +| 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. | 6 | +| Keyset / Encrypt | `keyset_encrypt_latest_after_rotation` | After rotation, encrypting by the bare keyset name must use the new key: | 9 | +| 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/rekey_non_latest_hsm` | Tests that Re-Key on a retired (non-latest) HSM key is rejected. | 8 | +| Negative / Keyset | `negative/rekey_non_latest_sql` | Tests that Re-Key on a retired (non-latest) SQL-backed key is rejected. | 8 | --- @@ -339,59 +469,57 @@ 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` | +| **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/src/lib.rs b/crate/test_kms_server/src/lib.rs index 1f2029a4e6..889cfc9dea 100644 --- a/crate/test_kms_server/src/lib.rs +++ b/crate/test_kms_server/src/lib.rs @@ -13,6 +13,7 @@ pub use test_server::{ start_default_test_kms_server_with_privileged_users, start_default_test_kms_server_with_softhsm2_and_kek, start_default_test_kms_server_with_softhsm2_and_kek_for_vectors, + start_default_test_kms_server_with_softhsm2_kek_uncreated_for_vectors, start_default_test_kms_server_with_three_softhsm2, start_default_test_kms_server_with_utimaco_and_kek, start_default_test_kms_server_with_utimaco_hsm, start_test_kms_server_with_config, @@ -24,6 +25,8 @@ mod test_server; mod test_jwt; +pub mod test_env; + pub mod vector_runner; use std::sync::Once; diff --git a/crate/test_kms_server/src/test_env.rs b/crate/test_kms_server/src/test_env.rs new file mode 100644 index 0000000000..a3216e95f7 --- /dev/null +++ b/crate/test_kms_server/src/test_env.rs @@ -0,0 +1,50 @@ +//! Safe in-process environment-variable overrides for test vectors. +//! +//! `std::env::set_var` is `unsafe` in Rust 1.87+ and is forbidden by this +//! crate's `deny(unsafe_code)` policy. This module provides a thread-safe +//! alternative that the vector runner consults **before** falling back to the +//! real process environment. +//! +//! Usage (server init): +//! ```rust,ignore +//! crate::test_env::set("HSM_BOOTSTRAP_KEK_ID", &kek_id); +//! ``` +//! +//! Usage (vector runner placeholder resolution): +//! ```rust,ignore +//! let value = crate::test_env::get("HSM_BOOTSTRAP_KEK_ID") +//! .or_else(|| std::env::var("HSM_BOOTSTRAP_KEK_ID").ok()); +//! ``` + +use std::{ + collections::HashMap, + sync::{OnceLock, PoisonError, RwLock}, +}; + +static OVERRIDES: OnceLock>> = OnceLock::new(); + +fn map() -> &'static RwLock> { + OVERRIDES.get_or_init(|| RwLock::new(HashMap::new())) +} + +/// Store an in-process env override under `key`. +/// +/// # Panics +/// Panics if the internal `RwLock` has been poisoned (should never happen in +/// a normal test run). +pub fn set(key: &str, value: &str) { + map() + .write() + .unwrap_or_else(PoisonError::into_inner) + .insert(key.to_owned(), value.to_owned()); +} + +/// Look up an in-process env override. Returns `None` if not set. +#[must_use] +pub fn get(key: &str) -> Option { + map() + .read() + .unwrap_or_else(PoisonError::into_inner) + .get(key) + .cloned() +} diff --git a/crate/test_kms_server/src/test_server.rs b/crate/test_kms_server/src/test_server.rs index a3c1eae595..7993a872cf 100644 --- a/crate/test_kms_server/src/test_server.rs +++ b/crate/test_kms_server/src/test_server.rs @@ -19,7 +19,7 @@ static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); use actix_server::ServerHandle; use cosmian_kms_client::{ GmailApiConf, KmsClient, KmsClientConfig, KmsClientError, - cosmian_kmip::{KmipResultHelper, kmip_2_1::extra::tagging::VENDOR_ID_COSMIAN, time_normalize}, + cosmian_kmip::{KmipResultHelper, kmip_2_1::extra::tagging::VENDOR_ID_COSMIAN}, kmip_0::kmip_types::CryptographicUsageMask, kmip_2_1::{ kmip_attributes::Attributes, @@ -375,7 +375,6 @@ async fn create_kek_in_db() -> Result<(PathBuf, String), KmsClientError> { ), object_type: Some(ObjectType::SymmetricKey), unique_identifier: Some(UniqueIdentifier::TextString(kek_id.to_owned())), - activation_date: Some(time_normalize()?), ..Default::default() }, protection_storage_masks: None, @@ -496,7 +495,6 @@ async fn create_softhsm2_kek_in_db() -> Result<(PathBuf, String), KmsClientError ), object_type: Some(ObjectType::SymmetricKey), unique_identifier: Some(UniqueIdentifier::TextString(kek_id.clone())), - activation_date: Some(time_normalize()?), ..Default::default() }, protection_storage_masks: None, @@ -591,6 +589,52 @@ pub async fn start_default_test_kms_server_with_softhsm2_and_kek_for_vectors() start_server_from_config(config, &config_path).await } +/// Start a `SoftHSM2` + KEK test server where the KEK has **not** been pre-created. +/// +/// This server type is used to reproduce the self-wrap regression (PR #968): +/// `wrap_and_cache` must not attempt to wrap an HSM-resident key with the +/// server-wide KEK, even when the key being created IS the KEK itself. +/// +/// Concretely, `key_encryption_key` is set to `"hsm::{slot}::kek_bootstrap"` +/// before the server starts. The first vector step creates that exact HSM key, +/// which would have triggered the self-wrap error prior to the fix. +/// +/// # Errors +/// Returns an error if the server fails to start. +/// +/// # Panics +/// Panics if `HSM_SLOT_ID` is not set or is not a valid `usize`. +pub async fn start_default_test_kms_server_with_softhsm2_kek_uncreated_for_vectors() +-> Result { + let slot = get_softhsm2_slot_id(); + let workspace_dir = std::env::temp_dir().join(format!( + "kms_test_softhsm2_kek_bootstrap_{}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let kek_id = format!("hsm::{slot}::kek_bootstrap_{}", std::process::id()); + // Export for {{$HSM_BOOTSTRAP_KEK_ID}} substitution in vector steps. + // Called once inside OnceCell initialisation before any vector steps run. + crate::test_env::set("HSM_BOOTSTRAP_KEK_ID", &kek_id); + + let config_path = hsm_config_path("hsm_softhsm2_kek.toml"); + let mut config = load_test_config_from_toml(&config_path)?; + config.hsm.hsm_slot = vec![slot]; + config.db.sqlite_path = workspace_dir.join("sqlite-data"); + config.workspace.root_data_path = workspace_dir.join("workspace"); + config.workspace.tmp_path = workspace_dir.join("tmp"); + config.key_encryption_key = Some(kek_id); + config.default_unwrap_type = Some(vec!["SecretData".to_owned(), "SymmetricKey".to_owned()]); + // Disable Google CSE: starting with an empty workspace means no Google CSE + // RSA keypair exists yet, and this test does not need that feature. + config.google_cse_config.google_cse_enable = false; + start_server_from_config(config, &config_path).await +} + /// Start a test KMS server with three `SoftHSM2` instances: /// /// - Slot 1 (`HSM_SLOT_ID_1`): legacy single-HSM config (`hsm:` top-level fields). diff --git a/crate/test_kms_server/src/vector_runner.rs b/crate/test_kms_server/src/vector_runner.rs index 5c1d511bab..001bb2fa37 100644 --- a/crate/test_kms_server/src/vector_runner.rs +++ b/crate/test_kms_server/src/vector_runner.rs @@ -30,6 +30,10 @@ static ONCE_VECTOR_CERT_AUTH: OnceCell = OnceCell::const_new(); static ONCE_VECTOR_AUTH_HTTPS: OnceCell = OnceCell::const_new(); /// Singleton server for vector tests requiring `SoftHSM2` + KEK. static ONCE_VECTOR_HSM_KEK: OnceCell = OnceCell::const_new(); +/// Singleton server for vector tests where the HSM KEK is configured but **not yet created**. +/// Used to verify that `wrap_and_cache` does not attempt to self-wrap when the first +/// operation creates the KEK itself (regression for PR #968 self-wrap bug). +static ONCE_VECTOR_HSM_KEK_UNCREATED: OnceCell = OnceCell::const_new(); /// A test vector manifest loaded from a TOML file. /// @@ -430,12 +434,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 +477,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 +723,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 +803,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 @@ -806,6 +828,19 @@ pub async fn run_test_vector(vector_dir: &str) -> Result<(), KmsClientError> { ); return execute_steps(context, &manifest, &vector_path).await; } + "hsm_kek_uncreated" => { + let context = ONCE_VECTOR_HSM_KEK_UNCREATED + .get_or_try_init(|| async { + crate::start_default_test_kms_server_with_softhsm2_kek_uncreated_for_vectors() + .await + }) + .await?; + eprintln!( + "▶ Running vector '{}' on server_type 'hsm_kek_uncreated'", + manifest.name + ); + return execute_steps(context, &manifest, &vector_path).await; + } other => { return Err(KmsClientError::UnexpectedError(format!( "Unknown server_type '{other}' in manifest for vector '{}'", @@ -1574,6 +1609,12 @@ 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_deactivated_fails() -> Result<(), KmsClientError> { crate::init_test_logging(); @@ -1610,12 +1651,44 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/fips/kmip_operations/rekey_old_key_still_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> { @@ -3256,6 +3329,12 @@ ObjectType = "SymmetricKey" run_test_vector("test_data/vectors/negative/set_attribute/read_only_attribute").await } + #[tokio::test] + async fn test_neg_hsm_rotate_offset_rejected() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/negative/set_attribute/hsm_rotate_offset_rejected").await + } + #[tokio::test] async fn test_neg_spec_sign_invalid_message() -> Result<(), KmsClientError> { crate::init_test_logging(); @@ -3331,34 +3410,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 +3425,43 @@ 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_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 +3518,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 +3547,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 +3568,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 +3611,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 +3707,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 +3873,41 @@ 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 + } + + /// 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] #[cfg(not(feature = "non-fips"))] async fn test_vec_hsm_kek_rsa1024_rejected() -> Result<(), KmsClientError> { @@ -3964,11 +4147,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 +4164,84 @@ 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 + } } 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..7162270f2a --- /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: + +``` +::::[::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_auto_rotation.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/kmip_support/attributes.md b/documentation/docs/kmip_support/attributes.md index 2b79bbd56c..37fa7bee51 100644 --- a/documentation/docs/kmip_support/attributes.md +++ b/documentation/docs/kmip_support/attributes.md @@ -1,4 +1,4 @@ -In [chapter 4](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/cs01/kmip-spec-v2.1-cs01.html#_Toc32239322), the KMIP 2.1 specification specifies a list of 63 Attributes, mostly made of enumerations and data structures, often nested in each other. Despite this impressive list, and as expected in such a large specification, KMIP allows for extensions to support new cryptographic schemes such as the ones enabled by Eviden. +In [chapter 4](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html#_Toc57115559), the KMIP 2.1 specification specifies a list of 63 Attributes, mostly made of enumerations and data structures, often nested in each other. Despite this impressive list, and as expected in such a large specification, KMIP allows for extensions to support new cryptographic schemes such as the ones enabled by Eviden. Extensions in KMIP consist mostly in augmenting enumerations with new values and attributing a specific prefix values, usually `0x8880` to the new variants. @@ -41,7 +41,7 @@ CoverCrypt = 0x8880_0004, #### Vendor Attributes -All keys managed by the Eviden KMS server are primarily a `KeyMaterial` made of bytes. Some keys, typically those of ABE, also carry information regarding the underlying access policies. This information is carried together with the keys using [VendorAttributes](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/cs01/kmip-spec-v2.1-cs01.html#_Toc32239382) +All keys managed by the Eviden KMS server are primarily a `KeyMaterial` made of bytes. Some keys, typically those of ABE, also carry information regarding the underlying access policies. This information is carried together with the keys using [VendorAttributes](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html#_Toc57115619) Typically a vendor attribute is made of 3 values: a `Vendor Identification` - set to the server's configured vendor ID (default: `cosmian`, configurable via `--vendor-identification` / `KMS_VENDOR_IDENTIFICATION`) - and a tuple `Attribute Name`, `Attribute Value`. diff --git a/documentation/docs/kmip_support/introduction/index.md b/documentation/docs/kmip_support/introduction/index.md index 4d7da90ea2..a6e76e574f 100644 --- a/documentation/docs/kmip_support/introduction/index.md +++ b/documentation/docs/kmip_support/introduction/index.md @@ -22,7 +22,7 @@ and encryption clients. Internally, all KMIP messages are translated to KMIP 2.1 specifications and converted back to KMIP 1.x when necessary. The Eviden KMS server implements a targeted subset of -the [KMIP 2.1 protocol](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/cs01/kmip-spec-v2.1-cs01.html). +the [KMIP 2.1 protocol](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html). ## Purpose of KMIP diff --git a/documentation/docs/kmip_support/key_auto_rotation.md b/documentation/docs/kmip_support/key_auto_rotation.md new file mode 100644 index 0000000000..31bc74448e --- /dev/null +++ b/documentation/docs/kmip_support/key_auto_rotation.md @@ -0,0 +1,763 @@ +# Key Auto-Rotation Policy + +Cosmian KMS supports **scheduled, policy-driven key rotation** for symmetric +keys and asymmetric key pairs. Instead of requiring an operator to call the +`Re-Key`, `Re-Key Key Pair` or `ReCertify` KMIP operations manually, a per-key *rotation +policy* can be attached to any key object. A background task then checks +periodically which keys are overdue and rotates them automatically. + +--- + +## Rotation policy attributes + +All rotation-policy state is stored as vendor-extension KMIP attributes on +the key object itself. The following attributes are available: + +| Attribute | Type | Description | Mutable | +| --------------------- | --------------- | ----------------------------------------------------------------------------------------------------------- | ------- | +| `x-rotate-interval` | `i64` (seconds) | How often this key should be rotated. `0` disables auto-rotation. | Yes | +| `x-rotate-name` | `String` | Name of the keyset this key belongs to (see [Keysets](#keysets)). | Yes | +| `x-rotate-offset` | `i64` (seconds) | Shift the first rotation trigger by this many seconds after `Initial Date`. | Yes | +| `x-rotate-generation` | `i32` | Incremented on every rotation; `0` for never-rotated keys. **Server-managed, read-only.** | No | +| `x-rotate-date` | `datetime` | Timestamp of the last rotation; populated automatically after each rotation. **Server-managed, read-only.** | No | +| `x-rotate-latest` | `bool` | `true` on the most-recent member of a keyset; `false` on all older (retired) members. **Server-managed, read-only.** | No | + +> **Read-only attributes:** `x-rotate-generation` and `x-rotate-date` are set +> exclusively by the server during the `Re-Key` operation. Any attempt to +> modify them via `AddAttribute`, `SetAttribute`, `ModifyAttribute`, or +> `DeleteAttribute` will be rejected with `Attribute_Read_Only`. +> +> These restrictions preserve two invariants that the scheduler and the +> rotation link-chain logic rely on: +> +> - **Monotonically increasing counter** — `x-rotate-generation` starts at `0` +> for a never-rotated key and is incremented by exactly `1` on each successful +> rotation. Within a key-set (keys linked via `ReplacedObjectLink` / +> `ReplacementObjectLink`), the generation number is therefore unique and +> strictly increasing, which lets the scheduler and client tooling identify +> the *current* key in a chain without inspecting every member. +> - **Authoritative last-rotation timestamp** — `x-rotate-date` is the only +> reliable source for "when was this key last rotated". The scheduler's +> `is_due_for_rotation` function computes the next trigger as +> `x-rotate-date + x-rotate-interval` (for previously-rotated keys) or as +> `initial_date + x-rotate-offset + x-rotate-interval` (for never-rotated +> keys with an initial date). Any external modification to `x-rotate-date` +> would cause the scheduler to fire too early, skip a scheduled rotation, or +> produce an inconsistent link chain. + +Use the `SetAttribute` KMIP operation (or the `ckms sym keys set-rotation-policy` +CLI command) to configure the mutable attributes on an existing key. + +```bash +# Rotate the key every hour starting from its Initial Date +ckms sym keys set-rotation-policy \ + --key-id \ + --interval 3600 \ + --name "my-key-set" +``` + +--- + +> **ℹ️ HSM-resident keys support manual rotation but not auto-rotation** +> +> Keys whose UID starts with `hsm::` (e.g. `hsm::softhsm2::473094471::my-kek`) +> are managed by a PKCS#11-capable Hardware Security Module. +> +> | Capability | Supported | Notes | +> |---|---|---| +> | Manual `Re-Key` via KMIP | ✅ Yes | Calls `C_GenerateKey` on the same HSM slot; see [HSM key rotation and keysets](#hsm-key-rotation-and-keysets) | +> | Keyset membership (`x-rotate-name`) | ✅ Yes | Stored in `CKA_LABEL`; supports bare-name and `name@version` addressing | +> | Auto-rotation scheduler | ❌ No | `find_due_for_rotation` never returns HSM UIDs; the scheduler skips them | +> | `x-rotate-interval` | ✅ Yes | Writes `CKA_START_DATE` / `CKA_END_DATE` for validity tracking | +> | `x-rotate-offset` | ❌ No | Not applicable to PKCS#11 scheduling; rejected with `NotSupported` | + +--- + +## Keysets + +A **keyset** is a named group of related key generations. Each generation is +a distinct cryptographic key (different key material, different UID for SQL +keys or different `key_id` suffix for HSM keys) produced by successive +`Re-Key` operations. Keysets are supported for both SQL-backed and +HSM-resident keys. + +### Assigning a key to a keyset + +Set `x-rotate-name` via `SetAttribute` (or the CLI): + +```bash +# SQL key +ckms sym keys set-rotation-policy --key-id --name "my-keyset" + +# HSM key (same command — writes CKA_LABEL on the PKCS#11 object) +ckms sym keys set-rotation-policy --key-id "hsm::softhsm2::473094471::my-key" --name "my-keyset" +``` + +The first `SetAttribute` marks the key as generation `0` and `x-rotate-latest = true`. +Every subsequent `Re-Key` increments the generation, sets `x-rotate-latest = true` +on the new key, and sets `x-rotate-latest = false` on the old key. + +### Keyset addressing syntax + +A keyset can be referenced by name instead of a specific UID: + +| Syntax | Resolves to | +|---|---| +| `my-keyset` (bare name) | Latest generation (`x-rotate-latest = true`) | +| `my-keyset@latest` | Latest generation (explicit) | +| `my-keyset@first` or `my-keyset@0` | Generation 0 (the original key) | +| `my-keyset@N` | Generation N | + +Keyset names are accepted wherever a `UniqueIdentifier` is expected: `Encrypt`, +`Decrypt`, `Sign`, `Verify`, `Get`, `GetAttributes`, `Re-Key`, etc. + +**Encrypt / Sign** resolves to the latest generation. +**Decrypt / Verify** walks the chain newest-to-oldest, trying each generation +until one succeeds. This lets in-flight ciphertexts produced with an older +key continue to decrypt after rotation. + +### Non-latest guard + +Only the **latest generation** of a keyset can be rotated via `Re-Key`. Attempting +to re-key a retired (non-latest) member is rejected: + +```text +Invalid Request: ReKey: key '' is not the latest in its keyset — +only the latest generation can be rotated +``` + +This prevents accidentally branching the rotation chain. Keys that do not +belong to any keyset (no `x-rotate-name`) are not subject to this restriction. + +### SQL keyset internals + +For SQL-backed keys the keyset state is stored as KMIP vendor attributes in the +database: + +- `x-rotate-name` — the keyset name (set once, inherited by each successive generation) +- `x-rotate-generation` — integer, starts at `0`, incremented per `Re-Key` +- `x-rotate-latest` — `true` on the current key; `false` on all older keys + +The rotation chain is also reflected in KMIP link attributes: +`ReplacementObjectLink` (old → new) and `ReplacedObjectLink` (new → old). +These back-pointers allow clients to traverse the full history. + +--- + +## HSM key rotation and keysets + +HSM-resident keys **fully support manual rotation via the `Re-Key` KMIP +operation** and the keyset feature. The background auto-rotation scheduler +does not apply to HSM keys (see note above). + +### CKA_LABEL convention + +HSM keyset metadata is stored entirely in the PKCS#11 `CKA_LABEL` attribute — +no SQL shadow rows are written. + +| CKA_LABEL value | Meaning | +| --------------------------------- | ---------------------------------- | +| `{name}::{gen}::{base_id}::latest` | Current (newest) key in the keyset | +| `{name}::{gen}::{base_id}` | Retired (older) key in the keyset | +| *(anything else)* | Key does not belong to a keyset | + +- `name` — the value set via `SetAttribute x-rotate-name` +- `gen` — integer starting at `0`, incremented on every `Re-Key` +- `base_id` — the original PKCS#11 `CKA_ID` of the gen-0 key + +### UID generation scheme + +```text +hsm:::: ← gen 0 (original key) +hsm::::::1 ← gen 1 (after first Re-Key) +hsm::::::2 ← gen 2 (after second Re-Key) +``` + +The numeric suffix is appended to the original `key_id`; the base name is +never changed. The full chain can therefore be discovered by inspecting +`CKA_LABEL` on the HSM slot. + +### Keyset resolution for HSM keys + +When a bare keyset name (e.g. `my-hsm-keyset`) or `name@version` syntax is +used, the server calls `find_by_rotate_name` which scans PKCS#11 objects in the +HSM slot and filters by `CKA_LABEL` prefix. Results are sorted by generation +(descending). For `Decrypt`, each generation is tried in order until one +succeeds. + +Unlike SQL-backed keys, HSM keysets do **not** use +`ReplacedObjectLink`/`ReplacementObjectLink` back-pointers; all state lives in +PKCS#11 attributes. + +### Example workflow + +```bash +# 1. Create an AES-256 key directly on the HSM (legacy UID format) +ckms sym keys create \ + --key-id "hsm::473094471::my-hsm-key" \ + --algorithm aes --length 256 + +# 2. Register the key in a keyset (writes CKA_LABEL = "my-keyset::0::my-hsm-key::latest") +ckms sym keys set-rotation-policy \ + --key-id "hsm::473094471::my-hsm-key" \ + --name "my-hsm-keyset" + +# 3. Encrypt using the keyset bare name (resolves to the latest key) +ckms sym encrypt --key-id "my-hsm-keyset" plaintext.bin + +# 4. Rotate: C_GenerateKey on the same HSM slot; updates CKA_LABEL on both keys +ckms sym keys rekey --key-id "hsm::473094471::my-hsm-key" +# → new UID: hsm::473094471::my-hsm-key::1 +# CKA_LABEL (gen-0): "my-keyset::0::my-hsm-key" (retired) +# CKA_LABEL (gen-1): "my-keyset::1::my-hsm-key::latest" (current) + +# 5. Decrypt old ciphertext: keyset tries gen-1 then gen-0 automatically +ckms sym decrypt --key-id "my-hsm-keyset" ciphertext.enc + +# 6. Second rotation +ckms sym keys rekey --key-id "hsm::473094471::my-hsm-key::1" +# → new UID: hsm::473094471::my-hsm-key::2 + +# Attempting to re-key a retired generation is rejected: +ckms sym keys rekey --key-id "hsm::473094471::my-hsm-key" # gen-0 — REJECTED +# Error: not the latest in its keyset +``` + +--- + +## Server-side scheduler + +The server's background cron thread runs an auto-rotation check at the +interval configured by the `--auto-rotation-check-interval-secs` server flag +(default: `0`, meaning disabled). + +```bash +cosmian_kms --auto-rotation-check-interval-secs 300 # check every 5 minutes +``` + +On each check, the server queries all **Active** symmetric keys and private +keys owned by any user whose `x-rotate-interval` has elapsed since either +`x-rotate-date` (for previously-rotated keys) or `Initial Date + x-rotate-offset` +(for never-rotated keys with an initial date). + +--- + +## State restrictions + +Only keys (or certificates) in the **Active** or **Deactivated** state can be +rotated. Attempting to call `Re-Key`, `Re-Key Key Pair`, or `ReCertify` on an +object in any other state will produce an error: + +| State | Rotation allowed? | Rationale | +| ------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------- | +| **Active** | ✅ Yes | The primary valid source state for rotation. | +| **Deactivated** | ✅ Yes | KMIP §6.1.46 does not list `Wrong_Key_Lifecycle_State` — a deactivated key may produce a replacement. | +| **Compromised** | ✅ Yes | The immediate response to a key compromise should be to rotate it — blocking rotation would be counter-productive. | +| **Pre-Active** | ❌ No | The key has never been activated — rotating unused material is premature. | +| **Destroyed** | ❌ No | The object no longer exists. | +| **Destroyed_Compromised** | ❌ No | The object no longer exists. | + +> **Note:** This restriction applies to the **source** key only. The *output* +> of a rotation operation can still enter the `Pre-Active` state when an +> `Offset > 0` is supplied in the request (the new key's `Activation Date` is +> computed as `Initial Date + Offset`, scheduling future activation). + +--- + +## Auto-deactivation (KMIP §4.57 transition 6) + +Per KMIP §4.57 state transition 6, the server **automatically transitions** an +Active key to the Deactivated state when its `Deactivation Date` is reached. +This happens on retrieval (the same mechanism as PreActive → Active +auto-activation). There is no need for an explicit `Revoke` call — setting a +`Deactivation Date` in the future schedules the deactivation. + +--- + +## Key types and rotation flows + +The behaviour differs according to whether the key is plain, a wrapping key, +or a wrapped key. Each case is described below with a lifecycle diagram. + +--- + +### 1. Plain symmetric key (no wrapping) + +A plain symmetric key carries only its own policy. On rotation: + +1. Fresh key material is generated (same algorithm and length). +2. The new key is assigned a new UUID. +3. A `ReplacedObjectLink` on the new key points back to the old key. +4. A `ReplacementObjectLink` on the old key points forward to the new key. +5. `x-rotate-generation` is incremented; `x-rotate-date` is set. + +```mermaid +stateDiagram-v2 + direction LR + [*] --> Active : Create + Active --> Active : Auto-rotation (new UID, new material) + Active --> Deactivated : Revoke + Deactivated --> Destroyed : Destroy + Destroyed --> [*] + + note right of Active + Each arrow = one rotation cycle. + Old key: ReplacementObjectLink → new key. + New key: ReplacedObjectLink → old key. + end note +``` + +**KMIP link chain after two successive rotations:** + +```mermaid +flowchart LR + K0["Key₀ (original)"] -->|ReplacementObjectLink| K1["Key₁ (1st rotation)"] + K1 -->|ReplacementObjectLink| K2["Key₂ (2nd rotation)"] + K2 -->|ReplacedObjectLink| K1 + K1 -->|ReplacedObjectLink| K0 +``` + +--- + +### 2. Wrapping key + +A *wrapping key* is a symmetric key (or asymmetric public key) whose +`WrappingKeyLink` points to it from one or more *wrapped* keys. + +When the wrapping key is rotated: + +1. A new wrapping key is created (Phase 1 — committed immediately so it is + available in the database). +2. Every **Active** key that references the old wrapping key via a + `WrappingKeyLink` is re-wrapped with the new wrapping key (Phase 2). +3. Each wrapped key's `WrappingKeyLink` is updated to the new wrapping key + UUID. +4. All standard rotation metadata (`ReplacementObjectLink`, generation counter, + date) are applied to both the old and new wrapping key. + +```mermaid +sequenceDiagram + participant Scheduler + participant KMS + participant DB + + Scheduler->>KMS: run_auto_rotation() + KMS->>DB: find_due_for_rotation() + DB-->>KMS: [wrapping_key_uid, ...] + KMS->>DB: Phase 1 — upsert new wrapping key (committed) + loop For each wrapped dependant + KMS->>DB: retrieve wrapped key + KMS->>KMS: unwrap with old wrapping key + KMS->>KMS: wrap with new wrapping key + KMS->>DB: update WrappingKeyLink → new wrapping key UID + end + KMS->>DB: Phase 2 — update old wrapping key links + metadata +``` + +**State view:** + +```mermaid +stateDiagram-v2 + direction LR + [*] --> WK_Active : Create wrapping key + WK_Active --> WK_Active : Auto-rotation (new UID, re-wraps all dependants) + WK_Active --> Deactivated : Revoke + Deactivated --> Destroyed : Destroy + Destroyed --> [*] +``` + +--- + +### 3. Wrapped key + +A *wrapped key* is any key whose key block contains `KeyWrappingData`. It +cannot simply be re-keyed in place because the new plaintext bytes must be +re-wrapped before storage. + +Rotation flow: + +1. The wrapped key is exported from the database and **unwrapped** in + memory using the current wrapping key. +2. Fresh plaintext key material is generated from the unwrapped attributes. +3. The new key material is **re-wrapped** with the same wrapping key. +4. The resulting ciphertext is stored under a new UUID; the new key entry + carries an active `WrappingKeyLink` pointing to the original wrapping key. +5. Standard rotation metadata is applied. + +```mermaid +sequenceDiagram + participant Scheduler + participant KMS + participant DB + + Scheduler->>KMS: run_auto_rotation() + KMS->>DB: find_due_for_rotation() + DB-->>KMS: [wrapped_key_uid, ...] + KMS->>DB: retrieve wrapped key + wrapping key + + Note over KMS: unwrap in-memory (plaintext never stored) + KMS->>KMS: generate new key material + KMS->>KMS: re-wrap with same wrapping key + + KMS->>DB: store new wrapped key (new UID, same WrappingKeyLink) + KMS->>DB: update old key: ReplacementObjectLink → new key + Note over DB: new key has ReplacedObjectLink → old key +``` + +**State view:** + +```mermaid +stateDiagram-v2 + direction LR + [*] --> Wrapped_Active : Create + wrap + Wrapped_Active --> Wrapped_Active : Auto-rotation (unwrap, new material, re-wrap) + Wrapped_Active --> Deactivated : Revoke + Deactivated --> Destroyed : Destroy + Destroyed --> [*] +``` + +--- + +### 4. Asymmetric key pair (private key — plain) + +For asymmetric keys managed via `Re-Key Key Pair`, the rotation target is the +**private key**. The associated public key UID is carried in the private key's +`PublicKeyLink` attribute and is preserved in the new private key. + +```mermaid +sequenceDiagram + participant Scheduler + participant KMS + + Scheduler->>KMS: run_auto_rotation() + KMS->>KMS: detect PrivateKey type + KMS->>KMS: ReKeyKeyPair (new private key + new public key) + note right of KMS: New PrivateKey UID
New PublicKey UID
(linked to new private key) +``` + +--- + +### 5. Wrapped private key (Covercrypt) + +A **Covercrypt** master private key and user decryption key that have been wrapped follows the same flow as any +other `PrivateKey` rotation: the `ReKeyKeyPair` (`rekey_keypair`) operation +unwraps the key in memory, rekeys the Covercrypt partition, and stores a new +wrapped private key under a fresh UID. + +Setting a rotation policy attribute on a wrapped private key works in all +cases: the attribute is stored in the metadata column (not in the ciphertext +key block) and does not require the key to be unwrapped first. + +```bash +# Works even when the private key is stored wrapped +ckms sym keys set-rotation-policy \ + --key-id \ + --interval 86400 \ + --name "nightly" +``` + +--- + +### 6. Certificate renewal (`ReCertify`) + +Certificate renewal creates a **new certificate for the same key pair** — no +new key material is generated. The KMIP `ReCertify` operation (§6.1.45) +assigns a fresh UID to the renewed certificate and links old → new via the +standard `ReplacementObjectLink` / `ReplacedObjectLink` pair. + +#### Standards and RFCs + +| Standard | Title | Relevance | +| ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [KMIP 2.1 §6.1.45](https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html#_Toc57115677) | Re-certify operation | Normative definition: request/response payload, attribute handling, link semantics | +| [RFC 4210](https://www.rfc-editor.org/rfc/rfc4210.html) | Internet X.509 PKI — Certificate Management Protocol (CMP) | Defines `kur` (Key Update Request, §5.3.5) / `kup` (Key Update Response, §5.3.6) for certificate renewal over the wire. KMIP `ReCertify` is the KMS-internal equivalent. | +| [RFC 4211](https://www.rfc-editor.org/rfc/rfc4211.html) | Internet X.509 CRMF (Certificate Request Message Format) | §6.5 "OldCert ID Control" — identifies the certificate being renewed in a CMP request | +| [RFC 5280](https://www.rfc-editor.org/rfc/rfc5280.html) | Internet X.509 PKI — Certificate and CRL Profile | Defines X.509v3 certificate structure, extensions, validity periods | +| [RFC 2986](https://www.rfc-editor.org/rfc/rfc2986.html) | PKCS#10: Certification Request Syntax | CSR format supported by KMIP `CertificateRequestType` | +| [RFC 5272](https://www.rfc-editor.org/rfc/rfc5272.html) | Certificate Management over CMS (CMC) | Alternative certificate lifecycle protocol | + +#### Relationship between CMP and KMIP ReCertify + +In the CMP protocol (RFC 4210), a client sends a **Key Update Request** (`kur`, +body tag [7]) to a CA to obtain a renewed certificate for an existing key pair. +The CA responds with a **Key Update Response** (`kup`, body tag [8]) containing +the new certificate. + +In Cosmian KMS, the server acts as both the CA and the key/certificate store. +The `ReCertify` KMIP operation performs the equivalent of a CMP `kur`/`kup` +exchange locally: it re-signs the certificate for the same subject and key pair, +assigns a fresh UID, and manages replacement links — all in a single atomic +database transaction. + +#### Rotation flow + +1. The existing certificate is retrieved and its issuer/subject are resolved. +2. A new certificate is built and signed (same key pair, same issuer). +3. The new certificate is assigned a fresh UID. +4. `ReplacedObjectLink` on the new certificate → old certificate. +5. `ReplacementObjectLink` on the old certificate → new certificate. +6. Keys linked to the old certificate have their `CertificateLink` updated + to point to the new certificate. +7. Rotation metadata (`x-rotate-generation`, `x-rotate-date`) is set. + +```mermaid +sequenceDiagram + participant Client + participant KMS + participant DB + + Client->>KMS: ReCertify(old_cert_uid) + KMS->>DB: retrieve old certificate + KMS->>KMS: resolve issuer + subject from old cert + KMS->>KMS: build & sign new certificate (same key pair) + KMS->>DB: Phase 1 — store new cert (fresh UID) + KMS->>DB: Phase 2 — update old cert (ReplacementObjectLink) + KMS->>DB: Phase 2 — relink keys (CertificateLink → new cert) + KMS-->>Client: ReCertifyResponse(new_cert_uid) +``` + +#### Attribute handling (KMIP 2.1 §6.1.45 Table 299) + +| Attribute | New certificate | Old certificate | +| ----------------------------- | ----------------------- | ----------------------- | +| `Unique Identifier` | Fresh UUID | Unchanged | +| `Initial Date` | Set to current time | Unchanged | +| `Link[ReplacedObjectLink]` | → old cert UID | — | +| `Link[ReplacementObjectLink]` | — | → new cert UID | +| `Link[PublicKeyLink]` | Preserved from old cert | Unchanged | +| `Link[PrivateKeyLink]` | Preserved from old cert | Unchanged | +| `Name` | Inherited from old cert | Removed (per KMIP spec) | +| `State` | Active | Active | +| `x-rotate-generation` | old value + 1 | Unchanged | +| `x-rotate-date` | Current timestamp | Unchanged | +| `Destroy Date` | Not set | Unchanged | +| `Revocation Reason` | Not set | Unchanged | + +#### Key differences from key rotation + +| Aspect | Key rotation (`ReKey` / `ReKeyKeyPair`) | Certificate renewal (`ReCertify`) | +| ----------------------- | ------------------------------------------ | --------------------------------- | +| New material generated? | Yes (new key bytes) | No (same key pair) | +| Wrapping involved? | Yes (if key was wrapped) | Never | +| Dependants re-wrapped? | Yes (for wrapping keys) | No — keys are *relinked* instead | +| KMIP operation | `Re-Key` (0x0A) / `Re-Key Key Pair` (0x0B) | `Re-Certify` (0x07) | + +#### CLI usage + +Certificate renewal is invoked via the `ckms certificates certify` command with +the `--certificate-id-to-re-certify` flag: + +```bash +# Renew an existing certificate (same key pair, new validity period) +ckms certificates certify \ + --certificate-id-to-re-certify \ + --issuer-private-key-id \ + --days 365 + +# Self-signed certificate renewal (issuer = subject) +ckms certificates certify \ + --certificate-id-to-re-certify \ + --days 3650 +``` + +--- + +### 7. Server-wide key-encryption key (KEK) + +The KMS server can be configured with a **key-encryption key** (`--key-encryption-key` +CLI flag or `key_encryption_key` in `kms.toml`). When this option is set, +**every object stored in the KMS database is transparently wrapped** by the KEK +before being persisted. The KEK is typically held in an HSM (SoftHSM2, +Utimaco, Proteccio, …). + +Auto-rotation works exactly the same as for plain or wrapped keys: the scheduler +detects objects whose `x-rotate-interval` has elapsed, unwraps them using the +server KEK, generates fresh key material, re-wraps the new key, and stores it. +The operator **does not need to do anything special** to rotate a key stored in +a KEK-protected server. + +Example server startup with SoftHSM2 and a KEK: + +```bash +cosmian_kms \ + --database-type sqlite \ + --hsm-model softhsm2 \ + --hsm-slot 0 \ + --hsm-password 12345678 \ + --key-encryption-key "hsm::softhsm2::0::my-kek" \ + --auto-rotation-check-interval-secs 300 +``` + +Setting a rotation policy on a wrapped key is identical to a plain key: + +```bash +ckms sym keys set-rotation-policy \ + --key-id \ + --interval 3600 \ + --name "my-key-set" +``` + +The `SetAttribute` call succeeds even when the target key is wrapped (the +attribute is stored separately in the metadata column, not inside the +ciphertext). + +--- + +## Interaction between key types during rotation + +```mermaid +flowchart TD + subgraph "Auto-rotation cycle" + direction TB + DUE["find_due_for_rotation()"] --> DISPATCH{"Object type?"} + DISPATCH -->|SymmetricKey| PLAIN["Plain rekey
(new material, new UID)"] + DISPATCH -->|SymmetricKey + has dependants| WRAP_K["Wrapping-key rotation
(Phase 1 → Phase 2 re-wrap)"] + DISPATCH -->|SymmetricKey + wrapped| WRAP_D["Wrapped-key rotation
(unwrap → new material → re-wrap)"] + DISPATCH -->|PrivateKey| ASYM["ReKeyKeyPair"] + DISPATCH -->|Certificate| CERT["ReCertify
(same key pair, new cert UID)"] + PLAIN --> META["Update metadata
(generation++, date, links)"] + WRAP_K --> META + WRAP_D --> META + ASYM --> META + CERT --> META + META --> OTEL["Increment
kms.key.auto_rotation
OTel counter"] + end +``` + +--- + +## Configuring auto-rotation end-to-end + +### Step 1 — Set the rotation policy on a key + +```bash +# Enable hourly rotation with a 60-second initial offset +ckms sym keys set-rotation-policy \ + --key-id \ + --interval 3600 \ + --offset 60 \ + --name "my-key-set" +``` + +### Step 2 — Enable the server scheduler + +In `kms.toml` (or on the command line): + +```toml +auto_rotation_check_interval_secs = 300 # check every 5 minutes +``` + +### Step 3 — Observe rotations + +The server emits an OpenTelemetry counter `kms.key.auto_rotation` labelled +with the `uid` and `algorithm` on every successful rotation. Use your +OTel-compatible backend (Prometheus + Grafana, Datadog, …) to alert on +unexpected gaps in rotation activity. + +--- + +## Disabling auto-rotation on a key + +Set `x-rotate-interval` to `0`: + +```bash +ckms sym keys set-rotation-policy --key-id --interval 0 +``` + +--- + +## Revoking superseded (old) keys + +After a rotation — whether triggered automatically by the scheduler or manually +via `Re-Key` — **the old key is not revoked automatically**. Its state remains +`Active` so that any in-flight operations that still reference the old UID can +complete gracefully. However, once all consumers have migrated to the new key, +the old key should be revoked to prevent further use and to accurately reflect +its lifecycle state. + +> **How to find the old key UID**: the new key always carries a +> `ReplacedObjectLink` attribute pointing back to the old key UID. Use +> `ckms objects get-attributes --key-id ` or the *Attributes → Get* +> page in the Web UI to read that link. + +### Using the CLI + +The revoke sub-command lives under each key-type group and takes a free-text +revocation reason as its first positional argument: + +```bash +# Symmetric key (old key superseded by rotation) +ckms sym keys revoke -k "Superseded" + +# RSA or EC key pair (revokes both the private key and its linked public key) +ckms rsa keys revoke -k "Superseded" +ckms ec keys revoke -k "Superseded" + +# Post-quantum key pair +ckms pqc keys revoke -k "Superseded" + +# Certificate +ckms certificates revoke -c "Superseded" +``` + +Once a key is in the `Deactivated` state it can still be exported by its owner +(with `--allow-revoked`), but it will be refused for all cryptographic +operations by any other user. + +### Using the Web UI + +1. Navigate to **Objects → Revoke** in the left-hand menu. +2. Enter the old key UID in the *Object ID* field. +3. Type a reason (e.g. `Superseded`) in the *Revocation Reason* field. +4. Click **Revoke**. + +The object's state will change to `Deactivated` immediately. + +--- + +## Interaction with KMIP attributes + +The table below summarises which KMIP attributes are **added** or **updated** +when a key is rotated. + +### Auto-rotation (cron-triggered) + +| Attribute | Old key | New key | +| ----------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------- | +| `Unique Identifier` | unchanged | fresh UUID | +| `Link[ReplacementObjectLink]` | → new key UID | — | +| `Link[ReplacedObjectLink]` | — | → old key UID | +| `Link[WrappingKeyLink]` | unchanged | copied from old key | +| `x-rotate-generation` | unchanged | old value + 1 | +| `x-rotate-date` | unchanged | timestamp of rotation | +| `x-rotate-interval` | **set to `0`** (disabled, so cron skips the old key in future runs) | **inherited** from old key (policy continues on the new key) | +| `x-rotate-name` | unchanged | inherited from old key | +| `x-rotate-offset` | unchanged | inherited from old key | +| `x-initial-date` | cleared | set to now (resets the baseline for the next rotation deadline) | +| `State` | Active | Active | +| `Cryptographic Algorithm` | unchanged | copied from old key | +| `Cryptographic Length` | unchanged | copied from old key | + +### Manual rekey (user-triggered via `Re-Key` / `re-key` CLI) + +When a user explicitly calls `Re-Key` (e.g. `ckms sym keys re-key --key-id `), +the semantics deliberately differ from auto-rotation: + +| Attribute | Old key | New key | +| ----------------------------- | ------------------------- | ----------------------------------------------------------------- | +| `x-rotate-interval` | **set to `0`** (disabled) | **`0`** (not inherited — user must re-arm the new key explicitly) | +| `x-rotate-generation` | unchanged | old value + 1 | +| `Link[ReplacementObjectLink]` | → new key UID | — | +| `Link[ReplacedObjectLink]` | — | → old key UID | + +This asymmetry is intentional: a manual rekey is an out-of-cycle operator action +(e.g. for incident response), so the operator is expected to re-evaluate the +rotation policy for the new key rather than blindly inheriting the old schedule. + +```bash +# After a manual rekey, re-arm the rotation policy on the new key: +ckms sym keys set-rotation-policy \ + --key-id \ + --interval 3600 \ + --name "my-key-set" +``` 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 7956e36ee3..6409e6c0b2 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -194,5 +194,6 @@ nav: - Mac: kmip_support/_mac.md - Re-Key: kmip_support/_re-key.md - Re-Key Key Pair: kmip_support/_re-key_key_pair.md + - Key Auto-Rotation: kmip_support/key_auto_rotation.md - Revoke: kmip_support/_revoke.md - Sign: kmip_support/_signature.md diff --git a/nix/expected-hashes/cli.vendor.dynamic.darwin.sha256 b/nix/expected-hashes/cli.vendor.dynamic.darwin.sha256 index 23c5e76195..6b9611ce98 100644 --- a/nix/expected-hashes/cli.vendor.dynamic.darwin.sha256 +++ b/nix/expected-hashes/cli.vendor.dynamic.darwin.sha256 @@ -1 +1 @@ -sha256-/gTLqWwGUWXNu2ITDiBrS0fTatzzesOfUl8+9ZviPGo= +sha256-ZbwRXWVW6av4QLqi09sgaSFuhZeQqG6Rf4gYlJdjj4M= diff --git a/nix/expected-hashes/cli.vendor.static.darwin.sha256 b/nix/expected-hashes/cli.vendor.static.darwin.sha256 index 3102eaaf59..5a1bf72185 100644 --- a/nix/expected-hashes/cli.vendor.static.darwin.sha256 +++ b/nix/expected-hashes/cli.vendor.static.darwin.sha256 @@ -1 +1 @@ -sha256-8elinoeEptYS3RLZxCAO6DnOadoq9UlejxGM3Tob6hw= +sha256-J0G3tcijxS0wiZZpzkI/rSReNhbjGs68PrGs/JI9+sg= diff --git a/nix/expected-hashes/server.vendor.dynamic.sha256 b/nix/expected-hashes/server.vendor.dynamic.sha256 index a975e5c00c..d5db52dd71 100644 --- a/nix/expected-hashes/server.vendor.dynamic.sha256 +++ b/nix/expected-hashes/server.vendor.dynamic.sha256 @@ -1 +1 @@ -sha256-J/qx8EQssXJ1VlgGmdYC3etWoZHv45HAan2zW9J4V8w= +sha256-6qE45nkRrjJpPC84RCDEEfcCOQmIadQuVXBNMnVlLzo= diff --git a/nix/expected-hashes/server.vendor.static.sha256 b/nix/expected-hashes/server.vendor.static.sha256 index abf6bbb9fe..2e774e0b3e 100644 --- a/nix/expected-hashes/server.vendor.static.sha256 +++ b/nix/expected-hashes/server.vendor.static.sha256 @@ -1 +1 @@ -sha256-zC8wZ56YE3zlTQLIApdxnyB6JOlO/kHLp9NNfWSb2GM= +sha256-+c8bs0Ku9HyCGN8YyGT1RquyICb4jQDJtRPF1xdE0H0= diff --git a/nix/expected-hashes/ui.pnpm.darwin.sha256 b/nix/expected-hashes/ui.pnpm.darwin.sha256 index 2b1efd6597..7caad56590 100644 --- a/nix/expected-hashes/ui.pnpm.darwin.sha256 +++ b/nix/expected-hashes/ui.pnpm.darwin.sha256 @@ -1 +1 @@ -sha256-cE0Vclgd9vLEhEKDQ9EsVkM+epU02Q4TXvZ6fb1t9j0= +sha256-ARaD/EpwcWuPIS9qkxcyUbA+3qGIh/aVgu58QOSUczw= diff --git a/nix/expected-hashes/ui.pnpm.linux.sha256 b/nix/expected-hashes/ui.pnpm.linux.sha256 index 984257088a..1361008630 100644 --- a/nix/expected-hashes/ui.pnpm.linux.sha256 +++ b/nix/expected-hashes/ui.pnpm.linux.sha256 @@ -1 +1 @@ -sha256-scUCQ107D4NhdnwEJ/2YxMN+PFGY4l5SjHy8ge0XmyM= +sha256-bvR4E2wLhNeN57OdD+k5thpbukeIEwFyLVZH6VgKmkA= diff --git a/nix/expected-hashes/ui.vendor.fips.sha256 b/nix/expected-hashes/ui.vendor.fips.sha256 index e8168abfd2..a7f8552539 100644 --- a/nix/expected-hashes/ui.vendor.fips.sha256 +++ b/nix/expected-hashes/ui.vendor.fips.sha256 @@ -1 +1 @@ -sha256-CzrBCCBbp53kXhGc9L0MSW8y2KKfaZwGpe2aiJGJye4= +sha256-Ssk4EPQEbNN5MNJyOXAn3T569WaqgkgZbU7V+FxhlaE= diff --git a/nix/expected-hashes/ui.vendor.non-fips.sha256 b/nix/expected-hashes/ui.vendor.non-fips.sha256 index eac4b17b5f..828edad25e 100644 --- a/nix/expected-hashes/ui.vendor.non-fips.sha256 +++ b/nix/expected-hashes/ui.vendor.non-fips.sha256 @@ -1 +1 @@ -sha256-SbxShAYxyBVZuxI0xJRJ1oXtubKjbXXDi2MSomRWyc4= +sha256-ghceCmTkYBLx7xup3nnaMMljTgVXtI5eMKSwLf64Rp0= diff --git a/pkg/kms.toml b/pkg/kms.toml index f25fea7b62..2cb36b1ae0 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 6687af9997..ac05c8cfbd 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/.gitignore b/scripts/.gitignore deleted file mode 100644 index d63edf2f86..0000000000 --- a/scripts/.gitignore +++ /dev/null @@ -1 +0,0 @@ -kms.db diff --git a/scripts/generate_rekey_vectors.sh b/scripts/generate_rekey_vectors.sh deleted file mode 100755 index 5e2eb1fb99..0000000000 --- a/scripts/generate_rekey_vectors.sh +++ /dev/null @@ -1,1954 +0,0 @@ -#!/usr/bin/env bash -# Generate test vector files for ReKey and ReKeyKeyPair operations -set -euo pipefail - -BASE="/Users/manu/Cosmian/core/kms_alt3/test_data/vectors" - -# ===== HELPER: Common JSON fragments ===== - -create_sym_key_named() { - local name="$1" - cat <"$DIR/step1_create.json" -# Step 2: Export with wrapping (wrap the key using itself for test purposes) -cat >"$DIR/step2_rekey.json" <<'EOF' -{ - "tag": "ReKey", - "value": [ - {"tag": "UniqueIdentifier", "type": "TextString", "value": "{{key_id}}"} - ] -} -EOF - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKey Wrapped Key Fails" -description = """ -Verifies that ReKey on a wrapped key fails with an appropriate error. \ -The server cannot safely rekey a wrapped object. -""" - -# NOTE: This test requires a pre-wrapped key. Since wrapping requires -# a wrapping key, we test this by importing a wrapped key fixture. -# For now, we use a simpler approach: create + rekey should succeed -# to validate the wrapped-key check is wired in correctly. -# The actual "wrapped key fails" negative test requires importing -# a pre-wrapped key fixture. - -[[steps]] -operation = "Create" -request = "step1_create.json" -assert_success = true -[steps.capture] -key_id = "UniqueIdentifier" - -[[steps]] -operation = "ReKey" -request = "step2_rekey.json" -assert_success = true - -[steps.capture] -new_key_id = "UniqueIdentifier" - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step3_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step4_destroy_old.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step5_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step6_destroy_new.json" -assert_success = true -EOF - -revoke_request "key_id" >"$DIR/step3_revoke_old.json" -destroy_request "key_id" >"$DIR/step4_destroy_old.json" -revoke_request "new_key_id" >"$DIR/step5_revoke_new.json" -destroy_request "new_key_id" >"$DIR/step6_destroy_new.json" - -# ===== 2. rekey_with_offset ===== -DIR="$BASE/fips/kmip_operations/rekey_with_offset" - -create_sym_key_named "rekey-offset-test" >"$DIR/step1_create.json" -rekey_with_offset "key_id" 3600 >"$DIR/step2_rekey.json" -get_attributes "new_key_id" >"$DIR/step3_get_attributes_new.json" -revoke_request "key_id" >"$DIR/step4_revoke_old.json" -destroy_request "key_id" >"$DIR/step5_destroy_old.json" -revoke_request "new_key_id" >"$DIR/step6_revoke_new.json" -destroy_request "new_key_id" >"$DIR/step7_destroy_new.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKey With Offset" -description = """ -Verifies that ReKey with an Offset parameter correctly computes \ -the replacement key's Activation Date as InitializationDate + Offset. -""" - -[[steps]] -operation = "Create" -request = "step1_create.json" -assert_success = true -[steps.capture] -key_id = "UniqueIdentifier" - -[[steps]] -operation = "ReKey" -request = "step2_rekey.json" -assert_success = true -[steps.capture] -new_key_id = "UniqueIdentifier" - -# Verify the new key has attributes set (at minimum, InitialDate exists) -[[steps]] -operation = "GetAttributes" -request = "step3_get_attributes_new.json" -assert_success = true - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step4_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step5_destroy_old.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step6_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step7_destroy_new.json" -assert_success = true -EOF - -# ===== 3. rekey_double_chain ===== -DIR="$BASE/fips/kmip_operations/rekey_double_chain" - -create_sym_key_named "rekey-chain-test" >"$DIR/step1_create.json" -rekey_request "key_id" >"$DIR/step2_rekey_first.json" -rekey_request "new_key_id_1" >"$DIR/step3_rekey_second.json" -get_attributes "key_id" >"$DIR/step4_get_attrs_k1.json" -get_attributes "new_key_id_1" >"$DIR/step5_get_attrs_k2.json" -get_attributes "new_key_id_2" >"$DIR/step6_get_attrs_k3.json" -revoke_request "key_id" >"$DIR/step7_revoke_k1.json" -destroy_request "key_id" >"$DIR/step8_destroy_k1.json" -revoke_request "new_key_id_1" >"$DIR/step9_revoke_k2.json" -destroy_request "new_key_id_1" >"$DIR/step10_destroy_k2.json" -revoke_request "new_key_id_2" >"$DIR/step11_revoke_k3.json" -destroy_request "new_key_id_2" >"$DIR/step12_destroy_k3.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKey Double Chain" -description = """ -Verifies that re-keying twice creates a proper chain: K1→K2→K3. \ -K1.ReplacementObjectLink=K2, K2.ReplacedObjectLink=K1, \ -K2.ReplacementObjectLink=K3, K3.ReplacedObjectLink=K2. -""" - -[[steps]] -operation = "Create" -request = "step1_create.json" -assert_success = true -[steps.capture] -key_id = "UniqueIdentifier" - -[[steps]] -operation = "ReKey" -request = "step2_rekey_first.json" -assert_success = true -[steps.capture] -new_key_id_1 = "UniqueIdentifier" - -[[steps]] -operation = "ReKey" -request = "step3_rekey_second.json" -assert_success = true -[steps.capture] -new_key_id_2 = "UniqueIdentifier" - -# K1 should have ReplacementObjectLink → K2 -[[steps]] -operation = "GetAttributes" -request = "step4_get_attrs_k1.json" -assert_success = true -[steps.assert_fields] -LinkedObjectIdentifier = "{{new_key_id_1}}" - -# K2 should have ReplacedObjectLink → K1 and ReplacementObjectLink → K3 -[[steps]] -operation = "GetAttributes" -request = "step5_get_attrs_k2.json" -assert_success = true -[steps.assert_any_field] -LinkedObjectIdentifier = "{{new_key_id_2}}" - -# K3 should have ReplacedObjectLink → K2 -[[steps]] -operation = "GetAttributes" -request = "step6_get_attrs_k3.json" -assert_success = true -[steps.assert_fields] -LinkedObjectIdentifier = "{{new_key_id_1}}" - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step7_revoke_k1.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_k1.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step9_revoke_k2.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step10_destroy_k2.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step11_revoke_k3.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step12_destroy_k3.json" -assert_success = true -EOF - -# ===== 4. rekey_old_key_still_decrypts ===== -DIR="$BASE/fips/kmip_operations/rekey_old_key_still_decrypts" - -create_sym_key_named "rekey-decrypt-test" >"$DIR/step1_create.json" -encrypt_request "key_id" >"$DIR/step2_encrypt.json" -rekey_request "key_id" >"$DIR/step3_rekey.json" -# Decrypt with old key (still active) -cat >"$DIR/step4_decrypt_old.json" <<'EOF' -{ - "tag": "Decrypt", - "value": [ - {"tag": "UniqueIdentifier", "type": "TextString", "value": "{{key_id}}"}, - {"tag": "Data", "type": "ByteString", "value": "{{ciphertext}}"}, - {"tag": "IvCounterNonce", "type": "ByteString", "value": "{{nonce}}"} - ] -} -EOF -revoke_request "key_id" >"$DIR/step5_revoke_old.json" -destroy_request "key_id" >"$DIR/step6_destroy_old.json" -revoke_request "new_key_id" >"$DIR/step7_revoke_new.json" -destroy_request "new_key_id" >"$DIR/step8_destroy_new.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKey Old Key Still Decrypts" -description = """ -Verifies that after ReKey, the old key remains Active and can still \ -decrypt data that was encrypted with it before the rekey. -""" - -[[steps]] -operation = "Create" -request = "step1_create.json" -assert_success = true -[steps.capture] -key_id = "UniqueIdentifier" - -[[steps]] -operation = "Encrypt" -request = "step2_encrypt.json" -assert_success = true -[steps.capture] -ciphertext = "Data" -nonce = "IvCounterNonce" - -[[steps]] -operation = "ReKey" -request = "step3_rekey.json" -assert_success = true -[steps.capture] -new_key_id = "UniqueIdentifier" - -# Old key should still decrypt (it remains Active) -[[steps]] -operation = "Decrypt" -request = "step4_decrypt_old.json" -assert_success = true - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step5_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step6_destroy_old.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step7_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_new.json" -assert_success = true -EOF - -# ===== 5. rekey_name_removed_from_old ===== -DIR="$BASE/fips/kmip_operations/rekey_name_removed_from_old" - -create_sym_key_named "rekey-name-remove-test" >"$DIR/step1_create.json" -rekey_request "key_id" >"$DIR/step2_rekey.json" -get_attributes "key_id" >"$DIR/step3_get_attrs_old.json" -revoke_request "key_id" >"$DIR/step4_revoke_old.json" -destroy_request "key_id" >"$DIR/step5_destroy_old.json" -revoke_request "new_key_id" >"$DIR/step6_revoke_new.json" -destroy_request "new_key_id" >"$DIR/step7_destroy_new.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKey Name Removed From Old Key" -description = """ -Verifies that after ReKey, the old key no longer has the Name attribute \ -(it was transferred to the replacement key). -""" - -[[steps]] -operation = "Create" -request = "step1_create.json" -assert_success = true -[steps.capture] -key_id = "UniqueIdentifier" - -[[steps]] -operation = "ReKey" -request = "step2_rekey.json" -assert_success = true -[steps.capture] -new_key_id = "UniqueIdentifier" - -# Old key should NOT have the Name attribute anymore -[[steps]] -operation = "GetAttributes" -request = "step3_get_attrs_old.json" -assert_success = true -assert_fields_absent = ["Name"] - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step4_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step5_destroy_old.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step6_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step7_destroy_new.json" -assert_success = true -EOF - -echo "ReKey symmetric vectors created." - -# ===== ReKeyKeyPair vectors ===== - -# Helper for EC keypair rekey vectors with full link verification -create_rekey_keypair_ec_links_vector() { - local dir="$1" - local algo="$2" - local curve="$3" - - create_keypair_ec "$algo" "$curve" >"$dir/step1_create_keypair.json" - rekey_keypair_request "private_key_id" >"$dir/step2_rekey_keypair.json" - get_attributes "private_key_id" >"$dir/step3_get_attrs_old_sk.json" - get_attributes "public_key_id" >"$dir/step4_get_attrs_old_pk.json" - get_attributes "new_private_key_id" >"$dir/step5_get_attrs_new_sk.json" - get_attributes "new_public_key_id" >"$dir/step6_get_attrs_new_pk.json" - revoke_request "private_key_id" >"$dir/step7_revoke_old_sk.json" - destroy_request "private_key_id" >"$dir/step8_destroy_old_sk.json" - revoke_request "new_private_key_id" >"$dir/step9_revoke_new_sk.json" - destroy_request "new_private_key_id" >"$dir/step10_destroy_new_sk.json" - destroy_request "public_key_id" >"$dir/step11_destroy_old_pk.json" - destroy_request "new_public_key_id" >"$dir/step12_destroy_new_pk.json" -} - -# ===== 6. rekey_keypair_ec_with_links ===== -DIR="$BASE/fips/kmip_operations/rekey_keypair_ec_with_links" -create_rekey_keypair_ec_links_vector "$DIR" "ECDSA" "P256" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKeyKeyPair EC P-256 With Links" -description = """ -Verifies that ReKeyKeyPair on an EC P-256 key pair properly sets \ -ReplacementObjectLink on both old keys and ReplacedObjectLink on both new keys. -""" - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step2_rekey_keypair.json" -assert_success = true -[steps.capture] -new_private_key_id = "PrivateKeyUniqueIdentifier" -new_public_key_id = "PublicKeyUniqueIdentifier" - -# Old SK has ReplacementObjectLink → new SK -[[steps]] -operation = "GetAttributes" -request = "step3_get_attrs_old_sk.json" -assert_success = true -[steps.assert_any_field] -LinkedObjectIdentifier = "{{new_private_key_id}}" - -# Old PK has ReplacementObjectLink → new PK -[[steps]] -operation = "GetAttributes" -request = "step4_get_attrs_old_pk.json" -assert_success = true -[steps.assert_any_field] -LinkedObjectIdentifier = "{{new_public_key_id}}" - -# New SK has ReplacedObjectLink → old SK -[[steps]] -operation = "GetAttributes" -request = "step5_get_attrs_new_sk.json" -assert_success = true -[steps.assert_any_field] -LinkedObjectIdentifier = "{{private_key_id}}" - -# New PK has ReplacedObjectLink → old PK -[[steps]] -operation = "GetAttributes" -request = "step6_get_attrs_new_pk.json" -assert_success = true -[steps.assert_any_field] -LinkedObjectIdentifier = "{{public_key_id}}" - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step7_revoke_old_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_old_sk.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step9_revoke_new_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step10_destroy_new_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step11_destroy_old_pk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step12_destroy_new_pk.json" -assert_success = true -EOF - -# ===== 7. rekey_keypair_rsa_with_links ===== -DIR="$BASE/fips/kmip_operations/rekey_keypair_rsa_with_links" -create_keypair_rsa 2048 >"$DIR/step1_create_keypair.json" -rekey_keypair_request "private_key_id" >"$DIR/step2_rekey_keypair.json" -get_attributes "private_key_id" >"$DIR/step3_get_attrs_old_sk.json" -get_attributes "new_private_key_id" >"$DIR/step4_get_attrs_new_sk.json" -revoke_request "private_key_id" >"$DIR/step5_revoke_old.json" -destroy_request "private_key_id" >"$DIR/step6_destroy_old_sk.json" -revoke_request "new_private_key_id" >"$DIR/step7_revoke_new.json" -destroy_request "new_private_key_id" >"$DIR/step8_destroy_new_sk.json" -destroy_request "public_key_id" >"$DIR/step9_destroy_old_pk.json" -destroy_request "new_public_key_id" >"$DIR/step10_destroy_new_pk.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKeyKeyPair RSA-2048 With Links" -description = """ -Verifies that ReKeyKeyPair on an RSA-2048 key pair properly sets \ -ReplacementObjectLink/ReplacedObjectLink bidirectionally. -""" - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step2_rekey_keypair.json" -assert_success = true -[steps.capture] -new_private_key_id = "PrivateKeyUniqueIdentifier" -new_public_key_id = "PublicKeyUniqueIdentifier" - -# Old SK has ReplacementObjectLink → new SK -[[steps]] -operation = "GetAttributes" -request = "step3_get_attrs_old_sk.json" -assert_success = true -[steps.assert_any_field] -LinkedObjectIdentifier = "{{new_private_key_id}}" - -# New SK has ReplacedObjectLink → old SK -[[steps]] -operation = "GetAttributes" -request = "step4_get_attrs_new_sk.json" -assert_success = true -[steps.assert_any_field] -LinkedObjectIdentifier = "{{private_key_id}}" - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step5_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step6_destroy_old_sk.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step7_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_new_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step9_destroy_old_pk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step10_destroy_new_pk.json" -assert_success = true -EOF - -# ===== 8. rekey_keypair_ec_locate_by_name ===== -DIR="$BASE/fips/kmip_operations/rekey_keypair_ec_locate_by_name" -create_keypair_ec_named "ECDSA" "P256" "rekey-kp-locate-test" >"$DIR/step1_create_keypair.json" -locate_by_name "rekey-kp-locate-test" >"$DIR/step2_locate_before.json" -rekey_keypair_request "private_key_id" >"$DIR/step3_rekey_keypair.json" -locate_by_name "rekey-kp-locate-test" >"$DIR/step4_locate_after.json" -revoke_request "private_key_id" >"$DIR/step5_revoke_old.json" -destroy_request "private_key_id" >"$DIR/step6_destroy_old_sk.json" -revoke_request "new_private_key_id" >"$DIR/step7_revoke_new.json" -destroy_request "new_private_key_id" >"$DIR/step8_destroy_new_sk.json" -destroy_request "public_key_id" >"$DIR/step9_destroy_old_pk.json" -destroy_request "new_public_key_id" >"$DIR/step10_destroy_new_pk.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKeyKeyPair EC Locate By Name" -description = """ -Verifies that after ReKeyKeyPair, the replacement private key inherits \ -the Name attribute and can be found via Locate by name. -""" - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -# Locate should find the original private key by name -[[steps]] -operation = "Locate" -request = "step2_locate_before.json" -assert_success = true -[steps.assert_fields] -UniqueIdentifier = "{{private_key_id}}" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step3_rekey_keypair.json" -assert_success = true -[steps.capture] -new_private_key_id = "PrivateKeyUniqueIdentifier" -new_public_key_id = "PublicKeyUniqueIdentifier" - -# After ReKey, Locate by name should find the NEW private key -[[steps]] -operation = "Locate" -request = "step4_locate_after.json" -assert_success = true -[steps.assert_fields] -UniqueIdentifier = "{{new_private_key_id}}" - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step5_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step6_destroy_old_sk.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step7_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_new_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step9_destroy_old_pk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step10_destroy_new_pk.json" -assert_success = true -EOF - -# ===== 9. rekey_keypair_ec_sign_verify ===== -DIR="$BASE/fips/kmip_operations/rekey_keypair_ec_sign_verify" -create_keypair_ec "ECDSA" "P256" >"$DIR/step1_create_keypair.json" -rekey_keypair_request "private_key_id" >"$DIR/step2_rekey_keypair.json" -sign_request "new_private_key_id" >"$DIR/step3_sign.json" -verify_request "new_public_key_id" "signature" >"$DIR/step4_verify.json" -revoke_request "private_key_id" >"$DIR/step5_revoke_old.json" -destroy_request "private_key_id" >"$DIR/step6_destroy_old_sk.json" -revoke_request "new_private_key_id" >"$DIR/step7_revoke_new.json" -destroy_request "new_private_key_id" >"$DIR/step8_destroy_new_sk.json" -destroy_request "public_key_id" >"$DIR/step9_destroy_old_pk.json" -destroy_request "new_public_key_id" >"$DIR/step10_destroy_new_pk.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKeyKeyPair EC Sign/Verify With New Key" -description = """ -Verifies that after ReKeyKeyPair, the new private key can sign \ -and the new public key can verify the signature. -""" - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step2_rekey_keypair.json" -assert_success = true -[steps.capture] -new_private_key_id = "PrivateKeyUniqueIdentifier" -new_public_key_id = "PublicKeyUniqueIdentifier" - -# Sign with new private key -[[steps]] -operation = "Sign" -request = "step3_sign.json" -assert_success = true -[steps.capture] -signature = "Signature" - -# Verify with new public key -[[steps]] -operation = "SignatureVerify" -request = "step4_verify.json" -assert_success = true - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step5_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step6_destroy_old_sk.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step7_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_new_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step9_destroy_old_pk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step10_destroy_new_pk.json" -assert_success = true -EOF - -# ===== Simple ReKeyKeyPair vectors for different algorithms ===== -create_simple_rekey_keypair_vector() { - local dir="$1" - local name="$2" - local create_json="$3" - - echo "$create_json" >"$dir/step1_create_keypair.json" - rekey_keypair_request "private_key_id" >"$dir/step2_rekey_keypair.json" - revoke_request "private_key_id" >"$dir/step3_revoke_old.json" - destroy_request "private_key_id" >"$dir/step4_destroy_old_sk.json" - revoke_request "new_private_key_id" >"$dir/step5_revoke_new.json" - destroy_request "new_private_key_id" >"$dir/step6_destroy_new_sk.json" - destroy_request "public_key_id" >"$dir/step7_destroy_old_pk.json" - destroy_request "new_public_key_id" >"$dir/step8_destroy_new_pk.json" - - cat >"$dir/manifest.toml" <"$DIR/step1_create_keypair.json" -rekey_keypair_request "private_key_id" >"$DIR/step2_rekey_keypair.json" -cat >"$DIR/step3_encrypt.json" <<'EOF' -{ - "tag": "Encrypt", - "value": [ - {"tag": "UniqueIdentifier", "type": "TextString", "value": "{{new_public_key_id}}"}, - {"tag": "Data", "type": "ByteString", "value": "AQIDBA=="}, - {"tag": "CryptographicParameters", "value": [ - {"tag": "PaddingMethod", "type": "Enumeration", "value": "OAEP"}, - {"tag": "HashingAlgorithm", "type": "Enumeration", "value": "SHA256"} - ]} - ] -} -EOF -cat >"$DIR/step4_decrypt.json" <<'EOF' -{ - "tag": "Decrypt", - "value": [ - {"tag": "UniqueIdentifier", "type": "TextString", "value": "{{new_private_key_id}}"}, - {"tag": "Data", "type": "ByteString", "value": "{{ciphertext}}"}, - {"tag": "CryptographicParameters", "value": [ - {"tag": "PaddingMethod", "type": "Enumeration", "value": "OAEP"}, - {"tag": "HashingAlgorithm", "type": "Enumeration", "value": "SHA256"} - ]} - ] -} -EOF -revoke_request "private_key_id" >"$DIR/step5_revoke_old.json" -destroy_request "private_key_id" >"$DIR/step6_destroy_old_sk.json" -revoke_request "new_private_key_id" >"$DIR/step7_revoke_new.json" -destroy_request "new_private_key_id" >"$DIR/step8_destroy_new_sk.json" -destroy_request "public_key_id" >"$DIR/step9_destroy_old_pk.json" -destroy_request "new_public_key_id" >"$DIR/step10_destroy_new_pk.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKeyKeyPair RSA Encrypt/Decrypt With New Key" -description = """ -Verifies that after ReKeyKeyPair, the new public key can encrypt \ -and the new private key can decrypt. -""" - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step2_rekey_keypair.json" -assert_success = true -[steps.capture] -new_private_key_id = "PrivateKeyUniqueIdentifier" -new_public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "Encrypt" -request = "step3_encrypt.json" -assert_success = true -[steps.capture] -ciphertext = "Data" - -[[steps]] -operation = "Decrypt" -request = "step4_decrypt.json" -assert_success = true - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step5_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step6_destroy_old_sk.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step7_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_new_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step9_destroy_old_pk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step10_destroy_new_pk.json" -assert_success = true -EOF - -# ===== 19. rekey_keypair_with_offset ===== -DIR="$BASE/fips/kmip_operations/rekey_keypair_with_offset" -create_keypair_ec "ECDSA" "P256" >"$DIR/step1_create_keypair.json" -rekey_keypair_with_offset "private_key_id" 7200 >"$DIR/step2_rekey_keypair.json" -get_attributes "new_private_key_id" >"$DIR/step3_get_attrs_new.json" -revoke_request "private_key_id" >"$DIR/step4_revoke_old.json" -destroy_request "private_key_id" >"$DIR/step5_destroy_old_sk.json" -revoke_request "new_private_key_id" >"$DIR/step6_revoke_new.json" -destroy_request "new_private_key_id" >"$DIR/step7_destroy_new_sk.json" -destroy_request "public_key_id" >"$DIR/step8_destroy_old_pk.json" -destroy_request "new_public_key_id" >"$DIR/step9_destroy_new_pk.json" - -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. -""" - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step2_rekey_keypair.json" -assert_success = true -[steps.capture] -new_private_key_id = "PrivateKeyUniqueIdentifier" -new_public_key_id = "PublicKeyUniqueIdentifier" - -# Verify new key has attributes (InitialDate set) -[[steps]] -operation = "GetAttributes" -request = "step3_get_attrs_new.json" -assert_success = true - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step4_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step5_destroy_old_sk.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step6_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step7_destroy_new_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_old_pk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step9_destroy_new_pk.json" -assert_success = true -EOF - -# ===== 20. rekey_keypair_double_chain ===== -DIR="$BASE/fips/kmip_operations/rekey_keypair_double_chain" -create_keypair_ec "ECDSA" "P256" >"$DIR/step1_create_keypair.json" -rekey_keypair_request "private_key_id" >"$DIR/step2_rekey_first.json" -rekey_keypair_request "new_sk_1" >"$DIR/step3_rekey_second.json" -get_attributes "private_key_id" >"$DIR/step4_get_attrs_kp1.json" -get_attributes "new_sk_1" >"$DIR/step5_get_attrs_kp2.json" -get_attributes "new_sk_2" >"$DIR/step6_get_attrs_kp3.json" -revoke_request "private_key_id" >"$DIR/step7_revoke_kp1.json" -destroy_request "private_key_id" >"$DIR/step8_destroy_kp1_sk.json" -revoke_request "new_sk_1" >"$DIR/step9_revoke_kp2.json" -destroy_request "new_sk_1" >"$DIR/step10_destroy_kp2_sk.json" -revoke_request "new_sk_2" >"$DIR/step11_revoke_kp3.json" -destroy_request "new_sk_2" >"$DIR/step12_destroy_kp3_sk.json" -destroy_request "public_key_id" >"$DIR/step13_destroy_kp1_pk.json" -destroy_request "new_pk_1" >"$DIR/step14_destroy_kp2_pk.json" -destroy_request "new_pk_2" >"$DIR/step15_destroy_kp3_pk.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKeyKeyPair Double Chain" -description = """ -Verifies that re-keying a key pair twice creates a proper chain. \ -KP1→KP2→KP3 with correct link attributes. -""" - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step2_rekey_first.json" -assert_success = true -[steps.capture] -new_sk_1 = "PrivateKeyUniqueIdentifier" -new_pk_1 = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step3_rekey_second.json" -assert_success = true -[steps.capture] -new_sk_2 = "PrivateKeyUniqueIdentifier" -new_pk_2 = "PublicKeyUniqueIdentifier" - -# KP1 SK has ReplacementObjectLink → KP2 SK -[[steps]] -operation = "GetAttributes" -request = "step4_get_attrs_kp1.json" -assert_success = true -[steps.assert_any_field] -LinkedObjectIdentifier = "{{new_sk_1}}" - -# KP2 SK has ReplacementObjectLink → KP3 SK -[[steps]] -operation = "GetAttributes" -request = "step5_get_attrs_kp2.json" -assert_success = true -[steps.assert_any_field] -LinkedObjectIdentifier = "{{new_sk_2}}" - -# KP3 SK has ReplacedObjectLink → KP2 SK -[[steps]] -operation = "GetAttributes" -request = "step6_get_attrs_kp3.json" -assert_success = true -[steps.assert_any_field] -LinkedObjectIdentifier = "{{new_sk_1}}" - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step7_revoke_kp1.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_kp1_sk.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step9_revoke_kp2.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step10_destroy_kp2_sk.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step11_revoke_kp3.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step12_destroy_kp3_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step13_destroy_kp1_pk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step14_destroy_kp2_pk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step15_destroy_kp3_pk.json" -assert_success = true -EOF - -# ===== 21. Negative: rekey_keypair_deactivated_fails ===== -DIR="$BASE/fips/kmip_operations/rekey_keypair_deactivated_fails" -create_keypair_ec "ECDSA" "P256" >"$DIR/step1_create_keypair.json" -revoke_request "private_key_id" >"$DIR/step2_revoke.json" -rekey_keypair_request "private_key_id" >"$DIR/step3_rekey_keypair.json" -destroy_request "private_key_id" >"$DIR/step4_destroy_sk.json" -destroy_request "public_key_id" >"$DIR/step5_destroy_pk.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "Negative: ReKeyKeyPair Deactivated Fails" -description = """ -Verifies that ReKeyKeyPair on a revoked/deactivated private key fails. -""" - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "Revoke" -request = "step2_revoke.json" -assert_success = true - -[[steps]] -operation = "ReKeyKeyPair" -request = "step3_rekey_keypair.json" -assert_success = false -assert_error_reason = "Item_Not_Found" - -# Cleanup -[[steps]] -operation = "Destroy" -request = "step4_destroy_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step5_destroy_pk.json" -assert_success = true -EOF - -# ===== 22. Negative: rekey_keypair_change_algo_fails ===== -DIR="$BASE/fips/kmip_operations/rekey_keypair_change_algo_fails" -create_keypair_ec "ECDSA" "P256" >"$DIR/step1_create_keypair.json" -rekey_keypair_change_algo "private_key_id" >"$DIR/step2_rekey_keypair.json" -revoke_request "private_key_id" >"$DIR/step3_revoke.json" -destroy_request "private_key_id" >"$DIR/step4_destroy_sk.json" -destroy_request "public_key_id" >"$DIR/step5_destroy_pk.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "Negative: ReKeyKeyPair Change Algorithm Fails" -description = """ -Verifies that ReKeyKeyPair rejects a request that tries to change \ -the cryptographic algorithm (from EC to RSA). -""" - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step2_rekey_keypair.json" -assert_success = false -assert_error_contains = "changing the cryptographic algorithm is not allowed" - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step3_revoke.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step4_destroy_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step5_destroy_pk.json" -assert_success = true -EOF - -# ===== 23. rekey_keypair_old_key_still_active ===== -DIR="$BASE/fips/kmip_operations/rekey_keypair_old_key_still_active" -create_keypair_ec "ECDSA" "P256" >"$DIR/step1_create_keypair.json" -rekey_keypair_request "private_key_id" >"$DIR/step2_rekey_keypair.json" -get_attributes "private_key_id" >"$DIR/step3_get_attrs_old.json" -revoke_request "private_key_id" >"$DIR/step4_revoke_old.json" -destroy_request "private_key_id" >"$DIR/step5_destroy_old_sk.json" -revoke_request "new_private_key_id" >"$DIR/step6_revoke_new.json" -destroy_request "new_private_key_id" >"$DIR/step7_destroy_new_sk.json" -destroy_request "public_key_id" >"$DIR/step8_destroy_old_pk.json" -destroy_request "new_public_key_id" >"$DIR/step9_destroy_new_pk.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKeyKeyPair Old Key Still Active" -description = """ -Verifies that after ReKeyKeyPair, the old private key remains in Active state. -""" - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step2_rekey_keypair.json" -assert_success = true -[steps.capture] -new_private_key_id = "PrivateKeyUniqueIdentifier" -new_public_key_id = "PublicKeyUniqueIdentifier" - -# Old key should still be Active -[[steps]] -operation = "GetAttributes" -request = "step3_get_attrs_old.json" -assert_success = true -[steps.assert_fields] -State = "Active" - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step4_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step5_destroy_old_sk.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step6_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step7_destroy_new_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_old_pk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step9_destroy_new_pk.json" -assert_success = true -EOF - -# ===== 24. rekey_keypair_name_removed_from_old ===== -DIR="$BASE/fips/kmip_operations/rekey_keypair_name_removed_from_old" -create_keypair_ec_named "ECDSA" "P256" "rekey-kp-name-rm-test" >"$DIR/step1_create_keypair.json" -rekey_keypair_request "private_key_id" >"$DIR/step2_rekey_keypair.json" -get_attributes "private_key_id" >"$DIR/step3_get_attrs_old.json" -revoke_request "private_key_id" >"$DIR/step4_revoke_old.json" -destroy_request "private_key_id" >"$DIR/step5_destroy_old_sk.json" -revoke_request "new_private_key_id" >"$DIR/step6_revoke_new.json" -destroy_request "new_private_key_id" >"$DIR/step7_destroy_new_sk.json" -destroy_request "public_key_id" >"$DIR/step8_destroy_old_pk.json" -destroy_request "new_public_key_id" >"$DIR/step9_destroy_new_pk.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKeyKeyPair Name Removed From Old" -description = """ -Verifies that after ReKeyKeyPair, the old private key no longer has the Name attribute. -""" - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step2_rekey_keypair.json" -assert_success = true -[steps.capture] -new_private_key_id = "PrivateKeyUniqueIdentifier" -new_public_key_id = "PublicKeyUniqueIdentifier" - -# Old SK should NOT have Name anymore -[[steps]] -operation = "GetAttributes" -request = "step3_get_attrs_old.json" -assert_success = true -assert_fields_absent = ["Name"] - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step4_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step5_destroy_old_sk.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step6_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step7_destroy_new_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_old_pk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step9_destroy_new_pk.json" -assert_success = true -EOF - -# ===== Non-FIPS vectors ===== - -# 25. Ed25519 -DIR="$BASE/non-fips/rekey_keypair_ed25519" -cat >"$DIR/step1_create_keypair.json" <<'EOF' -{ - "tag": "CreateKeyPair", - "value": [ - {"tag": "CommonAttributes", "value": [ - {"tag": "CryptographicAlgorithm", "type": "Enumeration", "value": "Ed25519"}, - {"tag": "CryptographicDomainParameters", "value": [ - {"tag": "RecommendedCurve", "type": "Enumeration", "value": "CURVEED25519"} - ]}, - {"tag": "KeyFormatType", "type": "Enumeration", "value": "ECPrivateKey"}, - {"tag": "CryptographicUsageMask", "type": "Integer", "value": 12}, - {"tag": "ActivationDate", "type": "DateTime", "value": "2024-01-01T00:00:00Z"} - ]} - ] -} -EOF -rekey_keypair_request "private_key_id" >"$DIR/step2_rekey_keypair.json" -revoke_request "private_key_id" >"$DIR/step3_revoke_old.json" -destroy_request "private_key_id" >"$DIR/step4_destroy_old_sk.json" -revoke_request "new_private_key_id" >"$DIR/step5_revoke_new.json" -destroy_request "new_private_key_id" >"$DIR/step6_destroy_new_sk.json" -destroy_request "public_key_id" >"$DIR/step7_destroy_old_pk.json" -destroy_request "new_public_key_id" >"$DIR/step8_destroy_new_pk.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKeyKeyPair Ed25519" -description = "Verifies that ReKeyKeyPair succeeds for Ed25519 key pairs (non-FIPS)." - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step2_rekey_keypair.json" -assert_success = true -[steps.capture] -new_private_key_id = "PrivateKeyUniqueIdentifier" -new_public_key_id = "PublicKeyUniqueIdentifier" - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step3_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step4_destroy_old_sk.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step5_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step6_destroy_new_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step7_destroy_old_pk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_new_pk.json" -assert_success = true -EOF - -# 26. X25519 -DIR="$BASE/non-fips/rekey_keypair_x25519" -cat >"$DIR/step1_create_keypair.json" <<'EOF' -{ - "tag": "CreateKeyPair", - "value": [ - {"tag": "CommonAttributes", "value": [ - {"tag": "CryptographicAlgorithm", "type": "Enumeration", "value": "ECDH"}, - {"tag": "CryptographicDomainParameters", "value": [ - {"tag": "RecommendedCurve", "type": "Enumeration", "value": "CURVE25519"} - ]}, - {"tag": "KeyFormatType", "type": "Enumeration", "value": "ECPrivateKey"}, - {"tag": "CryptographicUsageMask", "type": "Integer", "value": 12}, - {"tag": "ActivationDate", "type": "DateTime", "value": "2024-01-01T00:00:00Z"} - ]} - ] -} -EOF -rekey_keypair_request "private_key_id" >"$DIR/step2_rekey_keypair.json" -revoke_request "private_key_id" >"$DIR/step3_revoke_old.json" -destroy_request "private_key_id" >"$DIR/step4_destroy_old_sk.json" -revoke_request "new_private_key_id" >"$DIR/step5_revoke_new.json" -destroy_request "new_private_key_id" >"$DIR/step6_destroy_new_sk.json" -destroy_request "public_key_id" >"$DIR/step7_destroy_old_pk.json" -destroy_request "new_public_key_id" >"$DIR/step8_destroy_new_pk.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKeyKeyPair X25519" -description = "Verifies that ReKeyKeyPair succeeds for X25519 ECDH key pairs (non-FIPS)." - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step2_rekey_keypair.json" -assert_success = true -[steps.capture] -new_private_key_id = "PrivateKeyUniqueIdentifier" -new_public_key_id = "PublicKeyUniqueIdentifier" - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step3_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step4_destroy_old_sk.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step5_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step6_destroy_new_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step7_destroy_old_pk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_new_pk.json" -assert_success = true -EOF - -# 27. secp256k1 -DIR="$BASE/non-fips/rekey_keypair_secp256k1" -cat >"$DIR/step1_create_keypair.json" <<'EOF' -{ - "tag": "CreateKeyPair", - "value": [ - {"tag": "CommonAttributes", "value": [ - {"tag": "CryptographicAlgorithm", "type": "Enumeration", "value": "ECDSA"}, - {"tag": "CryptographicDomainParameters", "value": [ - {"tag": "RecommendedCurve", "type": "Enumeration", "value": "SECP256K1"} - ]}, - {"tag": "KeyFormatType", "type": "Enumeration", "value": "ECPrivateKey"}, - {"tag": "CryptographicUsageMask", "type": "Integer", "value": 12}, - {"tag": "ActivationDate", "type": "DateTime", "value": "2024-01-01T00:00:00Z"} - ]} - ] -} -EOF -rekey_keypair_request "private_key_id" >"$DIR/step2_rekey_keypair.json" -revoke_request "private_key_id" >"$DIR/step3_revoke_old.json" -destroy_request "private_key_id" >"$DIR/step4_destroy_old_sk.json" -revoke_request "new_private_key_id" >"$DIR/step5_revoke_new.json" -destroy_request "new_private_key_id" >"$DIR/step6_destroy_new_sk.json" -destroy_request "public_key_id" >"$DIR/step7_destroy_old_pk.json" -destroy_request "new_public_key_id" >"$DIR/step8_destroy_new_pk.json" - -cat >"$DIR/manifest.toml" <<'EOF' -name = "ReKeyKeyPair secp256k1" -description = "Verifies that ReKeyKeyPair succeeds for secp256k1 key pairs (non-FIPS)." - -[[steps]] -operation = "CreateKeyPair" -request = "step1_create_keypair.json" -assert_success = true -[steps.capture] -private_key_id = "PrivateKeyUniqueIdentifier" -public_key_id = "PublicKeyUniqueIdentifier" - -[[steps]] -operation = "ReKeyKeyPair" -request = "step2_rekey_keypair.json" -assert_success = true -[steps.capture] -new_private_key_id = "PrivateKeyUniqueIdentifier" -new_public_key_id = "PublicKeyUniqueIdentifier" - -# Cleanup -[[steps]] -operation = "Revoke" -request = "step3_revoke_old.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step4_destroy_old_sk.json" -assert_success = true - -[[steps]] -operation = "Revoke" -request = "step5_revoke_new.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step6_destroy_new_sk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step7_destroy_old_pk.json" -assert_success = true - -[[steps]] -operation = "Destroy" -request = "step8_destroy_new_pk.json" -assert_success = true -EOF - -echo "All test vectors created successfully!" diff --git a/test_data b/test_data index 4e7b95b038..4507c58cb7 160000 --- a/test_data +++ b/test_data @@ -1 +1 @@ -Subproject commit 4e7b95b0389c50f08a0718ae4df46c3cb8e5cfe6 +Subproject commit 4507c58cb71ddf8968da97c4c0d6b9f632be0ffa diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0a01f6ceeb..7f6ebce5a1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -26,6 +26,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,7 +36,10 @@ 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 GetRotationPolicyForm from "./actions/RotationPolicy/GetRotationPolicy"; +import SetRotationPolicyForm from "./actions/RotationPolicy/SetRotationPolicy"; import MacComputeForm from "./actions/MAC/MacCompute"; import MacVerifyForm from "./actions/MAC/MacVerify"; import HsmStatus from "./actions/Objects/HsmStatus"; @@ -47,11 +51,13 @@ 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 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 +73,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 +95,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 +267,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> + } /> } /> } /> } /> @@ -273,6 +278,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> + } /> } /> } /> } /> @@ -284,6 +290,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> + } /> } /> } /> } /> @@ -295,6 +302,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm } /> } /> } /> + } /> } /> } /> } /> @@ -306,6 +314,24 @@ 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/EC/ECKeysCreate.tsx b/ui/src/actions/EC/ECKeysCreate.tsx index c5aed01e75..745b72475a 100644 --- a/ui/src/actions/EC/ECKeysCreate.tsx +++ b/ui/src/actions/EC/ECKeysCreate.tsx @@ -1,4 +1,4 @@ -import { Button, Card, Checkbox, Form, Input, Select, Space } from "antd"; +import { Button, Card, Checkbox, Divider, Form, Input, InputNumber, Select, Space } from "antd"; import React, { useEffect, useState } from "react"; import { sendKmipRequest } from "../../utils/utils"; import * as wasm from "../../wasm/pkg"; @@ -11,6 +11,9 @@ interface ECKeyCreateFormData { tags: string[]; sensitive: boolean; wrappingKeyId?: string; + rotateName?: string; + rotateInterval?: number; + rotateOffset?: number; } type CreateKeyPairResponse = { @@ -55,7 +58,25 @@ const ECKeyCreateForm: React.FC = () => { const result_str = await sendKmipRequest(request, idToken, serverUrl); if (result_str) { const result: CreateKeyPairResponse = await wasm.parse_create_keypair_ttlv_response(result_str); - return `Key pair has been created. Private key Id: ${result.PrivateKeyUniqueIdentifier} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; + const skId = result.PrivateKeyUniqueIdentifier; + + // Apply rotation policy on the private key (keyset anchor) + if (values.rotateName || values.rotateInterval !== undefined || values.rotateOffset !== undefined) { + if (values.rotateInterval !== undefined) { + const req = wasm.set_rotate_interval_ttlv_request(skId, BigInt(values.rotateInterval)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateOffset !== undefined) { + const req = wasm.set_rotate_offset_ttlv_request(skId, BigInt(values.rotateOffset)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateName) { + const req = wasm.set_rotate_name_ttlv_request(skId, values.rotateName); + await sendKmipRequest(req, idToken, serverUrl); + } + } + + return `Key pair has been created. Private key Id: ${skId} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; } }); }; @@ -116,6 +137,30 @@ const ECKeyCreateForm: React.FC = () => { Sensitive + + + Rotation Policy (optional) + + + + + + + + + + + + + diff --git a/ui/src/actions/EC/ECReKey.tsx b/ui/src/actions/EC/ECReKey.tsx new file mode 100644 index 0000000000..cf847676c0 --- /dev/null +++ b/ui/src/actions/EC/ECReKey.tsx @@ -0,0 +1,75 @@ +import { Button, Card, Form, Input, Space } from "antd"; +import React from "react"; +import { ActionResponse } from "../../components/common/ActionResponse"; +import { useActionState } from "../../hooks/useActionState"; +import { sendKmipRequest } from "../../utils/utils"; +import * as wasm from "../../wasm/pkg"; + +interface ReKeyFormData { + keyId: string; +} + +type ReKeyKeyPairResponse = { + PrivateKeyUniqueIdentifier: string; + PublicKeyUniqueIdentifier: string; +}; + +const ECReKeyForm: React.FC = () => { + const [form] = Form.useForm(); + const { res, isLoading, responseRef, idToken, serverUrl, execute } = useActionState(); + + const onFinish = async (values: ReKeyFormData) => { + await execute(async () => { + const request = wasm.rekey_keypair_ttlv_request(values.keyId); + const result_str = await sendKmipRequest(request, idToken, serverUrl); + if (result_str) { + const result: ReKeyKeyPairResponse = await wasm.parse_rekey_keypair_ttlv_response(result_str); + return `The EC key pair was successfully rotated.\nNew private key: ${result.PrivateKeyUniqueIdentifier}\nNew public key: ${result.PublicKeyUniqueIdentifier}`; + } + }); + }; + + return ( +

+

Re-Key an Elliptic Curve key pair

+ +
+

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

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

Re-Key a Post-Quantum key pair

+ +
+

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

+
    +
  • A new private key and public key are created with the same algorithm.
  • +
  • The old key pair is linked to the new one via replacement links.
  • +
  • The rotation generation counter is incremented on the new key.
  • +
+
+ +
+ + + + + + + + + + + + + +
+ ); +}; + +export default PqcReKeyForm; diff --git a/ui/src/actions/RSA/RsaKeysCreate.tsx b/ui/src/actions/RSA/RsaKeysCreate.tsx index 27cd1b8b3b..eebe2acc96 100644 --- a/ui/src/actions/RSA/RsaKeysCreate.tsx +++ b/ui/src/actions/RSA/RsaKeysCreate.tsx @@ -1,7 +1,8 @@ -import { Button, Card, Checkbox, Form, Input, InputNumber, Select, Space } from "antd"; +import { Button, Card, Checkbox, Divider, Form, Input, InputNumber, Select, Space } from "antd"; import React from "react"; import { sendKmipRequest } from "../../utils/utils"; import { create_rsa_key_pair_ttlv_request, parse_create_keypair_ttlv_response } from "../../wasm/pkg"; +import * as wasm from "../../wasm/pkg"; import { useActionState } from "../../hooks/useActionState"; import { ActionResponse } from "../../components/common/ActionResponse"; @@ -11,6 +12,9 @@ interface RsaKeyCreateFormData { tags: string[]; sensitive: boolean; wrappingKeyId?: string; + rotateName?: string; + rotateInterval?: number; + rotateOffset?: number; } type CreateKeyPairResponse = { @@ -34,7 +38,25 @@ const RsaKeyCreateForm: React.FC = () => { const result_str = await sendKmipRequest(request, idToken, serverUrl); if (result_str) { const result: CreateKeyPairResponse = await parse_create_keypair_ttlv_response(result_str); - return `Key pair has been created. Private key Id: ${result.PrivateKeyUniqueIdentifier} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; + const skId = result.PrivateKeyUniqueIdentifier; + + // Apply rotation policy on the private key (keyset anchor) + if (values.rotateName || values.rotateInterval !== undefined || values.rotateOffset !== undefined) { + if (values.rotateInterval !== undefined) { + const req = wasm.set_rotate_interval_ttlv_request(skId, BigInt(values.rotateInterval)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateOffset !== undefined) { + const req = wasm.set_rotate_offset_ttlv_request(skId, BigInt(values.rotateOffset)); + await sendKmipRequest(req, idToken, serverUrl); + } + if (values.rotateName) { + const req = wasm.set_rotate_name_ttlv_request(skId, values.rotateName); + await sendKmipRequest(req, idToken, serverUrl); + } + } + + return `Key pair has been created. Private key Id: ${skId} - Public key Id: ${result.PublicKeyUniqueIdentifier}`; } }); }; @@ -96,6 +118,30 @@ const RsaKeyCreateForm: React.FC = () => { Sensitive + + + Rotation Policy (optional) + + + + + + + + + + + + + + + + + + + ); +}; + +export default RsaReKeyForm; diff --git a/ui/src/actions/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..b7b7bb4135 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,7 @@ const LocateForm: React.FC = () => { const getReq = wasm.get_attributes_ttlv_request(uid); const getRespStr = await sendKmipRequest(getReq, idToken, serverUrl); if (getRespStr) { - const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, [ - "object_type", - "state", - "tags", - "user_tags", - "cryptographic_algorithm", - "cryptographic_length", - "key_format_type", - ]); + const parsed = await wasm.parse_get_attributes_ttlv_response(getRespStr, getEnrichAttributeKeys()); const m = extractMeta(parsed); return { object_id: uid, @@ -804,9 +781,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..39e1b32ade 100644 --- a/ui/src/menuItems.tsx +++ b/ui/src/menuItems.tsx @@ -11,9 +11,9 @@ import { KeyOutlined, LockOutlined, SafetyCertificateOutlined, - SafetyOutlined, SearchOutlined, SolutionOutlined, + SyncOutlined, TeamOutlined, ToolOutlined, UsbOutlined, @@ -52,6 +52,7 @@ 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" }, ], @@ -64,7 +65,7 @@ const baseMenu: MenuItem[] = [ { key: "rsa", label: "RSA", - icon: , + icon: , collapsedlabel: "RSA", children: [ { @@ -74,6 +75,7 @@ const baseMenu: MenuItem[] = [ { key: "rsa/keys/create", label: "Create" }, { key: "rsa/keys/export", label: "Export" }, { key: "rsa/keys/import", label: "Import" }, + { key: "rsa/keys/rekey", label: "Re-Key" }, { key: "rsa/keys/revoke", label: "Revoke" }, { key: "rsa/keys/destroy", label: "Destroy" }, ], @@ -97,6 +99,7 @@ const baseMenu: MenuItem[] = [ { key: "ec/keys/create", label: "Create" }, { key: "ec/keys/export", label: "Export" }, { key: "ec/keys/import", label: "Import" }, + { key: "ec/keys/rekey", label: "Re-Key" }, { key: "ec/keys/revoke", label: "Revoke" }, { key: "ec/keys/destroy", label: "Destroy" }, ], @@ -121,6 +124,7 @@ const baseMenu: MenuItem[] = [ { key: "pqc/keys/create", label: "Create" }, { key: "pqc/keys/export", label: "Export" }, { key: "pqc/keys/import", label: "Import" }, + { key: "pqc/keys/rekey", label: "Re-Key" }, { key: "pqc/keys/revoke", label: "Revoke" }, { key: "pqc/keys/destroy", label: "Destroy" }, ], @@ -131,6 +135,46 @@ const baseMenu: MenuItem[] = [ { key: "pqc/verify", label: "Verify" }, ], }, + { + key: "rotation-policy", + label: "Rotation Policy", + icon: , + collapsedlabel: "ROT", + children: [ + { + key: "rotation-policy/sym", + label: "Symmetric", + children: [ + { key: "rotation-policy/sym/set", label: "Set Policy" }, + { key: "rotation-policy/sym/get", label: "Get Policy" }, + ], + }, + { + key: "rotation-policy/rsa", + label: "RSA", + children: [ + { key: "rotation-policy/rsa/set", label: "Set Policy" }, + { key: "rotation-policy/rsa/get", label: "Get Policy" }, + ], + }, + { + key: "rotation-policy/ec", + label: "Elliptic Curve", + children: [ + { key: "rotation-policy/ec/set", label: "Set Policy" }, + { key: "rotation-policy/ec/get", label: "Get Policy" }, + ], + }, + { + key: "rotation-policy/pqc", + label: "__PQC_ROTATION_LABEL__", + children: [ + { key: "rotation-policy/pqc/set", label: "Set Policy" }, + { key: "rotation-policy/pqc/get", label: "Get Policy" }, + ], + }, + ], + }, { key: "mac", label: "MAC", @@ -324,11 +368,32 @@ export function getMenuItems(options?: { enableCovercrypt?: boolean; pqcLabel?: const pqcLabel = options?.pqcLabel ?? "PQC"; const isFips = options?.isFips ?? false; - let menu = baseMenu.map((item) => (item.key === "pqc" ? { ...item, label: pqcLabel } : item)); + let menu = baseMenu.map((item) => { + if (item.key === "pqc") return { ...item, label: pqcLabel }; + if (item.key === "rotation-policy") { + // Replace the PQC child label placeholder with the real pqcLabel + return { + ...item, + children: item.children?.map((child) => (child.key === "rotation-policy/pqc" ? { ...child, label: pqcLabel } : child)), + }; + } + return item; + }); // Hide PQC, MAC, FPE, and Tokenize/Anonymize in FIPS mode (not approved / not available in FIPS build) + // For rotation-policy, keep the menu but hide the PQC child. if (isFips) { - menu = menu.filter((item) => item.key !== "pqc" && item.key !== "mac" && item.key !== "fpe" && item.key !== "tokenize"); + menu = menu + .filter((item) => item.key !== "pqc" && item.key !== "mac" && item.key !== "fpe" && item.key !== "tokenize") + .map((item) => { + if (item.key === "rotation-policy") { + return { + ...item, + children: item.children?.filter((child) => child.key !== "rotation-policy/pqc"), + }; + } + return item; + }); } // Insert Covercrypt immediately after PQC so Hyperscalers stays last diff --git a/ui/tests/e2e/README.md b/ui/tests/e2e/README.md index 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/keyset-addressing.spec.ts b/ui/tests/e2e/keyset-addressing.spec.ts new file mode 100644 index 0000000000..3df43b704b --- /dev/null +++ b/ui/tests/e2e/keyset-addressing.spec.ts @@ -0,0 +1,189 @@ +/** + * 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, + createSymKey, + extractUuid, + gotoAndWait, + submitAndWaitForDownload, + submitAndWaitForResponse, + uploadFile, + writeTempFile, +} from "./helpers"; + +const PLAINTEXT = "keyset-addressing-e2e-test-data"; + +// ── 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); + const newId = extractUuid(text); + expect(newId).not.toBeNull(); + 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 }) => { + const keyId = await createSymKey(page); + await setRotationPolicy(page, keyId, "e2e-ks-bare", 86400); + + const plainFile = writeTempFile("ks-bare.txt", PLAINTEXT); + const encPath = await symEncrypt(page, "e2e-ks-bare", 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 createSymKey(page); + await setRotationPolicy(page, keyId, "e2e-ks-latest", 86400); + + const plainFile = writeTempFile("ks-latest.txt", PLAINTEXT); + const encPath = await symEncrypt(page, "e2e-ks-latest@latest", plainFile); + + const decPath = await symDecrypt(page, keyId, encPath); + expect(fs.readFileSync(decPath, "utf-8")).toBe(PLAINTEXT); + }); + + test("encrypt with name@first after rekey resolves to gen-0", async ({ page }) => { + const keyId = await createSymKey(page); + await setRotationPolicy(page, keyId, "e2e-ks-first"); + + // Rotate: gen-0 → gen-1 + await rekeyKey(page, keyId); + + // Encrypt with @first — must use gen-0 + const plainFile = writeTempFile("ks-first.txt", PLAINTEXT); + const encPath = await symEncrypt(page, "e2e-ks-first@first", plainFile); + + // Decrypt with gen-0 UUID proves @first resolved correctly + const decPath = await symDecrypt(page, keyId, encPath); + expect(fs.readFileSync(decPath, "utf-8")).toBe(PLAINTEXT); + }); + + test("encrypt with name@0 after rekey resolves to gen-0", async ({ page }) => { + const keyId = await createSymKey(page); + await setRotationPolicy(page, keyId, "e2e-ks-zero"); + + await rekeyKey(page, keyId); + + const plainFile = writeTempFile("ks-zero.txt", PLAINTEXT); + const encPath = await symEncrypt(page, "e2e-ks-zero@0", plainFile); + + const decPath = await symDecrypt(page, keyId, encPath); + expect(fs.readFileSync(decPath, "utf-8")).toBe(PLAINTEXT); + }); + + test("encrypt with name@1 after double rekey resolves to gen-1", async ({ page }) => { + const keyId = await createSymKey(page); + await setRotationPolicy(page, keyId, "e2e-ks-gen1"); + + const gen1Id = await rekeyKey(page, keyId); + await rekeyKey(page, gen1Id); + + // Encrypt with @1 — must use gen-1 + const plainFile = writeTempFile("ks-gen1.txt", PLAINTEXT); + const encPath = await symEncrypt(page, "e2e-ks-gen1@1", plainFile); + + // Decrypt with gen-1 UUID proves @1 resolved correctly + const decPath = await symDecrypt(page, gen1Id, encPath); + expect(fs.readFileSync(decPath, "utf-8")).toBe(PLAINTEXT); + }); + + test("decrypt with bare keyset name walks chain after rotation", async ({ page }) => { + const keyId = await createSymKey(page); + await setRotationPolicy(page, keyId, "e2e-ks-chain"); + + // 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, "e2e-ks-chain", encPath); + expect(fs.readFileSync(decPath, "utf-8")).toBe(PLAINTEXT); + }); + + test("encrypt with name@99 fails for nonexistent generation", async ({ page }) => { + const keyId = await createSymKey(page); + await setRotationPolicy(page, keyId, "e2e-ks-bad"); + + 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"]', "e2e-ks-bad@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 createSymKey(page); + await setRotationPolicy(page, keyId, "e2e-ks-gen", 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..025b953afa --- /dev/null +++ b/ui/tests/e2e/rotation-policy.spec.ts @@ -0,0 +1,348 @@ +/** + * Key rotation policy E2E tests. + * + * Covers per-key-type (symmetric, RSA, EC, PQC): + * • set-rotation-policy – configure interval, offset and keyset name + * • get-rotation-policy – retrieve and verify the configured values + * • get-rotation-policy – returns "no policy" for fresh (unconfigured) keys + * • rekey – rotate the key and verify a new UID is returned + * + * Also covers certificate renewal via the KMIP ReCertify operation (Option 3 + * on the Certificate Issuance page): + * • re-certify self-signed RSA certificate → new UID ≠ original UID + * • re-certify self-signed EC P-256 certificate → new UID ≠ original UID + * • re-certify PQC ML-DSA-44 certificate (skip FIPS) → new UID ≠ original UID + * + * PQC tests are skipped when running in FIPS mode because ML-DSA / ML-KEM are + * not FIPS-approved algorithms. + */ + +import { expect, test } from "@playwright/test"; +import { + UI_READY_TIMEOUT, + createCertificate, + createEcKeyPair, + createPqcKeyPair, + createRsaKeyPair, + createSymKey, + extractUuid, + extractUuidAfterLabel, + gotoAndWait, + submitAndWaitForResponse, +} from "./helpers"; + +const FIPS_MODE = process.env.PLAYWRIGHT_FIPS_MODE === "true"; + +// --------------------------------------------------------------------------- +// Symmetric key rotation +// --------------------------------------------------------------------------- + +test.describe("Symmetric key rotation policy", () => { + test("set rotation policy on AES key", async ({ page }) => { + const keyId = await createSymKey(page); + + await gotoAndWait(page, "/ui/rotation-policy/sym/set"); + await page.fill('[data-testid="rotation-key-id"]', keyId); + // AntD v5 InputNumber passes data-testid to the itself (via rc-input-number inputProps). + await page.locator('[data-testid="rotation-interval"]').fill("86400"); + await page.locator('[data-testid="rotation-offset"]').fill("3600"); + await page.fill('[data-testid="rotation-name"]', "sym-keyset"); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy set successfully/i); + }); + + test("get rotation policy shows configured values", async ({ page }) => { + const keyId = await createSymKey(page); + + // First set a policy. + await gotoAndWait(page, "/ui/rotation-policy/sym/set"); + await page.fill('[data-testid="rotation-key-id"]', keyId); + await page.locator('[data-testid="rotation-interval"]').fill("86400"); + await page.fill('[data-testid="rotation-name"]', "sym-get-test"); + await submitAndWaitForResponse(page); + + // Then retrieve it. + await gotoAndWait(page, "/ui/rotation-policy/sym/get"); + await page.fill('[data-testid="get-rotation-key-id"]', keyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy retrieved successfully/i); + + // The details card should appear because interval is set. + await expect(page.locator('[data-testid="rotation-policy-details"]')).toBeVisible({ + timeout: UI_READY_TIMEOUT, + }); + // The card must contain the interval value we set. + await expect(page.locator('[data-testid="rotation-policy-details"]')).toContainText("86400"); + }); + + test("get rotation policy returns no-policy message for fresh key", async ({ page }) => { + const keyId = await createSymKey(page); + + await gotoAndWait(page, "/ui/rotation-policy/sym/get"); + await page.fill('[data-testid="get-rotation-key-id"]', keyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/no rotation policy configured/i); + }); + + test("re-key symmetric key returns a different UID", async ({ page }) => { + const keyId = await createSymKey(page); + + await gotoAndWait(page, "/ui/sym/keys/rekey"); + await page.fill('[data-testid="rekey-key-id"]', keyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/successfully refreshed/i); + expect(text).toMatch(/New key:/i); + + // The response must contain a valid UUID different from the original. + const newId = extractUuid(text); + expect(newId).not.toBeNull(); + expect(newId).not.toBe(keyId); + }); +}); + +// --------------------------------------------------------------------------- +// RSA key rotation +// --------------------------------------------------------------------------- + +test.describe("RSA key rotation policy", () => { + test("set rotation policy on RSA key pair", async ({ page }) => { + const { privKeyId } = await createRsaKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/rsa/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("604800"); + await page.fill('[data-testid="rotation-name"]', "rsa-keyset"); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy set successfully/i); + }); + + test("get rotation policy shows configured values for RSA key", async ({ page }) => { + const { privKeyId } = await createRsaKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/rsa/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("604800"); + await page.fill('[data-testid="rotation-name"]', "rsa-get-test"); + await submitAndWaitForResponse(page); + + await gotoAndWait(page, "/ui/rotation-policy/rsa/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy retrieved successfully/i); + + await expect(page.locator('[data-testid="rotation-policy-details"]')).toBeVisible({ + timeout: UI_READY_TIMEOUT, + }); + await expect(page.locator('[data-testid="rotation-policy-details"]')).toContainText("604800"); + }); + + test("get rotation policy returns no-policy message for fresh RSA key", async ({ page }) => { + const { privKeyId } = await createRsaKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/rsa/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/no rotation policy configured/i); + }); + + test("re-key RSA key pair returns new private and public key UIDs", async ({ page }) => { + const { privKeyId } = await createRsaKeyPair(page); + + await gotoAndWait(page, "/ui/rsa/keys/rekey"); + await page.fill('[data-testid="rekey-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/RSA key pair was successfully rotated/i); + + const newPrivId = extractUuidAfterLabel(text, "New private key"); + const newPubId = extractUuidAfterLabel(text, "New public key"); + expect(newPrivId).not.toBeNull(); + expect(newPubId).not.toBeNull(); + expect(newPrivId).not.toBe(privKeyId); + }); +}); + +// --------------------------------------------------------------------------- +// EC key rotation +// --------------------------------------------------------------------------- + +test.describe("EC key rotation policy", () => { + test("set rotation policy on EC key pair", async ({ page }) => { + const { privKeyId } = await createEcKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/ec/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("2592000"); + await page.fill('[data-testid="rotation-name"]', "ec-keyset"); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy set successfully/i); + }); + + test("get rotation policy shows configured values for EC key", async ({ page }) => { + const { privKeyId } = await createEcKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/ec/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("2592000"); + await page.fill('[data-testid="rotation-name"]', "ec-get-test"); + await submitAndWaitForResponse(page); + + await gotoAndWait(page, "/ui/rotation-policy/ec/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy retrieved successfully/i); + + await expect(page.locator('[data-testid="rotation-policy-details"]')).toBeVisible({ + timeout: UI_READY_TIMEOUT, + }); + await expect(page.locator('[data-testid="rotation-policy-details"]')).toContainText("2592000"); + }); + + test("get rotation policy returns no-policy message for fresh EC key", async ({ page }) => { + const { privKeyId } = await createEcKeyPair(page); + + await gotoAndWait(page, "/ui/rotation-policy/ec/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/no rotation policy configured/i); + }); + + test("re-key EC key pair returns new private and public key UIDs", async ({ page }) => { + const { privKeyId } = await createEcKeyPair(page); + + await gotoAndWait(page, "/ui/ec/keys/rekey"); + await page.fill('[data-testid="rekey-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/EC key pair was successfully rotated/i); + + const newPrivId = extractUuidAfterLabel(text, "New private key"); + const newPubId = extractUuidAfterLabel(text, "New public key"); + expect(newPrivId).not.toBeNull(); + expect(newPubId).not.toBeNull(); + expect(newPrivId).not.toBe(privKeyId); + }); +}); + +// --------------------------------------------------------------------------- +// PQC key rotation (ML-DSA-44; skipped in FIPS mode) +// --------------------------------------------------------------------------- + +test.describe("PQC key rotation policy", () => { + test.skip(FIPS_MODE, "PQC algorithms are not available in FIPS mode"); + + test("set rotation policy on PQC key pair", async ({ page }) => { + const { privKeyId } = await createPqcKeyPair(page, "ML-DSA-44"); + + await gotoAndWait(page, "/ui/rotation-policy/pqc/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("86400"); + await page.fill('[data-testid="rotation-name"]', "pqc-keyset"); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy set successfully/i); + }); + + test("get rotation policy shows configured values for PQC key", async ({ page }) => { + const { privKeyId } = await createPqcKeyPair(page, "ML-DSA-44"); + + await gotoAndWait(page, "/ui/rotation-policy/pqc/set"); + await page.fill('[data-testid="rotation-key-id"]', privKeyId); + await page.locator('[data-testid="rotation-interval"]').fill("86400"); + await page.fill('[data-testid="rotation-name"]', "pqc-get-test"); + await submitAndWaitForResponse(page); + + await gotoAndWait(page, "/ui/rotation-policy/pqc/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/rotation policy retrieved successfully/i); + + await expect(page.locator('[data-testid="rotation-policy-details"]')).toBeVisible({ + timeout: UI_READY_TIMEOUT, + }); + await expect(page.locator('[data-testid="rotation-policy-details"]')).toContainText("86400"); + }); + + test("get rotation policy returns no-policy message for fresh PQC key", async ({ page }) => { + const { privKeyId } = await createPqcKeyPair(page, "ML-DSA-44"); + + await gotoAndWait(page, "/ui/rotation-policy/pqc/get"); + await page.fill('[data-testid="get-rotation-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/no rotation policy configured/i); + }); + + test("re-key PQC key pair returns new private and public key UIDs", async ({ page }) => { + const { privKeyId } = await createPqcKeyPair(page, "ML-DSA-44"); + + await gotoAndWait(page, "/ui/pqc/keys/rekey"); + await page.fill('[data-testid="rekey-key-id"]', privKeyId); + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/post-quantum key pair was successfully rotated/i); + + const newPrivId = extractUuidAfterLabel(text, "New private key"); + const newPubId = extractUuidAfterLabel(text, "New public key"); + expect(newPrivId).not.toBeNull(); + expect(newPubId).not.toBeNull(); + expect(newPrivId).not.toBe(privKeyId); + }); +}); + +// --------------------------------------------------------------------------- +// Certificate renewal (KMIP ReCertify operation) +// --------------------------------------------------------------------------- + +test.describe("Certificate renewal (ReCertify)", () => { + test("re-certify self-signed RSA certificate returns a new distinct UID", async ({ page }) => { + // Create a base self-signed certificate (generates a key pair internally). + const originalId = await createCertificate(page, "RSA 2048"); + + // Re-certify it via Option 3 — this must call the dedicated KMIP ReCertify + // operation, which creates a brand-new certificate with a fresh UID and links + // the old and new certs via ReplacedObjectLink / ReplacementObjectLink. + await gotoAndWait(page, "/ui/certificates/certs/certify"); + await page.getByText("3. Certificate ID to Re-certify").click(); + await page.fill('input[placeholder="Enter certificate ID to re-certify"]', originalId); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/certificate successfully re-certified/i); + + // The new UID must be present and differ from the original. + const newId = extractUuid(text); + expect(newId).not.toBeNull(); + expect(newId).not.toBe(originalId); + }); + + test("re-certify self-signed EC P-256 certificate returns a new distinct UID", async ({ page }) => { + const originalId = await createCertificate(page, "NIST P-256"); + + await gotoAndWait(page, "/ui/certificates/certs/certify"); + await page.getByText("3. Certificate ID to Re-certify").click(); + await page.fill('input[placeholder="Enter certificate ID to re-certify"]', originalId); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/certificate successfully re-certified/i); + + const newId = extractUuid(text); + expect(newId).not.toBeNull(); + expect(newId).not.toBe(originalId); + }); + + test("re-certify PQC ML-DSA-44 certificate returns a new distinct UID", async ({ page }) => { + test.skip(FIPS_MODE, "PQC algorithms are not available in FIPS mode"); + + const originalId = await createCertificate(page, "ML-DSA-44 (PQC)"); + + await gotoAndWait(page, "/ui/certificates/certs/certify"); + await page.getByText("3. Certificate ID to Re-certify").click(); + await page.fill('input[placeholder="Enter certificate ID to re-certify"]', originalId); + + const text = await submitAndWaitForResponse(page); + expect(text).toMatch(/certificate successfully re-certified/i); + + const newId = extractUuid(text); + expect(newId).not.toBeNull(); + expect(newId).not.toBe(originalId); + }); +});