Šepot — end-to-end encrypted messenger

Initial public release. Anonymous on-device identity (no phone number,
e-mail, or password), Signal-protocol cryptography (X3DH + PQXDH, Double
Ratchet, sealed sender) via libsignal, a metadata-minimizing relay,
group chats, disappearing messages, encrypted local storage, optional
Tor transport, and a UI localized into Czech, English, German, Russian,
and Italian.

Monorepo: Flutter client (app/), Rust relay + wire types (rust/),
protobuf definitions (proto/), self-host deploy (deploy/).

Licensed under AGPL-3.0 with an app-store distribution exception (§7).
This commit is contained in:
Julius Joska
2026-07-11 17:06:39 +02:00
commit 8081d98c67
309 changed files with 44102 additions and 0 deletions
@@ -0,0 +1,67 @@
/// Lokální úložiště blobů příloh.
///
/// Na disku leží VŽDY jen šifrovaný blob (ciphertext || tag) pod názvem
/// blob_id — stejné bajty jako na relayi. At-rest ochrana = AES-256-GCM
/// per-file klíčem, který žije výhradně v SQLCipher DB (metadata zprávy).
/// Dešifruje se až při zobrazení, do paměti; plaintext se na disk dostane
/// jen krátkodobě při sdílení do jiné aplikace (viz UI vrstva).
///
/// blob_id přichází i od protistrany (remote input) — před použitím jako
/// název souboru se validuje tvar (32 hex znaků), žádný path traversal.
library;
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
class AttachmentStore {
AttachmentStore({String? dirOverride}) : _dirOverride = dirOverride;
final String? _dirOverride;
static final _blobIdPattern = RegExp(r'^[0-9a-f]{32}$');
static bool isValidBlobId(String blobId) => _blobIdPattern.hasMatch(blobId);
Future<Directory> _dir() async {
final base = _dirOverride ??
'${(await getApplicationSupportDirectory()).path}/attachments';
return Directory(base).create(recursive: true);
}
Future<File> _file(String blobId) async {
if (!isValidBlobId(blobId)) {
throw ArgumentError.value(blobId, 'blobId', 'invalid blob_id format');
}
return File('${(await _dir()).path}/$blobId');
}
Future<void> save(String blobId, Uint8List blob) async {
final file = await _file(blobId);
// přes temp + rename, ať pád zápisu nenechá poloviční blob
final tmp = File('${file.path}.part');
await tmp.writeAsBytes(blob, flush: true);
await tmp.rename(file.path);
}
Future<bool> exists(String blobId) async => (await _file(blobId)).exists();
/// Vrátí šifrovaný blob, nebo null, když (ještě) není stažený.
Future<Uint8List?> load(String blobId) async {
final file = await _file(blobId);
if (!await file.exists()) return null;
return file.readAsBytes();
}
Future<void> delete(String blobId) async {
final file = await _file(blobId);
if (await file.exists()) await file.delete();
}
/// Factory reset — smaže celý adresář příloh.
Future<void> deleteAll() async {
final dir = await _dir();
if (await dir.exists()) await dir.delete(recursive: true);
}
}
+225
View File
@@ -0,0 +1,225 @@
/// Šifrovaná lokální DB (SQLCipher) — krypto stavy i aplikační data.
///
/// Drží ratchet sessiony, prekeys, identity protistran a dále i kontakty,
/// konverzace se zprávami a nastavení (dřív plain Hive — nepřijatelné pro
/// privacy app). VLASTNÍ identity privátní klíč tu NENÍ — žije v OS keystore
/// (flutter_secure_storage), stejně jako DEK téhle databáze (obrana do
/// hloubky).
///
/// Nativní knihovnu (libsqlcipher) dodává build hook balíčku `sqlite3`
/// (pubspec: `hooks.user_defines.sqlite3.source: sqlcipher`) — jednotně
/// pro Android, iOS, desktop i testy na Linuxu.
///
/// Zápisy ratchet stavů jsou atomické (SQLite transakce + WAL) — neatomický
/// zápis session recordu = „bad MAC" a rozbitá konverzace.
library;
import 'dart:typed_data';
import 'package:sqlite3/sqlite3.dart';
class CryptoDb {
CryptoDb._(this._db);
final Database _db;
/// Otevře (a případně založí) šifrovanou DB.
///
/// [dek] je 32B klíč z OS keystore. Špatný klíč = [SqliteException]
/// hned při první query (SQLCipher nerozliší „špatný klíč" od
/// „poškozený soubor").
static CryptoDb open({required String path, required Uint8List dek}) {
if (dek.length != 32) {
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
}
final db = sqlite3.open(path);
try {
// Pojistka: kdyby build přilinkl plain sqlite3 místo SQLCipher,
// DB by se TIŠE založila nešifrovaná. To se nikdy nesmí stát.
final cipherVersion = db.select('PRAGMA cipher_version;');
if (cipherVersion.isEmpty) {
throw StateError(
'Native library is not SQLCipher — at-rest encryption would not run. '
'Check hooks.user_defines.sqlite3.source in pubspec.yaml.',
);
}
final hexKey =
dek.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
// PRAGMA key s raw klíčem (x'..' formát) — žádné KDF ze stringu
db.execute("PRAGMA key = \"x'$hexKey'\";");
db.execute('PRAGMA journal_mode = WAL;');
db.execute('PRAGMA foreign_keys = ON;');
// První dotaz ověří klíč — na špatném klíči vyhodí NOTADB
db.execute(_schema);
return CryptoDb._(db);
} catch (_) {
db.close();
rethrow;
}
}
static const _schema = '''
CREATE TABLE IF NOT EXISTS kv (
k TEXT PRIMARY KEY,
v BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
name TEXT NOT NULL,
device_id INTEGER NOT NULL,
record BLOB NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (name, device_id)
);
CREATE TABLE IF NOT EXISTS remote_identities (
name TEXT PRIMARY KEY,
identity BLOB NOT NULL,
first_seen INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS prekeys (
id INTEGER PRIMARY KEY,
record BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS signed_prekeys (
id INTEGER PRIMARY KEY,
record BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS kyber_prekeys (
id INTEGER PRIMARY KEY,
record BLOB NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY,
json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations (
contact_id TEXT PRIMARY KEY,
json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS app_settings (
k TEXT PRIMARY KEY,
v TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
json TEXT NOT NULL
);
''';
Database get raw => _db;
/// Atomický blok — ratchet stavy se smí zapisovat jen takhle.
T transaction<T>(T Function() body) {
_db.execute('BEGIN IMMEDIATE;');
try {
final result = body();
_db.execute('COMMIT;');
return result;
} catch (_) {
_db.execute('ROLLBACK;');
rethrow;
}
}
Uint8List? kvGet(String key) {
final rows = _db.select('SELECT v FROM kv WHERE k = ?', [key]);
if (rows.isEmpty) return null;
return rows.first['v'] as Uint8List;
}
void kvPut(String key, Uint8List value) {
_db.execute(
'INSERT INTO kv (k, v) VALUES (?, ?) '
'ON CONFLICT(k) DO UPDATE SET v = excluded.v',
[key, value],
);
}
// ── Aplikační data — modely jako JSON, (de)serializuje volající ──
List<String> contactsJsonAll() {
final rows = _db.select('SELECT json FROM contacts');
return [for (final r in rows) r['json'] as String];
}
void contactPut(String id, String json) {
_db.execute(
'INSERT INTO contacts (id, json) VALUES (?, ?) '
'ON CONFLICT(id) DO UPDATE SET json = excluded.json',
[id, json],
);
}
/// Smaže kontakt VČETNĚ jeho konverzace (atomicky).
void contactDelete(String id) {
transaction(() {
_db.execute('DELETE FROM contacts WHERE id = ?', [id]);
_db.execute('DELETE FROM conversations WHERE contact_id = ?', [id]);
});
}
String? conversationJson(String contactId) {
final rows = _db.select(
'SELECT json FROM conversations WHERE contact_id = ?',
[contactId],
);
if (rows.isEmpty) return null;
return rows.first['json'] as String;
}
List<String> conversationsJsonAll() {
final rows = _db.select('SELECT json FROM conversations');
return [for (final r in rows) r['json'] as String];
}
void conversationPut(String contactId, String json) {
_db.execute(
'INSERT INTO conversations (contact_id, json) VALUES (?, ?) '
'ON CONFLICT(contact_id) DO UPDATE SET json = excluded.json',
[contactId, json],
);
}
List<String> groupsJsonAll() {
final rows = _db.select('SELECT json FROM groups');
return [for (final r in rows) r['json'] as String];
}
String? groupJson(String id) {
final rows = _db.select('SELECT json FROM groups WHERE id = ?', [id]);
if (rows.isEmpty) return null;
return rows.first['json'] as String;
}
void groupPut(String id, String json) {
_db.execute(
'INSERT INTO groups (id, json) VALUES (?, ?) '
'ON CONFLICT(id) DO UPDATE SET json = excluded.json',
[id, json],
);
}
/// Smaže skupinu VČETNĚ její konverzace (atomicky).
void groupDelete(String id) {
transaction(() {
_db.execute('DELETE FROM groups WHERE id = ?', [id]);
_db.execute('DELETE FROM conversations WHERE contact_id = ?', [id]);
});
}
String? appSettingGet(String key) {
final rows = _db.select('SELECT v FROM app_settings WHERE k = ?', [key]);
if (rows.isEmpty) return null;
return rows.first['v'] as String;
}
void appSettingPut(String key, String value) {
_db.execute(
'INSERT INTO app_settings (k, v) VALUES (?, ?) '
'ON CONFLICT(k) DO UPDATE SET v = excluded.v',
[key, value],
);
}
void close() => _db.close();
}
+316
View File
@@ -0,0 +1,316 @@
/// Perzistentní libsignal stores nad šifrovanou DB (SQLCipher).
///
/// Ratchet stavy, prekeys a identity protistran přežívají restart aplikace.
/// Vlastní identity keypair sem NEPATŘÍ — dostává se zvenčí (OS keystore),
/// store drží jen odkaz v paměti.
///
/// Trust model identity protistran: TOFU per účet (name), stejná sémantika
/// jako InMemoryIdentityKeyStore z balíčku, ale porovnává se SERIALIZOVANÁ
/// podoba klíče (opaque FFI handle nemá spolehlivé ==).
library;
import 'dart:typed_data';
import 'package:libsignal/libsignal.dart';
import 'package:sepot/core/storage/crypto_db.dart';
bool _bytesEqual(List<int> a, List<int> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
class DbSessionStore implements SessionStore {
DbSessionStore(this._db);
final CryptoDb _db;
@override
Future<SessionRecord?> loadSession(ProtocolAddress address) async {
final rows = _db.raw.select(
'SELECT record FROM sessions WHERE name = ? AND device_id = ?',
[address.name(), address.deviceId()],
);
if (rows.isEmpty) return null;
return SessionRecord.deserialize(
bytes: (rows.first['record'] as Uint8List).toList(),
);
}
@override
Future<void> storeSession(
ProtocolAddress address,
SessionRecord record,
) async {
// Atomicky — částečně zapsaný ratchet stav = „bad MAC"
_db.transaction(() {
_db.raw.execute(
'INSERT INTO sessions (name, device_id, record, updated_at) '
'VALUES (?, ?, ?, ?) '
'ON CONFLICT(name, device_id) DO UPDATE '
'SET record = excluded.record, updated_at = excluded.updated_at',
[
address.name(),
address.deviceId(),
record.serialize(),
DateTime.now().millisecondsSinceEpoch,
],
);
});
}
@override
Future<bool> containsSession(ProtocolAddress address) async {
final rows = _db.raw.select(
'SELECT 1 FROM sessions WHERE name = ? AND device_id = ?',
[address.name(), address.deviceId()],
);
return rows.isNotEmpty;
}
@override
Future<void> deleteSession(ProtocolAddress address) async {
_db.raw.execute(
'DELETE FROM sessions WHERE name = ? AND device_id = ?',
[address.name(), address.deviceId()],
);
}
@override
Future<void> deleteAllSessions(String name) async {
_db.raw.execute('DELETE FROM sessions WHERE name = ?', [name]);
}
@override
Future<List<int>> getSubDeviceSessions(String name) async {
final rows = _db.raw.select(
'SELECT device_id FROM sessions WHERE name = ?',
[name],
);
return [for (final r in rows) r['device_id'] as int];
}
}
class DbIdentityKeyStore implements IdentityKeyStore {
DbIdentityKeyStore(
this._db, {
required IdentityKeyPair identityKeyPair,
required int registrationId,
}) : _identityKeyPair = identityKeyPair,
_registrationId = registrationId;
final CryptoDb _db;
final IdentityKeyPair _identityKeyPair;
final int _registrationId;
@override
Future<IdentityKeyPair> getIdentityKeyPair() async => _identityKeyPair;
@override
Future<int> getLocalRegistrationId() async => _registrationId;
Uint8List? _stored(String name) {
final rows = _db.raw.select(
'SELECT identity FROM remote_identities WHERE name = ?',
[name],
);
if (rows.isEmpty) return null;
return rows.first['identity'] as Uint8List;
}
@override
Future<bool> saveIdentity(
ProtocolAddress address,
PublicKey identityKey,
) async {
final name = address.name();
final incoming = identityKey.serialize();
final existing = _stored(name);
if (existing != null && _bytesEqual(existing, incoming)) {
return false; // beze změny
}
_db.raw.execute(
'INSERT INTO remote_identities (name, identity, first_seen) '
'VALUES (?, ?, ?) '
'ON CONFLICT(name) DO UPDATE SET identity = excluded.identity',
[name, incoming, DateTime.now().millisecondsSinceEpoch],
);
return true; // nová nebo ZMĚNĚNÁ identita (UI ukáže safety-number varování)
}
@override
Future<PublicKey?> getIdentity(ProtocolAddress address) async {
final stored = _stored(address.name());
if (stored == null) return null;
return PublicKey.deserialize(bytes: stored.toList());
}
@override
Future<bool> isTrustedIdentity(
ProtocolAddress address,
PublicKey identityKey,
Direction direction,
) async {
final stored = _stored(address.name());
if (stored == null) return true; // TOFU
return _bytesEqual(stored, identityKey.serialize());
}
}
class DbPreKeyStore implements PreKeyStore {
DbPreKeyStore(this._db);
final CryptoDb _db;
@override
Future<PreKeyRecord?> loadPreKey(int preKeyId) async {
final rows = _db.raw.select(
'SELECT record FROM prekeys WHERE id = ?',
[preKeyId],
);
if (rows.isEmpty) return null;
return PreKeyRecord.deserialize(
bytes: (rows.first['record'] as Uint8List).toList(),
);
}
@override
Future<void> storePreKey(int preKeyId, PreKeyRecord record) async {
_db.raw.execute(
'INSERT INTO prekeys (id, record) VALUES (?, ?) '
'ON CONFLICT(id) DO UPDATE SET record = excluded.record',
[preKeyId, record.serialize()],
);
}
@override
Future<bool> containsPreKey(int preKeyId) async {
return _db.raw
.select('SELECT 1 FROM prekeys WHERE id = ?', [preKeyId]).isNotEmpty;
}
@override
Future<void> removePreKey(int preKeyId) async {
// One-time prekey se po použití MAŽE (forward secrecy první zprávy)
_db.raw.execute('DELETE FROM prekeys WHERE id = ?', [preKeyId]);
}
@override
Future<List<int>> getAllPreKeyIds() async {
final rows = _db.raw.select('SELECT id FROM prekeys ORDER BY id');
return [for (final r in rows) r['id'] as int];
}
}
class DbSignedPreKeyStore implements SignedPreKeyStore {
DbSignedPreKeyStore(this._db);
final CryptoDb _db;
@override
Future<SignedPreKeyRecord?> loadSignedPreKey(int signedPreKeyId) async {
final rows = _db.raw.select(
'SELECT record FROM signed_prekeys WHERE id = ?',
[signedPreKeyId],
);
if (rows.isEmpty) return null;
return SignedPreKeyRecord.deserialize(
bytes: (rows.first['record'] as Uint8List).toList(),
);
}
@override
Future<void> storeSignedPreKey(
int signedPreKeyId,
SignedPreKeyRecord record,
) async {
_db.raw.execute(
'INSERT INTO signed_prekeys (id, record) VALUES (?, ?) '
'ON CONFLICT(id) DO UPDATE SET record = excluded.record',
[signedPreKeyId, record.serialize()],
);
}
@override
Future<bool> containsSignedPreKey(int signedPreKeyId) async {
return _db.raw.select(
'SELECT 1 FROM signed_prekeys WHERE id = ?',
[signedPreKeyId],
).isNotEmpty;
}
@override
Future<void> removeSignedPreKey(int signedPreKeyId) async {
_db.raw.execute(
'DELETE FROM signed_prekeys WHERE id = ?',
[signedPreKeyId],
);
}
@override
Future<List<int>> getAllSignedPreKeyIds() async {
final rows = _db.raw.select('SELECT id FROM signed_prekeys ORDER BY id');
return [for (final r in rows) r['id'] as int];
}
}
class DbKyberPreKeyStore implements KyberPreKeyStore {
DbKyberPreKeyStore(this._db);
final CryptoDb _db;
@override
Future<KyberPreKeyRecord?> loadKyberPreKey(int kyberPreKeyId) async {
final rows = _db.raw.select(
'SELECT record FROM kyber_prekeys WHERE id = ?',
[kyberPreKeyId],
);
if (rows.isEmpty) return null;
return KyberPreKeyRecord.deserialize(
bytes: (rows.first['record'] as Uint8List).toList(),
);
}
@override
Future<void> storeKyberPreKey(
int kyberPreKeyId,
KyberPreKeyRecord record,
) async {
_db.raw.execute(
'INSERT INTO kyber_prekeys (id, record) VALUES (?, ?) '
'ON CONFLICT(id) DO UPDATE SET record = excluded.record',
[kyberPreKeyId, record.serialize()],
);
}
@override
Future<bool> containsKyberPreKey(int kyberPreKeyId) async {
return _db.raw.select(
'SELECT 1 FROM kyber_prekeys WHERE id = ?',
[kyberPreKeyId],
).isNotEmpty;
}
@override
Future<void> markKyberPreKeyUsed(int kyberPreKeyId) async {
// Last-resort Kyber prekey se NEMAŽE (nahrazuje chybějící OTP,
// ochrana proti replay útoku) — jen se značí použití
_db.raw.execute(
'UPDATE kyber_prekeys SET used = 1 WHERE id = ?',
[kyberPreKeyId],
);
}
@override
Future<void> removeKyberPreKey(int kyberPreKeyId) async {
_db.raw.execute('DELETE FROM kyber_prekeys WHERE id = ?', [kyberPreKeyId]);
}
@override
Future<List<int>> getAllKyberPreKeyIds() async {
final rows = _db.raw.select('SELECT id FROM kyber_prekeys ORDER BY id');
return [for (final r in rows) r['id'] as int];
}
}
+520
View File
@@ -0,0 +1,520 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/crypto/dek_wrap.dart';
import 'package:sepot/core/storage/attachment_store.dart';
import 'package:sepot/core/storage/crypto_db.dart';
import 'package:sepot/models/identity.dart';
import 'package:sepot/models/contact.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/models/group.dart';
import 'package:sepot/models/message.dart';
/// Lokální úložiště — OS keystore (klíče) + SQLCipher DB (všechna data).
///
/// Klíčový model:
/// - DEK (32 B, náhodný) šifruje jedinou SQLCipher DB, kde žije VŠECHNO:
/// krypto stavy, kontakty, konverzace se zprávami i nastavení.
/// - Bez PINu: DEK leží raw v OS keystore (baseline ochrana OS).
/// - S PINem: DEK je zabalený Argon2id(PIN) + AES-256-GCM ([DekWrap]);
/// raw kopie se z keystore MAŽE. Ověření PINu = úspěšný unwrap,
/// žádný hash na porovnání neexistuje.
///
/// Lifecycle: appka startuje ZAMČENÁ ([isUnlocked] == false). Data jsou
/// dostupná až po [unlockWithPin] / [unlockWithoutPin] (nebo [enablePin]
/// při onboardingu, který DB rovnou otevře).
/// Nátlakový PIN je shodný se skutečným — nelze nastavit.
class DecoyPinConflict implements Exception {
const DecoyPinConflict();
@override
String toString() => 'decoy PIN must differ from the regular PIN';
}
class SecureStorageService {
static const _identityKey = 'local_identity';
static const _seedPhraseKey = 'seed_phrase';
static const _biometricsEnabledKey = 'biometrics_enabled';
static const _onboardingCompleteKey = 'onboarding_complete';
static const _rawDekKey = 'crypto_db_dek';
static const _wrappedDekKey = 'wrapped_dek';
// Biometrické odemčení: kopie DEK v OS keystore (TEE/StrongBox).
// VĚDOMÝ tradeoff — s PINem jinak DEK existuje JEN za Argon2id(PIN);
// biometrie ho vrací do keystore výměnou za pohodlí. Opt-in.
static const _biometricDekKey = 'biometric_dek';
// Legacy — PBKDF2 hash PINu, jen se uklízí
static const _legacyPinHashKey = 'pin_hash';
static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
final FlutterSecureStorage _secureStorage;
final String? _dbPathOverride;
CryptoDb? _db;
Uint8List? _dek;
bool _decoyMode = false;
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
bool get isDecoyMode => _decoyMode;
SecureStorageService({String? dbPath})
: _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
),
_dbPathOverride = dbPath;
// === Zámek a DB lifecycle ===
bool get isUnlocked => _db != null;
/// Otevřená šifrovaná DB — sdílí ji storage i messaging vrstva.
CryptoDb get db =>
_db ?? (throw StateError('Storage is locked — unlock first'));
/// Odemčení bez PINu — DEK leží raw v OS keystore.
Future<void> unlockWithoutPin() async {
if (isUnlocked) return;
if (await hasPin()) {
throw StateError('PIN is set — use unlockWithPin');
}
final dek = await _getOrCreateRawDek();
await _openDb(dek);
}
/// Odemčení PINem — Argon2id unwrap DEK. Špatný PIN = [WrongPinException].
///
/// Nejdřív zkusí skutečný PIN. Když nesedí a je nastavený nátlakový
/// (decoy) PIN, zkusí ten → otevře PRÁZDNOU decoy DB (plausible
/// deniability: útočník vidí nastavený, ale „nepoužívaný" messenger).
Future<void> unlockWithPin(String pin) async {
if (isUnlocked) return;
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) {
throw StateError('No PIN set — use unlockWithoutPin');
}
try {
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin);
await _openDb(dek);
} on WrongPinException {
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
if (decoy == null) rethrow;
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
await _openDb(dek, decoy: true);
}
}
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
Future<void> enablePin(String pin) async {
final dek = _dek ?? await _getOrCreateRawDek();
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
await _secureStorage.delete(key: _rawDekKey);
if (!isUnlocked) {
await _openDb(dek);
}
}
/// Vypne PIN: DEK se vrátí raw do OS keystore.
Future<void> disablePin() async {
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
await _secureStorage.delete(key: _wrappedDekKey);
}
/// Ověří PIN, aniž by cokoli změnil (unwrap a zahození DEK).
/// Stojí jednu Argon2id derivaci (~2 s) — to je zároveň rate limit.
Future<bool> verifyPin(String pin) async {
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) return false;
try {
await DekWrap.unwrap(wrapped: wrapped, pin: pin);
return true;
} on WrongPinException {
return false;
}
}
/// Změna PINu — starý PIN se ověří unwrapem (špatný = [WrongPinException]).
///
/// Nový PIN se nesmí trefit do nátlakového: jinak by běžné odemčení
/// otevřelo decoy DB (unlockWithPin zkouší real→decoy) a uživatel by
/// přišel o přístup ke svým datům.
Future<void> changePin({
required String oldPin,
required String newPin,
}) async {
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) throw StateError('No PIN set');
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
if (decoy != null) {
try {
await DekWrap.unwrap(wrapped: decoy, pin: newPin);
throw const DecoyPinConflict();
} on WrongPinException {
// OK — nový PIN se liší od nátlakového
}
}
final rewrapped = await DekWrap.wrap(dek: dek, pin: newPin);
await _secureStorage.write(key: _wrappedDekKey, value: rewrapped);
}
Future<bool> hasPin() async {
return await _secureStorage.read(key: _wrappedDekKey) != null;
}
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
Future<void> enableDecoyPin(String decoyPin) async {
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
if (realWrapped == null) {
throw StateError('Set a regular PIN first');
}
// Decoy PIN se musí lišit od skutečného (jinak by se decoy nikdy nespustil)
try {
await DekWrap.unwrap(wrapped: realWrapped, pin: decoyPin);
throw const DecoyPinConflict();
} on WrongPinException {
// OK — decoy se liší od skutečného PINu
}
final rng = Random.secure();
final decoyDek =
Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
final decoyPath = await _dbPath(decoy: true);
CryptoDb.open(path: decoyPath, dek: decoyDek).close();
final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin);
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
}
Future<bool> hasDecoyPin() async {
return await _secureStorage.read(key: _decoyWrappedDekKey) != null;
}
/// Zruší nátlakový PIN a smaže decoy DB.
Future<void> disableDecoyPin() async {
await _secureStorage.delete(key: _decoyWrappedDekKey);
try {
final f = File(await _dbPath(decoy: true));
if (f.existsSync()) f.deleteSync();
} catch (_) {}
}
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
/// (a nechceme) ověřit, uživatel si PIN nastaví znovu. Číst PŘED
/// unlockem ([_cleanupLegacy] marker maže).
Future<bool> hasLegacyPin() async {
return await _secureStorage.read(key: _legacyPinHashKey) != null;
}
/// Zamkne úložiště — zavře DB a zahodí DEK z paměti.
void lock() {
_db?.close();
_db = null;
_dek = null;
}
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
final path = await _dbPath(decoy: decoy);
_db = CryptoDb.open(path: path, dek: dek);
_dek = dek;
_decoyMode = decoy;
if (!decoy) await _cleanupLegacy();
}
Future<String> _dbPath({bool decoy = false}) async {
if (_dbPathOverride != null) {
return decoy ? '$_dbPathOverride.decoy' : _dbPathOverride;
}
final dir = await getApplicationSupportDirectory();
return '${dir.path}/${decoy ? 'sepot_crypto_decoy.db' : 'sepot_crypto.db'}';
}
Future<Uint8List> _getOrCreateRawDek() async {
final existing = await _secureStorage.read(key: _rawDekKey);
if (existing != null) {
return base64Decode(existing);
}
final rng = Random.secure();
final dek = Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
return dek;
}
/// Úklid po starších instalacích: PBKDF2 pin_hash z keystore a hlavně
/// PLAINTEXT Hive soubory z disku (obsahovaly zprávy nešifrovaně).
/// Bez datové migrace — žádná produkční data neexistují.
Future<void> _cleanupLegacy() async {
try {
await _secureStorage.delete(key: _legacyPinHashKey);
final docs = await getApplicationDocumentsDirectory();
for (final box in [
AppConstants.identityBox,
AppConstants.contactsBox,
AppConstants.conversationsBox,
AppConstants.messagesBox,
AppConstants.settingsBox,
]) {
for (final suffix in ['hive', 'lock']) {
final f = File('${docs.path}/$box.$suffix');
if (f.existsSync()) f.deleteSync();
}
}
} catch (_) {
// best-effort — nesmí shodit unlock
}
}
/// Test-only: podstrč otevřenou DB bez OS keystore (Linux VM testy).
@visibleForTesting
void debugAttachDb(CryptoDb db) {
_db = db;
}
// === Identita ===
/// Ulož lokální identitu (šifrovaně v Keychain/Keystore)
Future<void> saveIdentity(LocalIdentity identity) async {
await _secureStorage.write(
key: _identityKey,
value: jsonEncode(identity.toJson()),
);
}
/// Načti lokální identitu
Future<LocalIdentity?> loadIdentity() async {
final json = await _secureStorage.read(key: _identityKey);
if (json == null) return null;
try {
return LocalIdentity.fromJson(jsonDecode(json) as Map<String, dynamic>);
} on FormatException {
// Identita ve starém (před-libsignal) formátu — bez migrace,
// uživatel projde onboardingem znovu (žádná produkční data neexistují)
await _secureStorage.delete(key: _identityKey);
return null;
}
}
/// Existuje identita?
Future<bool> hasIdentity() async {
return await _secureStorage.read(key: _identityKey) != null;
}
// === Seed Phrase ===
Future<void> saveSeedPhrase(List<String> words) async {
await _secureStorage.write(
key: _seedPhraseKey,
value: jsonEncode(words),
);
}
Future<List<String>?> loadSeedPhrase() async {
final json = await _secureStorage.read(key: _seedPhraseKey);
if (json == null) return null;
return (jsonDecode(json) as List).cast<String>();
}
// === Biometrie ===
/// Zapne biometrické odemčení — uloží kopii DEK do OS keystore.
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
Future<void> enableBiometrics() async {
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write(
key: _biometricDekKey,
value: base64Encode(dek),
);
await _secureStorage.write(key: _biometricsEnabledKey, value: 'true');
}
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
/// PIN-only model: DEK existuje jen za Argon2id).
Future<void> disableBiometrics() async {
await _secureStorage.delete(key: _biometricDekKey);
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
}
/// Odemčení po úspěšné biometrické výzvě (local_auth v UI).
Future<void> unlockWithBiometrics() async {
if (isUnlocked) return;
final stored = await _secureStorage.read(key: _biometricDekKey);
if (stored == null) {
throw StateError('Biometric unlock is not set up');
}
await _openDb(base64Decode(stored));
}
Future<bool> isBiometricsEnabled() async {
final value = await _secureStorage.read(key: _biometricsEnabledKey);
return value == 'true' &&
await _secureStorage.read(key: _biometricDekKey) != null;
}
// === Onboarding ===
Future<void> setOnboardingComplete(bool complete) async {
await _secureStorage.write(
key: _onboardingCompleteKey,
value: complete.toString(),
);
}
Future<bool> isOnboardingComplete() async {
final value = await _secureStorage.read(key: _onboardingCompleteKey);
return value == 'true';
}
// === Kontakty (SQLCipher) ===
Future<List<Contact>> loadContacts() async {
return [
for (final json in db.contactsJsonAll())
Contact.fromJson(jsonDecode(json) as Map<String, dynamic>),
];
}
Future<void> saveContact(Contact contact) async {
db.contactPut(contact.id, jsonEncode(contact.toJson()));
}
Future<void> deleteContact(String contactId) async {
db.contactDelete(contactId);
}
// === Skupiny (SQLCipher) ===
Future<List<Group>> loadGroups() async {
return [
for (final json in db.groupsJsonAll())
Group.fromJson(jsonDecode(json) as Map<String, dynamic>),
];
}
Future<Group?> loadGroup(String id) async {
final json = db.groupJson(id);
if (json == null) return null;
return Group.fromJson(jsonDecode(json) as Map<String, dynamic>);
}
Future<void> saveGroup(Group group) async {
db.groupPut(group.id, jsonEncode(group.toJson()));
}
/// Smaže skupinu včetně konverzace (historie pryč — používat po
/// opuštění, když už uživatel historii nechce).
Future<void> deleteGroup(String id) async {
db.groupDelete(id);
}
// === Konverzace (SQLCipher) ===
Future<List<Conversation>> loadConversations() async {
final conversations = [
for (final json in db.conversationsJsonAll())
Conversation.fromJson(jsonDecode(json) as Map<String, dynamic>),
];
conversations.sort((a, b) => b.lastActivity.compareTo(a.lastActivity));
return conversations;
}
Future<Conversation?> loadConversation(String contactId) async {
final json = db.conversationJson(contactId);
if (json == null) return null;
return Conversation.fromJson(jsonDecode(json) as Map<String, dynamic>);
}
Future<void> saveConversation(Conversation conversation) async {
db.conversationPut(
conversation.contactId,
jsonEncode(conversation.toJson()),
);
}
Future<void> addMessage(String contactId, Message message) async {
var conv = await loadConversation(contactId);
conv ??= Conversation(contactId: contactId);
conv.addMessage(message);
await saveConversation(conv);
}
/// Založí prázdnou konverzaci, pokud ještě není (nová skupina musí být
/// v chat listu vidět i před první zprávou).
Future<void> ensureConversation(String contactId) async {
if (await loadConversation(contactId) != null) return;
await saveConversation(Conversation(contactId: contactId));
}
/// Nahradí existující zprávu (dle id) — stav přílohy, doručenka…
Future<void> updateMessage(String contactId, Message message) async {
final conv = await loadConversation(contactId);
if (conv == null) return;
conv.updateMessage(message);
await saveConversation(conv);
}
/// Smaže expirované mizící zprávy (v1 zjednodušení:
/// TTL běží od času zprávy — symetrické pro obě strany bez read
/// receiptů) včetně lokálních blobů příloh. Vrací true = něco smazáno.
Future<bool> purgeExpiredMessages() async {
if (!isUnlocked) return false;
var changed = false;
final now = DateTime.now();
for (final conv in await loadConversations()) {
final expired = conv.messages
.where((m) =>
m.disappearAfterSeconds != null &&
m.timestamp
.add(Duration(seconds: m.disappearAfterSeconds!))
.isBefore(now))
.toList();
if (expired.isEmpty) continue;
changed = true;
for (final m in expired) {
final blobId = m.attachment?.blobId;
if (blobId != null) {
await AttachmentStore().delete(blobId);
}
conv.messages.remove(m);
}
await saveConversation(conv);
}
return changed;
}
// === Nastavení (SQLCipher) ===
Future<void> setSetting(String key, dynamic value) async {
db.appSettingPut(key, jsonEncode(value));
}
Future<T?> getSetting<T>(String key) async {
final json = db.appSettingGet(key);
if (json == null) return null;
return jsonDecode(json) as T?;
}
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
/// i lokální bloby příloh.
Future<void> deleteAll() async {
final path = await _dbPath();
lock();
for (final suffix in ['', '-wal', '-shm']) {
final f = File('$path$suffix');
if (f.existsSync()) f.deleteSync();
}
await AttachmentStore().deleteAll();
await _secureStorage.deleteAll();
}
}