Allow for atomic account creation and activation

This commit is contained in:
Jon Chambers
2023-04-21 21:38:44 -04:00
committed by Jon Chambers
parent fb1b1e1c04
commit 66a619a378
10 changed files with 725 additions and 48 deletions

View File

@@ -755,7 +755,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getCdnConfiguration().bucket(), zkProfileOperations, batchIdentityCheckExecutor),
new ProvisioningController(rateLimiters, provisioningManager),
new RegistrationController(accountsManager, phoneVerificationTokenManager, registrationLockVerificationManager,
rateLimiters),
keys, rateLimiters),
new RemoteConfigController(remoteConfigsManager, adminEventLogger,
config.getRemoteConfigConfiguration().authorizedTokens().value(),
config.getRemoteConfigConfiguration().globalConfig()),

View File

@@ -21,6 +21,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
@@ -46,6 +48,8 @@ import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.Keys;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.Util;
@@ -65,18 +69,23 @@ public class RegistrationController {
private static final String COUNTRY_CODE_TAG_NAME = "countryCode";
private static final String REGION_CODE_TAG_NAME = "regionCode";
private static final String VERIFICATION_TYPE_TAG_NAME = "verification";
private static final String ACCOUNT_ACTIVATED_TAG_NAME = "accountActivated";
private final AccountsManager accounts;
private final PhoneVerificationTokenManager phoneVerificationTokenManager;
private final RegistrationLockVerificationManager registrationLockVerificationManager;
private final Keys keys;
private final RateLimiters rateLimiters;
public RegistrationController(final AccountsManager accounts,
final PhoneVerificationTokenManager phoneVerificationTokenManager,
final RegistrationLockVerificationManager registrationLockVerificationManager, final RateLimiters rateLimiters) {
final PhoneVerificationTokenManager phoneVerificationTokenManager,
final RegistrationLockVerificationManager registrationLockVerificationManager,
final Keys keys,
final RateLimiters rateLimiters) {
this.accounts = accounts;
this.phoneVerificationTokenManager = phoneVerificationTokenManager;
this.registrationLockVerificationManager = registrationLockVerificationManager;
this.keys = keys;
this.rateLimiters = rateLimiters;
}
@@ -134,13 +143,45 @@ public class RegistrationController {
throw new WebApplicationException(Response.status(409, "device transfer available").build());
}
final Account account = accounts.create(number, password, signalAgent, registrationRequest.accountAttributes(),
Account account = accounts.create(number, password, signalAgent, registrationRequest.accountAttributes(),
existingAccount.map(Account::getBadges).orElseGet(ArrayList::new));
// If the request includes all the information we need to fully "activate" the account, we should do so
if (registrationRequest.supportsAtomicAccountCreation()) {
assert registrationRequest.aciIdentityKey().isPresent();
assert registrationRequest.pniIdentityKey().isPresent();
assert registrationRequest.aciSignedPreKey().isPresent();
assert registrationRequest.pniSignedPreKey().isPresent();
assert registrationRequest.aciPqLastResortPreKey().isPresent();
assert registrationRequest.pniPqLastResortPreKey().isPresent();
account = accounts.update(account, a -> {
a.setIdentityKey(registrationRequest.aciIdentityKey().get());
a.setPhoneNumberIdentityKey(registrationRequest.pniIdentityKey().get());
});
account = accounts.updateDevice(account, Device.MASTER_ID, device -> {
device.setSignedPreKey(registrationRequest.aciSignedPreKey().get());
device.setPhoneNumberIdentitySignedPreKey(registrationRequest.pniSignedPreKey().get());
registrationRequest.apnToken().ifPresent(apnRegistrationId -> {
device.setApnId(apnRegistrationId.apnRegistrationId());
device.setVoipApnId(apnRegistrationId.voipRegistrationId());
});
registrationRequest.gcmToken().ifPresent(gcmRegistrationId ->
device.setGcmId(gcmRegistrationId.gcmRegistrationId()));
});
keys.storePqLastResort(account.getUuid(), Map.of(Device.MASTER_ID, registrationRequest.aciPqLastResortPreKey().get()));
keys.storePqLastResort(account.getPhoneNumberIdentifier(), Map.of(Device.MASTER_ID, registrationRequest.pniPqLastResortPreKey().get()));
}
Metrics.counter(ACCOUNT_CREATED_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(number)),
Tag.of(VERIFICATION_TYPE_TAG_NAME, verificationType.name())))
Tag.of(VERIFICATION_TYPE_TAG_NAME, verificationType.name()),
Tag.of(ACCOUNT_ACTIVATED_TAG_NAME, String.valueOf(account.isEnabled()))))
.increment();
return new AccountIdentityResponse(account.getUuid(),

View File

@@ -6,14 +6,152 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import com.google.common.annotations.VisibleForTesting;
import io.swagger.v3.oas.annotations.media.Schema;
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
public record RegistrationRequest(String sessionId,
@JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) byte[] recoveryPassword,
@NotNull @Valid AccountAttributes accountAttributes,
boolean skipDeviceTransfer) implements PhoneVerificationRequest {
import javax.validation.Valid;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.NotNull;
import java.util.List;
import java.util.Optional;
public record RegistrationRequest(@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = """
The ID of an existing verification session as it appears in a verification session
metadata object. Must be provided if `recoveryPassword` is not provided; must not be
provided if `recoveryPassword` is provided.
""")
String sessionId,
@JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = """
A base64-encoded registration recovery password. Must be provided if `sessionId` is
not provided; must not be provided if `sessionId` is provided
""")
byte[] recoveryPassword,
@NotNull
@Valid
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
AccountAttributes accountAttributes,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = """
If true, indicates that the end user has elected not to transfer data from another
device even though a device transfer is technically possible given the capabilities of
the calling device and the device associated with the existing account (if any). If
false and if a device transfer is technically possible, the registration request will
fail with an HTTP/409 response indicating that the client should prompt the user to
transfer data from an existing device.
""")
boolean skipDeviceTransfer,
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = """
The ACI-associated identity key for the account, encoded as a base64 string. If
provided, an account will be created "atomically," and all other properties needed for
atomic account creation must also be present.
""")
Optional<String> aciIdentityKey,
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = """
The PNI-associated identity key for the account, encoded as a base64 string. If
provided, an account will be created "atomically," and all other properties needed for
atomic account creation must also be present.
""")
Optional<String> pniIdentityKey,
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = """
A signed EC pre-key to be associated with this account's ACI. If provided, an account
will be created "atomically," and all other properties needed for atomic account
creation must also be present.
""")
Optional<@Valid SignedPreKey> aciSignedPreKey,
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = """
A signed EC pre-key to be associated with this account's PNI. If provided, an account
will be created "atomically," and all other properties needed for atomic account
creation must also be present.
""")
Optional<@Valid SignedPreKey> pniSignedPreKey,
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = """
A signed Kyber-1024 "last resort" pre-key to be associated with this account's ACI. If
provided, an account will be created "atomically," and all other properties needed for
atomic account creation must also be present.
""")
Optional<@Valid SignedPreKey> aciPqLastResortPreKey,
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = """
A signed Kyber-1024 "last resort" pre-key to be associated with this account's PNI. If
provided, an account will be created "atomically," and all other properties needed for
atomic account creation must also be present.
""")
Optional<@Valid SignedPreKey> pniPqLastResortPreKey,
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = """
An APNs token set for the account's primary device. If provided, the account's primary
device will be notified of new messages via push notifications to the given token. If
creating an account "atomically," callers must provide exactly one of an APNs token
set, an FCM token, or an `AccountAttributes` entity with `fetchesMessages` set to
`true`.
""")
Optional<@Valid ApnRegistrationId> apnToken,
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = """
An FCM/GCM token for the account's primary device. If provided, the account's primary
device will be notified of new messages via push notifications to the given token. If
creating an account "atomically," callers must provide exactly one of an APNs token
set, an FCM token, or an `AccountAttributes` entity with `fetchesMessages` set to
`true`.
""")
Optional<@Valid GcmRegistrationId> gcmToken) implements PhoneVerificationRequest {
@AssertTrue
public boolean isEverySignedKeyValid() {
return validatePreKeySignature(aciIdentityKey(), aciSignedPreKey())
&& validatePreKeySignature(pniIdentityKey(), pniSignedPreKey())
&& validatePreKeySignature(aciIdentityKey(), aciPqLastResortPreKey())
&& validatePreKeySignature(pniIdentityKey(), pniPqLastResortPreKey());
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
private static boolean validatePreKeySignature(final Optional<String> maybeIdentityKey,
final Optional<SignedPreKey> maybeSignedPreKey) {
return maybeSignedPreKey.map(signedPreKey -> maybeIdentityKey
.map(identityKey -> PreKeySignatureValidator.validatePreKeySignatures(identityKey, List.of(signedPreKey)))
.orElse(false))
.orElse(true);
}
@AssertTrue
public boolean isCompleteRequest() {
final boolean hasNoAtomicAccountCreationParameters =
aciIdentityKey().isEmpty()
&& pniIdentityKey().isEmpty()
&& aciSignedPreKey().isEmpty()
&& pniSignedPreKey().isEmpty()
&& aciPqLastResortPreKey().isEmpty()
&& pniPqLastResortPreKey().isEmpty();
return supportsAtomicAccountCreation() || hasNoAtomicAccountCreationParameters;
}
public boolean supportsAtomicAccountCreation() {
return hasExactlyOneMessageDeliveryChannel()
&& aciIdentityKey().isPresent()
&& pniIdentityKey().isPresent()
&& aciSignedPreKey().isPresent()
&& pniSignedPreKey().isPresent()
&& aciPqLastResortPreKey().isPresent()
&& pniPqLastResortPreKey().isPresent();
}
@VisibleForTesting
boolean hasExactlyOneMessageDeliveryChannel() {
if (accountAttributes.getFetchesMessages()) {
return apnToken.isEmpty() && gcmToken.isEmpty();
} else {
return apnToken.isPresent() ^ gcmToken.isPresent();
}
}
}