From a553093046e65fda54820b1ce9d008c657206c13 Mon Sep 17 00:00:00 2001 From: Sergey Skrobotov Date: Thu, 13 Apr 2023 10:44:00 -0700 Subject: [PATCH] integration tests initial setup --- .github/workflows/integration-tests.yml | 30 ++ integration-tests/.gitignore | 2 + integration-tests/pom.xml | 54 ++++ .../java/org/signal/integration/Codecs.java | 102 +++++++ .../signal/integration/IntegrationTools.java | 69 +++++ .../org/signal/integration/Operations.java | 274 ++++++++++++++++++ .../org/signal/integration/TestDevice.java | 64 ++++ .../java/org/signal/integration/TestUser.java | 183 ++++++++++++ .../org/signal/integration/config/Config.java | 14 + .../integration/config/DynamoDbTables.java | 10 + .../signal/integration/IntegrationTest.java | 116 ++++++++ pom.xml | 1 + .../CreateVerificationSessionRequest.java | 10 + 13 files changed, 929 insertions(+) create mode 100644 .github/workflows/integration-tests.yml create mode 100644 integration-tests/.gitignore create mode 100644 integration-tests/pom.xml create mode 100644 integration-tests/src/main/java/org/signal/integration/Codecs.java create mode 100644 integration-tests/src/main/java/org/signal/integration/IntegrationTools.java create mode 100644 integration-tests/src/main/java/org/signal/integration/Operations.java create mode 100644 integration-tests/src/main/java/org/signal/integration/TestDevice.java create mode 100644 integration-tests/src/main/java/org/signal/integration/TestUser.java create mode 100644 integration-tests/src/main/java/org/signal/integration/config/Config.java create mode 100644 integration-tests/src/main/java/org/signal/integration/config/DynamoDbTables.java create mode 100644 integration-tests/src/test/java/org/signal/integration/IntegrationTest.java 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; }