Add initial link+sync support.

This commit is contained in:
Greyson Parrelli
2024-10-25 09:53:17 -04:00
parent ebca386dcb
commit 7f3ceea9fe
27 changed files with 1042 additions and 260 deletions

View File

@@ -509,10 +509,6 @@ public class SignalServiceAccountManager {
return pushServiceSocket.getAccountDataReport();
}
public String getNewDeviceVerificationCode() throws IOException {
return this.pushServiceSocket.getNewDeviceVerificationCode();
}
public void addDevice(String deviceIdentifier,
ECPublicKey deviceKey,
IdentityKeyPair aciIdentityKeyPair,
@@ -552,7 +548,7 @@ public class SignalServiceAccountManager {
return this.pushServiceSocket.getDevices();
}
public void removeDevice(long deviceId) throws IOException {
public void removeDevice(int deviceId) throws IOException {
this.pushServiceSocket.removeDevice(deviceId);
}

View File

@@ -112,7 +112,17 @@ class AttachmentApi(
}
}
private fun getResumableUploadUrl(uploadForm: AttachmentUploadForm): NetworkResult<String> {
/**
* Uploads a raw file using the v4 upload scheme. No additional encryption is supplied! Always prefer [uploadAttachmentV4], unless you are using a separate
* encryption scheme (i.e. like backup files).
*/
fun uploadPreEncryptedFileToAttachmentV4(uploadForm: AttachmentUploadForm, resumableUploadUrl: String, inputStream: InputStream, inputStreamLength: Long): NetworkResult<Unit> {
return NetworkResult.fromFetch {
pushServiceSocket.uploadBackupFile(uploadForm, resumableUploadUrl, inputStream, inputStreamLength)
}
}
fun getResumableUploadUrl(uploadForm: AttachmentUploadForm): NetworkResult<String> {
return NetworkResult.fromFetch {
pushServiceSocket.getResumableUploadUrl(uploadForm)
}

View File

@@ -0,0 +1,131 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.link
import okio.ByteString.Companion.toByteString
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.backup.BackupKey
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher
import org.whispersystems.signalservice.internal.push.ProvisionMessage
import org.whispersystems.signalservice.internal.push.ProvisioningVersion
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import kotlin.math.min
/**
* Class to interact with device-linking endpoints.
*/
class LinkDeviceApi(private val pushServiceSocket: PushServiceSocket) {
/**
* Fetches a new verification code that lets you link a new device.
*
* GET /v1/devices/provisioning/code
*
* - 200: Success.
* - 411: Account is already at the device limit.
* - 429: Rate-limited.
*/
fun getDeviceVerificationCode(): NetworkResult<LinkedDeviceVerificationCodeResponse> {
return NetworkResult.fromFetch {
pushServiceSocket.getLinkedDeviceVerificationCode()
}
}
/**
* Links a new device to the account.
*
* PUT /v1/devices/link
*
* - 200: Success.
* - 403: Account not found or incorrect verification code.
* - 409: The new device is missing a required capability.
* - 411: Account is already at the device limit.
* - 422: Bad request.
* - 429: Rate-limited.
*/
fun linkDevice(
e164: String,
aci: ACI,
pni: PNI,
deviceIdentifier: String,
deviceKey: ECPublicKey,
aciIdentityKeyPair: IdentityKeyPair,
pniIdentityKeyPair: IdentityKeyPair,
profileKey: ProfileKey,
masterKey: MasterKey,
code: String,
ephemeralBackupKey: BackupKey?
): NetworkResult<Unit> {
return NetworkResult.fromFetch {
val cipher = PrimaryProvisioningCipher(deviceKey)
val message = ProvisionMessage(
aciIdentityKeyPublic = aciIdentityKeyPair.publicKey.serialize().toByteString(),
aciIdentityKeyPrivate = aciIdentityKeyPair.privateKey.serialize().toByteString(),
pniIdentityKeyPublic = pniIdentityKeyPair.publicKey.serialize().toByteString(),
pniIdentityKeyPrivate = pniIdentityKeyPair.privateKey.serialize().toByteString(),
aci = aci.toString(),
pni = pni.toStringWithoutPrefix(),
number = e164,
profileKey = profileKey.serialize().toByteString(),
provisioningCode = code,
provisioningVersion = ProvisioningVersion.CURRENT.value,
masterKey = masterKey.serialize().toByteString(),
ephemeralBackupKey = ephemeralBackupKey?.value?.toByteString()
)
val ciphertext = cipher.encrypt(message)
pushServiceSocket.sendProvisioningMessage(deviceIdentifier, ciphertext)
}
}
/**
* A "long-polling" endpoint that will return once the device has successfully been linked.
*
* @param timeoutSeconds The max amount of time to wait. Capped at 30 seconds.
*
* GET /v1/devices/wait_for_linked_device/{token}
*
* - 200: Success, a new device was linked associated with the provided token.
* - 204: No device was linked before the max waiting time elapsed.
* - 400: Invalid token/timeout.
* - 429: Rate-limited.
*/
fun waitForLinkedDevice(token: String, timeoutSeconds: Int = 30): NetworkResult<WaitForLinkedDeviceResponse> {
return NetworkResult.fromFetch {
pushServiceSocket.waitForLinkedDevice(token, min(timeoutSeconds, 30))
}
}
/**
* After a device has been linked and an archive has been uploaded, you can call this endpoint to share the archive with the linked device.
*
* PUT /v1/devices/transfer_archive
*
* - 204: Success.
* - 422: Bad inputs.
* - 429: Rate-limited.
*/
fun setTransferArchive(destinationDeviceId: Int, destinationDeviceCreated: Long, cdn: Int, cdnKey: String): NetworkResult<Unit> {
return NetworkResult.fromFetch {
pushServiceSocket.setLinkedDeviceTransferArchive(
SetLinkedDeviceTransferArchiveRequest(
destinationDeviceId = destinationDeviceId,
destinationDeviceCreated = destinationDeviceCreated,
transferArchive = SetLinkedDeviceTransferArchiveRequest.CdnInfo(
cdn = cdn,
key = cdnKey
)
)
)
}
}
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.link
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Response object for: GET /v1/devices/provisioning/code
*/
data class LinkedDeviceVerificationCodeResponse(
@JsonProperty val verificationCode: String,
@JsonProperty val tokenIdentifier: String
)

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.link
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Request body for setting the transfer archive for a linked device.
*/
data class SetLinkedDeviceTransferArchiveRequest(
@JsonProperty val destinationDeviceId: Int,
@JsonProperty val destinationDeviceCreated: Long,
@JsonProperty val transferArchive: CdnInfo
) {
data class CdnInfo(
@JsonProperty val cdn: Int,
@JsonProperty val key: String
)
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.link
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Response body for GET /v1/devices/wait_for_linked_device/{tokenIdentifier}
*/
data class WaitForLinkedDeviceResponse(
@JsonProperty val id: Int,
@JsonProperty val name: String,
@JsonProperty val created: Long,
@JsonProperty val lastSeen: Long
)

View File

@@ -1,13 +0,0 @@
package org.whispersystems.signalservice.internal.push;
import com.fasterxml.jackson.annotation.JsonProperty;
public class DeviceCode {
@JsonProperty
private String verificationCode;
public String getVerificationCode() {
return verificationCode;
}
}

View File

@@ -65,6 +65,9 @@ import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResp
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess;
import org.whispersystems.signalservice.api.groupsv2.CredentialResponse;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse;
import org.whispersystems.signalservice.api.link.SetLinkedDeviceTransferArchiveRequest;
import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
import org.whispersystems.signalservice.api.messages.calls.CallingResponse;
@@ -251,6 +254,8 @@ public class PushServiceSocket {
private static final String PROVISIONING_CODE_PATH = "/v1/devices/provisioning/code";
private static final String PROVISIONING_MESSAGE_PATH = "/v1/provisioning/%s";
private static final String DEVICE_PATH = "/v1/devices/%s";
private static final String WAIT_FOR_DEVICES_PATH = "/v1/devices/wait_for_linked_device/%s?timeout=%s";
private static final String TRANSFER_ARCHIVE_PATH = "/v1/devices/transfer_archive";
private static final String MESSAGE_PATH = "/v1/messages/%s";
private static final String GROUP_MESSAGE_PATH = "/v1/messages/multi_recipient?ts=%s&online=%s&urgent=%s&story=%s";
@@ -669,9 +674,9 @@ public class PushServiceSocket {
makeServiceRequest(SET_ACCOUNT_ATTRIBUTES, "PUT", JsonUtil.toJson(accountAttributes));
}
public String getNewDeviceVerificationCode() throws IOException {
String responseText = makeServiceRequest(PROVISIONING_CODE_PATH, "GET", null);
return JsonUtil.fromJson(responseText, DeviceCode.class).getVerificationCode();
public LinkedDeviceVerificationCodeResponse getLinkedDeviceVerificationCode() throws IOException {
String responseText = makeServiceRequest(PROVISIONING_CODE_PATH, "GET", null, NO_HEADERS, UNOPINIONATED_HANDLER, SealedSenderAccess.NONE);
return JsonUtil.fromJson(responseText, LinkedDeviceVerificationCodeResponse.class);
}
public List<DeviceInfo> getDevices() throws IOException {
@@ -679,6 +684,35 @@ public class PushServiceSocket {
return JsonUtil.fromJson(responseText, DeviceInfoList.class).getDevices();
}
/**
* This is a long-polling endpoint that relies on the fact that our normal connection timeout is already 30s.
*/
public WaitForLinkedDeviceResponse waitForLinkedDevice(String token, int timeoutSeconds) throws IOException {
// Note: We consider 204 failure, since that means that we timed out before determining if a device was linked. Easier that way.
String response = makeServiceRequest(String.format(Locale.US, WAIT_FOR_DEVICES_PATH, token, timeoutSeconds), "GET", null, NO_HEADERS, (responseCode, body) -> {
if (responseCode == 204 || responseCode < 200 || responseCode > 299) {
String bodyString = null;
if (body != null) {
try {
bodyString = readBodyString(body);
} catch (MalformedResponseException e) {
Log.w(TAG, "Failed to read body string", e);
}
}
throw new NonSuccessfulResponseCodeException(responseCode, "Response: " + responseCode, bodyString);
}
}, SealedSenderAccess.NONE);
return JsonUtil.fromJsonResponse(response, WaitForLinkedDeviceResponse.class);
}
public void setLinkedDeviceTransferArchive(SetLinkedDeviceTransferArchiveRequest request) throws IOException {
String body = JsonUtil.toJson(request);
makeServiceRequest(String.format(Locale.US, TRANSFER_ARCHIVE_PATH), "PUT", body, NO_HEADERS, UNOPINIONATED_HANDLER, SealedSenderAccess.NONE);
}
public void removeDevice(long deviceId) throws IOException {
makeServiceRequest(String.format(DEVICE_PATH, String.valueOf(deviceId)), "DELETE", null);
}
@@ -1605,21 +1639,6 @@ public class PushServiceSocket {
null, null);
}
public Pair<Long, AttachmentDigest> uploadAttachment(PushAttachmentData attachment, AttachmentV2UploadAttributes uploadAttributes)
throws PushNetworkException, NonSuccessfulResponseCodeException
{
long id = Long.parseLong(uploadAttributes.getAttachmentId());
AttachmentDigest digest = uploadToCdn0(ATTACHMENT_UPLOAD_PATH, uploadAttributes.getAcl(), uploadAttributes.getKey(),
uploadAttributes.getPolicy(), uploadAttributes.getAlgorithm(),
uploadAttributes.getCredential(), uploadAttributes.getDate(),
uploadAttributes.getSignature(), attachment.getData(),
"application/octet-stream", attachment.getDataSize(),
attachment.getIncremental(), attachment.getOutputStreamFactory(),
attachment.getListener(), attachment.getCancelationSignal());
return new Pair<>(id, digest);
}
public ResumableUploadSpec getResumableUploadSpec(AttachmentUploadForm uploadForm) throws IOException {
return new ResumableUploadSpec(Util.getSecretBytes(64),
Util.getSecretBytes(16),
@@ -1630,19 +1649,9 @@ public class PushServiceSocket {
uploadForm.headers);
}
public ResumableUploadSpec getResumableUploadSpecWithKey(AttachmentUploadForm uploadForm, byte[] secretKey) throws IOException {
return new ResumableUploadSpec(secretKey,
Util.getSecretBytes(16),
uploadForm.key,
uploadForm.cdn,
getResumableUploadUrl(uploadForm),
System.currentTimeMillis() + CDN2_RESUMABLE_LINK_LIFETIME_MILLIS,
uploadForm.headers);
}
public AttachmentDigest uploadAttachment(PushAttachmentData attachment) throws IOException {
if (attachment.getResumableUploadSpec() == null || attachment.getResumableUploadSpec().getExpirationTimestamp() < System.currentTimeMillis()) {
if (attachment.getResumableUploadSpec().getExpirationTimestamp() < System.currentTimeMillis()) {
throw new ResumeLocationInvalidException();
}

View File

@@ -33,7 +33,10 @@ message ProvisionMessage {
optional bool readReceipts = 7;
optional uint32 provisioningVersion = 9;
optional bytes masterKey = 13;
// NEXT ID: 14
optional bytes ephemeralBackupKey = 14; // 32 bytes
optional string accountEntropyPool = 15;
optional bytes mediaRootBackupKey = 16; // 32-bytes
// NEXT ID: 17
}
enum ProvisioningVersion {