diff --git a/ENCRYPTION_EXPLAINED.md b/ENCRYPTION_EXPLAINED.md new file mode 100644 index 0000000..f7473cc --- /dev/null +++ b/ENCRYPTION_EXPLAINED.md @@ -0,0 +1,689 @@ +# 🔐 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)