feat(rio): authenticated fixed-frame v2 encryption layout behind a write switch (#6600)

The legacy rio v1 stream format authenticates only each frame's
ciphertext: the 8-byte header (length + plaintext CRC32) and the end
marker sit outside the AEAD, frames carry no position binding, and
nothing marks the last frame - header rewrites, frame reordering and
truncation of trailing frames are not cryptographically detected.

Add a v2 layout in the same format family, dispatched per frame by the
type byte:
- the header plus the frame's index are AEAD associated data (0x01), so
  header tampering, reordering and cross-position splicing fail
  authentication;
- the final frame carries its own authenticated type byte (0x02); a
  clean EOF or an end marker before it is an error, every stream
  (including the empty one) ends in an authenticated final frame, and a
  v2 multipart stream fails if it ends before all listed part segments;
- the writer accumulates full 8 KiB blocks, so non-final frames are
  fixed-length (8218 ciphertext bytes) and single-part objects gain a
  closed-form offset mapping for the follow-up range seek.

Key hierarchy, nonce derivation, envelopes and metadata are unchanged;
v1 objects stay readable forever, while v2 frames reject the historical
nonce fallbacks and unknown frame types become a hard error.

Write side ships off by default (RUSTFS_ENCRYPTION_FRAME_V2): mixed
version clusters cannot read v2 frames, and encrypted ciphertext travels
verbatim through transition, decommission and SSE-C replication
passthrough. This release ships read support; the default flips in a
following release.
This commit is contained in:
唐小鸭
2026-08-26 09:36:00 +08:00
committed by GitHub
parent 5834949c56
commit f469869620
2 changed files with 572 additions and 38 deletions
+47 -16
View File
@@ -308,6 +308,30 @@ pub struct WriteEncryption {
mode: WriteEncryptionMode,
}
/// Write-side switch for the authenticated, fixed-frame legacy v2 layout.
///
/// Off by default for rolling-upgrade safety: nodes without v2 read support
/// cannot decrypt v2 frames, and encrypted ciphertext travels verbatim through
/// transition, decommission and SSE-C replication passthrough. Turn on only
/// after every node (and every RustFS warm/replication target that receives
/// raw ciphertext) runs a release with v2 read support. Reading v2 objects
/// needs no switch — the decrypt reader dispatches on the frame type byte.
pub(crate) const ENV_RUSTFS_ENCRYPTION_FRAME_V2: &str = "RUSTFS_ENCRYPTION_FRAME_V2";
pub(crate) const DEFAULT_RUSTFS_ENCRYPTION_FRAME_V2: bool = false;
#[cfg(not(feature = "rio-v2"))]
pub(crate) fn encryption_frame_v2_enabled() -> bool {
#[cfg(test)]
{
rustfs_utils::get_env_bool(ENV_RUSTFS_ENCRYPTION_FRAME_V2, DEFAULT_RUSTFS_ENCRYPTION_FRAME_V2)
}
#[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_FRAME_V2, DEFAULT_RUSTFS_ENCRYPTION_FRAME_V2))
}
}
#[derive(Debug, Clone, Copy)]
enum WriteEncryptionMode {
SinglepartObjectKey,
@@ -416,25 +440,32 @@ impl WritePlan {
None,
false,
)?,
WriteEncryptionMode::Singlepart { base_nonce } => HashReader::from_reader(
EncryptReader::new(reader, encryption.key_bytes, base_nonce),
HashReader::SIZE_PRESERVE_LAYER,
actual_size,
None,
None,
false,
)?,
WriteEncryptionMode::Singlepart { base_nonce } => {
#[cfg(not(feature = "rio-v2"))]
let encrypt_reader = if encryption_frame_v2_enabled() {
EncryptReader::new_v2(reader, encryption.key_bytes, base_nonce)
} else {
EncryptReader::new(reader, encryption.key_bytes, base_nonce)
};
#[cfg(feature = "rio-v2")]
let encrypt_reader = EncryptReader::new(reader, encryption.key_bytes, base_nonce);
HashReader::from_reader(encrypt_reader, HashReader::SIZE_PRESERVE_LAYER, actual_size, None, None, false)?
}
WriteEncryptionMode::MultipartLegacy {
base_nonce,
multipart_part_number,
} => HashReader::from_reader(
EncryptReader::new_multipart(reader, encryption.key_bytes, base_nonce, multipart_part_number),
HashReader::SIZE_PRESERVE_LAYER,
actual_size,
None,
None,
false,
)?,
} => {
#[cfg(not(feature = "rio-v2"))]
let encrypt_reader = 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)
};
#[cfg(feature = "rio-v2")]
let encrypt_reader =
EncryptReader::new_multipart(reader, encryption.key_bytes, base_nonce, multipart_part_number);
HashReader::from_reader(encrypt_reader, HashReader::SIZE_PRESERVE_LAYER, actual_size, None, None, false)?
}
WriteEncryptionMode::MultipartObjectKey { multipart_part_number } => HashReader::from_reader(
#[cfg(feature = "rio-v2")]
EncryptReader::new_multipart_with_object_key(reader, encryption.key_bytes, multipart_part_number),