Skip to content

Commit

Permalink
Resume Conversations (#86)
Browse files Browse the repository at this point in the history
* add api client with grpc kotlin

* add a deterministic way to get conversations

* update crypto logic for deriving keys

* get all the tests passing

* fix linter issues

* write a test for confirmation

* confirm android ios and js all make the same keys

* remove JS topic test

* fix up lint
  • Loading branch information
nplasterer authored Jul 7, 2023
1 parent 5cba590 commit 84abe1d
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import org.xmtp.android.library.messages.SealedInvitationBuilder
import org.xmtp.android.library.messages.SealedInvitationHeaderV1
import org.xmtp.android.library.messages.SignedContentBuilder
import org.xmtp.android.library.messages.Topic
import org.xmtp.android.library.messages.createRandom
import org.xmtp.android.library.messages.createDeterministic
import org.xmtp.android.library.messages.getPublicKeyBundle
import org.xmtp.android.library.messages.header
import org.xmtp.android.library.messages.recoverWalletSignerPublicKey
Expand Down Expand Up @@ -330,7 +330,10 @@ class ConversationTest {
it.conversationId = "https://example.com/1"
}.build()
val invitationv1 =
InvitationV1.newBuilder().build().createRandom(context = invitationContext)
InvitationV1.newBuilder().build().createDeterministic(
sender = client.keys,
recipient = fakeContactClient.keys.getPublicKeyBundle(), context = invitationContext
)
val senderBundle = client.privateKeyBundleV1?.toV2()
assertEquals(
senderBundle?.identityKey?.publicKey?.recoverWalletSignerPublicKey()?.walletAddress,
Expand Down Expand Up @@ -428,7 +431,12 @@ class ConversationTest {
bobConversation.send(text = "hey alice 1")
bobConversation.send(text = "hey alice 2")
bobConversation.send(text = "hey alice 3")
val messages = aliceClient.conversations.listBatchMessages(listOf(aliceConversation.topic, bobConversation.topic))
val messages = aliceClient.conversations.listBatchMessages(
listOf(
aliceConversation.topic,
bobConversation.topic
)
)
assertEquals(3, messages.size)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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.Topic
import org.xmtp.android.library.messages.createRandom
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
Expand All @@ -28,8 +28,17 @@ class ConversationsTest {
val created = Date()
val newWallet = PrivateKeyBuilder()
val newClient = Client().create(account = newWallet, apiClient = fixtures.fakeApiClient)
val message = MessageV1Builder.buildEncode(sender = newClient.privateKeyBundleV1, recipient = fixtures.aliceClient.v1keys.toPublicKeyBundle(), message = TextCodec().encode(content = "hello").toByteArray(), timestamp = created)
val envelope = EnvelopeBuilder.buildFromTopic(topic = Topic.userIntro(client.address), timestamp = created, message = MessageBuilder.buildFromMessageV1(v1 = message).toByteArray())
val message = MessageV1Builder.buildEncode(
sender = newClient.privateKeyBundleV1,
recipient = fixtures.aliceClient.v1keys.toPublicKeyBundle(),
message = TextCodec().encode(content = "hello").toByteArray(),
timestamp = created
)
val envelope = EnvelopeBuilder.buildFromTopic(
topic = Topic.userIntro(client.address),
timestamp = created,
message = MessageBuilder.buildFromMessageV1(v1 = message).toByteArray()
)
val conversation = client.conversations.fromIntro(envelope = envelope)
assertEquals(conversation.peerAddress, newWallet.address)
assertEquals(conversation.createdAt.time, created.time)
Expand All @@ -42,10 +51,22 @@ class ConversationsTest {
val created = Date()
val newWallet = PrivateKeyBuilder()
val newClient = Client().create(account = newWallet, apiClient = fixtures.fakeApiClient)
val invitation = InvitationV1.newBuilder().build().createRandom(context = null)
val sealed = SealedInvitationBuilder.buildFromV1(sender = newClient.keys, recipient = client.keys.getPublicKeyBundle(), created = created, invitation = invitation)
val invitation = InvitationV1.newBuilder().build().createDeterministic(
sender = newClient.keys,
recipient = client.keys.getPublicKeyBundle()
)
val sealed = SealedInvitationBuilder.buildFromV1(
sender = newClient.keys,
recipient = client.keys.getPublicKeyBundle(),
created = created,
invitation = invitation
)
val peerAddress = fixtures.alice.walletAddress
val envelope = EnvelopeBuilder.buildFromTopic(topic = Topic.userInvite(peerAddress), timestamp = created, message = sealed.toByteArray())
val envelope = EnvelopeBuilder.buildFromTopic(
topic = Topic.userInvite(peerAddress),
timestamp = created,
message = sealed.toByteArray()
)
val conversation = client.conversations.fromInvite(envelope = envelope)
assertEquals(conversation.peerAddress, newWallet.address)
assertEquals(conversation.createdAt.time, created.time)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.protobuf.kotlin.toByteString
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.xmtp.android.library.messages.InvitationV1
import org.xmtp.android.library.messages.InvitationV1ContextBuilder
import org.xmtp.android.library.messages.PrivateKey
import org.xmtp.android.library.messages.PrivateKeyBuilder
import org.xmtp.android.library.messages.PrivateKeyBundleV1
import org.xmtp.android.library.messages.SealedInvitation
import org.xmtp.android.library.messages.SealedInvitationBuilder
import org.xmtp.android.library.messages.createRandom
import org.xmtp.android.library.messages.createDeterministic
import org.xmtp.android.library.messages.generate
import org.xmtp.android.library.messages.getInvitation
import org.xmtp.android.library.messages.getPublicKeyBundle
Expand Down Expand Up @@ -51,13 +53,17 @@ class InvitationTest {
val message = conversations[0].messages().firstOrNull()
Assert.assertEquals(message?.body, "hello")
}

@Test
fun testGenerateSealedInvitation() {
val aliceWallet = FakeWallet.generate()
val bobWallet = FakeWallet.generate()
val alice = PrivateKeyBundleV1.newBuilder().build().generate(wallet = aliceWallet)
val bob = PrivateKeyBundleV1.newBuilder().build().generate(wallet = bobWallet)
val invitation = InvitationV1.newBuilder().build().createRandom()
val invitation = InvitationV1.newBuilder().build().createDeterministic(
sender = alice.toV2(),
recipient = bob.toV2().getPublicKeyBundle()
)
val newInvitation = SealedInvitationBuilder.buildFromV1(
sender = alice.toV2(),
recipient = bob.toV2().getPublicKeyBundle(),
Expand Down Expand Up @@ -86,4 +92,28 @@ class InvitationTest {
invitation.aes256GcmHkdfSha256.keyMaterial
)
}

@Test
fun testDeterministicInvite() {
val aliceWallet = FakeWallet.generate()
val bobWallet = FakeWallet.generate()
val alice = PrivateKeyBundleV1.newBuilder().build().generate(wallet = aliceWallet)
val bob = PrivateKeyBundleV1.newBuilder().build().generate(wallet = bobWallet)
val makeInvite = { conversationId: String ->
InvitationV1.newBuilder().build().createDeterministic(
sender = alice.toV2(),
recipient = bob.toV2().getPublicKeyBundle(),
context = InvitationV1ContextBuilder.buildFromConversation(conversationId)
)
}
// Repeatedly making the same invite should use the same topic/keys
val original = makeInvite("example.com/conversation-foo")
for (i in 1..10) {
val invite = makeInvite("example.com/conversation-foo")
assertEquals(original.topic, invite.topic)
}
// But when the conversationId changes then it use a new topic/keys
val invite = makeInvite("example.com/conversation-bar")
assertNotEquals(original.topic, invite.topic)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import org.xmtp.android.library.messages.PrivateKeyBundleV1
import org.xmtp.android.library.messages.PublicKeyBundle
import org.xmtp.android.library.messages.SealedInvitationBuilder
import org.xmtp.android.library.messages.SignedPublicKeyBundleBuilder
import org.xmtp.android.library.messages.createRandom
import org.xmtp.android.library.messages.createDeterministic
import org.xmtp.android.library.messages.decrypt
import org.xmtp.android.library.messages.generate
import org.xmtp.android.library.messages.getPublicKeyBundle
Expand Down Expand Up @@ -74,7 +74,11 @@ class MessageTest {
conversationId = "https://example.com/1"
}.build()
val invitationv1 =
InvitationV1.newBuilder().build().createRandom(context = invitationContext)
InvitationV1.newBuilder().build().createDeterministic(
sender = alice.toV2(),
recipient = bob.toV2().getPublicKeyBundle(),
context = invitationContext
)
val sealedInvitation = SealedInvitationBuilder.buildFromV1(
sender = alice.toV2(),
recipient = bob.toV2().getPublicKeyBundle(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import org.xmtp.android.library.messages.SealedInvitation
import org.xmtp.android.library.messages.SealedInvitationBuilder
import org.xmtp.android.library.messages.SignedPublicKeyBundle
import org.xmtp.android.library.messages.Topic
import org.xmtp.android.library.messages.createRandom
import org.xmtp.android.library.messages.createDeterministic
import org.xmtp.android.library.messages.decrypt
import org.xmtp.android.library.messages.getInvitation
import org.xmtp.android.library.messages.header
Expand All @@ -27,7 +27,6 @@ import org.xmtp.android.library.messages.walletAddress
import org.xmtp.proto.keystore.api.v1.Keystore.TopicMap.TopicData
import org.xmtp.proto.message.contents.Contact
import org.xmtp.proto.message.contents.Invitation
import java.lang.Exception
import java.util.Date

data class Conversations(
Expand Down Expand Up @@ -132,7 +131,8 @@ data class Conversations(
}
// We don't have an existing conversation, make a v2 one
val recipient = contact.toSignedPublicKeyBundle()
val invitation = Invitation.InvitationV1.newBuilder().build().createRandom(context)
val invitation = Invitation.InvitationV1.newBuilder().build()
.createDeterministic(client.keys, recipient, context)
val sealedInvitation =
sendInvitation(recipient = recipient, invitation = invitation, created = Date())
val conversationV2 = ConversationV2.create(
Expand Down
25 changes: 25 additions & 0 deletions library/src/main/java/org/xmtp/android/library/Crypto.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ package org.xmtp.android.library
import android.util.Log
import com.google.crypto.tink.subtle.Hkdf
import com.google.protobuf.kotlin.toByteString
import org.bouncycastle.crypto.digests.SHA256Digest
import org.bouncycastle.crypto.generators.HKDFBytesGenerator
import org.bouncycastle.crypto.params.HKDFParameters
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,4 +78,25 @@ class Crypto {
}
}
}

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 {
val derivationParameters = HKDFParameters(secret, salt, info)
val digest = SHA256Digest()
val hkdfGenerator = HKDFBytesGenerator(digest)
hkdfGenerator.init(derivationParameters)
val hkdf = ByteArray(32)
hkdfGenerator.generateBytes(hkdf, 0, hkdf.size)
return hkdf
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package org.xmtp.android.library.messages

import com.google.crypto.tink.subtle.Base64.encodeToString
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.InvitationV1.Context
import java.security.SecureRandom
Expand All @@ -12,8 +14,8 @@ class InvitationV1Builder {
companion object {
fun buildFromTopic(
topic: Topic,
context: Invitation.InvitationV1.Context? = null,
aes256GcmHkdfSha256: Invitation.InvitationV1.Aes256gcmHkdfsha256
context: Context? = null,
aes256GcmHkdfSha256: Invitation.InvitationV1.Aes256gcmHkdfsha256,
): InvitationV1 {
return InvitationV1.newBuilder().apply {
this.topic = topic.description
Expand All @@ -26,18 +28,18 @@ class InvitationV1Builder {

fun buildContextFromId(
conversationId: String = "",
metadata: Map<String, String> = mapOf()
): Invitation.InvitationV1.Context {
return Invitation.InvitationV1.Context.newBuilder().apply {
metadata: Map<String, String> = mapOf(),
): Context {
return Context.newBuilder().apply {
this.conversationId = conversationId
this.putAllMetadata(metadata)
}.build()
}
}
}

fun InvitationV1.createRandom(context: Invitation.InvitationV1.Context? = null): InvitationV1 {
val inviteContext = context ?: Invitation.InvitationV1.Context.newBuilder().build()
fun InvitationV1.createRandom(context: Context? = null): InvitationV1 {
val inviteContext = context ?: Context.newBuilder().build()
val randomBytes = SecureRandom().generateSeed(32)
val randomString = encodeToString(randomBytes, 0).replace(Regex("=*$"), "")
.replace(Regex("[^A-Za-z0-9]"), "")
Expand All @@ -54,11 +56,50 @@ fun InvitationV1.createRandom(context: Invitation.InvitationV1.Context? = null):
)
}

fun InvitationV1.createDeterministic(
sender: PrivateKeyBundleV2,
recipient: SignedPublicKeyBundle,
context: Context? = null,
): InvitationV1 {
val inviteContext = context ?: Context.newBuilder().build()
val secret = sender.sharedSecret(
peer = recipient,
myPreKey = sender.preKeysList[0].publicKey,
isRecipient = false
)

val addresses = arrayOf(sender.toV1().walletAddress, recipient.walletAddress)
addresses.sort()

val msg = if (context != null && !context.conversationId.isNullOrBlank()) {
context.conversationId + addresses.joinToString(separator = ",")
} else {
addresses.joinToString(separator = ",")
}

val topicId = Crypto().calculateMac(secret = secret, message = msg.toByteArray()).toHex()
val topic = Topic.directMessageV2(topicId)
val keyMaterial = Crypto().deriveKey(
secret = secret,
salt = "__XMTP__INVITATION__SALT__XMTP__".toByteArray(),
info = listOf("0").plus(addresses).joinToString(separator = "|").toByteArray()
)
val aes256GcmHkdfSha256 = Invitation.InvitationV1.Aes256gcmHkdfsha256.newBuilder().apply {
this.keyMaterial = keyMaterial.toByteString()
}.build()

return InvitationV1Builder.buildFromTopic(
topic = topic,
context = inviteContext,
aes256GcmHkdfSha256 = aes256GcmHkdfSha256
)
}

class InvitationV1ContextBuilder {
companion object {
fun buildFromConversation(
conversationId: String = "",
metadata: Map<String, String> = mapOf()
metadata: Map<String, String> = mapOf(),
): Context {
return Context.newBuilder().also {
it.conversationId = conversationId
Expand Down

0 comments on commit 84abe1d

Please sign in to comment.