Š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
+104
View File
@@ -0,0 +1,104 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:sepot/core/errors.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/models/contact.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/models/identity.dart';
/// Šifrovaná záloha historie (kontakty + konverzace).
///
/// Klíč se odvozuje z privátního klíče identity (HKDF-SHA256) → zálohu
/// dešifruje POUZE tvoje identita (po obnově ze slov). Přílohy (bloby)
/// v záloze nejsou — obnoví se metadata zpráv, samotný soubor se případně
/// dotáhne z relaye znovu (dokud tam je).
class BackupService {
BackupService(this._storage);
final SecureStorageService _storage;
// Formát: magic(8) ‖ nonce(12) ‖ ciphertext ‖ GCM tag(16)
static const _magic = 'SEPOTBK1';
static const _nonceLen = 12;
static const _tagLen = 16;
Future<SecretKey> _deriveKey(Uint8List privateKey) async {
final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
return hkdf.deriveKey(
secretKey: SecretKey(privateKey),
nonce: utf8.encode('sepot-backup-salt-v1'),
info: utf8.encode('sepot-backup-v1'),
);
}
Uint8List _randomNonce() {
final r = Random.secure();
return Uint8List.fromList(List.generate(_nonceLen, (_) => r.nextInt(256)));
}
/// Vytvoř šifrovanou zálohu — vrací bajty souboru `.sepotbackup`.
Future<Uint8List> export(LocalIdentity identity) async {
final contacts = await _storage.loadContacts();
final conversations = await _storage.loadConversations();
final payload = utf8.encode(jsonEncode({
'v': 1,
'contacts': contacts.map((c) => c.toJson()).toList(),
'conversations': conversations.map((c) => c.toJson()).toList(),
}));
final key = await _deriveKey(identity.identityPrivateKey);
final nonce = _randomNonce();
final box = await AesGcm.with256bits()
.encrypt(payload, secretKey: key, nonce: nonce);
final out = BytesBuilder();
out.add(utf8.encode(_magic));
out.add(nonce);
out.add(box.cipherText);
out.add(box.mac.bytes);
return out.toBytes();
}
/// Obnov ze zálohy. Vrací počet obnovených konverzací. Vyhodí typovanou
/// chybu ([BackupCorruptedError], [BackupUnknownFormatError],
/// [BackupWrongIdentityError]) u poškozeného souboru nebo zálohy cizí
/// identity — UI ji překládá přes describeError.
Future<int> import(LocalIdentity identity, Uint8List bytes) async {
if (bytes.length < _magic.length + _nonceLen + _tagLen) {
throw const BackupCorruptedError();
}
if (utf8.decode(bytes.sublist(0, _magic.length)) != _magic) {
throw const BackupUnknownFormatError();
}
final nonce = bytes.sublist(_magic.length, _magic.length + _nonceLen);
final body = bytes.sublist(_magic.length + _nonceLen);
final ct = body.sublist(0, body.length - _tagLen);
final mac = Mac(body.sublist(body.length - _tagLen));
final key = await _deriveKey(identity.identityPrivateKey);
List<int> plain;
try {
plain = await AesGcm.with256bits().decrypt(
SecretBox(ct, nonce: nonce, mac: mac),
secretKey: key,
);
} on SecretBoxAuthenticationError {
throw const BackupWrongIdentityError();
}
final json = jsonDecode(utf8.decode(plain)) as Map<String, dynamic>;
for (final c in (json['contacts'] as List? ?? const [])) {
await _storage.saveContact(Contact.fromJson(c as Map<String, dynamic>));
}
var restored = 0;
for (final c in (json['conversations'] as List? ?? const [])) {
await _storage
.saveConversation(Conversation.fromJson(c as Map<String, dynamic>));
restored++;
}
return restored;
}
}
+65
View File
@@ -0,0 +1,65 @@
/// Konstanty aplikace Šepot
class AppConstants {
AppConstants._();
static const String appName = 'Šepot';
/// Musí sedět s `version:` v pubspec.yaml (bez build čísla).
static const String appVersion = '0.3.2';
static const String appAuthor = 'ajtak.it';
// Relay server (REST + WS na jednom portu). Přepsatelné při buildu:
// flutter build --dart-define=SEPOT_RELAY=http://<dev-relay-host>:8470
static const String relayUrl = String.fromEnvironment(
'SEPOT_RELAY',
defaultValue: 'https://relay.sepot.cz',
);
// Tor onion vstup relaye (HiddenServicePort 80 → relay:8470; onion vrstva
// šifruje, TLS uvnitř netřeba). Používá se, když je v nastavení zapnutý
// Tor — provoz jde přes SOCKS5 (Orbot) a jméno .onion resolvuje Tor
// (viz TorTransport / MessagingService.setTorEnabled).
static const String relayOnion =
'http://cipjsctoeyf4fcrntvdvt22o2ansgr73qvthmewo246vuntd7cqmykyd.onion';
// Kryptografické konstanty
static const int pinLength = 6;
// 24 slov = 32 B identity klíče enkódovaného přes BIP39 (žádný KDF)
static const int seedPhraseWords = 24;
// Úložiště - názvy Hive boxů
static const String identityBox = 'identity';
static const String contactsBox = 'contacts';
static const String conversationsBox = 'conversations';
static const String messagesBox = 'messages';
static const String settingsBox = 'settings';
// Nastavení zpráv
static const int maxMessageLength = 4096;
static const int defaultDisappearingSeconds = 86400; // 24 hodin
// Přílohy — musí sedět s relayem (SEPOT_MAX_ATTACHMENT_BYTES, default
// 25 MiB na ŠIFROVANÝ blob; plaintext strop držíme kousek pod tím)
static const int maxAttachmentBytes = 25 * 1024 * 1024 - 64 * 1024;
// Auto-lock timeout (v sekundách)
static const int autoLockTimeout = 300; // 5 minut
// Nahlašování zneužití. Endpoint NENÍ na relayi: relay drží jen opaque
// obálky a nesmí získat čitelný kanál k obsahu. Nahlášení jde na web,
// odkud chodí e-mail — a odesílatel je o tom v UI zpraven.
static const String reportEndpoint = String.fromEnvironment(
'SEPOT_REPORT_ENDPOINT',
defaultValue: 'https://sepot.cz/api/nahlaseni',
);
/// Veřejné standardy ochrany dětí (Google Play CSAE, Apple 1.2).
static const String childSafetyUrl = 'https://sepot.cz/bezpecnost-deti';
/// Apple Guideline 5.1.1(i) žádá odkaz na zásady soukromí i UVNITŘ aplikace,
/// nejen v metadatech obchodu.
static const String privacyUrl = 'https://sepot.cz/ochrana-soukromi';
static const String termsUrl = 'https://sepot.cz/podminky';
static const String deletionUrl = 'https://sepot.cz/smazani-dat';
static const String supportEmail = 'podpora@sepot.cz';
}
+138
View File
@@ -0,0 +1,138 @@
/// Šifrování příloh (model Signal).
///
/// Každá příloha má vlastní náhodný klíč: soubor se zašifruje
/// AES-256-GCM, na relay jde UŽ ŠIFROVANÝ blob a klíč + nonce + digest
/// putují k příjemci uvnitř E2E zprávy (relay je nikdy nevidí).
///
/// Wire formát blobu: `ciphertext || GCM tag (16 B)`.
/// Digest = SHA-256 celého blobu — váže zprávu na konkrétní bajty na
/// relayi (relay nemůže blob vyměnit) a příjemce jím ověří stažená data
/// ještě PŘED dešifrováním.
///
/// Šifrování i dešifrování běží v samostatném isolate — soubory mají
/// megabajty a čistě dartový AES by jinak blokoval UI.
library;
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
/// Blob nesedí (digest mismatch / GCM tag fail) — poškozená nebo
/// podvržená příloha. Nikdy nepředávat obsah dál.
class AttachmentIntegrityException implements Exception {
const AttachmentIntegrityException(this.reason);
final String reason;
@override
String toString() => 'AttachmentIntegrityException: $reason';
}
/// Výsledek zašifrování přílohy — vše, co potřebuje odesílatel:
/// [blob] se nahraje na relay, zbytek jde do E2E zprávy.
class EncryptedAttachment {
const EncryptedAttachment({
required this.key,
required this.nonce,
required this.blob,
required this.digest,
required this.plaintextSize,
});
final Uint8List key; // 32 B, per-file
final Uint8List nonce; // 12 B
final Uint8List blob; // ciphertext || tag
final Uint8List digest; // SHA-256(blob)
final int plaintextSize;
}
class AttachmentCipher {
AttachmentCipher._();
static const int keyLength = 32;
static const int nonceLength = 12;
static const int tagLength = 16;
static Future<EncryptedAttachment> encrypt(Uint8List plaintext) async {
if (plaintext.isEmpty) {
throw ArgumentError('empty attachment');
}
final key = _randomBytes(keyLength);
final nonce = _randomBytes(nonceLength);
final blob = await Isolate.run(() async {
final aes = AesGcm.with256bits();
final box = await aes.encrypt(
plaintext,
secretKey: SecretKey(key),
nonce: nonce,
);
final out = Uint8List(box.cipherText.length + box.mac.bytes.length);
out.setAll(0, box.cipherText);
out.setAll(box.cipherText.length, box.mac.bytes);
return out;
});
return EncryptedAttachment(
key: key,
nonce: nonce,
blob: blob,
digest: await sha256(blob),
plaintextSize: plaintext.length,
);
}
/// Digest se ověřuje PŘED dešifrováním (stažený blob nemusí být ten,
/// na který zpráva ukazuje); GCM tag pak autentizuje obsah klíčem.
static Future<Uint8List> decrypt({
required Uint8List blob,
required Uint8List key,
required Uint8List nonce,
required Uint8List expectedDigest,
}) async {
if (blob.length <= tagLength) {
throw const AttachmentIntegrityException('blob too short');
}
final actualDigest = await sha256(blob);
if (!_bytesEqual(actualDigest, expectedDigest)) {
throw const AttachmentIntegrityException('digest mismatch');
}
try {
final plaintext = await Isolate.run(() async {
final aes = AesGcm.with256bits();
final cipherText = blob.sublist(0, blob.length - tagLength);
final mac = Mac(blob.sublist(blob.length - tagLength));
final out = await aes.decrypt(
SecretBox(cipherText, nonce: nonce, mac: mac),
secretKey: SecretKey(key),
);
return Uint8List.fromList(out);
});
return plaintext;
} on SecretBoxAuthenticationError {
throw const AttachmentIntegrityException('GCM tag mismatch');
}
}
static Future<Uint8List> sha256(Uint8List data) async {
final hash = await Sha256().hash(data);
return Uint8List.fromList(hash.bytes);
}
static Uint8List _randomBytes(int length) {
final rng = Random.secure();
return Uint8List.fromList(List.generate(length, (_) => rng.nextInt(256)));
}
static bool _bytesEqual(List<int> a, List<int> b) {
if (a.length != b.length) return false;
var diff = 0;
for (var i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}
}
+172
View File
@@ -0,0 +1,172 @@
/// Argon2id wrap databázového klíče (DEK) za PIN.
///
/// Schéma:
/// unlockKey = Argon2id(PIN, salt)
/// wrappedDek = AES-256-GCM(dek, unlockKey)
///
/// PIN se NIKDE neukládá ani nehashuje na porovnání — „ověření PINu" je
/// úspěšné rozbalení DEK (GCM auth tag). Špatný PIN = nečitelná DB.
///
/// Argon2id parametry jsou součástí blobu, takže budoucí rekalibrace
/// nerozbije stará data. Při wrap i unwrap se vynucuje dolní mez
/// (OWASP floor): m ≥ 19 MiB, t ≥ 2 — slabší
/// parametry se odmítnou (žádný tichý downgrade).
library;
import 'dart:convert';
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:cryptography/dart.dart';
/// GCM auth tag nesedí — špatný PIN, nebo poškozený/podvržený blob.
/// (AES-GCM tyhle dva případy principiálně nerozliší.)
class WrongPinException implements Exception {
const WrongPinException();
@override
String toString() => 'WrongPinException: wrong PIN or corrupted blob';
}
class DekWrap {
DekWrap._();
/// Výchozí parametry (m=64MiB, t=3, p=1).
/// Řádově ~2–3 s čistým Dartem na středně výkonném zařízení.
static const int defaultMemoryKiB = 64 * 1024;
static const int defaultIterations = 3;
static const int defaultParallelism = 1;
/// Dolní bezpečnostní mez (OWASP) — pod ni kalibrace NIKDY nesmí.
static const int floorMemoryKiB = 19 * 1024;
static const int floorIterations = 2;
static const String _version = 'sepot-dek-v1';
/// Zabal 32B DEK za PIN. Výstup je samopopisný string pro keystore:
/// `sepot-dek-v1:<mKiB>:<t>:<p>:<b64 salt>:<b64 nonce>:<b64 ct>:<b64 tag>`
static Future<String> wrap({
required Uint8List dek,
required String pin,
int memoryKiB = defaultMemoryKiB,
int iterations = defaultIterations,
int parallelism = defaultParallelism,
}) async {
if (dek.length != 32) {
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
}
_enforceFloor(memoryKiB, iterations);
final salt = _randomBytes(16);
final unlockKey = await _deriveUnlockKey(
pin: pin,
salt: salt,
memoryKiB: memoryKiB,
iterations: iterations,
parallelism: parallelism,
);
final aes = AesGcm.with256bits();
final box = await aes.encrypt(
dek,
secretKey: SecretKey(unlockKey),
nonce: _randomBytes(12),
);
return [
_version,
'$memoryKiB',
'$iterations',
'$parallelism',
base64Encode(salt),
base64Encode(box.nonce),
base64Encode(box.cipherText),
base64Encode(box.mac.bytes),
].join(':');
}
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
/// [WrongPinException]; na neplatném formátu [FormatException].
static Future<Uint8List> unwrap({
required String wrapped,
required String pin,
}) async {
final parts = wrapped.split(':');
if (parts.length != 8 || parts[0] != _version) {
throw FormatException('Unknown wrapped DEK format', wrapped);
}
final memoryKiB = int.parse(parts[1]);
final iterations = int.parse(parts[2]);
final parallelism = int.parse(parts[3]);
// Floor PŘED odvozením — podvržený blob se slabými parametry
// nesmí projít, ani kdyby měl platný tag
_enforceFloor(memoryKiB, iterations);
final salt = base64Decode(parts[4]);
final nonce = base64Decode(parts[5]);
final cipherText = base64Decode(parts[6]);
final mac = base64Decode(parts[7]);
final unlockKey = await _deriveUnlockKey(
pin: pin,
salt: salt,
memoryKiB: memoryKiB,
iterations: iterations,
parallelism: parallelism,
);
final aes = AesGcm.with256bits();
try {
final dek = await aes.decrypt(
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
secretKey: SecretKey(unlockKey),
);
return Uint8List.fromList(dek);
} on SecretBoxAuthenticationError {
throw const WrongPinException();
}
}
static void _enforceFloor(int memoryKiB, int iterations) {
if (memoryKiB < floorMemoryKiB || iterations < floorIterations) {
throw StateError(
'Argon2id parameters below security floor '
'(m=${memoryKiB}KiB t=$iterations, floor m=${floorMemoryKiB}KiB '
't=$floorIterations)',
);
}
}
/// Argon2id běží v samostatném isolate — 64 MiB memory-hard výpočet
/// by jinak na sekundy zmrazil UI.
static Future<Uint8List> _deriveUnlockKey({
required String pin,
required Uint8List salt,
required int memoryKiB,
required int iterations,
required int parallelism,
}) {
return Isolate.run(() async {
final algo = DartArgon2id(
memory: memoryKiB,
iterations: iterations,
parallelism: parallelism,
hashLength: 32,
);
final key = await algo.deriveKey(
secretKey: SecretKey(utf8.encode(pin)),
nonce: salt,
);
return Uint8List.fromList(await key.extractBytes());
});
}
static Uint8List _randomBytes(int length) {
final rng = Random.secure();
return Uint8List.fromList(
List.generate(length, (_) => rng.nextInt(256)),
);
}
}
+71
View File
@@ -0,0 +1,71 @@
/// Obnova identity přes mnemonic — BIP39 wordlist jako REVERZIBILNÍ
/// ENKÓDOVÁNÍ 32B privátního identity klíče.
///
/// Žádné KDF, žádný seed: 24 slov je přímý zápis bajtů klíče + checksum.
/// `decode(encode(klic)) == klic` platí bit po bitu — hlídají to KAT testy.
///
/// DŮLEŽITÉ: enkóduje se SERIALIZOVANÝ privátní klíč (výstup fasády
/// SepotCrypto, už clampnutý pro Curve25519). Díky tomu je
/// deserialize→serialize stabilní a mnemonic vždy obnoví identický klíč.
library;
import 'dart:typed_data';
import 'package:bip39/bip39.dart' as bip39;
import 'package:convert/convert.dart' show hex;
import 'package:sepot/core/errors.dart';
class SepotMnemonic {
SepotMnemonic._();
/// 32 B entropie = 24 slov BIP39 (256 bitů + 8bit checksum).
static const int wordCount = 24;
static const int keyLength = 32;
/// Serializovaný privátní klíč (32 B) → 24 slov.
static List<String> encode(Uint8List privateKey) {
if (privateKey.length != keyLength) {
throw ArgumentError.value(
privateKey.length,
'privateKey',
'Expected $keyLength bytes of serialized private key',
);
}
return bip39.entropyToMnemonic(hex.encode(privateKey)).split(' ');
}
/// 24 slov → 32 B privátního klíče.
///
/// Vyhodí [MnemonicWordCountError] při špatném počtu slov a
/// [MnemonicInvalidError] při neznámém slovu nebo nesedícím checksumu
/// (překlep uživatele) — UI je překládá přes describeError.
static Uint8List decode(List<String> words) {
final normalized = _normalize(words);
if (normalized.length != wordCount) {
throw MnemonicWordCountError(normalized.length, wordCount);
}
final String entropyHex;
try {
entropyHex = bip39.mnemonicToEntropy(normalized.join(' '));
} on ArgumentError catch (e) {
// bip39 hlásí neznámé slovo / špatný checksum přes ArgumentError a
// StateError — pro UI sjednocujeme na typovanou chybu
throw MnemonicInvalidError('${e.message}');
} on StateError catch (e) {
throw MnemonicInvalidError(e.message);
}
return Uint8List.fromList(hex.decode(entropyHex));
}
/// Rychlá validace pro UI (živá kontrola při zadávání).
static bool isValid(List<String> words) {
final normalized = _normalize(words);
if (normalized.length != wordCount) return false;
return bip39.validateMnemonic(normalized.join(' '));
}
static List<String> _normalize(List<String> words) => words
.expand((w) => w.trim().toLowerCase().split(RegExp(r'\s+')))
.where((w) => w.isNotEmpty)
.toList();
}
+70
View File
@@ -0,0 +1,70 @@
/// Safety number — vizuální ověření kontaktu.
///
/// Používá libsignal `Fingerprint` (NumericFingerprintGenerator), žádný
/// vlastní výpočet: iterovaný SHA-512 (5200 iterací jako Signal) nad
/// (verze ‖ identifikátor ‖ identity klíč) pro obě strany, zobrazení
/// 60 dekadických číslic (12 bloků po 5). Menší polovina jde první,
/// takže OBĚ strany vidí stejné číslo.
///
/// Stabilní identifikátor strany = BLAKE2b-256 fingerprint jejího
/// identity klíče (deterministický z klíče — žádný telefon, e-mail ani
/// server handle, přesně v duchu „identita = klíč").
///
/// QR ověření: [scannable] bajty do QR; sken protistrany se porovná přes
/// [SafetyNumber.compare] (libsignal kontroluje křížem local↔remote).
library;
import 'dart:typed_data';
import 'package:libsignal/libsignal.dart' as signal;
import 'package:sepot/models/identity.dart';
class SafetyNumber {
SafetyNumber._(this.display, this.scannable);
/// Iterace hashe jako Signal (bezpečnostní parametr — NEměnit,
/// změna = všem se změní safety numbers).
static const int iterations = 5200;
/// Verze scannable formátu (libsignal proto).
static const int version = 2;
/// 60 číslic bez mezer.
final String display;
/// Binární encoding pro QR + [compare].
final Uint8List scannable;
/// Spočítá safety number páru identity klíčů (33 B serializované).
factory SafetyNumber.compute({
required Uint8List localIdentityKey,
required Uint8List remoteIdentityKey,
}) {
final fp = signal.Fingerprint(
iterations: iterations,
version: version,
localIdentifier: SepotFingerprint.ofPublicKey(localIdentityKey).bytes,
localPublicKey: localIdentityKey,
remoteIdentifier: SepotFingerprint.ofPublicKey(remoteIdentityKey).bytes,
remotePublicKey: remoteIdentityKey,
);
return SafetyNumber._(fp.displayString(), fp.scannableEncoding());
}
/// 12 bloků po 5 číslicích pro zobrazení.
List<String> get groups => [
for (var i = 0; i < display.length; i += 5)
display.substring(i, i + 5),
];
/// Porovná můj scannable s naskenovaným od protistrany. True = klíče
/// si křížem odpovídají (žádný MITM), false = NESHODA.
static bool compare(Uint8List mine, Uint8List scanned) {
try {
return signal.fingerprintCompare(fingerprint1: mine, fingerprint2: scanned);
} on Object {
// vadný/cizí QR formát = neshoda, ne pád
return false;
}
}
}
+423
View File
@@ -0,0 +1,423 @@
/// Fasáda SepotCrypto — port/adapter nad krypto backendem (libsignal).
///
/// Smysl: veškerý kód Šepotu mluví JEN s touhle fasádou,
/// nikdy přímo s balíčkem `libsignal`. Backend tak zůstává vyměnitelný
/// (libsignal FFI ↔ libsignal_protocol_dart ↔ vlastní FRB fork) bez zásahu
/// do zbytku aplikace.
///
/// Stores jsou zatím in-memory; později je nahradí
/// perzistence nad SQLCipher.
library;
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:libsignal/libsignal.dart';
/// Pár serializovaných identity klíčů — jediný tvar, ve kterém identity
/// klíče opouštějí fasádu (žádné backend-specifické typy ven).
class SepotIdentityKeys {
SepotIdentityKeys({required this.privateKey, required this.publicKey});
/// 32 B, serializovaný (clampnutý) privátní klíč. Přesně tyhle bajty
/// se enkódují do mnemonic — díky clampingu při serializaci je
/// deserialize→serialize bitově stabilní roundtrip.
final Uint8List privateKey;
/// 33 B, serializovaný veřejný klíč (libsignal type byte + 32 B).
/// Z něj se počítá fingerprint.
final Uint8List publicKey;
}
/// Serializovatelný obsah prekey bundle — přesně to, co klient nahraje
/// na relay a co si druhá strana stáhne. Jen bajty a čísla, žádné
/// backend-specifické typy (musí projít protobufem beze změny).
class SepotPreKeyBundle {
SepotPreKeyBundle({
required this.registrationId,
required this.deviceId,
required this.preKeyId,
required this.preKeyPublic,
required this.signedPreKeyId,
required this.signedPreKeyPublic,
required this.signedPreKeySignature,
required this.identityKey,
required this.kyberPreKeyId,
required this.kyberPreKeyPublic,
required this.kyberPreKeySignature,
});
final int registrationId;
final int deviceId;
final int? preKeyId;
final Uint8List? preKeyPublic;
final int signedPreKeyId;
final Uint8List signedPreKeyPublic;
final Uint8List signedPreKeySignature;
final Uint8List identityKey;
final int kyberPreKeyId;
final Uint8List kyberPreKeyPublic;
final Uint8List kyberPreKeySignature;
}
/// Výsledek dešifrování sealed-sender obálky.
class SepotSealedMessage {
SepotSealedMessage({
required this.plaintext,
required this.senderName,
required this.senderDeviceId,
required this.senderIdentityKey,
});
final Uint8List plaintext;
/// accountId odesílatele (hex) ze SenderCertificate — zná ho JEN příjemce.
final String senderName;
final int senderDeviceId;
/// Serializovaný identity klíč odesílatele (33 B) — kryptograficky
/// ověřený certifikátem. Fingerprint z něj mapuje zprávu na kontakt.
final Uint8List senderIdentityKey;
}
/// Jeden one-time prekey k publikaci na relay.
class SepotOneTimePreKey {
SepotOneTimePreKey({required this.id, required this.publicKey});
final int id;
final Uint8List publicKey;
}
/// Sada stores, nad kterými krypto instance pracuje. Produkce dodává
/// perzistentní (SQLCipher, `persistent_stores.dart`), testy in-memory.
class SepotStores {
SepotStores({
required this.session,
required this.identity,
required this.preKey,
required this.signedPreKey,
required this.kyberPreKey,
});
/// In-memory sada (spike, testy) — po zabití procesu je pryč.
factory SepotStores.inMemory(IdentityKeyPair identity, int registrationId) {
return SepotStores(
session: InMemorySessionStore(),
identity: InMemoryIdentityKeyStore(identity, registrationId),
preKey: InMemoryPreKeyStore(),
signedPreKey: InMemorySignedPreKeyStore(),
kyberPreKey: InMemoryKyberPreKeyStore(),
);
}
final SessionStore session;
final IdentityKeyStore identity;
final PreKeyStore preKey;
final SignedPreKeyStore signedPreKey;
final KyberPreKeyStore kyberPreKey;
}
/// Jedna krypto identita (účet na jednom zařízení) se všemi stores.
class SepotCrypto {
SepotCrypto._({
required this.address,
required IdentityKeyPair identity,
required int registrationId,
SepotStores? stores,
}) : _identity = identity,
_registrationId = registrationId,
_stores = stores ?? SepotStores.inMemory(identity, registrationId);
/// Inicializace nativní knihovny — volat jednou per proces, před
/// jakoukoli jinou operací fasády (v `main()` před `runApp`).
///
/// iOS: libsignal doručuje `libsignal_frb` výhradně přes Flutter native
/// assets a jeho podspec framework nelinkuje. Výchozí FRB loader proto
/// dlopen-uje bez `@rpath` prefixu, dyld hledá v kořeni `.app` místo ve
/// `Frameworks/` a selže → němá bílá obrazovka. Předaná cesta se přesně
/// shoduje s install name, které frameworku nastaví flutter_tools, a
/// Runner má `@executable_path/Frameworks` v LD_RUNPATH_SEARCH_PATHS.
static Future<void> init() {
if (Platform.isIOS) {
return LibSignal.init(
libraryPath: '@rpath/libsignal_frb.framework/libsignal_frb',
);
}
return LibSignal.init();
}
/// Vytvoří novou identitu (keypair + registration ID).
factory SepotCrypto.generate({
required String accountId,
int deviceId = 1,
required int registrationId,
SepotStores? stores,
}) {
return SepotCrypto._(
address: ProtocolAddress(name: accountId, deviceId: deviceId),
identity: IdentityKeyPair.generate(),
registrationId: registrationId,
stores: stores,
);
}
/// Obnoví krypto instanci z existujícího privátního identity klíče
/// (onboarding po recovery z mnemonic, běžný start aplikace).
///
/// [storesFactory] dostává rekonstruovaný keypair — produkce z něj
/// postaví perzistentní stores (DbIdentityKeyStore keypair potřebuje).
factory SepotCrypto.fromPrivateKey({
required Uint8List privateKey,
required String accountId,
int deviceId = 1,
required int registrationId,
SepotStores Function(IdentityKeyPair identity)? storesFactory,
}) {
final private = PrivateKey.deserialize(bytes: privateKey.toList());
final identity = IdentityKeyPair.fromKeys(
privateKey: private,
publicKey: private.getPublicKey(),
);
return SepotCrypto._(
address: ProtocolAddress(name: accountId, deviceId: deviceId),
identity: identity,
registrationId: registrationId,
stores: storesFactory?.call(identity),
);
}
/// Vygeneruje nový identity keypair a vrátí ho serializovaný.
/// Používá onboarding; privátní část jde do secure storage
/// a do mnemonic, nic z toho nedrží backend typy.
static SepotIdentityKeys generateIdentityKeys() {
final pair = IdentityKeyPair.generate();
return SepotIdentityKeys(
privateKey: Uint8List.fromList(pair.privateKey.toList()),
publicKey: Uint8List.fromList(pair.publicKey.toList()),
);
}
/// Odvodí veřejný klíč (33 B serialized) z privátního (32 B).
/// Deterministické — základ recovery z mnemonic.
static Uint8List derivePublicKey(Uint8List privateKey) {
final private = PrivateKey.deserialize(bytes: privateKey.toList());
return Uint8List.fromList(private.getPublicKey().serialize().toList());
}
/// Lokální adresa. Po registraci na relay se přepíná na hex(account_id)
/// přes [bindAccount] — musí sedět se sender_uuid v SenderCertificate.
ProtocolAddress address;
final IdentityKeyPair _identity;
final int _registrationId;
final SepotStores _stores;
Uint8List get identityPublicKey => Uint8List.fromList(_identity.publicKey);
int get registrationId => _registrationId;
/// Po registraci: server přidělil account_id → lokální adresa se váže
/// na hex(account_id), aby seděla se sender_uuid v SenderCertificate.
void bindAccount(String accountHex) {
address = ProtocolAddress(name: accountHex, deviceId: address.deviceId());
}
/// XEdDSA podpis identity klíčem (AUTH výzva, REST auth hlavička).
Uint8List sign(List<int> message) {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
return Uint8List.fromList(identityPrivate.sign(message: message).toList());
}
/// Vygeneruje sadu prekeys (one-time + signed + Kyber), uloží je do
/// vlastních stores a vrátí bundle k publikaci na relay.
Future<SepotPreKeyBundle> generatePreKeyBundle({
required int preKeyId,
required int signedPreKeyId,
required int kyberPreKeyId,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final timestamp = BigInt.from(timestampMs);
final preKeyPrivate = PrivateKey.generate();
final preKeyPublic = preKeyPrivate.getPublicKey();
await _stores.preKey.storePreKey(
preKeyId,
PreKeyRecord(
id: preKeyId,
publicKey: preKeyPublic,
privateKey: preKeyPrivate,
),
);
final signedPreKeyPrivate = PrivateKey.generate();
final signedPreKeyPublic = signedPreKeyPrivate.getPublicKey();
final signedPreKeySignature = identityPrivate.sign(
message: signedPreKeyPublic.serialize().toList(),
);
await _stores.signedPreKey.storeSignedPreKey(
signedPreKeyId,
SignedPreKeyRecord(
id: signedPreKeyId,
publicKey: signedPreKeyPublic,
privateKey: signedPreKeyPrivate,
signature: signedPreKeySignature.toList(),
timestamp: timestamp,
),
);
final kyberKeyPair = KyberKeyPair.generate();
final kyberPublic = kyberKeyPair.getPublicKey();
final kyberSignature = identityPrivate.sign(
message: kyberPublic.serialize().toList(),
);
await _stores.kyberPreKey.storeKyberPreKey(
kyberPreKeyId,
KyberPreKeyRecord.create(
id: kyberPreKeyId,
keyPair: kyberKeyPair,
signature: kyberSignature.toList(),
timestamp: timestamp,
),
);
return SepotPreKeyBundle(
registrationId: _registrationId,
deviceId: address.deviceId(),
preKeyId: preKeyId,
preKeyPublic: preKeyPublic.serialize(),
signedPreKeyId: signedPreKeyId,
signedPreKeyPublic: Uint8List.fromList(
signedPreKeyPublic.serialize().toList(),
),
signedPreKeySignature: Uint8List.fromList(signedPreKeySignature.toList()),
identityKey: Uint8List.fromList(_identity.publicKey.toList()),
kyberPreKeyId: kyberPreKeyId,
kyberPreKeyPublic: Uint8List.fromList(kyberPublic.serialize().toList()),
kyberPreKeySignature: Uint8List.fromList(kyberSignature.toList()),
);
}
/// Existuje už ratchet session s protistranou?
Future<bool> hasSession(ProtocolAddress remote) =>
_stores.session.containsSession(remote);
/// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
Future<void> processRemoteBundle(
ProtocolAddress remote,
SepotPreKeyBundle bundle,
) async {
final preKeyBundle = PreKeyBundle(
registrationId: bundle.registrationId,
deviceId: bundle.deviceId,
preKeyId: bundle.preKeyId,
preKeyPublic: bundle.preKeyPublic,
signedPreKeyId: bundle.signedPreKeyId,
signedPreKeyPublic: bundle.signedPreKeyPublic.toList(),
signedPreKeySignature: bundle.signedPreKeySignature.toList(),
identityKey: bundle.identityKey.toList(),
kyberPreKeyId: bundle.kyberPreKeyId,
kyberPreKeyPublic: bundle.kyberPreKeyPublic.toList(),
kyberPreKeySignature: bundle.kyberPreKeySignature.toList(),
);
final builder = SessionBuilder(
localAddress: address,
sessionStore: _stores.session,
identityKeyStore: _stores.identity,
);
await builder.processPreKeyBundle(remote, preKeyBundle);
}
SessionCipher get _cipher => SessionCipher(
localAddress: address,
sessionStore: _stores.session,
identityKeyStore: _stores.identity,
preKeyStore: _stores.preKey,
signedPreKeyStore: _stores.signedPreKey,
kyberPreKeyStore: _stores.kyberPreKey,
);
/// Double Ratchet šifrování 1:1.
Future<CiphertextMessage> encryptTo(
ProtocolAddress remote,
List<int> plaintext,
) => _cipher.encrypt(remote, Uint8List.fromList(plaintext));
/// Double Ratchet dešifrování 1:1.
Future<Uint8List> decryptFrom(
ProtocolAddress remote,
CiphertextMessage message,
) => _cipher.decrypt(remote, message);
SealedSenderCipher get _sealedCipher => SealedSenderCipher(
localAddress: address,
sessionStore: _stores.session,
identityKeyStore: _stores.identity,
preKeyStore: _stores.preKey,
signedPreKeyStore: _stores.signedPreKey,
kyberPreKeyStore: _stores.kyberPreKey,
);
/// Sealed sender: zašifruje tak, že relay nezjistí odesílatele.
/// [senderCertificate] vydává relay (server-signed).
Future<Uint8List> sealedEncrypt({
required ProtocolAddress recipient,
required List<int> plaintext,
required Uint8List senderCertificate,
}) => _sealedCipher.encrypt(
recipientAddress: recipient,
plaintext: Uint8List.fromList(plaintext),
senderCertificate: senderCertificate,
);
/// Sealed sender: dešifruje obálku a ověří sender certificate proti
/// pinnutému trust rootu relaye.
Future<SepotSealedMessage> sealedDecrypt({
required Uint8List ciphertext,
required Uint8List trustRoot,
required int timestampMs,
}) async {
final result = await _sealedCipher.decrypt(
ciphertext: ciphertext,
trustRoot: trustRoot,
timestamp: timestampMs,
);
return SepotSealedMessage(
plaintext: result.plaintext,
senderName: result.senderAddress.name(),
senderDeviceId: result.senderAddress.deviceId(),
senderIdentityKey: Uint8List.fromList(
result.senderIdentityKey.serialize().toList(),
),
);
}
/// Vygeneruje dávku one-time prekeys (uloží privátní části do store,
/// vrátí veřejné k publikaci). Zásoba 100 ks, doplnění pod
/// prahem.
Future<List<SepotOneTimePreKey>> generateOneTimePreKeys({
required int startId,
required int count,
}) async {
final result = <SepotOneTimePreKey>[];
for (var i = 0; i < count; i++) {
final id = startId + i;
final private = PrivateKey.generate();
final public = private.getPublicKey();
await _stores.preKey.storePreKey(
id,
PreKeyRecord(id: id, publicKey: public, privateKey: private),
);
result.add(
SepotOneTimePreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
),
);
}
return result;
}
}
+178
View File
@@ -0,0 +1,178 @@
/// Typované chyby, které se zobrazují uživateli.
///
/// Service vrstva nemá přístup k lokalizaci (žádný BuildContext), proto
/// nesmí formulovat texty pro uživatele — vyhazuje tyhle typy a UI je
/// překládá přes `describeError` (core/l10n/error_messages.dart).
/// `toString()` je anglický technický popis pro logy, ne pro UI.
library;
sealed class SepotUserError implements Exception {
const SepotUserError();
}
/// Trust root relaye nesedí s pinovaným — server nelze ověřit.
class TrustRootChangedError extends SepotUserError {
const TrustRootChangedError();
@override
String toString() => 'relay trust root changed — refusing to communicate';
}
/// Relay nedostupný (typicky fetch sender certificate).
class RelayUnavailableError extends SepotUserError {
const RelayUnavailableError();
@override
String toString() => 'relay unavailable';
}
/// Příloha přesahuje serverový limit.
class AttachmentTooLargeError extends SepotUserError {
const AttachmentTooLargeError(this.maxBytes);
final int maxBytes;
int get maxMb => (maxBytes / (1024 * 1024)).round();
@override
String toString() => 'attachment too large (max $maxBytes bytes)';
}
/// Skupina bez jediného dalšího člena nedává smysl.
class GroupNeedsMemberError extends SepotUserError {
const GroupNeedsMemberError();
@override
String toString() => 'group needs at least one member';
}
/// Překročen strop počtu členů skupiny.
class GroupTooLargeError extends SepotUserError {
const GroupTooLargeError(this.maxMembers);
final int maxMembers;
@override
String toString() => 'group exceeds $maxMembers members';
}
/// Člen skupiny nemá doručovací adresu (starý odkaz bez routingu).
class MemberNoDeliveryAddressError extends SepotUserError {
const MemberNoDeliveryAddressError(this.displayName);
final String displayName;
@override
String toString() => 'member "$displayName" has no delivery address';
}
/// Kontakt nemá doručovací adresu (starý odkaz bez routingu).
class ContactNoDeliveryAddressError extends SepotUserError {
const ContactNoDeliveryAddressError();
@override
String toString() => 'contact has no delivery address';
}
/// Identity klíč z relaye nesedí s uloženým kontaktem — možný MITM.
class IdentityKeyMismatchError extends SepotUserError {
const IdentityKeyMismatchError();
@override
String toString() => 'identity key mismatch — possible MITM, message not sent';
}
// === Obnova účtu (mnemonic) ===
/// Fráze má špatný počet slov.
class MnemonicWordCountError extends SepotUserError {
const MnemonicWordCountError(this.count, this.expected);
final int count;
final int expected;
@override
String toString() => 'mnemonic has $count words, expected $expected';
}
/// Fráze neprošla validací BIP39 (neznámé slovo, checksum…).
class MnemonicInvalidError extends SepotUserError {
const MnemonicInvalidError(this.detail);
final String detail;
@override
String toString() => 'invalid mnemonic: $detail';
}
// === Zálohy historie (backup_service) ===
/// Soubor zálohy je poškozený (nejde přečíst/dešifrovat strukturu).
class BackupCorruptedError extends SepotUserError {
const BackupCorruptedError();
@override
String toString() => 'corrupted backup file';
}
/// Soubor není záloha Šepotu.
class BackupUnknownFormatError extends SepotUserError {
const BackupUnknownFormatError();
@override
String toString() => 'unknown backup format';
}
/// Záloha je šifrovaná klíčem jiné identity.
class BackupWrongIdentityError extends SepotUserError {
const BackupWrongIdentityError();
@override
String toString() => 'backup belongs to a different identity';
}
// === Kontakty ===
/// Kontakt s tímto fingerprintem už existuje.
class ContactAlreadyExistsError extends SepotUserError {
const ContactAlreadyExistsError();
@override
String toString() => 'contact already exists';
}
// === Nahlášení zneužití (report_service) ===
/// Hlášení bez popisu nemá co řešit.
class ReportEmptyDescriptionError extends SepotUserError {
const ReportEmptyDescriptionError();
@override
String toString() => 'report has empty description';
}
/// Server hlášení je nedosažitelný.
class ReportConnectionError extends SepotUserError {
const ReportConnectionError();
@override
String toString() => 'report endpoint unreachable';
}
/// Rate limit hlášení (429).
class ReportRateLimitedError extends SepotUserError {
const ReportRateLimitedError();
@override
String toString() => 'report rate limited';
}
/// Server hlášení vrátil chybový kód.
class ReportServerError extends SepotUserError {
const ReportServerError(this.statusCode);
final int statusCode;
@override
String toString() => 'report failed with status $statusCode';
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/errors.dart';
import 'package:sepot/core/storage/secure_storage.dart' show DecoyPinConflict;
import 'package:sepot/l10n/generated/app_localizations.dart';
/// Přeloží chybu na větu pro uživatele.
///
/// Typované chyby (`SepotUserError`, `DecoyPinConflict`) mají vlastní
/// lokalizované hlášky; cokoli neznámého spadne do obecné hlášky
/// s technickým popisem — radši ošklivá pravda než němé selhání.
String describeError(AppLocalizations l10n, Object error) => switch (error) {
TrustRootChangedError() => l10n.errorTrustRootChanged,
RelayUnavailableError() => l10n.errorRelayUnavailable,
AttachmentTooLargeError(:final maxMb) =>
l10n.errorAttachmentTooLarge(maxMb),
GroupNeedsMemberError() => l10n.errorGroupNeedsMember,
GroupTooLargeError(:final maxMembers) =>
l10n.errorGroupMaxMembers(maxMembers),
MemberNoDeliveryAddressError(:final displayName) =>
l10n.errorMemberNoDeliveryAddress(displayName),
ContactNoDeliveryAddressError() => l10n.errorContactNoDeliveryAddress,
IdentityKeyMismatchError() => l10n.errorIdentityKeyMismatch,
MnemonicWordCountError(:final count, :final expected) =>
l10n.restoreErrorMnemonicWordCount(count, expected),
MnemonicInvalidError(:final detail) =>
l10n.restoreErrorInvalidMnemonic(detail),
BackupCorruptedError() => l10n.backupErrorCorruptedFile,
BackupUnknownFormatError() => l10n.backupErrorUnknownFormat,
BackupWrongIdentityError() => l10n.backupErrorWrongIdentity,
ContactAlreadyExistsError() => l10n.contactsErrorAlreadyExists,
ReportEmptyDescriptionError() => l10n.reportErrorEmptyDescription,
ReportConnectionError() =>
l10n.reportErrorConnection(AppConstants.supportEmail),
ReportRateLimitedError() =>
l10n.reportErrorRateLimited(AppConstants.supportEmail),
ReportServerError(:final statusCode) =>
l10n.reportErrorServer(statusCode, AppConstants.supportEmail),
DecoyPinConflict() => l10n.settingsDecoyPinConflict,
_ => l10n.errorGeneric(error.toString()),
};
+23
View File
@@ -0,0 +1,23 @@
import 'dart:ui';
import 'package:flutter/widgets.dart';
import 'package:sepot/l10n/generated/app_localizations.dart';
export 'package:sepot/l10n/generated/app_localizations.dart';
/// Zkratka pro přístup k lokalizovaným textům: `context.l10n.klic`.
extension L10nX on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this);
}
/// Lokalizace mimo widget tree (notifikace, headless push start).
///
/// Preferuje ruční volbu jazyka (`override`, typicky
/// `LocaleNotifier.readStored()`), jinak jazyk systému; nepodporovaný
/// jazyk spadne na angličtinu — stejné pořadí jako `supportedLocales`.
AppLocalizations l10nFor(Locale? override) {
final locale = override ?? PlatformDispatcher.instance.locale;
final lang = Locale(locale.languageCode);
final supported = AppLocalizations.delegate.isSupported(lang);
return lookupAppLocalizations(supported ? lang : const Locale('en'));
}
+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;
}
}
+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)
}
}
}
+104
View File
@@ -0,0 +1,104 @@
/// Nahlášení zneužití — odesílá hlášení mimo relay, na web sepot.cz.
///
/// Proč ne přes relay: relay vidí jen opaque obálky a nemá čitelný kanál
/// k obsahu. Kdyby přijímal hlášení, získal by úložiště čitelných textů
/// vázaných na účty — přesně to, čemu se návrh vyhýbá.
///
/// Obsah zpráv se NIKDY neodesílá — ani automaticky, ani na vyžádání.
///
/// 🔴 Není to jen ohled na soukromí. Report systém, který provozovateli
/// doručí obsah nahlášené zprávy, znamená u dětské pornografie držení
/// materiálu = dokonaný § 192 trestního zákoníku, a to bez výjimky pro
/// „ověřování". UI proto uživatele výslovně od vkládání obsahu odrazuje
/// a u kategorie ohrožení dítěte ho posílá na STOPonline.cz / Policii ČR.
/// Hlášení nese jen metadata a slovní popis situace.
///
/// Splňuje: Google Play UGC policy (mechanismus nahlášení), Child Safety
/// Standards / CSAE (in-app nahlášení obav), Apple Guideline 1.2.
library;
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/errors.dart';
/// Kategorie hlášení. `wire` hodnoty jsou součástí API — musí zůstat
/// v souladu s webovým endpointem, který hlášení přijímá. Popisky a
/// nápovědy kategorií skládá report_screen z lokalizace.
enum ReportCategory {
childSafety('ohrozeni_ditete'),
harassment('obtezovani'),
spam('spam'),
fraud('podvod'),
other('jine');
const ReportCategory(this.wire);
final String wire;
/// CSAE hlášení mají u nás přednost a jiný proces (viz sepot.cz/bezpecnost-deti).
bool get isChildSafety => this == ReportCategory.childSafety;
}
class ReportService {
const ReportService({this.endpoint = AppConstants.reportEndpoint});
final String endpoint;
static const Duration _timeout = Duration(seconds: 20);
/// Odešle hlášení. Když se nepovede, vyhodí typovanou chybu
/// ([ReportEmptyDescriptionError], [ReportConnectionError],
/// [ReportRateLimitedError], [ReportServerError]) — UI ji překládá
/// přes describeError.
///
/// [reportedFingerprint] a [replyTo] jsou dobrovolné — bez nich je hlášení
/// anonymní (server přesto uvidí IP adresu, na to upozorňuje UI).
Future<void> submit({
required ReportCategory category,
required String description,
String? reportedFingerprint,
String? replyTo,
}) async {
final popis = description.trim();
if (popis.isEmpty) {
throw const ReportEmptyDescriptionError();
}
final body = <String, dynamic>{
'kategorie': category.wire,
'popis': popis,
'zdroj': 'app',
'platforma': Platform.operatingSystem,
'verze': AppConstants.appVersion,
if (reportedFingerprint != null && reportedFingerprint.isNotEmpty)
'nahlasenyOtisk': reportedFingerprint,
if (replyTo != null && replyTo.trim().isNotEmpty)
'kontakt': replyTo.trim(),
};
http.Response res;
try {
res = await http
.post(
Uri.parse(endpoint),
headers: const {
'Content-Type': 'application/json; charset=utf-8',
'X-Sepot-Client': 'app',
},
body: jsonEncode(body),
)
.timeout(_timeout);
} on Object {
throw const ReportConnectionError();
}
if (res.statusCode == 200) return;
if (res.statusCode == 429) {
throw const ReportRateLimitedError();
}
throw ReportServerError(res.statusCode);
}
}
+115
View File
@@ -0,0 +1,115 @@
/// Příprava obrázků před odesláním.
///
/// Každá fotka se před šifrováním RE-ENKÓDUJE (resize na max 2048 px,
/// JPEG q80) — tím se zahodí VŠECHNA metadata včetně EXIF/GPS (privacy:
/// poloha focení nesmí odjet ani E2E kanálem, protistrana ji nemá co
/// vidět). PNG (screenshoty, průhlednost) zůstává PNG, GIF se posílá
/// beze změny (animace; EXIF u něj nehrozí).
///
/// Náhled (JPEG ≤ 48 KiB) jede přímo uvnitř E2E plaintextu zprávy —
/// příjemce vidí obrázek okamžitě, bez čekání na download blobu.
library;
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'package:image/image.dart' as img;
class ProcessedImage {
const ProcessedImage({
required this.bytes,
required this.mime,
required this.width,
required this.height,
required this.thumbnailJpeg,
});
final Uint8List bytes;
final String mime;
final int width;
final int height;
final Uint8List thumbnailJpeg;
}
class ImageProcessor {
ImageProcessor._();
static const int maxDimension = 2048;
static const int jpegQuality = 80;
static const int thumbDimension = 320;
/// Náhled musí bezpečně projet obálkou (limit 256 KiB na CELOU
/// sealed-sender obálku) — držet výrazně pod tím.
static const int thumbMaxBytes = 48 * 1024;
/// Zpracuje obrázek v izolátu (dekódování megapixelů by blokovalo UI).
/// Vrací null, když bajty nejsou dekódovatelný obrázek — volající je
/// pak pošle jako dokument.
static Future<ProcessedImage?> process(
Uint8List original, {
String? mimeHint,
}) {
return Isolate.run(() {
final decoded = img.decodeImage(original);
if (decoded == null) return null;
// GIF beze změny — re-encode by zabil animaci
if (mimeHint == 'image/gif') {
return ProcessedImage(
bytes: original,
mime: 'image/gif',
width: decoded.width,
height: decoded.height,
thumbnailJpeg: _thumbnail(decoded),
);
}
var image = img.bakeOrientation(decoded);
if (max(image.width, image.height) > maxDimension) {
image = image.width >= image.height
? img.copyResize(image, width: maxDimension)
: img.copyResize(image, height: maxDimension);
}
// PNG s průhledností zůstává PNG (JPEG alfa kanál neumí, a re-encode
// PNG→PNG metadata stejně zahodí); vše ostatní JPEG q80.
final keepPng = mimeHint == 'image/png' && decoded.numChannels == 4;
final bytes = keepPng
? Uint8List.fromList(img.encodePng(image))
: Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality));
return ProcessedImage(
bytes: bytes,
mime: keepPng ? 'image/png' : 'image/jpeg',
width: image.width,
height: image.height,
thumbnailJpeg: _thumbnail(image),
);
});
}
static Uint8List _thumbnail(img.Image source) {
var thumb = max(source.width, source.height) > thumbDimension
? (source.width >= source.height
? img.copyResize(source, width: thumbDimension)
: img.copyResize(source, height: thumbDimension))
: source;
// JPEG náhled nemá alfu — průhlednost složit na tmavé pozadí
if (thumb.numChannels == 4) {
final flat = img.Image(width: thumb.width, height: thumb.height);
img.fill(flat, color: img.ColorRgb8(20, 20, 31));
img.compositeImage(flat, thumb);
thumb = flat;
}
var quality = 70;
var encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
// Náhled je jen placeholder, než dorazí ostrá fotka — kvalitu držíme
// rozumně (min 40), radši menší rozměr než rozsypaný obraz.
while (encoded.length > thumbMaxBytes && quality > 40) {
quality -= 10;
encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
}
return encoded;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
/// Lokální úložiště blobů příloh.
///
/// Na disku leží VŽDY jen šifrovaný blob (ciphertext || tag) pod názvem
/// blob_id — stejné bajty jako na relayi. At-rest ochrana = AES-256-GCM
/// per-file klíčem, který žije výhradně v SQLCipher DB (metadata zprávy).
/// Dešifruje se až při zobrazení, do paměti; plaintext se na disk dostane
/// jen krátkodobě při sdílení do jiné aplikace (viz UI vrstva).
///
/// blob_id přichází i od protistrany (remote input) — před použitím jako
/// název souboru se validuje tvar (32 hex znaků), žádný path traversal.
library;
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
class AttachmentStore {
AttachmentStore({String? dirOverride}) : _dirOverride = dirOverride;
final String? _dirOverride;
static final _blobIdPattern = RegExp(r'^[0-9a-f]{32}$');
static bool isValidBlobId(String blobId) => _blobIdPattern.hasMatch(blobId);
Future<Directory> _dir() async {
final base = _dirOverride ??
'${(await getApplicationSupportDirectory()).path}/attachments';
return Directory(base).create(recursive: true);
}
Future<File> _file(String blobId) async {
if (!isValidBlobId(blobId)) {
throw ArgumentError.value(blobId, 'blobId', 'invalid blob_id format');
}
return File('${(await _dir()).path}/$blobId');
}
Future<void> save(String blobId, Uint8List blob) async {
final file = await _file(blobId);
// přes temp + rename, ať pád zápisu nenechá poloviční blob
final tmp = File('${file.path}.part');
await tmp.writeAsBytes(blob, flush: true);
await tmp.rename(file.path);
}
Future<bool> exists(String blobId) async => (await _file(blobId)).exists();
/// Vrátí šifrovaný blob, nebo null, když (ještě) není stažený.
Future<Uint8List?> load(String blobId) async {
final file = await _file(blobId);
if (!await file.exists()) return null;
return file.readAsBytes();
}
Future<void> delete(String blobId) async {
final file = await _file(blobId);
if (await file.exists()) await file.delete();
}
/// Factory reset — smaže celý adresář příloh.
Future<void> deleteAll() async {
final dir = await _dir();
if (await dir.exists()) await dir.delete(recursive: true);
}
}
+225
View File
@@ -0,0 +1,225 @@
/// Šifrovaná lokální DB (SQLCipher) — krypto stavy i aplikační data.
///
/// Drží ratchet sessiony, prekeys, identity protistran a dále i kontakty,
/// konverzace se zprávami a nastavení (dřív plain Hive — nepřijatelné pro
/// privacy app). VLASTNÍ identity privátní klíč tu NENÍ — žije v OS keystore
/// (flutter_secure_storage), stejně jako DEK téhle databáze (obrana do
/// hloubky).
///
/// Nativní knihovnu (libsqlcipher) dodává build hook balíčku `sqlite3`
/// (pubspec: `hooks.user_defines.sqlite3.source: sqlcipher`) — jednotně
/// pro Android, iOS, desktop i testy na Linuxu.
///
/// Zápisy ratchet stavů jsou atomické (SQLite transakce + WAL) — neatomický
/// zápis session recordu = „bad MAC" a rozbitá konverzace.
library;
import 'dart:typed_data';
import 'package:sqlite3/sqlite3.dart';
class CryptoDb {
CryptoDb._(this._db);
final Database _db;
/// Otevře (a případně založí) šifrovanou DB.
///
/// [dek] je 32B klíč z OS keystore. Špatný klíč = [SqliteException]
/// hned při první query (SQLCipher nerozliší „špatný klíč" od
/// „poškozený soubor").
static CryptoDb open({required String path, required Uint8List dek}) {
if (dek.length != 32) {
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
}
final db = sqlite3.open(path);
try {
// Pojistka: kdyby build přilinkl plain sqlite3 místo SQLCipher,
// DB by se TIŠE založila nešifrovaná. To se nikdy nesmí stát.
final cipherVersion = db.select('PRAGMA cipher_version;');
if (cipherVersion.isEmpty) {
throw StateError(
'Native library is not SQLCipher — at-rest encryption would not run. '
'Check hooks.user_defines.sqlite3.source in pubspec.yaml.',
);
}
final hexKey =
dek.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
// PRAGMA key s raw klíčem (x'..' formát) — žádné KDF ze stringu
db.execute("PRAGMA key = \"x'$hexKey'\";");
db.execute('PRAGMA journal_mode = WAL;');
db.execute('PRAGMA foreign_keys = ON;');
// První dotaz ověří klíč — na špatném klíči vyhodí NOTADB
db.execute(_schema);
return CryptoDb._(db);
} catch (_) {
db.close();
rethrow;
}
}
static const _schema = '''
CREATE TABLE IF NOT EXISTS kv (
k TEXT PRIMARY KEY,
v BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
name TEXT NOT NULL,
device_id INTEGER NOT NULL,
record BLOB NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (name, device_id)
);
CREATE TABLE IF NOT EXISTS remote_identities (
name TEXT PRIMARY KEY,
identity BLOB NOT NULL,
first_seen INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS prekeys (
id INTEGER PRIMARY KEY,
record BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS signed_prekeys (
id INTEGER PRIMARY KEY,
record BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS kyber_prekeys (
id INTEGER PRIMARY KEY,
record BLOB NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY,
json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations (
contact_id TEXT PRIMARY KEY,
json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS app_settings (
k TEXT PRIMARY KEY,
v TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
json TEXT NOT NULL
);
''';
Database get raw => _db;
/// Atomický blok — ratchet stavy se smí zapisovat jen takhle.
T transaction<T>(T Function() body) {
_db.execute('BEGIN IMMEDIATE;');
try {
final result = body();
_db.execute('COMMIT;');
return result;
} catch (_) {
_db.execute('ROLLBACK;');
rethrow;
}
}
Uint8List? kvGet(String key) {
final rows = _db.select('SELECT v FROM kv WHERE k = ?', [key]);
if (rows.isEmpty) return null;
return rows.first['v'] as Uint8List;
}
void kvPut(String key, Uint8List value) {
_db.execute(
'INSERT INTO kv (k, v) VALUES (?, ?) '
'ON CONFLICT(k) DO UPDATE SET v = excluded.v',
[key, value],
);
}
// ── Aplikační data — modely jako JSON, (de)serializuje volající ──
List<String> contactsJsonAll() {
final rows = _db.select('SELECT json FROM contacts');
return [for (final r in rows) r['json'] as String];
}
void contactPut(String id, String json) {
_db.execute(
'INSERT INTO contacts (id, json) VALUES (?, ?) '
'ON CONFLICT(id) DO UPDATE SET json = excluded.json',
[id, json],
);
}
/// Smaže kontakt VČETNĚ jeho konverzace (atomicky).
void contactDelete(String id) {
transaction(() {
_db.execute('DELETE FROM contacts WHERE id = ?', [id]);
_db.execute('DELETE FROM conversations WHERE contact_id = ?', [id]);
});
}
String? conversationJson(String contactId) {
final rows = _db.select(
'SELECT json FROM conversations WHERE contact_id = ?',
[contactId],
);
if (rows.isEmpty) return null;
return rows.first['json'] as String;
}
List<String> conversationsJsonAll() {
final rows = _db.select('SELECT json FROM conversations');
return [for (final r in rows) r['json'] as String];
}
void conversationPut(String contactId, String json) {
_db.execute(
'INSERT INTO conversations (contact_id, json) VALUES (?, ?) '
'ON CONFLICT(contact_id) DO UPDATE SET json = excluded.json',
[contactId, json],
);
}
List<String> groupsJsonAll() {
final rows = _db.select('SELECT json FROM groups');
return [for (final r in rows) r['json'] as String];
}
String? groupJson(String id) {
final rows = _db.select('SELECT json FROM groups WHERE id = ?', [id]);
if (rows.isEmpty) return null;
return rows.first['json'] as String;
}
void groupPut(String id, String json) {
_db.execute(
'INSERT INTO groups (id, json) VALUES (?, ?) '
'ON CONFLICT(id) DO UPDATE SET json = excluded.json',
[id, json],
);
}
/// Smaže skupinu VČETNĚ její konverzace (atomicky).
void groupDelete(String id) {
transaction(() {
_db.execute('DELETE FROM groups WHERE id = ?', [id]);
_db.execute('DELETE FROM conversations WHERE contact_id = ?', [id]);
});
}
String? appSettingGet(String key) {
final rows = _db.select('SELECT v FROM app_settings WHERE k = ?', [key]);
if (rows.isEmpty) return null;
return rows.first['v'] as String;
}
void appSettingPut(String key, String value) {
_db.execute(
'INSERT INTO app_settings (k, v) VALUES (?, ?) '
'ON CONFLICT(k) DO UPDATE SET v = excluded.v',
[key, value],
);
}
void close() => _db.close();
}
+316
View File
@@ -0,0 +1,316 @@
/// Perzistentní libsignal stores nad šifrovanou DB (SQLCipher).
///
/// Ratchet stavy, prekeys a identity protistran přežívají restart aplikace.
/// Vlastní identity keypair sem NEPATŘÍ — dostává se zvenčí (OS keystore),
/// store drží jen odkaz v paměti.
///
/// Trust model identity protistran: TOFU per účet (name), stejná sémantika
/// jako InMemoryIdentityKeyStore z balíčku, ale porovnává se SERIALIZOVANÁ
/// podoba klíče (opaque FFI handle nemá spolehlivé ==).
library;
import 'dart:typed_data';
import 'package:libsignal/libsignal.dart';
import 'package:sepot/core/storage/crypto_db.dart';
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;
}
class DbSessionStore implements SessionStore {
DbSessionStore(this._db);
final CryptoDb _db;
@override
Future<SessionRecord?> loadSession(ProtocolAddress address) async {
final rows = _db.raw.select(
'SELECT record FROM sessions WHERE name = ? AND device_id = ?',
[address.name(), address.deviceId()],
);
if (rows.isEmpty) return null;
return SessionRecord.deserialize(
bytes: (rows.first['record'] as Uint8List).toList(),
);
}
@override
Future<void> storeSession(
ProtocolAddress address,
SessionRecord record,
) async {
// Atomicky — částečně zapsaný ratchet stav = „bad MAC"
_db.transaction(() {
_db.raw.execute(
'INSERT INTO sessions (name, device_id, record, updated_at) '
'VALUES (?, ?, ?, ?) '
'ON CONFLICT(name, device_id) DO UPDATE '
'SET record = excluded.record, updated_at = excluded.updated_at',
[
address.name(),
address.deviceId(),
record.serialize(),
DateTime.now().millisecondsSinceEpoch,
],
);
});
}
@override
Future<bool> containsSession(ProtocolAddress address) async {
final rows = _db.raw.select(
'SELECT 1 FROM sessions WHERE name = ? AND device_id = ?',
[address.name(), address.deviceId()],
);
return rows.isNotEmpty;
}
@override
Future<void> deleteSession(ProtocolAddress address) async {
_db.raw.execute(
'DELETE FROM sessions WHERE name = ? AND device_id = ?',
[address.name(), address.deviceId()],
);
}
@override
Future<void> deleteAllSessions(String name) async {
_db.raw.execute('DELETE FROM sessions WHERE name = ?', [name]);
}
@override
Future<List<int>> getSubDeviceSessions(String name) async {
final rows = _db.raw.select(
'SELECT device_id FROM sessions WHERE name = ?',
[name],
);
return [for (final r in rows) r['device_id'] as int];
}
}
class DbIdentityKeyStore implements IdentityKeyStore {
DbIdentityKeyStore(
this._db, {
required IdentityKeyPair identityKeyPair,
required int registrationId,
}) : _identityKeyPair = identityKeyPair,
_registrationId = registrationId;
final CryptoDb _db;
final IdentityKeyPair _identityKeyPair;
final int _registrationId;
@override
Future<IdentityKeyPair> getIdentityKeyPair() async => _identityKeyPair;
@override
Future<int> getLocalRegistrationId() async => _registrationId;
Uint8List? _stored(String name) {
final rows = _db.raw.select(
'SELECT identity FROM remote_identities WHERE name = ?',
[name],
);
if (rows.isEmpty) return null;
return rows.first['identity'] as Uint8List;
}
@override
Future<bool> saveIdentity(
ProtocolAddress address,
PublicKey identityKey,
) async {
final name = address.name();
final incoming = identityKey.serialize();
final existing = _stored(name);
if (existing != null && _bytesEqual(existing, incoming)) {
return false; // beze změny
}
_db.raw.execute(
'INSERT INTO remote_identities (name, identity, first_seen) '
'VALUES (?, ?, ?) '
'ON CONFLICT(name) DO UPDATE SET identity = excluded.identity',
[name, incoming, DateTime.now().millisecondsSinceEpoch],
);
return true; // nová nebo ZMĚNĚNÁ identita (UI ukáže safety-number varování)
}
@override
Future<PublicKey?> getIdentity(ProtocolAddress address) async {
final stored = _stored(address.name());
if (stored == null) return null;
return PublicKey.deserialize(bytes: stored.toList());
}
@override
Future<bool> isTrustedIdentity(
ProtocolAddress address,
PublicKey identityKey,
Direction direction,
) async {
final stored = _stored(address.name());
if (stored == null) return true; // TOFU
return _bytesEqual(stored, identityKey.serialize());
}
}
class DbPreKeyStore implements PreKeyStore {
DbPreKeyStore(this._db);
final CryptoDb _db;
@override
Future<PreKeyRecord?> loadPreKey(int preKeyId) async {
final rows = _db.raw.select(
'SELECT record FROM prekeys WHERE id = ?',
[preKeyId],
);
if (rows.isEmpty) return null;
return PreKeyRecord.deserialize(
bytes: (rows.first['record'] as Uint8List).toList(),
);
}
@override
Future<void> storePreKey(int preKeyId, PreKeyRecord record) async {
_db.raw.execute(
'INSERT INTO prekeys (id, record) VALUES (?, ?) '
'ON CONFLICT(id) DO UPDATE SET record = excluded.record',
[preKeyId, record.serialize()],
);
}
@override
Future<bool> containsPreKey(int preKeyId) async {
return _db.raw
.select('SELECT 1 FROM prekeys WHERE id = ?', [preKeyId]).isNotEmpty;
}
@override
Future<void> removePreKey(int preKeyId) async {
// One-time prekey se po použití MAŽE (forward secrecy první zprávy)
_db.raw.execute('DELETE FROM prekeys WHERE id = ?', [preKeyId]);
}
@override
Future<List<int>> getAllPreKeyIds() async {
final rows = _db.raw.select('SELECT id FROM prekeys ORDER BY id');
return [for (final r in rows) r['id'] as int];
}
}
class DbSignedPreKeyStore implements SignedPreKeyStore {
DbSignedPreKeyStore(this._db);
final CryptoDb _db;
@override
Future<SignedPreKeyRecord?> loadSignedPreKey(int signedPreKeyId) async {
final rows = _db.raw.select(
'SELECT record FROM signed_prekeys WHERE id = ?',
[signedPreKeyId],
);
if (rows.isEmpty) return null;
return SignedPreKeyRecord.deserialize(
bytes: (rows.first['record'] as Uint8List).toList(),
);
}
@override
Future<void> storeSignedPreKey(
int signedPreKeyId,
SignedPreKeyRecord record,
) async {
_db.raw.execute(
'INSERT INTO signed_prekeys (id, record) VALUES (?, ?) '
'ON CONFLICT(id) DO UPDATE SET record = excluded.record',
[signedPreKeyId, record.serialize()],
);
}
@override
Future<bool> containsSignedPreKey(int signedPreKeyId) async {
return _db.raw.select(
'SELECT 1 FROM signed_prekeys WHERE id = ?',
[signedPreKeyId],
).isNotEmpty;
}
@override
Future<void> removeSignedPreKey(int signedPreKeyId) async {
_db.raw.execute(
'DELETE FROM signed_prekeys WHERE id = ?',
[signedPreKeyId],
);
}
@override
Future<List<int>> getAllSignedPreKeyIds() async {
final rows = _db.raw.select('SELECT id FROM signed_prekeys ORDER BY id');
return [for (final r in rows) r['id'] as int];
}
}
class DbKyberPreKeyStore implements KyberPreKeyStore {
DbKyberPreKeyStore(this._db);
final CryptoDb _db;
@override
Future<KyberPreKeyRecord?> loadKyberPreKey(int kyberPreKeyId) async {
final rows = _db.raw.select(
'SELECT record FROM kyber_prekeys WHERE id = ?',
[kyberPreKeyId],
);
if (rows.isEmpty) return null;
return KyberPreKeyRecord.deserialize(
bytes: (rows.first['record'] as Uint8List).toList(),
);
}
@override
Future<void> storeKyberPreKey(
int kyberPreKeyId,
KyberPreKeyRecord record,
) async {
_db.raw.execute(
'INSERT INTO kyber_prekeys (id, record) VALUES (?, ?) '
'ON CONFLICT(id) DO UPDATE SET record = excluded.record',
[kyberPreKeyId, record.serialize()],
);
}
@override
Future<bool> containsKyberPreKey(int kyberPreKeyId) async {
return _db.raw.select(
'SELECT 1 FROM kyber_prekeys WHERE id = ?',
[kyberPreKeyId],
).isNotEmpty;
}
@override
Future<void> markKyberPreKeyUsed(int kyberPreKeyId) async {
// Last-resort Kyber prekey se NEMAŽE (nahrazuje chybějící OTP,
// ochrana proti replay útoku) — jen se značí použití
_db.raw.execute(
'UPDATE kyber_prekeys SET used = 1 WHERE id = ?',
[kyberPreKeyId],
);
}
@override
Future<void> removeKyberPreKey(int kyberPreKeyId) async {
_db.raw.execute('DELETE FROM kyber_prekeys WHERE id = ?', [kyberPreKeyId]);
}
@override
Future<List<int>> getAllKyberPreKeyIds() async {
final rows = _db.raw.select('SELECT id FROM kyber_prekeys ORDER BY id');
return [for (final r in rows) r['id'] as int];
}
}
+520
View File
@@ -0,0 +1,520 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/crypto/dek_wrap.dart';
import 'package:sepot/core/storage/attachment_store.dart';
import 'package:sepot/core/storage/crypto_db.dart';
import 'package:sepot/models/identity.dart';
import 'package:sepot/models/contact.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/models/group.dart';
import 'package:sepot/models/message.dart';
/// Lokální úložiště — OS keystore (klíče) + SQLCipher DB (všechna data).
///
/// Klíčový model:
/// - DEK (32 B, náhodný) šifruje jedinou SQLCipher DB, kde žije VŠECHNO:
/// krypto stavy, kontakty, konverzace se zprávami i nastavení.
/// - Bez PINu: DEK leží raw v OS keystore (baseline ochrana OS).
/// - S PINem: DEK je zabalený Argon2id(PIN) + AES-256-GCM ([DekWrap]);
/// raw kopie se z keystore MAŽE. Ověření PINu = úspěšný unwrap,
/// žádný hash na porovnání neexistuje.
///
/// Lifecycle: appka startuje ZAMČENÁ ([isUnlocked] == false). Data jsou
/// dostupná až po [unlockWithPin] / [unlockWithoutPin] (nebo [enablePin]
/// při onboardingu, který DB rovnou otevře).
/// Nátlakový PIN je shodný se skutečným — nelze nastavit.
class DecoyPinConflict implements Exception {
const DecoyPinConflict();
@override
String toString() => 'decoy PIN must differ from the regular PIN';
}
class SecureStorageService {
static const _identityKey = 'local_identity';
static const _seedPhraseKey = 'seed_phrase';
static const _biometricsEnabledKey = 'biometrics_enabled';
static const _onboardingCompleteKey = 'onboarding_complete';
static const _rawDekKey = 'crypto_db_dek';
static const _wrappedDekKey = 'wrapped_dek';
// Biometrické odemčení: kopie DEK v OS keystore (TEE/StrongBox).
// VĚDOMÝ tradeoff — s PINem jinak DEK existuje JEN za Argon2id(PIN);
// biometrie ho vrací do keystore výměnou za pohodlí. Opt-in.
static const _biometricDekKey = 'biometric_dek';
// Legacy — PBKDF2 hash PINu, jen se uklízí
static const _legacyPinHashKey = 'pin_hash';
static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
final FlutterSecureStorage _secureStorage;
final String? _dbPathOverride;
CryptoDb? _db;
Uint8List? _dek;
bool _decoyMode = false;
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
bool get isDecoyMode => _decoyMode;
SecureStorageService({String? dbPath})
: _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
),
_dbPathOverride = dbPath;
// === Zámek a DB lifecycle ===
bool get isUnlocked => _db != null;
/// Otevřená šifrovaná DB — sdílí ji storage i messaging vrstva.
CryptoDb get db =>
_db ?? (throw StateError('Storage is locked — unlock first'));
/// Odemčení bez PINu — DEK leží raw v OS keystore.
Future<void> unlockWithoutPin() async {
if (isUnlocked) return;
if (await hasPin()) {
throw StateError('PIN is set — use unlockWithPin');
}
final dek = await _getOrCreateRawDek();
await _openDb(dek);
}
/// Odemčení PINem — Argon2id unwrap DEK. Špatný PIN = [WrongPinException].
///
/// Nejdřív zkusí skutečný PIN. Když nesedí a je nastavený nátlakový
/// (decoy) PIN, zkusí ten → otevře PRÁZDNOU decoy DB (plausible
/// deniability: útočník vidí nastavený, ale „nepoužívaný" messenger).
Future<void> unlockWithPin(String pin) async {
if (isUnlocked) return;
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) {
throw StateError('No PIN set — use unlockWithoutPin');
}
try {
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin);
await _openDb(dek);
} on WrongPinException {
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
if (decoy == null) rethrow;
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
await _openDb(dek, decoy: true);
}
}
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
Future<void> enablePin(String pin) async {
final dek = _dek ?? await _getOrCreateRawDek();
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
await _secureStorage.delete(key: _rawDekKey);
if (!isUnlocked) {
await _openDb(dek);
}
}
/// Vypne PIN: DEK se vrátí raw do OS keystore.
Future<void> disablePin() async {
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
await _secureStorage.delete(key: _wrappedDekKey);
}
/// Ověří PIN, aniž by cokoli změnil (unwrap a zahození DEK).
/// Stojí jednu Argon2id derivaci (~2 s) — to je zároveň rate limit.
Future<bool> verifyPin(String pin) async {
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) return false;
try {
await DekWrap.unwrap(wrapped: wrapped, pin: pin);
return true;
} on WrongPinException {
return false;
}
}
/// Změna PINu — starý PIN se ověří unwrapem (špatný = [WrongPinException]).
///
/// Nový PIN se nesmí trefit do nátlakového: jinak by běžné odemčení
/// otevřelo decoy DB (unlockWithPin zkouší real→decoy) a uživatel by
/// přišel o přístup ke svým datům.
Future<void> changePin({
required String oldPin,
required String newPin,
}) async {
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) throw StateError('No PIN set');
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
if (decoy != null) {
try {
await DekWrap.unwrap(wrapped: decoy, pin: newPin);
throw const DecoyPinConflict();
} on WrongPinException {
// OK — nový PIN se liší od nátlakového
}
}
final rewrapped = await DekWrap.wrap(dek: dek, pin: newPin);
await _secureStorage.write(key: _wrappedDekKey, value: rewrapped);
}
Future<bool> hasPin() async {
return await _secureStorage.read(key: _wrappedDekKey) != null;
}
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
Future<void> enableDecoyPin(String decoyPin) async {
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
if (realWrapped == null) {
throw StateError('Set a regular PIN first');
}
// Decoy PIN se musí lišit od skutečného (jinak by se decoy nikdy nespustil)
try {
await DekWrap.unwrap(wrapped: realWrapped, pin: decoyPin);
throw const DecoyPinConflict();
} on WrongPinException {
// OK — decoy se liší od skutečného PINu
}
final rng = Random.secure();
final decoyDek =
Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
final decoyPath = await _dbPath(decoy: true);
CryptoDb.open(path: decoyPath, dek: decoyDek).close();
final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin);
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
}
Future<bool> hasDecoyPin() async {
return await _secureStorage.read(key: _decoyWrappedDekKey) != null;
}
/// Zruší nátlakový PIN a smaže decoy DB.
Future<void> disableDecoyPin() async {
await _secureStorage.delete(key: _decoyWrappedDekKey);
try {
final f = File(await _dbPath(decoy: true));
if (f.existsSync()) f.deleteSync();
} catch (_) {}
}
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
/// (a nechceme) ověřit, uživatel si PIN nastaví znovu. Číst PŘED
/// unlockem ([_cleanupLegacy] marker maže).
Future<bool> hasLegacyPin() async {
return await _secureStorage.read(key: _legacyPinHashKey) != null;
}
/// Zamkne úložiště — zavře DB a zahodí DEK z paměti.
void lock() {
_db?.close();
_db = null;
_dek = null;
}
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
final path = await _dbPath(decoy: decoy);
_db = CryptoDb.open(path: path, dek: dek);
_dek = dek;
_decoyMode = decoy;
if (!decoy) await _cleanupLegacy();
}
Future<String> _dbPath({bool decoy = false}) async {
if (_dbPathOverride != null) {
return decoy ? '$_dbPathOverride.decoy' : _dbPathOverride;
}
final dir = await getApplicationSupportDirectory();
return '${dir.path}/${decoy ? 'sepot_crypto_decoy.db' : 'sepot_crypto.db'}';
}
Future<Uint8List> _getOrCreateRawDek() async {
final existing = await _secureStorage.read(key: _rawDekKey);
if (existing != null) {
return base64Decode(existing);
}
final rng = Random.secure();
final dek = Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
return dek;
}
/// Úklid po starších instalacích: PBKDF2 pin_hash z keystore a hlavně
/// PLAINTEXT Hive soubory z disku (obsahovaly zprávy nešifrovaně).
/// Bez datové migrace — žádná produkční data neexistují.
Future<void> _cleanupLegacy() async {
try {
await _secureStorage.delete(key: _legacyPinHashKey);
final docs = await getApplicationDocumentsDirectory();
for (final box in [
AppConstants.identityBox,
AppConstants.contactsBox,
AppConstants.conversationsBox,
AppConstants.messagesBox,
AppConstants.settingsBox,
]) {
for (final suffix in ['hive', 'lock']) {
final f = File('${docs.path}/$box.$suffix');
if (f.existsSync()) f.deleteSync();
}
}
} catch (_) {
// best-effort — nesmí shodit unlock
}
}
/// Test-only: podstrč otevřenou DB bez OS keystore (Linux VM testy).
@visibleForTesting
void debugAttachDb(CryptoDb db) {
_db = db;
}
// === Identita ===
/// Ulož lokální identitu (šifrovaně v Keychain/Keystore)
Future<void> saveIdentity(LocalIdentity identity) async {
await _secureStorage.write(
key: _identityKey,
value: jsonEncode(identity.toJson()),
);
}
/// Načti lokální identitu
Future<LocalIdentity?> loadIdentity() async {
final json = await _secureStorage.read(key: _identityKey);
if (json == null) return null;
try {
return LocalIdentity.fromJson(jsonDecode(json) as Map<String, dynamic>);
} on FormatException {
// Identita ve starém (před-libsignal) formátu — bez migrace,
// uživatel projde onboardingem znovu (žádná produkční data neexistují)
await _secureStorage.delete(key: _identityKey);
return null;
}
}
/// Existuje identita?
Future<bool> hasIdentity() async {
return await _secureStorage.read(key: _identityKey) != null;
}
// === Seed Phrase ===
Future<void> saveSeedPhrase(List<String> words) async {
await _secureStorage.write(
key: _seedPhraseKey,
value: jsonEncode(words),
);
}
Future<List<String>?> loadSeedPhrase() async {
final json = await _secureStorage.read(key: _seedPhraseKey);
if (json == null) return null;
return (jsonDecode(json) as List).cast<String>();
}
// === Biometrie ===
/// Zapne biometrické odemčení — uloží kopii DEK do OS keystore.
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
Future<void> enableBiometrics() async {
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write(
key: _biometricDekKey,
value: base64Encode(dek),
);
await _secureStorage.write(key: _biometricsEnabledKey, value: 'true');
}
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
/// PIN-only model: DEK existuje jen za Argon2id).
Future<void> disableBiometrics() async {
await _secureStorage.delete(key: _biometricDekKey);
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
}
/// Odemčení po úspěšné biometrické výzvě (local_auth v UI).
Future<void> unlockWithBiometrics() async {
if (isUnlocked) return;
final stored = await _secureStorage.read(key: _biometricDekKey);
if (stored == null) {
throw StateError('Biometric unlock is not set up');
}
await _openDb(base64Decode(stored));
}
Future<bool> isBiometricsEnabled() async {
final value = await _secureStorage.read(key: _biometricsEnabledKey);
return value == 'true' &&
await _secureStorage.read(key: _biometricDekKey) != null;
}
// === Onboarding ===
Future<void> setOnboardingComplete(bool complete) async {
await _secureStorage.write(
key: _onboardingCompleteKey,
value: complete.toString(),
);
}
Future<bool> isOnboardingComplete() async {
final value = await _secureStorage.read(key: _onboardingCompleteKey);
return value == 'true';
}
// === Kontakty (SQLCipher) ===
Future<List<Contact>> loadContacts() async {
return [
for (final json in db.contactsJsonAll())
Contact.fromJson(jsonDecode(json) as Map<String, dynamic>),
];
}
Future<void> saveContact(Contact contact) async {
db.contactPut(contact.id, jsonEncode(contact.toJson()));
}
Future<void> deleteContact(String contactId) async {
db.contactDelete(contactId);
}
// === Skupiny (SQLCipher) ===
Future<List<Group>> loadGroups() async {
return [
for (final json in db.groupsJsonAll())
Group.fromJson(jsonDecode(json) as Map<String, dynamic>),
];
}
Future<Group?> loadGroup(String id) async {
final json = db.groupJson(id);
if (json == null) return null;
return Group.fromJson(jsonDecode(json) as Map<String, dynamic>);
}
Future<void> saveGroup(Group group) async {
db.groupPut(group.id, jsonEncode(group.toJson()));
}
/// Smaže skupinu včetně konverzace (historie pryč — používat po
/// opuštění, když už uživatel historii nechce).
Future<void> deleteGroup(String id) async {
db.groupDelete(id);
}
// === Konverzace (SQLCipher) ===
Future<List<Conversation>> loadConversations() async {
final conversations = [
for (final json in db.conversationsJsonAll())
Conversation.fromJson(jsonDecode(json) as Map<String, dynamic>),
];
conversations.sort((a, b) => b.lastActivity.compareTo(a.lastActivity));
return conversations;
}
Future<Conversation?> loadConversation(String contactId) async {
final json = db.conversationJson(contactId);
if (json == null) return null;
return Conversation.fromJson(jsonDecode(json) as Map<String, dynamic>);
}
Future<void> saveConversation(Conversation conversation) async {
db.conversationPut(
conversation.contactId,
jsonEncode(conversation.toJson()),
);
}
Future<void> addMessage(String contactId, Message message) async {
var conv = await loadConversation(contactId);
conv ??= Conversation(contactId: contactId);
conv.addMessage(message);
await saveConversation(conv);
}
/// Založí prázdnou konverzaci, pokud ještě není (nová skupina musí být
/// v chat listu vidět i před první zprávou).
Future<void> ensureConversation(String contactId) async {
if (await loadConversation(contactId) != null) return;
await saveConversation(Conversation(contactId: contactId));
}
/// Nahradí existující zprávu (dle id) — stav přílohy, doručenka…
Future<void> updateMessage(String contactId, Message message) async {
final conv = await loadConversation(contactId);
if (conv == null) return;
conv.updateMessage(message);
await saveConversation(conv);
}
/// Smaže expirované mizící zprávy (v1 zjednodušení:
/// TTL běží od času zprávy — symetrické pro obě strany bez read
/// receiptů) včetně lokálních blobů příloh. Vrací true = něco smazáno.
Future<bool> purgeExpiredMessages() async {
if (!isUnlocked) return false;
var changed = false;
final now = DateTime.now();
for (final conv in await loadConversations()) {
final expired = conv.messages
.where((m) =>
m.disappearAfterSeconds != null &&
m.timestamp
.add(Duration(seconds: m.disappearAfterSeconds!))
.isBefore(now))
.toList();
if (expired.isEmpty) continue;
changed = true;
for (final m in expired) {
final blobId = m.attachment?.blobId;
if (blobId != null) {
await AttachmentStore().delete(blobId);
}
conv.messages.remove(m);
}
await saveConversation(conv);
}
return changed;
}
// === Nastavení (SQLCipher) ===
Future<void> setSetting(String key, dynamic value) async {
db.appSettingPut(key, jsonEncode(value));
}
Future<T?> getSetting<T>(String key) async {
final json = db.appSettingGet(key);
if (json == null) return null;
return jsonDecode(json) as T?;
}
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
/// i lokální bloby příloh.
Future<void> deleteAll() async {
final path = await _dbPath();
lock();
for (final suffix in ['', '-wal', '-shm']) {
final f = File('$path$suffix');
if (f.existsSync()) f.deleteSync();
}
await AttachmentStore().deleteAll();
await _secureStorage.deleteAll();
}
}
+173
View File
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
/// Písma se dodávají v balíčku (assets/fonts, viz pubspec) — appka nikdy
/// nestahuje fonty za běhu, aby nevznikl síťový požadavek na cizí CDN.
const String kFontSans = 'Inter';
const String kFontMono = 'JetBrains Mono';
/// Barevná paleta Šepot — „Nokturno".
/// Noc + žhavý korál (ember) + chladný šum (mist). Dramaturgie: teplé konce,
/// studený střed — lidé jsou teplí (ember), síť je chladná (mist).
class SepotColors {
SepotColors._();
// Pozadí — noc
static const Color background = Color(0xFF0B0C12);
static const Color surface = Color(0xFF12141D);
static const Color surfaceVariant = Color(0xFF191C28);
// Text
static const Color textPrimary = Color(0xFFF0EEE8); // mléko
static const Color textSecondary = Color(0xFF9AA0B5); // mlha
static const Color textMuted = Color(0xFF626880);
// Akcentní barvy
static const Color ember = Color(0xFFF0795A); // žhavý korál — lidé, CTA
static const Color emberInk = Color(0xFF2B120A); // text na ember ploše
static const Color mist = Color(0xFF7D8BA6); // chladný šum — šifra, síť
static const Color sage = Color(0xFF7FAE8C); // šalvěj — ověřeno/doručeno
// Funkční barvy
static const Color error = Color(0xFFCF6B6B); // cihla
static const Color warning = Color(0xFFD9A441);
static const Color success = Color(0xFF7FAE8C);
// Okraje a oddělovače
static const Color border = Color(0xFF262A3A);
static const Color divider = Color(0xFF171A26);
// Bubliny zpráv — odeslaná teplá (ember), přijatá chladná (povrch)
static const Color bubbleSent = Color(0xFFF0795A);
static const Color bubbleReceived = Color(0xFF191C28);
}
/// Témata aplikace
class SepotTheme {
SepotTheme._();
static ThemeData get dark {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: SepotColors.background,
colorScheme: const ColorScheme.dark(
primary: SepotColors.ember,
secondary: SepotColors.mist,
tertiary: SepotColors.sage,
surface: SepotColors.surface,
error: SepotColors.error,
onPrimary: SepotColors.emberInk,
onSecondary: SepotColors.emberInk,
onSurface: SepotColors.textPrimary,
onError: Colors.white,
),
textTheme: ThemeData.dark().textTheme.apply(
fontFamily: kFontSans,
bodyColor: SepotColors.textPrimary,
displayColor: SepotColors.textPrimary,
),
appBarTheme: const AppBarTheme(
backgroundColor: SepotColors.background,
foregroundColor: SepotColors.textPrimary,
elevation: 0,
scrolledUnderElevation: 0,
),
cardTheme: CardThemeData(
color: SepotColors.surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: SepotColors.border, width: 1),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: SepotColors.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: SepotColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: SepotColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: SepotColors.ember, width: 2),
),
hintStyle: const TextStyle(color: SepotColors.textMuted),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: SepotColors.ember,
foregroundColor: SepotColors.emberInk,
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontFamily: kFontSans,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: SepotColors.ember,
side: const BorderSide(color: SepotColors.ember),
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: SepotColors.ember,
foregroundColor: SepotColors.emberInk,
elevation: 4,
),
dividerTheme: const DividerThemeData(
color: SepotColors.divider,
thickness: 1,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: SepotColors.surface,
selectedItemColor: SepotColors.ember,
unselectedItemColor: SepotColors.textMuted,
),
dialogTheme: DialogThemeData(
backgroundColor: SepotColors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
side: const BorderSide(color: SepotColors.border),
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: SepotColors.surfaceVariant,
contentTextStyle: const TextStyle(color: SepotColors.textPrimary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
behavior: SnackBarBehavior.floating,
),
);
}
/// Styl pro kryptografické texty (monospace)
static const TextStyle cryptoText = TextStyle(
fontFamily: kFontMono,
color: SepotColors.mist,
fontSize: 12,
fontWeight: FontWeight.w400,
);
/// Styl pro Šepot ID
static const TextStyle idText = TextStyle(
fontFamily: kFontMono,
color: SepotColors.ember,
fontSize: 14,
fontWeight: FontWeight.w500,
);
}