Signal-iOS/SignalServiceKit/ZeroKnowledge/BackupAuthCredentialManager.swift
2026-05-29 11:18:26 -07:00

519 lines
19 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import LibSignalClient
public enum BackupAuthCredentialType: String, Codable, CaseIterable, CodingKeyRepresentable {
case media
case messages
}
public enum BackupAuthCredentialFetchError: Error {
/// The server told us we had no existing backup id and therefore no backup credentials.
case noExistingBackupId
}
public protocol BackupAuthCredentialManager {
/// Fetch `BackupServiceAuth` for use during registration.
///
/// - Important
/// This API does not take any Backup entitlement-related actions, and so
/// should not be expected to return paid-tier auth regardless of the local
/// `BackupPlan` or the user's remote eligibility for the paid tier.
///
/// Relatedly, this API does not cache fetched credentials.
func fetchBackupServiceAuthForRegistration(
key: BackupKeyMaterial,
localAci: Aci,
chatServiceAuth: ChatServiceAuth,
logger: PrefixedLogger,
) async throws -> BackupServiceAuth
/// Fetch `BackupServiceAuth`. Callers may assume that tier of the returned
/// auth will match the tier the user is eligible for.
///
/// For example, paid-tier auth should be returned if the user is eligible
/// for the paid tier via IAP or AppAttest.
///
/// - parameter forceRefreshUnlessCachedPaidCredential: Forces a refresh if we have a cached
/// credential that isn't ``BackupLevel.paid``. Default false. Set this to true if intending to check whether a
/// paid credential is available.
func fetchBackupServiceAuth(
key: BackupKeyMaterial,
localAci: Aci,
chatServiceAuth: ChatServiceAuth,
forceRefreshUnlessCachedPaidCredential: Bool,
logger: PrefixedLogger,
) async throws -> BackupServiceAuth
func fetchSVRBAuthCredential(
key: MessageRootBackupKey,
chatServiceAuth: ChatServiceAuth,
logger: PrefixedLogger,
) async throws -> LibSignalClient.Auth
}
// MARK: -
class BackupAuthCredentialManagerImpl: BackupAuthCredentialManager {
private let authCredentialStore: AuthCredentialStore
private let backupIdService: BackupIdService
private let backupSubscriptionManager: BackupSubscriptionManager
private let backupTestFlightEntitlementManager: BackupTestFlightEntitlementManager
private let dateProvider: DateProvider
private let db: any DB
private let networkManager: NetworkManager
private let serialTaskQueue = ConcurrentTaskQueue(concurrentLimit: 1)
init(
authCredentialStore: AuthCredentialStore,
backupIdService: BackupIdService,
backupSubscriptionManager: BackupSubscriptionManager,
backupTestFlightEntitlementManager: BackupTestFlightEntitlementManager,
dateProvider: @escaping DateProvider,
db: any DB,
networkManager: NetworkManager,
) {
self.authCredentialStore = authCredentialStore
self.backupIdService = backupIdService
self.backupSubscriptionManager = backupSubscriptionManager
self.backupTestFlightEntitlementManager = backupTestFlightEntitlementManager
self.dateProvider = dateProvider
self.db = db
self.networkManager = networkManager
}
// MARK: -
func fetchBackupServiceAuthForRegistration(
key: BackupKeyMaterial,
localAci: Aci,
chatServiceAuth auth: ChatServiceAuth,
logger: PrefixedLogger,
) async throws -> BackupServiceAuth {
return try await serialTaskQueue.run {
try await _fetchBackupServiceAuthForRegistration(
key: key,
localAci: localAci,
chatServiceAuth: auth,
logger: logger,
)
}
}
private func _fetchBackupServiceAuthForRegistration(
key: BackupKeyMaterial,
localAci: Aci,
chatServiceAuth auth: ChatServiceAuth,
logger: PrefixedLogger,
) async throws -> BackupServiceAuth {
try await waitForAuthCredentialDependency(.registerBackupId(localAci: localAci, auth: auth), logger: logger)
let (_, backupServiceAuth) = try await fetchNewAuthCredentials(
localAci: localAci,
key: key,
auth: auth,
logger: logger,
)
return backupServiceAuth
}
// MARK: -
func fetchBackupServiceAuth(
key: BackupKeyMaterial,
localAci: Aci,
chatServiceAuth auth: ChatServiceAuth,
forceRefreshUnlessCachedPaidCredential: Bool,
logger: PrefixedLogger,
) async throws -> BackupServiceAuth {
return try await serialTaskQueue.run {
try await _fetchBackupServiceAuth(
key: key,
localAci: localAci,
chatServiceAuth: auth,
forceRefreshUnlessCachedPaidCredential: forceRefreshUnlessCachedPaidCredential,
logger: logger,
)
}
}
private func _fetchBackupServiceAuth(
key: BackupKeyMaterial,
localAci: Aci,
chatServiceAuth auth: ChatServiceAuth,
forceRefreshUnlessCachedPaidCredential: Bool,
logger: PrefixedLogger,
) async throws -> BackupServiceAuth {
try await waitForAuthCredentialDependency(.registerBackupId(localAci: localAci, auth: auth), logger: logger)
try await waitForAuthCredentialDependency(.renewBackupEntitlementForTestFlight, logger: logger)
try await waitForAuthCredentialDependency(.redeemBackupSubscriptionViaIAP, logger: logger)
if
let cachedServiceAuth = readCachedServiceAuth(
key: key,
localAci: localAci,
forceRefreshUnlessCachedPaidCredential: forceRefreshUnlessCachedPaidCredential,
logger: logger,
)
{
return cachedServiceAuth
}
let (
authCredentialsOfKeyType,
backupServiceAuth,
) = try await fetchNewAuthCredentials(localAci: localAci, key: key, auth: auth, logger: logger)
await db.awaitableWrite { tx in
cacheReceivedAuthCredentials(
authCredentialsOfKeyType,
credentialType: key.credentialType,
tx: tx,
logger: logger,
)
}
return backupServiceAuth
}
// MARK: -
func fetchSVRBAuthCredential(
key: MessageRootBackupKey,
chatServiceAuth auth: ChatServiceAuth,
logger: PrefixedLogger,
) async throws -> LibSignalClient.Auth {
try await serialTaskQueue.run {
try await _fetchSVRBAuthCredential(
key: key,
chatServiceAuth: auth,
logger: logger,
)
}
}
private func _fetchSVRBAuthCredential(
key: MessageRootBackupKey,
chatServiceAuth auth: ChatServiceAuth,
logger: PrefixedLogger,
) async throws -> LibSignalClient.Auth {
let backupServiceAuth = try await _fetchBackupServiceAuth(
key: key,
localAci: key.aci,
chatServiceAuth: auth,
forceRefreshUnlessCachedPaidCredential: false,
logger: logger,
)
let response = try await networkManager.asyncRequest(
OWSRequestFactory.fetchSVRBAuthCredential(auth: backupServiceAuth, logger: logger),
)
guard let bodyData = response.responseBodyData else {
throw OWSAssertionError("Missing body data", logger: logger)
}
let receivedSVRBAuthCredential = try JSONDecoder().decode(ReceivedSVRBAuthCredentials.self, from: bodyData)
let svrBAuth = LibSignalClient.Auth(
username: receivedSVRBAuthCredential.username,
password: receivedSVRBAuthCredential.password,
)
return svrBAuth
}
// MARK: -
/// Represents an action that should happen before we try and fetch auth
/// credentials, because they have side-effects that affect our ability to
/// fetch said credentials.
private enum BackupAuthCredentialDependency: Hashable {
case registerBackupId(localAci: Aci, auth: ChatServiceAuth)
case redeemBackupSubscriptionViaIAP
case renewBackupEntitlementForTestFlight
}
private func waitForAuthCredentialDependency(
_ dependency: BackupAuthCredentialDependency,
logger: PrefixedLogger,
) async throws {
let label: String
let block: () async throws -> Void
switch dependency {
case .registerBackupId(let localAci, let auth):
label = "registerBackupId"
block = { [dateProvider] in
// We can't fetch Backup auth credentials without having registered
// our Backup ID. Normally this will have already happened, making
// this call a no-op; however, it's possible it never succeeded or
// we need to run it again.
//
// It's also a particularly tightly rate-limited operation. If
// we fail because of rate limits we want to be sure that we
// respect the Retry-After at least enough to mitigate a
// thundering horde, since this is downstream of a potentially
// large volume of requests.
try await Retry.performWithBackoff(
maxAttempts: 3,
preferredBackoffBlock: { error in
return error.httpRetryAfterDate?.timeIntervalSince(dateProvider())
},
isRetryable: { $0.httpRetryAfterDate != nil },
block: {
try await self.backupIdService.registerBackupIDIfNecessary(
localAci: localAci,
auth: auth,
logger: logger,
)
},
)
}
case .redeemBackupSubscriptionViaIAP:
label = "redeemBackupSubscription"
block = {
// Redeem our subscription if necessary, to ensure we have our
// server-side Backup entitlement in place so we correctly fetch
// paid-ter credentials.
try await self.backupSubscriptionManager.redeemSubscriptionIfNecessary()
}
case .renewBackupEntitlementForTestFlight:
label = "testFlightEntitlement"
block = {
// Same motivation as redeeming our subscription above, but for
// TestFlight builds.
try await self.backupTestFlightEntitlementManager.renewEntitlementIfNecessary()
}
}
do {
try await block()
} catch {
logger.warn("Failed auth credential dependency step: \(label)! \(error)")
throw error
}
}
// MARK: -
private func readCachedAuthCredential(
key: BackupKeyMaterial,
logger: PrefixedLogger,
) -> BackupAuthCredential? {
return db.read { tx -> BackupAuthCredential? in
let redemptionTime = dateProvider().epochSecondsSinceStartOfToday
// Check there are more than 4 days of credentials remaining.
// If not, return nil and trigger a credential fetch.
guard
let _ = self.authCredentialStore.backupAuthCredential(
for: key.credentialType,
redemptionTime: redemptionTime + 4 * .dayInSeconds,
tx: tx,
)
else {
return nil
}
guard
let authCredential = self.authCredentialStore.backupAuthCredential(
for: key.credentialType,
redemptionTime: redemptionTime,
tx: tx,
)
else {
owsFailDebug("Unexpectedly missing auth credential for now, but had one for a future date!", logger: logger)
return nil
}
return authCredential
}
}
private let backupServiceAuthCache = LRUCache<Data, BackupServiceAuth>(maxSize: 4)
private func readCachedServiceAuth(
key: BackupKeyMaterial,
localAci: Aci,
forceRefreshUnlessCachedPaidCredential: Bool,
logger: PrefixedLogger,
) -> BackupServiceAuth? {
guard let cachedAuthCredential = readCachedAuthCredential(key: key, logger: logger) else {
return nil
}
switch cachedAuthCredential.backupLevel {
case .free where forceRefreshUnlessCachedPaidCredential:
return nil
case .free, .paid:
break
}
// Use the credential as the service auth cache key, so if the
// credential changes externally we skip the service auth cache.
let cacheKey = cachedAuthCredential.serialize()
if let cachedServiceAuth = backupServiceAuthCache[cacheKey] {
return cachedServiceAuth
} else {
let backupServiceAuth = BackupServiceAuth(
privateKey: key.deriveEcKey(aci: localAci),
authCredential: cachedAuthCredential,
type: key.credentialType,
)
backupServiceAuthCache[cacheKey] = backupServiceAuth
return backupServiceAuth
}
}
private func cacheReceivedAuthCredentials(
_ receivedAuthCredentials: [ReceivedBackupAuthCredential],
credentialType: BackupAuthCredentialType,
tx: DBWriteTransaction,
logger: PrefixedLogger,
) {
if receivedAuthCredentials.isEmpty {
owsFailDebug("Attempting to cache credentials, but none present!", logger: logger)
return
}
authCredentialStore.removeAllBackupAuthCredentials(ofType: credentialType, tx: tx)
for receivedCredential in receivedAuthCredentials {
authCredentialStore.setBackupAuthCredential(
receivedCredential.credential,
for: credentialType,
redemptionTime: receivedCredential.redemptionTime,
tx: tx,
)
}
}
private func fetchNewAuthCredentials(
localAci: Aci,
key: BackupKeyMaterial,
auth: ChatServiceAuth,
logger: PrefixedLogger,
) async throws -> ([ReceivedBackupAuthCredential], first: BackupServiceAuth) {
// Always fetch 7d worth of credentials at once.
let startTimestampSeconds = dateProvider().epochSecondsSinceStartOfToday
let endTimestampSeconds = startTimestampSeconds + 7 * .dayInSeconds
let timestampRange = startTimestampSeconds...endTimestampSeconds
let request = OWSRequestFactory.backupAuthenticationCredentialRequest(
from: startTimestampSeconds,
to: endTimestampSeconds,
auth: auth,
logger: logger,
)
let response: HTTPResponse
do {
response = try await networkManager.asyncRequest(request)
} catch let error {
if error.httpStatusCode == 404 {
throw BackupAuthCredentialFetchError.noExistingBackupId
} else {
throw error
}
}
guard let data = response.responseBodyData else {
throw OWSAssertionError("Missing response body data", logger: logger)
}
let authCredentialRepsonse = try JSONDecoder().decode(BackupCredentialResponse.self, from: data)
guard
let authCredentialsOfKeyType = authCredentialRepsonse.credentials[key.credentialType],
!authCredentialsOfKeyType.isEmpty
else {
throw OWSAssertionError("Missing auth credentials of type \(key.credentialType) in response!", logger: logger)
}
let backupServerPublicParams = try GenericServerPublicParams(contents: TSConstants.backupServerPublicParams)
let receivedAuthCredentials = try authCredentialsOfKeyType.compactMap { credential -> ReceivedBackupAuthCredential? in
guard timestampRange.contains(credential.redemptionTime) else {
owsFailDebug("Dropping backup credential outside of requested time range! \(key.credentialType)", logger: logger)
return nil
}
do {
let backupRequestContext = BackupAuthCredentialRequestContext.create(
backupKey: key.serialize(),
aci: localAci.rawUUID,
)
let backupAuthResponse = try BackupAuthCredentialResponse(contents: credential.credential)
let redemptionDate = Date(timeIntervalSince1970: TimeInterval(credential.redemptionTime))
let receivedCredential = try backupRequestContext.receive(
backupAuthResponse,
timestamp: redemptionDate,
params: backupServerPublicParams,
)
return ReceivedBackupAuthCredential(
redemptionTime: credential.redemptionTime,
credential: receivedCredential,
)
} catch {
logger.warn("Error creating credential! \(error)")
throw error
}
}
guard let firstAuthCredential = receivedAuthCredentials.first?.credential else {
throw OWSAssertionError("Unexpectedly missing auth credentials after parsing!", logger: logger)
}
return (
receivedAuthCredentials,
first: BackupServiceAuth(
privateKey: key.deriveEcKey(aci: localAci),
authCredential: firstAuthCredential,
type: key.credentialType,
),
)
}
// MARK: -
private struct BackupCredentialResponse: Decodable {
var credentials: [BackupAuthCredentialType: [AuthCredential]]
struct AuthCredential: Decodable {
var redemptionTime: UInt64
var credential: Data
}
}
private struct ReceivedBackupAuthCredential {
var redemptionTime: UInt64
var credential: BackupAuthCredential
}
private struct ReceivedSVRBAuthCredentials: Codable {
let username: String
let password: String
}
}
// MARK: -
private extension UInt64 {
static var dayInSeconds: UInt64 {
UInt64(TimeInterval.day)
}
}
private extension Date {
/// The "start of today", i.e. midnight at the beginning of today, in epoch seconds.
var epochSecondsSinceStartOfToday: UInt64 {
let daysSince1970 = UInt64(timeIntervalSince1970) / .dayInSeconds
return daysSince1970 * .dayInSeconds
}
}