rs_web/
encryption.rs

1//! AES-256-GCM encryption with Argon2id key derivation
2
3use aes_gcm::{
4    Aes256Gcm, Key, Nonce,
5    aead::{Aead, KeyInit},
6};
7use anyhow::{Result, anyhow};
8use argon2::{Algorithm, Argon2, Params, Version};
9use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
10use log::trace;
11use rand::RngCore;
12
13/// Argon2 parameters - must match JavaScript implementation
14const ARGON2_MEMORY_COST: u32 = 65536; // 64 MiB
15const ARGON2_TIME_COST: u32 = 3;
16const ARGON2_PARALLELISM: u32 = 1;
17const SALT_LENGTH: usize = 16;
18const NONCE_LENGTH: usize = 12;
19const KEY_LENGTH: usize = 32; // AES-256
20
21/// Encrypted content with all data needed for decryption
22#[derive(Debug, Clone, serde::Serialize)]
23pub struct EncryptedContent {
24    /// Base64-encoded ciphertext
25    pub ciphertext: String,
26    /// Base64-encoded salt used for key derivation
27    pub salt: String,
28    /// Base64-encoded nonce used for encryption
29    pub nonce: String,
30}
31
32/// Derive a 256-bit key from password using Argon2id
33fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; KEY_LENGTH]> {
34    let params = Params::new(
35        ARGON2_MEMORY_COST,
36        ARGON2_TIME_COST,
37        ARGON2_PARALLELISM,
38        Some(KEY_LENGTH),
39    )
40    .map_err(|e| anyhow!("Failed to create Argon2 params: {}", e))?;
41
42    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
43
44    let mut key = [0u8; KEY_LENGTH];
45    argon2
46        .hash_password_into(password.as_bytes(), salt, &mut key)
47        .map_err(|e| anyhow!("Failed to derive key: {}", e))?;
48
49    Ok(key)
50}
51
52/// Encrypt content using AES-256-GCM with Argon2id key derivation
53pub fn encrypt_content(content: &str, password: &str) -> Result<EncryptedContent> {
54    trace!("Encrypting content ({} bytes)", content.len());
55
56    // Generate random salt and nonce
57    let mut salt = [0u8; SALT_LENGTH];
58    let mut nonce_bytes = [0u8; NONCE_LENGTH];
59    rand::thread_rng().fill_bytes(&mut salt);
60    rand::thread_rng().fill_bytes(&mut nonce_bytes);
61
62    // Derive key from password
63    trace!("Deriving key with Argon2id");
64    let key = derive_key(password, &salt)?;
65
66    // Create cipher and encrypt
67    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
68    let nonce = Nonce::from_slice(&nonce_bytes);
69
70    let ciphertext = cipher
71        .encrypt(nonce, content.as_bytes())
72        .map_err(|e| anyhow!("Encryption failed: {}", e))?;
73
74    trace!("Content encrypted successfully");
75    Ok(EncryptedContent {
76        ciphertext: BASE64.encode(&ciphertext),
77        salt: BASE64.encode(salt),
78        nonce: BASE64.encode(nonce_bytes),
79    })
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn test_encrypt_content() {
88        let content = "This is a secret message!";
89        let password = "test-password-123";
90
91        let encrypted = encrypt_content(content, password).unwrap();
92
93        // Verify all fields are base64-encoded and non-empty
94        assert!(!encrypted.ciphertext.is_empty());
95        assert!(!encrypted.salt.is_empty());
96        assert!(!encrypted.nonce.is_empty());
97
98        // Verify we can decode the base64
99        let ciphertext_bytes = BASE64.decode(&encrypted.ciphertext).unwrap();
100        let salt_bytes = BASE64.decode(&encrypted.salt).unwrap();
101        let nonce_bytes = BASE64.decode(&encrypted.nonce).unwrap();
102
103        // Ciphertext should be longer than plaintext (includes auth tag)
104        assert!(ciphertext_bytes.len() > content.len());
105        assert_eq!(salt_bytes.len(), SALT_LENGTH);
106        assert_eq!(nonce_bytes.len(), NONCE_LENGTH);
107    }
108
109    #[test]
110    fn test_encrypt_decrypt_roundtrip() {
111        let content = "Secret content for roundtrip test!";
112        let password = "roundtrip-password";
113
114        let encrypted = encrypt_content(content, password).unwrap();
115
116        // Decrypt to verify
117        let salt = BASE64.decode(&encrypted.salt).unwrap();
118        let nonce_bytes = BASE64.decode(&encrypted.nonce).unwrap();
119        let ciphertext = BASE64.decode(&encrypted.ciphertext).unwrap();
120
121        let key = derive_key(password, &salt).unwrap();
122        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
123        let nonce = Nonce::from_slice(&nonce_bytes);
124
125        let decrypted = cipher.decrypt(nonce, ciphertext.as_ref()).unwrap();
126        let decrypted_str = String::from_utf8(decrypted).unwrap();
127
128        assert_eq!(decrypted_str, content);
129    }
130
131    #[test]
132    fn test_wrong_password_fails() {
133        let content = "Secret content";
134        let password = "correct-password";
135        let wrong_password = "wrong-password";
136
137        let encrypted = encrypt_content(content, password).unwrap();
138
139        // Try to decrypt with wrong password
140        let salt = BASE64.decode(&encrypted.salt).unwrap();
141        let nonce_bytes = BASE64.decode(&encrypted.nonce).unwrap();
142        let ciphertext = BASE64.decode(&encrypted.ciphertext).unwrap();
143
144        let key = derive_key(wrong_password, &salt).unwrap();
145        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
146        let nonce = Nonce::from_slice(&nonce_bytes);
147
148        // Should fail to decrypt
149        assert!(cipher.decrypt(nonce, ciphertext.as_ref()).is_err());
150    }
151}