Add some initial backupV2 network infrastructure.

This commit is contained in:
Greyson Parrelli
2023-12-07 16:11:57 -05:00
committed by Cody Henthorne
parent e17b07bb12
commit 6230a7553d
20 changed files with 609 additions and 11 deletions

View File

@@ -0,0 +1,93 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import java.io.IOException
/**
* A helper class that wraps the result of a network request, turning common exceptions
* into sealed classes, with optional request chaining.
*
* This was designed to be a middle ground between the heavy reliance on specific exceptions
* in old network code (which doesn't translate well to kotlin not having checked exceptions)
* and plain rx, which still doesn't free you from having to catch exceptions and translate
* things to sealed classes yourself.
*
* If you have a very complicated network request with lots of different possible response types
* based on specific errors, this isn't for you. You're likely better off writing your own
* sealed class. However, for the majority of requests which just require getting a model from
* the success case and the status code of the error, this can be quite convenient.
*/
sealed class NetworkResult<T> {
companion object {
/**
* A convenience method to capture the common case of making a request.
* Perform the network action in the [fetch] lambda, returning your result.
* Common exceptions will be caught and translated to errors.
*/
fun <T> fromFetch(fetch: () -> T): NetworkResult<T> = try {
Success(fetch())
} catch (e: NonSuccessfulResponseCodeException) {
StatusCodeError(e.code, e)
} catch (e: IOException) {
NetworkError(e)
} catch (e: Throwable) {
ApplicationError(e)
}
}
/** Indicates the request was successful */
data class Success<T>(val result: T) : NetworkResult<T>()
/** Indicates a generic network error occurred before we were able to process a response. */
data class NetworkError<T>(val throwable: Throwable? = null) : NetworkResult<T>()
/** Indicates we got a response, but it was a non-2xx response. */
data class StatusCodeError<T>(val code: Int, val throwable: Throwable? = null) : NetworkResult<T>()
/** Indicates that the application somehow failed in a way unrelated to network activity. Usually a runtime crash. */
data class ApplicationError<T>(val throwable: Throwable) : NetworkResult<T>()
/**
* Returns the result if successful, otherwise turns the result back into an exception and throws it.
*/
fun successOrThrow(): T {
when (this) {
is Success -> return result
is NetworkError -> throw throwable ?: PushNetworkException("Network error")
is StatusCodeError -> throw throwable ?: NonSuccessfulResponseCodeException(this.code)
is ApplicationError -> throw throwable
}
}
/**
* Takes the output of one [NetworkResult] and transforms it into another if the operation is successful.
* If it's a failure, the original failure will be propagated. Useful for changing the type of a result.
*/
fun <R> map(transform: (T) -> R): NetworkResult<R> {
return when (this) {
is Success -> Success(transform(this.result))
is NetworkError -> NetworkError(throwable)
is StatusCodeError -> StatusCodeError(code, throwable)
is ApplicationError -> ApplicationError(throwable)
}
}
/**
* Takes the output of one [NetworkResult] and passes it as the input to another if the operation is successful.
* If it's a failure, the original failure will be propagated. Useful for chaining operations together.
*/
fun <R> then(result: (T) -> NetworkResult<R>): NetworkResult<R> {
return when (this) {
is Success -> result(this.result)
is NetworkError -> NetworkError(throwable)
is StatusCodeError -> StatusCodeError(code, throwable)
is ApplicationError -> ApplicationError(throwable)
}
}
}

View File

@@ -21,6 +21,7 @@ import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
import org.whispersystems.signalservice.api.account.PniKeyDistributionRequest;
import org.whispersystems.signalservice.api.account.PreKeyCollection;
import org.whispersystems.signalservice.api.account.PreKeyUpload;
import org.whispersystems.signalservice.api.archive.ArchiveApi;
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
import org.whispersystems.signalservice.api.crypto.ProfileCipherOutputStream;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
@@ -834,6 +835,10 @@ public class SignalServiceAccountManager {
return new GroupsV2Api(pushServiceSocket, groupsV2Operations);
}
public ArchiveApi getArchiveApi() {
return ArchiveApi.create(pushServiceSocket, configuration.getBackupServerPublicParams(), credentials.getAci());
}
public AuthCredentials getPaymentsAuthorization() throws IOException {
return pushServiceSocket.getPaymentsAuthorization();
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import org.signal.libsignal.protocol.ecc.Curve
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.zkgroup.GenericServerPublicParams
import org.signal.libsignal.zkgroup.backups.BackupAuthCredential
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.backup.BackupKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.internal.push.PushServiceSocket
/**
* Class to interact with various archive-related endpoints.
* Why is it called archive instead of backup? Because SVR took the "backup" endpoint namespace first :)
*/
class ArchiveApi(
private val pushServiceSocket: PushServiceSocket,
private val backupServerPublicParams: GenericServerPublicParams,
private val aci: ACI
) {
companion object {
@JvmStatic
fun create(pushServiceSocket: PushServiceSocket, backupServerPublicParams: ByteArray, aci: ACI): ArchiveApi {
return ArchiveApi(
pushServiceSocket,
GenericServerPublicParams(backupServerPublicParams),
aci
)
}
}
/**
* Retrieves a set of credentials one can use to authorize other requests.
*
* You'll receive a set of credentials spanning 7 days. Cache them and store them for later use.
* It's important that (at least in the common case) you do not request credentials on-the-fly.
* Instead, request them in advance on a regular schedule. This is because the purpose of these
* credentials is to keep the caller anonymous, but that doesn't help if this authenticated request
* happens right before all of the unauthenticated ones, as that would make it easier to correlate
* traffic.
*/
fun getServiceCredentials(currentTime: Long): NetworkResult<ArchiveServiceCredentialsResponse> {
return NetworkResult.fromFetch {
pushServiceSocket.getArchiveCredentials(currentTime)
}
}
/**
* Ensures that you reserve a backupId on the service. This must be done before any other
* backup-related calls. You only need to do it once, but repeated calls are safe.
*/
fun triggerBackupIdReservation(backupKey: BackupKey): NetworkResult<Unit> {
return NetworkResult.fromFetch {
val backupRequestContext = BackupAuthCredentialRequestContext.create(backupKey.value, aci.rawUuid)
pushServiceSocket.setArchiveBackupId(backupRequestContext.request)
}
}
/**
* Sets a public key on the service derived from your [BackupKey]. This key is used to prevent
* unauthorized users from changing your backup data. You only need to do it once, but repeated
* calls are safe.
*/
fun setPublicKey(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): NetworkResult<Unit> {
return NetworkResult.fromFetch {
val zkCredential = getZkCredential(backupKey, serviceCredential)
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
pushServiceSocket.setArchivePublicKey(presentationData.publicKey, presentationData.toArchiveCredentialPresentation())
}
}
/**
* Fetches an upload form you can use to upload your main message backup file to cloud storage.
*/
fun getMessageBackupUploadForm(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): NetworkResult<ArchiveMessageBackupUploadFormResponse> {
return NetworkResult.fromFetch {
val zkCredential = getZkCredential(backupKey, serviceCredential)
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
pushServiceSocket.getArchiveMessageBackupUploadForm(presentationData.toArchiveCredentialPresentation())
}
}
/**
* Fetches metadata about your current backup.
* Will return a [NetworkResult.StatusCodeError] with status code 404 if you haven't uploaded a
* backup yet.
*/
fun getBackupInfo(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): NetworkResult<ArchiveGetBackupInfoResponse> {
return NetworkResult.fromFetch {
val zkCredential = getZkCredential(backupKey, serviceCredential)
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
pushServiceSocket.getArchiveBackupInfo(presentationData.toArchiveCredentialPresentation())
}
}
private fun getZkCredential(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): BackupAuthCredential {
val backupAuthResponse = BackupAuthCredentialResponse(serviceCredential.credential)
val backupRequestContext = BackupAuthCredentialRequestContext.create(backupKey.value, aci.rawUuid)
return backupRequestContext.receiveResponse(
backupAuthResponse,
backupServerPublicParams,
10
)
}
private class CredentialPresentationData(
val privateKey: ECPrivateKey,
val presentation: ByteArray,
val signedPresentation: ByteArray
) {
val publicKey: ECPublicKey = privateKey.publicKey()
companion object {
fun from(backupKey: BackupKey, credential: BackupAuthCredential, backupServerPublicParams: GenericServerPublicParams): CredentialPresentationData {
val privateKey: ECPrivateKey = Curve.decodePrivatePoint(backupKey.value)
val presentation: ByteArray = credential.present(backupServerPublicParams).serialize()
val signedPresentation: ByteArray = privateKey.calculateSignature(presentation)
return CredentialPresentationData(privateKey, presentation, signedPresentation)
}
}
fun toArchiveCredentialPresentation(): ArchiveCredentialPresentation {
return ArchiveCredentialPresentation(
presentation = presentation,
signedPresentation = signedPresentation
)
}
}
}

View File

@@ -0,0 +1,14 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
/**
* Acts as credentials for various archive operations.
*/
class ArchiveCredentialPresentation(
val presentation: ByteArray,
val signedPresentation: ByteArray
)

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Represents the response when fetching the archive backup info.
*/
data class ArchiveGetBackupInfoResponse(
@JsonProperty
val cdn: Int?,
@JsonProperty
val backupDir: String?,
@JsonProperty
val backupName: String?,
@JsonProperty
val usedSpace: Long?
)

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Represents the response body when we ask for a message backup upload form.
*/
data class ArchiveMessageBackupUploadFormResponse(
@JsonProperty
val cdn: Int,
@JsonProperty
val key: String,
@JsonProperty
val headers: Map<String, String>,
@JsonProperty
val signedUploadLocation: String
)

View File

@@ -0,0 +1,15 @@
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Represents an individual credential for an archive operation. Note that is isn't the final
* credential you will actually use -- that's [org.signal.libsignal.zkgroup.backups.BackupAuthCredential].
* But you use these to make those.
*/
class ArchiveServiceCredential(
@JsonProperty
val credential: ByteArray,
@JsonProperty
val redemptionTime: Long
)

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Represents the result of fetching archive credentials.
* See [ArchiveServiceCredential].
*/
class ArchiveServiceCredentialsResponse(
@JsonProperty
val credentials: Array<ArchiveServiceCredential>
)

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import org.signal.core.util.Base64
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest
/**
* Represents the request body when setting the archive backupId.
*/
class ArchiveSetBackupIdRequest(
@JsonProperty
@JsonSerialize(using = BackupAuthCredentialRequestSerializer::class)
val backupAuthCredentialRequest: BackupAuthCredentialRequest
) {
class BackupAuthCredentialRequestSerializer : JsonSerializer<BackupAuthCredentialRequest>() {
override fun serialize(value: BackupAuthCredentialRequest, gen: JsonGenerator, serializers: SerializerProvider) {
gen.writeString(Base64.encodeWithPadding(value.serialize()))
}
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import org.signal.core.util.Base64
import org.signal.libsignal.protocol.ecc.ECPublicKey
/**
* Represents the request body when setting the archive public key.
*/
class ArchiveSetPublicKeyRequest(
@JsonProperty
@JsonSerialize(using = PublicKeySerializer::class)
val backupIdPublicKey: ECPublicKey
) {
class PublicKeySerializer : JsonSerializer<ECPublicKey>() {
override fun serialize(value: ECPublicKey, gen: JsonGenerator, serializers: SerializerProvider) {
gen.writeString(Base64.encodeWithPadding(value.serialize()))
}
}
}

View File

@@ -17,5 +17,6 @@ class SignalServiceConfiguration(
val dns: Optional<Dns>,
val signalProxy: Optional<SignalProxy>,
val zkGroupServerPublicParams: ByteArray,
val genericServerPublicParams: ByteArray
val genericServerPublicParams: ByteArray,
val backupServerPublicParams: ByteArray
)

View File

@@ -20,6 +20,7 @@ import org.signal.libsignal.protocol.util.Pair;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialRequest;
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialResponse;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
@@ -42,6 +43,12 @@ import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
import org.whispersystems.signalservice.api.account.PniKeyDistributionRequest;
import org.whispersystems.signalservice.api.account.PreKeyCollection;
import org.whispersystems.signalservice.api.account.PreKeyUpload;
import org.whispersystems.signalservice.api.archive.ArchiveCredentialPresentation;
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredentialsResponse;
import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse;
import org.whispersystems.signalservice.api.archive.ArchiveMessageBackupUploadFormResponse;
import org.whispersystems.signalservice.api.archive.ArchiveSetBackupIdRequest;
import org.whispersystems.signalservice.api.archive.ArchiveSetPublicKeyRequest;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.groupsv2.CredentialResponse;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
@@ -85,7 +92,6 @@ import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import org.whispersystems.signalservice.api.push.exceptions.RangeException;
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
import org.whispersystems.signalservice.api.push.exceptions.RegistrationRetryException;
import org.whispersystems.signalservice.api.push.exceptions.RemoteAttestationResponseExpiredException;
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.api.push.exceptions.TokenNotAcceptedException;
@@ -108,9 +114,6 @@ import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl;
import org.whispersystems.signalservice.internal.configuration.SignalProxy;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.configuration.SignalUrl;
import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupRequest;
import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupResponse;
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
import org.whispersystems.signalservice.internal.crypto.AttachmentDigest;
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError;
import org.whispersystems.signalservice.internal.push.exceptions.DonationReceiptCredentialError;
@@ -303,6 +306,12 @@ public class PushServiceSocket {
private static final String BACKUP_AUTH_CHECK = "/v2/backup/auth/check";
private static final String ARCHIVE_CREDENTIALS = "/v1/archives/auth?redemptionStartSeconds=%d&redemptionEndSeconds=%d";
private static final String ARCHIVE_BACKUP_ID = "/v1/archives/backupid";
private static final String ARCHIVE_PUBLIC_KEY = "/v1/archives/keys";
private static final String ARCHIVE_INFO = "/v1/archives";
private static final String ARCHIVE_MESSAGE_UPLOAD_FORM = "/v1/archives/upload/form";
private static final String CALL_LINK_CREATION_AUTH = "/v1/call-link/create-auth";
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp";
@@ -471,6 +480,48 @@ public class PushServiceSocket {
return credentials;
}
public ArchiveServiceCredentialsResponse getArchiveCredentials(long currentTime) throws IOException {
long secondsRoundedToNearestDay = TimeUnit.DAYS.toSeconds(TimeUnit.MILLISECONDS.toDays(currentTime));
long endTimeInSeconds = secondsRoundedToNearestDay + TimeUnit.DAYS.toSeconds(7);
String response = makeServiceRequest(String.format(Locale.US, ARCHIVE_CREDENTIALS, secondsRoundedToNearestDay, endTimeInSeconds), "GET", null);
return JsonUtil.fromJson(response, ArchiveServiceCredentialsResponse.class);
}
public void setArchiveBackupId(BackupAuthCredentialRequest request) throws IOException {
String body = JsonUtil.toJson(new ArchiveSetBackupIdRequest(request));
makeServiceRequest(ARCHIVE_BACKUP_ID, "PUT", body);
}
public void setArchivePublicKey(ECPublicKey publicKey, ArchiveCredentialPresentation credentialPresentation) throws IOException {
Map<String, String> headers = new HashMap<>();
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
String body = JsonUtil.toJson(new ArchiveSetPublicKeyRequest(publicKey));
makeServiceRequestWithoutAuthentication(ARCHIVE_PUBLIC_KEY, "PUT", body, headers, NO_HANDLER);
}
public ArchiveGetBackupInfoResponse getArchiveBackupInfo(ArchiveCredentialPresentation credentialPresentation) throws IOException {
Map<String, String> headers = new HashMap<>();
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
String response = makeServiceRequestWithoutAuthentication(ARCHIVE_INFO, "GET", null, headers, NO_HANDLER);
return JsonUtil.fromJson(response, ArchiveGetBackupInfoResponse.class);
}
public ArchiveMessageBackupUploadFormResponse getArchiveMessageBackupUploadForm(ArchiveCredentialPresentation credentialPresentation) throws IOException {
Map<String, String> headers = new HashMap<>();
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
String response = makeServiceRequestWithoutAuthentication(ARCHIVE_MESSAGE_UPLOAD_FORM, "GET", null, headers, NO_HANDLER);
return JsonUtil.fromJson(response, ArchiveMessageBackupUploadFormResponse.class);
}
public VerifyAccountResponse changeNumber(@Nonnull ChangePhoneNumberRequest changePhoneNumberRequest)
throws IOException
{