MuffinStoreJailed-Public-copy/MuffinStoreJailed/IPATool.swift
2025-01-01 15:01:30 +01:00

513 lines
20 KiB
Swift

//
// IPATool.swift
// MuffinStoreJailed
//
// Created by Mineek on 19/10/2024.
//
// Heavily inspired by ipatool-py.
// https://github.com/NyaMisty/ipatool-py
import Foundation
import CommonCrypto
import Zip
extension Data {
var hexString: String {
return map { String(format: "%02x", $0) }.joined()
}
}
class SHA1 {
static func hash(_ data: Data) -> Data {
var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
data.withUnsafeBytes {
_ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest)
}
return Data(digest)
}
}
extension String {
subscript (i: Int) -> String {
return String(self[index(startIndex, offsetBy: i)])
}
subscript (r: Range<Int>) -> String {
let start = index(startIndex, offsetBy: r.lowerBound)
let end = index(startIndex, offsetBy: r.upperBound)
return String(self[start..<end])
}
}
class StoreClient {
var session: URLSession
var appleId: String
var password: String
var guid: String?
var accountName: String?
var authHeaders: [String: String]?
var authCookies: [HTTPCookie]?
init(appleId: String, password: String) {
session = URLSession.shared
self.appleId = appleId
self.password = password
self.guid = nil
self.accountName = nil
self.authHeaders = nil
self.authCookies = nil
}
func generateGuid(appleId: String) -> String {
print("Generating GUID")
let DEFAULT_GUID = "000C2941396B"
let GUID_DEFAULT_PREFIX = 2
let GUID_SEED = "CAFEBABE"
let GUID_POS = 10
let h = SHA1.hash((GUID_SEED + appleId + GUID_SEED).data(using: .utf8)!).hexString
let defaultPart = DEFAULT_GUID.prefix(GUID_DEFAULT_PREFIX)
let hashPart = h[GUID_POS..<GUID_POS + (DEFAULT_GUID.count - GUID_DEFAULT_PREFIX)]
let guid = (defaultPart + hashPart).uppercased()
print("Came up with GUID: \(guid)")
return guid
}
func saveAuthInfo() -> Void {
var authCookiesEnc1 = NSKeyedArchiver.archivedData(withRootObject: authCookies!)
var authCookiesEnc = authCookiesEnc1.base64EncodedString()
var out: [String: Any] = [
"appleId": appleId,
"password": password,
"guid": guid,
"accountName": accountName,
"authHeaders": authHeaders,
"authCookies": authCookiesEnc
]
var data = try! JSONSerialization.data(withJSONObject: out, options: [])
var base64 = data.base64EncodedString()
EncryptedKeychainWrapper.saveAuthInfo(base64: base64)
}
func tryLoadAuthInfo() -> Bool {
if let base64 = EncryptedKeychainWrapper.loadAuthInfo() {
var data = Data(base64Encoded: base64)!
var out = try! JSONSerialization.jsonObject(with: data, options: []) as! [String: Any]
appleId = out["appleId"] as! String
password = out["password"] as! String
guid = out["guid"] as? String
accountName = out["accountName"] as? String
authHeaders = out["authHeaders"] as? [String: String]
var authCookiesEnc = out["authCookies"] as! String
var authCookiesEnc1 = Data(base64Encoded: authCookiesEnc)!
authCookies = NSKeyedUnarchiver.unarchiveObject(with: authCookiesEnc1) as? [HTTPCookie]
print("Loaded auth info")
return true
}
print("No auth info found, need to authenticate")
return false
}
func authenticate(requestCode: Bool = false) -> Bool {
if self.guid == nil {
self.guid = generateGuid(appleId: appleId)
}
var req = [
"appleId": appleId,
"password": password,
"guid": guid!,
"rmp": "0",
"why": "signIn"
]
var url = URL(string: "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.allHTTPHeaderFields = [
"Accept": "*/*",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) AppleWebKit/0620.1.16.11.6"
]
var ret = false
for attempt in 1...4 {
req["attempt"] = String(attempt)
request.httpBody = try! JSONSerialization.data(withJSONObject: req, options: [])
let datatask = session.dataTask(with: request) { (data, response, error) in
if let error = error {
print("error 1 \(error.localizedDescription)")
return
}
if let response = response {
// print("Response: \(response)")
if let response = response as? HTTPURLResponse {
print("New URL: \(response.url!)")
request.url = response.url
}
}
if let data = data {
do {
let resp = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as! [String: Any]
if resp["m-allowed"] as! Bool {
print("Authentication successful")
var download_queue_info = resp["download-queue-info"] as! [String: Any]
var dsid = download_queue_info["dsid"] as! Int
var httpResp = response as! HTTPURLResponse
var storeFront = httpResp.value(forHTTPHeaderField: "x-set-apple-store-front")
print("Store front: \(storeFront!)")
self.authHeaders = [
"X-Dsid": String(dsid),
"iCloud-Dsid": String(dsid),
"X-Apple-Store-Front": storeFront!,
"X-Token": resp["passwordToken"] as! String
]
self.authCookies = self.session.configuration.httpCookieStorage?.cookies
var accountInfo = resp["accountInfo"] as! [String: Any]
var address = accountInfo["address"] as! [String: String]
self.accountName = address["firstName"]! + " " + address["lastName"]!
self.saveAuthInfo()
ret = true
} else {
print("Authentication failed: \(resp["customerMessage"] as! String)")
}
} catch {
print("Error: \(error)")
}
}
}
datatask.resume()
while datatask.state != .completed {
sleep(1)
}
if ret {
break
}
if requestCode {
ret = false
break
}
}
return ret
}
func volumeStoreDownloadProduct(appId: String, appVerId: String = "") -> [String: Any] {
var req = [
"creditDisplay": "",
"guid": self.guid!,
"salableAdamId": appId,
]
if appVerId != "" {
req["externalVersionId"] = appVerId
}
var url = URL(string: "https://p25-buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct?guid=\(self.guid!)")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.allHTTPHeaderFields = [
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) AppleWebKit/0620.1.16.11.6"
]
request.httpBody = try! JSONSerialization.data(withJSONObject: req, options: [])
print("Setting headers")
for (key, value) in self.authHeaders! {
print("Setting header \(key): \(value)")
request.addValue(value, forHTTPHeaderField: key)
}
print("Setting cookies")
self.session.configuration.httpCookieStorage?.setCookies(self.authCookies!, for: url, mainDocumentURL: nil)
var resp = [String: Any]()
let datatask = session.dataTask(with: request) { (data, response, error) in
if let error = error {
print("error 2 \(error.localizedDescription)")
return
}
if let data = data {
do {
print("Got response")
let resp1 = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as! [String: Any]
if resp1["cancel-purchase-batch"] != nil {
print("Failed to download product: \(resp1["customerMessage"] as! String)")
}
resp = resp1
} catch {
print("Error: \(error)")
}
}
}
datatask.resume()
while datatask.state != .completed {
sleep(1)
}
print("Got download response")
return resp
}
func download(appId: String, appVer: String = "", isRedownload: Bool = false) -> [String: Any] {
return self.volumeStoreDownloadProduct(appId: appId, appVerId: appVer)
}
func downloadToPath(url: String, path: String) -> Void {
var req = URLRequest(url: URL(string: url)!)
req.httpMethod = "GET"
let datatask = session.dataTask(with: req) { (data, response, error) in
if let error = error {
print("error 3 \(error.localizedDescription)")
return
}
if let data = data {
do {
try data.write(to: URL(fileURLWithPath: path))
} catch {
print("Error: \(error)")
}
}
}
datatask.resume()
while datatask.state != .completed {
sleep(1)
}
print("Downloaded to \(path)")
}
}
class IPATool {
var session: URLSession
var appleId: String
var password: String
var storeClient: StoreClient
init(appleId: String, password: String) {
print("init!")
session = URLSession.shared
self.appleId = appleId
self.password = password
storeClient = StoreClient(appleId: appleId, password: password)
}
func authenticate(requestCode: Bool = false) -> Bool {
print("Authenticating to iTunes Store...")
if !storeClient.tryLoadAuthInfo() {
return storeClient.authenticate(requestCode: requestCode)
} else {
return true
}
}
func getVersionIDList(appId: String) -> [String] {
print("Retrieving download info for appId \(appId)")
var downResp = storeClient.download(appId: appId, isRedownload: true)
var songList = downResp["songList"] as! [[String: Any]]
if songList.count == 0 {
print("Failed to get app download info!")
return []
}
var downInfo = songList[0]
var metadata = downInfo["metadata"] as! [String: Any]
var appVerIds = metadata["softwareVersionExternalIdentifiers"] as! [Int]
print("Got available version ids \(appVerIds)")
return appVerIds.map { String($0) }
}
func downloadIPAForVersion(appId: String, appVerId: String) -> String {
print("Downloading IPA for app \(appId) version \(appVerId)")
var downResp = storeClient.download(appId: appId, appVer: appVerId)
var songList = downResp["songList"] as! [[String: Any]]
if songList.count == 0 {
print("Failed to get app download info!")
return ""
}
var downInfo = songList[0]
var url = downInfo["URL"] as! String
print("Got download URL: \(url)")
var fm = FileManager.default
var tempDir = fm.temporaryDirectory
var path = tempDir.appendingPathComponent("app.ipa").path
if fm.fileExists(atPath: path) {
print("Removing existing file at \(path)")
try! fm.removeItem(atPath: path)
}
storeClient.downloadToPath(url: url, path: path)
Zip.addCustomFileExtension("ipa")
sleep(3)
let path3 = URL(string: path)!
let fileExtension = path3.pathExtension
let fileName = path3.lastPathComponent
let directoryName = fileName.replacingOccurrences(of: ".\(fileExtension)", with: "")
let documentsUrl = fm.urls(for: .documentDirectory, in: .userDomainMask)[0]
let destinationUrl = documentsUrl.appendingPathComponent(directoryName, isDirectory: true)
if fm.fileExists(atPath: destinationUrl.path) {
print("Removing existing folder at \(destinationUrl.path)")
try! fm.removeItem(at: destinationUrl)
}
let unzipDirectory = try! Zip.quickUnzipFile(URL(string: path)!)
var metadata = downInfo["metadata"] as! [String: Any]
var metadataPath = unzipDirectory.appendingPathComponent("iTunesMetadata.plist").path
metadata["apple-id"] = appleId
metadata["userName"] = appleId
try! (metadata as NSDictionary).write(toFile: metadataPath, atomically: true)
print("Wrote iTunesMetadata.plist")
var appContentDir = ""
let payloadDir = unzipDirectory.appendingPathComponent("Payload")
for entry in try! fm.contentsOfDirectory(atPath: payloadDir.path) {
if entry.hasSuffix(".app") {
print("Found app content dir: \(entry)")
appContentDir = "Payload/" + entry
break
}
}
print("Found app content dir: \(appContentDir)")
var scManifestData = try! Data(contentsOf: unzipDirectory.appendingPathComponent(appContentDir).appendingPathComponent("SC_Info").appendingPathComponent("Manifest.plist"))
var scManifest = try! PropertyListSerialization.propertyList(from: scManifestData, options: [], format: nil) as! [String: Any]
var sinfsDict = downInfo["sinfs"] as! [[String: Any]]
if let sinfPaths = scManifest["SinfPaths"] as? [String] {
for (i, sinfPath) in sinfPaths.enumerated() {
let sinfData = sinfsDict[i]["sinf"] as! Data
try! sinfData.write(to: unzipDirectory.appendingPathComponent(appContentDir).appendingPathComponent(sinfPath))
print("Wrote sinf to \(sinfPath)")
}
} else {
print("Manifest.plist does not exist! Assuming it is an old app without one...")
var infoListData = try! Data(contentsOf: unzipDirectory.appendingPathComponent(appContentDir).appendingPathComponent("Info.plist"))
var infoList = try! PropertyListSerialization.propertyList(from: infoListData, options: [], format: nil) as! [String: Any]
var sinfPath = appContentDir + "/SC_Info/" + (infoList["CFBundleExecutable"] as! String) + ".sinf"
let sinfData = sinfsDict[0]["sinf"] as! Data
try! sinfData.write(to: unzipDirectory.appendingPathComponent(sinfPath))
print("Wrote sinf to \(sinfPath)")
}
print("Downloaded IPA to \(unzipDirectory.path)")
return unzipDirectory.path
}
}
class EncryptedKeychainWrapper {
static func generateAndStoreKey() -> Void {
self.deleteKey()
print("Generating key")
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "dev.mineek.muffinstorejailed.key",
kSecAttrAccessControl as String: SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage, .biometryAny],
nil
)!
]
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(query as CFDictionary, &error) else {
print("Failed to generate key!!")
return
}
print("Generated key!")
print("Getting public key")
let pubKey = SecKeyCopyPublicKey(privateKey)!
print("Got public key")
let pubKeyData = SecKeyCopyExternalRepresentation(pubKey, &error)! as Data
let pubKeyBase64 = pubKeyData.base64EncodedString()
print("Public key: \(pubKeyBase64)")
}
static func deleteKey() -> Void {
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: "dev.mineek.muffinstorejailed.key"
]
SecItemDelete(query as CFDictionary)
}
static func saveAuthInfo(base64: String) -> Void {
let fm = FileManager.default
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: "dev.mineek.muffinstorejailed.key",
kSecReturnRef as String: true
]
var keyRef: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &keyRef)
if status != errSecSuccess {
print("Failed to get key!")
return
}
print("Got key!")
let key = keyRef as! SecKey
print("Getting public key")
let pubKey = SecKeyCopyPublicKey(key)!
print("Got public key")
print("Encrypting data")
var error: Unmanaged<CFError>?
guard let encryptedData = SecKeyCreateEncryptedData(pubKey, .eciesEncryptionCofactorVariableIVX963SHA256AESGCM, base64.data(using: .utf8)! as CFData, &error) else {
print("Failed to encrypt data!")
return
}
print("Encrypted data")
let path = fm.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("authinfo").path
fm.createFile(atPath: path, contents: encryptedData as Data, attributes: nil)
print("Saved encrypted auth info")
}
static func loadAuthInfo() -> String? {
let fm = FileManager.default
let path = fm.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("authinfo").path
if !fm.fileExists(atPath: path) {
return nil
}
let data = fm.contents(atPath: path)!
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: "dev.mineek.muffinstorejailed.key",
kSecReturnRef as String: true
]
var keyRef: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &keyRef)
if status != errSecSuccess {
print("Failed to get key!")
return nil
}
print("Got key!")
let key = keyRef as! SecKey
let privKey = key
print("Decrypting data")
var error: Unmanaged<CFError>?
guard let decryptedData = SecKeyCreateDecryptedData(privKey, .eciesEncryptionCofactorVariableIVX963SHA256AESGCM, data as CFData, &error) else {
print("Failed to decrypt data!")
return nil
}
print("Decrypted data")
return String(data: decryptedData as Data, encoding: .utf8)
}
static func deleteAuthInfo() -> Void {
let fm = FileManager.default
let path = fm.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("authinfo").path
try! fm.removeItem(atPath: path)
}
static func hasAuthInfo() -> Bool {
return loadAuthInfo() != nil
}
static func getAuthInfo() -> [String: Any]? {
if let base64 = loadAuthInfo() {
var data = Data(base64Encoded: base64)!
var out = try! JSONSerialization.jsonObject(with: data, options: []) as! [String: Any]
return out
}
return nil
}
static func nuke() -> Void {
deleteAuthInfo()
deleteKey()
}
}