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
+12
View File
@@ -331,6 +331,18 @@ class SepotCrypto {
Future<bool> hasSession(ProtocolAddress remote) =>
_stores.session.containsSession(remote);
/// Identity klíč, pod kterým naposledy vznikla relace s [remote]
/// (zapisuje ho libsignal při X3DH i při příjmu PreKey zprávy).
/// Null = zatím žádný.
Future<Uint8List?> sessionIdentity(ProtocolAddress remote) async {
final key = await _stores.identity.getIdentity(remote);
return key == null ? null : Uint8List.fromList(key.serialize().toList());
}
/// Zahodí relaci s [remote]; další zpráva založí novou z bundle.
Future<void> dropSession(ProtocolAddress remote) =>
_stores.session.deleteSession(remote);
/// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
Future<void> processRemoteBundle(
ProtocolAddress remote,
+12
View File
@@ -0,0 +1,12 @@
import 'package:sepot/l10n/generated/app_localizations.dart';
import 'package:sepot/models/contact.dart';
/// Jméno kontaktu pro UI. U žádosti o zprávu s viditelnou značkou, že si
/// jméno zvolil odesílatel sám: „Máma (neznámý)“ se nesmí na pohled
/// shodovat s přijatou „Mámou“ v seznamu, hlavičce ani ve skupině.
String contactName(AppLocalizations l10n, Contact contact) {
if (contact.displayName.isEmpty) return l10n.commonUnknownContact;
return contact.accepted
? contact.displayName
: l10n.contactRequestName(contact.displayName);
}
+13
View File
@@ -75,6 +75,19 @@ class PushNotifier {
);
}
/// Zpráva od neznámého odesílatele (žádost o zprávu): bez jména, které
/// si zvolil sám.
static Future<void> showMessageRequestNotification() async {
await _ensureInit();
final l10n = l10nFor(await LocaleNotifier.readStored());
await _plugin.show(
_notificationId,
l10n.commonAppName,
l10n.pushMessageRequest,
_details(l10n),
);
}
/// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock →
/// WS připojení → fronta se vyleje normální cestou).
static Future<void> showWakeNotification() async {
+64
View File
@@ -0,0 +1,64 @@
/// Rozpoznání jmen, která se pletou s existujícím kontaktem.
///
/// Jméno v Šepotu si každý volí sám, takže „Máma“ od cizího klíče je
/// nejlevnější phishing. Porovnává se „kostra“ jména: bez velikosti
/// písmen, diakritiky, mezer, interpunkce a neviditelných znaků, s cyrilicí
/// a řečtinou převedenou na latinku. „MAMA“, „Мáма“ (cyrilské М) i
/// „Máma“ s neviditelnou mezerou uprostřed tak padnou na totéž jako „Máma“.
library;
import 'package:sepot/core/utils/text_fold.dart';
import 'package:sepot/models/contact.dart';
// Znaky, které na displeji vypadají jako latinka. Jen ty, co se v malých
// písmenech opravdu pletou; ne celý Unicode confusables seznam — falešná
// shoda tu nic nerozbije, jen přidá varování navíc.
const _confusables = {
'а': 'a', 'в': 'b', 'е': 'e', 'ё': 'e', 'к': 'k', 'м': 'm', 'н': 'h',
'о': 'o', 'р': 'p', 'с': 'c', 'т': 't', 'у': 'y', 'х': 'x', 'і': 'l',
'ї': 'l', 'ј': 'j', 'ѕ': 's', 'ԁ': 'd', 'ɡ': 'g', 'α': 'a', 'β': 'b',
'ε': 'e', 'ι': 'l', 'κ': 'k', 'ν': 'v', 'ο': 'o', 'ρ': 'p', 'τ': 't',
'υ': 'u', 'χ': 'x', 'ү': 'y', '0': 'o', '1': 'l', 'i': 'l', '|': 'l',
// Latinská diakritika mimo češtinu a slovenštinu (tu skládá už fold).
'à': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a', 'ą': 'a', 'ç': 'c',
'ć': 'c', 'è': 'e', 'ê': 'e', 'ē': 'e', 'ę': 'e', 'ì': 'l', 'î': 'l',
'ï': 'l', 'ł': 'l', 'ñ': 'n', 'ń': 'n', 'ò': 'o', 'õ': 'o', 'ø': 'o',
'ő': 'o', 'ś': 's', 'ù': 'u', 'û': 'u', 'ű': 'u', 'ÿ': 'y', 'ź': 'z',
'ż': 'z',
};
final _invisible = RegExp(
'[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180E'
'\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0F\uFEFF]');
final _combining = RegExp('[\u0300-\u036F]');
final _separators = RegExp(r'[\s\p{P}\p{S}]', unicode: true);
/// Kostra jména pro porovnání. Prázdný výsledek = jméno nic nenese.
String nameSkeleton(String name) {
final folded = fold(name
.replaceAll(_invisible, '')
.replaceAll(_combining, '')
.replaceAll(_separators, ''));
final out = StringBuffer();
for (final ch in folded.split('')) {
out.write(_confusables[ch] ?? ch);
}
// „rn“ se v bezpatkovém písmu čte jako „m“.
return out.toString().replaceAll('rn', 'm');
}
/// Přijatý kontakt, se kterým se [name] plete (null = nic podobného).
/// [exceptId] = sám odesílatel, ten se se sebou neporovnává.
Contact? lookalikeContact(
String name,
Iterable<Contact> contacts, {
String? exceptId,
}) {
final skeleton = nameSkeleton(name);
if (skeleton.isEmpty) return null;
for (final c in contacts) {
if (!c.accepted || c.id == exceptId) continue;
if (nameSkeleton(c.displayName) == skeleton) return c;
}
return null;
}
+127 -25
View File
@@ -36,6 +36,7 @@ import 'package:sepot/core/errors.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/network/relay_client.dart';
import 'package:sepot/core/network/relay_transport.dart';
import 'package:sepot/core/safety/lookalike_names.dart';
import 'package:sepot/core/services/group_admin_policy.dart';
import 'package:sepot/core/services/image_processor.dart';
import 'package:sepot/core/services/typing_tracker.dart';
@@ -218,7 +219,7 @@ class MessagingService {
final targets = contacts.where((c) =>
!c.blocked &&
(group == null
? c.id == convId
? c.id == convId && c.accepted
: group.memberIds.contains(c.id) && c.id != _identity?.id));
for (final c in targets) {
final until = _typingBackoffUntil[c.id];
@@ -993,6 +994,8 @@ class MessagingService {
Future<void> sendReadReceipts(Contact contact, List<String> messageIds,
{String? gid}) async {
if (messageIds.isEmpty) return;
// Neznámý odesílatel se nedozví, že jsem jeho zprávu otevřel.
if (gid == null && !contact.accepted) return;
final enabled =
(await storage.getSetting<bool>('read_receipts_enabled')) ?? false;
if (!enabled) return;
@@ -1948,19 +1951,26 @@ class MessagingService {
verified: known.verified,
notes: known.notes,
blocked: known.blocked,
avatar: known.avatar,
accepted: known.accepted,
));
}
continue;
}
final name = (e['n'] as String?)?.trim();
await storage.saveContact(Contact.fromPublicIdentity(PublicIdentity(
// Prázdné jméno = UI si doplní lokalizovaný fallback
displayName: (name == null || name.isEmpty) ? '' : name,
identityKey: base64Decode(e['ik'] as String),
createdAt: DateTime.now(),
accountId: aid,
deliveryAddress: daB64 == null ? null : base64Decode(daB64),
)));
// Člena skupiny jsem si nepřidal já: jméno mu dal on sám (nebo ten,
// kdo roster poslal). Jako u příchozí zprávy je to žádost.
await storage.saveContact(Contact.fromPublicIdentity(
PublicIdentity(
// Prázdné jméno = UI si doplní lokalizovaný fallback
displayName: (name == null || name.isEmpty) ? '' : clipName(name),
identityKey: base64Decode(e['ik'] as String),
createdAt: DateTime.now(),
accountId: aid,
deliveryAddress: daB64 == null ? null : base64Decode(daB64),
),
accepted: false,
));
}
}
@@ -2215,12 +2225,14 @@ class MessagingService {
// načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek
// nedrží — čekání na receipt by blokovalo všechny ostatní.
final sealed = await _serial<Uint8List?>(() async {
await _dropForeignSession(crypto, peerAddr, contact);
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
if (!await crypto.hasSession(peerAddr)) {
if (ephemeral) return null;
final bundle = await relay.fetchBundle(peerDa);
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
await _noteIdentityChanged(contact);
throw const IdentityKeyMismatchError();
}
await crypto.processRemoteBundle(peerAddr, bundle);
@@ -2245,6 +2257,29 @@ class MessagingService {
return receipt.status;
}
/// libsignal 5.0.9 nad prázdným paměťovým storem identit důvěřuje
/// čemukoli (TOFU bez paměti): příchozí PreKey zpráva s cizím klíčem pod
/// stejnou adresou relaci tiše přepíše a další odpověď by šla jemu.
/// Klíč relace proto porovnáváme s klíčem kontaktu sami; nesedí-li,
/// relace se zahodí (nová vznikne z bundle, který se ověřuje výše)
/// a kontakt dostane varování.
Future<void> _dropForeignSession(
SepotCrypto crypto, ProtocolAddress peerAddr, Contact contact) async {
if (!await crypto.hasSession(peerAddr)) return;
final bound = await crypto.sessionIdentity(peerAddr);
if (bound == null ||
_listEquals(bound, contact.publicIdentity.identityKey)) {
return;
}
await crypto.dropSession(peerAddr);
await _noteIdentityChanged(contact);
}
@visibleForTesting
Future<void> debugDropForeignSession(
SepotCrypto crypto, ProtocolAddress peerAddr, Contact contact) =>
_dropForeignSession(crypto, peerAddr, contact);
// ── Příjem ──────────────────────────────────────────────────────────
/// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako
@@ -2279,7 +2314,17 @@ class MessagingService {
// (může jít o dočasný stav — např. cert refresh).
return;
}
await _processOpened(opened, envelope.msgUuid);
}
@visibleForTesting
Future<void> debugProcessOpened(
SepotSealedMessage opened, List<int> msgUuid) =>
_processOpened(opened, msgUuid);
/// Zpracuje už dešifrovanou obálku: kontakt, typ obsahu, uložení, ACK.
Future<void> _processOpened(
SepotSealedMessage opened, List<int> msgUuid) async {
// Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu
// nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se
// proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by
@@ -2292,7 +2337,7 @@ class MessagingService {
if (content['v'] != 1 || !known.contains(type)) {
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
_relay?.ack(envelope.msgUuid);
_relay?.ack(msgUuid);
return;
}
@@ -2302,15 +2347,19 @@ class MessagingService {
// Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje),
// nic neukládej ani nezobrazuj a neposílej doručenku.
if (contact.blocked) {
_relay!.ack(envelope.msgUuid);
_relay?.ack(msgUuid);
return;
}
// Hovory jen 1:1; skupinový gid tu nemá co dělat.
// Nic se neukládá — záznam „Hovor · 3:12“ si zapíše obsluha hovoru.
// Žádost o zprávu nezvoní: cizí klíč by jinak rozezvonil telefon
// s vlastním zvoleným jménem („Máma volá“).
if (type == 'call') {
if (content['gid'] == null) onCallSignal?.call(contact, content);
_relay!.ack(envelope.msgUuid);
if (content['gid'] == null && contact.accepted) {
onCallSignal?.call(contact, content);
}
_relay?.ack(msgUuid);
return;
}
@@ -2320,7 +2369,7 @@ class MessagingService {
// nepomůže).
if (_groupControlTypes.contains(type)) {
await _handleGroupControl(senderFp, type, content);
_relay!.ack(envelope.msgUuid);
_relay?.ack(msgUuid);
return;
}
@@ -2330,7 +2379,7 @@ class MessagingService {
final group =
gid == null ? null : await _groupForIncoming(gid, senderFp);
if (gid != null && group == null) {
_relay!.ack(envelope.msgUuid);
_relay?.ack(msgUuid);
return;
}
if (group != null && _noteCapability(group, senderFp, content)) {
@@ -2345,7 +2394,7 @@ class MessagingService {
final fresh = DateTime.now().difference(wireSentAt(content['ts'])) <
TypingTracker.showFor;
if (fresh && await _typingEnabled()) typing.mark(convId, senderFp);
_relay!.ack(envelope.msgUuid);
_relay?.ack(msgUuid);
return;
}
@@ -2380,7 +2429,7 @@ class MessagingService {
);
}
}
_relay!.ack(envelope.msgUuid);
_relay?.ack(msgUuid);
onUpdate?.call();
return;
}
@@ -2389,7 +2438,7 @@ class MessagingService {
// novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže).
if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') {
await _applyIncomingControl(convId, type!, content, opened);
_relay!.ack(envelope.msgUuid);
_relay?.ack(msgUuid);
onUpdate?.call();
return;
}
@@ -2407,7 +2456,7 @@ class MessagingService {
if (raw is! String) {
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
_relay!.ack(envelope.msgUuid);
_relay?.ack(msgUuid);
return;
}
// Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden
@@ -2441,7 +2490,7 @@ class MessagingService {
}
} on Object {
// Trvale vadná metadata — retry z fronty nepomůže, ACKnout
_relay!.ack(envelope.msgUuid);
_relay?.ack(msgUuid);
return;
}
message = Message(
@@ -2466,7 +2515,7 @@ class MessagingService {
// Uloženo na disku → teprve teď smí relay obálku zahodit.
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při
// neúspěchu má UI retry, ACK na něj nečeká.)
_relay!.ack(envelope.msgUuid);
_relay?.ack(msgUuid);
// Zprávu už známe (odesílatel ji poslal znovu po výpadku spojení).
// Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale
@@ -2499,11 +2548,11 @@ class MessagingService {
} on FormatException {
// Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64
// v profilu či rosteru) — trvale vadné, ACK a zahodit.
_relay?.ack(envelope.msgUuid);
_relay?.ack(msgUuid);
} on TypeError {
// Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) —
// trvale vadné, ACK a zahodit.
_relay?.ack(envelope.msgUuid);
_relay?.ack(msgUuid);
} catch (_) {
// Přechodné selhání zpracování (storage apod.): NEACKovat — další
// pokus po reconnectu za nic nedá, i když duplicitní decrypt
@@ -2733,6 +2782,7 @@ class MessagingService {
// Bez tohohle se zablokovaný kontakt při změně adresy odblokoval.
blocked: c.blocked,
avatar: avatarChanged ? avatar : c.avatar,
accepted: c.accepted,
);
await storage.saveContact(updated);
if (avatarChanged) {
@@ -2743,7 +2793,9 @@ class MessagingService {
return updated;
}
// Neznámý odesílatel → nový (neověřený) kontakt
// Neznámý klíč = nový kontakt, NIKDY splynutí s existujícím. Identita
// je klíč: jméno ani fotka, které si odesílatel zvolil, neříkají nic
// o tom, kdo to je. Proto žádost o zprávu, ne kontakt.
// Prázdné jméno = UI si doplní lokalizovaný fallback
final name = (profile?['n'] as String?)?.trim();
final identity = PublicIdentity(
@@ -2753,12 +2805,62 @@ class MessagingService {
accountId: opened.senderName,
deliveryAddress: profileDa == null ? null : base64Decode(profileDa),
);
final contact = Contact.fromPublicIdentity(identity)..avatar = avatar;
final contact = Contact.fromPublicIdentity(identity, accepted: false)
..avatar = avatar;
await storage.saveContact(contact);
if (avatar != null) unawaited(_downloadAvatar(avatar));
// Účet na relayi patří jednomu klíči navždy (identities.identity_key je
// unikátní). Stejný účet s jiným klíčem = relay lže nebo byl převzat:
// dosavadní kontakt ztrácí ověření a dostane varování.
final previous = contacts
.where((c) => c.publicIdentity.accountId == opened.senderName)
.firstOrNull;
if (previous != null) await _noteIdentityChanged(previous);
final lookalike =
lookalikeContact(identity.displayName, contacts, exceptId: contact.id);
if (lookalike != null && lookalike.id != previous?.id) {
await _noteLookalike(lookalike, identity.displayName);
}
return contact;
}
/// Bezpečnostní kód kontaktu se změnil: zrušit ověření a dát to najevo
/// v jeho konverzaci. Opakovaný výskyt za sebou nepřidává další řádky.
Future<void> _noteIdentityChanged(Contact contact) async {
if (contact.verified) {
contact.verified = false;
await storage.saveContact(contact);
}
await _addNotice(contact, MessageNotice.identityChanged);
}
/// Někdo cizí se jmenuje skoro stejně jako [contact]. Připomínka do jeho
/// konverzace (jen když existuje — nezakládat chat kvůli útočníkovi).
Future<void> _noteLookalike(Contact contact, String claimedName) async {
if (await storage.loadConversation(contact.id) == null) return;
await _addNotice(contact, MessageNotice.lookalikeRequest,
content: claimedName);
}
Future<void> _addNotice(Contact contact, MessageNotice notice,
{String? content}) async {
final me = _identity;
if (me == null) return;
final last = (await storage.loadConversation(contact.id))?.lastMessage;
if (last?.notice == notice && last?.content == content) return;
await storage.addMessage(
contact.id,
Message.notice(
contactId: contact.id,
myId: me.id,
notice: notice,
content: content),
);
onUpdate?.call();
}
// ── Sdílení vlastního kontaktu ──────────────────────────────────────
/// Veřejná identita včetně routingu — tohle patří do QR/odkazu.