Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Consent Proof #231

Merged
merged 7 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this throw an error instead like coming soon?

}
}

// 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
Loading