mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 11:28:05 +01:00
Add an authentication-required gRPC service for working with accounts
This commit is contained in:
@@ -119,6 +119,8 @@ import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
|
||||
import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter;
|
||||
import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter;
|
||||
import org.whispersystems.textsecuregcm.grpc.AcceptLanguageInterceptor;
|
||||
import org.whispersystems.textsecuregcm.grpc.AccountsAnonymousGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.AccountsGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor;
|
||||
import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsAnonymousGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsGrpcService;
|
||||
@@ -650,6 +652,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
new BasicCredentialAuthenticationInterceptor(new BaseAccountAuthenticator(accountsManager));
|
||||
|
||||
final ServerBuilder<?> grpcServer = ServerBuilder.forPort(config.getGrpcPort())
|
||||
.addService(ServerInterceptors.intercept(new AccountsGrpcService(accountsManager, rateLimiters, usernameHashZkProofVerifier, registrationRecoveryPasswordsManager), basicCredentialAuthenticationInterceptor))
|
||||
.addService(new AccountsAnonymousGrpcService(accountsManager, rateLimiters))
|
||||
.addService(ExternalServiceCredentialsGrpcService.createForAllExternalServices(config, rateLimiters))
|
||||
.addService(ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config))
|
||||
.addService(ServerInterceptors.intercept(new KeysGrpcService(accountsManager, keys, rateLimiters), basicCredentialAuthenticationInterceptor))
|
||||
|
||||
@@ -73,6 +73,7 @@ import org.whispersystems.textsecuregcm.util.Util;
|
||||
public class AccountController {
|
||||
public static final int MAXIMUM_USERNAME_HASHES_LIST_LENGTH = 20;
|
||||
public static final int USERNAME_HASH_LENGTH = 32;
|
||||
public static final int MAXIMUM_USERNAME_CIPHERTEXT_LENGTH = 128;
|
||||
|
||||
private final AccountsManager accounts;
|
||||
private final RateLimiters rateLimiters;
|
||||
|
||||
@@ -31,6 +31,6 @@ public record ConfirmUsernameHashRequest(
|
||||
@Nullable
|
||||
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
|
||||
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
|
||||
@Size(min = 1, max = 128)
|
||||
@Size(min = 1, max = AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH)
|
||||
byte[] encryptedUsername
|
||||
) {}
|
||||
|
||||
@@ -16,7 +16,9 @@ public record EncryptedUsername(
|
||||
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
|
||||
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
|
||||
@NotNull
|
||||
@Size(min = 1, max = 128)
|
||||
@Size(min = 1, max = EncryptedUsername.MAX_SIZE)
|
||||
@Schema(type = "string", description = "the URL-safe base64 encoding of the encrypted username")
|
||||
byte[] usernameLinkEncryptedValue) {
|
||||
|
||||
public static final int MAX_SIZE = 128;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.grpc.Status;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.signal.chat.account.ClearRegistrationLockRequest;
|
||||
import org.signal.chat.account.ClearRegistrationLockResponse;
|
||||
import org.signal.chat.account.ConfigureUnidentifiedAccessRequest;
|
||||
import org.signal.chat.account.ConfigureUnidentifiedAccessResponse;
|
||||
import org.signal.chat.account.ConfirmUsernameHashRequest;
|
||||
import org.signal.chat.account.ConfirmUsernameHashResponse;
|
||||
import org.signal.chat.account.DeleteAccountRequest;
|
||||
import org.signal.chat.account.DeleteAccountResponse;
|
||||
import org.signal.chat.account.DeleteUsernameHashRequest;
|
||||
import org.signal.chat.account.DeleteUsernameHashResponse;
|
||||
import org.signal.chat.account.DeleteUsernameLinkRequest;
|
||||
import org.signal.chat.account.DeleteUsernameLinkResponse;
|
||||
import org.signal.chat.account.GetAccountIdentityRequest;
|
||||
import org.signal.chat.account.GetAccountIdentityResponse;
|
||||
import org.signal.chat.account.ReactorAccountsGrpc;
|
||||
import org.signal.chat.account.ReserveUsernameHashError;
|
||||
import org.signal.chat.account.ReserveUsernameHashErrorType;
|
||||
import org.signal.chat.account.ReserveUsernameHashRequest;
|
||||
import org.signal.chat.account.ReserveUsernameHashResponse;
|
||||
import org.signal.chat.account.SetDiscoverableByPhoneNumberRequest;
|
||||
import org.signal.chat.account.SetDiscoverableByPhoneNumberResponse;
|
||||
import org.signal.chat.account.SetRegistrationLockRequest;
|
||||
import org.signal.chat.account.SetRegistrationLockResponse;
|
||||
import org.signal.chat.account.SetRegistrationRecoveryPasswordRequest;
|
||||
import org.signal.chat.account.SetRegistrationRecoveryPasswordResponse;
|
||||
import org.signal.chat.account.SetUsernameLinkRequest;
|
||||
import org.signal.chat.account.SetUsernameLinkResponse;
|
||||
import org.signal.chat.common.AccountIdentifiers;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
|
||||
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
|
||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||
import org.whispersystems.textsecuregcm.entities.EncryptedUsername;
|
||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final UsernameHashZkProofVerifier usernameHashZkProofVerifier;
|
||||
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
|
||||
|
||||
public AccountsGrpcService(final AccountsManager accountsManager,
|
||||
final RateLimiters rateLimiters,
|
||||
final UsernameHashZkProofVerifier usernameHashZkProofVerifier,
|
||||
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager) {
|
||||
|
||||
this.accountsManager = accountsManager;
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.usernameHashZkProofVerifier = usernameHashZkProofVerifier;
|
||||
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GetAccountIdentityResponse> getAccountIdentity(final GetAccountIdentityRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.map(account -> {
|
||||
final AccountIdentifiers.Builder accountIdentifiersBuilder = AccountIdentifiers.newBuilder()
|
||||
.addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
|
||||
.addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new PniServiceIdentifier(account.getPhoneNumberIdentifier())))
|
||||
.setE164(account.getNumber());
|
||||
|
||||
account.getUsernameHash().ifPresent(usernameHash ->
|
||||
accountIdentifiersBuilder.setUsernameHash(ByteString.copyFrom(usernameHash)));
|
||||
|
||||
return GetAccountIdentityResponse.newBuilder()
|
||||
.setAccountIdentifiers(accountIdentifiersBuilder.build())
|
||||
.build();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<DeleteAccountResponse> deleteAccount(final DeleteAccountRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice();
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.delete(account, AccountsManager.DeletionReason.USER_REQUEST)))
|
||||
.thenReturn(DeleteAccountResponse.newBuilder().build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SetRegistrationLockResponse> setRegistrationLock(final SetRegistrationLockRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice();
|
||||
|
||||
if (request.getRegistrationLock().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Registration lock secret must not be empty").asRuntimeException();
|
||||
}
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.flatMap(account -> {
|
||||
// In the previous REST-based API, clients would send hex strings directly. For backward compatibility, we
|
||||
// convert the registration lock secret to a lowercase hex string before turning it into a salted hash.
|
||||
final SaltedTokenHash credentials =
|
||||
SaltedTokenHash.generateFor(HexFormat.of().withLowerCase().formatHex(request.getRegistrationLock().toByteArray()));
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.updateAsync(account,
|
||||
a -> a.setRegistrationLock(credentials.hash(), credentials.salt())));
|
||||
})
|
||||
.map(ignored -> SetRegistrationLockResponse.newBuilder().build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ClearRegistrationLockResponse> clearRegistrationLock(final ClearRegistrationLockRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice();
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account,
|
||||
a -> a.setRegistrationLock(null, null))))
|
||||
.map(ignored -> ClearRegistrationLockResponse.newBuilder().build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ReserveUsernameHashResponse> reserveUsernameHash(final ReserveUsernameHashRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
if (request.getUsernameHashesCount() == 0) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("List of username hashes must not be empty")
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
if (request.getUsernameHashesCount() > AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription(String.format("List of username hashes may have at most %d elements, but actually had %d",
|
||||
AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH, request.getUsernameHashesCount()))
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
final List<byte[]> usernameHashes = new ArrayList<>(request.getUsernameHashesCount());
|
||||
|
||||
for (final ByteString usernameHash : request.getUsernameHashesList()) {
|
||||
if (usernameHash.size() != AccountController.USERNAME_HASH_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription(String.format("Username hash length must be %d bytes, but was actually %d",
|
||||
AccountController.USERNAME_HASH_LENGTH, usernameHash.size()))
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
usernameHashes.add(usernameHash.toByteArray());
|
||||
}
|
||||
|
||||
return rateLimiters.getUsernameReserveLimiter().validateReactive(authenticatedDevice.accountIdentifier())
|
||||
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.reserveUsernameHash(account, usernameHashes)))
|
||||
.map(reservation -> ReserveUsernameHashResponse.newBuilder()
|
||||
.setUsernameHash(ByteString.copyFrom(reservation.reservedUsernameHash()))
|
||||
.build())
|
||||
.onErrorReturn(UsernameHashNotAvailableException.class, ReserveUsernameHashResponse.newBuilder()
|
||||
.setError(ReserveUsernameHashError.newBuilder()
|
||||
.setErrorType(ReserveUsernameHashErrorType.RESERVE_USERNAME_HASH_ERROR_TYPE_NO_HASHES_AVAILABLE)
|
||||
.build())
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ConfirmUsernameHashResponse> confirmUsernameHash(final ConfirmUsernameHashRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
if (request.getUsernameHash().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Username hash must not be empty")
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
if (request.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription(String.format("Username hash length must be %d bytes, but was actually %d",
|
||||
AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size()))
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
if (request.getZkProof().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Zero-knowledge proof must not be empty")
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
if (request.getUsernameCiphertext().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Username ciphertext must not be empty")
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
if (request.getUsernameCiphertext().size() > AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription(String.format("Username hash length must at most %d bytes, but was actually %d",
|
||||
AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH, request.getUsernameCiphertext().size()))
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
try {
|
||||
usernameHashZkProofVerifier.verifyProof(request.getZkProof().toByteArray(), request.getUsernameHash().toByteArray());
|
||||
} catch (final BaseUsernameException e) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Could not verify proof").asRuntimeException();
|
||||
}
|
||||
|
||||
return rateLimiters.getUsernameSetLimiter().validateReactive(authenticatedDevice.accountIdentifier())
|
||||
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.confirmReservedUsernameHash(account, request.getUsernameHash().toByteArray(), request.getUsernameCiphertext().toByteArray())))
|
||||
.map(updatedAccount -> ConfirmUsernameHashResponse.newBuilder()
|
||||
.setUsernameHash(ByteString.copyFrom(updatedAccount.getUsernameHash().orElseThrow()))
|
||||
.setUsernameLinkHandle(UUIDUtil.toByteString(updatedAccount.getUsernameLinkHandle()))
|
||||
.build())
|
||||
.onErrorMap(UsernameReservationNotFoundException.class, throwable -> Status.FAILED_PRECONDITION.asRuntimeException())
|
||||
.onErrorMap(UsernameHashNotAvailableException.class, throwable -> Status.NOT_FOUND.asRuntimeException());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<DeleteUsernameHashResponse> deleteUsernameHash(final DeleteUsernameHashRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.clearUsernameHash(account)))
|
||||
.thenReturn(DeleteUsernameHashResponse.newBuilder().build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SetUsernameLinkResponse> setUsernameLink(final SetUsernameLinkRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
if (request.getUsernameCiphertext().isEmpty() || request.getUsernameCiphertext().size() > EncryptedUsername.MAX_SIZE) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription(String.format("Username ciphertext must not be empty and must be shorter than %d bytes", EncryptedUsername.MAX_SIZE))
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
return rateLimiters.getUsernameLinkOperationLimiter().validateReactive(authenticatedDevice.accountIdentifier())
|
||||
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.flatMap(account -> {
|
||||
if (account.getUsernameHash().isEmpty()) {
|
||||
return Mono.error(Status.FAILED_PRECONDITION
|
||||
.withDescription("Account does not have a username hash")
|
||||
.asRuntimeException());
|
||||
}
|
||||
|
||||
final UUID linkHandle = UUID.randomUUID();
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> a.setUsernameLinkDetails(linkHandle, request.getUsernameCiphertext().toByteArray())))
|
||||
.thenReturn(linkHandle);
|
||||
})
|
||||
.map(linkHandle -> SetUsernameLinkResponse.newBuilder()
|
||||
.setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle))
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<DeleteUsernameLinkResponse> deleteUsernameLink(final DeleteUsernameLinkRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
return rateLimiters.getUsernameLinkOperationLimiter().validateReactive(authenticatedDevice.accountIdentifier())
|
||||
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> a.setUsernameLinkDetails(null, null))))
|
||||
.thenReturn(DeleteUsernameLinkResponse.newBuilder().build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ConfigureUnidentifiedAccessResponse> configureUnidentifiedAccess(final ConfigureUnidentifiedAccessRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
if (!request.getAllowUnrestrictedUnidentifiedAccess() && request.getUnidentifiedAccessKey().size() != UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription(String.format("Unidentified access key must be %d bytes, but was actually %d",
|
||||
UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH, request.getUnidentifiedAccessKey().size()))
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> {
|
||||
a.setUnrestrictedUnidentifiedAccess(request.getAllowUnrestrictedUnidentifiedAccess());
|
||||
a.setUnidentifiedAccessKey(request.getAllowUnrestrictedUnidentifiedAccess() ? null : request.getUnidentifiedAccessKey().toByteArray());
|
||||
})))
|
||||
.thenReturn(ConfigureUnidentifiedAccessResponse.newBuilder().build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SetDiscoverableByPhoneNumberResponse> setDiscoverableByPhoneNumber(final SetDiscoverableByPhoneNumberRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account,
|
||||
a -> a.setDiscoverableByPhoneNumber(request.getDiscoverableByPhoneNumber()))))
|
||||
.thenReturn(SetDiscoverableByPhoneNumberResponse.newBuilder().build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SetRegistrationRecoveryPasswordResponse> setRegistrationRecoveryPassword(final SetRegistrationRecoveryPasswordRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
if (request.getRegistrationRecoveryPassword().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Registration recovery password must not be empty")
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.flatMap(account -> Mono.fromFuture(() -> registrationRecoveryPasswordsManager.storeForCurrentNumber(account.getNumber(), request.getRegistrationRecoveryPassword().toByteArray())))
|
||||
.thenReturn(SetRegistrationRecoveryPasswordResponse.newBuilder().build());
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,10 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
|
||||
return forDescriptor(For.USERNAME_LINK_LOOKUP_PER_IP);
|
||||
}
|
||||
|
||||
public RateLimiter getUsernameLinkOperationLimiter() {
|
||||
return forDescriptor(For.USERNAME_LINK_OPERATION);
|
||||
}
|
||||
|
||||
public RateLimiter getUsernameSetLimiter() {
|
||||
return forDescriptor(For.USERNAME_SET);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,101 @@ package org.signal.chat.account;
|
||||
|
||||
import "org/signal/chat/common.proto";
|
||||
|
||||
/**
|
||||
* Provides methods for working with Signal accounts.
|
||||
*/
|
||||
service Accounts {
|
||||
/**
|
||||
* Returns basic identifiers for the authenticated account.
|
||||
*/
|
||||
rpc GetAccountIdentity(GetAccountIdentityRequest) returns (GetAccountIdentityResponse) {}
|
||||
|
||||
/**
|
||||
* Deletes the authenticated account, purging all associated data in the
|
||||
* process.
|
||||
*/
|
||||
rpc DeleteAccount(DeleteAccountRequest) returns (DeleteAccountResponse) {}
|
||||
|
||||
/**
|
||||
* Sets the registration lock secret for the authenticated account. To remove
|
||||
* a registration lock, please use `ClearRegistrationLock`.
|
||||
*/
|
||||
rpc SetRegistrationLock(SetRegistrationLockRequest) returns (SetRegistrationLockResponse) {}
|
||||
|
||||
/**
|
||||
* Removes any registration lock credentials from the authenticated account.
|
||||
*/
|
||||
rpc ClearRegistrationLock(ClearRegistrationLockRequest) returns (ClearRegistrationLockResponse) {}
|
||||
|
||||
/**
|
||||
* Attempts to reserve one of multiple given username hashes. Reserved
|
||||
* usernames may be claimed later via `ConfirmUsernameHash`. This RPC may
|
||||
* fail with a `RESOURCE_EXHAUSTED` status if a rate limit for modifying
|
||||
* usernames has been exceeded, in which case a `retry-after` header
|
||||
* containing an ISO 8601 duration string will be present in the response
|
||||
* trailers.
|
||||
*/
|
||||
rpc ReserveUsernameHash(ReserveUsernameHashRequest) returns (ReserveUsernameHashResponse) {}
|
||||
|
||||
/**
|
||||
* Sets the username hash/encrypted username to a previously-reserved value
|
||||
* (see `ReserveUsernameHash`). This RPC may fail with a status of
|
||||
* `FAILED_PRECONDITION` if no reserved username hash was foudn for the given
|
||||
* account or `NOT_FOUND` if the reservation has lapsed and been claimed by
|
||||
* another caller. It may also fail with a `RESOURCE_EXHAUSTED` if a rate
|
||||
* limit for modifying usernames has been exceeded, in which case a
|
||||
* `retry-after` header containing an ISO 8601 duration string will be present
|
||||
* in the response trailers.
|
||||
*/
|
||||
rpc ConfirmUsernameHash(ConfirmUsernameHashRequest) returns (ConfirmUsernameHashResponse) {}
|
||||
|
||||
/**
|
||||
* Clears the current username hash, ciphertext, and link for the
|
||||
* authenticated user.
|
||||
*/
|
||||
rpc DeleteUsernameHash(DeleteUsernameHashRequest) returns (DeleteUsernameHashResponse) {}
|
||||
|
||||
/**
|
||||
* Generates a new link handle for the given username ciphertext, displacing
|
||||
* any previously-existing link handle.
|
||||
*
|
||||
* This RPC may fail with a status of `FAILED_PRECONDITION` if the
|
||||
* authenticated account does not have a username. It may also fail with
|
||||
* `RESOURCE_EXHAUSTED` if a rate limit for modifying username links has been
|
||||
* exceeded, in which case a `retry-after` header containing an ISO 8601
|
||||
* duration string will be present in the response trailers.
|
||||
*/
|
||||
rpc SetUsernameLink(SetUsernameLinkRequest) returns (SetUsernameLinkResponse) {}
|
||||
|
||||
/**
|
||||
* Clears any username link associated with the authenticated account. This
|
||||
* RPC may fail with `RESOURCE_EXHAUSTED` if a rate limit for modifying
|
||||
* username links has been exceeded, in which case a `retry-after` header
|
||||
* containing an ISO 8601 duration string will be present in the response
|
||||
* trailers.
|
||||
*/
|
||||
rpc DeleteUsernameLink(DeleteUsernameLinkRequest) returns (DeleteUsernameLinkResponse) {}
|
||||
|
||||
/**
|
||||
* Configures "unidentified access" keys and preferences for the authenticated
|
||||
* account. Other users permitted to interact with this account anonymously
|
||||
* may take actions like fetching pre-keys and profiles for this account or
|
||||
* sending sealed-sender messages without providing identifying credentials.
|
||||
*/
|
||||
rpc ConfigureUnidentifiedAccess(ConfigureUnidentifiedAccessRequest) returns (ConfigureUnidentifiedAccessResponse) {}
|
||||
|
||||
/**
|
||||
* Sets whether the authenticated account may be discovered by phone number
|
||||
* via the Contact Discovery Service (CDS).
|
||||
*/
|
||||
rpc SetDiscoverableByPhoneNumber(SetDiscoverableByPhoneNumberRequest) returns (SetDiscoverableByPhoneNumberResponse) {}
|
||||
|
||||
/**
|
||||
* Sets the registration recovery password for the authenticated account.
|
||||
*/
|
||||
rpc SetRegistrationRecoveryPassword(SetRegistrationRecoveryPasswordRequest) returns (SetRegistrationRecoveryPasswordResponse) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides methods for looking up Signal accounts. Callers must not provide
|
||||
* identifying credentials when calling methods in this service.
|
||||
@@ -31,6 +126,172 @@ service AccountsAnonymous {
|
||||
rpc LookupUsernameLink(LookupUsernameLinkRequest) returns (LookupUsernameLinkResponse) {}
|
||||
}
|
||||
|
||||
message GetAccountIdentityRequest {
|
||||
}
|
||||
|
||||
message GetAccountIdentityResponse {
|
||||
/**
|
||||
* A set of account identifiers for the authenticated account.
|
||||
*/
|
||||
common.AccountIdentifiers account_identifiers = 1;
|
||||
}
|
||||
|
||||
message DeleteAccountRequest {
|
||||
}
|
||||
|
||||
message DeleteAccountResponse {
|
||||
}
|
||||
|
||||
message SetRegistrationLockRequest {
|
||||
/**
|
||||
* The new registration lock secret for the authenticated account.
|
||||
*/
|
||||
bytes registration_lock = 1;
|
||||
}
|
||||
|
||||
message SetRegistrationLockResponse {
|
||||
}
|
||||
|
||||
message ClearRegistrationLockRequest {
|
||||
}
|
||||
|
||||
message ClearRegistrationLockResponse {
|
||||
}
|
||||
|
||||
message ReserveUsernameHashRequest {
|
||||
/**
|
||||
* A prioritized list of username hashes to attempt to reserve.
|
||||
*/
|
||||
repeated bytes username_hashes = 1;
|
||||
}
|
||||
|
||||
message ReserveUsernameHashResponse {
|
||||
oneof response {
|
||||
/**
|
||||
* The first username hash that was available (and actually reserved).
|
||||
*/
|
||||
bytes username_hash = 1;
|
||||
|
||||
/**
|
||||
* An error indicating why a username hash could not be reserved.
|
||||
*/
|
||||
ReserveUsernameHashError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message ReserveUsernameHashError {
|
||||
ReserveUsernameHashErrorType error_type = 1;
|
||||
}
|
||||
|
||||
enum ReserveUsernameHashErrorType {
|
||||
RESERVE_USERNAME_HASH_ERROR_TYPE_UNSPECIFIED = 0;
|
||||
|
||||
/**
|
||||
* Indicates that, of all of the candidate hashes provided, none were
|
||||
* available. Callers may generate a new set of hashes and and retry.
|
||||
*/
|
||||
RESERVE_USERNAME_HASH_ERROR_TYPE_NO_HASHES_AVAILABLE = 1;
|
||||
}
|
||||
|
||||
message ConfirmUsernameHashRequest {
|
||||
/**
|
||||
* The username hash to claim for the authenticated account.
|
||||
*/
|
||||
bytes username_hash = 1;
|
||||
|
||||
/**
|
||||
* A zero-knowledge proof that the given username hash was generated by the
|
||||
* Signal username algorithm.
|
||||
*/
|
||||
bytes zk_proof = 2;
|
||||
|
||||
/**
|
||||
* The ciphertext of the chosen username for use in public-facing contexts
|
||||
* (e.g. links and QR codes).
|
||||
*/
|
||||
bytes username_ciphertext = 3;
|
||||
}
|
||||
|
||||
message ConfirmUsernameHashResponse {
|
||||
/**
|
||||
* The newly-confirmed username hash.
|
||||
*/
|
||||
bytes username_hash = 1;
|
||||
|
||||
/**
|
||||
* The server-generated username link handle for the newly-confirmed username.
|
||||
*/
|
||||
bytes username_link_handle = 2;
|
||||
}
|
||||
|
||||
message DeleteUsernameHashRequest {
|
||||
}
|
||||
|
||||
message DeleteUsernameHashResponse {
|
||||
}
|
||||
|
||||
message SetUsernameLinkRequest {
|
||||
/**
|
||||
* The username ciphertext for which to generate a new link handle.
|
||||
*/
|
||||
bytes username_ciphertext = 1;
|
||||
}
|
||||
|
||||
message SetUsernameLinkResponse {
|
||||
/**
|
||||
* A new link handle for the given username ciphertext.
|
||||
*/
|
||||
bytes username_link_handle = 1;
|
||||
}
|
||||
|
||||
message DeleteUsernameLinkRequest {
|
||||
}
|
||||
|
||||
message DeleteUsernameLinkResponse {
|
||||
}
|
||||
|
||||
message ConfigureUnidentifiedAccessRequest {
|
||||
/**
|
||||
* The key that other users must provide to interact with this account
|
||||
* anonymously (i.e. to retrieve keys or profiles or to send messages) unless
|
||||
* unrestricted unidentified access is permitted. Must be present if
|
||||
* unrestricted unidentified access is not allowed.
|
||||
*/
|
||||
bytes unidentified_access_key = 1;
|
||||
|
||||
/**
|
||||
* If `true`, any user may interact with this account anonymously without
|
||||
* providing an unidentified access key. Otherwise, users must provide the
|
||||
* given unidentified access key to interact with this account anonymously.
|
||||
*/
|
||||
bool allow_unrestricted_unidentified_access = 2;
|
||||
}
|
||||
|
||||
message ConfigureUnidentifiedAccessResponse {
|
||||
}
|
||||
|
||||
message SetDiscoverableByPhoneNumberRequest {
|
||||
/**
|
||||
* If true, the authenticated account may be discovered by phone number via
|
||||
* the Contact Discovery Service (CDS). Otherwise, other users must discover
|
||||
* this account by other means (i.e. by username).
|
||||
*/
|
||||
bool discoverable_by_phone_number = 1;
|
||||
}
|
||||
|
||||
message SetDiscoverableByPhoneNumberResponse {
|
||||
}
|
||||
|
||||
message SetRegistrationRecoveryPasswordRequest {
|
||||
/**
|
||||
* The new registration recovery password for the authenticated account.
|
||||
*/
|
||||
bytes registration_recovery_password = 1;
|
||||
}
|
||||
|
||||
message SetRegistrationRecoveryPasswordResponse {
|
||||
}
|
||||
|
||||
message CheckAccountExistenceRequest {
|
||||
/**
|
||||
* The service identifier of an account that may or may not exist.
|
||||
|
||||
@@ -28,8 +28,20 @@ message ServiceIdentifier {
|
||||
}
|
||||
|
||||
message AccountIdentifiers {
|
||||
/**
|
||||
* A list of service identifiers for the identified account.
|
||||
*/
|
||||
repeated ServiceIdentifier service_identifiers = 1;
|
||||
|
||||
/**
|
||||
* The phone number associated with the identified account.
|
||||
*/
|
||||
string e164 = 2;
|
||||
|
||||
/**
|
||||
* The username hash (if any) associated with the identified account. May be
|
||||
* empty if no username is associated with the identified account.
|
||||
*/
|
||||
bytes username_hash = 3;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user