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

public import LibSignalClient

public protocol OWSDeviceService {
    /// Refresh the list of our linked devices.
    /// - Returns
    /// True if the list changed, and false otherwise.
    func refreshDevices() async throws -> Bool

    /// Unlink the given device.
    func unlinkDevice(deviceId: DeviceId) async throws

    /// Renames a device with the given encrypted name.
    func renameDevice(
        device: OWSDevice,
        newName: String,
    ) async throws
}

extension OWSDeviceService {
    public func unlinkDevice(_ device: OWSDevice) async throws {
        try await unlinkDevice(deviceId: device.deviceId)
    }
}

// MARK: -

struct OWSDeviceServiceImpl: OWSDeviceService {
    private let db: any DB
    private let deviceNameChangeSyncMessageSender: DeviceNameChangeSyncMessageSender
    private let deviceManager: OWSDeviceManager
    private let deviceStore: OWSDeviceStore
    private let identityManager: OWSIdentityManager
    private let networkManager: NetworkManager
    private let recipientFetcher: RecipientFetcher
    private let recipientManager: any SignalRecipientManager
    private let tsAccountManager: any TSAccountManager

    init(
        db: any DB,
        deviceManager: OWSDeviceManager,
        deviceStore: OWSDeviceStore,
        identityManager: OWSIdentityManager,
        messageSenderJobQueue: MessageSenderJobQueue,
        networkManager: NetworkManager,
        recipientFetcher: RecipientFetcher,
        recipientManager: any SignalRecipientManager,
        threadStore: ThreadStore,
        tsAccountManager: any TSAccountManager,
    ) {
        self.db = db
        self.deviceNameChangeSyncMessageSender = DeviceNameChangeSyncMessageSender(
            messageSenderJobQueue: messageSenderJobQueue,
            threadStore: threadStore,
        )
        self.deviceManager = deviceManager
        self.deviceStore = deviceStore
        self.identityManager = identityManager
        self.networkManager = networkManager
        self.recipientFetcher = recipientFetcher
        self.recipientManager = recipientManager
        self.tsAccountManager = tsAccountManager
    }

    // MARK: -

    private struct DeviceListResponse: Decodable {
        struct Device: Decodable {
            enum CodingKeys: String, CodingKey {
                case id
                case lastSeenAtMs = "lastSeen"
                case registrationId
                case createdAtCiphertext
                case nameCiphertext = "name"
            }

            let id: DeviceId
            let lastSeenAtMs: UInt64
            let registrationId: UInt32
            let createdAtCiphertext: Data
            let nameCiphertext: String?
        }

        let devices: [Device]
    }

    func refreshDevices() async throws -> Bool {
        guard
            let identityKeyPair = db.read(block: { tx in
                identityManager.identityKeyPair(for: .aci, tx: tx)?.keyPair
            })
        else {
            throw OWSAssertionError("Missing ACI identity key pair: will fail to refresh devices!")
        }

        let getDevicesResponse = try await networkManager.asyncRequest(.getDevices())

        let devices = try parseDeviceList(
            httpResponse: getDevicesResponse,
            identityKeyPair: identityKeyPair,
        )

        let deviceIds = devices.map(\.deviceId)

        let didAddOrRemove = await db.awaitableWrite { tx in
            let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx)!
            var localRecipient = recipientFetcher.fetchOrCreate(serviceId: localIdentifiers.aci, tx: tx)
            recipientManager.modifyAndSave(
                &localRecipient,
                deviceIdsToAdd: Array(Set(deviceIds).subtracting(localRecipient.deviceIds)),
                deviceIdsToRemove: Array(Set(localRecipient.deviceIds).subtracting(deviceIds)),
                shouldUpdateStorageService: false,
                tx: tx,
            )

            return deviceStore.replaceAll(with: devices, tx: tx)
        }

        return didAddOrRemove
    }

    private func parseDeviceList(
        httpResponse: HTTPResponse,
        identityKeyPair: IdentityKeyPair,
    ) throws -> [OWSDevice] {
        guard let responseBodyData = httpResponse.responseBodyData else {
            throw OWSAssertionError("Missing body data in getDevices response!")
        }

        let devicesResponse = try JSONDecoder().decode(DeviceListResponse.self, from: responseBodyData)

        return try devicesResponse.devices.map {
            try parseOWSDevice(from: $0, identityKeyPair: identityKeyPair)
        }
    }

    private func parseOWSDevice(
        from fetchedDevice: DeviceListResponse.Device,
        identityKeyPair: IdentityKeyPair,
    ) throws(OWSAssertionError) -> OWSDevice {
        let name: String?
        if let nameCiphertext = fetchedDevice.nameCiphertext?.strippedOrNil {
            do {
                name = try OWSDeviceNames.decryptDeviceName(
                    base64String: nameCiphertext,
                    identityKeyPair: identityKeyPair,
                )
            } catch OWSDeviceNameError.emptyName {
                name = nil
            } catch {
                owsFailDebug("Failed to decrypt device name! Is this a legacy device name? \(error)")
                name = nil
            }
        } else {
            name = nil
        }

        let createdAtMs: UInt64
        do {
            // The createdAtCiphertext is an Int64, encrypted using the identity
            // key PrivateKey with associated data (deviceId || registrationId).
            //
            // Note that the server does everything big-endian, whereas iOS uses
            // little-endian by default.

            var associatedData = Data()
            associatedData += fetchedDevice.id.rawValue.bigEndianData
            associatedData += fetchedDevice.registrationId.bigEndianData

            let createdAtData: Data = try identityKeyPair.privateKey.open(
                fetchedDevice.createdAtCiphertext,
                info: "deviceCreatedAt",
                associatedData: associatedData,
            )
            guard let createdAt = Int64(bigEndianData: createdAtData) else {
                throw OWSGenericError("not enough bytes for deviceCreatedAt")
            }
            createdAtMs = UInt64(clamping: createdAt)
        } catch {
            throw OWSAssertionError("Failed to decrypt device createdAt! \(error)")
        }

        return OWSDevice(
            deviceId: fetchedDevice.id,
            createdAt: Date(millisecondsSince1970: createdAtMs),
            lastSeenAt: Date(millisecondsSince1970: fetchedDevice.lastSeenAtMs),
            name: name,
        )
    }

    // MARK: -

    func unlinkDevice(deviceId: DeviceId) async throws {
        _ = try await networkManager.asyncRequest(TSRequest.deleteDevice(deviceId: deviceId))
    }

    func renameDevice(
        device: OWSDevice,
        newName: String,
    ) async throws {
        guard
            let identityKeyPair = db.read(block: { tx in
                identityManager.identityKeyPair(for: .aci, tx: tx)
            })
        else {
            throw OWSAssertionError("can't rename device without identity key")
        }

        let newNameEncrypted = try OWSDeviceNames.encryptDeviceName(
            plaintext: newName,
            identityKeyPair: identityKeyPair.keyPair,
        ).base64EncodedString()

        let response = try await self.networkManager.asyncRequest(
            .renameDevice(device: device, encryptedName: newNameEncrypted),
        )

        guard response.responseStatusCode == 204 else {
            throw response.asError()
        }

        await db.awaitableWrite { tx in
            deviceStore.setName(newName, for: device, tx: tx)

            deviceNameChangeSyncMessageSender.enqueueDeviceNameChangeSyncMessage(
                forDeviceId: device.deviceId.uint32Value,
                tx: tx,
            )
        }
    }
}

// MARK: -

private struct DeviceNameChangeSyncMessageSender {
    private let messageSenderJobQueue: MessageSenderJobQueue
    private let threadStore: ThreadStore

    init(messageSenderJobQueue: MessageSenderJobQueue, threadStore: ThreadStore) {
        self.messageSenderJobQueue = messageSenderJobQueue
        self.threadStore = threadStore
    }

    func enqueueDeviceNameChangeSyncMessage(
        forDeviceId deviceId: UInt32,
        tx: DBWriteTransaction,
    ) {
        guard let localThread = threadStore.getOrCreateLocalThread(tx: tx) else {
            owsFailDebug("Failed to create local thread!")
            return
        }

        let outgoingSyncMessage = OutgoingDeviceNameChangeSyncMessage(
            deviceId: deviceId,
            localThread: localThread,
            tx: tx,
        )

        messageSenderJobQueue.add(
            message: .preprepared(transientMessageWithoutAttachments: outgoingSyncMessage),
            transaction: tx,
        )
    }
}

// MARK: -

extension TSRequest {
    fileprivate static func getDevices() -> TSRequest {
        return TSRequest(
            url: URL(string: "v1/devices")!,
            method: "GET",
            parameters: [:],
        )
    }

    public static func deleteDevice(
        deviceId: DeviceId,
    ) -> TSRequest {
        return TSRequest(
            url: URL(string: "v1/devices/\(deviceId)")!,
            method: "DELETE",
            parameters: nil,
        )
    }

    fileprivate static func renameDevice(
        device: OWSDevice,
        encryptedName: String,
    ) -> TSRequest {
        var urlComponents = URLComponents(string: "v1/accounts/name")!
        urlComponents.queryItems = [URLQueryItem(
            name: "deviceId",
            value: "\(device.deviceId)",
        )]
        var request = TSRequest(
            url: urlComponents.url!,
            method: "PUT",
            parameters: [
                "deviceName": encryptedName,
            ],
        )
        request.applyRedactionStrategy(.redactURL())
        return request
    }
}