diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml index 08d5f6d..2b97df3 100644 --- a/.github/workflows/maven-release.yml +++ b/.github/workflows/maven-release.yml @@ -1,9 +1,9 @@ name: Release to Maven Central on: - push: - tags: - - '*' + workflow_dispatch: + release: + types: [ published ] jobs: tests: diff --git a/pom.xml b/pom.xml index 0be4823..9952a23 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.wire xenon - 1.7.0 + 1.7.1 Xenon Base Wire Bots Library @@ -115,6 +115,12 @@ 5.10.2 test + + org.mockito + mockito-core + 5.14.1 + test + org.postgresql postgresql @@ -198,6 +204,12 @@ org.jetbrains.dokka dokka-maven-plugin 1.9.20 + + + ${project.basedir}/src/main/java + ${project.basedir}/src/main/kotlin + + compile diff --git a/src/main/java/com/wire/xenon/MessageResourceBase.java b/src/main/java/com/wire/xenon/MessageResourceBase.java index 92004b3..6d48c24 100644 --- a/src/main/java/com/wire/xenon/MessageResourceBase.java +++ b/src/main/java/com/wire/xenon/MessageResourceBase.java @@ -15,6 +15,7 @@ public abstract class MessageResourceBase { protected final MessageHandlerBase handler; + protected static final Integer PREKEYS_DEFAULT_REPLENISH = 10; public MessageResourceBase(MessageHandlerBase handler) { this.handler = handler; @@ -57,6 +58,14 @@ protected void handleMessage(UUID eventId, Payload payload, WireClient client) t handler.onEvent(client, fromMls, genericMessageMls); break; + case "conversation.mls-welcome": + Logger.info("conversation.mls-welcome: bot: %s in: %s", botId, payload.conversation); + + client.processWelcomeMessage(payload.data.text); + + SystemMessage welcomeSystemMessage = getSystemMessage(eventId, payload); + handler.onNewConversation(client, welcomeSystemMessage); + break; case "conversation.member-join": Logger.debug("conversation.member-join: bot: %s", botId); @@ -71,7 +80,7 @@ protected void handleMessage(UUID eventId, Payload payload, WireClient client) t return; } - // Check if we still have some prekeys available. Upload new prekeys if needed + // Check if we still have some prekeys available. Upload them if needed handler.validatePreKeys(client, participants.size()); SystemMessage systemMessage = getSystemMessage(eventId, payload); @@ -107,16 +116,25 @@ protected void handleMessage(UUID eventId, Payload payload, WireClient client) t Logger.debug("conversation.create: bot: %s", botId); systemMessage = getSystemMessage(eventId, payload); + Integer preKeysUserCount = PREKEYS_DEFAULT_REPLENISH; if (systemMessage.conversation.members != null) { + preKeysUserCount = systemMessage.conversation.members.others.size(); Member self = new Member(); String selfDomain = null; - if (systemMessage.conversation != null && systemMessage.conversation.id != null) { + if (systemMessage.conversation.id != null) { selfDomain = systemMessage.conversation.id.domain; } self.id = new QualifiedId(botId, selfDomain); systemMessage.conversation.members.others.add(self); } + // Check if we still have some prekeys and keyPackages available. Upload them if needed + if (systemMessage.conversation.protocol == Conversation.Protocol.PROTEUS) + handler.validatePreKeys(client, preKeysUserCount); + else { + client.checkAndReplenishKeyPackages(); + } + handler.onNewConversation(client, systemMessage); break; case "conversation.rename": diff --git a/src/main/java/com/wire/xenon/WireClient.java b/src/main/java/com/wire/xenon/WireClient.java index be12ccb..a430191 100644 --- a/src/main/java/com/wire/xenon/WireClient.java +++ b/src/main/java/com/wire/xenon/WireClient.java @@ -40,6 +40,9 @@ */ public interface WireClient extends Closeable { + Integer KEY_PACKAGES_LOWER_THRESHOLD = 10; + Integer KEY_PACKAGES_REPLENISH_AMOUNT = 50; + /** * Post a generic message into conversation * @@ -173,6 +176,20 @@ public interface WireClient extends Closeable { */ void joinMlsConversation(QualifiedId conversationId, String mlsGroupId); + /** + * When a mls-welcome event is received, this method is called to process it. + * It will create a MLS conversation record in the local core-crypto storage. + * @param welcome base64 encoded welcome message + * @return the MLS group id of the conversation + */ + byte[] processWelcomeMessage(String welcome); + + /** + * Checks if the number of available key packages is below the threshold and replenishes them if necessary. + * NOTE: Will make an API call to publish the new key packages if needed. + */ + void checkAndReplenishKeyPackages(); + /** * Invoked by the sdk. Called once when the conversation is created * diff --git a/src/main/java/com/wire/xenon/WireClientBase.java b/src/main/java/com/wire/xenon/WireClientBase.java index 99f672d..5a243cf 100644 --- a/src/main/java/com/wire/xenon/WireClientBase.java +++ b/src/main/java/com/wire/xenon/WireClientBase.java @@ -233,14 +233,32 @@ public void uploadMlsKeyPackages(int keyPackageAmount) { @Override public void joinMlsConversation(QualifiedId conversationId, String mlsGroupId) { + if (cryptoMlsClient.conversationExists(mlsGroupId)) { + Logger.info("Conversation %s already exists, ignore it", conversationId); + return; + } final byte[] conversationGroupInfo = api.getConversationGroupInfo(conversationId); final byte[] commitBundle = cryptoMlsClient.createJoinConversationRequest(conversationGroupInfo); api.commitMlsBundle(commitBundle); - // TODO some error recovery + // TODO Add error recovery, maybe a simple 3 times retry on api calls with quadratic backoff cryptoMlsClient.markConversationAsJoined(mlsGroupId); } + @Override + public byte[] processWelcomeMessage(String welcome) { + checkAndReplenishKeyPackages(); + return cryptoMlsClient.processWelcomeMessage(welcome); + } + + @Override + public void checkAndReplenishKeyPackages() { + if (cryptoMlsClient.validKeyPackageCount() < KEY_PACKAGES_LOWER_THRESHOLD) { + Logger.info("Too few Key packages, replenish them"); + cryptoMlsClient.generateKeyPackages(KEY_PACKAGES_REPLENISH_AMOUNT); + } + } + @Override public PreKey newLastPreKey() throws CryptoException { return crypto.newLastPreKey(); diff --git a/src/main/kotlin/com/wire/xenon/crypto/mls/CryptoMlsClient.kt b/src/main/kotlin/com/wire/xenon/crypto/mls/CryptoMlsClient.kt index 5441de5..eb5588a 100644 --- a/src/main/kotlin/com/wire/xenon/crypto/mls/CryptoMlsClient.kt +++ b/src/main/kotlin/com/wire/xenon/crypto/mls/CryptoMlsClient.kt @@ -61,14 +61,12 @@ class CryptoMlsClient : Closeable { return keyPackages.map { it.value } } - // TODO handle conversation marked as complete, after both welcomeMessage and member-join events have been received, - // TODO remember checking there are enough key packages - // https://wearezeta.atlassian.net/wiki/spaces/ENGINEERIN/pages/563053166/Use+case+being+added+to+a+conversation+MLS /** * Process a welcome message, adding this client to a conversation, and return the group id. */ - fun welcomeMessage(welcome: ByteArray): ByteArray { - val welcomeBundle = runBlocking { mlsClient.processWelcomeMessage(Welcome(welcome)) } + fun processWelcomeMessage(welcome: String): ByteArray { + val welcomeBytes: ByteArray = Base64.getDecoder().decode(welcome) + val welcomeBundle = runBlocking { mlsClient.processWelcomeMessage(Welcome(welcomeBytes)) } return welcomeBundle.id.value } @@ -77,6 +75,11 @@ class CryptoMlsClient : Closeable { return packageCount.toLong() } + fun conversationExists(mlsGroupId: String): Boolean { + val mlsGroupIdBytes: ByteArray = Base64.getDecoder().decode(mlsGroupId) + return runBlocking { mlsClient.conversationExists(MLSGroupId(mlsGroupIdBytes)) } + } + /** * Create a request to join a conversation. * Needs to be followed by a call to markConversationAsJoined() to complete the process. @@ -97,9 +100,14 @@ class CryptoMlsClient : Closeable { return bundle.commit.value + bundle.groupInfoBundle.payload.value + (bundle.welcome?.value ?: ByteArray(0)) } + /** + * Completes the process of joining a conversation. + * To be called after createJoinConversationRequest(), and having a successful response from the backend + * while uploading the commitBundle. + */ fun markConversationAsJoined(mlsGroupId: String) { val mlsGroupIdBytes: ByteArray = Base64.getDecoder().decode(mlsGroupId) - val commitBundle = runBlocking { mlsClient.mergePendingGroupFromExternalCommit(MLSGroupId(mlsGroupIdBytes)) } + runBlocking { mlsClient.mergePendingGroupFromExternalCommit(MLSGroupId(mlsGroupIdBytes)) } // TODO support the possibility of merging returning some decrypted messages ? } diff --git a/src/test/java/com/wire/xenon/MlsClientTest.java b/src/test/java/com/wire/xenon/MlsClientTest.java index ac10326..a1f128f 100644 --- a/src/test/java/com/wire/xenon/MlsClientTest.java +++ b/src/test/java/com/wire/xenon/MlsClientTest.java @@ -57,9 +57,12 @@ public void testMlsClientCreateConversationAndEncrypt() throws IOException { // Create a new client and join the conversation CryptoMlsClient mlsClient = new CryptoMlsClient(client1, "pwd"); + assert !mlsClient.conversationExists(groupIdBase64); final byte[] commitBundle = mlsClient.createJoinConversationRequest(groupInfo); assert commitBundle.length > groupInfo.length; mlsClient.markConversationAsJoined(groupIdBase64); + assert mlsClient.conversationExists(groupIdBase64); + // Encrypt a message for the joined conversation String plainMessage = UUID.randomUUID().toString(); final byte[] encryptedMessage = mlsClient.encrypt(groupIdBase64, plainMessage.getBytes()); @@ -91,17 +94,22 @@ public void testMlsClientsEncryptAndDecrypt() throws IOException { // Create a new client and join the conversation CryptoMlsClient mlsClient = new CryptoMlsClient(client1, "pwd"); + assert !mlsClient.conversationExists(groupIdBase64); final byte[] commitBundle = mlsClient.createJoinConversationRequest(groupInfo); assert commitBundle.length > groupInfo.length; mlsClient.markConversationAsJoined(groupIdBase64); + assert mlsClient.conversationExists(groupIdBase64); // Create a second client and make the first client invite the second one String client2 = "bob1_" + UUID.randomUUID(); CryptoMlsClient mlsClient2 = new CryptoMlsClient(client2, "pwd"); + assert !mlsClient2.conversationExists(groupIdBase64); final List keyPackages = mlsClient2.generateKeyPackages(1); final byte[] welcome = mlsClient.addMemberToConversation(groupIdBase64, keyPackages); mlsClient.acceptLatestCommit(groupIdBase64); - mlsClient2.welcomeMessage(welcome); + String welcomeBase64 = new String(Base64.getEncoder().encode(welcome)); + mlsClient2.processWelcomeMessage(welcomeBase64); + assert mlsClient2.conversationExists(groupIdBase64); // Encrypt a message for the joined conversation String plainMessage = UUID.randomUUID().toString(); diff --git a/src/test/java/com/wire/xenon/WireClientBaseTest.java b/src/test/java/com/wire/xenon/WireClientBaseTest.java new file mode 100644 index 0000000..b320166 --- /dev/null +++ b/src/test/java/com/wire/xenon/WireClientBaseTest.java @@ -0,0 +1,56 @@ +package com.wire.xenon; + +import com.wire.xenon.backend.models.NewBot; +import com.wire.xenon.crypto.mls.CryptoMlsClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.mockito.Mockito.*; + +public class WireClientBaseTest { + private WireClientBase wireClientBase; + private WireAPI mockApi; + private CryptoMlsClient mockCryptoMlsClient; + private NewBot mockState; + + @BeforeEach + public void setUp() { + mockApi = mock(WireAPI.class); + mockCryptoMlsClient = mock(CryptoMlsClient.class); + mockState = mock(NewBot.class); + wireClientBase = new WireClientBase(mockApi, null, mockCryptoMlsClient, mockState); + } + + @Test + public void checkAndReplenishKeyPackages_replenishesWhenBelowThreshold() { + when(mockCryptoMlsClient.validKeyPackageCount()).thenReturn(WireClientBase.KEY_PACKAGES_LOWER_THRESHOLD - 5L); + + wireClientBase.checkAndReplenishKeyPackages(); + + verify(mockCryptoMlsClient, times(1)).generateKeyPackages(WireClientBase.KEY_PACKAGES_REPLENISH_AMOUNT); + } + + @Test + public void checkAndReplenishKeyPackages_doesNotReplenishWhenAboveThreshold() { + when(mockCryptoMlsClient.validKeyPackageCount()).thenReturn(WireClientBase.KEY_PACKAGES_LOWER_THRESHOLD + 5L); + + wireClientBase.checkAndReplenishKeyPackages(); + + verify(mockCryptoMlsClient, never()).generateKeyPackages(anyInt()); + } + + @Test + public void processWelcomeMessage_callsCheckAndReplenishKeyPackages() { + String welcomeMessage = "welcomeMessage"; + byte[] expectedResponse = new byte[]{1, 2, 3}; + + when(mockCryptoMlsClient.processWelcomeMessage(welcomeMessage)).thenReturn(expectedResponse); + when(mockCryptoMlsClient.validKeyPackageCount()).thenReturn(WireClientBase.KEY_PACKAGES_LOWER_THRESHOLD + 5L); + + byte[] response = wireClientBase.processWelcomeMessage(welcomeMessage); + + verify(mockCryptoMlsClient, times(1)).processWelcomeMessage(welcomeMessage); + assertArrayEquals(expectedResponse, response); + } +} \ No newline at end of file