Skip to content

Commit 84abe1d

Browse files
authored
Resume Conversations (#86)
* 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
1 parent 5cba590 commit 84abe1d

File tree

7 files changed

+153
-24
lines changed

7 files changed

+153
-24
lines changed

library/src/androidTest/java/org/xmtp/android/library/ConversationTest.kt

+11-3
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import org.xmtp.android.library.messages.SealedInvitationBuilder
2626
import org.xmtp.android.library.messages.SealedInvitationHeaderV1
2727
import org.xmtp.android.library.messages.SignedContentBuilder
2828
import org.xmtp.android.library.messages.Topic
29-
import org.xmtp.android.library.messages.createRandom
29+
import org.xmtp.android.library.messages.createDeterministic
3030
import org.xmtp.android.library.messages.getPublicKeyBundle
3131
import org.xmtp.android.library.messages.header
3232
import org.xmtp.android.library.messages.recoverWalletSignerPublicKey
@@ -330,7 +330,10 @@ class ConversationTest {
330330
it.conversationId = "https://example.com/1"
331331
}.build()
332332
val invitationv1 =
333-
InvitationV1.newBuilder().build().createRandom(context = invitationContext)
333+
InvitationV1.newBuilder().build().createDeterministic(
334+
sender = client.keys,
335+
recipient = fakeContactClient.keys.getPublicKeyBundle(), context = invitationContext
336+
)
334337
val senderBundle = client.privateKeyBundleV1?.toV2()
335338
assertEquals(
336339
senderBundle?.identityKey?.publicKey?.recoverWalletSignerPublicKey()?.walletAddress,
@@ -428,7 +431,12 @@ class ConversationTest {
428431
bobConversation.send(text = "hey alice 1")
429432
bobConversation.send(text = "hey alice 2")
430433
bobConversation.send(text = "hey alice 3")
431-
val messages = aliceClient.conversations.listBatchMessages(listOf(aliceConversation.topic, bobConversation.topic))
434+
val messages = aliceClient.conversations.listBatchMessages(
435+
listOf(
436+
aliceConversation.topic,
437+
bobConversation.topic
438+
)
439+
)
432440
assertEquals(3, messages.size)
433441
}
434442

library/src/androidTest/java/org/xmtp/android/library/ConversationsTest.kt

+27-6
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import org.xmtp.android.library.messages.MessageV1Builder
1212
import org.xmtp.android.library.messages.PrivateKeyBuilder
1313
import org.xmtp.android.library.messages.SealedInvitationBuilder
1414
import org.xmtp.android.library.messages.Topic
15-
import org.xmtp.android.library.messages.createRandom
15+
import org.xmtp.android.library.messages.createDeterministic
1616
import org.xmtp.android.library.messages.getPublicKeyBundle
1717
import org.xmtp.android.library.messages.toPublicKeyBundle
1818
import org.xmtp.android.library.messages.walletAddress
@@ -28,8 +28,17 @@ class ConversationsTest {
2828
val created = Date()
2929
val newWallet = PrivateKeyBuilder()
3030
val newClient = Client().create(account = newWallet, apiClient = fixtures.fakeApiClient)
31-
val message = MessageV1Builder.buildEncode(sender = newClient.privateKeyBundleV1, recipient = fixtures.aliceClient.v1keys.toPublicKeyBundle(), message = TextCodec().encode(content = "hello").toByteArray(), timestamp = created)
32-
val envelope = EnvelopeBuilder.buildFromTopic(topic = Topic.userIntro(client.address), timestamp = created, message = MessageBuilder.buildFromMessageV1(v1 = message).toByteArray())
31+
val message = MessageV1Builder.buildEncode(
32+
sender = newClient.privateKeyBundleV1,
33+
recipient = fixtures.aliceClient.v1keys.toPublicKeyBundle(),
34+
message = TextCodec().encode(content = "hello").toByteArray(),
35+
timestamp = created
36+
)
37+
val envelope = EnvelopeBuilder.buildFromTopic(
38+
topic = Topic.userIntro(client.address),
39+
timestamp = created,
40+
message = MessageBuilder.buildFromMessageV1(v1 = message).toByteArray()
41+
)
3342
val conversation = client.conversations.fromIntro(envelope = envelope)
3443
assertEquals(conversation.peerAddress, newWallet.address)
3544
assertEquals(conversation.createdAt.time, created.time)
@@ -42,10 +51,22 @@ class ConversationsTest {
4251
val created = Date()
4352
val newWallet = PrivateKeyBuilder()
4453
val newClient = Client().create(account = newWallet, apiClient = fixtures.fakeApiClient)
45-
val invitation = InvitationV1.newBuilder().build().createRandom(context = null)
46-
val sealed = SealedInvitationBuilder.buildFromV1(sender = newClient.keys, recipient = client.keys.getPublicKeyBundle(), created = created, invitation = invitation)
54+
val invitation = InvitationV1.newBuilder().build().createDeterministic(
55+
sender = newClient.keys,
56+
recipient = client.keys.getPublicKeyBundle()
57+
)
58+
val sealed = SealedInvitationBuilder.buildFromV1(
59+
sender = newClient.keys,
60+
recipient = client.keys.getPublicKeyBundle(),
61+
created = created,
62+
invitation = invitation
63+
)
4764
val peerAddress = fixtures.alice.walletAddress
48-
val envelope = EnvelopeBuilder.buildFromTopic(topic = Topic.userInvite(peerAddress), timestamp = created, message = sealed.toByteArray())
65+
val envelope = EnvelopeBuilder.buildFromTopic(
66+
topic = Topic.userInvite(peerAddress),
67+
timestamp = created,
68+
message = sealed.toByteArray()
69+
)
4970
val conversation = client.conversations.fromInvite(envelope = envelope)
5071
assertEquals(conversation.peerAddress, newWallet.address)
5172
assertEquals(conversation.createdAt.time, created.time)

library/src/androidTest/java/org/xmtp/android/library/InvitationTest.kt

+32-2
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
44
import com.google.protobuf.kotlin.toByteString
55
import org.junit.Assert
66
import org.junit.Assert.assertEquals
7+
import org.junit.Assert.assertNotEquals
78
import org.junit.Test
89
import org.junit.runner.RunWith
910
import org.xmtp.android.library.messages.InvitationV1
11+
import org.xmtp.android.library.messages.InvitationV1ContextBuilder
1012
import org.xmtp.android.library.messages.PrivateKey
1113
import org.xmtp.android.library.messages.PrivateKeyBuilder
1214
import org.xmtp.android.library.messages.PrivateKeyBundleV1
1315
import org.xmtp.android.library.messages.SealedInvitation
1416
import org.xmtp.android.library.messages.SealedInvitationBuilder
15-
import org.xmtp.android.library.messages.createRandom
17+
import org.xmtp.android.library.messages.createDeterministic
1618
import org.xmtp.android.library.messages.generate
1719
import org.xmtp.android.library.messages.getInvitation
1820
import org.xmtp.android.library.messages.getPublicKeyBundle
@@ -51,13 +53,17 @@ class InvitationTest {
5153
val message = conversations[0].messages().firstOrNull()
5254
Assert.assertEquals(message?.body, "hello")
5355
}
56+
5457
@Test
5558
fun testGenerateSealedInvitation() {
5659
val aliceWallet = FakeWallet.generate()
5760
val bobWallet = FakeWallet.generate()
5861
val alice = PrivateKeyBundleV1.newBuilder().build().generate(wallet = aliceWallet)
5962
val bob = PrivateKeyBundleV1.newBuilder().build().generate(wallet = bobWallet)
60-
val invitation = InvitationV1.newBuilder().build().createRandom()
63+
val invitation = InvitationV1.newBuilder().build().createDeterministic(
64+
sender = alice.toV2(),
65+
recipient = bob.toV2().getPublicKeyBundle()
66+
)
6167
val newInvitation = SealedInvitationBuilder.buildFromV1(
6268
sender = alice.toV2(),
6369
recipient = bob.toV2().getPublicKeyBundle(),
@@ -86,4 +92,28 @@ class InvitationTest {
8692
invitation.aes256GcmHkdfSha256.keyMaterial
8793
)
8894
}
95+
96+
@Test
97+
fun testDeterministicInvite() {
98+
val aliceWallet = FakeWallet.generate()
99+
val bobWallet = FakeWallet.generate()
100+
val alice = PrivateKeyBundleV1.newBuilder().build().generate(wallet = aliceWallet)
101+
val bob = PrivateKeyBundleV1.newBuilder().build().generate(wallet = bobWallet)
102+
val makeInvite = { conversationId: String ->
103+
InvitationV1.newBuilder().build().createDeterministic(
104+
sender = alice.toV2(),
105+
recipient = bob.toV2().getPublicKeyBundle(),
106+
context = InvitationV1ContextBuilder.buildFromConversation(conversationId)
107+
)
108+
}
109+
// Repeatedly making the same invite should use the same topic/keys
110+
val original = makeInvite("example.com/conversation-foo")
111+
for (i in 1..10) {
112+
val invite = makeInvite("example.com/conversation-foo")
113+
assertEquals(original.topic, invite.topic)
114+
}
115+
// But when the conversationId changes then it use a new topic/keys
116+
val invite = makeInvite("example.com/conversation-bar")
117+
assertNotEquals(original.topic, invite.topic)
118+
}
89119
}

library/src/androidTest/java/org/xmtp/android/library/MessageTest.kt

+6-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import org.xmtp.android.library.messages.PrivateKeyBundleV1
2121
import org.xmtp.android.library.messages.PublicKeyBundle
2222
import org.xmtp.android.library.messages.SealedInvitationBuilder
2323
import org.xmtp.android.library.messages.SignedPublicKeyBundleBuilder
24-
import org.xmtp.android.library.messages.createRandom
24+
import org.xmtp.android.library.messages.createDeterministic
2525
import org.xmtp.android.library.messages.decrypt
2626
import org.xmtp.android.library.messages.generate
2727
import org.xmtp.android.library.messages.getPublicKeyBundle
@@ -74,7 +74,11 @@ class MessageTest {
7474
conversationId = "https://example.com/1"
7575
}.build()
7676
val invitationv1 =
77-
InvitationV1.newBuilder().build().createRandom(context = invitationContext)
77+
InvitationV1.newBuilder().build().createDeterministic(
78+
sender = alice.toV2(),
79+
recipient = bob.toV2().getPublicKeyBundle(),
80+
context = invitationContext
81+
)
7882
val sealedInvitation = SealedInvitationBuilder.buildFromV1(
7983
sender = alice.toV2(),
8084
recipient = bob.toV2().getPublicKeyBundle(),

library/src/main/java/org/xmtp/android/library/Conversations.kt

+3-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import org.xmtp.android.library.messages.SealedInvitation
1414
import org.xmtp.android.library.messages.SealedInvitationBuilder
1515
import org.xmtp.android.library.messages.SignedPublicKeyBundle
1616
import org.xmtp.android.library.messages.Topic
17-
import org.xmtp.android.library.messages.createRandom
17+
import org.xmtp.android.library.messages.createDeterministic
1818
import org.xmtp.android.library.messages.decrypt
1919
import org.xmtp.android.library.messages.getInvitation
2020
import org.xmtp.android.library.messages.header
@@ -27,7 +27,6 @@ import org.xmtp.android.library.messages.walletAddress
2727
import org.xmtp.proto.keystore.api.v1.Keystore.TopicMap.TopicData
2828
import org.xmtp.proto.message.contents.Contact
2929
import org.xmtp.proto.message.contents.Invitation
30-
import java.lang.Exception
3130
import java.util.Date
3231

3332
data class Conversations(
@@ -132,7 +131,8 @@ data class Conversations(
132131
}
133132
// We don't have an existing conversation, make a v2 one
134133
val recipient = contact.toSignedPublicKeyBundle()
135-
val invitation = Invitation.InvitationV1.newBuilder().build().createRandom(context)
134+
val invitation = Invitation.InvitationV1.newBuilder().build()
135+
.createDeterministic(client.keys, recipient, context)
136136
val sealedInvitation =
137137
sendInvitation(recipient = recipient, invitation = invitation, created = Date())
138138
val conversationV2 = ConversationV2.create(

library/src/main/java/org/xmtp/android/library/Crypto.kt

+25
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ package org.xmtp.android.library
33
import android.util.Log
44
import com.google.crypto.tink.subtle.Hkdf
55
import com.google.protobuf.kotlin.toByteString
6+
import org.bouncycastle.crypto.digests.SHA256Digest
7+
import org.bouncycastle.crypto.generators.HKDFBytesGenerator
8+
import org.bouncycastle.crypto.params.HKDFParameters
69
import org.xmtp.proto.message.contents.CiphertextOuterClass
710
import java.security.GeneralSecurityException
811
import java.security.SecureRandom
912
import javax.crypto.Cipher
13+
import javax.crypto.Mac
1014
import javax.crypto.spec.GCMParameterSpec
1115
import javax.crypto.spec.SecretKeySpec
1216

@@ -74,4 +78,25 @@ class Crypto {
7478
}
7579
}
7680
}
81+
82+
fun calculateMac(secret: ByteArray, message: ByteArray): ByteArray {
83+
val sha256HMAC: Mac = Mac.getInstance("HmacSHA256")
84+
val secretKey = SecretKeySpec(secret, "HmacSHA256")
85+
sha256HMAC.init(secretKey)
86+
return sha256HMAC.doFinal(message)
87+
}
88+
89+
fun deriveKey(
90+
secret: ByteArray,
91+
salt: ByteArray,
92+
info: ByteArray,
93+
): ByteArray {
94+
val derivationParameters = HKDFParameters(secret, salt, info)
95+
val digest = SHA256Digest()
96+
val hkdfGenerator = HKDFBytesGenerator(digest)
97+
hkdfGenerator.init(derivationParameters)
98+
val hkdf = ByteArray(32)
99+
hkdfGenerator.generateBytes(hkdf, 0, hkdf.size)
100+
return hkdf
101+
}
77102
}

library/src/main/java/org/xmtp/android/library/messages/InvitationV1.kt

+49-8
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package org.xmtp.android.library.messages
22

33
import com.google.crypto.tink.subtle.Base64.encodeToString
44
import com.google.protobuf.kotlin.toByteString
5+
import org.xmtp.android.library.Crypto
6+
import org.xmtp.android.library.toHex
57
import org.xmtp.proto.message.contents.Invitation
68
import org.xmtp.proto.message.contents.Invitation.InvitationV1.Context
79
import java.security.SecureRandom
@@ -12,8 +14,8 @@ class InvitationV1Builder {
1214
companion object {
1315
fun buildFromTopic(
1416
topic: Topic,
15-
context: Invitation.InvitationV1.Context? = null,
16-
aes256GcmHkdfSha256: Invitation.InvitationV1.Aes256gcmHkdfsha256
17+
context: Context? = null,
18+
aes256GcmHkdfSha256: Invitation.InvitationV1.Aes256gcmHkdfsha256,
1719
): InvitationV1 {
1820
return InvitationV1.newBuilder().apply {
1921
this.topic = topic.description
@@ -26,18 +28,18 @@ class InvitationV1Builder {
2628

2729
fun buildContextFromId(
2830
conversationId: String = "",
29-
metadata: Map<String, String> = mapOf()
30-
): Invitation.InvitationV1.Context {
31-
return Invitation.InvitationV1.Context.newBuilder().apply {
31+
metadata: Map<String, String> = mapOf(),
32+
): Context {
33+
return Context.newBuilder().apply {
3234
this.conversationId = conversationId
3335
this.putAllMetadata(metadata)
3436
}.build()
3537
}
3638
}
3739
}
3840

39-
fun InvitationV1.createRandom(context: Invitation.InvitationV1.Context? = null): InvitationV1 {
40-
val inviteContext = context ?: Invitation.InvitationV1.Context.newBuilder().build()
41+
fun InvitationV1.createRandom(context: Context? = null): InvitationV1 {
42+
val inviteContext = context ?: Context.newBuilder().build()
4143
val randomBytes = SecureRandom().generateSeed(32)
4244
val randomString = encodeToString(randomBytes, 0).replace(Regex("=*$"), "")
4345
.replace(Regex("[^A-Za-z0-9]"), "")
@@ -54,11 +56,50 @@ fun InvitationV1.createRandom(context: Invitation.InvitationV1.Context? = null):
5456
)
5557
}
5658

59+
fun InvitationV1.createDeterministic(
60+
sender: PrivateKeyBundleV2,
61+
recipient: SignedPublicKeyBundle,
62+
context: Context? = null,
63+
): InvitationV1 {
64+
val inviteContext = context ?: Context.newBuilder().build()
65+
val secret = sender.sharedSecret(
66+
peer = recipient,
67+
myPreKey = sender.preKeysList[0].publicKey,
68+
isRecipient = false
69+
)
70+
71+
val addresses = arrayOf(sender.toV1().walletAddress, recipient.walletAddress)
72+
addresses.sort()
73+
74+
val msg = if (context != null && !context.conversationId.isNullOrBlank()) {
75+
context.conversationId + addresses.joinToString(separator = ",")
76+
} else {
77+
addresses.joinToString(separator = ",")
78+
}
79+
80+
val topicId = Crypto().calculateMac(secret = secret, message = msg.toByteArray()).toHex()
81+
val topic = Topic.directMessageV2(topicId)
82+
val keyMaterial = Crypto().deriveKey(
83+
secret = secret,
84+
salt = "__XMTP__INVITATION__SALT__XMTP__".toByteArray(),
85+
info = listOf("0").plus(addresses).joinToString(separator = "|").toByteArray()
86+
)
87+
val aes256GcmHkdfSha256 = Invitation.InvitationV1.Aes256gcmHkdfsha256.newBuilder().apply {
88+
this.keyMaterial = keyMaterial.toByteString()
89+
}.build()
90+
91+
return InvitationV1Builder.buildFromTopic(
92+
topic = topic,
93+
context = inviteContext,
94+
aes256GcmHkdfSha256 = aes256GcmHkdfSha256
95+
)
96+
}
97+
5798
class InvitationV1ContextBuilder {
5899
companion object {
59100
fun buildFromConversation(
60101
conversationId: String = "",
61-
metadata: Map<String, String> = mapOf()
102+
metadata: Map<String, String> = mapOf(),
62103
): Context {
63104
return Context.newBuilder().also {
64105
it.conversationId = conversationId

0 commit comments

Comments
 (0)