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
}
}