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:
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
Reference in New Issue
Block a user