Path: blob/main/SignalServiceKit/Backups/Settings/BackupKeyService.swift
1 views
//
// 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