From 26843d34b09a878e821c973761867a71224ecba2 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Wed, 24 Apr 2024 16:09:59 -0600 Subject: [PATCH] feat: Consent Proof (#231) * feat: Consent Proof Added handling for consent proof in new conversation Updated protos * Validating consent proof Added validation for consent proof Added additional type updates Added key utils Added new signing message * Clean up Moved functions to key util Fixed lint * Fixed lint --------- Co-authored-by: Alex Risch --- library/build.gradle | 2 +- .../xmtp/android/library/ConversationsTest.kt | 94 +++++++++++++++++++ .../org/xmtp/android/library/Conversation.kt | 10 ++ .../xmtp/android/library/ConversationV2.kt | 2 + .../org/xmtp/android/library/Conversations.kt | 26 ++++- .../java/org/xmtp/android/library/KeyUtil.kt | 29 ++++++ .../android/library/messages/InvitationV1.kt | 7 ++ .../android/library/messages/Signature.kt | 3 + 8 files changed, 170 insertions(+), 3 deletions(-) diff --git a/library/build.gradle b/library/build.gradle index 28ac00a9b..5a9c73fd6 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -86,7 +86,7 @@ dependencies { implementation 'org.web3j:crypto:5.0.0' implementation "net.java.dev.jna:jna:5.13.0@aar" api 'com.google.protobuf:protobuf-kotlin-lite:3.22.3' - api 'org.xmtp:proto-kotlin:3.47.0' + api 'org.xmtp:proto-kotlin:3.51.0' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'app.cash.turbine:turbine:0.12.1' 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 0d65539f2..2a19ffa96 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,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.xmtp.android.library.codecs.TextCodec @@ -15,11 +18,15 @@ import org.xmtp.android.library.messages.MessageBuilder import org.xmtp.android.library.messages.MessageV1Builder import org.xmtp.android.library.messages.PrivateKeyBuilder import org.xmtp.android.library.messages.SealedInvitationBuilder +import org.xmtp.android.library.messages.Signature import org.xmtp.android.library.messages.Topic +import org.xmtp.android.library.messages.consentProofText import org.xmtp.android.library.messages.createDeterministic import org.xmtp.android.library.messages.getPublicKeyBundle import org.xmtp.android.library.messages.toPublicKeyBundle import org.xmtp.android.library.messages.walletAddress +import org.xmtp.proto.message.contents.Invitation +import org.xmtp.proto.message.contents.Invitation.ConsentProofPayload import java.lang.Thread.sleep import java.util.Date @@ -172,4 +179,91 @@ class ConversationsTest { sleep(2000) assertEquals(allMessages.size, 2) } + + @Test + fun testSendConversationWithConsentSignature() { + val bo = PrivateKeyBuilder() + val alix = PrivateKeyBuilder() + val clientOptions = + ClientOptions(api = ClientOptions.Api(env = XMTPEnvironment.LOCAL, isSecure = false)) + val boClient = Client().create(bo, clientOptions) + val alixClient = Client().create(alix, clientOptions) + val timestamp = System.currentTimeMillis() + val signatureText = Signature.newBuilder().build().consentProofText(boClient.address, timestamp) + val digest = signatureText.toByteArray() + val signature = runBlocking { alix.sign(Util.keccak256(digest)) } + val hex = signature.toByteArray().toHex() + val consentProofPayload = ConsentProofPayload.newBuilder().also { + it.signature = hex + it.timestamp = timestamp + it.payloadVersion = Invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1 + }.build() + val boConversation = + runBlocking { boClient.conversations.newConversation(alixClient.address, null, consentProofPayload) } + val alixConversations = runBlocking { + alixClient.conversations.list() + } + val alixConversation = alixConversations.find { + it.topic == boConversation.topic + } + assertNotNull(alixConversation) + val isAllowed = runBlocking { alixClient.contacts.isAllowed(boClient.address) } + assertTrue(isAllowed) + } + + @Test + fun testNetworkConsentOverConsentProof() { + val bo = PrivateKeyBuilder() + val alix = PrivateKeyBuilder() + val clientOptions = + ClientOptions(api = ClientOptions.Api(env = XMTPEnvironment.LOCAL, isSecure = false)) + val boClient = Client().create(bo, clientOptions) + val alixClient = Client().create(alix, clientOptions) + val timestamp = System.currentTimeMillis() + val signatureText = Signature.newBuilder().build().consentProofText(boClient.address, timestamp) + val digest = signatureText.toByteArray() + + val signature = runBlocking { alix.sign(Util.keccak256(digest)) } + val hex = signature.toByteArray().toHex() + val consentProofPayload = ConsentProofPayload.newBuilder().also { + it.signature = hex + it.timestamp = timestamp + it.payloadVersion = Invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1 + }.build() + runBlocking { alixClient.contacts.deny(listOf(boClient.address)) } + val boConversation = runBlocking { boClient.conversations.newConversation(alixClient.address, null, consentProofPayload) } + val alixConversations = runBlocking { alixClient.conversations.list() } + val alixConversation = alixConversations.find { it.topic == boConversation.topic } + assertNotNull(alixConversation) + val isDenied = runBlocking { alixClient.contacts.isDenied(boClient.address) } + assertTrue(isDenied) + } + + @Test + fun testConsentProofInvalidSignature() { + val bo = PrivateKeyBuilder() + val alix = PrivateKeyBuilder() + val clientOptions = + ClientOptions(api = ClientOptions.Api(env = XMTPEnvironment.LOCAL, isSecure = false)) + val boClient = Client().create(bo, clientOptions) + val alixClient = Client().create(alix, clientOptions) + val timestamp = System.currentTimeMillis() + val signatureText = Signature.newBuilder().build().consentProofText(boClient.address, timestamp + 1) + val digest = signatureText.toByteArray() + + val signature = runBlocking { alix.sign(Util.keccak256(digest)) } + val hex = signature.toByteArray().toHex() + val consentProofPayload = ConsentProofPayload.newBuilder().also { + it.signature = hex + it.timestamp = timestamp + it.payloadVersion = Invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1 + }.build() + + val boConversation = runBlocking { boClient.conversations.newConversation(alixClient.address, null, consentProofPayload) } + val alixConversations = runBlocking { alixClient.conversations.list() } + val alixConversation = alixConversations.find { it.topic == boConversation.topic } + assertNotNull(alixConversation) + val isAllowed = runBlocking { alixClient.contacts.isAllowed(boClient.address) } + assertFalse(isAllowed) + } } diff --git a/library/src/main/java/org/xmtp/android/library/Conversation.kt b/library/src/main/java/org/xmtp/android/library/Conversation.kt index 7230c0b46..d41d9977f 100644 --- a/library/src/main/java/org/xmtp/android/library/Conversation.kt +++ b/library/src/main/java/org/xmtp/android/library/Conversation.kt @@ -11,6 +11,7 @@ import org.xmtp.android.library.messages.PagingInfoSortDirection import org.xmtp.proto.keystore.api.v1.Keystore.TopicMap.TopicData import org.xmtp.proto.message.api.v1.MessageApiOuterClass import org.xmtp.proto.message.contents.Invitation +import org.xmtp.proto.message.contents.Invitation.ConsentProofPayload import org.xmtp.proto.message.contents.Invitation.InvitationV1.Aes256gcmHkdfsha256 import java.util.Date @@ -288,6 +289,15 @@ sealed class Conversation { } } + val consentProof: ConsentProofPayload? + get() { + return when (this) { + is V1 -> return null + is V2 -> conversationV2.consentProof + is Group -> return null + } + } + // Get the client according to the version val client: Client get() { diff --git a/library/src/main/java/org/xmtp/android/library/ConversationV2.kt b/library/src/main/java/org/xmtp/android/library/ConversationV2.kt index 0d5c57a3a..e11fda4f0 100644 --- a/library/src/main/java/org/xmtp/android/library/ConversationV2.kt +++ b/library/src/main/java/org/xmtp/android/library/ConversationV2.kt @@ -27,6 +27,7 @@ data class ConversationV2( val topic: String, val keyMaterial: ByteArray, val context: Invitation.InvitationV1.Context, + var consentProof: Invitation.ConsentProofPayload? = null, val peerAddress: String, val client: Client, val createdAtNs: Long? = null, @@ -52,6 +53,7 @@ data class ConversationV2( client = client, createdAtNs = header.createdNs, header = header, + consentProof = if (invitation.hasConsentProof()) invitation.consentProof else null ) } } 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 d59d4ed3b..14d024296 100644 --- a/library/src/main/java/org/xmtp/android/library/Conversations.kt +++ b/library/src/main/java/org/xmtp/android/library/Conversations.kt @@ -150,6 +150,20 @@ data class Conversations( } ?: emptyList() } + private suspend fun handleConsentProof(consentProof: Invitation.ConsentProofPayload, peerAddress: String) { + val signature = consentProof.signature + val timestamp = consentProof.timestamp + + if (!KeyUtil.validateConsentSignature(signature, client.address, peerAddress, timestamp)) { + return + } + val contacts = client.contacts + contacts.refreshConsentList() + if (contacts.consentList.state(peerAddress) == ConsentState.UNKNOWN) { + contacts.allow(listOf(peerAddress)) + } + } + /** * This creates a new [Conversation] using a specified address * @param peerAddress The address of the client that you want to start a new conversation @@ -160,6 +174,7 @@ data class Conversations( suspend fun newConversation( peerAddress: String, context: Invitation.InvitationV1.Context? = null, + consentProof: Invitation.ConsentProofPayload? = null, ): Conversation { if (peerAddress.lowercase() == client.address.lowercase()) { throw XMTPException("Recipient is sender") @@ -216,6 +231,7 @@ data class Conversations( peerAddress = peerAddress, client = client, header = sealedInvitation.v1.header, + consentProof = if (invite.hasConsentProof()) invite.consentProof else null ), ) conversationsByTopic[conversation.topic] = conversation @@ -225,7 +241,7 @@ data class Conversations( // We don't have an existing conversation, make a v2 one val recipient = contact.toSignedPublicKeyBundle() val invitation = Invitation.InvitationV1.newBuilder().build() - .createDeterministic(client.keys, recipient, context) + .createDeterministic(client.keys, recipient, context, consentProof) val sealedInvitation = sendInvitation(recipient = recipient, invitation = invitation, created = Date()) val conversationV2 = ConversationV2.create( @@ -262,7 +278,12 @@ data class Conversations( val invitations = listInvitations(pagination = pagination) for (sealedInvitation in invitations) { try { - newConversations.add(Conversation.V2(conversation(sealedInvitation))) + val newConversation = Conversation.V2(conversation(sealedInvitation)) + newConversations.add(newConversation) + val consentProof = newConversation.consentProof + if (consentProof != null) { + handleConsentProof(consentProof, newConversation.peerAddress) + } } catch (e: Exception) { Log.d(TAG, e.message.toString()) } @@ -301,6 +322,7 @@ data class Conversations( client = client, createdAtNs = data.createdNs, header = Invitation.SealedInvitationHeaderV1.getDefaultInstance(), + consentProof = if (data.invitation.hasConsentProof()) data.invitation.consentProof else null ), ) } diff --git a/library/src/main/java/org/xmtp/android/library/KeyUtil.kt b/library/src/main/java/org/xmtp/android/library/KeyUtil.kt index 2c2b3c9de..74659d01b 100644 --- a/library/src/main/java/org/xmtp/android/library/KeyUtil.kt +++ b/library/src/main/java/org/xmtp/android/library/KeyUtil.kt @@ -1,7 +1,14 @@ package org.xmtp.android.library +import com.google.protobuf.kotlin.toByteString +import org.web3j.crypto.ECDSASignature +import org.web3j.crypto.Keys import org.web3j.crypto.Sign import org.web3j.crypto.Sign.SignatureData +import org.web3j.utils.Numeric +import org.xmtp.android.library.messages.Signature +import org.xmtp.android.library.messages.consentProofText +import org.xmtp.android.library.messages.rawData import java.math.BigInteger object KeyUtil { @@ -9,6 +16,19 @@ object KeyUtil { return Sign.publicKeyFromPrivate(BigInteger(1, privateKey)).toByteArray() } + private fun recoverPublicKeyKeccak256(signature: ByteArray, digest: ByteArray): BigInteger? { + val signatureData = getSignatureData(signature) + return Sign.recoverFromSignature( + BigInteger(1, signatureData.v).toInt(), + ECDSASignature(BigInteger(1, signatureData.r), BigInteger(1, signatureData.s)), + digest, + ) + } + + private fun publicKeyToAddress(publicKey: BigInteger): String { + return Keys.toChecksumAddress(Keys.getAddress(publicKey)) + } + fun addUncompressedByte(publicKey: ByteArray): ByteArray { return if (publicKey.size >= 65) { val newPublicKey = ByteArray(64) @@ -60,4 +80,13 @@ object KeyUtil { } return mergedArray } + + fun validateConsentSignature(signature: String, clientAddress: String, peerAddress: String, timestamp: Long): Boolean { + val messageData = Signature.newBuilder().build().consentProofText(peerAddress, timestamp).toByteArray() + val signatureData = Numeric.hexStringToByteArray(signature) + val sig = Signature.parseFrom(signatureData) + val recoveredPublicKey = recoverPublicKeyKeccak256(sig.rawData.toByteString().toByteArray(), Util.keccak256(messageData)) + ?: return false + return clientAddress == publicKeyToAddress(recoveredPublicKey) + } } diff --git a/library/src/main/java/org/xmtp/android/library/messages/InvitationV1.kt b/library/src/main/java/org/xmtp/android/library/messages/InvitationV1.kt index f0cd5e57c..f89d419c6 100644 --- a/library/src/main/java/org/xmtp/android/library/messages/InvitationV1.kt +++ b/library/src/main/java/org/xmtp/android/library/messages/InvitationV1.kt @@ -5,6 +5,7 @@ import com.google.protobuf.kotlin.toByteString import org.xmtp.android.library.Crypto import org.xmtp.android.library.toHex import org.xmtp.proto.message.contents.Invitation +import org.xmtp.proto.message.contents.Invitation.ConsentProofPayload import org.xmtp.proto.message.contents.Invitation.InvitationV1.Context import java.security.SecureRandom @@ -16,12 +17,16 @@ class InvitationV1Builder { topic: Topic, context: Context? = null, aes256GcmHkdfSha256: Invitation.InvitationV1.Aes256gcmHkdfsha256, + consentProof: ConsentProofPayload? = null ): InvitationV1 { return InvitationV1.newBuilder().apply { this.topic = topic.description if (context != null) { this.context = context } + if (consentProof != null) { + this.consentProof = consentProof + } this.aes256GcmHkdfSha256 = aes256GcmHkdfSha256 }.build() } @@ -60,6 +65,7 @@ fun InvitationV1.createDeterministic( sender: PrivateKeyBundleV2, recipient: SignedPublicKeyBundle, context: Context? = null, + consentProof: ConsentProofPayload? = null ): InvitationV1 { val myAddress = sender.toV1().walletAddress val theirAddress = recipient.walletAddress @@ -95,6 +101,7 @@ fun InvitationV1.createDeterministic( topic = topic, context = inviteContext, aes256GcmHkdfSha256 = aes256GcmHkdfSha256, + consentProof = consentProof ) } diff --git a/library/src/main/java/org/xmtp/android/library/messages/Signature.kt b/library/src/main/java/org/xmtp/android/library/messages/Signature.kt index fabc7558a..faea87a8c 100644 --- a/library/src/main/java/org/xmtp/android/library/messages/Signature.kt +++ b/library/src/main/java/org/xmtp/android/library/messages/Signature.kt @@ -47,6 +47,9 @@ fun Signature.createIdentityText(key: ByteArray): String = fun Signature.enableIdentityText(key: ByteArray): String = ("XMTP : Enable Identity\n" + "${key.toHex()}\n" + "\n" + "For more info: https://xmtp.org/signatures/") +fun Signature.consentProofText(peerAddress: String, timestamp: Long): String = + ("XMTP : Grant inbox consent to sender\n" + "\n" + "Current Time: ${timestamp}\n" + "From Address: ${peerAddress}\n" + "\n" + "For more info: https://xmtp.org/signatures/") + val Signature.rawData: ByteArray get() = if (hasEcdsaCompact()) { ecdsaCompact.bytes.toByteArray() + ecdsaCompact.recovery.toByte()