From 2b2084d2c087b0eb13df32ffc83e7813f3df6a72 Mon Sep 17 00:00:00 2001 From: Vignesh Date: Sun, 5 Jul 2026 06:23:16 +0530 Subject: [PATCH 1/8] perf(unlock): stream SHA-256 verify, header-only read instead of full blob unlock_container no longer reads entire encrypted blob into memory. Uses streaming SHA-256 that only buffers the header portion (salt + metadata_len + nonce + metadata_ciphertext) for KDF + session building. Full blob is still hashed for integrity verification, but not retained. V1 legacy path unchanged (full read). Added crypto::stream_verify_header() helper. Co-authored-by: forked.online --- src-tauri/src/commands.rs | 46 ++++++++++++++++++++++++-------------- src-tauri/src/crypto.rs | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 928fc8c..177c06d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -272,32 +272,44 @@ pub async fn unlock_container( bytes_total: 0, message: "Reading encrypted container…".into(), }); - let blob = std::fs::read(&meta.blob_path)?; - - let actual_sha256 = crypto::sha256_hex(&blob); - if actual_sha256 != meta.blob_sha256 { - return Err(CryptoError::IntegrityFailure); - } - // Emit derive-key progress (indeterminate) - emit_progress(&app, ProgressPayload { - operation: "derive-key".into(), - current: 0, - total: 0, - file_name: None, - bytes_processed: blob.len() as u64, - bytes_total: blob.len() as u64, - message: "Deriving decryption key…".into(), - }); + let blob_path = std::path::Path::new(&meta.blob_path); if meta.format_version == 2 { - let result = unlock_v2(&container_id, &password, &meta, &blob, sessions_v2); + // Read file size for progress tracking + let blob_len: u64 = std::fs::metadata(blob_path) + .map(|m| m.len()) + .unwrap_or(0); + + // Streaming path: hash-verify full blob but only buffer header + let header = crypto::stream_verify_header(blob_path, &meta.blob_sha256)?; + + // Emit derive-key progress (indeterminate) + emit_progress(&app, ProgressPayload { + operation: "derive-key".into(), + current: 0, + total: 0, + file_name: None, + bytes_processed: blob_len, + bytes_total: blob_len, + message: "Deriving decryption key…".into(), + }); + + // Derive key and build session from header bytes only + let result = unlock_v2(&container_id, &password, &meta, &header, sessions_v2); if result.is_ok() { record_audit(&pool, "unlock", Some(&container_id), Some(&meta.name), None); } return result; } + // v1 detected — full blob read (legacy, small) + let blob = std::fs::read(blob_path)?; + let actual_sha256 = crypto::sha256_hex(&blob); + if actual_sha256 != meta.blob_sha256 { + return Err(CryptoError::IntegrityFailure); + } + // v1 detected — auto-migrate to v2 emit_progress(&app, ProgressPayload { operation: "migrate".into(), diff --git a/src-tauri/src/crypto.rs b/src-tauri/src/crypto.rs index d16c336..674ade8 100644 --- a/src-tauri/src/crypto.rs +++ b/src-tauri/src/crypto.rs @@ -186,6 +186,53 @@ pub fn sha256_hex(data: &[u8]) -> String { hex::encode(hasher.finalize()) } +/// Stream-reads a blob file through SHA-256 for integrity verification. +/// Only buffers the header portion (salt + metadata_len + nonce + metadata_ciphertext). +/// Returns the header bytes, or `IntegrityFailure` on hash mismatch. +pub fn stream_verify_header( + path: &std::path::Path, + expected_sha256: &str, +) -> Result> { + use std::io::Read; + + let mut file = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + + // Phase 1: read fixed-size prefix: salt(16) + meta_len(4) + nonce(12) = 32 bytes + let prefix_len = SALT_LEN + 4 + NONCE_LEN; + let mut prefix = vec![0u8; prefix_len]; + file.read_exact(&mut prefix)?; + hasher.update(&prefix); + + // Parse metadata ciphertext length + let meta_len = u32::from_le_bytes( + prefix[SALT_LEN..SALT_LEN + 4].try_into().unwrap() + ) as usize; + + // Phase 2: read metadata ciphertext + let mut meta_ciphertext = vec![0u8; meta_len]; + file.read_exact(&mut meta_ciphertext)?; + hasher.update(&meta_ciphertext); + + // Phase 3: stream-read and hash remaining file data (file ciphertexts) + let mut buf = vec![0u8; 65536]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { break; } + hasher.update(&buf[..n]); + } + + // Verify integrity + let actual_hash = hex::encode(hasher.finalize()); + if actual_hash != expected_sha256 { + return Err(CryptoError::IntegrityFailure); + } + + // Return header bytes only (prefix + metadata ciphertext) + prefix.extend_from_slice(&meta_ciphertext); + Ok(prefix) +} + #[cfg(test)] mod tests { use super::*; From 652dcf0ed6f2f488441c7ed90bf4207ae2553393 Mon Sep 17 00:00:00 2001 From: Vignesh Date: Sun, 5 Jul 2026 06:25:12 +0530 Subject: [PATCH 2/8] perf(decrypt): remove redundant SHA-256 after GCM auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_file_data_v2 verified plaintext integrity with SHA-256 after GCM-authenticated decrypt already proved it. Deleted 4 lines. GCM auth failure bubbles as CryptoError::Decryption — the canonical integrity proof. Co-authored-by: forked.online --- src-tauri/src/commands.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 928fc8c..0e455f0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -627,11 +627,7 @@ fn get_file_data_v2( let plaintext = vault::decrypt_file(&encrypted, &fm, &key_arr)?; - // Verify SHA-256 - let hash = crypto::sha256_hex(&plaintext); - if hash != fm.sha256 { - return Err(CryptoError::IntegrityFailure); - } + // GCM authenticated decrypt already verified integrity // Insert into cache { From 5d6d54d58a987c6ee1e4f7482e85340f604ba557 Mon Sep 17 00:00:00 2001 From: Vignesh Date: Sun, 5 Jul 2026 06:27:23 +0530 Subject: [PATCH 3/8] perf(save): seek+read retained files instead of full blob read save_edits_v2 no longer reads entire encrypted blob. Opens file once, seeks to each retained file offset, reads only that ciphertext. I/O proportional to kept data, not full container size. Co-authored-by: forked.online --- src-tauri/src/commands.rs | 46 +++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 928fc8c..679ee33 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -786,17 +786,18 @@ async fn save_edits_v2( } drop(verification_key); - // Read existing blob - let blob = std::fs::read(&meta.blob_path)?; + // Open blob for seeking per retained file (no full read) + let mut blob_file = std::fs::File::open(&meta.blob_path)?; + use std::io::{Read, Seek, SeekFrom}; + let blob_total_len = blob_file.metadata() + .map(|m| m.len() as usize) + .unwrap_or(0); // Read metadata_len from blob header for read-side recovery - let end = SALT_LEN + 4; - if blob.len() < end { - return Err(CryptoError::InvalidFormat("save_edits_v2 blob too short for header".into())); - } - let meta_len_bytes: [u8; 4] = blob[SALT_LEN..end].try_into() - .map_err(|_| CryptoError::InvalidFormat("save_edits_v2 blob too short for header".into()))?; - let blob_metadata_len = u32::from_le_bytes(meta_len_bytes) as usize; + let mut meta_len_buf = [0u8; 4]; + blob_file.seek(SeekFrom::Start(SALT_LEN as u64))?; + blob_file.read_exact(&mut meta_len_buf)?; + let blob_metadata_len = u32::from_le_bytes(meta_len_buf) as usize; // Compute total plaintext bytes for progress bar (retained + new) let retained_bytes: u64 = old_metadata.files.iter() @@ -808,21 +809,14 @@ async fn save_edits_v2( let retained_count = old_metadata.files.iter().filter(|fm| !file_ids_to_remove.contains(&fm.id)).count(); let total_ops = (retained_count + files_to_add.len()) as u64; - // Decrypt remaining files (not removed), re-encrypt with new nonces. - // Uses read-side offset recovery (ignores stored fm.offset, computes from - // blob header metadata_len + cumulative `file_encrypted_len` sums) so that - // containers created with the buggy two-pass code — and chunked files from - // the streaming create_container — remain readable. + // Retained loop: seek + read each file's encrypted section from blob let mut new_meta: Vec = Vec::new(); let mut encrypted_parts: Vec> = Vec::new(); let mut total_size: u64 = 0; - let mut prior_sum: usize = 0; // cumulative encrypted size of prior retained files + let mut prior_sum: usize = 0; let mut progress_idx: u64 = 0; for fm in &old_metadata.files { - // On-disk encrypted length handles both whole-file and chunked layouts. - // Retained files created by the streaming `create_container` are chunked, - // so `fm.size + 16` would mis-slice them. let enc_len = vault::file_encrypted_len(fm); if file_ids_to_remove.contains(&fm.id) { prior_sum += enc_len; @@ -834,25 +828,25 @@ async fn save_edits_v2( total: total_ops, file_name: Some(fm.name.clone()), bytes_processed: total_size, - bytes_total: total_bytes, // total plaintext bytes (retained + new) + bytes_total: total_bytes, message: format!("Saving {} ({} / {})", fm.name, progress_idx + 1, total_ops), }); // Read-side recovery: compute actual offset from blob header metadata_len - let actual_offset = SALT_LEN + 4 + crypto::NONCE_LEN + blob_metadata_len + prior_sum; - if actual_offset + enc_len > blob.len() { + let actual_offset = (SALT_LEN + 4 + crypto::NONCE_LEN + blob_metadata_len + prior_sum) as u64; + if actual_offset as usize + enc_len > blob_total_len { return Err(CryptoError::IntegrityFailure); } - // Copy ciphertext as-is — no decrypt/re-encrypt needed. - // Plaintext unchanged, so original data_nonce + sha256 + chunks remain valid. - // Offset will be recomputed by compute_v2_layout below. - let enc_slice = blob[actual_offset..actual_offset + enc_len].to_vec(); + // Seek + read_exact — only this file's ciphertext enters memory + blob_file.seek(SeekFrom::Start(actual_offset))?; + let mut enc_slice = vec![0u8; enc_len]; + blob_file.read_exact(&mut enc_slice)?; new_meta.push(FileMetadata { id: fm.id.clone(), name: fm.name.clone(), mime: fm.mime.clone(), size: fm.size, - offset: 0, // set by compute_v2_layout below + offset: 0, data_nonce: fm.data_nonce, sha256: fm.sha256.clone(), chunks: fm.chunks.clone(), From 2fefe5ae4be8220130e127a717f1bc38396557ff Mon Sep 17 00:00:00 2001 From: Vignesh Date: Sun, 5 Jul 2026 06:30:10 +0530 Subject: [PATCH 4/8] perf(io): stream export/import to eliminate 2x memory buffering export: new export::write_ctnr() writes header + stream-copies blob file via io::copy (no full-Vec buffer). import: reads 10-byte header prefix, validates, then stream-copies blob body with inline SHA-256 hash (no full .ctnr read into memory). Co-authored-by: forked.online --- src-tauri/src/commands.rs | 72 ++++++++++++++++++++++++++------------- src-tauri/src/export.rs | 38 +++++++++++++++++++++ 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 928fc8c..bc4ff49 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1046,9 +1046,9 @@ pub async fn export_container( bytes_processed: 0, bytes_total: 0, message: "Reading container for export\u{2026}".into(), }); - - let blob = std::fs::read(&meta.blob_path)?; - let ctnr_bytes = export::serialize(&meta, &blob)?; + // Stream-write .ctnr: header + blob copy (no full-Vec buffer) + let mut out_file = std::fs::File::create(&dest_path)?; + export::write_ctnr(&mut out_file, &meta, std::path::Path::new(&meta.blob_path))?; emit_progress(&app, ProgressPayload { operation: "export".into(), @@ -1059,7 +1059,6 @@ pub async fn export_container( message: "Writing export file\u{2026}".into(), }); - std::fs::write(&dest_path, ctnr_bytes)?; let details = serde_json::json!({ "dest_path": &dest_path }).to_string(); record_audit(&pool, "export", Some(&container_id), Some(&meta.name), Some(&details)); Ok(()) @@ -1073,26 +1072,33 @@ pub async fn import_container( app: AppHandle, pool: State<'_, sqlx::SqlitePool>, ) -> std::result::Result { - let bytes = std::fs::read(&src_path)?; - - emit_progress(&app, ProgressPayload { - operation: "import".into(), - current: 0, total: 0, - file_name: None, - bytes_processed: 0, bytes_total: 0, - message: "Reading import file\u{2026}".into(), - }); - let (header, blob) = export::deserialize(&bytes)?; - - // Verify blob integrity - let actual_sha256 = crypto::sha256_hex(&blob); - if actual_sha256 != header.blob_sha256 { - return Err(CryptoError::IntegrityFailure); + // Streaming import: read header prefix, then copy blob with inline SHA-256 + use std::io::{Read, Write}; + use sha2::Digest; + let mut src_file = std::fs::File::open(&src_path)?; + let mut header_prefix = [0u8; 10]; + src_file.read_exact(&mut header_prefix)?; + + // Validate magic + parse header length + if &header_prefix[0..4] != export::MAGIC { + return Err(CryptoError::InvalidFormat("Not a .ctnr file".into())); + } + if header_prefix[4] != export::NULL { + return Err(CryptoError::InvalidFormat("Invalid null byte".into())); } + if header_prefix[5] != export::VERSION { + return Err(CryptoError::InvalidFormat(format!( + "Unsupported version: {}. Expected {}", header_prefix[5], export::VERSION + ))); + } + let header_len = u32::from_le_bytes(header_prefix[6..10].try_into().unwrap()) as usize; + + // Read and parse header JSON + let mut header_json = vec![0u8; header_len]; + src_file.read_exact(&mut header_json)?; + let header: export::ContainerHeader = serde_json::from_slice(&header_json)?; - // Check for duplicate ID before writing the blob, so a re-import fails - // cleanly with a friendly message instead of a cryptic UNIQUE-constraint - // SQL error — and without leaving an orphaned blob on disk. + // Check for duplicate ID before writing blob let existing = storage::get_container(&pool, &header.id).await; if existing.is_ok() { return Err(CryptoError::InvalidFormat( @@ -1100,7 +1106,7 @@ pub async fn import_container( )); } - // Write blob to local blobs dir + // Derive blob path let blobs_dir = app.path().app_data_dir() .map_err(|e| CryptoError::Io(std::io::Error::other(e.to_string())))? .join("blobs"); @@ -1115,7 +1121,25 @@ pub async fn import_container( bytes_total: 0, message: "Writing imported container\u{2026}".into(), }); - std::fs::write(&blob_path, &blob)?; + + // Stream-copy blob body with inline SHA-256 + let mut blob_file = std::fs::File::create(&blob_path)?; + let mut hasher = sha2::Sha256::new(); + let mut buf = vec![0u8; 65536]; + loop { + let n = src_file.read(&mut buf)?; + if n == 0 { break; } + hasher.update(&buf[..n]); + blob_file.write_all(&buf[..n])?; + } + + // Verify blob integrity + let actual_sha256 = hex::encode(hasher.finalize()); + if actual_sha256 != header.blob_sha256 { + // Clean up orphaned blob + let _ = std::fs::remove_file(&blob_path); + return Err(CryptoError::IntegrityFailure); + } let kdf_params: KdfParams = serde_json::from_value(header.kdf_params)?; let meta = ContainerMeta { diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index 31e82c1..2652e86 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -73,6 +73,44 @@ pub fn serialize(meta: &ContainerMeta, blob: &[u8]) -> Result> { Ok(out) } +/// Write .ctnr format to a writer, streaming the blob from disk (no full-Vec buffer). +pub fn write_ctnr( + writer: &mut W, + meta: &ContainerMeta, + blob_path: &std::path::Path, +) -> Result<()> { + let header = ContainerHeader { + id: meta.id.clone(), + name: meta.name.clone(), + algo: meta.algo.clone(), + kdf: meta.kdf_params.kdf.clone(), + kdf_params: serde_json::to_value(&meta.kdf_params)?, + hint: meta.hint.clone(), + tags: meta.tags.clone(), + file_count: meta.file_count, + total_size: meta.total_size, + created_at: meta.created_at.clone(), + modified_at: meta.modified_at.clone(), + blob_sha256: meta.blob_sha256.clone(), + format_version: meta.format_version, + }; + + let header_json = serde_json::to_vec(&header)?; + let header_len = u32::try_from(header_json.len()) + .map_err(|_| CryptoError::InvalidFormat("Header too large".into()))?; + + writer.write_all(MAGIC)?; + writer.write_all(&[NULL, VERSION])?; + writer.write_all(&header_len.to_le_bytes())?; + writer.write_all(&header_json)?; + + // Stream-copy blob file — no full-Vec buffer needed + let mut blob_file = std::fs::File::open(blob_path)?; + std::io::copy(&mut blob_file, writer)?; + + Ok(()) +} + /// Parse a .ctnr binary file into its header and blob. pub fn deserialize(data: &[u8]) -> Result<(ContainerHeader, Vec)> { // Validate magic bytes From 28826c604d207f57328065e17031c848082c38ad Mon Sep 17 00:00:00 2001 From: Vignesh Date: Sun, 5 Jul 2026 06:32:51 +0530 Subject: [PATCH 5/8] perf(download): pre-fetch metadata once, eliminate per-file lock thrash download_files now collects all file metadata in a single Mutex lock at loop start. Eliminates 3 lock acquisitions per file (two for file name lookup, one for total_bytes). Loop body uses pre-fetched Vec directly. Co-authored-by: forked.online --- src-tauri/src/commands.rs | 64 ++++++++++++--------------------------- 1 file changed, 20 insertions(+), 44 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 928fc8c..312ced7 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1205,65 +1205,42 @@ pub async fn download_files( let total_files = file_ids.len() as u64; - // Compute total bytes from session metadata - let total_bytes: u64 = { + // Pre-fetch all file metadata in a single lock — zero lock acquisitions in loop + let file_metas: Vec<(String, String, u64)> = { let store = sessions_v2.0.lock().unwrap(); - store.get(&container_id) - .map(|session| { - file_ids.iter().filter_map(|id| { - session.metadata.files.iter() - .find(|fm| fm.id == *id) - .map(|fm| fm.size) - }).sum() - }) - .unwrap_or(0) + store.get(&container_id).map(|s| { + file_ids.iter().filter_map(|id| { + s.metadata.files.iter() + .find(|fm| fm.id == *id) + .map(|fm| (fm.id.clone(), fm.name.clone(), fm.size)) + }).collect() + }).unwrap_or_default() }; + let total_bytes: u64 = file_metas.iter().map(|(_, _, sz)| sz).sum(); let mut results = Vec::with_capacity(file_ids.len()); let mut cumulative_bytes: u64 = 0; - for (i, file_id) in file_ids.iter().enumerate() { + for (i, (fid, fname, _fsize)) in file_metas.iter().enumerate() { // Emit decrypt progress before processing this file - let file_name_hint = { - let store = sessions_v2.0.lock().unwrap(); - store.get(&container_id) - .and_then(|session| { - session.metadata.files.iter() - .find(|fm| fm.id == *file_id) - .map(|fm| fm.name.clone()) - }) - .unwrap_or_else(|| file_id.clone()) - }; emit_progress(&app, ProgressPayload { operation: "decrypt".into(), current: i as u64, total: total_files, - file_name: Some(file_name_hint.clone()), + file_name: Some(fname.clone()), bytes_processed: cumulative_bytes, bytes_total: total_bytes, - message: format!("Decrypting {} ({} / {})", file_name_hint, i + 1, total_files), + message: format!("Decrypting {} ({} / {})", fname, i + 1, total_files), }); - match get_file_data_v2(&container_id, file_id, &sessions_v2) { + match get_file_data_v2(&container_id, fid, &sessions_v2) { Ok(Some(data)) => { cumulative_bytes += data.len() as u64; - // Look up the file name from session metadata - let file_name = { - let store = sessions_v2.0.lock().unwrap(); - store.get(&container_id) - .and_then(|session| { - session.metadata.files.iter() - .find(|f| f.id == *file_id) - .map(|f| f.name.clone()) - }) - .unwrap_or_else(|| file_id.clone()) - }; - - let write_path = resolve_collision_path(&dest, &file_name); + let write_path = resolve_collision_path(&dest, fname); match std::fs::write(&write_path, &data) { Ok(()) => { let path_str = write_path.to_string_lossy().into_owned(); results.push(vault::DownloadResult { - file_id: file_id.clone(), + file_id: fid.clone(), written_path: Some(path_str), bytes: data.len() as u64, error: None, @@ -1271,7 +1248,7 @@ pub async fn download_files( } Err(e) => { results.push(vault::DownloadResult { - file_id: file_id.clone(), + file_id: fid.clone(), written_path: None, bytes: 0, error: Some(format!("Write error: {}", e)), @@ -1281,7 +1258,7 @@ pub async fn download_files( } Ok(None) => { results.push(vault::DownloadResult { - file_id: file_id.clone(), + file_id: fid.clone(), written_path: None, bytes: 0, error: Some("No v2 session — container may be locked".into()), @@ -1289,7 +1266,7 @@ pub async fn download_files( } Err(e) => { results.push(vault::DownloadResult { - file_id: file_id.clone(), + file_id: fid.clone(), written_path: None, bytes: 0, error: Some(e.to_string()), @@ -1297,8 +1274,7 @@ pub async fn download_files( } } } - - // Audit container name from DB (not from session metadata's first file name) + // Audit container name from DB let container_name = storage::get_container(&pool, &container_id) .await .map(|m| m.name) From 1ec772ac6808d9e471a856cdb032e690b83f9662 Mon Sep 17 00:00:00 2001 From: Vignesh Date: Sun, 5 Jul 2026 11:52:11 +0530 Subject: [PATCH 6/8] perf(hash): remove SHA-256 from encrypt_file_chunked, convert_v1_to_v2, and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encrypt_file_chunked: removed Sha256 hasher (kept return type, returns empty string). convert_v1_to_v2: replaced sha256_hex call with empty string. Updated vault.rs + test assertions. GCM auth is the canonical integrity proof — SHA-256 was a redundant full pass on every encrypt. Also: removed unused sha2 import from vault.rs. Co-authored-by: forked.online --- src-tauri/src/commands.rs | 2 +- src-tauri/src/vault.rs | 8 ++------ src-tauri/tests/v2_integration.rs | 11 +++-------- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 13df99c..91f6a08 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -413,7 +413,7 @@ fn convert_v1_to_v2( }).collect(); for f in &payload.files { - let sha256 = crypto::sha256_hex(&f.data); + let sha256 = String::new(); let (encrypted, nonce) = crypto::encrypt_section(&f.data, &key)?; files_meta.push(FileMetadata { id: f.id.clone(), diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index 773230a..e815905 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -6,7 +6,6 @@ use crate::crypto::{self, KdfParams, SALT_LEN}; use crate::error::CryptoError; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; /// All metadata stored in plaintext in SQLite. /// Never includes encrypted content or key material. @@ -217,7 +216,6 @@ pub fn encrypt_file_chunked( debug_assert!(chunk_size > 0, "chunk_size must be non-zero"); use std::io::Read; let mut file = std::fs::File::open(path)?; - let mut hasher = Sha256::new(); let mut out: Vec = Vec::new(); let mut chunks: Vec = Vec::new(); let mut buf = vec![0u8; chunk_size]; @@ -235,7 +233,6 @@ pub fn encrypt_file_chunked( if filled == 0 { break; } - hasher.update(&buf[..filled]); let (ct, nonce) = crypto::encrypt_section(&buf[..filled], key)?; chunks.push(ChunkMetadata { offset: out.len() as u64, nonce, size: filled as u64 }); out.extend_from_slice(&ct); @@ -243,8 +240,7 @@ pub fn encrypt_file_chunked( progress(total); } - let sha = hex::encode(hasher.finalize()); - Ok((out, chunks, sha, total)) + Ok((out, chunks, String::new(), total)) } #[cfg(test)] @@ -410,7 +406,7 @@ mod tests { assert_eq!(len, 5000); assert_eq!(chunks.len(), 3); assert_eq!(ticks, vec![2048, 4096, 5000]); // progress after each chunk - assert_eq!(sha, crypto::sha256_hex(&plaintext)); + assert!(sha.is_empty()); // SHA-256 removed — GCM is canonical integrity proof // Reconstruct a FileMetadata and roundtrip through decrypt_file let fm = FileMetadata { diff --git a/src-tauri/tests/v2_integration.rs b/src-tauri/tests/v2_integration.rs index d4d7677..e6632a2 100644 --- a/src-tauri/tests/v2_integration.rs +++ b/src-tauri/tests/v2_integration.rs @@ -103,8 +103,6 @@ fn verify_v2_blob(blob: &[u8], password: &str, params: &KdfParams, expected_data let pt = crypto::decrypt_section(&blob[off..off + enc_len], &*key, &fm.data_nonce) .unwrap_or_else(|_| panic!("Decryption failed for file {}", name)); assert_eq!(pt, *original, "Data mismatch for file {}", name); - let hash = crypto::sha256_hex(&pt); - assert_eq!(hash, fm.sha256, "SHA-256 mismatch for file {}", name); } } @@ -272,8 +270,7 @@ fn v2_multifile_roundtrip_production_layout() { let pt = crypto::decrypt_section(&blob[off..off + enc_len], &*key, &fm.data_nonce) .unwrap_or_else(|_| panic!("Decryption failed for {} at stored offset {}", name, off)); assert_eq!(pt, *original, "Data mismatch for {}", name); - let hash = crypto::sha256_hex(&pt); - assert_eq!(hash, fm.sha256, "SHA-256 mismatch for {}", name); + // SHA-256 removed — GCM is canonical integrity proof } } @@ -530,7 +527,7 @@ fn create_container_streaming_roundtrips_two_files() { assert_eq!(recovered, file2_bytes, "recovered file-2 bytes must match original"); assert_eq!(files_meta[1].size, file2_bytes.len() as u64); - assert_eq!(crypto::sha256_hex(&recovered), files_meta[1].sha256); + // SHA-256 removed — GCM is canonical integrity proof std::fs::remove_dir_all(&dir).ok(); } @@ -583,8 +580,6 @@ fn v2_remove_non_trailing_file_keeps_remaining_decryptable() { fm.name, actual_offset, ) }); - let hash = crypto::sha256_hex(&pt); - assert_eq!(hash, fm.sha256, "SHA-256 mismatch for {}", fm.name); let expected_data = file_data.iter() .find(|(n, _)| *n == fm.name.as_str()) .map(|(_, d)| *d) @@ -874,7 +869,7 @@ fn get_file_data_v2_reads_chunked_after_prior_whole() { let section = &blob[offset..offset + section_len]; let recovered1 = vault::decrypt_file(section, &files_meta[1], &key).unwrap(); assert_eq!(recovered1, file1_bytes, "chunked file1 (after prior whole-file) must roundtrip"); - assert_eq!(crypto::sha256_hex(&recovered1), files_meta[1].sha256); + // SHA-256 removed — GCM is canonical integrity proof // ── Recover file0 (whole-file legacy, offset 0) — backward compat proof ── let file0_offset = crypto::SALT_LEN + 4 + crypto::NONCE_LEN + meta_len; From 36b8c24321e9772b1f1484ebba4735841c7d05ae Mon Sep 17 00:00:00 2001 From: Vignesh Date: Sun, 5 Jul 2026 13:37:42 +0530 Subject: [PATCH 7/8] fix(review): address 4 code review findings on PR #43 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🔴 path traversal: validate header.id is UUID before blob path - 🟠 OOM meta_len: cap at file_len or 50MB before allocation - 🟠 OOM header_len: cap at src_len or 50MB before allocation - 🟡 orphaned blob: wrap streaming copy in closure, cleanup on error Co-authored-by: forked.online --- src-tauri/src/commands.rs | 28 +++++++++++++++++++++++----- src-tauri/src/crypto.rs | 6 ++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 91f6a08..9a00a43 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1095,6 +1095,12 @@ pub async fn import_container( } let header_len = u32::from_le_bytes(header_prefix[6..10].try_into().unwrap()) as usize; + // Guard against OOM from malicious/corrupted header size + let src_len = src_file.metadata()?.len() as usize; + if header_len > src_len || header_len > 50 * 1024 * 1024 { + return Err(CryptoError::InvalidFormat("Header size is invalid or too large".into())); + } + // Read and parse header JSON let mut header_json = vec![0u8; header_len]; src_file.read_exact(&mut header_json)?; @@ -1108,6 +1114,11 @@ pub async fn import_container( )); } + // Validate container ID is a UUID to prevent path traversal + if uuid::Uuid::parse_str(&header.id).is_err() { + return Err(CryptoError::InvalidFormat("Invalid container ID format".into())); + } + // Derive blob path let blobs_dir = app.path().app_data_dir() .map_err(|e| CryptoError::Io(std::io::Error::other(e.to_string())))? @@ -1128,11 +1139,18 @@ pub async fn import_container( let mut blob_file = std::fs::File::create(&blob_path)?; let mut hasher = sha2::Sha256::new(); let mut buf = vec![0u8; 65536]; - loop { - let n = src_file.read(&mut buf)?; - if n == 0 { break; } - hasher.update(&buf[..n]); - blob_file.write_all(&buf[..n])?; + let copy_result = (|| -> std::result::Result<(), CryptoError> { + loop { + let n = src_file.read(&mut buf)?; + if n == 0 { break; } + hasher.update(&buf[..n]); + blob_file.write_all(&buf[..n])?; + } + Ok(()) + })(); + if let Err(e) = copy_result { + let _ = std::fs::remove_file(&blob_path); + return Err(e); } // Verify blob integrity diff --git a/src-tauri/src/crypto.rs b/src-tauri/src/crypto.rs index 674ade8..4c74407 100644 --- a/src-tauri/src/crypto.rs +++ b/src-tauri/src/crypto.rs @@ -209,6 +209,12 @@ pub fn stream_verify_header( prefix[SALT_LEN..SALT_LEN + 4].try_into().unwrap() ) as usize; + // Guard against OOM from malicious/corrupted metadata size + let file_len = file.metadata()?.len() as usize; + if meta_len > file_len || meta_len > 50 * 1024 * 1024 { + return Err(CryptoError::InvalidFormat("Metadata size is invalid or too large".into())); + } + // Phase 2: read metadata ciphertext let mut meta_ciphertext = vec![0u8; meta_len]; file.read_exact(&mut meta_ciphertext)?; From 214a3015cc279d4512435a15e3c05184b4476312 Mon Sep 17 00:00:00 2001 From: Vignesh Date: Sun, 5 Jul 2026 16:19:47 +0530 Subject: [PATCH 8/8] =?UTF-8?q?fix(review):=20address=202nd-round=20findin?= =?UTF-8?q?gs=20=E2=80=94=20Windows=20file-lock=20cleanup,=2032-bit=20trun?= =?UTF-8?q?cation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Windows compat: drop(blob_file) before remove_file calls - 32-bit safety: compare file sizes as u64, not usize (prevents truncation on 32-bit targets for containers >4GB) Co-authored-by: forked.online --- src-tauri/src/commands.rs | 5 +++-- src-tauri/src/crypto.rs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9a00a43..3c5ec73 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1096,8 +1096,8 @@ pub async fn import_container( let header_len = u32::from_le_bytes(header_prefix[6..10].try_into().unwrap()) as usize; // Guard against OOM from malicious/corrupted header size - let src_len = src_file.metadata()?.len() as usize; - if header_len > src_len || header_len > 50 * 1024 * 1024 { + let src_len = src_file.metadata()?.len(); + if (header_len as u64) > src_len || header_len > 50 * 1024 * 1024 { return Err(CryptoError::InvalidFormat("Header size is invalid or too large".into())); } @@ -1148,6 +1148,7 @@ pub async fn import_container( } Ok(()) })(); + drop(blob_file); // release handle before cleanup (Windows compat) if let Err(e) = copy_result { let _ = std::fs::remove_file(&blob_path); return Err(e); diff --git a/src-tauri/src/crypto.rs b/src-tauri/src/crypto.rs index 4c74407..f7bfa74 100644 --- a/src-tauri/src/crypto.rs +++ b/src-tauri/src/crypto.rs @@ -210,8 +210,8 @@ pub fn stream_verify_header( ) as usize; // Guard against OOM from malicious/corrupted metadata size - let file_len = file.metadata()?.len() as usize; - if meta_len > file_len || meta_len > 50 * 1024 * 1024 { + let file_len = file.metadata()?.len(); + if (meta_len as u64) > file_len || meta_len > 50 * 1024 * 1024 { return Err(CryptoError::InvalidFormat("Metadata size is invalid or too large".into())); }