Мне 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 эндпоинтов

Эндпоинт

Что делает

POST /api/provable/register-key

Сохраняет Ed25519 публичный ключ пользователя

POST /api/provable/enable

Включает режим для конкретного чата

POST /api/provable/status

Отдаёт текущее состояние цепочки

POST /api/provable/send-message

Принимает подписанное сообщение

POST /api/provable/messages

Отдаёт записи цепочки с пагинацией

POST /api/provable/export

Генерирует proof-документ

POST /api/provable/verify

Верифицирует 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)"  }
}

Верифицировать можно тремя способами:

  1. На сервере Xipher через POST /api/provable/verify

  2. Офлайн на устройстве через ProvableCrypto.verifyChain()

  3. На любом компьютере — 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 и работает уже сейчас.

Главный совет если делаете что-то похожее: сквозные тесты с фиксированными тестовыми векторами для хеш-функции — первым делом, до интеграции клиента с сервером.