258 lines
8.7 KiB
Swift
258 lines
8.7 KiB
Swift
//
|
|
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import LibSignalClient
|
|
|
|
/// Responsible for CRUD of the "BackupKey", which is an asymmetric key used to
|
|
/// sign Backup auth credentials.
|
|
///
|
|
/// - SeeAlso ``BackupIdService``
|
|
public protocol BackupKeyService {
|
|
|
|
/// "Enable" Backups by setting a public key used to sign Backup auth
|
|
/// credentials. This should only be done once for a given account while
|
|
/// Backups remains enabled, although it is idempotent and safe to call
|
|
/// repeatedly.
|
|
func registerBackupKey(
|
|
localIdentifiers: LocalIdentifiers,
|
|
auth: ChatServiceAuth,
|
|
logger: PrefixedLogger,
|
|
) async throws
|
|
|
|
/// De-initialize Backups by deleting a previously-registered BackupKey.
|
|
/// This is effectively a "delete Backup" operation, as subsequent to this
|
|
/// operation any Backup-related objects for this account will be deleted
|
|
/// from the server.
|
|
///
|
|
/// - Important
|
|
/// This operation is key to, but not all of, "disabling Backups". Callers
|
|
/// interested in a user-level "disable Backups" operation should instead
|
|
/// refer to `BackupDisablingManager`.
|
|
func deleteBackupKey(
|
|
localIdentifiers: LocalIdentifiers,
|
|
auth: ChatServiceAuth,
|
|
logger: PrefixedLogger,
|
|
) async throws
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
final class BackupKeyServiceImpl: BackupKeyService {
|
|
private let accountKeyStore: AccountKeyStore
|
|
private let backupRequestManager: BackupRequestManager
|
|
private let backupSettingsStore: BackupSettingsStore
|
|
private let db: DB
|
|
private let logger: PrefixedLogger
|
|
private let networkManager: NetworkManager
|
|
|
|
init(
|
|
accountKeyStore: AccountKeyStore,
|
|
backupRequestManager: BackupRequestManager,
|
|
backupSettingsStore: BackupSettingsStore,
|
|
db: DB,
|
|
networkManager: NetworkManager,
|
|
) {
|
|
self.accountKeyStore = accountKeyStore
|
|
self.backupRequestManager = backupRequestManager
|
|
self.backupSettingsStore = backupSettingsStore
|
|
self.db = db
|
|
self.logger = PrefixedLogger(prefix: "[Backups]")
|
|
self.networkManager = networkManager
|
|
}
|
|
|
|
private func rootBackupKeys(
|
|
localIdentifiers: LocalIdentifiers,
|
|
tx: DBWriteTransaction,
|
|
) throws -> (MessageRootBackupKey, MediaRootBackupKey) {
|
|
guard let messageRootBackupKey = try? accountKeyStore.getMessageRootBackupKey(aci: localIdentifiers.aci, tx: tx) else {
|
|
throw OWSAssertionError("Missing message root backup key! Do we not have an AEP?")
|
|
}
|
|
|
|
let mediaRootBackupKey = accountKeyStore.getOrGenerateMediaRootBackupKey(tx: tx)
|
|
|
|
return (messageRootBackupKey, mediaRootBackupKey)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func registerBackupKey(
|
|
localIdentifiers: LocalIdentifiers,
|
|
auth: ChatServiceAuth,
|
|
logger: PrefixedLogger,
|
|
) async throws {
|
|
try await _registerBackupKey(
|
|
localIdentifiers: localIdentifiers,
|
|
auth: auth,
|
|
retryOnFail: true,
|
|
logger: logger,
|
|
)
|
|
}
|
|
|
|
private func _registerBackupKey(
|
|
localIdentifiers: LocalIdentifiers,
|
|
auth: ChatServiceAuth,
|
|
retryOnFail: Bool,
|
|
logger: PrefixedLogger,
|
|
) async throws {
|
|
let (messageBackupKey, mediaBackupKey) = try await db.awaitableWrite { tx in
|
|
try rootBackupKeys(localIdentifiers: localIdentifiers, tx: tx)
|
|
}
|
|
|
|
do {
|
|
let messageBackupAuth = try await backupRequestManager.fetchBackupServiceAuth(
|
|
for: messageBackupKey,
|
|
localAci: localIdentifiers.aci,
|
|
auth: auth,
|
|
logger: logger,
|
|
)
|
|
|
|
_ = try await networkManager.asyncRequest(
|
|
.backupSetPublicKeyRequest(backupAuth: messageBackupAuth),
|
|
)
|
|
|
|
let mediaBackupAuth = try await backupRequestManager.fetchBackupServiceAuth(
|
|
for: mediaBackupKey,
|
|
localAci: localIdentifiers.aci,
|
|
auth: auth,
|
|
logger: logger,
|
|
)
|
|
|
|
_ = try await networkManager.asyncRequest(
|
|
.backupSetPublicKeyRequest(backupAuth: mediaBackupAuth),
|
|
)
|
|
} catch SignalError.verificationFailed where retryOnFail {
|
|
// This error is thrown if the backupID was never registered remotely.
|
|
// We *should* set it above in registerBackupIDIfNecessary based on local state,
|
|
// but in case local and remote state ever get out of sync, this will clear
|
|
// local state and re-register the backupID remotely.
|
|
Logger.error("Verification failed fetching BackupServiceAuth, clearing local state and retrying once.")
|
|
await db.awaitableWrite { tx in
|
|
BackupSettingsStore().setHaveSetBackupID(haveSetBackupID: false, tx: tx)
|
|
}
|
|
|
|
return try await _registerBackupKey(
|
|
localIdentifiers: localIdentifiers,
|
|
auth: auth,
|
|
retryOnFail: false,
|
|
logger: logger,
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func deleteBackupKey(
|
|
localIdentifiers: LocalIdentifiers,
|
|
auth: ChatServiceAuth,
|
|
logger: PrefixedLogger,
|
|
) async throws {
|
|
let (
|
|
messageBackupKey,
|
|
mediaBackupKey,
|
|
) = db.read { (
|
|
try? accountKeyStore.getMessageRootBackupKey(aci: localIdentifiers.aci, tx: $0),
|
|
accountKeyStore.getMediaRootBackupKey(tx: $0),
|
|
) }
|
|
|
|
func deleteBackup(key: BackupKeyMaterial) async throws {
|
|
let backupAuth = try await backupRequestManager.fetchBackupServiceAuth(
|
|
for: key,
|
|
localAci: localIdentifiers.aci,
|
|
auth: auth,
|
|
logger: logger,
|
|
)
|
|
|
|
try await deleteBackupKey(
|
|
localIdentifiers: localIdentifiers,
|
|
backupAuth: backupAuth,
|
|
)
|
|
}
|
|
|
|
if let messageBackupKey {
|
|
try await deleteBackup(key: messageBackupKey)
|
|
}
|
|
if let mediaBackupKey {
|
|
try await deleteBackup(key: mediaBackupKey)
|
|
}
|
|
}
|
|
|
|
func deleteBackupKey(
|
|
localIdentifiers: LocalIdentifiers,
|
|
backupAuth: BackupServiceAuth,
|
|
) async throws {
|
|
do {
|
|
_ = try await networkManager.asyncRequest(
|
|
.deleteBackupRequest(backupAuth: backupAuth),
|
|
)
|
|
} catch where error.httpStatusCode == 401 {
|
|
// This will happen if, for whatever reason, the user doesn't have
|
|
// a Backup to delete. (It's a 401 because this really means the
|
|
// server has deleted the key we use to authenticate Backup
|
|
// requests, which happens in response to an earlier success in
|
|
// calling this API.)
|
|
//
|
|
// Treat this like a success: maybe we deleted earlier, but
|
|
// never got the response back.
|
|
logger.warn("Got 401 deleting Backup: treating like success.")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private extension TSRequest {
|
|
static func backupSetPublicKeyRequest(
|
|
backupAuth: BackupServiceAuth,
|
|
) -> TSRequest {
|
|
var request = TSRequest(
|
|
url: URL(string: "v1/archives/keys")!,
|
|
method: "PUT",
|
|
parameters: ["backupIdPublicKey": backupAuth.publicKey.serialize().base64EncodedString()],
|
|
)
|
|
request.auth = .backup(backupAuth)
|
|
return request
|
|
}
|
|
|
|
static func deleteBackupRequest(
|
|
backupAuth: BackupServiceAuth,
|
|
) -> TSRequest {
|
|
var request = TSRequest(
|
|
url: URL(string: "v1/archives")!,
|
|
method: "DELETE",
|
|
parameters: nil,
|
|
)
|
|
// The first time you call this, a "delete" operation is enqueued on the
|
|
// server to be performed asynchronously (e.g., within 24h). If you call
|
|
// again with an async deletion already enqueued, it'll delete
|
|
// synchronously, which can be very slow.
|
|
request.timeoutInterval = 30
|
|
request.auth = .backup(backupAuth)
|
|
return request
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
#if TESTABLE_BUILD
|
|
|
|
class MockBackupKeyService: BackupKeyService {
|
|
func registerBackupKey(localIdentifiers: LocalIdentifiers, auth: ChatServiceAuth, logger: PrefixedLogger) async throws {
|
|
// Do nothing
|
|
}
|
|
|
|
var deleteBackupKeyMock: (() async throws -> Void)?
|
|
func deleteBackupKey(localIdentifiers: LocalIdentifiers, auth: ChatServiceAuth, logger: PrefixedLogger) async throws {
|
|
if let deleteBackupKeyMock {
|
|
return try await deleteBackupKeyMock()
|
|
}
|
|
}
|
|
|
|
func deleteBackupKey(localIdentifiers: LocalIdentifiers, backupAuth: BackupServiceAuth, logger: PrefixedLogger) async throws {
|
|
// Do nothing
|
|
}
|
|
}
|
|
|
|
#endif
|