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