Skip to main content

chronix_engine/storage/
encrypting.rs

1//! Encrypting storage backend — transparent AES-256-GCM encryption layer.
2//!
3//! Wraps any [`StorageBackend`] to provide transparent encryption and
4//! decryption of segment data at rest, using the `EncryptionService`
5//! from `chronix_security::auth`.
6//!
7//! # Envelope Format
8//!
9//! ```text
10//! [4-byte LE key_id_len][key_id bytes][12-byte nonce][AES-256-GCM ciphertext + tag]
11//! ```
12//!
13//! The key ID is embedded in each encrypted segment so that key rotation
14//! is seamless — segments encrypted with an older key can still be
15//! decrypted as long as the key provider retains the retired key.
16//!
17//! # Thread Safety
18//!
19//! `EncryptingBackend<B>` is `Send + Sync` whenever `B` is, making it
20//! usable as a drop-in replacement in async contexts.
21
22use std::sync::Arc;
23
24use tracing::debug;
25
26use chronix_core::{NamespaceId, ShardId};
27
28use crate::storage::backend::{SegmentPath, StorageBackend};
29use crate::storage::error::{Result, StorageError};
30
31/// Transparent encryption wrapper around any [`StorageBackend`].
32///
33/// Encrypts segment data before writing and decrypts after reading,
34/// using AES-256-GCM via [`chronix_security::auth::EncryptionService`].
35#[derive(Debug, Clone)]
36pub struct EncryptingBackend<B> {
37    inner: B,
38    encryption: Arc<chronix_security::auth::EncryptionService>,
39}
40
41impl<B> EncryptingBackend<B> {
42    /// Wrap `inner` backend with transparent encryption.
43    pub fn new(inner: B, encryption: Arc<chronix_security::auth::EncryptionService>) -> Self {
44        Self { inner, encryption }
45    }
46
47    /// Returns a reference to the inner (unencrypted) backend.
48    #[must_use]
49    pub fn inner(&self) -> &B {
50        &self.inner
51    }
52}
53
54/// Envelope: `[4-byte LE key_id_len][key_id][encrypted_data]`
55fn seal(
56    encryption: &chronix_security::auth::EncryptionService,
57    plaintext: &[u8],
58) -> Result<Vec<u8>> {
59    let (ciphertext, key_id) = encryption
60        .encrypt(plaintext)
61        .map_err(|e| StorageError::Io(std::io::Error::other(e.to_string())))?;
62
63    let key_id_bytes = key_id.as_bytes();
64    let key_id_len = u32::try_from(key_id_bytes.len()).map_err(|_| {
65        StorageError::Io(std::io::Error::new(
66            std::io::ErrorKind::InvalidData,
67            "key_id too long",
68        ))
69    })?;
70
71    let mut envelope = Vec::with_capacity(4 + key_id_bytes.len() + ciphertext.len());
72    envelope.extend_from_slice(&key_id_len.to_le_bytes());
73    envelope.extend_from_slice(key_id_bytes);
74    envelope.extend_from_slice(&ciphertext);
75    Ok(envelope)
76}
77
78/// Parse envelope and decrypt.
79fn unseal(
80    encryption: &chronix_security::auth::EncryptionService,
81    envelope: &[u8],
82) -> Result<Vec<u8>> {
83    if envelope.len() < 4 {
84        return Err(StorageError::Io(std::io::Error::new(
85            std::io::ErrorKind::InvalidData,
86            "encrypted envelope too short",
87        )));
88    }
89
90    let key_id_len = u32::from_le_bytes(envelope[..4].try_into().expect("4 bytes")) as usize;
91
92    if envelope.len() < 4 + key_id_len {
93        return Err(StorageError::Io(std::io::Error::new(
94            std::io::ErrorKind::InvalidData,
95            "encrypted envelope truncated at key_id",
96        )));
97    }
98
99    let key_id = std::str::from_utf8(&envelope[4..4 + key_id_len]).map_err(|_| {
100        StorageError::Io(std::io::Error::new(
101            std::io::ErrorKind::InvalidData,
102            "key_id is not valid UTF-8",
103        ))
104    })?;
105
106    let ciphertext = &envelope[4 + key_id_len..];
107
108    encryption.decrypt(ciphertext, key_id).map_err(|e| {
109        StorageError::Io(std::io::Error::new(
110            std::io::ErrorKind::InvalidData,
111            format!("decryption failed: {e}"),
112        ))
113    })
114}
115
116impl<B: StorageBackend> StorageBackend for EncryptingBackend<B> {
117    async fn put_segment(&self, path: &SegmentPath, data: &[u8]) -> Result<()> {
118        let envelope = seal(&self.encryption, data)?;
119        debug!(
120            path = %path,
121            plaintext_bytes = data.len(),
122            encrypted_bytes = envelope.len(),
123            "encrypting segment"
124        );
125        self.inner.put_segment(path, &envelope).await
126    }
127
128    async fn get_segment(&self, path: &SegmentPath) -> Result<Vec<u8>> {
129        let envelope = self.inner.get_segment(path).await?;
130        let plaintext = unseal(&self.encryption, &envelope)?;
131        debug!(
132            path = %path,
133            encrypted_bytes = envelope.len(),
134            plaintext_bytes = plaintext.len(),
135            "decrypting segment"
136        );
137        Ok(plaintext)
138    }
139
140    async fn get_range(&self, path: &SegmentPath, offset: u64, length: usize) -> Result<Vec<u8>> {
141        // Range reads on encrypted data require decrypting the entire segment
142        // because AES-GCM is authenticated — partial reads cannot be verified.
143        // This is the correct trade-off: security > performance for range reads.
144        let plaintext = self.get_segment(path).await?;
145        let end = offset as usize + length;
146        if end > plaintext.len() {
147            return Err(StorageError::Io(std::io::Error::new(
148                std::io::ErrorKind::UnexpectedEof,
149                format!(
150                    "range [{offset}..{end}) exceeds decrypted segment size {}",
151                    plaintext.len()
152                ),
153            )));
154        }
155        Ok(plaintext[offset as usize..end].to_vec())
156    }
157
158    async fn delete_segment(&self, path: &SegmentPath) -> Result<()> {
159        self.inner.delete_segment(path).await
160    }
161
162    async fn list_segments(
163        &self,
164        namespace: &NamespaceId,
165        shard_id: &ShardId,
166    ) -> Result<Vec<SegmentPath>> {
167        self.inner.list_segments(namespace, shard_id).await
168    }
169
170    async fn exists(&self, path: &SegmentPath) -> Result<bool> {
171        self.inner.exists(path).await
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::storage::local::LocalFsBackend;
179    use chronix_security::auth::error::AuthError;
180    use chronix_security::auth::{EncryptionService, KeyProvider, SecretKey};
181
182    /// Simple in-memory key provider for tests.
183    struct TestKeyProvider {
184        key: Vec<u8>,
185        key_id: String,
186    }
187
188    impl TestKeyProvider {
189        fn new() -> Self {
190            Self {
191                key: vec![0xAB; 32], // 256-bit key
192                key_id: "test-key-1".to_string(),
193            }
194        }
195    }
196
197    impl std::fmt::Debug for TestKeyProvider {
198        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199            f.debug_struct("TestKeyProvider")
200                .field("key_id", &self.key_id)
201                .finish()
202        }
203    }
204
205    impl KeyProvider for TestKeyProvider {
206        fn current_key(&self) -> std::result::Result<(SecretKey, String), AuthError> {
207            Ok((SecretKey::new(self.key.clone()), self.key_id.clone()))
208        }
209
210        fn key_by_id(&self, key_id: &str) -> std::result::Result<SecretKey, AuthError> {
211            if key_id == self.key_id {
212                Ok(SecretKey::new(self.key.clone()))
213            } else {
214                Err(AuthError::KeyNotFound(key_id.to_string()))
215            }
216        }
217
218        fn key_ids(&self) -> Vec<String> {
219            vec![self.key_id.clone()]
220        }
221    }
222
223    fn make_backend(dir: &std::path::Path) -> EncryptingBackend<LocalFsBackend> {
224        let local = LocalFsBackend::new(dir);
225        let enc = Arc::new(EncryptionService::new(Box::new(TestKeyProvider::new())));
226        EncryptingBackend::new(local, enc)
227    }
228
229    fn test_path() -> SegmentPath {
230        SegmentPath::new(
231            NamespaceId::default_namespace(),
232            ShardId(1),
233            "test_segment.csx",
234        )
235        .unwrap()
236    }
237
238    #[tokio::test]
239    async fn encrypt_decrypt_round_trip() {
240        let dir = tempfile::tempdir().unwrap();
241        let backend = make_backend(dir.path());
242        let path = test_path();
243
244        let data = b"Hello, encrypted segment!";
245        backend.put_segment(&path, data).await.unwrap();
246
247        // Verify on-disk data is NOT plaintext
248        let raw = LocalFsBackend::new(dir.path())
249            .get_segment(&path)
250            .await
251            .unwrap();
252        assert_ne!(&raw, data, "on-disk data must be encrypted");
253
254        // Verify decrypted data matches original
255        let decrypted = backend.get_segment(&path).await.unwrap();
256        assert_eq!(decrypted, data);
257    }
258
259    #[tokio::test]
260    async fn range_read_after_encryption() {
261        let dir = tempfile::tempdir().unwrap();
262        let backend = make_backend(dir.path());
263        let path = test_path();
264
265        let data = b"0123456789ABCDEF";
266        backend.put_segment(&path, data).await.unwrap();
267
268        let range = backend.get_range(&path, 4, 8).await.unwrap();
269        assert_eq!(&range, &data[4..12]);
270    }
271
272    #[tokio::test]
273    async fn range_read_out_of_bounds() {
274        let dir = tempfile::tempdir().unwrap();
275        let backend = make_backend(dir.path());
276        let path = test_path();
277
278        backend.put_segment(&path, b"short").await.unwrap();
279
280        let err = backend.get_range(&path, 0, 100).await;
281        assert!(err.is_err());
282    }
283
284    #[tokio::test]
285    async fn delete_encrypted_segment() {
286        let dir = tempfile::tempdir().unwrap();
287        let backend = make_backend(dir.path());
288        let path = test_path();
289
290        backend.put_segment(&path, b"delete me").await.unwrap();
291        assert!(backend.exists(&path).await.unwrap());
292
293        backend.delete_segment(&path).await.unwrap();
294        assert!(!backend.exists(&path).await.unwrap());
295    }
296
297    #[tokio::test]
298    async fn list_encrypted_segments() {
299        let dir = tempfile::tempdir().unwrap();
300        let backend = make_backend(dir.path());
301        let path = test_path();
302
303        backend.put_segment(&path, b"data").await.unwrap();
304
305        let segments = backend
306            .list_segments(&NamespaceId::default_namespace(), &ShardId(1))
307            .await
308            .unwrap();
309        assert_eq!(segments.len(), 1);
310        assert_eq!(segments[0].segment_name(), "test_segment.csx");
311    }
312
313    #[tokio::test]
314    async fn tampered_data_fails_decrypt() {
315        let dir = tempfile::tempdir().unwrap();
316        let backend = make_backend(dir.path());
317        let path = test_path();
318
319        backend.put_segment(&path, b"sensitive data").await.unwrap();
320
321        // Tamper with the encrypted data on disk
322        let raw_backend = LocalFsBackend::new(dir.path());
323        let mut raw = raw_backend.get_segment(&path).await.unwrap();
324        if let Some(last) = raw.last_mut() {
325            *last ^= 0xFF; // flip bits in the auth tag
326        }
327        raw_backend.put_segment(&path, &raw).await.unwrap();
328
329        // Decryption must fail due to authentication tag mismatch
330        let result = backend.get_segment(&path).await;
331        assert!(result.is_err(), "tampered segment must fail decryption");
332    }
333
334    #[tokio::test]
335    async fn wrong_key_fails_decrypt() {
336        let dir = tempfile::tempdir().unwrap();
337        let backend = make_backend(dir.path());
338        let path = test_path();
339
340        backend.put_segment(&path, b"secret").await.unwrap();
341
342        // Create a different backend with a different key
343        struct OtherKey;
344        impl std::fmt::Debug for OtherKey {
345            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346                write!(f, "OtherKey")
347            }
348        }
349        impl KeyProvider for OtherKey {
350            fn current_key(&self) -> std::result::Result<(SecretKey, String), AuthError> {
351                Ok((SecretKey::new(vec![0xCD; 32]), "test-key-1".into()))
352            }
353            fn key_by_id(&self, _: &str) -> std::result::Result<SecretKey, AuthError> {
354                Ok(SecretKey::new(vec![0xCD; 32]))
355            }
356            fn key_ids(&self) -> Vec<String> {
357                vec!["test-key-1".into()]
358            }
359        }
360
361        let wrong_backend = EncryptingBackend::new(
362            LocalFsBackend::new(dir.path()),
363            Arc::new(EncryptionService::new(Box::new(OtherKey))),
364        );
365
366        let result = wrong_backend.get_segment(&path).await;
367        assert!(result.is_err(), "wrong key must fail decryption");
368    }
369
370    #[tokio::test]
371    async fn large_segment_round_trip() {
372        let dir = tempfile::tempdir().unwrap();
373        let backend = make_backend(dir.path());
374        let path = test_path();
375
376        // 1 MiB of pseudo-random data
377        let data: Vec<u8> = (0..1_048_576u32).map(|i| (i % 256) as u8).collect();
378        backend.put_segment(&path, &data).await.unwrap();
379
380        let decrypted = backend.get_segment(&path).await.unwrap();
381        assert_eq!(decrypted, data);
382    }
383
384    // ── Additional edge-case tests ────────────────────────────────
385
386    #[tokio::test]
387    async fn exists_nonexistent_encrypted() {
388        let dir = tempfile::tempdir().unwrap();
389        let backend = make_backend(dir.path());
390        let path = test_path();
391        assert!(!backend.exists(&path).await.unwrap());
392    }
393
394    #[tokio::test]
395    async fn empty_segment_round_trip() {
396        let dir = tempfile::tempdir().unwrap();
397        let backend = make_backend(dir.path());
398        let path = test_path();
399
400        backend.put_segment(&path, b"").await.unwrap();
401        let data = backend.get_segment(&path).await.unwrap();
402        assert!(data.is_empty());
403    }
404
405    #[tokio::test]
406    async fn range_read_full_segment() {
407        let dir = tempfile::tempdir().unwrap();
408        let backend = make_backend(dir.path());
409        let path = test_path();
410
411        let original = b"complete data";
412        backend.put_segment(&path, original).await.unwrap();
413
414        let range = backend.get_range(&path, 0, original.len()).await.unwrap();
415        assert_eq!(&range, original);
416    }
417}