feat: Add detailed encryption architecture documentation for Rosette Messenger

This commit is contained in:
k1ngsterr1
2026-01-11 04:22:23 +05:00
parent f9411e8419
commit 8e32ea3782

689
ENCRYPTION_EXPLAINED.md Normal file
View File

@@ -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<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)
}
```
### Формат Пакета Сообщения
```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)