Š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
+59
View File
@@ -0,0 +1,59 @@
/// Nativní APNs most pro iOS (bez Firebase).
///
/// Komunikuje s AppDelegate.swift přes MethodChannel `cz.sepot.sepot/apns`:
/// Dart → native: register (registerForRemoteNotifications), getToken
/// native → Dart: onToken (APNs device token hex), onWake (tichý budíček)
///
/// APNs token se pošle relayi jako PLATFORM_APNS; relay pak posílá tichý
/// background budíček přímo přes Apple, žádný Google v cestě.
library;
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
class ApnsBridge {
ApnsBridge._();
static const _channel = MethodChannel('cz.sepot.sepot/apns');
static bool _handlerSet = false;
/// Callback při vydání/změně APNs tokenu (hex string).
static void Function(String token)? onToken;
/// Callback při tichém budíčku (probudit WS a vylít frontu).
static void Function()? onWake;
static bool get isSupported => Platform.isIOS;
/// Zaregistruje příjem native → Dart callbacků. Idempotentní; volat
/// při startu (i killed-state, aby onWake fungoval po probuzení pushem).
static void ensureHandler() {
if (_handlerSet || !isSupported) return;
_handlerSet = true;
_channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'onToken':
final token = call.arguments as String?;
if (token != null) onToken?.call(token);
case 'onWake':
onWake?.call();
}
return null;
});
}
/// Spustí nativní APNs registraci (systém se případně zeptá na povolení
/// notifikací). Token dorazí asynchronně přes [onToken].
static Future<void> register() async {
if (!isSupported) return;
ensureHandler();
await _channel.invokeMethod<void>('register');
}
/// Už vydaný token (null, pokud APNs ještě neodpověděl).
static Future<String?> currentToken() async {
if (!isSupported) return null;
return _channel.invokeMethod<String>('getToken');
}
}
+137
View File
@@ -0,0 +1,137 @@
/// Vstupní vrstva UnifiedPush — registruje se z main() PŘED runApp,
/// protože Android budí zabitou appku s argumentem `--unifiedpush-bg`
/// a bez UI: callbacky pak musí existovat dřív, než (ne)vznikne widget
/// tree.
///
/// Dvě situace při příchodu budíčku:
/// - appka běží (messaging nastartován) → wake: připoj WS, vylej frontu
/// - killed/zamčeno → DB je za PINem, nic nejde dešifrovat →
/// jen generická lokální notifikace (PushNotifier); zprávy se
/// stáhnou, až uživatel appku odemkne
///
/// Nový endpoint doručený na pozadí se NEDÁ poslat relayi (vyžaduje
/// autentizovaný WS + krypto z DB) → uloží se do OS keystore a odešle
/// při příštím plném startu (PushService.init ho vyzvedne).
library;
import 'dart:async';
import 'dart:io' show Platform;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:sepot/core/push/apns_ios.dart';
import 'package:sepot/core/push/push_notifier.dart';
import 'package:sepot/core/push/push_service.dart';
import 'package:unifiedpush/unifiedpush.dart';
/// FCM budíček se zabitou appkou — plugin spouští handler ve vlastním
/// background isolate. Stejný model jako UnifiedPush killed-state:
/// DB je zamčená, nic nejde dešifrovat → jen generická notifikace.
@pragma('vm:entry-point')
Future<void> sepotFcmBackgroundWake(RemoteMessage message) async {
await Firebase.initializeApp();
await PushNotifier.showWakeNotification();
}
class PushBootstrap {
PushBootstrap._();
/// True = proces nastartoval Android kvůli pushi (`--unifiedpush-bg`),
/// bez UI. main() pak NEvolá runApp.
static bool backgroundStart = false;
static PushService? _service;
static const _pendingEndpointKey = 'pending_push_endpoint';
static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm'
static const _store = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
/// Režim pushů žije v OS keystore (NE v SQLCipher DB) — musí být
/// čitelný před unlockem, aby main() věděl, zda registrovat FCM.
static Future<String> readMode() async =>
await _store.read(key: _modeKey) ?? 'off';
static Future<void> writeMode(String mode) =>
_store.write(key: _modeKey, value: mode);
static Future<void> init(List<String> args) async {
backgroundStart = args.contains('--unifiedpush-bg');
// iOS: nativní APNs most. onWake musí fungovat i v killed-state
// (iOS probudí engine tichým pushem), proto handler registrujeme tady,
// před runApp. Messaging se napojí přes attach()/PushService.init.
if (Platform.isIOS) {
ApnsBridge.onWake = _wake;
ApnsBridge.ensureHandler();
} else {
await UnifiedPush.initialize(
onNewEndpoint: _onNewEndpoint,
onRegistrationFailed: (_, _) {},
onUnregistered: _onUnregistered,
onMessage: _onMessage,
);
}
// Firebase se dotkne Googlu (installations FID) → inicializovat
// VÝHRADNĚ když si uživatel FCM režim sám zapnul.
if (!Platform.isLinux && await readMode() == 'fcm') {
try {
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(sepotFcmBackgroundWake);
} catch (_) {
// bez google-services konfigurace / na desktopu — FCM nedostupné
}
}
}
/// Napojí plnou appku (messaging vrstvu) na už zaregistrované callbacky.
static void attach(PushService service) {
_service = service;
}
/// Sdílené probuzení (UnifiedPush i APNs): appka běží → připoj WS a
/// vylej frontu; killed/zamčeno → generická notifikace bez obsahu.
static void _wake() {
final s = _service;
if (s != null && s.canWake) {
s.wake();
} else {
unawaited(PushNotifier.showWakeNotification());
}
}
static void _onMessage(PushMessage message, String instance) {
// Obsah budíčku se ZÁMĚRNĚ ignoruje (konstantní ťuk {"v":1}).
final s = _service;
if (s != null && s.canWake) {
s.wake();
} else {
unawaited(PushNotifier.showWakeNotification());
}
}
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
final s = _service;
if (s != null) {
s.handleNewEndpoint(endpoint.url);
} else {
unawaited(_store.write(key: _pendingEndpointKey, value: endpoint.url));
}
}
static void _onUnregistered(String instance) {
_service?.handleUnregistered();
}
/// Vyzvedne (a smaže) endpoint uložený během background startu.
static Future<String?> takePendingEndpoint() async {
final value = await _store.read(key: _pendingEndpointKey);
if (value != null) {
await _store.delete(key: _pendingEndpointKey);
}
return value;
}
}
+90
View File
@@ -0,0 +1,90 @@
/// Lokální notifikace pro push budíčky.
///
/// Zásada: notifikace NIKDY nenese obsah, odesílatele ani počet zpráv —
/// při killed-state budíčku je DB zamčená (PIN → Argon2id), takže ani
/// technicky NELZE nic dešifrovat. Generické „máš nové zprávy" je tudíž
/// jediná poctivá (a metadata-čistá) možnost — stejný model jako
/// zamčený Threema/Signal.
library;
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/providers/locale_provider.dart';
class PushNotifier {
PushNotifier._();
static final _plugin = FlutterLocalNotificationsPlugin();
static bool _ready = false;
/// Jedno pevné id — opakované budíčky aktualizují touž notifikaci,
/// žádné hromadění (počet by sám o sobě byl metadata leak).
static const int _notificationId = 1;
static Future<void> _ensureInit() async {
if (_ready) return;
final l10n = l10nFor(await LocaleNotifier.readStored());
await _plugin.initialize(
InitializationSettings(
android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
linux: LinuxInitializationSettings(
defaultActionName: l10n.pushActionOpen,
),
),
);
_ready = true;
}
/// Sdílené detaily notifikace — channel ID 'zpravy' je NEMĚNNÉ
/// (Android by při změně vytvořil nový kanál), name/description se
/// lokalizují (systém je čte při vytvoření kanálu).
static NotificationDetails _details(AppLocalizations l10n) {
return NotificationDetails(
android: AndroidNotificationDetails(
'zpravy',
l10n.pushChannelMessagesName,
channelDescription: l10n.pushChannelMessagesDescription,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
),
linux: const LinuxNotificationDetails(),
);
}
/// Android 13+ runtime permission — volat při zapnutí pushe v nastavení.
static Future<void> requestPermission() async {
await _ensureInit();
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
}
/// Notifikace o přijaté zprávě, když appka běží, ale není v popředí.
/// Jen jméno odesílatele — obsah zprávy do notifikace NIKDY (šifrovaný
/// obsah nepatří do notification logu OS ani na zamčenou obrazovku).
static Future<void> showMessageNotification(String senderName) async {
await _ensureInit();
final l10n = l10nFor(await LocaleNotifier.readStored());
await _plugin.show(
_notificationId,
l10n.commonAppName,
l10n.pushNewMessageFrom(senderName),
_details(l10n),
);
}
/// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock →
/// WS připojení → fronta se vyleje normální cestou).
static Future<void> showWakeNotification() async {
await _ensureInit();
final l10n = l10nFor(await LocaleNotifier.readStored());
await _plugin.show(
_notificationId,
l10n.commonAppName,
l10n.pushNewMessages,
_details(l10n),
);
}
}
+234
View File
@@ -0,0 +1,234 @@
/// Napojení UnifiedPush distributora na relay budíčky.
///
/// Tok:
/// distributor (ntfy) → onNewEndpoint → relay.registerPush(UNIFIEDPUSH, url)
/// příchozí budíček → onMessage → messaging.onPushWake()
/// (připoj se na WS → vylej offline frontu)
///
/// Obsah budíčku se ZÁMĚRNĚ ignoruje — je to konstantní „ťuk" `{"v":1}`,
/// žádný obsah ani odesílatel jím neprochází. Stačí i nešifrovaný
/// (`decrypted == false`) — potřebujeme jen fakt „něco přišlo, připoj se".
///
/// Callbacky u UnifiedPush registruje [PushBootstrap] z main() (kvůli
/// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je
/// most bootstrap ↔ messaging + API pro nastavení.
///
/// FCM „fast mode" (opt-in) je druhá cesta (viz [setModeFcm]); default je
/// degooglovaný UnifiedPush.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:sepot/core/push/apns_ios.dart';
import 'package:sepot/core/push/push_bootstrap.dart';
import 'package:sepot/core/push/push_notifier.dart';
import 'package:sepot/core/services/messaging_service.dart';
import 'package:sepot/proto/sepot.pb.dart' as pb;
import 'package:unifiedpush/unifiedpush.dart';
class PushService {
PushService(this._messaging);
final MessagingService _messaging;
StreamSubscription<String>? _fcmTokenSub;
StreamSubscription<RemoteMessage>? _fcmMessageSub;
pb.RegisterPush _registration(pb.RegisterPush_Platform platform,
[Uint8List? endpoint]) =>
pb.RegisterPush(
platform: platform,
endpoint: endpoint ?? Uint8List(0),
);
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
bool get canWake => _messaging.isStarted;
void wake() => _messaging.onPushWake();
void handleNewEndpoint(String url) {
_messaging.registerPush(_registration(
pb.RegisterPush_Platform.PLATFORM_UNIFIEDPUSH,
Uint8List.fromList(utf8.encode(url)),
));
}
void handleUnregistered() {
// Distributor nás odregistroval → relay ať token smaže (anonymní režim).
_messaging
.registerPush(_registration(pb.RegisterPush_Platform.PLATFORM_NONE));
}
/// Napojí messaging na push callbacky a doručí relayi endpoint, který
/// případně dorazil během killed-state startu. Idempotentní — volat po
/// startu messaging vrstvy.
Future<void> init() async {
PushBootstrap.attach(this);
final pending = await PushBootstrap.takePendingEndpoint();
if (pending != null) {
handleNewEndpoint(pending);
}
// iOS: APNs token → relay jako PLATFORM_APNS; tichý budíček → wake.
if (ApnsBridge.isSupported) {
ApnsBridge.onToken = _registerApnsToken;
ApnsBridge.onWake = wake;
ApnsBridge.ensureHandler();
}
}
void _registerApnsToken(String token) {
_messaging.registerPush(_registration(
pb.RegisterPush_Platform.PLATFORM_APNS,
Uint8List.fromList(utf8.encode(token)),
));
}
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
/// asynchronně a sám se pošle relayi. Bez Googlu.
Future<void> enableApns() async {
await init();
await PushNotifier.requestPermission();
await ApnsBridge.register();
final existing = await ApnsBridge.currentToken();
if (existing != null) _registerApnsToken(existing);
await PushBootstrap.writeMode('apns');
}
/// Zapne push: použije uloženého/výchozího distributora a zaregistruje se.
/// Vrací `false`, když žádný distributor není (uživatel musí nainstalovat
/// např. ntfy nebo vybrat přes [distributors]/[useDistributor]).
Future<bool> enable() async {
await init();
// Android 13+: bez POST_NOTIFICATIONS by killed-state budíček zapadl
await PushNotifier.requestPermission();
final ok = await UnifiedPush.tryUseCurrentOrDefaultDistributor();
if (!ok) return false;
await UnifiedPush.register();
return true;
}
/// Seznam nainstalovaných distributorů (pro výběr v nastavení).
Future<List<String>> distributors() => UnifiedPush.getDistributors();
/// Aktuálně uložený distributor (null = push vypnutý / nevybraný).
Future<String?> currentDistributor() => UnifiedPush.getDistributor();
/// Vybere konkrétního distributora a zaregistruje se u něj.
Future<void> useDistributor(String distributor) async {
await init();
await PushNotifier.requestPermission();
await UnifiedPush.saveDistributor(distributor);
await UnifiedPush.register();
}
/// Vypne push: odregistruje u distributora a řekne relayi smazat token.
Future<void> disable() async {
await UnifiedPush.unregister();
handleUnregistered();
}
Future<bool> get isEnabled async =>
(await UnifiedPush.getDistributor()) != null;
// ── Režimy (off / up / fcm) ─────────────────────────────────────────
/// Aktuální režim; 'up' zpětně dovodí z vybraného distributora
/// (instalace před zavedením režimů).
Future<String> currentMode() async {
final mode = await PushBootstrap.readMode();
if (mode != 'off') return mode;
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
}
Future<void> setModeOff() async {
await _teardownFcm(deleteToken: true);
try {
await UnifiedPush.unregister();
} catch (_) {}
handleUnregistered();
await PushBootstrap.writeMode('off');
}
/// UnifiedPush režim (default, degooglovaný). Vrací false, když chybí
/// distributor — UI pak nabídne výběr/instalaci.
Future<bool> setModeUnifiedPush() async {
await _teardownFcm(deleteToken: true);
final ok = await enable();
if (ok) await PushBootstrap.writeMode('up');
return ok;
}
/// FCM fast mode — OPT-IN: registrační token zařízení jde Googlu
/// (obsah budíčků nikdy, ťuk je konstantní `{"v":1}`).
Future<void> setModeFcm() async {
await init();
try {
await UnifiedPush.unregister();
} catch (_) {}
await PushNotifier.requestPermission();
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(sepotFcmBackgroundWake);
final fm = FirebaseMessaging.instance;
final token = await fm.getToken();
if (token == null) {
throw StateError('failed to obtain FCM token');
}
_registerFcmToken(token);
_fcmTokenSub ??= fm.onTokenRefresh.listen(_registerFcmToken);
// popředí: ťuk → připojit WS (paralela k UnifiedPush onMessage)
_fcmMessageSub ??= FirebaseMessaging.onMessage.listen((_) => wake());
await PushBootstrap.writeMode('fcm');
}
void _registerFcmToken(String token) {
_messaging.registerPush(_registration(
pb.RegisterPush_Platform.PLATFORM_FCM,
Uint8List.fromList(utf8.encode(token)),
));
}
Future<void> _teardownFcm({required bool deleteToken}) async {
await _fcmTokenSub?.cancel();
_fcmTokenSub = null;
await _fcmMessageSub?.cancel();
_fcmMessageSub = null;
if (deleteToken) {
try {
await FirebaseMessaging.instance.deleteToken();
} catch (_) {
// Firebase neinicializovaný / bez sítě — token vyprší sám
}
}
}
/// Obnova režimu po startu appky (volá pushInitProvider).
Future<void> restoreMode() async {
// iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only).
// Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ.
if (Platform.isIOS) {
try {
await enableApns();
} catch (_) {
await init();
}
return;
}
switch (await currentMode()) {
case 'up':
await enable();
case 'fcm':
try {
await setModeFcm();
} catch (_) {
// bez sítě — token registrace proběhne při příštím startu
}
default:
await init(); // aspoň attach (killed-state notifikace)
}
}
}