Skip to content

Commit

Permalink
V3 HMAC key support for self push notifications (#356)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
nplasterer authored Jan 7, 2025
1 parent bcc15c4 commit d759cb2
Show file tree
Hide file tree
Showing 11 changed files with 132 additions and 48 deletions.
17 changes: 14 additions & 3 deletions example/src/main/java/org/xmtp/android/example/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,20 @@ class MainViewModel : ViewModel() {
val listItems = mutableListOf<MainListItem>()
try {
val conversations = ClientManager.client.conversations.list()
val subscriptions: MutableList<Service.Subscription> = 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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {

Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class MessageViewHolder(
if (item.message.content<Any>() 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<Any>() is GroupUpdated) {
val changes = item.message.content() as? GroupUpdated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Any>() is String) {
val body: String = if (decodedMessage?.content<Any>() is String) {
decodedMessage.body
} else if (decodedMessage.content<Any>() is GroupUpdated) {
} else if (decodedMessage?.content<Any>() is GroupUpdated) {
val changes = decodedMessage.content() as? GroupUpdated
"Membership Changed ${
changes?.addedInboxesList?.mapNotNull { it.inboxId }.toString()
Expand Down
2 changes: 1 addition & 1 deletion library/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -187,4 +188,24 @@ class ConversationsTest {
assertEquals(2, allMessages.size)
job.cancel()
}

@Test
fun testReturnsAllHMACKeys() {
val conversations = mutableListOf<Conversation>()
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))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ class HistorySyncTest {

runBlocking {
alixClient2.preferences.syncConsent()
Thread.sleep(2000)
alixClient.conversations.syncAllConversations()
Thread.sleep(2000)
alixClient2.conversations.syncAllConversations()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
}
}
5 changes: 0 additions & 5 deletions library/src/main/java/org/xmtp/android/library/Client.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -386,10 +385,6 @@ class Client() {
ffiClient.dbReconnect()
}

suspend fun requestMessageHistorySync() {
ffiClient.sendSyncRequest(FfiDeviceSyncKind.MESSAGES)
}

suspend fun inboxStatesForInboxIds(
refreshFromNetwork: Boolean,
inboxIds: List<String>,
Expand Down
22 changes: 22 additions & 0 deletions library/src/main/java/org/xmtp/android/library/Conversations.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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
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
Expand Down Expand Up @@ -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()
}
}
31 changes: 0 additions & 31 deletions library/src/main/java/org/xmtp/android/library/Crypto.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -60,6 +62,10 @@ enum class EntryType {
}
}

enum class PreferenceType {
HMAC_KEYS;
}

data class ConsentRecord(
val value: String,
val entryType: EntryType,
Expand Down Expand Up @@ -100,6 +106,26 @@ data class PrivatePreferences(
ffiClient.sendSyncRequest(FfiDeviceSyncKind.CONSENT)
}

suspend fun streamPreferenceUpdates(): Flow<PreferenceType> = callbackFlow {
val preferenceCallback = object : FfiPreferenceCallback {
override fun onPreferenceUpdate(preference: List<FfiPreferenceUpdate>) {
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<ConsentRecord> = callbackFlow {
val consentCallback = object : FfiConsentCallback {
override fun onConsentUpdate(consent: List<FfiConsent>) {
Expand Down

0 comments on commit d759cb2

Please sign in to comment.