Add initial registration v5 prototype.

This commit is contained in:
Greyson Parrelli
2025-11-25 16:55:29 -05:00
committed by jeffrey-signal
parent 1a5163fc47
commit 5ea5279fbb
105 changed files with 8146 additions and 44 deletions

View File

@@ -0,0 +1,8 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.push.exceptions
class InvalidRegistrationSessionIdException : NonSuccessfulResponseCodeException(400)

View File

@@ -44,6 +44,7 @@ import org.whispersystems.signalservice.api.push.exceptions.ExpectationFailedExc
import org.whispersystems.signalservice.api.push.exceptions.ExternalServiceFailureException;
import org.whispersystems.signalservice.api.push.exceptions.HttpConflictException;
import org.whispersystems.signalservice.api.push.exceptions.IncorrectRegistrationRecoveryPasswordException;
import org.whispersystems.signalservice.api.push.exceptions.InvalidRegistrationSessionIdException;
import org.whispersystems.signalservice.api.push.exceptions.InvalidTransportModeException;
import org.whispersystems.signalservice.api.push.exceptions.MalformedRequestException;
import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseException;
@@ -340,6 +341,184 @@ public class PushServiceSocket {
return JsonUtil.fromJson(response, VerifyAccountResponse.class);
}
/**
* V2 API: Creates a verification session and returns the raw Response for manual handling.
* Caller is responsible for closing the response.
*/
public Response createVerificationSessionV2(@Nonnull String e164, @Nullable String pushToken, @Nullable String mcc, @Nullable String mnc) throws IOException {
final String jsonBody = JsonUtil.toJson(new VerificationSessionMetadataRequestBody(e164, pushToken, mcc, mnc));
return makeServiceRequestWithoutValidation(VERIFICATION_SESSION_PATH, "POST", jsonRequestBody(jsonBody), NO_HEADERS, SealedSenderAccess.NONE, false);
}
/**
* V2 API: Gets session status and returns the raw Response for manual handling.
* Caller is responsible for closing the response.
*/
public Response getSessionStatusV2(String sessionId) throws IOException {
String path = VERIFICATION_SESSION_PATH + "/" + sessionId;
return makeServiceRequestWithoutValidation(path, "GET", jsonRequestBody(null), NO_HEADERS, SealedSenderAccess.NONE, false);
}
/**
* V2 API: Patches verification session and returns the raw Response for manual handling.
* Caller is responsible for closing the response.
*/
public Response patchVerificationSessionV2(String sessionId, @Nullable String pushToken, @Nullable String mcc, @Nullable String mnc, @Nullable String captchaToken, @Nullable String pushChallengeToken) throws IOException {
String path = VERIFICATION_SESSION_PATH + "/" + sessionId;
final UpdateVerificationSessionRequestBody requestBody = new UpdateVerificationSessionRequestBody(captchaToken, pushToken, pushChallengeToken, mcc, mnc);
return makeServiceRequestWithoutValidation(path, "PATCH", jsonRequestBody(JsonUtil.toJson(requestBody)), NO_HEADERS, SealedSenderAccess.NONE, false);
}
/**
* V2 API: Requests verification code and returns the raw Response for manual handling.
* Caller is responsible for closing the response.
*/
public Response requestVerificationCodeV2(String sessionId, Locale locale, boolean androidSmsRetriever, VerificationCodeTransport transport) throws IOException {
String path = String.format(VERIFICATION_CODE_PATH, sessionId);
Map<String, String> headers = locale != null ? Collections.singletonMap("Accept-Language", locale.getLanguage() + "-" + locale.getCountry()) : NO_HEADERS;
Map<String, String> body = new HashMap<>();
switch (transport) {
case SMS:
body.put("transport", "sms");
break;
case VOICE:
body.put("transport", "voice");
break;
}
body.put("client", androidSmsRetriever ? "android-2021-03" : "android");
return makeServiceRequestWithoutValidation(path, "POST", jsonRequestBody(JsonUtil.toJson(body)), headers, SealedSenderAccess.NONE, false);
}
/**
* V2 API: Submits verification code and returns the raw Response for manual handling.
* Caller is responsible for closing the response.
*/
public Response submitVerificationCodeV2(String sessionId, String verificationCode) throws IOException {
String path = String.format(VERIFICATION_CODE_PATH, sessionId);
Map<String, String> body = new HashMap<>();
body.put("code", verificationCode);
return makeServiceRequestWithoutValidation(path, "PUT", jsonRequestBody(JsonUtil.toJson(body)), NO_HEADERS, SealedSenderAccess.NONE, false);
}
/**
* V2 API: Submits registration request and returns the raw Response for manual handling.
* Caller is responsible for closing the response.
*/
public Response submitRegistrationRequestV2(@Nullable String sessionId, @Nullable String recoveryPassword, AccountAttributes attributes, PreKeyCollection aciPreKeys, PreKeyCollection pniPreKeys, @Nullable String fcmToken, boolean skipDeviceTransfer) throws IOException {
String path = REGISTRATION_PATH;
if (sessionId == null && recoveryPassword == null) {
throw new IllegalArgumentException("Neither Session ID nor Recovery Password provided.");
}
if (sessionId != null && recoveryPassword != null) {
throw new IllegalArgumentException("You must supply one and only one of either: Session ID, or Recovery Password.");
}
GcmRegistrationId gcmRegistrationId;
if (attributes.getFetchesMessages()) {
gcmRegistrationId = null;
} else {
gcmRegistrationId = new GcmRegistrationId(fcmToken, true);
}
RegistrationSessionRequestBody body;
try {
final SignedPreKeyEntity aciSignedPreKey = new SignedPreKeyEntity(Objects.requireNonNull(aciPreKeys.getSignedPreKey()).getId(),
aciPreKeys.getSignedPreKey().getKeyPair().getPublicKey(),
aciPreKeys.getSignedPreKey().getSignature());
final SignedPreKeyEntity pniSignedPreKey = new SignedPreKeyEntity(Objects.requireNonNull(pniPreKeys.getSignedPreKey()).getId(),
pniPreKeys.getSignedPreKey().getKeyPair().getPublicKey(),
pniPreKeys.getSignedPreKey().getSignature());
final KyberPreKeyEntity aciLastResortKyberPreKey = new KyberPreKeyEntity(Objects.requireNonNull(aciPreKeys.getLastResortKyberPreKey()).getId(),
aciPreKeys.getLastResortKyberPreKey().getKeyPair().getPublicKey(),
aciPreKeys.getLastResortKyberPreKey().getSignature());
final KyberPreKeyEntity pniLastResortKyberPreKey = new KyberPreKeyEntity(Objects.requireNonNull(pniPreKeys.getLastResortKyberPreKey()).getId(),
pniPreKeys.getLastResortKyberPreKey().getKeyPair().getPublicKey(),
pniPreKeys.getLastResortKyberPreKey().getSignature());
body = new RegistrationSessionRequestBody(sessionId,
recoveryPassword,
attributes,
Base64.encodeWithoutPadding(aciPreKeys.getIdentityKey().serialize()),
Base64.encodeWithoutPadding(pniPreKeys.getIdentityKey().serialize()),
aciSignedPreKey,
pniSignedPreKey,
aciLastResortKyberPreKey,
pniLastResortKyberPreKey,
gcmRegistrationId,
skipDeviceTransfer,
true);
} catch (InvalidKeyException e) {
throw new AssertionError("unexpected invalid key", e);
}
return makeServiceRequestWithoutValidation(path, "POST", jsonRequestBody(JsonUtil.toJson(body)), NO_HEADERS, SealedSenderAccess.NONE, false);
}
/**
* V2 API: Submits registration request with explicit credentials and returns the raw Response for manual handling.
* Caller is responsible for closing the response.
*
* @param e164 The phone number in E.164 format (used as username for basic auth)
* @param password The password for basic auth
*/
public Response submitRegistrationRequestV2(@Nonnull String e164, @Nonnull String password, @Nullable String sessionId, @Nullable String recoveryPassword, AccountAttributes attributes, PreKeyCollection aciPreKeys, PreKeyCollection pniPreKeys, @Nullable String fcmToken, boolean skipDeviceTransfer) throws IOException {
String path = REGISTRATION_PATH;
if (sessionId == null && recoveryPassword == null) {
throw new IllegalArgumentException("Neither Session ID nor Recovery Password provided.");
}
if (sessionId != null && recoveryPassword != null) {
throw new IllegalArgumentException("You must supply one and only one of either: Session ID, or Recovery Password.");
}
GcmRegistrationId gcmRegistrationId;
if (attributes.getFetchesMessages()) {
gcmRegistrationId = null;
} else {
gcmRegistrationId = new GcmRegistrationId(fcmToken, true);
}
RegistrationSessionRequestBody body;
try {
final SignedPreKeyEntity aciSignedPreKey = new SignedPreKeyEntity(Objects.requireNonNull(aciPreKeys.getSignedPreKey()).getId(),
aciPreKeys.getSignedPreKey().getKeyPair().getPublicKey(),
aciPreKeys.getSignedPreKey().getSignature());
final SignedPreKeyEntity pniSignedPreKey = new SignedPreKeyEntity(Objects.requireNonNull(pniPreKeys.getSignedPreKey()).getId(),
pniPreKeys.getSignedPreKey().getKeyPair().getPublicKey(),
pniPreKeys.getSignedPreKey().getSignature());
final KyberPreKeyEntity aciLastResortKyberPreKey = new KyberPreKeyEntity(Objects.requireNonNull(aciPreKeys.getLastResortKyberPreKey()).getId(),
aciPreKeys.getLastResortKyberPreKey().getKeyPair().getPublicKey(),
aciPreKeys.getLastResortKyberPreKey().getSignature());
final KyberPreKeyEntity pniLastResortKyberPreKey = new KyberPreKeyEntity(Objects.requireNonNull(pniPreKeys.getLastResortKyberPreKey()).getId(),
pniPreKeys.getLastResortKyberPreKey().getKeyPair().getPublicKey(),
pniPreKeys.getLastResortKyberPreKey().getSignature());
body = new RegistrationSessionRequestBody(sessionId,
recoveryPassword,
attributes,
Base64.encodeWithoutPadding(aciPreKeys.getIdentityKey().serialize()),
Base64.encodeWithoutPadding(pniPreKeys.getIdentityKey().serialize()),
aciSignedPreKey,
pniSignedPreKey,
aciLastResortKyberPreKey,
pniLastResortKyberPreKey,
gcmRegistrationId,
skipDeviceTransfer,
true);
} catch (InvalidKeyException e) {
throw new AssertionError("unexpected invalid key", e);
}
String authHeader = "Basic " + Base64.encodeWithPadding((e164 + ":" + password).getBytes("UTF-8"));
Map<String, String> headers = Collections.singletonMap("Authorization", authHeader);
return makeServiceRequestWithoutValidation(path, "POST", jsonRequestBody(JsonUtil.toJson(body)), headers, SealedSenderAccess.NONE, false);
}
public void setRestoreMethodChosen(@Nonnull String token, @Nonnull RestoreMethodBody request) throws IOException {
String body = JsonUtil.toJson(request);
makeServiceRequest(String.format(Locale.US, SET_RESTORE_METHOD_PATH, urlEncode(token)), "PUT", body, NO_HEADERS, UNOPINIONATED_HANDLER, SealedSenderAccess.NONE);
@@ -1167,6 +1346,26 @@ public class PushServiceSocket {
}
}
private Response makeServiceRequestWithoutValidation(String urlFragment,
String method,
RequestBody body,
Map<String, String> headers,
@Nullable SealedSenderAccess sealedSenderAccess,
boolean doNotAddAuthenticationOrUnidentifiedAccessKey)
throws PushNetworkException
{
Response response = null;
try {
response = getServiceConnection(urlFragment, method, body, headers, sealedSenderAccess, doNotAddAuthenticationOrUnidentifiedAccessKey);
return response;
} catch (Exception e) {
if (response != null && response.body() != null) {
response.body().close();
}
throw e;
}
}
private Response validateServiceResponse(Response response)
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException {
int responseCode = response.code();
@@ -1903,7 +2102,9 @@ public class PushServiceSocket {
@Override
public void handle(int responseCode, ResponseBody body, Function<String, String> getHeader) throws NonSuccessfulResponseCodeException, PushNetworkException {
if (responseCode == 403) {
if (responseCode == 400) {
throw new InvalidRegistrationSessionIdException();
} if (responseCode == 403) {
throw new IncorrectRegistrationRecoveryPasswordException();
} else if (responseCode == 404) {
throw new NoSuchSessionException();