Files
mobile-android/ENCRYPTION_EXPLAINED.md

690 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🔐 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)