Files
sepot/app/test/groups_security_test.dart
T

265 lines
9.6 KiB
Dart

// Needs the native libsignal library (FFI); skipped on builders without it (--exclude-tags ffi).
@Tags(['ffi'])
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/services/messaging_service.dart';
import 'package:sepot/core/storage/crypto_db.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/models/contact.dart';
import 'package:sepot/models/group.dart';
import 'package:sepot/models/identity.dart';
/// Bezpečnostní pravidla skupin — invarianty příjmové cesty: fingerprint
/// v rosteru musí odpovídat identity klíči, odesílatel i já musíme být
/// členy, roster smí měnit jen stávající člen a identity klíč známého
/// kontaktu se nikdy nepřepisuje (TOFU). Útočné scénáře: podvržený roster,
/// cizí odesílatel, přepis identity klíče, pozvánka bez příjemce, roster
/// přes strop.
void main() {
late Directory tmp;
late CryptoDb db;
late SecureStorageService storage;
late MessagingService service;
late LocalIdentity ja;
final rng = Random.secure();
Uint8List randomDek() =>
Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
setUpAll(() async => SepotCrypto.init());
setUp(() async {
tmp = Directory.systemTemp.createTempSync('sepot_groups_sec');
db = CryptoDb.open(path: '${tmp.path}/app.db', dek: randomDek());
storage = SecureStorageService(dbPath: '${tmp.path}/app.db')
..debugAttachDb(db);
service = MessagingService(storage: storage);
ja = await LocalIdentity.create('Já');
service.debugSetIdentity(ja);
});
tearDown(() {
db.close();
tmp.deleteSync(recursive: true);
});
/// Roster entry pro danou identitu (poctivá: f == fingerprint(ik)).
Map<String, dynamic> entry(LocalIdentity id, {bool routing = true}) => {
'f': id.fingerprint.display,
'n': id.displayName,
'ik': base64Encode(id.publicIdentity.identityKey),
if (routing) 'aid': 'a' * 32,
if (routing) 'da': base64Encode(Uint8List(32)),
};
Map<String, dynamic> pozvanka(
String gid,
String gname,
List<Map<String, dynamic>> roster,
) =>
{'v': 1, 't': 'ginv', 'gid': gid, 'gname': gname, 'roster': roster};
test('poctivá pozvánka založí skupinu a neznámé členy jako kontakty',
() async {
final alice = await LocalIdentity.create('Alice');
await service.debugHandleGroupInvite(
alice.fingerprint.display,
pozvanka('g1', 'Rodina', [entry(alice), entry(ja)]),
);
final g = await storage.loadGroup('g1');
expect(g, isNotNull);
expect(g!.name, 'Rodina');
expect(g.memberIds.toSet(),
{alice.fingerprint.display, ja.fingerprint.display});
final kontakty = await storage.loadContacts();
expect(kontakty.map((c) => c.id), contains(alice.fingerprint.display),
reason: 'člen z rosteru se založí jako neověřený kontakt');
expect(kontakty.single.verified, isFalse);
});
test('PODVRŽENÝ ROSTER: fingerprint nesedící ke svému identity klíči '
'se zahodí (anti-spoof)', () async {
final alice = await LocalIdentity.create('Alice');
final mallory = await LocalIdentity.create('Mallory');
// Mallory tvrdí, že klíč patří Aliciinu fingerprintu.
final falesny = entry(mallory)..['f'] = alice.fingerprint.display;
await service.debugHandleGroupInvite(
mallory.fingerprint.display,
pozvanka('g1', 'Podvrh', [falesny, entry(mallory), entry(ja)]),
);
final g = await storage.loadGroup('g1');
expect(g, isNotNull, reason: 'zbytek pozvánky je platný');
expect(g!.memberIds, isNot(contains(alice.fingerprint.display)),
reason: 'záznam s nesedícím fingerprintem se musí zahodit');
final kontakty = await storage.loadContacts();
expect(kontakty.map((c) => c.id), isNot(contains(alice.fingerprint.display)),
reason: 'podvržený člen se nesmí založit jako kontakt');
});
test('pozvánka, ve které odesílatel není členem, se zahodí', () async {
final alice = await LocalIdentity.create('Alice');
final mallory = await LocalIdentity.create('Mallory');
await service.debugHandleGroupInvite(
mallory.fingerprint.display, // odesílatel chybí v rosteru
pozvanka('g1', 'Cizí', [entry(alice), entry(ja)]),
);
expect(await storage.loadGroup('g1'), isNull);
});
test('pozvánka, ve které nejsem členem, se zahodí', () async {
final alice = await LocalIdentity.create('Alice');
final bob = await LocalIdentity.create('Bob');
await service.debugHandleGroupInvite(
alice.fingerprint.display,
pozvanka('g1', 'Bez mě', [entry(alice), entry(bob)]),
);
expect(await storage.loadGroup('g1'), isNull);
});
test('roster přes strop 128 členů se zahodí', () async {
final alice = await LocalIdentity.create('Alice');
final roster = [entry(alice), entry(ja)];
for (var i = 0; i < Group.maxMembers; i++) {
roster.add(entry(await LocalIdentity.create('Člen $i')));
}
await service.debugHandleGroupInvite(
alice.fingerprint.display,
pozvanka('g1', 'Přeplněná', roster),
);
expect(await storage.loadGroup('g1'), isNull);
});
test('roster NEPŘEPÍŠE identity klíč existujícího kontaktu (TOFU)',
() async {
final alice = await LocalIdentity.create('Alice');
final mallory = await LocalIdentity.create('Mallory');
// Alici už znám a mám ověřený její pravý klíč.
await storage.saveContact(Contact(
displayName: 'Alice',
publicIdentity: alice.publicIdentity,
addedAt: DateTime(2026, 7, 1),
verified: true,
));
// Mallory pošle roster, kde Aliciin záznam nese Malloryin klíč pod
// Malloryiným fingerprintem — Alice tam je poctivě, ale útočník
// zkusí přejmenovat/přetlačit. Klíč Alice musí zůstat původní.
final rosterAlice = entry(alice);
await service.debugHandleGroupInvite(
mallory.fingerprint.display,
pozvanka('g1', 'Skupina', [rosterAlice, entry(mallory), entry(ja)]),
);
final kontakty = await storage.loadContacts();
final ulozena = kontakty.firstWhere((c) => c.id == alice.fingerprint.display);
expect(ulozena.publicIdentity.identityKey,
equals(alice.publicIdentity.identityKey));
expect(ulozena.verified, isTrue,
reason: 'pozvánka nesmí shodit ověření existujícího kontaktu');
});
test('MEMBERSHIP GUARD: obsah od nečlena → drop (null)', () async {
final alice = await LocalIdentity.create('Alice');
final mallory = await LocalIdentity.create('Mallory');
await storage.saveGroup(Group(
id: 'g1',
name: 'Uzavřená',
memberIds: [ja.fingerprint.display, alice.fingerprint.display],
createdBy: ja.fingerprint.display,
));
expect(await service.debugGroupForIncoming('g1', alice.fingerprint.display),
isNotNull);
expect(
await service.debugGroupForIncoming('g1', mallory.fingerprint.display),
isNull,
reason: 'nečlen nesmí psát do skupiny');
expect(await service.debugGroupForIncoming('neznama', alice.fingerprint.display),
isNull);
});
test('MEMBERSHIP GUARD: do opuštěné skupiny nedorazí nic', () async {
final alice = await LocalIdentity.create('Alice');
await storage.saveGroup(Group(
id: 'g1',
name: 'Opuštěná',
memberIds: [alice.fingerprint.display],
createdBy: alice.fingerprint.display,
left: true,
));
expect(await service.debugGroupForIncoming('g1', alice.fingerprint.display),
isNull);
});
test('aktualizaci rosteru smí poslat jen stávající člen', () async {
final alice = await LocalIdentity.create('Alice');
final mallory = await LocalIdentity.create('Mallory');
await storage.saveGroup(Group(
id: 'g1',
name: 'Původní',
memberIds: [ja.fingerprint.display, alice.fingerprint.display],
createdBy: ja.fingerprint.display,
));
// Mallory (nečlen) se zkusí přidat vlastní pozvánkou.
await service.debugHandleGroupInvite(
mallory.fingerprint.display,
pozvanka('g1', 'Únos', [entry(mallory), entry(alice), entry(ja)]),
);
final g = await storage.loadGroup('g1');
expect(g!.memberIds, isNot(contains(mallory.fingerprint.display)));
expect(g.name, 'Původní', reason: 'nečlen nesmí ani přejmenovat');
});
test('člen smí rozšířit roster (trust model v1: každý smí přidávat)',
() async {
final alice = await LocalIdentity.create('Alice');
final bob = await LocalIdentity.create('Bob');
await storage.saveGroup(Group(
id: 'g1',
name: 'Skupina',
memberIds: [ja.fingerprint.display, alice.fingerprint.display],
createdBy: ja.fingerprint.display,
));
await service.debugHandleGroupInvite(
alice.fingerprint.display,
pozvanka('g1', 'Skupina', [entry(alice), entry(bob), entry(ja)]),
);
final g = await storage.loadGroup('g1');
expect(g!.memberIds, contains(bob.fingerprint.display));
});
test('pozvánka s prázdným jménem nebo přerostlým gid se zahodí', () async {
final alice = await LocalIdentity.create('Alice');
await service.debugHandleGroupInvite(
alice.fingerprint.display,
pozvanka('g1', '', [entry(alice), entry(ja)]),
);
expect(await storage.loadGroup('g1'), isNull);
final dlouheGid = 'g' * 65;
await service.debugHandleGroupInvite(
alice.fingerprint.display,
pozvanka(dlouheGid, 'Dlouhé', [entry(alice), entry(ja)]),
);
expect(await storage.loadGroup(dlouheGid), isNull);
});
}