mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-26 05:58:09 +00:00
Add initial link+sync support.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user