Š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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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()),
|
||||
};
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user