use std::sync::Arc;
use crate::key_provider::{KeyError, KeyProvider, KeyPurpose};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CryptoShredConfig {
pub data_encryption: bool,
pub backup_encryption: bool,
}
impl Default for CryptoShredConfig {
fn default() -> Self {
Self {
data_encryption: true,
backup_encryption: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShredPlan {
pub tenant_id: String,
pub purposes: Vec<KeyPurpose>,
}
impl ShredPlan {
pub fn purpose_strings(&self) -> Vec<&'static str> {
self.purposes.iter().map(|p| p.as_str()).collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShredKeyResult {
pub purpose: KeyPurpose,
pub destroyed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShredOutcome {
pub tenant_id: String,
pub results: Vec<ShredKeyResult>,
}
impl ShredOutcome {
pub fn all_destroyed_or_absent(&self) -> bool {
true
}
pub fn destroyed_purposes(&self) -> Vec<KeyPurpose> {
self.results
.iter()
.filter(|r| r.destroyed)
.map(|r| r.purpose)
.collect()
}
}
#[derive(Debug, thiserror::Error)]
pub enum CryptoShredError {
#[error("invalid argument: {0}")]
InvalidArgument(String),
#[error("key provider error: {0}")]
KeyProvider(#[from] KeyError),
}
pub struct CryptoShredder {
keys: Arc<dyn KeyProvider>,
cfg: CryptoShredConfig,
}
impl CryptoShredder {
pub fn new(keys: Arc<dyn KeyProvider>, cfg: CryptoShredConfig) -> Self {
Self { keys, cfg }
}
pub fn prepare(&self, tenant_id: &str) -> Result<ShredPlan, CryptoShredError> {
if tenant_id.is_empty() {
return Err(CryptoShredError::InvalidArgument(
"tenant_id is empty".into(),
));
}
let mut purposes = Vec::new();
if self.cfg.data_encryption {
purposes.push(KeyPurpose::TenantDataEncryption);
}
if self.cfg.backup_encryption {
purposes.push(KeyPurpose::BackupBlobEncryption);
}
Ok(ShredPlan {
tenant_id: tenant_id.into(),
purposes,
})
}
pub async fn shred(&self, tenant_id: &str) -> Result<ShredOutcome, CryptoShredError> {
let plan = self.prepare(tenant_id)?;
let mut results = Vec::with_capacity(plan.purposes.len());
let mut first_err: Option<KeyError> = None;
for purpose in plan.purposes {
match self.keys.destroy(tenant_id, purpose).await {
Ok(destroyed) => results.push(ShredKeyResult { purpose, destroyed }),
Err(e) => {
if first_err.is_none() {
first_err = Some(e);
}
results.push(ShredKeyResult {
purpose,
destroyed: false,
});
}
}
}
let outcome = ShredOutcome {
tenant_id: tenant_id.into(),
results,
};
if let Some(e) = first_err {
return Err(CryptoShredError::KeyProvider(e));
}
Ok(outcome)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::key_provider::LocalKeyProvider;
fn shredder(cfg: CryptoShredConfig) -> (CryptoShredder, Arc<LocalKeyProvider>) {
let kp = Arc::new(LocalKeyProvider::new());
let dyn_kp: Arc<dyn KeyProvider> = kp.clone();
let s = CryptoShredder::new(dyn_kp, cfg);
(s, kp)
}
#[test]
fn config_defaults_destroy_both_data_and_backup_keys() {
let cfg = CryptoShredConfig::default();
assert!(cfg.data_encryption);
assert!(cfg.backup_encryption);
}
#[test]
fn prepare_lists_data_encryption_when_enabled() {
let (s, _kp) = shredder(CryptoShredConfig::default());
let plan = s.prepare("acme").unwrap();
assert_eq!(plan.tenant_id, "acme");
assert!(plan.purposes.contains(&KeyPurpose::TenantDataEncryption));
assert!(plan.purposes.contains(&KeyPurpose::BackupBlobEncryption));
}
#[test]
fn prepare_omits_backup_when_disabled() {
let cfg = CryptoShredConfig {
data_encryption: true,
backup_encryption: false,
};
let (s, _kp) = shredder(cfg);
let plan = s.prepare("acme").unwrap();
assert!(plan.purposes.contains(&KeyPurpose::TenantDataEncryption));
assert!(!plan.purposes.contains(&KeyPurpose::BackupBlobEncryption));
}
#[test]
fn prepare_with_empty_tenant_rejected() {
let (s, _kp) = shredder(CryptoShredConfig::default());
let err = s.prepare("").unwrap_err();
assert!(matches!(err, CryptoShredError::InvalidArgument(_)));
}
#[tokio::test]
async fn shred_destroys_data_encryption_key() {
let (s, kp) = shredder(CryptoShredConfig::default());
kp.rotate_key("acme", KeyPurpose::TenantDataEncryption)
.await
.unwrap();
kp.rotate_key("acme", KeyPurpose::BackupBlobEncryption)
.await
.unwrap();
let outcome = s.shred("acme").await.unwrap();
assert_eq!(outcome.tenant_id, "acme");
assert_eq!(outcome.results.len(), 2);
for r in &outcome.results {
assert!(r.destroyed, "expected destroyed for {:?}", r.purpose);
}
let err1 = kp
.get_key("acme", KeyPurpose::TenantDataEncryption)
.await
.unwrap_err();
assert!(matches!(err1, KeyError::NotFound { .. }));
let err2 = kp
.get_key("acme", KeyPurpose::BackupBlobEncryption)
.await
.unwrap_err();
assert!(matches!(err2, KeyError::NotFound { .. }));
}
#[tokio::test]
async fn shred_idempotent_on_second_call() {
let (s, kp) = shredder(CryptoShredConfig::default());
kp.rotate_key("acme", KeyPurpose::TenantDataEncryption)
.await
.unwrap();
let first = s.shred("acme").await.unwrap();
let second = s.shred("acme").await.unwrap();
assert!(first
.destroyed_purposes()
.contains(&KeyPurpose::TenantDataEncryption));
assert!(second.destroyed_purposes().is_empty());
assert!(first.all_destroyed_or_absent());
assert!(second.all_destroyed_or_absent());
}
#[tokio::test]
async fn shred_does_not_touch_other_purposes() {
let (s, kp) = shredder(CryptoShredConfig::default());
kp.rotate_key("acme", KeyPurpose::TenantDataEncryption)
.await
.unwrap();
kp.rotate_key("acme", KeyPurpose::ClusterTls).await.unwrap();
kp.rotate_key("acme", KeyPurpose::AuditSigning)
.await
.unwrap();
s.shred("acme").await.unwrap();
assert!(kp.get_key("acme", KeyPurpose::ClusterTls).await.is_ok());
assert!(kp.get_key("acme", KeyPurpose::AuditSigning).await.is_ok());
}
#[tokio::test]
async fn shred_does_not_touch_other_tenants() {
let (s, kp) = shredder(CryptoShredConfig::default());
kp.rotate_key("alice", KeyPurpose::TenantDataEncryption)
.await
.unwrap();
kp.rotate_key("bob", KeyPurpose::TenantDataEncryption)
.await
.unwrap();
s.shred("alice").await.unwrap();
assert!(kp
.get_key("alice", KeyPurpose::TenantDataEncryption)
.await
.is_err());
assert!(kp
.get_key("bob", KeyPurpose::TenantDataEncryption)
.await
.is_ok());
}
#[tokio::test]
async fn shred_invalidates_old_key_versions_too() {
let (s, kp) = shredder(CryptoShredConfig::default());
kp.rotate_key("acme", KeyPurpose::TenantDataEncryption)
.await
.unwrap();
kp.rotate_key("acme", KeyPurpose::TenantDataEncryption)
.await
.unwrap();
kp.rotate_key("acme", KeyPurpose::TenantDataEncryption)
.await
.unwrap();
s.shred("acme").await.unwrap();
for v in 1..=3 {
let err = kp
.get_key_version("acme", KeyPurpose::TenantDataEncryption, v)
.await
.unwrap_err();
assert!(
matches!(err, KeyError::UnknownVersion { .. }),
"expected v{} to be unrecoverable, got {:?}",
v,
err
);
}
}
#[tokio::test]
async fn shred_with_no_keys_succeeds_no_op() {
let (s, _kp) = shredder(CryptoShredConfig::default());
let outcome = s.shred("never-keyed").await.unwrap();
for r in &outcome.results {
assert!(!r.destroyed, "expected absent (not actively destroyed)");
}
assert!(outcome.all_destroyed_or_absent());
assert!(outcome.destroyed_purposes().is_empty());
}
#[tokio::test]
async fn shred_records_all_purposes_in_outcome() {
let (s, kp) = shredder(CryptoShredConfig::default());
kp.rotate_key("acme", KeyPurpose::TenantDataEncryption)
.await
.unwrap();
let outcome = s.shred("acme").await.unwrap();
let purposes: Vec<KeyPurpose> = outcome.results.iter().map(|r| r.purpose).collect();
assert!(purposes.contains(&KeyPurpose::TenantDataEncryption));
assert!(purposes.contains(&KeyPurpose::BackupBlobEncryption));
}
#[tokio::test]
async fn shred_with_empty_tenant_id_rejected() {
let (s, _kp) = shredder(CryptoShredConfig::default());
let err = s.shred("").await.unwrap_err();
assert!(matches!(err, CryptoShredError::InvalidArgument(_)));
}
#[tokio::test]
async fn shred_data_only_when_backup_disabled() {
let cfg = CryptoShredConfig {
data_encryption: true,
backup_encryption: false,
};
let (s, kp) = shredder(cfg);
kp.rotate_key("acme", KeyPurpose::TenantDataEncryption)
.await
.unwrap();
kp.rotate_key("acme", KeyPurpose::BackupBlobEncryption)
.await
.unwrap();
s.shred("acme").await.unwrap();
assert!(kp
.get_key("acme", KeyPurpose::TenantDataEncryption)
.await
.is_err());
assert!(kp
.get_key("acme", KeyPurpose::BackupBlobEncryption)
.await
.is_ok());
}
#[test]
fn purpose_strings_helper() {
let plan = ShredPlan {
tenant_id: "acme".into(),
purposes: vec![
KeyPurpose::TenantDataEncryption,
KeyPurpose::BackupBlobEncryption,
],
};
let strs = plan.purpose_strings();
assert_eq!(
strs,
vec!["tenant_data_encryption", "backup_blob_encryption"]
);
}
#[test]
fn destroyed_purposes_filters_active_destructions() {
let outcome = ShredOutcome {
tenant_id: "acme".into(),
results: vec![
ShredKeyResult {
purpose: KeyPurpose::TenantDataEncryption,
destroyed: true,
},
ShredKeyResult {
purpose: KeyPurpose::BackupBlobEncryption,
destroyed: false,
},
],
};
let destroyed = outcome.destroyed_purposes();
assert_eq!(destroyed, vec![KeyPurpose::TenantDataEncryption]);
}
}