From 7dfbba9b007e545f4437ad95a8d15ced7a82d35e Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Thu, 11 May 2023 17:02:20 -0500 Subject: [PATCH] =?UTF-8?q?Verifies=20downloaded=20app=E2=80=99s=20SHA-256?= =?UTF-8?q?=20checksum=20(if=20specified)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Operations/DownloadAppOperation.swift | 40 ++++++++++++++----- AltStore/Operations/OperationContexts.swift | 5 +++ AltStore/Operations/VerificationError.swift | 13 ++++++ AltStore/Operations/VerifyAppOperation.swift | 40 +++++++++++++++++-- .../AltStore 12.xcdatamodel/contents | 3 +- AltStoreCore/Model/AppVersion.swift | 6 ++- 6 files changed, 93 insertions(+), 14 deletions(-) diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index 0e448e9c..f85eb755 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -16,15 +16,16 @@ import AltSign final class DownloadAppOperation: ResultOperation { 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 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 } diff --git a/AltStore/Operations/OperationContexts.swift b/AltStore/Operations/OperationContexts.swift index 48e1741c..532bb350 100644 --- a/AltStore/Operations/OperationContexts.swift +++ b/AltStore/Operations/OperationContexts.swift @@ -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? } diff --git a/AltStore/Operations/VerificationError.swift b/AltStore/Operations/VerificationError.swift index db889c76..2e2ddd44 100644 --- a/AltStore/Operations/VerificationError.swift +++ b/AltStore/Operations/VerificationError.swift @@ -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) } } } diff --git a/AltStore/Operations/VerifyAppOperation.swift b/AltStore/Operations/VerifyAppOperation.swift index 87f80303..2b2c75a9 100644 --- a/AltStore/Operations/VerifyAppOperation.swift +++ b/AltStore/Operations/VerifyAppOperation.swift @@ -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 { - 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 throw VerificationError.iOSVersionNotSupported(app: app, requiredOSVersion: app.minimumiOSVersion) } - self.finish(.success(())) + guard let appVersion = self.context.appVersion else { + return self.finish(.success(())) + } + + Task { + 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 } } } + +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) } + } +} diff --git a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 12.xcdatamodel/contents b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 12.xcdatamodel/contents index 30810acb..afcec23c 100644 --- a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 12.xcdatamodel/contents +++ b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 12.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -38,6 +38,7 @@ + diff --git a/AltStoreCore/Model/AppVersion.swift b/AltStoreCore/Model/AppVersion.swift index 119d14bc..d8b1c9d7 100644 --- a/AltStoreCore/Model/AppVersion.swift +++ b/AltStoreCore/Model/AppVersion.swift @@ -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) }