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