Š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
+439
View File
@@ -0,0 +1,439 @@
/// Klient hloupého relaye — REST (registrace, prekey bundle, sender
/// certificate) + WS (AUTH výzva, obálky, doručení, ACK).
///
/// Wire = protobuf rámce z `proto/sepot.proto` (jediný zdroj pravdy pro
/// Dart i Rust). Klient NIKDY neposílá nic nešifrovaného — payload obálky
/// je sealed-sender ciphertext, relay vidí jen dst_address + msg_uuid.
///
/// ACK sémantika: relay maže zprávu z fronty při ACK (hard delete).
/// ACK se proto posílá až PO uložení dešifrované zprávy na disk —
/// jinak pád mezi doručením a uložením = ztracená zpráva.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/network/relay_transport.dart';
import 'package:sepot/proto/sepot.pb.dart' as pb;
import 'package:web_socket_channel/web_socket_channel.dart';
enum RelayConnectionState { disconnected, connecting, connected }
/// Výsledek registrace účtu na relayi.
class RelayRegistration {
RelayRegistration({required this.accountId, required this.deviceUuid});
final String accountId; // hex
final String deviceUuid; // hex
}
class RelayClient {
RelayClient({
required this.baseUrl,
required SepotCrypto crypto,
RelayTransport? transport,
}) : _crypto = crypto,
_transport = transport ?? DirectTransport();
final String baseUrl;
final SepotCrypto _crypto;
/// Jak se otevírají spojení (přímé vs Tor). Vlastní ho tento klient —
/// [dispose] ho zavře.
final RelayTransport _transport;
String get _wsUrl => baseUrl.replaceFirst('http', 'ws');
String? _deviceUuid;
WebSocketChannel? _channel;
StreamSubscription<dynamic>? _wsSub;
Timer? _reconnectTimer;
var _reconnectDelayS = 1;
var _shouldRun = false;
/// Poslední požadovaná push registrace. Drží se, aby se dala znovu odeslat
/// po každém (re)connectu — WS je jediná autentizovaná cesta a endpoint se
/// u distributora může měnit.
pb.RegisterPush? _pendingPush;
final _stateController =
StreamController<RelayConnectionState>.broadcast();
final _deliverController = StreamController<pb.Envelope>.broadcast();
final _receiptController = StreamController<pb.Receipt>.broadcast();
var _state = RelayConnectionState.disconnected;
RelayConnectionState get state => _state;
Stream<RelayConnectionState> get stateStream => _stateController.stream;
/// Příchozí obálky (offline fronta i online doručení). Konzument MUSÍ
/// po uložení zavolat [ack].
Stream<pb.Envelope> get deliveries => _deliverController.stream;
void _setState(RelayConnectionState s) {
_state = s;
_stateController.add(s);
}
static Uint8List randomBytes(int n) {
final rng = Random.secure();
return Uint8List.fromList(List.generate(n, (_) => rng.nextInt(256)));
}
static Uint8List hexToBytes(String hexStr) => Uint8List.fromList([
for (var i = 0; i < hexStr.length; i += 2)
int.parse(hexStr.substring(i, i + 2), radix: 16),
]);
// ── REST ────────────────────────────────────────────────────────────
/// POST /v1/register — nahraje identitu + prekey bundle + dávku OTP.
Future<RelayRegistration> register({
required SepotPreKeyBundle bundle,
required List<SepotOneTimePreKey> oneTimePreKeys,
required Uint8List deliveryAddress,
}) async {
final resp = await _transport.httpClient().post(
Uri.parse('$baseUrl/v1/register'),
headers: {'content-type': 'application/json'},
body: jsonEncode({
'identity_key': base64Encode(_crypto.identityPublicKey),
'device_id': 1,
'registration_id': _crypto.registrationId,
'delivery_address': base64Encode(deliveryAddress),
'signed_prekey': {
'key_id': bundle.signedPreKeyId,
'public_key': base64Encode(bundle.signedPreKeyPublic),
'signature': base64Encode(bundle.signedPreKeySignature),
},
'kyber_prekey': {
'key_id': bundle.kyberPreKeyId,
'public_key': base64Encode(bundle.kyberPreKeyPublic),
'signature': base64Encode(bundle.kyberPreKeySignature),
'is_last_resort': true,
},
'one_time_prekeys': [
for (final otp in oneTimePreKeys)
{'key_id': otp.id, 'public_key': base64Encode(otp.publicKey)},
],
}),
);
if (resp.statusCode != 201) {
throw RelayException('registration failed', resp.statusCode, resp.body);
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final reg = RelayRegistration(
accountId: body['account_id'] as String,
deviceUuid: body['device_uuid'] as String,
);
_deviceUuid = reg.deviceUuid;
_crypto.bindAccount(reg.accountId);
return reg;
}
/// Obnoví vazbu po startu aplikace (registrace už proběhla dřív).
void restoreSession({required String accountId, required String deviceUuid}) {
_deviceUuid = deviceUuid;
_crypto.bindAccount(accountId);
}
/// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP).
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
final hexAddr = deliveryAddress
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final resp =
await _transport.httpClient().get(Uri.parse('$baseUrl/v1/keys/$hexAddr'));
if (resp.statusCode != 200) {
throw RelayException('prekey bundle fetch failed', resp.statusCode, resp.body);
}
final device = (jsonDecode(resp.body)['devices'] as List).first
as Map<String, dynamic>;
final spk = device['signed_prekey'] as Map<String, dynamic>;
final kyber = device['kyber_prekey'] as Map<String, dynamic>;
final otp = device['one_time_prekey'] as Map<String, dynamic>?;
return SepotPreKeyBundle(
registrationId: device['registration_id'] as int,
deviceId: device['device_id'] as int,
preKeyId: otp?['key_id'] as int?,
preKeyPublic: otp == null
? null
: Uint8List.fromList(base64Decode(otp['public_key'] as String)),
signedPreKeyId: spk['key_id'] as int,
signedPreKeyPublic:
Uint8List.fromList(base64Decode(spk['public_key'] as String)),
signedPreKeySignature:
Uint8List.fromList(base64Decode(spk['signature'] as String)),
identityKey:
Uint8List.fromList(base64Decode(device['identity_key'] as String)),
kyberPreKeyId: kyber['key_id'] as int,
kyberPreKeyPublic:
Uint8List.fromList(base64Decode(kyber['public_key'] as String)),
kyberPreKeySignature:
Uint8List.fromList(base64Decode(kyber['signature'] as String)),
);
}
/// POST /v1/attachments — nahraje šifrovaný blob přílohy. Tělo = raw
/// bajty (ciphertext || tag); relay z nich nic nevyčte a vazbu na
/// zařízení si neukládá. Auth stejným podpisem jako sender certificate.
Future<String> uploadAttachment(Uint8List blob) async {
final deviceUuid = _requireDevice();
final ts = DateTime.now().millisecondsSinceEpoch;
final signature = _crypto.sign(utf8.encode('POST /v1/attachments\n$ts'));
final resp = await _transport
.httpClient()
.post(
Uri.parse('$baseUrl/v1/attachments'),
headers: {
'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}',
'content-type': 'application/octet-stream',
},
body: blob,
)
.timeout(const Duration(minutes: 3));
if (resp.statusCode != 201) {
throw RelayException('attachment upload failed', resp.statusCode, resp.body);
}
return (jsonDecode(resp.body) as Map<String, dynamic>)['blob_id'] as String;
}
/// GET /v1/attachments/{blob_id} — stáhne šifrovaný blob. Bez auth
/// (blob_id = bearer capability z E2E zprávy). Integrita se ověřuje
/// až v AttachmentCipher.decrypt proti digestu ze zprávy.
Future<Uint8List> downloadAttachment(String blobId) async {
final resp = await _transport
.httpClient()
.get(Uri.parse('$baseUrl/v1/attachments/$blobId'))
.timeout(const Duration(minutes: 3));
if (resp.statusCode != 200) {
throw RelayException(
'attachment download failed', resp.statusCode, resp.body);
}
return resp.bodyBytes;
}
/// DELETE /v1/identity — deregistrace účtu na relayi (identita,
/// zařízení, prekeys, push token i nedoručená fronta). Auth stejným
/// podpisem jako sender certificate — smazat účet smí jen držitel
/// identity klíče. Volá se PŘED lokálním wipe (podpis potřebuje klíč).
Future<void> deleteIdentity() async {
final deviceUuid = _requireDevice();
final ts = DateTime.now().millisecondsSinceEpoch;
final signature = _crypto.sign(utf8.encode('DELETE /v1/identity\n$ts'));
final resp = await _transport
.httpClient()
.delete(
Uri.parse('$baseUrl/v1/identity'),
headers: {
'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}',
},
)
.timeout(const Duration(seconds: 15));
if (resp.statusCode != 204) {
throw RelayException(
'identity deletion failed', resp.statusCode, resp.body);
}
}
/// GET /v1/certificate/delivery — krátkodobý SenderCertificate
/// + pinovaný trust root relaye.
Future<(Uint8List cert, Uint8List trustRoot)>
fetchSenderCertificate() async {
final deviceUuid = _requireDevice();
final ts = DateTime.now().millisecondsSinceEpoch;
final signature =
_crypto.sign(utf8.encode('GET /v1/certificate/delivery\n$ts'));
final resp = await _transport.httpClient().get(
Uri.parse('$baseUrl/v1/certificate/delivery'),
headers: {'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}'},
);
if (resp.statusCode != 200) {
throw RelayException('sender certificate fetch failed', resp.statusCode, resp.body);
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
return (
Uint8List.fromList(base64Decode(body['sender_certificate'] as String)),
Uint8List.fromList(base64Decode(body['trust_root'] as String)),
);
}
// ── WS ──────────────────────────────────────────────────────────────
/// Spustí spojení s automatickým reconnectem (exponenciální backoff).
void start() {
_shouldRun = true;
_connect();
}
Future<void> stop() async {
_shouldRun = false;
_reconnectTimer?.cancel();
await _teardownChannel();
_setState(RelayConnectionState.disconnected);
}
Future<void> _teardownChannel() async {
await _wsSub?.cancel();
_wsSub = null;
await _channel?.sink.close();
_channel = null;
}
Future<void> _connect() async {
if (!_shouldRun || _state != RelayConnectionState.disconnected) return;
final deviceUuid = _deviceUuid;
if (deviceUuid == null) return;
_setState(RelayConnectionState.connecting);
try {
final channel = _transport.connectWs(Uri.parse('$_wsUrl/v1/ws'));
await channel.ready;
_channel = channel;
// AUTH: výzva-odpověď podepsaná identity klíčem (XEdDSA)
final nonceClient = randomBytes(16);
var authenticated = false;
_wsSub = channel.stream.listen(
(data) {
final frame = pb.ServerFrame.fromBuffer(data as List<int>);
if (!authenticated) {
if (frame.hasChallenge()) {
final signature = _crypto.sign([
...nonceClient,
...frame.challenge.nonceServer,
...utf8.encode('sepot-auth-v1'),
]);
_send(pb.ClientFrame(
authProof: pb.AuthProof(signature: signature),
));
} else if (frame.hasAuthOk()) {
// Server podpis OVĚŘIL — teprve teď jsme reálně připojeni
authenticated = true;
_reconnectDelayS = 1;
_setState(RelayConnectionState.connected);
// Push endpoint se re-registruje po každém (re)connectu.
_sendPush();
}
return;
}
if (frame.hasDeliver()) {
_deliverController.add(frame.deliver);
} else if (frame.hasReceipt()) {
_receiptController.add(frame.receipt);
}
},
onDone: _onDisconnected,
onError: (Object _) => _onDisconnected(),
);
_send(pb.ClientFrame(
auth: pb.Auth(
deviceUuid: hexToBytes(deviceUuid),
nonceClient: nonceClient,
),
));
} catch (_) {
_onDisconnected();
}
}
void _onDisconnected() {
unawaited(_teardownChannel());
_setState(RelayConnectionState.disconnected);
if (!_shouldRun) return;
_reconnectTimer?.cancel();
_reconnectTimer = Timer(Duration(seconds: _reconnectDelayS), _connect);
_reconnectDelayS = min(_reconnectDelayS * 2, 60);
}
void _send(pb.ClientFrame frame) {
final channel = _channel;
if (channel == null) throw StateError('WebSocket not connected');
channel.sink.add(frame.writeToBuffer());
}
/// Odešle sealed-sender obálku. Vrací Receipt (STORED_OFFLINE /
/// DELIVERED_ONLINE) spárovaný přes msg_uuid.
Future<pb.Receipt> sendEnvelope({
required Uint8List dstAddress,
required Uint8List payload,
Duration timeout = const Duration(seconds: 10),
}) async {
final msgUuid = randomBytes(16);
final receiptFuture = _receiptController.stream
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
.timeout(timeout);
_send(pb.ClientFrame(
envelope: pb.Envelope(
dstAddress: dstAddress,
msgUuid: msgUuid,
contentHint: pb.ContentHint.CONTENT_HINT_DEFAULT,
payload: payload,
),
));
return receiptFuture;
}
/// Potvrdí zpracování obálky — relay ji smaže z fronty. Volat až PO
/// uložení zprávy na disk.
void ack(List<int> msgUuid) {
_send(pb.ClientFrame(ack: pb.Ack(msgUuid: msgUuid)));
}
/// Nastaví/aktualizuje push registraci u relaye. Bezpečné volat kdykoliv:
/// požadavek se zapamatuje a odešle po autentizaci (a znovu po každém
/// reconnectu — endpoint distributora se může měnit). platform=NONE push
/// vypne (relay token smaže → anonymní režim).
void registerPush(pb.RegisterPush registration) {
_pendingPush = registration;
if (_state == RelayConnectionState.connected) {
_sendPush();
}
}
void _sendPush() {
final reg = _pendingPush;
if (reg == null || _channel == null) return;
_send(pb.ClientFrame(registerPush: reg));
}
String _requireDevice() {
final d = _deviceUuid;
if (d == null) {
throw StateError('Call register() or restoreSession() first');
}
return d;
}
static bool _bytesEqual(List<int> a, List<int> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
Future<void> dispose() async {
await stop();
_transport.close();
await _stateController.close();
await _deliverController.close();
await _receiptController.close();
}
}
class RelayException implements Exception {
RelayException(this.message, this.statusCode, this.body);
final String message;
final int statusCode;
final String body;
@override
String toString() => 'RelayException($statusCode): $message — $body';
}
+108
View File
@@ -0,0 +1,108 @@
/// Transport relaye: JAK se navazují REST i WS spojení.
///
/// Dvě varianty se stejným rozhraním, aby [RelayClient] nevěděl, kudy
/// data tečou:
/// - [DirectTransport] — přímé šifrované spojení (HTTPS/WSS) na
/// `relay.sepot.cz`. Default.
/// - [TorTransport] — všechna spojení jdou přes lokální SOCKS5 proxy
/// (Orbot) na `.onion` adresu relaye. IP klienta se k relayi nikdy
/// nedostane; jméno `.onion` resolvuje Tor (klient sám DNS nedělá →
/// žádný únik dotazu).
///
/// Přepínání je runtime záležitost (viz `MessagingService.setTorEnabled`):
/// změna transportu = přestavení [RelayClient] s novou instancí.
library;
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'package:socks5_proxy/socks_client.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// Abstrakce nad tím, jak klient otevírá spojení k relayi.
abstract class RelayTransport {
/// Sdílený HTTP klient pro REST (registrace, prekey bundle, přílohy…).
http.Client httpClient();
/// Naváže WebSocket na [url].
WebSocketChannel connectWs(Uri url);
/// Uvolní podkladové klienty. Po zavření se transport už nepoužívá.
void close();
}
/// Přímé spojení bez proxy — HTTPS/WSS rovnou na relay.
class DirectTransport implements RelayTransport {
http.Client? _client;
@override
http.Client httpClient() => _client ??= http.Client();
@override
WebSocketChannel connectWs(Uri url) => WebSocketChannel.connect(url);
@override
void close() {
_client?.close();
_client = null;
}
}
/// Tor: REST i WS se tunelují přes SOCKS5 proxy (výchozí Orbot na
/// `127.0.0.1:9050`). Onion adresa se do SOCKS CONNECT posílá jako
/// doména (ATYP=0x03), takže její resolvování obstará Tor.
///
/// Jeden [HttpClient] slouží REST i WS — WS upgrade jede přes stejnou
/// connectionFactory (SOCKS socket), takže se tuneluje stejně.
class TorTransport implements RelayTransport {
TorTransport({this.socksHost = '127.0.0.1', this.socksPort = 9050});
/// Adresa SOCKS5 proxy. Orbot výchozí je `127.0.0.1:9050`.
final String socksHost;
final int socksPort;
HttpClient? _httpClient;
IOClient? _ioClient;
HttpClient _sharedHttpClient() {
final client = _httpClient;
if (client != null) return client;
final built = HttpClient();
// Nastaví connectionFactory tak, aby každé spojení šlo přes SOCKS5.
// Onion je http:// — Tor obálka šifruje, TLS uvnitř netřeba.
SocksTCPClient.assignToHttpClient(built, [
ProxySettings(InternetAddress(socksHost), socksPort),
]);
return _httpClient = built;
}
@override
http.Client httpClient() => _ioClient ??= IOClient(_sharedHttpClient());
@override
WebSocketChannel connectWs(Uri url) =>
IOWebSocketChannel.connect(_withExplicitPort(url),
customClient: _sharedHttpClient());
/// `Uri` pro `ws`/`wss` nezná výchozí port → `url.port` vrací 0 a SOCKS
/// CONNECT by cílil na port 0 (serverError). Doplníme ho podle schématu
/// (ws→80, wss→443). U přímého transportu to řeší WS klient sám, tady
/// port protéká až do connectionFactory, tak ho musíme dodat.
static Uri _withExplicitPort(Uri url) {
if (url.hasPort) return url;
final port = url.scheme == 'wss' ? 443 : 80;
return url.replace(port: port);
}
@override
void close() {
// IOClient.close() zavře i sdílený HttpClient; force pro jistotu
// strhne i případné živé WS spojení.
_ioClient?.close();
_ioClient = null;
_httpClient?.close(force: true);
_httpClient = null;
}
}