diff --git a/assets/chepuhagram_setup.exe b/assets/chepuhagram_setup.exe index 0b7e371..7977e4d 100644 Binary files a/assets/chepuhagram_setup.exe and b/assets/chepuhagram_setup.exe differ diff --git a/lib/logic/auth_provider.dart b/lib/logic/auth_provider.dart index 400a811..2a1ad7c 100644 --- a/lib/logic/auth_provider.dart +++ b/lib/logic/auth_provider.dart @@ -46,6 +46,9 @@ class AuthProvider extends ChangeNotifier { String? _about; String? get about => _about; + String? _pubKey; + String? get pubKey => _pubKey; + String? _avatarPath; String? get avatarPath => _avatarPath; @@ -500,6 +503,7 @@ class AuthProvider extends ChangeNotifier { 'encrypted_private_key': keys['encrypted_private_key'], }), ); + _pubKey = keys['public_key']; if (response.statusCode == 200) { final String currentUsername = _username ?? ''; @@ -548,6 +552,7 @@ class AuthProvider extends ChangeNotifier { _phone = data['phone']?.toString(); _email = data['email']?.toString(); _about = data['about']?.toString(); + _pubKey = data['public_key']?.toString(); final avatarFileId = data['avatar_file_id']?.toString(); _avatarUrl = avatarFileId != null ? '${AppConstants.baseUrl}/media/$avatarFileId' diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index b8e18bd..99989ec 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -22,6 +22,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:path_provider/path_provider.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:archive/archive.dart'; import '/data/datasources/ws_client.dart'; import '/data/models/contact_model.dart'; import '/data/models/message_model.dart'; @@ -52,6 +53,8 @@ class _ContactsScreenState extends State with RouteAware { int _apkFileSizeBytes = 0; CancelToken? _cancelToken = CancelToken(); String? _latestApkUrl; + String? _latestApkZipUrl; + String? _downloadStatus; bool _showUpdateBanner = false; bool _contactsLoaded = false; Timer? _contactLoadTimer; @@ -1146,7 +1149,7 @@ class _ContactsScreenState extends State with RouteAware { Align( alignment: Alignment.centerLeft, child: Text( - '${_formatBytes(_downloadedBytes)} из ${_formatBytes(_downloadTotalBytes)}', + _downloadStatus ?? '${_formatBytes(_downloadedBytes)} из ${_formatBytes(_downloadTotalBytes)}', style: TextStyle(color: Colors.white, fontSize: 14), ), ), @@ -1575,6 +1578,7 @@ class _ContactsScreenState extends State with RouteAware { setState(() { _showUpdateBanner = true; _latestApkUrl = data['download_url'] ?? data['apk_url']; + _latestApkZipUrl = data['apk_zip_url']; }); if (_latestApkUrl != null) { final size = await _fetchApkSize(_latestApkUrl!); @@ -2203,42 +2207,140 @@ class _ContactsScreenState extends State with RouteAware { return; } - // Логика для Android (Остается без изменений) - setState(() => _isDownloading = true); + // Логика для Android + setState(() { + _isDownloading = true; + _downloadStatus = null; + }); Directory? dir = await getExternalStorageDirectory(); - final path = '${dir!.path}/update.apk'; - final file = File(path); + final zipPath = '${dir!.path}/update.zip'; + final apkPath = '${dir.path}/update.apk'; + final zipFile = File(zipPath); + final apkFile = File(apkPath); - if (await file.exists()) await file.delete(); + if (await zipFile.exists()) await zipFile.delete(); + if (await apkFile.exists()) await apkFile.delete(); + + bool isZipDownloaded = false; try { - setState(() { - _downloadProgress = 0.0; - _downloadedBytes = 0; - _downloadTotalBytes = 0; - }); - await Dio().download( - _latestApkUrl!, - path, - cancelToken: _cancelToken, - onReceiveProgress: (rec, total) { + // 1. Пробуем сначала скачать ZIP-архив + if (_latestApkZipUrl != null) { + try { + setState(() { + _downloadProgress = 0.0; + _downloadedBytes = 0; + _downloadTotalBytes = 0; + _downloadStatus = null; + }); + print("Попытка скачать ZIP обновление с $_latestApkZipUrl"); + await Dio().download( + _latestApkZipUrl!, + zipPath, + cancelToken: _cancelToken, + onReceiveProgress: (rec, total) { + if (mounted) { + setState(() { + _downloadedBytes = rec; + _downloadTotalBytes = total > 0 ? total : 0; + _downloadProgress = total > 0 ? rec / total : 0.0; + }); + } + }, + ); + isZipDownloaded = true; + print("ZIP успешно скачан"); + } catch (e) { + print("Не удалось скачать ZIP-архив: $e. Переключаемся на обычный APK..."); + if (await zipFile.exists()) { + try { + await zipFile.delete(); + } catch (_) {} + } + } + } + + // 2. Если ZIP не скачался (или ссылка отсутствовала), качаем обычный APK + if (!isZipDownloaded && _latestApkUrl != null) { + try { + setState(() { + _downloadProgress = 0.0; + _downloadedBytes = 0; + _downloadTotalBytes = 0; + _downloadStatus = null; + }); + print("Скачивание обычного APK с $_latestApkUrl"); + await Dio().download( + _latestApkUrl!, + apkPath, + cancelToken: _cancelToken, + onReceiveProgress: (rec, total) { + if (mounted) { + setState(() { + _downloadedBytes = rec; + _downloadTotalBytes = total > 0 ? total : 0; + _downloadProgress = total > 0 ? rec / total : 0.0; + }); + } + }, + ); + } catch (e) { + print("Ошибка при скачивании APK: $e"); + rethrow; + } + } + + // 3. Если скачан ZIP, распаковываем его + if (isZipDownloaded) { + try { if (mounted) { setState(() { - _downloadedBytes = rec; - _downloadTotalBytes = total > 0 ? total : 0; - _downloadProgress = total > 0 ? rec / total : 0.0; + _downloadStatus = "Распаковка..."; + _downloadProgress = 0.0; }); } - }, - ); - await OpenFilex.open(path); + + print("Начало распаковки ZIP..."); + final bytes = await zipFile.readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + + for (final file in archive) { + final filename = file.name; + if (file.isFile && filename.endsWith('.apk')) { + final data = file.content as List; + await apkFile.writeAsBytes(data); + print("Распакован файл: $filename"); + break; + } + } + + // Удаляем временный zip-архив + await zipFile.delete(); + } catch (e) { + print("Ошибка при распаковке архива: $e"); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Ошибка при распаковке обновления')), + ); + } + return; + } + } + + // 4. Установка APK + if (await apkFile.exists()) { + await OpenFilex.open(apkPath); + } else { + print("Файл установки APK не найден"); + } } catch (_) { } finally { if (mounted) { setState(() { _isDownloading = false; _downloadProgress = 0.0; + _downloadStatus = null; }); } } diff --git a/lib/presentation/screens/my_profile_screen.dart b/lib/presentation/screens/my_profile_screen.dart index b23a84f..c337861 100644 --- a/lib/presentation/screens/my_profile_screen.dart +++ b/lib/presentation/screens/my_profile_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:image_picker/image_picker.dart'; import 'dart:io'; @@ -24,7 +25,6 @@ class MyProfileScreen extends StatefulWidget { class _MyProfileScreenState extends State { final ImagePicker _picker = ImagePicker(); bool _isAvatarExpanded = false; - String? privKey; double? _stableBaseHeight; bool _avatarInteracted = false; @@ -47,15 +47,6 @@ class _MyProfileScreenState extends State { @override void initState() { super.initState(); - _loadPrivKey(); - } - - Future _loadPrivKey() async { - final storage = const FlutterSecureStorage(); - privKey = await storage.read(key: 'private_key'); - if (mounted) { - setState(() {}); - } } @override @@ -301,7 +292,7 @@ class _MyProfileScreenState extends State { _buildInfoRow( context, Icons.key_rounded, - privKey ?? 'Отсутствует', + authProv.pubKey ?? 'Отсутствует', 'Публичный E2EE ключ', false, ), @@ -366,6 +357,17 @@ class _MyProfileScreenState extends State { horizontal: 20, vertical: 4, ), + onTap: value?.isNotEmpty == true + ? () { + Clipboard.setData(ClipboardData(text: value!)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$label скопирован в буфер обмена'), + duration: const Duration(seconds: 2), + ), + ); + } + : null, leading: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -377,6 +379,8 @@ class _MyProfileScreenState extends State { title: Text( value?.isNotEmpty == true ? value! : 'Не указано', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), subtitle: Text( label, diff --git a/pubspec.lock b/pubspec.lock index f271bf9..9d73f82 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -26,13 +26,13 @@ packages: source: hosted version: "7.7.1" archive: - dependency: transitive + dependency: "direct main" description: name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d url: "https://pub.dev" source: hosted - version: "4.0.9" + version: "3.6.1" args: dependency: transitive description: @@ -916,10 +916,10 @@ packages: dependency: transitive description: name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.3.0" image_picker: dependency: "direct main" description: @@ -1392,14 +1392,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" - posix: - dependency: transitive - description: - name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" - url: "https://pub.dev" - source: hosted - version: "6.5.0" provider: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 9c33099..63f35a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,6 +68,7 @@ dependencies: video_player: ^2.11.1 video_player_win: ^3.2.2 image_picker: ^1.2.2 + archive: ^3.6.1 permission_handler: ^12.0.1 wechat_assets_picker: ^9.0.0 photo_manager: ^3.0.0 diff --git a/srv/main.py b/srv/main.py index b821736..56c31c0 100644 --- a/srv/main.py +++ b/srv/main.py @@ -114,6 +114,16 @@ async def get_update(platform: str = Query("android")): ) # Ветка для Android + if "zip" in platform.lower(): + file_path = os.path.join(RELEASE_DIR, "app-release.zip") + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail="Файл ZIP не найден") + return FileResponse( + path=file_path, + filename="chepuhagram-release.zip", + media_type="application/zip" + ) + file_path = os.path.join(RELEASE_DIR, "app-release.apk") if not os.path.exists(file_path): raise HTTPException(status_code=404, detail="Файл APK не найден") diff --git a/srv/site/about-secutity/index.html b/srv/site/about-secutity/index.html index fc13e3f..acf5ba6 100644 --- a/srv/site/about-secutity/index.html +++ b/srv/site/about-secutity/index.html @@ -465,7 +465,7 @@ } .code-container::before { - content: 'Dart / PointyCastle / Cryptography'; + content: 'Криптографический Стек'; position: absolute; top: 8px; right: 16px; @@ -544,10 +544,10 @@
@@ -556,9 +556,9 @@
-

Безопасность и сквозное шифрование (E2EE)

+

Безопасность, Сквозное Шифрование и Архитектура Системы

Обновлено: Июнь 2026 • Документация Чепухаграм
-

В мессенджере Чепухаграм конфиденциальность переписки обеспечивается математическими законами. Никакие третьи лица, включая разработчиков и администраторов серверов, не могут получить доступ к содержимому ваших чатов.

+

В мессенджере Чепухаграм конфиденциальность переписки обеспечивается строгими математическими законами. Никакие третьи лица, включая разработчика и администратора сервера, не могут получить доступ к содержимому ваших чатов. Данный документ подробно описывает криптографический стек, жизненный цикл сообщений и механизмы синхронизации данных.

@@ -569,43 +569,50 @@
🔑 -

X25519

-

Протокол Диффи-Хеллмана для генерации общего ключа (ECDH)

+

X25519 (ECDH)

+

Согласование общего ключа на базе эллиптических кривых (Curve25519) без передачи секрета по сети.

⚙️

AES-256-GCM

-

Симметричное шифрование с проверкой целостности данных

+

Симметричное шифрование блоков данных с проверкой целостности и подлинности (AEAD).

🔒 -

PBKDF2

-

Криптографическая деривация ключей на базе мастер-пароля (600,000 ит.)

+

PBKDF2-HMAC-SHA256

+

Криптографическая деривация ключей из мастер-пароля с использованием 600,000 итераций.

+
+
+ 📡 +

WebSockets & Drift DB

+

Реалтайм-доставка сообщений и локальное структурированное хранение истории в зашифрованном виде.

-

Принцип сквозного шифрования (E2EE)

-

Шифрование «end-to-end» означает, что шифрование информации происходит непосредственно на устройстве отправителя, а дешифрование — только на устройстве получателя. Серверная часть выполняет лишь функцию почтальона — пересылает зашифрованные пакеты байт, не имея ключей для их расшифровки.

+

1. Сквозное шифрование (End-to-End Encryption)

+

С сквозным шифрованием (E2EE) ваши сообщения кодируются непосредственно перед отправкой на вашем устройстве и могут быть декодированы только на устройстве получателя. Серверная часть мессенджера Чепухаграм полностью изолирована от ключевой информации. Сервер функционирует исключительно как маршрутизатор зашифрованных бинарных пакетов и не способен восстановить исходный текст.

-

Схема согласования ключей (X25519)

+

Схема согласования ключей (X25519 / ECDH)

+

Для создания защищенного канала используется протокол Диффи-Хеллмана на эллиптических кривых. Ниже представлена схема независимого вычисления общего секрета:

+
Вы
-
Приватный ключ A
-
Публичный ключ A
-
Общий секрет (K)
+
Ваш приватный ключ A (dA)
+
Ваш публичный ключ A (QA = dA * G)
+
Общий секрет (K = dA * QB)
публичный ключ A →
← публичный ключ B
-

(сервер пересылает только публичные ключи)

+

(сервер транслирует только открытые ключи)

Собеседник
-
Приватный ключ B
-
Публичный ключ B
-
Общий секрет (K)
+
Приватный ключ B (dB)
+
Публичный ключ B (QB = dB * G)
+
Общий секрет (K = dB * QA)
@@ -613,104 +620,160 @@
1
-

Создание пары ключей

-

При первом запуске приложения Чепухаграм генерирует на устройстве пару асимметричных ключей X25519 (приватный и публичный). Публичный ключ отправляется на сервер для того, чтобы другие пользователи могли начать с вами чат.

+

Генерация локальной пары ключей

+

При первом входе или настройке аккаунта приложение генерирует на устройстве пару асимметричных ключей X25519. Приватный ключ надёжно сохраняется во внутреннем изолированном хранилище ОС (Secure Storage / KeyStore / Keychain), а публичный отправляется в базу данных сервера для обеспечения доступности вашего контакта другим пользователям.

2
-

Деривация общего секрета (Shared Secret)

-

Когда вы открываете чат, приложение берет ваш приватный ключ X25519 и публичный ключ собеседника, вычисляя общий секрет по алгоритму ECDH. Этот секретный ключ никогда не передается по сети — обе стороны вычисляют его независимо на своих девайсах.

+

Вычисление общего секрета (Shared Secret)

+

Когда вы открываете чат с пользователем, клиентское приложение автоматически запрашивает публичный ключ собеседника с сервера. Используя его совместно со своим приватным ключом, алгоритм ECDH вычисляет уникальный симметричный ключ (общий секрет). Он никогда не передаётся по сети — обе стороны получают абсолютно одинаковый ключ математическим путём.

3
-

Симметричное шифрование

-

Все отправляемые текстовые сообщения шифруются по стандарту AES-256-GCM с уникальным вектором инициализации (Nonce). Полученный шифротекст передается на сервер.

+

Симметричное шифрование AES-256-GCM

+

Полученный общий секрет передается в алгоритм шифрования AES-256 в режиме Galois/Counter Mode. Каждое сообщение шифруется с использованием уникального вектора инициализации (Nonce), что делает невозможным проведение атак на основе повторяющихся шифротекстов.

-

Шифрование текстовых сообщений

-

Каждое текстовое сообщение кодируется в массив байтов, после чего для него генерируется случайный 12-байтовый вектор инициализации (nonce). Данные шифруются на ключе sharedSecret по алгоритму AES-256-GCM. Результат представляет собой склеенный массив байтов: nonce (12 байт) + mac (16 байт) + ciphertext (зашифрованный текст), закодированный в Base64.

+

2. Шифрование и деривация текстовых сообщений

+

Каждое текстовое сообщение шифруется с генерацией случайного 12-байтового вектора инициализации (IV / Nonce). Режим GCM гарантирует аутентифицированное шифрование (AEAD): к шифротексту добавляется 16-байтовый тег аутентификации (MAC), подтверждающий, что данные не были изменены при транзите через сервер.

+

Итоговая структура пакета сообщения на сервере выглядит следующим образом:

+
+ [Nonce (12 байт)] + [MAC Тег (16 байт)] + [Шифротекст (N байт)] => Кодирование в Base64 +
+

Пример реализации алгоритмов шифрования и расшифрования в приложении на Dart с использованием криптографических библиотек:

-
// Схематичный пример дешифровки сообщения на клиенте
+            
// Шифрование строки текста на общем ключе
+Future<String> encryptMessage(String plainText, SecretKey sharedKey) async {
+  final algorithm = AesGcm.with256bits();
+  final clearTextBytes = utf8.encode(plainText);
+  
+  // Генерация случайного вектора инициализации (Nonce)
+  final secretBox = await algorithm.encrypt(
+    clearTextBytes,
+    secretKey: sharedKey,
+  );
+  
+  // Объединяем nonce, mac-тег и зашифрованные байты в один пакет
+  final combinedBytes = BytesBuilder()
+    ..add(secretBox.nonce)
+    ..add(secretBox.mac.bytes)
+    ..add(secretBox.cipherText);
+    
+  return base64Encode(combinedBytes.toBytes());
+}
+
+// Дешифрование сообщения на клиенте
 Future<String> decryptMessage(String base64Data, SecretKey sharedKey) async {
-  final data = base64Decode(base64Data);
-
-  final nonce = data.sublist(0, 12);
-  final mac = data.sublist(12, 28);
-  final cipherText = data.sublist(28);
-
-  final decrypted = await aesGcm.decrypt(
+  final rawData = base64Decode(base64Data);
+  if (rawData.length < 28) throw Exception("Пакет данных слишком мал");
+  
+  final nonce = rawData.sublist(0, 12);
+  final mac = rawData.sublist(12, 28);
+  final cipherText = rawData.sublist(28);
+  
+  final algorithm = AesGcm.with256bits();
+  final decryptedBytes = await algorithm.decrypt(
     SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
     secretKey: sharedKey,
   );
-
-  return utf8.decode(decrypted);
+  
+  return utf8.decode(decryptedBytes);
 }
-

Поблочное шифрование медиафайлов

-

Для предотвращения утечек данных при отправке медиафайлов (картинок, видео, голосовых заметок и документов), в Чепухаграм внедрена продвинутая поблочная система шифрования:

-
    -
  1. При выборе файла генерируется случайный симметричный ключ файла (fileKey).
  2. -
  3. Ключ fileKey шифруется на общем ключе чата (sharedSecret) и отправляется на сервер как поле encrypted_key.
  4. -
  5. Сам файл считывается потоком и шифруется блоками по 64 КБ с использованием fileKey. К каждому зашифрованному блоку добавляется 4-байтовый заголовок, содержащий точную длину блока, и уникальный вектор инициализации с тегом аутентификации MAC.
  6. -
+

3. Поблочное шифрование медиафайлов (Крипто-стриминг)

+

Шифрование больших файлов целиком в оперативной памяти мобильных устройств приводит к её переполнению и сбоям. Кроме того, это лишает возможности начать воспроизведение видео/аудио до окончания полной загрузки файла. В мессенджере Чепухаграм внедрена технология поблочного крипто-стриминга:

+
    +
  1. Генерация ключа файла: Для каждого медиафайла генерируется индивидуальный случайный 256-битный ключ (fileKey). Это позволяет безопасно делиться ключом конкретного файла, не компрометируя общий секрет чата.
  2. +
  3. Шифрование ключа: Ключ fileKey зашифровывается с помощью AES-GCM на основном общем ключе чата (sharedSecret). Этот зашифрованный ключ прикрепляется к сообщению как поле encrypted_key.
  4. +
  5. Поблочное чтение и шифрование: Исходный файл считывается потоком частями по 64 КБ. Каждый блок шифруется алгоритмом AES-256-GCM независимо.
  6. +
  7. Форматирование блоков: К зашифрованным блокам добавляется структура: +
    + [Длина блока (4 байта)] + [Nonce (12 байт)] + [Зашифрованные данные (64 КБ)] + [Тег аутентификации MAC (16 байт)] +
    +
  8. +
  9. Передача на Google Drive: Зашифрованный поток отправляется напрямую в облачное хранилище через сервер. Сервер имеет доступ только к зашифрованному потоку байтов и не знает ключ fileKey.
  10. +
+ -

Архитектура поблочного крипто-стрима

-
Исходный файл медиа
-
👇 Считывание блоками по 64 КБ
+
Исходный медиафайл (Изображение, Видео, Голосовая заметка)
+
👇 Разделение на чанки в потоке по 64 КБ
Блок 1 (64 КБ) -
🔒 AES-256-GCM (fileKey)
+
🔒 AES-256-GCM (на ключе fileKey)
Длина (4б) + Nonce (12б) + Данные + MAC (16б)
Блок 2 (64 КБ) -
🔒 AES-256-GCM (fileKey)
+
🔒 AES-256-GCM (на ключе fileKey)
Длина (4б) + Nonce (12б) + Данные + MAC (16б)
- Остаток (<64 КБ) -
🔒 AES-256-GCM (fileKey)
+ Хвостовой Блок (< 64 КБ) +
🔒 AES-256-GCM (на ключе fileKey)
Длина (4б) + Nonce (12б) + Данные + MAC (16б)
-

Важно: Такой подход позволяет осуществлять потоковую дешифрацию медиа во время загрузки (стриминг), благодаря чему видео и аудиозаписи начинают воспроизводиться еще до полной загрузки файла.

+

Преимущество стриминга: При скачивании получатель расшифровывает блоки на лету по мере их поступления по сети. Плеер приложения начинает воспроизведение видео- или аудиофайла мгновенно, не дожидаясь полной загрузки сотен мегабайт с Google Drive.

-

Резервная копия ключей и мастер-пароль

-

Поскольку приватный ключ X25519 хранится в изолированном защищенном хранилище (Secure Storage) вашего смартфона, при переустановке приложения вы можете потерять доступ к переписке. Для предотвращения этого в Чепухаграм создана система защищенных резервных копий:

+

4. Защита ключей и облачное резервное копирование (Backup)

+

Потеря устройства или очистка приложения без резервной копии привела бы к безвозвратной потере всей переписки, так как приватный ключ X25519 существует в единственном экземпляре на смартфоне. Для решения этой проблемы Чепухаграм реализует безопасную схему резервного копирования ключей:

+
    -
  • Вы придумываете сложный Мастер-пароль.
  • -
  • На основе этого пароля с помощью алгоритма PBKDF2 (600 000 итераций, HMAC-SHA256, соль "chepuhagram_salt") генерируется ключ шифрования резервной копии.
  • -
  • Приватный ключ X25519 шифруется на этом ключе и отправляется на сервер как encrypted_private_key.
  • -
  • При входе на новом устройстве вы вводите мастер-пароль, приложение скачивает копию ключа, расшифровывает её на вашем устройстве, и вы снова можете читать все ваши чаты. Сервер видит только зашифрованный массив байт и не может его декодировать.
  • +
  • Мастер-пароль: Пользователь задаёт сложный мастер-пароль при регистрации или первой инициализации профиля. Мастер-пароль не хранится на сервере ни в каком виде.
  • +
  • Деривация PBKDF2: Из пароля и предопределённой криптографической соли вычисляется производный ключ шифрования резервной копии. Используется стандарт PBKDF2 с хэш-функцией HMAC-SHA256 и 600,000 итерациями. Такое число итераций делает невозможным брутфорс (перебор паролей) на графических процессорах и специализированных чипах.
  • +
  • Шифрование приватного ключа: Локальный приватный ключ X25519 шифруется с помощью AES-256-GCM на деривированном ключе PBKDF2.
  • +
  • Сохранение копии: Зашифрованная строка приватного ключа (encrypted_private_key) отправляется на сервер. При авторизации на новом устройстве пользователь вводит свой мастер-пароль, приложение заново производит 600,000 итераций деривации ключа, скачивает защищённый контейнер с сервера и успешно расшифровывает приватный ключ локально.
-

Безопасность сессий и автозавершение

-

Чепухаграм поддерживает одновременный вход на нескольких устройствах. Вся сессионная активность полностью подконтрольна пользователю:

-

При любом изменении пароля сквозного шифрования (или при полном сбросе ключей) все остальные активные сессии на сторонних девайсах автоматически инвалидируются на сервере и прекращают работу. Это защищает ваши чаты от несанкционированного доступа с ранее авторизованных устройств.

+

5. Управление сессиями и синхронизация (Multi-Device Sync)

+

Чепухаграм спроектирован для бесшовной работы на нескольких устройствах одного пользователя. Это требует сложной логики синхронизации сообщений, прочтений и статусов через реалтайм WebSocket-каналы и локальную СУБД (Drift SQLite):

+ +

Доставка событий о прочтении (Read Receipts)

+

При чтении переписки на одном устройстве, состояние непрочитанных сообщений синхронизируется на всех остальных девайсах пользователя с точностью до чата:

+
    +
  • Когда пользователь открывает чат или прокручивает его в самый низ (где виден последний элемент), клиент отправляет на сервер WebSocket-событие read_all_chat с указанием ID контакта.
  • +
  • Сервер обновляет статус всех непрочитанных сообщений в базе данных и рассылает WebSocket-пакеты all_chat_read всем активным сессиям (устройствам) читателя и автора сообщений.
  • +
  • Все устройства читателя мгновенно обновляют локальную базу данных сообщений, а также сбрасывают счётчик непрочитанных сообщений конкретного чата в провайдере `ContactProvider` в 0, очищая бейджи непрочитанных в списке чатов.
  • +
  • Если сообщения прочитываются поштучно (по мере попадания в зону видимости экрана), отправляется событие read_receipt. Сервер вычисляет количество оставшихся непрочитанных сообщений **строго в рамках данного чата** и рассылает его устройствам, предотвращая некорректное суммирование глобального счётчика.
  • +
+ +

Смена мастер-пароля шифрования секретного ключа и отзыв авторизации (Force Logout)

+

Выход из сессий на других устройствах происходит исключительно при смене мастер-пароля шифрования секретного ключа (или при полном сбросе ключей). Это гарантирует своевременное прекращение доступа к аккаунту при изменении ключевого секрета:

+
    +
  1. Во время изменения мастер-пароля шифрования приватного ключа сервер определяет идентификатор сессии (session_id) устройства, инициировавшего операцию.
  2. +
  3. Сервер находит и удаляет все остальные активные сессии этого пользователя из таблицы sessions базы данных.
  4. +
  5. Для каждой отключенной сессии бэкенд вызывает метод kill_session_socket в менеджере WebSocket-соединений.
  6. +
  7. На эти устройства отправляется WebSocket-пакет {"type": "session_terminated"}, после чего сокеты принудительно закрываются.
  8. +
  9. Устройства, получившие данный сигнал, немедленно удаляют локальный файл базы данных сообщений (chat_app.db), токены авторизации, приватный ключ и возвращаются на экран входа.
  10. +
+ +
+

Безопасность при выходе: При логауте база данных SQLite на компьютере или телефоне полностью стирается на уровне файловой системы. Это исключает возможность физического извлечения переписки с диска устройства после выхода из учётной записи.

+
- - +
diff --git a/srv/site/index.html b/srv/site/index.html index 50c8efc..eab6171 100644 --- a/srv/site/index.html +++ b/srv/site/index.html @@ -731,8 +731,8 @@
@@ -744,7 +744,7 @@

Чепухаграм

Приватность следующего поколения. Полноценное сквозное шифрование сообщений, звонков и медиафайлов. Полный контроль над вашими личными данными.

@@ -835,7 +835,7 @@ - Подробнее о криптографии + Подробнее о криптографии @@ -850,7 +850,7 @@