Skip to content

Commit

Permalink
Allow primary devices to change names of linked devices
Browse files Browse the repository at this point in the history
  • Loading branch information
jon-signal authored Oct 29, 2024
1 parent 712f3af commit f3b22e0
Show file tree
Hide file tree
Showing 14 changed files with 231 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import io.dropwizard.auth.Auth;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.util.Base64;
import java.util.Objects;
Expand All @@ -19,6 +20,7 @@
import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.ForbiddenException;
import javax.ws.rs.GET;
import javax.ws.rs.HEAD;
import javax.ws.rs.HeaderParam;
Expand All @@ -27,6 +29,7 @@
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
Expand Down Expand Up @@ -192,10 +195,39 @@ public void removeRegistrationLock(@Mutable @Auth AuthenticatedDevice auth) {

@PUT
@Path("/name/")
public void setName(@Mutable @Auth AuthenticatedDevice auth, @NotNull @Valid DeviceName deviceName) {
Account account = auth.getAccount();
Device device = auth.getAuthenticatedDevice();
accounts.updateDevice(account, device.getId(), d -> d.setName(deviceName.getDeviceName()));
@Operation(summary = "Set a device's encrypted name",
description = """
Sets the encrypted name for the specified device. Primary devices may change the name of any device associated
with their account, but linked devices may only change their own name. If no device ID is specified, then the
authenticated device's ID will be used.
""")
@ApiResponse(responseCode = "204", description = "Device name changed successfully")
@ApiResponse(responseCode = "404", description = "No device found with the given ID")
@ApiResponse(responseCode = "403", description = "Not authorized to change the name of the device with the given ID")
public void setName(@Mutable @Auth final AuthenticatedDevice auth,
@NotNull @Valid final DeviceName deviceName,

@Nullable
@QueryParam("deviceId")
@Schema(description = "The ID of the device for which to set a name; if omitted, the authenticated device will be targeted for a name change",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
final Byte deviceId) {

final Account account = auth.getAccount();
final byte targetDeviceId = deviceId == null ? auth.getAuthenticatedDevice().getId() : deviceId;

if (account.getDevice(targetDeviceId).isEmpty()) {
throw new NotFoundException();
}

final boolean mayChangeName = auth.getAuthenticatedDevice().isPrimary() ||
auth.getAuthenticatedDevice().getId() == targetDeviceId;

if (!mayChangeName) {
throw new ForbiddenException();
}

accounts.updateDevice(account, targetDeviceId, d -> d.setName(deviceName.deviceName()));
}

@PUT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,15 @@

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.whispersystems.textsecuregcm.util.ByteArrayAdapter;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;

public class DeviceName {

@JsonProperty
@JsonSerialize(using = ByteArrayAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)
@NotEmpty
@Size(max = 225)
private byte[] deviceName;

public DeviceName() {}

public byte[] getDeviceName() {
return deviceName;
}
public record DeviceName(@JsonSerialize(using = ByteArrayAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)
@NotEmpty
@Size(max = 225)
byte[] deviceName) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
package org.whispersystems.textsecuregcm.grpc;

import io.grpc.Status;
import org.whispersystems.textsecuregcm.storage.Device;

public class DeviceIdUtil {

static byte validate(int deviceId) {
if (deviceId > Byte.MAX_VALUE) {
if (deviceId < Device.PRIMARY_ID || deviceId > Byte.MAX_VALUE) {
throw Status.INVALID_ARGUMENT.withDescription("Device ID is out of range").asRuntimeException();
}

return (byte) deviceId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ public Mono<RemoveDeviceResponse> removeDevice(final RemoveDeviceRequest request
public Mono<SetDeviceNameResponse> setDeviceName(final SetDeviceNameRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();

final byte deviceId = DeviceIdUtil.validate(request.getId());

final boolean mayChangeName = authenticatedDevice.deviceId() == Device.PRIMARY_ID ||
authenticatedDevice.deviceId() == deviceId;

if (!mayChangeName) {
throw Status.PERMISSION_DENIED
.withDescription("Authenticated device is not authorized to change target device name")
.asRuntimeException();
}

if (request.getName().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Must specify a device name").asRuntimeException();
}
Expand All @@ -100,7 +111,12 @@ public Mono<SetDeviceNameResponse> setDeviceName(final SetDeviceNameRequest requ

return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateDeviceAsync(account, authenticatedDevice.deviceId(),
.doOnNext(account -> {
if (account.getDevice(deviceId).isEmpty()) {
throw Status.NOT_FOUND.withDescription("No device found with given ID").asRuntimeException();
}
})
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateDeviceAsync(account, deviceId,
device -> device.setName(request.getName().toByteArray()))))
.thenReturn(SetDeviceNameResponse.newBuilder().build());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ public Mono<GetPreKeysResponse> getPreKeys(final GetPreKeysAnonymousRequest requ
final ServiceIdentifier serviceIdentifier =
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getTargetIdentifier());

final byte deviceId = DeviceIdUtil.validate(request.getRequest().getDeviceId());
final byte deviceId = request.getRequest().hasDeviceId()
? DeviceIdUtil.validate(request.getRequest().getDeviceId())
: KeysGrpcHelper.ALL_DEVICES;

return switch (request.getAuthorizationCase()) {
case GROUP_SEND_TOKEN ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

package org.whispersystems.textsecuregcm.grpc;

import com.google.common.annotations.VisibleForTesting;
import com.google.protobuf.ByteString;
import io.grpc.Status;
import org.signal.chat.common.EcPreKey;
Expand All @@ -26,7 +25,6 @@

class KeysGrpcHelper {

@VisibleForTesting
static final byte ALL_DEVICES = 0;

static Mono<GetPreKeysResponse> getPreKeys(final Account targetAccount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ public Mono<GetPreKeysResponse> getPreKeys(final GetPreKeysRequest request) {
final ServiceIdentifier targetIdentifier =
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getTargetIdentifier());

final byte deviceId = DeviceIdUtil.validate(request.getDeviceId());
final byte deviceId = request.hasDeviceId()
? DeviceIdUtil.validate(request.getDeviceId())
: KeysGrpcHelper.ALL_DEVICES;

final String rateLimitKey = authenticatedDevice.accountIdentifier() + "." +
authenticatedDevice.deviceId() + "__" +
Expand Down
13 changes: 13 additions & 0 deletions service/src/main/proto/org/signal/chat/device.proto
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ service Devices {
*/
rpc RemoveDevice(RemoveDeviceRequest) returns (RemoveDeviceResponse) {}

/**
* Sets the encrypted human-readable name for a specific devices. Primary
* devices may change the name of any device associated with their account,
* but linked devices may only change their own name. This call will fail with
* a status of `NOT_FOUND` if no device was found with the given identifier.
* It will also fail with a status of `PERMISSION_DENIED` if a linked device
* tries to change the name of any device other than itself.
*/
rpc SetDeviceName(SetDeviceNameRequest) returns (SetDeviceNameResponse) {}

/**
Expand Down Expand Up @@ -95,6 +103,11 @@ message SetDeviceNameRequest {
* device.
*/
bytes name = 1;

/**
* The identifier for the device for which to set a name.
*/
uint32 id = 2;
}

message SetDeviceNameResponse {}
Expand Down
2 changes: 1 addition & 1 deletion service/src/main/proto/org/signal/chat/keys.proto
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ message GetPreKeysRequest {
* retrieve pre-keys. If not set, pre-keys are returned for all devices
* associated with the targeted account.
*/
uint32 device_id = 2;
optional uint32 device_id = 2;
}

message GetPreKeysAnonymousRequest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
Expand Down Expand Up @@ -63,6 +64,7 @@
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest;
import org.whispersystems.textsecuregcm.entities.DeviceName;
import org.whispersystems.textsecuregcm.entities.EncryptedUsername;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
Expand All @@ -81,6 +83,7 @@
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
Expand Down Expand Up @@ -999,4 +1002,72 @@ void testPutUsernameLink(boolean keepLink) {
verify(AuthHelper.VALID_ACCOUNT).setUsernameLinkDetails(eq(newHandle.usernameLinkHandle()), eq(encryptedUsername));
}

@Test
void testSetDeviceName() {
try (final Response response = resources.getJerseyTest()
.target("/v1/accounts/name/")
.request()
.header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, Device.PRIMARY_ID, AuthHelper.VALID_PASSWORD_3_PRIMARY))
.put(Entity.json(new DeviceName(TestRandomUtil.nextBytes(64))))) {

assertThat(response.getStatus()).isEqualTo(204);
verify(accountsManager).updateDevice(eq(AuthHelper.VALID_ACCOUNT_3), eq(Device.PRIMARY_ID), any());
}
}

@Test
void testSetLinkedDeviceNameFromPrimary() {
try (final Response response = resources.getJerseyTest()
.target("/v1/accounts/name/")
.queryParam("deviceId", AuthHelper.VALID_DEVICE_3_LINKED_ID)
.request()
.header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, Device.PRIMARY_ID, AuthHelper.VALID_PASSWORD_3_PRIMARY))
.put(Entity.json(new DeviceName(TestRandomUtil.nextBytes(64))))) {

assertThat(response.getStatus()).isEqualTo(204);
verify(accountsManager).updateDevice(eq(AuthHelper.VALID_ACCOUNT_3), eq(AuthHelper.VALID_DEVICE_3_LINKED_ID), any());
}
}

@Test
void testSetLinkedDeviceNameFromLinked() {
try (final Response response = resources.getJerseyTest()
.target("/v1/accounts/name/")
.queryParam("deviceId", AuthHelper.VALID_DEVICE_3_LINKED_ID)
.request()
.header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, AuthHelper.VALID_DEVICE_3_LINKED_ID, AuthHelper.VALID_PASSWORD_3_LINKED))
.put(Entity.json(new DeviceName(TestRandomUtil.nextBytes(64))))) {

assertThat(response.getStatus()).isEqualTo(204);
verify(accountsManager).updateDevice(eq(AuthHelper.VALID_ACCOUNT_3), eq(AuthHelper.VALID_DEVICE_3_LINKED_ID), any());
}
}

@Test
void testSetDeviceNameDeviceNotFound() {
try (final Response response = resources.getJerseyTest()
.target("/v1/accounts/name/")
.queryParam("deviceId", Device.MAXIMUM_DEVICE_ID)
.request()
.header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, AuthHelper.VALID_PASSWORD_3_PRIMARY))
.put(Entity.json(new DeviceName(TestRandomUtil.nextBytes(64))))) {

assertThat(response.getStatus()).isEqualTo(404);
verify(accountsManager, never()).updateDevice(any(), anyByte(), any());
}
}

@Test
void testSetPrimaryDeviceNameFromLinked() {
try (final Response response = resources.getJerseyTest()
.target("/v1/accounts/name/")
.queryParam("deviceId", Device.PRIMARY_ID)
.request()
.header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, AuthHelper.VALID_DEVICE_3_LINKED_ID, AuthHelper.VALID_PASSWORD_3_LINKED))
.put(Entity.json(new DeviceName(TestRandomUtil.nextBytes(64))))) {

assertThat(response.getStatus()).isEqualTo(403);
verify(accountsManager, never()).updateDevice(any(), anyByte(), any());
}
}
}
Loading

0 comments on commit f3b22e0

Please sign in to comment.