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.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/core/safety/lookalike_names.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
|
||||
/// Jméno zvolené odesílatelem se nesmí dát „obejít“ diakritikou, velikostí
|
||||
/// písmen, cizí abecedou ani neviditelnými znaky.
|
||||
void main() {
|
||||
Contact kontakt(String jmeno, {bool accepted = true, int seed = 1}) =>
|
||||
Contact.fromPublicIdentity(
|
||||
PublicIdentity(
|
||||
displayName: jmeno,
|
||||
identityKey: Uint8List.fromList(
|
||||
[0x05, ...List.generate(32, (i) => (i * seed + 3) & 0xff)]),
|
||||
createdAt: DateTime.utc(2026),
|
||||
),
|
||||
accepted: accepted,
|
||||
);
|
||||
|
||||
test('stejná kostra pro varianty téhož jména', () {
|
||||
final mama = nameSkeleton('Máma');
|
||||
for (final varianta in [
|
||||
'mama',
|
||||
'MAMA',
|
||||
' Má ma ',
|
||||
'Máma!',
|
||||
'M\u00E1\u200Bma',
|
||||
'\u041C\u00E1\u043C\u0430', // cyrilské М, м, а
|
||||
'Ma\u0301ma', // kombinující čárka
|
||||
'Mámа\u202E',
|
||||
]) {
|
||||
expect(nameSkeleton(varianta), mama, reason: varianta);
|
||||
}
|
||||
});
|
||||
|
||||
test('homoglyfy l/I/1 a rn/m', () {
|
||||
expect(nameSkeleton('Pavel'), nameSkeleton('PaveI'));
|
||||
expect(nameSkeleton('Pavel'), nameSkeleton('Pave1'));
|
||||
expect(nameSkeleton('Marek'), nameSkeleton('rnarek'));
|
||||
});
|
||||
|
||||
test('různá jména se nepletou', () {
|
||||
expect(nameSkeleton('Máma'), isNot(nameSkeleton('Táta')));
|
||||
expect(nameSkeleton('Jana'), isNot(nameSkeleton('Jan')));
|
||||
});
|
||||
|
||||
test('porovnává jen s přijatými kontakty a ne se sebou', () {
|
||||
final mama = kontakt('Máma');
|
||||
final zadost = kontakt('MAMA', accepted: false, seed: 2);
|
||||
expect(lookalikeContact('mama', [mama, zadost]), same(mama));
|
||||
expect(lookalikeContact('mama', [zadost]), isNull);
|
||||
expect(lookalikeContact('mama', [mama], exceptId: mama.id), isNull);
|
||||
expect(lookalikeContact('', [kontakt('')]), isNull,
|
||||
reason: 'prázdné jméno nic nenese');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user