sf_cli/
crypto.rs

1//! Cryptographic operations for secure file encryption
2
3use aes_gcm::{
4    aead::{Aead, KeyInit, OsRng},
5    Aes256Gcm, Nonce,
6};
7use argon2::Argon2;
8use rand::RngCore;
9use sha2::{Digest, Sha256};
10use thiserror::Error;
11use zeroize::Zeroize;
12use std::path::Path;
13
14/// Cryptographic errors
15#[derive(Error, Debug)]
16pub enum CryptoError {
17    #[error("Invalid password")]
18    InvalidPassword,
19    #[error("Encryption failed: {0}")]
20    EncryptionFailed(String),
21    #[error("Decryption failed: {0}")]
22    DecryptionFailed(String),
23    #[error("Key derivation failed: {0}")]
24    KeyDerivationFailed(String),
25    #[error("Invalid encrypted data format")]
26    InvalidFormat,
27}
28
29/// Secure key material that is zeroized on drop
30pub struct SecureKey {
31    key: [u8; 32], // 256-bit key for AES-256
32}
33
34impl SecureKey {
35    /// Create a new secure key from raw bytes
36    pub fn new(key: [u8; 32]) -> Self {
37        Self { key }
38    }
39
40    /// Get key bytes for cryptographic operations
41    pub fn as_bytes(&self) -> &[u8; 32] {
42        &self.key
43    }
44}
45
46impl Drop for SecureKey {
47    fn drop(&mut self) {
48        self.key.zeroize();
49    }
50}
51
52/// Salt for key derivation
53pub const SALT_SIZE: usize = 32;
54pub const NONCE_SIZE: usize = 12;
55pub const CHECKSUM_SIZE: usize = 32; // SHA-256 hash
56
57/// File metadata stored with encrypted data
58#[derive(Debug, Clone)]
59pub struct FileMetadata {
60    /// Original filename with extension
61    pub filename: String,
62    /// File checksum (SHA-256)
63    pub checksum: [u8; CHECKSUM_SIZE],
64    /// Whether the data was compressed
65    pub compressed: bool,
66}
67
68impl FileMetadata {
69    /// Create new file metadata
70    pub fn new(filename: String, checksum: [u8; CHECKSUM_SIZE], compressed: bool) -> Self {
71        Self {
72            filename,
73            checksum,
74            compressed,
75        }
76    }
77
78    /// Create metadata from file path and data
79    pub fn from_file(file_path: &Path, data: &[u8], compressed: bool) -> Self {
80        let filename = file_path
81            .file_name()
82            .and_then(|n| n.to_str())
83            .unwrap_or("unknown")
84            .to_string();
85        
86        let mut hasher = Sha256::new();
87        hasher.update(data);
88        let checksum: [u8; CHECKSUM_SIZE] = hasher.finalize().into();
89
90        Self::new(filename, checksum, compressed)
91    }
92
93    /// Serialize metadata to bytes
94    pub fn to_bytes(&self) -> Vec<u8> {
95        let filename_bytes = self.filename.as_bytes();
96        let filename_len = filename_bytes.len() as u16;
97        
98        let mut bytes = Vec::new();
99        bytes.extend_from_slice(&filename_len.to_le_bytes());
100        bytes.extend_from_slice(filename_bytes);
101        bytes.extend_from_slice(&self.checksum);
102        bytes.push(if self.compressed { 1 } else { 0 });
103        
104        bytes
105    }
106
107    /// Deserialize metadata from bytes
108    pub fn from_bytes(bytes: &[u8]) -> Result<(Self, usize), CryptoError> {
109        if bytes.len() < 2 {
110            return Err(CryptoError::InvalidFormat);
111        }
112
113        let filename_len = u16::from_le_bytes([bytes[0], bytes[1]]) as usize;
114        let required_size = 2 + filename_len + CHECKSUM_SIZE + 1;
115        
116        if bytes.len() < required_size {
117            return Err(CryptoError::InvalidFormat);
118        }
119
120        let filename = String::from_utf8(bytes[2..2 + filename_len].to_vec())
121            .map_err(|_| CryptoError::InvalidFormat)?;
122        
123        let mut checksum = [0u8; CHECKSUM_SIZE];
124        checksum.copy_from_slice(&bytes[2 + filename_len..2 + filename_len + CHECKSUM_SIZE]);
125        
126        let compressed = bytes[2 + filename_len + CHECKSUM_SIZE] != 0;
127
128        Ok((Self::new(filename, checksum, compressed), required_size))
129    }
130
131    /// Verify checksum against data
132    pub fn verify_checksum(&self, data: &[u8]) -> bool {
133        let mut hasher = Sha256::new();
134        hasher.update(data);
135        let computed_checksum: [u8; CHECKSUM_SIZE] = hasher.finalize().into();
136        computed_checksum == self.checksum
137    }
138}
139
140/// Encryption parameters stored with encrypted data
141#[derive(Debug)]
142pub struct EncryptionHeader {
143    pub salt: [u8; SALT_SIZE],
144    pub nonce: [u8; NONCE_SIZE],
145    pub metadata: FileMetadata,
146}
147
148impl EncryptionHeader {
149    /// Create new encryption header with metadata
150    pub fn new(metadata: FileMetadata) -> Self {
151        let mut salt = [0u8; SALT_SIZE];
152        let mut nonce = [0u8; NONCE_SIZE];
153        
154        OsRng.fill_bytes(&mut salt);
155        OsRng.fill_bytes(&mut nonce);
156
157        Self { salt, nonce, metadata }
158    }
159
160    /// Serialize header to bytes
161    pub fn to_bytes(&self) -> Vec<u8> {
162        let mut bytes = Vec::new();
163        bytes.extend_from_slice(&self.salt);
164        bytes.extend_from_slice(&self.nonce);
165        
166        let metadata_bytes = self.metadata.to_bytes();
167        let metadata_len = metadata_bytes.len() as u32;
168        bytes.extend_from_slice(&metadata_len.to_le_bytes());
169        bytes.extend_from_slice(&metadata_bytes);
170        
171        bytes
172    }
173
174    /// Deserialize header from bytes
175    pub fn from_bytes(bytes: &[u8]) -> Result<(Self, usize), CryptoError> {
176        let min_size = SALT_SIZE + NONCE_SIZE + 4; // +4 for metadata length
177        if bytes.len() < min_size {
178            return Err(CryptoError::InvalidFormat);
179        }
180
181        let mut salt = [0u8; SALT_SIZE];
182        let mut nonce = [0u8; NONCE_SIZE];
183        
184        salt.copy_from_slice(&bytes[..SALT_SIZE]);
185        nonce.copy_from_slice(&bytes[SALT_SIZE..SALT_SIZE + NONCE_SIZE]);
186
187        let metadata_len_offset = SALT_SIZE + NONCE_SIZE;
188        let metadata_len = u32::from_le_bytes([
189            bytes[metadata_len_offset],
190            bytes[metadata_len_offset + 1],
191            bytes[metadata_len_offset + 2],
192            bytes[metadata_len_offset + 3],
193        ]) as usize;
194
195        let metadata_start = metadata_len_offset + 4;
196        if bytes.len() < metadata_start + metadata_len {
197            return Err(CryptoError::InvalidFormat);
198        }
199
200        let (metadata, _) = FileMetadata::from_bytes(&bytes[metadata_start..metadata_start + metadata_len])?;
201        let total_size = metadata_start + metadata_len;
202
203        Ok((Self { salt, nonce, metadata }, total_size))
204    }
205}
206
207/// Crypto engine for encryption/decryption operations
208pub struct CryptoEngine {
209    argon2: Argon2<'static>,
210}
211
212impl Default for CryptoEngine {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218impl CryptoEngine {
219    /// Create a new crypto engine with secure defaults
220    pub fn new() -> Self {
221        Self {
222            argon2: Argon2::default(),
223        }
224    }
225
226    /// Derive a secure key from password and salt using Argon2
227    pub fn derive_key(&self, password: &str, salt: &[u8; SALT_SIZE]) -> Result<SecureKey, CryptoError> {
228        let mut key = [0u8; 32];
229        self.argon2
230            .hash_password_into(password.as_bytes(), salt, &mut key)
231            .map_err(|e| CryptoError::KeyDerivationFailed(e.to_string()))?;
232
233        Ok(SecureKey::new(key))
234    }
235
236    /// Encrypt data with password and metadata
237    pub fn encrypt(&self, data: &[u8], password: &str, metadata: FileMetadata) -> Result<Vec<u8>, CryptoError> {
238        let header = EncryptionHeader::new(metadata);
239        let key = self.derive_key(password, &header.salt)?;
240        
241        let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
242            .map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
243
244        let nonce = Nonce::from_slice(&header.nonce);
245        let ciphertext = cipher
246            .encrypt(nonce, data)
247            .map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
248
249        let header_bytes = header.to_bytes();
250        let mut result = Vec::with_capacity(header_bytes.len() + ciphertext.len());
251        result.extend_from_slice(&header_bytes);
252        result.extend_from_slice(&ciphertext);
253
254        Ok(result)
255    }
256
257    /// Decrypt data with password, returning both data and metadata
258    pub fn decrypt(&self, encrypted_data: &[u8], password: &str) -> Result<(Vec<u8>, FileMetadata), CryptoError> {
259        if encrypted_data.len() < SALT_SIZE + NONCE_SIZE + 4 {
260            return Err(CryptoError::InvalidFormat);
261        }
262
263        let (header, header_size) = EncryptionHeader::from_bytes(encrypted_data)?;
264        let ciphertext = &encrypted_data[header_size..];
265
266        let key = self.derive_key(password, &header.salt)?;
267        
268        let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
269            .map_err(|e| CryptoError::DecryptionFailed(e.to_string()))?;
270
271        let nonce = Nonce::from_slice(&header.nonce);
272        let plaintext = cipher
273            .decrypt(nonce, ciphertext)
274            .map_err(|_| CryptoError::InvalidPassword)?;
275
276        Ok((plaintext, header.metadata))
277    }
278
279    /// Legacy decrypt method for backward compatibility
280    pub fn decrypt_legacy(&self, encrypted_data: &[u8], password: &str) -> Result<Vec<u8>, CryptoError> {
281        const LEGACY_HEADER_SIZE: usize = SALT_SIZE + NONCE_SIZE;
282        
283        if encrypted_data.len() < LEGACY_HEADER_SIZE {
284            return Err(CryptoError::InvalidFormat);
285        }
286
287        // Try to detect if this is legacy format (fixed header size)
288        let is_legacy = encrypted_data.len() >= LEGACY_HEADER_SIZE && {
289            // Check if the metadata length field would be reasonable for new format
290            if encrypted_data.len() > SALT_SIZE + NONCE_SIZE + 4 {
291                let metadata_len_offset = SALT_SIZE + NONCE_SIZE;
292                let metadata_len = u32::from_le_bytes([
293                    encrypted_data[metadata_len_offset],
294                    encrypted_data[metadata_len_offset + 1],
295                    encrypted_data[metadata_len_offset + 2],
296                    encrypted_data[metadata_len_offset + 3],
297                ]) as usize;
298                
299                // If metadata length is unreasonably large, assume legacy format
300                metadata_len > 1024 || metadata_len_offset + 4 + metadata_len > encrypted_data.len()
301            } else {
302                true
303            }
304        };
305
306        if is_legacy {
307            let mut salt = [0u8; SALT_SIZE];
308            let mut nonce = [0u8; NONCE_SIZE];
309            
310            salt.copy_from_slice(&encrypted_data[..SALT_SIZE]);
311            nonce.copy_from_slice(&encrypted_data[SALT_SIZE..LEGACY_HEADER_SIZE]);
312            
313            let ciphertext = &encrypted_data[LEGACY_HEADER_SIZE..];
314
315            let key = self.derive_key(password, &salt)?;
316            
317            let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
318                .map_err(|e| CryptoError::DecryptionFailed(e.to_string()))?;
319
320            let nonce_obj = Nonce::from_slice(&nonce);
321            let plaintext = cipher
322                .decrypt(nonce_obj, ciphertext)
323                .map_err(|_| CryptoError::InvalidPassword)?;
324
325            Ok(plaintext)
326        } else {
327            // Use new format
328            let (plaintext, _) = self.decrypt(encrypted_data, password)?;
329            Ok(plaintext)
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_encryption_decryption() {
340        let engine = CryptoEngine::new();
341        let data = b"Hello, World! This is a test message.";
342        let password = "strong_password_123";
343        let metadata = FileMetadata::new("test.txt".to_string(), [0u8; 32], false);
344
345        let encrypted = engine.encrypt(data, password, metadata.clone()).unwrap();
346        assert_ne!(encrypted.as_slice(), data);
347        assert!(encrypted.len() > data.len());
348
349        let (decrypted, recovered_metadata) = engine.decrypt(&encrypted, password).unwrap();
350        assert_eq!(decrypted.as_slice(), data);
351        assert_eq!(recovered_metadata.filename, metadata.filename);
352        assert_eq!(recovered_metadata.compressed, metadata.compressed);
353    }
354
355    #[test]
356    fn test_wrong_password() {
357        let engine = CryptoEngine::new();
358        let data = b"Secret message";
359        let password = "correct_password";
360        let wrong_password = "wrong_password";
361        let metadata = FileMetadata::new("secret.txt".to_string(), [0u8; 32], false);
362
363        let encrypted = engine.encrypt(data, password, metadata).unwrap();
364        let result = engine.decrypt(&encrypted, wrong_password);
365        
366        assert!(matches!(result, Err(CryptoError::InvalidPassword)));
367    }
368
369    #[test]
370    fn test_legacy_format_compatibility() {
371        let engine = CryptoEngine::new();
372        let _data = b"Legacy test data";
373        let password = "test_password";
374
375        // This test would require creating legacy format data
376        // For now, we'll test that legacy detection doesn't break
377        let result = engine.decrypt_legacy(b"invalid_short_data", password);
378        assert!(matches!(result, Err(CryptoError::InvalidFormat)));
379    }
380
381    #[test]
382    fn test_file_metadata() {
383        use std::path::Path;
384        
385        let data = b"Test file content";
386        let path = Path::new("test.txt");
387        let metadata = FileMetadata::from_file(path, data, false);
388        
389        assert_eq!(metadata.filename, "test.txt");
390        assert!(!metadata.compressed);
391        assert!(metadata.verify_checksum(data));
392        
393        // Test serialization
394        let bytes = metadata.to_bytes();
395        let (recovered, size) = FileMetadata::from_bytes(&bytes).unwrap();
396        assert_eq!(size, bytes.len());
397        assert_eq!(recovered.filename, metadata.filename);
398        assert_eq!(recovered.checksum, metadata.checksum);
399        assert_eq!(recovered.compressed, metadata.compressed);
400    }
401
402    #[test]
403    fn test_invalid_format() {
404        let engine = CryptoEngine::new();
405        let invalid_data = b"not_encrypted_data";
406        let password = "password";
407
408        let result = engine.decrypt(invalid_data, password);
409        assert!(matches!(result, Err(CryptoError::InvalidFormat)));
410    }
411
412    #[test]
413    fn test_encryption_header() {
414        let metadata = FileMetadata::new("test.txt".to_string(), [42u8; 32], true);
415        let header = EncryptionHeader::new(metadata.clone());
416
417        // Test serialization/deserialization
418        let bytes = header.to_bytes();
419        let (deserialized, size) = EncryptionHeader::from_bytes(&bytes).unwrap();
420        
421        assert_eq!(size, bytes.len());
422        assert_eq!(header.salt, deserialized.salt);
423        assert_eq!(header.nonce, deserialized.nonce);
424        assert_eq!(header.metadata.filename, deserialized.metadata.filename);
425        assert_eq!(header.metadata.checksum, deserialized.metadata.checksum);
426        assert_eq!(header.metadata.compressed, deserialized.metadata.compressed);
427    }
428
429    #[test]
430    fn test_secure_key_zeroization() {
431        let key_bytes = [42u8; 32];
432        let key = SecureKey::new(key_bytes);
433        assert_eq!(key.as_bytes(), &key_bytes);
434        
435        // Key should be zeroized when dropped
436        drop(key);
437        // Note: We can't test zeroization directly as the memory is freed
438    }
439}