1
0
Fork 0
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:
Konstantin Ivanov 2025-05-07 18:28:18 +02:00 committed by GitHub
parent 01638ff2f7
commit 707d3422df
Signed by: github
GPG key ID: B5690EEEBB952194
8 changed files with 424 additions and 0 deletions

View file

@ -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

View file

@ -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)
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}