Skip to content

Commit

Permalink
feat: Consent Proof (#231)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
alexrisch and Alex Risch authored Apr 24, 2024
1 parent 0654665 commit 26843d3
Show file tree
Hide file tree
Showing 8 changed files with 170 additions and 3 deletions.
2 changes: 1 addition & 1 deletion library/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
}
}
10 changes: 10 additions & 0 deletions library/src/main/java/org/xmtp/android/library/Conversation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -52,6 +53,7 @@ data class ConversationV2(
client = client,
createdAtNs = header.createdNs,
header = header,
consentProof = if (invitation.hasConsentProof()) invitation.consentProof else null
)
}
}
Expand Down
26 changes: 24 additions & 2 deletions library/src/main/java/org/xmtp/android/library/Conversations.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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
),
)
}
Expand Down
29 changes: 29 additions & 0 deletions library/src/main/java/org/xmtp/android/library/KeyUtil.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
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 {
fun getPublicKey(privateKey: ByteArray): ByteArray {
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)
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -95,6 +101,7 @@ fun InvitationV1.createDeterministic(
topic = topic,
context = inviteContext,
aes256GcmHkdfSha256 = aes256GcmHkdfSha256,
consentProof = consentProof
)
}

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

0 comments on commit 26843d3

Please sign in to comment.