Signal-iOS/SignalServiceKit/Util/StorageService.swift
2024-07-11 10:38:21 -05:00

578 lines
24 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
@objc
public protocol StorageServiceManagerObjc {
func recordPendingUpdates(updatedRecipientUniqueIds: [RecipientUniqueId])
func recordPendingUpdates(updatedAddresses: [SignalServiceAddress])
func recordPendingUpdates(updatedGroupV2MasterKeys: [Data])
func recordPendingUpdates(updatedStoryDistributionListIds: [Data])
// A convenience method that calls recordPendingUpdates(updatedGroupV2MasterKeys:).
func recordPendingUpdates(groupModel: TSGroupModel)
func recordPendingLocalAccountUpdates()
/// Updates the local user's identity.
///
/// Called during app launch, registration, and change number.
func setLocalIdentifiers(_ localIdentifiers: LocalIdentifiersObjC)
/// Waits for pending restores to finish.
///
/// When this is resolved, it means the current device has the latest state
/// available on storage service.
///
/// If this device believes there's new state available on storage service
/// but the request to fetch it has failed, this Promise will be rejected.
///
/// If the local device doesn't believe storage service has new state, this
/// will resolve without performing any network requests.
///
/// Due to the asynchronous nature of network requests, it's possible for
/// another device to write to storage service at the same time the returned
/// Promise resolves. Therefore, the precise behavior of this method is best
/// described as: "if this device has knowledge that storage service has new
/// state at the time this method is invoked, the returned Promise will be
/// resolved after that state has been fetched".
func waitForPendingRestores() -> AnyPromise
}
public protocol StorageServiceManager: StorageServiceManagerObjc {
func backupPendingChanges(authedDevice: AuthedDevice)
@discardableResult
func restoreOrCreateManifestIfNecessary(authedDevice: AuthedDevice) -> Promise<Void>
func resetLocalData(transaction: DBWriteTransaction)
}
// MARK: -
public struct StorageService: Dependencies {
public enum StorageError: Error, IsRetryableProvider {
case assertion
case retryableAssertion
case manifestEncryptionFailed(version: UInt64)
case manifestDecryptionFailed(version: UInt64)
/// Decryption succeeded (passed validation) but interpreting those bytes as a proto failed.
case manifestProtoDeserializationFailed(version: UInt64)
case itemEncryptionFailed(identifier: StorageIdentifier)
case itemDecryptionFailed(identifier: StorageIdentifier)
/// Decryption succeeded (passed validation) but interpreting those bytes as a proto failed.
case itemProtoDeserializationFailed(identifier: StorageIdentifier)
case networkError(statusCode: Int, underlyingError: Error)
// MARK:
public var isRetryableProvider: Bool {
switch self {
case .assertion:
return false
case .retryableAssertion:
return true
case .manifestEncryptionFailed:
return false
case .manifestDecryptionFailed:
return false
case .manifestProtoDeserializationFailed:
return false
case .itemEncryptionFailed:
return false
case .itemDecryptionFailed:
return false
case .itemProtoDeserializationFailed:
return false
case .networkError(let statusCode, _):
// If this is a server error, retry
return statusCode >= 500
}
}
public var errorUserInfo: [String: Any] {
var userInfo: [String: Any] = [:]
if case .networkError(_, let underlyingError) = self {
userInfo[NSUnderlyingErrorKey] = underlyingError
}
return userInfo
}
}
/// An identifier representing a given storage item.
/// This can be used to fetch specific items from the service.
public struct StorageIdentifier: Hashable, Codable {
public static let identifierLength: UInt = 16
public let data: Data
public let type: StorageServiceProtoManifestRecordKeyType
public init(data: Data, type: StorageServiceProtoManifestRecordKeyType) {
if data.count != StorageIdentifier.identifierLength { owsFail("Initialized with invalid data") }
self.data = data
self.type = type
}
public static func generate(type: StorageServiceProtoManifestRecordKeyType) -> StorageIdentifier {
return .init(data: Randomness.generateRandomBytes(identifierLength), type: type)
}
public func buildRecord() -> StorageServiceProtoManifestRecordKey {
let builder = StorageServiceProtoManifestRecordKey.builder(data: data, type: type)
return builder.buildInfallibly()
}
public static func deduplicate(_ identifiers: [StorageIdentifier]) -> [StorageIdentifier] {
var identifierTypeMap = [Data: StorageIdentifier]()
for identifier in identifiers {
if let existingIdentifier = identifierTypeMap[identifier.data] {
owsFailDebug("Duplicate identifiers in manifest with types: \(identifier.type), \(existingIdentifier.type)")
} else {
identifierTypeMap[identifier.data] = identifier
}
}
return Array(identifierTypeMap.values)
}
}
public struct StorageItem {
public let identifier: StorageIdentifier
public let record: StorageServiceProtoStorageRecord
public var type: StorageServiceProtoManifestRecordKeyType { identifier.type }
public var contactRecord: StorageServiceProtoContactRecord? {
guard case .contact = type else { return nil }
guard case .contact(let record) = record.record else {
owsFailDebug("unexpectedly missing contact record")
return nil
}
return record
}
public var groupV1Record: StorageServiceProtoGroupV1Record? {
guard case .groupv1 = type else { return nil }
guard case .groupV1(let record) = record.record else {
owsFailDebug("unexpectedly missing group v1 record")
return nil
}
return record
}
public var groupV2Record: StorageServiceProtoGroupV2Record? {
guard case .groupv2 = type else { return nil }
guard case .groupV2(let record) = record.record else {
owsFailDebug("unexpectedly missing group v2 record")
return nil
}
return record
}
public var accountRecord: StorageServiceProtoAccountRecord? {
guard case .account = type else { return nil }
guard case .account(let record) = record.record else {
owsFailDebug("unexpectedly missing account record")
return nil
}
return record
}
public var storyDistributionListRecord: StorageServiceProtoStoryDistributionListRecord? {
guard case .storyDistributionList = type else { return nil }
guard case .storyDistributionList(let record) = record.record else {
owsFailDebug("unexpectedly missing story distribution list record")
return nil
}
return record
}
public init(identifier: StorageIdentifier, contact: StorageServiceProtoContactRecord) {
var storageRecord = StorageServiceProtoStorageRecord.builder()
storageRecord.setRecord(.contact(contact))
self.init(identifier: identifier, record: storageRecord.buildInfallibly())
}
public init(identifier: StorageIdentifier, groupV1: StorageServiceProtoGroupV1Record) {
var storageRecord = StorageServiceProtoStorageRecord.builder()
storageRecord.setRecord(.groupV1(groupV1))
self.init(identifier: identifier, record: storageRecord.buildInfallibly())
}
public init(identifier: StorageIdentifier, groupV2: StorageServiceProtoGroupV2Record) {
var storageRecord = StorageServiceProtoStorageRecord.builder()
storageRecord.setRecord(.groupV2(groupV2))
self.init(identifier: identifier, record: storageRecord.buildInfallibly())
}
public init(identifier: StorageIdentifier, account: StorageServiceProtoAccountRecord) {
var storageRecord = StorageServiceProtoStorageRecord.builder()
storageRecord.setRecord(.account(account))
self.init(identifier: identifier, record: storageRecord.buildInfallibly())
}
public init(identifier: StorageIdentifier, storyDistributionList: StorageServiceProtoStoryDistributionListRecord) {
var storageRecord = StorageServiceProtoStorageRecord.builder()
storageRecord.setRecord(.storyDistributionList(storyDistributionList))
self.init(identifier: identifier, record: storageRecord.buildInfallibly())
}
public init(identifier: StorageIdentifier, record: StorageServiceProtoStorageRecord) {
self.identifier = identifier
self.record = record
}
}
public enum FetchLatestManifestResponse {
case latestManifest(StorageServiceProtoManifestRecord)
case noNewerManifest
case noExistingManifest
}
/// Fetch the latest manifest from the storage service.
/// If the greater than version is provided, only returns a manifest
/// if a newer one exists on the service, otherwise indicates
/// that there is no new content.
///
/// Returns nil if a manifest has never been stored.
public static func fetchLatestManifest(
greaterThanVersion: UInt64? = nil,
chatServiceAuth: ChatServiceAuth
) -> Promise<FetchLatestManifestResponse> {
Logger.info("")
var endpoint = "v1/storage/manifest"
if let greaterThanVersion = greaterThanVersion {
endpoint += "/version/\(greaterThanVersion)"
}
return storageRequest(
withMethod: .get,
endpoint: endpoint,
chatServiceAuth: chatServiceAuth
).map(on: DispatchQueue.global()) { response in
switch response.status {
case .success:
let encryptedManifestContainer = try StorageServiceProtoStorageManifest(serializedData: response.data)
let decryptResult = self.databaseStorage.read(block: { tx in
return DependenciesBridge.shared.svr.decrypt(
keyType: .storageServiceManifest(version: encryptedManifestContainer.version),
encryptedData: encryptedManifestContainer.value,
transaction: tx.asV2Read
)
})
switch decryptResult {
case .success(let manifestData):
do {
let proto = try StorageServiceProtoManifestRecord(serializedData: manifestData)
return .latestManifest(proto)
} catch {
Logger.error("Failed to deserialize manifest proto after successful decryption.")
throw StorageError.manifestProtoDeserializationFailed(version: encryptedManifestContainer.version)
}
case .masterKeyMissing, .cryptographyError:
throw StorageError.manifestDecryptionFailed(version: encryptedManifestContainer.version)
}
case .notFound:
return .noExistingManifest
case .noContent:
return .noNewerManifest
default:
owsFailDebug("unexpected response \(response.status)")
throw StorageError.retryableAssertion
}
}
}
/// Update the manifest record on the service.
///
/// If the version we are updating to already exists on the service,
/// the conflicting manifest will return and the update will not
/// have been applied until we resolve the conflicts.
public static func updateManifest(
_ manifest: StorageServiceProtoManifestRecord,
newItems: [StorageItem],
deletedIdentifiers: [StorageIdentifier] = [],
deleteAllExistingRecords: Bool = false,
chatServiceAuth: ChatServiceAuth
) -> Promise<StorageServiceProtoManifestRecord?> {
Logger.info("newItems: \(newItems.count), deletedIdentifiers: \(deletedIdentifiers.count), deleteAllExistingRecords: \(deleteAllExistingRecords)")
return DispatchQueue.global().async(.promise) {
var builder = StorageServiceProtoWriteOperation.builder()
// Encrypt the manifest
let manifestData = try manifest.serializedData()
let encryptedManifestData: Data
let encryptResult = self.databaseStorage.read(block: { tx in
return DependenciesBridge.shared.svr.encrypt(
keyType: .storageServiceManifest(version: manifest.version),
data: manifestData,
transaction: tx.asV2Read
)
})
switch encryptResult {
case .success(let data):
encryptedManifestData = data
case .masterKeyMissing, .cryptographyError:
throw StorageError.manifestEncryptionFailed(version: manifest.version)
}
let manifestWrapperBuilder = StorageServiceProtoStorageManifest.builder(
version: manifest.version,
value: encryptedManifestData
)
builder.setManifest(manifestWrapperBuilder.buildInfallibly())
// Encrypt the new items
builder.setInsertItem(try newItems.map { item in
let itemData = try item.record.serializedData()
let encryptedItemData: Data
let itemEncryptionResult = self.databaseStorage.read(block: { tx in
return DependenciesBridge.shared.svr.encrypt(
keyType: .storageServiceRecord(identifier: item.identifier),
data: itemData,
transaction: tx.asV2Read
)
})
switch itemEncryptionResult {
case .success(let data):
encryptedItemData = data
case .masterKeyMissing, .cryptographyError:
throw StorageError.itemEncryptionFailed(identifier: item.identifier)
}
let itemWrapperBuilder = StorageServiceProtoStorageItem.builder(key: item.identifier.data, value: encryptedItemData)
return itemWrapperBuilder.buildInfallibly()
})
// Flag the deleted keys
builder.setDeleteKey(deletedIdentifiers.map { $0.data })
builder.setDeleteAll(deleteAllExistingRecords)
return try builder.buildSerializedData()
}.then(on: DispatchQueue.global()) { data in
storageRequest(
withMethod: .put,
endpoint: "v1/storage",
body: data,
chatServiceAuth: chatServiceAuth
)
}.map(on: DispatchQueue.global()) { response in
switch response.status {
case .success:
// We expect a successful response to have no data
if !response.data.isEmpty { owsFailDebug("unexpected response data") }
return nil
case .conflict:
// Our version was out of date, we should've received a copy of the latest version
let encryptedManifestContainer = try StorageServiceProtoStorageManifest(serializedData: response.data)
let decryptionResult = self.databaseStorage.read(block: { tx in
return DependenciesBridge.shared.svr.decrypt(
keyType: .storageServiceManifest(version: encryptedManifestContainer.version),
encryptedData: encryptedManifestContainer.value,
transaction: tx.asV2Read
)
})
switch decryptionResult {
case .success(let manifestData):
do {
let proto = try StorageServiceProtoManifestRecord(serializedData: manifestData)
return proto
} catch {
Logger.error("Failed to deserialize manifest proto after successful decryption.")
throw StorageError.manifestProtoDeserializationFailed(version: encryptedManifestContainer.version)
}
case .masterKeyMissing, .cryptographyError:
throw StorageError.manifestDecryptionFailed(version: encryptedManifestContainer.version)
}
default:
owsFailDebug("unexpected response \(response.status)")
throw StorageError.retryableAssertion
}
}
}
/// Fetch an item record from the service
///
/// Returns nil if this record does not exist
public static func fetchItem(for key: StorageIdentifier, chatServiceAuth: ChatServiceAuth) -> Promise<StorageItem?> {
return fetchItems(for: [key], chatServiceAuth: chatServiceAuth).map { $0.first }
}
/// Fetch a list of item records from the service
///
/// The response will include only the items that could be found on the service
public static func fetchItems(
for identifiers: [StorageIdentifier],
chatServiceAuth: ChatServiceAuth
) -> Promise<[StorageItem]> {
Logger.info("")
let keys = StorageIdentifier.deduplicate(identifiers)
// The server will 500 if we try and request too many keys at once.
owsAssertDebug(keys.count <= 1024)
guard !keys.isEmpty else { return Promise.value([]) }
return DispatchQueue.global().async(.promise) {
var builder = StorageServiceProtoReadOperation.builder()
builder.setReadKey(keys.map { $0.data })
return try builder.buildSerializedData()
}.then(on: DispatchQueue.global()) { data in
storageRequest(
withMethod: .put,
endpoint: "v1/storage/read",
body: data,
chatServiceAuth: chatServiceAuth
)
}.map(on: DispatchQueue.global()) { response in
guard case .success = response.status else {
owsFailDebug("unexpected response \(response.status)")
throw StorageError.retryableAssertion
}
let itemsProto = try StorageServiceProtoStorageItems(serializedData: response.data)
let keyToIdentifier = Dictionary(uniqueKeysWithValues: keys.map { ($0.data, $0) })
return try itemsProto.items.map { item in
let encryptedItemData = item.value
guard let itemIdentifier = keyToIdentifier[item.key] else {
owsFailDebug("missing identifier for fetched item")
throw StorageError.assertion
}
let itemDecryptionResult = self.databaseStorage.read(block: { tx in
return DependenciesBridge.shared.svr.decrypt(
keyType: .storageServiceRecord(identifier: itemIdentifier),
encryptedData: encryptedItemData,
transaction: tx.asV2Read
)
})
switch itemDecryptionResult {
case .success(let itemData):
do {
let record = try StorageServiceProtoStorageRecord(serializedData: itemData)
return StorageItem(identifier: itemIdentifier, record: record)
} catch {
Logger.error("Failed to deserialize item proto after decryption succeeded")
throw StorageError.itemProtoDeserializationFailed(identifier: itemIdentifier)
}
case .masterKeyMissing, .cryptographyError:
throw StorageError.itemDecryptionFailed(identifier: itemIdentifier)
}
}
}
}
// MARK: - Dependencies
private static var urlSession: OWSURLSessionProtocol {
return self.signalService.urlSessionForStorageService()
}
// MARK: - Storage Requests
private struct StorageResponse {
enum Status {
case success
case conflict
case notFound
case noContent
}
let status: Status
let data: Data
}
private static func storageRequest(
withMethod method: HTTPMethod,
endpoint: String,
body: Data? = nil,
chatServiceAuth: ChatServiceAuth
) -> Promise<StorageResponse> {
return serviceClient
.requestStorageAuth(chatServiceAuth: chatServiceAuth)
.then { username, password -> Promise<HTTPResponse> in
if method == .get { assert(body == nil) }
let httpHeaders = OWSHttpHeaders()
httpHeaders.addHeader("Content-Type", value: MimeType.applicationXProtobuf.rawValue, overwriteOnConflict: true)
try httpHeaders.addAuthHeader(username: username, password: password)
Logger.info("Storage request started: \(method) \(endpoint)")
let urlSession = self.urlSession
// Some 4xx responses are expected;
// we'll discriminate the status code ourselves.
urlSession.require2xxOr3xx = false
return urlSession.dataTaskPromise(endpoint,
method: method,
headers: httpHeaders.headers,
body: body)
}
.map(on: DispatchQueue.global()) { (response: HTTPResponse) -> StorageResponse in
let status: StorageResponse.Status
let statusCode = response.responseStatusCode
switch statusCode {
case 200:
status = .success
case 204:
status = .noContent
case 409:
status = .conflict
case 404:
status = .notFound
default:
let error = OWSAssertionError("Unexpected statusCode: \(statusCode)")
throw StorageError.networkError(statusCode: statusCode, underlyingError: error)
}
// We should always receive response data, for some responses it will be empty.
guard let responseData = response.responseBodyData else {
owsFailDebug("missing response data")
throw StorageError.retryableAssertion
}
// The layers that use this only want to process 200 and 409 responses,
// anything else we should raise as an error.
Logger.info("Storage request succeeded: \(method) \(endpoint)")
return StorageResponse(status: status, data: responseData)
}.recover(on: DispatchQueue.global()) { (error: Error) -> Promise<StorageResponse> in
owsFailDebugUnlessNetworkFailure(error)
throw StorageError.networkError(statusCode: 0, underlyingError: error)
}
}
}
// MARK: -
extension StorageServiceProtoManifestRecordKeyType: Codable {}
extension StorageServiceProtoManifestRecordKeyType: CustomStringConvertible {
public var description: String {
switch self {
case .unknown:
return ".unknown"
case .contact:
return ".contact"
case .groupv1:
return ".groupv1"
case .groupv2:
return ".groupv2"
case .account:
return ".account"
case .storyDistributionList:
return ".storyDistributionList"
case .UNRECOGNIZED:
return ".UNRECOGNIZED"
}
}
}