mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-19 11:06:17 +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:
@@ -1832,12 +1832,27 @@ impl TargetClient {
|
||||
object: &str,
|
||||
version_id: Option<String>,
|
||||
) -> Result<HeadObjectOutput, SdkError<HeadObjectError>> {
|
||||
// Announce the replication check so a RustFS target returns SSE-C
|
||||
// object metadata (etag/size) without the customer key the replication
|
||||
// worker cannot hold; otherwise SSE-C replicas never converge on HEAD.
|
||||
let mut headers = HeaderMap::new();
|
||||
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_CHECK, "true");
|
||||
match self
|
||||
.client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(object)
|
||||
.set_version_id(version_id)
|
||||
.customize()
|
||||
.map_request(move |mut req| {
|
||||
for (k, v) in headers.clone().into_iter() {
|
||||
if let Some(key_str) = k.map(|k| k.as_str().to_string()) {
|
||||
let value_str = v.to_str().unwrap_or("").to_string();
|
||||
req.headers_mut().insert(key_str, value_str);
|
||||
}
|
||||
}
|
||||
Result::<_, std::convert::Infallible>::Ok(req)
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -2390,6 +2390,9 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
|
||||
version_suspended,
|
||||
versioned,
|
||||
replication_request: true,
|
||||
// SSE-C passthrough reads the stored ciphertext verbatim; the
|
||||
// decrypting reader cannot serve it (no customer key server-side).
|
||||
raw_data_movement_read: self.ssec,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -2451,6 +2454,9 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
|
||||
return rinfo;
|
||||
}
|
||||
};
|
||||
// SSE-C passthrough sends the stored ciphertext; the wire length is
|
||||
// the stored size while rinfo keeps the logical size for metering.
|
||||
let transfer_size = if self.ssec { object_info.size } else { size };
|
||||
|
||||
if tgt_client.bucket.is_empty() {
|
||||
debug!(
|
||||
@@ -2597,7 +2603,7 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
|
||||
gr.stream = wrap_with_bandwidth_monitor(gr.stream, &put_opts, &bucket, &rinfo.arn);
|
||||
let byte_stream = async_read_to_bytestream(gr.stream);
|
||||
let result = tgt_client
|
||||
.put_object(&tgt_client.bucket, &object, size, byte_stream, &put_opts)
|
||||
.put_object(&tgt_client.bucket, &object, transfer_size, byte_stream, &put_opts)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(e.to_string()));
|
||||
record_proxy_request(&bucket, "PutObject", result.is_err()).await;
|
||||
@@ -2682,6 +2688,9 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
|
||||
version_suspended,
|
||||
versioned,
|
||||
replication_request: true,
|
||||
// SSE-C passthrough reads the stored ciphertext verbatim; the
|
||||
// decrypting reader cannot serve it (no customer key server-side).
|
||||
raw_data_movement_read: self.ssec,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -2742,6 +2751,9 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
|
||||
return rinfo;
|
||||
}
|
||||
};
|
||||
// SSE-C passthrough sends the stored ciphertext; the wire length is
|
||||
// the stored size while rinfo keeps the logical size for metering.
|
||||
let transfer_size = if self.ssec { object_info.size } else { size };
|
||||
|
||||
if tgt_client.bucket.is_empty() {
|
||||
debug!(
|
||||
@@ -2998,7 +3010,7 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
|
||||
gr.stream = wrap_with_bandwidth_monitor(gr.stream, &put_opts, &bucket, &rinfo.arn);
|
||||
let byte_stream = async_read_to_bytestream(gr.stream);
|
||||
let result = tgt_client
|
||||
.put_object(&tgt_client.bucket, &object, size, byte_stream, &put_opts)
|
||||
.put_object(&tgt_client.bucket, &object, transfer_size, byte_stream, &put_opts)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(e.to_string()));
|
||||
record_proxy_request(&bucket, "PutObject", result.is_err()).await;
|
||||
@@ -3137,10 +3149,17 @@ async fn replicate_object_with_multipart<S: ReplicationObjectIO>(ctx: MultipartR
|
||||
let mut header_size = replication_put_object_header_size(&put_opts);
|
||||
let mut offset: i64 = 0;
|
||||
for part_info in object_info.parts.iter() {
|
||||
// Ciphertext passthrough (raw read) ranges over the stored part
|
||||
// bytes; decrypted reads range over the logical plaintext parts.
|
||||
let part_size = if obj_opts.raw_data_movement_read {
|
||||
part_info.size as i64
|
||||
} else {
|
||||
part_info.actual_size
|
||||
};
|
||||
let part_plan = replication_multipart_part_plan(ReplicationMultipartPartInput {
|
||||
offset,
|
||||
part_number: part_info.number,
|
||||
part_size: part_info.actual_size,
|
||||
part_size,
|
||||
})
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))?;
|
||||
let range_spec = HTTPRangeSpec {
|
||||
|
||||
@@ -253,6 +253,11 @@ pub struct ObjectOptions {
|
||||
/// fence avoids recursively acquiring the read lock behind a queued writer.
|
||||
pub bucket_lifecycle_lock_fence: Option<NamespaceLockFence>,
|
||||
pub replication_request: bool,
|
||||
/// Authorized SSE-C replication passthrough: the body is already
|
||||
/// ciphertext, so the write path must not encrypt or compress it and
|
||||
/// stores the restored encryption metadata verbatim. Only the
|
||||
/// replication-authorized options builders may set this.
|
||||
pub preserve_ciphertext: bool,
|
||||
pub delete_marker: bool,
|
||||
pub synthetic_version_id: bool,
|
||||
|
||||
|
||||
@@ -1897,6 +1897,13 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
// The SSE-C passthrough session marker is upload-scoped; drop it from
|
||||
// the completed object's metadata.
|
||||
rustfs_utils::http::metadata_compat::remove_str(
|
||||
&mut fi.metadata,
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT,
|
||||
);
|
||||
|
||||
if checksum_type.is_set() {
|
||||
checksum_type
|
||||
.merge(rustfs_rio::ChecksumType::MULTIPART)
|
||||
|
||||
@@ -1233,6 +1233,16 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
// SSE-C replication carries the source object's sealed checksum
|
||||
// out of band; store it verbatim like the multipart path does.
|
||||
if let Some(cssum) =
|
||||
rustfs_utils::http::get_header_map(&user_defined, rustfs_utils::http::SUFFIX_REPLICATION_SSEC_CRC)
|
||||
&& !cssum.is_empty()
|
||||
{
|
||||
fi.checksum = base64_simd::STANDARD.decode_to_vec(&cssum).ok().map(bytes::Bytes::from);
|
||||
rustfs_utils::http::remove_header_map(&mut user_defined, rustfs_utils::http::SUFFIX_REPLICATION_SSEC_CRC);
|
||||
}
|
||||
|
||||
if fi.checksum.is_none()
|
||||
&& let Some(content_hash) = data.as_hash_reader().content_hash()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user