mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-16 18:08:21 +00:00
feat(replication): SSE-C ciphertext passthrough replication (#5898)
Complete the encrypted-object replication series (backlog#1783, PR-C of 3, after #5872 and #5885): SSE-C objects replicate as ciphertext passthrough — the source holds no customer key, so the stored bytes and their encryption metadata travel verbatim and the replica decrypts only with the original customer key, single-part and multipart. - Sender: SSE-C objects read raw (raw_data_movement_read), transfer at ciphertext size, and range multipart parts over stored part sizes. - Receiver: authorized replication PUTs restore the stored SSE-C keys from the transport headers (exact lowercase forms - the read-path check is case-sensitive), set ObjectOptions.preserve_ciphertext, and skip compression, bucket-default SSE, and sse_encryption behind one restore-derived gate. Multipart uses an internal session marker to store parts verbatim and strips it on complete. - Convergence: the replication HEAD sends x-rustfs-source-replication-check; the target authorizes it as ReplicateObjectAction and skips SSE-C read validation for that request only, so keyless convergence HEADs see etag/size/mtime instead of 400 and SSE-C replicas stop re-driving forever. - e2e: SSE-C contract flips to a key-gated readable replica (no-key and wrong-key GETs fail - the direct silent-plaintext detector); new multipart passthrough contract with ETag/marker/stability assertions.
This commit is contained in:
@@ -81,7 +81,8 @@ use rustfs_s3_ops::S3Operation;
|
||||
use rustfs_targets::EventName;
|
||||
use rustfs_utils::CompressionAlgorithm;
|
||||
use rustfs_utils::http::{
|
||||
SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP, get_source_scheme,
|
||||
SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT, SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP,
|
||||
SUFFIX_SOURCE_REPLICATION_REQUEST, contains_key_str, get_header, get_source_scheme,
|
||||
headers::{AMZ_DECODED_CONTENT_LENGTH, AMZ_OBJECT_TAGGING, AMZ_STORAGE_CLASS},
|
||||
insert_str,
|
||||
};
|
||||
@@ -504,19 +505,24 @@ impl DefaultMultipartUsecase {
|
||||
.get_multipart_info(&bucket, &key, &upload_id, &opts)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
EncryptionRequest {
|
||||
bucket: &bucket,
|
||||
key: &key,
|
||||
server_side_encryption: None,
|
||||
ssekms_key_id: None,
|
||||
ssekms_context: None,
|
||||
sse_customer_algorithm,
|
||||
sse_customer_key,
|
||||
sse_customer_key_md5,
|
||||
content_size: 0,
|
||||
principal: None,
|
||||
// A ciphertext-passthrough session stores encrypted parts verbatim and
|
||||
// completes without the customer key (the replication client has none),
|
||||
// so the SSE-C completion check must be skipped for it.
|
||||
if !contains_key_str(&multipart_info.user_defined, SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT) {
|
||||
EncryptionRequest {
|
||||
bucket: &bucket,
|
||||
key: &key,
|
||||
server_side_encryption: None,
|
||||
ssekms_key_id: None,
|
||||
ssekms_context: None,
|
||||
sse_customer_algorithm,
|
||||
sse_customer_key,
|
||||
sse_customer_key_md5,
|
||||
content_size: 0,
|
||||
principal: None,
|
||||
}
|
||||
.validate_multipart_ssec(&multipart_info.user_defined)?;
|
||||
}
|
||||
.validate_multipart_ssec(&multipart_info.user_defined)?;
|
||||
let cache_adapter = self.object_data_cache();
|
||||
let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await;
|
||||
|
||||
@@ -759,7 +765,22 @@ impl DefaultMultipartUsecase {
|
||||
principal: session_principal.as_ref(),
|
||||
};
|
||||
|
||||
let (effective_sse, effective_kms_key_id) = match sse_prepare_encryption(encryption_request).await? {
|
||||
// SSE-C ciphertext passthrough: parts are already encrypted, so no
|
||||
// session DEK is prepared; a session marker tells UploadPart to store
|
||||
// the ciphertext verbatim instead of recovering encryption material.
|
||||
let ciphertext_passthrough = replication_authorized
|
||||
&& get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_REQUEST).as_deref() == Some("true")
|
||||
&& rustfs_utils::http::ssec_transport_to_stored_metadata(&req.headers).is_some();
|
||||
if ciphertext_passthrough {
|
||||
insert_str(&mut metadata, SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT, "true".to_string());
|
||||
}
|
||||
|
||||
let prepared_material = if ciphertext_passthrough {
|
||||
None
|
||||
} else {
|
||||
sse_prepare_encryption(encryption_request).await?
|
||||
};
|
||||
let (effective_sse, effective_kms_key_id) = match prepared_material {
|
||||
Some(material) => {
|
||||
let server_side_encryption = Some(material.server_side_encryption.clone());
|
||||
let ssekms_key_id = material.kms_key_id.clone();
|
||||
@@ -951,10 +972,14 @@ impl DefaultMultipartUsecase {
|
||||
}
|
||||
opts.want_checksum = reader.checksum();
|
||||
|
||||
let has_ssec = fi
|
||||
.user_defined
|
||||
.contains_key("x-amz-server-side-encryption-customer-algorithm");
|
||||
let (server_side_encryption, ssekms_key_id) = if has_ssec {
|
||||
// An SSE-C passthrough session stores ciphertext parts verbatim: no
|
||||
// material recovery, no validation against the (absent) customer key.
|
||||
let preserve_ciphertext = contains_key_str(&fi.user_defined, SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT);
|
||||
let has_ssec = !preserve_ciphertext
|
||||
&& fi
|
||||
.user_defined
|
||||
.contains_key("x-amz-server-side-encryption-customer-algorithm");
|
||||
let (server_side_encryption, ssekms_key_id) = if has_ssec || preserve_ciphertext {
|
||||
(None, None)
|
||||
} else {
|
||||
let sse = fi
|
||||
@@ -974,19 +999,21 @@ impl DefaultMultipartUsecase {
|
||||
};
|
||||
(sse, key_id)
|
||||
};
|
||||
EncryptionRequest {
|
||||
bucket: &bucket,
|
||||
key: &key,
|
||||
server_side_encryption: server_side_encryption.clone(),
|
||||
ssekms_key_id: ssekms_key_id.clone(),
|
||||
ssekms_context: None,
|
||||
sse_customer_algorithm: sse_customer_algorithm.clone(),
|
||||
sse_customer_key: sse_customer_key.clone(),
|
||||
sse_customer_key_md5: sse_customer_key_md5.clone(),
|
||||
content_size: actual_size,
|
||||
principal: None,
|
||||
if !preserve_ciphertext {
|
||||
EncryptionRequest {
|
||||
bucket: &bucket,
|
||||
key: &key,
|
||||
server_side_encryption: server_side_encryption.clone(),
|
||||
ssekms_key_id: ssekms_key_id.clone(),
|
||||
ssekms_context: None,
|
||||
sse_customer_algorithm: sse_customer_algorithm.clone(),
|
||||
sse_customer_key: sse_customer_key.clone(),
|
||||
sse_customer_key_md5: sse_customer_key_md5.clone(),
|
||||
content_size: actual_size,
|
||||
principal: None,
|
||||
}
|
||||
.validate_multipart_ssec(&fi.user_defined)?;
|
||||
}
|
||||
.validate_multipart_ssec(&fi.user_defined)?;
|
||||
let (requested_sse, requested_kms_key_id) = if has_ssec {
|
||||
let ssec_material = sse_decryption(DecryptionRequest {
|
||||
bucket: &bucket,
|
||||
|
||||
@@ -137,8 +137,8 @@ use rustfs_utils::CompressionAlgorithm;
|
||||
use rustfs_utils::http::{
|
||||
AMZ_BUCKET_REPLICATION_STATUS, AMZ_CHECKSUM_MODE, AMZ_CHECKSUM_TYPE, AMZ_WEBSITE_REDIRECT_LOCATION, CONTENT_TYPE,
|
||||
SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICA_STATUS, SUFFIX_REPLICA_TIMESTAMP,
|
||||
SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP, SUFFIX_RESTORE_OPERATION_ID, SUFFIX_SOURCE_REPLICATION_REQUEST,
|
||||
get_header,
|
||||
SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP, SUFFIX_RESTORE_OPERATION_ID, SUFFIX_SOURCE_REPLICATION_CHECK,
|
||||
SUFFIX_SOURCE_REPLICATION_REQUEST, get_header,
|
||||
headers::{
|
||||
AMZ_CONTENT_SHA256, AMZ_DECODED_CONTENT_LENGTH, AMZ_MINIO_SNOWBALL_IGNORE_DIRS, AMZ_MINIO_SNOWBALL_IGNORE_ERRORS,
|
||||
AMZ_MINIO_SNOWBALL_PREFIX, AMZ_OBJECT_LOCK_LEGAL_HOLD, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE,
|
||||
@@ -5393,6 +5393,11 @@ impl DefaultObjectUsecase {
|
||||
if is_put_object_extract_requested(&req.headers) && !inbound_replication_put {
|
||||
return Box::pin(self.execute_put_object_extract(req)).await;
|
||||
}
|
||||
// SSE-C ciphertext passthrough (authorized replication only): the body
|
||||
// is already ciphertext and must be stored verbatim — no compression,
|
||||
// no bucket-default encryption.
|
||||
let ciphertext_passthrough =
|
||||
inbound_replication_put && rustfs_utils::http::ssec_transport_to_stored_metadata(&req.headers).is_some();
|
||||
|
||||
let input = std::mem::take(&mut req.input);
|
||||
|
||||
@@ -5465,7 +5470,8 @@ impl DefaultObjectUsecase {
|
||||
self.check_bucket_quota(&bucket, quota_operation, size as u64).await?;
|
||||
|
||||
let ingress_stage_start = std::time::Instant::now();
|
||||
let should_compress = is_disk_compressible(&req.headers, &key) && size > MIN_DISK_COMPRESSIBLE_SIZE as i64;
|
||||
let should_compress =
|
||||
is_disk_compressible(&req.headers, &key) && size > MIN_DISK_COMPRESSIBLE_SIZE as i64 && !ciphertext_passthrough;
|
||||
let server_side_encryption_requested =
|
||||
server_side_encryption.is_some() || sse_customer_algorithm.is_some() || ssekms_key_id.is_some();
|
||||
|
||||
@@ -5575,6 +5581,13 @@ impl DefaultObjectUsecase {
|
||||
})
|
||||
});
|
||||
|
||||
if ciphertext_passthrough {
|
||||
// The replica keeps the source's SSE-C metadata; the bucket
|
||||
// default must not claim managed encryption on it.
|
||||
effective_sse = None;
|
||||
effective_kms_key_id = None;
|
||||
}
|
||||
|
||||
// Validate SSE-C headers early: reject partial/invalid combinations per S3 spec
|
||||
validate_sse_headers_for_write(
|
||||
effective_sse.as_ref(),
|
||||
@@ -5778,12 +5791,20 @@ impl DefaultObjectUsecase {
|
||||
principal: write_principal.as_ref(),
|
||||
};
|
||||
|
||||
let encryption_material = match sse_encryption(encryption_request).await {
|
||||
Ok(material) => material,
|
||||
Err(err) => {
|
||||
let result = Err(err.into());
|
||||
let _ = helper.complete(&result);
|
||||
return result;
|
||||
// SSE-C ciphertext passthrough must skip sse_encryption entirely: an
|
||||
// explicit guard is required because prepare_sse_configuration inside
|
||||
// it falls back to the bucket default encryption config and would
|
||||
// double-encrypt the already-encrypted body.
|
||||
let encryption_material = if opts.preserve_ciphertext {
|
||||
None
|
||||
} else {
|
||||
match sse_encryption(encryption_request).await {
|
||||
Ok(material) => material,
|
||||
Err(err) => {
|
||||
let result = Err(err.into());
|
||||
let _ = helper.complete(&result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8319,15 +8340,22 @@ impl DefaultObjectUsecase {
|
||||
{
|
||||
return Err(S3Error::new(S3ErrorCode::PreconditionFailed));
|
||||
}
|
||||
validate_sse_headers_for_read(&info.user_defined, &req.headers)?;
|
||||
// An authorized replication convergence check only needs etag/size/mtime
|
||||
// to compare source and replica; it holds no customer key, so the SSE-C
|
||||
// read validation is skipped for it (and only it).
|
||||
let replication_check = replication_request_authorized(&req)
|
||||
&& get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_CHECK).as_deref() == Some("true");
|
||||
if !replication_check {
|
||||
validate_sse_headers_for_read(&info.user_defined, &req.headers)?;
|
||||
|
||||
// Validate SSE-C: if the object was encrypted with a customer-provided key,
|
||||
// the caller must supply the matching key even for HEAD requests (per S3 spec).
|
||||
validate_ssec_for_read(
|
||||
&info.user_defined,
|
||||
req.input.sse_customer_key.as_ref(),
|
||||
req.input.sse_customer_key_md5.as_ref(),
|
||||
)?;
|
||||
// Validate SSE-C: if the object was encrypted with a customer-provided key,
|
||||
// the caller must supply the matching key even for HEAD requests (per S3 spec).
|
||||
validate_ssec_for_read(
|
||||
&info.user_defined,
|
||||
req.input.sse_customer_key.as_ref(),
|
||||
req.input.sse_customer_key_md5.as_ref(),
|
||||
)?;
|
||||
}
|
||||
|
||||
// Compute x-amz-expiration header from lifecycle prediction (before info is partially moved)
|
||||
let expiration_header = resolve_put_object_expiration(&bucket, &info).await;
|
||||
|
||||
@@ -87,6 +87,7 @@ fn has_replication_only_put_headers(headers: &HeaderMap) -> bool {
|
||||
|| get_header(headers, SUFFIX_SOURCE_REPLICATION_CHECK).is_some()
|
||||
|| get_header(headers, SUFFIX_SOURCE_REPLICATION_REQUEST).is_some()
|
||||
|| get_header(headers, SUFFIX_SOURCE_VERSION_ID).is_some()
|
||||
|| rustfs_utils::http::has_ssec_transport_headers(headers)
|
||||
}
|
||||
|
||||
async fn authorize_replication_only_put_headers<T>(req: &mut S3Request<T>) -> S3Result<()> {
|
||||
@@ -2182,6 +2183,16 @@ impl S3Access for FS {
|
||||
req_info.object = Some(req.input.key.clone());
|
||||
req_info.version_id = req.input.version_id.clone();
|
||||
|
||||
// A replication convergence check HEADs the replica to compare
|
||||
// etag/size/mtime. For SSE-C replicas the worker holds no customer key,
|
||||
// so authorize it as a replication action and let the handler skip the
|
||||
// SSE-C read validation.
|
||||
if get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_CHECK).as_deref() == Some("true") {
|
||||
authorize_request(req, Action::S3Action(S3Action::ReplicateObjectAction)).await?;
|
||||
req_info_mut(req)?.replication_request_authorized = true;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
authorize_request(req, Action::S3Action(S3Action::GetObjectAction)).await
|
||||
}
|
||||
|
||||
|
||||
@@ -427,6 +427,16 @@ pub fn put_opts_from_headers_with_replication_authorization(
|
||||
opts.replication_request = true;
|
||||
opts.mod_time = replication_source_mtime(headers);
|
||||
opts.preserve_etag = replication_source_etag(headers);
|
||||
// SSE-C ciphertext passthrough: restore the stored encryption metadata
|
||||
// from the transport headers and mark the body as already encrypted so
|
||||
// the write path stores it verbatim.
|
||||
if let Some(restored) = rustfs_utils::http::ssec_transport_to_stored_metadata(headers) {
|
||||
opts.user_defined.extend(restored);
|
||||
opts.preserve_ciphertext = true;
|
||||
}
|
||||
if let Some(crc) = get_header(headers, SUFFIX_REPLICATION_SSEC_CRC) {
|
||||
insert_header_map(&mut opts.user_defined, SUFFIX_REPLICATION_SSEC_CRC, crc.into_owned());
|
||||
}
|
||||
}
|
||||
Ok(opts)
|
||||
}
|
||||
@@ -1470,6 +1480,56 @@ mod tests {
|
||||
assert!(opts.preserve_etag.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_put_opts_from_headers_gates_ssec_passthrough_on_authorization() {
|
||||
use rustfs_utils::http::object_encryption_keys::{
|
||||
INTERNAL_ENCRYPTION_IV_HEADER, REPLICATION_ENCRYPTION_IV_HEADER, REPLICATION_SSEC_ALGORITHM_HEADER,
|
||||
};
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
|
||||
headers.insert(
|
||||
REPLICATION_SSEC_ALGORITHM_HEADER.parse::<http::HeaderName>().unwrap(),
|
||||
HeaderValue::from_static("AES256"),
|
||||
);
|
||||
headers.insert(
|
||||
REPLICATION_ENCRYPTION_IV_HEADER.parse::<http::HeaderName>().unwrap(),
|
||||
HeaderValue::from_static("iv-value"),
|
||||
);
|
||||
|
||||
// Unauthorized: the transport headers must be inert — no restored
|
||||
// encryption metadata, no ciphertext-passthrough flag.
|
||||
let untrusted = put_opts_from_headers(&headers, HashMap::new()).expect("ordinary PUT options should be created");
|
||||
assert!(!untrusted.preserve_ciphertext);
|
||||
assert!(!untrusted.user_defined.contains_key(INTERNAL_ENCRYPTION_IV_HEADER));
|
||||
assert!(
|
||||
!untrusted
|
||||
.user_defined
|
||||
.contains_key("x-amz-server-side-encryption-customer-algorithm")
|
||||
);
|
||||
|
||||
// Authorized: the stored keys are restored and the write path is told
|
||||
// the body is already ciphertext.
|
||||
let trusted = put_opts_from_headers_with_replication_authorization(&headers, HashMap::new(), true)
|
||||
.expect("authorized replication request should parse");
|
||||
assert!(trusted.preserve_ciphertext);
|
||||
assert_eq!(
|
||||
trusted.user_defined.get(INTERNAL_ENCRYPTION_IV_HEADER).map(String::as_str),
|
||||
Some("iv-value")
|
||||
);
|
||||
assert_eq!(
|
||||
trusted
|
||||
.user_defined
|
||||
.get("x-amz-server-side-encryption-customer-algorithm")
|
||||
.map(String::as_str),
|
||||
Some("AES256")
|
||||
);
|
||||
assert_eq!(
|
||||
trusted.user_defined.get("x-amz-server-side-encryption").map(String::as_str),
|
||||
Some("AES256")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_put_opts_from_headers_accepts_replication_request_after_authorization() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
Reference in New Issue
Block a user