From 6ce686ab9cdec99b25945c4aa8ab669db465884d Mon Sep 17 00:00:00 2001 From: Moxie Marlinspike Date: Thu, 6 Dec 2018 11:59:43 -0800 Subject: [PATCH] Transparent data controller --- .../WhisperServerConfiguration.java | 6 + .../textsecuregcm/WhisperServerService.java | 2 + .../TransparentDataController.java | 42 ++++++ .../textsecuregcm/storage/Account.java | 4 +- .../textsecuregcm/storage/Accounts.java | 2 +- .../storage/AccountsManager.java | 10 +- .../textsecuregcm/storage/PublicAccount.java | 18 +++ .../TransparentDataControllerTest.java | 129 ++++++++++++++++++ .../tests/storage/PublicAccountTest.java | 38 ++++++ .../textsecuregcm/tests/util/JsonHelpers.java | 4 +- .../fixtures/transparent_account.json | 1 + .../fixtures/transparent_account2.json | 1 + 12 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/whispersystems/textsecuregcm/controllers/TransparentDataController.java create mode 100644 src/main/java/org/whispersystems/textsecuregcm/storage/PublicAccount.java create mode 100644 src/test/java/org/whispersystems/textsecuregcm/tests/controllers/TransparentDataControllerTest.java create mode 100644 src/test/java/org/whispersystems/textsecuregcm/tests/storage/PublicAccountTest.java create mode 100644 src/test/resources/fixtures/transparent_account.json create mode 100644 src/test/resources/fixtures/transparent_account2.json diff --git a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index baa4c6d19..5d97983c7 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -141,6 +141,8 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private VoiceVerificationConfiguration voiceVerification; + private Map transparentDataIndex = new HashMap<>(); + public VoiceVerificationConfiguration getVoiceVerificationConfiguration() { return voiceVerification; @@ -244,4 +246,8 @@ public class WhisperServerConfiguration extends Configuration { return results; } + public Map getTransparentDataIndex() { + return transparentDataIndex; + } + } diff --git a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 851e41e85..aeb9f0487 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -39,6 +39,7 @@ import org.whispersystems.textsecuregcm.controllers.MessageController; import org.whispersystems.textsecuregcm.controllers.ProfileController; import org.whispersystems.textsecuregcm.controllers.ProvisioningController; import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController; +import org.whispersystems.textsecuregcm.controllers.TransparentDataController; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.liquibase.NameableMigrationsBundle; import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper; @@ -225,6 +226,7 @@ public class WhisperServerService extends Application transparentDataIndex; + + public TransparentDataController(AccountsManager accountsManager, + Map transparentDataIndex) + { + this.accountsManager = accountsManager; + this.transparentDataIndex = transparentDataIndex; + } + + @GET + @Path("/account/{id}") + @Produces(MediaType.APPLICATION_JSON) + public Optional getAccount(@PathParam("id") String id) { + String index = transparentDataIndex.get(id); + + if (index != null) { + return accountsManager.get(index).map(PublicAccount::new); + } + + return Optional.empty(); + } + + +} diff --git a/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java b/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java index 901c06b37..28888a5cb 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java +++ b/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java @@ -30,9 +30,9 @@ import java.util.concurrent.TimeUnit; public class Account implements Principal { - public static final int MEMCACHE_VERION = 5; + static final int MEMCACHE_VERION = 5; - @JsonProperty + @JsonIgnore private String number; @JsonProperty diff --git a/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java b/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java index 9180782e4..183a1ce21 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java +++ b/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java @@ -111,7 +111,7 @@ public abstract class Accounts { { try { Account account = mapper.readValue(resultSet.getString(DATA), Account.class); -// account.setId(resultSet.getLong(ID)); + account.setNumber(resultSet.getString(NUMBER)); return account; } catch (IOException e) { diff --git a/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java b/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java index fb577ac47..3d6819fd4 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java +++ b/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java @@ -136,8 +136,14 @@ public class AccountsManager { { String json = jedis.get(getKey(number)); - if (json != null) return Optional.of(mapper.readValue(json, Account.class)); - else return Optional.empty(); + if (json != null) { + Account account = mapper.readValue(json, Account.class); + account.setNumber(number); + + return Optional.of(account); + } + + return Optional.empty(); } catch (IOException e) { logger.warn("AccountsManager", "Deserialization error", e); return Optional.empty(); diff --git a/src/main/java/org/whispersystems/textsecuregcm/storage/PublicAccount.java b/src/main/java/org/whispersystems/textsecuregcm/storage/PublicAccount.java new file mode 100644 index 000000000..83514e408 --- /dev/null +++ b/src/main/java/org/whispersystems/textsecuregcm/storage/PublicAccount.java @@ -0,0 +1,18 @@ +package org.whispersystems.textsecuregcm.storage; + +public class PublicAccount extends Account { + + public PublicAccount() {} + + public PublicAccount(Account account) { + setIdentityKey(account.getIdentityKey()); + setUnidentifiedAccessKey(account.getUnidentifiedAccessKey().orElse(null)); + setUnrestrictedUnidentifiedAccess(account.isUnrestrictedUnidentifiedAccess()); + setAvatar(account.getAvatar()); + setProfileName(account.getProfileName()); + setPin("******"); + + account.getDevices().forEach(this::addDevice); + } + +} diff --git a/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/TransparentDataControllerTest.java b/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/TransparentDataControllerTest.java new file mode 100644 index 000000000..034d19c96 --- /dev/null +++ b/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/TransparentDataControllerTest.java @@ -0,0 +1,129 @@ +package org.whispersystems.textsecuregcm.tests.controllers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.hamcrest.MatcherAssert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.whispersystems.textsecuregcm.controllers.TransparentDataController; +import org.whispersystems.textsecuregcm.entities.SignedPreKey; +import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.PublicAccount; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import org.whispersystems.textsecuregcm.util.SystemMapper; + +import javax.ws.rs.core.Response; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import io.dropwizard.auth.AuthValueFactoryProvider; +import io.dropwizard.testing.junit.ResourceTestRule; +import static junit.framework.TestCase.*; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.asJson; +import static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.jsonFixture; + +public class TransparentDataControllerTest { + + private final AccountsManager accountsManager = mock(AccountsManager.class); + private final Map indexMap = new HashMap<>(); + + @Rule + public final ResourceTestRule resources = ResourceTestRule.builder() + .addProvider(AuthHelper.getAuthFilter()) + .addProvider(new AuthValueFactoryProvider.Binder<>(Account.class)) + .addProvider(new RateLimitExceededExceptionMapper()) + .setMapper(SystemMapper.getMapper()) + .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) + .addResource(new TransparentDataController(accountsManager, indexMap)) + .build(); + + + @Before + public void setup() { + Account accountOne = new Account("+14151231111", Collections.singleton(new Device(1, "foo", "bar", "salt", "keykey", "gcm-id", "apn-id", "voipapn-id", true, 1234, new SignedPreKey(5, "public-signed", "signtture-signed"), 31337, 31336, "CoolClient", true)), new byte[16]); + Account accountTwo = new Account("+14151232222", Collections.singleton(new Device(1, "2foo", "2bar", "2salt", "2keykey", "2gcm-id", "2apn-id", "2voipapn-id", true, 1234, new SignedPreKey(5, "public-signed", "signtture-signed"), 31337, 31336, "CoolClient", true)), new byte[16]); + + accountOne.setProfileName("OneProfileName"); + accountOne.setIdentityKey("identity_key_value"); + accountTwo.setProfileName("TwoProfileName"); + accountTwo.setIdentityKey("different_identity_key_value"); + + + indexMap.put("1", "+14151231111"); + indexMap.put("2", "+14151232222"); + + when(accountsManager.get(eq("+14151231111"))).thenReturn(Optional.of(accountOne)); + when(accountsManager.get(eq("+14151232222"))).thenReturn(Optional.of(accountTwo)); + } + + @Test + public void testAccountOne() throws IOException { + Response response = resources.getJerseyTest() + .target(String.format("/v1/transparency/account/%s", "1")) + .request() + .get(); + + assertEquals(200, response.getStatus()); + + Account result = response.readEntity(PublicAccount.class); + + assertTrue(result.getPin().isPresent()); + assertEquals("******", result.getPin().get()); + assertNull(result.getNumber()); + assertEquals("OneProfileName", result.getProfileName()); + + assertThat("Account serialization works", + asJson(result), + is(equalTo(jsonFixture("fixtures/transparent_account.json")))); + + verify(accountsManager, times(1)).get(eq("+14151231111")); + verifyNoMoreInteractions(accountsManager); + } + + @Test + public void testAccountTwo() throws IOException { + Response response = resources.getJerseyTest() + .target(String.format("/v1/transparency/account/%s", "2")) + .request() + .get(); + + assertEquals(200, response.getStatus()); + + Account result = response.readEntity(PublicAccount.class); + + assertTrue(result.getPin().isPresent()); + assertEquals("******", result.getPin().get()); + assertNull(result.getNumber()); + assertEquals("TwoProfileName", result.getProfileName()); + + assertThat("Account serialization works 2", + asJson(result), + is(equalTo(jsonFixture("fixtures/transparent_account2.json")))); + + verify(accountsManager, times(1)).get(eq("+14151232222")); + } + + @Test + public void testAccountMissing() { + Response response = resources.getJerseyTest() + .target(String.format("/v1/transparency/account/%s", "3")) + .request() + .get(); + + assertEquals(404, response.getStatus()); + verifyNoMoreInteractions(accountsManager); + } + +} diff --git a/src/test/java/org/whispersystems/textsecuregcm/tests/storage/PublicAccountTest.java b/src/test/java/org/whispersystems/textsecuregcm/tests/storage/PublicAccountTest.java new file mode 100644 index 000000000..dd19b9ac7 --- /dev/null +++ b/src/test/java/org/whispersystems/textsecuregcm/tests/storage/PublicAccountTest.java @@ -0,0 +1,38 @@ +package org.whispersystems.textsecuregcm.tests.storage; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; +import org.whispersystems.textsecuregcm.entities.SignedPreKey; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.PublicAccount; +import org.whispersystems.textsecuregcm.util.SystemMapper; + +import java.io.IOException; +import java.util.Collections; +import java.util.Set; + +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertNull; + +public class PublicAccountTest { + + @Test + public void testPinSanitation() throws IOException { + Set devices = Collections.singleton(new Device(1, "foo", "bar", "12345", null, "gcm-1234", null, null, true, 1234, new SignedPreKey(1, "public-foo", "signature-foo"), 31337, 31336, "Android4Life", true)); + Account account = new Account("+14151231234", devices, new byte[16]); + account.setPin("123456"); + + PublicAccount publicAccount = new PublicAccount(account); + + String serialized = SystemMapper.getMapper().writeValueAsString(publicAccount); + JsonNode result = SystemMapper.getMapper().readTree(serialized); + + assertEquals("******", result.get("pin").textValue()); + assertNull(result.get("number")); + } + + +} diff --git a/src/test/java/org/whispersystems/textsecuregcm/tests/util/JsonHelpers.java b/src/test/java/org/whispersystems/textsecuregcm/tests/util/JsonHelpers.java index fd3bded24..0d6d870e7 100644 --- a/src/test/java/org/whispersystems/textsecuregcm/tests/util/JsonHelpers.java +++ b/src/test/java/org/whispersystems/textsecuregcm/tests/util/JsonHelpers.java @@ -5,13 +5,15 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.whispersystems.textsecuregcm.util.SystemMapper; + import java.io.IOException; import static io.dropwizard.testing.FixtureHelpers.fixture; public class JsonHelpers { - private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final ObjectMapper objectMapper = SystemMapper.getMapper(); public static String asJson(Object object) throws JsonProcessingException { return objectMapper.writeValueAsString(object); diff --git a/src/test/resources/fixtures/transparent_account.json b/src/test/resources/fixtures/transparent_account.json new file mode 100644 index 000000000..a0c4db3f4 --- /dev/null +++ b/src/test/resources/fixtures/transparent_account.json @@ -0,0 +1 @@ +{"devices":[{"id":1,"name":"foo","authToken":"bar","salt":"salt","signalingKey":"keykey","gcmId":"gcm-id","apnId":"apn-id","voipApnId":"voipapn-id","pushTimestamp":0,"fetchesMessages":true,"registrationId":1234,"signedPreKey":{"keyId":5,"publicKey":"public-signed","signature":"signtture-signed"},"lastSeen":31337,"created":31336,"userAgent":"CoolClient","unauthenticatedDelivery":true}],"identityKey":"identity_key_value","name":"OneProfileName","avatar":null,"avatarDigest":null,"pin":"******","uak":"AAAAAAAAAAAAAAAAAAAAAA==","uua":false} diff --git a/src/test/resources/fixtures/transparent_account2.json b/src/test/resources/fixtures/transparent_account2.json new file mode 100644 index 000000000..6d496c095 --- /dev/null +++ b/src/test/resources/fixtures/transparent_account2.json @@ -0,0 +1 @@ +{"devices":[{"id":1,"name":"2foo","authToken":"2bar","salt":"2salt","signalingKey":"2keykey","gcmId":"2gcm-id","apnId":"2apn-id","voipApnId":"2voipapn-id","pushTimestamp":0,"fetchesMessages":true,"registrationId":1234,"signedPreKey":{"keyId":5,"publicKey":"public-signed","signature":"signtture-signed"},"lastSeen":31337,"created":31336,"userAgent":"CoolClient","unauthenticatedDelivery":true}],"identityKey":"different_identity_key_value","name":"TwoProfileName","avatar":null,"avatarDigest":null,"pin":"******","uak":"AAAAAAAAAAAAAAAAAAAAAA==","uua":false}