92eb316a83
Test comments referenced internal design documents that are not part of this repository. The relevant invariants are now stated inline.
263 lines
9.5 KiB
Dart
263 lines
9.5 KiB
Dart
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);
|
|
});
|
|
}
|