добавил ШЫФРАВАНИЕ сообщений симитричным паролем(в выбранных чатах), система стандартная, соль + хор + басе64 ну короче база, по желанию можете переделать.

This commit is contained in:
jganenok
2025-12-02 15:28:29 +07:00
parent 5cc66ebdcd
commit 4dfa1fb8ac
5 changed files with 494 additions and 5 deletions

View File

@@ -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<ChatEncryptionSettingsScreen> createState() =>
_ChatEncryptionSettingsScreenState();
}
class _ChatEncryptionSettingsScreenState
extends State<ChatEncryptionSettingsScreen> {
final TextEditingController _passwordController = TextEditingController();
bool _sendEncrypted = false;
bool _isPasswordCurrentlySet = false;
@override
void initState() {
super.initState();
_sendEncrypted = false;
_loadConfig();
}
Future<void> _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<void> _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,
),
),
),
],
),
),
);
}
}

View File

@@ -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<ChatScreen> {
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<ChatScreen> {
_pinnedMessage =
null; // Будет установлено при получении CONTROL сообщения с event 'pin'
_initializeChat();
_loadEncryptionConfig();
}
Future<void> _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<void> _initializeChat() async {
@@ -1159,8 +1177,8 @@ class _ChatScreenState extends State<ChatScreen> {
}
Future<void> _sendMessage() async {
final text = _textController.text.trim();
if (text.isNotEmpty) {
final originalText = _textController.text.trim();
if (originalText.isNotEmpty) {
final theme = context.read<ThemeProvider>();
final isBlocked = _currentContact.isBlockedByMe && !theme.blockBypass;
@@ -1176,10 +1194,34 @@ class _ChatScreenState extends State<ChatScreen> {
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<ChatScreen> {
ApiService.instance.sendMessage(
widget.chatId,
text,
textToSend,
replyToMessageId: _replyingToMessage?.id,
cid: tempCid, // Передаем тот же CID в API
);
@@ -2332,6 +2374,24 @@ class _ChatScreenState extends State<ChatScreen> {
.read<ThemeProvider>()
.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<ChatScreen> {
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<ChatScreen> {
_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<ChatScreen> {
}
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(

View File

@@ -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<String, dynamic> toJson() => {
'password': password,
'sendEncrypted': sendEncrypted,
};
factory ChatEncryptionConfig.fromJson(Map<String, dynamic> 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<ChatEncryptionConfig?> 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<String, dynamic>;
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<void> _saveConfig(
int chatId,
ChatEncryptionConfig config,
) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'$_configKeyPrefix$chatId',
jsonEncode(config.toJson()),
);
}
/// Установить пароль, не трогая флаг sendEncrypted.
static Future<void> 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<void> 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<String?> getPasswordForChat(int chatId) async {
final cfg = await getConfigForChat(chatId);
return cfg?.password;
}
/// Быстрый хелпер для проверки, включена ли зашифрованная отправка для чата.
static Future<bool> 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<String, dynamic>;
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<int> data, List<int> key) {
final out = Uint8List(data.length);
for (var i = 0; i < data.length; i++) {
out[i] = data[i] ^ key[i % key.length];
}
return out;
}
}

View File

@@ -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:
'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.',