Update error model in device.proto

This commit is contained in:
Ravi Khadiwala
2026-01-30 12:45:37 -06:00
committed by ravi-signal
parent a9f81e2ba6
commit 8804f28cb8
5 changed files with 62 additions and 75 deletions
@@ -5,7 +5,6 @@
package org.whispersystems.textsecuregcm.grpc;
import io.grpc.Status;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
public class DeviceCapabilityUtil {
@@ -19,7 +18,8 @@ public class DeviceCapabilityUtil {
case DEVICE_CAPABILITY_TRANSFER -> DeviceCapability.TRANSFER;
case DEVICE_CAPABILITY_ATTACHMENT_BACKFILL -> DeviceCapability.ATTACHMENT_BACKFILL;
case DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET -> DeviceCapability.SPARSE_POST_QUANTUM_RATCHET;
case DEVICE_CAPABILITY_UNSPECIFIED, UNRECOGNIZED -> throw Status.INVALID_ARGUMENT.withDescription("Unrecognized device capability").asRuntimeException();
case DEVICE_CAPABILITY_UNSPECIFIED, UNRECOGNIZED ->
throw GrpcExceptions.invalidArguments("unrecognized device capability");
};
}
@@ -16,7 +16,7 @@ public class DeviceIdUtil {
static byte validate(int deviceId) {
if (!isValid(deviceId)) {
throw Status.INVALID_ARGUMENT.withDescription("Device ID is out of range").asRuntimeException();
throw GrpcExceptions.invalidArguments("device ID is out of range");
}
return (byte) deviceId;
@@ -6,9 +6,10 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import io.grpc.Status;
import com.google.protobuf.Empty;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.apache.commons.lang3.StringUtils;
@@ -25,9 +26,11 @@ import org.signal.chat.device.SetDeviceNameRequest;
import org.signal.chat.device.SetDeviceNameResponse;
import org.signal.chat.device.SetPushTokenRequest;
import org.signal.chat.device.SetPushTokenResponse;
import org.signal.chat.errors.NotFound;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DeviceCapability;
@@ -38,8 +41,6 @@ public class DevicesGrpcService extends ReactorDevicesGrpc.DevicesImplBase {
private final AccountsManager accountsManager;
private static final int MAX_NAME_LENGTH = 256;
public DevicesGrpcService(final AccountsManager accountsManager) {
this.accountsManager = accountsManager;
}
@@ -48,8 +49,7 @@ public class DevicesGrpcService extends ReactorDevicesGrpc.DevicesImplBase {
public Mono<GetDevicesResponse> getDevices(final GetDevicesRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount(authenticatedDevice.accountIdentifier())
.flatMapMany(account -> Flux.fromIterable(account.getDevices()))
.reduce(GetDevicesResponse.newBuilder(), (builder, device) -> {
final GetDevicesResponse.LinkedDevice.Builder linkedDeviceBuilder = GetDevicesResponse.LinkedDevice.newBuilder();
@@ -72,21 +72,18 @@ public class DevicesGrpcService extends ReactorDevicesGrpc.DevicesImplBase {
@Override
public Mono<RemoveDeviceResponse> removeDevice(final RemoveDeviceRequest request) {
if (request.getId() == Device.PRIMARY_ID) {
throw Status.INVALID_ARGUMENT.withDescription("Cannot remove primary device").asRuntimeException();
throw GrpcExceptions.invalidArguments("cannot remove primary device");
}
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
if (authenticatedDevice.deviceId() != Device.PRIMARY_ID && request.getId() != authenticatedDevice.deviceId()) {
throw Status.PERMISSION_DENIED
.withDescription("Linked devices cannot remove devices other than themselves")
.asRuntimeException();
throw GrpcExceptions.badAuthentication("linked devices cannot remove devices other than themselves");
}
final byte deviceId = DeviceIdUtil.validate(request.getId());
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount(authenticatedDevice.accountIdentifier())
.flatMap(account -> Mono.fromFuture(accountsManager.removeDevice(account, deviceId)))
.thenReturn(RemoveDeviceResponse.newBuilder().build());
}
@@ -101,30 +98,18 @@ public class DevicesGrpcService extends ReactorDevicesGrpc.DevicesImplBase {
authenticatedDevice.deviceId() == deviceId;
if (!mayChangeName) {
throw Status.PERMISSION_DENIED
.withDescription("Authenticated device is not authorized to change target device name")
.asRuntimeException();
throw GrpcExceptions.badAuthentication("linked device is not authorized to change target device name");
}
if (request.getName().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Must specify a device name").asRuntimeException();
}
if (request.getName().size() > MAX_NAME_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("Device name must be at most " + MAX_NAME_LENGTH + " bytes")
.asRuntimeException();
}
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
.doOnNext(account -> {
return getAccount(authenticatedDevice.accountIdentifier())
.flatMap(account -> {
if (account.getDevice(deviceId).isEmpty()) {
throw Status.NOT_FOUND.withDescription("No device found with given ID").asRuntimeException();
return Mono.just(SetDeviceNameResponse.newBuilder().setTargetDeviceNotFound(NotFound.getDefaultInstance()).build());
}
})
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateDeviceAsync(account, deviceId,
device -> device.setName(request.getName().toByteArray()))))
.thenReturn(SetDeviceNameResponse.newBuilder().build());
return Mono.fromFuture(() -> accountsManager.updateDeviceAsync(account, deviceId, device ->
device.setName(request.getName().toByteArray())))
.thenReturn(SetDeviceNameResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build());
});
}
@Override
@@ -138,34 +123,23 @@ public class DevicesGrpcService extends ReactorDevicesGrpc.DevicesImplBase {
case APNS_TOKEN_REQUEST -> {
final SetPushTokenRequest.ApnsTokenRequest apnsTokenRequest = request.getApnsTokenRequest();
if (StringUtils.isBlank(apnsTokenRequest.getApnsToken())) {
throw Status.INVALID_ARGUMENT.withDescription("APNs token must not be blank").asRuntimeException();
}
apnsToken = StringUtils.stripToNull(apnsTokenRequest.getApnsToken());
fcmToken = null;
}
case FCM_TOKEN_REQUEST -> {
final SetPushTokenRequest.FcmTokenRequest fcmTokenRequest = request.getFcmTokenRequest();
if (StringUtils.isBlank(fcmTokenRequest.getFcmToken())) {
throw Status.INVALID_ARGUMENT.withDescription("FCM token must not be blank").asRuntimeException();
}
apnsToken = null;
fcmToken = StringUtils.stripToNull(fcmTokenRequest.getFcmToken());
}
default -> throw Status.INVALID_ARGUMENT.withDescription("No tokens specified").asRuntimeException();
default -> throw GrpcExceptions.fieldViolation("token_request", "No tokens specified");
}
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount(authenticatedDevice.accountIdentifier())
.flatMap(account -> {
final Device device = account.getDevice(authenticatedDevice.deviceId())
.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException);
.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials"));
final boolean tokenUnchanged =
Objects.equals(device.getApnId(), apnsToken) &&
@@ -186,8 +160,7 @@ public class DevicesGrpcService extends ReactorDevicesGrpc.DevicesImplBase {
public Mono<ClearPushTokenResponse> clearPushToken(final ClearPushTokenRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount(authenticatedDevice.accountIdentifier())
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateDeviceAsync(account, authenticatedDevice.deviceId(), device -> {
if (StringUtils.isNotBlank(device.getApnId())) {
device.setUserAgent(device.isPrimary() ? "OWI" : "OWP");
@@ -210,11 +183,15 @@ public class DevicesGrpcService extends ReactorDevicesGrpc.DevicesImplBase {
.map(DeviceCapabilityUtil::fromGrpcDeviceCapability)
.collect(Collectors.toSet());
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount(authenticatedDevice.accountIdentifier())
.flatMap(account ->
Mono.fromFuture(() -> accountsManager.updateDeviceAsync(account, authenticatedDevice.deviceId(),
d -> d.setCapabilities(capabilities))))
.thenReturn(SetCapabilitiesResponse.newBuilder().build());
}
private Mono<Account> getAccount(final UUID accountIdentifier) {
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(accountIdentifier))
.map(maybeAccount -> maybeAccount.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials")));
}
}
@@ -9,27 +9,28 @@ option java_multiple_files = true;
package org.signal.chat.device;
import "google/protobuf/empty.proto";
import "org/signal/chat/common.proto";
import "org/signal/chat/errors.proto";
import "org/signal/chat/require.proto";
import "org/signal/chat/tag.proto";
// Provides methods for working with devices attached to a Signal account.
service Devices {
// Returns a list of devices associated with the caller's account.
rpc GetDevices(GetDevicesRequest) returns (GetDevicesResponse) {}
// Removes a linked device from the caller's account. This call will fail with
// a status of `PERMISSION_DENIED` if not called from the primary device
// associated with an account. It will also fail with a status of
// `INVALID_ARGUMENT` if the targeted device is the primary device associated
// with the account.
// Removes a linked device from the caller's account.
//
// Linked devices may only remove themselves. Primary devices may remove
// any device other than themselves.
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.
// but linked devices may only change their own name. The response will
// indicate if the target device was not found.
rpc SetDeviceName(SetDeviceNameRequest) returns (SetDeviceNameResponse) {}
// Sets the token(s) the server should use to send new message notifications
@@ -78,32 +79,41 @@ message GetDevicesResponse {
}
message RemoveDeviceRequest {
// The identifier for the device to remove from the authenticated account.
// The identifier for the device to remove from the authenticated account. The
// identifier must not be for the primary device.
uint32 id = 1;
}
message SetDeviceNameRequest {
// A sequence of bytes that encodes an encrypted human-readable name for this
// device.
bytes name = 1;
bytes name = 1 [(require.size) = {min: 1, max: 256}];
// The identifier for the device for which to set a name.
uint32 id = 2;
}
message SetDeviceNameResponse {}
message SetDeviceNameResponse {
oneof response {
// The device name was successfully set
google.protobuf.Empty success = 1;
// No device with the provided identifier was found on the account
errors.NotFound target_device_not_found = 2 [(tag.reason) = "not_found"];
}
}
message RemoveDeviceResponse {}
message SetPushTokenRequest {
message ApnsTokenRequest {
// A "standard" APNs device token.
string apns_token = 1;
string apns_token = 1 [(require.nonEmpty) = true];
}
message FcmTokenRequest {
// An FCM push token.
string fcm_token = 1;
string fcm_token = 1 [(require.nonEmpty) = true];
}
oneof token_request {
@@ -6,6 +6,7 @@
package org.whispersystems.textsecuregcm.grpc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyByte;
import static org.mockito.Mockito.mock;
@@ -184,7 +185,7 @@ class DevicesGrpcServiceTest extends SimpleBaseGrpcTest<DevicesGrpcService, Devi
@Test
void removeDeviceNonPrimaryMismatchAuthenticated() {
mockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, (byte) (Device.PRIMARY_ID + 1));
assertStatusException(Status.PERMISSION_DENIED, () -> authenticatedServiceStub().removeDevice(RemoveDeviceRequest.newBuilder()
assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().removeDevice(RemoveDeviceRequest.newBuilder()
.setId(17)
.build()));
@@ -201,10 +202,10 @@ class DevicesGrpcServiceTest extends SimpleBaseGrpcTest<DevicesGrpcService, Devi
final byte[] deviceName = TestRandomUtil.nextBytes(128);
final SetDeviceNameResponse ignored = authenticatedServiceStub().setDeviceName(SetDeviceNameRequest.newBuilder()
assertTrue(authenticatedServiceStub().setDeviceName(SetDeviceNameRequest.newBuilder()
.setId(deviceId)
.setName(ByteString.copyFrom(deviceName))
.build());
.build()).hasSuccess());
verify(device).setName(deviceName);
}
@@ -239,7 +240,7 @@ class DevicesGrpcServiceTest extends SimpleBaseGrpcTest<DevicesGrpcService, Devi
final byte[] deviceName = TestRandomUtil.nextBytes(128);
assertStatusException(Status.PERMISSION_DENIED,
assertStatusException(Status.INVALID_ARGUMENT,
() -> authenticatedServiceStub().setDeviceName(SetDeviceNameRequest.newBuilder()
.setId(deviceId)
.setName(ByteString.copyFrom(deviceName))
@@ -255,11 +256,10 @@ class DevicesGrpcServiceTest extends SimpleBaseGrpcTest<DevicesGrpcService, Devi
final byte[] deviceName = TestRandomUtil.nextBytes(128);
assertStatusException(Status.NOT_FOUND,
() -> authenticatedServiceStub().setDeviceName(SetDeviceNameRequest.newBuilder()
.setId(Device.PRIMARY_ID + 1)
.setName(ByteString.copyFrom(deviceName))
.build()));
assertTrue(authenticatedServiceStub().setDeviceName(SetDeviceNameRequest.newBuilder()
.setId(Device.PRIMARY_ID + 1)
.setName(ByteString.copyFrom(deviceName))
.build()).hasTargetDeviceNotFound());
}
@ParameterizedTest