Verifies downloaded app’s SHA-256 checksum (if specified)

This commit is contained in:
Riley Testut
2023-05-11 17:02:20 -05:00
committed by Magesh K
parent 7ad8db7bdc
commit 7dfbba9b00
6 changed files with 93 additions and 14 deletions

View File

@@ -16,15 +16,16 @@ import AltSign
final class DownloadAppOperation: ResultOperation<ALTApplication>
{
let app: AppProtocol
let context: AppOperationContext
let context: InstallAppOperationContext
private let appName: String
private let bundleIdentifier: String
private let destinationURL: URL
private let session = URLSession(configuration: .default)
private let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
init(app: AppProtocol, destinationURL: URL, context: AppOperationContext)
init(app: AppProtocol, destinationURL: URL, context: InstallAppOperationContext)
{
self.app = app
self.context = context
@@ -51,8 +52,14 @@ final class DownloadAppOperation: ResultOperation<ALTApplication>
print("Downloading App:", self.bundleIdentifier)
self.localizedFailure = String(format: NSLocalizedString("%@ could not be downloaded.", comment: ""), self.appName)
guard let storeApp = self.app as? StoreApp else { return self.download(self.app) }
guard let storeApp = self.app as? StoreApp else {
// Only StoreApp allows falling back to previous versions.
// AppVersion can only install itself, and ALTApplication doesn't have previous versions.
return self.download(self.app)
}
// Verify storeApp
storeApp.managedObjectContext?.perform {
do {
let latestVersion = try self.verify(storeApp)
@@ -107,10 +114,19 @@ private extension DownloadAppOperation {
return version
}
func download(@Managed _ app: AppProtocol) {
guard let sourceURL = self.sourceURL else { return self.finish(.failure(OperationError.appNotFound(name: self.appName))) }
func download(@Managed _ app: AppProtocol)
{
guard let sourceURL = $app.url else { return self.finish(.failure(OperationError.appNotFound(name: self.appName))) }
if let appVersion = app as? AppVersion
{
// All downloads go through this path, and `app` is
// always an AppVersion if downloading from a source,
// so context.appVersion != nil means downloading from source.
self.context.appVersion = appVersion
}
self.downloadIPA(from: sourceURL) { result in
do
{
@@ -178,6 +194,12 @@ private extension DownloadAppOperation {
{
// File, so assuming this is a .ipa file.
appBundleURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: self.temporaryDirectory)
// Use context's temporaryDirectory to ensure .ipa isn't deleted before we're done installing.
let ipaURL = self.context.temporaryDirectory.appendingPathComponent("App.ipa")
try FileManager.default.copyItem(at: fileURL, to: ipaURL)
self.context.ipaURL = ipaURL
}
guard let application = ALTApplication(fileURL: appBundleURL) else { throw OperationError.invalidApp }

View File

@@ -109,6 +109,7 @@ class InstallAppOperationContext: AppOperationContext
return temporaryDirectory
}()
var ipaURL: URL?
var resignedApp: ALTApplication?
var installedApp: InstalledApp? {
didSet {
@@ -120,4 +121,8 @@ class InstallAppOperationContext: AppOperationContext
var beginInstallationHandler: ((InstalledApp) -> Void)?
var alternateIconURL: URL?
// Non-nil when installing from a source.
@AsyncManaged
var appVersion: AppVersion?
}

View File

@@ -20,6 +20,8 @@ extension VerificationError
case mismatchedBundleIdentifiers = 1
case iOSVersionNotSupported = 2
case mismatchedHash = 3
}
static func mismatchedBundleIdentifiers(sourceBundleID: String, app: ALTApplication) -> VerificationError {
@@ -29,6 +31,10 @@ extension VerificationError
static func iOSVersionNotSupported(app: AppProtocol, osVersion: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion, requiredOSVersion: OperatingSystemVersion?) -> VerificationError {
VerificationError(code: .iOSVersionNotSupported, app: app, deviceOSVersion: osVersion, requiredOSVersion: requiredOSVersion)
}
static func mismatchedHash(_ hash: String, expectedHash: String, app: AppProtocol) -> VerificationError {
VerificationError(code: .mismatchedHash, app: app, hash: hash, expectedHash: expectedHash)
}
}
struct VerificationError: ALTLocalizedError
@@ -43,6 +49,9 @@ struct VerificationError: ALTLocalizedError
var deviceOSVersion: OperatingSystemVersion?
var requiredOSVersion: OperatingSystemVersion?
@UserInfoValue var hash: String?
@UserInfoValue var expectedHash: String?
var errorDescription: String? {
//TODO: Make this automatic somehow with ALTLocalizedError
guard self.errorFailure == nil else { return nil }
@@ -104,6 +113,10 @@ struct VerificationError: ALTLocalizedError
let failureReason = String(format: NSLocalizedString("%@ requires iOS %@ or later.", comment: ""), appName, requiredOSVersion.stringValue)
return failureReason
}
case .mismatchedHash:
let appName = self.$app.name ?? NSLocalizedString("the downloaded app", comment: "")
return String(format: NSLocalizedString("The SHA-256 hash of %@ does not match the hash specified by the source.", comment: ""), appName)
}
}
}

View File

@@ -7,6 +7,7 @@
//
import Foundation
import CryptoKit
import AltStoreCore
import AltSign
@@ -96,10 +97,10 @@ struct VerificationError: ALTLocalizedError {
@objc(VerifyAppOperation)
final class VerifyAppOperation: ResultOperation<Void>
{
let context: AppOperationContext
let context: InstallAppOperationContext
var verificationHandler: ((VerificationError) -> Bool)?
init(context: AppOperationContext)
init(context: InstallAppOperationContext)
{
self.context = context
@@ -133,7 +134,23 @@ final class VerifyAppOperation: ResultOperation<Void>
throw VerificationError.iOSVersionNotSupported(app: app, requiredOSVersion: app.minimumiOSVersion)
}
self.finish(.success(()))
guard let appVersion = self.context.appVersion else {
return self.finish(.success(()))
}
Task<Void, Never> {
do
{
guard let ipaURL = self.context.ipaURL else { throw OperationError.appNotFound(name: app.name) }
try await self.verifyHash(of: app, at: ipaURL, matches: appVersion)
self.finish(.success(()))
}
catch
{
self.finish(.failure(error))
}
}
}
catch
{
@@ -141,3 +158,20 @@ final class VerifyAppOperation: ResultOperation<Void>
}
}
}
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()
print("[ALTLog] Comparing app hash (\(hashString)) against expected hash (\(expectedHash))...")
guard hashString == expectedHash else { throw VerificationError.mismatchedHash(hashString, expectedHash: expectedHash, app: app) }
}
}

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22D68" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D68" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="Account" syncable="YES">
<attribute name="appleID" attributeType="String"/>
<attribute name="firstName" attributeType="String"/>
@@ -38,6 +38,7 @@
<attribute name="localizedDescription" optional="YES" attributeType="String"/>
<attribute name="maxOSVersion" optional="YES" attributeType="String"/>
<attribute name="minOSVersion" optional="YES" attributeType="String"/>
<attribute name="sha256" optional="YES" attributeType="String"/>
<attribute name="size" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sourceID" optional="YES" attributeType="String"/>
<attribute name="version" attributeType="String"/>

View File

@@ -18,6 +18,7 @@ public class AppVersion: NSManagedObject, Decodable, Fetchable
@NSManaged public var downloadURL: URL
@NSManaged public var size: Int64
@NSManaged public var sha256: String?
@nonobjc public var minOSVersion: OperatingSystemVersion? {
guard let osVersionString = self._minOSVersion else { return nil }
@@ -54,6 +55,7 @@ public class AppVersion: NSManagedObject, Decodable, Fetchable
case localizedDescription
case downloadURL
case size
case sha256
case minOSVersion
case maxOSVersion
}
@@ -74,7 +76,9 @@ public class AppVersion: NSManagedObject, Decodable, Fetchable
self.downloadURL = try container.decode(URL.self, forKey: .downloadURL)
self.size = try container.decode(Int64.self, forKey: .size)
self.sha256 = try container.decodeIfPresent(String.self, forKey: .sha256)?.lowercased()
self._minOSVersion = try container.decodeIfPresent(String.self, forKey: .minOSVersion)
self._maxOSVersion = try container.decodeIfPresent(String.self, forKey: .maxOSVersion)
}