mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-29 08:27:06 +00:00
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:
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user