Files
sepot/app/test/message_request_test.dart
Julius Joska 717aeed4ff fix(security): treat a new identity key as a message request, never as a known contact
A stranger could pick the display name of an existing contact ("Mom")
and appear as that contact. Messages from a key that is not in the
contact list now open a message request labelled "(unknown)": no
replies, read receipts, typing indicators, calls or name in
notifications until the user accepts it and names the contact
themselves. Names that look like an existing contact (case, diacritics,
Cyrillic/Greek homoglyphs, invisible characters) also show a warning in
the real contact's chat.

When the relay account of a known contact shows up with a different
key, the chat shows "safety number changed", verification is cleared
and the session that now belongs to the other key is dropped before
sending, so a reply never goes to the new key.
2026-10-23 10:50:42 +02:00

252 lines
9.5 KiB
Dart

// Potřebuje nativní libsignal (FFI) — na builderu bez Rust toolchainu se vynechává (--exclude-tags ffi).
@Tags(['ffi'])
library;
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:libsignal/libsignal.dart' show ProtocolAddress;
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/persistent_stores.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/models/contact.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/models/identity.dart';
import 'package:sepot/models/message.dart';
/// Phishing „Máma“: cizí klíč se jménem existujícího kontaktu nesmí
/// splynout s kontaktem ani převzít jeho jméno, ověření nebo hovory.
/// Změna klíče pod adresou známého kontaktu ruší ověření a varuje.
void main() {
late Directory tmp;
late CryptoDb db;
late SecureStorageService storage;
late MessagingService service;
late LocalIdentity ja;
late LocalIdentity mama;
late Contact mamaContact;
final rng = Random.secure();
final sent = <(String, Map<String, dynamic>)>[];
final calls = <String>[];
Uint8List randomBytes(int n) =>
Uint8List.fromList(List.generate(n, (_) => rng.nextInt(256)));
const mamaAccount = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
setUpAll(() async => SepotCrypto.init());
setUp(() async {
tmp = Directory.systemTemp.createTempSync('sepot_request');
db = CryptoDb.open(path: '${tmp.path}/app.db', dek: randomBytes(32));
storage = SecureStorageService(dbPath: '${tmp.path}/app.db')
..debugAttachDb(db);
service = MessagingService(storage: storage);
ja = await LocalIdentity.create('Já');
service.debugSetIdentity(ja);
sent.clear();
calls.clear();
service.debugSendTap = (to, content) => sent.add((to, content));
service.onCallSignal = (contact, _) => calls.add(contact.id);
mama = await LocalIdentity.create('Máma');
mamaContact = Contact.fromPublicIdentity(
mama.publicIdentity
.withRouting(accountId: mamaAccount, deliveryAddress: randomBytes(32)),
verified: true,
);
await storage.saveContact(mamaContact);
await storage.saveConversation(Conversation(contactId: mamaContact.id));
});
tearDown(() {
db.close();
tmp.deleteSync(recursive: true);
});
/// Obálka od [from] po dešifrování (sealed sender už ověřil klíč a účet).
Future<void> deliver(
LocalIdentity from,
String account,
Map<String, dynamic> content,
) =>
service.debugProcessOpened(
SepotSealedMessage(
plaintext: utf8.encode(jsonEncode({'v': 1, ...content})),
senderName: account,
senderDeviceId: 1,
senderIdentityKey: from.publicIdentity.identityKey,
),
randomBytes(16),
);
Map<String, dynamic> text(String name, String txt) => {
't': 'txt',
'id': 'm-${rng.nextInt(1 << 30)}',
'txt': txt,
'p': {'n': name, 'da': base64Encode(randomBytes(32))},
};
Future<Contact> contactOf(LocalIdentity id) async =>
(await storage.loadContacts()).singleWhere((c) => c.id == id.id);
test('cizí klíč se jménem „Máma“ = žádost o zprávu, ne splynutí', () async {
final podvrh = await LocalIdentity.create('Máma');
await deliver(podvrh, 'b' * 32, text('Máma', 'Pošli mi kód z SMS'));
final kontakty = await storage.loadContacts();
expect(kontakty, hasLength(2), reason: 'nový klíč = nový kontakt');
final skutecna = await contactOf(mama);
expect(skutecna.displayName, 'Máma');
expect(skutecna.verified, isTrue, reason: 'cizí klíč ověření nemění');
expect(skutecna.publicIdentity.identityKey, mama.publicIdentity.identityKey);
final zadost = await contactOf(podvrh);
expect(zadost.accepted, isFalse);
expect(zadost.verified, isFalse);
final zprava = (await storage.loadConversation(zadost.id))!.messages.single;
expect(zprava.content, 'Pošli mi kód z SMS');
expect(zprava.senderId, podvrh.id, reason: 'zpráva patří podvrhu');
final upozorneni =
(await storage.loadConversation(skutecna.id))!.messages.single;
expect(upozorneni.notice, MessageNotice.lookalikeRequest);
expect(upozorneni.content, 'Máma');
});
test('podobné jméno (cyrilice, velká písmena, mezery) se taky pozná',
() async {
final podvrh = await LocalIdentity.create('x');
await deliver(podvrh, 'c' * 32, text(' МÁМА ', 'ahoj'));
final upozorneni =
(await storage.loadConversation(mamaContact.id))!.messages.single;
expect(upozorneni.notice, MessageNotice.lookalikeRequest);
});
test('stejný účet na relayi s jiným klíčem = změna bezpečnostního kódu',
() async {
final novyKlic = await LocalIdentity.create('Máma');
await deliver(novyKlic, mamaAccount, text('Máma', 'nový telefon'));
final skutecna = await contactOf(mama);
expect(skutecna.verified, isFalse, reason: 'ověření se ruší');
expect(skutecna.displayName, 'Máma');
final upozorneni =
(await storage.loadConversation(skutecna.id))!.messages.single;
expect(upozorneni.notice, MessageNotice.identityChanged);
final zadost = await contactOf(novyKlic);
expect(zadost.accepted, isFalse, reason: 'nový klíč se nepřebírá');
});
test('opakovaná změna nepřidává další a další upozornění', () async {
final novyKlic = await LocalIdentity.create('Máma');
await deliver(novyKlic, mamaAccount, text('Máma', '1'));
await deliver(novyKlic, mamaAccount, text('Máma', '2'));
final zpravy = (await storage.loadConversation(mamaContact.id))!.messages;
expect(zpravy.where((m) => m.notice != null), hasLength(1));
});
test('žádost o zprávu nezvoní, přijatý kontakt ano', () async {
final cizi = await LocalIdentity.create('Máma');
await deliver(cizi, 'd' * 32, text('Máma', 'ahoj'));
await deliver(cizi, 'd' * 32, {'t': 'call', 'id': 'c1', 'k': 'offer'});
expect(calls, isEmpty);
await deliver(mama, mamaAccount, {'t': 'call', 'id': 'c2', 'k': 'offer'});
expect(calls, [mama.id]);
});
test('neznámý odesílatel nedostane potvrzení přečtení', () async {
await storage.setSetting('read_receipts_enabled', true);
final cizi = await LocalIdentity.create('Někdo');
await deliver(cizi, 'e' * 32, text('Někdo', 'ahoj'));
final zadost = await contactOf(cizi);
sent.clear();
await service.sendReadReceipts(zadost, ['m1']);
expect(sent, isEmpty);
await service.sendReadReceipts(await contactOf(mama), ['m2']);
expect(sent.single.$2['t'], 'rr');
});
test('známý kontakt dál chodí do své konverzace beze změny', () async {
await deliver(mama, mamaAccount, text('Úplně jiné jméno', 'ahoj'));
final kontakty = await storage.loadContacts();
expect(kontakty, hasLength(1));
expect(kontakty.single.displayName, 'Máma',
reason: 'jméno z profilu odesílatele nepřepisuje moje');
expect(kontakty.single.accepted, isTrue);
expect(kontakty.single.verified, isTrue);
});
test('kontakt uložený před 1.2.3 (bez příznaku) je přijatý', () {
final json = mamaContact.toJson()..remove('accepted');
expect(Contact.fromJson(json).accepted, isTrue);
});
group('odesílání: relace patřící cizímu klíči', () {
late SepotCrypto ja2;
late ProtocolAddress addr;
setUp(() {
addr = ProtocolAddress(name: mamaAccount, deviceId: 1);
ja2 = SepotCrypto.fromPrivateKey(
privateKey: ja.identityPrivateKey,
accountId: 'f' * 32,
registrationId: 7,
storesFactory: (pair) => SepotStores(
session: DbSessionStore(db),
identity: DbIdentityKeyStore(db,
identityKeyPair: pair, registrationId: 7),
preKey: DbPreKeyStore(db),
signedPreKey: DbSignedPreKeyStore(db),
kyberPreKey: DbKyberPreKeyStore(db),
),
);
});
Future<SepotPreKeyBundle> bundleOf(LocalIdentity id) =>
SepotCrypto.fromPrivateKey(
privateKey: id.identityPrivateKey,
accountId: mamaAccount,
registrationId: 9,
).generatePreKeyBundle(
preKeyId: 1,
signedPreKeyId: 1,
kyberPreKeyId: 1,
timestampMs: DateTime.now().millisecondsSinceEpoch,
);
test('relace s klíčem kontaktu zůstává', () async {
await ja2.processRemoteBundle(addr, await bundleOf(mama));
await service.debugDropForeignSession(ja2, addr, mamaContact);
expect(await ja2.hasSession(addr), isTrue);
expect((await contactOf(mama)).verified, isTrue);
});
test('relace přepsaná cizím klíčem se zahodí a kontakt varuje', () async {
await ja2.processRemoteBundle(addr, await bundleOf(mama));
final utocnik = await LocalIdentity.create('Máma');
await ja2.processRemoteBundle(addr, await bundleOf(utocnik));
await service.debugDropForeignSession(ja2, addr, mamaContact);
expect(await ja2.hasSession(addr), isFalse,
reason: 'odpověď nesmí odejít útočníkovi');
final kontakt = await contactOf(mama);
expect(kontakt.verified, isFalse);
final upozorneni =
(await storage.loadConversation(kontakt.id))!.messages.single;
expect(upozorneni.notice, MessageNotice.identityChanged);
});
});
}