diff --git a/app/lib/core/crypto/sepot_crypto.dart b/app/lib/core/crypto/sepot_crypto.dart index 4b5effe..3f9e4c6 100644 --- a/app/lib/core/crypto/sepot_crypto.dart +++ b/app/lib/core/crypto/sepot_crypto.dart @@ -331,6 +331,18 @@ class SepotCrypto { Future 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 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 dropSession(ProtocolAddress remote) => + _stores.session.deleteSession(remote); + /// X3DH/PQXDH: zpracuje bundle protistrany a založí session. Future processRemoteBundle( ProtocolAddress remote, diff --git a/app/lib/core/l10n/contact_name.dart b/app/lib/core/l10n/contact_name.dart new file mode 100644 index 0000000..d078bc5 --- /dev/null +++ b/app/lib/core/l10n/contact_name.dart @@ -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); +} diff --git a/app/lib/core/push/push_notifier.dart b/app/lib/core/push/push_notifier.dart index d92343d..f921af4 100644 --- a/app/lib/core/push/push_notifier.dart +++ b/app/lib/core/push/push_notifier.dart @@ -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 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 showWakeNotification() async { diff --git a/app/lib/core/safety/lookalike_names.dart b/app/lib/core/safety/lookalike_names.dart new file mode 100644 index 0000000..c272b5a --- /dev/null +++ b/app/lib/core/safety/lookalike_names.dart @@ -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 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; +} diff --git a/app/lib/core/services/messaging_service.dart b/app/lib/core/services/messaging_service.dart index 3c30e90..4f2b605 100644 --- a/app/lib/core/services/messaging_service.dart +++ b/app/lib/core/services/messaging_service.dart @@ -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 sendReadReceipts(Contact contact, List 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('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(() 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 _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 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 debugProcessOpened( + SepotSealedMessage opened, List msgUuid) => + _processOpened(opened, msgUuid); + + /// Zpracuje už dešifrovanou obálku: kontakt, typ obsahu, uložení, ACK. + Future _processOpened( + SepotSealedMessage opened, List 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 _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 _noteLookalike(Contact contact, String claimedName) async { + if (await storage.loadConversation(contact.id) == null) return; + await _addNotice(contact, MessageNotice.lookalikeRequest, + content: claimedName); + } + + Future _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. diff --git a/app/lib/features/chat_list/archived_chats_screen.dart b/app/lib/features/chat_list/archived_chats_screen.dart index 66550cb..ae79082 100644 --- a/app/lib/features/chat_list/archived_chats_screen.dart +++ b/app/lib/features/chat_list/archived_chats_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:sepot/core/l10n/contact_name.dart'; import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/theme/app_theme.dart'; @@ -45,13 +46,15 @@ class ArchivedChatsScreen extends ConsumerWidget { ?.where((c) => c.publicIdentity.id == conv.contactId) .firstOrNull; final name = group?.name ?? - contact?.displayName ?? - context.l10n.commonUnknownContact; + (contact == null + ? context.l10n.commonUnknownContact + : contactName(context.l10n, contact)); return ChatListTile( conversation: conv, contactName: name, myId: myId, isGroup: group != null, + isRequest: group == null && contact?.accepted == false, onTap: () => context.push('/conversation/${conv.contactId}'), onLongPress: () => _actions(context, ref, conv), ); diff --git a/app/lib/features/chat_list/chat_list_screen.dart b/app/lib/features/chat_list/chat_list_screen.dart index 2ee85b7..a10bad7 100644 --- a/app/lib/features/chat_list/chat_list_screen.dart +++ b/app/lib/features/chat_list/chat_list_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:sepot/core/l10n/contact_name.dart'; import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/theme/app_theme.dart'; @@ -206,7 +207,7 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> { String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId) ? l10n.notesTitle : groupOf(c.contactId)?.name ?? - contactOf(c.contactId)?.displayName ?? + _nameOrNull(l10n, contactOf(c.contactId)) ?? l10n.commonUnknownContact; return SafeArea( @@ -265,7 +266,8 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> { ? l10n.chatTyping : writers.length == 1 ? l10n.chatTypingNamed( - (contactOf(writers.first)?.displayName ?? '?') + (_nameOrNull(l10n, contactOf(writers.first)) ?? + '?') .split(' ') .first) : l10n.chatTypingSeveral; @@ -275,8 +277,10 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> { myId: myId, isGroup: group != null, verified: contactOf(c.contactId)?.verified ?? false, + isRequest: group == null && + contactOf(c.contactId)?.accepted == false, lastAuthor: group != null && last != null - ? contactOf(last.senderId)?.displayName + ? _nameOrNull(l10n, contactOf(last.senderId)) : null, highlight: hits[c], typingLabel: typingLabel, @@ -685,3 +689,6 @@ class _EmptyState extends StatelessWidget { ); } } + +String? _nameOrNull(AppLocalizations l10n, Contact? c) => + c == null ? null : contactName(l10n, c); diff --git a/app/lib/features/chat_list/chat_list_tile.dart b/app/lib/features/chat_list/chat_list_tile.dart index 05dd47f..de8caf3 100644 --- a/app/lib/features/chat_list/chat_list_tile.dart +++ b/app/lib/features/chat_list/chat_list_tile.dart @@ -18,6 +18,10 @@ class ChatListTile extends StatelessWidget { final bool isGroup; final bool verified; + /// Žádost o zprávu (neznámý odesílatel): náhled místo textu hlásí, o co + /// jde — první věta útočníka nemá dostat prostor v seznamu chatů. + final bool isRequest; + /// Jméno autora poslední zprávy ve skupině (null = 1:1 nebo neznámý). final String? lastAuthor; @@ -40,6 +44,7 @@ class ChatListTile extends StatelessWidget { required this.myId, this.isGroup = false, this.verified = false, + this.isRequest = false, this.lastAuthor, this.highlight, this.typingLabel, @@ -54,7 +59,9 @@ class ChatListTile extends StatelessWidget { final unread = conversation.unreadCount(myId); final muted = conversation.isMuted; final mine = lastMsg != null && lastMsg.isMine(myId); - final preview = previewOf(context.l10n, lastMsg); + final preview = isRequest + ? (icon: Icons.person_search_rounded, text: context.l10n.requestTitle) + : previewOf(context.l10n, lastMsg); return InkWell( onTap: onTap, @@ -245,6 +252,9 @@ class ChatListTile extends StatelessWidget { final label = callLogLabel(l10n, call); return (icon: label.icon, text: label.text); } + if (msg.notice != null) { + return (icon: Icons.gpp_maybe_rounded, text: l10n.noticePreview); + } return switch (msg.type) { MessageType.text => (icon: null, text: msg.content ?? ''), MessageType.image => ( diff --git a/app/lib/features/contacts/contacts_screen.dart b/app/lib/features/contacts/contacts_screen.dart index dc279a6..f9f4a2d 100644 --- a/app/lib/features/contacts/contacts_screen.dart +++ b/app/lib/features/contacts/contacts_screen.dart @@ -95,7 +95,9 @@ class _ContactsScreenState extends ConsumerState { Expanded( child: contactsAsync.when( data: (contacts) { + // Žádosti o zprávu kontakty nejsou — žijí jen v seznamu chatů. final filtered = contacts.where((c) { + if (!c.accepted) return false; if (_searchQuery.isEmpty) return true; return c.displayName.toLowerCase().contains(_searchQuery) || c.sepotId.toLowerCase().contains(_searchQuery); diff --git a/app/lib/features/conversation/chat_actions.dart b/app/lib/features/conversation/chat_actions.dart index 5380143..f8ffc02 100644 --- a/app/lib/features/conversation/chat_actions.dart +++ b/app/lib/features/conversation/chat_actions.dart @@ -5,6 +5,7 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:sepot/core/l10n/contact_name.dart'; import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/theme/app_theme.dart'; @@ -17,9 +18,7 @@ import 'package:sepot/providers/groups_provider.dart'; import 'package:sepot/providers/storage_provider.dart'; String contactLabel(BuildContext context, Contact contact) => - contact.displayName.isEmpty - ? context.l10n.commonUnknownContact - : contact.displayName; + contactName(context.l10n, contact); void _snack(BuildContext context, String text) { if (!context.mounted) return; diff --git a/app/lib/features/conversation/conversation_screen.dart b/app/lib/features/conversation/conversation_screen.dart index 1b0088e..7628b11 100644 --- a/app/lib/features/conversation/conversation_screen.dart +++ b/app/lib/features/conversation/conversation_screen.dart @@ -12,6 +12,7 @@ import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mime/mime.dart'; import 'package:intl/intl.dart'; +import 'package:sepot/core/l10n/contact_name.dart'; import 'package:sepot/core/calls/call_controller.dart'; import 'package:sepot/core/constants/app_constants.dart'; import 'package:sepot/core/l10n/error_messages.dart'; @@ -21,6 +22,8 @@ import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/utils/text_fold.dart'; import 'package:sepot/features/calls/call_log_text.dart'; import 'package:sepot/features/conversation/chat_actions.dart'; +import 'package:sepot/features/conversation/message_request_panel.dart'; +import 'package:sepot/features/conversation/widgets/safety_notice_row.dart'; import 'package:sepot/features/conversation/forward_sheet.dart'; import 'package:sepot/features/conversation/widgets/attachment_content.dart'; import 'package:sepot/features/conversation/widgets/create_poll_sheet.dart'; @@ -272,14 +275,13 @@ class _ConversationScreenState extends ConsumerState // v kontaktech (roster je zakládá až s první zprávou), zkrátíme na id. String autorJmeno(String fingerprint) { if (fingerprint == identity?.id) return context.l10n.groupsYou; - final jmeno = contacts + final autor = contacts .where((c) => c.publicIdentity.id == fingerprint) - .firstOrNull - ?.displayName; - if (jmeno == null) { + .firstOrNull; + if (autor == null) { return 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}'; } - return jmeno.isEmpty ? context.l10n.commonUnknownContact : jmeno; + return contactName(context.l10n, autor); } final Widget bottom; @@ -293,6 +295,8 @@ class _ConversationScreenState extends ConsumerState child: Text(context.l10n.chatUnblock), ), ); + } else if (group == null && contact != null && !contact.accepted) { + bottom = MessageRequestPanel(contact: contact); } else { bottom = _buildComposer(); } @@ -472,9 +476,9 @@ class _ConversationScreenState extends ConsumerState } Widget _buildContactHeader(Contact? contact, {bool typing = false}) { - final name = (contact == null || contact.displayName.isEmpty) + final name = contact == null ? context.l10n.commonUnknownContact - : contact.displayName; + : contactName(context.l10n, contact); final verified = contact?.verified ?? false; return _buildHeaderFrame(children: [ _backButton(), @@ -485,19 +489,27 @@ class _ConversationScreenState extends ConsumerState // Stav ověření místo syrového ID (UX-FLOWS §3.1) subtitle: typing ? _typingLabel(context.l10n.chatTyping) - : Text( - verified - ? context.l10n.verifyVerified - : context.l10n.verifyUnverified, - style: TextStyle( - fontSize: 12, - color: - verified ? SepotColors.sage : SepotColors.textSecondary, - ), - ), + : contact != null && !contact.accepted + ? Text( + context.l10n.requestTitle, + style: const TextStyle( + fontSize: 12, color: SepotColors.warning), + ) + : Text( + verified + ? context.l10n.verifyVerified + : context.l10n.verifyUnverified, + style: TextStyle( + fontSize: 12, + color: verified + ? SepotColors.sage + : SepotColors.textSecondary, + ), + ), onTap: () => _openProfile('/contact/${widget.contactId}'), ), - if (contact != null && !contact.blocked) ...[ + // Žádosti o zprávu se nevolá — hovor by prozradil, že jsem u telefonu. + if (contact != null && !contact.blocked && contact.accepted) ...[ IconButton( tooltip: context.l10n.callVoice, icon: const Icon(Icons.call_outlined, @@ -823,6 +835,27 @@ class _ConversationScreenState extends ConsumerState ? null : messages.where((m) => m.id == msg.replyToId).firstOrNull; + final notice = msg.notice; + if (notice != null) { + return KeyedSubtree( + key: _keys.putIfAbsent(msg.id, GlobalKey.new), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showDate) _buildDateDivider(msg.timestamp), + SafetyNoticeRow( + notice: notice, + contactName: autorJmeno(widget.contactId), + claimedName: msg.content, + onVerify: group == null + ? () => context.push('/verify/${widget.contactId}') + : null, + ), + ], + ), + ); + } + final callLog = msg.callLog; if (callLog != null) { return KeyedSubtree( @@ -1745,9 +1778,7 @@ class _ConversationScreenState extends ConsumerState String jmeno(String f) { final c = contacts.where((c) => c.id == f).firstOrNull; if (c == null) return 'SEP-${f.substring(0, f.length.clamp(0, 10))}'; - return c.displayName.isEmpty - ? context.l10n.commonUnknownContact - : c.displayName; + return contactName(context.l10n, c); } Widget radek(IconData icon, String label, DateTime? t, {Color? color}) { diff --git a/app/lib/features/conversation/forward_sheet.dart b/app/lib/features/conversation/forward_sheet.dart index edb2fc2..648e24e 100644 --- a/app/lib/features/conversation/forward_sheet.dart +++ b/app/lib/features/conversation/forward_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sepot/core/l10n/contact_name.dart'; import 'package:sepot/core/constants/app_constants.dart'; import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/l10n.dart'; @@ -37,8 +38,8 @@ Future forwardMessage( for (final g in groups) if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true), for (final c in contacts) - if (!c.blocked && c.id != fromId) - _Target(c.id, c.displayName, verified: c.verified), + if (!c.blocked && c.accepted && c.id != fromId) + _Target(c.id, contactName(context.l10n, c), verified: c.verified), ]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); // Poznámky nahoře: přeposlat si něco „na později“ je nejčastější případ. if (!ConversationsNotifier.isNotes(fromId)) { diff --git a/app/lib/features/conversation/image_viewer_screen.dart b/app/lib/features/conversation/image_viewer_screen.dart index b15f852..cd14a48 100644 --- a/app/lib/features/conversation/image_viewer_screen.dart +++ b/app/lib/features/conversation/image_viewer_screen.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; +import 'package:sepot/core/l10n/contact_name.dart'; import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/models/attachment.dart'; @@ -103,9 +104,9 @@ class _ImageViewerScreenState extends ConsumerState { final contact = (ref.read(contactsProvider).valueOrNull ?? const []) .where((c) => c.publicIdentity.id == m.senderId) .firstOrNull; - return contact?.displayName.isNotEmpty == true - ? contact!.displayName - : context.l10n.commonUnknownContact; + return contact == null + ? context.l10n.commonUnknownContact + : contactName(context.l10n, contact); } @override diff --git a/app/lib/features/conversation/message_request_panel.dart b/app/lib/features/conversation/message_request_panel.dart new file mode 100644 index 0000000..534e471 --- /dev/null +++ b/app/lib/features/conversation/message_request_panel.dart @@ -0,0 +1,193 @@ +/// Žádost o zprávu: místo psacího pole u odesílatele, kterého jsem si +/// nepřidal. Jméno i fotku si zvolil sám, takže dokud ho nepřijmu, nesmí +/// vypadat jako můj kontakt a nesmí ode mě dostat odpověď omylem. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sepot/core/l10n/l10n.dart'; +import 'package:sepot/core/safety/lookalike_names.dart'; +import 'package:sepot/core/services/messaging_service.dart'; +import 'package:sepot/core/theme/app_theme.dart'; +import 'package:sepot/features/conversation/chat_actions.dart'; +import 'package:sepot/models/contact.dart'; +import 'package:sepot/providers/contacts_provider.dart'; + +class MessageRequestPanel extends ConsumerWidget { + const MessageRequestPanel({super.key, required this.contact}); + + final Contact contact; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final contacts = ref.watch(contactsProvider).valueOrNull ?? const []; + final claimed = contact.displayName.isEmpty + ? l10n.commonUnknownContact + : contact.displayName; + final lookalike = + lookalikeContact(contact.displayName, contacts, exceptId: contact.id); + + return SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(10, 6, 10, 10), + child: Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 10), + decoration: BoxDecoration( + color: SepotColors.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: lookalike != null ? SepotColors.error : SepotColors.warning, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + lookalike != null + ? Icons.gpp_maybe_rounded + : Icons.person_search_rounded, + size: 20, + color: lookalike != null + ? SepotColors.error + : SepotColors.warning, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.requestTitle, + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.w700), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + l10n.requestBody(claimed), + style: const TextStyle( + fontSize: 13, color: SepotColors.textSecondary), + ), + if (lookalike != null) ...[ + const SizedBox(height: 8), + Text( + l10n.requestLookalikeWarning(lookalike.displayName), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: SepotColors.error), + ), + ], + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => + setContactBlocked(context, ref, contact, true), + child: Text(l10n.chatMenuBlockContact, + style: const TextStyle(color: SepotColors.error)), + ), + const SizedBox(width: 4), + FilledButton( + onPressed: () => acceptMessageRequest(context, ref, contact), + child: Text(l10n.requestAccept), + ), + ], + ), + ], + ), + ), + ); + } +} + +/// Přijetí = uživatel kontakt pojmenuje sám. Jméno, které se plete +/// s jiným přijatým kontaktem, neprojde: dvě „Mámy“ v seznamu by +/// podvrh jen schovaly. +Future acceptMessageRequest( + BuildContext context, WidgetRef ref, Contact contact) async { + final contacts = ref.read(contactsProvider).valueOrNull ?? const []; + final controller = TextEditingController( + text: lookalikeContact(contact.displayName, contacts, + exceptId: contact.id) == + null + ? contact.displayName + : '', + ); + final name = await showDialog( + context: context, + builder: (ctx) => _AcceptDialog( + controller: controller, + isTaken: (n) => + lookalikeContact(n, contacts, exceptId: contact.id) != null, + ), + ); + controller.dispose(); + if (name == null) return; + await ref.read(contactsProvider.notifier).accept(contact.id, name: name); +} + +class _AcceptDialog extends StatefulWidget { + const _AcceptDialog({required this.controller, required this.isTaken}); + + final TextEditingController controller; + final bool Function(String name) isTaken; + + @override + State<_AcceptDialog> createState() => _AcceptDialogState(); +} + +class _AcceptDialogState extends State<_AcceptDialog> { + String? _error; + + void _submit() { + final l10n = context.l10n; + final name = widget.controller.text.trim(); + if (name.isEmpty) { + setState(() => _error = l10n.requestNameEmpty); + return; + } + if (widget.isTaken(name)) { + setState(() => _error = l10n.requestNameTaken); + return; + } + Navigator.of(context).pop(name); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AlertDialog( + title: Text(l10n.requestAcceptTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.requestAcceptBody, style: const TextStyle(fontSize: 13)), + const SizedBox(height: 12), + TextField( + controller: widget.controller, + autofocus: true, + maxLength: MessagingService.maxDisplayNameLength, + decoration: InputDecoration( + labelText: l10n.requestAcceptNameLabel, + errorText: _error, + ), + onSubmitted: (_) => _submit(), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.commonCancel), + ), + FilledButton(onPressed: _submit, child: Text(l10n.requestAccept)), + ], + ); + } +} diff --git a/app/lib/features/conversation/widgets/safety_notice_row.dart b/app/lib/features/conversation/widgets/safety_notice_row.dart new file mode 100644 index 0000000..c687524 --- /dev/null +++ b/app/lib/features/conversation/widgets/safety_notice_row.dart @@ -0,0 +1,78 @@ +/// Bezpečnostní upozornění uprostřed konverzace. Vypadá jinak než bublina +/// (nikdo ho neposlal), aby nešlo zaměnit se zprávou protistrany. +library; + +import 'package:flutter/material.dart'; +import 'package:sepot/core/l10n/l10n.dart'; +import 'package:sepot/core/theme/app_theme.dart'; +import 'package:sepot/models/message.dart'; + +class SafetyNoticeRow extends StatelessWidget { + const SafetyNoticeRow({ + super.key, + required this.notice, + required this.contactName, + this.claimedName, + this.onVerify, + }); + + final MessageNotice notice; + final String contactName; + + /// Jméno, které si zvolil neznámý odesílatel ([MessageNotice.lookalikeRequest]). + final String? claimedName; + + /// Odkaz na porovnání bezpečnostního kódu (null = bez tlačítka). + final VoidCallback? onVerify; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final text = switch (notice) { + MessageNotice.identityChanged => l10n.noticeIdentityChanged(contactName), + MessageNotice.lookalikeRequest => + l10n.noticeLookalikeRequest(claimedName ?? '', contactName), + }; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Container( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 6), + decoration: BoxDecoration( + color: SepotColors.surfaceVariant, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: SepotColors.warning), + ), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.gpp_maybe_rounded, + size: 18, color: SepotColors.warning), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: const TextStyle( + fontSize: 13, color: SepotColors.textPrimary), + ), + ), + ], + ), + if (onVerify != null && + notice == MessageNotice.identityChanged) + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: onVerify, + child: Text(l10n.noticeVerifyAction), + ), + ) + else + const SizedBox(height: 6), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/groups/create_group_screen.dart b/app/lib/features/groups/create_group_screen.dart index df4b4c4..c20705e 100644 --- a/app/lib/features/groups/create_group_screen.dart +++ b/app/lib/features/groups/create_group_screen.dart @@ -81,7 +81,9 @@ class _CreateGroupScreenState extends ConsumerState { : null; final contactsAsync = ref.watch(contactsProvider); final contacts = (contactsAsync.valueOrNull ?? []) - .where((c) => !c.blocked) + // Do skupiny jen lidi, které jsem přijal — žádost o zprávu by + // jinak dostala roster všech členů pod jménem, které si vymyslela. + .where((c) => !c.blocked && c.accepted) .where((c) => existing == null || !existing.memberIds.contains(c.id)) .toList(); // Já jsem v memberIds existující skupiny; nová skupina mě přidá sama. diff --git a/app/lib/models/contact.dart b/app/lib/models/contact.dart index 32f4ccc..e0ea44e 100644 --- a/app/lib/models/contact.dart +++ b/app/lib/models/contact.dart @@ -18,6 +18,13 @@ class Contact { /// Profilová fotka, kterou kontakt sdílí (null = iniciály). AvatarRef? avatar; + /// Přijatý kontakt. False = žádost o zprávu: napsal mi někdo, koho jsem + /// si sám nepřidal (nebo jsem ho potkal jen ve skupině), takže jméno + /// i fotku si zvolil on. Dokud ho nepřijmu, appka ho značí jako + /// neznámého, neukazuje jeho fotku, nezvoní jeho hovory a nedává mu + /// vědět, že čtu nebo píšu. + bool accepted; + Contact({ required this.displayName, required this.publicIdentity, @@ -26,6 +33,7 @@ class Contact { this.notes, this.blocked = false, this.avatar, + this.accepted = true, }); /// ID kontaktu = fingerprint identity klíče (identita = klíč). @@ -41,12 +49,14 @@ class Contact { factory Contact.fromPublicIdentity( PublicIdentity identity, { bool verified = false, + bool accepted = true, }) { return Contact( displayName: identity.displayName, publicIdentity: identity, addedAt: DateTime.now(), verified: verified, + accepted: accepted, ); } @@ -64,6 +74,7 @@ class Contact { 'notes': notes, 'blocked': blocked, 'avatar': avatar?.toJson(), + 'accepted': accepted, }; factory Contact.fromJson(Map json) { @@ -77,6 +88,9 @@ class Contact { notes: json['notes'] as String?, blocked: json['blocked'] as bool? ?? false, avatar: AvatarRef.fromJson(json['avatar']), + // Kontakty z doby před žádostmi o zprávu uživatel už léta používá — + // zpětně je vydávat za neznámé by jen naučilo varování odklikávat. + accepted: json['accepted'] as bool? ?? true, ); } } diff --git a/app/lib/models/message.dart b/app/lib/models/message.dart index de941c9..a6c8947 100644 --- a/app/lib/models/message.dart +++ b/app/lib/models/message.dart @@ -4,6 +4,18 @@ import 'package:sepot/models/group.dart'; import 'package:sepot/models/poll.dart'; import 'package:uuid/uuid.dart'; +/// Bezpečnostní upozornění, které appka sama vloží do konverzace. +/// Nikdy nejde po drátě — protistrana ho podvrhnout nemůže. +enum MessageNotice { + /// Pod adresou tohohle kontaktu se ozval jiný identity klíč (relay + /// hlásí jiný klíč, nebo relace patří cizímu klíči). Ověření se ruší. + identityChanged, + + /// Neznámý odesílatel si zvolil jméno, které se plete s tímhle + /// kontaktem. [Message.content] nese jméno, které si zvolil. + lookalikeRequest, +} + /// Typy zpráv — odpovídá Rust: MessageType enum MessageType { text, @@ -62,6 +74,9 @@ class Message { /// Lokální záznam hovoru — taková zpráva nemá text ani přílohu. final CallLog? callLog; + /// Bezpečnostní upozornění appky (není to zpráva od nikoho). + final MessageNotice? notice; + const Message({ required this.id, required this.senderId, @@ -85,8 +100,30 @@ class Message { this.poll, this.mentions = const [], this.callLog, + this.notice, }); + /// Upozornění appky v konverzaci [contactId]. Odesílatel = kontakt, aby + /// se konverzace v seznamu ukázala jako nepřečtená a upozornění si + /// uživatel nepřehlédl. + factory Message.notice({ + required String contactId, + required String myId, + required MessageNotice notice, + String? content, + }) { + return Message( + id: const Uuid().v4(), + senderId: contactId, + recipientId: myId, + type: MessageType.text, + content: content, + timestamp: DateTime.now(), + isDelivered: true, + notice: notice, + ); + } + /// Vytvoř textovou zprávu factory Message.text({ required String senderId, @@ -191,6 +228,7 @@ class Message { poll: poll ?? this.poll, mentions: mentions, callLog: callLog, + notice: notice, ); } @@ -231,6 +269,7 @@ class Message { 'poll': poll?.toJson(), if (mentions.isNotEmpty) 'mentions': mentions, if (callLog != null) 'call': callLog!.toJson(), + if (notice != null) 'notice': notice!.name, }; static Map _timesToJson(Map times) => @@ -282,6 +321,7 @@ class Message { poll: Poll.fromJson(json['poll']), mentions: mentionsFromWire(json['mentions']), callLog: CallLog.fromJson(json['call']), + notice: MessageNotice.values.asNameMap()[json['notice']], ); } } diff --git a/app/lib/providers/contacts_provider.dart b/app/lib/providers/contacts_provider.dart index 5b55c86..a4eba80 100644 --- a/app/lib/providers/contacts_provider.dart +++ b/app/lib/providers/contacts_provider.dart @@ -26,9 +26,19 @@ class ContactsNotifier extends AsyncNotifier> { 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> { 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 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 removeContact(String contactId) async { final storage = ref.read(storageProvider); diff --git a/app/lib/providers/conversations_provider.dart b/app/lib/providers/conversations_provider.dart index fc4f96b..da191ee 100644 --- a/app/lib/providers/conversations_provider.dart +++ b/app/lib/providers/conversations_provider.dart @@ -341,8 +341,10 @@ class ConversationsNotifier extends AsyncNotifier> { // Příchozí zprávy, které jsem právě přečetl → pošlu za ně potvrzení // přečtení (sendReadReceipts sám zkontroluje, jestli je funkce zapnutá). + // Upozornění appky (notice) nikdo neposlal — potvrzovat ho nelze. final justRead = conv.messages - .where((m) => !m.isMine(identity.id) && !m.isRead) + .where((m) => + !m.isMine(identity.id) && !m.isRead && m.notice == null) .toList(); conv.markAllRead(identity.id); diff --git a/app/lib/providers/messaging_provider.dart b/app/lib/providers/messaging_provider.dart index d3ac43a..2c5d0a4 100644 --- a/app/lib/providers/messaging_provider.dart +++ b/app/lib/providers/messaging_provider.dart @@ -35,9 +35,14 @@ final avatarProvider = final me = ref.watch(identityProvider).valueOrNull; final contacts = ref.watch(contactsProvider).valueOrNull ?? const []; if (!service.isStarted) return null; + final contact = contacts.where((c) => c.id == fingerprint).firstOrNull; + // Fotka neznámého odesílatele může být fotka mámy z Facebooku — do + // přijetí jen iniciály na tónu z klíče, ne jeho obrázek. final avatar = fingerprint == me?.id ? service.myAvatar - : contacts.where((c) => c.id == fingerprint).firstOrNull?.avatar; + : contact != null && contact.accepted + ? contact.avatar + : null; return avatar == null ? null : service.loadAvatar(avatar); }); @@ -83,7 +88,15 @@ final messagingStartProvider = FutureProvider((ref) async { final conv = await ref.read(storageProvider).loadConversation(convId); final mentionedMe = message.mentions.contains(identity.id); if (conv?.isMuted == true && !mentionedMe) return; - PushNotifier.showMessageNotification(contact.displayName); + // Jméno, které si neznámý zvolil sám, nepatří do notifikace — + // „Nová zpráva od Máma“ by byl phishing ještě před otevřením appky. + if (contact.accepted) { + PushNotifier.showMessageNotification(contact.displayName); + } else if (convId != contact.id) { + PushNotifier.showWakeNotification(); + } else { + PushNotifier.showMessageRequestNotification(); + } }; service.onCallSignal = (contact, signal) => diff --git a/app/test/lookalike_names_test.dart b/app/test/lookalike_names_test.dart new file mode 100644 index 0000000..675774b --- /dev/null +++ b/app/test/lookalike_names_test.dart @@ -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'); + }); +} diff --git a/app/test/message_request_test.dart b/app/test/message_request_test.dart new file mode 100644 index 0000000..48c50ae --- /dev/null +++ b/app/test/message_request_test.dart @@ -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)>[]; + final calls = []; + + 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 deliver( + LocalIdentity from, + String account, + Map content, + ) => + service.debugProcessOpened( + SepotSealedMessage( + plaintext: utf8.encode(jsonEncode({'v': 1, ...content})), + senderName: account, + senderDeviceId: 1, + senderIdentityKey: from.publicIdentity.identityKey, + ), + randomBytes(16), + ); + + Map text(String name, String txt) => { + 't': 'txt', + 'id': 'm-${rng.nextInt(1 << 30)}', + 'txt': txt, + 'p': {'n': name, 'da': base64Encode(randomBytes(32))}, + }; + + Future 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 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); + }); + }); +}