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
14const ARGON2_MEMORY_COST: u32 = 65536; const 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; #[derive(Debug, Clone, serde::Serialize)]
24pub struct EncryptedContent {
25 pub ciphertext: String,
27 pub salt: String,
29 pub nonce: String,
31}
32
33pub fn resolve_password(
42 config: &EncryptionConfig,
43 frontmatter_password: Option<&str>,
44) -> Result<String> {
45 trace!("Resolving encryption password");
46
47 if let Some(password) = frontmatter_password {
49 debug!("Using password from frontmatter");
50 return Ok(password.to_string());
51 }
52
53 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 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 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
101fn 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
121pub fn encrypt_content(content: &str, password: &str) -> Result<EncryptedContent> {
123 trace!("Encrypting content ({} bytes)", content.len());
124
125 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 trace!("Deriving key with Argon2id");
133 let key = derive_key(password, &salt)?;
134
135 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 assert!(!encrypted.ciphertext.is_empty());
164 assert!(!encrypted.salt.is_empty());
165 assert!(!encrypted.nonce.is_empty());
166
167 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 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 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 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 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 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 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}