diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 6b4d617..a778e53 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2647,7 +2647,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.6" +version = "0.0.7" dependencies = [ "anyhow", "argon2", @@ -2665,6 +2665,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2", "sled", "tokio", "tokio-tungstenite", @@ -2679,7 +2680,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.6" +version = "0.0.7" dependencies = [ "anyhow", "clap", @@ -2688,7 +2689,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.6" +version = "0.0.7" dependencies = [ "base64", "bincode", @@ -2711,7 +2712,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.6" +version = "0.0.7" dependencies = [ "anyhow", "axum", @@ -2738,7 +2739,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.6" +version = "0.0.7" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 1f5104b..25ffa44 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.6" +version = "0.0.7" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/Cargo.toml b/warzone/crates/warzone-client/Cargo.toml index 3c611d8..a6ead6b 100644 --- a/warzone/crates/warzone-client/Cargo.toml +++ b/warzone/crates/warzone-client/Cargo.toml @@ -24,6 +24,7 @@ hex.workspace = true base64.workspace = true x25519-dalek.workspace = true bincode.workspace = true +sha2.workspace = true libc = "0.2" uuid.workspace = true chrono.workspace = true diff --git a/warzone/crates/warzone-client/src/cli/recv.rs b/warzone/crates/warzone-client/src/cli/recv.rs index 7d9eb46..0f98f04 100644 --- a/warzone/crates/warzone-client/src/cli/recv.rs +++ b/warzone/crates/warzone-client/src/cli/recv.rs @@ -117,6 +117,12 @@ pub async fn run(server_url: &str, identity: &IdentityKeyPair) -> Result<()> { sender_fingerprint, message_id, receipt_type ); } + Ok(WireMessage::FileHeader { filename, sender_fingerprint, file_size, .. }) => { + println!(" [file header] {} is sending '{}' ({} bytes)", sender_fingerprint, filename, file_size); + } + Ok(WireMessage::FileChunk { filename, chunk_index, total_chunks, sender_fingerprint, .. }) => { + println!(" [file chunk] {} chunk {}/{} of '{}'", sender_fingerprint, chunk_index + 1, total_chunks, filename); + } Err(e) => { eprintln!(" failed to deserialize message: {}", e); } diff --git a/warzone/crates/warzone-client/src/tui/app.rs b/warzone/crates/warzone-client/src/tui/app.rs index b312468..6e18803 100644 --- a/warzone/crates/warzone-client/src/tui/app.rs +++ b/warzone/crates/warzone-client/src/tui/app.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -9,6 +10,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; use ratatui::Frame; +use sha2::{Sha256, Digest}; use warzone_protocol::identity::IdentityKeyPair; use warzone_protocol::message::{ReceiptType, WireMessage}; use warzone_protocol::ratchet::RatchetState; @@ -19,6 +21,22 @@ use x25519_dalek::PublicKey; use crate::net::ServerClient; use crate::storage::LocalDb; +/// Maximum file size: 10 MB. +const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; +/// Chunk size: 64 KB. +const CHUNK_SIZE: usize = 64 * 1024; + +/// State for tracking an incoming chunked file transfer. +#[derive(Clone)] +pub struct PendingFileTransfer { + pub filename: String, + pub total_chunks: u32, + pub received: u32, + pub chunks: Vec>>, + pub sha256: String, + pub file_size: u64, +} + /// Receipt status for a sent message. #[derive(Clone, Debug, PartialEq, Eq)] pub enum ReceiptStatus { @@ -36,6 +54,8 @@ pub struct App { pub should_quit: bool, /// Track receipt status for messages we sent, keyed by message ID. pub receipts: Arc>>, + /// Pending incoming file transfers, keyed by file ID. + pub pending_files: Arc>>, } #[derive(Clone)] @@ -78,7 +98,7 @@ impl App { messages.lock().unwrap().push(ChatLine { sender: "system".into(), - text: "Commands: /alias , /peer , /g , /info, /quit".into(), + text: "Commands: /alias , /peer , /g , /file , /info, /quit".into(), is_system: true, is_self: false, message_id: None, @@ -92,6 +112,7 @@ impl App { server_url, should_quit: false, receipts: Arc::new(Mutex::new(HashMap::new())), + pending_files: Arc::new(Mutex::new(HashMap::new())), } } @@ -304,6 +325,11 @@ impl App { self.group_list(client).await; return; } + if text.starts_with("/file ") { + let path_str = text[6..].trim(); + self.handle_file_send(path_str, identity, db, client).await; + return; + } // Send message (group or DM) let peer = match &self.peer_fp { @@ -461,6 +487,220 @@ impl App { } } + async fn handle_file_send( + &mut self, + path_str: &str, + identity: &IdentityKeyPair, + db: &LocalDb, + client: &ServerClient, + ) { + let path = PathBuf::from(path_str); + if !path.exists() { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("File not found: {}", path_str), + is_system: true, is_self: false, message_id: None, + }); + return; + } + + let metadata = match std::fs::metadata(&path) { + Ok(m) => m, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Cannot read file: {}", e), + is_system: true, is_self: false, message_id: None, + }); + return; + } + }; + + let file_size = metadata.len(); + if file_size > MAX_FILE_SIZE { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("File too large: {} bytes (max {} bytes)", file_size, MAX_FILE_SIZE), + is_system: true, is_self: false, message_id: None, + }); + return; + } + + let file_data = match std::fs::read(&path) { + Ok(d) => d, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Failed to read file: {}", e), + is_system: true, is_self: false, message_id: None, + }); + return; + } + }; + + let filename = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unnamed".to_string()); + + // SHA-256 of the complete file + let mut hasher = Sha256::new(); + hasher.update(&file_data); + let sha256 = format!("{:x}", hasher.finalize()); + + let file_id = uuid::Uuid::new_v4().to_string(); + let total_chunks = ((file_data.len() + CHUNK_SIZE - 1) / CHUNK_SIZE) as u32; + + // Resolve peer + let peer = match &self.peer_fp { + Some(p) if !p.starts_with('#') => p.clone(), + _ => { + self.add_message(ChatLine { + sender: "system".into(), + text: "File transfer requires a DM peer. Use /peer ".into(), + is_system: true, is_self: false, message_id: None, + }); + return; + } + }; + + let peer_fp = match Fingerprint::from_hex(&peer) { + Ok(fp) => fp, + Err(_) => { + self.add_message(ChatLine { + sender: "system".into(), + text: "Invalid peer fingerprint".into(), + is_system: true, is_self: false, message_id: None, + }); + return; + } + }; + + let our_pub = identity.public_identity(); + let our_fp_str = our_pub.fingerprint.to_string(); + + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Sending file '{}' ({} bytes, {} chunks)...", filename, file_size, total_chunks), + is_system: true, is_self: false, message_id: None, + }); + + // Send FileHeader (unencrypted metadata — the chunks carry ratchet-encrypted data) + let header = WireMessage::FileHeader { + id: file_id.clone(), + sender_fingerprint: our_fp_str.clone(), + filename: filename.clone(), + file_size, + total_chunks, + sha256: sha256.clone(), + }; + + let encoded_header = match bincode::serialize(&header) { + Ok(e) => e, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Serialize header failed: {}", e), + is_system: true, is_self: false, message_id: None, + }); + return; + } + }; + + if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded_header).await { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Failed to send file header: {}", e), + is_system: true, is_self: false, message_id: None, + }); + return; + } + + // Send each chunk: encrypt chunk data with ratchet, wrap in FileChunk + for i in 0..total_chunks { + let start = i as usize * CHUNK_SIZE; + let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len()); + let chunk_data = &file_data[start..end]; + + // Encrypt chunk data with ratchet + let mut ratchet = db.load_session(&peer_fp).ok().flatten(); + let encrypted_data = if let Some(ref mut state) = ratchet { + match state.encrypt(chunk_data) { + Ok(encrypted) => { + let _ = db.save_session(&peer_fp, state); + match bincode::serialize(&encrypted) { + Ok(e) => e, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Serialize chunk failed: {}", e), + is_system: true, is_self: false, message_id: None, + }); + return; + } + } + } + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Encrypt chunk {} failed: {}", i, e), + is_system: true, is_self: false, message_id: None, + }); + return; + } + } + } else { + self.add_message(ChatLine { + sender: "system".into(), + text: "No ratchet session. Send a text message first to establish one.".into(), + is_system: true, is_self: false, message_id: None, + }); + return; + }; + + let chunk_msg = WireMessage::FileChunk { + id: file_id.clone(), + sender_fingerprint: our_fp_str.clone(), + filename: filename.clone(), + chunk_index: i, + total_chunks, + data: encrypted_data, + }; + + let encoded = match bincode::serialize(&chunk_msg) { + Ok(e) => e, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Serialize chunk {} failed: {}", i, e), + is_system: true, is_self: false, message_id: None, + }); + return; + } + }; + + if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded).await { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Failed to send chunk {}/{}: {}", i + 1, total_chunks, e), + is_system: true, is_self: false, message_id: None, + }); + return; + } + + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Sent chunk [{}/{}] of {}", i + 1, total_chunks, filename), + is_system: true, is_self: false, message_id: None, + }); + } + + self.add_message(ChatLine { + sender: self.our_fp[..12.min(self.our_fp.len())].to_string(), + text: format!("Sent file: {} ({} bytes)", filename, file_size), + is_system: false, is_self: true, message_id: None, + }); + } + async fn group_create(&self, name: &str, client: &ServerClient) { let url = format!("{}/v1/groups/create", client.base_url); match client.client.post(&url) @@ -738,11 +978,12 @@ fn process_incoming( db: &LocalDb, messages: &Arc>>, receipts: &Arc>>, + pending_files: &Arc>>, our_fp: &str, client: &ServerClient, ) { match bincode::deserialize::(raw) { - Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, our_fp, client), + Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client), Err(_) => {} } } @@ -753,6 +994,7 @@ fn process_wire_message( db: &LocalDb, messages: &Arc>>, receipts: &Arc>>, + pending_files: &Arc>>, our_fp: &str, client: &ServerClient, ) { @@ -856,6 +1098,150 @@ fn process_wire_message( r.insert(message_id, new_status); } } + WireMessage::FileHeader { + id, + sender_fingerprint, + filename, + file_size, + total_chunks, + sha256, + } => { + let short_sender = &sender_fingerprint[..sender_fingerprint.len().min(12)]; + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!( + "Incoming file '{}' from {} ({} bytes, {} chunks)", + filename, short_sender, file_size, total_chunks + ), + is_system: true, + is_self: false, + message_id: None, + }); + + let transfer = PendingFileTransfer { + filename, + total_chunks, + received: 0, + chunks: vec![None; total_chunks as usize], + sha256, + file_size, + }; + pending_files.lock().unwrap().insert(id, transfer); + } + WireMessage::FileChunk { + id, + sender_fingerprint, + filename: _, + chunk_index, + total_chunks: _, + data, + } => { + // Decrypt the chunk data using our ratchet session with the sender + let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) { + Ok(fp) => fp, + Err(_) => return, + }; + let mut state = match db.load_session(&sender_fp) { + Ok(Some(s)) => s, + _ => return, + }; + + // The data field is a bincode-serialized RatchetMessage + let ratchet_msg = match bincode::deserialize(&data) { + Ok(m) => m, + Err(_) => return, + }; + + let plaintext = match state.decrypt(&ratchet_msg) { + Ok(pt) => { + let _ = db.save_session(&sender_fp, &state); + pt + } + Err(_) => return, + }; + + let mut pf = pending_files.lock().unwrap(); + if let Some(transfer) = pf.get_mut(&id) { + if (chunk_index as usize) < transfer.chunks.len() { + if transfer.chunks[chunk_index as usize].is_none() { + transfer.chunks[chunk_index as usize] = Some(plaintext); + transfer.received += 1; + } + + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!( + "Receiving {} [{}/{}]...", + transfer.filename, transfer.received, transfer.total_chunks + ), + is_system: true, + is_self: false, + message_id: None, + }); + + // Check if all chunks received + if transfer.received == transfer.total_chunks { + let mut assembled = Vec::with_capacity(transfer.file_size as usize); + for chunk in &transfer.chunks { + if let Some(data) = chunk { + assembled.extend_from_slice(data); + } + } + + // Verify SHA-256 + let mut hasher = Sha256::new(); + hasher.update(&assembled); + let computed_hash = format!("{:x}", hasher.finalize()); + + if computed_hash != transfer.sha256 { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!( + "File '{}' integrity check FAILED (hash mismatch)", + transfer.filename + ), + is_system: true, + is_self: false, + message_id: None, + }); + } else { + // Save to data_dir/downloads/ + let download_dir = crate::keystore::data_dir().join("downloads"); + let _ = std::fs::create_dir_all(&download_dir); + let save_path = download_dir.join(&transfer.filename); + match std::fs::write(&save_path, &assembled) { + Ok(_) => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!( + "File saved: {}", + save_path.display() + ), + is_system: true, + is_self: false, + message_id: None, + }); + } + Err(e) => { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("Failed to save file: {}", e), + is_system: true, + is_self: false, + message_id: None, + }); + } + } + } + + // Remove completed transfer + pf.remove(&id); + } + } + } else { + // Received chunk without header — ignore + } + } } } @@ -863,6 +1249,7 @@ fn process_wire_message( pub async fn poll_loop( messages: Arc>>, receipts: Arc>>, + pending_files: Arc>>, our_fp: String, identity: IdentityKeyPair, db: Arc, @@ -892,7 +1279,7 @@ pub async fn poll_loop( while let Some(Ok(msg)) = read.next().await { if let tokio_tungstenite::tungstenite::Message::Binary(data) = msg { - process_incoming(&data, &identity, &db, &messages, &receipts, &our_fp, &client); + process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client); } } @@ -913,7 +1300,7 @@ pub async fn poll_loop( Err(_) => continue, }; for raw in &raw_msgs { - process_incoming(raw, &identity, &db, &messages, &receipts, &our_fp, &client); + process_incoming(raw, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client); } } } @@ -939,12 +1326,13 @@ pub async fn run_tui( let poll_identity = poll_seed.derive_identity(); let poll_messages = app.messages.clone(); let poll_receipts = app.receipts.clone(); + let poll_pending_files = app.pending_files.clone(); let poll_client = client.clone(); let poll_db = db.clone(); let poll_fp = our_fp.clone(); tokio::spawn(async move { - poll_loop(poll_messages, poll_receipts, poll_fp, poll_identity, poll_db, poll_client).await; + poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client).await; }); loop { diff --git a/warzone/crates/warzone-protocol/src/message.rs b/warzone/crates/warzone-protocol/src/message.rs index 65d1616..61cce17 100644 --- a/warzone/crates/warzone-protocol/src/message.rs +++ b/warzone/crates/warzone-protocol/src/message.rs @@ -66,4 +66,22 @@ pub enum WireMessage { message_id: String, receipt_type: ReceiptType, }, + /// File transfer header: announces an incoming chunked file. + FileHeader { + id: String, + sender_fingerprint: String, + filename: String, + file_size: u64, + total_chunks: u32, + sha256: String, + }, + /// A single chunk of a file transfer (data is ratchet-encrypted). + FileChunk { + id: String, + sender_fingerprint: String, + filename: String, + chunk_index: u32, + total_chunks: u32, + data: Vec, + }, } diff --git a/warzone/crates/warzone-wasm/src/lib.rs b/warzone/crates/warzone-wasm/src/lib.rs index 0d4cd22..bff860f 100644 --- a/warzone/crates/warzone-wasm/src/lib.rs +++ b/warzone/crates/warzone-wasm/src/lib.rs @@ -448,5 +448,11 @@ pub fn decrypt_wire_message( "receipt_type": rt_str, }).to_string()) } + _ => { + // File transfer messages not yet handled in WASM + Ok(serde_json::json!({ + "type": "unsupported", + }).to_string()) + } } }