9 Commits

Author SHA1 Message Date
Julius Joska 830d6290ca release: 1.2.3 (26) 2026-10-23 10:53:46 +02:00
Julius Joska cbb09afbb1 fix(ui): skip the "(unknown)" suffix when building avatar initials
A message request named "Mom (unknown)" showed the initials "M(".
2026-10-23 10:51:02 +02:00
Julius Joska 59992af778 fix(push): register APNs and VoIP tokens on iOS
The local notifications plugin was initialised without iOS settings, so
flutter_local_notifications threw "iOS settings must be set" before the
app registered with Apple, and restoreMode swallowed the error. iPhones
never sent the relay an APNs token for message wake-ups or a VoIP token
for calls.

- add DarwinInitializationSettings and request iOS permission explicitly
- register with Apple before asking for notification permission (silent
  wake-ups do not need it)
- send the VoIP token on its own (PLATFORM_APNS with an empty endpoint)
  when no APNs token is available yet
- record a failed registration so the chat list shows it instead of
  failing silently
- drop the force cast of rootViewController in AppDelegate and the unused
  SceneDelegate
2026-10-23 10:51:02 +02:00
Julius Joska 717aeed4ff 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.
2026-10-23 10:50:42 +02:00
Julius Joska 2f6b542f24 fix(security): require a fresh PIN or biometric check for sensitive actions
An unlocked phone was enough to read the 24-word recovery phrase. The
phrase, backup export and import, the duress PIN, a longer auto-lock,
turning off screen security, enabling biometrics and deleting the
account now ask for the PIN (or biometrics with biometricOnly) every
time. The duress session never shows the phrase. PIN checks outside the
lock screen count towards the same failure throttle.
2026-10-23 10:50:16 +02:00
Julius Joska 5b237c82f2 docs: describe relay metadata and audit status precisely
State what the relay sees at runtime (authenticated submission reveals
which device hands an envelope to which address) instead of claiming it
never learns the sender, note where data is stored, drop the
"Signal-grade" label and add a non-affiliation notice, and document
that only internal audits and penetration tests have been done so far.
2026-10-23 09:17:44 +02:00
Julius Joska d2eb0d4f8c docs: describe how to verify the relay revision, APK and native libraries
Also state plainly what is not reproducible today and what we plan to do
about it.
2026-10-23 09:15:15 +02:00
Julius Joska ae127decd9 build: pin the native libsignal library by SHA-256
The libsignal Dart package downloads a prebuilt libsignal_frb from the
GitHub releases of djx-y-z/libsignal_dart and only checks it against a
checksum file from the same release. Keep our own pins in
app/tool/native_libs.sha256 and verify the finished AAB/APK with
app/tool/verify_native_libs.sh; any mismatch fails the release build.

The pins cover the upstream archives and the library as packaged after
the Android Gradle plugin strips it. `--from-upstream` re-derives the
packaged hashes from the upstream archives with llvm-strip.
2026-10-23 09:15:15 +02:00
Julius Joska ab0e35ddf4 docs(readme): update release status for Android and iOS 2026-10-15 09:32:53 +02:00
55 changed files with 3018 additions and 172 deletions
+30
View File
@@ -6,6 +6,36 @@ verzování dle [ajtak standardu](#verzování).
## [Nevydáno] ## [Nevydáno]
## [1.2.3] – 2026-10-23
### Bezpečnost
- **24 slov jen po čerstvém ověření.** Zálohovací fráze se z odemčené appky
ukáže až po zadání PINu (nebo otisku/obličeje, je-li zapnutý). Totéž platí
pro export a obnovu zálohy, nastavení nátlakového PINu, prodloužení
automatického zámku, vypnutí ochrany obrazovky, zapnutí biometrie
a smazání účtu. Kdo vezme odemčený telefon, k frázi se nedostane.
V nátlakové relaci se fráze nezobrazí nikdy. Ověření PINu podléhá stejnému
zpomalení po chybách jako odemykání.
- **Cizí klíč se jménem kontaktu už nesplyne s kontaktem.** Zpráva od
neznámého klíče je „žádost o zprávu“ se jménem, které si odesílatel zvolil
sám, a s označením „(neznámý)“. Dokud ji nepřijmete, nejde odpovědět,
nechodí od vás potvrzení přečtení ani psaní, nezvoní hovory a notifikace
neukazuje jméno. Když se jméno plete s existujícím kontaktem (i přes
cyrilici, diakritiku nebo neviditelné znaky), ukáže se varování i v chatu
skutečného kontaktu.
- **Změna bezpečnostního kódu.** Když se pod účtem známého kontaktu objeví
jiný klíč, chat ukáže „Bezpečnostní kód kontaktu … se změnil“, ověření
kontaktu se zruší a odpověď neodejde na nový klíč. Nový klíč přijde jako
samostatná žádost o zprávu.
### Opraveno
- **Na iPhonu nechodily budíčky ani hovory na pozadí.** Appka relayi vůbec
neposlala token pro zprávy (APNs) ani pro hovory (VoIP): nastavení
notifikací na iOS skončilo chybou, ta zastavila celou registraci a appka
ji tiše spolkla. Registrace u Apple teď proběhne i bez povolených
notifikací, token pro hovory jde zvlášť a případné selhání ukáže seznam
chatů.
## [1.2.2] – 2026-10-11 ## [1.2.2] – 2026-10-11
### Opraveno ### Opraveno
+10 -8
View File
@@ -2,16 +2,16 @@
**Šepot** is an end-to-end encrypted messenger with anonymous, on-device identity. There is no phone number, e-mail, or password: an account is a keypair generated on the device, your ID is the fingerprint of your public key, and the only backup is a mnemonic phrase. **Šepot** is an end-to-end encrypted messenger with anonymous, on-device identity. There is no phone number, e-mail, or password: an account is a keypair generated on the device, your ID is the fingerprint of your public key, and the only backup is a mnemonic phrase.
The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, does not learn who sent a message, and does not log IP addresses. What it *does* see is documented honestly below. The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, envelopes carry no sender, and it neither stores a sender–recipient link nor logs IP addresses. What it *does* see while a connection is open is documented honestly below.
> Status: 1.2.x. Android is on Google Play (production 1.1.2, 1.2.0 in internal testing); iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md). > Status: 1.2.x. Android is on [Google Play](https://play.google.com/store/apps/details?id=cz.sepot.sepot); iOS is on the App Store outside the EU, with EU availability pending Apple's trader verification under the Digital Services Act. See [`CHANGELOG.md`](CHANGELOG.md).
## Features ## Features
- **Signal-grade cryptography** via the official Rust `libsignal`: X3DH + post-quantum PQXDH (Kyber-1024) key agreement, Double Ratchet, and sealed sender. - **End-to-end encryption** via the open-source Rust `libsignal` library: X3DH + post-quantum PQXDH (Kyber-1024) key agreement, Double Ratchet, and sealed sender.
- **Anonymous identity** — no phone/e-mail/password. Recovery through a 24-word mnemonic that encodes the identity key itself (not a KDF seed). - **Anonymous identity** — no phone/e-mail/password. Recovery through a 24-word mnemonic that encodes the identity key itself (not a KDF seed).
- **Encrypted at rest** — all local data lives in SQLCipher, unlocked by a PIN (Argon2id-derived key) or biometrics. Optional **duress PIN** opens a separate empty database. - **Encrypted at rest** — all local data lives in SQLCipher, unlocked by a PIN (Argon2id-derived key) or biometrics. Optional **duress PIN** opens a separate empty database.
- **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; the relay never learns a group exists. - **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; groups are not a server-side object and the relay keeps no member list.
- **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send. - **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send.
- **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers). - **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers).
- **Push with or without Google** — UnifiedPush/ntfy when a distributor is installed, otherwise FCM on Android (default since 1.2.0, can be switched off) and APNs on iOS. A push wake-up never carries content, sender, or count. - **Push with or without Google** — UnifiedPush/ntfy when a distributor is installed, otherwise FCM on Android (default since 1.2.0, can be switched off) and APNs on iOS. A push wake-up never carries content, sender, or count.
@@ -29,9 +29,11 @@ The design goal is **metadata minimization with a public threat model** — neve
SQLCipher at rest opaque attachment blobs SQLCipher at rest opaque attachment blobs
``` ```
Every message is encrypted end-to-end with libsignal and wrapped in a sealed-sender envelope, so the relay routes by recipient delivery address without learning the sender. Undelivered messages sit in a per-recipient queue with a fixed TTL and are hard-deleted on acknowledgement. Every message is encrypted end-to-end with libsignal and wrapped in a sealed-sender envelope, so the envelope itself carries no sender and the relay routes it by the recipient's delivery address. Undelivered messages sit in a per-recipient queue with a fixed TTL and are hard-deleted on acknowledgement.
**What the relay can see:** the recipient's delivery address, message size, and timing. **What it cannot see:** message content, the sender's identity, group membership, or (with logging disabled as shipped) client IP addresses. The full model — including where the trust boundary sits — is in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md) and the crypto facade `app/lib/core/crypto/`. **What the relay can see:** the recipient's delivery address, envelope size and timing, push tokens, and — while a connection is open — the client IP address. Envelopes are submitted over an authenticated WebSocket, so at runtime the relay also knows which connected device hands an envelope to which delivery address; it needs that to deliver. **What it does not see or keep:** message content, names, contacts or group membership. It stores no sender–recipient link and no IP addresses, and writes neither to its logs. Someone who took over the relay could start recording runtime metadata; they still could not read messages. See [`SECURITY.md`](SECURITY.md) and the crypto facade `app/lib/core/crypto/`.
**Where data lives:** messages, accounts and keys are stored only on servers in the EU (Hetzner, Germany). Message content never leaves the device unencrypted. Nothing leaves the EU except an anonymous wake-up push via Google (FCM) or Apple (APNs), which carries no message content or sender; the website runs behind Cloudflare.
## Repository layout ## Repository layout
@@ -71,10 +73,10 @@ UI strings live in `app/lib/l10n/app_*.arb` (`cs`, `en`, `de`, `ru`, `it`) and a
## Security ## Security
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **security@sepot.cz** rather than opening a public issue — see [`SECURITY.md`](SECURITY.md) and [`/.well-known/security.txt`](https://sepot.cz/.well-known/security.txt). Cryptography is implemented by the open-source `libsignal` library, not by hand-rolled primitives. Šepot is not affiliated with Signal Messenger LLC; Signal is a trademark of its owner. The project runs regular internal security audits and penetration tests (August, September and October 2026); no independent third-party audit has taken place yet. If you find a vulnerability, please report it privately to **security@sepot.cz** rather than opening a public issue — see [`SECURITY.md`](SECURITY.md) and [`/.well-known/security.txt`](https://sepot.cz/.well-known/security.txt).
## License ## License
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE). Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE).
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository and the direct APK releases). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md). The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository and the direct APK releases). That permission covers only Šepot's own code. The bundled `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
+13 -4
View File
@@ -28,7 +28,16 @@ reproduce almost everything.
## What the relay can and cannot see ## What the relay can and cannot see
The threat model is documented in the README: the relay sees routing tokens, The threat model is documented in the README. In short: envelopes carry no
message sizes, timing and push tokens, never content or sender identity. sender (sealed sender) and the relay never sees content. It does see routing
Reports that demonstrate the relay learning more than that are the ones we tokens, envelope sizes, timing and push tokens, and while a connection is
care about most. open the client IP and which authenticated device submits an envelope to
which delivery address. It stores no sender–recipient link and no IP
addresses. Reports that demonstrate the relay learning or keeping more than
that are the ones we care about most.
## Audits
We run regular internal security audits and penetration tests (August,
September and October 2026); fixes are listed in `CHANGELOG.md`. No
independent third-party audit has taken place yet.
+21 -14
View File
@@ -17,22 +17,29 @@ import flutter_callkit_incoming
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
let controller = window?.rootViewController as! FlutterViewController // Bez force-castu: kdyby root nebyl FlutterViewController (jiný storyboard,
let channel = FlutterMethodChannel( // budoucí scény), appka by spadla hned při startu. Registrar dá messenger
name: "cz.sepot.sepot/apns", binaryMessenger: controller.binaryMessenger) // enginu i tak.
channel.setMethodCallHandler { [weak self] call, result in let messenger =
switch call.method { (window?.rootViewController as? FlutterViewController)?.binaryMessenger
case "register": ?? registrar(forPlugin: "SepotApns")?.messenger()
// Nativní APNs registrace (bez Firebase) — token přijde v callbacku if let messenger {
application.registerForRemoteNotifications() let channel = FlutterMethodChannel(
result(nil) name: "cz.sepot.sepot/apns", binaryMessenger: messenger)
case "getToken": channel.setMethodCallHandler { [weak self] call, result in
result(self?.pendingToken) switch call.method {
default: case "register":
result(FlutterMethodNotImplemented) // Nativní APNs registrace (bez Firebase) — token přijde v callbacku
application.registerForRemoteNotifications()
result(nil)
case "getToken":
result(self?.pendingToken)
default:
result(FlutterMethodNotImplemented)
}
} }
apnsChannel = channel
} }
apnsChannel = channel
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
-6
View File
@@ -1,6 +0,0 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+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);
}
+3 -3
View File
@@ -74,7 +74,7 @@ class PushBootstrap {
static Future<bool> registrationFailed() async => static Future<bool> registrationFailed() async =>
await _store.read(key: _registrationFailedKey) == '1'; await _store.read(key: _registrationFailedKey) == '1';
static Future<void> _setRegistrationFailed(bool failed) => failed static Future<void> setRegistrationFailed(bool failed) => failed
? _store.write(key: _registrationFailedKey, value: '1') ? _store.write(key: _registrationFailedKey, value: '1')
: _store.delete(key: _registrationFailedKey); : _store.delete(key: _registrationFailedKey);
@@ -93,7 +93,7 @@ class PushBootstrap {
await UnifiedPush.initialize( await UnifiedPush.initialize(
onNewEndpoint: _onNewEndpoint, onNewEndpoint: _onNewEndpoint,
onRegistrationFailed: (_, _) => onRegistrationFailed: (_, _) =>
unawaited(_setRegistrationFailed(true)), unawaited(setRegistrationFailed(true)),
onUnregistered: _onUnregistered, onUnregistered: _onUnregistered,
onMessage: _onMessage, onMessage: _onMessage,
); );
@@ -138,7 +138,7 @@ class PushBootstrap {
} }
static void _onNewEndpoint(PushEndpoint endpoint, String instance) { static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
unawaited(_setRegistrationFailed(false)); // registrace se povedla unawaited(setRegistrationFailed(false)); // registrace se povedla
final s = _service; final s = _service;
if (s != null) { if (s != null) {
s.handleNewEndpoint(endpoint.url); s.handleNewEndpoint(endpoint.url);
+28 -1
View File
@@ -27,6 +27,14 @@ class PushNotifier {
await _plugin.initialize( await _plugin.initialize(
InitializationSettings( InitializationSettings(
android: const AndroidInitializationSettings('@mipmap/ic_launcher'), android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
// Bez iOS nastavení plugin hodí ArgumentError a s ním padala celá
// APNs registrace — iPhony relayi neposlaly token vůbec. O oprávnění
// se žádá až v [requestPermission], ne při první notifikaci.
iOS: const DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
),
linux: LinuxInitializationSettings( linux: LinuxInitializationSettings(
defaultActionName: l10n.pushActionOpen, defaultActionName: l10n.pushActionOpen,
), ),
@@ -48,17 +56,23 @@ class PushNotifier {
priority: Priority.high, priority: Priority.high,
category: AndroidNotificationCategory.message, category: AndroidNotificationCategory.message,
), ),
iOS: const DarwinNotificationDetails(),
linux: const LinuxNotificationDetails(), linux: const LinuxNotificationDetails(),
); );
} }
/// Android 13+ runtime permission — volat při zapnutí pushe v nastavení. /// Oprávnění k viditelným notifikacím (Android 13+, iOS) — volat při
/// zapnutí pushe. Tichý budíček na iOS ho nepotřebuje.
static Future<void> requestPermission() async { static Future<void> requestPermission() async {
await _ensureInit(); await _ensureInit();
await _plugin await _plugin
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>() AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission(); ?.requestNotificationsPermission();
await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, badge: true, sound: true);
} }
/// Notifikace o přijaté zprávě, když appka běží, ale není v popředí. /// Notifikace o přijaté zprávě, když appka běží, ale není v popředí.
@@ -75,6 +89,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 {
+77 -22
View File
@@ -27,6 +27,7 @@ import 'dart:typed_data';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
import 'package:sepot/core/calls/call_ringer.dart'; import 'package:sepot/core/calls/call_ringer.dart';
import 'package:sepot/core/push/apns_ios.dart'; import 'package:sepot/core/push/apns_ios.dart';
import 'package:sepot/core/push/push_bootstrap.dart'; import 'package:sepot/core/push/push_bootstrap.dart';
@@ -35,10 +36,27 @@ import 'package:sepot/core/services/messaging_service.dart';
import 'package:sepot/proto/sepot.pb.dart' as pb; import 'package:sepot/proto/sepot.pb.dart' as pb;
import 'package:unifiedpush/unifiedpush.dart'; import 'package:unifiedpush/unifiedpush.dart';
/// Nativní strana iOS pushů. Oddělená od [PushService], aby šlo pořadí
/// kroků a skládání registrace ověřit testem bez iPhonu.
class IosPushPlatform {
const IosPushPlatform();
bool get isIOS => Platform.isIOS;
Future<void> registerApns() => ApnsBridge.register();
Future<String?> apnsToken() => ApnsBridge.currentToken();
Future<String?> voipToken() => CallRinger.voipToken();
Future<void> requestPermission() => PushNotifier.requestPermission();
Future<void> writeMode(String mode) => PushBootstrap.writeMode(mode);
Future<void> setRegistrationFailed(bool failed) =>
PushBootstrap.setRegistrationFailed(failed);
}
class PushService { class PushService {
PushService(this._messaging); PushService(this._messaging, {IosPushPlatform ios = const IosPushPlatform()})
: _ios = ios;
final MessagingService _messaging; final MessagingService _messaging;
final IosPushPlatform _ios;
StreamSubscription<String>? _fcmTokenSub; StreamSubscription<String>? _fcmTokenSub;
StreamSubscription<RemoteMessage>? _fcmMessageSub; StreamSubscription<RemoteMessage>? _fcmMessageSub;
@@ -94,34 +112,66 @@ class PushService {
String? _apnsToken; String? _apnsToken;
/// APNs token pro zprávy + VoIP token pro hovory v jedné registraci /// Registrace iOS pushů pro relay: APNs token pro zprávy + VoIP token pro
/// (relay drží obojí u zařízení). /// hovory (relay drží obojí u zařízení). VoIP token jde i sám, s prázdným
Future<void> _registerApnsToken(String token) async { /// endpointem — PushKit ho vydá nezávisle na APNs a hovory nesmí čekat,
_apnsToken = token; /// až Apple vydá i token pro zprávy. Null = zatím není co poslat.
final voip = await CallRinger.voipToken(); @visibleForTesting
final reg = _registration( static pb.RegisterPush? apnsRegistration({
pb.RegisterPush_Platform.PLATFORM_APNS, String? apnsToken,
Uint8List.fromList(utf8.encode(token)), String? voipToken,
}) {
if (apnsToken == null && voipToken == null) return null;
final reg = pb.RegisterPush(
platform: pb.RegisterPush_Platform.PLATFORM_APNS,
endpoint: apnsToken == null
? Uint8List(0)
: Uint8List.fromList(utf8.encode(apnsToken)),
); );
if (voip != null) reg.voipToken = utf8.encode(voip); if (voipToken != null) reg.voipToken = utf8.encode(voipToken);
_messaging.registerPush(reg); return reg;
} }
/// PushKit vydal (nový) VoIP token — přeregistrovat, pokud už APNs běží. Future<void> _sendApnsRegistration() async {
Future<void> refreshVoipToken() async { final reg = apnsRegistration(
final token = _apnsToken; apnsToken: _apnsToken,
if (token != null) await _registerApnsToken(token); voipToken: await _ios.voipToken(),
);
if (reg != null) _messaging.registerPush(reg);
} }
Future<void> _registerApnsToken(String token) async {
_apnsToken = token;
await _sendApnsRegistration();
}
/// PushKit vydal (nový) VoIP token — poslat ho relayi, i když APNs token
/// pro zprávy ještě nedorazil.
Future<void> refreshVoipToken() => _sendApnsRegistration();
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde /// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
/// asynchronně a sám se pošle relayi. Bez Googlu. /// asynchronně a sám se pošle relayi. Bez Googlu.
///
/// Registrace u Apple jde PŘED žádostí o oprávnění: tichý budíček
/// oprávnění nepotřebuje, takže odmítnutý (nebo selhaný) dialog nesmí
/// zastavit doručování zpráv.
Future<void> enableApns() async { Future<void> enableApns() async {
await init(); await init();
await PushNotifier.requestPermission(); await _ios.registerApns();
await ApnsBridge.register(); await _ios.writeMode('apns');
final existing = await ApnsBridge.currentToken(); final existing = await _ios.apnsToken();
if (existing != null) _registerApnsToken(existing); if (existing != null) {
await PushBootstrap.writeMode('apns'); await _registerApnsToken(existing);
} else {
// VoIP token mohl přijít dřív než APNs — hovory ať zvoní hned.
await _sendApnsRegistration();
}
try {
await _ios.requestPermission();
} catch (e, s) {
// Bez oprávnění chodí budíčky dál, jen bez viditelné notifikace.
debugPrint('Oprávnění k notifikacím selhalo: $e\n$s');
}
} }
/// Zapne push: použije uloženého/výchozího distributora a zaregistruje se. /// Zapne push: použije uloženého/výchozího distributora a zaregistruje se.
@@ -245,10 +295,15 @@ class PushService {
Future<void> restoreMode() async { Future<void> restoreMode() async {
// iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only). // iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only).
// Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ. // Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ.
if (Platform.isIOS) { if (_ios.isIOS) {
try { try {
await enableApns(); await enableApns();
} catch (_) { await _ios.setRegistrationFailed(false);
} catch (e, s) {
// Dřív se chyba spolkla a iPhone tiše zůstal bez budíčků. Teď ji
// uvidí stav pushů v chat listu (PushHealth.failed).
debugPrint('APNs registrace selhala: $e\n$s');
await _ios.setRegistrationFailed(true);
await init(); await init();
} }
return; return;
+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;
}
+127 -25
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,
// Prázdné jméno = UI si doplní lokalizovaný fallback // kdo roster poslal). Jako u příchozí zprávy je to žádost.
displayName: (name == null || name.isEmpty) ? '' : name, await storage.saveContact(Contact.fromPublicIdentity(
identityKey: base64Decode(e['ik'] as String), PublicIdentity(
createdAt: DateTime.now(), // Prázdné jméno = UI si doplní lokalizovaný fallback
accountId: aid, displayName: (name == null || name.isEmpty) ? '' : clipName(name),
deliveryAddress: daB64 == null ? null : base64Decode(daB64), identityKey: base64Decode(e['ik'] as String),
))); createdAt: DateTime.now(),
accountId: aid,
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.
+15 -2
View File
@@ -197,15 +197,28 @@ class SecureStorageService {
await _secureStorage.delete(key: _wrappedDekKey); await _secureStorage.delete(key: _wrappedDekKey);
} }
/// Ověří PIN, aniž by cokoli změnil (unwrap a zahození DEK). /// Ověří skutečný PIN, aniž by cokoli změnil (unwrap a zahození DEK).
/// Stojí jednu Argon2id derivaci (~2 s) — to je zároveň rate limit. /// Tímhle se potvrzují citlivé akce v odemčené appce (fráze, záloha,
/// smazání účtu, změna PINu).
///
/// Sdílí brzdu se zámkovou obrazovkou: kdo dostane do ruky odemčený
/// telefon, nesmí tu hádat PIN rychleji než na zámku. Zamčeno =
/// [PinLockedException].
///
/// V nátlakové relaci vždy false: i kdyby někdo znal skutečný PIN, k frázi
/// skutečné identity se odtud dostat nesmí (otevře si appku normálně).
Future<bool> verifyPin(String pin) async { Future<bool> verifyPin(String pin) async {
if (_decoyMode) return false;
final wrapped = await _secureStorage.read(key: _wrappedDekKey); final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) return false; if (wrapped == null) return false;
final locked = await pinLockRemaining();
if (locked != null) throw PinLockedException(locked);
try { try {
await DekWrap.unwrap(wrapped: wrapped, pin: pin); await DekWrap.unwrap(wrapped: wrapped, pin: pin);
await _resetPinFailures();
return true; return true;
} on WrongPinException { } on WrongPinException {
await _notePinFailure();
return false; return false;
} }
} }
+8 -1
View File
@@ -71,7 +71,14 @@ class _ChangePinScreenState extends ConsumerState<ChangePinScreen> {
switch (_krok) { switch (_krok) {
case _Krok.stary: case _Krok.stary:
setState(() => _pracuje = true); setState(() => _pracuje = true);
final ok = await storage.verifyPin(_pin); final bool ok;
try {
ok = await storage.verifyPin(_pin);
} on PinLockedException catch (e) {
if (!mounted) return;
_reset(context.l10n.pinLockedFor(e.remaining.inSeconds));
return;
}
if (!mounted) return; if (!mounted) return;
if (!ok) { if (!ok) {
_reset(context.l10n.pinWrong); _reset(context.l10n.pinWrong);
+190
View File
@@ -0,0 +1,190 @@
/// Čerstvé ověření před citlivou akcí v už odemčené appce.
///
/// Odemčený telefon v cizí ruce nesmí stačit na převzetí účtu: 24 slov,
/// záloha, smazání účtu nebo oslabení zámku chtějí PIN (Argon2id unwrap,
/// stejná brzda jako zámková obrazovka), případně skutečnou biometrii.
/// Žádné okno „před chvílí jsem ověřil“ — každá akce se ptá znovu.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:local_auth/local_auth.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/widgets/pin_keypad.dart';
/// Systémová biometrická výzva. Provider kvůli testům — skutečný
/// `local_auth` ve widget testu neběží.
final biometricPromptProvider = Provider<Future<bool> Function(String reason)>((
ref,
) {
return (reason) => LocalAuthentication().authenticate(
localizedReason: reason,
// biometricOnly: zámek telefonu (PIN/vzor zařízení) by obešel
// Argon2id ochranu slabším faktorem, který zná i „půjčovatel“.
options: const AuthenticationOptions(stickyAuth: true, biometricOnly: true),
);
});
/// Vrátí true, jen když uživatel právě teď prokázal, že zná PIN, nebo
/// prošel biometrií. [reason] se ukáže ve výzvě.
///
/// [allowBiometrics] = false u akcí, které biometrii samy zakládají:
/// zapnutí biometrie se nesmí potvrdit tím, co se teprve zapíná.
Future<bool> confirmSensitiveAction(
BuildContext context,
WidgetRef ref, {
required String reason,
bool allowBiometrics = true,
}) async {
final storage = ref.read(storageProvider);
if (storage.isDecoyMode) return false;
if (!await storage.hasPin()) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.reauthPinRequired),
action: SnackBarAction(
label: context.l10n.reauthSetPin,
onPressed: () => context.push('/pin-setup'),
),
),
);
}
return false;
}
if (allowBiometrics && await storage.isBiometricsEnabled()) {
try {
if (await ref.read(biometricPromptProvider)(reason)) return true;
} catch (_) {
// Biometrie nedostupná nebo zrušená — PIN je vždy cesta.
}
}
if (!context.mounted) return false;
final ok = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: SepotColors.background,
builder: (_) => _ReauthPinSheet(reason: reason),
);
return ok ?? false;
}
class _ReauthPinSheet extends ConsumerStatefulWidget {
const _ReauthPinSheet({required this.reason});
final String reason;
@override
ConsumerState<_ReauthPinSheet> createState() => _ReauthPinSheetState();
}
class _ReauthPinSheetState extends ConsumerState<_ReauthPinSheet> {
String _pin = '';
String? _error;
bool _busy = false;
void _onKey(String key) {
if (_busy) return;
setState(() {
_error = null;
if (key == 'DEL') {
if (_pin.isNotEmpty) _pin = _pin.substring(0, _pin.length - 1);
} else if (_pin.length < 6) {
_pin += key;
}
});
if (_pin.length == 6) _verify();
}
Future<void> _verify() async {
setState(() => _busy = true);
final l10n = context.l10n;
final storage = ref.read(storageProvider);
String error;
try {
if (await storage.verifyPin(_pin)) {
if (mounted) Navigator.of(context).pop(true);
return;
}
final locked = await storage.pinLockRemaining();
error = locked == null
? l10n.pinWrong
: l10n.pinLockedFor(locked.inSeconds);
} on PinLockedException catch (e) {
error = l10n.pinLockedFor(e.remaining.inSeconds);
}
if (!mounted) return;
setState(() {
_error = error;
_pin = '';
_busy = false;
});
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Padding(
padding: const EdgeInsets.fromLTRB(32, 24, 32, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.reauthTitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 22,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
widget.reason,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: SepotColors.textSecondary,
),
),
const SizedBox(height: 32),
PinDots(filled: _pin.length),
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(color: SepotColors.error, fontSize: 14),
),
],
if (_busy) ...[
const SizedBox(height: 16),
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SepotColors.ember,
),
),
],
const SizedBox(height: 32),
PinKeypad(onKey: _onKey, enabled: !_busy),
const SizedBox(height: 8),
TextButton(
onPressed: _busy ? null : () => Navigator.of(context).pop(false),
child: Text(l10n.commonCancel),
),
],
),
);
}
}
@@ -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)
: Text( : contact != null && !contact.accepted
verified ? Text(
? context.l10n.verifyVerified context.l10n.requestTitle,
: context.l10n.verifyUnverified, style: const TextStyle(
style: TextStyle( fontSize: 12, color: SepotColors.warning),
fontSize: 12, )
color: : Text(
verified ? SepotColors.sage : SepotColors.textSecondary, verified
), ? context.l10n.verifyVerified
), : context.l10n.verifyUnverified,
style: TextStyle(
fontSize: 12,
color: verified
? 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.
@@ -12,6 +12,7 @@ import 'package:local_auth/local_auth.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';
import 'package:sepot/features/auth/reauth.dart';
import 'package:sepot/providers/storage_provider.dart'; import 'package:sepot/providers/storage_provider.dart';
class BiometricsTile extends ConsumerStatefulWidget { class BiometricsTile extends ConsumerStatefulWidget {
@@ -48,6 +49,14 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
_snack(l10n.biometricsStorageLocked); _snack(l10n.biometricsStorageLocked);
return; return;
} }
// Biometrie dostane kopii DEK — zapnout ji smí jen ten, kdo zná PIN.
// Otisk sám nestačí: v telefonu může být zapsaný i cizí prst.
if (!mounted ||
!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonBiometrics,
allowBiometrics: false)) {
return;
}
final auth = LocalAuthentication(); final auth = LocalAuthentication();
if (!await auth.isDeviceSupported()) { if (!await auth.isDeviceSupported()) {
_snack(l10n.biometricsNotAvailable); _snack(l10n.biometricsNotAvailable);
+89 -38
View File
@@ -24,6 +24,7 @@ import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/messaging_provider.dart'; import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/storage_provider.dart'; import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/providers/settings_provider.dart'; import 'package:sepot/providers/settings_provider.dart';
import 'package:sepot/features/auth/reauth.dart';
import 'package:sepot/features/settings/biometrics_tile.dart'; import 'package:sepot/features/settings/biometrics_tile.dart';
import 'package:sepot/features/settings/language_settings_tile.dart'; import 'package:sepot/features/settings/language_settings_tile.dart';
import 'package:sepot/features/settings/push_settings_tile.dart'; import 'package:sepot/features/settings/push_settings_tile.dart';
@@ -151,8 +152,7 @@ class SettingsScreen extends ConsumerWidget {
subtitle: l10n.settingsScreenSecuritySubtitle, subtitle: l10n.settingsScreenSecuritySubtitle,
trailing: Switch( trailing: Switch(
value: ref.watch(screenSecurityProvider).valueOrNull ?? false, value: ref.watch(screenSecurityProvider).valueOrNull ?? false,
onChanged: (v) => onChanged: (v) => _setScreenSecurity(context, ref, v),
ref.read(screenSecurityProvider.notifier).set(v),
activeTrackColor: SepotColors.ember, activeTrackColor: SepotColors.ember,
), ),
onTap: () {}, onTap: () {},
@@ -319,9 +319,15 @@ class SettingsScreen extends ConsumerWidget {
], ],
), ),
); );
if (vybrano != null && vybrano != ted) { if (vybrano == null || vybrano == ted || !context.mounted) return;
await ref.read(autoLockProvider.notifier).set(vybrano); // Delší interval nebo „nikdy“ = slabší zámek. Kratší projde bez ptaní.
final weakens = vybrano == 0 || (ted != 0 && vybrano > ted);
if (weakens &&
!await confirmSensitiveAction(context, ref,
reason: context.l10n.reauthReasonAutoLock)) {
return;
} }
await ref.read(autoLockProvider.notifier).set(vybrano);
} }
/// Moje profilová fotka: vybrat z galerie (oříznutí a zmenšení běží /// Moje profilová fotka: vybrat z galerie (oříznutí a zmenšení běží
@@ -518,6 +524,10 @@ class SettingsScreen extends ConsumerWidget {
final l10n = context.l10n; final l10n = context.l10n;
final identity = ref.read(identityProvider).valueOrNull; final identity = ref.read(identityProvider).valueOrNull;
if (identity == null) return; if (identity == null) return;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonBackupExport)) {
return;
}
try { try {
final bytes = await ref.read(backupServiceProvider).export(identity); final bytes = await ref.read(backupServiceProvider).export(identity);
final dir = await getTemporaryDirectory(); final dir = await getTemporaryDirectory();
@@ -547,6 +557,10 @@ class SettingsScreen extends ConsumerWidget {
final l10n = context.l10n; final l10n = context.l10n;
final identity = ref.read(identityProvider).valueOrNull; final identity = ref.read(identityProvider).valueOrNull;
if (identity == null) return; if (identity == null) return;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonBackupImport)) {
return;
}
try { try {
final res = await FilePicker.pickFiles(withData: true); final res = await FilePicker.pickFiles(withData: true);
final data = res?.files.firstOrNull?.bytes; final data = res?.files.firstOrNull?.bytes;
@@ -570,6 +584,11 @@ class SettingsScreen extends ConsumerWidget {
Future<void> _setDecoyPin(BuildContext context, WidgetRef ref) async { Future<void> _setDecoyPin(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n; final l10n = context.l10n;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonDecoyPin)) {
return;
}
if (!context.mounted) return;
final pinCtrl = TextEditingController(); final pinCtrl = TextEditingController();
final confirmCtrl = TextEditingController(); final confirmCtrl = TextEditingController();
const numberField = TextInputType.number; const numberField = TextInputType.number;
@@ -645,6 +664,10 @@ class SettingsScreen extends ConsumerWidget {
Future<void> _disableDecoyPin(BuildContext context, WidgetRef ref) async { Future<void> _disableDecoyPin(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n; final l10n = context.l10n;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonDecoyPin)) {
return;
}
await ref.read(storageProvider).disableDecoyPin(); await ref.read(storageProvider).disableDecoyPin();
ref.invalidate(hasDecoyPinProvider); ref.invalidate(hasDecoyPinProvider);
if (context.mounted) { if (context.mounted) {
@@ -654,8 +677,15 @@ class SettingsScreen extends ConsumerWidget {
} }
} }
void _showSeedPhrase(BuildContext context, WidgetRef ref) async { /// Fráze = celý účet navždy. Načte se z keystore až po čerstvém ověření
/// a kopírovat ji nejde záměrně: schránku čtou klávesnice a synchronizace
/// do cloudu, takže by 24 slov opustilo telefon mimo kontrolu uživatele.
Future<void> _showSeedPhrase(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n; final l10n = context.l10n;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonSeed)) {
return;
}
final storage = ref.read(storageProvider); final storage = ref.read(storageProvider);
final seed = await storage.loadSeedPhrase(); final seed = await storage.loadSeedPhrase();
@@ -670,47 +700,64 @@ class SettingsScreen extends ConsumerWidget {
if (!context.mounted) return; if (!context.mounted) return;
showDialog( await ref.read(screenSecurityProvider.notifier).enforceWhile(
context: context, () => showDialog<void>(
builder: (ctx) => AlertDialog( context: context,
title: Text(ctx.l10n.settingsSeedPhraseTitle), builder: (ctx) => AlertDialog(
content: Column( title: Text(ctx.l10n.settingsSeedPhraseTitle),
mainAxisSize: MainAxisSize.min, // 24 čipů se na malý displej nevejde — posouvat, ne přetéct.
children: [ content: SingleChildScrollView(
Text( child: Column(
ctx.l10n.settingsSeedPhraseWarning, mainAxisSize: MainAxisSize.min,
style: const TextStyle( children: [
fontSize: 13, Text(
color: SepotColors.warning, ctx.l10n.settingsSeedPhraseWarning,
), style: const TextStyle(
), fontSize: 13,
const SizedBox(height: 16), color: SepotColors.warning,
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(seed.length, (i) {
return Chip(
label: Text(
ctx.l10n.settingsSeedWordChip(i + 1, seed[i]),
style: SepotTheme.cryptoText,
), ),
backgroundColor: SepotColors.surfaceVariant, ),
side: const BorderSide(color: SepotColors.border), const SizedBox(height: 16),
); Wrap(
}), spacing: 8,
runSpacing: 8,
children: List.generate(seed.length, (i) {
return Chip(
label: Text(
ctx.l10n.settingsSeedWordChip(i + 1, seed[i]),
style: SepotTheme.cryptoText,
),
backgroundColor: SepotColors.surfaceVariant,
side: const BorderSide(color: SepotColors.border),
);
}),
),
],
),
),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(ctx.l10n.commonClose),
), ),
], ],
), ),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(ctx.l10n.commonClose),
),
],
), ),
); );
} }
/// Vypnutí ochrany obrazovky pouští obsah (i frázi) do snímků a náhledu
/// v přepínači aplikací — stejné oslabení jako delší auto-zámek.
Future<void> _setScreenSecurity(
BuildContext context, WidgetRef ref, bool enabled) async {
if (!enabled &&
!await confirmSensitiveAction(context, ref,
reason: context.l10n.reauthReasonScreenSecurity)) {
return;
}
await ref.read(screenSecurityProvider.notifier).set(enabled);
}
void _showAbout(BuildContext context) { void _showAbout(BuildContext context) {
showDialog( showDialog(
context: context, context: context,
@@ -767,6 +814,10 @@ class SettingsScreen extends ConsumerWidget {
), ),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
if (!await confirmSensitiveAction(ctx, ref,
reason: ctx.l10n.reauthReasonDeleteAccount)) {
return;
}
final messaging = ref.read(messagingServiceProvider); final messaging = ref.read(messagingServiceProvider);
// Serverová deregistrace PŘED lokálním wipe — podpis // Serverová deregistrace PŘED lokálním wipe — podpis
// požadavku potřebuje identity klíč, který wipe zahodí. // požadavku potřebuje identity klíč, který wipe zahodí.
+132
View File
@@ -2339,5 +2339,137 @@
"type": "String" "type": "String"
} }
} }
},
"reauthTitle": "Potvrď, že jsi to ty",
"@reauthTitle": {
"description": "Nadpis výzvy k PINu před citlivou akcí v odemčené appce"
},
"reauthPinRequired": "Tahle akce potřebuje PIN. Nejdřív si ho nastav.",
"@reauthPinRequired": {
"description": "Snackbar: citlivá akce bez nastaveného PINu"
},
"reauthSetPin": "Nastavit PIN",
"@reauthSetPin": {
"description": "Akce snackbaru: nastavit PIN"
},
"reauthReasonSeed": "Zobrazení 24 slov pro obnovu účtu",
"@reauthReasonSeed": {
"description": "Důvod ve výzvě k PINu/biometrii: zobrazení obnovovací fráze"
},
"reauthReasonBackupExport": "Export zálohy",
"@reauthReasonBackupExport": {
"description": "Důvod: export zálohy"
},
"reauthReasonBackupImport": "Obnova ze zálohy",
"@reauthReasonBackupImport": {
"description": "Důvod: obnova ze zálohy"
},
"reauthReasonDecoyPin": "Změna nátlakového PINu",
"@reauthReasonDecoyPin": {
"description": "Důvod: nastavení nebo zrušení nátlakového PINu"
},
"reauthReasonAutoLock": "Prodloužení automatického zámku",
"@reauthReasonAutoLock": {
"description": "Důvod: prodloužení nebo vypnutí automatického zámku"
},
"reauthReasonScreenSecurity": "Vypnutí ochrany obrazovky",
"@reauthReasonScreenSecurity": {
"description": "Důvod: vypnutí ochrany obrazovky"
},
"reauthReasonDeleteAccount": "Smazání účtu a všech dat",
"@reauthReasonDeleteAccount": {
"description": "Důvod: smazání účtu"
},
"reauthReasonBiometrics": "Zapnutí biometrie",
"@reauthReasonBiometrics": {
"description": "Důvod: zapnutí biometrického odemčení"
},
"contactRequestName": "{name} (neznámý)",
"@contactRequestName": {
"description": "Jméno neznámého odesílatele (žádost o zprávu) všude v UI; {name} si zvolil sám",
"placeholders": {
"name": {
"type": "String"
}
}
},
"pushMessageRequest": "Žádost o zprávu od neznámého odesílatele",
"@pushMessageRequest": {
"description": "Text notifikace pro zprávu od neznámého odesílatele — bez jména, které si zvolil"
},
"requestTitle": "Žádost o zprávu",
"@requestTitle": {
"description": "Nadpis panelu / štítek žádosti o zprávu"
},
"requestBody": "„{name}“ není ve tvých kontaktech. Jméno i fotku si zvolil sám, takže nemusí jít o toho, za koho se vydává. Dokud ho nepřijmeš, neuvidí, že jsi zprávu četl, a nemůže ti volat.",
"@requestBody": {
"description": "Panel žádosti o zprávu; {name} = jméno, které si odesílatel zvolil",
"placeholders": {
"name": {
"type": "String"
}
}
},
"requestLookalikeWarning": "Pozor: stejně se jmenuje tvůj kontakt {name}. Tohle je jiný klíč, tedy jiný telefon nebo jiný člověk. Než odpovíš, ověř si to jinou cestou (zavolej, potkejte se).",
"@requestLookalikeWarning": {
"description": "Varování v panelu žádosti: jméno se plete s existujícím kontaktem {name}",
"placeholders": {
"name": {
"type": "String"
}
}
},
"requestAccept": "Přijmout",
"@requestAccept": {
"description": "Tlačítko: přijmout žádost o zprávu"
},
"requestAcceptTitle": "Přijmout jako kontakt",
"@requestAcceptTitle": {
"description": "Nadpis dialogu přijetí žádosti"
},
"requestAcceptBody": "Pojmenuj kontakt sám. Jméno, které si odesílatel zvolil, se nepřebírá automaticky.",
"@requestAcceptBody": {
"description": "Text dialogu přijetí: kontakt pojmenuje uživatel sám"
},
"requestAcceptNameLabel": "Jméno kontaktu",
"@requestAcceptNameLabel": {
"description": "Popisek pole se jménem kontaktu"
},
"requestNameEmpty": "Zadej jméno.",
"@requestNameEmpty": {
"description": "Chyba: prázdné jméno"
},
"requestNameTaken": "Tak (nebo skoro tak) se už jmenuje jiný kontakt. Zvol jméno, které je nezamění.",
"@requestNameTaken": {
"description": "Chyba: jméno se plete s jiným kontaktem"
},
"noticeIdentityChanged": "Bezpečnostní kód kontaktu {name} se změnil. Pod jeho adresou se ozval jiný klíč: nový telefon, přeinstalace, nebo podvrh. Ověření je zrušené. Než pošleš něco citlivého, porovnejte kód znovu.",
"@noticeIdentityChanged": {
"description": "Upozornění v konverzaci: kontaktu {name} se změnil identity klíč",
"placeholders": {
"name": {
"type": "String"
}
}
},
"noticeLookalikeRequest": "Neznámý odesílatel si dal jméno „{claimed}“, které se plete s kontaktem {name}. Tento kontakt to není, zpráva přišla z jiného klíče. Najdeš ji v chatech jako žádost o zprávu.",
"@noticeLookalikeRequest": {
"description": "Upozornění v konverzaci existujícího kontaktu {name}: neznámý si zvolil podobné jméno {claimed}",
"placeholders": {
"claimed": {
"type": "String"
},
"name": {
"type": "String"
}
}
},
"noticeVerifyAction": "Ověřit kód",
"@noticeVerifyAction": {
"description": "Tlačítko u upozornění na změnu kódu"
},
"noticePreview": "Bezpečnostní upozornění",
"@noticePreview": {
"description": "Náhled v seznamu chatů, když je poslední položkou bezpečnostní upozornění"
} }
} }
+27 -1
View File
@@ -486,5 +486,31 @@
"profilePhotoPick": "Foto auswählen", "profilePhotoPick": "Foto auswählen",
"profilePhotoRemove": "Foto entfernen", "profilePhotoRemove": "Foto entfernen",
"profilePhotoHint": "Nur deine Chatpartner sehen es — Ende-zu-Ende-verschlüsselt wie Nachrichten. Standort und Fotodaten werden nicht gesendet.", "profilePhotoHint": "Nur deine Chatpartner sehen es — Ende-zu-Ende-verschlüsselt wie Nachrichten. Standort und Fotodaten werden nicht gesendet.",
"profilePhotoFailed": "Foto konnte nicht gesetzt werden: {error}" "profilePhotoFailed": "Foto konnte nicht gesetzt werden: {error}",
"reauthTitle": "Bestätige, dass du es bist",
"reauthPinRequired": "Diese Aktion erfordert eine PIN. Lege zuerst eine fest.",
"reauthSetPin": "PIN festlegen",
"reauthReasonSeed": "24 Wiederherstellungswörter anzeigen",
"reauthReasonBackupExport": "Backup exportieren",
"reauthReasonBackupImport": "Aus Backup wiederherstellen",
"reauthReasonDecoyPin": "Notfall-PIN ändern",
"reauthReasonAutoLock": "Automatische Sperre verlängern",
"reauthReasonScreenSecurity": "Bildschirmschutz ausschalten",
"reauthReasonDeleteAccount": "Konto und alle Daten löschen",
"reauthReasonBiometrics": "Biometrie einschalten",
"contactRequestName": "{name} (unbekannt)",
"pushMessageRequest": "Nachrichtenanfrage von unbekanntem Absender",
"requestTitle": "Nachrichtenanfrage",
"requestBody": "„{name}“ ist nicht in deinen Kontakten. Name und Foto hat die Person selbst gewählt, sie ist also vielleicht nicht, wer sie vorgibt zu sein. Bis du annimmst, sieht sie nicht, dass du die Nachricht gelesen hast, und kann dich nicht anrufen.",
"requestLookalikeWarning": "Achtung: Dein Kontakt {name} heißt genauso. Das ist ein anderer Schlüssel, also ein anderes Telefon oder eine andere Person. Prüfe das auf anderem Weg, bevor du antwortest (anrufen, persönlich treffen).",
"requestAccept": "Annehmen",
"requestAcceptTitle": "Als Kontakt annehmen",
"requestAcceptBody": "Benenne den Kontakt selbst. Der vom Absender gewählte Name wird nicht automatisch übernommen.",
"requestAcceptNameLabel": "Kontaktname",
"requestNameEmpty": "Gib einen Namen ein.",
"requestNameTaken": "Ein anderer Kontakt heißt schon so (oder fast so). Wähle einen Namen, der nicht verwechselt werden kann.",
"noticeIdentityChanged": "Der Sicherheitscode von {name} hat sich geändert. Unter dieser Adresse meldet sich ein anderer Schlüssel: neues Telefon, Neuinstallation oder Fälschung. Die Verifizierung wurde aufgehoben. Vergleicht den Code erneut, bevor du etwas Sensibles sendest.",
"noticeLookalikeRequest": "Ein unbekannter Absender nennt sich „{claimed}“, was nach deinem Kontakt {name} aussieht. Es ist nicht dieser Kontakt, die Nachricht kam von einem anderen Schlüssel. Du findest sie in den Chats als Nachrichtenanfrage.",
"noticeVerifyAction": "Code prüfen",
"noticePreview": "Sicherheitshinweis"
} }
+27 -1
View File
@@ -486,5 +486,31 @@
"profilePhotoPick": "Choose photo", "profilePhotoPick": "Choose photo",
"profilePhotoRemove": "Remove photo", "profilePhotoRemove": "Remove photo",
"profilePhotoHint": "Only people you chat with will see it — end-to-end encrypted like your messages. No location or photo metadata is sent.", "profilePhotoHint": "Only people you chat with will see it — end-to-end encrypted like your messages. No location or photo metadata is sent.",
"profilePhotoFailed": "Could not set photo: {error}" "profilePhotoFailed": "Could not set photo: {error}",
"reauthTitle": "Confirm it's you",
"reauthPinRequired": "This action needs a PIN. Set one first.",
"reauthSetPin": "Set PIN",
"reauthReasonSeed": "Show the 24 recovery words",
"reauthReasonBackupExport": "Export backup",
"reauthReasonBackupImport": "Restore from backup",
"reauthReasonDecoyPin": "Change the duress PIN",
"reauthReasonAutoLock": "Extend the auto-lock",
"reauthReasonScreenSecurity": "Turn off screen security",
"reauthReasonDeleteAccount": "Delete the account and all data",
"reauthReasonBiometrics": "Turn on biometrics",
"contactRequestName": "{name} (unknown)",
"pushMessageRequest": "Message request from an unknown sender",
"requestTitle": "Message request",
"requestBody": "“{name}” is not in your contacts. They chose the name and photo themselves, so they may not be who they claim to be. Until you accept, they can't see that you read the message and can't call you.",
"requestLookalikeWarning": "Careful: your contact {name} has the same name. This is a different key, so a different phone or a different person. Check another way before you reply (call, meet in person).",
"requestAccept": "Accept",
"requestAcceptTitle": "Accept as contact",
"requestAcceptBody": "Name the contact yourself. The name the sender chose is not taken over automatically.",
"requestAcceptNameLabel": "Contact name",
"requestNameEmpty": "Enter a name.",
"requestNameTaken": "Another contact already has this (or a very similar) name. Choose one that can't be confused.",
"noticeIdentityChanged": "The safety code of {name} has changed. A different key showed up at their address: a new phone, a reinstall, or an impostor. Verification has been removed. Compare the code again before you send anything sensitive.",
"noticeLookalikeRequest": "An unknown sender named themselves “{claimed}”, which looks like your contact {name}. It is not this contact, the message came from a different key. You'll find it in your chats as a message request.",
"noticeVerifyAction": "Verify code",
"noticePreview": "Security notice"
} }
+27 -1
View File
@@ -486,5 +486,31 @@
"profilePhotoPick": "Scegli foto", "profilePhotoPick": "Scegli foto",
"profilePhotoRemove": "Rimuovi foto", "profilePhotoRemove": "Rimuovi foto",
"profilePhotoHint": "La vedranno solo le persone con cui chatti — cifrata come i messaggi. Posizione e metadati non vengono inviati.", "profilePhotoHint": "La vedranno solo le persone con cui chatti — cifrata come i messaggi. Posizione e metadati non vengono inviati.",
"profilePhotoFailed": "Impossibile impostare la foto: {error}" "profilePhotoFailed": "Impossibile impostare la foto: {error}",
"reauthTitle": "Conferma che sei tu",
"reauthPinRequired": "Questa azione richiede un PIN. Impostalo prima.",
"reauthSetPin": "Imposta PIN",
"reauthReasonSeed": "Mostra le 24 parole di recupero",
"reauthReasonBackupExport": "Esporta backup",
"reauthReasonBackupImport": "Ripristina da backup",
"reauthReasonDecoyPin": "Modifica il PIN di emergenza",
"reauthReasonAutoLock": "Prolunga il blocco automatico",
"reauthReasonScreenSecurity": "Disattiva la protezione schermo",
"reauthReasonDeleteAccount": "Elimina l'account e tutti i dati",
"reauthReasonBiometrics": "Attiva la biometria",
"contactRequestName": "{name} (sconosciuto)",
"pushMessageRequest": "Richiesta di messaggio da un mittente sconosciuto",
"requestTitle": "Richiesta di messaggio",
"requestBody": "«{name}» non è tra i tuoi contatti. Nome e foto li ha scelti da sé, quindi potrebbe non essere chi dice di essere. Finché non accetti, non vede che hai letto il messaggio e non può chiamarti.",
"requestLookalikeWarning": "Attenzione: il tuo contatto {name} ha lo stesso nome. Questa è una chiave diversa, quindi un altro telefono o un'altra persona. Verifica in un altro modo prima di rispondere (chiama, incontratevi).",
"requestAccept": "Accetta",
"requestAcceptTitle": "Accetta come contatto",
"requestAcceptBody": "Dai tu un nome al contatto. Il nome scelto dal mittente non viene ripreso automaticamente.",
"requestAcceptNameLabel": "Nome del contatto",
"requestNameEmpty": "Inserisci un nome.",
"requestNameTaken": "Un altro contatto ha già questo nome (o quasi). Scegline uno che non si possa confondere.",
"noticeIdentityChanged": "Il codice di sicurezza di {name} è cambiato. Al suo indirizzo si è presentata una chiave diversa: nuovo telefono, reinstallazione o un impostore. La verifica è stata annullata. Confrontate di nuovo il codice prima di inviare qualcosa di sensibile.",
"noticeLookalikeRequest": "Un mittente sconosciuto si è chiamato «{claimed}», un nome che si confonde con il tuo contatto {name}. Non è questo contatto, il messaggio è arrivato da una chiave diversa. Lo trovi nelle chat come richiesta di messaggio.",
"noticeVerifyAction": "Verifica codice",
"noticePreview": "Avviso di sicurezza"
} }
+27 -1
View File
@@ -486,5 +486,31 @@
"profilePhotoPick": "Выбрать фото", "profilePhotoPick": "Выбрать фото",
"profilePhotoRemove": "Удалить фото", "profilePhotoRemove": "Удалить фото",
"profilePhotoHint": "Фото увидят только ваши собеседники — зашифровано, как и сообщения. Геоданные и метаданные не отправляются.", "profilePhotoHint": "Фото увидят только ваши собеседники — зашифровано, как и сообщения. Геоданные и метаданные не отправляются.",
"profilePhotoFailed": "Не удалось установить фото: {error}" "profilePhotoFailed": "Не удалось установить фото: {error}",
"reauthTitle": "Подтвердите, что это вы",
"reauthPinRequired": "Для этого действия нужен PIN-код. Сначала задайте его.",
"reauthSetPin": "Задать PIN",
"reauthReasonSeed": "Показать 24 слова для восстановления",
"reauthReasonBackupExport": "Экспорт резервной копии",
"reauthReasonBackupImport": "Восстановление из резервной копии",
"reauthReasonDecoyPin": "Изменение PIN-кода под принуждением",
"reauthReasonAutoLock": "Увеличение времени автоблокировки",
"reauthReasonScreenSecurity": "Отключение защиты экрана",
"reauthReasonDeleteAccount": "Удаление аккаунта и всех данных",
"reauthReasonBiometrics": "Включение биометрии",
"contactRequestName": "{name} (неизвестный)",
"pushMessageRequest": "Запрос на переписку от неизвестного отправителя",
"requestTitle": "Запрос на переписку",
"requestBody": "«{name}» нет в ваших контактах. Имя и фото отправитель выбрал сам, поэтому это может быть не тот, за кого он себя выдаёт. Пока вы не примете запрос, он не увидит, что вы прочитали сообщение, и не сможет вам позвонить.",
"requestLookalikeWarning": "Осторожно: так же зовут ваш контакт {name}. Это другой ключ, то есть другой телефон или другой человек. Прежде чем отвечать, проверьте это другим способом (позвоните, встретьтесь).",
"requestAccept": "Принять",
"requestAcceptTitle": "Принять как контакт",
"requestAcceptBody": "Назовите контакт сами. Имя, выбранное отправителем, автоматически не используется.",
"requestAcceptNameLabel": "Имя контакта",
"requestNameEmpty": "Введите имя.",
"requestNameTaken": "Так (или почти так) уже называется другой контакт. Выберите имя, которое нельзя перепутать.",
"noticeIdentityChanged": "Код безопасности контакта {name} изменился. По его адресу появился другой ключ: новый телефон, переустановка или подделка. Подтверждение отменено. Прежде чем отправлять что-то важное, сверьте код заново.",
"noticeLookalikeRequest": "Неизвестный отправитель назвался «{claimed}», что похоже на ваш контакт {name}. Это не он: сообщение пришло с другого ключа. Оно в чатах как запрос на переписку.",
"noticeVerifyAction": "Проверить код",
"noticePreview": "Предупреждение безопасности"
} }
@@ -3025,6 +3025,162 @@ abstract class AppLocalizations {
/// In cs, this message translates to: /// In cs, this message translates to:
/// **'Fotku se nepodařilo nastavit: {error}'** /// **'Fotku se nepodařilo nastavit: {error}'**
String profilePhotoFailed(String error); String profilePhotoFailed(String error);
/// Nadpis výzvy k PINu před citlivou akcí v odemčené appce
///
/// In cs, this message translates to:
/// **'Potvrď, že jsi to ty'**
String get reauthTitle;
/// Snackbar: citlivá akce bez nastaveného PINu
///
/// In cs, this message translates to:
/// **'Tahle akce potřebuje PIN. Nejdřív si ho nastav.'**
String get reauthPinRequired;
/// Akce snackbaru: nastavit PIN
///
/// In cs, this message translates to:
/// **'Nastavit PIN'**
String get reauthSetPin;
/// Důvod ve výzvě k PINu/biometrii: zobrazení obnovovací fráze
///
/// In cs, this message translates to:
/// **'Zobrazení 24 slov pro obnovu účtu'**
String get reauthReasonSeed;
/// Důvod: export zálohy
///
/// In cs, this message translates to:
/// **'Export zálohy'**
String get reauthReasonBackupExport;
/// Důvod: obnova ze zálohy
///
/// In cs, this message translates to:
/// **'Obnova ze zálohy'**
String get reauthReasonBackupImport;
/// Důvod: nastavení nebo zrušení nátlakového PINu
///
/// In cs, this message translates to:
/// **'Změna nátlakového PINu'**
String get reauthReasonDecoyPin;
/// Důvod: prodloužení nebo vypnutí automatického zámku
///
/// In cs, this message translates to:
/// **'Prodloužení automatického zámku'**
String get reauthReasonAutoLock;
/// Důvod: vypnutí ochrany obrazovky
///
/// In cs, this message translates to:
/// **'Vypnutí ochrany obrazovky'**
String get reauthReasonScreenSecurity;
/// Důvod: smazání účtu
///
/// In cs, this message translates to:
/// **'Smazání účtu a všech dat'**
String get reauthReasonDeleteAccount;
/// Důvod: zapnutí biometrického odemčení
///
/// In cs, this message translates to:
/// **'Zapnutí biometrie'**
String get reauthReasonBiometrics;
/// Jméno neznámého odesílatele (žádost o zprávu) všude v UI; {name} si zvolil sám
///
/// In cs, this message translates to:
/// **'{name} (neznámý)'**
String contactRequestName(String name);
/// Text notifikace pro zprávu od neznámého odesílatele — bez jména, které si zvolil
///
/// In cs, this message translates to:
/// **'Žádost o zprávu od neznámého odesílatele'**
String get pushMessageRequest;
/// Nadpis panelu / štítek žádosti o zprávu
///
/// In cs, this message translates to:
/// **'Žádost o zprávu'**
String get requestTitle;
/// Panel žádosti o zprávu; {name} = jméno, které si odesílatel zvolil
///
/// In cs, this message translates to:
/// **'„{name}“ není ve tvých kontaktech. Jméno i fotku si zvolil sám, takže nemusí jít o toho, za koho se vydává. Dokud ho nepřijmeš, neuvidí, že jsi zprávu četl, a nemůže ti volat.'**
String requestBody(String name);
/// Varování v panelu žádosti: jméno se plete s existujícím kontaktem {name}
///
/// In cs, this message translates to:
/// **'Pozor: stejně se jmenuje tvůj kontakt {name}. Tohle je jiný klíč, tedy jiný telefon nebo jiný člověk. Než odpovíš, ověř si to jinou cestou (zavolej, potkejte se).'**
String requestLookalikeWarning(String name);
/// Tlačítko: přijmout žádost o zprávu
///
/// In cs, this message translates to:
/// **'Přijmout'**
String get requestAccept;
/// Nadpis dialogu přijetí žádosti
///
/// In cs, this message translates to:
/// **'Přijmout jako kontakt'**
String get requestAcceptTitle;
/// Text dialogu přijetí: kontakt pojmenuje uživatel sám
///
/// In cs, this message translates to:
/// **'Pojmenuj kontakt sám. Jméno, které si odesílatel zvolil, se nepřebírá automaticky.'**
String get requestAcceptBody;
/// Popisek pole se jménem kontaktu
///
/// In cs, this message translates to:
/// **'Jméno kontaktu'**
String get requestAcceptNameLabel;
/// Chyba: prázdné jméno
///
/// In cs, this message translates to:
/// **'Zadej jméno.'**
String get requestNameEmpty;
/// Chyba: jméno se plete s jiným kontaktem
///
/// In cs, this message translates to:
/// **'Tak (nebo skoro tak) se už jmenuje jiný kontakt. Zvol jméno, které je nezamění.'**
String get requestNameTaken;
/// Upozornění v konverzaci: kontaktu {name} se změnil identity klíč
///
/// In cs, this message translates to:
/// **'Bezpečnostní kód kontaktu {name} se změnil. Pod jeho adresou se ozval jiný klíč: nový telefon, přeinstalace, nebo podvrh. Ověření je zrušené. Než pošleš něco citlivého, porovnejte kód znovu.'**
String noticeIdentityChanged(String name);
/// Upozornění v konverzaci existujícího kontaktu {name}: neznámý si zvolil podobné jméno {claimed}
///
/// In cs, this message translates to:
/// **'Neznámý odesílatel si dal jméno „{claimed}“, které se plete s kontaktem {name}. Tento kontakt to není, zpráva přišla z jiného klíče. Najdeš ji v chatech jako žádost o zprávu.'**
String noticeLookalikeRequest(String claimed, String name);
/// Tlačítko u upozornění na změnu kódu
///
/// In cs, this message translates to:
/// **'Ověřit kód'**
String get noticeVerifyAction;
/// Náhled v seznamu chatů, když je poslední položkou bezpečnostní upozornění
///
/// In cs, this message translates to:
/// **'Bezpečnostní upozornění'**
String get noticePreview;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
@@ -1726,4 +1726,95 @@ class AppLocalizationsCs extends AppLocalizations {
String profilePhotoFailed(String error) { String profilePhotoFailed(String error) {
return 'Fotku se nepodařilo nastavit: $error'; return 'Fotku se nepodařilo nastavit: $error';
} }
@override
String get reauthTitle => 'Potvrď, že jsi to ty';
@override
String get reauthPinRequired =>
'Tahle akce potřebuje PIN. Nejdřív si ho nastav.';
@override
String get reauthSetPin => 'Nastavit PIN';
@override
String get reauthReasonSeed => 'Zobrazení 24 slov pro obnovu účtu';
@override
String get reauthReasonBackupExport => 'Export zálohy';
@override
String get reauthReasonBackupImport => 'Obnova ze zálohy';
@override
String get reauthReasonDecoyPin => 'Změna nátlakového PINu';
@override
String get reauthReasonAutoLock => 'Prodloužení automatického zámku';
@override
String get reauthReasonScreenSecurity => 'Vypnutí ochrany obrazovky';
@override
String get reauthReasonDeleteAccount => 'Smazání účtu a všech dat';
@override
String get reauthReasonBiometrics => 'Zapnutí biometrie';
@override
String contactRequestName(String name) {
return '$name (neznámý)';
}
@override
String get pushMessageRequest => 'Žádost o zprávu od neznámého odesílatele';
@override
String get requestTitle => 'Žádost o zprávu';
@override
String requestBody(String name) {
return '„$name“ není ve tvých kontaktech. Jméno i fotku si zvolil sám, takže nemusí jít o toho, za koho se vydává. Dokud ho nepřijmeš, neuvidí, že jsi zprávu četl, a nemůže ti volat.';
}
@override
String requestLookalikeWarning(String name) {
return 'Pozor: stejně se jmenuje tvůj kontakt $name. Tohle je jiný klíč, tedy jiný telefon nebo jiný člověk. Než odpovíš, ověř si to jinou cestou (zavolej, potkejte se).';
}
@override
String get requestAccept => 'Přijmout';
@override
String get requestAcceptTitle => 'Přijmout jako kontakt';
@override
String get requestAcceptBody =>
'Pojmenuj kontakt sám. Jméno, které si odesílatel zvolil, se nepřebírá automaticky.';
@override
String get requestAcceptNameLabel => 'Jméno kontaktu';
@override
String get requestNameEmpty => 'Zadej jméno.';
@override
String get requestNameTaken =>
'Tak (nebo skoro tak) se už jmenuje jiný kontakt. Zvol jméno, které je nezamění.';
@override
String noticeIdentityChanged(String name) {
return 'Bezpečnostní kód kontaktu $name se změnil. Pod jeho adresou se ozval jiný klíč: nový telefon, přeinstalace, nebo podvrh. Ověření je zrušené. Než pošleš něco citlivého, porovnejte kód znovu.';
}
@override
String noticeLookalikeRequest(String claimed, String name) {
return 'Neznámý odesílatel si dal jméno „$claimed“, které se plete s kontaktem $name. Tento kontakt to není, zpráva přišla z jiného klíče. Najdeš ji v chatech jako žádost o zprávu.';
}
@override
String get noticeVerifyAction => 'Ověřit kód';
@override
String get noticePreview => 'Bezpečnostní upozornění';
} }
@@ -1748,4 +1748,96 @@ class AppLocalizationsDe extends AppLocalizations {
String profilePhotoFailed(String error) { String profilePhotoFailed(String error) {
return 'Foto konnte nicht gesetzt werden: $error'; return 'Foto konnte nicht gesetzt werden: $error';
} }
@override
String get reauthTitle => 'Bestätige, dass du es bist';
@override
String get reauthPinRequired =>
'Diese Aktion erfordert eine PIN. Lege zuerst eine fest.';
@override
String get reauthSetPin => 'PIN festlegen';
@override
String get reauthReasonSeed => '24 Wiederherstellungswörter anzeigen';
@override
String get reauthReasonBackupExport => 'Backup exportieren';
@override
String get reauthReasonBackupImport => 'Aus Backup wiederherstellen';
@override
String get reauthReasonDecoyPin => 'Notfall-PIN ändern';
@override
String get reauthReasonAutoLock => 'Automatische Sperre verlängern';
@override
String get reauthReasonScreenSecurity => 'Bildschirmschutz ausschalten';
@override
String get reauthReasonDeleteAccount => 'Konto und alle Daten löschen';
@override
String get reauthReasonBiometrics => 'Biometrie einschalten';
@override
String contactRequestName(String name) {
return '$name (unbekannt)';
}
@override
String get pushMessageRequest =>
'Nachrichtenanfrage von unbekanntem Absender';
@override
String get requestTitle => 'Nachrichtenanfrage';
@override
String requestBody(String name) {
return '„$name“ ist nicht in deinen Kontakten. Name und Foto hat die Person selbst gewählt, sie ist also vielleicht nicht, wer sie vorgibt zu sein. Bis du annimmst, sieht sie nicht, dass du die Nachricht gelesen hast, und kann dich nicht anrufen.';
}
@override
String requestLookalikeWarning(String name) {
return 'Achtung: Dein Kontakt $name heißt genauso. Das ist ein anderer Schlüssel, also ein anderes Telefon oder eine andere Person. Prüfe das auf anderem Weg, bevor du antwortest (anrufen, persönlich treffen).';
}
@override
String get requestAccept => 'Annehmen';
@override
String get requestAcceptTitle => 'Als Kontakt annehmen';
@override
String get requestAcceptBody =>
'Benenne den Kontakt selbst. Der vom Absender gewählte Name wird nicht automatisch übernommen.';
@override
String get requestAcceptNameLabel => 'Kontaktname';
@override
String get requestNameEmpty => 'Gib einen Namen ein.';
@override
String get requestNameTaken =>
'Ein anderer Kontakt heißt schon so (oder fast so). Wähle einen Namen, der nicht verwechselt werden kann.';
@override
String noticeIdentityChanged(String name) {
return 'Der Sicherheitscode von $name hat sich geändert. Unter dieser Adresse meldet sich ein anderer Schlüssel: neues Telefon, Neuinstallation oder Fälschung. Die Verifizierung wurde aufgehoben. Vergleicht den Code erneut, bevor du etwas Sensibles sendest.';
}
@override
String noticeLookalikeRequest(String claimed, String name) {
return 'Ein unbekannter Absender nennt sich „$claimed“, was nach deinem Kontakt $name aussieht. Es ist nicht dieser Kontakt, die Nachricht kam von einem anderen Schlüssel. Du findest sie in den Chats als Nachrichtenanfrage.';
}
@override
String get noticeVerifyAction => 'Code prüfen';
@override
String get noticePreview => 'Sicherheitshinweis';
} }
@@ -1720,4 +1720,94 @@ class AppLocalizationsEn extends AppLocalizations {
String profilePhotoFailed(String error) { String profilePhotoFailed(String error) {
return 'Could not set photo: $error'; return 'Could not set photo: $error';
} }
@override
String get reauthTitle => 'Confirm it\'s you';
@override
String get reauthPinRequired => 'This action needs a PIN. Set one first.';
@override
String get reauthSetPin => 'Set PIN';
@override
String get reauthReasonSeed => 'Show the 24 recovery words';
@override
String get reauthReasonBackupExport => 'Export backup';
@override
String get reauthReasonBackupImport => 'Restore from backup';
@override
String get reauthReasonDecoyPin => 'Change the duress PIN';
@override
String get reauthReasonAutoLock => 'Extend the auto-lock';
@override
String get reauthReasonScreenSecurity => 'Turn off screen security';
@override
String get reauthReasonDeleteAccount => 'Delete the account and all data';
@override
String get reauthReasonBiometrics => 'Turn on biometrics';
@override
String contactRequestName(String name) {
return '$name (unknown)';
}
@override
String get pushMessageRequest => 'Message request from an unknown sender';
@override
String get requestTitle => 'Message request';
@override
String requestBody(String name) {
return '“$name” is not in your contacts. They chose the name and photo themselves, so they may not be who they claim to be. Until you accept, they can\'t see that you read the message and can\'t call you.';
}
@override
String requestLookalikeWarning(String name) {
return 'Careful: your contact $name has the same name. This is a different key, so a different phone or a different person. Check another way before you reply (call, meet in person).';
}
@override
String get requestAccept => 'Accept';
@override
String get requestAcceptTitle => 'Accept as contact';
@override
String get requestAcceptBody =>
'Name the contact yourself. The name the sender chose is not taken over automatically.';
@override
String get requestAcceptNameLabel => 'Contact name';
@override
String get requestNameEmpty => 'Enter a name.';
@override
String get requestNameTaken =>
'Another contact already has this (or a very similar) name. Choose one that can\'t be confused.';
@override
String noticeIdentityChanged(String name) {
return 'The safety code of $name has changed. A different key showed up at their address: a new phone, a reinstall, or an impostor. Verification has been removed. Compare the code again before you send anything sensitive.';
}
@override
String noticeLookalikeRequest(String claimed, String name) {
return 'An unknown sender named themselves “$claimed”, which looks like your contact $name. It is not this contact, the message came from a different key. You\'ll find it in your chats as a message request.';
}
@override
String get noticeVerifyAction => 'Verify code';
@override
String get noticePreview => 'Security notice';
} }
@@ -1735,4 +1735,96 @@ class AppLocalizationsIt extends AppLocalizations {
String profilePhotoFailed(String error) { String profilePhotoFailed(String error) {
return 'Impossibile impostare la foto: $error'; return 'Impossibile impostare la foto: $error';
} }
@override
String get reauthTitle => 'Conferma che sei tu';
@override
String get reauthPinRequired =>
'Questa azione richiede un PIN. Impostalo prima.';
@override
String get reauthSetPin => 'Imposta PIN';
@override
String get reauthReasonSeed => 'Mostra le 24 parole di recupero';
@override
String get reauthReasonBackupExport => 'Esporta backup';
@override
String get reauthReasonBackupImport => 'Ripristina da backup';
@override
String get reauthReasonDecoyPin => 'Modifica il PIN di emergenza';
@override
String get reauthReasonAutoLock => 'Prolunga il blocco automatico';
@override
String get reauthReasonScreenSecurity => 'Disattiva la protezione schermo';
@override
String get reauthReasonDeleteAccount => 'Elimina l\'account e tutti i dati';
@override
String get reauthReasonBiometrics => 'Attiva la biometria';
@override
String contactRequestName(String name) {
return '$name (sconosciuto)';
}
@override
String get pushMessageRequest =>
'Richiesta di messaggio da un mittente sconosciuto';
@override
String get requestTitle => 'Richiesta di messaggio';
@override
String requestBody(String name) {
return '«$name» non è tra i tuoi contatti. Nome e foto li ha scelti da sé, quindi potrebbe non essere chi dice di essere. Finché non accetti, non vede che hai letto il messaggio e non può chiamarti.';
}
@override
String requestLookalikeWarning(String name) {
return 'Attenzione: il tuo contatto $name ha lo stesso nome. Questa è una chiave diversa, quindi un altro telefono o un\'altra persona. Verifica in un altro modo prima di rispondere (chiama, incontratevi).';
}
@override
String get requestAccept => 'Accetta';
@override
String get requestAcceptTitle => 'Accetta come contatto';
@override
String get requestAcceptBody =>
'Dai tu un nome al contatto. Il nome scelto dal mittente non viene ripreso automaticamente.';
@override
String get requestAcceptNameLabel => 'Nome del contatto';
@override
String get requestNameEmpty => 'Inserisci un nome.';
@override
String get requestNameTaken =>
'Un altro contatto ha già questo nome (o quasi). Scegline uno che non si possa confondere.';
@override
String noticeIdentityChanged(String name) {
return 'Il codice di sicurezza di $name è cambiato. Al suo indirizzo si è presentata una chiave diversa: nuovo telefono, reinstallazione o un impostore. La verifica è stata annullata. Confrontate di nuovo il codice prima di inviare qualcosa di sensibile.';
}
@override
String noticeLookalikeRequest(String claimed, String name) {
return 'Un mittente sconosciuto si è chiamato «$claimed», un nome che si confonde con il tuo contatto $name. Non è questo contatto, il messaggio è arrivato da una chiave diversa. Lo trovi nelle chat come richiesta di messaggio.';
}
@override
String get noticeVerifyAction => 'Verifica codice';
@override
String get noticePreview => 'Avviso di sicurezza';
} }
@@ -1761,4 +1761,96 @@ class AppLocalizationsRu extends AppLocalizations {
String profilePhotoFailed(String error) { String profilePhotoFailed(String error) {
return 'Не удалось установить фото: $error'; return 'Не удалось установить фото: $error';
} }
@override
String get reauthTitle => 'Подтвердите, что это вы';
@override
String get reauthPinRequired =>
'Для этого действия нужен PIN-код. Сначала задайте его.';
@override
String get reauthSetPin => 'Задать PIN';
@override
String get reauthReasonSeed => 'Показать 24 слова для восстановления';
@override
String get reauthReasonBackupExport => 'Экспорт резервной копии';
@override
String get reauthReasonBackupImport => 'Восстановление из резервной копии';
@override
String get reauthReasonDecoyPin => 'Изменение PIN-кода под принуждением';
@override
String get reauthReasonAutoLock => 'Увеличение времени автоблокировки';
@override
String get reauthReasonScreenSecurity => 'Отключение защиты экрана';
@override
String get reauthReasonDeleteAccount => 'Удаление аккаунта и всех данных';
@override
String get reauthReasonBiometrics => 'Включение биометрии';
@override
String contactRequestName(String name) {
return '$name (неизвестный)';
}
@override
String get pushMessageRequest =>
'Запрос на переписку от неизвестного отправителя';
@override
String get requestTitle => 'Запрос на переписку';
@override
String requestBody(String name) {
return '«$name» нет в ваших контактах. Имя и фото отправитель выбрал сам, поэтому это может быть не тот, за кого он себя выдаёт. Пока вы не примете запрос, он не увидит, что вы прочитали сообщение, и не сможет вам позвонить.';
}
@override
String requestLookalikeWarning(String name) {
return 'Осторожно: так же зовут ваш контакт $name. Это другой ключ, то есть другой телефон или другой человек. Прежде чем отвечать, проверьте это другим способом (позвоните, встретьтесь).';
}
@override
String get requestAccept => 'Принять';
@override
String get requestAcceptTitle => 'Принять как контакт';
@override
String get requestAcceptBody =>
'Назовите контакт сами. Имя, выбранное отправителем, автоматически не используется.';
@override
String get requestAcceptNameLabel => 'Имя контакта';
@override
String get requestNameEmpty => 'Введите имя.';
@override
String get requestNameTaken =>
'Так (или почти так) уже называется другой контакт. Выберите имя, которое нельзя перепутать.';
@override
String noticeIdentityChanged(String name) {
return 'Код безопасности контакта $name изменился. По его адресу появился другой ключ: новый телефон, переустановка или подделка. Подтверждение отменено. Прежде чем отправлять что-то важное, сверьте код заново.';
}
@override
String noticeLookalikeRequest(String claimed, String name) {
return 'Неизвестный отправитель назвался «$claimed», что похоже на ваш контакт $name. Это не он: сообщение пришло с другого ключа. Оно в чатах как запрос на переписку.';
}
@override
String get noticeVerifyAction => 'Проверить код';
@override
String get noticePreview => 'Предупреждение безопасности';
} }
+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);
+15 -2
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;
PushNotifier.showMessageNotification(contact.displayName); // 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);
} else if (convId != contact.id) {
PushNotifier.showWakeNotification();
} else {
PushNotifier.showMessageRequestNotification();
}
}; };
service.onCallSignal = (contact, signal) => service.onCallSignal = (contact, signal) =>
+14
View File
@@ -83,6 +83,20 @@ class ScreenSecurityNotifier extends AsyncNotifier<bool> {
state = AsyncData(value); state = AsyncData(value);
} }
/// Po dobu [body] drží ochranu zapnutou bez ohledu na nastavení a pak
/// vrátí uložený stav. Pro 24 slov: vypnutý přepínač nesmí pustit frázi
/// do screenshotu ani do náhledu v přepínači aplikací. Ladicí build
/// výjimku má stejně jako MainActivity — QA potřebuje snímky z emulátoru.
Future<T> enforceWhile<T>(Future<T> Function() body) async {
if (kDebugMode) return body();
await _apply(true);
try {
return await body();
} finally {
await _apply(state.valueOrNull ?? vychozi);
}
}
Future<void> _apply(bool on) async { Future<void> _apply(bool on) async {
try { try {
if (on) { if (on) {
+9 -1
View File
@@ -99,12 +99,20 @@ class SepotAvatar extends ConsumerWidget {
); );
} }
static final _leadingPunct = RegExp(r'^\p{P}', unicode: true);
/// Iniciály pro avatar — veřejné, ať je sdílí i kolečka hlasujících apod. /// Iniciály pro avatar — veřejné, ať je sdílí i kolečka hlasujících apod.
static String initialsOf(String name) { static String initialsOf(String name) {
// Prázdné díly odfiltrovat — dvojitá mezera ve jméně nesmí shodit render. // Prázdné díly odfiltrovat — dvojitá mezera ve jméně nesmí shodit render.
// Grafémy (characters), ne code units — jméno začínající emoji by se // Grafémy (characters), ne code units — jméno začínající emoji by se
// jinak rozpůlilo na rozbitý surrogate. // jinak rozpůlilo na rozbitý surrogate.
final parts = name.trim().split(' ').where((p) => p.isNotEmpty).toList(); // Slova začínající interpunkcí přeskočit: přívlastek „(neznámý)“ u žádosti
// o zprávu by jinak dal iniciály „M(“.
final parts = name
.trim()
.split(' ')
.where((p) => p.isNotEmpty && !_leadingPunct.hasMatch(p))
.toList();
if (parts.length >= 2) { if (parts.length >= 2) {
return '${parts[0].characters.first}${parts[1].characters.first}' return '${parts[0].characters.first}${parts[1].characters.first}'
.toUpperCase(); .toUpperCase();
+1 -1
View File
@@ -1,7 +1,7 @@
name: sepot name: sepot
description: "Šepot — Soukromý messenger s E2E šifrováním" description: "Šepot — Soukromý messenger s E2E šifrováním"
publish_to: 'none' publish_to: 'none'
version: 1.2.2+25 version: 1.2.3+26
environment: environment:
sdk: ^3.10.8 sdk: ^3.10.8
+65
View File
@@ -0,0 +1,65 @@
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';
import 'package:sepot/widgets/avatar.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');
});
test('přívlastek žádosti v závorce nekazí iniciály avataru', () {
expect(SepotAvatar.initialsOf('Máma (neznámý)'), 'MÁ');
expect(SepotAvatar.initialsOf('Jan Novák (unknown)'), 'JN');
expect(SepotAvatar.initialsOf('(neznámý)'), '?');
});
}
+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);
});
});
}
+130
View File
@@ -0,0 +1,130 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/push/push_service.dart';
import 'package:sepot/core/services/messaging_service.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/proto/sepot.pb.dart' as pb;
/// iPhony relayi neposílaly APNs ani VoIP token: init lokálních notifikací
/// na iOS házel výjimku, ta zastavila registraci u Apple a restoreMode ji
/// spolkl. Tady se hlídá pořadí kroků a skládání registrace bez iPhonu.
class _FakeMessaging extends MessagingService {
_FakeMessaging() : super(storage: SecureStorageService());
final registrations = <pb.RegisterPush>[];
@override
void registerPush(pb.RegisterPush registration) =>
registrations.add(registration);
}
class _FakeIos extends IosPushPlatform {
_FakeIos({
this.apns,
this.voip,
this.permissionFails = false,
this.registerFails = false,
});
final String? apns;
final String? voip;
final bool permissionFails;
final bool registerFails;
final calls = <String>[];
bool? failedFlag;
String? mode;
@override
bool get isIOS => true;
@override
Future<void> registerApns() async {
calls.add('register');
if (registerFails) throw StateError('apns channel');
}
@override
Future<String?> apnsToken() async => apns;
@override
Future<String?> voipToken() async => voip;
@override
Future<void> requestPermission() async {
calls.add('permission');
if (permissionFails) {
throw ArgumentError('iOS settings must be set');
}
}
@override
Future<void> writeMode(String m) async => mode = m;
@override
Future<void> setRegistrationFailed(bool failed) async => failedFlag = failed;
}
void main() {
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
String text(List<int> bytes) => utf8.decode(bytes);
group('apnsRegistration', () {
test('APNs i VoIP v jedné registraci', () {
final reg = PushService.apnsRegistration(
apnsToken: 'ab12',
voipToken: 'cd34',
)!;
expect(reg.platform, pb.RegisterPush_Platform.PLATFORM_APNS);
expect(text(reg.endpoint), 'ab12');
expect(text(reg.voipToken), 'cd34');
});
test('samotný VoIP token jde s prázdným endpointem', () {
final reg = PushService.apnsRegistration(voipToken: 'cd34')!;
expect(reg.platform, pb.RegisterPush_Platform.PLATFORM_APNS);
expect(reg.endpoint, isEmpty);
expect(text(reg.voipToken), 'cd34');
});
test('bez tokenů se nic neposílá', () {
expect(PushService.apnsRegistration(), isNull);
});
});
test(
'selhané oprávnění nezastaví registraci u Apple ani token pro relay',
() async {
final messaging = _FakeMessaging();
final ios = _FakeIos(apns: 'ab12', voip: 'cd34', permissionFails: true);
await PushService(messaging, ios: ios).restoreMode();
expect(ios.calls, [
'register',
'permission',
], reason: 'registrace u Apple před dotazem na oprávnění');
expect(ios.mode, 'apns');
expect(ios.failedFlag, isFalse);
final reg = messaging.registrations.single;
expect(text(reg.endpoint), 'ab12');
expect(text(reg.voipToken), 'cd34');
},
);
test('VoIP token odejde, i když APNs token ještě nepřišel', () async {
final messaging = _FakeMessaging();
final push = PushService(messaging, ios: _FakeIos(voip: 'cd34'));
await push.refreshVoipToken();
final reg = messaging.registrations.single;
expect(reg.endpoint, isEmpty);
expect(text(reg.voipToken), 'cd34');
});
test('selhaná registrace se nespolkne — stav pushů ukáže chybu', () async {
final ios = _FakeIos(registerFails: true);
await PushService(_FakeMessaging(), ios: ios).restoreMode();
expect(ios.failedFlag, isTrue);
});
}
+68
View File
@@ -0,0 +1,68 @@
// Potřebuje nativní sqlcipher a Argon2id — na builderu se vynechává (--exclude-tags ffi).
@Tags(['ffi'])
// Argon2id (64 MiB) ~2 s na pokus, test jich dělá přes deset.
@Timeout(Duration(minutes: 3))
library;
import 'dart:io';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/storage/secure_storage.dart';
/// Opětovné ověření PINu v odemčené appce (fráze, záloha, smazání účtu):
/// stejná brzda jako zámek, nátlakový PIN ani nátlaková relace nic neodemknou.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tmp;
late SecureStorageService storage;
setUpAll(() async => SepotCrypto.init());
setUp(() async {
FlutterSecureStorage.setMockInitialValues({});
tmp = Directory.systemTemp.createTempSync('sepot_verify_pin');
storage = SecureStorageService(dbPath: '${tmp.path}/app.db');
await storage.unlockWithoutPin();
await storage.enablePin('111111');
await storage.enableDecoyPin('222222');
});
tearDown(() {
storage.lock();
tmp.deleteSync(recursive: true);
});
test('skutečný PIN projde, nátlakový ne', () async {
expect(await storage.verifyPin('111111'), isTrue);
expect(await storage.verifyPin('222222'), isFalse);
});
test('v nátlakové relaci neprojde ani skutečný PIN', () async {
storage.lock();
await storage.unlockWithPin('222222');
expect(storage.isDecoyMode, isTrue);
expect(await storage.verifyPin('111111'), isFalse);
expect(await storage.loadSeedPhrase(), isNull);
});
test('po pěti chybách zámek, hádání se nedá obejít', () async {
for (var i = 0; i < 5; i++) {
expect(await storage.verifyPin('99999$i'), isFalse);
}
expect(await storage.pinLockRemaining(), isNotNull);
expect(() => storage.verifyPin('111111'),
throwsA(isA<PinLockedException>()),
reason: 'ani správný PIN během zámku');
});
test('správný PIN vynuluje počítadlo chyb', () async {
for (var i = 0; i < 4; i++) {
await storage.verifyPin('99999$i');
}
expect(await storage.verifyPin('111111'), isTrue);
expect(await storage.verifyPin('999990'), isFalse);
expect(await storage.pinLockRemaining(), isNull);
});
}
+196
View File
@@ -0,0 +1,196 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/features/auth/reauth.dart';
import 'package:sepot/features/settings/settings_screen.dart';
import 'package:sepot/l10n/generated/app_localizations.dart';
import 'package:sepot/models/identity.dart';
import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/widgets/pin_keypad.dart';
/// 24 slov z odemčené appky jen po čerstvém PINu nebo biometrii; v nátlakové
/// relaci vůbec. Úložiště je podvržené — Argon2id a keystore testuje
/// `test/storage/verify_pin_test.dart`.
class _FakeStorage extends SecureStorageService {
_FakeStorage({this.pin = true, this.bio = false, this.decoy = false});
final bool pin;
final bool bio;
final bool decoy;
int seedReads = 0;
static const correctPin = '123456';
static final words = List.generate(24, (i) => 'slovo$i');
@override
bool get isDecoyMode => decoy;
@override
Future<bool> hasPin() async => pin;
@override
Future<bool> hasDecoyPin() async => false;
@override
Future<bool> isBiometricsEnabled() async => bio;
@override
Future<Duration?> pinLockRemaining() async => null;
@override
Future<bool> verifyPin(String pin) async => pin == correctPin;
@override
Future<List<String>?> loadSeedPhrase() async {
seedReads++;
return decoy ? null : words;
}
}
class _FakeIdentityNotifier extends IdentityNotifier {
@override
Future<LocalIdentity?> build() async => LocalIdentity(
displayName: 'Alice',
identityPrivateKey: Uint8List(32),
identityPublicKey: Uint8List(33),
createdAt: DateTime.utc(2026, 1, 1),
);
}
void main() {
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
Future<_FakeStorage> pump(
WidgetTester tester, {
bool pin = true,
bool bio = false,
bool bioResult = false,
bool decoy = false,
}) async {
final storage = _FakeStorage(pin: pin, bio: bio, decoy: decoy);
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2.5;
addTearDown(tester.view.reset);
await tester.pumpWidget(ProviderScope(
overrides: [
storageProvider.overrideWithValue(storage),
identityProvider.overrideWith(_FakeIdentityNotifier.new),
biometricPromptProvider.overrideWithValue((_) async => bioResult),
],
child: MaterialApp(
locale: const Locale('cs'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const SettingsScreen(),
),
));
await tester.pumpAndSettle();
return storage;
}
Future<void> openSeed(WidgetTester tester) async {
final tile = find.text('Zálohovací fráze');
await tester.scrollUntilVisible(tile, 200,
scrollable: find.byType(Scrollable).first);
await tester.tap(tile);
await tester.pumpAndSettle();
}
Future<void> typePin(WidgetTester tester, String pin) async {
for (final d in pin.split('')) {
await tester.tap(find.descendant(
of: find.byType(PinKeypad), matching: find.text(d)));
await tester.pump();
}
await tester.pumpAndSettle();
}
final firstWord = find.textContaining('slovo0');
testWidgets('fráze se bez PINu neukáže ani nenačte', (tester) async {
final storage = await pump(tester);
await openSeed(tester);
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
expect(firstWord, findsNothing);
expect(storage.seedReads, 0, reason: 'z keystore až po ověření');
await tester.tap(find.text('Zrušit'));
await tester.pumpAndSettle();
expect(firstWord, findsNothing);
expect(storage.seedReads, 0);
});
testWidgets('špatný PIN frázi neukáže', (tester) async {
final storage = await pump(tester);
await openSeed(tester);
await typePin(tester, '000000');
expect(find.text('Špatný PIN.'), findsOneWidget);
expect(firstWord, findsNothing);
expect(storage.seedReads, 0);
});
testWidgets('správný PIN frázi ukáže', (tester) async {
final storage = await pump(tester);
await openSeed(tester);
await typePin(tester, _FakeStorage.correctPin);
expect(firstWord, findsOneWidget);
expect(storage.seedReads, 1);
});
testWidgets('každé zobrazení chce PIN znovu', (tester) async {
await pump(tester);
await openSeed(tester);
await typePin(tester, _FakeStorage.correctPin);
await tester.tap(find.text('Zavřít'));
await tester.pumpAndSettle();
await openSeed(tester);
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
expect(firstWord, findsNothing);
});
testWidgets('úspěšná biometrie stačí', (tester) async {
await pump(tester, bio: true, bioResult: true);
await openSeed(tester);
expect(firstWord, findsOneWidget);
});
testWidgets('zrušená biometrie spadne na PIN', (tester) async {
await pump(tester, bio: true, bioResult: false);
await openSeed(tester);
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
expect(firstWord, findsNothing);
});
testWidgets('bez nastaveného PINu fráze nejde', (tester) async {
final storage = await pump(tester, pin: false);
await openSeed(tester);
expect(find.textContaining('Nejdřív si ho nastav'), findsOneWidget);
expect(firstWord, findsNothing);
expect(storage.seedReads, 0);
});
testWidgets('nátlaková relace frázi vůbec nenabízí', (tester) async {
await pump(tester, decoy: true);
expect(find.text('Zálohovací fráze', skipOffstage: false), findsNothing);
});
testWidgets('smazání účtu bez PINu neproběhne', (tester) async {
await pump(tester);
final button = find.text('Smazat všechna data');
await tester.scrollUntilVisible(button, 200,
scrollable: find.byType(Scrollable).first);
await tester.tap(button);
await tester.pumpAndSettle();
await tester.tap(find.byType(ElevatedButton).last);
await tester.pumpAndSettle();
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
await typePin(tester, '999999');
expect(find.text('Špatný PIN.'), findsOneWidget);
});
}
+25
View File
@@ -0,0 +1,25 @@
# Pinned native crypto library (libsignal_frb) shipped in the Android app.
#
# The Dart package `libsignal` downloads a prebuilt `libsignal_frb` from the
# GitHub releases of djx-y-z/libsignal_dart during the build. Its own check
# only compares against a checksum file from the same release, so a replaced
# release would go unnoticed. These pins live in our repository instead and
# tool/verify_native_libs.sh fails the release build on any mismatch.
#
# package = version of the `libsignal` Dart package in pubspec.lock
# archive = SHA-256 of the upstream release archive
# https://github.com/djx-y-z/libsignal_dart/releases/tag/libsignal_frb-4.0.9
# shipped = SHA-256 of liblibsignal_frb.so inside our AAB/APK. The Android
# Gradle plugin strips the library while packaging; the value equals
# `llvm-strip --strip-unneeded` (NDK 28) applied to the file from the
# archive. `verify_native_libs.sh --from-upstream` re-derives it.
#
# Updating libsignal = update every line here in the same commit.
package libsignal 5.0.9
archive android-arm64-v8a b03c1f47b07ded392a9a44b217b3d24470f0dce69901c67061c1f1295f96065f
archive android-armeabi-v7a 0dc16fbbfe2bf5b3e7758fd2b297c831fee296c55ac19b4f94a9e0086d26b9b0
archive android-x86_64 cdb1623a855fa508dd84c025b267eaac846f882637450f00a74199bdf2d85264
shipped arm64-v8a 39612f422ed1dc2c7576165c5c169d7911f64de093eeea4ee8d3047b884c1c0a
shipped armeabi-v7a b442795a864fe0ae15a1e04a151863244fedda3b8854b2d22ec9b23e59227e78
shipped x86_64 59e5925c2d6f5e9496cbe86d1a7d19965dc4060efe94cd452d58ba651fe7a122
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Checks the libsignal native library against the pins in native_libs.sha256.
#
# verify_native_libs.sh <app.aab|app.apk> every ABI in the artifact must carry
# exactly the pinned library
# verify_native_libs.sh --from-upstream download the upstream archives, check
# their pins and re-derive the shipped
# hashes (needs llvm-strip from the NDK)
#
# Exit code 0 = match, anything else = do not ship.
set -euo pipefail
TOOL_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PINS="$TOOL_DIR/native_libs.sha256"
LOCK="$TOOL_DIR/../pubspec.lock"
LIB=liblibsignal_frb.so
die() { echo "verify_native_libs: $*" >&2; exit 1; }
pin() { awk -v k="$1" -v n="$2" '$1 == k && $2 == n { print $3 }' "$PINS"; }
pinned_package=$(pin package libsignal)
[ -n "$pinned_package" ] || die "no package line in $PINS"
# A libsignal upgrade without new pins must stop here with a clear reason,
# not later as an unexplained hash mismatch.
if [ -f "$LOCK" ]; then
locked=$(awk '/^ libsignal:$/ { f = 1 } f && /^ version:/ { gsub(/"/, "", $2); print $2; exit }' "$LOCK")
[ "$locked" = "$pinned_package" ] ||
die "pubspec.lock has libsignal $locked, pins are for $pinned_package — update $PINS"
fi
sha() { sha256sum "$1" | cut -d' ' -f1; }
verify_artifact() {
local artifact=$1 prefix entries abi want got checked=0
[ -f "$artifact" ] || die "file not found: $artifact"
case "$artifact" in
*.aab) prefix=base/lib ;;
*.apk) prefix=lib ;;
*) die "expected .aab or .apk: $artifact" ;;
esac
entries=$(unzip -Z1 "$artifact" | grep -E "^$prefix/[^/]+/$LIB\$" || true)
[ -n "$entries" ] || die "$LIB not found in $artifact"
for entry in $entries; do
abi=$(basename "$(dirname "$entry")")
want=$(pin shipped "$abi")
[ -n "$want" ] || die "no pin for ABI $abi"
got=$(unzip -p "$artifact" "$entry" | sha256sum | cut -d' ' -f1)
[ "$got" = "$want" ] || die "$abi: $got does not match pinned $want"
echo "ok $abi $got"
checked=$((checked + 1))
done
# Every pinned ABI has to be present: a missing one would crash on those devices.
for abi in $(awk '$1 == "shipped" { print $2 }' "$PINS"); do
grep -q "^$prefix/$abi/$LIB\$" <<<"$entries" || die "$abi missing in $artifact"
done
echo "verify_native_libs: $checked libraries match the pins"
}
from_upstream() {
local strip=${LLVM_STRIP:-} work version url abi want got
if [ -z "$strip" ]; then
strip=$(ls "${ANDROID_HOME:-/opt/android-sdk}"/ndk/*/toolchains/llvm/prebuilt/*/bin/llvm-strip 2>/dev/null | sort -V | tail -1)
fi
[ -x "$strip" ] || die "llvm-strip not found, set LLVM_STRIP"
# Archive names carry the version of the native crate, not of the Dart package.
version=$(sed -n 's/.*releases\/tag\/libsignal_frb-\([0-9.]*\).*/\1/p' "$PINS" | head -1)
[ -n "$version" ] || die "no release tag in $PINS"
url="https://github.com/djx-y-z/libsignal_dart/releases/download/libsignal_frb-$version"
work=$(mktemp -d); trap 'rm -rf "$work"' RETURN
for abi in $(awk '$1 == "shipped" { print $2 }' "$PINS"); do
curl -fsSL -o "$work/$abi.tar.gz" "$url/libsignal_frb-$version-android-$abi.tar.gz"
want=$(pin archive "android-$abi"); got=$(sha "$work/$abi.tar.gz")
[ "$got" = "$want" ] || die "archive android-$abi: $got does not match pinned $want"
mkdir "$work/$abi" && tar -xzf "$work/$abi.tar.gz" -C "$work/$abi" "$LIB"
"$strip" --strip-unneeded "$work/$abi/$LIB"
want=$(pin shipped "$abi"); got=$(sha "$work/$abi/$LIB")
[ "$got" = "$want" ] || die "stripped $abi: $got does not match pinned $want"
echo "ok $abi archive and stripped library match"
done
}
case "${1:-}" in
--from-upstream) from_upstream ;;
""|-h|--help) sed -n '2,10p' "$0"; exit 64 ;;
*) verify_artifact "$1" ;;
esac
+149
View File
@@ -0,0 +1,149 @@
# Verifying Šepot
This document lists what you can check yourself today, how to do it, and what
cannot be checked yet. If something here does not match what you observe,
please report it to security@sepot.cz.
## 1. Which relay code is running
The relay announces its source and revision:
```sh
curl -s https://relay.sepot.cz/v1/server-info
# {"source":"https://github.com/juliusjoska/sepot","revision":"<commit>", ...}
```
The revision is baked into the container image at build time
(`deploy/relay/Dockerfile`, build argument `GIT_SHA`, also stored as the OCI
label `org.opencontainers.image.revision`). To read the code behind it:
```sh
git clone https://github.com/juliusjoska/sepot && cd sepot
git show --stat <commit>
git log --oneline <commit> -- rust/ proto/ deploy/relay/
```
**Current state (October 2026).** The relay deployed on 9 October 2026 reports
`4b3f4bb`. That is a commit of our private development repository, so GitHub
does not know it. The public commit with the same relay code is
[`b824f62`](https://github.com/juliusjoska/sepot/commit/b824f628788afa3e1a3b64fa90de1fc7bf50feaf).
The two trees differ only in source comments, test fixture IP addresses in
`push.rs`, the `repository` URL in `rust/Cargo.toml`, and migration
`0001_init.sql`, which in the private tree already contained the
`attempt_count` column that `0003_queue_attempt_count.sql` adds with
`ADD COLUMN IF NOT EXISTS`. The resulting schema and behaviour are the same.
**From the next relay deployment on**, images are built from this public
repository only. The build script refuses a commit that is not on `main` here
and not yet mirrored to GitHub, and refuses to build when the relay sources in
the public and the development repository differ. The reported revision is
then a full commit hash you can open on GitHub.
What this does *not* prove: the revision is reported by the server itself.
There is no remote attestation, so you are trusting us that the binary was
built from that commit. The relay is designed so that this trust matters as
little as possible: message content is end-to-end encrypted on your device and
the relay never holds the keys.
## 2. Android APK
Direct downloads are attached to the
[GitHub releases](https://github.com/juliusjoska/sepot/releases), together
with a `SHA256SUMS` file.
Since 1.2.1 the APK on GitHub is the universal APK that Google Play generates
from the same app bundle it serves to Play users, signed with the same key.
That means:
- it installs over the Google Play version and vice versa, and
- its signing certificate must be:
```
SHA-256: 9B:4C:DA:44:A1:80:81:26:3D:79:86:01:2B:9D:11:CA:72:AB:D0:24:9C:D9:DC:AB:82:5F:BE:AE:95:40:2D:02
apksigner prints it as 9b4cda44a18081263d7986012b9d11ca72abd0249cd9dcab825fbeae95402d02
```
Check a download:
```sh
sha256sum -c sepot-1.2.1-SHA256SUMS.txt
apksigner verify --print-certs sepot-1.2.1-universal.apk | grep 'SHA-256'
```
Check the app already installed on your phone (USB debugging enabled):
```sh
adb pull "$(adb shell pm path cz.sepot.sepot | head -1 | cut -d: -f2)" installed.apk
apksigner verify --print-certs installed.apk | grep 'SHA-256'
```
**Older direct APKs (1.1.1 and earlier)** were signed with our upload key
(`E2:13:D6:60:63:B9:0E:A1:73:90:1C:37:9E:4B:43:9C:76:8A:F1:AF:C2:D6:55:68:CC:46:CF:31:36:72:3F:31`).
Android will not update such an install with the 1.2.1 APK. Write down your
24-word recovery phrase, uninstall, install the new APK and restore the
account from the phrase. Message history is not carried over.
Source of each release is the tag of the same name (`v1.2.1`, …). The public
tree is an export of our development tree: comments are rewritten, deployment
configuration is left out, and so is a store-screenshot demo mode. That mode
is behind a compile-time flag (`SEPOT_DEMO`) that is not set for release
builds, so the compiler removes it.
## 3. Native cryptography library
End-to-end encryption uses [libsignal](https://github.com/signalapp/libsignal)
through the Dart package [`libsignal`](https://pub.dev/packages/libsignal)
(5.0.9 at the moment). The Dart package itself is pinned by its SHA-256 in
`app/pubspec.lock`. Its build hook downloads a prebuilt native library,
`libsignal_frb`, from the GitHub releases of
[djx-y-z/libsignal_dart](https://github.com/djx-y-z/libsignal_dart) and only
compares it with a checksum file from the same release.
We pin those binaries ourselves in
[`app/tool/native_libs.sha256`](../app/tool/native_libs.sha256): the upstream
archives and the library exactly as it ends up in the APK (the Android Gradle
plugin strips it while packaging). Our release build runs
`app/tool/verify_native_libs.sh` on the finished bundle and does not ship it on
any mismatch.
You can repeat both checks:
```sh
# the library inside an APK or AAB matches the pins
app/tool/verify_native_libs.sh sepot-1.2.1-universal.apk
# the pins follow from the upstream release: download, check archive hashes,
# strip with llvm-strip from the Android NDK and compare
LLVM_STRIP=/path/to/ndk/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip \
app/tool/verify_native_libs.sh --from-upstream
```
## 4. What is not reproducible (yet)
- **Android builds are not bit-for-bit reproducible.** A build from the tag
gives you a working app, but not the same bytes: the Flutter AOT snapshot
depends on the build environment, and Google Play re-signs and splits our
bundle. Today you can compare the signing certificate, the APK hash between
download channels, and the native crypto library. You cannot compare the
compiled Dart code against your own build.
- **`libsignal_frb` is a third-party prebuilt binary.** The pins guarantee
that every build ships the same file and that the file is the published
upstream release. They do not prove that the release was built from the
`libsignal_frb` source; the upstream release has no build provenance
attestation.
- **iOS** binaries from the App Store are encrypted by Apple and cannot be
compared with anything.
- **The relay image** is not reproducible either: the base images are
referenced by tag and the build applies current Debian security updates.
## 5. Plan
1. Relay revision equal to a public commit: build tooling is in place, the next
relay deployment will report it.
2. Build `libsignal_frb` from source on our own build server together with the
upgrade to the current libsignal release, and publish the resulting hashes
next to the pins above.
3. Relay: pin base images by digest and publish the image digest with each
relay deployment.
4. Work towards a reproducible Android build that independent rebuilders
(for example F-Droid) can confirm.