From 322ce21b9a5e9c64fe34446002a20d2c42e57f35 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Sat, 1 Aug 2026 14:07:32 +0800 Subject: [PATCH] feat(kms): add an AWS KMS backend (#5553) --- Cargo.lock | 124 +- Cargo.toml | 1 + crates/kms/Cargo.toml | 13 + crates/kms/src/api_types.rs | 11 + crates/kms/src/backends/aws.rs | 1260 +++++++++++++++++ crates/kms/src/backends/local.rs | 3 +- crates/kms/src/backends/mod.rs | 1 + ..._aws__tests__aws_backend_capabilities.snap | 14 + crates/kms/src/backends/vault.rs | 3 +- crates/kms/src/backends/vault_transit.rs | 4 +- crates/kms/src/config.rs | 118 ++ crates/kms/src/policy.rs | 4 +- crates/kms/src/service_manager.rs | 5 + docs/operations/kms-backend-security.md | 20 + rustfs/src/admin/handlers/kms_dynamic.rs | 2 + rustfs/src/admin/handlers/kms_management.rs | 1 + 16 files changed, 1574 insertions(+), 10 deletions(-) create mode 100644 crates/kms/src/backends/aws.rs create mode 100644 crates/kms/src/backends/snapshots/rustfs_kms__backends__aws__tests__aws_backend_capabilities.snap diff --git a/Cargo.lock b/Cargo.lock index 780f7b61f..4614fdd0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -599,6 +599,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "astral-tokio-tar" version = "0.6.4" @@ -943,6 +953,32 @@ dependencies = [ "uuid", ] +[[package]] +name = "aws-sdk-kms" +version = "1.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b7d906608ee41e7ddea9983577ba82200435644d567d63dc34e822e088b453" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-s3" version = "1.140.0" @@ -1158,17 +1194,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e" dependencies = [ "aws-smithy-async", + "aws-smithy-protocol-test", "aws-smithy-runtime-api", "aws-smithy-types", + "bytes", "h2", "http 1.5.0", + "http-body 1.1.0", "hyper", "hyper-rustls", "hyper-util", + "indexmap 2.14.0", "pin-project-lite", "rustls", "rustls-native-certs", "rustls-pki-types", + "serde", + "serde_json", "tokio", "tokio-rustls", "tower", @@ -1195,6 +1237,25 @@ dependencies = [ "aws-smithy-runtime-api", ] +[[package]] +name = "aws-smithy-protocol-test" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f76511a0e223ce78deb6a78b8afebda99cb737cfbc8a58d96dcb190f012dd40a" +dependencies = [ + "assert-json-diff", + "aws-smithy-runtime-api", + "base64-simd", + "cbor-diag", + "ciborium", + "http 0.2.12", + "pretty_assertions", + "regex-lite", + "roxmltree", + "serde_json", + "thiserror 2.0.19", +] + [[package]] name = "aws-smithy-query" version = "0.62.0" @@ -1598,7 +1659,7 @@ version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", @@ -1758,6 +1819,25 @@ dependencies = [ "cipher 0.5.2", ] +[[package]] +name = "cbor-diag" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc245b6ecd09b23901a4fbad1ad975701fd5061ceaef6afa93a2d70605a64429" +dependencies = [ + "bs58", + "chrono", + "data-encoding", + "half", + "nom 7.1.3", + "num-bigint", + "num-rational", + "num-traits", + "separator", + "url", + "uuid", +] + [[package]] name = "cc" version = "1.4.0" @@ -6668,6 +6748,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -6732,7 +6823,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.5.0", @@ -7857,7 +7948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.13.0", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -7877,7 +7968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", - "itertools 0.13.0", + "itertools 0.14.0", "log", "multimap", "petgraph 0.8.3", @@ -7898,7 +7989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -7911,7 +8002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -8631,6 +8722,15 @@ dependencies = [ "serde", ] +[[package]] +name = "roxmltree" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "921904a62e410e37e215c40381b7117f830d9d89ba60ab5236170541dd25646b" +dependencies = [ + "xmlparser", +] + [[package]] name = "rsa" version = "0.9.10" @@ -9511,10 +9611,16 @@ dependencies = [ "arc-swap", "argon2", "async-trait", + "aws-config", + "aws-sdk-kms", + "aws-smithy-http-client", + "aws-smithy-runtime-api", + "aws-smithy-types", "base64 0.23.0", "chacha20poly1305", "hex", "hotpath", + "http 1.5.0", "insta", "jiff", "md-5 0.11.0", @@ -10662,6 +10768,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "separator" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f97841a747eef040fcd2e7b3b9a220a7205926e60488e673d9e4926d27772ce5" + [[package]] name = "seq-macro" version = "0.3.6" diff --git a/Cargo.toml b/Cargo.toml index 112df1c40..60f355b1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -227,6 +227,7 @@ atoi = "3.1.0" atomic_enum = "0.3.0" aws-config = { version = "1.10.1" } aws-credential-types = { version = "1.3.0" } +aws-sdk-kms = { default-features = false, version = "1.114.0" } aws-sdk-s3 = { default-features = false, version = "1.140.0" } aws-sdk-sts = { default-features = false, version = "1.110.0" } aws-smithy-http-client = { default-features = false, version = "1.2.0" } diff --git a/crates/kms/Cargo.toml b/crates/kms/Cargo.toml index 3d8c80876..7eafe173f 100644 --- a/crates/kms/Cargo.toml +++ b/crates/kms/Cargo.toml @@ -77,6 +77,16 @@ vaultrs = { workspace = true } rustify = { workspace = true } tokio-util = { workspace = true } +# AWS KMS backend. Credentials come from the standard aws-config provider chain +# (environment, shared profile, IMDS/container roles); this crate never handles +# AWS credential material itself. +aws-config = { workspace = true } +aws-sdk-kms = { workspace = true, default-features = false, features = ["default-https-client", "rt-tokio"] } +# SdkError variants and raw HTTP status are needed to classify AWS failures for +# the operation policy's retry decisions. +aws-smithy-runtime-api = { workspace = true, features = ["http-1x"] } +aws-smithy-types = { workspace = true } + [dev-dependencies] anyhow = { workspace = true } # Debugging recorder for asserting emitted metrics in tests. @@ -86,6 +96,9 @@ tempfile = { workspace = true } temp-env = { workspace = true } # "net" backs the scripted loopback Vault used by the policy wiring tests. tokio = { workspace = true, features = ["net", "test-util"] } +# Replays canned AWS KMS HTTP exchanges so the AWS backend tests stay offline. +aws-smithy-http-client = { workspace = true, default-features = false, features = ["test-util"] } +http = { workspace = true } [features] default = [] diff --git a/crates/kms/src/api_types.rs b/crates/kms/src/api_types.rs index 7d96951c9..efd782e9b 100644 --- a/crates/kms/src/api_types.rs +++ b/crates/kms/src/api_types.rs @@ -418,6 +418,13 @@ pub enum BackendSummary { /// Configured key identifier key_id: String, }, + /// AWS KMS backend summary + Aws { + /// Configured region, when pinned instead of resolved by the AWS chain + region: Option, + /// Endpoint override, when set for an emulator or private endpoint + endpoint_url: Option, + }, } impl From<&KmsConfig> for KmsConfigSummary { @@ -467,6 +474,10 @@ impl From<&KmsConfig> for KmsConfigSummary { BackendConfig::Static(static_config) => BackendSummary::Static { key_id: static_config.key_id.clone(), }, + BackendConfig::Aws(aws_config) => BackendSummary::Aws { + region: aws_config.region.clone(), + endpoint_url: aws_config.endpoint_url.clone(), + }, }; Self { diff --git a/crates/kms/src/backends/aws.rs b/crates/kms/src/backends/aws.rs new file mode 100644 index 000000000..829daaa4f --- /dev/null +++ b/crates/kms/src/backends/aws.rs @@ -0,0 +1,1260 @@ +// Copyright 2024 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! AWS KMS backend. +//! +//! AWS is the cryptographic source of truth: key material never leaves KMS, +//! and AWS owns key state, backing-key rotation, and the pending-deletion +//! window. This backend is a thin mapping onto the native API — no key state +//! is mirrored locally, so there is no metadata store to keep consistent. +//! +//! Deliberate deviations from the RustFS backend contract, all forced by AWS +//! semantics and reflected in [`AwsKmsBackend::capabilities`]: +//! +//! - No physical deletion. `ScheduleKeyDeletion` is the only removal path AWS +//! offers, and AWS itself destroys the material when the window elapses, so +//! `physical_delete` is false and [`KmsBackend::remove_expired_key`] never +//! destroys anything — it only reports what AWS has already done. +//! - AWS rejects *decryption* with `Disabled` and `PendingDeletion` keys, +//! whereas every RustFS-managed backend keeps it working (see +//! `backends::ensure_key_state_permits`). Objects encrypted under a key that +//! is later disabled in AWS therefore become unreadable until it is +//! re-enabled. The shared contract-test driver is not applicable here for +//! exactly this reason. +//! - `CancelKeyDeletion` leaves the key `Disabled` in AWS, not `Enabled`. +//! - Key identifiers are assigned by AWS. Every other backend treats +//! `CreateKeyRequest::key_name` as the identifier the key will answer to, and +//! alias management is out of scope here, so a named create is *refused* +//! rather than silently creating a key the caller cannot address. Honouring +//! it silently would make each caller-by-name flow — SSE-S3 auto-creation +//! and the synthetic probe both describe-then-create — recreate an +//! unreachable key on every attempt. Consequently SSE-S3 key auto-creation +//! and the probe are unavailable on this backend: keys must be pre-created +//! and referenced by AWS key id or ARN. +//! - Versions are opaque: AWS addresses backing keys internally and decrypts +//! with the right one automatically, so `key_version` is reported as 1. +//! +//! Credentials come from the standard `aws-config` provider chain +//! (environment, shared profile, container/IMDS role). This backend never +//! stores or refreshes AWS credential material itself. +//! +//! Every call goes through [`crate::policy::execute`], which owns the +//! per-attempt timeout, the total operation deadline, and the retry decision; +//! the SDK's own retry loop is disabled so the configured budget is not +//! multiplied. Throttled reads are replayed, while mutations (create, rotate, +//! schedule/cancel deletion, enable/disable) are executed at most once because +//! a lost response could otherwise be replayed into a second side effect. + +use std::collections::HashMap; +use std::future::Future; + +use async_trait::async_trait; +use aws_sdk_kms::error::{ProvideErrorMetadata, SdkError}; +use aws_sdk_kms::types::{DataKeySpec, KeySpec as AwsKeySpec, KeyState as AwsKeyState, KeyUsageType, Tag}; +use aws_smithy_runtime_api::client::orchestrator::HttpResponse; +use aws_smithy_types::Blob; +use jiff::Zoned; +use tokio_util::sync::CancellationToken; + +use super::{BackendCapabilities, ExpiredKeyRemoval, KmsBackend}; +use crate::config::{BackendConfig, KmsConfig}; +use crate::error::{KmsError, Result}; +use crate::policy::{self, AttemptError, ErrorClass, OpClass, RetryPolicy, classify_status}; +use crate::types::*; + +/// Backend name reported in `UnsupportedCapability` errors. +const BACKEND_NAME: &str = "aws"; + +/// Reported as `KeyMetadata::key_manager`: the material is managed by AWS KMS. +const KEY_MANAGER: &str = "AWS_KMS"; + +/// AWS accepts a pending deletion window of 7 to 30 days. +const MIN_PENDING_WINDOW_DAYS: u32 = 7; +const MAX_PENDING_WINDOW_DAYS: u32 = 30; + +/// Default window applied when the caller does not pick one, matching AWS's +/// own default. +const DEFAULT_PENDING_WINDOW_DAYS: u32 = 30; + +/// AWS error codes describing a transient, server-side condition that a retry +/// can clear. Everything else — access denial, invalid state, not found, +/// malformed input — is deterministic and must not be replayed. +fn is_retryable_error_code(code: &str) -> bool { + matches!( + code, + "ThrottlingException" + | "ThrottledException" + | "TooManyRequestsException" + | "RequestThrottled" + | "SlowDown" + | "KMSInternalException" + | "DependencyTimeoutException" + | "KeyUnavailableException" + | "InternalFailure" + | "ServiceUnavailable" + ) +} + +/// Classify an AWS SDK failure for the operation policy's retry decision. +/// +/// Transport-level failures are connection-class: the request may or may not +/// have reached AWS, which is why non-idempotent mutations are executed once +/// regardless. Service errors are classified by AWS error code first and by +/// HTTP status second, so a throttling response retries even when the service +/// returns it as an unmodeled error. +fn classify_sdk_error(error: &SdkError) -> ErrorClass { + match error { + SdkError::ConstructionFailure(_) => ErrorClass::Fatal, + SdkError::TimeoutError(_) | SdkError::DispatchFailure(_) | SdkError::ResponseError(_) => ErrorClass::RetryableConn, + SdkError::ServiceError(context) => { + if context.err().code().is_some_and(is_retryable_error_code) { + return ErrorClass::RetryableStatus; + } + classify_status(context.raw().status().as_u16()) + } + // `SdkError` is non-exhaustive: an unrecognized failure mode is treated + // as deterministic rather than replayed blind. + _ => ErrorClass::Fatal, + } +} + +/// Human-readable failure detail that never carries key material: only the AWS +/// error code and its own message are surfaced. +fn error_detail(error: &SdkError) -> String { + match error { + SdkError::ServiceError(context) => format!( + "{} ({})", + context.err().message().unwrap_or("no message"), + context.err().code().unwrap_or("unknown code") + ), + SdkError::TimeoutError(_) => "the request timed out".to_string(), + SdkError::DispatchFailure(_) => "the request could not be dispatched".to_string(), + SdkError::ResponseError(_) => "the response could not be parsed".to_string(), + SdkError::ConstructionFailure(_) => "the request could not be constructed".to_string(), + _ => "an unrecognized failure occurred".to_string(), + } +} + +/// Map an AWS SDK failure onto the typed KMS error surface. +/// +/// `key_id` names the key the operation addressed, when it had one, so +/// not-found stays reportable as [`KmsError::KeyNotFound`] with the identifier +/// the caller passed. +fn map_sdk_error(operation: &str, key_id: Option<&str>, error: SdkError) -> KmsError { + let detail = error_detail(&error); + let subject = key_id.map(|id| format!(" for key {id}")).unwrap_or_default(); + let code = match &error { + SdkError::ServiceError(context) => context.err().code().unwrap_or_default().to_string(), + SdkError::TimeoutError(_) => { + return KmsError::operation_timed_out(format!("AWS KMS {operation}{subject} timed out")); + } + SdkError::ConstructionFailure(_) => { + return KmsError::configuration_error(format!( + "AWS KMS {operation}{subject} could not be built; check the configured region and credential chain: {detail}" + )); + } + _ => String::new(), + }; + + match code.as_str() { + "NotFoundException" => KmsError::key_not_found(key_id.unwrap_or("unknown")), + "AlreadyExistsException" => KmsError::key_already_exists(key_id.unwrap_or("unknown")), + "AccessDeniedException" => KmsError::access_denied(format!("AWS KMS denied {operation}{subject}: {detail}")), + // Credential problems surfaced by SigV4/STS: fail closed instead of + // reporting a generic backend error, so operators see the real cause. + "UnrecognizedClientException" | "InvalidClientTokenId" | "ExpiredTokenException" | "InvalidSignatureException" => { + KmsError::credentials_unavailable(format!( + "AWS KMS rejected the request credentials for {operation}{subject}: {detail}" + )) + } + "KMSInvalidStateException" | "DisabledException" | "InvalidKeyUsageException" | "InvalidGrantTokenException" => { + KmsError::invalid_key_state(format!("AWS KMS rejected {operation}{subject}: {detail}")) + } + "InvalidCiphertextException" | "IncorrectKeyException" | "IncorrectKeyMaterialException" => { + KmsError::cryptographic_error(operation.to_string(), detail) + } + // Malformed input — most often a key identifier that is neither an AWS + // key id, an ARN, nor an alias. Reported as a parameter problem so + // callers stop rather than treating it as a backend outage to retry. + "ValidationException" | "InvalidArnException" | "InvalidMarkerException" => { + KmsError::invalid_parameter(format!("AWS KMS rejected {operation}{subject} as invalid: {detail}")) + } + _ => KmsError::backend_error(format!("AWS KMS {operation}{subject} failed: {detail}")), + } +} + +/// Translate an AWS key state onto the shared state machine. +/// +/// States this build does not recognize — including `Creating`, `Updating`, +/// and any state added to AWS later — map to `Unavailable`, which every state +/// gate rejects, so an unknown state fails closed rather than being treated as +/// usable. +fn map_key_state(state: Option<&AwsKeyState>) -> KeyState { + match state { + Some(AwsKeyState::Enabled) => KeyState::Enabled, + Some(AwsKeyState::Disabled) => KeyState::Disabled, + Some(AwsKeyState::PendingDeletion | AwsKeyState::PendingReplicaDeletion) => KeyState::PendingDeletion, + Some(AwsKeyState::PendingImport) => KeyState::PendingImport, + _ => KeyState::Unavailable, + } +} + +/// Project a key state onto the coarser [`KeyStatus`] used by key listings. +fn key_status_of(state: &KeyState) -> KeyStatus { + match state { + KeyState::Enabled => KeyStatus::Active, + KeyState::PendingDeletion => KeyStatus::PendingDeletion, + // `Unavailable`/`PendingImport` keys are not deleted, only unusable; + // reporting them as `Deleted` would invite the deletion sweep to act + // on keys AWS still owns. + KeyState::Disabled | KeyState::PendingImport | KeyState::Unavailable => KeyStatus::Disabled, + } +} + +/// AWS models `GENERATE_VERIFY_MAC` and `KEY_AGREEMENT` usages that RustFS has +/// no equivalent for; only `SIGN_VERIFY` maps across, everything else is +/// reported as encrypt/decrypt. +fn map_key_usage(usage: Option<&KeyUsageType>) -> KeyUsage { + match usage { + Some(KeyUsageType::SignVerify) => KeyUsage::SignVerify, + _ => KeyUsage::EncryptDecrypt, + } +} + +fn to_zoned(value: Option<&aws_smithy_types::DateTime>) -> Option { + let value = value?; + let nanos = i32::try_from(value.subsec_nanos()).ok()?; + jiff::Timestamp::new(value.secs(), nanos) + .ok() + .map(|timestamp| timestamp.to_zoned(jiff::tz::TimeZone::UTC)) +} + +/// `None` for an empty context so the request omits the field entirely rather +/// than sending an empty map, which AWS treats as a distinct context. +fn encryption_context(context: &HashMap) -> Option> { + (!context.is_empty()).then(|| context.clone()) +} + +fn grant_tokens(tokens: &[String]) -> Option> { + (!tokens.is_empty()).then(|| tokens.to_vec()) +} + +/// Build the RustFS key metadata view of an AWS key. +/// +/// `tags` are supplied by the caller: reading them back requires a separate +/// `ListResourceTags` call (and the matching IAM permission), so describe +/// paths report an empty tag set rather than paying for it. +fn key_metadata_from_aws(metadata: &aws_sdk_kms::types::KeyMetadata, tags: HashMap) -> Result { + let creation_date = to_zoned(metadata.creation_date()).ok_or_else(|| { + KmsError::backend_error(format!("AWS KMS returned key {} without a usable creation date", metadata.key_id())) + })?; + + Ok(KeyMetadata { + key_id: metadata.key_id().to_string(), + key_state: map_key_state(metadata.key_state()), + key_usage: map_key_usage(metadata.key_usage()), + description: metadata.description().map(str::to_string), + creation_date, + deletion_date: to_zoned(metadata.deletion_date()), + origin: metadata + .origin() + .map(|origin| origin.as_str().to_string()) + .unwrap_or_else(|| KEY_MANAGER.to_string()), + key_manager: KEY_MANAGER.to_string(), + tags, + }) +} + +/// KMS backend backed by AWS KMS. +pub struct AwsKmsBackend { + client: aws_sdk_kms::Client, + /// Budgets wrapping every outbound AWS call (see [`crate::policy`]). + retry: RetryPolicy, + /// Cancellation point for the operation executor: aborts in-flight + /// attempts and backoff sleeps. Owned by the backend and currently never + /// triggered — shutdown drops the whole backend — but kept as the single + /// hook a future lifecycle owner can cancel through. + cancel: CancellationToken, +} + +impl AwsKmsBackend { + /// Build the backend from a KMS configuration. + /// + /// Resolving credentials and the region is delegated to `aws-config`. A + /// configuration that cannot produce a region fails here rather than at + /// the first cryptographic operation. + pub async fn new(config: KmsConfig) -> Result { + config.validate()?; + + let aws_backend_config = match &config.backend_config { + BackendConfig::Aws(aws_config) => (**aws_config).clone(), + BackendConfig::Local(_) | BackendConfig::VaultKv2(_) | BackendConfig::VaultTransit(_) | BackendConfig::Static(_) => { + return Err(KmsError::configuration_error("Expected AWS KMS backend configuration")); + } + }; + + let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest()); + if let Some(region) = &aws_backend_config.region { + loader = loader.region(aws_sdk_kms::config::Region::new(region.clone())); + } + let sdk_config = loader.load().await; + if sdk_config.region().is_none() { + return Err(KmsError::configuration_error( + "AWS KMS backend could not resolve a region; set the backend region or AWS_REGION", + )); + } + + let mut builder = aws_sdk_kms::config::Builder::from(&sdk_config) + // `crate::policy` owns retries and timeouts; leaving the SDK's own + // retry loop enabled would multiply the configured attempt budget + // and replay mutations the policy deliberately runs once. + .retry_config(aws_sdk_kms::config::retry::RetryConfig::disabled()); + if let Some(endpoint_url) = &aws_backend_config.endpoint_url { + builder = builder.endpoint_url(endpoint_url); + } + + Ok(Self::with_client(aws_sdk_kms::Client::from_conf(builder.build()), &config)) + } + + fn with_client(client: aws_sdk_kms::Client, config: &KmsConfig) -> Self { + Self { + client, + retry: RetryPolicy::from_config(config), + cancel: CancellationToken::new(), + } + } + + /// Run one AWS call under the operation policy. + async fn run(&self, operation: &'static str, class: OpClass, attempt: F) -> Result + where + F: FnMut() -> Fut, + Fut: Future>, + { + policy::execute(operation, class, &self.retry, &self.cancel, attempt).await + } + + /// Fetch a key's metadata from AWS. Tags are not read back; see + /// [`key_metadata_from_aws`]. + async fn describe(&self, key_id: &str) -> Result { + let output = self + .run("aws_kms_describe_key", OpClass::ReadIdempotent, move || async move { + self.client + .describe_key() + .key_id(key_id) + .send() + .await + .map_err(|error| AttemptError { + class: classify_sdk_error(&error), + error: map_sdk_error("describe_key", Some(key_id), error), + }) + }) + .await?; + + let metadata = output + .key_metadata() + .ok_or_else(|| KmsError::backend_error(format!("AWS KMS DescribeKey returned no metadata for key {key_id}")))?; + key_metadata_from_aws(metadata, HashMap::new()) + } +} + +#[async_trait] +impl KmsBackend for AwsKmsBackend { + async fn create_key(&self, request: CreateKeyRequest) -> Result { + if request.key_usage != KeyUsage::EncryptDecrypt { + return Err(KmsError::unsupported_capability(BACKEND_NAME, "create_key with SIGN_VERIFY usage")); + } + // AWS assigns the identifier itself and this backend does not manage + // aliases, so a key created here can never answer to the requested + // name. Reporting the gap keeps describe-then-create callers from + // creating a fresh, unreachable key on every attempt. + if request.key_name.is_some() { + return Err(KmsError::unsupported_capability( + BACKEND_NAME, + "create_key with a caller-assigned key name; AWS assigns key identifiers", + )); + } + + let description = request.description.clone(); + // Sorted so the request AWS receives is deterministic for a given tag set. + let mut tag_pairs: Vec<_> = request.tags.iter().collect(); + tag_pairs.sort_by_key(|(key, _)| *key); + let tags = tag_pairs + .into_iter() + .map(|(key, value)| Tag::builder().tag_key(key).tag_value(value).build()) + .collect::, _>>() + .map_err(|error| KmsError::invalid_parameter(format!("AWS KMS rejected the requested key tags: {error}")))?; + + // Single attempt: CreateKey has no idempotency key, so replaying a lost + // response would leave an orphaned second key behind. + let output = self + .run("aws_kms_create_key", OpClass::MutatingNonIdempotent, || { + let description = description.clone(); + let tags = tags.clone(); + async move { + let mut builder = self + .client + .create_key() + .key_usage(KeyUsageType::EncryptDecrypt) + .key_spec(AwsKeySpec::SymmetricDefault); + if let Some(description) = description { + builder = builder.description(description); + } + if !tags.is_empty() { + builder = builder.set_tags(Some(tags)); + } + builder.send().await.map_err(|error| AttemptError { + class: classify_sdk_error(&error), + error: map_sdk_error("create_key", None, error), + }) + } + }) + .await?; + + let metadata = output + .key_metadata() + .ok_or_else(|| KmsError::backend_error("AWS KMS CreateKey returned no key metadata"))?; + let key_metadata = key_metadata_from_aws(metadata, request.tags)?; + Ok(CreateKeyResponse { + key_id: key_metadata.key_id.clone(), + key_metadata, + }) + } + + async fn encrypt(&self, request: EncryptRequest) -> Result { + let key_id = request.key_id.as_str(); + let plaintext = request.plaintext.as_slice(); + let context = encryption_context(&request.encryption_context); + let tokens = grant_tokens(&request.grant_tokens); + + let output = self + .run("aws_kms_encrypt", OpClass::ReadIdempotent, || { + let context = context.clone(); + let tokens = tokens.clone(); + async move { + self.client + .encrypt() + .key_id(key_id) + .plaintext(Blob::new(plaintext.to_vec())) + .set_encryption_context(context) + .set_grant_tokens(tokens) + .send() + .await + .map_err(|error| AttemptError { + class: classify_sdk_error(&error), + error: map_sdk_error("encrypt", Some(key_id), error), + }) + } + }) + .await?; + + let ciphertext = output + .ciphertext_blob + .ok_or_else(|| KmsError::backend_error(format!("AWS KMS Encrypt returned no ciphertext for key {key_id}")))?; + Ok(EncryptResponse { + ciphertext: ciphertext.into_inner(), + key_id: output.key_id.unwrap_or_else(|| request.key_id.clone()), + // AWS addresses backing keys internally and never exposes a + // version to the caller. + key_version: 1, + algorithm: output + .encryption_algorithm + .map(|algorithm| algorithm.as_str().to_string()) + .unwrap_or_else(|| AwsKeySpec::SymmetricDefault.as_str().to_string()), + }) + } + + async fn decrypt(&self, request: DecryptRequest) -> Result { + // AWS ciphertext blobs identify their own key, so no key id is needed + // here — and AWS refuses to decrypt with a disabled or pending-deletion + // key, unlike the RustFS-managed backends. + let ciphertext = request.ciphertext.as_slice(); + let context = encryption_context(&request.encryption_context); + let tokens = grant_tokens(&request.grant_tokens); + + let output = self + .run("aws_kms_decrypt", OpClass::ReadIdempotent, || { + let context = context.clone(); + let tokens = tokens.clone(); + async move { + self.client + .decrypt() + .ciphertext_blob(Blob::new(ciphertext.to_vec())) + .set_encryption_context(context) + .set_grant_tokens(tokens) + .send() + .await + .map_err(|error| AttemptError { + class: classify_sdk_error(&error), + error: map_sdk_error("decrypt", None, error), + }) + } + }) + .await?; + + let plaintext = output + .plaintext + .ok_or_else(|| KmsError::backend_error("AWS KMS Decrypt returned no plaintext"))?; + Ok(DecryptResponse { + plaintext: plaintext.into_inner(), + key_id: output.key_id.unwrap_or_default(), + encryption_algorithm: output.encryption_algorithm.map(|algorithm| algorithm.as_str().to_string()), + }) + } + + async fn generate_data_key(&self, request: GenerateDataKeyRequest) -> Result { + let key_id = request.key_id.as_str(); + let context = encryption_context(&request.encryption_context); + // AWS names the AES specs directly; anything else is requested by + // length so the caller still gets material of the size it asked for. + let key_spec = match request.key_spec { + KeySpec::Aes256 => Some(DataKeySpec::Aes256), + KeySpec::Aes128 => Some(DataKeySpec::Aes128), + KeySpec::ChaCha20 => None, + }; + let number_of_bytes = match key_spec { + Some(_) => None, + None => Some( + i32::try_from(request.key_spec.key_size()) + .map_err(|_| KmsError::invalid_parameter("Requested data key size exceeds the AWS KMS limit"))?, + ), + }; + + let output = self + .run("aws_kms_generate_data_key", OpClass::ReadIdempotent, || { + let context = context.clone(); + let key_spec = key_spec.clone(); + async move { + self.client + .generate_data_key() + .key_id(key_id) + .set_key_spec(key_spec) + .set_number_of_bytes(number_of_bytes) + .set_encryption_context(context) + .send() + .await + .map_err(|error| AttemptError { + class: classify_sdk_error(&error), + error: map_sdk_error("generate_data_key", Some(key_id), error), + }) + } + }) + .await?; + + let plaintext = output + .plaintext + .ok_or_else(|| KmsError::backend_error(format!("AWS KMS GenerateDataKey returned no plaintext for key {key_id}")))?; + let ciphertext = output + .ciphertext_blob + .ok_or_else(|| KmsError::backend_error(format!("AWS KMS GenerateDataKey returned no ciphertext for key {key_id}")))?; + + Ok(GenerateDataKeyResponse { + key_id: output.key_id.unwrap_or_else(|| request.key_id.clone()), + plaintext_key: plaintext.into_inner(), + ciphertext_blob: ciphertext.into_inner(), + }) + } + + async fn describe_key(&self, request: DescribeKeyRequest) -> Result { + Ok(DescribeKeyResponse { + key_metadata: self.describe(&request.key_id).await?, + }) + } + + /// List keys, describing each entry. + /// + /// AWS `ListKeys` returns identifiers only, so filling in the state, usage + /// and creation date every caller relies on costs one `DescribeKey` per + /// listed key. The page size is bounded by the caller's `limit`. + async fn list_keys(&self, request: ListKeysRequest) -> Result { + let limit = request + .limit + .map(|limit| i32::try_from(limit).unwrap_or(i32::MAX).clamp(1, 1000)); + let marker = request.marker.clone(); + + let output = self + .run("aws_kms_list_keys", OpClass::ReadIdempotent, || { + let marker = marker.clone(); + async move { + self.client + .list_keys() + .set_limit(limit) + .set_marker(marker) + .send() + .await + .map_err(|error| AttemptError { + class: classify_sdk_error(&error), + error: map_sdk_error("list_keys", None, error), + }) + } + }) + .await?; + + let mut keys = Vec::new(); + for entry in output.keys() { + let Some(key_id) = entry.key_id() else { + continue; + }; + let metadata = self.describe(key_id).await?; + if request + .usage_filter + .as_ref() + .is_some_and(|filter| *filter != metadata.key_usage) + { + continue; + } + let status = key_status_of(&metadata.key_state); + if request.status_filter.as_ref().is_some_and(|filter| *filter != status) { + continue; + } + keys.push(KeyInfo { + key_id: metadata.key_id, + description: metadata.description, + algorithm: AwsKeySpec::SymmetricDefault.as_str().to_string(), + usage: metadata.key_usage, + status, + // AWS never exposes a backing-key version to the caller. + version: 1, + metadata: HashMap::new(), + tags: metadata.tags, + created_at: metadata.creation_date, + rotated_at: None, + created_by: None, + }); + } + + Ok(ListKeysResponse { + keys, + next_marker: output.next_marker.clone(), + truncated: output.truncated, + }) + } + + /// Schedule deletion through the native `ScheduleKeyDeletion`. + /// + /// AWS owns the window and destroys the material itself when it elapses; + /// there is no immediate-deletion path, so `force_immediate` is rejected. + async fn delete_key(&self, request: DeleteKeyRequest) -> Result { + if request.force_immediate.unwrap_or(false) { + return Err(KmsError::unsupported_capability(BACKEND_NAME, "immediate key deletion")); + } + + let days = request.pending_window_in_days.unwrap_or(DEFAULT_PENDING_WINDOW_DAYS); + if !(MIN_PENDING_WINDOW_DAYS..=MAX_PENDING_WINDOW_DAYS).contains(&days) { + return Err(KmsError::invalid_parameter(format!( + "pending_window_in_days must be between {MIN_PENDING_WINDOW_DAYS} and {MAX_PENDING_WINDOW_DAYS}" + ))); + } + let window = i32::try_from(days).map_err(|_| KmsError::invalid_parameter("pending_window_in_days is out of range"))?; + + let key_id = request.key_id.as_str(); + // Single attempt: AWS rejects a repeated schedule with an invalid-state + // error, so a replayed lost response would surface as a spurious + // failure rather than a completed deletion. + let output = self + .run("aws_kms_schedule_key_deletion", OpClass::MutatingNonIdempotent, move || async move { + self.client + .schedule_key_deletion() + .key_id(key_id) + .pending_window_in_days(window) + .send() + .await + .map_err(|error| AttemptError { + class: classify_sdk_error(&error), + error: map_sdk_error("schedule_key_deletion", Some(key_id), error), + }) + }) + .await?; + + let deletion_date = to_zoned(output.deletion_date()).map(|date| date.to_string()); + Ok(DeleteKeyResponse { + key_id: request.key_id.clone(), + deletion_date, + key_metadata: self.describe(&request.key_id).await?, + }) + } + + /// Cancel a scheduled deletion. + /// + /// AWS leaves the key `Disabled` afterwards; callers that need it usable + /// again must enable it explicitly. + async fn cancel_key_deletion(&self, request: CancelKeyDeletionRequest) -> Result { + let key_id = request.key_id.as_str(); + self.run("aws_kms_cancel_key_deletion", OpClass::MutatingNonIdempotent, move || async move { + self.client + .cancel_key_deletion() + .key_id(key_id) + .send() + .await + .map_err(|error| AttemptError { + class: classify_sdk_error(&error), + error: map_sdk_error("cancel_key_deletion", Some(key_id), error), + }) + }) + .await?; + + Ok(CancelKeyDeletionResponse { + key_id: request.key_id.clone(), + key_metadata: self.describe(&request.key_id).await?, + }) + } + + async fn enable_key(&self, key_id: &str) -> Result<()> { + self.run("aws_kms_enable_key", OpClass::MutatingNonIdempotent, move || async move { + self.client + .enable_key() + .key_id(key_id) + .send() + .await + .map(|_| ()) + .map_err(|error| AttemptError { + class: classify_sdk_error(&error), + error: map_sdk_error("enable_key", Some(key_id), error), + }) + }) + .await + } + + async fn disable_key(&self, key_id: &str) -> Result<()> { + self.run("aws_kms_disable_key", OpClass::MutatingNonIdempotent, move || async move { + self.client + .disable_key() + .key_id(key_id) + .send() + .await + .map(|_| ()) + .map_err(|error| AttemptError { + class: classify_sdk_error(&error), + error: map_sdk_error("disable_key", Some(key_id), error), + }) + }) + .await + } + + /// Rotate the key's backing material through `RotateKeyOnDemand`. + /// + /// This is AWS's *manual* rotation: it creates a new backing key while + /// every prior one stays available for decryption, which is exactly the + /// version-retaining rotation the trait requires. AWS's automatic + /// (yearly) rotation is a separate, independently configured mechanism + /// that this backend neither enables nor reports on. + async fn rotate_key(&self, key_id: &str) -> Result<()> { + self.run("aws_kms_rotate_key_on_demand", OpClass::MutatingNonIdempotent, move || async move { + self.client + .rotate_key_on_demand() + .key_id(key_id) + .send() + .await + .map(|_| ()) + .map_err(|error| AttemptError { + class: classify_sdk_error(&error), + error: map_sdk_error("rotate_key_on_demand", Some(key_id), error), + }) + }) + .await + } + + async fn health_check(&self) -> Result { + self.run("aws_kms_health_check", OpClass::ReadIdempotent, move || async move { + self.client + .list_keys() + .limit(1) + .send() + .await + .map(|_| true) + .map_err(|error| AttemptError { + class: classify_sdk_error(&error), + error: map_sdk_error("health_check", None, error), + }) + }) + .await + } + + fn capabilities(&self) -> BackendCapabilities { + // AWS supports on-demand rotation with retained backing keys, + // enable/disable, and a 7-30 day deletion window. It offers no + // immediate physical deletion: material is destroyed by AWS when the + // window elapses, never by RustFS. + BackendCapabilities::minimal() + .with_rotate(true) + .with_enable_disable(true) + .with_schedule_deletion(true) + .with_versioning(true) + .with_physical_delete(false) + } + + /// Observe, never destroy. + /// + /// AWS runs the deletion window itself, so the sweep's job here is only to + /// report what AWS has already done: a key that is gone counts as removed, + /// a key that left `PendingDeletion` counts as a state change, and a key + /// still pending is never expired by RustFS regardless of the deadline. + async fn remove_expired_key(&self, key_id: &str, _now: &Zoned) -> Result { + match self.describe(key_id).await { + Ok(metadata) => Ok(match metadata.key_state { + KeyState::PendingDeletion => ExpiredKeyRemoval::NotExpired, + _ => ExpiredKeyRemoval::StateChanged, + }), + Err(KmsError::KeyNotFound { .. }) => Ok(ExpiredKeyRemoval::Removed), + Err(error) => Err(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aws_sdk_kms::config::{BehaviorVersion, Credentials, Region}; + use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient}; + use aws_smithy_types::body::SdkBody; + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD as BASE64; + + /// AWS KMS speaks awsJson1_1; every request goes to `/` on the regional + /// endpoint, so the replayed request side carries no useful assertion. + fn any_request() -> http::Request { + http::Request::builder() + .method("POST") + .uri("https://kms.us-east-1.amazonaws.com/") + .body(SdkBody::from("{}")) + .expect("replay request should build") + } + + fn response(status: u16, body: String) -> http::Response { + http::Response::builder() + .status(status) + .header("content-type", "application/x-amz-json-1.1") + .body(SdkBody::from(body)) + .expect("replay response should build") + } + + fn ok_event(body: serde_json::Value) -> ReplayEvent { + ReplayEvent::new(any_request(), response(200, body.to_string())) + } + + /// An AWS error response in the awsJson1_1 shape. + fn error_event(status: u16, code: &str, message: &str) -> ReplayEvent { + ReplayEvent::new( + any_request(), + response(status, serde_json::json!({ "__type": code, "message": message }).to_string()), + ) + } + + fn scripted_backend(events: Vec) -> (StaticReplayClient, AwsKmsBackend) { + let http_client = StaticReplayClient::new(events); + let sdk_config = aws_sdk_kms::Config::builder() + .behavior_version(BehaviorVersion::latest()) + .region(Region::new("us-east-1")) + .credentials_provider(Credentials::new("AKIDTEST", "secret", None, None, "scripted")) + .http_client(http_client.clone()) + .retry_config(aws_sdk_kms::config::retry::RetryConfig::disabled()) + .build(); + let kms_config = KmsConfig::aws(Some("us-east-1".to_string())); + let backend = AwsKmsBackend::with_client(aws_sdk_kms::Client::from_conf(sdk_config), &kms_config); + (http_client, backend) + } + + fn key_metadata_json(key_id: &str, state: &str) -> serde_json::Value { + serde_json::json!({ + "KeyMetadata": { + "KeyId": key_id, + "Arn": format!("arn:aws:kms:us-east-1:111122223333:key/{key_id}"), + "CreationDate": 1_700_000_000, + "Enabled": state == "Enabled", + "Description": "contract key", + "KeyUsage": "ENCRYPT_DECRYPT", + "KeyState": state, + "Origin": "AWS_KMS", + "KeyManager": "CUSTOMER", + } + }) + } + + fn generate_request() -> GenerateDataKeyRequest { + GenerateDataKeyRequest { + key_id: "test-key".to_string(), + key_spec: KeySpec::Aes256, + encryption_context: HashMap::from([("bucket".to_string(), "aws".to_string())]), + } + } + + fn capabilities_snapshot(capabilities: BackendCapabilities) -> std::collections::BTreeMap { + serde_json::from_value(serde_json::to_value(capabilities).expect("capabilities should serialize")) + .expect("capabilities should deserialize into a flat bool map") + } + + #[tokio::test] + async fn aws_backend_capabilities_golden() { + let (_http, backend) = scripted_backend(Vec::new()); + insta::assert_json_snapshot!("aws_backend_capabilities", capabilities_snapshot(backend.capabilities())); + } + + #[tokio::test] + async fn generate_data_key_maps_the_native_response() { + let plaintext = vec![7u8; 32]; + let ciphertext = b"encrypted-data-key".to_vec(); + let (http_client, backend) = scripted_backend(vec![ok_event(serde_json::json!({ + "KeyId": "arn:aws:kms:us-east-1:111122223333:key/test-key", + "Plaintext": BASE64.encode(&plaintext), + "CiphertextBlob": BASE64.encode(&ciphertext), + }))]); + + let response = backend + .generate_data_key(generate_request()) + .await + .expect("generate_data_key should map the AWS response"); + + assert_eq!(response.plaintext_key, plaintext); + assert_eq!(response.ciphertext_blob, ciphertext); + assert_eq!(response.key_id, "arn:aws:kms:us-east-1:111122223333:key/test-key"); + assert_eq!(http_client.actual_requests().count(), 1); + } + + #[tokio::test] + async fn decrypt_recovers_the_plaintext_without_a_key_id() { + let plaintext = b"recovered-data-key".to_vec(); + let (_http, backend) = scripted_backend(vec![ok_event(serde_json::json!({ + "KeyId": "arn:aws:kms:us-east-1:111122223333:key/test-key", + "Plaintext": BASE64.encode(&plaintext), + "EncryptionAlgorithm": "SYMMETRIC_DEFAULT", + }))]); + + let response = backend + .decrypt(DecryptRequest { + ciphertext: b"blob".to_vec(), + encryption_context: HashMap::new(), + grant_tokens: Vec::new(), + }) + .await + .expect("decrypt should map the AWS response"); + + assert_eq!(response.plaintext, plaintext); + assert_eq!(response.encryption_algorithm.as_deref(), Some("SYMMETRIC_DEFAULT")); + } + + /// Asserts that a mapped failure landed on the intended `KmsError` variant. + type ErrorPredicate = fn(&KmsError) -> bool; + + /// Every AWS error code the backend classifies must land on the intended + /// typed error, so callers can keep reacting to categories rather than + /// parsing messages. + #[tokio::test] + async fn aws_error_codes_map_to_typed_errors() { + let cases: Vec<(u16, &str, ErrorPredicate)> = vec![ + (400, "NotFoundException", |error| matches!(error, KmsError::KeyNotFound { .. })), + (400, "AccessDeniedException", |error| matches!(error, KmsError::AccessDenied { .. })), + (400, "KMSInvalidStateException", |error| { + matches!(error, KmsError::InvalidOperation { .. }) + }), + (400, "DisabledException", |error| matches!(error, KmsError::InvalidOperation { .. })), + (400, "InvalidCiphertextException", |error| { + matches!(error, KmsError::CryptographicError { .. }) + }), + (400, "ValidationException", |error| matches!(error, KmsError::InvalidOperation { .. })), + (403, "UnrecognizedClientException", |error| { + matches!(error, KmsError::CredentialsUnavailable { .. }) + }), + (500, "KMSInternalException", |error| matches!(error, KmsError::BackendError { .. })), + ]; + + for (status, code, expected) in cases { + // One event per configured attempt so retryable codes exhaust the + // budget and still surface their mapped error. + let events = (0..4).map(|_| error_event(status, code, "scripted failure")).collect(); + let (_http, backend) = scripted_backend(events); + let error = backend + .generate_data_key(generate_request()) + .await + .expect_err("scripted AWS failure should surface"); + assert!(expected(&error), "unexpected mapping for {code}: {error:?}"); + } + } + + /// Throttling is replayed by the policy executor for read-shaped + /// operations, and the eventual success is returned to the caller. + #[tokio::test(start_paused = true)] + async fn throttled_reads_are_retried_until_they_succeed() { + let (http_client, backend) = scripted_backend(vec![ + error_event(400, "ThrottlingException", "rate exceeded"), + error_event(400, "ThrottlingException", "rate exceeded"), + ok_event(serde_json::json!({ + "KeyId": "test-key", + "Plaintext": BASE64.encode([1u8; 32]), + "CiphertextBlob": BASE64.encode(b"blob"), + })), + ]); + + backend + .generate_data_key(generate_request()) + .await + .expect("a throttled read must be retried"); + assert_eq!(http_client.actual_requests().count(), 3, "both throttled attempts should be replayed"); + } + + /// Access denial is deterministic: replaying it cannot help and would only + /// multiply the audit trail of denied calls. + #[tokio::test(start_paused = true)] + async fn access_denied_is_not_retried() { + let (http_client, backend) = scripted_backend(vec![ + error_event(400, "AccessDeniedException", "not authorized"), + error_event(400, "AccessDeniedException", "not authorized"), + ]); + + let error = backend + .generate_data_key(generate_request()) + .await + .expect_err("access denial should surface"); + assert!(matches!(error, KmsError::AccessDenied { .. }), "unexpected error: {error:?}"); + assert_eq!(http_client.actual_requests().count(), 1, "access denial must not be replayed"); + } + + /// Rotation carries an external side effect and has no idempotency key, so + /// even a throttled attempt is never replayed. + #[tokio::test(start_paused = true)] + async fn rotation_is_never_retried() { + let (http_client, backend) = scripted_backend(vec![ + error_event(400, "ThrottlingException", "rate exceeded"), + error_event(400, "ThrottlingException", "rate exceeded"), + ]); + + backend + .rotate_key("test-key") + .await + .expect_err("the scripted throttling should surface"); + assert_eq!(http_client.actual_requests().count(), 1, "a mutation must run at most once"); + } + + #[tokio::test] + async fn describe_key_maps_aws_states() { + for (aws_state, expected) in [ + ("Enabled", KeyState::Enabled), + ("Disabled", KeyState::Disabled), + ("PendingDeletion", KeyState::PendingDeletion), + ("PendingReplicaDeletion", KeyState::PendingDeletion), + ("PendingImport", KeyState::PendingImport), + // Transient and unknown AWS states must fail closed. + ("Creating", KeyState::Unavailable), + ("Updating", KeyState::Unavailable), + ] { + let (_http, backend) = scripted_backend(vec![ok_event(key_metadata_json("test-key", aws_state))]); + let described = backend + .describe_key(DescribeKeyRequest { + key_id: "test-key".to_string(), + }) + .await + .expect("describe_key should map the AWS response"); + assert_eq!(described.key_metadata.key_state, expected, "unexpected mapping for {aws_state}"); + assert_eq!(described.key_metadata.key_manager, KEY_MANAGER); + } + } + + /// AWS's window bounds are enforced before the request leaves the process. + #[tokio::test] + async fn deletion_window_is_validated_before_the_call() { + for days in [0, 6, 31] { + let (http_client, backend) = scripted_backend(Vec::new()); + let error = backend + .delete_key(DeleteKeyRequest { + key_id: "test-key".to_string(), + pending_window_in_days: Some(days), + force_immediate: None, + }) + .await + .expect_err("an out-of-range window must be rejected"); + assert!(matches!(error, KmsError::InvalidOperation { .. }), "unexpected error: {error:?}"); + assert_eq!(http_client.actual_requests().count(), 0, "no request should reach AWS"); + } + } + + /// AWS has no immediate-deletion path, so the capability gap is reported + /// instead of silently degrading to a scheduled deletion. + #[tokio::test] + async fn immediate_deletion_is_unsupported() { + let (http_client, backend) = scripted_backend(Vec::new()); + let error = backend + .delete_key(DeleteKeyRequest { + key_id: "test-key".to_string(), + pending_window_in_days: None, + force_immediate: Some(true), + }) + .await + .expect_err("immediate deletion must be rejected"); + assert!(matches!(error, KmsError::UnsupportedCapability { .. }), "unexpected error: {error:?}"); + assert_eq!(http_client.actual_requests().count(), 0); + assert!(!backend.capabilities().physical_delete); + } + + /// A caller-assigned key name cannot be honoured by AWS. Refusing it keeps + /// describe-then-create callers (SSE-S3 auto-creation, the synthetic + /// probe) from creating a fresh, unreachable key on every attempt. + #[tokio::test] + async fn caller_assigned_key_names_are_unsupported() { + let (http_client, backend) = scripted_backend(Vec::new()); + let error = backend + .create_key(CreateKeyRequest { + key_name: Some("rustfs-internal-kms-probe".to_string()), + ..Default::default() + }) + .await + .expect_err("a named create must be rejected"); + assert!(matches!(error, KmsError::UnsupportedCapability { .. }), "unexpected error: {error:?}"); + assert_eq!(http_client.actual_requests().count(), 0, "no key may be created in AWS"); + } + + /// Signing keys are outside the envelope-encryption surface this backend + /// serves; creating one is refused rather than silently downgraded. + #[tokio::test] + async fn sign_verify_keys_are_unsupported() { + let (http_client, backend) = scripted_backend(Vec::new()); + let error = backend + .create_key(CreateKeyRequest { + key_usage: KeyUsage::SignVerify, + ..Default::default() + }) + .await + .expect_err("SIGN_VERIFY keys must be rejected"); + assert!(matches!(error, KmsError::UnsupportedCapability { .. }), "unexpected error: {error:?}"); + assert_eq!(http_client.actual_requests().count(), 0); + } + + /// The deletion sweep must never destroy AWS-held material: a key still + /// inside its window is reported as not expired, and one AWS has already + /// removed is reported as removed. + #[tokio::test] + async fn expired_key_removal_only_observes_aws() { + let (http_client, backend) = scripted_backend(vec![ok_event(key_metadata_json("test-key", "PendingDeletion"))]); + assert_eq!( + backend + .remove_expired_key("test-key", &Zoned::now()) + .await + .expect("observation should succeed"), + ExpiredKeyRemoval::NotExpired + ); + assert_eq!(http_client.actual_requests().count(), 1, "no deletion call may be issued"); + + let (_http, backend) = scripted_backend(vec![error_event(400, "NotFoundException", "key does not exist")]); + assert_eq!( + backend + .remove_expired_key("test-key", &Zoned::now()) + .await + .expect("a key AWS already removed is a completed removal"), + ExpiredKeyRemoval::Removed + ); + + let (_http, backend) = scripted_backend(vec![ok_event(key_metadata_json("test-key", "Enabled"))]); + assert_eq!( + backend + .remove_expired_key("test-key", &Zoned::now()) + .await + .expect("observation should succeed"), + ExpiredKeyRemoval::StateChanged + ); + } + + /// End-to-end lifecycle against a real AWS account. + /// + /// Not part of the shared contract-test driver: AWS refuses to decrypt + /// with a `Disabled` or `PendingDeletion` key, and leaves a cancelled key + /// disabled, both of which the shared matrix asserts the other way around. + /// Requires credentials with `kms:CreateKey`, `kms:GenerateDataKey`, + /// `kms:Decrypt`, `kms:DescribeKey`, `kms:EnableKey`, `kms:DisableKey`, + /// `kms:RotateKeyOnDemand`, `kms:ScheduleKeyDeletion` and + /// `kms:CancelKeyDeletion`. + #[tokio::test] + #[ignore] // Requires real AWS credentials and creates a billable KMS key + async fn aws_backend_lifecycle_end_to_end() { + let region = std::env::var("RUSTFS_KMS_AWS_REGION").ok(); + let backend = AwsKmsBackend::new(KmsConfig::aws(region)) + .await + .expect("aws backend should build"); + + let created = backend + .create_key(CreateKeyRequest { + description: Some("rustfs aws backend contract".to_string()), + tags: HashMap::from([("rustfs-test".to_string(), "contract".to_string())]), + ..Default::default() + }) + .await + .expect("key should be created"); + let key_id = created.key_id; + + let data_key = backend + .generate_data_key(generate_request_for(&key_id)) + .await + .expect("an enabled key must generate data keys"); + let decrypted = backend + .decrypt(DecryptRequest { + ciphertext: data_key.ciphertext_blob.clone(), + encryption_context: HashMap::from([("bucket".to_string(), "aws".to_string())]), + grant_tokens: Vec::new(), + }) + .await + .expect("the data key must decrypt"); + assert_eq!(decrypted.plaintext, data_key.plaintext_key); + + backend.rotate_key(&key_id).await.expect("on-demand rotation must succeed"); + backend + .generate_data_key(generate_request_for(&key_id)) + .await + .expect("a rotated key must stay usable"); + backend + .decrypt(DecryptRequest { + ciphertext: data_key.ciphertext_blob.clone(), + encryption_context: HashMap::from([("bucket".to_string(), "aws".to_string())]), + grant_tokens: Vec::new(), + }) + .await + .expect("rotation must retain prior backing keys for decryption"); + + backend.disable_key(&key_id).await.expect("disable must succeed"); + assert_eq!( + backend.describe(&key_id).await.expect("describe must succeed").key_state, + KeyState::Disabled + ); + backend.enable_key(&key_id).await.expect("enable must succeed"); + + backend + .delete_key(DeleteKeyRequest { + key_id: key_id.clone(), + pending_window_in_days: Some(7), + force_immediate: None, + }) + .await + .expect("scheduling deletion must succeed"); + assert_eq!( + backend.describe(&key_id).await.expect("describe must succeed").key_state, + KeyState::PendingDeletion + ); + // Leave the key scheduled for deletion so repeated runs stay tidy; the + // cancel below is only there to prove the transition works. + backend + .cancel_key_deletion(CancelKeyDeletionRequest { key_id: key_id.clone() }) + .await + .expect("cancelling deletion must succeed"); + backend + .delete_key(DeleteKeyRequest { + key_id, + pending_window_in_days: Some(7), + force_immediate: None, + }) + .await + .expect("re-scheduling deletion must succeed"); + } + + fn generate_request_for(key_id: &str) -> GenerateDataKeyRequest { + GenerateDataKeyRequest { + key_id: key_id.to_string(), + ..generate_request() + } + } +} diff --git a/crates/kms/src/backends/local.rs b/crates/kms/src/backends/local.rs index 490e5bdac..3bd7a57aa 100644 --- a/crates/kms/src/backends/local.rs +++ b/crates/kms/src/backends/local.rs @@ -1386,7 +1386,8 @@ impl LocalKmsBackend { crate::config::BackendConfig::Local(local_config) => local_config.clone(), crate::config::BackendConfig::VaultKv2(_) | crate::config::BackendConfig::VaultTransit(_) - | crate::config::BackendConfig::Static(_) => { + | crate::config::BackendConfig::Static(_) + | crate::config::BackendConfig::Aws(_) => { return Err(KmsError::configuration_error("Expected Local backend configuration")); } }; diff --git a/crates/kms/src/backends/mod.rs b/crates/kms/src/backends/mod.rs index bf9a54e55..0150275db 100644 --- a/crates/kms/src/backends/mod.rs +++ b/crates/kms/src/backends/mod.rs @@ -20,6 +20,7 @@ use async_trait::async_trait; use jiff::Zoned; use serde::{Deserialize, Serialize}; +pub mod aws; #[cfg(test)] mod contract_tests; pub mod local; diff --git a/crates/kms/src/backends/snapshots/rustfs_kms__backends__aws__tests__aws_backend_capabilities.snap b/crates/kms/src/backends/snapshots/rustfs_kms__backends__aws__tests__aws_backend_capabilities.snap new file mode 100644 index 000000000..a6f50d1e1 --- /dev/null +++ b/crates/kms/src/backends/snapshots/rustfs_kms__backends__aws__tests__aws_backend_capabilities.snap @@ -0,0 +1,14 @@ +--- +source: crates/kms/src/backends/aws.rs +expression: capabilities_snapshot(backend.capabilities()) +--- +{ + "decrypt": true, + "enable_disable": true, + "encrypt": true, + "generate_data_key": true, + "physical_delete": false, + "rotate": true, + "schedule_deletion": true, + "versioning": true +} diff --git a/crates/kms/src/backends/vault.rs b/crates/kms/src/backends/vault.rs index bbe815fdf..2e2575eb1 100644 --- a/crates/kms/src/backends/vault.rs +++ b/crates/kms/src/backends/vault.rs @@ -1163,7 +1163,8 @@ impl VaultKmsBackend { crate::config::BackendConfig::VaultKv2(vault_config) => (**vault_config).clone(), crate::config::BackendConfig::Local(_) | crate::config::BackendConfig::VaultTransit(_) - | crate::config::BackendConfig::Static(_) => { + | crate::config::BackendConfig::Static(_) + | crate::config::BackendConfig::Aws(_) => { return Err(KmsError::configuration_error("Expected Vault KV2 backend configuration")); } }; diff --git a/crates/kms/src/backends/vault_transit.rs b/crates/kms/src/backends/vault_transit.rs index 2c8db47d7..af15c0f06 100644 --- a/crates/kms/src/backends/vault_transit.rs +++ b/crates/kms/src/backends/vault_transit.rs @@ -1012,7 +1012,9 @@ impl VaultTransitKmsBackend { metadata_key_prefix: vault_config.key_path_prefix.clone(), tls: vault_config.tls.clone(), }, - crate::config::BackendConfig::Local(_) | crate::config::BackendConfig::Static(_) => { + crate::config::BackendConfig::Local(_) + | crate::config::BackendConfig::Static(_) + | crate::config::BackendConfig::Aws(_) => { return Err(KmsError::configuration_error("Expected Vault Transit backend configuration")); } }; diff --git a/crates/kms/src/config.rs b/crates/kms/src/config.rs index fe09bcc9b..37cde700b 100644 --- a/crates/kms/src/config.rs +++ b/crates/kms/src/config.rs @@ -34,6 +34,8 @@ pub const ENV_KMS_VAULT_APPROLE_SECRET_ID: &str = "RUSTFS_KMS_VAULT_APPROLE_SECR pub const ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE: &str = "RUSTFS_KMS_VAULT_APPROLE_SECRET_ID_FILE"; pub const ENV_KMS_VAULT_APPROLE_MOUNT: &str = "RUSTFS_KMS_VAULT_APPROLE_MOUNT"; pub const ENV_KMS_VAULT_TOKEN_FILE: &str = "RUSTFS_KMS_VAULT_TOKEN_FILE"; +pub const ENV_KMS_AWS_REGION: &str = "RUSTFS_KMS_AWS_REGION"; +pub const ENV_KMS_AWS_ENDPOINT_URL: &str = "RUSTFS_KMS_AWS_ENDPOINT_URL"; pub const DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT: &str = "secret"; pub const DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX: &str = "rustfs/kms/transit-metadata"; pub const DEFAULT_VAULT_APPROLE_MOUNT: &str = "approle"; @@ -128,6 +130,10 @@ pub enum KmsBackend { /// Static single-key backend that derives DEKs from a pre-configured key #[serde(rename = "Static")] Static, + /// AWS KMS backend: AWS is the cryptographic source of truth and owns key + /// state, versioning, and the deletion window. + #[serde(rename = "AWS", alias = "AwsKms")] + Aws, } impl KmsBackend { @@ -141,6 +147,7 @@ impl KmsBackend { KmsBackend::VaultTransit => "vault-transit", KmsBackend::Local => "local", KmsBackend::Static => "static", + KmsBackend::Aws => "aws", } } } @@ -200,6 +207,9 @@ pub enum BackendConfig { VaultTransit(Box), /// Static single-key backend configuration Static(StaticConfig), + /// AWS KMS backend configuration + #[serde(rename = "AWS", alias = "AwsKms")] + Aws(Box), } impl Default for BackendConfig { @@ -215,6 +225,7 @@ impl fmt::Debug for BackendConfig { Self::VaultKv2(config) => f.debug_tuple("VaultKv2").field(config).finish(), Self::VaultTransit(config) => f.debug_tuple("VaultTransit").field(config).finish(), Self::Static(config) => f.debug_tuple("Static").field(config).finish(), + Self::Aws(config) => f.debug_tuple("Aws").field(config).finish(), } } } @@ -540,6 +551,25 @@ impl Default for CacheConfig { } } +/// AWS KMS backend configuration. +/// +/// Deliberately holds no credential material: the backend resolves credentials +/// through the standard `aws-config` provider chain (environment, shared +/// profile, container/IMDS role), so RustFS never stores, persists, or redacts +/// AWS secrets of its own. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct AwsKmsConfig { + /// AWS region hosting the KMS keys. When unset, the region is resolved by + /// the standard chain (`AWS_REGION`, profile, IMDS). + #[serde(default)] + pub region: Option, + /// Override for the KMS endpoint, for local emulators and private + /// endpoints. Unset in production, where the SDK derives the regional + /// endpoint. + #[serde(default)] + pub endpoint_url: Option, +} + impl KmsConfig { /// Create a new KMS configuration for local backend (for development and testing only) pub fn local(key_dir: PathBuf) -> Self { @@ -649,6 +679,29 @@ impl KmsConfig { } } + /// Create a new KMS configuration for the AWS KMS backend. + /// + /// Credentials are resolved by the standard `aws-config` provider chain; + /// only the region is configured here. + pub fn aws(region: Option) -> Self { + Self { + backend: KmsBackend::Aws, + backend_config: BackendConfig::Aws(Box::new(AwsKmsConfig { + region, + endpoint_url: None, + })), + ..Default::default() + } + } + + /// Get the AWS configuration if backend is AWS KMS + pub fn aws_kms_config(&self) -> Option<&AwsKmsConfig> { + match &self.backend_config { + BackendConfig::Aws(config) => Some(config), + _ => None, + } + } + /// Set default key ID pub fn with_default_key(mut self, key_id: String) -> Self { self.default_key_id = Some(key_id); @@ -805,6 +858,25 @@ impl KmsConfig { // Validate that the key can be decoded (right length, valid base64) config.decode_key()?; } + BackendConfig::Aws(config) => { + if let Some(region) = &config.region + && region.is_empty() + { + return Err(KmsError::configuration_error("AWS KMS region cannot be empty when set")); + } + + if let Some(endpoint) = &config.endpoint_url { + if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") { + return Err(KmsError::configuration_error("AWS KMS endpoint URL must use http or https scheme")); + } + // A plaintext endpoint override exposes every KMS request, + // including plaintext data keys, so it stays gated on the + // explicit development opt-in. + if endpoint.starts_with("http://") && !self.allow_insecure_dev_defaults { + return Err(development_default_error("AWS KMS endpoint URL must use https")); + } + } + } } // Validate cache configuration @@ -826,6 +898,7 @@ impl KmsConfig { "vault" | "vault-kv2" | "vault_kv2" => KmsBackend::VaultKv2, "vault-transit" | "vault_transit" => KmsBackend::VaultTransit, "static" => KmsBackend::Static, + "aws" | "aws-kms" | "aws_kms" => KmsBackend::Aws, _ => return Err(KmsError::configuration_error(format!("Unknown KMS backend: {backend_type}"))), }; } @@ -954,6 +1027,14 @@ impl KmsConfig { }); config.default_key_id = Some(key_id); } + KmsBackend::Aws => { + // Only non-credential settings are read here; access keys, + // profiles, and role assumption stay with the aws-config chain. + config.backend_config = BackendConfig::Aws(Box::new(AwsKmsConfig { + region: get_env_opt_str(ENV_KMS_AWS_REGION), + endpoint_url: get_env_opt_str(ENV_KMS_AWS_ENDPOINT_URL), + })); + } } config.validate()?; @@ -1555,6 +1636,43 @@ mod tests { ); } + /// The AWS backend reads only non-credential settings from the + /// environment; access keys, profiles, and role assumption stay with the + /// aws-config provider chain. + #[test] + fn test_from_env_selects_aws_backend_without_credentials() { + with_vars( + vec![ + ("RUSTFS_KMS_BACKEND", Some("aws")), + (ENV_KMS_AWS_REGION, Some("eu-central-1")), + (ENV_KMS_AWS_ENDPOINT_URL, None::<&str>), + ], + || { + let config = KmsConfig::from_env().expect("kms config should load from env"); + assert_eq!(config.backend, KmsBackend::Aws); + let aws = config.aws_kms_config().expect("aws backend config"); + assert_eq!(aws.region.as_deref(), Some("eu-central-1")); + assert_eq!(aws.endpoint_url, None); + }, + ); + } + + /// A plaintext endpoint override exposes every KMS request, including the + /// plaintext data keys, so it stays behind the explicit development opt-in. + #[test] + fn test_from_env_rejects_plaintext_aws_endpoint() { + with_vars( + vec![ + ("RUSTFS_KMS_BACKEND", Some("aws")), + (ENV_KMS_AWS_REGION, Some("us-east-1")), + (ENV_KMS_AWS_ENDPOINT_URL, Some("http://localhost:4566")), + ], + || { + KmsConfig::from_env().expect_err("a plaintext AWS endpoint must be rejected by default"); + }, + ); + } + #[test] fn test_from_env_approle_requires_secret_id_or_file() { with_vars( diff --git a/crates/kms/src/policy.rs b/crates/kms/src/policy.rs index bc7fcc664..da478a224 100644 --- a/crates/kms/src/policy.rs +++ b/crates/kms/src/policy.rs @@ -104,7 +104,9 @@ pub(crate) fn classify_vaultrs(error: &vaultrs::error::ClientError) -> ErrorClas } } -fn classify_status(code: u16) -> ErrorClass { +/// Retry classification of an HTTP status, shared by every backend that talks +/// to an external KMS over HTTP. +pub(crate) fn classify_status(code: u16) -> ErrorClass { match code { 429 | 500 | 502 | 503 | 504 => ErrorClass::RetryableStatus, _ => ErrorClass::Fatal, diff --git a/crates/kms/src/service_manager.rs b/crates/kms/src/service_manager.rs index 77104d815..8e43e0f43 100644 --- a/crates/kms/src/service_manager.rs +++ b/crates/kms/src/service_manager.rs @@ -626,6 +626,11 @@ impl KmsServiceManager { let backend = crate::backends::static_kms::StaticKmsBackend::new(config.clone()).await?; Arc::new(backend) as Arc } + BackendConfig::Aws(_) => { + info!("Creating AWS KMS backend for version {}", version); + let backend = crate::backends::aws::AwsKmsBackend::new(config.clone()).await?; + Arc::new(backend) as Arc + } }; // Create KMS manager diff --git a/docs/operations/kms-backend-security.md b/docs/operations/kms-backend-security.md index c888bc497..7638c10ca 100644 --- a/docs/operations/kms-backend-security.md +++ b/docs/operations/kms-backend-security.md @@ -12,6 +12,7 @@ For how the Vault backends authenticate (static token, AppRole, Vault Agent toke | Static | `Static` | Provided out-of-band via environment/file; never persisted by RustFS | Operator-managed secret distribution | No state persisted by RustFS | Rejected (read-only backend) | Simple deployments with an external secret manager | | Vault KV2 | `VaultKV2` (legacy alias `Vault`) | Stored **directly** in Vault KV v2 (Base64-encoded plaintext) | Vault ACLs + KV v2 at-rest encryption + TLS only | Delegated to Vault storage | Versioned retention (immutable per-version records + current pointer) | Deployments that accept Vault KV ACLs as the sole confidentiality boundary | | Vault Transit | `VaultTransit` | Key-encryption keys never leave Vault; only Transit ciphertext is visible outside | Vault Transit engine (cryptographic isolation) | Delegated to Vault storage | Via Vault Transit key versioning | Deployments that need key material to be unreadable through storage APIs | +| AWS KMS | `AWS` (alias `AwsKms`) | Key material never leaves AWS KMS; RustFS mirrors no key state | AWS KMS (cryptographic isolation) + IAM | Delegated to AWS | On-demand `RotateKeyOnDemand`; prior backing keys stay usable for decryption | Deployments already rooted in AWS IAM that want AWS as the cryptographic root — read [AWS KMS: deviations from the shared backend contract](#aws-kms-deviations-from-the-shared-backend-contract) first | ## Vault KV2: what the backend does and does not do @@ -146,6 +147,25 @@ Use **Vault Transit** (`VaultTransit`) when key material must be cryptographical Use **Vault KV2** only when you accept that the Vault ACL on the key path *is* the confidentiality boundary and you want the operational simplicity of a single KV mount. +## AWS KMS: deviations from the shared backend contract + +Select it with `RUSTFS_KMS_BACKEND=aws`. Credentials and region resolution are delegated entirely to the standard `aws-config` provider chain (environment, shared profile, container/IMDS role), so RustFS never stores, persists, or redacts AWS credential material of its own. Only two non-credential settings are read: `RUSTFS_KMS_AWS_REGION` and `RUSTFS_KMS_AWS_ENDPOINT_URL`. A plaintext (`http://`) endpoint override would expose every KMS request including plaintext data keys, so it is refused unless the development opt-in is set. + +AWS owns key state, backing-key rotation, and the deletion window, and this backend mirrors none of it locally. That makes four behaviours differ from every RustFS-managed backend. Verify each against your operational assumptions before switching: + +| Behaviour | RustFS-managed backends | AWS KMS backend | +| --- | --- | --- | +| Decryption with a `Disabled` or `PendingDeletion` key | Kept working, so disabling a key never breaks reads of objects already encrypted under it | **Refused by AWS.** Objects encrypted under a key that is later disabled become unreadable until it is re-enabled | +| Key deletion | Physical deletion available | **No physical delete.** `ScheduleKeyDeletion` is the only removal path; AWS destroys the material when the 7-30 day window elapses. RustFS never destroys AWS-held material, and `force_immediate` is refused | +| Cancelling a scheduled deletion | Key returns to `Enabled` | Key is left **`Disabled`**; enable it explicitly to make it usable again | +| Creating a key under a caller-chosen name | The requested name becomes the key id | **Refused.** AWS assigns identifiers and this backend does not manage aliases, so a named create would produce a key unreachable by that name | + +Two consequences follow from that last row: **SSE-S3 key auto-creation and the synthetic KMS probe are unavailable on this backend**, because both address a key by a name they choose. Pre-create keys in AWS and reference them by AWS key id or ARN. + +Key versions are opaque. AWS addresses backing keys internally and picks the right one to decrypt with, so RustFS reports `key_version` as 1 and cannot enumerate versions. Rotation uses `RotateKeyOnDemand`, which retains prior backing keys for decryption; AWS's separate automatic yearly rotation is neither enabled nor reported on by RustFS. + +The AWS backend is not configurable through the KMS admin API — use the environment variables above at startup. + ## Local backend durability and deployment support matrix The Local backend stores one JSON record per key (`.key`) plus an Argon2id salt file (`.master-key.salt`) inside the configured `key_dir`. This section documents which deployments that layout supports and how the backend recovers from a crash or power loss. For where the key material lives and who can read it, see the [backend comparison](#backend-comparison) above. diff --git a/rustfs/src/admin/handlers/kms_dynamic.rs b/rustfs/src/admin/handlers/kms_dynamic.rs index 526079d28..420e15e58 100644 --- a/rustfs/src/admin/handlers/kms_dynamic.rs +++ b/rustfs/src/admin/handlers/kms_dynamic.rs @@ -76,6 +76,8 @@ fn existing_vault_auth(config: &KmsConfig) -> Option Some(vault.auth_method.clone()), rustfs_kms::config::BackendConfig::Local(_) => None, rustfs_kms::config::BackendConfig::Static(_) => None, + // AWS credentials come from the aws-config chain, not from KMS config. + rustfs_kms::config::BackendConfig::Aws(_) => None, } } diff --git a/rustfs/src/admin/handlers/kms_management.rs b/rustfs/src/admin/handlers/kms_management.rs index 978848fb4..59fdd355b 100644 --- a/rustfs/src/admin/handlers/kms_management.rs +++ b/rustfs/src/admin/handlers/kms_management.rs @@ -53,6 +53,7 @@ fn backend_name(backend: &KmsBackend) -> &'static str { KmsBackend::VaultKv2 => "vault-kv2", KmsBackend::VaultTransit => "vault-transit", KmsBackend::Static => "static", + KmsBackend::Aws => "aws", } }