From d759cb26010088b56897e445c89ce3d2634153b5 Mon Sep 17 00:00:00 2001 From: Naomi Plasterer Date: Mon, 6 Jan 2025 21:43:28 -0800 Subject: [PATCH] V3 HMAC key support for self push notifications (#356) * hmac key updates * dump the bindings * clean up the example * add support for streaming hmac keys * add tests for streaming * fix up lint * more lint fixes * bump proto and fix test * fix up lint * bump * bump binaries as well * add back sync consent * get on latest bindings * get it working in the example app --- .../org/xmtp/android/example/MainViewModel.kt | 17 +++++-- .../ConversationDetailViewModel.kt | 3 +- .../example/message/MessageViewHolder.kt | 2 +- .../PushNotificationsService.kt | 6 +-- library/build.gradle | 2 +- .../xmtp/android/library/ConversationsTest.kt | 21 +++++++++ .../xmtp/android/library/HistorySyncTest.kt | 45 +++++++++++++++++-- .../java/org/xmtp/android/library/Client.kt | 5 --- .../org/xmtp/android/library/Conversations.kt | 22 +++++++++ .../java/org/xmtp/android/library/Crypto.kt | 31 ------------- .../android/library/PrivatePreferences.kt | 26 +++++++++++ 11 files changed, 132 insertions(+), 48 deletions(-) diff --git a/example/src/main/java/org/xmtp/android/example/MainViewModel.kt b/example/src/main/java/org/xmtp/android/example/MainViewModel.kt index edd9262c0..372e41548 100644 --- a/example/src/main/java/org/xmtp/android/example/MainViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/MainViewModel.kt @@ -46,9 +46,20 @@ class MainViewModel : ViewModel() { val listItems = mutableListOf() try { val conversations = ClientManager.client.conversations.list() - val subscriptions: MutableList = conversations.map { + val subscriptions = conversations.map { + val hmacKeysResult = ClientManager.client.conversations.getHmacKeys() + val hmacKeys = hmacKeysResult.hmacKeysMap + val result = hmacKeys[it.topic]?.valuesList?.map { hmacKey -> + Service.Subscription.HmacKey.newBuilder().also { sub_key -> + sub_key.key = hmacKey.hmacKey + sub_key.thirtyDayPeriodsSinceEpoch = hmacKey.thirtyDayPeriodsSinceEpoch + }.build() + } + Service.Subscription.newBuilder().also { sub -> + sub.addAllHmacKeys(result) sub.topic = it.topic + sub.isSilent = false }.build() }.toMutableList() @@ -85,7 +96,7 @@ class MainViewModel : ViewModel() { @WorkerThread private fun fetchMostRecentMessage(conversation: Conversation): Message? { - return runBlocking { conversation.messages(limit = 1).firstOrNull() } + return runBlocking { conversation.lastMessage() } } @OptIn(ExperimentalCoroutinesApi::class) @@ -124,7 +135,7 @@ class MainViewModel : ViewModel() { data class ConversationItem( override val id: String, val conversation: Conversation, - val mostRecentMessage: DecodedMessage?, + val mostRecentMessage: Message?, ) : MainListItem(id, ITEM_TYPE_CONVERSATION) data class Footer( diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt index 17ef956e3..e7d9b51e2 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt @@ -20,6 +20,7 @@ import org.xmtp.android.example.ClientManager import org.xmtp.android.example.extension.flowWhileShared import org.xmtp.android.example.extension.stateFlow import org.xmtp.android.library.Conversation +import org.xmtp.android.library.libxmtp.Message class ConversationDetailViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() { @@ -125,7 +126,7 @@ class ConversationDetailViewModel(private val savedStateHandle: SavedStateHandle const val ITEM_TYPE_MESSAGE = 1 } - data class Message(override val id: String, val message: DecodedMessage) : + data class Message(override val id: String, val message: org.xmtp.android.library.libxmtp.Message) : MessageListItem(id, ITEM_TYPE_MESSAGE) } } diff --git a/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt b/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt index 58a67f086..39a0d20c0 100644 --- a/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt +++ b/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt @@ -47,7 +47,7 @@ class MessageViewHolder( if (item.message.content() is String) { binding.messageBody.text = item.message.body val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - binding.messageDate.text = sdf.format(item.message.sent) + binding.messageDate.text = sdf.format(item.message.sentAt) } else if (item.message.content() is GroupUpdated) { val changes = item.message.content() as? GroupUpdated diff --git a/example/src/main/java/org/xmtp/android/example/pushnotifications/PushNotificationsService.kt b/example/src/main/java/org/xmtp/android/example/pushnotifications/PushNotificationsService.kt index 1b11d33f6..fc50d8bcd 100644 --- a/example/src/main/java/org/xmtp/android/example/pushnotifications/PushNotificationsService.kt +++ b/example/src/main/java/org/xmtp/android/example/pushnotifications/PushNotificationsService.kt @@ -90,12 +90,12 @@ class PushNotificationsService : FirebaseMessagingService() { return } val decodedMessage = - runBlocking { conversation.processMessage(encryptedMessageData).decode() } + runBlocking { conversation.processMessage(encryptedMessageData) } val peerAddress = conversation.id - val body: String = if (decodedMessage.content() is String) { + val body: String = if (decodedMessage?.content() is String) { decodedMessage.body - } else if (decodedMessage.content() is GroupUpdated) { + } else if (decodedMessage?.content() is GroupUpdated) { val changes = decodedMessage.content() as? GroupUpdated "Membership Changed ${ changes?.addedInboxesList?.mapNotNull { it.inboxId }.toString() diff --git a/library/build.gradle b/library/build.gradle index e8a4de20b..751b8f121 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -89,7 +89,7 @@ dependencies { implementation 'org.web3j:crypto:4.9.4' implementation "net.java.dev.jna:jna:5.14.0@aar" api 'com.google.protobuf:protobuf-kotlin-lite:3.22.3' - api 'org.xmtp:proto-kotlin:3.72.3' + api 'org.xmtp:proto-kotlin:3.72.4' testImplementation 'junit:junit:4.13.2' testImplementation 'androidx.test:monitor:1.7.2' diff --git a/library/src/androidTest/java/org/xmtp/android/library/ConversationsTest.kt b/library/src/androidTest/java/org/xmtp/android/library/ConversationsTest.kt index d0a8edac8..78aab7c7e 100644 --- a/library/src/androidTest/java/org/xmtp/android/library/ConversationsTest.kt +++ b/library/src/androidTest/java/org/xmtp/android/library/ConversationsTest.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -187,4 +188,24 @@ class ConversationsTest { assertEquals(2, allMessages.size) job.cancel() } + + @Test + fun testReturnsAllHMACKeys() { + val conversations = mutableListOf() + repeat(5) { + val account = PrivateKeyBuilder() + val client = runBlocking { Client().create(account, fixtures.clientOptions) } + runBlocking { + conversations.add( + alixClient.conversations.newConversation(client.address) + ) + } + } + val hmacKeys = alixClient.conversations.getHmacKeys() + + val topics = hmacKeys.hmacKeysMap.keys + conversations.forEach { convo -> + assertTrue(topics.contains(convo.topic)) + } + } } diff --git a/library/src/androidTest/java/org/xmtp/android/library/HistorySyncTest.kt b/library/src/androidTest/java/org/xmtp/android/library/HistorySyncTest.kt index 6505727aa..c6894235d 100644 --- a/library/src/androidTest/java/org/xmtp/android/library/HistorySyncTest.kt +++ b/library/src/androidTest/java/org/xmtp/android/library/HistorySyncTest.kt @@ -75,7 +75,6 @@ class HistorySyncTest { runBlocking { alixClient2.preferences.syncConsent() - Thread.sleep(2000) alixClient.conversations.syncAllConversations() Thread.sleep(2000) alixClient2.conversations.syncAllConversations() @@ -125,8 +124,7 @@ class HistorySyncTest { runBlocking { alix2Group.send("A message") alix2Group.send("A second message") - alixClient3.requestMessageHistorySync() - Thread.sleep(1000) + Thread.sleep(2000) alixClient.conversations.syncAllConversations() Thread.sleep(2000) alixClient2.conversations.syncAllConversations() @@ -170,4 +168,45 @@ class HistorySyncTest { assertEquals(alixGroup.consentState(), ConsentState.DENIED) job.cancel() } + + @Test + fun testStreamPreferenceUpdates() { + var preferences = 0 + val job = CoroutineScope(Dispatchers.IO).launch { + try { + alixClient.preferences.streamPreferenceUpdates() + .collect { entry -> + preferences++ + } + } catch (e: Exception) { + } + } + + Thread.sleep(2000) + + runBlocking { + val alixClient3 = runBlocking { + Client().create( + account = alixWallet, + options = ClientOptions( + ClientOptions.Api(XMTPEnvironment.LOCAL, false), + appContext = fixtures.context, + dbEncryptionKey = fixtures.key, + dbDirectory = File(fixtures.context.filesDir.absolutePath, "xmtp_db3").toPath() + .toString() + ) + ) + } + alixClient3.conversations.syncAllConversations() + Thread.sleep(2000) + alixClient.conversations.syncAllConversations() + Thread.sleep(2000) + alixClient2.conversations.syncAllConversations() + Thread.sleep(2000) + } + + Thread.sleep(2000) + assertEquals(2, preferences) + job.cancel() + } } diff --git a/library/src/main/java/org/xmtp/android/library/Client.kt b/library/src/main/java/org/xmtp/android/library/Client.kt index ffb300191..9e1d0b5f0 100644 --- a/library/src/main/java/org/xmtp/android/library/Client.kt +++ b/library/src/main/java/org/xmtp/android/library/Client.kt @@ -8,7 +8,6 @@ import org.xmtp.android.library.libxmtp.InboxState import org.xmtp.android.library.libxmtp.Message import org.xmtp.android.library.messages.rawData import uniffi.xmtpv3.FfiConversationType -import uniffi.xmtpv3.FfiDeviceSyncKind import uniffi.xmtpv3.FfiSignatureRequest import uniffi.xmtpv3.FfiXmtpClient import uniffi.xmtpv3.XmtpApiClient @@ -386,10 +385,6 @@ class Client() { ffiClient.dbReconnect() } - suspend fun requestMessageHistorySync() { - ffiClient.sendSyncRequest(FfiDeviceSyncKind.MESSAGES) - } - suspend fun inboxStatesForInboxIds( refreshFromNetwork: Boolean, inboxIds: List, diff --git a/library/src/main/java/org/xmtp/android/library/Conversations.kt b/library/src/main/java/org/xmtp/android/library/Conversations.kt index 7101e41fd..c849b87c4 100644 --- a/library/src/main/java/org/xmtp/android/library/Conversations.kt +++ b/library/src/main/java/org/xmtp/android/library/Conversations.kt @@ -1,6 +1,7 @@ package org.xmtp.android.library import android.util.Log +import com.google.protobuf.kotlin.toByteString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -8,6 +9,8 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.launch import org.xmtp.android.library.libxmtp.GroupPermissionPreconfiguration import org.xmtp.android.library.libxmtp.Message +import org.xmtp.android.library.messages.Topic +import org.xmtp.proto.keystore.api.v1.Keystore import org.xmtp.android.library.libxmtp.PermissionPolicySet import uniffi.xmtpv3.FfiConversation import uniffi.xmtpv3.FfiConversationCallback @@ -277,4 +280,23 @@ data class Conversations( awaitClose { stream.end() } } + + fun getHmacKeys(): Keystore.GetConversationHmacKeysResponse { + val hmacKeysResponse = Keystore.GetConversationHmacKeysResponse.newBuilder() + val conversations = ffiConversations.getHmacKeys() + conversations.iterator().forEach { + val hmacKeys = Keystore.GetConversationHmacKeysResponse.HmacKeys.newBuilder() + it.value.forEach { key -> + val hmacKeyData = Keystore.GetConversationHmacKeysResponse.HmacKeyData.newBuilder() + hmacKeyData.hmacKey = key.key.toByteString() + hmacKeyData.thirtyDayPeriodsSinceEpoch = key.epoch.toInt() + hmacKeys.addValues(hmacKeyData) + } + hmacKeysResponse.putHmacKeys( + Topic.groupMessage(it.key.toHex()).description, + hmacKeys.build() + ) + } + return hmacKeysResponse.build() + } } diff --git a/library/src/main/java/org/xmtp/android/library/Crypto.kt b/library/src/main/java/org/xmtp/android/library/Crypto.kt index 768f42aa7..3ff9f0207 100644 --- a/library/src/main/java/org/xmtp/android/library/Crypto.kt +++ b/library/src/main/java/org/xmtp/android/library/Crypto.kt @@ -7,7 +7,6 @@ import org.xmtp.proto.message.contents.CiphertextOuterClass import java.security.GeneralSecurityException import java.security.SecureRandom import javax.crypto.Cipher -import javax.crypto.Mac import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec @@ -74,35 +73,5 @@ class Crypto { null } } - - fun calculateMac(secret: ByteArray, message: ByteArray): ByteArray { - val sha256HMAC: Mac = Mac.getInstance("HmacSHA256") - val secretKey = SecretKeySpec(secret, "HmacSHA256") - sha256HMAC.init(secretKey) - return sha256HMAC.doFinal(message) - } - - fun deriveKey( - secret: ByteArray, - salt: ByteArray, - info: ByteArray, - ): ByteArray { - return Hkdf.computeHkdf("HMACSHA256", secret, salt, info, 32) - } - - fun verifyHmacSignature( - key: ByteArray, - signature: ByteArray, - message: ByteArray - ): Boolean { - return try { - val mac = Mac.getInstance("HmacSHA256") - mac.init(SecretKeySpec(key, "HmacSHA256")) - val computedSignature = mac.doFinal(message) - computedSignature.contentEquals(signature) - } catch (e: Exception) { - false - } - } } } diff --git a/library/src/main/java/org/xmtp/android/library/PrivatePreferences.kt b/library/src/main/java/org/xmtp/android/library/PrivatePreferences.kt index ec39b334c..18bd69c01 100644 --- a/library/src/main/java/org/xmtp/android/library/PrivatePreferences.kt +++ b/library/src/main/java/org/xmtp/android/library/PrivatePreferences.kt @@ -9,6 +9,8 @@ import uniffi.xmtpv3.FfiConsentCallback import uniffi.xmtpv3.FfiConsentEntityType import uniffi.xmtpv3.FfiConsentState import uniffi.xmtpv3.FfiDeviceSyncKind +import uniffi.xmtpv3.FfiPreferenceCallback +import uniffi.xmtpv3.FfiPreferenceUpdate import uniffi.xmtpv3.FfiSubscribeException import uniffi.xmtpv3.FfiXmtpClient @@ -60,6 +62,10 @@ enum class EntryType { } } +enum class PreferenceType { + HMAC_KEYS; +} + data class ConsentRecord( val value: String, val entryType: EntryType, @@ -100,6 +106,26 @@ data class PrivatePreferences( ffiClient.sendSyncRequest(FfiDeviceSyncKind.CONSENT) } + suspend fun streamPreferenceUpdates(): Flow = callbackFlow { + val preferenceCallback = object : FfiPreferenceCallback { + override fun onPreferenceUpdate(preference: List) { + preference.iterator().forEach { + when (it) { + is FfiPreferenceUpdate.Hmac -> trySend(PreferenceType.HMAC_KEYS) + } + } + } + + override fun onError(error: FfiSubscribeException) { + Log.e("XMTP preference update stream", error.message.toString()) + } + } + + val stream = ffiClient.conversations().streamPreferences(preferenceCallback) + + awaitClose { stream.end() } + } + suspend fun streamConsent(): Flow = callbackFlow { val consentCallback = object : FfiConsentCallback { override fun onConsentUpdate(consent: List) {