use async_trait::async_trait;
use crate::blob::{BlobError, BlobStore};
use crate::core::{Digest, Timestamp};
use super::{KeyError, KeyRing};
#[derive(Debug)]
pub struct EncryptedBlobs {
inner: std::sync::Arc<dyn BlobStore>,
keys: std::sync::Arc<dyn KeyRing>,
scope: String,
}
impl EncryptedBlobs {
#[must_use]
pub fn new(
inner: std::sync::Arc<dyn BlobStore>,
keys: std::sync::Arc<dyn KeyRing>,
scope: impl Into<String>,
) -> Self {
Self {
inner,
keys,
scope: scope.into(),
}
}
fn aad(&self, digest: Digest) -> String {
format!("blob:{}:{}", self.scope, digest.to_hex())
}
}
fn classify(digest: Digest, e: KeyError) -> BlobError {
match e {
KeyError::Destroyed { scope, at, reason } => BlobError::Expired {
digest: digest.to_hex(),
at: at.unix_timestamp(),
reason: format!("the data key for scope '{scope}' was destroyed: {reason}"),
},
e @ (KeyError::Retired { .. }
| KeyError::UnknownFormat { .. }
| KeyError::UnreadableHeader { .. }) => BlobError::Unopened {
digest: digest.to_hex(),
detail: e.to_string(),
},
KeyError::Unavailable(e) => BlobError::Backend(format!("the key ring is unavailable: {e}")),
KeyError::Refused(why) => BlobError::Corrupt {
expected: digest.to_hex(),
actual: why,
},
}
}
#[async_trait]
impl BlobStore for EncryptedBlobs {
async fn put(&self, bytes: &[u8]) -> Result<Digest, BlobError> {
let digest = Digest::of(bytes);
let envelope = super::envelope::seal(
self.keys.as_ref(),
&self.scope,
self.aad(digest).as_bytes(),
bytes,
)
.await
.map_err(|e| match e {
e @ KeyError::Destroyed { .. } => classify(digest, e),
other => BlobError::Backend(format!("sealing a payload failed: {other}")),
})?;
self.inner.put_at(digest, &envelope).await?;
Ok(digest)
}
async fn get(&self, digest: Digest) -> Result<Vec<u8>, BlobError> {
let envelope = self.inner.get_raw(digest).await?;
let plain =
super::envelope::open(self.keys.as_ref(), self.aad(digest).as_bytes(), &envelope)
.await
.map_err(|e| classify(digest, e))?;
let actual = Digest::of(&plain);
if actual != digest {
return Err(BlobError::Corrupt {
expected: digest.to_hex(),
actual: actual.to_hex(),
});
}
Ok(plain)
}
async fn put_at(&self, digest: Digest, _bytes: &[u8]) -> Result<(), BlobError> {
Err(BlobError::Backend(format!(
"put_at({}) refused: this blob store seals under scope '{}', and put_at would \
store the bytes unsealed — use put, which seals and preserves the plaintext \
address",
digest.to_hex(),
self.scope
)))
}
async fn get_raw(&self, digest: Digest) -> Result<Vec<u8>, BlobError> {
Err(BlobError::Backend(format!(
"get_raw({}) refused: this blob store seals under scope '{}', and raw envelope \
bytes are not a payload — use get, which opens and verifies",
digest.to_hex(),
self.scope
)))
}
async fn expire(&self, digest: Digest, at: Timestamp, reason: &str) -> Result<(), BlobError> {
self.inner.expire(digest, at, reason).await
}
async fn has(&self, digest: Digest) -> Result<bool, BlobError> {
self.inner.has(digest).await
}
}
#[cfg(all(test, feature = "testkit"))]
mod refusal_tests {
use super::*;
use crate::blob::MemoryBlobs;
use crate::testkit::MemoryKeyRing;
use std::sync::Arc;
#[tokio::test]
async fn the_sealed_handle_refuses_unsealed_io() {
let store = EncryptedBlobs::new(
Arc::new(MemoryBlobs::new()),
Arc::new(MemoryKeyRing::new()),
"acme/case-1",
);
let digest = store.put(b"the payload").await.expect("sealed put");
assert_eq!(
store.get(digest).await.expect("sealed get"),
b"the payload",
"the sealing pair must keep working"
);
let refused = store
.put_at(digest, b"plaintext through the side door")
.await
.expect_err("put_at on a sealed handle stored plaintext");
assert!(
refused.to_string().contains("unsealed"),
"the refusal must say why: {refused}"
);
assert!(
store.get_raw(digest).await.is_err(),
"get_raw on a sealed handle handed out raw envelopes"
);
assert_eq!(
store.get(digest).await.expect("still sealed"),
b"the payload"
);
}
#[tokio::test]
async fn an_envelope_read_under_another_scope_does_not_open() {
let inner: Arc<dyn BlobStore> = Arc::new(MemoryBlobs::new());
let ring: Arc<dyn KeyRing> = Arc::new(MemoryKeyRing::new());
let sealed_in = EncryptedBlobs::new(Arc::clone(&inner), Arc::clone(&ring), "acme/case-1");
let other = EncryptedBlobs::new(Arc::clone(&inner), Arc::clone(&ring), "acme/case-2");
let digest = sealed_in.put(b"case one's bytes").await.expect("put");
assert_eq!(
inner.get_raw(digest).await.expect("raw")[0],
super::super::envelope::FORMAT_VERSION,
"a stored blob is not the crate's versioned envelope"
);
assert!(
matches!(other.get(digest).await, Err(BlobError::Corrupt { .. })),
"an envelope sealed for one erasure unit opened under another"
);
assert_eq!(
sealed_in.get(digest).await.expect("get"),
b"case one's bytes"
);
}
#[tokio::test]
async fn a_retired_key_version_reads_as_unopened() {
let ring = Arc::new(MemoryKeyRing::new());
let store = EncryptedBlobs::new(
Arc::new(MemoryBlobs::new()),
Arc::clone(&ring) as Arc<dyn KeyRing>,
"acme/case-1",
);
let digest = store.put(b"the payload").await.expect("put");
ring.rotate();
ring.retire_below(1);
let read = store.get(digest).await;
assert!(
matches!(read, Err(BlobError::Unopened { ref detail, .. }) if detail.contains("retired")),
"a retired wrapping-key version read as {read:?}"
);
}
}