Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/QuickRestore/QuickRestoreManager.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import LibSignalClient
import SignalServiceKit

public class QuickRestoreManager {
    public typealias RestoreMethodToken = String

    public enum Error: Swift.Error {
        case errorWaitingForNewDevice
        case invalidRegistrationMessage
        case unsupportedRestoreMethod
        case missingRestoreInformation
        case unknown
    }

    private let accountKeyStore: AccountKeyStore
    private let backupNonceStore: BackupNonceMetadataStore
    private let backupSettingsStore: BackupSettingsStore
    private let db: any DB
    private let deviceProvisioningService: DeviceProvisioningService
    private let identityManager: OWSIdentityManager
    private let networkManager: any NetworkManagerProtocol
    private let tsAccountManager: TSAccountManager

    init(
        accountKeyStore: AccountKeyStore,
        backupNonceStore: BackupNonceMetadataStore,
        backupSettingsStore: BackupSettingsStore,
        db: any DB,
        deviceProvisioningService: DeviceProvisioningService,
        identityManager: OWSIdentityManager,
        networkManager: any NetworkManagerProtocol,
        tsAccountManager: TSAccountManager,
    ) {
        self.accountKeyStore = accountKeyStore
        self.backupNonceStore = backupNonceStore
        self.backupSettingsStore = backupSettingsStore
        self.db = db
        self.deviceProvisioningService = deviceProvisioningService
        self.identityManager = identityManager
        self.networkManager = networkManager
        self.tsAccountManager = tsAccountManager
    }

    public func register(deviceProvisioningUrl: DeviceProvisioningURL) async throws -> RestoreMethodToken {
        let (
            localIdentifiers,
            accountEntropyPool,
            aciIdentityKeyPair,
            pniIdentityKeyPair,
            pinCode,
            backupTier,
            lastBackupDate,
            lastBackupSizeBytes,
            lastBackupForwardSecrecyToken,
            nextBackupSecretData,
        ) = try db.read { tx in
            guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else {
                owsFailDebug("Can't quick restore without local identifiers")
                throw Error.missingRestoreInformation
            }
            guard let accountEntropyPool = accountKeyStore.getAccountEntropyPool(tx: tx) else {
                // This should be impossible; the only times you don't have
                // a AEP are during registration.
                owsFailDebug("Can't quick restore without AccountEntropyPool")
                throw Error.missingRestoreInformation
            }
            guard let aciIdentityKeyPair = identityManager.identityKeyPair(for: .aci, tx: tx) else {
                owsFailDebug("Can't quick restore without local identity key")
                throw Error.missingRestoreInformation
            }
            guard let pniIdentityKeyPair = identityManager.identityKeyPair(for: .pni, tx: tx) else {
                owsFailDebug("Can't quick restore without local identity key")
                throw Error.missingRestoreInformation
            }
            let pinCode = SSKEnvironment.shared.ows2FAManagerRef.pinCode(transaction: tx)

            let backupTier: RegistrationProvisioningMessage.BackupTier? = switch backupSettingsStore.backupPlan(tx: tx) {
            case .free: .free
            case .paid, .paidExpiringSoon, .paidAsTester: .paid
            case .disabled, .disabling: nil
            }

            let lastBackupTime: UInt64?
            let lastBackupSizeBytes: UInt64?
            if backupTier != nil {
                let lastBackupDetails = backupSettingsStore.lastBackupDetails(tx: tx)
                lastBackupTime = lastBackupDetails?.date.ows_millisecondsSince1970
                lastBackupSizeBytes = lastBackupDetails?.backupTotalSizeBytes
            } else {
                lastBackupTime = nil
                lastBackupSizeBytes = nil
            }

            let backupKey = try MessageRootBackupKey(
                accountEntropyPool: accountEntropyPool,
                aci: localIdentifiers.aci,
            )
            let lastBackupForwardSecrecyToken = try backupNonceStore.getLastForwardSecrecyToken(
                for: backupKey,
                tx: tx,
            )
            let nextBackupSecretData = backupNonceStore.getNextSecretMetadata(
                for: backupKey,
                tx: tx,
            )

            return (
                localIdentifiers,
                accountEntropyPool,
                aciIdentityKeyPair,
                pniIdentityKeyPair,
                pinCode,
                backupTier,
                lastBackupTime,
                lastBackupSizeBytes,
                lastBackupForwardSecrecyToken,
                nextBackupSecretData,
            )
        }

        let myAci = localIdentifiers.aci
        guard let myPhoneNumber = E164(localIdentifiers.phoneNumber) else {
            owsFailDebug("Can't quick restore without e164")
            throw Error.missingRestoreInformation
        }

        let restoreMethodToken = UUID().uuidString

        let registrationMessage = RegistrationProvisioningMessage(
            accountEntropyPool: accountEntropyPool,
            aci: myAci,
            aciIdentityKeyPair: aciIdentityKeyPair.identityKeyPair,
            pniIdentityKeyPair: pniIdentityKeyPair.identityKeyPair,
            phoneNumber: myPhoneNumber,
            pin: pinCode,
            tier: backupTier,
            backupVersion: BackupArchiveManagerImpl.Constants.supportedBackupVersion,
            backupTimestamp: lastBackupDate,
            backupSizeBytes: lastBackupSizeBytes,
            restoreMethodToken: restoreMethodToken,
            lastBackupForwardSecrecyToken: lastBackupForwardSecrecyToken,
            nextBackupSecretData: nextBackupSecretData,
        )

        let theirPublicKey = deviceProvisioningUrl.publicKey
        let messageBody = try registrationMessage.buildEncryptedMessageBody(theirPublicKey: theirPublicKey)
        try await deviceProvisioningService.provisionDevice(
            messageBody: messageBody,
            ephemeralDeviceId: deviceProvisioningUrl.ephemeralDeviceId,
        )

        return restoreMethodToken
    }

    public enum RestoreMethodType {
        case remoteBackup
        case localBackup
        case deviceTransfer(String)
        case decline

        fileprivate init?(response: Requests.WaitForRestoreMethodChoice.Response) {
            switch response.method {
            case .decline: self = .decline
            case .localBackup: self = .localBackup
            case .remoteBackup: self = .remoteBackup
            case .deviceTransfer:
                guard let bootstrapData = response.deviceTransferBootstrap else { return nil }
                self = .deviceTransfer(bootstrapData)
            }
        }
    }

    public func reportRestoreMethodChoice(method: RestoreMethodType, restoreMethodToken: RestoreMethodToken) async throws {
        whileLoop: while true {
            let response = try await networkManager.asyncRequest(
                Requests.ChooseRestoreMethod.buildRequest(
                    token: restoreMethodToken,
                    method: method,
                ),
            )
            switch response.responseStatusCode {
            case 200, 204:
                return
            case 429:
                try await Task.sleep(
                    nanoseconds: HTTPUtils.retryDelayNanoSeconds(response, defaultRetryTime: Constants.defaultRetryTime),
                )
                continue whileLoop
            default:
                owsFailDebug("Unexpected response")
                throw Error.unknown
            }
        }
    }

    public func waitForRestoreMethodChoice(restoreMethodToken: RestoreMethodToken) async throws -> RestoreMethodType {
        whileLoop: while true {
            do {
                let response = try await networkManager.asyncRequest(
                    Requests.WaitForRestoreMethodChoice.buildRequest(token: restoreMethodToken),
                )
                switch response.responseStatusCode {
                case 200:
                    guard
                        let data = response.responseBodyData,
                        let response = try? JSONDecoder().decode(
                            Requests.WaitForRestoreMethodChoice.Response.self,
                            from: data,
                        )
                    else {
                        throw Error.errorWaitingForNewDevice
                    }

                    guard let responseType = RestoreMethodType(response: response) else {
                        throw Error.unsupportedRestoreMethod
                    }
                    return responseType
                case 400:
                    throw Error.invalidRegistrationMessage
                case 204:
                    /// The timeout elapsed without the device linking; clients can request again.
                    continue whileLoop
                case 429:
                    try await Task.sleep(
                        nanoseconds: HTTPUtils.retryDelayNanoSeconds(response, defaultRetryTime: Constants.defaultRetryTime),
                    )
                    continue whileLoop
                default:
                    owsFailDebug("Unexpected response")
                    throw Error.unknown
                }
            } catch {
                owsFailDebug("Unexpected exception")
                throw Error.unknown
            }
        }
    }

    private enum Constants {
        static let longPollRequestTimeoutSeconds: UInt32 = 60 * 5
        static let defaultRetryTime: TimeInterval = 15
    }

    fileprivate enum Requests {
        enum RestoreMethod: String, Codable {
            case remoteBackup = "REMOTE_BACKUP"
            case localBackup = "LOCAL_BACKUP"
            case deviceTransfer = "DEVICE_TRANSFER"
            case decline = "DECLINE"
        }

        enum WaitForRestoreMethodChoice {
            struct Response: Codable {
                /// The method of restore chosen by the new device
                let method: RestoreMethod
                /// Additional data used to bootstrap device transfer
                let deviceTransferBootstrap: String?
            }

            static func buildRequest(token: RestoreMethodToken) -> TSRequest {
                var urlComponents = URLComponents(string: "v1/devices/restore_account/\(token)")!
                urlComponents.queryItems = [URLQueryItem(
                    name: "timeout",
                    value: "\(Constants.longPollRequestTimeoutSeconds)",
                )]
                var request = TSRequest(
                    url: urlComponents.url!,
                    method: "GET",
                    parameters: nil,
                )

                request.auth = .anonymous
                request.applyRedactionStrategy(.redactURL())
                // The timeout is server side; apply wiggle room for our local clock.
                request.timeoutInterval = 10 + TimeInterval(Constants.longPollRequestTimeoutSeconds)
                return request
            }
        }

        enum ChooseRestoreMethod {
            static func buildRequest(token: RestoreMethodToken, method: RestoreMethodType) -> TSRequest {
                var deviceTransferBootstrap: String?
                let method: RestoreMethod = {
                    switch method {
                    case .decline: return .decline
                    case .deviceTransfer(let data):
                        deviceTransferBootstrap = data
                        return .deviceTransfer
                    case .remoteBackup:
                        return .remoteBackup
                    case .localBackup:
                        return .localBackup
                    }
                }()

                var parameters: [String: Any] = ["method": method.rawValue]
                // `deviceTransferBootstrap` contains unpadded base64 encoded data that is used by
                // the other device to initiate device transfer. Note that server enforces a
                // 4096 bytes limit on this field.
                deviceTransferBootstrap.map { parameters["deviceTransferBootstrap"] = $0 }

                let urlComponents = URLComponents(string: "v1/devices/restore_account/\(token)")!
                var request = TSRequest(
                    url: urlComponents.url!,
                    method: "PUT",
                    parameters: parameters,
                )

                request.auth = .anonymous
                request.applyRedactionStrategy(.redactURL())
                return request
            }
        }
    }
}