diff --git a/AltSign/Apple API/ALTAppleAPI.h b/AltSign/Apple API/ALTAppleAPI.h index 5ad1e178..8413918d 100644 --- a/AltSign/Apple API/ALTAppleAPI.h +++ b/AltSign/Apple API/ALTAppleAPI.h @@ -28,6 +28,18 @@ NS_ASSUME_NONNULL_BEGIN @property (class, nonatomic, readonly) ALTAppleAPI *sharedAPI; +// Optional. Apple rejects one-time passwords that were already used, so when this is set +// authentication asks for fresh anisette data before each step that follows the first sign-in. +@property (nonatomic, copy, nullable) void (^anisetteDataRefreshHandler)(void (^completionHandler)(ALTAnisetteData *_Nullable anisetteData, NSError *_Nullable error)); + +// Optional. The User-Agent sent with GrandSlam requests. It must match the client that generated the +// anisette data (anisette v3 servers report it in /v3/client_info). Defaults to akd's user agent. +@property (nonatomic, copy, nullable) NSString *authenticationUserAgent; + +// Optional. Called once when GrandSlam answers hsc 434 (anisette data out of sync) with the X-Apple-I-MD-DATA +// value it returned. The handler should resynchronize the anisette provider and return fresh anisette data. +@property (nonatomic, copy, nullable) void (^anisetteDataResyncHandler)(NSString *sim, void (^completionHandler)(ALTAnisetteData *_Nullable anisetteData, NSError *_Nullable error)); + /* Teams */ - (void)fetchTeamsForAccount:(ALTAccount *)account session:(ALTAppleAPISession *)session completionHandler:(void (^)(NSArray *_Nullable teams, NSError *_Nullable error))completionHandler; diff --git a/AltSign/Sources/ALTAppleAPI+Authentication.swift b/AltSign/Sources/ALTAppleAPI+Authentication.swift index 6cdc7c75..20085826 100644 --- a/AltSign/Sources/ALTAppleAPI+Authentication.swift +++ b/AltSign/Sources/ALTAppleAPI+Authentication.swift @@ -11,6 +11,19 @@ import Foundation @_exported import CAltSign import CAltSign.Private +// Don't follow redirects for GrandSlam requests so an unexpected redirect surfaces as an HTTP error. +private final class AuthenticationSessionDelegate: NSObject, URLSessionTaskDelegate +{ + static let shared = AuthenticationSessionDelegate() + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) + { + completionHandler(nil) + } +} + public extension ALTAppleAPIError { static func unknown(statusCode: Int? = nil, failure: String? = nil, userInfo: [String: Any] = [:], sourceFile: String = #fileID, sourceLine: UInt = #line) -> ALTAppleAPIError @@ -53,160 +66,181 @@ public extension ALTAppleAPI do { - let clientDictionary = [ - "bootstrap": true, - "icscrec": true, - "pbe": false, - "prkgen": true, - "svct": "iCloud", - "loc": anisetteData.locale.identifier, - "X-Apple-Locale": anisetteData.locale.identifier, - "X-Apple-I-MD": anisetteData.oneTimePassword, - "X-Apple-I-MD-M": anisetteData.machineID, - "X-Mme-Device-Id": anisetteData.deviceUniqueIdentifier, - "X-Apple-I-MD-LU": anisetteData.localUserID, - "X-Apple-I-MD-RINFO": anisetteData.routingInfo, - "X-Apple-I-SRL-NO": anisetteData.deviceSerialNumber, - "X-Apple-I-Client-Time": self.dateFormatter.string(from: anisetteData.date), - "X-Apple-I-TimeZone": TimeZone.current.abbreviation() ?? "PST", - ] as [String: Any] - let context = GSAContext(username: sanitizedAppleID, password: password) guard let publicKey = context.start() else { throw ALTAppleAPIError(.authenticationHandshakeFailed) } - let parameters = [ - "A2k": publicKey, - "cpd": clientDictionary, - "ps": ["s2k", "s2k_fo"], - "o": "init", - "u": sanitizedAppleID - ] as [String: Any] + func beginAuthentication(using anisetteData: ALTAnisetteData, hasResynchronized: Bool = false) + { + let clientDictionary = self.makeClientDictionary(anisetteData: anisetteData) + let parameters = [ + "A2k": publicKey, + "cpd": clientDictionary, + "ps": ["s2k", "s2k_fo"], + "o": "init", + "u": sanitizedAppleID + ] as [String: Any] - self.sendAuthenticationRequest(parameters: parameters, anisetteData: anisetteData) { (result) in - do - { - let responseDictionary = try result.get() + self.sendAuthenticationRequest(parameters: parameters, anisetteData: anisetteData) { (result) in + do + { + let responseDictionary = try result.get() + if let status = responseDictionary["Status"] as? [String: Any], status["hsc"] as? Int == 434 + { + guard !hasResynchronized, let resync = self.anisetteDataResyncHandler else { throw ALTAppleAPIError(.invalidAnisetteData) } + let sim = (status["X-Apple-I-MD-DATA"] as? String) ?? + (status["X-Apple-I-MD-DATA"] as? Data)?.base64EncodedString() + guard let sim, !sim.isEmpty else { throw ALTAppleAPIError(.invalidAnisetteData) } + resync(sim) { data, error in + guard let data else { return completionHandler(nil, nil, error ?? ALTAppleAPIError(.invalidAnisetteData)) } + // Same SRP context and A2k; all later steps use the resynchronized cpd. + beginAuthentication(using: data, hasResynchronized: true) + } + return + } - guard let c = responseDictionary["c"] as? String, - let salt = responseDictionary["s"] as? Data, - let iterations = responseDictionary["i"] as? Int, - let serverPublicKey = responseDictionary["B"] as? Data - else { throw URLError(.badServerResponse) } + guard let c = responseDictionary["c"] as? String, + let salt = responseDictionary["s"] as? Data, + let iterations = responseDictionary["i"] as? Int, + let serverPublicKey = responseDictionary["B"] as? Data + else { throw URLError(.badServerResponse) } - context.salt = salt - context.serverPublicKey = serverPublicKey + context.salt = salt + context.serverPublicKey = serverPublicKey - let sp = responseDictionary["sp"] as? String - let isHexadecimal = (sp == "s2k_fo") + let sp = responseDictionary["sp"] as? String + let isHexadecimal = (sp == "s2k_fo") - guard let verificationMessage = context.makeVerificationMessage(iterations: iterations, isHexadecimal: isHexadecimal) else { - throw ALTAppleAPIError(.authenticationHandshakeFailed) - } + guard let verificationMessage = context.makeVerificationMessage(iterations: iterations, isHexadecimal: isHexadecimal) else { + throw ALTAppleAPIError(.authenticationHandshakeFailed) + } - let parameters = [ - "c": c, - "cpd": clientDictionary, - "M1": verificationMessage, - "o": "complete", - "u": sanitizedAppleID - ] as [String: Any] + let parameters = [ + "c": c, + "cpd": clientDictionary, + "M1": verificationMessage, + "o": "complete", + "u": sanitizedAppleID + ] as [String: Any] - self.sendAuthenticationRequest(parameters: parameters, anisetteData: anisetteData) { (result) in - do - { - let responseDictionary = try result.get() + self.sendAuthenticationRequest(parameters: parameters, anisetteData: anisetteData) { (result) in + do + { + let responseDictionary = try result.get() - guard let serverVerificationMessage = responseDictionary["M2"] as? Data, - let serverDictionary = responseDictionary["spd"] as? Data, - let statusDictionary = responseDictionary["Status"] as? [String: Any] - else { throw URLError(.badServerResponse) } + guard let serverVerificationMessage = responseDictionary["M2"] as? Data, + let serverDictionary = responseDictionary["spd"] as? Data, + let statusDictionary = responseDictionary["Status"] as? [String: Any] + else { throw URLError(.badServerResponse) } - guard context.verifyServerVerificationMessage(serverVerificationMessage) else { throw ALTAppleAPIError(.authenticationHandshakeFailed) } - guard let decryptedData = serverDictionary.decryptedCBC(context: context) else { throw ALTAppleAPIError(.authenticationHandshakeFailed) } + guard context.verifyServerVerificationMessage(serverVerificationMessage) else { throw ALTAppleAPIError(.authenticationHandshakeFailed) } + guard let decryptedData = serverDictionary.decryptedCBC(context: context) else { throw ALTAppleAPIError(.authenticationHandshakeFailed) } - guard let decryptedDictionary = try PropertyListSerialization.propertyList(from: decryptedData, format: nil) as? [String: Any], - let dsid = decryptedDictionary["adsid"] as? String, - let idmsToken = decryptedDictionary["GsIdmsToken"] as? String - else { throw URLError(.badServerResponse) } + guard let decryptedDictionary = try PropertyListSerialization.propertyList(from: decryptedData, format: nil) as? [String: Any], + let dsid = decryptedDictionary["adsid"] as? String, + let idmsToken = decryptedDictionary["GsIdmsToken"] as? String + else { throw URLError(.badServerResponse) } - context.dsid = dsid + context.dsid = dsid - let authType = statusDictionary["au"] as? String - switch authType - { - case "trustedDeviceSecondaryAuth": - guard let verificationHandler = verificationHandler else { throw ALTAppleAPIError(.requiresTwoFactorAuthentication) } - - self.requestTrustedDeviceTwoFactorCode(dsid: dsid, idmsToken: idmsToken, anisetteData: anisetteData, verificationHandler: verificationHandler) { (result) in - switch result - { - case .failure(let error): completionHandler(nil, nil, error) - case .success: - self.authenticate(appleID: unsanitizedAppleID, password: password, anisetteData: anisetteData, verificationHandler: verificationHandler, completionHandler: completionHandler) + func signInAgain(verificationHandler: @escaping (@escaping (String?) -> Void) -> Void) + { + self.refreshAnisetteData(anisetteData) { (result) in + switch result + { + case .failure(let error): completionHandler(nil, nil, error) + case .success(let anisetteData): + self.authenticate(appleID: unsanitizedAppleID, password: password, anisetteData: anisetteData, verificationHandler: verificationHandler, completionHandler: completionHandler) + } } } + + // Since October 2026 Apple often sends the code to trusted devices on its own. + // It then answers with hsc 409 and no "au" value instead of "trustedDeviceSecondaryAuth". + let authType = statusDictionary["au"] as? String + let isCodeAlreadySent = (authType ?? "").isEmpty && statusDictionary["hsc"] as? Int == 409 + + switch isCodeAlreadySent ? "trustedDeviceSecondaryAuth" : authType + { + case "trustedDeviceSecondaryAuth": + guard let verificationHandler = verificationHandler else { throw ALTAppleAPIError(.requiresTwoFactorAuthentication) } - case "secondaryAuth": - guard let verificationHandler = verificationHandler else { throw ALTAppleAPIError(.requiresTwoFactorAuthentication) } - - self.requestSMSTwoFactorCode(dsid: dsid, idmsToken: idmsToken, anisetteData: anisetteData, verificationHandler: verificationHandler) { (result) in - switch result - { - case .failure(let error): completionHandler(nil, nil, error) - case .success: - self.authenticate(appleID: unsanitizedAppleID, password: password, anisetteData: anisetteData, verificationHandler: verificationHandler, completionHandler: completionHandler) + self.requestTrustedDeviceTwoFactorCode(dsid: dsid, idmsToken: idmsToken, anisetteData: anisetteData, isCodeAlreadySent: isCodeAlreadySent, verificationHandler: verificationHandler) { (result) in + switch result + { + case .failure(let error): completionHandler(nil, nil, error) + case .success: signInAgain(verificationHandler: verificationHandler) + } } - } - default: - guard let sessionKey = decryptedDictionary["sk"] as? Data, - let c = decryptedDictionary["c"] as? Data - else { throw URLError(.badServerResponse) } + case "secondaryAuth": + guard let verificationHandler = verificationHandler else { throw ALTAppleAPIError(.requiresTwoFactorAuthentication) } - context.sessionKey = sessionKey + self.requestSMSTwoFactorCode(dsid: dsid, idmsToken: idmsToken, anisetteData: anisetteData, verificationHandler: verificationHandler) { (result) in + switch result + { + case .failure(let error): completionHandler(nil, nil, error) + case .success: signInAgain(verificationHandler: verificationHandler) + } + } - let app = "com.apple.gs.xcode.auth" - guard let checksum = context.makeChecksum(appName: app) else { throw ALTAppleAPIError(.authenticationHandshakeFailed) } + default: + guard let sessionKey = decryptedDictionary["sk"] as? Data, + let c = decryptedDictionary["c"] as? Data + else { throw URLError(.badServerResponse) } - let parameters = [ - "app": [app], - "c": c, - "checksum": checksum, - "cpd": clientDictionary, - "o": "apptokens", - "t": idmsToken, - "u": dsid - ] as [String: Any] + context.sessionKey = sessionKey - self.fetchAuthToken(app: app, parameters: parameters, context: context, anisetteData: anisetteData) { (result) in - switch result - { - case .failure(let error): completionHandler(nil, nil, error) - case .success(let token): - - let session = ALTAppleAPISession(dsid: dsid, authToken: token, anisetteData: anisetteData) - self.fetchAccount(session: session) { (result) in + let app = "com.apple.gs.xcode.auth" + guard let checksum = context.makeChecksum(appName: app) else { throw ALTAppleAPIError(.authenticationHandshakeFailed) } + + self.refreshAnisetteData(anisetteData) { (result) in + let anisetteData: ALTAnisetteData + do { anisetteData = try result.get() } + catch { return completionHandler(nil, nil, error) } + + let parameters = [ + "app": [app], + "c": c, + "checksum": checksum, + "cpd": self.makeClientDictionary(anisetteData: anisetteData), + "o": "apptokens", + "t": idmsToken, + "u": dsid + ] as [String: Any] + + self.fetchAuthToken(app: app, parameters: parameters, context: context, anisetteData: anisetteData) { (result) in switch result { case .failure(let error): completionHandler(nil, nil, error) - case .success(let account): completionHandler(account, session, nil) + case .success(let token): + + let session = ALTAppleAPISession(dsid: dsid, authToken: token, anisetteData: anisetteData) + self.fetchAccount(session: session) { (result) in + switch result + { + case .failure(let error): completionHandler(nil, nil, error) + case .success(let account): completionHandler(account, session, nil) + } + } } } } } } - } - catch - { - completionHandler(nil, nil, error) + catch + { + completionHandler(nil, nil, error) + } } } - } - catch - { - completionHandler(nil, nil, error) + catch + { + completionHandler(nil, nil, error) + } } } + + beginAuthentication(using: anisetteData) } catch { @@ -217,6 +251,39 @@ public extension ALTAppleAPI private extension ALTAppleAPI { + func makeClientDictionary(anisetteData: ALTAnisetteData) -> [String: Any] + { + let clientDictionary = [ + "bootstrap": "true", + "icscrec": "true", + "pbe": "false", + "prkgen": "true", + "svct": "iCloud", + "loc": "en_US", + "X-Apple-I-MD": anisetteData.oneTimePassword, + "X-Apple-I-MD-M": anisetteData.machineID, + "X-Mme-Device-Id": anisetteData.deviceUniqueIdentifier, + ] as [String: Any] + + return clientDictionary + } + + func refreshAnisetteData(_ anisetteData: ALTAnisetteData, completionHandler: @escaping (Result) -> Void) + { + guard let anisetteDataRefreshHandler = self.anisetteDataRefreshHandler else { return completionHandler(.success(anisetteData)) } + + anisetteDataRefreshHandler { (anisetteData, error) in + if let anisetteData = anisetteData + { + completionHandler(.success(anisetteData)) + } + else + { + completionHandler(.failure(error ?? ALTAppleAPIError.unknown())) + } + } + } + func fetchAuthToken(app: String, parameters: [String: Any], context: GSAContext, anisetteData: ALTAnisetteData, completionHandler: @escaping (Result) -> Void) { self.sendAuthenticationRequest(parameters: parameters, anisetteData: anisetteData) { (result) in @@ -248,12 +315,68 @@ private extension ALTAppleAPI func requestTrustedDeviceTwoFactorCode(dsid: String, idmsToken: String, anisetteData: ALTAnisetteData, + isCodeAlreadySent: Bool, verificationHandler: @escaping (@escaping (String?) -> Void) -> Void, completionHandler: @escaping (Result) -> Void) { let requestURL = URL(string: "https://gsa.apple.com/auth/verify/trusteddevice")! let verifyURL = URL(string: "https://gsa.apple.com/grandslam/GsService2/validate")! + func responseHandler(verificationCode: String?) + { + guard let verificationCode = verificationCode else { return completionHandler(.failure(ALTAppleAPIError(.requiresTwoFactorAuthentication))) } + + // Typing the code can take longer than the one-time password stays valid, so fetch fresh anisette data first. + self.refreshAnisetteData(anisetteData) { (result) in + do + { + let anisetteData = try result.get() + + var request = self.makeTwoFactorCodeRequest(url: verifyURL, dsid: dsid, idmsToken: idmsToken, anisetteData: anisetteData) + request.allHTTPHeaderFields?["security-code"] = verificationCode + + self.sendGSARequest(request) { (result) in + do + { + let (data, httpResponse) = try result.get() + + guard (200 ..< 300).contains(httpResponse.statusCode) else { throw ALTAppleAPIError.unknown(statusCode: httpResponse.statusCode) } + + guard let responseDictionary = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { + throw URLError(.badServerResponse) + } + + let errorCode = responseDictionary["ec"] as? Int ?? 0 + guard errorCode != 0 else { return completionHandler(.success(())) } + + switch errorCode + { + case -21669: throw ALTAppleAPIError(.incorrectVerificationCode) + default: + guard let errorDescription = responseDictionary["em"] as? String else { throw ALTAppleAPIError.unknown() } + + let localizedDescription = errorDescription + " (\(errorCode))" + throw NSError(domain: ALTUnderlyingAppleAPIErrorDomain, code: errorCode, userInfo: [NSLocalizedDescriptionKey: localizedDescription]) + } + } + catch + { + completionHandler(.failure(error)) + } + } + } + catch + { + completionHandler(.failure(error)) + } + } + } + + guard !isCodeAlreadySent else { + // Asking for another code here would make the one Apple already sent stop working. + return verificationHandler(responseHandler) + } + let request = self.makeTwoFactorCodeRequest(url: requestURL, dsid: dsid, idmsToken: idmsToken, anisetteData: anisetteData) self.sendGSARequest(request) { (result) in @@ -264,51 +387,6 @@ private extension ALTAppleAPI throw ALTAppleAPIError.unknown(statusCode: response.statusCode, failure: NSLocalizedString("Could not request a verification code from Apple.", comment: "")) } - func responseHandler(verificationCode: String?) - { - do - { - guard let verificationCode = verificationCode else { throw ALTAppleAPIError(.requiresTwoFactorAuthentication) } - - var request = self.makeTwoFactorCodeRequest(url: verifyURL, dsid: dsid, idmsToken: idmsToken, anisetteData: anisetteData) - request.allHTTPHeaderFields?["security-code"] = verificationCode - - self.sendGSARequest(request) { (result) in - do - { - let (data, httpResponse) = try result.get() - - guard (200 ..< 300).contains(httpResponse.statusCode) else { throw ALTAppleAPIError.unknown(statusCode: httpResponse.statusCode) } - - guard let responseDictionary = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { - throw URLError(.badServerResponse) - } - - let errorCode = responseDictionary["ec"] as? Int ?? 0 - guard errorCode != 0 else { return completionHandler(.success(())) } - - switch errorCode - { - case -21669: throw ALTAppleAPIError(.incorrectVerificationCode) - default: - guard let errorDescription = responseDictionary["em"] as? String else { throw ALTAppleAPIError.unknown() } - - let localizedDescription = errorDescription + " (\(errorCode))" - throw NSError(domain: ALTUnderlyingAppleAPIErrorDomain, code: errorCode, userInfo: [NSLocalizedDescriptionKey: localizedDescription]) - } - } - catch - { - completionHandler(.failure(error)) - } - } - } - catch - { - completionHandler(.failure(error)) - } - } - verificationHandler(responseHandler) } catch @@ -436,8 +514,8 @@ private extension ALTAppleAPI private extension ALTAppleAPI { - // Apple's servers reject outdated client identities, so use the same modern AuthKit identity for every request. - static let userAgent = "AuthKit/1 (Macintosh; OS X 26.5.2) (com.apple.dt.Xcode/26.0)" + // GrandSlam rejects requests whose User-Agent and X-MMe-Client-Info don't match the client that generated the anisette data. + var userAgent: String { self.authenticationUserAgent ?? "akd/1.0 CFNetwork/808.1.4" } func sendGSARequest(_ request: URLRequest, completionHandler: @escaping (Result<(Data, HTTPURLResponse), Error>) -> Void) { @@ -446,7 +524,7 @@ private extension ALTAppleAPI let configuration = URLSessionConfiguration.ephemeral configuration.httpMaximumConnectionsPerHost = 1 - let session = URLSession(configuration: configuration) + let session = URLSession(configuration: configuration, delegate: AuthenticationSessionDelegate.shared, delegateQueue: nil) defer { session.finishTasksAndInvalidate() } let dataTask = session.dataTask(with: request) { (data, response, error) in @@ -485,8 +563,11 @@ private extension ALTAppleAPI let httpHeaders = [ "Content-Type": "text/x-xml-plist", "X-MMe-Client-Info": anisetteData.deviceDescription, - "Accept": "*/*", - "User-Agent": ALTAppleAPI.userAgent + "Accept": "text/x-xml-plist", + "X-Xcode-Version": "27.0 (27A5218g)", + "X-Apple-App-Info": "com.apple.gs.xcode.auth", + "Connection": "close", + "User-Agent": self.userAgent ] let bodyData = try PropertyListSerialization.data(fromPropertyList: parameters, format: .xml, options: 0) @@ -499,13 +580,18 @@ private extension ALTAppleAPI self.sendGSARequest(request) { (result) in do { - let (data, _) = try result.get() + let (data, httpResponse) = try result.get() + + // GSA answers rejected clients with an HTML error page (e.g. 503), which would otherwise surface as a plist parsing error. + guard (200 ..< 300).contains(httpResponse.statusCode) else { throw ALTAppleAPIError.unknown(statusCode: httpResponse.statusCode) } guard let responseDictionary = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], let dictionary = responseDictionary["Response"] as? [String: Any], let status = dictionary["Status"] as? [String: Any] else { throw URLError(.badServerResponse) } + // hsc 434 is a protocol resync, handled once by the original init. + if status["hsc"] as? Int == 434 { return completionHandler(.success(dictionary)) } let errorCode = status["ec"] as? Int ?? 0 guard errorCode != 0 else { return completionHandler(.success(dictionary)) } @@ -543,10 +629,12 @@ private extension ALTAppleAPI let encodedIdentityToken = identityTokenData.base64EncodedString() let httpHeaders = [ - "Accept": "application/x-buddyml", + "Accept": "text/x-xml-plist", + "X-Xcode-Version": "27.0 (27A5218g)", + "Connection": "close", "Accept-Language": "en-us", - "Content-Type": "application/x-plist", - "User-Agent": ALTAppleAPI.userAgent, + "Content-Type": "text/x-xml-plist", + "User-Agent": self.userAgent, "X-Apple-App-Info": "com.apple.gs.xcode.auth", "X-Apple-Identity-Token": encodedIdentityToken, "X-Apple-I-MD-M": anisetteData.machineID,