From 9d59da3a4e6d97666a5c135ba46c707036183675 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 13:39:26 +0200 Subject: [PATCH] crypto handshake and encryption --- Cargo.lock | 221 +++++++++++++++++++++++++++++++++++-- Cargo.toml | 4 + src/crypto.rs | 121 +++++++++++++++++++- src/protocol/initialize.rs | 17 +-- src/server.rs | 20 +++- src/ws.rs | 42 +++++-- 6 files changed, 383 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 28940fa..18fd268 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[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 = "ahash" version = "0.8.12" @@ -108,6 +118,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 = "bs58" version = "0.5.1" @@ -145,12 +164,58 @@ 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 = "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 = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -160,6 +225,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 = "crypto-common" version = "0.1.7" @@ -170,6 +244,26 @@ 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 0.10.1", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -177,10 +271,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", - "fiat-crypto", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "fiat-crypto 0.3.0", "rustc_version", "subtle", "zeroize", @@ -209,7 +318,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "zeroize", ] @@ -219,8 +328,19 @@ 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]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", ] [[package]] @@ -239,11 +359,11 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "ed25519", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -255,15 +375,19 @@ dependencies = [ "anyhow", "axum", "bs58", + "chacha20poly1305", + "curve25519-dalek 5.0.0", "ed25519-dalek", "futures-util", "rand 0.8.7", "rusqlite", "serde", "serde_json", + "sha2 0.11.0", "tokio", "tower-http", "uuid", + "x25519-dalek", ] [[package]] @@ -284,6 +408,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -393,6 +523,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -464,6 +595,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[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" @@ -499,6 +639,15 @@ dependencies = [ "tower-service", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "itoa" version = "1.0.18" @@ -612,6 +761,16 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[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 = "ppv-lite86" version = "0.2.21" @@ -710,6 +869,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rusqlite" version = "0.31.0" @@ -824,8 +989,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -835,8 +1000,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1113,6 +1289,16 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[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 = "uuid" version = "1.24.1" @@ -1217,6 +1403,17 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek 5.0.0", + "rand_core 0.10.1", + "zeroize", +] + [[package]] name = "zerocopy" version = "0.8.56" diff --git a/Cargo.toml b/Cargo.toml index 2d0a27a..4b8d01a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,7 @@ tower-http = { version = "0.7.0", features = ["fs", "cors"] } rusqlite = { version = "0.31", features = ["bundled"] } uuid = { version = "1.24.1", features = ["v4"] } futures-util = "0.3.34" +x25519-dalek = { version = "3.0.0", features = ["static_secrets"] } +curve25519-dalek = "5.0.0" +sha2 = "0.11.0" +chacha20poly1305 = "0.11.0" diff --git a/src/crypto.rs b/src/crypto.rs index 3dd0753..31cdbed 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -1,7 +1,17 @@ -use std::path::PathBuf; +use std::{path::PathBuf, sync::Arc}; +use axum::extract::ws::WebSocket; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, Nonce, + aead::{Aead, KeyInit}, +}; +use curve25519_dalek::edwards::CompressedEdwardsY; use ed25519_dalek::{Signature, SigningKey, VerifyingKey}; use rand::rngs::OsRng; +use sha2::{Digest, Sha512}; +use x25519_dalek::{PublicKey as X25519Public, SharedSecret, StaticSecret as X25519Secret}; + +use crate::{server::Server, ws::EnclaveWebSocket}; pub async fn get() -> anyhow::Result { let private_key_path = PathBuf::from("private.key"); @@ -45,3 +55,112 @@ pub fn from_string_sig(signature: &str) -> anyhow::Result { .map_err(|_| anyhow::anyhow!("Invalid signature"))?, )) } + +pub fn ed25519_signing_key_to_x25519(signing_key: &SigningKey) -> X25519Secret { + let hash = Sha512::digest(signing_key.as_bytes()); + let mut scalar_bytes = [0u8; 32]; + scalar_bytes.copy_from_slice(&hash[..32]); + X25519Secret::from(scalar_bytes) // clamping happens internally +} + +pub fn ed25519_verifying_key_to_x25519(verifying_key: &VerifyingKey) -> Option { + let compressed = CompressedEdwardsY(verifying_key.to_bytes()); + let edwards_point = compressed.decompress()?; + Some(X25519Public::from(edwards_point.to_montgomery().to_bytes())) +} + +pub struct SessionCipher { + cipher: ChaCha20Poly1305, + send_counter: u64, + recv_counter: u64, +} + +impl SessionCipher { + pub fn new(shared_secret: &SharedSecret) -> anyhow::Result { + // SharedSecret's raw bytes ARE suitable for direct use as a ChaCha20Poly1305 key + // (both are 32 bytes), though in a hardened design you'd typically run this + // through a KDF (e.g. HKDF) rather than using the raw ECDH output directly. + let key = Key::try_from(shared_secret.as_bytes().as_slice())?; + + Ok(Self { + cipher: ChaCha20Poly1305::new(&key), + 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]) -> anyhow::Result> { + let nonce_bytes = self.next_send_nonce(); + let nonce = Nonce::try_from(nonce_bytes)?; + + let ciphertext = self + .cipher + .encrypt(&nonce, plaintext) + .map_err(|_| anyhow::anyhow!("encryption failed"))?; + + // 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]) -> anyhow::Result> { + if data.len() < 12 { + anyhow::bail!("message too short to contain a nonce"); + } + let (nonce_bytes, ciphertext) = data.split_at(12); + let nonce = Nonce::try_from(nonce_bytes)?; + + self.cipher + .decrypt(&nonce, ciphertext) + .map_err(|_| anyhow::anyhow!("decryption failed")) + } +} + +pub async fn crypto_handshake( + server: &Arc, + mut socket: WebSocket, +) -> anyhow::Result> { + socket + .send(axum::extract::ws::Message::Binary( + server.x_keypair.0.to_bytes().to_vec().into(), + )) + .await?; + + let axum::extract::ws::Message::Binary(raw_pubkey) = socket + .recv() + .await + .transpose()? + .ok_or(anyhow::anyhow!("Failed to get client x key"))? + else { + return Err(anyhow::anyhow!("")); + }; + + let client_pubkey = X25519Public::from(*raw_pubkey.as_array().ok_or(anyhow::anyhow!( + "Failed to get proper length of client x key" + ))?); + + let shared_secret = server.x_keypair.1.diffie_hellman(&client_pubkey); + + let cipher = SessionCipher::new(&shared_secret)?; + + Ok(Arc::new(EnclaveWebSocket::new(socket, cipher))) +} diff --git a/src/protocol/initialize.rs b/src/protocol/initialize.rs index b27f5cc..93b9f85 100644 --- a/src/protocol/initialize.rs +++ b/src/protocol/initialize.rs @@ -4,7 +4,6 @@ use std::{ }; use ed25519_dalek::{Signer, VerifyingKey}; -use futures_util::SinkExt; use crate::{server::Server, ws::EnclaveWebSocket}; @@ -14,18 +13,8 @@ use crate::server::UserConnections; impl UserConnections { pub async fn initialize( server: &Arc, - socket: Arc, - ) -> anyhow::Result<(Arc, VerifyingKey, ClientMeta)> { - { - socket - .tx() - .await - .send(axum::extract::ws::Message::Binary( - server.key.verifying_key().to_bytes().to_vec().into(), - )) - .await?; - } - + socket: &Arc, + ) -> anyhow::Result<(VerifyingKey, ClientMeta)> { let Some(ServerMethod::Initialize { public_key: public_key_string, signature, @@ -122,6 +111,6 @@ impl UserConnections { )); }; - Ok((socket, public_key, meta)) + Ok((public_key, meta)) } } diff --git a/src/server.rs b/src/server.rs index 32a0fa6..8436298 100644 --- a/src/server.rs +++ b/src/server.rs @@ -21,8 +21,8 @@ use crate::{ data::{config::Config, messages::MessageStore, users::UserMetaStore}, protocol::{ClientMethod, read_loop}, types::ClientMeta, - ws::EnclaveWebSocket, }; +use x25519_dalek::{PublicKey as X25519Public, StaticSecret as X25519Secret}; pub struct VoiceConnection { pub addr: SocketAddr, @@ -40,6 +40,7 @@ pub struct UserConnections { pub struct Server { pub key: SigningKey, + pub x_keypair: (X25519Public, X25519Secret), pub config: Config, pub clients: Mutex>>, pub voice_pins: Mutex>, @@ -50,8 +51,14 @@ pub struct Server { impl Server { pub async fn new() -> anyhow::Result> { + let key = crate::crypto::get().await?; Ok(Arc::new(Self { - key: crate::crypto::get().await?, + x_keypair: ( + crate::crypto::ed25519_verifying_key_to_x25519(&key.verifying_key()) + .ok_or(anyhow::anyhow!("Failed to convert ed pubkey to x"))?, + crate::crypto::ed25519_signing_key_to_x25519(&key), + ), + key, config: Config::get().await?, clients: Mutex::new(HashMap::new()), voice_pins: Mutex::new(HashMap::new()), @@ -67,8 +74,13 @@ impl Server { let s = self.clone(); ws.on_upgrade(move |socket: WebSocket| async move { - match UserConnections::initialize(&s, Arc::new(EnclaveWebSocket::new(socket))).await { - Ok((client, public_key, meta)) => { + let Ok(client) = crate::crypto::crypto_handshake(&s, socket).await else { + eprintln!("Filed to initialize crypto"); + return; + }; + + match UserConnections::initialize(&s, &client).await { + Ok((public_key, meta)) => { if let Err(e) = s .user_store .upsert_user(&crate::crypto::to_string(&public_key), &meta) diff --git a/src/ws.rs b/src/ws.rs index 225c9c1..07f2c8a 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -1,26 +1,31 @@ use std::borrow::Cow; -use axum::extract::ws::{Message, Utf8Bytes, WebSocket}; +use axum::extract::ws::{Message, WebSocket}; use futures_util::{ SinkExt, StreamExt, stream::{SplitSink, SplitStream}, }; -use tokio::sync::{Mutex, MutexGuard}; +use tokio::sync::Mutex; -use crate::protocol::{ClientMethod, ServerMethod}; +use crate::{ + crypto::SessionCipher, + protocol::{ClientMethod, ServerMethod}, +}; pub struct EnclaveWebSocket { tx: Mutex>, rx: Mutex>, + cihper: Mutex, } impl EnclaveWebSocket { - pub fn new(ws: WebSocket) -> Self { + pub fn new(ws: WebSocket, cipher: SessionCipher) -> Self { let (tx, rx) = ws.split(); Self { tx: Mutex::new(tx), rx: Mutex::new(rx), + cihper: Mutex::new(cipher), } } @@ -39,6 +44,23 @@ impl EnclaveWebSocket { } }, + Some(Message::Binary(encrypted)) => { + let text = String::from_utf8(self.cihper.lock().await.decrypt(&encrypted)?)?; + + match serde_json::from_str(&text.to_string()) { + Ok(msg) => Ok(Some(msg)), + + Err(e) => { + self.send(&ClientMethod::Error { + error: Cow::Owned(format!("Unable to parse message: {e}")), + }) + .await?; + + Ok(None) + } + } + } + Some(Message::Ping(v)) => { self.tx.lock().await.send(Message::Pong(v)).await?; @@ -52,18 +74,16 @@ impl EnclaveWebSocket { } pub async fn send(&self, message: &ClientMethod) -> anyhow::Result<()> { + let text = serde_json::to_string(message)?; + + let encrypted = self.cihper.lock().await.encrypt(text.as_bytes())?; + self.tx .lock() .await - .send(Message::Text(Utf8Bytes::from(serde_json::to_string( - message, - )?))) + .send(Message::Binary(encrypted.into())) .await?; Ok(()) } - - pub async fn tx(&self) -> MutexGuard<'_, SplitSink> { - self.tx.lock().await - } }