fix(ecstore): support MinIO DARE fixture compatibility (#3590)

This commit is contained in:
houseme
2026-06-19 10:13:39 +08:00
committed by GitHub
parent 80b1fca02a
commit 8cf3c0bfbd
13 changed files with 563 additions and 270 deletions
+1
View File
@@ -113,6 +113,7 @@ rustfs-madmin.workspace = true
rustfs-concurrency.workspace = true
reqwest = { workspace = true }
aes-gcm.workspace = true
chacha20poly1305.workspace = true
aws-sdk-s3 = { workspace = true }
urlencoding = { workspace = true }
smallvec = { workspace = true }
@@ -2887,7 +2887,9 @@ mod tests {
use uuid::Uuid;
#[tokio::test]
#[serial]
async fn expiry_enqueue_reports_missed_without_worker_channel() {
let before = global_metrics().report().await.lifecycle_expiry;
let state = ExpiryState::new();
let mut state = state.write().await;
let object = ObjectInfo {
@@ -2904,12 +2906,9 @@ mod tests {
assert!(!queued);
assert_eq!(state.stats.missed_tasks(), 1);
let expiry = global_metrics().report().await.lifecycle_expiry;
assert_eq!(expiry.current_queue_capacity, 0);
assert_eq!(expiry.current_queued, 0);
assert_eq!(expiry.current_active, 0);
assert_eq!(expiry.current_workers, 0);
assert_eq!(expiry.queue_missed, 1);
let after = global_metrics().report().await.lifecycle_expiry;
assert!(after.queue_missed >= before.queue_missed.saturating_add(1));
assert!(after.scanner_missed >= before.scanner_missed.saturating_add(1));
}
#[tokio::test]
+7
View File
@@ -154,6 +154,13 @@ pub fn get_global_endpoints_opt() -> Option<EndpointServerPools> {
GLOBAL_Endpoints.get().cloned()
}
#[cfg(test)]
pub async fn reset_local_disk_test_state() {
GLOBAL_LOCAL_DISK_MAP.write().await.clear();
GLOBAL_LOCAL_DISK_ID_MAP.write().await.clear();
GLOBAL_LOCAL_DISK_SET_DRIVES.write().await.clear();
}
pub async fn is_first_cluster_node_local() -> bool {
get_global_endpoints().first_local()
}
+9 -15
View File
@@ -1082,7 +1082,7 @@ mod tests {
use crate::disk::endpoint::Endpoint;
use crate::disk::local::LocalDisk;
use crate::endpoints::{Endpoints, PoolEndpoints};
use crate::global::{GLOBAL_LOCAL_DISK_ID_MAP, GLOBAL_LOCAL_DISK_MAP, GLOBAL_LOCAL_DISK_SET_DRIVES};
use crate::global::reset_local_disk_test_state;
use crate::store::init_local_disks;
use rustfs_filemeta::FileInfo;
use serial_test::serial;
@@ -1110,12 +1110,6 @@ mod tests {
}
}
async fn reset_local_disk_globals() {
GLOBAL_LOCAL_DISK_MAP.write().await.clear();
GLOBAL_LOCAL_DISK_ID_MAP.write().await.clear();
GLOBAL_LOCAL_DISK_SET_DRIVES.write().await.clear();
}
#[derive(Debug)]
struct TestPeerS3Client {
pools: Option<Vec<usize>>,
@@ -1292,7 +1286,7 @@ mod tests {
#[tokio::test]
#[serial]
async fn local_get_bucket_info_survives_prior_walk_timeout() {
reset_local_disk_globals().await;
reset_local_disk_test_state().await;
let temp_dir = TempDir::new().expect("create temp dir for local peer listing regression");
let disks = init_test_local_disks(&temp_dir, 1, "local-get-bucket-info-survives-prior-walk-timeout").await;
@@ -1337,13 +1331,13 @@ mod tests {
})
.await;
reset_local_disk_globals().await;
reset_local_disk_test_state().await;
}
#[tokio::test]
#[serial]
async fn local_get_bucket_info_requires_local_write_quorum() {
reset_local_disk_globals().await;
reset_local_disk_test_state().await;
let temp_dir = TempDir::new().expect("create temp dir for partial bucket regression");
let disks = init_test_local_disks(&temp_dir, 2, "local-get-bucket-info-requires-local-write-quorum").await;
@@ -1360,13 +1354,13 @@ mod tests {
assert_eq!(err, Error::ErasureWriteQuorum);
reset_local_disk_globals().await;
reset_local_disk_test_state().await;
}
#[tokio::test]
#[serial]
async fn local_peer_filters_disks_by_pool() {
reset_local_disk_globals().await;
reset_local_disk_test_state().await;
let temp_dir = TempDir::new().expect("create temp dir for pool filtered local peer regression");
let disks = init_test_local_disks_for_pools(&temp_dir, &[(0, 2), (1, 2)], "local-peer-filters-disks-by-pool").await;
@@ -1399,13 +1393,13 @@ mod tests {
.expect("pool 1 local listing should succeed against its own disks");
assert!(pool1_buckets.is_empty());
reset_local_disk_globals().await;
reset_local_disk_test_state().await;
}
#[tokio::test]
#[serial]
async fn heal_bucket_local_recreates_missing_bucket_volumes() {
reset_local_disk_globals().await;
reset_local_disk_test_state().await;
let temp_dir = TempDir::new().expect("create temp dir for bucket heal regression");
let disks = init_test_local_disks(&temp_dir, 2, "heal-bucket-local-recreates-missing-bucket-volumes").await;
@@ -1434,7 +1428,7 @@ mod tests {
disk.stat_volume(bucket).await.expect("bucket should exist after heal");
}
reset_local_disk_globals().await;
reset_local_disk_test_state().await;
}
#[test]
+3 -9
View File
@@ -841,17 +841,11 @@ impl ServerPoolsAvailableSpace {
mod tests {
use super::*;
use crate::endpoints::{Endpoints, PoolEndpoints};
use crate::global::{GLOBAL_LOCAL_DISK_ID_MAP, GLOBAL_LOCAL_DISK_MAP, GLOBAL_LOCAL_DISK_SET_DRIVES};
use crate::global::{GLOBAL_LOCAL_DISK_ID_MAP, reset_local_disk_test_state};
use crate::store_init::{connect_load_init_formats, init_disks};
use serial_test::serial;
use tempfile::TempDir;
async fn reset_local_disk_globals() {
GLOBAL_LOCAL_DISK_MAP.write().await.clear();
GLOBAL_LOCAL_DISK_ID_MAP.write().await.clear();
GLOBAL_LOCAL_DISK_SET_DRIVES.write().await.clear();
}
#[tokio::test]
async fn test_get_disk_infos() {
let disks = vec![None, None]; // Empty disks for testing
@@ -906,7 +900,7 @@ mod tests {
#[tokio::test]
#[serial]
async fn test_find_local_disk_by_ref_backfills_uuid_map() {
reset_local_disk_globals().await;
reset_local_disk_test_state().await;
let temp_dir = TempDir::new().expect("create temp dir for local disk ref test");
let disk_paths = (0..4)
@@ -967,7 +961,7 @@ mod tests {
Some(first_disk.endpoint().to_string())
);
reset_local_disk_globals().await;
reset_local_disk_test_state().await;
}
#[tokio::test]
+128 -26
View File
@@ -1,10 +1,14 @@
use super::*;
#[cfg(feature = "rio-v2")]
use aes_gcm::aead::Payload;
use aes_gcm::{
Aes256Gcm, Key, Nonce,
aead::{Aead, KeyInit},
};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
#[cfg(feature = "rio-v2")]
use chacha20poly1305::ChaCha20Poly1305;
#[cfg(feature = "rio-v2")]
use hmac::{Hmac, Mac};
use md5::{Digest, Md5};
use rustfs_kms::{service_manager::get_global_encryption_service, types::ObjectEncryptionContext};
@@ -53,6 +57,8 @@ const DARE_VERSION_20: u8 = 0x20;
#[cfg(feature = "rio-v2")]
const DARE_CIPHER_AES_256_GCM: u8 = 0x00;
#[cfg(feature = "rio-v2")]
const DARE_CIPHER_CHACHA20_POLY1305: u8 = 0x01;
#[cfg(feature = "rio-v2")]
const DARE_HEADER_SIZE: usize = 16;
#[cfg(feature = "rio-v2")]
const DARE_TAG_SIZE: usize = 16;
@@ -1025,6 +1031,35 @@ fn metadata_get<'a>(metadata: &'a HashMap<String, String>, key: &str) -> Option<
})
}
#[cfg(feature = "rio-v2")]
fn is_supported_sealed_object_key_cipher(cipher: u8) -> bool {
matches!(cipher, DARE_CIPHER_AES_256_GCM | DARE_CIPHER_CHACHA20_POLY1305)
}
#[cfg(feature = "rio-v2")]
fn decrypt_sealed_object_key_payload(sealing_key: [u8; 32], header: &[u8], sealed_key: &[u8]) -> Result<Vec<u8>> {
let nonce = &header[4..16];
let ciphertext = &sealed_key[DARE_HEADER_SIZE..];
let aad = &header[..4];
match header[1] {
DARE_CIPHER_AES_256_GCM => {
let cipher = Aes256Gcm::new_from_slice(&sealing_key)
.map_err(|err| Error::other(format!("invalid AES-GCM sealing key: {err}")))?;
let nonce = Nonce::try_from(nonce).map_err(|_| Error::other("invalid sealed object-key package nonce"))?;
cipher.decrypt(&nonce, Payload { msg: ciphertext, aad })
}
DARE_CIPHER_CHACHA20_POLY1305 => {
let cipher = ChaCha20Poly1305::new_from_slice(&sealing_key)
.map_err(|err| Error::other(format!("invalid ChaCha20-Poly1305 sealing key: {err}")))?;
let nonce =
chacha20poly1305::Nonce::try_from(nonce).map_err(|_| Error::other("invalid sealed object-key package nonce"))?;
cipher.decrypt(&nonce, Payload { msg: ciphertext, aad })
}
_ => return Err(Error::other("unsupported sealed object-key DARE header")),
}
.map_err(|err| Error::other(format!("failed to unseal object key: {err}")))
}
async fn resolve_encryption_material(oi: &ObjectInfo, headers: &HeaderMap<HeaderValue>) -> Result<EncryptionMaterial> {
if metadata_get(&oi.user_defined, SSEC_ALGORITHM_HEADER).is_some() {
return resolve_ssec_material(oi, headers);
@@ -1154,7 +1189,7 @@ fn try_unseal_minio_object_key(
return Ok(None);
};
let header = &sealed_key[..DARE_HEADER_SIZE];
if header[0] != DARE_VERSION_20 || header[1] != DARE_CIPHER_AES_256_GCM {
if header[0] != DARE_VERSION_20 || !is_supported_sealed_object_key_cipher(header[1]) {
return Err(Error::other("unsupported sealed object-key DARE header"));
}
if u16::from_le_bytes([header[2], header[3]]) != 31 || header[4] & 0x80 == 0 {
@@ -1162,17 +1197,7 @@ fn try_unseal_minio_object_key(
}
let sealing_key = derive_sealing_key(external_key, iv, managed_sse_domain(metadata), bucket, object);
let cipher = Aes256Gcm::new_from_slice(&sealing_key).map_err(|err| Error::other(format!("invalid sealing key: {err}")))?;
let nonce = Nonce::try_from(&header[4..16]).map_err(|_| Error::other("invalid sealed object-key package nonce"))?;
let plaintext = cipher
.decrypt(
&nonce,
aes_gcm::aead::Payload {
msg: &sealed_key[DARE_HEADER_SIZE..],
aad: &header[..4],
},
)
.map_err(|err| Error::other(format!("failed to unseal object key: {err}")))?;
let plaintext = decrypt_sealed_object_key_payload(sealing_key, header, &sealed_key)?;
let object_key: [u8; 32] = plaintext
.as_slice()
.try_into()
@@ -1888,34 +1913,70 @@ mod tests {
object: &str,
data_key: [u8; 32],
object_key: [u8; 32],
) -> ([u8; 32], Vec<u8>) {
seal_managed_s3_object_key_for_test_with_cipher(bucket, object, data_key, object_key, DARE_CIPHER_AES_256_GCM)
}
#[cfg(feature = "rio-v2")]
fn seal_managed_s3_object_key_for_test_with_cipher(
bucket: &str,
object: &str,
data_key: [u8; 32],
object_key: [u8; 32],
cipher_id: u8,
) -> ([u8; 32], Vec<u8>) {
let iv = [0x24u8; SEALED_KEY_IV_SIZE];
let sealing_key = derive_sealing_key(data_key, iv, "SSE-S3", bucket, object);
let cipher = Aes256Gcm::new_from_slice(&sealing_key).expect("valid sealing key");
let mut header = [0u8; DARE_HEADER_SIZE];
header[0] = DARE_VERSION_20;
header[1] = DARE_CIPHER_AES_256_GCM;
header[1] = cipher_id;
header[2..4].copy_from_slice(&31u16.to_le_bytes());
header[4] = 0x80;
header[5..16].copy_from_slice(&[0x46u8; 11]);
let nonce = Nonce::try_from(&header[4..16]).expect("valid nonce");
let ciphertext = match cipher_id {
DARE_CIPHER_AES_256_GCM => {
let cipher = Aes256Gcm::new_from_slice(&sealing_key).expect("valid sealing key");
let nonce = Nonce::try_from(&header[4..16]).expect("valid nonce");
cipher
.encrypt(
&nonce,
Payload {
msg: &object_key,
aad: &header[..4],
},
)
.expect("seal managed object key")
}
DARE_CIPHER_CHACHA20_POLY1305 => {
let cipher = ChaCha20Poly1305::new_from_slice(&sealing_key).expect("valid sealing key");
let nonce = chacha20poly1305::Nonce::try_from(&header[4..16]).expect("valid nonce");
cipher
.encrypt(
&nonce,
Payload {
msg: &object_key,
aad: &header[..4],
},
)
.expect("seal managed object key")
}
_ => panic!("unsupported test cipher"),
};
let mut sealed = header.to_vec();
sealed.extend_from_slice(
&cipher
.encrypt(
&nonce,
aes_gcm::aead::Payload {
msg: &object_key,
aad: &header[..4],
},
)
.expect("seal managed object key"),
);
sealed.extend_from_slice(&ciphertext);
(iv, sealed)
}
#[cfg(feature = "rio-v2")]
#[test]
fn test_supported_sealed_object_key_cipher_accepts_current_minio_fixture_value() {
assert!(is_supported_sealed_object_key_cipher(DARE_CIPHER_AES_256_GCM));
assert!(is_supported_sealed_object_key_cipher(DARE_CIPHER_CHACHA20_POLY1305));
assert!(!is_supported_sealed_object_key_cipher(0x02));
}
#[tokio::test]
async fn resolve_managed_material_accepts_case_insensitive_metadata_keys() {
async_with_vars([("__RUSTFS_SSE_SIMPLE_CMK", Some(BASE64_STANDARD.encode([0u8; 32])))], async {
@@ -1937,6 +1998,47 @@ mod tests {
.await;
}
#[cfg(feature = "rio-v2")]
#[tokio::test]
async fn resolve_managed_material_accepts_chacha20_poly1305_header_variant() {
async_with_vars([("__RUSTFS_SSE_SIMPLE_CMK", Some(BASE64_STANDARD.encode([0u8; 32])))], async {
let data_key = [0x24; 32];
let object_key = [0x33; 32];
let (iv, sealed_key) = seal_managed_s3_object_key_for_test_with_cipher(
"bucket",
"object",
data_key,
object_key,
DARE_CIPHER_CHACHA20_POLY1305,
);
let encrypted_dek = encrypt_managed_dek_for_test(data_key, [0u8; 32]);
let metadata = HashMap::from([
(
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(sealed_key),
),
(MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode(iv)),
(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
),
(
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(encrypted_dek.as_bytes()),
),
(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), "default".to_string()),
]);
let material = resolve_managed_material("bucket", "object", &metadata)
.await
.expect("managed material should accept current MinIO header variant");
assert_eq!(material.key_kind, EncryptionKeyKind::Object);
assert_eq!(material.key_bytes, object_key);
})
.await;
}
#[tokio::test]
async fn resolve_encryption_material_accepts_case_insensitive_metadata_keys() {
async_with_vars([("__RUSTFS_SSE_SIMPLE_CMK", Some(BASE64_STANDARD.encode([0u8; 32])))], async {
@@ -7,12 +7,13 @@ use std::path::{Path, PathBuf};
use rustfs_ecstore::bitrot::create_bitrot_reader;
use rustfs_ecstore::disk::endpoint::Endpoint;
use rustfs_ecstore::disk::{DiskAPI as _, DiskOption, new_disk};
use rustfs_ecstore::erasure_coding::Erasure;
use rustfs_ecstore::store_api::{GetObjectReader, ObjectInfo, ObjectOptions};
use rustfs_filemeta::{FileInfo, FileInfoOpts, get_file_info};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use temp_env::async_with_vars;
use tokio::io::AsyncReadExt;
use tokio::io::{AsyncReadExt, AsyncWrite};
#[derive(Debug, Deserialize)]
struct ManifestRecord {
@@ -21,6 +22,30 @@ struct ManifestRecord {
backend_files: Vec<String>,
}
#[derive(Default)]
struct VecAsyncWriter {
bytes: Vec<u8>,
}
impl AsyncWrite for VecAsyncWriter {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
self.bytes.extend_from_slice(buf);
std::task::Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: std::pin::Pin<&mut Self>, _cx: &mut std::task::Context<'_>) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(self: std::pin::Pin<&mut Self>, _cx: &mut std::task::Context<'_>) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
}
fn fixture_root() -> PathBuf {
std::env::var_os("RUSTFS_MINIO_FIXTURE_ROOT")
.map(PathBuf::from)
@@ -93,23 +118,32 @@ fn sha256_hex(bytes: &[u8]) -> String {
}
async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, file_info: &FileInfo) -> Vec<u8> {
let disk_root = case_dir.join("backend").join("disk1");
let disk_root_str = disk_root
.to_str()
.unwrap_or_else(|| panic!("non-utf8 disk root {}", disk_root.display()));
let mut endpoint = Endpoint::try_from(disk_root_str).expect("fixture disk endpoint");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(0);
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("open fixture disk");
let mut disks = Vec::with_capacity(file_info.erasure.distribution.len());
for disk_number in 1..=file_info.erasure.distribution.len() {
let disk_root = case_dir.join("backend").join(format!("disk{disk_number}"));
let disk_root_str = disk_root
.to_str()
.unwrap_or_else(|| panic!("non-utf8 disk root {}", disk_root.display()));
let mut endpoint = Endpoint::try_from(disk_root_str).expect("fixture disk endpoint");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_number - 1);
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.unwrap_or_else(|err| panic!("open fixture disk {disk_number}: {err}"));
disks.push(disk);
}
let mut disk_order = vec![None; disks.len()];
for (idx, disk) in disks.iter().enumerate() {
let block_index = file_info.erasure.distribution[idx];
disk_order[block_index - 1] = Some(disk);
}
let data_dir = file_info
.data_dir
.as_ref()
@@ -119,37 +153,42 @@ async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, fil
for part in &file_info.parts {
let checksum_info = file_info.erasure.get_checksum_info(part.number);
let path = format!("{}/{}/part.{}", manifest.object, data_dir, part.number);
let mut reader = create_bitrot_reader(
None,
Some(&disk),
&manifest.bucket,
&path,
0,
part.size,
file_info.erasure.shard_size(),
checksum_info.algorithm.clone(),
false,
false,
)
.await
.unwrap_or_else(|err| panic!("create bitrot reader for {path}: {err:?}"))
.unwrap_or_else(|| panic!("missing bitrot reader for {path}"));
let mut block = vec![0u8; file_info.erasure.shard_size()];
loop {
let n = reader
.read(&mut block)
.await
.unwrap_or_else(|err| panic!("read decoded encrypted bytes from {path}: {err}"));
if n == 0 {
break;
}
encrypted.extend_from_slice(&block[..n]);
if n < block.len() {
break;
}
let shard_read_len = file_info.erasure.shard_file_size(part.size as i64);
let mut readers = Vec::with_capacity(disks.len());
for (idx, disk) in disk_order.iter().enumerate() {
let reader = create_bitrot_reader(
None,
*disk,
&manifest.bucket,
&path,
0,
shard_read_len as usize,
file_info.erasure.shard_size(),
checksum_info.algorithm.clone(),
false,
false,
)
.await
.unwrap_or_else(|err| panic!("create bitrot reader for disk{} {path}: {err:?}", idx + 1));
readers.push(reader);
}
let erasure = Erasure::new(
file_info.erasure.data_blocks,
file_info.erasure.parity_blocks,
file_info.erasure.block_size,
);
let mut writer = VecAsyncWriter::default();
let (written, err) = erasure.decode(&mut writer, readers, 0, part.size, part.size).await;
if let Some(err) = err {
panic!("decode erasure shards for {path}: {err}");
}
assert_eq!(written, part.size, "decoded part size should match xl.meta part size");
encrypted.extend_from_slice(&writer.bytes);
}
for disk in disks {
disk.close().await.expect("close fixture disk");
}
disk.close().await.expect("close fixture disk");
encrypted
}
+1
View File
@@ -31,6 +31,7 @@ workspace = true
[dependencies]
aes-gcm.workspace = true
bytes.workspace = true
chacha20poly1305.workspace = true
hex.workspace = true
hmac.workspace = true
minlz = "1.1.0"
+114 -19
View File
@@ -12,8 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use aes_gcm::aead::Aead;
use aes_gcm::aead::{Aead, Payload};
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use chacha20poly1305::ChaCha20Poly1305;
use hmac::{Hmac, Mac};
use pin_project_lite::pin_project;
use rand::RngExt;
@@ -27,12 +28,49 @@ use tokio::io::{AsyncRead, ReadBuf};
const DARE_VERSION_20: u8 = 0x20;
const DARE_CIPHER_AES_256_GCM: u8 = 0x00;
const DARE_CIPHER_CHACHA20_POLY1305: u8 = 0x01;
const DARE_HEADER_SIZE: usize = 16;
const DARE_TAG_SIZE: usize = 16;
const DARE_PAYLOAD_SIZE: usize = 64 * 1024;
type HmacSha256 = Hmac<Sha256>;
enum DareDecryptCipher {
Aes256Gcm(Box<Aes256Gcm>),
ChaCha20Poly1305(ChaCha20Poly1305),
}
impl DareDecryptCipher {
fn new(cipher_id: u8, key: [u8; 32]) -> io::Result<Self> {
match cipher_id {
DARE_CIPHER_AES_256_GCM => Aes256Gcm::new_from_slice(&key)
.map(Box::new)
.map(Self::Aes256Gcm)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, format!("invalid AES-GCM key: {err}"))),
DARE_CIPHER_CHACHA20_POLY1305 => ChaCha20Poly1305::new_from_slice(&key)
.map(Self::ChaCha20Poly1305)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, format!("invalid ChaCha20-Poly1305 key: {err}"))),
_ => Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported DARE cipher suite")),
}
}
fn cipher_id(&self) -> u8 {
match self {
Self::Aes256Gcm(_) => DARE_CIPHER_AES_256_GCM,
Self::ChaCha20Poly1305(_) => DARE_CIPHER_CHACHA20_POLY1305,
}
}
fn decrypt(&self, nonce: &[u8; 12], ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>, aes_gcm::aead::Error> {
match self {
Self::Aes256Gcm(cipher) => cipher.decrypt(&Nonce::from(*nonce), Payload { msg: ciphertext, aad }),
Self::ChaCha20Poly1305(cipher) => {
cipher.decrypt(&chacha20poly1305::Nonce::from(*nonce), Payload { msg: ciphertext, aad })
}
}
}
}
#[derive(Clone, Copy)]
enum MultipartKeySource {
LegacyNonce { base_nonce: [u8; 12] },
@@ -215,7 +253,8 @@ pin_project! {
pub struct DecryptReader<R> {
#[pin]
inner: R,
cipher: Aes256Gcm,
key: [u8; 32],
cipher: Option<DareDecryptCipher>,
expected_base_nonce: Option<[u8; 12]>,
sequence_number: u32,
multipart_parts: Vec<usize>,
@@ -250,7 +289,8 @@ where
pub fn new_with_object_key_and_sequence(inner: R, object_key: [u8; 32], sequence_number: u32) -> Self {
Self {
inner,
cipher: Aes256Gcm::new_from_slice(&object_key).expect("valid AES-256-GCM key"),
key: object_key,
cipher: None,
expected_base_nonce: None,
sequence_number,
multipart_parts: Vec::new(),
@@ -273,7 +313,8 @@ where
pub fn new_with_sequence(inner: R, key: [u8; 32], nonce: [u8; 12], sequence_number: u32) -> Self {
Self {
inner,
cipher: Aes256Gcm::new_from_slice(&key).expect("valid AES-256-GCM key"),
key,
cipher: None,
expected_base_nonce: Some(nonce),
sequence_number,
multipart_parts: Vec::new(),
@@ -311,7 +352,8 @@ where
let first_part = multipart_parts.first().copied().unwrap_or(1);
Self {
inner,
cipher: Aes256Gcm::new_from_slice(&key).expect("valid AES-256-GCM key"),
key,
cipher: None,
expected_base_nonce: Some(multipart_part_nonce(base_nonce, first_part)),
sequence_number,
multipart_parts,
@@ -341,7 +383,8 @@ where
let first_key = derive_part_key(object_key, first_part as u32);
Self {
inner,
cipher: Aes256Gcm::new_from_slice(&first_key).expect("valid AES-256-GCM key"),
key: first_key,
cipher: None,
expected_base_nonce: None,
sequence_number,
multipart_parts,
@@ -395,7 +438,8 @@ where
}
Some(MultipartKeySource::ObjectKey { object_key }) => {
let part_key = derive_part_key(object_key, next_part as u32);
*this.cipher = Aes256Gcm::new_from_slice(&part_key).expect("valid AES-256-GCM key");
*this.key = part_key;
*this.cipher = None;
*this.expected_base_nonce = None;
}
None => {}
@@ -433,6 +477,12 @@ where
}
let header = this.header_buf;
if header[0] != DARE_VERSION_20 {
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported DARE version")));
}
if !matches!(header[1], DARE_CIPHER_AES_256_GCM | DARE_CIPHER_CHACHA20_POLY1305) {
return Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported DARE cipher suite")));
}
let payload_len = usize::from(u16::from_le_bytes([header[2], header[3]])) + 1;
let package_len = payload_len + DARE_TAG_SIZE;
if payload_len == 0 || payload_len > DARE_PAYLOAD_SIZE {
@@ -441,7 +491,10 @@ where
if !is_final_header(*header) && payload_len != DARE_PAYLOAD_SIZE {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
"non-final DARE package must carry a full 64KiB payload",
format!(
"non-final DARE package must carry a full 64KiB payload: cipher={}, payload_len={}, sequence_number={}, header={:02x?}",
header[1], payload_len, *this.sequence_number, header
),
)));
}
if this.ciphertext_buf.len() < package_len {
@@ -469,6 +522,7 @@ where
}
match open_dare_package(
*this.key,
this.cipher,
*this.sequence_number,
*this.expected_base_nonce,
@@ -573,7 +627,8 @@ fn build_dare_package(
}
fn open_dare_package(
cipher: &Aes256Gcm,
key: [u8; 32],
cipher: &mut Option<DareDecryptCipher>,
sequence_number: u32,
expected_base_nonce: Option<[u8; 12]>,
header: [u8; DARE_HEADER_SIZE],
@@ -583,9 +638,19 @@ fn open_dare_package(
if header[0] != DARE_VERSION_20 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported DARE version"));
}
if header[1] != DARE_CIPHER_AES_256_GCM {
if !matches!(header[1], DARE_CIPHER_AES_256_GCM | DARE_CIPHER_CHACHA20_POLY1305) {
return Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported DARE cipher suite"));
}
if let Some(cipher) = cipher.as_ref() {
if cipher.cipher_id() != header[1] {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"DARE package cipher suite does not match the stream reference cipher",
));
}
} else {
*cipher = Some(DareDecryptCipher::new(header[1], key)?);
}
let header_nonce: [u8; 12] = header[4..16].try_into().expect("nonce slice");
if let Some(expected_base_nonce) = expected_base_nonce {
let masked_expected = apply_final_flag(expected_base_nonce, is_final_header(header));
@@ -608,16 +673,10 @@ fn open_dare_package(
let mut package_nonce = header_nonce;
xor_sequence_into_nonce(&mut package_nonce, sequence_number);
let nonce = Nonce::try_from(package_nonce.as_slice())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid DARE nonce length"))?;
let plaintext = cipher
.decrypt(
&nonce,
aes_gcm::aead::Payload {
msg: ciphertext,
aad: &header[..4],
},
)
.as_ref()
.expect("DARE cipher is initialized")
.decrypt(&package_nonce, ciphertext, &header[..4])
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, format!("DARE authentication failed: {err}")))?;
Ok((plaintext, *current_ref, is_final_header(header)))
@@ -707,6 +766,42 @@ mod tests {
assert_eq!(decrypted, plaintext);
}
#[tokio::test]
async fn decrypt_reader_accepts_chacha20_poly1305_dare_v2_package() {
let object_key = [0x72u8; 32];
let plaintext = b"minio may emit ChaCha20-Poly1305 DARE packages".to_vec();
let mut header = [0u8; DARE_HEADER_SIZE];
header[0] = DARE_VERSION_20;
header[1] = DARE_CIPHER_CHACHA20_POLY1305;
header[2..4].copy_from_slice(&(u16::try_from(plaintext.len() - 1).expect("test payload fits")).to_le_bytes());
header[4] = 0x80;
header[5..16].copy_from_slice(&[0x27; 11]);
let cipher = ChaCha20Poly1305::new_from_slice(&object_key).expect("valid test key");
let nonce = chacha20poly1305::Nonce::try_from(&header[4..16]).expect("valid test nonce");
let ciphertext = cipher
.encrypt(
&nonce,
Payload {
msg: &plaintext,
aad: &header[..4],
},
)
.expect("encrypt chacha DARE package");
let mut encrypted = header.to_vec();
encrypted.extend_from_slice(&ciphertext);
let mut decrypted = Vec::new();
DecryptReader::new_with_object_key(Cursor::new(encrypted), object_key)
.read_to_end(&mut decrypted)
.await
.expect("decrypt chacha DARE package");
assert_eq!(decrypted, plaintext);
}
#[test]
fn derive_part_key_matches_minio_test_vectors() {
assert_eq!(