Verifies downloaded app’s permissions match source

Renames source JSON permissions key to “appPermissions” in order to preserve backwards compatibility, since we’ve changed the schema for permissions.
This commit is contained in:
Riley Testut
2023-05-12 18:26:24 -05:00
committed by Magesh K
parent f884d72a8b
commit ee410605e8
16 changed files with 452 additions and 127 deletions

View File

@@ -23,6 +23,8 @@ extension VerificationError
case mismatchedHash = 3
case mismatchedVersion = 4
case undeclaredPermissions = 6
}
static func mismatchedBundleIdentifiers(sourceBundleID: String, app: ALTApplication) -> VerificationError {
@@ -40,6 +42,10 @@ extension VerificationError
static func mismatchedVersion(_ version: String, expectedVersion: String, app: AppProtocol) -> VerificationError {
VerificationError(code: .mismatchedVersion, app: app, version: version, expectedVersion: expectedVersion)
}
static func undeclaredPermissions(_ permissions: [any ALTAppPermission], app: AppProtocol) -> VerificationError {
VerificationError(code: .undeclaredPermissions, app: app, permissions: permissions)
}
}
struct VerificationError: ALTLocalizedError
@@ -60,6 +66,9 @@ struct VerificationError: ALTLocalizedError
@UserInfoValue var version: String?
@UserInfoValue var expectedVersion: String?
@UserInfoValue
var permissions: [any ALTAppPermission]?
var errorDescription: String? {
//TODO: Make this automatic somehow with ALTLocalizedError
guard self.errorFailure == nil else { return nil }
@@ -129,6 +138,52 @@ struct VerificationError: ALTLocalizedError
case .mismatchedVersion:
let appName = self.$app.name ?? NSLocalizedString("the app", comment: "")
return String(format: NSLocalizedString("The downloaded version of %@ does not match the version specified by the source.", comment: ""), appName)
case .undeclaredPermissions:
let appName = self.$app.name ?? NSLocalizedString("The app", comment: "")
return String(format: NSLocalizedString("%@ requires additional permissions not specified by the source.", comment: ""), appName)
}
}
var recoverySuggestion: String? {
switch self.code
{
case .undeclaredPermissions:
guard let permissions, !permissions.isEmpty else { return nil }
let baseMessage = NSLocalizedString("These permissions must be declared by the source in order for AltStore to install this app:", comment: "")
let permissionsByType = Dictionary(grouping: permissions) { $0.type }
let permissionSections = [ALTAppPermissionType.entitlement, .privacy, .backgroundMode].compactMap { (type) -> String? in
guard let permissions = permissionsByType[type] else { return nil }
// "Privacy:"
var sectionText = "\(type.localizedName ?? type.rawValue):\n"
// Sort permissions + join into single string.
let sortedList = permissions.map { permission -> String in
if let localizedName = permission.localizedName
{
// "Entitlement Name (com.apple.entitlement.name)"
return "\(localizedName) (\(permission.rawValue))"
}
else
{
// "com.apple.entitlement.name"
return permission.rawValue
}
}
.sorted { $0.localizedStandardCompare($1) == .orderedAscending } // Case-insensitive sorting
.joined(separator: "\n")
sectionText += sortedList
return sectionText
}
let recoverySuggestion = ([baseMessage] + permissionSections).joined(separator: "\n\n")
return recoverySuggestion
default: return nil
}
}
}

View File

@@ -94,6 +94,16 @@ struct VerificationError: ALTLocalizedError {
}
}
import RegexBuilder
private extension ALTEntitlement
{
static var ignoredEntitlements: Set<ALTEntitlement> = [
.applicationIdentifier,
.teamIdentifier
]
}
@objc(VerifyAppOperation)
final class VerifyAppOperation: ResultOperation<Void>
{
@@ -146,6 +156,11 @@ final class VerifyAppOperation: ResultOperation<Void>
try await self.verifyHash(of: app, at: ipaURL, matches: appVersion)
try await self.verifyDownloadedVersion(of: app, matches: appVersion)
if let storeApp = await self.context.$appVersion.app
{
try await self.verifyPermissions(of: app, match: storeApp)
}
self.finish(.success(()))
}
catch
@@ -183,4 +198,81 @@ private extension VerifyAppOperation
guard version == app.version else { throw VerificationError.mismatchedVersion(app.version, expectedVersion: version, app: app) }
}
@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) }
// Background Modes
// App extensions can't have background modes, so don't need to worry about them.
let allBackgroundModes: Set<ALTAppBackgroundMode>
if let backgroundModes = app.bundle.infoDictionary?[Bundle.Info.backgroundModes] as? [String]
{
let backgroundModes = backgroundModes.lazy.map { ALTAppBackgroundMode($0) }
allBackgroundModes = Set(backgroundModes)
}
else
{
allBackgroundModes = []
}
// Privacy
let allPrivacyPermissions: Set<ALTAppPrivacyPermission>
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<AnyHashable> = Set(await $storeApp.perform { $0.permissions.map { AnyHashable($0.permission) } })
let localPermissions: [any ALTAppPermission] = Array(allEntitlements) + Array(allBackgroundModes) + 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
}
}