Š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
+416
View File
@@ -0,0 +1,416 @@
use chrono::{Duration, Utc};
use dashmap::DashMap;
use prost::Message;
use sepot_proto::v1;
use sepot_proto::MAX_ENVELOPE_PAYLOAD;
use sqlx::PgPool;
use tokio::sync::mpsc;
use crate::certs::CertAuthority;
/// TTL offline fronty.
pub const QUEUE_TTL_DAYS: i64 = 30;
pub struct AppState {
pub pool: PgPool,
/// delivery_address → kanál na živé WS spojení zařízení.
pub online: DashMap<Vec<u8>, mpsc::Sender<v1::ServerFrame>>,
/// klientská IP → počet souběžných WS spojení (anti-flood cap).
/// Jen v paměti, nikam se neloguje ani neukládá.
pub ws_conns: DashMap<std::net::IpAddr, u32>,
pub certs: CertAuthority,
/// HTTP klient na push budíčky (bez redirectů, krátký timeout).
pub http: reqwest::Client,
/// Úložiště šifrovaných příloh (adresář + strop velikosti).
pub attachments: crate::attachments::Config,
/// FCM fast mode (None = nenakonfigurováno, budíčky jen UnifiedPush).
pub fcm: Option<std::sync::Arc<crate::fcm::FcmSender>>,
/// APNs nativní iOS push (None = nenakonfigurováno).
pub apns: Option<std::sync::Arc<crate::apns::ApnsSender>>,
}
impl AppState {
pub fn new(pool: PgPool, certs: CertAuthority, attachments: crate::attachments::Config) -> Self {
Self {
pool,
online: DashMap::new(),
ws_conns: DashMap::new(),
certs,
http: crate::push::build_client(),
attachments,
fcm: crate::fcm::FcmSender::from_env(),
apns: crate::apns::ApnsSender::from_env(),
}
}
/// Přijme obálku: validace → perzistentní fronta (dedup) → pokud je
/// příjemce online, doručí hned. At-least-once: záznam z fronty maže
/// až ACK příjemce, ne odeslání.
pub async fn route_envelope(
&self,
env: v1::Envelope,
) -> anyhow::Result<v1::receipt::Status> {
if env.dst_address.len() != 32
|| env.msg_uuid.len() != 16
|| env.payload.is_empty()
|| env.payload.len() > MAX_ENVELOPE_PAYLOAD
{
anyhow::bail!("neplatná obálka");
}
let dst = env.dst_address.clone();
let encoded = env.encode_to_vec();
let expires = Utc::now() + Duration::days(QUEUE_TTL_DAYS);
sqlx::query(
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at)
VALUES ($1, $2, $3, $4) ON CONFLICT (msg_uuid) DO NOTHING",
)
.bind(&env.msg_uuid)
.bind(&dst)
.bind(&encoded)
.bind(expires)
.execute(&self.pool)
.await?;
if let Some(tx) = self.online.get(&dst) {
let deliver = v1::ServerFrame {
frame: Some(v1::server_frame::Frame::Deliver(env)),
};
if tx.send(deliver).await.is_ok() {
return Ok(v1::receipt::Status::DeliveredOnline);
}
}
// Offline (nebo online kanál selhal) → zkusit push budíček.
self.wake(&dst).await;
Ok(v1::receipt::Status::StoredOffline)
}
/// Budíček pro offline příjemce: dohledá jeho push token a pošle prázdný
/// „ťuk". Best-effort, na pozadí — neblokuje receipt odesílateli.
/// Endpoint se NELOGUJE.
async fn wake(&self, dst_address: &[u8]) {
let Some((platform, token)) = self.push_target(dst_address).await else {
return; // bez tokenu / anonymní režim → žádný push
};
let client = self.http.clone();
let fcm = self.fcm.clone();
let apns = self.apns.clone();
tokio::spawn(async move {
crate::push::send_wake(&client, platform, &token, fcm.as_deref(), apns.as_deref())
.await;
});
}
/// Dohledá push cíl (platform, token) pro delivery_address příjemce.
/// `pub(crate)` kvůli integračnímu testu correctness JOINu.
pub(crate) async fn push_target(&self, dst_address: &[u8]) -> Option<(i16, Vec<u8>)> {
let row: Option<(i16, Option<Vec<u8>>)> = sqlx::query_as(
"SELECT p.platform, p.token FROM push_tokens p
JOIN devices d USING (device_uuid)
WHERE d.delivery_address = $1",
)
.bind(dst_address)
.fetch_optional(&self.pool)
.await
.ok()
.flatten();
match row {
Some((platform, Some(token))) => Some((platform, token)),
_ => None,
}
}
/// Registrace/aktualizace push endpointu zařízení. platform=NONE token
/// smaže (anonymní režim). Volá se z autentizovaného WS.
pub async fn register_push(
&self,
device_uuid: &[u8],
platform: i16,
endpoint: Option<&[u8]>,
) -> anyhow::Result<()> {
if platform == crate::push::PLATFORM_NONE || endpoint.is_none() {
sqlx::query("DELETE FROM push_tokens WHERE device_uuid = $1")
.bind(device_uuid)
.execute(&self.pool)
.await?;
return Ok(());
}
sqlx::query(
"INSERT INTO push_tokens (device_uuid, platform, token, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (device_uuid)
DO UPDATE SET platform = excluded.platform,
token = excluded.token,
updated_at = now()",
)
.bind(device_uuid)
.bind(platform)
.bind(endpoint)
.execute(&self.pool)
.await?;
Ok(())
}
/// Deregistrace účtu (GDPR čl. 17). Kaskáda v DB odnese devices →
/// prekeys + push_tokens; fronta zpráv je vázaná na delivery_address
/// bez FK, maže se explicitně v téže transakci. Přílohy vazbu na účet
/// nemají (bearer capability) — dožijí TTL sweep.
pub async fn delete_identity(&self, account_id: &[u8]) -> anyhow::Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"DELETE FROM message_queue WHERE dst_address IN (
SELECT delivery_address FROM devices WHERE account_id = $1)",
)
.bind(account_id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM identities WHERE account_id = $1")
.bind(account_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
/// Po AUTH: vyleje nedoručenou frontu zařízení (DELIVER stream).
pub async fn flush_queue(
&self,
delivery_address: &[u8],
tx: &mpsc::Sender<v1::ServerFrame>,
) -> anyhow::Result<usize> {
let rows: Vec<(Vec<u8>,)> = sqlx::query_as(
"SELECT envelope FROM message_queue
WHERE dst_address = $1 AND expires_at > now()
ORDER BY enqueued_at",
)
.bind(delivery_address)
.fetch_all(&self.pool)
.await?;
let count = rows.len();
for (encoded,) in rows {
let env = v1::Envelope::decode(encoded.as_slice())?;
let frame = v1::ServerFrame {
frame: Some(v1::server_frame::Frame::Deliver(env)),
};
if tx.send(frame).await.is_err() {
break;
}
}
Ok(count)
}
/// ACK příjemce → hard-delete z fronty (žádná retence po doručení).
pub async fn ack(&self, delivery_address: &[u8], msg_uuid: &[u8]) -> anyhow::Result<()> {
sqlx::query("DELETE FROM message_queue WHERE msg_uuid = $1 AND dst_address = $2")
.bind(msg_uuid)
.bind(delivery_address)
.execute(&self.pool)
.await?;
Ok(())
}
}
#[cfg(test)]
mod db_tests {
//! Integrace push registrace nad reálným Postgresem. #[ignore] — spouštět
//! s běžící dev DB: `cargo test -p sepot-relay -- --ignored`. Používá
//! náhodná ID a po sobě uklízí (žádná pollution existujících dat).
use super::*;
use rand::RngCore;
async fn test_state() -> AppState {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://sepot:sepot_dev@127.0.0.1/sepot_relay".into());
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&url)
.await
.expect("dev DB");
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
AppState::new(pool, certs, crate::attachments::Config::from_env())
}
fn rand_bytes(n: usize) -> Vec<u8> {
let mut v = vec![0u8; n];
rand::rng().fill_bytes(&mut v);
v
}
/// Naseje identitu+zařízení. Vrací (account_id, device_uuid, delivery_address).
async fn seed_device(state: &AppState) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let account_id = rand_bytes(16);
let identity_key = rand_bytes(33); // CHECK octet_length = 33
let device_uuid = rand_bytes(16);
let delivery_address = rand_bytes(32);
sqlx::query("INSERT INTO identities (account_id, identity_key) VALUES ($1, $2)")
.bind(&account_id)
.bind(&identity_key)
.execute(&state.pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO devices
(device_uuid, account_id, device_id, registration_id, delivery_address)
VALUES ($1, $2, 1, 1, $3)",
)
.bind(&device_uuid)
.bind(&account_id)
.bind(&delivery_address)
.execute(&state.pool)
.await
.unwrap();
(account_id, device_uuid, delivery_address)
}
async fn cleanup(state: &AppState, account_id: &[u8], delivery_address: &[u8]) {
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
.bind(delivery_address)
.execute(&state.pool)
.await
.unwrap();
// CASCADE smaže devices i push_tokens
sqlx::query("DELETE FROM identities WHERE account_id = $1")
.bind(account_id)
.execute(&state.pool)
.await
.unwrap();
}
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn register_push_upsert_a_lookup() {
let state = test_state().await;
let (account_id, device_uuid, delivery_address) = seed_device(&state).await;
// bez registrace → žádný cíl
assert!(state.push_target(&delivery_address).await.is_none());
// UnifiedPush endpoint
let ep1 = b"https://ntfy.sh/UPabc".to_vec();
state
.register_push(&device_uuid, crate::push::PLATFORM_UNIFIEDPUSH, Some(&ep1))
.await
.unwrap();
assert_eq!(
state.push_target(&delivery_address).await,
Some((crate::push::PLATFORM_UNIFIEDPUSH, ep1))
);
// upsert na WebPush (jiná platforma i token)
let ep2 = b"https://push.example/w".to_vec();
state
.register_push(&device_uuid, crate::push::PLATFORM_WEBPUSH, Some(&ep2))
.await
.unwrap();
assert_eq!(
state.push_target(&delivery_address).await,
Some((crate::push::PLATFORM_WEBPUSH, ep2))
);
// NONE → smaže (anonymní režim)
state
.register_push(&device_uuid, crate::push::PLATFORM_NONE, None)
.await
.unwrap();
assert!(state.push_target(&delivery_address).await.is_none());
cleanup(&state, &account_id, &delivery_address).await;
}
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn route_envelope_offline_ulozi_a_spusti_wake() {
let state = test_state().await;
let (account_id, device_uuid, delivery_address) = seed_device(&state).await;
// Endpoint schválně na loopback → guard ho odmítne, žádný externí POST
// (test zůstává hermetický). Ověřujeme, že offline cesta projde a
// route_envelope se na budíčku nezhroutí.
state
.register_push(
&device_uuid,
crate::push::PLATFORM_UNIFIEDPUSH,
Some(b"https://localhost/up"),
)
.await
.unwrap();
let env = v1::Envelope {
dst_address: delivery_address.clone(),
msg_uuid: rand_bytes(16),
content_hint: 0,
payload: vec![0xAB; 64],
};
let status = state.route_envelope(env).await.unwrap();
assert_eq!(status, v1::receipt::Status::StoredOffline);
// obálka je ve frontě (příjemce byl offline)
let queued: i64 =
sqlx::query_scalar("SELECT count(*) FROM message_queue WHERE dst_address = $1")
.bind(&delivery_address)
.fetch_one(&state.pool)
.await
.unwrap();
assert_eq!(queued, 1);
cleanup(&state, &account_id, &delivery_address).await;
}
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn delete_identity_smaze_ucet_frontu_i_push() {
let state = test_state().await;
let (account_id, device_uuid, delivery_address) = seed_device(&state).await;
// účet má push token i nedoručenou zprávu ve frontě
state
.register_push(
&device_uuid,
crate::push::PLATFORM_UNIFIEDPUSH,
Some(b"https://localhost/up"),
)
.await
.unwrap();
sqlx::query(
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at)
VALUES ($1, $2, $3, now() + interval '30 days')",
)
.bind(rand_bytes(16))
.bind(&delivery_address)
.bind(vec![0xCDu8; 64])
.execute(&state.pool)
.await
.unwrap();
state.delete_identity(&account_id).await.unwrap();
let identities: i64 =
sqlx::query_scalar("SELECT count(*) FROM identities WHERE account_id = $1")
.bind(&account_id)
.fetch_one(&state.pool)
.await
.unwrap();
let devices: i64 =
sqlx::query_scalar("SELECT count(*) FROM devices WHERE account_id = $1")
.bind(&account_id)
.fetch_one(&state.pool)
.await
.unwrap();
let queued: i64 =
sqlx::query_scalar("SELECT count(*) FROM message_queue WHERE dst_address = $1")
.bind(&delivery_address)
.fetch_one(&state.pool)
.await
.unwrap();
let tokens: i64 =
sqlx::query_scalar("SELECT count(*) FROM push_tokens WHERE device_uuid = $1")
.bind(&device_uuid)
.fetch_one(&state.pool)
.await
.unwrap();
assert_eq!((identities, devices, queued, tokens), (0, 0, 0, 0));
// idempotence: druhé smazání neexistujícího účtu nesmí spadnout
state.delete_identity(&account_id).await.unwrap();
}
}