diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 61d785b..b35618d 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -902,6 +902,7 @@ dependencies = [ "tokio", "tokio-rustls", "uuid", + "xattr", ] [[package]] @@ -5616,6 +5617,16 @@ dependencies = [ "time", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yasna" version = "0.6.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 69be119..f4a648a 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -39,3 +39,6 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" sha2 = "0.11" tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } + +[target.'cfg(target_os = "macos")'.dependencies] +xattr = "1.6.1" diff --git a/apps/desktop/src-tauri/src/nearby/manager.rs b/apps/desktop/src-tauri/src/nearby/manager.rs index 8b8e25a..92e95ca 100644 --- a/apps/desktop/src-tauri/src/nearby/manager.rs +++ b/apps/desktop/src-tauri/src/nearby/manager.rs @@ -33,6 +33,7 @@ use super::{ read_chunk, read_message, write_chunk, write_message, LanFile, WireMessage, CERTIFICATE_REQUEST_MAGIC, LAN_PROTOCOL_VERSION, MAX_CHUNK_BYTES, }, + security::{assess_manifest, FileSecurityAssessment}, storage::{sha256_hex, validate_manifest, LocalSource, ReceiveSession}, NearbyError, }; @@ -144,6 +145,8 @@ pub struct IncomingTransferOffer { pub device_name: String, pub files: Vec, pub total_bytes: u64, + pub confirmation_stage: String, + pub security: FileSecurityAssessment, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -175,6 +178,7 @@ struct ApprovedIncomingTransfer { device_id: String, files: Vec, total_bytes: u64, + explicit_approval: bool, expires_at: Instant, } @@ -614,6 +618,57 @@ impl NearbyManager { sender.send(accepted).map_err(|_| NearbyError::NotFound) } + async fn request_transfer_approval( + &self, + transfer_id: &str, + trusted: &TrustedDevice, + files: &[LanFile], + total_bytes: u64, + confirmation_stage: &str, + security: FileSecurityAssessment, + ) -> Result { + let (sender, receiver) = oneshot::channel(); + { + let mut decisions = self + .inner + .transfer_decisions + .lock() + .map_err(|_| NearbyError::StateUnavailable)?; + if decisions.len() >= MAX_CONCURRENT_CONNECTIONS || decisions.contains_key(transfer_id) + { + return Err(NearbyError::Busy); + } + decisions.insert(transfer_id.to_owned(), sender); + } + if let Err(error) = self.inner.app.emit( + "nearby-transfer-offer", + IncomingTransferOffer { + transfer_id: transfer_id.to_owned(), + device_id: trusted.device_id.clone(), + device_name: trusted.device_name.clone(), + files: files.to_vec(), + total_bytes, + confirmation_stage: confirmation_stage.to_owned(), + security, + }, + ) { + if let Ok(mut decisions) = self.inner.transfer_decisions.lock() { + decisions.remove(transfer_id); + } + return Err(NearbyError::Protocol(format!( + "could not show the transfer approval: {error}" + ))); + } + let decision = match timeout(TRANSFER_DECISION_TIMEOUT, receiver).await { + Ok(result) => result.map_err(|_| NearbyError::Cancelled), + Err(_) => Err(NearbyError::Timeout), + }; + if let Ok(mut decisions) = self.inner.transfer_decisions.lock() { + decisions.remove(transfer_id); + } + decision + } + pub fn pause_transfer(&self, transfer_id: &str) -> Result<(), NearbyError> { let direction = self .inner @@ -861,10 +916,17 @@ impl NearbyManager { }, ) .await?; - match read_message_controlled(&mut stream, &control, &plan.transfer_id, CONNECTION_TIMEOUT) - .await? + match read_message_controlled( + &mut stream, + &control, + &plan.transfer_id, + TRANSFER_DECISION_TIMEOUT, + ) + .await? { WireMessage::CompleteAck { transfer_id } if transfer_id == plan.transfer_id => {} + WireMessage::Error { message, .. } => return Err(NearbyError::Protocol(message)), + WireMessage::Cancel { .. } => return Err(NearbyError::Cancelled), _ => { return Err(NearbyError::Protocol( "completion acknowledgement expected".to_owned(), @@ -1188,36 +1250,26 @@ impl NearbyManager { error: None, updated_at: now_millis(), }); - let resume_approved = - self.has_resume_approval(&transfer_id, &trusted.device_id, &files, total_bytes)?; - let accepted = if trusted.auto_accept_files || resume_approved { - true + let initial_security = assess_manifest(&files); + let resume_approval = + self.resume_approval(&transfer_id, &trusted.device_id, &files, total_bytes)?; + let (accepted, explicitly_approved) = if let Some(explicit) = resume_approval { + (true, explicit) + } else if trusted.auto_accept_files && !initial_security.requires_explicit_approval { + (true, false) } else { - let (sender, receiver) = oneshot::channel(); - self.inner - .transfer_decisions - .lock() - .map_err(|_| NearbyError::StateUnavailable)? - .insert(transfer_id.clone(), sender); - let _ = self.inner.app.emit( - "nearby-transfer-offer", - IncomingTransferOffer { - transfer_id: transfer_id.clone(), - device_id: trusted.device_id.clone(), - device_name: trusted.device_name.clone(), - files: files.clone(), + match self + .request_transfer_approval( + &transfer_id, + &trusted, + &files, total_bytes, - }, - ); - let decision = match timeout(TRANSFER_DECISION_TIMEOUT, receiver).await { - Ok(result) => result.map_err(|_| NearbyError::Cancelled), - Err(_) => Err(NearbyError::Timeout), - }; - if let Ok(mut decisions) = self.inner.transfer_decisions.lock() { - decisions.remove(&transfer_id); - } - match decision { - Ok(accepted) => accepted, + "BEFORE_TRANSFER", + initial_security.clone(), + ) + .await + { + Ok(accepted) => (accepted, accepted), Err(error) => { self.set_transfer_status(&transfer_id, "FAILED", Some(error.to_string())); self.clear_incoming_approval(&transfer_id); @@ -1239,7 +1291,13 @@ impl NearbyManager { self.clear_incoming_approval(&transfer_id); return result; } - self.remember_incoming_approval(&transfer_id, &trusted.device_id, &files, total_bytes)?; + self.remember_incoming_approval( + &transfer_id, + &trusted.device_id, + &files, + total_bytes, + explicitly_approved, + )?; let download_directory = PathBuf::from(self.inner.identity.preferences()?.download_directory); let (mut session, offsets) = @@ -1334,6 +1392,62 @@ impl NearbyManager { WireMessage::Complete { transfer_id: incoming_id, } if incoming_id == transfer_id => { + let inspected_security = session.inspect_security().await?; + let needs_second_approval = inspected_security.risk_rank() + > initial_security.risk_rank() + || (inspected_security.requires_explicit_approval + && !explicitly_approved); + if needs_second_approval { + self.set_transfer_status(&transfer_id, "WAITING", None); + let decision = self + .request_transfer_approval( + &transfer_id, + &trusted, + &files, + total_bytes, + "AFTER_INSPECTION", + inspected_security, + ) + .await; + match decision { + Ok(true) => { + self.remember_incoming_approval( + &transfer_id, + &trusted.device_id, + &files, + total_bytes, + true, + )?; + self.set_transfer_status(&transfer_id, "TRANSFERRING", None); + } + Ok(false) => { + let _ = write_message( + &mut stream, + &WireMessage::Error { + code: "SECURITY_REJECTED".to_owned(), + message: + "수신자가 파일 내용 확인 후 저장을 거절했습니다." + .to_owned(), + }, + ) + .await; + session.cancel().await?; + break Err(NearbyError::Cancelled); + } + Err(error) => { + let _ = write_message( + &mut stream, + &WireMessage::Error { + code: "SECURITY_CONFIRMATION_FAILED".to_owned(), + message: error.to_string(), + }, + ) + .await; + session.cancel().await?; + break Err(error); + } + } + } let _destinations = session.complete().await?; write_message( &mut stream, @@ -1587,13 +1701,13 @@ impl NearbyManager { .ok_or(NearbyError::NotFound) } - fn has_resume_approval( + fn resume_approval( &self, transfer_id: &str, device_id: &str, files: &[LanFile], total_bytes: u64, - ) -> Result { + ) -> Result, NearbyError> { let now = Instant::now(); let mut approvals = self .inner @@ -1601,10 +1715,11 @@ impl NearbyManager { .lock() .map_err(|_| NearbyError::StateUnavailable)?; approvals.retain(|_, approval| approval.expires_at > now); - Ok(approvals.get(transfer_id).is_some_and(|approval| { - approval.device_id == device_id + Ok(approvals.get(transfer_id).and_then(|approval| { + (approval.device_id == device_id && approval.total_bytes == total_bytes - && approval.files == files + && approval.files == files) + .then_some(approval.explicit_approval) })) } @@ -1614,6 +1729,7 @@ impl NearbyManager { device_id: &str, files: &[LanFile], total_bytes: u64, + explicit_approval: bool, ) -> Result<(), NearbyError> { let now = Instant::now(); let mut approvals = self @@ -1637,6 +1753,7 @@ impl NearbyManager { device_id: device_id.to_owned(), files: files.to_vec(), total_bytes, + explicit_approval, expires_at: now + RESUME_APPROVAL_TTL, }, ); diff --git a/apps/desktop/src-tauri/src/nearby/mod.rs b/apps/desktop/src-tauri/src/nearby/mod.rs index 636b888..0e21d94 100644 --- a/apps/desktop/src-tauri/src/nearby/mod.rs +++ b/apps/desktop/src-tauri/src/nearby/mod.rs @@ -1,6 +1,7 @@ mod identity; mod manager; mod protocol; +mod security; mod storage; use serde::Serialize; @@ -34,6 +35,8 @@ pub enum NearbyError { UnsafePath, #[error("전송 데이터 무결성 검증에 실패했습니다.")] Integrity, + #[error("수신 파일 보호 처리에 실패했습니다: {0}")] + Security(String), #[error("원본 파일이 변경되었습니다: {0}")] SourceChanged(String), #[error("잘못된 요청입니다: {0}")] diff --git a/apps/desktop/src-tauri/src/nearby/protocol.rs b/apps/desktop/src-tauri/src/nearby/protocol.rs index 5d0f9f4..1340da7 100644 --- a/apps/desktop/src-tauri/src/nearby/protocol.rs +++ b/apps/desktop/src-tauri/src/nearby/protocol.rs @@ -3,7 +3,7 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use super::NearbyError; -pub const LAN_PROTOCOL_VERSION: u16 = 1; +pub const LAN_PROTOCOL_VERSION: u16 = 2; pub const CERTIFICATE_REQUEST_MAGIC: &[u8; 8] = b"DDCERT1\n"; pub const MAX_CONTROL_FRAME_BYTES: usize = 1024 * 1024; pub const MAX_CHUNK_BYTES: usize = 4 * 1024 * 1024; diff --git a/apps/desktop/src-tauri/src/nearby/security.rs b/apps/desktop/src-tauri/src/nearby/security.rs new file mode 100644 index 0000000..6d1549a --- /dev/null +++ b/apps/desktop/src-tauri/src/nearby/security.rs @@ -0,0 +1,408 @@ +use std::{collections::HashSet, path::Path}; + +use serde::Serialize; +use tokio::{fs, io::AsyncReadExt}; + +use super::{protocol::LanFile, storage::validate_relative_path, NearbyError}; + +const EXECUTABLE_EXTENSIONS: &[&str] = &[ + "app", "apk", "appimage", "bin", "com", "cpl", "deb", "dll", "dylib", "exe", "gadget", "jar", + "msi", "msp", "pif", "pkg", "rpm", "scr", "sys", +]; +const SCRIPT_EXTENSIONS: &[&str] = &[ + "bat", "cmd", "command", "desktop", "fish", "hta", "inf", "ins", "isp", "job", "jse", "js", + "lnk", "msh", "msh1", "msh2", "mst", "php", "pl", "ps1", "psd1", "psm1", "py", "rb", "reg", + "scf", "sct", "sh", "url", "vb", "vbe", "vbs", "webloc", "workflow", "wsc", "wsf", "wsh", + "xnk", "zsh", +]; +const MACRO_EXTENSIONS: &[&str] = &[ + "chm", "doc", "docm", "dot", "dotm", "iqy", "one", "pot", "potm", "ppam", "pps", "ppsm", "ppt", + "pptm", "rtf", "sldm", "slk", "xla", "xlam", "xll", "xls", "xlsb", "xlsm", "xlt", "xltm", +]; +const ARCHIVE_EXTENSIONS: &[&str] = &[ + "7z", "bz2", "cab", "dmg", "gz", "img", "iso", "lz", "lzma", "rar", "tar", "tgz", "txz", "vhd", + "vhdx", "xz", "zip", +]; +const ACTIVE_WEB_EXTENSIONS: &[&str] = &["htm", "html", "mht", "mhtml", "svg", "xhtml"]; +const DECOY_EXTENSIONS: &[&str] = &[ + "csv", "doc", "docx", "gif", "jpeg", "jpg", "mp3", "mp4", "pdf", "png", "ppt", "pptx", "rtf", + "txt", "xls", "xlsx", +]; +const HIGH_RISK_REASONS: &[&str] = &[ + "EXECUTABLE_OR_INSTALLER", + "SCRIPT_OR_SHORTCUT", + "MACRO_DOCUMENT", + "DECEPTIVE_DOUBLE_EXTENSION", + "ACTIVE_MIME_MISMATCH", + "EXECUTABLE_CONTENT_MISMATCH", +]; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FileSecurityAssessment { + pub verdict: String, + pub risk_level: String, + pub requires_explicit_approval: bool, + pub risky_file_count: usize, + pub reasons: Vec, +} + +impl FileSecurityAssessment { + pub fn risk_rank(&self) -> u8 { + match self.risk_level.as_str() { + "HIGH_RISK" => 2, + "CAUTION" => 1, + _ => 0, + } + } +} + +pub fn assess_manifest(files: &[LanFile]) -> FileSecurityAssessment { + let mut all_reasons = Vec::new(); + let mut risky_file_count = 0; + + for file in files { + let reasons = assess_file(file); + if !reasons.is_empty() { + risky_file_count += 1; + } + for reason in reasons { + push_reason(&mut all_reasons, reason); + } + } + + build_assessment(risky_file_count, all_reasons) +} + +pub async fn inspect_staged_files( + root: &Path, + files: &[LanFile], +) -> Result { + let mut assessment = assess_manifest(files); + for file in files { + let relative = validate_relative_path(&file.relative_path)?; + let path = root.join(relative); + let metadata = fs::symlink_metadata(&path).await?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(NearbyError::Security( + "격리 영역에 예상하지 못한 파일 형식이 있습니다.".to_owned(), + )); + } + let mut handle = fs::File::open(&path).await?; + let mut header = vec![0_u8; 1024 * 1024]; + let read = handle.read(&mut header).await?; + header.truncate(read); + if detect_active_content(&header).is_some() + && !assess_file(file) + .iter() + .any(|reason| HIGH_RISK_REASONS.contains(&reason.as_str())) + { + if assess_file(file).is_empty() { + assessment.risky_file_count += 1; + } + push_reason( + &mut assessment.reasons, + "EXECUTABLE_CONTENT_MISMATCH".to_owned(), + ); + assessment.risk_level = "HIGH_RISK".to_owned(); + assessment.requires_explicit_approval = true; + } + } + Ok(assessment) +} + +pub async fn apply_platform_protection(root: &Path, transfer_id: &str) -> Result<(), NearbyError> { + if transfer_id.len() < 8 + || transfer_id.len() > 128 + || !transfer_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(NearbyError::Security( + "보호 메타데이터에 사용할 수 없는 전송 식별자입니다.".to_owned(), + )); + } + let root = root.to_path_buf(); + let transfer_id = transfer_id.to_owned(); + tokio::task::spawn_blocking(move || protect_tree(&root, &transfer_id)) + .await + .map_err(|error| NearbyError::Security(format!("보호 처리 작업 실패: {error}")))??; + Ok(()) +} + +fn assess_file(file: &LanFile) -> Vec { + let path = file.relative_path.to_ascii_lowercase(); + let filename = path.rsplit('/').next().unwrap_or(path.as_str()); + let extensions = filename + .split('.') + .skip(1) + .filter(|part| !part.is_empty()) + .collect::>(); + let extension = extensions.last().copied().unwrap_or_default(); + let mime = file.mime_type.to_ascii_lowercase(); + let mut reasons = Vec::new(); + + if EXECUTABLE_EXTENSIONS.contains(&extension) + || path.split('/').any(|segment| segment.ends_with(".app")) + || mime.contains("executable") + || mime.contains("x-msdownload") + || mime.contains("java-archive") + || mime.contains("msi") + { + push_reason(&mut reasons, "EXECUTABLE_OR_INSTALLER".to_owned()); + } + if SCRIPT_EXTENSIONS.contains(&extension) + || mime.contains("javascript") + || mime.contains("x-sh") + || mime.contains("powershell") + { + push_reason(&mut reasons, "SCRIPT_OR_SHORTCUT".to_owned()); + } + if MACRO_EXTENSIONS.contains(&extension) || mime.contains("macroenabled") { + push_reason(&mut reasons, "MACRO_DOCUMENT".to_owned()); + } + if ARCHIVE_EXTENSIONS.contains(&extension) + || mime.contains("compressed") + || mime.contains("archive") + || mime.contains("x-7z") + || mime.contains("rar") + || mime.contains("zip") + { + push_reason(&mut reasons, "ARCHIVE_OR_DISK_IMAGE".to_owned()); + } + if ACTIVE_WEB_EXTENSIONS.contains(&extension) || mime == "text/html" || mime == "image/svg+xml" + { + push_reason(&mut reasons, "ACTIVE_WEB_CONTENT".to_owned()); + } + if extensions.len() >= 2 + && DECOY_EXTENSIONS.contains(&extensions[extensions.len() - 2]) + && (EXECUTABLE_EXTENSIONS.contains(&extension) || SCRIPT_EXTENSIONS.contains(&extension)) + { + push_reason(&mut reasons, "DECEPTIVE_DOUBLE_EXTENSION".to_owned()); + } + if DECOY_EXTENSIONS.contains(&extension) + && (mime.contains("executable") + || mime.contains("x-msdownload") + || mime.contains("javascript") + || mime.contains("powershell")) + { + push_reason(&mut reasons, "ACTIVE_MIME_MISMATCH".to_owned()); + } + reasons +} + +fn build_assessment(risky_file_count: usize, reasons: Vec) -> FileSecurityAssessment { + let risk_level = if reasons + .iter() + .any(|reason| HIGH_RISK_REASONS.contains(&reason.as_str())) + { + "HIGH_RISK" + } else if reasons.is_empty() { + "LOWER_RISK" + } else { + "CAUTION" + }; + FileSecurityAssessment { + verdict: "UNSCANNED".to_owned(), + risk_level: risk_level.to_owned(), + requires_explicit_approval: risk_level != "LOWER_RISK", + risky_file_count, + reasons, + } +} + +fn push_reason(reasons: &mut Vec, reason: String) { + if !reasons.contains(&reason) { + reasons.push(reason); + } +} + +fn detect_active_content(bytes: &[u8]) -> Option<&'static str> { + if bytes.len() >= 64 && bytes.starts_with(b"MZ") { + let pe_offset = u32::from_le_bytes(bytes[0x3c..0x40].try_into().ok()?) as usize; + if pe_offset <= bytes.len().saturating_sub(4) + && &bytes[pe_offset..pe_offset + 4] == b"PE\0\0" + { + return Some("WINDOWS_EXECUTABLE"); + } + } + if bytes.starts_with(b"\x7fELF") { + return Some("ELF_EXECUTABLE"); + } + if bytes.len() >= 4 { + let magic: [u8; 4] = bytes[..4].try_into().ok()?; + if [ + [0xca, 0xfe, 0xba, 0xbe], + [0xbe, 0xba, 0xfe, 0xca], + [0xfe, 0xed, 0xfa, 0xce], + [0xce, 0xfa, 0xed, 0xfe], + [0xfe, 0xed, 0xfa, 0xcf], + [0xcf, 0xfa, 0xed, 0xfe], + ] + .contains(&magic) + { + return Some("MACHO_EXECUTABLE"); + } + } + bytes.starts_with(b"#!").then_some("SCRIPT_SHEBANG") +} + +fn protect_tree(root: &Path, transfer_id: &str) -> Result<(), NearbyError> { + let mut pending = vec![root.to_path_buf()]; + let mut visited = HashSet::new(); + while let Some(path) = pending.pop() { + if !visited.insert(path.clone()) { + return Err(NearbyError::Security( + "격리 영역에서 중복 경로를 발견했습니다.".to_owned(), + )); + } + let metadata = std::fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() { + return Err(NearbyError::Security( + "격리 영역의 심볼릭 링크를 거부했습니다.".to_owned(), + )); + } + + #[cfg(target_os = "macos")] + apply_macos_quarantine(&path, transfer_id)?; + + if metadata.is_dir() { + for entry in std::fs::read_dir(&path)? { + pending.push(entry?.path()); + } + } else if metadata.is_file() { + #[cfg(unix)] + clear_executable_bits(&path, &metadata)?; + #[cfg(windows)] + apply_windows_mark_of_the_web(&path, transfer_id)?; + } else { + return Err(NearbyError::Security( + "격리 영역에 지원하지 않는 항목이 있습니다.".to_owned(), + )); + } + } + Ok(()) +} + +#[cfg(unix)] +fn clear_executable_bits(path: &Path, metadata: &std::fs::Metadata) -> Result<(), NearbyError> { + use std::os::unix::fs::PermissionsExt; + + let mode = metadata.permissions().mode(); + if mode & 0o111 != 0 { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode & !0o111))?; + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn apply_macos_quarantine(path: &Path, transfer_id: &str) -> Result<(), NearbyError> { + use std::time::{SystemTime, UNIX_EPOCH}; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| NearbyError::Security(format!("시스템 시간 오류: {error}")))? + .as_secs(); + let value = format!("0083;{timestamp:x};DirectDrop;nearby://{transfer_id}"); + xattr::set(path, "com.apple.quarantine", value.as_bytes()).map_err(|error| { + NearbyError::Security(format!("macOS 격리 속성을 적용하지 못했습니다: {error}")) + }) +} + +#[cfg(windows)] +fn apply_windows_mark_of_the_web(path: &Path, transfer_id: &str) -> Result<(), NearbyError> { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + let mut stream = path.as_os_str().encode_wide().collect::>(); + stream.extend(":Zone.Identifier".encode_utf16()); + let stream = std::ffi::OsString::from_wide(&stream); + let metadata = format!( + "[ZoneTransfer]\r\nZoneId=3\r\nHostUrl=nearby://{transfer_id}\r\nReferrerUrl=DirectDrop\r\n" + ); + std::fs::write(Path::new(&stream), metadata.as_bytes()).map_err(|error| { + NearbyError::Security(format!( + "Windows 인터넷 출처 표시를 적용하지 못했습니다: {error}" + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn file(path: &str, mime_type: &str) -> LanFile { + LanFile { + id: format!("file-{path}-12345678"), + name: path.rsplit('/').next().unwrap().to_owned(), + relative_path: path.to_owned(), + size: 0, + mime_type: mime_type.to_owned(), + modified_at: 0, + } + } + + #[test] + fn classifies_active_files_without_claiming_they_are_malware() { + let assessment = assess_manifest(&[ + file("invoice.pdf.exe", "application/octet-stream"), + file("documents.zip", "application/zip"), + ]); + assert_eq!(assessment.verdict, "UNSCANNED"); + assert_eq!(assessment.risk_level, "HIGH_RISK"); + assert!(assessment.requires_explicit_approval); + assert!(assessment + .reasons + .contains(&"DECEPTIVE_DOUBLE_EXTENSION".to_owned())); + } + + #[tokio::test] + async fn escalates_a_renamed_executable_after_staging() { + let root = + std::env::temp_dir().join(format!("directdrop-security-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).await.unwrap(); + let mut pe = vec![0_u8; 128]; + pe[..2].copy_from_slice(b"MZ"); + pe[0x3c..0x40].copy_from_slice(&64_u32.to_le_bytes()); + pe[64..68].copy_from_slice(b"PE\0\0"); + fs::write(root.join("notes.txt"), pe).await.unwrap(); + let assessment = inspect_staged_files(&root, &[file("notes.txt", "text/plain")]) + .await + .unwrap(); + assert_eq!(assessment.risk_level, "HIGH_RISK"); + assert!(assessment + .reasons + .contains(&"EXECUTABLE_CONTENT_MISMATCH".to_owned())); + fs::remove_dir_all(root).await.unwrap(); + } + + #[tokio::test] + async fn rejects_transfer_identifiers_that_could_inject_provenance_metadata() { + let root = std::env::temp_dir().join(format!("directdrop-origin-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).await.unwrap(); + let error = apply_platform_protection(&root, "transfer\r\nZoneId=0") + .await + .unwrap_err(); + assert!(matches!(error, NearbyError::Security(_))); + fs::remove_dir_all(root).await.unwrap(); + } + + #[cfg(windows)] + #[test] + fn writes_windows_mark_of_the_web_with_transfer_origin() { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + let path = + std::env::temp_dir().join(format!("directdrop-zone-{}.txt", uuid::Uuid::new_v4())); + std::fs::write(&path, b"received").unwrap(); + apply_windows_mark_of_the_web(&path, "transfer-12345678").unwrap(); + + let mut stream = path.as_os_str().encode_wide().collect::>(); + stream.extend(":Zone.Identifier".encode_utf16()); + let stream = std::ffi::OsString::from_wide(&stream); + let metadata = std::fs::read_to_string(Path::new(&stream)).unwrap(); + assert!(metadata.contains("ZoneId=3")); + assert!(metadata.contains("HostUrl=nearby://transfer-12345678")); + + std::fs::remove_file(path).unwrap(); + } +} diff --git a/apps/desktop/src-tauri/src/nearby/storage.rs b/apps/desktop/src-tauri/src/nearby/storage.rs index 31732bc..2addfca 100644 --- a/apps/desktop/src-tauri/src/nearby/storage.rs +++ b/apps/desktop/src-tauri/src/nearby/storage.rs @@ -12,6 +12,7 @@ use tokio::{ use super::{ protocol::{FileOffset, LanFile, MAX_RELATIVE_PATH_BYTES, MAX_TRANSFER_FILES}, + security::{apply_platform_protection, inspect_staged_files, FileSecurityAssessment}, NearbyError, }; @@ -206,6 +207,16 @@ impl ReceiveSession { self.files.values().map(|file| file.offset).sum() } + pub async fn inspect_security(&self) -> Result { + let manifest = self + .file_order + .iter() + .filter_map(|file_id| self.files.get(file_id)) + .map(|file| file.metadata.clone()) + .collect::>(); + inspect_staged_files(&self.files_directory, &manifest).await + } + async fn persist_state(&self) -> Result<(), NearbyError> { persist_resume_state( &self.state_path, @@ -238,6 +249,11 @@ impl ReceiveSession { } drop(self.files); + // Received content remains in the hidden staging directory until platform + // provenance protection succeeds. A protection failure never exposes the + // files in the user's download directory. + apply_platform_protection(&self.files_directory, &self.transfer_id).await?; + let mut destinations = Vec::new(); if self.file_order.len() == 1 && !relative_paths[0].contains('/') { let source = first_file_path(&self.files_directory).await?; @@ -333,6 +349,11 @@ pub fn validate_manifest(files: &[LanFile]) -> Result { return Err(NearbyError::Protocol("invalid file metadata".to_owned())); } let relative = validate_relative_path(&file.relative_path)?; + if relative.file_name().and_then(|name| name.to_str()) != Some(file.name.as_str()) { + return Err(NearbyError::Protocol( + "display filename does not match the destination path".to_owned(), + )); + } if !paths.insert(relative) { return Err(NearbyError::Protocol("duplicate relative path".to_owned())); } @@ -518,6 +539,9 @@ mod tests { validate_relative_path("Project/src/main.rs").unwrap(), PathBuf::from("Project/src/main.rs") ); + let mut deceptive = file("invoice.pdf.exe", 1); + deceptive.name = "invoice.pdf".to_owned(); + assert!(validate_manifest(&[deceptive]).is_err()); } #[tokio::test] @@ -570,6 +594,10 @@ mod tests { let destinations = session.complete().await.unwrap(); assert_eq!(fs::read(root.join("safe.bin")).await.unwrap(), b"existing"); assert_eq!(fs::read(&destinations[0]).await.unwrap(), b"data"); + #[cfg(target_os = "macos")] + assert!(xattr::get(&destinations[0], "com.apple.quarantine") + .unwrap() + .is_some()); fs::remove_dir_all(root).await.unwrap(); } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e5693dd..70f1e63 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -51,6 +51,7 @@ import { type PublicFile, } from "@directdrop/protocol"; import { + fileSecurityReasonLabels, formatBytes, formatDuration, type ProgressSnapshot, @@ -468,8 +469,12 @@ export function App() { void listen("nearby-transfer-offer", (event) => { setNearbyOffer(event.payload); notifyIfEnabled( - "Nearby 파일 수신 요청", - `${event.payload.deviceName} 기기에서 ${event.payload.files.length}개 파일을 보내려고 합니다.`, + event.payload.confirmationStage === "AFTER_INSPECTION" + ? "Nearby 파일 재확인 필요" + : "Nearby 파일 수신 요청", + event.payload.confirmationStage === "AFTER_INSPECTION" + ? "파일 내용과 표시 형식이 달라 저장 전 확인이 필요합니다." + : `${event.payload.deviceName} 기기에서 ${event.payload.files.length}개 파일을 보내려고 합니다.`, ); }).then((unlisten) => { if (disposed) unlisten(); @@ -2567,7 +2572,7 @@ function NearbySettings({ {device.deviceId.slice(0, 12)} -