Convert provisioning and certificate endpoints to WebSocket and finalize attachments.

This commit is contained in:
Cody Henthorne
2025-03-14 18:26:36 -04:00
parent aeec3a6f7e
commit c66819449d
27 changed files with 208 additions and 183 deletions

View File

@@ -106,14 +106,6 @@ public class SignalServiceAccountManager {
this.configuration = pushServiceSocket.getConfiguration();
}
public byte[] getSenderCertificate() throws IOException {
return this.pushServiceSocket.getSenderCertificate();
}
public byte[] getSenderCertificateForPhoneNumberPrivacy() throws IOException {
return this.pushServiceSocket.getUuidOnlySenderCertificate();
}
public SecureValueRecoveryV2 getSecureValueRecoveryV2(String mrEnclave) {
return new SecureValueRecoveryV2(configuration, mrEnclave, authWebSocket);
}

View File

@@ -21,6 +21,7 @@ import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage;
import org.signal.libsignal.protocol.state.PreKeyBundle;
import org.signal.libsignal.protocol.state.SessionRecord;
import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;
import org.whispersystems.signalservice.api.attachment.AttachmentApi;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.whispersystems.signalservice.api.crypto.ContentHint;
import org.whispersystems.signalservice.api.crypto.EnvelopeContent;
@@ -77,14 +78,12 @@ import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
import org.whispersystems.signalservice.api.services.AttachmentService;
import org.whispersystems.signalservice.api.util.AttachmentPointerUtil;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.api.util.Preconditions;
import org.whispersystems.signalservice.api.util.Uint64RangeException;
import org.whispersystems.signalservice.api.util.Uint64Util;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
import org.whispersystems.signalservice.api.websocket.WebSocketUnavailableException;
import org.whispersystems.signalservice.internal.crypto.AttachmentDigest;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
@@ -175,9 +174,9 @@ public class SignalServiceMessageSender {
private final Optional<EventListener> eventListener;
private final IdentityKeyPair localPniIdentity;
private final AttachmentService attachmentService;
private final MessageApi messageApi;
private final KeysApi keysApi;
private final AttachmentApi attachmentApi;
private final MessageApi messageApi;
private final KeysApi keysApi;
private final Scheduler scheduler;
private final long maxEnvelopeSize;
@@ -185,7 +184,7 @@ public class SignalServiceMessageSender {
public SignalServiceMessageSender(PushServiceSocket pushServiceSocket,
SignalServiceDataStore store,
SignalSessionLock sessionLock,
SignalWebSocket.AuthenticatedWebSocket authWebSocket,
AttachmentApi attachmentApi,
MessageApi messageApi,
KeysApi keysApi,
Optional<EventListener> eventListener,
@@ -194,19 +193,19 @@ public class SignalServiceMessageSender {
{
CredentialsProvider credentialsProvider = pushServiceSocket.getCredentialsProvider();
this.socket = pushServiceSocket;
this.aciStore = store.aci();
this.sessionLock = sessionLock;
this.localAddress = new SignalServiceAddress(credentialsProvider.getAci(), credentialsProvider.getE164());
this.localDeviceId = credentialsProvider.getDeviceId();
this.localPni = credentialsProvider.getPni();
this.attachmentService = new AttachmentService(authWebSocket);
this.messageApi = messageApi;
this.eventListener = eventListener;
this.maxEnvelopeSize = maxEnvelopeSize;
this.localPniIdentity = store.pni().getIdentityKeyPair();
this.scheduler = Schedulers.from(executor, false, false);
this.keysApi = keysApi;
this.socket = pushServiceSocket;
this.aciStore = store.aci();
this.sessionLock = sessionLock;
this.localAddress = new SignalServiceAddress(credentialsProvider.getAci(), credentialsProvider.getE164());
this.localDeviceId = credentialsProvider.getDeviceId();
this.localPni = credentialsProvider.getPni();
this.attachmentApi = attachmentApi;
this.messageApi = messageApi;
this.eventListener = eventListener;
this.maxEnvelopeSize = maxEnvelopeSize;
this.localPniIdentity = store.pni().getIdentityKeyPair();
this.scheduler = Schedulers.from(executor, false, false);
this.keysApi = keysApi;
}
/**
@@ -810,24 +809,8 @@ public class SignalServiceMessageSender {
}
public ResumableUploadSpec getResumableUploadSpec() throws IOException {
AttachmentUploadForm v4UploadAttributes = null;
Log.d(TAG, "Using pipe to retrieve attachment upload attributes...");
try {
v4UploadAttributes = new AttachmentService.AttachmentAttributesResponseProcessor<>(attachmentService.getAttachmentV4UploadAttributes().blockingGet()).getResultOrThrow();
} catch (WebSocketUnavailableException e) {
Log.w(TAG, "[getResumableUploadSpec] Pipe unavailable, falling back... (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")");
} catch (IOException e) {
if (e instanceof RateLimitException) {
throw e;
}
Log.w(TAG, "Failed to retrieve attachment upload attributes using pipe. Falling back...");
}
if (v4UploadAttributes == null) {
Log.d(TAG, "Not using pipe to retrieve attachment upload attributes...");
v4UploadAttributes = socket.getAttachmentV4UploadAttributes();
}
AttachmentUploadForm v4UploadAttributes = NetworkResultUtil.toBasicLegacy(attachmentApi.getAttachmentV4UploadForm());
return socket.getResumableUploadSpec(v4UploadAttributes);
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.certificate
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.get
import org.whispersystems.signalservice.internal.push.SenderCertificate
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
/**
* Endpoints to get [SenderCertificate]s.
*/
class CertificateApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket) {
/**
* GET /v1/certificate/delivery
* - 200: Success
*/
fun getSenderCertificate(): NetworkResult<ByteArray> {
val request = WebSocketRequestMessage.get("/v1/certificate/delivery")
return NetworkResult.fromWebSocketRequest(authWebSocket, request, SenderCertificate::class)
.map { it.certificate }
}
/**
* GET /v1/certificate/delivery?includeE164=false
* - 200: Success
*/
fun getSenderCertificateForPhoneNumberPrivacy(): NetworkResult<ByteArray> {
val request = WebSocketRequestMessage.get("/v1/certificate/delivery?includeE164=false")
return NetworkResult.fromWebSocketRequest(authWebSocket, request, SenderCertificate::class)
.map { it.certificate }
}
}

View File

@@ -16,6 +16,7 @@ import org.whispersystems.signalservice.api.backup.MediaRootBackupKey
import org.whispersystems.signalservice.api.backup.MessageBackupKey
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo
import org.whispersystems.signalservice.api.provisioning.ProvisioningMessage
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
@@ -24,7 +25,6 @@ import org.whispersystems.signalservice.internal.delete
import org.whispersystems.signalservice.internal.get
import org.whispersystems.signalservice.internal.push.DeviceInfoList
import org.whispersystems.signalservice.internal.push.ProvisionMessage
import org.whispersystems.signalservice.internal.push.ProvisioningMessage
import org.whispersystems.signalservice.internal.push.ProvisioningVersion
import org.whispersystems.signalservice.internal.put
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.provisioning
import org.signal.core.util.Base64
import org.signal.core.util.urlEncode
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.registration.proto.RegistrationProvisionMessage
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.registration.RestoreMethodBody
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher
import org.whispersystems.signalservice.internal.get
import org.whispersystems.signalservice.internal.put
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import kotlin.time.Duration.Companion.seconds
/**
* Linked and new device provisioning endpoints.
*/
class ProvisioningApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket) {
/**
* Encrypts and sends the [registrationProvisionMessage] from the current primary (old device) to the new device over
* the provisioning web socket identified by [deviceIdentifier].
*
* PUT /v1/provisioning/[deviceIdentifier]
* - 204: Success
* - 400: Message was too large
* - 404: No provisioning socket for [deviceIdentifier]
*/
fun sendReRegisterDeviceProvisioningMessage(
deviceIdentifier: String,
deviceKey: ECPublicKey,
registrationProvisionMessage: RegistrationProvisionMessage
): NetworkResult<Unit> {
val cipherText = PrimaryProvisioningCipher(deviceKey).encrypt(registrationProvisionMessage)
val request = WebSocketRequestMessage.put("/v1/provisioning/${deviceIdentifier.urlEncode()}", ProvisioningMessage(Base64.encodeWithPadding(cipherText)))
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
}
/**
* Wait for the [RestoreMethod] to be set on the server by the new device. This is a long polling operation.
*
* GET /v1/devices/restore_account/[token]?timeout=[timeout]
* - 200: A request was received for the given token
* - 204: No request given yet, may repeat call to continue waiting
* - 400: Invalid [token] or [timeout]
* - 429: Rate limited
*/
fun waitForRestoreMethod(token: String, timeout: Int = 30): NetworkResult<RestoreMethod> {
val request = WebSocketRequestMessage.get("/v1/devices/restore_account/${token.urlEncode()}?timeout=$timeout")
return NetworkResult.fromWebSocket(NetworkResult.LongPollingWebSocketConverter(RestoreMethodBody::class)) {
authWebSocket.request(request, timeout.seconds)
}.map {
it.method ?: RestoreMethod.DECLINE
}
}
}

View File

@@ -1,4 +1,4 @@
package org.whispersystems.signalservice.internal.push;
package org.whispersystems.signalservice.api.provisioning;
import com.fasterxml.jackson.annotation.JsonProperty;

View File

@@ -1,9 +1,9 @@
/*
* Copyright 2024 Signal Messenger, LLC
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.registration
package org.whispersystems.signalservice.api.provisioning
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.registration
package org.whispersystems.signalservice.api.provisioning
/**
* Restore method chosen by user on new device after performing a quick-restore.

View File

@@ -5,12 +5,10 @@
package org.whispersystems.signalservice.api.registration
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.registration.proto.RegistrationProvisionMessage
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.account.AccountAttributes
import org.whispersystems.signalservice.api.account.PreKeyCollection
import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher
import org.whispersystems.signalservice.api.provisioning.RestoreMethod
import org.whispersystems.signalservice.internal.push.BackupV2AuthCheckResponse
import org.whispersystems.signalservice.internal.push.BackupV3AuthCheckResponse
import org.whispersystems.signalservice.internal.push.PushServiceSocket
@@ -127,22 +125,6 @@ class RegistrationApi(
}
}
/**
* Encrypts and sends the [RegistrationProvisionMessage] from the current primary (old device) to the new device over
* the provisioning web socket identified by [deviceIdentifier].
*/
fun sendReRegisterDeviceProvisioningMessage(
deviceIdentifier: String,
deviceKey: ECPublicKey,
registrationProvisionMessage: RegistrationProvisionMessage
): NetworkResult<Unit> {
val cipherText = PrimaryProvisioningCipher(deviceKey).encrypt(registrationProvisionMessage)
return NetworkResult.fromFetch {
pushServiceSocket.sendProvisioningMessage(deviceIdentifier, cipherText)
}
}
/**
* Set [RestoreMethod] enum on the server for use by the old device to update UX.
*/
@@ -151,13 +133,4 @@ class RegistrationApi(
pushServiceSocket.setRestoreMethodChosen(token, RestoreMethodBody(method = method))
}
}
/**
* Wait for the [RestoreMethod] to be set on the server by the new device. This is a long polling operation.
*/
fun waitForRestoreMethod(token: String, timeout: Int = 30): NetworkResult<RestoreMethod> {
return NetworkResult.fromFetch {
pushServiceSocket.waitForRestoreMethodChosen(token, timeout).method ?: RestoreMethod.DECLINE
}
}
}

View File

@@ -7,6 +7,7 @@ package org.whispersystems.signalservice.api.registration
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonProperty
import org.whispersystems.signalservice.api.provisioning.RestoreMethod
/**
* Request and response body used to communicate a quick restore method selection during registration.

View File

@@ -1,32 +0,0 @@
package org.whispersystems.signalservice.api.services
import io.reactivex.rxjava3.core.Single
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.ServiceResponseProcessor
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.websocket.DefaultResponseMapper
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import org.whispersystems.signalservice.internal.websocket.WebsocketResponse
import java.security.SecureRandom
/**
* Provide WebSocket based interface to attachment upload endpoints.
*
* Note: To be expanded to have REST fallback and other attachment related operations.
*/
class AttachmentService(private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket) {
fun getAttachmentV4UploadAttributes(): Single<ServiceResponse<AttachmentUploadForm>> {
val requestMessage = WebSocketRequestMessage(
id = SecureRandom().nextLong(),
verb = "GET",
path = "/v4/attachments/form/upload"
)
return authWebSocket.request(requestMessage)
.map { response: WebsocketResponse? -> DefaultResponseMapper.getDefault(AttachmentUploadForm::class.java).map(response) }
.onErrorReturn { throwable: Throwable? -> ServiceResponse.forUnknownError(throwable) }
}
class AttachmentAttributesResponseProcessor<T>(response: ServiceResponse<T>) : ServiceResponseProcessor<T>(response)
}

View File

@@ -185,19 +185,13 @@ public class PushServiceSocket {
private static final String DELETE_ACCOUNT_PATH = "/v1/accounts/me";
private static final String PROVISIONING_MESSAGE_PATH = "/v1/provisioning/%s";
private static final String SET_RESTORE_METHOD_PATH = "/v1/devices/restore_account/%s";
private static final String WAIT_RESTORE_METHOD_PATH = "/v1/devices/restore_account/%s?timeout=%s";
private static final String GROUP_MESSAGE_PATH = "/v1/messages/multi_recipient?ts=%s&online=%s&urgent=%s&story=%s";
private static final String ATTACHMENT_V4_PATH = "/v4/attachments/form/upload";
private static final String PROFILE_PATH = "/v1/profile/%s";
private static final String PROFILE_BATCH_CHECK_PATH = "/v1/profile/identity_check/batch";
private static final String SENDER_CERTIFICATE_PATH = "/v1/certificate/delivery";
private static final String SENDER_CERTIFICATE_NO_E164_PATH = "/v1/certificate/delivery?includeE164=false";
private static final String ATTACHMENT_KEY_DOWNLOAD_PATH = "attachments/%s";
private static final String ATTACHMENT_ID_DOWNLOAD_PATH = "attachments/%d";
private static final String AVATAR_UPLOAD_PATH = "";
@@ -242,11 +236,8 @@ public class PushServiceSocket {
private static final String ARCHIVE_MEDIA_DOWNLOAD_PATH = "backups/%s/%s";
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp";
private static final Map<String, String> NO_HEADERS = Collections.emptyMap();
private static final ResponseCodeHandler NO_HANDLER = new EmptyResponseCodeHandler();
private static final ResponseCodeHandler LONG_POLL_HANDLER = new LongPollingResponseCodeHandler();
private static final ResponseCodeHandler UNOPINIONATED_HANDLER = new UnopinionatedResponseCodeHandler();
private static final ResponseCodeHandler UNOPINIONATED_BINARY_ERROR_HANDLER = new UnopinionatedBinaryErrorResponseCodeHandler();
@@ -404,33 +395,10 @@ public class PushServiceSocket {
makeServiceRequest(String.format(Locale.US, SET_RESTORE_METHOD_PATH, urlEncode(token)), "PUT", body, NO_HEADERS, UNOPINIONATED_HANDLER, SealedSenderAccess.NONE);
}
/**
* This is a long-polling endpoint that relies on the fact that our normal connection timeout is already 30s.
*/
public @Nonnull RestoreMethodBody waitForRestoreMethodChosen(@Nonnull String token, int timeoutSeconds) throws IOException {
String response = makeServiceRequest(String.format(Locale.US, WAIT_RESTORE_METHOD_PATH, urlEncode(token), timeoutSeconds), "GET", null, NO_HEADERS, LONG_POLL_HANDLER, SealedSenderAccess.NONE);
return JsonUtil.fromJsonResponse(response, RestoreMethodBody.class);
}
public void sendProvisioningMessage(String destination, byte[] body) throws IOException {
makeServiceRequest(String.format(PROVISIONING_MESSAGE_PATH, urlEncode(destination)), "PUT",
JsonUtil.toJson(new ProvisioningMessage(Base64.encodeWithPadding(body))));
}
public void requestPushChallenge(String sessionId, String gcmRegistrationId) throws IOException {
patchVerificationSession(sessionId, gcmRegistrationId, null, null, null, null);
}
public byte[] getSenderCertificate() throws IOException {
String responseText = makeServiceRequest(SENDER_CERTIFICATE_PATH, "GET", null);
return JsonUtil.fromJson(responseText, SenderCertificate.class).getCertificate();
}
public byte[] getUuidOnlySenderCertificate() throws IOException {
String responseText = makeServiceRequest(SENDER_CERTIFICATE_NO_E164_PATH, "GET", null);
return JsonUtil.fromJson(responseText, SenderCertificate.class).getCertificate();
}
public SendGroupMessageResponse sendGroupMessage(byte[] body, @Nonnull SealedSenderAccess sealedSenderAccess, long timestamp, boolean online, boolean urgent, boolean story)
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
{
@@ -888,18 +856,6 @@ public class PushServiceSocket {
}
}
public AttachmentUploadForm getAttachmentV4UploadAttributes()
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
{
String response = makeServiceRequest(ATTACHMENT_V4_PATH, "GET", null);
try {
return JsonUtil.fromJson(response, AttachmentUploadForm.class);
} catch (IOException e) {
Log.w(TAG, e);
throw new MalformedResponseException("Unable to parse entity", e);
}
}
public AttachmentDigest uploadGroupV2Avatar(byte[] avatarCipherText, AvatarUploadAttributes uploadAttributes)
throws IOException
{