Files
mobile-android/docs/ENCRYPTION_EXPLAINED.md
k1ngsterr1 569aa34432 feat: Add comprehensive encryption architecture documentation for Rosette Messenger
feat: Implement Firebase Cloud Messaging (FCM) integration documentation for push notifications

docs: Outline remaining tasks for complete FCM integration in the project

fix: Resolve WebSocket connection issues after user registration
2026-01-17 19:04:05 +05:00

22 KiB
Raw Permalink Blame History

🔐 Rosette Messenger - Криптографическая Архитектура

Обзор

Rosette Messenger использует гибридную систему шифрования с двумя уровнями:

  1. XChaCha20-Poly1305 — для шифрования содержимого сообщений
  2. ECDH + AES-256-CBC — для безопасной передачи ключей между пользователями

📨 Архитектура Шифрования Сообщения

Уровень 1: Шифрование Содержимого (XChaCha20-Poly1305)

Параметры

  • Алгоритм: XChaCha20-Poly1305 (AEAD - Authenticated Encryption with Associated Data)
  • Размер ключа: 32 байта (256 бит)
  • Размер nonce: 24 байта (192 бита) — расширенный nonce для XChaCha
  • Размер тега аутентификации: 16 байт (128 бит, Poly1305 MAC)

Процесс Шифрования

fun encryptMessage(plaintext: String): EncryptedMessage {
    // 1. Генерация случайных параметров
    val key = SecureRandom.nextBytes(32)      // 256-bit ключ
    val nonce = SecureRandom.nextBytes(24)    // 192-bit nonce

    // 2. Шифрование с XChaCha20-Poly1305
    val ciphertext = xchacha20Poly1305Encrypt(
        plaintext.toByteArray(Charsets.UTF_8),
        key,
        nonce
    )

    return EncryptedMessage(
        ciphertext = ciphertext.toHex(),
        key = key.toHex(),
        nonce = nonce.toHex()
    )
}

🔑 XChaCha20-Poly1305: Детальная Реализация

Этап 1: HChaCha20 Subkey Derivation

XChaCha20 использует расширенный 192-битный nonce. Чтобы уместить его в стандартный ChaCha20 (96-битный nonce), используется HChaCha20 для получения производного ключа.

// Вход: 256-bit ключ + первые 128 бит (16 байт) nonce
// Выход: 256-bit subkey
val subkey = hchacha20(key, nonce[0..15])

HChaCha20 — это модифицированная версия ChaCha20, которая:

  • Берёт 256-битный ключ и 128 бит из nonce
  • Выполняет 20 раундов ChaCha quarter rounds
  • Возвращает первые и последние 4 слова состояния (32 байта)

Этап 2: Формирование ChaCha20 Nonce

// ChaCha20 использует 96-битный nonce (12 байт)
val chacha20Nonce = ByteArray(12)
// [0,0,0,0] + последние 8 байт оригинального nonce
System.arraycopy(nonce, 16, chacha20Nonce, 4, 8)
// Структура: [counter: 4 bytes][nonce: 8 bytes]

Этап 3: Инициализация ChaCha20 Engine

КРИТИЧНО: Используется ОДИН engine для всего процесса!

val engine = ChaCha7539Engine()
engine.init(true, ParametersWithIV(KeyParameter(subkey), chacha20Nonce))

Этап 4: Генерация Poly1305 Ключа

КРИТИЧЕСКАЯ ДЕТАЛЬ: Poly1305 ключ — это первые 32 байта первого блока ChaCha20 keystream (counter = 0).

// Генерируем первый 64-байтный блок keystream (counter = 0)
val poly1305KeyBlock = ByteArray(64)
engine.processBytes(ByteArray(64), 0, 64, poly1305KeyBlock, 0)

// Poly1305 ключ = первые 32 байта
val poly1305Key = poly1305KeyBlock.copyOfRange(0, 32)

Почему 64 байта?

  • ChaCha20 генерирует keystream блоками по 64 байта
  • Первые 32 байта → Poly1305 ключ
  • Остальные 32 байта → не используются
  • После этого counter автоматически увеличивается до 1

Этап 5: Шифрование Plaintext

// Engine продолжает с counter = 1
val ciphertext = ByteArray(plaintext.size)
engine.processBytes(plaintext, 0, plaintext.size, ciphertext, 0)

Keystream Layout:

Block 0 (counter=0): [Poly1305 key: 32 bytes][unused: 32 bytes]
Block 1 (counter=1): [Plaintext byte 0...63] ⊕ [Keystream]
Block 2 (counter=2): [Plaintext byte 64...127] ⊕ [Keystream]
...

Этап 6: Вычисление Poly1305 MAC

Poly1305 — это MAC (Message Authentication Code) для аутентификации ciphertext.

val mac = Poly1305()
mac.init(KeyParameter(poly1305Key))

// Обновляем MAC с ciphertext
mac.update(ciphertext, 0, ciphertext.size)

// Padding до 16 байт (AEAD требование)
val padding = (16 - (ciphertext.size % 16)) % 16
if (padding > 0) {
    mac.update(ByteArray(padding), 0, padding)
}

// Length fields (little-endian)
mac.update(ByteArray(8), 0, 8)  // AAD length (0 в нашем случае)
mac.update(longToLittleEndian(ciphertext.size), 0, 8)

val tag = ByteArray(16)
mac.doFinal(tag, 0)

Этап 7: Финальный Формат

return ciphertext + tag  // [ciphertext][16-byte Poly1305 tag]

🔓 Расшифровка XChaCha20-Poly1305

Процесс зеркальный, но с проверкой аутентификации перед расшифровкой:

fun xchacha20Poly1305Decrypt(
    ciphertextWithTag: ByteArray,
    key: ByteArray,
    nonce: ByteArray
): ByteArray {
    // 1. Разделение ciphertext и tag
    val ciphertext = ciphertextWithTag[0..-17]
    val tag = ciphertextWithTag[-16..-1]

    // 2. HChaCha20 subkey derivation (как при шифровании)
    val subkey = hchacha20(key, nonce[0..15])

    // 3. ChaCha20 nonce
    val chacha20Nonce = [0,0,0,0] + nonce[16..23]

    // 4. Инициализация engine
    val engine = ChaCha7539Engine()
    engine.init(true, ParametersWithIV(KeyParameter(subkey), chacha20Nonce))

    // 5. Генерация Poly1305 ключа (тот же процесс)
    val poly1305KeyBlock = ByteArray(64)
    engine.processBytes(ByteArray(64), 0, 64, poly1305KeyBlock, 0)

    // 6. Проверка Poly1305 tag
    val mac = Poly1305()
    mac.init(KeyParameter(poly1305KeyBlock[0..31]))
    mac.update(ciphertext)
    // ... padding и length fields ...
    val computedTag = mac.doFinal()

    if (!tag.contentEquals(computedTag)) {
        throw SecurityException("Authentication failed")
    }

    // 7. Расшифровка (только после проверки!)
    // Создаём новый engine для расшифровки
    val decryptEngine = ChaCha7539Engine()
    decryptEngine.init(false, ParametersWithIV(KeyParameter(subkey), chacha20Nonce))

    // Пропускаем первые 64 байта (Poly1305 key block)
    decryptEngine.processBytes(ByteArray(64), 0, 64, ByteArray(64), 0)

    // Расшифровываем
    val plaintext = ByteArray(ciphertext.size)
    decryptEngine.processBytes(ciphertext, 0, ciphertext.size, plaintext, 0)

    return plaintext
}

🔐 Уровень 2: Шифрование Ключей (ECDH + AES-256-CBC)

После шифрования сообщения XChaCha20, нужно безопасно передать ключ и nonce получателю.

Схема: Elliptic Curve Diffie-Hellman + AES

Параметры

  • Эллиптическая кривая: secp256k1 (та же что в Bitcoin)
  • AES режим: CBC (Cipher Block Chaining)
  • Размер ключа AES: 256 бит
  • Размер IV: 16 байт (128 бит)
  • Padding: PKCS5Padding

Процесс Шифрования Ключа

fun encryptKeyForRecipient(
    keyAndNonce: ByteArray,      // 56 байт: 32 (key) + 24 (nonce)
    recipientPublicKeyHex: String
): String {
    // 1. Генерация эфемерной пары ключей
    val ephemeralPrivateKey = SecureRandom.nextBytes(32)
    val ephemeralPublicKey = secp256k1.G × ephemeralPrivateKey

    // 2. ECDH: Вычисление shared secret
    val recipientPublicKey = parsePublicKey(recipientPublicKeyHex)
    val sharedPoint = recipientPublicKey × ephemeralPrivateKey
    val sharedSecret = sharedPoint.x.toHex()  // X-координата точки

    // 3. Генерация IV для AES
    val iv = SecureRandom.nextBytes(16)

    // 4. КРИТИЧНО: Latin1 → UTF-8 encoding
    // Эмуляция поведения crypto-js в JavaScript
    val latin1String = String(keyAndNonce, Charsets.ISO_8859_1)
    val utf8Bytes = latin1String.toByteArray(Charsets.UTF_8)

    // 5. AES-256-CBC шифрование
    val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
    cipher.init(
        Cipher.ENCRYPT_MODE,
        SecretKeySpec(sharedSecret.hexToBytes(), "AES"),
        IvParameterSpec(iv)
    )
    val encryptedKey = cipher.doFinal(utf8Bytes)

    // 6. Формат: iv:ciphertext:ephemeralPrivateKey
    val combined = "${iv.toHex()}:${encryptedKey.toHex()}:${ephemeralPrivateKey.toHex()}"

    // 7. Base64 encoding
    return Base64.encodeToString(combined.toByteArray(), Base64.NO_WRAP)
}

Почему Latin1 → UTF-8?

JavaScript (crypto-js) поведение:

// React Native
const key = Buffer.concat([keyBytes, nonceBytes]);  // 56 байт
const keyString = key.toString('binary');  // Latin1 строка (56 символов)
const encrypted = crypto.AES.encrypt(keyString, ...);
// crypto-js внутри делает: Utf8.parse(keyString) → конвертирует в UTF-8

Kotlin эмуляция:

// Байты → Latin1 строка (символы с кодами 0-255)
val latin1String = String(keyAndNonce, Charsets.ISO_8859_1)
// Latin1 строка → UTF-8 байты (байты > 127 становятся multi-byte)
val utf8Bytes = latin1String.toByteArray(Charsets.UTF_8)

Пример трансформации:

Байт:         [0xCC] (204)
↓
Latin1 char:  'Ì' (charCode = 204)
↓
UTF-8 bytes:  [0xC3, 0x8C] (2 байта)

Результат: 56 байт → 88 байт UTF-8 (байты > 127 занимают 2 байта в UTF-8)

Формат Зашифрованного Ключа

Base64( ivHex : ciphertextHex : ephemeralPrivateKeyHex )

Пример:
MjdmZTMzYTIyYjczYjNiNDhmOTIzYmY3YmJjNDhmMzE6MzIwNDc4NzMzNzM2NTQ1MzYy...

Декодированная строка:

27fe33a22b73b3b48f923bf7bbc48f31:32047873373654536225d8b1....:6ed2d3c3391fcccd...
[        IV (32 hex)        ]:[  Ciphertext (192 hex)    ]:[Ephemeral Key (64 hex)]

🔓 Расшифровка Ключа

fun decryptKeyFromSender(
    encryptedKeyBase64: String,
    myPrivateKeyHex: String
): ByteArray {
    // 1. Декодирование Base64
    val decoded = String(Base64.decode(encryptedKeyBase64, Base64.DEFAULT))
    val parts = decoded.split(":")
    val ivHex = parts[0]
    val ciphertextHex = parts[1]
    val ephemeralPrivateKeyHex = parts[2]

    // 2. Парсинг ключей
    val ephemeralPrivateKey = ephemeralPrivateKeyHex.hexToBytes()
    val myPrivateKey = myPrivateKeyHex.hexToBytes()

    // 3. ECDH: Вычисление того же shared secret
    val myPublicKey = secp256k1.G × myPrivateKey
    val ephemeralPublicKey = secp256k1.G × ephemeralPrivateKey
    val sharedPoint = myPublicKey × ephemeralPrivateKey
    val sharedSecret = sharedPoint.x.toHex()

    // 4. AES-256-CBC расшифровка
    val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
    cipher.init(
        Cipher.DECRYPT_MODE,
        SecretKeySpec(sharedSecret.hexToBytes(), "AES"),
        IvParameterSpec(ivHex.hexToBytes())
    )
    val decryptedUtf8Bytes = cipher.doFinal(ciphertextHex.hexToBytes())

    // 5. UTF-8 → Latin1 (обратная конвертация)
    val utf8String = String(decryptedUtf8Bytes, Charsets.UTF_8)
    val originalBytes = utf8String.toByteArray(Charsets.ISO_8859_1)

    return originalBytes  // 56 байт: 32 (key) + 24 (nonce)
}

📦 Полный Цикл: Отправка Сообщения

fun encryptForSending(
    plaintext: String,
    recipientPublicKey: String
): Pair<String, String> {
    // 1. Шифрование сообщения XChaCha20-Poly1305
    val encrypted = encryptMessage(plaintext)
    // encrypted.ciphertext = зашифрованное сообщение (hex)
    // encrypted.key = 32-byte ключ (hex)
    // encrypted.nonce = 24-byte nonce (hex)

    // 2. Объединение key + nonce
    val keyAndNonce = encrypted.key.hexToBytes() + encrypted.nonce.hexToBytes()
    // keyAndNonce = 56 байт

    // 3. Шифрование ключа для получателя (ECDH + AES)
    val encryptedKey = encryptKeyForRecipient(keyAndNonce, recipientPublicKey)
    // encryptedKey = Base64 строка

    return Pair(encrypted.ciphertext, encryptedKey)
}

Формат Пакета Сообщения

data class PacketMessage(
    val from: String,           // Публичный ключ отправителя (hex)
    val to: String,             // Публичный ключ получателя (hex)
    val content: String,        // Зашифрованное сообщение (hex)
    val chachaKey: String,      // Зашифрованный ключ (Base64)
    val timestamp: Long,
    val messageId: String
)

📬 Полный Цикл: Получение Сообщения

fun decryptReceived(
    encryptedContent: String,
    encryptedKey: String,
    myPrivateKey: String
): String {
    // 1. Расшифровка ключа (ECDH + AES)
    val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey)

    // 2. Разделение на key и nonce
    val key = keyAndNonce.copyOfRange(0, 32)
    val nonce = keyAndNonce.copyOfRange(32, 56)

    // 3. Расшифровка сообщения (XChaCha20-Poly1305)
    val plaintext = decryptMessage(
        encryptedContent.hexToBytes(),
        key,
        nonce
    )

    return String(plaintext, Charsets.UTF_8)
}

🔧 Критические Детали Реализации

1. Counter Management в XChaCha20

ПРОБЛЕМА: Если создать два отдельных ChaCha20 engine, оба начнут с counter=0.

Неправильно:

// Engine 1: Poly1305 ключ
val engine1 = ChaCha7539Engine()
engine1.processBytes(zeros, ...) // counter = 0

// Engine 2: Шифрование
val engine2 = ChaCha7539Engine()  // ❌ Снова counter = 0!
engine2.processBytes(plaintext, ...)

Правильно:

// Один engine для всего процесса
val engine = ChaCha7539Engine()
engine.init(true, ParametersWithIV(key, nonce))

// Poly1305 ключ (counter = 0)
engine.processBytes(zeros[64], ..., poly1305KeyBlock, ...)

// Шифрование (counter автоматически = 1)
engine.processBytes(plaintext, ..., ciphertext, ...)

2. JavaScript/Kotlin Совместимость

crypto-js особенности:

crypto.AES.encrypt(string, key, { iv });
// Внутри: crypto.enc.Utf8.parse(string)
// Это берёт charCode каждого символа как байт

Kotlin эмуляция:

// Байты → Latin1 (каждый байт = один символ)
val latin1 = String(bytes, Charsets.ISO_8859_1)
// Latin1 → UTF-8 (символы > 127 становятся multi-byte)
val utf8 = latin1.toByteArray(Charsets.UTF_8)

3. ECDH Shared Secret

JavaScript (elliptic.js):

const shared = ephemeralKey.derive(publicKey).toString(16);
// .toString(16) НЕ добавляет ведущие нули!

Kotlin эмуляция:

val xCoord = sharedPoint.normalize().xCoord.toBigInteger()
var hex = xCoord.toString(16)  // Может быть без ведущих нулей

// Если нечётная длина, добавить ведущий 0 для парсинга
if (hex.length % 2 != 0) {
    hex = "0$hex"
}

4. Little-Endian в Poly1305

Длины (AAD length, ciphertext length) должны быть в little-endian формате:

fun longToLittleEndian(n: Long): ByteArray {
    val bs = ByteArray(8)
    bs[0] = n.toByte()
    bs[1] = (n ushr 8).toByte()
    bs[2] = (n ushr 16).toByte()
    bs[3] = (n ushr 24).toByte()
    bs[4] = (n ushr 32).toByte()
    bs[5] = (n ushr 40).toByte()
    bs[6] = (n ushr 48).toByte()
    bs[7] = (n ushr 56).toByte()
    return bs
}

🎯 Безопасность

Сильные Стороны

  1. Forward Secrecy: Каждое сообщение использует уникальный эфемерный ключ
  2. Authenticated Encryption: Poly1305 MAC защищает от модификации
  3. Extended Nonce: XChaCha20 позволяет безопасно использовать случайные nonce
  4. ECDH: Асимметричное шифрование ключей без необходимости предварительного обмена

Важные Замечания

⚠️ Передача Ephemeral Private Key:

// Формат: iv:ciphertext:ephemeralPrivateKey

В текущей реализации ephemeral private key передаётся вместе с ciphertext. Это работает, но не является стандартной практикой. Обычно передаётся ephemeral public key, и получатель использует свой private key для ECDH.

Текущая схема:

  • Отправитель: ephemeralPrivate × recipientPublic = sharedSecret
  • Получатель: ephemeralPrivate × myPublic = sharedSecret

Стандартная схема:

  • Отправитель: ephemeralPrivate × recipientPublic = sharedSecret
  • Получатель: myPrivate × ephemeralPublic = sharedSecret

Текущая схема работает корректно, но передача private key нестандартна.


📊 Размеры Данных

Одно Сообщение

Plaintext:     N байт
XChaCha20:     N + 16 байт (ciphertext + Poly1305 tag)
Key+Nonce:     56 байт (32 + 24)
Latin1→UTF-8:  ~88 байт (зависит от содержимого)
AES+padding:   96 байт (после PKCS5 padding)
Зашифр. ключ: ~388 символов Base64

Итого overhead: ~452 символа (независимо от размера сообщения)

Пример

Сообщение: "Hello" (5 байт)
↓
XChaCha20: 21 байт (5 + 16 tag)
Key: 32 байт, Nonce: 24 байт → 56 байт
↓
AES: 96 байт (после padding)
↓
Base64: 388 символов

Передаётся:
- content: 42 символа (21 байт hex)
- chachaKey: 388 символов (Base64)
Итого: ~430 символов для передачи "Hello"

🧪 Тестирование

Unit Test для XChaCha20

@Test
fun testXChaCha20Compatibility() {
    val key = "ccd8617e4e328baee60fc2d5de0cca9aea7ac382330aa1daafb188bc875baa68"
    val nonce = "3cc45a27fe3910913bd429f6a814039fb2c7c564d6656727"
    val plaintext = "kdkdkdkd"

    val encrypted = xchacha20Poly1305Encrypt(
        plaintext.toByteArray(),
        key.hexToBytes(),
        nonce.hexToBytes()
    )

    // Должно совпадать с @noble/ciphers
    val decrypted = xchacha20Poly1305Decrypt(
        encrypted,
        key.hexToBytes(),
        nonce.hexToBytes()
    )

    assertEquals(plaintext, String(decrypted, Charsets.UTF_8))
}

Integration Test

@Test
fun testFullEncryptionCycle() {
    val alicePrivate = generatePrivateKey()
    val alicePublic = derivePublicKey(alicePrivate)

    val bobPrivate = generatePrivateKey()
    val bobPublic = derivePublicKey(bobPrivate)

    // Alice → Bob
    val message = "Secret message"
    val (ciphertext, encryptedKey) = encryptForSending(message, bobPublic)

    // Bob получает и расшифровывает
    val decrypted = decryptReceived(ciphertext, encryptedKey, bobPrivate)

    assertEquals(message, decrypted)
}

🔗 Библиотеки

Kotlin (Android)

  • BouncyCastle 1.77 — secp256k1, AES, ChaCha20, Poly1305
  • Android Crypto — SecureRandom

JavaScript (React Native/Desktop)

  • @noble/ciphers — XChaCha20-Poly1305
  • crypto-js — AES, PBKDF2, кодировки
  • elliptic — secp256k1, ECDH

📝 Changelog

Исправления 2026-01-11

Проблема: XChaCha20 Counter Management

Симптом: Desktop показывал кракозябры при получении сообщений от Kotlin Android app.

Причина: Kotlin создавал два отдельных ChaCha7539Engine:

  1. Первый для генерации Poly1305 ключа (counter = 0)
  2. Второй для шифрования plaintext (counter = 0 снова!)

Это приводило к тому что plaintext шифровался тем же keystream что и Poly1305 ключ.

Решение: Использовать один engine для всего процесса:

// 1. Генерация Poly1305 ключа (counter = 0, байты 0-63)
engine.processBytes(ByteArray(64), ..., poly1305KeyBlock, ...)

// 2. Шифрование (counter автоматически = 1, байты 64+)
engine.processBytes(plaintext, ..., ciphertext, ...)

🎓 Дополнительные Ресурсы