mirror of
https://github.com/anyproto/anytype-kotlin.git
synced 2025-06-08 05:47:05 +09:00
DROID-3638 Notifications | Push decryption, part 1 (#2385)
This commit is contained in:
parent
01638ff2f7
commit
707d3422df
8 changed files with 424 additions and 0 deletions
|
@ -3,6 +3,7 @@ plugins {
|
|||
id "kotlin-android"
|
||||
id "com.google.devtools.ksp"
|
||||
id "kotlin-parcelize"
|
||||
id "kotlinx-serialization"
|
||||
}
|
||||
|
||||
def networkConfigFile = rootProject.file("network.properties")
|
||||
|
@ -52,6 +53,8 @@ dependencies {
|
|||
|
||||
implementation libs.gson
|
||||
|
||||
implementation libs.kotlinxSerializationJson
|
||||
|
||||
testImplementation libs.junit
|
||||
testImplementation libs.kotlinTest
|
||||
testImplementation libs.mockitoKotlin
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
package com.anytypeio.anytype.presentation.notifications
|
||||
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
interface CryptoService {
|
||||
/**
|
||||
* Decrypts AES-GCM encrypted data.
|
||||
* @param data Combined format: nonce (12 bytes) + ciphertext + tag
|
||||
* @param keyData AES key bytes
|
||||
* @return decrypted plaintext bytes
|
||||
* @throws CryptoError.DecryptionFailed if decryption fails
|
||||
*/
|
||||
@Throws(CryptoError.DecryptionFailed::class)
|
||||
fun decryptAESGCM(data: ByteArray, keyData: ByteArray): ByteArray
|
||||
}
|
||||
|
||||
class CryptoServiceImpl : CryptoService {
|
||||
companion object {
|
||||
private const val NONCE_SIZE = 12
|
||||
}
|
||||
|
||||
@Throws(CryptoError.DecryptionFailed::class)
|
||||
override fun decryptAESGCM(data: ByteArray, keyData: ByteArray): ByteArray {
|
||||
return try {
|
||||
// Verify input data length
|
||||
if (data.size < NONCE_SIZE) {
|
||||
throw CryptoError.DecryptionFailed(IllegalArgumentException("Input data must be at least $NONCE_SIZE bytes"))
|
||||
}
|
||||
|
||||
// Create SecretKeySpec for AES
|
||||
val keySpec = SecretKeySpec(keyData, "AES")
|
||||
|
||||
// Combined data format: nonce + ciphertext + tag
|
||||
val nonce = data.copyOfRange(0, NONCE_SIZE)
|
||||
val cipherText = data.copyOfRange(NONCE_SIZE, data.size)
|
||||
|
||||
// Initialize Cipher for AES/GCM/NoPadding
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val gcmSpec = GCMParameterSpec(128, nonce)
|
||||
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec)
|
||||
|
||||
// Perform decryption and return plaintext
|
||||
cipher.doFinal(cipherText)
|
||||
} catch (e: Exception) {
|
||||
throw CryptoError.DecryptionFailed(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Errors for CryptoService
|
||||
*/
|
||||
sealed class CryptoError(message: String, cause: Throwable? = null) : Exception(message, cause) {
|
||||
class DecryptionFailed(cause: Throwable? = null) : CryptoError("Decryption failed", cause)
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package com.anytypeio.anytype.presentation.notifications
|
||||
|
||||
import android.util.Base64
|
||||
import com.anytypeio.anytype.core_models.DecryptedPushContent
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
interface DecryptionPushContentService {
|
||||
fun decrypt(encryptedData: ByteArray, keyId: String): DecryptedPushContent?
|
||||
}
|
||||
|
||||
class DecryptionPushContentServiceImpl @Inject constructor(
|
||||
private val pushKeyProvider: PushKeyProvider,
|
||||
private val cryptoService: CryptoService
|
||||
) : DecryptionPushContentService {
|
||||
|
||||
init {
|
||||
Timber.d("DecryptionPushContentService initialized")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
override fun decrypt(encryptedData: ByteArray, keyId: String): DecryptedPushContent? {
|
||||
return try {
|
||||
// Get the encryption key from provider
|
||||
val pushKeys = pushKeyProvider.getPushKey()
|
||||
val key = pushKeys[keyId] ?: run {
|
||||
Timber.w("No encryption key found for keyId: $keyId")
|
||||
return null
|
||||
}
|
||||
|
||||
// Decode the key from Base64
|
||||
val keyData = try {
|
||||
Base64.decode(key.value, Base64.DEFAULT)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.e(e, "Failed to decode key from Base64 for keyId: $keyId")
|
||||
return null
|
||||
}
|
||||
|
||||
// Decrypt the data
|
||||
val decryptedData = try {
|
||||
cryptoService.decryptAESGCM(data = encryptedData, keyData = keyData)
|
||||
} catch (e: CryptoError.DecryptionFailed) {
|
||||
Timber.e(e, "Failed to decrypt data for keyId: $keyId")
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse the decrypted JSON
|
||||
try {
|
||||
Json.decodeFromStream<DecryptedPushContent>(decryptedData.inputStream())
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse decrypted data for keyId: $keyId")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Unexpected error during decryption for keyId: $keyId")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package com.anytypeio.anytype.presentation.notifications
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class CryptoServiceTest {
|
||||
|
||||
private lateinit var cryptoService: CryptoService
|
||||
private lateinit var key: ByteArray
|
||||
private lateinit var validEncryptedData: ByteArray
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
cryptoService = CryptoServiceImpl()
|
||||
|
||||
// Generate a test key
|
||||
key = "testKey123456789".toByteArray()
|
||||
|
||||
// Create test data and encrypt it
|
||||
val testData = "Hello, World!".toByteArray()
|
||||
validEncryptedData = encryptTestData(testData, key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decryptAESGCM should successfully decrypt valid data`() {
|
||||
// Given
|
||||
val expectedData = "Hello, World!".toByteArray()
|
||||
|
||||
// When
|
||||
val decryptedData = cryptoService.decryptAESGCM(validEncryptedData, key)
|
||||
|
||||
// Then
|
||||
assertArrayEquals(expectedData, decryptedData)
|
||||
}
|
||||
|
||||
@Test(expected = CryptoError.DecryptionFailed::class)
|
||||
fun `decryptAESGCM should throw DecryptionFailed when key is invalid`() {
|
||||
// Given
|
||||
val invalidKey = "invalidKey123456".toByteArray()
|
||||
|
||||
// When
|
||||
cryptoService.decryptAESGCM(validEncryptedData, invalidKey)
|
||||
|
||||
// Then - expect exception
|
||||
}
|
||||
|
||||
@Test(expected = CryptoError.DecryptionFailed::class)
|
||||
fun `decryptAESGCM should throw DecryptionFailed when data is too short`() {
|
||||
// Given
|
||||
val invalidData = ByteArray(10) // Too short to contain nonce + ciphertext
|
||||
|
||||
// When
|
||||
cryptoService.decryptAESGCM(invalidData, key)
|
||||
|
||||
// Then - expect exception
|
||||
}
|
||||
|
||||
@Test(expected = CryptoError.DecryptionFailed::class)
|
||||
fun `decryptAESGCM should throw DecryptionFailed when data is corrupted`() {
|
||||
// Given
|
||||
val corruptedData = validEncryptedData.copyOf()
|
||||
corruptedData[20] = corruptedData[20].inc() // Corrupt one byte
|
||||
|
||||
// When
|
||||
cryptoService.decryptAESGCM(corruptedData, key)
|
||||
|
||||
// Then - expect exception
|
||||
}
|
||||
|
||||
private fun encryptTestData(data: ByteArray, key: ByteArray): ByteArray {
|
||||
val keySpec = SecretKeySpec(key, "AES")
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
|
||||
// Generate random nonce
|
||||
val nonce = ByteArray(12).apply {
|
||||
java.security.SecureRandom().nextBytes(this)
|
||||
}
|
||||
|
||||
val gcmSpec = GCMParameterSpec(128, nonce)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec)
|
||||
|
||||
// Encrypt the data
|
||||
val encryptedData = cipher.doFinal(data)
|
||||
|
||||
// Combine nonce and encrypted data
|
||||
return nonce + encryptedData
|
||||
}
|
||||
}
|
|
@ -0,0 +1,168 @@
|
|||
package com.anytypeio.anytype.presentation.notifications
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import com.anytypeio.anytype.core_models.DecryptedPushContent
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.P])
|
||||
class DecryptionPushContentServiceImplTest {
|
||||
|
||||
private lateinit var pushKeyProvider: PushKeyProvider
|
||||
private lateinit var cryptoService: CryptoService
|
||||
private lateinit var decryptionService: DecryptionPushContentService
|
||||
|
||||
private val testKeyId = "test-key-id"
|
||||
private val testKey = "testKey123456789"
|
||||
private val testSpaceId = "test-space-id"
|
||||
private val testSenderId = "test-sender-id"
|
||||
private val testChatId = "test-chat-id"
|
||||
private val testMsgId = "test-msg-id"
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
pushKeyProvider = mock()
|
||||
cryptoService = CryptoServiceImpl()
|
||||
decryptionService = DecryptionPushContentServiceImpl(
|
||||
pushKeyProvider = pushKeyProvider,
|
||||
cryptoService = cryptoService
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decrypt should successfully decrypt and parse valid data`() {
|
||||
// Given
|
||||
val keyAsBytes = testKey.toByteArray()
|
||||
val value = Base64.encodeToString(keyAsBytes, Base64.DEFAULT)
|
||||
val expectedContent = createTestContent()
|
||||
val encryptedData = encryptTestData(expectedContent)
|
||||
whenever(pushKeyProvider.getPushKey()).thenReturn(
|
||||
mapOf(testKeyId to PushKey(id = testKeyId, value = value))
|
||||
)
|
||||
|
||||
// When
|
||||
val result = decryptionService.decrypt(encryptedData, testKeyId)
|
||||
|
||||
// Then
|
||||
assertEquals(expectedContent, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decrypt should return null when key not found`() {
|
||||
// Given
|
||||
val encryptedData = ByteArray(100)
|
||||
whenever(pushKeyProvider.getPushKey()).thenReturn(emptyMap())
|
||||
|
||||
// When
|
||||
val result = decryptionService.decrypt(encryptedData, testKeyId)
|
||||
|
||||
// Then
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decrypt should return null when key is invalid base64`() {
|
||||
// Given
|
||||
val encryptedData = ByteArray(100)
|
||||
whenever(pushKeyProvider.getPushKey()).thenReturn(
|
||||
mapOf(testKeyId to PushKey(id = testKeyId, value = "invalid-base64"))
|
||||
)
|
||||
|
||||
// When
|
||||
val result = decryptionService.decrypt(encryptedData, testKeyId)
|
||||
|
||||
// Then
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decrypt should return null when decryption fails`() {
|
||||
// Given
|
||||
val encryptedData = ByteArray(100)
|
||||
whenever(pushKeyProvider.getPushKey()).thenReturn(
|
||||
mapOf(
|
||||
testKeyId to PushKey(
|
||||
id = testKeyId,
|
||||
value = Base64.encodeToString("wrong-key".toByteArray(), Base64.DEFAULT)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// When
|
||||
val result = decryptionService.decrypt(encryptedData, testKeyId)
|
||||
|
||||
// Then
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decrypt should return null when json parsing fails`() {
|
||||
// Given
|
||||
val invalidJson = "invalid-json".toByteArray()
|
||||
whenever(pushKeyProvider.getPushKey()).thenReturn(
|
||||
mapOf(
|
||||
testKeyId to PushKey(
|
||||
id = testKeyId,
|
||||
value = Base64.encodeToString(testKey.toByteArray(), Base64.DEFAULT)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// When
|
||||
val result = decryptionService.decrypt(invalidJson, testKeyId)
|
||||
|
||||
// Then
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
private fun createTestContent(): DecryptedPushContent {
|
||||
return DecryptedPushContent(
|
||||
spaceId = testSpaceId,
|
||||
type = 1,
|
||||
senderId = testSenderId,
|
||||
newMessage = DecryptedPushContent.Message(
|
||||
chatId = testChatId,
|
||||
msgId = testMsgId,
|
||||
text = "Test message",
|
||||
spaceName = "Test Space",
|
||||
senderName = "Test Sender"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun encryptTestData(content: DecryptedPushContent): ByteArray {
|
||||
// Convert content to JSON
|
||||
val jsonString = Json.encodeToString(DecryptedPushContent.serializer(), content)
|
||||
val plaintext = jsonString.toByteArray()
|
||||
|
||||
// Generate random nonce
|
||||
val nonce = ByteArray(12).apply {
|
||||
java.security.SecureRandom().nextBytes(this)
|
||||
}
|
||||
|
||||
// Initialize cipher
|
||||
val keySpec = SecretKeySpec(testKey.toByteArray(), "AES")
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val gcmSpec = GCMParameterSpec(128, nonce)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec)
|
||||
|
||||
// Encrypt
|
||||
val ciphertext = cipher.doFinal(plaintext)
|
||||
|
||||
// Combine nonce and ciphertext
|
||||
return nonce + ciphertext
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue