Skip to content

Commit

Permalink
Verify GroupUser may modify attributes when creating avatar form
Browse files Browse the repository at this point in the history
  • Loading branch information
eager-signal committed Dec 13, 2024
1 parent 2e06a67 commit 09ca73b
Show file tree
Hide file tree
Showing 3 changed files with 191 additions and 50 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -341,24 +341,40 @@ private static DistributionSummary distributionSummary(final String name, final
@GET
@Produces(ProtocolBufferMediaType.APPLICATION_PROTOBUF)
@Path("/avatar/form")
public AvatarUploadAttributes getAvatarUploadForm(@Auth GroupUser user) {
byte[] object = new byte[16];
new SecureRandom().nextBytes(object);

String objectName = "groups/" + Base64.encodeBase64URLSafeString(user.getGroupId().toByteArray()) + "/" + Base64.encodeBase64URLSafeString(object);
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
Pair<String, String> policy = policyGenerator.createFor(now, objectName, 3 * 1024 * 1024);
String signature = policySigner.getSignature(now, policy.second());

return AvatarUploadAttributes.newBuilder()
.setKey(objectName)
.setCredential(policy.first())
.setAcl("private")
.setAlgorithm("AWS4-HMAC-SHA256")
.setDate(now.format(PostPolicyGenerator.AWS_DATE_TIME))
.setPolicy(policy.second())
.setSignature(signature)
.build();
public CompletableFuture<Response> getAvatarUploadForm(@Auth GroupUser user) {

return groupsManager.getGroup(user.getGroupId()).thenApply(group -> {

if (group.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND).build();
}

if (!GroupAuth.isModifyAttributesAllowed(user, group.get())) {
return Response.status(Response.Status.FORBIDDEN).build();
}

final byte[] object = new byte[16];
new SecureRandom().nextBytes(object);

final String objectName = "groups/"
+ Base64.encodeBase64URLSafeString(user.getGroupId().toByteArray())
+ "/"
+ Base64.encodeBase64URLSafeString(object);
final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
final Pair<String, String> policy = policyGenerator.createFor(now, objectName, 3 * 1024 * 1024);
final String signature = policySigner.getSignature(now, policy.second());

return Response.ok(AvatarUploadAttributes.newBuilder()
.setKey(objectName)
.setCredential(policy.first())
.setAcl("private")
.setAlgorithm("AWS4-HMAC-SHA256")
.setDate(now.format(PostPolicyGenerator.AWS_DATE_TIME))
.setPolicy(policy.second())
.setSignature(signature)
.build())
.build();
});
}

@Timed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
package org.signal.storageservice.controllers;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
Expand All @@ -24,7 +23,6 @@
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
Expand All @@ -33,7 +31,6 @@
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;

import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Response;
import org.apache.http.HttpHeaders;
Expand All @@ -45,16 +42,14 @@
import org.mockito.ArgumentCaptor;
import org.signal.libsignal.protocol.ServiceId;
import org.signal.libsignal.zkgroup.NotarySignature;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPni;
import org.signal.libsignal.zkgroup.auth.ClientZkAuthOperations;
import org.signal.libsignal.zkgroup.groups.GroupPublicParams;
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
import org.signal.libsignal.zkgroup.groups.UuidCiphertext;
import org.signal.libsignal.zkgroup.groupsend.GroupSendDerivedKeyPair;
import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsement;
import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsementsResponse;
import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;
import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsementsResponse.ReceivedEndorsements;
import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialPresentation;
import org.signal.storageservice.providers.ProtocolBufferMediaType;
Expand Down Expand Up @@ -3851,27 +3846,90 @@ void testGetGroupJoinedAtVersion() throws IOException {
verifyNoMoreInteractions(groupsManager);
}

@Test
void testGetAvatarUpload() throws IOException {
@ParameterizedTest
@MethodSource
void testGetAvatarUpload(AccessControl.AccessRequired accessRequired, boolean isMemberAdmin, int expectedMemberStatusCode) throws IOException {
GroupSecretParams groupSecretParams = GroupSecretParams.generate();
GroupPublicParams groupPublicParams = groupSecretParams.getPublicParams();

ProfileKeyCredentialPresentation validUserPresentation = new ClientZkProfileOperations(
AuthHelper.GROUPS_SERVER_KEY.getPublicParams()).createProfileKeyCredentialPresentation(groupSecretParams,
AuthHelper.VALID_USER_PROFILE_CREDENTIAL);

ProfileKeyCredentialPresentation validAdminPresentation = new ClientZkProfileOperations(
AuthHelper.GROUPS_SERVER_KEY.getPublicParams()).createProfileKeyCredentialPresentation(groupSecretParams,
AuthHelper.VALID_USER_TWO_PROFILE_CREDENTIAL);

AuthCredentialWithPni userAuthCredential = isMemberAdmin
? AuthHelper.VALID_USER_TWO_AUTH_CREDENTIAL
: AuthHelper.VALID_USER_AUTH_CREDENTIAL;

Group group = Group.newBuilder()
.setPublicKey(ByteString.copyFrom(groupPublicParams.serialize()))
.setAccessControl(AccessControl.newBuilder()
.setMembers(accessRequired)
.setAttributes(accessRequired))
.setTitle(ByteString.copyFromUtf8("Some title"))
.setAvatar(avatarFor(groupPublicParams.getGroupIdentifier().serialize()))
.setVersion(7)
.addMembers(Member.newBuilder()
.setUserId(ByteString.copyFrom(validUserPresentation.getUuidCiphertext().serialize()))
.setProfileKey(ByteString.copyFrom(validUserPresentation.getProfileKeyCiphertext().serialize()))
.setRole(Member.Role.DEFAULT)
.setJoinedAtVersion(2)
.build())
.addMembers(Member.newBuilder()
.setUserId(ByteString.copyFrom(validAdminPresentation.getUuidCiphertext().serialize()))
.setProfileKey(ByteString.copyFrom(validAdminPresentation.getProfileKeyCiphertext().serialize()))
.setRole(Member.Role.ADMINISTRATOR)
.setJoinedAtVersion(1)
.build())
.build();

when(groupsManager.getGroup(eq(ByteString.copyFrom(groupPublicParams.getGroupIdentifier().serialize()))))
.thenReturn(CompletableFuture.completedFuture(Optional.of(group)));

// Verify that member gets expected status
Response response = resources.getJerseyTest()
.target("/v2/groups/avatar/form")
.request(ProtocolBufferMediaType.APPLICATION_PROTOBUF)
.header("Authorization", AuthHelper.getAuthHeader(groupSecretParams, AuthHelper.VALID_USER_AUTH_CREDENTIAL))
.header("Authorization", AuthHelper.getAuthHeader(groupSecretParams, userAuthCredential))
.get();

assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.hasEntity()).isTrue();
assertThat(response.getStatus()).isEqualTo(expectedMemberStatusCode);
if (expectedMemberStatusCode == 200) {
assertThat(response.hasEntity()).isTrue();

AvatarUploadAttributes uploadAttributes = AvatarUploadAttributes.parseFrom(response.readEntity(InputStream.class).readAllBytes());
AvatarUploadAttributes uploadAttributes = AvatarUploadAttributes.parseFrom(
response.readEntity(InputStream.class).readAllBytes());

assertThat(uploadAttributes.getKey()).startsWith("groups/" + Base64.getUrlEncoder().withoutPadding()
.encodeToString(groupPublicParams.getGroupIdentifier().serialize()));
assertThat(uploadAttributes.getAcl()).isEqualTo("private");
assertThat(uploadAttributes.getCredential()).isNotEmpty();
assertThat(uploadAttributes.getDate()).isNotEmpty();
assertThat(uploadAttributes.getSignature()).isNotEmpty();
}

// Verify that non-member gets 403
response = resources.getJerseyTest()
.target("/v2/groups/avatar/form")
.request(ProtocolBufferMediaType.APPLICATION_PROTOBUF)
.header("Authorization",
AuthHelper.getAuthHeader(groupSecretParams, AuthHelper.VALID_USER_THREE_AUTH_CREDENTIAL))
.get();

assertThat(response.getStatus()).isEqualTo(403);
assertThat(response.hasEntity()).isFalse();
}

assertThat(uploadAttributes.getKey()).startsWith("groups/" + Base64.getUrlEncoder().withoutPadding().encodeToString(groupPublicParams.getGroupIdentifier().serialize()));
assertThat(uploadAttributes.getAcl()).isEqualTo("private");
assertThat(uploadAttributes.getCredential()).isNotEmpty();
assertThat(uploadAttributes.getDate()).isNotEmpty();
assertThat(uploadAttributes.getSignature()).isNotEmpty();
static List<Arguments> testGetAvatarUpload() {
return List.of(
Arguments.of(AccessControl.AccessRequired.MEMBER, true, 200),
Arguments.of(AccessControl.AccessRequired.MEMBER, false, 200),
Arguments.of(AccessControl.AccessRequired.ADMINISTRATOR, true, 200),
Arguments.of(AccessControl.AccessRequired.ADMINISTRATOR, false, 403)
);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@
import org.apache.http.HttpHeaders;
import org.apache.http.HttpStatus;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.signal.libsignal.zkgroup.NotarySignature;
import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPni;
import org.signal.libsignal.zkgroup.auth.ClientZkAuthOperations;
import org.signal.libsignal.zkgroup.groups.GroupPublicParams;
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
Expand Down Expand Up @@ -3618,27 +3622,90 @@ void testGetGroupJoinedAtVersion() throws IOException {
verifyNoMoreInteractions(groupsManager);
}

@Test
void testGetAvatarUpload() throws IOException {
@ParameterizedTest
@MethodSource
void testGetAvatarUpload(AccessControl.AccessRequired accessRequired, boolean isMemberAdmin, int expectedMemberStatusCode) throws IOException {
GroupSecretParams groupSecretParams = GroupSecretParams.generate();
GroupPublicParams groupPublicParams = groupSecretParams.getPublicParams();

ProfileKeyCredentialPresentation validUserPresentation = new ClientZkProfileOperations(
AuthHelper.GROUPS_SERVER_KEY.getPublicParams()).createProfileKeyCredentialPresentation(groupSecretParams,
AuthHelper.VALID_USER_PROFILE_CREDENTIAL);

ProfileKeyCredentialPresentation validAdminPresentation = new ClientZkProfileOperations(
AuthHelper.GROUPS_SERVER_KEY.getPublicParams()).createProfileKeyCredentialPresentation(groupSecretParams,
AuthHelper.VALID_USER_TWO_PROFILE_CREDENTIAL);

AuthCredentialWithPni userAuthCredential = isMemberAdmin
? AuthHelper.VALID_USER_TWO_AUTH_CREDENTIAL
: AuthHelper.VALID_USER_AUTH_CREDENTIAL;

Group group = Group.newBuilder()
.setPublicKey(ByteString.copyFrom(groupPublicParams.serialize()))
.setAccessControl(AccessControl.newBuilder()
.setMembers(accessRequired)
.setAttributes(accessRequired))
.setTitle(ByteString.copyFromUtf8("Some title"))
.setAvatar(avatarFor(groupPublicParams.getGroupIdentifier().serialize()))
.setVersion(7)
.addMembers(Member.newBuilder()
.setUserId(ByteString.copyFrom(validUserPresentation.getUuidCiphertext().serialize()))
.setProfileKey(ByteString.copyFrom(validUserPresentation.getProfileKeyCiphertext().serialize()))
.setRole(Member.Role.DEFAULT)
.setJoinedAtVersion(2)
.build())
.addMembers(Member.newBuilder()
.setUserId(ByteString.copyFrom(validAdminPresentation.getUuidCiphertext().serialize()))
.setProfileKey(ByteString.copyFrom(validAdminPresentation.getProfileKeyCiphertext().serialize()))
.setRole(Member.Role.ADMINISTRATOR)
.setJoinedAtVersion(1)
.build())
.build();

when(groupsManager.getGroup(eq(ByteString.copyFrom(groupPublicParams.getGroupIdentifier().serialize()))))
.thenReturn(CompletableFuture.completedFuture(Optional.of(group)));

// Verify that member gets expected status
Response response = resources.getJerseyTest()
.target("/v1/groups/avatar/form")
.request(ProtocolBufferMediaType.APPLICATION_PROTOBUF)
.header("Authorization", AuthHelper.getAuthHeader(groupSecretParams, AuthHelper.VALID_USER_AUTH_CREDENTIAL))
.get();
.target("/v1/groups/avatar/form")
.request(ProtocolBufferMediaType.APPLICATION_PROTOBUF)
.header("Authorization", AuthHelper.getAuthHeader(groupSecretParams, userAuthCredential))
.get();

assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.hasEntity()).isTrue();
assertThat(response.getStatus()).isEqualTo(expectedMemberStatusCode);
if (expectedMemberStatusCode == 200) {
assertThat(response.hasEntity()).isTrue();

AvatarUploadAttributes uploadAttributes = AvatarUploadAttributes.parseFrom(response.readEntity(InputStream.class).readAllBytes());
AvatarUploadAttributes uploadAttributes = AvatarUploadAttributes.parseFrom(
response.readEntity(InputStream.class).readAllBytes());

assertThat(uploadAttributes.getKey()).startsWith("groups/" + Base64.getUrlEncoder().withoutPadding()
.encodeToString(groupPublicParams.getGroupIdentifier().serialize()));
assertThat(uploadAttributes.getAcl()).isEqualTo("private");
assertThat(uploadAttributes.getCredential()).isNotEmpty();
assertThat(uploadAttributes.getDate()).isNotEmpty();
assertThat(uploadAttributes.getSignature()).isNotEmpty();
}

// Verify that non-member gets 403
response = resources.getJerseyTest()
.target("/v2/groups/avatar/form")
.request(ProtocolBufferMediaType.APPLICATION_PROTOBUF)
.header("Authorization",
AuthHelper.getAuthHeader(groupSecretParams, AuthHelper.VALID_USER_THREE_AUTH_CREDENTIAL))
.get();

assertThat(response.getStatus()).isEqualTo(403);
assertThat(response.hasEntity()).isFalse();
}

assertThat(uploadAttributes.getKey()).startsWith("groups/" + Base64.getUrlEncoder().withoutPadding().encodeToString(groupPublicParams.getGroupIdentifier().serialize()));
assertThat(uploadAttributes.getAcl()).isEqualTo("private");
assertThat(uploadAttributes.getCredential()).isNotEmpty();
assertThat(uploadAttributes.getDate()).isNotEmpty();
assertThat(uploadAttributes.getSignature()).isNotEmpty();
static List<Arguments> testGetAvatarUpload() {
return List.of(
Arguments.of(AccessControl.AccessRequired.MEMBER, true, 200),
Arguments.of(AccessControl.AccessRequired.MEMBER, false, 200),
Arguments.of(AccessControl.AccessRequired.ADMINISTRATOR, true, 200),
Arguments.of(AccessControl.AccessRequired.ADMINISTRATOR, false, 403)
);
}

@Test
Expand Down

0 comments on commit 09ca73b

Please sign in to comment.