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
13const ARGON2_MEMORY_COST: u32 = 65536; const 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; #[derive(Debug, Clone)]
23pub struct EncryptedContent {
24 pub ciphertext: String,
26 pub salt: String,
28 pub nonce: String,
30}
31
32pub fn resolve_password(
38 config: &EncryptionConfig,
39 frontmatter_password: Option<&str>,
40) -> Result<String> {
41 if let Ok(password) = std::env::var("SITE_PASSWORD")
43 && !password.is_empty()
44 {
45 return Ok(password);
46 }
47
48 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 if let Some(ref password) = config.password {
76 return Ok(password.clone());
77 }
78
79 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
90fn 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
110pub fn encrypt_content(content: &str, password: &str) -> Result<EncryptedContent> {
112 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 let key = derive_key(password, &salt)?;
120
121 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 assert!(!encrypted.ciphertext.is_empty());
149 assert!(!encrypted.salt.is_empty());
150 assert!(!encrypted.nonce.is_empty());
151
152 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 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 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 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 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 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}