feat(kms): object-level DEK rewrap adapter and Transit context-bound rewrap (#6644)

* feat(kms): object-level DEK rewrap adapter and Transit context-bound rewrap

* fix(kms): zeroize rewrap plaintext on cancellation

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
This commit is contained in:
唐小鸭
2026-08-26 15:18:12 +08:00
committed by GitHub
parent 08d16d067d
commit 7ecb44ea60
3 changed files with 611 additions and 91 deletions
+163 -80
View File
@@ -54,6 +54,7 @@ use vaultrs::{
kv2, kv2,
transit::{data, key}, transit::{data, key},
}; };
use zeroize::Zeroizing;
/// Attempt budget for metadata read-modify-write cycles: every check-and-set /// 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 /// 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 /// Re-wrap an existing envelope onto the transit key's latest version.
/// Vault's native rewrap endpoint.
/// ///
/// The data key is never decrypted into this process: Vault re-encrypts the /// Envelopes without an encryption context go through Vault's native
/// ciphertext internally and hands back only the new ciphertext, so no /// rewrap endpoint: Vault re-encrypts the ciphertext internally, so no
/// `transit/decrypt` is issued and no plaintext data key exists here to /// plaintext data key exists in this process at all.
/// leak, log or persist.
/// ///
/// # Envelopes bound to an encryption context cannot be rewrapped /// Envelopes that bind their context as AEAD associated data
/// /// ([`Self::transit_encrypt`]) cannot use that endpoint — Vault's
/// This backend binds the encryption context into the wrapping as AEAD /// `transit/rewrap` accepts no `associated_data` parameter — so they move
/// associated data ([`Self::transit_encrypt`]), and Vault's `transit/rewrap` /// via `transit/decrypt` followed by `transit/encrypt`, both carrying the
/// endpoint accepts no `associated_data` parameter — the only way to move /// context. On that route the plaintext data key exists in this process
/// such a ciphertext onto a newer version is `transit/decrypt` followed by /// for exactly the length of the re-encrypt call, is zeroized immediately,
/// `transit/encrypt`, which materializes the plaintext data key inside /// and is never persisted, logged or returned — within the trait contract,
/// RustFS. That trade is refused here rather than made silently: it would /// and the same in-memory exposure every decrypt of the envelope already
/// hand back a valid envelope while dropping the very property that makes a /// has. The no-op case is answered against Vault's latest key version
/// backend-side rewrap worth having. Every object-level envelope carries a /// before anything is decrypted, so a converged sweep re-run materializes
/// bucket/object context, so in practice this rejects them all until the /// nothing.
/// context binding or the endpoint changes.
/// ///
/// The context guard still runs first, so a caller that cannot reproduce the /// 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 /// envelope's context is told that rather than anything about an envelope it
/// limitation of an envelope it has no claim on. /// has no claim on.
pub(crate) async fn rewrap_data_key(&self, request: &RewrapDataKeyRequest) -> Result<RewrapDataKeyResponse> { pub(crate) async fn rewrap_data_key(&self, request: &RewrapDataKeyRequest) -> Result<RewrapDataKeyResponse> {
let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext) let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext)
.map_err(|e| KmsError::cryptographic_error("parse", format!("Failed to parse data key envelope: {e}")))?; .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) self.ensure_key_state_allows(&envelope.master_key_id, StateGatedOperation::Encrypt)
.await?; .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) let source_ciphertext = std::str::from_utf8(&envelope.encrypted_key)
.map_err(|e| KmsError::cryptographic_error("utf8", format!("Invalid Transit ciphertext: {e}")))?; .map_err(|e| KmsError::cryptographic_error("utf8", format!("Invalid Transit ciphertext: {e}")))?;
let source_key_version = transit_ciphertext_version(source_ciphertext); let source_key_version = transit_ciphertext_version(source_ciphertext);
let rewrapped_ciphertext = match self.transit_rewrap(&envelope.master_key_id, source_ciphertext).await { let rewrapped_ciphertext = if envelope.encryption_context.is_empty() {
Ok(ciphertext) => ciphertext, match self.transit_rewrap(&envelope.master_key_id, source_ciphertext).await {
Err(error) => { Ok(ciphertext) => ciphertext,
self.invalidate_metadata_on_state_error(&envelope.master_key_id, &error).await; Err(error) => {
return 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); let destination_key_version = transit_ciphertext_version(&rewrapped_ciphertext);
@@ -1494,16 +1532,14 @@ impl VaultTransitKmsBackend {
let vault_config = match &config.backend_config { let vault_config = match &config.backend_config {
crate::config::BackendConfig::VaultTransit(vault_config) => (**vault_config).clone(), crate::config::BackendConfig::VaultTransit(vault_config) => (**vault_config).clone(),
crate::config::BackendConfig::VaultKv2(vault_config) => VaultTransitConfig { // Deriving a Transit configuration from a KV2 one used to be
address: vault_config.address.clone(), // accepted here, silently reinterpreting KV2's deprecated
auth_method: vault_config.auth_method.clone(), // `mount_path` as the Transit engine mount and its key storage as
namespace: vault_config.namespace.clone(), // the metadata location — a mount mismatch that surfaces as
mount_path: vault_config.mount_path.clone(), // confusing Vault 404s long after configuration time. A KV2
metadata_kv_mount: vault_config.kv_mount.clone(), // configuration reaching this constructor is a wiring bug; name it.
metadata_key_prefix: vault_config.key_path_prefix.clone(), crate::config::BackendConfig::VaultKv2(_)
tls: vault_config.tls.clone(), | crate::config::BackendConfig::Local(_)
},
crate::config::BackendConfig::Local(_)
| crate::config::BackendConfig::Static(_) | crate::config::BackendConfig::Static(_)
| crate::config::BackendConfig::Aws(_) => { | crate::config::BackendConfig::Aws(_) => {
return Err(KmsError::configuration_error("Expected Vault Transit backend configuration")); return Err(KmsError::configuration_error("Expected Vault Transit backend configuration"));
@@ -1769,11 +1805,10 @@ impl KmsBackend for VaultTransitKmsBackend {
fn capabilities(&self) -> BackendCapabilities { fn capabilities(&self) -> BackendCapabilities {
// Vault Transit natively supports version-retaining rotation, keeps // Vault Transit natively supports version-retaining rotation, keeps
// prior versions addressable for decryption, and allows physical // prior versions addressable for decryption, and allows physical
// deletion once a key is pending deletion. Rewrap is advertised because // deletion once a key is pending deletion. Rewrap covers every envelope:
// the endpoint exists and works; envelopes whose encryption context is // context-free ones via Vault's native rewrap endpoint, context-bound
// bound as associated data are still refused per envelope (see // ones via decrypt + re-encrypt with the associated data carried on
// `VaultTransitKmsClient::rewrap_data_key`), which is a property of the // both calls (see `VaultTransitKmsClient::rewrap_data_key`).
// envelope rather than of the backend.
BackendCapabilities::minimal() BackendCapabilities::minimal()
.with_rotate(true) .with_rotate(true)
.with_enable_disable(true) .with_enable_disable(true)
@@ -3191,18 +3226,84 @@ mod tests {
} }
/// Vault's `transit/rewrap` endpoint takes no `associated_data` parameter, /// Vault's `transit/rewrap` endpoint takes no `associated_data` parameter,
/// and this backend binds the encryption context as exactly that. The only /// and this backend binds the encryption context as exactly that — so a
/// remaining route would decrypt the data key inside RustFS, so the request /// context-bound envelope moves via decrypt + re-encrypt, with the
/// is refused rather than silently downgraded — and refused without any call /// associated data carried on both calls. The no-op case is answered from
/// to Vault at all. /// the key record alone, so a converged sweep never materializes a
/// plaintext data key.
#[tokio::test] #[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 context = HashMap::from([("bucket".to_string(), "photos/cat.jpg".to_string())]);
let metadata = TransitKeyMetadata::from_create_request(&CreateKeyRequest::default()); let metadata = TransitKeyMetadata::from_create_request(&CreateKeyRequest::default());
let (vault, client) = scripted_client(vec![ let (vault, client) = scripted_client(vec![
ScriptedResponse::ok(metadata_read_data(&metadata)), ScriptedResponse::ok(metadata_read_data(&metadata)),
ScriptedResponse::ok(serde_json::json!({ "ciphertext": "vault:v1:scripted" })), ScriptedResponse::ok(serde_json::json!({ "ciphertext": "vault:v2:scripted" })),
// Only the read-only accessor below is allowed to consume this. // rewrap: only the latest-version read.
ScriptedResponse::ok(transit_key_read_data_up_to("wired-key", 2)), ScriptedResponse::ok(transit_key_read_data_up_to("wired-key", 2)),
]) ])
.await; .await;
@@ -3212,42 +3313,24 @@ mod tests {
.await .await
.expect("generate_data_key must produce an envelope"); .expect("generate_data_key must produce an envelope");
let error = client let response = client
.rewrap_data_key(&RewrapDataKeyRequest { .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(), ciphertext: data_key.ciphertext.clone(),
encryption_context: context, encryption_context: context,
}) })
.await .await
.expect("describing the wrapping must work even when rewrapping it cannot"); .expect("an already-current bound envelope must be a no-op");
assert_eq!(described.key_version, Some(1)); assert!(!response.rewrapped);
assert_eq!(described.current_key_version, Some(2)); assert_eq!(response.ciphertext, data_key.ciphertext, "a no-op must hand the input back unchanged");
assert!(!described.is_current); assert_eq!(response.source_key_version, Some(2));
assert_eq!(response.destination_key_version, Some(2));
let requests = vault.requests(); let requests = vault.requests();
assert!(
!requests.iter().any(|request| request.contains("/transit/rewrap/")),
"the refusal must happen before any rewrap call: {requests:?}"
);
assert!( assert!(
!requests.iter().any(|request| request.contains("/transit/decrypt/")), !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 current version comes from Vault's own key record rather than from
/// the RustFS metadata counter, which only advances on rotations this /// the RustFS metadata counter, which only advances on rotations this
/// process performed. /// process performed.
+35
View File
@@ -305,6 +305,41 @@ impl ObjectEncryptionService {
self.kms_manager.backend_capabilities() 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<crate::types::RewrapDataKeyResponse> {
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<crate::types::DescribeDataKeyWrappingResponse> {
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 /// Create a data encryption key for object encryption
/// ///
/// # Arguments /// # Arguments
+413 -11
View File
@@ -2814,6 +2814,130 @@ pub struct SsecParams {
pub key_md5: SSECustomerKeyMD5, 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<String, String> },
}
/// 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<String, String>,
) -> Result<ObjectDekRewrapOutcome, ApiError> {
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<dyn SseDekProvider> =
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) // SSE DEK Provider Abstraction (Factory Pattern)
// ============================================================================ // ============================================================================
@@ -2834,6 +2958,22 @@ pub trait SseDekProvider: Send + Sync {
context: &ObjectEncryptionContext, context: &ObjectEncryptionContext,
) -> Result<[u8; 32], ApiError>; ) -> 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<rustfs_kms::types::RewrapDataKeyResponse, ApiError> {
Err(ApiError::from(StorageError::other(
"This DEK provider cannot rewrap KMS-wrapped data keys",
)))
}
/// Decrypt a DEK from positively identified legacy managed metadata. /// Decrypt a DEK from positively identified legacy managed metadata.
#[cfg(feature = "rio-v2")] #[cfg(feature = "rio-v2")]
async fn decrypt_legacy_sse_dek( async fn decrypt_legacy_sse_dek(
@@ -2947,6 +3087,21 @@ impl SseDekProvider for KmsSseDekProvider {
Ok((data_key, encrypted_data_key)) Ok((data_key, encrypted_data_key))
} }
async fn rewrap_sse_dek(
&self,
encrypted_dek: &[u8],
context: &ObjectEncryptionContext,
) -> Result<rustfs_kms::types::RewrapDataKeyResponse, ApiError> {
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( async fn decrypt_sse_dek(
&self, &self,
encrypted_dek: &[u8], encrypted_dek: &[u8],
@@ -3781,18 +3936,20 @@ mod tests {
EncryptionResolutionErrorKind, INTERNAL_ENCRYPTION_ALGORITHM_HEADER, INTERNAL_ENCRYPTION_IV_HEADER, EncryptionResolutionErrorKind, INTERNAL_ENCRYPTION_ALGORITHM_HEADER, INTERNAL_ENCRYPTION_IV_HEADER,
INTERNAL_ENCRYPTION_KEY_HEADER, INTERNAL_ENCRYPTION_KEY_ID_HEADER, KmsAction, KmsKeyAuthorizer, KmsSseDekProvider, INTERNAL_ENCRYPTION_KEY_HEADER, INTERNAL_ENCRYPTION_KEY_ID_HEADER, KmsAction, KmsKeyAuthorizer, KmsSseDekProvider,
KmsUnavailableError, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER, MINIO_INTERNAL_ENCRYPTION_IV_HEADER, 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_CONTEXT_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER,
MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER,
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER,
ObjectEncryptionResolver, PrepareEncryptionRequest, ReadEncryptionMode, ReadEncryptionRequest, SSEC_ORIGINAL_SIZE_HEADER, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, ObjectDekRewrapOutcome, ObjectEncryptionResolver,
SSEType, SseDekProvider, SseKmsPrincipal, SseObjectEncryptionResolver, SsecParams, StorageError, TestSseDekProvider, 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, 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, build_kms_request_context, classify_sse_read_response, encode_minio_kms_context, encryption_material_to_metadata,
extract_ssec_params_from_headers, extract_ssekms_context_from_headers, generate_ssec_nonce, is_managed_sse, extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers,
kms_operation_error, map_get_object_reader_error, mark_encrypted_multipart_metadata, md5_base64, generate_ssec_nonce, is_managed_sse, kms_operation_error, map_get_object_reader_error, mark_encrypted_multipart_metadata,
normalize_managed_metadata, recode_minio_kms_context, reset_sse_dek_provider, resolve_effective_kms_key_id, 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, rewrap_object_encryption_metadata, sse_decryption, sse_encryption, sse_prepare_encryption,
validate_sse_headers_for_write, validate_ssec_for_read, validate_ssec_params, verify_ssec_key_match, 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")] #[cfg(feature = "rio-v2")]
use super::{ use super::{
@@ -4847,6 +5004,251 @@ mod tests {
assert_eq!(kms_key_id.as_deref(), Some("bucket-default")); assert_eq!(kms_key_id.as_deref(), Some("bucket-default"));
} }
/// One recorded rewrap call: (envelope bytes, bucket, object key, context).
type RecordedRewrapCall = (Vec<u8>, String, String, HashMap<String, String>);
/// 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<u8>,
calls: std::sync::Mutex<Vec<RecordedRewrapCall>>,
}
#[async_trait]
impl SseDekProvider for RewrapProbeProvider {
async fn generate_sse_dek(
&self,
_context: &ObjectEncryptionContext,
_kms_key_id: &str,
) -> Result<(DataKey, Vec<u8>), 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<rustfs_kms::types::RewrapDataKeyResponse, ApiError> {
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<u8> {
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] #[tokio::test]
async fn test_sse_encryption_persists_aws_kms_header_for_kms_objects() { async fn test_sse_encryption_persists_aws_kms_header_for_kms_objects() {
let metadata = encryption_material_to_metadata(&EncryptionMaterial { let metadata = encryption_material_to_metadata(&EncryptionMaterial {