diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
new file mode 100644
index 000000000..3240d7f07
--- /dev/null
+++ b/.github/workflows/integration-tests.yml
@@ -0,0 +1,30 @@
+name: Integration Tests
+
+on: [workflow_dispatch]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+ cache: 'maven'
+ - uses: aws-actions/configure-aws-credentials@v2
+ name: Configure AWS credentials from Test account
+ with:
+ role-to-assume: ${{ vars.AWS_ROLE }}
+ aws-region: ${{ vars.AWS_REGION }}
+ - name: Fetch integration utils library
+ run: |
+ mkdir -p integration-tests/.libs
+ mkdir -p integration-tests/src/main/resources
+ wget -O integration-tests/.libs/software.amazon.awssdk-sso.jar https://repo1.maven.org/maven2/software/amazon/awssdk/sso/2.19.8/sso-2.19.8.jar
+ aws s3 cp "s3://${{ vars.INTEGRATION_TESTS_BUCKET }}/config-latest.yml" integration-tests/src/main/resources/config.yml
+ - name: Run and verify integration tests
+ run: ./mvnw clean compile test-compile failsafe:integration-test failsafe:verify
diff --git a/integration-tests/.gitignore b/integration-tests/.gitignore
new file mode 100644
index 000000000..9637f0290
--- /dev/null
+++ b/integration-tests/.gitignore
@@ -0,0 +1,2 @@
+.libs
+src/main/resources/config.yml
diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml
new file mode 100644
index 000000000..6d132f455
--- /dev/null
+++ b/integration-tests/pom.xml
@@ -0,0 +1,54 @@
+
+
+
+ TextSecureServer
+ org.whispersystems.textsecure
+ JGITVER
+
+ 4.0.0
+ integration-tests
+
+
+
+ org.whispersystems.textsecure
+ service
+ ${project.version}
+
+
+
+ software.amazon.awssdk
+ dynamodb
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.0.0-M7
+
+
+ **
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+ 3.0.0
+
+
+ ${project.basedir}/.libs/software.amazon.awssdk-sso.jar
+
+
+ **/*.java
+
+
+
+
+
+
diff --git a/integration-tests/src/main/java/org/signal/integration/Codecs.java b/integration-tests/src/main/java/org/signal/integration/Codecs.java
new file mode 100644
index 000000000..db985945c
--- /dev/null
+++ b/integration-tests/src/main/java/org/signal/integration/Codecs.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.integration;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import java.io.IOException;
+import java.util.Base64;
+import org.signal.libsignal.protocol.IdentityKey;
+import org.signal.libsignal.protocol.ecc.Curve;
+import org.signal.libsignal.protocol.ecc.ECPublicKey;
+
+public final class Codecs {
+
+ private Codecs() {
+ // utility class
+ }
+
+ @FunctionalInterface
+ public interface CheckedFunction {
+ R apply(T t) throws Exception;
+ }
+
+ public static class Base64BasedSerializer extends JsonSerializer {
+
+ private final CheckedFunction mapper;
+
+ public Base64BasedSerializer(final CheckedFunction mapper) {
+ this.mapper = mapper;
+ }
+
+ @Override
+ public void serialize(final T value, final JsonGenerator gen, final SerializerProvider serializers) throws IOException {
+ try {
+ gen.writeString(Base64.getEncoder().withoutPadding().encodeToString(mapper.apply(value)));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ public static class Base64BasedDeserializer extends JsonDeserializer {
+
+ private final CheckedFunction mapper;
+
+ public Base64BasedDeserializer(final CheckedFunction mapper) {
+ this.mapper = mapper;
+ }
+
+ @Override
+ public T deserialize(final JsonParser p, final DeserializationContext ctxt) throws IOException {
+ try {
+ return mapper.apply(Base64.getDecoder().decode(p.getValueAsString()));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ public static class ByteArraySerializer extends Base64BasedSerializer {
+ public ByteArraySerializer() {
+ super(bytes -> bytes);
+ }
+ }
+
+ public static class ByteArrayDeserializer extends Base64BasedDeserializer {
+ public ByteArrayDeserializer() {
+ super(bytes -> bytes);
+ }
+ }
+
+ public static class ECPublicKeySerializer extends Base64BasedSerializer {
+ public ECPublicKeySerializer() {
+ super(ECPublicKey::serialize);
+ }
+ }
+
+ public static class ECPublicKeyDeserializer extends Base64BasedDeserializer {
+ public ECPublicKeyDeserializer() {
+ super(bytes -> Curve.decodePoint(bytes, 0));
+ }
+ }
+
+ public static class IdentityKeySerializer extends Base64BasedSerializer {
+ public IdentityKeySerializer() {
+ super(IdentityKey::serialize);
+ }
+ }
+
+ public static class IdentityKeyDeserializer extends Base64BasedDeserializer {
+ public IdentityKeyDeserializer() {
+ super(bytes -> new IdentityKey(bytes, 0));
+ }
+ }
+}
diff --git a/integration-tests/src/main/java/org/signal/integration/IntegrationTools.java b/integration-tests/src/main/java/org/signal/integration/IntegrationTools.java
new file mode 100644
index 000000000..109a84805
--- /dev/null
+++ b/integration-tests/src/main/java/org/signal/integration/IntegrationTools.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.integration;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import org.signal.integration.config.Config;
+import org.whispersystems.textsecuregcm.registration.VerificationSession;
+import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords;
+import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
+import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
+import org.whispersystems.textsecuregcm.storage.VerificationSessions;
+import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
+import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+
+public class IntegrationTools {
+
+ private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
+
+ private final VerificationSessionManager verificationSessionManager;
+
+
+ public static IntegrationTools create(final Config config) {
+ final AwsCredentialsProvider credentialsProvider = DefaultCredentialsProvider.builder().build();
+
+ final DynamoDbAsyncClient dynamoDbAsyncClient = DynamoDbFromConfig.asyncClient(
+ config.dynamoDbClientConfiguration(),
+ credentialsProvider);
+
+ final DynamoDbClient dynamoDbClient = DynamoDbFromConfig.client(
+ config.dynamoDbClientConfiguration(),
+ credentialsProvider);
+
+ final RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords(
+ config.dynamoDbTables().registrationRecovery(), Duration.ofDays(1), dynamoDbClient, dynamoDbAsyncClient);
+
+ final VerificationSessions verificationSessions = new VerificationSessions(
+ dynamoDbAsyncClient, config.dynamoDbTables().verificationSessions(), Clock.systemUTC());
+
+ return new IntegrationTools(
+ new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords),
+ new VerificationSessionManager(verificationSessions)
+ );
+ }
+
+ private IntegrationTools(
+ final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
+ final VerificationSessionManager verificationSessionManager) {
+ this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
+ this.verificationSessionManager = verificationSessionManager;
+ }
+
+ public CompletableFuture populateRecoveryPassword(final String e164, final byte[] password) {
+ return registrationRecoveryPasswordsManager.storeForCurrentNumber(e164, password);
+ }
+
+ public CompletableFuture> peekVerificationSessionPushChallenge(final String sessionId) {
+ return verificationSessionManager.findForId(sessionId)
+ .thenApply(maybeSession -> maybeSession.map(VerificationSession::pushChallenge));
+ }
+}
diff --git a/integration-tests/src/main/java/org/signal/integration/Operations.java b/integration-tests/src/main/java/org/signal/integration/Operations.java
new file mode 100644
index 000000000..07f99ee00
--- /dev/null
+++ b/integration-tests/src/main/java/org/signal/integration/Operations.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.integration;
+
+import static java.util.Objects.requireNonNull;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.google.common.io.Resources;
+import com.google.common.net.HttpHeaders;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.net.URI;
+import java.net.URL;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.security.cert.CertificateException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Executors;
+import org.apache.commons.lang3.RandomUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Validate;
+import org.apache.commons.lang3.tuple.Pair;
+import org.signal.integration.config.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
+import org.whispersystems.textsecuregcm.entities.AccountAttributes;
+import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
+import org.whispersystems.textsecuregcm.entities.RegistrationRequest;
+import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
+import org.whispersystems.textsecuregcm.storage.Device;
+import org.whispersystems.textsecuregcm.util.HeaderUtils;
+import org.whispersystems.textsecuregcm.util.SystemMapper;
+
+public final class Operations {
+
+ private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+ private static final Config CONFIG = loadConfigFromClasspath("config.yml");
+
+ private static final IntegrationTools INTEGRATION_TOOLS = IntegrationTools.create(CONFIG);
+
+ private static final String USER_AGENT = "integration-test";
+
+ private static final FaultTolerantHttpClient CLIENT = buildClient();
+
+
+ private Operations() {
+ // utility class
+ }
+
+ public static TestUser newRegisteredUser(final String number) {
+ final byte[] registrationPassword = RandomUtils.nextBytes(32);
+ final String accountPassword = Base64.getEncoder().encodeToString(RandomUtils.nextBytes(32));
+
+ final TestUser user = TestUser.create(number, accountPassword, registrationPassword);
+ final AccountAttributes accountAttributes = user.accountAttributes();
+
+ INTEGRATION_TOOLS.populateRecoveryPassword(number, registrationPassword).join();
+
+ // register account
+ final RegistrationRequest registrationRequest = new RegistrationRequest(
+ null, registrationPassword, accountAttributes, true);
+
+ final AccountIdentityResponse registrationResponse = apiPost("/v1/registration", registrationRequest)
+ .authorized(number, accountPassword)
+ .executeExpectSuccess(AccountIdentityResponse.class);
+
+ user.setAciUuid(registrationResponse.uuid());
+ user.setPniUuid(registrationResponse.pni());
+
+ // upload pre-key
+ final TestUser.PreKeySetPublicView preKeySetPublicView = user.preKeys(Device.MASTER_ID, false);
+ apiPut("/v2/keys", preKeySetPublicView)
+ .authorized(user, Device.MASTER_ID)
+ .executeExpectSuccess();
+
+ return user;
+ }
+
+ public static void deleteUser(final TestUser user) {
+ apiDelete("/v1/accounts/me").authorized(user).executeExpectSuccess();
+ }
+
+ public static String peekVerificationSessionPushChallenge(final String sessionId) {
+ return INTEGRATION_TOOLS.peekVerificationSessionPushChallenge(sessionId).join()
+ .orElseThrow(() -> new RuntimeException("push challenge not found for the verification session"));
+ }
+
+ public static T sendEmptyRequestAuthenticated(
+ final String endpoint,
+ final String method,
+ final String username,
+ final String password,
+ final Class outputType) {
+ try {
+ final HttpRequest request = HttpRequest.newBuilder()
+ .uri(serverUri(endpoint, Collections.emptyList()))
+ .method(method, HttpRequest.BodyPublishers.noBody())
+ .header(HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(username, password))
+ .header(HttpHeaders.CONTENT_TYPE, "application/json")
+ .build();
+ return CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8))
+ .whenComplete((response, error) -> {
+ if (error != null) {
+ logger.error("request error", error);
+ error.printStackTrace();
+ } else {
+ logger.info("response: {}", response.statusCode());
+ System.out.println("response: " + response.statusCode() + ", " + response.body());
+ }
+ })
+ .thenApply(response -> {
+ try {
+ return outputType.equals(Void.class)
+ ? null
+ : SystemMapper.jsonMapper().readValue(response.body(), outputType);
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+ })
+ .get();
+ } catch (final Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static RequestBuilder apiGet(final String endpoint) {
+ return new RequestBuilder(HttpRequest.newBuilder().GET(), endpoint);
+ }
+
+ public static RequestBuilder apiDelete(final String endpoint) {
+ return new RequestBuilder(HttpRequest.newBuilder().DELETE(), endpoint);
+ }
+
+ public static RequestBuilder apiPost(final String endpoint, final R input) {
+ return RequestBuilder.withJsonBody(endpoint, "POST", input);
+ }
+
+ public static RequestBuilder apiPut(final String endpoint, final R input) {
+ return RequestBuilder.withJsonBody(endpoint, "PUT", input);
+ }
+
+ public static RequestBuilder apiPatch(final String endpoint, final R input) {
+ return RequestBuilder.withJsonBody(endpoint, "PATCH", input);
+ }
+
+ private static URI serverUri(final String endpoint, final List queryParams) {
+ final String query = queryParams.isEmpty()
+ ? StringUtils.EMPTY
+ : "?" + String.join("&", queryParams);
+ return URI.create("https://" + CONFIG.domain() + endpoint + query);
+ }
+
+ public static class RequestBuilder {
+
+ private final HttpRequest.Builder builder;
+
+ private final String endpoint;
+
+ private final List queryParams = new ArrayList<>();
+
+
+ private RequestBuilder(final HttpRequest.Builder builder, final String endpoint) {
+ this.builder = builder;
+ this.endpoint = endpoint;
+ }
+
+ private static RequestBuilder withJsonBody(final String endpoint, final String method, final R input) {
+ try {
+ final byte[] body = SystemMapper.jsonMapper().writeValueAsBytes(input);
+ return new RequestBuilder(HttpRequest.newBuilder()
+ .header(HttpHeaders.CONTENT_TYPE, "application/json")
+ .method(method, HttpRequest.BodyPublishers.ofByteArray(body)), endpoint);
+ } catch (final JsonProcessingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public RequestBuilder authorized(final TestUser user) {
+ return authorized(user, Device.MASTER_ID);
+ }
+
+ public RequestBuilder authorized(final TestUser user, final long deviceId) {
+ final String username = "%s.%d".formatted(user.aciUuid().toString(), deviceId);
+ return authorized(username, user.accountPassword());
+ }
+
+ public RequestBuilder authorized(final String username, final String password) {
+ builder.header(HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(username, password));
+ return this;
+ }
+
+ public RequestBuilder queryParam(final String key, final String value) {
+ queryParams.add("%s=%s".formatted(key, value));
+ return this;
+ }
+
+ public RequestBuilder header(final String name, final String value) {
+ builder.header(name, value);
+ return this;
+ }
+
+ public Pair execute() {
+ return execute(Void.class);
+ }
+
+ public Pair executeExpectSuccess() {
+ final Pair execute = execute();
+ Validate.isTrue(
+ execute.getLeft() >= 200 && execute.getLeft() < 300,
+ "Unexpected response code: %d",
+ execute.getLeft());
+ return execute;
+ }
+
+ public T executeExpectSuccess(final Class expectedType) {
+ final Pair execute = execute(expectedType);
+ return requireNonNull(execute.getRight());
+ }
+
+ public Pair execute(final Class expectedType) {
+ builder.uri(serverUri(endpoint, queryParams))
+ .header(HttpHeaders.USER_AGENT, USER_AGENT);
+ return CLIENT.sendAsync(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8))
+ .whenComplete((response, error) -> {
+ if (error != null) {
+ logger.error("request error", error);
+ error.printStackTrace();
+ }
+ })
+ .thenApply(response -> {
+ try {
+ final T result = expectedType.equals(Void.class)
+ ? null
+ : SystemMapper.jsonMapper().readValue(response.body(), expectedType);
+ return Pair.of(response.statusCode(), result);
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+ })
+ .join();
+ }
+ }
+
+ private static FaultTolerantHttpClient buildClient() {
+ try {
+ return FaultTolerantHttpClient.newBuilder()
+ .withName("integration-test")
+ .withExecutor(Executors.newFixedThreadPool(16))
+ .withCircuitBreaker(new CircuitBreakerConfiguration())
+ .withTrustedServerCertificates(CONFIG.rootCert())
+ .build();
+ } catch (final CertificateException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static Config loadConfigFromClasspath(final String filename) {
+ try {
+ final URL configFileUrl = Resources.getResource(filename);
+ return SystemMapper.yamlMapper().readValue(Resources.toByteArray(configFileUrl), Config.class);
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/integration-tests/src/main/java/org/signal/integration/TestDevice.java b/integration-tests/src/main/java/org/signal/integration/TestDevice.java
new file mode 100644
index 000000000..59c5de53a
--- /dev/null
+++ b/integration-tests/src/main/java/org/signal/integration/TestDevice.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.integration;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import org.apache.commons.lang3.tuple.Pair;
+import org.signal.libsignal.protocol.IdentityKeyPair;
+import org.signal.libsignal.protocol.InvalidKeyException;
+import org.signal.libsignal.protocol.ecc.Curve;
+import org.signal.libsignal.protocol.ecc.ECKeyPair;
+import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
+
+public class TestDevice {
+
+ private final long deviceId;
+
+ private final Map> signedPreKeys = new ConcurrentHashMap<>();
+
+
+ public static TestDevice create(
+ final long deviceId,
+ final IdentityKeyPair aciIdentityKeyPair,
+ final IdentityKeyPair pniIdentityKeyPair) {
+ final TestDevice device = new TestDevice(deviceId);
+ device.addSignedPreKey(aciIdentityKeyPair);
+ device.addSignedPreKey(pniIdentityKeyPair);
+ return device;
+ }
+
+ public TestDevice(final long deviceId) {
+ this.deviceId = deviceId;
+ }
+
+ public long deviceId() {
+ return deviceId;
+ }
+
+ public SignedPreKeyRecord latestSignedPreKey(final IdentityKeyPair identity) {
+ final int id = signedPreKeys.entrySet()
+ .stream()
+ .filter(p -> p.getValue().getLeft().equals(identity))
+ .mapToInt(Map.Entry::getKey)
+ .max()
+ .orElseThrow();
+ return signedPreKeys.get(id).getRight();
+ }
+
+ public SignedPreKeyRecord addSignedPreKey(final IdentityKeyPair identity) {
+ try {
+ final int nextId = signedPreKeys.keySet().stream().mapToInt(k -> k + 1).max().orElse(0);
+ final ECKeyPair keyPair = Curve.generateKeyPair();
+ final byte[] signature = Curve.calculateSignature(identity.getPrivateKey(), keyPair.getPublicKey().serialize());
+ final SignedPreKeyRecord signedPreKeyRecord = new SignedPreKeyRecord(nextId, System.currentTimeMillis(), keyPair, signature);
+ signedPreKeys.put(nextId, Pair.of(identity, signedPreKeyRecord));
+ return signedPreKeyRecord;
+ } catch (InvalidKeyException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/integration-tests/src/main/java/org/signal/integration/TestUser.java b/integration-tests/src/main/java/org/signal/integration/TestUser.java
new file mode 100644
index 000000000..7bbce9462
--- /dev/null
+++ b/integration-tests/src/main/java/org/signal/integration/TestUser.java
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.integration;
+
+import static java.util.Objects.requireNonNull;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import org.apache.commons.lang3.RandomUtils;
+import org.signal.libsignal.protocol.IdentityKey;
+import org.signal.libsignal.protocol.IdentityKeyPair;
+import org.signal.libsignal.protocol.ecc.ECPublicKey;
+import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
+import org.signal.libsignal.protocol.util.KeyHelper;
+import org.whispersystems.textsecuregcm.entities.AccountAttributes;
+import org.whispersystems.textsecuregcm.storage.Device;
+
+public class TestUser {
+
+ private final int registrationId;
+
+ private final IdentityKeyPair aciIdentityKey;
+
+ private final Map devices = new ConcurrentHashMap<>();
+
+ private final byte[] unidentifiedAccessKey;
+
+ private String phoneNumber;
+
+ private IdentityKeyPair pniIdentityKey;
+
+ private String accountPassword;
+
+ private byte[] registrationPassword;
+
+ private UUID aciUuid;
+
+ private UUID pniUuid;
+
+
+ public static TestUser create(final String phoneNumber, final String accountPassword, final byte[] registrationPassword) {
+ // ACI identity key pair
+ final IdentityKeyPair aciIdentityKey = IdentityKeyPair.generate();
+ // PNI identity key pair
+ final IdentityKeyPair pniIdentityKey = IdentityKeyPair.generate();
+ // registration id
+ final int registrationId = KeyHelper.generateRegistrationId(false);
+ // uak
+ final byte[] unidentifiedAccessKey = RandomUtils.nextBytes(16);
+
+ return new TestUser(
+ registrationId,
+ aciIdentityKey,
+ phoneNumber,
+ pniIdentityKey,
+ unidentifiedAccessKey,
+ accountPassword,
+ registrationPassword);
+ }
+
+ public TestUser(
+ final int registrationId,
+ final IdentityKeyPair aciIdentityKey,
+ final String phoneNumber,
+ final IdentityKeyPair pniIdentityKey,
+ final byte[] unidentifiedAccessKey,
+ final String accountPassword,
+ final byte[] registrationPassword) {
+ this.registrationId = registrationId;
+ this.aciIdentityKey = aciIdentityKey;
+ this.phoneNumber = phoneNumber;
+ this.pniIdentityKey = pniIdentityKey;
+ this.unidentifiedAccessKey = unidentifiedAccessKey;
+ this.accountPassword = accountPassword;
+ this.registrationPassword = registrationPassword;
+ devices.put(Device.MASTER_ID, TestDevice.create(Device.MASTER_ID, aciIdentityKey, pniIdentityKey));
+ }
+
+ public int registrationId() {
+ return registrationId;
+ }
+
+ public IdentityKeyPair aciIdentityKey() {
+ return aciIdentityKey;
+ }
+
+ public String phoneNumber() {
+ return phoneNumber;
+ }
+
+ public IdentityKeyPair pniIdentityKey() {
+ return pniIdentityKey;
+ }
+
+ public String accountPassword() {
+ return accountPassword;
+ }
+
+ public byte[] registrationPassword() {
+ return registrationPassword;
+ }
+
+ public UUID aciUuid() {
+ return aciUuid;
+ }
+
+ public UUID pniUuid() {
+ return pniUuid;
+ }
+
+ public AccountAttributes accountAttributes() {
+ return new AccountAttributes(true, registrationId, "", "", true, new Device.DeviceCapabilities())
+ .withUnidentifiedAccessKey(unidentifiedAccessKey)
+ .withRecoveryPassword(registrationPassword);
+ }
+
+ public void setAciUuid(final UUID aciUuid) {
+ this.aciUuid = aciUuid;
+ }
+
+ public void setPniUuid(final UUID pniUuid) {
+ this.pniUuid = pniUuid;
+ }
+
+ public void setPhoneNumber(final String phoneNumber) {
+ this.phoneNumber = phoneNumber;
+ }
+
+ public void setPniIdentityKey(final IdentityKeyPair pniIdentityKey) {
+ this.pniIdentityKey = pniIdentityKey;
+ }
+
+ public void setAccountPassword(final String accountPassword) {
+ this.accountPassword = accountPassword;
+ }
+
+ public void setRegistrationPassword(final byte[] registrationPassword) {
+ this.registrationPassword = registrationPassword;
+ }
+
+ public PreKeySetPublicView preKeys(final long deviceId, final boolean pni) {
+ final IdentityKeyPair identity = pni
+ ? pniIdentityKey
+ : aciIdentityKey;
+ final TestDevice device = requireNonNull(devices.get(deviceId));
+ final SignedPreKeyRecord signedPreKeyRecord = device.latestSignedPreKey(identity);
+ return new PreKeySetPublicView(
+ Collections.emptyList(),
+ identity.getPublicKey(),
+ new SignedPreKeyPublicView(
+ signedPreKeyRecord.getId(),
+ signedPreKeyRecord.getKeyPair().getPublicKey(),
+ signedPreKeyRecord.getSignature()
+ )
+ );
+ }
+
+ public record SignedPreKeyPublicView(
+ int keyId,
+ @JsonSerialize(using = Codecs.ECPublicKeySerializer.class)
+ @JsonDeserialize(using = Codecs.ECPublicKeyDeserializer.class)
+ ECPublicKey publicKey,
+ @JsonSerialize(using = Codecs.ByteArraySerializer.class)
+ @JsonDeserialize(using = Codecs.ByteArrayDeserializer.class)
+ byte[] signature) {
+ }
+
+ public record PreKeySetPublicView(
+ List preKeys,
+ @JsonSerialize(using = Codecs.IdentityKeySerializer.class)
+ @JsonDeserialize(using = Codecs.IdentityKeyDeserializer.class)
+ IdentityKey identityKey,
+ SignedPreKeyPublicView signedPreKey) {
+ }
+}
diff --git a/integration-tests/src/main/java/org/signal/integration/config/Config.java b/integration-tests/src/main/java/org/signal/integration/config/Config.java
new file mode 100644
index 000000000..6c7b4a77a
--- /dev/null
+++ b/integration-tests/src/main/java/org/signal/integration/config/Config.java
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.integration.config;
+
+import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration;
+
+public record Config(String domain,
+ String rootCert,
+ DynamoDbClientConfiguration dynamoDbClientConfiguration,
+ DynamoDbTables dynamoDbTables) {
+}
diff --git a/integration-tests/src/main/java/org/signal/integration/config/DynamoDbTables.java b/integration-tests/src/main/java/org/signal/integration/config/DynamoDbTables.java
new file mode 100644
index 000000000..9a1843ad2
--- /dev/null
+++ b/integration-tests/src/main/java/org/signal/integration/config/DynamoDbTables.java
@@ -0,0 +1,10 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.integration.config;
+
+public record DynamoDbTables(String registrationRecovery,
+ String verificationSessions) {
+}
diff --git a/integration-tests/src/test/java/org/signal/integration/IntegrationTest.java b/integration-tests/src/test/java/org/signal/integration/IntegrationTest.java
new file mode 100644
index 000000000..ca03ec40e
--- /dev/null
+++ b/integration-tests/src/test/java/org/signal/integration/IntegrationTest.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.integration;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
+import org.whispersystems.textsecuregcm.entities.CreateVerificationSessionRequest;
+import org.whispersystems.textsecuregcm.entities.IncomingMessage;
+import org.whispersystems.textsecuregcm.entities.IncomingMessageList;
+import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
+import org.whispersystems.textsecuregcm.entities.SendMessageResponse;
+import org.whispersystems.textsecuregcm.entities.SubmitVerificationCodeRequest;
+import org.whispersystems.textsecuregcm.entities.UpdateVerificationSessionRequest;
+import org.whispersystems.textsecuregcm.entities.VerificationCodeRequest;
+import org.whispersystems.textsecuregcm.entities.VerificationSessionResponse;
+import org.whispersystems.textsecuregcm.storage.Device;
+
+public class IntegrationTest {
+
+ @Test
+ public void testCreateAccount() throws Exception {
+ final TestUser user = Operations.newRegisteredUser("+19995550101");
+ try {
+ final Pair execute = Operations.apiGet("/v1/accounts/whoami")
+ .authorized(user)
+ .execute(AccountIdentityResponse.class);
+ assertEquals(200, execute.getLeft());
+ } finally {
+ Operations.deleteUser(user);
+ }
+ }
+
+ @Test
+ public void testRegistration() throws Exception {
+ final UpdateVerificationSessionRequest originalRequest = new UpdateVerificationSessionRequest(
+ "test", UpdateVerificationSessionRequest.PushTokenType.FCM, null, null, null, null);
+ final CreateVerificationSessionRequest input = new CreateVerificationSessionRequest("+19995550102", originalRequest);
+
+ final VerificationSessionResponse verificationSessionResponse = Operations
+ .apiPost("/v1/verification/session", input)
+ .executeExpectSuccess(VerificationSessionResponse.class);
+ System.out.println("session created: " + verificationSessionResponse);
+
+ final String sessionId = verificationSessionResponse.id();
+ final String pushChallenge = Operations.peekVerificationSessionPushChallenge(sessionId);
+
+ // supply push challenge
+ final UpdateVerificationSessionRequest updatedRequest = new UpdateVerificationSessionRequest(
+ "test", UpdateVerificationSessionRequest.PushTokenType.FCM, pushChallenge, null, null, null);
+ final VerificationSessionResponse pushChallengeSupplied = Operations
+ .apiPatch("/v1/verification/session/%s".formatted(sessionId), updatedRequest)
+ .executeExpectSuccess(VerificationSessionResponse.class);
+ System.out.println("push challenge supplied: " + pushChallengeSupplied);
+
+ Assertions.assertTrue(pushChallengeSupplied.allowedToRequestCode());
+
+ // request code
+ final VerificationCodeRequest verificationCodeRequest = new VerificationCodeRequest(
+ VerificationCodeRequest.Transport.SMS, "android-ng");
+
+ final VerificationSessionResponse codeRequested = Operations
+ .apiPost("/v1/verification/session/%s/code".formatted(sessionId), verificationCodeRequest)
+ .executeExpectSuccess(VerificationSessionResponse.class);
+ System.out.println("code requested: " + codeRequested);
+
+ // verify code
+ final SubmitVerificationCodeRequest submitVerificationCodeRequest = new SubmitVerificationCodeRequest("265402");
+ final VerificationSessionResponse codeVerified = Operations
+ .apiPut("/v1/verification/session/%s/code".formatted(sessionId), submitVerificationCodeRequest)
+ .executeExpectSuccess(VerificationSessionResponse.class);
+ System.out.println("sms code supplied: " + codeVerified);
+ }
+
+ @Test
+ public void testSendMessageUnsealed() throws Exception {
+ final TestUser userA = Operations.newRegisteredUser("+19995550102");
+ final TestUser userB = Operations.newRegisteredUser("+19995550103");
+
+ try {
+ final byte[] expectedContent = "Hello, World!".getBytes(StandardCharsets.UTF_8);
+ final String contentBase64 = Base64.getEncoder().encodeToString(expectedContent);
+ final IncomingMessage message = new IncomingMessage(1, Device.MASTER_ID, userB.registrationId(), contentBase64);
+ final IncomingMessageList messages = new IncomingMessageList(List.of(message), false, true, System.currentTimeMillis());
+
+ System.out.println("Sending message");
+ final Pair sendMessage = Operations
+ .apiPut("/v1/messages/%s".formatted(userB.aciUuid().toString()), messages)
+ .authorized(userA)
+ .execute(SendMessageResponse.class);
+ System.out.println("Message sent: " + sendMessage);
+
+ System.out.println("Receive message");
+ final Pair receiveMessages = Operations.apiGet("/v1/messages")
+ .authorized(userB)
+ .execute(OutgoingMessageEntityList.class);
+ System.out.println("Message received: " + receiveMessages);
+
+ final byte[] actualContent = receiveMessages.getRight().messages().get(0).content();
+ assertArrayEquals(expectedContent, actualContent);
+ } finally {
+ Operations.deleteUser(userA);
+ Operations.deleteUser(userB);
+ }
+ }
+}
diff --git a/pom.xml b/pom.xml
index cd43163be..9d87799a8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -32,6 +32,7 @@
api-doc
event-logger
+ integration-tests
redis-dispatch
service
websocket-resources
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateVerificationSessionRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateVerificationSessionRequest.java
index 82eedb58e..e3011c8af 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateVerificationSessionRequest.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateVerificationSessionRequest.java
@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
+import com.google.common.annotations.VisibleForTesting;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import org.whispersystems.textsecuregcm.util.E164;
@@ -24,6 +25,15 @@ public final class CreateVerificationSessionRequest {
@JsonUnwrapped
private UpdateVerificationSessionRequest updateVerificationSessionRequest;
+ public CreateVerificationSessionRequest() {
+ }
+
+ @VisibleForTesting
+ public CreateVerificationSessionRequest(final String number, final UpdateVerificationSessionRequest updateVerificationSessionRequest) {
+ this.number = number;
+ this.updateVerificationSessionRequest = updateVerificationSessionRequest;
+ }
+
public String getNumber() {
return number;
}