TM-SGNL-iOS/Signal/DeviceTransfer/DeviceTransferService.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

576 lines
23 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import CryptoKit
import Foundation
import MultipeerConnectivity
import SignalServiceKit
protocol DeviceTransferServiceObserver: AnyObject {
func deviceTransferServiceDiscoveredNewDevice(peerId: MCPeerID, discoveryInfo: [String: String]?)
func deviceTransferServiceDidStartTransfer(progress: Progress)
func deviceTransferServiceDidEndTransfer(error: DeviceTransferService.Error?)
func deviceTransferServiceDidRequestAppRelaunch()
}
///
/// The following service is used to facilitate users in transferring their account from
/// an old device (OD) to a new device (ND) using MultipeerConnectivity. The general steps
/// of the process follow the following flow:
///
/// 1) As you begin setting up a new device (ND), you are asked if you want to transfer data
/// from an old device (OD). This happens *after* the SMS code and reg lock pin are provided,
/// but (importantly) before the service replaces your old account. Accounts are identified
/// by the service as being eligible for transfer by setting the "transfer" capability.
/// 2) In order to notify potential ODs on the network, the ND will begin advertising a
/// transfer service using Bonjour. Nearby ODs will be readily browsing for this service,
/// but not establishing any connections until the user takes action. The ND will actively
/// attempt to connect to any other transfer service it finds. MC will under-the-hood
/// determine whether its best to use peer-to-peer Wi-Fi, Bluetooth, or infrastructure Wi-Fi
/// 3) In order to prepare for a session from the OD, the ND will generate an RSA 2048 private
/// key and self-signed public certificate (used for DTLS). It will then present a QR code
/// that contains:
/// a. The transfer version, so we can eliminate the need for a lot of backwards compatibility
/// b. The MC Peer identifier (an opaque blob of data that represents the ND, that the
/// OD can use to determine what device to connect to)
/// c. A sha256 hash of the public certificate, so we can verify we're connected to
/// the appropriate ND
/// d. A mode flag indicating whether we're expecting to transfer from a primary device
/// or a linked device.
/// 4) On your OD, you will accept the prompt in the Signal app to enter transfer mode.
/// A QR scanner will be presented to you.
/// 5) When the OD scans the QR code presented on the ND, it will:
/// a. Attempt to open an encrypted (DTLS) session with the specified MC session identifier
/// b. Validate the certificate for the connection exactly matches the certificate scanned from the ND
/// c. Start locally behaving as if it is unregistered, without actually unregistering from the
/// service (to prevent two devices registered with the same number)
/// d. Send a manifest to the ND that outlines a list of all the files it should expect, including:
/// i. The SQLCipher DB key
/// ii. The sqlite database file (with no additional encryption beyond SQLCipher)
/// iii. All attachment files stored on the device
/// iv. The user preference dictionary (user defaults)
/// e. Start transferring all the files to the new device
/// 6) When all data has been transferred successfully,
/// a. the OD will:
/// i. Flag that it was transferred, it will now remain unregistered regardless of what
/// happens on the ND.
/// ii. Send a "done" message to the ND, to notify that it thinks it's done
/// iii. Wait for a "done" message from the ND if received, all local data will be deleted.
/// b. the ND will, upon receipt of the "done" message:
/// i. Verify all data that was expected to be received was received
/// ii. Mark itself as pending restore
/// iii. Notify the ND that it is "done" and it's safe to self-destruct
/// iv. Move all the received files into place, set the new database key, etc.
/// v. Hot-swap the new database into place and present the conversation list
///
class DeviceTransferService: NSObject {
static let appSharedDataDirectory = URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath())
static let pendingTransferDirectory = URL(fileURLWithPath: "transfer", isDirectory: true, relativeTo: appSharedDataDirectory)
static let pendingTransferFilesDirectory = URL(fileURLWithPath: "files", isDirectory: true, relativeTo: pendingTransferDirectory)
static let manifestIdentifier = "manifest"
static let databaseIdentifier = "database"
static let databaseWALIdentifier = "database-wal"
static let missingFileData = "Missing File".data(using: .utf8)!
static let missingFileHash = Data(SHA256.hash(data: missingFileData))
// This must also be updated in the info.plist
private static let newDeviceServiceIdentifier = "sgnl-new-device"
private let serialQueue = DispatchQueue(label: "org.signal.device-transfer")
private var _transferState: TransferState = .idle
var transferState: TransferState {
get { serialQueue.sync { _transferState } }
set { serialQueue.sync { _transferState = newValue } }
}
private let sleepBlockObject = DeviceSleepManager.BlockObject(blockReason: "device transfer")
private(set) var identity: SecIdentity?
private(set) var session: MCSession?
private(set) lazy var peerId = MCPeerID(displayName: UUID().uuidString)
private lazy var newDeviceServiceBrowser: MCNearbyServiceBrowser = {
let browser = MCNearbyServiceBrowser(
peer: peerId,
serviceType: DeviceTransferService.newDeviceServiceIdentifier
)
browser.delegate = self
return browser
}()
private lazy var newDeviceServiceAdvertiser: MCNearbyServiceAdvertiser = {
let advertiser = MCNearbyServiceAdvertiser(
peer: peerId, discoveryInfo: nil,
serviceType: DeviceTransferService.newDeviceServiceIdentifier
)
advertiser.delegate = self
return advertiser
}()
// MARK: -
let appReadiness: AppReadiness
let keychainStorage: any KeychainStorage
init(appReadiness: AppReadiness, keychainStorage: any KeychainStorage) {
self.appReadiness = appReadiness
self.keychainStorage = keychainStorage
super.init()
SwiftSingletons.register(self)
NotificationCenter.default.addObserver(
self,
selector: #selector(didEnterBackground),
name: .OWSApplicationDidEnterBackground,
object: nil
)
}
// MARK: - New Device
func startAcceptingTransfersFromOldDevices(mode: TransferMode) throws -> URL {
// Create an identity to use for our TLS sessions, the old device
// will verify this identity via the QR code
let identity = try SelfSignedIdentity.create(name: "IncomingDeviceTransfer", validForDays: 1)
self.identity = identity
let session = MCSession(peer: peerId, securityIdentity: [identity], encryptionPreference: .required)
session.delegate = self
self.session = session
DeviceSleepManager.shared.addBlock(blockObject: sleepBlockObject)
newDeviceServiceAdvertiser.startAdvertisingPeer()
return try urlForTransfer(mode: mode)
}
func stopAcceptingTransfersFromOldDevices() {
newDeviceServiceAdvertiser.stopAdvertisingPeer()
}
func cancelTransferFromOldDevice() {
AssertIsOnMainThread()
guard case .incoming = transferState else { return }
notifyObservers { $0.deviceTransferServiceDidEndTransfer(error: .cancel) }
stopTransfer()
}
// MARK: - Old Device
func startListeningForNewDevices() {
newDeviceServiceBrowser.startBrowsingForPeers()
}
func stopListeningForNewDevices() {
newDeviceServiceBrowser.stopBrowsingForPeers()
}
func transferAccountToNewDevice(with peerId: MCPeerID, certificateHash: Data) throws {
cancelTransferToNewDevice()
// Marking the transfer as "in progress" does a few things, most notably it:
// * prevents any WAL checkpoints while the transfer is in progress
// * causes the device to behave is if it's not registered
DependenciesBridge.shared.db.write { tx in
DependenciesBridge.shared.registrationStateChangeManager.setIsTransferInProgress(tx: tx)
}
defer {
// If we failed to start the transfer, clear the transfer in progress flag
if case .idle = transferState {
DependenciesBridge.shared.db.write { tx in
DependenciesBridge.shared.registrationStateChangeManager.setIsTransferComplete(
sendStateUpdateNotification: true,
tx: tx
)
}
}
}
let manifest = try buildManifest()
let progress = Progress(totalUnitCount: Int64(manifest.estimatedTotalSize))
// We don't actually need to generate an identity for the old device, the new device
// doesn't verify this information. We do it anyway, for consistency.
let identity = try SelfSignedIdentity.create(name: "OutgoingDeviceTransfer", validForDays: 1)
self.identity = identity
let session = MCSession(peer: self.peerId, securityIdentity: [identity], encryptionPreference: .required)
session.delegate = self
self.session = session
DeviceSleepManager.shared.addBlock(blockObject: sleepBlockObject)
transferState = .outgoing(
newDevicePeerId: peerId,
newDeviceCertificateHash: certificateHash,
manifest: manifest,
transferredFileIds: [],
progress: progress
)
newDeviceServiceBrowser.invitePeer(peerId, to: session, withContext: nil, timeout: 30)
}
func cancelTransferToNewDevice() {
guard case .outgoing = transferState else { return }
notifyObservers { $0.deviceTransferServiceDidEndTransfer(error: .cancel) }
stopTransfer()
}
// MARK: - Observation
private var observers = [Weak<DeviceTransferServiceObserver>]()
func addObserver(_ observer: DeviceTransferServiceObserver) {
observers.append(Weak(value: observer))
}
func removeObserver(_ observer: DeviceTransferServiceObserver) {
observers.removeAll { return $0.value === observer }
}
func notifyObservers(_ block: @escaping (DeviceTransferServiceObserver) -> Void) {
DispatchMainThreadSafe {
self.observers.compactMap { $0.value }.forEach { block($0) }
}
}
// MARK: -
func failTransfer(_ error: Error, _ reason: String) {
Logger.error("Failed transfer \(reason)")
stopTransfer()
notifyObservers { $0.deviceTransferServiceDidEndTransfer(error: error) }
}
func stopTransfer(notifyRegState: Bool = true) {
switch transferState {
case .outgoing:
sendTask?.cancel()
case .incoming:
newDeviceServiceAdvertiser.stopAdvertisingPeer()
case .idle:
break
}
session?.disconnect()
session = nil
identity = nil
DeviceSleepManager.shared.removeBlock(blockObject: sleepBlockObject)
// It is possible that we get here because the app was backgrounded
// after a failed launch. In that case, `tsAccountManager` will not be
// available, and setting this will crash. It'd probably be safe to more
// simply return in the .idle case above since none of the values being
// reset should have values if we are idle, but I am scared of it.
if case .idle = transferState {} else {
DependenciesBridge.shared.db.write { tx in
DependenciesBridge.shared.registrationStateChangeManager.setIsTransferComplete(
sendStateUpdateNotification: notifyRegState,
tx: tx
)
}
}
transferState = .idle
stopThroughputCalculation()
}
// MARK: -
@objc
private func didEnterBackground() {
// MCSession automatically disconnects when the app is backgrounded.
// Send an explicit message to the peer (if connected) telling them
// that's what happened.
switch transferState {
case .idle:
break
case .incoming(let oldDevicePeerId, _, _, _, _):
try? sendBackgroundAppMessage(to: oldDevicePeerId)
notifyObservers { $0.deviceTransferServiceDidEndTransfer(error: .backgroundedDevice) }
case .outgoing(let newDevicePeerId, _, _, _, _):
try? sendBackgroundAppMessage(to: newDevicePeerId)
notifyObservers { $0.deviceTransferServiceDidEndTransfer(error: .backgroundedDevice) }
}
stopTransfer()
}
// MARK: - Sending
private var sendTask: Task<Void, any Swift.Error>?
func sendAllFiles() throws {
self.sendTask = Task {
do {
try await self._sendAllFiles()
} catch is CancellationError {
// Nothing to do.
} catch {
self.failTransfer(.assertion, "\(error)")
}
}
}
@MainActor
private func _sendAllFiles() async throws {
guard case .outgoing(let newDevicePeerId, _, let manifest, _, _) = transferState else {
throw OWSAssertionError("Attempted to send files while no transfer in progress")
}
guard let database = manifest.database else {
throw OWSAssertionError("Manifest unexpectedly missing database")
}
struct DatabaseCopy {
let db: DeviceTransferProtoFile
let wal: DeviceTransferProtoFile
}
try await withThrowingTaskGroup(of: Void.self) { taskGroup in
taskGroup.addTask {
// Make a copy of the database files within a write transaction so we can be confident
// they aren't mutated during the copy. We then transfer these copies.
let dbCopy = try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { _ in
do {
let dbCopy = try Self.makeLocalCopy(databaseFile: database.database)
let walCopy = try Self.makeLocalCopy(databaseFile: database.wal)
return DatabaseCopy(db: dbCopy, wal: walCopy)
} catch {
Logger.error("Failed to copy database files!")
throw error
}
}
defer {
for databaseFile in [dbCopy.db, dbCopy.wal] {
if let copyUrl = try? Self.urlForCopy(databaseFile: databaseFile) {
try? OWSFileSystem.deleteFile(url: copyUrl)
}
}
}
for databaseFile in [dbCopy.db, dbCopy.wal] {
try await DeviceTransferOperation(file: databaseFile).run()
}
}
for (index, file) in manifest.files.enumerated() {
if index >= 10 {
// If we've already kicked off 10, wait for one to finish before starting the next.
try await taskGroup.next()
}
taskGroup.addTask {
try await DeviceTransferOperation(file: file).run()
}
}
// Make sure to wait for whatever's left at the end.
try await taskGroup.waitForAll()
}
await DependenciesBridge.shared.db.awaitableWrite { tx in
DependenciesBridge.shared.registrationStateChangeManager.setWasTransferred(tx: tx)
}
try self.sendDoneMessage(to: newDevicePeerId)
}
private static let dbCopyFilename = "db_copy_for_transfer"
private static let walCopyFilename = "wal_copy_for_transfer"
private static func urlForCopy(
databaseFile: DeviceTransferProtoFile
) throws -> URL {
let newFileName: String
let newFileExtension: String
if databaseFile.identifier == databaseIdentifier {
newFileName = Self.dbCopyFilename
newFileExtension = ".sqlite"
} else if databaseFile.identifier == databaseWALIdentifier {
newFileName = Self.walCopyFilename
newFileExtension = ".sqlite-wal"
} else {
throw OWSAssertionError("Unknown db file being copied")
}
owsAssertDebug(databaseFile.relativePath.hasSuffix(newFileExtension))
return OWSFileSystem.temporaryFileUrl(fileName: newFileName, fileExtension: newFileExtension)
}
private static func makeLocalCopy(
databaseFile: DeviceTransferProtoFile
) throws -> DeviceTransferProtoFile {
let url = URL(
fileURLWithPath: databaseFile.relativePath,
relativeTo: DeviceTransferService.appSharedDataDirectory
)
if !OWSFileSystem.fileOrFolderExists(url: url) {
throw OWSAssertionError("Mandatory database file is missing for transfer")
}
let copyUrl = try Self.urlForCopy(databaseFile: databaseFile)
if OWSFileSystem.fileOrFolderExists(url: copyUrl) {
// We might have partially copied before. Delete it.
try OWSFileSystem.deleteFile(url: copyUrl)
}
try OWSFileSystem.copyFile(from: url, to: copyUrl)
// Note that the receiver doesn't care about the relative path
// for database files (it does care for other files!) because it
// forces the path to be that to its own local database.
var protoBuilder = databaseFile.asBuilder()
protoBuilder.setRelativePath(copyUrl.relativePath)
return protoBuilder.buildInfallibly()
}
static let doneMessage = "Transfer Complete".data(using: .utf8)!
func sendDoneMessage(to peerId: MCPeerID) throws {
Logger.info("Sending done message")
guard let session = session else {
throw OWSAssertionError("attempted to send done message without an available session")
}
try session.send(DeviceTransferService.doneMessage, toPeers: [peerId], with: .reliable)
}
static let backgroundAppMessage = "App backgrounded".data(using: .utf8)!
func sendBackgroundAppMessage(to peerId: MCPeerID) throws {
Logger.info("Sending backgrounded message")
guard let session = session else {
throw OWSAssertionError("attempted to send backgrounded message without an available session")
}
try session.send(DeviceTransferService.backgroundAppMessage, toPeers: [peerId], with: .unreliable)
}
// MARK: - Throughput
private var previouslyCompletedBytes: Double = 0
private var lastWholeNumberProgress = 0
private var throughputTimer: Timer?
func startThroughputCalculation() {
guard Thread.isMainThread else {
DispatchQueue.main.async { self.startThroughputCalculation() }
return
}
stopThroughputCalculation()
guard let progress: Progress = {
switch transferState {
case .incoming(_, _, _, _, let progress):
return progress
case .outgoing(_, _, _, _, let progress):
return progress
case .idle:
owsFailDebug("Can't start throughput calculation while idle")
return nil
}
}() else {
return owsFailDebug("Can't start throughput calculations without progress")
}
previouslyCompletedBytes = Double(progress.totalUnitCount) * progress.fractionCompleted
throughputTimer = WeakTimer.scheduledTimer(timeInterval: 1, target: self, userInfo: nil, repeats: true) { _ in
let completedBytes = Double(progress.totalUnitCount) * progress.fractionCompleted
let bytesOverLastSecond = completedBytes - self.previouslyCompletedBytes
let remainingBytes = Double(progress.totalUnitCount) - completedBytes
self.previouslyCompletedBytes = completedBytes
if let averageThroughput = progress.throughput {
// Give more weight to the existing average than the new value
// to "smooth" changes in throughput and estimated time remaining.
let newAverageThroughput = 0.2 * Double(bytesOverLastSecond) + 0.8 * Double(averageThroughput)
progress.throughput = Int(newAverageThroughput)
progress.estimatedTimeRemaining = remainingBytes / newAverageThroughput
} else {
progress.throughput = Int(bytesOverLastSecond)
progress.estimatedTimeRemaining = remainingBytes / TimeInterval(bytesOverLastSecond)
}
self.logProgress(progress, remainingBytes: remainingBytes)
}
throughputTimer?.fire()
}
private func logProgress(_ progress: Progress, remainingBytes: Double) {
let currentWholeNumberProgress = Int(progress.fractionCompleted * 100)
let percentChange = currentWholeNumberProgress - lastWholeNumberProgress
defer { lastWholeNumberProgress = currentWholeNumberProgress }
// Determine how frequently to log progress updates. If in verbose mode, we log
// every 1%. Otherwise, every 10%.
guard percentChange >= (DebugFlags.deviceTransferVerboseProgressLogging ? 1 : 10) else { return }
var progressLog = String(format: "Transfer progress %d%%", currentWholeNumberProgress)
var remainingNumber = remainingBytes
var remainingUnits = "b"
if remainingNumber / 1024 >= 1 {
remainingNumber /= 1024
remainingUnits = "Kb"
}
if remainingNumber / 1024 >= 1 {
remainingNumber /= 1024
remainingUnits = "Mb"
}
if remainingNumber / 1024 >= 1 {
remainingNumber /= 1024
remainingUnits = "Gb"
}
progressLog += String(format: " / %0.2f %@ remaining", remainingNumber, remainingUnits)
if let throughput = progress.throughput {
var transferSpeed = Double(throughput) / 1024
var transferUnits = "Kbps"
if transferSpeed / 1024 >= 1 {
transferSpeed /= 1024
transferUnits = "Mbps"
}
progressLog += String(format: " / %0.2f %@", transferSpeed, transferUnits)
}
if let estimatedTime = progress.estimatedTimeRemaining, estimatedTime.isFinite {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second]
formatter.unitsStyle = .full
formatter.maximumUnitCount = 2
formatter.includesApproximationPhrase = true
formatter.includesTimeRemainingPhrase = true
let formattedString = formatter.string(from: estimatedTime)!
progressLog += " / \(formattedString)"
}
Logger.info(progressLog)
}
func stopThroughputCalculation() {
throughputTimer?.invalidate()
throughputTimer = nil
previouslyCompletedBytes = 0
lastWholeNumberProgress = 0
}
}