Šepot — end-to-end encrypted messenger

Initial public release. Anonymous on-device identity (no phone number,
e-mail, or password), Signal-protocol cryptography (X3DH + PQXDH, Double
Ratchet, sealed sender) via libsignal, a metadata-minimizing relay,
group chats, disappearing messages, encrypted local storage, optional
Tor transport, and a UI localized into Czech, English, German, Russian,
and Italian.

Monorepo: Flutter client (app/), Rust relay + wire types (rust/),
protobuf definitions (proto/), self-host deploy (deploy/).

Licensed under AGPL-3.0 with an app-store distribution exception (§7).
This commit is contained in:
Julius Joska
2026-07-11 17:06:39 +02:00
commit 8081d98c67
309 changed files with 44102 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
//! WS relay kanál: AUTH → CHALLENGE → AUTH_PROOF,
//! pak ENVELOPE/ACK od klienta a RECEIPT/DELIVER od serveru.
//! Logy záměrně bez identifikátorů zařízení a bez IP.
//!
//! Anti-abuse (minimální sada):
//! - cap souběžných spojení per IP (flood ochranu doplňuje CrowdSec na edgi)
//! - deadline na AUTH — neautentizované spojení se po pár sekundách zavře
//! - max velikost WS zprávy těsně nad MAX_ENVELOPE_PAYLOAD
//! - rate limit rámců per spojení (token okno) — přetečení = disconnect
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{ConnectInfo, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use futures_util::{SinkExt, StreamExt};
use libsignal_protocol::PublicKey;
use prost::Message;
use rand::RngCore;
use sepot_proto::{v1, AUTH_CONTEXT, MAX_ENVELOPE_PAYLOAD};
use tokio::sync::mpsc;
use tokio::time::Instant;
use crate::state::AppState;
/// Max souběžných WS spojení z jedné IP (za traefikem = reálná klientská
/// IP z X-Real-Ip; tor kontejner sdílí jednu — proto ne moc těsně).
const MAX_CONNS_PER_IP: u32 = 32;
/// Neautentizované spojení zavřít po tomto čase.
const AUTH_DEADLINE: Duration = Duration::from_secs(10);
/// Rate limit rámců od klienta: max FRAME_WINDOW_MAX za FRAME_WINDOW.
const FRAME_WINDOW: Duration = Duration::from_secs(10);
const FRAME_WINDOW_MAX: u32 = 200;
/// Klientská IP: hlavičky od trusted proxy (na relay dosáhne jen traefik
/// z docker sítě), fallback peer adresa (dev/tor).
///
/// TRUST MODEL — POZOR: X-Real-Ip / X-Forwarded-For se přebírají bezpodmínečně
/// a řídí per-IP limity (cap spojení, rate limit). To je bezpečné VÝHRADNĚ
/// proto, že před relayem stojí důvěryhodná reverzní proxy (traefik), která
/// tyhle hlavičky vždy přepisuje reálnou klientskou IP. Klientem poslané
/// hodnoty tak nikdy neprojdou. Kdyby byl relay vystaven přímo do internetu
/// (bez proxy, která hlavičky sanitizuje), útočník by je mohl libovolně
/// podvrhnout, tím rozprostřít provoz přes falešné IP a všechny per-IP limity
/// obejít. Relay proto NIKDY nenasazovat bez takové proxy.
fn client_ip(headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
let from_header = |name: &str| {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.and_then(|v| v.trim().parse::<IpAddr>().ok())
};
from_header("x-real-ip")
.or_else(|| from_header("x-forwarded-for"))
.unwrap_or_else(|| peer.ip())
}
/// Drop-guard: úbytek čítače spojení i při panice/cancelu handleru.
struct ConnGuard {
state: Arc<AppState>,
ip: IpAddr,
}
impl Drop for ConnGuard {
fn drop(&mut self) {
self.state.ws_conns.remove_if_mut(&self.ip, |_, n| {
*n -= 1;
*n == 0
});
}
}
pub async fn ws_handler(
State(state): State<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
let ip = client_ip(&headers, peer);
let over_cap = {
let mut entry = state.ws_conns.entry(ip).or_insert(0);
if *entry >= MAX_CONNS_PER_IP {
true
} else {
*entry += 1;
false
}
};
if over_cap {
// Log bez IP — jen fakt, že cap zafungoval
tracing::debug!("ws spojení odmítnuto (cap per IP)");
return StatusCode::TOO_MANY_REQUESTS.into_response();
}
let guard = ConnGuard {
state: state.clone(),
ip,
};
ws.max_message_size(MAX_ENVELOPE_PAYLOAD + 4096)
.max_frame_size(MAX_ENVELOPE_PAYLOAD + 4096)
.on_upgrade(move |socket| async move {
let _guard = guard;
handle(state, socket).await;
})
.into_response()
}
/// Stav mezi AUTH a AUTH_PROOF.
struct Pending {
nonce_client: Vec<u8>,
nonce_server: Vec<u8>,
delivery_address: Vec<u8>,
identity_key: Vec<u8>,
device_uuid: Vec<u8>,
}
fn server_frame(frame: v1::server_frame::Frame) -> v1::ServerFrame {
v1::ServerFrame { frame: Some(frame) }
}
fn error_frame(code: u32, message: &str) -> v1::ServerFrame {
server_frame(v1::server_frame::Frame::Error(v1::Error {
code,
message: message.into(),
}))
}
async fn handle(state: Arc<AppState>, socket: WebSocket) {
let (mut sink, mut stream) = socket.split();
let (tx, mut rx) = mpsc::channel::<v1::ServerFrame>(64);
let writer = tokio::spawn(async move {
while let Some(frame) = rx.recv().await {
let bytes = frame.encode_to_vec();
if sink.send(WsMessage::Binary(bytes.into())).await.is_err() {
break;
}
}
});
let mut pending: Option<Pending> = None;
let mut authed_address: Option<Vec<u8>> = None;
// device_uuid ověřeného zařízení — klíč pro push registraci.
let mut authed_device: Option<Vec<u8>> = None;
// Anti-abuse okno: počítá VŠECHNY rámce od klienta (envelope/ack/pong)
let mut window_start = Instant::now();
let mut window_frames: u32 = 0;
loop {
// Před AUTH platí deadline — viset neautentizovaný nejde
let next = if authed_address.is_none() {
match tokio::time::timeout(AUTH_DEADLINE, stream.next()).await {
Ok(next) => next,
Err(_) => {
tracing::debug!("ws zavřeno: AUTH deadline");
break;
}
}
} else {
stream.next().await
};
let Some(Ok(msg)) = next else { break };
let now = Instant::now();
if now.duration_since(window_start) > FRAME_WINDOW {
window_start = now;
window_frames = 0;
}
window_frames += 1;
if window_frames > FRAME_WINDOW_MAX {
let _ = tx.send(error_frame(429, "příliš mnoho rámců")).await;
break;
}
let data = match msg {
WsMessage::Binary(b) => b,
WsMessage::Close(_) => break,
_ => continue,
};
let Ok(frame) = v1::ClientFrame::decode(data.as_ref()) else {
let _ = tx.send(error_frame(400, "neplatný rámec")).await;
break;
};
match frame.frame {
Some(v1::client_frame::Frame::Auth(auth)) if authed_address.is_none() => {
if auth.device_uuid.len() != 16 || auth.nonce_client.len() != 16 {
let _ = tx.send(error_frame(400, "neplatný AUTH")).await;
break;
}
let row: Option<(Vec<u8>, Vec<u8>)> = sqlx::query_as(
"SELECT d.delivery_address, i.identity_key
FROM devices d JOIN identities i USING (account_id)
WHERE d.device_uuid = $1",
)
.bind(&auth.device_uuid)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let Some((delivery_address, identity_key)) = row else {
let _ = tx.send(error_frame(401, "neautorizováno")).await;
break;
};
let mut nonce_server = vec![0u8; 32];
rand::rng().fill_bytes(&mut nonce_server);
let _ = tx
.send(server_frame(v1::server_frame::Frame::Challenge(
v1::Challenge {
nonce_server: nonce_server.clone(),
},
)))
.await;
pending = Some(Pending {
nonce_client: auth.nonce_client,
nonce_server,
delivery_address,
identity_key,
device_uuid: auth.device_uuid,
});
}
Some(v1::client_frame::Frame::AuthProof(proof)) if pending.is_some() => {
let pd = pending.take().expect("pending ověřen výše");
let mut message =
Vec::with_capacity(pd.nonce_client.len() + pd.nonce_server.len() + AUTH_CONTEXT.len());
message.extend_from_slice(&pd.nonce_client);
message.extend_from_slice(&pd.nonce_server);
message.extend_from_slice(AUTH_CONTEXT);
let verified = PublicKey::deserialize(&pd.identity_key)
.map(|pk| pk.verify_signature(&message, &proof.signature))
.unwrap_or(false);
if !verified {
let _ = tx.send(error_frame(401, "neplatný podpis")).await;
break;
}
state
.online
.insert(pd.delivery_address.clone(), tx.clone());
authed_address = Some(pd.delivery_address.clone());
// Potvrzení AUTH — klient až teď smí hlásit „připojeno"
let _ = tx
.send(server_frame(v1::server_frame::Frame::AuthOk(
v1::AuthOk {},
)))
.await;
// last_seen jen v hrubé granularitě (privacy — zaokrouhleno na hodinu).
let _ = sqlx::query(
"UPDATE devices SET last_seen_at = date_trunc('hour', now())
WHERE device_uuid = $1",
)
.bind(&pd.device_uuid)
.execute(&state.pool)
.await;
match state.flush_queue(&pd.delivery_address, &tx).await {
Ok(count) => {
tracing::info!(count, "klient autentizován, fronta vylita")
}
Err(e) => tracing::warn!("flush fronty selhal: {e}"),
}
authed_device = Some(pd.device_uuid);
}
Some(v1::client_frame::Frame::RegisterPush(reg)) if authed_address.is_some() => {
if let Some(device) = &authed_device {
let platform = reg.platform as i16;
if platform < 0 || platform > crate::push::PLATFORM_MAX {
let _ = tx.send(error_frame(400, "neznámá push platforma")).await;
} else {
let endpoint =
(!reg.endpoint.is_empty()).then_some(reg.endpoint.as_slice());
// Endpoint se ZÁMĚRNĚ neloguje (metadatový bod).
if let Err(e) =
state.register_push(device, platform, endpoint).await
{
tracing::warn!("register_push selhal: {e}");
}
}
}
}
Some(v1::client_frame::Frame::Envelope(envelope)) if authed_address.is_some() => {
let msg_uuid = envelope.msg_uuid.clone();
match state.route_envelope(envelope).await {
Ok(status) => {
let _ = tx
.send(server_frame(v1::server_frame::Frame::Receipt(
v1::Receipt {
msg_uuid,
status: status as i32,
},
)))
.await;
}
Err(_) => {
let _ = tx.send(error_frame(400, "obálka odmítnuta")).await;
}
}
}
Some(v1::client_frame::Frame::Ack(ack)) if authed_address.is_some() => {
if let Some(addr) = &authed_address {
if let Err(e) = state.ack(addr, &ack.msg_uuid).await {
tracing::warn!("ack selhal: {e}");
}
}
}
Some(v1::client_frame::Frame::Pong(_)) => {}
_ => {
let _ = tx.send(error_frame(403, "rámec mimo pořadí")).await;
break;
}
}
}
if let Some(addr) = authed_address {
state.online.remove(&addr);
}
writer.abort();
tracing::debug!("ws spojení uzavřeno");
}