// 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, confirm_key_id: 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), confirm_key_id: None, }) .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, confirm_key_id: 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, confirm_key_id: 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() } } }