Š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
+122
View File
@@ -0,0 +1,122 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/storage/crypto_db.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/models/message.dart';
/// S9: aplikační data (konverzace, zprávy, nastavení) žijí v SQLCipher DB,
/// ne v plain Hive. Testuje se DB vrstva i service nad ní (debugAttachDb —
/// OS keystore na Linux VM není).
void main() {
late Directory tmp;
late CryptoDb db;
late SecureStorageService storage;
final rng = Random.secure();
Uint8List randomDek() =>
Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
setUp(() {
tmp = Directory.systemTemp.createTempSync('sepot_s9_test');
db = CryptoDb.open(path: '${tmp.path}/app.db', dek: randomDek());
storage = SecureStorageService(dbPath: '${tmp.path}/app.db')
..debugAttachDb(db);
});
tearDown(() {
db.close();
tmp.deleteSync(recursive: true);
});
test('zamčený storage nedá data — StateError, žádný tichý fallback', () {
final locked = SecureStorageService(dbPath: '${tmp.path}/locked.db');
expect(() => locked.db, throwsStateError);
expect(locked.isUnlocked, isFalse);
});
test('zprávy uložené přes addMessage přežijí reopen DB', () async {
final msg = Message.text(
senderId: 'alice',
recipientId: 'bob',
content: 'Tajná zpráva',
);
await storage.addMessage('bob', msg);
// reopen — data musí přijít z disku, ne z paměti
final path = '${tmp.path}/reopen.db';
final dek = randomDek();
final db1 = CryptoDb.open(path: path, dek: dek);
final s1 = SecureStorageService(dbPath: path)..debugAttachDb(db1);
await s1.addMessage('bob', msg);
db1.close();
final db2 = CryptoDb.open(path: path, dek: dek);
final s2 = SecureStorageService(dbPath: path)..debugAttachDb(db2);
final conv = await s2.loadConversation('bob');
expect(conv, isNotNull);
expect(conv!.messages.single.content, 'Tajná zpráva');
db2.close();
});
test('DB soubor neobsahuje plaintext obsahu zpráv', () async {
await storage.addMessage(
'bob',
Message.text(
senderId: 'alice',
recipientId: 'bob',
content: 'NAJDI-MELESS-TOKEN',
),
);
// WAL checkpoint, ať je obsah v hlavním souboru
db.raw.execute('PRAGMA wal_checkpoint(TRUNCATE);');
final bytes = File('${tmp.path}/app.db').readAsBytesSync();
expect(
String.fromCharCodes(bytes).contains('NAJDI-MELESS-TOKEN'),
isFalse,
reason: 'Obsah zprávy je na disku čitelný = at-rest šifrování neběží!',
);
});
test('konverzace se řadí podle poslední aktivity', () async {
await storage.saveConversation(
Conversation(
contactId: 'stary',
lastActivity: DateTime(2026, 1, 1),
),
);
await storage.saveConversation(
Conversation(
contactId: 'novy',
lastActivity: DateTime(2026, 7, 1),
),
);
final list = await storage.loadConversations();
expect(list.map((c) => c.contactId).toList(), ['novy', 'stary']);
});
test('smazání kontaktu smaže i jeho konverzaci (atomicky)', () async {
db.contactPut('bob', '{"id":"bob"}');
db.conversationPut('bob', '{"contactId":"bob","messages":[],'
'"lastActivity":"2026-07-03T00:00:00.000"}');
db.contactDelete('bob');
expect(db.contactsJsonAll(), isEmpty);
expect(db.conversationJson('bob'), isNull);
});
test('nastavení: JSON roundtrip přes app_settings', () async {
await storage.setSetting('disappearing_seconds', 86400);
await storage.setSetting('theme', 'dark');
expect(await storage.getSetting<int>('disappearing_seconds'), 86400);
expect(await storage.getSetting<String>('theme'), 'dark');
expect(await storage.getSetting<String>('neexistuje'), isNull);
});
}
+132
View File
@@ -0,0 +1,132 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/storage/crypto_db.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/models/group.dart';
import 'package:sepot/models/message.dart';
/// Skupiny v1 (GROUPS-V1.md) — perzistence a model. Bezpečnostní pravidla
/// příjmové cesty (roster anti-spoof, membership guard) mají vlastní test
/// v `test/groups_security_test.dart`, protože nepotřebují DB.
void main() {
late Directory tmp;
late CryptoDb db;
late SecureStorageService storage;
final rng = Random.secure();
Uint8List randomDek() =>
Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
setUp(() {
tmp = Directory.systemTemp.createTempSync('sepot_groups_test');
db = CryptoDb.open(path: '${tmp.path}/app.db', dek: randomDek());
storage = SecureStorageService(dbPath: '${tmp.path}/app.db')
..debugAttachDb(db);
});
tearDown(() {
db.close();
tmp.deleteSync(recursive: true);
});
Group vzorovaSkupina({String id = 'g-1', List<String>? members}) => Group(
id: id,
name: 'Rodina',
memberIds: members ?? ['ME', 'ALICE', 'BOB'],
createdBy: 'ME',
);
test('skupina přežije uložení a načtení (JSON roundtrip přes DB)', () async {
final g = vzorovaSkupina();
await storage.saveGroup(g);
final nactena = await storage.loadGroup('g-1');
expect(nactena, isNotNull);
expect(nactena!.name, 'Rodina');
expect(nactena.memberIds, ['ME', 'ALICE', 'BOB']);
expect(nactena.createdBy, 'ME');
expect(nactena.left, isFalse);
expect(nactena.createdAt.toIso8601String(), g.createdAt.toIso8601String());
});
test('loadGroup vrátí null pro id, které patří 1:1 kontaktu', () async {
await storage.saveGroup(vzorovaSkupina());
expect(await storage.loadGroup('ALICE'), isNull,
reason: 'rozlišení skupina vs kontakt stojí na tomhle null');
});
test('loadGroups vrátí všechny skupiny včetně opuštěných', () async {
await storage.saveGroup(vzorovaSkupina(id: 'g-1'));
final opustena = vzorovaSkupina(id: 'g-2')
..left = true
..memberIds = ['ALICE', 'BOB'];
await storage.saveGroup(opustena);
final vsechny = await storage.loadGroups();
expect(vsechny.map((g) => g.id).toSet(), {'g-1', 'g-2'});
expect(vsechny.firstWhere((g) => g.id == 'g-2').left, isTrue,
reason: 'opuštěná skupina zůstává kvůli historii');
});
test('uložení stejného id skupinu přepíše, nezdvojí', () async {
await storage.saveGroup(vzorovaSkupina());
final zmenena = vzorovaSkupina()
..name = 'Rodina a přátelé'
..memberIds = ['ME', 'ALICE', 'BOB', 'CILKA'];
await storage.saveGroup(zmenena);
final vsechny = await storage.loadGroups();
expect(vsechny.length, 1);
expect(vsechny.single.name, 'Rodina a přátelé');
expect(vsechny.single.memberIds.length, 4);
});
test('deleteGroup skupinu odstraní', () async {
await storage.saveGroup(vzorovaSkupina());
await storage.deleteGroup('g-1');
expect(await storage.loadGroup('g-1'), isNull);
expect(await storage.loadGroups(), isEmpty);
});
test('skupinová konverzace žije pod contact_id = gid', () async {
final g = vzorovaSkupina();
await storage.saveGroup(g);
await storage.saveConversation(Conversation(
contactId: g.id,
messages: [
Message.text(senderId: 'ALICE', recipientId: g.id, content: 'ahoj'),
],
));
final conv = await storage.loadConversation(g.id);
expect(conv, isNotNull);
expect(conv!.messages.single.content, 'ahoj');
expect(conv.messages.single.senderId, 'ALICE',
reason: 'autor zprávy = fingerprint člena, ne gid');
});
test('Group.isMember rozhoduje o membership guardu', () {
final g = vzorovaSkupina();
expect(g.isMember('ALICE'), isTrue);
expect(g.isMember('MALLORY'), isFalse);
});
test('strop členů je 128 (MASTER-DESIGN otázka 9)', () {
expect(Group.maxMembers, 128);
});
test('fromJson snese chybějící "left" (starší záznam)', () {
final g = Group.fromJson({
'id': 'g-old',
'name': 'Staré',
'memberIds': ['ME'],
'createdBy': 'ME',
'createdAt': DateTime(2026, 7, 1).toIso8601String(),
});
expect(g.left, isFalse);
});
}
@@ -0,0 +1,168 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:libsignal/libsignal.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/storage/crypto_db.dart';
import 'package:sepot/core/storage/persistent_stores.dart';
import 'package:sqlite3/sqlite3.dart';
/// S3: krypto stavy musí přežít restart procesu — ratchet pokračuje
/// z disku, jinak by každý restart appky rozbil všechny konverzace.
void main() {
late Directory tmp;
final rng = Random.secure();
Uint8List randomDek() =>
Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
SepotStores dbStores(CryptoDb db, IdentityKeyPair identity, int regId) {
return SepotStores(
session: DbSessionStore(db),
identity: DbIdentityKeyStore(
db,
identityKeyPair: identity,
registrationId: regId,
),
preKey: DbPreKeyStore(db),
signedPreKey: DbSignedPreKeyStore(db),
kyberPreKey: DbKyberPreKeyStore(db),
);
}
setUpAll(() async {
await SepotCrypto.init();
});
setUp(() {
tmp = Directory.systemTemp.createTempSync('sepot_s3_test');
});
tearDown(() {
tmp.deleteSync(recursive: true);
});
test('DB soubor na disku není plaintext SQLite (SQLCipher aktivní)', () {
final path = '${tmp.path}/crypto.db';
final db = CryptoDb.open(path: path, dek: randomDek());
db.kvPut('test', Uint8List.fromList([1, 2, 3]));
db.close();
final header = File(path).openSync().readSync(16);
expect(
String.fromCharCodes(header),
isNot(startsWith('SQLite format 3')),
reason: 'Plaintext hlavička = DB se NEšifruje!',
);
});
test('špatný DEK → výjimka, žádný tichý přístup', () {
final path = '${tmp.path}/crypto.db';
CryptoDb.open(path: path, dek: randomDek()).close();
expect(
() => CryptoDb.open(path: path, dek: randomDek()),
throwsA(isA<SqliteException>()),
);
});
test('ratchet přežije restart: handshake → restart Boba → konverzace jede',
() async {
final path = '${tmp.path}/bob.db';
final dek = randomDek();
final bobKeys = SepotCrypto.generateIdentityKeys();
final aliceAddr = ProtocolAddress(name: 'alice', deviceId: 1);
final bobAddr = ProtocolAddress(name: 'bob', deviceId: 1);
// --- Bob, první běh "aplikace" ---
final bobDb1 = CryptoDb.open(path: path, dek: dek);
final bob1 = SepotCrypto.fromPrivateKey(
privateKey: bobKeys.privateKey,
accountId: 'bob',
registrationId: 2,
storesFactory: (identity) => dbStores(bobDb1, identity, 2),
);
final bobBundle = await bob1.generatePreKeyBundle(
preKeyId: 1,
signedPreKeyId: 1,
kyberPreKeyId: 1,
timestampMs: DateTime.now().millisecondsSinceEpoch,
);
// --- Alice (in-memory, druhá strana) ---
final alice = SepotCrypto.generate(accountId: 'alice', registrationId: 1);
// X3DH + obousměrný handshake
await alice.processRemoteBundle(bobAddr, bobBundle);
final msg1 = await alice.encryptTo(bobAddr, 'ahoj Bobe'.codeUnits);
final plain1 = await bob1.decryptFrom(aliceAddr, msg1);
expect(String.fromCharCodes(plain1), 'ahoj Bobe');
final msg2 = await bob1.encryptTo(aliceAddr, 'ahoj Alice'.codeUnits);
final plain2 = await alice.decryptFrom(bobAddr, msg2);
expect(String.fromCharCodes(plain2), 'ahoj Alice');
// OTP prekey se po použití maže (forward secrecy první zprávy)
expect(await DbPreKeyStore(bobDb1).getAllPreKeyIds(), isEmpty);
// --- „Zabití aplikace" ---
bobDb1.close();
// --- Bob, druhý běh: NIC v paměti, vše z disku ---
final bobDb2 = CryptoDb.open(path: path, dek: dek);
final bob2 = SepotCrypto.fromPrivateKey(
privateKey: bobKeys.privateKey,
accountId: 'bob',
registrationId: 2,
storesFactory: (identity) => dbStores(bobDb2, identity, 2),
);
// Ratchet pokračuje oběma směry
final msg3 = await alice.encryptTo(bobAddr, 'po restartu'.codeUnits);
final plain3 = await bob2.decryptFrom(aliceAddr, msg3);
expect(String.fromCharCodes(plain3), 'po restartu');
final msg4 = await bob2.encryptTo(aliceAddr, 'jedu dál'.codeUnits);
final plain4 = await alice.decryptFrom(bobAddr, msg4);
expect(String.fromCharCodes(plain4), 'jedu dál');
bobDb2.close();
});
test('TOFU: změna identity protistrany se detekuje (saveIdentity=true)',
() async {
final db = CryptoDb.open(path: '${tmp.path}/tofu.db', dek: randomDek());
final keys = SepotCrypto.generateIdentityKeys();
final identity = IdentityKeyPair.fromKeys(
privateKey: PrivateKey.deserialize(bytes: keys.privateKey.toList()),
publicKey: PrivateKey.deserialize(bytes: keys.privateKey.toList())
.getPublicKey(),
);
final store = DbIdentityKeyStore(
db,
identityKeyPair: identity,
registrationId: 7,
);
final addr = ProtocolAddress(name: 'mallory', deviceId: 1);
final firstKey = PrivateKey.generate().getPublicKey();
final secondKey = PrivateKey.generate().getPublicKey();
// první kontakt: nová identita, TOFU věří
expect(await store.saveIdentity(addr, firstKey), isTrue);
expect(
await store.isTrustedIdentity(addr, firstKey, Direction.sending),
isTrue,
);
// stejný klíč znovu: beze změny
expect(await store.saveIdentity(addr, firstKey), isFalse);
// JINÝ klíč: nedůvěryhodný, dokud ho saveIdentity nepřepíše
expect(
await store.isTrustedIdentity(addr, secondKey, Direction.sending),
isFalse,
);
db.close();
});
}