core: E2E payload encryption — server (and LAN) only ever see ciphertext
Encrypt clipboard text and file bytes at the engine with ChaCha20-Poly1305 (nonce‖ciphertext; clipboard base64'd into the text field) using a key derived from the shared account secret. Sealing happens BEFORE any transport, so LAN, the SSE relay, and RTC all carry only ciphertext — the server is reduced to pure signaling (presence/SDP/ICE), never content. RTC stays doubly protected under DTLS. Receive decrypts in the message + file sinks; undecryptable payloads (wrong key) are dropped. Plaintext passthrough when signed out (account=""). Honest caveat (in crypto.rs): the key is sha256(account) and the account is an email — low entropy, so this stops passive eavesdroppers but not someone who knows the account. The real fix is a high-entropy secret (Sign in with Apple `sub` / passphrase) → same from_secret() API. Verified by examples/e2e_demo.rs: B decrypts A's clipboard over LAN; a relayed send carries ciphertext only (plaintext never on the bus). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Generated
+3
@@ -2456,8 +2456,11 @@ dependencies = [
|
|||||||
name = "tethercore"
|
name = "tethercore"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chacha20poly1305",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"getrandom 0.4.3",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -26,3 +26,6 @@ webrtc = "0.17.1"
|
|||||||
mdns-sd = "0.20.0"
|
mdns-sd = "0.20.0"
|
||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
bytes = "1.12.0"
|
bytes = "1.12.0"
|
||||||
|
chacha20poly1305 = "0.10.1"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
getrandom = "0.4.3"
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//! Proves payload E2E encryption.
|
||||||
|
//! cargo run --example e2e_demo -- <room> # round-trip (A→B decrypts)
|
||||||
|
//! SOLO=1 cargo run --example e2e_demo -- <room> # 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<u8>) {}
|
||||||
|
}
|
||||||
|
struct Collector(mpsc::Sender<String>);
|
||||||
|
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<u8>) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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<ChaCha20Poly1305>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<u8> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<String> {
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
|
||||||
|
String::from_utf8(self.open(&bytes)?).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-4
@@ -22,9 +22,12 @@ use futures_util::StreamExt;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::runtime::Handle;
|
use tokio::runtime::Handle;
|
||||||
|
|
||||||
|
mod crypto;
|
||||||
mod lan;
|
mod lan;
|
||||||
mod rtc;
|
mod rtc;
|
||||||
|
|
||||||
|
use crypto::Crypto;
|
||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
|
|
||||||
/// 1:1 with the Go server `Message`, minus the RTC `signal` blob.
|
/// 1:1 with the Go server `Message`, minus the RTC `signal` blob.
|
||||||
@@ -297,6 +300,7 @@ pub struct Engine {
|
|||||||
present: PresentSet,
|
present: PresentSet,
|
||||||
file_counter: AtomicU64,
|
file_counter: AtomicU64,
|
||||||
transfers: Transfers,
|
transfers: Transfers,
|
||||||
|
crypto: Crypto,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
@@ -321,6 +325,7 @@ impl Engine {
|
|||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
.expect("tokio runtime");
|
.expect("tokio runtime");
|
||||||
|
let crypto = Crypto::from_secret(&account);
|
||||||
let cfg = Config { server, room, from, source, name, account };
|
let cfg = Config { server, room, from, source, name, account };
|
||||||
let discovered: Discovered = Arc::new(Mutex::new(HashMap::new()));
|
let discovered: Discovered = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let direct: DirectSet = Arc::new(Mutex::new(HashSet::new()));
|
let direct: DirectSet = Arc::new(Mutex::new(HashSet::new()));
|
||||||
@@ -356,6 +361,7 @@ impl Engine {
|
|||||||
present,
|
present,
|
||||||
file_counter: AtomicU64::new(0),
|
file_counter: AtomicU64::new(0),
|
||||||
transfers,
|
transfers,
|
||||||
|
crypto,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +379,7 @@ impl Engine {
|
|||||||
pub fn send_file(&self, name: String, mime: String, data: Vec<u8>) {
|
pub fn send_file(&self, name: String, mime: String, data: Vec<u8>) {
|
||||||
let n = self.file_counter.fetch_add(1, Ordering::SeqCst);
|
let n = self.file_counter.fetch_add(1, Ordering::SeqCst);
|
||||||
let id = format!("{:016x}", n);
|
let id = format!("{:016x}", n);
|
||||||
|
let data = self.crypto.seal(&data); // E2E: chunks carry ciphertext
|
||||||
let rt = self.rt.handle();
|
let rt = self.rt.handle();
|
||||||
for t in &self.transports {
|
for t in &self.transports {
|
||||||
// Direct transports only — files are never relayed over SSE.
|
// Direct transports only — files are never relayed over SSE.
|
||||||
@@ -424,7 +431,8 @@ impl Engine {
|
|||||||
let rt = self.rt.clone();
|
let rt = self.rt.clone();
|
||||||
let direct = self.direct.clone();
|
let direct = self.direct.clone();
|
||||||
let present = self.present.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 <name>".
|
// Delivery acks addressed to us → record "seen by <name>".
|
||||||
if m.kind == "receipt" {
|
if m.kind == "receipt" {
|
||||||
if m.to == cfg.from {
|
if m.to == cfg.from {
|
||||||
@@ -444,7 +452,14 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
return;
|
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() {
|
if m.text.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -477,6 +492,7 @@ impl Engine {
|
|||||||
let status: Status = Arc::new(move |c| h_status.on_status(c));
|
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.
|
// A file can arrive over both RTC and LAN — de-dup so on_file fires once.
|
||||||
let h_file = handler.clone();
|
let h_file = handler.clone();
|
||||||
|
let crypto_f = self.crypto.clone();
|
||||||
let file_seen: Arc<Mutex<VecDeque<(String, Instant)>>> = Arc::new(Mutex::new(VecDeque::new()));
|
let file_seen: Arc<Mutex<VecDeque<(String, Instant)>>> = Arc::new(Mutex::new(VecDeque::new()));
|
||||||
let files: FileSink = Arc::new(move |name: String, mime: String, data: Vec<u8>| {
|
let files: FileSink = Arc::new(move |name: String, mime: String, data: Vec<u8>| {
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -493,7 +509,12 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
seen.push_back((key, now));
|
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 {
|
for t in &self.transports {
|
||||||
@@ -514,7 +535,7 @@ impl Engine {
|
|||||||
pub fn send(&self, text: String) {
|
pub fn send(&self, text: String) {
|
||||||
let msg = Message {
|
let msg = Message {
|
||||||
kind: "clipboard".into(),
|
kind: "clipboard".into(),
|
||||||
text,
|
text: self.crypto.seal_b64(&text), // E2E: every transport carries ciphertext
|
||||||
from: self.cfg.from.clone(),
|
from: self.cfg.from.clone(),
|
||||||
to: String::new(),
|
to: String::new(),
|
||||||
role: String::new(),
|
role: String::new(),
|
||||||
|
|||||||
Reference in New Issue
Block a user