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

This commit is contained in:
Riley Testut
2023-05-11 17:02:20 -05:00
parent e1b7c9b64e
commit ad41024bcb
6 changed files with 80 additions and 6 deletions

View File

@@ -16,7 +16,7 @@ import AltSign
class DownloadAppOperation: ResultOperation<ALTApplication>
{
let app: AppProtocol
let context: AppOperationContext
let context: InstallAppOperationContext
private let appName: String
private let bundleIdentifier: String
@@ -25,7 +25,7 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
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
@@ -56,6 +56,8 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
self.localizedFailure = String(format: NSLocalizedString("%@ could not be downloaded.", comment: ""), self.appName)
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)
}
@@ -133,6 +135,14 @@ private extension DownloadAppOperation
{
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
{
@@ -201,6 +211,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

@@ -111,7 +111,9 @@ class InstallAppOperationContext: AppOperationContext
return temporaryDirectory
}()
var ipaURL: URL?
var resignedApp: ALTApplication?
var installationConnection: ServerConnection?
var installedApp: InstalledApp? {
didSet {
@@ -123,4 +125,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
@@ -15,10 +16,10 @@ import Roxas
@objc(VerifyAppOperation)
class VerifyAppOperation: ResultOperation<Void>
{
let context: AppOperationContext
let context: InstallAppOperationContext
var verificationHandler: ((VerificationError) -> Bool)?
init(context: AppOperationContext)
init(context: InstallAppOperationContext)
{
self.context = context
@@ -49,7 +50,23 @@ 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
{
@@ -57,3 +74,20 @@ 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
}
@@ -75,6 +77,8 @@ 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)
}