From d5dc9d82d7c8647f9def49a30d829eb0c3330184 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 15:20:38 +0200 Subject: [PATCH] Crypto in vc --- src-tauri/Cargo.lock | 140 +++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/audio.rs | 55 +++++++++---- src-tauri/src/crypto.rs | 62 ++++++++++++++ src-tauri/src/lib.rs | 1 + src/app/app.ts | 6 +- src/app/ws.ts | 2 +- 7 files changed, 246 insertions(+), 21 deletions(-) create mode 100644 src-tauri/src/crypto.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 06091f3..01365dc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -319,6 +329,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -508,6 +527,29 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.3.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", +] + [[package]] name = "chrono" version = "0.4.45" @@ -520,6 +562,23 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.7" @@ -642,6 +701,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -676,6 +744,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -715,6 +794,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.23.0" @@ -833,8 +921,8 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", ] [[package]] @@ -994,6 +1082,7 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" name = "enclave" version = "0.1.0" dependencies = [ + "chacha20poly1305", "cpal", "ringbuf", "rubato", @@ -1392,6 +1481,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core", ] [[package]] @@ -1627,6 +1717,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -1846,6 +1945,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.1" @@ -2807,6 +2915,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2944,6 +3062,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3398,7 +3522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4396,6 +4520,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "url" version = "2.5.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6cdf2a9..4555b87 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,4 +25,5 @@ serde_json = "1" cpal = "0.18.2" ringbuf = "0.5.1" rubato = "5.0.0" +chacha20poly1305 = "0.11.0" diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index 17f3eaf..7e6b5cf 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -9,6 +9,7 @@ use std::{ time::{Duration, Instant}, }; +use chacha20poly1305::{aead::KeyInit, ChaCha20Poly1305, Key}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use ringbuf::{ storage::Heap, @@ -17,11 +18,10 @@ use ringbuf::{ }; use tauri::State; -use crate::commands::config::ConfigState; +use crate::{commands::config::ConfigState, crypto::SessionCipher}; const TARGET_SAMPLE_RATE: u32 = 48_000; -const PACKET_SAMPLES: usize = 960; // 20ms @ 48kHz mono -const HEADER_SIZE: usize = 4; +const PACKET_SAMPLES: usize = 960; const MAX_PACKET_SIZE: usize = 4096; const INITIAL_PACKET_CUSHION: usize = 3; @@ -345,11 +345,15 @@ pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), Stri pub fn connect_to_vc( hostname: String, pin: u64, + shared_secret: Vec, // output of js `x25519.getSharedSecret` config_state: State<'_, ConfigState>, voice_state: State<'_, VoiceState>, ) -> Result<(), String> { disconnect_from_vc(voice_state.clone())?; + let key = Key::try_from(shared_secret.as_slice()).map_err(|v| v.to_string())?; + let cipher = Arc::new(Mutex::new(SessionCipher::new(ChaCha20Poly1305::new(&key)))); + let config = config_state.0.lock().unwrap().clone(); let state_inner = Arc::clone(&voice_state.inner); @@ -444,10 +448,10 @@ pub fn connect_to_vc( { let input_socket = socket.clone(); let shutdown = shutdown.clone(); + let cipher = cipher.clone(); thread::spawn(move || { let mut sequence = 0u32; - let mut net_packet = vec![0u8; HEADER_SIZE + PACKET_SAMPLES * 2]; let mut frame_buf = vec![0.0f32; PACKET_SAMPLES]; let mut hangover_counter = 0; @@ -469,13 +473,21 @@ pub fn connect_to_vc( }; if is_speaking { - net_packet[0..4].copy_from_slice(&sequence.to_be_bytes()); - for (i, sample) in frame_buf.iter().enumerate() { + // sequence goes INSIDE the plaintext now, prefixed before the PCM + let mut plaintext = Vec::with_capacity(4 + PACKET_SAMPLES * 2); + plaintext.extend_from_slice(&sequence.to_be_bytes()); + + for sample in frame_buf.iter() { let pcm = (sample.clamp(-1.0, 1.0) * 32767.0) as i16; - let offset = HEADER_SIZE + i * 2; - net_packet[offset..offset + 2].copy_from_slice(&pcm.to_be_bytes()); + plaintext.extend_from_slice(&pcm.to_be_bytes()); } + let Ok(net_packet) = cipher.lock().unwrap().encrypt(&plaintext) else { + eprintln!("[vc] failed to encrypt outgoing packet"); + sequence = sequence.wrapping_add(1); + continue; + }; + let _ = input_socket.send(&net_packet); sequence = sequence.wrapping_add(1); } @@ -490,6 +502,7 @@ pub fn connect_to_vc( { let socket = socket.clone(); let shutdown = shutdown.clone(); + let cipher = cipher.clone(); thread::spawn(move || { let mut packets: BTreeMap> = BTreeMap::new(); @@ -501,15 +514,25 @@ pub fn connect_to_vc( while !shutdown.load(Ordering::Relaxed) { if let Ok(len) = socket.recv(&mut udp_buffer) { - if len > HEADER_SIZE { - let seq = u32::from_be_bytes(udp_buffer[0..4].try_into().unwrap()); - let pcm = &udp_buffer[HEADER_SIZE..len]; - let samples: Vec = pcm - .chunks_exact(2) - .map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32768.0) - .collect(); + match cipher.lock().unwrap().decrypt(&udp_buffer[..len]) { + Ok(plaintext) => { + if plaintext.len() < 4 { + eprintln!("[vc] dropped packet: too short after decrypt"); + } else { + let seq = u32::from_be_bytes(plaintext[..4].try_into().unwrap()); + let pcm = &plaintext[4..]; - packets.insert(seq, samples); + let samples: Vec = pcm + .chunks_exact(2) + .map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32768.0) + .collect(); + + packets.insert(seq, samples); + } + } + Err(_) => { + eprintln!("[vc] dropped packet: decryption failed"); + } } } diff --git a/src-tauri/src/crypto.rs b/src-tauri/src/crypto.rs new file mode 100644 index 0000000..e9e6509 --- /dev/null +++ b/src-tauri/src/crypto.rs @@ -0,0 +1,62 @@ +use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Nonce}; + +pub struct SessionCipher { + cipher: ChaCha20Poly1305, + send_counter: u64, + recv_counter: u64, +} + +impl SessionCipher { + pub fn new(cipher: ChaCha20Poly1305) -> Self { + Self { + cipher, + send_counter: 0, + recv_counter: 0, + } + } + + pub fn next_send_nonce(&mut self) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[..8].copy_from_slice(&self.send_counter.to_be_bytes()); + // top bit distinguishes "send" direction from "recv" direction, + // so client-send and server-send counters never collide even if + // both happened to reach the same numeric value + nonce[11] |= 0b1000_0000; + self.send_counter += 1; + nonce + } + + pub fn next_recv_nonce(&mut self) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[..8].copy_from_slice(&self.recv_counter.to_be_bytes()); + self.recv_counter += 1; + nonce + } + + pub fn encrypt(&mut self, plaintext: &[u8]) -> Result, String> { + let nonce_bytes = self.next_send_nonce(); + let nonce = Nonce::try_from(nonce_bytes).map_err(|v| v.to_string())?; + + let ciphertext = self + .cipher + .encrypt(&nonce, plaintext) + .map_err(|v| v.to_string())?; + + // prepend the nonce so the other side can reconstruct it on decrypt + let mut out = nonce_bytes.to_vec(); + out.extend(ciphertext); + Ok(out) + } + + pub fn decrypt(&mut self, data: &[u8]) -> Result, String> { + if data.len() < 12 { + return Err("message too short to contain a nonce".to_string()); + } + let (nonce_bytes, ciphertext) = data.split_at(12); + let nonce = Nonce::try_from(nonce_bytes).map_err(|v| v.to_string())?; + + self.cipher + .decrypt(&nonce, ciphertext) + .map_err(|v| v.to_string()) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cd3b64b..73dbfc8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ use std::sync::Mutex; use crate::commands::config::ConfigState; pub mod commands; +pub mod crypto; pub mod types; #[cfg_attr(mobile, tauri::mobile_entry_point)] diff --git a/src/app/app.ts b/src/app/app.ts index 2d354c3..5ca3c46 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -171,7 +171,11 @@ export default class Enclave

{ if (!server || channel.kind !== "voice") return; server.voiceJoin = (pin, channelId) => { - invoke("connect_to_vc", { hostname: server.hostname, pin, channelId }) + invoke("connect_to_vc", { + hostname: server.hostname, + pin, + sharedSecret: server.websocket?.sharedSecret, + }) .then(() => { server.isInVoiceChat = true; server.voiceChannelId = channelId; diff --git a/src/app/ws.ts b/src/app/ws.ts index 465640b..f3f2f9c 100644 --- a/src/app/ws.ts +++ b/src/app/ws.ts @@ -18,7 +18,7 @@ export default class EnclaveWebSocket { private sendCounter = 0n; private recvCounter = 0n; - private sharedSecret: Uint8Array | null = null; + public sharedSecret: Uint8Array | null = null; private readonly handshakeReady: Promise; private resolveHandshake!: () => void;