Add snapshot/range storage primitives, gate GET preconditions on served snapshot, support partial-decrypt Range GET for SSE-encrypted objects

This commit is contained in:
2026-04-24 18:45:22 +08:00
parent 4f05192548
commit 5aba9ac9e9
11 changed files with 2219 additions and 542 deletions

View File

@@ -4,7 +4,9 @@ use rand::RngCore;
use std::collections::HashMap;
use std::path::Path;
use crate::aes_gcm::{decrypt_stream_chunked, encrypt_stream_chunked, CryptoError};
use crate::aes_gcm::{
decrypt_stream_chunked, decrypt_stream_chunked_range, encrypt_stream_chunked, CryptoError,
};
use crate::kms::KmsService;
#[derive(Debug, Clone, PartialEq)]
@@ -37,6 +39,8 @@ pub struct EncryptionMetadata {
pub nonce: String,
pub encrypted_data_key: Option<String>,
pub kms_key_id: Option<String>,
pub chunk_size: Option<usize>,
pub plaintext_size: Option<u64>,
}
impl EncryptionMetadata {
@@ -53,6 +57,15 @@ impl EncryptionMetadata {
if let Some(ref kid) = self.kms_key_id {
map.insert("x-amz-encryption-key-id".to_string(), kid.clone());
}
if let Some(cs) = self.chunk_size {
map.insert("x-amz-encryption-chunk-size".to_string(), cs.to_string());
}
if let Some(ps) = self.plaintext_size {
map.insert(
"x-amz-encryption-plaintext-size".to_string(),
ps.to_string(),
);
}
map
}
@@ -64,6 +77,12 @@ impl EncryptionMetadata {
nonce: nonce.clone(),
encrypted_data_key: meta.get("x-amz-encrypted-data-key").cloned(),
kms_key_id: meta.get("x-amz-encryption-key-id").cloned(),
chunk_size: meta
.get("x-amz-encryption-chunk-size")
.and_then(|s| s.parse().ok()),
plaintext_size: meta
.get("x-amz-encryption-plaintext-size")
.and_then(|s| s.parse().ok()),
})
}
@@ -76,6 +95,8 @@ impl EncryptionMetadata {
meta.remove("x-amz-encryption-nonce");
meta.remove("x-amz-encrypted-data-key");
meta.remove("x-amz-encryption-key-id");
meta.remove("x-amz-encryption-chunk-size");
meta.remove("x-amz-encryption-plaintext-size");
}
}
@@ -212,6 +233,11 @@ impl EncryptionService {
data_key
};
let plaintext_size = tokio::fs::metadata(input_path)
.await
.map_err(CryptoError::Io)?
.len();
let ip = input_path.to_owned();
let op = output_path.to_owned();
let ak = actual_key;
@@ -228,22 +254,23 @@ impl EncryptionService {
nonce: B64.encode(nonce),
encrypted_data_key,
kms_key_id,
chunk_size: Some(chunk_size),
plaintext_size: Some(plaintext_size),
})
}
pub async fn decrypt_object(
async fn resolve_data_key(
&self,
input_path: &Path,
output_path: &Path,
enc_meta: &EncryptionMetadata,
customer_key: Option<&[u8]>,
) -> Result<(), CryptoError> {
) -> Result<([u8; 32], [u8; 12]), CryptoError> {
let nonce_bytes = B64
.decode(&enc_meta.nonce)
.map_err(|e| CryptoError::EncryptionFailed(format!("Bad nonce encoding: {}", e)))?;
if nonce_bytes.len() != 12 {
return Err(CryptoError::InvalidNonceSize(nonce_bytes.len()));
}
let nonce: [u8; 12] = nonce_bytes.try_into().unwrap();
let data_key: [u8; 32] = if let Some(ck) = customer_key {
if ck.len() != 32 {
@@ -281,15 +308,62 @@ impl EncryptionService {
self.unwrap_data_key(wrapped)?
};
Ok((data_key, nonce))
}
pub async fn decrypt_object(
&self,
input_path: &Path,
output_path: &Path,
enc_meta: &EncryptionMetadata,
customer_key: Option<&[u8]>,
) -> Result<(), CryptoError> {
let (data_key, nonce) = self.resolve_data_key(enc_meta, customer_key).await?;
let ip = input_path.to_owned();
let op = output_path.to_owned();
let nb: [u8; 12] = nonce_bytes.try_into().unwrap();
tokio::task::spawn_blocking(move || decrypt_stream_chunked(&ip, &op, &data_key, &nb))
tokio::task::spawn_blocking(move || decrypt_stream_chunked(&ip, &op, &data_key, &nonce))
.await
.map_err(|e| CryptoError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))??;
Ok(())
}
pub async fn decrypt_object_range(
&self,
input_path: &Path,
output_path: &Path,
enc_meta: &EncryptionMetadata,
customer_key: Option<&[u8]>,
plain_start: u64,
plain_end_inclusive: u64,
) -> Result<u64, CryptoError> {
let chunk_size = enc_meta.chunk_size.ok_or_else(|| {
CryptoError::EncryptionFailed("chunk_size missing from encryption metadata".into())
})?;
let plaintext_size = enc_meta.plaintext_size.ok_or_else(|| {
CryptoError::EncryptionFailed("plaintext_size missing from encryption metadata".into())
})?;
let (data_key, nonce) = self.resolve_data_key(enc_meta, customer_key).await?;
let ip = input_path.to_owned();
let op = output_path.to_owned();
tokio::task::spawn_blocking(move || {
decrypt_stream_chunked_range(
&ip,
&op,
&data_key,
&nonce,
chunk_size,
plaintext_size,
plain_start,
plain_end_inclusive,
)
})
.await
.map_err(|e| CryptoError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?
}
}
#[cfg(test)]
@@ -383,12 +457,26 @@ mod tests {
nonce: "dGVzdG5vbmNlMTI=".to_string(),
encrypted_data_key: Some("c29tZWtleQ==".to_string()),
kms_key_id: None,
chunk_size: Some(65_536),
plaintext_size: Some(1_234_567),
};
let map = meta.to_metadata_map();
let restored = EncryptionMetadata::from_metadata(&map).unwrap();
assert_eq!(restored.algorithm, "AES256");
assert_eq!(restored.nonce, meta.nonce);
assert_eq!(restored.encrypted_data_key, meta.encrypted_data_key);
assert_eq!(restored.chunk_size, Some(65_536));
assert_eq!(restored.plaintext_size, Some(1_234_567));
}
#[test]
fn test_encryption_metadata_legacy_missing_sizes() {
let mut map = HashMap::new();
map.insert("x-amz-server-side-encryption".to_string(), "AES256".into());
map.insert("x-amz-encryption-nonce".to_string(), "aGVsbG8=".into());
let restored = EncryptionMetadata::from_metadata(&map).unwrap();
assert_eq!(restored.chunk_size, None);
assert_eq!(restored.plaintext_size, None);
}
#[test]