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
+12
View File
@@ -331,6 +331,18 @@ class SepotCrypto {
Future<bool> hasSession(ProtocolAddress remote) => Future<bool> hasSession(ProtocolAddress remote) =>
_stores.session.containsSession(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<Uint8List?> 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<void> dropSession(ProtocolAddress remote) =>
_stores.session.deleteSession(remote);
/// X3DH/PQXDH: zpracuje bundle protistrany a založí session. /// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
Future<void> processRemoteBundle( Future<void> processRemoteBundle(
ProtocolAddress remote, ProtocolAddress remote,
+12
View File
@@ -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);
}
+13
View File
@@ -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<void> 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 → /// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock →
/// WS připojení → fronta se vyleje normální cestou). /// WS připojení → fronta se vyleje normální cestou).
static Future<void> showWakeNotification() async { static Future<void> showWakeNotification() async {
+64
View File
@@ -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<Contact> 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;
}
+122 -20
View File
@@ -36,6 +36,7 @@ import 'package:sepot/core/errors.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart'; import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/network/relay_client.dart'; import 'package:sepot/core/network/relay_client.dart';
import 'package:sepot/core/network/relay_transport.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/group_admin_policy.dart';
import 'package:sepot/core/services/image_processor.dart'; import 'package:sepot/core/services/image_processor.dart';
import 'package:sepot/core/services/typing_tracker.dart'; import 'package:sepot/core/services/typing_tracker.dart';
@@ -218,7 +219,7 @@ class MessagingService {
final targets = contacts.where((c) => final targets = contacts.where((c) =>
!c.blocked && !c.blocked &&
(group == null (group == null
? c.id == convId ? c.id == convId && c.accepted
: group.memberIds.contains(c.id) && c.id != _identity?.id)); : group.memberIds.contains(c.id) && c.id != _identity?.id));
for (final c in targets) { for (final c in targets) {
final until = _typingBackoffUntil[c.id]; final until = _typingBackoffUntil[c.id];
@@ -993,6 +994,8 @@ class MessagingService {
Future<void> sendReadReceipts(Contact contact, List<String> messageIds, Future<void> sendReadReceipts(Contact contact, List<String> messageIds,
{String? gid}) async { {String? gid}) async {
if (messageIds.isEmpty) return; 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 = final enabled =
(await storage.getSetting<bool>('read_receipts_enabled')) ?? false; (await storage.getSetting<bool>('read_receipts_enabled')) ?? false;
if (!enabled) return; if (!enabled) return;
@@ -1948,19 +1951,26 @@ class MessagingService {
verified: known.verified, verified: known.verified,
notes: known.notes, notes: known.notes,
blocked: known.blocked, blocked: known.blocked,
avatar: known.avatar,
accepted: known.accepted,
)); ));
} }
continue; continue;
} }
final name = (e['n'] as String?)?.trim(); final name = (e['n'] as String?)?.trim();
await storage.saveContact(Contact.fromPublicIdentity(PublicIdentity( // Č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 // Prázdné jméno = UI si doplní lokalizovaný fallback
displayName: (name == null || name.isEmpty) ? '' : name, displayName: (name == null || name.isEmpty) ? '' : clipName(name),
identityKey: base64Decode(e['ik'] as String), identityKey: base64Decode(e['ik'] as String),
createdAt: DateTime.now(), createdAt: DateTime.now(),
accountId: aid, accountId: aid,
deliveryAddress: daB64 == null ? null : base64Decode(daB64), 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 // 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í. // nedrží — čekání na receipt by blokovalo všechny ostatní.
final sealed = await _serial<Uint8List?>(() async { final sealed = await _serial<Uint8List?>(() async {
await _dropForeignSession(crypto, peerAddr, contact);
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět // 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í). // s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
if (!await crypto.hasSession(peerAddr)) { if (!await crypto.hasSession(peerAddr)) {
if (ephemeral) return null; if (ephemeral) return null;
final bundle = await relay.fetchBundle(peerDa); final bundle = await relay.fetchBundle(peerDa);
if (!_listEquals(bundle.identityKey, peer.identityKey)) { if (!_listEquals(bundle.identityKey, peer.identityKey)) {
await _noteIdentityChanged(contact);
throw const IdentityKeyMismatchError(); throw const IdentityKeyMismatchError();
} }
await crypto.processRemoteBundle(peerAddr, bundle); await crypto.processRemoteBundle(peerAddr, bundle);
@@ -2245,6 +2257,29 @@ class MessagingService {
return receipt.status; 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<void> _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<void> debugDropForeignSession(
SepotCrypto crypto, ProtocolAddress peerAddr, Contact contact) =>
_dropForeignSession(crypto, peerAddr, contact);
// ── Příjem ────────────────────────────────────────────────────────── // ── Příjem ──────────────────────────────────────────────────────────
/// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako /// 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). // (může jít o dočasný stav — např. cert refresh).
return; return;
} }
await _processOpened(opened, envelope.msgUuid);
}
@visibleForTesting
Future<void> debugProcessOpened(
SepotSealedMessage opened, List<int> msgUuid) =>
_processOpened(opened, msgUuid);
/// Zpracuje už dešifrovanou obálku: kontakt, typ obsahu, uložení, ACK.
Future<void> _processOpened(
SepotSealedMessage opened, List<int> msgUuid) async {
// Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu // Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu
// nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se // nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se
// proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by // proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by
@@ -2292,7 +2337,7 @@ class MessagingService {
if (content['v'] != 1 || !known.contains(type)) { if (content['v'] != 1 || !known.contains(type)) {
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný // 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. // a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
_relay?.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
@@ -2302,15 +2347,19 @@ class MessagingService {
// Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje), // Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje),
// nic neukládej ani nezobrazuj a neposílej doručenku. // nic neukládej ani nezobrazuj a neposílej doručenku.
if (contact.blocked) { if (contact.blocked) {
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
// Hovory jen 1:1; skupinový gid tu nemá co dělat. // 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. // 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 (type == 'call') {
if (content['gid'] == null) onCallSignal?.call(contact, content); if (content['gid'] == null && contact.accepted) {
_relay!.ack(envelope.msgUuid); onCallSignal?.call(contact, content);
}
_relay?.ack(msgUuid);
return; return;
} }
@@ -2320,7 +2369,7 @@ class MessagingService {
// nepomůže). // nepomůže).
if (_groupControlTypes.contains(type)) { if (_groupControlTypes.contains(type)) {
await _handleGroupControl(senderFp, type, content); await _handleGroupControl(senderFp, type, content);
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
@@ -2330,7 +2379,7 @@ class MessagingService {
final group = final group =
gid == null ? null : await _groupForIncoming(gid, senderFp); gid == null ? null : await _groupForIncoming(gid, senderFp);
if (gid != null && group == null) { if (gid != null && group == null) {
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
if (group != null && _noteCapability(group, senderFp, content)) { if (group != null && _noteCapability(group, senderFp, content)) {
@@ -2345,7 +2394,7 @@ class MessagingService {
final fresh = DateTime.now().difference(wireSentAt(content['ts'])) < final fresh = DateTime.now().difference(wireSentAt(content['ts'])) <
TypingTracker.showFor; TypingTracker.showFor;
if (fresh && await _typingEnabled()) typing.mark(convId, senderFp); if (fresh && await _typingEnabled()) typing.mark(convId, senderFp);
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
@@ -2380,7 +2429,7 @@ class MessagingService {
); );
} }
} }
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
onUpdate?.call(); onUpdate?.call();
return; return;
} }
@@ -2389,7 +2438,7 @@ class MessagingService {
// novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže). // novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže).
if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') { if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') {
await _applyIncomingControl(convId, type!, content, opened); await _applyIncomingControl(convId, type!, content, opened);
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
onUpdate?.call(); onUpdate?.call();
return; return;
} }
@@ -2407,7 +2456,7 @@ class MessagingService {
if (raw is! String) { if (raw is! String) {
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty // Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh. // nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
// Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden // Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden
@@ -2441,7 +2490,7 @@ class MessagingService {
} }
} on Object { } on Object {
// Trvale vadná metadata — retry z fronty nepomůže, ACKnout // Trvale vadná metadata — retry z fronty nepomůže, ACKnout
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
message = Message( message = Message(
@@ -2466,7 +2515,7 @@ class MessagingService {
// Uloženo na disku → teprve teď smí relay obálku zahodit. // Uloženo na disku → teprve teď smí relay obálku zahodit.
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při // (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á.) // 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í). // 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 // Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale
@@ -2499,11 +2548,11 @@ class MessagingService {
} on FormatException { } on FormatException {
// Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64 // Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64
// v profilu či rosteru) — trvale vadné, ACK a zahodit. // v profilu či rosteru) — trvale vadné, ACK a zahodit.
_relay?.ack(envelope.msgUuid); _relay?.ack(msgUuid);
} on TypeError { } on TypeError {
// Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) — // Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) —
// trvale vadné, ACK a zahodit. // trvale vadné, ACK a zahodit.
_relay?.ack(envelope.msgUuid); _relay?.ack(msgUuid);
} catch (_) { } catch (_) {
// Přechodné selhání zpracování (storage apod.): NEACKovat — další // Přechodné selhání zpracování (storage apod.): NEACKovat — další
// pokus po reconnectu za nic nedá, i když duplicitní decrypt // 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. // Bez tohohle se zablokovaný kontakt při změně adresy odblokoval.
blocked: c.blocked, blocked: c.blocked,
avatar: avatarChanged ? avatar : c.avatar, avatar: avatarChanged ? avatar : c.avatar,
accepted: c.accepted,
); );
await storage.saveContact(updated); await storage.saveContact(updated);
if (avatarChanged) { if (avatarChanged) {
@@ -2743,7 +2793,9 @@ class MessagingService {
return updated; 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 // Prázdné jméno = UI si doplní lokalizovaný fallback
final name = (profile?['n'] as String?)?.trim(); final name = (profile?['n'] as String?)?.trim();
final identity = PublicIdentity( final identity = PublicIdentity(
@@ -2753,12 +2805,62 @@ class MessagingService {
accountId: opened.senderName, accountId: opened.senderName,
deliveryAddress: profileDa == null ? null : base64Decode(profileDa), 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); await storage.saveContact(contact);
if (avatar != null) unawaited(_downloadAvatar(avatar)); 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; 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<void> _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<void> _noteLookalike(Contact contact, String claimedName) async {
if (await storage.loadConversation(contact.id) == null) return;
await _addNotice(contact, MessageNotice.lookalikeRequest,
content: claimedName);
}
Future<void> _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 ────────────────────────────────────── // ── Sdílení vlastního kontaktu ──────────────────────────────────────
/// Veřejná identita včetně routingu — tohle patří do QR/odkazu. /// Veřejná identita včetně routingu — tohle patří do QR/odkazu.
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
@@ -45,13 +46,15 @@ class ArchivedChatsScreen extends ConsumerWidget {
?.where((c) => c.publicIdentity.id == conv.contactId) ?.where((c) => c.publicIdentity.id == conv.contactId)
.firstOrNull; .firstOrNull;
final name = group?.name ?? final name = group?.name ??
contact?.displayName ?? (contact == null
context.l10n.commonUnknownContact; ? context.l10n.commonUnknownContact
: contactName(context.l10n, contact));
return ChatListTile( return ChatListTile(
conversation: conv, conversation: conv,
contactName: name, contactName: name,
myId: myId, myId: myId,
isGroup: group != null, isGroup: group != null,
isRequest: group == null && contact?.accepted == false,
onTap: () => context.push('/conversation/${conv.contactId}'), onTap: () => context.push('/conversation/${conv.contactId}'),
onLongPress: () => _actions(context, ref, conv), onLongPress: () => _actions(context, ref, conv),
); );
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.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) String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId)
? l10n.notesTitle ? l10n.notesTitle
: groupOf(c.contactId)?.name ?? : groupOf(c.contactId)?.name ??
contactOf(c.contactId)?.displayName ?? _nameOrNull(l10n, contactOf(c.contactId)) ??
l10n.commonUnknownContact; l10n.commonUnknownContact;
return SafeArea( return SafeArea(
@@ -265,7 +266,8 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
? l10n.chatTyping ? l10n.chatTyping
: writers.length == 1 : writers.length == 1
? l10n.chatTypingNamed( ? l10n.chatTypingNamed(
(contactOf(writers.first)?.displayName ?? '?') (_nameOrNull(l10n, contactOf(writers.first)) ??
'?')
.split(' ') .split(' ')
.first) .first)
: l10n.chatTypingSeveral; : l10n.chatTypingSeveral;
@@ -275,8 +277,10 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
myId: myId, myId: myId,
isGroup: group != null, isGroup: group != null,
verified: contactOf(c.contactId)?.verified ?? false, verified: contactOf(c.contactId)?.verified ?? false,
isRequest: group == null &&
contactOf(c.contactId)?.accepted == false,
lastAuthor: group != null && last != null lastAuthor: group != null && last != null
? contactOf(last.senderId)?.displayName ? _nameOrNull(l10n, contactOf(last.senderId))
: null, : null,
highlight: hits[c], highlight: hits[c],
typingLabel: typingLabel, 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 isGroup;
final bool verified; 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ý). /// Jméno autora poslední zprávy ve skupině (null = 1:1 nebo neznámý).
final String? lastAuthor; final String? lastAuthor;
@@ -40,6 +44,7 @@ class ChatListTile extends StatelessWidget {
required this.myId, required this.myId,
this.isGroup = false, this.isGroup = false,
this.verified = false, this.verified = false,
this.isRequest = false,
this.lastAuthor, this.lastAuthor,
this.highlight, this.highlight,
this.typingLabel, this.typingLabel,
@@ -54,7 +59,9 @@ class ChatListTile extends StatelessWidget {
final unread = conversation.unreadCount(myId); final unread = conversation.unreadCount(myId);
final muted = conversation.isMuted; final muted = conversation.isMuted;
final mine = lastMsg != null && lastMsg.isMine(myId); 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( return InkWell(
onTap: onTap, onTap: onTap,
@@ -245,6 +252,9 @@ class ChatListTile extends StatelessWidget {
final label = callLogLabel(l10n, call); final label = callLogLabel(l10n, call);
return (icon: label.icon, text: label.text); return (icon: label.icon, text: label.text);
} }
if (msg.notice != null) {
return (icon: Icons.gpp_maybe_rounded, text: l10n.noticePreview);
}
return switch (msg.type) { return switch (msg.type) {
MessageType.text => (icon: null, text: msg.content ?? ''), MessageType.text => (icon: null, text: msg.content ?? ''),
MessageType.image => ( MessageType.image => (
@@ -95,7 +95,9 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
Expanded( Expanded(
child: contactsAsync.when( child: contactsAsync.when(
data: (contacts) { data: (contacts) {
// Žádosti o zprávu kontakty nejsou — žijí jen v seznamu chatů.
final filtered = contacts.where((c) { final filtered = contacts.where((c) {
if (!c.accepted) return false;
if (_searchQuery.isEmpty) return true; if (_searchQuery.isEmpty) return true;
return c.displayName.toLowerCase().contains(_searchQuery) || return c.displayName.toLowerCase().contains(_searchQuery) ||
c.sepotId.toLowerCase().contains(_searchQuery); c.sepotId.toLowerCase().contains(_searchQuery);
@@ -5,6 +5,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.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'; import 'package:sepot/providers/storage_provider.dart';
String contactLabel(BuildContext context, Contact contact) => String contactLabel(BuildContext context, Contact contact) =>
contact.displayName.isEmpty contactName(context.l10n, contact);
? context.l10n.commonUnknownContact
: contact.displayName;
void _snack(BuildContext context, String text) { void _snack(BuildContext context, String text) {
if (!context.mounted) return; if (!context.mounted) return;
@@ -12,6 +12,7 @@ import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:intl/intl.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/calls/call_controller.dart';
import 'package:sepot/core/constants/app_constants.dart'; import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/l10n/error_messages.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/core/utils/text_fold.dart';
import 'package:sepot/features/calls/call_log_text.dart'; import 'package:sepot/features/calls/call_log_text.dart';
import 'package:sepot/features/conversation/chat_actions.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/forward_sheet.dart';
import 'package:sepot/features/conversation/widgets/attachment_content.dart'; import 'package:sepot/features/conversation/widgets/attachment_content.dart';
import 'package:sepot/features/conversation/widgets/create_poll_sheet.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. // v kontaktech (roster je zakládá až s první zprávou), zkrátíme na id.
String autorJmeno(String fingerprint) { String autorJmeno(String fingerprint) {
if (fingerprint == identity?.id) return context.l10n.groupsYou; if (fingerprint == identity?.id) return context.l10n.groupsYou;
final jmeno = contacts final autor = contacts
.where((c) => c.publicIdentity.id == fingerprint) .where((c) => c.publicIdentity.id == fingerprint)
.firstOrNull .firstOrNull;
?.displayName; if (autor == null) {
if (jmeno == null) {
return 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}'; 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; final Widget bottom;
@@ -293,6 +295,8 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
child: Text(context.l10n.chatUnblock), child: Text(context.l10n.chatUnblock),
), ),
); );
} else if (group == null && contact != null && !contact.accepted) {
bottom = MessageRequestPanel(contact: contact);
} else { } else {
bottom = _buildComposer(); bottom = _buildComposer();
} }
@@ -472,9 +476,9 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
} }
Widget _buildContactHeader(Contact? contact, {bool typing = false}) { Widget _buildContactHeader(Contact? contact, {bool typing = false}) {
final name = (contact == null || contact.displayName.isEmpty) final name = contact == null
? context.l10n.commonUnknownContact ? context.l10n.commonUnknownContact
: contact.displayName; : contactName(context.l10n, contact);
final verified = contact?.verified ?? false; final verified = contact?.verified ?? false;
return _buildHeaderFrame(children: [ return _buildHeaderFrame(children: [
_backButton(), _backButton(),
@@ -485,19 +489,27 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
// Stav ověření místo syrového ID (UX-FLOWS §3.1) // Stav ověření místo syrového ID (UX-FLOWS §3.1)
subtitle: typing subtitle: typing
? _typingLabel(context.l10n.chatTyping) ? _typingLabel(context.l10n.chatTyping)
: contact != null && !contact.accepted
? Text(
context.l10n.requestTitle,
style: const TextStyle(
fontSize: 12, color: SepotColors.warning),
)
: Text( : Text(
verified verified
? context.l10n.verifyVerified ? context.l10n.verifyVerified
: context.l10n.verifyUnverified, : context.l10n.verifyUnverified,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: color: verified
verified ? SepotColors.sage : SepotColors.textSecondary, ? SepotColors.sage
: SepotColors.textSecondary,
), ),
), ),
onTap: () => _openProfile('/contact/${widget.contactId}'), 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( IconButton(
tooltip: context.l10n.callVoice, tooltip: context.l10n.callVoice,
icon: const Icon(Icons.call_outlined, icon: const Icon(Icons.call_outlined,
@@ -823,6 +835,27 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
? null ? null
: messages.where((m) => m.id == msg.replyToId).firstOrNull; : 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; final callLog = msg.callLog;
if (callLog != null) { if (callLog != null) {
return KeyedSubtree( return KeyedSubtree(
@@ -1745,9 +1778,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
String jmeno(String f) { String jmeno(String f) {
final c = contacts.where((c) => c.id == f).firstOrNull; final c = contacts.where((c) => c.id == f).firstOrNull;
if (c == null) return 'SEP-${f.substring(0, f.length.clamp(0, 10))}'; if (c == null) return 'SEP-${f.substring(0, f.length.clamp(0, 10))}';
return c.displayName.isEmpty return contactName(context.l10n, c);
? context.l10n.commonUnknownContact
: c.displayName;
} }
Widget radek(IconData icon, String label, DateTime? t, {Color? color}) { Widget radek(IconData icon, String label, DateTime? t, {Color? color}) {
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/constants/app_constants.dart';
import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
@@ -37,8 +38,8 @@ Future<void> forwardMessage(
for (final g in groups) for (final g in groups)
if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true), if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true),
for (final c in contacts) for (final c in contacts)
if (!c.blocked && c.id != fromId) if (!c.blocked && c.accepted && c.id != fromId)
_Target(c.id, c.displayName, verified: c.verified), _Target(c.id, contactName(context.l10n, c), verified: c.verified),
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); ]..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. // Poznámky nahoře: přeposlat si něco „na později“ je nejčastější případ.
if (!ConversationsNotifier.isNotes(fromId)) { if (!ConversationsNotifier.isNotes(fromId)) {
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.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/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/attachment.dart'; import 'package:sepot/models/attachment.dart';
@@ -103,9 +104,9 @@ class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
final contact = (ref.read(contactsProvider).valueOrNull ?? const []) final contact = (ref.read(contactsProvider).valueOrNull ?? const [])
.where((c) => c.publicIdentity.id == m.senderId) .where((c) => c.publicIdentity.id == m.senderId)
.firstOrNull; .firstOrNull;
return contact?.displayName.isNotEmpty == true return contact == null
? contact!.displayName ? context.l10n.commonUnknownContact
: context.l10n.commonUnknownContact; : contactName(context.l10n, contact);
} }
@override @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; : null;
final contactsAsync = ref.watch(contactsProvider); final contactsAsync = ref.watch(contactsProvider);
final contacts = (contactsAsync.valueOrNull ?? []) 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)) .where((c) => existing == null || !existing.memberIds.contains(c.id))
.toList(); .toList();
// Já jsem v memberIds existující skupiny; nová skupina mě přidá sama. // Já jsem v memberIds existující skupiny; nová skupina mě přidá sama.
+14
View File
@@ -18,6 +18,13 @@ class Contact {
/// Profilová fotka, kterou kontakt sdílí (null = iniciály). /// Profilová fotka, kterou kontakt sdílí (null = iniciály).
AvatarRef? avatar; 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({ Contact({
required this.displayName, required this.displayName,
required this.publicIdentity, required this.publicIdentity,
@@ -26,6 +33,7 @@ class Contact {
this.notes, this.notes,
this.blocked = false, this.blocked = false,
this.avatar, this.avatar,
this.accepted = true,
}); });
/// ID kontaktu = fingerprint identity klíče (identita = klíč). /// ID kontaktu = fingerprint identity klíče (identita = klíč).
@@ -41,12 +49,14 @@ class Contact {
factory Contact.fromPublicIdentity( factory Contact.fromPublicIdentity(
PublicIdentity identity, { PublicIdentity identity, {
bool verified = false, bool verified = false,
bool accepted = true,
}) { }) {
return Contact( return Contact(
displayName: identity.displayName, displayName: identity.displayName,
publicIdentity: identity, publicIdentity: identity,
addedAt: DateTime.now(), addedAt: DateTime.now(),
verified: verified, verified: verified,
accepted: accepted,
); );
} }
@@ -64,6 +74,7 @@ class Contact {
'notes': notes, 'notes': notes,
'blocked': blocked, 'blocked': blocked,
'avatar': avatar?.toJson(), 'avatar': avatar?.toJson(),
'accepted': accepted,
}; };
factory Contact.fromJson(Map<String, dynamic> json) { factory Contact.fromJson(Map<String, dynamic> json) {
@@ -77,6 +88,9 @@ class Contact {
notes: json['notes'] as String?, notes: json['notes'] as String?,
blocked: json['blocked'] as bool? ?? false, blocked: json['blocked'] as bool? ?? false,
avatar: AvatarRef.fromJson(json['avatar']), 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,
); );
} }
} }
+40
View File
@@ -4,6 +4,18 @@ import 'package:sepot/models/group.dart';
import 'package:sepot/models/poll.dart'; import 'package:sepot/models/poll.dart';
import 'package:uuid/uuid.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 /// Typy zpráv — odpovídá Rust: MessageType
enum MessageType { enum MessageType {
text, text,
@@ -62,6 +74,9 @@ class Message {
/// Lokální záznam hovoru — taková zpráva nemá text ani přílohu. /// Lokální záznam hovoru — taková zpráva nemá text ani přílohu.
final CallLog? callLog; final CallLog? callLog;
/// Bezpečnostní upozornění appky (není to zpráva od nikoho).
final MessageNotice? notice;
const Message({ const Message({
required this.id, required this.id,
required this.senderId, required this.senderId,
@@ -85,8 +100,30 @@ class Message {
this.poll, this.poll,
this.mentions = const [], this.mentions = const [],
this.callLog, 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 /// Vytvoř textovou zprávu
factory Message.text({ factory Message.text({
required String senderId, required String senderId,
@@ -191,6 +228,7 @@ class Message {
poll: poll ?? this.poll, poll: poll ?? this.poll,
mentions: mentions, mentions: mentions,
callLog: callLog, callLog: callLog,
notice: notice,
); );
} }
@@ -231,6 +269,7 @@ class Message {
'poll': poll?.toJson(), 'poll': poll?.toJson(),
if (mentions.isNotEmpty) 'mentions': mentions, if (mentions.isNotEmpty) 'mentions': mentions,
if (callLog != null) 'call': callLog!.toJson(), if (callLog != null) 'call': callLog!.toJson(),
if (notice != null) 'notice': notice!.name,
}; };
static Map<String, int> _timesToJson(Map<String, DateTime> times) => static Map<String, int> _timesToJson(Map<String, DateTime> times) =>
@@ -282,6 +321,7 @@ class Message {
poll: Poll.fromJson(json['poll']), poll: Poll.fromJson(json['poll']),
mentions: mentionsFromWire(json['mentions']), mentions: mentionsFromWire(json['mentions']),
callLog: CallLog.fromJson(json['call']), callLog: CallLog.fromJson(json['call']),
notice: MessageNotice.values.asNameMap()[json['notice']],
); );
} }
} }
+27 -3
View File
@@ -26,9 +26,19 @@ class ContactsNotifier extends AsyncNotifier<List<Contact>> {
throw const ContactLinkIncompleteError(); throw const ContactLinkIncompleteError();
} }
// Kontrola duplicity // Kontrola duplicity. Výjimka: žádost o zprávu od téhož klíče — ruční
if (contacts.any((c) => c.publicIdentity.id == identity.id)) { // přidání (QR, odkaz) je právě to přijetí, na které čekala.
throw const ContactAlreadyExistsError(); 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); final contact = Contact.fromPublicIdentity(identity, verified: verified);
@@ -38,6 +48,20 @@ class ContactsNotifier extends AsyncNotifier<List<Contact>> {
return contact; 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<void> 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 /// Smaž kontakt
Future<void> removeContact(String contactId) async { Future<void> removeContact(String contactId) async {
final storage = ref.read(storageProvider); final storage = ref.read(storageProvider);
@@ -341,8 +341,10 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
// Příchozí zprávy, které jsem právě přečetl → pošlu za ně potvrzení // 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á). // přečtení (sendReadReceipts sám zkontroluje, jestli je funkce zapnutá).
// Upozornění appky (notice) nikdo neposlal — potvrzovat ho nelze.
final justRead = conv.messages final justRead = conv.messages
.where((m) => !m.isMine(identity.id) && !m.isRead) .where((m) =>
!m.isMine(identity.id) && !m.isRead && m.notice == null)
.toList(); .toList();
conv.markAllRead(identity.id); conv.markAllRead(identity.id);
+14 -1
View File
@@ -35,9 +35,14 @@ final avatarProvider =
final me = ref.watch(identityProvider).valueOrNull; final me = ref.watch(identityProvider).valueOrNull;
final contacts = ref.watch(contactsProvider).valueOrNull ?? const []; final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
if (!service.isStarted) return null; 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 final avatar = fingerprint == me?.id
? service.myAvatar ? service.myAvatar
: contacts.where((c) => c.id == fingerprint).firstOrNull?.avatar; : contact != null && contact.accepted
? contact.avatar
: null;
return avatar == null ? null : service.loadAvatar(avatar); return avatar == null ? null : service.loadAvatar(avatar);
}); });
@@ -83,7 +88,15 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
final conv = await ref.read(storageProvider).loadConversation(convId); final conv = await ref.read(storageProvider).loadConversation(convId);
final mentionedMe = message.mentions.contains(identity.id); final mentionedMe = message.mentions.contains(identity.id);
if (conv?.isMuted == true && !mentionedMe) return; if (conv?.isMuted == true && !mentionedMe) return;
// 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); PushNotifier.showMessageNotification(contact.displayName);
} else if (convId != contact.id) {
PushNotifier.showWakeNotification();
} else {
PushNotifier.showMessageRequestNotification();
}
}; };
service.onCallSignal = (contact, signal) => service.onCallSignal = (contact, signal) =>
+58
View File
@@ -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');
});
}
+251
View File
@@ -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<String, dynamic>)>[];
final calls = <String>[];
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<void> deliver(
LocalIdentity from,
String account,
Map<String, dynamic> content,
) =>
service.debugProcessOpened(
SepotSealedMessage(
plaintext: utf8.encode(jsonEncode({'v': 1, ...content})),
senderName: account,
senderDeviceId: 1,
senderIdentityKey: from.publicIdentity.identityKey,
),
randomBytes(16),
);
Map<String, dynamic> text(String name, String txt) => {
't': 'txt',
'id': 'm-${rng.nextInt(1 << 30)}',
'txt': txt,
'p': {'n': name, 'da': base64Encode(randomBytes(32))},
};
Future<Contact> 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<SepotPreKeyBundle> 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);
});
});
}