Signal-iOS/SignalServiceKit/Subscriptions/Backups/BackupTestFlightEntitlementManager.swift
2025-10-27 13:27:11 -05:00

704 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import CryptoKit
import DeviceCheck
/// Responsible for managing paid-tier Backup entitlements for TestFlight users,
/// who aren't able to use StoreKit or perform real-money transactions.
public protocol BackupTestFlightEntitlementManager {
func acquireEntitlement() async throws
func setRenewEntitlementIsNecessary(tx: DBWriteTransaction)
func renewEntitlementIfNecessary() async throws
}
// MARK: -
final class BackupTestFlightEntitlementManagerImpl: BackupTestFlightEntitlementManager {
private enum StoreKeys {
static let lastEntitlementRenewalDate = "lastEntitlementRenewalDate"
}
private let appAttestManager: AppAttestManager
private let backupPlanManager: BackupPlanManager
private let backupSubscriptionIssueStore: BackupSubscriptionIssueStore
private let backupSubscriptionManager: BackupSubscriptionManager
private let dateProvider: DateProvider
private let db: DB
private let logger: PrefixedLogger
private let kvStore: KeyValueStore
private let serialTaskQueue: ConcurrentTaskQueue
private let tsAccountManager: TSAccountManager
init(
backupPlanManager: BackupPlanManager,
backupSubscriptionIssueStore: BackupSubscriptionIssueStore,
backupSubscriptionManager: BackupSubscriptionManager,
dateProvider: @escaping DateProvider,
db: DB,
networkManager: NetworkManager,
tsAccountManager: TSAccountManager,
) {
self.logger = PrefixedLogger(prefix: "[Backups]")
self.appAttestManager = AppAttestManager(
attestationService: .shared,
db: db,
logger: logger,
networkManager: networkManager
)
self.backupPlanManager = backupPlanManager
self.backupSubscriptionIssueStore = backupSubscriptionIssueStore
self.backupSubscriptionManager = backupSubscriptionManager
self.dateProvider = dateProvider
self.db = db
self.kvStore = KeyValueStore(collection: "BackupTestFlightEntitlementManager")
self.serialTaskQueue = ConcurrentTaskQueue(concurrentLimit: 1)
self.tsAccountManager = tsAccountManager
}
// MARK: -
func acquireEntitlement() async throws {
try await serialTaskQueue.run {
try await _acquireEntitlement()
}
}
private func _acquireEntitlement() async throws {
owsPrecondition(BuildFlags.Backups.avoidStoreKitForTesters)
guard TSConstants.isUsingProductionService else {
// If we're on Staging, no need to do anything all accounts on
// Staging get the entitlement automatically.
logger.info("Skipping acquiring Backup entitlement: on Staging!")
return
}
guard !BuildFlags.Backups.avoidAppAttestForDevs else {
// If we're on a dev build, we can't use AppAttest. If you're a dev
// who needs the entitlement (i.e., paid-tier Backup auth
// credentials), make sure you've gotten it for your account via
// another path.
logger.warn("WARNING! Skipping acquiring Backup entitlement: AppAttest not supported. Make sure your account has the entitlement via other means, if necessary.")
return
}
try await appAttestManager.performAttestationAction(.acquireBackupEntitlement)
logger.info("Successfully acquired Backup entitlement!")
}
// MARK: -
func setRenewEntitlementIsNecessary(tx: DBWriteTransaction) {
kvStore.removeValue(forKey: StoreKeys.lastEntitlementRenewalDate, transaction: tx)
}
func renewEntitlementIfNecessary() async throws {
let (
isRegisteredPrimaryDevice,
isCurrentlyTesterBuild,
currentBackupPlan,
lastEntitlementRenewalDate,
): (
Bool,
Bool,
BackupPlan,
Date?
) = db.read { tx in
(
tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice,
BuildFlags.Backups.avoidStoreKitForTesters,
backupPlanManager.backupPlan(tx: tx),
kvStore.getDate(StoreKeys.lastEntitlementRenewalDate, transaction: tx)
)
}
guard isRegisteredPrimaryDevice else {
return
}
let optimizeLocalStorage: Bool
switch currentBackupPlan {
case .disabled, .disabling, .free, .paid, .paidExpiringSoon:
// If we're not a paid-tier tester, nothing to do.
return
case .paidAsTester(let _optimizeLocalStorage):
optimizeLocalStorage = _optimizeLocalStorage
}
guard isCurrentlyTesterBuild else {
try await downgradeForNoLongerTestFlight(
hadOptimizeLocalStorage: optimizeLocalStorage
)
return
}
if
let lastEntitlementRenewalDate,
lastEntitlementRenewalDate.addingTimeInterval(3 * .day) > dateProvider()
{
return
}
try await acquireEntitlement()
await db.awaitableWrite { tx in
kvStore.setDate(
dateProvider(),
key: StoreKeys.lastEntitlementRenewalDate,
transaction: tx
)
}
}
private func downgradeForNoLongerTestFlight(
hadOptimizeLocalStorage: Bool,
) async throws {
let iapSubscription = try await backupSubscriptionManager.fetchAndMaybeDowngradeSubscription()
// We think we're a paid-tier tester, but our current build isn't a
// tester build! We likely went from TestFlight -> App Store builds.
//
// It's plausible, though, that we are still a paid-tier user by
// virtue of having an IAP subscription either from before we were
// on TestFlight, or from having moved to iOS from an Android on
// which we had an IAP subscription.
//
// To that end, check if we have an active IAP subscription. If so,
// set to `.paid`. If not, set to `.free`.
let newBackupPlan: BackupPlan
let shouldWarnDowngraded: Bool
if let iapSubscription, iapSubscription.active {
newBackupPlan = .paid(optimizeLocalStorage: hadOptimizeLocalStorage)
shouldWarnDowngraded = false
} else {
newBackupPlan = .free
shouldWarnDowngraded = true
}
try await db.awaitableWriteWithRollbackIfThrows { tx in
try backupPlanManager.setBackupPlan(newBackupPlan, tx: tx)
if shouldWarnDowngraded {
backupSubscriptionIssueStore.setShouldWarnTestFlightSubscriptionExpired(true, tx: tx)
}
}
}
}
// MARK: -
/// Responsible for using the `AppAttest` feature of Apple's `DeviceCheck`
/// framework to perform actions that are restricted to first-party instances of
/// Signal iOS.
///
/// For example, `StoreKit` on TestFlight builds is restricted to the Sandbox
/// environment, which makes it impossible for TestFlight users to pay for and
/// redeem a Backups subscription. As a workaround, we use the `AppAttest`
/// to make a request that gets us a paid-tier Backups entitlement without
/// requiring a paid `StoreKit` subscription.
///
/// However, to prevent abuse we need to restrict these requests to first-party
/// TestFlight builds. We do this by gating the request with `AppAttest`, and
/// then limit the requests to TestFlight-flavored builds using a BuildFlag.
private struct AppAttestManager {
/// Actions that require `DeviceCheck` attestation.
enum AttestationAction: String {
/// Add the "backup" entitlement to our account, as if we had redeemed a
/// Backups subscription.
case acquireBackupEntitlement = "backup"
}
enum AttestationError: Error {
/// Attestation is not supported on this device or app instance.
case notSupported
case networkError
case genericError
/// iOS failed to generate an assertion using a previously-attested key.
///
/// Believed to be an iOS issue, indicating that the previously-attested
/// key should be discarded.
case failedToGenerateAssertionWithPreviouslyAttestedKey
}
/// Represents a key, stored on this device in the Secure Enclave, which
/// has been both attested by Apple and verified by Signal servers.
///
/// Attestation by Apple requires a first-party instance of the app. Once
/// attested/verified, a key can be used to generate "assertions" for
/// requests, thereby proving the request originated from a first-party
/// instance of the app.
private struct AttestedKey {
let identifier: String
}
/// Represents a network request for which we've generated an assertion.
private struct RequestAssertion {
let requestData: Data
let assertion: Data
}
// MARK: -
private let attestationService: DCAppAttestService
private let db: DB
private let kvStore: KeyValueStore
private let logger: PrefixedLogger
private let networkManager: NetworkManager
init(
attestationService: DCAppAttestService,
db: DB,
logger: PrefixedLogger,
networkManager: NetworkManager
) {
self.attestationService = attestationService
self.db = db
self.kvStore = KeyValueStore(collection: "AppAttestationManager")
self.logger = logger
self.networkManager = networkManager
}
private func parseDCError(_ dcError: DCError) -> AttestationError {
switch dcError.code {
case .featureUnsupported:
return .notSupported
case .serverUnavailable:
return .networkError
case .unknownSystemFailure, .invalidInput, .invalidKey:
fallthrough
@unknown default:
owsFailDebug("Unexpected DCError code: \(dcError.code)", logger: logger)
return .genericError
}
}
// MARK: -
/// Perform the given attestation action.
///
/// This involves generating and attesting a key that is registered with
/// Signal servers, then using that key to generate an assertion for a
/// request to Signal servers to perform the given action. That assertion
/// is sent alongside the request to Signal servers, who upon validating the
/// assertion will perform the action.
func performAttestationAction(
_ action: AttestationAction,
) async throws(AttestationError) {
guard attestationService.isSupported else {
throw .notSupported
}
logger.info("Getting attested key.")
let attestedKey = try await getOrGenerateAttestedKey()
logger.info("Generating assertion.")
do {
let requestAssertion = try await generateAssertionForAction(
action,
attestedKey: attestedKey
)
logger.info("Performing attestation action with assertion.")
try await _performAttestationAction(
keyId: attestedKey.identifier,
requestAssertion: requestAssertion
)
} catch .failedToGenerateAssertionWithPreviouslyAttestedKey {
// If we failed to generate an assertion with a previously-attested
// key, throw that key away and try again.
logger.warn("Failed to generate assertion with previously-attested key. Wiping key and starting over.")
await wipeAttestedKeyId()
try await performAttestationAction(action)
}
}
private func _performAttestationAction(
keyId: String,
requestAssertion: RequestAssertion,
) async throws(AttestationError) {
guard let keyIdData = Data(base64Encoded: keyId) else {
owsFailDebug("Failed to convert keyId to data performing attestation action!")
throw .genericError
}
let response: HTTPResponse
do {
response = try await networkManager.asyncRequest(.performAttestationAction(
keyIdData: keyIdData,
assertedRequestData: requestAssertion.requestData,
assertion: requestAssertion.assertion
))
} catch where error.isNetworkFailureOrTimeout {
throw .networkError
} catch {
owsFailDebug("Unexpected error performing attestation action! \(error)", logger: logger)
throw .genericError
}
switch response.responseStatusCode {
case 204:
break
default:
owsFailDebug("Unexpected status code performing attestation action! \(response.responseStatusCode)", logger: logger)
throw .genericError
}
}
// MARK: - Attestation
/// Returns an identifier for a attested key. Generates and attests a new
/// key if necessary, or returns an existing key if attestation was
/// performed in the past.
private func getOrGenerateAttestedKey() async throws(AttestationError) -> AttestedKey {
if let attestedKeyId = await readAttestedKeyId() {
logger.info("Using previously-attested key.")
return AttestedKey(identifier: attestedKeyId)
}
// Generate a new key that we'll then attempt to attest and register
// with the Signal service.
let newKeyId: String
do {
newKeyId = try await attestationService.generateKey()
} catch let dcError as DCError {
throw parseDCError(dcError)
} catch {
owsFailDebug("Unexpected error generating key! \(error)", logger: logger)
throw .genericError
}
logger.info("Attesting and registering new key.")
return try await attestAndRegisterKey(newKeyId: newKeyId)
}
/// Perform attestation on a newly-generated key, and register it with
/// Signal servers.
///
/// This involves requesting a challenge from Signal, having Apple sign that
/// challenge using our new key, and finally having Signal validate that
/// signature and thereafter saving our new key.
///
/// Once a key has been attested and registered, it can be used to perform
/// assertions on future requests.
private func attestAndRegisterKey(newKeyId: String) async throws(AttestationError) -> AttestedKey {
// Get a challenge from Signal servers.
let keyAttestationChallenge: String = try await getKeyAttestationChallenge()
guard
let keyAttestationChallengeHash = keyAttestationChallenge
.data(using: .utf8)
.map({ Data(SHA256.hash(data: $0)) })
else {
owsFailDebug("Failed to hash challenge string!", logger: logger)
throw .genericError
}
// Sign the challenge-known-to-Signal-servers using our new key (aka,
// generate an attestation for this key).
let keyAttestation: Data
do {
keyAttestation = try await attestationService.attestKey(
newKeyId,
clientDataHash: keyAttestationChallengeHash
)
} catch let dcError as DCError {
throw parseDCError(dcError)
} catch {
owsFailDebug("Unexpected error attesting key with Apple! \(error)", logger: logger)
throw .genericError
}
// Give the signed challenge to Signal servers, who will validate that
// the signature/attestation (and therefore the key) is valid. If this
// succeeds, the Signal servers will record this key so we can use it
// to generate assertions for future requests.
try await _attestAndRegisterKey(
keyId: newKeyId,
keyAttestation: keyAttestation
)
// Hurray! The key is valid, and reigstered with Signal servers. We can
// now save it, so we can use it to sign future requests.
await saveAttestedKeyId(newKeyId)
return AttestedKey(identifier: newKeyId)
}
/// Get a challenge from Signal servers that we can use to attest that a new
/// key is valid.
private func getKeyAttestationChallenge() async throws(AttestationError) -> String {
let response: HTTPResponse
do {
response = try await networkManager.asyncRequest(.getAttestationChallenge())
} catch where error.isNetworkFailureOrTimeout {
throw .networkError
} catch {
owsFailDebug("Unexpected error fetching attestation challenge! \(error)", logger: logger)
throw .genericError
}
switch response.responseStatusCode {
case 200:
break
default:
owsFailDebug("Unexpected status code fetching attestation challenge! \(response.responseStatusCode)", logger: logger)
throw .genericError
}
guard let responseBodyData = response.responseBodyData else {
owsFailDebug("Missing response body data fetching attestation challenge!", logger: logger)
throw .genericError
}
struct AttestationChallengeResponseBody: Decodable {
let challenge: String
}
let responseBody: AttestationChallengeResponseBody
do {
responseBody = try JSONDecoder().decode(
AttestationChallengeResponseBody.self,
from: responseBodyData
)
} catch {
owsFailDebug("Failed to decode response body fetching attestation challenge! \(error)", logger: logger)
throw .genericError
}
return responseBody.challenge
}
/// Validate an attestation, or challenge signed by a new key, with Signal
/// servers. If this succeeds, Signal servers will record this key so it can
/// be used to generate assertions for future requests.
private func _attestAndRegisterKey(
keyId: String,
keyAttestation: Data,
) async throws(AttestationError) {
guard let keyIdData = Data(base64Encoded: keyId) else {
owsFailDebug("Failed to base64-decode keyId validating key attestation!")
throw .genericError
}
let response: HTTPResponse
do {
response = try await networkManager.asyncRequest(.attestAndRegisterKey(
keyIdData: keyIdData,
keyAttestation: keyAttestation
))
} catch where error.isNetworkFailureOrTimeout {
throw .networkError
} catch {
owsFailDebug("Unexpected error validating key attestation! \(error)", logger: logger)
throw .genericError
}
switch response.responseStatusCode {
case 204:
break
default:
owsFailDebug("Unexpected status code validating key attestation! \(response.responseStatusCode)", logger: logger)
throw .genericError
}
}
// MARK: - Assertions
/// Generate an assertion to perform the given action.
///
/// This involves requesting a challenge from Signal, merging the challenge
/// with the action into a request body, and using a previously-attested key
/// to generate an assertion for the request.
private func generateAssertionForAction(
_ action: AttestationAction,
attestedKey: AttestedKey,
) async throws(AttestationError) -> RequestAssertion {
struct AssertableAttestationAction: Encodable {
let action: String
let challenge: String
}
let assertableAction = AssertableAttestationAction(
action: action.rawValue,
challenge: try await getRequestAssertionChallenge(action: action)
)
let requestData: Data
do {
requestData = try JSONEncoder().encode(assertableAction)
} catch {
owsFailDebug("Failed to encode request parameters for assertion! \(error)", logger: logger)
throw .genericError
}
let assertion: Data
do {
assertion = try await attestationService.generateAssertion(
attestedKey.identifier,
clientDataHash: Data(SHA256.hash(data: requestData))
)
} catch let dcError as DCError {
switch dcError.code {
case .invalidInput, .invalidKey:
/// There appears to be an issue with AppAttest that can cause
/// the `.generateAssertion` API to throw `.invalidInput` when
/// using a previously-attested key, some significant percentage
/// of the time. Doesn't seem to be a clear pattern, and is
/// widely reported:
///
/// - https://github.com/firebase/firebase-ios-sdk/issues/12629
/// - https://developer.apple.com/forums/thread/788405
///
/// If nothing else, we know now that AppAttest considers this
/// key invalid, so we should discard it and start over.
///
/// For good measure, handle `.invalidKey` too.
throw .failedToGenerateAssertionWithPreviouslyAttestedKey
default:
throw parseDCError(dcError)
}
} catch {
owsFailDebug("Unexpected error generating assertion! \(error)", logger: logger)
throw .genericError
}
return RequestAssertion(
requestData: requestData,
assertion: assertion
)
}
/// Request a challenge from Signal servers to generate an assertion to
/// perform the given action.
private func getRequestAssertionChallenge(
action: AttestationAction,
) async throws(AttestationError) -> String {
let response: HTTPResponse
do {
response = try await networkManager.asyncRequest(.getAssertionChallenge(
action: action
))
} catch where error.isNetworkFailureOrTimeout {
throw .networkError
} catch {
owsFailDebug("Unexpected error fetching assertion challenge! \(error)", logger: logger)
throw .genericError
}
switch response.responseStatusCode {
case 200:
break
default:
owsFailDebug("Unexpected status code fetching assertion challenge! \(response.responseStatusCode)", logger: logger)
throw .genericError
}
guard let responseBodyData = response.responseBodyData else {
owsFailDebug("Missing response body data fetching assertion challenge!", logger: logger)
throw .genericError
}
struct AssertionChallengeResponseBody: Decodable {
let challenge: String
}
let responseBody: AssertionChallengeResponseBody
do {
responseBody = try JSONDecoder().decode(
AssertionChallengeResponseBody.self,
from: responseBodyData
)
} catch {
owsFailDebug("Failed to decode response body fetching assertion challenge! \(error)", logger: logger)
throw .genericError
}
return responseBody.challenge
}
// MARK: - Persistence
private enum StoreKeys {
static let keyId = "keyId"
}
/// Returns the identifier of a key for this device that has previously
/// passed attestation, if one exists.
private func readAttestedKeyId() async -> String? {
return db.read { tx in
return kvStore.getString(StoreKeys.keyId, transaction: tx)
}
}
/// Save the given key id, which represents a key for this device
/// that has passed attestation.
private func saveAttestedKeyId(_ keyIdentifier: String) async {
await db.awaitableWrite { tx in
kvStore.setString(keyIdentifier, key: StoreKeys.keyId, transaction: tx)
}
}
private func wipeAttestedKeyId() async {
await db.awaitableWrite { tx in
kvStore.removeValue(forKey: StoreKeys.keyId, transaction: tx)
}
}
}
// MARK: -
private extension TSRequest {
static func getAssertionChallenge(
action: AppAttestManager.AttestationAction,
) -> TSRequest {
return TSRequest(
url: URL(string: "v1/devicecheck/assert?action=\(action.rawValue)")!,
method: "GET",
)
}
static func performAttestationAction(
keyIdData: Data,
assertedRequestData: Data,
assertion: Data,
) -> TSRequest {
let urlPath = "v1/devicecheck/assert"
var request = TSRequest(
url: URL(string: "\(urlPath)?keyId=\(keyIdData.asBase64Url)&request=\(assertedRequestData.asBase64Url)")!,
method: "POST",
body: .data(assertion)
)
request.applyRedactionStrategy(.redactURL(replacement: "\(urlPath)?[REDACTED]"))
request.headers["Content-Type"] = "application/octet-stream"
return request
}
static func getAttestationChallenge() -> TSRequest {
return TSRequest(
url: URL(string: "v1/devicecheck/attest")!,
method: "GET",
parameters: nil
)
}
static func attestAndRegisterKey(
keyIdData: Data,
keyAttestation: Data,
) -> TSRequest {
let urlPath = "v1/devicecheck/attest"
var request = TSRequest(
url: URL(string: "\(urlPath)?keyId=\(keyIdData.asBase64Url)")!,
method: "PUT",
body: .data(keyAttestation)
)
request.applyRedactionStrategy(.redactURL(replacement: "\(urlPath)?[REDACTED]"))
request.headers["Content-Type"] = "application/octet-stream"
return request
}
}