From e3a2378c34999c6e3a8e0a6c847f8489ac8cada4 Mon Sep 17 00:00:00 2001 From: Julius Joska Date: Fri, 9 Oct 2026 14:02:33 +0200 Subject: [PATCH] fix(app): accept incoming calls, restore from recovery phrase, duress session isolation, message loss Calls: `call` was missing from the known content types, so every offer was acknowledged and dropped; the call screen column now fills the width (the avatar jumped left after hang-up); a ring started from a push ends after 12 s when no offer arrives; STUN fallback when no TURN credentials. Registration: X-Sepot-Identity-Proof lets a new device reclaim an existing identity; retry backs off from 30 s to 10 min and exposes a conflict flag. Prekeys: PrekeysLow triggers a batch of 50 one-time prekeys via POST /v1/keys; the signed prekey rotates weekly, the Kyber prekey monthly. Messaging: crypto operations run serially (concurrent encrypts derived the same message key); stop() waits for in-flight deliveries (auto-lock between decrypt and persist lost messages); disappearing-message cleanup no longer overwrites fresh messages; a push wake-up reconnects stale connections; the outbox carries polls and mentions; length limits on wire input. Storage: a duress session never exposes the real identity or phrase, does not start networking and cannot touch the keystore; deleting data in a duress session leaves the real account alone; PIN guessing is throttled (5 misses, then 30 s doubling up to 15 min); the duress blob shares the salt so unlock time does not reveal which PIN was entered; iOS keychain items are this-device only; biometrics are biometric-only; screen security defaults to on and is applied after unlock; tapjacking filter in MainActivity. Tests: content types, shared-salt unwrap, duress isolation. --- .../kotlin/cz/sepot/sepot/MainActivity.kt | 21 +- app/lib/app.dart | 4 +- app/lib/core/calls/call_controller.dart | 29 ++- app/lib/core/calls/call_ringer.dart | 56 +++- app/lib/core/crypto/dek_wrap.dart | 69 ++++- app/lib/core/crypto/sepot_crypto.dart | 86 ++++++ app/lib/core/network/relay_client.dart | 105 +++++++- app/lib/core/push/push_bootstrap.dart | 2 + app/lib/core/push/push_service.dart | 10 +- app/lib/core/services/messaging_service.dart | 246 ++++++++++++++++-- app/lib/core/storage/secure_storage.dart | 165 +++++++++--- app/lib/features/auth/pin_lock_screen.dart | 22 +- app/lib/features/calls/call_screen.dart | 11 +- .../features/settings/biometrics_tile.dart | 3 +- .../features/settings/settings_screen.dart | 130 +++++---- app/lib/l10n/app_cs.arb | 9 + app/lib/l10n/app_de.arb | 1 + app/lib/l10n/app_en.arb | 1 + app/lib/l10n/app_it.arb | 1 + app/lib/l10n/app_ru.arb | 1 + app/lib/l10n/generated/app_localizations.dart | 6 + .../l10n/generated/app_localizations_cs.dart | 5 + .../l10n/generated/app_localizations_de.dart | 5 + .../l10n/generated/app_localizations_en.dart | 5 + .../l10n/generated/app_localizations_it.dart | 5 + .../l10n/generated/app_localizations_ru.dart | 5 + app/lib/models/attachment.dart | 26 +- app/lib/models/identity.dart | 16 +- app/lib/providers/messaging_provider.dart | 3 + app/lib/providers/settings_provider.dart | 14 +- app/test/content_types_test.dart | 20 ++ .../crypto/dek_wrap_shared_salt_test.dart | 49 ++++ app/test/storage/decoy_isolation_test.dart | 86 ++++++ 33 files changed, 1055 insertions(+), 162 deletions(-) create mode 100644 app/test/content_types_test.dart create mode 100644 app/test/crypto/dek_wrap_shared_salt_test.dart create mode 100644 app/test/storage/decoy_isolation_test.dart diff --git a/app/android/app/src/main/kotlin/cz/sepot/sepot/MainActivity.kt b/app/android/app/src/main/kotlin/cz/sepot/sepot/MainActivity.kt index 6f42ea0..771c769 100644 --- a/app/android/app/src/main/kotlin/cz/sepot/sepot/MainActivity.kt +++ b/app/android/app/src/main/kotlin/cz/sepot/sepot/MainActivity.kt @@ -1,5 +1,6 @@ package cz.sepot.sepot +import android.content.pm.ApplicationInfo import android.os.Bundle import android.view.WindowManager import io.flutter.embedding.android.FlutterFragmentActivity @@ -7,10 +8,20 @@ import io.flutter.embedding.android.FlutterFragmentActivity class MainActivity : FlutterFragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací - window.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE, - ) + // Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací. + // Výchozí stav; Dart přepínač „Zabezpečení obrazovky" ho může po + // odemčení vypnout (default zapnuto, jako u Signalu). + // Debug build: screenshoty povolené kvůli QA na emulátoru (adb screencap + // by u FLAG_SECURE okna selhal); release vždy chráněný. + val debuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + if (!debuggable) { + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } + // Tapjacking: dotyky přes překryvné okno jiné appky (částečně + // průhledný overlay nad PIN klávesnicí) se zahodí. + window.decorView.filterTouchesWhenObscured = true } } diff --git a/app/lib/app.dart b/app/lib/app.dart index fdffdf2..40af72a 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -289,7 +289,9 @@ class _LoadingScreenState extends ConsumerState<_LoadingScreen> { final storage = ref.read(storageProvider); final legacyPin = await storage.hasLegacyPin(); await storage.unlockWithoutPin(); - if (mounted) context.go(legacyPin ? '/pin-setup' : '/chats'); + if (!mounted) return; + ref.invalidate(screenSecurityProvider); + context.go(legacyPin ? '/pin-setup' : '/chats'); } } diff --git a/app/lib/core/calls/call_controller.dart b/app/lib/core/calls/call_controller.dart index 990f6c5..1051963 100644 --- a/app/lib/core/calls/call_controller.dart +++ b/app/lib/core/calls/call_controller.dart @@ -236,9 +236,15 @@ class CallController extends Notifier { _ringId = pushRing.id; if (pushRing.accepted) { await accept(); - } else if (CallRinger.declinedEarly.remove(pushRing.id)) { - await reject(); + return; } + if (CallRinger.declinedEarly.remove(pushRing.id)) { + await reject(); + return; + } + // Spárováno s nabídkou: aby grace z `ringFromPush` zvonění neukončila, + // nahradí se jménem volajícího (bez značky push). + await _claimPushRing(pushRing.id, name, video); return; } final foreground = @@ -249,6 +255,15 @@ class CallController extends Notifier { } } + /// Převezme zvonění z budíčku: jméno volajícího místo „Šepot" a bez + /// značky push (grace v [CallRinger.ringFromPush] ho pak nechá být). + Future _claimPushRing(String pushId, String name, bool video) async { + if (await CallRinger.isAccepted(pushId)) return; // uživatel už zvedl + await CallRinger.end(pushId); + _ringId = state.callId; + await CallRinger.ringFor(callId: state.callId!, name: name, video: video); + } + void _onRingAccept(String id) { if (id == _ringId && state.phase == CallPhase.incoming) { unawaited(accept()); @@ -389,11 +404,19 @@ class CallController extends Notifier { return pc; } + /// STUN bez údajů: coturn na témž hostiteli odpovídá na STUN bez + /// přihlášení, takže i když relay TURN údaje nevydá (503, timeout), + /// jde aspoň přímé spojení přes NAT. Dřív byl seznam prázdný = jen host + /// kandidáti = hovor jen v jedné LAN. + static const _stunFallback = { + 'urls': ['stun:turn.sepot.cz:3478'], + }; + /// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN /// (přímé spojení, když obě strany nejsou za symetrickým NATem). Future>> _iceServers() async { final turn = await ref.read(messagingServiceProvider).turnCredentials(); - if (turn == null || turn.urls.isEmpty) return const []; + if (turn == null || turn.urls.isEmpty) return const [_stunFallback]; return [ { 'urls': turn.urls, diff --git a/app/lib/core/calls/call_ringer.dart b/app/lib/core/calls/call_ringer.dart index 0a8421b..6ec212f 100644 --- a/app/lib/core/calls/call_ringer.dart +++ b/app/lib/core/calls/call_ringer.dart @@ -22,6 +22,13 @@ class CallRinger { /// Jak dlouho zvoní — stejné jako expirace budíčku na relayi. static const ringMillis = 45000; + /// Zvonění z budíčku, ke kterému do té doby nedorazila nabídka, se + /// ukončí samo. Relay budíček hovoru nijak neověřuje (sealed sender), + /// takže kdokoli se znalostí adresy by jinak nechal telefon zvonit + /// 45 s na každou obálku s hintem CALL. Nabídka od + /// skutečného volajícího dorazí do pár sekund po připojení. + static const pushRingGrace = Duration(seconds: 12); + static StreamSubscription? _sub; /// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka. @@ -65,12 +72,41 @@ class CallRinger { _params(id: callId, name: name, video: video)); /// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme. - static Future ringFromPush() => FlutterCallkitIncoming - .showCallkitIncoming(_params( - id: const Uuid().v4(), - name: _appName, - video: false, - extra: {pushMarker: true})); + /// Vrací až po [pushRingGrace]: pokud se zvonění do té doby nespárovalo + /// s nabídkou (přijetí nebo ukončení ho z aktivních hovorů odebere, nebo + /// ho [CallController] převezme), tiše se ukončí. Background isolate FCM + /// handleru tím zůstane naživu přesně tak dlouho, jak je potřeba. + static Future ringFromPush() async { + final id = const Uuid().v4(); + await FlutterCallkitIncoming.showCallkitIncoming(_params( + id: id, name: _appName, video: false, extra: {pushMarker: true})); + await Future.delayed(pushRingGrace); + await endIfUnmatched(id); + } + + /// iOS: zvonění z VoIP pushe zakládá nativní AppDelegate (CallKit musí + /// zazvonit hned), takže grace z [ringFromPush] tam neběží. Po startu + /// Dartu se proto každé nepřevzaté zvonění z budíčku ohlídá odsud. + static Future expireStalePushRings() async { + final calls = await FlutterCallkitIncoming.activeCalls(); + for (final call in calls) { + if (call.extra?[pushMarker] == true && !call.isAccepted) { + final id = call.id; + unawaited(Future.delayed(pushRingGrace, () => endIfUnmatched(id))); + } + } + } + + /// Ukončí zvonění z budíčku, které nikdo nepřevzal (bez záznamu + /// o zmeškaném hovoru — nebylo od koho). + static Future endIfUnmatched(String id) async { + final calls = await FlutterCallkitIncoming.activeCalls(); + for (final call in calls) { + if (call.id == id && call.extra?[pushMarker] == true && !call.isAccepted) { + await FlutterCallkitIncoming.endCall(id); + } + } + } /// Zvonění z budíčku, které čeká na nabídku (nejnovější), a jestli ho /// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla. @@ -84,6 +120,14 @@ class CallRinger { return null; } + static Future isAccepted(String id) async { + final calls = await FlutterCallkitIncoming.activeCalls(); + for (final call in calls) { + if (call.id == id) return call.isAccepted; + } + return false; + } + static Future connected(String id) => FlutterCallkitIncoming.setCallConnected(id); diff --git a/app/lib/core/crypto/dek_wrap.dart b/app/lib/core/crypto/dek_wrap.dart index 58bd73d..388693c 100644 --- a/app/lib/core/crypto/dek_wrap.dart +++ b/app/lib/core/crypto/dek_wrap.dart @@ -53,16 +53,20 @@ class DekWrap { int memoryKiB = defaultMemoryKiB, int iterations = defaultIterations, int parallelism = defaultParallelism, + Uint8List? salt, }) async { if (dek.length != 32) { throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes'); } _enforceFloor(memoryKiB, iterations); - final salt = _randomBytes(16); + // Volitelná sdílená sůl: nátlakový blob se stejnou solí a parametry + // jako skutečný odemyká jedinou derivací (viz unwrapFirstMatching) — + // bez ní by decoy PIN trval 2× déle a šel poznat stopkami. + final useSalt = salt ?? _randomBytes(16); final unlockKey = await _deriveUnlockKey( pin: pin, - salt: salt, + salt: useSalt, memoryKiB: memoryKiB, iterations: iterations, parallelism: parallelism, @@ -80,13 +84,72 @@ class DekWrap { '$memoryKiB', '$iterations', '$parallelism', - base64Encode(salt), + base64Encode(useSalt), base64Encode(box.nonce), base64Encode(box.cipherText), base64Encode(box.mac.bytes), ].join(':'); } + /// Sůl použitá v zabaleném blobu (pro sdílení s nátlakovým blobem). + static Uint8List saltOf(String wrapped) { + final parts = wrapped.split(':'); + if (parts.length != 8 || parts[0] != _version) { + throw FormatException('Unknown wrapped DEK format', wrapped); + } + return Uint8List.fromList(base64Decode(parts[4])); + } + + /// Zkusí PIN proti několika blobům (skutečný + nátlakový) a vrátí index + /// prvního, který sedí, s rozbaleným DEK. Argon2id se počítá jen jednou + /// pro každou odlišnou kombinaci parametrů a soli, takže u blobů se + /// sdílenou solí stojí odemčení stejně, ať sedí kterýkoli — čas + /// odemčení neprozradí, který PIN byl zadán. Nic nesedí → + /// [WrongPinException]. + static Future<(int, Uint8List)> unwrapFirstMatching({ + required List wrapped, + required String pin, + }) async { + final parsed = >[]; + for (final w in wrapped) { + final parts = w.split(':'); + if (parts.length != 8 || parts[0] != _version) { + throw FormatException('Unknown wrapped DEK format', w); + } + _enforceFloor(int.parse(parts[1]), int.parse(parts[2])); + parsed.add(parts); + } + final keys = {}; + for (final parts in parsed) { + final group = parts.sublist(1, 5).join(':'); + keys[group] ??= await _deriveUnlockKey( + pin: pin, + salt: base64Decode(parts[4]), + memoryKiB: int.parse(parts[1]), + iterations: int.parse(parts[2]), + parallelism: int.parse(parts[3]), + ); + } + final aes = AesGcm.with256bits(); + for (var i = 0; i < parsed.length; i++) { + final parts = parsed[i]; + try { + final dek = await aes.decrypt( + SecretBox( + base64Decode(parts[6]), + nonce: base64Decode(parts[5]), + mac: Mac(base64Decode(parts[7])), + ), + secretKey: SecretKey(keys[parts.sublist(1, 5).join(':')]!), + ); + return (i, Uint8List.fromList(dek)); + } on SecretBoxAuthenticationError { + continue; + } + } + throw const WrongPinException(); + } + /// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí /// [WrongPinException]; na neplatném formátu [FormatException]. static Future unwrap({ diff --git a/app/lib/core/crypto/sepot_crypto.dart b/app/lib/core/crypto/sepot_crypto.dart index 0298b9e..4b5effe 100644 --- a/app/lib/core/crypto/sepot_crypto.dart +++ b/app/lib/core/crypto/sepot_crypto.dart @@ -81,6 +81,32 @@ class SepotSealedMessage { } /// Jeden one-time prekey k publikaci na relay. +/// Nový signed prekey k nahrání na relay (rotace). +class SepotSignedPreKey { + SepotSignedPreKey({ + required this.id, + required this.publicKey, + required this.signature, + }); + + final int id; + final Uint8List publicKey; + final Uint8List signature; +} + +/// Nový last-resort Kyber prekey k nahrání na relay. +class SepotKyberPreKey { + SepotKyberPreKey({ + required this.id, + required this.publicKey, + required this.signature, + }); + + final int id; + final Uint8List publicKey; + final Uint8List signature; +} + class SepotOneTimePreKey { SepotOneTimePreKey({required this.id, required this.publicKey}); @@ -420,4 +446,64 @@ class SepotCrypto { } return result; } + + /// Nový signed prekey (rotace). Starý zůstává ve store — relace založené + /// z bundle těsně před rotací ho ještě potřebují. + Future generateSignedPreKey({ + required int id, + required int timestampMs, + }) async { + final identityPrivate = PrivateKey.deserialize( + bytes: _identity.privateKey.toList(), + ); + final private = PrivateKey.generate(); + final public = private.getPublicKey(); + final signature = identityPrivate.sign( + message: public.serialize().toList(), + ); + await _stores.signedPreKey.storeSignedPreKey( + id, + SignedPreKeyRecord( + id: id, + publicKey: public, + privateKey: private, + signature: signature.toList(), + timestamp: BigInt.from(timestampMs), + ), + ); + return SepotSignedPreKey( + id: id, + publicKey: Uint8List.fromList(public.serialize().toList()), + signature: Uint8List.fromList(signature.toList()), + ); + } + + /// Nový last-resort Kyber prekey (rotace PQXDH kotvy). + Future generateKyberPreKey({ + required int id, + required int timestampMs, + }) async { + final identityPrivate = PrivateKey.deserialize( + bytes: _identity.privateKey.toList(), + ); + final keyPair = KyberKeyPair.generate(); + final public = keyPair.getPublicKey(); + final signature = identityPrivate.sign( + message: public.serialize().toList(), + ); + await _stores.kyberPreKey.storeKyberPreKey( + id, + KyberPreKeyRecord.create( + id: id, + keyPair: keyPair, + signature: signature.toList(), + timestamp: BigInt.from(timestampMs), + ), + ); + return SepotKyberPreKey( + id: id, + publicKey: Uint8List.fromList(public.serialize().toList()), + signature: Uint8List.fromList(signature.toList()), + ); + } } diff --git a/app/lib/core/network/relay_client.dart b/app/lib/core/network/relay_client.dart index 7108aa8..81a97de 100644 --- a/app/lib/core/network/relay_client.dart +++ b/app/lib/core/network/relay_client.dart @@ -64,9 +64,17 @@ class RelayClient { final _deliverController = StreamController.broadcast(); final _receiptController = StreamController.broadcast(); final _turnController = StreamController.broadcast(); + final _prekeysLowController = StreamController.broadcast(); var _state = RelayConnectionState.disconnected; + /// Kdy naposled přišel aplikační rámec od relaye. + DateTime _lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0); + static const _staleAfter = Duration(seconds: 20); + + /// Relay hlásí docházející one-time prekeys (zbývající počet). + Stream get prekeysLow => _prekeysLowController.stream; + RelayConnectionState get state => _state; Stream get stateStream => _stateController.stream; @@ -100,9 +108,18 @@ class RelayClient { required List oneTimePreKeys, required Uint8List deliveryAddress, }) async { + // Důkaz držení identity klíče: obnova ze 24 slov na novém telefonu + // narazí na relayi na už existující identitu (409) a bez tohohle + // podpisu by ji nikdy nepřevzala. Starší relay hlavičku + // ignoruje. + final proofTs = DateTime.now().millisecondsSinceEpoch; + final proof = _crypto.sign(utf8.encode('POST /v1/register\n$proofTs')); final resp = await _transport.httpClient().post( Uri.parse('$baseUrl/v1/register'), - headers: {'content-type': 'application/json'}, + headers: { + 'content-type': 'application/json', + 'x-sepot-identity-proof': '$proofTs.${base64Encode(proof)}', + }, body: jsonEncode({ 'identity_key': base64Encode(_crypto.identityPublicKey), 'device_id': 1, @@ -144,6 +161,49 @@ class RelayClient { _crypto.bindAccount(accountId); } + /// POST /v1/keys — doplnění one-time prekeys a rotace signed / Kyber + /// prekey. Vrací počet OTP na relayi po nahrání. + Future uploadKeys({ + SepotSignedPreKey? signedPreKey, + SepotKyberPreKey? kyberPreKey, + List oneTimePreKeys = const [], + }) async { + final deviceUuid = _requireDevice(); + final ts = DateTime.now().millisecondsSinceEpoch; + final signature = _crypto.sign(utf8.encode('POST /v1/keys\n$ts')); + final resp = await _transport.httpClient().post( + Uri.parse('$baseUrl/v1/keys'), + headers: { + 'content-type': 'application/json', + 'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}', + }, + body: jsonEncode({ + if (signedPreKey != null) + 'signed_prekey': { + 'key_id': signedPreKey.id, + 'public_key': base64Encode(signedPreKey.publicKey), + 'signature': base64Encode(signedPreKey.signature), + }, + if (kyberPreKey != null) + 'kyber_prekey': { + 'key_id': kyberPreKey.id, + 'public_key': base64Encode(kyberPreKey.publicKey), + 'signature': base64Encode(kyberPreKey.signature), + 'is_last_resort': true, + }, + 'one_time_prekeys': [ + for (final otp in oneTimePreKeys) + {'key_id': otp.id, 'public_key': base64Encode(otp.publicKey)}, + ], + }), + ); + if (resp.statusCode != 200) { + throw RelayException('prekey upload failed', resp.statusCode, resp.body); + } + final body = jsonDecode(resp.body) as Map; + return (body['one_time_prekeys'] as num?)?.toInt() ?? 0; + } + /// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP). Future fetchBundle(Uint8List deliveryAddress) async { final hexAddr = deliveryAddress @@ -280,6 +340,20 @@ class RelayClient { _setState(RelayConnectionState.disconnected); } + /// Budíček od relaye znamená, že nás nevidí jako živé. Spojení, které + /// hlásí „připojeno", ale [_staleAfter] nic nepřijalo, je po změně sítě + /// nejspíš zombie — zavřít a navázat hned, ne až po ping timeoutu + /// (pojistka proti zombie spojení na straně appky). Jinak jen [start]. + void kick() { + if (_state == RelayConnectionState.connected && + DateTime.now().difference(_lastFrameAt) > _staleAfter) { + _reconnectDelayS = 1; + _onDisconnected(); + return; + } + start(); + } + Future _teardownChannel() async { await _wsSub?.cancel(); _wsSub = null; @@ -313,6 +387,7 @@ class RelayClient { _wsSub = channel.stream.listen( (data) { final frame = pb.ServerFrame.fromBuffer(data as List); + _lastFrameAt = DateTime.now(); if (!authenticated) { if (frame.hasChallenge()) { final signature = _crypto.sign([ @@ -345,6 +420,10 @@ class RelayClient { if (!_turnController.isClosed) { _turnController.add(frame.turnCredentials); } + } else if (frame.hasPrekeysLow()) { + if (!_prekeysLowController.isClosed) { + _prekeysLowController.add(frame.prekeysLow.remaining); + } } }, onDone: _onDisconnected, @@ -389,14 +468,21 @@ class RelayClient { final receiptFuture = _receiptController.stream .firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid)) .timeout(timeout); - _send(pb.ClientFrame( - envelope: pb.Envelope( - dstAddress: dstAddress, - msgUuid: msgUuid, - contentHint: hint, - payload: payload, - ), - )); + try { + _send(pb.ClientFrame( + envelope: pb.Envelope( + dstAddress: dstAddress, + msgUuid: msgUuid, + contentHint: hint, + payload: payload, + ), + )); + } catch (_) { + // Odeslání selhalo (kanál zavřený) — čekající future by za 10 s + // hodil neodchycený TimeoutException do zvonku chyb. + receiptFuture.ignore(); + rethrow; + } return receiptFuture; } @@ -455,6 +541,7 @@ class RelayClient { _transport.close(); await _stateController.close(); await _turnController.close(); + await _prekeysLowController.close(); await _deliverController.close(); await _receiptController.close(); } diff --git a/app/lib/core/push/push_bootstrap.dart b/app/lib/core/push/push_bootstrap.dart index dbb31ce..71ebef6 100644 --- a/app/lib/core/push/push_bootstrap.dart +++ b/app/lib/core/push/push_bootstrap.dart @@ -87,6 +87,8 @@ class PushBootstrap { if (Platform.isIOS) { ApnsBridge.onWake = _wake; ApnsBridge.ensureHandler(); + // Zvonění z VoIP pushe bez nabídky nesmí zvonit celých 45 s. + unawaited(CallRinger.expireStalePushRings()); } else { await UnifiedPush.initialize( onNewEndpoint: _onNewEndpoint, diff --git a/app/lib/core/push/push_service.dart b/app/lib/core/push/push_service.dart index b6ea2d5..9ef27a9 100644 --- a/app/lib/core/push/push_service.dart +++ b/app/lib/core/push/push_service.dart @@ -52,7 +52,15 @@ class PushService { /// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.) bool get canWake => _messaging.isStarted; - void wake() => _messaging.onPushWake(); + /// Budíček: běžící messaging připojí WS; po auto-locku (zamčená DB) aspoň + /// generická notifikace bez obsahu, ať se uživatel o zprávě dozví. + void wake() { + if (canWake) { + _messaging.onPushWake(); + } else { + unawaited(PushNotifier.showWakeNotification()); + } + } void handleNewEndpoint(String url) { _messaging.registerPush(_registration( diff --git a/app/lib/core/services/messaging_service.dart b/app/lib/core/services/messaging_service.dart index eb567cb..3c30e90 100644 --- a/app/lib/core/services/messaging_service.dart +++ b/app/lib/core/services/messaging_service.dart @@ -107,6 +107,56 @@ class MessagingService { StreamSubscription? _stateSub; Timer? _registerRetryTimer; Timer? _purgeTimer; + StreamSubscription? _prekeysLowSub; + + /// Typy E2E obsahu, které tahle verze umí zpracovat. Cokoli jiného se + /// ACKne a zahodí (po upgradu už nejde dešifrovat znovu). Veřejná + /// konstanta kvůli testu: v 1.2.0 tu chyběl `call` a každá nabídka + /// hovoru se tiše zahodila — hovory vůbec nešly přijmout. + static const knownContentTypes = { + 'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote', 'call', + ..._groupControlTypes, + }; + + /// Prodleva dalšího pokusu o registraci; roste do [_registerRetryMax]. + Duration _registerRetryDelay = const Duration(seconds: 30); + static const _registerRetryMax = Duration(minutes: 10); + + /// Relay odmítl registraci jako konflikt identity (409) — typicky starší + /// relay bez převzetí identity po obnově ze 24 slov. UI může vysvětlit. + bool _registrationConflict = false; + bool get registrationConflict => _registrationConflict; + + /// Rozpracované dodávky: `stop()` (auto-lock) na ně počká, jinak by DB + /// zmizela mezi dešifrováním (ratchet už postoupil) a zápisem a zpráva + /// by byla nenávratně pryč. + int _inFlight = 0; + Completer? _drained; + + /// Operace nad ratchet stavem jdou postupně. libsignal načte relaci, + /// šifruje a uloží ji zpět s `await` na Dart store uprostřed — dva + /// souběžné běhy nad touž relací by odvodily stejný message key a druhá + /// obálka by u příjemce propadla. Doručenky, „píše…“ i outbox běží + /// `unawaited` vedle uživatelského odeslání, takže zámek je nutný. + Future _chain = Future.value(); + + Future _serial(Future Function() op) { + final next = _chain.then((_) => op()); + _chain = next.then((_) {}, onError: (_) {}); + return next; + } + + /// Nejvyšší počet znaků jména kontaktu/profilu z drátu nebo odkazu. + static const maxDisplayNameLength = 64; + + static String clipName(String name) => name.length > maxDisplayNameLength + ? name.substring(0, maxDisplayNameLength) + : name; + + /// Kolik one-time prekeys doplnit, když relay hlásí nedostatek. + static const _otpBatch = 50; + static const _spkRotation = Duration(days: 7); + static const _kyberRotation = Duration(days: 30); /// Běží právě dodatečné odeslání zaseklých zpráv? Reconnectů může přijít /// několik rychle za sebou a dva souběžné průchody by tutéž zprávu poslaly @@ -266,11 +316,13 @@ class MessagingService { final relay = _buildRelay(); _relay = relay; _deliverSub = relay.deliveries.listen(_onDeliver); + _prekeysLowSub = relay.prekeysLow.listen(_replenishPreKeys); // Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích. _stateSub = relay.stateStream.listen((state) { if (state == RelayConnectionState.connected) { unawaited(_flushOutbox()); unawaited(_refreshMyAvatarIfOld()); + unawaited(_rotatePreKeysIfDue()); } }); @@ -337,11 +389,13 @@ class MessagingService { final relay = _buildRelay(); _relay = relay; _deliverSub = relay.deliveries.listen(_onDeliver); + _prekeysLowSub = relay.prekeysLow.listen(_replenishPreKeys); // Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích. _stateSub = relay.stateStream.listen((state) { if (state == RelayConnectionState.connected) { unawaited(_flushOutbox()); unawaited(_refreshMyAvatarIfOld()); + unawaited(_rotatePreKeysIfDue()); } }); @@ -376,16 +430,109 @@ class MessagingService { Future _registerWithRetry() async { try { await _register(); + } on RelayException catch (e) { + // 409 = identita na relayi existuje a relay neumí převzetí (starší + // verze). Opakovat má smysl (relay se aktualizuje), ale ne každých + // 30 s navždy — a UI má vědět proč. + _registrationConflict = e.statusCode == 409; + _scheduleRegisterRetry(); + return; } catch (_) { - _registerRetryTimer?.cancel(); - _registerRetryTimer = - Timer(const Duration(seconds: 30), _registerWithRetry); + _scheduleRegisterRetry(); return; } + _registrationConflict = false; + _registerRetryDelay = const Duration(seconds: 30); _relay!.start(); await _refreshSenderCert(); } + void _scheduleRegisterRetry() { + _registerRetryTimer?.cancel(); + _registerRetryTimer = Timer(_registerRetryDelay, _registerWithRetry); + final doubled = _registerRetryDelay * 2; + _registerRetryDelay = + doubled > _registerRetryMax ? _registerRetryMax : doubled; + onUpdate?.call(); + } + + int? _kvInt(CryptoDb db, String key) { + final raw = db.kvGet(key); + return raw == null ? null : int.tryParse(utf8.decode(raw)); + } + + /// Relay po AUTH hlásí docházející one-time prekeys → vygenerovat dávku + /// a nahrát. Bez toho byl účet po vyčerpání zásoby natrvalo bez OTP + /// a každá nová relace k němu běžela bez jednorázového klíče. + Future _replenishPreKeys(int remaining) async { + final db = _db; + final crypto = _crypto; + final relay = _relay; + if (db == null || crypto == null || relay == null || accountId == null) { + return; + } + try { + // Registrace nahrála id 1–100; další dávky navazují. + final next = _kvInt(db, 'otp_next_id') ?? 101; + final otps = await _serial( + () => crypto.generateOneTimePreKeys(startId: next, count: _otpBatch)); + await relay.uploadKeys(oneTimePreKeys: otps); + db.kvPut('otp_next_id', utf8.encode('${next + _otpBatch}')); + } catch (_) { + // Síť / starší relay bez /v1/keys — relay to zopakuje po dalším AUTH. + } + } + + /// Rotace signed prekey (týdně) a last-resort Kyber prekey (měsíčně), + /// Staré klíče zůstávají ve store — relace z bundle + /// staženého těsně před rotací je ještě potřebují. + Future _rotatePreKeysIfDue() async { + final db = _db; + final crypto = _crypto; + final relay = _relay; + if (db == null || crypto == null || relay == null || accountId == null) { + return; + } + final now = DateTime.now(); + bool due(String key, Duration period) { + final at = _kvInt(db, key); + return at == null || + now.difference(DateTime.fromMillisecondsSinceEpoch(at)) > period; + } + + final spkDue = due('spk_rotated_at', _spkRotation); + final kyberDue = due('kyber_rotated_at', _kyberRotation); + if (!spkDue && !kyberDue) return; + try { + SepotSignedPreKey? spk; + SepotKyberPreKey? kyber; + if (spkDue) { + final id = _kvInt(db, 'spk_next_id') ?? 2; + spk = await _serial(() => crypto.generateSignedPreKey( + id: id, timestampMs: now.millisecondsSinceEpoch)); + } + if (kyberDue) { + final id = _kvInt(db, 'kyber_next_id') ?? 2; + kyber = await _serial(() => crypto.generateKyberPreKey( + id: id, timestampMs: now.millisecondsSinceEpoch)); + } + await relay.uploadKeys(signedPreKey: spk, kyberPreKey: kyber); + final stamp = utf8.encode('${now.millisecondsSinceEpoch}'); + db.transaction(() { + if (spk != null) { + db.kvPut('spk_next_id', utf8.encode('${spk.id + 1}')); + db.kvPut('spk_rotated_at', stamp); + } + if (kyber != null) { + db.kvPut('kyber_next_id', utf8.encode('${kyber.id + 1}')); + db.kvPut('kyber_rotated_at', stamp); + } + }); + } catch (_) { + // Starší relay bez /v1/keys nebo offline — zkusí se po dalším connectu. + } + } + Future _register() async { final db = _db!; final crypto = _crypto!; @@ -410,10 +557,17 @@ class MessagingService { deliveryAddress: deliveryAddress, ); + final stamp = utf8.encode('$nowMs'); db.transaction(() { db.kvPut('account_id', utf8.encode(reg.accountId)); db.kvPut('device_uuid', utf8.encode(reg.deviceUuid)); db.kvPut('delivery_address', deliveryAddress); + // Id pro doplňování a rotace klíčů navazují na registraci. + db.kvPut('otp_next_id', utf8.encode('101')); + db.kvPut('spk_next_id', utf8.encode('2')); + db.kvPut('kyber_next_id', utf8.encode('2')); + db.kvPut('spk_rotated_at', stamp); + db.kvPut('kyber_rotated_at', stamp); }); onUpdate?.call(); } @@ -2009,6 +2163,10 @@ class MessagingService { 'txt': caption ?? '', 're': ?message.replyToId, 'ttl': ?message.disappearAfterSeconds, + // Anketa a zmínky patří k téže zprávě i při dodatečném odeslání — + // bez nich by po reconnectu dorazil jen holý záložní text. + 'poll': ?message.poll?.toWire(), + if (message.mentions.isNotEmpty) 'mn': message.mentions, 'p': _profile(), }; } @@ -2053,22 +2211,27 @@ class MessagingService { } final peerAddr = ProtocolAddress(name: peerAid, deviceId: 1); - // 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í). - if (!await crypto.hasSession(peerAddr)) { - if (ephemeral) return null; - final bundle = await relay.fetchBundle(peerDa); - if (!_listEquals(bundle.identityKey, peer.identityKey)) { - throw const IdentityKeyMismatchError(); + // Krypto část jde přes [_serial] (viz komentář u pole): relace se + // 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í. + final sealed = await _serial(() async { + // 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í). + if (!await crypto.hasSession(peerAddr)) { + if (ephemeral) return null; + final bundle = await relay.fetchBundle(peerDa); + if (!_listEquals(bundle.identityKey, peer.identityKey)) { + throw const IdentityKeyMismatchError(); + } + await crypto.processRemoteBundle(peerAddr, bundle); } - await crypto.processRemoteBundle(peerAddr, bundle); - } - - final sealed = await crypto.sealedEncrypt( - recipient: peerAddr, - plaintext: utf8.encode(jsonEncode(content)), - senderCertificate: await _senderCertificate(), - ); + return crypto.sealedEncrypt( + recipient: peerAddr, + plaintext: utf8.encode(jsonEncode(content)), + senderCertificate: await _senderCertificate(), + ); + }); + if (sealed == null) return null; final receipt = await relay.sendEnvelope( dstAddress: peerDa, @@ -2084,10 +2247,25 @@ class MessagingService { // ── Příjem ────────────────────────────────────────────────────────── - Future _onDeliver(pb.Envelope envelope) async { + /// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako + /// rozpracované, aby na ně [stop] počkal. + void _onDeliver(pb.Envelope envelope) { + _inFlight++; + unawaited(_serial(() => _handleDelivery(envelope)) + .then((_) {}, onError: (_) {}) + .whenComplete(() { + _inFlight--; + if (_inFlight == 0) _drained?.complete(); + })); + } + + Future _handleDelivery(pb.Envelope envelope) async { final crypto = _crypto; final trustRoot = _trustRoot; - if (crypto == null || trustRoot == null) return; + // Zamčené úložiště (auto-lock stihl zamknout dřív, než jsme se dostali + // na řadu): nedešifrovat — ratchet by postoupil a zápis by selhal. + // Relay obálku doručí znovu po odemčení. + if (crypto == null || trustRoot == null || !storage.isUnlocked) return; final SepotSealedMessage opened; try { @@ -2110,10 +2288,7 @@ class MessagingService { final content = jsonDecode(utf8.decode(opened.plaintext)) as Map; final type = content['t'] as String?; - const known = { - 'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote', - ..._groupControlTypes, - }; + const known = knownContentTypes; if (content['v'] != 1 || !known.contains(type)) { // 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. @@ -2228,13 +2403,18 @@ class MessagingService { final replyTo = content['re'] as String?; final Message message; if (type == 'txt') { - final text = content['txt']; - if (text is! String) { + final raw = content['txt']; + if (raw is! String) { // Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty // nepomůže, ACKnout a zahodit stejně jako u vadných příloh. _relay!.ack(envelope.msgUuid); return; } + // Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden + // JSON blob — čtvrtmegabajtová bublina by ji trvale zpomalila. + const maxWireText = AppConstants.maxMessageLength * 4; + final text = + raw.length > maxWireText ? raw.substring(0, maxWireText) : raw; message = Message( id: messageId, senderId: contact.publicIdentity.fingerprint.display, @@ -2567,7 +2747,7 @@ class MessagingService { // Prázdné jméno = UI si doplní lokalizovaný fallback final name = (profile?['n'] as String?)?.trim(); final identity = PublicIdentity( - displayName: (name == null || name.isEmpty) ? '' : name, + displayName: (name == null || name.isEmpty) ? '' : clipName(name), identityKey: opened.senderIdentityKey, createdAt: DateTime.now(), accountId: opened.senderName, @@ -2603,7 +2783,7 @@ class MessagingService { /// Probuzení z push budíčku: ujisti se, že jsme připojení — fronta se /// vyleje po AUTH (relay pošle DELIVER stream). Idempotentní. void onPushWake() { - _relay?.start(); + _relay?.kick(); } /// Deregistrace účtu na relayi (GDPR čl. 17 / store „smazání účtu"). @@ -2631,7 +2811,17 @@ class MessagingService { _purgeTimer?.cancel(); _purgeTimer = null; await _deliverSub?.cancel(); + await _prekeysLowSub?.cancel(); await _stateSub?.cancel(); + // Rozpracované dodávky doběhnou (dešifrováno → zapsáno → ACK), teprve + // pak se úložiště smí zamknout. Strop, ať zaseklá dodávka nezablokuje + // zámek navždy. + if (_inFlight > 0) { + final drained = _drained ??= Completer(); + await drained.future + .timeout(const Duration(seconds: 8), onTimeout: () {}); + } + _drained = null; await _relay?.dispose(); // DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit _db = null; diff --git a/app/lib/core/storage/secure_storage.dart b/app/lib/core/storage/secure_storage.dart index 5ee5bf9..6921a64 100644 --- a/app/lib/core/storage/secure_storage.dart +++ b/app/lib/core/storage/secure_storage.dart @@ -30,6 +30,16 @@ import 'package:sepot/models/message.dart'; /// při onboardingu, který DB rovnou otevře). /// Nátlakový PIN je shodný se skutečným — nelze nastavit. +/// Příliš mnoho špatných PINů za sebou — další pokus až za [remaining]. +class PinLockedException implements Exception { + const PinLockedException(this.remaining); + + final Duration remaining; + + @override + String toString() => 'PIN locked for ${remaining.inSeconds}s'; +} + class DecoyPinConflict implements Exception { const DecoyPinConflict(); @override @@ -51,6 +61,13 @@ class SecureStorageService { static const _legacyPinHashKey = 'pin_hash'; static const _decoyWrappedDekKey = 'wrapped_dek_decoy'; + // Brzda hádání PINu: počet chyb za sebou a čas, do kdy je zámek uzamčený. + // V keystore (ne v DB) — DB je právě to, co se hádáním otevírá. + static const _pinFailCountKey = 'pin_fail_count'; + static const _pinLockUntilKey = 'pin_lock_until'; + static const _pinFreeAttempts = 5; + static const _pinLockBase = Duration(seconds: 30); + static const _pinLockMax = Duration(minutes: 15); final FlutterSecureStorage _secureStorage; final String? _dbPathOverride; @@ -60,12 +77,24 @@ class SecureStorageService { bool _decoyMode = false; /// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB. + /// V tomhle režimu úložiště nevydá skutečnou identitu ani frázi, + /// nestartuje síť a nic z keystore nemění (viz jednotlivé metody). bool get isDecoyMode => _decoyMode; + /// Identita ukazovaná v nátlakové relaci: náhodná, jen v paměti. + LocalIdentity? _decoyIdentity; + + void _requireRealSession() { + if (_decoyMode) throw StateError('Not available in a duress session'); + } + SecureStorageService({String? dbPath}) : _secureStorage = const FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), - iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + // this_device: identita a klíče nesmí putovat v iCloud Keychain + // ani v záloze na jiný telefon — obnova jde přes 24 slov. + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device), ), _dbPathOverride = dbPath; @@ -98,21 +127,59 @@ class SecureStorageService { if (wrapped == null) { throw StateError('No PIN set — use unlockWithoutPin'); } + final locked = await pinLockRemaining(); + if (locked != null) throw PinLockedException(locked); + + final decoy = await _secureStorage.read(key: _decoyWrappedDekKey); try { - final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin); - await _openDb(dek); + // Oba bloby jednou derivací (sdílená sůl) — čas odemčení neprozradí, + // který PIN padl. + final (index, dek) = await DekWrap.unwrapFirstMatching( + wrapped: [wrapped, ?decoy], + pin: pin, + ); + await _openDb(dek, decoy: index == 1); + await _resetPinFailures(); } on WrongPinException { - final decoy = await _secureStorage.read(key: _decoyWrappedDekKey); - if (decoy == null) rethrow; - // Když ani decoy nesedí, unwrap vyhodí WrongPinException dál. - final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin); - await _openDb(dek, decoy: true); + await _notePinFailure(); + rethrow; } } + /// Zbývající zámek po sérii špatných PINů; null = volno. + Future pinLockRemaining() async { + final raw = await _secureStorage.read(key: _pinLockUntilKey); + final until = raw == null ? null : int.tryParse(raw); + if (until == null) return null; + final remaining = DateTime.fromMillisecondsSinceEpoch(until) + .difference(DateTime.now()); + return remaining > Duration.zero ? remaining : null; + } + + Future _notePinFailure() async { + final raw = await _secureStorage.read(key: _pinFailCountKey); + final failures = (raw == null ? 0 : int.tryParse(raw) ?? 0) + 1; + await _secureStorage.write(key: _pinFailCountKey, value: '$failures'); + if (failures < _pinFreeAttempts) return; + // 30 s po páté chybě, pak dvojnásobně až do 15 minut: šest číslic + // (10⁶) tak trvá roky, Argon2id sám by brzdil jen ~2 s na pokus. + var lock = _pinLockBase * (1 << (failures - _pinFreeAttempts)); + if (lock > _pinLockMax) lock = _pinLockMax; + await _secureStorage.write( + key: _pinLockUntilKey, + value: '${DateTime.now().add(lock).millisecondsSinceEpoch}', + ); + } + + Future _resetPinFailures() async { + await _secureStorage.delete(key: _pinFailCountKey); + await _secureStorage.delete(key: _pinLockUntilKey); + } + /// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže. /// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře. Future enablePin(String pin) async { + _requireRealSession(); final dek = _dek ?? await _getOrCreateRawDek(); final wrapped = await DekWrap.wrap(dek: dek, pin: pin); await _secureStorage.write(key: _wrappedDekKey, value: wrapped); @@ -124,6 +191,7 @@ class SecureStorageService { /// Vypne PIN: DEK se vrátí raw do OS keystore. Future disablePin() async { + _requireRealSession(); final dek = _dek ?? (throw StateError('Requires unlocked storage')); await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek)); await _secureStorage.delete(key: _wrappedDekKey); @@ -151,6 +219,7 @@ class SecureStorageService { required String oldPin, required String newPin, }) async { + _requireRealSession(); final wrapped = await _secureStorage.read(key: _wrappedDekKey); if (wrapped == null) throw StateError('No PIN set'); final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin); @@ -165,7 +234,11 @@ class SecureStorageService { } } - final rewrapped = await DekWrap.wrap(dek: dek, pin: newPin); + // Sůl zůstává: nátlakový blob ji sdílí (jedna derivace pro oba PINy) + // a bez jeho PINu by se přebalit nedal. Jedna sůl pro dva PINy téhož + // uživatele nic neoslabuje — sůl brání předpočítání napříč uživateli. + final rewrapped = await DekWrap.wrap( + dek: dek, pin: newPin, salt: DekWrap.saltOf(wrapped)); await _secureStorage.write(key: _wrappedDekKey, value: rewrapped); } @@ -176,6 +249,7 @@ class SecureStorageService { /// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy /// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit. Future enableDecoyPin(String decoyPin) async { + _requireRealSession(); final realWrapped = await _secureStorage.read(key: _wrappedDekKey); if (realWrapped == null) { throw StateError('Set a regular PIN first'); @@ -193,7 +267,9 @@ class SecureStorageService { // Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB final decoyPath = await _dbPath(decoy: true); CryptoDb.open(path: decoyPath, dek: decoyDek).close(); - final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin); + // Stejná sůl jako skutečný blob → jedna Argon2id derivace pro oba PINy. + final wrapped = await DekWrap.wrap( + dek: decoyDek, pin: decoyPin, salt: DekWrap.saltOf(realWrapped)); await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped); } @@ -203,11 +279,20 @@ class SecureStorageService { /// Zruší nátlakový PIN a smaže decoy DB. Future disableDecoyPin() async { + _requireRealSession(); await _secureStorage.delete(key: _decoyWrappedDekKey); - try { - final f = File(await _dbPath(decoy: true)); - if (f.existsSync()) f.deleteSync(); - } catch (_) {} + await _deleteDbFiles(await _dbPath(decoy: true)); + } + + Future _deleteDbFiles(String path) async { + for (final suffix in ['', '-wal', '-shm']) { + try { + final f = File('$path$suffix'); + if (f.existsSync()) f.deleteSync(); + } catch (_) { + // best-effort úklid + } + } } /// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme @@ -222,6 +307,8 @@ class SecureStorageService { _db?.close(); _db = null; _dek = null; + _decoyMode = false; + _decoyIdentity = null; } Future _openDb(Uint8List dek, {bool decoy = false}) async { @@ -293,6 +380,11 @@ class SecureStorageService { /// Načti lokální identitu Future loadIdentity() async { + if (_decoyMode) { + // Nátlaková relace ukazuje náhodnou identitu jen z paměti — skutečný + // klíč z keystore se do ní nikdy nedostane (nátlakový PIN musí být izolovaný). + return _decoyIdentity ??= await LocalIdentity.create(''); + } final json = await _secureStorage.read(key: _identityKey); if (json == null) return null; try { @@ -320,6 +412,7 @@ class SecureStorageService { } Future?> loadSeedPhrase() async { + if (_decoyMode) return null; final json = await _secureStorage.read(key: _seedPhraseKey); if (json == null) return null; return (jsonDecode(json) as List).cast(); @@ -331,6 +424,7 @@ class SecureStorageService { /// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings). /// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním. Future enableBiometrics() async { + _requireRealSession(); final dek = _dek ?? (throw StateError('Requires unlocked storage')); await _secureStorage.write( key: _biometricDekKey, @@ -342,6 +436,7 @@ class SecureStorageService { /// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý /// PIN-only model: DEK existuje jen za Argon2id). Future disableBiometrics() async { + _requireRealSession(); await _secureStorage.delete(key: _biometricDekKey); await _secureStorage.write(key: _biometricsEnabledKey, value: 'false'); } @@ -475,24 +570,29 @@ class SecureStorageService { if (!isUnlocked) return false; var changed = false; final now = DateTime.now(); - for (final conv in await loadConversations()) { - final expired = conv.messages - .where((m) => - m.disappearAfterSeconds != null && - m.timestamp - .add(Duration(seconds: m.disappearAfterSeconds!)) - .isBefore(now)) - .toList(); + bool isExpired(Message m) => + m.disappearAfterSeconds != null && + m.timestamp + .add(Duration(seconds: m.disappearAfterSeconds!)) + .isBefore(now); + for (final snapshot in await loadConversations()) { + if (!snapshot.messages.any(isExpired)) continue; + // Konverzace se před zápisem načte znovu: mezi snímkem a zápisem + // běží event loop (mazání blobů, příjem zpráv) a starý snímek by + // přepsal zprávu přijatou mezitím — už ACKnutou, tedy navždy pryč. + final conv = await loadConversation(snapshot.contactId); + if (conv == null) continue; + final expired = conv.messages.where(isExpired).toList(); if (expired.isEmpty) continue; changed = true; + conv.messages.removeWhere(isExpired); + await saveConversation(conv); for (final m in expired) { final blobId = m.attachment?.blobId; if (blobId != null) { await AttachmentStore().delete(blobId); } - conv.messages.remove(m); } - await saveConversation(conv); } return changed; } @@ -512,12 +612,19 @@ class SecureStorageService { /// Smaž všechna data (factory reset) — DB soubor, klíče, keystore /// i lokální bloby příloh. Future deleteAll() async { - final path = await _dbPath(); - lock(); - for (final suffix in ['', '-wal', '-shm']) { - final f = File('$path$suffix'); - if (f.existsSync()) f.deleteSync(); + if (_decoyMode) { + // Nátlaková relace: „smazat" smí jen prázdnou decoy DB — skutečná + // data a keystore zůstávají (dřív to smazalo reálný účet). + final decoyPath = await _dbPath(decoy: true); + lock(); + await _deleteDbFiles(decoyPath); + return; } + final path = await _dbPath(); + final decoyPath = await _dbPath(decoy: true); + lock(); + await _deleteDbFiles(path); + await _deleteDbFiles(decoyPath); await AttachmentStore().deleteAll(); await _secureStorage.deleteAll(); } diff --git a/app/lib/features/auth/pin_lock_screen.dart b/app/lib/features/auth/pin_lock_screen.dart index 2f5da3e..8fde3ce 100644 --- a/app/lib/features/auth/pin_lock_screen.dart +++ b/app/lib/features/auth/pin_lock_screen.dart @@ -4,10 +4,12 @@ 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/crypto/dek_wrap.dart'; +import 'package:sepot/core/storage/secure_storage.dart' show PinLockedException; import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/providers/messaging_provider.dart'; import 'package:sepot/providers/push_provider.dart'; +import 'package:sepot/providers/settings_provider.dart'; import 'package:sepot/providers/storage_provider.dart'; import 'package:sepot/widgets/sepot_logo.dart'; @@ -41,7 +43,10 @@ class _PinLockScreenState extends ConsumerState { try { final ok = await LocalAuthentication().authenticate( localizedReason: unlockReason, - options: const AuthenticationOptions(stickyAuth: true), + // biometricOnly: zámek telefonu (PIN/vzor zařízení) nesmí Šepot + // odemknout — obešel by Argon2id ochranu DEK slabším faktorem. + options: const AuthenticationOptions( + stickyAuth: true, biometricOnly: true), ); if (!ok) return; // uživatel zrušil → zůstává PIN await storage.unlockWithBiometrics(); @@ -222,6 +227,9 @@ class _PinLockScreenState extends ConsumerState { void _restartMessagingAfterUnlock() { ref.invalidate(messagingStartProvider); ref.invalidate(pushInitProvider); + // Zabezpečení obrazovky se čte z DB — před odemčením provider nic + // neaplikoval, teď má skutečnou hodnotu. + ref.invalidate(screenSecurityProvider); } Future _verifyPin() async { @@ -234,12 +242,22 @@ class _PinLockScreenState extends ConsumerState { _restartMessagingAfterUnlock(); if (mounted) context.go('/chats'); } on WrongPinException { + final locked = await ref.read(storageProvider).pinLockRemaining(); + if (!mounted) return; setState(() { - _error = context.l10n.pinWrong2; + _error = locked == null + ? context.l10n.pinWrong2 + : context.l10n.pinLockedFor(locked.inSeconds); _pin = ''; _isVerifying = false; }); HapticFeedback.heavyImpact(); + } on PinLockedException catch (e) { + setState(() { + _error = context.l10n.pinLockedFor(e.remaining.inSeconds); + _pin = ''; + _isVerifying = false; + }); } on StateError { // Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding if (mounted) context.go('/onboarding'); diff --git a/app/lib/features/calls/call_screen.dart b/app/lib/features/calls/call_screen.dart index 547db0f..d8f9c82 100644 --- a/app/lib/features/calls/call_screen.dart +++ b/app/lib/features/calls/call_screen.dart @@ -89,9 +89,13 @@ class _CallScreenState extends ConsumerState { RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, ), ), - SafeArea( - child: Column( - children: [ + // Positioned.fill: bez něj si Stack dá sloupci volné rozměry a ve + // fázi „ukončeno" (žádný ovládací řádek přes celou šířku) se sloupec + // smrskne na šířku jména a zarovná vlevo nahoru — avatar uskočí. + Positioned.fill( + child: SafeArea( + child: Column( + children: [ const SizedBox(height: 48), if (!showVideo) ...[ SepotAvatar( @@ -136,6 +140,7 @@ class _CallScreenState extends ConsumerState { _InCallControls(call: call, controller: controller), const SizedBox(height: 32), ], + ), ), ), if (showVideo && !call.cameraOff) diff --git a/app/lib/features/settings/biometrics_tile.dart b/app/lib/features/settings/biometrics_tile.dart index 89c664a..47ee72d 100644 --- a/app/lib/features/settings/biometrics_tile.dart +++ b/app/lib/features/settings/biometrics_tile.dart @@ -55,7 +55,8 @@ class _BiometricsTileState extends ConsumerState { } final ok = await auth.authenticate( localizedReason: l10n.biometricsEnrollPromptReason, - options: const AuthenticationOptions(stickyAuth: true), + options: const AuthenticationOptions( + stickyAuth: true, biometricOnly: true), ); if (!ok) return; await storage.enableBiometrics(); diff --git a/app/lib/features/settings/settings_screen.dart b/app/lib/features/settings/settings_screen.dart index 091ec77..8ae102f 100644 --- a/app/lib/features/settings/settings_screen.dart +++ b/app/lib/features/settings/settings_screen.dart @@ -38,6 +38,9 @@ class SettingsScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final identityAsync = ref.watch(identityProvider); final l10n = context.l10n; + // Nátlaková relace: nic, co by prozradilo nebo poškodilo skutečný účet + // (fráze, biometrie, PIN, záloha, smazání) — appka vypadá jako čistá. + final decoy = ref.watch(storageProvider).isDecoyMode; return Scaffold( appBar: AppBar( @@ -63,13 +66,15 @@ class SettingsScreen extends ConsumerWidget { const SizedBox(height: 8), // Sekce Zabezpečení _buildSectionHeader(l10n.settingsSectionSecurity), - _buildTile( - icon: Icons.pin_outlined, - title: l10n.settingsChangePinTitle, - subtitle: l10n.settingsChangePinSubtitle, - onTap: () => context.push('/change-pin'), - ), - const BiometricsTile(), + if (!decoy) ...[ + _buildTile( + icon: Icons.pin_outlined, + title: l10n.settingsChangePinTitle, + subtitle: l10n.settingsChangePinSubtitle, + onTap: () => context.push('/change-pin'), + ), + const BiometricsTile(), + ], _buildTile( icon: Icons.timer_outlined, title: l10n.settingsAutoLockTitle, @@ -80,24 +85,25 @@ class SettingsScreen extends ConsumerWidget { ), onTap: () => _nastavAutoLock(context, ref), ), - _buildTile( - icon: Icons.password_outlined, - title: l10n.settingsDecoyPinTitle, - subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true - ? l10n.settingsDecoyPinSetSubtitle - : l10n.settingsDecoyPinUnsetSubtitle, - trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true - ? TextButton( - onPressed: () => _disableDecoyPin(context, ref), - child: Text(l10n.commonCancel), - ) - : null, - onTap: () { - if (ref.read(hasDecoyPinProvider).valueOrNull != true) { - _setDecoyPin(context, ref); - } - }, - ), + if (!decoy) + _buildTile( + icon: Icons.password_outlined, + title: l10n.settingsDecoyPinTitle, + subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true + ? l10n.settingsDecoyPinSetSubtitle + : l10n.settingsDecoyPinUnsetSubtitle, + trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true + ? TextButton( + onPressed: () => _disableDecoyPin(context, ref), + child: Text(l10n.commonCancel), + ) + : null, + onTap: () { + if (ref.read(hasDecoyPinProvider).valueOrNull != true) { + _setDecoyPin(context, ref); + } + }, + ), const SizedBox(height: 8), // Sekce Notifikace _buildSectionHeader(l10n.settingsSectionNotifications), @@ -153,25 +159,27 @@ class SettingsScreen extends ConsumerWidget { ), const SizedBox(height: 8), // Sekce Záloha - _buildSectionHeader(l10n.settingsSectionBackup), - _buildTile( - icon: Icons.key_outlined, - title: l10n.settingsSeedPhraseTitle, - subtitle: l10n.settingsSeedPhraseSubtitle, - onTap: () => _showSeedPhrase(context, ref), - ), - _buildTile( - icon: Icons.backup_outlined, - title: l10n.settingsBackupExportTitle, - subtitle: l10n.settingsBackupExportSubtitle, - onTap: () => _exportBackup(context, ref), - ), - _buildTile( - icon: Icons.restore_outlined, - title: l10n.settingsBackupImportTitle, - subtitle: l10n.settingsBackupImportSubtitle, - onTap: () => _importBackup(context, ref), - ), + if (!decoy) ...[ + _buildSectionHeader(l10n.settingsSectionBackup), + _buildTile( + icon: Icons.key_outlined, + title: l10n.settingsSeedPhraseTitle, + subtitle: l10n.settingsSeedPhraseSubtitle, + onTap: () => _showSeedPhrase(context, ref), + ), + _buildTile( + icon: Icons.backup_outlined, + title: l10n.settingsBackupExportTitle, + subtitle: l10n.settingsBackupExportSubtitle, + onTap: () => _exportBackup(context, ref), + ), + _buildTile( + icon: Icons.restore_outlined, + title: l10n.settingsBackupImportTitle, + subtitle: l10n.settingsBackupImportSubtitle, + onTap: () => _importBackup(context, ref), + ), + ], // „Web klient" byl slib funkce, která neexistuje (multi-device je // vlna 3). Apple Guideline 2.1 takové dlaždice bere jako nedodělek. const SizedBox(height: 8), @@ -230,17 +238,18 @@ class SettingsScreen extends ConsumerWidget { ), const SizedBox(height: 24), // Smazat účet - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: OutlinedButton( - onPressed: () => _confirmDeleteAccount(context, ref), - style: OutlinedButton.styleFrom( - foregroundColor: SepotColors.error, - side: const BorderSide(color: SepotColors.error), + if (!decoy) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: OutlinedButton( + onPressed: () => _confirmDeleteAccount(context, ref), + style: OutlinedButton.styleFrom( + foregroundColor: SepotColors.error, + side: const BorderSide(color: SepotColors.error), + ), + child: Text(l10n.settingsDeleteAllDataButton), ), - child: Text(l10n.settingsDeleteAllDataButton), ), - ), const SizedBox(height: 48), ], ), @@ -514,10 +523,17 @@ class SettingsScreen extends ConsumerWidget { final dir = await getTemporaryDirectory(); final path = '${dir.path}/sepot-zaloha.sepotbackup'; await File(path).writeAsBytes(bytes); - await Share.shareXFiles( - [XFile(path, mimeType: 'application/octet-stream')], - subject: l10n.backupShareSubject, - ); + try { + await Share.shareXFiles( + [XFile(path, mimeType: 'application/octet-stream')], + subject: l10n.backupShareSubject, + ); + } finally { + // Šifrovaná, ale v cache nemá co pohledávat po skončení sdílení. + try { + await File(path).delete(); + } catch (_) {} + } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/app/lib/l10n/app_cs.arb b/app/lib/l10n/app_cs.arb index 8cf4acf..4ec4a11 100644 --- a/app/lib/l10n/app_cs.arb +++ b/app/lib/l10n/app_cs.arb @@ -1481,6 +1481,15 @@ "@pinUnlockError": { "description": "Červená chybová hláška na odemykací obrazovce při jiné chybě odemčení než špatný PIN" }, + "pinLockedFor": "Příliš mnoho pokusů. Zkus to za {seconds} s.", + "@pinLockedFor": { + "description": "Hláška na odemykací obrazovce po sérii špatných PINů; {seconds} = počet sekund do dalšího pokusu", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, "pushTitle": "Push notifikace", "@pushTitle": { "description": "Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)" diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 4510dd1..495bc7b 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -315,6 +315,7 @@ "pinUnlockWithBiometrics": "Mit Biometrie entsperren", "pinWrong2": "Falsche PIN", "pinUnlockError": "Fehler beim Entsperren", + "pinLockedFor": "Zu viele Versuche. Versuche es in {seconds} s erneut.", "pushTitle": "Push-Benachrichtigungen", "pushModeOffDesc": "Nachrichten werden erst beim Öffnen der App abgerufen.", "pushModeUp": "UnifiedPush (ohne Google)", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 893ac7f..4642169 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -315,6 +315,7 @@ "pinUnlockWithBiometrics": "Unlock with biometrics", "pinWrong2": "Wrong PIN", "pinUnlockError": "Unlock error", + "pinLockedFor": "Too many attempts. Try again in {seconds} s.", "pushTitle": "Push notifications", "pushModeOffDesc": "Messages arrive only after you open the app.", "pushModeUp": "UnifiedPush (no Google)", diff --git a/app/lib/l10n/app_it.arb b/app/lib/l10n/app_it.arb index 0f0c9e0..83c8e8a 100644 --- a/app/lib/l10n/app_it.arb +++ b/app/lib/l10n/app_it.arb @@ -315,6 +315,7 @@ "pinUnlockWithBiometrics": "Sblocca con biometria", "pinWrong2": "PIN errato", "pinUnlockError": "Errore di sblocco", + "pinLockedFor": "Troppi tentativi. Riprova tra {seconds} s.", "pushTitle": "Notifiche push", "pushModeOffDesc": "I messaggi verranno scaricati solo all'apertura dell'app.", "pushModeUp": "UnifiedPush (senza Google)", diff --git a/app/lib/l10n/app_ru.arb b/app/lib/l10n/app_ru.arb index eb80d78..6cd0eb3 100644 --- a/app/lib/l10n/app_ru.arb +++ b/app/lib/l10n/app_ru.arb @@ -315,6 +315,7 @@ "pinUnlockWithBiometrics": "Разблокировать биометрией", "pinWrong2": "Неверный PIN", "pinUnlockError": "Ошибка разблокировки", + "pinLockedFor": "Слишком много попыток. Повторите через {seconds} с.", "pushTitle": "Push-уведомления", "pushModeOffDesc": "Сообщения загрузятся только после открытия приложения.", "pushModeUp": "UnifiedPush (без Google)", diff --git a/app/lib/l10n/generated/app_localizations.dart b/app/lib/l10n/generated/app_localizations.dart index 6b33dda..411bedd 100644 --- a/app/lib/l10n/generated/app_localizations.dart +++ b/app/lib/l10n/generated/app_localizations.dart @@ -1994,6 +1994,12 @@ abstract class AppLocalizations { /// **'Chyba odemčení'** String get pinUnlockError; + /// Hláška na odemykací obrazovce po sérii špatných PINů; {seconds} = počet sekund do dalšího pokusu + /// + /// In cs, this message translates to: + /// **'Příliš mnoho pokusů. Zkus to za {seconds} s.'** + String pinLockedFor(int seconds); + /// Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44) /// /// In cs, this message translates to: diff --git a/app/lib/l10n/generated/app_localizations_cs.dart b/app/lib/l10n/generated/app_localizations_cs.dart index 6529486..9b31bf2 100644 --- a/app/lib/l10n/generated/app_localizations_cs.dart +++ b/app/lib/l10n/generated/app_localizations_cs.dart @@ -1102,6 +1102,11 @@ class AppLocalizationsCs extends AppLocalizations { @override String get pinUnlockError => 'Chyba odemčení'; + @override + String pinLockedFor(int seconds) { + return 'Příliš mnoho pokusů. Zkus to za $seconds s.'; + } + @override String get pushTitle => 'Push notifikace'; diff --git a/app/lib/l10n/generated/app_localizations_de.dart b/app/lib/l10n/generated/app_localizations_de.dart index a91a2ab..aa8cc73 100644 --- a/app/lib/l10n/generated/app_localizations_de.dart +++ b/app/lib/l10n/generated/app_localizations_de.dart @@ -1121,6 +1121,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get pinUnlockError => 'Fehler beim Entsperren'; + @override + String pinLockedFor(int seconds) { + return 'Zu viele Versuche. Versuche es in $seconds s erneut.'; + } + @override String get pushTitle => 'Push-Benachrichtigungen'; diff --git a/app/lib/l10n/generated/app_localizations_en.dart b/app/lib/l10n/generated/app_localizations_en.dart index 9914fce..9fee1ad 100644 --- a/app/lib/l10n/generated/app_localizations_en.dart +++ b/app/lib/l10n/generated/app_localizations_en.dart @@ -1101,6 +1101,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pinUnlockError => 'Unlock error'; + @override + String pinLockedFor(int seconds) { + return 'Too many attempts. Try again in $seconds s.'; + } + @override String get pushTitle => 'Push notifications'; diff --git a/app/lib/l10n/generated/app_localizations_it.dart b/app/lib/l10n/generated/app_localizations_it.dart index 2514863..947336f 100644 --- a/app/lib/l10n/generated/app_localizations_it.dart +++ b/app/lib/l10n/generated/app_localizations_it.dart @@ -1111,6 +1111,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String get pinUnlockError => 'Errore di sblocco'; + @override + String pinLockedFor(int seconds) { + return 'Troppi tentativi. Riprova tra $seconds s.'; + } + @override String get pushTitle => 'Notifiche push'; diff --git a/app/lib/l10n/generated/app_localizations_ru.dart b/app/lib/l10n/generated/app_localizations_ru.dart index 445d9e3..81c91ca 100644 --- a/app/lib/l10n/generated/app_localizations_ru.dart +++ b/app/lib/l10n/generated/app_localizations_ru.dart @@ -1118,6 +1118,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get pinUnlockError => 'Ошибка разблокировки'; + @override + String pinLockedFor(int seconds) { + return 'Слишком много попыток. Повторите через $seconds с.'; + } + @override String get pushTitle => 'Push-уведомления'; diff --git a/app/lib/models/attachment.dart b/app/lib/models/attachment.dart index 7ed231a..90b6d93 100644 --- a/app/lib/models/attachment.dart +++ b/app/lib/models/attachment.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:sepot/core/constants/app_constants.dart'; + /// Stav přenosu přílohy — čistě lokální (na drát nikdy nejde). enum AttachmentTransferState { /// Přijatá zpráva, blob se ještě nezačal stahovat. @@ -94,14 +96,22 @@ class AttachmentMeta { /// (blob_id formát hlídá AttachmentStore, tady rozsahy a typy). factory AttachmentMeta.fromWire(Map wire) { final size = wire['s'] as int; - if (size <= 0 || size > 512 * 1024 * 1024) { + if (size <= 0 || size > AppConstants.maxAttachmentBytes) { throw const FormatException('implausible attachment size'); } - // Krypto pole MUSÍ být validní base64 (jinak je meta k ničemu); - // vyhodí FormatException → zpráva se zahodí jako vadná. - base64Decode(wire['k'] as String); - base64Decode(wire['n'] as String); - base64Decode(wire['d'] as String); + // Krypto pole MUSÍ být validní base64 správné délky (AES-256-GCM klíč, + // 96bitová nonce, SHA-256 digest) — jinak by vadná meta prošla a + // selhala až při otevření přílohy, kdy je obálka dávno ACKnutá. + if (base64Decode(wire['k'] as String).length != 32 || + base64Decode(wire['n'] as String).length != 12 || + base64Decode(wire['d'] as String).length != 32) { + throw const FormatException('invalid attachment key material'); + } + final name = wire['name'] as String?; + final mime = wire['mime'] as String; + if ((name != null && name.length > 255) || mime.length > 100) { + throw const FormatException('attachment name or mime too long'); + } // Vadný náhled zprávu nezabíjí — UI ukáže placeholder a ostrá fotka // se stáhne z blobu. base64Decode v build() by shodil celou bublinu. var thumb = wire['th'] as String?; @@ -118,8 +128,8 @@ class AttachmentMeta { nonceB64: wire['n'] as String, digestB64: wire['d'] as String, size: size, - mime: wire['mime'] as String, - name: wire['name'] as String?, + mime: mime, + name: name, width: wire['w'] as int?, height: wire['h'] as int?, thumbB64: thumb, diff --git a/app/lib/models/identity.dart b/app/lib/models/identity.dart index 16622eb..df55f99 100644 --- a/app/lib/models/identity.dart +++ b/app/lib/models/identity.dart @@ -146,6 +146,9 @@ class LocalIdentity { /// kontaktu poslat první zpráva. Obě pole jsou náhodná — s identitou je /// spojuje jen sám uživatel tím, že je sdílí ve svém odkazu. class PublicIdentity { + /// Nejvyšší délka jména z odkazu/QR — delší odkaz je vadný vstup. + static const maxDisplayNameLength = 64; + PublicIdentity({ required this.displayName, required this.identityKey, @@ -214,7 +217,14 @@ class PublicIdentity { as Map; if (map['v'] != 2) return null; final identityKey = base64Decode(map['ik'] as String); - if (identityKey.length != 33) return null; + // 33 B = typový bajt 0x05 + Curve25519; jiný prefix libsignal odmítne + // až při první zprávě, tady ho odmítneme hned. + if (identityKey.length != 33 || identityKey[0] != 0x05) return null; + final aid = map['aid'] as String?; + // Relay dává 32 hex znaků; starší testovací odkazy kratší — hex a strop stačí. + if (aid != null && !RegExp(r'^[0-9a-f]{8,64}$').hasMatch(aid)) return null; + final rawName = (map['n'] as String).trim(); + if (rawName.length > maxDisplayNameLength) return null; final da = map['da'] as String?; Uint8List? deliveryAddress; if (da != null) { @@ -224,10 +234,10 @@ class PublicIdentity { if (deliveryAddress.length != 32) return null; } return PublicIdentity( - displayName: map['n'] as String, + displayName: rawName, identityKey: identityKey, createdAt: DateTime.parse(map['ts'] as String), - accountId: map['aid'] as String?, + accountId: aid, deliveryAddress: deliveryAddress, ); } catch (_) { diff --git a/app/lib/providers/messaging_provider.dart b/app/lib/providers/messaging_provider.dart index 497eb2f..d3ac43a 100644 --- a/app/lib/providers/messaging_provider.dart +++ b/app/lib/providers/messaging_provider.dart @@ -55,6 +55,9 @@ final messagingStartProvider = FutureProvider((ref) async { // se provider invaliduje (viz unlock cesty) a spustí start znovu. final storage = ref.read(storageProvider); if (!storage.isUnlocked) return; + // Nátlaková relace: žádná síť. Messaging by se registroval skutečným + // identity klíčem (409 každých 30 s = důkaz pro pozorovatele sítě). + if (storage.isDecoyMode) return; final service = ref.read(messagingServiceProvider); if (service.isStarted) return; diff --git a/app/lib/providers/settings_provider.dart b/app/lib/providers/settings_provider.dart index b947fc8..2195925 100644 --- a/app/lib/providers/settings_provider.dart +++ b/app/lib/providers/settings_provider.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:screen_protector/screen_protector.dart'; import 'package:sepot/core/backup/backup_service.dart'; @@ -57,14 +58,21 @@ final typingIndicatorsProvider = TypingIndicatorsNotifier.new); /// Zabezpečení obrazovky: blok screenshotů (Android FLAG_SECURE) + skrytí -/// obsahu v přepínači appek (iOS). Default VYPNUTO — blokuje i vlastní -/// screenshoty, tak ať se uživatel rozhodne sám. +/// obsahu v přepínači appek (iOS). Výchozí ZAPNUTO jako u Signalu — stejné +/// jako nativní default v MainActivity, takže přepínač už neukazuje +/// „vypnuto" u obrazovky, která chráněná je. class ScreenSecurityNotifier extends AsyncNotifier { static const key = 'screen_security_enabled'; + // Debug build bez ochrany: QA na emulátoru potřebuje screenshoty. + static const vychozi = !kDebugMode; @override Future build() async { - final v = (await ref.read(storageProvider).getSetting(key)) ?? false; + final storage = ref.read(storageProvider); + // Před odemčením se nastavení přečíst nedá; nic neaplikovat (nativní + // default platí) a po unlocku se provider invaliduje. + if (!storage.isUnlocked) return vychozi; + final v = (await storage.getSetting(key)) ?? vychozi; await _apply(v); return v; } diff --git a/app/test/content_types_test.dart b/app/test/content_types_test.dart new file mode 100644 index 0000000..53c4f6f --- /dev/null +++ b/app/test/content_types_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sepot/core/services/messaging_service.dart'; + +/// Přijímací cesta zahazuje obsah neznámého typu (ACK + drop, protože po +/// upgradu už nejde dešifrovat znovu). Každý typ, který appka sama posílá, +/// proto MUSÍ být v množině známých typů. V 1.2.0 tu chyběl `call` — každá +/// nabídka hovoru se tiše zahodila a hovory nešly přijmout. +void main() { + test('všechny odesílané typy obsahu umí přijímací cesta zpracovat', () { + const sent = { + 'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote', 'call', + 'ginv', 'gleave', 'gmeta', 'gop', 'gsync', + }; + expect(MessagingService.knownContentTypes, containsAll(sent)); + }); + + test('signalizace hovoru je známý typ', () { + expect(MessagingService.knownContentTypes, contains('call')); + }); +} diff --git a/app/test/crypto/dek_wrap_shared_salt_test.dart b/app/test/crypto/dek_wrap_shared_salt_test.dart new file mode 100644 index 0000000..f74c80c --- /dev/null +++ b/app/test/crypto/dek_wrap_shared_salt_test.dart @@ -0,0 +1,49 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sepot/core/crypto/dek_wrap.dart'; + +/// Skutečný a nátlakový blob se sdílenou solí se zkouší jednou derivací: +/// který PIN padl, nejde poznat z času odemčení. +void main() { + final real = Uint8List.fromList(List.generate(32, (i) => i)); + final decoy = Uint8List.fromList(List.generate(32, (i) => 255 - i)); + // Nejslabší povolené parametry — test nemá čekat na 64 MiB derivaci. + const m = DekWrap.floorMemoryKiB; + const t = DekWrap.floorIterations; + + test('sdílená sůl: oba PINy se najdou, špatný PIN padne', () async { + final wReal = await DekWrap.wrap( + dek: real, pin: '111111', memoryKiB: m, iterations: t); + final salt = DekWrap.saltOf(wReal); + final wDecoy = await DekWrap.wrap( + dek: decoy, pin: '222222', memoryKiB: m, iterations: t, salt: salt); + expect(DekWrap.saltOf(wDecoy), salt); + + final (i1, d1) = await DekWrap.unwrapFirstMatching( + wrapped: [wReal, wDecoy], pin: '111111'); + expect(i1, 0); + expect(d1, real); + + final (i2, d2) = await DekWrap.unwrapFirstMatching( + wrapped: [wReal, wDecoy], pin: '222222'); + expect(i2, 1); + expect(d2, decoy); + + await expectLater( + DekWrap.unwrapFirstMatching(wrapped: [wReal, wDecoy], pin: '333333'), + throwsA(isA()), + ); + }); + + test('různé soli (starší instalace) fungují dál', () async { + final wReal = await DekWrap.wrap( + dek: real, pin: '111111', memoryKiB: m, iterations: t); + final wDecoy = await DekWrap.wrap( + dek: decoy, pin: '222222', memoryKiB: m, iterations: t); + final (i, d) = await DekWrap.unwrapFirstMatching( + wrapped: [wReal, wDecoy], pin: '222222'); + expect(i, 1); + expect(d, decoy); + }); +} diff --git a/app/test/storage/decoy_isolation_test.dart b/app/test/storage/decoy_isolation_test.dart new file mode 100644 index 0000000..da19f8d --- /dev/null +++ b/app/test/storage/decoy_isolation_test.dart @@ -0,0 +1,86 @@ +// Argon2id (64 MiB) ×6 pod zátěží celé sady: víc než výchozích 30 s. +@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/dek_wrap.dart'; +import 'package:sepot/core/crypto/sepot_crypto.dart'; +import 'package:sepot/core/storage/secure_storage.dart'; +import 'package:sepot/models/identity.dart'; + +/// Nátlaková (decoy) relace musí být izolovaná od skutečného účtu: +/// jiná identita, žádná fráze, „smazat" nesáhne na skutečná data, +/// a zámek režim resetuje. Keystore je nahrazený mockem, DB jde do temp. +/// Potřebuje nativní libsignal (LocalIdentity) a sqlcipher. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory tmp; + late SecureStorageService storage; + + setUpAll(() async => SepotCrypto.init()); + + setUp(() async { + FlutterSecureStorage.setMockInitialValues({}); + tmp = Directory.systemTemp.createTempSync('sepot_decoy'); + storage = SecureStorageService(dbPath: '${tmp.path}/app.db'); + await storage.unlockWithoutPin(); + await storage.saveIdentity(await LocalIdentity.create('Alice')); + await storage.saveSeedPhrase(List.filled(24, 'abandon')); + await storage.enablePin('111111'); + await storage.enableDecoyPin('222222'); + storage.lock(); + }); + + tearDown(() { + storage.lock(); + tmp.deleteSync(recursive: true); + }); + + test('decoy PIN otevře izolovanou relaci', () async { + await storage.unlockWithPin('222222'); + expect(storage.isDecoyMode, isTrue); + expect(await storage.loadSeedPhrase(), isNull); + final shown = await storage.loadIdentity(); + expect(shown, isNotNull); + + storage.lock(); + expect(storage.isDecoyMode, isFalse); + await storage.unlockWithPin('111111'); + expect(storage.isDecoyMode, isFalse); + final real = await storage.loadIdentity(); + expect(real!.id, isNot(shown!.id), reason: 'decoy ukazuje jinou identitu'); + expect(await storage.loadSeedPhrase(), hasLength(24)); + }); + + test('smazání v decoy relaci nechá skutečná data', () async { + await storage.unlockWithPin('222222'); + await storage.deleteAll(); + expect(File('${tmp.path}/app.db').existsSync(), isTrue); + expect(await storage.hasIdentity(), isTrue); + expect(await storage.hasPin(), isTrue); + await storage.unlockWithPin('111111'); + expect(storage.isDecoyMode, isFalse); + }); + + test('změny keystore jsou v decoy relaci zakázané', () async { + await storage.unlockWithPin('222222'); + expect(() => storage.enableBiometrics(), throwsStateError); + expect(() => storage.disablePin(), throwsStateError); + expect( + () => storage.changePin(oldPin: '222222', newPin: '333333'), + throwsStateError); + }); + + test('po pěti špatných PINech se zámek na čas zavře', () async { + for (var i = 0; i < 5; i++) { + await expectLater(storage.unlockWithPin('000000'), + throwsA(isA())); + } + expect(await storage.pinLockRemaining(), isNotNull); + await expectLater(storage.unlockWithPin('111111'), + throwsA(isA())); + }); +}