Мне 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 = "$senderIdn$contentn$timestampn$sequencen$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 и работает уже сейчас.
Главный совет если делаете что-то похожее: сквозные тесты с фиксированными тестовыми векторами для хеш-функции — первым делом, до интеграции клиента с сервером.
Автор: Svortex
