use aes_gcm::{AeadInOut, Aes256Gcm, Key, Nonce, Tag};
use async_stream::try_stream;
use async_trait::async_trait;
use base64::{Engine, prelude::BASE64_URL_SAFE};
use bytes::{Buf, Bytes, BytesMut};
use futures::{StreamExt, stream::BoxStream};
use moka::future::Cache;
use object_store::{path::Path, *};
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_bytes::ByteArray;
use sha3::Digest;
use std::{ops::Range, sync::Arc, time::Duration};
use crate::{
check_get_preconditions, check_update_version,
sidecar::{ListingMetaPolicy, SidecarMeta, SidecarStore, new_generation},
validate_ranges,
};
const DEFAULT_CHUNK_SIZE: u64 = 256 * 1024;
const CHUNK_AAD_LEGACY: u8 = 0;
const CHUNK_AAD_BOUND: u8 = 1;
#[derive(Clone)]
pub struct EncryptedStore<T: ObjectStore> {
inner: Arc<SidecarStore<T, Metadata>>,
cipher: Arc<Aes256Gcm>,
chunk_size: u64,
strict_metadata_auth: bool,
}
pub struct EncryptedStoreBuilder<T: ObjectStore> {
store: T,
cipher: Arc<Aes256Gcm>,
chunk_size: u64,
strict_metadata_auth: bool,
meta_cache: Cache<Path, Arc<Metadata>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Metadata {
#[serde(rename = "s")]
size: u64,
#[serde(rename = "e")]
e_tag: Option<String>,
#[serde(rename = "o")]
original_tag: Option<String>,
#[serde(rename = "v")]
original_version: Option<String>,
#[serde(rename = "n")]
aes_nonce: ByteArray<12>,
#[serde(rename = "t")]
aes_tags: Vec<ByteArray<16>>,
#[serde(rename = "c", default, skip_serializing_if = "Option::is_none")]
chunk_size: Option<u64>,
#[serde(rename = "av", default, skip_serializing_if = "Option::is_none")]
chunk_aad_version: Option<u8>,
#[serde(rename = "an", default, skip_serializing_if = "Option::is_none")]
auth_nonce: Option<ByteArray<12>>,
#[serde(rename = "at", default, skip_serializing_if = "Option::is_none")]
auth_tag: Option<ByteArray<16>>,
#[serde(rename = "g", default, skip_serializing_if = "Option::is_none")]
generation: Option<String>,
}
impl SidecarMeta for Metadata {
const STORE_NAME: &'static str = "EncryptedStore";
fn e_tag(&self) -> Option<&str> {
self.e_tag.as_deref()
}
fn size(&self) -> u64 {
self.size
}
fn generation(&self) -> Option<&str> {
self.generation.as_deref()
}
}
impl<T: ObjectStore> std::fmt::Display for EncryptedStore<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "EncryptedStore({:?})", self.inner.store)
}
}
impl<T: ObjectStore> std::fmt::Debug for EncryptedStore<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "EncryptedStore({:?})", self.inner.store)
}
}
impl<T: ObjectStore> EncryptedStoreBuilder<T> {
pub fn with_secret(store: T, meta_cache_capacity: u64, secret: [u8; 32]) -> Self {
use aes_gcm::aead::KeyInit;
let key = Key::<Aes256Gcm>::from(secret);
EncryptedStoreBuilder::new(store, meta_cache_capacity, Arc::new(Aes256Gcm::new(&key)))
}
pub fn new(store: T, meta_cache_capacity: u64, cipher: Arc<Aes256Gcm>) -> Self {
EncryptedStoreBuilder {
store,
cipher,
chunk_size: DEFAULT_CHUNK_SIZE,
strict_metadata_auth: false,
meta_cache: Cache::builder()
.max_capacity(meta_cache_capacity)
.time_to_live(Duration::from_secs(60 * 60))
.time_to_idle(Duration::from_secs(20 * 60))
.build(),
}
}
pub fn with_meta_cache(self, cache: Cache<Path, Arc<Metadata>>) -> Self {
Self {
meta_cache: cache,
..self
}
}
pub fn with_chunk_size(self, chunk_size: u64) -> Self {
Self {
chunk_size: normalize_chunk_size(chunk_size),
..self
}
}
pub fn with_conditional_put(self) -> Self {
self
}
pub fn with_strict_metadata_auth(self) -> Self {
Self {
strict_metadata_auth: true,
..self
}
}
pub fn build(self) -> EncryptedStore<T> {
EncryptedStore {
inner: Arc::new(SidecarStore::new(self.store, self.meta_cache)),
cipher: self.cipher,
chunk_size: self.chunk_size,
strict_metadata_auth: self.strict_metadata_auth,
}
}
}
impl<T: ObjectStore> EncryptedStore<T> {
fn read_chunk_size(&self, meta: &Metadata) -> u64 {
meta.chunk_size
.filter(|&c| c > 0)
.map(normalize_chunk_size)
.unwrap_or(self.chunk_size)
}
fn seal_metadata(&self, location: &Path, meta: &mut Metadata) -> Result<()> {
seal_metadata(&self.cipher, location, meta)
}
fn verify_metadata(&self, location: &Path, meta: &Metadata) -> Result<MetadataAuth> {
verify_metadata(&self.cipher, location, meta, self.strict_metadata_auth)
}
fn listing_meta_policy(&self) -> ListingMetaPolicy<Metadata> {
let cipher = self.cipher.clone();
let strict = self.strict_metadata_auth;
ListingMetaPolicy::verified(strict, move |location, meta| {
verify_metadata(&cipher, location, meta, strict)?;
Ok(())
})
}
async fn verified_metadata(&self, location: &Path) -> Result<Metadata> {
let meta = self.inner.get_meta(location).await?;
self.verify_metadata(location, &meta)?;
Ok((*meta).clone())
}
pub async fn collect_garbage(&self) -> Result<usize> {
self.inner.collect_garbage().await
}
}
#[async_trait]
impl<T: ObjectStore> ObjectStore for EncryptedStore<T> {
async fn put_opts(
&self,
location: &Path,
payload: PutPayload,
opts: PutOptions,
) -> Result<PutResult> {
let create = matches!(opts.mode, PutMode::Create);
let rt = self
.inner
.update_meta_with(location, create, async |meta| {
if let PutMode::Update(v) = &opts.mode {
match meta {
Some(m) => {
self.verify_metadata(location, m)?;
check_update_version(location, &m.e_tag, &m.generation, v)?;
}
None => {
return Err(Error::Precondition {
path: location.to_string(),
source: "metadata not found".into(),
});
}
}
}
let mut data = Vec::with_capacity(payload.content_length());
for segment in payload.iter() {
data.extend_from_slice(segment);
}
let base_nonce: [u8; 12] = rand_bytes();
let chunk_size = self.chunk_size as usize;
let mut aes_tags: Vec<ByteArray<16>> =
Vec::with_capacity(data.len().div_ceil(chunk_size));
for (i, chunk) in data.chunks_mut(chunk_size).enumerate() {
let nonce = derive_gcm_nonce(&base_nonce, i as u64);
let aad = chunk_aad(self.chunk_size, i as u64);
let tag = self
.cipher
.encrypt_inout_detached(&Nonce::from(nonce), &aad, chunk.into())
.map_err(|err| Error::Generic {
store: "EncryptedStore",
source: format!("AES256 encrypt failed for path {location}: {err:?}")
.into(),
})?;
let tag: [u8; 16] = tag.into();
aes_tags.push(tag.into());
}
let mut hasher = sha3::Sha3_256::new();
hasher.update(base_nonce);
hasher.update(&data);
let hash: [u8; 32] = hasher.finalize().into();
let generation = new_generation();
let mut meta = Metadata {
size: data.len() as u64,
e_tag: Some(BASE64_URL_SAFE.encode(hash)),
original_tag: None,
original_version: None,
aes_nonce: base_nonce.into(),
aes_tags,
chunk_size: Some(self.chunk_size),
chunk_aad_version: Some(CHUNK_AAD_BOUND),
auth_nonce: None,
auth_tag: None,
generation: Some(generation.clone()),
};
let gen_path = self.inner.generation_path(location, &generation);
let ciphertext: PutPayload = data.into();
let mut data_opts = opts.clone();
data_opts.mode = PutMode::Overwrite;
self.inner
.store
.put_opts(&gen_path, ciphertext, data_opts)
.await?;
self.seal_metadata(location, &mut meta)?;
Ok(meta)
})
.await?;
Ok(PutResult {
e_tag: rt.e_tag.clone(),
version: None,
extensions: Extensions::default(),
})
}
async fn put_multipart_opts(
&self,
location: &Path,
opts: PutMultipartOptions,
) -> Result<Box<dyn MultipartUpload>> {
let generation = new_generation();
let gen_path = self.inner.generation_path(location, &generation);
let inner = self.inner.store.put_multipart_opts(&gen_path, opts).await?;
let aes_nonce: [u8; 12] = rand_bytes();
let mut hasher = sha3::Sha3_256::new();
hasher.update(aes_nonce);
Ok(Box::new(EncryptedStoreUploader {
buf: Vec::new(),
hasher,
size: 0,
aes_nonce,
aes_tags: Vec::new(),
chunk_index: 0,
location: location.clone(),
generation,
store: self.inner.clone(),
cipher: self.cipher.clone(),
chunk_size: self.chunk_size,
inner,
}))
}
async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult> {
let mut retried = false;
loop {
let meta = self.inner.get_meta(location).await?;
self.verify_metadata(location, &meta)?;
let mut options = options.clone();
check_get_preconditions(location, &mut options, meta.e_tag.as_deref())?;
let range = if let Some(r) = &options.range {
r.as_range(meta.size)
.map_err(|source| object_store::Error::Generic {
store: "EncryptedStore",
source: source.into(),
})?
} else {
0..meta.size
};
let range = if options.head {
range.start..range.start
} else {
range
};
let chunk_size = self.read_chunk_size(&meta);
let rr = if range.start == range.end {
options.range = None;
options.head = true;
range.start..range.start
} else {
let rr_start = (range.start / chunk_size) * chunk_size;
let rr_end = range
.end
.saturating_sub(1)
.checked_div(chunk_size)
.and_then(|idx| idx.checked_add(1))
.and_then(|idx| idx.checked_mul(chunk_size))
.unwrap_or(u64::MAX)
.min(meta.size);
rr_start..rr_end
};
if rr.end > rr.start {
options.range = Some(GetRange::Bounded(rr.clone()));
}
let payload_path = self
.inner
.payload_path(location, meta.generation.as_deref());
let mut res = match self.inner.store.get_opts(&payload_path, options).await {
Ok(res) => res,
Err(Error::NotFound { source, .. }) => {
if !retried && meta.generation.is_some() {
retried = true;
self.inner.refresh_meta(location).await?;
continue;
}
return Err(Error::NotFound {
path: location.to_string(),
source,
});
}
Err(err) => return Err(err),
};
let attributes = std::mem::take(&mut res.attributes);
let mut obj = res.meta.clone();
obj.location = location.clone();
obj.e_tag = meta.e_tag.clone();
obj.version = None;
let start_idx = (rr.start / chunk_size) as usize;
let start_offset = (range.start - rr.start) as usize;
let size = range.end - range.start;
let stream = create_decryption_stream(
res,
self.cipher.clone(),
meta,
location.clone(),
chunk_size as usize,
start_idx,
start_offset,
size,
);
return Ok(GetResult {
payload: GetResultPayload::Stream(stream),
meta: obj,
range,
attributes,
extensions: Extensions::default(),
});
}
}
async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> Result<Vec<Bytes>> {
if ranges.is_empty() {
return Ok(Vec::new());
}
let mut retried = false;
'retry: loop {
let meta = self.inner.get_meta(location).await?;
self.verify_metadata(location, &meta)?;
validate_ranges("EncryptedStore", ranges, meta.size)?;
let chunk_size = self.read_chunk_size(&meta);
let payload_path = self
.inner
.payload_path(location, meta.generation.as_deref());
let mut result: Vec<Bytes> = Vec::with_capacity(ranges.len());
let mut cached_span = 0u64..0u64;
let mut cached = Bytes::new();
for &Range { start, end } in ranges {
if start < cached_span.start || end > cached_span.end {
let span_start = (start / chunk_size) * chunk_size;
let span_end = ((end - 1) / chunk_size)
.saturating_add(1)
.saturating_mul(chunk_size)
.min(meta.size);
let first_idx = start / chunk_size;
let data = match self
.inner
.store
.get_range(&payload_path, span_start..span_end)
.await
{
Ok(data) => data,
Err(Error::NotFound { source, .. }) => {
if !retried && meta.generation.is_some() {
retried = true;
self.inner.refresh_meta(location).await?;
continue 'retry;
}
return Err(Error::NotFound {
path: location.to_string(),
source,
});
}
Err(err) => return Err(err),
};
if data.len() as u64 != span_end - span_start {
return Err(Error::Generic {
store: "EncryptedStore",
source: format!(
"truncated encrypted data for path {location}: expected {} bytes, got {}",
span_end - span_start,
data.len()
)
.into(),
});
}
let mut data: Vec<u8> = data.into();
for (i, chunk) in data.chunks_mut(chunk_size as usize).enumerate() {
let idx = first_idx + i as u64;
let tag =
meta.aes_tags
.get(idx as usize)
.ok_or_else(|| Error::Generic {
store: "EncryptedStore",
source: format!(
"missing AES256 tag for chunk {idx} for path {location}"
)
.into(),
})?;
let nonce = derive_gcm_nonce(&meta.aes_nonce, idx);
let aad = chunk_aad_for_meta(&meta, chunk_size, idx)?;
self.cipher
.decrypt_inout_detached(
&Nonce::from(nonce),
&aad,
chunk.into(),
&Tag::from(**tag),
)
.map_err(|err| Error::Generic {
store: "EncryptedStore",
source: format!(
"AES256 decrypt failed for path {location}: {err:?}"
)
.into(),
})?;
}
cached = Bytes::from(data);
cached_span = span_start..span_end;
}
let s = (start - cached_span.start) as usize;
let e = (end - cached_span.start) as usize;
if (e - s) * 2 >= cached.len() {
result.push(cached.slice(s..e));
} else {
result.push(Bytes::copy_from_slice(&cached[s..e]));
}
}
return Ok(result);
}
}
fn delete_stream(
&self,
locations: BoxStream<'static, Result<Path>>,
) -> BoxStream<'static, Result<Path>> {
self.inner.clone().delete_stream(locations)
}
fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>> {
self.inner.clone().list(prefix, self.listing_meta_policy())
}
fn list_with_offset(
&self,
prefix: Option<&Path>,
offset: &Path,
) -> BoxStream<'static, Result<ObjectMeta>> {
self.inner
.clone()
.list_with_offset(prefix, offset, self.listing_meta_policy())
}
async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> {
self.inner
.list_with_delimiter(prefix, self.listing_meta_policy())
.await
}
async fn copy_opts(&self, from: &Path, to: &Path, options: CopyOptions) -> Result<()> {
let create = matches!(options.mode, CopyMode::Create);
let cipher = self.cipher.clone();
let strict = self.strict_metadata_auth;
let (src, generation) = self
.inner
.copy_payload(from, to, |location, meta| {
verify_metadata(&cipher, location, meta, strict)?;
Ok(())
})
.await?;
let mut meta = (*src).clone();
meta.generation = Some(generation);
meta.original_tag = None;
meta.original_version = None;
ensure_chunk_aad_version(&mut meta)?;
self.seal_metadata(to, &mut meta)?;
self.inner
.update_meta_with(to, create, async |_| Ok(meta))
.await?;
Ok(())
}
async fn rename_opts(&self, from: &Path, to: &Path, options: RenameOptions) -> Result<()> {
if from == to {
self.verified_metadata(from).await?;
return self.inner.check_self_rename(from, &options).await;
}
let mode = match options.target_mode {
RenameTargetMode::Overwrite => CopyMode::Overwrite,
RenameTargetMode::Create => CopyMode::Create,
};
self.copy_opts(
from,
to,
CopyOptions {
mode,
extensions: options.extensions,
},
)
.await?;
match self.inner.delete_object(from).await {
Ok(()) | Err(Error::NotFound { .. }) => Ok(()),
Err(err) => Err(err),
}
}
}
pub struct EncryptedStoreUploader<T: ObjectStore> {
buf: Vec<u8>,
hasher: sha3::Sha3_256,
size: usize,
aes_tags: Vec<ByteArray<16>>,
aes_nonce: [u8; 12],
chunk_index: u64,
location: Path,
generation: String,
store: Arc<SidecarStore<T, Metadata>>,
cipher: Arc<Aes256Gcm>,
chunk_size: u64,
inner: Box<dyn MultipartUpload>,
}
impl<T: ObjectStore> std::fmt::Debug for EncryptedStoreUploader<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "EncryptedStoreUploader({})", self.location)
}
}
#[async_trait]
impl<T: ObjectStore> MultipartUpload for EncryptedStoreUploader<T> {
fn put_part(&mut self, payload: PutPayload) -> UploadPart {
let chunk_size = self.chunk_size as usize;
self.size += payload.content_length();
for segment in payload.iter() {
self.buf.extend_from_slice(segment);
}
if self.buf.len() < chunk_size {
return Box::pin(futures::future::ready(Ok(())));
}
let split = self.buf.len() / chunk_size * chunk_size;
let mut data = std::mem::take(&mut self.buf);
self.buf = data.split_off(split);
for chunk in data.chunks_mut(chunk_size) {
let nonce = derive_gcm_nonce(&self.aes_nonce, self.chunk_index);
let aad = chunk_aad(self.chunk_size, self.chunk_index);
self.chunk_index = self.chunk_index.wrapping_add(1);
match self
.cipher
.encrypt_inout_detached(&Nonce::from(nonce), &aad, chunk.into())
{
Ok(tag) => {
let tag: [u8; 16] = tag.into();
self.aes_tags.push(tag.into());
}
Err(err) => {
return Box::pin(futures::future::ready(Err(Error::Generic {
store: "EncryptedStore",
source: format!(
"AES256 encrypt failed for path {}: {err:?}",
self.location
)
.into(),
})));
}
}
}
self.hasher.update(&data);
self.inner.put_part(data.into())
}
async fn complete(&mut self) -> Result<PutResult> {
if !self.buf.is_empty() {
let mut data = std::mem::take(&mut self.buf);
for chunk in data.chunks_mut(self.chunk_size as usize) {
let nonce = derive_gcm_nonce(&self.aes_nonce, self.chunk_index);
let aad = chunk_aad(self.chunk_size, self.chunk_index);
self.chunk_index = self.chunk_index.wrapping_add(1);
let tag = self
.cipher
.encrypt_inout_detached(&Nonce::from(nonce), &aad, chunk.into())
.map_err(|err| Error::Generic {
store: "EncryptedStore",
source: format!(
"AES256 encrypt failed for path {}: {err:?}",
self.location
)
.into(),
})?;
let tag: [u8; 16] = tag.into();
self.aes_tags.push(tag.into());
}
self.hasher.update(&data);
self.inner.put_part(data.into()).await?;
}
let hash: [u8; 32] = self.hasher.clone().finalize().into();
let e_tag = Some(BASE64_URL_SAFE.encode(hash));
let store = self.store.clone();
let location = self.location.clone();
let cipher = self.cipher.clone();
let generation = self.generation.clone();
let size = self.size as u64;
let aes_nonce = self.aes_nonce;
let aes_tags = self.aes_tags.clone();
let chunk_size = self.chunk_size;
let inner = &mut self.inner;
store
.update_meta_with(&location, false, async |_| {
inner.complete().await?;
let mut meta = Metadata {
size,
e_tag: e_tag.clone(),
original_tag: None,
original_version: None,
aes_nonce: aes_nonce.into(),
aes_tags,
chunk_size: Some(chunk_size),
chunk_aad_version: Some(CHUNK_AAD_BOUND),
auth_nonce: None,
auth_tag: None,
generation: Some(generation.clone()),
};
seal_metadata(&cipher, &location, &mut meta)?;
Ok(meta)
})
.await?;
Ok(PutResult {
e_tag,
version: None,
extensions: Extensions::default(),
})
}
async fn abort(&mut self) -> Result<()> {
self.inner.abort().await
}
}
#[allow(clippy::too_many_arguments)]
fn create_decryption_stream(
res: GetResult,
cipher: Arc<Aes256Gcm>,
meta: Arc<Metadata>,
location: Path,
chunk_size: usize,
start_idx: usize,
start_offset: usize,
size: u64,
) -> BoxStream<'static, Result<Bytes>> {
try_stream! {
let mut stream = res.into_stream();
let mut buf = BytesMut::new();
let mut idx = start_idx;
let mut remaining = size;
if remaining == 0 {
return;
}
while let Some(data) = stream.next().await {
let data = data?;
buf.extend_from_slice(&data);
while remaining > 0 && buf.len() >= chunk_size {
let mut chunk = buf.split_to(chunk_size);
let tag = meta.aes_tags.get(idx).ok_or_else(|| Error::Generic {
store: "EncryptedStore",
source: format!("missing AES256 tag for chunk {idx} for path {location}").into(),
})?;
let nonce = derive_gcm_nonce(&meta.aes_nonce, idx as u64);
let aad = chunk_aad_for_meta(&meta, chunk_size as u64, idx as u64)?;
cipher.decrypt_inout_detached(
&Nonce::from(nonce),
&aad,
(&mut chunk[..]).into(),
&Tag::from(**tag)
)
.map_err(|err| Error::Generic {
store: "EncryptedStore",
source: format!("AES256 decrypt failed for path {location}: {err:?}").into(),
})?;
if idx == start_idx && start_offset > 0 {
chunk.advance(start_offset);
}
if chunk.len() as u64 > remaining {
chunk.truncate(remaining as usize);
}
remaining -= chunk.len() as u64;
idx += 1;
yield chunk.freeze();
if remaining == 0 {
return;
}
}
}
if remaining > 0 && !buf.is_empty() {
let tag = meta.aes_tags.get(idx).ok_or_else(|| Error::Generic {
store: "EncryptedStore",
source: format!("missing AES256 tag for chunk {idx} for path {location}").into(),
})?;
let nonce = derive_gcm_nonce(&meta.aes_nonce, idx as u64);
let aad = chunk_aad_for_meta(&meta, chunk_size as u64, idx as u64)?;
cipher.decrypt_inout_detached(
&Nonce::from(nonce),
&aad,
(&mut buf[..]).into(),
&Tag::from(**tag)
)
.map_err(|err| Error::Generic {
store: "EncryptedStore",
source: format!("AES256 decrypt failed for path {location}: {err:?}").into(),
})?;
if idx == start_idx && start_offset > 0 {
if start_offset > buf.len() {
Err(Error::Generic {
store: "EncryptedStore",
source: format!(
"truncated encrypted data for path {location}: expected at least {start_offset} bytes in chunk {idx}, got {}",
buf.len()
)
.into(),
})?;
}
buf.advance(start_offset);
}
if (buf.len() as u64) < remaining {
Err(Error::Generic {
store: "EncryptedStore",
source: format!(
"truncated encrypted data for path {location}: expected {remaining} more bytes, got {}",
buf.len()
)
.into(),
})?;
}
buf.truncate(remaining as usize);
remaining = 0;
yield buf.freeze();
}
if remaining > 0 {
Err(Error::Generic {
store: "EncryptedStore",
source: format!(
"truncated encrypted data for path {location}: expected {remaining} more bytes"
)
.into(),
})?;
}
}.boxed()
}
fn normalize_chunk_size(chunk_size: u64) -> u64 {
chunk_size.clamp(1, usize::MAX as u64)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MetadataAuth {
Authenticated,
Legacy,
}
fn seal_metadata(cipher: &Aes256Gcm, location: &Path, meta: &mut Metadata) -> Result<()> {
let nonce: [u8; 12] = rand_bytes();
let aad = metadata_auth_aad(location, meta);
let mut empty = [];
let tag = cipher
.encrypt_inout_detached(&Nonce::from(nonce), &aad, (&mut empty[..]).into())
.map_err(|err| Error::Generic {
store: "EncryptedStore",
source: format!("metadata authentication failed for path {location}: {err:?}").into(),
})?;
let tag: [u8; 16] = tag.into();
meta.auth_nonce = Some(nonce.into());
meta.auth_tag = Some(tag.into());
Ok(())
}
fn verify_metadata(
cipher: &Aes256Gcm,
location: &Path,
meta: &Metadata,
strict: bool,
) -> Result<MetadataAuth> {
let (nonce, tag) = match (meta.auth_nonce.as_ref(), meta.auth_tag.as_ref()) {
(Some(nonce), Some(tag)) => (nonce, tag),
(None, None) => {
if meta.chunk_aad_version.is_some() || meta.generation.is_some() {
return Err(Error::Generic {
store: "EncryptedStore",
source: format!("stripped metadata authentication fields for path {location}")
.into(),
});
}
if strict {
return Err(Error::Generic {
store: "EncryptedStore",
source: format!(
"unauthenticated legacy metadata rejected (strict mode) for path {location}"
)
.into(),
});
}
chunk_aad_version(meta)?;
log::warn!(
"EncryptedStore: accepting unauthenticated legacy metadata for {location}; \
rewrite the object (or copy/rename it) to seal it, then enable \
strict metadata authentication"
);
return Ok(MetadataAuth::Legacy);
}
(None, Some(_)) => {
return Err(Error::Generic {
store: "EncryptedStore",
source: format!("missing metadata authentication nonce for path {location}").into(),
});
}
(Some(_), None) => {
return Err(Error::Generic {
store: "EncryptedStore",
source: format!("missing metadata authentication tag for path {location}").into(),
});
}
};
let aad = metadata_auth_aad(location, meta);
let mut empty = [];
cipher
.decrypt_inout_detached(
&Nonce::from(**nonce),
&aad,
(&mut empty[..]).into(),
&Tag::from(**tag),
)
.map_err(|err| Error::Generic {
store: "EncryptedStore",
source: format!("metadata authentication failed for path {location}: {err:?}").into(),
})?;
chunk_aad_version(meta)?;
Ok(MetadataAuth::Authenticated)
}
fn metadata_auth_aad(location: &Path, meta: &Metadata) -> Vec<u8> {
let mut aad = Vec::new();
aad.extend_from_slice(b"anda_object_store.encrypted.metadata.v1");
push_bytes(&mut aad, location.to_string().as_bytes());
aad.extend_from_slice(&meta.size.to_le_bytes());
push_opt_str(&mut aad, meta.e_tag.as_deref());
push_opt_str(&mut aad, meta.original_tag.as_deref());
push_opt_str(&mut aad, meta.original_version.as_deref());
push_bytes(&mut aad, meta.aes_nonce.as_slice());
push_opt_u64(&mut aad, meta.chunk_size);
push_opt_u8(&mut aad, meta.chunk_aad_version);
aad.extend_from_slice(&(meta.aes_tags.len() as u64).to_le_bytes());
for tag in &meta.aes_tags {
push_bytes(&mut aad, tag.as_slice());
}
if let Some(generation) = &meta.generation {
aad.extend_from_slice(b".g");
push_bytes(&mut aad, generation.as_bytes());
}
aad
}
fn ensure_chunk_aad_version(meta: &mut Metadata) -> Result<()> {
let version = chunk_aad_version(meta)?;
meta.chunk_aad_version = Some(version);
Ok(())
}
fn chunk_aad_version(meta: &Metadata) -> Result<u8> {
let version = meta.chunk_aad_version.unwrap_or_else(|| {
if meta.auth_nonce.is_some() && meta.auth_tag.is_some() {
CHUNK_AAD_BOUND
} else {
CHUNK_AAD_LEGACY
}
});
match version {
CHUNK_AAD_LEGACY | CHUNK_AAD_BOUND => Ok(version),
_ => Err(Error::Generic {
store: "EncryptedStore",
source: format!("unsupported encrypted chunk AAD version {version}").into(),
}),
}
}
fn chunk_aad_for_meta(meta: &Metadata, chunk_size: u64, chunk_index: u64) -> Result<Vec<u8>> {
match chunk_aad_version(meta)? {
CHUNK_AAD_LEGACY => Ok(Vec::new()),
CHUNK_AAD_BOUND => Ok(chunk_aad(chunk_size, chunk_index)),
_ => unreachable!("chunk_aad_version validates known versions"),
}
}
fn chunk_aad(chunk_size: u64, chunk_index: u64) -> Vec<u8> {
let mut aad = Vec::with_capacity(52);
aad.extend_from_slice(b"anda_object_store.encrypted.chunk.v1");
aad.extend_from_slice(&chunk_size.to_le_bytes());
aad.extend_from_slice(&chunk_index.to_le_bytes());
aad
}
fn push_bytes(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(&(value.len() as u64).to_le_bytes());
out.extend_from_slice(value);
}
fn push_opt_str(out: &mut Vec<u8>, value: Option<&str>) {
match value {
Some(value) => {
out.push(1);
push_bytes(out, value.as_bytes());
}
None => out.push(0),
}
}
fn push_opt_u64(out: &mut Vec<u8>, value: Option<u64>) {
match value {
Some(value) => {
out.push(1);
out.extend_from_slice(&value.to_le_bytes());
}
None => out.push(0),
}
}
fn push_opt_u8(out: &mut Vec<u8>, value: Option<u8>) {
match value {
Some(value) => {
out.push(1);
out.push(value);
}
None => out.push(0),
}
}
fn rand_bytes<const N: usize>() -> [u8; N] {
let mut rng = rand::rng();
let mut bytes = [0u8; N];
rng.fill_bytes(&mut bytes);
bytes
}
fn derive_gcm_nonce(base: &[u8; 12], idx: u64) -> [u8; 12] {
let mut nonce = *base;
let mut ctr = [0u8; 8];
ctr.copy_from_slice(&nonce[4..12]);
let c = u64::from_le_bytes(ctr).wrapping_add(idx);
nonce[4..12].copy_from_slice(&c.to_le_bytes());
nonce
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sha3_256;
use aes_gcm::KeyInit;
use futures::TryStreamExt;
use object_store::{integration::*, local::LocalFileSystem, memory::InMemory};
use tempfile::TempDir;
const NON_EXISTENT_NAME: &str = "nonexistentname";
fn test_cipher() -> Aes256Gcm {
Aes256Gcm::new(&Key::<Aes256Gcm>::from([0u8; 32]))
}
fn encrypt_chunks(
cipher: &Aes256Gcm,
base_nonce: &[u8; 12],
plaintext: &[u8],
chunk_size: u64,
bound_aad: bool,
) -> (Vec<u8>, Vec<ByteArray<16>>) {
let mut ciphertext = plaintext.to_vec();
let mut aes_tags = Vec::with_capacity(ciphertext.len().div_ceil(chunk_size as usize));
for (idx, chunk) in ciphertext.chunks_mut(chunk_size as usize).enumerate() {
let nonce = derive_gcm_nonce(base_nonce, idx as u64);
let aad = if bound_aad {
chunk_aad(chunk_size, idx as u64)
} else {
Vec::new()
};
let tag = cipher
.encrypt_inout_detached(&Nonce::from(nonce), &aad, chunk.into())
.unwrap();
let tag: [u8; 16] = tag.into();
aes_tags.push(tag.into());
}
(ciphertext, aes_tags)
}
async fn put_legacy_encrypted_object(
inner: &InMemory,
location: &Path,
plaintext: &'static [u8],
chunk_size: u64,
) {
let cipher = test_cipher();
let base_nonce = [7u8; 12];
let chunk_size = normalize_chunk_size(chunk_size);
let (ciphertext, aes_tags) =
encrypt_chunks(&cipher, &base_nonce, plaintext, chunk_size, false);
let hash = sha3_256(&ciphertext);
let put = inner
.put(
&Path::from(format!("data/{location}")),
Bytes::from(ciphertext).into(),
)
.await
.unwrap();
let meta = Metadata {
size: plaintext.len() as u64,
e_tag: Some(BASE64_URL_SAFE.encode(hash)),
original_tag: put.e_tag,
original_version: put.version,
aes_nonce: base_nonce.into(),
aes_tags,
chunk_size: Some(chunk_size),
chunk_aad_version: None,
auth_nonce: None,
auth_tag: None,
generation: None,
};
let mut buf = Vec::new();
cbor2::to_writer(&meta, &mut buf).unwrap();
inner
.put(&Path::from(format!("meta/{location}")), buf.into())
.await
.unwrap();
}
async fn put_sealed_v1_object(
inner: &InMemory,
location: &Path,
plaintext: &'static [u8],
chunk_size: u64,
) {
let cipher = test_cipher();
let base_nonce = [9u8; 12];
let chunk_size = normalize_chunk_size(chunk_size);
let (ciphertext, aes_tags) =
encrypt_chunks(&cipher, &base_nonce, plaintext, chunk_size, true);
let hash = sha3_256(&ciphertext);
let put = inner
.put(
&Path::from(format!("data/{location}")),
Bytes::from(ciphertext).into(),
)
.await
.unwrap();
let mut meta = Metadata {
size: plaintext.len() as u64,
e_tag: Some(BASE64_URL_SAFE.encode(hash)),
original_tag: put.e_tag,
original_version: put.version,
aes_nonce: base_nonce.into(),
aes_tags,
chunk_size: Some(chunk_size),
chunk_aad_version: Some(CHUNK_AAD_BOUND),
auth_nonce: None,
auth_tag: None,
generation: None,
};
seal_metadata(&cipher, location, &mut meta).unwrap();
let mut buf = Vec::new();
cbor2::to_writer(&meta, &mut buf).unwrap();
inner
.put(&Path::from(format!("meta/{location}")), buf.into())
.await
.unwrap();
}
async fn read_meta(inner: &InMemory, location: &Path) -> Metadata {
let bytes = inner
.get(&Path::from(format!("meta/{location}")))
.await
.unwrap()
.bytes()
.await
.unwrap();
cbor2::from_reader(&bytes[..]).unwrap()
}
async fn ciphertext_path(inner: &InMemory, location: &Path) -> Path {
let meta = read_meta(inner, location).await;
match meta.generation {
Some(g) => Path::from(format!("gen/{location}/{g}")),
None => Path::from(format!("data/{location}")),
}
}
#[test]
fn builder_custom_cache_and_display_debug_are_exercised() {
let cache = Cache::builder().max_capacity(1).build();
let storage = EncryptedStoreBuilder::with_secret(InMemory::new(), 100, [0u8; 32])
.with_meta_cache(cache)
.with_conditional_put() .build();
assert!(format!("{storage}").contains("EncryptedStore"));
assert!(format!("{storage:?}").contains("EncryptedStore"));
let location = Path::from("nested/object");
assert_eq!(
storage.inner.meta_path(&location).to_string(),
"meta/nested/object"
);
assert_eq!(
storage.inner.legacy_path(&location).to_string(),
"data/nested/object"
);
assert_eq!(
storage
.inner
.generation_path(&location, "0123-abcd")
.to_string(),
"gen/nested/object/0123-abcd"
);
}
#[tokio::test]
async fn test_with_memory() {
let storage = EncryptedStoreBuilder::with_secret(InMemory::new(), 10000, [0u8; 32]).build();
let location = Path::from(NON_EXISTENT_NAME);
let err = get_nonexistent_object(&storage, Some(location))
.await
.unwrap_err();
if let crate::Error::NotFound { path, .. } = err {
assert!(path.ends_with(NON_EXISTENT_NAME));
} else {
panic!("unexpected error type: {err:?}");
}
put_get_delete_list(&storage).await;
put_get_attributes(&storage).await;
get_opts(&storage).await;
put_opts(&storage, true).await;
list_uses_directories_correctly(&storage).await;
list_with_delimiter(&storage).await;
rename_and_copy(&storage).await;
copy_if_not_exists(&storage).await;
copy_rename_nonexistent_object(&storage).await;
multipart_race_condition(&storage, true).await;
multipart_out_of_order(&storage).await;
let storage = EncryptedStoreBuilder::with_secret(InMemory::new(), 10000, [0u8; 32]).build();
stream_get(&storage).await;
}
#[tokio::test]
async fn zero_chunk_size_is_normalized() {
let storage = EncryptedStoreBuilder::with_secret(InMemory::new(), 100, [0u8; 32])
.with_chunk_size(0)
.build();
let location = Path::from("zero-chunk-size");
storage
.put(&location, Bytes::from_static(b"abc").into())
.await
.unwrap();
let requested = 0..3;
let ranges = storage
.get_ranges(&location, std::slice::from_ref(&requested))
.await
.unwrap();
assert_eq!(ranges, vec![Bytes::from_static(b"abc")]);
}
#[tokio::test]
async fn recorded_chunk_size_survives_reconfiguration() {
let inner = InMemory::new();
let storage = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32])
.with_chunk_size(4)
.build();
let location = Path::from("chunked");
let payload = Bytes::from_static(b"abcdefghijklmnopqrstuvwxyz");
storage
.put(&location, payload.clone().into())
.await
.unwrap();
let storage = EncryptedStoreBuilder::with_secret(inner, 100, [0u8; 32])
.with_chunk_size(16)
.build();
let bytes = storage.get(&location).await.unwrap().bytes().await.unwrap();
assert_eq!(bytes, payload);
let ranges = storage
.get_ranges(&location, &[3..11, 0..26, 7..8])
.await
.unwrap();
assert_eq!(ranges[0], payload.slice(3..11));
assert_eq!(ranges[1], payload);
assert_eq!(ranges[2], payload.slice(7..8));
let bytes = storage.get_range(&location, 5..23).await.unwrap();
assert_eq!(bytes, payload.slice(5..23));
}
#[tokio::test]
async fn get_ranges_covers_multi_chunk_and_repeated_ranges() {
let storage = EncryptedStoreBuilder::with_secret(InMemory::new(), 100, [0u8; 32])
.with_chunk_size(4)
.build();
let location = Path::from("multi-chunk");
let payload: Vec<u8> = (0u8..=255).collect();
storage
.put(&location, Bytes::from(payload.clone()).into())
.await
.unwrap();
let ranges = vec![
0..256,
5..6,
4..8,
1..2,
250..256,
0..1,
255..256,
8..200,
7..9,
];
let got = storage.get_ranges(&location, &ranges).await.unwrap();
for (range, bytes) in ranges.iter().zip(&got) {
assert_eq!(
bytes.as_ref(),
&payload[range.start as usize..range.end as usize],
"range {range:?}"
);
}
}
#[tokio::test]
async fn legacy_metadata_without_auth_remains_readable() {
let inner = InMemory::new();
let location = Path::from("legacy-object");
let payload = b"legacy encrypted payload";
put_legacy_encrypted_object(&inner, &location, payload, 4).await;
let storage = EncryptedStoreBuilder::with_secret(inner, 100, [0u8; 32])
.with_chunk_size(16)
.build();
let bytes = storage.get(&location).await.unwrap().bytes().await.unwrap();
assert_eq!(bytes.as_ref(), payload);
let ranges = storage.get_ranges(&location, &[0..6, 7..16]).await.unwrap();
assert_eq!(ranges[0].as_ref(), &payload[0..6]);
assert_eq!(ranges[1].as_ref(), &payload[7..16]);
let range = storage.get_range(&location, 3..19).await.unwrap();
assert_eq!(range.as_ref(), &payload[3..19]);
}
#[tokio::test]
async fn sealed_v1_layout_still_verifies_and_upgrades() {
let inner = InMemory::new();
let location = Path::from("sealed-v1");
let payload = b"sealed v1 payload";
put_sealed_v1_object(&inner, &location, payload, 4).await;
let storage = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32])
.with_chunk_size(4)
.with_strict_metadata_auth()
.build();
let bytes = storage.get(&location).await.unwrap().bytes().await.unwrap();
assert_eq!(bytes.as_ref(), payload);
storage
.put(&location, Bytes::from_static(b"upgraded").into())
.await
.unwrap();
let bytes = storage.get(&location).await.unwrap().bytes().await.unwrap();
assert_eq!(bytes, Bytes::from_static(b"upgraded"));
let meta = read_meta(&inner, &location).await;
assert!(meta.generation.is_some());
assert!(matches!(
inner.get(&Path::from("data/sealed-v1")).await,
Err(Error::NotFound { .. })
));
}
#[tokio::test]
async fn legacy_metadata_copy_and_rename_reseal_legacy_chunk_aad() {
let inner = InMemory::new();
let source = Path::from("legacy-copy-source");
let copied = Path::from("legacy-copy-target");
let renamed = Path::from("legacy-rename-target");
let payload = b"legacy copy rename payload";
put_legacy_encrypted_object(&inner, &source, payload, 4).await;
let storage = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32])
.with_chunk_size(16)
.build();
storage.copy(&source, &copied).await.unwrap();
let copied_meta = read_meta(&inner, &copied).await;
assert_eq!(copied_meta.chunk_aad_version, Some(CHUNK_AAD_LEGACY));
assert!(copied_meta.auth_nonce.is_some());
assert!(copied_meta.auth_tag.is_some());
assert!(copied_meta.generation.is_some());
let bytes = storage.get(&copied).await.unwrap().bytes().await.unwrap();
assert_eq!(bytes.as_ref(), payload);
storage.rename(&copied, &renamed).await.unwrap();
let renamed_meta = read_meta(&inner, &renamed).await;
assert_eq!(renamed_meta.chunk_aad_version, Some(CHUNK_AAD_LEGACY));
assert!(renamed_meta.auth_nonce.is_some());
assert!(renamed_meta.auth_tag.is_some());
let bytes = storage.get(&renamed).await.unwrap().bytes().await.unwrap();
assert_eq!(bytes.as_ref(), payload);
assert!(matches!(
storage.get(&copied).await,
Err(Error::NotFound { .. })
));
}
#[tokio::test]
async fn metadata_path_binding_rejects_swapped_data_and_sidecar() {
let inner = InMemory::new();
let storage = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32])
.with_chunk_size(4)
.build();
let a = Path::from("object-a");
let b = Path::from("object-b");
storage
.put(&a, Bytes::from_static(b"aaaaaaaa").into())
.await
.unwrap();
storage
.put(&b, Bytes::from_static(b"bbbbbbbb").into())
.await
.unwrap();
let a_meta = read_meta(&inner, &a).await;
let a_gen = a_meta.generation.clone().unwrap();
let a_data = inner
.get(&Path::from(format!("gen/object-a/{a_gen}")))
.await
.unwrap()
.bytes()
.await
.unwrap();
inner
.put(&Path::from(format!("gen/object-b/{a_gen}")), a_data.into())
.await
.unwrap();
let a_meta_bytes = inner
.get(&Path::from("meta/object-a"))
.await
.unwrap()
.bytes()
.await
.unwrap();
inner
.put(&Path::from("meta/object-b"), a_meta_bytes.into())
.await
.unwrap();
let reopened = EncryptedStoreBuilder::with_secret(inner, 100, [0u8; 32])
.with_chunk_size(4)
.build();
let err = match reopened.get(&b).await {
Ok(_) => panic!("swapped sidecar should fail metadata authentication"),
Err(err) => err,
};
assert!(err.to_string().contains("metadata authentication failed"));
}
#[tokio::test]
async fn metadata_authentication_rejects_sidecar_mutation() {
let inner = InMemory::new();
let storage = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32])
.with_chunk_size(4)
.build();
let location = Path::from("tamper-meta");
storage
.put(&location, Bytes::from_static(b"abcdefgh").into())
.await
.unwrap();
let meta_path = Path::from("meta/tamper-meta");
let mut meta = read_meta(&inner, &location).await;
meta.size += 1;
let mut tampered = Vec::new();
cbor2::to_writer(&meta, &mut tampered).unwrap();
inner.put(&meta_path, tampered.into()).await.unwrap();
let reopened = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32])
.with_chunk_size(4)
.build();
let err = match reopened.get(&location).await {
Ok(_) => panic!("tampered sidecar should fail metadata authentication"),
Err(err) => err,
};
assert!(err.to_string().contains("metadata authentication failed"));
let mut meta = read_meta(&inner, &location).await;
meta.size -= 1; meta.generation = Some("0000000000000009-11111111".to_string());
let mut tampered = Vec::new();
cbor2::to_writer(&meta, &mut tampered).unwrap();
inner.put(&meta_path, tampered.into()).await.unwrap();
let reopened = EncryptedStoreBuilder::with_secret(inner, 100, [0u8; 32])
.with_chunk_size(4)
.build();
let err = reopened.get(&location).await.unwrap_err();
assert!(err.to_string().contains("metadata authentication failed"));
}
#[tokio::test]
async fn copy_and_rename_reject_tampered_source_metadata_without_resealing() {
let inner = InMemory::new();
let storage = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32]).build();
let copy_source = Path::from("tamper-copy-source");
let copy_target = Path::from("tamper-copy-target");
let rename_source = Path::from("tamper-rename-source");
let rename_target = Path::from("tamper-rename-target");
storage
.put(©_source, Bytes::from_static(b"copy").into())
.await
.unwrap();
storage
.put(&rename_source, Bytes::from_static(b"rename").into())
.await
.unwrap();
for meta_path in [
Path::from("meta/tamper-copy-source"),
Path::from("meta/tamper-rename-source"),
] {
let meta_bytes = inner.get(&meta_path).await.unwrap().bytes().await.unwrap();
let mut meta: Metadata = cbor2::from_reader(&meta_bytes[..]).unwrap();
meta.e_tag = Some("forged".to_string());
let mut tampered = Vec::new();
cbor2::to_writer(&meta, &mut tampered).unwrap();
inner.put(&meta_path, tampered.into()).await.unwrap();
}
let reopened = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32]).build();
let err = reopened.copy(©_source, ©_target).await.unwrap_err();
assert!(err.to_string().contains("metadata authentication failed"));
assert!(matches!(
reopened.get(©_target).await,
Err(Error::NotFound { .. })
));
let err = reopened
.rename(&rename_source, &rename_target)
.await
.unwrap_err();
assert!(err.to_string().contains("metadata authentication failed"));
assert!(matches!(
reopened.get(&rename_target).await,
Err(Error::NotFound { .. })
));
}
#[tokio::test]
async fn delete_nonexistent_reports_logical_path() {
let root = TempDir::new().unwrap();
let storage = EncryptedStoreBuilder::with_secret(
LocalFileSystem::new_with_prefix(root.path()).unwrap(),
100,
[0u8; 32],
)
.build();
let err = storage
.delete(&Path::from("missing/object"))
.await
.unwrap_err();
assert!(
matches!(&err, Error::NotFound { path, .. } if path == "missing/object"),
"unexpected error: {err:?}"
);
}
#[tokio::test]
async fn get_opts_accepts_comma_separated_logical_etags() {
let storage = EncryptedStoreBuilder::with_secret(InMemory::new(), 100, [0u8; 32]).build();
let location = Path::from("encrypted-etag-list");
let put = storage
.put(&location, Bytes::from_static(b"abc").into())
.await
.unwrap();
let e_tag = put.e_tag.unwrap();
let bytes = storage
.get_opts(
&location,
GetOptions {
if_match: Some(format!("other, {e_tag}")),
..Default::default()
},
)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(bytes, Bytes::from_static(b"abc"));
let err = storage
.get_opts(
&location,
GetOptions {
if_none_match: Some(format!("other, {e_tag}")),
..Default::default()
},
)
.await
.unwrap_err();
assert!(matches!(err, Error::NotModified { .. }));
}
#[tokio::test]
async fn copy_and_rename_preserve_logical_etag_preconditions() {
let storage = EncryptedStoreBuilder::with_secret(InMemory::new(), 100, [0u8; 32]).build();
let source = Path::from("encrypted-copy-source");
let copied = Path::from("encrypted-copy-target");
let renamed = Path::from("encrypted-rename-target");
let put = storage
.put(&source, Bytes::from_static(b"abc").into())
.await
.unwrap();
let e_tag = put.e_tag.unwrap();
storage.copy(&source, &copied).await.unwrap();
let bytes = storage
.get_opts(
&copied,
GetOptions {
if_match: Some(e_tag.clone()),
..Default::default()
},
)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(bytes, Bytes::from_static(b"abc"));
storage.rename(&copied, &renamed).await.unwrap();
let bytes = storage
.get_opts(
&renamed,
GetOptions {
if_match: Some(e_tag),
..Default::default()
},
)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(bytes, Bytes::from_static(b"abc"));
}
#[tokio::test]
async fn put_update_rejects_stale_version() {
let storage = EncryptedStoreBuilder::with_secret(InMemory::new(), 100, [0u8; 32]).build();
let location = Path::from("encrypted-stale-version");
let put = storage
.put(&location, Bytes::from_static(b"abc").into())
.await
.unwrap();
let err = storage
.put_opts(
&location,
Bytes::from_static(b"def").into(),
PutOptions {
mode: PutMode::Update(UpdateVersion {
e_tag: put.e_tag.clone(),
version: Some("stale".to_string()),
}),
..Default::default()
},
)
.await
.unwrap_err();
assert!(matches!(err, Error::Precondition { .. }));
storage
.put_opts(
&location,
Bytes::from_static(b"def").into(),
PutOptions {
mode: PutMode::Update(UpdateVersion {
e_tag: put.e_tag,
version: put.version,
}),
..Default::default()
},
)
.await
.unwrap();
}
#[tokio::test]
async fn truncated_ciphertext_errors_on_stream_read() {
let inner = InMemory::new();
let storage = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32])
.with_chunk_size(4)
.build();
let location = Path::from("truncated");
storage
.put(&location, Bytes::from_static(b"abcdefgh").into())
.await
.unwrap();
let data_path = ciphertext_path(&inner, &location).await;
let ciphertext = inner.get(&data_path).await.unwrap().bytes().await.unwrap();
inner
.put(&data_path, ciphertext.slice(..4).into())
.await
.unwrap();
let err = storage
.get(&location)
.await
.unwrap()
.bytes()
.await
.unwrap_err();
assert!(err.to_string().contains("truncated encrypted data"));
}
#[tokio::test]
async fn stripped_metadata_auth_is_rejected() {
let inner = InMemory::new();
let storage = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32])
.with_chunk_size(4)
.build();
let location = Path::from("stripped");
storage
.put(&location, Bytes::from_static(b"abcdefgh").into())
.await
.unwrap();
let meta_path = Path::from("meta/stripped");
let mut meta = read_meta(&inner, &location).await;
assert!(meta.auth_nonce.is_some() && meta.auth_tag.is_some());
meta.auth_nonce = None;
meta.auth_tag = None;
let mut stripped = Vec::new();
cbor2::to_writer(&meta, &mut stripped).unwrap();
inner.put(&meta_path, stripped.into()).await.unwrap();
let reopened = EncryptedStoreBuilder::with_secret(inner, 100, [0u8; 32])
.with_chunk_size(4)
.build();
let err = reopened.get(&location).await.unwrap_err();
assert!(
err.to_string().contains("stripped metadata authentication"),
"unexpected error: {err:?}"
);
}
#[tokio::test]
async fn strict_mode_rejects_legacy_metadata() {
let inner = InMemory::new();
let legacy = Path::from("strict-legacy");
let sealed = Path::from("strict-sealed");
let payload = b"legacy encrypted payload";
put_legacy_encrypted_object(&inner, &legacy, payload, 4).await;
let strict = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32])
.with_chunk_size(4)
.with_strict_metadata_auth()
.build();
strict
.put(&sealed, Bytes::from_static(b"sealed").into())
.await
.unwrap();
let bytes = strict.get(&sealed).await.unwrap().bytes().await.unwrap();
assert_eq!(bytes, Bytes::from_static(b"sealed"));
let err = strict.get(&legacy).await.unwrap_err();
assert!(
err.to_string().contains("strict mode"),
"unexpected error: {err:?}"
);
let lenient = EncryptedStoreBuilder::with_secret(inner, 100, [0u8; 32])
.with_chunk_size(4)
.build();
let bytes = lenient.get(&legacy).await.unwrap().bytes().await.unwrap();
assert_eq!(bytes.as_ref(), payload);
}
#[tokio::test]
async fn tampered_metadata_is_rejected_in_all_listing_variants() {
let inner = InMemory::new();
let location = Path::from("strict-list/tampered");
let writer = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32]).build();
writer
.put(&location, Bytes::from_static(b"authenticated").into())
.await
.unwrap();
let meta_path = Path::from("meta/strict-list/tampered");
let bytes = inner.get(&meta_path).await.unwrap().bytes().await.unwrap();
let mut meta: Metadata = cbor2::from_reader(&bytes[..]).unwrap();
meta.size += 1;
let mut tampered = Vec::new();
cbor2::to_writer(&meta, &mut tampered).unwrap();
inner.put(&meta_path, tampered.into()).await.unwrap();
let compatible = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32]).build();
let err = compatible
.list(Some(&Path::from("strict-list")))
.try_collect::<Vec<_>>()
.await
.unwrap_err();
assert!(err.to_string().contains("metadata authentication failed"));
let strict = EncryptedStoreBuilder::with_secret(inner, 100, [0u8; 32])
.with_strict_metadata_auth()
.build();
let err = strict
.list(Some(&Path::from("strict-list")))
.try_collect::<Vec<_>>()
.await
.unwrap_err();
assert!(err.to_string().contains("metadata authentication failed"));
let err = strict
.list_with_offset(
Some(&Path::from("strict-list")),
&Path::from("strict-list/a"),
)
.try_collect::<Vec<_>>()
.await
.unwrap_err();
assert!(err.to_string().contains("metadata authentication failed"));
let err = strict
.list_with_delimiter(Some(&Path::from("strict-list")))
.await
.unwrap_err();
assert!(err.to_string().contains("metadata authentication failed"));
}
#[tokio::test]
async fn corrupted_metadata_heals_on_overwrite() {
let inner = InMemory::new();
let storage = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32])
.with_chunk_size(4)
.build();
let location = Path::from("self-heal");
storage
.put(&location, Bytes::from_static(b"old-data").into())
.await
.unwrap();
inner
.put(
&Path::from("meta/self-heal"),
Bytes::from_static(b"\xffgarbage").into(),
)
.await
.unwrap();
let reopened = EncryptedStoreBuilder::with_secret(inner, 100, [0u8; 32])
.with_chunk_size(4)
.build();
assert!(reopened.get(&location).await.is_err());
reopened
.put(&location, Bytes::from_static(b"new-data").into())
.await
.unwrap();
let bytes = reopened
.get(&location)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(bytes, Bytes::from_static(b"new-data"));
}
#[tokio::test]
async fn uncommitted_payloads_are_invisible_and_collected() {
let inner = InMemory::new();
let storage = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32]).build();
let healthy = Path::from("list/healthy");
storage
.put(&healthy, Bytes::from_static(b"abc").into())
.await
.unwrap();
inner
.put(
&Path::from("gen/list/orphan/0000000000000001-00000000"),
Bytes::from_static(b"ghost").into(),
)
.await
.unwrap();
let reopened = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32]).build();
let listed: Vec<_> = reopened
.list(Some(&Path::from("list")))
.try_collect()
.await
.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].location, healthy);
assert!(listed[0].e_tag.is_some());
assert_eq!(reopened.collect_garbage().await.unwrap(), 1);
assert!(matches!(
inner
.get(&Path::from("gen/list/orphan/0000000000000001-00000000"))
.await,
Err(Error::NotFound { .. })
));
let bytes = reopened.get(&healthy).await.unwrap().bytes().await.unwrap();
assert_eq!(bytes, Bytes::from_static(b"abc"));
}
#[tokio::test]
async fn create_succeeds_after_commit_point_loss() {
let inner = InMemory::new();
let storage = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32])
.with_chunk_size(4)
.build();
let location = Path::from("create-heal");
storage
.put(&location, Bytes::from_static(b"old-data").into())
.await
.unwrap();
inner.delete(&Path::from("meta/create-heal")).await.unwrap();
let reopened = EncryptedStoreBuilder::with_secret(inner, 100, [0u8; 32])
.with_chunk_size(4)
.build();
reopened
.put_opts(
&location,
Bytes::from_static(b"new-data").into(),
PutOptions {
mode: PutMode::Create,
..Default::default()
},
)
.await
.unwrap();
let bytes = reopened
.get(&location)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(bytes, Bytes::from_static(b"new-data"));
let err = reopened
.put_opts(
&location,
Bytes::from_static(b"again").into(),
PutOptions {
mode: PutMode::Create,
..Default::default()
},
)
.await
.unwrap_err();
assert!(matches!(err, Error::AlreadyExists { .. }));
}
#[tokio::test]
async fn crash_before_pointer_switch_keeps_old_ciphertext_decryptable() {
let inner = InMemory::new();
let (fault, handle) = crate::FaultStore::wrap(inner.clone());
let storage = EncryptedStoreBuilder::with_secret(fault, 100, [0u8; 32])
.with_chunk_size(4)
.build();
let location = Path::from("crash/encrypted");
storage
.put(&location, Bytes::from_static(b"version-1").into())
.await
.unwrap();
handle.push_rule(crate::FaultRule::fail_once(crate::FaultOp::Put, "meta/"));
assert!(
storage
.put(&location, Bytes::from_static(b"version-2").into())
.await
.is_err()
);
let bytes = storage.get(&location).await.unwrap().bytes().await.unwrap();
assert_eq!(bytes, Bytes::from_static(b"version-1"));
let reopened = EncryptedStoreBuilder::with_secret(inner.clone(), 100, [0u8; 32])
.with_chunk_size(4)
.build();
let bytes = reopened
.get(&location)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(bytes, Bytes::from_static(b"version-1"));
tokio::time::sleep(Duration::from_millis(2)).await;
assert_eq!(reopened.collect_garbage().await.unwrap(), 1);
let bytes = reopened
.get(&location)
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(bytes, Bytes::from_static(b"version-1"));
}
#[tokio::test]
async fn multipart_crash_before_complete_preserves_old_version() {
let (fault, handle) = crate::FaultStore::wrap(InMemory::new());
let storage = EncryptedStoreBuilder::with_secret(fault, 100, [0u8; 32])
.with_chunk_size(4)
.build();
let location = Path::from("multipart-crash");
storage
.put(&location, Bytes::from_static(b"version-1").into())
.await
.unwrap();
let mut upload = storage.put_multipart(&location).await.unwrap();
upload
.put_part(Bytes::from_static(b"multipart-version-2").into())
.await
.unwrap();
handle.push_rule(crate::FaultRule::fail_once(crate::FaultOp::Put, "meta/"));
assert!(upload.complete().await.is_err());
let bytes = storage.get(&location).await.unwrap().bytes().await.unwrap();
assert_eq!(bytes, Bytes::from_static(b"version-1"));
}
#[tokio::test]
async fn rename_and_copy_to_self_preserve_object() {
let storage = EncryptedStoreBuilder::with_secret(InMemory::new(), 100, [0u8; 32])
.with_chunk_size(4)
.build();
let location = Path::from("self-target");
storage
.put(&location, Bytes::from_static(b"abcdefgh").into())
.await
.unwrap();
storage.rename(&location, &location).await.unwrap();
let bytes = storage.get(&location).await.unwrap().bytes().await.unwrap();
assert_eq!(bytes, Bytes::from_static(b"abcdefgh"));
let err = storage
.rename_if_not_exists(&location, &location)
.await
.unwrap_err();
assert!(matches!(err, Error::AlreadyExists { .. }));
storage.copy(&location, &location).await.unwrap();
let bytes = storage.get(&location).await.unwrap().bytes().await.unwrap();
assert_eq!(bytes, Bytes::from_static(b"abcdefgh"));
let missing = Path::from("self-missing");
let err = storage.rename(&missing, &missing).await.unwrap_err();
assert!(matches!(err, Error::NotFound { .. }));
}
#[tokio::test]
async fn head_request_returns_empty_stream() {
let storage = EncryptedStoreBuilder::with_secret(InMemory::new(), 100, [0u8; 32])
.with_chunk_size(4)
.build();
let location = Path::from("head-object");
let payload = Bytes::from_static(b"abcdefghij");
storage
.put(&location, payload.clone().into())
.await
.unwrap();
let res = storage
.get_opts(
&location,
GetOptions {
head: true,
..Default::default()
},
)
.await
.unwrap();
assert_eq!(res.meta.size, payload.len() as u64);
let bytes = res.bytes().await.unwrap();
assert!(bytes.is_empty());
let obj = storage.head(&location).await.unwrap();
assert_eq!(obj.size, payload.len() as u64);
let empty = Path::from("head-empty");
storage.put(&empty, Bytes::new().into()).await.unwrap();
let res = storage
.get_opts(
&empty,
GetOptions {
head: true,
..Default::default()
},
)
.await
.unwrap();
assert_eq!(res.meta.size, 0);
assert!(res.bytes().await.unwrap().is_empty());
let bytes = storage.get(&empty).await.unwrap().bytes().await.unwrap();
assert!(bytes.is_empty());
}
#[tokio::test]
async fn concurrent_multipart_completes_leave_readable_object() {
let storage = EncryptedStoreBuilder::with_secret(InMemory::new(), 100, [0u8; 32])
.with_chunk_size(4)
.build();
let location = Path::from("multipart-race");
let content_a = Bytes::from_static(b"aaaaaaaaaaaaaa");
let content_b = Bytes::from_static(b"bbbbbbbbbb");
let mut up_a = storage.put_multipart(&location).await.unwrap();
let mut up_b = storage.put_multipart(&location).await.unwrap();
up_a.put_part(content_a.clone().into()).await.unwrap();
up_b.put_part(content_b.clone().into()).await.unwrap();
let (ra, rb) = futures::join!(up_a.complete(), up_b.complete());
ra.unwrap();
rb.unwrap();
let bytes = storage.get(&location).await.unwrap().bytes().await.unwrap();
assert!(bytes == content_a || bytes == content_b);
}
#[test]
fn derive_gcm_nonce_counter_wraparound() {
let base = [0xffu8; 12];
let n0 = derive_gcm_nonce(&base, 0);
let n1 = derive_gcm_nonce(&base, 1);
assert_ne!(n0, n1);
assert_eq!(n0[..4], base[..4]);
assert_eq!(n1[..4], base[..4]);
assert_eq!(u64::from_le_bytes(n0[4..].try_into().unwrap()), u64::MAX);
assert_eq!(u64::from_le_bytes(n1[4..].try_into().unwrap()), 0);
let mut seen = std::collections::HashSet::new();
for idx in 0..1000u64 {
assert!(seen.insert(derive_gcm_nonce(&base, idx)));
}
}
#[tokio::test]
async fn test_with_local_file() {
let root = TempDir::new().unwrap();
let storage = EncryptedStoreBuilder::with_secret(
LocalFileSystem::new_with_prefix(root.path()).unwrap(),
10000,
[0u8; 32],
)
.build();
let location = Path::from(NON_EXISTENT_NAME);
let err = get_nonexistent_object(&storage, Some(location))
.await
.unwrap_err();
if let crate::Error::NotFound { path, .. } = err {
assert!(path.ends_with(NON_EXISTENT_NAME));
} else {
panic!("unexpected error type: {err:?}");
}
put_get_attributes(&storage).await;
get_opts(&storage).await;
put_opts(&storage, true).await;
list_uses_directories_correctly(&storage).await;
list_with_delimiter(&storage).await;
rename_and_copy(&storage).await;
copy_if_not_exists(&storage).await;
copy_rename_nonexistent_object(&storage).await;
multipart_race_condition(&storage, true).await;
multipart_out_of_order(&storage).await;
let root = TempDir::new().unwrap();
let storage = EncryptedStoreBuilder::with_secret(
LocalFileSystem::new_with_prefix(root.path()).unwrap(),
10000,
[0u8; 32],
)
.build();
stream_get(&storage).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn stress_occ_counter_local_file() {
const NUM_WORKERS: usize = 16;
const NUM_INCREMENTS: usize = 25;
let root = TempDir::new().unwrap();
let storage = std::sync::Arc::new(
EncryptedStoreBuilder::with_secret(
LocalFileSystem::new_with_prefix(root.path()).unwrap(),
10000,
[7u8; 32],
)
.build(),
);
let path = Path::from("RACE");
let mut tasks = tokio::task::JoinSet::new();
for _ in 0..NUM_WORKERS {
let storage = storage.clone();
let path = path.clone();
tasks.spawn(async move {
for _ in 0..NUM_INCREMENTS {
loop {
match storage.get(&path).await {
Ok(r) => {
let mode = PutMode::Update(UpdateVersion {
e_tag: r.meta.e_tag.clone(),
version: r.meta.version.clone(),
});
let b = r.bytes().await.unwrap();
let v: usize = std::str::from_utf8(&b).unwrap().parse().unwrap();
let new = (v + 1).to_string();
match storage.put_opts(&path, new.into(), mode.into()).await {
Ok(_) => break,
Err(object_store::Error::Precondition { .. }) => continue,
Err(e) => panic!("unexpected error: {e:?}"),
}
}
Err(object_store::Error::NotFound { .. }) => {
match storage
.put_opts(&path, "1".into(), PutMode::Create.into())
.await
{
Ok(_) => break,
Err(object_store::Error::AlreadyExists { .. }) => continue,
Err(e) => panic!("unexpected error: {e:?}"),
}
}
Err(e) => panic!("unexpected error: {e:?}"),
}
}
}
});
}
while let Some(rt) = tasks.join_next().await {
rt.unwrap();
}
let b = storage.get(&path).await.unwrap().bytes().await.unwrap();
let v = std::str::from_utf8(&b).unwrap().parse::<usize>().unwrap();
assert_eq!(v, NUM_WORKERS * NUM_INCREMENTS, "lost updates");
}
}