Skip to content

Commit

Permalink
Group Send Endorsement support for unversioned profile fetch
Browse files Browse the repository at this point in the history
  • Loading branch information
jkt-signal committed Apr 23, 2024
1 parent 9ef1fee commit f0dcd8e
Show file tree
Hide file tree
Showing 6 changed files with 274 additions and 66 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -798,7 +798,7 @@ protected void configureServer(final ServerBuilder<?> serverBuilder) {
.addService(new KeysAnonymousGrpcService(accountsManager, keysManager, zkSecretParams, Clock.systemUTC()))
.addService(new PaymentsGrpcService(currencyManager))
.addService(ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config))
.addService(new ProfileAnonymousGrpcService(accountsManager, profilesManager, profileBadgeConverter, zkProfileOperations));
.addService(new ProfileAnonymousGrpcService(accountsManager, profilesManager, profileBadgeConverter, zkSecretParams));
}
};

Expand Down Expand Up @@ -969,7 +969,7 @@ protected void configureServer(final ServerBuilder<?> serverBuilder) {
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager,
profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner,
config.getCdnConfiguration().bucket(), zkProfileOperations, batchIdentityCheckExecutor),
config.getCdnConfiguration().bucket(), zkSecretParams, zkProfileOperations, batchIdentityCheckExecutor),
new ProvisioningController(rateLimiters, provisioningManager),
new RegistrationController(accountsManager, phoneVerificationTokenManager, registrationLockVerificationManager,
rateLimiters),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,17 @@
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.ServiceId;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.groupsend.GroupSendDerivedKeyPair;
import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse;
import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.Anonymous;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.GroupSendTokenHeader;
import org.whispersystems.textsecuregcm.auth.OptionalAccess;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
Expand Down Expand Up @@ -109,19 +113,20 @@
public class ProfileController {
private final Logger logger = LoggerFactory.getLogger(ProfileController.class);
private final Clock clock;
private final RateLimiters rateLimiters;
private final ProfilesManager profilesManager;
private final AccountsManager accountsManager;
private final RateLimiters rateLimiters;
private final ProfilesManager profilesManager;
private final AccountsManager accountsManager;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
private final ProfileBadgeConverter profileBadgeConverter;
private final Map<String, BadgeConfiguration> badgeConfigurationMap;

private final PolicySigner policySigner;
private final PostPolicyGenerator policyGenerator;
private final PolicySigner policySigner;
private final PostPolicyGenerator policyGenerator;
private final ServerSecretParams serverSecretParams;
private final ServerZkProfileOperations zkProfileOperations;

private final S3Client s3client;
private final String bucket;
private final S3Client s3client;
private final String bucket;

private final Executor batchIdentityCheckExecutor;

Expand All @@ -142,21 +147,23 @@ public ProfileController(
PostPolicyGenerator policyGenerator,
PolicySigner policySigner,
String bucket,
ServerSecretParams serverSecretParams,
ServerZkProfileOperations zkProfileOperations,
Executor batchIdentityCheckExecutor) {
this.clock = clock;
this.rateLimiters = rateLimiters;
this.accountsManager = accountsManager;
this.profilesManager = profilesManager;
this.rateLimiters = rateLimiters;
this.accountsManager = accountsManager;
this.profilesManager = profilesManager;
this.dynamicConfigurationManager = dynamicConfigurationManager;
this.profileBadgeConverter = profileBadgeConverter;
this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(
BadgeConfiguration::getId, Function.identity()));
this.serverSecretParams = serverSecretParams;
this.zkProfileOperations = zkProfileOperations;
this.bucket = bucket;
this.s3client = s3client;
this.policyGenerator = policyGenerator;
this.policySigner = policySigner;
this.bucket = bucket;
this.s3client = s3client;
this.policyGenerator = policyGenerator;
this.policySigner = policySigner;
this.batchIdentityCheckExecutor = Preconditions.checkNotNull(batchIdentityCheckExecutor);
}

Expand Down Expand Up @@ -282,6 +289,7 @@ public CredentialProfileResponse getProfile(
public BaseProfileResponse getUnversionedProfile(
@ReadOnly @Auth Optional<AuthenticatedAccount> auth,
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
@HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) Optional<GroupSendTokenHeader> groupSendToken,
@Context ContainerRequestContext containerRequestContext,
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
@PathParam("identifier") ServiceIdentifier identifier,
Expand All @@ -290,8 +298,22 @@ public BaseProfileResponse getUnversionedProfile(

final Optional<Account> maybeRequester = auth.map(AuthenticatedAccount::getAccount);

final Account targetAccount = verifyPermissionToReceiveProfile(
maybeRequester, accessKey.filter(ignored -> identifier.identityType() == IdentityType.ACI), identifier);
final Account targetAccount;
if (groupSendToken.isPresent()) {
if (accessKey.isPresent()) {
throw new BadRequestException("may not provide both group send token and unidentified access key");
}
try {
final GroupSendFullToken token = groupSendToken.get().token();
token.verify(List.of(identifier.toLibsignal()), clock.instant(), GroupSendDerivedKeyPair.forExpiration(token.getExpiration(), serverSecretParams));
targetAccount = accountsManager.getByServiceIdentifier(identifier).orElseThrow(NotFoundException::new);
} catch (VerificationFailedException e) {
throw new NotAuthorizedException(e);
}
} else {
targetAccount = verifyPermissionToReceiveProfile(
maybeRequester, accessKey.filter(ignored -> identifier.identityType() == IdentityType.ACI), identifier);
}
return switch (identifier.identityType()) {
case ACI -> {
yield buildBaseProfileResponseForAccountIdentity(targetAccount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
package org.whispersystems.textsecuregcm.grpc;

import io.grpc.Status;

import java.time.Clock;
import java.util.List;

import org.signal.chat.profile.CredentialType;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialAnonymousRequest;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse;
Expand All @@ -14,6 +18,7 @@
import org.signal.chat.profile.GetVersionedProfileAnonymousRequest;
import org.signal.chat.profile.GetVersionedProfileResponse;
import org.signal.chat.profile.ReactorProfileAnonymousGrpc;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
Expand All @@ -29,16 +34,18 @@ public class ProfileAnonymousGrpcService extends ReactorProfileAnonymousGrpc.Pro
private final ProfilesManager profilesManager;
private final ProfileBadgeConverter profileBadgeConverter;
private final ServerZkProfileOperations zkProfileOperations;
private final GroupSendTokenUtil groupSendTokenUtil;

public ProfileAnonymousGrpcService(
final AccountsManager accountsManager,
final ProfilesManager profilesManager,
final ProfileBadgeConverter profileBadgeConverter,
final ServerZkProfileOperations zkProfileOperations) {
final ServerSecretParams serverSecretParams) {
this.accountsManager = accountsManager;
this.profilesManager = profilesManager;
this.profileBadgeConverter = profileBadgeConverter;
this.zkProfileOperations = zkProfileOperations;
this.zkProfileOperations = new ServerZkProfileOperations(serverSecretParams);
this.groupSendTokenUtil = new GroupSendTokenUtil(serverSecretParams, Clock.systemUTC());
}

@Override
Expand All @@ -51,8 +58,18 @@ public Mono<GetUnversionedProfileResponse> getUnversionedProfile(final GetUnvers
throw Status.UNAUTHENTICATED.asRuntimeException();
}

return getTargetAccountAndValidateUnidentifiedAccess(targetIdentifier, request.getUnidentifiedAccessKey().toByteArray())
.map(targetAccount -> ProfileGrpcHelper.buildUnversionedProfileResponse(targetIdentifier,
final Mono<Account> account = switch (request.getAuthenticationCase()) {
case GROUP_SEND_TOKEN ->
groupSendTokenUtil.checkGroupSendToken(request.getGroupSendToken(), List.of(targetIdentifier))
.then(Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(targetIdentifier)))
.flatMap(Mono::justOrEmpty)
.switchIfEmpty(Mono.error(Status.NOT_FOUND.asException()));
case UNIDENTIFIED_ACCESS_KEY ->
getTargetAccountAndValidateUnidentifiedAccess(targetIdentifier, request.getUnidentifiedAccessKey().toByteArray());
default -> Mono.error(Status.INVALID_ARGUMENT.asException());
};

return account.map(targetAccount -> ProfileGrpcHelper.buildUnversionedProfileResponse(targetIdentifier,
null,
targetAccount,
profileBadgeConverter));
Expand Down
16 changes: 12 additions & 4 deletions service/src/main/proto/org/signal/chat/profile.proto
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,18 @@ message GetUnversionedProfileAnonymousRequest {
* Contains the data necessary to request an unversioned profile.
*/
GetUnversionedProfileRequest request = 1;
/**
* The unidentified access key for the targeted account.
*/
bytes unidentified_access_key = 2;

oneof authentication {
/**
* The unidentified access key for the targeted account.
*/
bytes unidentified_access_key = 2;

/**
* A group send endorsement token for the targeted account.
*/
bytes group_send_token = 3;
}
}

message GetUnversionedProfileResponse {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import java.util.concurrent.Executors;
import java.util.stream.Stream;
import javax.ws.rs.client.Entity;
import javax.ws.rs.client.Invocation;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedHashMap;
import javax.ws.rs.core.MultivaluedMap;
Expand Down Expand Up @@ -117,7 +118,7 @@
@ExtendWith(DropwizardExtensionsSupport.class)
class ProfileControllerTest {

private static final Clock clock = TestClock.pinned(Instant.ofEpochSecond(42));
private static final TestClock clock = TestClock.now();
private static final AccountsManager accountsManager = mock(AccountsManager.class);
private static final ProfilesManager profilesManager = mock(ProfilesManager.class);
private static final RateLimiters rateLimiters = mock(RateLimiters.class);
Expand All @@ -129,6 +130,7 @@ class ProfileControllerTest {
"accessKey");
private static final PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1");
private static final ServerZkProfileOperations zkProfileOperations = mock(ServerZkProfileOperations.class);
private static final ServerSecretParams serverSecretParams = ServerSecretParams.generate();

private static final byte[] UNIDENTIFIED_ACCESS_KEY = "sixteenbytes1234".getBytes(StandardCharsets.UTF_8);
private static final IdentityKey ACCOUNT_IDENTITY_KEY = new IdentityKey(Curve.generateKeyPair().getPublicKey());
Expand Down Expand Up @@ -170,14 +172,15 @@ class ProfileControllerTest {
postPolicyGenerator,
policySigner,
"profilesBucket",
serverSecretParams,
zkProfileOperations,
Executors.newSingleThreadExecutor()))
.build();

@BeforeEach
void setup() {
reset(s3client);

clock.pin(Instant.ofEpochSecond(42));
AccountsHelper.setupMockUpdate(accountsManager);

dynamicPaymentsConfiguration = mock(DynamicPaymentsConfiguration.class);
Expand Down Expand Up @@ -311,6 +314,65 @@ void testProfileGetByAciUnidentifiedAccountNotFound() {
assertThat(response.getStatus()).isEqualTo(401);
}

@ParameterizedTest
@MethodSource
void testProfileGetWithGroupSendEndorsement(
UUID target, UUID authorizedTarget, Duration timeLeft, boolean includeUak, int expectedResponse) throws Exception {

final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS);
clock.pin(expiration.minus(timeLeft));

Invocation.Builder builder = resources.getJerseyTest()
.target("/v1/profile/" + target)
.queryParam("pq", "true")
.request()
.header(
HeaderUtils.GROUP_SEND_TOKEN,
AuthHelper.validGroupSendTokenHeader(serverSecretParams, List.of(new AciServiceIdentifier(authorizedTarget)), expiration));

if (includeUak) {
builder = builder.header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader(UNIDENTIFIED_ACCESS_KEY));
}

Response response = builder.get();
assertThat(response.getStatus()).isEqualTo(expectedResponse);

if (expectedResponse == 200) {
final BaseProfileResponse profile = response.readEntity(BaseProfileResponse.class);
assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY);
assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>(
badge -> "Test Badge".equals(badge.getName()), "has badge with expected name"));
}

verifyNoMoreInteractions(rateLimiter);
}

private static Stream<Arguments> testProfileGetWithGroupSendEndorsement() {
UUID notExistsUuid = UUID.randomUUID();

return Stream.of(
// valid endorsement
Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID_TWO, Duration.ofHours(1), false, 200),

// expired endorsement, not authorized
Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID_TWO, Duration.ofHours(-1), false, 401),

// endorsement for the wrong recipient, not authorized
Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID, Duration.ofHours(1), false, 401),

// expired endorsement for the wrong recipient, not authorized
Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID, Duration.ofHours(-1), false, 401),

// valid endorsement for the right recipient but they aren't registered, not found
Arguments.of(notExistsUuid, notExistsUuid, Duration.ofHours(1), false, 404),

// expired endorsement for the right recipient but they aren't registered, not authorized (NOT not found)
Arguments.of(notExistsUuid, notExistsUuid, Duration.ofHours(-1), false, 401),

// valid endorsement but also a UAK, bad request
Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID_TWO, Duration.ofHours(1), true, 400));
}

@Test
void testProfileGetByPni() throws RateLimitExceededException {
final BaseProfileResponse profile = resources.getJerseyTest()
Expand Down
Loading

0 comments on commit f0dcd8e

Please sign in to comment.