add an option to replace username ciphertext without rotating the link handle

This commit is contained in:
Jonathan Klabunde Tomer 2023-11-13 09:01:54 -08:00 committed by GitHub
parent a4a4204762
commit a83378a44e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 69 additions and 11 deletions

View File

@ -410,9 +410,8 @@ public class AccountController {
summary = "Set username link",
description = """
Authenticated endpoint. For the given encrypted username generates a username link handle.
Username link handle could be used to lookup the encrypted username.
An account can only have one username link at a time. Calling this endpoint will reset previously stored
encrypted username and deactivate previous link handle.
The username link handle can be used to lookup the encrypted username.
An account can only have one username link at a time; this endpoint overwrites the previous encrypted username if there was one.
"""
)
@ApiResponse(responseCode = "200", description = "Username Link updated successfully.", useReturnTypeSchema = true)
@ -426,12 +425,19 @@ public class AccountController {
// check ratelimiter for username link operations
rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.getAccount().getUuid());
final Account account = auth.getAccount();
// check if username hash is set for the account
if (auth.getAccount().getUsernameHash().isEmpty()) {
if (account.getUsernameHash().isEmpty()) {
throw new WebApplicationException(Status.CONFLICT);
}
final UUID usernameLinkHandle = UUID.randomUUID();
final UUID usernameLinkHandle;
if (encryptedUsername.keepLinkHandle() && account.getUsernameLinkHandle() != null) {
usernameLinkHandle = account.getUsernameLinkHandle();
} else {
usernameLinkHandle = UUID.randomUUID();
}
updateUsernameLink(auth.getAccount(), usernameLinkHandle, encryptedUsername.usernameLinkEncryptedValue());
return new UsernameLinkHandle(usernameLinkHandle);
}

View File

@ -5,6 +5,7 @@
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 io.swagger.v3.oas.annotations.media.Schema;
@ -18,7 +19,17 @@ public record EncryptedUsername(
@NotNull
@Size(min = 1, max = EncryptedUsername.MAX_SIZE)
@Schema(type = "string", description = "the URL-safe base64 encoding of the encrypted username")
byte[] usernameLinkEncryptedValue) {
byte[] usernameLinkEncryptedValue,
@JsonProperty
@Schema(type = "boolean", description = "if set and the account already has an encrypted-username link handle, reuse the same link handle rather than generating a new one. The response will still have the link handle.")
boolean keepLinkHandle
) {
public static final int MAX_SIZE = 128;
public EncryptedUsername(final byte[] usernameLinkEncryptedValue) {
this(usernameLinkEncryptedValue, false);
}
}

View File

@ -267,7 +267,12 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
.asRuntimeException());
}
final UUID linkHandle = UUID.randomUUID();
final UUID linkHandle;
if (request.getKeepLinkHandle() && account.getUsernameLinkHandle() != null) {
linkHandle = account.getUsernameLinkHandle();
} else {
linkHandle = UUID.randomUUID();
}
return Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> a.setUsernameLinkDetails(linkHandle, request.getUsernameCiphertext().toByteArray())))
.thenReturn(linkHandle);

View File

@ -61,8 +61,9 @@ service Accounts {
rpc DeleteUsernameHash(DeleteUsernameHashRequest) returns (DeleteUsernameHashResponse) {}
/**
* Generates a new link handle for the given username ciphertext, displacing
* any previously-existing link handle.
* Associates the given username ciphertext with the account, replacing any
* previously stored ciphertext. A new link handle will optionally be created,
* and the link handle to use will be returned in any event.
*
* This RPC may fail with a status of `FAILED_PRECONDITION` if the
* authenticated account does not have a username. It may also fail with
@ -235,6 +236,13 @@ message SetUsernameLinkRequest {
* The username ciphertext for which to generate a new link handle.
*/
bytes username_ciphertext = 1;
/**
* If true and the account already had an encrypted username stored, the
* existing link handle will be reused. Otherwise a new link handle will be
* created.
*/
bool keep_link_handle = 2;
}
message SetUsernameLinkResponse {

View File

@ -52,6 +52,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.stubbing.Answer;
import org.signal.libsignal.usernames.BaseUsernameException;
@ -71,6 +72,7 @@ import org.whispersystems.textsecuregcm.entities.RegistrationLock;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse;
import org.whispersystems.textsecuregcm.entities.UsernameHashResponse;
import org.whispersystems.textsecuregcm.entities.UsernameLinkHandle;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
import org.whispersystems.textsecuregcm.limits.RateLimitByIpFilter;
@ -998,4 +1000,24 @@ class AccountControllerTest {
.get()
.getStatus()).isEqualTo(422);
}
@ParameterizedTest
@ValueSource(booleans = { true, false })
void testPutUsernameLink(boolean keepLink) {
when(rateLimiters.forDescriptor(eq(RateLimiters.For.USERNAME_LINK_OPERATION))).thenReturn(mock(RateLimiter.class));
final UUID oldLinkHandle = UUID.randomUUID();
when(AuthHelper.VALID_ACCOUNT.getUsernameLinkHandle()).thenReturn(oldLinkHandle);
final byte[] encryptedUsername = "some encrypted goop".getBytes();
final UsernameLinkHandle newHandle = resources.getJerseyTest()
.target("/v1/accounts/username_link")
.request()
.header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new EncryptedUsername(encryptedUsername, keepLink)), UsernameLinkHandle.class);
assertThat(newHandle.usernameLinkHandle().equals(oldLinkHandle)).isEqualTo(keepLink);
verify(AuthHelper.VALID_ACCOUNT).setUsernameLinkDetails(eq(newHandle.usernameLinkHandle()), eq(encryptedUsername));
}
}

View File

@ -521,10 +521,13 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
verify(accountsManager).clearUsernameHash(account);
}
@Test
void setUsernameLink() {
@ParameterizedTest
@ValueSource(booleans = {false, true})
void setUsernameLink(final boolean keepLink) {
final Account account = mock(Account.class);
final UUID oldHandle = UUID.randomUUID();
when(account.getUsernameHash()).thenReturn(Optional.of(new byte[AccountController.USERNAME_HASH_LENGTH]));
when(account.getUsernameLinkHandle()).thenReturn(oldHandle);
when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI))
.thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
@ -535,12 +538,14 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
final SetUsernameLinkResponse response =
authenticatedServiceStub().setUsernameLink(SetUsernameLinkRequest.newBuilder()
.setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))
.setKeepLinkHandle(keepLink)
.build());
final ArgumentCaptor<UUID> linkHandleCaptor = ArgumentCaptor.forClass(UUID.class);
verify(account).setUsernameLinkDetails(linkHandleCaptor.capture(), eq(usernameCiphertext));
assertEquals(keepLink, oldHandle.equals(linkHandleCaptor.getValue()));
final SetUsernameLinkResponse expectedResponse = SetUsernameLinkResponse.newBuilder()
.setUsernameLinkHandle(UUIDUtil.toByteString(linkHandleCaptor.getValue()))
.build();

View File

@ -116,6 +116,7 @@ public class AccountsHelper {
case "getNumber" -> when(updatedAccount.getNumber()).thenAnswer(stubbing);
case "getUsername" -> when(updatedAccount.getUsernameHash()).thenAnswer(stubbing);
case "getUsernameHash" -> when(updatedAccount.getUsernameHash()).thenAnswer(stubbing);
case "getUsernameLinkHandle" -> when(updatedAccount.getUsernameLinkHandle()).thenAnswer(stubbing);
case "getDevices" -> when(updatedAccount.getDevices()).thenAnswer(stubbing);
case "getDevice" -> when(updatedAccount.getDevice(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing);
case "getPrimaryDevice" -> when(updatedAccount.getPrimaryDevice()).thenAnswer(stubbing);