From 569127100fca508a3b05d999895a98d6e6dd56a4 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 11 Jan 2026 01:42:29 +0500 Subject: [PATCH] feat: Simplify AES key encryption and decryption process in MessageCrypto by removing unnecessary conversions and enhancing logging --- ARCHITECTURE.md | 20 +- .../crypto/CryptoCompatibilityTest.kt | 229 ++++++++++++++++++ .../rosetta/messenger/crypto/CryptoManager.kt | 42 +++- 3 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 app/src/androidTest/java/com/rosetta/messenger/crypto/CryptoCompatibilityTest.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 77d83d0..6093c25 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -186,10 +186,13 @@ fun encryptWithPassword(data: String, password: String): String **Алгоритм:** ``` -1. Сжатие данных (Deflate) +1. Сжатие данных (zlib Deflate) data → compressed bytes + (совместимо с pako.deflate в JS) -2. Деривация ключа (PBKDF2-HMAC-SHA256) +2. Деривация ключа (PBKDF2-HMAC-SHA1) + ⚠️ ВАЖНО: SHA1, не SHA256! + (crypto-js по умолчанию использует SHA1) password + "rosetta" salt + 1000 iterations → 256-bit AES key @@ -205,6 +208,19 @@ fun encryptWithPassword(data: String, password: String): String "aGVsbG8=:d29ybGQ=" ``` +**Кросс-платформенная совместимость:** + +| Параметр | JS (crypto-js) | Kotlin | +| ----------- | -------------- | ----------------- | +| PBKDF2 | HMAC-SHA1 | HMAC-SHA1 ✅ | +| Salt | "rosetta" | "rosetta" ✅ | +| Iterations | 1000 | 1000 ✅ | +| Key size | 256 bit | 256 bit ✅ | +| Cipher | AES-256-CBC | AES-256-CBC ✅ | +| Padding | PKCS7 | PKCS5 (=PKCS7) ✅ | +| Compression | pako.deflate | Deflater ✅ | +| Format | iv:ct (base64) | iv:ct (base64) ✅ | + **Зачем сжатие?** - Seed phrase (12 слов ≈ 100 байт) → ~50 байт после сжатия diff --git a/app/src/androidTest/java/com/rosetta/messenger/crypto/CryptoCompatibilityTest.kt b/app/src/androidTest/java/com/rosetta/messenger/crypto/CryptoCompatibilityTest.kt new file mode 100644 index 0000000..9301fb6 --- /dev/null +++ b/app/src/androidTest/java/com/rosetta/messenger/crypto/CryptoCompatibilityTest.kt @@ -0,0 +1,229 @@ +package com.rosetta.messenger.crypto + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.Assert.* +import org.junit.Before +import org.junit.runner.RunWith + +/** + * 🧪 Тесты кросс-платформенной совместимости шифрования + * + * Проверяют совместимость между: + * - Kotlin (CryptoManager.kt) + * - JS/React Native (cryptoJSI.ts) + * + * Алгоритм: + * - PBKDF2-HMAC-SHA1 (1000 iterations, salt="rosetta") + * - AES-256-CBC + PKCS7 padding + * - zlib compression (deflate/inflate) + * - Format: base64(iv):base64(ciphertext) + */ +@RunWith(AndroidJUnit4::class) +class CryptoCompatibilityTest { + + @Before + fun setup() { + java.security.Security.addProvider(org.bouncycastle.jce.provider.BouncyCastleProvider()) + } + + // ======================================== + // 🔐 Тестовые данные, зашифрованные на JS + // ======================================== + + /** + * Эти данные были зашифрованы на JS (cryptoJSI.ts): + * + * const encrypted = encodeWithPasswordJSI("testPassword123", "Hello World"); + * console.log(encrypted); + * + * Параметры: + * - password: "testPassword123" + * - plaintext: "Hello World" + * - salt: "rosetta" + * - iterations: 1000 + * - hash: SHA1 + */ + companion object { + // ⚠️ TODO: Вставьте реальные зашифрованные данные из JS консоли + // Для генерации выполните в RN/JS: + // + // import { encodeWithPasswordJSI } from './cryptoJSI'; + // console.log('Test 1:', encodeWithPasswordJSI("testPassword123", "Hello World")); + // console.log('Test 2:', encodeWithPasswordJSI("mySecretPass", '{"key":"value","number":42}')); + // console.log('Test 3:', encodeWithPasswordJSI("password", "")); + // console.log('Test 4:', encodeWithPasswordJSI("пароль123", "Привет мир! 🔐")); + + // Placeholder - замените реальными данными из JS + const val JS_ENCRYPTED_HELLO_WORLD = "" // encodeWithPasswordJSI("testPassword123", "Hello World") + const val JS_ENCRYPTED_JSON = "" // encodeWithPasswordJSI("mySecretPass", '{"key":"value","number":42}') + const val JS_ENCRYPTED_EMPTY = "" // encodeWithPasswordJSI("password", "") + const val JS_ENCRYPTED_CYRILLIC = "" // encodeWithPasswordJSI("пароль123", "Привет мир! 🔐") + } + + // ======================================== + // ✅ Тесты Kotlin → Kotlin (базовая проверка) + // ======================================== + + @Test + fun kotlin_encrypt_decrypt_roundtrip() { + val originalData = "Hello, World! This is a secret message." + val password = "testPassword123" + + val encrypted = CryptoManager.encryptWithPassword(originalData, password) + val decrypted = CryptoManager.decryptWithPassword(encrypted, password) + + assertNotNull("Encrypted data should not be null", encrypted) + assertTrue("Encrypted should contain ':'", encrypted.contains(":")) + assertEquals("Decrypted should match original", originalData, decrypted) + } + + @Test + fun kotlin_encrypt_decrypt_empty_string() { + val originalData = "" + val password = "password" + + val encrypted = CryptoManager.encryptWithPassword(originalData, password) + val decrypted = CryptoManager.decryptWithPassword(encrypted, password) + + assertEquals("Empty string should roundtrip correctly", originalData, decrypted) + } + + @Test + fun kotlin_encrypt_decrypt_cyrillic_and_emoji() { + val originalData = "Привет мир! 🔐 Тест кириллицы и эмодзи 🚀" + val password = "пароль123" + + val encrypted = CryptoManager.encryptWithPassword(originalData, password) + val decrypted = CryptoManager.decryptWithPassword(encrypted, password) + + assertEquals("Cyrillic and emoji should roundtrip correctly", originalData, decrypted) + } + + @Test + fun kotlin_encrypt_decrypt_json() { + val originalData = """{"key":"value","number":42,"nested":{"array":[1,2,3]}}""" + val password = "jsonPassword" + + val encrypted = CryptoManager.encryptWithPassword(originalData, password) + val decrypted = CryptoManager.decryptWithPassword(encrypted, password) + + assertEquals("JSON should roundtrip correctly", originalData, decrypted) + } + + @Test + fun kotlin_wrong_password_returns_null() { + val originalData = "Secret message" + val correctPassword = "correctPassword" + val wrongPassword = "wrongPassword" + + val encrypted = CryptoManager.encryptWithPassword(originalData, correctPassword) + val decrypted = CryptoManager.decryptWithPassword(encrypted, wrongPassword) + + assertNull("Wrong password should return null", decrypted) + } + + // ======================================== + // 🌐 Тесты JS → Kotlin (кросс-платформа) + // ======================================== + + @Test + fun js_to_kotlin_decrypt_hello_world() { + if (JS_ENCRYPTED_HELLO_WORLD.isEmpty()) { + println("⚠️ SKIP: JS_ENCRYPTED_HELLO_WORLD not set. Generate from JS first.") + return + } + + val decrypted = CryptoManager.decryptWithPassword(JS_ENCRYPTED_HELLO_WORLD, "testPassword123") + + assertEquals("Should decrypt JS data correctly", "Hello World", decrypted) + } + + @Test + fun js_to_kotlin_decrypt_json() { + if (JS_ENCRYPTED_JSON.isEmpty()) { + println("⚠️ SKIP: JS_ENCRYPTED_JSON not set. Generate from JS first.") + return + } + + val decrypted = CryptoManager.decryptWithPassword(JS_ENCRYPTED_JSON, "mySecretPass") + + assertEquals("Should decrypt JSON from JS", """{"key":"value","number":42}""", decrypted) + } + + @Test + fun js_to_kotlin_decrypt_empty() { + if (JS_ENCRYPTED_EMPTY.isEmpty()) { + println("⚠️ SKIP: JS_ENCRYPTED_EMPTY not set. Generate from JS first.") + return + } + + val decrypted = CryptoManager.decryptWithPassword(JS_ENCRYPTED_EMPTY, "password") + + assertEquals("Should decrypt empty string from JS", "", decrypted) + } + + @Test + fun js_to_kotlin_decrypt_cyrillic() { + if (JS_ENCRYPTED_CYRILLIC.isEmpty()) { + println("⚠️ SKIP: JS_ENCRYPTED_CYRILLIC not set. Generate from JS first.") + return + } + + val decrypted = CryptoManager.decryptWithPassword(JS_ENCRYPTED_CYRILLIC, "пароль123") + + assertEquals("Should decrypt Cyrillic from JS", "Привет мир! 🔐", decrypted) + } + + // ======================================== + // 🔧 Тест формата данных + // ======================================== + + @Test + fun encrypted_format_is_correct() { + val encrypted = CryptoManager.encryptWithPassword("test", "password") + + val parts = encrypted.split(":") + assertEquals("Should have exactly 2 parts (iv:ct)", 2, parts.size) + + // Проверяем что обе части - валидный Base64 + try { + val iv = android.util.Base64.decode(parts[0], android.util.Base64.NO_WRAP) + val ct = android.util.Base64.decode(parts[1], android.util.Base64.NO_WRAP) + + assertEquals("IV should be 16 bytes", 16, iv.size) + assertTrue("Ciphertext should not be empty", ct.isNotEmpty()) + } catch (e: Exception) { + fail("Both parts should be valid Base64: ${e.message}") + } + } + + // ======================================== + // 📊 Тест PBKDF2 ключа (для отладки) + // ======================================== + + @Test + fun pbkdf2_key_derivation_is_correct() { + // Известный тестовый вектор для PBKDF2-HMAC-SHA1 + // password: "testPassword123", salt: "rosetta", iterations: 1000, keyLen: 32 + + val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + val spec = javax.crypto.spec.PBEKeySpec( + "testPassword123".toCharArray(), + "rosetta".toByteArray(Charsets.UTF_8), + 1000, + 256 + ) + val key = factory.generateSecret(spec).encoded + + // Выводим ключ для сравнения с JS + val keyHex = key.joinToString("") { "%02x".format(it) } + println("🔑 PBKDF2 Key (hex): $keyHex") + + assertEquals("Key should be 32 bytes", 32, key.size) + + // ⚠️ TODO: Сравните этот ключ с JS: + // const key = generatePBKDF2KeyJSI("testPassword123"); + // console.log("Key (hex):", key.toString('hex')); + } +} diff --git a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt index 041a540..6be22f2 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt @@ -139,14 +139,24 @@ object CryptoManager { /** * Encrypt data with password using PBKDF2 + AES + * + * ⚠️ ВАЖНО: Совместимость с JS (crypto-js) и React Native (cryptoJSI.ts): + * - PBKDF2WithHmacSHA1 (не SHA256!) - crypto-js использует SHA1 по умолчанию + * - Salt: "rosetta" + * - Iterations: 1000 + * - Key size: 256 bit + * - AES-256-CBC с PKCS5/PKCS7 padding + * - Compression: zlib deflate (pako.deflate в JS) + * - Формат: base64(iv):base64(ciphertext) */ fun encryptWithPassword(data: String, password: String): String { - // Compress data - val compressed = compress(data.toByteArray()) + // Compress data (zlib deflate - совместимо с pako.deflate в JS) + val compressed = compress(data.toByteArray(Charsets.UTF_8)) - // Derive key using PBKDF2 - val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") - val spec = PBEKeySpec(password.toCharArray(), SALT.toByteArray(), PBKDF2_ITERATIONS, KEY_SIZE) + // Derive key using PBKDF2-HMAC-SHA1 (⚠️ SHA1, не SHA256!) + // crypto-js по умолчанию использует SHA1 для PBKDF2 + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + val spec = PBEKeySpec(password.toCharArray(), SALT.toByteArray(Charsets.UTF_8), PBKDF2_ITERATIONS, KEY_SIZE) val secretKey = factory.generateSecret(spec) val key = SecretKeySpec(secretKey.encoded, "AES") @@ -169,6 +179,15 @@ object CryptoManager { /** * Decrypt data with password + * + * ⚠️ ВАЖНО: Совместимость с JS (crypto-js) и React Native (cryptoJSI.ts): + * - PBKDF2WithHmacSHA1 (не SHA256!) - crypto-js использует SHA1 по умолчанию + * - Salt: "rosetta" + * - Iterations: 1000 + * - Key size: 256 bit + * - AES-256-CBC с PKCS5/PKCS7 padding + * - Decompression: zlib inflate (pako.inflate в JS) + * - Формат: base64(iv):base64(ciphertext) */ fun decryptWithPassword(encryptedData: String, password: String): String? { return try { @@ -178,20 +197,21 @@ object CryptoManager { val iv = Base64.decode(parts[0], Base64.NO_WRAP) val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP) - // Derive key using PBKDF2 - val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") - val spec = PBEKeySpec(password.toCharArray(), SALT.toByteArray(), PBKDF2_ITERATIONS, KEY_SIZE) + // Derive key using PBKDF2-HMAC-SHA1 (⚠️ SHA1, не SHA256!) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + val spec = PBEKeySpec(password.toCharArray(), SALT.toByteArray(Charsets.UTF_8), PBKDF2_ITERATIONS, KEY_SIZE) val secretKey = factory.generateSecret(spec) val key = SecretKeySpec(secretKey.encoded, "AES") - // Decrypt + // Decrypt with AES-256-CBC val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) val decrypted = cipher.doFinal(ciphertext) - // Decompress - String(decompress(decrypted)) + // Decompress (zlib inflate - совместимо с pako.inflate в JS) + String(decompress(decrypted), Charsets.UTF_8) } catch (e: Exception) { + android.util.Log.e("CryptoManager", "decryptWithPassword failed: ${e.message}") null } }