mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-02 05:14:35 +08:00
fix(rio): give each encrypted multipart part write its own key
Direct-mode multipart uploads (SSE-C, SSE-S3, SSE-KMS in the default build) derive every part's nonces from one per-upload data key, base nonce and the part number, so writing a part number again within the same upload repeated its (key, nonce) sequence. Add a part-keyed segment layout to the legacy rio family: a part write opens with a frame of type 0x03 carrying a random 32-byte salt, and the part's v2 frames are sealed under HMAC-SHA256(data key, label || part number || salt). The decrypt reader accepts the frame only at the start of a multipart part segment, requires v2 frames and the authenticated final frame after it, and keeps decrypting legacy v1/v2 parts, so one object may mix both layouts and part-boundary range seeks keep working. Writes use the layout only when RUSTFS_ENCRYPTION_MULTIPART_PART_KEY is set (default off), mirroring RUSTFS_ENCRYPTION_FRAME_V2: nodes without part-keyed read support cannot decrypt these parts, so the switch is enabled after the whole fleet and every RustFS target receiving raw ciphertext has upgraded. ETags, error codes and the S3 API are unchanged.
This commit is contained in:
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Security
|
||||
|
||||
- **Per-write keys for encrypted multipart parts**: parts of an SSE-C, SSE-S3 or SSE-KMS multipart upload share the upload's data key and base nonce, so uploading the same part number again within one upload reused that part's AES-GCM nonces. A new part-keyed segment layout opens each part write with a random salt (frame type `0x03`) and seals the part's v2 frames under a key derived from the salt, the part number and the data key. Its write switch `RUSTFS_ENCRYPTION_MULTIPART_PART_KEY` is **off by default** for rolling-upgrade safety: nodes without part-keyed read support cannot decrypt these parts, and ciphertext travels verbatim through transition, decommission and SSE-C replication passthrough, so enable it only after every node — and every RustFS warm/replication target that receives raw ciphertext — runs this release. Reading part-keyed parts needs no switch, existing parts stay readable, and one object may mix both layouts. ETags, S3 error codes and the API are unchanged.
|
||||
|
||||
- **Header-signed SigV4 requests honour only signed headers** (GHSA-xm99-m3gq-83g8): a request authenticated with a SigV4 `Authorization` header that carries an `x-amz-*` request header not listed in its `SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3 and the presigned rule from GHSA-g8w9-qw9q-fghr. Previously anyone holding one header-signed `PutObject` request could add an unsigned `x-amz-copy-source` and turn it into a `CopyObject` that ran with the signer's permissions, copying any object the signer could read into the target. An `Authorization` header whose algorithm token is not `AWS4-HMAC-SHA256` is now rejected instead of being verified as SigV4. The request-envelope headers `x-amz-content-sha256`, `x-amz-decoded-content-length`, `x-amz-trailer` and `x-amz-checksum-algorithm` (the same set the upstream `s3s` fix exempts) and `x-amz-cf-id` (CloudFront) remain tolerated unsigned; AWS SDKs and RustFS's own signers already sign every other `x-amz-*` header. SigV2, JWT and anonymous requests are unchanged.
|
||||
|
||||
### Replication
|
||||
|
||||
Generated
+1
@@ -10609,6 +10609,7 @@ dependencies = [
|
||||
"faster-hex",
|
||||
"futures",
|
||||
"hex-simd",
|
||||
"hmac 0.13.0",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"http-body-util",
|
||||
|
||||
@@ -346,6 +346,42 @@ pub(crate) fn encryption_frame_v2_enabled() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write-side switch for part-keyed multipart segments.
|
||||
///
|
||||
/// Every part of a Direct-mode multipart upload shares one data key and base
|
||||
/// nonce, so the legacy and v2 part layouts derive the same nonce sequence
|
||||
/// each time a part number is written. With this switch on, each part write
|
||||
/// opens with a random salt and seals its frames under a key derived from it,
|
||||
/// so rewriting a part number never reuses a (key, nonce) pair.
|
||||
///
|
||||
/// Off by default for rolling-upgrade safety, like
|
||||
/// [`ENV_RUSTFS_ENCRYPTION_FRAME_V2`]: nodes without part-keyed read support
|
||||
/// reject these segments, and encrypted ciphertext travels verbatim through
|
||||
/// transition, decommission and SSE-C replication passthrough. Turn it on only
|
||||
/// after every node (and every RustFS warm/replication target that receives
|
||||
/// raw ciphertext) runs a release that reads part-keyed segments. Reading them
|
||||
/// needs no switch — the decrypt reader dispatches on the frame type byte.
|
||||
// RUSTFS_COMPAT_TODO(multipart-part-key-default-off-window): staged rollout switch for part-keyed multipart encryption, flipping the default to enabled on retirement. Remove after the minimum supported direct-upgrade release reads part-keyed segments.
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
pub(crate) const ENV_RUSTFS_ENCRYPTION_MULTIPART_PART_KEY: &str = "RUSTFS_ENCRYPTION_MULTIPART_PART_KEY";
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
pub(crate) const DEFAULT_RUSTFS_ENCRYPTION_MULTIPART_PART_KEY: bool = false;
|
||||
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
pub(crate) fn encryption_multipart_part_key_enabled() -> bool {
|
||||
#[cfg(test)]
|
||||
{
|
||||
rustfs_utils::get_env_bool(ENV_RUSTFS_ENCRYPTION_MULTIPART_PART_KEY, DEFAULT_RUSTFS_ENCRYPTION_MULTIPART_PART_KEY)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*CACHED.get_or_init(|| {
|
||||
rustfs_utils::get_env_bool(ENV_RUSTFS_ENCRYPTION_MULTIPART_PART_KEY, DEFAULT_RUSTFS_ENCRYPTION_MULTIPART_PART_KEY)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum WriteEncryptionMode {
|
||||
SinglepartObjectKey,
|
||||
@@ -470,7 +506,9 @@ impl WritePlan {
|
||||
multipart_part_number,
|
||||
} => {
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
let encrypt_reader = if encryption_frame_v2_enabled() {
|
||||
let encrypt_reader = if encryption_multipart_part_key_enabled() {
|
||||
EncryptReader::new_multipart_part_keyed(reader, encryption.key_bytes, base_nonce, multipart_part_number)
|
||||
} else if encryption_frame_v2_enabled() {
|
||||
EncryptReader::new_multipart_v2(reader, encryption.key_bytes, base_nonce, multipart_part_number)
|
||||
} else {
|
||||
EncryptReader::new_multipart(reader, encryption.key_bytes, base_nonce, multipart_part_number)
|
||||
@@ -646,6 +684,118 @@ mod tests {
|
||||
assert_eq!(actual, plaintext);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
async fn write_multipart_part(
|
||||
plaintext: &[u8],
|
||||
key_bytes: [u8; 32],
|
||||
base_nonce: [u8; 12],
|
||||
part_number: usize,
|
||||
) -> (Vec<u8>, String) {
|
||||
let actual_size = plaintext.len() as i64;
|
||||
let reader = HashReader::from_stream(Cursor::new(plaintext.to_vec()), actual_size, actual_size, None, None, false)
|
||||
.expect("create hash reader");
|
||||
let mut transformed = WritePlan::new()
|
||||
.with_encryption(WriteEncryption::multipart(key_bytes, base_nonce, part_number))
|
||||
.apply(reader, actual_size)
|
||||
.expect("apply multipart encryption plan");
|
||||
let mut ciphertext = Vec::new();
|
||||
transformed
|
||||
.read_to_end(&mut ciphertext)
|
||||
.await
|
||||
.expect("read transformed ciphertext");
|
||||
let etag = transformed.try_resolve_etag().expect("part ETag resolves");
|
||||
(ciphertext, etag)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
#[tokio::test]
|
||||
async fn write_plan_part_key_switch_gives_each_part_write_its_own_key() {
|
||||
let key_bytes = [0x5Au8; 32];
|
||||
let base_nonce = [0xA5u8; 12];
|
||||
let part_number = 4;
|
||||
let first = b"first-part-write-".repeat(1024);
|
||||
let second = b"other-part-write-".repeat(1024);
|
||||
|
||||
// Default: the rolling-upgrade-safe legacy layout, which repeats the
|
||||
// part's nonce sequence on every write of the same part number.
|
||||
let (legacy_one, legacy_etag) = temp_env::async_with_vars(
|
||||
[(ENV_RUSTFS_ENCRYPTION_MULTIPART_PART_KEY, None::<&str>)],
|
||||
write_multipart_part(&first, key_bytes, base_nonce, part_number),
|
||||
)
|
||||
.await;
|
||||
let (legacy_retry, _) = temp_env::async_with_vars(
|
||||
[(ENV_RUSTFS_ENCRYPTION_MULTIPART_PART_KEY, None::<&str>)],
|
||||
write_multipart_part(&first, key_bytes, base_nonce, part_number),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(legacy_one[0], 0x00, "the default writer keeps the legacy v1 frame layout");
|
||||
assert_eq!(legacy_one, legacy_retry);
|
||||
|
||||
let (keyed_one, keyed_etag) = temp_env::async_with_vars(
|
||||
[(ENV_RUSTFS_ENCRYPTION_MULTIPART_PART_KEY, Some("true"))],
|
||||
write_multipart_part(&first, key_bytes, base_nonce, part_number),
|
||||
)
|
||||
.await;
|
||||
let (keyed_retry, keyed_retry_etag) = temp_env::async_with_vars(
|
||||
[(ENV_RUSTFS_ENCRYPTION_MULTIPART_PART_KEY, Some("true"))],
|
||||
write_multipart_part(&first, key_bytes, base_nonce, part_number),
|
||||
)
|
||||
.await;
|
||||
let (keyed_other, _) = temp_env::async_with_vars(
|
||||
[(ENV_RUSTFS_ENCRYPTION_MULTIPART_PART_KEY, Some("true"))],
|
||||
write_multipart_part(&second, key_bytes, base_nonce, part_number),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(keyed_one[0], 0x03, "a part-keyed segment opens with its key frame");
|
||||
assert_ne!(keyed_one[8..40], keyed_retry[8..40], "every part write draws a fresh salt");
|
||||
assert_ne!(keyed_one[8..40], keyed_other[8..40]);
|
||||
assert_ne!(keyed_one, keyed_retry);
|
||||
// The part ETag stays the plaintext MD5: unchanged by the layout.
|
||||
assert_eq!(keyed_etag, legacy_etag);
|
||||
assert_eq!(keyed_retry_etag, legacy_etag);
|
||||
|
||||
// Legacy and part-keyed parts read back through one multipart reader.
|
||||
let (legacy_two, _) = write_multipart_part(&second, key_bytes, base_nonce, part_number + 1).await;
|
||||
let stream = [keyed_one.as_slice(), legacy_two.as_slice()].concat();
|
||||
let mut actual = Vec::new();
|
||||
DecryptReader::new_multipart(Cursor::new(stream), key_bytes, base_nonce, vec![part_number, part_number + 1])
|
||||
.read_to_end(&mut actual)
|
||||
.await
|
||||
.expect("decrypt mixed part layouts");
|
||||
assert_eq!(actual, [first.as_slice(), second.as_slice()].concat());
|
||||
|
||||
// Compressed multipart parts are encrypted after compression; the
|
||||
// part-keyed layout wraps that stream the same way.
|
||||
let compressed_ciphertext =
|
||||
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_MULTIPART_PART_KEY, Some("true"))], async {
|
||||
let actual_size = first.len() as i64;
|
||||
let reader = HashReader::from_stream(Cursor::new(first.clone()), actual_size, actual_size, None, None, false)
|
||||
.expect("create hash reader");
|
||||
let mut transformed = WritePlan::new()
|
||||
.with_compression(CompressionAlgorithm::default())
|
||||
.with_encryption(WriteEncryption::multipart(key_bytes, base_nonce, part_number))
|
||||
.apply(reader, actual_size)
|
||||
.expect("apply compression and part-keyed encryption plan");
|
||||
let mut ciphertext = Vec::new();
|
||||
transformed
|
||||
.read_to_end(&mut ciphertext)
|
||||
.await
|
||||
.expect("read transformed ciphertext");
|
||||
ciphertext
|
||||
})
|
||||
.await;
|
||||
assert_eq!(compressed_ciphertext[0], 0x03);
|
||||
let decrypt_reader =
|
||||
DecryptReader::new_multipart(Cursor::new(compressed_ciphertext), key_bytes, base_nonce, vec![part_number]);
|
||||
let mut decompressed = DecompressReader::new(Box::new(decrypt_reader), CompressionAlgorithm::default());
|
||||
let mut actual = Vec::new();
|
||||
decompressed
|
||||
.read_to_end(&mut actual)
|
||||
.await
|
||||
.expect("decrypt and decompress part-keyed stream");
|
||||
assert_eq!(actual, first);
|
||||
}
|
||||
|
||||
#[cfg(feature = "rio-v2")]
|
||||
#[tokio::test]
|
||||
async fn write_plan_supports_singlepart_object_key_encryption_roundtrip() {
|
||||
|
||||
@@ -3078,26 +3078,58 @@ mod tests {
|
||||
object: &str,
|
||||
key_bytes: [u8; 32],
|
||||
part_plain_sizes: &[usize],
|
||||
user_defined: HashMap<String, String>,
|
||||
) -> LegacyMultipartFixture {
|
||||
let layouts = vec![FixturePartLayout::Legacy; part_plain_sizes.len()];
|
||||
build_multipart_fixture_with_layouts(bucket, object, key_bytes, part_plain_sizes, &layouts, user_defined).await
|
||||
}
|
||||
|
||||
/// Stored layout of one fixture part: the legacy v1 segment, or the
|
||||
/// part-keyed v2 segment written under `RUSTFS_ENCRYPTION_MULTIPART_PART_KEY`.
|
||||
#[derive(Clone, Copy)]
|
||||
enum FixturePartLayout {
|
||||
Legacy,
|
||||
PartKeyed,
|
||||
}
|
||||
|
||||
async fn build_multipart_fixture_with_layouts(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
key_bytes: [u8; 32],
|
||||
part_plain_sizes: &[usize],
|
||||
layouts: &[FixturePartLayout],
|
||||
mut user_defined: HashMap<String, String>,
|
||||
) -> LegacyMultipartFixture {
|
||||
assert_eq!(part_plain_sizes.len(), layouts.len());
|
||||
let mut plaintext = Vec::new();
|
||||
let mut ciphertext = Vec::new();
|
||||
let mut parts = Vec::new();
|
||||
let mut part_physical_sizes = Vec::new();
|
||||
|
||||
for (part_index, &part_plain_size) in part_plain_sizes.iter().enumerate() {
|
||||
for (part_index, (&part_plain_size, layout)) in part_plain_sizes.iter().zip(layouts).enumerate() {
|
||||
let part_number = part_index + 1;
|
||||
let part_plain = legacy_fixture_part_plaintext(part_number, part_plain_size);
|
||||
let mut part_cipher = Vec::new();
|
||||
rustfs_rio::EncryptReader::new_multipart(
|
||||
Cursor::new(part_plain.clone()),
|
||||
key_bytes,
|
||||
LEGACY_FIXTURE_BASE_NONCE,
|
||||
part_number,
|
||||
)
|
||||
.read_to_end(&mut part_cipher)
|
||||
.await
|
||||
.expect("encrypt multipart fixture part");
|
||||
match layout {
|
||||
FixturePartLayout::Legacy => rustfs_rio::EncryptReader::new_multipart(
|
||||
Cursor::new(part_plain.clone()),
|
||||
key_bytes,
|
||||
LEGACY_FIXTURE_BASE_NONCE,
|
||||
part_number,
|
||||
)
|
||||
.read_to_end(&mut part_cipher)
|
||||
.await
|
||||
.expect("encrypt multipart fixture part"),
|
||||
FixturePartLayout::PartKeyed => rustfs_rio::EncryptReader::new_multipart_part_keyed(
|
||||
Cursor::new(part_plain.clone()),
|
||||
key_bytes,
|
||||
LEGACY_FIXTURE_BASE_NONCE,
|
||||
part_number,
|
||||
)
|
||||
.read_to_end(&mut part_cipher)
|
||||
.await
|
||||
.expect("encrypt part-keyed multipart fixture part"),
|
||||
};
|
||||
|
||||
parts.push(ObjectPartInfo {
|
||||
number: part_number,
|
||||
@@ -3486,6 +3518,71 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn part_keyed_and_mixed_ssec_multipart_reads_are_byte_exact() {
|
||||
use FixturePartLayout::{Legacy, PartKeyed};
|
||||
let key_bytes = [0x74; 32];
|
||||
let sizes = [20_000, 9_000, 5_000];
|
||||
let total_plaintext: usize = sizes.iter().sum();
|
||||
let headers = ssec_headers_from_key(key_bytes);
|
||||
for layouts in [
|
||||
[PartKeyed, PartKeyed, PartKeyed],
|
||||
[Legacy, PartKeyed, Legacy],
|
||||
[PartKeyed, Legacy, PartKeyed],
|
||||
] {
|
||||
let fixture = build_multipart_fixture_with_layouts(
|
||||
"bucket",
|
||||
"part-keyed-multipart",
|
||||
key_bytes,
|
||||
&sizes,
|
||||
&layouts,
|
||||
legacy_ssec_multipart_metadata(key_bytes, total_plaintext),
|
||||
)
|
||||
.await;
|
||||
let total = fixture.plaintext.len() as i64;
|
||||
let starts = [0, fixture.physical_part_start(1), fixture.physical_part_start(2)];
|
||||
let ranges: [(i64, i64, usize); 9] = [
|
||||
(0, 9, starts[0]),
|
||||
(8_191, 8_193, starts[0]),
|
||||
(19_999, 20_000, starts[0]),
|
||||
(20_000, 20_000, starts[1]),
|
||||
(20_100, 20_199, starts[1]),
|
||||
(20_000, 29_100, starts[1]),
|
||||
(28_999, 29_000, starts[1]),
|
||||
(33_900, 33_999, starts[2]),
|
||||
(0, total - 1, starts[0]),
|
||||
];
|
||||
|
||||
for seek in ["true", "false"] {
|
||||
async_with_vars([(ENV_RUSTFS_ENCRYPTED_RANGE_SEEK, Some(seek))], async {
|
||||
let opts = ObjectOptions::default();
|
||||
for (start, end, part_start) in ranges {
|
||||
let label = format!("seek={seek} range {start}-{end}");
|
||||
let (body, offset, _, reported) =
|
||||
read_via_seek_window(&fixture, Some(range(start, end)), &opts, &headers).await;
|
||||
let expected = &fixture.plaintext[start as usize..=end as usize];
|
||||
assert_eq!(body, expected, "{label}: body bytes");
|
||||
assert_eq!(reported, end - start + 1, "{label}: reported size");
|
||||
let expected_offset = if seek == "true" { part_start } else { 0 };
|
||||
assert_eq!(offset, expected_offset, "{label}: physical offset");
|
||||
}
|
||||
|
||||
let (body, offset, length, _) = read_via_seek_window(&fixture, None, &opts, &headers).await;
|
||||
assert_eq!((offset, length), (0, fixture.ciphertext.len() as i64), "seek={seek}: full read");
|
||||
assert_eq!(body, fixture.plaintext, "seek={seek}: full body");
|
||||
|
||||
let part_two = ObjectOptions {
|
||||
part_number: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
let (body, _, _, _) = read_via_seek_window(&fixture, None, &part_two, &headers).await;
|
||||
assert_eq!(body, &fixture.plaintext[20_000..29_000], "seek={seek}: partNumber=2 body");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The amplification-inversion guard: a small Range inside part 2 must schedule
|
||||
/// only part 2 and must not pull more than part 2's ciphertext. The stream is
|
||||
/// served from the planned offset all the way to the object end WITHOUT an end
|
||||
|
||||
@@ -65,6 +65,7 @@ rand = { workspace = true, features = ["serde"] }
|
||||
http.workspace = true
|
||||
hyper.workspace = true
|
||||
aes-gcm = { workspace = true, features = ["rand_core"] }
|
||||
hmac.workspace = true
|
||||
crc-fast = { workspace = true }
|
||||
pin-project-lite.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -15,8 +15,11 @@
|
||||
use crate::compress_index::{Index, TryGetIndex};
|
||||
use aes_gcm::aead::{Aead, Payload};
|
||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
||||
use hmac::{Hmac, Mac};
|
||||
use pin_project_lite::pin_project;
|
||||
use rand::Rng;
|
||||
use rustfs_utils::{put_uvarint, put_uvarint_len};
|
||||
use sha2::Sha256;
|
||||
use std::io::Error;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
@@ -46,6 +49,54 @@ const FRAME_TYPE_V2: u8 = 0x01;
|
||||
const FRAME_TYPE_V2_FINAL: u8 = 0x02;
|
||||
const FRAME_TYPE_END: u8 = 0xFF;
|
||||
|
||||
/// Leading frame of a part-keyed multipart segment.
|
||||
///
|
||||
/// Every part segment of a multipart object shares the upload's data key and
|
||||
/// base nonce, and the per-block nonces depend only on the part number and
|
||||
/// the block index. Writing the same part number twice within one upload
|
||||
/// therefore repeats the exact (key, nonce) sequence. A part-keyed segment
|
||||
/// opens with this frame, which carries a fresh random salt; the segment's
|
||||
/// v2 frames are then sealed under a key derived from the data key, the salt
|
||||
/// and the part number, so every part write uses its own AES-GCM key.
|
||||
///
|
||||
/// Layout: the usual 8-byte header (type, `len = salt + 4` as u24 LE, CRC32
|
||||
/// of the salt) followed by the raw salt. The salt is not secret; a modified
|
||||
/// salt derives a different key and the segment's frames fail authentication.
|
||||
const FRAME_TYPE_PART_KEY: u8 = 0x03;
|
||||
const PART_KEY_SALT_LEN: usize = 32;
|
||||
const PART_KEY_DERIVATION_LABEL: &[u8] = b"rustfs-sse-multipart-part-key-v1";
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Key that seals one part-keyed segment: HMAC-SHA256 over a fixed label,
|
||||
/// the part number and the segment's salt, keyed by the object data key.
|
||||
fn derive_part_segment_key(key: &[u8; 32], salt: &[u8; PART_KEY_SALT_LEN], part_number: usize) -> [u8; 32] {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts 32-byte keys");
|
||||
mac.update(PART_KEY_DERIVATION_LABEL);
|
||||
mac.update(&(part_number as u64).to_be_bytes());
|
||||
mac.update(salt);
|
||||
let mut segment_key = [0u8; 32];
|
||||
segment_key.copy_from_slice(mac.finalize().into_bytes().as_slice());
|
||||
segment_key
|
||||
}
|
||||
|
||||
fn crc32(bytes: &[u8]) -> u32 {
|
||||
let mut hasher = crc_fast::Digest::new(crc_fast::CrcAlgorithm::Crc32IsoHdlc);
|
||||
hasher.update(bytes);
|
||||
hasher.finalize() as u32
|
||||
}
|
||||
|
||||
fn part_key_frame(salt: &[u8; PART_KEY_SALT_LEN]) -> Vec<u8> {
|
||||
let len = PART_KEY_SALT_LEN + 4;
|
||||
let crc = crc32(salt);
|
||||
let mut out = Vec::with_capacity(8 + PART_KEY_SALT_LEN);
|
||||
out.push(FRAME_TYPE_PART_KEY);
|
||||
out.extend_from_slice(&(len as u32).to_le_bytes()[..3]);
|
||||
out.extend_from_slice(&crc.to_le_bytes());
|
||||
out.extend_from_slice(salt);
|
||||
out
|
||||
}
|
||||
|
||||
/// AEAD associated data of a v2 frame: the 8-byte header followed by the
|
||||
/// frame index within its segment, little-endian.
|
||||
fn v2_frame_aad(header: &[u8; 8], block_index: usize) -> [u8; 16] {
|
||||
@@ -72,6 +123,8 @@ pin_project! {
|
||||
frame_v2: bool,
|
||||
pending: usize,
|
||||
input_done: bool,
|
||||
// Salt of a part-keyed segment, emitted as the segment's first frame.
|
||||
pending_part_key_frame: Option<[u8; PART_KEY_SALT_LEN]>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +145,7 @@ where
|
||||
frame_v2: false,
|
||||
pending: 0,
|
||||
input_done: false,
|
||||
pending_part_key_frame: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +170,28 @@ where
|
||||
reader.frame_v2 = true;
|
||||
reader
|
||||
}
|
||||
|
||||
/// Multipart writer for a part-keyed v2 segment (see the
|
||||
/// `FRAME_TYPE_PART_KEY` frame): a fresh random salt per call, so writing the
|
||||
/// same part number again never reuses a (key, nonce) pair.
|
||||
pub fn new_multipart_part_keyed(inner: R, key: [u8; 32], base_nonce: [u8; 12], part_number: usize) -> Self {
|
||||
let mut salt = [0u8; PART_KEY_SALT_LEN];
|
||||
rand::rng().fill_bytes(&mut salt);
|
||||
Self::new_multipart_part_keyed_with_salt(inner, key, base_nonce, part_number, salt)
|
||||
}
|
||||
|
||||
fn new_multipart_part_keyed_with_salt(
|
||||
inner: R,
|
||||
key: [u8; 32],
|
||||
base_nonce: [u8; 12],
|
||||
part_number: usize,
|
||||
salt: [u8; PART_KEY_SALT_LEN],
|
||||
) -> Self {
|
||||
let segment_key = derive_part_segment_key(&key, &salt, part_number);
|
||||
let mut reader = Self::new_multipart_v2(inner, segment_key, base_nonce, part_number);
|
||||
reader.pending_part_key_frame = Some(salt);
|
||||
reader
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one frame: header, plaintext-length uvarint, ciphertext. For v2
|
||||
@@ -193,6 +269,14 @@ where
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
if let Some(salt) = this.pending_part_key_frame.take() {
|
||||
*this.buffer = part_key_frame(&salt);
|
||||
let to_copy = std::cmp::min(buf.remaining(), this.buffer.len());
|
||||
buf.put_slice(&this.buffer[..to_copy]);
|
||||
*this.buffer_pos = to_copy;
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
if *this.frame_v2 {
|
||||
// Accumulate a full block so every non-final frame carries exactly
|
||||
// ENCRYPTION_BLOCK_SIZE plaintext bytes (fixed-length frames give
|
||||
@@ -409,6 +493,11 @@ pin_project! {
|
||||
segments_completed: usize,
|
||||
v1_nonce_layout: Option<V1NonceLayout>,
|
||||
legacy_nonce_fallback: bool,
|
||||
// Part-keyed segments (see FRAME_TYPE_PART_KEY): the object data key
|
||||
// the segment keys derive from, and the current segment's cipher.
|
||||
key: [u8; 32],
|
||||
segment_keyed: bool,
|
||||
segment_cipher: Option<Aes256Gcm>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +533,9 @@ where
|
||||
segments_completed: 0,
|
||||
v1_nonce_layout: None,
|
||||
legacy_nonce_fallback: legacy_nonce_fallback_enabled(),
|
||||
key,
|
||||
segment_keyed: false,
|
||||
segment_cipher: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +588,9 @@ where
|
||||
segments_completed: 0,
|
||||
v1_nonce_layout: None,
|
||||
legacy_nonce_fallback: legacy_nonce_fallback_enabled(),
|
||||
key,
|
||||
segment_keyed: false,
|
||||
segment_cipher: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,7 +635,7 @@ where
|
||||
// frames were dropped.
|
||||
if *this.segment_frame_version == Some(2)
|
||||
&& !*this.saw_final_frame
|
||||
&& *this.segment_frames > 0
|
||||
&& (*this.segment_frames > 0 || *this.segment_keyed)
|
||||
{
|
||||
return Poll::Ready(Err(Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
@@ -603,6 +698,8 @@ where
|
||||
*this.saw_final_frame = false;
|
||||
*this.segment_frames = 0;
|
||||
*this.v1_nonce_layout = None;
|
||||
*this.segment_keyed = false;
|
||||
*this.segment_cipher = None;
|
||||
|
||||
if *this.multipart_mode {
|
||||
let next_part = if *this.current_part_index + 1 < this.multipart_parts.len() {
|
||||
@@ -630,6 +727,33 @@ where
|
||||
continue;
|
||||
}
|
||||
|
||||
if typ == FRAME_TYPE_PART_KEY {
|
||||
// Only the first frame of a multipart part segment may
|
||||
// name the segment key; anywhere else it would let a
|
||||
// spliced frame switch keys mid-segment.
|
||||
if !*this.multipart_mode || this.segment_frame_version.is_some() || *this.segment_keyed {
|
||||
return Poll::Ready(Err(Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"part key frame outside the start of a multipart part segment",
|
||||
)));
|
||||
}
|
||||
if len != PART_KEY_SALT_LEN + 4 {
|
||||
return Poll::Ready(Err(Error::new(std::io::ErrorKind::InvalidData, "invalid part key frame length")));
|
||||
}
|
||||
// A part-keyed segment carries only authenticated v2 frames
|
||||
// and must end with its final frame.
|
||||
*this.segment_keyed = true;
|
||||
*this.segment_frame_version = Some(2);
|
||||
*this.stream_saw_v2 = true;
|
||||
*this.current_frame_type = typ;
|
||||
if this.ciphertext_buf.len() < PART_KEY_SALT_LEN {
|
||||
this.ciphertext_buf.resize(PART_KEY_SALT_LEN, 0);
|
||||
}
|
||||
*this.ciphertext_len = PART_KEY_SALT_LEN;
|
||||
*this.ciphertext_read = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
let frame_version = match typ {
|
||||
FRAME_TYPE_V1 => 1,
|
||||
FRAME_TYPE_V2 | FRAME_TYPE_V2_FINAL => 2,
|
||||
@@ -714,6 +838,22 @@ where
|
||||
return Poll::Pending;
|
||||
}
|
||||
|
||||
if *this.current_frame_type == FRAME_TYPE_PART_KEY {
|
||||
let mut salt = [0u8; PART_KEY_SALT_LEN];
|
||||
salt.copy_from_slice(&this.ciphertext_buf[..PART_KEY_SALT_LEN]);
|
||||
*this.ciphertext_read = 0;
|
||||
*this.ciphertext_len = 0;
|
||||
let expected_crc =
|
||||
u32::from_le_bytes([this.header_buf[4], this.header_buf[5], this.header_buf[6], this.header_buf[7]]);
|
||||
if crc32(&salt) != expected_crc {
|
||||
return Poll::Ready(Err(Error::new(std::io::ErrorKind::InvalidData, "part key frame CRC32 mismatch")));
|
||||
}
|
||||
let segment_key = derive_part_segment_key(this.key, &salt, *this.current_part);
|
||||
*this.segment_cipher =
|
||||
Some(Aes256Gcm::new_from_slice(&segment_key).map_err(|_| Error::other("invalid part segment key length"))?);
|
||||
continue;
|
||||
}
|
||||
|
||||
let ciphertext_buf = &this.ciphertext_buf[..*this.ciphertext_len];
|
||||
// `ciphertext_buf`'s length derives from the untrusted 24-bit header length field, so
|
||||
// it can be shorter than 16 bytes. `uvarint` is safe on any slice length, so pass the
|
||||
@@ -736,7 +876,11 @@ where
|
||||
// derivation is exactly the modern scheme, and there are no
|
||||
// legacy fallbacks — any mismatch is tampering, not history.
|
||||
let aad = v2_frame_aad(this.header_buf, *this.block_index);
|
||||
this.cipher
|
||||
let cipher: &Aes256Gcm = match this.segment_cipher.as_ref() {
|
||||
Some(segment_cipher) => segment_cipher,
|
||||
None => this.cipher,
|
||||
};
|
||||
cipher
|
||||
.decrypt(
|
||||
&nonce,
|
||||
Payload {
|
||||
@@ -1819,4 +1963,276 @@ mod tests {
|
||||
.await
|
||||
.expect_err("a wrong absolute frame index must fail authentication");
|
||||
}
|
||||
|
||||
// Frozen ciphertext written by the pre-part-key multipart writers
|
||||
// (`new_multipart` v1 for part 1, `new_multipart_v2` for part 2) under
|
||||
// FIXTURE_KEY / FIXTURE_BASE_NONCE. Existing objects keep these bytes on
|
||||
// disk, so they must decrypt unchanged, alone and next to part-keyed parts.
|
||||
const FIXTURE_KEY: [u8; 32] = [0x11; 32];
|
||||
const FIXTURE_BASE_NONCE: [u8; 12] = [0x22; 12];
|
||||
const FROZEN_V1_PART_ONE: &str = "003d00003c2ea60d28cc95ba74f479d9ba69fba8f3ae0dc5ff36bd9017ba93f2bf3538623cb9b1ea23527d7122e15f68054fab6f3a97001384264c8dbed0a6923aff00000000000000";
|
||||
const FROZEN_V2_PART_TWO: &str =
|
||||
"022d0000be2e9f09184b9d704c7411f32671790af637f706520d50a5da1459ee0c5e122ce4e5e9ccd0bf68abb412bb0439ff00000000000000";
|
||||
|
||||
fn frozen_part(hex: &str) -> Vec<u8> {
|
||||
let mut out = vec![0u8; hex.len() / 2];
|
||||
faster_hex::hex_decode(hex.as_bytes(), &mut out).expect("valid fixture hex");
|
||||
out
|
||||
}
|
||||
|
||||
fn frozen_part_one_plaintext() -> Vec<u8> {
|
||||
(0..40u8).collect()
|
||||
}
|
||||
|
||||
fn frozen_part_two_plaintext() -> Vec<u8> {
|
||||
(100..124u8).collect()
|
||||
}
|
||||
|
||||
async fn encrypt_part_keyed(data: &[u8], key: [u8; 32], base_nonce: [u8; 12], part_number: usize) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
EncryptReader::new_multipart_part_keyed(Cursor::new(data.to_vec()), key, base_nonce, part_number)
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect("part-keyed encryption succeeds");
|
||||
out
|
||||
}
|
||||
|
||||
async fn decrypt_parts(stream: Vec<u8>, key: [u8; 32], base_nonce: [u8; 12], parts: Vec<usize>) -> std::io::Result<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
DecryptReader::new_multipart(BufReader::new(Cursor::new(stream)), key, base_nonce, parts)
|
||||
.read_to_end(&mut out)
|
||||
.await?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn part_key_salt(segment: &[u8]) -> [u8; super::PART_KEY_SALT_LEN] {
|
||||
assert_eq!(segment[0], super::FRAME_TYPE_PART_KEY, "a part-keyed segment opens with its key frame");
|
||||
segment[8..8 + super::PART_KEY_SALT_LEN].try_into().expect("salt slice")
|
||||
}
|
||||
|
||||
/// Ciphertext bytes (tag excluded) of the frame whose header starts at
|
||||
/// `frame_start`.
|
||||
fn frame_ciphertext(segment: &[u8], frame_start: usize, plaintext_len: usize) -> Vec<u8> {
|
||||
assert_ne!(segment[frame_start], super::FRAME_TYPE_END);
|
||||
let ct_start = frame_start + 8 + put_uvarint_len(plaintext_len as u64);
|
||||
segment[ct_start..ct_start + plaintext_len].to_vec()
|
||||
}
|
||||
|
||||
fn xor(a: &[u8], b: &[u8]) -> Vec<u8> {
|
||||
a.iter().zip(b).map(|(x, y)| x ^ y).collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_part_writes_of_one_part_number_share_a_keystream() {
|
||||
// The defect the part-keyed layout closes: the legacy writer derives
|
||||
// the same (key, nonce) sequence each time a part number is written,
|
||||
// so the XOR of two ciphertexts equals the XOR of their plaintexts.
|
||||
let first = vec![0x41u8; 64];
|
||||
let second = vec![0x7Au8; 64];
|
||||
let mut c1 = Vec::new();
|
||||
EncryptReader::new_multipart(Cursor::new(first.clone()), FIXTURE_KEY, FIXTURE_BASE_NONCE, 3)
|
||||
.read_to_end(&mut c1)
|
||||
.await
|
||||
.expect("legacy encryption succeeds");
|
||||
let mut c2 = Vec::new();
|
||||
EncryptReader::new_multipart(Cursor::new(second.clone()), FIXTURE_KEY, FIXTURE_BASE_NONCE, 3)
|
||||
.read_to_end(&mut c2)
|
||||
.await
|
||||
.expect("legacy encryption succeeds");
|
||||
assert_eq!(xor(&frame_ciphertext(&c1, 0, 64), &frame_ciphertext(&c2, 0, 64)), xor(&first, &second));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn part_keyed_rewrites_of_one_part_number_never_reuse_a_key_nonce_pair() {
|
||||
let block = ENCRYPTION_BLOCK_SIZE;
|
||||
let first = vec![0x41u8; block + 64];
|
||||
let second = vec![0x7Au8; block + 64];
|
||||
let c1 = encrypt_part_keyed(&first, FIXTURE_KEY, FIXTURE_BASE_NONCE, 3).await;
|
||||
let c2 = encrypt_part_keyed(&second, FIXTURE_KEY, FIXTURE_BASE_NONCE, 3).await;
|
||||
|
||||
let (s1, s2) = (part_key_salt(&c1), part_key_salt(&c2));
|
||||
assert_ne!(s1, s2, "each part write draws a fresh salt");
|
||||
let k1 = super::derive_part_segment_key(&FIXTURE_KEY, &s1, 3);
|
||||
let k2 = super::derive_part_segment_key(&FIXTURE_KEY, &s2, 3);
|
||||
assert_ne!(k1, k2, "rewrites of one part number seal under different keys");
|
||||
assert_ne!(k1, FIXTURE_KEY, "the object data key never seals a part-keyed frame");
|
||||
|
||||
// Same block position in both writes: the nonce is identical by
|
||||
// construction, so the (key, nonce) pair differs only through the key,
|
||||
// and the keystream no longer cancels out.
|
||||
let frame_start = 8 + super::PART_KEY_SALT_LEN;
|
||||
assert_ne!(
|
||||
xor(&frame_ciphertext(&c1, frame_start, block), &frame_ciphertext(&c2, frame_start, block)),
|
||||
xor(&first[..block], &second[..block])
|
||||
);
|
||||
|
||||
// An identical-content retry also yields distinct ciphertext.
|
||||
let c3 = encrypt_part_keyed(&first, FIXTURE_KEY, FIXTURE_BASE_NONCE, 3).await;
|
||||
assert_ne!(c1, c3);
|
||||
for (stream, expected) in [(c1, &first), (c2, &second), (c3, &first)] {
|
||||
let decrypted = decrypt_parts(stream, FIXTURE_KEY, FIXTURE_BASE_NONCE, vec![3])
|
||||
.await
|
||||
.expect("part-keyed segment decrypts");
|
||||
assert_eq!(&decrypted, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn part_keyed_segments_round_trip_every_boundary_length() {
|
||||
let block = ENCRYPTION_BLOCK_SIZE;
|
||||
for len in [0, 1, block - 1, block, block + 1, 3 * block, 3 * block + 5] {
|
||||
let data: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
|
||||
let tail: Vec<u8> = vec![0xC3; 17];
|
||||
let mut stream = encrypt_part_keyed(&data, FIXTURE_KEY, FIXTURE_BASE_NONCE, 1).await;
|
||||
stream.extend_from_slice(&encrypt_part_keyed(&tail, FIXTURE_KEY, FIXTURE_BASE_NONCE, 2).await);
|
||||
let mut expected = data;
|
||||
expected.extend_from_slice(&tail);
|
||||
|
||||
// Small upstream reads split the key frame across polls.
|
||||
let mut chunked = Vec::new();
|
||||
DecryptReader::new_multipart(ChunkedCursor::new(stream.clone(), 5), FIXTURE_KEY, FIXTURE_BASE_NONCE, vec![1, 2])
|
||||
.read_to_end(&mut chunked)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("chunked len {len}: {e}"));
|
||||
assert_eq!(chunked, expected, "chunked len {len}");
|
||||
|
||||
let decrypted = decrypt_parts(stream, FIXTURE_KEY, FIXTURE_BASE_NONCE, vec![1, 2])
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("len {len}: {e}"));
|
||||
assert_eq!(decrypted, expected, "len {len}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn frozen_legacy_parts_still_decrypt_alone_and_mixed_with_part_keyed_parts() {
|
||||
let part_one = frozen_part(FROZEN_V1_PART_ONE);
|
||||
let part_two = frozen_part(FROZEN_V2_PART_TWO);
|
||||
let mut expected = frozen_part_one_plaintext();
|
||||
expected.extend_from_slice(&frozen_part_two_plaintext());
|
||||
let legacy = [part_one.as_slice(), part_two.as_slice()].concat();
|
||||
assert_eq!(
|
||||
decrypt_parts(legacy, FIXTURE_KEY, FIXTURE_BASE_NONCE, vec![1, 2])
|
||||
.await
|
||||
.expect("frozen legacy parts decrypt"),
|
||||
expected
|
||||
);
|
||||
|
||||
// Part 3 was written after the upgrade; parts 1 and 2 stay legacy.
|
||||
let part_three_plain = vec![0x5Cu8; ENCRYPTION_BLOCK_SIZE + 9];
|
||||
let part_three = encrypt_part_keyed(&part_three_plain, FIXTURE_KEY, FIXTURE_BASE_NONCE, 3).await;
|
||||
let mut expected_all = expected.clone();
|
||||
expected_all.extend_from_slice(&part_three_plain);
|
||||
let mixed = [part_one.as_slice(), part_two.as_slice(), part_three.as_slice()].concat();
|
||||
assert_eq!(
|
||||
decrypt_parts(mixed, FIXTURE_KEY, FIXTURE_BASE_NONCE, vec![1, 2, 3])
|
||||
.await
|
||||
.expect("legacy and part-keyed parts decrypt together"),
|
||||
expected_all
|
||||
);
|
||||
|
||||
// A legacy part between part-keyed parts.
|
||||
let keyed_one = encrypt_part_keyed(&frozen_part_one_plaintext(), FIXTURE_KEY, FIXTURE_BASE_NONCE, 1).await;
|
||||
let interleaved = [keyed_one.as_slice(), part_two.as_slice(), part_three.as_slice()].concat();
|
||||
assert_eq!(
|
||||
decrypt_parts(interleaved, FIXTURE_KEY, FIXTURE_BASE_NONCE, vec![1, 2, 3])
|
||||
.await
|
||||
.expect("part-keyed, legacy v2 and part-keyed parts decrypt together"),
|
||||
expected_all
|
||||
);
|
||||
|
||||
// A read that starts at a later part boundary (multipart range seek).
|
||||
let from_two = [part_two.as_slice(), part_three.as_slice()].concat();
|
||||
let mut expected_from_two = frozen_part_two_plaintext();
|
||||
expected_from_two.extend_from_slice(&part_three_plain);
|
||||
assert_eq!(
|
||||
decrypt_parts(from_two, FIXTURE_KEY, FIXTURE_BASE_NONCE, vec![2, 3])
|
||||
.await
|
||||
.expect("seek to a part boundary"),
|
||||
expected_from_two
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn part_keyed_segments_reject_tampering_and_misplacement() {
|
||||
let data = vec![0x6Du8; ENCRYPTION_BLOCK_SIZE + 3];
|
||||
let segment = encrypt_part_keyed(&data, FIXTURE_KEY, FIXTURE_BASE_NONCE, 2).await;
|
||||
let key_frame_len = 8 + super::PART_KEY_SALT_LEN;
|
||||
let decrypt = |stream: Vec<u8>, parts: Vec<usize>| decrypt_parts(stream, FIXTURE_KEY, FIXTURE_BASE_NONCE, parts);
|
||||
|
||||
// A modified salt with a consistent CRC derives another key.
|
||||
let mut salt_flip = segment.clone();
|
||||
salt_flip[8] ^= 0x01;
|
||||
let crc = super::crc32(&salt_flip[8..key_frame_len]);
|
||||
salt_flip[4..8].copy_from_slice(&crc.to_le_bytes());
|
||||
let err = decrypt(salt_flip, vec![2]).await.expect_err("salt tampering must fail");
|
||||
assert_eq!(err.to_string(), "v2 encrypted frame failed authentication");
|
||||
|
||||
// A salt that disagrees with its header CRC is corruption.
|
||||
let mut crc_flip = segment.clone();
|
||||
crc_flip[9] ^= 0x01;
|
||||
let err = decrypt(crc_flip, vec![2]).await.expect_err("salt CRC mismatch must fail");
|
||||
assert_eq!(err.to_string(), "part key frame CRC32 mismatch");
|
||||
|
||||
// The segment key binds the part number: the same bytes listed as
|
||||
// another part fail authentication.
|
||||
decrypt(segment.clone(), vec![5])
|
||||
.await
|
||||
.expect_err("a segment moved to another part must fail");
|
||||
|
||||
// Without its key frame the segment is decrypted under the data key.
|
||||
decrypt(segment[key_frame_len..].to_vec(), vec![2])
|
||||
.await
|
||||
.expect_err("a stripped key frame must fail");
|
||||
|
||||
// A key frame alone, or followed only by the end marker, is truncation.
|
||||
decrypt(segment[..key_frame_len].to_vec(), vec![2])
|
||||
.await
|
||||
.expect_err("a lone key frame must fail");
|
||||
let mut key_then_end = segment[..key_frame_len].to_vec();
|
||||
key_then_end.extend_from_slice(&[super::FRAME_TYPE_END, 0, 0, 0, 0, 0, 0, 0]);
|
||||
decrypt(key_then_end, vec![2])
|
||||
.await
|
||||
.expect_err("an empty keyed segment must fail");
|
||||
|
||||
// A second key frame cannot switch keys mid-segment.
|
||||
let first_frame_end = key_frame_len + V2_FULL_FRAME_LEN;
|
||||
let rekeyed = [
|
||||
&segment[..first_frame_end],
|
||||
&segment[..key_frame_len],
|
||||
&segment[first_frame_end..],
|
||||
]
|
||||
.concat();
|
||||
let err = decrypt(rekeyed, vec![2])
|
||||
.await
|
||||
.expect_err("a mid-segment key frame must fail");
|
||||
assert_eq!(err.to_string(), "part key frame outside the start of a multipart part segment");
|
||||
|
||||
// A keyed segment carries only v2 frames.
|
||||
let mut v1 = Vec::new();
|
||||
EncryptReader::new_multipart(Cursor::new(vec![0x01u8; 16]), FIXTURE_KEY, FIXTURE_BASE_NONCE, 2)
|
||||
.read_to_end(&mut v1)
|
||||
.await
|
||||
.expect("v1 encryption succeeds");
|
||||
let v1_after_key = [&segment[..key_frame_len], v1.as_slice()].concat();
|
||||
decrypt(v1_after_key, vec![2])
|
||||
.await
|
||||
.expect_err("v1 frames in a keyed segment must fail");
|
||||
|
||||
// Malformed key-frame length.
|
||||
let mut bad_len = segment.clone();
|
||||
bad_len[1] = bad_len[1].wrapping_add(1);
|
||||
let err = decrypt(bad_len, vec![2]).await.expect_err("a malformed key frame must fail");
|
||||
assert_eq!(err.to_string(), "invalid part key frame length");
|
||||
|
||||
// Single-part streams never carry part keys.
|
||||
let mut single = Vec::new();
|
||||
let err = DecryptReader::new(Cursor::new(segment.clone()), FIXTURE_KEY, FIXTURE_BASE_NONCE)
|
||||
.read_to_end(&mut single)
|
||||
.await
|
||||
.expect_err("a single-part reader must reject a key frame");
|
||||
assert_eq!(err.to_string(), "part key frame outside the start of a multipart part segment");
|
||||
|
||||
// Control: the untampered segment decrypts.
|
||||
assert_eq!(decrypt(segment, vec![2]).await.expect("control decrypt"), data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
- `backlog-2097-tier-mutation-v4-error-text` tier-mutation Prepare rejection classification: a v3 server rejects a v4 request before store/runtime dispatch with the FailedPrecondition status and an authenticated, byte-exact unsupported-version message. A v4 coordinator recognizes only that exact code/message/requested-version tuple as definitely not persisted and fails the mutation without sending that peer an incompatible Abort; Unimplemented, near-text, missing/unknown failure classes, timeouts, and every other transport outcome remain ambiguous and stay in identity-bound Abort fanout. There is no automatic v3 retry. New servers retain v3 request/proof decoding for older coordinators, while operators must pause tier edit/remove/clear during a mixed v3/v4 rollout. Remove the text classifier after the minimum supported RustFS peer version returns the signed v4 PreDispatchRejected failure class.
|
||||
- `backlog-2097-tier-delete-journal-v6` tier-delete sole-owner recovery: v6 distinguishes transactions that may replace the xl.meta free-version owner and therefore require the all-pool live-source proof. v5-and-older readers reject and retain v6 records during rolling upgrades instead of performing an unsafe remote delete. Keep v1-v5 readers for upgrade recovery and keep the downgrade prohibition while any v6 record exists; retire the fence only after every supported rollback release understands and enforces v6 proof semantics.
|
||||
- `multipart-compression-default-off-window` staged multipart disk-compression rollout: releases before the resumable legacy decompressor fail transient reads of compressed objects under mid-payload suspension, so multipart uploads advertise the compression marker only when RUSTFS_COMPRESSION_MULTIPART_ENABLED is set in addition to RUSTFS_COMPRESSION_ENABLED, keeping rolling upgrades from creating new compressed multipart objects while pre-fix nodes may still serve reads. Flip the default to enabled (and retire the extra switch) after the minimum supported direct-upgrade release ships the resumable decompressor.
|
||||
- `multipart-part-key-default-off-window` staged part-keyed multipart encryption rollout: Direct-mode multipart parts share one data key and base nonce per upload, so rewriting a part number repeats its nonce sequence. Part-keyed segments open with a random salt frame (type 0x03) and seal their v2 frames under a per-write key, but releases without that frame type reject them, and ciphertext travels verbatim through transition, decommission, and SSE-C replication passthrough. Multipart writes therefore use the layout only when RUSTFS_ENCRYPTION_MULTIPART_PART_KEY is set; reads need no switch. Flip the default to enabled (and retire the switch) after the minimum supported direct-upgrade release, and every RustFS warm or replication target that receives raw ciphertext, reads part-keyed segments.
|
||||
- `s3gate-trailer-adapter` aws-chunked trailer handle bridge: s3s decodes aws-chunked request bodies and publishes their x-amz-checksum-* trailers through its own handle, while rio consumes trailers only through its framework-neutral TrailerSource trait. The RustFS application crate adapts the s3s handle at the object write path so rio carries no s3s dependency. Remove the adapter after the gateway stack replaces s3s as the request body decoder and publishes trailers through its own TrailerSource implementation.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
@@ -156,6 +156,8 @@ Two historical shapes additionally reuse a GCM nonce and cannot be repaired by a
|
||||
| Multipart objects written before `1.0.0-alpha.91` | The pre-alpha.91 writer reused a segment's part nonce for every block in it | The whole segment shares one nonce; a frame from index zero authenticates anywhere in that segment | Rewrite in place with CopyObject; then set `RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK=false` |
|
||||
| SSE-C objects written before `1.0.0-beta.9` that carry no stored IV | The nonce was derived deterministically from bucket and key | Historical versions of the same key share a nonce, which affects confidentiality as well as forgeability | Rewrite in place with CopyObject |
|
||||
|
||||
A third shape is still written by default: every part of a multipart upload shares the upload's data key and base nonce, and a part's nonces depend only on its part number, so a part number uploaded again within the same upload with different content reuses that part's nonces. Identical retries produce identical ciphertext. For SSE-C the data key is the customer key itself, so the exposure is not confined to one upload. `RUSTFS_ENCRYPTION_MULTIPART_PART_KEY=true` (default `false`) makes every part write open with a random salt and seal its frames under a key derived from that salt, the part number and the data key; such parts always use the v2 frame layout. Reading them needs no switch and existing parts stay readable, but nodes without part-keyed read support cannot decrypt them. Enable it only after every node — and every RustFS warm or replication target that receives raw ciphertext — runs a release that reads part-keyed segments, the same rollout rule as `RUSTFS_ENCRYPTION_FRAME_V2`. Parts written before the switch keep their stored bytes; rewrite an affected object with CopyObject to re-encrypt it.
|
||||
|
||||
The decrypt reader locks each segment to whichever nonce layout decoded its first non-zero-index frame, so a replayed frame is rejected as soon as any later frame disagrees. A stream that is nothing but repeats of frame zero has no later frame to disagree, so a deployment that holds no pre-alpha.91 objects should set `RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK=false` (default `true`) to drop that layout entirely. Turning it off refuses to decrypt pre-alpha.91 objects, so migrate first.
|
||||
|
||||
## Mixed-version clusters during a rolling upgrade
|
||||
|
||||
@@ -2599,6 +2599,136 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Both Direct-mode session kinds — SSE-C (customer key) and managed SSE
|
||||
/// (session DEK) — resolve one key and base nonce for every part of an
|
||||
/// upload. A part-keyed rewrite of a part number must still seal under a
|
||||
/// fresh key, and the GET-side material must read the rewritten part next
|
||||
/// to a legacy part.
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
#[tokio::test]
|
||||
async fn part_keyed_rewrites_round_trip_for_ssec_and_managed_sessions() {
|
||||
use base64_simd::STANDARD as BASE64;
|
||||
use md5::{Digest, Md5};
|
||||
|
||||
let customer_key_bytes = [0x5Eu8; 32];
|
||||
let customer_key = BASE64.encode_to_string(customer_key_bytes);
|
||||
let customer_key_md5 = BASE64.encode_to_string(Md5::digest(customer_key_bytes));
|
||||
let local_sse_master_key = BASE64.encode_to_string([0x24u8; 32]);
|
||||
|
||||
async_with_vars(
|
||||
[
|
||||
("__RUSTFS_SSE_SIMPLE_CMK", None::<String>),
|
||||
("RUSTFS_SSE_S3_MASTER_KEY", Some(local_sse_master_key)),
|
||||
],
|
||||
async {
|
||||
for ssec in [true, false] {
|
||||
let (sse, algorithm, sse_key, sse_key_md5) = if ssec {
|
||||
(
|
||||
None,
|
||||
Some("AES256".to_string()),
|
||||
Some(customer_key.clone()),
|
||||
Some(customer_key_md5.clone()),
|
||||
)
|
||||
} else {
|
||||
(Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), None, None, None)
|
||||
};
|
||||
let session_material = sse_prepare_encryption(PrepareEncryptionRequest {
|
||||
bucket: "bucket",
|
||||
key: "object",
|
||||
server_side_encryption: sse,
|
||||
ssekms_key_id: None,
|
||||
ssekms_context: None,
|
||||
sse_customer_algorithm: algorithm,
|
||||
sse_customer_key: sse_key,
|
||||
sse_customer_key_md5: sse_key_md5,
|
||||
principal: None,
|
||||
})
|
||||
.await
|
||||
.expect("prepare multipart encryption")
|
||||
.expect("multipart session material");
|
||||
assert_eq!(session_material.key_kind, EncryptionKeyKind::Direct);
|
||||
let mut session_metadata =
|
||||
encryption_material_to_metadata(&session_material).expect("multipart session metadata");
|
||||
mark_encrypted_multipart_metadata(&mut session_metadata);
|
||||
|
||||
let resolve = || {
|
||||
sse_decryption(DecryptionRequest {
|
||||
bucket: "bucket",
|
||||
key: "object",
|
||||
metadata: &session_metadata,
|
||||
sse_customer_key: ssec.then_some(&customer_key),
|
||||
sse_customer_key_md5: ssec.then_some(&customer_key_md5),
|
||||
principal: None,
|
||||
})
|
||||
};
|
||||
|
||||
// UploadPart resolves the session material per part.
|
||||
let part_material = resolve().await.expect("resolve part material").expect("part material");
|
||||
let encrypt = |plaintext: Vec<u8>, part_number: usize, part_keyed: bool| {
|
||||
let (key, nonce) = (part_material.key_bytes, part_material.base_nonce);
|
||||
async move {
|
||||
let mut out = Vec::new();
|
||||
if part_keyed {
|
||||
EncryptReader::new_multipart_part_keyed(Cursor::new(plaintext), key, nonce, part_number)
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
} else {
|
||||
EncryptReader::new_multipart(Cursor::new(plaintext), key, nonce, part_number)
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
}
|
||||
.expect("encrypt part");
|
||||
out
|
||||
}
|
||||
};
|
||||
|
||||
let part_one = vec![0x31; rustfs_rio::DEFAULT_ENCRYPTION_BLOCK_SIZE + 23];
|
||||
let first_write = encrypt(vec![0x99; part_one.len()], 1, true).await;
|
||||
let rewrite = encrypt(part_one.clone(), 1, true).await;
|
||||
assert_eq!((first_write[0], rewrite[0]), (0x03, 0x03), "ssec={ssec}: part-keyed segments");
|
||||
assert_ne!(first_write[8..40], rewrite[8..40], "ssec={ssec}: a rewrite draws a fresh part key");
|
||||
|
||||
let part_two = vec![0x32; 777];
|
||||
let legacy_two = encrypt(part_two.clone(), 2, false).await;
|
||||
let parts = vec![
|
||||
ObjectPartInfo {
|
||||
number: 1,
|
||||
size: rewrite.len(),
|
||||
actual_size: part_one.len() as i64,
|
||||
..Default::default()
|
||||
},
|
||||
ObjectPartInfo {
|
||||
number: 2,
|
||||
size: legacy_two.len(),
|
||||
actual_size: part_two.len() as i64,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
// GET resolves the stored object's material independently.
|
||||
let read_material = resolve().await.expect("resolve read material").expect("read material");
|
||||
let plaintext_size = multipart_plaintext_size(&parts, -1);
|
||||
let mut decrypted_reader = HardLimitReader::new(
|
||||
boxed_reader(DecryptReader::new_multipart(
|
||||
wrap_reader(Cursor::new([rewrite, legacy_two].concat())),
|
||||
read_material.key_bytes,
|
||||
read_material.base_nonce,
|
||||
multipart_part_numbers(&parts),
|
||||
)),
|
||||
plaintext_size,
|
||||
);
|
||||
let mut decrypted = Vec::new();
|
||||
decrypted_reader
|
||||
.read_to_end(&mut decrypted)
|
||||
.await
|
||||
.expect("read part-keyed and legacy parts");
|
||||
assert_eq!(decrypted, [part_one, part_two].concat(), "ssec={ssec}");
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_abort_multipart_upload_returns_internal_error_when_store_uninitialized() {
|
||||
let input = AbortMultipartUploadInput::builder()
|
||||
|
||||
Reference in New Issue
Block a user