coding_agent_search/html_export/
encryption.rs1#[cfg(feature = "encryption")]
7use std::num::NonZeroU32;
8#[cfg(feature = "encryption")]
9use std::time::Instant;
10
11use serde::Serialize;
12use tracing::{debug, warn};
13
14#[cfg(feature = "encryption")]
15use tracing::info;
16#[derive(Debug, thiserror::Error)]
18pub enum EncryptionError {
19 #[error("key derivation failed: {0}")]
21 KeyDerivation(String),
22 #[error("encryption failed: {0}")]
24 EncryptionFailed(String),
25 #[error("invalid passphrase")]
27 InvalidPassphrase,
28}
29
30#[derive(Debug, Clone, Serialize)]
32pub struct EncryptedContent {
33 pub salt: String,
35 pub iv: String,
37 pub ciphertext: String,
39 pub iterations: u32,
41}
42
43impl EncryptedContent {
44 pub fn to_json(&self) -> String {
48 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
49 }
50}
51
52#[derive(Debug, Clone)]
54pub struct EncryptionParams {
55 pub iterations: u32,
57 pub salt_len: usize,
59 pub iv_len: usize,
61}
62
63impl Default for EncryptionParams {
64 fn default() -> Self {
65 Self {
66 iterations: 600_000,
67 salt_len: 16,
68 iv_len: 12,
69 }
70 }
71}
72
73#[cfg(feature = "encryption")]
84pub fn encrypt_content(
85 plaintext: &str,
86 password: &str,
87 params: &EncryptionParams,
88) -> Result<EncryptedContent, EncryptionError> {
89 use aes_gcm::{
90 Aes256Gcm, Nonce,
91 aead::{Aead, KeyInit},
92 };
93 use ring::pbkdf2;
94
95 if password.is_empty() {
96 warn!(
97 component = "encryption",
98 operation = "validate_password",
99 "Rejected empty password"
100 );
101 return Err(EncryptionError::InvalidPassphrase);
102 }
103 if params.iterations == 0 {
104 return Err(EncryptionError::KeyDerivation(
105 "iterations must be greater than zero".to_string(),
106 ));
107 }
108 if params.salt_len == 0 {
109 return Err(EncryptionError::KeyDerivation(
110 "salt length must be greater than zero".to_string(),
111 ));
112 }
113 if params.iv_len != 12 {
114 return Err(EncryptionError::KeyDerivation(
115 "iv length must be 12 bytes for AES-GCM".to_string(),
116 ));
117 }
118
119 let started = Instant::now();
120 info!(
121 component = "encryption",
122 operation = "encrypt_payload",
123 plaintext_bytes = plaintext.len(),
124 iterations = params.iterations,
125 salt_len = params.salt_len,
126 iv_len = params.iv_len,
127 "Starting encryption"
128 );
129
130 let mut salt = vec![0u8; params.salt_len];
132 let mut iv = vec![0u8; params.iv_len];
133 fill_encryption_random("salt", &mut salt);
134 fill_encryption_random("iv", &mut iv);
135
136 let derive_started = Instant::now();
137 let mut key = zeroize::Zeroizing::new([0u8; 32]); let iterations = NonZeroU32::new(params.iterations).ok_or_else(|| {
140 EncryptionError::KeyDerivation("iterations must be greater than zero".to_string())
141 })?;
142 pbkdf2::derive(
143 pbkdf2::PBKDF2_HMAC_SHA256,
144 iterations,
145 &salt,
146 password.as_bytes(),
147 &mut *key,
148 );
149 debug!(
150 component = "encryption",
151 operation = "derive_key",
152 duration_ms = derive_started.elapsed().as_millis(),
153 "Derived key via PBKDF2"
154 );
155
156 let cipher = Aes256Gcm::new_from_slice(key.as_ref())
158 .map_err(|e| EncryptionError::EncryptionFailed(e.to_string()))?;
159
160 let nonce = Nonce::from_slice(&iv);
161 let ciphertext = cipher
162 .encrypt(nonce, plaintext.as_bytes())
163 .map_err(|e| EncryptionError::EncryptionFailed(e.to_string()))?;
164
165 let encrypted = EncryptedContent {
166 salt: base64_encode(&salt),
167 iv: base64_encode(&iv),
168 ciphertext: base64_encode(&ciphertext),
169 iterations: params.iterations,
170 };
171
172 info!(
173 component = "encryption",
174 operation = "encrypt_complete",
175 ciphertext_bytes = encrypted.ciphertext.len(),
176 duration_ms = started.elapsed().as_millis(),
177 "Encryption complete"
178 );
179
180 Ok(encrypted)
181}
182
183#[cfg(feature = "encryption")]
185fn fill_encryption_random(label: &str, output: &mut [u8]) {
186 if let Some(bytes) = deterministic_test_bytes(label, output.len()) {
187 output.copy_from_slice(&bytes);
188 return;
189 }
190
191 use aes_gcm::aead::{OsRng, rand_core::RngCore};
192 OsRng.fill_bytes(output);
193}
194
195#[cfg(feature = "encryption")]
197fn deterministic_test_bytes(entropy_label: &str, len: usize) -> Option<Vec<u8>> {
198 #[cfg(debug_assertions)]
199 {
200 let golden_label = dotenvy::var("CASS_HTML_EXPORT_GOLDEN_BYTES_LABEL").ok()?;
201 if golden_label.is_empty() {
202 return None;
203 }
204
205 let mut out = Vec::with_capacity(len);
206 let mut counter = 0u64;
207 while out.len() < len {
208 let mut hasher = blake3::Hasher::new();
209 hasher.update(b"cass-html-export-deterministic-encryption-v1");
210 hasher.update(golden_label.as_bytes());
211 hasher.update(entropy_label.as_bytes());
212 hasher.update(&counter.to_le_bytes());
213 out.extend_from_slice(hasher.finalize().as_bytes());
214 counter += 1;
215 }
216 out.truncate(len);
217 Some(out)
218 }
219
220 #[cfg(not(debug_assertions))]
221 {
222 let _ = (entropy_label, len);
223 None
224 }
225}
226
227#[cfg(not(feature = "encryption"))]
229pub fn encrypt_content(
230 _plaintext: &str,
231 _password: &str,
232 _params: &EncryptionParams,
233) -> Result<EncryptedContent, EncryptionError> {
234 warn!(
235 component = "encryption",
236 operation = "encrypt_payload",
237 "Encryption feature not enabled"
238 );
239 Err(EncryptionError::EncryptionFailed(
240 "encryption feature not enabled - compile with --features encryption".to_string(),
241 ))
242}
243
244#[cfg(feature = "encryption")]
246fn base64_encode(data: &[u8]) -> String {
247 use base64::Engine;
248 base64::prelude::BASE64_STANDARD.encode(data)
249}
250
251pub fn render_encrypted_placeholder(encrypted: &EncryptedContent) -> String {
256 debug!(
257 component = "encryption",
258 operation = "render_placeholder",
259 ciphertext_bytes = encrypted.ciphertext.len(),
260 "Rendering encrypted placeholder"
261 );
262 let json = encrypted.to_json();
264 let escaped_json = html_escape_for_content(&json);
265 format!(
266 r###" <!-- Encrypted content - requires password to decrypt -->
267 <div id="encrypted-content" hidden>{}</div>
268 <div class="encrypted-notice">
269 <p>This conversation is encrypted. Enter the password above to view.</p>
270 </div>"###,
271 escaped_json
272 )
273}
274
275fn html_escape_for_content(s: &str) -> String {
277 let mut result = String::with_capacity(s.len());
278 for c in s.chars() {
279 match c {
280 '&' => result.push_str("&"),
281 '<' => result.push_str("<"),
282 '>' => result.push_str(">"),
283 _ => result.push(c),
284 }
285 }
286 result
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn test_encryption_error_display_strings() {
295 assert_eq!(
296 EncryptionError::KeyDerivation("bad params".to_string()).to_string(),
297 "key derivation failed: bad params"
298 );
299 assert_eq!(
300 EncryptionError::EncryptionFailed("cipher failed".to_string()).to_string(),
301 "encryption failed: cipher failed"
302 );
303 assert_eq!(
304 EncryptionError::InvalidPassphrase.to_string(),
305 "invalid passphrase"
306 );
307 }
308
309 #[test]
310 #[cfg(feature = "encryption")]
311 fn test_base64_encode() {
312 assert_eq!(base64_encode(b"" as &[u8]), "");
313 assert_eq!(base64_encode(b"f" as &[u8]), "Zg==");
314 assert_eq!(base64_encode(b"fo" as &[u8]), "Zm8=");
315 assert_eq!(base64_encode(b"foo" as &[u8]), "Zm9v");
316 assert_eq!(base64_encode(b"foob" as &[u8]), "Zm9vYg==");
317 assert_eq!(base64_encode(b"fooba" as &[u8]), "Zm9vYmE=");
318 assert_eq!(base64_encode(b"foobar" as &[u8]), "Zm9vYmFy");
319 }
320
321 #[test]
322 fn test_encrypted_content_to_json() {
323 let content = EncryptedContent {
324 salt: "abc123".to_string(),
325 iv: "xyz789".to_string(),
326 ciphertext: "encrypted_data".to_string(),
327 iterations: 123_456,
328 };
329
330 let json = content.to_json();
331 assert!(json.contains("\"salt\":\"abc123\""));
332 assert!(json.contains("\"iv\":\"xyz789\""));
333 assert!(json.contains("\"ciphertext\":\"encrypted_data\""));
334 assert!(json.contains("\"iterations\":123456"));
335 }
336
337 #[test]
338 fn test_encryption_params_default() {
339 let params = EncryptionParams::default();
340 assert_eq!(params.iterations, 600_000);
341 assert_eq!(params.salt_len, 16);
342 assert_eq!(params.iv_len, 12);
343 }
344
345 #[test]
346 #[cfg(feature = "encryption")]
347 fn test_encrypt_content_roundtrip() {
348 use aes_gcm::{
349 Aes256Gcm, Nonce,
350 aead::{Aead, KeyInit},
351 };
352 use base64::Engine; use base64::prelude::BASE64_STANDARD;
354 use ring::pbkdf2;
355
356 let params = EncryptionParams {
357 iterations: 1_000,
358 salt_len: 16,
359 iv_len: 12,
360 };
361 let plaintext = "Hello 🌍";
362 let test_phrase = ["unit", "test", "phrase"].join(" ");
363
364 let encrypted = encrypt_content(plaintext, &test_phrase, ¶ms).expect("encrypt");
365 assert_eq!(encrypted.iterations, params.iterations);
366
367 let salt = BASE64_STANDARD
368 .decode(encrypted.salt.as_bytes())
369 .expect("salt b64");
370 let iv = BASE64_STANDARD
371 .decode(encrypted.iv.as_bytes())
372 .expect("iv b64");
373 let ciphertext = BASE64_STANDARD
374 .decode(encrypted.ciphertext.as_bytes())
375 .expect("ciphertext b64");
376
377 let mut key = [0u8; 32];
378 pbkdf2::derive(
379 pbkdf2::PBKDF2_HMAC_SHA256,
380 NonZeroU32::new(params.iterations).expect("test iterations should be non-zero"),
381 &salt,
382 test_phrase.as_bytes(),
383 &mut key,
384 );
385
386 let cipher = Aes256Gcm::new_from_slice(&key).expect("cipher");
387 let nonce = Nonce::from_slice(&iv);
388 let decrypted = cipher.decrypt(nonce, ciphertext.as_ref()).expect("decrypt");
389
390 assert_eq!(plaintext, String::from_utf8(decrypted).expect("utf8"));
391 }
392
393 #[test]
394 #[cfg(feature = "encryption")]
395 fn test_encrypt_content_produces_authenticated_ciphertext() {
396 let params = EncryptionParams {
397 iterations: 1_000,
398 salt_len: 16,
399 iv_len: 12,
400 };
401 let result = encrypt_content(
402 "sensitive data",
403 "authenticated encryption fixture",
404 ¶ms,
405 )
406 .expect("feature-enabled encrypt_content should produce ciphertext");
407
408 assert!(!result.salt.is_empty(), "salt must be generated");
409 assert!(!result.iv.is_empty(), "iv must be generated");
410 assert_ne!(
411 result.ciphertext, "sensitive data",
412 "ciphertext must differ from plaintext"
413 );
414 assert!(
415 result.ciphertext.len() > "sensitive data".len(),
416 "ciphertext should include authenticated-encryption overhead"
417 );
418 assert_eq!(result.iterations, params.iterations);
419 }
420
421 #[test]
422 #[cfg(feature = "encryption")]
423 fn test_encrypt_rejects_empty_password() {
424 let params = EncryptionParams {
425 iterations: 1_000,
426 salt_len: 16,
427 iv_len: 12,
428 };
429 let result = encrypt_content("hello", "", ¶ms);
430 assert!(matches!(result, Err(EncryptionError::InvalidPassphrase)));
431 }
432
433 #[test]
434 #[cfg(feature = "encryption")]
435 fn test_encrypt_rejects_invalid_params() {
436 let mut params = EncryptionParams {
437 iterations: 1_000,
438 salt_len: 16,
439 iv_len: 12,
440 };
441
442 params.iterations = 0;
443 let result = encrypt_content("hello", "pw", ¶ms);
444 assert!(matches!(result, Err(EncryptionError::KeyDerivation(_))));
445
446 params.iterations = 1_000;
447 params.salt_len = 0;
448 let result = encrypt_content("hello", "pw", ¶ms);
449 assert!(matches!(result, Err(EncryptionError::KeyDerivation(_))));
450
451 params.salt_len = 16;
452 params.iv_len = 8;
453 let result = encrypt_content("hello", "pw", ¶ms);
454 assert!(matches!(result, Err(EncryptionError::KeyDerivation(_))));
455 }
456
457 #[test]
458 #[cfg(not(feature = "encryption"))]
459 fn test_encrypt_without_feature_returns_error() {
460 let phrase = ["disabled", "feature", "phrase"].join(" ");
461 let result = encrypt_content("test", &phrase, &EncryptionParams::default());
462 assert!(result.is_err());
463 }
464}