Мне 18 лет, и последние несколько месяцев я разрабатываю Xipher — мессенджер, который пишу с нуля на C++ (бэкенд) и Kotlin (Android). В какой-то момент я захотел добавить фичу, которой нет ни в одном популярном мессенджере: режим, в котором переписку невозможно подделать — ни участникам, ни мне как владельцу сервера, — и это можно проверить независимо, без доступа к серверу.
Так появился Xipher Provable Chat. В этой статье разберу, как именно это реализовано, какие решения я принял и с какими проблемами столкнулся.
Зачем это нужно
Скриншот редактируется в Paint. Экспортированный JSON из Telegram открывается в блокноте. E2E-шифрование решает задачу конфиденциальности, но не доказуемости — это разные вещи.
Типичные сценарии, где доказуемость важна:
Деловая переписка с подтверждением договорённостей
Споры, где важна точная формулировка и дата сообщения
Любая ситуация «он сказал / она сказала» — и нужно доказать, кто именно что написал
Задача: сделать так, чтобы любой третий человек мог взять один JSON-файл и независимо проверить — переписка именно такая, ни одно сообщение не добавлено, не удалено, не изменено.
Три криптографических столпа
Я не изобретал ничего нового. Блокчейн решает похожую задачу уже давно — я взял его принципы и применил к переписке в Xipher без токенов и децентрализации.
1. Цифровые подписи Ed25519
Каждый пользователь Xipher при включении режима генерирует пару ключей. На Android (Kotlin):
val keyPairGenerator = KeyPairGenerator.getInstance("Ed25519") val keyPair = keyPairGenerator.generateKeyPair() // privateKey → EncryptedSharedPreferences (AES-256-GCM) // publicKey → регистрируется на сервере Xipher
На сервере (C++, OpenSSL EVP API):
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, nullptr); EVP_PKEY_keygen_init(ctx); EVP_PKEY* pkey = nullptr; EVP_PKEY_keygen(ctx, &pkey);
Приватный ключ никогда не покидает устройство. На сервер Xipher уходит только публичный.
Почему Ed25519, а не RSA или ECDSA? Ключи компактные (32 байта), подпись быстрая, алгоритм детерминистичный — одни и те же данные всегда дают одну и ту же подпись. Последнее критично для верификации.
2. Хеш-цепочка
Каждое сообщение хешируется с включением хеша предыдущего. Канонический формат:
canonical = sender_id + "\n" + content + "\n" + timestamp + "\n" + sequence + "\n" + prev_hash hash_i = hex(SHA-256(canonical))
Слово детерминистический здесь ключевое — клиент на Kotlin и сервер Xipher на C++ должны получить одинаковый хеш из одинаковых данных. Это требует жёстко зафиксированного формата: порядок полей, разделитель \n, кодировка UTF-8. Я написал сквозной тест, который гоняет одни и те же входные данные через оба движка.
3. Монотонный sequence
Каждое сообщение имеет строго возрастающий порядковый номер:
Перестановка сообщений — номера укажут на несоответствие
Вставка между существующими — sequence нарушится
Удаление — будет пропуск, который детектируется при верификации
Реализация на сервере Xipher
8 новых API эндпоинтов
Эндпоинт | Что делает |
|---|---|
| Сохраняет Ed25519 публичный ключ пользователя |
| Включает режим для конкретного чата |
| Отдаёт текущее состояние цепочки |
| Принимает подписанное сообщение |
| Отдаёт записи цепочки с пагинацией |
| Генерирует proof-документ |
| Верифицирует proof-документ |
Что происходит при отправке сообщения
1. Проверка токена авторизации 2. Проверка: provable mode включён для этого чата? 3. Публичный ключ отправителя из БД 4. next_sequence = MAX(sequence) + 1 5. prev_hash = последний hash в цепочке 6. timestamp = NOW() UTC — серверное время (важно!) 7. Пересчёт hash = SHA-256(canonical) 8. verify(hash, клиентская_подпись, публичный_ключ) 9. Подпись невалидна → 400, сообщение отклонено 10. INSERT INTO messages 11. INSERT INTO provable_chain 12. WebSocket уведомление обоим с флагом provable: true
Timestamp фиксирует сервер — это предотвращает манипуляции с датой со стороны клиента.
База данных PostgreSQL — три таблицы
CREATE TABLE provable_signing_keys ( user_id UUID PRIMARY KEY REFERENCES users(id), public_key TEXT NOT NULL, rotated_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE provable_chat_settings ( user_low UUID NOT NULL, -- нормализация через LEAST/GREATEST user_high UUID NOT NULL, enabled BOOLEAN DEFAULT true, PRIMARY KEY (user_low, user_high) ); CREATE TABLE provable_chain ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, message_id UUID NOT NULL, sender_id UUID NOT NULL, content TEXT NOT NULL, timestamp TIMESTAMPTZ NOT NULL, sequence BIGINT NOT NULL, prev_hash TEXT NOT NULL DEFAULT '', hash TEXT NOT NULL, signature TEXT NOT NULL, sender_public_key TEXT NOT NULL -- snapshot ключа на момент подписи );
sender_public_keyсохраняется прямо в записи цепочки, а не только вprovable_signing_keys. Причина: при ротации ключей старые proof-документы остаются верифицируемыми — каждая запись содержит ключ, который действовал в момент подписи.
Клиентская часть: Kotlin зеркало C++ движка
ProvableCrypto.kt в Xipher — зеркало provable_chain.cpp. Главное требование: идентичный канонический формат.
fun computeChainHash( senderId: String, content: String, timestamp: String, sequence: Long, prevHash: String ): String { val canonical = "$senderId\n$content\n$timestamp\n$sequence\n$prevHash" return MessageDigest.getInstance("SHA-256") .digest(canonical.toByteArray(Charsets.UTF_8)) .joinToString("") { "%02x".format(it) } } fun sign(hashHex: String, privateKey: PrivateKey): String { val sig = Signature.getInstance("Ed25519") sig.initSign(privateKey) sig.update(hashHex.toByteArray(Charsets.UTF_8)) return Base64.encodeToString(sig.sign(), Base64.NO_WRAP) }
Поток отправки:
1. GET /api/provable/status → {nextSeq, lastHash} 2. timestamp = текущее UTC (ISO 8601) 3. hash = computeChainHash(...) 4. signature = sign(hash, localPrivateKey) 5. POST /api/provable/send-message {content, signature} 6. ← {message_id, chain_hash, chain_sequence}
Proof-документ
Любую часть переписки в Xipher можно экспортировать как self-contained JSON:
{ "xipher_provable_chat": { "version": 1, "participants": ["alice", "bob"], "entries": [ { "sender_username": "alice", "content": "Договорились на 100к", "timestamp": "2026-02-19T12:00:00Z", "sequence": 1, "prev_hash": "", "hash": "a3f2c91b…", "signature": "Base64…", "sender_public_key": "Base64…" }, { "sender_username": "bob", "content": "Подтверждаю", "timestamp": "2026-02-19T12:00:05Z", "sequence": 2, "prev_hash": "a3f2c91b…", "hash": "7d8e4f1a…", "signature": "Base64…", "sender_public_key": "Base64…" } ] }, "verification": { "algorithm": "Ed25519", "hash_function": "SHA-256", "chain_method": "hash = SHA256(sender_id + LF + content + LF + timestamp + LF + sequence + LF + prev_hash)" } }
Верифицировать можно тремя способами:
На сервере Xipher через
POST /api/provable/verifyОфлайн на устройстве через
ProvableCrypto.verifyChain()На любом компьютере — SHA-256 + Ed25519 verify, 20 строк на Python
Что оказалось сложнее, чем я ожидал
Детерминизм хеша. Клиент и сервер давали разные хеши из одинаковых данных. Причина: Kotlin и C++ по-разному обрабатывали один из тестовых UUID с пробелом. Лечение — фиксированные тестовые векторы, которые прогоняются через оба движка автоматически.
Timestamp window. Между GET /status и POST /send-message другой участник может успеть отправить сообщение — тогда lastHash устареет и запрос упадёт. Это нормальное поведение, но требует retry-логики на клиенте.
Потеря ключа при сбросе устройства. EncryptedSharedPreferences привязан к Android Keystore. Сброс устройства = потеря приватного ключа = невозможность подписывать новые сообщения. Старые записи остаются верифицируемыми. Это надо явно объяснять пользователю в UI.
Честно об ограничениях
Это не E2E-шифрование — сервер Xipher видит содержание. Это осознанный выбор: доказуемость требует, чтобы сервер мог верифицировать подпись против данных.
Forward secrecy отсутствует — если утечёт приватный ключ, исторические подписи верифицируемы. Именно это и делает proof-документы долгосрочно действительными.
Сервер контролирует sequence — я как владелец могу не принять сообщение. Пропуск в sequence это обнаружит, но только при наличии полной цепочки у обеих сторон.
Итог
Задача решается четырьмя компонентами: Ed25519 подписи, SHA-256 хеш-цепочка, монотонный sequence, детерминистический канонический формат. Всё это реализовано в Xipher и работает уже сейчас.
Главный совет если делаете что-то похожее: сквозные тесты с фиксированными тестовыми векторами для хеш-функции — первым делом, до интеграции клиента с сервером.
