From 4dfa1fb8ac11325bcb8dab06ec051fd943448a53 Mon Sep 17 00:00:00 2001 From: jganenok Date: Tue, 2 Dec 2025 15:28:29 +0700 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=A8=D0=AB=D0=A4=D0=A0=D0=90=D0=92=D0=90=D0=9D=D0=98=D0=95=20?= =?UTF-8?q?=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D1=87=D0=BD=D1=8B=D0=BC=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D0=B5=D0=BC(=D0=B2=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B1=D1=80=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=D1=85),=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D0=B0=D0=BD=D0=B4=D0=B0=D1=80=D1=82=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F,=20=D1=81=D0=BE=D0=BB=D1=8C=20+=20=D1=85=D0=BE?= =?UTF-8?q?=D1=80=20+=20=D0=B1=D0=B0=D1=81=D0=B564=20=D0=BD=D1=83=20=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D1=87=D0=B5=20=D0=B1=D0=B0=D0=B7=D0=B0,=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B6=D0=B5=D0=BB=D0=B0=D0=BD=D0=B8=D1=8E=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B6=D0=B5=D1=82=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B0=D1=82=D1=8C.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- key (2).properties | 4 + .../chat_encryption_settings_screen.dart | 177 ++++++++++++++++++ lib/screens/chat_screen.dart | 109 ++++++++++- lib/services/chat_encryption_service.dart | 171 +++++++++++++++++ lib/widgets/chat_message_bubble.dart | 38 +++- 5 files changed, 494 insertions(+), 5 deletions(-) create mode 100644 key (2).properties create mode 100644 lib/screens/chat_encryption_settings_screen.dart create mode 100644 lib/services/chat_encryption_service.dart diff --git a/key (2).properties b/key (2).properties new file mode 100644 index 0000000..93b921a --- /dev/null +++ b/key (2).properties @@ -0,0 +1,4 @@ +storePassword=01102025huy +keyPassword=01102025huy +keyAlias=my-key-alias +storeFile=komet-key.jks \ No newline at end of file diff --git a/lib/screens/chat_encryption_settings_screen.dart b/lib/screens/chat_encryption_settings_screen.dart new file mode 100644 index 0000000..c8e4e55 --- /dev/null +++ b/lib/screens/chat_encryption_settings_screen.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:gwid/services/chat_encryption_service.dart'; + +class ChatEncryptionSettingsScreen extends StatefulWidget { + final int chatId; + final bool isPasswordSet; + + const ChatEncryptionSettingsScreen({ + super.key, + required this.chatId, + required this.isPasswordSet, + }); + + @override + State createState() => + _ChatEncryptionSettingsScreenState(); +} + +class _ChatEncryptionSettingsScreenState + extends State { + final TextEditingController _passwordController = TextEditingController(); + bool _sendEncrypted = false; + bool _isPasswordCurrentlySet = false; + + @override + void initState() { + super.initState(); + _sendEncrypted = false; + _loadConfig(); + } + + Future _loadConfig() async { + final cfg = await ChatEncryptionService.getConfigForChat(widget.chatId); + if (!mounted) return; + if (cfg != null) { + _passwordController.text = cfg.password; + _isPasswordCurrentlySet = cfg.password.isNotEmpty; + _sendEncrypted = cfg.sendEncrypted; + } else { + _isPasswordCurrentlySet = widget.isPasswordSet; + _sendEncrypted = false; + } + setState(() {}); + } + + Future _savePassword() async { + final password = _passwordController.text; + // Если пароль пустой — сбрасываем флаг шифрованной отправки + final effectiveSendEncrypted = + password.isNotEmpty ? _sendEncrypted : false; + + await ChatEncryptionService.setPasswordForChat(widget.chatId, password); + await ChatEncryptionService.setSendEncryptedForChat( + widget.chatId, + effectiveSendEncrypted, + ); + if (!mounted) return; + setState(() { + _isPasswordCurrentlySet = password.isNotEmpty; + _sendEncrypted = effectiveSendEncrypted; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Пароль шифрования сохранён'), + ), + ); + } + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Пароль от шифрования'), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'ID чата: ${widget.chatId}', + style: textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + const Icon(Icons.lock), + const SizedBox(width: 8), + Text( + _isPasswordCurrentlySet + ? 'Пароль шифрования установлен' + : 'Пароль шифрования не установлен', + style: textTheme.bodyMedium?.copyWith( + color: _isPasswordCurrentlySet ? Colors.green : Colors.red, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 24), + TextField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Пароль от шифрования', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Отправлять зашифрованные сообщения в этом чате'), + value: _sendEncrypted, + onChanged: _isPasswordCurrentlySet + ? (value) { + setState(() { + _sendEncrypted = value; + }); + } + : null, + ), + const SizedBox(height: 8), + Text( + 'Пароль от расшифровки ЧУЖИХ сообщений будет тот же что и ваш', + style: GoogleFonts.manrope( + textStyle: textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _savePassword, + child: const Text('Сохранить пароль'), + ), + ), + const SizedBox(height: 24), + Text( + 'ТУТОРИАЛ', + style: GoogleFonts.manrope( + textStyle: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: colors.onSurface, + ), + ), + ), + const SizedBox(height: 8), + Text( + 'Согласуйте с другим человеком пароль. Если вы хотите обмениваться зашифрованными сообщениями друг с другом, у вас на чатах должен стоять один и тот же пароль.', + style: GoogleFonts.manrope( + textStyle: textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ); + } +} + + diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 8e69e76..e07633e 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -27,6 +27,8 @@ import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:video_player/video_player.dart'; +import 'package:gwid/screens/chat_encryption_settings_screen.dart'; +import 'package:gwid/services/chat_encryption_service.dart'; bool _debugShowExactDate = false; @@ -117,6 +119,10 @@ class _ChatScreenState extends State { int? _actualMyId; bool _isIdReady = false; + bool _isEncryptionPasswordSetForCurrentChat = + false; // TODO: hook real state later + ChatEncryptionConfig? _encryptionConfigForCurrentChat; + bool _sendEncryptedForCurrentChat = false; bool _isSearching = false; final TextEditingController _searchController = TextEditingController(); @@ -346,6 +352,18 @@ class _ChatScreenState extends State { _pinnedMessage = null; // Будет установлено при получении CONTROL сообщения с event 'pin' _initializeChat(); + _loadEncryptionConfig(); + } + + Future _loadEncryptionConfig() async { + final cfg = await ChatEncryptionService.getConfigForChat(widget.chatId); + if (!mounted) return; + setState(() { + _encryptionConfigForCurrentChat = cfg; + _isEncryptionPasswordSetForCurrentChat = + cfg != null && cfg.password.isNotEmpty; + _sendEncryptedForCurrentChat = cfg?.sendEncrypted ?? false; + }); } Future _initializeChat() async { @@ -1159,8 +1177,8 @@ class _ChatScreenState extends State { } Future _sendMessage() async { - final text = _textController.text.trim(); - if (text.isNotEmpty) { + final originalText = _textController.text.trim(); + if (originalText.isNotEmpty) { final theme = context.read(); final isBlocked = _currentContact.isBlockedByMe && !theme.blockBypass; @@ -1176,10 +1194,34 @@ class _ChatScreenState extends State { return; } + // Защита от "служебного" текста при включённом шифровании, + // чтобы не получить что-то вроде kometSM.kometSM. + if (_encryptionConfigForCurrentChat != null && + _encryptionConfigForCurrentChat!.password.isNotEmpty && + _sendEncryptedForCurrentChat && + (originalText == 'kometSM' || originalText == 'kometSM.')) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Нее, так нельзя)'))); + return; + } + + // Готовим текст с учётом возможного шифрования + String textToSend = originalText; + if (_encryptionConfigForCurrentChat != null && + _encryptionConfigForCurrentChat!.password.isNotEmpty && + _sendEncryptedForCurrentChat && + !originalText.startsWith(ChatEncryptionService.encryptedPrefix)) { + textToSend = ChatEncryptionService.encryptWithPassword( + _encryptionConfigForCurrentChat!.password, + originalText, + ); + } + final int tempCid = DateTime.now().millisecondsSinceEpoch; final tempMessageJson = { 'id': 'local_$tempCid', - 'text': text, + 'text': textToSend, 'time': tempCid, 'sender': _actualMyId!, 'cid': tempCid, @@ -1211,7 +1253,7 @@ class _ChatScreenState extends State { ApiService.instance.sendMessage( widget.chatId, - text, + textToSend, replyToMessageId: _replyingToMessage?.id, cid: tempCid, // Передаем тот же CID в API ); @@ -2332,6 +2374,24 @@ class _ChatScreenState extends State { .read() .animatePhotoMessages; + String? decryptedText; + if (_isEncryptionPasswordSetForCurrentChat && + _encryptionConfigForCurrentChat != + null && + _encryptionConfigForCurrentChat! + .password + .isNotEmpty && + item.message.text.startsWith( + ChatEncryptionService.encryptedPrefix, + )) { + decryptedText = + ChatEncryptionService.decryptWithPassword( + _encryptionConfigForCurrentChat! + .password, + item.message.text, + ); + } + final bubble = ChatMessageBubble( key: key, message: item.message, @@ -2340,6 +2400,9 @@ class _ChatScreenState extends State { deferImageLoading: deferImageLoading, myUserId: _actualMyId, chatId: widget.chatId, + isEncryptionPasswordSet: + _isEncryptionPasswordSetForCurrentChat, + decryptedText: decryptedText, onReply: widget.isChannel ? null : () => _replyToMessage(item.message), @@ -2734,6 +2797,17 @@ class _ChatScreenState extends State { _showDeleteChatDialog(); } else if (value == 'leave_group' || value == 'leave_channel') { _showLeaveGroupDialog(); + } else if (value == 'encryption_password') { + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => ChatEncryptionSettingsScreen( + chatId: widget.chatId, + isPasswordSet: _isEncryptionPasswordSetForCurrentChat, + ), + ), + ) + .then((_) => _loadEncryptionConfig()); } }, itemBuilder: (context) { @@ -2749,7 +2823,34 @@ class _ChatScreenState extends State { } final bool canDeleteChat = !widget.isGroupChat || amIAdmin; + final bool isEncryptionPasswordSet = + _isEncryptionPasswordSetForCurrentChat; + return [ + PopupMenuItem( + value: 'encryption_password', + child: Row( + children: [ + Icon( + Icons.lock, + color: isEncryptionPasswordSet + ? Colors.green + : Colors.red, + ), + const SizedBox(width: 8), + Text( + isEncryptionPasswordSet + ? 'Пароль шифрования установлен' + : 'Пароль от шифрования', + style: TextStyle( + color: isEncryptionPasswordSet + ? Colors.green + : Colors.red, + ), + ), + ], + ), + ), const PopupMenuItem( value: 'search', child: Row( diff --git a/lib/services/chat_encryption_service.dart b/lib/services/chat_encryption_service.dart new file mode 100644 index 0000000..547e8df --- /dev/null +++ b/lib/services/chat_encryption_service.dart @@ -0,0 +1,171 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:shared_preferences/shared_preferences.dart'; + +class ChatEncryptionConfig { + final String password; + final bool sendEncrypted; + + ChatEncryptionConfig({ + required this.password, + required this.sendEncrypted, + }); + + Map toJson() => { + 'password': password, + 'sendEncrypted': sendEncrypted, + }; + + factory ChatEncryptionConfig.fromJson(Map json) { + return ChatEncryptionConfig( + password: (json['password'] as String?) ?? '', + sendEncrypted: (json['sendEncrypted'] as bool?) ?? false, + ); + } +} + +class ChatEncryptionService { + static const String _legacyPasswordKeyPrefix = 'encryption_pw_'; + static const String _configKeyPrefix = 'encryption_chat_'; + static const String encryptedPrefix = 'kometSM.'; + + static final Random _rand = Random.secure(); + + /// Получить полную конфигурацию шифрования для чата. + /// Если есть старый формат (только пароль), он будет автоматически + /// сконвертирован в новый. + static Future getConfigForChat(int chatId) async { + final prefs = await SharedPreferences.getInstance(); + + final configJson = prefs.getString('$_configKeyPrefix$chatId'); + if (configJson != null) { + try { + final data = jsonDecode(configJson) as Map; + return ChatEncryptionConfig.fromJson(data); + } catch (_) { + // Если по какой-то причине json битый — игнорируем и продолжаем. + } + } + + // Поддержка старого формата только с паролем + final legacyPassword = prefs.getString('$_legacyPasswordKeyPrefix$chatId'); + if (legacyPassword != null && legacyPassword.isNotEmpty) { + final legacyConfig = ChatEncryptionConfig( + password: legacyPassword, + sendEncrypted: false, + ); + await _saveConfig(chatId, legacyConfig); + return legacyConfig; + } + + return null; + } + + static Future _saveConfig( + int chatId, + ChatEncryptionConfig config, + ) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + '$_configKeyPrefix$chatId', + jsonEncode(config.toJson()), + ); + } + + /// Установить пароль, не трогая флаг sendEncrypted. + static Future setPasswordForChat(int chatId, String password) async { + final current = await getConfigForChat(chatId); + final updated = ChatEncryptionConfig( + password: password, + sendEncrypted: current?.sendEncrypted ?? false, + ); + await _saveConfig(chatId, updated); + } + + /// Установить флаг "отправлять зашифрованные сообщения" для чата. + static Future setSendEncryptedForChat( + int chatId, + bool enabled, + ) async { + final current = await getConfigForChat(chatId); + final updated = ChatEncryptionConfig( + password: current?.password ?? '', + sendEncrypted: enabled, + ); + await _saveConfig(chatId, updated); + } + + /// Быстрый хелпер для получения только пароля (для совместимости со старым кодом). + static Future getPasswordForChat(int chatId) async { + final cfg = await getConfigForChat(chatId); + return cfg?.password; + } + + /// Быстрый хелпер для проверки, включена ли зашифрованная отправка для чата. + static Future isSendEncryptedEnabled(int chatId) async { + final cfg = await getConfigForChat(chatId); + return cfg?.sendEncrypted ?? false; + } + + /// Простейшее симметричное "шифрование" на базе XOR с ключом из пароля и соли. + /// Это НЕ криптографически стойкая схема и при желании легко + /// может быть заменена на AES/ChaCha, но для прототипа достаточно. + static String encryptWithPassword(String password, String plaintext) { + final salt = _randomBytes(8); + final key = Uint8List.fromList(utf8.encode(password) + salt); + + final plainBytes = utf8.encode(plaintext); + final cipherBytes = _xorWithKey(plainBytes, key); + + final payload = { + 's': base64Encode(salt), + 'c': base64Encode(cipherBytes), + }; + + final payloadJson = jsonEncode(payload); + final payloadB64 = base64Encode(utf8.encode(payloadJson)); + + return '$encryptedPrefix$payloadB64'; + } + + /// Попытаться расшифровать сообщение с использованием пароля. + /// Если формат некорректный или пароль не подошёл — вернём null. + static String? decryptWithPassword(String password, String text) { + if (!text.startsWith(encryptedPrefix)) return null; + + final payloadB64 = text.substring(encryptedPrefix.length); + try { + final payloadJson = utf8.decode(base64Decode(payloadB64)); + final data = jsonDecode(payloadJson) as Map; + + final salt = base64Decode(data['s'] as String); + final cipherBytes = base64Decode(data['c'] as String); + + final key = Uint8List.fromList(utf8.encode(password) + salt); + final plainBytes = _xorWithKey(cipherBytes, key); + + return utf8.decode(plainBytes); + } catch (_) { + return null; + } + } + + static Uint8List _randomBytes(int length) { + final bytes = Uint8List(length); + for (var i = 0; i < length; i++) { + bytes[i] = _rand.nextInt(256); + } + return bytes; + } + + static Uint8List _xorWithKey(List data, List key) { + final out = Uint8List(data.length); + for (var i = 0; i < data.length; i++) { + out[i] = data[i] ^ key[i % key.length]; + } + return out; + } +} + diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index cd68782..51c3ea2 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -206,6 +206,8 @@ class ChatMessageBubble extends StatelessWidget { final bool isGrouped; final double avatarVerticalOffset; final int? chatId; + final bool isEncryptionPasswordSet; + final String? decryptedText; const ChatMessageBubble({ super.key, @@ -239,6 +241,8 @@ class ChatMessageBubble extends StatelessWidget { this.avatarVerticalOffset = -35.0, // выше ниже аватарку бля как хотите я жрать хочу this.chatId, + this.isEncryptionPasswordSet = false, + this.decryptedText, }); String _formatMessageTime(BuildContext context, int timestamp) { @@ -3843,7 +3847,39 @@ class ChatMessageBubble extends StatelessWidget { const SizedBox(height: 6), ], if (message.text.isNotEmpty) ...[ - if (message.text.contains("welcome.saved.dialog.message")) + if (message.text.startsWith('kometSM.') && + message.text.length > 'kometSM.'.length && + !isEncryptionPasswordSet) + Text( + 'это зашифрованное сообщение, для его отображение поставьте пароль шифрования на чат.', + style: TextStyle( + color: Colors.red, + fontStyle: FontStyle.italic, + fontSize: 14, + ), + ) + else if (message.text.startsWith('kometSM.') && + message.text.length > 'kometSM.'.length && + isEncryptionPasswordSet && + decryptedText == null) + Text( + 'некорректный ключ расшифровки, пароль точно верен?', + style: TextStyle( + color: Colors.red, + fontStyle: FontStyle.italic, + fontSize: 14, + ), + ) + else if (decryptedText != null) + Linkify( + text: decryptedText!, + style: defaultTextStyle, + linkStyle: linkStyle, + onOpen: onOpenLink, + options: const LinkifyOptions(humanize: false), + textAlign: TextAlign.left, + ) + else if (message.text.contains("welcome.saved.dialog.message")) Linkify( text: 'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.',