Files
Oxicloud/src/infrastructure/services/encrypted_blob_backend.rs
T
Claude 221c1f31b0 perf: round 11 — StoragePath joined-only, classifier fusion, memoized bodies, query-shape pack, SPA fine-grained stars
Backend (each change benchmark-gated with BEFORE replicas + equivalence
gates; see examples/bench_round11_micro.rs, bench_round11_queries.rs,
bench_log_writer.rs and benches/ROUND11.md — final numbers land in the
follow-up doc commit):

- StoragePath re-representation: single canonical joined String, segments
  derived on demand; File/Folder drop the duplicated path_string field
  (4000→1000 allocs per 500-row listing page)
- Display classifier fusion: classify_display shares one stack-lowered
  extension across the three decision trees; call sites in FileDto,
  folder/favorites/recent handlers, trash, path-resolver (+ interning
  where Arc::from was still used)
- /status.php and /openapi.json memoized into OnceLock<Bytes> (openapi
  rebuilt a 171 KiB spec per request: 2.8 ms → 18 ns)
- NC upload-session PROPFIND: write! + pre-sized body + stack RFC2822
  dates (2.3-2.6x, 2582→772 allocs at 256 chunks)
- REST download: dead FileDto clone removed (capture mime/size + move)
- CalendarEventDto/TrashedItem into_parts moves (11 KiB ical_data memcpy
  gone per CalDAV row); CardDAV getlastmodified stack render
- 4xx path: borrowed ErrorResponse serialize, ErrorKind::as_str,
  not_found/already_exists clone kill
- vCard emit via write!; search page moved out with into_iter skip/take;
  content-hit UUIDs parsed once; group last-user check via HashSet
- RateLimiter: lock-free get + insert (and_upsert_with variant REJECTED
  by benchmark); CSRF token borrow-compare + borrowed cookie extraction
- Thumbnail/preview ETags built from as_str (Debug-identical bytes)
- Encrypted backend: encrypt_in_place_detached single-buffer write path,
  chunk-sized reserve in collect_stream; retry labels made lazy
- PG: deferred upload registration 3→1 round-trips (persist_file CTE
  template); direct_grant_cache for Calendar/AddressBook/Playlist authz
  (single-flight + set_role/clear_role invalidation); expand_user
  tokio::join!; geo clusters min(uuid)::text; recluster face assignment
  batched into one UNNEST update
- People recluster cosine: norms precomputed once (bit-identical gate)
- NC capabilities poll logs demoted to debug; tracing-appender dep added
  for the log-writer benchmark

Frontend:
- ResourceList.selectedEntries O(N)-per-toggle → id-index projection
  O(k log k); favorites/recent consume the batchToolbar snippet param and
  drop their duplicate filter + dead selectedIds mirror
- Recent: star state via new favoriteIds prop — a star click no longer
  rebuilds all N entries
- admin timeAgo >30d fallback uses the cached Intl.DateTimeFormat
- vitest gates in src/lib/components/round11.bench.test.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABhTEHuGujvwoodh67Kga7
2026-07-18 22:02:00 +00:00

514 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `EncryptedBlobBackend` — AES-256-GCM encryption decorator for blob storage.
//!
//! Transparently encrypts blobs before they reach the inner backend and
//! decrypts them on read. Each blob gets a random 96-bit nonce which is
//! prepended to the ciphertext. The GCM authentication tag (16 bytes) is
//! appended by the cipher.
//!
//! **IMPORTANT**: BLAKE3 hashing is performed on the *plaintext* by
//! `DedupService` before this layer sees the blob, so content-addressable
//! dedup still works correctly.
//!
//! Layout on disk/S3: `[12-byte nonce][ciphertext + 16-byte GCM tag]`
//!
//! ## Runtime & memory characteristics
//!
//! GCM is all-or-nothing per blob: a blob can only be decrypted whole, so
//! every read materializes the full plaintext. This stays bounded because
//! `DedupService` stores all new content as CDC chunks (≤ 1 MiB each) and
//! resolves Range requests to the overlapping chunks *before* calling this
//! backend — an encrypted seek in a large video decrypts a handful of
//! chunks, never the file. The unbounded case is **legacy whole-file
//! blobs** written before CDC chunking: a range read of one still decrypts
//! the entire blob (re-uploading the file re-stores it chunked).
//!
//! Crypto work for payloads ≥ 64 KiB runs on the blocking pool so AES-GCM
//! never stalls the async runtime, and decryption happens **in place** —
//! the ciphertext buffer is reused for the plaintext instead of allocating
//! a second copy.
use std::path::{Path, PathBuf};
use std::pin::Pin;
use aes_gcm::aead::{AeadInPlace, KeyInit, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Nonce};
use bytes::Bytes;
use std::sync::Arc;
use tokio::fs;
use crate::application::ports::blob_storage_ports::{
BlobStorageBackend, BlobStream, StorageHealthStatus,
};
use crate::domain::errors::DomainError;
/// Nonce size for AES-256-GCM (96 bits = 12 bytes).
const NONCE_SIZE: usize = 12;
/// AES-256-GCM authentication tag length appended after the ciphertext.
const TAG_SIZE: usize = 16;
/// Payloads at or above this size run crypto on the blocking pool; below
/// it the `spawn_blocking` round-trip costs more than the AES work itself.
const CRYPTO_OFFLOAD_THRESHOLD: usize = 64 * 1024;
/// Emission size for decrypted payloads — matches the 64 KiB chunks the
/// unencrypted backends stream, so downstream consumers (HTTP bodies,
/// hashers) see the same backpressure shape either way.
const PLAINTEXT_EMIT_SIZE: usize = 64 * 1024;
/// `BlobStorageBackend` decorator that encrypts blobs at rest.
pub struct EncryptedBlobBackend {
inner: Arc<dyn BlobStorageBackend>,
/// `Arc` so the per-op `clone()` handed to `offload_crypto` closures is
/// an atomic bump instead of copying the ~240-byte expanded AES-256
/// round-key schedule on every chunk read/write.
cipher: Arc<Aes256Gcm>,
}
impl EncryptedBlobBackend {
/// Create a new encryption layer wrapping `inner`.
///
/// `key` must be exactly 32 bytes (AES-256).
pub fn new(inner: Arc<dyn BlobStorageBackend>, key: &[u8; 32]) -> Self {
let cipher =
Arc::new(Aes256Gcm::new_from_slice(key).expect("AES-256 key must be 32 bytes"));
Self { inner, cipher }
}
/// Generate a random 32-byte key suitable for AES-256.
pub fn generate_key() -> [u8; 32] {
use aes_gcm::aead::rand_core::RngCore;
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
key
}
}
/// Encrypt `data` into the on-disk layout: `[12-byte nonce][ciphertext + tag]`.
///
/// Single output buffer, mirroring the read side's `decrypt_in_place`:
/// the payload is copied exactly once and encrypted in place with the tag
/// appended. The old shape let `cipher.encrypt` allocate a full ciphertext
/// `Vec` and then copied it a second time behind the nonce — one extra
/// allocation + a full-size memcpy on every encrypted chunk write
/// (benches/ROUND11.md §15; output bytes identical for a given nonce).
fn encrypt_bytes(cipher: &Aes256Gcm, data: &[u8]) -> Result<Bytes, DomainError> {
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let mut out = Vec::with_capacity(NONCE_SIZE + data.len() + TAG_SIZE);
out.extend_from_slice(nonce.as_slice());
out.extend_from_slice(data);
let tag = cipher
.encrypt_in_place_detached(&nonce, b"", &mut out[NONCE_SIZE..])
.map_err(|e| DomainError::internal_error("Encryption", format!("encrypt failed: {e}")))?;
out.extend_from_slice(&tag);
Ok(Bytes::from(out))
}
/// Decrypt the on-disk layout `[nonce][ciphertext + tag]` **in place**.
///
/// Consumes the encrypted buffer and reuses it for the plaintext, so peak
/// RAM is one buffer — not ciphertext + plaintext side by side (which for
/// legacy whole-file blobs would double a multi-hundred-MB allocation).
fn decrypt_bytes(cipher: &Aes256Gcm, mut encrypted: Vec<u8>) -> Result<Bytes, DomainError> {
if encrypted.len() < NONCE_SIZE {
return Err(DomainError::internal_error(
"Encryption",
"encrypted blob too short (missing nonce)",
));
}
let mut ciphertext = encrypted.split_off(NONCE_SIZE); // `encrypted` keeps the nonce
let nonce = Nonce::from_slice(&encrypted);
cipher
.decrypt_in_place(nonce, b"", &mut ciphertext)
.map_err(|e| DomainError::internal_error("Encryption", format!("decrypt failed: {e}")))?;
Ok(Bytes::from(ciphertext))
}
/// Run a crypto closure inline for small payloads, on the blocking pool for
/// large ones — AES-GCM over megabytes must not stall async workers.
async fn offload_crypto<T, F>(work_len: usize, job: F) -> Result<T, DomainError>
where
T: Send + 'static,
F: FnOnce() -> Result<T, DomainError> + Send + 'static,
{
if work_len < CRYPTO_OFFLOAD_THRESHOLD {
return job();
}
tokio::task::spawn_blocking(job)
.await
.map_err(|e| DomainError::internal_error("Encryption", format!("crypto task join: {e}")))?
}
/// Turn a decrypted payload into a stream of bounded, zero-copy slices.
fn plaintext_stream(data: Bytes) -> BlobStream {
let len = data.len();
let slices: Vec<Result<Bytes, std::io::Error>> = (0..len)
.step_by(PLAINTEXT_EMIT_SIZE)
.map(|off| Ok(data.slice(off..len.min(off + PLAINTEXT_EMIT_SIZE))))
.collect();
Box::pin(futures::stream::iter(slices))
}
impl BlobStorageBackend for EncryptedBlobBackend {
fn initialize(
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
self.inner.initialize()
}
fn put_blob(
&self,
hash: &str,
source_path: &Path,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let hash = hash.to_string();
let source = source_path.to_path_buf();
let cipher = self.cipher.clone();
Box::pin(async move {
// Read plaintext from source
let plaintext = fs::read(&source).await.map_err(|e| {
DomainError::internal_error("Encryption", format!("read source: {e}"))
})?;
let len = plaintext.len();
let encrypted = offload_crypto(len, move || encrypt_bytes(&cipher, &plaintext)).await?;
// Hand the ciphertext straight to the inner backend. The previous
// implementation spooled it to a `.enc.tmp` file only for the
// inner backend to read it back — a full extra write + read of
// every blob that came through this path.
inner.put_blob_from_bytes(&hash, encrypted).await
})
}
fn put_blob_from_bytes(
&self,
hash: &str,
data: Bytes,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let hash = hash.to_string();
let cipher = self.cipher.clone();
Box::pin(async move {
let encrypted =
offload_crypto(data.len(), move || encrypt_bytes(&cipher, data.as_ref())).await?;
inner.put_blob_from_bytes(&hash, encrypted).await
})
}
fn put_blob_from_bytes_unsynced(
&self,
hash: &str,
data: Bytes,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
let inner = self.inner.clone();
let hash = hash.to_string();
let cipher = self.cipher.clone();
Box::pin(async move {
let encrypted =
offload_crypto(data.len(), move || encrypt_bytes(&cipher, data.as_ref())).await?;
inner.put_blob_from_bytes_unsynced(&hash, encrypted).await
})
}
fn sync_blobs(
&self,
hashes: &[String],
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
// Hashes key the *plaintext* content but address the same inner
// blobs, so the durability sweep forwards untouched.
self.inner.sync_blobs(hashes)
}
fn get_blob_stream(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let inner = self.inner.clone();
let hash = hash.to_string();
let cipher = self.cipher.clone();
Box::pin(async move {
// GCM must see the whole message: collect ciphertext, decrypt in
// place off the runtime, then stream zero-copy plaintext slices.
let enc_stream = inner.get_blob_stream(&hash).await?;
let encrypted = collect_stream(enc_stream).await?;
let len = encrypted.len();
let plaintext = offload_crypto(len, move || decrypt_bytes(&cipher, encrypted)).await?;
Ok(plaintext_stream(plaintext))
})
}
fn get_blob_range_stream(
&self,
hash: &str,
start: u64,
end: Option<u64>,
) -> Pin<Box<dyn std::future::Future<Output = Result<BlobStream, DomainError>> + Send + '_>>
{
let inner = self.inner.clone();
let hash = hash.to_string();
let cipher = self.cipher.clone();
Box::pin(async move {
// Decrypt the full blob, then slice the plaintext range without
// copying. For CDC chunks (every blob written since chunking
// landed) this is ≤ 1 MiB; only legacy whole-file blobs pay a
// full-blob decrypt here — see the module docs.
let enc_stream = inner.get_blob_stream(&hash).await?;
let encrypted = collect_stream(enc_stream).await?;
let len = encrypted.len();
let plaintext = offload_crypto(len, move || decrypt_bytes(&cipher, encrypted)).await?;
// `end` is exclusive — same contract as `LocalBlobBackend`, whose
// implementation reads `end - start` bytes. The previous version
// here treated it as inclusive and returned one extra byte on
// every bounded range, corrupting 206 responses when encryption
// was enabled.
let total = plaintext.len();
let end_excl = end.map(|e| e as usize).unwrap_or(total).min(total);
let start = (start as usize).min(end_excl);
Ok(plaintext_stream(plaintext.slice(start..end_excl)))
})
}
fn delete_blob(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), DomainError>> + Send + '_>> {
self.inner.delete_blob(hash)
}
fn blob_exists(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<bool, DomainError>> + Send + '_>> {
self.inner.blob_exists(hash)
}
fn blob_size(
&self,
hash: &str,
) -> Pin<Box<dyn std::future::Future<Output = Result<u64, DomainError>> + Send + '_>> {
// The stored size includes nonce + GCM tag overhead.
// Return the *plaintext* size by subtracting overhead.
let inner = self.inner.clone();
let hash = hash.to_string();
Box::pin(async move {
let encrypted_size = inner.blob_size(&hash).await?;
// overhead = 12 (nonce) + 16 (GCM tag) = 28 bytes
Ok(encrypted_size.saturating_sub(28))
})
}
fn health_check(
&self,
) -> Pin<
Box<dyn std::future::Future<Output = Result<StorageHealthStatus, DomainError>> + Send + '_>,
> {
let inner = self.inner.clone();
Box::pin(async move {
let mut status = inner.health_check().await?;
status.backend_type = format!("encrypted({})", status.backend_type);
status.message = format!("{} | Encryption: AES-256-GCM", status.message);
Ok(status)
})
}
fn backend_type(&self) -> &'static str {
"encrypted"
}
/// Transparent wrapper: the inner backend serves the bytes.
fn read_prefetch(&self) -> usize {
self.inner.read_prefetch()
}
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
// Encrypted blobs cannot be served directly from disk
None
}
}
/// Collect a byte stream into a single `Vec<u8>`.
///
/// Modern blobs are CDC chunks (≤ `CDC_MAX_CHUNK` + nonce/tag overhead),
/// delivered here as small reader frames — growing from `Vec::new()` paid
/// ~log₂(n) reallocations + a wasted ~0.75×-size memcpy per read. Reserving
/// one chunk's worth up front on the first frame makes the common case a
/// single allocation; legacy whole-file blobs beyond that fall back to
/// normal doubling (benches/ROUND11.md §16: 9 → 1 allocs on a 1 MiB blob).
async fn collect_stream(stream: BlobStream) -> Result<Vec<u8>, DomainError> {
use futures::StreamExt;
let mut stream = stream;
let mut buf = Vec::new();
while let Some(chunk) = stream.next().await {
let bytes = chunk
.map_err(|e| DomainError::internal_error("Encryption", format!("stream read: {e}")))?;
if buf.capacity() == 0 {
buf.reserve(
(crate::infrastructure::services::dedup_service::CDC_MAX_CHUNK
+ NONCE_SIZE
+ TAG_SIZE)
.max(bytes.len()),
);
}
buf.extend_from_slice(&bytes);
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::services::local_blob_backend::LocalBlobBackend;
use tempfile::TempDir;
use tokio::io::AsyncWriteExt;
#[tokio::test]
async fn test_encrypt_decrypt_roundtrip() {
let tmp = TempDir::new().unwrap();
let blob_dir = tmp.path().join("blobs");
let local = Arc::new(LocalBlobBackend::new(&blob_dir));
local.initialize().await.unwrap();
let key = EncryptedBlobBackend::generate_key();
let encrypted = EncryptedBlobBackend::new(local, &key);
// Write a test blob
let data = b"Hello, encrypted world!";
let source = tmp.path().join("test.tmp");
let mut f = fs::File::create(&source).await.unwrap();
f.write_all(data).await.unwrap();
f.flush().await.unwrap();
drop(f);
let hash = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
encrypted.put_blob(hash, &source).await.unwrap();
// Read back via stream
let stream = encrypted.get_blob_stream(hash).await.unwrap();
let decrypted = collect_stream(stream).await.unwrap();
assert_eq!(decrypted, data);
// Read range — `end` is exclusive, matching LocalBlobBackend
let range_stream = encrypted
.get_blob_range_stream(hash, 7, Some(16))
.await
.unwrap();
let range_data = collect_stream(range_stream).await.unwrap();
assert_eq!(range_data, b"encrypted");
// Size should reflect plaintext
let size = encrypted.blob_size(hash).await.unwrap();
assert_eq!(size, data.len() as u64);
// Exists
assert!(encrypted.blob_exists(hash).await.unwrap());
// Delete
encrypted.delete_blob(hash).await.unwrap();
assert!(!encrypted.blob_exists(hash).await.unwrap());
}
/// Payloads above `CRYPTO_OFFLOAD_THRESHOLD` take the spawn_blocking
/// path and are emitted as multiple bounded slices — the roundtrip and
/// range semantics must be identical to the inline path.
#[tokio::test]
async fn test_large_blob_offloaded_roundtrip_and_ranges() {
let tmp = TempDir::new().unwrap();
let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs")));
local.initialize().await.unwrap();
let key = EncryptedBlobBackend::generate_key();
let encrypted = EncryptedBlobBackend::new(local, &key);
// 300 KiB of a repeating pattern — crosses the offload threshold and
// spans several PLAINTEXT_EMIT_SIZE slices.
let data: Vec<u8> = (0..300 * 1024).map(|i| (i % 251) as u8).collect();
let hash = "feedbeef1234567890feedbeef1234567890feedbeef1234567890feedbeef12";
encrypted
.put_blob_from_bytes(hash, Bytes::from(data.clone()))
.await
.unwrap();
// Full roundtrip
let stream = encrypted.get_blob_stream(hash).await.unwrap();
let decrypted = collect_stream(stream).await.unwrap();
assert_eq!(decrypted, data);
// Mid-file range crossing an emission boundary (`end` exclusive)
let (start, end) = (60_000u64, 200_000u64);
let stream = encrypted
.get_blob_range_stream(hash, start, Some(end))
.await
.unwrap();
let ranged = collect_stream(stream).await.unwrap();
assert_eq!(ranged, &data[start as usize..end as usize]);
// Open-ended suffix range
let stream = encrypted
.get_blob_range_stream(hash, 299 * 1024, None)
.await
.unwrap();
let suffix = collect_stream(stream).await.unwrap();
assert_eq!(suffix, &data[299 * 1024..]);
// Range entirely past EOF yields empty content
let stream = encrypted
.get_blob_range_stream(hash, data.len() as u64 + 10, None)
.await
.unwrap();
assert!(collect_stream(stream).await.unwrap().is_empty());
// Plaintext size reported
assert_eq!(encrypted.blob_size(hash).await.unwrap(), data.len() as u64);
}
/// A flipped ciphertext byte must fail GCM authentication, never return
/// corrupted plaintext.
#[tokio::test]
async fn test_tampered_ciphertext_fails_decrypt() {
let tmp = TempDir::new().unwrap();
let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs")));
local.initialize().await.unwrap();
let key = EncryptedBlobBackend::generate_key();
let encrypted = EncryptedBlobBackend::new(local.clone(), &key);
let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
encrypted
.put_blob_from_bytes(hash, Bytes::from_static(b"sensitive payload"))
.await
.unwrap();
// Corrupt one ciphertext byte on disk (past the 12-byte nonce).
let path = local.local_blob_path(hash).expect("local path");
let mut raw = std::fs::read(&path).unwrap();
raw[NONCE_SIZE] ^= 0xFF;
std::fs::write(&path, raw).unwrap();
assert!(encrypted.get_blob_stream(hash).await.is_err());
}
/// Decrypting with a different key must fail authentication.
#[tokio::test]
async fn test_wrong_key_fails_decrypt() {
let tmp = TempDir::new().unwrap();
let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs")));
local.initialize().await.unwrap();
let hash = "aaaabbbbccccddddaaaabbbbccccddddaaaabbbbccccddddaaaabbbbccccdddd";
let writer =
EncryptedBlobBackend::new(local.clone(), &EncryptedBlobBackend::generate_key());
writer
.put_blob_from_bytes(hash, Bytes::from_static(b"locked"))
.await
.unwrap();
let reader = EncryptedBlobBackend::new(local, &EncryptedBlobBackend::generate_key());
assert!(reader.get_blob_stream(hash).await.is_err());
}
}