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
@@ -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),
],
),
),
);
}
}