From 222c7ea641e05a0ca2dea8323eae21b293fcd08e Mon Sep 17 00:00:00 2001 From: Moxie Marlinspike Date: Thu, 13 Nov 2014 14:25:33 -0800 Subject: [PATCH] Support for signature token based account verification. --- .../WhisperServerConfiguration.java | 8 ++ .../textsecuregcm/WhisperServerService.java | 18 +-- .../auth/AuthorizationToken.java | 76 +++++++++++++ .../configuration/RedPhoneConfiguration.java | 20 ++++ .../controllers/AccountController.java | 90 +++++++++++---- .../textsecuregcm/providers/TimeProvider.java | 7 ++ .../controllers/AccountControllerTest.java | 103 ++++++++++++++++-- 7 files changed, 284 insertions(+), 38 deletions(-) create mode 100644 src/main/java/org/whispersystems/textsecuregcm/auth/AuthorizationToken.java create mode 100644 src/main/java/org/whispersystems/textsecuregcm/configuration/RedPhoneConfiguration.java create mode 100644 src/main/java/org/whispersystems/textsecuregcm/providers/TimeProvider.java diff --git a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index b04ad74c4..6245fe67a 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -25,6 +25,7 @@ import org.whispersystems.textsecuregcm.configuration.MemcacheConfiguration; import org.whispersystems.textsecuregcm.configuration.MetricsConfiguration; import org.whispersystems.textsecuregcm.configuration.NexmoConfiguration; import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration; +import org.whispersystems.textsecuregcm.configuration.RedPhoneConfiguration; import org.whispersystems.textsecuregcm.configuration.RedisConfiguration; import org.whispersystems.textsecuregcm.configuration.S3Configuration; import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration; @@ -95,6 +96,9 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private WebsocketConfiguration websocket = new WebsocketConfiguration(); + @JsonProperty + private RedPhoneConfiguration redphone = new RedPhoneConfiguration(); + public WebsocketConfiguration getWebsocketConfiguration() { return websocket; } @@ -146,4 +150,8 @@ public class WhisperServerConfiguration extends Configuration { public MetricsConfiguration getMetricsConfiguration() { return viz; } + + public RedPhoneConfiguration getRedphoneConfiguration() { + return redphone; + } } diff --git a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 58bbc9d55..d15d0f313 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -52,6 +52,7 @@ import org.whispersystems.textsecuregcm.providers.MemcacheHealthCheck; import org.whispersystems.textsecuregcm.providers.MemcachedClientFactory; import org.whispersystems.textsecuregcm.providers.RedisClientFactory; import org.whispersystems.textsecuregcm.providers.RedisHealthCheck; +import org.whispersystems.textsecuregcm.providers.TimeProvider; import org.whispersystems.textsecuregcm.push.APNSender; import org.whispersystems.textsecuregcm.push.GCMSender; import org.whispersystems.textsecuregcm.push.PushSender; @@ -155,14 +156,15 @@ public class WhisperServerService extends Application nexmoSmsSender = initializeNexmoSmsSender(config.getNexmoConfiguration()); - SmsSender smsSender = new SmsSender(twilioSmsSender, nexmoSmsSender, config.getTwilioConfiguration().isInternational()); - UrlSigner urlSigner = new UrlSigner(config.getS3Configuration()); - PushSender pushSender = new PushSender(gcmSender, apnSender, websocketSender); + TwilioSmsSender twilioSmsSender = new TwilioSmsSender(config.getTwilioConfiguration()); + Optional nexmoSmsSender = initializeNexmoSmsSender(config.getNexmoConfiguration()); + SmsSender smsSender = new SmsSender(twilioSmsSender, nexmoSmsSender, config.getTwilioConfiguration().isInternational()); + UrlSigner urlSigner = new UrlSigner(config.getS3Configuration()); + PushSender pushSender = new PushSender(gcmSender, apnSender, websocketSender); + Optional authorizationKey = config.getRedphoneConfiguration().getAuthorizationKey(); AttachmentController attachmentController = new AttachmentController(rateLimiters, federatedClientManager, urlSigner); KeysControllerV1 keysControllerV1 = new KeysControllerV1(rateLimiters, keys, accountsManager, federatedClientManager); @@ -174,7 +176,7 @@ public class WhisperServerService extends Application getAuthorizationKey() throws DecoderException { + if (authKey == null || authKey.trim().length() == 0) { + return Optional.absent(); + } + + return Optional.of(Hex.decodeHex(authKey.toCharArray())); + } +} diff --git a/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java index a63964078..ba45b13c6 100644 --- a/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java +++ b/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java @@ -27,7 +27,9 @@ import org.whispersystems.textsecuregcm.auth.InvalidAuthorizationHeaderException import org.whispersystems.textsecuregcm.entities.AccountAttributes; import org.whispersystems.textsecuregcm.entities.ApnRegistrationId; import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; +import org.whispersystems.textsecuregcm.auth.AuthorizationToken; import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.providers.TimeProvider; import org.whispersystems.textsecuregcm.sms.SmsSender; import org.whispersystems.textsecuregcm.sms.TwilioSmsSender; import org.whispersystems.textsecuregcm.storage.Account; @@ -68,18 +70,24 @@ public class AccountController { private final RateLimiters rateLimiters; private final SmsSender smsSender; private final StoredMessages storedMessages; + private final TimeProvider timeProvider; + private final Optional authorizationKey; public AccountController(PendingAccountsManager pendingAccounts, AccountsManager accounts, RateLimiters rateLimiters, SmsSender smsSenderFactory, - StoredMessages storedMessages) + StoredMessages storedMessages, + TimeProvider timeProvider, + Optional authorizationKey) { - this.pendingAccounts = pendingAccounts; - this.accounts = accounts; - this.rateLimiters = rateLimiters; - this.smsSender = smsSenderFactory; - this.storedMessages = storedMessages; + this.pendingAccounts = pendingAccounts; + this.accounts = accounts; + this.rateLimiters = rateLimiters; + this.smsSender = smsSenderFactory; + this.storedMessages = storedMessages; + this.timeProvider = timeProvider; + this.authorizationKey = authorizationKey; } @Timed @@ -145,30 +153,46 @@ public class AccountController { throw new WebApplicationException(Response.status(417).build()); } - Device device = new Device(); - device.setId(Device.MASTER_ID); - device.setAuthenticationCredentials(new AuthenticationCredentials(password)); - device.setSignalingKey(accountAttributes.getSignalingKey()); - device.setFetchesMessages(accountAttributes.getFetchesMessages()); - device.setRegistrationId(accountAttributes.getRegistrationId()); - - Account account = new Account(); - account.setNumber(number); - account.setSupportsSms(accountAttributes.getSupportsSms()); - account.addDevice(device); - - accounts.create(account); - storedMessages.clear(new WebsocketAddress(number, Device.MASTER_ID)); - pendingAccounts.remove(number); - - logger.debug("Stored device..."); + createAccount(number, password, accountAttributes); } catch (InvalidAuthorizationHeaderException e) { logger.info("Bad Authorization Header", e); throw new WebApplicationException(Response.status(401).build()); } } + @Timed + @PUT + @Consumes(MediaType.APPLICATION_JSON) + @Path("/token/{verification_token}") + public void verifyToken(@PathParam("verification_token") String verificationToken, + @HeaderParam("Authorization") String authorizationHeader, + @Valid AccountAttributes accountAttributes) + throws RateLimitExceededException + { + try { + AuthorizationHeader header = AuthorizationHeader.fromFullHeader(authorizationHeader); + String number = header.getNumber(); + String password = header.getPassword(); + rateLimiters.getVerifyLimiter().validate(number); + + if (!authorizationKey.isPresent()) { + logger.debug("Attempt to authorize with key but not configured..."); + throw new WebApplicationException(Response.status(403).build()); + } + + AuthorizationToken token = new AuthorizationToken(verificationToken, authorizationKey.get()); + + if (!token.isValid(number, timeProvider.getCurrentTimeMillis())) { + throw new WebApplicationException(Response.status(403).build()); + } + + createAccount(number, password, accountAttributes); + } catch (InvalidAuthorizationHeaderException e) { + logger.info("Bad authorization header", e); + throw new WebApplicationException(Response.status(401).build()); + } + } @Timed @PUT @@ -219,6 +243,26 @@ public class AccountController { encodedVerificationText)).build(); } + private void createAccount(String number, String password, AccountAttributes accountAttributes) { + Device device = new Device(); + device.setId(Device.MASTER_ID); + device.setAuthenticationCredentials(new AuthenticationCredentials(password)); + device.setSignalingKey(accountAttributes.getSignalingKey()); + device.setFetchesMessages(accountAttributes.getFetchesMessages()); + device.setRegistrationId(accountAttributes.getRegistrationId()); + + Account account = new Account(); + account.setNumber(number); + account.setSupportsSms(accountAttributes.getSupportsSms()); + account.addDevice(device); + + accounts.create(account); + storedMessages.clear(new WebsocketAddress(number, Device.MASTER_ID)); + pendingAccounts.remove(number); + + logger.debug("Stored device..."); + } + @VisibleForTesting protected VerificationCode generateVerificationCode() { try { SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); diff --git a/src/main/java/org/whispersystems/textsecuregcm/providers/TimeProvider.java b/src/main/java/org/whispersystems/textsecuregcm/providers/TimeProvider.java new file mode 100644 index 000000000..5fabf5cc7 --- /dev/null +++ b/src/main/java/org/whispersystems/textsecuregcm/providers/TimeProvider.java @@ -0,0 +1,7 @@ +package org.whispersystems.textsecuregcm.providers; + +public class TimeProvider { + public long getCurrentTimeMillis() { + return System.currentTimeMillis(); + } +} diff --git a/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java b/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java index 819808c8f..060f83757 100644 --- a/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java +++ b/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java @@ -2,6 +2,8 @@ package org.whispersystems.textsecuregcm.tests.controllers; import com.google.common.base.Optional; import com.sun.jersey.api.client.ClientResponse; +import org.apache.commons.codec.DecoderException; +import org.apache.commons.codec.binary.Hex; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -9,6 +11,7 @@ import org.whispersystems.textsecuregcm.controllers.AccountController; import org.whispersystems.textsecuregcm.entities.AccountAttributes; import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.providers.TimeProvider; import org.whispersystems.textsecuregcm.sms.SmsSender; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; @@ -27,12 +30,14 @@ public class AccountControllerTest { private static final String SENDER = "+14152222222"; - private PendingAccountsManager pendingAccountsManager = mock(PendingAccountsManager.class); - private AccountsManager accountsManager = mock(AccountsManager.class ); - private RateLimiters rateLimiters = mock(RateLimiters.class ); - private RateLimiter rateLimiter = mock(RateLimiter.class ); - private SmsSender smsSender = mock(SmsSender.class ); - private StoredMessages storedMessages = mock(StoredMessages.class ); + private PendingAccountsManager pendingAccountsManager = mock(PendingAccountsManager.class); + private AccountsManager accountsManager = mock(AccountsManager.class ); + private RateLimiters rateLimiters = mock(RateLimiters.class ); + private RateLimiter rateLimiter = mock(RateLimiter.class ); + private SmsSender smsSender = mock(SmsSender.class ); + private StoredMessages storedMessages = mock(StoredMessages.class ); + private TimeProvider timeProvider = mock(TimeProvider.class ); + private static byte[] authorizationKey = decodeHex("3a078586eea8971155f5c1ebd73c8c923cbec1c3ed22a54722e4e88321dc749f"); @Rule public final ResourceTestRule resources = ResourceTestRule.builder() @@ -41,7 +46,9 @@ public class AccountControllerTest { accountsManager, rateLimiters, smsSender, - storedMessages)) + storedMessages, + timeProvider, + Optional.of(authorizationKey))) .build(); @@ -51,6 +58,8 @@ public class AccountControllerTest { when(rateLimiters.getVoiceDestinationLimiter()).thenReturn(rateLimiter); when(rateLimiters.getVerifyLimiter()).thenReturn(rateLimiter); + when(timeProvider.getCurrentTimeMillis()).thenReturn(System.currentTimeMillis()); + when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.of("1234")); } @@ -93,4 +102,84 @@ public class AccountControllerTest { verifyNoMoreInteractions(accountsManager); } + @Test + public void testVerifyToken() throws Exception { + when(timeProvider.getCurrentTimeMillis()).thenReturn(1415917053106L); + + String token = SENDER + ":1415906573:af4f046107c21721224a"; + + ClientResponse response = + resources.client().resource(String.format("/v1/accounts/token/%s", token)) + .header("Authorization", AuthHelper.getAuthHeader(SENDER, "bar")) + .entity(new AccountAttributes("keykeykeykey", false, false, 4444)) + .type(MediaType.APPLICATION_JSON_TYPE) + .put(ClientResponse.class); + + assertThat(response.getStatus()).isEqualTo(204); + + verify(accountsManager, times(1)).create(isA(Account.class)); + } + + @Test + public void testVerifyBadToken() throws Exception { + when(timeProvider.getCurrentTimeMillis()).thenReturn(1415917053106L); + + String token = SENDER + ":1415906574:af4f046107c21721224a"; + + ClientResponse response = + resources.client().resource(String.format("/v1/accounts/token/%s", token)) + .header("Authorization", AuthHelper.getAuthHeader(SENDER, "bar")) + .entity(new AccountAttributes("keykeykeykey", false, false, 4444)) + .type(MediaType.APPLICATION_JSON_TYPE) + .put(ClientResponse.class); + + assertThat(response.getStatus()).isEqualTo(403); + + verifyNoMoreInteractions(accountsManager); + } + + @Test + public void testVerifyWrongToken() throws Exception { + when(timeProvider.getCurrentTimeMillis()).thenReturn(1415917053106L); + + String token = SENDER + ":1415906573:af4f046107c21721224a"; + + ClientResponse response = + resources.client().resource(String.format("/v1/accounts/token/%s", token)) + .header("Authorization", AuthHelper.getAuthHeader("+14151111111", "bar")) + .entity(new AccountAttributes("keykeykeykey", false, false, 4444)) + .type(MediaType.APPLICATION_JSON_TYPE) + .put(ClientResponse.class); + + assertThat(response.getStatus()).isEqualTo(403); + + verifyNoMoreInteractions(accountsManager); + } + + @Test + public void testVerifyExpiredToken() throws Exception { + when(timeProvider.getCurrentTimeMillis()).thenReturn(1416003757901L); + + String token = SENDER + ":1415906573:af4f046107c21721224a"; + + ClientResponse response = + resources.client().resource(String.format("/v1/accounts/token/%s", token)) + .header("Authorization", AuthHelper.getAuthHeader(SENDER, "bar")) + .entity(new AccountAttributes("keykeykeykey", false, false, 4444)) + .type(MediaType.APPLICATION_JSON_TYPE) + .put(ClientResponse.class); + + assertThat(response.getStatus()).isEqualTo(403); + + verifyNoMoreInteractions(accountsManager); + } + + private static byte[] decodeHex(String hex) { + try { + return Hex.decodeHex(hex.toCharArray()); + } catch (DecoderException e) { + throw new AssertionError(e); + } + } + } \ No newline at end of file