fix(sse): Temporarily refactored the SSE design for ECStore (#2813)

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: cxymds <Cxymds@qq.com>
This commit is contained in:
唐小鸭
2026-05-09 22:06:35 +08:00
committed by GitHub
parent 6275918d92
commit 09c2d15057
12 changed files with 1338 additions and 970 deletions
+2
View File
@@ -44,6 +44,7 @@ rustfs-credentials = { workspace = true }
rustfs-common.workspace = true
rustfs-policy.workspace = true
rustfs-protos.workspace = true
rustfs-kms.workspace = true
rustfs-s3-common = { workspace = true }
async-trait.workspace = true
bytes.workspace = true
@@ -104,6 +105,7 @@ rustix = { workspace = true }
rustfs-madmin.workspace = true
rustfs-workers.workspace = true
reqwest = { workspace = true }
aes-gcm.workspace = true
aws-sdk-s3 = { workspace = true }
urlencoding = { workspace = true }
smallvec = { workspace = true }
+35 -7
View File
@@ -88,8 +88,9 @@ use rustfs_utils::http::headers::{
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _,
};
use rustfs_utils::http::{
SUFFIX_ACTUAL_OBJECT_SIZE_CAP, SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICATION_SSEC_CRC,
contains_key_str, get_header_map, get_str, insert_str, remove_header_map,
SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER, SUFFIX_ACTUAL_OBJECT_SIZE_CAP, SUFFIX_ACTUAL_SIZE,
SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICATION_SSEC_CRC, contains_key_str, get_header_map, get_str,
insert_str, is_encryption_metadata_key, remove_header_map,
};
use rustfs_utils::{
HashAlgorithm,
@@ -134,6 +135,13 @@ pub(crate) fn strip_internal_multipart_metadata(metadata: &mut HashMap<String, S
metadata.remove(RUSTFS_MULTIPART_OBJECT_KEY);
}
fn should_persist_encryption_original_size(metadata: &HashMap<String, String>) -> bool {
metadata.keys().any(|key| is_encryption_metadata_key(key))
|| metadata.contains_key(SSEC_ALGORITHM_HEADER)
|| metadata.contains_key(SSEC_KEY_HEADER)
|| metadata.contains_key(SSEC_KEY_MD5_HEADER)
}
fn capacity_scope_from_disks(disks: &[Option<DiskStore>]) -> CapacityScope {
let mut unique = HashSet::with_capacity(disks.len());
let mut scoped_disks = Vec::with_capacity(disks.len());
@@ -694,7 +702,7 @@ impl ObjectIO for SetDisks {
let (rd, wd) = tokio::io::duplex(duplex_buffer_size);
debug!(bucket, object, duplex_buffer_size, "Created duplex pipe for object data transfer");
let (reader, offset, length) = GetObjectReader::new(Box::new(rd), range, &object_info, opts, &h)?;
let (reader, offset, length) = GetObjectReader::new(Box::new(rd), range, &object_info, opts, &h).await?;
// let disks = disks.clone();
let bucket = bucket.to_owned();
@@ -3510,16 +3518,22 @@ impl MultipartOperations for SetDisks {
fi.metadata.insert("etag".to_owned(), etag);
let persist_encryption_original_size = should_persist_encryption_original_size(&fi.metadata);
if opts.replication_request {
if let Some(actual_size) = get_str(&opts.user_defined, SUFFIX_ACTUAL_OBJECT_SIZE_CAP) {
insert_str(&mut fi.metadata, SUFFIX_ACTUAL_SIZE, actual_size.clone());
fi.metadata
.insert("x-rustfs-encryption-original-size".to_string(), actual_size);
if persist_encryption_original_size {
fi.metadata
.insert("x-rustfs-encryption-original-size".to_string(), actual_size);
}
}
} else {
insert_str(&mut fi.metadata, SUFFIX_ACTUAL_SIZE, object_actual_size.to_string());
fi.metadata
.insert("x-rustfs-encryption-original-size".to_string(), object_actual_size.to_string());
if persist_encryption_original_size {
fi.metadata
.insert("x-rustfs-encryption-original-size".to_string(), object_actual_size.to_string());
}
}
if fi.is_compressed() {
@@ -5721,6 +5735,20 @@ mod tests {
.expect("GOVERNANCE shortening with bypass should remain allowed");
}
#[test]
fn test_should_persist_encryption_original_size_rejects_plain_metadata() {
let metadata = HashMap::from([("content-type".to_string(), "application/octet-stream".to_string())]);
assert!(!should_persist_encryption_original_size(&metadata));
}
#[test]
fn test_should_persist_encryption_original_size_accepts_sse_c_metadata() {
let metadata = HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())]);
assert!(should_persist_encryption_original_size(&metadata));
}
#[test]
fn test_should_prevent_write() {
let oi = ObjectInfo {
+800 -24
View File
@@ -1,4 +1,79 @@
use super::*;
use aes_gcm::{
Aes256Gcm, Key, Nonce,
aead::{Aead, KeyInit},
};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
use md5::{Digest, Md5};
use rustfs_kms::{service_manager::get_global_encryption_service, types::ObjectEncryptionContext};
use rustfs_rio::DecryptReader;
use rustfs_utils::http::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER};
use std::collections::HashMap;
use std::env;
const INTERNAL_ENCRYPTION_KEY_ID_HEADER: &str = "x-rustfs-encryption-key-id";
const INTERNAL_ENCRYPTION_KEY_HEADER: &str = "x-rustfs-encryption-key";
const INTERNAL_ENCRYPTION_IV_HEADER: &str = "x-rustfs-encryption-iv";
const INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER: &str = "x-rustfs-encryption-original-size";
const SSEC_ORIGINAL_SIZE_HEADER: &str = "x-amz-server-side-encryption-customer-original-size";
const DEFAULT_SSE_ALGORITHM: &str = "AES256";
fn part_plaintext_size(part: &ObjectPartInfo) -> i64 {
if part.actual_size > 0 {
part.actual_size
} else {
part.size as i64
}
}
fn restore_request_active(opts: &ObjectOptions) -> bool {
let restore = &opts.transition.restore_request;
restore.type_.is_some() || restore.days.is_some() || restore.output_location.is_some() || restore.select_parameters.is_some()
}
fn decode_compression_index(index: Option<&bytes::Bytes>) -> Option<rustfs_rio::Index> {
let bytes = index?;
let mut decoded = rustfs_rio::Index::new();
if decoded.load(bytes.as_ref()).is_ok() {
Some(decoded)
} else {
None
}
}
fn get_compressed_offsets(oi: &ObjectInfo, offset: i64) -> (i64, i64, usize, i64, u64) {
let mut skip_length = 0_i64;
let mut cumulative_actual_size = 0_i64;
let mut first_part_idx = 0_usize;
let mut compressed_offset = 0_i64;
for (i, part) in oi.parts.iter().enumerate() {
cumulative_actual_size += part_plaintext_size(part);
if cumulative_actual_size <= offset {
compressed_offset += part.size as i64;
} else {
first_part_idx = i;
skip_length = cumulative_actual_size - part_plaintext_size(part);
break;
}
}
let mut part_skip = offset - skip_length;
let decrypt_skip = 0_i64;
let seq_num = 0_u64;
if part_skip > 0
&& let Some(part) = oi.parts.get(first_part_idx)
&& let Some(index) = decode_compression_index(part.index.as_ref())
&& let Ok((comp_off, uncomp_off)) = index.find(part_skip)
&& comp_off > 0
{
compressed_offset += comp_off;
part_skip -= uncomp_off;
}
(compressed_offset, part_skip, first_part_idx, decrypt_skip, seq_num)
}
pub struct PutObjReader {
pub stream: HashReader,
@@ -46,14 +121,19 @@ pub struct GetObjectReader {
pub object_info: ObjectInfo,
}
#[derive(Debug, Clone, Copy)]
struct EncryptionMaterial {
key_bytes: [u8; 32],
base_nonce: [u8; 12],
}
impl GetObjectReader {
#[tracing::instrument(level = "debug", skip(reader, rs, opts, _h))]
pub fn new(
pub async fn new(
reader: Box<dyn AsyncRead + Unpin + Send + Sync>,
rs: Option<HTTPRangeSpec>,
oi: &ObjectInfo,
opts: &ObjectOptions,
_h: &HeaderMap<HeaderValue>,
h: &HeaderMap<HeaderValue>,
) -> Result<(Self, usize, i64)> {
let mut rs = rs;
@@ -63,25 +143,27 @@ impl GetObjectReader {
rs = HTTPRangeSpec::from_object_info(oi, part_number);
}
// TODO:Encrypted
let mut is_encrypted = oi.is_encrypted();
let (algo, mut is_compressed) = oi.is_compressed_ok()?;
let (algo, is_compressed) = oi.is_compressed_ok()?;
if restore_request_active(opts) {
is_encrypted = false;
is_compressed = false;
}
// TODO: check TRANSITION
if is_compressed {
if is_compressed && !is_encrypted {
let actual_size = oi.get_actual_size()?;
let (off, length, dec_off, dec_length) = if let Some(rs) = rs {
// Support range requests for compressed objects
let (dec_off, dec_length) = rs.get_offset_length(actual_size)?;
(0, oi.size, dec_off, dec_length)
let (req_off, req_length) = rs.get_offset_length(actual_size)?;
let (physical_off, decompressed_skip, _, _, _) = get_compressed_offsets(oi, req_off as i64);
(physical_off as usize, oi.size - physical_off, decompressed_skip as usize, req_length)
} else {
(0, oi.size, 0, actual_size)
};
let dec_reader = DecompressReader::new(reader, algo);
let actual_size_usize = if actual_size > 0 {
let actual_size_usize = if actual_size >= 0 {
actual_size as usize
} else {
return Err(Error::other(format!("invalid decompressed size {actual_size}")));
@@ -122,6 +204,65 @@ impl GetObjectReader {
));
}
if is_encrypted {
let material = resolve_encryption_material(oi, h).await?;
let is_multipart = is_multipart_encrypted_object(&oi.parts, oi.etag.as_deref());
let plaintext_size = encrypted_plaintext_size(oi, is_multipart, is_compressed)?;
let plaintext_size_usize =
usize::try_from(plaintext_size).map_err(|_| Error::other(format!("invalid decrypted size {plaintext_size}")))?;
let (plain_offset, plain_length) = if let Some(rs) = rs {
rs.get_offset_length(plaintext_size)?
} else {
(0, plaintext_size)
};
let decrypted_reader: Box<dyn AsyncRead + Unpin + Send + Sync> = if is_multipart {
Box::new(DecryptReader::new_multipart(
reader,
material.key_bytes,
material.base_nonce,
multipart_part_numbers(&oi.parts),
))
} else {
Box::new(DecryptReader::new(reader, material.key_bytes, material.base_nonce))
};
let final_reader: Box<dyn AsyncRead + Unpin + Send + Sync> = if is_compressed {
let decompressed_reader = DecompressReader::new(decrypted_reader, algo);
if plain_offset > 0 || plain_length != plaintext_size {
Box::new(RangedDecompressReader::new(
decompressed_reader,
plain_offset,
plain_length,
plaintext_size_usize,
)?)
} else {
Box::new(LimitReader::new(decompressed_reader, plaintext_size_usize))
}
} else if plain_offset > 0 || plain_length != plaintext_size {
Box::new(RangedDecompressReader::new(
decrypted_reader,
plain_offset,
plain_length,
plaintext_size_usize,
)?)
} else {
Box::new(LimitReader::new(decrypted_reader, plaintext_size_usize))
};
let mut object_info = oi.clone();
object_info.size = plain_length;
return Ok((
GetObjectReader {
stream: final_reader,
object_info,
},
0,
oi.size,
));
}
if let Some(rs) = rs {
let (off, length) = rs.get_offset_length(oi.size)?;
@@ -188,7 +329,7 @@ impl HTTPRangeSpec {
for i in 0..part_number {
let part = &oi.parts[i];
start = end + 1;
end = start + (part.size as i64) - 1;
end = start + part_plaintext_size(part) - 1;
}
Some(HTTPRangeSpec {
@@ -481,12 +622,239 @@ impl<R: AsyncRead + Unpin + Send + 'static> Drop for StreamConsumer<R> {
}
}
fn encrypted_plaintext_size(oi: &ObjectInfo, is_multipart: bool, is_compressed: bool) -> Result<i64> {
if is_compressed {
return oi.get_actual_size().map_err(Into::into);
}
if is_multipart {
return Ok(multipart_plaintext_size(&oi.parts, oi.decrypted_size()?));
}
oi.decrypted_size().map_err(Into::into)
}
fn is_multipart_encrypted_object(parts: &[rustfs_filemeta::ObjectPartInfo], etag: Option<&str>) -> bool {
if parts.len() > 1 {
return true;
}
etag.map(|etag| etag.trim_matches('"').len() != 32).unwrap_or(false)
}
fn multipart_plaintext_size(parts: &[rustfs_filemeta::ObjectPartInfo], fallback: i64) -> i64 {
let total: i64 = parts.iter().map(part_plaintext_size).sum();
if total > 0 { total } else { fallback }
}
fn multipart_part_numbers(parts: &[rustfs_filemeta::ObjectPartInfo]) -> Vec<usize> {
parts.iter().map(|part| part.number).collect()
}
async fn resolve_encryption_material(oi: &ObjectInfo, headers: &HeaderMap<HeaderValue>) -> Result<EncryptionMaterial> {
if oi.user_defined.contains_key(SSEC_ALGORITHM_HEADER) {
return resolve_ssec_material(oi, headers);
}
if oi.user_defined.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER) {
return resolve_managed_material(&oi.user_defined).await;
}
Err(Error::other("encrypted object metadata is incomplete"))
}
fn resolve_ssec_material(oi: &ObjectInfo, headers: &HeaderMap<HeaderValue>) -> Result<EncryptionMaterial> {
let algorithm = headers
.get(SSEC_ALGORITHM_HEADER)
.ok_or_else(|| Error::other("missing SSE-C algorithm header"))?
.to_str()
.map_err(|_| Error::other("invalid SSE-C algorithm header"))?;
if algorithm != DEFAULT_SSE_ALGORITHM {
return Err(Error::other(format!("unsupported SSE-C algorithm {algorithm}")));
}
let key_b64 = headers
.get(SSEC_KEY_HEADER)
.ok_or_else(|| Error::other("missing SSE-C key header"))?
.to_str()
.map_err(|_| Error::other("invalid SSE-C key header"))?;
let key_md5 = headers
.get(SSEC_KEY_MD5_HEADER)
.ok_or_else(|| Error::other("missing SSE-C key md5 header"))?
.to_str()
.map_err(|_| Error::other("invalid SSE-C key md5 header"))?;
let key_bytes_vec = BASE64_STANDARD
.decode(key_b64)
.map_err(|_| Error::other("failed to decode SSE-C key"))?;
let key_bytes: [u8; 32] = key_bytes_vec
.try_into()
.map_err(|_| Error::other("SSE-C key must be 32 bytes"))?;
let expected_md5 = BASE64_STANDARD.encode(md5_bytes(key_bytes));
if expected_md5 != key_md5 {
return Err(Error::other("SSE-C key MD5 mismatch"));
}
let stored_md5 = oi
.user_defined
.get(SSEC_KEY_MD5_HEADER)
.ok_or_else(|| Error::other("missing stored SSE-C key md5"))?;
if stored_md5 != &expected_md5 {
return Err(Error::other("SSE-C key does not match object metadata"));
}
Ok(EncryptionMaterial {
key_bytes,
base_nonce: generate_ssec_nonce(&oi.bucket, &oi.name),
})
}
async fn resolve_managed_material(metadata: &HashMap<String, String>) -> Result<EncryptionMaterial> {
let encrypted_dek = metadata
.get(INTERNAL_ENCRYPTION_KEY_HEADER)
.ok_or_else(|| Error::other("missing managed encrypted DEK"))?;
let encrypted_dek = BASE64_STANDARD
.decode(encrypted_dek)
.map_err(|e| Error::other(format!("failed to decode managed encrypted DEK: {e}")))?;
let iv_b64 = metadata
.get(INTERNAL_ENCRYPTION_IV_HEADER)
.ok_or_else(|| Error::other("missing managed encryption IV"))?;
let iv = BASE64_STANDARD
.decode(iv_b64)
.map_err(|e| Error::other(format!("failed to decode managed encryption IV: {e}")))?;
let base_nonce: [u8; 12] = iv
.as_slice()
.try_into()
.map_err(|_| Error::other("managed encryption IV must be 12 bytes"))?;
let kms_key_id = metadata
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
.map(String::as_str)
.unwrap_or("default");
let key_bytes = if let Some(service) = get_global_encryption_service().await {
service
.decrypt_data_key(&encrypted_dek, &ObjectEncryptionContext::new(String::new(), String::new()))
.await
.map_err(|e| Error::other(format!("failed to decrypt managed data key: {e}")))?
.plaintext_key
} else {
decrypt_local_sse_dek(&encrypted_dek, kms_key_id)?
};
Ok(EncryptionMaterial { key_bytes, base_nonce })
}
fn decrypt_local_sse_dek(encrypted_dek: &[u8], _kms_key_id: &str) -> Result<[u8; 32]> {
let encrypted_dek = std::str::from_utf8(encrypted_dek).map_err(|_| Error::other("managed DEK is not valid UTF-8"))?;
let parts: Vec<&str> = encrypted_dek.split(':').collect();
if parts.len() != 2 {
return Err(Error::other("invalid managed DEK format"));
}
let nonce_vec = BASE64_STANDARD
.decode(parts[0])
.map_err(|_| Error::other("invalid managed DEK nonce"))?;
let ciphertext = BASE64_STANDARD
.decode(parts[1])
.map_err(|_| Error::other("invalid managed DEK ciphertext"))?;
let nonce_array: [u8; 12] = nonce_vec
.as_slice()
.try_into()
.map_err(|_| Error::other("invalid managed DEK nonce length"))?;
let key = Key::<Aes256Gcm>::from(local_sse_master_key()?);
let cipher = Aes256Gcm::new(&key);
let plaintext = cipher
.decrypt(&Nonce::from(nonce_array), ciphertext.as_slice())
.map_err(|e| Error::other(format!("failed to decrypt managed DEK: {e}")))?;
plaintext
.as_slice()
.try_into()
.map_err(|_| Error::other("managed DEK has invalid plaintext length"))
}
fn local_sse_master_key() -> Result<[u8; 32]> {
if let Some(key) = decode_master_key_env("__RUSTFS_SSE_SIMPLE_CMK")? {
return Ok(key);
}
if let Some(key) = decode_master_key_env("RUSTFS_SSE_S3_MASTER_KEY")? {
return Ok(key);
}
Ok([0u8; 32])
}
fn decode_master_key_env(name: &str) -> Result<Option<[u8; 32]>> {
let Ok(value) = env::var(name) else {
return Ok(None);
};
let value = value.trim();
if value.is_empty() {
return Ok(None);
}
let decoded = BASE64_STANDARD
.decode(value)
.map_err(|e| Error::other(format!("{name} is not valid base64: {e}")))?;
let key =
<[u8; 32]>::try_from(decoded.as_slice()).map_err(|_| Error::other(format!("{name} must decode to exactly 32 bytes")))?;
Ok(Some(key))
}
fn generate_ssec_nonce(bucket: &str, key: &str) -> [u8; 12] {
let digest = md5_bytes(format!("{bucket}-{key}").as_bytes());
let mut nonce = [0u8; 12];
nonce.copy_from_slice(&digest[..12]);
nonce
}
fn md5_bytes(data: impl AsRef<[u8]>) -> [u8; 16] {
let digest = Md5::digest(data.as_ref());
let mut out = [0u8; 16];
out.copy_from_slice(&digest);
out
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use md5::{Digest, Md5};
use std::io::Cursor;
use temp_env::async_with_vars;
use tokio::io::AsyncReadExt;
fn md5_bytes(data: impl AsRef<[u8]>) -> [u8; 16] {
let digest = Md5::digest(data.as_ref());
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&digest);
bytes
}
fn ssec_headers_from_key(key_bytes: [u8; 32]) -> HeaderMap<HeaderValue> {
let mut headers = HeaderMap::new();
headers.insert(rustfs_utils::http::SSEC_ALGORITHM_HEADER, HeaderValue::from_static("AES256"));
headers.insert(
rustfs_utils::http::SSEC_KEY_HEADER,
HeaderValue::from_str(&BASE64_STANDARD.encode(key_bytes)).expect("valid base64 header"),
);
headers.insert(
rustfs_utils::http::SSEC_KEY_MD5_HEADER,
HeaderValue::from_str(&BASE64_STANDARD.encode(md5_bytes(key_bytes))).expect("valid md5 header"),
);
headers
}
#[tokio::test]
async fn test_ranged_decompress_reader() {
// Create test data
@@ -626,6 +994,76 @@ mod tests {
assert!(HTTPRangeSpec::from_object_info(&object_info, 4).is_none());
}
#[test]
fn test_http_range_spec_from_object_info_uses_actual_size() {
let object_info = ObjectInfo {
size: 90,
parts: vec![
ObjectPartInfo {
etag: String::new(),
number: 1,
size: 20,
actual_size: 30,
..Default::default()
},
ObjectPartInfo {
etag: String::new(),
number: 2,
size: 30,
actual_size: 40,
..Default::default()
},
ObjectPartInfo {
etag: String::new(),
number: 3,
size: 40,
actual_size: 50,
..Default::default()
},
],
..Default::default()
};
let spec = HTTPRangeSpec::from_object_info(&object_info, 2).unwrap();
assert_eq!(spec.start, 30);
assert_eq!(spec.end, 69);
}
#[test]
fn test_http_range_spec_from_object_info_falls_back_to_part_size_when_actual_size_missing() {
let object_info = ObjectInfo {
size: 90,
parts: vec![
ObjectPartInfo {
etag: String::new(),
number: 1,
size: 20,
actual_size: 0,
..Default::default()
},
ObjectPartInfo {
etag: String::new(),
number: 2,
size: 30,
actual_size: 40,
..Default::default()
},
ObjectPartInfo {
etag: String::new(),
number: 3,
size: 40,
actual_size: 0,
..Default::default()
},
],
..Default::default()
};
let spec = HTTPRangeSpec::from_object_info(&object_info, 3).unwrap();
assert_eq!(spec.start, 60);
assert_eq!(spec.end, 99);
}
#[tokio::test]
async fn test_ranged_decompress_reader_zero_length() {
let original_data = b"Hello, World!";
@@ -676,11 +1114,22 @@ mod tests {
assert_eq!(&buf2[..1], b"e");
}
#[test]
fn test_get_object_reader_range_uses_stored_size_for_encrypted_metadata() {
fn encrypt_managed_dek_for_test(dek: [u8; 32], master_key: [u8; 32]) -> String {
let key = Key::<Aes256Gcm>::from(master_key);
let cipher = Aes256Gcm::new(&key);
let nonce = Nonce::from([0u8; 12]);
let ciphertext = cipher.encrypt(&nonce, dek.as_slice()).expect("encrypt managed dek");
format!("{}:{}", BASE64_STANDARD.encode(nonce), BASE64_STANDARD.encode(ciphertext))
}
#[tokio::test]
async fn test_get_object_reader_rejects_ssec_read_without_headers() {
let object_info = ObjectInfo {
size: 10,
user_defined: HashMap::from([("x-amz-server-side-encryption-customer-original-size".to_string(), "20".to_string())]),
user_defined: HashMap::from([
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
("x-amz-server-side-encryption-customer-original-size".to_string(), "20".to_string()),
]),
..Default::default()
};
@@ -690,24 +1139,26 @@ mod tests {
end: -1,
};
let (_, offset, length) = GetObjectReader::new(
let result = GetObjectReader::new(
Box::new(Cursor::new(b"0123456789".to_vec())),
Some(range),
&object_info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.unwrap();
.await;
assert_eq!(offset, 8);
assert_eq!(length, 2);
assert!(result.is_err());
}
#[test]
fn test_get_object_reader_suffix_range_uses_stored_size_for_encrypted_metadata() {
#[tokio::test]
async fn test_get_object_reader_restore_request_bypasses_encryption_range_rewrite() {
let object_info = ObjectInfo {
size: 10,
user_defined: HashMap::from([("x-rustfs-encryption-original-size".to_string(), "20".to_string())]),
user_defined: HashMap::from([
("x-rustfs-encryption-key".to_string(), "encrypted-key".to_string()),
("x-rustfs-encryption-original-size".to_string(), "20".to_string()),
]),
..Default::default()
};
@@ -717,16 +1168,341 @@ mod tests {
end: -1,
};
let mut opts = ObjectOptions::default();
opts.transition.restore_request.days = Some(1);
let (_, offset, length) = GetObjectReader::new(
Box::new(Cursor::new(b"0123456789".to_vec())),
Some(range),
&object_info,
&ObjectOptions::default(),
&opts,
&HeaderMap::new(),
)
.await
.unwrap();
assert_eq!(offset, 6);
assert_eq!(length, 4);
}
#[tokio::test]
async fn test_get_object_reader_allows_encrypted_full_object_passthrough() {
async_with_vars([("__RUSTFS_SSE_SIMPLE_CMK", Some(BASE64_STANDARD.encode([0u8; 32])))], async {
let plaintext = b"managed-full-object".to_vec();
let data_key = [0x21; 32];
let base_nonce = [0x11; 12];
let encrypted_dek = encrypt_managed_dek_for_test(data_key, [0u8; 32]);
let mut encrypted = Vec::new();
rustfs_rio::EncryptReader::new(Cursor::new(plaintext.clone()), data_key, base_nonce)
.read_to_end(&mut encrypted)
.await
.expect("encrypt managed object");
let object_info = ObjectInfo {
size: encrypted.len() as i64,
user_defined: HashMap::from([
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
("x-rustfs-encryption-key".to_string(), BASE64_STANDARD.encode(encrypted_dek.as_bytes())),
("x-rustfs-encryption-iv".to_string(), BASE64_STANDARD.encode(base_nonce)),
("x-rustfs-encryption-original-size".to_string(), plaintext.len().to_string()),
]),
..Default::default()
};
let (mut reader, offset, length) = GetObjectReader::new(
Box::new(Cursor::new(encrypted.clone())),
None,
&object_info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("managed encrypted full-object reads should decrypt inside ecstore");
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read managed plaintext");
assert_eq!(offset, 0);
assert_eq!(length, object_info.size);
assert_eq!(reader.object_info.size, plaintext.len() as i64);
assert_eq!(actual, plaintext);
})
.await;
}
#[tokio::test]
async fn test_get_object_reader_uses_local_managed_fallback_without_env() {
async_with_vars(
[
("__RUSTFS_SSE_SIMPLE_CMK", None::<String>),
("RUSTFS_SSE_S3_MASTER_KEY", None::<String>),
],
async {
let plaintext = b"managed-local-fallback".to_vec();
let data_key = [0x22; 32];
let base_nonce = [0x12; 12];
let encrypted_dek = encrypt_managed_dek_for_test(data_key, [0u8; 32]);
let mut encrypted = Vec::new();
rustfs_rio::EncryptReader::new(Cursor::new(plaintext.clone()), data_key, base_nonce)
.read_to_end(&mut encrypted)
.await
.expect("encrypt managed object with local fallback key");
let object_info = ObjectInfo {
size: encrypted.len() as i64,
user_defined: HashMap::from([
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
("x-rustfs-encryption-key".to_string(), BASE64_STANDARD.encode(encrypted_dek.as_bytes())),
("x-rustfs-encryption-iv".to_string(), BASE64_STANDARD.encode(base_nonce)),
("x-rustfs-encryption-original-size".to_string(), plaintext.len().to_string()),
]),
..Default::default()
};
let (mut reader, _, _) = GetObjectReader::new(
Box::new(Cursor::new(encrypted)),
None,
&object_info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("managed encrypted reads should fall back to the local SSE-S3 key");
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read managed plaintext");
assert_eq!(reader.object_info.size, plaintext.len() as i64);
assert_eq!(actual, plaintext);
},
)
.await;
}
#[tokio::test]
async fn test_get_object_reader_compressed_range_returns_physical_offset_from_index() {
let mut index = rustfs_rio::Index::new();
index.add(0, 0).unwrap();
index.add(1_048_576, 2_097_152).unwrap();
let object_info = ObjectInfo {
size: 3_000_000,
parts: vec![ObjectPartInfo {
etag: String::new(),
number: 1,
size: 3_000_000,
actual_size: 4_194_304,
index: Some(index.into_vec()),
..Default::default()
}],
user_defined: HashMap::from([
("x-minio-internal-compression".to_string(), "gzip".to_string()),
("x-minio-internal-actual-size".to_string(), "4194304".to_string()),
]),
..Default::default()
};
let range = HTTPRangeSpec {
is_suffix_length: false,
start: 2_097_152,
end: 2_097_161,
};
let (reader, offset, length) = GetObjectReader::new(
Box::new(Cursor::new(Vec::<u8>::new())),
Some(range),
&object_info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.unwrap();
assert!(offset > 0);
assert!(offset < 2_097_152);
assert_eq!(length, object_info.size - offset as i64);
assert_eq!(reader.object_info.size, 10);
}
#[tokio::test]
async fn test_get_object_reader_decrypts_ssec_full_object() {
let plaintext = b"ecstore-ssec-full-object".to_vec();
let key_bytes = [0x31; 32];
let bucket = "bucket";
let object = "object";
let nonce = md5_bytes(format!("{bucket}-{object}").as_bytes());
let mut base_nonce = [0u8; 12];
base_nonce.copy_from_slice(&nonce[..12]);
let mut encrypted = Vec::new();
rustfs_rio::EncryptReader::new(Cursor::new(plaintext.clone()), key_bytes, base_nonce)
.read_to_end(&mut encrypted)
.await
.expect("encrypt object");
let object_info = ObjectInfo {
bucket: bucket.to_string(),
name: object.to_string(),
size: encrypted.len() as i64,
user_defined: HashMap::from([
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
(
"x-amz-server-side-encryption-customer-key-md5".to_string(),
BASE64_STANDARD.encode(md5_bytes(key_bytes)),
),
(
"x-amz-server-side-encryption-customer-original-size".to_string(),
plaintext.len().to_string(),
),
]),
..Default::default()
};
let (mut reader, offset, length) = GetObjectReader::new(
Box::new(Cursor::new(encrypted.clone())),
None,
&object_info,
&ObjectOptions::default(),
&ssec_headers_from_key(key_bytes),
)
.await
.expect("ssec read should be supported");
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read decrypted ssec object");
assert_eq!(offset, 0);
assert_eq!(length, encrypted.len() as i64);
assert_eq!(reader.object_info.size, plaintext.len() as i64);
assert_eq!(actual, plaintext);
}
#[tokio::test]
async fn test_get_object_reader_decrypts_ssec_range_on_plaintext_semantics() {
let plaintext = b"0123456789abcdefghijklmnopqrstuvwxyz".to_vec();
let key_bytes = [0x41; 32];
let bucket = "bucket";
let object = "range-object";
let nonce = md5_bytes(format!("{bucket}-{object}").as_bytes());
let mut base_nonce = [0u8; 12];
base_nonce.copy_from_slice(&nonce[..12]);
let mut encrypted = Vec::new();
rustfs_rio::EncryptReader::new(Cursor::new(plaintext.clone()), key_bytes, base_nonce)
.read_to_end(&mut encrypted)
.await
.expect("encrypt ranged object");
let object_info = ObjectInfo {
bucket: bucket.to_string(),
name: object.to_string(),
size: encrypted.len() as i64,
user_defined: HashMap::from([
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
(
"x-amz-server-side-encryption-customer-key-md5".to_string(),
BASE64_STANDARD.encode(md5_bytes(key_bytes)),
),
(
"x-amz-server-side-encryption-customer-original-size".to_string(),
plaintext.len().to_string(),
),
]),
..Default::default()
};
let range = HTTPRangeSpec {
is_suffix_length: false,
start: 5,
end: 11,
};
let (mut reader, offset, length) = GetObjectReader::new(
Box::new(Cursor::new(encrypted.clone())),
Some(range),
&object_info,
&ObjectOptions::default(),
&ssec_headers_from_key(key_bytes),
)
.await
.expect("ssec range read should be supported");
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read ranged decrypted object");
assert_eq!(offset, 0);
assert_eq!(length, encrypted.len() as i64);
assert_eq!(reader.object_info.size, 7);
assert_eq!(actual, b"56789ab");
}
#[tokio::test]
async fn test_get_object_reader_decrypts_then_decompresses_before_applying_range() {
let plaintext = b"abcdefghijklmnopqrstuvwxyz".to_vec();
let key_bytes = [0x51; 32];
let bucket = "bucket";
let object = "compressed-object";
let nonce = md5_bytes(format!("{bucket}-{object}").as_bytes());
let mut base_nonce = [0u8; 12];
base_nonce.copy_from_slice(&nonce[..12]);
let mut compressed = Vec::new();
rustfs_rio::CompressReader::new(Cursor::new(plaintext.clone()), CompressionAlgorithm::default())
.read_to_end(&mut compressed)
.await
.expect("compress plaintext");
let mut encrypted = Vec::new();
rustfs_rio::EncryptReader::new(Cursor::new(compressed), key_bytes, base_nonce)
.read_to_end(&mut encrypted)
.await
.expect("encrypt compressed plaintext");
let object_info = ObjectInfo {
bucket: bucket.to_string(),
name: object.to_string(),
size: encrypted.len() as i64,
user_defined: HashMap::from([
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
(
"x-amz-server-side-encryption-customer-key-md5".to_string(),
BASE64_STANDARD.encode(md5_bytes(key_bytes)),
),
(
"x-amz-server-side-encryption-customer-original-size".to_string(),
plaintext.len().to_string(),
),
("x-minio-internal-compression".to_string(), CompressionAlgorithm::default().to_string()),
("x-minio-internal-actual-size".to_string(), plaintext.len().to_string()),
]),
..Default::default()
};
let range = HTTPRangeSpec {
is_suffix_length: false,
start: 5,
end: 11,
};
let (mut reader, offset, length) = GetObjectReader::new(
Box::new(Cursor::new(encrypted.clone())),
Some(range),
&object_info,
&ObjectOptions::default(),
&ssec_headers_from_key(key_bytes),
)
.await
.expect("encrypted+compressed range read should be supported");
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
.await
.expect("read ranged decompressed plaintext");
assert_eq!(offset, 0);
assert_eq!(length, encrypted.len() as i64);
assert_eq!(reader.object_info.size, 7);
assert_eq!(actual, b"fghijkl");
}
}
+32 -9
View File
@@ -383,6 +383,37 @@ impl ObjectInfo {
self.etag.as_ref().is_some_and(|v| v.len() != 32)
}
pub fn is_encrypted(&self) -> bool {
use rustfs_utils::http::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER};
self.user_defined
.keys()
.any(|key| rustfs_utils::http::is_encryption_metadata_key(key))
|| self.user_defined.contains_key(SSEC_ALGORITHM_HEADER)
|| self.user_defined.contains_key(SSEC_KEY_HEADER)
|| self.user_defined.contains_key(SSEC_KEY_MD5_HEADER)
}
pub fn encryption_original_size(&self) -> std::io::Result<Option<i64>> {
if let Some(size_str) = self
.user_defined
.get("x-rustfs-encryption-original-size")
.or_else(|| self.user_defined.get("x-amz-server-side-encryption-customer-original-size"))
&& !size_str.is_empty()
{
let size = size_str
.parse::<i64>()
.map_err(|e| std::io::Error::other(format!("Failed to parse encryption original size: {e}")))?;
return Ok(Some(size));
}
Ok(None)
}
pub fn decrypted_size(&self) -> std::io::Result<i64> {
Ok(self.encryption_original_size()?.unwrap_or(self.size))
}
pub fn get_actual_size(&self) -> std::io::Result<i64> {
if self.actual_size > 0 {
return Ok(self.actual_size);
@@ -410,15 +441,7 @@ impl ObjectInfo {
// Check if object is encrypted
// Managed SSE stores original size in x-rustfs-encryption-original-size metadata
// SSE-C stores original size in x-amz-server-side-encryption-customer-original-size
if let Some(size_str) = self
.user_defined
.get("x-rustfs-encryption-original-size")
.or_else(|| self.user_defined.get("x-amz-server-side-encryption-customer-original-size"))
&& !size_str.is_empty()
{
let size = size_str
.parse::<i64>()
.map_err(|e| std::io::Error::other(format!("Failed to parse encryption original size: {e}")))?;
if let Some(size) = self.encryption_original_size()? {
return Ok(size);
}
+8
View File
@@ -57,6 +57,10 @@ where
finished: false,
}
}
pub fn new_multipart(inner: R, key: [u8; 32], base_nonce: [u8; 12], part_number: usize) -> Self {
Self::new(inner, key, multipart_part_nonce(base_nonce, part_number))
}
}
impl<R> AsyncRead for EncryptReader<R>
@@ -474,6 +478,10 @@ fn derive_block_nonce(base: &[u8; 12], block_index: usize) -> [u8; 12] {
derive_nonce_offset(base, 8, block_index)
}
pub fn multipart_part_nonce(base_nonce: [u8; 12], part_number: usize) -> [u8; 12] {
derive_part_nonce(&base_nonce, part_number)
}
fn derive_part_nonce(base: &[u8; 12], part_number: usize) -> [u8; 12] {
derive_nonce_offset(base, 4, part_number)
}
+1 -1
View File
@@ -88,7 +88,7 @@ mod compress_reader;
pub use compress_reader::{CompressReader, DecompressReader};
mod encrypt_reader;
pub use encrypt_reader::{DecryptReader, EncryptReader};
pub use encrypt_reader::{DecryptReader, EncryptReader, multipart_part_nonce};
mod hardlimit_reader;
pub use hardlimit_reader::HardLimitReader;