diff --git a/crates/kms/src/backends/vault_transit.rs b/crates/kms/src/backends/vault_transit.rs index 8f9ec0991..5b49ed87b 100644 --- a/crates/kms/src/backends/vault_transit.rs +++ b/crates/kms/src/backends/vault_transit.rs @@ -54,6 +54,7 @@ use vaultrs::{ kv2, transit::{data, key}, }; +use zeroize::Zeroizing; /// Attempt budget for metadata read-modify-write cycles: every check-and-set /// conflict triggers a fresh read plus state-gate re-validation, never a blind @@ -1081,30 +1082,27 @@ impl VaultTransitKmsClient { }) } - /// Re-wrap an existing envelope onto the transit key's latest version using - /// Vault's native rewrap endpoint. + /// Re-wrap an existing envelope onto the transit key's latest version. /// - /// The data key is never decrypted into this process: Vault re-encrypts the - /// ciphertext internally and hands back only the new ciphertext, so no - /// `transit/decrypt` is issued and no plaintext data key exists here to - /// leak, log or persist. + /// Envelopes without an encryption context go through Vault's native + /// rewrap endpoint: Vault re-encrypts the ciphertext internally, so no + /// plaintext data key exists in this process at all. /// - /// # Envelopes bound to an encryption context cannot be rewrapped - /// - /// This backend binds the encryption context into the wrapping as AEAD - /// associated data ([`Self::transit_encrypt`]), and Vault's `transit/rewrap` - /// endpoint accepts no `associated_data` parameter — the only way to move - /// such a ciphertext onto a newer version is `transit/decrypt` followed by - /// `transit/encrypt`, which materializes the plaintext data key inside - /// RustFS. That trade is refused here rather than made silently: it would - /// hand back a valid envelope while dropping the very property that makes a - /// backend-side rewrap worth having. Every object-level envelope carries a - /// bucket/object context, so in practice this rejects them all until the - /// context binding or the endpoint changes. + /// Envelopes that bind their context as AEAD associated data + /// ([`Self::transit_encrypt`]) cannot use that endpoint — Vault's + /// `transit/rewrap` accepts no `associated_data` parameter — so they move + /// via `transit/decrypt` followed by `transit/encrypt`, both carrying the + /// context. On that route the plaintext data key exists in this process + /// for exactly the length of the re-encrypt call, is zeroized immediately, + /// and is never persisted, logged or returned — within the trait contract, + /// and the same in-memory exposure every decrypt of the envelope already + /// has. The no-op case is answered against Vault's latest key version + /// before anything is decrypted, so a converged sweep re-run materializes + /// nothing. /// /// The context guard still runs first, so a caller that cannot reproduce the - /// envelope's context is told that rather than being told about the AAD - /// limitation of an envelope it has no claim on. + /// envelope's context is told that rather than anything about an envelope it + /// has no claim on. pub(crate) async fn rewrap_data_key(&self, request: &RewrapDataKeyRequest) -> Result { let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext) .map_err(|e| KmsError::cryptographic_error("parse", format!("Failed to parse data key envelope: {e}")))?; @@ -1112,23 +1110,63 @@ impl VaultTransitKmsClient { self.ensure_key_state_allows(&envelope.master_key_id, StateGatedOperation::Encrypt) .await?; - if !envelope.encryption_context.is_empty() { - return Err(KmsError::rewrap_would_expose_plaintext( - &envelope.master_key_id, - "the envelope binds its encryption context as AEAD associated data, which Vault Transit's rewrap endpoint \ - cannot carry; rewrapping it would require decrypting the data key inside RustFS", - )); - } - let source_ciphertext = std::str::from_utf8(&envelope.encrypted_key) .map_err(|e| KmsError::cryptographic_error("utf8", format!("Invalid Transit ciphertext: {e}")))?; let source_key_version = transit_ciphertext_version(source_ciphertext); - let rewrapped_ciphertext = match self.transit_rewrap(&envelope.master_key_id, source_ciphertext).await { - Ok(ciphertext) => ciphertext, - Err(error) => { - self.invalidate_metadata_on_state_error(&envelope.master_key_id, &error).await; - return Err(error); + let rewrapped_ciphertext = if envelope.encryption_context.is_empty() { + match self.transit_rewrap(&envelope.master_key_id, source_ciphertext).await { + Ok(ciphertext) => ciphertext, + Err(error) => { + self.invalidate_metadata_on_state_error(&envelope.master_key_id, &error).await; + return Err(error); + } + } + } else { + // Context-bound route. Most envelopes a sweep re-visits are already + // current: answer those from the key record alone, before any + // plaintext exists. + let latest = match self.latest_transit_key_version(&envelope.master_key_id).await { + Ok(latest) => latest, + Err(error) => { + self.invalidate_metadata_on_state_error(&envelope.master_key_id, &error).await; + return Err(error); + } + }; + if source_key_version.is_some() && source_key_version == latest { + return Ok(RewrapDataKeyResponse { + ciphertext: request.ciphertext.clone(), + key_id: envelope.master_key_id, + source_key_version, + destination_key_version: latest, + rewrapped: false, + }); + } + + let plaintext_key = Zeroizing::new( + match self + .transit_decrypt(&envelope.master_key_id, source_ciphertext, &envelope.encryption_context) + .await + { + Ok(plaintext) => plaintext, + Err(error) => { + self.invalidate_metadata_on_state_error(&envelope.master_key_id, &error).await; + return Err(error); + } + }, + ); + // Keep the plaintext in a zeroizing wrapper across the await so + // cancellation cannot bypass clearing it on drop. + let reencrypted = self + .transit_encrypt(&envelope.master_key_id, &plaintext_key, &envelope.encryption_context) + .await; + drop(plaintext_key); + match reencrypted { + Ok(ciphertext) => ciphertext, + Err(error) => { + self.invalidate_metadata_on_state_error(&envelope.master_key_id, &error).await; + return Err(error); + } } }; let destination_key_version = transit_ciphertext_version(&rewrapped_ciphertext); @@ -1494,16 +1532,14 @@ impl VaultTransitKmsBackend { let vault_config = match &config.backend_config { crate::config::BackendConfig::VaultTransit(vault_config) => (**vault_config).clone(), - crate::config::BackendConfig::VaultKv2(vault_config) => VaultTransitConfig { - address: vault_config.address.clone(), - auth_method: vault_config.auth_method.clone(), - namespace: vault_config.namespace.clone(), - mount_path: vault_config.mount_path.clone(), - metadata_kv_mount: vault_config.kv_mount.clone(), - metadata_key_prefix: vault_config.key_path_prefix.clone(), - tls: vault_config.tls.clone(), - }, - crate::config::BackendConfig::Local(_) + // Deriving a Transit configuration from a KV2 one used to be + // accepted here, silently reinterpreting KV2's deprecated + // `mount_path` as the Transit engine mount and its key storage as + // the metadata location — a mount mismatch that surfaces as + // confusing Vault 404s long after configuration time. A KV2 + // configuration reaching this constructor is a wiring bug; name it. + crate::config::BackendConfig::VaultKv2(_) + | crate::config::BackendConfig::Local(_) | crate::config::BackendConfig::Static(_) | crate::config::BackendConfig::Aws(_) => { return Err(KmsError::configuration_error("Expected Vault Transit backend configuration")); @@ -1769,11 +1805,10 @@ impl KmsBackend for VaultTransitKmsBackend { fn capabilities(&self) -> BackendCapabilities { // Vault Transit natively supports version-retaining rotation, keeps // prior versions addressable for decryption, and allows physical - // deletion once a key is pending deletion. Rewrap is advertised because - // the endpoint exists and works; envelopes whose encryption context is - // bound as associated data are still refused per envelope (see - // `VaultTransitKmsClient::rewrap_data_key`), which is a property of the - // envelope rather than of the backend. + // deletion once a key is pending deletion. Rewrap covers every envelope: + // context-free ones via Vault's native rewrap endpoint, context-bound + // ones via decrypt + re-encrypt with the associated data carried on + // both calls (see `VaultTransitKmsClient::rewrap_data_key`). BackendCapabilities::minimal() .with_rotate(true) .with_enable_disable(true) @@ -3191,18 +3226,84 @@ mod tests { } /// Vault's `transit/rewrap` endpoint takes no `associated_data` parameter, - /// and this backend binds the encryption context as exactly that. The only - /// remaining route would decrypt the data key inside RustFS, so the request - /// is refused rather than silently downgraded — and refused without any call - /// to Vault at all. + /// and this backend binds the encryption context as exactly that — so a + /// context-bound envelope moves via decrypt + re-encrypt, with the + /// associated data carried on both calls. The no-op case is answered from + /// the key record alone, so a converged sweep never materializes a + /// plaintext data key. #[tokio::test] - async fn wired_transit_rewrap_refuses_an_aad_bound_envelope() { + async fn wired_transit_rewrap_moves_an_aad_bound_envelope_via_decrypt_reencrypt() { + const RECOVERED_DEK: [u8; 32] = [0x59u8; 32]; + let context = HashMap::from([("bucket".to_string(), "photos/cat.jpg".to_string())]); + let metadata = TransitKeyMetadata::from_create_request(&CreateKeyRequest::default()); + let (vault, client) = scripted_client(vec![ + // generate_data_key: metadata state gate, then the transit encrypt. + ScriptedResponse::ok(metadata_read_data(&metadata)), + ScriptedResponse::ok(serde_json::json!({ "ciphertext": "vault:v1:scripted" })), + // rewrap, context-bound route: latest-version read, then decrypt, + // then re-encrypt under the newest version. + ScriptedResponse::ok(transit_key_read_data_up_to("wired-key", 2)), + ScriptedResponse::ok(serde_json::json!({ "plaintext": BASE64.encode(RECOVERED_DEK) })), + ScriptedResponse::ok(serde_json::json!({ "ciphertext": "vault:v2:rewrapped" })), + ]) + .await; + + let data_key = client + .generate_data_key(&wired_key_request(context.clone()), None) + .await + .expect("generate_data_key must produce an envelope"); + + let response = client + .rewrap_data_key(&RewrapDataKeyRequest { + ciphertext: data_key.ciphertext.clone(), + encryption_context: context.clone(), + }) + .await + .expect("a context-bound envelope must rewrap via decrypt + re-encrypt"); + assert!(response.rewrapped); + assert_eq!(response.source_key_version, Some(1)); + assert_eq!(response.destination_key_version, Some(2)); + + let original: DataKeyEnvelope = serde_json::from_slice(&data_key.ciphertext).expect("envelope must parse"); + let rewrapped: DataKeyEnvelope = serde_json::from_slice(&response.ciphertext).expect("rewrapped envelope must parse"); + assert_eq!(rewrapped.encrypted_key, b"vault:v2:rewrapped".to_vec()); + assert_eq!(rewrapped.encryption_context, original.encryption_context); + assert_eq!(rewrapped.key_id, original.key_id); + assert_eq!(rewrapped.created_at, original.created_at); + + let requests = vault.requests(); + assert_eq!(requests[2], "GET /v1/transit/keys/wired-key", "{requests:?}"); + assert_eq!(requests[3], "POST /v1/transit/decrypt/wired-key", "{requests:?}"); + assert_eq!(requests[4], "POST /v1/transit/encrypt/wired-key", "{requests:?}"); + assert!( + !requests.iter().any(|request| request.contains("/transit/rewrap/")), + "the native endpoint cannot carry the associated data: {requests:?}" + ); + // Dropping the associated data on either call would silently unbind the + // context; both bodies must carry it. + let bodies = vault.request_bodies(); + for index in [3usize, 4] { + let body: serde_json::Value = serde_json::from_str(&bodies[index]).expect("request body must be JSON"); + assert!( + body.get("associated_data") + .is_some_and(|aad| !aad.as_str().unwrap_or("").is_empty()), + "request {index} must carry the associated data: {body}" + ); + } + } + + /// The converged case of the context-bound route: an envelope already on + /// Vault's latest version is answered from the key record alone — no + /// decrypt is issued, no plaintext exists, and the input comes back byte + /// for byte so a sweep re-run performs no writes. + #[tokio::test] + async fn wired_transit_rewrap_of_a_current_bound_envelope_never_decrypts() { let context = HashMap::from([("bucket".to_string(), "photos/cat.jpg".to_string())]); let metadata = TransitKeyMetadata::from_create_request(&CreateKeyRequest::default()); let (vault, client) = scripted_client(vec![ ScriptedResponse::ok(metadata_read_data(&metadata)), - ScriptedResponse::ok(serde_json::json!({ "ciphertext": "vault:v1:scripted" })), - // Only the read-only accessor below is allowed to consume this. + ScriptedResponse::ok(serde_json::json!({ "ciphertext": "vault:v2:scripted" })), + // rewrap: only the latest-version read. ScriptedResponse::ok(transit_key_read_data_up_to("wired-key", 2)), ]) .await; @@ -3212,42 +3313,24 @@ mod tests { .await .expect("generate_data_key must produce an envelope"); - let error = client + let response = client .rewrap_data_key(&RewrapDataKeyRequest { - ciphertext: data_key.ciphertext.clone(), - encryption_context: context.clone(), - }) - .await - .expect_err("an AAD-bound envelope must not be rewrapped by decrypting it here"); - assert!( - matches!(&error, KmsError::RewrapWouldExposePlaintext { key_id, .. } if key_id == "wired-key"), - "got {error:?}" - ); - - // The stuck envelope must still be countable, or an inventory could not - // report how much of the key version is unmigratable. - let described = client - .describe_data_key_wrapping(&DescribeDataKeyWrappingRequest { ciphertext: data_key.ciphertext.clone(), encryption_context: context, }) .await - .expect("describing the wrapping must work even when rewrapping it cannot"); - assert_eq!(described.key_version, Some(1)); - assert_eq!(described.current_key_version, Some(2)); - assert!(!described.is_current); + .expect("an already-current bound envelope must be a no-op"); + assert!(!response.rewrapped); + assert_eq!(response.ciphertext, data_key.ciphertext, "a no-op must hand the input back unchanged"); + assert_eq!(response.source_key_version, Some(2)); + assert_eq!(response.destination_key_version, Some(2)); let requests = vault.requests(); - assert!( - !requests.iter().any(|request| request.contains("/transit/rewrap/")), - "the refusal must happen before any rewrap call: {requests:?}" - ); assert!( !requests.iter().any(|request| request.contains("/transit/decrypt/")), - "and above all before any decrypt: {requests:?}" + "the no-op must not materialize any plaintext: {requests:?}" ); } - /// The current version comes from Vault's own key record rather than from /// the RustFS metadata counter, which only advances on rotations this /// process performed. diff --git a/crates/kms/src/service.rs b/crates/kms/src/service.rs index 77fbdea3d..7efe65b94 100644 --- a/crates/kms/src/service.rs +++ b/crates/kms/src/service.rs @@ -305,6 +305,41 @@ impl ObjectEncryptionService { self.kms_manager.backend_capabilities() } + /// Re-wrap an object's encrypted data key onto its master key's current + /// version, without the plaintext data key ever reaching the caller. + /// + /// Pure passthrough: the backend owns the format and the no-op decision + /// ([`RewrapDataKeyResponse::rewrapped`] false means nothing to persist). + /// The context must be the object's own — the backend refuses an envelope + /// whose recorded context the caller cannot reproduce. + pub async fn rewrap_data_key( + &self, + encrypted_key: &[u8], + context: &ObjectEncryptionContext, + ) -> Result { + self.kms_manager + .rewrap_data_key(crate::types::RewrapDataKeyRequest { + ciphertext: encrypted_key.to_vec(), + encryption_context: request_encryption_context(context), + }) + .await + } + + /// Report which master key version wraps an object's encrypted data key, + /// and whether a rewrap would change anything. + pub async fn describe_data_key_wrapping( + &self, + encrypted_key: &[u8], + context: &ObjectEncryptionContext, + ) -> Result { + self.kms_manager + .describe_data_key_wrapping(crate::types::DescribeDataKeyWrappingRequest { + ciphertext: encrypted_key.to_vec(), + encryption_context: request_encryption_context(context), + }) + .await + } + /// Create a data encryption key for object encryption /// /// # Arguments diff --git a/rustfs/src/storage/sse.rs b/rustfs/src/storage/sse.rs index 772238706..6da76d0b2 100644 --- a/rustfs/src/storage/sse.rs +++ b/rustfs/src/storage/sse.rs @@ -2814,6 +2814,130 @@ pub struct SsecParams { pub key_md5: SSECustomerKeyMD5, } +// ============================================================================ +// Object-level DEK rewrap adapter +// ============================================================================ + +/// Outcome of a single object's DEK rewrap attempt. +// Consumed by the bulk rekey sweep in the follow-up PR; tests exercise it now. +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum ObjectDekRewrapOutcome { + /// The object carries no KMS-wrapped RustFS data-key envelope this build + /// can rewrap: plaintext, SSE-C, or a MinIO-sealed data key. + NotApplicable, + /// The envelope is already on the current master key version and format; + /// the caller must persist nothing, so a sweep re-run converges. + AlreadyCurrent, + /// The envelope was rewrapped. `metadata` holds the overrides to merge + /// into the object's user-defined metadata — every stored copy of the old + /// envelope, replaced — via `ObjectLayer::put_object_metadata`. + Rewrapped { metadata: HashMap }, +} + +/// Rewrap the KMS-wrapped data key in an object's stored metadata onto its +/// master key's current version (and current envelope format), without +/// touching the object's data or its plaintext DEK. +/// +/// Mirrors the managed decrypt path byte for byte where it matters: the +/// envelope is located through the same normalized-header resolution, and the +/// encryption context is rebuilt with the same helpers, so an envelope the +/// read path can open is exactly an envelope this can rewrap. The KMS backend +/// owns the format and the no-op decision. +/// +/// The returned overrides replace every stored copy of the old envelope +/// (RustFS's internal header and the MinIO-compatible slots that the writer +/// fills with the same bytes). A copy left behind would win a read-path +/// fallback and resurrect the old wrapping, so finding no replaceable copy is +/// an error, never a silent success. +#[allow(dead_code)] // Consumed by the bulk rekey sweep in the follow-up PR; tests exercise it now. +pub(crate) async fn rewrap_object_encryption_metadata( + bucket: &str, + key: &str, + metadata: &HashMap, +) -> Result { + if !contains_managed_encryption_metadata(metadata) { + return Ok(ObjectDekRewrapOutcome::NotApplicable); + } + + let encryption_type = match metadata.get("x-amz-server-side-encryption").map(String::as_str) { + Some(ServerSideEncryption::AWS_KMS) => SSEType::SseKms, + Some(_) => SSEType::SseS3, + // MinIO-written objects synthesize the public header on read; their + // sealed data keys are not RustFS envelopes and fall out below. + None => SSEType::SseS3, + }; + + let normalized_metadata = normalize_managed_metadata(metadata, Some(recode_minio_kms_context)); + let Some(envelope_b64) = normalized_metadata + .get(INTERNAL_ENCRYPTION_KEY_HEADER) + .or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)) + else { + return Ok(ObjectDekRewrapOutcome::NotApplicable); + }; + let encrypted_data_key = BASE64_STANDARD + .decode(envelope_b64) + .map_err(|e| ApiError::from(StorageError::other(format!("Failed to decode encrypted key: {e}"))))?; + // Only RustFS envelopes are rewrappable here; MinIO's builtin-KMS + // ciphertext is opaque bytes owned by a different root of trust. + if !is_data_key_envelope(&encrypted_data_key) { + return Ok(ObjectDekRewrapOutcome::NotApplicable); + } + + let kms_context = if matches!(encryption_type, SSEType::SseKms) { + decode_minio_kms_context(metadata)? + } else { + None + }; + let object_context = build_object_encryption_context(bucket, key, kms_context.as_ref()); + + // The same provider selection as the managed decrypt path: the + // test-injected provider when registered, the KMS-backed one otherwise. + let provider: Arc = + if let Some(cached) = GLOBAL_KMS_DEK_PROVIDER.read().ok().and_then(|guard| guard.as_ref().cloned()) { + cached + } else { + Arc::new(KmsSseDekProvider::new().await?) + }; + let response = provider.rewrap_sse_dek(&encrypted_data_key, &object_context).await?; + if !response.rewrapped { + return Ok(ObjectDekRewrapOutcome::AlreadyCurrent); + } + + // Replace every stored copy of the old envelope, keyed by value and + // matched case-insensitively: metadata key casing drifts through the + // storage layer, and an override inserted under a differently-cased name + // would sit beside the old copy instead of replacing it. The MinIO + // sealed-key slots can instead hold a sealed *object* key (rio-v2 writer); + // those bytes differ from the envelope and are untouched — the data key + // they seal is unchanged by a rewrap. + const REWRAP_ENVELOPE_HEADERS: [&str; 4] = [ + INTERNAL_ENCRYPTION_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER, + ]; + let old_envelope_b64 = envelope_b64.clone(); + let new_envelope_b64 = BASE64_STANDARD.encode(&response.ciphertext); + let mut overrides = HashMap::new(); + for (stored_name, stored_value) in metadata { + let is_envelope_slot = REWRAP_ENVELOPE_HEADERS + .iter() + .any(|header| stored_name.eq_ignore_ascii_case(header)); + if is_envelope_slot && *stored_value == old_envelope_b64 { + overrides.insert(stored_name.clone(), new_envelope_b64.clone()); + } + } + if overrides.is_empty() { + return Err(ApiError::from(StorageError::other( + "rewrapped a data-key envelope but found no stored metadata copy to replace; refusing a write that would \ + leave the old wrapping live", + ))); + } + + Ok(ObjectDekRewrapOutcome::Rewrapped { metadata: overrides }) +} + // ============================================================================ // SSE DEK Provider Abstraction (Factory Pattern) // ============================================================================ @@ -2834,6 +2958,22 @@ pub trait SseDekProvider: Send + Sync { context: &ObjectEncryptionContext, ) -> Result<[u8; 32], ApiError>; + /// Re-wrap a KMS-wrapped DEK envelope onto its master key's current + /// version without exposing the plaintext DEK to the caller. + /// + /// Defaults to refusing: only the KMS-backed provider can rewrap, and a + /// provider that cannot must say so rather than hand back the input as if + /// it had been re-protected. + async fn rewrap_sse_dek( + &self, + _encrypted_dek: &[u8], + _context: &ObjectEncryptionContext, + ) -> Result { + Err(ApiError::from(StorageError::other( + "This DEK provider cannot rewrap KMS-wrapped data keys", + ))) + } + /// Decrypt a DEK from positively identified legacy managed metadata. #[cfg(feature = "rio-v2")] async fn decrypt_legacy_sse_dek( @@ -2947,6 +3087,21 @@ impl SseDekProvider for KmsSseDekProvider { Ok((data_key, encrypted_data_key)) } + async fn rewrap_sse_dek( + &self, + encrypted_dek: &[u8], + context: &ObjectEncryptionContext, + ) -> Result { + let service = self + .current_service() + .await + .ok_or_else(|| ApiError::from(StorageError::other(KmsUnavailableError)))?; + service + .rewrap_data_key(encrypted_dek, context) + .await + .map_err(kms_operation_error) + } + async fn decrypt_sse_dek( &self, encrypted_dek: &[u8], @@ -3781,18 +3936,20 @@ mod tests { EncryptionResolutionErrorKind, INTERNAL_ENCRYPTION_ALGORITHM_HEADER, INTERNAL_ENCRYPTION_IV_HEADER, INTERNAL_ENCRYPTION_KEY_HEADER, INTERNAL_ENCRYPTION_KEY_ID_HEADER, KmsAction, KmsKeyAuthorizer, KmsSseDekProvider, KmsUnavailableError, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER, MINIO_INTERNAL_ENCRYPTION_IV_HEADER, - MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER, - MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, - MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, - ObjectEncryptionResolver, PrepareEncryptionRequest, ReadEncryptionMode, ReadEncryptionRequest, SSEC_ORIGINAL_SIZE_HEADER, - SSEType, SseDekProvider, SseKmsPrincipal, SseObjectEncryptionResolver, SsecParams, StorageError, TestSseDekProvider, + MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, ObjectDekRewrapOutcome, ObjectEncryptionResolver, + PrepareEncryptionRequest, ReadEncryptionMode, ReadEncryptionRequest, SSEC_ORIGINAL_SIZE_HEADER, SSEType, SseDekProvider, + SseKmsPrincipal, SseObjectEncryptionResolver, SsecParams, StorageError, TestSseDekProvider, apply_managed_decryption_material, apply_managed_encryption_material, authorize_sse_kms_object_read, - classify_sse_read_response, encryption_material_to_metadata, extract_server_side_encryption_from_headers, - extract_ssec_params_from_headers, extract_ssekms_context_from_headers, generate_ssec_nonce, is_managed_sse, - kms_operation_error, map_get_object_reader_error, mark_encrypted_multipart_metadata, md5_base64, - normalize_managed_metadata, recode_minio_kms_context, reset_sse_dek_provider, resolve_effective_kms_key_id, - sse_decryption, sse_encryption, sse_prepare_encryption, strip_managed_encryption_metadata, validate_sse_headers_for_read, - validate_sse_headers_for_write, validate_ssec_for_read, validate_ssec_params, verify_ssec_key_match, + build_kms_request_context, classify_sse_read_response, encode_minio_kms_context, encryption_material_to_metadata, + extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers, + generate_ssec_nonce, is_managed_sse, kms_operation_error, map_get_object_reader_error, mark_encrypted_multipart_metadata, + md5_base64, normalize_managed_metadata, recode_minio_kms_context, reset_sse_dek_provider, resolve_effective_kms_key_id, + rewrap_object_encryption_metadata, sse_decryption, sse_encryption, sse_prepare_encryption, + strip_managed_encryption_metadata, validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read, + validate_ssec_params, verify_ssec_key_match, }; #[cfg(feature = "rio-v2")] use super::{ @@ -4847,6 +5004,251 @@ mod tests { assert_eq!(kms_key_id.as_deref(), Some("bucket-default")); } + /// One recorded rewrap call: (envelope bytes, bucket, object key, context). + type RecordedRewrapCall = (Vec, String, String, HashMap); + + /// Test double for the rewrap seam: records what it was asked to rewrap + /// and answers with a canned response. + struct RewrapProbeProvider { + rewrapped: bool, + new_ciphertext: Vec, + calls: std::sync::Mutex>, + } + + #[async_trait] + impl SseDekProvider for RewrapProbeProvider { + async fn generate_sse_dek( + &self, + _context: &ObjectEncryptionContext, + _kms_key_id: &str, + ) -> Result<(DataKey, Vec), ApiError> { + unreachable!("rewrap tests never generate keys") + } + + async fn decrypt_sse_dek( + &self, + _encrypted_dek: &[u8], + _kms_key_id: &str, + _context: &ObjectEncryptionContext, + ) -> Result<[u8; 32], ApiError> { + unreachable!("rewrap tests never decrypt keys") + } + + async fn rewrap_sse_dek( + &self, + encrypted_dek: &[u8], + context: &ObjectEncryptionContext, + ) -> Result { + self.calls.lock().expect("probe lock").push(( + encrypted_dek.to_vec(), + context.bucket.clone(), + context.object_key.clone(), + context.encryption_context.clone(), + )); + Ok(rustfs_kms::types::RewrapDataKeyResponse { + ciphertext: if self.rewrapped { + self.new_ciphertext.clone() + } else { + encrypted_dek.to_vec() + }, + key_id: "probe-key".to_string(), + source_key_version: Some(1), + destination_key_version: Some(2), + rewrapped: self.rewrapped, + }) + } + } + + /// A minimal but well-formed data-key envelope, the shape + /// `is_data_key_envelope` recognizes. + fn probe_envelope_json() -> Vec { + serde_json::to_vec(&serde_json::json!({ + "key_id": "dek-id", + "master_key_id": "master-key", + "key_spec": "AES_256", + "encrypted_key": [1, 2, 3, 4], + "nonce": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + "encryption_context": {"bucket": "bucket/dir/object"}, + "created_at": "2024-01-01T00:00:00+00:00" + })) + .expect("serialize probe envelope") + } + + /// The adapter must replace every stored copy of the old envelope — under + /// whatever key casing the storage layer preserved — and leave slots + /// holding different bytes (a sealed object key) untouched. Its context + /// must be rebuilt exactly as the managed decrypt path rebuilds it. + #[tokio::test] + async fn rewrap_object_metadata_replaces_every_stored_envelope_copy() { + let _guard = lock_sse_test_state().await; + reset_sse_dek_provider(); + + let envelope = probe_envelope_json(); + let envelope_b64 = BASE64_STANDARD.encode(&envelope); + let client_context = HashMap::from([("tenant".to_string(), "alpha".to_string())]); + let metadata = HashMap::from([ + ("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()), + // Mixed casing on the internal header, exactly as the storage layer + // can hand it back. + ("X-Rustfs-Encryption-Key".to_string(), envelope_b64.clone()), + (MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(), envelope_b64.clone()), + // A sealed object key: different bytes, must not be rewritten. + ( + MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER.to_string(), + BASE64_STANDARD.encode(b"sealed-object-key-not-the-envelope"), + ), + ( + MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER.to_string(), + encode_minio_kms_context(&client_context).expect("encode context"), + ), + ]); + + let new_ciphertext = b"rewrapped-envelope-bytes".to_vec(); + let provider = Arc::new(RewrapProbeProvider { + rewrapped: true, + new_ciphertext: new_ciphertext.clone(), + calls: std::sync::Mutex::new(Vec::new()), + }); + super::set_sse_dek_provider_for_test(provider.clone()); + + let outcome = rewrap_object_encryption_metadata("bucket", "dir/object", &metadata) + .await + .expect("rewrap must succeed"); + let ObjectDekRewrapOutcome::Rewrapped { metadata: overrides } = outcome else { + panic!("expected a rewrapped outcome, got {outcome:?}"); + }; + + let new_b64 = BASE64_STANDARD.encode(&new_ciphertext); + assert_eq!( + overrides, + HashMap::from([ + ("X-Rustfs-Encryption-Key".to_string(), new_b64.clone()), + (MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(), new_b64), + ]), + "every envelope copy must be replaced under its stored name and nothing else touched" + ); + + let calls = provider.calls.lock().expect("probe lock"); + let (sent_envelope, bucket, object_key, sent_context) = calls.first().expect("the provider must be called"); + assert_eq!(*sent_envelope, envelope, "the decoded stored envelope must reach the provider"); + assert_eq!(bucket, "bucket"); + assert_eq!(object_key, "dir/object"); + assert_eq!( + *sent_context, + build_kms_request_context("bucket", "dir/object", Some(&client_context)), + "the context must be rebuilt exactly as the managed decrypt path rebuilds it" + ); + + reset_sse_dek_provider(); + } + + /// `rewrapped: false` from the backend means nothing to persist; the + /// adapter must answer AlreadyCurrent so a sweep re-run converges. + #[tokio::test] + async fn rewrap_object_metadata_converges_when_already_current() { + let _guard = lock_sse_test_state().await; + reset_sse_dek_provider(); + + let envelope_b64 = BASE64_STANDARD.encode(probe_envelope_json()); + let metadata = HashMap::from([ + ("x-amz-server-side-encryption".to_string(), "AES256".to_string()), + (INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), envelope_b64), + ]); + let provider = Arc::new(RewrapProbeProvider { + rewrapped: false, + new_ciphertext: Vec::new(), + calls: std::sync::Mutex::new(Vec::new()), + }); + super::set_sse_dek_provider_for_test(provider.clone()); + + let outcome = rewrap_object_encryption_metadata("bucket", "object", &metadata) + .await + .expect("rewrap must succeed"); + assert!(matches!(outcome, ObjectDekRewrapOutcome::AlreadyCurrent), "got {outcome:?}"); + assert_eq!(provider.calls.lock().expect("probe lock").len(), 1); + + reset_sse_dek_provider(); + } + + /// The KMS-backed provider's rewrap threads through the encryption + /// service to the backend. The Local test backend has no rewrap support, + /// so the capability refusal coming back proves the whole chain is wired — + /// a stub that silently succeeded would return Ok here. + #[tokio::test] + async fn kms_provider_rewrap_reaches_the_backend_through_the_service() { + let _guard = lock_sse_test_state().await; + reset_sse_dek_provider(); + + let manager = configure_test_global_local_kms().await; + let provider = KmsSseDekProvider::new_with_service_manager(manager) + .await + .expect("kms provider should initialize from the configured test manager"); + + let context = super::build_object_encryption_context("bucket", "object", None); + let error = provider + .rewrap_sse_dek(b"{}", &context) + .await + .expect_err("the Local backend must refuse rewrap through the full chain"); + assert!( + error.to_string().contains("rewrap") || format!("{:?}", error.source).contains("rewrap_data_key"), + "the refusal must come from the backend capability gate: {error:?}" + ); + + reset_sse_dek_provider(); + } + + /// Objects without a rewrappable envelope — plaintext, SSE-C, or a + /// MinIO-sealed opaque data key — are reported NotApplicable without any + /// provider call. + #[tokio::test] + async fn rewrap_object_metadata_skips_objects_without_a_rustfs_envelope() { + let _guard = lock_sse_test_state().await; + reset_sse_dek_provider(); + + let provider = Arc::new(RewrapProbeProvider { + rewrapped: true, + new_ciphertext: b"never-used".to_vec(), + calls: std::sync::Mutex::new(Vec::new()), + }); + super::set_sse_dek_provider_for_test(provider.clone()); + + // Plaintext object. + let outcome = rewrap_object_encryption_metadata("bucket", "object", &HashMap::new()) + .await + .expect("plaintext objects must not error"); + assert!(matches!(outcome, ObjectDekRewrapOutcome::NotApplicable), "got {outcome:?}"); + + // SSE-C object: customer-key encryption never reaches KMS. + let ssec = HashMap::from([ + ("X-Amz-Server-Side-Encryption-Customer-Algorithm".to_string(), "AES256".to_string()), + (INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode([1u8; 12])), + ]); + let outcome = rewrap_object_encryption_metadata("bucket", "object", &ssec) + .await + .expect("SSE-C objects must not error"); + assert!(matches!(outcome, ObjectDekRewrapOutcome::NotApplicable), "got {outcome:?}"); + + // MinIO builtin-KMS ciphertext: opaque bytes, not a RustFS envelope. + let minio = HashMap::from([ + ("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()), + ( + MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(), + BASE64_STANDARD.encode(b"opaque-minio-sealed-bytes"), + ), + ]); + let outcome = rewrap_object_encryption_metadata("bucket", "object", &minio) + .await + .expect("MinIO-sealed objects must not error"); + assert!(matches!(outcome, ObjectDekRewrapOutcome::NotApplicable), "got {outcome:?}"); + + assert!( + provider.calls.lock().expect("probe lock").is_empty(), + "no provider call may happen for non-rewrappable objects" + ); + + reset_sse_dek_provider(); + } + #[tokio::test] async fn test_sse_encryption_persists_aws_kms_header_for_kms_objects() { let metadata = encryption_material_to_metadata(&EncryptionMaterial {