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
@@ -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);
+11 -1
View File
@@ -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 => (
@@ -95,7 +95,9 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
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);
@@ -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;
@@ -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<ConversationScreen>
// 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<ConversationScreen>
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<ConversationScreen>
}
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<ConversationScreen>
// 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<ConversationScreen>
? 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<ConversationScreen>
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}) {
@@ -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<void> 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)) {
@@ -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<ImageViewerScreen> {
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
@@ -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<void> 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<String>(
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)),
],
);
}
}
@@ -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),
],
),
),
);
}
}
@@ -81,7 +81,9 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
: 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.