feat(ecstore): closed-form range seek for single-part v2 encrypted objects (#6601)

Single-part encrypted objects in the legacy format could not serve range
reads without decrypting from byte 0: v1 frames are emitted per upstream
read, so no closed-form plaintext-to-ciphertext mapping exists. The v2
layout fixed the frame length (8218 ciphertext bytes per 8 KiB plaintext
frame), making the mapping closed-form.

Consume it:
- Single-part PUTs that encrypt locally under the v2 write switch stamp
  the frame-layout marker, valued with the object's data_dir token -
  ciphertext passthrough, data movement and copies mint a new data_dir
  or strip the marker, so a re-homed marker disqualifies itself.
- The encrypted read plan seeks marked, uncompressed single-part objects
  to frame_index * 8218 and decrypts from that frame: the frame index
  rides the plan's sequence-number slot into DecryptReader::new_at_block,
  whose nonce and AAD bind absolute indices. New metric path label
  frame_seek.
- A lying marker fails closed: v2 authentication rejects bytes at a fake
  frame boundary; plaintext is never served from the wrong offset.

Compressed objects and multipart sub-part seeks keep the conservative
paths (follow-up work); reading needs no switch - seekability follows
the marker.
This commit is contained in:
唐小鸭
2026-08-26 09:43:34 +08:00
committed by GitHub
parent f469869620
commit 9118a6e344
7 changed files with 682 additions and 2 deletions
@@ -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<dyn std::error::Error + Send + Sync>> {
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<u8> = (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<u8> = (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(())
}
+3
View File
@@ -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;
+14 -2
View File
@@ -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))
}
}
}
+7
View File
@@ -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;
+382
View File
@@ -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<i64>,
) -> 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<i64>,
) -> Result<EncryptedRangePlan> {
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<R> {
inner: R,
cap: usize,
}
impl<R: AsyncRead + Unpin> AsyncRead for ShortReads<R> {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
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");
}
}
+89
View File
@@ -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();
+47
View File
@@ -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<usize>) -> 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<u8> = (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");
}
}