// // VerifyAppOperation.swift // AltStore // // Created by Riley Testut on 5/2/20. // Copyright © 2020 Riley Testut. All rights reserved. // import Foundation import CryptoKit import AltStoreCore import AltSign import Roxas extension VerificationError { enum Code: Int, ALTErrorCode, CaseIterable { typealias Error = VerificationError case privateEntitlements case mismatchedBundleIdentifiers case iOSVersionNotSupported } static func privateEntitlements(_ entitlements: [String: Any], app: ALTApplication) -> VerificationError { VerificationError(code: .privateEntitlements, app: app, entitlements: entitlements) } static func mismatchedBundleIdentifiers(sourceBundleID: String, app: ALTApplication) -> VerificationError { VerificationError(code: .mismatchedBundleIdentifiers, app: app, sourceBundleID: sourceBundleID) } static func iOSVersionNotSupported(app: AppProtocol, osVersion: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion, requiredOSVersion: OperatingSystemVersion?) -> VerificationError { VerificationError(code: .iOSVersionNotSupported, app: app) } } struct VerificationError: ALTLocalizedError { let code: Code var errorTitle: String? var errorFailure: String? @Managed var app: AppProtocol? var sourceBundleID: String? var deviceOSVersion: OperatingSystemVersion? var requiredOSVersion: OperatingSystemVersion? var errorDescription: String? { switch self.code { case .iOSVersionNotSupported: guard let deviceOSVersion else { return nil } var failureReason = self.errorFailureReason if self.app == nil { let firstLetter = failureReason.prefix(1).lowercased() failureReason = firstLetter + failureReason.dropFirst() } return String(formatted: "This device is running iOS %@, but %@", deviceOSVersion.stringValue, failureReason) default: return nil } return self.errorFailureReason } var errorFailureReason: String { switch self.code { case .privateEntitlements: let appName = self.$app.name ?? NSLocalizedString("The app", comment: "") return String(formatted: "“%@” requires private permissions.", appName) case .mismatchedBundleIdentifiers: if let appBundleID = self.$app.bundleIdentifier, let bundleID = self.sourceBundleID { return String(formatted: "The bundle ID '%@' does not match the one specified by the source ('%@').", appBundleID, bundleID) } else { return NSLocalizedString("The bundle ID does not match the one specified by the source.", comment: "") } case .iOSVersionNotSupported: let appName = self.$app.name ?? NSLocalizedString("The app", comment: "") let deviceOSVersion = self.deviceOSVersion ?? ProcessInfo.processInfo.operatingSystemVersion guard let requiredOSVersion else { return String(formatted: "%@ does not support iOS %@.", appName, deviceOSVersion.stringValue) } if deviceOSVersion > requiredOSVersion { return String(formatted: "%@ requires iOS %@ or earlier", appName, requiredOSVersion.stringValue) } else { return String(formatted: "%@ requires iOS %@ or later", appName, requiredOSVersion.stringValue) } } } } import RegexBuilder private extension ALTEntitlement { static var ignoredEntitlements: Set = [ .applicationIdentifier, .teamIdentifier ] } extension VerifyAppOperation { enum PermissionReviewMode { case none case all case added } } @objc(VerifyAppOperation) final class VerifyAppOperation: ResultOperation { let permissionsMode: PermissionReviewMode let context: InstallAppOperationContext init(permissionsMode: PermissionReviewMode, context: InstallAppOperationContext) { self.permissionsMode = permissionsMode self.context = context super.init() } override func main() { super.main() do { if let error = self.context.error { throw error } let appName = self.context.app?.name ?? NSLocalizedString("The app", comment: "") self.localizedFailure = String(format: NSLocalizedString("%@ could not be installed.", comment: ""), appName) guard let app = self.context.app else { throw OperationError.invalidParameters("VerifyAppOperation.main: self.context.app is nil") } if !["ny.litritt.ignited", "com.litritt.ignited"].contains(where: { $0 == app.bundleIdentifier }) { guard app.bundleIdentifier == self.context.bundleIdentifier else { throw VerificationError.mismatchedBundleIdentifiers(sourceBundleID: self.context.bundleIdentifier, app: app) } } guard ProcessInfo.processInfo.isOperatingSystemAtLeast(app.minimumiOSVersion) else { throw VerificationError.iOSVersionNotSupported(app: app, requiredOSVersion: app.minimumiOSVersion) } guard let appVersion = self.context.appVersion else { return self.finish(.success(())) } Task { do { do { guard let ipaURL = self.context.ipaURL else { throw OperationError.appNotFound(name: app.name) } try await self.verifyHash(of: app, at: ipaURL, matches: appVersion) try await self.verifyDownloadedVersion(of: app, matches: appVersion) // Verify permissions last in case user bypasses error. try await self.verifyPermissions(of: app, match: appVersion) } catch let error as VerificationError where error.code == .undeclaredPermissions { #if !BETA throw error #endif if let trustedSources = UserDefaults.shared.trustedSources, let sourceID = await self.context.$appVersion.sourceID { let isTrusted = trustedSources.contains { $0.identifier == sourceID } guard !isTrusted else { // Don't enforce permission checking for Trusted Sources while 2.0 is in beta. return self.finish(.success(())) } } // While in beta, allow users to temporarily bypass permissions alert // so source maintainers have time to update their sources. guard let presentingViewController = self.context.presentingViewController else { throw error } let message = NSLocalizedString("While AltStore 2.0 is in beta, you may choose to ignore this warning at your own risk until the source is updated.", comment: "") let ignoreAction = await UIAlertAction(title: NSLocalizedString("Install Anyway", comment: ""), style: .destructive) let viewPermissionsAction = await UIAlertAction(title: NSLocalizedString("View Permisions", comment: ""), style: .default) while true { let action = try await presentingViewController.presentConfirmationAlert(title: error.errorFailureReason, message: message, actions: [ignoreAction, viewPermissionsAction]) guard action == viewPermissionsAction else { break } // break loop to continue with installation (unless we're viewing permissions). await presentingViewController.presentAlert(title: NSLocalizedString("Undeclared Permissions", comment: ""), message: error.recoverySuggestion) } } self.finish(.success(())) } catch { self.finish(.failure(error)) } } } catch { self.finish(.failure(error)) } } } private extension VerifyAppOperation { func verifyHash(of app: ALTApplication, at ipaURL: URL, @AsyncManaged matches appVersion: AppVersion) async throws { // Do nothing if source doesn't provide hash. guard let expectedHash = await $appVersion.sha256 else { return } let data = try Data(contentsOf: ipaURL) let sha256Hash = SHA256.hash(data: data) let hashString = sha256Hash.compactMap { String(format: "%02x", $0) }.joined() Logger.sideload.debug("Comparing app hash (\(hashString, privacy: .public)) against expected hash (\(expectedHash, privacy: .public))...") guard hashString == expectedHash else { throw VerificationError.mismatchedHash(hashString, expectedHash: expectedHash, app: app) } } func verifyDownloadedVersion(of app: ALTApplication, @AsyncManaged matches appVersion: AppVersion) async throws { let (version, buildVersion) = await $appVersion.perform { ($0.version, $0.buildVersion) } guard version == app.version else { throw VerificationError.mismatchedVersion(app.version, expectedVersion: version, app: app) } if let buildVersion { guard buildVersion == app.buildVersion else { throw VerificationError.mismatchedBuildVersion(app.buildVersion, expectedVersion: buildVersion, app: app) } } } func verifyPermissions(of app: ALTApplication, @AsyncManaged match appVersion: AppVersion) async throws { guard self.permissionsMode != .none else { return } guard let storeApp = await $appVersion.app else { throw OperationError.invalidParameters } // Verify source permissions match first. _ = try await self.verifyPermissions(of: app, match: storeApp) // TODO: Uncomment to verify added permissions. // switch self.permissionsMode // { // case .none, .all: break // case .added: // let installedAppURL = InstalledApp.fileURL(for: app) // guard let previousApp = ALTApplication(fileURL: installedAppURL) else { throw OperationError.appNotFound(name: app.name) } // // var previousEntitlements = Set(previousApp.entitlements.keys) // for appExtension in previousApp.appExtensions // { // previousEntitlements.formUnion(appExtension.entitlements.keys) // } // // // Make sure all entitlements already exist in previousApp. // let addedEntitlements = Array(allPermissions.lazy.compactMap { $0 as? ALTEntitlement }.filter { !previousEntitlements.contains($0) }) // guard addedEntitlements.isEmpty else { throw VerificationError.addedPermissions(addedEntitlements, appVersion: appVersion) } // } } @discardableResult func verifyPermissions(of app: ALTApplication, @AsyncManaged match storeApp: StoreApp) async throws -> [any ALTAppPermission] { // Entitlements var allEntitlements = Set(app.entitlements.keys) for appExtension in app.appExtensions { allEntitlements.formUnion(appExtension.entitlements.keys) } // Filter out ignored entitlements. allEntitlements = allEntitlements.filter { !ALTEntitlement.ignoredEntitlements.contains($0) } // Privacy let allPrivacyPermissions: Set if #available(iOS 16, *) { let regex = Regex { "NS" // Capture permission "name" Capture { OneOrMore(.anyGraphemeCluster) } "UsageDescription" // Optional suffix Optionally(OneOrMore(.anyGraphemeCluster)) } let privacyPermissions = ([app] + app.appExtensions).flatMap { (app) in let permissions = app.bundle.infoDictionary?.keys.compactMap { key -> ALTAppPrivacyPermission? in guard let match = key.wholeMatch(of: regex) else { return nil } let permission = ALTAppPrivacyPermission(rawValue: String(match.1)) return permission } ?? [] return permissions } allPrivacyPermissions = Set(privacyPermissions) } else { allPrivacyPermissions = [] } // Verify permissions. let sourcePermissions: Set = Set(await $storeApp.perform { $0.permissions.map { AnyHashable($0.permission) } }) let localPermissions: [any ALTAppPermission] = Array(allEntitlements) + Array(allPrivacyPermissions) // To pass: EVERY permission in localPermissions must also appear in sourcePermissions. // If there is a single missing permission, throw error. let missingPermissions: [any ALTAppPermission] = localPermissions.filter { !sourcePermissions.contains(AnyHashable($0)) } guard missingPermissions.isEmpty else { throw VerificationError.undeclaredPermissions(missingPermissions, app: app) } return localPermissions } }