добавил ШЫФРАВАНИЕ сообщений симитричным паролем(в выбранных чатах), система стандартная, соль + хор + басе64 ну короче база, по желанию можете переделать.
This commit is contained in:
4
key (2).properties
Normal file
4
key (2).properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
storePassword=01102025huy
|
||||||
|
keyPassword=01102025huy
|
||||||
|
keyAlias=my-key-alias
|
||||||
|
storeFile=komet-key.jks
|
||||||
177
lib/screens/chat_encryption_settings_screen.dart
Normal file
177
lib/screens/chat_encryption_settings_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -27,6 +27,8 @@ import 'package:flutter_linkify/flutter_linkify.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:video_player/video_player.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;
|
bool _debugShowExactDate = false;
|
||||||
|
|
||||||
@@ -117,6 +119,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
int? _actualMyId;
|
int? _actualMyId;
|
||||||
|
|
||||||
bool _isIdReady = false;
|
bool _isIdReady = false;
|
||||||
|
bool _isEncryptionPasswordSetForCurrentChat =
|
||||||
|
false; // TODO: hook real state later
|
||||||
|
ChatEncryptionConfig? _encryptionConfigForCurrentChat;
|
||||||
|
bool _sendEncryptedForCurrentChat = false;
|
||||||
|
|
||||||
bool _isSearching = false;
|
bool _isSearching = false;
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
@@ -346,6 +352,18 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_pinnedMessage =
|
_pinnedMessage =
|
||||||
null; // Будет установлено при получении CONTROL сообщения с event 'pin'
|
null; // Будет установлено при получении CONTROL сообщения с event 'pin'
|
||||||
_initializeChat();
|
_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 {
|
Future<void> _initializeChat() async {
|
||||||
@@ -1159,8 +1177,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendMessage() async {
|
Future<void> _sendMessage() async {
|
||||||
final text = _textController.text.trim();
|
final originalText = _textController.text.trim();
|
||||||
if (text.isNotEmpty) {
|
if (originalText.isNotEmpty) {
|
||||||
final theme = context.read<ThemeProvider>();
|
final theme = context.read<ThemeProvider>();
|
||||||
final isBlocked = _currentContact.isBlockedByMe && !theme.blockBypass;
|
final isBlocked = _currentContact.isBlockedByMe && !theme.blockBypass;
|
||||||
|
|
||||||
@@ -1176,10 +1194,34 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
return;
|
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 int tempCid = DateTime.now().millisecondsSinceEpoch;
|
||||||
final tempMessageJson = {
|
final tempMessageJson = {
|
||||||
'id': 'local_$tempCid',
|
'id': 'local_$tempCid',
|
||||||
'text': text,
|
'text': textToSend,
|
||||||
'time': tempCid,
|
'time': tempCid,
|
||||||
'sender': _actualMyId!,
|
'sender': _actualMyId!,
|
||||||
'cid': tempCid,
|
'cid': tempCid,
|
||||||
@@ -1211,7 +1253,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
ApiService.instance.sendMessage(
|
ApiService.instance.sendMessage(
|
||||||
widget.chatId,
|
widget.chatId,
|
||||||
text,
|
textToSend,
|
||||||
replyToMessageId: _replyingToMessage?.id,
|
replyToMessageId: _replyingToMessage?.id,
|
||||||
cid: tempCid, // Передаем тот же CID в API
|
cid: tempCid, // Передаем тот же CID в API
|
||||||
);
|
);
|
||||||
@@ -2332,6 +2374,24 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
.read<ThemeProvider>()
|
.read<ThemeProvider>()
|
||||||
.animatePhotoMessages;
|
.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(
|
final bubble = ChatMessageBubble(
|
||||||
key: key,
|
key: key,
|
||||||
message: item.message,
|
message: item.message,
|
||||||
@@ -2340,6 +2400,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
deferImageLoading: deferImageLoading,
|
deferImageLoading: deferImageLoading,
|
||||||
myUserId: _actualMyId,
|
myUserId: _actualMyId,
|
||||||
chatId: widget.chatId,
|
chatId: widget.chatId,
|
||||||
|
isEncryptionPasswordSet:
|
||||||
|
_isEncryptionPasswordSetForCurrentChat,
|
||||||
|
decryptedText: decryptedText,
|
||||||
onReply: widget.isChannel
|
onReply: widget.isChannel
|
||||||
? null
|
? null
|
||||||
: () => _replyToMessage(item.message),
|
: () => _replyToMessage(item.message),
|
||||||
@@ -2734,6 +2797,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_showDeleteChatDialog();
|
_showDeleteChatDialog();
|
||||||
} else if (value == 'leave_group' || value == 'leave_channel') {
|
} else if (value == 'leave_group' || value == 'leave_channel') {
|
||||||
_showLeaveGroupDialog();
|
_showLeaveGroupDialog();
|
||||||
|
} else if (value == 'encryption_password') {
|
||||||
|
Navigator.of(context)
|
||||||
|
.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ChatEncryptionSettingsScreen(
|
||||||
|
chatId: widget.chatId,
|
||||||
|
isPasswordSet: _isEncryptionPasswordSetForCurrentChat,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((_) => _loadEncryptionConfig());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
@@ -2749,7 +2823,34 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
final bool canDeleteChat = !widget.isGroupChat || amIAdmin;
|
final bool canDeleteChat = !widget.isGroupChat || amIAdmin;
|
||||||
|
|
||||||
|
final bool isEncryptionPasswordSet =
|
||||||
|
_isEncryptionPasswordSetForCurrentChat;
|
||||||
|
|
||||||
return [
|
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(
|
const PopupMenuItem(
|
||||||
value: 'search',
|
value: 'search',
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
171
lib/services/chat_encryption_service.dart
Normal file
171
lib/services/chat_encryption_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -206,6 +206,8 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
final bool isGrouped;
|
final bool isGrouped;
|
||||||
final double avatarVerticalOffset;
|
final double avatarVerticalOffset;
|
||||||
final int? chatId;
|
final int? chatId;
|
||||||
|
final bool isEncryptionPasswordSet;
|
||||||
|
final String? decryptedText;
|
||||||
|
|
||||||
const ChatMessageBubble({
|
const ChatMessageBubble({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -239,6 +241,8 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
this.avatarVerticalOffset =
|
this.avatarVerticalOffset =
|
||||||
-35.0, // выше ниже аватарку бля как хотите я жрать хочу
|
-35.0, // выше ниже аватарку бля как хотите я жрать хочу
|
||||||
this.chatId,
|
this.chatId,
|
||||||
|
this.isEncryptionPasswordSet = false,
|
||||||
|
this.decryptedText,
|
||||||
});
|
});
|
||||||
|
|
||||||
String _formatMessageTime(BuildContext context, int timestamp) {
|
String _formatMessageTime(BuildContext context, int timestamp) {
|
||||||
@@ -3843,7 +3847,39 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
],
|
],
|
||||||
if (message.text.isNotEmpty) ...[
|
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(
|
Linkify(
|
||||||
text:
|
text:
|
||||||
'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.',
|
'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.',
|
||||||
|
|||||||
Reference in New Issue
Block a user