2020-05-02 22:06:57 -07:00
//
// V e r i f y A p p O p e r a t i o n . s w i f t
// A l t S t o r e
//
// C r e a t e d b y R i l e y T e s t u t o n 5 / 2 / 2 0 .
// C o p y r i g h t © 2 0 2 0 R i l e y T e s t u t . A l l r i g h t s r e s e r v e d .
//
import Foundation
2023-05-11 17:02:20 -05:00
import CryptoKit
2020-05-02 22:06:57 -07:00
2024-08-06 10:43:52 +09:00
import AltStoreCore
2020-05-02 22:06:57 -07:00
import AltSign
import Roxas
2024-08-06 10:43:52 +09:00
extension VerificationError
2020-05-02 22:06:57 -07:00
{
2024-08-06 10:43:52 +09:00
enum Code : Int , ALTErrorCode , CaseIterable {
typealias Error = VerificationError
case privateEntitlements
case mismatchedBundleIdentifiers
case iOSVersionNotSupported
2020-05-02 22:06:57 -07:00
}
2024-08-06 10:43:52 +09:00
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 )
2020-05-02 22:06:57 -07:00
}
2024-08-06 10:43:52 +09:00
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 ?
2020-05-02 22:06:57 -07:00
2024-08-06 10:43:52 +09:00
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
}
2022-12-12 15:34:09 -06:00
return self . errorFailureReason
2024-08-06 10:43:52 +09:00
}
var errorFailureReason : String {
switch self . code
2020-05-02 22:06:57 -07:00
{
2024-08-06 10:43:52 +09:00
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 )
2021-02-26 13:46:49 -06:00
}
2020-05-02 22:06:57 -07:00
}
}
}
2023-05-12 18:26:24 -05:00
import RegexBuilder
private extension ALTEntitlement
{
static var ignoredEntitlements : Set < ALTEntitlement > = [
. applicationIdentifier ,
. teamIdentifier
]
}
2023-05-15 15:13:13 -05:00
extension VerifyAppOperation
{
enum PermissionReviewMode
{
case none
case all
case added
}
}
2020-05-02 22:06:57 -07:00
@objc ( VerifyAppOperation )
2023-01-04 09:52:12 -05:00
final class VerifyAppOperation : ResultOperation < Void >
2020-05-02 22:06:57 -07:00
{
2023-05-15 15:13:13 -05:00
let permissionsMode : PermissionReviewMode
2023-05-11 17:02:20 -05:00
let context : InstallAppOperationContext
2023-05-15 15:13:13 -05:00
init ( permissionsMode : PermissionReviewMode , context : InstallAppOperationContext )
2020-05-02 22:06:57 -07:00
{
2023-05-15 15:13:13 -05:00
self . permissionsMode = permissionsMode
2020-05-02 22:06:57 -07:00
self . context = context
super . init ( )
}
override func main ( )
{
super . main ( )
do
{
if let error = self . context . error
{
throw error
}
2024-11-10 02:54:18 +05:30
let appName = self . context . app ? . name ? ? NSLocalizedString ( " The app " , comment : " " )
self . localizedFailure = String ( format : NSLocalizedString ( " %@ could not be installed. " , comment : " " ) , appName )
2020-05-02 22:06:57 -07:00
2024-11-09 14:35:18 +05:30
guard let app = self . context . app else {
throw OperationError . invalidParameters ( " VerifyAppOperation.main: self.context.app is nil " )
}
2020-05-07 13:13:05 -07:00
2024-08-06 10:43:52 +09:00
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 )
}
2020-05-07 13:13:05 -07:00
}
2021-02-26 13:46:49 -06:00
guard ProcessInfo . processInfo . isOperatingSystemAtLeast ( app . minimumiOSVersion ) else {
2024-08-06 10:43:52 +09:00
throw VerificationError . iOSVersionNotSupported ( app : app , requiredOSVersion : app . minimumiOSVersion )
2021-02-26 13:46:49 -06:00
}
2023-05-11 17:02:20 -05:00
guard let appVersion = self . context . appVersion else {
return self . finish ( . success ( ( ) ) )
}
Task < Void , Never > {
do
{
2023-12-07 17:15:08 -06:00
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 )
try await self . verifyPermissions ( of : app , match : appVersion )
2023-05-12 18:26:24 -05:00
2023-05-11 17:02:20 -05:00
self . finish ( . success ( ( ) ) )
}
catch
{
self . finish ( . failure ( error ) )
}
}
2020-05-02 22:06:57 -07:00
}
catch
{
self . finish ( . failure ( error ) )
}
}
}
2023-05-11 17:02:20 -05:00
private extension VerifyAppOperation
{
func verifyHash ( of app : ALTApplication , at ipaURL : URL , @ AsyncManaged matches appVersion : AppVersion ) async throws
{
// D o n o t h i n g i f s o u r c e d o e s n ' t p r o v i d e h a s h .
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 ( )
2023-10-18 14:06:10 -05:00
Logger . sideload . debug ( " Comparing app hash ( \( hashString , privacy : . public ) ) against expected hash ( \( expectedHash , privacy : . public ) )... " )
2023-05-11 17:02:20 -05:00
guard hashString = = expectedHash else { throw VerificationError . mismatchedHash ( hashString , expectedHash : expectedHash , app : app ) }
}
2023-05-11 17:47:03 -05:00
func verifyDownloadedVersion ( of app : ALTApplication , @ AsyncManaged matches appVersion : AppVersion ) async throws
{
2023-05-18 15:55:26 -05:00
let ( version , buildVersion ) = await $ appVersion . perform { ( $0 . version , $0 . buildVersion ) }
2023-05-11 17:47:03 -05:00
guard version = = app . version else { throw VerificationError . mismatchedVersion ( app . version , expectedVersion : version , app : app ) }
2023-05-18 15:55:26 -05:00
if let buildVersion
{
guard buildVersion = = app . buildVersion else { throw VerificationError . mismatchedBuildVersion ( app . buildVersion , expectedVersion : buildVersion , app : app ) }
}
2023-05-11 17:47:03 -05:00
}
2023-05-12 18:26:24 -05:00
2023-05-15 15:13:13 -05:00
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 }
// V e r i f y s o u r c e p e r m i s s i o n s m a t c h f i r s t .
2023-12-07 17:15:08 -06:00
let allPermissions = try await self . verifyPermissions ( of : app , match : storeApp )
guard #available ( iOS 15 , * ) else {
// O n l y r e v i e w d o w n l o a d e d a p p p e r m i s s i o n s o n i O S 1 5 a n d a b o v e .
return
}
2023-05-15 15:13:13 -05:00
2023-12-07 17:15:08 -06:00
switch self . permissionsMode
{
case . none : break
case . all :
guard let presentingViewController = self . context . presentingViewController else { break } // D o n ' t f a i l j u s t b e c a u s e w e c a n ' t s h o w p e r m i s s i o n s .
let allEntitlements = allPermissions . compactMap { $0 as ? ALTEntitlement }
if ! allEntitlements . isEmpty
{
try await self . review ( allEntitlements , for : app , mode : . all , presentingViewController : presentingViewController )
}
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 )
}
// M a k e s u r e a l l e n t i t l e m e n t s a l r e a d y e x i s t i n p r e v i o u s A p p .
let addedEntitlements = Array ( allPermissions . lazy . compactMap { $0 as ? ALTEntitlement } . filter { ! previousEntitlements . contains ( $0 ) } )
if ! addedEntitlements . isEmpty
{
// _ D O _ t h r o w e r r o r i f t h e r e i s n ' t a p r e s e n t i n g V i e w C o n t r o l l e r .
guard let presentingViewController = self . context . presentingViewController else { throw VerificationError . addedPermissions ( addedEntitlements , appVersion : appVersion ) }
try await self . review ( addedEntitlements , for : app , mode : . added , presentingViewController : presentingViewController )
}
}
2023-05-15 15:13:13 -05:00
}
2023-05-12 18:26:24 -05:00
@ discardableResult
func verifyPermissions ( of app : ALTApplication , @ AsyncManaged match storeApp : StoreApp ) async throws -> [ any ALTAppPermission ]
{
// E n t i t l e m e n t s
var allEntitlements = Set ( app . entitlements . keys )
for appExtension in app . appExtensions
{
allEntitlements . formUnion ( appExtension . entitlements . keys )
}
// F i l t e r o u t i g n o r e d e n t i t l e m e n t s .
allEntitlements = allEntitlements . filter { ! ALTEntitlement . ignoredEntitlements . contains ( $0 ) }
// P r i v a c y
2023-10-10 14:47:00 -05:00
let allPrivacyPermissions = ( [ app ] + app . appExtensions ) . flatMap { ( app ) in
let permissions = app . bundle . infoDictionary ? . keys . compactMap { key -> ALTAppPrivacyPermission ? in
if #available ( iOS 16 , * )
{
guard key . wholeMatch ( of : Regex . privacyPermission ) != nil else { return nil }
}
else
{
guard key . contains ( " UsageDescription " ) else { return nil }
2023-05-12 18:26:24 -05:00
}
2023-10-10 14:47:00 -05:00
let permission = ALTAppPrivacyPermission ( rawValue : key )
return permission
} ? ? [ ]
2023-05-12 18:26:24 -05:00
2023-10-10 14:47:00 -05:00
return permissions
2023-05-12 18:26:24 -05:00
}
// V e r i f y p e r m i s s i o n s .
let sourcePermissions : Set < AnyHashable > = Set ( await $ storeApp . perform { $0 . permissions . map { AnyHashable ( $0 . permission ) } } )
2023-05-26 14:58:52 -05:00
let localPermissions : [ any ALTAppPermission ] = Array ( allEntitlements ) + Array ( allPrivacyPermissions )
2023-05-12 18:26:24 -05:00
// T o p a s s : E V E R Y p e r m i s s i o n i n l o c a l P e r m i s s i o n s m u s t a l s o a p p e a r i n s o u r c e P e r m i s s i o n s .
// I f t h e r e i s a s i n g l e m i s s i n g p e r m i s s i o n , t h r o w e r r o r .
2023-10-10 14:47:00 -05:00
let missingPermissions : [ any ALTAppPermission ] = localPermissions . filter { permission in
if sourcePermissions . contains ( AnyHashable ( permission ) )
{
// ` p e r m i s s i o n ` e x i s t s i n s o u r c e , s o r e t u r n f a l s e .
return false
}
else if permission . type = = . privacy
{
guard #available ( iOS 16 , * ) else {
// A s s u m e a l l p r i v a c y p e r m i s s i o n s _ a r e _ i n c l u d e d i n s o u r c e o n p r e - i O S 1 6 d e v i c e s .
return false
}
// S p e c i a l - h a n d l i n g f o r l e g a c y p r i v a c y p e r m i s s i o n s .
if let match = permission . rawValue . firstMatch ( of : Regex . privacyPermission ) ,
case let legacyPermission = ALTAppPrivacyPermission ( rawValue : String ( match . 1 ) ) ,
sourcePermissions . contains ( AnyHashable ( legacyPermission ) )
{
// T h e l e g a c y n a m e o f t h i s p e r m i s s i o n e x i s t s i n t h e s o u r c e , s o r e t u r n f a l s e .
return false
}
}
// S o u r c e d o e s n ' t c o n t a i n p e r m i s s i o n o r i t s l e g a c y n a m e , s o a s s u m e i t i s m i s s i n g .
return true
}
2023-12-07 17:15:08 -06:00
do
{
guard missingPermissions . isEmpty else {
// T h e r e i s a t l e a s t o n e u n d e c l a r e d p e r m i s s i o n , s o t h r o w e r r o r .
throw VerificationError . undeclaredPermissions ( missingPermissions , app : app )
}
}
catch let error as VerificationError where error . code = = . undeclaredPermissions
{
#if ! BETA
throw error
#endif
if let recommendedSources = UserDefaults . shared . recommendedSources , let ( sourceID , sourceURL ) = await $ storeApp . perform ( { $0 . source . map { ( $0 . identifier , $0 . sourceURL ) } } )
{
let normalizedSourceURL = try ? sourceURL . normalized ( )
let isRecommended = recommendedSources . contains { $0 . identifier = = sourceID || ( try ? $0 . sourceURL ? . normalized ( ) ) = = normalizedSourceURL }
guard ! isRecommended else {
// D o n ' t e n f o r c e p e r m i s s i o n c h e c k i n g f o r R e c o m m e n d e d S o u r c e s w h i l e 2 . 0 i s i n b e t a .
return localPermissions
}
}
// W h i l e i n b e t a , a l l o w u s e r s t o t e m p o r a r i l y b y p a s s p e r m i s s i o n s a l e r t
// s o s o u r c e m a i n t a i n e r s h a v e t i m e t o u p d a t e t h e i r s o u r c e s .
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 } // b r e a k l o o p t o c o n t i n u e w i t h i n s t a l l a t i o n ( u n l e s s w e ' r e v i e w i n g p e r m i s s i o n s ) .
await presentingViewController . presentAlert ( title : NSLocalizedString ( " Undeclared Permissions " , comment : " " ) , message : error . recoverySuggestion )
}
2023-10-10 14:47:00 -05:00
}
2023-05-12 18:26:24 -05:00
return localPermissions
}
2023-12-07 17:15:08 -06:00
@ MainActor @ available ( iOS 15 , * )
func review ( _ permissions : [ ALTEntitlement ] , for app : AppProtocol , mode : PermissionReviewMode , presentingViewController : UIViewController ) async throws
{
let reviewPermissionsViewController = ReviewPermissionsViewController ( app : app , permissions : permissions , mode : mode )
let navigationController = UINavigationController ( rootViewController : reviewPermissionsViewController )
defer {
navigationController . dismiss ( animated : true )
}
try await withCheckedThrowingContinuation { continuation in
reviewPermissionsViewController . completionHandler = { result in
continuation . resume ( with : result )
}
presentingViewController . present ( navigationController , animated : true )
}
}
2023-05-11 17:02:20 -05:00
}