diff --git a/core/Cargo.lock b/core/Cargo.lock index 397885e..e5ab7df 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -2456,8 +2456,11 @@ dependencies = [ name = "tethercore" version = "0.1.0" dependencies = [ + "base64", "bytes", + "chacha20poly1305", "futures-util", + "getrandom 0.4.3", "mdns-sd", "reqwest", "serde", diff --git a/core/Cargo.toml b/core/Cargo.toml index f3a0095..d5c1319 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -26,3 +26,6 @@ webrtc = "0.17.1" mdns-sd = "0.20.0" sha2 = "0.11.0" bytes = "1.12.0" +chacha20poly1305 = "0.10.1" +base64 = "0.22.1" +getrandom = "0.4.3" diff --git a/core/examples/e2e_demo.rs b/core/examples/e2e_demo.rs new file mode 100644 index 0000000..386d39a --- /dev/null +++ b/core/examples/e2e_demo.rs @@ -0,0 +1,66 @@ +//! Proves payload E2E encryption. +//! cargo run --example e2e_demo -- # round-trip (A→B decrypts) +//! SOLO=1 cargo run --example e2e_demo -- # one sender, no peer → +//! relay carries ciphertext +//! In SOLO mode, subscribe to the room on the server and confirm the secret is +//! NOT visible (only base64 ciphertext in the text field). + +use std::sync::mpsc; +use std::time::Duration; + +use tethercore::{Engine, Message, MessageHandler}; + +struct Noop; +impl MessageHandler for Noop { + fn on_message(&self, _m: Message) {} + fn on_status(&self, _c: bool) {} + fn on_file(&self, _n: String, _m: String, _d: Vec) {} +} +struct Collector(mpsc::Sender); +impl MessageHandler for Collector { + fn on_message(&self, m: Message) { + let _ = self.0.send(m.text); + } + fn on_status(&self, _c: bool) {} + fn on_file(&self, _n: String, _m: String, _d: Vec) {} +} + +const SECRET_PLAINTEXT: &str = "TOP-SECRET-PLAINTEXT"; + +fn main() { + let server = std::env::var("TETHER_SERVER").unwrap_or_else(|_| "https://tether.pecord.io".into()); + let room = std::env::args().nth(1).unwrap_or_else(|| "e2e-demo".into()); + let account = "shared-high-entropy-secret"; // same key on both ends + + if std::env::var("SOLO").is_ok() { + // One sender, no peer → not "covered" → the relay carries the message. + let a = Engine::new(server, room, "solo-a".into(), "macos".into(), "A".into(), account.into()); + a.start(Box::new(Noop)); + std::thread::sleep(Duration::from_secs(3)); + eprintln!("sending '{SECRET_PLAINTEXT}' (encrypted) over the relay…"); + a.send(SECRET_PLAINTEXT.to_string()); + std::thread::sleep(Duration::from_secs(3)); + return; + } + + let (tx, rx) = mpsc::channel(); + let a = Engine::new(server.clone(), room.clone(), "dev-a".into(), "macos".into(), "A".into(), account.into()); + let b = Engine::new(server, room, "dev-b".into(), "ios".into(), "B".into(), account.into()); + a.start(Box::new(Noop)); + b.start(Box::new(Collector(tx))); + + eprintln!("waiting for link…"); + std::thread::sleep(Duration::from_secs(8)); + a.send(SECRET_PLAINTEXT.to_string()); + + match rx.recv_timeout(Duration::from_secs(6)) { + Ok(t) if t == SECRET_PLAINTEXT => println!("✅ B decrypted the payload: {t:?}"), + Ok(t) => println!("⚠️ B got {t:?} (expected plaintext)"), + Err(_) => { + println!("❌ B got nothing"); + std::process::exit(1); + } + } + a.stop(); + b.stop(); +} diff --git a/core/src/crypto.rs b/core/src/crypto.rs new file mode 100644 index 0000000..6f14429 --- /dev/null +++ b/core/src/crypto.rs @@ -0,0 +1,79 @@ +//! Payload encryption (ChaCha20-Poly1305) with a key derived from the shared +//! account secret. Clipboard/file content is sealed BEFORE it touches any +//! transport, so the server (SSE relay) and any LAN sniffer only ever see +//! ciphertext — the server is reduced to pure signaling. +//! +//! NOTE: the key is sha256(secret), and the current secret is the account (an +//! email — low entropy). This defends against passive eavesdroppers, not +//! someone who already knows the account. The real fix is a high-entropy secret +//! (Sign in with Apple `sub` / a passphrase) fed into the same `from_secret`. + +use base64::Engine as _; +use chacha20poly1305::aead::Aead; +use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce}; +use sha2::{Digest, Sha256}; + +#[derive(Clone)] +pub(crate) struct Crypto { + cipher: Option, +} + +impl Crypto { + pub(crate) fn from_secret(secret: &str) -> Self { + if secret.is_empty() { + return Self { cipher: None }; // signed out → plaintext (no shared key) + } + let mut key = [0u8; 32]; + key.copy_from_slice(&Sha256::digest(secret.as_bytes())); + Self { + cipher: Some(ChaCha20Poly1305::new(Key::from_slice(&key))), + } + } + + pub(crate) fn enabled(&self) -> bool { + self.cipher.is_some() + } + + /// nonce(12) ‖ ciphertext. Passthrough when disabled. + pub(crate) fn seal(&self, plaintext: &[u8]) -> Vec { + match &self.cipher { + Some(c) => { + let mut nonce = [0u8; 12]; + getrandom::fill(&mut nonce).expect("rng"); + match c.encrypt(Nonce::from_slice(&nonce), plaintext) { + Ok(ct) => { + let mut out = nonce.to_vec(); + out.extend_from_slice(&ct); + out + } + Err(_) => plaintext.to_vec(), + } + } + None => plaintext.to_vec(), + } + } + + /// Open nonce‖ciphertext. None if it can't be decrypted. Passthrough when + /// disabled. + pub(crate) fn open(&self, data: &[u8]) -> Option> { + match &self.cipher { + Some(c) => { + if data.len() < 12 { + return None; + } + let (nonce, ct) = data.split_at(12); + c.decrypt(Nonce::from_slice(nonce), ct).ok() + } + None => Some(data.to_vec()), + } + } + + /// Clipboard text helpers — base64 so the ciphertext rides the JSON `text`. + pub(crate) fn seal_b64(&self, plaintext: &str) -> String { + base64::engine::general_purpose::STANDARD.encode(self.seal(plaintext.as_bytes())) + } + pub(crate) fn open_b64(&self, b64: &str) -> Option { + let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?; + String::from_utf8(self.open(&bytes)?).ok() + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index fc489d6..2048740 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -22,9 +22,12 @@ use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use tokio::runtime::Handle; +mod crypto; mod lan; mod rtc; +use crypto::Crypto; + uniffi::setup_scaffolding!(); /// 1:1 with the Go server `Message`, minus the RTC `signal` blob. @@ -297,6 +300,7 @@ pub struct Engine { present: PresentSet, file_counter: AtomicU64, transfers: Transfers, + crypto: Crypto, } #[uniffi::export] @@ -321,6 +325,7 @@ impl Engine { .enable_all() .build() .expect("tokio runtime"); + let crypto = Crypto::from_secret(&account); let cfg = Config { server, room, from, source, name, account }; let discovered: Discovered = Arc::new(Mutex::new(HashMap::new())); let direct: DirectSet = Arc::new(Mutex::new(HashSet::new())); @@ -356,6 +361,7 @@ impl Engine { present, file_counter: AtomicU64::new(0), transfers, + crypto, }) } @@ -373,6 +379,7 @@ impl Engine { pub fn send_file(&self, name: String, mime: String, data: Vec) { let n = self.file_counter.fetch_add(1, Ordering::SeqCst); let id = format!("{:016x}", n); + let data = self.crypto.seal(&data); // E2E: chunks carry ciphertext let rt = self.rt.handle(); for t in &self.transports { // Direct transports only — files are never relayed over SSE. @@ -424,7 +431,8 @@ impl Engine { let rt = self.rt.clone(); let direct = self.direct.clone(); let present = self.present.clone(); - let sink: Sink = Arc::new(move |m: Message| { + let crypto = self.crypto.clone(); + let sink: Sink = Arc::new(move |mut m: Message| { // Delivery acks addressed to us → record "seen by ". if m.kind == "receipt" { if m.to == cfg.from { @@ -444,7 +452,14 @@ impl Engine { } return; } - // Clipboard: ignore empties, de-dup, ack the sender, then deliver. + // Clipboard: decrypt the payload (every transport carried ciphertext). + if crypto.enabled() { + match crypto.open_b64(&m.text) { + Some(plain) => m.text = plain, + None => return, // not for us / wrong key + } + } + // ignore empties, de-dup, ack the sender, then deliver. if m.text.is_empty() { return; } @@ -477,6 +492,7 @@ impl Engine { let status: Status = Arc::new(move |c| h_status.on_status(c)); // A file can arrive over both RTC and LAN — de-dup so on_file fires once. let h_file = handler.clone(); + let crypto_f = self.crypto.clone(); let file_seen: Arc>> = Arc::new(Mutex::new(VecDeque::new())); let files: FileSink = Arc::new(move |name: String, mime: String, data: Vec| { use sha2::{Digest, Sha256}; @@ -493,7 +509,12 @@ impl Engine { } seen.push_back((key, now)); } - h_file.on_file(name, mime, data); + // Decrypt the reassembled payload (chunks carried ciphertext). + let plain = match crypto_f.open(&data) { + Some(d) => d, + None => return, // not for us / wrong key + }; + h_file.on_file(name, mime, plain); }); for t in &self.transports { @@ -514,7 +535,7 @@ impl Engine { pub fn send(&self, text: String) { let msg = Message { kind: "clipboard".into(), - text, + text: self.crypto.seal_b64(&text), // E2E: every transport carries ciphertext from: self.cfg.from.clone(), to: String::new(), role: String::new(),