diff --git a/service/pom.xml b/service/pom.xml
index 4f7ff852c..0c65356da 100644
--- a/service/pom.xml
+++ b/service/pom.xml
@@ -38,6 +38,11 @@
+
+ org.signal
+ zkgroup-java
+ 0.1
+
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
index bad875ecd..6604fbfcb 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
@@ -166,6 +166,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private SecureBackupServiceConfiguration backupService;
+ @Valid
+ @NotNull
+ @JsonProperty
+ private ZkConfig zkConfig;
+
private Map transparentDataIndex = new HashMap<>();
public RecaptchaConfiguration getRecaptchaConfiguration() {
@@ -289,4 +294,9 @@ public class WhisperServerConfiguration extends Configuration {
public SecureBackupServiceConfiguration getSecureBackupServiceConfiguration() {
return backupService;
}
+
+ public ZkConfig getZkConfig() {
+ return zkConfig;
+ }
+
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
index 7a1bdbf57..0fd59a500 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
@@ -16,6 +16,12 @@
*/
package org.whispersystems.textsecuregcm;
+import com.amazonaws.auth.AWSCredentials;
+import com.amazonaws.auth.AWSCredentialsProvider;
+import com.amazonaws.auth.AWSStaticCredentialsProvider;
+import com.amazonaws.auth.BasicAWSCredentials;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.AmazonS3Client;
import com.codahale.metrics.SharedMetricRegistries;
import com.codahale.metrics.jdbi3.strategies.DefaultNameStrategy;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
@@ -26,6 +32,9 @@ import com.google.common.collect.ImmutableSet;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.eclipse.jetty.servlets.CrossOriginFilter;
import org.jdbi.v3.core.Jdbi;
+import org.signal.zkgroup.ServerSecretParams;
+import org.signal.zkgroup.auth.ServerZkAuthOperations;
+import org.signal.zkgroup.profiles.ServerZkProfileOperations;
import org.whispersystems.dispatch.DispatchManager;
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
@@ -55,6 +64,8 @@ import org.whispersystems.textsecuregcm.push.ReceiptSender;
import org.whispersystems.textsecuregcm.push.WebsocketSender;
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool;
+import org.whispersystems.textsecuregcm.s3.PolicySigner;
+import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.sms.SmsSender;
import org.whispersystems.textsecuregcm.sms.TwilioSmsSender;
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
@@ -67,6 +78,7 @@ import org.whispersystems.textsecuregcm.websocket.WebSocketAccountAuthenticator;
import org.whispersystems.textsecuregcm.workers.CertificateCommand;
import org.whispersystems.textsecuregcm.workers.DeleteUserCommand;
import org.whispersystems.textsecuregcm.workers.VacuumCommand;
+import org.whispersystems.textsecuregcm.workers.ZkParamsCommand;
import org.whispersystems.websocket.WebSocketResourceProviderFactory;
import org.whispersystems.websocket.setup.WebSocketEnvironment;
@@ -102,6 +114,7 @@ public class WhisperServerService extends Application("keysdb", "keysdb.xml") {
@Override
public DataSourceFactory getDataSourceFactory(WhisperServerConfiguration configuration) {
@@ -162,6 +175,7 @@ public class WhisperServerService extends Application accountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(accountAuthenticator).buildAuthFilter ();
@@ -249,7 +274,7 @@ public class WhisperServerService extends Application endRedemptionTime) throw new WebApplicationException(Response.Status.BAD_REQUEST);
+ if (endRedemptionTime > Util.currentDaysSinceEpoch() + 7) throw new WebApplicationException(Response.Status.BAD_REQUEST);
+ if (startRedemptionTime < Util.currentDaysSinceEpoch()) throw new WebApplicationException(Response.Status.BAD_REQUEST);
+
+ List credentials = new LinkedList<>();
+
+ for (int i=startRedemptionTime;i<=endRedemptionTime;i++) {
+ credentials.add(new GroupCredentials.GroupCredential(serverZkAuthOperations.issueAuthCredential(account.getUuid(), i)
+ .serialize(),
+ i));
+ }
+
+ return new GroupCredentials(credentials);
+ }
+
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java
index 1cbcd6238..9f5cd8ada 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java
@@ -1,20 +1,25 @@
package org.whispersystems.textsecuregcm.controllers;
-import com.amazonaws.auth.AWSCredentials;
-import com.amazonaws.auth.AWSCredentialsProvider;
-import com.amazonaws.auth.AWSStaticCredentialsProvider;
-import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
-import com.amazonaws.services.s3.AmazonS3Client;
import com.codahale.metrics.annotation.Timed;
+import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Base64;
+import org.apache.commons.codec.binary.Hex;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.valuehandling.UnwrapValidatedValue;
+import org.signal.zkgroup.InvalidInputException;
+import org.signal.zkgroup.VerificationFailedException;
+import org.signal.zkgroup.profiles.ProfileKeyCommitment;
+import org.signal.zkgroup.profiles.ProfileKeyCredentialRequest;
+import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse;
+import org.signal.zkgroup.profiles.ServerZkProfileOperations;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AmbiguousIdentifier;
import org.whispersystems.textsecuregcm.auth.Anonymous;
import org.whispersystems.textsecuregcm.auth.OptionalAccess;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
-import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
+import org.whispersystems.textsecuregcm.entities.CreateProfileRequest;
import org.whispersystems.textsecuregcm.entities.Profile;
import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes;
import org.whispersystems.textsecuregcm.entities.UserCapabilities;
@@ -23,10 +28,14 @@ import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
+import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.UsernamesManager;
import org.whispersystems.textsecuregcm.util.ExactlySize;
+import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.util.Pair;
+import javax.validation.Valid;
+import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.PUT;
@@ -49,41 +58,212 @@ import io.dropwizard.auth.Auth;
@Path("/v1/profile")
public class ProfileController {
+ private final Logger logger = LoggerFactory.getLogger(ProfileController.class);
+
private final RateLimiters rateLimiters;
+ private final ProfilesManager profilesManager;
private final AccountsManager accountsManager;
private final UsernamesManager usernamesManager;
- private final PolicySigner policySigner;
- private final PostPolicyGenerator policyGenerator;
+ private final PolicySigner policySigner;
+ private final PostPolicyGenerator policyGenerator;
+ private final ServerZkProfileOperations zkProfileOperations;
private final AmazonS3 s3client;
private final String bucket;
public ProfileController(RateLimiters rateLimiters,
AccountsManager accountsManager,
+ ProfilesManager profilesManager,
UsernamesManager usernamesManager,
- CdnConfiguration profilesConfiguration)
+ AmazonS3 s3client,
+ PostPolicyGenerator policyGenerator,
+ PolicySigner policySigner,
+ String bucket,
+ ServerZkProfileOperations zkProfileOperations)
{
- AWSCredentials credentials = new BasicAWSCredentials(profilesConfiguration.getAccessKey(), profilesConfiguration.getAccessSecret());
- AWSCredentialsProvider credentialsProvider = new AWSStaticCredentialsProvider(credentials);
-
- this.rateLimiters = rateLimiters;
- this.accountsManager = accountsManager;
- this.usernamesManager = usernamesManager;
- this.bucket = profilesConfiguration.getBucket();
- this.s3client = AmazonS3Client.builder()
- .withCredentials(credentialsProvider)
- .withRegion(profilesConfiguration.getRegion())
- .build();
-
- this.policyGenerator = new PostPolicyGenerator(profilesConfiguration.getRegion(),
- profilesConfiguration.getBucket(),
- profilesConfiguration.getAccessKey());
-
- this.policySigner = new PolicySigner(profilesConfiguration.getAccessSecret(),
- profilesConfiguration.getRegion());
+ this.rateLimiters = rateLimiters;
+ this.accountsManager = accountsManager;
+ this.profilesManager = profilesManager;
+ this.usernamesManager = usernamesManager;
+ this.zkProfileOperations = zkProfileOperations;
+ this.bucket = bucket;
+ this.s3client = s3client;
+ this.policyGenerator = policyGenerator;
+ this.policySigner = policySigner;
}
+ @Timed
+ @PUT
+ @Produces(MediaType.APPLICATION_JSON)
+ @Consumes(MediaType.APPLICATION_JSON)
+ public Response setProfile(@Auth Account account, @Valid CreateProfileRequest request) {
+ Optional currentProfile = profilesManager.get(account.getUuid(), request.getVersion());
+ String avatar = request.isAvatar() ? generateAvatarObjectName() : null;
+ Optional response = Optional.empty();
+
+ profilesManager.set(account.getUuid(), new VersionedProfile(request.getVersion(), request.getName(), avatar, request.getCommitment().serialize()));
+
+ if (request.isAvatar()) {
+ Optional currentAvatar = Optional.empty();
+
+ if (currentProfile.isPresent() && currentProfile.get().getAvatar() != null && currentProfile.get().getAvatar().startsWith("profiles/")) {
+ currentAvatar = Optional.of(currentProfile.get().getAvatar());
+ }
+
+ if (currentAvatar.isEmpty() && account.getAvatar() != null && account.getAvatar().startsWith("profiles/")) {
+ currentAvatar = Optional.of(account.getAvatar());
+ }
+
+ currentAvatar.ifPresent(s -> s3client.deleteObject(bucket, s));
+
+ response = Optional.of(generateAvatarUploadForm(avatar));
+ }
+
+ account.setProfileName(request.getName());
+ if (avatar != null) account.setAvatar(avatar);
+ accountsManager.update(account);
+
+ if (response.isPresent()) return Response.ok(response).build();
+ else return Response.ok().build();
+ }
+
+ @Timed
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/{uuid}/{version}")
+ public Optional getProfile(@Auth Optional requestAccount,
+ @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey,
+ @PathParam("uuid") UUID uuid,
+ @PathParam("version") String version)
+ throws RateLimitExceededException
+ {
+ return getVersionedProfile(requestAccount, accessKey, uuid, version, Optional.empty());
+ }
+
+ @Timed
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/{uuid}/{version}/{credentialRequest}")
+ public Optional getProfile(@Auth Optional requestAccount,
+ @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey,
+ @PathParam("uuid") UUID uuid,
+ @PathParam("version") String version,
+ @PathParam("credentialRequest") String credentialRequest)
+ throws RateLimitExceededException
+ {
+ return getVersionedProfile(requestAccount, accessKey, uuid, version, Optional.of(credentialRequest));
+ }
+
+ @SuppressWarnings("OptionalIsPresent")
+ private Optional getVersionedProfile(Optional requestAccount,
+ Optional accessKey,
+ UUID uuid,
+ String version,
+ Optional credentialRequest)
+ throws RateLimitExceededException
+ {
+ try {
+ if (!requestAccount.isPresent() && !accessKey.isPresent()) {
+ throw new WebApplicationException(Response.Status.UNAUTHORIZED);
+ }
+
+ if (requestAccount.isPresent()) {
+ rateLimiters.getProfileLimiter().validate(requestAccount.get().getNumber());
+ }
+
+ Optional accountProfile = accountsManager.get(uuid);
+ OptionalAccess.verify(requestAccount, accessKey, accountProfile);
+
+ assert(accountProfile.isPresent());
+
+ Optional username = usernamesManager.get(accountProfile.get().getUuid());
+ Optional profile = profilesManager.get(uuid, version);
+
+ String name = profile.map(VersionedProfile::getName).orElse(accountProfile.get().getProfileName());
+ String avatar = profile.map(VersionedProfile::getAvatar).orElse(accountProfile.get().getAvatar());
+
+ Optional credential = getProfileCredential(credentialRequest, profile, uuid);
+
+ return Optional.of(new Profile(name,
+ avatar,
+ accountProfile.get().getIdentityKey(),
+ UnidentifiedAccessChecksum.generateFor(accountProfile.get().getUnidentifiedAccessKey()),
+ accountProfile.get().isUnrestrictedUnidentifiedAccess(),
+ new UserCapabilities(accountProfile.get().isUuidAddressingSupported()),
+ username.orElse(null),
+ null, credential.orElse(null)));
+ } catch (InvalidInputException e) {
+ logger.info("Bad profile request", e);
+ throw new WebApplicationException(Response.Status.BAD_REQUEST);
+ }
+ }
+
+
+ @Timed
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/username/{username}")
+ public Profile getProfileByUsername(@Auth Account account, @PathParam("username") String username) throws RateLimitExceededException {
+ rateLimiters.getUsernameLookupLimiter().validate(account.getUuid().toString());
+
+ username = username.toLowerCase();
+
+ Optional uuid = usernamesManager.get(username);
+
+ if (!uuid.isPresent()) {
+ throw new WebApplicationException(Response.status(Response.Status.NOT_FOUND).build());
+ }
+
+ Optional accountProfile = accountsManager.get(uuid.get());
+
+ if (!accountProfile.isPresent()) {
+ throw new WebApplicationException(Response.status(Response.Status.NOT_FOUND).build());
+ }
+
+ return new Profile(accountProfile.get().getProfileName(),
+ accountProfile.get().getAvatar(),
+ accountProfile.get().getIdentityKey(),
+ UnidentifiedAccessChecksum.generateFor(accountProfile.get().getUnidentifiedAccessKey()),
+ accountProfile.get().isUnrestrictedUnidentifiedAccess(),
+ new UserCapabilities(accountProfile.get().isUuidAddressingSupported()),
+ username,
+ accountProfile.get().getUuid(), null);
+ }
+
+ private Optional getProfileCredential(Optional encodedProfileCredentialRequest,
+ Optional profile,
+ UUID uuid)
+ throws InvalidInputException
+ {
+ if (!encodedProfileCredentialRequest.isPresent()) return Optional.empty();
+ if (!profile.isPresent()) return Optional.empty();
+
+ try {
+ ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.get().getCommitment());
+ ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(Hex.decodeHex(encodedProfileCredentialRequest.get()));
+ ProfileKeyCredentialResponse response = zkProfileOperations.issueProfileKeyCredential(request, uuid, commitment);
+
+ return Optional.of(response);
+ } catch (DecoderException | VerificationFailedException e) {
+ throw new WebApplicationException(e, Response.status(Response.Status.BAD_REQUEST).build());
+ }
+ }
+
+
+ // Old profile endpoints. Replaced by versioned profile endpoints (above)
+
+ @Deprecated
+ @Timed
+ @PUT
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/name/{name}")
+ public void setProfile(@Auth Account account, @PathParam("name") @UnwrapValidatedValue(true) @ExactlySize({72, 108}) Optional name) {
+ account.setProfileName(name.orElse(null));
+ accountsManager.update(account);
+ }
+
+ @Deprecated
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
@@ -119,60 +299,19 @@ public class ProfileController {
accountProfile.get().isUnrestrictedUnidentifiedAccess(),
new UserCapabilities(accountProfile.get().isUuidAddressingSupported()),
username.orElse(null),
- null);
- }
-
- @Timed
- @GET
- @Produces(MediaType.APPLICATION_JSON)
- @Path("/username/{username}")
- public Profile getProfileByUsername(@Auth Account account, @PathParam("username") String username) throws RateLimitExceededException {
- rateLimiters.getUsernameLookupLimiter().validate(account.getUuid().toString());
-
- username = username.toLowerCase();
-
- Optional uuid = usernamesManager.get(username);
-
- if (!uuid.isPresent()) {
- throw new WebApplicationException(Response.status(Response.Status.NOT_FOUND).build());
- }
-
- Optional accountProfile = accountsManager.get(uuid.get());
-
- if (!accountProfile.isPresent()) {
- throw new WebApplicationException(Response.status(Response.Status.NOT_FOUND).build());
- }
-
- return new Profile(accountProfile.get().getProfileName(),
- accountProfile.get().getAvatar(),
- accountProfile.get().getIdentityKey(),
- UnidentifiedAccessChecksum.generateFor(accountProfile.get().getUnidentifiedAccessKey()),
- accountProfile.get().isUnrestrictedUnidentifiedAccess(),
- new UserCapabilities(accountProfile.get().isUuidAddressingSupported()),
- username,
- accountProfile.get().getUuid());
- }
-
- @Timed
- @PUT
- @Produces(MediaType.APPLICATION_JSON)
- @Path("/name/{name}")
- public void setProfile(@Auth Account account, @PathParam("name") @UnwrapValidatedValue(true) @ExactlySize({72, 108}) Optional name) {
- account.setProfileName(name.orElse(null));
- accountsManager.update(account);
+ null, null);
}
+ @Deprecated
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/form/avatar")
public ProfileAvatarUploadAttributes getAvatarUploadForm(@Auth Account account) {
- String previousAvatar = account.getAvatar();
- ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
- String objectName = generateAvatarObjectName();
- Pair policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
- String signature = policySigner.getSignature(now, policy.second());
+ String previousAvatar = account.getAvatar();
+ String objectName = generateAvatarObjectName();
+ ProfileAvatarUploadAttributes profileAvatarUploadAttributes = generateAvatarUploadForm(objectName);
if (previousAvatar != null && previousAvatar.startsWith("profiles/")) {
s3client.deleteObject(bucket, previousAvatar);
@@ -181,8 +320,19 @@ public class ProfileController {
account.setAvatar(objectName);
accountsManager.update(account);
+ return profileAvatarUploadAttributes;
+ }
+
+ ////
+
+ private ProfileAvatarUploadAttributes generateAvatarUploadForm(String objectName) {
+ ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
+ Pair policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
+ String signature = policySigner.getSignature(now, policy.second());
+
return new ProfileAvatarUploadAttributes(objectName, policy.first(), "private", "AWS4-HMAC-SHA256",
now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
+
}
private String generateAvatarObjectName() {
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateProfileRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateProfileRequest.java
new file mode 100644
index 000000000..386e25903
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateProfileRequest.java
@@ -0,0 +1,55 @@
+package org.whispersystems.textsecuregcm.entities;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import org.hibernate.validator.constraints.NotEmpty;
+import org.signal.zkgroup.profiles.ProfileKeyCommitment;
+import org.whispersystems.textsecuregcm.util.ExactlySize;
+
+import javax.validation.constraints.NotNull;
+
+public class CreateProfileRequest {
+
+ @JsonProperty
+ @NotEmpty
+ private String version;
+
+ @JsonProperty
+ @ExactlySize({108})
+ private String name;
+
+ @JsonProperty
+ private boolean avatar;
+
+ @JsonProperty
+ @NotNull
+ @JsonDeserialize(using = ProfileKeyCommitmentAdapter.Deserializing.class)
+ @JsonSerialize(using = ProfileKeyCommitmentAdapter.Serializing.class)
+ private ProfileKeyCommitment commitment;
+
+ public CreateProfileRequest() {}
+
+ public CreateProfileRequest(ProfileKeyCommitment commitment, String version, String name, boolean wantsAvatar) {
+ this.commitment = commitment;
+ this.version = version;
+ this.name = name;
+ this.avatar = wantsAvatar;
+ }
+
+ public ProfileKeyCommitment getCommitment() {
+ return commitment;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public boolean isAvatar() {
+ return avatar;
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java
new file mode 100644
index 000000000..cfff4c127
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java
@@ -0,0 +1,72 @@
+package org.whispersystems.textsecuregcm.entities;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+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 com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import org.whispersystems.textsecuregcm.util.Base64;
+
+import java.io.IOException;
+import java.util.List;
+
+public class GroupCredentials {
+
+ @JsonProperty
+ private List credentials;
+
+ public GroupCredentials() {}
+
+ public GroupCredentials(List credentials) {
+ this.credentials = credentials;
+ }
+
+ public List getCredentials() {
+ return credentials;
+ }
+
+ public static class GroupCredential {
+
+ @JsonProperty
+ @JsonSerialize(using = ByteArraySerializer.class)
+ @JsonDeserialize(using = ByteArrayDeserializer.class)
+ private byte[] credential;
+
+ @JsonProperty
+ private int redemptionTime;
+
+ public GroupCredential() {}
+
+ public GroupCredential(byte[] credential, int redemptionTime) {
+ this.credential = credential;
+ this.redemptionTime = redemptionTime;
+ }
+
+ public byte[] getCredential() {
+ return credential;
+ }
+
+ public int getRedemptionTime() {
+ return redemptionTime;
+ }
+ }
+
+ public static class ByteArraySerializer extends JsonSerializer {
+ @Override
+ public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
+ jsonGenerator.writeString(Base64.encodeBytes(bytes));
+ }
+ }
+
+ public static class ByteArrayDeserializer extends JsonDeserializer {
+ @Override
+ public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
+ return Base64.decode(jsonParser.getValueAsString());
+ }
+ }
+
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/Profile.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/Profile.java
index b6597e9f5..cf6acdf31 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/Profile.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/Profile.java
@@ -1,8 +1,12 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.google.common.annotations.VisibleForTesting;
+import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse;
+
import java.util.UUID;
public class Profile {
@@ -31,11 +35,17 @@ public class Profile {
@JsonProperty
private UUID uuid;
+ @JsonProperty
+ @JsonSerialize(using = ProfileKeyCredentialResponseAdapter.Serializing.class)
+ @JsonDeserialize(using = ProfileKeyCredentialResponseAdapter.Deserializing.class)
+ private ProfileKeyCredentialResponse credential;
+
public Profile() {}
public Profile(String name, String avatar, String identityKey,
String unidentifiedAccess, boolean unrestrictedUnidentifiedAccess,
- UserCapabilities capabilities, String username, UUID uuid)
+ UserCapabilities capabilities, String username, UUID uuid,
+ ProfileKeyCredentialResponse credential)
{
this.name = name;
this.avatar = avatar;
@@ -45,6 +55,7 @@ public class Profile {
this.capabilities = capabilities;
this.username = username;
this.uuid = uuid;
+ this.credential = credential;
}
@VisibleForTesting
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileAvatarUploadAttributes.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileAvatarUploadAttributes.java
index 12ed0651e..e47b1fae2 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileAvatarUploadAttributes.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileAvatarUploadAttributes.java
@@ -41,4 +41,8 @@ public class ProfileAvatarUploadAttributes {
this.signature = signature;
}
+ public String getKey() {
+ return key;
+ }
+
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCommitmentAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCommitmentAdapter.java
new file mode 100644
index 000000000..7f62652fe
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCommitmentAdapter.java
@@ -0,0 +1,37 @@
+package org.whispersystems.textsecuregcm.entities;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+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 org.signal.zkgroup.InvalidInputException;
+import org.signal.zkgroup.profiles.ProfileKeyCommitment;
+import org.whispersystems.textsecuregcm.util.Base64;
+
+import java.io.IOException;
+
+public class ProfileKeyCommitmentAdapter {
+
+ public static class Serializing extends JsonSerializer {
+ @Override
+ public void serialize(ProfileKeyCommitment value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
+ gen.writeString(Base64.encodeBytes(value.serialize()));
+ }
+ }
+
+ public static class Deserializing extends JsonDeserializer {
+
+ @Override
+ public ProfileKeyCommitment deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
+ try {
+ return new ProfileKeyCommitment(Base64.decode(p.getValueAsString()));
+ } catch (InvalidInputException e) {
+ throw new IOException(e);
+ }
+ }
+ }
+}
+
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCredentialResponseAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCredentialResponseAdapter.java
new file mode 100644
index 000000000..e57bc524c
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCredentialResponseAdapter.java
@@ -0,0 +1,40 @@
+package org.whispersystems.textsecuregcm.entities;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+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 org.signal.zkgroup.InvalidInputException;
+import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse;
+import org.whispersystems.textsecuregcm.util.Base64;
+
+import java.io.IOException;
+
+public class ProfileKeyCredentialResponseAdapter {
+
+ public static class Serializing extends JsonSerializer {
+ @Override
+ public void serialize(ProfileKeyCredentialResponse response, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
+ throws IOException, JsonProcessingException
+ {
+ if (response == null) jsonGenerator.writeNull();
+ else jsonGenerator.writeString(Base64.encodeBytes(response.serialize()));
+ }
+ }
+
+ public static class Deserializing extends JsonDeserializer {
+ @Override
+ public ProfileKeyCredentialResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext)
+ throws IOException, JsonProcessingException
+ {
+ try {
+ return new ProfileKeyCredentialResponse(Base64.decode(jsonParser.getValueAsString()));
+ } catch (InvalidInputException e) {
+ throw new IOException(e);
+ }
+ }
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Profiles.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Profiles.java
new file mode 100644
index 000000000..a3020da5e
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Profiles.java
@@ -0,0 +1,71 @@
+package org.whispersystems.textsecuregcm.storage;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.SharedMetricRegistries;
+import com.codahale.metrics.Timer;
+import org.whispersystems.textsecuregcm.storage.mappers.VersionedProfileMapper;
+import org.whispersystems.textsecuregcm.util.Constants;
+
+import java.util.Optional;
+import java.util.UUID;
+
+import static com.codahale.metrics.MetricRegistry.name;
+
+public class Profiles {
+
+ public static final String ID = "id";
+ public static final String UID = "uuid";
+ public static final String VERSION = "version";
+ public static final String NAME = "name";
+ public static final String AVATAR = "avatar";
+ public static final String COMMITMENT = "commitment";
+
+ private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
+
+ private final Timer setTimer = metricRegistry.timer(name(Profiles.class, "set" ));
+ private final Timer getTimer = metricRegistry.timer(name(Profiles.class, "get" ));
+ private final Timer deleteTimer = metricRegistry.timer(name(Profiles.class, "delete"));
+
+ private final FaultTolerantDatabase database;
+
+ public Profiles(FaultTolerantDatabase database) {
+ this.database = database;
+ this.database.getDatabase().registerRowMapper(new VersionedProfileMapper());
+ }
+
+ public void set(UUID uuid, VersionedProfile profile) {
+ database.use(jdbi -> jdbi.useHandle(handle -> {
+ try (Timer.Context ignored = setTimer.time()) {
+ handle.createUpdate("INSERT INTO profiles (" + UID + ", " + VERSION + ", " + NAME + ", " + AVATAR + ", " + COMMITMENT + ") VALUES (:uuid, :version, :name, :avatar, :commitment) ON CONFLICT (" + UID + ", " + VERSION + ") DO UPDATE SET " + NAME + " = EXCLUDED." + NAME + ", " + AVATAR + " = EXCLUDED." + AVATAR)
+ .bind("uuid", uuid)
+ .bind("version", profile.getVersion())
+ .bind("name", profile.getName())
+ .bind("avatar", profile.getAvatar())
+ .bind("commitment", profile.getCommitment())
+ .execute();
+ }
+ }));
+ }
+
+ public Optional get(UUID uuid, String version) {
+ return database.with(jdbi -> jdbi.withHandle(handle -> {
+ try (Timer.Context ignored = getTimer.time()) {
+ return handle.createQuery("SELECT * FROM profiles WHERE " + UID + " = :uuid AND " + VERSION + " = :version")
+ .bind("uuid", uuid)
+ .bind("version", version)
+ .mapTo(VersionedProfile.class)
+ .findFirst();
+ }
+ }));
+ }
+
+ public void deleteAll(UUID uuid) {
+ database.use(jdbi -> jdbi.useHandle(handle -> {
+ try (Timer.Context ignored = deleteTimer.time()) {
+ handle.createUpdate("DELETE FROM profiles WHERE " + UID + " = :uuid")
+ .bind("uuid", uuid)
+ .execute();
+ }
+ }));
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ProfilesManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ProfilesManager.java
new file mode 100644
index 000000000..5245d78b9
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ProfilesManager.java
@@ -0,0 +1,82 @@
+package org.whispersystems.textsecuregcm.storage;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool;
+import org.whispersystems.textsecuregcm.util.SystemMapper;
+
+import java.io.IOException;
+import java.util.Optional;
+import java.util.UUID;
+
+import redis.clients.jedis.Jedis;
+import redis.clients.jedis.exceptions.JedisException;
+
+public class ProfilesManager {
+
+ private final Logger logger = LoggerFactory.getLogger(PendingAccountsManager.class);
+
+ private static final String CACHE_PREFIX = "profiles::";
+
+ private final Profiles profiles;
+ private final ReplicatedJedisPool cacheClient;
+ private final ObjectMapper mapper;
+
+ public ProfilesManager(Profiles profiles, ReplicatedJedisPool cacheClient) {
+ this.profiles = profiles;
+ this.cacheClient = cacheClient;
+ this.mapper = SystemMapper.getMapper();
+ }
+
+ public void set(UUID uuid, VersionedProfile versionedProfile) {
+ memcacheSet(uuid, versionedProfile);
+ profiles.set(uuid, versionedProfile);
+ }
+
+ public void deleteAll(UUID uuid) {
+ memcacheDelete(uuid);
+ profiles.deleteAll(uuid);
+ }
+
+ public Optional get(UUID uuid, String version) {
+ Optional profile = memcacheGet(uuid, version);
+
+ if (!profile.isPresent()) {
+ profile = profiles.get(uuid, version);
+ profile.ifPresent(versionedProfile -> memcacheSet(uuid, versionedProfile));
+ }
+
+ return profile;
+ }
+
+ private void memcacheSet(UUID uuid, VersionedProfile profile) {
+ try (Jedis jedis = cacheClient.getWriteResource()) {
+ jedis.hset(CACHE_PREFIX + uuid.toString(), profile.getVersion(), mapper.writeValueAsString(profile));
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+
+ private Optional memcacheGet(UUID uuid, String version) {
+ try (Jedis jedis = cacheClient.getReadResource()) {
+ String json = jedis.hget(CACHE_PREFIX + uuid.toString(), version);
+
+ if (json == null) return Optional.empty();
+ else return Optional.of(mapper.readValue(json, VersionedProfile.class));
+ } catch (IOException e) {
+ logger.warn("Error deserializing value...", e);
+ return Optional.empty();
+ } catch (JedisException e) {
+ logger.warn("Redis exception", e);
+ return Optional.empty();
+ }
+ }
+
+ private void memcacheDelete(UUID uuid) {
+ try (Jedis jedis = cacheClient.getWriteResource()) {
+ jedis.del(CACHE_PREFIX + uuid.toString());
+ }
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/VersionedProfile.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/VersionedProfile.java
new file mode 100644
index 000000000..2a9fd3be4
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/VersionedProfile.java
@@ -0,0 +1,49 @@
+package org.whispersystems.textsecuregcm.storage;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import org.whispersystems.textsecuregcm.entities.DeliveryCertificate;
+import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
+
+public class VersionedProfile {
+
+ @JsonProperty
+ private String version;
+
+ @JsonProperty
+ private String name;
+
+ @JsonProperty
+ private String avatar;
+
+ @JsonProperty
+ @JsonSerialize(using = ByteArrayAdapter.Serializing.class)
+ @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)
+ private byte[] commitment;
+
+ public VersionedProfile() {}
+
+ public VersionedProfile(String version, String name, String avatar, byte[] commitment) {
+ this.version = version;
+ this.name = name;
+ this.avatar = avatar;
+ this.commitment = commitment;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getAvatar() {
+ return avatar;
+ }
+
+ public byte[] getCommitment() {
+ return commitment;
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/mappers/VersionedProfileMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/mappers/VersionedProfileMapper.java
new file mode 100644
index 000000000..579c8d413
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/mappers/VersionedProfileMapper.java
@@ -0,0 +1,20 @@
+package org.whispersystems.textsecuregcm.storage.mappers;
+
+import org.jdbi.v3.core.mapper.RowMapper;
+import org.jdbi.v3.core.statement.StatementContext;
+import org.whispersystems.textsecuregcm.storage.Profiles;
+import org.whispersystems.textsecuregcm.storage.VersionedProfile;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+public class VersionedProfileMapper implements RowMapper {
+
+ @Override
+ public VersionedProfile map(ResultSet resultSet, StatementContext ctx) throws SQLException {
+ return new VersionedProfile(resultSet.getString(Profiles.VERSION),
+ resultSet.getString(Profiles.NAME),
+ resultSet.getString(Profiles.AVATAR),
+ resultSet.getBytes(Profiles.COMMITMENT));
+ }
+}
\ No newline at end of file
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java
index 6f63c1f86..71939f2f1 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java
@@ -144,6 +144,17 @@ public class Util {
return data;
}
+ public static int toIntExact(long value) {
+ if ((int)value != value) {
+ throw new ArithmeticException("integer overflow");
+ }
+ return (int)value;
+ }
+
+ public static int currentDaysSinceEpoch() {
+ return Util.toIntExact(System.currentTimeMillis() / 1000 / 60 / 60 / 24);
+ }
+
public static void sleep(long i) {
try {
Thread.sleep(i);
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/ZkParamsCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/ZkParamsCommand.java
new file mode 100644
index 000000000..c09c7ce67
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/workers/ZkParamsCommand.java
@@ -0,0 +1,32 @@
+package org.whispersystems.textsecuregcm.workers;
+
+import net.sourceforge.argparse4j.inf.Namespace;
+import net.sourceforge.argparse4j.inf.Subparser;
+import org.signal.zkgroup.ServerPublicParams;
+import org.signal.zkgroup.ServerSecretParams;
+import org.whispersystems.textsecuregcm.util.Base64;
+
+import io.dropwizard.cli.Command;
+import io.dropwizard.setup.Bootstrap;
+
+public class ZkParamsCommand extends Command {
+
+ public ZkParamsCommand() {
+ super("zkparams", "Generates server zkparams");
+ }
+
+ @Override
+ public void configure(Subparser subparser) {
+
+ }
+
+ @Override
+ public void run(Bootstrap> bootstrap, Namespace namespace) throws Exception {
+ ServerSecretParams serverSecretParams = ServerSecretParams.generate();
+ ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams();
+
+ System.out.println("Public: " + Base64.encodeBytesWithoutPadding(serverPublicParams.serialize()));
+ System.out.println("Private: " + Base64.encodeBytesWithoutPadding(serverSecretParams.serialize()));
+ }
+
+}
diff --git a/service/src/main/resources/accountsdb.xml b/service/src/main/resources/accountsdb.xml
index 4ff07f2c2..76d6ef050 100644
--- a/service/src/main/resources/accountsdb.xml
+++ b/service/src/main/resources/accountsdb.xml
@@ -239,4 +239,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CertificateControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CertificateControllerTest.java
index 3b0d9eab2..cdf96326a 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CertificateControllerTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CertificateControllerTest.java
@@ -4,18 +4,27 @@ import com.google.common.collect.ImmutableSet;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.ClassRule;
import org.junit.Test;
+import org.signal.zkgroup.InvalidInputException;
+import org.signal.zkgroup.ServerSecretParams;
+import org.signal.zkgroup.VerificationFailedException;
+import org.signal.zkgroup.auth.AuthCredential;
+import org.signal.zkgroup.auth.AuthCredentialResponse;
+import org.signal.zkgroup.auth.ClientZkAuthOperations;
+import org.signal.zkgroup.auth.ServerZkAuthOperations;
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccount;
import org.whispersystems.textsecuregcm.auth.OptionalAccess;
import org.whispersystems.textsecuregcm.controllers.CertificateController;
import org.whispersystems.textsecuregcm.crypto.Curve;
import org.whispersystems.textsecuregcm.entities.DeliveryCertificate;
+import org.whispersystems.textsecuregcm.entities.GroupCredentials;
import org.whispersystems.textsecuregcm.entities.MessageProtos.SenderCertificate;
import org.whispersystems.textsecuregcm.entities.MessageProtos.ServerCertificate;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.Base64;
import org.whispersystems.textsecuregcm.util.SystemMapper;
+import org.whispersystems.textsecuregcm.util.Util;
import javax.ws.rs.core.Response;
import java.io.IOException;
@@ -24,6 +33,7 @@ import java.util.Arrays;
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit.ResourceTestRule;
import static junit.framework.TestCase.assertTrue;
+import static org.assertj.core.api.Java6Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -35,11 +45,14 @@ public class CertificateControllerTest {
private static final String signingCertificate = "CiUIDBIhBbTz4h1My+tt+vw+TVscgUe/DeHS0W02tPWAWbTO2xc3EkD+go4bJnU0AcnFfbOLKoiBfCzouZtDYMOVi69rE7r4U9cXREEqOkUmU2WJBjykAxWPCcSTmVTYHDw7hkSp/puG";
private static final String signingKey = "ABOxG29xrfq4E7IrW11Eg7+HBbtba9iiS0500YoBjn4=";
- private static CertificateGenerator certificateGenerator;
+ private static ServerSecretParams serverSecretParams = ServerSecretParams.generate();
+ private static CertificateGenerator certificateGenerator;
+ private static ServerZkAuthOperations serverZkAuthOperations;
static {
try {
- certificateGenerator = new CertificateGenerator(Base64.decode(signingCertificate), Curve.decodePrivatePoint(Base64.decode(signingKey)), 1);
+ certificateGenerator = new CertificateGenerator(Base64.decode(signingCertificate), Curve.decodePrivatePoint(Base64.decode(signingKey)), 1);
+ serverZkAuthOperations = new ServerZkAuthOperations(serverSecretParams);
} catch (IOException e) {
throw new AssertionError(e);
}
@@ -52,7 +65,7 @@ public class CertificateControllerTest {
.addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(ImmutableSet.of(Account.class, DisabledPermittedAccount.class)))
.setMapper(SystemMapper.getMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
- .addResource(new CertificateController(certificateGenerator))
+ .addResource(new CertificateController(certificateGenerator, serverZkAuthOperations))
.build();
@@ -150,5 +163,73 @@ public class CertificateControllerTest {
assertEquals(response.getStatus(), 401);
}
+ @Test
+ public void testGetSingleAuthCredential() throws InvalidInputException, VerificationFailedException {
+ GroupCredentials credentials = resources.getJerseyTest()
+ .target("/v1/certificate/group/" + Util.currentDaysSinceEpoch() + "/" + Util.currentDaysSinceEpoch())
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD))
+ .get(GroupCredentials.class);
+
+ assertThat(credentials.getCredentials().size()).isEqualTo(1);
+ assertThat(credentials.getCredentials().get(0).getRedemptionTime()).isEqualTo(Util.currentDaysSinceEpoch());
+
+ ClientZkAuthOperations clientZkAuthOperations = new ClientZkAuthOperations(serverSecretParams.getPublicParams());
+ AuthCredential credential = clientZkAuthOperations.receiveAuthCredential(AuthHelper.VALID_UUID, Util.currentDaysSinceEpoch(), new AuthCredentialResponse(credentials.getCredentials().get(0).getCredential()));
+ }
+
+ @Test
+ public void testGetWeekLongAuthCredentials() throws InvalidInputException, VerificationFailedException {
+ GroupCredentials credentials = resources.getJerseyTest()
+ .target("/v1/certificate/group/" + Util.currentDaysSinceEpoch() + "/" + (Util.currentDaysSinceEpoch() + 7))
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD))
+ .get(GroupCredentials.class);
+
+ assertThat(credentials.getCredentials().size()).isEqualTo(8);
+
+ for (int i=0;i<=7;i++) {
+ assertThat(credentials.getCredentials().get(i).getRedemptionTime()).isEqualTo(Util.currentDaysSinceEpoch() + i);
+
+ ClientZkAuthOperations clientZkAuthOperations = new ClientZkAuthOperations(serverSecretParams.getPublicParams());
+ AuthCredential credential = clientZkAuthOperations.receiveAuthCredential(AuthHelper.VALID_UUID, Util.currentDaysSinceEpoch() + i , new AuthCredentialResponse(credentials.getCredentials().get(i).getCredential()));
+ }
+ }
+
+ @Test
+ public void testTooManyDaysOut() throws InvalidInputException {
+ Response response = resources.getJerseyTest()
+ .target("/v1/certificate/group/" + Util.currentDaysSinceEpoch() + "/" + (Util.currentDaysSinceEpoch() + 8))
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD))
+ .get();
+
+ assertThat(response.getStatus()).isEqualTo(400);
+ }
+
+ @Test
+ public void testBackwardsInTime() throws InvalidInputException {
+ Response response = resources.getJerseyTest()
+ .target("/v1/certificate/group/" + (Util.currentDaysSinceEpoch() - 1) + "/" + (Util.currentDaysSinceEpoch() + 7))
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD))
+ .get();
+
+ assertThat(response.getStatus()).isEqualTo(400);
+ }
+
+ @Test
+ public void testBadAuth() throws InvalidInputException {
+ Response response = resources.getJerseyTest()
+ .target("/v1/certificate/group/" + Util.currentDaysSinceEpoch() + "/" + (Util.currentDaysSinceEpoch() + 7))
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.INVALID_PASSWORD))
+ .get();
+
+ assertThat(response.getStatus()).isEqualTo(401);
+ }
+
+
+
}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java
index 9cc8ddfd2..eaf5179b0 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java
@@ -1,5 +1,7 @@
package org.whispersystems.textsecuregcm.tests.controllers;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.AmazonS3Client;
import com.google.common.collect.ImmutableSet;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.Before;
@@ -8,21 +10,32 @@ import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatcher;
import org.mockito.Mockito;
+import org.signal.zkgroup.InvalidInputException;
+import org.signal.zkgroup.profiles.ProfileKey;
+import org.signal.zkgroup.profiles.ProfileKeyCommitment;
+import org.signal.zkgroup.profiles.ServerZkProfileOperations;
import org.whispersystems.textsecuregcm.auth.AmbiguousIdentifier;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccount;
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
import org.whispersystems.textsecuregcm.controllers.ProfileController;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
+import org.whispersystems.textsecuregcm.entities.CreateProfileRequest;
import org.whispersystems.textsecuregcm.entities.Profile;
+import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
+import org.whispersystems.textsecuregcm.s3.PolicySigner;
+import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
+import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.UsernamesManager;
+import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import javax.ws.rs.client.Entity;
+import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.Optional;
@@ -34,18 +47,17 @@ import static org.mockito.Mockito.*;
public class ProfileControllerTest {
private static AccountsManager accountsManager = mock(AccountsManager.class );
+ private static ProfilesManager profilesManager = mock(ProfilesManager.class);
private static UsernamesManager usernamesManager = mock(UsernamesManager.class);
private static RateLimiters rateLimiters = mock(RateLimiters.class );
private static RateLimiter rateLimiter = mock(RateLimiter.class );
private static RateLimiter usernameRateLimiter = mock(RateLimiter.class );
- private static CdnConfiguration configuration = mock(CdnConfiguration.class);
- static {
- when(configuration.getAccessKey()).thenReturn("accessKey");
- when(configuration.getAccessSecret()).thenReturn("accessSecret");
- when(configuration.getRegion()).thenReturn("us-east-1");
- when(configuration.getBucket()).thenReturn("profile-bucket");
- }
+ private static AmazonS3 s3client = mock(AmazonS3.class);
+ private static PostPolicyGenerator postPolicyGenerator = new PostPolicyGenerator("us-west-1", "profile-bucket", "accessKey");
+ private static PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1");
+ private static ServerZkProfileOperations zkProfileOperations = mock(ServerZkProfileOperations.class);
+
@ClassRule
public static final ResourceTestRule resources = ResourceTestRule.builder()
@@ -55,8 +67,13 @@ public class ProfileControllerTest {
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new ProfileController(rateLimiters,
accountsManager,
+ profilesManager,
usernamesManager,
- configuration))
+ s3client,
+ postPolicyGenerator,
+ policySigner,
+ "profilesBucket",
+ zkProfileOperations))
.build();
@Before
@@ -93,7 +110,14 @@ public class ProfileControllerTest {
when(accountsManager.get(AuthHelper.VALID_NUMBER)).thenReturn(Optional.of(capabilitiesAccount));
when(accountsManager.get(argThat((ArgumentMatcher) identifier -> identifier != null && identifier.hasNumber() && identifier.getNumber().equals(AuthHelper.VALID_NUMBER)))).thenReturn(Optional.of(capabilitiesAccount));
- Mockito.clearInvocations(accountsManager);
+ when(profilesManager.get(eq(AuthHelper.VALID_UUID), eq("someversion"))).thenReturn(Optional.empty());
+ when(profilesManager.get(eq(AuthHelper.VALID_UUID_TWO), eq("validversion"))).thenReturn(Optional.of(new VersionedProfile("validversion", "validname", "profiles/validavatar", "validcommitmnet".getBytes())));
+
+ clearInvocations(rateLimiter);
+ clearInvocations(accountsManager);
+ clearInvocations(usernamesManager);
+ clearInvocations(usernameRateLimiter);
+ clearInvocations(profilesManager);
}
@Test
@@ -111,8 +135,7 @@ public class ProfileControllerTest {
verify(accountsManager, times(1)).get(argThat((ArgumentMatcher) identifier -> identifier != null && identifier.hasUuid() && identifier.getUuid().equals(AuthHelper.VALID_UUID_TWO)));
verify(usernamesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO));
- verify(rateLimiter, times(2)).validate(eq(AuthHelper.VALID_NUMBER));
- reset(rateLimiter);
+ verify(rateLimiter, times(1)).validate(eq(AuthHelper.VALID_NUMBER));
}
@Test
@@ -133,7 +156,6 @@ public class ProfileControllerTest {
verify(accountsManager, times(1)).get(argThat((ArgumentMatcher) identifier -> identifier != null && identifier.hasNumber() && identifier.getNumber().equals(AuthHelper.VALID_NUMBER_TWO)));
verifyNoMoreInteractions(usernamesManager);
verify(rateLimiter, times(1)).validate(eq(AuthHelper.VALID_NUMBER));
- reset(rateLimiter);
}
@Test
@@ -188,7 +210,6 @@ public class ProfileControllerTest {
verify(usernamesManager, times(1)).get(eq("n00bkillerzzzzz"));
verify(usernameRateLimiter, times(1)).validate(eq(AuthHelper.VALID_UUID.toString()));
- reset(usernameRateLimiter);
}
@@ -215,7 +236,7 @@ public class ProfileControllerTest {
}
@Test
- public void testSetProfileName() {
+ public void testSetProfileNameDeprecated() {
Response response = resources.getJerseyTest()
.target("/v1/profile/name/123456789012345678901234567890123456789012345678901234567890123456789012")
.request()
@@ -228,7 +249,7 @@ public class ProfileControllerTest {
}
@Test
- public void testSetProfileNameExtended() {
+ public void testSetProfileNameExtendedDeprecated() {
Response response = resources.getJerseyTest()
.target("/v1/profile/name/123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678")
.request()
@@ -241,7 +262,7 @@ public class ProfileControllerTest {
}
@Test
- public void testSetProfileNameWrongSize() {
+ public void testSetProfileNameWrongSizeDeprecated() {
Response response = resources.getJerseyTest()
.target("/v1/profile/name/1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890")
.request()
@@ -252,4 +273,113 @@ public class ProfileControllerTest {
verifyNoMoreInteractions(accountsManager);
}
+ /////
+
+ @Test
+ public void testSetProfileWantAvatarUpload() throws InvalidInputException {
+ ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment();
+
+ ProfileAvatarUploadAttributes uploadAttributes = resources.getJerseyTest()
+ .target("/v1/profile/")
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD))
+ .put(Entity.entity(new CreateProfileRequest(commitment, "someversion", "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678", true), MediaType.APPLICATION_JSON_TYPE), ProfileAvatarUploadAttributes.class);
+
+ ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);
+
+ verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID), eq("someversion"));
+ verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID), profileArgumentCaptor.capture());
+
+ verifyNoMoreInteractions(s3client);
+
+ assertThat(profileArgumentCaptor.getValue().getCommitment()).isEqualTo(commitment.serialize());
+ assertThat(profileArgumentCaptor.getValue().getAvatar()).isEqualTo(uploadAttributes.getKey());
+ assertThat(profileArgumentCaptor.getValue().getVersion()).isEqualTo("someversion");
+ assertThat(profileArgumentCaptor.getValue().getName()).isEqualTo("123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678");
+ }
+
+ @Test
+ public void testSetProfileWantAvatarUploadWithBadProfileSize() throws InvalidInputException {
+ ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment();
+
+ Response response = resources.getJerseyTest()
+ .target("/v1/profile/")
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD))
+ .put(Entity.entity(new CreateProfileRequest(commitment, "someversion", "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890", true), MediaType.APPLICATION_JSON_TYPE));
+
+ assertThat(response.getStatus()).isEqualTo(422);
+ }
+
+ @Test
+ public void testSetProfileWithoutAvatarUpload() throws InvalidInputException {
+ ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment();
+
+ Response response = resources.getJerseyTest()
+ .target("/v1/profile/")
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER_TWO, AuthHelper.VALID_PASSWORD_TWO))
+ .put(Entity.entity(new CreateProfileRequest(commitment, "anotherversion", "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678", false), MediaType.APPLICATION_JSON_TYPE));
+
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThat(response.hasEntity()).isFalse();
+
+ ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);
+
+ verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq("anotherversion"));
+ verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture());
+
+ verifyNoMoreInteractions(s3client);
+
+ assertThat(profileArgumentCaptor.getValue().getCommitment()).isEqualTo(commitment.serialize());
+ assertThat(profileArgumentCaptor.getValue().getAvatar()).isNull();
+ assertThat(profileArgumentCaptor.getValue().getVersion()).isEqualTo("anotherversion");
+ assertThat(profileArgumentCaptor.getValue().getName()).isEqualTo("123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678");
+ }
+
+ @Test
+ public void testSetProvfileWithAvatarUploadAndPreviousAvatar() throws InvalidInputException {
+ ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment();
+
+ ProfileAvatarUploadAttributes uploadAttributes= resources.getJerseyTest()
+ .target("/v1/profile/")
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER_TWO, AuthHelper.VALID_PASSWORD_TWO))
+ .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678", true), MediaType.APPLICATION_JSON_TYPE), ProfileAvatarUploadAttributes.class);
+
+ ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);
+
+ verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq("validversion"));
+ verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture());
+ verify(s3client, times(1)).deleteObject(eq("profilesBucket"), eq("profiles/validavatar"));
+
+ assertThat(profileArgumentCaptor.getValue().getCommitment()).isEqualTo(commitment.serialize());
+ assertThat(profileArgumentCaptor.getValue().getAvatar()).startsWith("profiles/");
+ assertThat(profileArgumentCaptor.getValue().getVersion()).isEqualTo("validversion");
+ assertThat(profileArgumentCaptor.getValue().getName()).isEqualTo("123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678");
+ }
+
+ @Test
+ public void testGetProfileByVersion() throws RateLimitExceededException {
+ Profile profile = resources.getJerseyTest()
+ .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO + "/validversion")
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD))
+ .get(Profile.class);
+
+ assertThat(profile.getIdentityKey()).isEqualTo("bar");
+ assertThat(profile.getName()).isEqualTo("validname");
+ assertThat(profile.getAvatar()).isEqualTo("profiles/validavatar");
+ assertThat(profile.getCapabilities().isUuid()).isFalse();
+ assertThat(profile.getUsername()).isEqualTo("n00bkiller");
+ assertThat(profile.getUuid()).isNull();;
+
+ verify(accountsManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO));
+ verify(usernamesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO));
+ verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq("validversion"));
+
+ verify(rateLimiter, times(1)).validate(eq(AuthHelper.VALID_NUMBER));
+ }
+
+
}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/ProfilesManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/ProfilesManagerTest.java
new file mode 100644
index 000000000..81b95057b
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/ProfilesManagerTest.java
@@ -0,0 +1,107 @@
+package org.whispersystems.textsecuregcm.tests.storage;
+
+import org.junit.Test;
+import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool;
+import org.whispersystems.textsecuregcm.storage.Profiles;
+import org.whispersystems.textsecuregcm.storage.ProfilesManager;
+import org.whispersystems.textsecuregcm.storage.VersionedProfile;
+import org.whispersystems.textsecuregcm.util.Base64;
+
+import java.util.Optional;
+import java.util.UUID;
+
+import static junit.framework.TestCase.assertSame;
+import static junit.framework.TestCase.assertTrue;
+import static org.assertj.core.api.Java6Assertions.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+import redis.clients.jedis.Jedis;
+import redis.clients.jedis.exceptions.JedisException;
+
+public class ProfilesManagerTest {
+
+ @Test
+ public void testGetProfileInCache() {
+ ReplicatedJedisPool cacheClient = mock(ReplicatedJedisPool.class);
+ Jedis jedis = mock(Jedis.class );
+ Profiles profiles = mock(Profiles.class );
+
+ UUID uuid = UUID.randomUUID();
+
+ when(cacheClient.getReadResource()).thenReturn(jedis);
+ when(jedis.hget(eq("profiles::" + uuid.toString()), eq("someversion"))).thenReturn("{\"version\": \"someversion\", \"name\": \"somename\", \"avatar\": \"someavatar\", \"commitment\":\"" + Base64.encodeBytes("somecommitment".getBytes()) + "\"}");
+
+ ProfilesManager profilesManager = new ProfilesManager(profiles, cacheClient);
+ Optional profile = profilesManager.get(uuid, "someversion");
+
+ assertTrue(profile.isPresent());
+ assertEquals(profile.get().getName(), "somename");
+ assertEquals(profile.get().getAvatar(), "someavatar");
+ assertThat(profile.get().getCommitment()).isEqualTo("somecommitment".getBytes());
+
+ verify(jedis, times(1)).hget(eq("profiles::" + uuid.toString()), eq("someversion"));
+ verify(jedis, times(1)).close();
+ verifyNoMoreInteractions(jedis);
+ verifyNoMoreInteractions(profiles);
+ }
+
+ @Test
+ public void testGetProfileNotInCache() {
+ ReplicatedJedisPool cacheClient = mock(ReplicatedJedisPool.class);
+ Jedis jedis = mock(Jedis.class );
+ Profiles profiles = mock(Profiles.class );
+
+ UUID uuid = UUID.randomUUID();
+ VersionedProfile profile = new VersionedProfile("someversion", "somename", "someavatar", "somecommitment".getBytes());
+
+ when(cacheClient.getReadResource()).thenReturn(jedis);
+ when(cacheClient.getWriteResource()).thenReturn(jedis);
+ when(jedis.hget(eq("profiles::" + uuid.toString()), eq("someversion"))).thenReturn(null);
+ when(profiles.get(eq(uuid), eq("someversion"))).thenReturn(Optional.of(profile));
+
+ ProfilesManager profilesManager = new ProfilesManager(profiles, cacheClient);
+ Optional retrieved = profilesManager.get(uuid, "someversion");
+
+ assertTrue(retrieved.isPresent());
+ assertSame(retrieved.get(), profile);
+
+ verify(jedis, times(1)).hget(eq("profiles::" + uuid.toString()), eq("someversion"));
+ verify(jedis, times(1)).hset(eq("profiles::" + uuid.toString()), eq("someversion"), anyString());
+ verify(jedis, times(2)).close();
+ verifyNoMoreInteractions(jedis);
+
+ verify(profiles, times(1)).get(eq(uuid), eq("someversion"));
+ verifyNoMoreInteractions(profiles);
+ }
+
+ @Test
+ public void testGetProfileBrokenCache() {
+ ReplicatedJedisPool cacheClient = mock(ReplicatedJedisPool.class);
+ Jedis jedis = mock(Jedis.class );
+ Profiles profiles = mock(Profiles.class );
+
+ UUID uuid = UUID.randomUUID();
+ VersionedProfile profile = new VersionedProfile("someversion", "somename", "someavatar", "somecommitment".getBytes());
+
+ when(cacheClient.getReadResource()).thenReturn(jedis);
+ when(cacheClient.getWriteResource()).thenReturn(jedis);
+ when(jedis.hget(eq("profiles::" + uuid.toString()), eq("someversion"))).thenThrow(new JedisException("Connection lost"));
+ when(profiles.get(eq(uuid), eq("someversion"))).thenReturn(Optional.of(profile));
+
+ ProfilesManager profilesManager = new ProfilesManager(profiles, cacheClient);
+ Optional retrieved = profilesManager.get(uuid, "someversion");
+
+ assertTrue(retrieved.isPresent());
+ assertSame(retrieved.get(), profile);
+
+ verify(jedis, times(1)).hget(eq("profiles::" + uuid.toString()), eq("someversion"));
+ verify(jedis, times(1)).hset(eq("profiles::" + uuid.toString()), eq("someversion"), anyString());
+ verify(jedis, times(2)).close();
+ verifyNoMoreInteractions(jedis);
+
+ verify(profiles, times(1)).get(eq(uuid), eq("someversion"));
+ verifyNoMoreInteractions(profiles);
+ }
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/ProfilesTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/ProfilesTest.java
new file mode 100644
index 000000000..f70f97085
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/ProfilesTest.java
@@ -0,0 +1,130 @@
+package org.whispersystems.textsecuregcm.tests.storage;
+
+import com.opentable.db.postgres.embedded.LiquibasePreparer;
+import com.opentable.db.postgres.junit.EmbeddedPostgresRules;
+import com.opentable.db.postgres.junit.PreparedDbRule;
+import org.jdbi.v3.core.Jdbi;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
+import org.whispersystems.textsecuregcm.storage.FaultTolerantDatabase;
+import org.whispersystems.textsecuregcm.storage.Profiles;
+import org.whispersystems.textsecuregcm.storage.VersionedProfile;
+
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+
+public class ProfilesTest {
+
+ @Rule
+ public PreparedDbRule db = EmbeddedPostgresRules.preparedDatabase(LiquibasePreparer.forClasspathLocation("accountsdb.xml"));
+
+ private Profiles profiles;
+
+ @Before
+ public void setupProfilesDao() {
+ FaultTolerantDatabase faultTolerantDatabase = new FaultTolerantDatabase("profilesTest",
+ Jdbi.create(db.getTestDatabase()),
+ new CircuitBreakerConfiguration());
+
+ this.profiles = new Profiles(faultTolerantDatabase);
+ }
+
+ @Test
+ public void testSetGet() {
+ UUID uuid = UUID.randomUUID();
+ VersionedProfile profile = new VersionedProfile("123", "foo", "avatarLocation", "acommitment".getBytes());
+ profiles.set(uuid, profile);
+
+ Optional retrieved = profiles.get(uuid, "123");
+
+ assertThat(retrieved.isPresent()).isTrue();
+ assertThat(retrieved.get().getName()).isEqualTo(profile.getName());
+ assertThat(retrieved.get().getAvatar()).isEqualTo(profile.getAvatar());
+ assertThat(retrieved.get().getCommitment()).isEqualTo(profile.getCommitment());
+ }
+
+ @Test
+ public void testSetReplace() {
+ UUID uuid = UUID.randomUUID();
+ VersionedProfile profile = new VersionedProfile("123", "foo", "avatarLocation", "acommitment".getBytes());
+ profiles.set(uuid, profile);
+
+ Optional retrieved = profiles.get(uuid, "123");
+
+ assertThat(retrieved.isPresent()).isTrue();
+ assertThat(retrieved.get().getName()).isEqualTo(profile.getName());
+ assertThat(retrieved.get().getAvatar()).isEqualTo(profile.getAvatar());
+ assertThat(retrieved.get().getCommitment()).isEqualTo(profile.getCommitment());
+
+ VersionedProfile updated = new VersionedProfile("123", "bar", "baz", "boof".getBytes());
+ profiles.set(uuid, updated);
+
+ retrieved = profiles.get(uuid, "123");
+
+ assertThat(retrieved.isPresent()).isTrue();
+ assertThat(retrieved.get().getName()).isEqualTo(updated.getName());
+ assertThat(retrieved.get().getAvatar()).isEqualTo(updated.getAvatar());
+ assertThat(retrieved.get().getCommitment()).isEqualTo(profile.getCommitment());
+ }
+
+ @Test
+ public void testMultipleVersions() {
+ UUID uuid = UUID.randomUUID();
+ VersionedProfile profileOne = new VersionedProfile("123", "foo", "avatarLocation", "acommitmnet".getBytes());
+ VersionedProfile profileTwo = new VersionedProfile("345", "bar", "baz", "boof".getBytes());
+
+ profiles.set(uuid, profileOne);
+ profiles.set(uuid, profileTwo);
+
+ Optional retrieved = profiles.get(uuid, "123");
+
+ assertThat(retrieved.isPresent()).isTrue();
+ assertThat(retrieved.get().getName()).isEqualTo(profileOne.getName());
+ assertThat(retrieved.get().getAvatar()).isEqualTo(profileOne.getAvatar());
+ assertThat(retrieved.get().getCommitment()).isEqualTo(profileOne.getCommitment());
+
+ retrieved = profiles.get(uuid, "345");
+
+ assertThat(retrieved.isPresent()).isTrue();
+ assertThat(retrieved.get().getName()).isEqualTo(profileTwo.getName());
+ assertThat(retrieved.get().getAvatar()).isEqualTo(profileTwo.getAvatar());
+ assertThat(retrieved.get().getCommitment()).isEqualTo(profileTwo.getCommitment());
+ }
+
+ @Test
+ public void testMissing() {
+ UUID uuid = UUID.randomUUID();
+ VersionedProfile profile = new VersionedProfile("123", "foo", "avatarLocation", "aDigest".getBytes());
+ profiles.set(uuid, profile);
+
+ Optional retrieved = profiles.get(uuid, "888");
+ assertThat(retrieved.isPresent()).isFalse();
+ }
+
+
+ @Test
+ public void testDelete() {
+ UUID uuid = UUID.randomUUID();
+ VersionedProfile profileOne = new VersionedProfile("123", "foo", "avatarLocation", "aDigest".getBytes());
+ VersionedProfile profileTwo = new VersionedProfile("345", "bar", "baz", "boof".getBytes());
+
+ profiles.set(uuid, profileOne);
+ profiles.set(uuid, profileTwo);
+
+ profiles.deleteAll(uuid);
+
+ Optional retrieved = profiles.get(uuid, "123");
+
+ assertThat(retrieved.isPresent()).isFalse();
+
+ retrieved = profiles.get(uuid, "345");
+
+ assertThat(retrieved.isPresent()).isFalse();
+ }
+
+
+}