diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml index 86a4753..285117d 100644 --- a/.github/workflows/maven-release.yml +++ b/.github/workflows/maven-release.yml @@ -51,7 +51,7 @@ jobs: - name: Set up JDK uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 17 - name: Build with Maven run: mvn -DskipTests package @@ -59,7 +59,7 @@ jobs: - name: Set up Apache Maven Central uses: actions/setup-java@v1 with: # running setup-java again overwrites the settings.xml - java-version: 11 + java-version: 17 server-id: ossrh server-username: OSSRH_USERNAME server-password: OSSRH_PASSWORD diff --git a/pom.xml b/pom.xml index 876fe1e..2b3c681 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.wire helium - 1.4.1 + 1.5.0 Helium User mode for Wire Bots @@ -53,8 +53,8 @@ - 11 - 11 + 17 + 17 UTF-8 UTF-8 @@ -65,7 +65,7 @@ com.wire xenon - 1.6.2 + 1.7.0 jakarta.ws.rs @@ -156,7 +156,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.2.0 + 3.5.0 attach-javadocs diff --git a/src/main/java/com/wire/helium/API.java b/src/main/java/com/wire/helium/API.java index c7fd19b..448d756 100644 --- a/src/main/java/com/wire/helium/API.java +++ b/src/main/java/com/wire/helium/API.java @@ -22,14 +22,18 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.google.protobuf.ByteString; import com.wire.helium.models.Connection; +import com.wire.helium.models.model.response.FeatureConfig; import com.wire.helium.models.NotificationList; +import com.wire.helium.models.model.response.PublicKeysResponse; +import com.wire.helium.models.model.request.ConversationListPaginationConfig; +import com.wire.helium.models.model.request.ConversationListRequest; +import com.wire.helium.models.model.response.ConversationListIdsResponse; +import com.wire.helium.models.model.response.ConversationListResponse; import com.wire.messages.Otr; import com.wire.xenon.WireAPI; import com.wire.xenon.assets.IAsset; -import com.wire.xenon.backend.models.Conversation; -import com.wire.xenon.backend.models.Member; -import com.wire.xenon.backend.models.QualifiedId; -import com.wire.xenon.backend.models.User; +import com.wire.xenon.backend.KeyPackageUpdate; +import com.wire.xenon.backend.models.*; import com.wire.xenon.exceptions.AuthException; import com.wire.xenon.exceptions.HttpException; import com.wire.xenon.models.AssetKey; @@ -53,6 +57,7 @@ import java.util.stream.Collectors; public class API extends LoginClient implements WireAPI { + private final WebTarget versionedPath; private final WebTarget conversationsPath; private final WebTarget usersPath; private final WebTarget assetsPath; @@ -60,6 +65,8 @@ public class API extends LoginClient implements WireAPI { private final WebTarget connectionsPath; private final WebTarget selfPath; private final WebTarget notificationsPath; + private final WebTarget clientsPath; + private final WebTarget mlsPath; private final String token; private final QualifiedId convId; @@ -71,6 +78,7 @@ public API(Client client, QualifiedId convId, String token) { this.token = token; WebTarget versionedTarget = client.target(host()).path(BACKEND_API_VERSION); + versionedPath = versionedTarget; conversationsPath = versionedTarget.path("conversations"); usersPath = versionedTarget.path("users"); @@ -79,6 +87,8 @@ public API(Client client, QualifiedId convId, String token) { connectionsPath = versionedTarget.path("connections"); selfPath = versionedTarget.path("self"); notificationsPath = versionedTarget.path("notifications"); + clientsPath = versionedTarget.path("clients"); + mlsPath = versionedTarget.path("mls"); } /** @@ -347,12 +357,7 @@ public Conversation getConversation() { throw new RuntimeException(msgError); } - _Conv conv = response.readEntity(_Conv.class); - Conversation ret = new Conversation(); - ret.name = conv.name; - ret.id = conv.id; - ret.members = conv.members.others; - return ret; + return response.readEntity(Conversation.class); } @Override @@ -436,13 +441,7 @@ public Conversation createConversation(String name, UUID teamId, List + * To verify if MLS is enabled we need to go through 2 requests. They are: + *

+ *

+ * First: from GET /feature-configs there will be a `mls` object containing a `status` of type boolean + *

+ *

+ * Second: from GET /mls/public/keys returning a `removal` object containing public keys + *

+ *

+ * If the first value is false, then we already return a `false` value. + * If the first value is true, then we do the second request, in case it returns a 200 HTTP Code then MLS is + * enabled and we can return a `true` value. + *
+ * In case any of those requests fail (with HTTP Code >= 400) then we assume it is not enabled and log the error. + *

+ * + * @return boolean + */ + @Override + public boolean isMlsEnabled() { + Response featureConfigsResponse = versionedPath + .path("feature-configs") + .request(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .get(); - @JsonProperty - public String name; + if (isErrorResponse(featureConfigsResponse.getStatus())) { + String msgError = featureConfigsResponse.readEntity(String.class); + Logger.error("isMlsEnabled - Feature Configs error: %s, status: %d", msgError, featureConfigsResponse.getStatus()); + return false; + } - @JsonProperty - public _Members members; + FeatureConfig featureConfig = featureConfigsResponse.readEntity(FeatureConfig.class); + + if (featureConfig.mls.isMlsStatusEnabled()) { + Response mlsPublicKeysResponse = mlsPath + .path("public-keys") + .request(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .get(); + + if (isErrorResponse(featureConfigsResponse.getStatus())) { + String msgError = featureConfigsResponse.readEntity(String.class); + Logger.error("isMlsEnabled - Public Keys error: %s, status: %d", msgError, featureConfigsResponse.getStatus()); + return false; + } + + try { + PublicKeysResponse publicKeysResponse = mlsPublicKeysResponse.readEntity(PublicKeysResponse.class); + } catch (Exception e) { + Logger.error("isMlsEnabled - Public Keys Deserialization error: %s", e.getMessage()); + return false; + } + + return true; + } + + return false; } - @JsonIgnoreProperties(ignoreUnknown = true) - public static class _Members { - @JsonProperty - public List others; + /** + *

+ * To upload client public key we PUT a {@link ClientUpdate} object containing the public keys + * to /clients/{clientId} + *

+ *

+ * As there is no return, in case it fails we just map the HTTP Code and log the message. + *

+ * + * @param clientId clientId to upload the public keys + * @param clientUpdate the public keys + */ + @Override + public void uploadClientPublicKey(String clientId, ClientUpdate clientUpdate) { + try { + Response response = clientsPath + .path(clientId) + .request(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .put(Entity.json(clientUpdate)); + + if (isErrorResponse(response.getStatus())) { + String msgError = response.readEntity(String.class); + Logger.error( + "uploadClientPublicKey error: %s, clientId: %s, status: %d", + msgError, clientId, response.getStatus() + ); + } else if(isSuccessResponse(response.getStatus())) { + Logger.info("uploadClientPublicKey success for clientId: %s", clientId); + } + } catch (Exception e) { + Logger.error("uploadClientPublicKey error: %s", e.getMessage()); + } + } + + /** + *

+ * To upload client key packages we POST a {@link KeyPackageUpdate} object containing a list of package keys + * to /mls/key-packages/self/{clientId} + *

+ *

+ * As there is no return, in case it fails we just map the HTTP Code and log the message. + *

+ * + * @param clientId clientId to upload the package keys + * @param keyPackageUpdate list of package keys + */ + @Override + public void uploadClientKeyPackages(String clientId, KeyPackageUpdate keyPackageUpdate) { + try { + Response response = mlsPath + .path("key-packages") + .path("self") + .path(clientId) + .request(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .post(Entity.json(keyPackageUpdate)); + + if (isErrorResponse(response.getStatus())) { + String msgError = response.readEntity(String.class); + Logger.error( + "getConversationGroupInfo error: %s, clientId: %s, status: %d", + msgError, clientId, response.getStatus() + ); + } else if(isSuccessResponse(response.getStatus())) { + Logger.info("uploadClientKeyPackages success for clientId: %s", clientId); + } + } catch (Exception e) { + Logger.error("uploadClientKeyPackages, clientId: %s, error: %s", clientId, e.getMessage()); + } } + @Override + public byte[] getConversationGroupInfo(QualifiedId conversationId) throws RuntimeException { + Response response = conversationsPath + .path(conversationId.domain) + .path(conversationId.id.toString()) + .path("groupinfo") + .request(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .accept("message/mls") + .get(); + + if (isSuccessResponse(response.getStatus())) { + return response.readEntity(byte[].class); + } + + if (isErrorResponse(response.getStatus())) { + String msgError = response.readEntity(String.class); + Logger.error("getConversationGroupInfo error: %s, status: %d", msgError, response.getStatus()); + } + + throw new RuntimeException( + "getConversationGroupInfo failed", + new HttpException(response.readEntity(String.class), response.getStatus()) + ); + } + + @Override + public void commitMlsBundle(byte[] commitBundle) { + try { + Response response = mlsPath + .path("commit-bundles") + .request(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .post(Entity.entity(commitBundle, "message/mls")); + + if (isErrorResponse(response.getStatus())) { + String msgError = response.readEntity(String.class); + Logger.error("commitMlsBundle error: %s, status: %d", msgError, response.getStatus()); + } + + if (isSuccessResponse(response.getStatus())) { + Logger.info("commitMlsBundle success."); + } + + } catch (Exception e) { + Logger.error("commitMlsBundle error: %s", e.getMessage()); + } + } + + /** + *

+ * In order to get user conversations, first we need to get all the paginated conversation ids. + *

+ *

+ * For getting the conversation details, we need to do a "paginated" request, as the backend has a limit of + * 1000 conversation ids per request. + *

+ * + * @return List of {@link Conversation} details from the fetched conversation ids. + */ + @Override + public List getUserConversations() { + ConversationListPaginationConfig pagingConfig = new ConversationListPaginationConfig( + null, + 100 + ); + + List conversationIds = new ArrayList<>(); + List conversations = new ArrayList<>(); + + boolean hasMorePages; + do { + hasMorePages = false; + + Response listIdsResponse = conversationsPath + .path("list-ids") + .request(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .post(Entity.entity(pagingConfig, MediaType.APPLICATION_JSON)); + + if (listIdsResponse.getStatus() >= 400) { + String msgError = listIdsResponse.readEntity(String.class); + Logger.error("getUserConversations - List Ids error: %s, status: %d", msgError, listIdsResponse.getStatus()); + } + + if (listIdsResponse.getStatus() == 200) { + ConversationListIdsResponse conversationListIds = listIdsResponse.readEntity(ConversationListIdsResponse.class); + hasMorePages = conversationListIds.hasMore; + pagingConfig.setPagingState(conversationListIds.pagingState); + + conversationIds.addAll(conversationListIds.qualifiedConversations); + + Logger.info("getUserConversations - List Ids success. has more pages: " + hasMorePages); + } + } while (hasMorePages); + + if (!conversationIds.isEmpty()) { + int startIndex = 0; + int endIndex = 1000; + do { + if (endIndex > conversationIds.size()) { + endIndex = conversationIds.size(); + } + + conversations.addAll(getConversationsFromIds(conversationIds.subList(startIndex, endIndex))); + startIndex += 1000; + endIndex += 1000; + } while (endIndex < conversationIds.size() + 1000); + } + + return conversations; + } + + private List getConversationsFromIds(List conversationIds) { + Response conversationListResponse = conversationsPath + .path("/list") + .request(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .post(Entity.entity( + new ConversationListRequest(conversationIds), + MediaType.APPLICATION_JSON + )); + + if (conversationListResponse.getStatus() == 200) { + ConversationListResponse result = conversationListResponse.readEntity(ConversationListResponse.class); + + return result.found; + } + + if (conversationListResponse.getStatus() >= 400) { + String msgError = conversationListResponse.readEntity(String.class); + Logger.error("getUserConversations - Conversation List error: %s, status: %d", msgError, conversationListResponse.getStatus()); + } + + return List.of(); + } + + private boolean isErrorResponse(int statusCode) { + return Response.Status.Family.familyOf(statusCode).equals(Response.Status.Family.CLIENT_ERROR) + || Response.Status.Family.familyOf(statusCode).equals(Response.Status.Family.SERVER_ERROR); + } + + private boolean isSuccessResponse(int statusCode) { + return Response.Status.Family.familyOf(statusCode).equals(Response.Status.Family.SUCCESSFUL); + } + + /** + * @deprecated This class is deprecated and in case there is any work related to _Service, + * {@link Service} can be used instead. + */ @JsonIgnoreProperties(ignoreUnknown = true) static class _Service { public UUID service; diff --git a/src/main/java/com/wire/helium/models/model/request/ConversationListPaginationConfig.java b/src/main/java/com/wire/helium/models/model/request/ConversationListPaginationConfig.java new file mode 100644 index 0000000..087b0fc --- /dev/null +++ b/src/main/java/com/wire/helium/models/model/request/ConversationListPaginationConfig.java @@ -0,0 +1,32 @@ +package com.wire.helium.models.model.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ConversationListPaginationConfig { + @JsonProperty("paging_state") + public String pagingState; + + @JsonProperty("size") + public int size; + + public ConversationListPaginationConfig(String pagingState, int size) { + this.pagingState = pagingState; + this.size = size; + } + + public String getPagingState() { + return pagingState; + } + + public void setPagingState(String pagingState) { + this.pagingState = pagingState; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } +} diff --git a/src/main/java/com/wire/helium/models/model/request/ConversationListRequest.java b/src/main/java/com/wire/helium/models/model/request/ConversationListRequest.java new file mode 100644 index 0000000..88c96ed --- /dev/null +++ b/src/main/java/com/wire/helium/models/model/request/ConversationListRequest.java @@ -0,0 +1,15 @@ +package com.wire.helium.models.model.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.wire.xenon.backend.models.QualifiedId; + +import java.util.List; + +public class ConversationListRequest { + @JsonProperty("qualified_ids") + public List qualifiedIds; + + public ConversationListRequest(List qualifiedIds) { + this.qualifiedIds = qualifiedIds; + } +} diff --git a/src/main/java/com/wire/helium/models/model/response/ConversationListIdsResponse.java b/src/main/java/com/wire/helium/models/model/response/ConversationListIdsResponse.java new file mode 100644 index 0000000..dc2fc74 --- /dev/null +++ b/src/main/java/com/wire/helium/models/model/response/ConversationListIdsResponse.java @@ -0,0 +1,19 @@ +package com.wire.helium.models.model.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.wire.xenon.backend.models.QualifiedId; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class ConversationListIdsResponse { + @JsonProperty("has_more") + public boolean hasMore; + + @JsonProperty("paging_state") + public String pagingState; + + @JsonProperty("qualified_conversations") + public List qualifiedConversations; +} diff --git a/src/main/java/com/wire/helium/models/model/response/ConversationListResponse.java b/src/main/java/com/wire/helium/models/model/response/ConversationListResponse.java new file mode 100644 index 0000000..1c3dedc --- /dev/null +++ b/src/main/java/com/wire/helium/models/model/response/ConversationListResponse.java @@ -0,0 +1,20 @@ +package com.wire.helium.models.model.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.wire.xenon.backend.models.Conversation; +import com.wire.xenon.backend.models.QualifiedId; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class ConversationListResponse { + @JsonProperty("failed") + public List failed; + + @JsonProperty("found") + public List found; + + @JsonProperty("not_found") + public List notFound; +} diff --git a/src/main/java/com/wire/helium/models/model/response/FeatureConfig.java b/src/main/java/com/wire/helium/models/model/response/FeatureConfig.java new file mode 100644 index 0000000..6126728 --- /dev/null +++ b/src/main/java/com/wire/helium/models/model/response/FeatureConfig.java @@ -0,0 +1,13 @@ +package com.wire.helium.models.model.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class FeatureConfig { + + @JsonProperty("mls") + public MlsResponse mls; +} diff --git a/src/main/java/com/wire/helium/models/model/response/MlsConfigResponse.java b/src/main/java/com/wire/helium/models/model/response/MlsConfigResponse.java new file mode 100644 index 0000000..17d100a --- /dev/null +++ b/src/main/java/com/wire/helium/models/model/response/MlsConfigResponse.java @@ -0,0 +1,23 @@ +package com.wire.helium.models.model.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MlsConfigResponse { + @JsonProperty + public List allowedCipherSuites; + + @JsonProperty + public Integer defaultCipherSuite; + + @JsonProperty + public String defaultProtocol; + + @JsonProperty + public List supportedProtocols; +} diff --git a/src/main/java/com/wire/helium/models/model/response/MlsResponse.java b/src/main/java/com/wire/helium/models/model/response/MlsResponse.java new file mode 100644 index 0000000..3e920f3 --- /dev/null +++ b/src/main/java/com/wire/helium/models/model/response/MlsResponse.java @@ -0,0 +1,19 @@ +package com.wire.helium.models.model.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MlsResponse { + @JsonProperty("config") + public MlsConfigResponse config; + + @JsonProperty + public String status; + + public boolean isMlsStatusEnabled() { + return status.equals("enabled"); + } +} diff --git a/src/main/java/com/wire/helium/models/model/response/PublicKeysResponse.java b/src/main/java/com/wire/helium/models/model/response/PublicKeysResponse.java new file mode 100644 index 0000000..39721f6 --- /dev/null +++ b/src/main/java/com/wire/helium/models/model/response/PublicKeysResponse.java @@ -0,0 +1,14 @@ +package com.wire.helium.models.model.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.wire.xenon.backend.models.ClientUpdate; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PublicKeysResponse { + + @JsonProperty("removal") + public ClientUpdate.MlsPublicKeys removal; +} diff --git a/src/test/java/com/wire/helium/End2EndTest.java b/src/test/java/com/wire/helium/End2EndTest.java index 1c5527a..8db557f 100644 --- a/src/test/java/com/wire/helium/End2EndTest.java +++ b/src/test/java/com/wire/helium/End2EndTest.java @@ -12,6 +12,7 @@ import com.wire.xenon.backend.models.NewBot; import com.wire.xenon.backend.models.QualifiedId; import com.wire.xenon.crypto.CryptoDatabase; +import com.wire.xenon.crypto.mls.CryptoMlsClient; import com.wire.xenon.crypto.storage.JdbiStorage; import com.wire.xenon.models.otr.OtrMessage; import org.junit.jupiter.api.AfterEach; @@ -31,6 +32,7 @@ public class End2EndTest extends DatabaseTestBase { public void beforeEach() { rootFolder = "helium-unit-test-" + UUID.randomUUID(); storage = new JdbiStorage(jdbi); + } @AfterEach @@ -49,11 +51,12 @@ public void testAliceToAlice() throws Exception { CryptoDatabase aliceCrypto = new CryptoDatabase(aliceId, storage, rootFolder + "/testAliceToAlice/1"); CryptoDatabase aliceCrypto1 = new CryptoDatabase(aliceId, storage, rootFolder + "/testAliceToAlice/2"); + CryptoMlsClient cryptoMlsClient = new CryptoMlsClient(client1, client1 + "_db_key"); DummyAPI api = new DummyAPI(); api.addDevice(aliceId, client1, aliceCrypto1.box().newLastPreKey()); - WireClient aliceClient = new WireClientBase(api, aliceCrypto, state); + WireClient aliceClient = new WireClientBase(api, aliceCrypto, cryptoMlsClient, state); for (int i = 0; i < 10; i++) { String text = "Hello Alice, This is Alice!"; @@ -78,6 +81,7 @@ public void testAliceToBob() throws Exception { CryptoDatabase aliceCrypto = new CryptoDatabase(aliceId, storage, rootFolder + "/testAliceToBob"); CryptoDatabase bobCrypto = new CryptoDatabase(bobId, storage, rootFolder + "/testAliceToBob"); + CryptoMlsClient cryptoMlsClient = new CryptoMlsClient(client1, client1 + "_db_key"); DummyAPI api = new DummyAPI(); api.addDevice(bobId, client1, bobCrypto.box().newLastPreKey()); @@ -85,7 +89,7 @@ public void testAliceToBob() throws Exception { NewBot state = new NewBot(); state.id = aliceId.id; state.client = "alice1"; - WireClient aliceClient = new WireClientBase(api, aliceCrypto, state); + WireClient aliceClient = new WireClientBase(api, aliceCrypto, cryptoMlsClient, state); for (int i = 0; i < 10; i++) { String text = "Hello Bob, This is Alice!"; @@ -112,6 +116,7 @@ public void testMultiDevicePostgres() throws Exception { CryptoDatabase aliceCrypto1 = new CryptoDatabase(aliceId, storage, rootFolder + "/testMultiDevicePostgres/alice/1"); CryptoDatabase bobCrypto1 = new CryptoDatabase(bobId, storage, rootFolder + "/testMultiDevicePostgres/bob/1"); CryptoDatabase bobCrypto2 = new CryptoDatabase(bobId, storage, rootFolder + "/testMultiDevicePostgres/bob/2"); + CryptoMlsClient cryptoMlsClient = new CryptoMlsClient(client1, client1 + "_db_key"); DummyAPI api = new DummyAPI(); api.addDevice(bobId, client1, bobCrypto1.box().newLastPreKey()); @@ -123,7 +128,7 @@ public void testMultiDevicePostgres() throws Exception { NewBot state = new NewBot(); state.id = aliceId.id; state.client = aliceCl; - WireClient aliceClient = new WireClientBase(api, aliceCrypto, state); + WireClient aliceClient = new WireClientBase(api, aliceCrypto, cryptoMlsClient, state); for (int i = 0; i < 10; i++) { String text = "Hello Bob, This is Alice!";