Support for signature token based account verification.

This commit is contained in:
Moxie Marlinspike 2014-11-13 14:25:33 -08:00
parent 8f2722263f
commit 222c7ea641
7 changed files with 284 additions and 38 deletions

View File

@ -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;
}
}

View File

@ -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<WhisperServerConfiguration
environment.lifecycle().manage(apnSender);
environment.lifecycle().manage(gcmSender);
AccountAuthenticator deviceAuthenticator = new AccountAuthenticator(accountsManager);
RateLimiters rateLimiters = new RateLimiters(config.getLimitsConfiguration(), memcachedClient);
AccountAuthenticator deviceAuthenticator = new AccountAuthenticator(accountsManager);
RateLimiters rateLimiters = new RateLimiters(config.getLimitsConfiguration(), memcachedClient);
TwilioSmsSender twilioSmsSender = new TwilioSmsSender(config.getTwilioConfiguration());
Optional<NexmoSmsSender> 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> 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<byte[]> 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<WhisperServerConfiguration
deviceAuthenticator,
Device.class, "WhisperServer"));
environment.jersey().register(new AccountController(pendingAccountsManager, accountsManager, rateLimiters, smsSender, storedMessages));
environment.jersey().register(new AccountController(pendingAccountsManager, accountsManager, rateLimiters, smsSender, storedMessages, new TimeProvider(), authorizationKey));
environment.jersey().register(new DeviceController(pendingDevicesManager, accountsManager, rateLimiters));
environment.jersey().register(new DirectoryController(rateLimiters, directory));
environment.jersey().register(new FederationControllerV1(accountsManager, attachmentController, messageController, keysControllerV1));

View File

@ -0,0 +1,76 @@
package org.whispersystems.textsecuregcm.auth;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.util.Util;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.TimeUnit;
public class AuthorizationToken {
private final Logger logger = LoggerFactory.getLogger(AuthorizationToken.class);
private final String token;
private final byte[] key;
public AuthorizationToken(String token, byte[] key) {
this.token = token;
this.key = key;
}
public boolean isValid(String number, long currentTimeMillis) {
String[] parts = token.split(":");
if (parts.length != 3) {
return false;
}
if (!number.equals(parts[0])) {
return false;
}
if (!isValidTime(parts[1], currentTimeMillis)) {
return false;
}
return isValidSignature(parts[0] + ":" + parts[1], parts[2]);
}
private boolean isValidTime(String timeString, long currentTimeMillis) {
try {
long tokenTime = Long.parseLong(timeString);
long ourTime = TimeUnit.MILLISECONDS.toSeconds(currentTimeMillis);
return TimeUnit.SECONDS.toHours(Math.abs(ourTime - tokenTime)) < 24;
} catch (NumberFormatException e) {
logger.warn("Number Format", e);
return false;
}
}
private boolean isValidSignature(String prefix, String suffix) {
try {
Mac hmac = Mac.getInstance("HmacSHA256");
hmac.init(new SecretKeySpec(key, "HmacSHA256"));
byte[] ourSuffix = Util.truncate(hmac.doFinal(prefix.getBytes()), 10);
byte[] theirSuffix = Hex.decodeHex(suffix.toCharArray());
return MessageDigest.isEqual(ourSuffix, theirSuffix);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new AssertionError(e);
} catch (DecoderException e) {
logger.warn("Authorizationtoken", e);
return false;
}
}
}

View File

@ -0,0 +1,20 @@
package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.base.Optional;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
public class RedPhoneConfiguration {
@JsonProperty
private String authKey;
public Optional<byte[]> getAuthorizationKey() throws DecoderException {
if (authKey == null || authKey.trim().length() == 0) {
return Optional.absent();
}
return Optional.of(Hex.decodeHex(authKey.toCharArray()));
}
}

View File

@ -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<byte[]> authorizationKey;
public AccountController(PendingAccountsManager pendingAccounts,
AccountsManager accounts,
RateLimiters rateLimiters,
SmsSender smsSenderFactory,
StoredMessages storedMessages)
StoredMessages storedMessages,
TimeProvider timeProvider,
Optional<byte[]> 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");

View File

@ -0,0 +1,7 @@
package org.whispersystems.textsecuregcm.providers;
public class TimeProvider {
public long getCurrentTimeMillis() {
return System.currentTimeMillis();
}
}

View File

@ -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);
}
}
}