diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index bac0a92..02b5d74 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -61,6 +61,12 @@ UIApplicationSupportsIndirectInputEvents NSCameraUsageDescription - Камера нужна для сканирования QR-кода входа в веб-версию и приложение MAX на компьютере. + Камера нужна для съёмки фото и видео в чатах, сканирования QR-кода входа и работы веб-приложений. + NSMicrophoneUsageDescription + Микрофон нужен для записи голосовых сообщений, видео и звонков. + NSPhotoLibraryUsageDescription + Доступ к галерее нужен, чтобы отправлять фото и видео в чатах. + NSPhotoLibraryAddUsageDescription + Доступ к галерее нужен, чтобы сохранять полученные фото и видео. diff --git a/lib/backend/api.dart b/lib/backend/api.dart index a3fb613..43234f8 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -301,15 +301,15 @@ class Api { /// Стрим всех входящих пушей от сервера. Stream get pushStream => _dispatcher.pushStream; - void dispose() { + Future dispose() async { _autoReconnect = false; _reconnectTimer?.cancel(); _cleanup(); _dispatcher.dispose(); - _connection.dispose(); - _stateController.close(); - _sessionExpiredController.close(); - _handshakeSuccessController.close(); + await _connection.dispose(); + await _stateController.close(); + await _sessionExpiredController.close(); + await _handshakeSuccessController.close(); } // Внутрянка diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 333f0d9..a71307d 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -19,6 +19,11 @@ String _normalizeAuthPhone(String phone) { return '+$digits'; } +String _maskPhone(String phone) { + if (phone.length <= 5) return '***'; + return '${phone.substring(0, 3)}***${phone.substring(phone.length - 2)}'; +} + class PrivacyConfig { final String searchByPhone; final String incomingCall; @@ -1408,7 +1413,7 @@ class AccountModule { 'language': language, }; - logger.i('Запрос OTP-кода: phone=$normalizedPhone type=${type.value}'); + logger.i('Запрос OTP-кода: phone=${_maskPhone(normalizedPhone)} type=${type.value}'); final packet = await _api.sendRequest(Opcode.authRequest, payload); diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index bcc1a7a..0699017 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -191,6 +191,7 @@ class ChatsModule { static StreamSubscription? _globalPushSub; static StreamSubscription? _globalStateSub; + static Future _pushQueue = Future.value(); static final Set _dirtyChats = {}; static final Set _knownChats = {}; @@ -203,7 +204,7 @@ class ChatsModule { static void attachGlobalPushHandlers(Api api) { _globalPushSub?.cancel(); _globalStateSub?.cancel(); - _globalPushSub = api.pushStream.listen(_handleGlobalPush); + _globalPushSub = api.pushStream.listen(_enqueueGlobalPush); _globalStateSub = api.stateStream.listen(_handleSessionState); if (api.state != SessionState.online) { _markAllKnownChatsDirty(); @@ -240,6 +241,14 @@ class ChatsModule { _dirtyChats.addAll(_knownChats); } + static void _enqueueGlobalPush(Packet packet) { + _pushQueue = _pushQueue + .then((_) => _handleGlobalPush(packet)) + .catchError((Object e) { + logger.w('Ошибка обработки пуша: $e'); + }); + } + static Future _handleGlobalPush(Packet packet) async { switch (packet.opcode) { case Opcode.notifMessage: diff --git a/lib/backend/modules/digital_id.dart b/lib/backend/modules/digital_id.dart index 19e4bb1..03a66ec 100644 --- a/lib/backend/modules/digital_id.dart +++ b/lib/backend/modules/digital_id.dart @@ -286,6 +286,7 @@ class DigitalIdModule { await _send('DELETE', '/v3/digital-id/delete-profile'); final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { + await TokenStorage.deleteSecure('${_tokenKey}_$accountId'); await AppDatabase.setSyncValue(accountId, _tokenKey, ''); } } @@ -293,14 +294,23 @@ class DigitalIdModule { Future _storedToken() async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return null; - final value = await AppDatabase.getSyncValue(accountId, _tokenKey); - return (value != null && value.isNotEmpty) ? value : null; + final secureKey = '${_tokenKey}_$accountId'; + final secured = await TokenStorage.readSecure(secureKey); + if (secured != null && secured.isNotEmpty) return secured; + + final legacy = await AppDatabase.getSyncValue(accountId, _tokenKey); + if (legacy != null && legacy.isNotEmpty) { + await TokenStorage.writeSecure(secureKey, legacy); + await AppDatabase.setSyncValue(accountId, _tokenKey, ''); + return legacy; + } + return null; } Future _saveToken(String token) async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { - await AppDatabase.setSyncValue(accountId, _tokenKey, token); + await TokenStorage.writeSecure('${_tokenKey}_$accountId', token); } } diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 3091e60..3dc5342 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -7,6 +7,7 @@ import '../api.dart'; import '../../core/config/proxy_config.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/transport/proxy_connector.dart'; +import '../../core/transport/tls_config.dart'; import '../../core/utils/logger.dart'; import 'messages.dart'; @@ -158,11 +159,12 @@ class FileUploader { ? await ProxyConnector(proxySettings).connect(uri.host, uri.port) : await Socket.connect(uri.host, uri.port); if (uri.scheme != 'https') return base; - return SecureSocket.secure( - base, - host: uri.host, - onBadCertificate: (_) => true, - ); + final allowInsecure = await TlsConfig.isInsecureAllowed(); + if (allowInsecure) { + logger.w('TLS: проверка сертификата отключена (дебаг) — загрузка уязвима к MitM'); + return SecureSocket.secure(base, host: uri.host, onBadCertificate: (_) => true); + } + return SecureSocket.secure(base, host: uri.host); } void _writeHeaders( diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index e893153..bbca3cb 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -250,6 +250,18 @@ class CachedMessage { ); } + static List _decodeRows(List> rows) => + rows.map(CachedMessage.fromDbRow).toList(); + + static Future> fromDbRowsAsync( + List> rows, + ) { + if (rows.length < 20) { + return Future.value(_decodeRows(rows)); + } + return compute(_decodeRows, rows); + } + Map toDbRow() => { 'id': id, 'account_id': accountId, @@ -361,7 +373,7 @@ class MessagesModule { limit: limit, offset: offset, ); - return rows.map(CachedMessage.fromDbRow).toList(); + return CachedMessage.fromDbRowsAsync(rows); } CachedMessage? _parseMessage( diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 448a866..5e16093 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -119,60 +119,57 @@ Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { return out; } -/// Распаковка пакета от сервера +const int _isolateDecodeThreshold = 4096; + Future unpackPacket(Uint8List packet) async { - return Isolate.run(() { - // Для удобства расшифровки пакета переводим в ByteData - ByteData packetData = ByteData.view( - packet.buffer, - packet.offsetInBytes, - packet.lengthInBytes, - ); + final header = ByteData.sublistView(packet); - // API версия и cmd представляют из себя 8 битные числа - final apiVer = packetData.getUint8(0) & 0xFF; - final cmd = packetData.getUint8(1) & 0xFF; + final apiVer = header.getUint8(0) & 0xFF; + final cmd = header.getUint8(1) & 0xFF; + final seq = header.getUint16(2) & 0xFFFF; + final opcode = header.getUint16(4) & 0xFFFF; + final packedLen = header.getUint32(6); + final compFlag = packedLen >> 24; + final payloadLength = packedLen & 0xFFFFFF; - // Sequence и OPCode представляют из себя 16 битные числа - final seq = packetData.getUint16(2) & 0xFFFF; - final opcode = packetData.getUint16(4) & 0xFFFF; + if (payloadLength == 0) { + return Packet(api: apiVer, cmd: cmd, seq: seq, opcode: opcode); + } - // После базовых переменных идет длина пакета, является 32 битным числом - final packedLen = packetData.getUint32(6); + final end = headerSize + payloadLength; + if (end > packet.length) { + throw Exception('Packet payload length $payloadLength exceeds buffer'); + } + final slice = Uint8List.sublistView(packet, headerSize, end); - // Compression flag показывает, сжат ли payload - final compFlag = packedLen >> 24; + dynamic payload; + if (compFlag == 0 && slice.length < _isolateDecodeThreshold) { + payload = _deserializePayload(slice, compFlag); + } else { + final owned = Uint8List.fromList(slice); + payload = await Isolate.run(() => _deserializePayload(owned, compFlag)); + } - // Длина payload'а - final payloadLength = packedLen & 0xFFFFFF; + return Packet( + api: apiVer, + cmd: cmd, + seq: seq, + opcode: opcode, + payload: payload, + ); +} - // Байты payload'а, могут быть сжаты LZ4 - var payloadBytes = packet.buffer.asUint8List(10, payloadLength); - - dynamic payload; - - if (payloadBytes.isNotEmpty) { - if (compFlag != 0) { - payloadBytes = _decompressPayload(payloadBytes); - } - - try { - payload = msgpack.deserialize(payloadBytes); - } catch (e) { - if (payloadBytes.isNotEmpty) { - throw Exception("MsgPack deserialization error: $e"); - } - } - } - - return Packet( - api: apiVer, - cmd: cmd, - seq: seq, - opcode: opcode, - payload: payload, - ); - }); +dynamic _deserializePayload(Uint8List payloadBytes, int compFlag) { + var bytes = payloadBytes; + if (compFlag != 0) { + bytes = _decompressPayload(bytes); + } + if (bytes.isEmpty) return null; + try { + return msgpack.deserialize(bytes); + } catch (e) { + throw Exception('MsgPack deserialization error: $e'); + } } /// Определяет формат сжатия по magic-number и распаковывает payload. @@ -185,7 +182,7 @@ Uint8List _decompressPayload(Uint8List src) { src[2] == 0x2F && src[3] == 0xFD) { try { - return ZstdCodec().decompress(src); + return ZstdCodec(maxDecompressedSize: _maxDecompressedSize).decompress(src); } catch (e) { throw Exception('Zstd decompression error: $e'); } @@ -215,9 +212,23 @@ Uint8List _decompressPayload(Uint8List src) { /// LZ4 block декомпрессия (без frame-заголовка). /// Сервер шлёт именно block-формат, dart_lz4 его не поддерживает. Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) { - final dst = BytesBuilder(copy: false); + var out = Uint8List(1024); + int outLen = 0; int pos = 0; + void ensure(int extra) { + if (outLen + extra > maxSize) throw StateError('LZ4: превышен лимит'); + if (outLen + extra <= out.length) return; + var newCap = out.length * 2; + while (newCap < outLen + extra) { + newCap *= 2; + } + if (newCap > maxSize) newCap = maxSize; + final grown = Uint8List(newCap); + grown.setRange(0, outLen, out); + out = grown; + } + while (pos < src.length) { final token = src[pos++]; var litLen = token >> 4; @@ -231,7 +242,9 @@ Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) { } if (litLen > 0) { - dst.add(src.sublist(pos, pos + litLen)); + ensure(litLen); + out.setRange(outLen, outLen + litLen, src, pos); + outLen += litLen; pos += litLen; } @@ -251,12 +264,14 @@ Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) { } } - final out = dst.toBytes(); - final start = out.length - offset; - dst.add(List.generate(matchLen, (i) => out[start + (i % offset)])); - - if (dst.length > maxSize) throw StateError('LZ4: превышен лимит'); + ensure(matchLen); + final start = outLen - offset; + if (start < 0) throw StateError('LZ4: offset за пределами вывода'); + for (var i = 0; i < matchLen; i++) { + out[outLen + i] = out[start + i]; + } + outLen += matchLen; } - return dst.toBytes(); + return Uint8List.sublistView(out, 0, outLen); } diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index f1cccfa..93af2bb 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -159,7 +159,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 10, + version: 11, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -209,6 +209,9 @@ class AppDatabase { 'ALTER TABLE chats_cache ADD COLUMN admins TEXT', ); } + if (oldVersion < 11) { + await _createIndexes(db); + } }, ); } @@ -234,6 +237,19 @@ class AppDatabase { await db.execute(_chatsCacheSchema); await db.execute(_contactsSchema); await db.execute(_messagesSchema); + await _createIndexes(db); + } + + static Future _createIndexes(Database db) async { + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(account_id, chat_id, time DESC)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_chats_account ON chats_cache(account_id, last_event_time DESC)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_contacts_account ON contacts(account_id)', + ); } static const _contactsSchema = ''' diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index 2d4f600..64002a7 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -1,22 +1,50 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; class TokenStorage { static const _tokenPrefix = 'auth_token_'; static const _activeAccountKey = 'active_account_id'; + static const _secure = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); + + static Future writeSecure(String key, String value) async { + await _secure.write(key: key, value: value); + } + + static Future readSecure(String key) async { + return _secure.read(key: key); + } + + static Future deleteSecure(String key) async { + await _secure.delete(key: key); + } + static Future saveToken(String token, int accountId) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('$_tokenPrefix$accountId', token); + await _secure.write(key: '$_tokenPrefix$accountId', value: token); } static Future readToken(int accountId) async { + final key = '$_tokenPrefix$accountId'; + final secured = await _secure.read(key: key); + if (secured != null) return secured; + final prefs = await SharedPreferences.getInstance(); - return prefs.getString('$_tokenPrefix$accountId'); + final legacy = prefs.getString(key); + if (legacy != null) { + await _secure.write(key: key, value: legacy); + await prefs.remove(key); + return legacy; + } + return null; } static Future deleteToken(int accountId) async { + final key = '$_tokenPrefix$accountId'; + await _secure.delete(key: key); final prefs = await SharedPreferences.getInstance(); - await prefs.remove('$_tokenPrefix$accountId'); + await prefs.remove(key); } static Future setActiveAccount(int accountId) async { diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index afb9dd9..3b38410 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -30,7 +30,7 @@ class Connection { void _setState(SocketState newState) { if (_state == newState) return; _state = newState; - _stateController.add(newState); + if (!_stateController.isClosed) _stateController.add(newState); } Future connect( @@ -65,7 +65,9 @@ class Connection { logger.i('Подключено к $host:$port'); _subscription = _socket!.listen( - (data) => _dataController.add(data), + (data) { + if (!_dataController.isClosed) _dataController.add(data); + }, onError: (Object error) { logger.e('Ошибка сокета: $error'); disconnect(); @@ -138,9 +140,9 @@ class Connection { _setState(SocketState.disconnected); } - void dispose() { - disconnect(); - _dataController.close(); - _stateController.close(); + Future dispose() async { + await disconnect(); + await _dataController.close(); + await _stateController.close(); } } diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index ff776c8..8562283 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -33,6 +33,12 @@ class PacketDispatcher { /// Регистрирует ожидание ответа — future завершится когда /// придёт пакет с совпадающим seq. Future registerPending(int seq) { + final existing = _pendingRequests[seq]; + if (existing != null && !existing.isCompleted) { + existing.completeError( + StateError('seq=$seq переиспользован до получения ответа'), + ); + } final completer = Completer(); _pendingRequests[seq] = completer; _requestTimestamps[seq] = DateTime.now(); @@ -55,7 +61,7 @@ class PacketDispatcher { packet.cmd == CmdType.error || packet.cmd == CmdType.notFound) { logger.i( - '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${redactForLog(packet.payload)}}', + '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${payloadForLog(packet.payload)}}', ); final completer = _pendingRequests.remove(packet.seq); @@ -84,7 +90,7 @@ class PacketDispatcher { } } else if (packet.isPush) { logger.i( - '<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${redactForLog(packet.payload)}}', + '<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${payloadForLog(packet.payload)}}', ); _pushHandlers[packet.opcode]?.call(packet); _pushController.add(packet); diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart index 0c2451a..f762c9b 100644 --- a/lib/core/transport/sender.dart +++ b/lib/core/transport/sender.dart @@ -18,7 +18,7 @@ class PacketSender { final data = packPacket(opcode, payload, seq: seq); connection.write(data); logger.i( - '=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: ${redactForLog(payload)}}', + '=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: ${payloadForLog(payload)}}', ); return seq; } diff --git a/lib/core/transport/tls_config.dart b/lib/core/transport/tls_config.dart index 50d8639..4c118a9 100644 --- a/lib/core/transport/tls_config.dart +++ b/lib/core/transport/tls_config.dart @@ -1,9 +1,11 @@ +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; abstract class TlsConfig { static const String prefKey = 'dev_tls_insecure'; static Future isInsecureAllowed() async { + if (kReleaseMode) return false; final prefs = await SharedPreferences.getInstance(); return prefs.getBool(prefKey) ?? false; } diff --git a/lib/core/utils/log_redact.dart b/lib/core/utils/log_redact.dart index 3ad73e8..caad69f 100644 --- a/lib/core/utils/log_redact.dart +++ b/lib/core/utils/log_redact.dart @@ -1,6 +1,14 @@ +import 'package:flutter/foundation.dart'; + const _redacted = '***'; -const _sensitiveSubstrings = ['password', 'token', 'phone', 'secret']; +const _sensitiveSubstrings = [ + 'password', + 'token', + 'phone', + 'secret', + 'auth', +]; const _sensitiveExact = { 'code', @@ -12,6 +20,10 @@ const _sensitiveExact = { 'qrlink', 'text', 'msisdn', + 'deviceid', + 'mt_instanceid', + 'instanceid', + 'webappdata', }; bool _isSensitiveKey(Object? key) { @@ -37,3 +49,8 @@ dynamic redactForLog(dynamic value) { } return value; } + +Object? payloadForLog(dynamic value) { + if (kReleaseMode) return '