# 🔐 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) #### Процесс Шифрования ```kotlin 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** для получения производного ключа. ```kotlin // Вход: 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 ```kotlin // 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 для всего процесса! ```kotlin val engine = ChaCha7539Engine() engine.init(true, ParametersWithIV(KeyParameter(subkey), chacha20Nonce)) ``` ### Этап 4: Генерация Poly1305 Ключа **КРИТИЧЕСКАЯ ДЕТАЛЬ**: Poly1305 ключ — это первые 32 байта первого блока ChaCha20 keystream (counter = 0). ```kotlin // Генерируем первый 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 ```kotlin // 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. ```kotlin 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: Финальный Формат ```kotlin return ciphertext + tag // [ciphertext][16-byte Poly1305 tag] ``` --- ## 🔓 Расшифровка XChaCha20-Poly1305 Процесс зеркальный, но с проверкой аутентификации **перед** расшифровкой: ```kotlin 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 #### Процесс Шифрования Ключа ```kotlin 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) поведение**: ```javascript // 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 эмуляция**: ```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)] ``` --- ## 🔓 Расшифровка Ключа ```kotlin 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) } ``` --- ## 📦 Полный Цикл: Отправка Сообщения ```kotlin fun encryptForSending( plaintext: String, recipientPublicKey: String ): Pair { // 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) } ``` ### Формат Пакета Сообщения ```kotlin 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 ) ``` --- ## 📬 Полный Цикл: Получение Сообщения ```kotlin 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. ❌ **Неправильно**: ```kotlin // Engine 1: Poly1305 ключ val engine1 = ChaCha7539Engine() engine1.processBytes(zeros, ...) // counter = 0 // Engine 2: Шифрование val engine2 = ChaCha7539Engine() // ❌ Снова counter = 0! engine2.processBytes(plaintext, ...) ``` ✅ **Правильно**: ```kotlin // Один 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 особенности**: ```javascript crypto.AES.encrypt(string, key, { iv }); // Внутри: crypto.enc.Utf8.parse(string) // Это берёт charCode каждого символа как байт ``` **Kotlin эмуляция**: ```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)**: ```javascript const shared = ephemeralKey.derive(publicKey).toString(16); // .toString(16) НЕ добавляет ведущие нули! ``` **Kotlin эмуляция**: ```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** формате: ```kotlin 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**: ```kotlin // Формат: 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 ```kotlin @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 ```kotlin @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 для всего процесса: ```kotlin // 1. Генерация Poly1305 ключа (counter = 0, байты 0-63) engine.processBytes(ByteArray(64), ..., poly1305KeyBlock, ...) // 2. Шифрование (counter автоматически = 1, байты 64+) engine.processBytes(plaintext, ..., ciphertext, ...) ``` --- ## 🎓 Дополнительные Ресурсы - [XChaCha20-Poly1305 RFC Draft](https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-xchacha) - [ChaCha20 и Poly1305 для IETF](https://datatracker.ietf.org/doc/html/rfc8439) - [secp256k1 на SEC2](https://www.secg.org/sec2-v2.pdf) - [Elliptic Curve Diffie-Hellman](https://en.wikipedia.org/wiki/Elliptic-curve_Diffie%E2%80%93Hellman)