From ba3102d66786107c7f09c37856ee2d3fc87296b1 Mon Sep 17 00:00:00 2001 From: Moxie Marlinspike Date: Wed, 9 Oct 2019 11:30:01 -0700 Subject: [PATCH] Support for versioned profiles Includes support for issuing zkgroup auth credentials --- service/pom.xml | 5 + .../WhisperServerConfiguration.java | 10 + .../textsecuregcm/WhisperServerService.java | 29 +- .../textsecuregcm/configuration/ZkConfig.java | 31 ++ .../controllers/CertificateController.java | 36 ++- .../controllers/ProfileController.java | 296 +++++++++++++----- .../entities/CreateProfileRequest.java | 55 ++++ .../entities/GroupCredentials.java | 72 +++++ .../textsecuregcm/entities/Profile.java | 13 +- .../ProfileAvatarUploadAttributes.java | 4 + .../entities/ProfileKeyCommitmentAdapter.java | 37 +++ .../ProfileKeyCredentialResponseAdapter.java | 40 +++ .../textsecuregcm/storage/Profiles.java | 71 +++++ .../storage/ProfilesManager.java | 82 +++++ .../storage/VersionedProfile.java | 49 +++ .../mappers/VersionedProfileMapper.java | 20 ++ .../textsecuregcm/util/Util.java | 11 + .../workers/ZkParamsCommand.java | 32 ++ service/src/main/resources/accountsdb.xml | 34 ++ .../CertificateControllerTest.java | 87 ++++- .../controllers/ProfileControllerTest.java | 162 +++++++++- .../tests/storage/ProfilesManagerTest.java | 107 +++++++ .../tests/storage/ProfilesTest.java | 130 ++++++++ 23 files changed, 1315 insertions(+), 98 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/configuration/ZkConfig.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateProfileRequest.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCommitmentAdapter.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCredentialResponseAdapter.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/Profiles.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/ProfilesManager.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/VersionedProfile.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/mappers/VersionedProfileMapper.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/workers/ZkParamsCommand.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/ProfilesManagerTest.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/ProfilesTest.java 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(); + } + + +}