diff --git a/crates/e2e_test/src/kms/encrypted_range_get_test.rs b/crates/e2e_test/src/kms/encrypted_range_get_test.rs new file mode 100644 index 000000000..423aa7f4d --- /dev/null +++ b/crates/e2e_test/src/kms/encrypted_range_get_test.rs @@ -0,0 +1,140 @@ +// Copyright 2024 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Ranged GETs over encrypted single-part objects. +//! +//! Byte-exactness must hold on every frame layout the server can write: +//! legacy v1 (variable frames, conservative full read) and, when +//! `RUSTFS_ENCRYPTION_FRAME_V2=true` reaches the server under test, the +//! fixed-frame v2 layout whose marker enables the closed-form frame seek. +//! The matrix crosses frame boundaries, starts mid-frame, and ends inside +//! the final short frame, so a mispositioned seek cannot pass. + +use super::common::LocalKMSTestEnvironment; +use crate::common::{TEST_BUCKET, init_logging}; +use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::types::ServerSideEncryption; +use tracing::info; + +const FRAME_PLAINTEXT: usize = 8 * 1024; + +#[tokio::test] +async fn sse_s3_single_part_ranged_gets_are_byte_exact() -> Result<(), Box> { + init_logging(); + info!("Testing ranged GETs over an SSE-S3 single-part object"); + + let mut kms_env = LocalKMSTestEnvironment::new().await?; + let _default_key_id = kms_env.start_rustfs_for_local_kms().await?; + kms_env.wait_for_kms_ready().await?; + + let s3_client = kms_env.base_env.create_s3_client(); + kms_env.base_env.create_test_bucket(TEST_BUCKET).await?; + + let test_key = "encrypted-range-get"; + let body: Vec = (0..3 * FRAME_PLAINTEXT + 500).map(|i| (i % 251) as u8).collect(); + + let put = s3_client + .put_object() + .bucket(TEST_BUCKET) + .key(test_key) + .server_side_encryption(ServerSideEncryption::Aes256) + .body(ByteStream::from(body.clone())) + .send() + .await?; + assert_eq!( + put.server_side_encryption(), + Some(&ServerSideEncryption::Aes256), + "the object under test must actually be encrypted" + ); + + let cases: &[(usize, usize)] = &[ + // Head range inside frame 0. + (0, 99), + // Crossing the first frame boundary. + (FRAME_PLAINTEXT - 1, FRAME_PLAINTEXT), + // Starting exactly on a frame boundary. + (FRAME_PLAINTEXT, FRAME_PLAINTEXT + 9), + // Mid-object, mid-frame on both ends. + (2 * FRAME_PLAINTEXT + 5, 3 * FRAME_PLAINTEXT + 100), + // Tail range ending inside the final short frame. + (3 * FRAME_PLAINTEXT + 100, 3 * FRAME_PLAINTEXT + 499), + ]; + + for &(start, end) in cases { + let response = s3_client + .get_object() + .bucket(TEST_BUCKET) + .key(test_key) + .range(format!("bytes={start}-{end}")) + .send() + .await?; + assert_eq!( + response.content_length(), + Some((end - start + 1) as i64), + "range {start}-{end} content length" + ); + let data = response.body.collect().await?.into_bytes(); + assert_eq!(data.as_ref(), &body[start..=end], "range {start}-{end} must be byte-exact"); + } + + // A suffix range exercises the offset resolution path as well. + let response = s3_client + .get_object() + .bucket(TEST_BUCKET) + .key(test_key) + .range("bytes=-123") + .send() + .await?; + let data = response.body.collect().await?.into_bytes(); + assert_eq!(data.as_ref(), &body[body.len() - 123..], "suffix range must be byte-exact"); + + // The unranged body still round-trips. + let response = s3_client.get_object().bucket(TEST_BUCKET).key(test_key).send().await?; + let data = response.body.collect().await?.into_bytes(); + assert_eq!(data.as_ref(), body.as_slice(), "full body must round-trip"); + + // A block-aligned object ends in an empty authenticated final frame under + // the v2 layout; tail ranges touching the last plaintext byte must not be + // misread as truncation. + let aligned_key = "encrypted-range-get-aligned"; + let aligned_body: Vec = (0..3 * FRAME_PLAINTEXT).map(|i| ((i + 3) % 251) as u8).collect(); + s3_client + .put_object() + .bucket(TEST_BUCKET) + .key(aligned_key) + .server_side_encryption(ServerSideEncryption::Aes256) + .body(ByteStream::from(aligned_body.clone())) + .send() + .await?; + for (start, end) in [ + (2 * FRAME_PLAINTEXT + 10, 3 * FRAME_PLAINTEXT - 1), + (3 * FRAME_PLAINTEXT - 1, 3 * FRAME_PLAINTEXT - 1), + ] { + let response = s3_client + .get_object() + .bucket(TEST_BUCKET) + .key(aligned_key) + .range(format!("bytes={start}-{end}")) + .send() + .await?; + let data = response.body.collect().await?.into_bytes(); + assert_eq!( + data.as_ref(), + &aligned_body[start..=end], + "aligned range {start}-{end} must be byte-exact" + ); + } + + Ok(()) +} diff --git a/crates/e2e_test/src/kms/mod.rs b/crates/e2e_test/src/kms/mod.rs index 3b849fa1b..5d0e045e5 100644 --- a/crates/e2e_test/src/kms/mod.rs +++ b/crates/e2e_test/src/kms/mod.rs @@ -48,6 +48,9 @@ mod encryption_metadata_test; #[cfg(test)] mod copy_object_self_copy_sse_test; +#[cfg(test)] +mod encrypted_range_get_test; + #[cfg(test)] mod copy_object_version_restore_sse_test; diff --git a/crates/ecstore/src/io_support/rio.rs b/crates/ecstore/src/io_support/rio.rs index 39ad1dedb..c5a4e8bdb 100644 --- a/crates/ecstore/src/io_support/rio.rs +++ b/crates/ecstore/src/io_support/rio.rs @@ -180,6 +180,12 @@ where #[cfg(feature = "rio-v2")] { match backend { + // The legacy family includes v2 fixed-frame objects, whose plans + // seek by frame; honor the starting index here too so a rio-v2 + // build can range-read objects a default-build node wrote. + ReadEncryptionBackend::Legacy if sequence_number > 0 => { + Box::new(rustfs_rio::DecryptReader::new_at_block(reader, key, base_nonce, sequence_number as usize)) + } ReadEncryptionBackend::Legacy => Box::new(rustfs_rio::DecryptReader::new(reader, key, base_nonce)), ReadEncryptionBackend::V2 => { Box::new(rustfs_rio_v2::DecryptReader::new_with_sequence(reader, key, base_nonce, sequence_number)) @@ -189,8 +195,14 @@ where #[cfg(not(feature = "rio-v2"))] { - let _ = (backend, sequence_number); - Box::new(rustfs_rio::DecryptReader::new(reader, key, base_nonce)) + let _ = backend; + if sequence_number > 0 { + // Single-part v2 frame seek: the plan positioned the storage read + // at frame `sequence_number`; nonce and AAD bind absolute indices. + Box::new(rustfs_rio::DecryptReader::new_at_block(reader, key, base_nonce, sequence_number as usize)) + } else { + Box::new(rustfs_rio::DecryptReader::new(reader, key, base_nonce)) + } } } diff --git a/crates/ecstore/src/object_api/mod.rs b/crates/ecstore/src/object_api/mod.rs index 12b12ff23..6acd360a0 100644 --- a/crates/ecstore/src/object_api/mod.rs +++ b/crates/ecstore/src/object_api/mod.rs @@ -52,6 +52,13 @@ pub const ERASURE_ALGORITHM: &str = "rs-vandermonde"; pub const BLOCK_SIZE_V2: usize = 1024 * 1024; // 1M pub(crate) const ENCRYPTED_PART_LAYOUT_CANDIDATE_SUFFIX: &str = "encrypted-part-layout-quorum-candidate-v1"; pub(crate) const ENCRYPTED_PART_LAYOUT_QUORUM_SUFFIX: &str = "encrypted-part-layout-quorum-v1"; +/// Marker naming the fixed-8-KiB v2 frame layout of a single-part encrypted +/// object. The value is the object's `data_dir` token, exactly like the part +/// layout markers above: any path that re-homes this metadata onto other +/// ciphertext or a new object identity (copy, replication re-encryption, data +/// movement) mints a new `data_dir`, invalidating the marker so the read falls +/// back to the conservative full path instead of trusting a stale layout. +pub(crate) const ENCRYPTED_FRAME_LAYOUT_FIXED8K_SUFFIX: &str = "encrypted-frame-layout-fixed8k-v1"; pub(crate) const ENV_RUSTFS_ENCRYPTED_RANGE_SEEK: &str = "RUSTFS_ENCRYPTED_RANGE_SEEK"; pub(crate) const DEFAULT_RUSTFS_ENCRYPTED_RANGE_SEEK: bool = true; diff --git a/crates/ecstore/src/object_api/readers.rs b/crates/ecstore/src/object_api/readers.rs index 4d2838c6d..9fc69118d 100644 --- a/crates/ecstore/src/object_api/readers.rs +++ b/crates/ecstore/src/object_api/readers.rs @@ -142,6 +142,87 @@ fn get_encrypted_offsets(oi: &ObjectInfo, offset: i64) -> Result<(i64, usize, us const ENCRYPTED_RANGE_READ_PATH_FULL: &str = "full"; /// Metric path label for a Legacy encrypted Range GET served by the part-boundary seek. const ENCRYPTED_RANGE_READ_PATH_PART_SEEK: &str = "part_seek"; +/// Metric path label for a single-part v2 fixed-frame seek. +const ENCRYPTED_RANGE_READ_PATH_FRAME_SEEK: &str = "frame_seek"; + +/// Plaintext bytes per non-final v2 frame (the rio v2 writer's fixed block). +const V2_FRAME_PLAINTEXT_LEN: i64 = 8 * 1024; +/// Ciphertext cost of one full v2 frame: 8-byte header, 2-byte uvarint(8192), +/// the plaintext block, and the 16-byte GCM tag. +const V2_FRAME_CIPHERTEXT_LEN: i64 = 8 + 2 + V2_FRAME_PLAINTEXT_LEN + 16; + +/// True when a single-part v2 fixed-frame object may seek to a frame boundary. +/// +/// The marker's value is the object's `data_dir` token (see +/// [`ENCRYPTED_FRAME_LAYOUT_FIXED8K_SUFFIX`]), so metadata re-homed onto other +/// ciphertext or another object identity disqualifies itself. Correctness never +/// rests on the marker: the v2 decrypt path authenticates every frame against +/// its absolute index, so a lying marker surfaces as a read error, never as +/// plaintext from the wrong offset. +fn v2_frame_seek_eligible( + oi: &ObjectInfo, + is_multipart: bool, + is_compressed: bool, + requested_length: i64, + recorded_plaintext_size: Option, +) -> bool { + if is_multipart || is_compressed || requested_length <= 0 || recorded_plaintext_size.is_none() { + return false; + } + let Some(data_dir) = oi.data_dir.filter(|data_dir| !data_dir.is_nil()) else { + return false; + }; + let mut layout_token_buf = [0_u8; 36]; + let layout_token = data_dir.hyphenated().encode_lower(&mut layout_token_buf); + has_encrypted_part_layout_marker(&oi.user_defined, ENCRYPTED_FRAME_LAYOUT_FIXED8K_SUFFIX, layout_token) +} + +/// Closed-form frame-boundary offsets for a single-part v2 object. +/// +/// Returns `(storage_offset, storage_length, plaintext_skip_in_frame, +/// starting_frame_index, remaining_plaintext)`. `None` on any bound the stored +/// layout cannot satisfy — the caller then keeps the conservative full read. +fn get_v2_frame_offsets(oi: &ObjectInfo, offset: i64, length: i64, plaintext_size: i64) -> Option<(i64, i64, usize, u32, i64)> { + if offset < 0 || length <= 0 || plaintext_size < 0 { + return None; + } + let end = offset.checked_add(length)?.checked_sub(1)?; + if end >= plaintext_size { + return None; + } + let frame_index = offset / V2_FRAME_PLAINTEXT_LEN; + let storage_offset = frame_index.checked_mul(V2_FRAME_CIPHERTEXT_LEN)?; + if storage_offset >= oi.size { + return None; + } + let end_frame = end / V2_FRAME_PLAINTEXT_LEN; + let total_full_frames = plaintext_size / V2_FRAME_PLAINTEXT_LEN; + // Frames below `total_full_frames` are full-size by construction. A range + // reaching into the last plaintext-bearing frame extends the window to the + // ciphertext end: a range that touches the plaintext end drains the + // decrypt stream, and the stream only ends cleanly at its authenticated + // final frame (which on a block-aligned object is the empty frame after + // the last full one). Interior ranges stop before their window ends and + // never observe the cut. + let storage_end = if end_frame.checked_add(1)? < total_full_frames { + end_frame.checked_add(1)?.checked_mul(V2_FRAME_CIPHERTEXT_LEN)?.min(oi.size) + } else { + oi.size + }; + if storage_end <= storage_offset { + return None; + } + let plaintext_skip = usize::try_from(offset % V2_FRAME_PLAINTEXT_LEN).ok()?; + let remaining_plaintext = plaintext_size.checked_sub(frame_index.checked_mul(V2_FRAME_PLAINTEXT_LEN)?)?; + let starting_frame = u32::try_from(frame_index).ok()?; + Some(( + storage_offset, + storage_end - storage_offset, + plaintext_skip, + starting_frame, + remaining_plaintext, + )) +} /// True when a Legacy (rio v1) encrypted Range GET may seek to the covering part /// boundary instead of streaming the whole ciphertext. @@ -294,6 +375,29 @@ fn legacy_encrypted_range_plan( full_plaintext_size: usize, recorded_plaintext_size: Option, ) -> Result { + if v2_frame_seek_eligible(oi, is_multipart, is_compressed, requested_length, recorded_plaintext_size) + && let Some(recorded_plaintext) = recorded_plaintext_size + && let Ok(requested_offset_i64) = i64::try_from(requested_offset) + && let Some((physical_offset, physical_length, plaintext_skip_in_frame, starting_frame, remaining_plaintext)) = + get_v2_frame_offsets(oi, requested_offset_i64, requested_length, recorded_plaintext) + { + let storage_offset = usize::try_from(physical_offset) + .map_err(|_| Error::other(format!("invalid v2 encrypted offset {physical_offset}")))?; + let total_plaintext_size = usize::try_from(remaining_plaintext) + .map_err(|_| Error::other(format!("invalid v2 remaining decrypted size {remaining_plaintext}")))?; + record_encrypted_range_read_amplification(ENCRYPTED_RANGE_READ_PATH_FRAME_SEEK, physical_length, requested_length); + return Ok(( + storage_offset, + physical_length, + 0, + plaintext_skip_in_frame, + requested_length, + total_plaintext_size, + starting_frame, + Vec::new(), + )); + } + if legacy_encrypted_seek_eligible(oi, is_multipart, is_compressed, requested_length, recorded_plaintext_size) && let Ok(requested_offset) = i64::try_from(requested_offset) && let Some((physical_offset, physical_length, plaintext_skip_in_part, part_start_index, remaining_plaintext)) = @@ -2926,6 +3030,45 @@ mod tests { .await } + /// Single-part object encrypted with the v2 fixed-frame writer, carrying + /// the frame-layout marker bound to its `data_dir`. + async fn build_v2_singlepart_fixture(key_bytes: [u8; 32], plain_size: usize) -> LegacyMultipartFixture { + let plaintext = legacy_fixture_part_plaintext(1, plain_size); + let mut ciphertext = Vec::new(); + rustfs_rio::EncryptReader::new_v2(Cursor::new(plaintext.clone()), key_bytes, LEGACY_FIXTURE_BASE_NONCE) + .read_to_end(&mut ciphertext) + .await + .expect("encrypt v2 single-part fixture"); + + let data_dir = Uuid::from_u128(2); + let mut user_defined = legacy_ssec_multipart_metadata(key_bytes, plain_size); + rustfs_utils::http::insert_str(&mut user_defined, ENCRYPTED_FRAME_LAYOUT_FIXED8K_SUFFIX, data_dir.to_string()); + + let parts = vec![ObjectPartInfo { + number: 1, + size: ciphertext.len(), + actual_size: plaintext.len() as i64, + ..Default::default() + }]; + let object_info = ObjectInfo { + bucket: "bucket".to_string(), + name: "v2-single-part".to_string(), + size: ciphertext.len() as i64, + data_dir: Some(data_dir), + etag: Some("d41d8cd98f00b204e9800998ecf8427e".to_string()), + parts: Arc::new(parts), + user_defined: Arc::new(user_defined), + ..Default::default() + }; + + LegacyMultipartFixture { + plaintext, + ciphertext, + object_info, + part_physical_sizes: Vec::new(), + } + } + /// Serves exactly the ciphertext window the plan schedules — the contract the /// erasure layer honors for `(storage_offset, storage_length)` — then decodes /// the body end to end. Returns the body, the plan offsets, and the reported @@ -3888,4 +4031,243 @@ mod tests { assert_eq!(reader.object_info.size, 64); assert_eq!(actual, plaintext[range.start as usize..range.start as usize + 64]); } + + // =============== v2 fixed-frame single-part seek =============== + + #[test] + fn test_get_v2_frame_offsets_math() { + let frame = V2_FRAME_CIPHERTEXT_LEN; + let block = V2_FRAME_PLAINTEXT_LEN; + // 3 full frames + a 500-byte final frame (2-byte uvarint) + end marker. + let plaintext_size = 3 * block + 500; + let final_frame = 8 + 2 + 500 + 16; + let oi = ObjectInfo { + size: 3 * frame + final_frame + 8, + ..Default::default() + }; + + // Head range stays inside frame 0. + let (off, len, skip, seq, remaining) = get_v2_frame_offsets(&oi, 0, 100, plaintext_size).expect("head range plans"); + assert_eq!((off, len, skip, seq), (0, frame, 0, 0)); + assert_eq!(remaining, plaintext_size); + + // A range crossing the first frame boundary covers frames 0..=1. + let (off, len, skip, seq, _) = get_v2_frame_offsets(&oi, block - 1, 2, plaintext_size).expect("boundary range plans"); + assert_eq!((off, len, skip, seq), (0, 2 * frame, (block - 1) as usize, 0)); + + // A range starting exactly on frame 1 seeks past frame 0. + let (off, len, skip, seq, remaining) = get_v2_frame_offsets(&oi, block, 10, plaintext_size).expect("aligned range plans"); + assert_eq!((off, len, skip, seq), (frame, frame, 0, 1)); + assert_eq!(remaining, plaintext_size - block); + + // A tail range inside the final frame reads through the ciphertext end. + let (off, len, skip, seq, remaining) = + get_v2_frame_offsets(&oi, 3 * block + 100, 200, plaintext_size).expect("tail range plans"); + assert_eq!((off, len, skip, seq), (3 * frame, final_frame + 8, 100, 3)); + assert_eq!(remaining, 500); + + // A range ending inside the LAST full frame also extends to the + // ciphertext end, so a drain-to-plaintext-end read still observes the + // authenticated final frame. + let (off, len, skip, seq, _) = + get_v2_frame_offsets(&oi, 2 * block + 1, block - 1, plaintext_size).expect("last-full-frame range plans"); + assert_eq!((off, len, skip, seq), (2 * frame, oi.size - 2 * frame, 1, 2)); + + // Out-of-bounds and degenerate requests fall back. + assert!(get_v2_frame_offsets(&oi, plaintext_size - 10, 20, plaintext_size).is_none()); + assert!(get_v2_frame_offsets(&oi, -1, 10, plaintext_size).is_none()); + assert!(get_v2_frame_offsets(&oi, 0, 0, plaintext_size).is_none()); + } + + #[tokio::test] + async fn test_v2_singlepart_range_seek_byte_exact_matrix() { + let key_bytes = [0x6B; 32]; + let block = V2_FRAME_PLAINTEXT_LEN as usize; + let fixture = build_v2_singlepart_fixture(key_bytes, 3 * block + 500).await; + + let cases = [ + (0_i64, 99_i64), + ((block - 1) as i64, block as i64), + (block as i64, (block + 9) as i64), + ((2 * block + 5) as i64, (3 * block + 480) as i64), + ]; + for (start, end) in cases { + let (body, offset, length, _) = read_via_seek_window( + &fixture, + Some(range(start, end)), + &ObjectOptions::default(), + &ssec_headers_from_key(key_bytes), + ) + .await; + let expected = &fixture.plaintext[start as usize..=end as usize]; + assert_eq!(body, expected, "range {start}-{end} must be byte-exact"); + let expected_offset = (start / V2_FRAME_PLAINTEXT_LEN) * V2_FRAME_CIPHERTEXT_LEN; + assert_eq!(offset, usize::try_from(expected_offset).expect("offset fits"), "range {start}-{end}"); + assert!( + length < fixture.ciphertext.len() as i64 || start < V2_FRAME_PLAINTEXT_LEN, + "a mid-object range must not span the whole ciphertext" + ); + } + } + + #[tokio::test] + async fn test_v2_singlepart_block_aligned_object_serves_tail_ranges() { + // A block-aligned object ends in an EMPTY authenticated final frame. + // Ranges ending on the last plaintext byte plan a window that excludes + // that final frame; the ranged reader must not misread the window end + // as truncation. + let key_bytes = [0x6E; 32]; + let block = V2_FRAME_PLAINTEXT_LEN as usize; + let fixture = build_v2_singlepart_fixture(key_bytes, 3 * block).await; + + for (start, end) in [ + (2 * block as i64 + 10, 3 * block as i64 - 1), + (3 * block as i64 - 1, 3 * block as i64 - 1), + (0, 3 * block as i64 - 1), + ] { + let (body, _, _, _) = read_via_seek_window( + &fixture, + Some(range(start, end)), + &ObjectOptions::default(), + &ssec_headers_from_key(key_bytes), + ) + .await; + assert_eq!( + body, + &fixture.plaintext[start as usize..=end as usize], + "aligned-object range {start}-{end} must be byte-exact" + ); + } + } + + #[tokio::test] + async fn test_v2_singlepart_seek_requires_a_matching_marker() { + let key_bytes = [0x6C; 32]; + let block = V2_FRAME_PLAINTEXT_LEN as usize; + let fixture = build_v2_singlepart_fixture(key_bytes, 2 * block + 40).await; + + // Marker bound to another data_dir (a copied/re-homed object): full read. + let mut foreign = (*fixture.object_info.user_defined).clone(); + rustfs_utils::http::insert_str(&mut foreign, ENCRYPTED_FRAME_LAYOUT_FIXED8K_SUFFIX, Uuid::from_u128(9).to_string()); + let mut object_info = fixture.object_info.clone(); + object_info.user_defined = Arc::new(foreign); + let plan = ReadPlan::build( + Some(range(block as i64, (block + 5) as i64)), + &object_info, + &ObjectOptions::default(), + &ssec_headers_from_key(key_bytes), + ) + .await + .expect("mismatched marker still plans a conservative read"); + assert_eq!(plan.storage_offset, 0); + assert_eq!(plan.storage_length, fixture.ciphertext.len() as i64); + + // No marker at all: full read. + let mut unmarked = (*fixture.object_info.user_defined).clone(); + unmarked.retain(|key, _| !rustfs_utils::http::has_internal_suffix(key, ENCRYPTED_FRAME_LAYOUT_FIXED8K_SUFFIX)); + let mut object_info = fixture.object_info.clone(); + object_info.user_defined = Arc::new(unmarked); + let plan = ReadPlan::build( + Some(range(block as i64, (block + 5) as i64)), + &object_info, + &ObjectOptions::default(), + &ssec_headers_from_key(key_bytes), + ) + .await + .expect("markerless object plans a conservative read"); + assert_eq!(plan.storage_offset, 0); + } + + #[tokio::test] + async fn test_v2_singlepart_lying_marker_fails_closed() { + // v1 ciphertext with a v2 marker planted on it: the seek positions the + // read at a fake frame boundary, and decryption must fail — never + // serve bytes from the wrong offset. The v1 writer frames per upstream + // read, so a small-buffered source yields short (non-8-KiB) frames and + // the fixed-frame math lands mid-frame. + struct ShortReads { + inner: R, + cap: usize, + } + impl AsyncRead for ShortReads { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let cap = self.cap.min(buf.remaining()); + let mut tmp = vec![0u8; cap]; + let mut limited = ReadBuf::new(&mut tmp); + match Pin::new(&mut self.inner).poll_read(cx, &mut limited) { + Poll::Ready(Ok(())) => { + buf.put_slice(limited.filled()); + Poll::Ready(Ok(())) + } + other => other, + } + } + } + + let key_bytes = [0x6D; 32]; + let block = V2_FRAME_PLAINTEXT_LEN as usize; + let plaintext = legacy_fixture_part_plaintext(1, 3 * block); + let mut ciphertext = Vec::new(); + rustfs_rio::EncryptReader::new( + ShortReads { + inner: Cursor::new(plaintext.clone()), + cap: 5_000, + }, + key_bytes, + LEGACY_FIXTURE_BASE_NONCE, + ) + .read_to_end(&mut ciphertext) + .await + .expect("encrypt v1 fixture"); + // The fixture premise: short frames mean the fixed-frame boundary is + // not a real frame boundary in this ciphertext. + assert_ne!(ciphertext[0..8][1], ((V2_FRAME_CIPHERTEXT_LEN - 8) & 0xFF) as u8, "frames must be short"); + + let data_dir = Uuid::from_u128(3); + let mut user_defined = legacy_ssec_multipart_metadata(key_bytes, 3 * block); + rustfs_utils::http::insert_str(&mut user_defined, ENCRYPTED_FRAME_LAYOUT_FIXED8K_SUFFIX, data_dir.to_string()); + let object_info = ObjectInfo { + bucket: "bucket".to_string(), + name: "lying-marker".to_string(), + size: ciphertext.len() as i64, + data_dir: Some(data_dir), + etag: Some("d41d8cd98f00b204e9800998ecf8427e".to_string()), + parts: Arc::new(vec![ObjectPartInfo { + number: 1, + size: ciphertext.len(), + actual_size: (3 * block) as i64, + ..Default::default() + }]), + user_defined: Arc::new(user_defined), + ..Default::default() + }; + + let rs = range(block as i64, (block + 50) as i64); + let plan = ReadPlan::build( + Some(rs.clone()), + &object_info, + &ObjectOptions::default(), + &ssec_headers_from_key(key_bytes), + ) + .await + .expect("plan still builds; failure must come from authentication"); + let start = plan.storage_offset; + let window_len = usize::try_from(plan.storage_length).expect("window fits"); + let window = ciphertext[start.min(ciphertext.len())..(start + window_len).min(ciphertext.len())].to_vec(); + + let (mut reader, _, _) = GetObjectReader::new( + Box::new(Cursor::new(window)), + Some(rs), + &object_info, + &ObjectOptions::default(), + &ssec_headers_from_key(key_bytes), + ) + .await + .expect("reader construction succeeds; decryption fails at read time"); + let mut body = Vec::new(); + reader + .read_to_end(&mut body) + .await + .expect_err("a lying frame-layout marker must fail closed, not serve plaintext"); + } } diff --git a/crates/ecstore/src/set_disk/ops/object.rs b/crates/ecstore/src/set_disk/ops/object.rs index f8ee574af..30a1e30a4 100644 --- a/crates/ecstore/src/set_disk/ops/object.rs +++ b/crates/ecstore/src/set_disk/ops/object.rs @@ -608,6 +608,72 @@ mod lifecycle_delete_all_plan_tests { )); } + #[cfg(not(feature = "rio-v2"))] + #[tokio::test] + async fn put_object_stamps_the_v2_frame_layout_marker_only_when_enabled() { + use rustfs_utils::http::headers::SSEC_ALGORITHM_HEADER; + + async fn put_encrypted_and_reread(marker_expected: bool) { + let (_temp_dirs, disks, set) = hermetic_set_disks(4).await; + let bucket = "frame-layout-marker-bucket"; + for disk in &disks { + disk.make_volume(bucket).await.expect("bucket volume should be created"); + } + + let opts = ObjectOptions { + user_defined: HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())]), + ..Default::default() + }; + let mut reader = PutObjReader::from_vec(vec![0x42; 4096]); + set.put_object(bucket, "encrypted", &mut reader, &opts) + .await + .expect("encrypted object should be stored"); + + let info = set + .get_object_info(bucket, "encrypted", &ObjectOptions::default()) + .await + .expect("stored object should be readable"); + let marker = rustfs_utils::http::get_consistent_str( + &info.user_defined, + crate::object_api::ENCRYPTED_FRAME_LAYOUT_FIXED8K_SUFFIX, + ) + .map(str::to_string); + if marker_expected { + let data_dir = info.data_dir.expect("stored object should have a data dir").to_string(); + assert_eq!(marker.as_deref(), Some(data_dir.as_str()), "marker must bind the object's data_dir"); + } else { + assert_eq!(marker, None, "marker must not be stamped"); + } + + // A plaintext object never carries the marker, whatever the switch. + let mut reader = PutObjReader::from_vec(vec![0x43; 128]); + set.put_object(bucket, "plaintext", &mut reader, &ObjectOptions::default()) + .await + .expect("plaintext object should be stored"); + let info = set + .get_object_info(bucket, "plaintext", &ObjectOptions::default()) + .await + .expect("plaintext object should be readable"); + assert_eq!( + rustfs_utils::http::get_consistent_str( + &info.user_defined, + crate::object_api::ENCRYPTED_FRAME_LAYOUT_FIXED8K_SUFFIX + ), + None, + "plaintext objects never carry a frame-layout marker" + ); + } + + temp_env::async_with_vars([(crate::io_support::rio::ENV_RUSTFS_ENCRYPTION_FRAME_V2, Some("true"))], async { + put_encrypted_and_reread(true).await; + }) + .await; + temp_env::async_with_vars([(crate::io_support::rio::ENV_RUSTFS_ENCRYPTION_FRAME_V2, None::<&str>)], async { + put_encrypted_and_reread(false).await; + }) + .await; + } + #[tokio::test] async fn staged_delete_removes_history_before_the_trigger() { let (temp_dirs, disks, set) = hermetic_set_disks(4).await; @@ -2325,6 +2391,29 @@ impl SetDisks { } fi.data_dir = Some(Uuid::new_v4()); + + // Never inherit a frame-layout marker: an incoming one (replication + // passthrough, data movement) describes some other object identity and + // this write mints a fresh data_dir. Stamp only when this put actually + // encrypts its stream locally, which is when the v2 write switch is on. + rustfs_utils::http::metadata_compat::remove_str( + &mut user_defined, + crate::object_api::ENCRYPTED_FRAME_LAYOUT_FIXED8K_SUFFIX, + ); + #[cfg(not(feature = "rio-v2"))] + if crate::io_support::rio::encryption_frame_v2_enabled() + && !opts.preserve_ciphertext + && !opts.data_movement + && should_persist_encryption_original_size(&user_defined) + && let Some(data_dir) = fi.data_dir.filter(|data_dir| !data_dir.is_nil()) + { + insert_str( + &mut user_defined, + crate::object_api::ENCRYPTED_FRAME_LAYOUT_FIXED8K_SUFFIX, + data_dir.to_string(), + ); + } + let mut shuffle_disks = Self::shuffle_disks_owned(disks, &fi.erasure.distribution); let tmp_dir = Uuid::new_v4().to_string(); diff --git a/crates/rio/src/encrypt_reader.rs b/crates/rio/src/encrypt_reader.rs index f06c1e6ad..a579a52f5 100644 --- a/crates/rio/src/encrypt_reader.rs +++ b/crates/rio/src/encrypt_reader.rs @@ -394,6 +394,22 @@ where } } + /// Decrypt a stream that starts at an arbitrary frame boundary of a + /// single-part v2 object. + /// + /// `starting_block_index` is the absolute index of the first frame in the + /// stream: both the per-block nonce derivation and the v2 AEAD associated + /// data bind absolute indices, so a frame served from the middle of an + /// object only authenticates when the caller positions the read at a true + /// frame boundary and names that frame's index. v1 streams are never + /// planned with a non-zero start (their frames have no closed-form + /// positions), so this constructor is v2-only by construction. + pub fn new_at_block(inner: R, key: [u8; 32], nonce: [u8; 12], starting_block_index: usize) -> Self { + let mut reader = Self::new(inner, key, nonce); + reader.block_index = starting_block_index; + reader + } + pub fn new_multipart(inner: R, key: [u8; 32], base_nonce: [u8; 12], multipart_parts: Vec) -> Self { let first_part = multipart_parts.first().copied().unwrap_or(1); let initial_nonce = derive_part_nonce(&base_nonce, first_part); @@ -1548,4 +1564,35 @@ mod tests { assert_eq!(decrypted.len(), expected.len(), "no segment may be dropped after an empty final frame"); assert_eq!(decrypted, expected); } + + #[tokio::test] + async fn v2_decrypts_from_an_arbitrary_frame_boundary() { + let mut key = [0u8; 32]; + let mut nonce = [0u8; 12]; + rand::rng().fill_bytes(&mut key); + rand::rng().fill_bytes(&mut nonce); + + let block = super::ENCRYPTION_BLOCK_SIZE; + let data: Vec = (0..4 * block + 321).map(|i| (i % 247) as u8).collect(); + let encrypted = v2_encrypt(&data, key, nonce).await; + + // Serve the ciphertext window starting at frame 2 and decrypt with the + // matching absolute frame index: nonce and AAD both line up. + let window = encrypted[2 * V2_FULL_FRAME_LEN..].to_vec(); + let mut decrypt_reader = DecryptReader::new_at_block(Cursor::new(window.clone()), key, nonce, 2); + let mut decrypted = Vec::new(); + decrypt_reader + .read_to_end(&mut decrypted) + .await + .expect("mid-stream decrypt at a true frame boundary succeeds"); + assert_eq!(decrypted, &data[2 * block..]); + + // The same window with a wrong starting index fails authentication. + let mut decrypt_reader = DecryptReader::new_at_block(Cursor::new(window), key, nonce, 1); + let mut decrypted = Vec::new(); + decrypt_reader + .read_to_end(&mut decrypted) + .await + .expect_err("a wrong absolute frame index must fail authentication"); + } }