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