rs_web/
encryption.rs

1use aes_gcm::{
2    Aes256Gcm, Key, Nonce,
3    aead::{Aead, KeyInit},
4};
5use anyhow::{Context, Result, anyhow};
6use argon2::{Algorithm, Argon2, Params, Version};
7use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
8use rand::RngCore;
9use std::process::Command;
10
11use crate::config::EncryptionConfig;
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)]
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/// Resolve password from various sources in priority order:
33/// 1. SITE_PASSWORD environment variable
34/// 2. password_command output
35/// 3. config password
36/// 4. per-post password (frontmatter)
37pub fn resolve_password(
38    config: &EncryptionConfig,
39    frontmatter_password: Option<&str>,
40) -> Result<String> {
41    // Priority 1: Environment variable
42    if let Ok(password) = std::env::var("SITE_PASSWORD")
43        && !password.is_empty()
44    {
45        return Ok(password);
46    }
47
48    // Priority 2: Command output
49    if let Some(ref cmd) = config.password_command {
50        let output = Command::new("sh")
51            .arg("-c")
52            .arg(cmd)
53            .output()
54            .with_context(|| format!("Failed to execute password command: {}", cmd))?;
55
56        if output.status.success() {
57            let password = String::from_utf8(output.stdout)
58                .with_context(|| "Password command output is not valid UTF-8")?
59                .trim()
60                .to_string();
61            if !password.is_empty() {
62                return Ok(password);
63            }
64        } else {
65            let stderr = String::from_utf8_lossy(&output.stderr);
66            return Err(anyhow!(
67                "Password command failed: {} - {}",
68                cmd,
69                stderr.trim()
70            ));
71        }
72    }
73
74    // Priority 3: Config password
75    if let Some(ref password) = config.password {
76        return Ok(password.clone());
77    }
78
79    // Priority 4: Frontmatter password
80    if let Some(password) = frontmatter_password {
81        return Ok(password.to_string());
82    }
83
84    Err(anyhow!(
85        "No encryption password found. Set SITE_PASSWORD env var, \
86         configure password_command, or set password in config/frontmatter"
87    ))
88}
89
90/// Derive a 256-bit key from password using Argon2id
91fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; KEY_LENGTH]> {
92    let params = Params::new(
93        ARGON2_MEMORY_COST,
94        ARGON2_TIME_COST,
95        ARGON2_PARALLELISM,
96        Some(KEY_LENGTH),
97    )
98    .map_err(|e| anyhow!("Failed to create Argon2 params: {}", e))?;
99
100    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
101
102    let mut key = [0u8; KEY_LENGTH];
103    argon2
104        .hash_password_into(password.as_bytes(), salt, &mut key)
105        .map_err(|e| anyhow!("Failed to derive key: {}", e))?;
106
107    Ok(key)
108}
109
110/// Encrypt content using AES-256-GCM with Argon2id key derivation
111pub fn encrypt_content(content: &str, password: &str) -> Result<EncryptedContent> {
112    // Generate random salt and nonce
113    let mut salt = [0u8; SALT_LENGTH];
114    let mut nonce_bytes = [0u8; NONCE_LENGTH];
115    rand::thread_rng().fill_bytes(&mut salt);
116    rand::thread_rng().fill_bytes(&mut nonce_bytes);
117
118    // Derive key from password
119    let key = derive_key(password, &salt)?;
120
121    // Create cipher and encrypt
122    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
123    let nonce = Nonce::from_slice(&nonce_bytes);
124
125    let ciphertext = cipher
126        .encrypt(nonce, content.as_bytes())
127        .map_err(|e| anyhow!("Encryption failed: {}", e))?;
128
129    Ok(EncryptedContent {
130        ciphertext: BASE64.encode(&ciphertext),
131        salt: BASE64.encode(salt),
132        nonce: BASE64.encode(nonce_bytes),
133    })
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_encrypt_content() {
142        let content = "This is a secret message!";
143        let password = "test-password-123";
144
145        let encrypted = encrypt_content(content, password).unwrap();
146
147        // Verify all fields are base64-encoded and non-empty
148        assert!(!encrypted.ciphertext.is_empty());
149        assert!(!encrypted.salt.is_empty());
150        assert!(!encrypted.nonce.is_empty());
151
152        // Verify we can decode the base64
153        let ciphertext_bytes = BASE64.decode(&encrypted.ciphertext).unwrap();
154        let salt_bytes = BASE64.decode(&encrypted.salt).unwrap();
155        let nonce_bytes = BASE64.decode(&encrypted.nonce).unwrap();
156
157        // Ciphertext should be longer than plaintext (includes auth tag)
158        assert!(ciphertext_bytes.len() > content.len());
159        assert_eq!(salt_bytes.len(), SALT_LENGTH);
160        assert_eq!(nonce_bytes.len(), NONCE_LENGTH);
161    }
162
163    #[test]
164    fn test_encrypt_decrypt_roundtrip() {
165        let content = "Secret content for roundtrip test!";
166        let password = "roundtrip-password";
167
168        let encrypted = encrypt_content(content, password).unwrap();
169
170        // Decrypt to verify
171        let salt = BASE64.decode(&encrypted.salt).unwrap();
172        let nonce_bytes = BASE64.decode(&encrypted.nonce).unwrap();
173        let ciphertext = BASE64.decode(&encrypted.ciphertext).unwrap();
174
175        let key = derive_key(password, &salt).unwrap();
176        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
177        let nonce = Nonce::from_slice(&nonce_bytes);
178
179        let decrypted = cipher.decrypt(nonce, ciphertext.as_ref()).unwrap();
180        let decrypted_str = String::from_utf8(decrypted).unwrap();
181
182        assert_eq!(decrypted_str, content);
183    }
184
185    #[test]
186    fn test_wrong_password_fails() {
187        let content = "Secret content";
188        let password = "correct-password";
189        let wrong_password = "wrong-password";
190
191        let encrypted = encrypt_content(content, password).unwrap();
192
193        // Try to decrypt with wrong password
194        let salt = BASE64.decode(&encrypted.salt).unwrap();
195        let nonce_bytes = BASE64.decode(&encrypted.nonce).unwrap();
196        let ciphertext = BASE64.decode(&encrypted.ciphertext).unwrap();
197
198        let key = derive_key(wrong_password, &salt).unwrap();
199        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
200        let nonce = Nonce::from_slice(&nonce_bytes);
201
202        // Should fail to decrypt
203        assert!(cipher.decrypt(nonce, ciphertext.as_ref()).is_err());
204    }
205
206    #[test]
207    fn test_resolve_password_from_frontmatter() {
208        let config = EncryptionConfig {
209            password_command: None,
210            password: None,
211        };
212
213        let password = resolve_password(&config, Some("frontmatter-pass")).unwrap();
214        assert_eq!(password, "frontmatter-pass");
215    }
216
217    #[test]
218    fn test_resolve_password_from_config() {
219        let config = EncryptionConfig {
220            password_command: None,
221            password: Some("config-pass".to_string()),
222        };
223
224        // Config password takes priority over frontmatter
225        let password = resolve_password(&config, Some("frontmatter-pass")).unwrap();
226        assert_eq!(password, "config-pass");
227    }
228
229    #[test]
230    fn test_resolve_password_no_source() {
231        let config = EncryptionConfig {
232            password_command: None,
233            password: None,
234        };
235
236        let result = resolve_password(&config, None);
237        assert!(result.is_err());
238    }
239}