mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-08 21:25:59 +00:00
901 lines
37 KiB
Rust
901 lines
37 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! Data Encryption Key (DEK) encryption interface and implementations
|
|
//!
|
|
//! This module provides a unified interface for encrypting and decrypting
|
|
//! data encryption keys using master keys. It abstracts the encryption
|
|
//! operations so that different backends can share the same encryption logic.
|
|
|
|
use crate::error::{KmsError, Result};
|
|
use crate::persisted_observability::{BoundedUnknownFieldName, UnknownFieldSummary};
|
|
use async_trait::async_trait;
|
|
use jiff::Zoned;
|
|
use rand::Rng;
|
|
use serde::de::{self, IgnoredAny, MapAccess, Visitor};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::fmt;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
impl UnknownFieldSummary {
|
|
fn record_for_data_key_envelope(&self) {
|
|
let Some((field, field_name_truncated, field_count)) = self.record("data-key-envelope") else {
|
|
return;
|
|
};
|
|
|
|
static RECORDS_WITH_UNKNOWN_FIELDS: AtomicU64 = AtomicU64::new(0);
|
|
let observed_records = RECORDS_WITH_UNKNOWN_FIELDS.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
|
if observed_records.is_power_of_two() {
|
|
tracing::warn!(
|
|
field = ?field,
|
|
field_name_truncated,
|
|
field_count,
|
|
observed_records,
|
|
"KMS data-key envelope contains unknown fields"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Data key envelope for encrypting/decrypting data keys
|
|
///
|
|
/// This structure stores the encrypted DEK along with metadata needed for decryption.
|
|
/// The `master_key_version` field records which version of the KEK (Key Encryption Key)
|
|
/// wrapped this DEK so rotation-aware backends can load the matching historical
|
|
/// material. Envelopes written before versioning carry `None`; backends must resolve
|
|
/// `None` to a deterministic baseline version recorded in key metadata, never
|
|
/// implicitly to whatever version is current.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct DataKeyEnvelope {
|
|
pub key_id: String,
|
|
pub master_key_id: String,
|
|
pub key_spec: String,
|
|
pub encrypted_key: Vec<u8>,
|
|
pub nonce: Vec<u8>,
|
|
pub encryption_context: HashMap<String, String>,
|
|
#[serde(with = "crate::time_serde::zoned")]
|
|
pub created_at: Zoned,
|
|
/// KEK version that wrapped `encrypted_key`; `None` on pre-versioning envelopes.
|
|
///
|
|
/// Optional and omitted when `None` so envelopes from non-rotating backends stay
|
|
/// byte-identical to the historical seven-field JSON shape.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub master_key_version: Option<u32>,
|
|
/// How `encryption_context` is cryptographically bound into `encrypted_key`.
|
|
///
|
|
/// `None` on legacy envelopes: the context rides in the envelope
|
|
/// unauthenticated and is checked only by field comparison.
|
|
/// [`CONTEXT_BINDING_AAD_V1`] means the canonical context bytes
|
|
/// ([`context_aad`]) were passed as AES-GCM additional data when the DEK
|
|
/// was wrapped, so rewriting the stored context (or the flag) makes the
|
|
/// unwrap fail authentication. Any other value belongs to a newer format
|
|
/// and must fail closed rather than decrypt without the binding.
|
|
///
|
|
/// Optional and omitted when `None` so legacy-writing nodes and readers
|
|
/// keep exchanging the historical JSON shape unchanged.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub context_binding: Option<u8>,
|
|
}
|
|
|
|
/// `context_binding` value: the canonical encryption context is bound as
|
|
/// AES-GCM additional data over `encrypted_key`.
|
|
pub const CONTEXT_BINDING_AAD_V1: u8 = 1;
|
|
|
|
/// Resolve the AAD bytes an envelope's wrap was sealed with.
|
|
///
|
|
/// Legacy envelopes were sealed without additional data, which for AES-GCM is
|
|
/// byte-identical to an empty AAD — so `None` maps to empty bytes and both
|
|
/// generations decrypt through the same code path. An unrecognized binding
|
|
/// version is a format from a newer release: decrypting it while ignoring its
|
|
/// binding would silently drop an authentication the writer relied on, so it
|
|
/// fails closed instead.
|
|
pub fn envelope_wrap_aad(envelope: &DataKeyEnvelope) -> Result<Vec<u8>> {
|
|
match envelope.context_binding {
|
|
None => Ok(Vec::new()),
|
|
Some(CONTEXT_BINDING_AAD_V1) => context_aad(&envelope.encryption_context),
|
|
Some(version) => Err(KmsError::cryptographic_error(
|
|
"context_binding",
|
|
format!("unsupported data-key envelope context binding version {version}; written by a newer RustFS release"),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// The binding a rewrap of this envelope must produce.
|
|
///
|
|
/// Never below the envelope's existing binding — a bound envelope must not
|
|
/// regress to the unbound format whatever the write switch says — and upgraded
|
|
/// to [`CONTEXT_BINDING_AAD_V1`] when the write switch is on. Shared by
|
|
/// `rewrap_data_key` and `describe_data_key_wrapping` so the sweep and the
|
|
/// scan agree on which envelopes still need rewriting; two divergent copies of
|
|
/// this rule would leave a sweep that never converges.
|
|
pub fn desired_context_binding(existing: Option<u8>) -> Option<u8> {
|
|
if existing == Some(CONTEXT_BINDING_AAD_V1) || envelope_aad_write_enabled() {
|
|
Some(CONTEXT_BINDING_AAD_V1)
|
|
} else {
|
|
existing
|
|
}
|
|
}
|
|
|
|
/// Whether newly wrapped DEK envelopes bind their encryption context as AAD.
|
|
///
|
|
/// Default off for one release: an envelope written with the binding cannot be
|
|
/// opened by a node that predates it (the unwrap fails authentication), so the
|
|
/// switch must only be enabled once every node in the cluster runs a release
|
|
/// that understands `context_binding`. Reading bound envelopes needs no switch.
|
|
pub fn envelope_aad_write_enabled() -> bool {
|
|
use crate::config::ENV_KMS_ENVELOPE_AAD;
|
|
use rustfs_utils::get_env_bool;
|
|
|
|
#[cfg(test)]
|
|
{
|
|
get_env_bool(ENV_KMS_ENVELOPE_AAD, false)
|
|
}
|
|
#[cfg(not(test))]
|
|
{
|
|
static ENABLED: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| get_env_bool(ENV_KMS_ENVELOPE_AAD, false));
|
|
*ENABLED
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for DataKeyEnvelope {
|
|
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
enum Field {
|
|
KeyId,
|
|
MasterKeyId,
|
|
KeySpec,
|
|
EncryptedKey,
|
|
Nonce,
|
|
EncryptionContext,
|
|
CreatedAt,
|
|
MasterKeyVersion,
|
|
ContextBinding,
|
|
Unknown(BoundedUnknownFieldName),
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for Field {
|
|
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
struct FieldVisitor;
|
|
|
|
impl Visitor<'_> for FieldVisitor {
|
|
type Value = Field;
|
|
|
|
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str("a KMS data-key envelope field name")
|
|
}
|
|
|
|
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
|
|
where
|
|
E: de::Error,
|
|
{
|
|
Ok(match value {
|
|
"key_id" => Field::KeyId,
|
|
"master_key_id" => Field::MasterKeyId,
|
|
"key_spec" => Field::KeySpec,
|
|
"encrypted_key" => Field::EncryptedKey,
|
|
"nonce" => Field::Nonce,
|
|
"encryption_context" => Field::EncryptionContext,
|
|
"created_at" => Field::CreatedAt,
|
|
"master_key_version" => Field::MasterKeyVersion,
|
|
"context_binding" => Field::ContextBinding,
|
|
_ => Field::Unknown(BoundedUnknownFieldName::new(value)),
|
|
})
|
|
}
|
|
}
|
|
|
|
deserializer.deserialize_identifier(FieldVisitor)
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ZonedValue(#[serde(with = "crate::time_serde::zoned")] Zoned);
|
|
|
|
struct DataKeyEnvelopeVisitor;
|
|
|
|
impl<'de> Visitor<'de> for DataKeyEnvelopeVisitor {
|
|
type Value = DataKeyEnvelope;
|
|
|
|
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str("a KMS data-key envelope")
|
|
}
|
|
|
|
fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
|
|
where
|
|
A: MapAccess<'de>,
|
|
{
|
|
macro_rules! read_field {
|
|
($slot:ident, $name:literal) => {{
|
|
if $slot.is_some() {
|
|
return Err(de::Error::duplicate_field($name));
|
|
}
|
|
$slot = Some(map.next_value()?);
|
|
}};
|
|
}
|
|
|
|
let mut key_id = None;
|
|
let mut master_key_id = None;
|
|
let mut key_spec = None;
|
|
let mut encrypted_key = None;
|
|
let mut nonce = None;
|
|
let mut encryption_context = None;
|
|
let mut created_at: Option<ZonedValue> = None;
|
|
let mut master_key_version = None;
|
|
let mut context_binding = None;
|
|
let mut unknown_fields = UnknownFieldSummary::default();
|
|
|
|
while let Some(field) = map.next_key()? {
|
|
match field {
|
|
Field::KeyId => read_field!(key_id, "key_id"),
|
|
Field::MasterKeyId => read_field!(master_key_id, "master_key_id"),
|
|
Field::KeySpec => read_field!(key_spec, "key_spec"),
|
|
Field::EncryptedKey => read_field!(encrypted_key, "encrypted_key"),
|
|
Field::Nonce => read_field!(nonce, "nonce"),
|
|
Field::EncryptionContext => read_field!(encryption_context, "encryption_context"),
|
|
Field::CreatedAt => read_field!(created_at, "created_at"),
|
|
Field::MasterKeyVersion => read_field!(master_key_version, "master_key_version"),
|
|
Field::ContextBinding => read_field!(context_binding, "context_binding"),
|
|
Field::Unknown(field) => {
|
|
let _: IgnoredAny = map.next_value()?;
|
|
unknown_fields.observe(field);
|
|
}
|
|
}
|
|
}
|
|
|
|
let envelope = DataKeyEnvelope {
|
|
key_id: key_id.ok_or_else(|| de::Error::missing_field("key_id"))?,
|
|
master_key_id: master_key_id.ok_or_else(|| de::Error::missing_field("master_key_id"))?,
|
|
key_spec: key_spec.ok_or_else(|| de::Error::missing_field("key_spec"))?,
|
|
encrypted_key: encrypted_key.ok_or_else(|| de::Error::missing_field("encrypted_key"))?,
|
|
nonce: nonce.ok_or_else(|| de::Error::missing_field("nonce"))?,
|
|
encryption_context: encryption_context.ok_or_else(|| de::Error::missing_field("encryption_context"))?,
|
|
created_at: created_at.ok_or_else(|| de::Error::missing_field("created_at"))?.0,
|
|
master_key_version: master_key_version.unwrap_or(None),
|
|
context_binding: context_binding.unwrap_or(None),
|
|
};
|
|
unknown_fields.record_for_data_key_envelope();
|
|
Ok(envelope)
|
|
}
|
|
}
|
|
|
|
const FIELDS: &[&str] = &[
|
|
"key_id",
|
|
"master_key_id",
|
|
"key_spec",
|
|
"encrypted_key",
|
|
"nonce",
|
|
"encryption_context",
|
|
"created_at",
|
|
"master_key_version",
|
|
"context_binding",
|
|
];
|
|
deserializer.deserialize_struct("DataKeyEnvelope", FIELDS, DataKeyEnvelopeVisitor)
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct DataKeyEnvelopeMarker {
|
|
#[serde(rename = "key_id")]
|
|
_key_id: IgnoredAny,
|
|
#[serde(rename = "master_key_id")]
|
|
_master_key_id: IgnoredAny,
|
|
#[serde(rename = "key_spec")]
|
|
_key_spec: IgnoredAny,
|
|
#[serde(rename = "encrypted_key")]
|
|
_encrypted_key: IgnoredAny,
|
|
#[serde(rename = "nonce")]
|
|
_nonce: IgnoredAny,
|
|
#[serde(rename = "encryption_context")]
|
|
_encryption_context: IgnoredAny,
|
|
#[serde(rename = "created_at")]
|
|
_created_at: IgnoredAny,
|
|
}
|
|
|
|
/// Serialize an encryption context into deterministic AAD bytes.
|
|
///
|
|
/// The AAD has to be reproducible byte-for-byte at decrypt time. A `HashMap`
|
|
/// serializes in its own iteration order, which differs between instances — so
|
|
/// a context rebuilt from storage (or from headers) would produce different
|
|
/// bytes than the one used to seal, and the sealed data would never open
|
|
/// again. Ordering by key removes that dependency.
|
|
///
|
|
/// Shared by every layer that binds a context as additional data. It lives
|
|
/// here rather than beside one caller because a second, subtly different copy
|
|
/// is exactly how the object layer ended up serializing a `HashMap` directly
|
|
/// while the Static backend was already canonicalizing.
|
|
pub fn context_aad(context: &HashMap<String, String>) -> Result<Vec<u8>> {
|
|
let canonical: BTreeMap<&str, &str> = context.iter().map(|(key, value)| (key.as_str(), value.as_str())).collect();
|
|
serde_json::to_vec(&canonical).map_err(Into::into)
|
|
}
|
|
|
|
/// Returns whether ciphertext is a RustFS KMS data-key envelope.
|
|
pub fn is_data_key_envelope(ciphertext: &[u8]) -> bool {
|
|
ciphertext.iter().copied().find(|byte| !byte.is_ascii_whitespace()) == Some(b'{')
|
|
&& serde_json::from_slice::<DataKeyEnvelopeMarker>(ciphertext).is_ok()
|
|
}
|
|
|
|
/// Trait for encrypting and decrypting data encryption keys (DEK)
|
|
///
|
|
/// This trait abstracts the encryption operations used to protect
|
|
/// data encryption keys with master keys. Different implementations
|
|
/// can use different encryption algorithms (e.g., AES-256-GCM).
|
|
#[async_trait]
|
|
pub trait DekCrypto: Send + Sync {
|
|
/// Encrypt plaintext data using a master key material
|
|
///
|
|
/// # Arguments
|
|
/// * `key_material` - The master key material (raw bytes)
|
|
/// * `plaintext` - The data to encrypt
|
|
///
|
|
/// # Returns
|
|
/// A tuple of (ciphertext, nonce) where:
|
|
/// - `ciphertext` - The encrypted data
|
|
/// - `nonce` - The nonce used for encryption (should be stored with ciphertext)
|
|
///
|
|
/// `aad` is authenticated but not encrypted; pass empty bytes for the
|
|
/// legacy unbound format (for AES-GCM the two are byte-identical).
|
|
async fn encrypt(&self, key_material: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<(Vec<u8>, Vec<u8>)>;
|
|
|
|
/// Decrypt ciphertext data using a master key material
|
|
///
|
|
/// # Arguments
|
|
/// * `key_material` - The master key material (raw bytes)
|
|
/// * `ciphertext` - The encrypted data
|
|
/// * `nonce` - The nonce used for encryption
|
|
///
|
|
/// # Returns
|
|
/// The decrypted plaintext data
|
|
///
|
|
/// `aad` must be byte-identical to the value used at encryption time or
|
|
/// authentication fails; pass empty bytes for legacy unbound ciphertext.
|
|
async fn decrypt(&self, key_material: &[u8], ciphertext: &[u8], nonce: &[u8], aad: &[u8]) -> Result<Vec<u8>>;
|
|
|
|
/// Get the algorithm name used by this implementation
|
|
#[allow(dead_code)] // May be used by implementations or for debugging
|
|
fn algorithm(&self) -> &'static str;
|
|
|
|
/// Get the required key material size in bytes
|
|
#[allow(dead_code)] // May be used by implementations or for debugging
|
|
fn key_size(&self) -> usize;
|
|
}
|
|
|
|
/// AES-256-GCM implementation of DEK encryption
|
|
pub struct AesDekCrypto;
|
|
|
|
impl AesDekCrypto {
|
|
/// Create a new AES-256-GCM DEK crypto instance
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl DekCrypto for AesDekCrypto {
|
|
async fn encrypt(&self, key_material: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
|
|
use aes_gcm::{
|
|
Aes256Gcm, Key, Nonce,
|
|
aead::{Aead, KeyInit, Payload},
|
|
};
|
|
|
|
// Validate key material length
|
|
if key_material.len() != 32 {
|
|
return Err(KmsError::cryptographic_error(
|
|
"key",
|
|
format!("Invalid key length: expected 32 bytes, got {}", key_material.len()),
|
|
));
|
|
}
|
|
|
|
// Create cipher from key material
|
|
let key =
|
|
Key::<Aes256Gcm>::try_from(key_material).map_err(|_| KmsError::cryptographic_error("key", "Invalid key length"))?;
|
|
let cipher = Aes256Gcm::new(&key);
|
|
|
|
// Generate random nonce (12 bytes for GCM)
|
|
let mut nonce_bytes = [0u8; 12];
|
|
rand::rng().fill_bytes(&mut nonce_bytes);
|
|
let nonce = Nonce::from(nonce_bytes);
|
|
|
|
// Encrypt plaintext; an empty `aad` produces the same bytes as no AAD.
|
|
let ciphertext = cipher
|
|
.encrypt(&nonce, Payload { msg: plaintext, aad })
|
|
.map_err(|e| KmsError::cryptographic_error("encrypt", e.to_string()))?;
|
|
|
|
Ok((ciphertext, nonce_bytes.to_vec()))
|
|
}
|
|
|
|
async fn decrypt(&self, key_material: &[u8], ciphertext: &[u8], nonce: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
|
|
use aes_gcm::{
|
|
Aes256Gcm, Key, Nonce,
|
|
aead::{Aead, KeyInit, Payload},
|
|
};
|
|
|
|
// Validate nonce length
|
|
if nonce.len() != 12 {
|
|
return Err(KmsError::cryptographic_error("nonce", "Invalid nonce length: expected 12 bytes"));
|
|
}
|
|
|
|
// Validate key material length
|
|
if key_material.len() != 32 {
|
|
return Err(KmsError::cryptographic_error(
|
|
"key",
|
|
format!("Invalid key length: expected 32 bytes, got {}", key_material.len()),
|
|
));
|
|
}
|
|
|
|
// Create cipher from key material
|
|
let key =
|
|
Key::<Aes256Gcm>::try_from(key_material).map_err(|_| KmsError::cryptographic_error("key", "Invalid key length"))?;
|
|
let cipher = Aes256Gcm::new(&key);
|
|
|
|
// Convert nonce
|
|
let mut nonce_array = [0u8; 12];
|
|
nonce_array.copy_from_slice(nonce);
|
|
let nonce_ref = Nonce::from(nonce_array);
|
|
|
|
// Decrypt ciphertext; the AAD must match the encryption-time bytes.
|
|
let plaintext = cipher
|
|
.decrypt(&nonce_ref, Payload { msg: ciphertext, aad })
|
|
.map_err(|e| KmsError::cryptographic_error("decrypt", e.to_string()))?;
|
|
|
|
Ok(plaintext)
|
|
}
|
|
|
|
#[allow(dead_code)] // Trait method, may be used by implementations
|
|
fn algorithm(&self) -> &'static str {
|
|
"AES-256-GCM"
|
|
}
|
|
|
|
#[allow(dead_code)] // Trait method, may be used by implementations
|
|
fn key_size(&self) -> usize {
|
|
32 // 256 bits
|
|
}
|
|
}
|
|
|
|
impl Default for AesDekCrypto {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Generate random key material for the given algorithm.
|
|
///
|
|
/// The lengths must track [`crate::types::KeySpec::key_size`].
|
|
///
|
|
/// # Arguments
|
|
/// * `algorithm` - The key algorithm (e.g., "AES_256", "AES_128", "ChaCha20")
|
|
///
|
|
/// # Returns
|
|
/// A vector containing the generated key material
|
|
pub fn generate_key_material(algorithm: &str) -> Result<Vec<u8>> {
|
|
let key_size = match algorithm {
|
|
"AES_256" | "ChaCha20" => 32,
|
|
"AES_128" => 16,
|
|
_ => return Err(KmsError::unsupported_algorithm(algorithm)),
|
|
};
|
|
|
|
let mut key_material = vec![0u8; key_size];
|
|
rand::rng().fill_bytes(&mut key_material);
|
|
Ok(key_material)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::test_support::{deserialize_with_ignored_only_unknown, unknown_field_metric};
|
|
use metrics_util::debugging::DebuggingRecorder;
|
|
|
|
#[tokio::test]
|
|
async fn test_aes_dek_crypto_encrypt_decrypt() {
|
|
let crypto = AesDekCrypto::new();
|
|
|
|
// Generate test key material
|
|
let key_material = generate_key_material("AES_256").expect("Failed to generate key material");
|
|
let plaintext = b"Hello, World! This is a test message.";
|
|
|
|
// Test encryption
|
|
let (ciphertext, nonce) = crypto
|
|
.encrypt(&key_material, plaintext, &[])
|
|
.await
|
|
.expect("Encryption should succeed");
|
|
|
|
assert!(!ciphertext.is_empty());
|
|
assert_eq!(nonce.len(), 12);
|
|
assert_ne!(ciphertext, plaintext);
|
|
|
|
// Test decryption
|
|
let decrypted = crypto
|
|
.decrypt(&key_material, &ciphertext, &nonce, &[])
|
|
.await
|
|
.expect("Decryption should succeed");
|
|
|
|
assert_eq!(decrypted, plaintext);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_aes_dek_crypto_invalid_key_size() {
|
|
let crypto = AesDekCrypto::new();
|
|
let invalid_key = vec![0u8; 16]; // Too short
|
|
let plaintext = b"test";
|
|
|
|
let result = crypto.encrypt(&invalid_key, plaintext, &[]).await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_aes_dek_crypto_invalid_nonce() {
|
|
let crypto = AesDekCrypto::new();
|
|
let key_material = generate_key_material("AES_256").expect("Failed to generate key material");
|
|
let ciphertext = vec![0u8; 16];
|
|
let invalid_nonce = vec![0u8; 8]; // Too short
|
|
|
|
let result = crypto.decrypt(&key_material, &ciphertext, &invalid_nonce, &[]).await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
/// The AAD parameter genuinely binds the ciphertext: the same bytes must
|
|
/// be presented at decrypt time, and empty AAD is byte-compatible with the
|
|
/// legacy no-AAD format so both generations share one code path.
|
|
#[tokio::test]
|
|
async fn test_aad_binds_the_ciphertext() {
|
|
let crypto = AesDekCrypto::new();
|
|
let key_material = generate_key_material("AES_256").expect("Failed to generate key material");
|
|
let context = HashMap::from([("bucket".to_string(), "aad-bucket".to_string())]);
|
|
let aad = context_aad(&context).expect("context must canonicalize");
|
|
|
|
let (ciphertext, nonce) = crypto
|
|
.encrypt(&key_material, b"bound-dek", &aad)
|
|
.await
|
|
.expect("encryption with AAD should succeed");
|
|
assert_eq!(
|
|
crypto
|
|
.decrypt(&key_material, &ciphertext, &nonce, &aad)
|
|
.await
|
|
.expect("matching AAD must decrypt"),
|
|
b"bound-dek"
|
|
);
|
|
assert!(
|
|
crypto.decrypt(&key_material, &ciphertext, &nonce, &[]).await.is_err(),
|
|
"stripping the AAD must fail authentication"
|
|
);
|
|
let other = context_aad(&HashMap::from([("bucket".to_string(), "other".to_string())])).expect("canonicalize");
|
|
assert!(
|
|
crypto.decrypt(&key_material, &ciphertext, &nonce, &other).await.is_err(),
|
|
"a different AAD must fail authentication"
|
|
);
|
|
}
|
|
|
|
/// `envelope_wrap_aad` maps the binding flag to the exact AAD bytes the
|
|
/// wrap was sealed with, and fails closed on versions from the future.
|
|
#[test]
|
|
fn test_envelope_wrap_aad_mapping() {
|
|
let mut envelope = DataKeyEnvelope {
|
|
key_id: "test-key-id".to_string(),
|
|
master_key_id: "master-key-id".to_string(),
|
|
key_spec: "AES_256".to_string(),
|
|
encrypted_key: vec![1, 2, 3, 4],
|
|
nonce: vec![5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
|
encryption_context: HashMap::from([("bucket".to_string(), "b".to_string())]),
|
|
created_at: Zoned::now(),
|
|
master_key_version: None,
|
|
context_binding: None,
|
|
};
|
|
|
|
assert!(
|
|
envelope_wrap_aad(&envelope).expect("legacy envelopes are valid").is_empty(),
|
|
"legacy envelopes were sealed without AAD"
|
|
);
|
|
|
|
envelope.context_binding = Some(CONTEXT_BINDING_AAD_V1);
|
|
assert_eq!(
|
|
envelope_wrap_aad(&envelope).expect("v1 binding is valid"),
|
|
context_aad(&envelope.encryption_context).expect("canonicalize"),
|
|
"the v1 binding must reproduce the canonical context bytes"
|
|
);
|
|
|
|
envelope.context_binding = Some(9);
|
|
let error = envelope_wrap_aad(&envelope).expect_err("an unknown binding version must fail closed");
|
|
assert!(error.to_string().contains("context binding version"), "got {error:?}");
|
|
}
|
|
|
|
/// The binding flag round-trips through JSON, stays absent for `None` so
|
|
/// legacy writers and readers keep the historical shape, and defaults to
|
|
/// `None` on envelopes that predate it.
|
|
#[test]
|
|
fn test_context_binding_serde_round_trip() {
|
|
let mut envelope = DataKeyEnvelope {
|
|
key_id: "test-key-id".to_string(),
|
|
master_key_id: "master-key-id".to_string(),
|
|
key_spec: "AES_256".to_string(),
|
|
encrypted_key: vec![1, 2, 3, 4],
|
|
nonce: vec![5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
|
encryption_context: HashMap::new(),
|
|
created_at: Zoned::now(),
|
|
master_key_version: None,
|
|
context_binding: Some(CONTEXT_BINDING_AAD_V1),
|
|
};
|
|
|
|
let serialized = serde_json::to_vec(&envelope).expect("serialize envelope");
|
|
let value: serde_json::Value = serde_json::from_slice(&serialized).expect("parse serialized envelope");
|
|
assert_eq!(value.get("context_binding"), Some(&serde_json::json!(1)));
|
|
let deserialized: DataKeyEnvelope = serde_json::from_slice(&serialized).expect("deserialize envelope");
|
|
assert_eq!(deserialized.context_binding, Some(CONTEXT_BINDING_AAD_V1));
|
|
|
|
envelope.context_binding = None;
|
|
let value = serde_json::to_value(&envelope).expect("serialize envelope");
|
|
assert!(
|
|
!value
|
|
.as_object()
|
|
.expect("envelope is an object")
|
|
.contains_key("context_binding"),
|
|
"None must keep the historical shape"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_generate_key_material() {
|
|
let key_256 = generate_key_material("AES_256").expect("Should generate AES_256 key");
|
|
assert_eq!(key_256.len(), 32);
|
|
|
|
let key_128 = generate_key_material("AES_128").expect("Should generate AES_128 key");
|
|
assert_eq!(key_128.len(), 16);
|
|
|
|
// Keys should be different
|
|
let key_256_2 = generate_key_material("AES_256").expect("Should generate AES_256 key");
|
|
assert_ne!(key_256, key_256_2);
|
|
|
|
// Invalid algorithm
|
|
assert!(generate_key_material("INVALID").is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_data_key_envelope_serialization() {
|
|
let envelope = DataKeyEnvelope {
|
|
key_id: "test-key-id".to_string(),
|
|
master_key_id: "master-key-id".to_string(),
|
|
key_spec: "AES_256".to_string(),
|
|
encrypted_key: vec![1, 2, 3, 4],
|
|
nonce: vec![5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
|
encryption_context: {
|
|
let mut map = HashMap::new();
|
|
map.insert("bucket".to_string(), "test-bucket".to_string());
|
|
map
|
|
},
|
|
created_at: Zoned::now(),
|
|
master_key_version: None,
|
|
context_binding: None,
|
|
};
|
|
|
|
// Test serialization
|
|
let serialized = serde_json::to_vec(&envelope).expect("Serialization should succeed");
|
|
assert!(!serialized.is_empty());
|
|
|
|
// Test deserialization
|
|
let deserialized: DataKeyEnvelope = serde_json::from_slice(&serialized).expect("Deserialization should succeed");
|
|
assert_eq!(deserialized.key_id, envelope.key_id);
|
|
assert_eq!(deserialized.master_key_id, envelope.master_key_id);
|
|
assert_eq!(deserialized.encrypted_key, envelope.encrypted_key);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_data_key_envelope_backward_compatibility() {
|
|
// Test deserialization with current Zoned format (with timezone annotation)
|
|
let envelope_json = r#"{
|
|
"key_id": "test-key-id",
|
|
"master_key_id": "master-key-id",
|
|
"key_spec": "AES_256",
|
|
"encrypted_key": [1, 2, 3, 4],
|
|
"nonce": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
|
"encryption_context": {"bucket": "test-bucket"},
|
|
"created_at": "2024-01-01T00:00:00+00:00[UTC]"
|
|
}"#;
|
|
|
|
let deserialized: DataKeyEnvelope = serde_json::from_str(envelope_json).expect("Should deserialize current format");
|
|
assert_eq!(deserialized.key_id, "test-key-id");
|
|
assert_eq!(deserialized.master_key_id, "master-key-id");
|
|
// Envelopes persisted before versioning must parse with no master key version.
|
|
assert_eq!(deserialized.master_key_version, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_data_key_envelope_accepts_legacy_rfc3339_timestamp() {
|
|
let envelope_json = r#"{
|
|
"key_id": "test-key-id",
|
|
"master_key_id": "master-key-id",
|
|
"key_spec": "AES_256",
|
|
"encrypted_key": [1, 2, 3, 4],
|
|
"nonce": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
|
"encryption_context": {"bucket": "test-bucket"},
|
|
"created_at": "2024-01-01T00:00:00+00:00"
|
|
}"#;
|
|
|
|
let deserialized: DataKeyEnvelope = serde_json::from_str(envelope_json).expect("Should deserialize legacy format");
|
|
assert_eq!(deserialized.key_id, "test-key-id");
|
|
assert_eq!(deserialized.master_key_id, "master-key-id");
|
|
assert_eq!(deserialized.master_key_version, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_data_key_envelope_unknown_fields_remain_readable() {
|
|
const UNKNOWN_FIELD_VALUE: &str = "field value must not be logged";
|
|
let envelope = serde_json::json!({
|
|
"key_id": "test-key-id",
|
|
"master_key_id": "master-key-id",
|
|
"key_spec": "AES_256",
|
|
"encrypted_key": [1, 2, 3, 4],
|
|
"nonce": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
|
"encryption_context": {"bucket": "test-bucket"},
|
|
"created_at": "2024-01-01T00:00:00+00:00[UTC]"
|
|
});
|
|
let long_field = format!("{}界", "a".repeat(126));
|
|
let long_prefix = "a".repeat(126);
|
|
let injection_field = "b\n\u{1b}[31m";
|
|
|
|
let record_with_unknown = |field: &str| {
|
|
let mut record = envelope.clone();
|
|
let object = record.as_object_mut().expect("envelope is an object");
|
|
object.insert(field.to_owned(), serde_json::json!(UNKNOWN_FIELD_VALUE));
|
|
object.insert("zeta_extension".to_owned(), serde_json::json!("another value must not be logged"));
|
|
serde_json::to_vec(&record).expect("encode envelope with unknown fields")
|
|
};
|
|
let long_record = record_with_unknown(&long_field);
|
|
let injection_record = record_with_unknown(injection_field);
|
|
let logs = crate::test_support::CapturedLogs::default();
|
|
let subscriber = tracing_subscriber::fmt()
|
|
.with_ansi(false)
|
|
.with_max_level(tracing::Level::WARN)
|
|
.with_writer(logs.clone())
|
|
.finish();
|
|
let dispatch = tracing::Dispatch::new(subscriber);
|
|
let parse = |record: &[u8]| {
|
|
let recorder = DebuggingRecorder::new();
|
|
let envelope = metrics::with_local_recorder(&recorder, || {
|
|
tracing::dispatcher::with_default(&dispatch, || {
|
|
serde_json::from_slice(record).expect("unknown fields must remain readable")
|
|
})
|
|
});
|
|
assert_eq!(unknown_field_metric(&recorder, "data-key-envelope"), 2);
|
|
envelope
|
|
};
|
|
let deserialized: DataKeyEnvelope = parse(&long_record);
|
|
let _: DataKeyEnvelope = parse(&long_record);
|
|
let _: DataKeyEnvelope = parse(&injection_record);
|
|
let _: DataKeyEnvelope = parse(&injection_record);
|
|
assert_eq!(deserialized.key_id, "test-key-id");
|
|
assert_eq!(deserialized.master_key_version, None);
|
|
|
|
let output = logs.output();
|
|
assert!(output.contains("WARN"));
|
|
assert_eq!(output.matches("KMS data-key envelope contains unknown fields").count(), 3);
|
|
assert!(output.contains(&long_prefix));
|
|
assert!(!output.contains(&long_field));
|
|
assert!(output.contains("field_name_truncated=true"));
|
|
assert!(output.contains(r#"\n\u{1b}[31m"#));
|
|
assert!(!output.contains("zeta_extension"));
|
|
assert!(output.contains("field_count=2"));
|
|
for observed_records in [1, 2, 4] {
|
|
assert!(output.contains(&format!("observed_records={observed_records}")));
|
|
}
|
|
assert!(!output.contains("observed_records=3"));
|
|
assert!(!output.contains(UNKNOWN_FIELD_VALUE));
|
|
assert!(!output.contains("another value must not be logged"));
|
|
|
|
let streamed: DataKeyEnvelope = deserialize_with_ignored_only_unknown(envelope, "stream_only_extension")
|
|
.expect("unknown values must be consumed through deserialize_ignored_any");
|
|
assert_eq!(streamed.key_id, "test-key-id");
|
|
}
|
|
|
|
#[test]
|
|
fn test_data_key_envelope_none_version_serializes_without_field() {
|
|
// A `None` version must keep the serialized envelope on the historical
|
|
// seven-field JSON shape so non-rotating backends emit byte-compatible
|
|
// envelopes that older readers accept unchanged.
|
|
let envelope = DataKeyEnvelope {
|
|
key_id: "test-key-id".to_string(),
|
|
master_key_id: "master-key-id".to_string(),
|
|
key_spec: "AES_256".to_string(),
|
|
encrypted_key: vec![1, 2, 3, 4],
|
|
nonce: vec![5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
|
encryption_context: HashMap::new(),
|
|
created_at: Zoned::now(),
|
|
master_key_version: None,
|
|
context_binding: None,
|
|
};
|
|
|
|
let value = serde_json::to_value(&envelope).expect("serialize envelope");
|
|
let object = value.as_object().expect("envelope serializes to an object");
|
|
assert!(!object.contains_key("master_key_version"));
|
|
assert_eq!(object.len(), 7, "None version must not change the seven-field JSON shape");
|
|
}
|
|
|
|
#[test]
|
|
fn test_data_key_envelope_version_round_trip() {
|
|
let envelope = DataKeyEnvelope {
|
|
key_id: "test-key-id".to_string(),
|
|
master_key_id: "master-key-id".to_string(),
|
|
key_spec: "AES_256".to_string(),
|
|
encrypted_key: vec![1, 2, 3, 4],
|
|
nonce: vec![5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
|
encryption_context: HashMap::new(),
|
|
created_at: Zoned::now(),
|
|
master_key_version: Some(7),
|
|
context_binding: None,
|
|
};
|
|
|
|
let serialized = serde_json::to_vec(&envelope).expect("serialize envelope");
|
|
let value: serde_json::Value = serde_json::from_slice(&serialized).expect("parse serialized envelope");
|
|
assert_eq!(value.get("master_key_version"), Some(&serde_json::json!(7)));
|
|
|
|
let deserialized: DataKeyEnvelope = serde_json::from_slice(&serialized).expect("deserialize envelope");
|
|
assert_eq!(deserialized.master_key_version, Some(7));
|
|
}
|
|
|
|
#[test]
|
|
fn test_data_key_envelope_discriminator_rejects_local_formats() {
|
|
let kms_envelope = br#"{
|
|
"key_id": "test-key-id",
|
|
"master_key_id": "master-key-id",
|
|
"key_spec": "AES_256",
|
|
"encrypted_key": [1, 2, 3, 4],
|
|
"nonce": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
|
"encryption_context": {},
|
|
"created_at": "2024-01-01T00:00:00+00:00"
|
|
}"#;
|
|
let minio_legacy = br#"{"aead":"AES-256-GCM-HMAC-SHA-256","iv":[1],"nonce":[2],"bytes":[3]}"#;
|
|
let duplicate_key_id = [b"{\"key_id\":\"duplicate\",".as_slice(), &kms_envelope[1..]].concat();
|
|
// Rotation-aware envelope: the optional master_key_version field must not
|
|
// change how mixed batches of old and new envelopes are routed.
|
|
let versioned_envelope = {
|
|
let mut value: serde_json::Value = serde_json::from_slice(kms_envelope).expect("parse KMS envelope fixture");
|
|
value
|
|
.as_object_mut()
|
|
.expect("KMS envelope fixture is an object")
|
|
.insert("master_key_version".to_string(), serde_json::json!(2));
|
|
serde_json::to_vec(&value).expect("serialize versioned envelope")
|
|
};
|
|
|
|
assert!(is_data_key_envelope(kms_envelope));
|
|
assert!(is_data_key_envelope(&versioned_envelope));
|
|
assert!(is_data_key_envelope(&[b" \n".as_slice(), kms_envelope].concat()));
|
|
assert!(!is_data_key_envelope(&duplicate_key_id));
|
|
assert!(!is_data_key_envelope(b"bm9uY2U=:Y2lwaGVydGV4dA=="));
|
|
assert!(!is_data_key_envelope(minio_legacy));
|
|
|
|
let envelope_value: serde_json::Value = serde_json::from_slice(kms_envelope).expect("parse KMS envelope fixture");
|
|
for required_field in [
|
|
"key_id",
|
|
"master_key_id",
|
|
"key_spec",
|
|
"encrypted_key",
|
|
"nonce",
|
|
"encryption_context",
|
|
"created_at",
|
|
] {
|
|
let mut incomplete = envelope_value.clone();
|
|
incomplete
|
|
.as_object_mut()
|
|
.expect("KMS envelope fixture is an object")
|
|
.remove(required_field);
|
|
assert!(
|
|
!is_data_key_envelope(&serde_json::to_vec(&incomplete).expect("serialize incomplete envelope")),
|
|
"missing {required_field} must not classify as a KMS envelope"
|
|
);
|
|
}
|
|
}
|
|
}
|