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:
Julius Joska
2026-10-23 10:50:42 +02:00
parent 2f6b542f24
commit 717aeed4ff
23 changed files with 999 additions and 67 deletions
+27 -3
View File
@@ -26,9 +26,19 @@ class ContactsNotifier extends AsyncNotifier<List<Contact>> {
throw const ContactLinkIncompleteError();
}
// Kontrola duplicity
if (contacts.any((c) => c.publicIdentity.id == identity.id)) {
throw const ContactAlreadyExistsError();
// Kontrola duplicity. Výjimka: žádost o zprávu od téhož klíče — ruční
// přidání (QR, odkaz) je právě to přijetí, na které čekala.
final existing =
contacts.where((c) => c.publicIdentity.id == identity.id).firstOrNull;
if (existing != null) {
if (existing.accepted) throw const ContactAlreadyExistsError();
await accept(existing.id,
name: identity.displayName.isEmpty
? existing.displayName
: identity.displayName);
if (verified) existing.verified = true;
await storage.saveContact(existing);
return existing;
}
final contact = Contact.fromPublicIdentity(identity, verified: verified);
@@ -38,6 +48,20 @@ class ContactsNotifier extends AsyncNotifier<List<Contact>> {
return contact;
}
/// Přijme žádost o zprávu pod jménem, které jí dal uživatel (jméno
/// zvolené odesílatelem se tím nepřebírá automaticky).
Future<void> accept(String contactId, {required String name}) async {
final storage = ref.read(storageProvider);
final contacts = await future;
final contact = contacts.where((c) => c.id == contactId).firstOrNull;
if (contact == null) return;
contact
..displayName = name.trim()
..accepted = true;
await storage.saveContact(contact);
state = AsyncData([...contacts]);
}
/// Smaž kontakt
Future<void> removeContact(String contactId) async {
final storage = ref.read(storageProvider);