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:
唐小鸭
2026-08-09 23:53:04 +08:00
committed by GitHub
parent 942faefb25
commit 6333f21a2e
12 changed files with 465 additions and 113 deletions
+57 -30
View File
@@ -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,
+45 -17
View File
@@ -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;
+11
View File
@@ -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
}
+60
View File
@@ -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();