Skip to main content

coding_agent_search/html_export/
encryption.rs

1//! Client-side encryption for HTML exports.
2//!
3//! Uses Web Crypto API compatible encryption (AES-GCM) with PBKDF2 key derivation.
4//! The encryption happens in Rust, decryption happens in the browser via JavaScript.
5
6#[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/// Errors that can occur during encryption.
17#[derive(Debug, thiserror::Error)]
18pub enum EncryptionError {
19    /// Key derivation failed
20    #[error("key derivation failed: {0}")]
21    KeyDerivation(String),
22    /// Encryption operation failed
23    #[error("encryption failed: {0}")]
24    EncryptionFailed(String),
25    /// Invalid passphrase
26    #[error("invalid passphrase")]
27    InvalidPassphrase,
28}
29
30/// Encrypted content bundle ready for embedding in HTML.
31#[derive(Debug, Clone, Serialize)]
32pub struct EncryptedContent {
33    /// Base64-encoded salt (16 bytes)
34    pub salt: String,
35    /// Base64-encoded IV/nonce (12 bytes for AES-GCM)
36    pub iv: String,
37    /// Base64-encoded ciphertext (includes GCM tag)
38    pub ciphertext: String,
39    /// PBKDF2 iteration count used for key derivation
40    pub iterations: u32,
41}
42
43impl EncryptedContent {
44    /// Convert to JSON for embedding in HTML.
45    ///
46    /// Note: Values are expected to be base64-encoded (safe characters only).
47    pub fn to_json(&self) -> String {
48        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
49    }
50}
51
52/// Encryption parameters matching Web Crypto API defaults.
53#[derive(Debug, Clone)]
54pub struct EncryptionParams {
55    /// PBKDF2 iterations (600,000 recommended)
56    pub iterations: u32,
57    /// Salt length in bytes
58    pub salt_len: usize,
59    /// IV/nonce length in bytes (12 for AES-GCM)
60    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/// Encrypt content for client-side decryption.
74///
75/// This uses AES-256-GCM with PBKDF2-SHA256 key derivation,
76/// matching the Web Crypto API implementation in scripts.rs.
77///
78/// # Note
79/// This is the production encryption path for feature-enabled HTML export.
80/// It intentionally uses the same algorithm and parameter contract as the
81/// browser-side Web Crypto decryptor so exported pages can be decrypted
82/// client-side without a server round-trip.
83#[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    // Generate random salt and IV
131    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    // Derive key using PBKDF2-SHA256
138    let mut key = zeroize::Zeroizing::new([0u8; 32]); // 256 bits for AES-256
139    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    // Encrypt with AES-256-GCM
157    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/// Fill encryption entropy for salt/IV generation.
184#[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/// Deterministic bytes for debug/test golden generation only.
196#[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/// Placeholder encrypt function when encryption feature is disabled.
228#[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/// Base64 encode bytes (standard alphabet).
245#[cfg(feature = "encryption")]
246fn base64_encode(data: &[u8]) -> String {
247    use base64::Engine;
248    base64::prelude::BASE64_STANDARD.encode(data)
249}
250
251/// Generate HTML for encrypted content display.
252///
253/// The JSON is HTML-escaped to prevent XSS even if EncryptedContent
254/// contains unexpected data (defensive programming).
255pub 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    // HTML-escape the JSON to prevent XSS if someone passes malicious data
263    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
275/// Escape HTML special characters for safe embedding in HTML content.
276fn 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("&amp;"),
281            '<' => result.push_str("&lt;"),
282            '>' => result.push_str("&gt;"),
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; // Required for decode() method
353        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, &params).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            &params,
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", "", &params);
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", &params);
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", &params);
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", &params);
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}