//! `EncryptedBlobBackend` — v1 blob-format decorator for blob storage. //! //! Wraps an inner `BlobStorageBackend` and adds two orthogonal //! behaviours: //! //! 1. **v1 header framing on every new write** — every blob written //! by this wrapper starts with a 15-byte header //! `OXCPT | | ` so future reads are //! self-describing regardless of the entry's current config. //! 2. **Pair-list encryption** — the wrapper owns an ordered //! [`KeyPair`] list. Writes use the LAST pair (the "head"); reads //! dispatch on the header's `` field into an O(1) //! fp → cipher lookup, so a blob written under any pair still in //! the list decrypts without fallback attempts. //! //! See `docs/plan/storage-key-rotation.md` for the full design. //! //! ## v1 on-disk layout //! //! Encrypted-v1 (`head_cipher` is a real AEAD): //! //! ```text //! "OXCPT" 5 bytes — magic marker //! 2 bytes — big-endian u16; v1 = 0x0001 //! 8 bytes — sha256(key material)[..8], routes reads //! 12 bytes — random per blob (AES-GCM 96-bit) //! N bytes — same length as plaintext //! 16 bytes — AEAD authentication tag //! ``` //! //! Plaintext-v1 (`head_cipher` is `None`, i.e. entry uses a `none:` //! head pair or has no encryption declared at all): //! //! ```text //! "OXCPT" 5 bytes — magic marker //! 2 bytes — big-endian u16; v1 = 0x0001 //! 8 bytes — all zero //! N bytes — raw plaintext //! ``` //! //! ## Legacy fallback on reads //! //! Blobs written before this wrapper existed have no OXCPT magic. //! Reads check the first 5 bytes: //! //! * `"OXCPT"` → v1 path (version + key_fp lookup + AEAD or raw). //! * anything else → **legacy path** — try `head_cipher` (if any) //! as an AES-GCM decode over the pre-v1 shape //! `[nonce][ciphertext][tag]`; otherwise return raw bytes. //! //! Collision probability: 2⁻⁴⁰ per blob for random legacy bytes to //! start with `"OXCPT"`. If it happens, subsequent version / key_fp //! checks fail with a hard error (`UnsupportedBlobVersion` or //! `NoKeyForBlob`) — never silent misread. //! //! **IMPORTANT**: BLAKE3 hashing is performed on the *plaintext* by //! `DedupService` before this layer sees the blob, so content-addressable //! dedup still works correctly. //! //! ## 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::collections::HashMap; 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::common::config::KeyPair; use crate::domain::errors::DomainError; /// v1 magic marker — every v1 blob starts with these 5 ASCII bytes. /// Chosen for its 2⁻⁴⁰ collision odds against random legacy data /// and its clean legibility in a `hexdump` (mnemonic: /// "OXiCloud CiPherText"). const OXCPT_MAGIC: &[u8; 5] = b"OXCPT"; /// v1 header version bytes (big-endian u16 = 0x0001). Future formats /// bump this in-place — the [`OXCPT_MAGIC`] stays the discriminator /// against legacy / non-OxiCloud files. const V1_VERSION_BYTES: [u8; 2] = [0x00, 0x01]; /// On-blob `` field size — 8 bytes = 64-bit truncation of /// `sha256(key)`. Wide enough that random collisions are 2⁻⁶⁴; the /// parser also uniqueness-checks pairs on raw key material so /// duplicates can't sneak in. const KEY_FP_SIZE: usize = 8; /// Total v1 header size = magic + version + key_fp. const HEADER_SIZE: usize = 5 + 2 + KEY_FP_SIZE; /// 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; /// AEAD overhead per blob (nonce + tag) — 28 bytes, same regardless /// of header framing. const AEAD_OVERHEAD: usize = NONCE_SIZE + TAG_SIZE; /// Per-blob overhead for an encrypted-v1 blob: header + AEAD = 43 bytes. const ENCRYPTED_V1_OVERHEAD: usize = HEADER_SIZE + AEAD_OVERHEAD; /// 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 applies v1 header framing and /// pair-list-driven encryption. See the module-level docs for the /// on-disk layout and read-fallback semantics. pub struct EncryptedBlobBackend { inner: Arc, /// The pair list as declared by the operator. Guaranteed /// non-empty by ctor (an empty input auto-synthesises a single /// `none:` pair, so the invariant holds). Used by: /// * `read_dispatch` legacy-fallback path — iterates in order /// (oldest → newest) to try every real-cipher pair when a /// legacy blob's head-key decrypt fails. /// * K3 `backend_rotate` — needs to walk pair indices. pairs: Vec, /// `` → per-pair cipher, for O(1) read dispatch on v1 /// blobs. Excludes any `none:` pair (nothing to build). Cloned /// per read via `Arc::clone` — the ~240-byte expanded AES-256 /// round-key schedule is amortised across every request. fp_ciphers: HashMap<[u8; KEY_FP_SIZE], Arc>, /// Cipher used by writes (last pair in the list). `None` when /// the head is a `CipherKind::None` pair — in that case writes /// emit plaintext-v1 (magic + version + zero fp + raw payload). head_cipher: Option>, /// Head pair's `key_fp` — embedded in every write's v1 header. /// `[0u8; 8]` when head is `CipherKind::None`, matching the /// plaintext-v1 shape. head_key_fp: [u8; KEY_FP_SIZE], } impl EncryptedBlobBackend { /// Primary constructor. Takes an ordered pair list — the LAST /// pair is the write pair (head), every pair is a candidate for /// reads via `` dispatch. /// /// Empty `pairs` is legal and treated as a single implicit /// `none:` pair — the wrapper still emits v1 headers on writes /// (plaintext-v1 flavor) and still magic-byte-dispatches on /// reads (with legacy fallback for header-less blobs). This is /// the always-wrap contract used by `entry_backend.rs` under the /// K2 "normalize data" rule. /// /// Panics if any real-cipher pair's key material isn't 32 bytes, /// which the parser guarantees — a panic here signals a /// programmer bug, not operator error. pub fn new(inner: Arc, pairs: Vec) -> Self { let pairs = if pairs.is_empty() { vec![KeyPair::new_none()] } else { pairs }; let mut fp_ciphers = HashMap::with_capacity(pairs.len()); for kp in &pairs { if let Some(mat) = kp.key_material.as_ref() { let cipher = Aes256Gcm::new_from_slice(mat) .expect("KeyPair invariant: real-cipher pair has 32-byte key"); fp_ciphers.insert(kp.key_fp(), Arc::new(cipher)); } } let head = pairs .last() .expect("post-normalisation pair list is non-empty"); let head_key_fp = head.key_fp(); let head_cipher = fp_ciphers.get(&head_key_fp).cloned(); Self { inner, pairs, fp_ciphers, head_cipher, head_key_fp, } } /// Convenience: wrap with a single AES-256-GCM pair. Same effect /// as `new(inner, vec![KeyPair::new_aes_gcm(*key)])`. Used by /// tests + the pre-multi-entry legacy synthesis fallback in /// `di.rs`. pub fn new_single_aes(inner: Arc, key: &[u8; 32]) -> Self { Self::new(inner, vec![KeyPair::new_aes_gcm(*key)]) } /// 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 } /// The format `backend_rotate` should normalise every blob TO — /// derived from the wrapper's head pair. When /// `head_cipher.is_some()` we're writing encrypted-v1 with the /// head pair's `key_fp`; when it's `None` we're writing /// plaintext-v1 (all-zero `key_fp`). /// /// K3 uses this as the "target format" that a per-blob decision /// tree compares against `BlobFormat::classify(bytes)` — any /// mismatch means the blob needs rewriting. pub fn head_format(&self) -> BlobFormat { if self.head_cipher.is_some() { BlobFormat::EncryptedV1 { key_fp: self.head_key_fp, } } else { BlobFormat::PlaintextV1 } } /// Richer variant of [`Self::is_at_head_format`] — returns not /// just "matches head?" but also *why* it doesn't match, so /// callers (currently `backend_migration`) can log the concrete /// action they're about to take: **skip** (match), **overwrite** /// (blob exists but with wrong format/key), or **fresh write** /// (blob absent). Same one-round-trip cost as the boolean version. /// /// Backend-agnostic: reads through the trait's /// `get_blob_range_stream` on the *inner* backend (bypasses this /// wrapper's decrypt so we see the raw on-disk header bytes). /// Local pays one `pread` syscall; S3 pays one HEAD/GET with /// `Range: bytes=0-14`; Azure the same. pub async fn head_check(&self, hash: &str) -> HeadCheck { let stream = match self .inner .get_blob_range_stream(hash, 0, Some(HEADER_SIZE as u64)) .await { Ok(s) => s, Err(_) => return HeadCheck::Absent, }; let raw = match collect_stream(stream).await { Ok(b) => b, Err(_) => return HeadCheck::Absent, }; if raw.is_empty() { return HeadCheck::Absent; } // Bytes present but short-header — treat as "exists, needs // rewrite" (malformed blob, classify falls back to Legacy). let current = BlobFormat::classify(&raw); if current == self.head_format() { HeadCheck::Match } else { HeadCheck::Mismatch(current) } } /// Boolean convenience wrapper over [`Self::head_check`] for /// callers that only need "matches head?" — retained for API /// symmetry with the original design. pub async fn is_at_head_format(&self, hash: &str) -> Result { Ok(matches!(self.head_check(hash).await, HeadCheck::Match)) } /// Fetch, classify, and decrypt a blob in one round-trip. Used by /// K3's `backend_rotate` per-blob step: it needs both the /// plaintext (to re-encrypt under the head pair) AND the current /// on-disk format (to decide whether a rewrite is needed at all). /// /// The inner backend is read once; the raw bytes are inspected /// for their format before being consumed by `read_dispatch`. No /// duplicated I/O. /// /// Returned tuple: `(plaintext, current_format)`. Rotate compares /// `current_format` against [`Self::head_format`]; equal → skip, /// different → rewrite via the standard write path. pub async fn read_and_classify(&self, hash: &str) -> Result<(Bytes, BlobFormat), DomainError> { let enc_stream = self.inner.get_blob_stream(hash).await?; let raw = collect_stream(enc_stream).await?; let format = BlobFormat::classify(&raw); let pairs = self.pairs.clone(); let fp_ciphers = self.fp_ciphers.clone(); let head_cipher = self.head_cipher.clone(); let head_key_fp = self.head_key_fp; let hash_owned = hash.to_string(); let len = raw.len(); let plaintext = offload_crypto(len, move || { read_dispatch( &pairs, &fp_ciphers, head_cipher.as_deref(), head_key_fp, &hash_owned, raw, ) }) .await?; Ok((plaintext, format)) } } /// Classification of a raw blob's on-disk format. Exposed for K3's /// `backend_rotate` decision tree; not used on the hot request path. /// /// PartialEq is derived so `current == head_format` collapses the /// plan's six-case decision tree into a single equality check: /// /// * `Legacy != anything v1` → always rewrite. /// * `EncryptedV1{fp_a} != EncryptedV1{fp_b}` when fps differ → rewrite (key rotation). /// * `PlaintextV1 != EncryptedV1` and vice-versa → rewrite (encrypt / decrypt in place). /// * Match cases → skip (already normalised). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BlobFormat { /// No `OXCPT` magic. Pre-K2 shape — either raw plaintext or raw /// `nonce | ct | tag` AES-GCM output; the wrapper's legacy read /// path handles both. Legacy, /// v1 with a `none:`-style all-zero `key_fp`. Post-header bytes /// are raw plaintext. PlaintextV1, /// v1 with a real cipher pair. `key_fp` identifies which pair /// (matches [`KeyPair::key_fp`]). EncryptedV1 { key_fp: [u8; KEY_FP_SIZE] }, } impl BlobFormat { /// Inspect the first `HEADER_SIZE` bytes and classify the blob. /// O(1), no allocation. Used by [`EncryptedBlobBackend::read_and_classify`] /// but also useful in isolation for offline tools. pub fn classify(bytes: &[u8]) -> Self { if bytes.len() < 5 || &bytes[..5] != OXCPT_MAGIC { return BlobFormat::Legacy; } // Magic OK. If the rest of the header isn't present the blob // is malformed — treat as Legacy so the decision tree marks // it for rewrite (and the actual read will surface the error // to the finding stream). if bytes.len() < HEADER_SIZE { return BlobFormat::Legacy; } // v1 magic + at least a full header. `key_fp` == 0 → plaintext. let mut key_fp = [0u8; KEY_FP_SIZE]; key_fp.copy_from_slice(&bytes[7..HEADER_SIZE]); if key_fp == [0u8; KEY_FP_SIZE] { BlobFormat::PlaintextV1 } else { BlobFormat::EncryptedV1 { key_fp } } } } /// Result of [`EncryptedBlobBackend::head_check`] — describes the /// exact state of a target blob relative to the wrapper's current /// head format, so callers can log/act with precision. /// /// [`HeadCheck::Absent`] and [`HeadCheck::Mismatch`] both require a /// write; the distinction is purely observability — operators seeing /// `Mismatch` in migration logs know the K1.2 residue is being /// repaired, while `Absent` is a plain first-time copy. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HeadCheck { /// Blob exists AND its header bytes match the wrapper's /// head format exactly — skip the write. Match, /// Blob exists but header differs (legacy shape, different /// `key_fp`, plaintext vs encrypted, or malformed shorter than /// [`HEADER_SIZE`]). The current on-disk shape is reported so /// the caller can name it in the log. Mismatch(BlobFormat), /// No bytes at that hash — fresh write, not an overwrite. Absent, } impl std::fmt::Display for BlobFormat { /// Human-friendly format for audit logs + finding details. /// Renders `key_fp` as SSH-style colon-hex (e.g. /// `83:96:ff:90:94:d7:ef:de`) instead of the raw byte-array Debug /// shape (`[131, 150, 255, ...]`). Same spelling `xxd` produces /// when you inspect a blob's on-disk header, so operators can /// cross-check without a mental conversion. /// /// Handlers that render this in tracing macros should use `%` /// (Display) — `?` (Debug) still gives the raw byte-array shape /// for programmer-consumers who need the exact bytes. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { BlobFormat::Legacy => write!(f, "legacy"), BlobFormat::PlaintextV1 => write!(f, "plaintext-v1"), BlobFormat::EncryptedV1 { key_fp } => { write!(f, "encrypted-v1 key_fp=")?; for (i, byte) in key_fp.iter().enumerate() { if i > 0 { write!(f, ":")?; } write!(f, "{byte:02x}")?; } Ok(()) } } } } /// Assemble an encrypted-v1 blob: /// `OXCPT | v1 | key_fp | nonce | ciphertext | tag`. /// /// Single output buffer, mirroring the read side's in-place detached /// decrypt: the payload is copied exactly once and encrypted in /// place with the tag appended. The old shape (pre-K2, no header) /// 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). K2 preserves the single-buffer /// discipline: we `extend_from_slice` header + nonce + payload, then /// encrypt in place from `HEADER_SIZE + NONCE_SIZE`. fn encrypt_v1( cipher: &Aes256Gcm, head_key_fp: [u8; KEY_FP_SIZE], data: &[u8], ) -> Result { let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let mut out = Vec::with_capacity(ENCRYPTED_V1_OVERHEAD + data.len()); out.extend_from_slice(OXCPT_MAGIC); out.extend_from_slice(&V1_VERSION_BYTES); out.extend_from_slice(&head_key_fp); out.extend_from_slice(nonce.as_slice()); out.extend_from_slice(data); let tag = cipher .encrypt_in_place_detached(&nonce, b"", &mut out[HEADER_SIZE + NONCE_SIZE..]) .map_err(|e| DomainError::internal_error("Encryption", format!("encrypt failed: {e}")))?; out.extend_from_slice(&tag); Ok(Bytes::from(out)) } /// Assemble a plaintext-v1 blob: /// `OXCPT | v1 | <8 zero bytes> | payload`. /// /// No crypto, no allocation beyond the header prefix. Produced when /// the wrapper's head pair is `CipherKind::None`. fn write_plaintext_v1(data: &[u8]) -> Bytes { let mut out = Vec::with_capacity(HEADER_SIZE + data.len()); out.extend_from_slice(OXCPT_MAGIC); out.extend_from_slice(&V1_VERSION_BYTES); out.extend_from_slice(&[0u8; KEY_FP_SIZE]); out.extend_from_slice(data); Bytes::from(out) } /// Read dispatch — the K2 core. Given a fetched blob and the /// wrapper's pair table, returns plaintext. /// /// * `OXCPT` at position 0 → **v1 path**: /// * version check (only `0x0001` accepted today); /// * `key_fp == [0u8; 8]` → plaintext-v1 → return post-header /// bytes as-is; /// * else `key_fp` lookup in `fp_ciphers` → AEAD decrypt over /// the post-header body. /// * anything else → **legacy path**: /// * `head_cipher = Some` → AES-GCM decrypt attempts, head-first, /// then every OTHER real-cipher pair in the wrapper's list. /// Head-first is the fast path (pre-v1 world used exactly one /// key which becomes head on upgrade, so it's the correct key /// for legacy blobs the first time you rotate). The extra /// fallbacks cover post-rotation scenarios where an operator /// restores a pre-v1 backup encrypted under a now-non-head /// pair — that blob was encrypted with K1 but head is now K2; /// without the fallback the read would fail with tag error /// even though K1 is still in the pair-list. Each failed tag /// check is microseconds — bounded by the pair count (1-3 in /// practice). /// * `head_cipher = None` → return raw bytes (pre-K2 plaintext /// deployment). /// /// Never falls through silently: every failure returns a distinct /// typed error (`UnsupportedBlobVersion`, `NoKeyForBlob`, AEAD tag /// failure). Random legacy bytes matching `OXCPT` (2⁻⁴⁰) fail the /// subsequent version/key_fp check with a hard error, not silent /// garbage. fn read_dispatch( pairs: &[KeyPair], fp_ciphers: &HashMap<[u8; KEY_FP_SIZE], Arc>, head_cipher: Option<&Aes256Gcm>, head_key_fp: [u8; KEY_FP_SIZE], expected_hash: &str, encrypted: Vec, ) -> Result { if encrypted.len() >= 5 && &encrypted[..5] == OXCPT_MAGIC { return read_v1(fp_ciphers, encrypted); } match head_cipher { Some(head) => { // Fast path: try the head pair first. This is the // pre-v1-upgrade case (single key = head; correct by // construction) and the most common case even // post-rotation (head was head just before the operator // rotated it to a new pair). if let Ok(pt) = decrypt_aead_in_place(head, encrypted.clone()) { return Ok(pt); } // Fallback: try every other real-cipher pair in // list order — oldest → newest. Legacy blobs are OLD // by definition (pre-K2, no header), so an older pair // is more likely to have encrypted them than a newer // one. Head is skipped (already tried above). Each // failed AEAD tag check is µs; the loop is bounded by // the pair count (1-3 in practice). // // `Vec` ordering is stable (env-declaration // order), unlike `HashMap` iteration which is randomised. // // Clone per attempt because `decrypt_in_place_detached` // leaves the buffer in an undefined state on tag failure // — retrying against another key needs a fresh copy. for pair in pairs { if !pair.cipher.needs_key() { continue; // `none` pair — no cipher to try } let fp = pair.key_fp(); if fp == head_key_fp { continue; // already tried above } if let Some(cipher) = fp_ciphers.get(&fp) && let Ok(pt) = decrypt_aead_in_place(cipher, encrypted.clone()) { return Ok(pt); } } // ── BLAKE3 rescue (last safety net) ───────────────── // If every configured key failed AND the raw bytes // BLAKE3 to the expected hash, those bytes ARE the // plaintext — the blob was written pre-encryption // (pre-K2 legacy plaintext) or via a migration that // silently retained plaintext blobs (see the K1.2 // skip-check bug — historical residue). // // Zero-false-positive because we're recomputing the // content-addressable hash: matching bytes = matching // content, period. Cheap (BLAKE3 ~2 GB/s) and only // runs on the pathological path where AES already // failed. Emits an audit line so operators can spot // pre-encryption blobs and decide whether to re-write // them under the head via rotate. if hex_matches_blake3(expected_hash, &encrypted) { tracing::info!( target: "audit", event = "encryption.legacy_plaintext_rescued", hash = %expected_hash, size = encrypted.len(), "🩹 legacy plaintext blob served via BLAKE3 rescue — no configured key \ decrypted it, but content hash matched. Run backend_rotate to re-write \ under the current head." ); return Ok(Bytes::from(encrypted)); } Err(DomainError::internal_error( "Encryption", "legacy blob failed to decrypt under any configured key — \ wrong key removed from pair-list, or blob is corrupt", )) } None => Ok(Bytes::from(encrypted)), } } /// Return true iff `expected_hex` is a valid 32-byte BLAKE3 hex /// digest AND matches `blake3(bytes)`. Case-insensitive on hex. /// /// Kept out of the hot path — only called from the legacy-fallback /// last-resort branch when every AES key already failed. fn hex_matches_blake3(expected_hex: &str, bytes: &[u8]) -> bool { if expected_hex.len() != 64 { return false; } let mut expected = [0u8; 32]; if hex::decode_to_slice(expected_hex, &mut expected).is_err() { return false; } let actual = blake3::hash(bytes); actual.as_bytes() == &expected } /// The v1 branch of `read_dispatch`, factored out for clarity. fn read_v1( fp_ciphers: &HashMap<[u8; KEY_FP_SIZE], Arc>, encrypted: Vec, ) -> Result { if encrypted.len() < HEADER_SIZE { return Err(DomainError::internal_error( "Encryption", format!( "v1 blob too short (need at least {HEADER_SIZE} bytes for the header, got {})", encrypted.len() ), )); } let version = &encrypted[5..7]; if version != V1_VERSION_BYTES { return Err(DomainError::internal_error( "Encryption", format!( "unsupported v1 blob version 0x{:02x}{:02x} — this build only reads 0x0001", version[0], version[1] ), )); } let mut key_fp = [0u8; KEY_FP_SIZE]; key_fp.copy_from_slice(&encrypted[7..HEADER_SIZE]); let mut body = encrypted; body.drain(..HEADER_SIZE); if key_fp == [0u8; KEY_FP_SIZE] { // Plaintext-v1 — post-header bytes ARE the plaintext. return Ok(Bytes::from(body)); } let cipher = fp_ciphers.get(&key_fp).ok_or_else(|| { DomainError::internal_error( "Encryption", format!( "v1 blob key_fp {} does not match any configured pair — cannot decrypt", hex::encode(key_fp) ), ) })?; decrypt_aead_in_place(cipher, body) } /// Decrypt the AEAD body `[nonce][ciphertext][tag]` **in place**. /// /// Reuses the encrypted buffer 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). The nonce and 16-byte GCM tag /// are lifted to the stack, the ciphertext body is decrypted in place via the /// detached API (mirroring the encrypt side's `encrypt_in_place_detached`), and /// the plaintext is returned as a zero-copy `Bytes::slice` past the nonce. fn decrypt_aead_in_place(cipher: &Aes256Gcm, mut encrypted: Vec) -> Result { let len = encrypted.len(); if len < AEAD_OVERHEAD { return Err(DomainError::internal_error( "Encryption", "AEAD body too short (missing nonce/tag)", )); } // Nonce (first 12 bytes) and GCM tag (last 16 bytes) copied to the stack so // the middle can be borrowed mutably for in-place decryption. let mut nonce_buf = [0u8; NONCE_SIZE]; nonce_buf.copy_from_slice(&encrypted[..NONCE_SIZE]); let nonce = Nonce::from_slice(&nonce_buf); let tag = aes_gcm::aead::Tag::::clone_from_slice(&encrypted[len - TAG_SIZE..]); cipher .decrypt_in_place_detached(nonce, b"", &mut encrypted[NONCE_SIZE..len - TAG_SIZE], &tag) .map_err(|e| DomainError::internal_error("Encryption", format!("decrypt failed: {e}")))?; // Plaintext now lives at `encrypted[NONCE_SIZE..len - TAG_SIZE]`; drop the // tag and hand out a refcounted view past the nonce — no copy, no new alloc. encrypted.truncate(len - TAG_SIZE); Ok(Bytes::from(encrypted).slice(NONCE_SIZE..)) } /// 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(work_len: usize, job: F) -> Result where T: Send + 'static, F: FnOnce() -> Result + 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. /// /// The emit-slice iterator is handed to `stream::iter` lazily — the closure /// owns `data` (a refcounted `Bytes`), so each `slice` is produced on demand /// as the consumer polls, rather than eagerly `collect`ing a `Vec` of /// ⌈len/64 KiB⌉ slice handles up front (benches/ROUND20.md §I4). fn plaintext_stream(data: Bytes) -> BlobStream { let len = data.len(); Box::pin(futures::stream::iter( (0..len) .step_by(PLAINTEXT_EMIT_SIZE) .map(move |off| Ok(data.slice(off..len.min(off + PLAINTEXT_EMIT_SIZE)))), )) } impl BlobStorageBackend for EncryptedBlobBackend { fn initialize( &self, ) -> Pin> + Send + '_>> { self.inner.initialize() } fn put_blob( &self, hash: &str, source_path: &Path, ) -> Pin> + Send + '_>> { let inner = self.inner.clone(); let hash = hash.to_string(); let source = source_path.to_path_buf(); let head_cipher = self.head_cipher.clone(); let head_key_fp = self.head_key_fp; 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 out = frame_write(head_cipher, head_key_fp, plaintext).await?; inner.put_blob_from_bytes(&hash, out).await }) } fn put_blob_from_bytes( &self, hash: &str, data: Bytes, ) -> Pin> + Send + '_>> { let inner = self.inner.clone(); let hash = hash.to_string(); let head_cipher = self.head_cipher.clone(); let head_key_fp = self.head_key_fp; Box::pin(async move { let out = frame_write(head_cipher, head_key_fp, data.to_vec()).await?; inner.put_blob_from_bytes(&hash, out).await }) } fn put_blob_from_bytes_unsynced( &self, hash: &str, data: Bytes, ) -> Pin> + Send + '_>> { let inner = self.inner.clone(); let hash = hash.to_string(); let head_cipher = self.head_cipher.clone(); let head_key_fp = self.head_key_fp; Box::pin(async move { let out = frame_write(head_cipher, head_key_fp, data.to_vec()).await?; inner.put_blob_from_bytes_unsynced(&hash, out).await }) } /// Frame the plaintext with the head pair's format (encrypted-v1 /// or plaintext-v1), then delegate the atomic replace to the /// inner backend. Used by `backend_rotate` to actually change the /// on-disk bytes — `put_blob_from_bytes` would silently no-op on /// `LocalBlobBackend` when the object key already exists. fn put_blob_from_bytes_replace( &self, hash: &str, data: Bytes, ) -> Pin> + Send + '_>> { let inner = self.inner.clone(); let hash = hash.to_string(); let head_cipher = self.head_cipher.clone(); let head_key_fp = self.head_key_fp; Box::pin(async move { let out = frame_write(head_cipher, head_key_fp, data.to_vec()).await?; inner.put_blob_from_bytes_replace(&hash, out).await }) } fn sync_blobs( &self, hashes: &[String], ) -> Pin> + 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> + Send + '_>> { let inner = self.inner.clone(); let hash = hash.to_string(); let pairs = self.pairs.clone(); let fp_ciphers = self.fp_ciphers.clone(); let head_cipher = self.head_cipher.clone(); let head_key_fp = self.head_key_fp; Box::pin(async move { // Collect the full blob, dispatch on magic bytes 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 hash_for_dispatch = hash.clone(); let plaintext = offload_crypto(len, move || { read_dispatch( &pairs, &fp_ciphers, head_cipher.as_deref(), head_key_fp, &hash_for_dispatch, encrypted, ) }) .await?; Ok(plaintext_stream(plaintext)) }) } fn get_blob_range_stream( &self, hash: &str, start: u64, end: Option, ) -> Pin> + Send + '_>> { let inner = self.inner.clone(); let hash = hash.to_string(); let pairs = self.pairs.clone(); let fp_ciphers = self.fp_ciphers.clone(); let head_cipher = self.head_cipher.clone(); let head_key_fp = self.head_key_fp; Box::pin(async move { // Decrypt (or unwrap) 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 hash_for_dispatch = hash.clone(); let plaintext = offload_crypto(len, move || { read_dispatch( &pairs, &fp_ciphers, head_cipher.as_deref(), head_key_fp, &hash_for_dispatch, 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> + Send + '_>> { self.inner.delete_blob(hash) } fn blob_exists( &self, hash: &str, ) -> Pin> + Send + '_>> { self.inner.blob_exists(hash) } fn blob_size( &self, hash: &str, ) -> Pin> + Send + '_>> { // Plaintext size = stored size - per-format overhead. The exact // overhead depends on which format the blob is in (encrypted-v1 // = 43, plaintext-v1 = 15, legacy-encrypted = 28, legacy-plain = // 0), which we can't know without inspecting bytes. We assume // the blob was written under the wrapper's current head — that's // true for every new write from K2 onward. // // For legacy blobs still on disk the estimate is off by // ±(HEADER_SIZE) or so. Since `blob_size` is used for capacity // metrics and admin dashboards (not byte-exact accounting — // content-hash is the source of truth for that), a small drift // during the legacy-blob window is acceptable. If a hot path // starts depending on byte-exact `blob_size`, revisit. let inner = self.inner.clone(); let hash = hash.to_string(); let overhead = if self.head_cipher.is_some() { ENCRYPTED_V1_OVERHEAD as u64 } else { HEADER_SIZE as u64 }; Box::pin(async move { let stored = inner.blob_size(&hash).await?; Ok(stored.saturating_sub(overhead)) }) } fn health_check( &self, ) -> Pin< Box> + Send + '_>, > { let inner = self.inner.clone(); let (outer_name, cipher_desc) = if self.head_cipher.is_some() { ("encrypted", "AES-256-GCM") } else { ("v1-plaintext", "none") }; Box::pin(async move { let mut status = inner.health_check().await?; status.backend_type = format!("{outer_name}({})", status.backend_type); status.message = format!("{} | Encryption: {cipher_desc}", status.message); Ok(status) }) } fn backend_type(&self) -> &'static str { // Choice 2/B: dynamic — reflects head-pair semantics so admin // surfaces show "v1-plaintext(local)" for a `none:`-headed // entry instead of misleadingly saying "encrypted(local)". if self.head_cipher.is_some() { "encrypted" } else { "v1-plaintext" } } /// 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 { // Encrypted blobs cannot be served directly from disk None } /// Enumeration = plaintext hashes, same as the inner backend. /// Encryption operates on payload bytes, not on the hash key: /// blob objects on the inner backend are stored under the /// PLAINTEXT hash so dedup works. Delegating list to the inner /// backend therefore returns exactly the right identifiers. fn list_blob_hashes( &self, cursor: Option, limit: usize, ) -> Pin< Box< dyn std::future::Future< Output = Result< crate::application::ports::blob_storage_ports::BlobListPage, DomainError, >, > + Send + '_, >, > { self.inner.list_blob_hashes(cursor, limit) } } /// Frame a plaintext payload into a v1 blob per the head-pair /// configuration. Encrypted case runs the AEAD on the blocking pool /// for large payloads; plaintext case is a small header-prepend and /// stays inline (no crypto = no offload). async fn frame_write( head_cipher: Option>, head_key_fp: [u8; KEY_FP_SIZE], plaintext: Vec, ) -> Result { match head_cipher { Some(cipher) => { let len = plaintext.len(); offload_crypto(len, move || encrypt_v1(&cipher, head_key_fp, &plaintext)).await } None => Ok(write_plaintext_v1(&plaintext)), } } /// Collect a byte stream into a single `Vec`. /// /// 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, 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_single_aes(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_single_aes(local, &key); // 300 KiB of a repeating pattern — crosses the offload threshold and // spans several PLAINTEXT_EMIT_SIZE slices. let data: Vec = (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_single_aes(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. v1 layout: 15-byte // header + 12-byte nonce + ciphertext, so the first // ciphertext byte is at `HEADER_SIZE + NONCE_SIZE`. Flipping // it must fail AEAD tag verification. let path = local.local_blob_path(hash).expect("local path"); let mut raw = std::fs::read(&path).unwrap(); raw[HEADER_SIZE + 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 — post-K2 via the /// key_fp lookup (writer's fp isn't in the reader's map). #[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_single_aes( local.clone(), &EncryptedBlobBackend::generate_key(), ); writer .put_blob_from_bytes(hash, Bytes::from_static(b"locked")) .await .unwrap(); let reader = EncryptedBlobBackend::new_single_aes(local, &EncryptedBlobBackend::generate_key()); // Result::unwrap_err needs Ok: Debug; BlobStream isn't Debug. // Match directly, and extract the message off the DomainError. let err = match reader.get_blob_stream(hash).await { Ok(_) => panic!("expected a decrypt error, got Ok"), Err(e) => e, }; let msg = err.to_string(); assert!( msg.contains("key_fp") || msg.contains("does not match"), "expected NoKeyForBlob-shape error, got: {msg}" ); } // ───────────────────────────────────────────────────────────── // K2 tests — v1 header format + magic-byte dispatch + legacy // fallback + pair-list read routing. // ───────────────────────────────────────────────────────────── /// Every encrypted-v1 blob starts with the magic + version + /// head-pair fingerprint. Pins the on-disk byte layout so a /// future refactor can't silently break the format. #[tokio::test] async fn v1_encrypted_blob_has_expected_header() { let tmp = TempDir::new().unwrap(); let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs"))); local.initialize().await.unwrap(); let key = [42u8; 32]; let backend = EncryptedBlobBackend::new_single_aes(local.clone(), &key); let hash = "1111111111111111111111111111111111111111111111111111111111111111"; backend .put_blob_from_bytes(hash, Bytes::from_static(b"hello world")) .await .unwrap(); let path = local.local_blob_path(hash).expect("local path"); let raw = std::fs::read(&path).unwrap(); assert!( raw.len() >= ENCRYPTED_V1_OVERHEAD, "blob too short: {}", raw.len() ); assert_eq!(&raw[..5], OXCPT_MAGIC, "missing OXCPT magic"); assert_eq!(&raw[5..7], &V1_VERSION_BYTES, "wrong version bytes"); let expected_fp = KeyPair::new_aes_gcm(key).key_fp(); assert_eq!(&raw[7..HEADER_SIZE], &expected_fp, "wrong key_fp in header"); } /// `none:`-headed entry emits plaintext-v1 (header + raw /// payload, no crypto). Round-trip must yield identity bytes. #[tokio::test] async fn v1_plaintext_blob_round_trips() { let tmp = TempDir::new().unwrap(); let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs"))); local.initialize().await.unwrap(); let backend = EncryptedBlobBackend::new(local.clone(), vec![KeyPair::new_none()]); let hash = "2222222222222222222222222222222222222222222222222222222222222222"; let payload = Bytes::from_static(b"cleartext bytes"); backend .put_blob_from_bytes(hash, payload.clone()) .await .unwrap(); // On-disk shape: magic + version + zero fp + raw payload. let path = local.local_blob_path(hash).expect("local path"); let raw = std::fs::read(&path).unwrap(); assert_eq!(&raw[..5], OXCPT_MAGIC); assert_eq!(&raw[5..7], &V1_VERSION_BYTES); assert_eq!(&raw[7..HEADER_SIZE], &[0u8; KEY_FP_SIZE]); assert_eq!(&raw[HEADER_SIZE..], payload.as_ref()); // Read must strip the header and return payload identity. let stream = backend.get_blob_stream(hash).await.unwrap(); let round_tripped = collect_stream(stream).await.unwrap(); assert_eq!(round_tripped, payload.as_ref()); } /// Legacy fallback: a blob written with the pre-K2 AEAD shape /// (nonce | ct | tag, no OXCPT header) still decrypts via the /// head-pair AES key. This is the guarantee that upgrading to /// K2 doesn't break existing encrypted deployments. #[tokio::test] async fn legacy_encrypted_blob_still_readable() { let tmp = TempDir::new().unwrap(); let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs"))); local.initialize().await.unwrap(); let key = [0x33u8; 32]; let backend = EncryptedBlobBackend::new_single_aes(local.clone(), &key); // Craft a legacy blob by hand: AES-GCM with random nonce, // no OXCPT header. Matches exactly what pre-K2 code wrote. let plaintext = b"legacy secret"; let cipher = Aes256Gcm::new_from_slice(&key).unwrap(); let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let mut legacy = Vec::new(); legacy.extend_from_slice(nonce.as_slice()); legacy.extend_from_slice(plaintext); let tag = cipher .encrypt_in_place_detached(&nonce, b"", &mut legacy[NONCE_SIZE..]) .unwrap(); legacy.extend_from_slice(&tag); // Write the raw bytes directly onto the local backend, bypassing // the wrapper (else it'd add a v1 header). local .put_blob_from_bytes( "3333333333333333333333333333333333333333333333333333333333333333", Bytes::from(legacy), ) .await .unwrap(); // The wrapper's read path must dispatch on absent magic → // legacy branch → head-pair AES decode. let stream = backend .get_blob_stream("3333333333333333333333333333333333333333333333333333333333333333") .await .unwrap(); let got = collect_stream(stream).await.unwrap(); assert_eq!(got, plaintext); } /// Legacy fallback for pure plaintext: entry has no encryption /// (empty pair list → `none:` synthesised), reads of a raw-byte /// blob written before the wrapper existed still return raw. #[tokio::test] async fn legacy_plaintext_blob_still_readable() { let tmp = TempDir::new().unwrap(); let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs"))); local.initialize().await.unwrap(); let backend = EncryptedBlobBackend::new(local.clone(), vec![]); let hash = "4444444444444444444444444444444444444444444444444444444444444444"; let raw = b"just some bytes, no header"; // Bypass the wrapper — write raw plaintext directly. local .put_blob_from_bytes(hash, Bytes::from_static(raw)) .await .unwrap(); // No magic → legacy path → head is None → return bytes as-is. let stream = backend.get_blob_stream(hash).await.unwrap(); let got = collect_stream(stream).await.unwrap(); assert_eq!(got, raw); } /// Pair-list key rotation: write under pair[0]'s key, add /// pair[1] as head, read must still succeed via pair[0]'s /// key_fp entry in the lookup table. #[tokio::test] async fn read_dispatches_by_key_fp_in_pair_list() { let tmp = TempDir::new().unwrap(); let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs"))); local.initialize().await.unwrap(); let k_old = [0x11u8; 32]; let k_new = [0x22u8; 32]; // Write with a single-pair wrapper using k_old. let writer = EncryptedBlobBackend::new_single_aes(local.clone(), &k_old); let hash = "5555555555555555555555555555555555555555555555555555555555555555"; writer .put_blob_from_bytes(hash, Bytes::from_static(b"payload")) .await .unwrap(); // Reader has BOTH keys: k_old at position 0, k_new at head. // The blob's key_fp field points at k_old → lookup succeeds // even though writes now go under k_new. let reader = EncryptedBlobBackend::new( local, vec![KeyPair::new_aes_gcm(k_old), KeyPair::new_aes_gcm(k_new)], ); let stream = reader.get_blob_stream(hash).await.unwrap(); let got = collect_stream(stream).await.unwrap(); assert_eq!(got, b"payload"); } /// Malformed v1 blob (correct magic, unknown version bytes) → /// hard error, never silent misread. Guards against the /// theoretical 2⁻⁴⁰ magic-collision case on random legacy data. #[tokio::test] async fn unknown_v1_version_returns_hard_error() { let tmp = TempDir::new().unwrap(); let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs"))); local.initialize().await.unwrap(); let backend = EncryptedBlobBackend::new(local.clone(), vec![]); let hash = "6666666666666666666666666666666666666666666666666666666666666666"; // Magic OK, version = 0xFFFF (future format we don't know). let mut bogus = Vec::from(*OXCPT_MAGIC); bogus.extend_from_slice(&[0xFF, 0xFF]); bogus.extend_from_slice(&[0u8; KEY_FP_SIZE]); bogus.extend_from_slice(b"payload"); local .put_blob_from_bytes(hash, Bytes::from(bogus)) .await .unwrap(); let err = match backend.get_blob_stream(hash).await { Ok(_) => panic!("expected an UnsupportedBlobVersion error, got Ok"), Err(e) => e, }; let msg = err.to_string(); assert!( msg.contains("unsupported v1 blob version"), "expected UnsupportedBlobVersion error, got: {msg}" ); } // ───────────────────────────────────────────────────────────── // K3 tests — BlobFormat classifier + head_format + read_and_classify. // // These pin the format-inspection contract that `backend_rotate` // depends on. The rotate job's per-blob decision tree collapses // to `current != head_format ? rewrite : skip`, so any drift in // either helper would silently change rotation semantics. // ───────────────────────────────────────────────────────────── #[test] fn classify_recognises_encrypted_v1() { let mut blob = Vec::from(*OXCPT_MAGIC); blob.extend_from_slice(&V1_VERSION_BYTES); let key_fp = [0x11u8; KEY_FP_SIZE]; blob.extend_from_slice(&key_fp); blob.extend_from_slice(b"nonce_ct_tag_bytes_would_go_here"); assert_eq!( BlobFormat::classify(&blob), BlobFormat::EncryptedV1 { key_fp } ); } #[test] fn classify_recognises_plaintext_v1() { let mut blob = Vec::from(*OXCPT_MAGIC); blob.extend_from_slice(&V1_VERSION_BYTES); blob.extend_from_slice(&[0u8; KEY_FP_SIZE]); blob.extend_from_slice(b"raw payload after header"); assert_eq!(BlobFormat::classify(&blob), BlobFormat::PlaintextV1); } #[test] fn classify_recognises_legacy_no_magic() { // Random bytes with no OXCPT prefix. let raw = b"some legacy bytes not starting with the magic"; assert_eq!(BlobFormat::classify(raw), BlobFormat::Legacy); } #[test] fn classify_treats_short_magic_only_blob_as_legacy() { // 5 bytes = magic only, no room for version+key_fp. Malformed // v1; treated as Legacy so the decision tree flags it for // rewrite instead of pretending it's a real v1 blob. let raw = Vec::from(*OXCPT_MAGIC); assert_eq!(BlobFormat::classify(&raw), BlobFormat::Legacy); } #[test] fn classify_empty_is_legacy() { assert_eq!(BlobFormat::classify(&[]), BlobFormat::Legacy); } #[tokio::test] async fn head_format_matches_encrypted_head_pair_fp() { let tmp = TempDir::new().unwrap(); let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs"))); local.initialize().await.unwrap(); let key = [0x77u8; 32]; let backend = EncryptedBlobBackend::new_single_aes(local, &key); match backend.head_format() { BlobFormat::EncryptedV1 { key_fp } => { assert_eq!(key_fp, KeyPair::new_aes_gcm(key).key_fp()); } other => panic!("expected EncryptedV1, got {other:?}"), } } #[tokio::test] async fn head_format_is_plaintext_v1_for_none_head() { let tmp = TempDir::new().unwrap(); let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs"))); local.initialize().await.unwrap(); // Empty pair list → wrapper synthesises a single `none:` pair. let backend = EncryptedBlobBackend::new(local, vec![]); assert_eq!(backend.head_format(), BlobFormat::PlaintextV1); } #[tokio::test] async fn read_and_classify_returns_plaintext_and_current_format() { let tmp = TempDir::new().unwrap(); let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs"))); local.initialize().await.unwrap(); let k_old = [0xAAu8; 32]; let k_new = [0xBBu8; 32]; let hash = "7777777777777777777777777777777777777777777777777777777777777777"; // Write under the OLD key. let writer = EncryptedBlobBackend::new_single_aes(local.clone(), &k_old); writer .put_blob_from_bytes(hash, Bytes::from_static(b"secret payload")) .await .unwrap(); // Reader has BOTH keys, k_new at head. Rotate scenario: // classifier should report EncryptedV1{k_old_fp}, decrypt // succeeds via key_fp lookup, plaintext round-trips. let reader = EncryptedBlobBackend::new( local, vec![KeyPair::new_aes_gcm(k_old), KeyPair::new_aes_gcm(k_new)], ); let (plaintext, current) = reader.read_and_classify(hash).await.unwrap(); assert_eq!(plaintext, b"secret payload".as_slice()); match current { BlobFormat::EncryptedV1 { key_fp } => { assert_eq!(key_fp, KeyPair::new_aes_gcm(k_old).key_fp()); } other => panic!("expected EncryptedV1 with old fp, got {other:?}"), } // Confirms the rotate decision: current != head_format → // rewrite (key rotation case). assert_ne!(current, reader.head_format()); } /// Legacy blob (no OXCPT header) encrypted under a NON-head pair /// still decrypts via the fallback loop. This is the /// "operator restored a pre-K2 backup post-rotation" case: /// the blob was originally encrypted with K1, then K2 was added /// and rotated to head. Reading the restored bytes under a /// `[K1, K2]` pair-list where K2 is head should succeed by /// falling through to K1. #[tokio::test] async fn legacy_blob_under_non_head_key_still_readable() { let tmp = TempDir::new().unwrap(); let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs"))); local.initialize().await.unwrap(); let k_old = [0x11u8; 32]; // will be non-head after rotation let k_new = [0x22u8; 32]; // will be head after rotation // Craft a legacy blob by hand: AES-GCM with K_OLD, no OXCPT // header. Matches exactly what pre-K2 code wrote. let plaintext = b"pre-K2 secret restored post-rotation"; let cipher_old = Aes256Gcm::new_from_slice(&k_old).unwrap(); let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let mut legacy = Vec::new(); legacy.extend_from_slice(nonce.as_slice()); legacy.extend_from_slice(plaintext); let tag = cipher_old .encrypt_in_place_detached(&nonce, b"", &mut legacy[NONCE_SIZE..]) .unwrap(); legacy.extend_from_slice(&tag); let hash = "9999999999999999999999999999999999999999999999999999999999999999"; local .put_blob_from_bytes(hash, Bytes::from(legacy)) .await .unwrap(); // Reader has BOTH keys, with K_NEW as head. The legacy blob's // head decrypt attempt (with K_NEW) will fail on the tag — // the fallback loop then tries K_OLD (the only other pair) // and succeeds. let reader = EncryptedBlobBackend::new( local, vec![KeyPair::new_aes_gcm(k_old), KeyPair::new_aes_gcm(k_new)], ); let stream = reader.get_blob_stream(hash).await.unwrap(); let got = collect_stream(stream).await.unwrap(); assert_eq!(got, plaintext); } }