common/crypto/
secret.rs

1//! Content encryption using ChaCha20-Poly1305
2//!
3//! This module provides symmetric encryption for bucket data. Each encrypted item
4//! (nodes, files) has its own unique `Secret` key, providing:
5//! - **Content-addressed storage**: Encrypted data can be hashed deterministically
6//! - **Per-item encryption**: Compromising one key doesn't affect other items
7//! - **Efficient key rotation**: Can re-encrypt specific items without touching others
8
9use std::io::Read;
10use std::ops::Deref;
11
12use chacha20poly1305::Key;
13use chacha20poly1305::{
14    aead::{Aead, KeyInit},
15    ChaCha20Poly1305, Nonce,
16};
17use serde::{Deserialize, Serialize};
18
19/// Size of ChaCha20-Poly1305 nonce in bytes
20pub const NONCE_SIZE: usize = 12;
21/// Size of ChaCha20-Poly1305 key in bytes (256 bits)
22pub const SECRET_SIZE: usize = 32;
23/// Size of BLAKE3 hash in bytes (256 bits)
24pub const BLAKE3_HASH_SIZE: usize = 32;
25/// Default chunk size for streaming operations
26#[allow(dead_code)]
27pub const CHUNK_SIZE: usize = 4096;
28
29/// Errors that can occur during encryption/decryption
30#[derive(Debug, thiserror::Error)]
31pub enum SecretError {
32    #[error("secret error: {0}")]
33    Default(#[from] anyhow::Error),
34    #[error("IO error: {0}")]
35    Io(#[from] std::io::Error),
36}
37
38/// A 256-bit symmetric encryption key for content encryption
39///
40/// Each `Secret` is used to encrypt a single item (node or data blob) using ChaCha20-Poly1305 AEAD.
41/// The encrypted format is: `nonce (12 bytes) || encrypted(hash(32 bytes) || plaintext) || tag (16 bytes)`.
42/// The BLAKE3 hash of the plaintext is prepended before encryption to enable content verification
43/// without full decryption (useful for filesystem sync operations).
44///
45/// # Examples
46///
47/// ```ignore
48/// // Generate a new random secret
49/// let secret = Secret::generate();
50///
51/// // Encrypt data
52/// let plaintext = b"sensitive data";
53/// let ciphertext = secret.encrypt(plaintext)?;
54///
55/// // Decrypt data
56/// let recovered = secret.decrypt(&ciphertext)?;
57/// assert_eq!(plaintext, &recovered[..]);
58/// ```
59#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
60pub struct Secret([u8; SECRET_SIZE]);
61
62impl Default for Secret {
63    fn default() -> Self {
64        Secret([0; SECRET_SIZE])
65    }
66}
67
68impl Deref for Secret {
69    type Target = [u8; SECRET_SIZE];
70    fn deref(&self) -> &Self::Target {
71        &self.0
72    }
73}
74
75impl From<[u8; SECRET_SIZE]> for Secret {
76    fn from(bytes: [u8; SECRET_SIZE]) -> Self {
77        Secret(bytes)
78    }
79}
80
81impl Secret {
82    /// Generate a new random secret using a cryptographically secure RNG
83    pub fn generate() -> Self {
84        let mut buff = [0; SECRET_SIZE];
85        getrandom::getrandom(&mut buff).expect("failed to generate random bytes");
86        Self(buff)
87    }
88
89    /// Create a secret from a byte slice
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if the slice length is not exactly `SECRET_SIZE` bytes.
94    pub fn from_slice(data: &[u8]) -> Result<Self, SecretError> {
95        if data.len() != SECRET_SIZE {
96            return Err(anyhow::anyhow!(
97                "invalid secret size, expected {}, got {}",
98                SECRET_SIZE,
99                data.len()
100            )
101            .into());
102        }
103        let mut buff = [0; SECRET_SIZE];
104        buff.copy_from_slice(data);
105        Ok(buff.into())
106    }
107
108    /// Get a reference to the secret key bytes
109    pub fn bytes(&self) -> &[u8] {
110        self.0.as_ref()
111    }
112
113    /// Encrypt data using ChaCha20-Poly1305 AEAD
114    ///
115    /// The output format is: `nonce (12 bytes) || encrypted(hash(32) || plaintext) || auth_tag (16 bytes)`.
116    /// A BLAKE3 hash of the plaintext is computed and prepended to the data before encryption.
117    /// A random nonce is generated for each encryption operation.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if encryption fails (should be rare, only on system RNG failure).
122    pub fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>, SecretError> {
123        // Compute BLAKE3 hash of plaintext
124        let plaintext_hash = blake3::hash(data);
125
126        // Prepend hash to plaintext
127        let mut data_with_hash = Vec::with_capacity(BLAKE3_HASH_SIZE + data.len());
128        data_with_hash.extend_from_slice(plaintext_hash.as_bytes());
129        data_with_hash.extend_from_slice(data);
130
131        let key = Key::from_slice(self.bytes());
132        let cipher = ChaCha20Poly1305::new(key);
133
134        // Generate random nonce
135        let mut nonce_bytes = [0u8; NONCE_SIZE];
136        getrandom::getrandom(&mut nonce_bytes)
137            .map_err(|e| anyhow::anyhow!("failed to generate nonce: {}", e))?;
138        let nonce = Nonce::from_slice(&nonce_bytes);
139
140        let ciphertext = cipher
141            .encrypt(nonce, data_with_hash.as_ref())
142            .map_err(|_| anyhow::anyhow!("encrypt error"))?;
143
144        let mut out = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
145        out.extend_from_slice(nonce.as_ref());
146        out.extend_from_slice(ciphertext.as_ref());
147
148        Ok(out)
149    }
150
151    /// Decrypt data using ChaCha20-Poly1305 AEAD
152    ///
153    /// Expects input in the format: `nonce (12 bytes) || encrypted(hash(32) || plaintext) || auth_tag (16 bytes)`.
154    /// Returns only the plaintext (hash is stripped but verified for integrity).
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if:
159    /// - Data is too short to contain a nonce
160    /// - Authentication tag verification fails (data was tampered with or wrong key)
161    /// - Decrypted data is too short to contain the hash header
162    /// - Hash verification fails (data corruption)
163    pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, SecretError> {
164        if data.len() < NONCE_SIZE {
165            return Err(anyhow::anyhow!("data too short for nonce").into());
166        }
167
168        let key = Key::from_slice(self.bytes());
169        let nonce = Nonce::from_slice(&data[..NONCE_SIZE]);
170        let cipher = ChaCha20Poly1305::new(key);
171        let decrypted = cipher
172            .decrypt(nonce, &data[NONCE_SIZE..])
173            .map_err(|_| anyhow::anyhow!("decrypt error"))?;
174
175        // Extract hash and plaintext
176        if decrypted.len() < BLAKE3_HASH_SIZE {
177            return Err(anyhow::anyhow!("decrypted data too short for hash header").into());
178        }
179
180        let stored_hash = &decrypted[..BLAKE3_HASH_SIZE];
181        let plaintext = &decrypted[BLAKE3_HASH_SIZE..];
182
183        // Verify hash integrity
184        let computed_hash = blake3::hash(plaintext);
185        if stored_hash != computed_hash.as_bytes() {
186            return Err(anyhow::anyhow!("hash verification failed - data corrupted").into());
187        }
188
189        Ok(plaintext.to_vec())
190    }
191
192    /// Extract the BLAKE3 hash of the plaintext without decrypting the full content
193    ///
194    /// This is useful for filesystem sync operations where you only need to compare
195    /// content hashes without loading the entire file into memory.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if:
200    /// - Data is too short to contain a nonce
201    /// - Authentication tag verification fails (data was tampered with or wrong key)
202    /// - Decrypted data is too short to contain the hash header
203    pub fn extract_plaintext_hash(
204        &self,
205        data: &[u8],
206    ) -> Result<[u8; BLAKE3_HASH_SIZE], SecretError> {
207        if data.len() < NONCE_SIZE {
208            return Err(anyhow::anyhow!("data too short for nonce").into());
209        }
210
211        let key = Key::from_slice(self.bytes());
212        let nonce = Nonce::from_slice(&data[..NONCE_SIZE]);
213        let cipher = ChaCha20Poly1305::new(key);
214        let decrypted = cipher
215            .decrypt(nonce, &data[NONCE_SIZE..])
216            .map_err(|_| anyhow::anyhow!("decrypt error"))?;
217
218        // Extract just the hash
219        if decrypted.len() < BLAKE3_HASH_SIZE {
220            return Err(anyhow::anyhow!("decrypted data too short for hash header").into());
221        }
222
223        let mut hash = [0u8; BLAKE3_HASH_SIZE];
224        hash.copy_from_slice(&decrypted[..BLAKE3_HASH_SIZE]);
225
226        Ok(hash)
227    }
228
229    /// Create an encrypted reader from a plaintext reader
230    ///
231    /// This buffers all data in memory, encrypts it, and returns a reader over the encrypted data.
232    /// Future optimization: implement true streaming encryption.
233    pub fn encrypt_reader<R>(&self, reader: R) -> Result<impl Read, SecretError>
234    where
235        R: Read,
236    {
237        let mut data = Vec::new();
238        let mut reader = reader;
239        reader.read_to_end(&mut data).map_err(SecretError::Io)?;
240
241        let encrypted = self.encrypt(&data)?;
242        Ok(std::io::Cursor::new(encrypted))
243    }
244
245    /// Create a decrypted reader from an encrypted reader
246    ///
247    /// This buffers all encrypted data in memory, decrypts it, and returns a reader over the plaintext.
248    /// Future optimization: implement true streaming decryption.
249    pub fn decrypt_reader<R>(&self, reader: R) -> Result<impl Read, SecretError>
250    where
251        R: Read,
252    {
253        let mut encrypted_data = Vec::new();
254        let mut reader = reader;
255        reader
256            .read_to_end(&mut encrypted_data)
257            .map_err(SecretError::Io)?;
258
259        let decrypted = self.decrypt(&encrypted_data)?;
260        Ok(std::io::Cursor::new(decrypted))
261    }
262}
263
264#[cfg(test)]
265mod test {
266    use super::*;
267    use std::io::Cursor;
268
269    #[test]
270    fn test_secret_encrypt_decrypt() {
271        let secret = Secret::generate();
272        let data = b"hello world, this is a test message for encryption";
273
274        let encrypted = secret.encrypt(data).unwrap();
275        let decrypted = secret.decrypt(&encrypted).unwrap();
276
277        assert_eq!(data.as_slice(), decrypted.as_slice());
278    }
279
280    #[test]
281    fn test_encrypt_decrypt_reader() {
282        let secret = Secret::generate();
283        let data = b"hello world, this is a test message for reader encryption and decryption";
284
285        // Create encrypted reader
286        let reader = Cursor::new(data.to_vec());
287        let mut encrypted_reader = secret.encrypt_reader(reader).unwrap();
288
289        // Read encrypted data
290        let mut encrypted_data = Vec::new();
291        encrypted_reader.read_to_end(&mut encrypted_data).unwrap();
292
293        // Decrypt using reader
294        let encrypted_cursor = Cursor::new(encrypted_data);
295        let mut decrypted_reader = secret.decrypt_reader(encrypted_cursor).unwrap();
296
297        let mut decrypted_data = Vec::new();
298        decrypted_reader.read_to_end(&mut decrypted_data).unwrap();
299
300        assert_eq!(data.to_vec(), decrypted_data);
301    }
302
303    #[test]
304    fn test_secret_size_validation() {
305        let too_short = [1u8; 16];
306        let too_long = [1u8; 64];
307
308        assert!(Secret::from_slice(&too_short).is_err());
309        assert!(Secret::from_slice(&too_long).is_err());
310
311        let just_right = [1u8; SECRET_SIZE];
312        assert!(Secret::from_slice(&just_right).is_ok());
313    }
314
315    #[test]
316    fn test_extract_plaintext_hash() {
317        let secret = Secret::generate();
318        let data = b"test data for hash extraction";
319
320        // Encrypt the data
321        let encrypted = secret.encrypt(data).unwrap();
322
323        // Extract the hash without full decryption
324        let extracted_hash = secret.extract_plaintext_hash(&encrypted).unwrap();
325
326        // Compute expected hash
327        let expected_hash = blake3::hash(data);
328
329        assert_eq!(extracted_hash, *expected_hash.as_bytes());
330    }
331
332    #[test]
333    fn test_hash_verification_on_decrypt() {
334        let secret = Secret::generate();
335        let data = b"test data for integrity check";
336
337        // Encrypt the data
338        let mut encrypted = secret.encrypt(data).unwrap();
339
340        // Decrypt should succeed with valid data
341        let decrypted = secret.decrypt(&encrypted).unwrap();
342        assert_eq!(decrypted, data.to_vec());
343
344        // Corrupt the encrypted data (modify a byte in the ciphertext region)
345        // Note: This should fail authentication, not hash verification
346        if encrypted.len() > NONCE_SIZE + 16 {
347            encrypted[NONCE_SIZE + 10] ^= 0xFF;
348
349            // This should fail during ChaCha20-Poly1305 authentication
350            let result = secret.decrypt(&encrypted);
351            assert!(result.is_err());
352        }
353    }
354
355    #[test]
356    fn test_empty_data_encryption() {
357        let secret = Secret::generate();
358        let data = b"";
359
360        let encrypted = secret.encrypt(data).unwrap();
361        let decrypted = secret.decrypt(&encrypted).unwrap();
362
363        assert_eq!(decrypted, data.to_vec());
364
365        // Hash should still be extractable
366        let hash = secret.extract_plaintext_hash(&encrypted).unwrap();
367        let expected_hash = blake3::hash(data);
368        assert_eq!(hash, *expected_hash.as_bytes());
369    }
370}