Skip to content

Commit 608e2dd

Browse files
JSKittyclaude
andcommitted
refactor: move DM attachment decrypt+save to vector-core
Add decrypt_and_save_attachment to vector-core crypto — handles DM file decryption (AES-GCM), content dedup, collision resolution, and atomic write to get_download_dir(). MLS path (MDK MIP-04) stays in src-tauri. CLI/SDK can now download and decrypt file attachments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b856a32 commit 608e2dd

3 files changed

Lines changed: 69 additions & 51 deletions

File tree

crates/vector-core/src/crypto/mod.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,50 @@ pub fn resolve_unique_filename(dir: &std::path::Path, name: &str) -> std::path::
461461
}
462462
}
463463

464+
/// Decrypt a DM file attachment and save to the download directory.
465+
///
466+
/// Uses AES-GCM decryption with the key/nonce from the attachment metadata.
467+
/// Saves with atomic write (temp file + rename). Returns (path, content_hash).
468+
/// If an identical file already exists (same name + size + hash), reuses it.
469+
pub fn decrypt_and_save_attachment(
470+
encrypted_data: &[u8],
471+
key: &str,
472+
nonce: &str,
473+
name: &str,
474+
extension: &str,
475+
) -> Result<(std::path::PathBuf, String), String> {
476+
let decrypted = decrypt_data(encrypted_data, key, nonce)?;
477+
let file_hash = sha256_hex(&decrypted);
478+
479+
let dir = crate::db::get_download_dir();
480+
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?;
481+
482+
let target_name = if name.is_empty() {
483+
format!("{}.{}", file_hash, extension)
484+
} else {
485+
name.to_string()
486+
};
487+
488+
// Content dedup: reuse if same name + size + hash
489+
let candidate = dir.join(&target_name);
490+
let already_exists = candidate.exists()
491+
&& std::fs::metadata(&candidate).map(|m| m.len() == decrypted.len() as u64).unwrap_or(false)
492+
&& std::fs::read(&candidate).map(|b| sha256_hex(&b) == file_hash).unwrap_or(false);
493+
494+
if already_exists {
495+
return Ok((candidate, file_hash));
496+
}
497+
498+
let file_path = resolve_unique_filename(&dir, &target_name);
499+
500+
// Atomic write: temp file then rename
501+
let tmp_path = dir.join(format!(".{}.{}.tmp", file_hash, extension));
502+
std::fs::write(&tmp_path, &decrypted).map_err(|e| format!("Failed to write file: {}", e))?;
503+
std::fs::rename(&tmp_path, &file_path).map_err(|e| format!("Failed to rename file: {}", e))?;
504+
505+
Ok((file_path, file_hash))
506+
}
507+
464508
/// Format bytes into human-readable format (KB, MB, GB).
465509
pub fn format_bytes(bytes: u64) -> String {
466510
const KB: f64 = 1024.0;

src-tauri/src/commands/attachments.rs

Lines changed: 24 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use tokio::sync::Mutex;
1111
use tauri::{AppHandle, Emitter, Manager, Runtime};
1212

1313
use crate::{STATE, TAURI_APP, ChatType, Attachment};
14-
use crate::{util, crypto, net, db, mls};
14+
use crate::{util, net, db, mls};
1515
use crate::util::hex_string_to_bytes;
1616

1717
/// Global set of attachment IDs currently being downloaded.
@@ -108,62 +108,36 @@ pub(crate) fn resolve_unique_filename(dir: &std::path::Path, name: &str) -> std:
108108
///
109109
/// Returns (path, content_hash) if successful, or an error message if unsuccessful
110110
pub async fn decrypt_and_save_attachment<R: Runtime>(
111-
handle: &AppHandle<R>,
111+
_handle: &AppHandle<R>,
112112
encrypted_data: &[u8],
113113
attachment: &Attachment
114114
) -> Result<(std::path::PathBuf, String), String> {
115-
// Decrypt the attachment using the appropriate method
116-
let decrypted_data = if let Some(ref group_id) = attachment.group_id {
117-
// MLS attachment - use MDK's MIP-04 decryption
118-
decrypt_mls_attachment(encrypted_data, attachment, group_id).await?
119-
} else {
120-
// DM attachment - use explicit key/nonce with AES-GCM
121-
crypto::decrypt_data(encrypted_data, &attachment.key, &attachment.nonce)
122-
.map_err(|e| e.to_string())?
123-
};
115+
if let Some(ref group_id) = attachment.group_id {
116+
// MLS attachment — MDK's MIP-04 decryption (stays in src-tauri)
117+
let decrypted_data = decrypt_mls_attachment(encrypted_data, attachment, group_id).await?;
118+
let file_hash = util::calculate_file_hash(&decrypted_data);
124119

125-
// Calculate the hash of the decrypted file
126-
let file_hash = util::calculate_file_hash(&decrypted_data);
120+
let dir = vector_core::db::get_download_dir();
121+
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?;
127122

128-
// Choose the appropriate base directory based on platform
129-
let base_directory = if cfg!(target_os = "ios") {
130-
tauri::path::BaseDirectory::Document
131-
} else {
132-
tauri::path::BaseDirectory::Download
133-
};
134-
135-
// Resolve the directory path using the determined base directory
136-
let dir = handle.path().resolve("vector", base_directory).unwrap();
137-
138-
// Use human-readable filename if available, otherwise fall back to hash-based
139-
let target_name = if attachment.name.is_empty() {
140-
format!("{}.{}", file_hash, attachment.extension)
123+
let target_name = if attachment.name.is_empty() {
124+
format!("{}.{}", file_hash, attachment.extension)
125+
} else {
126+
attachment.name.clone()
127+
};
128+
129+
let file_path = resolve_unique_filename(&dir, &target_name);
130+
let tmp_path = dir.join(format!(".{}.{}.tmp", file_hash, attachment.extension));
131+
std::fs::write(&tmp_path, &decrypted_data).map_err(|e| format!("Failed to write file: {}", e))?;
132+
std::fs::rename(&tmp_path, &file_path).map_err(|e| format!("Failed to rename file: {}", e))?;
133+
Ok((file_path, file_hash))
141134
} else {
142-
attachment.name.clone()
143-
};
144-
145-
// Create the vector directory if it doesn't exist
146-
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?;
147-
148-
// If file with same name + same size + same hash already exists, reuse it (content dedup)
149-
let candidate = dir.join(&target_name);
150-
let already_exists = candidate.exists()
151-
&& std::fs::metadata(&candidate).map(|m| m.len() == decrypted_data.len() as u64).unwrap_or(false)
152-
&& std::fs::read(&candidate).map(|b| util::calculate_file_hash(&b) == file_hash).unwrap_or(false);
153-
154-
if already_exists {
155-
return Ok((candidate, file_hash));
135+
// DM attachment — delegates to vector-core
136+
vector_core::crypto::decrypt_and_save_attachment(
137+
encrypted_data, &attachment.key, &attachment.nonce,
138+
&attachment.name, &attachment.extension,
139+
)
156140
}
157-
158-
let file_path = resolve_unique_filename(&dir, &target_name);
159-
160-
// Atomic write: write to temp file then rename, so the file is never 0 bytes
161-
// (prevents corrupted state if another thread reads concurrently on macOS APFS)
162-
let tmp_path = dir.join(format!(".{}.{}.tmp", file_hash, attachment.extension));
163-
std::fs::write(&tmp_path, &decrypted_data).map_err(|e| format!("Failed to write file: {}", e))?;
164-
std::fs::rename(&tmp_path, &file_path).map_err(|e| format!("Failed to rename file: {}", e))?;
165-
166-
Ok((file_path, file_hash))
167141
}
168142

169143
/// Decrypt an MLS attachment using MDK's MIP-04 decryption

src-tauri/src/crypto.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use zeroize::Zeroize;
1717

1818
// Re-export from vector-core
1919
pub use vector_core::crypto::{
20-
decrypt_data, looks_encrypted, is_encryption_enabled,
20+
looks_encrypted, is_encryption_enabled,
2121
maybe_encrypt, maybe_decrypt,
2222
};
2323

0 commit comments

Comments
 (0)