|
|
|
@@ -1,16 +1,62 @@
|
|
|
|
|
//! `EncryptedBlobBackend` — AES-256-GCM encryption decorator for blob storage.
|
|
|
|
|
//! `EncryptedBlobBackend` — v1 blob-format 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.
|
|
|
|
|
//! 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 | <version u16> | <key_fp u64>` 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 `<key_fp>` 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
|
|
|
|
|
//! <version> 2 bytes — big-endian u16; v1 = 0x0001
|
|
|
|
|
//! <key_fp> 8 bytes — sha256(key material)[..8], routes reads
|
|
|
|
|
//! <nonce> 12 bytes — random per blob (AES-GCM 96-bit)
|
|
|
|
|
//! <ciphertext> N bytes — same length as plaintext
|
|
|
|
|
//! <auth_tag> 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
|
|
|
|
|
//! <version> 2 bytes — big-endian u16; v1 = 0x0001
|
|
|
|
|
//! <key_fp> 8 bytes — all zero
|
|
|
|
|
//! <payload> 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.
|
|
|
|
|
//!
|
|
|
|
|
//! 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
|
|
|
|
@@ -27,6 +73,7 @@
|
|
|
|
|
//! 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;
|
|
|
|
|
|
|
|
|
@@ -39,14 +86,42 @@ 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 `<key_fp>` 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;
|
|
|
|
@@ -56,23 +131,82 @@ const CRYPTO_OFFLOAD_THRESHOLD: usize = 64 * 1024;
|
|
|
|
|
/// hashers) see the same backpressure shape either way.
|
|
|
|
|
const PLAINTEXT_EMIT_SIZE: usize = 64 * 1024;
|
|
|
|
|
|
|
|
|
|
/// `BlobStorageBackend` decorator that encrypts blobs at rest.
|
|
|
|
|
/// `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<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>,
|
|
|
|
|
/// 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). Preserved verbatim for
|
|
|
|
|
/// observability and for downstream K3 `storage_rotate` which
|
|
|
|
|
/// needs to walk pair indices.
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
pairs: Vec<KeyPair>,
|
|
|
|
|
/// `<key_fp>` → 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<Aes256Gcm>>,
|
|
|
|
|
/// 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<Arc<Aes256Gcm>>,
|
|
|
|
|
/// 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 {
|
|
|
|
|
/// Create a new encryption layer wrapping `inner`.
|
|
|
|
|
/// Primary constructor. Takes an ordered pair list — the LAST
|
|
|
|
|
/// pair is the write pair (head), every pair is a candidate for
|
|
|
|
|
/// reads via `<key_fp>` dispatch.
|
|
|
|
|
///
|
|
|
|
|
/// `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 }
|
|
|
|
|
/// 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<dyn BlobStorageBackend>, pairs: Vec<KeyPair>) -> 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<dyn BlobStorageBackend>, key: &[u8; 32]) -> Self {
|
|
|
|
|
Self::new(inner, vec![KeyPair::new_aes_gcm(*key)])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Generate a random 32-byte key suitable for AES-256.
|
|
|
|
@@ -84,27 +218,132 @@ impl EncryptedBlobBackend {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Encrypt `data` into the on-disk layout: `[12-byte nonce][ciphertext + tag]`.
|
|
|
|
|
/// 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 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> {
|
|
|
|
|
/// 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<Bytes, DomainError> {
|
|
|
|
|
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
|
|
|
|
let mut out = Vec::with_capacity(NONCE_SIZE + data.len() + TAG_SIZE);
|
|
|
|
|
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[NONCE_SIZE..])
|
|
|
|
|
.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))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Decrypt the on-disk layout `[nonce][ciphertext + tag]` **in place**.
|
|
|
|
|
/// 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 over pre-K2 shape
|
|
|
|
|
/// (`nonce | ct | tag`) — the pre-v1 world had exactly one key
|
|
|
|
|
/// per entry, and that key is the head pair by construction of
|
|
|
|
|
/// any pair-list upgraded from a pre-K1 config;
|
|
|
|
|
/// * `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(
|
|
|
|
|
fp_ciphers: &HashMap<[u8; KEY_FP_SIZE], Arc<Aes256Gcm>>,
|
|
|
|
|
head_cipher: Option<&Aes256Gcm>,
|
|
|
|
|
encrypted: Vec<u8>,
|
|
|
|
|
) -> Result<Bytes, DomainError> {
|
|
|
|
|
if encrypted.len() >= 5 && &encrypted[..5] == OXCPT_MAGIC {
|
|
|
|
|
return read_v1(fp_ciphers, encrypted);
|
|
|
|
|
}
|
|
|
|
|
match head_cipher {
|
|
|
|
|
Some(cipher) => decrypt_aead_in_place(cipher, encrypted),
|
|
|
|
|
None => Ok(Bytes::from(encrypted)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The v1 branch of `read_dispatch`, factored out for clarity.
|
|
|
|
|
fn read_v1(
|
|
|
|
|
fp_ciphers: &HashMap<[u8; KEY_FP_SIZE], Arc<Aes256Gcm>>,
|
|
|
|
|
encrypted: Vec<u8>,
|
|
|
|
|
) -> Result<Bytes, DomainError> {
|
|
|
|
|
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
|
|
|
|
@@ -112,18 +351,12 @@ fn encrypt_bytes(cipher: &Aes256Gcm, data: &[u8]) -> Result<Bytes, DomainError>
|
|
|
|
|
/// 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.
|
|
|
|
|
///
|
|
|
|
|
/// The prior shape did `encrypted.split_off(NONCE_SIZE)`, which allocated a
|
|
|
|
|
/// fresh `Vec` and memcpy'd the entire ciphertext (up to a whole legacy blob)
|
|
|
|
|
/// on every decrypted read — one full-payload allocation + copy the doc comment
|
|
|
|
|
/// above claimed did not happen (benches/ROUND25.md §M1; ROUND11 §15 fixed only
|
|
|
|
|
/// the encrypt side). Output plaintext is byte-identical.
|
|
|
|
|
fn decrypt_bytes(cipher: &Aes256Gcm, mut encrypted: Vec<u8>) -> Result<Bytes, DomainError> {
|
|
|
|
|
fn decrypt_aead_in_place(cipher: &Aes256Gcm, mut encrypted: Vec<u8>) -> Result<Bytes, DomainError> {
|
|
|
|
|
let len = encrypted.len();
|
|
|
|
|
if len < NONCE_SIZE + TAG_SIZE {
|
|
|
|
|
if len < AEAD_OVERHEAD {
|
|
|
|
|
return Err(DomainError::internal_error(
|
|
|
|
|
"Encryption",
|
|
|
|
|
"encrypted blob too short (missing nonce/tag)",
|
|
|
|
|
"AEAD body too short (missing nonce/tag)",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
// Nonce (first 12 bytes) and GCM tag (last 16 bytes) copied to the stack so
|
|
|
|
@@ -186,21 +419,15 @@ impl BlobStorageBackend for EncryptedBlobBackend {
|
|
|
|
|
let inner = self.inner.clone();
|
|
|
|
|
let hash = hash.to_string();
|
|
|
|
|
let source = source_path.to_path_buf();
|
|
|
|
|
let cipher = self.cipher.clone();
|
|
|
|
|
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 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
|
|
|
|
|
let out = frame_write(head_cipher, head_key_fp, plaintext).await?;
|
|
|
|
|
inner.put_blob_from_bytes(&hash, out).await
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -211,11 +438,11 @@ impl BlobStorageBackend for EncryptedBlobBackend {
|
|
|
|
|
) -> 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();
|
|
|
|
|
let head_cipher = self.head_cipher.clone();
|
|
|
|
|
let head_key_fp = self.head_key_fp;
|
|
|
|
|
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
|
|
|
|
|
let out = frame_write(head_cipher, head_key_fp, data.to_vec()).await?;
|
|
|
|
|
inner.put_blob_from_bytes(&hash, out).await
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -226,11 +453,11 @@ impl BlobStorageBackend for EncryptedBlobBackend {
|
|
|
|
|
) -> 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();
|
|
|
|
|
let head_cipher = self.head_cipher.clone();
|
|
|
|
|
let head_key_fp = self.head_key_fp;
|
|
|
|
|
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
|
|
|
|
|
let out = frame_write(head_cipher, head_key_fp, data.to_vec()).await?;
|
|
|
|
|
inner.put_blob_from_bytes_unsynced(&hash, out).await
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -250,14 +477,18 @@ impl BlobStorageBackend for EncryptedBlobBackend {
|
|
|
|
|
{
|
|
|
|
|
let inner = self.inner.clone();
|
|
|
|
|
let hash = hash.to_string();
|
|
|
|
|
let cipher = self.cipher.clone();
|
|
|
|
|
let fp_ciphers = self.fp_ciphers.clone();
|
|
|
|
|
let head_cipher = self.head_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.
|
|
|
|
|
// 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 plaintext = offload_crypto(len, move || decrypt_bytes(&cipher, encrypted)).await?;
|
|
|
|
|
let plaintext = offload_crypto(len, move || {
|
|
|
|
|
read_dispatch(&fp_ciphers, head_cipher.as_deref(), encrypted)
|
|
|
|
|
})
|
|
|
|
|
.await?;
|
|
|
|
|
Ok(plaintext_stream(plaintext))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
@@ -271,16 +502,20 @@ impl BlobStorageBackend for EncryptedBlobBackend {
|
|
|
|
|
{
|
|
|
|
|
let inner = self.inner.clone();
|
|
|
|
|
let hash = hash.to_string();
|
|
|
|
|
let cipher = self.cipher.clone();
|
|
|
|
|
let fp_ciphers = self.fp_ciphers.clone();
|
|
|
|
|
let head_cipher = self.head_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.
|
|
|
|
|
// 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 plaintext = offload_crypto(len, move || decrypt_bytes(&cipher, encrypted)).await?;
|
|
|
|
|
let plaintext = offload_crypto(len, move || {
|
|
|
|
|
read_dispatch(&fp_ciphers, head_cipher.as_deref(), encrypted)
|
|
|
|
|
})
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
// `end` is exclusive — same contract as `LocalBlobBackend`, whose
|
|
|
|
|
// implementation reads `end - start` bytes. The previous version
|
|
|
|
@@ -313,14 +548,29 @@ impl BlobStorageBackend for EncryptedBlobBackend {
|
|
|
|
|
&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.
|
|
|
|
|
// 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 encrypted_size = inner.blob_size(&hash).await?;
|
|
|
|
|
// overhead = 12 (nonce) + 16 (GCM tag) = 28 bytes
|
|
|
|
|
Ok(encrypted_size.saturating_sub(28))
|
|
|
|
|
let stored = inner.blob_size(&hash).await?;
|
|
|
|
|
Ok(stored.saturating_sub(overhead))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -330,16 +580,28 @@ impl BlobStorageBackend for EncryptedBlobBackend {
|
|
|
|
|
Box<dyn std::future::Future<Output = Result<StorageHealthStatus, DomainError>> + 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!("encrypted({})", status.backend_type);
|
|
|
|
|
status.message = format!("{} | Encryption: AES-256-GCM", status.message);
|
|
|
|
|
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 {
|
|
|
|
|
"encrypted"
|
|
|
|
|
// 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.
|
|
|
|
@@ -376,6 +638,24 @@ impl BlobStorageBackend for EncryptedBlobBackend {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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<Arc<Aes256Gcm>>,
|
|
|
|
|
head_key_fp: [u8; KEY_FP_SIZE],
|
|
|
|
|
plaintext: Vec<u8>,
|
|
|
|
|
) -> Result<Bytes, DomainError> {
|
|
|
|
|
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<u8>`.
|
|
|
|
|
///
|
|
|
|
|
/// Modern blobs are CDC chunks (≤ `CDC_MAX_CHUNK` + nonce/tag overhead),
|
|
|
|
@@ -419,7 +699,7 @@ mod tests {
|
|
|
|
|
local.initialize().await.unwrap();
|
|
|
|
|
|
|
|
|
|
let key = EncryptedBlobBackend::generate_key();
|
|
|
|
|
let encrypted = EncryptedBlobBackend::new(local, &key);
|
|
|
|
|
let encrypted = EncryptedBlobBackend::new_single_aes(local, &key);
|
|
|
|
|
|
|
|
|
|
// Write a test blob
|
|
|
|
|
let data = b"Hello, encrypted world!";
|
|
|
|
@@ -467,7 +747,7 @@ mod tests {
|
|
|
|
|
local.initialize().await.unwrap();
|
|
|
|
|
|
|
|
|
|
let key = EncryptedBlobBackend::generate_key();
|
|
|
|
|
let encrypted = EncryptedBlobBackend::new(local, &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.
|
|
|
|
@@ -520,7 +800,7 @@ mod tests {
|
|
|
|
|
local.initialize().await.unwrap();
|
|
|
|
|
|
|
|
|
|
let key = EncryptedBlobBackend::generate_key();
|
|
|
|
|
let encrypted = EncryptedBlobBackend::new(local.clone(), &key);
|
|
|
|
|
let encrypted = EncryptedBlobBackend::new_single_aes(local.clone(), &key);
|
|
|
|
|
|
|
|
|
|
let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
|
encrypted
|
|
|
|
@@ -528,16 +808,20 @@ mod tests {
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Corrupt one ciphertext byte on disk (past the 12-byte nonce).
|
|
|
|
|
// 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[NONCE_SIZE] ^= 0xFF;
|
|
|
|
|
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 authentication.
|
|
|
|
|
/// 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();
|
|
|
|
@@ -545,14 +829,230 @@ mod tests {
|
|
|
|
|
local.initialize().await.unwrap();
|
|
|
|
|
|
|
|
|
|
let hash = "aaaabbbbccccddddaaaabbbbccccddddaaaabbbbccccddddaaaabbbbccccdddd";
|
|
|
|
|
let writer =
|
|
|
|
|
EncryptedBlobBackend::new(local.clone(), &EncryptedBlobBackend::generate_key());
|
|
|
|
|
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(local, &EncryptedBlobBackend::generate_key());
|
|
|
|
|
assert!(reader.get_blob_stream(hash).await.is_err());
|
|
|
|
|
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}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|