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