From 780f7cfb9b0dab1189fb8fcdaaa31f005cfab597 Mon Sep 17 00:00:00 2001 From: whydr4 <42878446+whydr4@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:16:21 +0200 Subject: [PATCH] Handle 2FA codes that Apple sends on its own Since October 13 Apple often pushes the verification code to trusted devices right after the password step. In that case the complete response has hsc 409 and no "au" value, so we used to skip 2FA, ask for app tokens right away and fail with -22411 (This action cannot be completed at this time). Now hsc 409 is treated like trustedDeviceSecondaryAuth, except that we don't request a new code because that would invalidate the one already sent. This also adds an optional anisetteDataRefreshHandler so that the code check, the second sign-in and the app token request can each use a fresh one-time password instead of reusing the one from the first request. --- AltSign/Apple API/ALTAppleAPI.h | 4 + .../Sources/ALTAppleAPI+Authentication.swift | 234 +++++++++++------- 2 files changed, 148 insertions(+), 90 deletions(-) diff --git a/AltSign/Apple API/ALTAppleAPI.h b/AltSign/Apple API/ALTAppleAPI.h index 5ad1e178..3afce0a8 100644 --- a/AltSign/Apple API/ALTAppleAPI.h +++ b/AltSign/Apple API/ALTAppleAPI.h @@ -28,6 +28,10 @@ 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)); + /* 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..0eeed94e 100644 --- a/AltSign/Sources/ALTAppleAPI+Authentication.swift +++ b/AltSign/Sources/ALTAppleAPI+Authentication.swift @@ -53,23 +53,7 @@ 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 clientDictionary = self.makeClientDictionary(anisetteData: anisetteData) let context = GSAContext(username: sanitizedAppleID, password: password) guard let publicKey = context.start() else { throw ALTAppleAPIError(.authenticationHandshakeFailed) } @@ -131,18 +115,33 @@ public extension ALTAppleAPI context.dsid = dsid + 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 - switch authType + let isCodeAlreadySent = (authType ?? "").isEmpty && statusDictionary["hsc"] as? Int == 409 + + switch isCodeAlreadySent ? "trustedDeviceSecondaryAuth" : authType { case "trustedDeviceSecondaryAuth": guard let verificationHandler = verificationHandler else { throw ALTAppleAPIError(.requiresTwoFactorAuthentication) } - self.requestTrustedDeviceTwoFactorCode(dsid: dsid, idmsToken: idmsToken, anisetteData: anisetteData, verificationHandler: verificationHandler) { (result) in + 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: - self.authenticate(appleID: unsanitizedAppleID, password: password, anisetteData: anisetteData, verificationHandler: verificationHandler, completionHandler: completionHandler) + case .success: signInAgain(verificationHandler: verificationHandler) } } @@ -153,8 +152,7 @@ public extension ALTAppleAPI switch result { case .failure(let error): completionHandler(nil, nil, error) - case .success: - self.authenticate(appleID: unsanitizedAppleID, password: password, anisetteData: anisetteData, verificationHandler: verificationHandler, completionHandler: completionHandler) + case .success: signInAgain(verificationHandler: verificationHandler) } } @@ -168,28 +166,34 @@ public extension ALTAppleAPI let app = "com.apple.gs.xcode.auth" guard let checksum = context.makeChecksum(appName: app) else { throw ALTAppleAPIError(.authenticationHandshakeFailed) } - let parameters = [ - "app": [app], - "c": c, - "checksum": checksum, - "cpd": clientDictionary, - "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 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) + 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 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) + } } } } @@ -217,6 +221,45 @@ 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": 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] + + 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 +291,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 +363,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