coven/
encryption.rs

1use chacha20poly1305::aead::generic_array::GenericArray;
2use chacha20poly1305::{aead::Aead, KeyInit, XChaCha20Poly1305};
3use hkdf::Hkdf;
4use hmac::{Hmac, Mac};
5use rand::RngCore;
6use sha2::{Digest, Sha256};
7use thiserror::Error;
8use tracing::info;
9
10/// XChaCha20-Poly1305 nonce size (24 bytes).
11pub const NONCE_SIZE: usize = 24;
12
13/// Poly1305 auth tag size (16 bytes).
14pub const TAG_SIZE: usize = 16;
15
16/// 64KB plaintext chunks
17pub const CHUNK_SIZE: usize = 65536;
18/// Each encrypted chunk: plaintext + 16-byte auth tag
19pub const ENCRYPTED_CHUNK_SIZE: usize = CHUNK_SIZE + TAG_SIZE;
20
21/// Generate a random 32-byte key.
22pub fn generate_random_key() -> [u8; 32] {
23    let mut key = [0u8; 32];
24    rand::rng().fill_bytes(&mut key);
25    key
26}
27
28#[derive(Error, Debug)]
29pub enum EncryptionError {
30    #[error("Encryption failed: {0}")]
31    Encryption(String),
32    #[error("Decryption failed: {0}")]
33    Decryption(String),
34    #[error("Key management error: {0}")]
35    KeyManagement(String),
36    #[error("IO error: {0}")]
37    Io(#[from] std::io::Error),
38}
39/// Manages encryption keys and provides XChaCha20-Poly1305 encryption/decryption
40///
41/// This implements the security model described in the README:
42/// - Files are encrypted using XChaCha20-Poly1305 for authenticated encryption
43/// - Chunked format enables random-access decryption for efficient range reads
44#[derive(Clone)]
45pub struct EncryptionService {
46    key: [u8; 32],
47}
48impl std::fmt::Debug for EncryptionService {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("EncryptionService")
51            .field("cipher", &"<initialized>")
52            .finish()
53    }
54}
55impl EncryptionService {
56    /// Create a new encryption service from a hex-encoded key string
57    pub fn new(key_hex: &str) -> Result<Self, EncryptionError> {
58        info!("Loading master key...");
59        let key_bytes = hex::decode(key_hex)
60            .map_err(|e| EncryptionError::KeyManagement(format!("Invalid key format: {}", e)))?;
61        if key_bytes.len() != 32 {
62            return Err(EncryptionError::KeyManagement(
63                "Invalid key length, expected 32 bytes".to_string(),
64            ));
65        }
66        let key: [u8; 32] = key_bytes.try_into().map_err(|_| {
67            EncryptionError::KeyManagement("Failed to convert key bytes to array".to_string())
68        })?;
69        Ok(EncryptionService { key })
70    }
71
72    /// Create a new encryption service from a raw 32-byte key.
73    pub fn from_key(key: [u8; 32]) -> Self {
74        EncryptionService { key }
75    }
76
77    /// SHA-256 fingerprint of the key, first 8 bytes hex-encoded (16 hex chars).
78    /// Short enough to display in UI, long enough to detect wrong keys.
79    pub fn fingerprint(&self) -> String {
80        let hash = Sha256::digest(self.key);
81        hex::encode(&hash[..8])
82    }
83
84    /// Create an encryption service with a raw key (for testing)
85    #[cfg(any(test, feature = "test-utils"))]
86    pub fn new_with_key(key_bytes: &[u8]) -> Self {
87        if key_bytes.len() != 32 {
88            panic!("Invalid key length, expected 32 bytes");
89        }
90        let key: [u8; 32] = key_bytes.try_into().unwrap();
91        EncryptionService { key }
92    }
93
94    /// Return the raw 32-byte key.
95    pub fn key_bytes(&self) -> [u8; 32] {
96        self.key
97    }
98
99    /// Encrypt data using chunked XChaCha20-Poly1305 format.
100    /// Returns: [base_nonce: 24 bytes][ciphertext with auth tags]
101    /// For small data (single chunk), this is equivalent to standard AEAD.
102    /// For large data, each chunk is independently encrypted for random-access.
103    pub fn encrypt(&self, plaintext: &[u8]) -> Vec<u8> {
104        self.encrypt_chunked(plaintext)
105    }
106
107    /// Decrypt data in chunked format: [nonce (24 bytes)][ciphertext chunks...]
108    pub fn decrypt(&self, encrypted_data: &[u8]) -> Result<Vec<u8>, EncryptionError> {
109        self.decrypt_chunked(encrypted_data)
110    }
111
112    /// Encrypt data using chunked XChaCha20-Poly1305 format.
113    /// Returns: `[base_nonce: 24 bytes][chunk_0][chunk_1]...`
114    /// Each chunk is independently encrypted, enabling random-access decryption.
115    pub fn encrypt_chunked(&self, plaintext: &[u8]) -> Vec<u8> {
116        let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&self.key));
117
118        // Generate random base nonce
119        let mut base_nonce = [0u8; NONCE_SIZE];
120        rand::rng().fill_bytes(&mut base_nonce);
121
122        let mut output = base_nonce.to_vec();
123
124        // Handle empty plaintext - still produce one chunk with just auth tag
125        if plaintext.is_empty() {
126            let nonce = chunk_nonce(&base_nonce, 0);
127            let nonce_arr = GenericArray::from_slice(&nonce);
128            let ct = cipher
129                .encrypt(nonce_arr, &[][..])
130                .expect("encryption should not fail");
131            output.extend(ct);
132            return output;
133        }
134
135        for (i, chunk) in plaintext.chunks(CHUNK_SIZE).enumerate() {
136            let nonce = chunk_nonce(&base_nonce, i as u64);
137            let nonce_arr = GenericArray::from_slice(&nonce);
138            let ct = cipher
139                .encrypt(nonce_arr, chunk)
140                .expect("encryption should not fail");
141            output.extend(ct);
142        }
143
144        output
145    }
146
147    /// Decrypt a specific chunk from chunked encrypted data.
148    /// Enables random-access decryption without reading preceding chunks.
149    pub fn decrypt_chunk(
150        &self,
151        ciphertext: &[u8],
152        chunk_index: usize,
153    ) -> Result<Vec<u8>, EncryptionError> {
154        if ciphertext.len() < NONCE_SIZE {
155            return Err(EncryptionError::Decryption(
156                "Ciphertext too short for nonce".to_string(),
157            ));
158        }
159
160        let base_nonce: [u8; NONCE_SIZE] = ciphertext[..NONCE_SIZE]
161            .try_into()
162            .map_err(|_| EncryptionError::Decryption("Invalid nonce".to_string()))?;
163
164        let data_start = NONCE_SIZE;
165        let total_data_len = ciphertext.len() - data_start;
166
167        // Calculate chunk boundaries
168        let num_full_chunks = total_data_len / ENCRYPTED_CHUNK_SIZE;
169        let has_partial = !total_data_len.is_multiple_of(ENCRYPTED_CHUNK_SIZE);
170        let total_chunks = num_full_chunks + if has_partial { 1 } else { 0 };
171
172        if chunk_index >= total_chunks {
173            return Err(EncryptionError::Decryption(format!(
174                "Chunk index {} out of range (total chunks: {})",
175                chunk_index, total_chunks
176            )));
177        }
178
179        let chunk_start = data_start + chunk_index * ENCRYPTED_CHUNK_SIZE;
180        let chunk_end = if chunk_index == total_chunks - 1 && has_partial {
181            ciphertext.len()
182        } else {
183            chunk_start + ENCRYPTED_CHUNK_SIZE
184        };
185
186        let chunk_data = &ciphertext[chunk_start..chunk_end];
187        let nonce = chunk_nonce(&base_nonce, chunk_index as u64);
188        let nonce_arr = GenericArray::from_slice(&nonce);
189
190        let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&self.key));
191        cipher
192            .decrypt(nonce_arr, chunk_data)
193            .map_err(|_| EncryptionError::Decryption("Authentication failed".to_string()))
194    }
195
196    /// Decrypt all chunks from chunked encrypted data.
197    pub fn decrypt_chunked(&self, ciphertext: &[u8]) -> Result<Vec<u8>, EncryptionError> {
198        if ciphertext.len() < NONCE_SIZE {
199            return Err(EncryptionError::Decryption(
200                "Ciphertext too short for nonce".to_string(),
201            ));
202        }
203
204        let data_start = NONCE_SIZE;
205        let total_data_len = ciphertext.len() - data_start;
206
207        let num_full_chunks = total_data_len / ENCRYPTED_CHUNK_SIZE;
208        let has_partial = !total_data_len.is_multiple_of(ENCRYPTED_CHUNK_SIZE);
209        let total_chunks = num_full_chunks + if has_partial { 1 } else { 0 };
210
211        let mut result = Vec::new();
212        for i in 0..total_chunks {
213            let chunk = self.decrypt_chunk(ciphertext, i)?;
214            result.extend(chunk);
215        }
216
217        Ok(result)
218    }
219
220    /// Decrypt a specific plaintext byte range from encrypted data.
221    ///
222    /// The ciphertext must start with the nonce (first 24 bytes) but may be
223    /// truncated after the chunks needed for the requested range.
224    ///
225    /// Returns exactly the plaintext bytes from `plaintext_start` to `plaintext_end`.
226    pub fn decrypt_range(
227        &self,
228        ciphertext: &[u8],
229        plaintext_start: u64,
230        plaintext_end: u64,
231    ) -> Result<Vec<u8>, EncryptionError> {
232        if plaintext_start >= plaintext_end {
233            return Err(EncryptionError::Decryption(format!(
234                "Invalid range: start ({}) >= end ({})",
235                plaintext_start, plaintext_end
236            )));
237        }
238
239        let start_chunk = plaintext_start / CHUNK_SIZE as u64;
240        let end_chunk = (plaintext_end.saturating_sub(1)) / CHUNK_SIZE as u64;
241
242        let mut plaintext = Vec::new();
243        for chunk_idx in start_chunk..=end_chunk {
244            let chunk = self.decrypt_chunk(ciphertext, chunk_idx as usize)?;
245            plaintext.extend(chunk);
246        }
247
248        // Slice to exact range within the decrypted chunks
249        let offset_in_first_chunk = (plaintext_start % CHUNK_SIZE as u64) as usize;
250        let len = (plaintext_end - plaintext_start) as usize;
251        let end = offset_in_first_chunk + len;
252
253        if end > plaintext.len() {
254            return Err(EncryptionError::Decryption(format!(
255                "Decrypted data too short: need {} bytes, got {}",
256                end,
257                plaintext.len()
258            )));
259        }
260
261        Ok(plaintext[offset_in_first_chunk..end].to_vec())
262    }
263
264    /// Decrypt a plaintext byte range using nonce from DB and partial chunk data.
265    ///
266    /// This is the efficient method for encrypted range requests:
267    /// - `nonce`: 24-byte nonce stored in DB at import time
268    /// - `encrypted_chunks`: Raw encrypted chunk bytes (NO nonce prefix)
269    /// - `first_chunk_index`: Which chunk index the encrypted_chunks starts at
270    /// - `plaintext_start`, `plaintext_end`: Absolute byte positions in original file
271    ///
272    /// Example: To read plaintext bytes 500,000-600,000:
273    /// 1. Calculate needed chunks: `encrypted_chunk_range(500000, 600000)` -> chunks 7-9
274    /// 2. Fetch encrypted bytes from cloud at those positions
275    /// 3. Call `decrypt_range_with_offset(nonce, chunks, 7, 500000, 600000)`
276    pub fn decrypt_range_with_offset(
277        &self,
278        nonce: &[u8],
279        encrypted_chunks: &[u8],
280        first_chunk_index: u64,
281        plaintext_start: u64,
282        plaintext_end: u64,
283    ) -> Result<Vec<u8>, EncryptionError> {
284        if nonce.len() != NONCE_SIZE {
285            return Err(EncryptionError::Decryption(format!(
286                "Invalid nonce length: expected {}, got {}",
287                NONCE_SIZE,
288                nonce.len()
289            )));
290        }
291
292        if plaintext_start >= plaintext_end {
293            return Err(EncryptionError::Decryption(format!(
294                "Invalid range: start ({}) >= end ({})",
295                plaintext_start, plaintext_end
296            )));
297        }
298
299        let base_nonce: [u8; NONCE_SIZE] = nonce
300            .try_into()
301            .map_err(|_| EncryptionError::Decryption("Invalid nonce".to_string()))?;
302
303        let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&self.key));
304
305        let start_chunk = plaintext_start / CHUNK_SIZE as u64;
306        let end_chunk = (plaintext_end.saturating_sub(1)) / CHUNK_SIZE as u64;
307
308        let mut plaintext = Vec::new();
309
310        for absolute_chunk_idx in start_chunk..=end_chunk {
311            // Convert absolute chunk index to position in encrypted_chunks
312            let relative_idx = absolute_chunk_idx - first_chunk_index;
313            let chunk_start = (relative_idx as usize) * ENCRYPTED_CHUNK_SIZE;
314
315            // Handle last chunk which may be smaller
316            let chunk_end = if chunk_start + ENCRYPTED_CHUNK_SIZE > encrypted_chunks.len() {
317                encrypted_chunks.len()
318            } else {
319                chunk_start + ENCRYPTED_CHUNK_SIZE
320            };
321
322            if chunk_start >= encrypted_chunks.len() {
323                return Err(EncryptionError::Decryption(format!(
324                    "Chunk {} not in provided data (first_chunk_index={})",
325                    absolute_chunk_idx, first_chunk_index
326                )));
327            }
328
329            let chunk_data = &encrypted_chunks[chunk_start..chunk_end];
330            let nonce = chunk_nonce(&base_nonce, absolute_chunk_idx);
331            let nonce_arr = GenericArray::from_slice(&nonce);
332
333            let decrypted = cipher.decrypt(nonce_arr, chunk_data).map_err(|_| {
334                EncryptionError::Decryption(format!(
335                    "Authentication failed for chunk {}",
336                    absolute_chunk_idx
337                ))
338            })?;
339
340            plaintext.extend(decrypted);
341        }
342
343        // Slice to exact range within the decrypted chunks
344        let offset_in_first_chunk = (plaintext_start % CHUNK_SIZE as u64) as usize;
345        let len = (plaintext_end - plaintext_start) as usize;
346        let end = offset_in_first_chunk + len;
347
348        if end > plaintext.len() {
349            return Err(EncryptionError::Decryption(format!(
350                "Decrypted data too short: need {} bytes, got {}",
351                end,
352                plaintext.len()
353            )));
354        }
355
356        Ok(plaintext[offset_in_first_chunk..end].to_vec())
357    }
358
359    /// Derive a per-release encryption service.
360    ///
361    /// Uses HKDF: master_key + "bae-release-v1:{release_id}" -> 32-byte key.
362    /// Deterministic: same master + release_id always gives the same key.
363    pub fn derive_scoped(&self, release_id: &str) -> EncryptionService {
364        let derived = self.derive_key(&format!("bae-release-v1:{release_id}"));
365        EncryptionService::from_key(derived)
366    }
367
368    /// Derive a 32-byte key using HKDF-SHA256 with the given info label.
369    ///
370    /// The derivation is deterministic: same master key + same info string always
371    /// produces the same derived key.
372    ///
373    /// - Salt: `HMAC-SHA256(master_key, "bae-hkdf-salt-v1")`
374    /// - IKM: master key
375    /// - Info: caller-provided label
376    pub fn derive_key(&self, info: &str) -> [u8; 32] {
377        let mut mac =
378            <Hmac<Sha256> as Mac>::new_from_slice(&self.key).expect("HMAC accepts any key length");
379        mac.update(b"bae-hkdf-salt-v1");
380        let salt = mac.finalize().into_bytes();
381
382        let hk = Hkdf::<Sha256>::new(Some(&salt), &self.key);
383        let mut okm = [0u8; 32];
384        hk.expand(info.as_bytes(), &mut okm)
385            .expect("32 bytes is a valid HKDF output length");
386        okm
387    }
388}
389
390/// Derive nonce for chunk i: base_nonce XOR i (little-endian)
391fn chunk_nonce(base_nonce: &[u8; NONCE_SIZE], chunk_index: u64) -> [u8; NONCE_SIZE] {
392    let mut nonce = *base_nonce;
393    let index_bytes = chunk_index.to_le_bytes();
394    for i in 0..8 {
395        nonce[i] ^= index_bytes[i];
396    }
397    nonce
398}
399
400/// Calculate the encrypted byte range for a plaintext byte range.
401///
402/// Returns `(chunk_start, chunk_end)` - the byte positions in the encrypted file
403/// where the needed chunks are located. Does NOT include the nonce (first 24 bytes).
404///
405/// Use this for efficient range requests: fetch nonce separately (or from DB),
406/// then fetch just `chunk_start..chunk_end` from storage.
407pub fn encrypted_chunk_range(plaintext_start: u64, plaintext_end: u64) -> (u64, u64) {
408    let start_chunk = plaintext_start / CHUNK_SIZE as u64;
409    let end_chunk = (plaintext_end.saturating_sub(1)) / CHUNK_SIZE as u64;
410
411    let chunk_start = NONCE_SIZE as u64 + start_chunk * ENCRYPTED_CHUNK_SIZE as u64;
412    let chunk_end = NONCE_SIZE as u64 + (end_chunk + 1) * ENCRYPTED_CHUNK_SIZE as u64;
413
414    (chunk_start, chunk_end)
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    /// Calculate the encrypted byte range needed for a plaintext byte range.
422    /// Returns (encrypted_start, encrypted_end) including the nonce header.
423    fn encrypted_range_for_plaintext(start: u64, end: u64) -> (u64, u64) {
424        let start_chunk = start / CHUNK_SIZE as u64;
425        let end_chunk = (end.saturating_sub(1)) / CHUNK_SIZE as u64;
426
427        let enc_start = NONCE_SIZE as u64 + start_chunk * ENCRYPTED_CHUNK_SIZE as u64;
428        let enc_end = NONCE_SIZE as u64 + (end_chunk + 1) * ENCRYPTED_CHUNK_SIZE as u64;
429
430        // Always need the nonce header
431        (0, enc_end.max(enc_start))
432    }
433
434    fn test_key() -> [u8; 32] {
435        // Fixed test key for reproducibility
436        [
437            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
438            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
439            0x1c, 0x1d, 0x1e, 0x1f,
440        ]
441    }
442
443    fn create_test_service() -> EncryptionService {
444        EncryptionService::new_with_key(&test_key())
445    }
446
447    #[test]
448    fn test_roundtrip_small() {
449        let service = create_test_service();
450        let plaintext = b"Hello, world!";
451
452        let ciphertext = service.encrypt(plaintext);
453        let decrypted = service.decrypt(&ciphertext).unwrap();
454
455        assert_eq!(decrypted, plaintext);
456    }
457
458    #[test]
459    fn test_roundtrip_exact_chunk() {
460        let service = create_test_service();
461        let plaintext = vec![0x42u8; CHUNK_SIZE];
462
463        let ciphertext = service.encrypt(&plaintext);
464        let decrypted = service.decrypt(&ciphertext).unwrap();
465
466        assert_eq!(decrypted, plaintext);
467    }
468
469    #[test]
470    fn test_roundtrip_multiple_chunks() {
471        let service = create_test_service();
472        // 2.5 chunks worth of data
473        let plaintext: Vec<u8> = (0..CHUNK_SIZE * 2 + CHUNK_SIZE / 2)
474            .map(|i| (i % 256) as u8)
475            .collect();
476
477        let ciphertext = service.encrypt(&plaintext);
478        let decrypted = service.decrypt(&ciphertext).unwrap();
479
480        assert_eq!(decrypted, plaintext);
481    }
482
483    #[test]
484    fn test_random_access_chunk() {
485        let service = create_test_service();
486        // 3 chunks: chunk 0 = 0x00, chunk 1 = 0x11, chunk 2 = 0x22
487        let mut plaintext = vec![0x00u8; CHUNK_SIZE];
488        plaintext.extend(vec![0x11u8; CHUNK_SIZE]);
489        plaintext.extend(vec![0x22u8; CHUNK_SIZE]);
490
491        let ciphertext = service.encrypt(&plaintext);
492
493        // Decrypt only chunk 1 (middle chunk)
494        let chunk1 = service.decrypt_chunk(&ciphertext, 1).unwrap();
495        assert_eq!(chunk1, vec![0x11u8; CHUNK_SIZE]);
496
497        // Decrypt chunk 0
498        let chunk0 = service.decrypt_chunk(&ciphertext, 0).unwrap();
499        assert_eq!(chunk0, vec![0x00u8; CHUNK_SIZE]);
500
501        // Decrypt chunk 2
502        let chunk2 = service.decrypt_chunk(&ciphertext, 2).unwrap();
503        assert_eq!(chunk2, vec![0x22u8; CHUNK_SIZE]);
504    }
505
506    #[test]
507    fn test_random_access_partial_last_chunk() {
508        let service = create_test_service();
509        // 1 full chunk + partial chunk
510        let mut plaintext = vec![0xAAu8; CHUNK_SIZE];
511        plaintext.extend(vec![0xBBu8; 100]);
512
513        let ciphertext = service.encrypt(&plaintext);
514
515        let chunk0 = service.decrypt_chunk(&ciphertext, 0).unwrap();
516        assert_eq!(chunk0, vec![0xAAu8; CHUNK_SIZE]);
517
518        let chunk1 = service.decrypt_chunk(&ciphertext, 1).unwrap();
519        assert_eq!(chunk1, vec![0xBBu8; 100]);
520    }
521
522    #[test]
523    fn test_tamper_detection() {
524        let service = create_test_service();
525        let plaintext = b"Secret data";
526
527        let mut ciphertext = service.encrypt(plaintext);
528
529        // Tamper with the ciphertext (after nonce)
530        let tamper_pos = NONCE_SIZE + 5;
531        ciphertext[tamper_pos] ^= 0xFF;
532
533        let result = service.decrypt(&ciphertext);
534        assert!(result.is_err());
535    }
536
537    #[test]
538    fn test_empty_plaintext() {
539        let service = create_test_service();
540        let plaintext = b"";
541
542        let ciphertext = service.encrypt(plaintext);
543
544        // Should just be nonce + auth tag
545        assert_eq!(ciphertext.len(), NONCE_SIZE + TAG_SIZE);
546
547        let decrypted = service.decrypt(&ciphertext).unwrap();
548        assert_eq!(decrypted, plaintext);
549    }
550
551    #[test]
552    fn test_single_byte() {
553        let service = create_test_service();
554        let plaintext = b"x";
555
556        let ciphertext = service.encrypt(plaintext);
557        let decrypted = service.decrypt(&ciphertext).unwrap();
558
559        assert_eq!(decrypted, plaintext);
560    }
561
562    #[test]
563    fn test_encrypted_range_single_chunk() {
564        // Plaintext bytes 0-100 are in chunk 0
565        let (start, end) = encrypted_range_for_plaintext(0, 100);
566
567        assert_eq!(start, 0); // Always need nonce
568        assert_eq!(end, NONCE_SIZE as u64 + ENCRYPTED_CHUNK_SIZE as u64);
569    }
570
571    #[test]
572    fn test_encrypted_range_spans_chunks() {
573        // Plaintext bytes spanning chunk 0 and chunk 1
574        let (start, end) =
575            encrypted_range_for_plaintext(CHUNK_SIZE as u64 - 10, CHUNK_SIZE as u64 + 10);
576
577        assert_eq!(start, 0); // Always need nonce
578        assert_eq!(end, NONCE_SIZE as u64 + 2 * ENCRYPTED_CHUNK_SIZE as u64);
579    }
580
581    #[test]
582    fn test_encrypted_range_middle_chunk() {
583        // Plaintext bytes entirely within chunk 2
584        let chunk2_start = CHUNK_SIZE as u64 * 2;
585        let (start, end) = encrypted_range_for_plaintext(chunk2_start + 10, chunk2_start + 100);
586
587        assert_eq!(start, 0); // Always need nonce
588        assert_eq!(end, NONCE_SIZE as u64 + 3 * ENCRYPTED_CHUNK_SIZE as u64);
589    }
590
591    #[test]
592    fn test_different_encryptions_different_ciphertext() {
593        let service = create_test_service();
594        let plaintext = b"Same message";
595
596        let ciphertext1 = service.encrypt(plaintext);
597        let ciphertext2 = service.encrypt(plaintext);
598
599        // Different nonces = different ciphertext
600        assert_ne!(ciphertext1, ciphertext2);
601
602        // Both decrypt to same plaintext
603        assert_eq!(service.decrypt(&ciphertext1).unwrap(), plaintext);
604        assert_eq!(service.decrypt(&ciphertext2).unwrap(), plaintext);
605    }
606
607    #[test]
608    fn test_chunk_index_out_of_range() {
609        let service = create_test_service();
610        let plaintext = vec![0u8; CHUNK_SIZE]; // Exactly 1 chunk
611
612        let ciphertext = service.encrypt(&plaintext);
613
614        // Chunk 0 should work
615        assert!(service.decrypt_chunk(&ciphertext, 0).is_ok());
616
617        // Chunk 1 should fail
618        assert!(service.decrypt_chunk(&ciphertext, 1).is_err());
619    }
620
621    #[test]
622    fn test_decrypt_range_within_single_chunk() {
623        let service = create_test_service();
624        // Create plaintext with recognizable pattern
625        let plaintext: Vec<u8> = (0..CHUNK_SIZE).map(|i| (i % 256) as u8).collect();
626
627        let ciphertext = service.encrypt(&plaintext);
628
629        // Decrypt range [100, 200) within first chunk
630        let decrypted = service.decrypt_range(&ciphertext, 100, 200).unwrap();
631
632        assert_eq!(decrypted.len(), 100);
633        assert_eq!(decrypted, plaintext[100..200]);
634    }
635
636    #[test]
637    fn test_decrypt_range_spanning_chunks() {
638        let service = create_test_service();
639        // 3 chunks of data
640        let plaintext: Vec<u8> = (0..CHUNK_SIZE * 3).map(|i| (i % 256) as u8).collect();
641
642        let ciphertext = service.encrypt(&plaintext);
643
644        // Range spanning from end of chunk 0 into chunk 1
645        let start = CHUNK_SIZE as u64 - 100;
646        let end = CHUNK_SIZE as u64 + 100;
647        let decrypted = service.decrypt_range(&ciphertext, start, end).unwrap();
648
649        assert_eq!(decrypted.len(), 200);
650        assert_eq!(decrypted, &plaintext[start as usize..end as usize]);
651    }
652
653    #[test]
654    fn test_decrypt_range_entire_middle_chunk() {
655        let service = create_test_service();
656        // 3 chunks, middle chunk filled with 0xBB
657        let mut plaintext = vec![0xAAu8; CHUNK_SIZE];
658        plaintext.extend(vec![0xBBu8; CHUNK_SIZE]);
659        plaintext.extend(vec![0xCCu8; CHUNK_SIZE]);
660
661        let ciphertext = service.encrypt(&plaintext);
662
663        // Decrypt just the middle chunk
664        let start = CHUNK_SIZE as u64;
665        let end = (CHUNK_SIZE * 2) as u64;
666        let decrypted = service.decrypt_range(&ciphertext, start, end).unwrap();
667
668        assert_eq!(decrypted, vec![0xBBu8; CHUNK_SIZE]);
669    }
670
671    #[test]
672    fn test_decrypt_range_with_partial_encrypted_data() {
673        let service = create_test_service();
674        // Create 3-chunk plaintext
675        let plaintext: Vec<u8> = (0..CHUNK_SIZE * 3).map(|i| (i % 256) as u8).collect();
676        let full_ciphertext = service.encrypt(&plaintext);
677
678        // Calculate encrypted range for plaintext bytes in chunk 1
679        let plaintext_start = CHUNK_SIZE as u64 + 100;
680        let plaintext_end = CHUNK_SIZE as u64 + 200;
681        let (enc_start, enc_end) = encrypted_range_for_plaintext(plaintext_start, plaintext_end);
682
683        // Fetch only the needed encrypted bytes (simulating range read)
684        let partial_ciphertext = full_ciphertext[enc_start as usize..enc_end as usize].to_vec();
685
686        // Decrypt range from partial data
687        let decrypted = service
688            .decrypt_range(&partial_ciphertext, plaintext_start, plaintext_end)
689            .unwrap();
690
691        assert_eq!(decrypted.len(), 100);
692        assert_eq!(
693            decrypted,
694            &plaintext[plaintext_start as usize..plaintext_end as usize]
695        );
696    }
697
698    #[test]
699    fn test_encrypted_chunk_range_returns_actual_bounds() {
700        // For plaintext in chunk 5, should return just chunk 5's encrypted bytes
701        // NOT starting from 0
702        let chunk5_start = CHUNK_SIZE as u64 * 5;
703        let chunk5_end = chunk5_start + 1000;
704
705        let (enc_start, enc_end) = encrypted_chunk_range(chunk5_start, chunk5_end);
706
707        // Should start at chunk 5's position, not 0
708        let expected_start = NONCE_SIZE as u64 + 5 * ENCRYPTED_CHUNK_SIZE as u64;
709        let expected_end = NONCE_SIZE as u64 + 6 * ENCRYPTED_CHUNK_SIZE as u64;
710
711        assert_eq!(
712            enc_start, expected_start,
713            "encrypted_chunk_range should return actual chunk start, not 0"
714        );
715        assert_eq!(enc_end, expected_end);
716    }
717
718    #[test]
719    fn test_encrypted_chunk_range_spanning_multiple_chunks() {
720        // Range spanning chunks 3-5
721        let start = CHUNK_SIZE as u64 * 3 + 100;
722        let end = CHUNK_SIZE as u64 * 5 + 500;
723
724        let (enc_start, enc_end) = encrypted_chunk_range(start, end);
725
726        let expected_start = NONCE_SIZE as u64 + 3 * ENCRYPTED_CHUNK_SIZE as u64;
727        let expected_end = NONCE_SIZE as u64 + 6 * ENCRYPTED_CHUNK_SIZE as u64;
728
729        assert_eq!(enc_start, expected_start);
730        assert_eq!(enc_end, expected_end);
731    }
732
733    #[test]
734    fn test_decrypt_range_with_separate_nonce() {
735        // This simulates production flow: nonce from DB + chunks from range request
736        let service = create_test_service();
737
738        // Create 10-chunk plaintext with recognizable pattern
739        let plaintext: Vec<u8> = (0..CHUNK_SIZE * 10).map(|i| (i % 256) as u8).collect();
740        let full_ciphertext = service.encrypt(&plaintext);
741
742        // Extract nonce (this would come from DB in production)
743        let nonce = &full_ciphertext[..NONCE_SIZE];
744
745        // We want plaintext bytes in chunk 7
746        let plaintext_start = CHUNK_SIZE as u64 * 7 + 100;
747        let plaintext_end = CHUNK_SIZE as u64 * 7 + 500;
748
749        // Get the encrypted chunk range (NOT starting from 0)
750        let (chunk_start, chunk_end) = encrypted_chunk_range(plaintext_start, plaintext_end);
751
752        // Fetch just the needed chunks (simulating range request)
753        let chunks_only = &full_ciphertext[chunk_start as usize..chunk_end as usize];
754
755        // First chunk index is 7 (the chunk our range starts in)
756        let first_chunk_index = plaintext_start / CHUNK_SIZE as u64;
757
758        // Use the new method that handles offset chunks
759        let decrypted = service
760            .decrypt_range_with_offset(
761                nonce,
762                chunks_only,
763                first_chunk_index,
764                plaintext_start,
765                plaintext_end,
766            )
767            .unwrap();
768
769        assert_eq!(decrypted.len(), 400);
770        assert_eq!(
771            decrypted,
772            &plaintext[plaintext_start as usize..plaintext_end as usize]
773        );
774    }
775
776    #[test]
777    fn test_decrypt_range_with_offset_spanning_chunks() {
778        // Test decrypting a range that spans multiple chunks
779        let service = create_test_service();
780
781        let plaintext: Vec<u8> = (0..CHUNK_SIZE * 10).map(|i| (i % 256) as u8).collect();
782        let full_ciphertext = service.encrypt(&plaintext);
783        let nonce = &full_ciphertext[..NONCE_SIZE];
784
785        // Range spanning chunks 3, 4, 5
786        let plaintext_start = CHUNK_SIZE as u64 * 3 + 1000;
787        let plaintext_end = CHUNK_SIZE as u64 * 5 + 2000;
788
789        let (chunk_start, chunk_end) = encrypted_chunk_range(plaintext_start, plaintext_end);
790        let chunks_only = &full_ciphertext[chunk_start as usize..chunk_end as usize];
791        let first_chunk_index = plaintext_start / CHUNK_SIZE as u64;
792
793        let decrypted = service
794            .decrypt_range_with_offset(
795                nonce,
796                chunks_only,
797                first_chunk_index,
798                plaintext_start,
799                plaintext_end,
800            )
801            .unwrap();
802
803        let expected_len = (plaintext_end - plaintext_start) as usize;
804        assert_eq!(decrypted.len(), expected_len);
805        assert_eq!(
806            decrypted,
807            &plaintext[plaintext_start as usize..plaintext_end as usize]
808        );
809    }
810
811    #[test]
812    fn test_fingerprint_deterministic() {
813        let service = create_test_service();
814        assert_eq!(service.fingerprint(), service.fingerprint());
815    }
816
817    #[test]
818    fn test_fingerprint_different_keys() {
819        let service1 = EncryptionService::new_with_key(&[0u8; 32]);
820        let service2 = EncryptionService::new_with_key(&[1u8; 32]);
821        assert_ne!(service1.fingerprint(), service2.fingerprint());
822    }
823
824    #[test]
825    fn derive_scoped_deterministic() {
826        let service = create_test_service();
827        let derived1 = service.derive_scoped("rel-123");
828        let derived2 = service.derive_scoped("rel-123");
829        assert_eq!(derived1.key_bytes(), derived2.key_bytes());
830    }
831
832    #[test]
833    fn derive_scoped_different_releases() {
834        let service = create_test_service();
835        let key_a = service.derive_scoped("rel-aaa").key_bytes();
836        let key_b = service.derive_scoped("rel-bbb").key_bytes();
837        assert_ne!(key_a, key_b);
838    }
839
840    #[test]
841    fn derive_scoped_different_master_keys() {
842        let svc1 = EncryptionService::new_with_key(&[0u8; 32]);
843        let svc2 = EncryptionService::new_with_key(&[1u8; 32]);
844        let key1 = svc1.derive_scoped("rel-123").key_bytes();
845        let key2 = svc2.derive_scoped("rel-123").key_bytes();
846        assert_ne!(key1, key2);
847    }
848
849    #[test]
850    fn derive_scoped_roundtrip() {
851        let master = create_test_service();
852        let release_enc = master.derive_scoped("rel-456");
853        let plaintext = b"test audio data for this release";
854
855        let encrypted = release_enc.encrypt(plaintext);
856        let decrypted = release_enc.decrypt(&encrypted).unwrap();
857        assert_eq!(decrypted, plaintext);
858
859        // Cannot decrypt with master key
860        assert!(master.decrypt(&encrypted).is_err());
861
862        // Cannot decrypt with wrong release key
863        let wrong_enc = master.derive_scoped("rel-999");
864        assert!(wrong_enc.decrypt(&encrypted).is_err());
865    }
866}