Skip to main content

vector_core/crypto/
mod.rs

1pub mod guarded_key;
2pub use guarded_key::GuardedKey;
3
4mod signer;
5pub use signer::GuardedSigner;
6
7use argon2::Argon2;
8use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, KeyInit};
9use zeroize::Zeroize;
10
11/// Derive a 32-byte key from a password using Argon2id.
12/// Parameters: 150MB memory, 10 iterations (matches src-tauri).
13pub async fn hash_pass(password: &str) -> [u8; 32] {
14    let password = password.to_string();
15    tokio::task::spawn_blocking(move || {
16        let salt = b"vectorvectovectvecvev";
17        let mut output = [0u8; 32];
18
19        let params = argon2::Params::new(
20            150_000, // 150 MB
21            10,      // iterations
22            1,       // parallelism
23            Some(32),
24        ).unwrap();
25
26        let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
27        argon2.hash_password_into(password.as_bytes(), salt, &mut output).unwrap();
28
29        output
30    }).await.unwrap()
31}
32
33/// Encrypt a string with the global ENCRYPTION_KEY (ChaCha20-Poly1305).
34pub fn encrypt_with_key(plaintext: &str, key: &[u8; 32]) -> Result<String, String> {
35    use chacha20poly1305::aead::OsRng;
36    use chacha20poly1305::AeadCore;
37
38    let cipher = ChaCha20Poly1305::new(key.into());
39    let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
40
41    let ciphertext = cipher.encrypt(&nonce, plaintext.as_bytes())
42        .map_err(|e| format!("Encryption failed: {}", e))?;
43
44    // SIMD hex-encode nonce || ciphertext in one pass (matches maybe_encrypt_inner) —
45    // one buffer + one encode instead of two scalar hex::encode calls + a concat.
46    let mut buffer = Vec::with_capacity(nonce.len() + ciphertext.len());
47    buffer.extend_from_slice(&nonce);
48    buffer.extend_from_slice(&ciphertext);
49    Ok(crate::simd::hex::bytes_to_hex_string(&buffer))
50}
51
52/// Decrypt a hex-encoded ChaCha20-Poly1305 ciphertext with a key. Mirrors
53/// `maybe_decrypt_inner`'s fast path: SIMD hex decode, no plaintext copy, and
54/// `from_utf8_unchecked`.
55pub fn decrypt_with_key(hex_data: &str, key: &[u8; 32]) -> Result<String, String> {
56    let bytes = crate::simd::hex::hex_string_to_bytes(hex_data);
57    if bytes.len() < 12 {
58        return Err("Ciphertext too short".to_string());
59    }
60    let (nonce_bytes, ciphertext) = bytes.split_at(12);
61    let nonce_arr: [u8; 12] = nonce_bytes.try_into()
62        .map_err(|_| "Invalid nonce length".to_string())?;
63    let nonce = chacha20poly1305::Nonce::from(nonce_arr);
64    let cipher = ChaCha20Poly1305::new(key.into());
65
66    let plaintext = cipher.decrypt(&nonce, ciphertext)
67        .map_err(|_| "Decryption failed (wrong key or corrupted data)".to_string())?;
68
69    // SAFETY: the content was valid UTF-8 when encrypted and Poly1305 authenticates the
70    // ciphertext, so these bytes are exactly what was stored — no re-validation needed.
71    Ok(unsafe { String::from_utf8_unchecked(plaintext) })
72}
73
74/// Check if encryption is enabled in the database.
75///
76/// Delegates to `state::resolve_encryption_enabled_from_db` — the single
77/// source of truth that handles the missing-row case consistently. The
78/// previous implementation defaulted to `false` on missing rows, which
79/// silently disagreed with `init_encryption_enabled` (defaulted to `true`)
80/// and silently mis-routed login flows after an account swap.
81pub fn is_encryption_enabled() -> bool {
82    crate::state::resolve_encryption_enabled_from_db()
83}
84
85/// Simple hex encode/decode (for crypto module internal use).
86mod hex {
87    pub fn encode(bytes: &[u8]) -> String {
88        // SIMD hex encode (NEON/SSE2, fast-pathing 32/16-byte). Identical lowercase output to the
89        // old per-byte format! loop, but it runs on every encrypt (ciphertext + nonce).
90        crate::simd::hex::bytes_to_hex_string(bytes)
91    }
92
93    pub fn decode(hex: &str) -> Result<Vec<u8>, String> {
94        if hex.len() % 2 != 0 {
95            return Err("Odd-length hex string".to_string());
96        }
97        // SIMD-validated decode (NEON/SSE2 in-register hex validation). Runs on every message read
98        // (decrypt), so the recurring path stays fast; rejects non-hex like the old scalar loop.
99        crate::simd::hex::hex_string_to_bytes_checked(hex)
100            .ok_or_else(|| "Invalid hex character".to_string())
101    }
102}
103
104// ============================================================================
105// AES-256-GCM File Encryption (for NIP-96/Blossom attachments)
106// ============================================================================
107
108/// Parameters for AES-256-GCM file encryption (hex-encoded key + nonce).
109#[derive(Debug)]
110pub struct EncryptionParams {
111    pub key: String,   // 32-byte key as hex
112    pub nonce: String, // 16-byte nonce as hex (0xChat-compatible)
113}
114
115/// Generate random AES-256-GCM encryption parameters.
116pub fn generate_encryption_params() -> EncryptionParams {
117    use rand::Rng;
118    let mut rng = rand::thread_rng();
119    let mut key: [u8; 32] = rng.gen();
120    let nonce: [u8; 16] = rng.gen();
121    let params = EncryptionParams {
122        key: hex::encode(&key),
123        nonce: hex::encode(&nonce),
124    };
125    key.iter_mut().for_each(|b| *b = 0); // zeroize
126    params
127}
128
129/// Encrypt data with AES-256-GCM using a 16-byte nonce (0xChat-compatible).
130pub fn encrypt_data(data: &[u8], params: &EncryptionParams) -> Result<Vec<u8>, String> {
131    use aes::Aes256;
132    use aes::cipher::typenum::U16;
133    use aes_gcm::{AesGcm, AeadInPlace, KeyInit as AesKeyInit};
134
135    let key_bytes = hex::decode(&params.key).map_err(|e| format!("Invalid key: {}", e))?;
136    let nonce_bytes = hex::decode(&params.nonce).map_err(|e| format!("Invalid nonce: {}", e))?;
137
138    let cipher = AesGcm::<Aes256, U16>::new_from_slice(&key_bytes)
139        .map_err(|_| "Invalid encryption key".to_string())?;
140
141    let nonce_arr: [u8; 16] = nonce_bytes.try_into()
142        .map_err(|_| "Invalid nonce length".to_string())?;
143    let nonce = aes_gcm::Nonce::<U16>::from(nonce_arr);
144
145    let mut buffer = data.to_vec();
146    let tag = cipher.encrypt_in_place_detached(&nonce, &[], &mut buffer)
147        .map_err(|_| "Encryption failed".to_string())?;
148
149    buffer.extend_from_slice(&tag);
150    Ok(buffer)
151}
152
153/// Decrypt data with AES-256-GCM using a 16-byte nonce (0xChat-compatible).
154/// Input format: ciphertext || 16-byte auth tag.
155pub fn decrypt_data(encrypted_data: &[u8], key_hex: &str, nonce_hex: &str) -> Result<Vec<u8>, String> {
156    use aes::Aes256;
157    use aes::cipher::typenum::U16;
158    use aes_gcm::{AesGcm, AeadInPlace, KeyInit as AesKeyInit};
159
160    if encrypted_data.len() < 16 {
161        return Err(format!("Invalid Input: encrypted data too small ({} bytes, minimum 16 bytes required for authentication tag)", encrypted_data.len()));
162    }
163
164    let key_bytes = hex::decode(key_hex).map_err(|e| format!("Invalid key: {}", e))?;
165    let nonce_bytes = hex::decode(nonce_hex).map_err(|e| format!("Invalid nonce: {}", e))?;
166
167    let (ciphertext, tag_bytes) = encrypted_data.split_at(encrypted_data.len() - 16);
168
169    let cipher = AesGcm::<Aes256, U16>::new_from_slice(&key_bytes)
170        .map_err(|_| "Invalid decryption key".to_string())?;
171
172    let nonce_arr: [u8; 16] = nonce_bytes.try_into()
173        .map_err(|_| "Invalid nonce length".to_string())?;
174    let nonce = aes_gcm::Nonce::<U16>::from(nonce_arr);
175    let tag_arr: [u8; 16] = tag_bytes.try_into()
176        .map_err(|_| "Invalid tag length".to_string())?;
177    let tag = aes_gcm::Tag::<U16>::from(tag_arr);
178
179    let mut buffer = ciphertext.to_vec();
180    cipher.decrypt_in_place_detached(&nonce, &[], &mut buffer, &tag)
181        .map_err(|e| e.to_string())?;
182
183    Ok(buffer)
184}
185
186/// Calculate SHA-256 hash of data, returned as hex string.
187pub fn sha256_hex(data: &[u8]) -> String {
188    use sha2::{Sha256, Digest};
189    let mut hasher = Sha256::new();
190    hasher.update(data);
191    hex::encode(&hasher.finalize())
192}
193
194/// Identity basis for a RECEIVED attachment: the sender's `ox` (plaintext
195/// hash) when provided — it's what enables honest cross-message dedup — else
196/// a digest of nonce+url. Never the raw nonce: senders can and do reuse
197/// nonces, which cross-bound DIFFERENT files to one identity (the "new image
198/// renders as an old one" class). The upload URL is unique per ciphertext,
199/// so the digest is collision-resistant even against nonce reuse. Nothing
200/// honest ever writes a file under the digest name, so existence of one is
201/// never proof of download — reuse decisions stay with the content-verified
202/// download path.
203pub fn attachment_identity_basis(ox: Option<&str>, nonce: &str, url: &str) -> String {
204    match ox.filter(|h| !h.is_empty()) {
205        Some(h) => h.to_string(),
206        None => {
207            let mut buf = Vec::with_capacity(nonce.len() + 1 + url.len());
208            buf.extend_from_slice(nonce.as_bytes());
209            buf.push(0);
210            buf.extend_from_slice(url.as_bytes());
211            sha256_hex(&buf)
212        }
213    }
214}
215
216/// Get MIME type from file extension.
217pub fn mime_from_extension(ext: &str) -> &'static str {
218    match ext.to_lowercase().as_str() {
219        "png" => "image/png",
220        "jpg" | "jpeg" => "image/jpeg",
221        "gif" => "image/gif",
222        "webp" => "image/webp",
223        "svg" => "image/svg+xml",
224        "bmp" => "image/bmp",
225        "ico" => "image/x-icon",
226        "tiff" | "tif" => "image/tiff",
227        "dng" => "image/x-adobe-dng",
228        "cr2" => "image/x-canon-cr2",
229        "nef" => "image/x-nikon-nef",
230        "arw" => "image/x-sony-arw",
231        "mp4" => "video/mp4",
232        "webm" => "video/webm",
233        "mov" => "video/quicktime",
234        "avi" => "video/x-msvideo",
235        "mkv" => "video/x-matroska",
236        "flv" => "video/x-flv",
237        "wmv" => "video/x-ms-wmv",
238        "mpg" | "mpeg" => "video/mpeg",
239        "3gp" => "video/3gpp",
240        "ogv" => "video/ogg",
241        "ts" => "video/mp2t",
242        "mp3" => "audio/mpeg",
243        "ogg" => "audio/ogg",
244        "wav" => "audio/wav",
245        "flac" => "audio/flac",
246        "m4a" => "audio/mp4",
247        "aac" => "audio/aac",
248        "wma" => "audio/x-ms-wma",
249        "opus" => "audio/opus",
250        "pdf" => "application/pdf",
251        "doc" => "application/msword",
252        "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
253        "xls" => "application/vnd.ms-excel",
254        "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
255        "ppt" => "application/vnd.ms-powerpoint",
256        "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
257        "odt" => "application/vnd.oasis.opendocument.text",
258        "ods" => "application/vnd.oasis.opendocument.spreadsheet",
259        "odp" => "application/vnd.oasis.opendocument.presentation",
260        "rtf" => "application/rtf",
261        "txt" => "text/plain",
262        "md" => "text/markdown",
263        "csv" => "text/csv",
264        "json" => "application/json",
265        "xml" => "application/xml",
266        "yaml" | "yml" => "application/x-yaml",
267        "toml" => "application/toml",
268        "sql" => "application/sql",
269        "zip" => "application/zip",
270        "rar" => "application/vnd.rar",
271        "7z" => "application/x-7z-compressed",
272        "tar" => "application/x-tar",
273        "gz" => "application/gzip",
274        "bz2" => "application/x-bzip2",
275        "xz" => "application/x-xz",
276        "iso" => "application/x-iso9660-image",
277        "dmg" => "application/x-apple-diskimage",
278        "apk" => "application/vnd.android.package-archive",
279        "jar" => "application/java-archive",
280        "xdc" => "application/vnd.webxdc+zip",
281        "obj" => "model/obj",
282        "gltf" => "model/gltf+json",
283        "glb" => "model/gltf-binary",
284        "stl" => "model/stl",
285        "dae" => "model/vnd.collada+xml",
286        "js" => "text/javascript",
287        "py" => "text/x-python",
288        "rs" => "text/x-rust",
289        "go" => "text/x-go",
290        "java" => "text/x-java",
291        "c" => "text/x-c",
292        "cpp" => "text/x-c++",
293        "cs" => "text/x-csharp",
294        "rb" => "text/x-ruby",
295        "php" => "text/x-php",
296        "swift" => "text/x-swift",
297        "html" | "htm" => "text/html",
298        "css" => "text/css",
299        "exe" => "application/x-msdownload",
300        "msi" => "application/x-msi",
301        "ttf" => "font/ttf",
302        "otf" => "font/otf",
303        "woff" => "font/woff",
304        "woff2" => "font/woff2",
305        _ => "application/octet-stream",
306    }
307}
308
309/// Convert a MIME type to a file extension.
310/// Falls back to using the MIME subtype when unknown.
311pub fn extension_from_mime(mime: &str) -> String {
312    match mime.trim().to_lowercase().as_str() {
313        // Images
314        "image/png" => "png",
315        "image/jpeg" | "image/jpg" => "jpg",
316        "image/gif" => "gif",
317        "image/webp" => "webp",
318        "image/svg+xml" => "svg",
319        "image/bmp" | "image/x-ms-bmp" => "bmp",
320        "image/x-icon" | "image/vnd.microsoft.icon" => "ico",
321        "image/tiff" => "tiff",
322        "image/x-adobe-dng" => "dng",
323        "image/x-canon-cr2" => "cr2",
324        "image/x-nikon-nef" => "nef",
325        "image/x-sony-arw" => "arw",
326        // Audio
327        "audio/wav" | "audio/x-wav" | "audio/wave" => "wav",
328        "audio/mp3" | "audio/mpeg" => "mp3",
329        "audio/flac" => "flac",
330        "audio/ogg" => "ogg",
331        "audio/mp4" => "m4a",
332        "audio/aac" | "audio/x-aac" => "aac",
333        "audio/x-ms-wma" => "wma",
334        "audio/opus" => "opus",
335        // Video
336        "video/mp4" => "mp4",
337        "video/webm" => "webm",
338        "video/quicktime" => "mov",
339        "video/x-msvideo" => "avi",
340        "video/x-matroska" => "mkv",
341        "video/x-flv" => "flv",
342        "video/x-ms-wmv" => "wmv",
343        "video/mpeg" => "mpg",
344        "video/3gpp" => "3gp",
345        "video/ogg" => "ogv",
346        "video/mp2t" => "ts",
347        // Documents
348        "application/pdf" => "pdf",
349        "application/msword" => "doc",
350        "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
351        "application/vnd.ms-excel" => "xls",
352        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx",
353        "application/vnd.ms-powerpoint" => "ppt",
354        "application/vnd.openxmlformats-officedocument.presentationml.presentation" => "pptx",
355        "application/vnd.oasis.opendocument.text" => "odt",
356        "application/vnd.oasis.opendocument.spreadsheet" => "ods",
357        "application/vnd.oasis.opendocument.presentation" => "odp",
358        "application/rtf" => "rtf",
359        // Text/Data
360        "text/plain" => "txt",
361        "text/markdown" => "md",
362        "text/csv" => "csv",
363        "application/json" => "json",
364        "application/xml" | "text/xml" => "xml",
365        "application/x-yaml" | "text/yaml" => "yaml",
366        "application/toml" => "toml",
367        "application/sql" => "sql",
368        // Archives
369        "application/zip" => "zip",
370        "application/x-rar-compressed" | "application/vnd.rar" => "rar",
371        "application/x-7z-compressed" => "7z",
372        "application/x-tar" => "tar",
373        "application/gzip" => "gz",
374        "application/x-bzip2" => "bz2",
375        "application/x-xz" => "xz",
376        "application/x-iso9660-image" => "iso",
377        "application/x-apple-diskimage" => "dmg",
378        "application/vnd.android.package-archive" => "apk",
379        "application/java-archive" => "jar",
380        "application/vnd.webxdc+zip" => "xdc",
381        // 3D
382        "model/obj" => "obj",
383        "model/gltf+json" => "gltf",
384        "model/gltf-binary" => "glb",
385        "model/stl" | "application/sla" => "stl",
386        "model/vnd.collada+xml" => "dae",
387        // Code
388        "text/javascript" | "application/javascript" => "js",
389        "text/typescript" | "application/typescript" => "ts",
390        "text/x-python" | "application/x-python" => "py",
391        "text/x-rust" => "rs",
392        "text/x-go" => "go",
393        "text/x-java" => "java",
394        "text/x-c" => "c",
395        "text/x-c++" => "cpp",
396        "text/x-csharp" => "cs",
397        "text/x-ruby" => "rb",
398        "text/x-php" => "php",
399        "text/x-swift" => "swift",
400        // Web
401        "text/html" => "html",
402        "text/css" => "css",
403        // Other
404        "application/x-msdownload" | "application/x-dosexec" => "exe",
405        "application/x-msi" => "msi",
406        "application/x-font-ttf" | "font/ttf" => "ttf",
407        "application/x-font-otf" | "font/otf" => "otf",
408        "font/woff" => "woff",
409        "font/woff2" => "woff2",
410        // Fallback: extract subtype
411        _ => {
412            let lower = mime.trim().to_lowercase();
413            return lower.split('/').nth(1).unwrap_or("bin").to_string();
414        }
415    }.to_string()
416}
417
418/// Sanitize a filename for safe filesystem use.
419/// Strips path traversal, dangerous characters, and truncates to 64-char stem.
420pub fn sanitize_filename(name: &str) -> String {
421    let base = name.rsplit('/').next().unwrap_or(name);
422    let base = base.rsplit('\\').next().unwrap_or(base);
423
424    let sanitized: String = base.chars().filter(|c| {
425        !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0')
426    }).collect();
427
428    let sanitized = sanitized.trim_matches(|c: char| c == '.' || c == ' ');
429
430    if sanitized.is_empty() {
431        return String::new();
432    }
433
434    if let Some(dot_pos) = sanitized.rfind('.') {
435        let stem = &sanitized[..dot_pos];
436        let ext = &sanitized[dot_pos..];
437        if stem.len() > 64 {
438            let truncated = &stem[..stem.floor_char_boundary(64)];
439            return format!("{}{}", truncated, ext);
440        }
441    } else if sanitized.len() > 64 {
442        let truncated = &sanitized[..sanitized.floor_char_boundary(64)];
443        return truncated.to_string();
444    }
445
446    sanitized.to_string()
447}
448
449/// Resolve a unique filename in `dir`, appending `-1`, `-2`, etc. on collision.
450///
451/// If `dir/name` doesn't exist, returns it as-is. Otherwise increments a
452/// counter on the stem: `photo.jpg` → `photo-1.jpg` → `photo-2.jpg` ...
453pub fn resolve_unique_filename(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
454    let candidate = dir.join(name);
455    if !candidate.exists() {
456        return candidate;
457    }
458
459    let stem = std::path::Path::new(name)
460        .file_stem()
461        .and_then(|s| s.to_str())
462        .unwrap_or(name);
463    let ext = std::path::Path::new(name)
464        .extension()
465        .and_then(|s| s.to_str())
466        .unwrap_or("");
467
468    let mut counter = 1u32;
469    loop {
470        let suffixed = if ext.is_empty() {
471            format!("{}-{}", stem, counter)
472        } else {
473            format!("{}-{}.{}", stem, counter, ext)
474        };
475        let candidate = dir.join(&suffixed);
476        if !candidate.exists() {
477            return candidate;
478        }
479        counter += 1;
480    }
481}
482
483/// Decrypt a DM file attachment and save to the download directory.
484///
485/// Uses AES-GCM decryption with the key/nonce from the attachment metadata.
486/// Saves with atomic write (temp file + rename). Returns (path, content_hash).
487/// If an identical file already exists (same name + size + hash), reuses it.
488pub fn decrypt_and_save_attachment(
489    encrypted_data: &[u8],
490    key: &str,
491    nonce: &str,
492    name: &str,
493    extension: &str,
494) -> Result<(std::path::PathBuf, String), String> {
495    // Unencrypted foreign media (NIP-92 carries no decryption keys — those are
496    // Vector's own extension): the downloaded bytes ARE the plaintext, so skip
497    // AES-GCM and render best-effort. Hash/dedup/save below are identical either way.
498    let decrypted = if key.is_empty() || nonce.is_empty() {
499        encrypted_data.to_vec()
500    } else {
501        decrypt_data(encrypted_data, key, nonce)?
502    };
503    let file_hash = sha256_hex(&decrypted);
504
505    let dir = crate::db::get_download_dir();
506    std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?;
507
508    let target_name = if name.is_empty() {
509        format!("{}.{}", file_hash, extension)
510    } else {
511        name.to_string()
512    };
513
514    // Content dedup: reuse if same name + size + hash
515    let candidate = dir.join(&target_name);
516    let already_exists = candidate.exists()
517        && std::fs::metadata(&candidate).map(|m| m.len() == decrypted.len() as u64).unwrap_or(false)
518        && std::fs::read(&candidate).map(|b| sha256_hex(&b) == file_hash).unwrap_or(false);
519
520    if already_exists {
521        return Ok((candidate, file_hash));
522    }
523
524    let file_path = resolve_unique_filename(&dir, &target_name);
525
526    // Atomic write: temp file then rename
527    let tmp_path = dir.join(format!(".{}.{}.tmp", file_hash, extension));
528    std::fs::write(&tmp_path, &decrypted).map_err(|e| format!("Failed to write file: {}", e))?;
529    std::fs::rename(&tmp_path, &file_path).map_err(|e| format!("Failed to rename file: {}", e))?;
530
531    Ok((file_path, file_hash))
532}
533
534/// Format bytes into human-readable format (KB, MB, GB).
535pub fn format_bytes(bytes: u64) -> String {
536    const KB: f64 = 1024.0;
537    const MB: f64 = KB * 1024.0;
538    const GB: f64 = MB * 1024.0;
539
540    if bytes < KB as u64 {
541        format!("{} B", bytes)
542    } else if bytes < MB as u64 {
543        format!("{:.1} KB", bytes as f64 / KB)
544    } else if bytes < GB as u64 {
545        format!("{:.1} MB", bytes as f64 / MB)
546    } else {
547        format!("{:.1} GB", bytes as f64 / GB)
548    }
549}
550
551/// Returns true if the provided MIME type is an image/*.
552pub fn is_image_mime(mime: &str) -> bool {
553    mime.trim().starts_with("image/")
554}
555
556/// Convert a file extension to a MIME type, with an optional restriction to image/* types.
557pub fn mime_from_extension_safe(extension: &str, image_only: bool) -> Result<String, String> {
558    let mime = mime_from_extension(extension).to_string();
559    if image_only && !is_image_mime(&mime) {
560        return Err(mime);
561    }
562    Ok(mime)
563}
564
565/// Detect MIME type from file magic bytes.
566/// Supports PNG, JPEG, GIF, WebP, TIFF, ICO, and SVG.
567/// Returns "application/octet-stream" for unrecognized formats.
568pub fn mime_from_magic_bytes(bytes: &[u8]) -> &'static str {
569    if bytes.len() < 4 {
570        return "application/octet-stream";
571    }
572    match bytes[0] {
573        0x89 if bytes[1..4] == [0x50, 0x4E, 0x47] => "image/png",
574        0xFF if bytes[1..3] == [0xD8, 0xFF] => "image/jpeg",
575        b'G' if bytes.len() >= 6 && (bytes[..6] == *b"GIF87a" || bytes[..6] == *b"GIF89a") => "image/gif",
576        b'R' if bytes.len() > 12 && bytes[..4] == *b"RIFF" && bytes[8..12] == *b"WEBP" => "image/webp",
577        0x49 if bytes[1..4] == [0x49, 0x2A, 0x00] => "image/tiff",
578        0x4D if bytes[1..4] == [0x4D, 0x00, 0x2A] => "image/tiff",
579        0x00 if bytes[1..4] == [0x00, 0x01, 0x00] => "image/x-icon",
580        b'<' if bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") => "image/svg+xml",
581        _ => "application/octet-stream",
582    }
583}
584
585// ============================================================================
586// Conditional Encryption — maybe_encrypt / maybe_decrypt
587// ============================================================================
588
589use rand::Rng;
590use chacha20poly1305::Nonce;
591
592/// Check if a string looks like encrypted content (hex-encoded ChaCha20 output).
593/// Minimum (empty message): 12 + 0 + 16 = 28 bytes = 56 hex chars.
594#[inline]
595pub fn looks_encrypted(s: &str) -> bool {
596    if s.len() < 56 { return false; }
597    is_all_lowercase_hex(s.as_bytes())
598}
599
600/// NEON: check if all bytes are lowercase hex [0-9a-f].
601#[cfg(target_arch = "aarch64")]
602#[inline]
603fn is_all_lowercase_hex(bytes: &[u8]) -> bool {
604    use std::arch::aarch64::*;
605    unsafe {
606        let mut i = 0;
607        while i + 16 <= bytes.len() {
608            let chunk = vld1q_u8(bytes.as_ptr().add(i));
609            let is_digit = vandq_u8(vcgeq_u8(chunk, vdupq_n_u8(b'0')),
610                                    vcleq_u8(chunk, vdupq_n_u8(b'9')));
611            let is_af = vandq_u8(vcgeq_u8(chunk, vdupq_n_u8(b'a')),
612                                 vcleq_u8(chunk, vdupq_n_u8(b'f')));
613            if vminvq_u8(vorrq_u8(is_digit, is_af)) == 0 { return false; }
614            i += 16;
615        }
616        while i < bytes.len() {
617            let b = bytes[i];
618            if !matches!(b, b'0'..=b'9' | b'a'..=b'f') { return false; }
619            i += 1;
620        }
621    }
622    true
623}
624
625/// SSE2: check if all bytes are lowercase hex [0-9a-f].
626#[cfg(target_arch = "x86_64")]
627#[inline]
628fn is_all_lowercase_hex(bytes: &[u8]) -> bool {
629    use std::arch::x86_64::*;
630    unsafe {
631        let mut i = 0;
632        while i + 16 <= bytes.len() {
633            let chunk = _mm_loadu_si128(bytes.as_ptr().add(i) as *const __m128i);
634            let is_digit = _mm_cmpeq_epi8(
635                _mm_subs_epu8(_mm_sub_epi8(chunk, _mm_set1_epi8(b'0' as i8)), _mm_set1_epi8(9)),
636                _mm_setzero_si128());
637            let is_af = _mm_cmpeq_epi8(
638                _mm_subs_epu8(_mm_sub_epi8(chunk, _mm_set1_epi8(b'a' as i8)), _mm_set1_epi8(5)),
639                _mm_setzero_si128());
640            if _mm_movemask_epi8(_mm_or_si128(is_digit, is_af)) != 0xFFFF { return false; }
641            i += 16;
642        }
643        while i < bytes.len() {
644            let b = bytes[i];
645            if !matches!(b, b'0'..=b'9' | b'a'..=b'f') { return false; }
646            i += 1;
647        }
648    }
649    true
650}
651
652/// Scalar fallback for platforms without SIMD.
653#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
654#[inline]
655fn is_all_lowercase_hex(bytes: &[u8]) -> bool {
656    const IS_LOWER_HEX: [bool; 256] = {
657        let mut t = [false; 256];
658        t[b'0' as usize] = true; t[b'1' as usize] = true; t[b'2' as usize] = true;
659        t[b'3' as usize] = true; t[b'4' as usize] = true; t[b'5' as usize] = true;
660        t[b'6' as usize] = true; t[b'7' as usize] = true; t[b'8' as usize] = true;
661        t[b'9' as usize] = true; t[b'a' as usize] = true; t[b'b' as usize] = true;
662        t[b'c' as usize] = true; t[b'd' as usize] = true; t[b'e' as usize] = true;
663        t[b'f' as usize] = true;
664        t
665    };
666    bytes.iter().all(|&b| IS_LOWER_HEX[b as usize])
667}
668
669/// Encrypt a string using ENCRYPTION_KEY vault (ChaCha20-Poly1305).
670/// If `password` is Some, derives a key from it instead.
671pub async fn maybe_encrypt_inner(mut input: String, password: Option<String>) -> String {
672    let mut key: [u8; 32] = if password.is_none() {
673        crate::state::ENCRYPTION_KEY.get().expect("Encryption key must be set")
674    } else {
675        hash_pass(&password.unwrap()).await
676    };
677
678    let mut rng = rand::thread_rng();
679    let nonce_bytes: [u8; 12] = rng.gen();
680
681    let cipher = ChaCha20Poly1305::new_from_slice(&key)
682        .expect("Key should be valid");
683    let nonce: Nonce = nonce_bytes.into();
684
685    let ciphertext = cipher
686        .encrypt(&nonce, input.as_bytes())
687        .expect("Encryption should not fail");
688    input.zeroize();
689
690    let mut buffer = Vec::with_capacity(nonce_bytes.len() + ciphertext.len());
691    buffer.extend_from_slice(&nonce_bytes);
692    buffer.extend_from_slice(&ciphertext);
693
694    if !crate::state::ENCRYPTION_KEY.has_key() {
695        crate::state::ENCRYPTION_KEY.set(key, &[&crate::state::MY_SECRET_KEY]);
696    }
697
698    key.zeroize();
699
700    crate::simd::hex::bytes_to_hex_string(&buffer)
701}
702
703/// Decrypt a hex-encoded ChaCha20-Poly1305 ciphertext using ENCRYPTION_KEY vault.
704/// If `password` is Some, derives a key from it instead.
705pub async fn maybe_decrypt_inner(ciphertext: String, password: Option<String>) -> Result<String, ()> {
706    let has_password = password.is_some();
707
708    let mut key: [u8; 32] = if let Some(pass) = password {
709        hash_pass(&pass).await
710    } else {
711        match crate::state::ENCRYPTION_KEY.get() {
712            Some(k) => k,
713            None => return Err(()),
714        }
715    };
716
717    let encrypted_data = crate::simd::hex::hex_string_to_bytes(ciphertext.as_str());
718    if encrypted_data.len() < 12 {
719        key.zeroize();
720        return Err(());
721    }
722
723    let (nonce_bytes, actual_ciphertext) = encrypted_data.split_at(12);
724
725    let cipher = match ChaCha20Poly1305::new_from_slice(&key) {
726        Ok(c) => c,
727        Err(_) => { key.zeroize(); return Err(()) }
728    };
729
730    let nonce_arr: [u8; 12] = match nonce_bytes.try_into() {
731        Ok(n) => n,
732        Err(_) => { key.zeroize(); return Err(()) }
733    };
734    let nonce: Nonce = nonce_arr.into();
735    let plaintext = match cipher.decrypt(&nonce, actual_ciphertext) {
736        Ok(pt) => pt,
737        Err(_) => { key.zeroize(); return Err(()) }
738    };
739
740    if has_password && !crate::state::ENCRYPTION_KEY.has_key() {
741        crate::state::ENCRYPTION_KEY.set(key, &[&crate::state::MY_SECRET_KEY]);
742    }
743
744    key.zeroize();
745
746    // SAFETY: plaintext was originally valid UTF-8, authenticated decryption ensures integrity
747    unsafe { Ok(String::from_utf8_unchecked(plaintext)) }
748}
749
750/// Conditionally encrypt content based on encryption_enabled setting.
751pub async fn maybe_encrypt(input: String) -> String {
752    if crate::state::is_encryption_enabled_fast() {
753        maybe_encrypt_inner(input, None).await
754    } else {
755        input
756    }
757}
758
759/// Conditionally decrypt content. Handles crash recovery — if decryption fails
760/// on non-encrypted-looking content, returns it as-is.
761pub async fn maybe_decrypt(input: String) -> Result<String, ()> {
762    if crate::state::is_encryption_enabled_fast() {
763        match maybe_decrypt_inner(input.clone(), None).await {
764            Ok(decrypted) => Ok(decrypted),
765            Err(_) => {
766                if looks_encrypted(&input) { Err(()) } else { Ok(input) }
767            }
768        }
769    } else {
770        if looks_encrypted(&input) {
771            match maybe_decrypt_inner(input.clone(), None).await {
772                Ok(decrypted) => Ok(decrypted),
773                Err(_) => Ok(input),
774            }
775        } else {
776            Ok(input)
777        }
778    }
779}
780
781// ============================================================================
782// Synchronous at-rest helpers (Concord tables, sync DB code)
783// ============================================================================
784//
785// `maybe_encrypt`/`maybe_decrypt` are async (they may derive a key from a
786// password). The Concord DB layer is synchronous and only ever uses the live
787// ENCRYPTION_KEY vault, so these sync variants wrap the field-level primitives
788// against the vault + the enabled flag. Discriminators for the half-migrated
789// case: a key BLOB is 32 bytes raw vs 12+len+16 encrypted; a text field uses
790// `looks_encrypted` (>=56 lowercase-hex chars).
791
792/// ChaCha20-Poly1305 encrypt raw bytes with an explicit key → `nonce(12) || ct || tag(16)`.
793pub fn encrypt_blob_with_key(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, String> {
794    use rand::Rng;
795    let cipher = ChaCha20Poly1305::new_from_slice(key).map_err(|e| e.to_string())?;
796    let nonce_bytes: [u8; 12] = rand::thread_rng().gen();
797    let nonce = chacha20poly1305::Nonce::from(nonce_bytes);
798    let ct = cipher
799        .encrypt(&nonce, plaintext)
800        .map_err(|e| format!("blob encryption failed: {}", e))?;
801    let mut out = Vec::with_capacity(12 + ct.len());
802    out.extend_from_slice(&nonce_bytes);
803    out.extend_from_slice(&ct);
804    Ok(out)
805}
806
807/// ChaCha20-Poly1305 decrypt `nonce(12) || ct || tag(16)` with an explicit key.
808pub fn decrypt_blob_with_key(stored: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, String> {
809    if stored.len() < 12 + 16 {
810        return Err("blob too short to be ciphertext".to_string());
811    }
812    let cipher = ChaCha20Poly1305::new_from_slice(key).map_err(|e| e.to_string())?;
813    let (nonce_bytes, ct) = stored.split_at(12);
814    let nonce_arr: [u8; 12] = nonce_bytes.try_into().map_err(|_| "bad nonce".to_string())?;
815    let nonce = chacha20poly1305::Nonce::from(nonce_arr);
816    cipher
817        .decrypt(&nonce, ct)
818        .map_err(|_| "blob decryption failed (wrong key or corrupted)".to_string())
819}
820
821/// Encrypt a secret key BLOB for at-rest storage. Off → unchanged. Enabled but
822/// the vault is empty → Err (never silently persists a secret in plaintext).
823pub fn maybe_encrypt_blob(plaintext: &[u8]) -> Result<Vec<u8>, String> {
824    if !crate::state::is_encryption_enabled_fast() {
825        return Ok(plaintext.to_vec());
826    }
827    let mut key = crate::state::ENCRYPTION_KEY
828        .get()
829        .ok_or_else(|| "encryption enabled but key vault is empty".to_string())?;
830    let out = encrypt_blob_with_key(plaintext, &key);
831    key.zeroize();
832    out
833}
834
835/// Decrypt a secret key BLOB. Tolerant of the half-migrated DB: a 32-byte value
836/// is a raw (not-yet-wrapped / encryption-off) key, and anything that fails to
837/// authenticate is returned as-is, so a mixed store always reads back correctly.
838pub fn maybe_decrypt_blob(stored: &[u8]) -> Vec<u8> {
839    // 32 bytes = a raw key (encryption off, or a row written before the at-rest pass).
840    if stored.len() == 32 {
841        return stored.to_vec();
842    }
843    match crate::state::ENCRYPTION_KEY.get() {
844        Some(mut key) => {
845            let out = decrypt_blob_with_key(stored, &key).unwrap_or_else(|_| stored.to_vec());
846            key.zeroize();
847            out
848        }
849        None => stored.to_vec(),
850    }
851}
852
853/// Encrypt a text field for at-rest storage. Off → unchanged. Enabled but the
854/// vault is empty → Err.
855pub fn maybe_encrypt_text(plaintext: &str) -> Result<String, String> {
856    if !crate::state::is_encryption_enabled_fast() {
857        return Ok(plaintext.to_string());
858    }
859    let mut key = crate::state::ENCRYPTION_KEY
860        .get()
861        .ok_or_else(|| "encryption enabled but key vault is empty".to_string())?;
862    let out = encrypt_with_key(plaintext, &key);
863    key.zeroize();
864    out
865}
866
867/// Decrypt a text field. A value that doesn't look encrypted (or can't be
868/// decrypted) is returned as-is — pre-migration rows and encryption-off rows.
869pub fn maybe_decrypt_text(stored: &str) -> String {
870    if !looks_encrypted(stored) {
871        return stored.to_string();
872    }
873    match crate::state::ENCRYPTION_KEY.get() {
874        Some(mut key) => {
875            let out = decrypt_with_key(stored, &key).unwrap_or_else(|_| stored.to_string());
876            key.zeroize();
877            out
878        }
879        None => stored.to_string(),
880    }
881}
882
883#[cfg(test)]
884mod at_rest_tests {
885    use super::*;
886
887    #[test]
888    fn blob_roundtrip_with_explicit_key() {
889        let key = [7u8; 32];
890        let secret = [0x42u8; 32];
891        let ct = encrypt_blob_with_key(&secret, &key).unwrap();
892        assert_eq!(ct.len(), 12 + 32 + 16, "nonce + ciphertext + tag");
893        assert_ne!(&ct[12..44], &secret[..], "ciphertext must not equal plaintext");
894        assert_eq!(decrypt_blob_with_key(&ct, &key).unwrap(), secret.to_vec());
895    }
896
897    #[test]
898    fn blob_wrong_key_fails() {
899        let ct = encrypt_blob_with_key(&[1u8; 32], &[7u8; 32]).unwrap();
900        assert!(decrypt_blob_with_key(&ct, &[9u8; 32]).is_err());
901    }
902
903    #[test]
904    fn encrypted_text_is_always_detected_as_encrypted() {
905        // Even the shortest fields ("[]", "{}", "") must exceed the looks_encrypted floor
906        // so they round-trip through maybe_decrypt_text.
907        let key = [3u8; 32];
908        for s in ["", "[]", "{}", "a"] {
909            let ct = encrypt_with_key(s, &key).unwrap();
910            assert!(looks_encrypted(&ct), "ciphertext for {:?} must look encrypted", s);
911            assert_eq!(decrypt_with_key(&ct, &key).unwrap(), s);
912        }
913    }
914}
915
916// ============================================================================
917// Image Metadata — thumbhash + dimensions for file attachments
918// ============================================================================
919
920/// Re-export SIMD nearest-neighbor downsample from simd::image.
921pub use crate::simd::image::nearest_neighbor_downsample_rgba;
922
923/// Generate a thumbhash from RGBA8 pixel data.
924///
925/// Downscales to fit within 100x100 (ThumbHash's max) using fast nearest-neighbor,
926/// then hashes. Returns the base91-encoded thumbhash string.
927pub fn generate_thumbhash_from_rgba(pixels: &[u8], width: u32, height: u32) -> Option<String> {
928    use fast_thumbhash::{rgba_to_thumb_hash, base91_encode};
929
930    const MAX_DIM: u32 = 100;
931
932    let (thumb_w, thumb_h) = if width <= MAX_DIM && height <= MAX_DIM {
933        (width, height)
934    } else if width > height {
935        (MAX_DIM, (MAX_DIM * height / width).max(1))
936    } else {
937        ((MAX_DIM * width / height).max(1), MAX_DIM)
938    };
939
940    let thumbnail = if thumb_w == width && thumb_h == height {
941        pixels.to_vec()
942    } else {
943        nearest_neighbor_downsample_rgba(pixels, width, height, thumb_w, thumb_h)
944    };
945
946    let hash = rgba_to_thumb_hash(thumb_w as usize, thumb_h as usize, &thumbnail);
947    Some(base91_encode(&hash))
948}
949
950/// Decode an image with allocation limits. A tiny file declaring 60000×60000
951/// would otherwise allocate ~14 GB before a single pixel decodes — every
952/// decode of bytes we didn't author must go through this.
953///
954/// Applies EXIF orientation to the pixels. Camera photos store their pixels in
955/// the sensor's native axis plus an orientation tag; `<img>` honours that tag,
956/// but our re-encode strips all metadata, so an un-baked decode would ship the
957/// sensor pixels with no tag and the receiver sees the photo rotated. Baking it
958/// in here keeps every downstream path (compress, thumbhash, dimensions) upright.
959pub fn decode_image_bounded(bytes: &[u8]) -> Result<image::DynamicImage, String> {
960    use image::ImageDecoder;
961    let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes))
962        .with_guessed_format()
963        .map_err(|e| format!("image format: {e}"))?;
964    reader.limits(bounded_image_limits());
965    let mut decoder = reader.into_decoder().map_err(|e| format!("image decode: {e}"))?;
966    // Read the tag before consuming the decoder; formats without one report NoTransforms.
967    let orientation = decoder.orientation().map_err(|e| format!("image orientation: {e}"))?;
968    let mut img = image::DynamicImage::from_decoder(decoder)
969        .map_err(|e| format!("image decode: {e}"))?;
970    img.apply_orientation(orientation);
971    Ok(img)
972}
973
974/// Shared decode limits for [`decode_image_bounded`] and call sites that need
975/// their own `ImageReader` (fixed-format decodes).
976pub fn bounded_image_limits() -> image::Limits {
977    let mut limits = image::Limits::default();
978    limits.max_image_width = Some(16_384);
979    limits.max_image_height = Some(16_384);
980    limits.max_alloc = Some(256 * 1024 * 1024);
981    limits
982}
983
984/// Generate image metadata (thumbhash + dimensions) from raw file bytes.
985///
986/// Returns None if the bytes can't be decoded as an image.
987/// Used by `send_file_dm` to automatically include preview metadata for images.
988pub fn generate_image_metadata(file_bytes: &[u8]) -> Option<crate::types::ImageMetadata> {
989    let img = decode_image_bounded(file_bytes).ok()?;
990    let width = img.width();
991    let height = img.height();
992
993    let rgba = img.to_rgba8();
994    let thumbhash = generate_thumbhash_from_rgba(rgba.as_raw(), width, height)?;
995
996    Some(crate::types::ImageMetadata {
997        thumbhash,
998        width,
999        height,
1000    })
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005    use super::*;
1006
1007    // ========================================================================
1008    // encrypt_with_key / decrypt_with_key roundtrip tests
1009    // ========================================================================
1010
1011    fn test_key() -> [u8; 32] {
1012        [0x42u8; 32]
1013    }
1014
1015    fn alt_key() -> [u8; 32] {
1016        [0x99u8; 32]
1017    }
1018
1019    #[test]
1020    fn encrypt_decrypt_roundtrip_simple() {
1021        let key = test_key();
1022        let plaintext = "hello world";
1023        let encrypted = encrypt_with_key(plaintext, &key).expect("encryption should succeed");
1024        let decrypted = decrypt_with_key(&encrypted, &key).expect("decryption should succeed");
1025        assert_eq!(decrypted, plaintext, "roundtrip should preserve plaintext");
1026    }
1027
1028    #[test]
1029    fn encrypt_decrypt_100_random_strings() {
1030        use rand::Rng;
1031        let key = test_key();
1032        let mut rng = rand::thread_rng();
1033        for i in 0..100 {
1034            let len = rng.gen_range(1..=200);
1035            let s: String = (0..len).map(|_| rng.gen_range(0x20u8..0x7f) as char).collect();
1036            let enc = encrypt_with_key(&s, &key)
1037                .unwrap_or_else(|e| panic!("encryption failed on iteration {}: {}", i, e));
1038            let dec = decrypt_with_key(&enc, &key)
1039                .unwrap_or_else(|e| panic!("decryption failed on iteration {}: {}", i, e));
1040            assert_eq!(dec, s, "roundtrip failed on iteration {}", i);
1041        }
1042    }
1043
1044    #[test]
1045    fn encrypt_decrypt_empty_string() {
1046        let key = test_key();
1047        let encrypted = encrypt_with_key("", &key).expect("encrypting empty string should succeed");
1048        let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting empty string should succeed");
1049        assert_eq!(decrypted, "", "empty string roundtrip should produce empty string");
1050    }
1051
1052    #[test]
1053    fn encrypt_decrypt_large_string() {
1054        let key = test_key();
1055        let plaintext = "A".repeat(10 * 1024); // 10 KB
1056        let encrypted = encrypt_with_key(&plaintext, &key).expect("encrypting 10KB should succeed");
1057        let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting 10KB should succeed");
1058        assert_eq!(decrypted, plaintext, "10KB roundtrip should preserve content");
1059    }
1060
1061    #[test]
1062    fn decrypt_with_wrong_key_fails() {
1063        let key = test_key();
1064        let wrong_key = alt_key();
1065        let encrypted = encrypt_with_key("secret data", &key).expect("encryption should succeed");
1066        let result = decrypt_with_key(&encrypted, &wrong_key);
1067        assert!(result.is_err(), "decryption with wrong key should fail");
1068    }
1069
1070    #[test]
1071    fn decrypt_corrupted_ciphertext_fails() {
1072        let key = test_key();
1073        let mut encrypted = encrypt_with_key("secret data", &key).expect("encryption should succeed");
1074        // Corrupt a byte in the ciphertext portion (past the 24-char nonce)
1075        let bytes: Vec<u8> = encrypted.bytes().collect();
1076        if bytes.len() > 30 {
1077            let mut chars: Vec<char> = encrypted.chars().collect();
1078            // Flip a hex digit in the ciphertext area
1079            chars[30] = if chars[30] == '0' { 'f' } else { '0' };
1080            encrypted = chars.into_iter().collect();
1081        }
1082        let result = decrypt_with_key(&encrypted, &key);
1083        assert!(result.is_err(), "decryption of corrupted ciphertext should fail");
1084    }
1085
1086    #[test]
1087    fn different_keys_produce_different_ciphertext() {
1088        let key1 = test_key();
1089        let key2 = alt_key();
1090        let plaintext = "same plaintext";
1091        let enc1 = encrypt_with_key(plaintext, &key1).expect("enc1 should succeed");
1092        let enc2 = encrypt_with_key(plaintext, &key2).expect("enc2 should succeed");
1093        // Ciphertexts after the nonce portion should differ (nonces differ too since random)
1094        assert_ne!(enc1, enc2, "different keys should produce different ciphertext");
1095    }
1096
1097    #[test]
1098    fn nonce_is_always_different() {
1099        let key = test_key();
1100        let plaintext = "same string encrypted twice";
1101        let enc1 = encrypt_with_key(plaintext, &key).expect("enc1 should succeed");
1102        let enc2 = encrypt_with_key(plaintext, &key).expect("enc2 should succeed");
1103        // The first 24 hex chars are the nonce
1104        let nonce1 = &enc1[..24];
1105        let nonce2 = &enc2[..24];
1106        assert_ne!(nonce1, nonce2, "nonces should differ between encryptions of the same plaintext");
1107    }
1108
1109    #[test]
1110    fn unicode_content_preserved() {
1111        let key = test_key();
1112        let plaintext = "Hello \u{1F600} \u{1F4A9} \u{1F30D} \u{00E9}\u{00E0}\u{00FC} \u{4E16}\u{754C} \u{0410}\u{0411}\u{0412}";
1113        let encrypted = encrypt_with_key(plaintext, &key).expect("encrypting unicode should succeed");
1114        let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting unicode should succeed");
1115        assert_eq!(decrypted, plaintext, "unicode content should be preserved through encrypt/decrypt");
1116    }
1117
1118    #[test]
1119    fn decrypt_too_short_ciphertext_fails() {
1120        let key = test_key();
1121        let result = decrypt_with_key("abcdef", &key);
1122        assert!(result.is_err(), "ciphertext shorter than 24 hex chars should fail");
1123        assert!(result.unwrap_err().contains("too short"), "error should mention too short");
1124    }
1125
1126    #[test]
1127    fn encrypt_output_is_hex_encoded() {
1128        let key = test_key();
1129        let encrypted = encrypt_with_key("test", &key).expect("encryption should succeed");
1130        assert!(encrypted.chars().all(|c| c.is_ascii_hexdigit()),
1131            "encrypted output should be entirely hex characters");
1132    }
1133
1134    #[test]
1135    fn encrypt_output_has_correct_structure() {
1136        let key = test_key();
1137        let encrypted = encrypt_with_key("test", &key).expect("encryption should succeed");
1138        // Must be at least 24 hex chars (nonce) + some ciphertext
1139        assert!(encrypted.len() > 24,
1140            "encrypted output should have nonce (24 hex chars) plus ciphertext");
1141        // Length should be even (hex pairs)
1142        assert_eq!(encrypted.len() % 2, 0,
1143            "encrypted output length should be even (hex pairs)");
1144    }
1145
1146    #[test]
1147    fn encrypt_decrypt_special_characters() {
1148        let key = test_key();
1149        let plaintext = r#"!@#$%^&*()_+-=[]{}|;':",.<>?/\`~"#;
1150        let encrypted = encrypt_with_key(plaintext, &key).expect("encrypting special chars should succeed");
1151        let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting special chars should succeed");
1152        assert_eq!(decrypted, plaintext, "special characters should survive roundtrip");
1153    }
1154
1155    #[test]
1156    fn encrypt_decrypt_newlines_and_whitespace() {
1157        let key = test_key();
1158        let plaintext = "line1\nline2\r\nline3\ttab\0null";
1159        let encrypted = encrypt_with_key(plaintext, &key).expect("encrypting whitespace should succeed");
1160        let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting whitespace should succeed");
1161        assert_eq!(decrypted, plaintext, "whitespace and control chars should survive roundtrip");
1162    }
1163
1164    #[test]
1165    fn decrypt_invalid_hex_in_nonce_fails() {
1166        let key = test_key();
1167        // 24 chars of invalid hex + some ciphertext
1168        let bad = "zzzzzzzzzzzzzzzzzzzzzzzz0000000000000000";
1169        let result = decrypt_with_key(bad, &key);
1170        assert!(result.is_err(), "invalid hex in nonce should fail");
1171    }
1172
1173    #[test]
1174    fn decrypt_invalid_hex_in_ciphertext_fails() {
1175        let key = test_key();
1176        // Valid nonce (24 hex chars) + invalid ciphertext hex
1177        let bad = "000000000000000000000000gggggggg";
1178        let result = decrypt_with_key(bad, &key);
1179        assert!(result.is_err(), "invalid hex in ciphertext should fail");
1180    }
1181
1182    // ========================================================================
1183    // hex module tests
1184    // ========================================================================
1185
1186    #[test]
1187    fn hex_encode_decode_roundtrip() {
1188        let data = vec![0x00, 0x01, 0xff, 0x80, 0x7f];
1189        let encoded = hex::encode(&data);
1190        let decoded = hex::decode(&encoded).expect("decode should succeed");
1191        assert_eq!(decoded, data, "hex encode/decode roundtrip should preserve bytes");
1192    }
1193
1194    #[test]
1195    fn hex_decode_odd_length_error() {
1196        let result = hex::decode("abc");
1197        assert!(result.is_err(), "odd-length hex string should fail");
1198        assert!(result.unwrap_err().contains("Odd-length"), "error should mention odd-length");
1199    }
1200
1201    #[test]
1202    fn hex_decode_invalid_hex_error() {
1203        let result = hex::decode("zzzz");
1204        assert!(result.is_err(), "invalid hex characters should fail");
1205    }
1206
1207    #[test]
1208    fn hex_encode_empty() {
1209        assert_eq!(hex::encode(&[]), "", "encoding empty bytes should produce empty string");
1210    }
1211
1212    #[test]
1213    fn hex_decode_empty() {
1214        let decoded = hex::decode("").expect("decoding empty string should succeed");
1215        assert!(decoded.is_empty(), "decoding empty string should produce empty vec");
1216    }
1217
1218    // ========================================================================
1219    // hash_pass tests
1220    // ========================================================================
1221
1222    #[tokio::test]
1223    async fn hash_pass_deterministic() {
1224        let key1 = hash_pass("my_password").await;
1225        let key2 = hash_pass("my_password").await;
1226        assert_eq!(key1, key2, "same password should always produce the same key");
1227    }
1228
1229    #[tokio::test]
1230    async fn hash_pass_different_passwords_different_keys() {
1231        let key1 = hash_pass("password_one").await;
1232        let key2 = hash_pass("password_two").await;
1233        assert_ne!(key1, key2, "different passwords should produce different keys");
1234    }
1235
1236    #[tokio::test]
1237    async fn hash_pass_output_is_32_bytes() {
1238        let key = hash_pass("test_password").await;
1239        assert_eq!(key.len(), 32, "hash_pass should produce exactly 32 bytes");
1240        // Ensure it is not all zeros (i.e. hashing actually happened)
1241        assert!(key.iter().any(|&b| b != 0), "hash output should not be all zeros");
1242    }
1243
1244    #[test]
1245    fn generate_thumbhash_from_bytes_basic() {
1246        // 2x2 red pixel image in RGBA
1247        let pixels: Vec<u8> = vec![
1248            255, 0, 0, 255,  255, 0, 0, 255,
1249            255, 0, 0, 255,  255, 0, 0, 255,
1250        ];
1251        let result = super::generate_thumbhash_from_rgba(&pixels, 2, 2);
1252        assert!(result.is_some());
1253        assert!(!result.unwrap().is_empty());
1254    }
1255
1256    #[test]
1257    fn generate_image_metadata_from_bytes() {
1258        // Create a 4x4 blue PNG in memory
1259        let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 255, 255]));
1260        let mut buf = Vec::new();
1261        let mut cursor = std::io::Cursor::new(&mut buf);
1262        img.write_to(&mut cursor, image::ImageFormat::Png).unwrap();
1263
1264        let meta = super::generate_image_metadata(&buf);
1265        assert!(meta.is_some());
1266        let meta = meta.unwrap();
1267        assert_eq!(meta.width, 4);
1268        assert_eq!(meta.height, 4);
1269        assert!(!meta.thumbhash.is_empty());
1270    }
1271
1272    #[test]
1273    fn generate_image_metadata_non_image() {
1274        let text_bytes = b"this is not an image";
1275        let meta = super::generate_image_metadata(text_bytes);
1276        assert!(meta.is_none());
1277    }
1278}